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 JSIn 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 handleEventWhy 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.mobis never replaced with the LiveView versionwindow.mob.send()routes through the native NIF bridge instead of LiveViewhandle_event/3never 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).
Summary
Functions
Builds a plist <key>/<value> entry for Info.plist injection.
Returns the canonical pyproject.toml string for a freshly-enabled
Pythonx project. Used by the on_start template generator and by the
desktop Pythonx.Uv.fetch/init calls in user code.
Inspects the project's existing native build templates for the markers
mix mob.deploy --native expects when Pythonx is enabled. Returns a
list of {relative_path, missing_marker} tuples for every file that
exists but is missing the marker. An empty list means everything looks
fresh.
Finds root.html.heex in a Phoenix project rooted at project_dir.
Adds android:networkSecurityConfig="@xml/network_security_config" to the
<application> tag in an AndroidManifest.xml string.
Injects the hidden bridge <div> into content (a root.html.heex file).
Injects the MobHook definition and registration into content (the full
text of assets/js/app.js).
Patches mix.exs content to add {:pythonx, "~> 0.4"} to the
deps list when missing. Idempotent.
Returns the hidden bridge <div> element that must appear in root.html.heex.
Returns the MobHook JS constant to inject into app.js.
Returns the XML content for the Android network security config.
Returns the source for the <App>.PythonPaths module that
mix mob.enable pythonx writes to lib/<app>/python_paths.ex.
Reads the app: atom from the given mix.exs path and returns the app
name as a string, or raises.
Functions
Builds a plist <key>/<value> entry for Info.plist injection.
Options:
type: :bool— emits<true/>or<false/>instead of<string>
Returns the canonical pyproject.toml string for a freshly-enabled
Pythonx project. Used by the on_start template generator and by the
desktop Pythonx.Uv.fetch/init calls in user code.
Inspects the project's existing native build templates for the markers
mix mob.deploy --native expects when Pythonx is enabled. Returns a
list of {relative_path, missing_marker} tuples for every file that
exists but is missing the marker. An empty list means everything looks
fresh.
We deliberately do not auto-patch — these files are typically
hand-customized after mix mob.new, and silently inserting blocks is
riskier than asking the user to copy from the template.
Files that don't exist yet (e.g. a project that never generated an ios/build.sh) are skipped — this is "stale-template detection," not "missing-platform detection."
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.heexand the pre-1.7 convention:
lib/<app_name>_web/templates/layout/root.html.heexReturns the path string or nil if neither file exists.
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.
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.
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
importline. - Registers
MobHookin thehooks:option passed toLiveSocket.
Returns the patched JS string. Idempotency (skip if already present) is handled by the calling task, not by this function.
Patches mix.exs content to add {:pythonx, "~> 0.4"} to the
deps list when missing. Idempotent.
Returns the (possibly-modified) content. Returns the original content
unchanged when there's no recognizable defp deps do [ block — caller is
expected to fall back to a friendly "couldn't find deps block" message.
@spec mob_bridge_element() :: String.t()
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_hook_js() :: String.t()
Returns the MobHook JS constant to inject into app.js.
@spec network_security_config_xml() :: String.t()
Returns the XML content for the Android network security config.
Returns the source for the <App>.PythonPaths module that
mix mob.enable pythonx writes to lib/<app>/python_paths.ex.
Pure — no filesystem access. The generated module supports iOS
(paths under <otp_root>/python/) and Android (paths from
MOB_PYTHON_HOME / MOB_PYTHON_DL env vars set by the user's
MainActivity.kt before BEAM startup).
Reads the app: atom from the given mix.exs path and returns the app
name as a string, or raises.