| name | desktop-backend-tauri |
| description | Tauri 2.x Rust command patterns, state management, error handling, events, channels, testing |
Tauri Rust Backend Patterns
Quick Guide: Define commands with #[tauri::command], register in generate_handler![]. Use State<T> for shared state (wrap mutable fields in Mutex). Error types must implement both serde::Serialize and Display -- use thiserror for ergonomic error enums. Async commands run on Tokio -- borrowed args (&str, State<'_, T>) require Result<T, E> return type. Stream data to frontend via Channel<T> (not events) for high throughput. Emit events with app.emit() for fire-and-forget notifications.
Current version: Tauri 2.x (stable). Async runtime is Tokio.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)
(You MUST register every command in tauri::generate_handler![] -- unregistered commands compile fine but silently fail at runtime)
(You MUST implement serde::Serialize on all error types returned from commands -- Tauri serializes errors across the IPC boundary)
(You MUST wrap mutable managed state in Mutex or RwLock -- commands run concurrently and State<T> requires Send + Sync)
(You MUST return Result<T, E> from async commands that use borrowed args (&str, State<'_, T>) -- Rust lifetime rules require it)
(You MUST use Channel<T> for streaming data to frontend -- events are designed for small payloads, not high-throughput streaming)
</critical_requirements>
Auto-detection: #[tauri::command], tauri::command, tauri::State, AppHandle, app.manage, generate_handler, tauri::ipc::Channel, Emitter, Listener, thiserror, tauri::test, mock_builder, async tauri command, tauri error handling, tauri state management
When to use:
- Defining Rust command handlers (sync and async) for frontend invocation
- Managing application state across commands with
app.manage() and State<T>
- Implementing error types that serialize across the IPC boundary
- Emitting events from Rust to frontend (progress, notifications, background updates)
- Streaming data from Rust to frontend via channels
- Testing Rust commands with Tauri's mock runtime
- Organizing commands into modules as the backend grows
When NOT to use:
- Frontend invoke patterns and TypeScript types (see the framework-level Tauri skill)
- Permission/capability configuration (see the framework-level Tauri skill)
- Plugin installation and configuration (see the framework-level Tauri skill)
- Window management, system tray, menus (see the framework-level Tauri skill)
- Packaging and distribution (see the framework-level Tauri skill)
- General Rust programming not specific to Tauri APIs
Key patterns covered:
Detailed resources:
Philosophy
The Tauri Rust backend is the trust boundary between the untrusted webview frontend and the operating system. Every sensitive operation -- file I/O, network requests, shell commands, state mutations -- flows through Rust commands. The backend is responsible for validation, authorization, and safe execution.
Design principles:
- Commands are the API surface. Each command is a well-defined endpoint with typed arguments, typed return values, and explicit error handling. Treat them like HTTP handlers.
- State is managed, not global. Use
app.manage(T) to register singletons. Commands request state via State<T> injection -- no global statics, no lazy_static.
- Errors are data, not panics. Never
unwrap() in commands. Return Result<T, E> where E implements Serialize. The frontend receives structured error information.
- Async by default for I/O. Sync commands block the main thread. Use async for anything involving files, network, or long computation. Tokio is the runtime.
- Channels for streaming, events for notifications.
Channel<T> is optimized for ordered, high-throughput data delivery. Events are pub-sub fire-and-forget for small payloads.
Core Patterns
Pattern 1: Sync and Async Commands
Sync commands execute on the main thread. Async commands run on Tokio's thread pool.
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[tauri::command]
async fn read_file(path: String) -> Result<String, String> {
tokio::fs::read_to_string(&path)
.await
.map_err(|e| e.to_string())
}
Key rule: Async commands cannot use &str arguments unless the return type is Result<T, E>. Use String for owned args, or wrap in Result to satisfy Rust's async lifetime constraints.
See examples/core.md for command registration and argument conventions.
Pattern 2: Error Handling with thiserror
Command error types must implement both Serialize (for IPC) and Display (for Tauri's error serialization). The thiserror crate provides Display via #[error()] macros; implement Serialize manually to serialize as a string.
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("File not found: {0}")]
NotFound(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("Validation failed: {0}")]
Validation(String),
}
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())
}
}
Why manual Serialize: #[derive(Serialize)] on error enums serializes the enum variant structure (e.g., {"Io": {...}}), which is rarely useful for frontend error display. Serializing as a string gives the frontend a human-readable message.
See examples/core.md for the full error pattern with #[from] conversions.
Pattern 3: Managed State with Mutex
Register state with app.manage(). Commands access it via State<T> injection. Mutable fields require Mutex or RwLock.
use std::sync::Mutex;
#[derive(Default)]
struct AppState {
counter: Mutex<u32>,
config: Mutex<AppConfig>,
}
#[tauri::command]
fn increment(state: tauri::State<AppState>) -> u32 {
let mut counter = state.counter.lock().unwrap();
*counter += 1;
*counter
}
Key rule: State<T> requires T: Send + Sync. Mutex<T> and RwLock<T> provide this for mutable data. Tauri injects state automatically -- it is not passed from the frontend. Missing .manage() registration causes a runtime panic.
See examples/core.md for async state access, type alias patterns, and RwLock usage.
Pattern 4: AppHandle for App Resources
AppHandle gives commands access to the app's runtime: paths, windows, event emission, and plugin APIs.
use tauri::Manager;
#[tauri::command]
async fn get_app_data_path(app: tauri::AppHandle) -> Result<String, String> {
app.path()
.app_data_dir()
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| e.to_string())
}
Key rule: AppHandle is injected automatically like State<T>. Import tauri::Manager to access .path(), .get_webview_window(), and other runtime methods.
See examples/core.md for window access and combined state + AppHandle patterns.
Pattern 5: Channels for Streaming
Channel<T> streams ordered data from a command to the frontend. More efficient than events for high-throughput scenarios (file reads, download progress, log streaming).
use tauri::ipc::Channel;
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
enum DownloadEvent {
#[serde(rename_all = "camelCase")]
Progress { percent: u32, bytes_received: u64 },
Finished,
}
#[tauri::command]
async fn download(url: String, on_event: Channel<DownloadEvent>) -> Result<(), String> {
on_event.send(DownloadEvent::Progress { percent: 50, bytes_received: 1024 })
.map_err(|e| e.to_string())?;
on_event.send(DownloadEvent::Finished)
.map_err(|e| e.to_string())?;
Ok(())
}
Key rule: Channel payload types must implement Serialize + Clone. The channel is tied to the command invocation lifecycle. Use events (not channels) when you need to broadcast to all listeners from outside a command.
See examples/core.md for the frontend Channel setup.
Pattern 6: Emitting Events from Rust
Events provide fire-and-forget pub-sub communication from backend to frontend. Use for progress notifications, background updates, and decoupled messaging.
use tauri::Emitter;
#[tauri::command]
async fn start_sync(app: tauri::AppHandle) -> Result<(), String> {
app.emit("sync-started", ()).map_err(|e| e.to_string())?;
app.emit("sync-complete", serde_json::json!({ "count": 42 }))
.map_err(|e| e.to_string())?;
Ok(())
}
Key rule: Import tauri::Emitter to use .emit(), .emit_to(), and .emit_filter(). Event payloads must implement Serialize + Clone. Events are not typed -- use consistent naming conventions.
See examples/events.md for targeted window events, filtered emission, and listening from Rust.
<decision_framework>
Decision Framework
Command Design
How should this command be structured?
|-- Fast, CPU-only, no I/O?
| +-- Sync command: #[tauri::command] fn
|-- Involves file, network, or long computation?
| +-- Async command: #[tauri::command] async fn -> Result<T, E>
|-- Needs shared app state?
| +-- Add State<T> parameter, register with .manage()
|-- Needs app paths, windows, or event emission?
| +-- Add AppHandle parameter, import Manager trait
|-- Needs to stream data back to frontend?
| +-- Add Channel<T> parameter
+-- Needs raw request headers or binary body?
+-- Add tauri::ipc::Request parameter
Communication Method
How should Rust communicate with the frontend?
|-- Request/response (frontend asks, Rust answers)?
| +-- Command (invoke from frontend, return value)
|-- Ordered stream from a specific operation?
| +-- Channel<T> parameter in a command
|-- Fire-and-forget notification (broadcast)?
| +-- Event: app.emit() or app.emit_to()
+-- Need to run JS in the webview?
+-- webview.eval() (escape hatch, avoid if possible)
Error Strategy
How should this command handle errors?
|-- Quick prototype or simple command?
| +-- Result<T, String> with .map_err(|e| e.to_string())
|-- Production command with multiple error sources?
| +-- Custom error enum with thiserror + manual Serialize impl
|-- Truly unrecoverable (corrupt state, invariant violation)?
| +-- panic! (but never unwrap() on expected errors)
State Mutability
How should state be wrapped?
|-- Read-only config set once at startup?
| +-- No wrapper needed: app.manage(Config { ... })
|-- Read-heavy, infrequent writes?
| +-- RwLock<T>: multiple concurrent readers, exclusive writer
|-- Frequent reads and writes, simple fields?
| +-- Mutex<T>: exclusive access for both reads and writes
+-- Need to hold lock across .await points?
+-- tokio::sync::Mutex (not std::sync::Mutex)
</decision_framework>
<red_flags>
RED FLAGS
High Priority Issues:
- Using
unwrap() in commands instead of returning Result -- panics crash the command handler, frontend gets a generic error with no details
- Forgetting to register commands in
generate_handler![] -- compiles fine, silently fails at runtime
- Missing
serde::Serialize on error types -- compilation error, but the fix is non-obvious (manual impl, not derive)
- Using
std::sync::Mutex and holding the lock across .await -- blocks the Tokio runtime, causes deadlocks. Use tokio::sync::Mutex when you need to hold across await points
- Forgetting
.manage(T) registration -- runtime panic when a command tries to access State<T>
- Deriving
Serialize on error enums -- produces variant-structure JSON ({"Io": {...}}) instead of a readable string
Medium Priority Issues:
- Using events for high-throughput streaming (download progress, log tailing) -- events are JSON-serialized pub-sub, not optimized for throughput. Use
Channel<T>
- Using sync commands for I/O operations -- blocks the main thread, freezes the webview
- Not importing
tauri::Emitter when calling .emit() -- compilation error with confusing message about missing method
- Returning
Option<()> from commands -- serializes as null which the frontend may not expect (serde serialization/deserialization asymmetry)
Gotchas & Edge Cases:
- Async + borrowed args:
async fn cmd(name: &str) without Result return type fails to compile. Either use String or return Result<T, E>
- Argument naming: Frontend passes camelCase (
invokeMessage), Rust receives snake_case (invoke_message) by default. Use #[tauri::command(rename_all = "snake_case")] to change this
- State injection order:
State<T> parameters are not passed from frontend -- they are injected by Tauri. Mixing up "frontend args" and "injected params" in the function signature is confusing but works (Tauri filters them)
- Mutex poisoning:
lock().unwrap() panics if a previous holder panicked. In production, handle PoisonError or use lock().expect("state lock poisoned")
- Multiple state types: Each
.manage(T) call registers a separate type. State<Mutex<AppState>> and State<AppState> are different registrations
- Channel lifetime:
Channel<T> is tied to the command invocation. It cannot be stored for later use outside the command
- Event payload types: Event payloads must be
Serialize + Clone. serde_json::Value works as a catch-all but loses type safety
emit_to target: Target is a webview label string. If the webview does not exist, the event is silently dropped
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)
(You MUST register every command in tauri::generate_handler![] -- unregistered commands compile fine but silently fail at runtime)
(You MUST implement serde::Serialize on all error types returned from commands -- Tauri serializes errors across the IPC boundary)
(You MUST wrap mutable managed state in Mutex or RwLock -- commands run concurrently and State<T> requires Send + Sync)
(You MUST return Result<T, E> from async commands that use borrowed args (&str, State<'_, T>) -- Rust lifetime rules require it)
(You MUST use Channel<T> for streaming data to frontend -- events are designed for small payloads, not high-throughput streaming)
Failure to follow these rules will cause silent command failures, runtime panics, deadlocked async runtimes, or unserializable error types.
</critical_reminders>