| name | tauri-v2 |
| description | Tauri v2+ cross-platform app development with Rust backend. Use when configuring tauri.conf.json, implementing Rust commands (#[tauri::command]), setting up IPC patterns (invoke, emit, channels), configuring permissions/capabilities, troubleshooting build issues, deploying desktop/mobile apps, or setting up E2E testing with tauri-driver + selenium-webdriver. Triggers on Tauri, src-tauri, invoke, emit, capabilities.json, tauri-driver. |
| version | 1.1.0 |
Tauri v2+ Development Skill
Build cross-platform desktop and mobile apps with web frontends and Rust backends.
Before You Start
This skill prevents 8+ common errors and saves ~60% tokens.
| Metric | Without Skill | With Skill |
|---|
| Setup Time | ~2 hours | ~30 min |
| Common Errors | 8+ | 0 |
| Token Usage | High (exploration) | Low (direct patterns) |
Known Issues This Skill Prevents
- Permission denied errors from missing capabilities
- IPC failures from unregistered commands in
generate_handler!
- State management panics from type mismatches
- Mobile build failures from missing Rust targets
- White screen issues from misconfigured dev URLs
Quick Start
Step 1: Create a Tauri Command
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Why this matters: Commands not in generate_handler![] silently fail when invoked from frontend.
main.rs stays thin: src-tauri/src/main.rs should only be a thin passthrough โ all application logic lives in lib.rs:
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
This split is required for mobile builds โ Tauri replaces main() with mobile_entry_point on mobile targets.
Step 2: Call from Frontend
import { invoke } from '@tauri-apps/api/core';
const greeting = await invoke<string>('greet', { name: 'World' });
console.log(greeting);
Why this matters: Use @tauri-apps/api/core (not @tauri-apps/api/tauri - that's v1 API).
Step 3: Add Required Permissions
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": ["core:default"]
}
Why this matters: Tauri v2 denies everything by default - explicit permissions required for all operations.
Critical Rules
Always Do
- Register every command in
tauri::generate_handler![cmd1, cmd2, ...]
- Return
Result<T, E> from commands for proper error handling
- Use
Mutex<T> for shared state accessed from multiple commands
- Add capabilities before using any plugin features
- Use
lib.rs for shared code (required for mobile builds)
- Use
#[cfg_attr(mobile, tauri::mobile_entry_point)] on pub fn run() in lib.rs for mobile compatibility
Never Do
- Never use borrowed types (
&str) in async commands - use owned types
- Never block the main thread - use async for I/O operations
- Never hardcode paths - use Tauri path APIs (
app.path())
- Never skip capability setup - even "safe" operations need permissions
Common Mistakes
Wrong - Borrowed type in async:
#[tauri::command]
async fn bad(name: &str) -> String {
name.to_string()
}
Correct - Owned type:
#[tauri::command]
async fn good(name: String) -> String {
name
}
Why: Async commands cannot borrow data across await points; Tauri requires owned types for async command parameters.
Known Issues Prevention
| Issue | Root Cause | Solution |
|---|
| "Command not found" | Missing from generate_handler! | Add command to handler macro |
| "Permission denied" | Missing capability | Add to capabilities/default.json |
| Plugin feature silently fails | Plugin installed but permission not in capability | Add plugin permission string to capabilities/default.json |
| Updater fails in production | Unsigned artifacts or HTTP endpoint | Generate keys with cargo tauri signer generate, use HTTPS endpoint only |
| Sidecar not found | externalBin not in tauri.conf.json or missing executable | Add path to bundle.externalBin, ensure binary is bundled |
| Feature works on desktop, breaks on mobile | Desktop-only API used | Check if API has mobile support โ some plugins are desktop-only |
| State panic on access | Type mismatch in State<T> | Use exact type from .manage() |
| White screen on launch | Frontend not building | Check beforeDevCommand in config |
| IPC timeout | Blocking async command | Remove blocking code or use spawn |
| Mobile build fails | Missing Rust targets | Run rustup target add <target> |
Deep-Dive References
Configuration Reference
tauri.conf.json
{
"$schema": "./gen/schemas/desktop-schema.json",
"productName": "my-app",
"version": "1.0.0",
"identifier": "com.example.myapp",
"build": {
"devUrl": "http://localhost:5173",
"frontendDist": "../dist",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"label": "main",
"title": "My App",
"width": 800,
"height": 600
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:",
"capabilities": ["default"]
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.icns", "icons/icon.ico", "icons/icon.png"]
}
}
Key settings:
build.devUrl: Must match your frontend dev server port
app.security.capabilities: Array of capability file identifiers
Plugin configuration โ Some plugins require additional tauri.conf.json blocks (e.g., store, updater). Always check the specific plugin docs at v2.tauri.app/plugin/<plugin-name>/ for required config keys.
Project Structure
my-tauri-app/
โโโ src/ # Frontend source
โโโ src-tauri/
โ โโโ src/
โ โ โโโ main.rs # Thin passthrough โ calls lib::run()
โ โ โโโ lib.rs # ALL application logic lives here
โ โโโ capabilities/
โ โ โโโ default.json # Capability definitions (grant permissions here)
โ โโโ tauri.conf.json # App configuration (devUrl, bundle, security)
โ โโโ Cargo.toml # Rust dependencies
โ โโโ build.rs # Build script (required for tauri-build)
โโโ package.json
Why lib.rs owns all logic: Tauri replaces main() with #[cfg_attr(mobile, tauri::mobile_entry_point)] on mobile. All commands, state, and builder setup must live in lib.rs::run().
Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Key settings:
[lib] section: Required for mobile builds
crate-type: Must include all three types for cross-platform
Common Patterns
Error Handling Pattern
Use Result<T, E> and thiserror for type-safe error propagation across the IPC boundary. See references/ipc-patterns.md for full implementation details.
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Not found: {0}")]
NotFound(String),
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: serde::ser::Serializer {
serializer.serialize_str(self.to_string().as_ref())
}
}
#[tauri::command]
fn risky_operation() -> Result<String, AppError> {
Ok("success".into())
}
Serde Boundary Rules
All command arguments must implement serde::Deserialize, and return types must implement serde::Serialize. This is how Tauri bridges JSON over the IPC boundary.
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateUserArgs {
name: String,
email: String,
role: Option<String>,
}
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
#[tauri::command]
fn create_user(args: CreateUserArgs) -> Result<User, String> {
Ok(User { id: 1, name: args.name })
}
Common serde pitfalls:
- Field names are camelCase in JS, snake_case in Rust โ Tauri automatically converts between them
Option<T> maps to optional JS arguments (can be undefined or null)
- Complex enums need
#[serde(tag = "type")] or similar to be JSON-safe
- Error types must also implement
Serialize (see Error Handling Pattern above)
State Management Pattern
Tauri state manages application data across commands. See references/ipc-patterns.md for more complex state patterns.
use std::sync::Mutex;
use tauri::State;
struct AppState {
counter: u32,
}
#[tauri::command]
fn increment(state: State<'_, Mutex<AppState>>) -> u32 {
let mut s = state.lock().unwrap();
s.counter += 1;
s.counter
}
tauri::Builder::default()
.manage(Mutex::new(AppState { counter: 0 }))
Event Emission Pattern
Events are fire-and-forget notifications. See references/ipc-patterns.md for bidirectional examples.
use tauri::Emitter;
#[tauri::command]
fn start_task(app: tauri::AppHandle) {
std::thread::spawn(move || {
app.emit("task-progress", 50).unwrap();
app.emit("task-complete", "done").unwrap();
});
}
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen('task-progress', (e) => {
console.log('Progress:', e.payload);
});
Channel Streaming Pattern
Channels provide high-frequency, typed streaming from Rust to Frontend. See references/ipc-patterns.md for full implementation details.
use tauri::ipc::Channel;
#[derive(Clone, serde::Serialize)]
#[serde(tag = "event", content = "data")]
enum DownloadEvent {
Progress { percent: u32 },
Complete { path: String },
}
#[tauri::command]
async fn download(url: String, on_event: Channel<DownloadEvent>) {
for i in 0..=100 {
on_event.send(DownloadEvent::Progress { percent: i }).unwrap();
}
on_event.send(DownloadEvent::Complete { path: "/downloads/file".into() }).unwrap();
}
import { invoke, Channel } from '@tauri-apps/api/core';
const channel = new Channel<DownloadEvent>();
channel.onmessage = (msg) => console.log(msg.event, msg.data);
await invoke('download', { url: 'https://...', onEvent: channel });
Window Access Pattern
Tauri v2 uses WebviewWindow for unified window and webview management.
use tauri::Manager;
#[tauri::command]
fn focus_window(app: tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}
Why this matters: Use tauri::WebviewWindow and app.get_webview_window("label") in v2 โ the v1 app.get_window() API is removed in v2.
Bundled Resources
References
Located in references/:
Note: For deep dives on specific topics, see the reference files above.
Dependencies
Required
| Package | Version | Purpose |
|---|
@tauri-apps/cli | ^2 (v2+) | CLI tooling |
@tauri-apps/api | ^2 (v2+) | Frontend APIs |
tauri | ^2 (v2+) | Rust core |
tauri-build | ^2 (v2+) | Build scripts |
*Last verified: 2026-04-02. Always check official changelog for feature timing.
Optional (Plugins)
| Package | Version | Purpose | Key Permission |
|---|
tauri-plugin-fs | ^2 (v2+) | File system access | fs:default |
tauri-plugin-dialog | ^2 (v2+) | Native dialogs | dialog:default |
tauri-plugin-shell | ^2 (v2+) | Shell commands, open URLs | shell:default |
tauri-plugin-http | ^2 (v2+) | HTTP client | http:default |
tauri-plugin-store | ^2 (v2+) | Key-value storage | store:default |
Plugin permissions are mandatory. Installing a plugin without adding its permission string to a capability file causes silent runtime failures. See references/plugin-reference.md for full install + permission details for all official plugins.
Official Documentation
E2E Testing with tauri-driver
Tauri provides an official WebDriver implementation (tauri-driver) for end-to-end testing using selenium-webdriver (not WebdriverIO โ use the selenium-webdriver npm package).
Setup
cargo install tauri-driver
pnpm exec tauri build --debug --no-bundle
Selenium WebDriver Configuration (selenium-webdriver)
import { Builder, Capabilities, type WebDriver } from "selenium-webdriver";
import { spawn, type ChildProcess } from "node:child_process";
const tauriDriver: ChildProcess = spawn("tauri-driver", [], {
stdio: ["ignore", "inherit", "inherit"],
});
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
const capabilities = new Capabilities();
capabilities.setBrowserName("wry");
capabilities.set("tauri:options", {
application: "/absolute/path/to/src-tauri/target/debug/your-app",
});
const driver: WebDriver = await new Builder()
.withCapabilities(capabilities)
.usingServer("http://127.0.0.1:4444/")
.build();
Why this matters: setBrowserName("wry") and tauri:options.application are Tauri-specific โ standard chrome/firefox browser names will not work. Use selenium-webdriver's Capabilities class, not WebdriverIO config objects.
data-testid Convention for Testability
Add data-testid to React components for reliable CSS selector-based testing:
<div data-testid="app-root" className={...}>
<aside data-testid="app-sidebar" className={...}>
<main data-testid="app-main" className={...}>
// Navigation items (dynamic โ view name injected)
<div data-testid={`nav-item-${view}`} onClick={() => setCurrentView(view)}>
// Server list and cards
<div data-testid="server-list" className={...}>
<button data-testid={`server-card-${server.id}`} className={...}>
// Modals (Radix UI Dialog)
<Dialog.Content data-testid="add-server-modal" className={...}>
// Form inputs (react-hook-form โ add data-testid alongside spread, no conflict)
<input {...register('name')} data-testid="server-name-input" className={...} />
// Context menu items (Radix UI)
<ContextMenu.Item data-testid={`delete-server-${server.id}`} onSelect={...}>
react-hook-form compatibility: {...register('name')} spreads only ref / name / onChange / onBlur. Adding data-testid alongside it causes no conflict.
Radix UI compatibility: Dialog.Content, ContextMenu.Item, and other Radix primitives accept data-testid directly.
Common E2E Patterns
import { By, until } from "selenium-webdriver";
await driver.wait(until.elementLocated(By.css("[data-testid='app-root']")), 10000);
const btn = await driver.findElement(By.css("[data-testid='create-server-button']"));
await btn.click();
const card = await driver.findElement(By.css("[data-testid='server-card-abc123']"));
await driver.actions({ async: true }).contextClick(card).perform();
const deleteBtn = await driver.wait(
until.elementLocated(By.css("[data-testid='delete-server-abc123']")),
5000,
);
await deleteBtn.click();
await driver.wait(async () => {
const els = await driver.findElements(By.css("[data-testid='add-server-modal']"));
return els.length === 0;
}, 15000);
Tauri-Specific E2E Pitfalls
| Pitfall | Solution |
|---|
Sidebar component returns null when collapsed | Check sidebar class before asserting child elements; click [data-testid='sidebar-toggle-button'] to open |
| Zustand-driven views (no React Router) | Navigation tests must click [data-testid='nav-item-{view}'] โ do NOT navigate to a URL |
| ContextMenu items not found | Open with Actions.contextClick() first โ items only appear in DOM while menu is open |
react-hook-form + data-testid conflict | No conflict โ add data-testid separately from {...register()} |
| App binary not found | Ensure src-tauri/target/debug/<name> exists; run tauri build --debug --no-bundle first |
Sidebar State Guard Pattern
When a sidebar component renders null while collapsed, always check/open it before asserting child elements:
before(async () => {
ctx = await startApp();
const sidebar = await ctx.driver.findElements(By.css("[data-testid='app-sidebar']"));
if (sidebar.length > 0) {
const cls = await sidebar[0].getAttribute("class");
if (!cls.includes("--open")) {
const toggle = await ctx.driver.findElement(
By.css("[data-testid='sidebar-toggle-button']"),
);
await toggle.click();
await ctx.driver.sleep(500);
}
}
});
Troubleshooting
White Screen on Launch
Symptoms: App launches but shows blank white screen
Solution:
- Verify
devUrl matches your frontend dev server port
- Check
beforeDevCommand runs your dev server
- Open DevTools (Cmd+Option+I / Ctrl+Shift+I) to check for errors
Command Returns Undefined
Symptoms: invoke() returns undefined instead of expected value
Solution:
- Verify command is in
generate_handler![]
- Check Rust command actually returns a value
- Ensure argument names match (camelCase in JS, snake_case in Rust by default)
Mobile Build Failures
Symptoms: Android/iOS build fails with missing target
Solution:
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim
Desktop vs Mobile Behavioral Differences
Not all Tauri APIs and plugins support mobile (iOS/Android). Before using any plugin or API in a mobile build:
- Check the plugin page at
v2.tauri.app/plugin/<name>/ for platform support matrix
- Common desktop-only items: System tray (
TrayIconBuilder), window labels/multi-window, some shell plugin features
- Mobile-safe patterns: IPC commands/events/channels work on all platforms;
tauri::AppHandle is mobile-safe
- Conditional compilation: Use
#[cfg(desktop)] / #[cfg(mobile)] for platform-specific Rust logic
#[tauri::command]
fn platform_info() -> String {
#[cfg(desktop)]
return "desktop".to_string();
#[cfg(mobile)]
return "mobile".to_string();
}
Setup Checklist
Before using this skill, verify: