Current section

Files

Jump to
mob_new lib mob_new project_generator.ex
Raw

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.
"""
defp templates_root, do: :mob_new |> :code.priv_dir() |> Path.join("templates/mob.new")
defp static_root, do: :mob_new |> :code.priv_dir() |> Path.join("static/mob.new")
# 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()
}
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)
{:ok, project_dir}
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-rc3
elixir 1.20.0-rc.4-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
mob.exs
android/local.properties
android/.gradle/
android/app/build/
"""
}
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)
executable_templates = ["ios/build.sh"]
executable_static = ["android/gradlew"]
templates_root()
|> find_templates()
|> Enum.filter(&platform_included?(&1, templates_root(), no_ios, no_android))
|> Enum.reject(&liveview_phoenix_owned?(&1, templates_root(), opts))
|> Enum.each(fn template_path ->
rel = Path.relative_to(template_path, templates_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.)
static_root()
|> Path.join("**/*")
|> Path.wildcard(match_dot: true)
|> Enum.reject(&File.dir?/1)
|> Enum.filter(&platform_included?(&1, static_root(), no_ios, no_android))
|> Enum.reject(&liveview_phoenix_owned?(&1, static_root(), opts))
|> Enum.each(fn src ->
rel = Path.relative_to(src, static_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
@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
rel == ".gitignore.eex" -> true
rel == ".tool-versions.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. Overwrite ios/build.sh with the LiveView-specific version
# (different deps copy strategy, crypto shim, ssl copy, priv/static deploy)
overwrite_liveview_build_sh(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"},
{"config/runtime.exs", Regex.compile!(~S{"PORT"\s*\)\s*\|\|\s*"4000"}),
"\"PORT\") || \"4200\"", "runtime PORT default 4000 → 4200"}
]
|> Enum.each(&apply_port_patch(project_dir, &1))
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 overwrite_liveview_build_sh(project_dir, app_name, module_name) do
path = Path.join([project_dir, "ios", "build.sh"])
File.mkdir_p!(Path.dirname(path))
File.write!(path, MobNew.LiveViewPatcher.liveview_build_sh_content(module_name, app_name))
File.chmod!(path, 0o755)
Mix.shell().info([:green, "* create ", :reset, path, " (LiveView build.sh)"])
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)
unless String.contains?(content, "mob.exs") do
File.write!(path, content <> "\n# Mob local config\nmob.exs\n")
Mix.shell().info([:green, "* patch ", :reset, path, " (added mob.exs)"])
end
end
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()
mob_dep = ~s({:mob, path: "#{mob_dir}"})
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.5"})
mob_dev_dep = ~s({:mob_dev, "~> 0.3", 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 ──────────────────────────────────────────────────────────────────
@executable_files ["ios/build.sh"]
defp render_templates(assigns, project_dir, opts) do
no_ios = Keyword.get(opts, :no_ios, false)
no_android = Keyword.get(opts, :no_android, false)
templates_root()
|> find_templates()
|> Enum.filter(&platform_included?(&1, templates_root(), no_ios, no_android))
|> Enum.each(fn template_path ->
rel = Path.relative_to(template_path, templates_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)
if dest_rel in @executable_files, do: File.chmod!(dest, 0o755)
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)
static_root()
|> Path.join("**/*")
|> Path.wildcard(match_dot: true)
|> Enum.reject(&File.dir?/1)
|> Enum.filter(&platform_included?(&1, static_root(), no_ios, no_android))
|> Enum.each(fn src ->
rel = Path.relative_to(src, static_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
|> 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