Skip to main content

README.md

# ExTauri

**Build native desktop applications with Phoenix and Elixir.**

ExTauri wraps [Tauri](https://tauri.app) to enable Phoenix LiveView applications to run as native desktop apps on macOS, Windows, and Linux.

![example.gif](example.gif)

## Features

- **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.Desktop` sends notifications and drives the system tray from GenServers and background jobs, no browser involved
- **One-Command Plugin Setup** — `mix ex_tauri.add dialog fs` wires 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.dev` runs 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](https://github.com/burrito-elixir/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](https://hexdocs.pm/igniter) for safe, AST-aware project configuration
- **Cross-Platform** — Build for macOS, Windows, and Linux

## Prerequisites

- **Elixir** >= 1.15 with **OTP 27** (OTP 28 not yet supported due to Burrito ERTS availability)
- **Rust** — [Install via rustup](https://www.rust-lang.org/tools/install)
- **Platform dependencies** — see [Tauri prerequisites](https://v2.tauri.app/start/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

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

### 2. Install and set up

```bash
mix deps.get
mix ex_tauri.install
```

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

- **Config** — Sets sensible defaults for `app_name`, `host`, `port`, and `version` in `config/config.exs`
- **Tauri CLI** — Installs via Cargo
- **Project structure** — Scaffolds `src-tauri/` with Rust code, config, and capabilities
- **Supervision tree** — Adds `ExTauri.ShutdownManager` to your application (via Igniter)
- **Release config** — Adds a `:desktop` release to `mix.exs` (via Igniter)
- **JS hook** — Generates `assets/vendor/ex_tauri.js` and auto-injects the import and hook registration into `assets/js/app.js`
- **Layout** — Auto-injects the `<div id="tauri-bridge">` element into your root layout

### 3. Run in development

```bash
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](guides/releasing.md)

### 1. Add Burrito wrapping

Update the `:desktop` release in your `mix.exs` to include Burrito:

```elixir
# 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

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

### 3. Build

```bash
mix ex_tauri.build
```

Your app bundle will be at `src-tauri/target/release/bundle/` with platform-specific packages:
- **macOS:** `.app` and `.dmg`
- **Linux:** `.deb` and `.appimage`
- **Windows:** `.msi` and `.exe`

## Mix Tasks

| 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.

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

```elixir
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`:

- **`ExTauri.Notification`** — Native desktop notifications
- **`ExTauri.Shell`** — Open URLs, execute scoped commands
- **`ExTauri.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

### One command away

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` |

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

```elixir
# 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

```elixir
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:

```elixir
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

```elixir
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 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.

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

```elixir
# 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

```elixir
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

```elixir
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](https://www.rust-lang.org/tools/install):
```bash
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`:

```elixir
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:

```elixir
# 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:

```elixir
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`:

```elixir
# 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:

```bash
lsof -i :4000
```

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

## Example

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

## Acknowledgements

- [Tauri App](https://tauri.app) — For the amazing framework
- [Burrito](https://github.com/burrito-elixir/burrito) by Digit/Doawoo — For single-binary Elixir apps
- [Igniter](https://hexdocs.pm/igniter) — For safe, AST-aware code patching
- [phx_new_desktop](https://github.com/feng19/phx_new_desktop) by Kevin Pan/Feng19 — For inspiration

## License

MIT