tauri-app-dev
Tauri 2.0 App Development
Tauri is a framework for building small, fast, secure desktop apps using web frontends and Rust backends.
Architecture Overview
┌─────────────────────────────────────────┐
│ Frontend (Webview) │
│ HTML/CSS/JS • React/Vue/Svelte │
└────────────────┬────────────────────────┘
│ IPC (invoke/events)
┌────────────────▼────────────────────────┐
│ Tauri Core (Rust) │
│ Commands • State • Plugins • Events │
└────────────────┬────────────────────────┘
│ TAO (windows) + WRY (webview)
┌────────────────▼────────────────────────┐
│ Operating System │
│ macOS • Windows • Linux • Mobile │
└─────────────────────────────────────────┘
Project Structure
my-app/
├── src/ # Frontend source
├── src-tauri/
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Tauri configuration
│ ├── capabilities/ # Security permissions (v2)
│ │ └── default.json
│ ├── src/
│ │ ├── main.rs # Desktop entry point
│ │ └── lib.rs # Main app logic + mobile entry
│ └── icons/
└── package.json
Commands (Frontend → Rust)
Define commands in Rust with #[tauri::command]:
// src-tauri/src/lib.rs
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
#[tauri::command]
async fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet, read_file])
.run(tauri::generate_context!())
.expect("error running app");
}
Call from frontend (direct):
import { invoke } from '@tauri-apps/api/core';
const greeting = await invoke<string>('greet', { name: 'World' });
const content = await invoke<string>('read_file', { path: '/tmp/test.txt' });
Project convention: Wrap invoke() with TanStack Query for caching and state management:
import { useQuery, useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
// Query (read operations)
const { data: content } = useQuery({
queryKey: ['file', path],
queryFn: () => invoke<string>('read_file', { path }),
});
// Mutation (write operations)
const { mutate: saveFile } = useMutation({
mutationFn: (content: string) => invoke('write_file', { path, content }),
});
Key rules:
- Arguments must implement
serde::Deserialize - Return types must implement
serde::Serialize - Use
Result<T, E>for fallible operations - Async commands run on thread pool (non-blocking)
- Snake_case in Rust → camelCase in JS arguments
State Management
Share state across commands:
use std::sync::Mutex;
use tauri::State;
struct AppState {
counter: Mutex<i32>,
db: Mutex<Option<Database>>,
}
#[tauri::command]
fn increment(state: State<'_, AppState>) -> i32 {
let mut counter = state.counter.lock().unwrap();
*counter += 1;
*counter
}
pub fn run() {
tauri::Builder::default()
.manage(AppState {
counter: Mutex::new(0),
db: Mutex::new(None),
})
.invoke_handler(tauri::generate_handler![increment])
.run(tauri::generate_context!())
.expect("error running app");
}
Access via AppHandle (for background threads):
use tauri::Manager;
#[tauri::command]
async fn background_task(app: tauri::AppHandle) {
let state = app.state::<AppState>();
// use state...
}
Events (Rust → Frontend)
Emit events from Rust:
use tauri::Emitter;
#[tauri::command]
fn start_process(app: tauri::AppHandle) {
std::thread::spawn(move || {
for i in 0..100 {
app.emit("progress", i).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
}
app.emit("complete", "Done!").unwrap();
});
}
Listen in frontend:
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen<number>('progress', (event) => {
console.log(`Progress: ${event.payload}%`);
});
// Clean up when done
unlisten();
Essential Plugins
Install plugins: cargo add <plugin> in src-tauri, pnpm add <package> in frontend.
| Plugin | Cargo Crate | NPM Package | Purpose |
|---|---|---|---|
| File System | tauri-plugin-fs |
@tauri-apps/plugin-fs |
Read/write files |
| Dialog | tauri-plugin-dialog |
@tauri-apps/plugin-dialog |
Open/save dialogs |
| Clipboard | tauri-plugin-clipboard-manager |
@tauri-apps/plugin-clipboard-manager |
Copy/paste |
| Shell | tauri-plugin-shell |
@tauri-apps/plugin-shell |
Run external commands |
| Store | tauri-plugin-store |
@tauri-apps/plugin-store |
Key-value persistence |
| Updater | tauri-plugin-updater |
@tauri-apps/plugin-updater |
Auto-updates |
Register in Rust:
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.run(tauri::generate_context!())
.expect("error running app");
}
Security: Capabilities & Permissions
Tauri 2.0 uses capabilities (in src-tauri/capabilities/) to control what APIs each window can access.
src-tauri/capabilities/default.json:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
"fs:allow-read-text-file",
"dialog:default",
{
"identifier": "fs:scope",
"allow": [{ "path": "$APPDATA/**" }, { "path": "$DOCUMENT/**" }]
}
]
}
Scope variables: $APPDATA, $APPCONFIG, $DOCUMENT, $DOWNLOAD, $HOME, $TEMP, etc.
File Operations (Editor Pattern)
import { open, save } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
// Open file dialog
const path = await open({
filters: [{ name: 'Markdown', extensions: ['md'] }],
multiple: false,
});
if (path) {
const content = await readTextFile(path);
// Edit content...
await writeTextFile(path, modifiedContent);
}
// Save as dialog
const savePath = await save({
filters: [{ name: 'Markdown', extensions: ['md'] }],
defaultPath: 'untitled.md',
});
if (savePath) {
await writeTextFile(savePath, content);
}
Window Management
Create windows at runtime:
use tauri::{WebviewUrl, WebviewWindowBuilder};
#[tauri::command]
async fn open_settings(app: tauri::AppHandle) -> Result<(), String> {
WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App("settings.html".into()))
.title("Settings")
.inner_size(600.0, 400.0)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
Configure in tauri.conf.json:
{
"app": {
"windows": [
{
"label": "main",
"title": "My App",
"width": 1200,
"height": 800,
"decorations": true,
"resizable": true
}
]
}
}
Custom Titlebar
Set decorations: false in config, then:
<div data-tauri-drag-region class="titlebar">
<span>My App</span>
<button id="minimize">−</button>
<button id="maximize">□</button>
<button id="close">×</button>
</div>
import { getCurrentWindow } from '@tauri-apps/api/window';
const appWindow = getCurrentWindow();
document.getElementById('minimize')?.addEventListener('click', () => appWindow.minimize());
document.getElementById('maximize')?.addEventListener('click', () => appWindow.toggleMaximize());
document.getElementById('close')?.addEventListener('click', () => appWindow.close());
Building & Distribution
# Development
pnpm tauri dev
# Production build
pnpm tauri build
# Build specific targets
pnpm tauri build --target universal-apple-darwin # macOS universal
pnpm tauri build --bundles deb,appimage # Linux only
pnpm tauri build --bundles nsis # Windows NSIS
Output locations:
- macOS:
target/release/bundle/macos/*.app,*.dmg - Windows:
target/release/bundle/nsis/*-setup.exe,msi/*.msi - Linux:
target/release/bundle/deb/*.deb,appimage/*.AppImage
Quick Reference
| Task | Resource |
|---|---|
| Commands, IPC, channels | See references/commands-and-ipc.md |
| Plugin usage & development | See references/plugins.md |
| Security configuration | See references/security.md |
| Bundling & distribution | See references/bundling.md |
| Common app patterns | See references/patterns.md |
Test-Driven Development (TDD)
CRITICAL: Always follow TDD - write tests BEFORE implementation.
TDD Workflow
1. RED → Write failing test first
2. GREEN → Write minimal code to pass
3. REFACTOR → Clean up, keep tests green
Testing Stack
| Layer | Tool | Purpose |
|---|---|---|
| Rust Unit | cargo test |
Test commands, business logic |
| React Unit | Vitest | Test components, hooks, stores |
| Integration | Vitest + MSW | Test frontend with mocked IPC |
| E2E | Tauri MCP | Test running app (NOT Chrome DevTools) |
E2E Testing with Tauri MCP
IMPORTANT: Always use tauri_* MCP tools for testing the running app. Do NOT use chrome-devtools MCP - it's for browser pages only.
// Tauri MCP workflow for E2E tests:
// 1. Start session (connect to running Tauri app)
tauri_driver_session({ action: 'start', port: 9223 })
// 2. Take snapshot (get DOM state)
tauri_webview_screenshot()
tauri_webview_find_element({ selector: '.editor-content' })
// 3. Interact with app
tauri_webview_interact({ action: 'click', selector: '#save-button' })
tauri_webview_keyboard({ action: 'type', selector: 'input', text: 'hello' })
// 4. Wait for results
tauri_webview_wait_for({ type: 'selector', value: '.success-toast' })
// 5. Verify IPC calls
tauri_ipc_monitor({ action: 'start' })
tauri_ipc_get_captured({ filter: 'save_file' })
// 6. Check backend state
tauri_ipc_execute_command({ command: 'get_app_state' })
// 7. Read logs for debugging
tauri_read_logs({ source: 'console', lines: 50 })
Rust Unit Tests
// src-tauri/src/lib.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_greet() {
let result = greet("World".to_string());
assert_eq!(result, "Hello, World!");
}
#[test]
fn test_parse_markdown() {
let input = "# Hello";
let result = parse_markdown(input);
assert!(result.is_ok());
assert_eq!(result.unwrap().title, "Hello");
}
#[tokio::test]
async fn test_async_command() {
let result = read_file("/tmp/test.txt".to_string()).await;
// Test with temp files or mocks
}
}
Run: cd src-tauri && cargo test
React Component Tests (Vitest)
// src/components/Editor.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Editor } from './Editor'
// Mock Tauri invoke
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn()
}))
describe('Editor', () => {
it('should render editor content', () => {
render(<Editor initialValue="# Hello" />)
expect(screen.getByText('Hello')).toBeInTheDocument()
})
it('should call save on Ctrl+S', async () => {
const { invoke } = await import('@tauri-apps/api/core')
render(<Editor initialValue="test" />)
await userEvent.keyboard('{Control>}s{/Control}')
expect(invoke).toHaveBeenCalledWith('save_file', expect.any(Object))
})
})
Zustand Store Tests
// src/stores/editorStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useEditorStore } from './editorStore'
describe('editorStore', () => {
beforeEach(() => {
// Reset store before each test
useEditorStore.setState({
content: '',
isDirty: false,
filePath: null
})
})
it('should update content and mark dirty', () => {
const { setContent } = useEditorStore.getState()
setContent('new content')
const state = useEditorStore.getState()
expect(state.content).toBe('new content')
expect(state.isDirty).toBe(true)
})
it('should clear dirty flag after save', () => {
useEditorStore.setState({ isDirty: true })
const { markSaved } = useEditorStore.getState()
markSaved()
expect(useEditorStore.getState().isDirty).toBe(false)
})
})
Integration Tests with Mocked IPC
// src/features/file/useFileOperations.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useFileOperations } from './useFileOperations'
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn()
}))
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: vi.fn(),
save: vi.fn()
}))
describe('useFileOperations', () => {
let queryClient: QueryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
vi.clearAllMocks()
})
it('should open file and load content', async () => {
const { invoke } = await import('@tauri-apps/api/core')
const { open } = await import('@tauri-apps/plugin-dialog')
vi.mocked(open).mockResolvedValue('/path/to/file.md')
vi.mocked(invoke).mockResolvedValue('# File Content')
const { result } = renderHook(() => useFileOperations(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
})
await result.current.openFile()
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('read_file', { path: '/path/to/file.md' })
})
})
})
TDD Example: Adding a New Feature
// Step 1: RED - Write failing test first
// src/features/wordcount/useWordCount.test.ts
describe('useWordCount', () => {
it('should count words in content', () => {
const { result } = renderHook(() => useWordCount('hello world'))
expect(result.current.words).toBe(2)
})
it('should handle empty content', () => {
const { result } = renderHook(() => useWordCount(''))
expect(result.current.words).toBe(0)
})
it('should count characters', () => {
const { result } = renderHook(() => useWordCount('hello'))
expect(result.current.characters).toBe(5)
})
})
// Step 2: GREEN - Minimal implementation
// src/features/wordcount/useWordCount.ts
export function useWordCount(content: string) {
return {
words: content.trim() ? content.trim().split(/\s+/).length : 0,
characters: content.length
}
}
// Step 3: REFACTOR - Add memoization, types, etc.
export function useWordCount(content: string): WordCountResult {
return useMemo(() => ({
words: content.trim() ? content.trim().split(/\s+/).length : 0,
characters: content.length,
charactersNoSpaces: content.replace(/\s/g, '').length
}), [content])
}
Running Tests
# All tests
pnpm test
# Watch mode
pnpm test:watch
# Coverage
pnpm test:coverage
# Rust tests only
cd src-tauri && cargo test
# Type check + lint + test
pnpm check:all
Debugging Tips
- DevTools: Right-click → Inspect, or
Cmd+Option+I(macOS) /Ctrl+Shift+I(Windows/Linux) - Rust logs: Use
logcrate +tauri-plugin-logorprintln!(visible in terminal) - Check capabilities: "Not allowed" errors mean missing permissions in capabilities
- IPC errors: Ensure argument names match (snake_case Rust → camelCase JS)
- E2E debugging: Use
tauri_read_logs({ source: 'console' })to see webview console
Related Skills
tauri-v2-integration— VMark-specific Tauri IPC patterns (invoke/emit bridges, menu accelerators)tauri-mcp-testing— E2E testing of the running Tauri app via MCP toolsrust-tauri-backend— VMark Rust backend (commands, menu items, filesystem)