Current section

Files

Jump to
mob_dev lib mob_dev deployer.ex
Raw

lib/mob_dev/deployer.ex

defmodule MobDev.Deployer do
@moduledoc """
Pushes compiled BEAM files from `_build/dev/lib/*/ebin/` to connected devices.
Does NOT rebuild APKs or recompile native code — that's `deploy.sh` (first-time setup).
Use this for day-to-day code iteration: edit Elixir → `mix mob.deploy` → code running.
## Transport selection
**Erlang dist (preferred)**: when a device node is already reachable via Erlang
distribution, BEAMs are hot-loaded via RPC. No restart needed — modules are
loaded in place exactly like `nl/1` in IEx.
**adb push / cp (fallback)**: when no dist connection exists (first deploy, app not
running), falls back to the traditional push-then-restart path.
## Platform behaviour
**Android**: pushes via `adb push` (requires `adb root`, i.e. emulator or debug build),
or falls back to `adb push``/data/local/tmp/``run-as tar xf` for real devices.
**iOS simulator**: copies files locally into `/tmp/otp-ios-sim/beamhello/` (no network
hop — the simulator shares the Mac filesystem).
"""
alias MobDev.Discovery.{Android, IOS}
alias MobDev.{Device, HotPush, Tunnel}
@cookie :mob_secret
@android_activity ".MainActivity"
defp app_name, do: Mix.Project.config()[:app] |> to_string()
defp bundle_id, do: MobDev.Config.bundle_id()
defp android_package, do: bundle_id()
defp android_app_data, do: "/data/data/#{android_package()}/files"
defp android_beams_dir, do: "#{android_app_data()}/otp/#{app_name()}"
defp ios_bundle_id, do: bundle_id()
defp ios_beams_dir do
# The simulator's OTP_ROOT is resolved by `MobDev.Paths.sim_runtime_dir/1`.
# New projects: ~/.mob/runtime/ios-sim. Legacy projects (build.sh predates
# MOB_SIM_RUNTIME_DIR support): /tmp/otp-ios-sim. Either way, if that
# directory exists deploy beams there so the running BEAM picks them up
# immediately. Fall back to the cache dir on a fresh machine that hasn't
# done its first --native build yet.
runtime_dir = MobDev.Paths.sim_runtime_dir()
runtime_path = Path.join(runtime_dir, app_name())
cache_path = Path.join(MobDev.OtpDownloader.ios_sim_otp_dir(), app_name())
if File.dir?(runtime_dir), do: runtime_path, else: cache_path
end
@doc """
Discovers devices, pushes BEAMs, and optionally restarts apps.
Returns `{deployed, failed, skipped}` lists of `%Device{}`.
`skipped` is the deploy-isn't-applicable case — e.g. the app
isn't installed on a device because only the other platform was
built. Distinct from `failed` (real error during push).
"""
@spec deploy_all(keyword()) :: {[Device.t()], [Device.t()], [Device.t()]}
def deploy_all(opts \\ []) do
restart = Keyword.get(opts, :restart, true)
platforms = Keyword.get(opts, :platforms, [:android, :ios])
force_fs = Keyword.get(opts, :force_fs, false)
device_id = Keyword.get(opts, :device, nil)
ios_device_id = Keyword.get(opts, :ios_device, nil)
beam_flags = Keyword.get(opts, :beam_flags, nil)
beam_dirs = collect_beam_dirs()
android =
if :android in platforms,
do:
Android.list_devices()
|> Enum.reject(&(&1.status == :unauthorized))
|> filter_by_device_id(device_id),
else: []
ios =
if :ios in platforms,
do: IOS.list_devices() |> filter_by_device_id(ios_device_id || device_id),
else: []
all = android ++ ios
if all == [] do
IO.puts(" #{color(:yellow)}No devices found.#{color(:reset)}")
{[], [], []}
else
IO.puts(" Pushing #{count_beams(beam_dirs)} BEAM file(s) to #{length(all)} device(s)...")
# Try Erlang dist first — hot-loads modules with no restart. We set up
# tunnels and attempt Node.connect for each device; those that respond
# get BEAMs via RPC, the rest fall back to adb/cp + restart.
# force_fs: true skips dist and always writes to the filesystem — required
# after a native build/install where the old BEAM process is dead.
dist_nodes = if force_fs, do: [], else: connect_dist(all)
# Manual overrides from `mix mob.deploy --dist-port N --node-suffix X`.
# When set, all targeted devices share the same port/suffix (the user
# is being explicit about a single device they care about). The
# auto-allocated per-device values (one port per index, suffix per
# serial/UDID) only apply when these are nil.
dist_port_override = Keyword.get(opts, :dist_port)
node_suffix_override = Keyword.get(opts, :node_suffix)
results =
all
|> Enum.map(fn device ->
IO.write(" #{device.name || device.serial} → pushing...")
# Serial-derived so the port a device is deployed to listen on matches
# what `mix mob.connect` later forwards to (same crc32(serial) base).
dist_port = dist_port_override || Tunnel.serial_base_port(device.serial)
node = Device.node_name(device)
{method, result} =
if node in dist_nodes do
{:dist, push_via_dist(node, device)}
else
fallback =
case device.platform do
:android ->
deploy_android(device, beam_dirs,
restart: restart,
dist_port: dist_port,
node_suffix: node_suffix_override,
beam_flags: beam_flags
)
:ios ->
deploy_ios(device, beam_dirs,
restart: restart,
dist_port: dist_port,
node_suffix: node_suffix_override,
beam_flags: beam_flags
)
end
{:adb, fallback}
end
case result do
{:ok, d} ->
suffix = if method == :dist, do: " (dist, no restart)", else: ""
IO.puts(" #{color(:green)}#{suffix}#{color(:reset)}")
{:ok, d}
{:skipped, reason} ->
# Yellow dash, not a red x — this device wasn't a target.
IO.puts(
" #{color(:yellow)}#{color(:reset)} #{color(:faint)}#{reason}#{color(:reset)}"
)
{:skipped, %{device | status: :skipped, error: reason}}
{:error, reason} ->
IO.puts(" #{color(:red)}#{color(:reset)}")
IO.puts(" #{color(:red)}#{reason}#{color(:reset)}")
{:error, %{device | status: :error, error: reason}}
end
end)
categorize_results(results)
end
end
@doc """
Bucket a per-device results list into `{deployed, failed, skipped}`.
Three outcomes:
* `:ok` — push succeeded → `deployed`
* `:skipped` — device wasn't a target (e.g. app not installed,
typical when only one platform was built) → `skipped`
* `:error` — push attempted and failed for a real reason → `failed`
Public so the categorization invariant (skipped never crosses into
failed; an unknown outcome isn't silently dropped) can be tested
independent of the hardware-dependent push pipeline.
"""
@spec categorize_results([{:ok | :skipped | :error, Device.t()}]) ::
{[Device.t()], [Device.t()], [Device.t()]}
def categorize_results(results) do
deployed = for {:ok, d} <- results, do: d
failed = for {:error, d} <- results, do: d
skipped = for {:skipped, d} <- results, do: d
{deployed, failed, skipped}
end
@doc """
True when the `adb shell pm list packages <pkg>` output indicates
`<pkg>` is installed on the device.
The check is a substring match for `package:<pkg>` because adb's
output is one `package:<name>` line per matching package — empty
output means "no match" (not "package called empty").
Public so the rule can be regression-tested without an emulator.
"""
@spec android_package_installed?(String.t(), String.t()) :: boolean()
def android_package_installed?(pm_output, package_name) when is_binary(pm_output) do
String.contains?(pm_output, "package:#{package_name}")
end
# ── Device filtering ─────────────────────────────────────────────────────────
defp filter_by_device_id(devices, nil), do: devices
defp filter_by_device_id(devices, id) do
case Enum.filter(devices, &Device.match_id?(&1, id)) do
[] ->
IO.puts(" #{color(:red)}No device matched \"#{id}\".#{color(:reset)}")
IO.puts(
" Run #{color(:cyan)}mix mob.devices#{color(:reset)} to see available device IDs."
)
[]
matched ->
matched
end
end
# ── Android ─────────────────────────────────────────────────────────────────
defp deploy_android(%Device{serial: serial} = device, beam_dirs, opts) do
restart = Keyword.get(opts, :restart, true)
dist_port = Keyword.get(opts, :dist_port, 9100)
node_suffix = Keyword.get(opts, :node_suffix)
beam_flags = Keyword.get(opts, :beam_flags, nil)
pkg = android_package()
{pm_out, _} =
System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", pkg],
stderr_to_stdout: true
)
if not android_package_installed?(pm_out, pkg) do
# NOT a failure — this device isn't a deploy target for this app.
# Returning `:skipped` lets the top-level report distinguish
# "device didn't have the app installed" (a normal multi-device
# situation when only one platform was built) from real push
# failures.
{:skipped,
"#{pkg} not installed on #{device.name || serial} (ABI mismatch or app not built for this platform)"}
else
case ensure_erts_on_device(serial, pkg) do
:ok ->
case push_beams_android(serial, beam_dirs) do
:ok ->
sync_elixir_stdlib_android(serial)
write_beam_flags_android(serial, beam_flags)
setup_exqlite_android(serial)
setup_app_priv_android(serial)
if restart,
do: restart_android(serial, dist_port: dist_port, node_suffix: node_suffix)
{:ok, device}
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
end
# Verify the OTP runtime (erts-X.Y/bin/erl_child_setup) is present on
# the device. Without this, the BEAM can't start — symlinks fail with
# ENOENT, the app crashes immediately. This typically happens when the
# device wasn't connected during a previous `mix mob.deploy --native`.
#
# Returns :ok if ERTS is present, {:error, message} with a helpful hint
# if missing.
defp ensure_erts_on_device(serial, pkg) do
# The wildcard must be expanded *inside* the run-as sandbox — `run-as`
# itself does not invoke a shell, and the outer adb-shell shell can't
# see /data/data/<pkg>/, so a literal "erts-*" gets passed to ls if we
# don't wrap with `sh -c` here.
cmd =
"run-as #{pkg} sh -c 'ls /data/data/#{pkg}/files/otp/erts-*/bin/erl_child_setup' 2>&1"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, out} ->
if String.contains?(out, "No such file") or String.contains?(out, "not found") do
{:error, erts_missing_message(serial, pkg)}
else
:ok
end
_ ->
# adb shell failed entirely — let the deploy proceed and fail later
# if needed; this check is best-effort.
:ok
end
end
defp erts_missing_message(serial, pkg) do
"""
OTP runtime missing on device #{serial}.
The app is installed, but /data/data/#{pkg}/files/otp/erts-*/bin/ is
empty. Without ERTS the BEAM can't start (you'll see "symlink
erl_child_setup failed: No such file or directory" in logcat).
This usually means the device wasn't connected during a previous
`mix mob.deploy --native`. Provision it now:
mix mob.deploy --native --device #{serial}
That rebuilds the APK and pushes the right OTP for this device's ABI.
Subsequent `mix mob.deploy` runs (without --native) will work normally.
"""
end
# If the Elixir stdlib on the device was installed by a different Elixir version
# than the host (e.g. after `asdf` upgrade), regex literals and other stdlib
# internals will be incompatible. Detect the mismatch and push updated BEAMs.
defp sync_elixir_stdlib_android(serial) do
host_vsn = System.version()
pkg = android_package()
app_data = android_app_data()
elixir_app = "#{app_data}/otp/lib/elixir/ebin/elixir.app"
device_vsn =
case run_adb(["-s", serial, "shell", "run-as #{pkg} cat #{elixir_app}"]) do
{:ok, content} -> MobDev.AppFile.vsn_from_content(content)
_ -> nil
end
if device_vsn != host_vsn do
Mix.shell().info([
:yellow,
"* Elixir version mismatch (device: #{device_vsn || "unknown"}, host: #{host_vsn}) — syncing stdlib...",
:reset
])
elixir_lib = :code.lib_dir(:elixir) |> to_string() |> Path.dirname()
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} when is_binary(out) ->
if out =~ "restarting" or out =~ "already running as root" do
:timer.sleep(600)
true
else
false
end
_ ->
false
end
if rooted? do
Enum.each([:elixir, :logger, :eex], fn app ->
src = Path.join(elixir_lib, "#{app}/ebin")
dst = "#{app_data}/otp/lib/#{app}/ebin"
if File.dir?(src) do
run_adb(["-s", serial, "shell", "mkdir -p #{dst}"])
run_adb(["-s", serial, "push", "#{src}/.", "#{dst}/"])
end
end)
else
sync_elixir_stdlib_android_runas(serial, pkg, app_data, elixir_lib)
end
Mix.shell().info([:green, "* Elixir stdlib synced to #{host_vsn}", :reset])
end
end
# Non-rooted path: stage elixir/logger/eex ebin into a tar on /data/local/tmp,
# then extract into the app sandbox via `run-as`. Files created by run-as are
# owned by the app user so they can be overwritten on the next sync.
defp sync_elixir_stdlib_android_runas(serial, pkg, app_data, elixir_lib) do
stage_local = Path.join(System.tmp_dir!(), "mob_elixir_#{serial}.tar")
stage_device = "/data/local/tmp/mob_elixir.tar"
try do
tmp = Path.join(System.tmp_dir!(), "mob_elixir_stage_#{serial}")
File.rm_rf!(tmp)
for app <- [:elixir, :logger, :eex] do
src = Path.join(elixir_lib, "#{app}/ebin")
if File.dir?(src) do
dst = Path.join(tmp, "#{app}/ebin")
File.mkdir_p!(dst)
System.cmd("cp", ["-r", "#{src}/.", dst], stderr_to_stdout: true)
end
end
System.cmd("tar", ["cf", stage_local, "-C", tmp, "."],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
)
run_adb(["-s", serial, "push", stage_local, stage_device])
# Extract relative to otp/lib/ so elixir/ebin, logger/ebin, eex/ebin land correctly.
cmd =
"run-as #{pkg} tar xf #{stage_device} -C #{app_data}/otp/lib 2>/dev/null; true"
run_adb(["-s", serial, "shell", cmd])
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
after
File.rm(stage_local)
File.rm_rf(Path.join(System.tmp_dir!(), "mob_elixir_stage_#{serial}"))
end
end
defp write_beam_flags_android(_serial, nil), do: :ok
defp write_beam_flags_android(serial, flags) do
beams_dir = android_beams_dir()
tmp = Path.join(System.tmp_dir!(), "mob_beam_flags_#{serial}")
File.write!(tmp, flags)
case System.cmd(
"adb",
["-s", serial, "shell", "run-as", android_package(), "test", "-d", beams_dir],
stderr_to_stdout: true
) do
{_, 0} ->
System.cmd("adb", ["-s", serial, "push", tmp, "#{beams_dir}/mob_beam_flags"],
stderr_to_stdout: true
)
_ ->
:ok
end
File.rm(tmp)
:ok
end
# Ensure exqlite lives in $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv} so that
# the OTP boot-time lib scan registers a correct lib_dir for the application.
# Without this, code:lib_dir(:exqlite) returns {:error, :bad_name} and exqlite's
# NIF on_load callback (which calls :code.priv_dir(:exqlite)) fails.
# mob_beam.c creates the sqlite3_nif.so symlink in priv/ at runtime (it knows
# the APK-hash-dependent nativeLibraryDir; we don't at deploy time).
defp setup_exqlite_android(serial) do
with vsn when is_binary(vsn) <- exqlite_version(),
exqlite_ebin when exqlite_ebin != nil <-
Path.wildcard("_build/dev/lib/exqlite/ebin") |> List.first() do
app_data = android_app_data()
exqlite_lib = "#{app_data}/otp/lib/exqlite-#{vsn}"
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
pkg = android_package()
:timer.sleep(600)
run_adb(["-s", serial, "shell", "mkdir -p #{exqlite_lib}/ebin #{exqlite_lib}/priv"])
run_adb(["-s", serial, "push", "#{Path.expand(exqlite_ebin)}/.", "#{exqlite_lib}/ebin/"])
# Read label from cache/ (has full s0:cXXX,cYYY MCS categories on Android 15),
# not files/ which carries a bare s0 label.
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{pkg}/cache) #{app_data}/otp/lib/exqlite-#{vsn}"
])
create_exqlite_nif_symlink(serial, exqlite_lib, :rooted)
else
push_exqlite_runas(serial, exqlite_ebin, exqlite_lib)
end
else
# exqlite not present or version unknown — skip silently
_ -> :ok
end
end
# Push the app's priv/ directory to {beams_dir}/priv/ on the device so that
# migration .exs files are available at runtime.
#
# WHY THIS IS NECESSARY
#
# Ecto.Migrator locates migration files via :code.priv_dir(app), which looks
# up the app's OTP lib directory ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps
# are deployed as flat .beam files in a -pa directory — there is no versioned
# lib structure — so :code.priv_dir/1 returns {error, bad_name}. When that
# happens Ecto.Migrator.run silently finds zero migrations and logs "Migrations
# already up" without creating any tables.
#
# The fix has two parts:
# 1. This function pushes priv/ to {beams_dir}/priv/ on the device.
# 2. mob_beam.c sets MOB_BEAMS_DIR=beams_dir before erl_start so app code
# can call Ecto.Migrator.run(repo, beams_dir <> "/priv/repo/migrations", ...)
# with an explicit path instead of relying on :code.priv_dir/1.
#
# PERMISSION TRAP: chmod -R 755 is not optional.
#
# `mkdir -p` executed via `adb root` shell creates directories owned by
# system:system with mode drwxrwx--x (owner=rwx, group=rwx, other=--x).
# The BEAM process runs as the app user (u0_a0), which is "other" relative to
# system:system, so it gets only --x (traverse, no read). Path.wildcard calls
# opendir(3) on the directory, which requires read permission (r bit). Without
# it, wildcard returns [] even though the .exs file is right there — and Ecto
# again logs "Migrations already up". chmod -R 755 gives world-readable
# directories (r-x for other) while keeping files at their pushed permissions.
defp setup_app_priv_android(serial) do
local_priv = Path.join(File.cwd!(), "priv")
if File.dir?(local_priv) do
device_priv = "#{android_beams_dir()}/priv"
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
:timer.sleep(600)
run_adb(["-s", serial, "shell", "mkdir -p #{device_priv}"])
run_adb(["-s", serial, "push", "#{Path.expand(local_priv)}/.", "#{device_priv}/"])
# Make directories world-readable. mkdir as root creates them system:system
# drwxrwx--x; the app process (other) gets only --x → Path.wildcard returns
# [] → migrations silently skipped. See comment above for the full story.
run_adb(["-s", serial, "shell", "chmod -R 755 #{device_priv}"])
# Fix SELinux MCS categories so the app can actually open the files.
# Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15).
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_beams_dir()}"
])
else
push_priv_android_runas(serial, local_priv, device_priv)
end
end
:ok
end
# Non-rooted path: stage priv/ into a tar on /data/local/tmp (world-writable),
# then extract into the app sandbox via `run-as`. Files created by run-as are
# owned by the app user (u0_a0) so no chmod is needed — the app can read its
# own files without any extra permission fixup.
defp push_priv_android_runas(serial, local_priv, device_priv) do
stage_local = Path.join(System.tmp_dir!(), "mob_priv_#{serial}.tar")
stage_device = "/data/local/tmp/mob_priv.tar"
try do
# Tar with priv/ as the top-level entry; extract relative to beams_dir so
# the result lands at {beams_dir}/priv/repo/migrations/... etc.
case System.cmd(
"tar",
["cf", stage_local, "-C", Path.dirname(local_priv), Path.basename(local_priv)],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "tar create failed: #{out}"})
end
case run_adb(["-s", serial, "push", stage_local, stage_device]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "adb push failed: #{r}"})
end
run_adb(["-s", serial, "shell", "run-as #{android_package()} mkdir -p #{device_priv}"])
cmd =
"run-as #{android_package()} tar xf #{stage_device} -C #{android_beams_dir()} 2>/dev/null; true"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "run-as tar failed: #{r}"})
end
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
catch
{:error, reason} ->
IO.puts(" (warning: priv push failed: #{reason})")
after
File.rm(stage_local)
end
end
defp push_exqlite_runas(serial, exqlite_ebin, exqlite_lib) do
stage_local = Path.join(System.tmp_dir!(), "mob_exqlite_#{serial}.tar")
stage_device = "/data/local/tmp/mob_exqlite.tar"
tmp = Path.join(System.tmp_dir!(), "mob_exqlite_stage_#{serial}")
try do
File.rm_rf!(tmp)
File.mkdir_p!(Path.join(tmp, "ebin"))
File.mkdir_p!(Path.join(tmp, "priv"))
System.cmd("cp", ["-r", "#{exqlite_ebin}/.", Path.join(tmp, "ebin")],
stderr_to_stdout: true
)
# Tar with ebin/ and priv/ as top-level entries; extract to exqlite_lib/.
case System.cmd("tar", ["cf", stage_local, "-C", tmp, "."],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "tar create failed: #{out}"})
end
case run_adb(["-s", serial, "push", stage_local, stage_device]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "adb push failed: #{r}"})
end
cmd =
"run-as #{android_package()} mkdir -p #{exqlite_lib}/ebin #{exqlite_lib}/priv && " <>
"run-as #{android_package()} tar xf #{stage_device} -C #{exqlite_lib}/ 2>/dev/null; true"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "run-as tar failed: #{r}"})
end
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
create_exqlite_nif_symlink(serial, exqlite_lib, :runas)
:ok
catch
{:error, reason} ->
IO.puts(" (warning: exqlite lib setup failed: #{reason})")
:ok
after
File.rm(stage_local)
File.rm_rf(tmp)
end
end
# Create sqlite3_nif.so symlink in the exqlite priv dir pointing at the APK's
# native lib. Uses `pm path` to locate the APK (its parent dir contains lib/arm64/).
# On non-rooted devices we use `run-as` since the priv dir is in the app sandbox.
defp create_exqlite_nif_symlink(serial, exqlite_lib, mode) do
case run_adb(["-s", serial, "shell", "pm path #{android_package()}"]) do
{:ok, path_out} ->
# pm path can return multiple lines (split APKs: base + config.*); they
# all live under the same install dir, so take the first.
apk_path =
path_out
|> String.split("\n", trim: true)
|> List.first("")
|> String.trim()
|> String.replace_prefix("package:", "")
apk_dir = Path.dirname(apk_path)
case resolve_sqlite_nif_target(serial, apk_dir) do
nil ->
IO.puts(
" (warning: libsqlite3_nif.so not found under #{apk_dir}/lib — " <>
"exqlite NIF symlink skipped)"
)
nif_target ->
nif_link = "#{exqlite_lib}/priv/sqlite3_nif.so"
cmd =
case mode do
:runas -> "run-as #{android_package()} ln -sf #{nif_target} #{nif_link}"
:rooted -> "ln -sf #{nif_target} #{nif_link}"
end
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, e} -> IO.puts(" (warning: exqlite NIF symlink failed: #{e})")
end
end
_ ->
IO.puts(" (warning: pm path failed — exqlite NIF symlink skipped)")
end
end
# The native lib lands under `lib/<abi>/` — `arm64-v8a` → "arm64",
# `armeabi-v7a` → "arm". Android extracts only the device's active ABI, so a
# glob matches exactly one file. Probe for it rather than assuming 64-bit, so
# 32-bit devices (older / low-end phones) get a real target instead of a
# dangling `lib/arm64` symlink (which left exqlite `:nif_not_loaded` and
# crashed boot). Returns the absolute path or nil.
@doc false
@spec __sqlite_nif_target__([String.t()]) :: String.t() | nil
def __sqlite_nif_target__(ls_lines) do
ls_lines
|> Enum.map(&String.trim/1)
|> Enum.find(&String.ends_with?(&1, "/libsqlite3_nif.so"))
end
defp resolve_sqlite_nif_target(serial, apk_dir) do
case run_adb(["-s", serial, "shell", "ls #{apk_dir}/lib/*/libsqlite3_nif.so 2>/dev/null"]) do
{:ok, out} -> __sqlite_nif_target__(String.split(out, "\n", trim: true))
_ -> nil
end
end
defp exqlite_version, do: MobDev.AppFile.dep_version(:exqlite)
defp push_beams_android(serial, beam_dirs) do
# Try adb root first (works on emulators and eng builds).
# Check the output text — non-rooted devices return exit 0 with
# "cannot run as root in production builds".
rooted? =
case run_adb(["-s", serial, "root"]) do
{:ok, out} -> out =~ "restarting" or out =~ "already running as root"
_ -> false
end
if rooted? do
:timer.sleep(600)
run_adb(["-s", serial, "shell", "mkdir -p #{android_beams_dir()}"])
result =
Enum.reduce_while(beam_dirs, :ok, fn dir, _ ->
case run_adb(["-s", serial, "push", "#{Path.expand(dir)}/.", "#{android_beams_dir()}/"]) do
{:ok, _} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, "push failed: #{reason}"}}
end
end)
# Fix SELinux MCS categories on pushed files. adb push (as root) labels
# files with root's categories; restorecon only fixes the type, not MCS.
# Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15).
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_app_data()}/otp"
])
result
else
# Fall back to run-as tar (non-rooted physical devices).
push_beams_android_runas(serial, beam_dirs)
end
end
defp push_beams_android_runas(serial, beam_dirs) do
stage_local = System.tmp_dir!() |> Path.join("mob_beams_#{serial}.tar")
stage_device = "/data/local/tmp/mob_beams.tar"
try do
tmp = Path.join(System.tmp_dir!(), "mob_beam_stage_#{serial}")
File.rm_rf!(tmp)
File.mkdir_p!(tmp)
Enum.each(beam_dirs, fn dir ->
System.cmd("cp", ["-r", "#{dir}/.", tmp], stderr_to_stdout: true)
end)
# Archive from inside tmp so there is no top-level wrapper directory.
# BusyBox/Toybox tar (Android ≤11) does not support --strip-components, so
# we avoid needing it by using `tar cf ... -C tmp .`.
# COPYFILE_DISABLE=1 prevents macOS from adding ._<file> AppleDouble
# sidecars into the archive.
case System.cmd("tar", ["cf", stage_local, "-C", tmp, "."],
env: [{"COPYFILE_DISABLE", "1"}],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "tar create failed: #{out}"})
end
case run_adb(["-s", serial, "push", stage_local, stage_device]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "adb push failed: #{r}"})
end
run_adb([
"-s",
serial,
"shell",
"run-as #{android_package()} mkdir -p #{android_beams_dir()}"
])
# Redirect stderr and always exit 0: Android's Toybox tar cannot chown to
# macOS UID 501 and exits 1, but the files are extracted correctly.
cmd =
"run-as #{android_package()} tar xf #{stage_device} -C #{android_beams_dir()}/ 2>/dev/null; true"
case run_adb(["-s", serial, "shell", cmd]) do
{:ok, _} -> :ok
{:error, r} -> throw({:error, "run-as tar failed: #{r}"})
end
run_adb(["-s", serial, "shell", "rm -f #{stage_device}"])
:ok
catch
{:error, reason} -> {:error, reason}
after
File.rm(stage_local)
end
end
defp restart_android(serial, opts) do
dist_port = Keyword.get(opts, :dist_port, 9100)
node_suffix = Keyword.get(opts, :node_suffix) || Android.device_node_suffix(serial)
run_adb(["-s", serial, "shell", "am", "force-stop", android_package()])
# Heal SELinux MCS category mismatch before start — APK reinstall changes
# the app's category but leaves OTP files with stale labels.
# Read label from cache/ (full s0:cXXX,cYYY) not files/ (bare s0 on Android 15).
run_adb([
"-s",
serial,
"shell",
"chcon -hR $(stat -c %C /data/data/#{android_package()}/cache) #{android_app_data()}/otp"
])
:timer.sleep(300)
run_adb([
"-s",
serial,
"shell",
"am",
"start",
"-n",
"#{android_package()}/#{@android_activity}",
"--ei",
"mob_dist_port",
to_string(dist_port),
"--es",
"mob_node_suffix",
node_suffix
])
:ok
end
# ── iOS ─────────────────────────────────────────────────────────────────────
defp deploy_ios(%Device{type: :physical} = device, beam_dirs, opts) do
deploy_ios_physical(device, beam_dirs, opts)
end
defp deploy_ios(device, beam_dirs, opts) do
deploy_ios_simulator(device, beam_dirs, opts)
end
defp deploy_ios_simulator(%Device{serial: udid} = device, beam_dirs, opts) do
restart = Keyword.get(opts, :restart, true)
dist_port = Keyword.get(opts, :dist_port, 9100)
beam_flags = Keyword.get(opts, :beam_flags, nil)
try do
File.mkdir_p!(ios_beams_dir())
# Use rsync rather than cp -r for two reasons that hit Nix users hard:
#
# 1. macOS BSD `cp` preserves source mode in practice (despite what the
# man page implies). Sources in /nix/store are mode 444, so cp leaves
# 444 files in our managed runtime dir; the next deploy then trips on
# `cp: cannot create regular file ... Permission denied` when trying
# to overwrite them.
#
# 2. cp -r unconditionally overwrites every file, even when nothing has
# changed. rsync's mtime+size check skips identical files, so a
# no-op deploy after a previous successful one is genuinely a no-op.
#
# `--no-perms` keeps existing destination permissions untouched and uses
# the user's umask for newly-created files (so we don't propagate Nix's
# 444 mode). rsync's atomic-rename writer can also overwrite a 444
# destination cleanly without needing a chmod first.
Enum.each(beam_dirs, fn dir ->
abs_dir = Path.expand(dir)
case System.cmd(
"rsync",
["-a", "--no-perms", "#{abs_dir}/", "#{ios_beams_dir()}/"],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "rsync failed: #{out}"})
end
end)
# Push priv/ alongside the BEAMs so migrations and other priv assets are
# available at runtime. On iOS, beams_dir = $RUNTIME_DIR/APP_NAME and
# MOB_DATA_DIR = the app's Documents directory — these are two different
# paths, so we can't derive beams_dir from MOB_DATA_DIR. mob_beam.m sets
# MOB_BEAMS_DIR=beams_dir explicitly so app code always knows where to look.
local_priv = Path.join(File.cwd!(), "priv")
if File.dir?(local_priv) do
priv_dest = Path.join(ios_beams_dir(), "priv")
File.mkdir_p!(priv_dest)
case System.cmd(
"rsync",
["-a", "--no-perms", "#{Path.expand(local_priv)}/", "#{priv_dest}/"],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> IO.puts(" (warning: iOS priv push failed: #{out})")
end
end
if beam_flags do
File.write!(Path.join(ios_beams_dir(), "mob_beam_flags"), beam_flags)
end
if restart do
IOS.terminate_app(udid, ios_bundle_id())
:timer.sleep(300)
# node_suffix nil → IOS.launch_app omits SIMCTL_CHILD_MOB_NODE_SUFFIX
# → mob_beam.m auto-derives from SIMULATOR_UDID. Pass explicit when
# `mix mob.deploy --node-suffix ...` was used.
node_suffix = Keyword.get(opts, :node_suffix)
IOS.launch_app(udid, ios_bundle_id(), dist_port: dist_port, node_suffix: node_suffix)
end
{:ok, device}
catch
{:error, reason} -> {:error, reason}
end
end
# Physical iOS deploy: push BEAMs into the app's Documents container via
# `xcrun devicectl`. mob_beam.m (MOB_BUNDLE_OTP build) checks
# Documents/otp/<app>/ at startup and prefers it over the read-only in-bundle
# copy, enabling fast deploys without a full Xcode rebuild.
#
# The merged staging dir is named <app> so that devicectl's directory-copy
# semantics land the files at Documents/otp/<app>/ on device.
defp deploy_ios_physical(%Device{serial: udid} = device, beam_dirs, opts) do
restart = Keyword.get(opts, :restart, true)
beam_flags = Keyword.get(opts, :beam_flags, nil)
bundle = ios_bundle_id()
app = app_name()
# When discovered via WiFi-only EPMD scan the serial is the IP address, which
# xcrun devicectl does not accept as a --device argument. Resolve to a hardware
# UDID before proceeding.
udid = resolve_ios_udid_if_ip(udid)
if Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) do
throw(
{:error,
"device only reachable via WiFi (#{udid}) — use `mix mob.push` for BEAM-only updates, or connect via USB for a native deploy"}
)
end
# Stage all BEAMs (and priv/) into a temp dir named <app>.
staging_parent =
Path.join(System.tmp_dir!(), "mob_ios_deploy_#{:erlang.unique_integer([:positive])}")
staging_dir = Path.join(staging_parent, app)
File.mkdir_p!(staging_dir)
try do
Enum.each(beam_dirs, fn dir ->
case System.cmd("cp", ["-r", "#{Path.expand(dir)}/.", staging_dir],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> throw({:error, "cp failed: #{out}"})
end
end)
local_priv = Path.join(File.cwd!(), "priv")
if File.dir?(local_priv) do
priv_dest = Path.join(staging_dir, "priv")
File.mkdir_p!(priv_dest)
case System.cmd("cp", ["-r", "#{Path.expand(local_priv)}/.", priv_dest],
stderr_to_stdout: true
) do
{_, 0} -> :ok
{out, _} -> IO.puts(" (warning: priv copy failed: #{out})")
end
end
if beam_flags do
File.write!(Path.join(staging_dir, "mob_beam_flags"), beam_flags)
end
# devicectl copies the contents of --source into --destination.
# To land BEAMs at Documents/otp/<app>/, the destination must include
# the app subdirectory explicitly (staging_dir naming alone is not enough).
case System.cmd(
"xcrun",
[
"devicectl",
"device",
"copy",
"to",
"--device",
udid,
"--domain-type",
"appDataContainer",
"--domain-identifier",
bundle,
"--source",
staging_dir,
"--destination",
"Documents/otp/#{app}"
],
stderr_to_stdout: true
) do
{_, 0} ->
:ok
{out, _} ->
reason =
if String.contains?(out, "ContainerLookupErrorDomain") do
"""
App '#{bundle}' is not installed on this device.
To fix this, you need to build and install the app on the device first.
The easiest way is to open the ios/ directory in Xcode and run on device:
open ios/*.xcodeproj (or ios/*.xcworkspace)
Then select your device in Xcode and press Run (⌘R).
Alternatively, if you have another app with a different bundle ID already
installed on the device, update bundle_id in mob.exs to match it:
config :mob_dev, bundle_id: "com.yourcompany.yourapp"
"""
else
"devicectl copy failed: #{out}"
end
throw({:error, reason})
end
if restart, do: IOS.restart_app_physical(udid, bundle)
{:ok, device}
catch
{:error, reason} -> {:error, reason}
after
File.rm_rf!(staging_parent)
end
end
# ── iOS WiFi UDID resolution ──────────────────────────────────────────────────
# When a physical device was discovered only via LAN EPMD scan (no USB), its
# serial is the IP address. xcrun devicectl requires a hardware UDID or
# CoreDevice UUID for --device. This function resolves an IP to a UDID.
defp resolve_ios_udid_if_ip(udid) do
if Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) do
resolve_udid_from_ip(udid) || udid
else
udid
end
end
defp resolve_udid_from_ip(ip) do
# Strategy 1: idevice_id -n lists network-connected device UDIDs.
# If exactly one is found, it must be the WiFi-only device we're targeting.
# Strategy 2: xcrun devicectl list devices, subtract known USB devices.
from_idevice_id_network(ip) ||
from_devicectl_list(ip)
end
defp from_idevice_id_network(ip) do
with true <- not is_nil(System.find_executable("idevice_id")),
{out, 0} <- System.cmd("idevice_id", ["-n"], stderr_to_stdout: true),
udids <-
out |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")),
true <- udids != [] do
# With a single network device, return it immediately without querying its IP.
# With multiple, use ideviceinfo to find which UDID has the target IP.
case udids do
[single] ->
single
many ->
Enum.find_value(many, fn udid ->
case System.cmd("ideviceinfo", ["--network", "-u", udid, "-k", "IPAddress"],
stderr_to_stdout: true
) do
{ip_out, 0} ->
if String.trim(ip_out) == ip, do: udid, else: nil
_ ->
nil
end
end)
end
else
_ -> nil
end
end
defp from_devicectl_list(_ip) do
usb_udids =
if System.find_executable("idevice_id") do
case System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do
{out, 0} ->
out
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> MapSet.new()
_ ->
MapSet.new()
end
else
MapSet.new()
end
with {json, 0} <-
System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", "-"],
stderr_to_stdout: true
),
{:ok, data} <- Jason.decode(json),
devices <- get_in(data, ["result", "devices"]) || [],
wifi_only <-
Enum.reject(devices, fn d ->
hw_udid = get_in(d, ["hardwareProperties", "udid"]) || ""
MapSet.member?(usb_udids, hw_udid)
end),
[device] <- wifi_only,
udid when not is_nil(udid) <- get_in(device, ["hardwareProperties", "udid"]) do
udid
else
_ -> nil
end
end
# ── Dist push ────────────────────────────────────────────────────────────────
# Try to connect via Erlang dist to each discovered device. Returns a list of
# connected node atoms. Devices that don't respond are left for the adb fallback.
defp connect_dist(devices) do
ensure_local_dist()
Enum.flat_map(devices, fn device ->
node = Device.node_name(device)
Node.set_cookie(node, @cookie)
if Node.connect(node), do: [node], else: []
end)
rescue
_ -> []
end
defp ensure_local_dist do
unless Node.alive?() do
Node.start(:"mob_dev@127.0.0.1", :longnames)
Node.set_cookie(@cookie)
end
end
# Push all compiled BEAMs to a single dist-connected node, then trigger
# a re-render of the currently displayed screen so the user sees the changes
# immediately without a full restart.
#
# WHY THE RE-RENDER MESSAGE IS NECESSARY
#
# Erlang hot code loading (`code:load_binary`) replaces the module in the
# code server but does NOT cause running processes to re-execute. A
# Mob.Screen GenServer that is already mounted and displaying will continue
# to sit in its receive loop waiting for the next message. Until something
# sends it a message, `render/1` never runs again — so the user sees the
# old UI even though the new code is live in memory.
#
# The fix: immediately after the push, RPC-send `:__mob_hot_reload__` to the
# `:mob_screen` registered process on the device. Mob.Screen's handle_info
# catch-all receives it, delegates to the user module's handle_info (which
# ignores unknown messages), then calls do_render/2 using the now-current
# version of the screen module. The screen repaints with the new code, with
# no restart and no loss of GenServer state.
#
# This is why `mix mob.deploy` appeared to do nothing before this fix — the
# code WAS pushed correctly, the screen just had no trigger to repaint.
defp push_via_dist(node, device) do
{_pushed, failed} = HotPush.push_all([node])
if failed == [] do
# Best-effort: ignored if no screen is currently registered (nav edge
# cases, app in background, etc.).
:rpc.call(node, :erlang, :send, [:mob_screen, :__mob_hot_reload__])
{:ok, device}
else
mods = Enum.map_join(failed, ", ", fn {mod, _} -> inspect(mod) end)
{:error, "dist push failed for: #{mods}"}
end
end
# ── Helpers ──────────────────────────────────────────────────────────────────
defp collect_beam_dirs do
# Use the same runtime-dep filter as HotPush so we don't push dev-only
# tooling (mob_dev, credo, etc.) to the device filesystem.
app_dirs = HotPush.runtime_beam_dirs()
# EEx is part of the Elixir stdlib but not in _build/dev/lib/. Ecto depends
# on it, so include it in every push so it lands in the flat beams_dir
# (which is already on the -pa code path on both Android and iOS).
eex_ebin = Path.join(to_string(:code.lib_dir(:eex)), "ebin")
stdlib_dirs = if File.dir?(eex_ebin), do: [eex_ebin], else: []
# ssl is a required OTP app (thousand_island lists it as a dependency) but the
# iOS and Android OTP builds omit it. ssl is pure Erlang (no NIFs), so host
# BEAM files run identically on both targets. For HTTP-only Phoenix at loopback,
# ssl starts but no TLS sockets are opened.
ssl_ebin = Path.join(to_string(:code.lib_dir(:ssl)), "ebin")
ssl_dirs = if File.dir?(ssl_ebin), do: [ssl_ebin], else: []
# crypto is a required OTP app. Older device-side OTP builds (pre-2026-05)
# were configured `--without-ssl` to skip OpenSSL, so the device runtime
# had no real crypto and we shipped a deliberately-insecure shim
# (md5-only hash, no x25519, no AEAD) just to let `ensure_all_started`
# succeed for HTTP-only Phoenix at loopback.
#
# Newer tarballs ship a real crypto.so NIF + crypto.beam built against
# OpenSSL 3.x. When the host has a cached OTP runtime that already
# contains crypto.beam, we MUST NOT push the shim — its `crypto.beam`
# would land first in the on-device code path and shadow the real one,
# making `crypto:generate_key/2` undef even though the NIF is loaded.
shim_dirs =
if real_device_crypto_available?() do
[]
else
case generate_crypto_shim() do
{:ok, dir} -> [dir]
_ -> []
end
end
app_dirs ++ stdlib_dirs ++ ssl_dirs ++ shim_dirs
end
# Detects whether any cached device-side OTP runtime under ~/.mob/cache/
# already ships a real crypto.beam. If yes, the deployer must skip the
# shim — pushing the shim's crypto.beam shadows the real one in the
# on-device code path, making :crypto.generate_key/2 (and friends) undef
# despite the NIF being loaded.
@spec real_device_crypto_available?() :: boolean()
defp real_device_crypto_available? do
cache = Path.join([System.user_home!(), ".mob", "cache"])
Path.wildcard(Path.join([cache, "otp-*", "lib", "crypto-*", "ebin", "crypto.beam"]))
|> Enum.any?()
end
# Generates crypto.beam + crypto.app in a temp dir and returns {:ok, dir}.
# Returns {:error, reason} if erlc is not available.
#
# Used only as a fallback when the device-side OTP runtime was built
# `--without-ssl` (pre-2026-05 tarballs). Modern tarballs ship a real
# crypto.so NIF; in that case `real_device_crypto_available?/0` returns
# true and this shim is skipped.
@doc false
@spec generate_crypto_shim() :: {:ok, String.t()} | {:error, term()}
def generate_crypto_shim do
dir = Path.join(System.tmp_dir!(), "mob_crypto_shim")
File.mkdir_p!(dir)
src = Path.join(dir, "crypto.erl")
File.write!(src, """
-module(crypto).
-export([strong_rand_bytes/1, hash/2, mac/4, mac/3, supports/1, pbkdf2_hmac/5, exor/2]).
strong_rand_bytes(N) -> rand:bytes(N).
hash(_Type, Data) -> erlang:md5(Data).
%% HMAC-MD5 (ignores hash algorithm) — dev-only shim, no OpenSSL required.
mac(hmac, _Alg, Key, Data) -> hmac_md5(Key, Data);
mac(_Type, _SubType, _Key, _Data) -> <<>>.
mac(hmac, Key, Data) -> hmac_md5(Key, Data);
mac(_Type, _Key, _Data) -> <<>>.
supports(_Type) -> [].
%% PBKDF2-HMAC shim using HMAC-MD5 as PRF. Not cryptographically secure;
%% suitable only for local dev on-device where 127.0.0.1 is the only listener.
pbkdf2_hmac(_Hash, Password0, Salt0, Iterations, DerivedLen) ->
Password = iolist_to_binary(Password0),
Salt = iolist_to_binary(Salt0),
Blocks = (DerivedLen + 15) div 16,
Derived = iolist_to_binary([pbkdf2_block(Password, Salt, Iterations, I)
|| I <- lists:seq(1, Blocks)]),
binary:part(Derived, 0, DerivedLen).
pbkdf2_block(Password, Salt, Iterations, BlockNum) ->
U1 = hmac_md5(Password, <<Salt/binary, BlockNum:32/big>>),
pbkdf2_iterate(Password, U1, Iterations - 1, U1).
pbkdf2_iterate(_Password, _Prev, 0, Acc) -> Acc;
pbkdf2_iterate(Password, Prev, N, Acc) ->
U = hmac_md5(Password, Prev),
pbkdf2_iterate(Password, U, N - 1, xor_bins(Acc, U)).
hmac_md5(Key0, Data0) ->
Key = iolist_to_binary(Key0),
Data = iolist_to_binary(Data0),
BS = 64,
K = case byte_size(Key) > BS of
true -> erlang:md5(Key);
false -> Key
end,
Pad = binary:copy(<<0>>, BS - byte_size(K)),
KPad = <<K/binary, Pad/binary>>,
IKey = << <<(X bxor 16#36)>> || <<X>> <= KPad >>,
OKey = << <<(X bxor 16#5c)>> || <<X>> <= KPad >>,
erlang:md5(<<OKey/binary, (erlang:md5(<<IKey/binary, Data/binary>>))/binary>>).
xor_bins(A, B) ->
list_to_binary([X bxor Y || {X, Y} <- lists:zip(binary_to_list(A), binary_to_list(B))]).
exor(A, B) ->
xor_bins(iolist_to_binary(A), iolist_to_binary(B)).
""")
app =
"{application,crypto,[{modules,[crypto]},{applications,[kernel,stdlib]}," <>
"{description,\"Crypto shim for mobile (no OpenSSL; uses rand:bytes)\"}," <>
"{registered,[]},{vsn,\"5.6\"}]}."
File.write!(Path.join(dir, "crypto.app"), app)
case System.cmd("erlc", ["-o", dir, src], stderr_to_stdout: true) do
{_, 0} -> {:ok, dir}
{out, _} -> {:error, "crypto shim compile failed: #{out}"}
end
end
defp count_beams(beam_dirs) do
Enum.reduce(beam_dirs, 0, fn dir, acc ->
case File.ls(dir) do
{:ok, files} -> acc + Enum.count(files, &String.ends_with?(&1, ".beam"))
_ -> acc
end
end)
end
defp run_adb(args) do
case System.cmd("adb", args, stderr_to_stdout: true) do
{output, 0} -> {:ok, String.trim(output)}
{output, _} -> {:error, String.trim(output)}
end
end
defp color(:green), do: IO.ANSI.green()
defp color(:yellow), do: IO.ANSI.yellow()
defp color(:red), do: IO.ANSI.red()
defp color(:cyan), do: IO.ANSI.cyan()
defp color(:faint), do: IO.ANSI.faint()
defp color(:reset), do: IO.ANSI.reset()
end