Current section

Files

Jump to
mob_dev lib mob_dev hot_push.ex
Raw

lib/mob_dev/hot_push.ex

defmodule MobDev.HotPush do
@moduledoc """
Connects to already-running device nodes and hot-pushes BEAM modules via RPC.
Unlike `MobDev.Deployer`, this does NOT restart apps — modules are loaded
into the running BEAM in place, just like `nl/1` in IEx.
Requires apps to already be running (start with `mix mob.connect` or
`mix mob.deploy` first).
"""
alias MobDev.{Tunnel}
alias MobDev.Discovery.{Android, IOS}
@cookie :mob_secret
@doc """
Sets up adb tunnels (idempotent) and connects to all running device nodes.
Returns list of connected node atoms.
"""
@spec connect(keyword()) :: [node()]
def connect(opts \\ []) do
cookie = Keyword.get(opts, :cookie, @cookie)
nodes =
(Android.list_devices() ++ IOS.list_simulators())
|> Enum.with_index()
|> Enum.flat_map(fn {device, idx} ->
case Tunnel.setup(device, idx) do
{:ok, d} -> [d]
_ -> []
end
end)
|> Enum.flat_map(fn device ->
ensure_local_dist(cookie)
Node.set_cookie(device.node, cookie)
case Node.connect(device.node) do
true -> [device.node]
_ -> []
end
end)
nodes
end
@doc """
Pushes all compiled BEAM files from `_build/dev/lib/*/ebin/` to `nodes`.
Only pushes BEAMs for runtime dependencies — deps marked `only: :dev` or
`runtime: false` in `mix.exs` (and their transitive deps) are excluded.
This prevents dev tooling (mob_dev, Bandit, Phoenix, etc.) from being pushed
to the device when using `path:` deps during local framework development.
Returns `{pushed_count, failed_list}`.
"""
@spec push_all([node()]) :: {non_neg_integer(), list()}
def push_all(nodes) do
beams = runtime_beam_paths()
push_beams(nodes, beams)
end
@doc """
Takes a snapshot of current BEAM mtimes for runtime deps only.
Pass the result to `push_changed/2` before and after compiling to get only
the modules that actually changed.
"""
@spec snapshot_beams() :: %{String.t() => non_neg_integer()}
def snapshot_beams do
runtime_beam_paths()
|> Map.new(fn path ->
mtime =
case File.stat(path, time: :posix) do
{:ok, %{mtime: t}} -> t
_ -> 0
end
{path, mtime}
end)
end
@doc """
Pushes BEAM files that changed since `snapshot` (from `snapshot_beams/0`).
Returns `{pushed_count, failed_list}` — pushed_count is 0 if nothing changed.
"""
@spec push_changed([node()], %{String.t() => non_neg_integer()}) :: {non_neg_integer(), list()}
def push_changed(nodes, snapshot) do
beams =
runtime_beam_paths()
|> Enum.filter(fn path ->
current_mtime =
case File.stat(path, time: :posix) do
{:ok, %{mtime: t}} -> t
_ -> 0
end
current_mtime != Map.get(snapshot, path, 0)
end)
push_beams(nodes, beams)
end
# ── Runtime dep filtering ────────────────────────────────────────────────────
# Returns only BEAM paths that belong to the app's runtime dependency tree.
# Excludes deps marked only: :dev or runtime: false in mix.exs, and all of
# their transitive deps (resolved via OTP .app files).
defp runtime_beam_paths do
runtime = runtime_lib_names()
Path.wildcard("_build/dev/lib/*/ebin/*.beam")
|> Enum.filter(fn path ->
lib = path |> Path.split() |> Enum.at(-3)
MapSet.member?(runtime, lib)
end)
end
@doc """
Returns ebin directories for runtime deps only (no dev-only tooling).
Used by `Deployer` so the filesystem push matches the dist push scope.
"""
@spec runtime_beam_dirs() :: [String.t()]
def runtime_beam_dirs do
runtime = runtime_lib_names()
case File.ls("_build/dev/lib") do
{:ok, libs} ->
libs
|> Enum.filter(&MapSet.member?(runtime, &1))
|> Enum.map(&"_build/dev/lib/#{&1}/ebin")
|> Enum.filter(&File.dir?/1)
{:error, _} ->
[]
end
end
defp runtime_lib_names do
project_app = to_string(Mix.Project.config()[:app])
# Direct runtime deps: no only: :dev and not runtime: false
direct =
Mix.Project.config()
|> Keyword.get(:deps, [])
|> Enum.flat_map(&dep_runtime_name/1)
|> MapSet.new()
|> MapSet.put(project_app)
expand_runtime_libs(direct)
end
# Expand a set of lib names to include their transitive OTP deps,
# by reading each lib's .app file in _build/dev.
defp expand_runtime_libs(libs) do
new_libs =
Enum.flat_map(libs, fn lib ->
case Path.wildcard("_build/dev/lib/#{lib}/ebin/*.app") do
[app_file | _] ->
case :file.consult(String.to_charlist(app_file)) do
{:ok, [{:application, _app, props}]} ->
(props[:applications] || []) |> Enum.map(&to_string/1)
_ ->
[]
end
[] ->
[]
end
end)
|> MapSet.new()
|> MapSet.difference(libs)
if MapSet.size(new_libs) == 0 do
libs
else
expand_runtime_libs(MapSet.union(libs, new_libs))
end
end
# Returns the app name as a string if this dep is a runtime dep, else [].
defp dep_runtime_name(dep) do
{app, opts} =
case dep do
{app, _version, opts} when is_list(opts) -> {app, opts}
{app, opts} when is_list(opts) -> {app, opts}
{app, _version} -> {app, []}
app when is_atom(app) -> {app, []}
end
only = Keyword.get(opts, :only)
runtime = Keyword.get(opts, :runtime, true)
dev_only = only == :dev or only == [:dev] or (is_list(only) and only == [:dev])
if dev_only or not runtime, do: [], else: [to_string(app)]
end
# ── Private ─────────────────────────────────────────────────────────────────
defp push_beams(_nodes, []), do: {0, []}
defp push_beams(nodes, beam_files) do
results =
Enum.map(beam_files, fn path ->
module = beam_path_to_module(path)
case File.read(path) do
{:ok, binary} -> load_on_nodes(nodes, module, path, binary)
{:error, reason} -> {:error, {module, reason}}
end
end)
pushed = Enum.count(results, &match?(:ok, &1))
failed = for {:error, pair} <- results, do: pair
{pushed, failed}
end
defp load_on_nodes(nodes, module, path, binary) do
fname = String.to_charlist(path)
errors =
Enum.flat_map(nodes, fn node ->
case :rpc.call(node, :code, :load_binary, [module, fname, binary]) do
{:module, ^module} -> []
# NIF modules already loaded — safe to ignore
{:error, :on_load_failure} -> []
{:badrpc, reason} -> [{node, reason}]
{:error, reason} -> [{node, reason}]
end
end)
if errors == [], do: :ok, else: {:error, {module, errors}}
end
defp beam_path_to_module(path) do
path |> Path.basename(".beam") |> String.to_atom()
end
defp ensure_local_dist(cookie) do
unless Node.alive?() do
Node.start(:"mob_dev@127.0.0.1", :longnames)
Node.set_cookie(cookie)
end
end
end