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

### Fixed

- **Activating `mob_bluetooth` and `mob_midi` together builds (MOB-421).**
Both declare `NSBluetoothAlwaysUsageDescription`, each with its own reason,
and the plugin gate refused the pair as an Info.plist key collision unless
the project's `ios/Info.plist` already set the key. A privacy usage
description (`*UsageDescription`) that several plugins declare as strings is
now combined instead: `Merge.plist_keys/1` joins each plugin's distinct
sentence in activation order, so the permission prompt gives every reason
("Bluetooth access is required to discover and advertise to nearby devices.
Bluetooth access is required to connect to wireless (BLE) MIDI devices."),
and the iOS build prints which plugins it combined and that setting the key
in `ios/Info.plist` words it yourself (the project's value still wins). Any
other Info.plist key two plugins declare is still a collision unless every
declaration is a scalar and the project sets the key, and the error now says
so; a usage description declared as a non-string still collides. Keys
compare by name, so `:K` in one manifest and `"K"` in another collide
instead of one being dropped. See
`decisions/2026-10-09-plugin-usage-descriptions-combine.md`.

## [0.7.18] - 2026-10-09

### Fixed
Expand Down
57 changes: 57 additions & 0 deletions decisions/2026-10-09-plugin-usage-descriptions-combine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Plugin usage descriptions combine across plugins; other Info.plist keys still collide

- Date: 2026-10-09
- Status: accepted
- Issue: MOB-421 (mob_ci finding F9)

## Context

`mob_bluetooth` and `mob_midi` both declare `NSBluetoothAlwaysUsageDescription`
in `ios.plist_keys`, each with its own reason ("discover and advertise to nearby
devices", "connect to wireless (BLE) MIDI devices"). `cross_validate/2` treated
every Info.plist key two plugins declare as a collision, so a host activating
both was refused before the build. The MOB-387 host exemption let it through
only if the author already knew to set the key in `ios/Info.plist`.

A usage description is the text of the iOS permission prompt. When two plugins
need the same permission, the app needs it for both reasons, and App Review
expects the purpose string to describe the app's actual use. Either plugin's
string alone is incomplete; last-write-wins (what `Merge.plist_keys/1` did)
silently drops one reason.

Options considered:

1. **Host must set the key**: a validate error naming both plugins and the
`ios/Info.plist` line to add. A clear instruction, but every host combining
two such plugins stops at a build error to write prose.
2. **Plugins stop declaring usage descriptions**: then even a single-plugin
host has no default and iOS terminates the app on first Bluetooth access.
3. **Combine**: join each plugin's distinct sentence, in activation order.

## Decision

Option 3, with the host override from the 2026-05-28 ADR unchanged.

- `Manifest.usage_description_key?/1`: a key ending in `UsageDescription`.
- `Merge.plist_keys/1` groups declarations by the key's string name. A usage
description every declaring plugin gives as a string becomes the distinct
(trimmed, non-empty) sentences joined with a space, each ended with a period
if it has no terminal punctuation. The same string from several plugins
lands once, verbatim. Any other key keeps the later plugin's value.
- `cross_validate/2` exempts those combinable keys. Everything else is as
before: a non-description key two plugins declare is a collision unless the
host's `ios/Info.plist` sets it (scalars only); a usage description some
plugin declares as a non-string still collides. Keys compare by string name,
so `:K` and `"K"` from two plugins now clash instead of one silently losing.
- The iOS build prints one line per combined description the host doesn't set,
naming the plugins and saying to set the key in `ios/Info.plist` to word the
prompt itself. The gate's collision error says that a host-set key is not a
conflict when an Info.plist key is among the errors.

## Consequences

- Activating any set of plugins that only share usage descriptions builds with
no host change, and the prompt names every reason.
- The combined text is two plugin-authored sentences, not prose the author
chose; the build line tells them, and their `ios/Info.plist` value wins.
- mob_ci's `all` set no longer needs to park `mob_midi` (F9).
13 changes: 12 additions & 1 deletion lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5070,9 +5070,20 @@ defmodule MobDev.NativeBuild do
# on conflict; plugins fill gaps" semantics — so a plugin can ship a default
# NSCameraUsageDescription that the app author can override in their own
# Info.plist without changing the plugin. See ADR
# decisions/2026-05-28-plugin-plist-keys-merge.md.
# decisions/2026-05-28-plugin-plist-keys-merge.md. A usage description several
# plugins declare arrives combined (Merge.plist_keys/1, MOB-421); say so when
# it lands, since the host never wrote that prompt text.
defp apply_plugin_plist_keys!(info_plist) do
activated_plugins = MobDev.Plugin.activated()
host_keys = MobDev.Plugin.Validator.host_plist_keys(info_plist)

for {key, names} <- MobDev.Plugin.Validator.combined_usage_descriptions(activated_plugins),
key not in host_keys do
Mix.shell().info(
" [plugin plist] #{key}: combined the descriptions of #{Enum.join(names, ", ")}; " <>
"set #{key} in ios/Info.plist to word the permission prompt yourself"
)
end

for {key, value} <- MobDev.Plugin.Merge.plist_keys(activated_plugins) do
cond do
Expand Down
11 changes: 11 additions & 0 deletions lib/mob_dev/plugin/manifest.ex
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ defmodule MobDev.Plugin.Manifest do
end
end

@doc """
Whether an `ios.plist_keys` key is a privacy usage description
(`NSBluetoothAlwaysUsageDescription`, `NFCReaderUsageDescription`, …): the
string iOS shows in a permission prompt to explain why the app asks. Several
plugins may each need the same permission for their own reason, so these keys
combine across plugins instead of colliding (`MobDev.Plugin.Merge.plist_keys/1`,
`MobDev.Plugin.Validator.cross_validate/2`).
"""
@spec usage_description_key?(atom() | String.t()) :: boolean()
def usage_description_key?(key), do: key |> to_string() |> String.ends_with?("UsageDescription")

defp eval(path) do
case Code.eval_file(path) do
{map, _bindings} when is_map(map) ->
Expand Down
41 changes: 37 additions & 4 deletions lib/mob_dev/plugin/merge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -278,17 +278,50 @@ defmodule MobDev.Plugin.Merge do
end
end

@doc "Merged iOS `plist_keys` across plugins (later plugins win on conflict)."
@spec plist_keys([plugin()]) :: map()
@doc """
Merged iOS `plist_keys` across plugins, keyed by the key's string name.

A privacy usage description (`Manifest.usage_description_key?/1`) that several
plugins declare as strings is **combined**: each distinct sentence once, in
activation order, so the permission prompt explains every reason the app asks
(mob_bluetooth's "discover nearby devices" and mob_midi's "connect to BLE MIDI
devices"). Any other key declared twice keeps the later plugin's value;
`Validator.cross_validate/2` rejects that case unless the host's own
`ios/Info.plist` sets the key (its value always wins over every plugin's).
"""
@spec plist_keys([plugin()]) :: %{String.t() => term()}
def plist_keys(plugins) do
for {_dir, manifest} <- with_manifests(plugins),
keys = get_in(manifest, [:ios, :plist_keys]),
is_map(keys),
reduce: %{} do
acc -> Map.merge(acc, keys)
{key, value} <- keys do
{to_string(key), value}
end
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Map.new(fn {key, values} -> {key, merge_plist_values(key, values)} end)
end

defp merge_plist_values(key, values) do
if Manifest.usage_description_key?(key) and Enum.all?(values, &is_binary/1) do
values
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
|> join_sentences()
else
List.last(values)
end
end

defp join_sentences([]), do: ""
defp join_sentences([one]), do: one

defp join_sentences(sentences) do
Enum.map_join(sentences, " ", fn s ->
if String.ends_with?(s, [".", "!", "?"]), do: s, else: s <> "."
end)
end

@doc "Combined `ui_components` entries across plugins."
@spec ui_components([plugin()]) :: [map()]
def ui_components(plugins) do
Expand Down
90 changes: 79 additions & 11 deletions lib/mob_dev/plugin/validator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,20 @@ defmodule MobDev.Plugin.Validator do
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.

A privacy usage description (`Manifest.usage_description_key?/1`) that every
declaring plugin gives as a string is never a conflict either:
`Merge.plist_keys/1` combines the sentences, so no plugin's reason is lost
(`combined_usage_descriptions/1`).
"""
@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, []))

exempt =
owned
|> exempt_plist_keys(Keyword.get(opts, :host_plist_keys, []))
|> MapSet.union(combinable_plist_keys(owned))

errors =
for {gatherer, {:collision, checks}} <- conflict_surface(),
Expand All @@ -230,21 +239,67 @@ defmodule MobDev.Plugin.Validator do
# 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)
for {key, value} <- plist_declarations(owned), is_list(value), into: MapSet.new(), do: key

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

# Usage-description keys whose every declaration is a string: Merge combines them.
defp combinable_plist_keys(owned) do
owned
|> plist_declarations()
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Enum.filter(fn {key, values} ->
Manifest.usage_description_key?(key) and Enum.all?(values, &is_binary/1)
end)
|> MapSet.new(&elem(&1, 0))
end

defp plist_declarations(owned) do
for {_, m} <- owned,
keys = get_in(m, [:ios, :plist_keys]),
is_map(keys),
{key, value} <- keys,
do: {to_string(key), value}
end

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

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

@doc """
The usage descriptions `Merge.plist_keys/1` combines from more than one
plugin, as `{key, plugin names}` sorted by key: keys at least two plugins
declare with different strings. Used to tell the host that its permission
prompt text was put together from several plugins and how to word it itself.
"""
@spec combined_usage_descriptions([{atom() | Path.t(), map() | nil}]) ::
[{String.t(), [String.t()]}]
def combined_usage_descriptions(plugins) do
owned = for {owner, m} <- plugins, is_map(m), do: {owner, m}
combinable = combinable_plist_keys(owned)

for {owner, m} <- owned,
{key, value} <- plist_declarations([{owner, m}]),
key in combinable,
text = String.trim(value),
text != "" do
{key, {plugin_name(owner, m), text}}
end
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Enum.filter(fn {_key, decls} -> match?([_, _ | _], Enum.uniq_by(decls, &elem(&1, 1))) end)
|> Enum.map(fn {key, decls} -> {key, decls |> Enum.map(&elem(&1, 0)) |> Enum.uniq()} end)
|> Enum.sort()
end

defp plugin_name(_owner, %{name: name}) when is_atom(name) and not is_nil(name),
do: to_string(name)

defp plugin_name(owner, _manifest) when is_binary(owner), do: Path.basename(owner)
defp plugin_name(owner, _manifest), do: inspect(owner)

@doc """
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
Expand All @@ -261,11 +316,21 @@ defmodule MobDev.Plugin.Validator do
%{errors: errors} ->
Mix.raise(
"activated plugins conflict — remove one from `config :mob, :plugins` in " <>
"mob.exs or fix the manifests:\n" <> Enum.map_join(errors, "\n", &" - #{&1}")
"mob.exs or fix the manifests:\n" <>
Enum.map_join(errors, "\n", &" - #{&1}") <> plist_hint(errors)
)
end
end

defp plist_hint(errors) do
if Enum.any?(errors, &(&1 =~ "iOS Info.plist key")) do
"\nAn Info.plist key every plugin declares as a scalar (not an array) is not a " <>
"conflict once the project's own ios/Info.plist sets it: its value wins."
else
""
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
Expand Down Expand Up @@ -817,11 +882,14 @@ defmodule MobDev.Plugin.Validator do
Merge.android_res_files([{".", manifest}]) |> Enum.map(& &1.dest)
end

# Two plugins setting the same Info.plist key silently last-write-wins in the
# merged plist (Map.merge in Merge.plist_keys/1).
# Two plugins setting the same Info.plist key: Merge.plist_keys/1 keeps the
# later value, silently dropping the earlier one. Keyed by string name, as
# Merge keys them, so `:K` in one manifest and `"K"` in another still clash.
# Usage descriptions every plugin declares as strings combine instead and are
# exempted in cross_validate/2.
defp plist_key_names(manifest) do
case get_in(manifest, [:ios, :plist_keys]) do
m when is_map(m) -> Map.keys(m)
m when is_map(m) -> Enum.map(Map.keys(m), &to_string/1)
_ -> []
end
end
Expand Down
Loading
Loading