Tauri 2.x 系列(四):窗口、菜单、托盘与应用生命周期——做出真正的桌面体验

核心目标:在 EMS Simulate 中建立主窗口、报文窗口、菜单和系统托盘的统一生命周期,正确处理显示、隐藏、关闭、明确退出、单实例唤醒和后台进程清理。

前置知识 :已阅读 Part 1Part 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 后台、系统能力和安装包之间的完整调用链。


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,
});

这里有三个容易忽略的细节:

  1. label 必须唯一,而且只能使用 Tauri 允许的字符;
  2. 设备名称可能包含中文、空格和特殊字符,不应直接拼成 label;
  3. 找到现有窗口后要依次 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

安全退出顺序应由拥有资源的一层执行:

  1. 前端停止发起新操作;
  2. FastAPI 将设备和通道切换到 draining;
  3. 停止 Modbus、IEC 104、IEC 61850、DL/T 645 等服务;
  4. 关闭 WebSocket,会话写入终态;
  5. flush 日志与数据库事务;
  6. Rust 等待 graceful shutdown 超时;
  7. 超时后只 kill 当前实例持有的 sidecar/进程树;
  8. 进入 RunEvent::Exit 做幂等兜底。

清理函数必须幂等,因为 CloseRequestedExitRequestedExit 可能先后触发。


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 生命周期

迭代顺序:

  1. 保持当前"关闭即退出",先抽出幂等 request_exit()
  2. 加入托盘,但暂不拦截主窗口关闭;
  3. 添加 explicit_exit,实现关闭到托盘;
  4. 接入后台 running 状态和退出确认;
  5. 让单实例回调统一进入 OpenIntent
  6. 最后加入设置窗口、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 已经具备主窗口、独立报文窗口、单实例和退出清理;下一步加入托盘时,最关键的改动是"明确退出状态 + 幂等安全停机",而不是托盘图标本身。

官方资料:

下一篇进入用户最关心的系统能力:以 EMS 的浏览器打开、日志目录、SCL/点表选择和文件保存为例,建立 core API、官方插件、Rust crate 与原生 API 的选择方法。

相关推荐
MC皮蛋侠客29 分钟前
Tauri 2.x 系列(一):架构全景与最小闭环——从 WebView 到 EMS 后台
架构·rust·tauri
今天AI了吗3 小时前
从“金鱼脑”到“大象记忆”:AI Agent 短期记忆与长期记忆的存储与检索全解
数据库·人工智能·python·sql·rust
MC皮蛋侠客3 小时前
Tauri 2.x 系列(二):项目结构与前端框架——Vue、React 如何成为桌面前台
rust·tauri
@atweiwei1 天前
用 Rust 构建 Agent 应用的高性能框架:langchainrust 架构全景
人工智能·架构·rust·langchain·llm·agent·ai编程
Source.Liu1 天前
【Dioxus】Dioxus CLI (dx) 命令笔记
笔记·rust·dioxus
梦醒沉醉1 天前
1、Rust参考手册——记法和词法结构
rust
SomeB1oody2 天前
【RustyML入门】6.3. 并行归约
开发语言·后端·机器学习·rust·教程