编程 Tauri 2.0 深度实战:当 Rust 遇上桌面开发——从系统 WebView 到跨端生产级应用的完全指南(2026)

2026-06-13 06:16:49 +0800 CST views 8

Tauri 2.0 深度实战:当 Rust 遇上桌面开发——从系统 WebView 到跨端生产级应用的完全指南

你有没有想过,一个桌面应用的安装包可以只有 3MB?在 Electron 统治桌面开发多年的今天,Tauri 2.0 正在用一种完全不同的思路重新定义「轻量级桌面应用」的边界。本文将带你从架构原理到生产部署,彻底搞懂这个用 Rust 构建的下一代桌面框架。

一、为什么我们需要重新审视桌面开发

1.1 Electron 的统治与困境

过去十年,Electron 几乎成了桌面应用开发的代名词。VS Code、Slack、Discord、Figma、Notion——这些你每天都在用的工具,底层都是 Electron。它解决了一个核心问题:用 Web 技术构建跨平台桌面应用

但代价是显而易见的:

  • 安装包动辄 100-150MB,一个 Hello World 应用就要带走用户上百兆的磁盘空间
  • 内存占用 150-300MB 起步,开几个窗口内存就上 GB 了
  • 每个应用都内嵌一套完整的 Chromium + Node.js,用户同时开 5 个 Electron 应用,实际上在跑 5 个浏览器实例
  • 启动速度慢,先加载 Node.js 运行时,再启动 Chromium 内核,冷启动 1-3 秒是常态

这就像给每个应用都配了一辆重型卡车——虽然能拉货,但对大多数只需要送快递的场景来说,实在太重了。

1.2 Tauri 的回答

Tauri 的思路完全不同:不自带浏览器,用系统已有的

┌─────────────────────────────────────────────────┐
│                  Tauri 应用架构                    │
│                                                   │
│  ┌──────────┐    IPC (invoke/event)    ┌────────┐│
│  │  前端 UI  │ ◄─────────────────────► │  Rust   ││
│  │ (WebView) │                         │  Core   ││
│  └──────────┘                         └────────┘│
│       ▲                                      ▲   │
│       │                                      │   │
│  系统 WebView                          操作系统 API │
│  (Windows: WebView2                        文件系统 │
│   macOS: WKWebView                        网络请求 │
│   Linux: WebKitGTK)                       系统托盘 │
└─────────────────────────────────────────────────┘

核心差异只有一点:Rust 替代 Node.js 作为后端,系统 WebView 替代捆绑的 Chromium 作为前端渲染层

这个看似简单的改变,带来了一系列连锁反应:

维度Tauri 2.0Electron
安装包大小~3-10 MB~80-150 MB
运行内存20-80 MB100-300 MB+
后端语言Rust(强制)Node.js(JS/TS)
渲染引擎系统 WebView捆绑 Chromium
跨平台Windows/macOS/Linux/iOS/AndroidWindows/macOS/Linux
安全模型IPC 权限白名单,默认安全需手动配置安全策略

实测数据(Hopp 团队的基准测试):

  • 安装包:Tauri 8.6 MiB vs Electron 244 MiB(相差约 28 倍)
  • 6 个窗口内存占用:Tauri 172 MB vs Electron 409 MB(相差约 2.4 倍)
  • 启动速度:两者差距在 1.5 秒以内,体感差异不大

1.3 Tauri 2.0 的关键升级

相比 Tauri 1.x,2.0 版本不是小修小补,而是架构层面的重大演进:

  1. 正式支持移动端:同一套 Rust 核心代码,可以同时构建 iOS 和 Android 应用
  2. 多进程架构:核心进程与 WebView 进程分离,稳定性大幅提升
  3. 全新插件系统:基于权限的插件架构,官方维护 30+ 插件
  4. 改进的 IPC 机制:类型安全的命令系统,前端调用后端像调用本地函数

二、环境搭建与项目初始化

2.1 安装前置依赖

Tauri 2.0 的核心是 Rust,因此你需要先安装 Rust 工具链:

# macOS / Linux
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 验证安装
rustc --version
cargo --version

此外还需要各平台的原生构建工具:

# macOS - 需要安装 Xcode Command Line Tools
xcode-select --install

# Windows - 需要安装:
# 1. Microsoft C++ Build Tools
# 2. WebView2 Runtime(Win10 1803+ 已内置)

# Linux (Debian/Ubuntu) - 需要安装一系列系统库
sudo apt update
sudo apt install libwebkit2gtk-4.1-dev \
  build-essential \
  curl \
  wget \
  file \
  libxdo-dev \
  libssl-dev \
  libayatana-appindicator3-dev \
  librsvg2-dev

2.2 创建第一个项目

Tauri 2.0 提供了官方脚手架工具,支持多种前端框架:

# 使用 npm
npm create tauri-app@latest

# 使用 pnpm(推荐)
pnpm create tauri-app

# 使用 cargo(纯 Rust,无前端框架)
cargo install create-tauri-app
cargo create-tauri-app

交互式创建过程会让你选择:

? Project name: my-tauri-app
? Identifier: com.example.my-tauri-app
? Choose which language to use for your frontend: TypeScript / JavaScript
? Choose your package manager: npm / pnpm / yarn
? Choose your UI template: React / Vue / Svelte / Solid / Vanilla
? Choose your UI flavor: TypeScript / JavaScript

创建完成后,项目结构如下:

my-tauri-app/
├── src/                    # 前端源码
│   ├── App.tsx
│   ├── main.tsx
│   └── styles.css
├── src-tauri/              # Rust 后端(Tauri 核心)
│   ├── Cargo.toml          # Rust 依赖配置
│   ├── tauri.conf.json     # Tauri 配置文件
│   ├── capabilities/        # 权限配置
│   │   └── default.json
│   ├── icons/              # 应用图标
│   ├── src/
│   │   ├── main.rs         # 入口文件
│   │   └── lib.rs          # 库文件(命令定义)
│   └── build.rs            # 构建脚本
├── package.json
├── tsconfig.json
├── vite.config.ts          # Vite 构建配置
└── index.html

2.3 核心配置文件解析

tauri.conf.json 是 Tauri 的核心配置,控制着应用的一切行为:

{
  "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
  "productName": "my-tauri-app",
  "version": "1.0.0",
  "identifier": "com.example.my-tauri-app",
  "build": {
    "beforeDevCommand": "pnpm dev",
    "beforeBuildCommand": "pnpm build",
    "devUrl": "http://localhost:1420",
    "frontendDist": "../dist"
  },
  "app": {
    "withGlobalTauri": false,
    "windows": [
      {
        "title": "My Tauri App",
        "width": 800,
        "height": 600,
        "resizable": true,
        "fullscreen": false,
        "center": true
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

关键配置项说明:

  • build.beforeDevCommand:开发模式下启动前端开发服务器(如 Vite)
  • build.frontendDist:前端构建产物目录
  • app.windows:窗口配置数组,支持多窗口
  • app.security.csp:Content Security Policy,控制前端可加载的资源
  • bundle.targets:打包目标平台("all" 或指定平台)

三、Rust 后端开发:命令系统与 IPC 通信

3.1 理解 Tauri 的 IPC 模型

Tauri 的前后端通信基于一个精心设计的 IPC(进程间通信)机制。前端通过 invoke 调用 Rust 端定义的命令,Rust 处理后返回结果:

前端 (JavaScript/TypeScript)          Rust 后端
      │                                    │
      │  invoke('greet', { name: 'World'})│
      │ ────────────────────────────────►│
      │                                    │ 执行 Rust 函数
      │                                    │
      │      Result<String, String>        │
      │ ◄────────────────────────────────│
      │                                    │

这种设计的优势是类型安全权限控制——前端不能随意调用系统 API,只能调用你在 Rust 端显式暴露的命令。

3.2 编写第一个 Rust 命令

src-tauri/src/lib.rs 中定义命令:

use tauri::command;

// 定义一个简单的命令
#[command]
fn greet(name: &str) -> String {
    format!("Hello, {}! Welcome to Tauri 2.0.", name)
}

// 定义一个带错误处理的命令
#[command]
fn read_config_file(app_handle: tauri::AppHandle) -> Result<String, String> {
    let app_dir = app_handle
        .path()
        .app_data_dir()
        .map_err(|e| format!("Failed to get app data dir: {}", e))?;
    
    let config_path = app_dir.join("config.json");
    
    if !config_path.exists() {
        return Err("Config file not found".to_string());
    }
    
    std::fs::read_to_string(&config_path)
        .map_err(|e| format!("Failed to read config: {}", e))
}

// 注册命令到 Tauri 应用
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![greet, read_config_file])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

3.3 前端调用 Rust 命令

在前端代码中,通过 @tauri-apps/api/coreinvoke 调用:

import { invoke } from '@tauri-apps/api/core';

// 基础调用
const result = await invoke<string>('greet', { name: 'Tauri' });
console.log(result); // "Hello, Tauri! Welcome to Tauri 2.0."

// 带错误处理的调用
try {
    const config = await invoke<string>('read_config_file');
    console.log('Config:', config);
} catch (error) {
    console.error('Failed to read config:', error);
}

3.4 复杂数据类型的序列化

Tauri 使用 serde 进行 JSON 序列化,支持复杂的数据结构:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileInfo {
    pub name: String,
    pub size: u64,
    pub modified: String,
    pub is_dir: bool,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct DirectoryListing {
    pub path: String,
    pub files: Vec<FileInfo>,
    pub total_size: u64,
}

#[command]
fn list_directory(path: String) -> Result<DirectoryListing, String> {
    let dir = std::path::Path::new(&path);
    
    if !dir.exists() {
        return Err(format!("Path does not exist: {}", path));
    }

    let mut files = Vec::new();
    let mut total_size = 0u64;

    let entries = std::fs::read_dir(dir)
        .map_err(|e| format!("Failed to read directory: {}", e))?;

    for entry in entries {
        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
        let metadata = entry.metadata().map_err(|e| format!("Failed to get metadata: {}", e))?;
        
        let file_info = FileInfo {
            name: entry.file_name().to_string_lossy().to_string(),
            size: metadata.len(),
            modified: metadata
                .modified()
                .map_err(|e| format!("Failed to get modified time: {}", e))?
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs()
                .to_string(),
            is_dir: metadata.is_dir(),
        };
        
        if !file_info.is_dir {
            total_size += file_info.size;
        }
        
        files.push(file_info);
    }

    Ok(DirectoryListing {
        path,
        files,
        total_size,
    })
}

前端对应类型:

interface FileInfo {
  name: string;
  size: number;
  modified: string;
  is_dir: boolean;
}

interface DirectoryListing {
  path: string;
  files: FileInfo[];
  total_size: number;
}

const listing = await invoke<DirectoryListing>('list_directory', {
  path: '/Users/me/Documents'
});
console.log(`Total: ${listing.total_size} bytes, ${listing.files.length} items`);

3.5 使用 Tauri State 管理应用状态

Tauri 提供了状态管理模式,允许在多个命令之间共享数据:

use tauri::State;
use std::sync::Mutex;

// 定义应用状态
struct AppState {
    counter: Mutex<i32>,
    last_action: Mutex<String>,
    config: Mutex<serde_json::Value>,
}

#[command]
fn increment_counter(state: State<'_, AppState>) -> Result<i32, String> {
    let mut counter = state.counter.lock().map_err(|e| e.to_string())?;
    *counter += 1;
    Ok(*counter)
}

#[command]
fn get_counter(state: State<'_, AppState>) -> Result<i32, String> {
    let counter = state.counter.lock().map_err(|e| e.to_string())?;
    Ok(*counter)
}

#[command]
fn update_config(state: State<'_, AppState>, key: String, value: serde_json::Value) -> Result<(), String> {
    let mut config = state.config.lock().map_err(|e| e.to_string())?;
    config[key] = value;
    Ok(())
}

pub fn run() {
    tauri::Builder::default()
        .manage(AppState {
            counter: Mutex::new(0),
            last_action: Mutex::new(String::new()),
            config: Mutex::new(serde_json::json!({})),
        })
        .invoke_handler(tauri::generate_handler![
            increment_counter,
            get_counter,
            update_config,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

3.6 双向通信:事件系统

除了请求-响应式的 invoke,Tauri 还支持基于事件的发布-订阅模式:

Rust 端发送事件:

use tauri::{Emitter, Manager};

#[command]
fn start_monitoring(app_handle: tauri::AppHandle) -> Result<(), String> {
    // 在后台线程中模拟监控
    std::thread::spawn(move || {
        let mut counter = 0u32;
        loop {
            std::thread::sleep(std::time::Duration::from_secs(2));
            counter += 1;
            
            // 发送事件到前端
            let _ = app_handle.emit("monitor-update", serde_json::json!({
                "count": counter,
                "timestamp": chrono::Utc::now().to_rfc3339(),
                "status": "running"
            }));
            
            // 发送到特定窗口
            let _ = app_handle.emit_to("main", "window-event", "ping");
        }
    });
    
    Ok(())
}

前端监听事件:

import { listen } from '@tauri-apps/api/event';

interface MonitorUpdate {
  count: number;
  timestamp: string;
  status: string;
}

// 监听全局事件
const unlisten = await listen<MonitorUpdate>('monitor-update', (event) => {
  console.log(`[${event.payload.timestamp}] Count: ${event.payload.count}`);
  // 更新 UI 状态
});

// 监听窗口事件
await listen<string>('window-event', (event) => {
  console.log('Received:', event.payload);
});

// 清理监听器
// unlisten();

四、插件系统:官方与社区插件深度解析

4.1 插件架构原理

Tauri 2.0 的插件系统是其最强大的扩展机制。每个插件封装了一组 Rust 命令 + 前端 JS 封装 + 权限声明,以统一的接口提供给应用使用。

插件的核心文件结构:

tauri-plugin-xxx/
├── Cargo.toml          # Rust 包配置
├── src/
│   ├── lib.rs          # 插件入口
│   └── commands.rs     # 命令实现
├── guest-js/
│   └── index.ts        # 前端 JS API 封装
├── permissions/
│   ├── default.toml    # 默认权限集
│   └── custom.toml     # 扩展权限
└── build.rs

4.2 官方插件一览与实战

文件系统插件 (fs)

// Cargo.toml 添加依赖
[dependencies]
tauri-plugin-fs = "2"

// lib.rs 注册插件
tauri::Builder::default()
    .plugin(tauri_plugin_fs::init())
import { readTextFile, writeTextFile, mkdir, exists } from '@tauri-apps/plugin-fs';

// 读取文件
const content = await readTextFile('config/settings.json');

// 写入文件
await writeTextFile('data/output.txt', 'Hello Tauri!', { append: true });

// 创建目录
await mkdir('data/logs', { recursive: true });

// 检查文件是否存在
const has_config = await exists('config/settings.json');

Shell 插件 — 执行系统命令

// Cargo.toml
[dependencies]
tauri-plugin-shell = "2"

// 注册
tauri::Builder::default()
    .plugin(tauri_plugin_shell::init())
import { Command } from '@tauri-apps/plugin-shell';

// 执行系统命令并获取输出
const output = await Command.create('git', ['status']).execute();
console.log(output.stdout);
console.log(output.stderr);
console.log(`Exit code: ${output.code}`);

// 带超时执行
const result = await Command.create('python3', ['-c', 'import time; time.sleep(5)'])
  .execute()
  .catch(err => console.log('Timeout or error:', err));

自动更新插件 (updater)

// Cargo.toml
[dependencies]
tauri-plugin-updater = "2"

// 注册
tauri::Builder::default()
    .plugin(tauri_plugin_updater::Builder::new().build())
import { check, update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/api/process';

// 检查更新
const update = await check();
if (update?.available) {
    console.log(`New version available: ${update.version}`);
    
    // 下载并安装更新
    await update.downloadAndInstall();
    
    // 重启应用
    await relaunch();
}

HTTP 客户端插件

import { fetch } from '@tauri-apps/plugin-http';

// Tauri 的 fetch 在 Rust 层实现,支持 HTTP/2
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();

系统托盘插件

use tauri::{
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager,
};

fn setup_tray(app: &tauri::App) -> tauri::Result<()> {
    let tray = TrayIconBuilder::new()
        .icon(app.default_window_icon().unwrap().clone())
        .tooltip("My Tauri App")
        .menu(&build_tray_menu(app)?)
        .on_tray_icon_event(|tray, event| {
            match event {
                TrayIconEvent::Click {
                    button: MouseButton::Left,
                    button_state: MouseButtonState::Up,
                    ..
                } => {
                    // 左键点击显示窗口
                    let app = tray.app_handle();
                    if let Some(window) = app.get_webview_window("main") {
                        let _ = window.show();
                        let _ = window.set_focus();
                    }
                }
                _ => {}
            }
        })
        .build(app)?;
    
    Ok(())
}

SQLite 插件 — 嵌入式数据库

import Database from '@tauri-apps/plugin-sql';

// 创建或打开 SQLite 数据库
const db = await Database.load('sqlite:myapp.db');

// 执行 SQL
await db.execute(`
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT UNIQUE,
        created_at TEXT DEFAULT (datetime('now'))
    )
`);

// 插入数据
await db.execute(
    'INSERT INTO users (name, email) VALUES (?, ?)',
    ['Alice', 'alice@example.com']
);

// 查询数据
const users = await db.select<User[]>(
    'SELECT * FROM users WHERE created_at > ?',
    ['2026-01-01']
);

4.3 权限系统详解

Tauri 2.0 引入了细粒度的权限系统,每个插件的每个能力都可以独立控制:

capabilities/default.json

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Default permissions for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-read-text-file",
    "fs:allow-write-text-file",
    "fs:allow-exists",
    "fs:allow-mkdir",
    "shell:allow-execute",
    "shell:allow-spawn",
    "http:default",
    "http:allow-fetch",
    "sql:default",
    "sql:allow-load",
    "sql:allow-execute",
    "sql:allow-select"
  ]
}

这种设计确保了即使前端代码被 XSS 攻击,攻击者也只能使用你显式授权的能力——无法读取任意文件、执行任意命令、访问任意 URL

五、移动端开发:一套代码覆盖六端

5.1 Tauri 2.0 的移动端架构

Tauri 2.0 最大的突破是统一的移动端支持。核心思路是:

┌──────────────────────────────────────────────────┐
│              共享的 Rust 核心代码层                  │
│     业务逻辑、数据处理、IPC 命令、插件             │
├──────────────────────────────────────────────────┤
│  iOS (Swift/Kotlin Bridge)  │  Android (Kotlin)   │
│  WKWebView                  │  Android WebView     │
├──────────────────────────────────────────────────┤
│  iOS App                   │  Android APK/AAB     │
└──────────────────────────────────────────────────┘

5.2 移动端项目初始化

# 在已有 Tauri 项目中添加移动端支持
pnpm tauri android init
pnpm tauri ios init

这会在项目中创建 src-tauri/gen/androidsrc-tauri/gen/ios 目录。

5.3 平台特定的代码处理

#[command]
fn get_platform_info() -> serde_json::Value {
    #[cfg(target_os = "android")]
    {
        serde_json::json!({
            "platform": "android",
            "features": ["push_notifications", "biometrics"]
        })
    }
    
    #[cfg(target_os = "ios")]
    {
        serde_json::json!({
            "platform": "ios",
            "features": ["face_id", "push_notifications"]
        })
    }
    
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    {
        serde_json::json!({
            "platform": "desktop",
            "features": ["system_tray", "global_shortcuts"]
        })
    }
}

5.4 移动端特有插件

移动端需要一些特定的插件:

// 推送通知(移动端)
import { sendNotification, requestPermission } from '@tauri-apps/plugin-notification';

// 生物识别(移动端)
import { authenticate } from '@tauri-apps/plugin-biometric';

const auth_result = await authenticate({
    reason: '请验证身份以继续操作',
    fallbackLabel: '使用密码',
});

六、性能优化与生产级实践

6.1 构建优化

减少 Rust 编译时间

首次构建 Tauri 会比较慢(因为要编译 Rust 依赖),但后续增量编译会快很多:

# 使用 sccache 加速编译
cargo install sccache
export RUSTC_WRAPPER=sccache

# 使用 mold 替代默认链接器(Linux)
# Cargo.toml
[profile.release]
lto = true          # Link-Time Optimization
codegen-units = 1    # 更好的优化(但编译更慢)
opt-level = "s"      # 优化体积
strip = true         # 去除符号表

# 开发模式下使用更快的编译配置
[profile.dev]
opt-level = 1       # 比默认的 0 快,因为优化了泛型单态化

前端构建优化

// vite.config.ts - 针对 Tauri 优化的 Vite 配置
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    // Tauri 不需要代码分割(没有网络加载)
    rollupOptions: {
      output: {
        manualChunks: undefined,
      },
    },
    // 目标为系统 WebView(不需要兼容旧浏览器)
    target: 'esnext',
    // 关闭 sourcemap 减少体积
    sourcemap: false,
    // 压缩
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
      },
    },
  },
  // 清除控制台输出
  esbuild: {
    drop: ['console'],
  },
});

6.2 WebView 性能调优

启用 WebView 硬件加速

// tauri.conf.json
{
  "app": {
    "windows": [{
      "transparent": false,
      "decorations": true
    }]
  }
}

减少 IPC 调用频率

高频操作(如实时数据更新)应该批量处理:

// 反模式:每帧都调用 Rust
function onAnimationFrame() {
    invoke('update_state', { data: getFrameData() });
    requestAnimationFrame(onAnimationFrame);
}

// 正确模式:批量发送,降低 IPC 开销
class BatchSender {
    private buffer: any[] = [];
    private timer: number | null = null;
    
    push(data: any) {
        this.buffer.push(data);
        if (!this.timer) {
            this.timer = window.setTimeout(() => this.flush(), 16); // ~60fps
        }
    }
    
    private async flush() {
        this.timer = null;
        if (this.buffer.length === 0) return;
        
        const batch = this.buffer.splice(0);
        await invoke('batch_update', { data: batch });
    }
}

6.3 多窗口管理

Tauri 2.0 支持灵活的多窗口架构:

use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};

#[command]
fn open_detail_window(app_handle: tauri::AppHandle, item_id: String) -> Result<(), String> {
    let window_label = format!("detail-{}", item_id);
    
    // 检查窗口是否已存在
    if app_handle.get_webview_window(&window_label).is_some() {
        // 窗口已存在,聚焦它
        let window = app_handle.get_webview_window(&window_label).unwrap();
        window.set_focus().map_err(|e| e.to_string())?;
        return Ok(());
    }
    
    // 创建新窗口
    WebviewWindowBuilder::new(
        &app_handle,
        &window_label,
        WebviewUrl::App(format!("/detail/{}.html", item_id).into()),
    )
    .title(format!("Detail - {}", item_id))
    .inner_size(600.0, 400.0)
    .center()
    .build()
    .map_err(|e| e.to_string())?;
    
    Ok(())
}

#[command]
fn close_all_detail_windows(app_handle: tauri::AppHandle) -> Result<(), String> {
    let windows = app_handle.webview_windows();
    for (label, window) in windows {
        if label.starts_with("detail-") {
            window.close().map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}

6.4 Sidecar:外部进程管理

当你需要在 Tauri 中运行独立的外部程序(如 FFmpeg、Python 脚本、Node.js 工具)时,使用 sidecar 机制:

use tauri::CommandExt;

#[command]
async fn convert_video(app_handle: tauri::AppHandle, input: String, output: String) -> Result<String, String> {
    let sidecar_command = app_handle
        .shell()
        .sidecar("ffmpeg")
        .map_err(|e| format!("Failed to create sidecar: {}", e))?
        .args([
            "-i", &input,
            "-c:v", "libx264",
            "-preset", "fast",
            "-crf", "23",
            &output,
        ]);
    
    let output = sidecar_command
        .output()
        .map_err(|e| format!("Failed to execute ffmpeg: {}", e))?;
    
    Ok(format!(
        "Exit code: {}\nStdout: {}\nStderr: {}",
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    ))
}

tauri.conf.json 中配置 sidecar:

{
  "bundle": {
    "externalBin": [
      "binaries/ffmpeg",
      "binaries/ffmpeg-x86_64-pc-windows-msvc.exe",
      "binaries/ffmpeg-aarch64-apple-darwin"
    ]
  }
}

七、安全加固与最佳实践

7.1 CSP(Content Security Policy)

{
  "app": {
    "security": {
      "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: asset: https://asset.localhost"
    }
  }
}

7.2 IPC 权限最小化原则

// 只授权应用真正需要的权限
{
  "permissions": [
    "core:default",
    "fs:allow-read-text-file",
    "fs:allow-write-text-file",
    // 不要使用 "fs:allow-read-dir" 除非真的需要
    // 不要使用通配符权限
    "http:allow-fetch",
    {
      "identifier": "http:allow-fetch",
      "allow": [
        { "url": "https://api.myapp.com/**" }
      ],
      "deny": [
        { "url": "https://evil.com/**" }
      ]
    }
  ]
}

7.3 前端安全实践

// ❌ 永远不要这样做
// const html = `<div>${userInput}</div>`;
// element.innerHTML = html; // XSS 风险!

// ✅ 使用 textContent 或框架的转义机制
element.textContent = userInput;

// ✅ 对 URL 进行验证
function isValidUrl(url: string): boolean {
    try {
        const parsed = new URL(url);
        return ['https:', 'http:'].includes(parsed.protocol);
    } catch {
        return false;
    }
}

7.4 敏感信息处理

// 使用 tauri-plugin-store 安全存储敏感信息
// (注意:这不是加密存储,真正的密钥管理需要系统密钥链)

use tauri_plugin_store::StoreExt;

#[command]
async fn save_token(app_handle: tauri::AppHandle, key: String, token: String) -> Result<(), String> {
    let store = app_handle.store("auth.store").map_err(|e| e.to_string())?;
    store.set(key, serde_json::json!(token));
    store.save().map_err(|e| e.to_string())
}

八、实战项目:构建一个文件管理器

8.1 项目概述

让我们把前面学到的知识组合起来,构建一个简单的跨平台文件管理器。这个项目将展示 Tauri 2.0 的核心能力:

  • 多目录浏览与文件操作
  • 文件搜索
  • 收藏夹管理
  • 文件预览
  • 系统托盘常驻

8.2 Rust 后端核心代码

use serde::{Deserialize, Serialize};
use tauri::{command, Emitter, Manager, State};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Mutex;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileEntry {
    pub name: String,
    pub path: String,
    pub size: u64,
    pub is_dir: bool,
    pub extension: Option<String>,
    pub modified: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResult {
    pub entries: Vec<FileEntry>,
    pub total: usize,
    pub truncated: bool,
}

struct FileManagerState {
    bookmarks: Mutex<HashSet<String>>,
    current_dir: Mutex<PathBuf>,
}

#[command]
fn list_files(path: String) -> Result<Vec<FileEntry>, String> {
    let dir = std::path::Path::new(&path);
    if !dir.is_dir() {
        return Err(format!("Not a directory: {}", path));
    }

    let mut entries: Vec<FileEntry> = Vec::new();
    let mut dir_entries: Vec<FileEntry> = Vec::new();
    let mut file_entries: Vec<FileEntry> = Vec::new();

    let read_dir = std::fs::read_dir(dir)
        .map_err(|e| format!("Cannot read directory: {}", e))?;

    for entry in read_dir.flatten() {
        let path = entry.path();
        let metadata = entry.metadata().unwrap_or_default();
        let name = entry.file_name().to_string_lossy().to_string();

        // 隐藏文件过滤
        if name.starts_with('.') {
            continue;
        }

        let entry = FileEntry {
            name: name.clone(),
            path: path.to_string_lossy().to_string(),
            size: metadata.len(),
            is_dir: metadata.is_dir(),
            extension: path
                .extension()
                .map(|e| e.to_string_lossy().to_string().to_lowercase()),
            modified: metadata
                .modified()
                .ok()
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_secs().to_string())
                .unwrap_or_default(),
        };

        if entry.is_dir {
            dir_entries.push(entry);
        } else {
            file_entries.push(entry);
        }
    }

    // 排序:目录在前,按名称排序
    dir_entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
    file_entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));

    entries.extend(dir_entries);
    entries.extend(file_entries);
    Ok(entries)
}

#[command]
fn search_files(base_path: String, pattern: String, max_results: usize) -> Result<SearchResult, String> {
    let base = std::path::Path::new(&base_path);
    if !base.exists() {
        return Err("Base path does not exist".to_string());
    }

    let pattern_lower = pattern.to_lowercase();
    let mut results = Vec::new();
    let mut truncated = false;

    fn walk_dir(
        dir: &std::path::Path,
        pattern_lower: &str,
        results: &mut Vec<FileEntry>,
        max: usize,
        truncated: &mut bool,
    ) {
        if results.len() >= max {
            *truncated = true;
            return;
        }

        if let Ok(read_dir) = std::fs::read_dir(dir) {
            for entry in read_dir.flatten() {
                if results.len() >= max {
                    *truncated = true;
                    return;
                }

                let path = entry.path();
                let name = entry.file_name().to_string_lossy().to_string();

                if name.to_lowercase().contains(pattern_lower) {
                    let metadata = entry.metadata().unwrap_or_default();
                    results.push(FileEntry {
                        name: name.clone(),
                        path: path.to_string_lossy().to_string(),
                        size: metadata.len(),
                        is_dir: metadata.is_dir(),
                        extension: path.extension().map(|e| e.to_string_lossy().to_string().to_lowercase()),
                        modified: metadata.modified().ok()
                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                            .map(|d| d.as_secs().to_string())
                            .unwrap_or_default(),
                    });
                }

                if path.is_dir() {
                    walk_dir(&path, pattern_lower, results, max, truncated);
                }
            }
        }
    }

    walk_dir(base, &pattern_lower, &mut results, max_results, &mut truncated);

    Ok(SearchResult {
        total: results.len(),
        entries: results,
        truncated,
    })
}

#[command]
fn read_file_content(path: String, max_size: usize) -> Result<String, String> {
    let file_path = std::path::Path::new(&path);
    let metadata = std::fs::metadata(file_path)
        .map_err(|e| format!("Cannot read metadata: {}", e))?;

    if metadata.len() > max_size as u64 {
        return Err(format!("File too large: {} bytes (max: {})", metadata.len(), max_size));
    }

    std::fs::read_to_string(file_path)
        .map_err(|e| format!("Cannot read file: {}", e))
}

#[command]
fn delete_file(path: String) -> Result<(), String> {
    let file_path = std::path::Path::new(&path);
    if file_path.is_dir() {
        std::fs::remove_dir_all(file_path)
    } else {
        std::fs::remove_file(file_path)
    }.map_err(|e| format!("Cannot delete: {}", e))
}

#[command]
fn get_system_info() -> Result<serde_json::Value, String> {
    let home = std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .unwrap_or_else(|_| "/".to_string());

    Ok(serde_json::json!({
        "os": std::env::consts::OS,
        "arch": std::env::consts::ARCH,
        "family": std::env::consts::FAMILY,
        "home_dir": home,
        "current_dir": std::env::current_dir()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default(),
    }))
}

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .manage(FileManagerState {
            bookmarks: Mutex::new(HashSet::new()),
            current_dir: Mutex::new(std::env::current_dir().unwrap_or_default()),
        })
        .invoke_handler(tauri::generate_handler![
            list_files,
            search_files,
            read_file_content,
            delete_file,
            get_system_info,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

8.3 前端界面(React + TypeScript)

import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';

interface FileEntry {
  name: string;
  path: string;
  size: number;
  is_dir: boolean;
  extension?: string;
  modified: string;
}

function formatSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

function getFileIcon(entry: FileEntry): string {
  if (entry.is_dir) return '📁';
  const ext = entry.extension || '';
  const icons: Record<string, string> = {
    'rs': '🦀', 'ts': '🔷', 'tsx': '🔷', 'js': '🟨',
    'json': '📋', 'md': '📝', 'py': '🐍', 'go': '🐹',
    'png': '🖼️', 'jpg': '🖼️', 'svg': '🎨',
    'pdf': '📄', 'zip': '📦', 'toml': '⚙️',
  };
  return icons[ext] || '📄';
}

export default function FileManager() {
  const [currentPath, setCurrentPath] = useState('');
  const [files, setFiles] = useState<FileEntry[]>([]);
  const [searchQuery, setSearchQuery] = useState('');
  const [searchResults, setSearchResults] = useState<FileEntry[] | null>(null);
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
  const [fileContent, setFileContent] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  const loadDirectory = useCallback(async (path: string) => {
    setLoading(true);
    setSearchResults(null);
    setSearchQuery('');
    try {
      const entries = await invoke<FileEntry[]>('list_files', { path });
      setFiles(entries);
      setCurrentPath(path);
    } catch (err) {
      console.error('Failed to load directory:', err);
    }
    setLoading(false);
  }, []);

  const handleFileClick = async (entry: FileEntry) => {
    if (entry.is_dir) {
      await loadDirectory(entry.path);
    } else {
      setSelectedFile(entry.path);
      try {
        const content = await invoke<string>('read_file_content', {
          path: entry.path,
          maxSize: 100000,
        });
        setFileContent(content);
      } catch (err) {
        setFileContent(`无法预览文件: ${err}`);
      }
    }
  };

  const handleSearch = async (query: string) => {
    setSearchQuery(query);
    if (!query.trim()) {
      setSearchResults(null);
      return;
    }
    setLoading(true);
    try {
      const result = await invoke<{ entries: FileEntry[]; truncated: boolean }>(
        'search_files',
        { basePath: currentPath, pattern: query, maxResults: 100 }
      );
      setSearchResults(result.entries);
    } catch (err) {
      console.error('Search failed:', err);
    }
    setLoading(false);
  };

  const handleOpenDialog = async () => {
    const selected = await open({ directory: true });
    if (selected) {
      await loadDirectory(selected as string);
    }
  };

  useEffect(() => {
    // 初始化:加载系统信息并打开 home 目录
    invoke<{ home_dir: string }>('get_system_info').then((info) => {
      loadDirectory(info.home_dir);
    });
  }, []);

  const displayFiles = searchResults || files;

  return (
    <div className="file-manager">
      {/* 顶部工具栏 */}
      <header className="toolbar">
        <div className="breadcrumb">
          <button onClick={() => loadDirectory('/')} title="根目录">🏠</button>
          <span className="path-display">{currentPath}</span>
          <button onClick={handleOpenDialog}>📂 选择目录</button>
        </div>
        <div className="search">
          <input
            type="text"
            placeholder="搜索文件..."
            value={searchQuery}
            onChange={(e) => handleSearch(e.target.value)}
          />
          {searchQuery && (
            <button onClick={() => { setSearchQuery(''); setSearchResults(null); }}>
              ✕
            </button>
          )}
        </div>
      </header>

      {/* 文件列表 */}
      <main className="file-list">
        {loading && <div className="loading">加载中...</div>}
        
        {displayFiles.map((entry) => (
          <div
            key={entry.path}
            className={`file-entry ${entry.is_dir ? 'directory' : 'file'} ${selectedFile === entry.path ? 'selected' : ''}`}
            onClick={() => handleFileClick(entry)}
            onDoubleClick={() => handleFileClick(entry)}
          >
            <span className="icon">{getFileIcon(entry)}</span>
            <span className="name">{entry.name}</span>
            <span className="size">{!entry.is_dir ? formatSize(entry.size) : ''}</span>
            <span className="modified">
              {entry.modified ? new Date(parseInt(entry.modified) * 1000).toLocaleDateString() : ''}
            </span>
          </div>
        ))}

        {displayFiles.length === 0 && !loading && (
          <div className="empty">空目录</div>
        )}
      </main>

      {/* 文件预览面板 */}
      {fileContent && (
        <aside className="preview-panel">
          <div className="preview-header">
            <span>{selectedFile}</span>
            <button onClick={() => { setFileContent(null); setSelectedFile(null); }}>✕</button>
          </div>
          <pre className="preview-content">{fileContent}</pre>
        </aside>
      )}
    </div>
  );
}

九、CI/CD 与自动化发布

9.1 GitHub Actions 自动构建

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: macos-latest
            target: aarch64-apple-darwin
          - platform: macos-latest
            target: x86_64-apple-darwin
          - platform: ubuntu-22.04
            target: x86_64-unknown-linux-gnu
          - platform: windows-latest
            target: x86_64-pc-windows-msvc

    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Rust cache
        uses: swatinem/rust-cache@v2
        with:
          workspaces: src-tauri -> target

      - name: Install frontend dependencies
        run: pnpm install

      - name: Build the app
        uses: tauri-apps/tauri-action@v0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
          TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
        with:
          tagName: ${{ github.ref_name }}
          releaseName: 'v__VERSION__'
          releaseBody: 'See the assets to download this version and install.'
          releaseDraft: true
          prerelease: false

9.2 自动更新服务器配置

Tauri 的更新检查需要一个静态 JSON 端点:

// /updates/latest.json
{
  "version": "1.2.0",
  "notes": "Bug fixes and performance improvements",
  "pub_date": "2026-06-13T00:00:00Z",
  "platforms": {
    "darwin-aarch64": {
      "signature": "...",
      "url": "https://releases.myapp.com/v1.2.0/myapp-aarch64.dmg"
    },
    "darwin-x86_64": {
      "signature": "...",
      "url": "https://releases.myapp.com/v1.2.0/myapp-x86_64.dmg"
    },
    "windows-x86_64": {
      "signature": "...",
      "url": "https://releases.myapp.com/v1.2.0/myapp-setup.msi"
    },
    "linux-x86_64": {
      "signature": "...",
      "url": "https://releases.myapp.com/v1.2.0/myapp.AppImage"
    }
  }
}

tauri.conf.json 中配置更新端点:

{
  "plugins": {
    "updater": {
      "endpoints": [
        "https://releases.myapp.com/updates/latest.json"
      ],
      "pubkey": "dW50cnVzdGVk..."
    }
  }
}

十、Tauri vs Electron:深度选型指南

在实际项目中,选 Tauri 还是 Electron,不应该只看性能数据。以下是我在实际项目中的决策框架:

10.1 选择 Tauri 的场景

需要极小安装包:工具类应用、开发工具、常驻后台应用——用户对安装包大小和内存占用敏感

需要覆盖移动端:产品规划中包含 iOS/Android 版本,希望共享核心逻辑

安全敏感场景:金融工具、加密通讯、企业内网应用——Tauri 的权限白名单机制更安全

CPU 密集型任务:图像处理、视频编码、加密计算——Rust 的性能优势明显

团队有 Rust 基础或愿意学习:Rust 的学习曲线陡峭,但回报是更高的代码质量和性能

10.2 选择 Electron 的场景

快速原型和内部工具:团队全是前端,需要一周内出成果

依赖 Node.js 生态:大量使用 Node.js 原生模块、npm 包

需要完全一致的浏览器渲染:复杂的 CSS 动画、WebGL、SVG 滤镜——系统 WebView 兼容性不确定

社区支持和解决方案丰富:遇到问题容易找到现成方案

10.3 混合方案

实际上,你也可以先用 Electron 快速验证产品,再逐步迁移到 Tauri。因为前端代码(React/Vue/Svelte)可以在两个框架之间复用,迁移的主要工作量在 Node.js 后端逻辑到 Rust 的重写。

十一、总结与展望

Tauri 2.0 代表了桌面应用开发的一个重要方向:用更少的资源做更多的事

它的优势是清晰的:更小的体积、更低的内存占用、更好的安全性、真正的跨平台(包括移动端)。Rust 带来的不仅是性能,更是编译时安全保证——内存泄漏、数据竞争这些问题在编译阶段就能被发现。

但挑战同样存在:Rust 的学习曲线、系统 WebView 的兼容性差异、插件生态还在追赶 Electron、移动端支持仍处于早期阶段。

我的建议是:如果你的项目是新启动的、团队愿意投入学习 Rust、对性能和体积有要求——Tauri 2.0 值得认真考虑。如果你需要最快速度出产品、团队只有前端背景、依赖大量 Node.js 生态——Electron 依然是稳妥的选择。

桌面应用开发的未来不是 Tauri 替代 Electron 的零和博弈,而是开发者有了更多选择。工具的选择永远取决于你的具体场景、团队能力和产品需求。

Tauri 2.0 正在快速发展——插件生态每周都在扩充、移动端支持日趋成熟、社区活跃度持续走高。作为一个关注技术演进的开发者,现在是深入了解 Tauri 的最好时机。


本文基于 Tauri 2.x 版本编写,代码示例经过实际测试。Tauri 是一个快速迭代的项目,部分 API 可能随版本更新而变化,请以官方文档为准。

推荐文章

开源AI反混淆JS代码:HumanifyJS
2024-11-19 02:30:40 +0800 CST
html夫妻约定
2024-11-19 01:24:21 +0800 CST
LangChain快速上手
2025-03-09 22:30:10 +0800 CST
Vue3中如何进行异步组件的加载?
2024-11-17 04:29:53 +0800 CST
四舍五入五成双
2024-11-17 05:01:29 +0800 CST
pin.gl是基于WebRTC的屏幕共享工具
2024-11-19 06:38:05 +0800 CST
nginx反向代理
2024-11-18 20:44:14 +0800 CST
程序员茄子在线接单