All documents now reference only Tauri 2.0 official patterns and APIs with no dependency on any specific reference application.
56 KiB
Tauri 2.0 Definitive Developer Guide
Version: 2.0 | Last Updated: 2026-05-31 Official Docs: https://v2.tauri.app
Table of Contents
- Introduction & Architecture Overview
- Project Structure & Scaffolding
- Configuration System (
tauri.conf.json) - Capabilities & Permissions (Security Model)
- Rust Backend:
lib.rs& Commands - Frontend Integration Patterns
- IPC: Commands, Events & Channels
- Window Management
- Plugin Ecosystem
- Native WebSocket Server (Rust-side)
- Script Injection into Webview Windows
- State Management
- v1 to v2 Migration Reference
- Common Anti-Patterns to Avoid
- Quick Reference Cheat Sheet
1. Introduction & Architecture Overview
Tauri 2.0 is a framework for building small, fast binaries for all major desktop and mobile platforms. It uses a hybrid architecture where a Rust backend handles system-level operations while a web frontend (HTML/CSS/JS, or any framework like React/Vue/Svelte) renders the UI inside a native OS webview.
Core Architecture Principles
- Rust Core (
src-tauri/): Handles system access, file operations, network calls, window management, and any heavy computation. All system interactions MUST go through Rust to maintain security boundaries. - Web Frontend (
src/): Renders the UI using standard web technologies. Communicates with Rust exclusively through Tauri's IPC mechanisms (invoke, events, channels). - Security Boundary: The frontend runs in a sandboxed webview. It cannot directly access the filesystem, network, or OS APIs — it must request these through Tauri commands and plugins, which are governed by the Capabilities & Permissions system.
- Mobile Support (NEW in v2): Tauri 2.0 adds first-class iOS and Android support. This requires a specific project structure (
lib.rs+main.rspattern) and platform-specific capability configurations.
What Changed from v1 to v2
Tauri 2.0 is a complete architectural overhaul, not an incremental update. The most significant changes are:
| Area | v1 | v2 |
|---|---|---|
| Security | allowlist in config |
Capabilities + Permissions (ACL-based) |
| APIs | Built-in tauri::api module |
Everything is a plugin |
| Window type | Window / WindowBuilder |
WebviewWindow / WebviewWindowBuilder |
| Config structure | Nested tauri > key |
Flattened: app, bundle, build |
| Event system | Per-window scoped | emit() is global; emit_to() for targeting |
| JS imports | @tauri-apps/api/tauri |
@tauri-apps/api/core |
| Mobile support | Community plugins | First-class iOS/Android support |
| Project structure | main.rs only |
lib.rs + main.rs pattern required |
2. Project Structure & Scaffolding
Standard Tauri 2.0 File Hierarchy
my-app/
├── src/ # Frontend UI source
│ ├── index.html # Main HTML entry
│ ├── main.js # Frontend JavaScript
│ └── styles.css # Styles
├── src-tauri/ # Rust backend environment
│ ├── capabilities/ # Security capability definitions
│ │ └── default.json # Maps windows → permissions
│ ├── icons/ # App icons for all platforms
│ ├── src/
│ │ ├── main.rs # Minimal desktop entry-point
│ │ ├── lib.rs # Core application setup & commands
│ │ └── <modules>.rs # Additional Rust modules
│ ├── build.rs # Tauri build script
│ ├── Cargo.toml # Rust dependencies
│ ├── Cargo.lock # Lockfile (COMMIT THIS)
│ └── tauri.conf.json # Main Tauri configuration
├── package.json # Node.js dependencies & scripts
└── package-lock.json
Creating a New Project
# Using the interactive CLI scaffolder
npm create tauri-app@latest
# Or with specific template
npm create tauri-app@latest -- --template vanilla-ts
# Cargo alternative
cargo create-tauri-app
Supported templates: Vanilla, Vue, Svelte, React, Solid, Angular, Preact, Yew, Leptos, Sycamore.
Adding Tauri to an Existing Frontend
npm install -D @tauri-apps/cli@latest
npx tauri init
The init command will prompt for: app name, window title, web assets location, dev server URL, and frontend build/dev commands.
The lib.rs + main.rs Pattern (Required in v2)
Tauri 2.0 requires your core application logic to live in lib.rs, with a minimal main.rs that calls into it. This is mandatory for mobile support because mobile platforms require a shared library entry-point rather than a standard main() function.
src-tauri/src/main.rs — Desktop-only entry-point:
// Prevents additional console window on Windows in release mode
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
my_app_lib::run()
}
src-tauri/src/lib.rs — Core application:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// plugins, commands, setup, etc.
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
src-tauri/Cargo.toml — Must declare the library:
[lib]
name = "my_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
The _lib suffix in the crate name prevents naming conflicts with the binary on Windows. The three crate types cover: static linking (mobile), dynamic linking (mobile), and Rust library usage (desktop/tests).
Development Commands
# Run in development mode (hot-reloads UI + auto-recompiles Rust)
npm run tauri dev
# or: pnpm tauri dev, bun tauri dev, cargo tauri dev
# Build production artifacts
npm run tauri build
3. Configuration System (tauri.conf.json)
Main Configuration File
The primary configuration file is src-tauri/tauri.conf.json. Tauri 2.0 supports three formats:
| Format | Feature Flag Required |
|---|---|
| JSON (default) | None |
| JSON5 | config-json5 on both tauri and tauri-build |
| TOML | config-toml on both tauri and tauri-build |
Complete v2 Configuration Structure
{
"$schema": "https://tauri.app",
"productName": "my-app",
"version": "1.0.0",
"identifier": "com.mycompany.myapp",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "My App",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false,
"center": true,
"dragDropEnabled": true,
"useHttpsScheme": false
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'",
"assetProtocol": {
"scope": ["$APPDATA/**", "$RESOURCE/**"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"licenseFile": "LICENSE",
"copyright": "",
"category": "Utility"
}
}
Key Configuration Changes from v1
| v1 Path | v2 Path | Notes |
|---|---|---|
package.productName |
Top-level productName |
Moved out of package |
package.version |
Top-level version |
Moved out of package |
package |
(removed) | Fields redistributed |
build.distDir |
build.frontendDist |
Renamed |
build.devPath |
build.devUrl |
Renamed |
build.withGlobalTauri |
app.withGlobalTauri |
Moved to app section |
tauri.* |
app.* |
Top-level key renamed |
tauri.allowlist |
(removed) | Replaced by capabilities |
tauri.windows.fileDropEnabled |
app.windows.dragDropEnabled |
Renamed |
tauri.bundle |
Top-level bundle |
Promoted to top-level |
tauri.updater |
plugins.updater |
Moved to plugins |
tauri.systemTray |
app.trayIcon |
Renamed |
tauri.cli |
plugins.cli |
Moved to plugins |
bundle.identifier |
Top-level identifier |
Promoted to top-level |
Platform-Specific Configuration
Create platform override files that merge with the main config using JSON Merge Patch (RFC 7396). Arrays are replaced entirely (not element-by-element merged).
| Platform | File Pattern |
|---|---|
| Linux | tauri.linux.conf.json or Tauri.linux.toml |
| Windows | tauri.windows.conf.json or Tauri.windows.toml |
| macOS | tauri.macos.conf.json or Tauri.macos.toml |
| Android | tauri.android.conf.json or Tauri.android.toml |
| iOS | tauri.ios.conf.json or Tauri.ios.toml |
Cargo.toml Dependencies
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Important: Keep tauri, tauri-build, and @tauri-apps/cli on the same minor version. Always commit Cargo.lock for reproducible builds.
package.json Scripts
{
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
The "tauri" script is only required when using npm (not needed for yarn/pnpm/bun).
4. Capabilities & Permissions (Security Model)
Tauri 2.0 introduces a strict Access Control List (ACL) security model that replaces the v1 allowlist. This is the most important new concept to understand.
Core Concepts
- Capabilities define which permissions are granted to which windows or webviews.
- Permissions describe explicit privileges for specific commands (e.g.,
fs:allow-read,http:allow-request). - Scopes further restrict what a permission can access (e.g., only certain file paths).
- Multiple capabilities can apply to the same window — permissions merge.
- Security boundaries are based on window labels (not titles).
Capability File Location
src-tauri/capabilities/<identifier>.json (or .toml)
All files in this directory are automatically loaded by default.
Basic Capability File
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window-capability",
"description": "Permissions for the primary application window",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"permissions": [
"core:default"
]
}
Field reference:
$schema— Points to generated schema for IDE autocompletion. Usedesktop-schema.jsonfor desktop,mobile-schema.jsonfor mobile.identifier— Unique capability name (ASCII lowercase, max 116 chars).description— Human-readable purpose.windows— Array of window labels this capability applies to. Use["*"]for all windows.permissions— Array of permission identifiers.platforms— Optional. Restricts to specific platforms. Defaults to all platforms if omitted.
Permission Identifier Naming Convention
| Pattern | Meaning | Example |
|---|---|---|
<plugin>:default |
Default permission set for a plugin | fs:default |
<plugin>:allow-<command> |
Allow a specific command | fs:allow-read |
<plugin>:deny-<command> |
Deny a specific command | fs:deny-write |
core:<module>:<permission> |
Core Tauri module permission | core:event:allow-emit |
Common Permission Identifiers
Core permissions:
core:default— Basic runtime permissionscore:path:default— Path resolutioncore:event:default— Event system (includes listen/emit)core:event:allow-listen— Allow listening to eventscore:event:allow-emit— Allow emitting eventscore:window:default— Window managementcore:window:allow-set-title— Allow changing window title
Plugin permissions:
fs:allow-read,fs:allow-write,fs:allow-exists,fs:allow-mkdir,fs:allow-removehttp:allow-request— HTTP client requestsdialog:allow-open,dialog:allow-save,dialog:allow-messageshell:allow-open,shell:allow-executenotification:allow-notify,notification:allow-is-permission-grantedclipboard-manager:allow-read-text,clipboard-manager:allow-write-textwebsocket:allow-connect,websocket:allow-sendstore:default— Persistent key-value store
Fine-Grained Scopes
Scopes restrict what a permission can actually access:
{
"identifier": "secure-fs-capability",
"windows": ["main"],
"permissions": [
{
"identifier": "fs:allow-read",
"allow": [{ "path": "$APPDATA/logs/*" }, { "path": "$HOME/Documents/*.txt" }]
},
{
"identifier": "fs:allow-write",
"allow": [{ "path": "$APPDATA/logs/*.log" }]
},
{
"identifier": "fs:deny-write",
"deny": [{ "path": "$APPDATA/config/*" }]
}
]
}
Available scope variables: $APPDATA, $HOME, $APPCONFIG, $APPCACHE, $APPLOG, $RESOURCE, $EXE.
Restricting Custom Commands
By default, all #[tauri::command] functions are accessible from all windows. To restrict a custom command to specific windows only, use build.rs:
fn main() {
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(
tauri_build::AppManifest::new()
.commands(&["my_restricted_command", "admin_only_command"])
),
)
.unwrap();
}
Then grant access in the capability:
{
"identifier": "admin-capability",
"windows": ["admin-panel"],
"permissions": [
{ "identifier": "allow-my-restricted-command" },
{ "identifier": "allow-admin-only-command" }
]
}
Referencing Capabilities Explicitly
By default, all capability files in src-tauri/capabilities/ are auto-discovered. To control which ones are used explicitly:
{
"app": {
"security": {
"capabilities": ["my-capability", "admin-capability"]
}
}
}
Inline Capabilities
You can define capabilities directly in tauri.conf.json instead of separate files:
{
"app": {
"security": {
"capabilities": [
{
"identifier": "inline-cap",
"windows": ["*"],
"permissions": ["core:default"]
}
]
}
}
}
5. Rust Backend: lib.rs & Commands
Application Builder Pattern
All Tauri 2.0 applications follow the tauri::Builder pattern:
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) // Register plugins
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| { // Setup hook (runs once at startup)
// Initialize resources, spawn servers, etc.
Ok(())
})
.manage(AppState { /* ... */ }) // Inject managed state
.invoke_handler(tauri::generate_handler![ // Register commands
my_command,
another_command,
module::command_in_module
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Critical rules:
invoke_handlercan only be called once. All commands must be in a singlegenerate_handler![]call.- Commands defined in
lib.rscannot be markedpub. - Commands defined in separate modules must be marked
pub. - The
#[cfg_attr(mobile, tauri::mobile_entry_point)]attribute is required for therun()function.
Defining Commands
Commands are the primary IPC mechanism. They are Rust functions annotated with #[tauri::command] and callable from the frontend via invoke().
Basic Command
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}! Welcome from Rust.", name)
}
Command with Error Handling
#[tauri::command]
fn login(user: String, password: String) -> Result<String, String> {
if user == "admin" && password == "secret" {
Ok("authenticated".to_string())
} else {
Err("invalid credentials".to_string())
}
}
Async Command
#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
let response = reqwest::get(&url)
.await
.map_err(|e| e.to_string())?;
response.text().await.map_err(|e| e.to_string())
}
IMPORTANT limitation: Async commands cannot accept borrowed references (&str, &Path) directly. Convert to owned types (String, PathBuf) or wrap the return in Result:
// Option A: Use owned types
#[tauri::command]
async fn process(value: String) -> String { /* ... */ }
// Option B: Wrap return in Result
#[tauri::command]
async fn process(value: &str) -> Result<String, ()> { /* ... */ }
Custom Error Types (Recommended)
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
}
// Implement Serialize so the error can cross the IPC boundary
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 read_config() -> Result<String, AppError> {
let content = std::fs::read_to_string("config.toml")?;
Ok(content)
}
Structured Error with Kind/Tag
#[derive(Debug, Error)]
enum AppError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("{0}")]
Custom(String),
}
#[derive(serde::Serialize)]
#[serde(tag = "kind", content = "message")]
#[serde(rename_all = "camelCase")]
enum ErrorKind {
Io(String),
Custom(String),
}
impl serde::Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let kind = match self {
Self::Io(e) => ErrorKind::Io(e.to_string()),
Self::Custom(msg) => ErrorKind::Custom(msg.clone()),
};
kind.serialize(serializer)
}
}
Frontend receives: { kind: 'io', message: '...' } — making it easy to handle errors by type.
Accessing Special Objects in Commands
AppHandle — Access to the application instance:
#[tauri::command]
fn do_something(app_handle: tauri::AppHandle) {
let app_dir = app_handle.path().app_dir();
// Spawn windows, emit events, access state...
}
WebviewWindow — The window that invoked the command:
#[tauri::command]
fn get_window_info(webview_window: tauri::WebviewWindow) -> String {
webview_window.label().to_string()
}
Managed State — Shared application state:
use tauri::State;
use std::sync::Mutex;
struct DbConnection(Mutex<String>);
#[tauri::command]
fn query_database(state: State<'_, DbConnection>) -> String {
let conn = state.0.lock().unwrap();
conn.clone()
}
Raw Request — Access headers and raw body:
#[tauri::command]
fn upload(request: tauri::ipc::Request) -> Result<(), String> {
let tauri::ipc::InvokeBody::Raw(data) = request.body() else {
return Err("Expected raw body".to_string());
};
let auth = request.headers().get("Authorization")
.ok_or("Missing auth header")?;
Ok(())
}
Commands in Separate Modules
// src-tauri/src/commands.rs
#[tauri::command]
pub fn create_user(name: String) -> String {
format!("Created user: {}", name)
}
#[tauri::command]
pub fn delete_user(id: u32) -> bool {
// ...
true
}
// src-tauri/src/lib.rs
mod commands;
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
commands::create_user,
commands::delete_user
])
// ...
}
Note: The commands:: prefix is Rust path resolution only — the frontend still calls invoke('create_user', ...) without any prefix.
Returning Large Data (ArrayBuffers)
For large binary data like file contents, use tauri::ipc::Response:
use tauri::ipc::Response;
#[tauri::command]
fn read_file() -> Response {
let data = std::fs::read("/path/to/file").unwrap();
Response::new(data)
}
Streaming Data (Channels)
For streaming large amounts of data to the frontend:
use tauri::ipc::Channel;
#[tauri::command]
async fn stream_file(path: std::path::PathBuf, on_chunk: Channel<Vec<u8>>) {
let mut file = tokio::fs::File::open(path).await.unwrap();
let mut buf = vec![0u8; 4096];
loop {
let n = file.read(&mut buf).await.unwrap();
if n == 0 { break; }
on_chunk.send(&buf[..n]).unwrap();
}
}
6. Frontend Integration Patterns
Using @tauri-apps/api npm Package (Recommended)
npm install @tauri-apps/api
// Imports in v2 — NOTE the path changes from v1
import { invoke } from '@tauri-apps/api/core';
import { listen, emit, emitTo, once } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow, WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { Channel } from '@tauri-apps/api/core';
Using Global window.__TAURI__ (No npm package needed)
Requires app.withGlobalTauri: true in tauri.conf.json:
// No import needed — accessed via the global object
const { invoke } = window.__TAURI__.core;
const { listen, emit } = window.__TAURI__.event;
This pattern is ideal for vanilla JS projects that don't use a bundler, avoiding the need for any npm packages beyond @tauri-apps/cli.
Import Path Changes from v1
| v1 Import | v2 Import |
|---|---|
@tauri-apps/api/tauri |
@tauri-apps/api/core |
@tauri-apps/api/window |
@tauri-apps/api/webviewWindow |
@tauri-apps/api/cli |
@tauri-apps/plugin-cli |
@tauri-apps/api/clipboard |
@tauri-apps/plugin-clipboard-manager |
@tauri-apps/api/dialog |
@tauri-apps/plugin-dialog |
@tauri-apps/api/fs |
@tauri-apps/plugin-fs |
@tauri-apps/api/global-shortcut |
@tauri-apps/plugin-global-shortcut |
@tauri-apps/api/http |
@tauri-apps/plugin-http |
@tauri-apps/api/notification |
@tauri-apps/plugin-notification |
@tauri-apps/api/shell |
@tauri-apps/plugin-shell |
@tauri-apps/api/updater |
@tauri-apps/plugin-updater |
@tauri-apps/api/os |
@tauri-apps/plugin-os |
@tauri-apps/api/process |
@tauri-apps/plugin-process |
Rule of thumb in v2: If it's not core, event, or webviewWindow, it's a plugin.
Plugin JS Package Naming
All v2 plugin JS packages follow: @tauri-apps/plugin-<name>
npm install @tauri-apps/plugin-store
npm install @tauri-apps/plugin-fs
npm install @tauri-apps/plugin-http
7. IPC: Commands, Events & Channels
Commands (Frontend → Rust)
The primary request-response mechanism:
// Frontend (v2 import)
import { invoke } from '@tauri-apps/api/core';
// Simple invocation
const result = await invoke('greet', { name: 'World' });
console.log(result); // "Hello, World! Welcome from Rust."
// With error handling
try {
const token = await invoke('login', { user: 'admin', password: 'secret' });
console.log('Authenticated:', token);
} catch (error) {
console.error('Login failed:', error);
}
Argument naming convention: Arguments are passed as a JSON object with camelCase keys by default. Use #[tauri::command(rename_all = "snake_case")] to accept snake_case from the frontend.
Events (Bidirectional, Multi-Consumer)
Events are fire-and-forget messages. Use them for notifications, streaming status updates, or any data that multiple components might need.
Emitting Events from Rust
use tauri::{AppHandle, Emitter};
// Global event (all listeners receive it)
app_handle.emit("download-progress", 42)?;
// Target a specific window
app_handle.emit_to("settings-panel", "config-changed", new_config)?;
// Filter to specific windows
use tauri::EventTarget;
app_handle.emit_filter("notification", payload, |target| {
matches!(target, EventTarget::WebviewWindow { label } if label == "main")
})?;
Listening for Events on the Frontend
import { listen, once, emit, emitTo } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
// Global listen (receives ALL events with this name)
const unlisten = await listen('download-progress', (event) => {
console.log(`Progress: ${event.payload}%`);
});
// Targeted listen (only events emitted to THIS window)
const appWebview = getCurrentWebviewWindow();
const unlisten2 = await appWebview.listen('config-changed', (event) => {
console.log('New config:', event.payload);
});
// Listen once, then auto-cleanup
await once('initialization-complete', (event) => {
console.log('App ready!', event.payload);
});
// Emit from frontend to Rust
await emit('user-action', { type: 'click', target: 'button' });
// Emit to a specific window from frontend
const target = new WebviewWindow('settings-panel');
await target.emit('settings-request', { key: 'theme' });
Always call unlisten() when the component unmounts or the listener is no longer needed to prevent memory leaks.
Listening for Events on the Rust Side
use tauri::{Listener, Manager};
pub fn run() {
tauri::Builder::default()
.setup(|app| {
// Listen globally
app.listen("user-action", |event| {
println!("Received: {}", event.payload());
});
// Listen on a specific window
let main = app.get_webview_window("main").unwrap();
main.listen("config-changed", |event| {
println!("Config updated: {}", event.data);
});
// Listen once
app.once("ready", |event| {
println!("App is ready!");
});
Ok(())
})
// ...
}
Channels (High-Throughput Streaming)
Channels are the recommended mechanism for streaming large amounts of ordered data from Rust to the frontend. They are faster and more memory-efficient than events for high-throughput scenarios.
use tauri::ipc::Channel;
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(tag = "event", content = "data")]
enum StreamEvent {
Started { total: usize },
Progress { current: usize, chunk: Vec<u8> },
Finished { total: usize },
}
#[tauri::command]
fn download_file(url: String, on_event: Channel<StreamEvent>) {
on_event.send(StreamEvent::Started { total: 1000 }).unwrap();
// ... streaming logic ...
on_event.send(StreamEvent::Finished { total: 1000 }).unwrap();
}
import { invoke, Channel } from '@tauri-apps/api/core';
const onEvent = new Channel();
onEvent.onmessage = (event) => {
switch (event.event) {
case 'started': console.log(`Starting: ${event.data.total} bytes`); break;
case 'progress': /* handle chunk */ break;
case 'finished': console.log('Done!'); break;
}
};
await invoke('download_file', { url: 'https://...', onEvent });
Evaluating JavaScript from Rust
use tauri::Manager;
// In a command or setup hook
let webview = app.get_webview_window("main").unwrap();
webview.eval("document.getElementById('status').textContent = 'Loaded!'")?;
For complex data passing, use the serialize-to-javascript crate.
8. Window Management
Creating Windows Dynamically
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
#[tauri::command]
fn open_settings(app_handle: AppHandle) -> Result<(), String> {
// Focus existing window if it exists
if let Some(window) = app_handle.get_webview_window("settings") {
window.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
// Create new window
let _window = WebviewWindowBuilder::new(
&app_handle,
"settings",
WebviewUrl::App("settings.html".into())
)
.title("Settings")
.inner_size(600.0, 400.0)
.resizable(true)
.center(true)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
Creating Windows with External URLs
use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder};
#[tauri::command]
async fn open_remote(app_handle: AppHandle, url: String) -> Result<String, String> {
let target_url = url.parse()
.map(WebviewUrl::External)
.map_err(|_| "Invalid URL".to_string())?;
app_handle.run_on_main_thread(move || {
WebviewWindowBuilder::new(&app_handle, "remote-window", target_url)
.title("Remote Session")
.inner_size(1024.0, 768.0)
.build()
}).map_err(|e| e.to_string())?;
Ok("Window spawned".to_string())
}
Key pattern: Always use run_on_main_thread when creating windows from async contexts or non-main-thread code. This is critical — window creation MUST happen on the main thread.
Window Builder Options
WebviewWindowBuilder::new(&app, "label", WebviewUrl::App("path".into()))
.title("Window Title")
.inner_size(800.0, 600.0) // Width, Height
.min_inner_size(400.0, 300.0) // Minimum size
.max_inner_size(1920.0, 1080.0) // Maximum size
.resizable(true)
.fullscreen(false)
.center(true)
.decorations(true) // Title bar
.always_on_top(false)
.visible(true) // Start visible
.focused(true)
.initialization_script("...") // Inject JS on load
.build()?;
Managing Windows from Frontend
import { getCurrentWebviewWindow, WebviewWindow } from '@tauri-apps/api/webviewWindow';
// Get current window reference
const appWindow = getCurrentWebviewWindow();
// Window operations
await appWindow.setTitle("New Title");
await appWindow.center();
await appWindow.minimize();
await appWindow.maximize();
await appWindow.unmaximize();
await appWindow.close();
await appWindow.setFullscreen(true);
await appWindow.setResizable(false);
await appWindow.setSize({ width: 1024, height: 768 });
await appWindow.setPosition({ x: 100, y: 100 });
// Create new window from frontend
const newWindow = new WebviewWindow('secondary', {
url: 'index.html#/settings',
title: 'Settings',
width: 600,
height: 400,
});
await newWindow.once('tauri://created', () => {
console.log('Window created!');
});
await newWindow.once('tauri://error', (e) => {
console.error('Window creation error:', e);
});
Window v1 to v2 API Renames
| v1 | v2 |
|---|---|
tauri::Window |
tauri::WebviewWindow |
tauri::WindowBuilder |
tauri::WebviewWindowBuilder |
tauri::WindowUrl |
tauri::WebviewUrl |
app.get_window("main") |
app.get_webview_window("main") |
WebviewUrl::App(path) |
WebviewUrl::App(path.into()) |
WebviewUrl::External(url) |
WebviewUrl::External(url) |
9. Plugin Ecosystem
Complete List of Official Tauri 2.0 Plugins (33 plugins)
| Plugin | Crate | JS Package | Platforms |
|---|---|---|---|
| Autostart | tauri-plugin-autostart |
@tauri-apps/plugin-autostart |
Win, Lin, Mac |
| Barcode Scanner | tauri-plugin-barcode-scanner |
@tauri-apps/plugin-barcode-scanner |
Android, iOS |
| Biometric | tauri-plugin-biometric |
@tauri-apps/plugin-biometric |
Android, iOS |
| Clipboard Manager | tauri-plugin-clipboard-manager |
@tauri-apps/plugin-clipboard-manager |
Win, Lin, Mac, Android, iOS |
| CLI | tauri-plugin-cli |
@tauri-apps/plugin-cli |
Win, Lin, Mac, Android, iOS |
| Deep Linking | tauri-plugin-deep-link |
@tauri-apps/plugin-deep-link |
Win, Mac, Android, iOS |
| Dialog | tauri-plugin-dialog |
@tauri-apps/plugin-dialog |
Win, Lin, Mac, Android |
| File System | tauri-plugin-fs |
@tauri-apps/plugin-fs |
Win, Lin, Mac, Android, iOS |
| Geolocation | tauri-plugin-geolocation |
@tauri-apps/plugin-geolocation |
Android, iOS |
| Global Shortcut | tauri-plugin-global-shortcut |
@tauri-apps/plugin-global-shortcut |
Win, Lin, Mac |
| Haptics | tauri-plugin-haptics |
@tauri-apps/plugin-haptics |
Android, iOS |
| HTTP Client | tauri-plugin-http |
@tauri-apps/plugin-http |
Win, Lin, Mac, Android, iOS |
| Localhost | tauri-plugin-localhost |
@tauri-apps/plugin-localhost |
Win, Lin, Mac |
| Logging | tauri-plugin-log |
@tauri-apps/plugin-log |
Win, Lin, Mac, Android, iOS |
| NFC | tauri-plugin-nfc |
@tauri-apps/plugin-nfc |
Android, iOS |
| Notification | tauri-plugin-notification |
@tauri-apps/plugin-notification |
Win, Lin, Mac, Android |
| Opener | tauri-plugin-opener |
@tauri-apps/plugin-opener |
Win, Lin, Mac, Android, iOS |
| OS Info | tauri-plugin-os |
@tauri-apps/plugin-os |
Win, Lin, Mac, Android, iOS |
| Persisted Scope | tauri-plugin-persisted-scope |
@tauri-apps/plugin-persisted-scope |
Win, Lin, Mac, Android, iOS |
| Positioner | tauri-plugin-positioner |
@tauri-apps/plugin-positioner |
Win, Lin, Mac |
| Process | tauri-plugin-process |
@tauri-apps/plugin-process |
Win, Lin, Mac, Android, iOS |
| Shell | tauri-plugin-shell |
@tauri-apps/plugin-shell |
Win, Lin, Mac, Android |
| Single Instance | tauri-plugin-single-instance |
@tauri-apps/plugin-single-instance |
Win, Lin, Mac |
| SQL | tauri-plugin-sql |
@tauri-apps/plugin-sql |
Win, Lin, Mac, Android, iOS |
| Store | tauri-plugin-store |
@tauri-apps/plugin-store |
Win, Lin, Mac, Android, iOS |
| Stronghold | tauri-plugin-stronghold |
@tauri-apps/plugin-stronghold |
Win, Lin, Mac, Android, iOS |
| System Tray | tauri-plugin-system-tray |
@tauri-apps/plugin-system-tray |
Win, Lin, Mac |
| Updater | tauri-plugin-updater |
@tauri-apps/plugin-updater |
Win, Lin, Mac |
| Upload | tauri-plugin-upload |
@tauri-apps/plugin-upload |
Win, Lin, Mac, Android, iOS |
| Websocket | tauri-plugin-websocket |
@tauri-apps/plugin-websocket |
Win, Lin, Mac, Android, iOS |
| Window State | tauri-plugin-window-state |
@tauri-apps/plugin-window-state |
Win, Lin, Mac, Android, iOS |
| Window Customization | tauri-plugin-window-customization |
@tauri-apps/plugin-window-customization |
Win, Lin, Mac |
Adding a Plugin (3-Step Pattern)
Every plugin follows the same 3-step setup:
Step 1: Add Rust dependency
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-store = "2"
Step 2: Register in Builder
// src-tauri/src/lib.rs
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
Step 3: Add JS dependency
npm install @tauri-apps/plugin-store
Step 4 (optional but required): Add permissions
// src-tauri/capabilities/default.json
{
"permissions": ["store:default"]
}
Plugin Registration Variations
Different plugins have different initialization patterns:
// Simple init (most plugins)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_clipboard_manager::init())
// Builder pattern (plugins with configuration)
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_log::Builder::default().build())
// With closures/config
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_shell::init())
Key Plugin Examples
File System Plugin (tauri-plugin-fs)
Rust side uses std::fs. JS side uses the plugin:
import { readFile, writeFile, mkdir, exists, remove, readDir } from '@tauri-apps/plugin-fs';
// Read a text file
const content = await readFile('config.json');
// Write a file
await writeFile('output.txt', 'Hello World!');
// Create directory
await mkdir('data/logs', { recursive: true });
// Check existence
const hasFile = await exists('config.json');
// Remove file
await remove('temp.txt');
HTTP Client Plugin (tauri-plugin-http)
import { fetch } from '@tauri-apps/plugin-http';
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
});
const data = await response.json();
Store Plugin (Persistent Key-Value)
import { Store } from '@tauri-apps/plugin-store';
const store = await Store.load('settings.json');
await store.set('theme', 'dark');
await store.set('volume', 75);
const theme = await store.get('theme'); // 'dark'
await store.save();
Dialog Plugin
import { open, save, message, ask, confirm } from '@tauri-apps/plugin-dialog';
// File picker
const file = await open({ multiple: false, filters: [{ name: 'JSON', extensions: ['json'] }] });
// Save dialog
const path = await save({ filters: [{ name: 'Text', extensions: ['txt'] }] });
// Alert
await message('Operation completed!');
// Confirmation
const yes = await confirm('Are you sure you want to delete this file?');
10. Native WebSocket Server (Rust-side)
This section documents the WebSocket server pattern for Tauri 2.0 applications. This pattern is useful when your app needs to act as a server — accepting connections from local webviews, external applications, or other processes.
Architecture Overview
The WebSocket server runs as a Tokio async task spawned during the Tauri setup phase. It uses tokio-tungstenite for the WebSocket implementation and tokio::sync::broadcast for message fan-out to all connected clients.
Dependencies (Cargo.toml)
[dependencies]
tokio = { version = "1", features = ["net", "rt", "sync", "macros"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
Server Implementation
// src-tauri/src/server.rs
use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
use futures_util::stream::StreamExt;
use futures_util::sink::SinkExt;
use tokio::sync::broadcast;
pub async fn start_websocket_server() {
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
println!("WebSocket Server listening on ws://127.0.0.1:8080");
// Broadcast channel: 16 message queue depth
let (tx, _rx) = broadcast::channel::<String>(16);
while let Ok((stream, _addr)) = listener.accept().await {
let tx = tx.clone();
let mut rx = tx.subscribe();
// Spawn a task per client connection
tauri::async_runtime::spawn(async move {
if let Ok(ws_stream) = accept_async(stream).await {
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
loop {
tokio::select! {
// Branch A: Receive message FROM this client
incoming = ws_receiver.next() => {
match incoming {
Some(Ok(msg)) if msg.is_text() => {
let text = msg.to_text().unwrap().to_string();
println!("Received: {}", text);
// Broadcast to ALL other connected clients
let _ = tx.send(text);
}
_ => break, // Disconnect
}
}
// Branch B: Receive broadcast FROM other clients
broadcast = rx.recv() => {
if let Ok(payload) = broadcast {
let frame = tokio_tungstenite::tungstenite::Message::Text(
payload.into()
);
if let Err(e) = ws_sender.send(frame).await {
eprintln!("Send error: {}", e);
break;
}
}
}
}
}
}
});
}
}
Spawning the Server from lib.rs
// src-tauri/src/lib.rs
mod server;
pub fn run() {
tauri::Builder::default()
.setup(|_app| {
tauri::async_runtime::spawn(server::start_websocket_server());
Ok(())
})
// ...
}
Key pattern: Use tauri::async_runtime::spawn() to launch the server. This ensures the server runs on Tauri's managed async runtime rather than blocking the setup hook.
Frontend Client Connection
// Connect to the local WebSocket server
const ws = new WebSocket('ws://127.0.0.1:8080');
ws.onopen = () => {
console.log('Connected to local WS server');
ws.send(JSON.stringify({ type: 'hello', from: 'main-window' }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received from server:', data);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
When to Use This Pattern vs. the WebSocket Plugin
| Scenario | Approach |
|---|---|
| App connects to an external WebSocket server | Use tauri-plugin-websocket (client plugin) |
| App is the WebSocket server | Use the native Tokio pattern above |
| Cross-window communication within the app | Use Tauri events/channels, or this pattern for high throughput |
11. Script Injection into Webview Windows
This section documents the script injection pattern for injecting JavaScript into dynamically created webview windows (including those loading external URLs).
The Pattern
Use WebviewWindowBuilder::initialization_script() to inject JavaScript that runs when the webview's page loads:
// src-tauri/src/lib.rs
// Load script from an external file (recommended for larger scripts)
let script_to_inject = include_str!("../extWebview.js");
WebviewWindowBuilder::new(&app_clone, window_label, url_clone)
.title("Remote Session")
.inner_size(1024.0, 768.0)
.initialization_script(script_to_inject)
.build();
The Injected Script
// extWebview.js
// This script runs automatically when the webview loads
window.addEventListener('DOMContentLoaded', () => {
console.log('Initializing injected socket link...');
const ws = new WebSocket('ws://127.0.0.1:8080');
ws.onopen = () => {
console.log('Injected socket connected!');
const payload = {
command: 'remote_action',
message: 'Hello from: ' + window.location.href
};
ws.send(JSON.stringify(payload));
};
ws.onmessage = (event) => {
console.log('RECEIVED FROM TAURI BACKEND:', JSON.parse(event.data));
// Respond with session data
ws.send(JSON.stringify({
localsession: window.localStorage.getItem('token') || 'no-session'
}));
};
ws.onerror = (err) => {
console.error('Injected socket error:', err);
};
// Expose socket globally for the page's own code to use
window.localAppSocket = ws;
});
Use Cases
- Bridge external pages: Allow pages loaded from external URLs to communicate with the Tauri backend via a local WebSocket connection.
- Data extraction: Inject scripts that read data from external pages (localStorage, DOM elements, cookies) and relay it back to the Tauri backend.
- UI modification: Modify the appearance or behavior of external pages loaded in a webview.
- Session sharing: Extract authentication tokens or session data from one webview and relay it to another.
Important Notes
- The injected script runs in the webview's JavaScript context, so it has access to that page's
window,document, andlocalStorage. - For security reasons, injected scripts on external URLs depend on CSP (Content Security Policy). Set
"csp": nullintauri.conf.jsonto disable CSP restrictions (use with caution). - Use
include_str!()to load scripts from files at compile time, keeping your Rust code clean.
12. State Management
Managed State (Tauri's Built-in)
Use tauri::State for shared state across commands:
use std::sync::Mutex;
use tauri::State;
struct AppState {
pub user: Mutex<Option<String>>,
pub settings: Mutex<Settings>,
}
#[tauri::command]
fn get_user(state: State<'_, AppState>) -> Option<String> {
state.user.lock().unwrap().clone()
}
#[tauri::command]
fn set_user(state: State<'_, AppState>, name: String) {
*state.user.lock().unwrap() = Some(name);
}
pub fn run() {
tauri::Builder::default()
.manage(AppState {
user: Mutex::new(None),
settings: Mutex::new(Settings::default()),
})
.invoke_handler(tauri::generate_handler![get_user, set_user])
// ...
}
Thread-safe types: Use Mutex<T> for simple cases, RwLock<T> for read-heavy workloads, or tokio::sync::Mutex<T> for async contexts.
Using the Store Plugin (Persistent KV Storage)
For data that persists across app restarts:
// Cargo.toml
tauri-plugin-store = "2"
// Frontend
import { Store } from '@tauri-apps/plugin-store';
const store = await Store.load('app-state.json');
await store.set('user-preferences', { theme: 'dark', language: 'en' });
await store.save();
// Read on next launch
const prefs = await store.get('user-preferences');
Recommended Patterns
| Data Type | Approach |
|---|---|
| Transient UI state (form inputs, selections) | Frontend state management (React state, Vue refs, etc.) |
| Shared app state across commands | tauri::State with Mutex/RwLock |
| Persistent user preferences | tauri-plugin-store |
| Structured persistent data (records, models) | tauri-plugin-sql with SQLite |
| Encrypted sensitive data | tauri-plugin-stronghold |
13. v1 to v2 Migration Reference
Automated Migration
npm install @tauri-apps/cli@latest
npm run tauri migrate
This automates config migration and generates capability files from the v1 allowlist. However, it is NOT a complete replacement for understanding the changes — manual review is still required.
Rust API Migration Map
| v1 Pattern | v2 Replacement |
|---|---|
tauri::api::dialog::* |
tauri-plugin-dialog |
tauri::api::fs::* |
std::fs (Rust) or tauri-plugin-fs (JS) |
tauri::api::http::* |
tauri-plugin-http |
tauri::api::path::*, tauri::PathResolver |
tauri::Manager::path() |
tauri::api::process::Command |
tauri-plugin-shell |
tauri::api::shell::* |
tauri-plugin-shell |
tauri::api::clipboard::* |
tauri-plugin-clipboard-manager |
tauri::api::notification::* |
tauri-plugin-notification |
tauri::api::global_shortcut::* |
tauri-plugin-global-shortcut |
tauri::api::updater::* |
tauri-plugin-updater |
tauri::api::cli::* |
tauri-plugin-cli |
tauri::api::os::* |
tauri-plugin-os |
tauri::api::version |
semver crate |
tauri::Window |
tauri::WebviewWindow |
tauri::WindowBuilder |
tauri::WebviewWindowBuilder |
tauri::WindowUrl |
tauri::WebviewUrl |
Manager::get_window() |
Manager::get_webview_window() |
App::window() |
App::get_webview_window() |
tauri::Menu |
tauri::menu::MenuBuilder |
tauri::MenuItem |
tauri::menu::PredefinedMenuItem |
tauri::CustomMenuItem |
tauri::menu::MenuItemBuilder |
tauri::Submenu |
tauri::menu::SubmenuBuilder |
tauri::SystemTray |
tauri::tray::TrayIconBuilder |
App::clipboard_manager() |
tauri_plugin_clipboard_manager::ClipboardExt |
App::global_shortcut_manager() |
tauri_plugin_global_shortcut::GlobalShortcutExt |
Manager::fs_scope() |
tauri_plugin_fs::FsExt |
emit() |
emit() (now broadcasts to ALL listeners) |
emit_to() |
NEW in v2 for targeted emission |
listen_global() |
Renamed to listen_any() |
JavaScript API Migration Map
| v1 Import | v2 Import |
|---|---|
@tauri-apps/api/tauri |
@tauri-apps/api/core |
@tauri-apps/api/window |
@tauri-apps/api/webviewWindow |
@tauri-apps/api/dialog |
@tauri-apps/plugin-dialog |
@tauri-apps/api/fs |
@tauri-apps/plugin-fs |
@tauri-apps/api/http |
@tauri-apps/plugin-http |
@tauri-apps/api/notification |
@tauri-apps/plugin-notification |
@tauri-apps/api/clipboard |
@tauri-apps/plugin-clipboard-manager |
@tauri-apps/api/shell |
@tauri-apps/plugin-shell |
@tauri-apps/api/global-shortcut |
@tauri-apps/plugin-global-shortcut |
@tauri-apps/api/updater |
@tauri-apps/plugin-updater |
@tauri-apps/api/os |
@tauri-apps/plugin-os |
@tauri-apps/api/process |
@tauri-apps/plugin-process |
@tauri-apps/api/cli |
@tauri-apps/plugin-cli |
tauri-plugin-* JS packages |
@tauri-apps/plugin-* |
Environment Variable Migration
| v1 | v2 |
|---|---|
TAURI_PRIVATE_KEY |
TAURI_SIGNING_PRIVATE_KEY |
TAURI_KEY_PASSWORD |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
TAURI_SKIP_DEVSERVER_CHECK |
TAURI_CLI_NO_DEV_SERVER_WAIT |
TAURI_DEV_SERVER_PORT |
TAURI_CLI_PORT |
TAURI_TRAY |
TAURI_LINUX_AYATANA_APPINDICATOR |
TAURI_APPLE_DEVELOPMENT_TEAM |
APPLE_DEVELOPMENT_TEAM |
TAURI_PLATFORM |
TAURI_ENV_PLATFORM |
TAURI_ARCH |
TAURI_ENV_ARCH |
TAURI_DEBUG |
TAURI_ENV_DEBUG |
Windows Origin URL Change
v1: https://tauri.localhost
v2: http://tauri.localhost
This means IndexedDB, LocalStorage, and Cookies from v1 apps will not carry over to v2. To keep the HTTPS scheme:
{
"app": {
"windows": [{ "useHttpsScheme": true }]
}
}
14. Common Anti-Patterns to Avoid
1. Using v1 APIs or Import Paths
// WRONG - v1 pattern (tauri::api was REMOVED)
use tauri::api::dialog;
use tauri::api::fs;
// CORRECT - v2 pattern
// Use std::fs in Rust, or tauri-plugin-fs on the frontend
// WRONG - v1 import path
import { invoke } from '@tauri-apps/api/tauri';
import { open } from '@tauri-apps/api/dialog';
// CORRECT - v2 import path
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
2. Using tauri.conf.json v1 Structure
// WRONG - v1 structure
{
"tauri": {
"allowlist": { ... },
"windows": [...],
"bundle": { ... }
},
"build": { "distDir": "../dist", "withGlobalTauri": true }
}
// CORRECT - v2 structure
{
"app": {
"security": { "capabilities": [...] },
"windows": [...],
"withGlobalTauri": true
},
"build": { "frontendDist": "../dist" },
"bundle": { ... }
}
3. Using Old Window Types
// WRONG - v1 types
let window = tauri::WindowBuilder::new(&app, "main", tauri::WindowUrl::App("index.html".into()));
app.get_window("main");
// CORRECT - v2 types
let window = tauri::WebviewWindowBuilder::new(&app, "main", tauri::WebviewUrl::App("index.html".into()));
app.get_webview_window("main");
4. Calling invoke_handler Multiple Times
// WRONG - only the last one takes effect
.invoke_handler(tauri::generate_handler![command_a])
.invoke_handler(tauri::generate_handler![command_b])
// CORRECT - all commands in a single call
.invoke_handler(tauri::generate_handler![command_a, command_b])
5. Using Borrowed Types in Async Commands
// WRONG - &str cannot cross async boundary
#[tauri::command]
async fn process(data: &str) -> String { ... }
// CORRECT - use owned types
#[tauri::command]
async fn process(data: String) -> String { ... }
// ALTERNATIVELY - wrap return in Result
#[tauri::command]
async fn process(data: &str) -> Result<String, ()> { ... }
6. Forgetting to Add Permissions for Plugins
Every plugin requires permissions to be granted in a capability file. Without them, the plugin's frontend API calls will silently fail or throw permission errors.
// WRONG - no permissions for the fs plugin
{
"permissions": ["core:default"]
}
// CORRECT - grant fs plugin permissions
{
"permissions": [
"core:default",
"fs:default"
]
}
7. Creating Windows Outside the Main Thread
// WRONG - window creation from async context without dispatch
#[tauri::command]
async fn open_window(app_handle: AppHandle) {
tauri::WebviewWindowBuilder::new(&app_handle, "new", WebviewUrl::App("page.html".into()))
.build(); // This will panic or fail!
}
// CORRECT - dispatch to main thread
#[tauri::command]
async fn open_window(app_handle: AppHandle) -> Result<(), String> {
app_handle.run_on_main_thread(move || {
let _ = tauri::WebviewWindowBuilder::new(
&app_handle,
"new",
tauri::WebviewUrl::App("page.html".into()),
)
.build();
}).map_err(|e| e.to_string())?;
Ok(())
}
8. Not Cleaning Up Event Listeners
// WRONG - listener leaks memory
const component = () => {
useEffect(() => {
listen('my-event', handler); // Never cleaned up!
}, []);
};
// CORRECT - clean up on unmount
const component = () => {
useEffect(() => {
const unlisten = listen('my-event', handler);
return () => { unlisten.then(fn => fn()); };
}, []);
};
15. Quick Reference Cheat Sheet
Tauri Builder Setup
tauri::Builder::default()
.plugin(plugin::init()) // Register plugins
.setup(|app| Ok(())) // Startup logic
.manage(State {}) // Inject state
.invoke_handler(tauri::generate_handler![ // Register commands
cmd1, cmd2, module::cmd3
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Command Definition
#[tauri::command]
async fn command_name(arg: String) -> Result<ReturnType, ErrorType> { ... }
Frontend Invoke
import { invoke } from '@tauri-apps/api/core';
const result = await invoke('command_name', { arg: 'value' });
Event System
// Rust
use tauri::{AppHandle, Emitter};
app_handle.emit("event-name", payload)?;
app_handle.emit_to("window-label", "event-name", payload)?;
// Frontend
import { listen, emit, emitTo } from '@tauri-apps/api/event';
const unlisten = await listen('event-name', (event) => { /* event.payload */ });
await emit('event-name', data);
await emitTo('window-label', 'event-name', data);
Window Management
use tauri::{WebviewWindowBuilder, WebviewUrl, Manager};
WebviewWindowBuilder::new(&app, "label", WebviewUrl::App("page.html".into()))
.title("Title").inner_size(800.0, 600.0).build()?;
app.get_webview_window("label")?.set_focus()?;
Key v2 Naming Rules
- Window type:
WebviewWindow(notWindow) - Window builder:
WebviewWindowBuilder(notWindowBuilder) - URL type:
WebviewUrl(notWindowUrl) - Get window:
get_webview_window()(notget_window()) - Core import:
@tauri-apps/api/core(not@tauri-apps/api/tauri) - Plugin imports:
@tauri-apps/plugin-<name>(not@tauri-apps/api/<name>) - Config section:
app(nottauri) - Security model: Capabilities + Permissions (not
allowlist)
Official Documentation Links
- Main Docs: https://v2.tauri.app
- Migration Guide: https://v2.tauri.app/start/migrate/from-tauri-1/
- Capabilities: https://v2.tauri.app/security/capabilities/
- Permissions: https://v2.tauri.app/security/permissions/
- Configuration: https://v2.tauri.app/develop/configuration-files/
- Calling Rust: https://v2.tauri.app/develop/calling-rust/
- Calling Frontend: https://v2.tauri.app/develop/calling-frontend/
- Plugin Index: https://v2.tauri.app/plugin/
- Rust Docs: https://docs.rs/tauri/latest/tauri/