diff --git a/lib/mix/tasks/mob.connect.ex b/lib/mix/tasks/mob.connect.ex index b11dab7..49a6106 100644 --- a/lib/mix/tasks/mob.connect.ex +++ b/lib/mix/tasks/mob.connect.ex @@ -15,6 +15,13 @@ defmodule Mix.Tasks.Mob.Connect do * `--no-iex` — set up connections but don't start IEx (print node names instead) * `--name` — local node name for this session (default: `mob_dev@127.0.0.1`) * `--cookie` — Erlang cookie (default: `mob_secret`) + * `--ios-only` / `--android-only` — restrict discovery to one platform. iOS-only + development on a Mac with no Android platform-tools installed works without + this flag (adb's absence is handled gracefully), but `--ios-only` skips the + Android scan entirely — useful when a phone for another project is plugged in. + To make it the default for a project, set it once in `mob.exs`: + + config :mob_dev, platforms: [:ios] * `--only` / `--device` (`-d`) — restrict to devices whose serial/udid contains the given substring. Repeatable. Without it, connect attaches to *every* running device, so a single slow or locked device (e.g. a plugged-in @@ -117,7 +124,15 @@ defmodule Mix.Tasks.Mob.Connect do def run(args) do {opts, _, _} = OptionParser.parse(args, - switches: [iex: :boolean, cookie: :string, name: :string, only: :keep, device: :keep], + switches: [ + iex: :boolean, + cookie: :string, + name: :string, + only: :keep, + device: :keep, + ios_only: :boolean, + android_only: :boolean + ], aliases: [c: :cookie, n: :name, d: :device] ) @@ -131,7 +146,17 @@ defmodule Mix.Tasks.Mob.Connect do Mix.Task.run("app.config") - {connected, _failed} = MobDev.Connector.connect_all(cookie: cookie, only: only) + # Platform filter: --ios-only / --android-only override the mob.exs default + # (`config :mob_dev, platforms: [...]`). An iOS-only Mac with no adb skips + # Android discovery entirely rather than crashing on the missing binary. + platforms = + case resolve_platforms(opts, MobDev.Config.platforms()) do + {:ok, platforms} -> platforms + {:error, message} -> Mix.raise(message) + end + + {connected, _failed} = + MobDev.Connector.connect_all(cookie: cookie, only: only, platforms: platforms) if connected == [] do IO.puts("\n#{IO.ANSI.yellow()}No nodes connected. Nothing to do.#{IO.ANSI.reset()}\n") @@ -146,6 +171,36 @@ defmodule Mix.Tasks.Mob.Connect do end end + @doc """ + Resolves which platforms to discover from the parsed options and the + `mob.exs` default. + + `--ios-only` / `--android-only` win over the default; passing both is a + contradiction and returns `{:error, _}`. With neither flag, the `mob.exs` + default (`config :mob_dev, platforms: [...]`, both platforms when unset) is + used. Pure — exposed for testing. + """ + @spec resolve_platforms(keyword(), [:android | :ios]) :: + {:ok, [:android | :ios]} | {:error, String.t()} + def resolve_platforms(opts, default) do + ios_only = Keyword.get(opts, :ios_only, false) + android_only = Keyword.get(opts, :android_only, false) + + cond do + ios_only and android_only -> + {:error, "Cannot combine --ios-only and --android-only."} + + ios_only -> + {:ok, [:ios]} + + android_only -> + {:ok, [:android]} + + true -> + {:ok, default} + end + end + defp start_iex(connected, cookie, local_name) do IO.puts( "\n#{IO.ANSI.cyan()}Starting IEx (connected to #{length(connected)} device(s))...#{IO.ANSI.reset()}" diff --git a/lib/mob_dev/config.ex b/lib/mob_dev/config.ex index 3efb444..d5864a3 100644 --- a/lib/mob_dev/config.ex +++ b/lib/mob_dev/config.ex @@ -46,6 +46,42 @@ defmodule MobDev.Config do end end + # The platforms a project develops for. `:android` and `:ios` are the only + # valid entries; order is irrelevant. + @all_platforms [:android, :ios] + + @doc """ + Platforms this project targets, read from `mob.exs` + (`config :mob_dev, platforms: [:ios]`). + + Defaults to both platforms when unset. The chief use is letting a Mac-only + iOS developer opt out of Android discovery/tunnelling once, instead of + passing `--ios-only` on every command. A `--ios-only` / `--android-only` + flag overrides this at the call site. + """ + @spec platforms() :: [:android | :ios] + def platforms, do: parse_platforms(load_mob_config()[:platforms]) + + @doc """ + Normalises a raw `:platforms` config value to a valid platform list. + + `nil` (unset) yields both platforms. A list is filtered to the known + platforms (`:android`, `:ios`); unknown or malformed entries are dropped. + If nothing valid remains, falls back to both platforms rather than leaving + the caller with no devices to discover. Pure — exposed for testing. + """ + @spec parse_platforms(term()) :: [:android | :ios] + def parse_platforms(nil), do: @all_platforms + + def parse_platforms(value) when is_list(value) do + case Enum.filter(@all_platforms, &(&1 in value)) do + [] -> @all_platforms + valid -> valid + end + end + + def parse_platforms(_other), do: @all_platforms + @doc """ Reads the `mob_dev` section from `mob.exs` in the current directory. Returns an empty keyword list if the file does not exist. diff --git a/lib/mob_dev/connector.ex b/lib/mob_dev/connector.ex index 8da74ed..a077120 100644 --- a/lib/mob_dev/connector.ex +++ b/lib/mob_dev/connector.ex @@ -27,10 +27,11 @@ defmodule MobDev.Connector do cookie = Keyword.get(opts, :cookie, :mob_secret) only = opts |> Keyword.get(:only, []) |> List.wrap() + platforms = opts |> Keyword.get(:platforms, [:android, :ios]) |> List.wrap() IO.puts("\n#{color(:cyan)}Scanning for devices...#{color(:reset)}\n") - devices = discover_all() |> filter_only(only) + devices = platforms |> discover_all() |> filter_only(only) if devices == [] do if only != [] do @@ -93,9 +94,13 @@ defmodule MobDev.Connector do end end - defp discover_all do - android = Android.list_devices() - ios = IOS.list_devices() + # Only scan the platforms the project targets. An iOS-only Mac (no Android + # platform-tools) skips Android discovery entirely — both so it never shells + # out to a missing `adb`, and so a plugged-in Android phone for some *other* + # project isn't swept into this session. + defp discover_all(platforms) do + android = if :android in platforms, do: Android.list_devices(), else: [] + ios = if :ios in platforms, do: IOS.list_devices(), else: [] android ++ ios end diff --git a/lib/mob_dev/tunnel.ex b/lib/mob_dev/tunnel.ex index 109fdb7..f8f9160 100644 --- a/lib/mob_dev/tunnel.ex +++ b/lib/mob_dev/tunnel.ex @@ -304,17 +304,28 @@ defmodule MobDev.Tunnel do # Pure-Elixir timeout via Task — avoids depending on the GNU `timeout` # binary, which doesn't ship with macOS or BSD by default. Calls adb # directly via System.cmd/3 (no shell, no quoting concerns). + # + # Resolves `adb` up front via System.find_executable/1: an iOS-only Mac + # has no Android platform-tools, and `System.cmd("adb", ...)` *raises* + # `:enoent` for a missing binary (it does not return a non-zero exit). That + # raise inside the linked Task would propagate an exit to the caller and + # crash the whole `mix mob.connect`. Returning `{:error, ...}` instead lets + # every caller's existing error branch degrade gracefully (no forwards → + # empty port set, no-op cleanup), so iOS-only setups never touch adb. defp run_adb(args) do - task = - Task.async(fn -> - System.cmd("adb", args, stderr_to_stdout: true) - end) - - case Task.yield(task, 8_000) || Task.shutdown(task, :brutal_kill) do - {:ok, {output, 0}} -> {:ok, String.trim(output)} - {:ok, {output, _rc}} -> {:error, String.trim(output)} - nil -> {:error, "adb timed out"} - {:exit, reason} -> {:error, "adb crashed: #{inspect(reason)}"} + case System.find_executable("adb") do + nil -> + {:error, "adb not found on PATH"} + + adb -> + task = Task.async(fn -> System.cmd(adb, args, stderr_to_stdout: true) end) + + case Task.yield(task, 8_000) || Task.shutdown(task, :brutal_kill) do + {:ok, {output, 0}} -> {:ok, String.trim(output)} + {:ok, {output, _rc}} -> {:error, String.trim(output)} + nil -> {:error, "adb timed out"} + {:exit, reason} -> {:error, "adb crashed: #{inspect(reason)}"} + end end end end diff --git a/test/mix/tasks/mob_connect_test.exs b/test/mix/tasks/mob_connect_test.exs index 516bab1..65278c8 100644 --- a/test/mix/tasks/mob_connect_test.exs +++ b/test/mix/tasks/mob_connect_test.exs @@ -9,4 +9,28 @@ defmodule Mix.Tasks.Mob.ConnectTest do assert :ok = Connect.ensure_iex_started() assert {:ok, _} = Application.ensure_all_started(:iex) end + + describe "resolve_platforms/2" do + test "no flags uses the supplied default" do + assert Connect.resolve_platforms([], [:android, :ios]) == {:ok, [:android, :ios]} + assert Connect.resolve_platforms([], [:ios]) == {:ok, [:ios]} + end + + test "--ios-only restricts to iOS, overriding the default" do + assert Connect.resolve_platforms([ios_only: true], [:android, :ios]) == {:ok, [:ios]} + end + + test "--android-only restricts to Android, overriding the default" do + assert Connect.resolve_platforms([android_only: true], [:android, :ios]) == + {:ok, [:android]} + end + + test "combining both flags is an error" do + assert {:error, message} = + Connect.resolve_platforms([ios_only: true, android_only: true], [:android, :ios]) + + assert message =~ "--ios-only" + assert message =~ "--android-only" + end + end end diff --git a/test/mob_dev/config_test.exs b/test/mob_dev/config_test.exs new file mode 100644 index 0000000..34ba588 --- /dev/null +++ b/test/mob_dev/config_test.exs @@ -0,0 +1,37 @@ +defmodule MobDev.ConfigTest do + use ExUnit.Case, async: true + + alias MobDev.Config + + describe "parse_platforms/1" do + test "nil (unset) defaults to both platforms" do + assert Config.parse_platforms(nil) == [:android, :ios] + end + + test "a single platform is kept" do + assert Config.parse_platforms([:ios]) == [:ios] + assert Config.parse_platforms([:android]) == [:android] + end + + test "both platforms normalize to a stable order" do + assert Config.parse_platforms([:ios, :android]) == [:android, :ios] + end + + test "unknown entries are dropped, valid ones kept" do + assert Config.parse_platforms([:windows, :ios]) == [:ios] + end + + test "an empty list falls back to both platforms" do + assert Config.parse_platforms([]) == [:android, :ios] + end + + test "a list with no valid platforms falls back to both" do + assert Config.parse_platforms([:bogus, "ios"]) == [:android, :ios] + end + + test "a non-list value falls back to both platforms" do + assert Config.parse_platforms(:ios) == [:android, :ios] + assert Config.parse_platforms("ios") == [:android, :ios] + end + end +end diff --git a/test/mob_dev/tunnel_adb_missing_test.exs b/test/mob_dev/tunnel_adb_missing_test.exs new file mode 100644 index 0000000..7ef77e0 --- /dev/null +++ b/test/mob_dev/tunnel_adb_missing_test.exs @@ -0,0 +1,41 @@ +defmodule MobDev.TunnelAdbMissingTest do + # async: false — mutates the OS-process-global PATH. Kept out of the main + # (async) tunnel_test so it never overlaps a concurrent reader. + use ExUnit.Case, async: false + + alias MobDev.Tunnel + + describe "adb absent from PATH (iOS-only Mac)" do + setup do + original = System.get_env("PATH") + + # Point PATH at a dir that has epmd (ports_in_use queries it too) but not + # adb, so we exercise exactly the missing-adb path. + dir = Path.join(System.tmp_dir!(), "mob_dev_no_adb_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + + case :os.find_executable(~c"epmd") do + false -> flunk("epmd not found on PATH — cannot set up the no-adb fixture") + epmd -> File.ln_s!(to_string(epmd), Path.join(dir, "epmd")) + end + + System.put_env("PATH", dir) + refute System.find_executable("adb"), "fixture leaked an adb on PATH" + + on_exit(fn -> + if original, do: System.put_env("PATH", original), else: System.delete_env("PATH") + File.rm_rf!(dir) + end) + + :ok + end + + test "ports_in_use/1 degrades to a MapSet instead of crashing on :enoent" do + # Regression: run_adb shelled out to a missing `adb`; System.cmd raised + # :enoent inside a linked Task, propagating an exit that killed the whole + # `mix mob.connect` for iOS-only Macs. It must now return the (adb-less) + # port set without raising — forwards simply read as "none". + assert %MapSet{} = Tunnel.ports_in_use() + end + end +end