Packages
mob_dev
0.4.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
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.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
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