desktop-backend-tauri
Tauri 2.x Rust command patterns, state management, error handling, events, channels, testing
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/desktop-backend-tauri/skills/desktop-backend-tauri
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Tauri Rust Backend Patterns
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>
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: #[tauricommand], tauricommand, tauriState, AppHandle, app.manage, generate_handler, tauriipcChannel, Emitter, Listener, thiserror, tauritest, 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()andState<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:
- Sync and async commands with
#[tauri::command](examples/core.md) - Error handling with
thiserror+ manualSerializeimpl (examples/core.md) - Managed state with
Mutex/RwLockandState<T>injection (examples/core.md) AppHandlefor accessing app resources from commands (examples/core.md)- Channels for streaming data to frontend (examples/core.md)
- Emitting events from Rust (examples/events.md)
- Listening for frontend events in Rust (examples/events.md)
- Testing commands with mock runtime (examples/testing.md)
- Command organization in modules (examples/core.md)
Detailed resources:
- examples/core.md - Commands, error handling, state, AppHandle, channels, modules
- examples/events.md - Emitting and listening for events from Rust
- examples/testing.md - Mock runtime, testing commands with state
- reference.md - Decision frameworks, quick-lookup tables, lifetime rules
<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 returningResult-- 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::Serializeon error types -- compilation error, but the fix is non-obvious (manual impl, not derive) - Using
std::sync::Mutexand holding the lock across.await-- blocks the Tokio runtime, causes deadlocks. Usetokio::sync::Mutexwhen you need to hold across await points - Forgetting
.manage(T)registration -- runtime panic when a command tries to accessState<T> - Deriving
Serializeon 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::Emitterwhen calling.emit()-- compilation error with confusing message about missing method - Returning
Option<()>from commands -- serializes asnullwhich the frontend may not expect (serde serialization/deserialization asymmetry)
Gotchas & Edge Cases:
- Async + borrowed args:
async fn cmd(name: &str)withoutResultreturn type fails to compile. Either useStringor returnResult<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, handlePoisonErroror uselock().expect("state lock poisoned") - Multiple state types: Each
.manage(T)call registers a separate type.State<Mutex<AppState>>andState<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::Valueworks as a catch-all but loses type safety emit_totarget: 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>
Files (skills)
-
examples
-
core.md 11.3 KB
# Tauri Rust Backend - Core Examples > Commands, error handling, state management, AppHandle, channels, command organization. See [SKILL.md](../SKILL.md) for decision frameworks and red flags. See [events.md](events.md) for event emission patterns. See [testing.md](testing.md) for mock runtime testing. --- ## Sync Command with Arguments ```rust // src-tauri/src/commands/greet.rs #[tauri::command] pub fn greet(name: &str) -> String { format!("Hello, {}!", name) } ``` ```typescript // Frontend invocation import { invoke } from "@tauri-apps/api/core"; const greeting = await invoke<string>("greet", { name: "World" }); ``` **Key points:** - Arguments arrive as a JSON object -- keys must match Rust parameter names - By default, frontend sends camelCase (`invokeMessage`), Rust receives snake_case (`invoke_message`) - Use `#[tauri::command(rename_all = "snake_case")]` to require snake_case from frontend instead --- ## Async Command with Result ```rust // src-tauri/src/commands/files.rs #[tauri::command] pub async fn read_file(path: String) -> Result<String, String> { tokio::fs::read_to_string(&path) .await .map_err(|e| e.to_string()) } ``` **Why `String` not `&str` for `path`:** Async commands cannot use borrowed args (`&str`) unless the return type is `Result<T, E>`. Using owned `String` avoids the lifetime constraint entirely. **Alternative with borrowed args:** ```rust // This works because of the Result return type #[tauri::command] pub async fn search(query: &str, state: tauri::State<'_, AppState>) -> Result<Vec<String>, String> { // &str and State<'_, T> both work when return type is Result let items = state.items.lock().unwrap(); Ok(items.iter().filter(|i| i.contains(query)).cloned().collect()) } ``` --- ## Error Handling with thiserror The production pattern for error handling: `thiserror` for `Display` and `From` impls, manual `Serialize` for IPC. ```rust // src-tauri/src/error.rs use thiserror::Error; #[derive(Debug, Error)] pub enum AppError { #[error("File not found: {0}")] NotFound(String), #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] SerdeJson(#[from] serde_json::Error), #[error("Validation failed: {0}")] Validation(String), #[error("Database error: {0}")] Database(String), } // Manual Serialize: always serialize as the 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()) } } ``` ```rust // src-tauri/src/commands/data.rs use crate::error::AppError; #[tauri::command] pub async fn load_config(path: String) -> Result<serde_json::Value, AppError> { let content = tokio::fs::read_to_string(&path).await?; // #[from] converts io::Error let config: serde_json::Value = serde_json::from_str(&content)?; // #[from] converts serde error Ok(config) } ``` **Why manual `Serialize` instead of `#[derive(Serialize)]`:** ```rust // BAD: derive(Serialize) produces variant-structure JSON #[derive(Debug, Error, serde::Serialize)] pub enum AppError { #[error("Not found: {0}")] NotFound(String), } // Frontend receives: {"NotFound": "file.txt"} -- awkward to handle // GOOD: manual impl produces a readable string // Frontend receives: "Not found: file.txt" -- easy to display ``` --- ## Managed State with Mutex ```rust // src-tauri/src/state.rs use std::sync::Mutex; #[derive(Default)] pub struct AppStateInner { pub items: Vec<String>, pub count: u32, } // Type alias avoids repeating Mutex<...> everywhere pub type AppState = Mutex<AppStateInner>; ``` ```rust // src-tauri/src/lib.rs use state::AppState; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(AppState::default()) .invoke_handler(tauri::generate_handler![ commands::items::add_item, commands::items::get_items, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` ```rust // src-tauri/src/commands/items.rs use crate::state::AppState; #[tauri::command] pub fn add_item(state: tauri::State<AppState>, item: String) -> Vec<String> { let mut inner = state.lock().unwrap(); inner.items.push(item); inner.count += 1; inner.items.clone() } #[tauri::command] pub fn get_items(state: tauri::State<AppState>) -> Vec<String> { let inner = state.lock().unwrap(); inner.items.clone() } ``` **Key points:** - `State<T>` is injected automatically -- it is never passed from the frontend - The type alias `type AppState = Mutex<AppStateInner>` keeps command signatures clean - `lock().unwrap()` panics if a previous holder panicked (mutex poisoning). In production, handle `PoisonError` ### RwLock for Read-Heavy State ```rust use std::sync::RwLock; pub type AppConfig = RwLock<ConfigInner>; #[tauri::command] pub fn get_setting(state: tauri::State<AppConfig>, key: String) -> Option<String> { let config = state.read().unwrap(); config.settings.get(&key).cloned() } #[tauri::command] pub fn set_setting(state: tauri::State<AppConfig>, key: String, value: String) { let mut config = state.write().unwrap(); config.settings.insert(key, value); } ``` **When to use RwLock:** Multiple concurrent readers with infrequent writes. `read()` does not block other readers. `write()` blocks everything. ### Async State Access with tokio::sync::Mutex ```rust use tokio::sync::Mutex; pub type AsyncState = Mutex<ExpensiveResource>; #[tauri::command] pub async fn process(state: tauri::State<'_, AsyncState>) -> Result<String, String> { let mut resource = state.lock().await; // .await, not .unwrap() resource.do_async_work().await.map_err(|e| e.to_string()) } ``` **When to use tokio::sync::Mutex:** When you need to hold the lock across `.await` points. Standard `std::sync::Mutex` blocks the Tokio runtime thread if held across an await -- use it only when the critical section is synchronous. --- ## AppHandle for App Resources ```rust use tauri::Manager; #[tauri::command] pub async fn open_settings(app: tauri::AppHandle) -> Result<(), String> { // Access app directories let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; // Get or focus a window if let Some(window) = app.get_webview_window("settings") { window.set_focus().map_err(|e| e.to_string())?; } Ok(()) } ``` ### Combined State + AppHandle ```rust use tauri::Manager; use crate::state::AppState; #[tauri::command] pub async fn save_and_notify( app: tauri::AppHandle, state: tauri::State<'_, AppState>, data: String, ) -> Result<(), String> { // Access state { let mut inner = state.lock().unwrap(); inner.items.push(data.clone()); } // Lock released here // Use app handle for side effects use tauri::Emitter; app.emit("data-saved", &data).map_err(|e| e.to_string())?; Ok(()) } ``` **Key point:** Release the `Mutex` lock before performing async operations or emitting events. Use a block scope `{ ... }` to drop the lock guard early. --- ## Channels for Streaming Data ### Rust Command with Channel ```rust use tauri::ipc::Channel; use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase", tag = "type")] pub enum ProgressEvent { #[serde(rename_all = "camelCase")] Progress { percent: u32, bytes_received: u64 }, Finished { total_bytes: u64 }, Error { message: String }, } const CHUNK_SIZE: usize = 4096; #[tauri::command] pub async fn download_file( url: String, on_progress: Channel<ProgressEvent>, ) -> Result<(), String> { // Simulated download loop let total: u64 = 10240; let mut received: u64 = 0; while received < total { // ... perform download chunk ... received += CHUNK_SIZE as u64; let percent = ((received as f64 / total as f64) * 100.0) as u32; on_progress .send(ProgressEvent::Progress { percent, bytes_received: received, }) .map_err(|e| e.to_string())?; } on_progress .send(ProgressEvent::Finished { total_bytes: total }) .map_err(|e| e.to_string())?; Ok(()) } ``` ### Frontend Channel Setup ```typescript import { invoke, Channel } from "@tauri-apps/api/core"; type ProgressEvent = | { type: "progress"; percent: number; bytesReceived: number } | { type: "finished"; totalBytes: number } | { type: "error"; message: string }; const channel = new Channel<ProgressEvent>(); channel.onmessage = (event) => { switch (event.type) { case "progress": console.log(`${event.percent}% (${event.bytesReceived} bytes)`); break; case "finished": console.log(`Done: ${event.totalBytes} bytes`); break; case "error": console.error(event.message); break; } }; await invoke("download_file", { url: "https://example.com/file", onProgress: channel, }); ``` **Key points:** - `Channel<T>` payload must implement `Serialize + Clone` - Channel is tied to the command invocation -- it cannot be stored or reused outside the command - Use `#[serde(tag = "type")]` for tagged union serialization that maps cleanly to TypeScript discriminated unions - Frontend receives camelCase keys (`bytesReceived`) due to `#[serde(rename_all = "camelCase")]` --- ## Raw IPC Request Access For custom headers or binary payloads: ```rust use tauri::ipc::Request; #[tauri::command] pub fn upload(request: Request) -> Result<String, String> { let tauri::ipc::InvokeBody::Raw(data) = request.body() else { return Err("Expected raw binary body".into()); }; let auth = request .headers() .get("Authorization") .ok_or("Missing Authorization header")? .to_str() .map_err(|e| e.to_string())?; Ok(format!("Received {} bytes with auth: {}", data.len(), auth)) } ``` --- ## Command Organization in Modules As the backend grows, organize commands by domain: ``` src-tauri/src/ lib.rs # Builder setup, manage(), generate_handler! error.rs # Shared AppError type state.rs # AppState definition commands/ mod.rs # pub mod declarations files.rs # File-related commands auth.rs # Auth-related commands sync.rs # Sync/background commands ``` ```rust // src-tauri/src/commands/mod.rs pub mod auth; pub mod files; pub mod sync; ``` ```rust // src-tauri/src/lib.rs mod commands; mod error; mod state; use state::AppState; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(AppState::default()) .invoke_handler(tauri::generate_handler![ commands::files::read_file, commands::files::write_file, commands::auth::login, commands::auth::logout, commands::sync::start_sync, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` **Key rule:** All commands must be listed in a single `generate_handler![]` macro invocation. Commands in modules must be `pub` and referenced with their full module path. --- See [events.md](events.md) for event emission patterns and [testing.md](testing.md) for mock runtime testing. -
events.md 5.8 KB
# Tauri Rust Backend - Event Patterns > Emitting events from Rust, listening for frontend events, targeted emission, structured payloads. See [SKILL.md](../SKILL.md) for decision frameworks. See [core.md](core.md) for commands and channels. --- ## Emitting Global Events Broadcast to all listeners (frontend and backend): ```rust use tauri::Emitter; use serde::Serialize; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct SyncProgress { items_synced: u32, total_items: u32, } #[tauri::command] async fn start_sync(app: tauri::AppHandle) -> Result<(), String> { let total: u32 = 100; for i in 0..=total { app.emit( "sync-progress", SyncProgress { items_synced: i, total_items: total, }, ) .map_err(|e| e.to_string())?; tokio::time::sleep(std::time::Duration::from_millis(50)).await; } app.emit("sync-complete", ()).map_err(|e| e.to_string())?; Ok(()) } ``` **Key points:** - Import `tauri::Emitter` to use `.emit()` on `AppHandle` - Payloads must implement `Serialize + Clone` - Use `#[serde(rename_all = "camelCase")]` so frontend receives camelCase keys - Unit payload `()` is valid for signal-only events --- ## Emitting to Specific Windows Target a single webview window by label: ```rust use tauri::Emitter; #[tauri::command] async fn notify_window(app: tauri::AppHandle, label: String, message: String) -> Result<(), String> { app.emit_to(&label, "notification", &message) .map_err(|e| e.to_string()) } ``` **Gotcha:** If the target webview does not exist, `emit_to` silently drops the event -- no error is returned. Verify the window exists first if delivery is critical. --- ## Filtered Event Emission Emit to a subset of webview windows based on a predicate: ```rust use tauri::{Emitter, EventTarget}; #[tauri::command] async fn broadcast_to_editors(app: tauri::AppHandle, content: String) -> Result<(), String> { app.emit_filter("content-updated", &content, |target| match target { EventTarget::WebviewWindow { label } => { label.starts_with("editor-") } _ => false, }) .map_err(|e| e.to_string()) } ``` **When to use:** When you need to target multiple (but not all) windows. For a single window, use `emit_to`. For all windows, use `emit`. --- ## Listening for Frontend Events in Rust ### In App Setup ```rust use tauri::Listener; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .setup(|app| { app.listen("user-action", |event| { println!("Received user action: {:?}", event.payload()); }); // Listen with unlisten capability let id = app.listen("settings-changed", |event| { println!("Settings changed: {:?}", event.payload()); }); // Later, to stop listening: // app.unlisten(id); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` ### In Commands via AppHandle ```rust use tauri::Listener; #[tauri::command] async fn watch_for_event(app: tauri::AppHandle) -> Result<(), String> { let handle = app.clone(); app.listen("external-data", move |event| { let payload = event.payload(); println!("Received: {:?}", payload); // Use the cloned handle for further actions use tauri::Emitter; let _ = handle.emit("data-processed", payload); }); Ok(()) } ``` **Key points:** - Import `tauri::Listener` to use `.listen()` on `AppHandle` or `App` - `.listen()` returns an `EventId` that can be passed to `.unlisten()` to stop listening - Event payloads arrive as `&str` (JSON string) -- parse manually if structured data is needed - Clone `AppHandle` before moving into closure if you need to emit from inside a listener --- ## Structured Event Payloads Use serde-tagged enums for type-safe event payloads: ```rust use serde::Serialize; use tauri::Emitter; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase", tag = "status")] enum TaskUpdate { #[serde(rename_all = "camelCase")] Running { progress_percent: u32 }, Completed { result: String }, Failed { error: String }, } #[tauri::command] async fn run_task(app: tauri::AppHandle) -> Result<(), String> { app.emit("task-update", TaskUpdate::Running { progress_percent: 0 }) .map_err(|e| e.to_string())?; // ... task work ... app.emit("task-update", TaskUpdate::Completed { result: "done".into() }) .map_err(|e| e.to_string())?; Ok(()) } ``` ```typescript // Frontend receives tagged objects: // { status: "running", progressPercent: 0 } // { status: "completed", result: "done" } // { status: "failed", error: "..." } ``` **Why tagged enums:** Maps cleanly to TypeScript discriminated unions. The frontend can switch on the `status` field for type-safe handling. --- ## Events from Background Threads For long-running background work that outlives a single command: ```rust use tauri::Emitter; pub fn spawn_background_watcher(app: tauri::AppHandle) { tauri::async_runtime::spawn(async move { loop { // ... check for changes ... let _ = app.emit("file-changed", "/path/to/file"); tokio::time::sleep(std::time::Duration::from_secs(1)).await; } }); } ``` ```rust // Register in setup tauri::Builder::default() .setup(|app| { spawn_background_watcher(app.handle().clone()); Ok(()) }) ``` **Key point:** Use `tauri::async_runtime::spawn` (not `tokio::spawn` directly) to ensure the task runs on Tauri's managed runtime. Clone `AppHandle` before moving into the spawned future. --- See [core.md](core.md) for channels (preferred for streaming) and [testing.md](testing.md) for testing event emission. -
testing.md 6.1 KB
# Tauri Rust Backend - Testing Patterns > Testing commands with Tauri's mock runtime, testing state access, testing async commands. See [SKILL.md](../SKILL.md) for red flags. See [core.md](core.md) for command patterns. --- ## Setup: Enable Test Feature Add the `test` feature to your Tauri dependency: ```toml # src-tauri/Cargo.toml [dev-dependencies] tauri = { version = "2", features = ["test"] } tokio = { version = "1", features = ["macros", "rt"] } serde_json = "1" ``` --- ## Testing Commands Directly (Unit Test) The simplest approach: call the command function directly, bypassing the IPC layer. Works for commands that do not use injected parameters (`State<T>`, `AppHandle`). ```rust #[cfg(test)] mod tests { use super::*; #[test] fn greet_returns_formatted_string() { let result = greet("World"); assert_eq!(result, "Hello, World!"); } #[tokio::test] async fn read_file_returns_error_for_missing_path() { let result = read_file("/nonexistent/path.txt".into()).await; assert!(result.is_err()); } } ``` **When to use:** Commands with no injected parameters. Fast, no Tauri runtime overhead. --- ## Testing with Mock Runtime For commands that use `State<T>`, `AppHandle`, or `WebviewWindow`, use Tauri's mock builder: ```rust #[cfg(test)] mod tests { use std::sync::Mutex; use tauri::test::{mock_builder, mock_context, noop_assets}; use super::*; use crate::state::AppState; fn create_test_app() -> tauri::App<tauri::test::MockRuntime> { mock_builder() .manage(Mutex::new(AppState::default())) .invoke_handler(tauri::generate_handler![add_item, get_items]) .build(mock_context(noop_assets())) .expect("failed to build test app") } #[test] fn add_item_updates_state() { let app = create_test_app(); let state = app.state::<Mutex<AppState>>(); // Call the command directly with injected state let result = add_item( tauri::State::from(&state), "test item".into(), ); assert_eq!(result, vec!["test item"]); } #[test] fn get_items_returns_current_state() { let app = create_test_app(); let state = app.state::<Mutex<AppState>>(); // Pre-populate state { let mut inner = state.lock().unwrap(); inner.items.push("existing".into()); } let items = get_items(tauri::State::from(&state)); assert_eq!(items, vec!["existing"]); } } ``` **Key points:** - `mock_builder()` creates a `Builder` with `MockRuntime` (no actual webview) - `mock_context(noop_assets())` provides a minimal app context - Access managed state via `app.state::<T>()` and wrap in `tauri::State::from()` - The `MockRuntime` does not execute any native webview code --- ## Testing Async Commands with State ```rust #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn save_and_notify_adds_to_state() { let app = create_test_app(); let state = app.state::<Mutex<AppState>>(); let handle = app.handle().clone(); let result = save_and_notify( handle, tauri::State::from(&state), "new data".into(), ) .await; assert!(result.is_ok()); let inner = state.lock().unwrap(); assert!(inner.items.contains(&"new data".to_string())); } } ``` **Key point:** For commands that take `AppHandle`, pass `app.handle().clone()`. The mock runtime provides a functional `AppHandle` that supports state access and event emission (but not actual window operations). --- ## Testing Error Handling ```rust #[cfg(test)] mod tests { use super::*; use crate::error::AppError; #[tokio::test] async fn load_config_returns_not_found_for_missing_file() { let result = load_config("/nonexistent/config.json".into()).await; assert!(result.is_err()); let err = result.unwrap_err(); // Verify the error serializes correctly (frontend receives this string) let serialized = serde_json::to_string(&err).unwrap(); assert!(serialized.contains("No such file")); } #[test] fn app_error_serializes_as_string() { let error = AppError::NotFound("test.txt".into()); let json = serde_json::to_value(&error).unwrap(); // Manual Serialize impl produces a string, not a variant object assert_eq!(json, serde_json::json!("File not found: test.txt")); } } ``` **Why test serialization:** The frontend receives the serialized error. Verify that your manual `Serialize` impl produces the expected string format. --- ## Testing Pure Logic Separately Extract business logic out of command handlers into pure functions. Test those directly without any Tauri infrastructure: ```rust // src-tauri/src/logic/validation.rs pub fn validate_username(name: &str) -> Result<(), String> { if name.len() < 3 { return Err("Username must be at least 3 characters".into()); } if !name.chars().all(|c| c.is_alphanumeric() || c == '_') { return Err("Username can only contain letters, numbers, and underscores".into()); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn rejects_short_username() { assert!(validate_username("ab").is_err()); } #[test] fn rejects_special_characters() { assert!(validate_username("user@name").is_err()); } #[test] fn accepts_valid_username() { assert!(validate_username("valid_user_123").is_ok()); } } ``` ```rust // Command is a thin wrapper around pure logic #[tauri::command] pub fn create_user(name: String) -> Result<String, String> { validate_username(&name)?; Ok(format!("User {} created", name)) } ``` **Best practice:** Keep command handlers thin. Extract validation, transformation, and business logic into pure functions. Test the pure functions directly (fast, no mocking). Use mock runtime tests only for integration points (state access, event emission, AppHandle usage). --- See [core.md](core.md) for command patterns and [events.md](events.md) for event emission.
-
-
reference.md 7.4 KB
# Tauri Rust Backend Quick Reference > Quick-lookup tables, lifetime rules, common imports. See [SKILL.md](SKILL.md) for decision frameworks and red flags. See [examples/core.md](examples/core.md) for full code examples. --- ## Command Signature Quick Reference | Scenario | Signature | | -------------------- | -------------------------------------------------------------------------------- | | Sync, no deps | `fn cmd(arg: &str) -> String` | | Async, owned args | `async fn cmd(arg: String) -> Result<T, E>` | | Async, borrowed args | `async fn cmd(arg: &str) -> Result<T, E>` | | With state | `fn cmd(state: State<AppState>) -> T` | | With state (async) | `async fn cmd(state: State<'_, AppState>) -> Result<T, E>` | | With AppHandle | `async fn cmd(app: AppHandle) -> Result<T, E>` | | With Channel | `async fn cmd(ch: Channel<Event>) -> Result<(), E>` | | With raw request | `fn cmd(req: tauri::ipc::Request) -> Result<T, E>` | | Combined | `async fn cmd(app: AppHandle, state: State<'_, T>, arg: String) -> Result<V, E>` | --- ## Common Imports | Import | When | | -------------------------------------- | ------------------------------------------------------------------ | | `use tauri::Manager;` | Access `.path()`, `.get_webview_window()`, `.state()` on AppHandle | | `use tauri::Emitter;` | Call `.emit()`, `.emit_to()`, `.emit_filter()` | | `use tauri::Listener;` | Call `.listen()`, `.unlisten()` | | `use tauri::ipc::Channel;` | Streaming data to frontend from a command | | `use tauri::ipc::Request;` | Access raw request headers and body | | `use tauri::ipc::Response;` | Return optimized binary data | | `use serde::{Serialize, Deserialize};` | Serialize/deserialize command args and returns | | `use thiserror::Error;` | Derive Display and From for error enums | | `use std::sync::Mutex;` | Wrap mutable state (sync critical sections) | | `use std::sync::RwLock;` | Wrap read-heavy mutable state | | `use tokio::sync::Mutex;` | Wrap state accessed across .await points | --- ## Lifetime Rules for Async Commands | Argument Type | Sync Command | Async Command | | ----------------- | ------------ | ----------------------------------------------- | | `&str` | Works | Requires `Result<T, E>` return | | `String` | Works | Works | | `State<AppState>` | Works | Requires `State<'_, T>` + `Result<T, E>` return | | `AppHandle` | Works | Works | | `Channel<T>` | Works | Works | | `WebviewWindow` | Works | Works | **Rule:** If an async command uses any borrowed type (`&str`, `State<'_, T>`), the return type MUST be `Result<T, E>`. This is a Rust lifetime constraint, not a Tauri design choice. --- ## Argument Naming Convention | Rust Parameter | Frontend Key (default) | With `rename_all = "snake_case"` | | -------------- | ---------------------- | -------------------------------- | | `file_path` | `filePath` | `file_path` | | `user_name` | `userName` | `user_name` | | `is_active` | `isActive` | `is_active` | **Default:** Frontend sends camelCase, Rust receives snake_case. This happens automatically. **Override:** `#[tauri::command(rename_all = "snake_case")]` requires frontend to send snake_case. --- ## State Wrapper Decision Table | Scenario | Wrapper | Reason | | ------------------------------- | ----------------------- | ---------------------------------- | | Immutable config | None | Set once at startup, never changes | | Mutable, short critical section | `std::sync::Mutex<T>` | Simple exclusive access | | Read-heavy, infrequent writes | `std::sync::RwLock<T>` | Multiple concurrent readers | | Lock held across .await | `tokio::sync::Mutex<T>` | Does not block Tokio runtime | | Per-field granularity | Wrap individual fields | Reduces lock contention | --- ## Error Type Checklist For any error type returned from a `#[tauri::command]`: - [ ] Implements `Debug` (derive) - [ ] Implements `Display` (via `thiserror::Error` derive) - [ ] Implements `serde::Serialize` (manual impl, serializes as string) - [ ] Uses `#[from]` on variants for automatic `?` operator conversion - [ ] Uses `#[error(transparent)]` for pass-through error messages --- ## Injected vs Frontend Parameters Tauri automatically identifies and injects these parameter types -- they are NOT passed from the frontend: | Injected Parameter | Purpose | | --------------------- | ----------------------------------------------- | | `State<T>` | Managed state registered with `.manage()` | | `AppHandle` | App runtime access (paths, windows, events) | | `WebviewWindow` | The calling webview window | | `Channel<T>` | Streaming channel (frontend creates and passes) | | `tauri::ipc::Request` | Raw IPC request (headers, body) | All other parameters are deserialized from the frontend's `invoke()` arguments object. --- ## Communication Method Comparison | Feature | Commands | Events | Channels | | ----------- | ---------------------------- | ----------------------- | -------------------- | | Direction | Frontend -> Rust -> Frontend | Bidirectional | Rust -> Frontend | | Pattern | Request/response | Pub/sub | Ordered stream | | Type safety | Full (serde) | Weak (JSON string) | Full (serde) | | Throughput | Per-call | Low (JSON overhead) | High (optimized) | | Lifetime | Single invocation | App-wide | Tied to command | | Use case | Data queries, mutations | Notifications, progress | File streaming, logs | --- ## See Also - [Tauri v2 - Calling Rust from Frontend](https://v2.tauri.app/develop/calling-rust/) - [Tauri v2 - Calling Frontend from Rust](https://v2.tauri.app/develop/calling-frontend/) - [Tauri v2 - State Management](https://v2.tauri.app/develop/state-management/) - [Tauri v2 - Testing](https://v2.tauri.app/develop/tests/) - [thiserror crate](https://docs.rs/thiserror/latest/thiserror/) -
SKILL.md 16.1 KB
--- 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:** - Sync and async commands with `#[tauri::command]` ([examples/core.md](examples/core.md)) - Error handling with `thiserror` + manual `Serialize` impl ([examples/core.md](examples/core.md)) - Managed state with `Mutex`/`RwLock` and `State<T>` injection ([examples/core.md](examples/core.md)) - `AppHandle` for accessing app resources from commands ([examples/core.md](examples/core.md)) - Channels for streaming data to frontend ([examples/core.md](examples/core.md)) - Emitting events from Rust ([examples/events.md](examples/events.md)) - Listening for frontend events in Rust ([examples/events.md](examples/events.md)) - Testing commands with mock runtime ([examples/testing.md](examples/testing.md)) - Command organization in modules ([examples/core.md](examples/core.md)) **Detailed resources:** - [examples/core.md](examples/core.md) - Commands, error handling, state, AppHandle, channels, modules - [examples/events.md](examples/events.md) - Emitting and listening for events from Rust - [examples/testing.md](examples/testing.md) - Mock runtime, testing commands with state - [reference.md](reference.md) - Decision frameworks, quick-lookup tables, lifetime rules --- <philosophy> ## 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. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Sync and Async Commands Sync commands execute on the main thread. Async commands run on Tokio's thread pool. ```rust // 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](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. ```rust 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](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`. ```rust 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](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. ```rust 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](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). ```rust 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](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. ```rust 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](examples/events.md) for targeted window events, filtered emission, and listening from Rust. </patterns> --- <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>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.