Current section
Files
Jump to
Current section
Files
usage-rules.md
# ExTauri usage rules
ExTauri turns a Phoenix LiveView app into a native desktop app (macOS/Windows/Linux)
by wrapping [Tauri](https://tauri.app). Tauri launches your app as a **sidecar
process**; the WebView renders your LiveView over HTTP; a local socket carries a
heartbeat + a duplex desktop channel.
<!-- usage-rules-start -->
## Setup
- Requires **Elixir ≥ 1.17** and **Rust** (rustup), on **OTP 27** for Burrito
builds (its precompiled ERTS stops at 27) or **OTP 28** for the ampoule
packager and iOS. Add `{:ex_tauri, "~> 0.5"}`, then `mix deps.get` and
`mix ex_tauri.install`.
- `mix ex_tauri.install` is Igniter-driven and idempotent: it sets
`app_name`/`host`/`port`/`version` config, scaffolds `src-tauri/`, adds
`ExTauri.ShutdownManager` to the supervision tree, adds a release named
`<otp_app>_desktop` (an app that already has a `:desktop` release keeps it;
`config :ex_tauri, release_name:` wins over both), and injects the JS hook +
`<div id="tauri-bridge">`.
- Run in a native window with hot reload: `mix ex_tauri.dev`. Build for prod:
`mix ex_tauri.build`.
- Config lives under `config :ex_tauri` — `app_name`, `host`, `port` are
required; `app_name` also keys the heartbeat socket and all `ExTauri.Paths` dirs.
## Calling desktop APIs from LiveView
- Add `use ExTauri.LiveView` to any LiveView that calls ExTauri APIs. It routes
bridge responses to the **callback you pass as the last argument** — do not
hand-write `handle_event("tauri_response", ...)` unless you deliberately skip
the callback style.
- Reply callbacks are `fn {:ok, payload} | {:error, reason}, socket -> socket end`
(one-shot). Event handlers (`Event.subscribe/3`, `GlobalShortcut.register/3`)
are `fn payload, socket -> socket end` (persistent). **Both must return the socket.**
- Payload maps use **string keys** (they come from JS): `%{"path" => path}`,
`%{"text" => text}`, etc.
- Subscribe to events in `mount/3` guarded by `connected?/1` — subscriptions are
torn down on navigation and must be re-established.
```elixir
def handle_event("pick_file", _p, socket) do
socket = ExTauri.Dialog.open(socket, [title: "Pick a file"], fn
{:ok, %{"path" => path}}, socket -> assign(socket, :file, path)
{:error, _}, socket -> put_flash(socket, :error, "cancelled")
end)
{:noreply, socket}
end
```
## Plugins: an API doing nothing usually means a missing plugin
- Out of the box: `ExTauri.Notification`, `ExTauri.Shell`, `ExTauri.Window`,
`ExTauri.Event` (core), `ExTauri.App.info`.
- Need `mix ex_tauri.add <plugin>`: `dialog` → `Dialog`, `fs` → `Filesystem`,
`clipboard` → `Clipboard`, `os` → `OS`, `global-shortcut` → `GlobalShortcut`,
`autostart` → `Autostart`, `process` → `App.exit`/`relaunch`, `updater` →
`Updater`, `deep-link` → deep links via `Event`.
- `mix ex_tauri.add` is idempotent; rebuild with `mix ex_tauri.dev` after adding.
`deep-link` also needs its scheme declared in `src-tauri/tauri.conf.json`.
## Server-side (no LiveView)
- `ExTauri.Desktop.notify/2` and `ExTauri.Desktop.set_tray/1` work from any
process (GenServer, Oban, PubSub) over the internal channel. Subscribe with
`ExTauri.Desktop.subscribe/0`; tray clicks arrive as
`{:ex_tauri_event, "tray_menu_click", %{"id" => id}}`.
- Use `ExTauri.Paths.{data_dir,config_dir,cache_dir,log_dir}/0` for any
filesystem path (DB, logs, config). **Never hardcode `/tmp` or `~/`.**
## Sidecars that outlive their window
- Call `ExTauri.Sidecar.Orphan.ensure_port_available(port)` at the very top of
`Application.start/2`, before the endpoint's child spec. A sidecar whose shell
died is reparented to init and keeps running — holding your port, so the next
launch dies on `:eaddrinuse`, and running timers and pollers with no window.
This evicts the one squatting the port and sweeps the rest.
- It only ever signals **your** release. Burrito names its unpack directory
after the release, and installs before 0.4 all scaffolded `:desktop`, so two
such apps share `.burrito/desktop_erts-*`; the payload (`lib/<otp_app>-<vsn>`)
is what tells them apart. Pass `:otp_app` if your OTP app is not the
sanitised `:app_name`, and `:release_name` if your release is not the one in
`config :ex_tauri, :release_name` (default `"desktop"`).
- `ExTauri.ShutdownManager` handles the rest by itself once it is in your tree:
it stops on a closed connection, on a heartbeat that stops, and — in a
packaged build only — on a sidecar that never got a heartbeat and has lost its
parent. Tune with `:heartbeat_reconnect_grace`, `:heartbeat_stall_grace` and
`:heartbeat_orphan_grace` if the defaults (3s / 1s / 60s) do not fit.
## Desktop specifics (Ecto)
- Put the SQLite DB under `ExTauri.Paths.data_dir()` in `config/runtime.exs`.
- Releases don't run `mix ecto.migrate`; call a `Release.migrate/0` at the top
of `Application.start/2` before the Repo's children serve traffic.
## Production
- The release `mix ex_tauri.install` added needs `steps: [:assemble,
&Burrito.wrap/1]` and a `burrito: [targets: [...]]`. Build each OS's bundles
on that OS.
- Or pack it with [ampoule](https://github.com/filipecabaco/ampoule) instead:
`config :ex_tauri, :packager, :ampoule` makes `mix ex_tauri.build` produce
one executable running the BEAM as WebAssembly on OTP 28, with nothing to
unpack (macOS and Linux; no Windows). `ampoule` must be on PATH, or set
`config :ex_tauri, :ampoule_path`. `main.rs` tells the two apart at launch.
- Signing runs during `mix ex_tauri.build` when the platform env vars are set
(`APPLE_*` on macOS; thumbprint/timestamp on Windows). Auto-updates need
`mix ex_tauri.signer generate`, the `updater` config block, the `updater`
plugin, and `TAURI_SIGNING_PRIVATE_KEY` at build time.
## Not tied to Phoenix
- The dev command is `config :ex_tauri, :dev_command` (default `mix phx.server`)
and prod env injection is `:sidecar_env`. `PORT` and `SECRET_KEY_BASE` are
always injected — your app must bind the injected `PORT`. The native API
bridge is Phoenix LiveView-only today; other frameworks get window + lifecycle.
- In production `PORT` is an **OS-assigned ephemeral port**, not your configured
`:port` — binding a hardcoded port gives a blank window with no error. Phoenix
reads `PORT` on its own; Francis needs one line in `config/runtime.exs`:
```elixir
if port = System.get_env("PORT") do
config :francis, bandit_opts: [ip: {127, 0, 0, 1}, port: String.to_integer(port)]
end
```
- **That recipe requires francis >= 0.3.4.** On 0.3.3 and earlier, Francis reads
`:bandit_opts` from the `use Francis` macro options only and silently discards
the application config, binding Bandit's default 4000. On older Francis put the
port in the macro options instead — they are spliced into the generated
`start/2`, so `System.get_env("PORT")` is still read at boot.
## iPhone and iPad (simulator only)
- `mix ex_tauri.ios init` once, then `mix ex_tauri.ios run` (the backend packed
with ampoule, as it ships) or `mix ex_tauri.ios dev` (the simulator on your
dev server, live reload). Needs Xcode and `ampoule` on PATH, on OTP 28.
- `init` writes the iOS shell to `src-tauri/src/lib.rs` (all `#[cfg(mobile)]`,
the desktop build is untouched), a `[lib]` in `Cargo.toml`,
`tauri.ios.conf.json`, and a loading page in `src-tauri/mobile/index.html`
that is yours to restyle (rerunning `init` keeps it). It refuses a `lib.rs`
with code of its own unless `--force`.
- A real device forbids starting a process, so this runs in the simulator only.
- Keep files under `ExTauri.Paths` — on iOS they resolve inside the app's
container; `ExTauri.Paths.documents_dir/0` is the folder the Files app shows.
`System.user_home!/0` is a private `/tmp` deleted when the app stops.
- The app reloads its page when iOS reclaims it or ends it, and after the shell
restarts a crashed backend. Browser storage does not survive a launch (the
origin's port changes), so keep state that must survive (open document,
unsaved edits) with the backend, under `ExTauri.Paths.config_dir/0`.
- Lay out for the phone and the tablet with CSS media queries and
`env(safe-area-inset-*)`, with `viewport-fit=cover` in the viewport meta.
`"__TAURI__" in window && navigator.maxTouchPoints > 0` is the shell on
iPhone or iPad; on the Mac `maxTouchPoints` is 0.
- `ExTauri.Desktop` (tray, server-side notifications) answers
`{:error, :not_connected}` on iOS.
<!-- usage-rules-end -->