核心目标:理解 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。用户双击应用后看到的是一个窗口,但窗口背后至少有两条调用链:
#mermaid-svg-vQ2KpakQCBZVT2VR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vQ2KpakQCBZVT2VR .error-icon{fill:#552222;}#mermaid-svg-vQ2KpakQCBZVT2VR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vQ2KpakQCBZVT2VR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vQ2KpakQCBZVT2VR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vQ2KpakQCBZVT2VR .marker.cross{stroke:#333333;}#mermaid-svg-vQ2KpakQCBZVT2VR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vQ2KpakQCBZVT2VR p{margin:0;}#mermaid-svg-vQ2KpakQCBZVT2VR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vQ2KpakQCBZVT2VR .cluster-label text{fill:#333;}#mermaid-svg-vQ2KpakQCBZVT2VR .cluster-label span{color:#333;}#mermaid-svg-vQ2KpakQCBZVT2VR .cluster-label span p{background-color:transparent;}#mermaid-svg-vQ2KpakQCBZVT2VR .label text,#mermaid-svg-vQ2KpakQCBZVT2VR span{fill:#333;color:#333;}#mermaid-svg-vQ2KpakQCBZVT2VR .node rect,#mermaid-svg-vQ2KpakQCBZVT2VR .node circle,#mermaid-svg-vQ2KpakQCBZVT2VR .node ellipse,#mermaid-svg-vQ2KpakQCBZVT2VR .node polygon,#mermaid-svg-vQ2KpakQCBZVT2VR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vQ2KpakQCBZVT2VR .rough-node .label text,#mermaid-svg-vQ2KpakQCBZVT2VR .node .label text,#mermaid-svg-vQ2KpakQCBZVT2VR .image-shape .label,#mermaid-svg-vQ2KpakQCBZVT2VR .icon-shape .label{text-anchor:middle;}#mermaid-svg-vQ2KpakQCBZVT2VR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vQ2KpakQCBZVT2VR .rough-node .label,#mermaid-svg-vQ2KpakQCBZVT2VR .node .label,#mermaid-svg-vQ2KpakQCBZVT2VR .image-shape .label,#mermaid-svg-vQ2KpakQCBZVT2VR .icon-shape .label{text-align:center;}#mermaid-svg-vQ2KpakQCBZVT2VR .node.clickable{cursor:pointer;}#mermaid-svg-vQ2KpakQCBZVT2VR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vQ2KpakQCBZVT2VR .arrowheadPath{fill:#333333;}#mermaid-svg-vQ2KpakQCBZVT2VR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vQ2KpakQCBZVT2VR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vQ2KpakQCBZVT2VR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vQ2KpakQCBZVT2VR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vQ2KpakQCBZVT2VR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vQ2KpakQCBZVT2VR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vQ2KpakQCBZVT2VR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vQ2KpakQCBZVT2VR .cluster text{fill:#333;}#mermaid-svg-vQ2KpakQCBZVT2VR .cluster span{color:#333;}#mermaid-svg-vQ2KpakQCBZVT2VR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vQ2KpakQCBZVT2VR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vQ2KpakQCBZVT2VR rect.text{fill:none;stroke-width:0;}#mermaid-svg-vQ2KpakQCBZVT2VR .icon-shape,#mermaid-svg-vQ2KpakQCBZVT2VR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vQ2KpakQCBZVT2VR .icon-shape p,#mermaid-svg-vQ2KpakQCBZVT2VR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vQ2KpakQCBZVT2VR .icon-shape .label rect,#mermaid-svg-vQ2KpakQCBZVT2VR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vQ2KpakQCBZVT2VR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vQ2KpakQCBZVT2VR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vQ2KpakQCBZVT2VR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户双击 EMS Simulate
Tauri / Rust 进程
系统 WebView
Python sidecar
FastAPI / Uvicorn
Vue 静态页面
REST / WebSocket
设备、测点与协议引擎
如果不先理解这张图,许多故障都会被笼统地描述成"客户端打不开":
- 窗口已经创建,但 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 分层职责
#mermaid-svg-s1kHnTg9TKDkhmc9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s1kHnTg9TKDkhmc9 .error-icon{fill:#552222;}#mermaid-svg-s1kHnTg9TKDkhmc9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s1kHnTg9TKDkhmc9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .marker.cross{stroke:#333333;}#mermaid-svg-s1kHnTg9TKDkhmc9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s1kHnTg9TKDkhmc9 p{margin:0;}#mermaid-svg-s1kHnTg9TKDkhmc9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .cluster-label text{fill:#333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .cluster-label span{color:#333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .cluster-label span p{background-color:transparent;}#mermaid-svg-s1kHnTg9TKDkhmc9 .label text,#mermaid-svg-s1kHnTg9TKDkhmc9 span{fill:#333;color:#333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .node rect,#mermaid-svg-s1kHnTg9TKDkhmc9 .node circle,#mermaid-svg-s1kHnTg9TKDkhmc9 .node ellipse,#mermaid-svg-s1kHnTg9TKDkhmc9 .node polygon,#mermaid-svg-s1kHnTg9TKDkhmc9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s1kHnTg9TKDkhmc9 .rough-node .label text,#mermaid-svg-s1kHnTg9TKDkhmc9 .node .label text,#mermaid-svg-s1kHnTg9TKDkhmc9 .image-shape .label,#mermaid-svg-s1kHnTg9TKDkhmc9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-s1kHnTg9TKDkhmc9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s1kHnTg9TKDkhmc9 .rough-node .label,#mermaid-svg-s1kHnTg9TKDkhmc9 .node .label,#mermaid-svg-s1kHnTg9TKDkhmc9 .image-shape .label,#mermaid-svg-s1kHnTg9TKDkhmc9 .icon-shape .label{text-align:center;}#mermaid-svg-s1kHnTg9TKDkhmc9 .node.clickable{cursor:pointer;}#mermaid-svg-s1kHnTg9TKDkhmc9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .arrowheadPath{fill:#333333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s1kHnTg9TKDkhmc9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s1kHnTg9TKDkhmc9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s1kHnTg9TKDkhmc9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s1kHnTg9TKDkhmc9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s1kHnTg9TKDkhmc9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s1kHnTg9TKDkhmc9 .cluster text{fill:#333;}#mermaid-svg-s1kHnTg9TKDkhmc9 .cluster span{color:#333;}#mermaid-svg-s1kHnTg9TKDkhmc9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s1kHnTg9TKDkhmc9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s1kHnTg9TKDkhmc9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-s1kHnTg9TKDkhmc9 .icon-shape,#mermaid-svg-s1kHnTg9TKDkhmc9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s1kHnTg9TKDkhmc9 .icon-shape p,#mermaid-svg-s1kHnTg9TKDkhmc9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s1kHnTg9TKDkhmc9 .icon-shape .label rect,#mermaid-svg-s1kHnTg9TKDkhmc9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s1kHnTg9TKDkhmc9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s1kHnTg9TKDkhmc9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s1kHnTg9TKDkhmc9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Vue / React
界面和交互
系统 WebView
HTML/CSS/JS 运行时
Tauri IPC
invoke / event / channel
Rust Core
command、状态、生命周期
操作系统
窗口、文件、进程、网络
| 层 | 能看到什么 | 不应该承担什么 |
|---|---|---|
| Vue/React | 组件、路由、用户操作 | 任意系统命令、进程治理 |
| WebView | DOM、Fetch、浏览器 API | Python 进程所有权 |
| Tauri IPC | 序列化后的命令和事件 | 自动理解 EMS 业务 |
| Rust Core | command、AppHandle、插件、系统资源 | 重写全部工业协议 |
| OS | 窗口、文件、进程、网络栈 | 理解 Vue 或设备模型 |
2.2 一次 invoke() 发生了什么
前端调用:
ts
import { invoke } from "@tauri-apps/api/core";
const url = await invoke<string>("get_backend_url");
Rust 注册并处理:
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]);
调用链为:
Rust command Tauri IPC WebView Vue/TS Rust command Tauri IPC WebView Vue/TS #mermaid-svg-2bAhxH960aEufw5O{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2bAhxH960aEufw5O .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2bAhxH960aEufw5O .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2bAhxH960aEufw5O .error-icon{fill:#552222;}#mermaid-svg-2bAhxH960aEufw5O .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2bAhxH960aEufw5O .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2bAhxH960aEufw5O .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2bAhxH960aEufw5O .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2bAhxH960aEufw5O .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2bAhxH960aEufw5O .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2bAhxH960aEufw5O .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2bAhxH960aEufw5O .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2bAhxH960aEufw5O .marker.cross{stroke:#333333;}#mermaid-svg-2bAhxH960aEufw5O svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2bAhxH960aEufw5O p{margin:0;}#mermaid-svg-2bAhxH960aEufw5O .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2bAhxH960aEufw5O text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-2bAhxH960aEufw5O .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2bAhxH960aEufw5O .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-2bAhxH960aEufw5O .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-2bAhxH960aEufw5O .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-2bAhxH960aEufw5O #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-2bAhxH960aEufw5O .sequenceNumber{fill:white;}#mermaid-svg-2bAhxH960aEufw5O #sequencenumber{fill:#333;}#mermaid-svg-2bAhxH960aEufw5O #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-2bAhxH960aEufw5O .messageText{fill:#333;stroke:none;}#mermaid-svg-2bAhxH960aEufw5O .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2bAhxH960aEufw5O .labelText,#mermaid-svg-2bAhxH960aEufw5O .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-2bAhxH960aEufw5O .loopText,#mermaid-svg-2bAhxH960aEufw5O .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-2bAhxH960aEufw5O .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2bAhxH960aEufw5O .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2bAhxH960aEufw5O .noteText,#mermaid-svg-2bAhxH960aEufw5O .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-2bAhxH960aEufw5O .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2bAhxH960aEufw5O .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2bAhxH960aEufw5O .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2bAhxH960aEufw5O .actorPopupMenu{position:absolute;}#mermaid-svg-2bAhxH960aEufw5O .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-2bAhxH960aEufw5O .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2bAhxH960aEufw5O .actor-man circle,#mermaid-svg-2bAhxH960aEufw5O line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-2bAhxH960aEufw5O :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 成功 失败 invoke("get_backend_url") command + JSON 参数 查找 handler / 反序列化 Ok(String) Promise resolve Err(String) Promise reject
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 输出目录打进应用:
json
{
"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 Python sidecar 内置 loading 页面 Tauri/Rust Vue 页面 FastAPI Python sidecar 内置 loading 页面 Tauri/Rust #mermaid-svg-ULw0ssykWWTN19rH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ULw0ssykWWTN19rH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ULw0ssykWWTN19rH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ULw0ssykWWTN19rH .error-icon{fill:#552222;}#mermaid-svg-ULw0ssykWWTN19rH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ULw0ssykWWTN19rH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ULw0ssykWWTN19rH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ULw0ssykWWTN19rH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ULw0ssykWWTN19rH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ULw0ssykWWTN19rH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ULw0ssykWWTN19rH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ULw0ssykWWTN19rH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ULw0ssykWWTN19rH .marker.cross{stroke:#333333;}#mermaid-svg-ULw0ssykWWTN19rH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ULw0ssykWWTN19rH p{margin:0;}#mermaid-svg-ULw0ssykWWTN19rH .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ULw0ssykWWTN19rH text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ULw0ssykWWTN19rH .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ULw0ssykWWTN19rH .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ULw0ssykWWTN19rH .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ULw0ssykWWTN19rH .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ULw0ssykWWTN19rH #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ULw0ssykWWTN19rH .sequenceNumber{fill:white;}#mermaid-svg-ULw0ssykWWTN19rH #sequencenumber{fill:#333;}#mermaid-svg-ULw0ssykWWTN19rH #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ULw0ssykWWTN19rH .messageText{fill:#333;stroke:none;}#mermaid-svg-ULw0ssykWWTN19rH .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ULw0ssykWWTN19rH .labelText,#mermaid-svg-ULw0ssykWWTN19rH .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ULw0ssykWWTN19rH .loopText,#mermaid-svg-ULw0ssykWWTN19rH .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ULw0ssykWWTN19rH .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ULw0ssykWWTN19rH .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ULw0ssykWWTN19rH .noteText,#mermaid-svg-ULw0ssykWWTN19rH .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ULw0ssykWWTN19rH .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ULw0ssykWWTN19rH .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ULw0ssykWWTN19rH .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ULw0ssykWWTN19rH .actorPopupMenu{position:absolute;}#mermaid-svg-ULw0ssykWWTN19rH .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ULw0ssykWWTN19rH .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ULw0ssykWWTN19rH .actor-man circle,#mermaid-svg-ULw0ssykWWTN19rH line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ULw0ssykWWTN19rH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 每 100ms,最多 30s spawn --port 5xxxx 创建隐藏窗口后显示 invoke get_backend_url GET /api/health invoke is_backend_ready(HTTP 失败时) ready window.location = 动态 backend URL
选择它有明确原因:
- 同一份 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 中:
rust
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 调用
ts
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 一个故意失败的调用
ts
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 插件放在最前:
rust
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:
text
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:
rust
#[tauri::command]
fn set_backend_port(backend_port: u16) {}
前端错误调用:
ts
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 最小测试
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 的最小心智模型是:
text
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 散落在业务组件中。