Packages
mob_dev
0.3.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: runtime only — no plist key needed
- Android: adds `POST_NOTIFICATIONS` permission (API 33+)
"""
@valid_features ~w(liveview camera photo_library file_sharing location notifications)
@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_noop(
"notifications",
"iOS notification permission is requested at runtime, no plist key needed"
)
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_noop(feature, reason) do
Mix.shell().info(" * iOS #{feature}: #{reason}")
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
# ── 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