Skip to main content
Technology & EngineeringTauri594 lines

Tauri Patterns

Common Tauri 2.0 patterns: system tray apps, menu bar apps, file handling, SQLite database integration, IPC communication patterns, background tasks, and single-instance enforcement.

Quick Summary26 lines
You are an expert in Tauri 2.0 application patterns, covering system tray integration, menu construction, file handling, database access, IPC design, background processing, and single-instance enforcement.

## Key Points

- **Storing database connections without Mutex**: SQLite connections are not thread-safe. Always wrap them in `Mutex<Connection>` when shared via Tauri state.
- **Granting blanket file system permissions**: Use scoped permissions in your capability files. Only allow access to the directories your app actually needs (app data, documents, etc.).
- **Using `unwrap()` in command handlers**: Commands should return `Result<T, String>` and use `map_err` to convert errors. Panicking in a command crashes the backend process.
- **Polling from the frontend instead of using events**: Do not use `setInterval` in JavaScript to check for updates. Use Rust events (`app.emit()`) to push data to the frontend when it changes.
- **Ignoring the single-instance plugin for document-based apps**: Without it, double-clicking a file associated with your app opens a new instance each time, which is unexpected for users.

## Quick Example

```toml
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
```

```toml
# Cargo.toml
[dependencies]
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
```
skilldb get tauri-skills/Tauri PatternsFull skill: 594 lines
Paste into your CLAUDE.md or agent config

Tauri Patterns — Common Desktop Application Architectures

You are an expert in Tauri 2.0 application patterns, covering system tray integration, menu construction, file handling, database access, IPC design, background processing, and single-instance enforcement.

System Tray Apps

System tray (notification area) apps run in the background with an icon in the system tray. They may or may not have a visible window.

Cargo.toml

[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"

Capability

// src-tauri/capabilities/tray.json
{
  "identifier": "tray-capability",
  "windows": ["main"],
  "permissions": [
    "core:tray:default",
    "core:tray:allow-set-icon",
    "core:tray:allow-set-tooltip",
    "core:menu:default"
  ]
}

Rust Setup

use tauri::{
    menu::{Menu, MenuItem},
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager,
};

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            // Build the tray menu
            let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
            let hide = MenuItem::with_id(app, "hide", "Hide Window", true, None::<&str>)?;
            let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
            let menu = Menu::with_items(app, &[&show, &hide, &quit])?;

            // Create the tray icon
            TrayIconBuilder::new()
                .icon(app.default_window_icon().unwrap().clone())
                .tooltip("My App")
                .menu(&menu)
                .on_menu_event(|app, event| match event.id.as_ref() {
                    "show" => {
                        if let Some(window) = app.get_webview_window("main") {
                            let _ = window.show();
                            let _ = window.set_focus();
                        }
                    }
                    "hide" => {
                        if let Some(window) = app.get_webview_window("main") {
                            let _ = window.hide();
                        }
                    }
                    "quit" => {
                        app.exit(0);
                    }
                    _ => {}
                })
                .on_tray_icon_event(|tray, event| {
                    // Show window on left click
                    if let TrayIconEvent::Click {
                        button: MouseButton::Left,
                        button_state: MouseButtonState::Up,
                        ..
                    } = event
                    {
                        let app = tray.app_handle();
                        if let Some(window) = app.get_webview_window("main") {
                            let _ = window.show();
                            let _ = window.set_focus();
                        }
                    }
                })
                .build(app)?;

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running application");
}

Hide to Tray on Close

use tauri::Manager;

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            let window = app.get_webview_window("main").unwrap();
            // Intercept the close event -- hide instead of quitting
            window.on_window_event(move |event| {
                if let tauri::WindowEvent::CloseRequested { api, .. } = event {
                    api.prevent_close();
                    // Hide the window instead of closing
                    // The app stays running in the tray
                }
            });
            Ok(())
        })
        // ... tray setup as above
        .run(tauri::generate_context!())
        .expect("error");
}

Menu Bar Apps

Application menus (File, Edit, View, etc.) for desktop apps:

use tauri::menu::{Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder};

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            let file_menu = SubmenuBuilder::new(app, "File")
                .item(&MenuItemBuilder::with_id("new", "New").accelerator("CmdOrCtrl+N").build(app)?)
                .item(&MenuItemBuilder::with_id("open", "Open...").accelerator("CmdOrCtrl+O").build(app)?)
                .item(&MenuItemBuilder::with_id("save", "Save").accelerator("CmdOrCtrl+S").build(app)?)
                .separator()
                .item(&MenuItemBuilder::with_id("quit", "Quit").accelerator("CmdOrCtrl+Q").build(app)?)
                .build(app)?;

            let edit_menu = SubmenuBuilder::new(app, "Edit")
                .undo()
                .redo()
                .separator()
                .cut()
                .copy()
                .paste()
                .select_all()
                .build(app)?;

            let menu = MenuBuilder::new(app)
                .item(&file_menu)
                .item(&edit_menu)
                .build(app)?;

            app.set_menu(menu)?;

            app.on_menu_event(|app, event| {
                match event.id().as_ref() {
                    "new" => { /* handle new file */ }
                    "open" => { /* handle open */ }
                    "save" => { /* handle save */ }
                    "quit" => app.exit(0),
                    _ => {}
                }
            });

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error");
}

File Handling

File Dialog and Read/Write

# Cargo.toml
[dependencies]
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
// capabilities/files.json
{
  "identifier": "file-capability",
  "windows": ["main"],
  "permissions": [
    "dialog:default",
    "dialog:allow-open",
    "dialog:allow-save",
    "fs:default",
    "fs:allow-read",
    "fs:allow-write"
  ]
}
use std::fs;
use tauri::command;

#[command]
fn read_file(path: String) -> Result<String, String> {
    fs::read_to_string(&path).map_err(|e| e.to_string())
}

#[command]
fn write_file(path: String, content: String) -> Result<(), String> {
    fs::write(&path, content).map_err(|e| e.to_string())
}
// Frontend
import { open, save } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";

async function openFile() {
  const path = await open({
    filters: [
      { name: "Text", extensions: ["txt", "md"] },
      { name: "All Files", extensions: ["*"] },
    ],
    multiple: false,
  });

  if (path) {
    const content = await invoke<string>("read_file", { path });
    editor.setValue(content);
  }
}

async function saveFile() {
  const path = await save({
    filters: [{ name: "Text", extensions: ["txt"] }],
    defaultPath: "untitled.txt",
  });

  if (path) {
    const content = editor.getValue();
    await invoke("write_file", { path, content });
  }
}

Drag and Drop

use tauri::Manager;

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            let window = app.get_webview_window("main").unwrap();
            window.on_drag_drop_event(|window, event| {
                match event {
                    tauri::DragDropEvent::Drop { paths, position } => {
                        for path in paths {
                            println!("Dropped file: {:?}", path);
                            window.emit("file-dropped", path.to_string_lossy().to_string()).unwrap();
                        }
                    }
                    _ => {}
                }
            });
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error");
}

SQLite Database

# Cargo.toml
[dependencies]
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

Using the SQL Plugin (Frontend-Driven)

import Database from "@tauri-apps/plugin-sql";

// Open or create database (stored in app data directory)
const db = await Database.load("sqlite:app.db");

// Create tables
await db.execute(`
  CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    content TEXT DEFAULT '',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )
`);

// Insert
const result = await db.execute(
  "INSERT INTO notes (title, content) VALUES ($1, $2)",
  ["My Note", "Note content here"]
);
console.log("Inserted ID:", result.lastInsertId);

// Query
const notes = await db.select<Array<{
  id: number;
  title: string;
  content: string;
  created_at: string;
}>>("SELECT * FROM notes ORDER BY updated_at DESC");

// Update
await db.execute(
  "UPDATE notes SET content = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2",
  ["Updated content", 1]
);

// Delete
await db.execute("DELETE FROM notes WHERE id = $1", [1]);

Using Rusqlite (Rust-Driven)

# Cargo.toml
[dependencies]
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::State;

#[derive(Debug, Serialize, Deserialize)]
struct Note {
    id: i64,
    title: String,
    content: String,
}

struct AppState {
    db: Mutex<Connection>,
}

#[tauri::command]
fn get_notes(state: State<AppState>) -> Result<Vec<Note>, String> {
    let db = state.db.lock().map_err(|e| e.to_string())?;
    let mut stmt = db.prepare("SELECT id, title, content FROM notes")
        .map_err(|e| e.to_string())?;

    let notes = stmt.query_map([], |row| {
        Ok(Note {
            id: row.get(0)?,
            title: row.get(1)?,
            content: row.get(2)?,
        })
    })
    .map_err(|e| e.to_string())?
    .collect::<Result<Vec<_>, _>>()
    .map_err(|e| e.to_string())?;

    Ok(notes)
}

fn main() {
    let db = Connection::open("app.db").expect("Failed to open database");
    db.execute_batch(
        "CREATE TABLE IF NOT EXISTS notes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            content TEXT DEFAULT ''
        )"
    ).expect("Failed to create table");

    tauri::Builder::default()
        .manage(AppState { db: Mutex::new(db) })
        .invoke_handler(tauri::generate_handler![get_notes])
        .run(tauri::generate_context!())
        .expect("error");
}

IPC Patterns

Command Pattern (Frontend Calls Rust)

#[derive(serde::Serialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}

#[tauri::command]
async fn fetch_user(id: u32) -> Result<User, String> {
    // Simulate async work
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    Ok(User {
        id,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    })
}

// Register commands
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![fetch_user])
// Frontend
import { invoke } from "@tauri-apps/api/core";

interface User {
  id: number;
  name: string;
  email: string;
}

const user = await invoke<User>("fetch_user", { id: 1 });

Event Pattern (Rust Pushes to Frontend)

use tauri::Emitter;

#[tauri::command]
async fn start_processing(app: tauri::AppHandle) -> Result<(), String> {
    // Spawn a background task that emits progress events
    tauri::async_runtime::spawn(async move {
        for i in 0..=100 {
            app.emit("progress", i).unwrap();
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        }
        app.emit("complete", "Done!").unwrap();
    });
    Ok(())
}
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";

const unlisten = await listen<number>("progress", (event) => {
  progressBar.value = event.payload;
});

const unlistenComplete = await listen<string>("complete", (event) => {
  console.log(event.payload);
  unlisten(); // clean up
  unlistenComplete();
});

await invoke("start_processing");

Channel Pattern (Streaming Data)

use tauri::ipc::Channel;

#[tauri::command]
fn stream_logs(channel: Channel<String>) -> Result<(), String> {
    std::thread::spawn(move || {
        for line in read_log_lines() {
            channel.send(line).unwrap();
        }
    });
    Ok(())
}

Background Tasks

Long-Running Tasks with Tokio

use std::sync::Arc;
use tokio::sync::Mutex;
use tauri::{State, Emitter};

struct TaskManager {
    running: Arc<Mutex<bool>>,
}

#[tauri::command]
async fn start_sync(
    app: tauri::AppHandle,
    state: State<'_, TaskManager>,
) -> Result<(), String> {
    let running = state.running.clone();

    // Check if already running
    let mut is_running = running.lock().await;
    if *is_running {
        return Err("Sync already in progress".to_string());
    }
    *is_running = true;
    drop(is_running);

    tauri::async_runtime::spawn(async move {
        loop {
            let should_run = *running.lock().await;
            if !should_run {
                break;
            }

            // Do sync work
            match perform_sync().await {
                Ok(count) => {
                    app.emit("sync-progress", count).unwrap();
                }
                Err(e) => {
                    app.emit("sync-error", e.to_string()).unwrap();
                }
            }

            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
        }
    });

    Ok(())
}

#[tauri::command]
async fn stop_sync(state: State<'_, TaskManager>) -> Result<(), String> {
    let mut running = state.running.lock().await;
    *running = false;
    Ok(())
}

Single-Instance Enforcement

Prevent multiple instances of your app from running simultaneously:

# Cargo.toml
[dependencies]
tauri-plugin-single-instance = "2"
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
            // This closure runs when a second instance is launched
            // Focus the existing window instead
            if let Some(window) = app.get_webview_window("main") {
                let _ = window.show();
                let _ = window.set_focus();
            }

            // You can also handle the args from the second instance
            // e.g., open a file that was double-clicked
            if args.len() > 1 {
                if let Some(window) = app.get_webview_window("main") {
                    let _ = window.emit("open-file", &args[1]);
                }
            }
        }))
        .run(tauri::generate_context!())
        .expect("error");
}

Anti-Patterns

  • Performing heavy computation in IPC commands synchronously: Use async commands with tauri::async_runtime::spawn for CPU-intensive work. Synchronous commands block the IPC thread and freeze the UI.
  • Storing database connections without Mutex: SQLite connections are not thread-safe. Always wrap them in Mutex<Connection> when shared via Tauri state.
  • Emitting events without cleanup on the frontend: Every listen() call returns an unlisten function. Failing to call it causes memory leaks, especially in SPA frameworks where components mount and unmount.
  • Granting blanket file system permissions: Use scoped permissions in your capability files. Only allow access to the directories your app actually needs (app data, documents, etc.).
  • Using unwrap() in command handlers: Commands should return Result<T, String> and use map_err to convert errors. Panicking in a command crashes the backend process.
  • Polling from the frontend instead of using events: Do not use setInterval in JavaScript to check for updates. Use Rust events (app.emit()) to push data to the frontend when it changes.
  • Ignoring the single-instance plugin for document-based apps: Without it, double-clicking a file associated with your app opens a new instance each time, which is unexpected for users.

Install this skill directly: skilldb add tauri-skills

Get CLI access →