## [0.7.21] - 2026-10-09

### Added

- **cpp_archive plugin NIFs can link a prebuilt third-party library
  (MOB-427).** A `lang: :cpp_archive` entry may declare
  `prebuilt: %{url:, sha256:, static_libs: %{ios_sim: [...], ios_device: [...]}}`:
  the build downloads the tarball once into `~/.mob/cache/plugin-prebuilt/`,
  refuses it unless it matches the pinned `sha256`, compiles the plugin's
  sources against `{:prebuilt, "<dir>"}` includes, and links the target's
  listed archives beside `lib<module>.a` through the existing
  `plugin_static_libs` inputs — the simulator and device deploys and `mix
  mob.release --ios`, with no host `build.zig` edit. The URL must be https, the
  hash exact and every path inside the bundle; `mix mob.validate_plugin` and
  the build both check. `MOB_PLUGIN_PREBUILT_DIR=<dir>` takes the tarball from
  a local directory instead of downloading it (the hash still applies). This is
  what lets `mob_scene3d` (Filament) build into a blank iOS host from
  activation alone. See `decisions/2026-10-09-cpp-archive-prebuilt-bundles.md`.

### Fixed

- **A cpp_archive `.m` source compiles as Objective-C with `:cflags*`
  (MOB-427).** It went to `clang++` with the CXXFLAGS, which compiles it as
  Objective-C and fails on any C++ flag (`-std=gnu++17`), so an ObjC NIF could
  not share an archive with an Objective-C++ (`.mm`) source.

## [0.7.20] - 2026-10-09

### Documentation

- **The recommended `AGENTS.md` block in the README says where the Mob docs
  are and the app rules.** It points at `deps/mob/guides/<name>.md` and
  `deps/mob/usage-rules.md` (mob 0.9.17+), hexdocs `llms.txt` and the
  per-page `.md` files, the LiveView mapping guide, and
  `mix hex.docs fetch mob`; and it states that async loading is
  `start_async/3` + `handle_async/3`, long lists are `<LazyList>` with
  `on_end_reached` (there is no `stream`), and screens are tested with
  `Mob.ScreenCase`. It also says a project from `mix mob.new` may already
  have an `AGENTS.md`.
- **iOS node names in the agent examples were wrong.** They used
  `my_app_ios@127.0.0.1`; a simulator is
  `my_app_ios_<first 8 hex of udid, lowercase>@127.0.0.1`
  and a device is `my_app_ios@<device ip>`, which the block now lists.

## [0.7.19] - 2026-10-09

### 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

- **A wired iPhone connects again on macOS 27 (MOB-428).** `mix
  mob.connect` (and every `MobDev.Connector` caller, `mix mob.selftest`,
  mob_ci) failed with `device usb ip: no device USB IP in ARP`: on macOS
  27.0.1 `arp -a` spawned from the BEAM reads an empty table (the same
  command from a shell sees the entries), so the phone's USB link-local
  address was never found. `MobDev.Tunnel` now takes it from the phone's own
  mDNS name — the `<name>.coredevice.local` hostnames `devicectl` lists for
  that UDID, resolved as `<name>.local` (`MobDev.Discovery.IOS.usb_link_local_ip/2`)
  — which also ties the address to the phone being connected instead of the
  first `169.254.*` neighbour of any device. ARP stays as the fallback.
- **`mix mob.connect` reaches a wired iPhone whose WiFi is a network the Mac
  isn't on (MOB-428).** The relaunch passes `DEVICECTL_CHILD_MOB_NODE_HOST`
  with the address the Mac reaches the phone at, and mob 0.9.16 names the
  node after it; before, the phone named it after its WiFi address and the
  connect timed out. The address is the phone's WiFi IP when the Mac reaches
  it (the name the phone picks by itself, which LAN discovery, `--no-restart`
  and hot push dial), and the USB link-local IP only when it doesn't. Older
  mob ignores the variable.
- **`grant_permissions/4` pre-grants mob_photos' `:media` on an iOS
  simulator.** The `:media` capability mapped to the `media-library`
  `simctl privacy` service (Apple Music); it now maps to `photos`, the
  `PHPhotoLibrary` access mob_photos (the only plugin declaring `:media`)
  checks, so its self-test's library leg runs on a simulator instead of
  finding the library unauthorized (MOB-418). On iOS 26.x simulator
  runtimes `simctl privacy grant photos` is ignored by PhotoKit (the TCC row
  it writes is version 1); the grant works from iOS 27.

## [0.7.17] - 2026-10-08

### Added

- **Plugin self-tests: `mix mob.selftest`, `MobDev.Plugin.SelfTest.run_all/3`
  (MOB-411).** A plugin names a `Mob.Plugin.SelfTest` implementation in its
  manifest as `selftest: Module` (mob 0.9.15). `mix mob.selftest` attaches to
  the deployed app on each selected device (the `mix mob.smoke` selection
  options, honouring agent-device leases, plus `--timeout` and
  `--no-restart`), grants the permissions the manifests declare on
  emulators and simulators (`adb shell pm grant`, `xcrun simctl privacy
  grant`), relaunches the app, waits for the plugins' OTP applications to
  be started (`:boot_timeout_ms`, 15 s), calls every activated plugin's
  `run/1` on the device with
  `%{platform:, device: :simulator | :emulator | :physical}` and prints a
  table per device. A self-test that raises, exits, times out (30 s; it is
  then killed on the device) or returns outside the contract is a `FAIL`;
  a plugin without one is a `skip`. Non-zero exit on any `FAIL` or
  unreachable device. `run_all/3` is the runner for callers that hold a
  node already (mob_ci's invariant P12); `grant_permissions/4` is the
  pre-launch grant for them.
- **`selftest` manifest key.** `MobDev.Plugin.Manifest` accepts it (an
  Elixir module; when the module is loadable it must export `run/1`).
  `mix mob.validate_plugin` **warns** `missing selftest` on a manifest
  without one; a later release turns that into an error once the first-party
  plugins carry theirs (MOB-418). `mix mob.new_plugin` tier-1 and tier-4
  scaffolds now generate a `SelfTest` module and declare it.

## [0.7.16] - 2026-10-06

### Fixed

- **`mix mob.release` builds the native code from the current plugin set
  (MOB-404).** `mix mob.release --android` never rebuilt the native library:
  Gradle imported whatever `jniLibs/<abi>/lib<app>.so` the last
  `mix mob.deploy --native` in the checkout left behind, so a release after
  adding or bumping a native plugin shipped a stale `.so` and crashed at
  launch (Operator 1.0.0 with `mob_sensors`: `UnsatisfiedLinkError …
  MobSensorsBridge.nativeRegister()`). The release now runs the same native
  build as `mix mob.deploy --native` (`MobDev.NativeBuild.build_android_native/2`,
  factored out of the deploy path; no device needed) for every Gradle
  `abiFilters` ABI before `bundleRelease`, and refuses an ABI it can't build
  instead of shipping a leftover library. When zig isn't used, a leftover
  `lib<app>.so` is removed so CMake compiles it from source. Both release
  pipelines (Android and iOS) now also regenerate the plugin-derived files a
  dev build does (`MobDev.NativeBuild.prepare_plugin_build_state!/0`): the
  iOS release linked the checked-in `driver_tab_ios.c` as is, so a NIF plugin
  added since it was last generated linked but never registered.

## [0.7.15] - 2026-10-05

### Fixed

- **Slim builds no longer strip an OTP lib the app needs (MOB-399).** The
  default slim pass of `mix mob.release --android` dropped `inets` (and the
  rest of its fixed list) whether or not the app used it, so a release of an
  app whose dependencies list `:inets` (Operator: `ash` → `igniter` →
  `inets`) logged `plugin :mob_ash: OTP application failed to start …
  {:inets, {~c"no such file or directory", ~c"inets.app"}}`; debug deploys
  ship the whole OTP tree, so only release builds broke. A slim pass now
  never strips a lib any shipped app lists in its `.app` `applications` or
  `included_applications`, transitively: `MobDev.OtpRequiredApps` follows
  the runtime dependency set (`MobDev.HotPush.runtime_lib_names/0`) through
  the bundled OTP tree's `.app` files. It applies to the Android release
  (`MobDev.OtpAssetBundle`, which logs the libs it kept), the iOS release
  script (`MOB_SLIM_KEEP_LIBS`) and `mix mob.deploy --slim`
  (`MobDev.OtpAudit.Slim`'s new `:required_libs`; `:drop_libs` and
  `:keep_libs` still win). The `_build` traversal that decides what ships
  now follows `included_applications` as well.

## [0.7.14] - 2026-10-04

### Fixed

- **Two plugins declaring the same Info.plist key no longer block the build
  when the project's own `ios/Info.plist` sets that key (MOB-387).** Plugin
  plist keys only fill gaps (PlistBuddy `Add` never overrides), so the host's
  value always wins and the collision is moot. Activating `mob_bluetooth` and
  `mob_midi` together (both declare `NSBluetoothAlwaysUsageDescription`)
  raised "activated plugins conflict"; now `Validator.cross_validate/2` takes
  `:host_plist_keys` and the native build passes the top-level keys of
  `ios/Info.plist` (`Validator.host_plist_keys/1`, parsed with xmerl; a binary
  plist via `plutil` where it exists). Keys the host doesn't set, and keys any
  plugin declares as an array (merged into the host's array rather than
  yielding to it), still conflict.
- **A static Rust NIF a dependency ships is built (MOB-392).** A
  `:static_nifs` entry whose crate lives in a dependency at
  `native/<name>/Cargo.toml` (e.g. `mob_rapier`'s `lab_physics`) was
  classified as Elixir-only and never cross-compiled, so the app crashed at
  launch with `cannot locate symbol lab_physics_nif_init`. When the project
  ships no C, Rust or Zig source for the NIF, every dependency
  `Mix.Project.deps_paths/0` resolves (hex, git, path) is searched (not for
  plugin-contributed NIFs, which the plugin build compiles); two dependencies
  shipping the same crate name is a build error. Every Rust NIF now builds
  into `_build/<env>/mob_rust_nifs/<name>` (`cargo --target-dir`) instead of
  the crate's `target/`, so a workspace member or `CARGO_TARGET_DIR` no longer
  misplaces the archive (existing `native/<name>/target/` dirs can be deleted).
  `mix mob.doctor` checks the Rust Android targets for such crates too.

## [0.7.13] - 2026-10-03

### Added

- **`:cpp_archive` plugin NIFs can mix C and C++ sources (MOB-381).** A `.c`
  entry in `:sources` is compiled by the target's C driver (`clang`) with the
  new `:cflags` / `:cflags_android` / `:cflags_ios`; everything else still goes
  to `clang++` with the CXXFLAGS. Libraries such as ggml (whisper.cpp, the
  `mob_whisper` plugin) ship `.c` files that aren't valid C++, so they couldn't
  be built before.
- **`:cpp_archive` plugin NIFs build for the x86_64 Android emulator ABI.**
  Every Android native build compiles arm64, armv7 and x86_64, and an active
  cpp_archive plugin used to stop it with "cannot be built for
  :android_x86_64". The archive is now cross-compiled for x86_64 too
  (`x86_64-linux-android28-clang++`; Arm-only `-mbranch-protection=` flags are
  dropped there), so such a plugin works on an emulator and no longer blocks
  every build.

### Fixed

- **Two cpp_archive sources with the same file name no longer overwrite each
  other's object.** Objects were named `<basename>.o`, so ggml's
  `ggml-cpu/quants.c` and `ggml-cpu/arch/arm/quants.c` collided and one set of
  symbols silently vanished from the archive. Objects are now
  `<basename>-<hash of the path>.o`. Sources also compile in parallel (a
  ~30-file archive builds in ~5 s instead of serially).

## [0.7.12] - 2026-10-03

### Upgrading

- **Deep links also need native files that call `mob_deliver_link`**
  (MOB-379). `url_schemes` only makes the OS open the app for `myapp://…`;
  the link reaches the BEAM through code in app-owned files. New projects
  from mob_new 0.6.4 have it. An app generated by mob_new 0.6.3 or older opens
  on the link but never receives `{:link, …}` until it ports the
  `MainActivity` / `MobBridge.kt` / `beam_jni.c` / `SceneDelegate` changes from
  the "Deep links" section of mob's `guides/device_capabilities.md` and
  depends on mob 0.9.11. Apps that don't set `url_schemes` need no change.

### Added

- **Deep-link URL schemes: `config :mob_dev, url_schemes: ["myapp"]` in
  `mob.exs` (MOB-379).** The native build makes the OS open the app for
  `myapp://…` URLs. mob 0.9.11 then delivers them to the root screen as
  `{:link, %{url: url, source: :launch | :running}}`. On Android,
  `mix mob.deploy --native` and `mix mob.release --android` keep a managed
  `VIEW` intent filter (`DEFAULT` + `BROWSABLE`) inside the launcher activity
  of `AndroidManifest.xml`. On iOS, the sim and device builds and
  `mix mob.release --ios` append a `CFBundleURLTypes` entry (named after the
  iOS bundle id, role `Viewer`) to the built bundle's Info.plist. The app's
  own entries are kept, and `ios/Info.plist` is never rewritten. A scheme the
  app already routes is skipped: on Android, only when a broad
  `VIEW` + `DEFAULT` + `BROWSABLE` filter on the launcher activity declares
  it. A scheme a `VIEW` filter on another activity also declares is added
  with a build warning. On iOS, a scheme in any existing entry is skipped.
  Removing the key removes the filter and the entry. Schemes must be
  lowercase. `http`/`https` are refused, since
  verified App Links and universal links need a host and verification. An
  invalid value fails the build and `mix mob.doctor`. With `url_schemes` set,
  the launcher activity must be `android:launchMode="singleTask"` (or
  `singleInstance`). Without it, a link from another app's task starts a
  second `MainActivity`. The Android build and `mix mob.doctor` refuse any
  other launch mode. mob_new's template uses `singleTop`, so an app opts in
  when it adds deep links. See `decisions/2026-10-03-url-schemes.md`.

### Fixed

- **A guarded project NIF no longer breaks `mix mob.deploy --native`
  (MOB-376).** A `mob.exs` `:static_nifs` entry with a `:guard` failed every
  dev build of a platform it covers with `invalid option: -D<module>_static`:
  mob_dev passed a zig option no mob_new build file declares, and the
  generated Zig driver table read a `build_options` field none of them
  provides. The driver table now selects a guarded project NIF by the target
  it is compiled for, from the entry's `:archs` (the simulator ABI on iOS, the
  CPU on Android; `TARGET_OS_SIMULATOR` / `__aarch64__` / `__arm__` in a C
  table), so it works with every app's existing build files. The next native
  build regenerates the table; tables without project guards are unchanged.
  The built-in sqlite / EMLX / NxEigen / TFLite switches are unchanged. See
  `decisions/2026-10-03-guarded-project-nifs-select-by-target-arch.md`.

### Changed

- **A project NIF's `:guard` selects by `:archs`; it is no longer an off
  switch.** An entry whose `:archs` cover the whole platform is now always
  registered (`#if 1` / `true`), where a 0.7.11 C table wrapped it in
  `#ifdef <guard>` that no dev build defined, so it was left out. If you used a
  `:guard` to keep a NIF out of a build, remove the entry or narrow its
  `:archs` instead. An app with a project guard will see its committed
  `priv/generated/driver_tab_*` files change on the next
  `mix mob.deploy --native`; commit them. Until that build, `mix mob.doctor`
  reports the C table as stale.

## [0.7.11] - 2026-10-03

### Fixed

- **`mix mob.release --ios` links the project's own Swift and NIFs and plugin
  `:cpp_archive` NIFs (MOB-373).** A release ignored `mob.exs`
  `project_swift_sources`, failed to link an app with a project NIF
  (`c_src/<name>.c`, a Rust or Zig NIF, `:extra_static_libs`) or a cpp_archive
  plugin (`Undefined symbols: _<module>_nif_init`), and silently dropped a
  project NIF with a `:guard` from the driver table. The release now gathers
  each of these with the device build's own functions, defines each guarded
  project NIF's `:guard`, and links the archives after OTP's, in the device
  build's order. The MLX / NxEigen / TFLite NIFs (`mix mob.enable
  mlx|nxeigen|tflite`) are still not part of a release build, and a *device*
  build (`mix mob.deploy --native`) of a guarded project NIF still fails on an
  unknown zig option (MOB-376). See
  `decisions/2026-10-03-ios-release-links-project-inputs-and-plugin-archives.md`.
- **Plugin gate errors and plugin docs link `MOB_PLUGIN_SECURITY.md` and
  `MOB_PLUGINS.md` on GitHub.** Both live in the mob repo; the messages named
  files a mob_dev user doesn't have.

### Changed

- **A release compiles NIF sources at `-Os`, as the device build does.** This
  covers the project's C NIFs and the activated plugins' C/ObjC NIFs, which
  0.7.10 and earlier built at clang's default `-O0` in a release. An `.ipa`'s
  plugin NIF code is now optimized like a dev device build's.

## [0.7.10] - 2026-10-03

### Fixed

- **`mix mob.release --ios` links a fresh app (and one whose plugins ship
  Swift) (MOB-372).** The release build failed with `Undefined symbols: _mob_register_plugins`
  for an app with no plugins at all, because the release script compiled mob's
  Swift sources but never the generated bootstrap that defines that function
  (`AppDelegate.m` calls it unconditionally); `mix mob.deploy --native --ios` of
  the same app linked. The script now compiles the bootstrap and the activated
  plugins' Swift sources too, by the rule the dev builds use, so an app
  scaffolded before plugins existed still builds as before. Swift files listed
  in `mob.exs` `project_swift_sources` are still not part of a release build.
  See `decisions/2026-10-03-ios-release-compiles-plugin-swift-and-bootstrap.md`.

### Changed

- **`mix mob.release --ios` runs the plugin signature, trust and capability
  gate, as the dev builds do.** Before it starts signing, downloading or
  building, a release now stops on an unsigned plugin not listed in
  `acknowledge_unsafe_plugins`, a plugin signed by a key not in
  `trusted_plugins`, a tampered plugin (previously left out of the binary
  without a word), or plugin Swift that imports a framework its manifest
  doesn't declare. Plugins the dev builds accept pass the gate unchanged.
  (Plugin `static_archives`, `:cpp_archive` NIFs, are still not part of a
  release build.)

## [0.7.9] - 2026-10-02

### Added

- **`mob.exs` `multi_window: true` lets iPad users open several windows of
  the app (MOB-245).** The simulator and device builds and `mix mob.release`
  stamp it into the bundle's Info.plist as `UIApplicationSceneManifest` →
  `UIApplicationSupportsMultipleScenes` (`false` removes the key, which iOS
  reads as false; unset leaves the plist as written). The feature itself, one
  navigation stack per window in one BEAM (`Mob.Scene`), needs mob#184
  (unreleased); with an older mob, keep the key unset or `false`. A plist
  without an application scene configuration (`UISceneConfigurations` →
  `UIWindowSceneSessionRoleApplication`, the `SceneDelegate`) stops the build
  rather than gain a scene manifest with no delegate, and `mix mob.doctor` fails that
  combination with the fix. A non-boolean value stops the build.

- **`mob.exs` chooses the iOS devices and orientations (MOB-206).**
  `config :mob_dev, ios_target_devices: [:iphone, :ipad]` (or `[:iphone]`)
  sets the built app's `UIDeviceFamily`, and `ios_orientations: :all`
  (`:portrait`, `:landscape`) sets its iPhone `UISupportedInterfaceOrientations`;
  iPad always gets all four. The simulator and device builds and
  `mix mob.release` stamp them into the bundle's Info.plist, overriding
  `ios/Info.plist`, which isn't rewritten. Unset keys leave the plist as
  written, so an existing app builds as before until it opts in. An invalid
  value stops the build. See `decisions/2026-10-01-ios-layout-plist-keys.md`.

- **`mix mob.doctor` warns when an iOS app can't use iPad or Split View
  (MOB-206).** A warning, never a failure, when the Info.plist (with the
  `mob.exs` overrides applied) leaves out iPad (`UIDeviceFamily`, the case for
  every app generated by mob_new 0.6.3 or older), locks iPhone to portrait or
  landscape, restricts iPad orientations, or sets `UIRequiresFullScreen`. Each
  warning prints the `mob.exs` line and the plist XML that fix it; a choice
  made in `mob.exs` (`ios_target_devices: [:iphone]`,
  `ios_orientations: :portrait`) isn't warned about.

- **`mix mob.doctor` reports the Xcode and iOS SDK it will build with, and
  whether they can build for iPhone Duo (MOB-201).** The Tools section now
  shows `Xcode — 27.0 (27A266a) — /Applications/Xcode-27.0.app` and
  `iOS SDK — 27.0` for the Xcode selected by `DEVELOPER_DIR`, else
  `xcode-select -s` (the same one `mix mob.deploy --native` uses), and warns
  `iPhone Duo — unsupported (needs Xcode 27.1+)` when the Xcode or its iOS SDK
  is older than 27.1. The Duo row is a warning, never a failure; Xcode older
  than 15 still fails. A missing iOS platform (Xcode 15+ downloads it
  separately) is now a warning with `xcodebuild -downloadPlatform iOS` as the
  fix, and an unparseable `xcodebuild -version` warns instead of passing. See
  `decisions/2026-10-01-doctor-reports-xcode-and-duo-readiness.md`.
- CI: an `xcode` lane runs those rows against real Xcode 27.1 and 27.0
  (GitHub's `xcode-27` image) and Xcode 26.6 (`macos-26`).

### Fixed

- **The `mob_dir` / `:mob` dependency mismatch error says when the app would
  crash at boot (MOB-227).** When the two checkouts' `src/mob_nif.erl`
  `-nifs` tables differ, `load_nif` rejects the native library and the app
  dies at boot with `undef mob_nif:log/1`, with nothing pointing at the two
  mobs. The `mix mob.deploy --native` refusal and the `mix mob.doctor` row
  for a `mob_dir` that isn't the `:mob` dependency (MOB-351) now add that,
  listing the NIFs only one checkout has.

- **`mix mob.connect` finds an Android app whose node name it can't predict
  (MOB-229).** An app whose `Mob.Dist.ensure_started/1` base name isn't
  `<app>_android` (e.g. `crosscourt@127.0.0.1`, registered as
  `crosscourt_<serial>`) timed out with "distribution didn't start on the
  device" while the node was up. Connect now also accepts the node registered
  on the dist port it launched the app with, and reports the name it found.

- **A plugin activated twice is built once (MOB-325).** Listing a plugin
  twice in `config :mob, :plugins` passed the plugin-collision check but merged
  the plugin twice, so its NIF sources compiled and linked twice (duplicate
  symbols) and its Kotlin/Swift sources, manifest snippets and native
  registrations were added twice. The activated plugins are now deduplicated
  by plugin directory, the rule the collision check uses, before any build step
  reads them.

- **iOS native builds no longer leave their build dir in `$TMPDIR`, and
  rebuild faster (MOB-313).** Every `mix mob.deploy --native` to an iPhone
  left a `mob_ios_device_<n>` dir of ~190 MB (the bundled `.app`, binary and
  codesign scratch), whether the build worked or not; one machine had 3.3 GB
  of them. Simulator builds left a `mob_ios_sim_<n>` and a
  `mob_ios_bundle_<n>` each time, and a failed iOS device exqlite compile left
  `mob_exqlite_<n>`. The `.app` and scratch now go in a temp dir that is
  removed when the build returns, fails or raises. The sources mob_dev
  generates for `zig build` now live in `_build/<env>/mob_ios/<target>/`
  instead: their paths used to change every build, which made zig recompile
  all the Swift and relink each time. Rebuilding a blank app for the
  simulator with nothing changed took 21 s in `zig build` before and under
  1 s now. Dirs left by earlier versions aren't touched: remove them with
  `rm -rf "$TMPDIR"/mob_ios_device_* "$TMPDIR"/mob_ios_sim_* "$TMPDIR"/mob_ios_bundle_*`
  while no build is running. See
  `decisions/2026-10-01-ios-build-sources-stable-app-dir-removed.md`.

## [0.7.8] - 2026-10-01

### Upgrading

- **iOS users on mob 0.9.8 / mob_dev 0.7.7: update both (mob 0.9.9, mob_dev
  0.7.8) and redeploy with `mix mob.deploy --native`** (MOB-348). That
  installs mob 0.9.9's `mob_beam.m`, which reads the cookie file this release
  writes, and on a physical iPhone it is the deploy that writes the file (a
  phone that is only hot-loaded over dist gets none). Either half alone keeps
  0.7.7's behaviour: mob 0.9.8 ignores the file, and mob 0.9.9 with mob_dev
  0.7.7 finds none; in both cases only launches mob_dev makes get the private
  cookie, as before.

### Fixed

- **A native build stops when `mob_dir` isn't the `:mob` dependency
  (MOB-351).** `mix mob.deploy --native` and the iOS `mix mob.release`
  compiled mob's native code from `mob.exs` `mob_dir` and the Elixir side
  from the `:mob` dependency without comparing them, so an app could ship
  native code and BEAMs from two different mob commits and misbehave with no
  error. Both now raise before compiling anything, naming the two paths and
  how to make them agree (point `mob_dir` at the dependency, or the
  dependency at `mob_dir`); `mix mob.doctor` fails the same check. Symlinked
  paths to the same checkout match, and an unset `mob_dir` or a project
  without `:mob` is not checked. See
  `decisions/2026-10-01-mob-dir-must-match-mob-dep.md`.

- **iOS apps stay reachable after a relaunch mob_dev didn't make (MOB-348).**
  iOS got its private cookie only in the launch environment, so an icon tap,
  `xcrun simctl launch` or an agent-device relaunch (every relaunching
  `mix mob.smoke` flow) left the node on a random cookie: registered in EPMD,
  but `mob.connect --no-restart` and `mob.smoke`'s health checks timed out.
  `mix mob.deploy` now writes the cookie to `mob_dist_cookie` in the app's
  beams dir on iOS too, owner-only (`MobDev.DistCookie.write_app_file!/2`):
  the simulator runtime dir on every deploy, and `Documents/otp/<app>/` on a
  physical iPhone whenever the deploy copies BEAMs there (`--native`, or the
  app isn't connected over dist; a hot-load-only deploy writes nothing to the
  phone). mob 0.9.9's `mob_beam.m` reads it when the launch environment has
  none; mob 0.9.8 ignores it.

- **`mix mob.smoke` no longer says the app held up when it never read its
  health (MOB-347).** With the node unreachable (or `Mob.Diag.health/0`
  missing), every flow's check was skipped with a note, yet the summary showed
  `0 failure(s), 0 warning(s)` and the run ended "All flows passed and the app
  held up." The health column now reads `not checked (<reason>)`, or keeps
  the counts and adds `not checked (<reason>)` (or `k of n flow(s) not
  checked (<reason>)` when only some flows were checked) when there are any,
  and a passing run ends `All flows passed; app health not checked on:` with
  the devices and reasons. Exit status is unchanged (0 when the flows pass).
  If the node was unreachable before the first flow, the task now waits for
  it once after that flow, since recorded flows relaunch the app.

- **Compiler warnings in a native build no longer print as `failed
  command:`, and a failed NIF compile names its plugin (MOB-344).** zig's
  build runner prints any build step that wrote to stderr — a plugin NIF
  whose clang compile emits a warning — with a `failed command:` line, even
  though the step succeeded. An iOS simulator build showed nine of them and
  then `✓ iOS native build complete`; every NIF was in fact compiled in. The
  native `zig build` runs (iOS simulator, iOS device, Android, Zigler NIFs) now set
  `ZIG_BUILD_ERROR_STYLE=minimal` unless you set a style, and list warned
  steps after a successful build as warnings. When a compile does fail, the
  `✗ native build failed` line names the plugin and source and quotes the
  compiler's `error:` lines instead of only zig's exit code. See
  `decisions/2026-10-01-zig-build-warnings-vs-failures.md`.

### Documentation

- **`mix mob.deploy` documents the physical-iPhone exception to the
  legacy-app restart.** The 0.7.7 entry and the task's docs said an app built
  against a pre-MOB-49 mob is always restarted onto its private cookie. A
  connected physical iPhone is the exception: mob_dev never writes BEAMs to a
  phone it is hot-loading (a `devicectl` replace has no undo), so the legacy
  app is hot-loaded and keeps `mob_secret` until `mix mob.deploy --native`.

## [0.7.7] - 2026-10-01

### Security

- **Every platform uses a private distribution cookie per app; `mob_secret`
  is only a fallback for old apps (MOB-49).** The Mac-side node and every
  development app used the public cookie `mob_secret`, so anyone who could
  reach a dist port could run code on the phone or the Mac. mob_dev now keeps
  a random 256-bit cookie per app in `~/.mob/dist_cookies/` (owner-only) and
  hands it over at deploy/connect time: iOS in the launch environment, Android
  as a file in the app's private storage (sent over adb's stdin, never on a
  command line). An app built against a mob from before MOB-49 still answers
  only to `mob_secret`; `mob.connect`, `mob.deploy`, `mob.push`, `mob.watch`,
  `mob.attest`, `mob.smoke`, `mob.trace_otp` and the battery benches fall back
  to it with a warning. `mob.deploy` restarts such an app instead of
  hot-loading it, so updating `mob` and deploying (`--native` for iOS) moves
  it to the private cookie. `--cookie` now means "only this cookie". To attach
  by hand, load the cookie inside the VM
  (`Node.set_cookie(MobDev.DistCookie.for_project!())`) rather than passing
  it as `--cookie`, which would put it in the process arguments. See
  `decisions/2026-09-30-private-dist-cookie-for-every-platform.md`.

  **Upgrade note:** update mob_dev together with mob. mob 0.9.8 accepts only
  the private cookie this release hands over, so mob_dev 0.7.6 or older can't
  `mix mob.connect` (or deploy, push, watch) to an app built on mob 0.9.8.
  mob_dev 0.7.7 still reaches apps built on older mob through the
  `mob_secret` fallback; the first `mix mob.deploy` after updating mob
  (`--native` for iOS) moves the app to its private cookie.

### Fixed

- **Tasks that pick devices on their own leave another agent's leased
  device alone (MOB-330).** A bare `mix mob.deploy` installed and launched an
  app on an emulator another agent held through `agent-device`. When
  `agent-device` is on PATH, every selection the user did not name now skips
  devices another session has claimed (read from `agent-device device status
  --json`) and prints each one with the session holding it: single-device
  auto-selection and `--all-devices` / `--all-physical` in `mix mob.deploy`,
  `mix mob.uninstall` and `mix mob.smoke`, `mix mob.connect` without
  `--device`, `mix mob.push` / `mix mob.watch`, and the battery benches'
  device auto-detection. `mix mob.connect` also leaves the app running on a
  claimed simulator when it kills stale simulator apps, and `mix mob.watch`
  stops pushing to a device another session claims while it runs. An iPhone
  found only over the LAN is known by its IP while a claim names a UDID, so
  it is skipped whenever another session claims a device that could be an
  iPhone; name it with `--device` to use it. A claim is yours when its
  session equals `AGENT_DEVICE_SESSION`; with that unset, every claim is
  someone else's. A claimed device named with `--device` is still used, after
  a loud warning: naming it is the same consent `--device` gives for a phone.
  The device auto-selection picks is always printed. Without `agent-device`,
  selection is unchanged. See `MobDev.DeviceLeases`.
- **`mix mob.plugins` says when a plugin's signature doesn't verify (MOB-332).**
  Such a plugin was listed as `tier 0 … no manifest (regular dep)` after a
  separate `skipping <name>: invalid_signature` line, or not at all when not
  activated. Its row now shows `tier ?`, what is wrong (not signed, invalid,
  no public key, legacy v1) and the fix: re-sign a local checkout, reinstall,
  ask the author, or acknowledge an unsigned plugin for development.
- **`mix mob.plugin.trust` writes one trusted plugin per line and shows a path
  dependency's real version (MOB-334).** `config :mob, :trusted_plugins` was
  rewritten as one long line; it is now laid out as `mix format` would, sorted
  by name, so a trust change is a one-line diff. An existing one-line stanza
  is converted in place. The review showed `version: (unset)` for a path
  dependency; it now shows the version from the plugin's `mix.exs` (read, not
  evaluated) and the dependency's path.
- **`mix mob.deploy`'s documentation matches what it does (MOB-333).** The
  fast deploy hot-loads over Erlang distribution without a restart and only
  restarts when it has to write the BEAMs to the device; `--dist-port`
  defaults to the port derived from the device serial and app name, not
  `9100 + index`; the "Under the hood" commands are the ones the task runs.
- **The crypto shim no longer breaks concurrent deploys and test runs
  (MOB-339).** `Deployer.generate_crypto_shim` wrote one shared
  `$TMPDIR/mob_crypto_shim` in place, so two runs on one machine could delete
  or truncate each other's `crypto.beam` mid-compile ("error writing file").
  It now compiles in a private staging directory and renames it into a
  directory named for the shim's content. Tests that timed out only under
  machine load wait on their work instead of a 5 s clock, or carry an
  explicit timeout for legitimately heavy work.
- **`mix mob.smoke` explains why a flow recorded on an iOS simulator fails
  its identity check (MOB-343).** Such flows failed at their first tap with
  `REPLAY_DIVERGENCE … nothing in the current tree carries the recorded
  identity`. agent-device (seen on 0.21.1 and 0.21.19) records each step's
  identity (the `# agent-device:target-v1` line) from its simulator
  accessibility bridge, but replay checks it against an XCTest snapshot. Inside
  a SwiftUI `ScrollView`, XCTest labels the content container with its first
  child's text and keeps it, so the recorded ancestry (`scrollview`) never
  matches the replayed one (`other "☀️"`). A plain SwiftUI app fails the same
  way, so it is not a mob bug. On an iOS simulator, an `IDENTITY_MISMATCH`
  failure now carries a hint with the `sed` command that deletes the flow's
  identity lines; replay then resolves the step by its selector. The README
  ("Smoke flows on devices") says to strip them after recording on a
  simulator.

## [0.7.6] - 2026-10-01

### Breaking

- **Legacy v1 plugin signatures are refused again; the MOB-287 transition
  is gone (MOB-301).** Every first-party plugin now has a v2-signed
  release, so `MobDev.Plugin.V1Transition` is deleted: a v1 envelope no
  longer passes because the plugin comes from Hex, is pinned to hexpm in
  `mix.lock` and has a trusted key. Every v1 envelope now fails
  verification with `:envelope_v1_unsupported`, and its manifest is never
  evaluated. The per-build "uses a legacy v1 signature, accepted during the
  v2 transition" notice is gone too.

  **Upgrade note:** on mob_dev 0.7.6, apps locked to plugin releases signed
  before 2026-09-30 (e.g. mob_deliver ≤ 0.2.0) fail the native build with
  `plugin :<name> ships a legacy v1 signature`. Run
  `mix deps.update <plugin>` to move to a v2-signed release. A third-party
  plugin whose newest release is still v1 needs its author to re-sign with
  `mix mob.plugin.sign` (mob_dev 0.7.2 or later). See
  `decisions/2026-09-30-v1-envelope-transition.md`.

- **Plugin collisions now fail native and release builds (MOB-170).**
  Two activated plugins claiming the same component or native-view key,
  screen route, migration namespace, NIF module, native source destination,
  bridge class, Android manifest component, iOS plist key, supervised
  worker, notification match or default font used to build, and one plugin's
  registration silently overwrote the other's at runtime. Native builds and
  the `.ipa`/`.aab` release builds now refuse, naming each colliding
  `priv/mob_plugin.exs`, and `mix mob.plugins` exits non-zero on the same
  check (it now loads plugins listed in `acknowledge_unsafe_plugins` the way
  the build does, so it cannot pass what the build rejects). Shared Android
  permissions, Gradle dependencies and iOS frameworks remain allowed, and the
  same plugin activated twice is not a collision. An opted-in Android factory
  name that isn't a valid class name now fails early with the plugin named.

  **Upgrade note:** an app that built on 0.7.5 can now fail instead of
  silently keeping one plugin's contribution. Run `mix mob.plugins` to see
  the collision, then deactivate one plugin in `config :mob, :plugins` or fix
  the manifests (re-signing any signed manifest you change).

### Added

- **`mix mob.smoke` replays recorded UI flows on devices and checks the app
  held up.** It runs `agent-device test` on the `.ad` flows in `smoke/` on
  each selected device (`--serial`/`--udid`, per-device and per-flow artifacts
  and JUnit), judging the counts rather than agent-device's top-level
  `success`, which is true even when scripts fail. It reads
  `Mob.Diag.health/0` over dist before the first flow and after each one,
  without restarting the app: a store's `lost`/`resets` or the listener's
  undeliverable count rising during a flow fails the run, and no new receipts
  (the receipt store's cumulative `recorded`) is a warning on mob 0.9.7 or
  later, which receipts native taps; on older mob it is a note. Each flow runs on
  its own so a `--relaunch` cannot erase the previous flow's evidence; a
  reading from a relaunched BEAM is compared from zero.
  A device another agent-device session holds is skipped with its owner named
  and fails the run. Android's one-UiAutomation-client conflict with
  mobile-mcp gets a hint naming the `pkill` that clears it. See
  `decisions/2026-09-30-mob-smoke-replays-agent-device-flows.md`.
- **The legacy v1 signature refusal names the fix for a local checkout.** A
  path dependency is not changed by `mix deps.update`; the message now also
  says to re-sign it in place with `mix mob.plugin.sign`
  (`~/.mob/keys/<name>.priv`), and that a git dependency pinned with
  `ref:`/`tag:` needs a v2-signed ref. Path checkouts have been refused since
  MOB-74; the MOB-287 window only ever covered Hex checkouts.

- **`mix mob.new_plugin` scaffolds the signing release setup for tiers 1–4.**
  A plugin with a manifest is verified by every host, but the scaffold said
  nothing about signing, and its `mix.exs` had no `:mob_dev`, so
  `mix mob.plugin.keygen`/`mix mob.plugin.sign`/`mix mob.validate_plugin`
  were not even available in the plugin. Tiers 1–4 now get: a `.gitignore`
  that ignores `priv/mob_plugin.sig` (the committed key is
  `priv/mob_plugin.pub`); a `mix.exs` with a dev-only `:mob_dev` dep and
  `package files:` that ship all of `priv/`; and
  `.github/workflows/release.yml`, modelled on the first-party plugins'. On a
  `version:` bump in `mix.exs` it tags, creates the GitHub Release, checks that
  the `MOB_PLUGIN_SIGN_KEY` secret (the contents of
  `~/.mob/keys/<name>.priv`) derives the committed public key, runs
  `mix mob.validate_plugin` and `mix mob.plugin.sign`, then
  `mix hex.publish`, so the package always carries a fresh v2 signature over
  exactly what ships. Unlike the first-party workflow it refuses to publish
  when the signing key secret is missing instead of shipping an unsigned
  plugin. The printed next steps cover keygen, the secret, local signing and
  `mix mob.plugin.trust`; tier 0 (no manifest) has nothing to sign. The
  tier-4 hint now names `Mob.Plugins.get_setting/2` / `put_setting/3`.

### Fixed

- **Android native builds take the NDK sysroot from the project's SDK
  (MOB-72).** Zig used `ANDROID_HOME`/`ANDROID_SDK_ROOT` while the toolchain
  check and Gradle used `android/local.properties` `sdk.dir`, so an SDK
  configured only there passed the check and failed in zig with
  missing-header errors. The SDK root now resolves `sdk.dir`, then
  `ANDROID_HOME`, `ANDROID_SDK_ROOT`, then the OS default, and a missing
  sysroot fails with its path. If `sdk.dir` is stale, fix
  `android/local.properties`: setting `ANDROID_HOME` no longer overrides it.

## [0.7.5] - 2026-09-30

### Fixed

- **Two Mob apps on one device no longer share a dist port.** The port was
  hashed from the device serial alone, so a second app on the same emulator,
  simulator or phone claimed the first one's port: its dist failed with
  `{:EXIT, :nodistribution}` on Android and `eaddrinuse` on the iOS
  simulator. The port is now a crc32 of the serial **and the app name**
  (`MobDev.Tunnel.base_port/2`), still in `9100..9899` and the same on every
  run, and it is bumped past any port another live node in the Mac's EPMD or
  another device's `adb forward` holds. `mix mob.deploy` and `mix mob.connect`
  resolve it through the same function, so they agree. `mix mob.connect` also
  stopped removing every forward of the device it connects to: a forward
  another app on that device is live on, and other tools' `localabstract:`
  forwards, are left alone. An explicit `--dist-port` still wins.

  **Upgrade note:** an existing app's dist port changes once. Nothing needs
  doing: the next restarting `mix mob.deploy` (or `mix mob.connect`) starts the
  app on the new port and records it in `mob_dist`, and
  `mix mob.connect --no-restart` finds a still-running app on its old port
  through EPMD. Scripts that hard-coded the old serial-derived port should
  read the node's port from `epmd -names` or pass `--dist-port`.

## [0.7.4] - 2026-09-30

### Added

- **The project's application config now reaches the device.** Mob apps
  boot from `<app>:start()`, not a release, so `config/*.exs` never got there
  and `Application.get_env/3` returned nil for everything but `compile_env`
  values (mob_deliver's `:endpoint`, `:app` and `:channel`, for one). mob_dev
  now evaluates `config/config.exs` with `Config.Reader` for the build's
  `Mix.env()` and `Mix.target()`, merges `config/runtime.exs` on top when it
  exists, drops `:mob_dev`, and compiles the result into the Erlang module
  `mob_app_config` (`config/0` returns `[{app, [{key, value}]}]`).
  `mob_app_config.beam` is written next to `<app>.beam` in the app's compile
  path, so it ships wherever the app's BEAMs do: every `mix mob.deploy`
  (filesystem push and dist hot load, Android and iOS), the iOS simulator and
  device builds, and the Android and iOS release builds. mob 0.9.6 applies it
  with `Application.put_all_env/2` at app start. `runtime.exs` is evaluated
  **on the host at build time**, so `System.get_env/1` there reads the
  developer's environment, not the phone's. Values are embedded with
  `:erlang.term_to_binary/2`; a key whose value can't mean anything on
  another VM (an anonymous function, pid, port or reference, which includes a
  compiled regex) is left out with a warning. A config change reaches a
  running app at its next start. See `MobDev.AppConfig`.

- **`mix mob.connect --no-restart` attaches to the running app** instead of
  restarting it, so it can inspect a live session. The restart stays the
  default: it is what makes the app register in the Mac's EPMD through the
  tunnels just set up. On Android the running node is looked up in EPMD under
  either the deploy-time name (`<app>_android_<suffix>`) or the bare
  `<app>_android` a launcher start registers, and the port it actually
  registered is forwarded (never one another device already holds).

- **The runtime plugin manifest lists every activated plugin** as `plugins:`
  (manifest `:name`s, activation order). mob 0.9.6 starts each plugin's OTP
  application before any plugin `on_start`, including NIF-only and
  component-only plugins that have no lifecycle entry.

- **`mix mob.doctor` and Android native builds flag an app missing the
  lifecycle hooks.** mob 0.9.6 delivers `Mob.Device` `:app` events on Android
  only if the app-owned `MainActivity.kt` calls `nativeNotifyAppLifecycle` and
  `beam_jni.c` forwards it to `mob_send_app_lifecycle`. An app generated
  before mob_new 0.6.2 builds and runs without them and silently gets no
  events. When the mob checkout exports `mob_send_app_lifecycle` and either
  file lacks its half, both print which file and the exact code to add. An
  app on an older mob isn't asked to add a call that wouldn't link.

### Fixed

- **An Android deploy that restarts the app sets up the dist tunnels first.**
  After an emulator reboot (or an adbd restart) nothing had re-created
  `adb reverse tcp:4369`, so a restarted app logged `Mob.Dist: no EPMD on
  port 4369 after 10s -- skipping dist`, never registered, and
  `mix mob.connect --no-restart` had nothing to attach to. The deployer now
  reverses EPMD and forwards the dist port before `am start`, and warns if it
  can't.

- **`mix mob.connect --device <android serial>` no longer scans for iOS
  devices.** When every `--device`/`--only` pattern matches an Android device,
  the iOS discovery (which probes the LAN for physical iPhones, ~17 s) is
  skipped. `--no-restart --device emulator-5558` went from ~33 s to ~8 s.

- **iOS native builds of apps that depend on `emlx` can download MLX**
  (MOB-295). `MobDev.MLXDownloader` downloads
  `libmlx-0.25.1-ios-{device,sim}.tar.gz` from the `mlx-0.25.1` release of
  `GenericJam/mob`, which had never been published, so every such build
  stopped at a curl 404. The release now exists, built with
  `scripts/release/mlx/all_ios.sh` from MLX 0.25.1 and EMLX 0.2.0 against
  the `otp-5c9c69fc` iOS runtimes. `mix mob.enable mlx` now adds
  `{:emlx, "~> 0.2.0"}` instead of `~> 0.2`: the looser requirement resolves
  to EMLX 0.4.x (MLX 0.31/0.32, a different NIF table), whose Elixir side
  can't load the bundled 0.2.0 NIF. An iOS native build whose resolved EMLX
  isn't 0.2.x now fails before downloading the bundle, with the migration
  steps, instead of linking an app whose EMLX falls back to
  `Nx.BinaryBackend` at runtime. To migrate an app enabled earlier, run
  `mix mob.enable mlx --yes` (`--yes` now also answers the
  replace-dependency prompt) or edit the requirement, then
  `mix deps.update emlx`.

- **A hot `mix mob.deploy` no longer breaks the app's next launch.** Over dist
  the deployer also persists the BEAMs to the device (restart off), but the
  SELinux relabel of the files pushed as root ran only before a restart, and
  the exqlite NIF symlink was recreated after the exqlite relabel. The next
  launch from the launcher failed with `dlopen failed: library
  ".../exqlite-<vsn>/priv/sqlite3_nif.so" not found`. All of `otp/` is now
  relabelled once, after the last write, on every Android deploy. Other
  `exqlite-*` versions on the device (the OTP tarball ships one) are removed
  when the app's version is installed, as the iOS build already does.

- **`mix mob.deploy` reports what happened to each device.** A dist hot load
  printed "Apps restarted" although the app kept running with the same pid.
  The summary now says which devices were hot-loaded without a restart (and
  how to attach to them) and which were restarted.

- **A launcher start comes back under the deploy-time node name and port.**
  A deploy starts the app as `<app>_android_<suffix>` on the device's
  serial-derived port, but a start from the launcher has no intent extras and
  registered `<app>_android` on 9100, where no tooling looked. Every Android
  deploy now records the suffix and port in `files/otp/<app>/mob_dist`, which
  mob 0.9.6 reads when the intent carries none.

- **A second host session no longer fails on a taken node name.** `mix
  mob.connect`, `mix mob.deploy` and `mix mob.watch` start distribution as
  `mob_dev@127.0.0.1`; when another process on the Mac already holds it they
  now use `mob_dev_<os pid>@127.0.0.1` instead of failing with "the name
  mob_dev@127.0.0.1 seems to be in use" (connect) or silently falling back to
  push-and-restart (deploy). An explicit `--name` is still used as given.

- **`mix mob.install` and `mix mob.icon` write icons only for the platforms
  the project has.** An `--android` project got an `ios/Assets.xcassets` tree
  right after "iOS OTP skipped — no ios/ in project". `mix mob.install` also
  checked only the Android icon before writing its placeholder, so it could
  overwrite a custom iOS icon; it now fills in only the platforms that have
  none.

## [0.7.3] - 2026-09-30

### Security

- **Plugin signatures now cover the files the native build reads from a
  plugin** (MOB-297). The signer hashed only `.c`/`.h`/`.cpp`/`.zig` inside a
  NIF's `native_dir`, so every iOS Objective-C NIF (`.m`) was unsigned. It
  also skipped `cpp_archive` sources, NIFs relying on the default
  `native_dir`, and plugin migrations, fonts and images. The verifier
  checked only the files the signature listed. `mix mob.plugin.sign` now
  lists everything `MobDev.Plugin.Sign.build_inputs/2` derives from the
  build's own `Merge` gatherers: every compiled source and every file in its
  directory, plus the copied files. It also adds a signed coverage marker
  entry. `cpp_archive` `includes:` roots stay unsigned because a host can
  provision them (mob_nx_eigen downloads Eigen). A host seeing the marker
  refuses the plugin (`:invalid_signature`) if any build input is unlisted,
  e.g. a header dropped into a NIF directory after
  signing. Signatures made before this change carry no marker and verify
  exactly as before (the published mob_scanner 0.1.4 is a test fixture).
  New signatures still verify on mob_dev 0.7.2: the marker is a
  `file_hashes` entry for a path that never exists, so there is no new
  envelope key or atom for 0.7.2's `:safe` decode to reject, and 0.7.2
  hashes the missing path as empty bytes, which matches. Only the
  completeness check needs the upgrade. Plugin authors should re-sign. See
  `decisions/2026-09-30-plugin-signature-coverage.md`.

## [0.7.2] - 2026-09-30

### Fixed

- **Android `mix mob.deploy` refuses a device whose OTP runtime is only
  half-installed** (MOB-183). The per-app runtime probe checked for ERTS
  (`otp/erts-*/bin/erl_child_setup`) but not the release bootfile
  (`otp/releases/*/start_clean.boot`), so a device installed via `adb install`
  without `mix mob.deploy --native` (or an interrupted native deploy) showed
  "Deployed / Apps restarted" and then crashed at boot with
  `cannot get bootfile`. The probe now checks both and prints which one is
  missing before pushing; run `mix mob.deploy --native` to fix the device. See
  `decisions/2026-09-11-android-runtime-check-covers-bootfile.md`.

- **Plugin manifests no longer warn "unknown key(s) [:description]"**
  (MOB-291). The unknown-top-level-key warning in
  `MobDev.Plugin.Manifest.validate/1` did not recognize the keys it has no
  validation for, so every `mix mob.new_plugin` scaffold (which writes
  `description:`) logged a false "silently ignored" warning on every
  validate. `:description`, `:version`, `:tags`, `:host_config_keys` and
  `:setup` are recognized now: `mix mob.plugin.trust` prints `:version`,
  mob's `~MOB` sigil reads `:tags`, the generator host-config audit reads
  `:host_config_keys`, and `MOB_PLUGINS.md` documents `:description` and
  `:setup`.

- **iOS release builds no longer warn "STATIC_ERLANG_NIF macro redefined" on
  every plugin NIF compile** (MOB-284). The release script passed
  `-DSTATIC_ERLANG_NIF` alongside `-DSTATIC_ERLANG_NIF_LIBNAME=<name>`, but
  `erl_nif.h` derives the former from the latter. Only `_LIBNAME` is passed
  now; the `mix mob.add_nif` build guidance and plugin scaffold comment match.

- **iOS physical-device native builds of apps without `emlx` no longer try to
  download MLX** (MOB-282). Bundling `mlx.metallib` into the `.app` called
  `MLXDownloader.ensure_ios_device/0` unconditionally, printing
  `Downloading MLX 0.25.1 (ios_device)...` and a curl 404 on every build. The
  copy is now gated on `emlx` being a project dep, like the MLX link step.
- **A `mob.exs` that fails to evaluate now fails loudly instead of reading as
  empty** (MOB-280). `MobDev.Plugin.activated_names/0` wrapped
  `Config.Reader.read!` in a blanket `rescue`, so a syntax error (or a
  raising expression) in `mob.exs` became "no plugins activated": the native
  build linked no plugin NIFs, succeeded, and every plugin call raised
  `:nif_not_loaded` at runtime with nothing pointing at `mob.exs`. The same
  swallow is gone from `MobDev.Style.activated_names/0` / `default_style/0`,
  `MobDev.Plugin.SignatureGate.acknowledged_unsafe/0`,
  `MobDev.Plugin.TrustStore.load_trusted_plugins/1` (where `mix
  mob.plugin.trust` would otherwise rewrite the trust entry from an empty
  map, dropping existing entries) and `mix mob.plugins`, which now reads
  activation through `MobDev.Plugin.activated_names/0`. The Application-env /
  empty fallback still applies when `mob.exs` is missing. Each reader takes
  an optional `project_dir`.
- **Plugin NIFs that never reached the installed app are now named, not left
  to fail as `{:nif_not_loaded, ...}` at the first call** (MOB-281). A
  plugin's `on_load` tolerates a missing NIF, so both silent failure modes
  built and booted clean. `mix mob.deploy --native` and `mix mob.doctor` now
  warn about every device-runtime dep (not `only: :dev` / `runtime: false`)
  that ships a `priv/mob_plugin.exs` declaring `nifs:` but isn't in
  `config :mob, :plugins`, printing the exact `config` line to set. Each
  successful native build records, per platform, which activated plugins it
  compiled NIFs for (`mob_native_plugins.txt` under
  `Mix.Project.build_path/0`); a BEAM-only `mix mob.deploy` or `mix mob.push`
  warns when a NIF plugin was activated since ("the installed app was built
  without it — run `mix mob.deploy --native`"), or that it can't tell when
  there is no record for that platform yet. Warnings only, including a
  record that can't be written. Kernels live in `MobDev.Plugin.NifActivation`.
  Alongside: `MobDev.Plugin.Verify` now refuses a v2 signature envelope with
  malformed `file_hashes` entries as corrupt instead of raising
  `FunctionClauseError` mid-verification.
- **`mix mob.connect` attaches to this project's app on a physical iPhone, at
  the IP it actually registered** (MOB-283). iOS discovery took the first
  `*_ios` name in the phone's EPMD, so another Mob app on the same phone could
  be connected instead; it now picks the project's `<app>_ios` node (any
  `*_ios` only outside a Mix project) and reads every EPMD entry. A
  USB-discovered iPhone no longer assumes its node is named after the USB
  link-local IP: the link-local EPMD is queried, then the addresses registered
  under the phone's `.local` mDNS name, and the WiFi address is used when its
  EPMD lists the same node at the same dist port. Other LAN hosts are not
  considered. The link-local name
  remains the fallback when the app has not registered yet. EPMD replies are
  read to close under an overall 2 s deadline, and the LAN scan reads
  `arp -an`, skipping reverse-DNS lookups that took ~15 s per scan on a slow
  resolver.
- **`mix mob.security_scan --strict` no longer reports HIGH
  `MOB-DRIFT-ios_*-exqlite_beam` for the exqlite an iOS native build installs**
  (MOB-289). iOS tarballs don't ship exqlite (manifest `nil`); the native build
  installs the project's own exqlite into the cached OTP dir, and the
  `:hex_deps` layer audits it. When the cached version equals the exqlite in the
  scanned project's `mix.lock`, the scan now shows it as
  `exqlite <vsn> (deploy-installed, not tracked)` instead of flagging it. If the
  project has no exqlite or locks a different version, the cached copy is still
  HIGH drift, and the finding now says it is not the project's exqlite.
  Android exqlite and all other fields are checked as before.
- **`mob.exs` is committed project config; machine paths go in the
  gitignored `mob.local.exs`** (MOB-286). `mob.exs` carries plugin
  activation (`config :mob, :plugins`), trust and styles, but
  `mix mob.adopt.mob_exs` gitignored it — a clone activated no plugins and
  hit `:nif_not_loaded` at runtime — and `mix mob.install` rewrote the whole
  file as `config :mob_dev, mob_dir: <abs>`, dropping all of it. Now
  `mob.install` writes the prompted `mob_dir` to `mob.local.exs` (keeping
  anything else there), gitignores `mob.local.exs` if needed, and only adds
  the conditional `import_config("mob.local.exs")` line to `mob.exs` when
  it doesn't already import that file in any form; `mob.adopt.mob_exs`
  writes mob_new's portable `mob.exs`, gitignores `mob.local.exs`, and under
  `--local` puts the checkout path in `mob.local.exs`. `mob.plugin.trust`,
  `mob.deploy --beam-flags` and `mob.enable liveview` insert their stanza
  above that import, so `mob.local.exs` values still win. Projects adopted
  earlier must un-ignore and commit `mob.exs` by hand. See
  `decisions/2026-09-30-mob-local-exs-overrides.md`.
- **mob_dev now resolves alongside mob 0.9.x** (MOB-285). 0.7.1 required
  `mob ~> 0.7.25 or ~> 0.8.1`, so every app on mob 0.9 (all apps generated by
  mob_new 0.6.0) locked mob_dev 0.7.0 and missed every fix since. The
  requirement now also admits `~> 0.9.0`.

- **mint 1.10.0 → 1.11.0** (transitive, via req/finch) for EEF-CVE-2026-91043
  (high) and three medium HTTP/1 + HTTP/2 framing advisories reported by
  `mix mob.security_scan`.

- **`mix mob.deploy --native --device <id>` can no longer report success after
  skipping the named device's native build** (MOB-225). The selected device now
  makes its platform a required build target even without a redundant
  `--android` / `--ios` flag. Android native deploys also create the gitignored
  `android/local.properties` when the SDK is detectable, sharing
  `mix mob.install`'s path writer; when it is not detectable, the skipped
  requested build exits non-zero.

- **`mix mob.connect --name` is now honored end-to-end** (MOB-69). The
  option was parsed at the task layer, but `Connector.connect_all/1`
  called `ensure_local_dist/1` which hardcoded `Node.start(:"mob_dev@127.0.0.1", ...)`.
  By the time `start_iex/3` tried to honor `--name`, `Node.alive?/0` was
  already true and the second `Node.start` was skipped — so the multi-
  session workflow (one `--name mob_dev_N@127.0.0.1` per developer,
  documented in the task's moduledoc) crashed with the wrong node name
  registered in EPMD. Threaded `:name` through `connect_all/1` into a
  new `ensure_local_dist/2`, backed by a public
  `Connector.local_name_from_opts/1` helper so the option-plumbing is
  unit-testable.

- **`mix mob.deploy --slim` is no longer a silent no-op** (MOB-73).
  `NativeBuild.build_all/1` stored `slim` in the process dict
  (`Process.put(:mob_slim, slim)`) but the actual gate at
  `maybe_slim_otp_bundle/2` reads `System.get_env("MOB_SLIM")` — so
  `--slim` from the CLI never reached the strip pass. Only
  `mix mob.release` produced a slim OTP bundle, because that path
  explicitly sets the env var. Fixed by publishing `MOB_SLIM=1` / `=0`
  in `__apply_slim_env__/1`, which the gate already knows how to read.
  Two revert-verified tests lock the env-var contract.

- **Plugin signature verification now runs before `Code.eval_file`** on the
  manifest (MOB-74). The v1 signature covered the eval'd manifest map, so
  verifiers needed the eval to run first to rebuild the payload — letting
  a malicious `priv/mob_plugin.exs` execute arbitrary code on the
  consumer's machine during plugin activation.

  Envelope v2 puts the signed `file_hashes` list on disk alongside the
  signature; verification runs entirely off the envelope and the file
  bytes, no eval required. `priv/mob_plugin.exs` is one of those
  file_hashes, so tampering with the manifest bytes shifts its hash and
  fails verification. `MobDev.Plugin.Verify.load_verified/1` is the new
  safe consumer path: verify → then eval. `MobDev.Plugin.activated/0`,
  `mix mob.plugins`, `mix mob.audit_plugins`, and `MobDev.Plugin.Report`
  all migrated.

  **v1 envelopes are refused** — accepting them silently reopens the
  bug. The error is a distinguished `:envelope_v1_unsupported` reason
  and the `SignatureGate` message tells the author to re-sign with a
  mob_dev that produces envelope v2 (`mix mob.plugin.sign`). The one
  exception is the MOB-287 transition rule, described in the next entry.

  Follow-up tickets on file for the walk to data-only manifests
  (safe-by-construction): **MOB-185** (`mix mob.plugin.lint`),
  **MOB-186** (migrate first-party plugins to the pure-data subset),
  **MOB-187** (static JSON/TOML manifest at 1.0).

  See `decisions/2026-09-11-plugin-envelope-v2-verify-before-eval.md`.

- **Published first-party plugins keep building during the v2 transition**
  (MOB-287). Every first-party plugin on Hex (for example mob_scanner 0.1.3
  and mob_camera 0.1.8) is v1-signed, because plugin CI signs with the Hex
  mob_dev. Refusing all v1 envelopes would therefore break the next native
  build of every app that activates one. For this release window a v1
  envelope is accepted only when all of these hold:
  - Mix resolves the plugin through Hex, and `mix.lock` pins it as a Hex
    package from public `hexpm`.
  - The plugin directory is that package's checkout in `deps/`. Path and git
    deps don't qualify, including a path override that points into `deps/`.
  - The fingerprint of its `priv/mob_plugin.pub` is trusted for that name in
    `config :mob, :trusted_plugins`.
  - Its v1 signature verifies.

  The first three checks run before the manifest is evaluated, so a plugin
  that fails them never has its `priv/mob_plugin.exs` run. Any other v1
  envelope is still refused, and the `SignatureGate` error now states the
  rule. Each plugin accepted this way prints one line per build:
  `<plugin> <vsn> uses a legacy v1 signature, accepted during the v2
  transition (MOB-287); it will be refused once re-signed releases ship`.
  `mix mob.plugin.sign` still produces v2 only. The rule lives in
  `MobDev.Plugin.V1Transition` and will be removed once every first-party
  plugin has been republished with a v2 signature. See
  `decisions/2026-09-30-v1-envelope-transition.md`.

- **Play upload keystore no longer bakes a trailing newline into the stored
  password** (MOB-71). `Mix.shell().prompt/1` returns the whole input line
  including the trailing `\n`, and `MobDev.GooglePlay.SetupWizard.generate_keystore/2`
  passed that value verbatim to both `keytool -storepass` and
  `android/keystore.properties`. The keystore then contained a password
  ending in `\n` that Gradle read literally on every subsequent release
  signing — a value nobody could re-type at the next Play upload, and only
  discovered when a signed AAB fails to upload with a stack trace deep
  inside `bundletool`. The three prompts (passphrase, name, org) are now
  trimmed at the source (`prompt_trimmed/2`), and
  `keystore_properties_content/1` is extracted and public so the trim is
  covered by revert-verified unit tests.

### Changed

- **`mix mob.add_nif --demo` notice names both home-screen shapes**
  (MOB-188). Once mob_new #68 lands, the generated default app is the
  Mishka Chelekom showcase, whose home lists demos through
  `Kit.compact_button/2` in `demo_buttons/1`; the notice shows that form
  first and keeps the `nav_button/2` form for `--blank` apps and for apps
  generated before the change.

- **`mix mob.deploy` now freezes an explicit target set before doing work**
  (MOB-169). A bare deploy automatically targets exactly one emulator or
  simulator and never a physical phone. `--device`, `--all-devices`, and
  `--all-physical` provide explicit single, development-device, and physical
  scopes; `ANDROID_SERIAL` acts as the Android single-device selector on
  Android-only runs.

  Compatibility checks, native installs, and the final BEAM push all consume
  the same snapshot, so a device appearing during the build cannot join the
  operation. On the iOS path, the diagnostic that annotates a mid-copy
  failure is now extracted (`Deployer.finalize_ios_override_result/2`) so
  a `:skipped` result stays `:skipped` and only real `:error` finalizations
  can inherit the incomplete-override wording.

  **Behaviour change** — a bare `mix mob.deploy` with **two or more emulators
  or simulators running for parallel testing** used to fan out to both, and
  now refuses with an ambiguity error demanding an explicit selection
  (`--device <id>` or `--all-devices`). This is the loudest new failure mode:
  scripts and shell aliases that relied on the fan-out will need one of the
  new flags. Physical devices also no longer receive an implicit deploy —
  name one with `--device` or pass `--all-physical` to include them.

  See `decisions/2026-09-11-deploy-freezes-explicit-targets.md`.

---

## [0.7.1] - 2026-09-11

### Added
- **`MobDev.Differential.run/3` emits divergences onto `Mob.Defect.Bus`**
  (MOB-159 phase 2). A `{:divergence, ...}` result now emits a
  `Mob.Defect.Capsule` (kind: `:divergence`, owner: `:mob`) via
  `Mob.Defect.emit_divergence/2`. New `:fixture` option threads a
  caller-supplied identifier into the capsule's fingerprint key so
  the same divergence in the same fixture groups across runs.

  `:ok` and every documented `{:error, _}` shape (`:not_ready`,
  `:differential_unavailable`, `:tree_error`, `:comparator_error`) do
  not emit — nothing to report on a clean run, and a harness gap is
  not a framework defect. A novel result shape logs at `:error` via
  `Logger` and does not emit, so adding a new taxonomy member is an
  explicit decision to compare or skip rather than silent absorption.

  The emit path is guarded by `Code.ensure_loaded?(Mob.Defect) and
  function_exported?(Mob.Defect, :emit_divergence, 2)`, so a resolver
  picking a `mob 0.7.x` (still allowed by the widened dep spec below)
  silently no-ops instead of crashing with `UndefinedFunctionError`.
  Callers who require the emit should pin `mob >= 0.8.1` themselves.

### Changed
- **`{:mob, ...}` dep spec widened to `~> 0.7.25 or ~> 0.8.1`.** Blocks
  0.8.0 (which predates `Mob.Defect.emit_divergence/2`) while still
  admitting the 0.7.x line for consumers who have not moved to the 0.8
  series yet.

---

## [0.7.0] - 2026-09-11

### Added
- **`MobDev.Differential.run/3` drives the iOS/Android view-tree comparator
  against two live device BEAMs** (MOB-157). The orchestrator samples both
  trees via `:rpc.call(node, Mob.Test, :view_tree, [node])`, then invokes
  `Mob.Differential.compare/3` on one of the devices, iOS by convention. If
  iOS returns `{:badrpc, {:EXIT, {:undef, _}}}` (i.e. the device's mob build
  predates the comparator) the orchestrator falls over to the Android node.
  If either device has not rendered yet (a root with `children: []`) the run
  short-circuits with `{:error, :not_ready}` before the comparator is called;
  a `:not_ready` answer from the comparator itself is **not** retried on the
  other node; any other `{:error, _}` from the comparator surfaces as
  `{:error, {:comparator_error, node, other}}` so the documented error
  taxonomy stays a closed set; if **neither** node has `Mob.Differential`
  loaded (both apps predate the comparator), the run returns
  `{:error, :differential_unavailable}`. An error tuple from `Mob.Test.view_tree/1` (e.g. Android's
  `{:error, :not_loaded}` when the Kotlin bridge predates
  `MobBridge.uiViewTree()`) surfaces as `{:error, {:tree_error, node,
  reason}}`, distinct from `:not_ready`. `frame_tolerance_dp` is forwarded
  to the comparator; `rpc_timeout` bounds every RPC (worst-case wall clock
  is roughly `3 * rpc_timeout` because the three round-trips are
  sequential). Unknown options raise `ArgumentError` rather than being
  silently dropped. The RPC module is injectable so callers can stub it in
  tests.

### Fixed
- **Tier-2 projects from `mix mob.new_plugin` compile again** (MOB-168). The
  generated module documentation embedded one heredoc inside another, which
  ended the outer string early and left the example markup as invalid Elixir.
  The same compile check caught a tier-3 list referring to module data as a
  screen assign; executable navigation coverage then caught a callback that did
  not match its row-tap messages. Generated tier-3 lists now render and navigate
  correctly. Scaffold tests compile a real generated project for every tier.

- **`mix mob.connect` no longer force-quits every app on an attached iPhone**
  (MOB-70). Clearing other Mob apps off the device before a launch is
  necessary — each one starts an in-process EPMD on `0.0.0.0:4369`, so only one
  can run at a time — but the code decided what to clear by matching every
  process under `Bundle/Application/`, which is where **all** third-party apps
  live. Plugging in a personal iPhone and running `mix mob.connect` terminated
  every app its owner had open, and the `except_bundle` argument meant to spare
  the target app was discarded outright.

  Measured against an attached iPhone SE: the old code would have killed 15
  user apps, TestFlight among them. It now kills 0 unless mob_dev installed
  them itself.

  mob_dev records what it installs (`MobDev.IOSInstalls`, `~/.mob/ios_installs.json`)
  and kills only that. An absent or unreadable record means kill nothing.
  **Behaviour change:** a Mob app installed by another route — Xcode,
  TestFlight, a colleague's build — is no longer cleared, so it will still hold
  EPMD 4369 and the launch will fail as it did before mob_dev cleared anything.
  See `decisions/2026-09-06-mob-dev-kills-only-what-it-installed.md`.


### Changed

- **`mix mob.deploy` rejects unrecognised options instead of ignoring them.**
  A run that passed an extra or misspelled flag used to carry on regardless;
  it now fails and names the option. **If you have a wrapper script, CI step
  or shell alias passing a flag this task does not accept, it will start
  failing.** It also takes no positional arguments — `mix mob.deploy --native
  ABC123` deployed to every device and said nothing, and now refuses.

### Fixed

- **`mix mob.deploy` hot-pushed over distribution without writing the BEAMs to
  disk, so changes reverted on the next restart.** The dist and filesystem
  paths were mutually exclusive: when a device answered over dist, the new
  modules were loaded into the running VM and the on-disk copies were left
  stale. The app then reverted to the last filesystem deploy whenever it
  restarted — and `mix mob.connect` restarts the app, so connecting to inspect
  your change was enough to undo it. This is the long-standing "`mix
  mob.deploy` didn't do anything" report; `--native` was unaffected because it
  skips dist entirely. The dist path now hot-loads *and* writes, without
  restarting, and a failed write is reported instead of being hidden behind a
  successful hot load. Verify with `mix mob.attest` after a restart.
  Physical iPhones are excluded and get the hot load plus a warning: they
  are often LAN-only with no `devicectl` route, and the write is a
  no-undo replace that a mid-copy interruption would leave unbootable.

- **`mix mob.deploy --help` and `mix mob.mutate --help` refused to help.**
  Strict option parsing turned the flag people try first, on the task they run
  most, into `Unrecognized or invalid option(s): --help` — telling the reader
  to go and read the help. Both now defer to `MobDev.TaskHelp`, as
  `mix mob.uninstall` already did.
- **`mix mob.uninstall` ignored `:ios_bundle_id`.** The task resolved one
  bundle id for both platforms and passed it down, so the per-platform clause
  that was supposed to pick `:ios_bundle_id` for an iOS device never ran. On a
  device holding the app under a different iOS id, uninstall removed nothing
  and reported success. The accompanying test passed an opts shape the task
  never sends, which is why the suite stayed green.
- **`mix mob.deploy`'s error told you the equals form was required for
  dash-prefixed values.** It stopped being true in the same commit that wrote
  it, once the spaced form was handled.

- **`--beam-flags "-S 4:4 -A 4"` aborted the deploy.** `OptionParser` will not
  consume a dash-prefixed argument as a string value, so the spelling this
  repo prints in seven places — the moduledoc, five README recipes and both
  battery-bench workflows — parsed as two unknown options. Under the previous
  lenient parsing the value was silently dropped and the deploy carried on
  with whatever `mob.exs` held; strict parsing turned that into a hard failure
  whose message named `--beam-flags` itself as unknown. Both spellings work
  now.
- **The error said "Unknown option(s)" for options that are known.**
  `--schedulers abc` reported `--schedulers` as unknown and discarded the
  value, sending the reader to a help page that lists it. It now distinguishes
  an unrecognised flag from a bad value and prints both.

### Added

- **`mix mob.attest`** — prove the device is running the code you just pushed.
  Compares `module_info(:md5)` on the device against `:beam_lib.md5/1` of the
  local `.beam`, so a push that never landed, landed in the wrong place, or
  landed and was never loaded all show up. Exits non-zero on a mismatch, and
  also when the check could not run — a check that could not run is not a check
  that passed. Modules the device has not loaded yet are reported and are not a
  failure. Defaults to the exact set `mix mob.deploy` pushes, so what is
  attested cannot drift from what was shipped. Written after a bundle-id
  divergence let a BEAM push succeed against the wrong app's container,
  printing a tick while the app kept running old code; it found a second live
  instance (MOB-161) on its first real use.

- **`mix mob.mutate`** — mutation testing for the lines a branch changed.
  Breaks the production code one line at a time and reports the changes nothing
  noticed, which is the only cheap way to tell a test that guards something
  from one that merely runs. Doing this by hand has found real defects every
  time it has been tried here, and got the baseline comparison wrong once,
  reporting a surviving mutation as caught. The baseline is always captured by
  running, never supplied. `--file`, `--base`, `--test-command`, `--max`,
  `--json`; exits non-zero when anything survives.

- **`mix mob.deploy --json`** — a machine-readable result on stdout listing the
  deployed, failed and skipped devices with their per-device reasons, and an
  `outcome` that mirrors the exit status. Progress output is redirected to
  stderr for the run, so `mix mob.deploy --json | jq` receives exactly one
  document. Emitted on the native-build failure path too, which is when a
  caller most needs it.

- **`:ios_bundle_id`** in `mob.exs`, for when Android's `applicationId` is not a
  legal Apple bundle id. Apple forbids the underscores Android allows, and
  `com.example.*` is often already claimed by another Apple team, so the two
  frequently cannot be the same string. Every iOS path — deploy, connect,
  provision, battery bench, uninstall, the simulator and device builds,
  code-signing, and the release IPA — resolves `:ios_bundle_id || :bundle_id`.
  Android keeps using `:bundle_id`.

### Changed

- **`mix mob.deploy` no longer exits 0 after shipping nothing.** Four ways it
  could, all fixed (MOB-150):
  - `--android --native` with no `sdk.dir` in `android/local.properties`
    printed a skip warning, built nothing, and succeeded. The old rule was
    `ok_count == length(results)`, which is `0 == 0` for a run that produced no
    results at all.
  - `--ios` / `--android` where every device of that platform was skipped.
    A skip stays non-fatal when it is incidental — a phone that happens to be
    attached — and is fatal when the run named that platform. The rule is per
    **platform**, not per device: one simulator deploying while a stale one is
    skipped is a success, not a failure.
  - `--device X` that reached X and deployed nothing to it, and `--device NOPE`
    matching no device at all.
  - `--ios` on Linux, which resolves to no platforms and enumerated no devices.

  A `--native` run that built the artifact and found no device to push it to
  still exits 0 — "build the APK now, attach the phone after" is unchanged.

- **`mix mob.deploy` now exits non-zero when a device fails.** A run that
  printed `Failed on 1 device(s)` previously still returned status 0, so CI and
  wrapper scripts read a failed deploy as success. Every device is still
  attempted and the full summary still printed first — only the exit code
  changed. A device that is *skipped* because the app is not installed on it
  stays non-fatal on both platforms. **If you have CI that deploys to several
  devices and has been passing, it may now fail** — check whether it was
  passing on a partial deploy.

### Fixed

- **`mix mob.deploy` silently ignored unknown options.** `-d` was never aliased
  to `--device` here — `mob.connect` has aliased it all along — so
  `mix mob.deploy -d <udid>` deployed to *every* connected device instead of
  the one named, without a word. A typo'd `--devcie` did the same. Parsing is
  strict now and the run refuses, naming the offending options.

- **iOS deploys installed the app under one bundle id and pushed BEAMs at
  another.** `:ios_bundle_id` was resolved by the build but discarded by the
  deployer and connector, so `mix mob.deploy --native --device <udid>` installed
  successfully and then failed with *"App '…' is not installed on this device."*
  On a machine that also had an older build under the other id it was worse: the
  push silently succeeded against the wrong app and reported success.
- **The simulator and device builds disagreed about the bundle id.** The
  simulator build took it verbatim from `ios/Info.plist` while the device build
  used the configured id, so `simctl launch <configured-id>` failed. Both paths
  now stamp and print the id they installed.
- **The device build looked its provisioning profile up by the Android id**, so
  a profile minted by `mix mob.provision` (which uses the iOS id) was never
  matched.
- **The release IPA was stamped and signed with the Android `applicationId`**,
  which App Store Connect rejects when it contains an underscore.
- **`mix mob.uninstall` targeted the Android id on iOS devices**, removing
  nothing and reporting success.
- **`mix mob.doctor` warned that `bundle_id` was unset** for a project that
  correctly set only `ios_bundle_id`.
- **Stamping `CFBundleIdentifier` crashed on an `Info.plist` that lacked the
  key** — plausible for `mix mob.adopt` projects — instead of adding it.

## [0.6.33] - 2026-09-04

### Fixed
- **The shared OTP root keeps one version of an app, not every version it has
  ever seen** (MOB-143). The root is keyed by OTP hash rather than by project,
  so every app on the machine shares one `lib/`. Installing a dependency
  without removing its other versions left them side by side, and the code
  server resolves an application to the **highest** version it finds there.

  That splits an app in half. Its beams come from its own `mix.lock` and are
  pushed to the device; `code:lib_dir/1` — and so `code:priv_dir/1`, which is
  where `load_nif` looks — resolves against the shared directory. An app
  locking exqlite 0.38.0 would load 0.38.0's beams and 0.40.0's
  `sqlite3_nif.so`, and `on_load` would fail with `bad_lib`, taking down every
  database connection and the boot with it. The visible error talked about
  database credentials and said nothing about a cached artifact.

  It was also **non-local**: an app that built fine yesterday broke because a
  *different* app upgraded a dependency. `pythonx` always swept by wildcard and
  never had this; `exqlite`, `emlx` and the generic installer removed only a
  malformed empty-version directory and let real versions accumulate.

  All four install paths now sweep other versions, keep the target, and log
  what they removed. A stale version left by an earlier build is cleaned on the
  next native build, so the first build after upgrading may report removals.

## [0.6.32] - 2026-08-31

### Fixed
- **Android plugin component registration is backward-compatible again**
  (regression in 0.6.31 — **do not use 0.6.31**; upgrade from 0.6.30
  directly to 0.6.32 or later).
  `ui_components.android.composable` remains the native registry key used by
  existing plugin bridges instead of being treated as a callable Kotlin
  symbol. Generated registration is now explicit through `android.factory`;
  opted-in factories receive both `props` and the native event sender. The
  generated factories run before bridge registration so a bridge-owned
  factory remains authoritative for the same key.

- **Skew note:** `android.factory` is inert on mob_dev ≤ 0.6.30 (silently
  ignored — the component renders only if bridge/host-registered).

## [0.6.31] - 2026-08-31

### Fixed
- **Android hosts now register plugin `ui_components` composables
  automatically** (mob_scene3d-q03). The manifest's
  `ui_components.android.composable` was data nobody consumed: every host had
  to hand-register the Compose factory in `MainActivity.onCreate`, and a host
  that forgot rendered the component as *nothing* —
  `MobNativeViewRegistry.render` returns silently on an unknown key. The
  generated `MobPluginBootstrap.registerAll(this)` (already called by every
  generated/adopted MainActivity) now registers each activated plugin's
  composable with the app's `MobNativeViewRegistry`, mirroring iOS's
  `mob_register_plugins()` bootstrap (`MobDev.Plugin.AndroidBootstrap`). The
  registry key is `android.view_module`, falling back to `ios.view_module`;
  a bare `composable` is qualified with the `bridge_class` package. Silent
  blanks are gone: a typo'd composable fails the Gradle Kotlin compile, a
  malformed declaration (no key / no composable) fails the mob_dev build with
  the manifest error, and a declared-but-unresolvable composable (the
  hand-copied tier-2 workflow) registers a loud red "Missing native
  component" placeholder + `Log.e` that the host's own later registration
  overwrites. The validator also rejects an `android.composable` that isn't a
  Kotlin identifier or dotted path, at validate time instead of Gradle time.

## [0.6.30] - 2026-08-30

### Fixed
- **0.6.29's published package could not compile.** The compile-time Zig-pin
  reader loaded the repository-root `.tool-versions`, which Hex omits from
  packages. The required version now lives in packaged source (lockstep test
  against the repo's `.tool-versions`), and a packed-artifact regression
  compiles the real Hex layout and exercises doctor/native exact, mismatch,
  and failed probes. Use 0.6.30 instead of 0.6.29.

## [0.6.29] - 2026-08-30

### Changed
- **Zig preflight now enforces the exact Android build toolchain.**
  `mix mob.deploy --native` and `mix mob.doctor` derive the required version
  from `.tool-versions` (`zig 0.17.0-dev.269+ebff43698`) and report
  missing/mismatched/broken zig with exact `mise install` commands, replacing
  the stale `zig 0.15.x` advice (asdf guidance dropped — asdf cannot fetch
  historical dev nightlies). **Behavior change:** a non-exact zig now fails
  the preflight even for legacy projects with app-owned `build.zig` that
  previously built with other versions — install the pinned version
  (`mise install zig@0.17.0-dev.269+ebff43698`). Companion to mob_new
  0.4.27, which pins the same version in generated projects.

## [0.6.28] - 2026-08-28

### Added
- **`mix mob.doctor` warns when a project still carries the pre-MOB-104 sheet
  dismissal wiring.** An app generated before the fix routes
  `Mob.UI.sheet/2`'s `:on_dismiss` through `MobBridge.nativeSendTap` and
  delivers `{:tap, tag}`, but the documented contract — and iOS — deliver
  `{:dismiss, tag}`. A screen written to the contract never matches it and
  dies with `FunctionClauseError`, or the dismissal is silently swallowed and
  the sheet can never be re-presented. `MobBridge.kt` is app-owned and never
  re-rendered, so mob_new's template fix doesn't reach existing projects (see
  `decisions/2026-08-25-detect-dont-autopatch-native-source.md` for why this
  repo detects rather than auto-patches). The check names the two-line port and
  notes it needs mob >= 0.7.31. Only fires on projects that actually render
  sheets, so apps predating `Mob.UI.sheet/2` stay quiet (MOB-104).

### Changed
- **Physical iOS overrides are now replaced, not merged**
  (`devicectl --remove-existing-content`), and the transfer is verified before
  the app is restarted. Note the trade-off: because `mob_beam.m` prefers
  `Documents/otp/<app>` on directory existence alone, a transfer interrupted
  after the copy begins leaves an incomplete override that the next launch will
  still prefer over the signed bundle. There is no rollback — `devicectl` has no
  delete verb, and an empty directory is still preferred — so the deploy now
  says so explicitly and names the recoveries (re-run the deploy, or reinstall).

### Fixed
- **`mix mob.deploy` no longer drops dependencies reachable only through
  `extra_applications`.** The runtime filter stopped consulting the project's
  own `.app` file, so a dep declared `runtime: false` and opted back in via
  `extra_applications:` — the documented idiom — was silently never pushed. The
  app booted and died with `undef` on first use. The project's `.app` is
  traversed again, with dev-only deps subtracted afterwards rather than by
  skipping the traversal.
- BEAM discovery follows `Mix.Project.build_path/0` and `compile_path/0` instead
  of a hardcoded `_build/dev`, so a non-dev `MIX_ENV` or a custom `:build_path`
  is honoured. Projects using `build_per_environment: false` previously pushed
  no dependency BEAMs at all.
- `mix mob.deploy` from an umbrella root now reports that mob does not support
  umbrellas instead of leaking a raw `Mix.Project.app_path/1` error.
- A physical-iOS deploy targeting a WiFi-only device returns a per-device error
  instead of throwing past the run and aborting every other device's summary.
- iOS staging directories are PID-qualified, so two concurrent deploys cannot
  delete each other's staging mid-copy.

## [0.6.27] - 2026-08-26

### Added
- **`mix mob.doctor` detects the pre-fix Android component-event JNI
  mismatch** (see `mob`'s 0.7.25 CHANGELOG entry) in an already-generated
  project's `MobBridge.kt` and points at the fix — this repo doesn't
  auto-patch hand-editable native source (see
  `decisions/2026-08-25-detect-dont-autopatch-native-source.md`), so an
  existing app stays broken until a human ports the template fix or
  regenerates; this surfaces it via `mix mob.doctor` instead of an
  `UnsatisfiedLinkError` on first real interaction with a tier-2 native
  component. (MOB-98)

### Fixed
- **`mix mob.connect` waited the full ~500ms iOS accessibility settle
  delay even against physical devices**, where it does nothing —
  `IOS.enable_accessibility/1` shells out to `xcrun simctl`, simulator-only
  tooling. Now gated on simulator type as well as platform, matching the
  same predicate already used elsewhere in this file for the identical
  reason. (MOB-99)

## [0.6.26] - 2026-08-25

### Added
- **`MobDev.Plugin.Manifest.validate/1` now warns on unknown top-level
  keys in `priv/mob_plugin.exs`.** `mix mob.plugins` previously reported
  "vetting clean" for a manifest carrying keys that meant nothing — the
  validator only checked the shape of known keys, never that every key
  was one of them. A `styles:` / `default_style:` key (which belongs in
  the separate `priv/mob_style.exs` manifest — see `mob`'s
  `MOB_PLUGINS.md`) validated silently and then went nowhere. The
  warning is spec-versioned: a manifest declaring a `plugin_spec_version`
  this `mob_dev` doesn't support already errors separately
  (`check_spec_version`), so an unrecognized key there doesn't pile a
  second, noisier warning on top — forward-compat stays intact. For a
  *supported* spec version, an unrecognized key is almost always author
  error, and now says so instead of vanishing.

Found via a real report from someone building a style plugin against
this system.

## [0.6.25] - 2026-08-25

### Added
- **Plugin-declared default fonts, part of the mob custom-fonts feature
  (see [`mob`'s `MOB_FONTS.md`](https://github.com/GenericJam/mob/blob/master/MOB_FONTS.md)).**
  A capability plugin's `priv/mob_plugin.exs` manifest can now carry a
  `default_font: %{family:, file:}` field — `family` is the iOS PostScript
  name (can't be derived from the filename, so the plugin author supplies
  it), `file` must be one of the plugin's own `assets.fonts` entries.
  `MobDev.Plugin.Manifest`'s validation pipeline checks the shape and the
  file reference; `MobDev.Plugin.Merge.default_font/1` gathers it;
  `MobDev.Plugin.Validator`'s conflict surface makes two plugins declaring
  one a build error (only one can win, since `Mob.Theme.fonts[:default]`
  is a single slot); `MobDev.Plugin.RuntimeManifest.build/1` converts the
  survivor to the on-device `%{ios:, android:}` spec read at boot by
  `Mob.Plugins.default_font/0`.
- **`Mob.Font.android_resource_name/1` is now called from mob core instead
  of a local copy.** The plugin asset planner (`MobDev.Plugin.Assets`)
  used to carry its own filename→Android-resource-name normalization,
  kept in sync with mob's runtime copy (`Mob.Theme.font/2`) only by
  convention. Both now call the one implementation in `mob`, so a build-time
  bundled font and its runtime theme token can no longer drift apart.

### Fixed
- **`{:mob, path: "../mob"}` only resolved on a machine with a sibling
  `mob` checkout — broke `mob_dev`'s own CI**, which has no such directory
  (`** (Mix) Cannot compile dependency :mob because it isn't available`).
  Switched to a git dependency during font development, and now to the
  proper Hex constraint (`~> 0.7.25`) now that mob has published the
  version carrying `Mob.Font`.

## [0.6.24] - 2026-08-19

### Fixed
- **`mix mob.deploy`'s ERTS preflight silently treated a non-debuggable
  APK as healthy, so every subsequent `run-as`-based BEAM push failed
  and got swallowed too.** `ensure_erts_on_device/2` only pattern-matched
  the preflight `run-as ... ls ...` output for `"No such file"` /
  `"not found"` (missing-ERTS signatures). A `run-as: package not
  debuggable: <pkg>` response — the normal signal for an APK built as a
  release/non-debug variant — matched neither, so the check returned
  `:ok` and `deploy_android` proceeded straight into
  `push_beams_android`/`push_priv_android`/`push_exqlite`, each of which
  shells out to `run-as #{pkg} tar xf ... 2>/dev/null; true`. That
  trailing `; true` exists to swallow Toybox tar's benign
  chown-to-macOS-UID exit code, but it also swallowed the `run-as`
  failure itself — so `mix mob.deploy` reported `✓` while nothing
  reached the device. `ensure_erts_on_device/2` now detects any
  `run-as:`-prefixed response and returns a clear, actionable error
  (rebuild via `mix mob.deploy --native` to get a debuggable install)
  before any push is attempted.

## [0.6.23] - 2026-07-12

### Fixed
- **`cpp_archive` / nx_eigen NDK resolution honors `ANDROID_HOME`.**
  `MobDev.Plugin.CppArchive` and `MobDev.NxEigenNif` hardcoded
  `~/Library/Android/sdk/ndk/<ver>` (+ the `darwin-x86_64` host), ignoring
  `ANDROID_HOME` / `ANDROID_SDK_ROOT` — so a `lang: :cpp_archive` plugin build
  failed with `NDK toolchain not found` wherever the NDK lives elsewhere (CI, a
  shared SDK), unlike the main `NativeBuild` path. Extracted shared
  `MobDev.NdkVersion.{root,host,toolchain_bin,sysroot}/0` (on the existing
  env-aware `sdk_root/0`) and routed `cpp_archive`, `nx_eigen_nif`, and
  `native_build`'s `ndk_sysroot` through them — one source of truth, so the NDK
  path can't diverge again. (MOB-89)

---

## [0.6.22] - 2026-07-11

### Added
- **`ios_release_screenshot` config — opt mob's public-API `screenshot` NIF into
  release builds.** Companion to mob 0.7.20 (mob#71), which carves `screenshot/3`
  into `#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)`. Setting
  `config :mob_dev, ios_release_screenshot: true` exports `MOB_ENABLE_SCREENSHOT=1`,
  and the generated `release_device.sh` compiles `mob_nif.m` with
  `${MOB_ENABLE_SCREENSHOT:+-DMOB_ENABLE_SCREENSHOT}` — so a release build can ship the
  screenshot NIF (letting an agent SEE a shipped app's screen to error-correct) while
  the private synthetic-input NIFs (tap/type) stay stripped. Default is unchanged and
  byte-identical (screenshot stays stripped); enabling it is a deliberate choice because
  the NIF captures the app's own window with no OS prompt or indicator. (#39)

## [0.6.21] - 2026-07-08

### Fixed
- **iOS app icons are flattened opaque so App Store upload validation accepts
  them.** A transparent source icon (a common design — a rounded badge on
  transparent corners) produced transparent iOS icons and tripped altool error
  90717 ("Invalid large app icon … can't be transparent or contain an alpha
  channel"). `IconGenerator.write_ios_icons` now composites an alpha-bearing
  source onto an opaque background (`Image.flatten!`) before resizing — using the
  explicit `--adaptive-bg` colour when given, else the same colour sampled for
  the Android adaptive background, so both platforms stay consistent; an opaque
  source is left untouched. **Android icons keep their transparency** (adaptive
  foregrounds + legacy launcher icons need it), and the bundled fallback
  `mob_logo` iOS assets were pre-flattened. Verified: a transparent-badge icon
  now passes App Store validation and reaches TestFlight. (#37)

---

## [0.6.20] - 2026-07-07

### Fixed
- **iOS release builds now compile + link activated-plugin NIFs.** `mix
  mob.release --ios` produced a binary that failed to link for any app with NIF
  plugins — `Undefined symbols: _<module>_nif_init, referenced from
  _erts_static_nif_tab in driver_tab_ios.o`. iOS statically links every NIF into
  the single app binary (no `dlopen` under the App Store sandbox), so the
  generated `driver_tab_ios.c` references each activated plugin's
  `<module>_nif_init`, but the release path (`release.ex` → `release_device.sh`)
  hand-compiled a fixed object list and never touched plugins (the dev path
  already compiled them via `build.zig -Dplugin_c_nifs`). `release_env/2` now
  emits `MOB_PLUGIN_IOS_NIF_SOURCES` + `MOB_PLUGIN_IOS_FRAMEWORKS` from
  `MobDev.Plugin.activated()` (via the new pure, tested
  `MobDev.Release.plugin_ios_build_env/1`); `release_device.sh` compiles each
  plugin NIF source with `-DSTATIC_ERLANG_NIF_LIBNAME=<basename>` + `-fmodules`
  (framework autolink) and links the objects plus the declared frameworks.
  Verified end-to-end: a 10-plugin app links and the IPA validates against its
  App Store distribution profile. Scope: covers `nif_sources` (`lang: :c |
  :objc`) + `ios_frameworks`; plugin `swift_files` and `:cpp_archive` static
  archives on the release path remain a follow-up. (#36)
- **Test: `MobDev.Release.HelpersTest`'s git fixture no longer inherits the
  ambient git environment.** Run inside a git hook (`.githooks/pre-push`), git
  exports `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`; the fixture's `git`
  commands inherited them and operated on the outer repo instead of their
  tmpdir, crashing setup — so the suite passed standalone but failed only on
  push. The fixture now clears those vars on every `git` invocation. (#36)

---

## [0.6.19] - 2026-07-07

### Added
- **Plugins can contribute AndroidManifest `<application>` components and `res/`
  files.** Two new optional `android:` manifest keys —
  `manifest_application_snippets` (XML fragments spliced into the app's
  `<application>` block, idempotent per `android:name`) and `res_files`
  (plugin-relative paths copied into the app `res/` tree at their derived
  `res/<type>/<file>` destination, path-contained + host-clobber-guarded +
  signed). This closes the gap that forced plugins needing a
  `<service>`/`<receiver>`/`<provider>` + resource (e.g. `mob_nfc`'s HCE
  `HostApduService` + `apduservice.xml`) to make it a manual `host_requirement`.
  New `MobDev.Plugin.Merge.android_manifest_snippets/1` + `android_res_files/1`
  gatherers (classified in the cross-plugin conflict surface), manifest-schema
  validation, and `NativeBuild` splice + copy (ledger-pruned like `bridge_kt`).
  (MOB-39)

### Changed
- **Plugin contributions merged into host-owned build files are now reversible
  (going forward).** Plugin `<uses-permission>`s, `<application>` components, and
  Gradle deps are fenced in a regenerated-each-build managed region
  (`MobDev.Plugin.ManagedBlock`) in `AndroidManifest.xml` / `build.gradle`, so
  removing a plugin drops its **fenced** contributions (no more dangling
  `<service>` / orphan permission / orphan dep) while hand-authored content
  outside the fence is untouched. Idempotent (`strip(place(x)) == x`); de-dupes
  against existing entries; `strip` is orphan-BEGIN-safe (never deletes host
  lines between a stray marker and a real region). **Forward-only:** entries an
  older mob_dev already appended *unfenced* are indistinguishable from
  hand-authored ones, so they're left in place (not double-added, not
  auto-removed) — regenerate the app, or remove them by hand, to fence them.
  (MOB-40)

### Fixed
- **`mix mob.new_plugin` no longer scaffolds plugins pinned to the abandoned
  `mob ~> 0.6`.** `MobDev.Plugin.Scaffold` hard-coded `{:mob, "~> 0.6"}` in the
  generated `mix.exs` and `mob_version: "~> 0.6"` in every tier's manifest, so a
  freshly scaffolded plugin could not activate against the published mob 0.7.x
  (`installed :mob 0.7.x does not satisfy mob_version "~> 0.6"`). The requirement
  is now derived at scaffold time from the mob actually resolved in the project
  (`Scaffold.detect_mob_requirement/0`), falling back to a single
  `@fallback_mob_requirement` constant (`"~> 0.7"`) when mob isn't loadable.
  `mix.exs` and the manifest always agree. A `Scaffold` test pins the default and
  asserts a generated manifest validates against a matching mob version, so the
  pin can't silently lag a future mob release. (#21)

---

## [0.6.18] - 2026-07-05

### Added
- **`mix mob.provision` can authenticate via an App Store Connect API key**, so
  an unattended user — a CI runner or a headless agent account with no GUI login
  — can provision without a signed-in Xcode Apple ID account. Set `APP_STORE_CONNECT_KEY_ID`,
  `APP_STORE_CONNECT_ISSUER_ID`, and `APP_STORE_CONNECT_API_KEY_PATH` (the downloaded `AuthKey_<id>.p8`)
  and the task passes them to `xcodebuild` as `-authenticationKeyID` /
  `-authenticationKeyIssuerID` / `-authenticationKeyPath`. All three or none
  (a partial set raises, naming what's missing); with none set the signed-in
  Xcode account is used exactly as before. Pure, tested `Mix.Tasks.Mob.Provision.asc_auth_args/1`.
  Signing still needs the certificate + private key in an unlocked keychain — the
  API key only authorizes the profile/device calls. (#31)

## [0.6.17] - 2026-06-29

### Fixed
- **`mix mob.connect` crashed on a Mac set up only for iOS.** The dist-port
  collision check shelled out to `adb forward --list`, and `System.cmd("adb", …)`
  *raises* `:enoent` for a missing binary rather than returning a non-zero exit —
  inside a linked `Task`, so the crash propagated and killed the whole connect.
  `MobDev.Tunnel.run_adb/1` now resolves `adb` via `System.find_executable/1`
  first (mirroring `Discovery.Android.list_devices/0`) and returns `{:error, …}`
  when it's absent, so the port scan degrades to "no forwards" and iOS-only
  Macs work with no Android platform-tools installed. (#29)

### Added
- **`mix mob.connect --ios-only` / `--android-only`** restrict discovery to one
  platform — handy when a phone for another project is plugged in. Set it
  project-wide in `mob.exs` with `config :mob_dev, platforms: [:ios]`. New pure
  helpers `MobDev.Config.parse_platforms/1` and
  `Mix.Tasks.Mob.Connect.resolve_platforms/2`. (#29)

---

## [0.6.16] - 2026-06-24

### Added
- **Plugins can contribute array-valued iOS plist keys** (e.g.
  `UIBackgroundModes`). `apply_plugin_plist_keys!` previously skipped any
  non-scalar `ios.plist_keys` value as unsupported; a list value now **merges**
  into the host Info.plist array (creating it if absent, appending only the
  missing string entries, deduped) instead of clobbering it. So one plugin can
  add `bluetooth-central` while another (e.g. `mob_background`) keeps `audio`.
  Merge decision extracted to the pure, tested `NativeBuild.plist_array_additions/2`.

---

## [0.6.15] - 2026-06-23

### Security
- **Bumped `req` 0.5.18 → 0.6.2** (pulls `finch` 0.22.0 → 0.23.0), clearing
  EEF-CVE-2026-49755 (HIGH) and EEF-CVE-2026-49756 (LOW) flagged by
  `mix mob.security_scan`. `req` is a transitive dep (via `igniter`); the bump
  stays within `igniter`'s `~> 0.5` requirement.

---

## [0.6.14] - 2026-06-23

### Added
- **`:extra_static_libs` hook on `:static_nifs` entries** — a Mob app can now
  link external per-ABI static archives alongside its project NIF archives.
  Some project NIFs intentionally declare `extern` symbols and don't host-link
  their backing archive (avoiding a host/device archive mismatch during `mix
  compile`); this lets the native app link resolve those symbols against the
  correct per-ABI `.a`. Entries require concrete per-ABI keys (`:ios_sim`,
  `:ios_device`, `:android_arm64`, `:android_arm32`, `:android_x86_64`), and the
  matching archive paths are appended to the existing `-Dproject_rust_libs=`
  link argument. `-D<module>_static=true` is emitted only on the ABIs where the
  entry applies, and iOS project-NIF filtering is now per-ABI so a device-only
  guarded entry doesn't leak into simulator args. Also passes Zigler 0.16's
  required generated-build flags when re-driving staged Zig NIF builds and
  resolves Zigler dotfile sources with `match_dot: true`. Verified on a physical
  SM-T577U (arm64-v8a) tablet via a Ghostty VT NIF. (#24)

---

## [0.6.13] - 2026-06-20

### Changed
- **`mix mob.deploy --native` now preserves on-device app data when the signing
  key matches.** The Android install path previously ran an unconditional
  `adb uninstall` before `adb install`, which wiped `MOB_DATA_DIR` (on-device
  identity, screen stores) on **every** native deploy — even an in-place update
  signed with the same (e.g. committed) debug keystore. It now attempts
  `adb install -r` first and only falls back to uninstall + install when the
  in-place update is genuinely rejected (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`
  from a signature mismatch, `INSTALL_FAILED_VERSION_DOWNGRADE`, etc.). Apps that
  pin a committed debug keystore now keep their identity across `--native`
  redeploys. Decision logic extracted to `NativeBuild.needs_clean_reinstall?/2`
  and unit-tested.

### Fixed
- **`mix mob.deploy --native --android` now fails fast with the real cause when
  `zig` is missing** (landed in code before 0.6.13; previously undocumented).
  The Android JNI build is driven by `build.zig`; with `zig` off PATH it used to
  print a yellow "skipping build.zig step" warning and fall through to a CMake
  fallback that references C sources mob 0.7+ no longer ships, dying ~150 lines
  later with a misleading `Cannot find source file: .../mob_nif.c`. It now aborts
  before Gradle with an actionable message (install zig 0.15.x, verify with
  `mix mob.doctor`) when `build.zig` is present, `zig` is absent, and the legacy
  C sources are gone. Decision extracted to `NativeBuild.zig_build_plan/3`. (#20)

---

## [0.6.12] - 2026-06-19

### Fixed
- **16 KB page-size alignment enforced at build time for every app.** Android
  15+ devices use 16 KB memory pages and Google Play requires every bundled
  `.so` to have 16 KB-aligned LOAD segments. The `-Wl,-z,max-page-size=16384`
  link flag lives in the app's `build.zig`, which is copied once at `mix mob.new`
  and never regenerated — so apps generated before the template carried the flag
  kept linking 4 KB-aligned `.so` and failed Play. `mix mob.deploy --native` now
  reads the app's `build.zig` and, if its `-shared` link lacks the flag, injects
  it before linking (idempotent — a no-op when already present, e.g. the current
  mob_new template or a hand-fixed app). Pure core in `inject_page_size_flag/1`.

---

## [0.6.11] - 2026-06-19

### Added
- **`mix mob.adopt`** — installs Mob into an *existing* Phoenix project,
  the Igniter-based install-into-existing counterpart to `mix mob.new`
  (which generates from scratch). Composable: the orchestrator runs the
  sub-installers `mob.adopt.{deps,bridge,screen,mob_app,mob_exs,native,finalize}`,
  each invokable independently. Default LV-bridge mode wires `window.mob`
  through a LiveView `phx-hook` and generates a `mob_app.ex` that boots the
  host Phoenix endpoint on-device (SQLite Repo assumed); `--no-live-view`
  generates a thin-client shell whose WebView opens a deployed server.
  Pre-1.0: refuses loudly (via Igniter issues) on umbrella / non-Phoenix /
  heavily-customised `app.js` or root layout / non-SQLite LV hosts rather
  than risk breaking the app. The native Android/iOS trees (`--android` /
  `--ios`) render from mob_new's templates and require the **mob_new archive
  installed** (`mix archive.install hex mob_new`) — mob_new stays the single
  source of native templates; the Elixir-side adoption needs no archive.
  Contributed as [mob_new#8](https://github.com/GenericJam/mob_new/pull/8)
  by [@ken-kost](https://github.com/ken-kost) and relocated here — adopt is
  an Igniter task that mutates an existing project (like `mob.add_nif` /
  `mob.enable`), so it belongs in mob_dev (a Hex dep), not in mob_new (a
  self-contained Mix archive that can't carry Igniter). See
  `decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md`. The shared
  patcher/generator helpers are duplicated from mob_new into
  `MobDev.Adopt.{Patcher,Generator}` pending the Phase-5 Igniter
  reunification.

---

## [0.6.10] - 2026-06-19

### Added
- **Plugin `:cpp_archive` NIFs** — ship a C++ static library (e.g. an Nx
  backend) from a plugin. Manifest-driven cross-compile + `--whole-archive`
  static link (rule #11), with `<module>_nif_init` symbol verification and
  duplicate-init cross-validation. (#18)

### Fixed
- cpp_archive builds now **fail fast with a named error** on an unsupported
  Android ABI (e.g. the x86_64 emulator) instead of silently skipping — which
  previously deferred an unresolved `<module>_nif_init` to an on-device link
  failure.
- The plugin manifest now requires a lowercase `:module` atom for
  `:cpp_archive` entries (fail-loud instead of fail-open / `libnil.a`).

---

## [0.6.9] - 2026-06-18

### Fixed
- **`mix mob.publish --android` now commits when Google requires
  `changesNotSentForReview=true`.** Uploading a release while the app is under
  policy review (or otherwise can't auto-send for review) made the Play Edits
  `:commit` fail with HTTP 400 ("Please set the query parameter
  changesNotSentForReview to true"), discarding the whole edit so nothing
  reached the track. `commit_edit/3` now detects that 400 and retries the commit
  with `?changesNotSentForReview=true`; the changes land on the track and are
  sent for review from the Play Console UI. Verified uploading Io v13 to the
  internal track.

---

## [0.6.8] - 2026-06-18

### Added
- **`mix mob.connect --only <serial>` (alias `--device`/`-d`, repeatable).**
  Restricts the run to devices whose serial/udid contains the given substring.
  Without it, connect attaches to *every* running device, so one slow or locked
  device (typically a plugged-in physical iPhone whose app restart blocks) could
  stall the whole session before any node connected. Verified end-to-end against
  a single Android phone: `mix mob.connect --only ZY22CRLMWK` tunnels, restarts,
  and connects `livebook_mob_android_zy22crlmwk@127.0.0.1` on its serial-derived
  port 9633, then RPC into the device BEAM works (read live state, eval code).

---

## [0.6.7] - 2026-06-18

### Breaking
- **`MobDev.Tunnel.setup/2` → `setup/1`.** The dist port is now derived from
  the device serial internally, so callers no longer pass an index. `setup/1`
  is `@doc`'d public API, so out-of-tree callers (a custom deploy Mix task,
  for example) break at compile time with `MobDev.Tunnel.setup/2 is undefined
  or private. Did you mean: setup/1`. Fix: drop the second argument — the
  returned `%Device{}` carries the assigned `dist_port`.

  *(Noted retroactively. This shipped in 0.6.7 mentioned only as a
  parenthetical inside a "Fixed" bullet about dist-port keying, where anyone
  scanning for breaking changes would miss it — reported by @dl-alexandre in
  GenericJam/mob_dev#25.)*

### Fixed
- **`mix mob.connect` reliability — dist ports keyed by device serial, not run
  index.** The Mac runs one shared EPMD; assigning ports as `9100 + index` meant
  *every* project's first device claimed 9100, so two phones (or two projects'
  device-0) registered the same port and `adb forward tcp:9100` could only reach
  one — the other silently timed out. Ports are now derived from the device
  serial (`Tunnel.serial_base_port/1`, a crc32 hash into 9100..9899) and bumped
  past any port another live node/forward already holds (`assign_dist_port/2`).
  A given phone always gets the same unique port across runs and projects, and
  deploy and connect agree on it.
- **Stale-tunnel cleanup.** `Tunnel.setup` removes the device's own old forwards
  first (scoped to that serial), so prior runs no longer leave duplicate/wrong
  forwards that poison the next connect.
- **Real diagnostics on connect failure.** A timed-out node now reports *why* —
  app not running / Standby-killed, dist never registered in EPMD, registered at
  a different port, no forward, or cookie mismatch — instead of a black-box
  "timed out".

---

## [0.6.6] - 2026-06-18

### Fixed
- **Android native build skips ABIs the app's `build.zig` doesn't handle**
  instead of hard-failing. mob_dev builds arm64-v8a/armeabi-v7a/x86_64 by
  default, but an app's app-owned `build.zig` (copied at `mix mob.new` time)
  may predate x86_64 support (mob_new < 0.4.5) and reject `-Dabi=x86_64`. That
  used to fail the whole native build — aborting *before* the
  `io.mob.plugin.MobPluginBootstrap` regen, so the next gradle build then failed
  on an unresolved bootstrap. Now each ABI is pre-flighted against the build.zig
  (`build_zig_supports_abi?/2`) and unsupported ones are skipped with a warning
  (gradle `abiFilters` wouldn't ship them anyway). Real failures of a SUPPORTED
  ABI still halt the build.

---

## [0.6.5] - 2026-06-17

### Changed
- **Default OTP runtime → Elixir 1.20.1** (`@otp_hash` `7d46fdd4` → `5c9c69fc`).
  Same OTP-29 / erts-17.0 / OpenSSL 3.4.0 base; the bundled Elixir stdlib
  (elixir/logger/eex) is swapped rc.5 → 1.20.1 across all five tarballs
  (android, android-arm32, android-x86_64, ios-sim, ios-device), published as
  the `otp-5c9c69fc` release on GenericJam/mob. `bundled_versions.exs` adds the
  `5c9c69fc` bundle and flips `active_hash`. Backward compatible — apps pick up
  1.20.1 on their next `mix mob.deploy` (recompile against the new runtime).
  NOTE: the major.minor skew check treats rc.5 and 1.20.1 as both "1.20", so it
  does NOT warn on this transition; the stdlib swap is what makes beams load.

---

## [0.6.4] - 2026-06-16

### Added
- **Android x86_64 emulator support** (resolves GenericJam/mob#20). The `x86_64`
  ABI is now wired throughout: `OtpDownloader.ensure_android("x86_64")` (#11),
  the x86_64 native build path (`zig_build_android_objects`, `ensure_jni_libs`,
  `otp_dir_for_abi`), and an `android_x86_64` target in `mix mob.release.otp`
  plus the `scripts/release/*x86_64*` build scripts. The
  `otp-android-x86_64-<hash>.tar.gz` runtime is published on the `otp-<hash>`
  release. This is the slice x86_64 Linux / CI hosts need, where ARM emulation
  isn't available.

## [0.6.3] - 2026-06-16

### Fixed
- **exqlite NIF symlink now picks the device's actual ABI.** The Android
  deployer hardcoded `lib/arm64` for the `sqlite3_nif.so` symlink target, so on
  a 32-bit (`armeabi-v7a`) device the link dangled, exqlite was
  `:nif_not_loaded`, and any generated app using ecto_sqlite3 crashed on boot.
  It now probes `lib/<abi>/libsqlite3_nif.so` (Android extracts only the active
  ABI). Found + fixed verifying the showcase on a 32-bit Moto E — SQLite
  migrations now run and the app boots.

## [0.6.2] - 2026-06-15

### Fixed
- **Create an app-level driver_tab when a NIF-bearing plugin is active.** A
  generated app ships no driver_tab and links against mob's core static-NIF
  table — which has no plugin entries. So a plugin's `<module>_nif_init` linked
  but never registered, and the NIF was `:nif_not_loaded` on device (the home
  rendered, the Kotlin/permission bridge worked, but the actual capability call
  crashed). `regen_driver_tab!` now creates `priv/generated/driver_tab_*.zig`
  (core + plugin entries) when plugins contribute NIFs and the app has none.
  Found + fixed verifying the showcase app on a physical Android phone and the
  iOS simulator (location demo now returns a real fix on both).

## [0.6.1] - 2026-06-15

### Fixed
- **Prune orphaned plugin artifacts when a plugin is removed.** Plugin tier-3
  merges copy files into the host tree (bridge Kotlin into the Kotlin sourceSet,
  migrations, images); these lingered after a plugin was dropped, and an
  orphaned bridge `.kt` could break the Gradle compile. `NativeBuild`'s kotlin /
  migration / image merges now ledger what they write (per concern, under
  `priv/generated/.mob_plugin_artifacts/`) and delete what a prior build
  produced but the current one no longer does — so add/remove of a plugin is
  clean in both directions.

## [0.6.0] - 2026-06-12

### Added
- **Style packages, tokens-only tier**: `MobDev.Style` (priv/mob_style.exs loader + validator), `config :mob, :styles`/`:default_style` activation, runtime-manifest emission (misconfiguration fails the build), and `mix mob.styles`.
- **`ui_components` `expand:` form honored** (pure-Elixir composites): validated native-XOR-expand; expand-only plugins classify tier 2 but hot-push; the runtime manifest carries `composites` for boot registration.
- **`mix mob.doctor`**: pre-plugin build.zig detection (missing `-Dplugin_*` options) and the host_requirements lane.
- **`host_requirements` manifest key** printed by every native build; `mix mob.new_plugin` scaffolds starter test suites for every tier.

### Changed
- **Native builds auto-regenerate the static-NIF driver_tab** (was a checked-in artifact whose staleness produced runtime `:nif_not_loaded`).
- **`mix mob.regen_plugin_manifest` loads the host app first** — spec-v2 generators may call host modules (mob_ash), not just read config.

### Fixed
- **Dep detection uses `Mix.Project.deps_paths`**, not stale `_build` dirs — ends the spurious MLX-404 downloads for apps that never dep emlx.
- ExSlop is registered as a credo PLUGIN (it had silently never run).

## [0.5.17]

### Added
- **`mix mob.new_plugin` scaffolds a starter test suite for every tier** (`test/test_helper.exs` + `test/<name>_test.exs`): tiers 1–4 get stdlib-only structural manifest checks (required keys, NIF stub loadable, `native_dir` exists, screen modules compile) with a pointer at `mix mob.validate_plugin` for the full validator; tier 0 gets a compile smoke test. New plugins start covered instead of starting at zero tests.
- **Plugin `host_requirements` manifest key.** A plugin can declare human-readable host-app obligations the build can't automate (e.g. the AndroidManifest `<service android:foregroundServiceType="mediaProjection">` fragment mob_screencast needs, or a capture `FileProvider`). The manifest validator enforces the shape, `MobDev.Plugin.Merge.host_requirements/1` gathers them, and every native build prints them as a warning block — previously forgetting the manual step built + booted clean and only failed at first feature use.
- **`mix mob.doctor` detects pre-plugin build files.** When plugins are activated but a `build.zig`/`build_device.zig` declares no `b.option` for the `-Dplugin_*` flags the native build emits, doctor warns with the exact missing options — previously Zig rejected the unknown flag half a build in.

### Changed
- **`mix mob.deploy --native` regenerates the static-NIF driver table on every build** (whatever formats the project uses, zig and/or c), exactly like the runtime plugin manifest: it's derived state. A stale checked-in `driver_tab_*` used to link a newly activated plugin's `<module>_nif_init` without registering it, so every NIF call raised `:nif_not_loaded` at runtime with nothing pointing at the cause.

### Added
- **`mix mob.new_plugin` scaffolds tiers 3 (multi-screen) and 4 (sub-app)**, not just 0–2. Tier 3 emits two `Mob.Screen` modules + a `:screens`/`:migrations` manifest + a namespaced Ecto migration; tier 4 emits a lifecycle module + supervised worker + notification handler + settings editor screen + a `:lifecycle`/`:settings`/`:notifications` manifest. Generated manifests validate and modules compile against real mob.
- **Cross-plugin conflict detection.** `MobDev.Plugin.Validator.conflict_surface/0` classifies every merge gatherer; `cross_validate/1` fails the build when two activated plugins clash on any shared resource — screen route, component atom, iOS/Android native view key, migration `repo_namespace`, NIF module, Swift/JNI source basename, Android bridge class, iOS plist key, supervised worker name, or notification match. A completeness meta-test forces every new shared-resource field to be classified; a property-based fuzzer checks detection is sound + complete across random N-plugin sets. A single plugin declaring a cross-platform NIF (one iOS + one Android entry sharing a `:module`) is correctly not flagged.

### Changed
- **`mix mob.deploy --native` regenerates the runtime plugin manifest (`priv/generated/mob_plugins.exs`) on every build**, not only when `config :mob, :plugins` changes. Adding/changing a plugin's tier-3/4 sections previously shipped a stale manifest (the new sections silently didn't activate on device); it is now derived state, always rebuilt before bundling — like the driver table.

### Fixed
- **iOS simulator deploy now boots from a clean `mix mob.deploy --native`** (was device-only). Three gaps made the sim deploy incomplete vs the device path, so the sim crashed on boot even though the device worked:
  - The Elixir-distribution apps `elixir`/`logger` were staged only under `lib/<app>/ebin`, which the sim's `mob_beam.m` doesn't add to the code path — boot failed at `ensure_all_started(:elixir)` with "elixir.app not found". They're now flattened into the flat BEAMS_DIR alongside `eex` (which already needed this), where the path resolves.
  - `priv/` was only partially staged (`repo/migrations`), so `Application.app_dir(:<app>, "priv/cacerts.pem")` was `:enoent` and `Mob.Certs.load_cacerts!` crashed the boot. The whole `priv/` is now rsynced into the flat dir (cacerts, `mix`/`hex` ebins, vendored static, …), matching the device release.
  - `Paths.sim_runtime_dir/0` fell back to `/tmp/otp-ios-sim` for zig-based projects (no `ios/build.sh`), but the runtime is synced to `~/.mob/runtime/ios-sim` — so the launcher and staging disagreed. It now recognizes `ios/build.zig` and returns the default runtime dir.
  Verified: a clean `mob.deploy --native` to an iPhone 11 Pro Max sim boots Io, Phoenix endpoint up, embedded Livebook home renders — no manual runtime fixups.

## [0.5.16]

### Fixed
- **Gate the plugin flags on the iOS *device* build path too.** 0.5.15 gated the plugin-flag emission for Android and the iOS *simulator* build, but `zig_build_binary_ios_device` still emitted `-Dplugin_swift_files`/`-Dplugin_frameworks` (and generated the bootstrap, making `plugin_swift_files` always non-empty) unconditionally — so `mix mob.deploy --native` to a physical iPhone broke on an app scaffolded before the plugin system (`invalid option: -Dplugin_swift_files`). The device path now mirrors the sim path: bootstrap + flags only when plugins are activated. Verified `mix mob.deploy --native` to a physical iPhone — full OTP, Phoenix endpoint up, LiveView connected, embedded Livebook home rendered.

## [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
- **iOS release: `erl_errno_id_unknown` shim written with a literal `\n`.** The weak-stub line in `release_device.sh` used `printf '%s\\n'` inside the `~S` (raw) heredoc, so bash received both backslashes and `printf` wrote a literal backslash-`n` into `erl_errno_id_compat.c` — clang then rejected the trailing `}\n` and `mix mob.release --ios` failed. Now `printf '%s\n'` (one backslash) emits a real newline. Regression guard added to `release_script_test`.
- **iOS release: clear preflight when `priv/generated/driver_tab_ios.c` is missing.** The release links a per-app static-NIF driver table, but the dev build uses the built-in Zig table and `mix mob.regen_driver_tab` defaults to Zig, so a project that never ran it with `--format c` died deep in `release_device.sh` with a cryptic `cc: no such file`. `build_ipa` now fails early with the exact command to run (`mix mob.regen_driver_tab --format c`).

## [0.5.13]

### Fixed
- **iOS deploy now ships the whole `priv/`, not just `priv/repo/migrations` + `priv/static`.** `MobDev.Release`'s iOS bundler copied only migrations and `priv/static`, so apps that bundle extra runtime assets under `priv/` — `:mix`/`:hex` ebins for on-device `Mix.install`, or a vendored library's own `priv/` (e.g. Livebook's `priv/static` + `priv/livebook`) — silently never reached the device. Now rsyncs all of `priv/`, matching the Android deployer. Unblocks on-device `Mix.install` and embedded Livebook on iOS. Verified on a physical iPhone: `priv/mix/ebin` (103 beams) and `priv/livebook/static` present on device, embedded Livebook serves, and `Mix.install([{:short_uuid, "~> 0.1"}])` returns `:ok`.

## [0.5.12]

### Changed
- **OTP runtime bumped to `7d46fdd4` (Elixir 1.20.0-rc.5).** `@otp_hash` now points at the `otp-7d46fdd4` release: all four platform tarballs (ios-sim, ios-device, android, android-arm32) bundle Elixir 1.20.0-rc.5 matched to the OTP-29 erts. Completes the 1.19.5 to 1.20 runtime migration the bundled-versions manifest was already staged for. Verified end-to-end on a physical iPhone and a physical Android (Moto G): `System.version` 1.20.0-rc.5, OTP 29, `Mix.install([{:short_uuid, "~> 0.1"}])` returns `:ok` with the dep compiled on-device.

### Fixed
- **iOS `{spawn, <linked-in driver>}` now works** (erts `erts_open_driver`). The iOS-build `#ifdef __IOS__` guard returned BADARG for any `open_port` whose spawn_type included the EXECUTABLE bit (i.e. plain `{spawn, Name}`), firing before the linked-in-driver name lookup. This broke `ram_file` (`{spawn, "ram_file_drv"}`), and therefore `file:open(_, [:ram])`, `erl_tar` in-memory extract, `hex_tarball.unpack`, and `Mix.install` on iOS. The guard now fires only when no linked-in driver matched the name. Bundled in the `7d46fdd4` OTP tarballs.

## [0.5.11]

### Added
- **`mob.exs :project_swift_sources` config key** — optional list of extra Swift sources to compile into the iOS app module alongside Mob's bridge sources. Threaded into both `zig_build_binary_ios_sim` and `zig_build_binary_ios_device` as `-Dproject_swift_sources=<absolute,paths>`. Comma-containing entries are rejected at the boundary; nil/[] is a no-op. Pairs with mob_new's `project_swift_sources` build hook (mob_new#5). Originally proposed by @dl-alexandre.

## [0.5.10]

### Added
- **`mix mob.deploy --dist-port N` and `--node-suffix S` flags** — manual
  override path for the BEAM-distribution surface. When set, all targeted
  devices use the same value (use with `--device <id>` to be explicit).
  Nil falls back to per-device auto-allocation
  (`Tunnel.dist_port(idx)` + `Discovery.Android.device_node_suffix` /
  SIMULATOR_UDID-derived suffix). Resolves the
  `register/listen error: no_reg_reply_from_epmd` symptom seen when running
  multiple sims/emulators of the same app concurrently for cross-platform
  visual comparison.
- `MobDev.Device` struct gains a `:node_suffix` field for plumbing the
  override per-device alongside `:dist_port`. Nil keeps auto-derive.
- `MobDev.Discovery.IOS.launch_app/3` accepts `:node_suffix` opt and
  forwards as `SIMCTL_CHILD_MOB_NODE_SUFFIX` to the launched sim. Companion
  to `mob 0.6.10`'s `MOB_NODE_SUFFIX` support in `mob_beam.m`.
- `MobDev.Discovery.IOS.build_simctl_env/2` — pure helper extracted from
  `launch_app/3` so override behaviour is unit-testable without spawning
  `simctl`. 7 new tests cover dist_port + node_suffix override paths.

### Changed
- `MobDev.Connector.restart_app/1` pattern-matches `:node_suffix` from
  `Device` in both Android + iOS-sim variants, threading the value to the
  launchers.
- `MobDev.Deployer.deploy_all/1` accepts top-level `:dist_port` +
  `:node_suffix` opts; threaded through `deploy_android` and
  `deploy_ios_simulator`.

## [0.5.9]

### Changed
- `mix mob.enable tflite` now injects `{:nx_tflite_mob, "~> 0.0.3"}`
  (Hex) instead of the GitHub-branch form. `nx_tflite_mob` v0.0.3 went
  live on Hex with 16 integration tests + a reproducible Mac host
  build path (see
  [its CHANGELOG](https://github.com/GenericJam/nx_tflite_mob/blob/main/CHANGELOG.md)).
  Downstream Mob apps now get version-pinned deps + clean
  `mix deps.tree` output, instead of a transient `github:` checkout.

### Notes
- The Mac host-build path in `nx_tflite_mob` is for that package's own
  test suite, not for downstream consumers — production phone builds
  via `mix mob.deploy --native` continue to use the prebuilt Android
  AAR + iOS xcframework that mob_dev's `MobDev.TfliteDownloader`
  fetches.

## [0.5.8]

### Added
- **End-to-end `mix mob.enable tflite`** — what 0.5.7 promised as
  "lands in 0.5.8". `MobDev.NativeBuild` now auto-detects the
  `:nx_tflite_mob` dep and threads the full TFLite path through
  Android + iOS sim + iOS device build pipelines:
  - `maybe_build_tflite/1` → `MobDev.TfliteDownloader.ensure/1` +
    `MobDev.TfliteNif.build/2` for each target arch
  - `tflite_zig_args_android/1` emits `-Dtflite_static=true
    -Dtflite_lib=…` for the per-ABI Android link
  - `tflite_zig_args_ios/1` emits `-Dtflite_static=true
    -Dtflite_dir=… -Dtflite_framework_dir=…` for the iOS link
  - `copy_tflite_runtime_lib_android/2` drops
    `libtensorflowlite_jni.so` into `android/app/src/main/jniLibs/<abi>/`
    during the assemble step
- `copy_tflite_frameworks_ios/3` (kept as future-compat hook) — see
  the iOS-deploy-fix gotcha below
- 13 new tests covering the public NativeBuild plumbing
  (`native_build_tflite_test.exs`), bringing the TFLite suite to 76
  passing total

### Fixed
- **iOS deploy: TFLite framework binaries are MH_OBJECT, not
  MH_DYLIB.** TFLite's iOS xcframework slices ship their binaries as
  filetype=1 relocatable objects, which the linker statically pulls
  into the app's main Mach-O at build time. Trying to embed them as
  runtime `.framework` bundles tripped iOS install twice during this
  cut: first on missing per-framework Info.plist (which CocoaPods
  generates), then on "code signature version no longer supported"
  (iOS 26+ rejects v1 signatures, and codesign only makes v3 sigs
  for MH_EXECUTE/MH_DYLIB). The fix is to do nothing — the framework
  search-path arg already covers everything at build time.
- **Resolve `:nx_tflite_mob` via `Mix.Project.deps_paths()`** rather
  than `Path.join(deps_path, "nx_tflite_mob")`. The latter assumes
  the dep landed in `deps/` (hex / git deps do), but `path:` deps
  consume in-place from the user's source tree.

### Verified on real hardware
- Moto G Power 5G (BXM-8-256, Android 15): 75-117 ms YOLOv8n via
  NNAPI / `mtk-gpu_shim`
- iPhone SE 3rd gen (A15, iOS 26.4): 24 ms YOLOv8n via Core ML → ANE
  (FP16 model; 214/385 nodes delegated)

## [0.5.7]

### Added
- `mix mob.enable tflite` — wires TensorFlow Lite into a Mob project on
  iOS AND Android. Adds `{:nx_tflite_mob, ...}` to deps and generates
  `<App>.TfliteInit` (returns per-platform default delegate opts —
  NNAPI/`mtk-gpu_shim` on Android, Core ML delegate on iOS). The
  static-NIF table entry `%{module: :tflite_nif, guard: "MOB_STATIC_TFLITE_NIF"}`
  is registered in `MobDev.StaticNifs.default_nifs/0`, so the zig
  build picks it up automatically once `tflite_static=true` is set.
- `MobDev.TfliteDownloader` — fetches `tensorflow-lite-2.16.1.aar`
  (Maven Central, Android) and `TensorFlowLiteC-2.17.0.tar.gz`
  (dl.google.com, iOS) into `~/.mob/cache/`. Honours `MOB_CACHE_DIR`
  for test redirection and `MOB_TFLITE_LOCAL_TARBALL_DIR` for offline
  iteration.
- `MobDev.TfliteNif` — cross-compiles `tflite_nif.c` (from the
  `:nx_tflite_mob` dep) per-arch and archives as `libtflite_nif.a` for
  static linking. Mirrors `MobDev.NxEigenNif` shape. Validates the
  produced symbol (`tflite_nif_nif_init`) before declaring success.

Bundle size impact: ~3-4 MB extracted (Android `libtensorflowlite_jni.so`),
~20-30 MB on iOS (TensorFlowLiteC + CoreML + Metal frameworks). Apps
that don't enable TFLite pay zero size cost — the guard keeps the
static-NIF table entry inactive.

End-to-end deploy (`mob.deploy --native` auto-build + runtime-lib
embedding) lands in 0.5.8; this release ships the building blocks.

## [0.5.6]

### Added
- `CLAUDE.md` "Release flow" section pointing at the canonical process
  in [`mob/RELEASE.md`](https://github.com/GenericJam/mob/blob/master/RELEASE.md)
  (URL form so it resolves without a local mob checkout). mob_dev
  specifics: the pre-push hook additionally runs `mix mob.security_scan`
  here (this is the only repo that ships the scanner), and the OTP
  tarball workflow stays separate from `mix.exs` version bumps.
- `.githooks/pre-push` — same script shipped in mob (cheap preflight
  always, release preflight when `mix.exs` changed). The
  `mob.security_scan` step is gated via `mix help` availability so the
  same hook script works in all three repos.

## [0.5.5]

### Fixed
- Android 15 segfault on launch (Pixel 7+, after the OS rolled out via OTA). Bumps `@otp_hash` from `550d7b78` → `d9045670` to pick up OTP tarballs cross-compiled with `-Wl,-z,max-page-size=16384`. Without the flag, every `.so` in the bundled OTP runtime (`crypto.so`, `asn1rt_nif.so`, `dyntrace.so`, etc.) shipped with 4KB-aligned ELF `PT_LOAD` segments. Android 15 enforces 16KB alignment on devices with 16KB-page kernels and refuses to load misaligned libs, crashing the app at startup. New tarballs are 16KB-aligned (`Align=0x4000`).

## [0.5.4]

### Fixed
- HexDocs source links pointed at the non-existent `main` branch — corrected to `master` so each `</>` glyph in generated docs opens the actual source file.

### Added
- `.github/workflows/test.yml` — runs `mix test`, `mix format --check-formatted`, `mix credo --strict`, and `mix mob.security_scan` on push to master and on every PR.
- `.github/workflows/release.yml` — on tag push, creates a GitHub Release whose body is the matching `## [X.Y.Z]` section from this changelog.

## [0.5.3]

### Changed
- `guides/nifs.md` — rewrote the "Nx backends on mobile" section to match the current state (`mix mob.enable nxeigen` now real, `mix mob.enable mlx` includes the Metal GPU path on iOS device, EXLA "why not" preserved).
- `guides/nifs.md` — restructured the multi-Rust-NIF section to lead with [filmor's](https://github.com/rusterlium/rustler/issues/686) preferred shape (one Rustler crate per app, multiple `#[rustler::nif]` functions inside it). Multi-crate static linking remains supported and documented as an escape hatch, with the specific tradeoffs called out.

## [0.5.2]

### Added
- `mix mob.enable nxeigen` — wires NxEigen (Eigen C++ CPU backend) into a Mob app. Builds as a C++ `:static_nifs` entry, cross-compiled per arch (`arm64-ios`, `arm64-iossim`, `arm64-android`, `armv7a-android`). FFT support uses Eigen's bundled kissfft.
- EMLX Metal GPU enabled on iOS device. `lib/mob_dev/mlx_downloader.ex` now fetches the Metal-enabled `libmlx.a` + `mlx.metallib` bundle; `lib/mob_dev/native_build.ex#maybe_bundle_mlx_metallib/2` copies the precompiled kernel library into the .app at build time, so `EMLX.Backend` with `device: :gpu` works on device without runtime kernel compilation.
- `scripts/release/mlx/ios_device_metal.sh` + supporting build scripts for producing the Metal-enabled tarball; `scripts/release/mlx/patches/0001-ios-metal-build.patch` patches MLX 0.25.1's CMakeLists to switch SDK from `macosx` to `iphoneos` based on `CMAKE_SYSTEM_NAME`.

## [0.5.1] and earlier

Earlier releases predate this changelog; consult the [tag list](https://github.com/genericjam/mob_dev/tags) and the per-tag commit messages for history.
