第 4 章:IPC、事件与三种通信通道
前端与后端不是只有 `invoke`。Clash Verge Rev 同时使用 Tauri command、Tauri event 和 Mihomo WebSocket,并另外维护一个仅回环可见的嵌入 HTTP 服务器。本章解释每条通道解决什么问题,以及如何避免类型漂移、订阅竞态和重复连接。
一、为什么一种 IPC 不够
不同数据具有不同时间特征:
| 数据 | 方向 | 频率 | 合适通道 |
|---|---|---|---|
| 保存设置、导入 Profile | 前端 → 后端 → 前端 | 低 | Tauri command |
| 运行状态、配置刷新通知 | 后端 → 前端 | 低/突发 | Tauri event |
| 流量、连接、日志 | Mihomo → 前端 | 高频持续 | WebSocket |
| PAC、第二实例唤醒 | 本机进程/系统 → 应用 | 按需 | loopback HTTP |
若全部用轮询 command,高频数据会造成大量序列化与调度;若全部用 event,请求的成功/失败和返回值不自然;若前端直接访问所有系统端口,权限与安全边界会散开。
二、Tauri command:有结果的用例调用
2.1 后端注册
app_init::generate_handlers() 用 tauri::generate_handler! 注册约 89 个命令,覆盖:
- 核心启停与 RunState;
- Clash/Verge 配置;
- Profile CRUD、更新和增强;
- 系统代理、监听端口、网络接口;
- 备份、WebDAV、更新和诊断;
- 流媒体解锁测试。
所有命令集中注册有两个好处:一眼可见 IPC 攻击面,也让 Tauri 2 的 unused command 移除机制有完整入口。
2.2 前端门面
src/services/cmds.ts 为每个命令提供 TypeScript 函数:
export const getRuntimeState = async () =>
invoke<RunState>('get_runtime_state')
export async function patchVergeConfig(payload: IVergeConfig) {
return invoke<void>('patch_verge_config', { payload })
}页面不应该直接散落 invoke('some_string')。统一门面提供:
- 命令名唯一位置;
- 参数命名统一;
- 返回类型;
- 必要的结果后处理;
- 统一通知或错误降级。
2.3 command 不是业务层
后端 command 最好保持薄。例如 record_selected_node 只接收 group/node 并调用配置模块,真正的“latest-write merge、防止并发覆盖”在 config::profiles。如果在 command 里直接读整个 Profile、修改再写回,两次快速节点切换就可能互相覆盖。
三、Tauri event:无请求方的状态变化
3.1 后端统一发射
core/handle.rs 把事件封装成语义函数:
refresh_clash()refresh_verge()refresh_profiles()notify_run_state()notify_profile_changed()notice_message()
调用方不需要知道前端事件字符串和 payload 细节。
3.2 前端事件字典
services/events.ts 用 VergeEvents interface 建立名称到 payload 的映射:
interface VergeEvents {
'verge://refresh-clash-config': string
'verge://run-state-changed': RunState
'verge://notice-message': [string, string]
'profile-changed': string
}TypeScript 无法让 Rust 事件名自动通过编译检查,但至少保证前端所有订阅点共享同一个名称和 payload 类型,避免每个组件自行猜 listen<T>。
四、订阅建立也有竞态
典型流程是:
读取当前 RunState
↓
异步注册 event listener若状态恰好在两者之间变化,事件不会排队,界面会一直停留在旧快照,直到下次变化。
subscribeVergeEvents(handlers, onSubscribed) 的解法是:
- 并行注册全部 listener;
- 等所有注册 Promise 完成;
- 调用
onSubscribed再读一次当前状态。
这不是重复请求,而是关闭“读与订阅之间的时间缝隙”。
五、异步 teardown 的另一个竞态
Tauri listen() 异步返回 unlisten。组件可能在它返回前已经卸载:
effect 开始注册
↓
组件卸载,cleanup 执行
↓
listen Promise 才 resolve若只保存已经返回的 unlisten,新 listener 会泄漏。项目用 disposed 标志处理:Promise resolve 时发现已销毁,立刻执行刚拿到的 unlisten()。
这个模式适用于任何“异步建立、同步声明清理”的资源,不只 Tauri events。
六、Mihomo 通道:本地 socket 上的 API 与 WebSocket
Tauri 的 Mihomo 插件配置为 LocalSocket,socket path 与运行时配置中的 external controller IPC 一致。前端依赖 tauri-plugin-mihomo-api,调用方式看起来像普通 TypeScript API:
getProxies()selectNodeForGroup()MihomoWebSocket.connect_connections()MihomoWebSocket.connect_logs()MihomoWebSocket.connect_traffic()
实际请求由插件跨过 Tauri 边界并通过本地 socket 到 Mihomo。这样前端不需要持有 controller secret,也不需要处理 Unix socket/Windows pipe 的平台差异。
七、共享 WebSocket:按 key 复用,而不是每个 Hook 一条连接
useMihomoWsSubscription 维护模块级 Map<string, SharedSubscriptionEntry>。每个 entry 包含:
- 引用计数
refs; - 当前 socket;
- 重连 timer 和 connecting 标记;
- 多个 ref holder;
- 多个 owner,但只有一个 active owner 处理消息;
- connect/reconnect 函数。
生命周期是:
第一个订阅者 → 创建 entry → 建立 socket
后续同 key 订阅者 → refs + 1,复用 socket
active owner 卸载 → 选择下一个仍 mounted 的 owner
最后一个订阅者卸载 → 关闭 socket → 删除 entry为什么需要 active owner?多个组件可能调用同一 Hook,但消息只应写一次共享缓存。若每个 owner 都执行 handler,同一条日志会被追加多次。
八、WebSocket 的缓存契约
订阅结果使用特殊 SWR key:$sub$${subscriptKey}。消息 handler 不直接 setState,而是通过 setCacheData 更新共享缓存。好处是:
- 页面切换后数据仍可复用;
- 多个消费者得到同一快照;
- handler 支持函数式 updater,避免并发 append 覆盖;
- refresh 通过更换持久化 date key 创建新订阅代际。
高频消息还可使用 throttleMs:第一条立即落缓存,窗口内只保留最新 pending 值,到期再 flush。这比单纯 debounce 更适合实时数据,因为不会让持续流永远等不到第一次更新。
九、嵌入 HTTP 服务器:只服务本机系统集成
utils/server.rs 在 loopback 随机端口启动 Warp server,主要承担:
/commands/pac:向操作系统提供 PAC;- 第二实例把深链/参数转发给主实例;
- readiness 与命令协调。
关键安全约束:
- 只绑定回环地址;
- 实例记录含随机 token;
- Unix 记录文件权限为
0600; - 旧记录中的端口只是 hint,不是授权;
- 首选端口被占用时回退到新端口。
端口与 token 必须一起看。仅靠“随机本机端口”不足以证明调用者是本应用的另一个实例。
十、怎样选择通道
可以用四个问题判断:
- 调用者是否需要明确结果?需要则 command。
- 变化是否由后端主动发生?是则 event。
- 数据是否持续且高频?是则 WebSocket,并考虑共享与节流。
- 调用者是否不是 WebView,而是操作系统或第二进程?是则受限 loopback server。
不要让技术偏好替代数据语义。项目同时使用四种通道不是重复,而是每种通道只承担适合自己的负载。
本章源码索引
src-tauri/src/lib.rs::generate_handlers:command 注册表src-tauri/src/cmd/:后端 IPC 适配层src/services/cmds.ts:前端 invoke 门面src-tauri/src/core/handle.rs:后端事件发射门面src/services/events.ts:事件名称与 payload 映射src/hooks/use-mihomo-ws-subscription.ts:共享订阅、重连、节流与 teardownsrc/hooks/use-log-data.ts:日志流批量 flushsrc-tauri/src/utils/server.rs:PAC、单例与回环服务器src-tauri/src/config/clash.rs::guard_external_controller_ipc:核心本地控制通道