Tauri v2 Expert
Cross-platform desktop/mobile apps with Rust backend and web frontend.
Project Setup
# 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)
// 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)
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)
// 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
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
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
{
"$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)
# src-tauri/permissions/my-permissions.toml
[[permission]]
identifier = "allow-greet"
description = "Allows calling the greet command"
commands.allow = ["greet"]
// Add to capabilities/default.json
{
"permissions": ["my-plugin:allow-greet"]
}
Principle of Least Privilege
// ❌ 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
// 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
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)
# 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
// 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");
}
// tauri.conf.json — mobile-specific config
{
"bundle": {
"iOS": {
"minimumSystemVersion": "13.0"
},
"android": {
"minSdkVersion": 24
}
}
}
Build Configuration
// 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
- Capabilities are explicit and scoped — never grant
*:defaultbroadly; 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 inMutex/RwLock - Typed command wrapper on frontend — single source of truth for all
invoke()calls - CSP configured in
tauri.conf.json— restrictconnect-srcto 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-storeor SQLite for persistence — not browser localStorage (unreliable in WebView)