Tauri 2.x 系列(一):架构全景与最小闭环——从 WebView 到 EMS 后台
核心目标:理解 Tauri、WebView、Rust Core、Python sidecar 与操作系统的职责边界;完成一次可观察、可失败、可定位的前端 → Rust → Python → FastAPI 最小闭环。
前置知识:会使用 TypeScript 和命令行,能读懂基础 Rust/Python;不要求预先掌握 Tauri。
验证基线:EMS Simulate 5.0.0;Tauri 2.11.2、Tauri JS API 2.11.0、Rust 1.95.0、Node.js 22.12.0、Python 3.11.6、Windows 11 x86_64。版本来自项目锁文件与本机工具链,最后复核日期:2026-08-26。
🚀 配套实战项目:EMS Simulate(能源管理系统模拟器)
为了避免只讲零散 API,本系列统一使用我开发并持续维护的 EMS Simulate 作为贯穿案例:它是一款免费开源的工业协议仿真软件,支持 IEC 60870-5-104、IEC 61850、Modbus TCP/RTU、DL/T 645 等主流协议,可模拟 PCS 储能变流器、BMS 电池管理系统、电表等真实设备,并提供四遥(YC/YX/YK/YT)配置和报文实时查看。结合 Wireshark,读者可以直接观察协议报文,并验证 Tauri 界面、Python 后台、系统能力和安装包之间的完整调用链。
- 📦 GitHub 开源仓库(欢迎 Star ⭐)
- 📖 在线技术文档
- 🏪 Microsoft Store(Windows 10/11 免配置安装)
0. 问题场景:双击图标之后,究竟是谁启动了 EMS?
EMS Simulate 的界面使用 Vue 3,业务后台使用 Python/FastAPI,桌面壳使用 Tauri/Rust。用户双击应用后看到的是一个窗口,但窗口背后至少有两条调用链:
如果不先理解这张图,许多故障都会被笼统地描述成“客户端打不开”:
- 窗口已经创建,但 WebView2 不可用;
- loading 页面正常,Python sidecar 没有找到;
- Python 进程存在,但 FastAPI 尚未监听端口;
/api/health成功,Vue 静态文件却没有打进 PyInstaller;- Vue 页面正常,Tauri command 没有注册;
- 开发模式可用,MSIX 安装后因为路径和进程创建方式不同而失败。
本篇先建立分层模型,再从 EMS Simulate 抽取最小启动闭环。目标不是立刻解释所有业务,而是能够回答三个问题:
- 当前代码运行在哪个进程?
- 数据跨越了哪条边界?
- 失败证据应该去哪一层寻找?
1. Tauri 是什么,不是什么
1.1 Tauri 提供的是“桌面应用核心 + WebView”,不是完整业务架构
一个典型 Tauri 桌面应用由两部分组成:
| 部分 | 常见技术 | 主要职责 |
|---|---|---|
| Web 前端 | Vue、React、Svelte、原生 TS | 界面、交互、局部状态 |
| Rust Core | Tauri、Rust crate、插件 | 窗口、生命周期、系统 API、可信命令 |
Tauri 不要求业务后台必须使用 Rust。EMS Simulate 就是一个有代表性的组合:
- Vue 负责设备树、测点、协议操作和报文展示;
- FastAPI 负责 REST、WebSocket 和静态页面;
- Python 负责设备模拟、SQLAlchemy 数据访问以及工业协议;
- Rust 负责单实例、动态端口、sidecar 生命周期、系统文件和窗口。
因此,“Tauri 做客户端壳”并不等于 Rust 只剩一个空窗口。更准确的说法是:
Rust Core 是桌面应用的可信控制面;Python/FastAPI 是 EMS 业务数据面;Vue 是用户交互面。
1.2 Tauri 不内置 Chromium
Tauri 使用操作系统提供的 WebView:
| 平台 | 主要 WebView | 工程影响 |
|---|---|---|
| Windows | WebView2 | 依赖设备上的 WebView2 Runtime |
| macOS | WKWebView | 跟随系统 WebKit 与 macOS 能力 |
| Linux | WebKitGTK | 依赖发行版系统包和桌面环境 |
这通常能减少应用自身需要携带的渲染运行时,但也意味着:
- 不同机器的 WebView 版本可能不同;
- Linux 需要正确声明 WebKitGTK 等系统依赖;
- 浏览器里能运行的代码仍要在目标 WebView 上验证;
- “安装包小”不能推出“所有机器表现完全一致”。
1.3 Tauri 不是任意系统权限的快捷通道
WebView 中运行的是前端代码。即使这些代码随应用发布,也不能假定它永远可信:XSS、依赖污染、未清洗的设备名称或 SCL 字段,都可能让攻击者控制前端执行流。
真正能够访问文件、进程和系统 API 的 Rust/plugin 层必须:
- 只暴露业务需要的 command;
- 对路径、URL、参数和数据大小再次校验;
- 使用 capabilities/permissions 收窄前端可调用能力;
- 不把
shell、任意文件路径或任意 URL 全量开放。
2. Tauri 的五层运行模型
2.1 分层职责
| 层 | 能看到什么 | 不应该承担什么 |
|---|---|---|
| Vue/React | 组件、路由、用户操作 | 任意系统命令、进程治理 |
| WebView | DOM、Fetch、浏览器 API | Python 进程所有权 |
| Tauri IPC | 序列化后的命令和事件 | 自动理解 EMS 业务 |
| Rust Core | command、AppHandle、插件、系统资源 | 重写全部工业协议 |
| OS | 窗口、文件、进程、网络栈 | 理解 Vue 或设备模型 |
2.2 一次 invoke() 发生了什么
前端调用:
import { invoke } from "@tauri-apps/api/core";
const url = await invoke<string>("get_backend_url");
Rust 注册并处理:
#[tauri::command]
fn get_backend_url() -> Result<String, String> {
Ok("http://127.0.0.1:50001".to_string())
}
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![get_backend_url]);
调用链为:
invoke() 不是普通 HTTP 请求。它通过 Tauri IPC 到达 Rust command。EMS Simulate 的设备列表、测点和协议操作则主要通过 HTTP/WebSocket 到达 FastAPI。这两条通道必须分清:
| 调用 | 通道 | 原因 |
|---|---|---|
get_backend_url |
Tauri IPC | URL 由 Rust 当前实例管理 |
restart_backend |
Tauri IPC | Rust 持有 sidecar 进程句柄 |
| 查询设备/测点 | HTTP | 属于 FastAPI 业务 API |
| 实时报文/状态 | WebSocket | 连续双向/推送数据 |
| 保存导出文件 | Dialog + Tauri command | 需要系统选择器与本地写入 |
3. 两种前端资源模型
3.1 官方推荐的静态资源模型
通常 Tauri 在构建时将 Vite 输出目录打进应用:
{
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
}
}
开发阶段从 Vite dev server 加载,生产阶段从 bundle 内静态资源加载。这个模式边界简单,是大多数 SPA 的首选。
3.2 EMS Simulate 的两阶段模型
EMS Simulate 的 frontendDist 指向 src-tauri/loading。真正的 Vue 构建产物由 FastAPI sidecar 托管:
选择它有明确原因:
- 同一份 Vue/FastAPI 应用可继续作为 Web 版本运行;
- REST、WebSocket 和页面同源,前端 API 组织简单;
- FastAPI 可以统一托管业务 API 与静态页面。
代价也必须承认:
- UI 可用依赖 Python sidecar 成功启动;
- localhost 端口、CSP、Host/Origin 和鉴权成为安全边界;
- loading 页面和业务页面是两套资源;
- 后台重启必须尽量保持 origin 稳定;
- 安装包必须同时包含 Vue
www、Python 代码和动态库。
这不是 SSR。Vue 仍是静态 SPA,只是静态文件由 FastAPI 的 localhost 服务返回。
4. 最小可运行示例:后台地址 command
4.1 Rust command
在最小项目的 src-tauri/src/lib.rs 中:
use std::sync::Mutex;
use tauri::State;
struct BackendState {
port: Mutex<Option<u16>>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct BackendInfo {
base_url: String,
ready: bool,
}
#[tauri::command]
fn get_backend_info(state: State<'_, BackendState>) -> Result<BackendInfo, String> {
let port = state
.port
.lock()
.map_err(|_| "后台状态锁不可用".to_string())?
.ok_or_else(|| "后台端口尚未分配".to_string())?;
Ok(BackendInfo {
base_url: format!("http://127.0.0.1:{port}"),
ready: false,
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(BackendState {
port: Mutex::new(Some(50_001)),
})
.invoke_handler(tauri::generate_handler![get_backend_info])
.run(tauri::generate_context!())
.expect("Tauri 应用启动失败");
}
这里刻意返回结构化对象,而不是让前端拼接端口。Rust 是端口状态的所有者,前端只消费结果。
4.2 TypeScript 调用
import { invoke } from "@tauri-apps/api/core";
interface BackendInfo {
baseUrl: string;
ready: boolean;
}
export async function getBackendInfo(): Promise<BackendInfo> {
return invoke<BackendInfo>("get_backend_info");
}
注意 Rust 使用 #[serde(rename_all = "camelCase")],因此 base_url 到前端后是 baseUrl。
4.3 一个故意失败的调用
try {
const info = await invoke<BackendInfo>("get_backend_info");
console.log(info.baseUrl);
} catch (error) {
console.error("无法获取后台信息", error);
}
把状态中的端口改为 None,预期 Promise reject,错误包含“后台端口尚未分配”。验证点不是错误字符串本身,而是:
- Rust 没有 panic;
- 前端没有无限等待;
- 错误沿 IPC 边界可见;
- UI 可以决定重试或显示诊断入口。
5. EMS Simulate 工程实战:启动闭环
5.1 插件注册顺序
EMS Simulate 将 single-instance 插件放在最前:
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init());
这不是风格问题。第二实例必须在进入 setup、创建共享数据目录或拉起 Python 后台之前退出,否则两个实例可能:
- 同时访问 SQLite;
- 重复监听 Modbus/IEC 104/IEC 61850 业务端口;
- 各自创建后台 HTTP 服务;
- 退出时误清理另一实例的资源。
5.2 动态端口
项目从 50000-65535 选择空闲回环端口,再把它传给 sidecar:
Rust 选择端口
└─ ems_simulate_backend --port 5xxxx
└─ uvicorn host=127.0.0.1 port=5xxxx
这解决了固定 8991 被占用时后台无法启动的问题。还要看到一个边界:Rust 的“探测端口”与 Python 的“真正绑定端口”不是原子操作,中间存在短暂竞争窗口。生产代码仍需处理 sidecar 绑定失败,而不能认为探测成功就永远成功。
5.3 运行数据根目录
Rust 通过 EMS_ROOT_DIR 告诉 Python 数据应该写到哪里。Python 在导入 FastAPI 应用之前完成:
- 解析
EMS_ROOT_DIR/--root-dir; - 创建
data、config、upload、plan、log; - 首次复制配置、初始 SQLite 和点表;
- 加载运行配置;
- 最后导入并启动 FastAPI。
“先固定目录,再导入业务模块”可以避免模块导入阶段读取了错误的相对路径。
5.4 健康检查不是“进程存在”
进程存在只能说明操作系统仍有该 PID,不能说明:
- Uvicorn 已经开始监听;
- FastAPI 路由初始化完成;
- SQLite 可访问;
- Vue 静态资源存在。
EMS Simulate 同时参考:
- Rust 持有的进程句柄是否存活;
/api/health是否成功;- 是否已经完成过首次 ready;
- 连续健康失败次数。
首次启动必须 health 成功;已就绪后允许少量瞬时探测失败,第三次连续失败才判定不健康。这比“一次请求失败就显示后台挂了”更抗抖动。
5.5 明确退出所有权
应用退出时由 Rust 停止自己持有的 sidecar。不能再按端口扫描并杀进程,因为端口可能已被其他程序重新占用。安全规则是:
谁创建进程,谁保存句柄;谁保存句柄,谁负责终止和等待。
6. 开发模式、普通安装与 MSIX 不是同一个环境
| 环境 | 后台来源 | 数据目录 | 关键差异 |
|---|---|---|---|
| 开发模式 | 直接 Python 或 sidecar | 项目目录 | 有源码、解释器、当前工作目录 |
| MSI/普通桌面 | bundled sidecar | 安装/应用数据策略 | 路径与权限不同 |
| MSIX/Store | WindowsApps 包内资源 | 包/用户数据边界 | 包路径、进程标志、签名身份不同 |
| Linux deb/AppImage | bundled sidecar | XDG app data | /usr/bin 或 AppImage 挂载不可写 |
EMS Simulate 曾遇到 MSIX 下 sidecar 无法启动:FullTrustApplication 没有预期环境变量,普通 sidecar 路径和进程标志也不能照搬。最终将 backend/normal.rs 与 backend/msix.rs 分开,通过统一入口转发。
这个案例说明:平台差异应该收敛在明确适配层,而不是把大量 if msix 散落到所有 command 中。
7. 失败实验与根因
7.1 command 未注册
操作:从 generate_handler![] 删除 get_backend_info,前端仍调用。
预期:Promise reject;Rust 业务函数不会执行。
根因:#[tauri::command] 只生成 command glue,并不会自动注册。
7.2 参数命名不匹配
Rust:
#[tauri::command]
fn set_backend_port(backend_port: u16) {}
前端错误调用:
invoke("set_backend_port", { backend_port: 50001 });
Tauri 默认从 JavaScript 接收 camelCase 参数,应使用 backendPort,或在 Rust command 上显式配置 rename_all = "snake_case"。
7.3 health 永不成功
按顺序检查:
- Rust 是否成功获得 child handle;
- sidecar stderr 是否有导入/动态库错误;
- 端口是否真正监听在
127.0.0.1; /api/health是否返回 2xx;- loading 页是否拿到正确动态 URL;
- CSP 是否允许该连接。
7.4 release 页面 404
开发模式依赖本地文件和解释器,release 依赖 bundle。检查:
frontendDist是否包含 loading 页面;- PyInstaller 是否包含
www; bundle.externalBin名称是否匹配 target triple 产物;- runtime resources 是否进入安装包;
- 代码是否错误依赖当前工作目录。
8. 测试与验收
8.1 Rust 最小测试
#[test]
fn backend_url_uses_loopback() {
let port = 50_001;
assert_eq!(format!("http://127.0.0.1:{port}"), "http://127.0.0.1:50001");
}
EMS Simulate 当前还包含端口范围、跳过占用端口、健康失败容忍和首次 health 要求等测试。
8.2 本篇验收清单
- 能解释 Vue、WebView、IPC、Rust、Python、FastAPI 的边界;
- 能区分 Tauri IPC 与 HTTP/WebSocket;
- 最小 command 成功和失败路径都可观察;
- loading 页能获得动态 backend URL;
- sidecar 未就绪时不进入业务页面;
- 后台退出后状态能够变为 unhealthy;
- release 不依赖系统 Python 和项目当前目录;
- 退出只清理当前实例持有的进程。
9. 常见误区
9.1 “用了 Tauri,后端就必须写 Rust”
不成立。Rust 适合可信控制面和系统桥接;已经成熟的 Python 协议与业务代码可以通过 sidecar 复用。
9.2 “进程启动成功就代表后台可用”
不成立。必须等待应用级 health,就绪与存活是两个状态。
9.3 “localhost 就是可信的”
不成立。本机其他进程同样可能访问回环端口。动态端口不是鉴权,Part 3 和安全篇会继续处理协议、token 和输入校验。
9.4 “开发模式能跑,打包只是多执行一个命令”
不成立。解释器、资源路径、动态库、可写目录、签名和进程模型都会变化。
9.5 “Tauri 与 Electron 的差别只有体积”
不成立。系统 WebView、Node 能力、IPC、安全模型、插件生态和跨平台一致性都是架构差异。
10. 本篇小结与官方资料
EMS Simulate 的最小心智模型是:
Vue 业务 UI
├─ HTTP/WebSocket → FastAPI → 设备/协议/数据库
└─ Tauri IPC → Rust → 窗口/文件/进程/系统 API
Rust 启动并持有 Python sidecar
loading 页等待 /api/health
后台 ready 后进入动态 localhost Vue 页面
只要始终追问“代码在哪个进程、跨越哪条边界、谁拥有生命周期”,后续的窗口、系统 API、Python 后台和发布问题都会清晰很多。
官方资料:
- Tauri:What is Tauri
- Prerequisites
- Project Structure
- Frontend Configuration
- Calling Rust from the Frontend
- State Management
- Capabilities
- Embedding External Binaries
下一篇将进入 Vue/React 前端接入,重点解释 EMS Simulate 为什么同时保留浏览器模式和 Tauri 模式,以及如何防止桌面 API 散落在业务组件中。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)