From d0b0c414adb37dd6dc5346cefa887dd4be3b356b Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 7 Jul 2026 23:32:09 -0600 Subject: [PATCH 1/2] iOS release: compile + link activated-plugin NIFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release path (release.ex/release_device.sh) hand-compiled a fixed object list and never touched plugins, so any app with NIF plugins failed to link: driver_tab_ios references each activated plugin's _nif_init but nothing defined them (iOS statically links all NIFs — no dlopen). The dev path already compiles them via build.zig -Dplugin_c_nifs; this brings the release path to parity. release_env/2 now emits MOB_PLUGIN_IOS_NIF_SOURCES + MOB_PLUGIN_IOS_FRAMEWORKS from MobDev.Plugin.activated() (via the pure, tested plugin_ios_build_env/1). release_device.sh compiles each source with -DSTATIC_ERLANG_NIF_LIBNAME= (-> _nif_init) and -fmodules (framework autolink), links the objects, and passes the declared frameworks explicitly. Verified: Sloppy Joe's 10 ObjC NIF plugins compile + link; the IPA code-signs and validates against its App Store profile. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-07-ios-release-links-plugin-nifs.md | 65 +++++++++++++++ lib/mob_dev/release.ex | 61 ++++++++++++++ test/mob_dev/release_script_test.exs | 39 +++++++++ test/mob_dev/release_test.exs | 79 +++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 decisions/2026-07-07-ios-release-links-plugin-nifs.md diff --git a/decisions/2026-07-07-ios-release-links-plugin-nifs.md b/decisions/2026-07-07-ios-release-links-plugin-nifs.md new file mode 100644 index 0000000..5898c06 --- /dev/null +++ b/decisions/2026-07-07-ios-release-links-plugin-nifs.md @@ -0,0 +1,65 @@ +# iOS release build compiles + links activated-plugin NIFs + +- Date: 2026-07-07 +- Status: accepted + +## Context + +`mix mob.release --ios` produced a binary that failed to link for any app with +NIF plugins: + +``` +Undefined symbols for architecture arm64: + "_mob_camera_nif_nif_init", referenced from: + _erts_static_nif_tab in driver_tab_ios.o + ... (one per activated plugin) +``` + +iOS statically links every NIF into the single app binary (no `dlopen` under the +App Store sandbox), so `priv/generated/driver_tab_ios.c` references each activated +plugin's `_nif_init`. The **dev** path (`native_build.ex` → +`build.zig -Dplugin_c_nifs`, fed by `MobDev.Plugin.Merge.nif_sources/2`) compiles +those sources in, which is why all plugins work on-device in dev. But the +**release** path (`release.ex` → `release_device.sh`) is a separate hand-rolled +clang/swiftc script that predates plugin support: it compiled a fixed object list +(MobNode, mob_nif, mob_beam, driver_tab, …) and never touched plugins. Result: the +driver table declared the symbols, nothing defined them, link failed. + +Surfaced shipping Sloppy Joe (activates all 10 capability plugins) to the App +Store. Android was unaffected — it loads NIFs from per-ABI `.so`s, not a single +static binary. + +## Decision + +Bring the release path to parity with the dev path. `release_env/2` now emits two +env vars from `MobDev.Plugin.activated()`, via the pure, unit-tested +`Release.plugin_ios_build_env/1`: + +- `MOB_PLUGIN_IOS_NIF_SOURCES` — absolute paths of each activated plugin's iOS + C/ObjC NIF source (`Merge.nif_sources(activated, :ios)`). +- `MOB_PLUGIN_IOS_FRAMEWORKS` — the union of frameworks the plugins declare + (`Merge.ios_frameworks/1`). + +`release_device.sh` loops over the sources, compiling each with +`-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=` (so `ERL_NIF_INIT` +emits `_nif_init`, matching the driver table) and `-fmodules` (Clang +autolinks every framework the source `@import`s — a plugin often imports beyond its +manifest's declared set, e.g. Accelerate). The compiled objects join the swiftc +link line, and each declared framework is also passed explicitly. + +## Consequences + +- Any multi-plugin mob app can now produce a store-ready iOS binary. Verified: + Sloppy Joe's 10 ObjC NIF plugins compile + link, and the IPA code-signs + + validates against its App Store profile. +- Scope: this covers `nif_sources` (`lang: :c | :objc`) + `ios_frameworks`, which + is what every current plugin uses. The dev path also handles plugin + `swift_files` and `static_archives` (`:cpp_archive`, e.g. mob_nx_eigen); the + release path does **not** yet. No shipped plugin needs those on iOS today, so + they're a documented follow-up rather than untested code. A plugin that adds an + iOS Swift source or cpp-archive NIF would relink-fail the same way until then. +- Tests: `plugin_ios_build_env/1` gets a pure matrix (none / one / many / + platform-filtered) in `release_test.exs`; `release_script_test.exs` asserts the + script shape (compile loop, libname derivation, `$PLUGIN_OBJS` on the link, + framework flags) so a regression is caught at `mix test`, not in a TestFlight + round trip. diff --git a/lib/mob_dev/release.ex b/lib/mob_dev/release.ex index bebd47c..da524bf 100644 --- a/lib/mob_dev/release.ex +++ b/lib/mob_dev/release.ex @@ -353,6 +353,35 @@ defmodule MobDev.Release do {"MOB_IOS_PROFILE_UUID", cfg[:ios_dist_profile_uuid]}, {"MOB_APP_NAME", app_name}, {"MOB_APP_MODULE", app_module} + ] ++ plugin_ios_build_env(MobDev.Plugin.activated()) + end + + @doc false + # Env vars that drive `release_device.sh`'s activated-plugin NIF compile + link + # step. Pure over the activated-plugin list (each `{plugin_dir, manifest}`, the + # shape `MobDev.Plugin.activated/0` returns) so it can be unit-tested without a + # real deps tree. + # + # - `MOB_PLUGIN_IOS_NIF_SOURCES` — space-joined absolute paths of each activated + # plugin's iOS C/ObjC NIF source (`priv/native/ios/.m`). The generated + # `driver_tab_ios` references every activated plugin's `_nif_init`, so + # the release link fails with "Undefined symbols: __nif_init" unless + # these are compiled in. Each source's basename is the NIF libname → the script + # compiles it with `-DSTATIC_ERLANG_NIF_LIBNAME=` so `ERL_NIF_INIT` + # emits `_nif_init`, matching the table. Mirrors the dev path's + # `build.zig -Dplugin_c_nifs` (`MobDev.Plugin.Merge.nif_sources/2`). + # - `MOB_PLUGIN_IOS_FRAMEWORKS` — space-joined union of the frameworks the + # activated plugins' iOS code drives. Belt-and-suspenders: the sources are + # compiled with `-fmodules` (Clang autolinks every imported framework), and + # these are also passed explicitly to the linker. + @spec plugin_ios_build_env([MobDev.Plugin.Merge.plugin()]) :: [{String.t(), String.t()}] + def plugin_ios_build_env(activated) do + sources = activated |> MobDev.Plugin.Merge.nif_sources(:ios) |> Enum.map(&Path.expand/1) + frameworks = MobDev.Plugin.Merge.ios_frameworks(activated) + + [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", Enum.join(sources, " ")}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", Enum.join(frameworks, " ")} ] end @@ -591,6 +620,36 @@ defmodule MobDev.Release do 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 NIFs ───────────────────────────────────────────────── + # driver_tab_ios references each activated plugin's _nif_init; those + # definitions live in the plugin's iOS NIF source (priv/native/ios/.m, + # lang: :objc). The dev build compiles these via build.zig -Dplugin_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. + # -fmodules lets Clang autolink every framework the source @imports (a plugin + # may import frameworks beyond its manifest's declared set, e.g. Accelerate). + PLUGIN_OBJS="" + for SRC in $MOB_PLUGIN_IOS_NIF_SOURCES; do + NAME=$(basename "$SRC"); NAME="${NAME%.*}" + case "$SRC" in + *.m) ARC="-fobjc-arc" ;; + *) ARC="" ;; + esac + echo " plugin NIF: $NAME ($SRC)" + $CC $ARC -fmodules $IFLAGS \ + -DSTATIC_ERLANG_NIF -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 \ @@ -602,6 +661,7 @@ defmodule MobDev.Release do "$BUILD_DIR/AppDelegate.o" \ "$BUILD_DIR/beam_main.o" \ "$BUILD_DIR/erl_errno_id_compat.o" \ + $PLUGIN_OBJS \ $LIBS \ "$SQLITE_STATIC_LIB" \ -lz -lc++ -lpthread \ @@ -610,6 +670,7 @@ defmodule MobDev.Release do -Xlinker -framework -Xlinker CoreGraphics \ -Xlinker -framework -Xlinker QuartzCore \ -Xlinker -framework -Xlinker SwiftUI \ + $PLUGIN_FRAMEWORK_FLAGS \ -o "$BUILD_DIR/$APP_NAME" echo "=== Building .app bundle ===" diff --git a/test/mob_dev/release_script_test.exs b/test/mob_dev/release_script_test.exs index 2c6195b..5842295 100644 --- a/test/mob_dev/release_script_test.exs +++ b/test/mob_dev/release_script_test.exs @@ -138,6 +138,45 @@ defmodule MobDev.ReleaseScriptTest do end end + describe "activated-plugin NIFs are compiled + linked" do + # driver_tab_ios references each activated plugin's _nif_init. + # Before this fix the release script hand-compiled a fixed object list and + # never touched plugins, so any app with NIF plugins (all of them) died at + # link with "Undefined symbols: __nif_init". The dev build already + # compiled these via build.zig -Dplugin_c_nifs; this brings the release + # path to parity. + + test "loops over MOB_PLUGIN_IOS_NIF_SOURCES and compiles each", %{sh: sh} do + assert sh =~ ~s|for SRC in $MOB_PLUGIN_IOS_NIF_SOURCES| + end + + test "derives the NIF libname from the source basename", %{sh: sh} do + # basename minus extension → STATIC_ERLANG_NIF_LIBNAME, so ERL_NIF_INIT + # emits _nif_init matching the driver table. + assert sh =~ ~s|NAME=$(basename "$SRC"); NAME="${NAME%.*}"| + assert sh =~ ~s|-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME="$NAME"| + end + + test "compiles ObjC (.m) sources with -fobjc-arc and always -fmodules", %{sh: sh} do + # -fmodules autolinks every framework the source @imports (a plugin may + # import frameworks beyond its manifest set, e.g. Accelerate). + assert sh =~ ~s|*.m) ARC="-fobjc-arc" ;;| + assert sh =~ ~s|$CC $ARC -fmodules $IFLAGS| + end + + test "adds the compiled plugin objects to the final link line", %{sh: sh} do + assert sh =~ ~r/PLUGIN_OBJS="\$PLUGIN_OBJS \$BUILD_DIR\/\$NAME\.o"/ + # $PLUGIN_OBJS is on the swiftc link command (unquoted so it word-splits). + assert sh =~ ~r/"\$BUILD_DIR\/erl_errno_id_compat\.o" \\\n\s*\$PLUGIN_OBJS \\/ + end + + test "passes each declared framework explicitly to the linker", %{sh: sh} do + assert sh =~ ~s|for FW in $MOB_PLUGIN_IOS_FRAMEWORKS| + assert sh =~ ~s|-Xlinker -framework -Xlinker $FW| + assert sh =~ ~s|$PLUGIN_FRAMEWORK_FLAGS \\| + end + end + describe "test harness compiled out of release builds" do # Apple error code 50 — non-public selectors. Mob's synthetic-touch # NIFs (`tap_xy`, `swipe_xy`, …) use private UIKit APIs. mob 0.5.12 diff --git a/test/mob_dev/release_test.exs b/test/mob_dev/release_test.exs index 3694c9f..5a7f73e 100644 --- a/test/mob_dev/release_test.exs +++ b/test/mob_dev/release_test.exs @@ -139,6 +139,85 @@ defmodule MobDev.ReleaseTest do end end + describe "plugin_ios_build_env/1" do + # The two env vars feed release_device.sh's plugin NIF compile + link loop. + # Pure over the activated-plugin list, so the matrix runs without a deps tree. + + test "no activated plugins → empty source + framework strings" do + assert Release.plugin_ios_build_env([]) == [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", ""}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", ""} + ] + end + + test "one ObjC plugin → its .m source (absolute) + declared frameworks" do + activated = [ + {"/deps/mob_camera", + %{ + nifs: [ + %{ + module: :mob_camera_nif, + native_dir: "priv/native/ios", + lang: :objc, + platform: :ios + } + ], + ios: %{frameworks: ["AVFoundation", "Photos"]} + }} + ] + + assert [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", srcs}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", fws} + ] = Release.plugin_ios_build_env(activated) + + assert srcs == "/deps/mob_camera/priv/native/ios/mob_camera_nif.m" + assert fws == "AVFoundation Photos" + end + + test "multiple plugins → space-joined sources; frameworks de-duped across the union" do + activated = [ + {"/deps/a", + %{ + nifs: [%{module: :a_nif, native_dir: "priv/native/ios", lang: :objc, platform: :ios}], + ios: %{frameworks: ["CoreBluetooth", "Foundation"]} + }}, + {"/deps/b", + %{ + nifs: [%{module: :b_nif, native_dir: "priv/native/ios", lang: :objc, platform: :ios}], + ios: %{frameworks: ["Foundation", "AVFoundation"]} + }} + ] + + assert [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", srcs}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", fws} + ] = Release.plugin_ios_build_env(activated) + + assert srcs == "/deps/a/priv/native/ios/a_nif.m /deps/b/priv/native/ios/b_nif.m" + # collect_uniq preserves first-seen order across plugins. + assert fws == "CoreBluetooth Foundation AVFoundation" + end + + test "an Android-only NIF entry on the same plugin is excluded from iOS sources" do + activated = [ + {"/deps/x", + %{ + nifs: [ + %{module: :x_nif, native_dir: "priv/native/ios", lang: :objc, platform: :ios}, + %{module: :x_nif, native_dir: "priv/native/jni", lang: :c, platform: :android} + ], + ios: %{frameworks: []} + }} + ] + + assert [{"MOB_PLUGIN_IOS_NIF_SOURCES", srcs}, {"MOB_PLUGIN_IOS_FRAMEWORKS", ""}] = + Release.plugin_ios_build_env(activated) + + assert srcs == "/deps/x/priv/native/ios/x_nif.m" + end + end + # A parsed-profile map in the shape parse_mobileprovision/1 returns; App Store by # default (no provisioned devices, not provisions-all). defp profile(overrides) do From 0f0b5881661a262cbb804e216646dd3274407128 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Tue, 7 Jul 2026 23:39:49 -0600 Subject: [PATCH 2/2] test: sanitize ambient git env in git_hash/1 fixture MobDev.Release.HelpersTest's git fixture ran git init/add/commit without clearing the ambient git environment. When the suite runs inside a git hook (.githooks/pre-push), git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE into the env; the fixture inherited them and operated on the outer repo instead of its tmpdir (git add matched nothing -> git commit exited non-zero -> setup crashed, failing both tests). Passed standalone, failed only on push. Clear the leaking vars (nil removes them for the child) on all three git commands. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/mob_dev/release/helpers_test.exs | 30 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/test/mob_dev/release/helpers_test.exs b/test/mob_dev/release/helpers_test.exs index 50b993f..4960eb6 100644 --- a/test/mob_dev/release/helpers_test.exs +++ b/test/mob_dev/release/helpers_test.exs @@ -75,23 +75,35 @@ defmodule MobDev.Release.HelpersTest do setup do tmp = mk_tmpdir("git_hash") - File.mkdir_p!(tmp) - {_, 0} = System.cmd("git", ["init", "--quiet", "-b", "main", tmp]) - File.write!(Path.join(tmp, "vsn.mk"), "VSN = 17.0\n") - {_, 0} = System.cmd("git", ["-C", tmp, "add", "vsn.mk"]) - # Disable signing + use a stable identity so the hash is reproducible - # *only* within a single test run (we don't pin the value — we just - # assert it's a valid 8-char hex). - env = [ + # Sanitize the ambient git environment. When the suite runs from inside a + # git hook (e.g. `.githooks/pre-push`), git exports GIT_DIR / GIT_WORK_TREE + # / GIT_INDEX_FILE / … into the environment; the fixture's git commands + # would inherit them and operate on the *outer* repo instead of `tmp` + # (`git add` matches nothing → `git commit` exits non-zero → setup crashes). + # nil removes the var for the child process. The stable author/committer + # identity keeps the commit reproducible within a run (we assert the hash + # is 8-char hex, not a pinned value). + git_env = [ + {"GIT_DIR", nil}, + {"GIT_WORK_TREE", nil}, + {"GIT_INDEX_FILE", nil}, + {"GIT_OBJECT_DIRECTORY", nil}, + {"GIT_COMMON_DIR", nil}, + {"GIT_PREFIX", nil}, {"GIT_AUTHOR_NAME", "test"}, {"GIT_AUTHOR_EMAIL", "test@example.com"}, {"GIT_COMMITTER_NAME", "test"}, {"GIT_COMMITTER_EMAIL", "test@example.com"} ] + File.mkdir_p!(tmp) + {_, 0} = System.cmd("git", ["init", "--quiet", "-b", "main", tmp], env: git_env) + File.write!(Path.join(tmp, "vsn.mk"), "VSN = 17.0\n") + {_, 0} = System.cmd("git", ["-C", tmp, "add", "vsn.mk"], env: git_env) + {_, 0} = System.cmd("git", ["-C", tmp, "commit", "-m", "init", "--no-gpg-sign", "--quiet"], - env: env + env: git_env ) on_exit(fn -> File.rm_rf!(tmp) end)