---
name: tauri
description: "Use when building a lightweight cross-platform desktop (or v2 mobile) app with Tauri — a Rust core plus the OS-native WebView — wiring Rust commands and IPC, streaming data to the frontend, locking down the capabilities/permissions security model, or bundling and auto-updating tiny signed binaries. Triggers: 'build a Tauri app', 'expose a Rust command to my frontend', 'invoke from JS', 'tauri.conf.json', 'capabilities and permissions', 'stream download progress to the WebView', 'my command froze the UI', 'shrink my desktop binary vs Electron', 'set up the Tauri updater', 'crear una app d'escriptori lleugera amb Rust en lloc d'Electron', 'reducir el tamaño del binario de escritorio'. NOT a Chromium+Node desktop app that needs Node APIs in a main process (that is electron)."
tags: [tauri, desktop, rust, cross-platform, ipc, webview]
recommends: [rust, electron, react, secure-coding, github-actions]
origin: risco
---

# Tauri — Rust core, OS WebView, locked-down IPC, tiny binaries

Tauri builds a desktop (and, since v2, mobile) app from a **Rust core** plus the
**operating system's own WebView** — not a bundled browser. That is the whole value
proposition: ~12MB installers and 30-50MB idle RAM, versus Electron's ~180MB installers
and 150-300MB because it ships Chromium + Node. You write your UI in any web framework,
expose privileged work as Rust commands, and the WebView talks to Rust over a sandboxed
IPC bridge.

**Always target v2.** Tauri 2.0 went stable in October 2024; the current line is 2.x
(2.11.x as of mid-2026). v1 docs use a `tauri > allowlist` config that no longer exists —
if you see `allowlist`, you are reading the wrong era. v2 also adds iOS/Android targets,
so the same Rust core can ship to mobile.

This skill owns the **shell**: commands, IPC, the security ACL, the bundler, the updater.
It does **not** own the Rust language itself (that is the `rust` skill), the web UI inside
the window (the `react`/`nextjs` skills), or a Chromium+Node shell (`../electron/SKILL.md`).

## Pick your starting shape

| Situation | Do this |
|-----------|---------|
| Greenfield app, no UI yet | `npm create tauri-app@latest` — pick your frontend, get `src-tauri/` wired |
| You already have a web app (Vite/Next/etc.) | `npx @tauri-apps/cli@latest init` inside it; point `build.frontendDist` at your build output |
| Add mobile to an existing desktop app | `tauri ios init` / `tauri android init`; gate native bits behind `#[cfg(mobile)]` |
| You see `tauri.conf.json > tauri > allowlist` | You are on v1 — migrate to v2 capabilities before adding anything |

`src-tauri/` is its own Cargo crate: `Cargo.toml`, `tauri.conf.json`, `src/lib.rs`
(the `run()` entry point), and `capabilities/`. The frontend is a sibling directory the
bundler reads from `frontendDist`.

## Commands & IPC — the core contract

A command is a Rust function the frontend can call. Each rule below has a one-line *why*.

- **Annotate and register.** `#[tauri::command]` on the fn, then list it in
  `tauri::generate_handler![...]` inside `invoke_handler`. *Unregistered commands are not a
  compile error — they fail at runtime when JS calls them.*
- **Naming crosses the bridge.** JS `invoke('read_config', { filePath })` maps to Rust
  `read_config(file_path: String)`. *Command names stay snake_case; args auto-map
  camelCase (JS) ⇄ snake_case (Rust).*
- **Fallible commands return `Result<T, E>` where `E: Serialize`.** *An `Err` becomes a
  rejected JS promise; a panic instead crashes the command thread silently.*
- **Never block the command thread.** Long or I/O work goes in an `async` command or a
  spawned task. *Commands run on a shared IPC thread pool — a blocking call freezes other
  IPC, which users see as a frozen UI.*
- **Share state with `.manage(x)` + `State<'_, T>`.** *If a guard is held across an
  `.await`, use `tokio::sync::Mutex`, not `std::sync::Mutex` — the std guard is not `Send`
  and will not compile in an async command.*

```rust
// src-tauri/src/lib.rs
use tauri::State;
use tokio::sync::Mutex;

#[derive(Default)]
struct AppState { counter: u64 }

#[tauri::command]                                   // registered below or it 404s at runtime
async fn read_config(file_path: String) -> Result<String, String> {
    tokio::fs::read_to_string(&file_path)           // async I/O — does not block the IPC pool
        .await
        .map_err(|e| e.to_string())                 // Err -> rejected JS promise
}

#[tauri::command]
async fn bump(state: State<'_, Mutex<AppState>>) -> Result<u64, String> {
    let mut s = state.lock().await;                 // tokio Mutex: guard is held across .await
    s.counter += 1;
    Ok(s.counter)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]      // same core compiles for iOS/Android
pub fn run() {
    tauri::Builder::default()
        .manage(Mutex::new(AppState::default()))
        .invoke_handler(tauri::generate_handler![read_config, bump])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

```javascript
// frontend
import { invoke } from '@tauri-apps/api/core';

const text = await invoke('read_config', { filePath: '/app/config.toml' });
// throws (rejected promise) if the command returns Err — wrap in try/catch
```

Bad → Good, the failure people hit most:

```rust
// Bad: std Mutex held across .await — won't compile in an async command, or you
// "fix" it by dropping the guard early and create a race.
async fn save(state: State<'_, std::sync::Mutex<AppState>>) { /* ... */ }

// Good: async-aware lock.
async fn save(state: State<'_, tokio::sync::Mutex<AppState>>) -> Result<(), String> { Ok(()) }
```

## Stream vs notify

Two ways to push from Rust to the frontend — pick by ordering needs:

- **`Channel<T>` for ordered streaming.** Download progress, file chunks, an HTTP body.
  *Messages arrive in send order on one typed channel — the right tool for "report
  progress as it happens."*
- **`emit` / `listen` events for fire-and-forget pub/sub.** App-wide notifications, "data
  refreshed," a tray action. *No ordering or backpressure guarantees; many listeners, no
  reply.*

```rust
use tauri::ipc::Channel;

#[derive(Clone, serde::Serialize)]
struct Progress { downloaded: u64, total: u64 }

#[tauri::command]
async fn download(url: String, on_progress: Channel<Progress>) -> Result<(), String> {
    // ... as bytes arrive:
    on_progress.send(Progress { downloaded: 4096, total: 1_000_000 })
        .map_err(|e| e.to_string())?;
    Ok(())
}
```

```javascript
import { Channel, invoke } from '@tauri-apps/api/core';

const onProgress = new Channel();
onProgress.onmessage = (p) => updateBar(p.downloaded / p.total);
await invoke('download', { url, onProgress });
```

## Security — the part people skip

Tauri v2's IPC is an **Access Control List**, default-deny. The chain:

**capabilities** (group windows/webviews) → grant **permissions** (named command sets) →
**permissions map scopes** (what data/paths a command may touch). **A webview that matches
no capability has zero IPC access.** This is the opposite of v1's opt-out allowlist —
you grant exactly what each window needs.

```json
// src-tauri/capabilities/default.json — grant only what the main window uses
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [{ "path": "$APPCONFIG/*" }]   // scope: app config dir only, nothing else
    }
  ]
}
```

Three more rules that bite real apps:

- **CSP is only enforced if you set it** in `tauri.conf.json > app > security > csp`.
  *No CSP = the WebView runs whatever it loads; local scripts are hashed, external ones get
  a per-load nonce only once a CSP exists.*
- **Use the isolation pattern when frontend code may be untrusted** (third-party deps,
  plugins). *It injects a sandboxed iframe that can inspect/modify every IPC message before
  it reaches Rust; messages are encrypted with SubtleCrypto using a key regenerated each
  app start.*
- **Treat the WebView as hostile.** *Anything in the frontend bundle ships to the user —
  no API keys, tokens, or secrets in JS. Privileged work and secrets stay in Rust.*

Full capabilities/permissions/scope JSON, fs/http scope globs, CSP dev-vs-prod recipes, and
isolation-pattern setup live in `references/security.md`.

## Bundle & ship

`tauri build` produces native installers per OS — but unsigned binaries trigger
"unidentified developer" / SmartScreen warnings, so signing is not optional for distribution.

- **macOS:** sign with a Developer ID cert, then **notarize** — Gatekeeper blocks
  un-notarized apps.
- **Windows:** Authenticode-sign the `.exe`/MSI/NSIS or SmartScreen warns.
- **Linux:** AppImage / `.deb` / `.rpm`; no central signing authority, but ship checksums.
- **Auto-update:** the updater plugin needs a signing keypair (`tauri signer generate`);
  the private key signs releases, the public key ships in config. *Without it the updater
  refuses unsigned updates — by design.*
- **Sidecar:** embed an external binary via `bundle.externalBin` to call it at runtime
  (e.g. ship a CLI your app shells out to).

Per-OS flags, notarization steps, updater config, sidecar setup, and a CI release matrix
live in `references/bundling-distribution.md`. The CI runner matrix that *runs* those builds
across three OSes is the `github-actions` skill's job; this skill defines what to build and sign.

## Anti-patterns

| Anti-pattern | Why it's wrong | Do instead |
|--------------|----------------|------------|
| One capability granting broad permissions to all windows | Any XSS gets the full IPC surface | Per-window capability, scoped to the commands that window needs |
| Permission with no `allow`/scope on `fs`/`http` | Command can touch any path/host | Add an `allow` glob (`$APPCONFIG/*`) and deny the rest |
| Blocking call (`std::fs`, sync HTTP) in a command | Freezes the IPC pool → frozen UI | `async fn` + `tokio` / spawn the work |
| `.unwrap()` instead of returning `Result<T, E>` | Panic crashes the command thread silently | Return `Result`, `map_err` to a serializable error |
| API keys/tokens in the frontend bundle | Ships to every user; trivially extracted | Keep secrets and privileged calls in Rust |
| No CSP set in config | WebView runs any loaded script | Set `app.security.csp`; isolation pattern if deps are untrusted |
| Copying a v1 `tauri.conf.json > allowlist` | That key does not exist in v2 | Use `capabilities/*.json` (ACL) |
| Assuming bundled Chromium | It's the **OS** WebView (WebKit/WebView2) | Test rendering on each OS's engine; avoid Chromium-only CSS/JS |

## Pointers

- `references/security.md` — capabilities/permissions/scope JSON, CSP recipes, isolation setup.
- `references/bundling-distribution.md` — per-OS signing, updater keypair, sidecar, CI matrix.
- Siblings: `../electron/SKILL.md` (the Chromium+Node alternative when you need a Node main
  process), `../secure-coding/SKILL.md` (app-code hardening). The `rust` skill owns the
  language; `react`/`nextjs` own the frontend UI; `github-actions` owns the release CI matrix.
- `scripts/verify.sh` — advisory static lint over `src-tauri/` (capabilities present, registered
  commands exist, no v1 `allowlist`, fallible-looking commands return `Result`).
