Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,53 @@ Full module documentation: [hexdocs.pm/mob_new](https://hexdocs.pm/mob_new).

---

## [Unreleased]

### Added
- **`mix mob.install` — add Mob to an existing Phoenix project.** Composable
Igniter-based installer mirroring the architecture of
[team-alembic/phx_install](https://github.com/team-alembic/phx_install).
Distributed as a path/Hex dep (not the mob_new archive — Igniter tasks
need to compile against the target project's deps tree). An orchestrator
(`mix mob.install`) composes 7 sub-installers: `mob.install.deps`,
`mob.install.bridge`, `mob.install.screen`, `mob.install.mob_app`,
`mob.install.mob_exs`, `mob.install.native` (+ `.android` / `.ios`),
`mob.install.finalize`. Each sub-installer is idempotent and runnable
standalone. `Mob.App` is the *behaviour* the on-device entry uses via
`use Mob.App`, never a supervision-tree child — on-device runtime
services start imperatively inside `<App>.MobApp.start/0`. Patches existing `app.js` /
`root.html.heex` for the LiveView bridge; for non-LV hosts (e.g.
Hologram) those patches harmlessly no-op since the native `window.mob`
injection from Mob's WebView shell carries the bridge traffic. Triggered
by [mob#16](https://github.com/GenericJam/mob/issues/16).

Native trees (android/ios) emit through Igniter for EEx-rendered text
and direct `File.copy!/2` for binaries (gradle-wrapper.jar, gradlew,
PNG icons) since Igniter's `Rewrite` engine assumes UTF-8.
`mix mob.new` is unaffected — install is purely additive. Adds
`{:igniter, "~> 0.7"}` to mob_new's deps.

CLI flags:
- `--no-ios` / `--no-android` — skip a platform's native tree.
- `--local` — `path:` deps + pre-fill `mob.exs` from `MOB_DIR` /
`MOB_DEV_DIR`.
- `--python` — iOS-only Pythonx pre-config.
- `--host-url URL` — writes `config :mob, host_url: URL` to
`config/config.exs`. Generated `MobScreen` reads via
`Application.get_env(:mob, :host_url, "http://127.0.0.1:4000/")`,
so the URL lives in config (not hardcoded in the module). Use for
thin-client deployments pointing at a deployed Phoenix server.
- `--no-live-view` — skip the LV bridge patches AND generate a
thin-client `mob_app.ex` using `use Mob.App` (no
`Application.ensure_all_started(:<app>)`, no on-device Phoenix
boot). For Hologram-only or non-Phoenix hosts.

All flags forward from `mix mob.install` to each sub-installer.
Every sub-installer accepts the full orchestrator flag set in its
schema, ignoring flags that don't apply to it — so each is also
individually invokable with the same CLI surface (`mix help
mob.install.screen` documents `--host-url`, etc.).

## [0.3.10]

### Added
Expand Down
174 changes: 174 additions & 0 deletions lib/mix/tasks/mob/install.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
defmodule Mix.Tasks.Mob.Install do
@shortdoc "Installs Mob into an existing Phoenix project"

@moduledoc """
Adds Mob (mobile framework) to an existing Phoenix-based Elixir project.

Composable, [Igniter](https://hex.pm/packages/igniter)-based — mirrors
the architecture of [team-alembic/phx_install](https://github.com/team-alembic/phx_install).
This is the install-into-existing counterpart to `mix mob.new`, which
generates a project from scratch. `mix mob.new` is unaffected by this task.

## Usage

mix mob.install [OPTIONS]

Run from inside an existing Mix project. The target project must
declare `{:igniter, "~> 0.7", only: [:dev, :test]}` in its mix.exs
(most modern Phoenix-ecosystem projects already do).

## Options

- `--no-ios` — skip the iOS native tree
- `--no-android` — skip the Android native tree
- `--local` — `path:` deps for `:mob`/`:mob_dev`; pre-fill `mob.exs`
paths from `MOB_DIR` / `MOB_DEV_DIR`. For Mob framework contributors.
- `--python` — iOS-only: pre-configure embedded CPython via Pythonx
- `--host-url URL` — write `config :mob, host_url: URL` so the
generated `MobScreen` opens `URL` instead of the default
`http://127.0.0.1:4000/`. Use for thin-client deployments where
the WebView points at a deployed Phoenix server (fly.io etc.).
- `--no-live-view` — skip the LiveView bridge patches
(`assets/js/app.js` MobHook, `root.html.heex` bridge div) AND
generate a thin-client `mob_app.ex` that does NOT boot Phoenix
on-device. For Hologram-only or non-Phoenix hosts where the
BEAM-on-device is just the native interop layer.

Both platforms emit by default. Passing both `--no-ios` and
`--no-android` raises.

## What gets installed

- `:mob` + `:mob_dev` deps in `mix.exs`
- `lib/<app>/mob_screen.ex` — `Mob.Screen` opening a WebView at
`Application.get_env(:mob, :host_url)` (default localhost)
- `mob.exs` — build-environment config
- `.gitignore` updated to ignore `mob.exs`
- `android/` and/or `ios/` native trees (gated by platform flags)
- `lib/<app>/mob_app.ex` + `src/<app>.erl` for on-device BEAM entry
- `erlc_paths`/`erlc_options` added to `mix.exs`

Default (no `--no-live-view`):
- `MobHook` injected into `assets/js/app.js`
- bridge `<div>` injected into `root.html.heex`
- `mob_app.ex` boots the host Phoenix endpoint on-device

With `--no-live-view`:
- LiveView bridge patches skipped
- `mob_app.ex` is the thin-client variant (`use Mob.App` shell,
no `Application.ensure_all_started`)

## Composability

Every sub-installer is invokable independently:

mix mob.install.deps # just bump mix.exs
mix mob.install.bridge # just patch app.js + root.html.heex
mix mob.install.screen # just generate mob_screen.ex
mix mob.install.mob_app # just generate mob_app.ex + .erl bootstrap
mix mob.install.mob_exs # just write mob.exs + .gitignore
mix mob.install.native # both native trees
mix mob.install.native.android
mix mob.install.native.ios
mix mob.install.finalize # post-install notice (no file changes)

Each accepts the same flags as `mob.install` and respects them
individually. Run `mix help mob.install.<sub>` for sub-task docs.

On-device runtime services (`Mob.ComponentRegistry`,
`Mob.NativeLogger`, etc.) start imperatively inside
`<App>.MobApp.start/0` — `Mob.App` is the *behaviour* the device
entry uses (via `use Mob.App`), never a supervision-tree child.
"""
use Igniter.Mix.Task

alias Mix.Tasks.Mob.Install.{Bridge, Deps, Finalize, MobApp, MobExs, Native, Screen}

@schema [
ios: :boolean,
android: :boolean,
local: :boolean,
python: :boolean,
host_url: :string,
live_view: :boolean
]

@defaults [ios: true, android: true, live_view: true]

@doc false
def common_schema, do: @schema
@doc false
def common_defaults, do: @defaults

@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :mob,
example: "mix mob.install --host-url https://my-app.fly.dev/",
schema: @schema,
defaults: @defaults,
composes: [
"mob.install.deps",
"mob.install.bridge",
"mob.install.screen",
"mob.install.mob_app",
"mob.install.mob_exs",
"mob.install.native",
"mob.install.finalize"
]
}
end

@impl Igniter.Mix.Task
def igniter(igniter) do
ensure_archive_path_loaded()
validate_platforms!(igniter.args.options)
argv = igniter.args.argv || []

# Sub-tasks are dispatched by module atom rather than by task-name
# string. `Igniter.compose_task/4` accepts either, but the atom form
# uses `Code.ensure_compiled!/1` directly instead of routing through
# `Mix.Task.get/1` — which is what lets this orchestrator work when
# `mob_new` is installed as a Mix archive. (Archive-resident task
# modules aren't findable through `Mix.Task.get/1`, but they ARE
# findable through the code path once we've put the archive's ebin
# on it via `ensure_archive_path_loaded/0` above.)
igniter
|> Igniter.compose_task(Deps, argv)
|> Igniter.compose_task(Bridge, argv)
|> Igniter.compose_task(Screen, argv)
|> Igniter.compose_task(MobApp, argv)
|> Igniter.compose_task(MobExs, argv)
|> Igniter.compose_task(Native, argv)
|> Igniter.compose_task(Finalize, argv)
end

defp validate_platforms!(opts) do
if Keyword.get(opts, :ios, true) == false and Keyword.get(opts, :android, true) == false do
Mix.raise("Cannot pass both --no-ios and --no-android; at least one platform must remain.")
end
end

# When `mob_new` is installed as a Mix archive, Mix loads this module
# by resolving its BEAM path directly from the archive's `.app` file
# rather than via the Erlang code path. Sibling modules in the same
# archive (the sub-installers) are therefore not findable via
# `Code.ensure_compiled/1` until we put the archive's ebin on the
# code path. `:code.which(__MODULE__)` gives us this BEAM's directory;
# `:code.add_patha/1` is idempotent (no-op when already present), so
# non-archive distributions (path/Hex dep) are unaffected.
#
# Inlined here rather than calling a `MobNew.*` helper because that
# helper would itself be archive-resident and therefore unreachable
# at the moment this fix needs to run.
defp ensure_archive_path_loaded do
case :code.which(__MODULE__) do
path when is_list(path) ->
path |> Path.dirname() |> String.to_charlist() |> :code.add_patha()
:ok

_ ->
:ok
end
end
end
124 changes: 124 additions & 0 deletions lib/mix/tasks/mob/install/bridge.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
defmodule Mix.Tasks.Mob.Install.Bridge do
@shortdoc "Installs the Mob LiveView bridge (MobHook + bridge div)"

@moduledoc """
Patches `assets/js/app.js` and `lib/<web>/components/layouts/root.html.heex`
to wire `window.mob` through a LiveView `phx-hook`. Output matches
`mix mob.new --liveview`.

Mob's native shell injects `window.mob` into every WebView for direct
JS↔native interop (camera, audio, sensors). The LV bridge patches
here *replace* that injection on mount with a LiveView-routed shim,
so `window.mob.send` goes through `pushEvent`/`handle_event` instead
of straight to native code. Useful when you want server-side BEAM
visibility into JS messages. Skip this if your project isn't using
LiveView (e.g. Hologram, vanilla controllers) — the native injection
alone is what you want.

## Options

- `--no-live-view` — skip the patches entirely with a notice. For
Hologram-only or non-Phoenix hosts. Equivalent to deleting any
accidentally-applied `MobHook` + bridge div afterward.

Other orchestrator flags (`--no-ios`, `--no-android`, `--local`,
`--python`, `--host-url`) are accepted but inert here — declared in
the schema only so `mix mob.install` can forward its full argv
without Igniter rejecting unknown options.

## Idempotency

Both `MobNew.LiveViewPatcher.inject_mob_hook/1` and
`inject_mob_bridge_element/1` short-circuit when their markers
(`MobHook` / `mob-bridge`) are already present in the file. Safe to
re-run. If a target file can't be located, a warning is emitted with
the snippet to add manually; the rest of `mix mob.install` still
completes.

Typically called by `mix mob.install`, not directly.
"""
use Igniter.Mix.Task

alias Igniter.Project.Application, as: ProjectApplication
alias MobNew.LiveViewPatcher

@common_schema [
ios: :boolean,
android: :boolean,
local: :boolean,
python: :boolean,
host_url: :string,
live_view: :boolean
]
@common_defaults [ios: true, android: true, live_view: true]

@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :mob,
example: "mix mob.install.bridge",
schema: @common_schema,
defaults: @common_defaults
}
end

@impl Igniter.Mix.Task
def igniter(igniter) do
if Keyword.get(igniter.args.options, :live_view, true) do
igniter
|> patch_app_js()
|> patch_root_html()
else
Igniter.add_notice(igniter, """
`mob.install.bridge` skipped (--no-live-view). The native shell
will inject `window.mob` directly; no LiveView hook needed.
""")
end
end

defp patch_app_js(igniter) do
path = "assets/js/app.js"

if Igniter.exists?(igniter, path) do
Igniter.update_file(igniter, path, &update_app_js/1)
else
Igniter.add_warning(igniter, """
Could not find assets/js/app.js. Add the MobHook manually:

#{LiveViewPatcher.mob_hook_js()}
""")
end
end

defp update_app_js(source) do
content = Rewrite.Source.get(source, :content)
Rewrite.Source.update(source, :content, LiveViewPatcher.inject_mob_hook(content))
end

defp patch_root_html(igniter) do
web = "#{ProjectApplication.app_name(igniter)}_web"

candidates = [
"lib/#{web}/components/layouts/root.html.heex",
"lib/#{web}/templates/layout/root.html.heex"
]

case Enum.find(candidates, &Igniter.exists?(igniter, &1)) do
nil ->
Igniter.add_warning(igniter, """
Could not find a root.html.heex layout. Add the Mob bridge element
manually inside the <body> tag:

#{LiveViewPatcher.mob_bridge_element()}
""")

path ->
Igniter.update_file(igniter, path, &update_root_html/1)
end
end

defp update_root_html(source) do
content = Rewrite.Source.get(source, :content)
Rewrite.Source.update(source, :content, LiveViewPatcher.inject_mob_bridge_element(content))
end
end
Loading