From 93971329c182985c146ef2b0b6745dd9da11ffe1 Mon Sep 17 00:00:00 2001 From: Malian Date: Sun, 25 Oct 2026 17:16:33 +0100 Subject: [PATCH] release: bundle the activated plugins' Info.plist keys in the iOS release `mix mob.release --ios` copied ios/Info.plist into the bundle as written, so the `ios.plist_keys` a plugin declares, which the sim and device builds merge through `NativeBuild.apply_plugin_plist_keys!`, were missing from the App Store build: a plugin's privacy strings and Bonjour service types were gone, and iOS refused what the plugin needed. `apply_plugin_plist_keys!/2` now takes the activated plugins and is public. `Release.write_release_info_plist!/2` writes the project's plist with the plugins' keys to the device build-inputs dir, and `release_env/5` takes its path as a required argument: the script bundles that file and the URL-types and UIAppFonts commands are computed from it, as the dev build computes them from the bundle's merged copy. A preflight refuses a project without ios/Info.plist before the OTP download. Decision records corrected where they claimed the release merged the keys or read ios/Info.plist for the URL types; the plist-keys record gets an addendum. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 + .../2026-05-28-plugin-plist-keys-merge.md | 39 +++++ ...026-07-07-ios-release-links-plugin-nifs.md | 2 +- decisions/2026-10-01-ios-layout-plist-keys.md | 6 + ...ase-compiles-plugin-swift-and-bootstrap.md | 2 +- ...inks-project-inputs-and-plugin-archives.md | 2 +- decisions/2026-10-03-url-schemes.md | 8 + lib/mob_dev/native_build.ex | 28 ++-- lib/mob_dev/release.ex | 80 ++++++--- test/mob_dev/release_fonts_test.exs | 8 +- test/mob_dev/release_plugin_plist_test.exs | 155 ++++++++++++++++++ test/mob_dev/release_test.exs | 6 +- 12 files changed, 303 insertions(+), 42 deletions(-) create mode 100644 test/mob_dev/release_plugin_plist_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6b9bc8..2fb2f821 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 481f9e22..b3fce483 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 6e19316b..03f59842 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 f9dccd6f..843440ce 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 29ec4f7e..aca312da 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 5c369d1f..227df929 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 90169b70..56f5d2d1 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 95f1f816..e9530662 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 195d25ee..bbd0a3be 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 82edd903..d04e4408 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 00000000..8ca77a4b --- /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 1a74a4cd..5e6a6387 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, []) )