From dec3f318dada2f7ee2dd05d30fa801017679304b Mon Sep 17 00:00:00 2001 From: ken-kost Date: Fri, 22 May 2026 13:54:09 +0200 Subject: [PATCH 1/5] Add igniter dep --- mix.exs | 7 +++++++ mix.lock | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/mix.exs b/mix.exs index eb4c664..2c6db14 100644 --- a/mix.exs +++ b/mix.exs @@ -27,6 +27,13 @@ defmodule MobNew.MixProject do # migration replaced the regex-on-Elixir-source approach that had # been the main fragility in the LV generator. {:sourceror, "~> 1.0"}, + # Igniter — composable code-mod framework. Powers `mix mob.install`, + # which adds Mob to an existing Phoenix project (analogous to how + # team-alembic/phx_install adds Phoenix to a vanilla Elixir project). + # Bundled into the archive so users don't need it as a target-project + # dep. `mix mob.new --liveview` does not use Igniter — that path + # continues to use Sourceror directly via `LiveViewPatcher`. + {:igniter, "~> 0.7"}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, # ex_slop — Credo check that catches AI-generated Elixir patterns diff --git a/mix.lock b/mix.lock index 59e65c4..69d7d16 100644 --- a/mix.lock +++ b/mix.lock @@ -2,16 +2,31 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, "ex_slop": {:hex, :ex_slop, "0.4.0", "06c39628e2a278a9adeaf76047f7b98002a453b53a38b48faa3921835675c680", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "563da973e0251ebd69785a21873ea566158c95b123a5dccf075c0c687e4acc2e"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, + "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, + "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, } From 371b8af4cdd119bdfa1f01dff69770f1efe6fda3 Mon Sep 17 00:00:00 2001 From: ken-kost Date: Fri, 22 May 2026 13:55:30 +0200 Subject: [PATCH 2/5] Expose mob hook js and private functions from project generator --- lib/mob_new/live_view_patcher.ex | 112 +++++++++++++++++++++++++++++++ lib/mob_new/project_generator.ex | 33 ++++++--- 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/lib/mob_new/live_view_patcher.ex b/lib/mob_new/live_view_patcher.ex index 2152121..6292eb6 100644 --- a/lib/mob_new/live_view_patcher.ex +++ b/lib/mob_new/live_view_patcher.ex @@ -55,6 +55,9 @@ defmodule MobNew.LiveViewPatcher do @doc "Returns the hidden bridge element string (for test assertions)." def mob_bridge_element, do: @mob_bridge_element + @doc "Returns the MobHook JS string (for tests and warning messages)." + def mob_hook_js, do: @mob_hook_js + @doc """ Injects the MobHook definition and registration into the given `app.js` content. @@ -235,6 +238,115 @@ defmodule MobNew.LiveViewPatcher do """ end + @doc """ + Generates `MobScreen` content for `mix mob.install`. + + The generated module reads the WebView URL from application config: + + config :mob, host_url: "https://your-app.example.com/" + + Default if unset is `http://127.0.0.1:4000/`, suitable for on-device + BEAM hitting a local Phoenix endpoint. `mix mob.install --host-url + ` writes the config entry so the user doesn't need to edit + `config/config.exs` by hand. + + Distinct from `mob_screen_content/1` (used by `mix mob.new --liveview`, + which resolves the URL via `Mob.LiveView.local_url/1`). + """ + def mob_screen_content_install(module_name) do + """ + defmodule #{module_name}.MobScreen do + @moduledoc \"\"\" + Mob.Screen that wraps the host Phoenix app in a native WebView. + + Reads the URL from `config :mob, :host_url` (default + `http://127.0.0.1:4000/`) so the same module works for the + on-device BEAM (localhost) or a remote deployment (set + `config :mob, host_url: "https://your-app.example.com/"`). + \"\"\" + use Mob.Screen + + @default_host_url "http://127.0.0.1:4000/" + + def host_url do + Application.get_env(:mob, :host_url, @default_host_url) + end + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(_assigns) do + Mob.UI.webview( + url: host_url(), + show_url: false + ) + end + end + """ + end + + @doc """ + Generates a thin-client `.MobApp` for projects where the BEAM on + device does NOT host Phoenix/Hologram/game state — instead the WebView + points at a deployed Phoenix server and the device's BEAM is just the + native interop layer. + + Produced when `mix mob.install --no-live-view` is invoked. The thin + variant uses `use Mob.App` with `navigation/1` + `on_start/0` + callbacks (the same shape `mix mob.new` generates for native mode), + rather than the LV-flavored `def start do ... end` that boots the + host Phoenix endpoint on-device. + + See [scrawly-thin-client-mob-plan.md](scrawly-thin-client-mob-plan.md) + in this repo for the architectural context. + """ + def mob_app_content_thin(module_name, app_name) do + """ + defmodule #{module_name}.MobApp do + @moduledoc \"\"\" + Thin-client on-device BEAM entry. The native shell launches the + BEAM, this module configures DNS, opens `MobScreen` (which loads + a WebView at `config :mob, :host_url`), and starts Erlang + distribution so `mix mob.connect` can attach. + + Does NOT call `Application.ensure_all_started(:#{app_name})` — the + host's `#{module_name}.Application` belongs on the deployed server, + not on the phone. If you later decide you DO want the host app + running on-device (full on-device Phoenix), swap this for the + LiveView-flavoured `mob_app.ex` template generated by + `mix mob.install` without `--no-live-view`. + \"\"\" + + use Mob.App + + @impl Mob.App + def navigation(_platform) do + stack(:main, root: #{module_name}.MobScreen) + end + + @impl Mob.App + def on_start do + # Pure-BEAM DNS — iOS's `inet_gethost` port program is broken; + # this flips Erlang's lookup chain to `[:file, :dns]` with + # Google + Cloudflare as fallback resolvers. See + # `Mob.DNS.configure_pure_beam/1` for tuning. + Mob.DNS.configure_pure_beam() + + # Open the WebView pointed at the configured host URL. + Mob.Screen.start_root(#{module_name}.MobScreen) + + # Distribution for `mix mob.connect`. Optional; remove if you + # don't need on-device IEx. + Mob.Dist.ensure_started( + node: :"#{app_name}_android@127.0.0.1", + cookie: :mob_secret + ) + end + end + """ + end + @doc """ Generates mob.exs config content for a LiveView project. """ diff --git a/lib/mob_new/project_generator.ex b/lib/mob_new/project_generator.ex index 653aa1c..503d589 100644 --- a/lib/mob_new/project_generator.ex +++ b/lib/mob_new/project_generator.ex @@ -38,8 +38,13 @@ defmodule MobNew.ProjectGenerator do # mob/mob_dev" only, but users (rightly) expected it to also pick up # local template fixes — same mental model as `--local` everywhere # else in the Mix ecosystem. - defp templates_root(opts), do: priv_root(opts) |> Path.join("templates/mob.new") - defp static_root(opts), do: priv_root(opts) |> Path.join("static/mob.new") + @doc false + @spec templates_root(keyword()) :: String.t() + def templates_root(opts), do: priv_root(opts) |> Path.join("templates/mob.new") + + @doc false + @spec static_root(keyword()) :: String.t() + def static_root(opts), do: priv_root(opts) |> Path.join("static/mob.new") defp priv_root(opts) do case local_mob_new_priv(opts) do @@ -986,7 +991,9 @@ defmodule MobNew.ProjectGenerator do end end - defp extract_secret_key_base(project_dir) do + @doc false + @spec extract_secret_key_base(String.t()) :: String.t() | nil + def extract_secret_key_base(project_dir) do dev_exs = Path.join([project_dir, "config", "dev.exs"]) if File.exists?(dev_exs) do @@ -999,11 +1006,15 @@ defmodule MobNew.ProjectGenerator do end end - defp generate_secret_key_base do + @doc false + @spec generate_secret_key_base() :: String.t() + def generate_secret_key_base do :crypto.strong_rand_bytes(48) |> Base.encode64(padding: false) end - defp generate_signing_salt do + @doc false + @spec generate_signing_salt() :: String.t() + def generate_signing_salt do :crypto.strong_rand_bytes(8) |> Base.encode64(padding: false) end @@ -1058,7 +1069,9 @@ defmodule MobNew.ProjectGenerator do # ── Dep resolution ──────────────────────────────────────────────────────────── - defp resolve_deps(opts) do + @doc false + @spec resolve_deps(keyword()) :: {String.t(), String.t(), String.t(), String.t()} + def resolve_deps(opts) do if opts[:local] do mob_dir = resolve_local_path("MOB_DIR", "mob") mob_dev_dir = resolve_local_path("MOB_DEV_DIR", "mob_dev") @@ -1089,7 +1102,9 @@ defmodule MobNew.ProjectGenerator do end end - defp resolve_local_path(env_var, sibling_name) do + @doc false + @spec resolve_local_path(String.t(), String.t()) :: String.t() + def resolve_local_path(env_var, sibling_name) do cond do path = System.get_env(env_var) -> Path.expand(path) @@ -1165,8 +1180,10 @@ defmodule MobNew.ProjectGenerator do end) end + @doc false # Replace `app_name` placeholder in directory segments and strip .eex extension. - defp expand_path(rel, assigns) do + @spec expand_path(String.t(), map()) :: String.t() + def expand_path(rel, assigns) do rel |> String.replace("app_name", assigns.app_name) |> String.replace("java/", "java/#{assigns.java_path}/") From f8cad50753ad750370f029df72136663933c2de4 Mon Sep 17 00:00:00 2001 From: ken-kost Date: Fri, 22 May 2026 13:57:36 +0200 Subject: [PATCH 3/5] Implement mob.install and its composite tasks --- lib/mix/tasks/mob/install.ex | 174 ++++++++++++++++++++ lib/mix/tasks/mob/install/bridge.ex | 124 ++++++++++++++ lib/mix/tasks/mob/install/deps.ex | 70 ++++++++ lib/mix/tasks/mob/install/finalize.ex | 80 +++++++++ lib/mix/tasks/mob/install/mob_app.ex | 132 +++++++++++++++ lib/mix/tasks/mob/install/mob_exs.ex | 84 ++++++++++ lib/mix/tasks/mob/install/native.ex | 73 ++++++++ lib/mix/tasks/mob/install/native/android.ex | 92 +++++++++++ lib/mix/tasks/mob/install/native/ios.ex | 65 ++++++++ lib/mix/tasks/mob/install/screen.ex | 91 ++++++++++ 10 files changed, 985 insertions(+) create mode 100644 lib/mix/tasks/mob/install.ex create mode 100644 lib/mix/tasks/mob/install/bridge.ex create mode 100644 lib/mix/tasks/mob/install/deps.ex create mode 100644 lib/mix/tasks/mob/install/finalize.ex create mode 100644 lib/mix/tasks/mob/install/mob_app.ex create mode 100644 lib/mix/tasks/mob/install/mob_exs.ex create mode 100644 lib/mix/tasks/mob/install/native.ex create mode 100644 lib/mix/tasks/mob/install/native/android.ex create mode 100644 lib/mix/tasks/mob/install/native/ios.ex create mode 100644 lib/mix/tasks/mob/install/screen.ex diff --git a/lib/mix/tasks/mob/install.ex b/lib/mix/tasks/mob/install.ex new file mode 100644 index 0000000..754ea38 --- /dev/null +++ b/lib/mix/tasks/mob/install.ex @@ -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//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//mob_app.ex` + `src/.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 `
` 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.` for sub-task docs. + + On-device runtime services (`Mob.ComponentRegistry`, + `Mob.NativeLogger`, etc.) start imperatively inside + `.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 diff --git a/lib/mix/tasks/mob/install/bridge.ex b/lib/mix/tasks/mob/install/bridge.ex new file mode 100644 index 0000000..cb17da9 --- /dev/null +++ b/lib/mix/tasks/mob/install/bridge.ex @@ -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//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 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 diff --git a/lib/mix/tasks/mob/install/deps.ex b/lib/mix/tasks/mob/install/deps.ex new file mode 100644 index 0000000..72d69ca --- /dev/null +++ b/lib/mix/tasks/mob/install/deps.ex @@ -0,0 +1,70 @@ +defmodule Mix.Tasks.Mob.Install.Deps do + @shortdoc "Adds :mob and :mob_dev to the project's mix.exs" + + @moduledoc """ + Adds Mob's two deps to the host project's `mix.exs`: + + - `{:mob, "~> 0.5"}` — the framework, used at runtime. + - `{:mob_dev, "~> 0.3", only: :dev, runtime: false}` — build/deploy + Mix tasks. Dev-only. + + ## Options + + - `--local` — write `path:` deps instead of Hex version constraints, + resolved from `MOB_DIR` / `MOB_DEV_DIR` env vars (falling back to + `./mob` / `../mob`). For Mob framework contributors. + + Other orchestrator flags accepted but inert. + + ## Idempotency + + `Igniter.Project.Deps.add_dep/3` is called with `on_exists: :skip` + and short-circuits if the dep is already declared (any version). + + Typically called by `mix mob.install`, not directly. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Deps, as: ProjectDeps + alias MobNew.ProjectGenerator + + @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.deps", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + {mob_tuple, mob_dev_tuple} = dep_tuples(opts[:local] || false) + + igniter + |> ProjectDeps.add_dep(mob_tuple, on_exists: :skip) + |> ProjectDeps.add_dep(mob_dev_tuple, on_exists: :skip) + end + + defp dep_tuples(true = _local) do + mob_dir = ProjectGenerator.resolve_local_path("MOB_DIR", "mob") + mob_dev_dir = ProjectGenerator.resolve_local_path("MOB_DEV_DIR", "mob_dev") + {{:mob, [path: mob_dir]}, {:mob_dev, [path: mob_dev_dir, only: :dev, runtime: false]}} + end + + defp dep_tuples(false = _local) do + {{:mob, "~> 0.5"}, {:mob_dev, "~> 0.3", only: :dev, runtime: false}} + end +end diff --git a/lib/mix/tasks/mob/install/finalize.ex b/lib/mix/tasks/mob/install/finalize.ex new file mode 100644 index 0000000..0bf0437 --- /dev/null +++ b/lib/mix/tasks/mob/install/finalize.ex @@ -0,0 +1,80 @@ +defmodule Mix.Tasks.Mob.Install.Finalize do + @shortdoc "Prints next-steps after mob.install" + + @moduledoc """ + Emits a post-install notice with the next steps for the user. + Performs no file changes — purely informational. + + All orchestrator flags accepted but inert. (`--no-live-view` causes a + slight variation in the notice, mentioning the thin-client setup + instead of the standard LV bridge flow.) + """ + use Igniter.Mix.Task + + @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.finalize", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + live_view? = Keyword.get(igniter.args.options, :live_view, true) + host_url = igniter.args.options[:host_url] + Igniter.add_notice(igniter, notice(live_view?, host_url)) + end + + defp notice(live_view?, host_url) do + host_line = + if is_binary(host_url) and host_url != "" do + " - WebView URL set to `#{host_url}` via `config :mob, host_url:`.\n" + else + " - WebView URL defaults to `http://127.0.0.1:4000/`. Override with\n" <> + " `config :mob, host_url: \"https://your-app.example.com/\"`.\n" + end + + flavour_line = + if live_view?, + do: " - `mob_app.ex` boots the host Phoenix on-device (LiveView bridge).\n", + else: + " - `mob_app.ex` is the thin-client variant (no on-device Phoenix).\n" <> + " Deploy your Phoenix server separately; WebView opens its URL.\n" + + """ + + Mob installed. + + #{flavour_line}#{host_line} + 1. Edit mob.exs with your local paths (mob_dir, elixir_lib). + 2. Edit android/local.properties with your Android SDK path. + 3. First-time setup (icon generation, OTP runtime, signing): + + mix mob.install # one-time setup + # (different from the install you just ran — + # that's the project-side post-install task + # shipped by `mob_dev`, runs once per device) + + 4. iOS only — if targeting a physical iPhone: + + mix mob.provision # register bundle ID + provisioning profile + + 5. Deploy to device (first time builds the native APK/iOS app): + + mix mob.deploy --native + """ + end +end diff --git a/lib/mix/tasks/mob/install/mob_app.ex b/lib/mix/tasks/mob/install/mob_app.ex new file mode 100644 index 0000000..a5e060e --- /dev/null +++ b/lib/mix/tasks/mob/install/mob_app.ex @@ -0,0 +1,132 @@ +defmodule Mix.Tasks.Mob.Install.MobApp do + @shortdoc "Generates lib//mob_app.ex + src/.erl (on-device BEAM entry)" + + @moduledoc """ + Generates the on-device BEAM entry point invoked by Mob's native + shell at app launch: + + - `lib//mob_app.ex` — the entry module + - `src/.erl` — Erlang bootstrap that calls + `.MobApp.start/0` + - `mix.exs` patches: `erlc_paths: ["src"]` + `erlc_options: [:debug_info]` + so the Erlang bootstrap gets compiled + + Two flavours of `mob_app.ex`: + + - **LiveView** (default) — calls + `Application.ensure_all_started(:)` which boots the host + Phoenix endpoint, runs Ecto migrations, sets up the on-device + runtime config. `secret_key_base` is read from + `config/dev.exs` if available (so it matches the host dev + server) or freshly generated. + - **Thin client** (with `--no-live-view`) — uses `use Mob.App` with + `navigation/1` + `on_start/0` callbacks. Does NOT boot Phoenix + on-device; the WebView points at a deployed Phoenix server (set + `config :mob, host_url: ...`). The device's BEAM is just the + native interop layer. See + [scrawly-thin-client-mob-plan.md](scrawly-thin-client-mob-plan.md). + + ## Options + + - `--no-live-view` — generate the thin-client `mob_app.ex` instead + of the LiveView-flavoured one. Pairs with the `bridge` sub-task + being skipped under the same flag. + + Other orchestrator flags accepted but inert. + + ## Idempotency + + - Files are created with `on_exists: :skip`. Re-running won't + overwrite — delete first if you want to switch between LV and + thin flavours. + - `erlc_paths` / `erlc_options` injection checks string presence in + `mix.exs` before patching. + + Typically called by `mix mob.install`, not directly. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias MobNew.{LiveViewPatcher, ProjectGenerator} + + @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.mob_app", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + app_name = ProjectApplication.app_name(igniter) |> to_string() + module_name = Macro.camelize(app_name) + live_view? = Keyword.get(igniter.args.options, :live_view, true) + + mob_app_content = build_mob_app_content(live_view?, module_name, app_name) + erl_content = LiveViewPatcher.erlang_entry_content(module_name, app_name) + + igniter + |> Igniter.create_new_file("lib/#{app_name}/mob_app.ex", mob_app_content, on_exists: :skip) + |> Igniter.create_new_file("src/#{app_name}.erl", erl_content, on_exists: :skip) + |> patch_erlc_paths() + end + + defp build_mob_app_content(true = _live_view, module_name, app_name) do + secret_key_base = + ProjectGenerator.extract_secret_key_base(File.cwd!()) || + ProjectGenerator.generate_secret_key_base() + + signing_salt = ProjectGenerator.generate_signing_salt() + + LiveViewPatcher.mob_live_app_content(module_name, app_name, secret_key_base, signing_salt) + end + + defp build_mob_app_content(false = _live_view, module_name, app_name) do + LiveViewPatcher.mob_app_content_thin(module_name, app_name) + end + + # Adds erlc_paths: ["src"] and erlc_options: [:debug_info] to the host + # mix.exs def project. Text-based for resilience — keyword-list AST + # manipulation inside `def project do [...]` is fragile across Phoenix + # versions. Idempotent via String.contains? checks. + defp patch_erlc_paths(igniter) do + Igniter.update_file(igniter, "mix.exs", fn source -> + content = Rewrite.Source.get(source, :content) + Rewrite.Source.update(source, :content, inject_erlc(content)) + end) + end + + @doc false + @spec inject_erlc(String.t()) :: String.t() + def inject_erlc(content) do + content + |> maybe_inject_key("erlc_paths", ~s(erlc_paths: ["src"],)) + |> maybe_inject_key("erlc_options", ~s(erlc_options: [:debug_info],)) + end + + defp maybe_inject_key(content, key, snippet) do + if String.contains?(content, key) do + content + else + Regex.replace( + ~r/(def project do\s*\[)/, + content, + "\\1\n #{snippet}", + global: false + ) + end + end +end diff --git a/lib/mix/tasks/mob/install/mob_exs.ex b/lib/mix/tasks/mob/install/mob_exs.ex new file mode 100644 index 0000000..d7a2bd5 --- /dev/null +++ b/lib/mix/tasks/mob/install/mob_exs.ex @@ -0,0 +1,84 @@ +defmodule Mix.Tasks.Mob.Install.MobExs do + @shortdoc "Generates mob.exs and adds it to .gitignore" + + @moduledoc """ + Writes `mob.exs` (build-environment config: `mob_dir`, `elixir_lib`) + and ensures `.gitignore` ignores it. + + ## Options + + - `--local` — pre-fill `mob_dir` and `elixir_lib` from `MOB_DIR` / + `MOB_DEV_DIR` env vars (or sibling-directory fallbacks). Without + `--local` the file uses `Path.join(File.cwd!(), "deps/mob")` and + reads `MOB_ELIXIR_LIB` / `:code.lib_dir(:elixir)` at runtime. + + Other orchestrator flags accepted but inert. + + ## Idempotency + + - `mob.exs` is created with `on_exists: :skip` — re-running won't + overwrite an edited mob.exs. + - `.gitignore` patch checks for `mob.exs` before appending. + + Typically called by `mix mob.install`, not directly. + """ + use Igniter.Mix.Task + + @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.mob_exs", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + + {_mob_dep, _mob_dev_dep, mob_dir_expr, elixir_lib_expr} = + MobNew.ProjectGenerator.resolve_deps(local: opts[:local] || false) + + mob_exs_content = MobNew.LiveViewPatcher.mob_exs_content(mob_dir_expr, elixir_lib_expr) + + igniter + |> Igniter.create_new_file("mob.exs", mob_exs_content, on_exists: :skip) + |> patch_gitignore() + end + + defp patch_gitignore(igniter) do + if Igniter.exists?(igniter, ".gitignore") do + Igniter.update_file(igniter, ".gitignore", &append_mob_exs/1) + else + Igniter.create_new_file(igniter, ".gitignore", "# Mob local config\nmob.exs\n", + on_exists: :skip + ) + end + end + + defp append_mob_exs(source) do + content = Rewrite.Source.get(source, :content) + + if mob_exs_ignored?(content) do + source + else + Rewrite.Source.update(source, :content, content <> "\n# Mob local config\nmob.exs\n") + end + end + + defp mob_exs_ignored?(content) do + String.contains?(content, "\nmob.exs") or String.starts_with?(content, "mob.exs") + end +end diff --git a/lib/mix/tasks/mob/install/native.ex b/lib/mix/tasks/mob/install/native.ex new file mode 100644 index 0000000..16ad041 --- /dev/null +++ b/lib/mix/tasks/mob/install/native.ex @@ -0,0 +1,73 @@ +defmodule Mix.Tasks.Mob.Install.Native do + @shortdoc "Installs the native (Android + iOS) build trees" + + @moduledoc """ + Dispatcher — composes `mob.install.native.android` and + `mob.install.native.ios`. + + ## Options + + - `--no-android` — skip the Android tree. + - `--no-ios` — skip the iOS tree. + - `--local` — forwarded to the platform sub-installers for path-dep + resolution. + - `--python` — iOS-only: pre-configure embedded CPython via Pythonx + (forwarded to `mob.install.native.ios`). + + Other orchestrator flags accepted but inert. + + Both platforms emit by default. Useful as a standalone task for + "refresh the native trees after a template fix" workflows. + """ + use Igniter.Mix.Task + + alias Mix.Tasks.Mob.Install.Native.{Android, Ios} + + @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.native", + schema: @common_schema, + defaults: @common_defaults, + composes: ["mob.install.native.android", "mob.install.native.ios"] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + ensure_archive_path_loaded() + opts = igniter.args.options + + igniter + |> maybe_compose(Android, Keyword.get(opts, :android, true)) + |> maybe_compose(Ios, Keyword.get(opts, :ios, true)) + end + + defp maybe_compose(igniter, _module, false), do: igniter + + defp maybe_compose(igniter, module, true), + do: Igniter.compose_task(igniter, module, igniter.args.argv) + + # See `Mix.Tasks.Mob.Install.ensure_archive_path_loaded/0`. + 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 diff --git a/lib/mix/tasks/mob/install/native/android.ex b/lib/mix/tasks/mob/install/native/android.ex new file mode 100644 index 0000000..7e22472 --- /dev/null +++ b/lib/mix/tasks/mob/install/native/android.ex @@ -0,0 +1,92 @@ +defmodule Mix.Tasks.Mob.Install.Native.Android do + @shortdoc "Generates the android/ native tree from mob_new templates" + + @moduledoc """ + Walks `priv/templates/mob.new/android/**/*.eex`, renders each with the + project's assigns, and writes them via `Igniter.create_new_file/4` with + `on_exists: :skip`. Then copies the binary static tree + (`priv/static/mob.new/android/**`) via direct `File.copy!/2` since + Igniter's `Rewrite` engine assumes UTF-8 text and would corrupt the + Gradle wrapper jar and PNG icons. + + Idempotent — `on_exists: :skip` for EEx-rendered files; `File.exists?` + pre-check for binaries. + + The `gradlew` script is `chmod 0o755` after copy so it's executable. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias MobNew.ProjectGenerator + + @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.native.android", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + app_name = ProjectApplication.app_name(igniter) |> to_string() + assigns = ProjectGenerator.assigns(app_name, opts) + + igniter + |> emit_templates(assigns, opts, "android") + |> copy_static_binaries(opts, "android") + end + + @doc false + @spec emit_templates(Igniter.t(), map(), keyword(), String.t()) :: Igniter.t() + def emit_templates(igniter, assigns, opts, platform) do + t_root = ProjectGenerator.templates_root(opts) + + t_root + |> Path.join("#{platform}/**/*.eex") + |> Path.wildcard(match_dot: true) + |> Enum.reduce(igniter, fn template_path, ig -> + rel = Path.relative_to(template_path, t_root) + dest_rel = ProjectGenerator.expand_path(rel, assigns) + content = EEx.eval_file(template_path, Map.to_list(assigns)) + Igniter.create_new_file(ig, dest_rel, content, on_exists: :skip) + end) + end + + @doc false + @spec copy_static_binaries(Igniter.t(), keyword(), String.t()) :: Igniter.t() + def copy_static_binaries(igniter, opts, platform) do + s_root = ProjectGenerator.static_root(opts) + + s_root + |> Path.join("#{platform}/**/*") + |> Path.wildcard(match_dot: true) + |> Enum.reject(&File.dir?/1) + |> Enum.reduce(igniter, ©_one_binary(&1, &2, s_root)) + end + + defp copy_one_binary(src, igniter, s_root) do + rel = Path.relative_to(src, s_root) + if File.exists?(rel), do: igniter, else: do_copy_binary(src, rel, igniter) + end + + defp do_copy_binary(src, rel, igniter) do + File.mkdir_p!(Path.dirname(rel)) + File.copy!(src, rel) + if rel == "android/gradlew", do: File.chmod!(rel, 0o755) + Igniter.add_notice(igniter, "* copied binary: #{rel}") + end +end diff --git a/lib/mix/tasks/mob/install/native/ios.ex b/lib/mix/tasks/mob/install/native/ios.ex new file mode 100644 index 0000000..2086205 --- /dev/null +++ b/lib/mix/tasks/mob/install/native/ios.ex @@ -0,0 +1,65 @@ +defmodule Mix.Tasks.Mob.Install.Native.Ios do + @shortdoc "Generates the ios/ native tree from mob_new templates" + + @moduledoc """ + Walks `priv/templates/mob.new/ios/**/*.eex`, renders each, and writes via + `Igniter.create_new_file/4` with `on_exists: :skip`. Then copies binary + static iOS assets via direct `File.copy!/2`. + + Idempotent — `on_exists: :skip` for EEx-rendered files; `File.exists?` + pre-check for binaries. + + With `--python`, also applies the Pythonx wiring (`{:pythonx, ...}` dep + in `mix.exs`, generates `lib//python_paths.ex`). iOS-only — Android + Python is intentionally out of scope. Mirrors `mix mob.enable pythonx`. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias Mix.Tasks.Mob.Install.Native.Android, as: AndroidInstaller + alias MobNew.ProjectGenerator + + @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.native.ios --python", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + app_name = ProjectApplication.app_name(igniter) |> to_string() + assigns = ProjectGenerator.assigns(app_name, opts) + + igniter + |> AndroidInstaller.emit_templates(assigns, opts, "ios") + |> AndroidInstaller.copy_static_binaries(opts, "ios") + |> maybe_apply_python(opts, app_name) + end + + defp maybe_apply_python(igniter, opts, app_name) do + if opts[:python] == true do + # apply_python_patches operates on the filesystem directly (it + # predates the Igniter install path). Wrap as a side-effect noticed + # by Igniter so users see it in the output. + ProjectGenerator.apply_python_patches(File.cwd!(), app_name) + Igniter.add_notice(igniter, "* applied Pythonx wiring (mix.exs + python_paths.ex)") + else + igniter + end + end +end diff --git a/lib/mix/tasks/mob/install/screen.ex b/lib/mix/tasks/mob/install/screen.ex new file mode 100644 index 0000000..8f11e8b --- /dev/null +++ b/lib/mix/tasks/mob/install/screen.ex @@ -0,0 +1,91 @@ +defmodule Mix.Tasks.Mob.Install.Screen do + @shortdoc "Generates the MobScreen (WebView wrapper) module" + + @moduledoc """ + Generates `lib//mob_screen.ex` — the `Mob.Screen` that opens the + WebView pointed at the host Phoenix endpoint. + + The generated module reads the URL from application config: + + config :mob, host_url: "https://your-app.example.com/" + + Default if unset is `http://127.0.0.1:4000/`, suitable for on-device + BEAM hitting a local Phoenix endpoint. The screen module never has + the URL hardcoded. + + ## Options + + - `--host-url URL` — write `config :mob, host_url: URL` to + `config/config.exs`. Equivalent to editing config by hand after + install; provided as a flag so the install pipeline can be fully + declarative. No-op when not given. + + Other orchestrator flags (`--no-ios`, `--no-android`, `--local`, + `--python`, `--no-live-view`) are accepted but inert here — declared + in the schema only so `mix mob.install` can forward its full argv + to this sub-installer without Igniter rejecting unknown options. + + ## Idempotency + + - `lib//mob_screen.ex` is created with `on_exists: :skip` — if + it already exists, contents are left alone. To regenerate, delete + the file first. + - `--host-url`'s config write goes through `Igniter.Project.Config`, + which is idempotent: the key is set to the new value, or left as + is if the same value is already present. + + Typically called by `mix mob.install`, not directly. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias Igniter.Project.Config, as: ProjectConfig + alias MobNew.LiveViewPatcher + + # Common schema — every install sub-task accepts the full orchestrator + # flag set so `mix mob.install` can forward its argv unchanged. + # Sub-tasks ignore options that don't apply to them. + @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.screen --host-url https://my-app.fly.dev/", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + app_name = ProjectApplication.app_name(igniter) |> to_string() + module_name = Macro.camelize(app_name) + + igniter + |> Igniter.create_new_file( + "lib/#{app_name}/mob_screen.ex", + LiveViewPatcher.mob_screen_content_install(module_name), + on_exists: :skip + ) + |> maybe_configure_host_url() + end + + defp maybe_configure_host_url(igniter) do + case igniter.args.options[:host_url] do + url when is_binary(url) and url != "" -> + ProjectConfig.configure(igniter, "config.exs", :mob, [:host_url], url) + + _ -> + igniter + end + end +end From b7389e5ad9823c711eaa977d2a0105467129c9e5 Mon Sep 17 00:00:00 2001 From: ken-kost Date: Fri, 22 May 2026 13:57:48 +0200 Subject: [PATCH 4/5] Add tests --- .../mob_install_acceptance_test.exs | 79 +++++++++++++++++ test/mix/tasks/mob/install/bridge_test.exs | 85 +++++++++++++++++++ test/mix/tasks/mob/install/deps_test.exs | 31 +++++++ test/mix/tasks/mob/install/mob_app_test.exs | 70 +++++++++++++++ test/mix/tasks/mob/install/mob_exs_test.exs | 57 +++++++++++++ .../tasks/mob/install/native/android_test.exs | 50 +++++++++++ .../mix/tasks/mob/install/native/ios_test.exs | 26 ++++++ test/mix/tasks/mob/install/screen_test.exs | 49 +++++++++++ test/mix/tasks/mob/install_test.exs | 27 ++++++ 9 files changed, 474 insertions(+) create mode 100644 test/acceptance/mob_install_acceptance_test.exs create mode 100644 test/mix/tasks/mob/install/bridge_test.exs create mode 100644 test/mix/tasks/mob/install/deps_test.exs create mode 100644 test/mix/tasks/mob/install/mob_app_test.exs create mode 100644 test/mix/tasks/mob/install/mob_exs_test.exs create mode 100644 test/mix/tasks/mob/install/native/android_test.exs create mode 100644 test/mix/tasks/mob/install/native/ios_test.exs create mode 100644 test/mix/tasks/mob/install/screen_test.exs create mode 100644 test/mix/tasks/mob/install_test.exs diff --git a/test/acceptance/mob_install_acceptance_test.exs b/test/acceptance/mob_install_acceptance_test.exs new file mode 100644 index 0000000..e2a929d --- /dev/null +++ b/test/acceptance/mob_install_acceptance_test.exs @@ -0,0 +1,79 @@ +defmodule MobInstallAcceptanceTest do + @moduledoc """ + End-to-end check: generate a real `mix phx.new` project, run + `mix mob.install` against it via Igniter compose, assert the resulting + tree has the expected mob bits and compiles. + + Slow (60s+) — tagged `@tag :acceptance` and excluded from the default + test suite. Run with: + + mix test --only acceptance + + Requires `phx_new` archive on the system. Skipped if `mix phx.new --help` + exits non-zero. + """ + use ExUnit.Case, async: false + + @moduletag :acceptance + @moduletag timeout: 180_000 + + setup_all do + case System.cmd("mix", ["help", "phx.new"], stderr_to_stdout: true) do + {_, 0} -> :ok + _ -> {:skip, "phx.new not installed — run `mix archive.install hex phx_new`"} + end + end + + setup do + tmp = System.tmp_dir!() |> Path.join("mob_acceptance_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "mob.install against a fresh phx.new project produces a usable mob app", %{tmp: tmp} do + app_dir = Path.join(tmp, "test_mob_app") + + # Generate a minimal Phoenix project. + {_, 0} = + System.cmd( + "mix", + [ + "phx.new", + "test_mob_app", + "--no-install", + "--no-ecto", + "--no-mailer", + "--no-dashboard" + ], + cd: tmp, + stderr_to_stdout: true + ) + + # Run mob.install in the generated project. + cwd = File.cwd!() + File.cd!(app_dir) + + try do + {output, code} = + System.cmd("mix", ["mob.install", "--yes", "--no-install"], stderr_to_stdout: true) + + assert code == 0, "mob.install failed:\n#{output}" + + # mix.exs has the mob deps + mix_exs = File.read!(Path.join(app_dir, "mix.exs")) + assert mix_exs =~ ":mob" + assert mix_exs =~ ":mob_dev" + + # Bridge files exist + assert File.exists?(Path.join(app_dir, "lib/test_mob_app/mob_screen.ex")) + assert File.exists?(Path.join(app_dir, "mob.exs")) + + # Native trees emitted + assert File.exists?(Path.join(app_dir, "android/build.gradle")) + assert File.exists?(Path.join(app_dir, "ios/Info.plist")) + after + File.cd!(cwd) + end + end +end diff --git a/test/mix/tasks/mob/install/bridge_test.exs b/test/mix/tasks/mob/install/bridge_test.exs new file mode 100644 index 0000000..30c062d --- /dev/null +++ b/test/mix/tasks/mob/install/bridge_test.exs @@ -0,0 +1,85 @@ +defmodule Mix.Tasks.Mob.Install.BridgeTest do + use ExUnit.Case, async: true + + import Igniter.Test + + describe "mob.install.bridge" do + test "injects MobHook into assets/js/app.js when present" do + app_js = """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + igniter = + test_project(files: %{"assets/js/app.js" => app_js}) + |> Igniter.compose_task("mob.install.bridge") + |> apply_igniter!() + + source = Rewrite.source!(igniter.rewrite, "assets/js/app.js") + content = Rewrite.Source.get(source, :content) + assert content =~ "MobHook" + end + + test "injects bridge element into root.html.heex when present" do + root_heex = """ + + + Hello + + + """ + + igniter = + test_project(files: %{"lib/test_web/components/layouts/root.html.heex" => root_heex}) + |> Igniter.compose_task("mob.install.bridge") + |> apply_igniter!() + + source = Rewrite.source!(igniter.rewrite, "lib/test_web/components/layouts/root.html.heex") + content = Rewrite.Source.get(source, :content) + assert content =~ ~s(id="mob-bridge") + end + + test "warns when no app.js exists" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.bridge") + + assert Enum.any?(igniter.warnings, &String.contains?(&1, "MobHook")) + end + + test "--no-live-view skips all patches with a notice" do + app_js = """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + root_heex = """ + Hello + """ + + # `apply_igniter!` resets `notices` to [] during `simulate_write`, + # so we inspect the un-applied igniter directly to assert on + # notices, then materialise separately to check file content. + igniter = + test_project( + files: %{ + "assets/js/app.js" => app_js, + "lib/test_web/components/layouts/root.html.heex" => root_heex + } + ) + |> Igniter.compose_task("mob.install.bridge", ["--no-live-view"]) + + # Notice emitted. + assert Enum.any?(igniter.notices, &String.contains?(&1, "skipped (--no-live-view)")) + + # And there should be NO file changes queued for the bridge files. + app_js_source = Rewrite.source!(igniter.rewrite, "assets/js/app.js") + refute Rewrite.Source.get(app_js_source, :content) =~ "MobHook" + + heex_source = + Rewrite.source!(igniter.rewrite, "lib/test_web/components/layouts/root.html.heex") + + refute Rewrite.Source.get(heex_source, :content) =~ "mob-bridge" + end + end +end diff --git a/test/mix/tasks/mob/install/deps_test.exs b/test/mix/tasks/mob/install/deps_test.exs new file mode 100644 index 0000000..7a0560f --- /dev/null +++ b/test/mix/tasks/mob/install/deps_test.exs @@ -0,0 +1,31 @@ +defmodule Mix.Tasks.Mob.Install.DepsTest do + use ExUnit.Case, async: true + + import Igniter.Test + + describe "mob.install.deps" do + test "adds :mob and :mob_dev to mix.exs" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.deps") + |> apply_igniter!() + + source = Rewrite.source!(igniter.rewrite, "mix.exs") + content = Rewrite.Source.get(source, :content) + + assert content =~ ":mob" + assert content =~ ":mob_dev" + assert content =~ "only: :dev" + end + + test "is idempotent on a second run" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.deps") + |> apply_igniter!() + |> Igniter.compose_task("mob.install.deps") + + assert_unchanged(igniter) + end + end +end diff --git a/test/mix/tasks/mob/install/mob_app_test.exs b/test/mix/tasks/mob/install/mob_app_test.exs new file mode 100644 index 0000000..3659688 --- /dev/null +++ b/test/mix/tasks/mob/install/mob_app_test.exs @@ -0,0 +1,70 @@ +defmodule Mix.Tasks.Mob.Install.MobAppTest do + use ExUnit.Case, async: true + + import Igniter.Test + + describe "mob.install.mob_app (default — LiveView flavour)" do + test "generates LV-flavoured mob_app.ex that boots the host Phoenix app" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.mob_app") + |> apply_igniter!() + + content = + Rewrite.source!(igniter.rewrite, "lib/test/mob_app.ex") + |> Rewrite.Source.get(:content) + + assert content =~ "defmodule Test.MobApp" + assert content =~ "{:ok, _} = Application.ensure_all_started(:test)" + assert content =~ "Mob.NativeLogger.install()" + assert content =~ "Ecto.Migrator.run" + end + + test "writes src/.erl bootstrap" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.mob_app") + |> apply_igniter!() + + erl = Rewrite.source!(igniter.rewrite, "src/test.erl") |> Rewrite.Source.get(:content) + assert erl =~ "test" + end + + test "patches mix.exs with erlc_paths and erlc_options" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.mob_app") + |> apply_igniter!() + + content = Rewrite.source!(igniter.rewrite, "mix.exs") |> Rewrite.Source.get(:content) + assert content =~ ~s(erlc_paths: ["src"]) + assert content =~ "erlc_options: [:debug_info]" + end + end + + describe "mob.install.mob_app --no-live-view (thin-client flavour)" do + test "generates thin mob_app.ex using `use Mob.App` without ensure_all_started" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.mob_app", ["--no-live-view"]) + |> apply_igniter!() + + content = + Rewrite.source!(igniter.rewrite, "lib/test/mob_app.ex") + |> Rewrite.Source.get(:content) + + assert content =~ "defmodule Test.MobApp" + assert content =~ "use Mob.App" + assert content =~ "def navigation" + assert content =~ "def on_start" + assert content =~ "Mob.Screen.start_root(Test.MobScreen)" + assert content =~ "Mob.DNS.configure_pure_beam" + + # Crucially, the thin variant does NOT actually boot the host + # Phoenix app or run Ecto migrations on-device. (The docstring + # mentions both in prose, but the code body does not.) + refute content =~ "{:ok, _} = Application.ensure_all_started" + refute content =~ "Ecto.Migrator.run" + end + end +end diff --git a/test/mix/tasks/mob/install/mob_exs_test.exs b/test/mix/tasks/mob/install/mob_exs_test.exs new file mode 100644 index 0000000..2254e55 --- /dev/null +++ b/test/mix/tasks/mob/install/mob_exs_test.exs @@ -0,0 +1,57 @@ +defmodule Mix.Tasks.Mob.Install.MobExsTest do + use ExUnit.Case, async: true + + import Igniter.Test + + describe "mob.install.mob_exs" do + test "creates mob.exs" do + test_project() + |> Igniter.compose_task("mob.install.mob_exs") + |> assert_creates("mob.exs") + end + + test "mob.exs content has the expected structure" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.mob_exs") + |> apply_igniter!() + + source = Rewrite.source!(igniter.rewrite, "mob.exs") + content = Rewrite.Source.get(source, :content) + + assert content =~ "import Config" + assert content =~ "config :mob_dev" + assert content =~ "mob_dir:" + assert content =~ "elixir_lib:" + end + + test "patches .gitignore to ignore mob.exs" do + igniter = + test_project(files: %{".gitignore" => "/_build\n/deps\n"}) + |> Igniter.compose_task("mob.install.mob_exs") + |> apply_igniter!() + + # Dotfiles are filtered out by the post-apply `**/*.*` include_glob + # in `Igniter.Test.simulate_write/1`, so they only live in + # `assigns[:test_files]` after apply. Read from there. + content = igniter.assigns[:test_files][".gitignore"] + assert content =~ "mob.exs" + end + + test "is idempotent on .gitignore patches" do + base = + test_project(files: %{".gitignore" => "/_build\n/deps\n"}) + |> Igniter.compose_task("mob.install.mob_exs") + |> apply_igniter!() + + first_content = base.assigns[:test_files][".gitignore"] + + after_second = + base + |> Igniter.compose_task("mob.install.mob_exs") + |> apply_igniter!() + + assert after_second.assigns[:test_files][".gitignore"] == first_content + end + end +end diff --git a/test/mix/tasks/mob/install/native/android_test.exs b/test/mix/tasks/mob/install/native/android_test.exs new file mode 100644 index 0000000..56cd329 --- /dev/null +++ b/test/mix/tasks/mob/install/native/android_test.exs @@ -0,0 +1,50 @@ +defmodule Mix.Tasks.Mob.Install.Native.AndroidTest do + # NOT async: copy_static_binaries writes to the project CWD via File.copy! + # (see the native installer's deliberate divergence from Igniter for + # binary assets), so two test runs would race on android/gradlew. + use ExUnit.Case, async: false + + import Igniter.Test + + setup do + # Each test runs in its own temp cwd so the binary-copy side effects + # don't leak into the repo root or between tests. + cwd = File.cwd!() + + tmp = + System.tmp_dir!() |> Path.join("mob_install_native_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp) + File.cd!(tmp) + on_exit(fn -> File.cd!(cwd) end) + {:ok, tmp: tmp} + end + + describe "mob.install.native.android" do + test "creates AndroidManifest and build.gradle for the app" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.native.android") + |> apply_igniter!() + + assert Rewrite.has_source?( + igniter.rewrite, + "android/app/src/main/AndroidManifest.xml" + ) + + assert Rewrite.has_source?(igniter.rewrite, "android/app/build.gradle") + end + + test "MainActivity.kt is templated with the app name", %{tmp: _tmp} do + igniter = + test_project() + |> Igniter.compose_task("mob.install.native.android") + |> apply_igniter!() + + path = "android/app/src/main/java/com/example/test/MainActivity.kt" + source = Rewrite.source!(igniter.rewrite, path) + content = Rewrite.Source.get(source, :content) + assert content =~ "com.example.test" + end + end +end diff --git a/test/mix/tasks/mob/install/native/ios_test.exs b/test/mix/tasks/mob/install/native/ios_test.exs new file mode 100644 index 0000000..1fceb3d --- /dev/null +++ b/test/mix/tasks/mob/install/native/ios_test.exs @@ -0,0 +1,26 @@ +defmodule Mix.Tasks.Mob.Install.Native.IosTest do + use ExUnit.Case, async: false + + import Igniter.Test + + setup do + cwd = File.cwd!() + tmp = System.tmp_dir!() |> Path.join("mob_install_ios_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + File.cd!(tmp) + on_exit(fn -> File.cd!(cwd) end) + {:ok, tmp: tmp} + end + + describe "mob.install.native.ios" do + test "creates Info.plist and beam_main.m" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.native.ios") + |> apply_igniter!() + + assert Rewrite.has_source?(igniter.rewrite, "ios/Info.plist") + assert Rewrite.has_source?(igniter.rewrite, "ios/beam_main.m") + end + end +end diff --git a/test/mix/tasks/mob/install/screen_test.exs b/test/mix/tasks/mob/install/screen_test.exs new file mode 100644 index 0000000..ae3e7d3 --- /dev/null +++ b/test/mix/tasks/mob/install/screen_test.exs @@ -0,0 +1,49 @@ +defmodule Mix.Tasks.Mob.Install.ScreenTest do + use ExUnit.Case, async: true + + import Igniter.Test + + describe "mob.install.screen" do + test "creates lib//mob_screen.ex reading host URL from app config" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.screen") + |> apply_igniter!() + + source = Rewrite.source!(igniter.rewrite, "lib/test/mob_screen.ex") + content = Rewrite.Source.get(source, :content) + + assert content =~ "Test.MobScreen" + assert content =~ "Application.get_env(:mob, :host_url" + assert content =~ ~s("http://127.0.0.1:4000/") + refute content =~ "Mob.LiveView.local_url" + end + + test "is idempotent" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.screen") + |> apply_igniter!() + |> Igniter.compose_task("mob.install.screen") + + assert_unchanged(igniter) + end + + test "--host-url writes `config :mob, host_url: URL` to config/config.exs" do + igniter = + test_project() + |> Igniter.compose_task("mob.install.screen", ["--host-url", "https://my.fly.dev/"]) + |> apply_igniter!() + + # The mob_screen.ex itself remains URL-agnostic — it reads the config. + mob_screen = Rewrite.source!(igniter.rewrite, "lib/test/mob_screen.ex") + refute Rewrite.Source.get(mob_screen, :content) =~ "https://my.fly.dev/" + + # config/config.exs gets the new key. + config = Rewrite.source!(igniter.rewrite, "config/config.exs") + content = Rewrite.Source.get(config, :content) + assert content =~ "config :mob" + assert content =~ ~s(host_url: "https://my.fly.dev/") + end + end +end diff --git a/test/mix/tasks/mob/install_test.exs b/test/mix/tasks/mob/install_test.exs new file mode 100644 index 0000000..3ab4eb2 --- /dev/null +++ b/test/mix/tasks/mob/install_test.exs @@ -0,0 +1,27 @@ +defmodule Mix.Tasks.Mob.InstallTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.Install + + describe "info/2" do + test "composes the expected sub-tasks" do + info = Install.info([], nil) + + assert info.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 + + test "defaults to both platforms on" do + info = Install.info([], nil) + assert info.defaults[:ios] == true + assert info.defaults[:android] == true + end + end +end From c622878b7374e56323e74f1f76599f6a70871f9c Mon Sep 17 00:00:00 2001 From: ken-kost Date: Fri, 22 May 2026 13:58:53 +0200 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac12824..d1cd5ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.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(:)`, 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