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: 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 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_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() 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 # ── 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