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 后台、系统能力和安装包之间的完整调用链。


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. 当前代码运行在哪个进程?
  2. 数据跨越了哪条边界?
  3. 失败证据应该去哪一层寻找?

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 应用之前完成:

  1. 解析 EMS_ROOT_DIR / --root-dir
  2. 创建 dataconfiguploadplanlog
  3. 首次复制配置、初始 SQLite 和点表;
  4. 加载运行配置;
  5. 最后导入并启动 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.rsbackend/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 永不成功

按顺序检查:

  1. Rust 是否成功获得 child handle;
  2. sidecar stderr 是否有导入/动态库错误;
  3. 端口是否真正监听在 127.0.0.1
  4. /api/health 是否返回 2xx;
  5. loading 页是否拿到正确动态 URL;
  6. 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 后台和发布问题都会清晰很多。

官方资料:

下一篇将进入 Vue/React 前端接入,重点解释 EMS Simulate 为什么同时保留浏览器模式和 Tauri 模式,以及如何防止桌面 API 散落在业务组件中。

相关推荐
水寒2592 小时前
vue3 低代码:注册组件后,让Json Schema拥有完整的类型提示
架构
致Great2 小时前
Qwen3.8-Flash 发布:6B 激活,Qwen4 架构提前亮相
数据库·架构
2501_912784082 小时前
跨境建站避坑:为什么通用电商架构不适配反向代购业务
大数据·人工智能·架构·taoify
yychen_java2 小时前
二:Multi-Agent 协作架构与 MCP 协议实战:Java 企业级 AI 智能体进阶指南
java·人工智能·架构
今天AI了吗3 小时前
从“金鱼脑”到“大象记忆”:AI Agent 短期记忆与长期记忆的存储与检索全解
数据库·人工智能·python·sql·rust
tqs_123453 小时前
AI后端服务高性能架构:GPU独立部署、算力解耦、弹性伸缩实战
人工智能·架构
MC皮蛋侠客3 小时前
Tauri 2.x 系列(二):项目结构与前端框架——Vue、React 如何成为桌面前台
rust·tauri
谁在黄金彼岸3 小时前
Windows 远程桌面(RDP)是怎么建立的
架构