diff --git a/CHANGELOG.md b/CHANGELOG.md index d78cf55..e3cf111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev). --- +## [0.5.15] + +### Fixed +- **`mix mob.release --android --no-slim` now actually ships the full OTP tree.** The Android release stripped OTP libs unconditionally (`OtpAssetBundle.build/2` was called with no opts), so `--no-slim` was silently ignored on Android. `slim` is now threaded `build_aab → OtpAssetBundle.build(slim:)`; with `slim: false` the OTP tree ships untouched. Required for apps that run arbitrary user code at runtime (e.g. an embedded Livebook host doing `Mix.install`) — stripping any OTP lib (`inets`, `ssl`, `xmerl`, `runtime_tools`, …) is a latent crash when a user's deps need it. Default stays `slim: true`. +- **iOS `--no-slim` release passes App Store validation.** The always-on Apple-policy strip cleared `erts-*/bin` and `priv/bin` but missed standalone executables inside OTP libs (e.g. `erl_interface/bin/erl_call`), which App Store validation rejects (90171). Now `lib/*/bin/*` executables are stripped too (always on), keeping every lib's `.beam`/`.app` — so a full-OTP `--no-slim` bundle is still Apple-compliant. +- **Native builds no longer break on pre-plugin app scaffolding.** `native_build.ex` emitted `-Dplugin_c_nifs`/`-Dplugin_zig_nifs`/`-Dplugin_jni_sources` (Android) and `-Dplugin_swift_files`/`-Dplugin_frameworks` (iOS) unconditionally, but an app scaffolded before the plugin system has no such options in its `build.zig` and Zig rejects the unknown `-D` flag. These flags (and the iOS plugin bootstrap) are now emitted only when plugins are activated; a plugin-aware `build.zig` defaults them to `""` so behaviour is unchanged there. + ## [0.5.14] ### Fixed diff --git a/lib/mix/tasks/mob.release.ex b/lib/mix/tasks/mob.release.ex index 61f7d80..66c26f0 100644 --- a/lib/mix/tasks/mob.release.ex +++ b/lib/mix/tasks/mob.release.ex @@ -120,7 +120,7 @@ defmodule Mix.Tasks.Mob.Release do Mix.Task.run("compile") - case MobDev.ReleaseAndroid.build_aab() do + case MobDev.ReleaseAndroid.build_aab(slim: Keyword.get(opts, :slim, true)) do {:ok, path} -> Mix.shell().info("") Mix.shell().info("#{green()}✓ Release build complete#{reset()}") diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 38e8e1d..b8da3ff 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -272,12 +272,22 @@ defmodule MobDev.NativeBuild do "-Dndk_sysroot=#{ndk_sysroot()}", "-Dapp_name=#{app_name}", "-Dproject_root=#{project_root}", - "-Dexqlite_src=#{Path.join(project_root, "deps/exqlite/c_src")}", - "-Dplugin_c_nifs=#{plugin_c_nifs}", - "-Dplugin_zig_nifs=#{plugin_zig_nifs}", - "-Dplugin_jni_sources=#{plugin_jni_sources}" + "-Dexqlite_src=#{Path.join(project_root, "deps/exqlite/c_src")}" ] + # Only emit -Dplugin_* when non-empty. A plugin-aware build.zig defaults + # these to "" (so omitting them is equivalent there), but an app scaffolded + # before the plugin system has no such option and Zig rejects the unknown + # -D flag. Gating keeps non-plugin apps on older mob scaffolding building. + plugin_args = + for {name, val} <- [ + {"plugin_c_nifs", plugin_c_nifs}, + {"plugin_zig_nifs", plugin_zig_nifs}, + {"plugin_jni_sources", plugin_jni_sources} + ], + val != "", + do: "-D#{name}=#{val}" + # `project_nif_zig_args/1` also emits `-Dproject_root=` (since the # iOS templates need it and don't have a baseline equivalent). The # Android base_args above already supply it for the existing @@ -289,6 +299,7 @@ defmodule MobDev.NativeBuild do args = base_args ++ + plugin_args ++ nif_args_no_root ++ nxeigen_zig_args_android(nxeigen_archive) ++ tflite_zig_args_android(tflite_build) @@ -2203,19 +2214,28 @@ defmodule MobDev.NativeBuild do # declare. Raises with the full list of drifts when any are found. MobDev.Plugin.Validator.raise_on_capability_drift!(activated_plugins) - # Generated bootstrap Swift gets compiled alongside the plugins' own - # Swift files, so it lands in the same `-Dplugin_swift_files` arg. - # That keeps the build.zig template surface unchanged — one flag, one - # split-and-compile loop — and means the bootstrap function ends up in - # MobApp-Swift.h's module the same way plugin views do. - bootstrap_path = generate_ios_plugin_bootstrap(build_dir) + # Generated bootstrap Swift gets compiled alongside the plugins' own Swift + # files via -Dplugin_swift_files. But that flag (and the bootstrap, which + # makes it always non-empty) only matters when plugins are activated: an app + # scaffolded before the plugin system has no plugin_swift_files option in + # ios/build.zig, and mob's pre-plugin Swift never calls the bootstrap. So + # when nothing is activated, skip the bootstrap and leave the flags empty + # (omitted below) — keeping non-plugin apps on older mob building. + {plugin_swift_files, plugin_frameworks} = + if activated_plugins == [] do + {"", ""} + else + bootstrap_path = generate_ios_plugin_bootstrap(build_dir) - plugin_swift_files = - (MobDev.Plugin.Merge.swift_files(activated_plugins) ++ [bootstrap_path]) - |> Enum.join(",") + swift = + (MobDev.Plugin.Merge.swift_files(activated_plugins) ++ [bootstrap_path]) + |> Enum.join(",") - plugin_frameworks = - activated_plugins |> MobDev.Plugin.Merge.ios_frameworks() |> Enum.join(",") + frameworks = + activated_plugins |> MobDev.Plugin.Merge.ios_frameworks() |> Enum.join(",") + + {swift, frameworks} + end base_args = [ "build", @@ -2230,14 +2250,23 @@ defmodule MobDev.NativeBuild do "-Denif_keepalive=#{Path.join(build_dir, "enif_keepalive.c")}", "-Dproject_ios_dir=#{Path.expand("ios")}", "-Dmodule_name=#{display_name}", - "-Dproject_swift_sources=#{project_swift_sources}", - "-Dplugin_swift_files=#{plugin_swift_files}", - "-Dplugin_frameworks=#{plugin_frameworks}" + "-Dproject_swift_sources=#{project_swift_sources}" ] + # Omit -Dplugin_* when empty (no plugins) so apps on pre-plugin ios/build.zig + # don't choke on unknown options; a plugin-aware build.zig defaults them to "". + plugin_args = + for {name, val} <- [ + {"plugin_swift_files", plugin_swift_files}, + {"plugin_frameworks", plugin_frameworks} + ], + val != "", + do: "-D#{name}=#{val}" + with {:ok, nif_args} <- project_nif_zig_args(:ios_sim) do args = base_args ++ + plugin_args ++ nif_args ++ mlx_zig_args(mlx_dir) ++ nxeigen_zig_args_ios(nxeigen_archive) ++ diff --git a/lib/mob_dev/otp_asset_bundle.ex b/lib/mob_dev/otp_asset_bundle.ex index 3c70614..a74cea2 100644 --- a/lib/mob_dev/otp_asset_bundle.ex +++ b/lib/mob_dev/otp_asset_bundle.ex @@ -110,12 +110,19 @@ defmodule MobDev.OtpAssetBundle do case System.cmd("cp", ["-R", source <> "/.", staging], stderr_to_stdout: true) do {_, 0} -> - prefixes = compute_strip_set(opts) - strip_otp_libs(staging, prefixes) - strip_standalone_execs(staging) - strip_static_archives(staging) - strip_source_and_headers(staging) - strip_beam_chunks(staging) + # slim: false ships the OTP tree untouched. Required for apps that run + # arbitrary user code at runtime (e.g. an embedded Livebook host doing + # Mix.install) — we can't know which OTP libs (inets, ssl, xmerl, + # runtime_tools, …) a user's deps will need, so stripping any is unsafe. + if Keyword.get(opts, :slim, true) do + prefixes = compute_strip_set(opts) + strip_otp_libs(staging, prefixes) + strip_standalone_execs(staging) + strip_static_archives(staging) + strip_source_and_headers(staging) + strip_beam_chunks(staging) + end + {:ok, staging} {out, _} -> diff --git a/lib/mob_dev/release.ex b/lib/mob_dev/release.ex index d48a470..0b0ec2d 100644 --- a/lib/mob_dev/release.ex +++ b/lib/mob_dev/release.ex @@ -708,6 +708,12 @@ defmodule MobDev.Release do find "$OTP_BUNDLE" -type f \( -name "*.so" -o -name "*.a" \) -delete find "$OTP_BUNDLE" -path "*/priv/bin/*" -type f -delete find "$OTP_BUNDLE/$ERTS_VSN/bin" -type f -delete 2>/dev/null || true + # Standalone executables inside OTP libs (e.g. erl_interface/bin/erl_call) + # are also rejected by App Store validation (90171) and can't exec on iOS + # anyway. Remove every lib/*/bin/* executable while keeping the libs' + # .beam/.app — so a --no-slim full-OTP bundle (needed for runtime Mix.install) + # still passes Apple's "no standalone executables" rule. + find "$OTP_BUNDLE/lib" -path "*/bin/*" -type f -delete 2>/dev/null || true # ── Slim strips (gated; opt out with `mix mob.release --no-slim`) ── # Each step echoes a tagged header AND the bundle size delta so a diff --git a/lib/mob_dev/release_android.ex b/lib/mob_dev/release_android.ex index 2a532ae..3a6670f 100644 --- a/lib/mob_dev/release_android.ex +++ b/lib/mob_dev/release_android.ex @@ -27,8 +27,9 @@ defmodule MobDev.ReleaseAndroid do `{:error, reason}`. """ @spec build_aab(keyword()) :: {:ok, Path.t()} | {:error, String.t()} - def build_aab(_opts \\ []) do + def build_aab(opts \\ []) do app_name = Mix.Project.config()[:app] |> to_string() + slim = Keyword.get(opts, :slim, true) with :ok <- check_android_project(), log("Ensuring Android OTP runtime..."), @@ -36,7 +37,7 @@ defmodule MobDev.ReleaseAndroid do log("Staging OTP tree + app BEAMs..."), {:ok, staging} <- stage_otp_tree(otp_arm64, app_name), log("Building otp.zip (stripping unused OTP libs)..."), - {:ok, info} <- build_zip(staging), + {:ok, info} <- build_zip(staging, slim), _ = File.rm_rf!(staging), log( " #{info.zipped_files} files, " <> @@ -228,10 +229,10 @@ defmodule MobDev.ReleaseAndroid do # ── otp.zip ────────────────────────────────────────────────────────────────── - defp build_zip(staging) do + defp build_zip(staging, slim) do zip_path = Path.expand(Path.join(@app_assets, "otp.zip")) File.mkdir_p!(Path.dirname(zip_path)) - MobDev.OtpAssetBundle.build(staging, zip_path) + MobDev.OtpAssetBundle.build(staging, zip_path, slim: slim) end # ── Gradle ─────────────────────────────────────────────────────────────────── diff --git a/mix.exs b/mix.exs index ad70c5c..e8670a9 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule MobDev.MixProject do def project do [ app: :mob_dev, - version: "0.5.14", + version: "0.5.15", elixir: "~> 1.19", description: "Development tooling for the Mob mobile framework", source_url: "https://github.com/genericjam/mob_dev", diff --git a/test/mob_dev/otp_asset_bundle_test.exs b/test/mob_dev/otp_asset_bundle_test.exs index 5fc12b9..3868cb6 100644 --- a/test/mob_dev/otp_asset_bundle_test.exs +++ b/test/mob_dev/otp_asset_bundle_test.exs @@ -80,6 +80,27 @@ defmodule MobDev.OtpAssetBundleTest do end end + test "slim: false ships the OTP tree untouched (no lib stripping)" do + source = build_fake_otp_tree() + + target_zip = + Path.join(System.tmp_dir!(), "mob_otp_test_noslim_#{:rand.uniform(999_999)}.zip") + + try do + assert {:ok, _} = OtpAssetBundle.build(source, target_zip, slim: false) + {listing, 0} = System.cmd("unzip", ["-l", target_zip], stderr_to_stdout: true) + + # With slim: false, libs that the default strip would remove survive — + # required for apps running arbitrary user code (Mix.install) where any + # OTP lib (inets, ssl, runtime_tools, …) might be needed at runtime. + assert listing =~ "lib/megaco-1.0.0/" + assert listing =~ "lib/wx-1.0.0/" + after + File.rm_rf!(source) + File.rm(target_zip) + end + end + test "respects :keep_prefixes — opts can re-add a stripped lib" do source = build_fake_otp_tree() target_zip = Path.join(System.tmp_dir!(), "mob_otp_test_keep_#{:rand.uniform(999_999)}.zip")