Current section

Files

Jump to
mob_dev lib mix tasks mob.install.ex
Raw

lib/mix/tasks/mob.install.ex

defmodule Mix.Tasks.Mob.Install do
use Mix.Task
@shortdoc "First-run setup for a new Mob project"
@moduledoc """
Runs first-time setup for a Mob project generated by `mix mob.new`.
Must be run from inside the project directory (the one containing `mix.exs`).
mix mob.install [--icon PATH]
## What it does
1. Prompts for machine-specific paths (`mob_dir`, `elixir_lib`) and writes them
to `mob.exs` (gitignored) and `android/local.properties`
2. Downloads and caches the pre-built OTP runtime tarballs for Android and iOS
3. Writes the Mob logo as a placeholder app icon (if no icon exists yet)
## Options
* `--icon PATH` — use a custom image instead of the Mob logo placeholder
## Icon output
- `android/app/src/main/res/mipmap-*/ic_launcher.png`
- `ios/Assets.xcassets/AppIcon.appiconset/icon_*.png` + `Contents.json`
The Mob logo is written as a placeholder. Replace it any time:
mix mob.icon # generate a custom icon
mix mob.icon --source my_logo.png
## Under the hood
`mix mob.install` does three things that would otherwise require manual steps:
**1. Path configuration** — reads `mob.exs`, detects sensible defaults, prompts
for missing values, and writes them back. Equivalent to:
# mob.exs (gitignored, machine-specific)
config :mob_dev,
mob_dir: "/Users/me/code/mob"
**2. OTP download** — fetches pre-built ERTS tarballs from GitHub Releases:
# Roughly equivalent to:
curl -L https://github.com/genericjam/mob/releases/download/<tag>/otp-android-arm64.tar.gz \
-o ~/.mob_dev/otp-android-arm64.tar.gz
**3. Placeholder icon** — writes the Mob logo to all platform icon sizes (pre-built PNGs,
no system tools required). Run `mix mob.icon` afterwards to replace it with a custom icon.
"""
@switches [icon: :string]
@impl Mix.Task
def run(argv) do
{opts, _args, _} = OptionParser.parse(argv, strict: @switches)
project_dir = File.cwd!()
unless File.exists?(Path.join(project_dir, "mix.exs")) do
Mix.raise("No mix.exs found. Run mix mob.install from your project root.")
end
configure_paths(project_dir)
download_otp()
setup_icon(project_dir, opts[:icon])
Mix.shell().info("""
Mob project ready!
mix mob.deploy --native # first deploy: build APK + iOS app, install + push BEAMs
mix mob.deploy # fast push + restart (day-to-day)
mix mob.watch # auto-push changes while developing
mix mob.connect # connect IEx to running device nodes
Run `mix mob.icon` to replace the placeholder icon with a custom one.
""")
end
# ── OTP download ─────────────────────────────────────────────────────────────
defp download_otp do
Mix.shell().info("Ensuring OTP releases are cached...")
if has_android_project?() do
case MobDev.OtpDownloader.ensure_android("arm64-v8a") do
{:ok, path} ->
Mix.shell().info([:green, "* Android arm64 OTP: #{path}", :reset])
{:error, reason} ->
Mix.shell().error("Warning: Android arm64 OTP download failed: #{reason}")
end
case MobDev.OtpDownloader.ensure_android("armeabi-v7a") do
{:ok, path} ->
Mix.shell().info([:green, "* Android arm32 OTP: #{path}", :reset])
{:error, reason} ->
Mix.shell().error("Warning: Android arm32 OTP download failed: #{reason}")
end
else
Mix.shell().info([:yellow, "* Android OTP skipped — no android/ in project", :reset])
end
if has_ios_project?() and match?({:unix, :darwin}, :os.type()) do
case MobDev.OtpDownloader.ensure_ios_sim() do
{:ok, path} -> Mix.shell().info([:green, "* iOS OTP: #{path}", :reset])
{:error, reason} -> Mix.shell().error("Warning: iOS OTP download failed: #{reason}")
end
else
cond do
not has_ios_project?() ->
Mix.shell().info([:yellow, "* iOS OTP skipped — no ios/ in project", :reset])
not match?({:unix, :darwin}, :os.type()) ->
Mix.shell().info([:yellow, "* iOS OTP skipped — non-macOS host", :reset])
true ->
:ok
end
end
end
# Project layout detection — same predicates the doctor uses.
defp has_android_project?, do: File.dir?(Path.join(File.cwd!(), "android"))
defp has_ios_project?, do: File.exists?(Path.join(File.cwd!(), "ios/build.sh"))
# ── Path configuration ───────────────────────────────────────────────────────
# elixir_lib is no longer prompted — it's always auto-detected from the running BEAM.
@required_keys [:mob_dir]
defp configure_paths(project_dir) do
mob_exs = Path.join(project_dir, "mob.exs")
cfg =
if File.exists?(mob_exs) do
Config.Reader.read!(mob_exs) |> Keyword.get(:mob_dev, [])
else
[]
end
missing =
Enum.filter(@required_keys, fn key ->
val = cfg[key]
is_nil(val) or (is_binary(val) and String.contains?(val, "/path/to/"))
end)
if missing != [] do
defaults = detect_defaults(project_dir)
Mix.shell().info("""
Configure your local build paths in mob.exs.
These are machine-specific and gitignored.
Press Enter to accept a detected value [ ], or type a new path.
""")
updates =
Enum.map(missing, fn key ->
{key, prompt_path(key, defaults[key])}
end)
new_cfg = Keyword.merge(cfg, updates)
write_mob_exs(mob_exs, new_cfg)
write_local_properties(project_dir, new_cfg)
Mix.shell().info([:green, "* mob.exs configured", :reset])
else
# mob.exs already has all required paths (e.g. generated with --local).
# Still sync local.properties if it has placeholder values.
write_local_properties(project_dir, cfg)
end
end
defp detect_defaults(project_dir) do
%{
elixir_lib: detect_elixir_lib(),
mob_dir: detect_mob_dir(project_dir)
}
end
# We're running inside Elixir — :code.lib_dir(:elixir) is always available.
defp detect_elixir_lib do
:code.lib_dir(:elixir) |> to_string() |> Path.dirname()
end
# Read the {:mob, path: "..."} dep from mix.exs and resolve it.
# Falls back to deps/mob for Hex installs.
defp detect_mob_dir(project_dir) do
mix_exs = Path.join(project_dir, "mix.exs")
with {:ok, content} <- File.read(mix_exs),
[_, rel] <- Regex.run(Regex.compile!("\\{:mob,\\s+path:\\s+\"([^\"]+)\""), content) do
Path.expand(rel, project_dir)
else
_ ->
deps_mob = Path.join(project_dir, "deps/mob")
if File.dir?(deps_mob), do: deps_mob, else: nil
end
end
defp prompt_path(key, detected) do
label = prompt_label(key)
if detected do
input = Mix.shell().prompt(" #{label} [#{detected}]:") |> String.trim()
if input == "", do: detected, else: input
else
Mix.shell().prompt(" #{label}:") |> String.trim()
end
end
defp prompt_label(:mob_dir), do: "mob library path"
defp prompt_label(:elixir_lib), do: "Elixir lib path"
defp write_mob_exs(path, cfg) do
content = """
import Config
config :mob_dev,
mob_dir: #{inspect(cfg[:mob_dir])}
"""
File.write!(path, content)
end
@doc false
@spec write_local_properties(String.t(), keyword()) :: :ok | nil
def write_local_properties(project_dir, cfg) do
props = Path.join(project_dir, "android/local.properties")
if File.exists?(props) do
content = File.read!(props)
needs_mob_paths? = String.contains?(content, "/path/to/")
needs_sdk_dir? = not has_active_sdk_dir?(content)
if needs_mob_paths? or needs_sdk_dir? do
otp_dir = MobDev.OtpDownloader.android_otp_dir("arm64-v8a")
otp_dir_arm32 = MobDev.OtpDownloader.android_otp_dir("armeabi-v7a")
new_content =
content
|> replace_prop("mob.otp_release", otp_dir)
|> replace_prop("mob.otp_release_arm32", otp_dir_arm32)
|> replace_prop("mob.mob_dir", cfg[:mob_dir])
|> ensure_sdk_dir(detect_android_sdk())
File.write!(props, new_content)
Mix.shell().info([:green, "* android/local.properties configured", :reset])
end
end
end
# Returns true iff local.properties has an *uncommented* `sdk.dir=...` line.
# The mob_new template ships with a commented `# sdk.dir=...` placeholder; we
# treat that as "not set" so the auto-detection writes a real value over it.
@doc false
@spec has_active_sdk_dir?(String.t()) :: boolean()
def has_active_sdk_dir?(content) do
Regex.match?(Regex.compile!("^\\s*sdk\\.dir\\s*=", "m"), content)
end
# Inserts or updates `sdk.dir=...` in local.properties when a real SDK path
# is detected. No-op when nothing was found — better to leave the comment
# placeholder than write a bogus value.
@doc false
@spec ensure_sdk_dir(String.t(), String.t() | nil) :: String.t()
def ensure_sdk_dir(content, nil), do: content
def ensure_sdk_dir(content, sdk_path) when is_binary(sdk_path) do
cond do
Regex.match?(Regex.compile!("^\\s*sdk\\.dir\\s*=", "m"), content) ->
# Active line already present — replace its value
Regex.replace(
Regex.compile!("^\\s*sdk\\.dir\\s*=.*$", "m"),
content,
"sdk.dir=#{sdk_path}"
)
Regex.match?(Regex.compile!("^\\s*#\\s*sdk\\.dir\\s*=", "m"), content) ->
# Commented placeholder — replace it with the active line
Regex.replace(
Regex.compile!("^\\s*#\\s*sdk\\.dir\\s*=.*$", "m"),
content,
"sdk.dir=#{sdk_path}"
)
true ->
# Neither — prepend the line so Gradle finds it without scanning the
# comment block at the top of the file
"sdk.dir=#{sdk_path}\n" <> content
end
end
# Locate the Android SDK by checking the standard env vars first, then the
# platform-default install paths Android Studio uses. Returns `nil` if
# nothing was found — `mix mob.deploy --native` will fall back to its own
# toolchain warning so the user knows what to install.
@doc false
@spec detect_android_sdk() :: String.t() | nil
def detect_android_sdk do
candidates =
[
System.get_env("ANDROID_HOME"),
System.get_env("ANDROID_SDK_ROOT")
] ++ default_sdk_locations()
Enum.find(candidates, fn
nil -> false
"" -> false
path -> File.dir?(path)
end)
end
defp default_sdk_locations do
home = System.user_home!()
case :os.type() do
{:unix, :darwin} ->
[Path.join([home, "Library", "Android", "sdk"])]
{:unix, _} ->
# Linux. Android Studio's default is ~/Android/Sdk; package managers
# often install to /opt/android-sdk or /opt/android-sdk-linux.
[
Path.join([home, "Android", "Sdk"]),
"/opt/android-sdk",
"/opt/android-sdk-linux"
]
{:win32, _} ->
[Path.join([home, "AppData", "Local", "Android", "Sdk"])]
_ ->
[]
end
end
@doc false
@spec replace_prop(String.t(), String.t(), String.t() | nil) :: String.t()
def replace_prop(content, key, value) when not is_nil(value) do
String.replace(content, Regex.compile!("^#{Regex.escape(key)}=.*$", "m"), "#{key}=#{value}")
end
def replace_prop(content, _key, nil), do: content
# ── Icon setup ────────────────────────────────────────────────────────────────
defp setup_icon(project_dir, nil) do
placeholder =
Path.join([
project_dir,
"android",
"app",
"src",
"main",
"res",
"mipmap-mdpi",
"ic_launcher.png"
])
if File.exists?(placeholder) do
Mix.shell().info([
:cyan,
"* icons already present — skipping (run `mix mob.icon` to replace)",
:reset
])
else
Mix.shell().info("Writing Mob logo as placeholder icon...")
MobDev.IconGenerator.use_mob_logo(project_dir)
Mix.shell().info([
:green,
"* placeholder icons written (run `mix mob.icon` to customise)",
:reset
])
end
end
defp setup_icon(project_dir, source) do
unless File.exists?(source) do
Mix.raise("Source file not found: #{source}")
end
Mix.shell().info("Generating app icon from #{source}...")
MobDev.IconGenerator.generate_from_source(source, project_dir)
Mix.shell().info([:green, "* icons written", :reset])
end
end