Tauri combines a Rust backend with your choice of web frontend — React, Svelte, Vue — to produce lightweight, secure desktop apps. Unlike Electron, Tauri uses the OS’s native WebView and a Rust core, resulting in binaries 10-100x smaller with significantly lower memory usage. Claude Code generates Tauri command handlers, IPC bridge code, file system access patterns, system tray menus, and the packaging configuration for distribution.
CLAUDE.md for Tauri Projects
## Desktop Stack
- Tauri 2.x with Rust backend
- Frontend: React 18 + TypeScript + Vite
- State: Zustand for frontend, Tauri Store plugin for persistence
- Database: SQLite via tauri-plugin-sql
- Styling: Tailwind CSS
- Auto-update: tauri-plugin-updater with GitHub Releases
- Target: macOS (universal), Windows (NSIS), Linux (AppImage)
- IPC: Tauri commands (invoke), events (emit/listen)
- Security: CSP in tauri.conf.json, allowlist per-command
Tauri Command Handlers (Rust)
// src-tauri/src/commands/files.rs
use tauri::{AppHandle, Manager};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct FileInfo {
pub name: String,
pub path: String,
pub size: u64,
pub modified: u64,
pub is_dir: bool,
}
#[derive(Debug, thiserror::Error, Serialize)]
pub enum FileError {
#[error("Path not found: {0}")]
NotFound(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("IO error: {0}")]
Io(String),
}
// Tauri command: list directory contents
#[tauri::command]
pub async fn list_directory(path: String) -> Result<Vec<FileInfo>, FileError> {
let dir_path = PathBuf::from(&path);
if !dir_path.exists() {
return Err(FileError::NotFound(path));
}
let mut entries = Vec::new();
let read_dir = std::fs::read_dir(&dir_path)
.map_err(|e| FileError::Io(e.to_string()))?;
for entry in read_dir {
let entry = entry.map_err(|e| FileError::Io(e.to_string()))?;
let metadata = entry.metadata().map_err(|e| FileError::Io(e.to_string()))?;
let modified = metadata
.modified()
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs())
.unwrap_or(0);
entries.push(FileInfo {
name: entry.file_name().to_string_lossy().into_owned(),
path: entry.path().to_string_lossy().into_owned(),
size: metadata.len(),
modified,
is_dir: metadata.is_dir(),
});
}
entries.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))
});
Ok(entries)
}
#[tauri::command]
pub async fn read_file_text(path: String) -> Result<String, FileError> {
std::fs::read_to_string(&path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FileError::NotFound(path),
std::io::ErrorKind::PermissionDenied => FileError::PermissionDenied(path),
_ => FileError::Io(e.to_string()),
})
}
#[tauri::command]
pub async fn write_file_text(path: String, content: String) -> Result<(), FileError> {
std::fs::write(&path, content)
.map_err(|e| FileError::Io(e.to_string()))
}
// src-tauri/src/main.rs
mod commands;
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![
commands::files::list_directory,
commands::files::read_file_text,
commands::files::write_file_text,
commands::database::get_notes,
commands::database::create_note,
commands::database::delete_note,
])
.setup(|app| {
// Initialize database on startup
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
commands::database::initialize_db(&handle).await.unwrap();
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
SQLite with tauri-plugin-sql
// src-tauri/src/commands/database.rs
use tauri::AppHandle;
use tauri_plugin_sql::{DbPool, Migration, MigrationKind};
#[derive(Debug, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct Note {
pub id: i64,
pub title: String,
pub content: String,
pub created_at: i64,
pub updated_at: i64,
}
pub async fn initialize_db(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let db_url = format!(
"sqlite:{}/notes.db",
app.path().app_data_dir()?.to_string_lossy()
);
// Migrations run automatically on connect
let migrations = vec![
Migration {
version: 1,
description: "create_notes",
sql: "CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
); CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at DESC);",
kind: MigrationKind::Up,
},
];
let pool = DbPool::connect(&db_url, migrations).await?;
app.manage(pool);
Ok(())
}
#[tauri::command]
pub async fn get_notes(
state: tauri::State<'_, DbPool>,
search: Option<String>,
) -> Result<Vec<Note>, String> {
let pool = state.pool().await.map_err(|e| e.to_string())?;
let notes = if let Some(q) = search {
sqlx::query_as::<_, Note>(
"SELECT * FROM notes WHERE title LIKE ? OR content LIKE ? ORDER BY updated_at DESC"
)
.bind(format!("%{q}%"))
.bind(format!("%{q}%"))
.fetch_all(&pool)
.await
.map_err(|e| e.to_string())?
} else {
sqlx::query_as::<_, Note>("SELECT * FROM notes ORDER BY updated_at DESC")
.fetch_all(&pool)
.await
.map_err(|e| e.to_string())?
};
Ok(notes)
}
#[tauri::command]
pub async fn create_note(
state: tauri::State<'_, DbPool>,
title: String,
content: String,
) -> Result<Note, String> {
let pool = state.pool().await.map_err(|e| e.to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let note = sqlx::query_as::<_, Note>(
"INSERT INTO notes (title, content, created_at, updated_at) VALUES (?, ?, ?, ?) RETURNING *"
)
.bind(&title)
.bind(&content)
.bind(now)
.bind(now)
.fetch_one(&pool)
.await
.map_err(|e| e.to_string())?;
Ok(note)
}
Frontend IPC (React + TypeScript)
// src/lib/tauri.ts — typed wrappers around Tauri invoke
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
export interface FileInfo {
name: string;
path: string;
size: number;
modified: number;
isDir: boolean;
}
export interface Note {
id: number;
title: string;
content: string;
createdAt: number;
updatedAt: number;
}
// Typed invoke wrappers
export const api = {
listDirectory: (path: string) =>
invoke<FileInfo[]>('list_directory', { path }),
readFileText: (path: string) =>
invoke<string>('read_file_text', { path }),
writeFileText: (path: string, content: string) =>
invoke<void>('write_file_text', { path, content }),
getNotes: (search?: string) =>
invoke<Note[]>('get_notes', { search }),
createNote: (title: string, content: string) =>
invoke<Note>('create_note', { title, content }),
deleteNote: (id: number) =>
invoke<void>('delete_note', { id }),
};
// Listen to Rust-emitted events
export function onFileChanged(callback: (path: string) => void) {
return listen<string>('file-changed', (event) => {
callback(event.payload);
});
}
// src/components/NotesList.tsx
import { useState, useEffect } from 'react';
import { api, Note } from '../lib/tauri';
export function NotesList() {
const [notes, setNotes] = useState<Note[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadNotes = async (q?: string) => {
try {
setLoading(true);
const result = await api.getNotes(q || undefined);
setNotes(result);
setError(null);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadNotes();
}, []);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearch(q);
loadNotes(q);
};
const handleCreate = async () => {
const note = await api.createNote('New Note', '');
setNotes(prev => [note, ...prev]);
};
return (
<div className="notes-list">
<div className="toolbar">
<input
value={search}
onChange={handleSearchChange}
placeholder="Search notes..."
className="search-input"
/>
<button onClick={handleCreate}>New Note</button>
</div>
{loading && <p>Loading...</p>}
{error && <p className="error">{error}</p>}
{notes.map(note => (
<div key={note.id} className="note-item">
<h3>{note.title || 'Untitled'}</h3>
<p>{note.content.slice(0, 100)}</p>
<time>{new Date(note.updatedAt * 1000).toLocaleDateString()}</time>
</div>
))}
</div>
);
}
System Tray
// src-tauri/src/tray.rs
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{TrayIcon, TrayIconBuilder},
Manager, Runtime,
};
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<TrayIcon<R>> {
let show_i = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
let hide_i = MenuItem::with_id(app, "hide", "Hide Window", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &hide_i, &sep, &quit_i])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.menu_on_left_click(false)
.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),
_ => {}
})
.build(app)
}
Auto-Updater
// src-tauri/src/updater.rs — check for updates and prompt user
use tauri::AppHandle;
use tauri_plugin_updater::UpdaterExt;
#[tauri::command]
pub async fn check_for_updates(app: AppHandle) -> Result<Option<String>, String> {
let updater = app.updater().map_err(|e| e.to_string())?;
match updater.check().await {
Ok(Some(update)) => {
let version = update.version.clone();
// Emit progress events during download
update.download_and_install(
|chunk_length, content_length| {
let _ = app.emit("update-progress", serde_json::json!({
"downloaded": chunk_length,
"total": content_length,
}));
},
|| {
let _ = app.emit("update-installed", ());
},
).await.map_err(|e| e.to_string())?;
Ok(Some(version))
}
Ok(None) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
// src-tauri/tauri.conf.json (relevant sections)
{
"bundle": {
"active": true,
"category": "Productivity",
"targets": ["nsis", "msi", "dmg", "app", "appimage", "deb"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.icns", "icons/icon.ico"]
},
"plugins": {
"updater": {
"endpoints": [
"https://releases.myapp.com/{{target}}/{{arch}}/{{current_version}}"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6..."
}
},
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
}
}
For the Rust async patterns powering Tauri commands, see the Rust async guide for tokio runtime and async/await patterns. For the React frontend that renders in Tauri’s WebView, the React hooks guide covers state management patterns that work identically in Tauri. The Claude Skills 360 bundle includes Tauri skill sets covering command handlers, IPC bridge patterns, and packaging configuration. Start with the free tier to try Tauri command generation.