Files
LocalNetMsg/docs/connection-status.md
T
xiaoshuai a79086e4e8 feat: 网盘浏览下载 + 远程功能修复 + 连接状态同步 + Linux 图标打包
新增网盘功能
- FileServer 增加 GET /drive/meta|list|download, 每机长期 token 校验 + 目录穿越防护
- 新增 drive.ts 客户端: listDrive / getDriveRoot / downloadDriveFile / collectDriveFiles
- ipc 暴露 drive:list|root|download|openFolder|pickDir, DB 增 drive_token 列 + ALTER 迁移
- 新增 DrivePanel.vue, 聊天框加"网盘" tab, 设置页加网盘分区
- 支持整目录递归下载并保持层级

修复远程功能
- 终端: 控制端收到的 output 帧被直接丢弃 (case 'output': return), 导致 xterm 全空白
- ChatServer: ws 构造即 listen, 'listening' 可能早于 start() 挂监听, 导致永久挂起
- USB: serverAttach 自行生成 sessionId 与控制端不一致 (数据帧查表落空);
  libusb 枚举误用 getDevices() (WebUSB 语义, 只返回已授权设备) 改 loadDevices();
  control transfer 传数字 bmRequestType (node-usb v3 要字符串枚举);
  detachKernelDriver 挪到 selectConfiguration 之前 (否则 EBUSY)
- 转发 UI: store 的 onOpened 是 no-op 致对方发起的转发不显示、无轮询致流量/TTL 不刷新、
  对方发起的会话关不掉 (新增 closeSession, server 侧关闭需通知对端否则端口泄漏)、
  入口/目标补归属标注、方向标签语义统一、监听端口输入框加宽

修复"在线却显示等待重连"
- 根因: 启动时主进程先连上对端 (connectTo 早于 createMainWindow), device:wsState
  在渲染层订阅前发出去被丢弃; 之后连接一直保持, 再无 open 事件 -> UI 永久停在等待重连
- 改为推全量快照 + 渲染层 mount 时主动拉取 (app:state); 渲染层先注册监听再 await
- 通道语义改为 outgoing 或 inbound 任一 WS 活着即可用 (原来只看 outgoing)

修复 Linux 安装后没图标
- linux.icon 指向单张 PNG 时 app-builder 解析不出尺寸 (size=0), 图标被装进
  hicolor/0x0/ 非法目录, 桌面环境找不到; 改为指向 resources/icons 目录 (8 个标准尺寸)
- StartupWMClass 填的是 productName, 与窗口真实 WM_CLASS (可执行文件名) 对不上,
  任务栏找不到启动器; 改为 local-net-msg

其他
- 新增 scripts/selftest 自测框架 (两个虚拟节点 + 真实 ChatServer/ChatClient), 30 条用例全过
- 重写 scripts/make-icon.js 生成多尺寸图标集 (原脚本引用的源图已不存在, sharp 依赖也缺)
- 新增 docs/drive.md, docs/connection-status.md
2026-10-20 16:43:56 +08:00

86 lines
4.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 连接状态模型 (在线 / 通道可用)
聊天框顶部那个小圆点 + 旁边的文字,到底表示什么?这份文档说清楚,
以及为什么状态必须**拉取式**同步而不是只靠事件推送。
## 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`(灰点) |
远程功能(终端 / 转发 / USB / 网盘)都走 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` | 顶部的点与文案 |