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.0 | Electron |
|---|---|---|
| 安装包大小 | ~3-10 MB | ~80-150 MB |
| 运行内存 | 20-80 MB | 100-300 MB+ |
| 后端语言 | Rust(强制) | Node.js(JS/TS) |
| 渲染引擎 | 系统 WebView | 捆绑 Chromium |
| 跨平台 | Windows/macOS/Linux/iOS/Android | Windows/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 版本不是小修小补,而是架构层面的重大演进:
- 正式支持移动端:同一套 Rust 核心代码,可以同时构建 iOS 和 Android 应用
- 多进程架构:核心进程与 WebView 进程分离,稳定性大幅提升
- 全新插件系统:基于权限的插件架构,官方维护 30+ 插件
- 改进的 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/core 的 invoke 调用:
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/android 和 src-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 可能随版本更新而变化,请以官方文档为准。