diff --git a/AGENTS.md b/AGENTS.md index d79fd87..990a9d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -640,7 +640,7 @@ narrowing functions). Don't make them private: `Release.release_env/4` (the assembled script env). See `decisions/2026-10-03-ios-release-compiles-plugin-swift-and-bootstrap.md` - `NativeBuild.project_nif_build_inputs/1`, `project_swift_sources/1` and - `build_plugin_static_archives/3`, plus `Release.project_release_env/3` (MOB-373: + `build_plugin_static_archives/4`, plus `Release.project_release_env/3` (MOB-373: the project NIFs, project Swift and cpp_archive plugin archives a device build links, as data the zig builds and `mix mob.release --ios` both consume; `project_nif_zig_args/1` renders its `-D` args from the first). See @@ -649,7 +649,20 @@ narrowing functions). Don't make them private: `write_build_input!/2` (MOB-313: the iOS `.app` goes in a temp dir removed on every exit path; the sources zig compiles stay at stable paths so its cache hits; see - `decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md`) + `decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md`). + `mix mob.release --ios` runs `release_device.sh` inside one too, passed as + `MOB_RELEASE_BUILD_DIR`; every scratch path in the script goes under it + (`Release.release_scratch_sh/0`, MOB-425). A new scratch file or dir in the + script goes under `$MOB_RELEASE_SCRATCH`, never a bare `mktemp` (macOS + `mktemp -d` ignores TMPDIR and nothing would remove it) +- `MobDev.Plugin.IosLinkCheck.check/5` and its pure kernel + (`parse_dynamic_lookups/1`, `defined_symbols/1`, `tbd_name/1`, + `sdk_exports/2`, `error_message/4`): every iOS `cpp_archive` plugin archive + is linked without `-dead_strip` and may leave undefined only what the BEAM, + mob core or the iOS SDK define (MOB-471). Every iOS link dead-strips, the + release one included, so this check is what still catches a dangling + reference in dead code (MOB-470). See + `decisions/2026-10-13-ios-release-dead-strips-and-plugin-link-check.md` - `MobDev.UrlSchemes.schemes/1`, `merge_manifest/2`, `declared_elsewhere/2`, `launch_mode_error/2`, `plist_commands/3`, `bundle_plist_commands!/3` and `audit/2` (documented: `mob.exs` `url_schemes` validation, the Android diff --git a/decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md b/decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md index f83f87d..6d8c677 100644 --- a/decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md +++ b/decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md @@ -73,3 +73,16 @@ a stable one. - `ios/zig-out/` and `~/.mob/runtime/ios-sim` are still fixed paths, so two concurrent builds of one project still race on those. That is separate from this change. + +## Update 2026-10-13: `mix mob.release --ios` follows this rule (MOB-425) + +The release script (`release_device.sh`) had its own scratch: a bare +`mktemp -d` for the objects, the linked binary and the `.app` (~100 MB, never +removed), plus the exqlite objects, actool's partial plist and the IPA +staging dir, removed only on success. Bare macOS `mktemp -d` ignores +`TMPDIR`, so it all went to `DARWIN_USER_TEMP_DIR`. `Release.build_ipa/1` now +runs the script inside `with_temp_build_dir("release", …)` and passes the dir +as `MOB_RELEASE_BUILD_DIR`; every scratch path in the script is under it +(`Release.release_scratch_sh/0`). A direct run of the script makes its own +dir under `$TMPDIR` and removes it with an `EXIT` trap. Nothing reads the +scratch once the `.ipa` is in `_build/mob_release/`. diff --git a/decisions/2026-10-13-ios-release-dead-strips-and-plugin-link-check.md b/decisions/2026-10-13-ios-release-dead-strips-and-plugin-link-check.md new file mode 100644 index 0000000..4bcbaac --- /dev/null +++ b/decisions/2026-10-13-ios-release-dead-strips-and-plugin-link-check.md @@ -0,0 +1,78 @@ +# iOS release links dead-strip like the dev links; plugin archives get a no-dead-strip link check +- Date: 2026-10-13 +- Status: accepted +- Issue: MOB-471 (found by MOB-470) + +## Context + +mob_new's `ios/build.zig` and `ios/build_device.zig` (every +`mix mob.deploy --native --ios`) link with `-Xlinker -dead_strip`; +`release_device.sh` (`mix mob.release --ios`) did not. ld64 reports an +undefined symbol only from code that survives dead-stripping, so a reference +in code the app never reaches linked on every dev build and failed only the +release. mob_whisper 0.1.1 shipped that way: ggml-backend-reg.cpp's `dl_*` +helpers were declared but their source (`ggml-backend-dl.cpp`) was not in +the manifest (MOB-470). Users would meet it at App Store time. + +The two ways to make the paths agree: + +1. Drop `-dead_strip` from the dev links: the same failure everywhere, but + the dev binaries grow, the dev and release builds still differ from what + ships, and Mob's slim-build flags (`-ffunction-sections -fdata-sections`, + `guides/slim_release.md`) exist to be dead-stripped. +2. Add `-dead_strip` to the release link: smaller `.ipa`, but it hides the + bug class on both paths. + +## Decision + +Both paths dead-strip, and the bug class is caught by a separate check that +runs on every path, before any link. + +- `release_device.sh` passes `-Xlinker -dead_strip` to the final `swiftc` + link, as the dev links do. `release_script_test.exs` asserts the release + script and both mob_new templates agree on the flag. +- `MobDev.Plugin.IosLinkCheck.check/5` runs on every `cpp_archive` plugin + archive built for `:ios_sim` or `:ios_device` + (`NativeBuild.build_plugin_static_archives/4`, which the simulator build, + the device build and `mix mob.release --ios` all call). It links the + archive into a throwaway dylib without `-dead_strip` (`-force_load` of the + plugin's own `lib.a`; its prebuilt archives contribute what those + members pull in, as in the app link) with `-undefined dynamic_lookup`, and + lists what stayed unresolved. Each of those must be defined by something + the app's link supplies: + - the BEAM: symbols the target's ERTS static libraries define + (`/erts-*/lib/*.a`, `lib/internal/*.a`), which covers `enif_*`; + - mob core: the external functions, globals and `@implementation` classes + in `/ios/*.{m,mm,c}` (e.g. `mob_register_permission_handler`), + found by a text scan because `mob_nif.m` imports a Swift header only the + full build generates; + - the iOS SDK: names exported by any `.tbd` stub of the target SDK. + + Anything else fails the build, naming the plugin, the symbols and the + archive members that reference them. On mob_whisper 0.1.1 it reports + `dl_get_sym`, `dl_load_library` and `dl_error` from + `ggml-backend-reg.o`. + +The allow-list is derived from those inputs rather than written down. The +SDK is in it whole, not just the frameworks a manifest names, because the +app also links frameworks through clang module autolinking from mob core: +mob_scene3d's prebuilt Filament references OpenGL ES, which no manifest +declares and which the app gets that way. + +## Consequences + +- A `mix mob.release --ios` binary keeps what a dev build keeps. A blank + host with the default plugins plus mob_whisper 0.1.2 and mob_scene3d 0.2.0 + (2026-10-13): the app binary in the `.ipa` went from 14,437,328 to + 12,631,520 bytes (−12.5 %), the `.ipa` from 54,319,714 to 53,581,889 bytes. +- Every iOS build with a `cpp_archive` plugin does one extra link and a scan + of the SDK's `.tbd` files per plugin: about 0.5 s each for mob_whisper and + mob_scene3d on the Mac mini. +- Not covered: plugin C/ObjC NIFs (`lang: :c` / `:objc`) and project NIFs, + which are compiled straight into the app link rather than into an archive + on this path; a symbol one plugin's archive expects another plugin to + define (none do today); and a framework symbol the app doesn't link (the + check accepts any SDK export; the real link still fails on a live one). +- `-undefined dynamic_lookup` is deprecated on iOS (ld prints a warning, + captured with the probe's output). If a future ld drops it, the check fails + loudly with the link output rather than passing. diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index c1de292..f864947 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3037,7 +3037,8 @@ defmodule MobDev.NativeBuild do do: "-D#{name}=#{val}" with {:ok, nif_args} <- project_nif_zig_args(:ios_sim), - {:ok, plugin_archives} <- build_plugin_static_archives(:ios_sim, :ios, otp_root) do + {:ok, plugin_archives} <- + build_plugin_static_archives(:ios_sim, :ios, otp_root, mob_dir: mob_dir) do args = base_args ++ plugin_args ++ @@ -3233,17 +3234,20 @@ defmodule MobDev.NativeBuild do # cpp_archive plugin never leaves the driver_tab's `_nif_init` # unresolved on one of them. A spec with a `:prebuilt` bundle # (MobDev.Plugin.Prebuilt, MOB-427) also contributes that bundle's archives - # for the target, listed right after its own lib.a. - @spec build_plugin_static_archives(atom(), :ios | :android, Path.t()) :: + # for the target, listed right after its own lib.a. On the iOS targets + # each archive then passes MobDev.Plugin.IosLinkCheck (MOB-471): linked + # without -dead_strip, it may leave undefined only what the BEAM, mob core + # (`opts[:mob_dir]`) or the iOS SDK define. + @spec build_plugin_static_archives(atom(), :ios | :android, Path.t(), keyword()) :: {:ok, [Path.t()]} | {:error, String.t()} - def build_plugin_static_archives(target_id, platform, otp_dir) do + def build_plugin_static_archives(target_id, platform, otp_dir, opts \\ []) do case MobDev.Plugin.Merge.static_archives(MobDev.Plugin.activated(), platform) do [] -> {:ok, []} - specs -> do_build_plugin_static_archives(specs, target_id, otp_dir) + specs -> do_build_plugin_static_archives(specs, target_id, otp_dir, opts) end end - defp do_build_plugin_static_archives(specs, target_id, otp_dir) do + defp do_build_plugin_static_archives(specs, target_id, otp_dir, opts) do with {:ok, erts_inc} <- resolve_erts_include(otp_dir) do out_dir = Path.join([Mix.Project.build_path(), "plugin_archives", Atom.to_string(target_id)]) @@ -3259,7 +3263,8 @@ defmodule MobDev.NativeBuild do MobDev.Plugin.CppArchive.build(spec, target_id, out_dir: out_dir, erts_include: erts_inc - ) do + ), + :ok <- ios_link_check(spec, target_id, info.archive, prebuilt_libs, otp_dir, opts) do IO.puts(" ✓ #{info.archive}") if prebuilt_libs != [], @@ -3267,6 +3272,9 @@ defmodule MobDev.NativeBuild do {:cont, {:ok, Enum.reverse(prebuilt_libs, [info.archive | acc])}} else + {:error, {:link_check, msg}} -> + {:halt, {:error, msg}} + {:error, {tag, detail}} -> {:halt, {:error, @@ -3287,6 +3295,19 @@ defmodule MobDev.NativeBuild do end end + defp ios_link_check(spec, target_id, archive, prebuilt_libs, otp_dir, opts) + when target_id in [:ios_sim, :ios_device] do + case MobDev.Plugin.IosLinkCheck.check(spec, target_id, archive, prebuilt_libs, + otp_dir: otp_dir, + mob_dir: opts[:mob_dir] + ) do + :ok -> :ok + {:error, msg} -> {:error, {:link_check, msg}} + end + end + + defp ios_link_check(_spec, _target_id, _archive, _prebuilt_libs, _otp_dir, _opts), do: :ok + @doc false # Android ABI string → CppArchive target id; nil = an ABI the Android build # doesn't produce (no plugin archive is built for it). Public for unit @@ -4567,7 +4588,8 @@ defmodule MobDev.NativeBuild do do: "-D#{name}=#{val}" with {:ok, nif_args} <- project_nif_zig_args(:ios_device), - {:ok, plugin_archives} <- build_plugin_static_archives(:ios_device, :ios, otp_root) do + {:ok, plugin_archives} <- + build_plugin_static_archives(:ios_device, :ios, otp_root, mob_dir: mob_dir) do sqlite_args = case sqlite_static_lib do nil -> [] diff --git a/lib/mob_dev/plugin/cpp_archive.ex b/lib/mob_dev/plugin/cpp_archive.ex index 362d86c..6fe97f6 100644 --- a/lib/mob_dev/plugin/cpp_archive.ex +++ b/lib/mob_dev/plugin/cpp_archive.ex @@ -369,8 +369,10 @@ defmodule MobDev.Plugin.CppArchive do defp ios_sdk_name(:ios_sim), do: "iphonesimulator" defp ios_sdk_name(:ios_device), do: "iphoneos" - defp ios_min_flag(:ios_sim), do: "-mios-simulator-version-min=#{@ios_min_version}" - defp ios_min_flag(:ios_device), do: "-miphoneos-version-min=#{@ios_min_version}" + @doc "The deployment-target flag an iOS archive is compiled for. Pure." + @spec ios_min_flag(:ios_sim | :ios_device) :: String.t() + def ios_min_flag(:ios_sim), do: "-mios-simulator-version-min=#{@ios_min_version}" + def ios_min_flag(:ios_device), do: "-miphoneos-version-min=#{@ios_min_version}" # Honors ANDROID_HOME / ANDROID_SDK_ROOT via the shared NdkVersion helper — # do NOT re-derive the SDK path here (MOB-89: this used to hardcode diff --git a/lib/mob_dev/plugin/ios_link_check.ex b/lib/mob_dev/plugin/ios_link_check.ex new file mode 100644 index 0000000..846626c --- /dev/null +++ b/lib/mob_dev/plugin/ios_link_check.ex @@ -0,0 +1,264 @@ +defmodule MobDev.Plugin.IosLinkCheck do + @moduledoc """ + Fails an iOS build early when a plugin's `cpp_archive` references a symbol + nothing in the app defines. + + The iOS links (`mix mob.deploy`'s `ios/build.zig` / `build_device.zig` and + `mix mob.release --ios`) all pass `-dead_strip`, and ld64 reports an + undefined symbol only from code that survives dead-stripping. A reference + in code the app never reaches therefore links everywhere, until a link + without `-dead_strip`, or a change that makes that code reachable, fails + at the worst moment (MOB-470: mob_whisper 0.1.1's ggml `dl_*` helpers + passed every dev build and failed `mix mob.release --ios`). + + `check/5` links the plugin's archive into a throwaway dylib *without* + `-dead_strip`: every member of its own `lib.a` (`-force_load`), + plus the members of its prebuilt archives (MOB-427) that those pull in, + as the app's link would. The symbols left unresolved must each be + defined by something the app's link supplies, and the allow-list is + derived from those inputs, not written down: + + * the BEAM: every symbol the target's ERTS static libraries define + (`/erts-*/lib/**/*.a`; the `enif_*` API and the rest of ERTS), + * mob core: the non-static C functions, globals and ObjC classes mob's + iOS sources (`/ios/*.{m,mm,c}`) define, e.g. + `mob_register_permission_handler`, + * the iOS SDK: every symbol a library or framework of the target's SDK + exports (its `.tbd` stubs), since the app may link any of them, + explicitly or through clang module autolinking. + + Anything else is a dangling reference and fails the build, naming the + plugin, the symbols and the archive members that reference them. See + `decisions/2026-10-13-ios-release-dead-strips-and-plugin-link-check.md`. + """ + + @word_chars ~c"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$" + @objc_prefixes ["_OBJC_CLASS_$_", "_OBJC_METACLASS_$_", "_OBJC_EHTYPE_$_", "_OBJC_IVAR_$_"] + + @doc """ + Check one built plugin archive for `target_id` (`:ios_sim` or `:ios_device`). + + `spec` is the `MobDev.Plugin.Merge.static_archives/2` entry (`:plugin`, + `:module`); `archive` its `lib.a`; `prebuilt` the archives its + prebuilt bundle contributes for the target. Options: `:otp_dir` (the + target's OTP runtime, **required**) and `:mob_dir` (mob's checkout; mob + core's symbols are allowed only when given). + + Returns `:ok`, or `{:error, message}`. + """ + @spec check(map(), :ios_sim | :ios_device, Path.t(), [Path.t()], keyword()) :: + :ok | {:error, String.t()} + def check(spec, target_id, archive, prebuilt, opts) + when target_id in [:ios_sim, :ios_device] do + otp_dir = Keyword.fetch!(opts, :otp_dir) + sdk = sdk_name(target_id) + + MobDev.NativeBuild.with_temp_build_dir("link_check", fn dir -> + with {:ok, unresolved} <- probe(sdk, target_id, archive, prebuilt, dir), + {:ok, beam} <- beam_symbols(sdk, otp_dir), + {:ok, sdk_path} <- xcrun(["-sdk", sdk, "--show-sdk-path"]) do + host = MapSet.union(beam, mob_core_symbols(opts[:mob_dir])) + candidates = Enum.reject(unresolved, &MapSet.member?(host, &1)) + # The path is the last line (xcrun warnings, if any, come first). + sdk_path = sdk_path |> String.split("\n", trim: true) |> List.last() + exported = sdk_exports(sdk_path, Enum.map(candidates, &tbd_name/1)) + + case Enum.reject(candidates, &MapSet.member?(exported, tbd_name(&1))) do + [] -> + :ok + + dangling -> + {:error, error_message(spec, target_id, dangling, referrers(sdk, archive, prebuilt))} + end + end + end) + end + + # xcrun's output, or an error naming the command (the check runs inside + # every iOS build, so a tool failure must be a build error, not a crash). + defp xcrun(args) do + case System.cmd("xcrun", args, stderr_to_stdout: true) do + {out, 0} -> {:ok, out} + {out, status} -> {:error, "xcrun #{Enum.join(args, " ")} failed (exit #{status}):\n#{out}"} + end + end + + # The dylib link: no -dead_strip, unresolved symbols left for dyld + # (`-undefined dynamic_lookup`) so the link always completes and nm can list + # them. -force_load loads every member of the plugin's own archive; the + # prebuilt archives contribute only what those members reach. + defp probe(sdk, target_id, archive, prebuilt, dir) do + dylib = Path.join(dir, "probe.dylib") + + args = + ["-sdk", sdk, "clang++", "-arch", "arm64", min_flag(target_id), "-dynamiclib"] ++ + ["-Xlinker", "-force_load", "-Xlinker", archive | prebuilt] ++ + ["-Wl,-undefined,dynamic_lookup", "-o", dylib] + + case xcrun(args) do + {:ok, _} -> + with {:ok, nm} <- xcrun(["-sdk", sdk, "nm", "-m", "-u", dylib]), + do: {:ok, parse_dynamic_lookups(nm)} + + {:error, out} -> + {:error, "linking #{archive} for the undefined-symbol check failed: #{out}"} + end + end + + @doc """ + The symbols `nm -m -u` lists as left for dyld to look up (the ones the + probe link could not resolve). Pure. + """ + @spec parse_dynamic_lookups(String.t()) :: [String.t()] + def parse_dynamic_lookups(nm_output) do + for line <- String.split(nm_output, "\n"), + [_, sym] <- [Regex.run(~r/external (\S+) \(dynamically looked up\)/, line)], + do: sym + end + + defp beam_symbols(sdk, otp_dir) do + libs = + Path.wildcard(Path.join(otp_dir, "erts-*/lib/*.a")) ++ + Path.wildcard(Path.join(otp_dir, "erts-*/lib/internal/*.a")) + + case libs do + [] -> + {:ok, MapSet.new()} + + libs -> + with {:ok, out} <- xcrun(["-sdk", sdk, "nm", "-gUj" | libs]), + do: {:ok, out |> String.split("\n", trim: true) |> MapSet.new()} + end + end + + defp mob_core_symbols(nil), do: MapSet.new() + + defp mob_core_symbols(mob_dir) do + mob_dir + |> Path.join("ios/*.{m,mm,c}") + |> Path.wildcard() + |> Enum.flat_map(&(&1 |> File.read!() |> defined_symbols())) + |> MapSet.new() + end + + @doc """ + The Mach-O names of what a C / ObjC source defines with external linkage: + top-level non-`static` functions and globals, and `@implementation` + classes. A text scan, for mob core's sources (which import a Swift header + the build generates, so they can't be compiled on their own here). Pure. + """ + @spec defined_symbols(String.t()) :: [String.t()] + def defined_symbols(source) do + functions = + Regex.scan( + ~r/^(?!static\b|extern\b|typedef\b|return\b)[A-Za-z_][\w \t*]*?[\s*]([A-Za-z_]\w*)\s*\((?:[^;{}()]|\([^()]*\))*\)\s*\{/m, + source, + capture: :all_but_first + ) + + globals = + Regex.scan( + ~r/^(?!static\b|extern\b|typedef\b|return\b)[A-Za-z_][\w \t*]*?[\s*]([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*=[^=]/m, + source, + capture: :all_but_first + ) + + classes = + ~r/^@implementation\s+([A-Za-z_]\w*)/m + |> Regex.scan(source, capture: :all_but_first) + |> Enum.flat_map(fn [c] -> [["OBJC_CLASS_$_" <> c], ["OBJC_METACLASS_$_" <> c]] end) + + for [name] <- functions ++ globals ++ classes, do: "_" <> name + end + + @doc """ + How a Mach-O symbol is spelled in an SDK `.tbd` stub: ObjC class, metaclass, + exception-type and ivar symbols by their bare class (or `Class.ivar`) name, + everything else as is. Pure. + """ + @spec tbd_name(String.t()) :: String.t() + def tbd_name(symbol) do + Enum.find_value(@objc_prefixes, symbol, fn prefix -> + if String.starts_with?(symbol, prefix), do: String.replace_prefix(symbol, prefix, "") + end) + end + + @doc """ + Which of `names` some `.tbd` under `sdk_path` exports, matched as whole + words. Every library and framework the SDK ships has one. + """ + @spec sdk_exports(Path.t(), [String.t()]) :: MapSet.t(String.t()) + def sdk_exports(_sdk_path, []), do: MapSet.new() + + def sdk_exports(sdk_path, names) do + pattern = :binary.compile_pattern(Enum.uniq(names)) + + # `**` doesn't follow symlinks; the SDK's own .tbd files are real files. + sdk_path + |> Path.join("**/*.tbd") + |> Path.wildcard() + |> Enum.reduce(MapSet.new(), fn tbd, found -> + text = File.read!(tbd) + + for {pos, len} <- :binary.matches(text, pattern), + word_at?(text, pos, len), + into: found, + do: binary_part(text, pos, len) + end) + end + + defp word_at?(text, pos, len) do + not word_char?(text, pos - 1) and not word_char?(text, pos + len) + end + + defp word_char?(text, i) when i < 0 or i >= byte_size(text), do: false + defp word_char?(text, i), do: :binary.at(text, i) in @word_chars + + # symbol => the archive members that reference it, for the error message. + defp referrers(sdk, archive, prebuilt) do + {out, _} = System.cmd("xcrun", ["-sdk", sdk, "nm", "-A", "-u", archive | prebuilt]) + + for line <- String.split(out, "\n", trim: true), + [where, sym] <- [String.split(line, ": ", parts: 2)], + reduce: %{} do + acc -> Map.update(acc, String.trim(sym), [member(where)], &[member(where) | &1]) + end + end + + defp member(where) do + case String.split(where, ":") do + [path, obj] -> "#{Path.basename(path)}(#{obj})" + _ -> Path.basename(where) + end + end + + @doc """ + The build error for `dangling` (symbols nothing in the app defines), with + the archive members referencing each from `referrers`. Pure. + """ + @spec error_message(map(), atom(), [String.t()], %{String.t() => [String.t()]}) :: String.t() + def error_message(spec, target_id, dangling, referrers) do + lines = + for sym <- Enum.sort(dangling) do + case referrers |> Map.get(sym, []) |> Enum.uniq() |> Enum.sort() do + [] -> " #{sym}" + from -> " #{sym} (referenced from #{Enum.join(from, ", ")})" + end + end + + """ + plugin #{spec.plugin}: lib#{spec.module}.a (#{target_id}) references symbols that \ + nothing in an iOS app defines (not the plugin's archives, the BEAM, mob or the iOS SDK): + #{Enum.join(lines, "\n")} + A dead-stripping link drops code it never reaches, so this can link today and \ + fail as soon as that code is reached or linked without -dead_strip (MOB-470). \ + Fix the plugin: add the sources that define these symbols to the manifest's \ + :sources, or remove the references.\ + """ + end + + defp sdk_name(:ios_sim), do: "iphonesimulator" + defp sdk_name(:ios_device), do: "iphoneos" + + defp min_flag(target_id), do: MobDev.Plugin.CppArchive.ios_min_flag(target_id) +end diff --git a/lib/mob_dev/release.ex b/lib/mob_dev/release.ex index 0907abf..76f3177 100644 --- a/lib/mob_dev/release.ex +++ b/lib/mob_dev/release.ex @@ -68,7 +68,9 @@ defmodule MobDev.Release do {:ok, cfg} <- resolve_distribution_signing(cfg), {:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_device(), {:ok, plugin_archives} <- - MobDev.NativeBuild.build_plugin_static_archives(:ios_device, :ios, otp_root), + MobDev.NativeBuild.build_plugin_static_archives(:ios_device, :ios, otp_root, + mob_dir: Path.expand(cfg[:mob_dir]) + ), {:ok, project_nifs} <- MobDev.NativeBuild.project_nif_build_inputs(:ios_device) do script_path = "ios/release_device.sh" File.write!(script_path, release_device_sh()) @@ -96,18 +98,26 @@ defmodule MobDev.Release do | env ] - case System.cmd("bash", [script_path], - env: env, - stderr_to_stdout: true, - into: IO.stream() - ) do - {_, 0} -> - app_name = Mix.Project.config()[:app] |> to_string() |> Macro.camelize() - ipa_path = Path.join(output_dir, "#{app_name}.ipa") - {:ok, ipa_path} - - {_, _} -> - {:error, "release_device.sh failed — check output above"} + # The script's scratch (objects, the linked binary, the .app, the IPA + # staging dir: ~100 MB) goes in a directory removed on every exit path, + # success or failure (MOB-425, the MOB-313 rule for the dev builds). + status = + MobDev.NativeBuild.with_temp_build_dir("release", fn build_dir -> + {_, status} = + System.cmd("bash", [script_path], + env: [{"MOB_RELEASE_BUILD_DIR", build_dir} | env], + stderr_to_stdout: true, + into: IO.stream() + ) + + status + end) + + if status == 0 do + app_name = Mix.Project.config()[:app] |> to_string() |> Macro.camelize() + {:ok, Path.join(output_dir, "#{app_name}.ipa")} + else + {:error, "release_device.sh failed — check output above"} end end end @@ -441,7 +451,7 @@ defmodule MobDev.Release do # `-Dproject_swift_sources`, `-Dproject_c_nifs` / `-Dproject_rust_libs`, and # `-Dplugin_static_libs` (MOB-373). Pure over # `NativeBuild.project_swift_sources/1`, `project_nif_build_inputs/1` and - # `build_plugin_static_archives/3` results. Space-joined and word-split by the + # `build_plugin_static_archives/4` results. Space-joined and word-split by the # script like the other path lists. # # - `MOB_PROJECT_SWIFT_SOURCES` — compiled into the app's Swift module. @@ -652,562 +662,591 @@ defmodule MobDev.Release do APP_NAME="${MOB_APP_NAME:?MOB_APP_NAME not set}" APP_MODULE="${MOB_APP_MODULE:?MOB_APP_MODULE not set}" OUTPUT_DIR="${MOB_RELEASE_OUTPUT_DIR:?MOB_RELEASE_OUTPUT_DIR not set}" + """ <> + release_scratch_sh() <> + ~S""" + + ERTS_VSN=$(ls "$OTP_ROOT" | grep '^erts-' | sort -V | tail -1) + [ -z "$ERTS_VSN" ] && echo "ERROR: No erts-* in $OTP_ROOT" && exit 1 + 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 "=== RELEASE: ERTS=$ERTS_VSN OTP=$OTP_RELEASE App=$APP_NAME Bundle=$BUNDLE_ID ===" + + BEAMS_DIR="$OTP_ROOT/$APP_MODULE" + SDKROOT=$(xcrun -sdk iphoneos --show-sdk-path) + HOSTCC=$(xcrun -find cc) + CC="$HOSTCC -arch arm64 -miphoneos-version-min=17.0 -isysroot $SDKROOT" + + IFLAGS="-I$OTP_ROOT/$ERTS_VSN/include \ + -I$OTP_ROOT/$ERTS_VSN/include/internal \ + -I$MOB_DIR/ios" + + 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/crypto.a + $OTP_ROOT/$ERTS_VSN/lib/libcrypto.a + " + + 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 + 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/" + + EXQLITE_SRC="deps/exqlite/c_src" + BUILD_DIR_TMP="$MOB_RELEASE_SCRATCH/exqlite" + mkdir -p "$BUILD_DIR_TMP" + $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" + fi + + # Real crypto + ssl (no shims). The iOS OTP cache ships crypto-5.9 and + # ssl-11.7 (NOT in the slim-strip list below) and the crypto NIF is + # statically linked via crypto.a, so the real beams work on device. The + # old md5-only crypto shim + no-op ssl shim used to be compiled into + # BEAMS_DIR, where (being on the prepended -pa path) they SHADOWED the + # real beams in lib/{crypto,ssl}-*/ebin. That broke TLS: real ssl needs + # ciphers crypto can't provide, and the ssl shim didn't even export + # versions/0 — so Mint hit `:ssl.versions/0 undefined`, every HTTPS + # request crashed, and the orchestra SSE never connected on device. + # Removing the shims lets the real, NIF-backed crypto + ssl load. + + 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/" 2>/dev/null || true + cp "$ELIXIR_LIB/eex/ebin/eex.app" "$BEAMS_DIR/" 2>/dev/null || true + + 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/" + fi + } + copy_otp_lib runtime_tools + copy_otp_lib asn1 + copy_otp_lib public_key + + echo "=== Copying priv (migrations, assets, bundled ebins, app priv) ===" + if [ -d "assets" ]; then + mix assets.build + fi + # Ship the WHOLE priv/ to the device, not just repo/migrations + static. + # Apps that bundle extra runtime assets under priv/ — e.g. :mix/:hex ebins + # for on-device Mix.install, or a vendored library's priv/static (Livebook) — + # need those on device too. Mirrors the Android deployer, which pushes all + # of priv/. (Previously only priv/repo/migrations and priv/static shipped, + # so priv/mix, priv/hex, priv//... silently never reached the device.) + if [ -d "priv" ]; then + mkdir -p "$BEAMS_DIR/priv" + rsync -a "priv/" "$BEAMS_DIR/priv/" + fi + + 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 + fi + + 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 + + echo "=== Compiling native sources (release: -DMOB_RELEASE, no EPMD) ===" + 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 \ + -O \ + "$MOB_DIR"/ios/*.swift \ + $MOB_PLUGIN_IOS_SWIFT_SOURCES \ + $MOB_PROJECT_SWIFT_SOURCES \ + -c -o "$BUILD_DIR/swift_mob.o" + + # MOB_RELEASE on mob_nif.m strips the test harness (synthetic-input + # NIFs that use private UIKit selectors — App Store auto-rejects). + # MOB_ENABLE_SCREENSHOT (set when `ios_release_screenshot: true`) opts the + # public-API screenshot NIF back in — it stays stripped otherwise. `${VAR:+flag}` + # expands to the flag only when VAR is non-empty, so the default build is byte-identical. + $CC -fobjc-arc -fmodules $IFLAGS \ + -I "$BUILD_DIR" -DSTATIC_ERLANG_NIF -DMOB_RELEASE \ + ${MOB_ENABLE_SCREENSHOT:+-DMOB_ENABLE_SCREENSHOT} \ + -c "$MOB_DIR/ios/mob_nif.m" -o "$BUILD_DIR/mob_nif.o" + + # MOB_RELEASE on mob_beam.m drops -name/-setcookie/-kernel-dist BEAM + # args + EPMD thread (no Erlang distribution surface in shipped apps). + $CC -fobjc-arc -fmodules $IFLAGS \ + -DMOB_BUNDLE_OTP \ + -DMOB_RELEASE \ + -DERTS_VSN=\"$ERTS_VSN\" \ + -DOTP_RELEASE=\"$OTP_RELEASE\" \ + -c "$MOB_DIR/ios/mob_beam.m" -o "$BUILD_DIR/mob_beam.o" + + SQLITE_FLAG="" + [ -n "$SQLITE_STATIC_LIB" ] && SQLITE_FLAG="-DMOB_STATIC_SQLITE_NIF" + # driver_tab now lives in priv/generated (per-app, regenerated via + # `mix mob.regen_driver_tab --format c`), not $MOB_DIR/ios. + # MOB_DRIVER_TAB_DEFINES: -D for each guarded project NIF built for + # this target, so its #ifdef'd row stays in the table (MOB-373). + $CC $IFLAGS $SQLITE_FLAG $MOB_DRIVER_TAB_DEFINES \ + -c "priv/generated/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" + + # erl_errno_id stub: BEAM's erl_posix_str.o references + # erl_errno_id_unknown but the bundled OTP doesn't define it. Weak so + # an OTP-internal definition wins if one ever appears. Written with + # printf (not a heredoc) to stay cleanly indentable inside this + # Elixir \""" string. NOTE the single backslash: this is a ~S (raw) heredoc, + # so '%s\\n' would reach bash verbatim and printf would emit a literal + # backslash-n into the C file (clang then rejects `}\n`). '%s\n' emits a real + # newline. + printf '%s\n' '__attribute__((weak)) const char *erl_errno_id_unknown(int error) { (void)error; return "unknown"; }' > "$BUILD_DIR/erl_errno_id_compat.c" + $CC $IFLAGS -c "$BUILD_DIR/erl_errno_id_compat.c" -o "$BUILD_DIR/erl_errno_id_compat.o" + + # ── Activated-plugin and project C/ObjC NIFs ────────────────────────────── + # driver_tab_ios references each activated plugin's and each project NIF's + # _nif_init; those definitions live in the plugin's iOS NIF source + # (priv/native/ios/.m, lang: :objc) or the project's c_src/.c. + # The dev build compiles these via build.zig -Dplugin_c_nifs and + # -Dproject_c_nifs; the release build must do the same or the final link dies + # with "Undefined symbols: __nif_init". The source basename is the NIF + # libname → -DSTATIC_ERLANG_NIF_LIBNAME= makes ERL_NIF_INIT emit + # _nif_init (erl_nif.h derives STATIC_ERLANG_NIF from it; passing both + # redefines it). -fmodules lets Clang autolink every framework the source + # @imports (a plugin may import frameworks beyond its manifest's declared set, + # e.g. Accelerate). -Os as the device build compiles NIF sources. + PLUGIN_OBJS="" + for SRC in $MOB_PLUGIN_IOS_NIF_SOURCES $MOB_PROJECT_NIF_SOURCES; do + NAME=$(basename "$SRC"); NAME="${NAME%.*}" + case "$SRC" in + *.m) ARC="-fobjc-arc" ;; + *) ARC="" ;; + esac + echo " NIF: $NAME ($SRC)" + $CC $ARC -Os -fmodules $IFLAGS \ + -DSTATIC_ERLANG_NIF_LIBNAME="$NAME" \ + -c "$SRC" -o "$BUILD_DIR/$NAME.o" + PLUGIN_OBJS="$PLUGIN_OBJS $BUILD_DIR/$NAME.o" + done + + # Frameworks the activated plugins declare (explicit, alongside -fmodules + # autolink above): -framework for each unique name. + PLUGIN_FRAMEWORK_FLAGS="" + for FW in $MOB_PLUGIN_IOS_FRAMEWORKS; do + PLUGIN_FRAMEWORK_FLAGS="$PLUGIN_FRAMEWORK_FLAGS -Xlinker -framework -Xlinker $FW" + done + + # -dead_strip as the dev builds link (mob_new's ios/build.zig and + # build_device.zig), so a release keeps exactly the code a dev build keeps. + # A dangling reference in code dead-stripping removes (MOB-470) is caught + # before any link by MobDev.Plugin.IosLinkCheck on the plugin's archive. + # See decisions/2026-10-13-ios-release-dead-strips-and-plugin-link-check.md. + echo "=== Linking $APP_NAME (release, no EPMD) ===" + 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/AppDelegate.o" \ + "$BUILD_DIR/beam_main.o" \ + "$BUILD_DIR/erl_errno_id_compat.o" \ + $PLUGIN_OBJS \ + $LIBS \ + "$SQLITE_STATIC_LIB" \ + $MOB_PROJECT_STATIC_LIBS \ + $MOB_PLUGIN_STATIC_LIBS \ + -lz -lc++ -lpthread \ + -Xlinker -framework -Xlinker UIKit \ + -Xlinker -framework -Xlinker Foundation \ + -Xlinker -framework -Xlinker CoreGraphics \ + -Xlinker -framework -Xlinker QuartzCore \ + -Xlinker -framework -Xlinker SwiftUI \ + $PLUGIN_FRAMEWORK_FLAGS \ + -Xlinker -dead_strip \ + -o "$BUILD_DIR/$APP_NAME" + + echo "=== Building .app bundle ===" + APP="$BUILD_DIR/$APP_NAME.app" + rm -rf "$APP" + mkdir -p "$APP" + cp "$BUILD_DIR/$APP_NAME" "$APP/" + + 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" + + # mob.exs ios_target_devices / ios_orientations / multi_window + # (MobDev.IosLayoutPlist): one PlistBuddy command per line. A Delete of an + # absent key fails harmlessly; a Print checks that multi_window: true has a + # scene manifest to extend; any other failure stops the build (set -e). + # Runs before the UIDeviceFamily default below, which then sees the key and + # keeps it. + if [ -n "$MOB_IOS_LAYOUT_PLIST_COMMANDS" ]; then + while IFS= read -r CMD; do + case "$CMD" in + "Delete "*) /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" 2>/dev/null || true ;; + "Print "*) /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" >/dev/null 2>&1 || { + echo "error: mob.exs multi_window: true needs UIApplicationSceneManifest -> UISceneConfigurations -> UIWindowSceneSessionRoleApplication (a SceneDelegate) in ios/Info.plist. Copy the UIApplicationSceneManifest dict from a newly generated app's ios/Info.plist (mix mob.new), or set multi_window: false" >&2 + exit 1 + } ;; + *) /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" ;; + esac + done <<< "$MOB_IOS_LAYOUT_PLIST_COMMANDS" + fi + + # mob.exs url_schemes (MobDev.UrlSchemes): PlistBuddy Adds computed from + # ios/Info.plist, appending one CFBundleURLTypes entry after the app's own + # and skipping schemes it declares. Every command must succeed (set -e). + if [ -n "$MOB_IOS_URL_TYPES_PLIST_COMMANDS" ]; then + while IFS= read -r CMD; do + /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" + done <<< "$MOB_IOS_URL_TYPES_PLIST_COMMANDS" + fi + + # Apple's App Store validator requires MinimumOSVersion and DTPlatformName + # in the bundle Info.plist (codes 90065/90507/90530). Both are derived + # from the build target — set them defensively here so any app gets + # them right without needing to remember to add them by hand. + # `Add` errors if the key already exists; fall through to `Set` for the + # idempotent case. + /usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string 17.0" "$APP/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Set :MinimumOSVersion 17.0" "$APP/Info.plist" + /usr/libexec/PlistBuddy -c "Add :DTPlatformName string iphoneos" "$APP/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Set :DTPlatformName iphoneos" "$APP/Info.plist" + + # The DT* keys ("Development Tools") record what built the bundle. + # App Store Connect's validator (error 90534) cross-references + # DTSDKBuild + DTXcodeBuild against an allow-list of accepted Xcode + # release versions. Without them the upload is rejected as "built + # with an unsupported SDK or Xcode version" even when Xcode is current. + SDK_VERSION=$(xcrun --sdk iphoneos --show-sdk-version) + SDK_BUILD=$(xcrun --sdk iphoneos --show-sdk-build-version) + XCODE_RAW=$(xcodebuild -version | head -1 | awk '{print $2}') + XCODE_BUILD=$(xcodebuild -version | sed -n '2p' | awk '{print $3}') + XCODE_MAJOR=$(echo "$XCODE_RAW" | cut -d. -f1) + XCODE_MINOR=$(echo "$XCODE_RAW" | cut -d. -f2) + [ -z "$XCODE_MINOR" ] && XCODE_MINOR=0 + XCODE_PATCH=$(echo "$XCODE_RAW" | cut -d. -f3) + [ -z "$XCODE_PATCH" ] && XCODE_PATCH=0 + # DTXcode encoding: e.g. "26.4" → "2640" (major × 1000 + minor × 10 + + # patch). Same scheme Xcode itself stamps into bundles. Computed via + # arithmetic so the result is always 4 digits regardless of how the + # version components were entered. + # Apple's encoding (per their IPA validator): Xcode 16.0 → 1600, + # 16.4 → 1640, 26.4 → 2640. Always 4 digits while major is 2-digit. + DTXCODE=$(( XCODE_MAJOR * 100 + XCODE_MINOR * 10 + XCODE_PATCH )) + + for kv in \ + "DTSDKName=iphoneos${SDK_VERSION}" \ + "DTSDKBuild=${SDK_BUILD}" \ + "DTPlatformVersion=${SDK_VERSION}" \ + "DTPlatformBuild=${SDK_BUILD}" \ + "DTXcode=${DTXCODE}" \ + "DTXcodeBuild=${XCODE_BUILD}" \ + "DTCompiler=com.apple.compilers.llvm.clang.1_0" \ + "BuildMachineOSBuild=$(sw_vers -buildVersion)"; do + K="${kv%%=*}" + V="${kv#*=}" + /usr/libexec/PlistBuddy -c "Add :$K string $V" "$APP/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Set :$K $V" "$APP/Info.plist" + done + # UIDeviceFamily is required when MinimumOSVersion >= 3.2 (always, in + # practice). 1 = iPhone, 2 = iPad. Default to iPhone-only for a plist that + # declares none (an app generated before mob_new declared [1, 2]); an app + # that sets the key in ios/Info.plist or mob.exs ios_target_devices keeps + # its value (the `Add` fails and we don't overwrite). + /usr/libexec/PlistBuddy -c "Add :UIDeviceFamily array" "$APP/Info.plist" 2>/dev/null \ + && /usr/libexec/PlistBuddy -c "Add :UIDeviceFamily:0 integer 1" "$APP/Info.plist" + + # CFBundleSupportedPlatforms: array with one string identifying the + # platform the binary was built for. "iPhoneOS" for device builds, + # "iPhoneSimulator" for sim. Apple validator error 90562 if missing. + /usr/libexec/PlistBuddy -c "Add :CFBundleSupportedPlatforms array" "$APP/Info.plist" 2>/dev/null \ + && /usr/libexec/PlistBuddy -c "Add :CFBundleSupportedPlatforms:0 string iPhoneOS" "$APP/Info.plist" + + if [ -d "ios/Assets.xcassets/AppIcon.appiconset" ]; then + ACTOOL_PLIST="$MOB_RELEASE_SCRATCH/actool_partial.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 + fi + + echo "=== Bundling OTP runtime (no EPMD binary path) ===" + 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" + + # ── App Store bundle policy: ONE Mach-O per .app, no .so/.a/standalone ── + # Apple's validator rejects the bundle if it contains any of: + # - dynamic loadable libraries (.so files for NIFs/drivers) + # - static archives (.a — these are linked into the main binary at + # build time, but copying them into the bundle is still rejected) + # - standalone executable files (erl_call, memsup, beam.smp, etc.) + # Strip them all from the bundled OTP tree. The static archives are + # already linked into $APP_NAME (the main Mach-O); the .so files + # belong to OTP libs the app doesn't actually use (megaco, + # runtime_tools, asn1's dynamic variant). + # ── Apple-policy strips (always on; not optional for App Store) ── + # Apple's validator rejects bundles containing .so/.a (frameworks must + # use .framework), priv/bin executables, or extra binaries in erts-*/bin. + # The BEAM is static-linked into the main Mach-O so these are + # unreachable from runtime anyway. NOT gated on MOB_SLIM — even + # `--no-slim` builds need to pass App Store validation. + echo "=== Stripping App-Store-disallowed binaries (always on) ===" + find "$OTP_BUNDLE" -type f \( -name "*.so" -o -name "*.a" \) -delete + find "$OTP_BUNDLE" -path "*/priv/bin/*" -type f -delete + find "$OTP_BUNDLE/$ERTS_VSN/bin" -type f -delete 2>/dev/null || true + # Standalone executables inside OTP libs (e.g. erl_interface/bin/erl_call) + # are also rejected by App Store validation (90171) and can't exec on iOS + # anyway. Remove every lib/*/bin/* executable while keeping the libs' + # .beam/.app — so a --no-slim full-OTP bundle (needed for runtime Mix.install) + # still passes Apple's "no standalone executables" rule. + find "$OTP_BUNDLE/lib" -path "*/bin/*" -type f -delete 2>/dev/null || true + + # ── Slim strips (gated; opt out with `mix mob.release --no-slim`) ── + # Each step echoes a tagged header AND the bundle size delta so a + # broken build can be traced to a specific step. The grep-friendly tag + # `[SLIM:]` is what the docs walkthrough searches for. + if [ "${MOB_SLIM:-1}" = "1" ]; then + # Helper to log size delta around a step. Bash function so each + # step's size delta is visible in the build log without bespoke code. + slim_step() { + local label=$1 + local before=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') + shift + "$@" + local after=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') + local delta=$((before - after)) + printf "[SLIM:%s] %s KB → %s KB (-%s KB)\n" "$label" "$before" "$after" "$delta" + } - ERTS_VSN=$(ls "$OTP_ROOT" | grep '^erts-' | sort -V | tail -1) - [ -z "$ERTS_VSN" ] && echo "ERROR: No erts-* in $OTP_ROOT" && exit 1 - 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 "=== RELEASE: ERTS=$ERTS_VSN OTP=$OTP_RELEASE App=$APP_NAME Bundle=$BUNDLE_ID ===" - - BEAMS_DIR="$OTP_ROOT/$APP_MODULE" - SDKROOT=$(xcrun -sdk iphoneos --show-sdk-path) - HOSTCC=$(xcrun -find cc) - CC="$HOSTCC -arch arm64 -miphoneos-version-min=17.0 -isysroot $SDKROOT" - - IFLAGS="-I$OTP_ROOT/$ERTS_VSN/include \ - -I$OTP_ROOT/$ERTS_VSN/include/internal \ - -I$MOB_DIR/ios" - - 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/crypto.a - $OTP_ROOT/$ERTS_VSN/lib/libcrypto.a - " - - 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 - 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/" - - 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" - fi - - # Real crypto + ssl (no shims). The iOS OTP cache ships crypto-5.9 and - # ssl-11.7 (NOT in the slim-strip list below) and the crypto NIF is - # statically linked via crypto.a, so the real beams work on device. The - # old md5-only crypto shim + no-op ssl shim used to be compiled into - # BEAMS_DIR, where (being on the prepended -pa path) they SHADOWED the - # real beams in lib/{crypto,ssl}-*/ebin. That broke TLS: real ssl needs - # ciphers crypto can't provide, and the ssl shim didn't even export - # versions/0 — so Mint hit `:ssl.versions/0 undefined`, every HTTPS - # request crashed, and the orchestra SSE never connected on device. - # Removing the shims lets the real, NIF-backed crypto + ssl load. - - 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/" 2>/dev/null || true - cp "$ELIXIR_LIB/eex/ebin/eex.app" "$BEAMS_DIR/" 2>/dev/null || true - - 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/" - fi - } - copy_otp_lib runtime_tools - copy_otp_lib asn1 - copy_otp_lib public_key - - echo "=== Copying priv (migrations, assets, bundled ebins, app priv) ===" - if [ -d "assets" ]; then - mix assets.build - fi - # Ship the WHOLE priv/ to the device, not just repo/migrations + static. - # Apps that bundle extra runtime assets under priv/ — e.g. :mix/:hex ebins - # for on-device Mix.install, or a vendored library's priv/static (Livebook) — - # need those on device too. Mirrors the Android deployer, which pushes all - # of priv/. (Previously only priv/repo/migrations and priv/static shipped, - # so priv/mix, priv/hex, priv//... silently never reached the device.) - if [ -d "priv" ]; then - mkdir -p "$BEAMS_DIR/priv" - rsync -a "priv/" "$BEAMS_DIR/priv/" - fi - - 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 - fi - - 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 - - echo "=== Compiling native sources (release: -DMOB_RELEASE, no EPMD) ===" - 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 \ - -O \ - "$MOB_DIR"/ios/*.swift \ - $MOB_PLUGIN_IOS_SWIFT_SOURCES \ - $MOB_PROJECT_SWIFT_SOURCES \ - -c -o "$BUILD_DIR/swift_mob.o" - - # MOB_RELEASE on mob_nif.m strips the test harness (synthetic-input - # NIFs that use private UIKit selectors — App Store auto-rejects). - # MOB_ENABLE_SCREENSHOT (set when `ios_release_screenshot: true`) opts the - # public-API screenshot NIF back in — it stays stripped otherwise. `${VAR:+flag}` - # expands to the flag only when VAR is non-empty, so the default build is byte-identical. - $CC -fobjc-arc -fmodules $IFLAGS \ - -I "$BUILD_DIR" -DSTATIC_ERLANG_NIF -DMOB_RELEASE \ - ${MOB_ENABLE_SCREENSHOT:+-DMOB_ENABLE_SCREENSHOT} \ - -c "$MOB_DIR/ios/mob_nif.m" -o "$BUILD_DIR/mob_nif.o" - - # MOB_RELEASE on mob_beam.m drops -name/-setcookie/-kernel-dist BEAM - # args + EPMD thread (no Erlang distribution surface in shipped apps). - $CC -fobjc-arc -fmodules $IFLAGS \ - -DMOB_BUNDLE_OTP \ - -DMOB_RELEASE \ - -DERTS_VSN=\"$ERTS_VSN\" \ - -DOTP_RELEASE=\"$OTP_RELEASE\" \ - -c "$MOB_DIR/ios/mob_beam.m" -o "$BUILD_DIR/mob_beam.o" - - SQLITE_FLAG="" - [ -n "$SQLITE_STATIC_LIB" ] && SQLITE_FLAG="-DMOB_STATIC_SQLITE_NIF" - # driver_tab now lives in priv/generated (per-app, regenerated via - # `mix mob.regen_driver_tab --format c`), not $MOB_DIR/ios. - # MOB_DRIVER_TAB_DEFINES: -D for each guarded project NIF built for - # this target, so its #ifdef'd row stays in the table (MOB-373). - $CC $IFLAGS $SQLITE_FLAG $MOB_DRIVER_TAB_DEFINES \ - -c "priv/generated/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" - - # erl_errno_id stub: BEAM's erl_posix_str.o references - # erl_errno_id_unknown but the bundled OTP doesn't define it. Weak so - # an OTP-internal definition wins if one ever appears. Written with - # printf (not a heredoc) to stay cleanly indentable inside this - # Elixir \""" string. NOTE the single backslash: this is a ~S (raw) heredoc, - # so '%s\\n' would reach bash verbatim and printf would emit a literal - # backslash-n into the C file (clang then rejects `}\n`). '%s\n' emits a real - # newline. - printf '%s\n' '__attribute__((weak)) const char *erl_errno_id_unknown(int error) { (void)error; return "unknown"; }' > "$BUILD_DIR/erl_errno_id_compat.c" - $CC $IFLAGS -c "$BUILD_DIR/erl_errno_id_compat.c" -o "$BUILD_DIR/erl_errno_id_compat.o" - - # ── Activated-plugin and project C/ObjC NIFs ────────────────────────────── - # driver_tab_ios references each activated plugin's and each project NIF's - # _nif_init; those definitions live in the plugin's iOS NIF source - # (priv/native/ios/.m, lang: :objc) or the project's c_src/.c. - # The dev build compiles these via build.zig -Dplugin_c_nifs and - # -Dproject_c_nifs; the release build must do the same or the final link dies - # with "Undefined symbols: __nif_init". The source basename is the NIF - # libname → -DSTATIC_ERLANG_NIF_LIBNAME= makes ERL_NIF_INIT emit - # _nif_init (erl_nif.h derives STATIC_ERLANG_NIF from it; passing both - # redefines it). -fmodules lets Clang autolink every framework the source - # @imports (a plugin may import frameworks beyond its manifest's declared set, - # e.g. Accelerate). -Os as the device build compiles NIF sources. - PLUGIN_OBJS="" - for SRC in $MOB_PLUGIN_IOS_NIF_SOURCES $MOB_PROJECT_NIF_SOURCES; do - NAME=$(basename "$SRC"); NAME="${NAME%.*}" - case "$SRC" in - *.m) ARC="-fobjc-arc" ;; - *) ARC="" ;; - esac - echo " NIF: $NAME ($SRC)" - $CC $ARC -Os -fmodules $IFLAGS \ - -DSTATIC_ERLANG_NIF_LIBNAME="$NAME" \ - -c "$SRC" -o "$BUILD_DIR/$NAME.o" - PLUGIN_OBJS="$PLUGIN_OBJS $BUILD_DIR/$NAME.o" - done - - # Frameworks the activated plugins declare (explicit, alongside -fmodules - # autolink above): -framework for each unique name. - PLUGIN_FRAMEWORK_FLAGS="" - for FW in $MOB_PLUGIN_IOS_FRAMEWORKS; do - PLUGIN_FRAMEWORK_FLAGS="$PLUGIN_FRAMEWORK_FLAGS -Xlinker -framework -Xlinker $FW" - done - - echo "=== Linking $APP_NAME (release, no EPMD) ===" - 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/AppDelegate.o" \ - "$BUILD_DIR/beam_main.o" \ - "$BUILD_DIR/erl_errno_id_compat.o" \ - $PLUGIN_OBJS \ - $LIBS \ - "$SQLITE_STATIC_LIB" \ - $MOB_PROJECT_STATIC_LIBS \ - $MOB_PLUGIN_STATIC_LIBS \ - -lz -lc++ -lpthread \ - -Xlinker -framework -Xlinker UIKit \ - -Xlinker -framework -Xlinker Foundation \ - -Xlinker -framework -Xlinker CoreGraphics \ - -Xlinker -framework -Xlinker QuartzCore \ - -Xlinker -framework -Xlinker SwiftUI \ - $PLUGIN_FRAMEWORK_FLAGS \ - -o "$BUILD_DIR/$APP_NAME" - - echo "=== Building .app bundle ===" - APP="$BUILD_DIR/$APP_NAME.app" - rm -rf "$APP" - mkdir -p "$APP" - cp "$BUILD_DIR/$APP_NAME" "$APP/" - - 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" - - # mob.exs ios_target_devices / ios_orientations / multi_window - # (MobDev.IosLayoutPlist): one PlistBuddy command per line. A Delete of an - # absent key fails harmlessly; a Print checks that multi_window: true has a - # scene manifest to extend; any other failure stops the build (set -e). - # Runs before the UIDeviceFamily default below, which then sees the key and - # keeps it. - if [ -n "$MOB_IOS_LAYOUT_PLIST_COMMANDS" ]; then - while IFS= read -r CMD; do - case "$CMD" in - "Delete "*) /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" 2>/dev/null || true ;; - "Print "*) /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" >/dev/null 2>&1 || { - echo "error: mob.exs multi_window: true needs UIApplicationSceneManifest -> UISceneConfigurations -> UIWindowSceneSessionRoleApplication (a SceneDelegate) in ios/Info.plist. Copy the UIApplicationSceneManifest dict from a newly generated app's ios/Info.plist (mix mob.new), or set multi_window: false" >&2 - exit 1 - } ;; - *) /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" ;; - esac - done <<< "$MOB_IOS_LAYOUT_PLIST_COMMANDS" - fi - - # mob.exs url_schemes (MobDev.UrlSchemes): PlistBuddy Adds computed from - # ios/Info.plist, appending one CFBundleURLTypes entry after the app's own - # and skipping schemes it declares. Every command must succeed (set -e). - if [ -n "$MOB_IOS_URL_TYPES_PLIST_COMMANDS" ]; then - while IFS= read -r CMD; do - /usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist" - done <<< "$MOB_IOS_URL_TYPES_PLIST_COMMANDS" - fi - - # Apple's App Store validator requires MinimumOSVersion and DTPlatformName - # in the bundle Info.plist (codes 90065/90507/90530). Both are derived - # from the build target — set them defensively here so any app gets - # them right without needing to remember to add them by hand. - # `Add` errors if the key already exists; fall through to `Set` for the - # idempotent case. - /usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string 17.0" "$APP/Info.plist" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c "Set :MinimumOSVersion 17.0" "$APP/Info.plist" - /usr/libexec/PlistBuddy -c "Add :DTPlatformName string iphoneos" "$APP/Info.plist" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c "Set :DTPlatformName iphoneos" "$APP/Info.plist" - - # The DT* keys ("Development Tools") record what built the bundle. - # App Store Connect's validator (error 90534) cross-references - # DTSDKBuild + DTXcodeBuild against an allow-list of accepted Xcode - # release versions. Without them the upload is rejected as "built - # with an unsupported SDK or Xcode version" even when Xcode is current. - SDK_VERSION=$(xcrun --sdk iphoneos --show-sdk-version) - SDK_BUILD=$(xcrun --sdk iphoneos --show-sdk-build-version) - XCODE_RAW=$(xcodebuild -version | head -1 | awk '{print $2}') - XCODE_BUILD=$(xcodebuild -version | sed -n '2p' | awk '{print $3}') - XCODE_MAJOR=$(echo "$XCODE_RAW" | cut -d. -f1) - XCODE_MINOR=$(echo "$XCODE_RAW" | cut -d. -f2) - [ -z "$XCODE_MINOR" ] && XCODE_MINOR=0 - XCODE_PATCH=$(echo "$XCODE_RAW" | cut -d. -f3) - [ -z "$XCODE_PATCH" ] && XCODE_PATCH=0 - # DTXcode encoding: e.g. "26.4" → "2640" (major × 1000 + minor × 10 + - # patch). Same scheme Xcode itself stamps into bundles. Computed via - # arithmetic so the result is always 4 digits regardless of how the - # version components were entered. - # Apple's encoding (per their IPA validator): Xcode 16.0 → 1600, - # 16.4 → 1640, 26.4 → 2640. Always 4 digits while major is 2-digit. - DTXCODE=$(( XCODE_MAJOR * 100 + XCODE_MINOR * 10 + XCODE_PATCH )) - - for kv in \ - "DTSDKName=iphoneos${SDK_VERSION}" \ - "DTSDKBuild=${SDK_BUILD}" \ - "DTPlatformVersion=${SDK_VERSION}" \ - "DTPlatformBuild=${SDK_BUILD}" \ - "DTXcode=${DTXCODE}" \ - "DTXcodeBuild=${XCODE_BUILD}" \ - "DTCompiler=com.apple.compilers.llvm.clang.1_0" \ - "BuildMachineOSBuild=$(sw_vers -buildVersion)"; do - K="${kv%%=*}" - V="${kv#*=}" - /usr/libexec/PlistBuddy -c "Add :$K string $V" "$APP/Info.plist" 2>/dev/null \ - || /usr/libexec/PlistBuddy -c "Set :$K $V" "$APP/Info.plist" - done - # UIDeviceFamily is required when MinimumOSVersion >= 3.2 (always, in - # practice). 1 = iPhone, 2 = iPad. Default to iPhone-only for a plist that - # declares none (an app generated before mob_new declared [1, 2]); an app - # that sets the key in ios/Info.plist or mob.exs ios_target_devices keeps - # its value (the `Add` fails and we don't overwrite). - /usr/libexec/PlistBuddy -c "Add :UIDeviceFamily array" "$APP/Info.plist" 2>/dev/null \ - && /usr/libexec/PlistBuddy -c "Add :UIDeviceFamily:0 integer 1" "$APP/Info.plist" - - # CFBundleSupportedPlatforms: array with one string identifying the - # platform the binary was built for. "iPhoneOS" for device builds, - # "iPhoneSimulator" for sim. Apple validator error 90562 if missing. - /usr/libexec/PlistBuddy -c "Add :CFBundleSupportedPlatforms array" "$APP/Info.plist" 2>/dev/null \ - && /usr/libexec/PlistBuddy -c "Add :CFBundleSupportedPlatforms:0 string iPhoneOS" "$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 "=== Slim strip pass ===" + + slim_step prefix_libs bash -c ' + # Note: compiler intentionally kept — Ecto.Migrator compiles + # .exs migration files at runtime via Code.compile_file, which + # requires the :compiler OTP app. Stripping it lands a + # `{:badmatch, {:error, :enoent, :"compiler.app"}}` deep in + # application_controller during app boot, so the BEAM never + # reaches the first screen. + # A lib the app needs (MOB_SLIM_KEEP_LIBS, set by release.ex from + # MobDev.OtpRequiredApps) is never stripped: starting an app whose + # .app lists a missing lib fails on the device. + for prefix in megaco runtime_tools erl_interface os_mon wx et eunit \ + observer debugger diameter edoc tools snmp dialyzer \ + syntax_tools parsetools xmerl reltool inets ftp tftp \ + common_test mnesia eldap odbc \ + ssh; do + case " ${MOB_SLIM_KEEP_LIBS:-} " in + *" $prefix "*) echo " keeping $prefix (the app needs it)"; continue ;; + esac + rm -rf "'"$OTP_BUNDLE"'/lib/$prefix-"* + done + ' + + slim_step foreign_apps bash -c ' + for prefix in toy_ test_ mob_test scratch_; do + rm -rf "'"$OTP_BUNDLE"'/lib/$prefix"*-* + done + ' + + slim_step dedup_versions bash -c ' + set +e + cd "'"$OTP_BUNDLE"'/lib" + for name in $(ls -1 2>/dev/null | sed "s/-[0-9].*$//" | sort -u); do + versions=$(ls -1d "${name}"-[0-9]* 2>/dev/null | sort -V) + [ -z "$versions" ] && continue + count=$(printf "%s\n" "$versions" | wc -l | tr -d " ") + if [ "$count" -gt 1 ]; then + latest=$(printf "%s\n" "$versions" | tail -1) + for v in $versions; do + [ "$v" != "$latest" ] && rm -rf "$v" + done + fi + done + ' + + slim_step src_and_headers find "$OTP_BUNDLE" -type d \( -name src -o -name include \) -prune -exec rm -rf {} + + + slim_step beam_chunks erl -noinput -boot start_clean -eval " + case beam_lib:strip_release(\"$OTP_BUNDLE\") of + {ok, _} -> erlang:halt(0); + {error, beam_lib, R} -> + io:format(standard_error, \" strip_release error: ~p~n\", [R]), + erlang:halt(1) + end." + else + echo "[SLIM:skipped] MOB_SLIM=0 — keeping full OTP runtime" + fi + + echo " $(find "$OTP_BUNDLE" -type f | wc -l | tr -d ' ') files in bundle after strip" + + # Strip non-global symbols from the main Mach-O — slim only. + # MUST happen before codesigning since strip rewrites the file. + if [ "${MOB_SLIM:-1}" = "1" ]; then + echo "=== Stripping non-global symbols from main binary ===" + SIZE_BEFORE_STRIP=$(stat -f%z "$APP/$APP_NAME") + xcrun strip -x "$APP/$APP_NAME" + SIZE_AFTER_STRIP=$(stat -f%z "$APP/$APP_NAME") + echo " $APP_NAME: $((SIZE_BEFORE_STRIP / 1024)) KB → $((SIZE_AFTER_STRIP / 1024)) KB" + fi + + echo "=== Embedding App Store provisioning profile ===" + PROFILE_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" + PROFILE="$PROFILE_DIR/${PROFILE_UUID}.mobileprovision" + if [ ! -f "$PROFILE" ]; then + PROFILE="$HOME/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" + fi + if [ ! -f "$PROFILE" ]; then + echo "ERROR: Provisioning profile $PROFILE_UUID not found." + exit 1 + fi + cp "$PROFILE" "$APP/embedded.mobileprovision" + + echo "=== Code signing (distribution, no get-task-allow) ===" + ENTITLEMENTS_FILE="$BUILD_DIR/mob_release.entitlements" + cat > "$ENTITLEMENTS_FILE" << ENTEOF + + + + + application-identifier + ${TEAM_ID}.${BUNDLE_ID} + com.apple.developer.team-identifier + ${TEAM_ID} + beta-reports-active + + + + ENTEOF + codesign --force --sign "$SIGN_IDENTITY" \ + --entitlements "$ENTITLEMENTS_FILE" \ + --timestamp \ + --options runtime \ + "$APP" + + echo "=== Verifying signature ===" + codesign --verify --deep --strict --verbose=2 "$APP" + + echo "=== Packaging IPA ===" + # `ditto -c -k --keepParent` (rather than plain `zip -r`) preserves + # symlinks and bundle structure that App Store Connect's validator + # checks (error code 90071: "CodeResources must be a symbolic link"). + # Skip --sequesterRsrc — that's for macOS resource forks, not iOS; + # adding it injects a __MACOSX/ sidecar tree that confuses the + # validator. + # cp -RP preserves symlinks (plain cp -R follows them and turns them + # into regular files, which would defeat the whole exercise). + IPA_STAGE="$MOB_RELEASE_SCRATCH/ipa" + mkdir -p "$IPA_STAGE/Payload" + cp -RP "$APP" "$IPA_STAGE/Payload/" + # `dot_clean` removes the macOS AppleDouble (`._`) sidecars + # that get created when `cp` preserves extended attributes across + # filesystems. Apple's validator can flag these. + dot_clean -m "$IPA_STAGE/Payload" 2>/dev/null || true + find "$IPA_STAGE/Payload" -name '._*' -delete 2>/dev/null || true + IPA_PATH="$OUTPUT_DIR/$APP_NAME.ipa" + rm -f "$IPA_PATH" + # --norsrc / --noextattr / --noqtn: don't preserve resource forks, + # extended attributes, or quarantine flags. Without these, ditto + # creates `._` AppleDouble sidecars inside the IPA for any + # source file that happens to have an xattr (the OTP cross-build + # leaves a bunch of these on the cached output). Apple's validator + # generally tolerates them but the IPA is cleaner without. + (cd "$IPA_STAGE" && ditto -c -k --norsrc --noextattr --noqtn --keepParent Payload "$IPA_PATH") + + echo "=== Done: $IPA_PATH ($(du -h "$IPA_PATH" | cut -f1)) ===" + """ + end - echo "=== Bundling OTP runtime (no EPMD binary path) ===" - 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" - - # ── App Store bundle policy: ONE Mach-O per .app, no .so/.a/standalone ── - # Apple's validator rejects the bundle if it contains any of: - # - dynamic loadable libraries (.so files for NIFs/drivers) - # - static archives (.a — these are linked into the main binary at - # build time, but copying them into the bundle is still rejected) - # - standalone executable files (erl_call, memsup, beam.smp, etc.) - # Strip them all from the bundled OTP tree. The static archives are - # already linked into $APP_NAME (the main Mach-O); the .so files - # belong to OTP libs the app doesn't actually use (megaco, - # runtime_tools, asn1's dynamic variant). - # ── Apple-policy strips (always on; not optional for App Store) ── - # Apple's validator rejects bundles containing .so/.a (frameworks must - # use .framework), priv/bin executables, or extra binaries in erts-*/bin. - # The BEAM is static-linked into the main Mach-O so these are - # unreachable from runtime anyway. NOT gated on MOB_SLIM — even - # `--no-slim` builds need to pass App Store validation. - echo "=== Stripping App-Store-disallowed binaries (always on) ===" - find "$OTP_BUNDLE" -type f \( -name "*.so" -o -name "*.a" \) -delete - find "$OTP_BUNDLE" -path "*/priv/bin/*" -type f -delete - find "$OTP_BUNDLE/$ERTS_VSN/bin" -type f -delete 2>/dev/null || true - # Standalone executables inside OTP libs (e.g. erl_interface/bin/erl_call) - # are also rejected by App Store validation (90171) and can't exec on iOS - # anyway. Remove every lib/*/bin/* executable while keeping the libs' - # .beam/.app — so a --no-slim full-OTP bundle (needed for runtime Mix.install) - # still passes Apple's "no standalone executables" rule. - find "$OTP_BUNDLE/lib" -path "*/bin/*" -type f -delete 2>/dev/null || true - - # ── Slim strips (gated; opt out with `mix mob.release --no-slim`) ── - # Each step echoes a tagged header AND the bundle size delta so a - # broken build can be traced to a specific step. The grep-friendly tag - # `[SLIM:]` is what the docs walkthrough searches for. - if [ "${MOB_SLIM:-1}" = "1" ]; then - # Helper to log size delta around a step. Bash function so each - # step's size delta is visible in the build log without bespoke code. - slim_step() { - local label=$1 - local before=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') - shift - "$@" - local after=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') - local delta=$((before - after)) - printf "[SLIM:%s] %s KB → %s KB (-%s KB)\n" "$label" "$before" "$after" "$delta" - } - - echo "=== Slim strip pass ===" - - slim_step prefix_libs bash -c ' - # Note: compiler intentionally kept — Ecto.Migrator compiles - # .exs migration files at runtime via Code.compile_file, which - # requires the :compiler OTP app. Stripping it lands a - # `{:badmatch, {:error, :enoent, :"compiler.app"}}` deep in - # application_controller during app boot, so the BEAM never - # reaches the first screen. - # A lib the app needs (MOB_SLIM_KEEP_LIBS, set by release.ex from - # MobDev.OtpRequiredApps) is never stripped: starting an app whose - # .app lists a missing lib fails on the device. - for prefix in megaco runtime_tools erl_interface os_mon wx et eunit \ - observer debugger diameter edoc tools snmp dialyzer \ - syntax_tools parsetools xmerl reltool inets ftp tftp \ - common_test mnesia eldap odbc \ - ssh; do - case " ${MOB_SLIM_KEEP_LIBS:-} " in - *" $prefix "*) echo " keeping $prefix (the app needs it)"; continue ;; - esac - rm -rf "'"$OTP_BUNDLE"'/lib/$prefix-"* - done - ' - - slim_step foreign_apps bash -c ' - for prefix in toy_ test_ mob_test scratch_; do - rm -rf "'"$OTP_BUNDLE"'/lib/$prefix"*-* - done - ' - - slim_step dedup_versions bash -c ' - set +e - cd "'"$OTP_BUNDLE"'/lib" - for name in $(ls -1 2>/dev/null | sed "s/-[0-9].*$//" | sort -u); do - versions=$(ls -1d "${name}"-[0-9]* 2>/dev/null | sort -V) - [ -z "$versions" ] && continue - count=$(printf "%s\n" "$versions" | wc -l | tr -d " ") - if [ "$count" -gt 1 ]; then - latest=$(printf "%s\n" "$versions" | tail -1) - for v in $versions; do - [ "$v" != "$latest" ] && rm -rf "$v" - done - fi - done - ' - - slim_step src_and_headers find "$OTP_BUNDLE" -type d \( -name src -o -name include \) -prune -exec rm -rf {} + - - slim_step beam_chunks erl -noinput -boot start_clean -eval " - case beam_lib:strip_release(\"$OTP_BUNDLE\") of - {ok, _} -> erlang:halt(0); - {error, beam_lib, R} -> - io:format(standard_error, \" strip_release error: ~p~n\", [R]), - erlang:halt(1) - end." + @doc false + # The part of `release_device.sh` that picks where the build's scratch goes: + # the objects, the linked binary, the `.app` before packaging, the exqlite + # objects, actool's partial plist and the IPA staging dir. None of it is read + # after the `.ipa` lands in `$OUTPUT_DIR`. `build_ipa/1` passes a directory + # from `MobDev.NativeBuild.with_temp_build_dir/2` as `MOB_RELEASE_BUILD_DIR` + # and removes it on every exit path; a direct run of the script makes one + # under `$TMPDIR` (bare macOS `mktemp -d` ignores TMPDIR) and removes it on + # exit, success or failure (MOB-425). Public so a test can run it on its own. + @spec release_scratch_sh() :: String.t() + def release_scratch_sh do + ~S""" + if [ -n "${MOB_RELEASE_BUILD_DIR:-}" ]; then + MOB_RELEASE_SCRATCH="$MOB_RELEASE_BUILD_DIR" else - echo "[SLIM:skipped] MOB_SLIM=0 — keeping full OTP runtime" - fi - - echo " $(find "$OTP_BUNDLE" -type f | wc -l | tr -d ' ') files in bundle after strip" - - # Strip non-global symbols from the main Mach-O — slim only. - # MUST happen before codesigning since strip rewrites the file. - if [ "${MOB_SLIM:-1}" = "1" ]; then - echo "=== Stripping non-global symbols from main binary ===" - SIZE_BEFORE_STRIP=$(stat -f%z "$APP/$APP_NAME") - xcrun strip -x "$APP/$APP_NAME" - SIZE_AFTER_STRIP=$(stat -f%z "$APP/$APP_NAME") - echo " $APP_NAME: $((SIZE_BEFORE_STRIP / 1024)) KB → $((SIZE_AFTER_STRIP / 1024)) KB" - fi - - echo "=== Embedding App Store provisioning profile ===" - PROFILE_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" - PROFILE="$PROFILE_DIR/${PROFILE_UUID}.mobileprovision" - if [ ! -f "$PROFILE" ]; then - PROFILE="$HOME/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" - fi - if [ ! -f "$PROFILE" ]; then - echo "ERROR: Provisioning profile $PROFILE_UUID not found." - exit 1 + MOB_RELEASE_SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/mob_release.XXXXXX") + trap 'rm -rf "$MOB_RELEASE_SCRATCH"' EXIT fi - cp "$PROFILE" "$APP/embedded.mobileprovision" - - echo "=== Code signing (distribution, no get-task-allow) ===" - ENTITLEMENTS_FILE="$BUILD_DIR/mob_release.entitlements" - cat > "$ENTITLEMENTS_FILE" << ENTEOF - - - - - application-identifier - ${TEAM_ID}.${BUNDLE_ID} - com.apple.developer.team-identifier - ${TEAM_ID} - beta-reports-active - - - - ENTEOF - codesign --force --sign "$SIGN_IDENTITY" \ - --entitlements "$ENTITLEMENTS_FILE" \ - --timestamp \ - --options runtime \ - "$APP" - - echo "=== Verifying signature ===" - codesign --verify --deep --strict --verbose=2 "$APP" - - echo "=== Packaging IPA ===" - # `ditto -c -k --keepParent` (rather than plain `zip -r`) preserves - # symlinks and bundle structure that App Store Connect's validator - # checks (error code 90071: "CodeResources must be a symbolic link"). - # Skip --sequesterRsrc — that's for macOS resource forks, not iOS; - # adding it injects a __MACOSX/ sidecar tree that confuses the - # validator. - # cp -RP preserves symlinks (plain cp -R follows them and turns them - # into regular files, which would defeat the whole exercise). - IPA_STAGE=$(mktemp -d) - mkdir -p "$IPA_STAGE/Payload" - cp -RP "$APP" "$IPA_STAGE/Payload/" - # `dot_clean` removes the macOS AppleDouble (`._`) sidecars - # that get created when `cp` preserves extended attributes across - # filesystems. Apple's validator can flag these. - dot_clean -m "$IPA_STAGE/Payload" 2>/dev/null || true - find "$IPA_STAGE/Payload" -name '._*' -delete 2>/dev/null || true - IPA_PATH="$OUTPUT_DIR/$APP_NAME.ipa" - rm -f "$IPA_PATH" - # --norsrc / --noextattr / --noqtn: don't preserve resource forks, - # extended attributes, or quarantine flags. Without these, ditto - # creates `._` AppleDouble sidecars inside the IPA for any - # source file that happens to have an xattr (the OTP cross-build - # leaves a bunch of these on the cached output). Apple's validator - # generally tolerates them but the IPA is cleaner without. - (cd "$IPA_STAGE" && ditto -c -k --norsrc --noextattr --noqtn --keepParent Payload "$IPA_PATH") - rm -rf "$IPA_STAGE" - - echo "=== Done: $IPA_PATH ($(du -h "$IPA_PATH" | cut -f1)) ===" + BUILD_DIR="$MOB_RELEASE_SCRATCH/build" + mkdir -p "$BUILD_DIR" """ end end diff --git a/test/mob_dev/plugin/ios_link_check_test.exs b/test/mob_dev/plugin/ios_link_check_test.exs new file mode 100644 index 0000000..cff67e8 --- /dev/null +++ b/test/mob_dev/plugin/ios_link_check_test.exs @@ -0,0 +1,180 @@ +defmodule MobDev.Plugin.IosLinkCheckTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.IosLinkCheck + + describe "against real archives (MOB-470's shape)" do + # Needs Xcode's iOS simulator SDK: builds tiny archives with clang. + @describetag :macos_only + @describetag :tmp_dir + + setup %{tmp_dir: tmp} do + # A stand-in OTP runtime: its libbeam.a defines the one enif_* the + # fixtures call, the way the real one defines the whole API. + otp = Path.join(tmp, "otp") + File.mkdir_p!(Path.join(otp, "erts-99/lib")) + + beam_o = + compile!(tmp, "erl_nif.c", "int enif_make_atom(void *env, const char *n) { return 0; }") + + ar!(Path.join(otp, "erts-99/lib/libbeam.a"), [beam_o]) + + # A stand-in mob checkout: mob core defines the permission hook plugins call. + mob = Path.join(tmp, "mob") + File.mkdir_p!(Path.join(mob, "ios")) + + File.write!(Path.join(mob, "ios/mob_nif.m"), """ + static int helper(void) { return 0; } + void mob_register_permission_handler(const char *cap, void (*fn)(void *pid)) { + (void)cap; (void)fn; + } + """) + + {:ok, otp: otp, mob: mob, spec: %{plugin: :fixture_plugin, module: :fixture_nif}} + end + + test "an archive whose undefined symbols the BEAM, mob core and the SDK define passes", + %{tmp_dir: tmp, otp: otp, mob: mob, spec: spec} do + archive = + archive!(tmp, "clean", """ + #include + extern int enif_make_atom(void *env, const char *n); + extern void mob_register_permission_handler(const char *cap, void (*fn)(void *pid)); + static void on_permission(void *pid) { (void)pid; } + int fixture_nif_nif_init(void) { + mob_register_permission_handler("fixture", on_permission); + return enif_make_atom(0, "ok") + (int)(long)malloc(1); + } + """) + + assert IosLinkCheck.check(spec, :ios_sim, archive, [], otp_dir: otp, mob_dir: mob) == :ok + end + + test "a dangling reference in code a dead-stripping link drops fails, naming plugin, symbol and member", + %{tmp_dir: tmp, otp: otp, mob: mob, spec: spec} do + archive = + archive!(tmp, "mob470", """ + extern int enif_make_atom(void *env, const char *n); + extern void *ggml_dl_load_library(const char *path); + int fixture_nif_nif_init(void) { return enif_make_atom(0, "ok"); } + // Nothing calls this, so -dead_strip removes it (MOB-470: ggml's dl_* helpers). + void *fixture_load_backend(const char *p) { return ggml_dl_load_library(p); } + """) + + # The shape that hid MOB-470: the app's dead-stripping link passes, the + # same link without -dead_strip fails. + assert {_, 0} = link(tmp, archive, ["-Xlinker", "-dead_strip"], otp) + assert {_, status} = link(tmp, archive, [], otp) + assert status != 0 + + assert {:error, msg} = + IosLinkCheck.check(spec, :ios_sim, archive, [], otp_dir: otp, mob_dir: mob) + + assert msg =~ "fixture_plugin" + assert msg =~ "_ggml_dl_load_library" + assert msg =~ "mob470.o" + refute msg =~ "_enif_make_atom" + end + + test "mob core's symbols are allowed only from the mob checkout it is given", + %{tmp_dir: tmp, otp: otp, spec: spec} do + archive = + archive!(tmp, "uses_mob", """ + extern void mob_register_permission_handler(const char *cap, void (*fn)(void *pid)); + int fixture_nif_nif_init(void) { mob_register_permission_handler("x", 0); return 0; } + """) + + assert {:error, msg} = IosLinkCheck.check(spec, :ios_sim, archive, [], otp_dir: otp) + assert msg =~ "_mob_register_permission_handler" + end + end + + describe "defined_symbols/1" do + test "lists external functions, globals and ObjC classes, not static ones" do + source = """ + static int hidden(void) { return 0; } + static const char *also_hidden = "x"; + extern int declared_elsewhere(void); + int counter = 0; + void mob_register_permission_handler(const char *cap, + void (*fn)(void *pid)) { + (void)cap; + } + @implementation MobNode + @end + """ + + assert Enum.sort(IosLinkCheck.defined_symbols(source)) == + Enum.sort([ + "_counter", + "_mob_register_permission_handler", + "_OBJC_CLASS_$_MobNode", + "_OBJC_METACLASS_$_MobNode" + ]) + end + end + + describe "tbd_name/1" do + test "ObjC class symbols are looked up by class name, C symbols as they are" do + assert IosLinkCheck.tbd_name("_OBJC_CLASS_$_EAGLContext") == "EAGLContext" + assert IosLinkCheck.tbd_name("_OBJC_METACLASS_$_UIView") == "UIView" + assert IosLinkCheck.tbd_name("_glBindBuffer") == "_glBindBuffer" + end + end + + describe "sdk_exports/2" do + @describetag :tmp_dir + + test "matches whole names only", %{tmp_dir: tmp} do + File.write!(Path.join(tmp, "Lib.tbd"), """ + exports: + - symbols: [ _glBindBuffer, _glBindBufferBase ] + objc-classes: [ EAGLContext ] + """) + + assert IosLinkCheck.sdk_exports(tmp, ["_glBindBuffer", "_glBind", "EAGLContext", "EAGL"]) == + MapSet.new(["_glBindBuffer", "EAGLContext"]) + end + end + + defp compile!(dir, name, source) do + src = Path.join(dir, name) + obj = Path.rootname(src) <> ".o" + File.write!(src, source) + + {out, status} = + System.cmd( + "xcrun", + ["-sdk", "iphonesimulator", "clang", "-arch", "arm64"] ++ + ["-mios-simulator-version-min=17.0", "-ffunction-sections", "-c", src, "-o", obj], + stderr_to_stdout: true + ) + + assert status == 0, out + obj + end + + defp ar!(archive, objects) do + {out, status} = System.cmd("xcrun", ["ar", "rcs", archive | objects], stderr_to_stdout: true) + assert status == 0, out + archive + end + + defp archive!(dir, name, source) do + ar!(Path.join(dir, "lib#{name}.a"), [compile!(dir, "#{name}.c", source)]) + end + + # A link like the app's: the archive's init symbol is the root, the BEAM + # supplies enif_*. + defp link(dir, archive, flags, otp) do + System.cmd( + "xcrun", + ["-sdk", "iphonesimulator", "clang", "-arch", "arm64", "-mios-simulator-version-min=17.0"] ++ + ["-dynamiclib", "-Wl,-u,_fixture_nif_nif_init"] ++ + ["-Wl,-exported_symbol,_fixture_nif_nif_init", archive] ++ + [Path.join(otp, "erts-99/lib/libbeam.a")] ++ + flags ++ ["-o", Path.join(dir, "app.dylib")], + stderr_to_stdout: true + ) + end +end diff --git a/test/mob_dev/release_scratch_test.exs b/test/mob_dev/release_scratch_test.exs new file mode 100644 index 0000000..0de8700 --- /dev/null +++ b/test/mob_dev/release_scratch_test.exs @@ -0,0 +1,69 @@ +defmodule MobDev.ReleaseScratchTest do + # Runs the scratch prologue of release_device.sh (MOB-425): the build's + # scratch (objects, the linked binary, the .app, the IPA staging dir) must + # live under one directory that is gone when the script exits, whether the + # build succeeds or fails, and a direct run must honour TMPDIR (bare macOS + # `mktemp -d` ignores it and writes to DARWIN_USER_TEMP_DIR). + use ExUnit.Case, async: true + + alias MobDev.Release + + @moduletag :tmp_dir + + # The prologue, then what the build does with BUILD_DIR, then `tail`. + defp run_prologue(env, tail) do + script = """ + set -e + #{Release.release_scratch_sh()} + echo "BUILD_DIR=$BUILD_DIR" + touch "$BUILD_DIR/MobNode.o" + mkdir -p "$MOB_RELEASE_SCRATCH/ipa/Payload" + #{tail} + """ + + {out, status} = System.cmd("bash", ["-c", script], env: env, stderr_to_stdout: true) + [_, build_dir] = Regex.run(~r/^BUILD_DIR=(.*)$/m, out) + {status, build_dir} + end + + describe "a direct run (no MOB_RELEASE_BUILD_DIR)" do + setup %{tmp_dir: tmp} do + tmpdir = Path.join(tmp, "TMPDIR") + File.mkdir_p!(tmpdir) + {:ok, tmpdir: tmpdir, env: [{"TMPDIR", tmpdir}, {"MOB_RELEASE_BUILD_DIR", nil}]} + end + + test "puts its scratch under TMPDIR and removes it when the build fails", + %{tmpdir: tmpdir, env: env} do + {status, build_dir} = run_prologue(env, "false") + + assert status != 0 + assert String.starts_with?(Path.expand(build_dir), Path.expand(tmpdir)) + assert File.ls!(tmpdir) == [] + end + + test "removes its scratch when the build succeeds", %{tmpdir: tmpdir, env: env} do + {status, build_dir} = run_prologue(env, "true") + + assert status == 0 + assert String.starts_with?(Path.expand(build_dir), Path.expand(tmpdir)) + assert File.ls!(tmpdir) == [] + end + end + + test "with MOB_RELEASE_BUILD_DIR, the scratch goes in that directory and nowhere else", + %{tmp_dir: tmp} do + given = Path.join(tmp, "given") + tmpdir = Path.join(tmp, "TMPDIR") + File.mkdir_p!(given) + File.mkdir_p!(tmpdir) + + {status, build_dir} = + run_prologue([{"TMPDIR", tmpdir}, {"MOB_RELEASE_BUILD_DIR", given}], "true") + + assert status == 0 + assert Path.dirname(build_dir) == given + assert File.exists?(Path.join(build_dir, "MobNode.o")) + assert File.ls!(tmpdir) == [] + end +end diff --git a/test/mob_dev/release_script_test.exs b/test/mob_dev/release_script_test.exs index 38af103..353e61e 100644 --- a/test/mob_dev/release_script_test.exs +++ b/test/mob_dev/release_script_test.exs @@ -194,6 +194,28 @@ defmodule MobDev.ReleaseScriptTest do end end + describe "the release link dead-strips like the dev links (MOB-471)" do + # A dev build (mob_new's ios/build.zig, ios/build_device.zig) and + # `mix mob.release --ios` must agree on -dead_strip: when only one side + # strips, a plugin's dangling reference in dead code passes one and fails + # the other (MOB-470). The templates come from mob_new, as mob.adopt's do. + test "the release script and both dev build templates pass -dead_strip to the linker", + %{sh: sh} do + root = MobDev.Adopt.Generator.templates_root([]) + + [sim, device] = + for file <- ["ios/build.zig.eex", "ios/build_device.zig.eex"] do + root |> Path.join(file) |> File.read!() =~ ~s("-Xlinker", "-dead_strip") + end + + [_, link] = String.split(sh, ~s|echo "=== Linking $APP_NAME|, parts: 2) + [link | _] = String.split(link, ~s|-o "$BUILD_DIR/$APP_NAME"|, parts: 2) + release = link =~ "-Xlinker -dead_strip" + + assert {sim, device} == {release, release} + end + end + describe "plugin Swift sources + bootstrap are compiled into the app module" do # AppDelegate.m calls mob_register_plugins() unconditionally; the generated # bootstrap defines it. The script compiled only $MOB_DIR/ios/*.swift, so