diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f6b9bc..2fb2f82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
## [Unreleased]
+### Fixed
+
+- **`mix mob.release --ios` bundles the activated plugins' Info.plist keys.**
+ The release copied `ios/Info.plist` as written, so the `ios.plist_keys` a
+ plugin declares (its privacy strings, its Bonjour service types), which the
+ sim and device builds merge, were missing from the App Store build, and iOS
+ refused what the plugin needed. The release now merges them the way the dev
+ builds do: the project's keys win, plugins fill gaps.
+
## [0.7.31] - 2026-10-24
### Changed
diff --git a/decisions/2026-05-28-plugin-plist-keys-merge.md b/decisions/2026-05-28-plugin-plist-keys-merge.md
index 481f9e2..b3fce48 100644
--- a/decisions/2026-05-28-plugin-plist-keys-merge.md
+++ b/decisions/2026-05-28-plugin-plist-keys-merge.md
@@ -43,6 +43,10 @@ needs.
silently change a privacy string the user set themselves. This matches the
least-surprise principle for app submission: what the author sees in
`ios/Info.plist` is what App Store Connect will see.
+
+ > **Correction (2026-10-25):** what App Store Connect sees is
+ > `ios/Info.plist` plus the plugins' keys for the gaps, and mob_dev's own
+ > stamps. The release left the plugins' keys out until the addendum below.
- Plugins ship sensible defaults that "just work" for the common case. A camera
plugin can include a generic `NSCameraUsageDescription` default; an app
author who never touches their `Info.plist` still gets a functioning camera
@@ -56,3 +60,38 @@ needs.
derivations, not plugin contributions, and must take precedence over any
literal value the project's `ios/Info.plist` happens to carry over from a
template.
+
+## Addendum 2026-10-25: the iOS release merges them too
+
+`mix mob.release --ios` never called `apply_plugin_plist_keys!`:
+`release_device.sh` copied `ios/Info.plist` into the bundle as written. A
+release therefore lost every plugin's `ios.plist_keys`, the privacy strings
+and Bonjour service types the sim and device builds carried, and iOS refused
+what the plugin needed (a Bluetooth plugin's first scan ends the app without
+`NSBluetoothAlwaysUsageDescription`; a browse for a service type missing from
+`NSBonjourServices` finds nothing).
+
+- `apply_plugin_plist_keys!/2` now takes the activated plugins and is public:
+ `write_bundle_info_plist!/3` passes `MobDev.Plugin.activated()`, the release
+ passes the list `build_ipa/1` already gated.
+- `Release.write_release_info_plist!/2` writes the project's plist with the
+ plugins' keys to the device build-inputs dir (`release_Info.plist`), and
+ `release_env/5` takes its path as a required argument: the script bundles
+ that file (`MOB_IOS_INFO_PLIST`; an unset variable stops the script rather
+ than bundling the bare plist), and the URL-types and UIAppFonts commands
+ are computed from it, as the dev build computes them from the bundle's
+ merged file. Computed from `ios/Info.plist`, they would not see a key a
+ plugin added (an `Add :CFBundleURLTypes array` the merged file already has
+ aborts the script). The bundle id, layout and DT* stamps are pure over
+ `mob.exs` or `Set`, so they behave the same either way.
+- `release_Info.plist` sits beside the device build's generated sources but
+ is not one of them: zig never reads it, and each release overwrites it
+ before the script runs, so one left by an earlier release is never bundled.
+ It is written in place rather than with `write_build_input!/2`; two
+ releases of one project at once are not supported anyway (they share
+ `_build/mob_release`).
+- Why a merged copy and not PlistBuddy commands passed to the script like
+ `MOB_IOS_LAYOUT_PLIST_COMMANDS`: the merge reads the plist it writes (host
+ keys to leave alone, array entries already present), so it has to run
+ against the file. Running the same function keeps the precedence rule above
+ identical in both paths.
diff --git a/decisions/2026-07-07-ios-release-links-plugin-nifs.md b/decisions/2026-07-07-ios-release-links-plugin-nifs.md
index 6e19316..03f5984 100644
--- a/decisions/2026-07-07-ios-release-links-plugin-nifs.md
+++ b/decisions/2026-07-07-ios-release-links-plugin-nifs.md
@@ -71,7 +71,7 @@ link line, and each declared framework is also passed explicitly.
> (`2026-10-03-ios-release-compiles-plugin-swift-and-bootstrap.md`).
> `static_archives` are still a follow-up. The release also now runs the
> plugin signature, trust and capability gate before building these env vars
- > (`plugin_release_env/3`; `release_env/2` above is now `release_env/4`).
+ > (`plugin_release_env/3`; `release_env/2` above is now `release_env/5`).
> **Later (MOB-373):** `static_archives` are linked too
> (`2026-10-03-ios-release-links-project-inputs-and-plugin-archives.md`).
diff --git a/decisions/2026-10-01-ios-layout-plist-keys.md b/decisions/2026-10-01-ios-layout-plist-keys.md
index f9dccd6..843440c 100644
--- a/decisions/2026-10-01-ios-layout-plist-keys.md
+++ b/decisions/2026-10-01-ios-layout-plist-keys.md
@@ -19,6 +19,12 @@ copy: plugin keys, fonts, `CFBundleIdentifier` (`NativeBuild.bundle_ios_app/3`,
iPhone-only `UIDeviceFamily` default when the key is absent
(`release_device.sh`). The project file itself is never rewritten.
+> **Correction (2026-10-25):** "plugin keys" held for the sim and device
+> bundles only. `release_device.sh` copied `ios/Info.plist` as written, so a
+> release had no `ios.plist_keys` from activated plugins. The release now
+> bundles a copy with them (`Release.write_release_info_plist!/2`, see the
+> 2026-10-25 addendum to `2026-05-28-plugin-plist-keys-merge.md`).
+
Facts this rests on, checked 2026-10-01 against Xcode 27.0:
- The iOS 27.0 SDK defines two device families: `SDKSettings.plist`
diff --git a/decisions/2026-10-03-ios-release-compiles-plugin-swift-and-bootstrap.md b/decisions/2026-10-03-ios-release-compiles-plugin-swift-and-bootstrap.md
index 29ec4f7..aca312d 100644
--- a/decisions/2026-10-03-ios-release-compiles-plugin-swift-and-bootstrap.md
+++ b/decisions/2026-10-03-ios-release-compiles-plugin-swift-and-bootstrap.md
@@ -39,7 +39,7 @@ that registers it compile together, as they do under zig).
builds run before linking plugin code. `build_ipa/1` calls it before
signing resolution, the OTP download, the script rewrite or the bootstrap
write (only the empty `_build` inputs dir is created first), and
- `release_env/3` (now `release_env/4`, MOB-373) takes its result as an
+ `release_env/3` (now `release_env/5`: MOB-373, then the merged Info.plist) takes its result as an
argument, so a release can neither skip the gate nor leave the plugin inputs
out.
- **The rule is the dev path's.** Which files go in is decided by
diff --git a/decisions/2026-10-03-ios-release-links-project-inputs-and-plugin-archives.md b/decisions/2026-10-03-ios-release-links-project-inputs-and-plugin-archives.md
index 5c369d1..227df92 100644
--- a/decisions/2026-10-03-ios-release-links-project-inputs-and-plugin-archives.md
+++ b/decisions/2026-10-03-ios-release-links-project-inputs-and-plugin-archives.md
@@ -64,7 +64,7 @@ gets an unknown zig option. That dev-path gap is MOB-376.
> `2026-10-03-guarded-project-nifs-select-by-target-arch.md`.
`Release.project_release_env/3` maps the three results to env vars (pure,
-tested), and `release_env/4` takes that result as a required argument next to
+tested), and `release_env/4` (now `release_env/5`) takes that result as a required argument next to
the plugin env, so a release can't leave the project inputs out.
`build_ipa/1` calls the builders after the OTP download, since the
cpp_archive build needs the device ERTS headers; the Rust/Zig cross-compiles
diff --git a/decisions/2026-10-03-url-schemes.md b/decisions/2026-10-03-url-schemes.md
index 90169b7..56f5d2d 100644
--- a/decisions/2026-10-03-url-schemes.md
+++ b/decisions/2026-10-03-url-schemes.md
@@ -64,6 +64,14 @@ scheme: an Android `VIEW` intent filter on an activity, an iOS
`ios/Info.plist`, which the script copies into the bundle with
`CFBundleURLTypes` untouched. `ios/Info.plist` is never rewritten. Each
build starts from a fresh copy, so removing the key removes the entry.
+
+ > **Correction (2026-10-25):** the script copies the project's plist with
+ > the activated plugins' keys (`Release.write_release_info_plist!/2`), and
+ > the release now computes these commands from that copy, as the dev build
+ > computes them from the bundle's merged file. Computed from `ios/Info.plist`
+ > they started with `Add :CFBundleURLTypes array`, which aborts the script
+ > when a plugin's `plist_keys` creates the array. See the 2026-10-25
+ > addendum to `2026-05-28-plugin-plist-keys-merge.md`.
The plist is read through `plutil -convert xml1`, so a binary plist works.
- **PlistBuddy facts behind the command shape** (checked 2026-10-03 on this
Mac). `Add :CFBundleURLTypes array` fails ("Entry Already Exists", exit 1)
diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex
index 95f1f81..e953066 100644
--- a/lib/mob_dev/native_build.ex
+++ b/lib/mob_dev/native_build.ex
@@ -4497,7 +4497,7 @@ defmodule MobDev.NativeBuild do
def write_bundle_info_plist!(app_path, cfg, bundle_id) do
info_plist = Path.join(app_path, "Info.plist")
File.cp!("ios/Info.plist", info_plist)
- apply_plugin_plist_keys!(info_plist)
+ apply_plugin_plist_keys!(info_plist, MobDev.Plugin.activated())
MobDev.IosLayoutPlist.apply!(info_plist, cfg)
MobDev.UrlSchemes.apply_plist!(info_plist, cfg, bundle_id)
apply_fonts_to_ios_bundle!(info_plist, app_path)
@@ -4854,16 +4854,22 @@ defmodule MobDev.NativeBuild do
:ok
end
- # Adds plugin-declared Info.plist keys via PlistBuddy `Add`. Add fails (and is
- # ignored) when the key is already present, giving us "project Info.plist wins
- # 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. 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()
+ @doc """
+ Adds the keys `activated_plugins` declare (`ios.plist_keys`) to the
+ Info.plist at `info_plist`, a copy of the project's: the sim and device
+ bundles (`write_bundle_info_plist!/3`) and `mix mob.release --ios`
+ (`MobDev.Release.write_release_info_plist!/2`) both call it.
+
+ PlistBuddy `Add` fails (and is ignored) when the key is already present,
+ giving "project Info.plist wins 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. A usage description several
+ plugins declare arrives combined (Merge.plist_keys/1, MOB-421); this says so
+ when it lands, since the host never wrote that prompt text.
+ """
+ @spec apply_plugin_plist_keys!(Path.t(), [MobDev.Plugin.Merge.plugin()]) :: :ok
+ def apply_plugin_plist_keys!(info_plist, activated_plugins) do
host_keys = MobDev.Plugin.Validator.host_plist_keys(info_plist)
for {key, names} <- MobDev.Plugin.Validator.combined_usage_descriptions(activated_plugins),
diff --git a/lib/mob_dev/release.ex b/lib/mob_dev/release.ex
index 195d25e..bbd0a3b 100644
--- a/lib/mob_dev/release.ex
+++ b/lib/mob_dev/release.ex
@@ -51,12 +51,8 @@ defmodule MobDev.Release do
# Before signing resolution, the OTP download and the script/bootstrap
# writes, so a plugin the gate refuses leaves nothing built or downloaded.
- plugin_env =
- plugin_release_env(
- activated,
- Path.expand("ios/build_device.zig"),
- MobDev.NativeBuild.ios_build_inputs_dir(:ios_device)
- )
+ inputs_dir = MobDev.NativeBuild.ios_build_inputs_dir(:ios_device)
+ plugin_env = plugin_release_env(activated, Path.expand("ios/build_device.zig"), inputs_dir)
with :ok <- check_macos(),
:ok <- check_xcrun(),
@@ -65,6 +61,7 @@ defmodule MobDev.Release do
# the plugin set of the last dev build (MOB-404).
:ok <- MobDev.NativeBuild.prepare_plugin_build_state!(),
:ok <- check_driver_table(),
+ :ok <- check_ios_info_plist(),
{:ok, cfg} <- resolve_distribution_signing(cfg),
{:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_device(),
{:ok, plugin_archives} <-
@@ -83,7 +80,8 @@ defmodule MobDev.Release do
MobDev.AppConfig.write!(Path.join(["_build", "dev", "lib", app, "ebin"]))
project_env = project_release_env(project_swift_sources, project_nifs, plugin_archives)
- env = release_env(cfg, otp_root, plugin_env, project_env)
+ info_plist = write_release_info_plist!(activated, inputs_dir)
+ env = release_env(cfg, otp_root, info_plist, plugin_env, project_env)
output_dir = Path.expand("_build/mob_release")
File.mkdir_p!(output_dir)
@@ -150,6 +148,17 @@ defmodule MobDev.Release do
end
end
+ # The release bundles a copy of ios/Info.plist with the plugins' keys
+ # (`write_release_info_plist!/2`); without the file that copy would raise
+ # after the OTP download and the plugin archive builds.
+ defp check_ios_info_plist do
+ if File.exists?("ios/Info.plist") do
+ :ok
+ else
+ {:error, "ios/Info.plist not found. The iOS release bundles it; run from the app's root."}
+ end
+ end
+
# ── Signing config ───────────────────────────────────────────────────────────
@doc false
@@ -396,14 +405,17 @@ defmodule MobDev.Release do
@doc false
# The whole env `release_device.sh` runs with, minus the two output vars
- # `build_ipa/1` adds. `plugin_env` is `plugin_release_env/3`'s result and
- # `project_env` is `project_release_env/3`'s, both required, so neither the
- # plugin nor the project inputs can be left out of a release. Public for
- # testing.
- @spec release_env(keyword(), Path.t(), [{String.t(), String.t()}], [
+ # `build_ipa/1` adds. `info_plist` is `write_release_info_plist!/2`'s result,
+ # the Info.plist the script bundles and the one the URL types and fonts are
+ # computed against, as the dev build computes them against the bundle's
+ # merged copy. `plugin_env` is `plugin_release_env/3`'s result and
+ # `project_env` is `project_release_env/3`'s. All three are required, so
+ # neither the plugins' keys nor the plugin or project inputs can be left out
+ # of a release. Public for testing.
+ @spec release_env(keyword(), Path.t(), Path.t(), [{String.t(), String.t()}], [
{String.t(), String.t()}
]) :: [{String.t(), String.t()}]
- def release_env(cfg, otp_root, plugin_env, project_env) do
+ def release_env(cfg, otp_root, info_plist, plugin_env, project_env) do
app_atom = Mix.Project.config()[:app]
app_name = app_atom |> to_string() |> Macro.camelize()
app_module = to_string(app_atom)
@@ -421,11 +433,12 @@ defmodule MobDev.Release do
{"MOB_IOS_PROFILE_UUID", cfg[:ios_dist_profile_uuid]},
{"MOB_APP_NAME", app_name},
{"MOB_APP_MODULE", app_module},
+ {"MOB_IOS_INFO_PLIST", info_plist},
screenshot_build_env(cfg),
layout_plist_env(cfg),
- url_types_plist_env(cfg, "ios/Info.plist")
+ url_types_plist_env(cfg, info_plist)
] ++
- ios_fonts_release_env("ios/Info.plist", MobDev.NativeBuild.collect_all_fonts()) ++
+ ios_fonts_release_env(info_plist, MobDev.NativeBuild.collect_all_fonts()) ++
plugin_env ++ project_env
end
@@ -502,6 +515,23 @@ defmodule MobDev.Release do
plugin_ios_swift_env(activated, supports_plugins?, bootstrap_path)
end
+ @doc false
+ # The project's ios/Info.plist with the activated plugins' keys
+ # (`NativeBuild.apply_plugin_plist_keys!/2`, the dev bundles' merge), written
+ # to `inputs_dir` as `release_Info.plist`; `release_env/5` hands its path to
+ # release_device.sh, which bundles it. Without it a release shipped
+ # ios/Info.plist as written, without a plugin's privacy strings or Bonjour
+ # service types. The project's file is left alone. Overwritten by each
+ # release before the script reads it, so a file left by an earlier one is
+ # never bundled.
+ @spec write_release_info_plist!([MobDev.Plugin.Merge.plugin()], Path.t()) :: Path.t()
+ def write_release_info_plist!(activated, inputs_dir) do
+ info_plist = Path.join(inputs_dir, "release_Info.plist")
+ File.cp!("ios/Info.plist", info_plist)
+ MobDev.NativeBuild.apply_plugin_plist_keys!(info_plist, activated)
+ info_plist
+ end
+
@doc false
# `mob.exs` `ios_target_devices` / `ios_orientations` / `multi_window` as
# newline-separated PlistBuddy commands; `release_device.sh` runs them on the
@@ -516,8 +546,9 @@ defmodule MobDev.Release do
@doc false
# `mob.exs` `url_schemes` as newline-separated PlistBuddy commands computed
- # from `plist_path` (the project's `ios/Info.plist`, which release_device.sh
- # copies into the bundle unchanged as far as CFBundleURLTypes goes):
+ # from `plist_path` (`write_release_info_plist!/2`'s file, which
+ # release_device.sh copies into the bundle unchanged as far as
+ # CFBundleURLTypes goes):
# `MobDev.UrlSchemes.bundle_plist_commands!/3`, the dev build's commands, under
# the iOS bundle id as CFBundleURLName. Empty without reading the plist when
# `url_schemes` is unset. Raises on an invalid value.
@@ -543,9 +574,9 @@ defmodule MobDev.Release do
# line per font, from `NativeBuild.ios_font_copies!/1` (the dev build's
# collision and name checks).
# - `MOB_IOS_UI_APP_FONTS` — one base64 bundle name per line for each name
- # `plist_path` (the project's `ios/Info.plist`, copied into the bundle as
- # is) doesn't already list in UIAppFonts, so the result matches the dev
- # build's `Assets.merge_ui_app_fonts/2`. The script adds each with
+ # `plist_path` (`write_release_info_plist!/2`'s file, copied into the
+ # bundle as is) doesn't already list in UIAppFonts, so the result matches
+ # the dev build's `Assets.merge_ui_app_fonts/2`. The script adds each with
# `plutil -insert`, the name a single argument, never parsed.
#
# Both empty without reading the plist when there are no fonts.
@@ -984,7 +1015,9 @@ defmodule MobDev.Release do
mkdir -p "$APP"
cp "$BUILD_DIR/$APP_NAME" "$APP/"
- cp ios/Info.plist "$APP/"
+ # ios/Info.plist with the activated plugins' keys
+ # (write_release_info_plist!/2).
+ cp "${MOB_IOS_INFO_PLIST:?MOB_IOS_INFO_PLIST not set}" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $APP_NAME" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$APP/Info.plist"
@@ -1009,8 +1042,9 @@ defmodule MobDev.Release do
fi
# mob.exs url_schemes (MobDev.UrlSchemes): PlistBuddy Adds computed from
- # ios/Info.plist, appending one CFBundleURLTypes entry after the app's own
- # and skipping schemes it declares. Every command must succeed (set -e).
+ # the Info.plist copied above, appending one CFBundleURLTypes entry after
+ # the app's own and skipping schemes it declares. Every command must
+ # succeed (set -e).
if [ -n "$MOB_IOS_URL_TYPES_PLIST_COMMANDS" ]; then
while IFS= read -r CMD; do
/usr/libexec/PlistBuddy -c "$CMD" "$APP/Info.plist"
diff --git a/test/mob_dev/release_fonts_test.exs b/test/mob_dev/release_fonts_test.exs
index 82edd90..d04e440 100644
--- a/test/mob_dev/release_fonts_test.exs
+++ b/test/mob_dev/release_fonts_test.exs
@@ -1,5 +1,5 @@
defmodule MobDev.ReleaseFontsTest do
- # Not async: the release_env/4 test runs from a project directory (cwd).
+ # Not async: the release_env/5 test runs from a project directory (cwd).
use ExUnit.Case, async: false
# mix mob.release --ios used to ship no custom fonts at all (MOB-480). The
@@ -174,10 +174,10 @@ defmodule MobDev.ReleaseFontsTest do
end
end
- # The path mix mob.release --ios takes: release_env/4 (what build_ipa/1
+ # The path mix mob.release --ios takes: release_env/5 (what build_ipa/1
# hands release_device.sh) computes the fonts from the project, so a release
# that stops passing them fails here.
- test "release_env/4 hands the script the project's fonts", %{dir: dir} do
+ test "release_env/5 hands the script the project's fonts", %{dir: dir} do
project = Path.join(dir, "project")
File.mkdir_p!(Path.join(project, "priv/fonts"))
File.mkdir_p!(Path.join(project, "ios"))
@@ -190,7 +190,7 @@ defmodule MobDev.ReleaseFontsTest do
env =
try do
cfg = [mob_dir: project, elixir_lib: Path.dirname(to_string(:code.lib_dir(:elixir)))]
- Release.release_env(cfg, "/otp", [], []) |> Map.new()
+ Release.release_env(cfg, "/otp", "ios/Info.plist", [], []) |> Map.new()
after
File.cd!(cwd)
end
diff --git a/test/mob_dev/release_plugin_plist_test.exs b/test/mob_dev/release_plugin_plist_test.exs
new file mode 100644
index 0000000..8ca77a4
--- /dev/null
+++ b/test/mob_dev/release_plugin_plist_test.exs
@@ -0,0 +1,155 @@
+defmodule MobDev.ReleasePluginPlistTest do
+ # Changes the working directory: the release reads the project's ios/Info.plist from it.
+ use ExUnit.Case, async: false
+
+ # `mix mob.release --ios` copied ios/Info.plist into the bundle without the
+ # keys activated plugins declare (`ios.plist_keys`), which the sim and device
+ # builds merge (`NativeBuild.write_bundle_info_plist!/3`). An App Store build
+ # then lost a plugin's privacy strings and Bonjour service types, and iOS
+ # refused what the plugin needed.
+
+ alias MobDev.Release
+
+ @project_plist """
+
+
+
+
+ CFBundleIdentifier
+ placeholder
+
+
+ """
+
+ setup do
+ cwd = File.cwd!()
+
+ dir =
+ Path.join(System.tmp_dir!(), "release_plugin_plist_#{System.unique_integer([:positive])}")
+
+ File.mkdir_p!(Path.join(dir, "ios"))
+ File.mkdir_p!(Path.join(dir, "inputs"))
+ File.cd!(dir)
+
+ on_exit(fn ->
+ File.cd!(cwd)
+ File.rm_rf!(dir)
+ end)
+
+ :ok
+ end
+
+ defp plugin(plist_keys),
+ do: {"/deps/demo_plugin", %{name: :demo_plugin, ios: %{plist_keys: plist_keys}}}
+
+ defp extract(plist, key) do
+ {json, 0} = System.cmd("plutil", ["-extract", key, "json", "-o", "-", plist])
+ :json.decode(json)
+ end
+
+ # plutil's json format takes containers only; a string comes out as raw text.
+ defp extract_string(plist, key) do
+ {text, 0} = System.cmd("plutil", ["-extract", key, "raw", "-o", "-", plist])
+ String.trim_trailing(text, "\n")
+ end
+
+ # release_device.sh's url_schemes loop, run on a copy of `plist` as on the bundle's.
+ defp run_url_types_block(plist, commands) do
+ [block] =
+ Regex.run(
+ ~r/^ *if \[ -n "\$MOB_IOS_URL_TYPES_PLIST_COMMANDS" \]; then\n.*?^ *fi\n/ms,
+ Release.release_device_sh()
+ )
+
+ File.cp!(plist, "Info.plist")
+
+ System.cmd("bash", ["-e", "-c", block],
+ env: [{"MOB_IOS_URL_TYPES_PLIST_COMMANDS", commands}, {"APP", File.cwd!()}],
+ stderr_to_stdout: true
+ )
+ end
+
+ @tag :macos_only
+ test "the release Info.plist carries the plugins' keys, and the project's file is left as written" do
+ File.write!("ios/Info.plist", @project_plist)
+
+ activated = [
+ plugin(%{
+ NSBluetoothAlwaysUsageDescription: "Find devices nearby",
+ NSBonjourServices: ["_http._tcp"]
+ })
+ ]
+
+ plist = Release.write_release_info_plist!(activated, "inputs")
+
+ assert extract_string(plist, "NSBluetoothAlwaysUsageDescription") == "Find devices nearby"
+ assert extract(plist, "NSBonjourServices") == ["_http._tcp"]
+ assert extract_string(plist, "CFBundleIdentifier") == "placeholder"
+ assert File.read!("ios/Info.plist") == @project_plist
+ end
+
+ @tag :macos_only
+ test "a key the project's Info.plist sets wins over the plugin's, the others are added" do
+ File.write!(
+ "ios/Info.plist",
+ String.replace(@project_plist, "", """
+ NSBluetoothAlwaysUsageDescription
+ The app's own words
+
+ """)
+ )
+
+ activated = [
+ plugin(%{
+ NSBluetoothAlwaysUsageDescription: "Find devices nearby",
+ NSLocalNetworkUsageDescription: "Talk to devices on the network"
+ })
+ ]
+
+ plist = Release.write_release_info_plist!(activated, "inputs")
+
+ assert extract_string(plist, "NSBluetoothAlwaysUsageDescription") == "The app's own words"
+
+ assert extract_string(plist, "NSLocalNetworkUsageDescription") ==
+ "Talk to devices on the network"
+ end
+
+ test "release_env/5 hands the script the Info.plist it is given" do
+ cfg = [mob_dir: "/mob", elixir_lib: "/elixir/lib", ios_bundle_id: "com.example.app"]
+ env = Release.release_env(cfg, "/otp", "/app/release_Info.plist", [], [])
+
+ assert {"MOB_IOS_INFO_PLIST", "/app/release_Info.plist"} in env
+ end
+
+ # The dev build computes the URL types from the bundle's merged file. Computed
+ # from ios/Info.plist, the commands would add a CFBundleURLTypes array the
+ # plugin already created, and the script would stop on PlistBuddy's
+ # "Entry Already Exists".
+ @tag :macos_only
+ test "the URL types are computed from the merged Info.plist, so a plugin's CFBundleURLTypes doesn't stop the release" do
+ File.write!("ios/Info.plist", @project_plist)
+
+ activated = [plugin(%{CFBundleURLTypes: [%{"CFBundleURLSchemes" => ["plugin"]}]})]
+ plist = Release.write_release_info_plist!(activated, "inputs")
+
+ cfg = [
+ mob_dir: "/mob",
+ elixir_lib: "/elixir/lib",
+ ios_bundle_id: "com.example.app",
+ url_schemes: ["myapp"]
+ ]
+
+ env = Release.release_env(cfg, "/otp", plist, [], []) |> Map.new()
+ {output, status} = run_url_types_block(plist, env["MOB_IOS_URL_TYPES_PLIST_COMMANDS"])
+
+ assert status == 0, output
+ assert [%{"CFBundleURLSchemes" => ["myapp"]}] = extract("Info.plist", "CFBundleURLTypes")
+ end
+
+ test "the release script bundles that Info.plist, not the project's own file" do
+ sh = Release.release_device_sh()
+
+ assert sh =~ ~s|cp "${MOB_IOS_INFO_PLIST:?|
+ refute sh =~ "cp ios/Info.plist"
+ end
+end
diff --git a/test/mob_dev/release_test.exs b/test/mob_dev/release_test.exs
index 1a74a4c..5e6a638 100644
--- a/test/mob_dev/release_test.exs
+++ b/test/mob_dev/release_test.exs
@@ -368,7 +368,7 @@ defmodule MobDev.ReleaseTest do
end
end
- describe "release_env/4 + plugin_release_env/3 (the env release_device.sh runs with)" do
+ describe "release_env/5 + plugin_release_env/3 (the env release_device.sh runs with)" do
@describetag :tmp_dir
setup %{tmp_dir: dir} do
@@ -398,6 +398,7 @@ defmodule MobDev.ReleaseTest do
Release.release_env(
cfg,
"/otp",
+ "/app/release_Info.plist",
Release.plugin_release_env([], build_file, inputs),
Release.project_release_env([], project_nifs, [])
)
@@ -406,6 +407,7 @@ defmodule MobDev.ReleaseTest do
assert {"MOB_PLUGIN_IOS_NIF_SOURCES", ""} in env
assert {"MOB_PROJECT_NIF_SOURCES", "/app/c_src/fastmath.c"} in env
assert {"MOB_IOS_DEVICE_OTP_ROOT", "/otp"} in env
+ assert {"MOB_IOS_INFO_PLIST", "/app/release_Info.plist"} in env
assert File.read!(bootstrap) =~ ~s|@_cdecl("mob_register_plugins")|
end
@@ -417,6 +419,7 @@ defmodule MobDev.ReleaseTest do
Release.release_env(
cfg,
"/otp",
+ "/app/release_Info.plist",
Release.plugin_release_env([], build_file, inputs),
Release.project_release_env([], empty, [])
)
@@ -427,6 +430,7 @@ defmodule MobDev.ReleaseTest do
Release.release_env(
cfg ++ [url_schemes: ["https"]],
"/otp",
+ "/app/release_Info.plist",
Release.plugin_release_env([], build_file, inputs),
Release.project_release_env([], empty, [])
)