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 [--no-icon] [--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 * `--no-icon` — skip icon setup entirely * `--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 [no_icon: :boolean, 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() unless opts[:no_icon] do setup_icon(project_dir, opts[:icon]) end 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...") 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 if 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 end end # ── 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(~r/\{: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) if String.contains?(content, "/path/to/") 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]) File.write!(props, new_content) Mix.shell().info([:green, "* android/local.properties configured", :reset]) end 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, ~r/^#{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