defmodule DalaDev.Enable do @moduledoc """ Pure helpers for `mix dala.enable` — extracted for testability. ## LiveView bridge architecture Enabling LiveView mode involves three coordinated patches. Understanding why all three are necessary prevents subtle bugs when setting up projects manually. ### The two bridges The native WebView (iOS WKWebView / Android WebView) injects a `window.dala` JavaScript object into every page it loads. This object routes calls through the NIF bridge: window.dala.send(data) // JS → NIF → Elixir handle_info window.dala.onMessage(fn) // registers handler for NIF → JS messages window.dala._dispatch(json) // called by the NIF to deliver messages to JS In LiveView mode you want a different routing: JS messages should travel over the LiveView WebSocket so that `handle_event/3` in your LiveView receives them and `push_event/3` delivers server messages to JS. The DalaHook replaces `window.dala` with a LiveView-backed version on mount: window.dala.send(data) // JS → pushEvent("dala_message") → handle_event/3 window.dala.onMessage(fn) // registers handler for handleEvent("dala_push") window.dala._dispatch // no-op: server messages arrive via handleEvent ### Why a DOM element is required (the non-obvious part) Phoenix LiveView hooks only execute their `mounted()` callback when an element carrying `phx-hook="DalaHook"` is present in the rendered HTML *and* the LiveView WebSocket has connected. Registering DalaHook in the `hooks:` map in `app.js` is necessary but not sufficient — the hook is dormant until LiveView finds a matching DOM element. Without the element: - DalaHook never mounts - `window.dala` is never replaced with the LiveView version - `window.dala.send()` routes through the native NIF bridge instead of LiveView - `handle_event/3` never fires; your LiveView cannot receive JS messages The element is a hidden `
` placed immediately after the opening `` tag in `root.html.heex`: Placing it at the top of `` ensures the hook mounts as early as possible, so `window.dala` is overridden before any page-specific JS runs. ### Android timing note iOS injects the native `window.dala` shim via `WKUserScript` at `.atDocumentStart` — before any page JS runs. Android injects it via `evaluateJavascript` in `onPageFinished` — after the page has loaded. Between page load and `onPageFinished` on Android, `window.dala` is undefined. In practice LiveView connects after `onPageFinished`, so both shims are available by the time the DalaHook mounts. If you call `window.dala` during `DOMContentLoaded`, guard with `if (window.dala)`. """ @dala_hook_js ~S""" // DalaHook — Dala LiveView bridge. Added by `mix dala.enable liveview`. // // WHY THIS EXISTS: The native WebView injects window.dala pointing at the NIF // bridge (postMessage on iOS, JavascriptInterface on Android). In LiveView // mode we want window.dala to route through the LiveView WebSocket instead so // handle_event/3 in your LiveView receives JS messages and push_event/3 // delivers server messages back to JS. // // This hook replaces window.dala on mount. It requires a DOM element with // phx-hook="DalaHook" — see root.html.heex. Without that element this hook // never runs and messages silently use the native bridge instead. const DalaHook = { mounted() { window.dala = { // JS → LiveView: arrives as handle_event("dala_message", data, socket) send: (data) => this.pushEvent("dala_message", data), // LiveView → JS: push_event(socket, "dala_push", data) calls all handlers onMessage: (handler) => this.handleEvent("dala_push", handler), // No-op in LiveView mode. The native bridge calls this to deliver // webview_post_message results, but in LiveView mode server messages // arrive via handleEvent("dala_push") instead. _dispatch: () => {} } } } """ # The hidden bridge element injected into root.html.heex. # id="dala-bridge" is used as the idempotency sentinel — do not change it. @dala_bridge_element ~s() @doc """ Returns the DalaHook JS constant to inject into app.js. """ @spec dala_hook_js() :: String.t() def dala_hook_js, do: @dala_hook_js @doc """ Returns the hidden bridge `
` element that must appear in `root.html.heex`. See the module doc for why this element is required. """ @spec dala_bridge_element() :: String.t() def dala_bridge_element, do: @dala_bridge_element @doc """ Injects the DalaHook definition and registration into `content` (the full text of `assets/js/app.js`). - Inserts the hook constant after the last top-level `import` line. - Registers `DalaHook` in the `hooks:` option passed to `LiveSocket`. Returns the patched JS string. Idempotency (skip if already present) is handled by the calling task, not by this function. """ @spec inject_dala_hook(String.t()) :: String.t() def inject_dala_hook(content) do content |> insert_hook_definition() |> register_hook_in_live_socket() end @doc """ Injects the hidden bridge `
` into `content` (a `root.html.heex` file). The element is placed immediately after the opening `` tag. This is the mount point for DalaHook — without it the hook never executes and `window.dala` is never replaced with the LiveView version. See the module doc for the full explanation. Returns the patched HTML string unchanged if `id="dala-bridge"` is already present. """ @spec inject_dala_bridge_element(String.t()) :: String.t() def inject_dala_bridge_element(content) do if String.contains?(content, "dala-bridge") do content else Regex.replace( Regex.compile!("]*)>"), content, "\n #{@dala_bridge_element}", global: false ) end end @doc """ Finds `root.html.heex` in a Phoenix project rooted at `project_dir`. Checks both the Phoenix 1.7+ convention: lib/_web/components/layouts/root.html.heex and the pre-1.7 convention: lib/_web/templates/layout/root.html.heex Returns the path string or `nil` if neither file exists. """ @spec find_root_html(String.t(), String.t()) :: String.t() | nil def find_root_html(project_dir, app_name) do web = app_name <> "_web" candidates = [ Path.join([project_dir, "lib", web, "components", "layouts", "root.html.heex"]), Path.join([project_dir, "lib", web, "templates", "layout", "root.html.heex"]) ] Enum.find(candidates, &File.exists?/1) end @doc """ Reads the `app:` atom from the given `mix.exs` path and returns the app name as a string, or raises. """ @spec read_app_name_from(String.t()) :: String.t() def read_app_name_from(mix_exs_path) do case File.read(mix_exs_path) do {:ok, content} -> case Regex.run(Regex.compile!("app:\\s+:([a-z0-9_]+)"), content) do [_, name] -> name _ -> raise "Could not read app name from #{mix_exs_path}" end _ -> raise "Could not read #{mix_exs_path}" end end @doc """ Builds a plist `/` entry for Info.plist injection. Options: - `type: :bool` — emits `` or `` instead of `` """ @spec build_plist_entry(String.t(), term(), keyword()) :: String.t() def build_plist_entry(key, value, opts \\ []) do if opts[:type] == :bool do "\t#{key}\n\t<#{value}/>" else "\t#{key}\n\t#{value}" end end @network_security_config_xml """ 127.0.0.1 localhost """ @doc "Returns the XML content for the Android network security config." @spec network_security_config_xml() :: String.t() def network_security_config_xml, do: @network_security_config_xml @doc """ Adds `android:networkSecurityConfig="@xml/network_security_config"` to the `` tag in an AndroidManifest.xml string. Idempotent — returns the content unchanged if the attribute is already present. """ @spec inject_android_network_security_config(String.t()) :: String.t() def inject_android_network_security_config(manifest_content) do if String.contains?(manifest_content, "networkSecurityConfig") do manifest_content else String.replace( manifest_content, Regex.compile!("( Enum.with_index() |> Enum.filter(fn {line, _} -> String.starts_with?(String.trim(line), "import ") end) |> Enum.map(fn {_, idx} -> idx end) |> List.last() insert_at = (last_import_idx || -1) + 1 hook_lines = String.split(@dala_hook_js, "\n") (Enum.take(lines, insert_at) ++ [""] ++ hook_lines ++ Enum.drop(lines, insert_at)) |> Enum.join("\n") end defp register_hook_in_live_socket(content) do cond do String.contains?(content, "hooks: {}") -> String.replace(content, "hooks: {}", "hooks: {DalaHook}") Regex.match?(Regex.compile!("hooks:\\s*\\{"), content) -> Regex.replace(Regex.compile!("(hooks:\\s*\\{)"), content, "\\1DalaHook, ", global: false) true -> Regex.replace( Regex.compile!("(new LiveSocket\\([^)]+)\\)"), content, fn full, prefix -> if String.contains?(full, "{") do String.replace(full, "}", ", hooks: {DalaHook}}", global: false) else "#{prefix}, {hooks: {DalaHook}})" end end, global: false ) end end end