A mob plugin is a Hex package (or a local path dependency) that extends a host
app. It can be a few helper functions, a set of screens with their own
database tables, a background service, or a wrapper around a platform API in C,
Kotlin and Swift. A host app turns it on in mob.exs; having it in deps is
not enough.
This guide is for people writing plugins, and for the coding agents that help
them. It covers the decision you make first (what kind of plugin), a
walkthrough for each kind from scaffold to Hex, and the reference material
both kinds need. The full manifest schema is in
MOB_PLUGINS.md and the trust model in
MOB_PLUGIN_SECURITY.md; this guide doesn't repeat
them.
The walkthroughs were run end to end against a host app generated by
mix mob.new (mob_new 0.6.2) on an Android emulator. The scaffold's release
setup and the refusal of legacy v1 signatures are new in mob_dev 0.7.6;
use 0.7.6 or later.
Which kind of plugin are you writing?
There are two kinds. Everything else follows from which one you pick.
BEAM-only plugins are Elixir code only. They can ship:
- helper modules and
Mob.Screens; - pure-Elixir composite components (a
ui_componentsentry withexpand: {Module, :function}, seeMob.Composite); - lifecycle hooks (
on_start,on_resume,on_background), router hooks (Mob.Router.Hooks), supervised workers, or a whole OTP application with its own supervision tree; - typed settings, notification handlers, and Ecto migrations.
mob_deliver is the reference: signed over-the-air updates and just-in-time
screens with no native code at all. It uses on_start, on_resume /
on_background and both router hooks, and gets its supervision tree from its
own OTP application, which mob starts at boot.
Native plugins integrate with the app's native build. Anything in this list makes a plugin native:
- NIFs in C, Zig or Objective-C, statically linked into the app on both platforms;
- native UI components (a SwiftUI view and a Compose function behind a
<MyView>tag); - Kotlin and Swift bridge code, JNI and Objective-C glue;
- iOS frameworks, Gradle dependencies, Android permissions, Info.plist keys;
host_requirements: manual steps such as anAndroidManifest.xml<service>fragment.
mob_nfc, mob_camera and mob_wake are native plugins.
What each kind costs:
| BEAM-only | Native | |
|---|---|---|
| Reaches the device by | mix mob.deploy (BEAMs and the runtime plugin manifest) | mix mob.deploy --native (rebuild and reinstall the app) |
Hot push (mix mob.push, dist deploy) | Yes, all of it | Only the Elixir half; native code needs a rebuild |
| Signing | Required once it has a manifest | Required; the signature also covers every native source |
| Platform parity | Free: the same BEAMs run on iOS and Android | You write and test both sides, or declare one platform |
| Testing | mix test on the host covers most of it | Host tests cover the Elixir half; the native half only runs on a device |
If you can do it in Elixir, do. A plugin can start BEAM-only and grow a NIF later.
Where the tiers fit
Older docs and the tooling describe plugins in tiers 0 to 4.
mix mob.new_plugin --tier N still picks its template by tier, and
mix mob.plugins prints a tier for each plugin, computed from the manifest
sections present:
| Tier | Template ships | Kind |
|---|---|---|
| 0 | Helper module, no manifest | BEAM-only |
| 1 | C NIF + Erlang stub + Elixir wrapper | Native |
| 2 | Native UI component (Kotlin + Swift + Mob.Component) | Native (an expand: composite is tier 2 but BEAM-only) |
| 3 | Two screens + an Ecto migration | BEAM-only (fonts and images in assets: are copied by the native build) |
| 4 | Lifecycle hooks + supervised worker + settings + notification handler | BEAM-only |
The tier is the highest section present, not what it costs. A plugin with
lifecycle and nifs is tier 4 and native. mix mob.plugins has a
separate HOT-PUSH column (yes, partial, no) that answers the cost
question directly.
Walkthrough: a BEAM-only plugin
This builds my_hooks, a plugin with its own OTP application, an on_start
that reads app config and registers a router hook, and foreground/background
hooks. Run the commands from the host app's root.
1. Scaffold
mix mob.new_plugin my_hooks --tier 4
plugins/my_hooks/
mix.exs # package metadata, mob + mob_dev deps
lib/my_hooks.ex # lifecycle MFAs
lib/my_hooks/worker.ex # supervised GenServer
lib/my_hooks/notifications.ex # notification handler
lib/my_hooks/settings_screen.ex # settings editor screen
priv/mob_plugin.exs # the manifest
test/test_helper.exs
test/my_hooks_test.exs # structural checks of the manifest
README.md
CHANGELOG.md # release notes come from here
.gitignore # ignores priv/mob_plugin.sig
.github/workflows/release.yml # signs and publishes on a version bumpThe generated code compiles and the manifest validates as-is. Delete what you
don't need: a plugin that only needs on_start can drop settings,
notifications and the worker from the manifest and their modules.
The generated mix.exs depends on :mob and, for development only, on
:mob_dev (which provides mix mob.validate_plugin, mob.plugin.keygen and
mob.plugin.sign inside the plugin directory). Its package/0 lists the
files Hex ships, including all of priv/, and @source_url is a placeholder
(https://github.com/OWNER/my_hooks) that you set to the plugin's
repository; the release workflow refuses to publish until you do.
2. The manifest
priv/mob_plugin.exs is data: a map with three required keys (name,
mob_version, plugin_spec_version) and the sections you use. The scaffold's
lifecycle section:
lifecycle: %{
on_start: {MyHooks, :start, []},
on_resume: {MyHooks, :on_resume, []},
on_background: {MyHooks, :on_background, []},
supervised: [MyHooks.Worker]
}Every key is in MOB_PLUGINS.md.
3. Implement
If the plugin needs processes of its own, give it an OTP application. mob
starts every activated plugin's application before any plugin's on_start
(see Boot order), so the
supervision tree and its dependencies are up when on_start runs:
# mix.exs
def application do
[extra_applications: [:logger], mod: {MyHooks.Application, []}]
enddefmodule MyHooks.Application do
@moduledoc false
use Application
@impl Application
def start(_type, _args) do
Supervisor.start_link([], strategy: :one_for_one, name: MyHooks.Supervisor)
end
endon_start reads configuration at runtime and registers hooks. It runs
during boot, so it must not raise, return {:error, _} or block: any of
those stops the app from starting.
defmodule MyHooks do
require Logger
def start do
greeting = Application.get_env(:my_hooks, :greeting, "not configured")
Logger.info("[my_hooks] on_start, greeting=#{inspect(greeting)}")
Mob.Router.Hooks.register(:after_first_render, {__MODULE__, :first_render, []})
rescue
e -> Logger.error("[my_hooks] on_start failed: " <> Exception.message(e))
end
def first_render(screen), do: Logger.info("[my_hooks] first frame from #{inspect(screen)}")
def on_resume, do: Logger.info("[my_hooks] on_resume")
def on_background, do: Logger.info("[my_hooks] on_background")
end4. Test on the host
From the plugin directory:
cd plugins/my_hooks
mix deps.get
mix test
The generated tests check that the manifest evaluates and that every module it names loads. Put the plugin's logic in plain functions and test them here; the device is for wiring.
5. Validate
Still in the plugin directory:
mix mob.validate_plugin
✓ manifest validIt checks the required fields, that every path the manifest names exists,
and that the installed :mob satisfies mob_version. It exits non-zero on
any error.
6. Sign
Every plugin with a manifest is signed, and a host refuses to build with an activated plugin whose signature is missing, invalid or from a key it doesn't trust. Generate a key pair once per plugin:
mix mob.plugin.keygen
generated ed25519 keypair for my_hooks
private key: ~/.mob/keys/my_hooks.priv (mode 0600)
public key: plugins/my_hooks/priv/mob_plugin.pub
fingerprint: ed25519:pjffs39rb+Ap9SvGELI5FHDMv+oomvf5wcpiNlnTeuA=The private key never leaves your machine (and CI's secret store; see
Publish). Commit priv/mob_plugin.pub. The fingerprint
is the public key, so it stays the same when you re-sign.
To test the plugin in a host app, sign it locally:
mix mob.plugin.sign && mix mob.validate_plugin
This writes priv/mob_plugin.sig, a v2 envelope: an Ed25519 signature over
the SHA-256 of every file the native build reads from the plugin (the
manifest, NIF sources and every other file in their directories, Kotlin and
Swift files, migrations, fonts, images). The host checks those hashes before
it evaluates the manifest, so a tampered manifest is never run, and it
refuses a signature that leaves out any build input. Re-sign after changing
any of them. Elixir code under lib/ is not a build input and is not
covered; Hex's package checksum and mix.lock pin it.
mob_dev refuses legacy v1 signatures (plugin :<name> ships a legacy v1 signature). If you published a release signed before v2, re-sign with
mix mob.plugin.sign and publish a new version.
priv/mob_plugin.sig is gitignored and never committed: you sign locally to
develop, and the release workflow signs again, over exactly the files it
publishes (see Publish).
7. Activate in a host app
Activation is two deliberate steps, plus trust:
# mix.exs
{:my_hooks, path: "plugins/my_hooks"}
# mob.exs
config :mob, :plugins, [..., :my_hooks]mix deps.get
mix mob.plugin.trust my_hooks
mix mob.plugin.trust shows the plugin's fingerprint and declared
capabilities, asks y/N, and records the key in
config :mob, :trusted_plugins in mob.exs. A later release signed by a
different key is refused until you trust it again. For an unsigned prototype,
config :mob, :acknowledge_unsafe_plugins, [:my_hooks] lets the build through
with a banner on every build.
App configuration for the plugin goes in the host's config/config.exs like
any dependency's; it reaches the device (see
Boot order):
config :my_hooks, greeting: "hello from config.exs"Check what the host sees:
mix mob.plugins
PLUGIN TIER HOT-PUSH VETTING STATUS
──────────────────────────────────────────────────────────────────────────
my_hooks tier 4 yes clean activated8. Deploy and verify on a device
A BEAM-only plugin doesn't need a native rebuild. Regenerate the runtime
plugin manifest (priv/generated/mob_plugins.exs, which the device reads at
boot to find screens, lifecycle hooks, settings and notification handlers),
then deploy:
mix mob.regen_plugin_manifest
mix mob.deploy --device <serial>
on_start runs at boot, so the app has to start for it to run. The deploy
summary says which happened: Apps restarted (the plugin is live), or
Hot-loaded into the running app — not restarted, in which case the old
process is still running and you restart the app (mix mob.connect
restarts it unless you pass --no-restart). Then read the device log
(adb logcat -s Elixir on Android):
[mob] app config: loaded :mob_app_config (my_app: 1 key, mob: 2 keys, my_hooks: 1 key)
[my_hooks] OTP application started
[mob] plugin :my_hooks: OTP application started
[my_hooks] on_start, greeting="hello from config.exs"
[my_hooks] first frame from MyApp.HomeScreenPress Home and bring the app back to see [my_hooks] on_background and
[my_hooks] on_resume. On Android these need the lifecycle hook in the app's
MainActivity.kt (mob_new 0.6.2+ generates it; mix mob.doctor says what to
add to an older app).
From here, Elixir changes reach the running app without a restart:
mix mob.deploy --device <serial> hot-loads over distribution and the app
keeps its state.
9. Publish to Hex
The scaffold's .github/workflows/release.yml follows the first-party
plugins' pattern (mob_wake, mob_deliver, mob_nfc): mix.exs is the
source of truth for the version, and bumping it publishes. You never sign a
release by hand.
One-time setup:
- Set
@source_urlinmix.exsto the plugin's GitHub repository. - Commit
priv/mob_plugin.pub(priv/mob_plugin.sigstays ignored). - Add two repository secrets (Settings → Secrets and variables → Actions):
HEX_API_KEY, andMOB_PLUGIN_SIGN_KEY, set to the exact contents of~/.mob/keys/my_hooks.priv. The scaffold's next-steps output prints thegh secret setcommands for both.
Then, for each release, add a ## [x.y.z] section to CHANGELOG.md, bump
version: in mix.exs, and push to main or master. The workflow tags the
version, creates a GitHub Release from the CHANGELOG section, and, unless that
version is already on Hex:
- fails if
@source_urlis still the placeholder, ifMOB_PLUGIN_SIGN_KEYis empty (it never publishes an unsigned plugin), or if the key doesn't derive the committedpriv/mob_plugin.pub(a wrong secret would publish a signature no host can verify); - runs
mix mob.validate_plugin; - writes the key to
~/.mob/keys/<name>.priv, runsmix mob.plugin.sign, and deletes the key; - runs
mix hex.publish --yes.
Each step skips what's already done, so re-running it from the Actions tab finishes a release that stopped part way. You can run the key check locally before adding the secret:
MOB_PLUGIN_SIGN_KEY="$(cat ~/.mob/keys/my_hooks.priv)" elixir -e '
priv = System.fetch_env!("MOB_PLUGIN_SIGN_KEY") |> String.trim() |> Base.decode64!()
{pub, _} = :crypto.generate_key(:eddsa, :ed25519, priv)
committed = "priv/mob_plugin.pub" |> File.read!() |> String.trim() |> Base.decode64!()
IO.puts(if pub == committed, do: "signing key matches priv/mob_plugin.pub", else: "MISMATCH")'
and check what the package will contain with mix hex.build (the file list
must include priv/mob_plugin.exs, priv/mob_plugin.pub and, after
signing, priv/mob_plugin.sig).
Hosts then depend on the Hex release and trust its key once, as in step 7:
{:my_hooks, "~> 0.1"}Walkthrough: a native plugin
This builds my_math, a C NIF that adds two integers. Steps that are the same
as for a BEAM-only plugin (test, validate, sign, activate, publish) are
listed with what differs.
1. Scaffold
mix mob.new_plugin my_math --tier 1
plugins/my_math/
mix.exs
lib/my_math.ex # Elixir wrapper (defdelegate)
src/my_math_nif.erl # Erlang NIF stub, tolerant on_load
priv/mob_plugin.exs # manifest with :nifs
priv/native/jni/my_math_nif.c # the C NIF
test/test_helper.exs
test/my_math_test.exs
README.md, CHANGELOG.md, .gitignore, .github/workflows/release.ymlFor a native UI component use --tier 2 (a Mob.Component, a Kotlin
Composable and a Swift view).
2. The manifest
nifs: [
%{module: :my_math_nif, native_dir: "priv/native/jni"}
]:module is the NIF's Erlang module name and a C token, not an Elixir
module: ERL_NIF_INIT uses it as the registered module name and as the
prefix of the static init symbol (my_math_nif_nif_init). :lang selects
:c (the default), :zig or :objc. Permissions, Gradle dependencies,
iOS frameworks, Info.plist keys, Kotlin/Swift bridges and
host_requirements all go in the same manifest; see
MOB_PLUGINS.md.
3. Implement
The C side registers functions under the module name:
#include <erl_nif.h>
static ERL_NIF_TERM add(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
int a, b;
(void)argc;
if (!enif_get_int(env, argv[0], &a) || !enif_get_int(env, argv[1], &b))
return enif_make_badarg(env);
return enif_make_int(env, a + b);
}
static ErlNifFunc nif_funcs[] = {
{"add", 2, add},
};
ERL_NIF_INIT(my_math_nif, nif_funcs, NULL, NULL, NULL, NULL)The Erlang stub declares the same functions. Its on_load tolerates a load
failure, so the module still loads on the host, where the NIF isn't linked:
-module(my_math_nif).
-export([add/2]).
-on_load(init/0).
init() ->
case erlang:load_nif("my_math_nif", 0) of
ok -> ok;
{error, _} -> ok
end.
add(_A, _B) -> erlang:nif_error(nif_not_loaded).defmodule MyMath do
defdelegate add(a, b), to: :my_math_nif
end4–7. Test, validate, sign, activate
As for a BEAM-only plugin, with two differences:
Host tests can't call the NIF (
:nif_not_loadedon the host). Test the Elixir around it: argument handling, parsing of what the NIF returns.Signing covers the C source and every other file in
native_dir, so re-runmix mob.plugin.signafter every native change. With a stale signature,mix mob.pluginsprintsskipping my_math: invalid_signature(and lists the plugin as having no manifest), and a native build stops:** (Mix) plugin signature check failed — refusing to build (see MOB_PLUGIN_SECURITY.md, Phase 2): - signature for plugin :my_math is invalid — this can indicate tampering with the plugin's manifest or source files.
8. Deploy and verify on a device
Native code reaches the device only through a native build:
mix mob.deploy --native --device <serial>
The build compiles and links the NIF, merges permissions and other native
contributions, regenerates the runtime plugin manifest and reinstalls the
app. A plain mix mob.deploy after activating a NIF plugin warns that the
installed app was built without it. Call the NIF on the device over
distribution (see Agentic coding for connecting):
mix mob.connect --no-iex --no-restart --device <serial>
:rpc.call(:"my_app_android_emulator_5560@127.0.0.1", MyMath, :add, [40, 2])
#=> 42Repeat for each platform you support. mix mob.validate_plugin warns when a
UI component has only one platform's side.
9. Publish
As for a BEAM-only plugin. The scaffold's package/0 already ships src and
all of priv, which the host's native build compiles from
deps/<name>/priv/. Leaving a signed file out of the package fails
verification on every host.
Boot order and configuration on device
A Mob app boots from its own BEAM entry, not a release, so the start/0 that
use Mob.App generates does the work a release would. In order:
- App config.
config/config.exs(andconfig/runtime.exs, evaluated on the build machine at build time, not on the phone) is put into the application environment from the:mob_app_configmodule mob_dev ships with every build and BEAM push.Application.get_env(:my_plugin, :key)therefore works on device, and the values survive the plugin's own application being loaded later (they're put withpersistent: true). The:mob_devkey is dropped. Logged as[mob] app config: loaded :mob_app_config (my_plugin: 3 keys, …). Logger is already running by then, soconfig :logger, level: …is applied withLogger.configure/1; settings Logger reads only at its own startup (:default_handler,:default_formatter, …) don't take effect. A config change reaches a running app at its next start, not on a hot push. SeeMob.AppConfig. - Framework services (navigation registry, state, sender, device, …).
- Plugin OTP applications.
Application.ensure_all_started/1for each activated plugin (its manifestnameis its OTP app), so the plugin's supervision tree and its deps (Req, Finch, …) are up. A failure, a crash, or a start that takes longer than 5 s is logged ([mob] plugin :my_plugin: OTP application failed to start, continuing boot: …) and boot continues. A start that timed out is abandoned without undoing anything: dependencies it already started stay running for the plugins after it, even if its own start later fails. - Plugin
lifecycle.on_start, in activation order, then the plugins'supervisedchildren. Anon_startthat raises or returns{:error, reason}fails the app's boot. - The app's own
on_start/0, which starts the root screen.
Because step 1 can be absent (an app built with an older mob_dev, or a host test), a plugin should still treat a missing setting as "not configured" and keep running, not crash.
Foreground and background hooks
A manifest's lifecycle may declare on_resume and on_background MFAs.
Mob.Plugins.Lifecycle subscribes to Mob.Device's :app events and calls
on_resume on :did_become_active and on_background on
:did_enter_background; a hook that raises is logged and the other plugins'
hooks still run. On Android those events exist only when the app-owned
MainActivity.kt and beam_jni.c carry the lifecycle hook (generated by
mob_new 0.6.2+; mix mob.doctor says what to add to an older app). Without
it the hooks never fire and Mob.Device.foreground?/0 stays true.
Router hooks
A plugin that needs to act around navigation registers a hook at runtime —
from its lifecycle.on_start, which runs before the app starts its root
screen — with Mob.Router.Hooks.register/2. No app configuration.
:before_navigate— called with the destination (a screen module or a route atom) before a navigation that mounts a screen (push_screen,reset_to; pops don't call it). Return:ok,{:redirect, module}to mount another screen instead (same kind of navigation: a push still pushes, so BACK returns to the screen underneath),{:reset, module}to replace all navigation withmodule(every stack discarded, persisted screen state cleared, empty params; BACK leaves the app), or{:error, reason}to refuse (navigation stays put, as for an unknown destination). Hooks run in registration order and the first non-:okanswer wins; a hook that raises, exits or returns anything else counts as a refusal. Runs in the router process: keep it fast when there's nothing to do.:after_first_render— called once per VM, in its own process, once the app's first frame has actually reached the native layer: the root screen'srender/1returned without raising andMob.Sendercommitted the tree (set_root). The router's first paint is asynchronous, so this is later than the router starting. A root screen whoserender/1raises never triggers it; if a restart of that screen later renders, it fires then. If the root screen navigates away before painting, the destination's first frame counts. The committed screen's module is appended to the hook's arguments. To wait for a frame from a particular screen, callMob.Router.Hooks.rearm_first_render/0from the hook when it's the wrong one: the next committed frame, from any screen, fires it again.
def on_start do
Mob.Router.Hooks.register(:before_navigate, {MyPlugin, :before_navigate, []})
Mob.Router.Hooks.register(:after_first_render, {MyPlugin, :first_render, []})
end
# MyPlugin.before_navigate(destination) :: :ok | {:redirect, m} | {:reset, m} | {:error, r}
# MyPlugin.first_render(screen_module)mob_deliver (signed OTA updates and just-in-time screens) is the reference
plugin for these hooks and uses both: :before_navigate fetches a
just-in-time screen (or resets to its forced-update screen),
:after_first_render ends a freshly installed update's probation — safe only
because a crash in the root screen's render never reaches it, and only on a
frame from the app's own root (it rearms while its update screen is the one
showing). Its rollback relies on one more framework behaviour: on a device,
an app left with no live screen (the root screen failed to start, or the
current screen crashed past its restart limit) logs why and ends its process
with System.halt(1), so the next launch is a cold boot.
Multiple plugins and conflicts
A host can activate any combination of plugins, so mobdev checks at build time
that they compose: two plugins may not claim the same screen route, NIF module,
native view key, migration namespace, supervised worker name, plist key, or
notification match. A clash is a loud build error, not a silent last-write-wins.
See MOB_PLUGINS.md → Cross-plugin conflict detection for the
full list and the completeness guarantee. Keep your routes/namespaces/worker
names specific to your plugin (the scaffold's `"<name>"` defaults do this).
Style packages (a sibling lane)
A package that ships a look rather than a capability uses the styles lane:
a four-field priv/mob_style.exs (name, mob_version,
style_spec_version, theme: — a module exporting theme/0) instead of a
plugin manifest, activated via config :mob, :styles +
config :mob, :default_style. Core applies the default style's theme at
boot. See MOB_STYLES.md for the schema and current
implementation status; mob_themes is the worked example. A single package
may ship both manifests.
Worked examples
The mob_plugin_demo project carries a device-verified plugin for each
template:
| Plugin | Kind | Demonstrates |
|---|---|---|
mob_palette_demo | BEAM-only | Helpers, no manifest, activated via mob.exs only |
mob_demo_kit | BEAM-only | Pure-Elixir composite components (<DemoCard>, <DemoCombobox>) via expand: |
mob_demo_kv_browser | BEAM-only (+ a font) | Two screens + a migration + a bundled font + a plugin:// image |
mob_demo_subapp | BEAM-only | Lifecycle hooks, supervised worker, settings, notification handler |
mob_demo_gen_screens | BEAM-only | Spec-v2 screens_generator and lifecycle/settings/notifications in one plugin |
mob_demo_haptic_extras | Native | C NIF + iOS framework (CoreHaptics) |
mob_demo_zig_extras | Native | Zig NIF + Android Kotlin bridge |
mob_demo_perm | Native | Extending the permission registry |
mob_demo_signature_pad | Native | SwiftUI / Compose component |
The shipped first-party packages are full-size references. BEAM-only:
mob_deliver (OTP application, lifecycle and router hooks) and mob_ash
(spec-v2 screens_generator against host config). Native: mob_camera
(Objective-C + Zig + Kotlin + permission registry), mob_scanner (depends on
mob_camera; an Activity host requirement), mob_nfc, mob_wake, and
mob_notify (host_requirements). mob_themes is a style package. See the
First-Party Packages catalog.
For coding agents
Checklist
Run each check and read its output; an exit code alone proves nothing.
| Step | Command | Expect |
|---|---|---|
| Scaffold | mix mob.new_plugin <name> --tier N (host root) | plugins/<name>/ created |
| Host tests | mix test (plugin dir) | all pass |
| Validate | mix mob.validate_plugin (plugin dir) | ✓ manifest valid |
| Sign (local) | mix mob.plugin.keygen once, then mix mob.plugin.sign && mix mob.validate_plugin (plugin dir) | fingerprint printed, ✓ manifest valid |
| Activate | deps + config :mob, :plugins + mix mob.plugin.trust <name> (host) | mix mob.plugins shows clean / activated |
| Deploy, BEAM-only | mix mob.regen_plugin_manifest && mix mob.deploy --device <serial> | app restarted, or hot-loaded (then restart it for on_start) |
| Deploy, native | mix mob.deploy --native --device <serial> | BUILD SUCCESSFUL, Deployed to 1 device(s) |
| Boot | device log | [mob] plugin :<name>: OTP application started, your on_start line, no failed to start |
| Call | mix mob.connect --no-iex --no-restart --device <serial>, then :rpc.call(node, Mod, :fun, args) | the value, not {:badrpc, _} or :nif_not_loaded |
Rules that bite
on_startmust not raise, return{:error, _}or hang. It runs inside boot; any of those stops the app from starting. Rescue, log and return:ok. Do slow work in your OTP application's processes, not inon_start.- Router hooks run inside the router. A
:before_navigatehook that raises refuses the navigation; one that blocks freezes navigation. Return:okfast when there's nothing to do. - Read configuration at runtime with
Application.get_env/3. The host'sconfig/*.exsarrives throughmob_app_configat boot, after your code was compiled. UseApplication.compile_env/3only for values that must be fixed at build time, such as a trust root (mob_deliver's:trusted_publish_key). - No compile-time
~r//literals. They compile to a call that OTP 28 removed, and the device runs OTP 28. UseRegex.compile!/2at runtime. - NIF
:moduleis a C token ([a-z][a-z0-9_]*), not an Elixir module. - Names are global across plugins. Screen routes, migration namespaces, supervised worker names, NIF modules, native view keys, plist keys and notification matches must be unique among the host's activated plugins. Prefix them with the plugin name.
- Re-sign after changing any build input (manifest, native sources,
Kotlin/Swift, migrations, assets). Changes under
lib/don't need it. A stale signature shows asskipping <name>: invalid_signatureinmix mob.plugins, which then lists the plugin astier 0 … no manifest; that line means "re-sign", not "the manifest is gone". - Never commit
priv/mob_plugin.sigor a private key, and never sign a release by hand. The release workflow signs what it publishes. Commitpriv/mob_plugin.pubonly. - Always pass
--device. Without it,mix mob.deploypicks an emulator or simulator by itself, which may be one someone else is using. - Native code needs
--native.:nif_not_loadedon the device means the installed app was built without your NIF. - Settings are
Mob.Plugins.get_setting/2andMob.Plugins.put_setting/3, validated against the schema's:type.