Current section

Files

Jump to
mob_dev lib mix tasks mob.enable.ex
Raw

lib/mix/tasks/mob.enable.ex

defmodule Mix.Tasks.Mob.Enable do
use Mix.Task
@shortdoc "Enable optional Mob features in this project"
@moduledoc """
Enables one or more optional Mob features by patching `mix.exs`, manifest
files, and generating any required source files.
## Usage
mix mob.enable FEATURE [FEATURE ...]
Multiple features can be enabled in a single command:
mix mob.enable camera photo_library
mix mob.enable camera photo_library file_sharing liveview
## Features
### `liveview`
Enables LiveView mode — the Mob app runs a local Phoenix endpoint and displays
it in a native WebView. Web developers can ship a mobile app with zero native
UI code.
What it does:
- Generates `lib/<app>/mob_screen.ex` — a `Mob.Screen` that opens a WebView
at `http://127.0.0.1:PORT/`
- Injects the `MobHook` LiveView hook into `assets/js/app.js`
- Injects a hidden `<div id="mob-bridge" phx-hook="MobHook">` into
`root.html.heex` — **this is required for the hook to mount**
- Updates `mob.exs` with `liveview_port` so `Mob.LiveView.local_url/1` works
### Why the hidden div is required
Phoenix LiveView hooks only execute when a DOM element carrying
`phx-hook="MobHook"` exists in the rendered page. Registering `MobHook` in
`app.js` is necessary but not sufficient — without a matching DOM element the
hook never mounts and `window.mob` is never replaced with the LiveView-backed
version. Messages would silently route through the native NIF bridge instead
of the LiveView WebSocket, so `handle_event/3` would never fire.
See `MobDev.Enable` module doc and `guides/liveview.md` for the full
two-bridge architecture explanation.
After running:
1. Add `MyApp.MobScreen` to your supervision tree (or call
`Mob.Screen.start_root(MyApp.MobScreen)` from your `Mob.App.on_start/0`)
2. Ensure Phoenix is running on the port set in `mob.exs` (default: 4000)
### `camera`
Adds camera permission declarations to platform manifests.
- iOS: adds `NSCameraUsageDescription` to `ios/*/Info.plist`
- Android: adds `<uses-permission android:name="android.permission.CAMERA"/>`
to `android/app/src/main/AndroidManifest.xml`
### `photo_library`
- iOS: adds `NSPhotoLibraryAddUsageDescription` to Info.plist
- Android: no manifest change needed (API 29+)
### `file_sharing`
- iOS: adds `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace`
to Info.plist
- Android: adds `<provider android:name="FileProvider">` with paths config
### `location`
- iOS: adds `NSLocationWhenInUseUsageDescription` to Info.plist
- Android: adds `ACCESS_FINE_LOCATION` permission
### `notifications`
- iOS: creates `ios/<app>.entitlements` with `aps-environment: development`.
After running, execute `mix mob.provision` so Xcode downloads a push-capable
provisioning profile. Then call `Mob.Permissions.request(socket, :notifications)`
and `Mob.Notify.register_push(socket)` at runtime to obtain a device token.
- Android: runtime only — `POST_NOTIFICATIONS` is requested at runtime, no
manifest key needed.
### `python`
Enables embedded CPython via [Pythonx](https://hex.pm/packages/pythonx)
on iOS **and** Android.
- **iOS:** BeeWare's [`Python-Apple-support`](https://github.com/beeware/Python-Apple-support)
`Python.xcframework` is bundled by `mix mob.deploy --native`.
- **Android:** [Chaquopy](https://chaquo.com/chaquopy/)'s prebuilt CPython
is unpacked at first launch. `libpythonx.so` (the Pythonx NIF) is
cross-compiled with the Android NDK against a stub `libpython3.13.so`
so the BEAM dynamic loader is satisfied; the real lib resolves at
runtime via SONAME match.
- **Bare CPython only.** Bundles ship the interpreter, stdlib, and
standard C extensions (`_ssl`, `_ctypes`, `_hashlib`, …). Third-party
wheels (`cryptography`, `numpy`, `RNS`, …) are out of scope —
produce your own (BeeWare's [`mobile-forge`](https://github.com/beeware/mobile-forge)
on iOS, Chaquopy's wheel pipeline on Android) and drop them into
your project.
What it does:
- Adds `{:pythonx, "~> 0.4"}` to `mix.exs` deps.
- Generates `lib/<app>/python_paths.ex` — pure detection module that
locates the bundled framework at runtime (`:desktop` /
`{:ios, paths}` / `{:android, paths}` / `{:partial, missing}`).
- **No `:uv_init` config patch.** Pythonx ships an Application
that auto-runs uv at boot if `:uv_init` is in compile-time config,
and uv doesn't exist on device. Instead the on_start template
inlines `pyproject_toml` and calls `Pythonx.Uv.fetch/2 +
Pythonx.Uv.init/2` only on the `:desktop` branch. Same code path
`iex -S mix` would use, just opt-in.
Bundle size impact: ~70 MB on iOS, ~30 MB on Android (interpreter +
stdlib + arch-specific C extensions). Apply this only when you actually
want to call Python from BEAM — non-Python apps stay vanilla.
After running, your `Mob.App.on_start/0` should:
- call `Application.ensure_all_started(:pythonx)` (starts the
`Pythonx.Janitor`, required for `Pythonx.eval/3`);
- case-match `<App>.PythonPaths.detect/1` and call `Pythonx.Uv.fetch
+ init` on `:desktop` (provisioning a uv-managed CPython on first
run) or `Pythonx.init/4` on `{:ios, _}` / `{:android, _}`.
See `guides/python_embedding.md` for the full template.
"""
@valid_features ~w(liveview camera photo_library file_sharing location notifications python)
@impl Mix.Task
def run([]) do
Mix.shell().error("Usage: mix mob.enable FEATURE [FEATURE ...]")
Mix.shell().info("Valid features: #{Enum.join(@valid_features, ", ")}")
Mix.raise("No features specified")
end
def run(argv) do
{_opts, features, _} = OptionParser.parse(argv, strict: [])
project_dir = File.cwd!()
unless File.exists?(Path.join(project_dir, "mix.exs")) do
Mix.raise("No mix.exs found. Run mix mob.enable from your project root.")
end
unknown = features -- @valid_features
if unknown != [] do
Mix.raise(
"Unknown feature(s): #{Enum.join(unknown, ", ")}. " <>
"Valid: #{Enum.join(@valid_features, ", ")}"
)
end
app_name = read_app_name(project_dir)
Enum.each(features, fn feature ->
Mix.shell().info([:cyan, "\nEnabling #{feature}...", :reset])
enable(feature, project_dir, app_name)
end)
Mix.shell().info([:green, "\nDone.", :reset])
end
# ── Feature handlers ──────────────────────────────────────────────────────
defp enable("liveview", project_dir, app_name) do
generate_mob_screen(project_dir, app_name)
inject_mob_hook(project_dir)
inject_mob_bridge_element(project_dir, app_name)
update_mob_exs(project_dir)
android_add_liveview_network_config(project_dir)
end
defp enable("camera", project_dir, _app_name) do
ios_add_plist_key(project_dir, "NSCameraUsageDescription", "This app uses the camera.")
android_add_permission(project_dir, "android.permission.CAMERA")
end
defp enable("photo_library", project_dir, _app_name) do
ios_add_plist_key(
project_dir,
"NSPhotoLibraryAddUsageDescription",
"This app saves photos to your library."
)
android_noop("photo_library", "no manifest change needed on API 29+")
end
defp enable("file_sharing", project_dir, _app_name) do
ios_add_plist_key(project_dir, "UIFileSharingEnabled", "true", type: :bool)
ios_add_plist_key(project_dir, "LSSupportsOpeningDocumentsInPlace", "true", type: :bool)
android_add_file_provider(project_dir)
end
defp enable("location", project_dir, _app_name) do
ios_add_plist_key(
project_dir,
"NSLocationWhenInUseUsageDescription",
"This app uses your location."
)
android_add_permission(project_dir, "android.permission.ACCESS_FINE_LOCATION")
end
defp enable("notifications", project_dir, app_name) do
android_noop(
"notifications",
"POST_NOTIFICATIONS is requested at runtime, no manifest key needed"
)
ios_create_push_entitlements(project_dir, app_name)
end
defp enable("python", project_dir, app_name) do
pythonx_inject_dep(project_dir)
pythonx_generate_paths_module(project_dir, app_name)
pythonx_check_native_templates(project_dir, app_name)
module = Macro.camelize(app_name)
Mix.shell().info("""
Next steps:
1. Run `mix deps.get` to fetch :pythonx
2. In your `Mob.App.on_start/0`, call:
{:ok, _} = Application.ensure_all_started(:pythonx)
case #{module}.PythonPaths.detect(to_string(:code.root_dir())) do
:desktop ->
toml = \"\"\"
[project]
name = "#{app_name}"
version = "0.1.0"
requires-python = "==3.13.*"
dependencies = []
\"\"\"
Pythonx.Uv.fetch(toml, false)
Pythonx.Uv.init(toml, false)
{:ios, %{dl_path: dl, home_path: home}} ->
Pythonx.init(dl, home, dl, sys_paths: [])
{:android, %{dl_path: dl, home_path: home}} ->
Pythonx.init(dl, home, dl,
sys_paths: [Path.join([home, "lib", "python3.13"])])
{:partial, missing} ->
# log + bail out — bundle is incomplete
nil
end
pyproject_toml is inlined so it's NOT in compile-time config —
Pythonx.Application would otherwise auto-run uv at app boot,
which fails on device.
3. `mix mob.deploy --native --device <udid>` to bundle CPython +
install on device. iOS downloads the BeeWare framework on first
run; Android pulls Chaquopy's prebuilt distribution.
""")
end
# ── LiveView: generate MobScreen ─────────────────────────────────────────
defp generate_mob_screen(project_dir, app_name) do
module_name = Macro.camelize(app_name)
dir = Path.join([project_dir, "lib", app_name])
path = Path.join(dir, "mob_screen.ex")
if File.exists?(path) do
Mix.shell().info(" * skip #{path} (already exists)")
else
File.mkdir_p!(dir)
File.write!(path, mob_screen_template(module_name))
Mix.shell().info([:green, " * create ", :reset, path])
end
end
defp mob_screen_template(module_name) do
"""
defmodule #{module_name}.MobScreen do
@moduledoc \"\"\"
Mob.Screen that wraps the Phoenix LiveView app in a native WebView.
Add this to your supervision tree or call from Mob.App.on_start/0:
Mob.Screen.start_root(#{module_name}.MobScreen)
\"\"\"
use Mob.Screen
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(_assigns) do
Mob.UI.webview(
url: Mob.LiveView.local_url("/"),
show_url: false
)
end
end
"""
end
# ── LiveView: inject MobHook into assets/js/app.js ───────────────────────
defp inject_mob_hook(project_dir) do
path = Path.join([project_dir, "assets", "js", "app.js"])
unless File.exists?(path) do
Mix.shell().info(" * skip MobHook injection (#{path} not found)")
Mix.shell().info(" Add the hook manually — see `Mob.LiveView` docs.")
return(nil)
end
content = File.read!(path)
if String.contains?(content, "MobHook") do
Mix.shell().info(" * skip #{path} (MobHook already present)")
else
patched = MobDev.Enable.inject_mob_hook(content)
File.write!(path, patched)
Mix.shell().info([:green, " * patch ", :reset, path, " (added MobHook)"])
end
end
# ── LiveView: inject bridge element into root.html.heex ──────────────────
defp inject_mob_bridge_element(project_dir, app_name) do
path = MobDev.Enable.find_root_html(project_dir, app_name)
if path do
content = File.read!(path)
if String.contains?(content, "mob-bridge") do
Mix.shell().info(" * skip #{path} (mob-bridge already present)")
else
patched = MobDev.Enable.inject_mob_bridge_element(content)
File.write!(path, patched)
Mix.shell().info([:green, " * patch ", :reset, path, " (added mob-bridge element)"])
end
else
Mix.shell().info([:yellow, " * skip root.html.heex (not found)", :reset])
Mix.shell().info(" Add the following manually inside <body> in your root layout:")
Mix.shell().info(" " <> MobDev.Enable.mob_bridge_element())
Mix.shell().info(" Without this element MobHook never mounts and window.mob")
Mix.shell().info(" will not route through LiveView. See guides/liveview.md.")
end
end
# ── LiveView: update mob.exs ──────────────────────────────────────────────
defp update_mob_exs(project_dir) do
path = Path.join(project_dir, "mob.exs")
liveview_line = "config :mob, liveview_port: 4000"
if File.exists?(path) do
content = File.read!(path)
if String.contains?(content, "liveview_port") do
Mix.shell().info(" * skip #{path} (liveview_port already set)")
else
File.write!(path, content <> "\n#{liveview_line}\n")
Mix.shell().info([:green, " * patch ", :reset, path, " (added liveview_port)"])
end
else
File.write!(path, """
import Config
#{liveview_line}
""")
Mix.shell().info([:green, " * create ", :reset, path])
end
end
# ── iOS plist helpers ─────────────────────────────────────────────────────
defp ios_add_plist_key(project_dir, key, value, opts \\ []) do
plist = find_ios_plist(project_dir)
if plist do
content = File.read!(plist)
if String.contains?(content, key) do
Mix.shell().info(" * skip #{plist} (#{key} already present)")
else
entry = build_plist_entry(key, value, opts)
patched = String.replace(content, "</dict>\n</plist>", "#{entry}\n</dict>\n</plist>")
File.write!(plist, patched)
Mix.shell().info([:green, " * patch ", :reset, plist, " (added #{key})"])
end
else
Mix.shell().info(" * skip iOS (no Info.plist found under ios/)")
end
end
defp build_plist_entry(key, value, opts) do
MobDev.Enable.build_plist_entry(key, value, opts)
end
defp find_ios_plist(project_dir) do
Path.wildcard(Path.join(project_dir, "ios/**/*.xcodeproj/../*.plist"))
|> Enum.find(&String.ends_with?(&1, "Info.plist"))
|> then(fn
nil ->
Path.wildcard(Path.join(project_dir, "ios/**/Info.plist"))
|> List.first()
path ->
path
end)
end
defp ios_create_push_entitlements(project_dir, app_name) do
ios_dir = Path.join(project_dir, "ios")
path = Path.join(ios_dir, "#{app_name}.entitlements")
if File.exists?(path) do
Mix.shell().info(" * skip #{path} (already exists)")
else
File.mkdir_p!(ios_dir)
File.write!(path, push_entitlements_plist())
Mix.shell().info([:green, " * create ", :reset, path])
Mix.shell().info(
" Run `mix mob.provision` to download a push-capable provisioning profile."
)
end
end
defp push_entitlements_plist do
"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
"""
end
# ── Android manifest helpers ──────────────────────────────────────────────
defp android_add_liveview_network_config(project_dir) do
manifest = find_android_manifest(project_dir)
if manifest do
content = File.read!(manifest)
if String.contains?(content, "networkSecurityConfig") do
Mix.shell().info(" * skip #{manifest} (networkSecurityConfig already present)")
else
patched = MobDev.Enable.inject_android_network_security_config(content)
File.write!(manifest, patched)
Mix.shell().info([
:green,
" * patch ",
:reset,
manifest,
" (added networkSecurityConfig for cleartext HTTP to 127.0.0.1)"
])
end
write_android_network_security_config(project_dir)
else
Mix.shell().info(" * skip Android (AndroidManifest.xml not found)")
end
end
defp write_android_network_security_config(project_dir) do
xml_dir = Path.join([project_dir, "android", "app", "src", "main", "res", "xml"])
path = Path.join(xml_dir, "network_security_config.xml")
if File.exists?(path) do
Mix.shell().info(" * skip #{path} (already exists)")
else
File.mkdir_p!(xml_dir)
File.write!(path, MobDev.Enable.network_security_config_xml())
Mix.shell().info([:green, " * create ", :reset, path])
end
end
defp android_add_permission(project_dir, permission) do
manifest = find_android_manifest(project_dir)
if manifest do
content = File.read!(manifest)
tag = ~s(<uses-permission android:name="#{permission}"/>)
if String.contains?(content, permission) do
Mix.shell().info(" * skip #{manifest} (#{permission} already present)")
else
patched =
String.replace(content, "<application", "#{tag}\n <application", global: false)
File.write!(manifest, patched)
Mix.shell().info([:green, " * patch ", :reset, manifest, " (added #{permission})"])
end
else
Mix.shell().info(" * skip Android (AndroidManifest.xml not found)")
end
end
defp android_add_file_provider(project_dir) do
manifest = find_android_manifest(project_dir)
if manifest do
content = File.read!(manifest)
if String.contains?(content, "FileProvider") do
Mix.shell().info(" * skip #{manifest} (FileProvider already present)")
else
provider_xml =
" <provider\n" <>
" android:name=\"androidx.core.content.FileProvider\"\n" <>
" android:authorities=\"${applicationId}.fileprovider\"\n" <>
" android:exported=\"false\"\n" <>
" android:grantUriPermissions=\"true\">\n" <>
" <meta-data\n" <>
" android:name=\"android.support.FILE_PROVIDER_PATHS\"\n" <>
" android:resource=\"@xml/file_provider_paths\"/>\n" <>
" </provider>"
patched =
String.replace(content, "</application>", "#{provider_xml}\n </application>",
global: false
)
File.write!(manifest, patched)
Mix.shell().info([:green, " * patch ", :reset, manifest, " (added FileProvider)"])
write_file_provider_paths(project_dir)
end
else
Mix.shell().info(" * skip Android (AndroidManifest.xml not found)")
end
end
defp write_file_provider_paths(project_dir) do
xml_dir = Path.join([project_dir, "android", "app", "src", "main", "res", "xml"])
path = Path.join(xml_dir, "file_provider_paths.xml")
if File.exists?(path) do
Mix.shell().info(" * skip #{path} (already exists)")
else
File.mkdir_p!(xml_dir)
File.write!(path, """
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="mob_files" path="." />
<cache-path name="mob_cache" path="." />
<external-files-path name="mob_external" path="." />
</paths>
""")
Mix.shell().info([:green, " * create ", :reset, path])
end
end
defp find_android_manifest(project_dir) do
Path.wildcard(Path.join(project_dir, "android/**/AndroidManifest.xml"))
|> Enum.reject(&String.contains?(&1, "androidTest"))
|> List.first()
end
defp android_noop(feature, reason) do
Mix.shell().info(" * Android #{feature}: #{reason}")
end
# ── Python feature: helpers ───────────────────────────────────────────────
defp pythonx_inject_dep(project_dir) do
path = Path.join(project_dir, "mix.exs")
content = File.read!(path)
patched = MobDev.Enable.inject_pythonx_dep(content)
cond do
patched == content and String.contains?(content, ":pythonx") ->
Mix.shell().info(" * skip mix.exs (:pythonx already in deps)")
patched == content ->
Mix.shell().info(
" * skip mix.exs (no recognizable `defp deps do [` block — add {:pythonx, \"~> 0.4\"} manually)"
)
true ->
File.write!(path, patched)
Mix.shell().info([:green, " * patch ", :reset, path, " (added :pythonx dep)"])
end
end
defp pythonx_generate_paths_module(project_dir, app_name) do
module_name = Macro.camelize(app_name)
dir = Path.join([project_dir, "lib", app_name])
path = Path.join(dir, "python_paths.ex")
if File.exists?(path) do
Mix.shell().info(" * skip #{path} (already exists)")
else
File.mkdir_p!(dir)
File.write!(path, MobDev.Enable.python_paths_module_template(module_name))
Mix.shell().info([:green, " * create ", :reset, path])
end
end
# Existing projects generated before the Pythonx work was upstreamed
# don't have the build-time hooks the deploy expects. We can't safely
# auto-patch existing build.sh / CMakeLists / MainActivity (they're
# often hand-customized), so detect and surface a clear next step.
defp pythonx_check_native_templates(project_dir, app_name) do
findings = MobDev.Enable.detect_stale_pythonx_templates(project_dir, app_name)
case findings do
[] ->
Mix.shell().info(" * native templates look up to date")
stale ->
Mix.shell().info([
:yellow,
"\n ! Native build templates are stale — Pythonx requires extra ",
"build steps that aren't present in your project:",
:reset
])
Enum.each(stale, fn {file, marker} ->
Mix.shell().info(" - #{file} (missing: #{marker})")
end)
Mix.shell().info("""
Either generate a fresh project with `mix mob.new` and copy your
app code over, or copy the missing blocks from
~/.mix/archives/mob_new-*/priv/templates/mob.new/. The reference
markers in each file (e.g. `=== Cross-compiling libpythonx.so`,
`extractPythonAssetsIfNeeded`, `enif_keepalive.c`) bracket the
regions that need to be added.
""")
end
end
# ── Helpers ───────────────────────────────────────────────────────────────
defp read_app_name(project_dir) do
MobDev.Enable.read_app_name_from(Path.join(project_dir, "mix.exs"))
rescue
e -> Mix.raise(Exception.message(e))
end
defp return(val), do: val
end