Build native desktop applications with Phoenix and Elixir.
ExTauri wraps Tauri to enable Phoenix LiveView applications to run as native desktop apps on macOS, Windows, and Linux.
Website: feature showcase, installation guide, and API tour — source in website/, built with Francis.
- Phoenix LiveView as Desktop Apps — Turn your Phoenix app into a native desktop application
- Desktop APIs from Elixir — Notifications, dialogs, clipboard, filesystem, window management, global shortcuts, and more — with callback-style responses (
use ExTauri.LiveView), no JavaScript required - Server-Side Desktop Channel —
ExTauri.Desktopsends notifications and drives the system tray from GenServers and background jobs, no browser involved - One-Command Plugin Setup —
mix ex_tauri.add dialog fswires the Rust side for you (Cargo dep, plugin registration, permissions) - Native Events in LiveView — Subscribe to drag-and-drop, deep links, or custom Rust events and handle them like any other LiveView event
- True Hot Reload —
mix ex_tauri.devruns your Phoenix dev server inside the native window: code reloading and live reload just work - Multi-Window — Open and close secondary native windows from LiveView
- Single Binary Distribution — Uses Burrito to bundle everything into one executable
- Graceful Shutdown — Heartbeat-based mechanism ensures clean shutdown on CMD+Q, crashes, or force-quit
- Automated Setup — Uses Igniter for safe, AST-aware project configuration
- Cross-Platform — Build for macOS, Windows, and Linux
- Elixir >= 1.15 with OTP 27 (OTP 28 not yet supported due to Burrito ERTS availability)
- Rust — Install via rustup
- Platform dependencies — see Tauri prerequisites
Note: Zig is only required if you use Burrito for cross-compilation. For same-platform builds, Rust alone is sufficient.
# mix.exs
def deps do
[
{:ex_tauri, "~> 0.1"}
]
endmix deps.get
mix ex_tauri.installThat's it! mix ex_tauri.install handles everything automatically:
- Config — Sets sensible defaults for
app_name,host,port, andversioninconfig/config.exs - Tauri CLI — Installs via Cargo
- Project structure — Scaffolds
src-tauri/with Rust code, config, and capabilities - Supervision tree — Adds
ExTauri.ShutdownManagerto your application (via Igniter) - Release config — Adds a
:desktoprelease tomix.exs(via Igniter) - JS hook — Generates
assets/vendor/ex_tauri.jsand auto-injects the import and hook registration intoassets/js/app.js - Layout — Auto-injects the
<div id="tauri-bridge">element into your root layout
mix ex_tauri.devThis opens your Phoenix app in a native desktop window, running the actual
mix phx.server dev loop inside it — code reloading and live reload work
exactly as in the browser. (Use --sidecar release to test against a compiled
release instead; --prod-sidecar is a deprecated alias.)
Tauri launches your app through a small sidecar shim. The default shim runs
mix phx.server, but the command is configurable — point it at any dev server:
# config/config.exs — run Francis (or anything) instead of Phoenix
config :ex_tauri, :dev_command, ~w(mix francis.server)For launch strategies a command can't express, implement the ExTauri.Sidecar
behaviour, register it under config :ex_tauri, :sidecars, %{my_shim: MyModule},
and select it with mix ex_tauri.dev --sidecar my_shim. See ExTauri.Sidecar.
In production, the sidecar is your compiled release, and the Rust shell
injects environment into it. PORT and SECRET_KEY_BASE are always injected;
everything else comes from :sidecar_env, which defaults to Phoenix's signals:
# config/config.exs — the default, made explicit
config :ex_tauri, :sidecar_env, [{"PHX_SERVER", "true"}, {"PHX_HOST", "localhost"}]
# …or drop the Phoenix env entirely for another framework
config :ex_tauri, :sidecar_env, []Your app must bind to the injected PORT. Phoenix reads it automatically;
frameworks that don't (e.g. Francis) need a one-line config/runtime.exs:
# config/runtime.exs
if port = System.get_env("PORT") do
config :francis, bandit_opts: [ip: {127, 0, 0, 1}, port: String.to_integer(port)]
endThe native API bridge (notifications, dialogs, clipboard, …) is currently Phoenix LiveView-only; non-Phoenix apps get the window + server lifecycle.
Tip: Review the generated config in
config/config.exsto customize your app name, port, or window settings.
Full guide — signing, notarization, auto-updates, CI matrix: guides/releasing.md
Update the :desktop release in your mix.exs to include Burrito:
# mix.exs
def project do
[
# ... existing config
releases: [
desktop: [
steps: [:assemble, &Burrito.wrap/1],
burrito: [
targets: [
"aarch64-apple-darwin": [os: :darwin, cpu: :aarch64]
]
]
]
]
]
end# mix.exs
def application do
[
mod: {MyApp.Application, []},
extra_applications: [:logger, :runtime_tools, :inets]
]
endmix ex_tauri.buildYour app bundle will be at src-tauri/target/release/bundle/ with platform-specific packages:
- macOS:
.appand.dmg - Linux:
.deband.appimage - Windows:
.msiand.exe
| Task | Description |
|---|---|
mix ex_tauri.install |
Set up Tauri in your project (one-time) |
mix ex_tauri.add |
Add Tauri plugins (dialogs, filesystem, shortcuts, ...) |
mix ex_tauri.dev |
Run in development mode with hot-reload |
mix ex_tauri.build |
Build for production |
Run mix help ex_tauri.<task> for detailed options.
ExTauri provides Elixir modules for desktop features, all driven from your LiveView. The JS bridge uses Tauri's global API, so no npm packages are required — a stock Phoenix esbuild setup just works.
Add use ExTauri.LiveView to your LiveView and every API accepts a trailing
callback that receives the result — no manual event pattern-matching:
defmodule MyAppWeb.HomeLive do
use MyAppWeb, :live_view
use ExTauri.LiveView
def handle_event("pick_file", _params, socket) do
socket =
ExTauri.Dialog.open(socket, [title: "Pick a file"], fn
{:ok, %{"path" => path}}, socket -> assign(socket, :file, path)
{:error, _reason}, socket -> put_flash(socket, :error, "No file selected")
end)
{:noreply, socket}
end
endThese work out of the box after mix ex_tauri.install:
ExTauri.Notification— Native desktop notificationsExTauri.Shell— Open URLs, execute scoped commandsExTauri.Window— Runtime window management (minimize, fullscreen, title, size, ...)ExTauri.Event— Subscribe to native events (drag-and-drop, focus, custom Rust events)ExTauri.App— App name/version info
These modules need a Tauri plugin on the Rust side. mix ex_tauri.add wires
everything (Cargo dependency, plugin registration, permissions):
| Module | Enable with |
|---|---|
ExTauri.Dialog — File open/save dialogs, message boxes |
mix ex_tauri.add dialog |
ExTauri.Clipboard — Read/write the system clipboard |
mix ex_tauri.add clipboard |
ExTauri.Filesystem — Read/write files outside the web sandbox |
mix ex_tauri.add fs |
ExTauri.OS — Query platform, architecture, locale |
mix ex_tauri.add os |
ExTauri.GlobalShortcut — App-wide keyboard shortcuts |
mix ex_tauri.add global-shortcut |
ExTauri.Autostart — Launch at login |
mix ex_tauri.add autostart |
ExTauri.App.exit/relaunch — App lifecycle control |
mix ex_tauri.add process |
ExTauri.Updater — Check for and install updates |
mix ex_tauri.add updater |
Deep links (myapp:// URLs, via ExTauri.Event) |
mix ex_tauri.add deep-link |
ExTauri.Desktop talks to the frontend over ExTauri's internal channel (the
same local socket that carries the shutdown heartbeat), so it works from any
process — background jobs, GenServers, PubSub handlers:
# Notify from a background job
ExTauri.Desktop.notify("Sync complete", body: "128 records updated")
# Define a system tray from Elixir; clicks arrive as messages
ExTauri.Desktop.set_tray(
tooltip: "My App",
items: [%{id: "sync", label: "Sync now"}, %{id: "quit", label: "Quit"}]
)
ExTauri.Desktop.subscribe()
# ... later, in handle_info:
{:ex_tauri_event, "tray_menu_click", %{"id" => "sync"}}def handle_event("notify", _params, socket) do
socket = ExTauri.Notification.send(socket, "Saved!", body: "Your file was saved.")
{:noreply, socket}
end
def handle_event("tauri_response", %{"command" => "notification", "status" => status}, socket) do
{:noreply, assign(socket, :notification_status, status)}
endFirst, install tauri-plugin-dialog (see ExTauri.Dialog docs), then:
def handle_event("open_file", _params, socket) do
socket = ExTauri.Dialog.open(socket,
title: "Select a file",
filters: [%{name: "Text", extensions: ["txt", "md"]}]
)
{:noreply, socket}
end
def handle_event("tauri_response", %{"command" => "dialog_open", "path" => path}, socket) do
{:noreply, assign(socket, :selected_file, path)}
enddef mount(_params, _session, socket) do
socket =
if connected?(socket) do
ExTauri.Event.subscribe(socket, "tauri://drag-drop")
else
socket
end
{:ok, socket}
end
# React to files dropped onto the window
def handle_event("tauri_event", %{"event" => "tauri://drag-drop", "payload" => %{"paths" => paths}}, socket) do
{:noreply, assign(socket, :dropped_files, paths)}
end
# Update the native window title as app state changes
def handle_event("open_document", %{"name" => name}, socket) do
{:noreply, ExTauri.Window.set_title(socket, "#{name} — MyApp")}
end┌─────────────────────┐
│ Tauri Window │ Native window (Rust/WebView)
│ ┌───────────────┐ │
│ │ Phoenix UI │ │ Your LiveView app rendered in WebView
│ └───────────────┘ │
└─────────┬────────────┘
│
│ HTTP — serves your Phoenix UI to the WebView
│ Local socket — heartbeat + duplex desktop channel
│
┌─────────┴────────────┐
│ Phoenix Server │ Your Elixir app (Burrito-wrapped sidecar)
│ (Sidecar Process) │
└──────────────────────┘
Tauri launches your Phoenix app as a sidecar process. The WebView connects to Phoenix over HTTP to render your LiveView UI. A separate local socket carries the heartbeat plus a duplex JSON channel used by ExTauri.Desktop (server-side notifications, tray, native events).
In production the app binds Phoenix to an OS-assigned free port — no port
collisions with other software. The Rust shell passes PORT, PHX_SERVER,
PHX_HOST, and a generated SECRET_KEY_BASE to the sidecar (standard Phoenix
runtime.exs picks these up) and navigates the window once the server is up.
In dev, EX_TAURI_PORT pins the configured port so live reload URLs match.
ExTauri uses a local socket heartbeat to detect when the Tauri frontend exits:
ShutdownManageropens a listener:- macOS/Linux: a Unix domain socket at
<tmpdir>/tauri_heartbeat_<app_name>.sock - Windows: a TCP socket on
127.0.0.1with an OS-assigned port, published in<tmpdir>/tauri_heartbeat_<app_name>.portfor the frontend to discover
- macOS/Linux: a Unix domain socket at
- The Rust frontend connects and sends a byte every 100ms
ShutdownManagerchecks for heartbeats every 500ms- If no heartbeat is received for 1500ms, graceful shutdown begins
- Phoenix closes connections, flushes logs, and exits cleanly
This works even when the app is force-quit, crashes, or is killed unexpectedly. The socket path is unique per application (based on :app_name) to prevent collisions. When you quit normally, the frontend stops heartbeating before waiting on the sidecar, so the same mechanism drives graceful shutdown on platforms without SIGTERM (Windows).
# config/config.exs
config :ex_tauri,
version: "2.5.1", # Tauri version (default: latest)
app_name: "My App", # Application name (required)
host: "localhost", # Phoenix host (required)
port: 4000 # Phoenix port (required)config :ex_tauri,
window_title: "My Window", # Window title (defaults to app_name)
fullscreen: false, # Start in fullscreen
width: 800, # Window width
height: 600, # Window height
resize: true # Allow window resizeconfig :ex_tauri,
heartbeat_interval: 500, # How often to check heartbeat (ms)
heartbeat_timeout: 1500, # Time without heartbeat before shutdown (ms)
scheme: "http" # URL scheme (http or https)Rust/Cargo is not installed or not in your PATH.
Install Rust via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shDesktop apps need a local database path. Configure in config/runtime.exs:
database_path =
System.get_env("DATABASE_PATH") ||
Path.join([ExTauri.Paths.data_dir(), "my_app.db"])
config :my_app, MyApp.Repo,
database: database_path,
pool_size: 5Desktop releases don't run mix ecto.migrate. Add a release module that
runs migrations on startup:
# lib/my_app/release.ex
defmodule MyApp.Release do
def migrate do
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
defp repos, do: Application.fetch_env!(:my_app, :ecto_repos)
endThen call it early in your application.ex startup:
def start(_type, _args) do
MyApp.Release.migrate()
children = [
MyApp.Repo,
ExTauri.ShutdownManager,
# ...
]
# ...
endRemove or comment out cache_static_manifest in config/prod.exs if you don't use mix assets.deploy:
# config :my_app, MyAppWeb.Endpoint,
# cache_static_manifest: "priv/static/cache_manifest.json"execution error: Not authorised to send Apple events to Finder. (-1743)
Grant automation permissions: System Settings > Privacy & Security > Automation > enable Finder for your terminal app.
If mix ex_tauri.dev hangs, check if another process is using the configured port:
lsof -i :4000Kill the process or change the :port in your ExTauri config.
See the example/ directory for a complete working Phoenix desktop app with SQLite, LiveView, and Tailwind CSS.
- Tauri App — For the amazing framework
- Burrito by Digit/Doawoo — For single-binary Elixir apps
- Igniter — For safe, AST-aware code patching
- phx_new_desktop by Kevin Pan/Feng19 — For inspiration
MIT
