# 连接状态模型 (在线 / 通道可用) 聊天框顶部那个小圆点 + 旁边的文字,到底表示什么?这份文档说清楚, 以及为什么状态必须**拉取式**同步而不是只靠事件推送。 ## 1. 两种"在线",别混 | 概念 | 含义 | 数据来源 | 表现 | |---|---|---|---| | **设备在线** | 对方机器还在局域网里活着 | UDP 广播发现 (`discovery.ts`),15s 没心跳算 lost | 侧边栏"在线"绿标 | | **通道可用** | 我们和对方之间有活的 WS,能收发消息 | `outgoing` (我们连过去的) 或 `inbound` (对方连过来的) 任一活着 | 聊天框顶部绿点 | - 设备在线 ≠ 通道可用。对方可能开着防火墙挡住 WS,UDP 还在广播。 - 通道可用 **不等于** 设备在线:对端进程刚起、还没被 UDP 发现到,但已经连上来了。 **通道可用**的判定(`src/main/ws-state.ts` 的 `computeWsState`): ```ts alive = clientStatus[id] === 'open' || server.isConnected(id) // ↑ outgoing: 我们主动连的 ↑ inbound: 对方连过来的 ``` 两个方向是独立的两条腿。早期实现只看 outgoing,结果对方只连进来、 我们没连上时,UI 会误报"等待重连"——而消息其实收发正常。 ## 2. 状态必须能"随时拉取" 启动顺序里有一处天然竞态(`src/main/index.ts`): ``` discovery.start() ← 先启动 chatClient.connectTo(...) ← 已入库设备, 立刻发起 WS 连接 (setTimeout 0) createMainWindow() ← 之后才建窗口 ``` WS 通常几十毫秒内就握手成功 → 主进程广播 `device:wsState` → **可渲染层还没加载完、还没注册监听 → 这条事件被整个丢掉**。 之后再没有 'open' 事件了(连接一直保持着),UI 就永久停在"等待重连…"。 **结论**:状态事件只管"变化通知",不能当"当前状态"的唯一来源。 任何时候想知道现在的状态,都得能主动查一次。 | 时机 | 谁来拉 | 拉什么 | |---|---|---| | 渲染层 mount | 渲染层 | `ipcRenderer.invoke('app:state')` → `{ ws: {...} }` | | 主进程 `boot:ready` | 主进程推 | 带上同一份 `ws` 快照 | | 连接断开 | 主进程推 | 全量快照 `{ ws: {...} }` | `device:wsState` 事件带的是**全量快照**而不是"某个设备变了"的增量: 增量事件会漏(订阅晚了就没了),全量快照任何时刻都能自愈。 渲染层还有两条纪律(`src/renderer/src/App.vue`): 1. **先注册监听,再做 await**。任何 `await` 都会让出事件循环,期间推来的事件就丢了。 2. 快照里的设备 = "没连上"(按 false 处理),不做增量合并,避免留下过期状态。 ## 3. 文案 | 条件 | 显示 | |---|---| | 通道可用 | `在线 · 192.168.0.7`(绿点) | | 设备在线但通道不通 | `在线 · 192.168.0.7 (连接中…)`(黄点,后台在 backoff 重连) | | 都不可用 | `离线 · 12:05`(灰点) | 远程功能(终端 / 转发 / 串口 / 网盘)都走 WS 通道,所以入口按钮在 `设备离线 && 通道不通` 时才禁用——只看设备在线会把"能连但没连上"的情况误判成可用。 ## 4. 回归测试 `npm run selftest` 里的 `[通道状态]` 组(W1–W3): - **W1** 通道状态可随时拉取(连接早已建好,现在才查也查得到) - **W2** 只有 inbound 通(对方连我,我没主动连)也算通道可用 - **W3** 两条腿都断了 → 状态回落 false ## 5. 相关文件 | 文件 | 作用 | |---|---| | `src/main/ws-state.ts` | `computeWsState` 纯函数(无 electron 依赖,方便自测) | | `src/main/chat-server.ts` | `isConnected(deviceId)` — 查 inbound 连接 | | `src/main/chat-client.ts` | `getStatus()` — 各对端的 outgoing 状态 | | `src/main/ipc.ts` | `collectWsState()` / `broadcastWsState()` / `app:state` IPC | | `src/renderer/src/stores/device.ts` | `wsOpen` 映射 + `setWsStates()` 全量替换 | | `src/renderer/src/components/ChatView.vue` | 顶部的点与文案 |