第 14 章:托盘、窗口、轻量模式与后台任务
桌面客户端即使关闭主窗口也常要继续代理。托盘、热键、窗口和后台任务因此不是附属功能,而是另一套产品入口。本章解释它们怎样共享 Config/RunState,又如何避免把平台差异带进 React。
一、托盘是第二套 UI
Tray 不只包含“显示/退出”,还提供:
- 当前代理模式;
- 系统代理和 TUN 开关;
- Proxy group/node 选择;
- Profile 切换;
- lightweight 状态;
- 更新提示;
- 动态图标、tooltip 和 macOS 速度文本。
因此配置或节点改变后,既要刷新 WebView,也要更新 tray。feat/config.rs::UpdateFlags 把 menu/icon/tooltip 分开,避免任何 patch 都重建全部托盘资源。
二、托盘状态必须读取同一权威快照
Tray 通过 Config、RunState 和 Mihomo API构建菜单,不维护独立业务状态。节点切换后:
- 切 Mihomo group;
- 记录 Profile selection;
- 发前端 proxy refresh;
- 更新 tray menu。
若 tray 自己保存“当前节点”,核心 reload 后就会与实际状态分裂。
三、热键映射为产品动作
Hotkey 模块负责注册/更新 global shortcut,并将动作路由到:
- 切系统代理;
- 切 TUN;
- 切模式;
- 显示窗口;
- 执行其他配置 patch。
热键不应直接修改 YAML。它仍调用与设置页相同的 feat,因此校验、RunState reconciliation、tray refresh 都不会被绕过。
窗口 focused/unfocused 时,macOS 还动态处理 Cmd+Q/Cmd+W 与用户 global hotkey 设置,避免系统快捷键和自定义注册互相冲突。
四、窗口状态与核心状态分离
关闭主窗口通常执行 hide,不退出应用。WindowManager 统一处理:
- create/show/hide/destroy;
- 装饰与自定义 titlebar;
- 尺寸位置恢复;
- 多平台 focus/activation policy;
- macOS WebContent process 终止后的 reload。
前端 WindowProvider 只暴露装饰和窗口操作,不直接接触平台 API 分支。
五、轻量模式为什么直接销毁窗口
普通隐藏仍保留 WebView、React、Canvas、WebSocket 和 JS heap。轻量模式目标是减少后台资源,因此:
- 状态从 Normal → In;
- 销毁主窗口;
- 核心、Service、tray 和定时器继续;
- 用户重新打开时进入 Exiting,重建窗口,最后回 Normal。
状态用 AtomicU8 CAS,保证并发入口只有一个成功。退出轻量模式在窗口重建完成后才回 Normal,防止第二个 show 与第一个交错。
六、自动轻量模式的可取消定时器
启用后监听:
- window close requested → 启动倒计时;
- WebView focus → 取消倒计时。
Timer 使用 oneshot sender 存在全局 Mutex:
- 已有 sender 表示 timer 已存在,不重复创建;
- cancel 取出 sender 并发送;
- timeout 先清 sender,再进入 lightweight。
这种“单槽取消句柄”比多个 sleep 后再检查 bool 更节省任务,也更容易保证只触发一次。
七、静默启动是窗口策略,不是核心策略
enable_silent_start 使 init_window() 创建但不显示窗口,并可在 auto_lightweight_boot() 中直接进入轻量模式。核心、tray、backup、updater 仍正常初始化。
因此不要在“窗口不可见”时停止数据面。窗口只是管理界面,代理核心是后台服务能力。
八、自动备份管理器
AutoBackupManager 支持:
- 固定小时周期;
- Global Merge 变化触发;
- Global Script 变化触发;
- 至少 60 秒间隔去抖;
- 执行 Mutex 防并发;
- 自动备份最多保留 20 份。
设置通过 watch::Sender 推给 scheduler:
schedule disabled → 等待 settings changed
schedule enabled → sleep(interval) 与 settings changed 竞争修改间隔不必杀死/重建后台线程,watch change 会打断当前 sleep 并按新值重新开始。
九、备份为什么要加密敏感字段
WebDAV 配置含用户名/密码。config/encrypt.rs 使用 AES-256-GCM:
- 每次随机 12 字节 nonce;
- nonce + ciphertext 做 base64;
- 加密 key 来自应用目录/平台派生;
- task-local
ENCRYPTION_ACTIVE控制序列化时是否加密。
同一个 struct 在普通内存/YAML 操作时保持原类型,只有备份序列化作用域启用加密 serializer。task-local 能跨 async await 跟随当前任务,又不会影响并发的普通保存。
十、静默更新器
SilentUpdater 管理:
- 后台检查;
- 下载 bytes;
- 内存 pending update;
- 磁盘 cache 的 binary 与 metadata;
- 下次启动恢复 pending;
- 安装前停止核心并清系统状态。
Tauri updater 配置包含多个镜像 endpoint 和 minisign public key。镜像提高可用性,签名验证保证下载源替换不等于更新信任被替换。
缓存元数据记录 version/downloaded_at。安装前仍要与当前版本和 update metadata 对齐,不能只因为本地存在 pending_update.bin 就执行。
十一、Deep Link 与单例协作
注册 clash://、clash-verge:// 后:
- 主实例在 Tauri deep-link event 中解析;
- 第二实例通过 loopback singleton server 把 URL 交给主实例;
resolve::scheme判断是订阅导入还是其他动作;- WindowManager 必要时显示主窗口。
Deep link 解析属于后端,因为它会触发 Profile 创建、文件写入和窗口操作,不应只是 React Router URL。
十二、跨平台差异的典型收敛点
| 能力 | Windows | macOS | Linux |
|---|---|---|---|
| 服务 | Windows Service | launch daemon/helper | systemd |
| 进程收束 | Job Object | 进程/Service | 进程/Service |
| deep link | Registry | bundle scheme | xdg-mime |
| 代理 | sysproxy/本地 | Service 或本地 | sysproxy/本地 |
| 窗口 | 自定义 controls | activation policy | WebKit/Wayland workaround |
上层通过同名动作或 RunState 观察结果,不把平台矩阵复制到每个页面。
十三、桌面后台能力的设计原则
- 托盘与热键是 UI 入口,但不是独立状态源。
- 窗口生命周期与核心生命周期解耦。
- 后台 timer 必须可取消、去重并对配置热更新。
- 敏感数据只在明确序列化作用域加密。
- 更新可用性与更新真实性分开:镜像负责前者,签名负责后者。
本章源码索引
src-tauri/src/core/tray/:菜单、图标与速度任务src-tauri/src/core/hotkey.rs:全局热键src-tauri/src/utils/window_manager.rs、feat/window.rs:窗口生命周期src/providers/window/:前端窗口抽象src-tauri/src/module/lightweight.rs:轻量模式状态机与 timersrc-tauri/src/module/auto_backup.rs:watch scheduler 与保留策略src-tauri/src/feat/backup.rs、core/backup.rs:本地/WebDAV 备份src-tauri/src/config/encrypt.rs:AES-GCM task-local 序列化src-tauri/src/core/updater.rs:静默下载、缓存与安装src-tauri/src/utils/resolve/scheme.rs:Deep Link 分派