tauri2-guide/tauri2-guide/definitive-guide.md
Z User 5e7cbbae47 Remove all moxie-app references — make guides standalone
All documents now reference only Tauri 2.0 official patterns and APIs
with no dependency on any specific reference application.
2026-05-30 19:29:03 +00:00

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

  1. Introduction & Architecture Overview
  2. Project Structure & Scaffolding
  3. Configuration System (tauri.conf.json)
  4. Capabilities & Permissions (Security Model)
  5. Rust Backend: lib.rs & Commands
  6. Frontend Integration Patterns
  7. IPC: Commands, Events & Channels
  8. Window Management
  9. Plugin Ecosystem
  10. Native WebSocket Server (Rust-side)
  11. Script Injection into Webview Windows
  12. State Management
  13. v1 to v2 Migration Reference
  14. Common Anti-Patterns to Avoid
  15. 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.rs pattern) 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. Use desktop-schema.json for desktop, mobile-schema.json for 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 permissions
  • core:path:default — Path resolution
  • core:event:default — Event system (includes listen/emit)
  • core:event:allow-listen — Allow listening to events
  • core:event:allow-emit — Allow emitting events
  • core:window:default — Window management
  • core:window:allow-set-title — Allow changing window title

Plugin permissions:

  • fs:allow-read, fs:allow-write, fs:allow-exists, fs:allow-mkdir, fs:allow-remove
  • http:allow-request — HTTP client requests
  • dialog:allow-open, dialog:allow-save, dialog:allow-message
  • shell:allow-open, shell:allow-execute
  • notification:allow-notify, notification:allow-is-permission-granted
  • clipboard-manager:allow-read-text, clipboard-manager:allow-write-text
  • websocket:allow-connect, websocket:allow-send
  • store: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_handler can only be called once. All commands must be in a single generate_handler![] call.
  • Commands defined in lib.rs cannot be marked pub.
  • Commands defined in separate modules must be marked pub.
  • The #[cfg_attr(mobile, tauri::mobile_entry_point)] attribute is required for the run() 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, ()> { /* ... */ }
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

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, and localStorage.
  • For security reasons, injected scripts on external URLs depend on CSP (Content Security Policy). Set "csp": null in tauri.conf.json to 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');
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 (not Window)
  • Window builder: WebviewWindowBuilder (not WindowBuilder)
  • URL type: WebviewUrl (not WindowUrl)
  • Get window: get_webview_window() (not get_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 (not tauri)
  • Security model: Capabilities + Permissions (not allowlist)