Current section

Files

Jump to
mob_new lib mix tasks mob.new.ex
Raw

lib/mix/tasks/mob.new.ex

defmodule Mix.Tasks.Mob.New do
use Mix.Task
@shortdoc "Create a new Mob mobile app project"
@moduledoc """
Creates a new Mob project with Android and iOS boilerplate.
mix mob.new APP_NAME [--liveview] [--python] [--ios | --android] [--no-install] [--dest DIR] [--local]
## Platform selection
By default the generator emits boilerplate for both Android and iOS. Pass
one of the platform flags to scope it to a single platform:
mix mob.new my_app --ios # iOS only — no android/ directory generated
mix mob.new my_app --android # Android only — no ios/ directory generated
mix mob.new my_app # both (default)
`--no-ios` and `--no-android` are equivalent inverse forms (kept for
back-compat). `mix mob.install`, `mix mob.deploy`, and `mix mob.doctor`
detect the project's platform set from on-disk layout, so a single-platform
project skips the absent platform's setup automatically.
## Options
* `--liveview` — generate a Phoenix LiveView app wrapped in a Mob WebView.
Calls `mix phx.new` to scaffold a Phoenix project, then
adds the Mob native boilerplate and LiveView bridge patches
(MobHook in app.js, mob-bridge element in root.html.heex,
MobScreen, mob.exs with liveview_port). Requires
`phx_new` archive to be installed (`mix archive.install hex phx_new`).
* `--ios` — generate iOS boilerplate only (skip android/)
* `--android` — generate Android boilerplate only (skip ios/)
* `--no-ios` — alias for `--android` (skip iOS boilerplate)
* `--no-android` — alias for `--ios` (skip Android boilerplate)
* `--python` — pre-configure embedded CPython via Pythonx (iOS only).
Adds `:pythonx` to deps, generates a
`<App>.PythonPaths` detection module, and gates
`:pythonx, :uv_init` in `config.exs` on
`MOB_TARGET=ios`. Mirrors `mix mob.enable python`
post-scaffold. Bundle size ~70 MB; iOS only —
Android Python is intentionally out of scope. See
the `Embedded CPython` guide in mob_dev's docs.
* `--no-install` — skip running `mix deps.get` after generation
* `--dest DIR` — create project in DIR (default: current directory)
* `--local` — use `path:` deps pointing to local mob/mob_dev repos
instead of hex version constraints, AND render from
the local mob_new checkout's templates (not the
installed archive's, which can be stale relative to
master). **For Mob framework contributors only** —
not intended for app developers.
Mob paths resolved from `MOB_DIR` / `MOB_DEV_DIR`
env vars, falling back to `./mob` / `./mob_dev`,
then `../mob` / `../mob_dev`. mob_new template path
resolved from `MOB_NEW_DIR`, falling back to
`$HOME/code/mob_new`. Pre-fills `mob.exs` so
`mix mob.install` skips path configuration prompts.
If the local mob_new checkout can't be found, falls
back to the installed archive's templates and notes
it in the build output.
## What gets generated (native mode, default)
APP_NAME/
mix.exs
lib/APP_NAME/app.ex
lib/APP_NAME/home_screen.ex
android/
settings.gradle
build.gradle
app/
build.gradle
src/main/
AndroidManifest.xml
java/com/mob/APP_NAME/MainActivity.kt
java/com/mob/APP_NAME/MobBridge.kt
java/com/mob/APP_NAME/MobNode.kt
java/com/mob/APP_NAME/MobScannerActivity.kt
gradle.properties
ios/
beam_main.m
Info.plist
## What gets generated (--liveview mode)
Everything `mix phx.new APP_NAME --no-install` generates, plus:
APP_NAME/
lib/APP_NAME/mob_screen.ex # Mob.Screen wrapping the Phoenix WebView
mob.exs # Mob config with liveview_port: 4000
android/ # same Android boilerplate as native mode
ios/ # same iOS boilerplate as native mode
Patches applied to the Phoenix project:
- `assets/js/app.js` — MobHook definition + registration
- `lib/APP_NAME_web/.../root.html.heex` — mob-bridge hidden div
- `lib/APP_NAME/application.ex` — Mob.App child in supervision tree
- `mix.exs` — mob / mob_dev deps added
After generation, run:
cd APP_NAME
mix mob.install # icon generation + any first-run setup
"""
@switches [
no_install: :boolean,
no_ios: :boolean,
no_android: :boolean,
ios: :boolean,
android: :boolean,
dest: :string,
local: :boolean,
liveview: :boolean,
python: :boolean
]
@impl Mix.Task
def run(argv) do
{opts, args, _} = OptionParser.parse(argv, strict: @switches)
app_name = parse_app_name!(args)
{dest_dir, liveview, gen_opts} = parse_gen_opts(opts)
project_dir = Path.join(Path.expand(dest_dir), app_name)
log_flags(gen_opts, liveview)
Mix.shell().info([:green, "* creating ", :reset, project_dir])
case generate(app_name, dest_dir, liveview, gen_opts) do
{:error, reason} -> Mix.raise(reason)
{:ok, project_dir} -> post_generate(project_dir, app_name, liveview, gen_opts, opts)
end
end
defp parse_app_name!([name | _]), do: validate_app_name!(name)
defp parse_app_name!([]), do: Mix.raise("Usage: mix mob.new APP_NAME")
defp validate_app_name!(name) do
unless valid_app_name?(name) do
Mix.raise("App name must be lowercase letters, digits, and underscores only (e.g. my_app).")
end
name
end
defp parse_gen_opts(opts) do
dest_dir = opts[:dest] || "."
liveview = opts[:liveview] || false
{no_ios, no_android} = resolve_platforms!(opts)
gen_opts = [
local: opts[:local] || false,
no_ios: no_ios,
no_android: no_android,
python: opts[:python] || false
]
{dest_dir, liveview, gen_opts}
end
# Resolves the four platform-related flags into {no_ios?, no_android?}.
# Positive flags (--ios, --android) and negative flags (--no-ios, --no-android)
# are accepted; --ios is sugar for --no-android and vice versa.
defp resolve_platforms!(opts) do
ios? = opts[:ios] == true
android? = opts[:android] == true
no_ios? = opts[:no_ios] == true or android?
no_android? = opts[:no_android] == true or ios?
if no_ios? and no_android? do
Mix.raise(
"Cannot exclude both platforms. Pass at most one of --ios, --android, --no-ios, --no-android."
)
end
{no_ios?, no_android?}
end
defp log_flags(gen_opts, liveview) do
if gen_opts[:local] do
Mix.shell().info([
:yellow,
"* local mode: path: deps for mob/mob_dev + templates from local mob_new checkout",
:reset
])
end
if gen_opts[:no_ios] do
Mix.shell().info([:yellow, "* iOS skipped — Android-only project", :reset])
end
if gen_opts[:no_android] do
Mix.shell().info([:yellow, "* Android skipped — iOS-only project", :reset])
end
if liveview do
Mix.shell().info([
:cyan,
"* --liveview: generating Phoenix LiveView app with Mob bridge",
:reset
])
end
if gen_opts[:python] do
Mix.shell().info([
:cyan,
"* --python: enabling embedded CPython (iOS only). See python_embedding guide.",
:reset
])
end
end
defp generate(app_name, dest_dir, true = _liveview, gen_opts) do
MobNew.ProjectGenerator.liveview_generate(app_name, dest_dir, gen_opts)
end
defp generate(app_name, dest_dir, false = _liveview, gen_opts) do
MobNew.ProjectGenerator.generate(app_name, dest_dir, gen_opts)
end
defp post_generate(project_dir, app_name, liveview, gen_opts, opts) do
unless liveview, do: print_created_files(project_dir, app_name, gen_opts)
unless opts[:no_install], do: fetch_deps(project_dir)
if liveview do
print_liveview_next_steps(app_name, opts[:no_install])
else
print_next_steps(app_name, opts[:no_install], gen_opts)
end
end
# ── private ──────────────────────────────────────────────────────────────────
defp fetch_deps(project_dir) do
Mix.shell().info("")
Mix.shell().info("Fetching dependencies...")
mix = System.find_executable("mix")
abs_dir = Path.expand(project_dir)
case System.cmd(mix, ["deps.get"], cd: abs_dir, into: IO.stream()) do
{_, 0} ->
:ok
{_, _} ->
Mix.shell().info([
:yellow,
"* deps.get failed — run it manually inside #{project_dir}",
:reset
])
end
end
defp print_created_files(project_dir, app_name, gen_opts) do
no_ios = gen_opts[:no_ios] || false
no_android = gen_opts[:no_android] || false
common = ["mix.exs", "lib/#{app_name}/app.ex", "lib/#{app_name}/home_screen.ex"]
android_files =
if no_android,
do: [],
else: [
"android/settings.gradle",
"android/build.gradle",
"android/app/build.gradle",
"android/app/src/main/AndroidManifest.xml",
"android/app/src/main/java/com/mob/#{app_name}/MainActivity.kt",
"android/app/src/main/java/com/mob/#{app_name}/MobBridge.kt",
"android/app/src/main/java/com/mob/#{app_name}/MobNode.kt",
"android/app/src/main/java/com/mob/#{app_name}/MobScannerActivity.kt",
"android/gradle.properties"
]
ios_files = if no_ios, do: [], else: ["ios/beam_main.m", "ios/Info.plist"]
Enum.each(common ++ android_files ++ ios_files, fn f ->
Mix.shell().info([:green, "* creating ", :reset, Path.join(project_dir, f)])
end)
end
defp print_next_steps(app_name, no_install, gen_opts) do
install_hint =
if no_install,
do: "\n mix deps.get",
else: ""
{paths_hint, binaries_hint} = platform_specific_hints(gen_opts)
provision_hint = ios_provision_hint(gen_opts)
Mix.shell().info("""
Your Mob app #{app_name} is ready!
#{install_hint}
cd #{app_name}
mix mob.install # generates app icon + first-run setup
#{provision_hint}
First deploy — edit mob.exs#{paths_hint}, then build native binaries
(#{binaries_hint}), install on device, and push BEAMs:
mix mob.deploy --native # first time, or after native code changes
Day-to-day development — just push changed BEAMs, no native rebuild needed:
mix mob.deploy # fast push + restart
mix mob.watch # auto-push on file save
""")
end
# Returns a `mix mob.provision` callout when the project includes iOS, or
# an empty string otherwise. Required as a one-time setup step before
# `mix mob.deploy --native` can install to a physical iPhone (registers
# the bundle ID with Apple and downloads a development provisioning
# profile). Skipped for sim-only flows since simctl install doesn't sign.
defp ios_provision_hint(gen_opts) do
if gen_opts[:no_ios] do
""
else
"""
mix mob.provision # iOS only, one-time: register bundle ID
# + download provisioning profile (skip if
# you're only running on the simulator)
"""
end
end
defp platform_specific_hints(gen_opts) do
no_ios = gen_opts[:no_ios] || false
no_android = gen_opts[:no_android] || false
case {no_android, no_ios} do
# iOS only — no Android SDK path needed
{true, false} -> {"", "iOS app"}
# Android only
{false, true} -> {" and android/local.properties with your local paths", "APK"}
# Both
_ -> {" and android/local.properties with your local paths", "APK + iOS app"}
end
end
defp print_liveview_next_steps(app_name, no_install) do
install_hint =
if no_install,
do: "\n mix deps.get",
else: ""
Mix.shell().info("""
Your Mob LiveView app #{app_name} is ready!
#{install_hint}
Next steps:
1. Edit mob.exs with your local paths (mob_dir, elixir_lib).
2. Edit android/local.properties with your Android SDK path.
3. Run first-time setup:
cd #{app_name}
mix mob.install # icon generation + first-run setup
4. Configure your database in config/dev.exs and run:
mix ecto.create && mix ecto.migrate
5. Run the Phoenix server once to download JS/CSS dependencies and compile
assets. This is required before deploying — skipping it will result in
missing assets on device:
mix phx.server
Open http://localhost:4000 in your browser to confirm it loads, then
stop the server (Ctrl-C).
6. iOS only — if you're targeting a physical iPhone, register the bundle
ID with Apple and download a provisioning profile (one-time setup;
skip for the simulator):
mix mob.provision
7. Deploy to device (first time — builds native APK/iOS app):
mix mob.deploy --native
The Mob WebView will load your Phoenix app at http://127.0.0.1:4000/.
Verify `window.mob.send` in browser devtools routes through `pushEvent`
(not `postMessage`) to confirm the LiveView bridge is active.
Day-to-day development:
mix mob.deploy # fast push + restart
mix mob.watch # auto-push on file save
""")
end
defp valid_app_name?(name) do
case name do
<<first, rest::binary>> when first in ?a..?z ->
rest
|> :binary.bin_to_list()
|> Enum.all?(fn c -> c in ?a..?z or c in ?0..?9 or c == ?_ end)
_ ->
false
end
end
end