tauri-app-dev

Tauri 2.0 App Development

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "tauri-app-dev" with this command: npx skills add xiaolai/vmark/xiaolai-vmark-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 }) => (
    &#x3C;QueryClientProvider client={queryClient}>
      {children}
    &#x3C;/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 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

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 tools

  • rust-tauri-backend — VMark Rust backend (commands, menu items, filesystem)

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

tiptap-dev

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

mcp-dev

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

react-app-dev

No summary provided by upstream source.

Repository SourceNeeds Review