Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
39 changes: 39 additions & 0 deletions decisions/2026-05-28-plugin-plist-keys-merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
2 changes: 1 addition & 1 deletion decisions/2026-07-07-ios-release-links-plugin-nifs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
6 changes: 6 additions & 0 deletions decisions/2026-10-01-ios-layout-plist-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions decisions/2026-10-03-url-schemes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 17 additions & 11 deletions lib/mob_dev/native_build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
80 changes: 57 additions & 23 deletions lib/mob_dev/release.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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} <-
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions test/mob_dev/release_fonts_test.exs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"))
Expand All @@ -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
Expand Down
Loading