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
59 changes: 57 additions & 2 deletions lib/mix/tasks/mob.connect.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
)

Expand All @@ -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")
Expand All @@ -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()}"
Expand Down
36 changes: 36 additions & 0 deletions lib/mob_dev/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 9 additions & 4 deletions lib/mob_dev/connector.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
31 changes: 21 additions & 10 deletions lib/mob_dev/tunnel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions test/mix/tasks/mob_connect_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions test/mob_dev/config_test.exs
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions test/mob_dev/tunnel_adb_missing_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading