Skip to content

Latest commit

History

528 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Mob

Mob Logo

BEAM-on-device mobile framework for Elixir. OTP runs inside your iOS and Android apps — embedded directly in the app bundle, no server required. Screens are GenServers; the UI is rendered by Compose and SwiftUI via a thin NIF.

Hex.pmDocs

Status: Early development. Confirmed on the iOS simulator, the Android emulator, and real iOS and Android devices.

What it is

flowchart TD
A["Your Elixir app<br/>(GenServers, OTP supervision, pattern matching, pipes)"]
B["Mob.Screen.Server<br/>(one GenServer per live screen — your logic lives here)"]
C["Mob.Renderer<br/>(component tree → JSON → NIF call)"]
D1["Compose (Android)<br/>native rendering, gestures"]
D2["SwiftUI (iOS)<br/>native rendering, gestures"]
A --> B --> C
C --> D1
C --> D2
Loading

You write Elixir. The native layer handles rendering. The BEAM node runs on the device — connect your dev machine to the running app over Erlang distribution, inspect state, and hot-push new bytecode without a restart.

Installation

Add to mix.exs:

defdepsdo[{:mob,"~> 0.7"}]end

The mob_new package (separate) provides project generation, deployment tooling, and will import mob_dev which is a live dashboard. Install it as a Mix archive:

mix archive.install hex mob_new

A screen

defmoduleMyApp.CounterScreendouseMob.Screendefmount(_params,_session,socket)do{:ok,Mob.Socket.assign(socket,:count,0)}enddefrender(assigns)do%{type: :column,props: %{padding: :space_md,gap: :space_md,background: :background},children: [%{type: :text,props: %{text: "Count: #{assigns.count}",text_size: :xl,text_color: :on_background},children: []},%{type: :button,props: %{text: "Increment",on_tap: {self(),:increment}},children: []}]}enddefhandle_info({:tap,:increment},socket)do{:noreply,Mob.Socket.assign(socket,:count,socket.assigns.count+1)}endend

A tap on the button delivers {:tap, :increment} to handle_info/2 — the tag comes from the on_tap: {self(), :increment} tuple in render/1, and self() is this screen's own process.

App entry point

defmoduleMyAppdouseMob.App,theme: Mob.Theme.Darkdefnavigation(_platform)dostack(:home,root: MyApp.CounterScreen)enddefon_startdoMob.Screen.start_root(MyApp.CounterScreen)Mob.Dist.ensure_started(node: :"my_app@127.0.0.1",cookie: :secret)endend

Navigation

# Push a new screenMob.Socket.push_screen(socket,MyApp.DetailScreen,%{id: 42})# Pop backMob.Socket.pop_screen(socket)# Tab bar layouttab_bar([stack(:home,root: MyApp.HomeScreen,title: "Home"),stack(:profile,root: MyApp.ProfileScreen,title: "Profile")])

Theming

# Named themeuseMob.App,theme: Mob.Theme.Dark# Override individual tokensuseMob.App,theme: {Mob.Theme.Dark,primary: :rose_500}# From scratchuseMob.App,theme: [primary: :emerald_500,background: :gray_950]# Runtime switch (accessibility, user preference)Mob.Theme.set(MobThemes.Citrus)

Core ships Mob.Theme.Light, Mob.Theme.Dark, and Mob.Theme.Adaptive (follows the system light/dark setting). The preset themes — MobThemes.Obsidian, MobThemes.ObsidianGlass, MobThemes.Citrus, MobThemes.Birch, MobThemes.Material3 — live in the mob_themes style package:

# mix.exs{:mob_themes,"~> 0.1"}# mob.exsconfig:mob,:styles,[:mob_themes]config:mob,:default_style,:mob_themes# boots into MobThemes.Obsidian

See the Theming guide for details.

Device APIs

All async — call the function, handle the result in handle_info/2:

# Haptic feedback (core; synchronous — no handle_info needed)Mob.Haptic.trigger(socket,:success)# Camera (mob_camera plugin)MobCamera.capture_photo(socket)defhandle_info({:camera,:photo,%{path: path}},socket),do: ...# Location (mob_location plugin)MobLocation.start(socket,accuracy: :high)defhandle_info({:location,%{lat: lat,lon: lon}},socket),do: ...# Push notifications (mob_notify plugin)MobNotify.register_push(socket)defhandle_info({:push_token,:ios,token},socket),do: ...

Some capabilities ship as first-party plugins rather than in core — see the First-Party Packages catalog for the full set. Activating one is two lines:

# mix.exs{:mob_camera,"~> 0.1"}# mob.exsconfig:mob,:plugins,[:mob_camera]

In core: Mob.Clipboard, Mob.Share, Mob.Files, Mob.Audio, Mob.Motion, Mob.Permissions. As plugins: MobCamera (mob_camera), MobLocation (mob_location), MobNotify (mob_notify), MobPhotos (mob_photos), MobBiometric (mob_biometric), MobScanner (mob_scanner — also needs mob_camera), MobBluetooth (mob_bluetooth).

For a full audit of what mob covers vs. what's missing vs. what's out of scope (compared against React Native + Expo SDK capabilities), see the Mobile Surface Matrix. Set realistic expectations before starting an app; spot plugin candidates if you want to fill a gap.

Background execution

The BEAM runs on the device, but it does not keep running once the app is backgrounded. iOS suspends the whole process within seconds — schedulers stop, GenServers freeze, and any distribution / socket connections drop. Android does the same unless you run a foreground service (the persistent-notification kind). This is an OS constraint every mobile runtime lives with, not a Mob limitation.

So a server can't push straight into a long-lived GenServer — the OS has to wake you first, via APNs (iOS) or FCM (Android). The shape is:

# Register for a push token; your server stores it and sends through APNs/FCM.# MobNotify ships in the mob_notify plugin; see the mob_push package for the# server side.MobNotify.register_push(socket)defhandle_info({:push_token,:ios,token},socket),do: ...# React to the OS suspending / resuming the app. A push wakes the app, the BEAM# resumes, your handler runs in a short window, then the OS suspends you again.Mob.Device.subscribe([:app])defhandle_info({:mob_device,:did_enter_background},socket),do: ...defhandle_info({:mob_device,:will_enter_foreground},socket),do: ...

Mob.Device.foreground?/0 reports the current state. For true always-on (e.g. a live connection held open), an Android foreground service is the only path; iOS will not allow it. Otherwise treat the device as push-driven: server → APNs/FCM → OS wakes app → BEAM handles the event → BEAM suspends again.

What's in the box

The pre-built OTP runtime that ships with each app includes:

  • Real :crypto — OpenSSL 3.x, statically linked into the app's native lib. ECDH (incl. x25519, secp256r1), AEAD (ChaCha20-Poly1305, AES-GCM), SHA-2 hashes, HMAC, PBKDF2, HKDF, real strong_rand_bytes/1. No insecure shim, no dlopen.
  • :public_key + :ssl — cert parsing, HTTPS clients, TLS sockets. The whole standard :ssl API is available.
  • Phoenix-compatible — Phoenix, LiveView, plug_crypto, jose, joken, guardian, oban, and anything else using :crypto/:ssl works unmodified.
  • Erlang distributionmix mob.connect opens an IEx session on-device. Hot-push individual modules with nl/1.

Native APIs (above) cover audio, files, clipboard, share, motion sensors, and permissions in core, with camera, location, push, photos, biometrics, and scanning available as first-party capability plugins.

The OTP runtime tarball is ~80 MB compressed; sliced per-arch by App Thinning (iOS) and App Bundle (Android) so each user only downloads ~25 MB of native runtime, on top of the BEAM bytecode for your app.

Live development

mix mob.connect # tunnel + connect IEx to running device
nl(MyApp.SomeScreen) # hot-push new bytecode, no restart# In IEx:
Mob.Test.screen(:"my_app_ios@127.0.0.1") #=> MyApp.CounterScreen
Mob.Test.assigns(:"my_app_ios@127.0.0.1") #=> %{count: 3, ...}
Mob.Test.tap(:"my_app_ios@127.0.0.1", :increment)

Testing

defmoduleMyApp.CounterScreenTestdouseMob.ScreenCasetest"increments count"doview=mount_screen(MyApp.CounterScreen)view=render_info(view,{:tap,:increment})assertassigns(view).count==1endend

Related packages

PackagePurpose
mob_devDev tooling: mix mob.new, mix mob.deploy, mix mob.connect, live dashboard
mob_pushServer-side push notifications (APNs + FCM)

Documentation

Full documentation at hexdocs.pm/mob, including:

Development

Clone, then run once:

mix setup

That fetches deps and activates the repo's git hooks (.githooks/pre-push): mix format --check, mix credo --strict (incl. ExSlop), and mix compile --warnings-as-errors run on every push, plus the full test suite when mix.exs changes — the same gate CI enforces before publishing.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

240 stars

Watchers

11 watching

Forks

Releases

Packages

Contributors

Languages