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.
- Phoenix LiveView as Desktop Apps — Turn your Phoenix app into a native desktop application
- Single Binary Distribution — Uses Burrito to bundle everything into one executable
- Hot Reload in Dev Mode — Full Phoenix development experience with live reload
- 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, git: "https://github.com/filipecabaco/ex_tauri.git"}
]
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 starts your Phoenix app as a native desktop window with full hot-reload support.
Tip: Review the generated config in
config/config.exsto customize your app name, port, or window settings.
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.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 common desktop features. Some are included by default, others require installing additional Tauri plugins.
These work out of the box after mix ex_tauri.install:
ExTauri.Notification— Native desktop notificationsExTauri.Shell— Open URLs, execute scoped commands
These modules need a Tauri plugin added to your src-tauri/ project.
See each module's documentation for setup instructions.
ExTauri.Dialog— File open/save dialogs, message boxesExTauri.Clipboard— Read/write the system clipboardExTauri.Filesystem— Read/write files outside the web sandboxExTauri.OS— Query platform, architecture, locale
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)}
end┌─────────────────────┐
│ Tauri Window │ Native window (Rust/WebView)
│ ┌───────────────┐ │
│ │ Phoenix UI │ │ Your LiveView app rendered in WebView
│ └───────────────┘ │
└─────────┬────────────┘
│
│ HTTP — serves your Phoenix UI to the WebView
│ Unix Socket — heartbeat for lifecycle management
│
┌─────────┴────────────┐
│ 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 Unix domain socket carries heartbeat signals for lifecycle management.
ExTauri uses a Unix domain socket heartbeat to detect when the Tauri frontend exits:
ShutdownManagercreates a socket at<tmpdir>/tauri_heartbeat_<app_name>.sock- 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.
# 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
