---
overlay: Tauri Specialization
parent_agent: Super Coder
description: "Tauri 2.x desktop app development"
---

## TAURI 2.x GUIDELINES

You are working in a **Tauri 2.x** desktop application codebase (Rust backend + web frontend). Apply these patterns with zero exceptions. This is Tauri v2 -- do NOT use Tauri v1 APIs.

### Project Structure -- THE STANDARD LAYOUT
```
my-app/
  src/                        # Frontend (React/Vue/Svelte/etc.)
  src-tauri/
    Cargo.toml                # Rust deps -- tauri, serde, thiserror
    tauri.conf.json           # App config, bundle ID, plugins, security
    capabilities/             # ACL capability files (JSON/TOML)
      default.json            # Auto-enabled permissions for windows
    icons/                    # App icons (output of `tauri icon`)
    build.rs                  # Must call tauri_build::build()
    src/
      lib.rs                  # Main entry -- Builder, plugins, state, commands
      main.rs                 # Desktop-only: just calls app_lib::run()
      commands/               # Split commands into modules by domain
        mod.rs
        files.rs
        settings.rs
      state.rs                # AppState structs + type aliases
      errors.rs               # Custom error types with thiserror + Serialize
```

- **lib.rs is the real entry point** -- `main.rs` only calls `app_lib::run()`. This enables mobile support.
- **NEVER put all commands in lib.rs** for non-trivial apps. Use a `commands/` module.
- Cargo.toml must include `[lib]` section with `name = "app_lib"` and `crate-type = ["staticlib", "cdylib", "rlib"]` for mobile compatibility.

### lib.rs Entry Point Pattern
```rust
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_shell::init())
        .manage(std::sync::Mutex::new(AppState::default()))
        .invoke_handler(tauri::generate_handler![
            commands::files::read_project,
            commands::files::save_project,
            commands::settings::get_settings,
            commands::settings::update_settings,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

### Commands -- #[tauri::command]

**Basic command with arguments:**
```rust
#[tauri::command]
fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}
```

**Frontend invocation -- arguments are camelCase by default:**
```typescript
import { invoke } from '@tauri-apps/api/core';
const result = await invoke<string>('greet', { name: 'World' });
```

**Force snake_case arguments from frontend:**
```rust
#[tauri::command(rename_all = "snake_case")]
fn process_data(file_path: String) -> Result<(), AppError> { ... }
```

**Async commands -- MUST return Result:**
```rust
#[tauri::command]
async fn fetch_data(url: String) -> Result<Vec<DataItem>, AppError> {
    let response = reqwest::get(&url).await.map_err(AppError::Network)?;
    let items = response.json().await.map_err(AppError::Parse)?;
    Ok(items)
}
```

**Accessing AppHandle, Window, WebviewWindow in commands:**
```rust
#[tauri::command]
fn do_work(app: tauri::AppHandle, window: tauri::WebviewWindow) -> Result<(), AppError> {
    let label = window.label();
    // Use app for emit, state, etc.
    Ok(())
}
```

**Returning large binary data -- use ipc::Response to avoid JSON overhead:**
```rust
use tauri::ipc::Response;

#[tauri::command]
fn read_binary(path: String) -> Result<Response, AppError> {
    let data = std::fs::read(&path).map_err(AppError::Io)?;
    Ok(Response::new(data))
}
```

### Error Handling -- THE MANDATORY PATTERN

Standard Rust errors do NOT implement `serde::Serialize`. You MUST create a custom error type.

```rust
// src-tauri/src/errors.rs
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error("Database error: {0}")]
    Database(String),

    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Validation failed: {0}")]
    Validation(String),

    #[error(transparent)]
    Anyhow(#[from] anyhow::Error),
}

// REQUIRED: Manual Serialize impl -- derive won't work for error types
impl serde::Serialize for AppError {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where S: serde::ser::Serializer {
        serializer.serialize_str(self.to_string().as_ref())
    }
}
```

- Add `thiserror = "2"` and `serde = { version = "1", features = ["derive"] }` to Cargo.toml.
- Use `#[from]` for automatic conversion with `?` operator.
- NEVER use `Result<T, String>` in production -- always use a typed error enum.

### State Management -- Mutex Patterns

**Setup state:**
```rust
use std::sync::Mutex;

#[derive(Default)]
struct AppState {
    counter: u32,
    config: Option<Config>,
}

// Type alias prevents State<'_, Mutex<AppState>> vs State<'_, AppState> runtime panics
type AppStateMutex = Mutex<AppState>;

// In lib.rs run():
.manage(Mutex::new(AppState::default()))
```

**Access state in commands:**
```rust
#[tauri::command]
fn increment(state: tauri::State<'_, AppStateMutex>) -> Result<u32, AppError> {
    let mut s = state.lock().map_err(|_| AppError::Validation("poisoned lock".into()))?;
    s.counter += 1;
    Ok(s.counter)
}
```

**Async commands with state -- use tokio Mutex if holding lock across .await:**
```rust
use tauri::async_runtime::Mutex as AsyncMutex;

#[tauri::command]
async fn load_data(state: tauri::State<'_, AsyncMutex<AppState>>) -> Result<String, AppError> {
    let mut s = state.lock().await;
    s.config = Some(fetch_config().await?);
    Ok("loaded".into())
}
```

**Access state outside commands via AppHandle:**
```rust
fn background_task(app: tauri::AppHandle) {
    let state = app.state::<AppStateMutex>();
    let mut s = state.lock().unwrap();
    s.counter = 0;
}
```

- You do NOT need `Arc` for Tauri managed state -- Tauri wraps it in `Arc` internally.
- Each state type must be unique. You cannot `.manage()` two values of the same type.
- Use `std::sync::Mutex` by default. Only use `tauri::async_runtime::Mutex` when you hold the lock across `.await` points.

### Events -- Rust to Frontend Communication

**Emit global event from Rust:**
```rust
use tauri::Emitter;

#[tauri::command]
fn start_sync(app: tauri::AppHandle) {
    std::thread::spawn(move || {
        for progress in 0..=100 {
            app.emit("sync-progress", progress).unwrap();
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
        app.emit("sync-complete", ()).unwrap();
    });
}
```

**Listen in frontend:**
```typescript
import { listen } from '@tauri-apps/api/event';

const unlisten = await listen<number>('sync-progress', (event) => {
    console.log(`Progress: ${event.payload}%`);
});

// ALWAYS unlisten on cleanup (React useEffect return, Vue onUnmounted, etc.)
unlisten();
```

**Emit to specific window:**
```rust
use tauri::Emitter;
app.emit_to("settings", "config-changed", &new_config).unwrap();
```

**Emit with filter:**
```rust
use tauri::{Emitter, EventTarget};
app.emit_filter("file-opened", &path, |target| match target {
    EventTarget::WebviewWindow { label } => label == "main" || label == "editor",
    _ => false,
}).unwrap();
```

### Channels -- For Streaming / High-Throughput Data

Use channels instead of events when you need ordered, fast streaming (download progress, log tailing, etc.).

**Rust side:**
```rust
use tauri::ipc::Channel;
use serde::Serialize;

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "event", content = "data")]
enum DownloadEvent {
    #[serde(rename_all = "camelCase")]
    Progress { download_id: usize, bytes_done: usize, total: usize },
    #[serde(rename_all = "camelCase")]
    Finished { download_id: usize },
}

#[tauri::command]
async fn download(url: String, on_event: Channel<DownloadEvent>) -> Result<(), AppError> {
    // ... stream data ...
    on_event.send(DownloadEvent::Progress { download_id: 1, bytes_done: 500, total: 1000 })?;
    on_event.send(DownloadEvent::Finished { download_id: 1 })?;
    Ok(())
}
```

**Frontend side:**
```typescript
import { invoke, Channel } from '@tauri-apps/api/core';

type DownloadEvent =
    | { event: 'progress'; data: { downloadId: number; bytesDone: number; total: number } }
    | { event: 'finished'; data: { downloadId: number } };

const onEvent = new Channel<DownloadEvent>();
onEvent.onmessage = (msg) => {
    if (msg.event === 'progress') {
        updateProgressBar(msg.data.bytesDone / msg.data.total);
    }
};
await invoke('download', { url: 'https://example.com/file.bin', onEvent });
```

- Channels are faster than events and guarantee message ordering.
- Events are for pub/sub (multi-consumer). Channels are for point-to-point streaming.

### Capabilities & Permissions -- Tauri 2.x Security (Replaces v1 Allowlist)

Capabilities are JSON files in `src-tauri/capabilities/`. All files in this directory are auto-enabled.

**src-tauri/capabilities/default.json:**
```json
{
    "$schema": "../gen/schemas/desktop-schema.json",
    "identifier": "default",
    "description": "Default capability for the main window",
    "windows": ["main"],
    "permissions": [
        "core:default",
        "fs:default",
        "fs:allow-read-text-file",
        "fs:allow-write-text-file",
        "shell:allow-open",
        {
            "identifier": "fs:scope",
            "allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }]
        }
    ]
}
```

**Platform-specific capabilities:**
```json
{
    "identifier": "desktop-only",
    "windows": ["main"],
    "platforms": ["linux", "macOS", "windows"],
    "permissions": ["global-shortcut:allow-register"]
}
```

**Multi-window with different capabilities:**
```json
{
    "identifier": "settings-window",
    "windows": ["settings"],
    "permissions": ["core:default", "fs:allow-read-text-file"]
}
```

- A window with NO matching capability has ZERO access to IPC. This is intentional.
- Permission format: `plugin-name:permission-name` (e.g., `fs:allow-read-text-file`).
- Core permissions are prefixed with `core:` (e.g., `core:window:allow-set-title`).
- Deny scopes take precedence over allow scopes.
- NEVER use `"windows": ["*"]` unless every window truly needs those permissions.

### Plugin System

**Using official plugins:**
```bash
# Install from npm + cargo in one step
npm run tauri add fs
npm run tauri add shell
npm run tauri add updater
```

**Register in lib.rs:**
```rust
tauri::Builder::default()
    .plugin(tauri_plugin_fs::init())
    .plugin(tauri_plugin_shell::init())
    .plugin(tauri_plugin_updater::init())
```

**Creating a custom plugin:**
```rust
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::Runtime;

#[tauri::command]
fn my_plugin_command(state: tauri::State<'_, MyPluginState>) -> Result<String, AppError> {
    Ok("plugin response".into())
}

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    PluginBuilder::new("my-plugin")
        .setup(|app, _api| {
            app.manage(MyPluginState::default());
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![my_plugin_command])
        .on_navigation(|window, url| {
            // Return false to block navigation
            true
        })
        .on_event(|app, event| {
            // Handle app lifecycle events
        })
        .build()
}
```

**Frontend calls plugin commands with namespace prefix:**
```typescript
await invoke('plugin:my-plugin|my_plugin_command');
```

- Plugin commands require permissions in capabilities: `"my-plugin:allow-my-plugin-command"`.
- For inline plugins (defined in the app, not a crate), you must update `build.rs` to generate permissions.

### Window Management

**Create windows from Rust:**
```rust
use tauri::{WebviewWindowBuilder, WebviewUrl};

#[tauri::command]
fn open_settings(app: tauri::AppHandle) -> Result<(), AppError> {
    let _window = WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App("/settings".into()))
        .title("Settings")
        .inner_size(600.0, 400.0)
        .build()
        .map_err(|e| AppError::Validation(e.to_string()))?;
    Ok(())
}
```

**Create windows from frontend (requires capability):**
```typescript
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';

const settings = new WebviewWindow('settings', {
    url: '/settings',
    width: 600,
    height: 400,
    title: 'Settings',
});

settings.once('tauri://created', () => { /* success */ });
settings.once('tauri://error', (e) => { /* handle error */ });
```

- Requires `"core:webview:allow-create-webview-window"` permission.
- Inter-window communication: use `emit_to()` targeting window labels or global events.
- Use `WebviewWindow::getByLabel('label')` to get a handle to an existing window.

### File System Plugin

**Frontend file operations:**
```typescript
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
import { appDataDir, join } from '@tauri-apps/api/path';

// Read from app data
const config = await readTextFile('config.json', { baseDir: BaseDirectory.AppData });

// Write to app data
await writeTextFile('config.json', JSON.stringify(settings), {
    baseDir: BaseDirectory.AppData,
});

// Resolve absolute path
const dataDir = await appDataDir();
const filePath = await join(dataDir, 'projects', 'data.json');
```

**Rust path resolution:**
```rust
use tauri::Manager;

fn get_app_data(app: &tauri::AppHandle) -> Result<std::path::PathBuf, AppError> {
    app.path().app_data_dir().map_err(|e| AppError::Io(e.into()))
}
```

- Paths using this API cannot traverse parent directories (`../` is blocked).
- BaseDirectory variables for scopes: `$APPDATA`, `$APPCONFIG`, `$APPLOG`, `$HOME`, `$RESOURCE`, `$TEMP`.
- App directories must be created at runtime before use -- they do not exist by default.

### Security -- CSP & IPC Safety

**tauri.conf.json CSP configuration:**
```json
{
    "app": {
        "security": {
            "csp": {
                "default-src": "'self' customprotocol: asset:",
                "connect-src": "ipc: http://ipc.localhost",
                "img-src": "'self' asset: http://asset.localhost blob: data:",
                "style-src": "'unsafe-inline' 'self'"
            }
        }
    }
}
```

- Tauri auto-injects nonces and hashes for bundled scripts/styles at compile time.
- NEVER use `'unsafe-eval'` unless using WASM (then use `'wasm-unsafe-eval'`).
- Avoid loading remote scripts. Any untrusted content is an attack vector.
- Sanitize ALL user input on the Rust side before processing.
- Defer business logic to Rust -- minimize logic in the frontend.
- Consider the Isolation pattern (`"isolation": { "pattern": "..." }`) for apps handling untrusted content.

### Build & Distribution

**Development:**
```bash
npm run tauri dev          # Hot-reload development
npm run tauri build        # Production build
npm run tauri icon          # Generate icons from source image
```

**tauri.conf.json essentials:**
```json
{
    "productName": "My App",
    "mainBinaryName": "my-app",
    "version": "1.0.0",
    "identifier": "com.mycompany.myapp",
    "build": {
        "frontendDist": "../dist",
        "devUrl": "http://localhost:5173",
        "beforeBuildCommand": "npm run build",
        "beforeDevCommand": "npm run dev"
    }
}
```

**Updater setup (tauri.conf.json):**
```json
{
    "plugins": {
        "updater": {
            "endpoints": ["https://releases.example.com/{{target}}/{{arch}}/{{current_version}}"],
            "pubkey": "YOUR_PUBLIC_KEY_HERE"
        }
    }
}
```

**GitHub Actions CI/CD:**
```yaml
- uses: tauri-apps/tauri-action@v0
  env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
      TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
  with:
      tagName: v__VERSION__
      releaseName: 'v__VERSION__'
      includeUpdaterJson: true
```

- `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` must be set as environment variables (not .env files).
- Generate signing keys with `npx tauri signer generate`.
- macOS requires code signing + notarization for distribution. Set `APPLE_SIGNING_IDENTITY` env var.
- `mainBinaryName` is REQUIRED in Tauri 2.x -- the binary is no longer auto-renamed to match `productName`.

### Common Gotchas -- AVOID THESE

1. **Serde serialization for errors:** Standard error types don't implement `Serialize`. You MUST create a custom error type (see Error Handling section above). This is the #1 newcomer mistake.

2. **State type mismatch:** `State<'_, AppState>` vs `State<'_, Mutex<AppState>>` gives a runtime panic, not a compile error. Always use a type alias for your wrapped state.

3. **Async commands with borrowed data:** `&str` and `State<'_, T>` in async commands cause "future is not Send" errors. Solution: async commands MUST return `Result<T, E>`. For `&str`, use `String` instead.

4. **Missing capabilities:** Commands silently fail (or return 400) if the capability file doesn't include the permission. Check `src-tauri/capabilities/` first when a command doesn't work.

5. **Frontend argument casing:** Rust `snake_case` args become `camelCase` on the frontend by default. Use `#[tauri::command(rename_all = "snake_case")]` to keep snake_case.

6. **v1 imports still in code:** `@tauri-apps/api/tauri` is now `@tauri-apps/api/core`. `@tauri-apps/api/fs` is now `@tauri-apps/plugin-fs`. There is no backward compatibility.

7. **Events are not type-safe:** Events use JSON payloads with no type checking at the Rust/TS boundary. Prefer commands for typed request/response and channels for typed streaming.

8. **Window without capability = zero IPC access.** If a webview or its window matches no capability, it has no access to the IPC layer at all. This is not a bug.

9. **Mutex poisoning:** Use `.lock().map_err(...)` instead of `.lock().unwrap()` in commands. A panicked thread poisons the mutex, and `.unwrap()` will crash subsequent calls.

10. **Plugin names cannot contain underscores.** Use hyphens (`my-plugin`, not `my_plugin`). Underscore names cause cryptic serde deserialization errors in capabilities.

### Naming & Conventions
- `snake_case` for Rust functions, modules, variables
- `PascalCase` for Rust types, traits, enums
- `camelCase` for TypeScript functions, variables, and invoke argument names
- Commands registered with `generate_handler!` use their Rust function name as the invoke name
- Plugin commands are invoked as `plugin:plugin-name|command_name`
