Packages
mob_new
0.4.2
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
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.0
0.1.45
0.1.44
0.1.43
0.1.42
0.1.40
0.1.33
0.1.30
0.1.29
0.1.28
0.1.27
0.1.26
0.1.25
0.1.24
0.1.23
0.1.21
0.1.20
0.1.19
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Project generator for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_new/project_generator.ex
defmodule MobNew.ProjectGenerator do
@moduledoc """
Generates a new Mob project from EEx templates in `priv/templates/mob.new/`.
## Naming conventions
Given `app_name = "my_cool_app"` and the default bundle prefix:
- `module_name` → `"MyCoolApp"`
- `display_name` → `"MyCoolApp"`
- `bundle_id` → `"com.example.my_cool_app"`
- `java_package` → `"com.example.my_cool_app"`
- `lib_name` → `"mycoolapp"` (no underscores, for `System.loadLibrary`)
- `java_path` → `"com/example/my_cool_app"` (for directory structure)
## Bundle prefix
The reverse-DNS prefix for the bundle ID defaults to `com.example`, the
universal "must change before shipping" placeholder. Override at generation
time with the `MOB_BUNDLE_PREFIX` env var:
MOB_BUNDLE_PREFIX=net.acme mix mob.new my_cool_app
# → bundle_id = "net.acme.my_cool_app"
We deliberately do **not** use `com.mob` — that's our reverse-DNS namespace,
and Apple/Google enforce ownership at submission time, so a project that
ships with `com.mob.*` would have to be renamed before reaching either store.
"""
# Template + static roots. Default to the installed archive's priv dir
# (the loaded mob_new code's :code.priv_dir/1). When the caller asks for
# local-override behaviour AND a usable mob_new checkout is reachable,
# use that checkout's priv instead — so `mix mob.new --local` picks up
# template fixes that haven't been republished to the archive yet.
#
# Mob_new's archive install pattern means the templates baked into the
# archive get stale the moment someone commits a template fix in the
# repo. `--local` was originally documented as "use path: deps for
# mob/mob_dev" only, but users (rightly) expected it to also pick up
# local template fixes — same mental model as `--local` everywhere
# else in the Mix ecosystem.
defp templates_root(opts), do: priv_root(opts) |> Path.join("templates/mob.new")
defp static_root(opts), do: priv_root(opts) |> Path.join("static/mob.new")
defp priv_root(opts) do
case local_mob_new_priv(opts) do
nil -> :code.priv_dir(:mob_new) |> to_string()
dir -> dir
end
end
# Returns the priv path of a local mob_new checkout when:
# 1. `opts[:local]` is truthy (user opted in), AND
# 2. an override path is reachable: `$MOB_NEW_DIR` env var or
# `$HOME/code/mob_new` as the fallback location, AND
# 3. that path actually contains `priv/templates/mob.new/`.
# Otherwise returns nil (caller falls back to :code.priv_dir/1).
#
# Public for testing — same pattern as other "decide which fixture to
# use" helpers we expose so tests can stub the env independently of the
# filesystem.
@doc false
@spec local_mob_new_priv(keyword()) :: String.t() | nil
def local_mob_new_priv(opts) do
if Keyword.get(opts, :local, false) do
[System.get_env("MOB_NEW_DIR"), Path.expand("~/code/mob_new")]
|> Enum.reject(&is_nil/1)
|> Enum.find_value(&priv_if_templates_exist/1)
end
end
defp priv_if_templates_exist(dir) do
priv = Path.join(dir, "priv")
if File.dir?(Path.join(priv, "templates/mob.new")), do: priv
end
# Reverse-DNS prefix for the generated bundle id. Honors MOB_BUNDLE_PREFIX
# (typical value: "com.acme" or "net.you"); defaults to "com.example", the
# universal "must change before shipping" placeholder. Never defaults to
# "com.mob" — Apple and Google enforce reverse-DNS ownership at App Store
# / Play Store submission, so apps generated with our namespace would have
# to be renamed before reaching either store.
@spec bundle_prefix() :: String.t()
def bundle_prefix do
case System.get_env("MOB_BUNDLE_PREFIX") do
nil -> "com.example"
"" -> "com.example"
raw -> String.trim(raw)
end
end
@doc """
Returns the EEx template assigns map for `app_name`.
Options:
- `:local` — when `true`, generates `path:` deps pointing to local mob/mob_dev
repos instead of hex version constraints. Paths are resolved from the
`MOB_DIR` and `MOB_DEV_DIR` environment variables, falling back to
`../mob` and `../mob_dev` relative to the generated project location.
"""
@spec assigns(String.t(), keyword()) :: map()
def assigns(app_name, opts \\ []) do
module_name = Macro.camelize(app_name)
display_name = module_name
bundle_prefix = bundle_prefix()
bundle_id = "#{bundle_prefix}.#{app_name}"
java_package = bundle_id
lib_name = String.replace(app_name, "_", "")
java_path = String.replace(bundle_id, ".", "/")
# JNI method name segment: dots→underscores, then underscores→_1
# e.g. "com.mob.test_app" → "com_mob_test_1app"
jni_package =
java_package
|> String.replace("_", "_1")
|> String.replace(".", "_")
{mob_dep, mob_dev_dep, mob_exs_mob_dir, mob_exs_elixir_lib} = resolve_deps(opts)
%{
app_name: app_name,
module_name: module_name,
display_name: display_name,
bundle_id: bundle_id,
java_package: java_package,
jni_package: jni_package,
lib_name: lib_name,
java_path: java_path,
mob_dep: mob_dep,
mob_dev_dep: mob_dev_dep,
mob_exs_mob_dir: mob_exs_mob_dir,
mob_exs_elixir_lib: mob_exs_elixir_lib,
ndk_version: MobNew.NdkVersion.recommended(),
python: Keyword.get(opts, :python, false)
}
end
@doc """
Generates a new project at `dest_dir/<app_name>` from the bundled templates.
Returns `{:ok, project_dir}` or `{:error, reason}`.
"""
@spec generate(String.t(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
def generate(app_name, dest_dir \\ ".", opts \\ []) do
project_dir = Path.join(dest_dir, app_name)
if File.exists?(project_dir) do
{:error, "Directory already exists: #{project_dir}"}
else
File.mkdir_p!(project_dir)
a = assigns(app_name, opts)
render_templates(a, project_dir, opts)
copy_static(project_dir, opts)
write_dotfiles(project_dir, opts)
if Keyword.get(opts, :python, false), do: apply_python_patches(project_dir, app_name)
{:ok, project_dir}
end
end
@doc """
Patches a generated project to enable Pythonx (embedded CPython,
iOS + Android).
Two patches:
* `mix.exs` — adds `{:pythonx, "~> 0.4"}` to deps.
* `lib/<app>/python_paths.ex` — pure detection module that reads
`:code.root_dir/0` for iOS and `MOB_PYTHON_HOME` / `MOB_PYTHON_DL`
env vars (set by Android's `MainActivity`) for Android. Returns
`:desktop` / `{:ios, paths}` / `{:android, paths}` /
`{:partial, missing}`.
Note: deliberately does NOT patch `config/config.exs` — `:pythonx,
:uv_init` in compile-time config makes `Pythonx.Application.start/2`
auto-run uv at boot, which fails on device. The generated `app.ex`
(when --python is set) inlines the pyproject_toml and calls
`Pythonx.Uv.fetch + init` only on the `:desktop` branch.
Mirrors `mix mob.enable pythonx` (in `mob_dev`). Idempotent — safe to
run twice. Public for testing.
"""
@spec apply_python_patches(String.t(), String.t()) :: :ok
def apply_python_patches(project_dir, app_name) do
add_pythonx_dep(project_dir)
write_python_paths_module(project_dir, app_name)
:ok
end
defp add_pythonx_dep(project_dir) do
path = Path.join(project_dir, "mix.exs")
case File.read(path) do
{:ok, content} ->
cond do
String.contains?(content, ":pythonx") ->
:ok
Regex.match?(Regex.compile!(~S{defp\s+deps\s+do\s*\[}), content) ->
patched =
Regex.replace(
Regex.compile!(~S{(defp\s+deps\s+do\s*\[)}),
content,
~s(\\1\n {:pythonx, "~> 0.4"},),
global: false
)
File.write!(path, patched)
true ->
:ok
end
_ ->
:ok
end
end
defp write_python_paths_module(project_dir, app_name) do
module_name = Macro.camelize(app_name)
dir = Path.join([project_dir, "lib", app_name])
File.mkdir_p!(dir)
path = Path.join(dir, "python_paths.ex")
unless File.exists?(path) do
File.write!(path, python_paths_module_source(module_name))
end
end
defp python_paths_module_source(module_name) do
"""
defmodule #{module_name}.PythonPaths do
@moduledoc \"\"\"
Detects bundled CPython at runtime and reports the paths needed
for `Pythonx.init/4` (dl_path, home_path, stdlib_path).
Pure detection logic — see your app's `App` module for how the
result is fed into `Pythonx.init/4` at boot.
## Per-platform layout
* **iOS**: `mix mob.deploy --native` bundles `Python.framework`,
stdlib, and lib-dynload at `<App>.app/otp/python/`. Detection
reads `:code.root_dir/0` and inspects that subtree.
* **Android**: `mix mob.deploy --native` bundles libpython.so
into the APK's `jniLibs/<abi>/` (auto-extracted by the
installer to `applicationInfo.nativeLibraryDir`) and stdlib
+ lib-dynload into `assets/python/` (extracted to
`filesDir/python/` by `MainActivity.onCreate` on first
launch). MainActivity exports the resolved paths via
`MOB_PYTHON_DL` and `MOB_PYTHON_HOME` env vars before
starting the BEAM.
## Returns
* `:desktop` — no platform bundle found; the caller should
drive `Pythonx.Uv.fetch + init` manually.
* `{:ios, paths}` / `{:android, paths}` — bundle present; pass
into `Pythonx.init/4`.
* `{:partial, missing}` — bundle is incomplete; surface to
the user.
\"\"\"
@type python_paths :: %{
dl_path: String.t(),
home_path: String.t(),
stdlib_path: String.t()
}
@type detection ::
:desktop
| {:ios, python_paths()}
| {:android, python_paths()}
| {:partial, [atom()]}
@python_version "python3.13"
@spec detect(String.t()) :: detection()
def detect(otp_root) when is_binary(otp_root) do
cond do
android_paths() != nil ->
paths = android_paths()
case missing(paths) do
[] -> {:android, paths}
missing -> {:partial, missing}
end
File.dir?(Path.join(otp_root, "python")) ->
paths = build_ios_paths(otp_root)
case missing(paths) do
[] -> {:ios, paths}
missing -> {:partial, missing}
end
true ->
:desktop
end
end
@spec build_ios_paths(String.t()) :: python_paths()
def build_ios_paths(otp_root) when is_binary(otp_root) do
python_dir = Path.join(otp_root, "python")
%{
dl_path: Path.join([python_dir, "Python.framework", "Python"]),
home_path: python_dir,
stdlib_path: Path.join([python_dir, "lib", @python_version])
}
end
@spec build_android_paths() :: python_paths() | nil
def build_android_paths do
case {System.get_env("MOB_PYTHON_DL"), System.get_env("MOB_PYTHON_HOME")} do
{dl, home} when is_binary(dl) and is_binary(home) ->
%{
dl_path: dl,
home_path: home,
stdlib_path: Path.join([home, "lib", @python_version])
}
_ ->
nil
end
end
defp android_paths, do: build_android_paths()
@spec missing(python_paths()) :: [atom()]
def missing(%{dl_path: dl, home_path: home, stdlib_path: stdlib}) do
[
{:dl_path, File.exists?(dl)},
{:home_path, File.dir?(home)},
{:stdlib_path, File.dir?(stdlib)}
]
|> Enum.reject(fn {_, present?} -> present? end)
|> Enum.map(&elem(&1, 0))
end
end
"""
end
# `mix archive.build` packages files in `priv/` via `Path.wildcard/2`
# without `match_dot: true`, which silently drops every dotfile in
# the template tree. We sidestep that by writing them inline here so
# the archive packaging never has to know about them. If you need
# variable substitution in a future dotfile, switch to a non-dot
# template name (e.g. `dot_tool_versions.eex`) and rename in
# `expand_path/2` rather than relying on the archive to ship `.eex`
# dotfiles directly.
@dotfiles %{
"android/.editorconfig" => """
# ktlint configuration for Mob-generated Android projects.
#
# package-name: Mob app names use underscores (e.g. my_app → com.example.my_app).
# Android allows underscores in package names; the style guide discourages them
# but the Mob naming convention requires them.
#
# function-naming: JNI bridge functions (nativeSetActivity, nativeStartBeam, etc.)
# must match the JNI symbol names declared in beam_jni.c. Renaming them to
# camelCase would break the native linkage.
#
# no-multi-spaces: Mob templates use column-aligned arrows in when expressions
# for readability. ktlint cannot auto-correct this, so we suppress it globally.
[*.kt]
ktlint_standard_package-name = disabled
ktlint_standard_function-naming = disabled
ktlint_standard_no-multi-spaces = disabled
""",
".tool-versions" => """
# Pinned toolchain versions for Mob development.
# Both mise and asdf read this file automatically.
#
# mise install https://mise.jdx.dev (recommended — brew install mise)
# asdf install https://asdf-vm.com
#
# OTP 29 matches the device runtime tarballs. Java 17 LTS is required for Gradle.
erlang 29.0
elixir 1.20.0-otp-29
java temurin-17.0.18
""",
".formatter.exs" => """
[
plugins: [Mob.Formatter],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
""",
".gitignore" => """
/_build/
/deps/
/doc/
/.fetch
*.ez
*.beam
*.o
*.so
*.a
erl_crash.dump
mob.exs
# Signing secrets — NEVER commit
android/keystore.properties
android/*.keystore
# Native build caches / outputs
android/app/src/main/assets/otp.zip
android/local.properties
android/.gradle/
android/app/build/
android/app/.cxx/
ios/zig-out/
ios/.zig-cache/
**/.zig-cache/
_build/mob_release/
# macOS
.DS_Store
"""
}
defp write_dotfiles(project_dir, opts) do
no_android = Keyword.get(opts, :no_android, false)
no_ios = Keyword.get(opts, :no_ios, false)
Enum.each(@dotfiles, fn {name, content} ->
skip =
(no_android and String.starts_with?(name, "android/")) or
(no_ios and String.starts_with?(name, "ios/"))
unless skip do
File.write!(Path.join(project_dir, name), content)
end
end)
end
@doc """
Generates a LiveView-wrapped Mob project at `dest_dir/<app_name>`.
This calls `mix phx.new` as a subprocess to create the Phoenix project, then:
- Patches `mix.exs` to add the mob / mob_dev dependencies
- Copies the standard Android/iOS native boilerplate
- Applies the `mix mob.enable liveview` patches:
- Injects `MobHook` into `assets/js/app.js`
- Injects the bridge `<div>` into `root.html.heex`
- Generates `lib/<app>/mob_screen.ex`
- Writes `mob.exs` with `liveview_port: 4000`
- Patches `lib/<app>/application.ex` to start `Mob.App` alongside Phoenix
Returns `{:ok, project_dir}` or `{:error, reason}`.
"""
@spec liveview_generate(String.t(), String.t(), keyword()) ::
{:ok, String.t()} | {:error, term()}
def liveview_generate(app_name, dest_dir \\ ".", opts \\ []) do
project_dir = Path.join(Path.expand(dest_dir), app_name)
if File.exists?(project_dir) do
{:error, "Directory already exists: #{project_dir}"}
else
# Mark this as a liveview generation so copy_native_boilerplate skips
# files Phoenix's `mix phx.new` already created (mix.exs, config/, etc.).
liveview_opts = Keyword.put(opts, :liveview, true)
with :ok <- run_phx_new(app_name, dest_dir, opts),
:ok <- patch_mix_exs(project_dir, opts),
:ok <- copy_native_boilerplate(app_name, project_dir, liveview_opts),
:ok <- apply_liveview_patches(app_name, project_dir, opts) do
{:ok, project_dir}
end
end
end
# ── LiveView generation helpers ───────────────────────────────────────────────
defp run_phx_new(app_name, dest_dir, _opts) do
mix = System.find_executable("mix")
if mix do
abs_dest = Path.expand(dest_dir)
args = [
"phx.new",
app_name,
"--no-install",
"--no-dashboard",
"--no-mailer",
"--no-ecto"
]
Mix.shell().info("Running mix phx.new #{app_name} in #{abs_dest}...")
case System.cmd(mix, args, cd: abs_dest, stderr_to_stdout: true) do
{_output, 0} ->
:ok
{output, exit_code} ->
{:error, "mix phx.new failed (exit #{exit_code}):\n#{output}"}
end
else
{:error, "mix executable not found in PATH"}
end
end
defp patch_mix_exs(project_dir, opts) do
path = Path.join(project_dir, "mix.exs")
if File.exists?(path) do
a = assigns(Path.basename(project_dir), opts)
content = File.read!(path)
patched = MobNew.LiveViewPatcher.inject_deps(content, a.mob_dep, a.mob_dev_dep)
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (added mob deps)"])
:ok
else
{:error, "mix.exs not found in #{project_dir}"}
end
end
defp copy_native_boilerplate(app_name, project_dir, opts) do
# Render the native templates (android + ios) into a temp dir,
# then move them into the Phoenix project dir.
a = assigns(app_name, opts)
no_ios = Keyword.get(opts, :no_ios, false)
no_android = Keyword.get(opts, :no_android, false)
# iOS sim build.sh.eex was eliminated in Phase 2 iter 13b — Mix is now
# the sole orchestrator for iOS sim builds. LiveView still emits a
# build.sh dynamically (iter 13c will eliminate that too).
executable_templates = []
executable_static = ["android/gradlew"]
t_root = templates_root(opts)
s_root = static_root(opts)
log_local_priv_once(opts, t_root)
t_root
|> find_templates()
|> Enum.filter(&platform_included?(&1, t_root, no_ios, no_android))
|> Enum.reject(&liveview_phoenix_owned?(&1, t_root, opts))
|> Enum.each(fn template_path ->
rel = Path.relative_to(template_path, t_root)
dest_rel = expand_path(rel, a)
dest = Path.join(project_dir, dest_rel)
File.mkdir_p!(Path.dirname(dest))
content = EEx.eval_file(template_path, Map.to_list(a))
File.write!(dest, content)
Mix.shell().info([:green, "* create ", :reset, dest])
if dest_rel in executable_templates, do: File.chmod!(dest, 0o755)
end)
# Copy static files (gradlew, wrapper jars, iOS assets, etc.)
s_root
|> Path.join("**/*")
|> Path.wildcard(match_dot: true)
|> Enum.reject(&File.dir?/1)
|> Enum.filter(&platform_included?(&1, s_root, no_ios, no_android))
|> Enum.reject(&liveview_phoenix_owned?(&1, s_root, opts))
|> Enum.each(fn src ->
rel = Path.relative_to(src, s_root)
dest = Path.join(project_dir, rel)
File.mkdir_p!(Path.dirname(dest))
File.copy!(src, dest)
if rel in executable_static, do: File.chmod!(dest, 0o755)
end)
:ok
end
# One-time log so the user knows their --local is doing what they
# expect. The first time templates resolve from a local checkout
# (rather than the installed archive), say so.
defp log_local_priv_once(opts, t_root) do
if Keyword.get(opts, :local, false) do
archive_root = :code.priv_dir(:mob_new) |> to_string() |> Path.join("templates/mob.new")
if t_root != archive_root do
Mix.shell().info([:cyan, "* --local: using templates from ", :reset, t_root])
end
end
end
@doc """
When generating a LiveView project, `mix phx.new` already produced its own
mix.exs, config/, lib/<app>/, lib/<app>_web/, .gitignore, and assets/ — and
those are the *correct* versions for a Phoenix app (with gettext,
telemetry_metrics, etc.). The native template's same-named files are
written for the bare-Mob path and would clobber Phoenix's, leaving the
project unable to compile.
When `:liveview` is true in opts, this predicate returns true for any
template path that Phoenix already owns, so the copy step skips it. Files
that are unique to Mob (mob.exs, src/<app>.erl, android/, ios/) still get
emitted normally.
Public for testing — guards against the regression where a new template
path lands in the native tree without being added to the LiveView
blocklist.
"""
@spec liveview_phoenix_owned?(String.t(), String.t(), keyword()) :: boolean()
def liveview_phoenix_owned?(path, root, opts) do
if Keyword.get(opts, :liveview, false) do
rel = Path.relative_to(path, root)
cond do
rel == "mix.exs.eex" -> true
String.starts_with?(rel, "config/") -> true
# Native-template `lib/app_name/` includes sample screens (audio, camera,
# webview, etc.) that are Mob-native UI — they don't make sense in a
# LiveView project where the UI is HTML/HEEx, and they collide with
# Phoenix's own lib/<app>/ contents. Skip the whole subtree; the
# LiveView-specific MobScreen file is generated by apply_liveview_patches.
String.starts_with?(rel, "lib/app_name/") -> true
# priv/repo/migrations is added explicitly by apply_liveview_patches when
# ecto_sqlite3 is wired up — skip the native template's version.
String.starts_with?(rel, "priv/") -> true
true -> false
end
else
false
end
end
# Decide whether a template/static file should be emitted given the platform
# exclusion flags. Files outside android/ and ios/ are always included
# (lib/, mix.exs, etc.). Files under android/ are excluded when no_android,
# likewise ios/ when no_ios.
defp platform_included?(path, root, no_ios, no_android) do
rel = Path.relative_to(path, root)
cond do
String.starts_with?(rel, "android/") -> not no_android
String.starts_with?(rel, "ios/") -> not no_ios
true -> true
end
end
defp apply_liveview_patches(app_name, project_dir, opts) do
module_name = Macro.camelize(app_name)
a = assigns(app_name, opts)
# 1. Inject MobHook into assets/js/app.js
patch_app_js(project_dir)
# 2. Inject bridge element into root.html.heex
patch_root_html(project_dir, app_name)
# 3. Generate lib/<app>/mob_screen.ex
generate_mob_screen(project_dir, app_name, module_name)
# 4. Generate lib/<app>/mob_app.ex (BEAM entry point for LiveView mode)
generate_mob_live_app(project_dir, app_name, module_name)
# 5. Generate src/<app>.erl (Erlang bootstrap, calls MobApp.start/0)
generate_erlang_entry(project_dir, app_name, module_name)
# 6. Patch mix.exs to include src/ in erlc_paths
patch_mix_exs_erlc(project_dir, app_name)
# 7. Write mob.exs with liveview_port
write_mob_exs(project_dir, a.mob_exs_mob_dir, a.mob_exs_elixir_lib)
# 7b. Patch Phoenix's config files to use 4200 (dev) and 4202 (test) so
# `mix phx.server` doesn't collide with another LV project on 4000.
# The on-device runtime in mob_app.ex already uses 4200 — this lines
# up the host-side dev/test endpoints with that.
patch_config_ports(project_dir)
# 8. Write .gitignore entry for mob.exs (append if file exists)
patch_gitignore(project_dir)
# 9. Generate the notes starter app: Repo, Note schema, Notes context,
# migration, three LiveViews, and patch router + application.ex + configs.
inject_ecto_sqlite3_dep(project_dir)
patch_config_for_ecto(project_dir, app_name, module_name)
generate_notes_app(project_dir, app_name, module_name)
# 10. (Phase 2 iter 13b) LiveView's ios/build.sh used to be overwritten
# here with crypto-shim/ssl-copy/Phoenix-asset glue. All of that
# now lives in mob_dev's NativeBuild as `maybe_install_crypto_shim`,
# `maybe_copy_ssl_beams`, `maybe_build_phoenix_assets` — gated on
# `liveview_project?/0` (presence of phoenix_live_view dep). No
# ios/build.sh emission needed; the iOS sim build.zig handles
# native compile + link the same way for vanilla and LV.
_ = {project_dir, app_name, module_name}
:ok
end
defp patch_app_js(project_dir) do
path = Path.join([project_dir, "assets", "js", "app.js"])
if File.exists?(path) do
content = File.read!(path)
patched = MobNew.LiveViewPatcher.inject_mob_hook(content)
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (added MobHook)"])
else
Mix.shell().info([
:yellow,
"* skip ",
:reset,
"assets/js/app.js not found — add MobHook manually"
])
end
end
defp patch_root_html(project_dir, app_name) do
web_name = app_name <> "_web"
candidates = [
Path.join([project_dir, "lib", web_name, "components", "layouts", "root.html.heex"]),
Path.join([project_dir, "lib", web_name, "templates", "layout", "root.html.heex"])
]
path = Enum.find(candidates, &File.exists?/1)
if path do
content = File.read!(path)
patched = MobNew.LiveViewPatcher.inject_mob_bridge_element(content)
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (added mob-bridge element)"])
else
Mix.shell().info([
:yellow,
"* skip root.html.heex not found — add mob-bridge element manually:",
:reset
])
Mix.shell().info(" " <> MobNew.LiveViewPatcher.mob_bridge_element())
end
end
defp generate_mob_screen(project_dir, app_name, module_name) do
dir = Path.join([project_dir, "lib", app_name])
path = Path.join(dir, "mob_screen.ex")
File.mkdir_p!(dir)
File.write!(path, MobNew.LiveViewPatcher.mob_screen_content(module_name))
Mix.shell().info([:green, "* create ", :reset, path])
end
defp write_mob_exs(project_dir, mob_exs_mob_dir, mob_exs_elixir_lib) do
path = Path.join(project_dir, "mob.exs")
File.write!(path, MobNew.LiveViewPatcher.mob_exs_content(mob_exs_mob_dir, mob_exs_elixir_lib))
Mix.shell().info([:green, "* create ", :reset, path])
end
defp generate_mob_live_app(project_dir, app_name, module_name) do
dir = Path.join([project_dir, "lib", app_name])
path = Path.join(dir, "mob_app.ex")
File.mkdir_p!(dir)
# Extract the secret_key_base phx.new wrote into config/dev.exs so that
# on-device Application.put_env uses the same key the dev server uses.
# Falls back to a freshly generated key if extraction fails.
secret_key_base = extract_secret_key_base(project_dir) || generate_secret_key_base()
signing_salt = generate_signing_salt()
content =
MobNew.LiveViewPatcher.mob_live_app_content(
module_name,
app_name,
secret_key_base,
signing_salt
)
File.write!(path, content)
Mix.shell().info([:green, "* create ", :reset, path])
end
defp inject_ecto_sqlite3_dep(project_dir) do
path = Path.join(project_dir, "mix.exs")
if File.exists?(path) do
content = File.read!(path)
unless String.contains?(content, "ecto_sqlite3") do
patched =
Regex.replace(
Regex.compile!("(defp deps do\\s*\\[)"),
content,
~s[\\1\n {:ecto_sqlite3, "~> 0.18"},],
global: false
)
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (added ecto_sqlite3)"])
end
end
end
# phx.new emits Endpoint http port 4000 in dev.exs, 4002 in test.exs, and a
# `PORT` env var defaulting to "4000" in runtime.exs. Mob's LiveView mode
# standardises on 4200 (host dev + on-device runtime — see mob_app.ex), so
# `mix phx.server` from a generated project doesn't collide with someone
# else's Phoenix app already on 4000. Idempotent: skips the rewrite if the
# file's port has already been bumped.
defp patch_config_ports(project_dir) do
[
{"config/dev.exs", Regex.compile!("port:\\s*4000\\b"), "port: 4200",
"dev port 4000 → 4200"},
{"config/test.exs", Regex.compile!("port:\\s*4002\\b"), "port: 4202",
"test port 4002 → 4202"},
# Phoenix ≤ 1.7: `System.get_env("PORT") || "4000"`.
{"config/runtime.exs", Regex.compile!(~S{"PORT"\s*\)\s*\|\|\s*"4000"}),
"\"PORT\") || \"4200\"", "runtime PORT default 4000 → 4200 (legacy ||)"},
# Phoenix ≥ 1.8: `System.get_env("PORT", "4000")` — two-arg form.
{"config/runtime.exs", Regex.compile!(~S{"PORT"\s*,\s*"4000"}), "\"PORT\", \"4200\"",
"runtime PORT default 4000 → 4200"}
]
|> Enum.each(&apply_port_patch(project_dir, &1))
# Fallback for newer Phoenix versions that omit an explicit `port:` line
# in config/dev.exs (Phoenix defaults the port to 4000 implicitly). The
# regex above finds nothing to replace, so we have to inject a port: line
# into the http: keyword list ourselves. Idempotent.
ensure_dev_port(project_dir)
end
defp ensure_dev_port(project_dir) do
path = Path.join([project_dir, "config", "dev.exs"])
with true <- File.exists?(path),
content <- File.read!(path),
# Idempotency check tied to the actual target value, not a generic
# `port:\s*\d+` regex — Phoenix's dev.exs ships an SSL example block
# in comments that contains `port: 4001`, which would otherwise
# fool a presence-only check into thinking we already patched.
false <- String.contains?(content, "port: 4200"),
# First `http: [` in the file is the Endpoint's. Inject the port
# at the front of its keyword list. The global: false on
# String.replace ensures only the first occurrence is touched.
[match | _] <- Regex.run(~r/http:\s*\[/, content) do
patched = String.replace(content, match, "#{match}port: 4200, ", global: false)
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (dev port injected → 4200)"])
else
_ -> :ok
end
end
defp apply_port_patch(project_dir, {rel, find, replace, label}) do
path = Path.join(project_dir, rel)
if File.exists?(path) do
content = File.read!(path)
patched = Regex.replace(find, content, replace, global: false)
if patched != content do
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (#{label})"])
end
end
end
defp patch_config_for_ecto(project_dir, app_name, module_name) do
config_exs = Path.join([project_dir, "config", "config.exs"])
dev_exs = Path.join([project_dir, "config", "dev.exs"])
if File.exists?(config_exs) do
content = File.read!(config_exs)
unless String.contains?(content, "ecto_repos") do
ecto_config = """
config :#{app_name},
ecto_repos: [#{module_name}.Repo],
generators: [timestamp_type: :utc_datetime]
"""
patched =
String.replace(content, "import_config", ecto_config <> "\nimport_config",
global: false
)
File.write!(config_exs, patched)
Mix.shell().info([:green, "* patch ", :reset, config_exs, " (added ecto_repos)"])
end
end
if File.exists?(dev_exs) do
content = File.read!(dev_exs)
unless String.contains?(content, "#{module_name}.Repo") do
repo_config = """
config :#{app_name}, #{module_name}.Repo,
database: Path.expand("../priv/repo/#{app_name}_dev.db", __DIR__),
pool_size: 5
"""
File.write!(dev_exs, content <> repo_config)
Mix.shell().info([:green, "* patch ", :reset, dev_exs, " (added Repo dev config)"])
end
end
end
defp generate_notes_app(project_dir, app_name, module_name) do
live_dir = Path.join([project_dir, "lib", "#{app_name}_web", "live"])
lib_dir = Path.join([project_dir, "lib", app_name])
migrations_dir = Path.join([project_dir, "priv", "repo", "migrations"])
File.mkdir_p!(live_dir)
File.mkdir_p!(lib_dir)
File.mkdir_p!(migrations_dir)
write = fn path, content ->
File.write!(path, content)
Mix.shell().info([:green, "* create ", :reset, path])
end
write.(
Path.join(lib_dir, "repo.ex"),
MobNew.LiveViewPatcher.repo_content(module_name, app_name)
)
write.(
Path.join(lib_dir, "note.ex"),
MobNew.LiveViewPatcher.note_content(module_name)
)
write.(
Path.join(lib_dir, "notes.ex"),
MobNew.LiveViewPatcher.notes_content(module_name, app_name)
)
write.(
Path.join(migrations_dir, "20260424000000_create_notes.exs"),
MobNew.LiveViewPatcher.migration_content(app_name)
)
write.(
Path.join(live_dir, "notes_list_live.ex"),
MobNew.LiveViewPatcher.notes_list_live_content(module_name, app_name)
)
write.(
Path.join(live_dir, "note_editor_live.ex"),
MobNew.LiveViewPatcher.note_editor_live_content(module_name, app_name)
)
write.(
Path.join(live_dir, "about_live.ex"),
MobNew.LiveViewPatcher.about_live_content(module_name, app_name)
)
patch_router_for_notes(project_dir, app_name, module_name)
patch_application_ex_for_repo(project_dir, app_name, module_name)
end
defp patch_router_for_notes(project_dir, app_name, _module_name) do
web_name = app_name <> "_web"
path = Path.join([project_dir, "lib", web_name, "router.ex"])
notes_routes =
~s[live "/", NotesListLive\n live "/notes/:id", NoteEditorLive\n live "/about", AboutLive]
if File.exists?(path) do
content = File.read!(path)
patched =
Regex.replace(
Regex.compile!("get\\s+\"\\/\",\\s+PageController,\\s+:home"),
content,
notes_routes,
global: false
)
patched =
if patched == content do
# Fallback: replace any existing live "/" route
Regex.replace(
Regex.compile!("live\\s+\"\\/\",\\s+\\w+"),
content,
notes_routes,
global: false
)
else
patched
end
if patched != content do
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (notes routes)"])
end
end
end
defp patch_application_ex_for_repo(project_dir, app_name, module_name) do
path = Path.join([project_dir, "lib", app_name, "application.ex"])
if File.exists?(path) do
content = File.read!(path)
unless String.contains?(content, "#{module_name}.Repo") do
patched =
String.replace(
content,
"#{module_name}Web.Endpoint",
"#{module_name}.Repo,\n #{module_name}Web.Endpoint",
global: false
)
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (added Repo to supervision tree)"])
end
end
end
defp extract_secret_key_base(project_dir) do
dev_exs = Path.join([project_dir, "config", "dev.exs"])
if File.exists?(dev_exs) do
content = File.read!(dev_exs)
case Regex.run(Regex.compile!("secret_key_base:\\s*\"([^\"]{40,})\""), content) do
[_, key] -> key
_ -> nil
end
end
end
defp generate_secret_key_base do
:crypto.strong_rand_bytes(48) |> Base.encode64(padding: false)
end
defp generate_signing_salt do
:crypto.strong_rand_bytes(8) |> Base.encode64(padding: false)
end
defp generate_erlang_entry(project_dir, app_name, module_name) do
dir = Path.join(project_dir, "src")
path = Path.join(dir, "#{app_name}.erl")
File.mkdir_p!(dir)
File.write!(path, MobNew.LiveViewPatcher.erlang_entry_content(module_name, app_name))
Mix.shell().info([:green, "* create ", :reset, path])
end
defp patch_mix_exs_erlc(project_dir, _app_name) do
# Phoenix projects don't compile .erl files by default. We need to add:
# erlc_paths: ["src"]
# to the project/0 function so the Erlang bootstrap is compiled.
path = Path.join(project_dir, "mix.exs")
if File.exists?(path) do
content = File.read!(path)
if String.contains?(content, "erlc_paths") do
Mix.shell().info(" * skip #{path} (erlc_paths already set)")
else
patched =
Regex.replace(
Regex.compile!("(def project do\\s*\\[)"),
content,
"\\1\n erlc_paths: [\"src\"],\n erlc_options: [:debug_info],",
global: false
)
File.write!(path, patched)
Mix.shell().info([:green, "* patch ", :reset, path, " (added erlc_paths: [\"src\"])"])
end
end
:ok
end
defp patch_gitignore(project_dir) do
path = Path.join(project_dir, ".gitignore")
if File.exists?(path) do
content = File.read!(path)
# Phoenix ships its own .gitignore (so the native template's is blocked
# by liveview_phoenix_owned?/3), but it knows nothing about Mob's native
# build: the bundled OTP zip, .cxx/.zig-cache outputs, and Android signing
# keystores would all be committed on `git add -A`. Append what's missing,
# each guarded by a sentinel so re-running the generator stays idempotent.
additions =
[
{"mob.exs", "# Mob local config\nmob.exs\n"},
{"android/app/.cxx/", mob_native_gitignore_block()}
]
|> Enum.reject(fn {sentinel, _} -> String.contains?(content, sentinel) end)
|> Enum.map_join("\n", fn {_sentinel, block} -> block end)
unless additions == "" do
File.write!(path, content <> "\n" <> additions)
Mix.shell().info([:green, "* patch ", :reset, path, " (mob excludes)"])
end
end
end
defp mob_native_gitignore_block do
"""
# Mob native build caches / outputs and signing secrets
*.o
*.so
*.a
erl_crash.dump
android/keystore.properties
android/*.keystore
android/app/src/main/assets/otp.zip
android/app/.cxx/
ios/zig-out/
ios/.zig-cache/
**/.zig-cache/
_build/mob_release/
"""
end
# ── Dep resolution ────────────────────────────────────────────────────────────
defp resolve_deps(opts) do
if opts[:local] do
mob_dir = resolve_local_path("MOB_DIR", "mob")
mob_dev_dir = resolve_local_path("MOB_DEV_DIR", "mob_dev")
elixir_lib = :code.lib_dir(:elixir) |> to_string() |> Path.dirname() |> Path.expand()
# override: true so the local checkout satisfies the `mob ~> 0.7`
# requirement that the Hex showcase plugins (mob_camera, mob_themes, …)
# declare — Mix won't otherwise use a path dep to resolve a Hex
# sub-dependency requirement.
mob_dep = ~s({:mob, path: "#{mob_dir}", override: true})
mob_dev_dep = ~s({:mob_dev, path: "#{mob_dev_dir}", only: :dev, runtime: false})
mob_exs_mob_dir = inspect(mob_dir)
mob_exs_elixir_lib = inspect(elixir_lib)
{mob_dep, mob_dev_dep, mob_exs_mob_dir, mob_exs_elixir_lib}
else
mob_dep = ~s({:mob, "~> 0.7"})
mob_dev_dep = ~s({:mob_dev, "~> 0.6", only: :dev, runtime: false})
mob_exs_mob_dir = "Path.join(File.cwd!(), \"deps/mob\")"
# Default to the running Elixir's actual lib dir — `:code.lib_dir(:elixir)`
# returns ".../lib/elixir", so `Path.dirname/1` yields the parent that
# holds elixir/, logger/, eex/, etc. that build.sh's stdlib copy needs.
# A hardcoded version path here drifts the moment the user's mise
# install moves; a build.sh that copies from an old Elixir into a tarball
# whose OTP is much newer breaks Phoenix's Regex layer
# (Elixir.Regex.safe_run/3 function_clause on the on-device re_pattern).
mob_exs_elixir_lib =
"System.get_env(\"MOB_ELIXIR_LIB\", :code.lib_dir(:elixir) |> to_string() |> Path.dirname())"
{mob_dep, mob_dev_dep, mob_exs_mob_dir, mob_exs_elixir_lib}
end
end
defp resolve_local_path(env_var, sibling_name) do
cond do
path = System.get_env(env_var) ->
Path.expand(path)
File.dir?(sibling = Path.expand("./#{sibling_name}")) ->
sibling
File.dir?(sibling = Path.expand("../#{sibling_name}")) ->
sibling
true ->
Mix.raise("""
Could not find local #{sibling_name} directory.
Set #{env_var} env var or ensure #{sibling_name} exists alongside your project:
export #{env_var}=/path/to/#{sibling_name}
""")
end
end
# ── private ──────────────────────────────────────────────────────────────────
# iOS sim build.sh.eex eliminated in Phase 2 iter 13b. The
# generate/4 path (used by `mix mob.new`) keeps a local
# `executable_templates` list because that one occasionally needs
# a chmod after rendering — see line ~508. THIS path
# (`generate/3`, the non-LiveView default + library callers) has
# no executable templates today. Re-add a chmod hook here only
# when a template needs it; the `@executable_files []` module
# attribute that used to live here was triggering Elixir 1.20's
# type checker on a known-always-false `in` check.
defp render_templates(assigns, project_dir, opts) do
no_ios = Keyword.get(opts, :no_ios, false)
no_android = Keyword.get(opts, :no_android, false)
t_root = templates_root(opts)
log_local_priv_once(opts, t_root)
t_root
|> find_templates()
|> Enum.filter(&platform_included?(&1, t_root, no_ios, no_android))
|> Enum.each(fn template_path ->
rel = Path.relative_to(template_path, t_root)
dest_rel = expand_path(rel, assigns)
dest = Path.join(project_dir, dest_rel)
File.mkdir_p!(Path.dirname(dest))
content = EEx.eval_file(template_path, Map.to_list(assigns))
File.write!(dest, content)
end)
end
defp find_templates(dir) do
Path.wildcard(Path.join(dir, "**/*.eex"), match_dot: true)
end
@executable_static ["android/gradlew"]
defp copy_static(project_dir, opts) do
no_ios = Keyword.get(opts, :no_ios, false)
no_android = Keyword.get(opts, :no_android, false)
s_root = static_root(opts)
s_root
|> Path.join("**/*")
|> Path.wildcard(match_dot: true)
|> Enum.reject(&File.dir?/1)
|> Enum.filter(&platform_included?(&1, s_root, no_ios, no_android))
|> Enum.each(fn src ->
rel = Path.relative_to(src, s_root)
dest = Path.join(project_dir, rel)
File.mkdir_p!(Path.dirname(dest))
File.copy!(src, dest)
if rel in @executable_static, do: File.chmod!(dest, 0o755)
end)
end
# Replace `app_name` placeholder in directory segments and strip .eex extension.
defp expand_path(rel, assigns) do
rel
# Dotfile templates can't ship in the archive (mix archive.build's
# wildcard drops dotfiles), so they live under non-dot names and are
# renamed here — the escape hatch the @dotfiles comment documents.
|> String.replace("dot_credo.exs", ".credo.exs")
|> String.replace("app_name", assigns.app_name)
|> String.replace("java/", "java/#{assigns.java_path}/")
|> strip_eex()
end
defp strip_eex(path) do
if String.ends_with?(path, ".eex"),
do: String.slice(path, 0..-5//1),
else: path
end
end