Packages
mob_dev
0.3.23
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/mob_dev/enable.ex
defmodule MobDev.Enable do
@moduledoc """
Pure helpers for `mix mob.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.mob`
JavaScript object into every page it loads. This object routes calls through
the NIF bridge:
window.mob.send(data) // JS → NIF → Elixir handle_info
window.mob.onMessage(fn) // registers handler for NIF → JS messages
window.mob._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 MobHook replaces
`window.mob` with a LiveView-backed version on mount:
window.mob.send(data) // JS → pushEvent("mob_message") → handle_event/3
window.mob.onMessage(fn) // registers handler for handleEvent("mob_push")
window.mob._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="MobHook"` is present in the rendered HTML *and* the
LiveView WebSocket has connected. Registering MobHook 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:
- MobHook never mounts
- `window.mob` is never replaced with the LiveView version
- `window.mob.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 `<div>` placed immediately after the opening `<body>`
tag in `root.html.heex`:
<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>
Placing it at the top of `<body>` ensures the hook mounts as early as possible,
so `window.mob` is overridden before any page-specific JS runs.
### Android timing note
iOS injects the native `window.mob` 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.mob` is undefined. In
practice LiveView connects after `onPageFinished`, so both shims are available
by the time the MobHook mounts. If you call `window.mob` during
`DOMContentLoaded`, guard with `if (window.mob)`.
"""
@mob_hook_js ~S"""
// MobHook — Mob LiveView bridge. Added by `mix mob.enable liveview`.
//
// WHY THIS EXISTS: The native WebView injects window.mob pointing at the NIF
// bridge (postMessage on iOS, JavascriptInterface on Android). In LiveView
// mode we want window.mob 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.mob on mount. It requires a DOM element with
// phx-hook="MobHook" — see root.html.heex. Without that element this hook
// never runs and messages silently use the native bridge instead.
const MobHook = {
mounted() {
window.mob = {
// JS → LiveView: arrives as handle_event("mob_message", data, socket)
send: (data) => this.pushEvent("mob_message", data),
// LiveView → JS: push_event(socket, "mob_push", data) calls all handlers
onMessage: (handler) => this.handleEvent("mob_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("mob_push") instead.
_dispatch: () => {}
}
}
}
"""
# The hidden bridge element injected into root.html.heex.
# id="mob-bridge" is used as the idempotency sentinel — do not change it.
@mob_bridge_element ~s(<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>)
@doc """
Returns the MobHook JS constant to inject into app.js.
"""
@spec mob_hook_js() :: String.t()
def mob_hook_js, do: @mob_hook_js
@doc """
Returns the hidden bridge `<div>` element that must appear in `root.html.heex`.
See the module doc for why this element is required.
"""
@spec mob_bridge_element() :: String.t()
def mob_bridge_element, do: @mob_bridge_element
@doc """
Injects the MobHook 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 `MobHook` 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_mob_hook(String.t()) :: String.t()
def inject_mob_hook(content) do
content
|> insert_hook_definition()
|> register_hook_in_live_socket()
end
@doc """
Injects the hidden bridge `<div>` into `content` (a `root.html.heex` file).
The element is placed immediately after the opening `<body>` tag. This is
the mount point for MobHook — without it the hook never executes and
`window.mob` is never replaced with the LiveView version. See the module doc
for the full explanation.
Returns the patched HTML string unchanged if `id="mob-bridge"` is already
present.
"""
@spec inject_mob_bridge_element(String.t()) :: String.t()
def inject_mob_bridge_element(content) do
if String.contains?(content, "mob-bridge") do
content
else
Regex.replace(
Regex.compile!("<body([^>]*)>"),
content,
"<body\\1>\n #{@mob_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/<app_name>_web/components/layouts/root.html.heex
and the pre-1.7 convention:
lib/<app_name>_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 `<key>/<value>` entry for Info.plist injection.
Options:
- `type: :bool` — emits `<true/>` or `<false/>` instead of `<string>`
"""
@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>#{key}</key>\n\t<#{value}/>"
else
"\t<key>#{key}</key>\n\t<string>#{value}</string>"
end
end
@network_security_config_xml """
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
"""
@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
`<application>` 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!("(<application\\b)"),
"\\1\n android:networkSecurityConfig=\"@xml/network_security_config\"",
global: false
)
end
end
# ── Private ───────────────────────────────────────────────────────────────
defp insert_hook_definition(content) do
lines = String.split(content, "\n")
last_import_idx =
lines
|> 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(@mob_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: {MobHook}")
Regex.match?(Regex.compile!("hooks:\\s*\\{"), content) ->
Regex.replace(Regex.compile!("(hooks:\\s*\\{)"), content, "\\1MobHook, ", global: false)
true ->
Regex.replace(
Regex.compile!("(new LiveSocket\\([^)]+)\\)"),
content,
fn full, prefix ->
if String.contains?(full, "{") do
String.replace(full, "}", ", hooks: {MobHook}}", global: false)
else
"#{prefix}, {hooks: {MobHook}})"
end
end,
global: false
)
end
end
end