Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,16 @@ mix test --exclude integration # skip the device-dependent ones
prefers `Documents/otp/<app>` over its complete signed bundle. Replace that
directory rather than incrementally merging it, require `<app>.beam` before
transfer, and verify the received bootstrap bytes before restarting.
- **iOS bundle ids resolve through one function per side, never `bundle_id/0`.**
Consumers (deploy, connect, provision, battery bench) use
`MobDev.Config.ios_bundle_id/0`; the build (sim bundle, device bundle,
codesign) uses `NativeBuild.ios_bundle_id/1` over the loaded cfg. Both are
`:ios_bundle_id || :bundle_id`. Reaching for plain `bundle_id/0` on an
iOS path is the bug that installed an app under one id and pushed BEAMs at
another ("App '…' is not installed on this device" right after a
successful install). Android keeps `bundle_id/0` — the two ids often
cannot be the same string (Apple forbids `_`). See
`decisions/2026-08-08-ios-bundle-id-single-source-and-deploy-exit-code.md`.
- **`xcodebuild` errors get rewritten** to actionable hints by
`diagnose_xcodebuild_failure/1` in `mob.provision`. Apple's verbatim text is
preserved alongside our hint so the snippet stays google-able. Add new
Expand DownExpand Up@@ -152,8 +162,15 @@ narrowing functions). Don't make them private:
- `Mix.Tasks.Mob.Doctor.__zig_install_fix__/0`
- `Mix.Tasks.Mob.Doctor.__zig_check_result__/1`
- `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1`
- `NativeBuild.ios_bundle_id/1` (the `:ios_bundle_id || :bundle_id` rule for the build side)
- `Deployer.ios_bundle_id/0`, `Connector.ios_bundle_id/0` (the same rule on the
consumer side — public so the WIRING is testable, not just the resolver;
reverting either to `bundle_id/0` was the original defect and the suite
did not notice)
- `Emulators.parse_simctl_json/1`, `find_emulator_binary/1`
- `Provision.diagnose_xcodebuild_failure/1`
- `Mix.Tasks.Mob.Deploy.failure_message/3` (which bucket makes a deploy exit non-zero)
- `Uninstaller.resolve_apps_for_device/3` (which id gets uninstalled, per platform)

If you make any of these private, every downstream test breaks loudly — but
you'll lose the ability to evolve the parsers safely.
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,51 @@ Full module documentation: [hexdocs.pm/mob_dev](https://hexdocs.pm/mob_dev).

## [Unreleased]

### Changed

- **`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.

### Added

- **`: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`.

### Fixed

- **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
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
# One iOS bundle id everywhere, and a non-zero exit on failed deploy

- Date: 2026-08-08
- Status: accepted

## Context

Three defects found deploying one app to a physical iPhone and an Android
phone, with `bundle_id: "com.example.mishka_mob"` (Android's
`applicationId`, underscore and all) plus
`ios_bundle_id: "com.genericjam.mishkamob"` in `mob.exs`:

1. `MobDev.Deployer` resolved the iOS id with `MobDev.Config.bundle_id/0`,
discarding `:ios_bundle_id`. `MobDev.NativeBuild` honoured it. So
`mix mob.deploy --native --device <udid>` installed the app under the
configured id and then pushed BEAMs at an id that was never installed:
`App 'com.example.mishka_mob' is not installed on this device.` The only
workaround was clobbering `:bundle_id` — which Android may not accept
(Apple forbids `_` in a bundle id; `com.example.*` is frequently already
claimed by another Apple team).
2. That run printed `Failed on 1 device(s)` and exited **0**.
3. The iOS *simulator* bundle never stamped `CFBundleIdentifier`, so it
inherited whatever `ios/Info.plist` carried, while the *device* bundle
stamped the configured id. Same project, two ids, and no way to tell
which one a given build used.

## Decision

**One resolver per side.** `MobDev.Config.ios_bundle_id/0`
(`:ios_bundle_id || bundle_id/0`) is what every iOS-targeting *consumer*
resolves — `Deployer`, `Connector`, `mob.provision`, `mob.battery_bench_ios`.
`MobDev.NativeBuild.ios_bundle_id/1` (`cfg[:ios_bundle_id] || cfg[:bundle_id]`)
is the same rule over the already-loaded build config, used by the sim
bundle, the device bundle, and code signing. The two agree because
`load_config/0` resolves `cfg[:bundle_id]` through `Config.bundle_id/0`.

`mob.provision` is included deliberately: it mints the provisioning profile,
which must cover the id `NativeBuild` actually signs.

**Simulator now stamps `CFBundleIdentifier` too**, rather than accepting the
divergence and only reporting it. No signing step rewrites the id on either
path, so there was no technical reason for them to differ. For projects that
never set `:bundle_id`/`:ios_bundle_id` this is a no-op — `bundle_id/0`
already falls back to `ios/Info.plist`, so the stamped value equals the
inherited one. Both paths additionally *print* the id they installed, since
`simctl launch` / `devicectl` / `mob.connect` all need it and it appeared
nowhere in the build output.

**`mix mob.deploy` exits non-zero when the `failed` bucket is non-empty**
(`Mix.Tasks.Mob.Deploy.failure_message/3` → `Mix.raise`), after the full
summary is printed.

- `skipped` stays non-fatal. It means "app not installed for that platform",
the expected outcome of building `--ios` with an Android phone also
plugged in. This keeps faith with the earlier fix that split `skipped` out
of `Failed on N` in `format_summary/4`.
- Partial success is fatal. The fan-out is unchanged — every targeted device
is still attempted and reported, so an operator can see which ones got the
BEAMs. But a *script* cannot notice that one device missed out if the
status code says everything is fine, and that is precisely who the exit
code is for.

## Consequences

- A CI job that deploys to several devices and previously "passed" with one
device failing now fails. That is the point, but it is a behaviour change
for anyone who was relying on the old status code.
- `:ios_bundle_id` is now a genuinely usable setting rather than one the
build honours and the deployer ignores; cross-platform projects no longer
have to pick an id that satisfies both Apple and Android.
- `NativeBuild.ios_bundle_id/1` joins the public-but-undocumented seams
(listed in `AGENTS.md`) — public for testing, don't privatise.
4 changes: 2 additions & 2 deletions lib/mix/tasks/mob.battery_bench_ios.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,7 +201,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do
# device_id is what we pass to xcrun devicectl (install, launch, terminate).
udid = device_id

pkg = MobDev.Config.bundle_id()
pkg = MobDev.Config.ios_bundle_id()
cfg = MobDev.Config.load_mob_config()

# Workspace discovery is only needed when building. Skip it with --no-build.
Expand DownExpand Up@@ -645,7 +645,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do

defp dry_run!(opts) do
cfg = MobDev.Config.load_mob_config()
pkg = MobDev.Config.bundle_id()
pkg = MobDev.Config.ios_bundle_id()

scheme =
opts[:scheme] || cfg[:ios_scheme] ||
Expand Down
39 changes: 39 additions & 0 deletions lib/mix/tasks/mob.deploy.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -116,6 +116,15 @@ defmodule Mix.Tasks.Mob.Deploy do
# iOS simulator
xcodebuild -scheme <app> -destination 'platform=iOS Simulator,...' build
xcrun simctl install booted <app>.app

## Exit status

Every targeted device is attempted and the full summary printed, then the
task exits non-zero if **any** device landed in the `Failed on N device(s)`
bucket — including a partial success where other devices deployed fine.

Devices under `Skipped on N device(s)` (app not installed for that
platform) do not fail the run.
"""

@switches [
Expand DownExpand Up@@ -242,8 +251,38 @@ defmodule Mix.Tasks.Mob.Deploy do
)

Enum.each(format_summary(deployed, failed, skipped, restart: restart), &IO.puts/1)

# The full summary is printed first, then the status code is set — the
# fan-out across devices is unchanged, only the exit code is.
case failure_message(deployed, failed, skipped) do
nil -> :ok
message -> Mix.raise(message)
end
end

@doc """
The `Mix.raise` message for a finished deploy, or `nil` when the run
should exit 0.

A deploy that printed "Failed on N device(s)" used to still exit 0, so
CI and wrapper scripts read a failed deploy as a success.

Only `failed` (a real error during push) is fatal. `skipped` is not:
it means "app not installed for that platform", the expected outcome of
e.g. building `--ios` with an Android phone also plugged in — the same
distinction `format_summary/4` renders.

Partial success is still a failure. Every targeted device is still
attempted and reported before this runs, so the operator can see which
ones got the BEAMs; a *script* has no way to notice one device missed
out if the status code says everything is fine.
"""
@spec failure_message([Device.t()], [Device.t()], [Device.t()]) :: String.t() | nil
def failure_message(_deployed, [], _skipped), do: nil

def failure_message(_deployed, failed, _skipped),
do: "Deploy failed on #{length(failed)} device(s) — see errors above"

@doc """
Build the per-deploy summary lines from the three device buckets.

Expand Down
5 changes: 4 additions & 1 deletion lib/mix/tasks/mob.doctor.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -567,7 +567,10 @@ defmodule Mix.Tasks.Mob.Doctor do
end

defp check_bundle_id(cfg) do
case cfg[:bundle_id] do
# `ios_bundle_id` alone is a legitimate configuration — an iOS-only project
# whose Apple id is the only one it needs. Warning about a missing
# `bundle_id` there sends the user to add a key nothing reads.
case cfg[:bundle_id] || cfg[:ios_bundle_id] do
nil ->
{:warn, "bundle_id", "not set in mob.exs (only needed for mob.battery_bench)",
"Add to mob.exs: config :mob_dev, bundle_id: \"com.example.myapp\""}
Expand Down
4 changes: 3 additions & 1 deletion lib/mix/tasks/mob.provision.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -282,7 +282,9 @@ defmodule Mix.Tasks.Mob.Provision do
end

defp check_bundle_id! do
bundle_id = MobDev.Config.bundle_id()
# Must match what NativeBuild signs the .app with, or the profile this
# task provisions won't cover the installed binary.
bundle_id = MobDev.Config.ios_bundle_id()
IO.puts(" #{green()}✓#{reset()} Bundle ID — #{bundle_id}")
bundle_id
end
Expand Down
19 changes: 19 additions & 0 deletions lib/mob_dev/config.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,25 @@ defmodule MobDev.Config do
"#{bundle_prefix()}.#{app_name()}"
end

@doc """
Returns the iOS bundle ID: `mob.exs`'s `:ios_bundle_id` when set,
otherwise `bundle_id/0`.

iOS and Android often *cannot* share one identifier — Apple forbids
underscores in a bundle id (Android's `applicationId` allows them), and
a `com.example.*` id is frequently already claimed by another Apple team.
`:ios_bundle_id` is the per-platform escape hatch.

Every iOS-targeting caller must resolve through here. The native build
signs and installs with this value, so a caller that talks to the
installed app by `bundle_id/0` instead (terminate/launch/`devicectl
copy`) addresses an id that was never installed — the failure surfaces
as "App '...' is not installed on this device" *after* a successful
install.
"""
@spec ios_bundle_id() :: String.t()
def ios_bundle_id, do: load_mob_config()[:ios_bundle_id] || bundle_id()

@doc """
Default reverse-DNS prefix when no platform manifest is available.
Honors `MOB_BUNDLE_PREFIX` so users with a corporate prefix can set
Expand Down
6 changes: 4 additions & 2 deletions lib/mob_dev/connector.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,9 @@ defmodule MobDev.Connector do

defp bundle_id, do: MobDev.Config.bundle_id()
defp android_package, do: bundle_id()
defp ios_bundle_id, do: bundle_id()
@doc false
@spec ios_bundle_id() :: String.t() | nil
def ios_bundle_id, do: MobDev.Config.ios_bundle_id()
# ms to wait for node to appear
@connect_timeout 25_000
# ms between polls
Expand DownExpand Up@@ -161,7 +163,7 @@ defmodule MobDev.Connector do
|> Enum.map(& &1.serial)
|> MapSet.new()

case System.cmd("pgrep", ["-fl", bundle_id()], stderr_to_stdout: true) do
case System.cmd("pgrep", ["-fl", ios_bundle_id()], stderr_to_stdout: true) do
{output, 0} ->
output
|> String.split("\n", trim: true)
Expand Down
63 changes: 40 additions & 23 deletions lib/mob_dev/deployer.ex
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,9 @@ defmodule MobDev.Deployer do
defp android_package, do: bundle_id()
defp android_app_data, do: "/data/data/#{android_package()}/files"
defp android_beams_dir, do: "#{android_app_data()}/otp/#{app_name()}"
defp ios_bundle_id, do: bundle_id()
@doc false
@spec ios_bundle_id() :: String.t() | nil
def ios_bundle_id, do: MobDev.Config.ios_bundle_id()

defp ios_beams_dir do
# The simulator's OTP_ROOT is resolved by `MobDev.Paths.sim_runtime_dir/1`.
Expand DownExpand Up@@ -1163,28 +1165,38 @@ defmodule MobDev.Deployer do
:ok

{out, _} ->
reason =
if String.contains?(out, "ContainerLookupErrorDomain") do
"""
App '#{bundle}' is not installed on this device.

To fix this, you need to build and install the app on the device first.
The easiest way is to open the ios/ directory in Xcode and run on device:

open ios/*.xcodeproj (or ios/*.xcworkspace)

Then select your device in Xcode and press Run (⌘R).

Alternatively, if you have another app with a different bundle ID already
installed on the device, update bundle_id in mob.exs to match it:

config :mob_dev, bundle_id: "com.yourcompany.yourapp"
"""
else
"devicectl copy failed: #{out}"
end

throw({:error, reason})
# "Not installed" is the same condition Android reports as :skipped
# (deploy_android/2), and it must be bucketed the same way. It means
# "this device is not a target for this app" — a phone that happens to
# be plugged in — not "the deploy failed". Since mob.deploy started
# returning a non-zero exit code for failures, tagging this as an
# error made a plain `mix mob.deploy` fail on any Mac with an
# unrelated iPhone attached.
if String.contains?(out, "ContainerLookupErrorDomain") do
throw(
{:skipped,
"""
App '#{bundle}' is not installed on this device.

To fix this, you need to build and install the app on the device first.
The easiest way is to open the ios/ directory in Xcode and run on device:

open ios/*.xcodeproj (or ios/*.xcworkspace)

Then select your device in Xcode and press Run (⌘R).

Alternatively, if you have another app with a different bundle ID already
installed on the device, update the ID in mob.exs to match it:

config :mob_dev, ios_bundle_id: "com.yourcompany.yourapp"

(`ios_bundle_id` overrides `bundle_id` on iOS only — use it when
Android's applicationId isn't a legal Apple bundle ID.)
"""}
)
else
throw({:error, "devicectl copy failed: #{out}"})
end
end

received_bootstrap = Path.join(staging_parent, "received_#{app}.beam")
Expand All@@ -1209,6 +1221,11 @@ defmodule MobDev.Deployer do
Process.delete(:mob_ios_override_replaced)
{:ok, device}
catch
# Not annotated with override state: the copy never started, so nothing
# on the device was replaced and there is no partial override to warn about.
{:skipped, reason} ->
{:skipped, reason}

{:error, reason} ->
{:error, annotate_override_state(reason, app)}
after
Expand Down
Loading
Loading