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
59 changes: 59 additions & 0 deletions decisions/2026-07-04-blank-ios-plugin-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Blank iOS apps must still emit the plugin bootstrap

- Date: 2026-07-04
- Status: accepted
- Issue: MOB-7

## Context

`mix mob.new foo --blank --ios` + `mix mob.deploy --native --ios` failed to link:

```
Undefined symbols for architecture arm64:
"_mob_register_plugins", referenced from:
-[AppDelegate application:didFinishLaunchingWithOptions:] in AppDelegate.o
```

The generated `AppDelegate.m` (mob_new template) *always* declares and calls
`mob_register_plugins()`. That symbol is *defined* by the generated Swift
bootstrap (`MobDev.Plugin.IOSBootstrap.swift_source/1`, emitted via
`generate_ios_plugin_bootstrap/1`). But `native_build.ex` only generated the
bootstrap when `activated_plugins != []` — both the sim and device paths short-
circuited to `{"", ""}` for a plugin-less app. So a `--blank` app has a call with
no definition. Non-blank apps happened to work only because an activated plugin
triggered bootstrap generation (its empty body still defines the symbol).

The original guard existed for a real reason: an app scaffolded *before* the
plugin system has no `plugin_swift_files` option in its `ios/build.zig`, so
passing `-Dplugin_swift_files` would be an unknown-option error. Its assumption —
"a plugin-less app never calls the bootstrap" — went stale when the AppDelegate
template was changed to always call it.

## Decision

Gate bootstrap generation on the **app's build file capability**, not on whether
plugins are activated. `build_file_supports_plugins?/1` (pure, tested) checks the
`ios/build.zig` / `ios/build_device.zig` for the `plugin_swift_files` token:

- **Plugins activated** → plugins' Swift + bootstrap (unchanged).
- **No plugins, but build file supports `plugin_swift_files`** (current template,
whose AppDelegate always calls the symbol) → emit the empty bootstrap so
`mob_register_plugins` is defined.
- **No plugins, legacy build file** (no option, AppDelegate never calls it) →
empty flags, omitted — legacy apps keep building.

The presence of the `plugin_swift_files` option and the AppDelegate's call to
`mob_register_plugins` are generated together, so the token is a sound proxy for
"this app expects the bootstrap symbol." Both iOS paths share one helper,
`ios_plugin_swift_and_frameworks/3`.

## Consequences

- `--blank --ios` apps link again; verified end-to-end on a physical iPhone SE
(build + install succeeded, no undefined symbol).
- A zero-plugin app now compiles one extra ~10-line Swift file (empty
`mob_register_plugins`) — negligible.
- Legacy pre-plugin scaffolds are unaffected (fall through to empty flags).
- Follow-up: the mob_new `AppDelegate.m.eex` could guard the call in a `#if`
instead, but keying off the build file keeps the fix entirely in mob_dev and
matches how the flags are already conditionally emitted.
123 changes: 84 additions & 39 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2440,6 +2440,72 @@ defmodule MobDev.NativeBuild do
out_path
end

@doc false
# Pure kernel: does an iOS build file (build.zig / build_device.zig) accept the
# `plugin_swift_files` option? Presence of that token means the app was
# scaffolded from a plugin-aware template — whose AppDelegate also always calls
# `mob_register_plugins()`. So even with zero plugins activated we must still
# feed it the (empty) bootstrap that defines that symbol, or the link fails
# with `undefined _mob_register_plugins` (MOB-7). Legacy scaffolds lack the
# token *and* never call the symbol, so they stay on the empty-flags path.
@spec build_file_supports_plugins?(String.t()) :: boolean()
def build_file_supports_plugins?(content), do: String.contains?(content, "plugin_swift_files")

@doc false
# Thin I/O wrapper around build_file_supports_plugins?/1. Missing/unreadable
# file ⇒ false (treat as legacy: omit the flags rather than risk an unknown
# -D option on an old build.zig). Public (@doc false) so the missing-file
# branch is testable.
@spec ios_build_file_supports_plugins?(String.t()) :: boolean()
def ios_build_file_supports_plugins?(path) do
case File.read(path) do
{:ok, content} -> build_file_supports_plugins?(content)
_ -> false
end
end

@doc false
# Pure decision — which iOS plugin-swift mode applies. Extracted from
# ios_plugin_swift_and_frameworks/3 so the MOB-7 branch (`:bootstrap_only` —
# no plugins, but a plugin-aware build file whose AppDelegate still calls
# mob_register_plugins) is unit-testable without file I/O or the plugin
# registry. A regression flipping that branch back to `:none` reintroduces
# MOB-7, so it must be pinned.
@spec ios_plugin_swift_mode([term()], boolean()) :: :with_plugins | :bootstrap_only | :none
def ios_plugin_swift_mode([], true), do: :bootstrap_only
def ios_plugin_swift_mode([], false), do: :none
def ios_plugin_swift_mode(_activated, _supports?), do: :with_plugins

# Resolves the {plugin_swift_files, plugin_frameworks} pair for an iOS build,
# shared by the sim and device paths. Activated plugins ⇒ their Swift + the
# bootstrap. No plugins but a plugin-aware build file ⇒ just the bootstrap (so
# mob_register_plugins is defined — see MOB-7). Otherwise empty (flags omitted).
defp ios_plugin_swift_and_frameworks(activated_plugins, build_dir, ios_build_file) do
# `and` short-circuits: with plugins activated we never read the build file,
# so the activated path is byte-identical to before this MOB-7 change.
supports? = activated_plugins == [] and ios_build_file_supports_plugins?(ios_build_file)

case ios_plugin_swift_mode(activated_plugins, supports?) do
:with_plugins ->
bootstrap_path = generate_ios_plugin_bootstrap(build_dir)

swift =
(MobDev.Plugin.Merge.swift_files(activated_plugins) ++ [bootstrap_path])
|> Enum.join(",")

frameworks =
activated_plugins |> MobDev.Plugin.Merge.ios_frameworks() |> Enum.join(",")

{swift, frameworks}

:bootstrap_only ->
{generate_ios_plugin_bootstrap(build_dir), ""}

:none ->
{"", ""}
end
end

defp zig_build_binary_ios_sim(
mob_dir,
otp_root,
Expand Down Expand Up @@ -2468,27 +2534,17 @@ defmodule MobDev.NativeBuild do
MobDev.Plugin.Validator.raise_on_capability_drift!(activated_plugins)

# Generated bootstrap Swift gets compiled alongside the plugins' own Swift
# files via -Dplugin_swift_files. But that flag (and the bootstrap, which
# makes it always non-empty) only matters when plugins are activated: an app
# scaffolded before the plugin system has no plugin_swift_files option in
# ios/build.zig, and mob's pre-plugin Swift never calls the bootstrap. So
# when nothing is activated, skip the bootstrap and leave the flags empty
# (omitted below) — keeping non-plugin apps on older mob building.
# files via -Dplugin_swift_files. The bootstrap also defines
# mob_register_plugins(), which a plugin-aware AppDelegate always calls — so
# even a zero-plugin app on a current build.zig needs it (see MOB-7). Legacy
# scaffolds (no plugin_swift_files option, no call to the symbol) get empty
# flags, omitted below, keeping them building.
{plugin_swift_files, plugin_frameworks} =
if activated_plugins == [] do
{"", ""}
else
bootstrap_path = generate_ios_plugin_bootstrap(build_dir)

swift =
(MobDev.Plugin.Merge.swift_files(activated_plugins) ++ [bootstrap_path])
|> Enum.join(",")

frameworks =
activated_plugins |> MobDev.Plugin.Merge.ios_frameworks() |> Enum.join(",")

{swift, frameworks}
end
ios_plugin_swift_and_frameworks(
activated_plugins,
build_dir,
Path.expand("ios/build.zig")
)

# Activated plugins' C NIF sources (tier-1 plugins). Mirrors the Android
# plugin_c_nifs path and the iOS project_c_nifs path: each .c is compiled +
Expand Down Expand Up @@ -3947,26 +4003,15 @@ defmodule MobDev.NativeBuild do
# MOB_PLUGIN_SECURITY.md, Layer 2.
MobDev.Plugin.Validator.raise_on_capability_drift!(activated_plugins)

# See sim build for the rationale. Only generate the bootstrap + emit the
# -Dplugin_* flags when plugins are activated; an app scaffolded before the
# plugin system has no plugin_swift_files/plugin_frameworks option in
# ios/build_device.zig, and the bootstrap would otherwise make
# plugin_swift_files always non-empty.
# See sim build for the rationale (MOB-7). A plugin-aware build_device.zig
# implies an AppDelegate that always calls mob_register_plugins(), so a
# zero-plugin app still needs the bootstrap; legacy scaffolds get empty flags.
{plugin_swift_files, plugin_frameworks} =
if activated_plugins == [] do
{"", ""}
else
bootstrap_path = generate_ios_plugin_bootstrap(build_dir)

swift =
(MobDev.Plugin.Merge.swift_files(activated_plugins) ++ [bootstrap_path])
|> Enum.join(",")

frameworks =
activated_plugins |> MobDev.Plugin.Merge.ios_frameworks() |> Enum.join(",")

{swift, frameworks}
end
ios_plugin_swift_and_frameworks(
activated_plugins,
build_dir,
Path.expand("ios/build_device.zig")
)

# Activated plugins' C NIF sources — see the sim build for the full
# rationale. Same path on device; the iPhone uses build_device.zig.
Expand Down
63 changes: 63 additions & 0 deletions test/mob_dev/native_build_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1851,4 +1851,67 @@ defmodule MobDev.NativeBuildTest do
assert NativeBuild.plist_array_additions(["a", "b"], ["a", "b"]) == []
end
end

describe "build_file_supports_plugins?/1 (MOB-7: blank-iOS mob_register_plugins link)" do
test "true when the iOS build file exposes the plugin_swift_files option" do
src = ~s|
const plugin_swift_files = b.option([]const u8, "plugin_swift_files", "…") orelse "";
const plugin_frameworks = b.option([]const u8, "plugin_frameworks", "…") orelse "";
|

assert NativeBuild.build_file_supports_plugins?(src)
end

test "false for a pre-plugin build file (no plugin_swift_files option)" do
src = ~s|
const mob_dir = b.option([]const u8, "mob_dir", "…") orelse "";
const sdkroot = b.option([]const u8, "sdkroot", "…") orelse "";
|

refute NativeBuild.build_file_supports_plugins?(src)
end

test "false for empty content" do
refute NativeBuild.build_file_supports_plugins?("")
end
end

describe "ios_plugin_swift_mode/2 (MOB-7: the actual bootstrap decision)" do
# This is the fix. The :bootstrap_only case — no plugins activated, but the
# build file supports plugins (so its AppDelegate calls mob_register_plugins)
# — is what a --blank iOS app needs; flipping it back to :none reintroduces
# the undefined-symbol link failure.
test "no plugins + plugin-aware build file => :bootstrap_only (the MOB-7 fix)" do
assert NativeBuild.ios_plugin_swift_mode([], true) == :bootstrap_only
end

test "no plugins + legacy build file => :none (omit flags, keep legacy building)" do
assert NativeBuild.ios_plugin_swift_mode([], false) == :none
end

test "activated plugins => :with_plugins regardless of build-file support" do
assert NativeBuild.ios_plugin_swift_mode([{"dir", %{}}], true) == :with_plugins
assert NativeBuild.ios_plugin_swift_mode([{"dir", %{}}], false) == :with_plugins
end
end

describe "ios_build_file_supports_plugins?/1 (file-read wrapper)" do
@describetag :tmp_dir

test "true when the file exists and declares the option", %{tmp_dir: dir} do
path = Path.join(dir, "build.zig")
File.write!(path, ~s|const plugin_swift_files = b.option(...);|)
assert NativeBuild.ios_build_file_supports_plugins?(path)
end

test "false when the file exists without the option", %{tmp_dir: dir} do
path = Path.join(dir, "build.zig")
File.write!(path, ~s|const mob_dir = b.option(...);|)
refute NativeBuild.ios_build_file_supports_plugins?(path)
end

test "false when the file is missing (legacy scaffold, no build.zig)", %{tmp_dir: dir} do
refute NativeBuild.ios_build_file_supports_plugins?(Path.join(dir, "does_not_exist.zig"))
end
end
end
Loading