Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions decisions/2026-07-07-ios-release-links-plugin-nifs.md
Original file line number Diff line number Diff line change
@@ -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 `<module>_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=<basename>` (so `ERL_NIF_INIT`
emits `<basename>_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.
61 changes: 61 additions & 0 deletions lib/mob_dev/release.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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/<module>.m`). The generated
# `driver_tab_ios` references every activated plugin's `<module>_nif_init`, so
# the release link fails with "Undefined symbols: _<module>_nif_init" unless
# these are compiled in. Each source's basename is the NIF libname → the script
# compiles it with `-DSTATIC_ERLANG_NIF_LIBNAME=<basename>` so `ERL_NIF_INIT`
# emits `<basename>_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

Expand Down Expand Up @@ -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 <module>_nif_init; those
# definitions live in the plugin's iOS NIF source (priv/native/ios/<module>.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: _<module>_nif_init". The source basename is the NIF libname →
# -DSTATIC_ERLANG_NIF_LIBNAME=<name> makes ERL_NIF_INIT emit <name>_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 <FW> 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 \
Expand All @@ -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 \
Expand All @@ -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 ==="
Expand Down
30 changes: 21 additions & 9 deletions test/mob_dev/release/helpers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions test/mob_dev/release_script_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <module>_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: _<module>_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 <name>_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
Expand Down
79 changes: 79 additions & 0 deletions test/mob_dev/release_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading