Packages
mob_dev
0.3.23
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
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