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]:
rust
1// src-tauri/src/lib.rs
2#[tauri::command]
3fn greet(name: String) -> String {
4 format!("Hello, {}!", name)
5}
6
7#[tauri::command]
8async fn read_file(path: String) -> Result<String, String> {
9 std::fs::read_to_string(&path).map_err(|e| e.to_string())
10}
11
12pub fn run() {
13 tauri::Builder::default()
14 .invoke_handler(tauri::generate_handler![greet, read_file])
15 .run(tauri::generate_context!())
16 .expect("error running app");
17}
Call from frontend (direct):
typescript
1import { invoke } from '@tauri-apps/api/core';
2
3const greeting = await invoke<string>('greet', { name: 'World' });
4const content = await invoke<string>('read_file', { path: '/tmp/test.txt' });
Project convention: Wrap invoke() with TanStack Query for caching and state management:
typescript
1import { useQuery, useMutation } from '@tanstack/react-query';
2import { invoke } from '@tauri-apps/api/core';
3
4// Query (read operations)
5const { data: content } = useQuery({
6 queryKey: ['file', path],
7 queryFn: () => invoke<string>('read_file', { path }),
8});
9
10// Mutation (write operations)
11const { mutate: saveFile } = useMutation({
12 mutationFn: (content: string) => invoke('write_file', { path, content }),
13});
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:
rust
1use std::sync::Mutex;
2use tauri::State;
3
4struct AppState {
5 counter: Mutex<i32>,
6 db: Mutex<Option<Database>>,
7}
8
9#[tauri::command]
10fn increment(state: State<'_, AppState>) -> i32 {
11 let mut counter = state.counter.lock().unwrap();
12 *counter += 1;
13 *counter
14}
15
16pub fn run() {
17 tauri::Builder::default()
18 .manage(AppState {
19 counter: Mutex::new(0),
20 db: Mutex::new(None),
21 })
22 .invoke_handler(tauri::generate_handler![increment])
23 .run(tauri::generate_context!())
24 .expect("error running app");
25}
Access via AppHandle (for background threads):
rust
1use tauri::Manager;
2
3#[tauri::command]
4async fn background_task(app: tauri::AppHandle) {
5 let state = app.state::<AppState>();
6 // use state...
7}
Events (Rust → Frontend)
Emit events from Rust:
rust
1use tauri::Emitter;
2
3#[tauri::command]
4fn start_process(app: tauri::AppHandle) {
5 std::thread::spawn(move || {
6 for i in 0..100 {
7 app.emit("progress", i).unwrap();
8 std::thread::sleep(std::time::Duration::from_millis(50));
9 }
10 app.emit("complete", "Done!").unwrap();
11 });
12}
Listen in frontend:
typescript
1import { listen } from '@tauri-apps/api/event';
2
3const unlisten = await listen<number>('progress', (event) => {
4 console.log(`Progress: ${event.payload}%`);
5});
6
7// Clean up when done
8unlisten();
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:
rust
1pub fn run() {
2 tauri::Builder::default()
3 .plugin(tauri_plugin_fs::init())
4 .plugin(tauri_plugin_dialog::init())
5 .plugin(tauri_plugin_clipboard_manager::init())
6 .run(tauri::generate_context!())
7 .expect("error running app");
8}
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:
json
1{
2 "$schema": "../gen/schemas/desktop-schema.json",
3 "identifier": "main-capability",
4 "windows": ["main"],
5 "permissions": [
6 "core:default",
7 "fs:default",
8 "fs:allow-read-text-file",
9 "dialog:default",
10 {
11 "identifier": "fs:scope",
12 "allow": [{ "path": "$APPDATA/**" }, { "path": "$DOCUMENT/**" }]
13 }
14 ]
15}
Scope variables: $APPDATA, $APPCONFIG, $DOCUMENT, $DOWNLOAD, $HOME, $TEMP, etc.
File Operations (Editor Pattern)
typescript
1import { open, save } from '@tauri-apps/plugin-dialog';
2import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
3
4// Open file dialog
5const path = await open({
6 filters: [{ name: 'Markdown', extensions: ['md'] }],
7 multiple: false,
8});
9
10if (path) {
11 const content = await readTextFile(path);
12 // Edit content...
13 await writeTextFile(path, modifiedContent);
14}
15
16// Save as dialog
17const savePath = await save({
18 filters: [{ name: 'Markdown', extensions: ['md'] }],
19 defaultPath: 'untitled.md',
20});
21
22if (savePath) {
23 await writeTextFile(savePath, content);
24}
Window Management
Create windows at runtime:
rust
1use tauri::{WebviewUrl, WebviewWindowBuilder};
2
3#[tauri::command]
4async fn open_settings(app: tauri::AppHandle) -> Result<(), String> {
5 WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App("settings.html".into()))
6 .title("Settings")
7 .inner_size(600.0, 400.0)
8 .build()
9 .map_err(|e| e.to_string())?;
10 Ok(())
11}
Configure in tauri.conf.json:
json
1{
2 "app": {
3 "windows": [
4 {
5 "label": "main",
6 "title": "My App",
7 "width": 1200,
8 "height": 800,
9 "decorations": true,
10 "resizable": true
11 }
12 ]
13 }
14}
Custom Titlebar
Set decorations: false in config, then:
html
1<div data-tauri-drag-region class="titlebar">
2 <span>My App</span>
3 <button id="minimize">−</button>
4 <button id="maximize">□</button>
5 <button id="close">×</button>
6</div>
typescript
1import { getCurrentWindow } from '@tauri-apps/api/window';
2
3const appWindow = getCurrentWindow();
4document.getElementById('minimize')?.addEventListener('click', () => appWindow.minimize());
5document.getElementById('maximize')?.addEventListener('click', () => appWindow.toggleMaximize());
6document.getElementById('close')?.addEventListener('click', () => appWindow.close());
Building & Distribution
bash
1# Development
2pnpm tauri dev
3
4# Production build
5pnpm tauri build
6
7# Build specific targets
8pnpm tauri build --target universal-apple-darwin # macOS universal
9pnpm tauri build --bundles deb,appimage # Linux only
10pnpm 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
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.
typescript
1// Tauri MCP workflow for E2E tests:
2
3// 1. Start session (connect to running Tauri app)
4tauri_driver_session({ action: 'start', port: 9223 })
5
6// 2. Take snapshot (get DOM state)
7tauri_webview_screenshot()
8tauri_webview_find_element({ selector: '.editor-content' })
9
10// 3. Interact with app
11tauri_webview_interact({ action: 'click', selector: '#save-button' })
12tauri_webview_keyboard({ action: 'type', selector: 'input', text: 'hello' })
13
14// 4. Wait for results
15tauri_webview_wait_for({ type: 'selector', value: '.success-toast' })
16
17// 5. Verify IPC calls
18tauri_ipc_monitor({ action: 'start' })
19tauri_ipc_get_captured({ filter: 'save_file' })
20
21// 6. Check backend state
22tauri_ipc_execute_command({ command: 'get_app_state' })
23
24// 7. Read logs for debugging
25tauri_read_logs({ source: 'console', lines: 50 })
Rust Unit Tests
rust
1// src-tauri/src/lib.rs
2
3#[cfg(test)]
4mod tests {
5 use super::*;
6
7 #[test]
8 fn test_greet() {
9 let result = greet("World".to_string());
10 assert_eq!(result, "Hello, World!");
11 }
12
13 #[test]
14 fn test_parse_markdown() {
15 let input = "# Hello";
16 let result = parse_markdown(input);
17 assert!(result.is_ok());
18 assert_eq!(result.unwrap().title, "Hello");
19 }
20
21 #[tokio::test]
22 async fn test_async_command() {
23 let result = read_file("/tmp/test.txt".to_string()).await;
24 // Test with temp files or mocks
25 }
26}
Run: cd src-tauri && cargo test
React Component Tests (Vitest)
typescript
1// src/components/Editor.test.tsx
2import { describe, it, expect, vi } from 'vitest'
3import { render, screen } from '@testing-library/react'
4import userEvent from '@testing-library/user-event'
5import { Editor } from './Editor'
6
7// Mock Tauri invoke
8vi.mock('@tauri-apps/api/core', () => ({
9 invoke: vi.fn()
10}))
11
12describe('Editor', () => {
13 it('should render editor content', () => {
14 render(<Editor initialValue="# Hello" />)
15 expect(screen.getByText('Hello')).toBeInTheDocument()
16 })
17
18 it('should call save on Ctrl+S', async () => {
19 const { invoke } = await import('@tauri-apps/api/core')
20 render(<Editor initialValue="test" />)
21
22 await userEvent.keyboard('{Control>}s{/Control}')
23
24 expect(invoke).toHaveBeenCalledWith('save_file', expect.any(Object))
25 })
26})
Zustand Store Tests
typescript
1// src/stores/editorStore.test.ts
2import { describe, it, expect, beforeEach } from 'vitest'
3import { useEditorStore } from './editorStore'
4
5describe('editorStore', () => {
6 beforeEach(() => {
7 // Reset store before each test
8 useEditorStore.setState({
9 content: '',
10 isDirty: false,
11 filePath: null
12 })
13 })
14
15 it('should update content and mark dirty', () => {
16 const { setContent } = useEditorStore.getState()
17
18 setContent('new content')
19
20 const state = useEditorStore.getState()
21 expect(state.content).toBe('new content')
22 expect(state.isDirty).toBe(true)
23 })
24
25 it('should clear dirty flag after save', () => {
26 useEditorStore.setState({ isDirty: true })
27 const { markSaved } = useEditorStore.getState()
28
29 markSaved()
30
31 expect(useEditorStore.getState().isDirty).toBe(false)
32 })
33})
Integration Tests with Mocked IPC
typescript
1// src/features/file/useFileOperations.test.ts
2import { describe, it, expect, vi, beforeEach } from 'vitest'
3import { renderHook, waitFor } from '@testing-library/react'
4import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5import { useFileOperations } from './useFileOperations'
6
7vi.mock('@tauri-apps/api/core', () => ({
8 invoke: vi.fn()
9}))
10
11vi.mock('@tauri-apps/plugin-dialog', () => ({
12 open: vi.fn(),
13 save: vi.fn()
14}))
15
16describe('useFileOperations', () => {
17 let queryClient: QueryClient
18
19 beforeEach(() => {
20 queryClient = new QueryClient({
21 defaultOptions: { queries: { retry: false } }
22 })
23 vi.clearAllMocks()
24 })
25
26 it('should open file and load content', async () => {
27 const { invoke } = await import('@tauri-apps/api/core')
28 const { open } = await import('@tauri-apps/plugin-dialog')
29
30 vi.mocked(open).mockResolvedValue('/path/to/file.md')
31 vi.mocked(invoke).mockResolvedValue('# File Content')
32
33 const { result } = renderHook(() => useFileOperations(), {
34 wrapper: ({ children }) => (
35 <QueryClientProvider client={queryClient}>
36 {children}
37 </QueryClientProvider>
38 )
39 })
40
41 await result.current.openFile()
42
43 await waitFor(() => {
44 expect(invoke).toHaveBeenCalledWith('read_file', { path: '/path/to/file.md' })
45 })
46 })
47})
TDD Example: Adding a New Feature
typescript
1// Step 1: RED - Write failing test first
2// src/features/wordcount/useWordCount.test.ts
3describe('useWordCount', () => {
4 it('should count words in content', () => {
5 const { result } = renderHook(() => useWordCount('hello world'))
6 expect(result.current.words).toBe(2)
7 })
8
9 it('should handle empty content', () => {
10 const { result } = renderHook(() => useWordCount(''))
11 expect(result.current.words).toBe(0)
12 })
13
14 it('should count characters', () => {
15 const { result } = renderHook(() => useWordCount('hello'))
16 expect(result.current.characters).toBe(5)
17 })
18})
19
20// Step 2: GREEN - Minimal implementation
21// src/features/wordcount/useWordCount.ts
22export function useWordCount(content: string) {
23 return {
24 words: content.trim() ? content.trim().split(/\s+/).length : 0,
25 characters: content.length
26 }
27}
28
29// Step 3: REFACTOR - Add memoization, types, etc.
30export function useWordCount(content: string): WordCountResult {
31 return useMemo(() => ({
32 words: content.trim() ? content.trim().split(/\s+/).length : 0,
33 characters: content.length,
34 charactersNoSpaces: content.replace(/\s/g, '').length
35 }), [content])
36}
Running Tests
bash
1# All tests
2pnpm test
3
4# Watch mode
5pnpm test:watch
6
7# Coverage
8pnpm test:coverage
9
10# Rust tests only
11cd src-tauri && cargo test
12
13# Type check + lint + test
14pnpm check:all
Debugging Tips
- DevTools: Right-click → Inspect, or
Cmd+Option+I (macOS) / Ctrl+Shift+I (Windows/Linux)
- Rust logs: Use
log crate + tauri-plugin-log or println! (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
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 tools
rust-tauri-backend — VMark Rust backend (commands, menu items, filesystem)