Skip to content

第 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 函数:

ts
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.tsVergeEvents interface 建立名称到 payload 的映射:

ts
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>

四、订阅建立也有竞态

典型流程是:

text
读取当前 RunState

异步注册 event listener

若状态恰好在两者之间变化,事件不会排队,界面会一直停留在旧快照,直到下次变化。

subscribeVergeEvents(handlers, onSubscribed) 的解法是:

  1. 并行注册全部 listener;
  2. 等所有注册 Promise 完成;
  3. 调用 onSubscribed 再读一次当前状态。

这不是重复请求,而是关闭“读与订阅之间的时间缝隙”。

五、异步 teardown 的另一个竞态

Tauri listen() 异步返回 unlisten。组件可能在它返回前已经卸载:

text
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 函数。

生命周期是:

text
第一个订阅者 → 创建 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 必须一起看。仅靠“随机本机端口”不足以证明调用者是本应用的另一个实例。

十、怎样选择通道

可以用四个问题判断:

  1. 调用者是否需要明确结果?需要则 command。
  2. 变化是否由后端主动发生?是则 event。
  3. 数据是否持续且高频?是则 WebSocket,并考虑共享与节流。
  4. 调用者是否不是 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:共享订阅、重连、节流与 teardown
  • src/hooks/use-log-data.ts:日志流批量 flush
  • src-tauri/src/utils/server.rs:PAC、单例与回环服务器
  • src-tauri/src/config/clash.rs::guard_external_controller_ipc:核心本地控制通道

原创文档 CC BY-SA 4.0 · 站点代码 MIT