# Tauri Expert

> Expert skill for Tauri v2 development covering Rust backend, IPC (inter-process communication), and capabilities/permissions system. Use whenever the user is building a Tauri desktop or mobile app, working with Tauri commands, configuring capabilities, or bridging frontend and Rust backend. Trigger on mentions of Tauri, tauri.conf.json, Tauri commands, invoke(), Tauri capabilities, or building cross-platform desktop apps with web frontend + Rust backend.

- Skill: `roedyrustam/tauri-expert-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add roedyrustam/tauri-expert-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/roedyrustam/tauri-expert-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: roedyrustam (https://skillmd.com/u/roedyrustam)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/roedyrustam/tauri-expert-2

---


# Tauri v2 Expert

Cross-platform desktop/mobile apps with Rust backend and web frontend.

---

## Project Setup

```bash
# Create new Tauri app
npm create tauri-app@latest

# Add to existing frontend project
npm install -D @tauri-apps/cli
npx tauri init

# Dev
npm run tauri dev

# Build
npm run tauri build
```

### Project Structure
```
my-app/
├── src/                    # Frontend (React/Vue/Svelte/vanilla)
├── src-tauri/
│   ├── src/
│   │   ├── main.rs
│   │   └── lib.rs          # Commands defined here
│   ├── capabilities/
│   │   └── default.json    # Permission grants
│   ├── Cargo.toml
│   └── tauri.conf.json     # App config
└── package.json
```

---

## Tauri Commands (IPC)

### Define a Command (Rust)
```rust
// src-tauri/src/lib.rs
use tauri::State;
use std::sync::Mutex;

#[derive(Default)]
struct AppState {
    counter: Mutex<i32>,
}

#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {name}! You've been greeted from Rust.")
}

#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
    reqwest::get(&url)
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
fn increment_counter(state: State<AppState>) -> i32 {
    let mut counter = state.counter.lock().unwrap();
    *counter += 1;
    *counter
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .manage(AppState::default())
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![
            greet,
            fetch_data,
            increment_counter
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

### Call from Frontend (TypeScript)
```typescript
import { invoke } from "@tauri-apps/api/core"

// Simple command
const greeting = await invoke<string>("greet", { name: "World" })

// Async command with error handling
try {
  const data = await invoke<string>("fetch_data", { url: "https://api.example.com" })
} catch (error) {
  console.error("Command failed:", error)
}

// State-managed command
const count = await invoke<number>("increment_counter")
```

### Typed Command Wrapper (Recommended Pattern)
```typescript
// lib/tauri-commands.ts — single source of truth for all commands
import { invoke } from "@tauri-apps/api/core"

export const commands = {
  greet: (name: string) => invoke<string>("greet", { name }),
  fetchData: (url: string) => invoke<string>("fetch_data", { url }),
  incrementCounter: () => invoke<number>("increment_counter"),
} as const

// Usage — fully typed
const result = await commands.greet("Alice")
```

---

## Events (Backend → Frontend Push)

### Emit from Rust
```rust
use tauri::{Emitter, AppHandle};

#[tauri::command]
async fn long_running_task(app: AppHandle) -> Result<(), String> {
    for i in 0..100 {
        // Do work...
        app.emit("progress", i).map_err(|e| e.to_string())?;
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    }
    app.emit("task-complete", ()).map_err(|e| e.to_string())?;
    Ok(())
}
```

### Listen in Frontend
```typescript
import { listen } from "@tauri-apps/api/event"

const unlisten = await listen<number>("progress", (event) => {
  console.log("Progress:", event.payload)
  setProgress(event.payload)
})

// Clean up when component unmounts
useEffect(() => {
  return () => { unlisten() }
}, [])
```

---

## Capabilities & Permissions (Security Model)

Tauri v2 uses an explicit capability system — nothing is allowed by default.

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

### Custom Permissions (for your own commands)
```toml
# src-tauri/permissions/my-permissions.toml
[[permission]]
identifier = "allow-greet"
description = "Allows calling the greet command"
commands.allow = ["greet"]
```

```json
// Add to capabilities/default.json
{
  "permissions": ["my-plugin:allow-greet"]
}
```

### Principle of Least Privilege
```json
// ❌ Overly broad
{ "permissions": ["fs:default"] }  // grants all filesystem access

// ✅ Scoped to exactly what's needed
{
  "permissions": [
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [{ "path": "$APPDATA/config.json" }]
    }
  ]
}
```

---

## State Management

```rust
// Shared, mutable state across commands
use std::sync::Mutex;
use tauri::State;

struct Database {
    conn: Mutex<rusqlite::Connection>,
}

#[tauri::command]
fn get_user(id: i64, db: State<Database>) -> Result<User, String> {
    let conn = db.conn.lock().map_err(|e| e.to_string())?;
    conn.query_row(
        "SELECT id, name FROM users WHERE id = ?1",
        [id],
        |row| Ok(User { id: row.get(0)?, name: row.get(1)? }),
    )
    .map_err(|e| e.to_string())
}

pub fn run() {
    let conn = rusqlite::Connection::open("app.db").expect("failed to open db");

    tauri::Builder::default()
        .manage(Database { conn: Mutex::new(conn) })
        .invoke_handler(tauri::generate_handler![get_user])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

---

## Window Management

```rust
use tauri::{WebviewWindowBuilder, WebviewUrl};

#[tauri::command]
async fn open_settings_window(app: tauri::AppHandle) -> Result<(), String> {
    WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App("settings.html".into()))
        .title("Settings")
        .inner_size(600.0, 400.0)
        .resizable(false)
        .build()
        .map_err(|e| e.to_string())?;
    Ok(())
}
```

```typescript
import { getCurrentWindow } from "@tauri-apps/api/window"

const win = getCurrentWindow()
await win.setTitle("New Title")
await win.minimize()
await win.close()
```

---

## Mobile (iOS/Android)

```bash
# Initialize mobile targets
npx tauri ios init
npx tauri android init

# Dev
npx tauri ios dev
npx tauri android dev

# Build
npx tauri ios build
npx tauri android build
```

```rust
// Mobile-specific entry point required
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        // ...
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

```json
// tauri.conf.json — mobile-specific config
{
  "bundle": {
    "iOS": {
      "minimumSystemVersion": "13.0"
    },
    "android": {
      "minSdkVersion": 24
    }
  }
}
```

---

## Build Configuration

```json
// tauri.conf.json
{
  "productName": "MyApp",
  "version": "1.0.0",
  "identifier": "com.example.myapp",
  "app": {
    "windows": [
      {
        "title": "MyApp",
        "width": 1024,
        "height": 768,
        "resizable": true
      }
    ],
    "security": {
      "csp": "default-src 'self'; img-src 'self' data: https:; connect-src 'self' https://api.example.com"
    }
  },
  "bundle": {
    "active": true,
    "targets": ["dmg", "msi", "deb", "appimage"],
    "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.icns", "icons/icon.ico"]
  }
}
```

---

## Key Rules

1. **Capabilities are explicit and scoped** — never grant `*:default` broadly; scope to specific paths/URLs
2. **All commands return `Result<T, String>`** for fallible operations — never panic across the IPC boundary
3. **Use `State<T>` for shared backend state** — wrap mutable state in `Mutex`/`RwLock`
4. **Typed command wrapper on frontend** — single source of truth for all `invoke()` calls
5. **CSP configured in `tauri.conf.json`** — restrict `connect-src` to known API domains
6. **Events for backend→frontend push**, commands for frontend→backend calls
7. **`#[cfg_attr(mobile, tauri::mobile_entry_point)]`** required if targeting iOS/Android
8. **Clean up event listeners** — call `unlisten()` on component unmount
9. **Never trust frontend input in commands** — validate in Rust just like a backend API
10. **Use `tauri-plugin-store` or SQLite** for persistence — not browser localStorage (unreliable in WebView)

