核心目标:理解 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。用户双击应用后看到的是一个窗口,但窗口背后至少有两条调用链:

用户双击 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 分层职责

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() 发生了什么

前端调用:

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]);

调用链为:

Rust command Tauri IPC WebView Vue/TS Rust command Tauri IPC WebView Vue/TS 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 输出目录打进应用:

{
  "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 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 中:

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

  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:

#[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 永不成功

按顺序检查:

  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 最小测试

#[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 后台和发布问题都会清晰很多。

官方资料:

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

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐