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
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# 连接状态模型 (在线 / 通道可用)
|
||||
|
||||
聊天框顶部那个小圆点 + 旁边的文字,到底表示什么?这份文档说清楚,
|
||||
以及为什么状态必须**拉取式**同步而不是只靠事件推送。
|
||||
|
||||
## 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` | 顶部的点与文案 |
|
||||
@@ -0,0 +1,70 @@
|
||||
# 网盘 (共享目录浏览 + 选择下载)
|
||||
|
||||
局域网里两台 LocalNetMsg 互传文件, 以前只能"发消息带附件" (点对点传单个文件)。
|
||||
网盘功能让对方能**浏览我机器上指定的共享目录**, 挑需要的文件下载。
|
||||
|
||||
## 1. 组成
|
||||
|
||||
```
|
||||
被控端 (文件持有方) 控制端 (浏览/下载方)
|
||||
┌────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ FileServer (0.0.0.0:47901) │ │ 聊天框 → "网盘" tab │
|
||||
│ GET /drive/meta │◀── HTTP ──│ DrivePanel.vue │
|
||||
│ GET /drive/list │ │ 目录浏览 / 多选 / 下载 │
|
||||
│ GET /drive/download │ │ ↓ ipc │
|
||||
│ ↑ 只能访问 driveDir 内的 │ │ drive.ts (客户端) │
|
||||
│ 文件, 且要带对 token │ │ listDrive / downloadDrive… │
|
||||
└────────────────────────────┘ └──────────────────────────────┘
|
||||
```
|
||||
|
||||
- **文件服务器启动即开启**: `FileServer` 本来就在应用启动时跑起来了 (端口 47901),
|
||||
网盘只是给它加了 3 个 `/drive/*` 路由, 不需要额外进程。
|
||||
- **共享目录**: 设置 → 网盘 → 共享目录, 默认 `~/Documents/LocalNetMsg/网盘`。
|
||||
对端**只能看到这个目录里的内容**, 子目录也跳不出去。
|
||||
- **访问令牌**: 每台机器一个长期 token (`userData/data/drive.token`), 随局域网发现包广播,
|
||||
对端拿它调 `/drive/*`。关掉"启用网盘"开关或 token 不符都会 403。
|
||||
|
||||
## 2. 协议
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /drive/meta?token=` | 取网盘根目录 (仅提示用) |
|
||||
| `GET /drive/list?token=&path=` | 列目录, `path` 是相对网盘根的路径 (`''` = 根) |
|
||||
| `GET /drive/download?token=&path=` | 下载文件 (流式, `Content-Disposition: attachment`) |
|
||||
|
||||
返回的条目:
|
||||
|
||||
```ts
|
||||
interface DriveEntry {
|
||||
name: string // 文件名
|
||||
path: string // 相对网盘根的路径 (下载时用它)
|
||||
size: number
|
||||
mtime: number
|
||||
isDir: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 安全
|
||||
|
||||
| 风险 | 处理 |
|
||||
|---|---|
|
||||
| 越权读任意文件 | 路径必须落在 `driveDir` 内 (resolve 后前缀校验), `..` 一律拒绝 |
|
||||
| 局域网陌生人访问 | 每次请求校验 token; 开关关掉直接 404 |
|
||||
| 对方版本太老 | 老版本没有 token 字段 → 控制端显示"对方版本暂不支持网盘" |
|
||||
|
||||
自测覆盖: `npm run selftest -- drive` (D1–D7), 包含目录穿越和错误 token 的用例。
|
||||
|
||||
## 4. 用法
|
||||
|
||||
1. 把要共享的文件丢进网盘目录 (设置里可以改位置, 面板里有"打开文件夹")
|
||||
2. 在聊天框切到 **网盘** tab → 看到对方网盘的文件列表
|
||||
3. 点目录进去, 勾选文件 → **下载选中**
|
||||
4. 文件落到本机接收目录 (设置 → 接收目录), 面板里能看到逐个进度
|
||||
|
||||
**目录也支持**:
|
||||
- 点目录名可以进去, "上一级"回退, 面包屑显示当前位置
|
||||
- 目录行也能打勾 → **整个目录递归下载**, 本地保持原有层级
|
||||
(下载 `产品文档/` → 本机得到 `产品文档/需求说明.md`、`产品文档/原型图/xx.png`)
|
||||
- 递归深度上限 12 层, 防止异常目录结构把进程拖死
|
||||
|
||||
对方从我的网盘下载文件时, 本机会弹一条提示, 并记进审计日志 (`drive.download`)。
|
||||
@@ -594,6 +594,46 @@ CREATE INDEX idx_audit_ts ON audit_log(ts DESC);
|
||||
|
||||
## 11. 验证清单(手动两机)
|
||||
|
||||
### 11.1 自动化自测(单进程双节点)
|
||||
|
||||
`npm run selftest` 会在一个进程里拉起两个虚拟节点(A=控制端、B=被控端),走真实的
|
||||
`ChatServer` + `ChatClient` + `TerminalManager` / `ForwardManager` / `UsbBridgeManager`,
|
||||
接线方式与 `src/main/ipc.ts` 完全一致,不需要第二台机器:
|
||||
|
||||
| # | 用例 | 覆盖 |
|
||||
|---|---|---|
|
||||
| T1–T5 | 终端 open / 输出回显 / 输入 / resize / close | PTY 生命周期与 `terminal.output` 帧 |
|
||||
| F1–F4 | 转发 self-out / self-in / 端口黑名单 / 字节计数 | 真实 TCP 隧道往返 |
|
||||
| U1–U2c | 列设备 / libusb 枚举 / 控制传输 setup 翻译 | node-usb v3 API 适配 |
|
||||
| U3–U5 | 串口 attach + 双向字节流 | 用 socat 造 PTY 对冒充真串口 |
|
||||
| A1 | 关键操作写进 `audit_log` | 审计链路 |
|
||||
|
||||
> 跑测试用 `ELECTRON_RUN_AS_NODE=1` 起 Electron 的 node:`better-sqlite3` 是按 Electron ABI
|
||||
> 编译的,用系统 node 会报 `NODE_MODULE_VERSION` 不匹配。
|
||||
|
||||
### 11.2 实现约定(踩过的坑)
|
||||
|
||||
- **sessionId 两端必须一致**:控制端生成 sessionId 并随 `attach` 帧发出,被控端**必须原样沿用**
|
||||
作为自己 session 的 key。反过来自己 `randomUUID()` 会导致数据帧查表落空、被当成"无此 session"丢掉
|
||||
(表现为串口/USB 字节流永远不通、detach 报"session 不存在")。
|
||||
- **终端 output 帧要在控制端落地**:`handleIncoming` 收到 `output` 帧后要 `emit('output', ...)`,
|
||||
`ipc.ts` 靠这个事件把数据广播给渲染层;只在被控端 emit 的话控制端 xterm 永远是空白。
|
||||
- **node-usb v3(Rust 重写版)**:
|
||||
- 枚举用 `webusb.loadDevices()`;`webusb.getDevices()` 是 WebUSB 语义,只返回本进程已授权的设备。
|
||||
- 控制传输的 `setup` 用字符串枚举(`requestType: 'standard'|'class'|'vendor'`、
|
||||
`recipient: 'device'|'interface'|'endpoint'|'other'`),协议里传的标准 `bmRequestType`
|
||||
数值要翻译一次(`toUsbControlSetup`)。
|
||||
- 设备被内核驱动占着时,必须**先 `detachKernelDriver` 再 `selectConfiguration`**,否则 EBUSY。
|
||||
- **转发端口的归属**:只有两种拓扑,UI 必须按视角标注,否则用户会连错机器——
|
||||
- `self-out`:发起方 bind 监听端口,目标在**被控端**机器上
|
||||
- `self-in`:被控端 bind 监听端口,目标在**发起方**机器上
|
||||
- 列表里 `side='client'` 表示"我发起",`side='server'` 表示"对方发起";
|
||||
入口/目标列都要写清"本机"还是"对方"。
|
||||
- **转发 session 两侧都要能关**:UI 的"关闭"按钮走 `ForwardManager.closeSession()`,
|
||||
关闭 server 侧时必须给对端发 `close` 帧,否则对端的监听端口不会释放(端口一直被占)。
|
||||
|
||||
### 11.3 手动清单
|
||||
|
||||
| # | 场景 | 期望 |
|
||||
|---|---|---|
|
||||
| T1 | 控制端请求被控端终端 → 被控端允许 | xterm 渲染 PowerShell/zsh/bash 提示符 |
|
||||
|
||||
Reference in New Issue
Block a user