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
17 changes: 15 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,16 @@ a stable one.
- `ios/zig-out/<App>` 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/`.
Original file line number Diff line number Diff line change
@@ -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<module>.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
(`<otp>/erts-*/lib/*.a`, `lib/internal/*.a`), which covers `enif_*`;
- mob core: the external functions, globals and `@implementation` classes
in `<mob_dir>/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.
38 changes: 30 additions & 8 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 ++
Expand Down Expand Up @@ -3233,17 +3234,20 @@ defmodule MobDev.NativeBuild do
# cpp_archive plugin never leaves the driver_tab's `<module>_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<mod>.a.
@spec build_plugin_static_archives(atom(), :ios | :android, Path.t()) ::
# for the target, listed right after its own lib<mod>.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)])
Expand All @@ -3259,14 +3263,18 @@ 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 != [],
do: IO.puts(" + #{length(prebuilt_libs)} prebuilt archive(s) to link")

{:cont, {:ok, Enum.reverse(prebuilt_libs, [info.archive | acc])}}
else
{:error, {:link_check, msg}} ->
{:halt, {:error, msg}}

{:error, {tag, detail}} ->
{:halt,
{:error,
Expand All @@ -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
Expand Down Expand Up @@ -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 -> []
Expand Down
6 changes: 4 additions & 2 deletions lib/mob_dev/plugin/cpp_archive.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading