ExTauri

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.

example.gif

Features

Prerequisites

Note: Zig is only required if you use Burrito for cross-compilation. For same-platform builds, Rust alone is sufficient.

Getting Started

1. Add ExTauri to your Phoenix project

# mix.exs
def deps do
[
{:ex_tauri, git: "https://github.com/filipecabaco/ex_tauri.git"}
]
end

2. Install and set up

mix deps.get
mix ex_tauri.install

That's it! mix ex_tauri.install handles everything automatically:

3. Run in development

mix ex_tauri.dev

This 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 --prod-sidecar to test against a compiled release instead.)

Tip: Review the generated config in config/config.exs to customize your app name, port, or window settings.

Building for Production

Full guide — signing, notarization, auto-updates, CI matrix: guides/releasing.md

1. Add Burrito wrapping

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

2. Add required applications

# mix.exs
def application do
[
mod: {MyApp.Application, []},
extra_applications: [:logger, :runtime_tools, :inets]
]
end

3. Build

mix ex_tauri.build

Your app bundle will be at src-tauri/target/release/bundle/ with platform-specific packages:

Mix Tasks

TaskDescription
mix ex_tauri.installSet up Tauri in your project (one-time)
mix ex_tauri.addAdd Tauri plugins (dialogs, filesystem, shortcuts, ...)
mix ex_tauri.devRun in development mode with hot-reload
mix ex_tauri.buildBuild for production

Run mix help ex_tauri.<task> for detailed options.

Using Desktop APIs

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
end

Included by default

These work out of the box after mix ex_tauri.install:

One command away

These modules need a Tauri plugin on the Rust side. mix ex_tauri.add wires everything (Cargo dependency, plugin registration, permissions):

ModuleEnable with
ExTauri.Dialog — File open/save dialogs, message boxesmix ex_tauri.add dialog
ExTauri.Clipboard — Read/write the system clipboardmix ex_tauri.add clipboard
ExTauri.Filesystem — Read/write files outside the web sandboxmix ex_tauri.add fs
ExTauri.OS — Query platform, architecture, localemix ex_tauri.add os
ExTauri.GlobalShortcut — App-wide keyboard shortcutsmix ex_tauri.add global-shortcut
ExTauri.Autostart — Launch at loginmix ex_tauri.add autostart
ExTauri.App.exit/relaunch — App lifecycle controlmix ex_tauri.add process
ExTauri.Updater — Check for and install updatesmix ex_tauri.add updater
Deep links (myapp:// URLs, via ExTauri.Event)mix ex_tauri.add deep-link

Server-side: notifications and system tray without a LiveView

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"}}

Example: sending a notification from LiveView

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)}
end

Example: opening a file dialog

First, 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

Example: window management and drag-and-drop

def 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

How It Works

Architecture

┌─────────────────────┐
│ Tauri Window │ Native window (Rust/WebView)
│ ┌───────────────┐ │
│ │ Phoenix UI │ │ Your LiveView app rendered in WebView
│ └───────────────┘ │
└─────────┬────────────┘
│ HTTP — serves your Phoenix UI to the WebView
│ Local socketheartbeat + 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.

Heartbeat-Based Shutdown

ExTauri uses a local socket heartbeat to detect when the Tauri frontend exits:

  1. ShutdownManager opens a listener:
    • macOS/Linux: a Unix domain socket at <tmpdir>/tauri_heartbeat_<app_name>.sock
    • Windows: a TCP socket on 127.0.0.1 with an OS-assigned port, published in <tmpdir>/tauri_heartbeat_<app_name>.port for the frontend to discover
  2. The Rust frontend connects and sends a byte every 100ms
  3. ShutdownManager checks for heartbeats every 500ms
  4. If no heartbeat is received for 1500ms, graceful shutdown begins
  5. 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).

Configuration

Core Settings

# 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)

Window Settings

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 resize

Advanced Settings

config :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)

Troubleshooting

Rust/Cargo not found

Rust/Cargo is not installed or not in your PATH.

Install Rust via rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Database configuration for desktop apps

Desktop 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: 5

Running Ecto migrations in a desktop release

Desktop 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)
end

Then call it early in your application.ex startup:

def start(_type, _args) do
MyApp.Release.migrate()
children = [
MyApp.Repo,
ExTauri.ShutdownManager,
# ...
]
# ...
end

Static assets in production

Remove 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"

DMG build permission error (macOS)

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.

Port already in use

If mix ex_tauri.dev hangs, check if another process is using the configured port:

lsof -i :4000

Kill the process or change the :port in your ExTauri config.

Example

See the example/ directory for a complete working Phoenix desktop app with SQLite, LiveView, and Tailwind CSS.

Acknowledgements

License

MIT