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. Generates app icons (random robot avatar, or a provided source image) ## Options * `--no-icon` — skip icon generation * `--icon PATH` — use an existing image instead of generating a random robot ## Icon output - `android/app/src/main/res/mipmap-*/ic_launcher.png` - `ios/Assets.xcassets/AppIcon.appiconset/icon_*.png` + `Contents.json` - `icon_source.png` (1024×1024 master, when generating) Run at any time to regenerate icons: mix mob.install --no-icon # skip icons, re-run other setup steps mix mob.icon # icon only, any time after install """ @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 generate_icons(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 """) end # ── OTP download ───────────────────────────────────────────────────────────── defp download_otp do Mix.shell().info("Ensuring OTP releases are cached...") case MobDev.OtpDownloader.ensure_android() do {:ok, path} -> Mix.shell().info([:green, "* Android OTP: #{path}", :reset]) {:error, reason} -> Mix.shell().error("Warning: Android 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 ─────────────────────────────────────────────────────── @required_keys [:mob_dir, :elixir_lib] 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 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]) 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. 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 _ -> 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])}, elixir_lib: #{inspect(cfg[:elixir_lib])} """ File.write!(path, content) end defp 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() new_content = content |> replace_prop("mob.otp_release", otp_dir) |> 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 defp replace_prop(content, key, value) when not is_nil(value) do String.replace(content, ~r/^#{Regex.escape(key)}=.*$/m, "#{key}=#{value}") end defp replace_prop(content, _key, nil), do: content # ── Icon generation ─────────────────────────────────────────────────────────── defp generate_icons(project_dir, nil) do Mix.shell().info("Generating app icon (random robot)...") MobDev.IconGenerator.generate_random(project_dir) Mix.shell().info([:green, "* icons written", :reset]) end defp generate_icons(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