Packages
mob_dev
0.1.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/mob.watch.ex
defmodule Mix.Tasks.Mob.Watch do
use Mix.Task
@shortdoc "Watch for source changes and auto hot-push to running mob devices"
@moduledoc """
Watches `lib/` for source changes and automatically compiles + hot-pushes
updated modules to all running Android and iOS devices.
Apps must already be running. Modules are loaded in place — no restart.
Only modules that actually changed are pushed each cycle.
Press Ctrl-C to stop.
Options:
--cookie Erlang cookie (default: mob_secret)
--debounce ms to wait after a change before compiling (default: 300)
--interval ms between file-change polls (default: 500)
Examples:
mix mob.watch
mix mob.watch --debounce 500
mix mob.watch --cookie my_cookie
"""
@impl Mix.Task
def run(args) do
{opts, _, _} = OptionParser.parse(args,
switches: [cookie: :string, debounce: :integer, interval: :integer],
aliases: [c: :cookie]
)
cookie = opts |> Keyword.get(:cookie, "mob_secret") |> String.to_atom()
debounce = Keyword.get(opts, :debounce, 300)
interval = Keyword.get(opts, :interval, 500)
IO.puts("")
IO.puts("#{IO.ANSI.cyan()}mob.watch#{IO.ANSI.reset()} — watching lib/ for changes (Ctrl-C to stop)\n")
nodes = connect_with_retry(cookie)
# Initial compile + push everything so device is in sync.
recompile()
{pushed, _} = MobDev.HotPush.push_all(nodes)
if pushed > 0 do
IO.puts(" #{IO.ANSI.green()}✓ initial push: #{pushed} module(s)#{IO.ANSI.reset()}")
end
# Snapshot source mtimes.
sources = snapshot_sources()
IO.puts(" Watching #{map_size(sources)} source file(s)...\n")
watch_loop(sources, nodes, cookie, debounce, interval)
end
# ── Loop ────────────────────────────────────────────────────────────────────
defp watch_loop(sources, nodes, cookie, debounce, interval) do
:timer.sleep(interval)
current = snapshot_sources()
changed_files = changed_sources(sources, current)
if changed_files == [] do
watch_loop(current, nodes, cookie, debounce, interval)
else
IO.puts("#{IO.ANSI.cyan()}◉ #{length(changed_files)} file(s) changed#{IO.ANSI.reset()}")
Enum.each(changed_files, fn f ->
IO.puts(" #{Path.relative_to_cwd(f)}")
end)
# Debounce — wait in case more saves are incoming (e.g. format-on-save).
:timer.sleep(debounce)
current2 = snapshot_sources()
# Re-connect if any nodes dropped (device rebooted, app restarted, etc.)
live_nodes = reconnect_if_needed(nodes, cookie)
snapshot = MobDev.HotPush.snapshot_beams()
recompile()
{pushed, failed} = MobDev.HotPush.push_changed(live_nodes, snapshot)
cond do
pushed > 0 ->
node_str = Enum.map_join(live_nodes, " ", &short_node/1)
IO.puts(" #{IO.ANSI.green()}✓ #{pushed} module(s) → #{node_str}#{IO.ANSI.reset()}")
failed != [] ->
Enum.each(failed, fn {mod, reason} ->
IO.puts(" #{IO.ANSI.red()}✗ #{mod}: #{inspect(reason)}#{IO.ANSI.reset()}")
end)
true ->
IO.puts(" #{IO.ANSI.yellow()}(compile ran but no new BEAMs — syntax error?)#{IO.ANSI.reset()}")
end
IO.puts("")
watch_loop(current2, live_nodes, cookie, debounce, interval)
end
end
# ── Helpers ─────────────────────────────────────────────────────────────────
defp connect_with_retry(cookie) do
IO.write("Connecting to devices...")
nodes = MobDev.HotPush.connect(cookie: cookie)
if nodes == [] do
IO.puts(" #{IO.ANSI.yellow()}none found#{IO.ANSI.reset()}")
IO.puts(" Start apps first: mix mob.connect")
IO.puts(" Watching anyway — will connect when nodes come up.\n")
else
IO.puts(" #{IO.ANSI.green()}#{length(nodes)} node(s)#{IO.ANSI.reset()}")
Enum.each(nodes, fn n -> IO.puts(" #{IO.ANSI.green()}✓#{IO.ANSI.reset()} #{n}") end)
IO.puts("")
end
nodes
end
defp reconnect_if_needed(nodes, cookie) do
alive = Enum.filter(nodes, &(Node.connect(&1) == true))
new_nodes = MobDev.HotPush.connect(cookie: cookie)
# Union: keep existing alive nodes + any newly discovered ones
Enum.uniq(alive ++ new_nodes)
end
# Lines from mix compile subprocess we don't want to echo.
@noise_prefixes ["warning! Erlang/OTP", "Regexes will be re-compiled",
"This can be fixed by using"]
defp recompile do
# Run in a subprocess — Mix task caches are process-local and can't be
# fully cleared with reenable/1 when inside another running mix task.
mix = System.find_executable("mix") || "mix"
{output, _} = System.cmd(mix, ["compile"], cd: File.cwd!(), stderr_to_stdout: true)
output
|> String.split("\n", trim: true)
|> Enum.reject(fn line -> Enum.any?(@noise_prefixes, &String.starts_with?(line, &1)) end)
|> Enum.each(&IO.puts/1)
end
defp snapshot_sources do
Path.wildcard("lib/**/*.ex")
|> Map.new(fn path ->
mtime = case File.stat(path, time: :posix) do
{:ok, %{mtime: t}} -> t
_ -> 0
end
{path, mtime}
end)
end
defp changed_sources(old, current) do
Enum.flat_map(current, fn {path, mtime} ->
if Map.get(old, path) != mtime, do: [path], else: []
end)
end
defp short_node(node) do
node |> to_string() |> String.split("@") |> hd()
end
end