| name | tauri-expert |
| description | 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.
|
Tauri v2 Expert
Cross-platform desktop/mobile apps with Rust backend and web frontend.
Project Setup
npm create tauri-app@latest
npm install -D @tauri-apps/cli
npx tauri init
npm run tauri dev
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)
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)
import { invoke } from "@tauri-apps/api/core"
const greeting = await invoke<string>("greet", { name: "World" })
try {
const data = await invoke<string>("fetch_data", { url: "https://api.example.com" })
} catch (error) {
console.error("Command failed:", error)
}
const count = await invoke<number>("increment_counter")
Typed Command Wrapper (Recommended Pattern)
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
const result = await commands.greet("Alice")
Events (Backend → Frontend Push)
Emit from Rust
use tauri::{Emitter, AppHandle};
#[tauri::command]
async fn long_running_task(app: AppHandle) -> Result<(), String> {
for i in 0..100 {
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
import { listen } from "@tauri-apps/api/event"
const unlisten = await listen<number>("progress", (event) => {
console.log("Progress:", event.payload)
setProgress(event.payload)
})
useEffect(() => {
return () => { unlisten() }
}, [])
Capabilities & Permissions (Security Model)
Tauri v2 uses an explicit capability system — nothing is allowed by default.
capabilities/default.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)
[[permission]]
identifier = "allow-greet"
description = "Allows calling the greet command"
commands.allow = ["greet"]
{
"permissions": ["my-plugin:allow-greet"]
}
Principle of Least Privilege
{ "permissions": ["fs:default"] }
{
"permissions": [
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$APPDATA/config.json" }]
}
]
}
State Management
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
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(())
}
import { getCurrentWindow } from "@tauri-apps/api/window"
const win = getCurrentWindow()
await win.setTitle("New Title")
await win.minimize()
await win.close()
Mobile (iOS/Android)
npx tauri ios init
npx tauri android init
npx tauri ios dev
npx tauri android dev
npx tauri ios build
npx tauri android build
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
{
"bundle": {
"iOS": {
"minimumSystemVersion": "13.0"
},
"android": {
"minSdkVersion": 24
}
}
}
Build Configuration
{
"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
- Capabilities are explicit and scoped — never grant
*:default broadly; scope to specific paths/URLs
- All commands return
Result<T, String> for fallible operations — never panic across the IPC boundary
- Use
State<T> for shared backend state — wrap mutable state in Mutex/RwLock
- Typed command wrapper on frontend — single source of truth for all
invoke() calls
- CSP configured in
tauri.conf.json — restrict connect-src to known API domains
- Events for backend→frontend push, commands for frontend→backend calls
#[cfg_attr(mobile, tauri::mobile_entry_point)] required if targeting iOS/Android
- Clean up event listeners — call
unlisten() on component unmount
- Never trust frontend input in commands — validate in Rust just like a backend API
- Use
tauri-plugin-store or SQLite for persistence — not browser localStorage (unreliable in WebView)