From 903d1c4ee61cfb4dc63b9bcff76c219cffc5f3e1 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 15:50:30 -0600 Subject: [PATCH 01/11] MOB-387: plugin plist-key collision is moot when the host Info.plist sets the key The host's ios/Info.plist value always wins (PlistBuddy Add never overrides), so cross_validate/2 exempts plist keys the host sets. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 ++++++ lib/mob_dev/plugin/validator.ex | 50 +++++++++++++++++-- test/mob_dev/plugin/conflict_surface_test.exs | 50 +++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da5b5fa..d79decd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [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`). Keys the host doesn't set + still conflict. + ## [0.7.13] - 2026-10-03 ### Added diff --git a/lib/mob_dev/plugin/validator.ex b/lib/mob_dev/plugin/validator.ex index 2d2a9e47..b65b5b41 100644 --- a/lib/mob_dev/plugin/validator.ex +++ b/lib/mob_dev/plugin/validator.ex @@ -202,28 +202,43 @@ 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. The + host's value 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. """ - @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} + host_plist_keys = opts |> Keyword.get(:host_plist_keys, []) |> MapSet.new(&to_string/1) errors = - for {_gatherer, {:collision, checks}} <- conflict_surface(), + for {gatherer, {:collision, checks}} <- conflict_surface(), {label, extractor} <- checks, + extractor = exempting(gatherer, extractor, host_plist_keys), error <- collisions(owned, extractor, label), do: error %{errors: errors, warnings: []} end + defp exempting(:plist_keys, extractor, host_keys) do + fn manifest -> Enum.reject(extractor.(manifest), &(to_string(&1) in host_keys)) 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 @@ -235,6 +250,31 @@ defmodule MobDev.Plugin.Validator do end end + @doc """ + The top-level keys an XML Info.plist sets (`[]` when the file is absent or + unreadable). Nested dictionaries' keys are not included. + """ + @spec host_plist_keys(Path.t()) :: [String.t()] + def host_plist_keys(path) do + with {:ok, xml} <- File.read(path), + [_, body] <- + Regex.run(Regex.compile!("]*>\\s*(.*)\\s*", "s"), xml) do + body + |> strip_nested_dicts() + |> then(&Regex.scan(Regex.compile!("([^<]*)"), &1)) + |> Enum.map(&Enum.at(&1, 1)) + else + _ -> [] + end + end + + # Removes innermost … blocks until none remain, so only the root + # dictionary's own keys are left. + defp strip_nested_dicts(body) do + stripped = Regex.replace(Regex.compile!("(?:(?!).)*?", "s"), body, "") + if stripped == body, do: body, else: strip_nested_dicts(stripped) + end + defp manifest_location(dir) when is_binary(dir), do: Path.join([dir, "priv", "mob_plugin.exs"]) defp manifest_location(other), do: inspect(other) diff --git a/test/mob_dev/plugin/conflict_surface_test.exs b/test/mob_dev/plugin/conflict_surface_test.exs index 65a023fc..f3875a5a 100644 --- a/test/mob_dev/plugin/conflict_surface_test.exs +++ b/test/mob_dev/plugin/conflict_surface_test.exs @@ -99,6 +99,56 @@ defmodule MobDev.Plugin.ConflictSurfaceTest do assert Enum.any?(errs, &(&1 =~ "Info.plist key")) end + test "a duplicate Info.plist key the host's own Info.plist sets is no conflict" do + plugins = + two( + %{ + ios: %{ + plist_keys: %{NSBluetoothAlwaysUsageDescription: "A", NSCameraUsageDescription: "A"} + } + }, + %{ + ios: %{ + plist_keys: %{NSBluetoothAlwaysUsageDescription: "B", NSCameraUsageDescription: "B"} + } + } + ) + + assert %{errors: [err]} = + Validator.cross_validate(plugins, + host_plist_keys: ["NSBluetoothAlwaysUsageDescription"] + ) + + assert err =~ "NSCameraUsageDescription" + end + + @tag :tmp_dir + test "host_plist_keys/1 lists the root dictionary's keys only", %{tmp_dir: dir} do + path = Path.join(dir, "Info.plist") + + File.write!(path, """ + + + + NSCameraUsageDescription + camera + UIApplicationSceneManifest + + UISceneConfigurations + Innerx + + NFCReaderUsageDescription + nfc + + + """) + + assert Validator.host_plist_keys(path) == + ~w(NSCameraUsageDescription UIApplicationSceneManifest NFCReaderUsageDescription) + + assert Validator.host_plist_keys(Path.join(dir, "missing.plist")) == [] + end + test "duplicate AndroidManifest component name across plugins" do plugins = same(%{ From 4e648d65e8497f2ec3642d37db406a76e5472fb3 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 15:57:19 -0600 Subject: [PATCH 02/11] MOB-387: exempt only scalar host plist keys; ignore comments; read binary plists Array values are merged into the host's array (plugin-to-plugin is last-wins), so they still conflict. Commented-out keys no longer count as host-set. Pin the raise_on_cross_plugin_conflicts! wiring to the cwd's ios/Info.plist. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- lib/mob_dev/plugin/validator.ex | 38 ++++++++++--- test/mob_dev/plugin/conflict_surface_test.exs | 16 ++++++ .../plugin/host_plist_conflicts_test.exs | 55 +++++++++++++++++++ 4 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 test/mob_dev/plugin/host_plist_conflicts_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index d79decd7..20ce1f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ `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`). Keys the host doesn't set - still conflict. + `ios/Info.plist` (`Validator.host_plist_keys/1`; XML or, where `plutil` + exists, binary). Keys the host doesn't set, and array values (merged into + the host's array rather than replaced by it), still conflict. ## [0.7.13] - 2026-10-03 diff --git a/lib/mob_dev/plugin/validator.ex b/lib/mob_dev/plugin/validator.ex index b65b5b41..6967080f 100644 --- a/lib/mob_dev/plugin/validator.ex +++ b/lib/mob_dev/plugin/validator.ex @@ -204,9 +204,11 @@ defmodule MobDev.Plugin.Validator do `priv/mob_plugin.exs` path, anything else via `inspect/1`. Options: - * `:host_plist_keys` — the keys the host's own `ios/Info.plist` sets. The - host's value 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. + * `: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. Array values are merged into the host's array + instead, so two plugins declaring the same array key still conflict. """ @spec cross_validate([{atom() | Path.t(), map() | nil}], keyword()) :: result() def cross_validate(plugins, opts \\ []) do @@ -224,7 +226,13 @@ defmodule MobDev.Plugin.Validator do end defp exempting(:plist_keys, extractor, host_keys) do - fn manifest -> Enum.reject(extractor.(manifest), &(to_string(&1) in host_keys)) end + fn manifest -> + declared = get_in(manifest, [:ios, :plist_keys]) || %{} + + Enum.reject(extractor.(manifest), fn key -> + to_string(key) in host_keys and not is_list(Map.get(declared, key)) + end) + end end defp exempting(_gatherer, extractor, _host_keys), do: extractor @@ -251,12 +259,16 @@ defmodule MobDev.Plugin.Validator do end @doc """ - The top-level keys an XML Info.plist sets (`[]` when the file is absent or - unreadable). Nested dictionaries' keys are not included. + The top-level keys an Info.plist sets (`[]` when the file is absent or + unreadable). XML comments are ignored and nested dictionaries' keys are not + included. A binary plist is converted with `plutil` when it is available; + otherwise it yields `[]`, which keeps every collision an error. """ @spec host_plist_keys(Path.t()) :: [String.t()] def host_plist_keys(path) do - with {:ok, xml} <- File.read(path), + with {:ok, raw} <- File.read(path), + {:ok, xml} <- plist_xml(raw, path), + xml = Regex.replace(Regex.compile!("", "s"), xml, ""), [_, body] <- Regex.run(Regex.compile!("]*>\\s*(.*)\\s*", "s"), xml) do body @@ -268,6 +280,18 @@ defmodule MobDev.Plugin.Validator do 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], stderr_to_stdout: true) do + {:ok, xml} + else + _ -> :error + end + end + + defp plist_xml(xml, _path), do: {:ok, xml} + # Removes innermost … blocks until none remain, so only the root # dictionary's own keys are left. defp strip_nested_dicts(body) do diff --git a/test/mob_dev/plugin/conflict_surface_test.exs b/test/mob_dev/plugin/conflict_surface_test.exs index f3875a5a..12a440f5 100644 --- a/test/mob_dev/plugin/conflict_surface_test.exs +++ b/test/mob_dev/plugin/conflict_surface_test.exs @@ -122,6 +122,21 @@ defmodule MobDev.Plugin.ConflictSurfaceTest do assert err =~ "NSCameraUsageDescription" end + test "a duplicate array Info.plist key still conflicts when the host sets it" do + # Plugin arrays are merged into the host's array, and between plugins the + # later one wins, so plugin A's modes would be silently lost. + plugins = + two( + %{ios: %{plist_keys: %{"UIBackgroundModes" => ["bluetooth-central"]}}}, + %{ios: %{plist_keys: %{"UIBackgroundModes" => ["location"]}}} + ) + + assert %{errors: [err]} = + Validator.cross_validate(plugins, host_plist_keys: ["UIBackgroundModes"]) + + assert err =~ "UIBackgroundModes" + end + @tag :tmp_dir test "host_plist_keys/1 lists the root dictionary's keys only", %{tmp_dir: dir} do path = Path.join(dir, "Info.plist") @@ -137,6 +152,7 @@ defmodule MobDev.Plugin.ConflictSurfaceTest do UISceneConfigurations Innerx + NFCReaderUsageDescription nfc diff --git a/test/mob_dev/plugin/host_plist_conflicts_test.exs b/test/mob_dev/plugin/host_plist_conflicts_test.exs new file mode 100644 index 00000000..c5008b2a --- /dev/null +++ b/test/mob_dev/plugin/host_plist_conflicts_test.exs @@ -0,0 +1,55 @@ +defmodule MobDev.Plugin.HostPlistConflictsTest do + # Changes the cwd, so not async. + use ExUnit.Case, async: false + + alias MobDev.Plugin.Validator + + @base %{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1} + + @plugins [ + {:a, Map.put(@base, :ios, %{plist_keys: %{"NSBluetoothAlwaysUsageDescription" => "A"}})}, + {:b, + Map.put(%{@base | name: :b}, :ios, %{ + plist_keys: %{"NSBluetoothAlwaysUsageDescription" => "B"} + })} + ] + + @host_plist """ + + + + NSBluetoothAlwaysUsageDescription + host + + + """ + + @tag :tmp_dir + test "the native build's gate reads the project's ios/Info.plist", %{tmp_dir: dir} do + File.cd!(dir, fn -> + assert_raise Mix.Error, ~r/NSBluetoothAlwaysUsageDescription/, fn -> + Validator.raise_on_cross_plugin_conflicts!(@plugins) + end + + File.mkdir_p!("ios") + File.write!("ios/Info.plist", @host_plist) + assert Validator.raise_on_cross_plugin_conflicts!(@plugins) == :ok + end) + end + + @tag :tmp_dir + test "host_plist_keys/1 reads a binary plist via plutil", %{tmp_dir: dir} do + plutil = System.find_executable("plutil") + path = Path.join(dir, "Info.plist") + File.write!(path, @host_plist) + + if plutil do + {_, 0} = System.cmd(plutil, ["-convert", "binary1", path]) + assert File.read!(path) =~ ~r/\Abplist/ + assert Validator.host_plist_keys(path) == ["NSBluetoothAlwaysUsageDescription"] + else + File.write!(path, "bplist00garbage") + assert Validator.host_plist_keys(path) == [] + end + end +end From de6fbdfcbf8044714aae830433d56c78b5d9f574 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:04:39 -0600 Subject: [PATCH 03/11] MOB-387: parse host Info.plist with xmerl; any array declaration keeps the conflict Regex scanning counted markup inside CDATA as host keys; reuse IosLayoutPlist's structural parse (new top_level_keys/1). Exempt a key only when no plugin declares it as an array, so mixed array/scalar collisions still raise. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++- lib/mob_dev/ios_layout_plist.ex | 9 +++ lib/mob_dev/plugin/validator.ex | 55 +++++++++---------- test/mob_dev/plugin/conflict_surface_test.exs | 28 ++++++---- .../plugin/host_plist_conflicts_test.exs | 1 + 5 files changed, 55 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ce1f6f..3e86f884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,10 @@ `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`; XML or, where `plutil` - exists, binary). Keys the host doesn't set, and array values (merged into - the host's array rather than replaced by it), still conflict. + `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. ## [0.7.13] - 2026-10-03 diff --git a/lib/mob_dev/ios_layout_plist.ex b/lib/mob_dev/ios_layout_plist.ex index a028918a..5a4443fd 100644 --- a/lib/mob_dev/ios_layout_plist.ex +++ b/lib/mob_dev/ios_layout_plist.ex @@ -237,6 +237,15 @@ defmodule MobDev.IosLayoutPlist do end end + @doc """ + The keys of an XML property list's top-level ``, 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), diff --git a/lib/mob_dev/plugin/validator.ex b/lib/mob_dev/plugin/validator.ex index 6967080f..8e730e7f 100644 --- a/lib/mob_dev/plugin/validator.ex +++ b/lib/mob_dev/plugin/validator.ex @@ -207,32 +207,40 @@ defmodule MobDev.Plugin.Validator do * `: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. Array values are merged into the host's array - instead, so two plugins declaring the same array key still 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}], keyword()) :: result() def cross_validate(plugins, opts \\ []) do owned = for {owner, m} <- plugins, is_map(m), do: {manifest_location(owner), m} - host_plist_keys = opts |> Keyword.get(:host_plist_keys, []) |> MapSet.new(&to_string/1) + exempt = exempt_plist_keys(owned, Keyword.get(opts, :host_plist_keys, [])) errors = for {gatherer, {:collision, checks}} <- conflict_surface(), {label, extractor} <- checks, - extractor = exempting(gatherer, extractor, host_plist_keys), + extractor = exempting(gatherer, extractor, exempt), error <- collisions(owned, extractor, label), do: error %{errors: errors, warnings: []} end - defp exempting(:plist_keys, extractor, host_keys) do - fn manifest -> - declared = get_in(manifest, [:ios, :plist_keys]) || %{} + # 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) - Enum.reject(extractor.(manifest), fn key -> - to_string(key) in host_keys and not is_list(Map.get(declared, key)) - end) - end + 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 @@ -260,21 +268,16 @@ defmodule MobDev.Plugin.Validator do @doc """ The top-level keys an Info.plist sets (`[]` when the file is absent or - unreadable). XML comments are ignored and nested dictionaries' keys are not - included. A binary plist is converted with `plutil` when it is available; - otherwise it yields `[]`, which keeps every collision an error. + 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), - xml = Regex.replace(Regex.compile!("", "s"), xml, ""), - [_, body] <- - Regex.run(Regex.compile!("]*>\\s*(.*)\\s*", "s"), xml) do - body - |> strip_nested_dicts() - |> then(&Regex.scan(Regex.compile!("([^<]*)"), &1)) - |> Enum.map(&Enum.at(&1, 1)) + {:ok, keys} <- MobDev.IosLayoutPlist.top_level_keys(xml) do + keys else _ -> [] end @@ -282,8 +285,7 @@ defmodule MobDev.Plugin.Validator do 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], stderr_to_stdout: true) do + {xml, 0} <- System.cmd(exe, ["-convert", "xml1", "-o", "-", path]) do {:ok, xml} else _ -> :error @@ -292,13 +294,6 @@ defmodule MobDev.Plugin.Validator do defp plist_xml(xml, _path), do: {:ok, xml} - # Removes innermost … blocks until none remain, so only the root - # dictionary's own keys are left. - defp strip_nested_dicts(body) do - stripped = Regex.replace(Regex.compile!("(?:(?!).)*?", "s"), body, "") - if stripped == body, do: body, else: strip_nested_dicts(stripped) - end - defp manifest_location(dir) when is_binary(dir), do: Path.join([dir, "priv", "mob_plugin.exs"]) defp manifest_location(other), do: inspect(other) diff --git a/test/mob_dev/plugin/conflict_surface_test.exs b/test/mob_dev/plugin/conflict_surface_test.exs index 12a440f5..e6e46ce2 100644 --- a/test/mob_dev/plugin/conflict_surface_test.exs +++ b/test/mob_dev/plugin/conflict_surface_test.exs @@ -122,19 +122,21 @@ defmodule MobDev.Plugin.ConflictSurfaceTest do assert err =~ "NSCameraUsageDescription" end - test "a duplicate array Info.plist key still conflicts when the host sets it" do + test "a host-set Info.plist key any plugin declares as an array still conflicts" do # Plugin arrays are merged into the host's array, and between plugins the - # later one wins, so plugin A's modes would be silently lost. - plugins = - two( - %{ios: %{plist_keys: %{"UIBackgroundModes" => ["bluetooth-central"]}}}, - %{ios: %{plist_keys: %{"UIBackgroundModes" => ["location"]}}} - ) + # later value wins, so the other plugin's array would be silently lost. + array = %{ios: %{plist_keys: %{"UIBackgroundModes" => ["bluetooth-central"]}}} - assert %{errors: [err]} = - Validator.cross_validate(plugins, host_plist_keys: ["UIBackgroundModes"]) + for other <- [["location"], "scalar"], {a, b} <- [{:array, :other}, {:other, :array}] do + pick = %{array: array, other: %{ios: %{plist_keys: %{"UIBackgroundModes" => other}}}} + + assert %{errors: [err]} = + Validator.cross_validate(two(pick[a], pick[b]), + host_plist_keys: ["UIBackgroundModes"] + ) - assert err =~ "UIBackgroundModes" + assert err =~ "UIBackgroundModes" + end end @tag :tmp_dir @@ -153,14 +155,16 @@ defmodule MobDev.Plugin.ConflictSurfaceTest do Innerx + Real + Fake text]]> NFCReaderUsageDescription nfc """) - assert Validator.host_plist_keys(path) == - ~w(NSCameraUsageDescription UIApplicationSceneManifest NFCReaderUsageDescription) + assert Enum.sort(Validator.host_plist_keys(path)) == + ~w(NFCReaderUsageDescription NSCameraUsageDescription Real UIApplicationSceneManifest) assert Validator.host_plist_keys(Path.join(dir, "missing.plist")) == [] end diff --git a/test/mob_dev/plugin/host_plist_conflicts_test.exs b/test/mob_dev/plugin/host_plist_conflicts_test.exs index c5008b2a..3850bb1a 100644 --- a/test/mob_dev/plugin/host_plist_conflicts_test.exs +++ b/test/mob_dev/plugin/host_plist_conflicts_test.exs @@ -16,6 +16,7 @@ defmodule MobDev.Plugin.HostPlistConflictsTest do @host_plist """ + NSBluetoothAlwaysUsageDescription From a6da4d95e5493ea183fa0d89a35421c4128a1c47 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:04:55 -0600 Subject: [PATCH 04/11] MOB-392: build a static Rust NIF a dependency ships classify_project_nif/2 also finds deps/*/native//Cargo.toml, and the built archive path is derived from the manifest's dir. mob_rapier's lab_physics was never built, so Operator crashed at launch with 'cannot locate symbol lab_physics_nif_init'. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ lib/mob_dev/native_build.ex | 15 ++++++++++++++- test/mob_dev/native_build_test.exs | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e86f884..0ea8a2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ 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 at `deps//native//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`. The crate is now found in the + dependency and its archive is read from the crate's own `target/` directory. ## [0.7.13] - 2026-10-03 diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index f440735a..35cb732e 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3770,10 +3770,23 @@ defmodule MobDev.NativeBuild 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} + dep_manifest = dep_rust_manifest(name, project_root) -> {:rust, dep_manifest} true -> classify_via_zig_stub(name, project_root) 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/. + defp dep_rust_manifest(name, project_root) do + project_root + |> Path.join("deps/*/native/#{name}/Cargo.toml") + |> Path.wildcard() + |> Enum.sort() + |> List.first() + end + # Detect Zigler-backed NIFs by `use Zig` in the generated stub. # `mob.add_nif --type zigler` puts the stub at `lib//nifs/.ex`. # If it contains `use Zig,`, treat the NIF as Zigler-backed and record @@ -3943,7 +3956,7 @@ defmodule MobDev.NativeBuild do 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 = manifest |> Path.dirname() |> Path.join("target/#{target}/release/lib#{name}.a") if File.exists?(a) do {:ok, a} diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index c143e2d0..0110897b 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1572,6 +1572,20 @@ defmodule MobDev.NativeBuildTest do assert {:rust, ^cargo_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) end + test "finds a Rust crate a dependency ships at deps//native/", %{tmp: tmp} do + cargo_path = Path.join(tmp, "deps/some_dep/native/foo/Cargo.toml") + File.mkdir_p!(Path.dirname(cargo_path)) + File.write!(cargo_path, "") + + assert {:rust, ^cargo_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) + + # The project's own crate wins over a dep's. + own = Path.join(tmp, "native/foo/Cargo.toml") + File.mkdir_p!(Path.dirname(own)) + File.write!(own, "") + assert {:rust, ^own} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) + end + test "C wins if both exist (user has explicitly written C)", %{tmp: tmp} do File.mkdir_p!(Path.join(tmp, "c_src")) File.mkdir_p!(Path.join(tmp, "native/foo")) From aed26e6fd1929ea1b927ac7a94abb077374a0842 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:14:44 -0600 Subject: [PATCH 05/11] MOB-392: find dep crates via Mix.Project.deps_paths; raise on ambiguity The deps/* glob missed path deps and custom MIX_DEPS_PATH. Two deps shipping the same crate name now raises instead of picking one. The archive path is one tested helper (rust_nif_archive/3). Co-Authored-By: Claude Opus 5.5 --- lib/mob_dev/native_build.ex | 69 ++++++++++++++++++++++++------ test/mob_dev/native_build_test.exs | 31 ++++++++++++-- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 35cb732e..275e66c0 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3759,32 +3759,65 @@ 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 for a crate the + # project doesn't ship itself; they default to `Mix.Project.deps_paths/0` + # (hex, git and path deps, wherever MIX_DEPS_PATH puts them). + @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} - dep_manifest = dep_rust_manifest(name, project_root) -> {:rust, dep_manifest} - true -> classify_via_zig_stub(name, project_root) + File.exists?(c_src) -> + {:c, c_src} + + File.exists?(rust_manifest) -> + {:rust, rust_manifest} + + dep_manifest = dep_rust_manifest(name, dep_dirs || project_dep_dirs()) -> + {:rust, dep_manifest} + + true -> + classify_via_zig_stub(name, project_root) end end + defp project_dep_dirs do + Map.values(Mix.Project.deps_paths()) + rescue + # Outside a Mix project there are no dependencies to search. + _ -> [] + 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/. - defp dep_rust_manifest(name, project_root) do - project_root - |> Path.join("deps/*/native/#{name}/Cargo.toml") - |> Path.wildcard() - |> Enum.sort() - |> List.first() + # 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 # Detect Zigler-backed NIFs by `use Zig` in the generated stub. @@ -3956,7 +3989,7 @@ defmodule MobDev.NativeBuild do case System.cmd("cargo", args, stderr_to_stdout: true, into: IO.stream()) do {_, 0} -> - a = manifest |> Path.dirname() |> Path.join("target/#{target}/release/lib#{name}.a") + a = rust_nif_archive(manifest, name, target) if File.exists?(a) do {:ok, a} @@ -3976,6 +4009,14 @@ defmodule MobDev.NativeBuild do end end + @doc false + # The static archive `cargo rustc --manifest-path ` writes: the + # crate's own target/ dir, which for a dependency's crate is inside the dep. + @spec rust_nif_archive(Path.t(), String.t(), String.t()) :: Path.t() + def rust_nif_archive(manifest, name, target) do + manifest |> Path.dirname() |> Path.join("target/#{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 diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 0110897b..59089423 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1572,18 +1572,41 @@ defmodule MobDev.NativeBuildTest do assert {:rust, ^cargo_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) end - test "finds a Rust crate a dependency ships at deps//native/", %{tmp: tmp} do - cargo_path = Path.join(tmp, "deps/some_dep/native/foo/Cargo.toml") + test "finds a Rust crate a dependency ships, wherever the dep lives", %{tmp: tmp} do + # A path dep (or a custom MIX_DEPS_PATH) is not under /deps. + dep = Path.join(tmp, "elsewhere/some_dep") + cargo_path = Path.join(dep, "native/foo/Cargo.toml") File.mkdir_p!(Path.dirname(cargo_path)) File.write!(cargo_path, "") + other_dep = Path.join(tmp, "elsewhere/other_dep") + File.mkdir_p!(other_dep) - assert {:rust, ^cargo_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) + assert {:rust, ^cargo_path} = + NativeBuild.classify_project_nif(%{module: :foo}, tmp, [other_dep, dep]) + + # The archive is read from the dependency crate's own target/ dir. + assert NativeBuild.rust_nif_archive(cargo_path, "foo", "aarch64-apple-ios") == + Path.join(dep, "native/foo/target/aarch64-apple-ios/release/libfoo.a") # The project's own crate wins over a dep's. own = Path.join(tmp, "native/foo/Cargo.toml") File.mkdir_p!(Path.dirname(own)) File.write!(own, "") - assert {:rust, ^own} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) + assert {:rust, ^own} = NativeBuild.classify_project_nif(%{module: :foo}, tmp, [dep]) + end + + test "two dependencies shipping the same crate name is an error", %{tmp: tmp} do + deps = + for d <- ~w(a b) do + dir = Path.join(tmp, "deps/#{d}") + File.mkdir_p!(Path.join(dir, "native/foo")) + File.write!(Path.join(dir, "native/foo/Cargo.toml"), "") + dir + end + + assert_raise Mix.Error, ~r/more than one dependency ships native\/foo/, fn -> + NativeBuild.classify_project_nif(%{module: :foo}, tmp, deps) + end end test "C wins if both exist (user has explicitly written C)", %{tmp: tmp} do From 5012c34e199a9f3ea8f2793c392c1c0386fdc1e6 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:14:55 -0600 Subject: [PATCH 06/11] MOB-392: changelog covers deps_paths search and ambiguity Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea8a2d8..9114f0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,13 @@ 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 at `deps//native//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`. The crate is now found in the - dependency and its archive is read from the crate's own `target/` directory. + `:static_nifs` entry whose crate lives in a dependency at + `native//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`. Every dependency + `Mix.Project.deps_paths/0` resolves (hex, git, path) is now searched, the + archive is read from the crate's own `target/` directory, and two + dependencies shipping the same crate name is a build error. ## [0.7.13] - 2026-10-03 From 4bf507c30a86fa2cf084cbe876a2521c6ebdd329 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:29:20 -0600 Subject: [PATCH 07/11] MOB-392: dep crates searched last, plugins excluded; cargo builds into _build The dependency search runs only when the project has no C/Rust/Zig source for the NIF and skips activated plugins. No blanket rescue around deps_paths. cargo --target-dir puts every Rust NIF archive under _build//mob_rust_nifs/ (read-only deps, workspaces, CARGO_TARGET_DIR). mob.doctor checks Rust targets for dep crates. The default deps_paths lookup is pinned with a path-dep fixture project. Co-Authored-By: Claude Opus 5.5 --- lib/mix/tasks/mob.doctor.ex | 11 +++++- lib/mob_dev/native_build.ex | 44 +++++++++++++--------- test/mob_dev/native_build_test.exs | 60 ++++++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 23 deletions(-) diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index a39a74dc..c8d0bb24 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -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 '' is not installed". @@ -437,7 +438,13 @@ 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 + Enum.any?( + MobDev.NativeBuild.project_nif_user_entries(), + &match?({:rust, _}, MobDev.NativeBuild.classify_project_nif(&1)) + ) + end defp check_rust_android_targets do case System.cmd("rustup", ["target", "list", "--installed"], stderr_to_stdout: true) do diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 275e66c0..1635b483 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3759,9 +3759,11 @@ defmodule MobDev.NativeBuild do def classify_project_nif(entry), do: classify_project_nif(entry, File.cwd!()) @doc false - # `dep_dirs` are the dependency directories searched for a crate the - # project doesn't ship itself; they default to `Mix.Project.deps_paths/0` - # (hex, git and path deps, wherever MIX_DEPS_PATH puts them). + # `dep_dirs` are the dependency directories searched, last, for a crate the + # project ships no source for; they default to every dependency + # `Mix.Project.deps_paths/0` resolves (hex, git and path deps, wherever + # MIX_DEPS_PATH puts them) except activated plugins, whose NIFs their own + # build paths compile. @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, dep_dirs \\ nil) do @@ -3777,19 +3779,19 @@ defmodule MobDev.NativeBuild do File.exists?(rust_manifest) -> {:rust, rust_manifest} - dep_manifest = dep_rust_manifest(name, dep_dirs || project_dep_dirs()) -> - {:rust, dep_manifest} - true -> - classify_via_zig_stub(name, project_root) + with :elixir_only <- classify_via_zig_stub(name, project_root) do + case dep_rust_manifest(name, dep_dirs || project_dep_dirs()) do + nil -> :elixir_only + manifest -> {:rust, manifest} + end + end end end defp project_dep_dirs do - Map.values(Mix.Project.deps_paths()) - rescue - # Outside a Mix project there are no dependencies to search. - _ -> [] + plugin_dirs = MapSet.new(MobDev.Plugin.activated(), fn {dir, _manifest} -> dir end) + Mix.Project.deps_paths() |> Map.values() |> Enum.reject(&MapSet.member?(plugin_dirs, &1)) end # A Rustler NIF a dependency ships (e.g. mob_rapier's @@ -3984,12 +3986,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 = rust_nif_archive(manifest, name, target) + a = rust_nif_archive(rust_nif_target_dir(name), name, target) if File.exists?(a) do {:ok, a} @@ -4009,12 +4013,18 @@ defmodule MobDev.NativeBuild do end end + # Cargo builds every Rust NIF into the host's own build dir, never next to + # the crate: a dependency's directory may be read-only, and 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 --manifest-path ` writes: the - # crate's own target/ dir, which for a dependency's crate is inside the dep. + # The static archive `cargo rustc --target-dir ` writes. @spec rust_nif_archive(Path.t(), String.t(), String.t()) :: Path.t() - def rust_nif_archive(manifest, name, target) do - manifest |> Path.dirname() |> Path.join("target/#{target}/release/lib#{name}.a") + 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 diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 59089423..2d6ce02f 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1572,7 +1572,7 @@ defmodule MobDev.NativeBuildTest do assert {:rust, ^cargo_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) end - test "finds a Rust crate a dependency ships, wherever the dep lives", %{tmp: tmp} do + test "finds a Rust crate one of the given dependencies ships", %{tmp: tmp} do # A path dep (or a custom MIX_DEPS_PATH) is not under /deps. dep = Path.join(tmp, "elsewhere/some_dep") cargo_path = Path.join(dep, "native/foo/Cargo.toml") @@ -1584,9 +1584,8 @@ defmodule MobDev.NativeBuildTest do assert {:rust, ^cargo_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp, [other_dep, dep]) - # The archive is read from the dependency crate's own target/ dir. - assert NativeBuild.rust_nif_archive(cargo_path, "foo", "aarch64-apple-ios") == - Path.join(dep, "native/foo/target/aarch64-apple-ios/release/libfoo.a") + assert NativeBuild.rust_nif_archive("/b/mob_rust_nifs/foo", "foo", "aarch64-apple-ios") == + "/b/mob_rust_nifs/foo/aarch64-apple-ios/release/libfoo.a" # The project's own crate wins over a dep's. own = Path.join(tmp, "native/foo/Cargo.toml") @@ -1609,6 +1608,59 @@ defmodule MobDev.NativeBuildTest do end end + test "the 2-arity form searches the project's resolved path deps", %{tmp: tmp} do + # A path dep lives outside /deps; only Mix.Project.deps_paths/0 + # knows where it is. + host = Path.join(tmp, "host") + dep = Path.join(tmp, "elsewhere/rdep") + cargo_path = Path.join(dep, "native/foo/Cargo.toml") + File.mkdir_p!(Path.dirname(cargo_path)) + File.write!(cargo_path, "") + suffix = System.unique_integer([:positive]) + + File.write!(Path.join(dep, "mix.exs"), """ + defmodule RDep#{suffix}.MixProject do + use Mix.Project + def project, do: [app: :rdep, version: "0.1.0"] + end + """) + + File.mkdir_p!(host) + + File.write!(Path.join(host, "mix.exs"), """ + defmodule Host#{suffix}.MixProject do + use Mix.Project + def project, do: [app: :host, version: "0.1.0", deps: [{:rdep, path: "../elsewhere/rdep"}]] + end + """) + + # on_clean_slate makes the fixture the top-level project; nested under + # mob_dev's own project, Mix resolves its deps from mob_dev's cache. + result = + Mix.ProjectStack.on_clean_slate(fn -> + Mix.Project.in_project(:"host#{suffix}", host, fn _ -> + NativeBuild.classify_project_nif(%{module: :foo}, host) + end) + end) + + assert {:rust, found} = result + # deps_paths resolves symlinks (macOS /var → /private/var). + assert String.ends_with?(found, Path.relative_to(cargo_path, Path.dirname(tmp))) + end + + test "the project's own Zig NIF wins over a dependency's crate", %{tmp: tmp} do + dep = Path.join(tmp, "deps/d") + File.mkdir_p!(Path.join(dep, "native/foo")) + File.write!(Path.join(dep, "native/foo/Cargo.toml"), "") + Application.put_env(:mob_dev, :__app_name__, :zapp) + on_exit(fn -> Application.delete_env(:mob_dev, :__app_name__) end) + stub = Path.join(tmp, "lib/zapp/nifs/foo.ex") + File.mkdir_p!(Path.dirname(stub)) + File.write!(stub, "defmodule Foo do\n use Zig, otp_app: :zapp\nend\n") + + assert {:zig, _} = NativeBuild.classify_project_nif(%{module: :foo}, tmp, [dep]) + end + test "C wins if both exist (user has explicitly written C)", %{tmp: tmp} do File.mkdir_p!(Path.join(tmp, "c_src")) File.mkdir_p!(Path.join(tmp, "native/foo")) From 557436c7ef56b41ba27991855f1a3f27ca4baafe Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:29:32 -0600 Subject: [PATCH 08/11] MOB-392: changelog for search order, plugins, target dir, doctor Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9114f0a5..8b4d41db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,14 @@ `:static_nifs` entry whose crate lives in a dependency at `native//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`. Every dependency - `Mix.Project.deps_paths/0` resolves (hex, git, path) is now searched, the - archive is read from the crate's own `target/` directory, and two - dependencies shipping the same crate name is a build error. + 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; activated plugins + excepted) is searched; two dependencies shipping the same crate name is a + build error. Every Rust NIF now builds into `_build//mob_rust_nifs/` + (`cargo --target-dir`) instead of the crate's `target/`, so a read-only + dependency, a workspace member or `CARGO_TARGET_DIR` no longer misplaces the + archive. `mix mob.doctor` checks the Rust Android targets for such crates too. ## [0.7.13] - 2026-10-03 From 5e7acc3ae43b1f7c18adc5a605bf96a3cc6148b3 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:35:41 -0600 Subject: [PATCH 09/11] MOB-392: compute NIF dep dirs once per build; skip plugin NIFs by entry MobDev.Plugin.activated() verifies every plugin manifest (~120 ms in Operator), and it ran once per NIF entry per ABI. nif_dep_dirs/0 does it once and skips the dep search for plugin-contributed entries. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++++++------ lib/mix/tasks/mob.doctor.ex | 16 +++++++++++----- lib/mob_dev/native_build.ex | 29 ++++++++++++++++++++--------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4d41db..f83169df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,13 @@ 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; activated plugins - excepted) is searched; two dependencies shipping the same crate name is a - build error. Every Rust NIF now builds into `_build//mob_rust_nifs/` - (`cargo --target-dir`) instead of the crate's `target/`, so a read-only - dependency, a workspace member or `CARGO_TARGET_DIR` no longer misplaces the - archive. `mix mob.doctor` checks the Rust Android targets for such crates too. + `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//mob_rust_nifs/` (`cargo --target-dir`) instead of + the crate's `target/`, so a workspace member or `CARGO_TARGET_DIR` no longer + misplaces the archive (existing `native//target/` dirs can be deleted). + `mix mob.doctor` checks the Rust Android targets for such crates too. ## [0.7.13] - 2026-10-03 diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index c8d0bb24..643b774f 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -438,12 +438,18 @@ defmodule Mix.Tasks.Mob.Doctor do end end - defp has_rust_nif? do - Path.wildcard("native/*/Cargo.toml") != [] or - Enum.any?( - MobDev.NativeBuild.project_nif_user_entries(), - &match?({:rust, _}, MobDev.NativeBuild.classify_project_nif(&1)) + 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 diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 1635b483..fc5f9f63 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3762,8 +3762,8 @@ defmodule MobDev.NativeBuild do # `dep_dirs` are the dependency directories searched, last, for a crate the # project ships no source for; they default to every dependency # `Mix.Project.deps_paths/0` resolves (hex, git and path deps, wherever - # MIX_DEPS_PATH puts them) except activated plugins, whose NIFs their own - # build paths compile. + # MIX_DEPS_PATH puts them). Builds pass `nif_dep_dirs/0`'s answer, which + # skips the search for plugin-contributed NIFs. @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, dep_dirs \\ nil) do @@ -3781,7 +3781,7 @@ defmodule MobDev.NativeBuild do true -> with :elixir_only <- classify_via_zig_stub(name, project_root) do - case dep_rust_manifest(name, dep_dirs || project_dep_dirs()) do + case dep_rust_manifest(name, dep_dirs || Map.values(Mix.Project.deps_paths())) do nil -> :elixir_only manifest -> {:rust, manifest} end @@ -3789,9 +3789,18 @@ defmodule MobDev.NativeBuild do end end - defp project_dep_dirs do - plugin_dirs = MapSet.new(MobDev.Plugin.activated(), fn {dir, _manifest} -> dir end) - Mix.Project.deps_paths() |> Map.values() |> Enum.reject(&MapSet.member?(plugin_dirs, &1)) + @doc false + # The dep dirs to search per static-NIF entry, computed once per build + # (activating plugins verifies every manifest, so not once per entry). A + # plugin's NIF is compiled by the plugin build paths; searching deps for it + # would link a second _nif_init, so it gets none. + @spec nif_dep_dirs() :: (MobDev.StaticNifs.nif_entry() -> [Path.t()]) + def nif_dep_dirs do + plugin_modules = + MobDev.Plugin.activated() |> MobDev.Plugin.Merge.nifs() |> MapSet.new(& &1.module) + + dep_dirs = Map.values(Mix.Project.deps_paths()) + 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 @@ -3919,9 +3928,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} @@ -4013,8 +4024,8 @@ defmodule MobDev.NativeBuild do end end - # Cargo builds every Rust NIF into the host's own build dir, never next to - # the crate: a dependency's directory may be read-only, and a crate that is a + # 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), From 05573e156183c470714f66f62956b146e60a31b6 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:40:09 -0600 Subject: [PATCH 10/11] MOB-392: classify's default dep dirs go through nif_dep_dirs; pin plugin skip The path-dep fixture test now covers the production lookup, and the plugin skip is a pure nif_dep_dirs/2 with its own test. Co-Authored-By: Claude Opus 5.5 --- lib/mob_dev/native_build.ex | 22 ++++++++++++++-------- test/mob_dev/native_build_test.exs | 8 +++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index fc5f9f63..13b65732 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3781,7 +3781,7 @@ defmodule MobDev.NativeBuild do true -> with :elixir_only <- classify_via_zig_stub(name, project_root) do - case dep_rust_manifest(name, dep_dirs || Map.values(Mix.Project.deps_paths())) do + case dep_rust_manifest(name, dep_dirs || nif_dep_dirs().(entry)) do nil -> :elixir_only manifest -> {:rust, manifest} end @@ -3790,16 +3790,22 @@ defmodule MobDev.NativeBuild do end @doc false - # The dep dirs to search per static-NIF entry, computed once per build - # (activating plugins verifies every manifest, so not once per entry). A - # plugin's NIF is compiled by the plugin build paths; searching deps for it - # would link a second _nif_init, so it gets none. + # 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 _nif_init, so it gets none. @spec nif_dep_dirs() :: (MobDev.StaticNifs.nif_entry() -> [Path.t()]) def nif_dep_dirs do - plugin_modules = - MobDev.Plugin.activated() |> MobDev.Plugin.Merge.nifs() |> MapSet.new(& &1.module) + nif_dep_dirs( + MobDev.Plugin.Merge.nifs(MobDev.Plugin.activated()), + Map.values(Mix.Project.deps_paths()) + ) + end - dep_dirs = Map.values(Mix.Project.deps_paths()) + @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 diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs index 2d6ce02f..f837a4ba 100644 --- a/test/mob_dev/native_build_test.exs +++ b/test/mob_dev/native_build_test.exs @@ -1540,7 +1540,7 @@ defmodule MobDev.NativeBuildTest do end end - describe "classify_project_nif/2" do + describe "classify_project_nif/2,3" do # Pins the source-classification logic that decides whether a # project-side NIF gets the C wiring path, the Rust cross-compile + # link path, or no native wiring at all (Elixir-only stub). Issue #18. @@ -1648,6 +1648,12 @@ defmodule MobDev.NativeBuildTest do assert String.ends_with?(found, Path.relative_to(cargo_path, Path.dirname(tmp))) end + test "nif_dep_dirs/2 searches no deps for a plugin's own NIF" do + dirs_for = NativeBuild.nif_dep_dirs([%{module: :plugin_nif}], ["/deps/a"]) + assert dirs_for.(%{module: :plugin_nif}) == [] + assert dirs_for.(%{module: :host_nif}) == ["/deps/a"] + end + test "the project's own Zig NIF wins over a dependency's crate", %{tmp: tmp} do dep = Path.join(tmp, "deps/d") File.mkdir_p!(Path.join(dep, "native/foo")) From 76632036f982b1b7344d6927fdc2b3bc05696f52 Mon Sep 17 00:00:00 2001 From: Kevin Edey Date: Sun, 4 Oct 2026 16:43:21 -0600 Subject: [PATCH 11/11] MOB-392: classify_project_nif doc matches its default Co-Authored-By: Claude Opus 5.5 --- lib/mob_dev/native_build.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 13b65732..7170d395 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -3760,10 +3760,10 @@ defmodule MobDev.NativeBuild do @doc false # `dep_dirs` are the dependency directories searched, last, for a crate the - # project ships no source for; they default to every dependency - # `Mix.Project.deps_paths/0` resolves (hex, git and path deps, wherever - # MIX_DEPS_PATH puts them). Builds pass `nif_dep_dirs/0`'s answer, which - # skips the search for plugin-contributed NIFs. + # 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, dep_dirs \\ nil) do