.agents/skills/tauri-v2/references/ipc-patterns.md

9.9 KiB

Tauri v2 IPC Patterns Reference

Overview

Tauri v2 provides three IPC primitives:

  1. Commands: Request-response (most common)
  2. Events: Fire-and-forget notifications
  3. Channels: High-frequency streaming

Commands (invoke)

Basic Command

Rust:

#[tauri::command]
fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}

// Register in builder
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![greet])

Frontend:

import { invoke } from '@tauri-apps/api/core';

const result = await invoke<string>('greet', { name: 'World' });

Command with Multiple Arguments

Rust:

#[tauri::command]
fn calculate(a: i32, b: i32, operation: String) -> i32 {
    match operation.as_str() {
        "add" => a + b,
        "sub" => a - b,
        "mul" => a * b,
        "div" => a / b,
        _ => 0,
    }
}

Frontend:

const result = await invoke<number>('calculate', {
    a: 10,
    b: 5,
    operation: 'add'
});

Async Command

Rust:

#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
    // Use owned types (String, not &str) in async commands
    let response = reqwest::get(&url)
        .await
        .map_err(|e| e.to_string())?;

    response.text()
        .await
        .map_err(|e| e.to_string())
}

Frontend:

try {
    const data = await invoke<string>('fetch_data', { url: 'https://api.example.com' });
} catch (error) {
    console.error('Failed:', error);
}

Command with Result Error Handling

Rust:

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("File not found: {0}")]
    NotFound(String),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Permission denied")]
    PermissionDenied,
}

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())
    }
}

#[tauri::command]
fn read_config(path: String) -> Result<Config, AppError> {
    if !std::path::Path::new(&path).exists() {
        return Err(AppError::NotFound(path));
    }
    // ...
}

Frontend:

try {
    const config = await invoke<Config>('read_config', { path: '/config.json' });
} catch (error) {
    // error is the serialized error string
    console.error('Config error:', error);
}

Command with State

Rust:

use std::sync::Mutex;
use tauri::State;

struct AppState {
    counter: u32,
    items: Vec<String>,
}

#[tauri::command]
fn get_count(state: State<'_, Mutex<AppState>>) -> u32 {
    state.lock().unwrap().counter
}

#[tauri::command]
fn increment(state: State<'_, Mutex<AppState>>) -> u32 {
    let mut s = state.lock().unwrap();
    s.counter += 1;
    s.counter
}

#[tauri::command]
fn add_item(item: String, state: State<'_, Mutex<AppState>>) {
    state.lock().unwrap().items.push(item);
}

// In builder:
tauri::Builder::default()
    .manage(Mutex::new(AppState { counter: 0, items: vec![] }))
    .invoke_handler(tauri::generate_handler![get_count, increment, add_item])

Command with Window Access

Rust:

use tauri::{WebviewWindow, AppHandle};

#[tauri::command]
fn get_window_info(window: WebviewWindow) -> String {
    format!("Window label: {}", window.label())
}

#[tauri::command]
fn create_window(app: AppHandle) -> Result<(), String> {
    tauri::WebviewWindowBuilder::new(
        &app,
        "new-window",
        tauri::WebviewUrl::App("index.html".into())
    )
    .title("New Window")
    .build()
    .map_err(|e| e.to_string())?;
    Ok(())
}

Command with Raw Binary Data

Rust:

use tauri::ipc::Response;

#[tauri::command]
fn read_binary_file(path: String) -> Result<Response, String> {
    let data = std::fs::read(&path).map_err(|e| e.to_string())?;
    Ok(Response::new(data)) // Avoids JSON serialization overhead
}

#[tauri::command]
fn upload_file(request: tauri::ipc::Request) -> Result<(), String> {
    let tauri::ipc::InvokeBody::Raw(data) = request.body() else {
        return Err("Expected raw body".into());
    };
    std::fs::write("upload.bin", data).map_err(|e| e.to_string())
}

Frontend:

// Reading binary
const data = await invoke<ArrayBuffer>('read_binary_file', { path: '/file.bin' });

// Uploading binary
const fileData = new Uint8Array([1, 2, 3, 4]);
await invoke('upload_file', fileData);

Events

Emit from Rust to Frontend

Rust:

use tauri::Emitter;

#[tauri::command]
fn start_background_task(app: tauri::AppHandle) {
    std::thread::spawn(move || {
        for i in 0..100 {
            std::thread::sleep(std::time::Duration::from_millis(100));
            app.emit("progress", i).unwrap();
        }
        app.emit("complete", "Task finished").unwrap();
    });
}

// Emit to specific window
#[tauri::command]
fn notify_window(app: tauri::AppHandle, window_label: String, message: String) {
    app.emit_to(&window_label, "notification", message).unwrap();
}

Frontend:

import { listen, once } from '@tauri-apps/api/event';

// Listen continuously
const unlisten = await listen<number>('progress', (event) => {
    console.log(`Progress: ${event.payload}%`);
});

// Listen once
await once<string>('complete', (event) => {
    console.log(event.payload);
});

// Clean up when done
unlisten();

Emit from Frontend to Rust

Frontend:

import { emit } from '@tauri-apps/api/event';

await emit('user-action', { action: 'click', target: 'button' });

Rust (in setup or command):

use tauri::Listener;

fn setup_listeners(app: &tauri::App) {
    app.listen("user-action", |event| {
        println!("User action: {:?}", event.payload());
    });
}

Window-Specific Events

Rust:

use tauri::{Emitter, WebviewWindow};

#[tauri::command]
fn emit_to_window(window: WebviewWindow, message: String) {
    window.emit("window-message", message).unwrap();
}

Channels (Streaming)

Basic Channel Pattern

Rust:

use tauri::ipc::Channel;

#[derive(Clone, serde::Serialize)]
struct ProgressUpdate {
    current: u32,
    total: u32,
    message: String,
}

#[tauri::command]
async fn process_files(
    files: Vec<String>,
    on_progress: Channel<ProgressUpdate>
) -> Result<(), String> {
    let total = files.len() as u32;

    for (i, file) in files.iter().enumerate() {
        // Process file...
        on_progress.send(ProgressUpdate {
            current: i as u32 + 1,
            total,
            message: format!("Processing {}", file),
        }).unwrap();
    }

    Ok(())
}

Frontend:

import { invoke, Channel } from '@tauri-apps/api/core';

interface ProgressUpdate {
    current: number;
    total: number;
    message: string;
}

const channel = new Channel<ProgressUpdate>();
channel.onmessage = (update) => {
    const percent = (update.current / update.total) * 100;
    console.log(`${percent}% - ${update.message}`);
};

await invoke('process_files', {
    files: ['file1.txt', 'file2.txt'],
    onProgress: channel
});

Tagged Union Events (Discriminated)

Rust:

use tauri::ipc::Channel;

#[derive(Clone, serde::Serialize)]
#[serde(tag = "event", content = "data")]
enum DownloadEvent {
    Started { url: String, size: u64 },
    Progress { downloaded: u64, total: u64 },
    Complete { path: String },
    Error { message: String },
}

#[tauri::command]
async fn download_file(
    url: String,
    on_event: Channel<DownloadEvent>
) -> Result<String, String> {
    on_event.send(DownloadEvent::Started {
        url: url.clone(),
        size: 1000,
    }).unwrap();

    for i in 0..=100 {
        on_event.send(DownloadEvent::Progress {
            downloaded: i * 10,
            total: 1000,
        }).unwrap();
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    }

    let path = "/downloads/file.zip".to_string();
    on_event.send(DownloadEvent::Complete {
        path: path.clone(),
    }).unwrap();

    Ok(path)
}

Frontend:

import { invoke, Channel } from '@tauri-apps/api/core';

type DownloadEvent =
    | { event: 'Started'; data: { url: string; size: number } }
    | { event: 'Progress'; data: { downloaded: number; total: number } }
    | { event: 'Complete'; data: { path: string } }
    | { event: 'Error'; data: { message: string } };

const channel = new Channel<DownloadEvent>();
channel.onmessage = (msg) => {
    switch (msg.event) {
        case 'Started':
            console.log(`Starting download: ${msg.data.url} (${msg.data.size} bytes)`);
            break;
        case 'Progress':
            const percent = (msg.data.downloaded / msg.data.total) * 100;
            console.log(`Download: ${percent.toFixed(1)}%`);
            break;
        case 'Complete':
            console.log(`Downloaded to: ${msg.data.path}`);
            break;
        case 'Error':
            console.error(`Download failed: ${msg.data.message}`);
            break;
    }
};

const path = await invoke<string>('download_file', {
    url: 'https://example.com/file.zip',
    onEvent: channel
});

IPC Selection Guide

Pattern Use Case Direction Frequency
Commands Request-response, data fetching Frontend → Rust One-time
Events Notifications, state changes Bidirectional Low-medium
Channels Progress updates, streaming data Rust → Frontend High

When to Use Each

Commands (invoke)

  • Fetching data from Rust
  • Performing actions that return results
  • CRUD operations
  • Most common pattern

Events (emit/listen)

  • Notifying UI of background changes
  • Broadcasting to multiple windows
  • Fire-and-forget notifications
  • System events (window close, minimize)

Channels

  • File download/upload progress
  • Long-running operations with updates
  • Streaming log output
  • Real-time data feeds