Packages
mob_dev
0.2.5
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/mob_dev/server/log_streamer.ex
defmodule MobDev.Server.LogStreamer do
@moduledoc """
Streams logcat from connected Android devices and iOS simulator console,
broadcasting parsed lines via PubSub.
One Port per device. Automatically starts/stops ports as devices
connect and disconnect (driven by :devices_updated PubSub events).
"""
use GenServer
@topic "logs"
defstruct ports: %{} # serial => port
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
# ── GenServer ────────────────────────────────────────────────────────────────
@impl GenServer
@spec init(term()) :: {:ok, %__MODULE__{}}
def init(_opts) do
Phoenix.PubSub.subscribe(MobDev.PubSub, "devices")
devices = MobDev.Server.DevicePoller.get_devices()
# If there are already-connected devices, this is a streamer restart — mark it.
Enum.each(devices, fn d -> broadcast_restart(d.serial) end)
ports = Enum.reduce(devices, %{}, &open_port_for/2)
{:ok, %__MODULE__{ports: ports}}
end
@impl GenServer
@spec handle_info(term(), %__MODULE__{}) :: {:noreply, %__MODULE__{}}
def handle_info({:devices_updated, devices}, state) do
current_serials = MapSet.new(Map.keys(state.ports))
new_serials = MapSet.new(Enum.map(devices, & &1.serial))
# Stop ports for disconnected devices
removed = MapSet.difference(current_serials, new_serials)
ports = Enum.reduce(removed, state.ports, fn serial, acc ->
if port = acc[serial] do
Port.close(port)
end
Map.delete(acc, serial)
end)
# Open ports for newly connected devices
added = MapSet.difference(new_serials, current_serials)
new_devices = Enum.filter(devices, &(MapSet.member?(added, &1.serial)))
ports = Enum.reduce(new_devices, ports, &open_port_for/2)
{:noreply, %{state | ports: ports}}
end
# Data from a logcat/simctl port.
# With {:line, N} port option, data arrives as {:eol, line} or {:noeol, partial}.
def handle_info({port, {:data, {:eol, line}}}, state) do
broadcast_line(port, line, state)
{:noreply, state}
end
def handle_info({port, {:data, {:noeol, _partial}}}, state) do
# Partial line (buffer full) — drop it, logcat lines are never this long
_ = port
{:noreply, state}
end
def handle_info({port, {:exit_status, _}}, state) do
serial = Enum.find_value(state.ports, fn {s, p} -> if p == port, do: s end)
ports = if serial, do: Map.delete(state.ports, serial), else: state.ports
# Schedule a reopen attempt — logcat exits when the app is force-stopped,
# but the device is usually still connected and will restart shortly.
if serial, do: Process.send_after(self(), {:reopen, serial}, 2_000)
{:noreply, %{state | ports: ports}}
end
def handle_info({:reopen, serial}, state) do
# Only reopen if device is still connected and we don't already have a port.
if Map.has_key?(state.ports, serial) do
{:noreply, state}
else
devices = MobDev.Server.DevicePoller.get_devices()
case Enum.find(devices, &(&1.serial == serial)) do
nil -> {:noreply, state}
device ->
broadcast_restart(serial)
ports = open_port_for(device, state.ports)
{:noreply, %{state | ports: ports}}
end
end
end
def handle_info(_, state), do: {:noreply, state}
defp broadcast_line(port, line, state) do
serial = Enum.find_value(state.ports, fn {s, p} -> if p == port, do: s end)
if serial do
parsed = parse_line(line, serial)
MobDev.Server.LogBuffer.push(parsed)
Phoenix.PubSub.broadcast(MobDev.PubSub, @topic, {:log_line, serial, parsed})
end
end
defp broadcast_restart(serial) do
line = %{
id: unique_id(),
serial: serial,
level: "I",
tag: nil,
message: "── Restart ──",
raw: "",
mob: true,
restart: true,
ts: time_string()
}
MobDev.Server.LogBuffer.push(line)
Phoenix.PubSub.broadcast(MobDev.PubSub, @topic, {:log_line, serial, line})
end
# ── Port management ──────────────────────────────────────────────────────────
defp open_port_for(%{platform: :android, serial: serial}, ports) do
args = ["-s", serial, "logcat", "-v", "brief", "-T", "1"]
port = open_port("adb", args)
Map.put(ports, serial, port)
end
defp open_port_for(%{platform: :ios, serial: udid}, ports) do
# Stream iOS simulator log, filter to mob-relevant output.
# Process name is the binary name ("MobDemo"), not the bundle ID.
args = ["simctl", "spawn", udid, "log", "stream",
"--predicate", "process == 'MobDemo'",
"--style", "syslog"]
port = open_port("xcrun", args)
Map.put(ports, udid, port)
end
defp open_port_for(_, ports), do: ports
defp open_port(cmd, args) do
executable = System.find_executable(cmd) || cmd
Port.open({:spawn_executable, executable},
[:binary, :exit_status, {:args, args}, {:line, 4096}])
end
# ── Log line parsing ─────────────────────────────────────────────────────────
@doc """
Parses a logcat brief-format line into a map.
Brief format: `I/TagName(PID): message`
"""
@spec parse_line(String.t(), String.t()) :: map()
def parse_line(raw, serial) do
# Android logcat brief: "I/MobBeam( 1234): message text"
case Regex.run(~r/^([EWIDVF])\/([^\(]+)\(\s*\d+\):\s*(.*)$/, String.trim(raw)) do
[_, level, tag, message] ->
%{
id: unique_id(),
serial: serial,
level: level,
tag: String.trim(tag),
message: message,
raw: raw,
mob: mob_tag?(tag),
ts: time_string()
}
nil ->
# iOS syslog or unparsed line
%{
id: unique_id(),
serial: serial,
level: "I",
tag: nil,
message: String.trim(raw),
raw: raw,
mob: mob_line?(raw),
ts: time_string()
}
end
end
defp mob_tag?(tag) do
tag = String.trim(tag)
tag in ["MobBeam", "MobNif", "MobDist", "MobBridge", "Elixir"] or
String.starts_with?(tag, "Mob")
end
defp mob_line?(line) do
app = Mix.Project.config()[:app] |> to_string()
app_camel = app |> Macro.camelize()
String.contains?(line, "MobBeam") or
String.contains?(line, "MobNIF") or
String.contains?(line, "MobBridge") or
String.contains?(line, app) or
String.contains?(line, app_camel)
end
defp unique_id, do: :erlang.unique_integer([:monotonic, :positive])
defp time_string do
{{_y, _mo, _d}, {h, m, s}} = :calendar.local_time()
:io_lib.format("~2..0B:~2..0B:~2..0B", [h, m, s]) |> IO.iodata_to_binary()
end
end