Packages
mob
0.9.17
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.1
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.39
0.7.38
0.7.37
0.7.36
0.7.35
0.7.34
0.7.33
0.7.32
0.7.31
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
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.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
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.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.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
MOB_PLUGINS.md
# Mob plugins — manifest schema
Mob plugins are regular Hex packages with a `priv/mob_plugin.exs` data
file. The data file declares what the plugin contributes (NIFs, UI
components, screens, permissions, etc.) and mob_dev's compile step
autolinks those contributions into the host app's build.
This doc covers:
- The two kinds of plugin, and the tiers the tooling still uses
- The manifest schema, annotated with concrete examples
- Install + activation flow
- Validation + compatibility rules
For writing, signing, testing and publishing a plugin step by step, see
the [Writing a Plugin guide](guides/plugins.md); for signing and trust,
[`MOB_PLUGIN_SECURITY.md`](MOB_PLUGIN_SECURITY.md).
## Plugin kinds and tiers
Plugins range from "10 lines of helper code" to "embedded chat app."
The manifest scales — small plugins use 3 fields, big plugins use a
dozen. Every section below the required header is optional; you only
write what you need.
What matters most is which of two kinds a plugin is:
- **BEAM-only** — Elixir code only: helpers, screens, `expand:`
composites, lifecycle and router hooks, supervised workers or the
plugin's own OTP application, settings, notification handlers,
migrations. It reaches a device with a plain `mix mob.deploy` and
hot-pushes. `mob_deliver` is the reference.
- **Native** — anything the native build has to integrate: `nifs`,
native `ui_components`, `android` / `ios` sections (bridges, Gradle
deps, frameworks, plist keys), `permissions`, `host_requirements`. It
needs `mix mob.deploy --native`; only its Elixir half hot-pushes.
`mob_nfc`, `mob_camera` and `mob_wake` are examples.
The sections below are organised by **tier**, which `mix mob.new_plugin
--tier N` uses to pick a template and `mix mob.plugins` prints (the
highest section present). A tier says what a plugin ships, not what it
costs; `mix mob.plugins`' `HOT-PUSH` column (computed from the native
sections above) says that.
| Tier | Example | What it ships | Kind |
|--|--|--|--|
| 0 | `mob_color_palette` | Pure Elixir module, no manifest | BEAM-only |
| 1 | `mob_haptic_extras` | NIF + Elixir wrapper | Native |
| 2 | `mob_signature_pad` | + new `<SignaturePad>` component | Native (BEAM-only for an `expand:` composite) |
| 3 | `mob_in_app_purchase` | + `Mob.Screen` modules, migrations, assets | BEAM-only unless it also has native sections (fonts and images are copied by the native build) |
| 4 | `mob_chat_kit` | + lifecycle hooks, settings, notification handlers | BEAM-only unless it also has native sections |
A tier-0 plugin doesn't need this spec at all — it's just a Hex
package depending on `:mob`. Every plugin with a manifest must be
signed (see `MOB_PLUGIN_SECURITY.md`).
## Minimum viable manifest (tier 1)
```elixir
# priv/mob_plugin.exs
%{
name: :mob_haptic_extras,
mob_version: "~> 0.6",
plugin_spec_version: 1
}
```
Three required fields, that's it. A manifest this small means "this
plugin's contributions are entirely in the lib/ folder, no native
code, no permissions." Functionally equivalent to a tier-0 plugin
but allows mob_dev to print it in `mix mob.plugins` output and
enforce the `mob_version` constraint at compile time.
Add fields below as you need them. Every section is independently
optional.
## Tier 1 — functional plugin
A NIF + Elixir wrapper + per-platform helper code that doesn't touch
the render tree. The canonical example is the `mob_bluetooth` plugin
(extracted from core in Wave 1):
```elixir
%{
name: :mob_bluetooth,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "Bluetooth Classic peripheral (HFP / SPP / HID)",
# Static-linked NIFs. `:module` is the NIF's Erlang module name (a valid
# C token: `[a-z][a-z0-9_]*`), NOT an Elixir module — ERL_NIF_INIT uses
# it as BOTH the registered module name AND the static-init symbol prefix
# (`<module>_nif_init`), so an Elixir module like `MobBluetooth.Nif`
# would yield an invalid C symbol. The plugin ships a small Erlang stub
# (e.g. `src/mob_bluetooth_nif.erl`) that calls `erlang:load_nif/2`; an
# Elixir wrapper can then `defdelegate` into it.
#
# `:native_dir` is the per-NIF native source directory. mob_dev's build
# appends these entries to the existing :static_nifs list (the generated
# driver table references `<module>_nif_init` over C ABI regardless of
# source language).
#
# `:lang` selects the compile path (default `:c`):
# - `:c` → `<native_dir>/<module>.c`, compiled with
# `-DSTATIC_ERLANG_NIF_LIBNAME=<module>` (the ERL_NIF_INIT macro
# emits the init symbol). Fed to build.zig via `-Dplugin_c_nifs`.
# - `:zig` → `<native_dir>/<module>.zig`, compiled via `addZigObject` and
# fed via `-Dplugin_zig_nifs`. The source names its own
# `export fn <module>_nif_init()` (no libname flag) and reaches
# mob-core bindings through the named imports `@import("erts")`
# / `@import("jni")` that build.zig wires for plugin zig objects.
# See mob_dev `decisions/2026-05-28-zig-plugin-nifs.md`.
nifs: [
%{module: :mob_bluetooth_nif, native_dir: "priv/native/jni", lang: :zig}
],
android: %{
# Merged into android/app/build.gradle's dependencies block.
gradle_deps: [],
# Merged into AndroidManifest.xml. REQUIRES explicit user opt-in
# via `config :mob, :plugins` — mob_dev refuses to merge these
# silently for plugins that haven't been activated.
permissions: [
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN"
],
# The plugin's own Kotlin bridge class, in its OWN package (NOT the app's
# MobBridge). mob_dev copies it into the app source tree before
# `gradle assembleDebug` so the app's Kotlin sourceSet compiles it.
bridge_kt: "priv/native/android/MobBluetoothBridge.kt",
# Fully-qualified name of that Kotlin class. mob_dev generates a
# `MobPluginBootstrap.registerAll/0` (called from MainActivity.onCreate)
# that invokes `<bridge_class>.register()` at startup; the plugin's
# `nativeRegister(env, cls)` JNI thunk caches its own jclass + method IDs
# from the `cls` arg (no FindClass / classloader problem). This is how a
# plugin-owned Kotlin class becomes callable from its NIF.
bridge_class: "io.mob.bluetooth.MobBluetoothBridge",
# Plain JNI-thunk C (Java_<pkg>_<Class>_*) compiled alongside beam_jni.c
# via `-Dplugin_jni_sources` (no NIF-init libname — these aren't NIFs).
# Holds nativeRegister + the nativeDeliver* thunks that call the plugin
# NIF's `mob_deliver_*` exports.
jni_source: "priv/native/jni/mob_bluetooth_jni.c"
},
ios: %{
# Swift files compiled with the project's existing swiftc invocation.
swift_files: ["priv/native/ios/MobBluetooth.swift"],
# Info.plist keys the build adds when the host's own ios/Info.plist
# doesn't set them (an array value, e.g. UIBackgroundModes, appends its
# missing entries to the host's array instead). iOS terminates an app
# that requests a permission without its usage description — same opt-in
# gate as Android.
plist_keys: %{
"NSBluetoothAlwaysUsageDescription" =>
"Bluetooth access is required to discover and advertise to nearby devices."
},
# System frameworks linked at the static-link step.
frameworks: ["CoreBluetooth"]
}
}
```
Notes:
- `:gradle_deps` accept any string Gradle would understand (`group:artifact:version`).
- `:plist_keys` values are defaults: a scalar key the host's `ios/Info.plist`
sets keeps the host's value; an array value (e.g. `UIBackgroundModes`)
appends its missing entries to the host's array. Write a usage description
(`*UsageDescription`) as one sentence saying what *your plugin* uses the
permission for. When several activated plugins declare the same usage
description, the build (mob_dev ≥ 0.7.19) combines their distinct sentences
(activation order) so the prompt gives every reason, and prints which
plugins it combined; the host words it itself by setting the key in
`ios/Info.plist`. Any other key two plugins declare is a conflict (below).
- iOS or Android can be omitted. iOS-only and Android-only plugins
are valid. The validator warns (does not error) when one is missing
so users discover the gap.
## Tier 2 — visual plugin
Adds new render-tree node types. Same shape as tier 1 plus a
`:ui_components` section:
```elixir
%{
name: :mob_charts,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "Line / bar / pie chart components",
android: %{
gradle_deps: ["com.github.PhilJay:MPAndroidChart:v3.1.0"]
},
ui_components: [
%{
# PascalCase tag for the ~MOB sigil: <Chart data={@series} />
tag: "Chart",
# Snake-case atom for the render tree: %{type: :chart, ...}
atom: :chart,
# Props the component accepts. Documentation + (eventually)
# compile-time validation. Optional today; required if you
# want `mix mob.routes` and similar tools to know the shape.
props: [:data, :type, :color, :width, :height],
ios: %{
# SwiftUI View struct in priv/native/ios/. mob's renderer
# dispatches `case .chart:` → `MobChartView(node: node)`.
view_module: "MobChartView"
},
android: %{
# @Composable function in priv/native/android/. mob's
# renderer dispatches `"chart" -> MobChart(node, m)`.
composable: "MobChart"
}
},
%{
tag: "Sparkline",
atom: :sparkline,
props: [:data, :color],
ios: %{view_module: "MobSparklineView"},
android: %{composable: "MobSparkline"}
}
]
}
```
A visual plugin can omit one platform if the component is genuinely
platform-specific (e.g., an iOS-only Live Activity widget). The
validator warns when a `ui_components` entry has only one platform —
silent UX bugs on the missing side are the #1 React Native plugin
pain point.
**Visual plugins are NOT hot-pushable.** Adding a new node type
requires recompiling the native shell. The dev loop is "edit Elixir
→ rebuild app → reinstall," not "edit Elixir → `mix mob.push`."
The manifest validator surfaces this distinction.
## Tier 3 — multi-screen plugin
Plugins that ship entire screens (effectively mini-applications
embedded in the host). Adds `:screens`, `:migrations`, `:assets`:
```elixir
%{
name: :mob_in_app_purchase,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "StoreKit / Play Billing IAP flow",
# ── tier-1 capability bits ──
nifs: [%{module: :mob_iap_nif, native_dir: "priv/native/jni"}],
android: %{
gradle_deps: ["com.android.billingclient:billing:6.1.0"],
bridge_kt: "priv/native/android/MobIapBridge.kt",
jni_source: "priv/native/android/jni/iap.c"
},
ios: %{
swift_files: ["priv/native/ios/MobIap.swift"],
frameworks: ["StoreKit"]
},
# ── tier-3 additions ──
# Mob.Screen modules the plugin contributes. Host can push them
# via `Mob.UI.push_screen(MobIap.CatalogScreen)`. The plugin's
# README explains the intended navigation patterns.
screens: [
%{module: MobIap.CatalogScreen, default_route: "/iap/catalog"},
%{module: MobIap.CartScreen, default_route: "/iap/cart"},
%{module: MobIap.ConfirmationScreen, default_route: "/iap/confirm"}
],
# Ecto migrations the plugin ships. The repo_namespace prefixes
# table names so plugins from different vendors don't collide.
# Host app's migrator picks them up at boot.
migrations: %{
repo_namespace: "mob_iap_",
migrations_dir: "priv/repo/migrations"
},
# Asset bundles to merge into the host app's bundle.
# Fonts get registered automatically on iOS (UIAppFonts) and
# Android (assets/fonts/). Images are addressable from Mob.UI
# via "plugin://mob_iap/<filename>" path syntax.
assets: %{
fonts: ["priv/assets/iap-icons.ttf"],
images: ["priv/assets/store-badge.png"]
},
# Optional: suggest this font as the app's default (Mob.Theme.fonts[:default]),
# without taking over the whole visual theme the way a style package (MOB_STYLES.md)
# does. :file must be one of assets.fonts above. Only applied if the host hasn't
# already set a default itself (use Mob.App theme:, an activated style package, or
# Mob.Theme.set/1 in on_start/0 all outrank it) — see MOB_FONTS.md for the full
# precedence ladder. Two activated plugins both setting default_font is a build
# error, not a silent pick.
default_font: %{family: "IAPSans-Regular", file: "priv/assets/iap-icons.ttf"}
}
```
The `screens:` section is declarative — it tells the host these
modules exist and provides suggested routes. The host app *chooses*
whether and where to wire them into its navigation. This avoids the
React-Native problem of plugins silently grabbing routes.
## Tier 4 — embedded sub-app
Tier 3 plus lifecycle hooks, settings, background workers, push
notifications. The line between "plugin" and "embedded application"
gets thin here — but as long as the plugin lives under the host's
supervisor (no independent OTP app), it's still a plugin.
```elixir
%{
name: :mob_chat_kit,
mob_version: "~> 0.6",
plugin_spec_version: 1,
description: "Embeddable chat (channels, messages, attachments)",
# ... tier 1/2/3 fields ...
lifecycle: %{
# Called from Mob.App.start/0 before the host's own on_start/0, after
# this plugin's OTP application (and its deps) has been started and the
# project's config applied (guides/plugins.md → "Boot order").
# Returns :ok or {:error, reason} — error bubbles to host.
on_start: {MobChatKit, :start, []},
# Children added to the host's supervisor tree. Same shape as
# Supervisor.child_spec. Started after on_start succeeds.
supervised: [
MobChatKit.MessageSync,
{MobChatKit.PresenceTracker, []}
],
# Optional OS-level callbacks. Called when the app foregrounds
# or backgrounds. Plugin can flush pending state, pause workers, etc.
on_resume: {MobChatKit, :on_resume, []},
on_background: {MobChatKit, :on_background, []}
},
settings: %{
# User-facing settings the plugin exposes. Persisted via
# Mob.State, namespaced per plugin. Defaults are
# used until the user opens the editor_screen and saves.
schema: [
%{key: :sound_on_message, type: :boolean, default: true},
%{key: :default_channel, type: :string, default: "#general"},
%{key: :sync_interval_seconds, type: :integer, default: 30}
],
# Mob.Screen module the host can push to let users edit. The
# plugin owns the screen's UX; the host just provides the
# entry point.
editor_screen: MobChatKit.SettingsScreen
},
notifications: %{
# Push notification handler. The host's notification dispatcher
# checks each plugin's handler in registration order; first
# match wins. `match` is either a function or a map prefix.
handlers: [
%{
match: %{type: "chat_message"},
handler: {MobChatKit.Notifications, :handle_message, 1}
}
]
}
}
```
`:settings.schema` typed entries get free runtime validation via
`Mob.State`. The plugin reads its own settings with
`Mob.Plugins.get_setting(:mob_chat_kit, :default_channel)`.
> **Status (2026-06-06):** tiers 3 and 4 are **built and device-verified**
> (iPhone + Android). The wiring is pure-Elixir off a generated runtime
> manifest read by `Mob.Plugins` at boot; see
> `decisions/2026-06-06-plugin-tiers-3-4.md`. Device-verified: static +
> generated screens, migrations (table created on device), `plugin://` images,
> notification routing, tier-4 lifecycle/settings/supervised workers, and
> **custom fonts** — `assets.fonts` are build-bundled (iOS `.app` + `UIAppFonts`,
> Android `res/font` uncompressed) and used via the `font:` prop, visually
> confirmed on Android (a plugin-shipped serif font rendered distinct from the
> system font). This also makes app-level `priv/fonts/` custom fonts (documented
> above) actually work for the first time. Merged to masters; `mix mob.new_plugin`
> scaffolds all five tiers (0–4), cross-plugin conflict detection + a runtime-
> manifest auto-regen guard the multi-plugin case (see "Cross-plugin conflict
> detection" below), and a second plugin migration is device-verified composing
> alongside the first.
## Code-generated plugins (spec version 2+)
Some plugins need to derive their contributions from the *host app's*
configuration at compile time, not declare them statically. The
canonical case is an Ash integration: define `N` Ash resources in
the host app, and a `mob_ash` plugin generates `N × 3` screens (list,
detail, form) plus any matching UI components — all baked into the
build, not runtime.
For these plugins the static `:screens` list isn't enough. Spec
version 2 adds the `:screens_generator` field that returns the same
shape at compile time:
```elixir
%{
name: :mob_ash,
mob_version: "~> 0.6",
plugin_spec_version: 2, # bumped — requires v2
description: "Generate Mob screens from Ash resources",
# Either static (tier 3) or generated (this section), not both.
# Generator is {Module, :function, args}; mob_dev calls it during
# the compile step and uses the returned list as if it had been
# declared statically.
screens_generator: {MobAsh.ScreenGenerator, :generate, []},
ui_components: [
%{tag: "AshForm", atom: :ash_form, props: [:resource, :action, :record]},
%{tag: "AshList", atom: :ash_list, props: [:resource, :filter, :sort]},
%{tag: "AshField", atom: :ash_field, props: [:attribute, :record]}
],
ios: %{swift_files: ["priv/native/ios/MobAshForm.swift", ...]},
android: %{composable_files: [...]}
}
```
The generator function returns a list with the same shape as
`:screens`:
```elixir
defmodule MobAsh.ScreenGenerator do
def generate do
# Read host app's Ash domain registration.
domains = MobDev.Plugin.host_config(:my_app, :ash_domains, [])
for domain <- domains,
resource <- domain.resources(),
screen <- [:list, :detail, :form] do
module = generated_module_name(resource, screen)
route = generated_route(resource, screen)
# Actually create the module at compile time via Module.create/3.
create_screen_module(module, resource, screen)
%{module: module, default_route: route}
end
end
end
```
`MobDev.Plugin.host_config/3` is the explicit, audited API for
generators to read the host's `config :my_app, ...` during compile.
Calls outside this surface (e.g. reading `mob.exs` directly,
introspecting other plugins) require `:host_config_keys` declared
in the manifest so the audit can verify what the generator touches.
### Other generator fields
Spec version 2 adds matching generator forms for any section that
benefits from dynamic computation:
- `:nifs_generator` — useful when the NIF set depends on host config
(e.g., conditionally include a feature)
- `:ui_components_generator` — for plugins that synthesize components
from a schema (form-builders, data-bound widgets)
A plugin can mix static and generator forms across different
sections — static `:nifs` + generated `:screens` is fine.
### Why generators at compile time, not runtime
Mob plugins are statically merged for App Store / Play Store
compatibility. Runtime plugin registration would require dynamic
module loading which our build posture forbids. Compile-time
generators produce real modules that ship in the binary the same as
hand-written ones. Hot-push works for any pure-Elixir generated
modules (same rule as static screens); native-touching generators
require a rebuild.
### What a host app looks like
The Ash integration story for an end user:
```elixir
# mix.exs
{:mob_ash, "~> 0.1"}
# mob.exs
config :mob, :plugins, [:mob_ash]
# my_app.ex (the host's Ash domain)
config :my_app, :ash_domains, [MyApp.Blog, MyApp.Auth]
# That's it. Compile produces:
# MobAsh.Generated.Blog.Post.ListScreen
# MobAsh.Generated.Blog.Post.DetailScreen
# MobAsh.Generated.Blog.Post.FormScreen
# MobAsh.Generated.Auth.User.ListScreen
# ... etc, all baked into the build.
```
Adding a resource to the Ash domain regenerates its screen set on
next compile. Removing one removes the screens. The host's
`App.navigation/1` can either wire them up by convention or pick a
subset.
### The contract is generic — Ash is one example
The `:screens_generator` + `host_config/3` API doesn't know about
Ash. Any host-side registry of resource-like things can drive
screen generation. A `mob_ecto` sketch shows the same pattern
without Ash as a dependency:
```elixir
%{
name: :mob_ecto,
mob_version: "~> 0.6",
plugin_spec_version: 2,
description: "Generate Mob screens from Ecto schemas",
screens_generator: {MobEcto.ScreenGenerator, :generate, []},
ui_components: [
%{tag: "EctoForm", atom: :ecto_form, props: [:schema, :changeset]},
%{tag: "EctoList", atom: :ecto_list, props: [:schema, :query]},
%{tag: "EctoField", atom: :ecto_field, props: [:field, :record]}
]
}
```
The host registers its schemas the same way Ash domains are
registered:
```elixir
# my_app.ex
config :my_app, :ecto_schemas, [MyApp.Blog.Post, MyApp.Auth.User]
```
And the generator iterates schemas instead of resources:
```elixir
defmodule MobEcto.ScreenGenerator do
def generate do
schemas = MobDev.Plugin.host_config(:my_app, :ecto_schemas, [])
for schema <- schemas,
screen <- [:list, :detail, :form] do
module = generated_module_name(schema, screen)
route = generated_route(schema, screen)
create_screen_module(module, schema, screen)
%{module: module, default_route: route}
end
end
end
```
mob_ash and mob_ecto have identical contracts with mob_dev — they
differ only in how they introspect the host's resource definitions.
The same pattern fits Phoenix schemas, Memento tables, or any
custom host-side registry.
### Working with Ash beyond the basics
If a host app wants to share resource code between the Phoenix
server and the mob_ash generator — the same `User` attributes,
validations, or calculations on both sides — the recommended path
is **Spark Fragments**, the existing Ash mechanism for composable
DSL fragments:
```elixir
defmodule Shared.User.Attributes do
use Spark.Dsl.Fragment, of: Ash.Resource
attributes do
attribute :email, :string
attribute :name, :string
end
end
# server-side resource
defmodule MyApp.Auth.User do
use Ash.Resource, fragments: [Shared.User.Attributes]
# + server-only actions, policies, data layer
end
# mobile-side resource (read by mob_ash's generator)
defmodule MyApp.Mobile.User do
use Ash.Resource, fragments: [Shared.User.Attributes]
# + mobile-safe action subset
end
```
Per-action exposure granularity ("expose only `:read` and `:create`
to mobile") is a host-app concern, expressed by which actions live
on the mobile-side resource module. mob_dev does not need a DSL
for this — the generator sees whatever resources the host registers
in `config :my_app, :ash_domains` and generates screens for their
declared actions.
This keeps mob_dev's contract Ash-agnostic while giving Ash users
a clean path for the server/mobile code-sharing question without
mob_dev needing to know anything about it.
## Install + activation flow
Two-step opt-in by design, then a one-time trust step for plugins the
app doesn't already trust.
### Step 1 — install (`deps + mix deps.get`)
Standard Hex flow. The plugin is now resolvable; mob_dev sees it on
the next compile.
```elixir
# mix.exs
defp deps do
[
{:mob, "~> 0.6"},
{:mob_haptic_extras, "~> 0.1"}
]
end
```
```bash
mix deps.get
```
After this, `mix mob.plugins` lists the plugin as **installed but not
activated**. Its native code is NOT merged into the build. Its
permissions are NOT added to your manifest. This is deliberate — a
silent `mix deps.get` should never modify your app's permission set.
### Step 2 — activation (explicit consent in `mob.exs`)
```elixir
# mob.exs
config :mob, :plugins, [
:mob_haptic_extras,
:mob_bluetooth
]
```
Now mob_dev's compile step merges contributions. If `mob_bluetooth`
declares `BLUETOOTH_CONNECT` + `BLUETOOTH_SCAN`, those permissions
get added to `AndroidManifest.xml` only after the plugin is in this
list. mob_dev prints the diff at compile time so you see exactly
what's being added.
If you've added a plugin to `deps` but not to `config :mob,
:plugins`, the next compile prints:
```
[mob] :mob_bluetooth is installed but not activated. Add it to
`config :mob, :plugins` in mob.exs to enable its contributions
(NIFs, permissions, native code).
```
### Step 3 — trust (`mix mob.plugin.trust`)
The build refuses an activated plugin unless its signature verifies
against a fingerprint in `config :mob, :trusted_plugins` in `mob.exs`.
Apps generated by `mix mob.new` already trust the first-party plugins'
signing key, so an official plugin needs only steps 1 and 2. For any
other plugin, trust its key once:
```bash
mix mob.plugin.trust mob_haptic_extras
```
The task shows the plugin's fingerprint and declared capabilities (iOS
frameworks, Android permissions and Gradle dependencies, plist keys),
asks `y/N`, and records the fingerprint in `mob.exs`. Later releases
signed by the same key pass; a release signed by a different key is
refused until you trust it again. An unsigned plugin you're developing
goes in `config :mob, :acknowledge_unsafe_plugins` instead, and every
build prints a banner for it. See `MOB_PLUGIN_SECURITY.md`.
## Schema reference
**Theming does not ride this manifest.** `styles:` / `default_style:`
belong to a *different* file (`priv/mob_style.exs`, a style package's own
manifest) validated by a different module (`MobDev.Style`) — see
`MOB_STYLES.md`. The two files share vocabulary ("manifest") and a similar
map shape, which invites putting a `styles:` key here by mistake; it
validates (mob_dev warns on the unrecognized key but doesn't reject it) and
then goes nowhere — nothing that builds the runtime manifest behind
`Mob.Plugins.styles/0` ever reads this file.
Top-level required:
- `:name` — atom matching the package name. Convention: `mob_` prefix.
- `:mob_version` — string, semver requirement (`"~> 0.6"`).
- `:plugin_spec_version` — integer. Current: `1`. Bumped when this
schema makes breaking changes; old plugins keep working against
old spec versions.
Top-level optional:
- `:description` — short string for `mix mob.plugins` output.
- `:host_requirements` — list of strings: manual host-app steps the build
can't automate (e.g. an AndroidManifest fragment like mob_screencast's
`<service android:foregroundServiceType="mediaProjection">` or a capture
`FileProvider`). Every `mix mob.deploy --native` of the host prints them
as a warning block, so a missing manual step can't fail silently at first
feature use. Declare one entry per step, with the exact XML/snippet the
host author must add.
- `:tags` — PascalCase tag names the `~MOB` sigil should recognize as
whitelisted. For **composite** plugins whose components are pure-Elixir
expansions registered at boot via `Mob.Composite.register/2` (canonical
example: a UI kit like `mob_mishka`). A **tier-2 native plugin does not
need to list its `ui_components` tags here** — those are auto-whitelisted
from the `ui_components: [%{tag: ...}]` entries below. Accepts the same
shapes `config :mob, :extra_tags` does, plus per-platform maps:
tags: ~w(MishkaChip MishkaDrawer) # flat list
tags: "MishkaChip" # bare string
tags: MishkaChip # bare alias
tags: %{ios: ~w(FooIOS), android: ~w(FooAndroid)} # per-platform
Read at each `~MOB` call site's compile time — plugin authors get sigil
whitelist membership without asking the host to edit `config :mob,
:extra_tags`. Snake-case atoms are accepted equivalently to their
PascalCase form. Mix does not track the read, so `mix compile --force`
after a plugin update is needed to pick up new tags on already-compiled
screens.
Capability sections (any combination):
- `:nifs` — list of NIF declarations. See tier 1 example.
- `:android` — map of Android-specific contributions:
- `:gradle_deps` (list of strings)
- `:permissions` (list of strings — opt-in via activation)
- `:bridge_kt` (path to Kotlin file)
- `:jni_source` (path to C/Zig file)
- `:min_sdk` (integer, optional override)
- `:ios` — map of iOS-specific contributions:
- `:swift_files` (list of paths)
- `:plist_keys` (map — opt-in via activation)
- `:frameworks` (list of strings)
- `:min_version` (string, optional override)
Visual sections:
- `:ui_components` — list of component maps. Each entry:
- `:tag` (PascalCase string for the sigil)
- `:atom` (snake_case atom for the render tree)
- `:props` (list of atom keys, optional)
- `:ios` (map: `:view_module` SwiftUI struct name)
- `:android` (map: `:composable` function name)
Multi-screen sections:
- `:screens` — list of `%{module, default_route}` maps
- `:migrations` — `%{repo_namespace, migrations_dir}` map
- `:assets` — `%{fonts, images}` map
- `:default_font` — optional `%{family, file}` map; `file` must be one of
`assets.fonts`. Suggests `Mob.Theme.fonts[:default]`; host/style-package
choices always outrank it; two plugins declaring one is a build error.
See `MOB_FONTS.md`.
Sub-app sections:
- `:lifecycle` — `%{on_start, supervised, on_resume, on_background}` map
- `:settings` — `%{schema, editor_screen}` map
- `:notifications` — `%{handlers}` map
Setup section (tier 3+):
- `:setup` — list of interactive setup prompts for the host author.
Optional; mostly for tier-3/4 plugins. Reserved: mob_dev accepts the
key, but nothing reads it yet.
Self-test (any manifest, tiers 1–4; a tier-0 package has no manifest to
declare one in, and adding a manifest moves it into this model):
- `:selftest` — a module implementing `Mob.Plugin.SelfTest`. See
"Self-test" below. Missing: mob_dev warns today and will refuse the
plugin in a later release.
## Validation rules
`mix mob.validate_plugin` (run from a plugin project) checks:
- Required top-level fields present
- `mob_version` is a valid version requirement
- Every path in the manifest exists on disk
- Files declared as `bridge_kt` / `jni_source` / `swift_files` /
`view_module` / `composable` exist and parse
- `ui_components` entries with only one platform (warning, not error)
- `permissions` and `plist_keys` declared (warning + manual review
recommended before publishing)
- `mob_version` satisfied by the version of `:mob` in deps
- `selftest` names a module; when the plugin is compiled, that module
exports `run/1`. A manifest without `selftest` is a warning (an error
in a later mob_dev)
Compile-time validation (run by mob_dev when activating plugins):
- Plugin's `mob_version` requirement satisfied by the installed mob
- All plugins in `config :mob, :plugins` are present in `deps`
- **Cross-plugin conflict detection** (see below)
Both stages fail loud — never silent.
### Self-test
Static validation checks manifests on paper. It cannot see a NIF that
links but aborts in `nif_init`, a Kotlin bridge that compiles alone but
not next to another plugin's, or an Objective-C delegate the release link
dropped. So every plugin ships its own proof: a module implementing
`Mob.Plugin.SelfTest`, named in the manifest:
```elixir
%{
name: :mob_location,
# ...
selftest: MobLocation.SelfTest
}
```
```elixir
defmodule MobLocation.SelfTest do
@behaviour Mob.Plugin.SelfTest
@impl true
def run(%{platform: platform, device: _}) do
# A no-op while nothing runs, but it goes through the NIF into
# CLLocationManager / the Kotlin bridge: :ok proves the native side
# is linked and initialised.
case :mob_location_nif.location_stop() do
:ok -> :pass
other -> {:fail, "location_stop/0 on #{platform} returned #{inspect(other)}"}
end
end
end
```
`run/1` gets `%{platform: :ios | :android, device: :simulator | :emulator
| :physical}` and answers `:pass`, `{:fail, "why"}` or `{:skip,
:needs_hardware | :needs_user | "why"}`. The rule for `:pass`: the test
got a real answer back from native code (or, for a pure-Elixir plugin,
from its real API path). Loading a module or reading config is not a
pass. A skip is for a resource the device does not have, after the test
has proved everything it can without it; base `:needs_hardware` on what
the native side reports is absent, not on the device being an emulator.
The host runs them with `mix mob.selftest` (mob_dev; attaches to the
running app on each selected device, pre-grants the manifest's declared
permissions on emulators and simulators, calls every activated plugin's
`run/1` over distribution with a 30 s timeout, prints a table and exits
non-zero on any failure). mob_ci runs the same per nightly cell and
attributes a failure to the plugin when it fails alone, or to the plugin
set when it fails only in company. A raise, timeout or a return outside
the contract counts as a failure, so a test can assert with pattern
matches instead of rescuing.
### Cross-plugin conflict detection
Anyone can ship a plugin, and a host can activate any combination — so when two
plugins both contribute into the same shared namespace, mob_dev must catch it at
build time rather than let one silently win on device. `cross_validate` (in
`MobDev.Plugin.Validator`) runs over the activated set and **fails the build**
when two plugins clash on any of:
| Shared resource | Manifest field |
|--|--|
| Screen route | `screens.default_route` |
| Component atom | `ui_components.atom` |
| iOS native view key | `ui_components.ios.view_module` |
| Android native view key | `ui_components.android.composable` |
| Migration namespace | `migrations.repo_namespace` |
| NIF module | `nifs.module` |
| iOS Swift source basename | `ios.swift_files` |
| Android JNI source basename | `android.jni_source` |
| Android bridge class | `android.bridge_class` |
| iOS Info.plist key | `ios.plist_keys` |
| Supervised worker | `lifecycle.supervised` |
| Notification match | `notifications.handlers[].match` |
A clash on any of these is a build error naming the resource, the value, and how
many plugins declared it. Resources that are *inherently* safe — settings (keyed
per-plugin), `plugin://` images (namespaced per-plugin), Android permissions /
iOS frameworks (set-unioned) — compose without a check.
One exception for `ios.plist_keys`: a usage description (`*UsageDescription`)
every declaring plugin gives as a string is combined, not a clash (see the tier-1
notes). Any other Info.plist key two plugins declare is a clash unless every
declaration is a scalar and the host's own `ios/Info.plist` sets the key, whose
value then wins.
A note on what counts as a clash: the check is **cross-plugin**, so a single
plugin legitimately declaring the same value twice is fine — e.g. a
cross-platform NIF that ships one iOS (`lang: :objc`) and one Android
(`lang: :zig`) entry for the same `:module` is *not* a collision; two *different*
plugins claiming that module is. Detection only flags identical values, not
semantic overlap (two notification predicates that could both match the same
payload aren't comparable in general — keep matches disjoint).
**Completeness guarantee.** Every field that lands in a shared namespace is
classified in `Validator.conflict_surface/0`, and a test (`conflict_surface_test`)
asserts that classification covers *every* merge gatherer. Adding a new
shared-resource field to the schema without classifying its conflict behavior
fails CI — so the guarantee that multiples compose can't silently rot as the
schema grows. A property-based fuzzer (`merge_fuzz_test`) additionally checks the
detection is sound and complete across random N-plugin combinations.
### Runtime plugin manifest
Tiers 3 and 4 are pure-Elixir and **runtime-wired**: the host needs to know,
while running, which screens / lifecycle hooks / settings / notification handlers
the activated plugins declared. mob_dev bakes that into a generated terms file,
`priv/generated/mob_plugins.exs`, which the core `Mob.Plugins` module reads once
at boot. It is **derived state, not hand-maintained** — `mix mob.deploy --native`
regenerates it from the activated plugins' current manifests on every build (you
can also run `mix mob.regen_plugin_manifest` directly, or `--check` it in CI).
Because it regenerates unconditionally, changing a plugin's tier-3/4 sections
can't ship a stale manifest. Tier-0/1/2 plugins contribute nothing to it.
## Versioning and forward compatibility
`:plugin_spec_version` is the escape hatch for evolving the schema
without breaking existing plugins.
- Today: spec version 1. All examples above target spec 1.
- If the schema needs a breaking change (e.g., renaming `:ui_components`
to `:components`), bump to spec 2 and have mob_dev support both.
- Plugins declare which spec they target; mob_dev validates against
that spec; old plugins keep compiling unchanged.
Bumping spec version means giving plugin authors a migration window
before deprecating the old spec.
## Hot-push compatibility
Hot-pushability follows the plugin's kind, not its tier.
`MobDev.Plugin.Manifest.hot_pushable/1` computes it from the populated
sections, and `mix mob.plugins` prints it:
| Plugin has | Hot-pushable? | Why |
|--|--|--|
| No native sections (BEAM-only, any tier) | Yes | Pure Elixir; `.beam` ships via `mix mob.push` / `mix mob.deploy` |
| Only native sections (`nifs`, native `ui_components`, `android`, `ios`, `permissions`) | No | Native code requires an APK/IPA rebuild |
| Native sections plus `screens`, `lifecycle` or `migrations` | Partial | The Elixir half hot-pushes; the native half needs a rebuild |
## Why this design
A few choices to flag:
- **Manifest is data, not code.** The plugin doesn't `register_plugin`
at runtime; mob_dev reads the data at compile time. Static,
inspectable, validatable. Closer to `mix.exs`'s `project/0` than
to Phoenix's runtime route registration.
- **Two-step activation (deps + config).** Borrowed from how iOS
entitlements work — a framework supporting capability X doesn't
mean your app uses X; that requires explicit declaration. Mitigates
the supply-chain risk of silent permission merges.
- **Schema scales with tier, not exhaustive everywhere.** A tier-1
plugin doesn't fill out `:lifecycle` or `:settings`. The schema
doesn't make small plugins look big.
- **Hex is the substrate.** Versioning, dep resolution, security
posture, hexdocs publication — all free. Local `path:` deps work
the same way for development.
- **Static-link required, no dlopen.** Mob's App-Store-compatible
build pins this. Plugins follow the same rule; the build embeds
plugin NIFs into the host's `libpigeon.so`. Restrictive vs. React
Native; necessary for App Store shipping.
## Requirements raised by third-party UI-kit evaluation
Evaluating whether an established web component library (Mishka
Chelekom — shadcn-style Phoenix/Tailwind generator) could be brought
to Mob surfaced two gaps in the current spec. Both are now resolved
(2026-05-27) — see `decisions/2026-05-27-pure-elixir-composite-tier.md`
and `decisions/2026-05-27-ui-kit-distribution-model.md`. The resolution
for each is noted inline below.
### 1. Pure-Elixir composite components have no tier
`:ui_components` (tier 2) assumes **native backing** — every entry
maps `tag`/`atom` → a SwiftUI `view_module` and an Android
`composable`. There is currently no slot for a **pure-Elixir
composite**: a tag that expands to a *built-in widget tree* with no
native code (e.g. `<MishkaCombobox/>` → `Column` + `TextField` +
`List`). This is the headline ask from any UI-kit author who doesn't
write Swift/Kotlin.
What exists today:
- **Tier 0** already gives function-call composites —
`def combobox(opts), do: ~MOB"..."` invoked via the sigil's
`{combobox(...)}` child slot. Pure Elixir, hot-pushable, ships as a
plain Hex package with no manifest. A UI kit can ship its
presentational + simple-interactive components this way **now**.
- What's missing is **tag syntax** (`<MishkaCombobox/>`) and a
manifest declaration for it.
Reserved shape — a third form alongside the native one:
```elixir
ui_components: [
%{tag: "MishkaCombobox", atom: :mishka_combobox,
expand: {Mishka.Combobox, :expand}} # pure-Elixir, no :ios/:android — HONORED since 2026-06-11
]
```
This implies a **third expansion pass in core**, run before
`Mob.List.expand` / `Mob.Component.expand` in `Mob.Screen.do_render/3`
(so a composite can itself emit `<List>` / `native_view` for the later
passes), recursing to a fixpoint with a depth guard. Because the pass
runs in the screen process and is handed the screen pid (like
`Mob.List.expand` is), it can **auto-inject event targets** — the
author writes `on_select="combo_select"` and the pass wires
`{screen_pid, :combo_select}`, removing the need to thread `self()`
through every component. Hot-pushable (pure Elixir; same rule as
tier 0).
**Resolution (updated 2026-06-11):** the `expand:` field is **honored**.
`Mob.Composite` runs as the FIRST expansion pass in `Mob.Screen`'s
render pipeline (before `Mob.List.expand` / `Mob.Component.expand`, so a
composite may itself emit `<List>` or `Mob.UI.native_view`), recursing
to a fixpoint with a depth guard. The expander contract is
`expand(props, children, ctx)`; `on_*` props written as bare
strings/atoms are AUTO-INJECTED as `{screen_pid, tag}` — no `self()`
threading. Registration is the manifest `expand:` form (validated:
native backing XOR expand; expand-only plugins are hot-pushable) or
`Mob.Composite.register/2` at runtime for plain Hex kits with no
manifest. Tier-0 function composites (`{combobox(...)}`) remain fully
supported — the tag form is ergonomics on top. Worked example:
`mob_plugin_demo/plugins/mob_demo_kit` (<DemoCard>/<DemoCombobox>,
device-verified). The original deferral ("no core churn in Phase 1,
design against a concrete consumer") resolved when both conditions
flipped — see decisions/2026-06-11-composite-expansion-pass.md.
Known wart: the `~MOB` sigil warns once per call site for tags outside
its compile-time whitelist; composite tags compile fine through the
PascalCase→snake_case fallback. Follow-up: app-extendable tag list.
### 2. Generator vs. dependency — distribution model
Mishka-class kits are **shadcn-style generators**: a dev-only tool
(`mix mishka.ui.gen.component`, built on Igniter) that emits component
**source the user owns and edits** into their project; components are
free, the paid tier is templates + support, not components. The plugin
system here is **dependency-shaped** (Hex dep + two-step activation).
These are different products. A faithful UI kit for Mob may be a
**generator** (`mix mob.gen.component`) rather than a plugin at all —
or the two coexist (a generator scaffolds owned source *from* a plugin
package). Decide which model a UI kit targets before committing a
vendor to it; it determines whether a kit is a plugin in the first
place, and it's the vendor's entire identity as a tool author.
(Igniter is shared ground — Mishka is built on it and Mob's build
migration is heading there — so the generator path is not foreign
territory.)
**Resolution:** two lanes, kept separate. The **plugin (dependency)
lane** — Hex dep + two-step activation — is what this spec covers and
is in scope for the plugin epic; it's for native-backed,
capability-bearing, or centrally-maintained components. The
**generator lane** — `mix mob.gen.component`, Igniter-based, emitting
owned-source presentational components — is a separate tool tracked
with the Igniter build-migration work, **not** part of the plugin
epic. The two can coexist. For Mishka specifically, the faithful port
is the generator lane.
## Future: full-language plugins
This section parks an idea that's coherent but explicitly out of
scope for the current spec. Mob's lane is Elixir-first / BEAM-native
(see `plugin_extraction_plan.md` "Scope"). A determined plugin author
who wants to write entire screens in Python, Lua, JS, or any other
language-with-an-embedded-interpreter could in principle build that
on top of the plugin system — but the framework doesn't ship the
glue.
### What would be needed
A new manifest concept — a **screen dispatcher**:
```elixir
%{
name: :mob_python_app,
mob_version: "~> 0.6",
plugin_spec_version: 3, # speculative — not part of v2
requires: [:mob_pythonx],
screen_dispatcher: %{
kind: :python,
module: MobPythonApp.Dispatcher,
callbacks: [
mount: 3,
render: 1,
handle_event: 3
]
}
}
```
A screen registered with `kind: :python` would route its lifecycle
callbacks through the dispatcher instead of expecting an Elixir
module. The dispatcher resolves them however it wants — calling
into the embedded Python interpreter, in this case.
The user's authoring story would then look like:
```python
# app/screens/home.py
import mob
@mob.screen("home")
class HomeScreen:
def mount(self, params, session):
return {"count": 0}
def render(self, assigns):
return mob.ui.column([
mob.ui.text(f"Count: {assigns['count']}"),
mob.ui.button("Tap", on_tap=("incr", None))
])
def handle_event(self, name, _, assigns):
if name == "incr":
return {"count": assigns["count"] + 1}
```
### Why it's parked
- **Lane discipline.** Mob's value rests on Elixir + BEAM
ergonomics. Diverting design effort into "Python frontends are
equally first-class" weakens the core lane without obviously
reaching parity with React Native / Flutter / native SDKs in their
own lanes.
- **The hooks are conceptually clear; the implementation is
bottomless.** Sketching the screen-dispatcher takes a paragraph.
Making it actually pleasant (debugging, hot-reload across the
language seam, error attribution, asset bundling, IDE support) is
multi-month framework work. Worth doing only if the demand is
clear.
- **The hybrid model captures the win without the cost.** Apps that
use Mob screens in Elixir but call into Rust (via `mob_rustler`)
or Python (via `mob_pythonx`) for specific concerns — ML, perf-
sensitive paths, scripting layers — get most of the benefit
without forcing the entire screen surface through an interpreter.
See `plugin_extraction_plan.md` "Scope" for the recommended
hybrid pattern.
### What stays open
- The plugin spec versioning leaves room. If a plugin author builds
a full Python (or Lua, JS, etc.) frontend on top of the current
spec, the framework can codify the screen-dispatcher concept in a
later spec bump without breaking anyone.
- The BEAM-native path is unaffected. Gleam, LFE, Hamler, or any
BEAM language can already author Mob screens today — Mob's API is
just BEAM modules, the sigil is the only Elixir-flavoured part. A
`mob_gleam` ergonomic-wrappers plugin is a perfectly reasonable
community project that needs no framework changes.
The door stays open. Walking through it is on the ambitious
plugin author, not the framework.