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//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 `
` 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 `` 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 `` with paths config ### `location` - iOS: adds `NSLocationWhenInUseUsageDescription` to Info.plist - Android: adds `ACCESS_FINE_LOCATION` permission ### `notifications` - iOS: creates `ios/.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//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 `.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 ` 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 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, "\n", "#{entry}\n\n") 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 """ aps-environment development """ 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() if String.contains?(content, permission) do Mix.shell().info(" * skip #{manifest} (#{permission} already present)") else patched = String.replace(content, " " android:name=\"androidx.core.content.FileProvider\"\n" <> " android:authorities=\"${applicationId}.fileprovider\"\n" <> " android:exported=\"false\"\n" <> " android:grantUriPermissions=\"true\">\n" <> " " android:name=\"android.support.FILE_PROVIDER_PATHS\"\n" <> " android:resource=\"@xml/file_provider_paths\"/>\n" <> " " patched = String.replace(content, "", "#{provider_xml}\n ", 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, """ """) 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