src/skills/desktop-backend-tauri/SKILL.md
Tauri 2.x Rust command patterns, state management, error handling, events, channels, testing
npx skillsauth add agents-inc/skills desktop-backend-tauriInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: Define commands with
#[tauri::command], register ingenerate_handler![]. UseState<T>for shared state (wrap mutable fields inMutex). Error types must implement bothserde::SerializeandDisplay-- usethiserrorfor ergonomic error enums. Async commands run on Tokio -- borrowed args (&str,State<'_, T>) requireResult<T, E>return type. Stream data to frontend viaChannel<T>(not events) for high throughput. Emit events withapp.emit()for fire-and-forget notifications.Current version: Tauri 2.x (stable). Async runtime is Tokio.
<critical_requirements>
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:
app.manage() and State<T>When NOT to use:
Key patterns covered:
#[tauri::command] (examples/core.md)thiserror + manual Serialize impl (examples/core.md)Mutex/RwLock and State<T> injection (examples/core.md)AppHandle for accessing app resources from commands (examples/core.md)Detailed resources:
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:
app.manage(T) to register singletons. Commands request state via State<T> injection -- no global statics, no lazy_static.unwrap() in commands. Return Result<T, E> where E implements Serialize. The frontend receives structured error information.Channel<T> is optimized for ordered, high-throughput data delivery. Events are pub-sub fire-and-forget for small payloads.Sync commands execute on the main thread. Async commands run on Tokio's thread pool.
// Sync -- blocks main thread, use only for fast operations
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// Async -- runs on Tokio, use for I/O and long operations
#[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.
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),
}
// Manual Serialize -- converts error to its Display 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.
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.
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.
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> {
// ... download logic ...
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.
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())?;
// ... sync work ...
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.
</patterns><decision_framework>
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
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)
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)
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>
High Priority Issues:
unwrap() in commands instead of returning Result -- panics crash the command handler, frontend gets a generic error with no detailsgenerate_handler![] -- compiles fine, silently fails at runtimeserde::Serialize on error types -- compilation error, but the fix is non-obvious (manual impl, not derive)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.manage(T) registration -- runtime panic when a command tries to access State<T>Serialize on error enums -- produces variant-structure JSON ({"Io": {...}}) instead of a readable stringMedium Priority Issues:
Channel<T>tauri::Emitter when calling .emit() -- compilation error with confusing message about missing methodOption<()> from commands -- serializes as null which the frontend may not expect (serde serialization/deserialization asymmetry)Gotchas & Edge Cases:
async fn cmd(name: &str) without Result return type fails to compile. Either use String or return Result<T, E>invokeMessage), Rust receives snake_case (invoke_message) by default. Use #[tauri::command(rename_all = "snake_case")] to change thisState<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)lock().unwrap() panics if a previous holder panicked. In production, handle PoisonError or use lock().expect("state lock poisoned").manage(T) call registers a separate type. State<Mutex<AppState>> and State<AppState> are different registrationsChannel<T> is tied to the command invocation. It cannot be stored for later use outside the commandSerialize + Clone. serde_json::Value works as a catch-all but loses type safetyemit_to target: Target is a webview label string. If the webview does not exist, the event is silently dropped</red_flags>
<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>
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Xquik REST API patterns for X post search, user and timeline reads, cursor pagination, media downloads, monitors, signed webhooks, and approval-gated X actions
development
Mapbox GL JS interactive maps - map initialization, markers, popups, sources, layers, expressions, clustering, 3D terrain, geocoding, directions
tools
Leaflet interactive maps - map setup, tile layers, markers, popups, GeoJSON, custom controls, plugins, clustering, events