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//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