核心目标:在 EMS Simulate 中建立主窗口、报文窗口、菜单和系统托盘的统一生命周期,正确处理显示、隐藏、关闭、明确退出、单实例唤醒和后台进程清理。
前置知识 :已阅读 Part 1~Part 3,理解 Tauri Builder、Command、Event 和 Rust 状态。
验证基线:EMS Simulate 5.0.0;Tauri 2.11.2、Tauri JS API 2.11.0、Rust 1.95.0、Vue 3.5.26,Windows 11。最后复核日期: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 到底应该退出还是继续仿真
普通网页没有"关闭到托盘"这个概念,工业仿真客户端却必须回答:
- 主窗口关闭后,Modbus、IEC 104、IEC 61850 服务是否继续运行;
- 用户只是暂时隐藏界面,还是明确要求退出;
- 报文窗口仍然存在时,关闭主窗口该怎么办;
- 托盘中的"退出"会不会又被窗口 close handler 拦截;
- 第二次双击桌面图标时,应该新建一套后台,还是唤醒原实例;
- 后台仍有仿真任务时,能否直接 kill;
- Windows、macOS 和 Linux 对"最后一个窗口关闭"的行为是否一致。
EMS Simulate 当前采用的是明确而简单的策略:关闭主窗口就清理 Python 后台并退出整个应用 。src-tauri/src/lib.rs 中的实际逻辑是:
rust
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
backend::cleanup_managed_process();
app_handle.exit(0);
}
});
本篇在保留这个现状说明的基础上,给出一个可增量落地的"关闭到托盘"版本。二者是产品策略差异,不是谁绝对正确。
1. Window 与 WebviewWindow:先分清两个对象
Tauri 2 将原生窗口与 WebView 分为两个概念:
text
Window
└── 操作系统窗口:位置、尺寸、标题栏、最小化、焦点
Webview
└── 系统 WebView:URL、页面脚本、导航、IPC
WebviewWindow
└── 一个 Window 承载一个 Webview 的常用组合
对于 EMS Simulate:
| 窗口 | label | 内容 | 生命周期 |
|---|---|---|---|
| 主窗口 | main |
设备、通道、建模和运行控制 | 应用级 |
| 报文窗口 | message-<hash> |
指定设备的实时报文 | 按需创建 |
| 设置窗口 | settings(规划) |
桌面与后台配置 | 可隐藏复用 |
| 诊断窗口 | diagnostics(规划) |
sidecar、端口、日志和版本 | 出错时打开 |
1.1 配置创建与运行时创建
主窗口适合写入 tauri.conf.json:
json
{
"app": {
"windows": [
{
"label": "main",
"title": "EMS Simulate",
"width": 1280,
"height": 800,
"minWidth": 960,
"minHeight": 600,
"visible": false,
"url": "index.html"
}
]
}
}
报文窗口只有用户打开设备报文时才需要,适合由 Vue 运行时创建。当前项目已经这样实现:
ts
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const existing = await WebviewWindow.getByLabel(label);
if (existing) {
await existing.show();
await existing.unminimize();
await existing.setFocus();
return;
}
const messageWindow = new WebviewWindow(label, {
url: `${baseUrl}#/message-view/${encodeURIComponent(deviceName)}`,
title: `设备报文 - ${deviceName}`,
width: 1100,
height: 650,
minWidth: 820,
minHeight: 480,
center: true,
});
这里有三个容易忽略的细节:
- label 必须唯一,而且只能使用 Tauri 允许的字符;
- 设备名称可能包含中文、空格和特殊字符,不应直接拼成 label;
- 找到现有窗口后要依次
show → unminimize → setFocus,仅show()不能恢复最小化窗口。
EMS 使用 FNV-1a 风格哈希生成 message-<hex>,既稳定又避开非法字符。同一设备因此只保留一个报文窗口。
1.2 窗口权限不要"一把梭"
当前 default.json 同时匹配:
json
"windows": ["main", "message-*"]
这便于快速落地,但长期建议拆分:
text
main → dialog、opener、restart_backend、设备控制
message-* → core window、只读报文接口
settings → 读取/修改批准的配置
diagnostics → backend status、打开日志目录
窗口越多,越应该按用途分配 capability。否则一个只展示报文的窗口也会继承保存文件、重启后台等高权限能力。
2. 窗口状态持久化:记住体验,不记住事故
可以使用 window-state 插件记录位置、尺寸和最大化状态,也可以在 Rust 中自行保存。无论采用哪种方式,都应满足:
- 只在移动或缩放结束后去抖保存;
- 记录逻辑尺寸,不自行猜测 DPI 换算;
- 恢复前检查目标显示器是否仍然存在;
- 位置完全落在屏幕外时回退到居中;
- 不恢复"正在最小化""启动页尚未 ready"等瞬时状态;
- 报文窗口可以按用途记忆尺寸,但不要无限保存设备窗口条目。
一个稳妥的数据模型是:
json
{
"schemaVersion": 1,
"main": {
"width": 1280,
"height": 800,
"maximized": false
},
"messageViewer": {
"width": 1100,
"height": 650
}
}
显示器布局变化是常态。窗口状态属于"偏好",恢复失败时应丢弃并居中,不能因此阻止 EMS 启动。
3. 菜单不是另一套业务层
Tauri 菜单可以挂在窗口或托盘上,支持普通项、复选项、子菜单、分隔线和快捷键。真正重要的是:菜单 ID 只映射 action,不直接复制业务实现。
rust
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
fn build_main_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let open_logs = MenuItem::with_id(app, "open_logs", "打开日志目录", true, None::<&str>)?;
let restart = MenuItem::with_id(app, "restart_backend", "重启后台", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, Some("Ctrl+Q"))?;
let separator = PredefinedMenuItem::separator(app)?;
let app_menu = Submenu::with_items(
app,
"应用",
true,
&[&open_logs, &restart, &separator, &quit],
)?;
Menu::with_items(app, &[&app_menu])
}
建议让所有入口汇入同一 action 层:
text
窗口菜单 ─┐
托盘菜单 ─┼→ AppAction → 生命周期/后台/前端领域服务
快捷键 ─┤
深链接 ─┤
第二实例 ─┘
例如 AppAction::ShowMain 只负责恢复主窗口;AppAction::RestartBackend 只调用 BackendManager;"启动全部仿真"则应该进入 FastAPI 领域接口,而不是在菜单回调里重写一套协议逻辑。
macOS 通常有系统级应用菜单,Windows/Linux 更多是每窗口菜单。平台上看起来相同的快捷键也可能被系统保留,因此菜单 ID 才是稳定业务标识,显示文字和 accelerator 都只是 UI 属性。
4. 系统托盘:从"有一个图标"升级到可控状态
4.1 最小托盘
先为 Tauri 启用托盘 feature。EMS 当前 Cargo.toml 只有 image-png,增量实现需要改为:
toml
[dependencies]
tauri = { version = "2", features = ["image-png", "tray-icon"] }
下面的 Rust 示例适合 EMS:托盘由 Rust 创建和持有,前端页面重载不会重复创建。
rust
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager,
};
fn show_main(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn install_tray(app: &tauri::App) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show_main", "显示 EMS Simulate", true, None::<&str>)?;
let open_logs = MenuItem::with_id(app, "open_logs", "打开日志目录", true, None::<&str>)?;
let restart = MenuItem::with_id(app, "restart_backend", "重启后台", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &open_logs, &restart, &quit])?;
TrayIconBuilder::with_id("main-tray")
.icon(app.default_window_icon().cloned().expect("缺少应用图标"))
.tooltip("EMS Simulate")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| dispatch_menu(app, event.id.as_ref()))
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
dispatch_menu() 不应包含大段业务代码:
rust
fn dispatch_menu(app: &tauri::AppHandle, id: &str) {
match id {
"show_main" => show_main(app),
"open_logs" => open_log_directory(app),
"restart_backend" => schedule_backend_restart(app.clone()),
"quit" => request_explicit_exit(app),
_ => eprintln!("[EMS] unknown menu action: {id}"),
}
}
4.2 托盘状态不是颜色游戏
EMS 托盘至少有四类状态:
| 状态 | tooltip 示例 | 菜单策略 |
|---|---|---|
| Starting | 正在启动后台 | 禁用重启/启动全部 |
| Ready | 后台已就绪 | 开放正常操作 |
| Degraded | health 连续失败 | 开放诊断和重启 |
| Stopping | 正在安全退出 | 禁用所有重复操作 |
可以更换图标,也可以只更新 tooltip 和菜单启用状态。不要用高频协议状态不断重建托盘:托盘是低频应用状态入口,不是实时仪表盘。
4.3 平台差异
- Windows 建议准备清晰的
.ico/PNG 多尺寸资源; - macOS 菜单栏图标通常应使用 template icon,并验证浅色/深色主题;
- Linux 托盘依赖桌面环境和 AppIndicator 支持,托盘点击事件能力也与 Windows/macOS 不完全相同;
- JavaScript 创建的托盘资源若要提前销毁,需要显式
close();由 Rust 在 setup 中创建通常更容易控制生命周期; - 托盘 ID 必须稳定,热重载或重复 setup 时不能无条件创建多份。
5. 关闭、隐藏、销毁、退出是四件事
text
hide :窗口不可见,但对象、WebView 和应用仍存在
close :发出关闭请求,可被 prevent_close 拦截
destroy :真正销毁窗口资源
exit :结束 Tauri 事件循环,进入应用退出清理
"关闭到托盘"的关键不是调用 hide(),而是引入明确退出状态:
rust
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Default)]
struct LifecycleState {
explicit_exit: AtomicBool,
}
fn request_explicit_exit(app: &tauri::AppHandle) {
let state = app.state::<LifecycleState>();
if state.explicit_exit.swap(true, Ordering::SeqCst) {
return;
}
backend::stop_backend();
app.exit(0);
}
主窗口 close handler:
rust
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let state = app_handle.state::<LifecycleState>();
if !state.explicit_exit.load(Ordering::SeqCst) {
api.prevent_close();
if let Some(main) = app_handle.get_webview_window("main") {
let _ = main.hide();
}
}
}
});
Builder 中先注册状态,再安装托盘:
rust
tauri::Builder::default()
.manage(LifecycleState::default())
.setup(|app| {
install_tray(app)?;
install_main_window_lifecycle(app)?;
Ok(())
});
如果托盘菜单点击"退出"时没有先设置 explicit_exit,可能出现这条错误链:
text
托盘退出 → app.exit / window.close
→ CloseRequested
→ prevent_close + hide
→ 用户以为退出,后台仍运行
5.1 有仿真任务时的安全退出
工业仿真不应默认"二次确认后立刻 kill"。推荐状态机:
#mermaid-svg-xEwVKYpdQWyU7aLy{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-xEwVKYpdQWyU7aLy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xEwVKYpdQWyU7aLy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xEwVKYpdQWyU7aLy .error-icon{fill:#552222;}#mermaid-svg-xEwVKYpdQWyU7aLy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xEwVKYpdQWyU7aLy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xEwVKYpdQWyU7aLy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xEwVKYpdQWyU7aLy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xEwVKYpdQWyU7aLy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xEwVKYpdQWyU7aLy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xEwVKYpdQWyU7aLy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xEwVKYpdQWyU7aLy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xEwVKYpdQWyU7aLy .marker.cross{stroke:#333333;}#mermaid-svg-xEwVKYpdQWyU7aLy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xEwVKYpdQWyU7aLy p{margin:0;}#mermaid-svg-xEwVKYpdQWyU7aLy defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-xEwVKYpdQWyU7aLy g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-xEwVKYpdQWyU7aLy g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-xEwVKYpdQWyU7aLy g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-xEwVKYpdQWyU7aLy g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-xEwVKYpdQWyU7aLy g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-xEwVKYpdQWyU7aLy .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-xEwVKYpdQWyU7aLy .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-xEwVKYpdQWyU7aLy .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-xEwVKYpdQWyU7aLy .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-xEwVKYpdQWyU7aLy .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-xEwVKYpdQWyU7aLy .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-xEwVKYpdQWyU7aLy .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-xEwVKYpdQWyU7aLy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xEwVKYpdQWyU7aLy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xEwVKYpdQWyU7aLy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xEwVKYpdQWyU7aLy .edgeLabel .label text{fill:#333;}#mermaid-svg-xEwVKYpdQWyU7aLy .label div .edgeLabel{color:#333;}#mermaid-svg-xEwVKYpdQWyU7aLy .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-xEwVKYpdQWyU7aLy .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-xEwVKYpdQWyU7aLy .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-xEwVKYpdQWyU7aLy .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-xEwVKYpdQWyU7aLy .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-xEwVKYpdQWyU7aLy .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xEwVKYpdQWyU7aLy .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xEwVKYpdQWyU7aLy #statediagram-barbEnd{fill:#333333;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xEwVKYpdQWyU7aLy .cluster-label,#mermaid-svg-xEwVKYpdQWyU7aLy .nodeLabel{color:#131300;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-xEwVKYpdQWyU7aLy .note-edge{stroke-dasharray:5;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-note text{fill:black;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram-note .nodeLabel{color:black;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagram .edgeLabel{color:red;}#mermaid-svg-xEwVKYpdQWyU7aLy #dependencyStart,#mermaid-svg-xEwVKYpdQWyU7aLy #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-xEwVKYpdQWyU7aLy .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xEwVKYpdQWyU7aLy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 关闭到托盘
托盘显示/第二实例
明确退出
明确退出
用户取消
用户确认
停协议/flush/关数据库/停 sidecar
Running
Hidden
ConfirmingExit
Draining
Exiting
安全退出顺序应由拥有资源的一层执行:
- 前端停止发起新操作;
- FastAPI 将设备和通道切换到 draining;
- 停止 Modbus、IEC 104、IEC 61850、DL/T 645 等服务;
- 关闭 WebSocket,会话写入终态;
- flush 日志与数据库事务;
- Rust 等待 graceful shutdown 超时;
- 超时后只 kill 当前实例持有的 sidecar/进程树;
- 进入
RunEvent::Exit做幂等兜底。
清理函数必须幂等,因为 CloseRequested、ExitRequested 和 Exit 可能先后触发。
6. 单实例:必须在 sidecar 之前挡住第二实例
当前 EMS 已使用 tauri-plugin-single-instance,并且刻意把它放在所有插件和 setup 之前:
rust
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(
|app, args, working_directory| {
show_main(app);
handle_second_instance(args, working_directory);
},
))
.plugin(tauri_plugin_shell::init())
.setup(|app| {
// 只有主实例走到这里并拉起 Python sidecar
Ok(())
});
如果 single-instance 注册在 setup 之后,第二实例可能已经:
- 启动另一份 Python 后台;
- 创建新的运行目录或锁住 SQLite;
- 抢占动态端口;
- 启动重复协议监听服务;
- 在退出时误清理主实例资源。
第二实例回调不只是"聚焦窗口",还可以把命令行参数转换为统一入口:
rust
#[derive(Debug)]
enum OpenIntent {
ShowMain,
ImportScl(std::path::PathBuf),
ImportPointTable(std::path::PathBuf),
OpenDevice(String),
}
未来的深链接、文件关联和第二实例参数都先解析成 OpenIntent:
text
第二实例参数 ─┐
文件关联 ─┼→ validate → OpenIntent → show/focus → 前端路由或导入确认
深链接 ─┘
必须先校验 scheme、扩展名、路径和调用来源,再交给业务页面。ems-simulate://import?... 只是入口,不是授权证明。
6.1 开机启动不是默认勾选项
autostart 会改变用户系统状态,应由用户在设置页明确开启。还要验证:
- 开机后是否静默进入托盘;
- sidecar 启动失败如何提示;
- 系统尚未联网是否影响本地仿真;
- 多用户环境使用哪个数据目录;
- 卸载时是否移除注册项。
不要把"开机启动"和"关闭到托盘"耦合成一个开关。
7. EMS 的推荐生命周期实现顺序
不建议一次性把所有功能塞进 lib.rs。可以按下面的边界拆分:
text
src-tauri/src/
├── lib.rs # Builder 与插件注册
├── lifecycle.rs # close/hide/exit 状态机
├── tray.rs # 托盘构建、状态更新
├── actions.rs # AppAction 统一分发
├── windows.rs # show/focus/create/restore
├── intents.rs # 第二实例/深链接/文件关联
└── backend/ # Python sidecar 生命周期
迭代顺序:
- 保持当前"关闭即退出",先抽出幂等
request_exit(); - 加入托盘,但暂不拦截主窗口关闭;
- 添加
explicit_exit,实现关闭到托盘; - 接入后台 running 状态和退出确认;
- 让单实例回调统一进入
OpenIntent; - 最后加入设置窗口、autostart、深链接和文件关联。
这样每一步都有明确回滚点。
8. 失败实验与根因
8.1 隐藏窗口后应用立刻退出
现象 :执行 hide() 后托盘也消失。
检查 :是否仍有 close handler 调用了 app.exit(0);托盘是否真的创建成功;Linux 桌面是否支持托盘。
8.2 托盘"退出"只隐藏窗口
根因:明确退出标志未在触发 close/exit 前设置,close handler 无法区分来源。
8.3 重载前端后出现多个托盘图标
根因:在 Vue 组件挂载时创建托盘,又没有显式 close;或初始化函数被重复调用。
修复:应用级托盘优先在 Rust setup 中创建一次。
8.4 报文窗口无法再次打开
根因 :窗口已经存在但被最小化或隐藏;只检查了变量,没有使用 getByLabel() 查询真实窗口。
8.5 第二实例仍启动了第二份后台
根因:single-instance 插件注册太晚,或 sidecar 启动发生在插件拦截之前。
8.6 退出后仍有 Python/协议进程
根因:只清理窗口,没有清理 sidecar;只 kill 父进程,没有处理其子进程树;graceful shutdown 没有超时兜底。
8.7 Linux 托盘不显示或点击无反应
根因:桌面环境/AppIndicator 支持差异。不能把 Windows 上的鼠标事件行为视为所有 Linux 桌面的统一契约。
9. 测试与验收
9.1 可自动化的 Rust 单元测试
explicit_exit只能从 false 进入 true 一次;- 重复退出只执行一次资源清理;
OpenIntent拒绝危险 scheme、未知扩展名和无效路径;- 设备名称到窗口 label 的映射稳定且不冲突于测试向量;
- action ID 映射未知值时返回可解释错误。
9.2 必须人工验证的桌面行为
| 场景 | 预期 |
|---|---|
| 第一次启动 | 只出现一个主窗口和一个 sidecar |
| 同设备连续打开报文 | 聚焦同一个 message-* 窗口 |
| 关闭主窗口 | 按产品策略退出,或隐藏到托盘 |
| 托盘左键 | 显示、恢复并聚焦主窗口 |
| 托盘明确退出 | 安全停止仿真并退出,不被 close handler 拦截 |
| 再次双击程序 | 不启动第二个后台,唤醒主实例 |
| sidecar 异常 | 托盘状态变为 degraded,可打开诊断或重启 |
| 应用退出 | 无残留进程、监听端口和数据库锁 |
至少在 Windows MSI、Windows MSIX、Linux deb/AppImage 分别验证。托盘行为和应用数据目录不能只靠 cargo tauri dev 推断。
9.3 本篇验收清单
- 能区分 hide、close、destroy 和 exit;
- 当前"关闭即退出"和新增"关闭到托盘"策略有清晰边界;
- 托盘只创建一次,菜单 action 进入统一分发层;
- 同一设备只保留一个报文窗口;
- 单实例在 setup 前阻止第二套 sidecar;
- 明确退出有幂等清理和超时兜底;
- 后台运行时退出会先确认并安全停机;
- 不同平台分别验证托盘和最后窗口行为。
10. 常见误区
误区一:托盘只是多放一个图标
托盘改变的是应用生命周期:没有可见窗口时,进程和后台仍可能运行,退出语义必须随之重构。
误区二:点击 X 就等于用户想退出
监控、同步和工业仿真客户端常把 X 定义为隐藏。产品必须明确说明,且托盘中必须提供可发现的退出入口。
误区三:所有窗口共享同一 capability 最省事
这会让低权限报文窗口继承主窗口的系统能力。窗口越独立,权限边界越应该独立。
误区四:第二实例只需要 return
用户已经发起了动作。主实例至少应该显示、恢复和聚焦;有文件参数时还应转成可审计的 OpenIntent。
误区五:app.exit() 后不需要清理
协议服务、数据库、日志和 sidecar 都有自己的生命周期。进程退出事件只能作为兜底,不能替代可控的 draining。
11. 本篇小结与官方资料
EMS 的窗口和托盘不应该各自拥有一套业务逻辑。稳健方案是:
text
窗口/菜单/托盘/快捷键/第二实例
↓
AppAction
↓
LifecycleState + BackendManager + OpenIntent
↓
FastAPI 领域服务
当前 EMS 已经具备主窗口、独立报文窗口、单实例和退出清理;下一步加入托盘时,最关键的改动是"明确退出状态 + 幂等安全停机",而不是托盘图标本身。
官方资料:
- System Tray
- Window Menu
- WebviewWindow JavaScript API
- Core Permissions
- Single Instance Plugin
- Deep Linking Plugin
- Autostart Plugin
- Window State Plugin
下一篇进入用户最关心的系统能力:以 EMS 的浏览器打开、日志目录、SCL/点表选择和文件保存为例,建立 core API、官方插件、Rust crate 与原生 API 的选择方法。