Claude Skill

desktop-backend-tauri

Tauri 2.x Rust command patterns, state management, error handling, events, channels, testing

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_desktop-backend-tauri_skills_desktop-backend-tauri-3a51ef5.zip · 16 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/desktop-backend-tauri/skills/desktop-backend-tauri
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git 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 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: #[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() 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:




<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>

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.

No comments yet.

Reviews (0)

No reviews yet.

Related