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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
## [Unreleased]

### Fixed

- **Two plugins declaring the same Info.plist key no longer block the build
when the project's own `ios/Info.plist` sets that key (MOB-387).** Plugin
plist keys only fill gaps (PlistBuddy `Add` never overrides), so the host's
value always wins and the collision is moot. Activating `mob_bluetooth` and
`mob_midi` together (both declare `NSBluetoothAlwaysUsageDescription`)
raised "activated plugins conflict"; now `Validator.cross_validate/2` takes
`:host_plist_keys` and the native build passes the top-level keys of
`ios/Info.plist` (`Validator.host_plist_keys/1`, parsed with xmerl; a binary
plist via `plutil` where it exists). Keys the host doesn't set, and keys any
plugin declares as an array (merged into the host's array rather than
yielding to it), still conflict.
- **A static Rust NIF a dependency ships is built (MOB-392).** A
`:static_nifs` entry whose crate lives in a dependency at
`native/<name>/Cargo.toml` (e.g. `mob_rapier`'s `lab_physics`) was
classified as Elixir-only and never cross-compiled, so the app crashed at
launch with `cannot locate symbol lab_physics_nif_init`. When the project
ships no C, Rust or Zig source for the NIF, every dependency
`Mix.Project.deps_paths/0` resolves (hex, git, path) is searched (not for
plugin-contributed NIFs, which the plugin build compiles); two dependencies
shipping the same crate name is a build error. Every Rust NIF now builds
into `_build/<env>/mob_rust_nifs/<name>` (`cargo --target-dir`) instead of
the crate's `target/`, so a workspace member or `CARGO_TARGET_DIR` no longer
misplaces the archive (existing `native/<name>/target/` dirs can be deleted).
`mix mob.doctor` checks the Rust Android targets for such crates too.

## [0.7.13] - 2026-10-03

### Added
Expand Down
17 changes: 15 additions & 2 deletions lib/mix/tasks/mob.doctor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,8 @@ defmodule Mix.Tasks.Mob.Doctor do
] ++ maybe_check_rust_android_targets()
end

# If the project has any Rust NIFs (native/*/Cargo.toml), make sure both
# If the project builds any Rust NIFs (its own native/*/Cargo.toml, or a
# crate a dependency ships for a :static_nifs entry), make sure both
# Android rustup targets are installed. Without these, `cargo build
# --target=aarch64-linux-android` (or `armv7-linux-androideabi`) fails
# with "error: toolchain '<x>' is not installed".
Expand All @@ -437,7 +438,19 @@ defmodule Mix.Tasks.Mob.Doctor do
end
end

defp has_rust_nif?, do: Path.wildcard("native/*/Cargo.toml") != []
defp has_rust_nif?, do: Path.wildcard("native/*/Cargo.toml") != [] or dep_rust_nif?()

defp dep_rust_nif? do
dep_dirs_for = MobDev.NativeBuild.nif_dep_dirs()
root = File.cwd!()

Enum.any?(MobDev.NativeBuild.project_nif_user_entries(), fn entry ->
match?(
{:rust, _},
MobDev.NativeBuild.classify_project_nif(entry, root, dep_dirs_for.(entry))
)
end)
end

defp check_rust_android_targets do
case System.cmd("rustup", ["target", "list", "--installed"], stderr_to_stdout: true) do
Expand Down
9 changes: 9 additions & 0 deletions lib/mob_dev/ios_layout_plist.ex
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,15 @@ defmodule MobDev.IosLayoutPlist do
end
end

@doc """
The keys of an XML property list's top-level `<dict>`, parsed structurally
(comments, CDATA and nested dictionaries contribute nothing).
"""
@spec top_level_keys(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def top_level_keys(xml) do
with {:ok, dict} <- top_level_dict(xml), do: {:ok, Map.keys(dict)}
end

defp top_level_dict(xml) do
{doc, _rest} =
:xmerl_scan.string(:binary.bin_to_list(xml),
Expand Down
97 changes: 89 additions & 8 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3759,18 +3759,81 @@ defmodule MobDev.NativeBuild do
def classify_project_nif(entry), do: classify_project_nif(entry, File.cwd!())

@doc false
@spec classify_project_nif(MobDev.StaticNifs.nif_entry(), Path.t()) ::
# `dep_dirs` are the dependency directories searched, last, for a crate the
# project ships no source for; they default to `nif_dep_dirs/0`'s answer for
# the entry (every dependency `Mix.Project.deps_paths/0` resolves, or none
# for a plugin-contributed NIF). Callers classifying many entries should
# compute `nif_dep_dirs/0` once and pass its answer.
@spec classify_project_nif(MobDev.StaticNifs.nif_entry(), Path.t(), [Path.t()] | nil) ::
{:c, Path.t()} | {:rust, Path.t()} | {:zig, atom()} | :elixir_only
def classify_project_nif(entry, project_root) do
def classify_project_nif(entry, project_root, dep_dirs \\ nil) do
name = to_string(entry.module)
c_src = Path.join(project_root, "c_src/#{name}.c")
rust_manifest = Path.join(project_root, "native/#{name}/Cargo.toml")

cond do
# C wins if both exist — the user has explicitly written C.
File.exists?(c_src) -> {:c, c_src}
File.exists?(rust_manifest) -> {:rust, rust_manifest}
true -> classify_via_zig_stub(name, project_root)
File.exists?(c_src) ->
{:c, c_src}

File.exists?(rust_manifest) ->
{:rust, rust_manifest}

true ->
with :elixir_only <- classify_via_zig_stub(name, project_root) do
case dep_rust_manifest(name, dep_dirs || nif_dep_dirs().(entry)) do
nil -> :elixir_only
manifest -> {:rust, manifest}
end
end
end
end

@doc false
# The dep dirs to search per static-NIF entry. Builds call it once per
# target, not per entry: activating plugins verifies every manifest. A
# plugin's NIF belongs to the plugin's own build; searching deps for it
# could link a second <module>_nif_init, so it gets none.
@spec nif_dep_dirs() :: (MobDev.StaticNifs.nif_entry() -> [Path.t()])
def nif_dep_dirs do
nif_dep_dirs(
MobDev.Plugin.Merge.nifs(MobDev.Plugin.activated()),
Map.values(Mix.Project.deps_paths())
)
end

@doc false
@spec nif_dep_dirs([map()], [Path.t()]) :: (MobDev.StaticNifs.nif_entry() -> [Path.t()])
def nif_dep_dirs(plugin_nifs, dep_dirs) do
plugin_modules = MapSet.new(plugin_nifs, & &1.module)
fn entry -> if MapSet.member?(plugin_modules, entry.module), do: [], else: dep_dirs end
end

# A Rustler NIF a dependency ships (e.g. mob_rapier's
# deps/mob_rapier/native/lab_physics): the host registers it in
# :static_nifs and the crate is cross-compiled from the dep, so the host
# needn't copy or symlink it into its own native/. :static_nifs names only
# the module, so two deps shipping the same crate name is ambiguous.
defp dep_rust_manifest(name, dep_dirs) do
candidates =
for dir <- dep_dirs,
manifest = Path.join(dir, "native/#{name}/Cargo.toml"),
File.exists?(manifest),
do: manifest

case Enum.sort(candidates) do
[] ->
nil

[manifest] ->
manifest

many ->
Mix.raise(
"static NIF #{name}: more than one dependency ships native/#{name}/Cargo.toml:\n" <>
Enum.map_join(many, "\n", &" - #{&1}") <>
"\nRemove one of those dependencies or rename one crate."
)
end
end

Expand Down Expand Up @@ -3871,9 +3934,11 @@ defmodule MobDev.NativeBuild do
project_nif_user_entries()
|> Enum.filter(&MobDev.StaticNifs.on_platform?(&1, target_arch))

dep_dirs_for = nif_dep_dirs()

{c_sources, rust_manifests, zig_modules} =
Enum.reduce(entries, {[], [], []}, fn entry, {c_acc, rust_acc, zig_acc} ->
case classify_project_nif(entry, project_root) do
case classify_project_nif(entry, project_root, dep_dirs_for.(entry)) do
{:c, path} ->
{[{to_string(entry.module), path} | c_acc], rust_acc, zig_acc}

Expand Down Expand Up @@ -3938,12 +4003,14 @@ defmodule MobDev.NativeBuild do
"--crate-type",
"staticlib",
"--manifest-path",
manifest
manifest,
"--target-dir",
rust_nif_target_dir(name)
]

case System.cmd("cargo", args, stderr_to_stdout: true, into: IO.stream()) do
{_, 0} ->
a = Path.expand("native/#{name}/target/#{target}/release/lib#{name}.a")
a = rust_nif_archive(rust_nif_target_dir(name), name, target)

if File.exists?(a) do
{:ok, a}
Expand All @@ -3963,6 +4030,20 @@ defmodule MobDev.NativeBuild do
end
end

# Cargo builds every Rust NIF into the host's own build dir, not next to the
# crate (which for a dependency is inside deps/): a crate that is a
# workspace member or a set CARGO_TARGET_DIR would otherwise put the archive
# somewhere this build doesn't look.
defp rust_nif_target_dir(name),
do: Path.expand(Path.join(Mix.Project.build_path(), "mob_rust_nifs/#{name}"))

@doc false
# The static archive `cargo rustc --target-dir <target_dir>` writes.
@spec rust_nif_archive(Path.t(), String.t(), String.t()) :: Path.t()
def rust_nif_archive(target_dir, name, target) do
Path.join(target_dir, "#{target}/release/lib#{name}.a")
end

# Apple toolchains differ between simulator and device targets. Both
# are arm64 on Apple Silicon Macs; sim has the `-sim` suffix because
# the SDK headers differ. Android splits per-ABI: arm64-v8a uses
Expand Down
69 changes: 64 additions & 5 deletions lib/mob_dev/plugin/validator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -202,28 +202,59 @@ defmodule MobDev.Plugin.Validator do
ignored). The first element identifies the plugin in error messages: a
plugin directory (as `MobDev.Plugin.activated/0` returns) is reported as its
`priv/mob_plugin.exs` path, anything else via `inspect/1`.

Options:
* `:host_plist_keys` — the keys the host's own `ios/Info.plist` sets. For a
scalar value the host's wins over every plugin's (plugin keys only fill
gaps), so two plugins declaring one of these keys is not a conflict:
neither value lands. A key any plugin declares as an array still
conflicts: arrays merge into the host's array instead of yielding to it.
"""
@spec cross_validate([{atom() | Path.t(), map() | nil}]) :: result()
def cross_validate(plugins) do
@spec cross_validate([{atom() | Path.t(), map() | nil}], keyword()) :: result()
def cross_validate(plugins, opts \\ []) do
owned = for {owner, m} <- plugins, is_map(m), do: {manifest_location(owner), m}
exempt = exempt_plist_keys(owned, Keyword.get(opts, :host_plist_keys, []))

errors =
for {_gatherer, {:collision, checks}} <- conflict_surface(),
for {gatherer, {:collision, checks}} <- conflict_surface(),
{label, extractor} <- checks,
extractor = exempting(gatherer, extractor, exempt),
error <- collisions(owned, extractor, label),
do: error

%{errors: errors, warnings: []}
end

# Host keys every plugin declares as a scalar. One array declaration anywhere
# keeps the key a collision: arrays merge into the host's array, so a later
# plugin's value (array or scalar) would silently drop an earlier one's array.
defp exempt_plist_keys(owned, host_keys) do
array_keys =
for {_, m} <- owned,
{key, value} <- get_in(m, [:ios, :plist_keys]) || %{},
is_list(value),
into: MapSet.new(),
do: to_string(key)

host_keys |> MapSet.new(&to_string/1) |> MapSet.difference(array_keys)
end

defp exempting(:plist_keys, extractor, exempt) do
fn manifest -> Enum.reject(extractor.(manifest), &(to_string(&1) in exempt)) end
end

defp exempting(_gatherer, extractor, _host_keys), do: extractor

@doc """
Runs `cross_validate/1` over the activated `{plugin_dir, manifest}` pairs and
Runs `cross_validate/2` over the activated `{plugin_dir, manifest}` pairs and
`Mix.raise/1`s on any collision, so a native build stops before codegen
instead of letting one plugin's registration silently overwrite another's.
Info.plist keys the project's own `ios/Info.plist` already sets are exempt
(the project's value wins; see `cross_validate/2`).
"""
@spec raise_on_cross_plugin_conflicts!([{Path.t(), map() | nil}]) :: :ok
def raise_on_cross_plugin_conflicts!(plugins) do
case cross_validate(plugins) do
case cross_validate(plugins, host_plist_keys: host_plist_keys("ios/Info.plist")) do
%{errors: []} ->
:ok

Expand All @@ -235,6 +266,34 @@ defmodule MobDev.Plugin.Validator do
end
end

@doc """
The top-level keys an Info.plist sets (`[]` when the file is absent or
unparseable, which keeps every collision an error). The XML is parsed
structurally (`MobDev.IosLayoutPlist.top_level_keys/1`); a binary plist is
converted with `plutil` first when it is available.
"""
@spec host_plist_keys(Path.t()) :: [String.t()]
def host_plist_keys(path) do
with {:ok, raw} <- File.read(path),
{:ok, xml} <- plist_xml(raw, path),
{:ok, keys} <- MobDev.IosLayoutPlist.top_level_keys(xml) do
keys
else
_ -> []
end
end

defp plist_xml("bplist" <> _, path) do
with exe when is_binary(exe) <- System.find_executable("plutil"),
{xml, 0} <- System.cmd(exe, ["-convert", "xml1", "-o", "-", path]) do
{:ok, xml}
else
_ -> :error
end
end

defp plist_xml(xml, _path), do: {:ok, xml}

defp manifest_location(dir) when is_binary(dir), do: Path.join([dir, "priv", "mob_plugin.exs"])
defp manifest_location(other), do: inspect(other)

Expand Down
Loading
Loading