Packages
mob_new
0.1.17
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
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.0
0.1.45
0.1.44
0.1.43
0.1.42
0.1.40
0.1.33
0.1.30
0.1.29
0.1.28
0.1.27
0.1.26
0.1.25
0.1.24
0.1.23
0.1.21
0.1.20
0.1.19
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Project generator for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_new/live_view_patcher.ex
defmodule MobNew.LiveViewPatcher do
@moduledoc """
Pure helpers for applying the Mob LiveView bridge patches to a freshly
generated Phoenix project.
These are duplicated from `MobDev.Enable` (which lives in mob_dev, a separate
package) to keep mob_new self-contained as a Mix archive with no runtime deps
on mob_dev.
## What gets patched
1. `assets/js/app.js` — `MobHook` definition inserted after the last import,
and `MobHook` registered in the `LiveSocket` `hooks:` option.
2. `lib/<app>_web/components/layouts/root.html.heex` — hidden
`<div id="mob-bridge" phx-hook="MobHook">` inserted after `<body>`.
3. `lib/<app>/mob_screen.ex` — generated (the `Mob.Screen` that opens the WebView).
4. `mob.exs` — `liveview_port: 4000` added.
5. `mix.exs` — mob / mob_dev deps injected into the `deps/0` function.
6. `lib/<app>/application.ex` — `Mob.App` child started in the supervision tree.
"""
@mob_hook_js ~S"""
// MobHook — Mob LiveView bridge. Added by `mix mob.new --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: () => {}
}
}
}
"""
@mob_bridge_element ~s(<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>)
# ── Public API ────────────────────────────────────────────────────────────────
@doc "Returns the hidden bridge element string (for test assertions)."
def mob_bridge_element, do: @mob_bridge_element
@doc """
Injects the MobHook definition and registration into the given `app.js` content.
Idempotent: returns unchanged content if MobHook is already present.
"""
def inject_mob_hook(content) do
if String.contains?(content, "MobHook") do
content
else
content
|> insert_hook_definition()
|> register_hook_in_live_socket()
end
end
@doc """
Injects the hidden bridge `<div>` immediately after the opening `<body>` tag.
Idempotent: returns unchanged content if mob-bridge is already present.
"""
def inject_mob_bridge_element(content) do
if String.contains?(content, "mob-bridge") do
content
else
Regex.replace(
~r/<body([^>]*)>/,
content,
"<body\\1>\n #{@mob_bridge_element}",
global: false
)
end
end
@doc """
Injects mob / mob_dev dependencies into the `deps/0` function in `mix.exs` content.
`mob_dep` and `mob_dev_dep` are the dependency tuple strings (already formatted).
Idempotent: no-op if `:mob` is already present.
"""
def inject_deps(content, mob_dep, mob_dev_dep) do
if String.contains?(content, ":mob,") or String.contains?(content, ":mob ") do
content
else
# Insert before the closing bracket of the deps list
String.replace(
content,
~r/(defp deps do\s*\[)/,
"\\1\n #{mob_dep},\n #{mob_dev_dep},",
global: false
)
end
end
@doc """
Generates the MobScreen source file content for the given module name.
"""
def mob_screen_content(module_name) do
"""
defmodule #{module_name}.MobScreen do
@moduledoc \"\"\"
Mob.Screen that wraps the Phoenix LiveView app in a native WebView.
This screen is started automatically alongside the Phoenix endpoint.
You can push events from your LiveViews to JS with:
push_event(socket, "mob_push", %{type: "my_event", data: ...})
And receive JS events in your LiveViews with:
def handle_event("mob_message", data, socket) do
...
end
\"\"\"
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
@doc """
Generates mob.exs config content for a LiveView project.
"""
def mob_exs_content(mob_exs_mob_dir, mob_exs_elixir_lib) do
"""
# mob.exs — Mob build environment configuration.
# Set these paths for your machine. Not committed to version control.
# (Add mob.exs to .gitignore if you share this project.)
#
# OTP runtimes for Android and iOS are downloaded automatically by `mix mob.install`.
import Config
config :mob_dev,
# Path to the mob library repo (native source files for iOS/Android builds).
mob_dir: #{mob_exs_mob_dir},
# Path to your Elixir lib dir (e.g. ~/.local/share/mise/installs/elixir/1.18.4-otp-28/lib).
elixir_lib: #{mob_exs_elixir_lib}
config :mob, liveview_port: 4200
"""
end
@doc """
Generates the `mob_app.ex` entry point for a LiveView project.
This module is called from the Erlang bootstrap (`src/app_name.erl`) instead
of a native `Mob.App` module. It starts the Phoenix OTP application (which
boots the endpoint) and then starts `MobScreen` to open the WebView.
Unlike native Mob apps, this does NOT `use Mob.App` — Phoenix owns the
supervision tree. Mob is wired in at the BEAM entry level only.
`secret_key_base` and `signing_salt` are embedded directly because Mix config
files (`config/*.exs`) are not loaded on-device — `Application.put_env/3` is
the only way to configure the endpoint before `ensure_all_started/1` runs.
Port 4200 avoids conflicts with a host `mix phx.server` on 4000.
"""
def mob_live_app_content(module_name, app_name, secret_key_base, signing_salt) do
"""
defmodule #{module_name}.MobApp do
@moduledoc \"\"\"
BEAM entry point for the LiveView Mob app.
Called from `src/#{app_name}.erl` by the iOS/Android native launcher.
Starts the Phoenix OTP application (which boots the endpoint and all
supervision trees), then opens the MobScreen WebView pointing at
http://127.0.0.1:<liveview_port>/ (port set in mob.exs).
This module is the LiveView equivalent of `Mob.App`. It does not use
`use Mob.App` because Phoenix owns the supervision tree. Mob is added
only as a WebView wrapper around the running Phoenix endpoint.
\"\"\"
def start do
Mob.NativeLogger.install()
# On-device, Mix config files are not loaded — set Phoenix endpoint
# config explicitly before starting applications so the endpoint knows
# its port, adapter, and secret key base. Watchers and code reload
# are omitted (no dev tools on-device).
#
# Port 4200 avoids conflicts with a host `mix phx.server` on 4000
# (iOS simulator shares the host loopback at 127.0.0.1).
liveview_port = Application.get_env(:mob, :liveview_port, 4200)
Application.put_env(:mob, :liveview_port, liveview_port)
Application.put_env(:#{app_name}, #{module_name}Web.Endpoint,
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: liveview_port],
check_origin: false,
debug_errors: true,
server: true,
secret_key_base: "#{secret_key_base}",
pubsub_server: #{module_name}.PubSub,
live_view: [signing_salt: "#{signing_salt}"]
)
# ecto_sqlite3 must be started before #{app_name} so its NIF is loaded
# before the Repo supervisor tries to open the database.
{:ok, _} = Application.ensure_all_started(:ecto_sqlite3)
# Start the Phoenix application and all its children.
# This boots the endpoint, repo, pubsub, telemetry, etc.
{:ok, _} = Application.ensure_all_started(:#{app_name})
# Run any pending Ecto migrations. MOB_BEAMS_DIR is set by the native
# launcher to the flat deploy directory; migrations are copied there at
# build time. Falls back to Application.app_dir when running in dev.
Ecto.Migrator.with_repo(#{module_name}.Repo, fn _repo ->
Ecto.Migrator.run(#{module_name}.Repo, migrations_dir(), :up, all: true)
end)
# ComponentRegistry is normally started by Mob.App but we bypass that.
# Start it standalone so Mob.Screen.start_root can render components.
{:ok, _} = Mob.ComponentRegistry.start_link()
# Start the MobScreen WebView pointing at the local Phoenix endpoint.
# The WebView loads http://127.0.0.1:<liveview_port>/ (see mob.exs).
Mob.Screen.start_root(#{module_name}.MobScreen)
# Start Erlang distribution so `mix mob.connect` can attach.
Mob.Dist.ensure_started(node: :"#{app_name}_android@127.0.0.1", cookie: :mob_secret)
end
defp migrations_dir do
case System.get_env("MOB_BEAMS_DIR") do
nil -> Application.app_dir(:#{app_name}, "priv/repo/migrations")
beams_dir -> Path.join([beams_dir, "priv", "repo", "migrations"])
end
end
end
"""
end
@doc """
Generates `lib/<app>_web/live/page_live.ex` — a minimal starter LiveView
that replaces the default PageController route.
"""
def page_live_content(module_name, app_name) do
"""
defmodule #{module_name}Web.PageLive do
use #{module_name}Web, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, :pong, false)}
end
def render(assigns) do
~H\"\"\"
<div class="p-8">
<h1 class="text-2xl font-bold text-zinc-900">#{module_name} on Mob</h1>
<p class="mt-4 text-zinc-600">
LiveView is running on-device! Edit
<code class="text-sm bg-zinc-100 px-1 rounded">lib/#{app_name}_web/live/page_live.ex</code>
to get started.
</p>
<button
phx-click="ping"
class="mt-6 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700"
>
Ping
</button>
<p :if={@pong} class="mt-4 text-green-600 font-semibold">Pong!</p>
</div>
\"\"\"
end
def handle_event("ping", _params, socket) do
{:noreply, assign(socket, :pong, true)}
end
end
"""
end
@doc "Generates `lib/<app>/repo.ex` — Ecto.Repo backed by SQLite3."
def repo_content(module_name, app_name) do
"""
defmodule #{module_name}.Repo do
use Ecto.Repo,
otp_app: :#{app_name},
adapter: Ecto.Adapters.SQLite3
@impl true
def init(_type, config) do
db_path =
case System.get_env("MOB_DATA_DIR") do
nil -> config[:database] || Path.join(File.cwd!(), "#{app_name}.db")
dir -> Path.join(dir, "app.db")
end
{:ok, Keyword.merge(config, database: db_path, pool_size: 1)}
end
end
"""
end
@doc "Generates `lib/<app>/note.ex` — the Note Ecto schema."
def note_content(module_name) do
"""
defmodule #{module_name}.Note do
use Ecto.Schema
import Ecto.Changeset
schema "notes" do
field :title, :string, default: ""
field :body, :string, default: ""
timestamps(type: :utc_datetime)
end
def changeset(note, attrs) do
note
|> cast(attrs, [:title, :body])
end
end
"""
end
@doc "Generates `lib/<app>/notes.ex` — the Notes context with seed data."
def notes_content(module_name, _app_name) do
"""
defmodule #{module_name}.Notes do
import Ecto.Query
alias #{module_name}.{Repo, Note}
@seeds [
%{title: "Welcome to Mob", body: "LiveView is running on-device inside a WKWebView. The full Phoenix stack — Bandit, Plug, LiveView WebSocket — runs entirely on the device.\\n\\nTry editing this note!"},
%{title: "How it works", body: "The app boots the BEAM, starts the Phoenix endpoint on 127.0.0.1:4200, then loads http://127.0.0.1:4200/ in a native WebView.\\n\\nLiveView handles all UI updates over a WebSocket — no page reloads needed."},
%{title: "Things to try", body: "• Create a new note with the + button\\n• Edit any note — changes persist to SQLite\\n• Delete notes by tapping the × button\\n• Check out the About tab"},
]
def list do
Repo.all(from n in Note, order_by: [desc: n.updated_at])
|> maybe_seed()
end
def get(id), do: Repo.get(Note, id)
def create do
%Note{}
|> Note.changeset(%{title: "", body: ""})
|> Repo.insert!()
end
def update(id, attrs) do
case Repo.get(Note, id) do
nil -> nil
note ->
note
|> Note.changeset(attrs)
|> Repo.update!()
end
end
def delete(id) do
case Repo.get(Note, id) do
nil -> :ok
note -> Repo.delete!(note)
end
:ok
end
defp maybe_seed([]) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
@seeds
|> Enum.with_index()
|> Enum.each(fn {attrs, i} ->
ts = DateTime.add(now, -i * 3600, :second)
Repo.insert!(%Note{title: attrs.title, body: attrs.body, inserted_at: ts, updated_at: ts})
end)
Repo.all(from n in Note, order_by: [desc: n.updated_at])
end
defp maybe_seed(notes), do: notes
end
"""
end
@doc "Generates the create_notes Ecto migration."
def migration_content(app_name) do
module = app_name |> Macro.camelize()
"""
defmodule #{module}.Repo.Migrations.CreateNotes do
use Ecto.Migration
def change do
create table(:notes) do
add :title, :string, default: ""
add :body, :text, default: ""
timestamps(type: :utc_datetime)
end
end
end
"""
end
@doc "Generates `lib/<app>_web/live/notes_list_live.ex`."
def notes_list_live_content(module_name, _app_name) do
"""
defmodule #{module_name}Web.NotesListLive do
use #{module_name}Web, :live_view
alias #{module_name}.Notes
def mount(_params, _session, socket) do
{:ok, assign(socket, notes: Notes.list(), page_title: "Notes")}
end
def render(assigns) do
~H\"\"\"
<div class="px-4 pt-4">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-900">Notes</h1>
<button
phx-click="new_note"
class="w-10 h-10 flex items-center justify-center bg-indigo-600 text-white rounded-full shadow-md text-2xl leading-none"
aria-label="New note"
>
+
</button>
</div>
<ul :if={@notes != []} class="space-y-2">
<li :for={note <- @notes}>
<div class="flex items-stretch bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<button
phx-click="open"
phx-value-id={note.id}
class="flex-1 text-left px-4 py-3 min-h-[72px]"
>
<p class="font-semibold text-gray-900 leading-tight truncate">
{if note.title == "", do: "Untitled", else: note.title}
</p>
<p class="text-sm text-gray-500 mt-0.5 line-clamp-2">
{if note.body == "", do: "No content", else: note.body}
</p>
<p class="text-xs text-gray-400 mt-1">{format_time(note.updated_at)}</p>
</button>
<button
phx-click="delete"
phx-value-id={note.id}
data-confirm="Delete this note?"
class="px-4 text-gray-300 hover:text-red-400 border-l border-gray-100 text-xl"
aria-label="Delete"
>
×
</button>
</div>
</li>
</ul>
<div :if={@notes == []} class="flex flex-col items-center justify-center mt-24 text-center">
<p class="text-5xl mb-4">📝</p>
<p class="text-gray-500 text-lg font-medium">No notes yet</p>
<p class="text-gray-400 text-sm mt-1">Tap + to create your first note</p>
</div>
</div>
\"\"\"
end
def handle_event("new_note", _params, socket) do
note = Notes.create()
{:noreply, push_navigate(socket, to: "/notes/\#{note.id}")}
end
def handle_event("open", %{"id" => id}, socket) do
{:noreply, push_navigate(socket, to: "/notes/\#{id}")}
end
def handle_event("delete", %{"id" => id}, socket) do
Notes.delete(id)
{:noreply, assign(socket, notes: Notes.list())}
end
defp format_time(dt) do
now = DateTime.utc_now()
diff = DateTime.diff(now, dt, :second)
cond do
diff < 60 -> "Just now"
diff < 3600 -> "\#{div(diff, 60)}m ago"
diff < 86_400 -> "\#{div(diff, 3600)}h ago"
true -> Calendar.strftime(dt, "%b %-d")
end
end
end
"""
end
@doc "Generates `lib/<app>_web/live/note_editor_live.ex`."
def note_editor_live_content(module_name, _app_name) do
"""
defmodule #{module_name}Web.NoteEditorLive do
use #{module_name}Web, :live_view
alias #{module_name}.Notes
def mount(%{"id" => id}, _session, socket) do
case Notes.get(id) do
nil ->
{:ok, push_navigate(socket, to: "/")}
note ->
{:ok,
assign(socket,
note: note,
word_count: count_words(note.body),
saved: true,
page_title: if(note.title == "", do: "New Note", else: note.title)
)}
end
end
def render(assigns) do
~H\"\"\"
<div class="flex flex-col h-screen bg-white">
<div class="flex items-center px-4 pt-4 pb-2 border-b border-gray-100">
<a
href="/"
class="flex items-center text-indigo-600 text-sm font-medium mr-4 min-w-[44px] min-h-[44px] -ml-2 px-2 justify-center"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Notes
</a>
<span class="ml-auto text-xs text-gray-400">
{if @saved, do: "Saved", else: "Saving…"}
</span>
</div>
<form phx-change="update_note" class="flex-1 flex flex-col overflow-hidden">
<input
type="text"
name="title"
value={@note.title}
placeholder="Title"
class="w-full px-4 pt-4 pb-2 text-2xl font-bold text-gray-900 placeholder-gray-300 border-none outline-none bg-white"
/>
<textarea
name="body"
placeholder="Start writing…"
class="flex-1 w-full px-4 py-2 text-base text-gray-800 placeholder-gray-300 border-none outline-none resize-none bg-white leading-relaxed"
>{@note.body}</textarea>
</form>
<div class="px-4 py-3 border-t border-gray-100 flex items-center justify-between mb-16">
<span class="text-xs text-gray-400">
{@word_count} {if @word_count == 1, do: "word", else: "words"}
</span>
<span class="text-xs text-gray-400">
{String.length(@note.body)} chars
</span>
</div>
</div>
\"\"\"
end
def handle_event("update_note", %{"title" => title, "body" => body}, socket) do
note = Notes.update(socket.assigns.note.id, %{title: title, body: body})
{:noreply, assign(socket,
note: note,
word_count: count_words(body),
saved: true,
page_title: if(title == "", do: "New Note", else: title)
)}
end
defp count_words(""), do: 0
defp count_words(text) do
text |> String.split(~r/\\s+/, trim: true) |> length()
end
end
"""
end
@doc "Generates `lib/<app>_web/live/about_live.ex`."
def about_live_content(module_name, _app_name) do
"""
defmodule #{module_name}Web.AboutLive do
use #{module_name}Web, :live_view
def mount(_params, _session, socket) do
{:ok,
assign(socket,
page_title: "About",
name: "Anonymous",
editing_name: false,
name_draft: "Anonymous",
blurb: "Write something about yourself here.",
blurb_word_count: 5
)}
end
def render(assigns) do
~H\"\"\"
<div class="px-4 pt-6 max-w-lg mx-auto">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-16 h-16 rounded-full bg-indigo-100 flex items-center justify-center text-2xl font-bold text-indigo-600">
{String.first(@name) |> String.upcase()}
</div>
<div class="flex-1 min-w-0">
<%= if @editing_name do %>
<form phx-submit="save_name" class="flex gap-2">
<input
type="text"
name="name"
value={@name_draft}
phx-change="draft_name"
autofocus
maxlength="40"
class="flex-1 border border-indigo-300 rounded-lg px-3 py-1.5 text-lg font-semibold text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
type="submit"
class="px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-sm font-medium"
>
Save
</button>
</form>
<% else %>
<div class="flex items-center gap-2">
<p class="text-xl font-bold text-gray-900 truncate">{@name}</p>
<button
phx-click="edit_name"
class="text-gray-400 hover:text-indigo-500 p-1"
aria-label="Edit name"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" />
</svg>
</button>
</div>
<p class="text-sm text-gray-400">Tap the pencil to edit</p>
<% end %>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-5 mb-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">About me</h2>
<span class="text-xs text-gray-400">
{@blurb_word_count} {if @blurb_word_count == 1, do: "word", else: "words"}
</span>
</div>
<textarea
name="blurb"
phx-change="update_blurb"
phx-debounce="200"
placeholder="Write something about yourself…"
rows="5"
class="w-full text-base text-gray-700 placeholder-gray-300 border-none outline-none resize-none leading-relaxed"
>{@blurb}</textarea>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-5">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">App info</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500">Framework</dt>
<dd class="font-medium text-gray-800">Phoenix LiveView</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Runtime</dt>
<dd class="font-medium text-gray-800">BEAM on device</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Transport</dt>
<dd class="font-medium text-gray-800">WebSocket</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">OTP</dt>
<dd class="font-medium text-gray-800">{:erlang.system_info(:otp_release)}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Elixir</dt>
<dd class="font-medium text-gray-800">{System.version()}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Notes stored</dt>
<dd class="font-medium text-gray-800">{#{module_name}.Notes.list() |> length()}</dd>
</div>
</dl>
</div>
</div>
\"\"\"
end
def handle_event("edit_name", _params, socket) do
{:noreply, assign(socket, editing_name: true, name_draft: socket.assigns.name)}
end
def handle_event("draft_name", %{"name" => name}, socket) do
{:noreply, assign(socket, name_draft: name)}
end
def handle_event("save_name", %{"name" => name}, socket) do
name = if String.trim(name) == "", do: "Anonymous", else: String.trim(name)
{:noreply, assign(socket, name: name, editing_name: false)}
end
def handle_event("update_blurb", %{"blurb" => blurb}, socket) do
count = blurb |> String.split(~r/\\s+/, trim: true) |> length()
{:noreply, assign(socket, blurb: blurb, blurb_word_count: count)}
end
end
"""
end
@doc """
Generates the LiveView-specific `ios/build.sh` content.
Key differences from the native build.sh:
- Copies ALL compiled deps (Phoenix, Plug, Bandit, etc.) with a glob loop
- Ships a full crypto shim: pbkdf2_hmac/5, exor/2, HMAC-MD5 via erlang:md5/1
- Copies pure-Erlang ssl beams from host OTP (thousand_island requires :ssl)
- Builds and deploys JS/CSS assets to BEAMS_DIR/priv/static/ so Plug.Static
can serve them (code:priv_dir(:app) resolves relative to the flat BEAMS_DIR)
- No exqlite NIF cross-compile (no Ecto in LiveView projects)
"""
def liveview_build_sh_content(module_name, app_name) do
display_name = module_name
"""
#!/bin/bash
# ios/build.sh — Build and deploy #{display_name} to iOS simulator (LiveView mode).
# Reads paths from environment (set by `mix mob.deploy --native` via mob.exs,
# or export them manually before running this script directly).
#
# Required env vars (set in mob.exs or export manually):
# MOB_DIR — path to mob library repo
# MOB_ELIXIR_LIB — path to Elixir lib dir
# MOB_IOS_OTP_ROOT — iOS OTP runtime root (set automatically by mob_dev OtpDownloader)
set -e
cd "$(dirname "$0")/.." # project root (contains mix.exs)
# ── Paths ─────────────────────────────────────────────────────────────────────
MOB_DIR="${MOB_DIR:?MOB_DIR not set — configure mob.exs}"
ELIXIR_LIB="${MOB_ELIXIR_LIB:?MOB_ELIXIR_LIB not set — configure mob.exs}"
OTP_ROOT="${MOB_IOS_OTP_ROOT:?MOB_IOS_OTP_ROOT not set — run mix mob.install to download OTP}"
# Auto-detect ERTS version from the OTP runtime root.
ERTS_VSN=$(ls "$OTP_ROOT" | grep '^erts-' | sort -V | tail -1)
if [ -z "$ERTS_VSN" ]; then
echo "ERROR: No erts-* directory found in $OTP_ROOT"
echo " Have you built OTP for iOS simulator?"
exit 1
fi
BEAMS_DIR="$OTP_ROOT/#{app_name}"
SDKROOT=$(xcrun -sdk iphonesimulator --show-sdk-path)
CC="xcrun -sdk iphonesimulator cc -arch arm64 -mios-simulator-version-min=16.0 -isysroot $SDKROOT"
IFLAGS="-I$OTP_ROOT/$ERTS_VSN/include \\
-I$OTP_ROOT/$ERTS_VSN/include/aarch64-apple-iossimulator \\
-I$MOB_DIR/ios"
LIBS="
$OTP_ROOT/$ERTS_VSN/lib/libbeam.a
$OTP_ROOT/$ERTS_VSN/lib/internal/liberts_internal_r.a
$OTP_ROOT/$ERTS_VSN/lib/internal/libethread.a
$OTP_ROOT/$ERTS_VSN/lib/libzstd.a
$OTP_ROOT/$ERTS_VSN/lib/libepcre.a
$OTP_ROOT/$ERTS_VSN/lib/libryu.a
$OTP_ROOT/$ERTS_VSN/lib/asn1rt_nif.a
"
# ── Find booted simulator ──────────────────────────────────────────────────────
if [ -n "$1" ]; then
SIM_ID="$1"
else
SIM_ID=$(xcrun simctl list devices booted -j \\
| python3 -c "
import json,sys
d=json.load(sys.stdin)
for sims in d['devices'].values():
for s in sims:
if s.get('state') == 'Booted':
print(s['udid'])
exit()
" 2>/dev/null || true)
fi
if [ -z "$SIM_ID" ]; then
echo "ERROR: No booted simulator found. Boot one in Simulator.app or pass UDID as argument."
exit 1
fi
echo "=== Target simulator: $SIM_ID ==="
# ── Compile Erlang/Elixir ──────────────────────────────────────────────────────
echo "=== Compiling Erlang/Elixir ==="
mix compile
echo "=== Copying BEAM files to $BEAMS_DIR ==="
mkdir -p "$BEAMS_DIR"
# Copy .beam and .app files for ALL compiled deps + the app itself.
# .app files are required by application:ensure_all_started to resolve OTP metadata.
# The glob loop handles Phoenix, Plug, Bandit, phoenix_live_view, etc. automatically.
for lib_dir in _build/dev/lib/*/ebin; do
cp "$lib_dir"/* "$BEAMS_DIR/" 2>/dev/null || true
done
echo "=== Copying Elixir stdlib ==="
mkdir -p "$OTP_ROOT/lib/elixir/ebin"
mkdir -p "$OTP_ROOT/lib/logger/ebin"
cp "$ELIXIR_LIB/elixir/ebin/"*.beam "$OTP_ROOT/lib/elixir/ebin/"
cp "$ELIXIR_LIB/elixir/ebin/elixir.app" "$OTP_ROOT/lib/elixir/ebin/"
cp "$ELIXIR_LIB/logger/ebin/"*.beam "$OTP_ROOT/lib/logger/ebin/"
cp "$ELIXIR_LIB/logger/ebin/logger.app" "$OTP_ROOT/lib/logger/ebin/"
# EEx is used by Phoenix templates — copy into BEAMS_DIR so code:where_is_file("eex.app")
# resolves correctly alongside other Elixir stdlib beams.
cp "$ELIXIR_LIB/eex/ebin/"*.beam "$BEAMS_DIR/"
cp "$ELIXIR_LIB/eex/ebin/eex.app" "$BEAMS_DIR/"
echo "=== Installing crypto shim (iOS OTP has no OpenSSL) ==="
# phoenix and plug_crypto list :crypto as a required OTP application.
# The iOS OTP build does not include crypto (no OpenSSL NIF).
# This shim satisfies application:ensure_started(:crypto) so the app boots.
# For HTTP-only Phoenix at loopback, crypto is never actually called for TLS.
# pbkdf2_hmac/5 is needed by plug_crypto KeyGenerator (session key derivation).
# exor/2 is needed by Plug.CSRFProtection (token masking).
# Both are implemented in pure Erlang using erlang:md5/1 (a built-in BIF).
CRYPTO_TMP=$(mktemp -d)
cat > "$CRYPTO_TMP/crypto.erl" << 'ERLEOF'
-module(crypto).
-behaviour(application).
-export([start/2, stop/1, strong_rand_bytes/1, rand_bytes/1,
hash/2, mac/4, mac/3, supports/1, exor/2,
generate_key/2, compute_key/4, sign/4, verify/5,
pbkdf2_hmac/5]).
start(_Type, _Args) -> {ok, self()}.
stop(_State) -> ok.
strong_rand_bytes(N) -> rand:bytes(N).
rand_bytes(N) -> rand:bytes(N).
hash(_Type, Data) -> erlang:md5(iolist_to_binary(Data)).
supports(_Type) -> [].
generate_key(_Alg, _Params) -> {<<>>, <<>>}.
compute_key(_Alg, _OtherKey, _MyKey, _Params) -> <<>>.
sign(_Alg, _DigestType, _Msg, _Key) -> <<>>.
verify(_Alg, _DigestType, _Msg, _Signature, _Key) -> true.
%% crypto:exor/2 — used by Plug.CSRFProtection to mask tokens.
exor(A, B) -> xor_bytes(iolist_to_binary(A), iolist_to_binary(B)).
%% Pure-Erlang HMAC-MD5 shim. DigestType ignored (local dev only).
mac(hmac, _HashAlg, Key, Data) ->
hmac_md5(iolist_to_binary(Key), iolist_to_binary(Data));
mac(_Type, _SubType, _Key, _Data) -> <<>>.
mac(_Type, _Key, _Data) -> <<>>.
%% pbkdf2_hmac/5 — used by plug_crypto KeyGenerator for session key derivation.
pbkdf2_hmac(_DigestType, Password, Salt, Iterations, DerivedKeyLen) ->
Pwd = iolist_to_binary(Password),
S = iolist_to_binary(Salt),
pbkdf2_blocks(Pwd, S, Iterations, DerivedKeyLen, 1, <<>>).
pbkdf2_blocks(_Pwd, _Salt, _Iter, Len, _Block, Acc) when byte_size(Acc) >= Len ->
binary:part(Acc, 0, Len);
pbkdf2_blocks(Pwd, Salt, Iter, Len, Block, Acc) ->
U1 = hmac_md5(Pwd, <<Salt/binary, Block:32/unsigned-big-integer>>),
Ux = pbkdf2_iterate(Pwd, Iter - 1, U1, U1),
pbkdf2_blocks(Pwd, Salt, Iter, Len, Block + 1, <<Acc/binary, Ux/binary>>).
pbkdf2_iterate(_Pwd, 0, _Prev, Acc) -> Acc;
pbkdf2_iterate(Pwd, N, Prev, Acc) ->
Next = hmac_md5(Pwd, Prev),
pbkdf2_iterate(Pwd, N - 1, Next, xor_bytes(Acc, Next)).
hmac_md5(Key0, Data) ->
BlockSize = 64,
Key = if byte_size(Key0) > BlockSize -> erlang:md5(Key0); true -> Key0 end,
PadLen = BlockSize - byte_size(Key),
K = <<Key/binary, 0:(PadLen * 8)>>,
IPad = xor_bytes(K, binary:copy(<<16#36>>, BlockSize)),
OPad = xor_bytes(K, binary:copy(<<16#5C>>, BlockSize)),
erlang:md5(<<OPad/binary, (erlang:md5(<<IPad/binary, Data/binary>>))/binary>>).
%% Byte-wise XOR — recursive zip, not cartesian product.
xor_bytes(A, B) -> xor_bytes(A, B, []).
xor_bytes(<<X, Ra/binary>>, <<Y, Rb/binary>>, Acc) ->
xor_bytes(Ra, Rb, [X bxor Y | Acc]);
xor_bytes(<<>>, <<>>, Acc) ->
list_to_binary(lists:reverse(Acc)).
ERLEOF
erlc -o "$BEAMS_DIR" "$CRYPTO_TMP/crypto.erl"
cat > "$BEAMS_DIR/crypto.app" << 'APPEOF'
{application,crypto,[{modules,[crypto]},{applications,[kernel,stdlib]},{description,"Crypto shim for iOS (HTTP-only; no OpenSSL)"},{registered,[]},{vsn,"5.6"},{mod,{crypto,[]}}]}.
APPEOF
rm -rf "$CRYPTO_TMP"
echo "=== Copying ssl from host OTP (pure Erlang — BEAM is platform-neutral) ==="
# thousand_island lists :ssl as a required OTP application.
# ssl is implemented entirely in Erlang (no NIFs), so the host macOS .beam
# files run identically on the iOS simulator.
# For HTTP-only Phoenix at loopback, ssl starts but no TLS sockets are opened.
HOST_SSL_DIR=$(ls -d ~/.local/share/mise/installs/erlang/*/lib/ssl-* 2>/dev/null | sort -V | tail -1)
if [ -n "$HOST_SSL_DIR" ]; then
cp "$HOST_SSL_DIR/ebin/"*.beam "$BEAMS_DIR/"
cp "$HOST_SSL_DIR/ebin/ssl.app" "$BEAMS_DIR/"
echo "* ssl copied from $HOST_SSL_DIR"
else
echo "WARNING: ssl not found in host OTP — thousand_island may fail to start"
fi
# ── Sync OTP runtime to /tmp/otp-ios-sim ─────────────────────────────────────
echo "=== Syncing OTP runtime to /tmp/otp-ios-sim ==="
mkdir -p "/tmp/otp-ios-sim"
rsync -a --delete "$OTP_ROOT/" "/tmp/otp-ios-sim/"
echo "=== Copying Mob logos ==="
cp "$MOB_DIR/assets/logo/logo_dark.png" "/tmp/otp-ios-sim/mob_logo_dark.png"
cp "$MOB_DIR/assets/logo/logo_light.png" "/tmp/otp-ios-sim/mob_logo_light.png"
echo "=== Building and copying Phoenix static assets ==="
# Build JS/CSS with esbuild + tailwind, then place them under
# BEAMS_DIR/priv/static so Plug.Static can serve them.
# code:lib_dir(:#{app_name}) resolves to BEAMS_DIR (the flat ebin directory),
# so priv/static must live there. Without this, the LiveView JS never loads
# and phx-click events cannot reach the server.
mix assets.build
mkdir -p "$BEAMS_DIR/priv/static"
cp -r priv/static/. "$BEAMS_DIR/priv/static/"
rsync -a "$BEAMS_DIR/priv/" "/tmp/otp-ios-sim/#{app_name}/priv/"
echo "=== Spot-check ==="
ls "$BEAMS_DIR/Elixir.#{module_name}.MobApp.beam"
ls "$BEAMS_DIR/Elixir.#{module_name}.MobScreen.beam"
# ── Compile C/ObjC/Swift ──────────────────────────────────────────────────────
echo "=== Compiling native sources ==="
BUILD_DIR=$(mktemp -d)
SWIFT_BRIDGING="$MOB_DIR/ios/MobDemo-Bridging-Header.h"
$CC -fobjc-arc -fmodules $IFLAGS \\
-c "$MOB_DIR/ios/MobNode.m" -o "$BUILD_DIR/MobNode.o"
xcrun -sdk iphonesimulator swiftc \\
-target arm64-apple-ios16.0-simulator \\
-module-name #{display_name} \\
-emit-objc-header -emit-objc-header-path "$BUILD_DIR/MobApp-Swift.h" \\
-import-objc-header "$SWIFT_BRIDGING" \\
-I "$MOB_DIR/ios" \\
-parse-as-library \\
-wmo \\
"$MOB_DIR/ios/MobViewModel.swift" \\
"$MOB_DIR/ios/MobRootView.swift" \\
-c -o "$BUILD_DIR/swift_mob.o"
$CC -fobjc-arc -fmodules $IFLAGS \\
-I "$BUILD_DIR" \\
-DSTATIC_ERLANG_NIF \\
-c "$MOB_DIR/ios/mob_nif.m" -o "$BUILD_DIR/mob_nif.o"
$CC -fobjc-arc -fmodules $IFLAGS \\
-c "$MOB_DIR/ios/mob_beam.m" -o "$BUILD_DIR/mob_beam.o"
$CC $IFLAGS \\
-c "$MOB_DIR/ios/driver_tab_ios.c" -o "$BUILD_DIR/driver_tab_ios.o"
$CC -fobjc-arc -fmodules $IFLAGS \\
-I "$BUILD_DIR" \\
-c ios/AppDelegate.m -o "$BUILD_DIR/AppDelegate.o"
$CC -fobjc-arc -fmodules $IFLAGS \\
-c ios/beam_main.m -o "$BUILD_DIR/beam_main.o"
# ── Link ───────────────────────────────────────────────────────────────────────
echo "=== Linking #{display_name} binary ==="
xcrun -sdk iphonesimulator swiftc \\
-target arm64-apple-ios16.0-simulator \\
"$BUILD_DIR/driver_tab_ios.o" \\
"$BUILD_DIR/MobNode.o" \\
"$BUILD_DIR/swift_mob.o" \\
"$BUILD_DIR/mob_nif.o" \\
"$BUILD_DIR/mob_beam.o" \\
"$BUILD_DIR/AppDelegate.o" \\
"$BUILD_DIR/beam_main.o" \\
$LIBS \\
-lz -lc++ -lpthread \\
-Xlinker -framework -Xlinker UIKit \\
-Xlinker -framework -Xlinker Foundation \\
-Xlinker -framework -Xlinker CoreGraphics \\
-Xlinker -framework -Xlinker QuartzCore \\
-Xlinker -framework -Xlinker SwiftUI \\
-o "$BUILD_DIR/#{display_name}"
# ── Bundle + install ───────────────────────────────────────────────────────────
echo "=== Building .app bundle ==="
APP="$BUILD_DIR/#{display_name}.app"
rm -rf "$APP"
mkdir -p "$APP"
cp "$BUILD_DIR/#{display_name}" "$APP/"
cp ios/Info.plist "$APP/"
if [ -d "ios/Assets.xcassets/AppIcon.appiconset" ]; then
ACTOOL_PLIST=$(mktemp /tmp/actool_XXXXXX.plist)
xcrun actool ios/Assets.xcassets \\
--compile "$APP" \\
--platform iphonesimulator \\
--minimum-deployment-target 16.0 \\
--app-icon AppIcon \\
--output-partial-info-plist "$ACTOOL_PLIST" \\
2>/dev/null || true
/usr/libexec/PlistBuddy -c "Merge $ACTOOL_PLIST" "$APP/Info.plist" 2>/dev/null || true
rm -f "$ACTOOL_PLIST"
fi
echo "=== Installing on simulator $SIM_ID ==="
xcrun simctl install "$SIM_ID" "$APP"
echo "=== Installing complete ==="
"""
end
@doc """
Generates the Erlang bootstrap for a LiveView project.
Calls `ModuleName.MobApp.start()` instead of `ModuleName.App.start()`.
"""
def erlang_entry_content(module_name, app_name) do
"""
%% #{app_name}.erl — BEAM bootstrap for #{module_name} (LiveView mode).
%% Called by the iOS/Android native launcher via -eval '#{app_name}:start().'.
%% Starts the OTP ecosystem, then starts Phoenix + MobScreen via MobApp.
-module(#{app_name}).
-export([start/0]).
start() ->
step(1, fun() -> application:start(compiler) end),
step(2, fun() -> application:start(elixir) end),
step(3, fun() -> application:start(logger) end),
step(4, fun() -> mob_nif:platform() end),
step(5, fun() -> 'Elixir.#{module_name}.MobApp':start() end),
timer:sleep(infinity).
step(N, Fun) ->
mob_nif:log("step " ++ integer_to_list(N) ++ " starting"),
Result = (catch Fun()),
mob_nif:log("step " ++ integer_to_list(N) ++ " => " ++
lists:flatten(io_lib:format("~p", [Result]))).
"""
end
# ── Private ───────────────────────────────────────────────────────────────────
# Insert `hooks: {MobHook}` before the closing `})` of the LiveSocket call.
# Works by tracking brace depth line by line — avoids regex fights with nested braces.
defp insert_hooks_before_closing(content) do
lines = String.split(content, "\n")
{result_lines, _} =
Enum.reduce(lines, {[], :before}, fn line, {acc, state} ->
reduce_line(line, acc, state)
end)
Enum.join(result_lines, "\n")
end
defp reduce_line(line, acc, :before) do
if String.contains?(line, "new LiveSocket(") do
depth = count_brace_depth(line)
if depth <= 0 do
patched = Regex.replace(~r/\)\s*$/, line, ", {hooks: {MobHook}})", global: false)
{acc ++ [patched], :done}
else
{acc ++ [line], {:in_call, depth}}
end
else
{acc ++ [line], :before}
end
end
defp reduce_line(line, acc, {:in_call, depth}) do
new_depth = depth + count_brace_depth(line)
trimmed = String.trim(line)
if new_depth <= 0 and (trimmed == "})" or String.starts_with?(trimmed, "})")) do
{insert_hooks_line(acc, line), :done}
else
{acc ++ [line], {:in_call, new_depth}}
end
end
defp reduce_line(line, acc, :done), do: {acc ++ [line], :done}
defp insert_hooks_line(acc, closing_line) do
last_acc = List.last(acc)
last_trimmed = if last_acc, do: String.trim_trailing(last_acc), else: ""
acc_with_comma =
if String.ends_with?(last_trimmed, ",") do
acc
else
List.update_at(acc, -1, fn l -> String.trim_trailing(l) <> "," end)
end
acc_with_comma ++ [" hooks: {MobHook}", closing_line]
end
# Returns the net brace depth change for a line (opens minus closes).
defp count_brace_depth(line) do
opens = line |> String.graphemes() |> Enum.count(&(&1 == "{"))
closes = line |> String.graphemes() |> Enum.count(&(&1 == "}"))
opens - closes
end
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?(~r/hooks:\s*\{/, content) ->
# hooks key already exists — prepend MobHook to it
Regex.replace(~r/(hooks:\s*\{)/, content, "\\1MobHook, ", global: false)
true ->
# No hooks key. Insert `hooks: {MobHook}` into the LiveSocket options.
#
# Strategy: process line by line. Once we see `new LiveSocket(`, track
# nesting depth. When we find the line that closes the options object
# (depth goes to 0 with `})`), insert `hooks: {MobHook}` before it.
#
# This handles both single-line and multiline LiveSocket calls correctly
# without fighting nested-brace regex limitations.
insert_hooks_before_closing(content)
end
end
end