The single process the native layer delivers interaction events to.
Native knows two things and neither of them is a screen: the registered name
:mob_screen (used by enif_whereis_pid for the back gesture, alert actions
and the launch-notification fallback, on both platforms) and whatever pid was
stored in a tap handle by register_tap/1. This module takes over the second.
The envelope
nif_register_tap stores an arbitrary term as the handle's tag and echoes it
back verbatim — mob_send_tap sends {:tap, tag}, mob_send_event sends
{event, tag}. The tag is copied with enif_make_copy, so it can be any
shape, including a nested tuple.
So instead of registering {screen_pid, tag}, Mob.Renderer registers
{listener_pid, {:mob_route, screen_pid, tag}}Native then delivers {:tap, {:mob_route, screen_pid, tag}} here, and the
listener forwards {:tap, tag} to the screen. Native remains ignorant that
screens exist, and no .m, .zig or generator-template change is
required to move the inbound path off a single hard-wired screen process.
Native has two message shapes for handle-addressed events, and the listener has to unwrap both:
{event, tag}—mob_send_tap,mob_send_event,mob_send_scrolled_past. Covers:tap,:focus,:blur,:submit,:dismiss,:selectand the other payload-free events.{event, tag, payload}—mob_send_change,mob_send_compose,mob_send_swipe_with_direction,mob_send_scroll,mob_send_drag,mob_send_pinch,mob_send_rotate,mob_send_pointer_move. This is everything carrying a value: text-field and toggle and slideron_change, tab selection, and every gesture stream.
Both are unwrapped on the event atom rather than one clause per event, so a new event kind needs no change here — but a new arity would. Anything else is logged rather than silently discarded, because an unmodelled shape is invisible otherwise: the widget simply stops working.
A dead screen's input is recorded, not delivered
A handle can outlive its screen. After a handler crash Mob.Router restarts
the screen under a new pid, and until native commits the replacement's tree
every tap on the old one is addressed to a dead process. send/2 to a dead
pid is a silent no-op, so those taps used to vanish with no trace at all
(MOB-306).
They are still not redirected to the replacement — it may be showing
something else, which is the misrouting MOB-107 reported. Instead a dead
local target is detected before forwarding: every such event is counted
(Mob.Diag.health/0, listener: %{undeliverable: n}), a discrete one (see
Mob.Event.NativeInput) gets a receipt whose only stage is
:undeliverable, and the first event for each dead screen is logged. A
remote pid cannot be checked from here and is forwarded as before.
Why a hop at all
Today there is one screen process, so carrying its pid through the envelope
and forwarding is, on its own, a hop that buys nothing. What it buys is that
the ~35 register_tap call sites in Mob.Renderer stop naming a screen
process directly. When MOB-112 makes screens processes and MOB-113 adds the
router, the change is confined to handler/1 and handle_info/2 here rather
than spread across every interactive prop in the renderer.
The escape hatch
A high-frequency stream — drag, scroll, mob_touch at display rate — pays one
extra hop and one extra copy per event. Registering the screen pid directly
bypasses this module entirely and still works, because that is exactly what
the renderer did before:
nif.register_tap({screen_pid, tag}) # direct, no listenerNothing bypasses it today. The hop has not been measured, and adding an exception before there is a number to point at would be guessing.
Summary
Functions
Returns a specification to start this module under a supervisor.
Start the listener if it is not already running.
Wrap a register_tap/1 target so native delivers the event here instead of
straight to the screen.
Value-free health for Mob.Diag.health/0: the listener's pid (or nil) and
how many events have arrived for a dead screen. Read-only; never calls in.
Whether the listener is running.
Start the listener. Named, so there is exactly one.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec ensure_started() :: :ok
Start the listener if it is not already running.
Unlinked, for the same reason Mob.Sender.ensure_started/0 is: the caller is
usually a screen, and a screen crash must not take down the process every
screen's events arrive through.
Wrap a register_tap/1 target so native delivers the event here instead of
straight to the screen.
Accepts either shape the renderer uses — a bare pid, or {pid, tag} — and
returns the term to hand to register_tap/1.
Returns the target unchanged when the listener is not running, so events go directly to the screen exactly as they did before this module existed. That is the fallback for any boot path that does not start a listener, and it is what keeps the renderer's own tests working without one.
@spec health() :: %{process: pid() | nil, undeliverable: non_neg_integer()}
Value-free health for Mob.Diag.health/0: the listener's pid (or nil) and
how many events have arrived for a dead screen. Read-only; never calls in.
@spec running?() :: boolean()
Whether the listener is running.
@spec start_link(keyword()) :: GenServer.on_start()
Start the listener. Named, so there is exactly one.