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:
2026-10-20 16:43:56 +08:00
parent ec9f854136
commit a79086e4e8
42 changed files with 2258 additions and 144 deletions
+2
View File
@@ -10,3 +10,5 @@ data/
!data/.gitkeep
.cache/
.vscode/
.idea/
scripts/selftest/.out/
+16 -2
View File
@@ -11,13 +11,17 @@ LAN instant-messaging desktop app. Stack: **Electron 32 + Vue 3 + TypeScript + V
| Typecheck main+preload | `npm run typecheck:node` |
| Typecheck renderer | `npm run typecheck:web` |
| Typecheck both | `npm run typecheck` |
| Remote feature self-test (terminal / forward / USB) | `npm run selftest` |
| Windows installer | `npm run package:win` → `dist-Electron\LocalNetMsg-0.1.0-Setup.exe` |
| macOS / Linux | `npm run package:mac` / `npm run package:linux` |
| Unpacked dir only | `npm run package:dir` |
All `package:*` scripts bake in `ELECTRON_MIRROR` + `ELECTRON_BUILDER_BINARIES_MIRROR` (npmmirror.com). Do not remove unless on the open internet.
No test suite. Verify changes via `npm run typecheck` + manual `npm run dev`.
`npm run selftest` runs the remote (terminal / port-forward / USB) self-test: one process, two virtual
nodes, real `ChatServer` + `ChatClient` + remote managers. See `scripts/selftest/`.
Verify changes via `npm run typecheck` + `npm run selftest` + manual `npm run dev`.
## Layout
@@ -67,7 +71,7 @@ UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All th
6. **Renderer global drag highlight.** Track `dragenter`/`dragleave` with a depth counter (not a boolean) — child elements fire leave/enter pairs. See `MessageInput.vue`'s `dragDepth`.
7. **Try not to leak Electron internals to UI.** Chat-client WS state stays in main; renderer only sees discovery online/offline. Adding a "chat not connected" pill in the UI was reverted for a reason — keep the surface area to two states (在线/离线).
7. **WS 通道状态要"拉取式"同步, 不能只靠事件推送.** 聊天框顶部显示的是"通道可用"(outgoing 或 inbound 任一 WS 活着),跟侧边栏的"设备在线"(UDP 发现)是两回事。启动时主进程往往先连上对端(`index.ts` 里 `connectTo` 早于 `createMainWindow`),那条 `device:wsState` 事件会在渲染层订阅之前就发出去被丢掉 —— 早期实现只有事件推送,UI 因此永久停在"等待重连…",而连接其实一直是好的。规矩:(1) `device:wsState` 推**全量快照**而不是增量;(2) 渲染层 mount 时主动 `invoke('app:state')` 拉一次;(3) 渲染层**先注册监听再 await**,否则 await 期间的事件也会丢。详见 `docs/connection-status.md`。
8. **File conflict naming.** Append `_N` before the first `.`: `archive.tar.gz` → `archive_1.tar.gz`, `.bashrc` → `.bashrc_1`. Logic in `file-server.ts` `targetPath()`.
@@ -80,3 +84,13 @@ UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All th
## Packaging gotcha
`release/` contains an `app.asar` Windows cannot delete while the previous app instance holds a file handle. Build now writes to `dist-Electron\` to dodge this. If you ever switch back, kill the running app first or you'll get EPERM.
## Linux packaging (icon + taskbar)
Ubuntu 装完"没图标"是个坑,有两个独立原因:
1. **`linux.icon` 必须指向目录,不能是单张 PNG。** electron-builder 把图标装进 `/usr/share/icons/hicolor/<size>x<size>/apps/<exe>.png`,而单张 PNG 交给 `app-builder` 时它解析不出尺寸(返回 `size: 0`),于是装进 `hicolor/0x0/` 这种非法目录,桌面环境根本找不到 → 启动器没图标。正确做法是给一个**目录**,里面按 `<size>x<size>.png` 命名:`resources/icons/{16,24,32,48,64,128,256,512}x{...}.png`,然后 `linux.icon = "resources/icons"`。图标集用 `node scripts/make-icon.js` 生成(需要 ImageMagick 的 `convert`;生成结果已提交,平时不用跑)。
2. **`StartupWMClass` 要跟窗口真实的 WM_CLASS 一致。** electron-builder 默认填 `productName`(`LocalNetMsg`),但 Electron 实际设的是**可执行文件名**(`local-net-msg`)—— 对不上,任务栏就找不到对应启动器,显示成通用图标。用 `xprop -id <wid> WM_CLASS` 确认真实值,然后在 `build.linux.desktop.StartupWMClass` 里写它。
Windows / macOS 仍用单张 `resources/icon.png`(那里不按尺寸分目录)。
+85
View File
@@ -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` | 顶部的点与文案 |
+70
View File
@@ -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`)。
+40
View File
@@ -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 提示符 |
+15 -3
View File
@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"description": "局域网即时通讯 (Electron + Vue 3) - 飞书风格",
"homepage": "https://github.com/localnetmsg/local-net-msg",
"author": "LocalNetMsg <dev@localnetmsg.local>",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
@@ -12,6 +14,8 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"typecheck:web": "vue-tsc --noEmit -p src/renderer/tsconfig.json",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"selftest:build": "esbuild scripts/selftest/main.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/selftest.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --alias:@lydell/node-pty=./scripts/selftest/pty-shim.cjs --external:better-sqlite3 --external:serialport --external:usb --external:node-hid --external:sudo-prompt --external:ws",
"selftest": "npm run selftest:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/selftest.cjs",
"package:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --win --x64",
"package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac",
"package:linux": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --linux",
@@ -60,6 +64,7 @@
"output": "dist-Electron",
"buildResources": "resources"
},
"npmRebuild": false,
"files": [
"out/**/*",
"package.json"
@@ -105,10 +110,17 @@
"category": "public.app-category.social-networking"
},
"linux": {
"icon": "resources/icon.png",
"icon": "resources/icons",
"target": [
"AppImage"
]
"deb"
],
"category": "Network",
"synopsis": "LAN instant messaging",
"description": "LocalNetMsg — a LAN instant messaging desktop app.",
"maintainer": "LocalNetMsg <dev@localnetmsg.local>",
"desktop": {
"StartupWMClass": "local-net-msg"
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+72 -23
View File
@@ -1,28 +1,77 @@
// 把候选 logo 处理成 app icon:
// - 转 PNG (白色背景填充为透明)
// - 缩放到 1024x1024 (electron-builder 推荐)
// - 顺便输出 256x256 给 renderer 用
const sharp = require('sharp')
// 生成 Linux 多尺寸图标集 -> resources/icons/<size>x<size>.png
//
// 为什么需要多尺寸: electron-builder 的 Linux 打包会把图标装进
// /usr/share/icons/hicolor/<size>x<size>/apps/<executable>.png。
// 如果 linux.icon 指向"单张 PNG", app-builder 解析不出尺寸 (返回 size=0),
// 图标就被装到 hicolor/0x0/ 这种非法目录里, 桌面环境根本找不到 -> 安装后没图标。
// 指向一个"目录 + <size>x<size>.png 命名"才能被正确识别。
//
// 用法:
// node scripts/make-icon.js # 用 resources/icon.png 作为基准图
// node scripts/make-icon.js <源图路径> # 用指定图片重新生成基准图 + 全套尺寸
//
// 依赖: ImageMagick 的 convert (生成结果已提交, 平时不需要跑这个脚本)
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const SRC = path.resolve(__dirname, '..', 'resources', 'logo-candidate_004.jpg')
const RES = path.resolve(__dirname, '..', 'resources')
const OUT_DIR = path.join(RES, 'icons')
const BASE = path.join(RES, 'icon.png')
// hicolor 标准尺寸: 覆盖所有实际显示场景, 全部从 512 基准图缩小而来 (清晰不糊)
const SIZES = [16, 24, 32, 48, 64, 128, 256, 512]
async function main() {
const out1024 = path.resolve(__dirname, '..', 'resources', 'icon.png')
const out256 = path.resolve(__dirname, '..', 'resources', 'icon-256.png')
// 缩放到 1024x1024, JPEG 白色背景保持 (Windows 图标习惯)
await sharp(SRC)
.resize(1024, 1024, { fit: 'cover', position: 'center' })
.png({ quality: 95 })
.toFile(out1024)
await sharp(SRC)
.resize(256, 256, { fit: 'cover', position: 'center' })
.png()
.toFile(out256)
console.log('Generated:', out1024, out256)
function haveConvert() {
try {
execFileSync('convert', ['-version'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
main().catch(e => { console.error(e); process.exit(1) })
function convert(args) {
execFileSync('convert', args, { stdio: 'inherit' })
}
function main() {
if (!haveConvert()) {
console.error('需要 ImageMagick 的 convert 命令。')
console.error('Debian/Ubuntu: sudo apt install imagemagick')
process.exit(1)
}
const src = process.argv[2]
if (src) {
// 指定了源图: 先把基准图做成 512x512 (最高精度, 往下缩都清晰)
if (!fs.existsSync(src)) {
console.error('源图不存在:', src)
process.exit(1)
}
convert([src, '-resize', '512x512', '-background', 'none', '-gravity', 'center', '-extent', '512x512', BASE])
console.log('基准图已更新:', BASE)
}
if (!fs.existsSync(BASE)) {
console.error('基准图不存在:', BASE)
process.exit(1)
}
fs.mkdirSync(OUT_DIR, { recursive: true })
for (const s of SIZES) {
const out = path.join(OUT_DIR, `${s}x${s}.png`)
// <= 基准尺寸的直接从基准图降采样 (只重采样一次, 小图标才清晰);
// 超过基准尺寸的才放大, 放到最后单独处理
if (s <= 256) {
convert([BASE, '-resize', `${s}x${s}`, '-strip', out])
} else {
convert([BASE, '-resize', '512x512', '-background', 'none', '-gravity', 'center', '-extent', '512x512', out])
}
console.log('生成', path.relative(process.cwd(), out))
}
console.log('\n完成。打包时用 linux.icon = "resources/icons" 指向这个目录。')
}
main()
+48
View File
@@ -0,0 +1,48 @@
// 无头测试用的 electron 桩: 只提供 src/main 里真正用到的 API
// (app.getPath / app.getVersion / BrowserWindow / ipcMain / shell ...)
const os = require('node:os')
const path = require('node:path')
const fs = require('node:fs')
const root = process.env.LNM_SELFTEST_HOME || path.join(os.tmpdir(), 'lnm-selftest')
fs.mkdirSync(root, { recursive: true })
function ensure(p) {
fs.mkdirSync(p, { recursive: true })
return p
}
module.exports = {
app: {
isPackaged: false,
getPath(name) {
if (name === 'userData') return ensure(path.join(root, 'userData'))
if (name === 'documents') return ensure(path.join(root, 'documents'))
if (name === 'downloads') return ensure(path.join(root, 'downloads'))
if (name === 'home') return os.homedir()
return ensure(path.join(root, name))
},
getVersion: () => '0.1.0',
getName: () => 'local-net-msg',
getAppPath: () => process.cwd(),
on: () => {},
once: () => {},
whenReady: async () => {},
quit: () => {},
},
BrowserWindow: class BrowserWindow {
static getAllWindows() { return [] }
static getFocusedWindow() { return null }
isMinimized() { return false }
restore() {}
show() {}
focus() {}
webContents = { send: () => {} }
},
// 注意: 故意不提供 ipcRenderer —— electron-store 见到它会以为是渲染进程
ipcMain: { on: () => {}, once: () => {}, handle: () => {}, removeHandler: () => {} },
contextBridge: { exposeInMainWorld: () => {} },
dialog: { showErrorBox: () => {}, showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
shell: { openPath: async () => '', showItemInFolder: () => {} },
Notification: class { static isSupported() { return false } constructor() {} show() {} },
}
+676
View File
@@ -0,0 +1,676 @@
// LocalNetMsg 远程功能自测 (terminal / forward / usb)
// 在一个进程里跑两个虚拟节点 (A = 控制端, B = 被控端), 走真实的
// ChatServer + ChatClient + 三个 remote manager, 接线方式与 src/main/ipc.ts 完全一致.
//
// 用法:
// npm run selftest # 全部
// npm run selftest -- terminal # 只跑某些用例
import { spawn, ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync } from 'node:fs'
import * as fs from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { connect } from 'node:net'
import { createServer, Server } from 'node:http'
import { createHash } from 'node:crypto'
import { ChatServer } from '../../src/main/chat-server'
import { ChatClient } from '../../src/main/chat-client'
import { FileServer } from '../../src/main/file-server'
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
import { computeWsState } from '../../src/main/ws-state'
import { TerminalManager } from '../../src/main/remote/terminal'
import { ForwardManager } from '../../src/main/remote/forward'
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
import { updateSettings } from '../../src/main/settings'
import { listAudit } from '../../src/main/db'
import type { DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame } from '../../src/main/protocol'
// ============ 小型测试框架 ============
interface CaseResult { name: string; ok: boolean; detail: string; ms: number }
const results: CaseResult[] = []
let current = ''
function assert(cond: any, msg: string) {
if (!cond) throw new Error(msg)
}
function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) }
async function runCase(name: string, fn: () => Promise<void>) {
current = name
const t0 = Date.now()
try {
await fn()
results.push({ name, ok: true, detail: '', ms: Date.now() - t0 })
console.log(` \x1b[32mPASS\x1b[0m ${name} (${Date.now() - t0}ms)`)
} catch (e: any) {
const detail = String(e?.message || e)
results.push({ name, ok: false, detail, ms: Date.now() - t0 })
console.log(` \x1b[31mFAIL\x1b[0m ${name} (${Date.now() - t0}ms): ${detail}`)
}
}
// ============ 虚拟节点 ============
interface UiEvent { kind: string; sessionId?: string; peerId?: string; data?: string; text?: string; reason?: string }
class Node {
info: DeviceInfo
server: ChatServer
client: ChatClient
fileServer: FileServer
terminal: TerminalManager
forward: ForwardManager
usb: UsbBridgeManager
events: UiEvent[] = []
driveDir = ''
driveToken = ''
constructor(id: string, name: string) {
this.info = {
deviceId: id, name, hostname: name, platform: 'linux', version: 1,
appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(),
}
this.server = new ChatServer(0)
this.client = new ChatClient(this.info)
this.fileServer = new FileServer(0, join(tmpdir(), `lnm-selftest-dl-${id.slice(-4)}`), {
getDriveRoot: () => this.driveDir,
getDriveToken: () => this.driveToken,
isDriveEnabled: () => true,
})
this.terminal = new TerminalManager(this.client, id)
this.forward = new ForwardManager(this.client)
this.usb = new UsbBridgeManager(this.client)
}
async start() {
const port = await this.server.start()
this.info.chatPort = port
const filePort = await this.fileServer.start()
this.info.filePort = filePort
// 网盘目录: 放几个测试文件
this.driveDir = join(tmpdir(), `lnm-selftest-drive-${this.info.deviceId.slice(-4)}`)
rmSync(this.driveDir, { recursive: true, force: true })
mkdirSync(join(this.driveDir, 'sub', 'deep'), { recursive: true })
writeFileSync(join(this.driveDir, 'hello.txt'), 'HELLO-FROM-DRIVE')
writeFileSync(join(this.driveDir, 'sub', 'nested.txt'), 'NESTED-FROM-DRIVE')
writeFileSync(join(this.driveDir, 'sub', 'deep', 'deeper.txt'), 'DEEPER-FROM-DRIVE')
this.driveToken = `tok-${this.info.deviceId.slice(-4)}`
this.info.driveToken = this.driveToken
// ---- 与 src/main/ipc.ts 完全一致的帧路由 ----
const TRACE = !!process.env.LNM_TRACE
const route = (peer: DeviceInfo, frame: WsFrame) => {
if (TRACE) {
const p: any = (frame as any).payload
console.log(`[trace ${this.info.name}] <- ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
}
if (frame.type === 'terminal') {
this.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
.catch((e) => console.warn(`[${this.info.name}] terminal handle error:`, e?.message))
} else if (frame.type === 'forward') {
this.forward.handleIncoming(peer, frame.payload as ForwardFrame)
} else if (frame.type === 'usb') {
this.usb.handleIncoming(peer, frame.payload as UsbFrame)
}
}
this.server.on('frame', route)
this.client.on('message', route)
// ---- 渲染层能看到的事件 (对应 ipc.ts 里 broadcastToRenderer) ----
this.terminal.on('output', (sessionId, bytes, peerId) =>
this.events.push({ kind: 'terminal:output', sessionId, peerId, data: bytes.toString('base64') }))
this.terminal.on('sessionOpened', (sessionId, peerId) =>
this.events.push({ kind: 'terminal:opened', sessionId, peerId }))
this.terminal.on('sessionClosed', (sessionId, peerId, reason) =>
this.events.push({ kind: 'terminal:closed', sessionId, peerId, reason }))
this.terminal.on('approvalRequested', (req) =>
this.events.push({ kind: 'approval', peerId: req.peerId, text: req.detail }))
this.forward.on('sessionOpened', (i: any) =>
this.events.push({ kind: 'forward:opened', sessionId: i.sessionId, peerId: i.peerId }))
this.forward.on('sessionClosed', (i: any) =>
this.events.push({ kind: 'forward:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
this.usb.on('output', (sessionId, bytes, peerId) =>
this.events.push({ kind: 'usb:output', sessionId, peerId, data: bytes.toString('base64') }))
this.usb.on('sessionOpened', (i: any) =>
this.events.push({ kind: 'usb:opened', sessionId: i.sessionId, peerId: i.peerId }))
this.usb.on('sessionClosed', (i: any) =>
this.events.push({ kind: 'usb:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason }))
this.usb.on('sessionError', (i: any) =>
this.events.push({ kind: 'usb:error', sessionId: i.sessionId, peerId: i.peerId, text: i.error }))
this.client.on('error', (_peer, err) => console.warn(`[${this.info.name}] ws error: ${err.message}`))
// 追踪本端发出的帧
if (TRACE) {
const origSend = this.client.send.bind(this.client)
;(this.client as any).send = (frame: WsFrame, deviceId?: string) => {
const p: any = (frame as any).payload
console.log(`[trace ${this.info.name}] -> ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`)
return origSend(frame, deviceId)
}
}
}
connectTo(other: Node) {
this.client.connectTo({ ...other.info })
}
// 该节点收到的某个 kind 事件 (最近 n 秒内)
hasEvent(kind: string, pred?: (e: UiEvent) => boolean) {
return this.events.some(e => e.kind === kind && (!pred || pred(e)))
}
findEvents(kind: string) { return this.events.filter(e => e.kind === kind) }
clearEvents() { this.events = [] }
async stop() {
try { await this.server.stop() } catch {}
try { this.client.stop() } catch {}
try { this.terminal.stop() } catch {}
try { this.forward.stop() } catch {}
try { this.usb.stop() } catch {}
}
}
function decode(b64: string) { return Buffer.from(b64, 'base64').toString('utf8') }
// ============ 小工具 ============
function startHttpServer(port: number, body: string): Promise<Server> {
return new Promise((resolve, reject) => {
const s = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end(body)
})
s.on('error', reject)
s.listen(port, '127.0.0.1', () => resolve(s))
})
}
// 发起 HTTP 请求, 收到完整响应后**保持连接** (隧道是单连接设计, 连接断了整个转发就结束)
function httpGet(port: number, path = '/', timeoutMs = 5000): Promise<{ body: string; sock: Socket }> {
return new Promise((resolve, reject) => {
const req = connect({ host: '127.0.0.1', port }, () => {
req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\n\r\n`)
})
let buf = ''
let settled = false
const finish = () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ body: buf, sock: req })
}
// 目标服务是 keep-alive, 连接不会自己关 —— 按 Content-Length 判完整, 别等 'end'
const tryComplete = () => {
const idx = buf.indexOf('\r\n\r\n')
if (idx < 0) return
const m = /content-length:\s*(\d+)/i.exec(buf.slice(0, idx))
const need = m ? Number(m[1]) : 0
if (buf.length >= idx + 4 + need) finish()
}
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error(`http 超时 (收到 ${buf.length}B): ${buf.slice(0, 120)}`)) } }, timeoutMs)
req.on('data', (d) => { buf += d.toString('utf8'); tryComplete() })
req.on('end', () => finish())
req.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(e) } })
})
}
function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise<void> {
return new Promise((resolve, reject) => {
const t0 = Date.now()
const tick = () => {
if (pred()) return resolve()
if (Date.now() - t0 > timeoutMs) return reject(new Error('等待超时'))
setTimeout(tick, stepMs)
}
tick()
})
}
// 造一个 socat 串口对: 一头是 PTY (给被控端当真实串口), 另一头我们直接读写
async function startSocatSerial(linkDir: string): Promise<{ proc: ChildProcess; userPath: string; internalPath: string }> {
const userPath = join(linkDir, 'user')
const internalPath = join(linkDir, 'internal')
const proc = spawn('socat', [
'-d', '-d',
`pty,raw,echo=0,link=${userPath}`,
`pty,raw,echo=0,link=${internalPath}`,
], { stdio: ['ignore', 'pipe', 'pipe'] })
let err = ''
proc.stderr?.on('data', (b) => { err += b.toString() })
const t0 = Date.now()
while (!(existsSync(userPath) && existsSync(internalPath))) {
if (Date.now() - t0 > 5000) { proc.kill(); throw new Error('socat 起不来: ' + err) }
await sleep(100)
}
return { proc, userPath, internalPath }
}
// 打开 PTY 的一端做非阻塞读写
function openPty(path: string) {
const fd = openSync(path, fs.constants.O_RDWR | fs.constants.O_NONBLOCK)
return {
fd,
write: (s: string | Buffer) => writeSync(fd, s),
read: (): string => {
const buf = Buffer.alloc(4096)
try {
const n = readSync(fd, buf, 0, buf.length, null)
return n > 0 ? buf.slice(0, n).toString('utf8') : ''
} catch (e: any) {
if (e?.code === 'EAGAIN' || e?.code === 'EWOULDBLOCK') return ''
throw e
}
},
close: () => { try { closeSync(fd) } catch {} },
}
}
// ============ 主流程 ============
async function main() {
const filter = process.argv.slice(2).filter(a => !a.startsWith('-'))
const want = (name: string) => !filter.length || filter.some(f => name.toLowerCase().includes(f.toLowerCase()))
const step = (m: string) => console.log(`[step] ${m}`)
step('建节点 A')
const A = new Node('peer-aaaaaaaa-0000-4000-8000-00000000000a', '控制端A')
step('建节点 B')
const B = new Node('peer-bbbbbbbb-0000-4000-8000-00000000000b', '被控端B')
step('启动 A')
await A.start()
step('启动 B')
await B.start()
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort})`)
// 双向连通 (模拟 discovery 找到对方后双方都连上)
step('建立 WS 连接')
A.connectTo(B)
B.connectTo(A)
await waitFor(() => A.client.isOpen(B.info.deviceId) && B.client.isOpen(A.info.deviceId), 8000)
console.log(`\n两个节点已连通: A(:${A.info.chatPort}) <-> B(:${B.info.chatPort})\n`)
// 授权: 双方互相放行, 避免弹窗 (等价于设置里勾了"允许")
updateSettings({
remoteEnabled: true,
remoteAllowPeers: {
[A.info.deviceId]: { terminal: true, forward: true, usb: true },
[B.info.deviceId]: { terminal: true, forward: true, usb: true },
},
})
// ============ 终端 ============
console.log('\x1b[1m[终端]\x1b[0m')
let termSessionId = ''
const TERM_MARK = 'LNM_TERM_SELFTEST'
if (want('terminal') || want('term')) {
await runCase('T1 打开终端: 控制端收到 ack ok', async () => {
const r = await A.terminal.openOnPeer(B.info.deviceId, { rows: 24, cols: 80 })
assert(r.ok, `open 被拒: ${r.reason}`)
assert(!!r.sessionId, '没有 sessionId')
termSessionId = r.sessionId!
})
await runCase('T2 控制端能收到终端输出 (提示符/回显)', async () => {
const got = await waitFor(() => A.hasEvent('terminal:output'), 6000).then(() => true).catch(() => false)
assert(got, '控制端 6s 内没有收到任何 terminal:output 事件 —— 终端画面是空的')
const text = A.findEvents('terminal:output').map(e => decode(e.data!)).join('')
assert(text.length > 0, '收到的输出为空')
})
await runCase('T3 输入命令, 输出能回显到控制端', async () => {
A.clearEvents()
const ok = A.terminal.sendInput(termSessionId, `echo ${TERM_MARK}\n`)
assert(ok, 'sendInput 失败 (会话不存在?)')
await waitFor(() => A.findEvents('terminal:output').map(e => decode(e.data!)).join('').includes(TERM_MARK), 8000)
.catch(() => { throw new Error('8s 内没有看到命令回显 ' + TERM_MARK) })
})
await runCase('T4 窗口尺寸 resize 能传到被控端', async () => {
A.terminal.resize(termSessionId, 40, 120)
await sleep(300)
const sess = B.terminal.listServerSessions().find(s => s.sessionId === termSessionId)
assert(!!sess, '被控端没有这个 session')
assert(sess!.rows === 40 && sess!.cols === 120, `被控端尺寸不对: ${sess!.rows}x${sess!.cols}`)
})
await runCase('T5 关闭终端: 被控端 session 被回收', async () => {
A.terminal.close(termSessionId, 'selftest')
await waitFor(() => B.terminal.listServerSessions().every(s => s.sessionId !== termSessionId), 5000)
.catch(() => { throw new Error('被控端 session 没有被回收') })
})
}
// ============ 端口转发 ============
console.log('\n\x1b[1m[端口转发]\x1b[0m')
if (want('forward') || want('for')) {
const targetPort = 47881 // 被控端的目标服务
const listenPort = 47882 // 控制端的本地监听
const peerListenPort = 47883 // 被控端的监听 (self-in)
const ctrlTargetPort = 47884 // 控制端的目标服务 (self-in)
const peerTarget = await startHttpServer(targetPort, 'HELLO-FROM-PEER')
const ctrlTarget = await startHttpServer(ctrlTargetPort, 'HELLO-FROM-CONTROL')
const openSocks: Socket[] = []
try {
await runCase('F1 self-out: 控制端 127.0.0.1:47882 拿到被控端服务内容', async () => {
const r = await A.forward.openOnPeer(B.info.deviceId, {
direction: 'self-out', listenPort, targetHost: '127.0.0.1', targetPort, ttlSec: 120,
})
assert(r.ok, `open 被拒: ${r.reason}`)
const { body, sock } = await httpGet(listenPort)
openSocks.push(sock)
assert(body.includes('HELLO-FROM-PEER'), `拿到的是: ${body.slice(0, 120)}`)
})
await runCase('F2 self-in: 被控端 127.0.0.1:47883 拿到控制端服务内容', async () => {
const r = await A.forward.openOnPeer(B.info.deviceId, {
direction: 'self-in', listenPort: peerListenPort, targetHost: '127.0.0.1', targetPort: ctrlTargetPort, ttlSec: 120,
})
assert(r.ok, `open 被拒: ${r.reason}`)
const { body, sock } = await httpGet(peerListenPort)
openSocks.push(sock)
assert(body.includes('HELLO-FROM-CONTROL'), `拿到的是: ${body.slice(0, 120)}`)
})
await runCase('F3 拒绝把本机自身端口 (47900) 作为转发目标', async () => {
const r = await A.forward.openOnPeer(B.info.deviceId, {
direction: 'self-out', listenPort: 47890, targetHost: '127.0.0.1', targetPort: 47900, ttlSec: 60,
})
assert(!r.ok, '自身端口竟然被放行了')
})
await runCase('F4 转发字节计数在两端都有记录', async () => {
const cs = A.forward.listClientSessions()
assert(cs.length > 0, '控制端没有转发 session')
const s = cs[0]
assert(s.bytesIn > 0 && s.bytesOut > 0, `字节计数不对: in=${s.bytesIn} out=${s.bytesOut}`)
})
await runCase('F5 关掉"对方发起的"转发: 对端 session 和监听端口都要释放', async () => {
// 由 B 发起: B bind 47885, A 作为 server 侧连 B 填的目标
const r = await B.forward.openOnPeer(A.info.deviceId, {
direction: 'self-out', listenPort: 47885, targetHost: '127.0.0.1', targetPort: targetPort, ttlSec: 300,
})
assert(r.ok, `B 发起转发失败: ${r.reason}`)
const serverSess = A.forward.listServerSessions().find(s => s.sessionId === r.sessionId)
assert(!!serverSess, 'A 侧没有对应的 server session (对方发起的)')
assert(B.forward.listClientSessions().some(s => s.sessionId === r.sessionId), 'B 侧 client session 不存在')
// A 在 UI 上点"关闭" (对方发起的) —— 走 closeSession, 要能关掉 server 侧
const closed = A.forward.closeSession(r.sessionId!, 'user')
assert(closed, 'closeSession 没关掉任何东西')
assert(A.forward.listServerSessions().every(s => s.sessionId !== r.sessionId), 'A 侧 server session 还在')
// 关键: close 帧要发到对端, B 的 client session 及其监听端口要一起回收
await waitFor(() => B.forward.listClientSessions().every(s => s.sessionId !== r.sessionId), 5000)
.catch(() => { throw new Error('对端的 client session 没被回收 (close 帧没发出去?)') })
let portStillOpen = true
try {
await new Promise<void>((res, rej) => {
const sock = connect({ host: '127.0.0.1', port: 47885 }, () => { sock.destroy(); res() })
sock.on('error', () => rej(new Error('refused')))
setTimeout(() => { sock.destroy(); rej(new Error('timeout')) }, 2000)
})
} catch { portStillOpen = false }
assert(!portStillOpen, '对端的监听端口 47885 还占着, 没释放')
})
} finally {
for (const s of openSocks) { try { s.destroy() } catch {} }
peerTarget.close(); ctrlTarget.close()
}
}
// ============ USB / 串口 ============
console.log('\n\x1b[1m[USB / 串口]\x1b[0m')
if (want('usb')) {
await runCase('U1 列对方设备: 请求-响应链路通', async () => {
const r = await A.usb.listOnPeer(B.info.deviceId)
assert(r.ok, `list 失败: ${r.reason}`)
assert(Array.isArray(r.devices), 'devices 不是数组')
})
await runCase('U2 USB 模式设备枚举 (libusb) 能列出真机设备', async () => {
const devs = await B.usb.listLocal()
const usbDevs = devs.filter(d => d.kind === 'usb')
console.log(` (枚举到 ${devs.length} 个设备, 其中 usb ${usbDevs.length} 个)`)
assert(usbDevs.length > 0, '一个 libusb 设备都没枚举到 —— USB 模式不可用')
})
await runCase('U2b 内核占用的设备: attach 失败原因能正确回传到控制端', async () => {
const devs = await B.usb.listLocal()
const usbDevs = devs.filter(d => d.kind === 'usb')
assert(usbDevs.length > 0, '没有可用的 usb 设备')
// 这台机器上的设备都被内核驱动占着 (键鼠/摄像头/蓝牙), 测试里不让它抢驱动
const pick = usbDevs.find(d => /hub/i.test(d.product || '')) || usbDevs[0]
console.log(` (选用设备 ${pick.busId} ${pick.product || ''})`)
const r = await A.usb.attach(B.info.deviceId, {
busId: pick.busId, direction: 'self-out',
config: { kind: 'usb', configurationValue: 1, interfaceNumber: 0, detachKernelDriver: false },
})
// 期望: 要么成功, 要么失败原因能传回来 (而不是超时/静默)
if (!r.ok) {
console.log(` (按预期被内核占用挡住: ${r.reason})`)
assert(!!r.reason && r.reason.length > 0, '失败时没有给出原因')
}
})
await runCase('U2c 控制传输 setup 翻译正确, 真机 GET_DESCRIPTOR 能拿到数据', async () => {
const usbMod: any = require('usb')
const devs = await usbMod.webusb.loadDevices()
assert(devs.length > 0, 'libusb 枚举不到设备')
const dev = devs.find((d: any) => d.vendorId === 0x0c45 && d.productId === 0x6366) || devs[0]
await dev.open()
try {
// 用应用里同一套翻译函数 (bmRequestType 数值 -> node-usb v3 字符串枚举)
const setup = toUsbControlSetup({ requestType: 0x80, request: 0x06, value: 0x0100, index: 0x0000 })
const buf = await dev.nativeControlTransferIn(setup, 3000, 18)
assert(!!buf && buf.length === 18, `描述符长度不对: ${buf?.length}`)
const hexd = Buffer.from(buf).toString('hex')
console.log(` (descriptor: ${hexd})`)
assert(hexd.startsWith('1201'), '不是合法的设备描述符')
} finally {
await dev.close().catch(() => {})
}
})
// 串口双向字节流: 用 socat 造一个 PTY 对冒充"真串口"
const vcomDir = join(tmpdir(), `lnm-selftest-vcom-${Date.now()}`)
mkdirSync(vcomDir, { recursive: true })
let socatProc: ChildProcess | null = null
let devEnd: ReturnType<typeof openPty> | null = null
try {
const pair = await startSocatSerial(vcomDir)
socatProc = pair.proc
// 被控端持有 "user" 端 (当真实串口), 测试这边拿 "internal" 端当外部设备
B.usb.listLocal = async () => ([{
busId: `serial:${pair.userPath}`, vid: 0, pid: 0, deviceClass: 0x02, deviceSubclass: 0x02,
product: 'selftest-pty', manufacturer: 'socat', kind: 'serial', serialPath: pair.userPath, baudRate: 115200,
}] as any)
await runCase('U3 attach 串口: 控制端能拿到 attached ok', async () => {
const r = await A.usb.attach(B.info.deviceId, {
busId: `serial:${pair.userPath}`, direction: 'self-out',
config: { kind: 'serial', baudRate: 115200 },
})
assert(r.ok, `attach 被拒: ${r.reason}`)
})
await runCase('U4 串口双向字节流: 控制端 -> 设备', async () => {
const sess = A.usb.listClientSessions()[0]
assert(!!sess, '控制端没有 usb session')
devEnd = openPty(pair.internalPath)
const ok = await A.usb.sendSerialBytes(sess.sessionId, Buffer.from('HELLO-FROM-CONTROL\n'))
assert(ok, 'sendSerialBytes 失败')
// 设备端应该收到
let seen = ''
for (let i = 0; i < 50 && !seen.includes('HELLO-FROM-CONTROL'); i++) {
await sleep(100)
seen += devEnd.read()
}
assert(seen.includes('HELLO-FROM-CONTROL'), `设备端没收到: ${JSON.stringify(seen)}`)
})
await runCase('U5 串口双向字节流: 设备 -> 控制端', async () => {
assert(!!devEnd, '设备端没准备好')
A.clearEvents()
devEnd.write('HELLO-FROM-DEVICE\n')
await waitFor(() => A.findEvents('usb:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000)
.catch(() => { throw new Error('控制端没收到设备发来的字节') })
})
} finally {
try { devEnd?.close() } catch {}
try { socatProc?.kill() } catch {}
try { rmSync(vcomDir, { recursive: true, force: true }) } catch {}
}
}
// ============ 网盘 ============
console.log('\n\x1b[1m[网盘]\x1b[0m')
if (want('drive') || want('网盘')) {
await runCase('D1 列对方网盘根目录', async () => {
const r = await listDrive(B.info, '')
assert(r.ok, `列目录失败: ${r.reason}`)
const names = (r.entries || []).map(e => e.name)
assert(names.includes('hello.txt'), `没看到 hello.txt, 实际: ${names.join(',')}`)
assert(names.includes('sub'), `没看到子目录 sub, 实际: ${names.join(',')}`)
const f = (r.entries || []).find(e => e.name === 'hello.txt')!
assert(f.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${f.size}`)
assert(!f.isDir, '文件被标成目录了')
})
await runCase('D2 进子目录再列一次', async () => {
const r = await listDrive(B.info, 'sub')
assert(r.ok, `列子目录失败: ${r.reason}`)
const names = (r.entries || []).map(e => e.name)
assert(names.includes('nested.txt'), `实际: ${names.join(',')}`)
})
await runCase('D3 下载文件: 内容一致', async () => {
const dest = join(tmpdir(), `lnm-selftest-dl-${Date.now()}`)
mkdirSync(dest, { recursive: true })
const r = await downloadDriveFile({ peer: B.info, relPath: 'hello.txt', destDir: dest })
assert(r.ok, `下载失败: ${r.reason}`)
const content = readFileSync(r.path!, 'utf8')
assert(content === 'HELLO-FROM-DRIVE', `内容不对: ${content}`)
assert(r.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${r.size}`)
})
await runCase('D4 下载子目录里的文件', async () => {
const dest = join(tmpdir(), `lnm-selftest-dl2-${Date.now()}`)
mkdirSync(dest, { recursive: true })
const r = await downloadDriveFile({ peer: B.info, relPath: 'sub/nested.txt', destDir: dest })
assert(r.ok, `下载失败: ${r.reason}`)
assert(readFileSync(r.path!, 'utf8') === 'NESTED-FROM-DRIVE', '内容不对')
})
await runCase('D5 目录穿越被挡住 (.. 逃不出网盘根)', async () => {
const r = await listDrive(B.info, '../../../../etc')
assert(!r.ok, '竟然列出了网盘之外的目录!')
const r2 = await listDrive(B.info, '..')
assert(!r2.ok, '竟然能列出上级目录!')
})
await runCase('D6 token 错误 -> 拒绝访问', async () => {
const bad = { ...B.info, driveToken: 'wrong-token' }
const r = await listDrive(bad, '')
assert(!r.ok, '错误 token 竟然能访问!')
assert(/令牌/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
})
await runCase('D7 对方没开网盘 (无 token) -> 明确提示不支持', async () => {
const r = await listDrive({ ...B.info, driveToken: undefined }, '')
assert(!r.ok, '无 token 竟然能访问!')
assert(/不支持/.test(r.reason || ''), `错误信息不对: ${r.reason}`)
})
await runCase('D8 递归收集目录下的全部文件', async () => {
const r = await collectDriveFiles(B.info, 'sub')
assert(r.ok, `收集失败: ${r.reason}`)
const names = r.files.map(f => f.relPath).sort()
assert(names.includes('sub/nested.txt'), `缺 nested.txt: ${names.join(',')}`)
assert(names.includes('sub/deep/deeper.txt'), `缺深层文件: ${names.join(',')}`)
})
await runCase('D9 整目录下载: 保持层级', async () => {
const dest = join(tmpdir(), `lnm-selftest-dl3-${Date.now()}`)
mkdirSync(dest, { recursive: true })
const collected = await collectDriveFiles(B.info, 'sub')
assert(collected.ok, '收集失败')
for (const f of collected.files) {
const relInside = f.relPath.slice('sub'.length).replace(/^[/\\]+/, '')
const dirInside = relInside ? dirname(relInside) : ''
const destSubPath = dirInside && dirInside !== '.' ? join('sub', dirInside) : 'sub'
const r = await downloadDriveFile({ peer: B.info, relPath: f.relPath, destDir: dest, destSubPath })
assert(r.ok, `下载 ${f.relPath} 失败: ${r.reason}`)
}
assert(existsSync(join(dest, 'sub', 'nested.txt')), '层级不对: 少了 sub/nested.txt')
assert(existsSync(join(dest, 'sub', 'deep', 'deeper.txt')), '层级不对: 少了 sub/deep/deeper.txt')
assert(readFileSync(join(dest, 'sub', 'deep', 'deeper.txt'), 'utf8') === 'DEEPER-FROM-DRIVE', '内容不对')
})
}
// ============ 消息通道状态 ============
// 回归: 启动时主进程先连上对端, 渲染层后订阅 -> open 事件被丢掉, UI 永久显示"等待重连"
// 所以状态必须是"拉取式"的: 任何时刻去查都能查到当前真实状态
if (want('ws') || want('state')) {
console.log('\n\x1b[1m[通道状态]\x1b[0m')
await runCase('W1 通道状态可随时拉取 (连接早已建好, 现在才查也查得到)', async () => {
assert(A.client.isOpen(B.info.deviceId), '前提不成立: A->B 的 outgoing 没开')
// 模拟"渲染层晚订阅": 此刻才去查状态
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
assert(st[B.info.deviceId] === true, `状态不对: ${JSON.stringify(st)}`)
})
await runCase('W2 只有 inbound 通 (对方连我, 我没主动连) 也算通道可用', async () => {
const C = new Node('peer-cccccccc-0000-4000-8000-00000000000c', '只入站C')
await C.start()
try {
B.connectTo(C) // 只有 B -> C 这一条腿
await waitFor(() => C.server.isConnected(B.info.deviceId), 8000)
assert(!C.client.isOpen(B.info.deviceId), '前提不成立: C 不该有 outgoing 连接')
const st = computeWsState([B.info.deviceId], C.client.getStatus(), C.server)
assert(st[B.info.deviceId] === true, `只有 inbound 通时不该判死: ${JSON.stringify(st)}`)
} finally {
await C.stop()
}
})
await runCase('W3 两条腿都断了 -> 状态回落 false', async () => {
A.client.remove(B.info.deviceId)
B.client.remove(A.info.deviceId)
await waitFor(() => !A.server.isConnected(B.info.deviceId), 5000)
await waitFor(() => !B.server.isConnected(A.info.deviceId), 5000)
const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server)
assert(st[B.info.deviceId] === false, `断了还报可用: ${JSON.stringify(st)}`)
})
}
// ============ 审计 ============
if (want('audit')) {
console.log('\n\x1b[1m[审计]\x1b[0m')
await runCase('A1 关键操作都写进了 audit_log', async () => {
const rows = listAudit({ limit: 200 })
const actions = new Set(rows.map(r => r.action))
const need = ['terminal.open', 'forward.open']
for (const n of need) assert(actions.has(n), `audit 里没有 ${n} (现有: ${[...actions].join(', ')})`)
})
}
// ============ 汇总 ============
await A.stop()
await B.stop()
const pass = results.filter(r => r.ok).length
const fail = results.length - pass
console.log(`\n\x1b[1m结果: ${pass} 通过 / ${fail} 失败\x1b[0m`)
if (fail) {
console.log('\n失败用例:')
for (const r of results.filter(x => !x.ok)) console.log(` - ${r.name}\n ${r.detail}`)
}
process.exit(fail ? 1 : 0)
}
main().catch((e) => {
console.error('自测脚本异常:', e)
process.exit(2)
})
+15
View File
@@ -0,0 +1,15 @@
// @lydell/node-pty 的 module.exports 带 __esModule 标记 (非枚举),
// esbuild 的 __toESM interop 会因此认为它"没有 default 导出",
// 导致 terminal.ts 里的 `(nodePty as any).default ?? nodePty` 变成 undefined.default.
// 这里包一层, 重新导出一个"干净"的 CJS 对象.
//
// 注意: 必须用变量拼包名, 否则 esbuild 的 --alias 会把这里的 require 也重写成指向自己.
const PKG = '@lydell/node-pty'
const m = require(PKG)
module.exports = {
spawn: m.spawn,
fork: m.fork,
createTerminal: m.createTerminal,
open: m.open,
native: m.native,
}
+22 -4
View File
@@ -25,20 +25,31 @@ export class ChatServer extends EventEmitter {
super()
this.port = port
this.wss = new WebSocketServer({ port, host: '0.0.0.0' })
// 连接处理必须在这里挂: ws 在构造时就 listen, 'listening' 事件可能早于 start() 挂监听而丢失,
// 那种情况下 start() 走 "已在监听" 分支, 不会再走到下面的 on('connection')
this.wss.on('connection', (ws, req) => this.onConnection(ws, req))
}
getPort(): number {
return this.wss.address() as { port: number } | null as any
const addr = this.wss.address()
return typeof addr === 'object' && addr ? addr.port : this.port
}
start(): Promise<number> {
return new Promise((resolve, reject) => {
// ws 的 WebSocketServer 在构造时就 listen 了, 'listening' 事件可能早于本方法挂监听而丢失
// (例如同一 tick 里先 await 了别的东西再调 start()) —— 这里先看是否已经在监听
const addr = this.wss.address()
if (addr && typeof addr === 'object' && addr.port) {
this.port = addr.port
resolve(addr.port)
return
}
this.wss.once('error', reject)
this.wss.once('listening', () => {
const addr = this.wss.address()
const realPort = typeof addr === 'object' && addr ? addr.port : this.port
const a = this.wss.address()
const realPort = typeof a === 'object' && a ? a.port : this.port
this.port = realPort
this.wss.on('connection', (ws, req) => this.onConnection(ws, req))
resolve(realPort)
})
})
@@ -116,6 +127,13 @@ export class ChatServer extends EventEmitter {
return true
}
// 该设备是否有活着的入站连接 (对方主动连过来的 ws)
// 与 chat-client 的 outgoing 相对: 任何一条通, 消息通道就是通的
isConnected(deviceId: string): boolean {
const ws = this.sockets.get(deviceId)
return !!ws && ws.readyState === WebSocket.OPEN
}
async stop() {
for (const ws of this.sockets.values()) {
try { ws.close(1001, 'server shutdown') } catch {}
+9 -4
View File
@@ -20,7 +20,8 @@ CREATE TABLE IF NOT EXISTS devices (
app_version TEXT,
last_ip TEXT,
last_seen INTEGER NOT NULL,
ignored INTEGER NOT NULL DEFAULT 0
ignored INTEGER NOT NULL DEFAULT 0,
drive_token TEXT
);
CREATE TABLE IF NOT EXISTS messages (
@@ -63,6 +64,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
// 迁移: 给老库补 ignored 列 (用 try/catch 包住, 已存在会抛)
try { db.exec(`ALTER TABLE devices ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0`) } catch {}
// 迁移: 给老库补 drive_token 列 (网盘访问令牌, 老版本没有)
try { db.exec(`ALTER TABLE devices ADD COLUMN drive_token TEXT`) } catch {}
// conversation_key: 任意两个 deviceId 排序后 join, 1对1
export function conversationKey(a: string, b: string): string {
@@ -79,18 +82,20 @@ export interface DeviceRow {
last_ip: string | null
last_seen: number
ignored: number // 0/1
drive_token: string | null // 网盘访问令牌 (对端用它访问我的网盘)
}
const upsertDeviceStmt = db.prepare(`
INSERT INTO devices (device_id, name, hostname, platform, app_version, last_ip, last_seen)
VALUES (@device_id, @name, @hostname, @platform, @app_version, @last_ip, @last_seen)
INSERT INTO devices (device_id, name, hostname, platform, app_version, last_ip, last_seen, drive_token)
VALUES (@device_id, @name, @hostname, @platform, @app_version, @last_ip, @last_seen, @drive_token)
ON CONFLICT(device_id) DO UPDATE SET
name = excluded.name,
hostname = excluded.hostname,
platform = excluded.platform,
app_version = excluded.app_version,
last_ip = excluded.last_ip,
last_seen = excluded.last_seen
last_seen = excluded.last_seen,
drive_token = excluded.drive_token
`)
export function upsertDevice(d: DeviceRow) {
+213
View File
@@ -0,0 +1,213 @@
// 网盘客户端: 从对端的 HTTP 文件服务拉目录列表 / 下载文件到本地
//
// 对端接口 (src/main/file-server.ts):
// GET /drive/meta?token= -> { ok, dir }
// GET /drive/list?token=&path= -> { ok, path, entries[] }
// GET /drive/download?token=&path= -> 文件流
//
// token 由对端在局域网发现包里广播, 没 token = 对端不支持网盘 (老版本)
import { request as httpRequest, get as httpGet } from 'node:http'
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs'
import { join, basename, dirname } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { DeviceInfo } from './protocol'
export interface DriveEntryView {
name: string
path: string
size: number
mtime: number
isDir: boolean
}
export interface DriveListResult {
ok: boolean
reason?: string
path?: string
entries?: DriveEntryView[]
/** 对端网盘根目录 (本机路径, 仅提示用) */
root?: string
}
/** 对端是否支持网盘 (有没有广播 token) */
export function peerSupportsDrive(peer: { driveToken?: string } | null | undefined): boolean {
return !!(peer && peer.driveToken)
}
function driveBase(peer: DeviceInfo): { host: string; port: number; token: string } | null {
const token = peer.driveToken
if (!token) return null
const host = peer.address
if (!host) return null
const port = peer.filePort
if (!port) return null
return { host, port, token }
}
/** 列出对端网盘某个目录 (relPath 为相对网盘根的路径, '' = 根) */
export function listDrive(peer: DeviceInfo, relPath = '', timeoutMs = 8000): Promise<DriveListResult> {
const base = driveBase(peer)
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
const q = `token=${encodeURIComponent(base.token)}&path=${encodeURIComponent(relPath)}`
const url = `http://${base.host}:${base.port}/drive/list?${q}`
return new Promise((resolve) => {
const req = httpGet(url, { timeout: timeoutMs }, (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
try {
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
if (res.statusCode === 403) return resolve({ ok: false, reason: data?.error || '网盘令牌无效 (对方可能改了设置)' })
if (!data?.ok) return resolve({ ok: false, reason: data?.error || '读取失败' })
resolve({ ok: true, path: data.path, entries: data.entries || [] })
} catch (e: any) {
resolve({ ok: false, reason: e?.message || '解析失败' })
}
})
})
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: '读取超时' }) })
req.on('error', (e) => resolve({ ok: false, reason: e?.message || '连接失败' }))
})
}
/** 取对端网盘根目录 (只用于显示"对方的网盘在 xxx") */
export function getDriveRoot(peer: DeviceInfo, timeoutMs = 8000): Promise<{ ok: boolean; dir?: string; reason?: string }> {
const base = driveBase(peer)
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
const url = `http://${base.host}:${base.port}/drive/meta?token=${encodeURIComponent(base.token)}`
return new Promise((resolve) => {
const req = httpGet(url, { timeout: timeoutMs }, (res) => {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
try {
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
resolve({ ok: !!data?.ok, dir: data?.dir, reason: data?.error })
} catch (e: any) {
resolve({ ok: false, reason: e?.message || '解析失败' })
}
})
})
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: '读取超时' }) })
req.on('error', (e) => resolve({ ok: false, reason: e?.message || '连接失败' }))
})
}
export interface DriveDownloadOptions {
peer: DeviceInfo
/** 对端网盘里的相对路径 */
relPath: string
/** 本地保存目录 */
destDir: string
/** 存到 destDir 下的相对子路径 (下载整个目录时用来保持层级) */
destSubPath?: string
onProgress?: (sent: number, total: number) => void
}
/** 递归收集目录下的所有文件 (返回相对网盘根的路径) */
export async function collectDriveFiles(
peer: DeviceInfo,
relPath: string,
out: { relPath: string; size: number }[] = [],
depth = 0,
): Promise<{ ok: boolean; reason?: string; files: { relPath: string; size: number }[] }> {
if (depth > 12) return { ok: false, reason: '目录层级太深', files: out }
const r = await listDrive(peer, relPath)
if (!r.ok) {
// 不是目录 -> 当作单个文件
if (depth === 0) return { ok: true, files: [{ relPath, size: 0 }] }
return { ok: false, reason: r.reason, files: out }
}
for (const e of r.entries || []) {
if (e.isDir) {
const sub = await collectDriveFiles(peer, e.path, out, depth + 1)
if (!sub.ok) return sub
} else {
out.push({ relPath: e.path, size: e.size })
}
}
return { ok: true, files: out }
}
/** 从对端网盘下载一个文件到本地目录 (重名自动加 _1) */
export function downloadDriveFile(opts: DriveDownloadOptions): Promise<{ ok: boolean; path?: string; size?: number; reason?: string }> {
const base = driveBase(opts.peer)
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
if (!existsSync(opts.destDir)) mkdirSync(opts.destDir, { recursive: true })
const name = basename(opts.relPath) || 'file'
// 保持目录层级: destSubPath 里的目录要先建出来
const subDir = opts.destSubPath ? join(opts.destDir, opts.destSubPath) : opts.destDir
if (!existsSync(subDir)) mkdirSync(subDir, { recursive: true })
const target = uniquePath(join(subDir, name))
const path = `/drive/download?token=${encodeURIComponent(base.token)}&path=${encodeURIComponent(opts.relPath)}`
return new Promise((resolve) => {
let sent = 0
let total = 0
let lastEmit = 0
let failed = false
const ws = createWriteStream(target)
const fail = (reason: string) => {
if (failed) return
failed = true
try { ws.destroy() } catch {}
try { unlinkSync(target) } catch {}
resolve({ ok: false, reason })
}
const req = httpRequest({ host: base.host, port: base.port, method: 'GET', path, timeout: 120_000 })
req.on('timeout', () => { req.destroy(new Error('下载超时')); fail('下载超时') })
req.on('error', (e) => fail(e?.message || '下载失败'))
req.on('response', (res) => {
if (res.statusCode !== 200) {
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
let msg = `HTTP ${res.statusCode}`
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8'))?.error || msg } catch {}
fail(msg)
})
return
}
const len = res.headers['content-length']
total = len ? Number(len) : 0
res.on('data', (chunk: Buffer) => {
sent += chunk.length
const now = Date.now()
if (now - lastEmit >= 100 || (total && sent >= total)) {
lastEmit = now
opts.onProgress?.(sent, total || sent)
}
})
res.pipe(ws)
ws.on('finish', () => {
opts.onProgress?.(sent, total || sent)
resolve({ ok: true, path: target, size: sent })
})
ws.on('error', (e) => fail(e?.message || '写入失败'))
})
req.end()
})
}
// 同名文件自动加 _1, _2 ...
function uniquePath(p: string): string {
if (!existsSync(p)) return p
const dir = dirname(p)
const ext = p.includes('.') && !p.endsWith('.') ? p.slice(p.lastIndexOf('.')) : ''
const stem = ext ? p.slice(0, p.length - ext.length) : p
for (let i = 1; i < 9999; i++) {
const cand = `${stem}_${i}${ext}`
if (!existsSync(cand)) return cand
}
return `${stem}_${Date.now()}${ext}`
}
/** 生成一个批次 id (下载任务用) */
export function newBatchId(): string {
return randomUUID()
}
+119 -3
View File
@@ -10,16 +10,38 @@
import { createServer, IncomingMessage, ServerResponse } from 'node:http'
import { EventEmitter } from 'node:events'
import { createWriteStream, existsSync, statSync, mkdirSync } from 'node:fs'
import { createWriteStream, existsSync, statSync, mkdirSync, readdirSync } from 'node:fs'
import { createReadStream } from 'node:fs'
import { mkdir, stat, unlink, rename } from 'node:fs/promises'
import { join, extname, resolve, basename } from 'node:path'
import { join, extname, resolve, basename, sep } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { getSettings } from './settings'
export interface FileServerEvents {
received: [info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }]
progress: [info: { fileId: string; sent: number; total: number; fromAddr: string }]
/** 对端从我的网盘下载了一个文件 */
driveDownloaded: [info: { name: string; size: number; relPath: string; fromAddr: string }]
}
/** 网盘接口的回调 (设置项运行时可变, 所以用 getter 而不是固定值) */
export interface DriveOptions {
/** 网盘根目录 (对端只能看到这个目录里的内容) */
getDriveRoot: () => string
/** 访问令牌; 返回空 = 不提供网盘服务 */
getDriveToken: () => string | undefined
/** 总开关 */
isDriveEnabled: () => boolean
}
/** 网盘目录里的一个条目 */
export interface DriveEntry {
name: string
/** 相对网盘根目录的路径 (给对端用) */
path: string
size: number
mtime: number
isDir: boolean
}
// 单文件大小上限, 默认 100GB (LAN 局域网传大视频常见, 不限太死)
@@ -63,11 +85,13 @@ export class FileServer extends EventEmitter {
private server = createServer((req, res) => this.handle(req, res))
private port: number
private root: string
private drive: DriveOptions | null = null
constructor(port: number, root: string) {
constructor(port: number, root: string, drive?: DriveOptions) {
super()
this.port = port
this.root = root
this.drive = drive || null
}
start(): Promise<number> {
@@ -130,12 +154,104 @@ export class FileServer extends EventEmitter {
await this.handleGetFile(url, res)
return
}
// ---- 网盘 ----
if (url.startsWith('/drive/') && req.method === 'GET') {
await this.handleDrive(req, url, res)
return
}
this.json(res, 404, { ok: false, error: 'not found' })
} catch (e: any) {
this.json(res, 500, { ok: false, error: e?.message || 'server error' })
}
}
// ========== 网盘 ==========
// 把对端传来的相对路径解析成绝对路径, 并确保没有跳出网盘根目录
private resolveDrivePath(rel: string): { abs: string; root: string } | null {
if (!this.drive) return null
const root = resolve(this.drive.getDriveRoot())
const clean = (rel || '').replace(/^[/\\]+/, '')
const abs = resolve(join(root, clean))
// 必须严格在 root 之内 (相等或以 root + sep 开头)
if (abs !== root && !abs.startsWith(root + sep)) return null
return { abs, root }
}
private driveTokenOk(token: string | null): boolean {
if (!this.drive) return false
if (!this.drive.isDriveEnabled()) return false
const expect = this.drive.getDriveToken()
if (!expect) return false
return !!token && token === expect
}
private async handleDrive(req: IncomingMessage, url: string, res: ServerResponse) {
const qIdx = url.indexOf('?')
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
const token = params.get('token')
if (!this.drive || !this.drive.isDriveEnabled()) {
this.json(res, 404, { ok: false, error: '网盘未开启' })
return
}
if (!this.driveTokenOk(token)) {
this.json(res, 403, { ok: false, error: '网盘令牌无效' })
return
}
const route = url.slice('/drive/'.length, qIdx < 0 ? undefined : qIdx)
if (route === 'meta') {
this.json(res, 200, { ok: true, dir: this.drive.getDriveRoot() })
return
}
if (route === 'list') {
const rel = params.get('path') || ''
const r = this.resolveDrivePath(rel)
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
let st: any
try { st = statSync(r.abs) } catch { this.json(res, 404, { ok: false, error: '目录不存在' }); return }
if (!st.isDirectory()) { this.json(res, 400, { ok: false, error: '不是目录' }); return }
let names: string[] = []
try { names = readdirSync(r.abs) } catch { this.json(res, 500, { ok: false, error: '读取目录失败' }); return }
const entries: DriveEntry[] = []
for (const name of names) {
try {
const s = statSync(join(r.abs, name))
const relPath = rel ? `${rel.replace(/[/\\]+$/, '')}/${name}` : name
entries.push({
name, path: relPath, size: s.isDirectory() ? 0 : s.size,
mtime: s.mtimeMs, isDir: s.isDirectory(),
})
} catch { /* 单个条目 stat 失败就跳过 */ }
}
// 目录在前, 同类按名称排
entries.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name, 'zh-CN') : (a.isDir ? -1 : 1)))
this.json(res, 200, { ok: true, path: rel, entries })
return
}
if (route === 'download') {
const rel = params.get('path') || ''
const r = this.resolveDrivePath(rel)
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
let st: any
try { st = statSync(r.abs) } catch { this.json(res, 404, { ok: false, error: '文件不存在' }); return }
if (!st.isFile()) { this.json(res, 400, { ok: false, error: '不是文件' }); return }
res.statusCode = 200
res.setHeader('Content-Length', String(st.size))
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(r.abs))}"`)
res.setHeader('Access-Control-Allow-Origin', '*')
this.emit('driveDownloaded', {
name: basename(r.abs), size: st.size, relPath: rel,
fromAddr: req.socket.remoteAddress || '',
})
createReadStream(r.abs).pipe(res)
return
}
this.json(res, 404, { ok: false, error: 'not found' })
}
private json(res: ServerResponse, code: number, data: any) {
res.statusCode = code
res.setHeader('Content-Type', 'application/json; charset=utf-8')
+31 -3
View File
@@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto'
import { hostname, platform } from 'node:os'
import { paths } from './paths'
import { getSettings, ensureDownloadDir } from './settings'
import { getSettings, ensureDownloadDir, ensureDriveDir } from './settings'
import { ensureIconFile } from './icons'
import { createMainWindow, broadcastToRenderer } from './window'
import { createTray } from './tray'
@@ -17,7 +17,7 @@ import { FileServer } from './file-server'
import { TerminalManager } from './remote/terminal'
import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { registerIpc, bindNetworkContext } from './ipc'
import { registerIpc, bindNetworkContext, collectWsState } from './ipc'
import { listDevices, totalUnread, getUnread, pruneAudit } from './db'
import { setBadgeCount } from './notify'
import { DEFAULT_PORTS, PROTOCOL_VERSION } from './protocol'
@@ -60,7 +60,11 @@ async function main() {
const discovery = new Discovery(self)
const chatServer = new ChatServer(DEFAULT_PORTS.chat)
const chatClient = new ChatClient(self)
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir())
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir(), {
getDriveRoot: () => ensureDriveDir(),
getDriveToken: () => getDriveToken(),
isDriveEnabled: () => getSettings().driveEnabled !== false,
})
const terminal = new TerminalManager(chatClient, self.deviceId)
const forward = new ForwardManager(chatClient)
const usb = new UsbBridgeManager(chatClient)
@@ -130,6 +134,28 @@ function buildSelf(): DeviceInfo {
chatPort: 0,
filePort: 0,
ts: Date.now(),
driveToken: getDriveToken(),
}
}
// 网盘访问令牌: 一次生成, 长期不变 (对端在发现包里拿到它)
let driveTokenCache: string | null = null
export function getDriveToken(): string {
if (driveTokenCache) return driveTokenCache
const f = join(paths.appData, 'drive.token')
try {
if (existsSync(f)) {
const t = readFileSync(f, 'utf8').trim()
if (t) { driveTokenCache = t; return t }
}
const t = randomUUID().replace(/-/g, '')
writeFileSync(f, t, 'utf8')
driveTokenCache = t
return t
} catch {
// 落盘失败也要能用 (内存 token, 重启后失效)
driveTokenCache = randomUUID().replace(/-/g, '')
return driveTokenCache
}
}
@@ -137,5 +163,7 @@ function pushInitialState() {
broadcastToRenderer('boot:ready', {
settings: getSettings(),
unread: getUnread(),
// 带上 WS 通道状态快照: 渲染层订阅前发生的连接事件不会重放, 只能在这里补一次
ws: collectWsState(),
})
}
+161 -9
View File
@@ -2,10 +2,10 @@
import { ipcMain, dialog, shell, BrowserWindow, app } from 'electron'
import { randomUUID } from 'node:crypto'
import { existsSync, statSync, readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { basename, join, dirname } from 'node:path'
import { promises as fsp } from 'node:fs'
import { getSettings, updateSettings, ensureDownloadDir } from './settings'
import { getSettings, updateSettings, ensureDownloadDir, ensureDriveDir } from './settings'
import {
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
@@ -17,6 +17,7 @@ import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
import { ChatServer } from './chat-server'
import { ChatClient } from './chat-client'
import { FileServer, uploadFile, uploadBuffer } from './file-server'
import { listDrive, getDriveRoot, downloadDriveFile, collectDriveFiles } from './drive'
import { broadcastToRenderer, focusMainWindow } from './window'
import { rebuildMenu } from './tray'
import { notify, setBadgeCount } from './notify'
@@ -24,7 +25,9 @@ import { TerminalManager } from './remote/terminal'
import { ForwardManager } from './remote/forward'
import { UsbBridgeManager } from './remote/usb'
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
import { computeWsState } from './ws-state'
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
import { DEFAULT_PORTS } from './protocol'
interface NetCtx {
self: DeviceInfo
@@ -39,6 +42,32 @@ interface NetCtx {
let ctx: NetCtx | null = null
// 收集所有对端的"消息通道"状态
export function collectWsState(): Record<string, boolean> {
if (!ctx) return {}
return computeWsState(
ctx.discovery.getPeers().map(p => p.deviceId),
ctx.chatClient.getStatus(),
ctx.chatServer,
)
}
// 对端是否还"在线": discovery 能看到它, 或还有任一条 WS 通道活着
function isPeerReachable(deviceId: string): boolean {
if (!ctx) return false
if (ctx.chatServer.isConnected(deviceId)) return true
if (ctx.chatClient.getStatus()[deviceId] === 'open') return true
return ctx.discovery.getPeers().some(p => p.deviceId === deviceId)
}
// 广播 WS 通道状态全量快照
// 用"全量快照"而不是"单个设备的增量事件": 增量事件在渲染层还没订阅时会被丢弃,
// 启动阶段主进程往往先连上对端 (index.ts 里 connectTo 早于 createMainWindow),
// 渲染层 mount 后就再也收不到那条 open, UI 会永久停在"等待重连"。
function broadcastWsState() {
broadcastToRenderer('device:wsState', { ws: collectWsState() })
}
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
// 任一进度事件或 received 完成事件取消; 到了 -> 只通知 sender, 不自动删消息
// 气泡留在 receiving 状态, 让用户右键手动删
@@ -72,7 +101,8 @@ export function bindNetworkContext(c: NetCtx) {
if (getDevice(d.deviceId)?.ignored) return
upsertDevice({
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0,
drive_token: d.driveToken || null,
})
broadcastToRenderer('device:found', d)
c.chatClient.connectTo(d)
@@ -81,7 +111,8 @@ export function bindNetworkContext(c: NetCtx) {
if (getDevice(d.deviceId)?.ignored) return
upsertDevice({
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0,
drive_token: d.driveToken || null,
})
broadcastToRenderer('device:updated', d)
c.chatClient.connectTo(d)
@@ -205,6 +236,9 @@ export function bindNetworkContext(c: NetCtx) {
})
c.chatServer.on('connect', (_ws, peer) => {
// 先推通道状态再推在线事件: 渲染层收到 "offline" 时会查通道状态决定要不要真标离线,
// 顺序反了它读到的是旧快照, 会把刚断开的设备继续留在"在线"
broadcastWsState()
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
// 对端上线 -> 触发离线消息 flush
flushPendingFor(peer.deviceId)
@@ -221,14 +255,20 @@ export function bindNetworkContext(c: NetCtx) {
// "discovery 在线" 和 "WS 在线"; 同时 open 时主动 flush 一次待发消息 (双端 WS 都建好后必走 chat-server.on('connect') 那条路,
// 但如果对方先来认我们这边, 我们 outgoing 还没通, flush 在自己这条线路上反而能立刻推)
c.chatClient.on('open', (peer) => {
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: true })
broadcastWsState()
flushPendingFor(peer.deviceId)
})
c.chatClient.on('close', (peer) => {
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: false })
broadcastWsState()
// 两条腿都断了, discovery 也不认识它了 -> 确实离线, 不用等 discovery 的 15s 超时
if (!isPeerReachable(peer.deviceId)) {
broadcastToRenderer('device:offline', { deviceId: peer.deviceId })
}
})
c.chatServer.on('disconnect', (id) => {
// 先推通道状态再推离线事件 (理由同 'connect')
broadcastWsState()
broadcastToRenderer('device:offline', { deviceId: id })
})
@@ -246,6 +286,18 @@ export function bindNetworkContext(c: NetCtx) {
disarmPendingMetaTimer(info.fileId)
})
// 对端从我的网盘下载了文件 -> 本机提示 + 审计
c.fileServer.on('driveDownloaded', (info: { name: string; size: number; relPath: string; fromAddr: string }) => {
const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr)
const who = peer ? peer.name : info.fromAddr || '某台设备'
recordAudit({
action: 'drive.download', source: peer?.deviceId || info.fromAddr,
result: 'ok',
payload: { name: info.name, size: info.size, path: info.relPath },
})
broadcastToRenderer('drive:inbound', { name: info.name, size: info.size, peerId: peer?.deviceId, peerName: who })
})
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
c.chatServer.on('recall', (from, messageId) => {
const row = getMessage(messageId)
@@ -443,6 +495,11 @@ export function registerIpc() {
// ---- 设备 / 自身 ----
ipcMain.handle('self:info', () => ctx?.self)
// 渲染层启动时拉一次当前状态 (补上启动阶段被丢弃的事件)
ipcMain.handle('app:state', () => ({
ws: collectWsState(),
}))
ipcMain.handle('device:list', () => {
const rows = listDevices()
const online = new Set(ctx?.discovery.getPeers().map(p => p.deviceId) || [])
@@ -974,6 +1031,102 @@ export function registerIpc() {
return { path: p, name: basename(p), size: st.size }
})
// ===== 网盘 (浏览对方共享目录 + 选文件下载) =====
// 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录
function findPeer(peerId: string): DeviceInfo | null {
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
if (p) return p as DeviceInfo
const d = listDevices().find(x => x.device_id === peerId) as any
if (!d) return null
return {
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
driveToken: d.drive_token || undefined,
} as DeviceInfo
}
ipcMain.handle('drive:list', async (_e, args: { peerId: string; path?: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
return listDrive(peer, args.path || '')
})
ipcMain.handle('drive:root', async (_e, args: { peerId: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
return getDriveRoot(peer)
})
// 下载选中的文件/目录 (顺序下载, 逐个报进度)
ipcMain.handle('drive:download', async (_e, args: { peerId: string; paths: string[]; destDir?: string }) => {
if (!ctx) return { ok: false, reason: 'no ctx' }
const peer = findPeer(args.peerId)
if (!peer) return { ok: false, reason: '找不到该设备' }
const destDir = args.destDir || ensureDownloadDir()
const results: { relPath: string; ok: boolean; savedPath?: string; size?: number; reason?: string }[] = []
let index = 0
const total = (args.paths || []).length
for (const relPath of args.paths || []) {
// 选中的是目录 -> 递归收集里面的文件, 保持层级下载
const collected = await collectDriveFiles(peer, relPath)
if (!collected.ok) {
results.push({ relPath, ok: false, reason: collected.reason })
continue
}
// 目录本身也存一份 (下载整目录时, 以目录名作为本地子目录)
const isDir = collected.files.length > 0 && collected.files[0].relPath !== relPath
const baseName = basename(relPath)
for (const f of collected.files) {
// 相对目录的层级 (去掉目录名前缀), 只取目录部分, 文件名由下载时自己拼
const relInside = isDir ? f.relPath.slice(relPath.length).replace(/^[/\\]+/, '') : ''
const dirInside = relInside ? dirname(relInside) : ''
const destSubPath = isDir ? (dirInside && dirInside !== '.' ? join(baseName, dirInside) : baseName) : undefined
const r = await downloadDriveFile({
peer, relPath: f.relPath, destDir,
destSubPath: destSubPath || undefined,
onProgress: (sent, totalBytes) => {
broadcastToRenderer('drive:progress', { peerId: args.peerId, relPath: f.relPath, sent, total: totalBytes, index, count: total })
},
})
results.push({ relPath: f.relPath, ok: r.ok, savedPath: r.path, size: r.size, reason: r.reason })
index++
}
if (!isDir) index++
}
const okCount = results.filter(r => r.ok).length
recordAudit({
action: 'drive.download', target: args.peerId,
result: okCount === results.length && results.length > 0 ? 'ok' : 'error',
payload: { count: results.length, ok: okCount, files: results.map(r => r.relPath) },
})
return { ok: true, results }
})
// 打开本机网盘目录
ipcMain.handle('drive:openFolder', async () => {
try {
await shell.openPath(ensureDriveDir())
return true
} catch { return false }
})
// 换网盘根目录
ipcMain.handle('drive:pickDir', async () => {
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
if (!w) return null
const r = await dialog.showOpenDialog(w, { properties: ['openDirectory', 'createDirectory'] })
if (r.canceled || !r.filePaths[0]) return null
updateSettings({ driveDir: r.filePaths[0] })
ensureDriveDir()
rebuildMenu()
broadcastToRenderer('settings:changed', getSettings())
return r.filePaths[0]
})
// ---- 应用控制 ----
ipcMain.handle('app:hide', () => {
const w = BrowserWindow.getAllWindows()[0]
@@ -1051,9 +1204,8 @@ export function registerIpc() {
ipcMain.handle('forward:close', (_e, args: { sessionId: string }) => {
if (!ctx) return false
// 控制端: 关 clientSession
ctx.forward.cleanupClientSession(args.sessionId, 'user')
return true
// 我发起的是 clientSession, 对方发起的是 serverSession —— 两边都得关得掉
return ctx.forward.closeSession(args.sessionId, 'user')
})
ipcMain.handle('forward:listSessions', () => {
+6
View File
@@ -30,6 +30,12 @@ export const paths = {
if (!existsSync(p)) mkdirSync(p, { recursive: true })
return p
})(),
// 默认网盘目录 (对外共享的根目录): ~/Documents/LocalNetMsg/网盘
defaultDriveDir: (() => {
const p = join(app.getPath('documents'), 'LocalNetMsg', '网盘')
if (!existsSync(p)) mkdirSync(p, { recursive: true })
return p
})(),
}
export { isDev }
+2
View File
@@ -23,6 +23,8 @@ export interface DeviceInfo {
chatPort: number
filePort: number
ts: number // 上次心跳时间
/** 网盘访问令牌 (对端用它调 /drive/* 接口; 没有这个字段 = 老版本, 不支持网盘) */
driveToken?: string
}
// ============ WebSocket 协议 ============
+28 -12
View File
@@ -276,18 +276,28 @@ export class ForwardManager extends EventEmitter {
return true
}
cleanupClientSession(sessionId: string, reason = 'user') {
cleanupClientSession(sessionId: string, reason = 'user', notifyPeer = true): boolean {
const s = this.clientSessions.get(sessionId)
if (!s) return
if (!s) return false
try { s.server?.close() } catch {}
try { s.client?.destroy() } catch {}
try { s.activeStream?.destroy() } catch {}
this.clientSessions.delete(sessionId)
// 通知对方
const f: CloseF = { type: 'close', sessionId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, s.peerId)
// 通知对方 (收到对方的 close 帧时不用回发)
if (notifyPeer) {
const f: CloseF = { type: 'close', sessionId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, s.peerId)
}
this.emit('sessionClosed', { sessionId, peerId: s.peerId, direction: s.direction, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' })
recordAudit({ action: 'forward.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
return true
}
// UI 的"关闭"按钮: 我发起的是 clientSession, 对方发起的是 serverSession, 两边都要关得掉
closeSession(sessionId: string, reason = 'user'): boolean {
if (this.clientSessions.has(sessionId)) return this.cleanupClientSession(sessionId, reason)
if (this.serverSessions.has(sessionId)) return this.cleanupServerSession(sessionId, reason)
return false
}
// UI 列出 (控制端) 全部 session: 包括 self-out (我 server) 和 self-in (我 client)
@@ -336,13 +346,13 @@ export class ForwardManager extends EventEmitter {
return
}
case 'close': {
// close 两边都可能收到 (任意一方主动关闭)
// close 两边都可能收到 (任意一方主动关闭) —— 收到的不用再回发
if (this.serverSessions.has(f.sessionId)) {
this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer')
this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer', false)
return
}
if (this.clientSessions.has(f.sessionId)) {
this.cleanupClientSession(f.sessionId, f.reason || 'closed-by-peer')
this.cleanupClientSession(f.sessionId, f.reason || 'closed-by-peer', false)
return
}
return
@@ -509,15 +519,21 @@ export class ForwardManager extends EventEmitter {
}
}
cleanupServerSession(sessionId: string, reason: string) {
cleanupServerSession(sessionId: string, reason: string, notifyPeer = true): boolean {
const sess = this.serverSessions.get(sessionId)
if (!sess) return
if (!sess) return false
try { sess.client?.destroy() } catch {}
try { sess.server?.close() } catch {}
try { sess.activeStream?.destroy() } catch {}
this.serverSessions.delete(sessionId)
// 通知对方: 否则对方的 clientSession 还在, 它 bind 的监听端口不会释放
if (notifyPeer) {
const f: CloseF = { type: 'close', sessionId, reason, bytesIn: sess.bytesIn, bytesOut: sess.bytesOut }
this.chatClient.send({ type: 'forward', payload: f } as WsFrame, sess.peerId)
}
this.emit('sessionClosed', { sessionId, peerId: sess.peerId, direction: sess.direction, reason, bytesIn: sess.bytesIn, bytesOut: sess.bytesOut, side: 'server' })
recordAudit({ action: 'forward.close', source: sess.peerId, sessionId, result: 'closed', bytesIn: sess.bytesIn, bytesOut: sess.bytesOut })
return true
}
private sendAck(peerId: string, sessionId: string, ok: boolean, reason?: string) {
@@ -547,10 +563,10 @@ export class ForwardManager extends EventEmitter {
onPeerDisconnected(peerId: string) {
for (const [sid, sess] of this.clientSessions) {
if (sess.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected')
if (sess.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected', false)
}
for (const [sid, sess] of this.serverSessions) {
if (sess.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected')
if (sess.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected', false)
}
}
+10 -2
View File
@@ -130,8 +130,16 @@ export class TerminalManager extends EventEmitter {
// 控制端收到我们的 open 后, ack 给对端
this.emit(`ack:${f.sessionId}`, f.ok, f.reason)
return
case 'output':
return // 协议层不应该出现
case 'output': {
// 控制端: 被控端发来的终端输出 -> 交给渲染层
// (ipc.ts 监听 TerminalManager 的 'output' 事件, 广播 'remote:terminal:output' 给 UI)
// 之前这里直接 return, 导致控制端永远收不到任何输出 —— 终端窗口一片空白
if (!f.data) return
const buf = Buffer.from(f.data, 'base64')
if (buf.length === 0) return
this.emit('output', f.sessionId, buf, peer.deviceId)
return
}
}
}
+38 -17
View File
@@ -126,11 +126,15 @@ export class UsbBridgeManager extends EventEmitter {
console.warn('[usb] serialport.list failed:', e?.message)
}
}
// libusb (用 webusb.getDevices, 不会 claim interface, 安全)
// libusb (只读枚举, 不 claim interface, 安全)
if (usbMod) {
try {
// 用 webusb.getDevices() 不需要 detach kernel driver, 是只读枚举
const devices: any[] = await usbMod.webusb.getDevices()
// 注意: node-usb v3 的 webusb.getDevices() 是 WebUSB 语义 —— 只返回"本进程已授权/已打开"的设备,
// 枚举系统上已插着的设备必须用 loadDevices() (内部走 nativeGetDevices)
const webusb: any = usbMod.webusb
const devices: any[] = typeof webusb?.loadDevices === 'function'
? await webusb.loadDevices()
: await webusb.getDevices()
for (const d of devices) {
const busId = `usb:${pad4(d.vendorId)}:${pad4(d.productId)}:${d.serialNumber || d.productName || d.productName || '0'}`
// 避免和已加的串口重复 (USB-串口适配器在 OS 看来是串口)
@@ -299,17 +303,18 @@ export class UsbBridgeManager extends EventEmitter {
}
// self-in: 我方是 server, 设备在我方
private async serverAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
// sessionId: 由发起方决定 —— 对端 attach 请求里带的 id 必须原样沿用,
// 否则 server session 的 key 和控制端的 key 对不上, 数据帧会被当成"无此 session"丢掉
private async serverAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig, sessionId: string = randomUUID()): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
// 1. 解析 busId 找到本机真实路径/handle
const local = await this.listLocal()
const dev = local.find(d => d.busId === busId)
if (!dev) {
recordAudit({ action: 'usb.attach', target: peerId, sessionId: randomUUID(), result: 'error', payload: { phase: 'no-device', busId, kind } })
recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'error', payload: { phase: 'no-device', busId, kind } })
return { ok: false, reason: '本机找不到该设备' }
}
// 2. 开设备
const sessionId = randomUUID()
const sess: ServerSession = {
sessionId, ownerId: peerId, peerId, busId, kind,
bytesIn: 0, bytesOut: 0,
@@ -355,12 +360,14 @@ export class UsbBridgeManager extends EventEmitter {
const dev2: any = await usbMod.webusb.findDeviceByIds(dev.vid, dev.pid).catch(() => null)
if (!dev2) throw new Error('libusb 找不到该设备')
await dev2.open()
const configVal = c.configurationValue ?? dev2.configuration?.configurationValue ?? 1
await dev2.selectConfiguration(configVal)
const ifaceNum = c.interfaceNumber ?? 0
// 顺序很关键: 设备若被内核驱动占着 (HID/uvcvideo/btusb...), 必须先 detach 再 selectConfiguration,
// 否则 selectConfiguration 直接 EBUSY ("device is busy")
if (platform === 'linux' && c.detachKernelDriver !== false) {
try { await dev2.detachKernelDriver(ifaceNum) } catch {}
}
const configVal = c.configurationValue ?? dev2.configuration?.configurationValue ?? 1
await dev2.selectConfiguration(configVal)
await dev2.claimInterface(ifaceNum)
sess.usbDev = dev2
sess.usbInterface = ifaceNum
@@ -556,8 +563,8 @@ export class UsbBridgeManager extends EventEmitter {
if (verdict.remember) markApproved(peer.deviceId, 'usb')
}
// 走和 self-in 一样的开设备流程
const r = await this.serverAttach(peer.deviceId, f.busId, f.config.kind, f.config)
// 走和 self-in 一样的开设备流程 (沿用对端给的 sessionId, 两端 key 必须一致)
const r = await this.serverAttach(peer.deviceId, f.busId, f.config.kind, f.config, f.sessionId)
this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: r.ok, reason: r.reason, info: r.ok ? this.serverSessions.get(f.sessionId)?.info : undefined })
if (!r.ok) {
recordAudit({ action: 'usb.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'open', reason: r.reason } })
@@ -578,13 +585,7 @@ export class UsbBridgeManager extends EventEmitter {
private async handleCtrlReq(sess: ServerSession, peer: DeviceInfo, f: Extract<UsbFrame, { type: 'ctrlOut' | 'ctrlIn' }>) {
try {
const dev = sess.usbDev
const setup: any = {
requestType: f.setup.requestType,
recipient: (f.setup.requestType & 0x1f) as any, // low 5 bits
request: f.setup.request,
value: f.setup.value,
index: f.setup.index,
}
const setup: any = toUsbControlSetup(f.setup)
if (f.type === 'ctrlOut') {
const data = f.data ? Buffer.from(f.data, 'base64') : null
const written = await dev.nativeControlTransferOut(setup, 5000, data ? new Uint8Array(data) : null)
@@ -929,6 +930,26 @@ function pad4(n: number): string {
return (n || 0).toString(16).padStart(4, '0').toLowerCase()
}
// 协议里传的是标准 bmRequestType (bit7 方向 / bit6-5 类型 / bit4-0 recipient),
// 而 node-usb v3 的 control transfer 参数用的是字符串枚举, 这里翻译一下
export function toUsbControlSetup(setup: { requestType: number; request: number; value: number; index: number }) {
const bm = setup.requestType | 0
const typeBits = (bm >> 5) & 0x03
const requestType = typeBits === 1 ? 'class' : typeBits === 2 ? 'vendor' : 'standard'
const rcptBits = bm & 0x1f
const recipient =
rcptBits === 0 ? 'device' :
rcptBits === 1 ? 'interface' :
rcptBits === 2 ? 'endpoint' : 'other'
return {
requestType,
recipient,
request: setup.request | 0,
value: setup.value | 0,
index: setup.index | 0,
}
}
function describeAttach(dir: UsbDirection, config: UsbAttachConfig, busId: string): string {
if (config.kind === 'serial') {
return dir === 'self-out'
+16
View File
@@ -26,6 +26,10 @@ export interface Settings {
terminalReadOnlyByDefault: boolean
/** 审计日志保留天数 */
auditRetentionDays: number
/** 网盘总开关 (关闭后不再对外提供目录浏览/下载) */
driveEnabled: boolean
/** 网盘根目录 (对外共享的目录, 对端只能看到这个目录里的内容) */
driveDir: string
}
export const store = new Store<Settings>({
@@ -46,6 +50,8 @@ export const store = new Store<Settings>({
forwardMaxBytesPerSec: 10 * 1024 * 1024,
terminalReadOnlyByDefault: false,
auditRetentionDays: 30,
driveEnabled: true,
driveDir: paths.defaultDriveDir,
},
})
@@ -68,3 +74,13 @@ export function ensureDownloadDir(): string {
store.set('downloadDir', dir)
return dir
}
// 网盘根目录 (对外共享). 保证存在, 并把设置里的值同步成真实路径
export function ensureDriveDir(): string {
const dir = store.get('driveDir') || paths.defaultDriveDir
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
store.set('driveDir', dir)
return dir
}
+31
View File
@@ -0,0 +1,31 @@
// 消息通道状态计算 (纯逻辑, 不依赖 electron, 便于自测)
//
// 通道 = outgoing WS (我们主动连过去的) 或 inbound WS (对方连过来的), 任何一条活的就说明消息能通。
// 之前 UI 只盯着 outgoing, 结果对方只连进来、我们没连上时, 会误报"等待重连"。
export type WsClientStatus = Record<string, 'open' | 'closed' | 'connecting'>
export interface WsStateServer {
isConnected: (deviceId: string) => boolean
}
/**
* 算出各对端的通道是否可用。
* @param peerIds 已知对端 (比如 discovery 报到的)
* @param clientStatus chat-client 里各对端的 outgoing 状态
* @param server chat-server, 用来查 inbound 连接
*/
export function computeWsState(
peerIds: string[],
clientStatus: WsClientStatus,
server: WsStateServer,
): Record<string, boolean> {
const out: Record<string, boolean> = {}
const alive = (id: string) => clientStatus[id] === 'open' || server.isConnected(id)
for (const id of peerIds) out[id] = alive(id)
// 没有出现在 peer 列表、但已有连接记录的, 也补上
for (const id of Object.keys(clientStatus)) {
if (!(id in out)) out[id] = alive(id)
}
return out
}
+9
View File
@@ -8,6 +8,8 @@ contextBridge.exposeInMainWorld('api', {
// self / 设备
self: () => ipcRenderer.invoke('self:info'),
listDevices: () => ipcRenderer.invoke('device:list'),
// 启动时拉一次主进程当前状态 (WS 通道等) — 补上订阅前丢失的事件
appState: () => ipcRenderer.invoke('app:state'),
triggerScan: () => ipcRenderer.invoke('device:triggerScan'),
deleteDevice: (deviceId: string) => ipcRenderer.invoke('device:delete', deviceId),
@@ -104,6 +106,13 @@ contextBridge.exposeInMainWorld('api', {
usbDetach: (sessionId: string) => ipcRenderer.invoke('usb:detach', { sessionId }),
usbListSessions: () => ipcRenderer.invoke('usb:listSessions'),
// 网盘: 浏览对方共享目录 + 选文件下载
driveList: (peerId: string, path?: string) => ipcRenderer.invoke('drive:list', { peerId, path: path || '' }),
driveRoot: (peerId: string) => ipcRenderer.invoke('drive:root', { peerId }),
driveDownload: (peerId: string, paths: string[]) => ipcRenderer.invoke('drive:download', { peerId, paths }),
driveOpenFolder: () => ipcRenderer.invoke('drive:openFolder'),
drivePickDir: () => ipcRenderer.invoke('drive:pickDir'),
// 授权 / 审计
remoteApprovalList: () => ipcRenderer.invoke('remote:approval:list'),
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) =>
+38 -11
View File
@@ -38,14 +38,25 @@ function showToast(text: string) {
const unsubs: Array<() => void> = []
onMounted(async () => {
await device.loadSelf()
if (device.self) message.setSelf(device.self.deviceId)
await device.refresh()
// 启动后尝试一次 flush pending
setTimeout(() => { window.api.flushPending() }, 1500)
// 应用 WS 通道状态快照
// 通道活着 = 对方直接可达, 顺带把"在线"状态拉回来 (discovery 心跳断了但通道还在的情况)
function applyWsState(ws: Record<string, boolean> | undefined) {
const map = ws || {}
device.setWsStates(map)
for (const [id, alive] of Object.entries(map)) {
if (!alive) continue
const d = device.devices.find(x => x.deviceId === id)
if (d && !d.online) d.online = true
}
}
unsubs.push(window.api.on('boot:ready', () => { device.refresh() }))
onMounted(async () => {
// 监听器必须先注册再做任何 await —— 这期间主进程推来的事件会被整条丢掉
// (启动时主进程往往已经连上对端, 渲染层订阅晚一步就永远收不到那条状态, UI 会卡在"等待重连")
unsubs.push(window.api.on('boot:ready', async (st: { ws?: Record<string, boolean> }) => {
await device.refresh()
if (st?.ws) applyWsState(st.ws)
}))
unsubs.push(window.api.on('device:found', (d: DeviceView) => {
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
const merged: DeviceView = { ...(device.devices[idx] || ({} as DeviceView)), ...d, online: true, lastSeen: Date.now() }
@@ -57,6 +68,8 @@ onMounted(async () => {
if (idx >= 0) device.devices[idx] = { ...device.devices[idx], ...d, online: true, lastSeen: Date.now() }
}))
unsubs.push(window.api.on('device:lost', ({ deviceId }: { deviceId: string }) => {
// UDP 心跳断了, 但消息通道可能还活着 (对端直接连着我们) —— 那就不算离线
if (device.channelAlive(deviceId)) return
const d = device.devices.find(x => x.deviceId === deviceId)
if (d) d.online = false
}))
@@ -65,12 +78,14 @@ onMounted(async () => {
if (d) d.online = true
}))
unsubs.push(window.api.on('device:offline', ({ deviceId }: { deviceId: string }) => {
// 入站连接断了, outgoing 可能还通 —— 通道活着就不算离线
if (device.channelAlive(deviceId)) return
const d = device.devices.find(x => x.deviceId === deviceId)
if (d) d.online = false
}))
// 我们 outgoing WS 接通/断开 — 让 chat header 能区分 "discovery 在线" 和 "WS 在线"
unsubs.push(window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => {
device.setWsOpen(deviceId, wsOpen)
// 消息通道状态 (全量快照) — 让 chat header 能区分 "discovery 在线" 和 "通道可用"
unsubs.push(window.api.on('device:wsState', ({ ws }: { ws: Record<string, boolean> }) => {
applyWsState(ws)
}))
unsubs.push(window.api.on('message:received', (env: MessageView) => {
const fromId = env.fromDeviceId
@@ -157,12 +172,24 @@ onMounted(async () => {
unsubs.push(window.api.on('remote:terminal:closed', (p) => terminal.onClosed(p)))
unsubs.push(window.api.on('remote:forward:opened', (p) => forward.onOpened(p)))
unsubs.push(window.api.on('remote:forward:closed', (p) => forward.onClosed(p)))
unsubs.push(window.api.on('remote:usb:opened', () => { /* no-op; refresh in component */ }))
unsubs.push(window.api.on('remote:usb:opened', (p) => usb.onOpened(p)))
unsubs.push(window.api.on('remote:usb:closed', (p) => usb.onClosed(p)))
unsubs.push(window.api.on('remote:usb:output', (p) => usb.onOutput(p)))
unsubs.push(window.api.on('remote:usb:error', (p) => { /* surface via console for now */ console.warn('[usb]', p) }))
unsubs.push(window.api.on('remote:approval:requested', (req) => approval.add(req)))
// ---- 初始状态拉取 (监听器已就位, 这里只补数据) ----
await device.loadSelf()
if (device.self) message.setSelf(device.self.deviceId)
await device.refresh()
// 拉一次主进程当前快照: 补上订阅之前发生、永远不会被重放的 WS 连接事件
try {
const st = await window.api.appState()
applyWsState(st?.ws)
} catch { /* 主进程还没起来就算了, 后续事件会覆盖 */ }
// 启动后尝试一次 flush pending
setTimeout(() => { window.api.flushPending() }, 1500)
// 启动时拉取当前 approval 队列 (防止刚启动就出现未读请求)
approval.refresh().catch(() => {})
})
+38
View File
@@ -7,6 +7,8 @@ export type LnmApiBase = {
self: () => Promise<DeviceSelf | null>
listDevices: () => Promise<DeviceView[]>
// 主进程当前状态快照 (ws = 各设备的消息通道是否可用)
appState: () => Promise<AppStateView>
triggerScan: () => Promise<boolean>
deleteDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }>
@@ -68,6 +70,12 @@ export interface DeviceView {
lastSeen: number
}
// 主进程状态快照
export interface AppStateView {
// deviceId -> 消息通道是否可用 (outgoing 或 inbound 任一 WS 活着即为 true)
ws: Record<string, boolean>
}
export type MessageType = 'text' | 'image' | 'file' | 'system'
export interface MessageView {
@@ -95,6 +103,8 @@ export interface Settings {
forwardMaxBytesPerSec?: number
terminalReadOnlyByDefault?: boolean
auditRetentionDays?: number
driveEnabled?: boolean
driveDir?: string
}
export interface NetInterface {
@@ -255,6 +265,23 @@ export interface AuditEntry {
bytes_out: number
}
// 远程: 网盘 (浏览对方共享目录 + 选文件下载)
export interface DriveEntry {
name: string
path: string // 相对对方网盘根目录的路径
size: number
mtime: number
isDir: boolean
}
export interface DriveListResult {
ok: boolean
reason?: string
path?: string
entries?: DriveEntry[]
root?: string
}
// 扩展 API
export type LnmApiExtra = {
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
@@ -288,6 +315,17 @@ export type LnmApiExtra = {
usbDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }>
usbListSessions: () => Promise<{ client: UsbSessionInfo[]; server: UsbSessionInfo[] }>
// 网盘
driveList: (peerId: string, path?: string) => Promise<DriveListResult>
driveRoot: (peerId: string) => Promise<{ ok: boolean; dir?: string; reason?: string }>
driveDownload: (peerId: string, paths: string[]) => Promise<{
ok: boolean
reason?: string
results?: { relPath: string; ok: boolean; savedPath?: string; size?: number; reason?: string }[]
}>
driveOpenFolder: () => Promise<boolean>
drivePickDir: () => Promise<string | null>
remoteApprovalList: () => Promise<ApprovalRequestView[]>
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise<boolean>
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise<AuditEntry[]>
+20 -15
View File
@@ -8,10 +8,11 @@ import MessageInput from './MessageInput.vue'
import TerminalPanel from './remote/TerminalPanel.vue'
import ForwardPanel from './remote/ForwardPanel.vue'
import UsbPanel from './remote/UsbPanel.vue'
import DrivePanel from './remote/DrivePanel.vue'
import type { DeviceView } from '@/api'
import { initialsOf, colorFor, formatTime } from '@/utils/format'
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone } from '@element-plus/icons-vue'
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone, FolderOpened } from '@element-plus/icons-vue'
const props = defineProps<{ peer: DeviceView | null }>()
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
@@ -20,7 +21,7 @@ const device = useDeviceStore()
const message = useMessageStore()
const session = useSessionStore()
type Tab = 'chat' | 'terminal' | 'forward' | 'usb'
type Tab = 'chat' | 'terminal' | 'forward' | 'usb' | 'drive'
const activeTab = ref<Tab>('chat')
// 切到别的设备时回到 chat tab
watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
@@ -28,12 +29,11 @@ watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
const self = computed(() => device.self)
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false)
// 我方 outgoing WS 是否连通 — 严格默认 false (从没收到过 device:wsState 时,
// 显示 "等待重连" 而不是骗用户 "在线"; 看到首次 open 事件后才会变成 true)
const wsOpen = computed(() => {
// 消息通道是否可用: outgoing 或 inbound 任一 WS 活着就算通 (主进程给的是全量快照)
// 拿不到状态时按 false 处理 (显示"连接中"), 不猜"在线"
const channelAlive = computed(() => {
if (!props.peer) return false
const v = device.wsOpen[props.peer.deviceId]
return v === undefined ? false : v
return device.wsOpen[props.peer.deviceId] === true
})
const bodyEl = ref<{ scrollTo: (opts: { top: number }) => void } | null>(null)
@@ -140,16 +140,16 @@ async function onGlobalDrop(e: DragEvent) {
<div class="header-meta">
<span
class="status-dot"
:class="{ online: peer.online && wsOpen, 'ws-down': peer.online && !wsOpen, offline: !peer.online }"
:class="{ online: channelAlive, 'ws-down': peer.online && !channelAlive, offline: !peer.online && !channelAlive }"
/>
<template v-if="!peer.online">
<template v-if="!peer.online && !channelAlive">
离线 · {{ formatTime(peer.lastSeen) }}
</template>
<template v-else-if="!wsOpen">
在线 · {{ peer.address }} <span class="ws-note">(等待重连…)</span>
<template v-else-if="channelAlive">
在线 · {{ peer.address }}
</template>
<template v-else>
在线 · {{ peer.address }}
在线 · {{ peer.address }} <span class="ws-note">(连接中…)</span>
</template>
</div>
</div>
@@ -157,15 +157,18 @@ async function onGlobalDrop(e: DragEvent) {
<button :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">
<el-icon><ChatLineRound /></el-icon><span>聊天</span>
</button>
<button :class="{ active: activeTab === 'terminal' }" @click="activeTab = 'terminal'" :disabled="!peer.online">
<button :class="{ active: activeTab === 'terminal' }" @click="activeTab = 'terminal'" :disabled="!peer.online && !channelAlive">
<el-icon><Position /></el-icon><span>终端</span>
</button>
<button :class="{ active: activeTab === 'forward' }" @click="activeTab = 'forward'" :disabled="!peer.online">
<button :class="{ active: activeTab === 'forward' }" @click="activeTab = 'forward'" :disabled="!peer.online && !channelAlive">
<el-icon><Connection /></el-icon><span>转发</span>
</button>
<button :class="{ active: activeTab === 'usb' }" @click="activeTab = 'usb'" :disabled="!peer.online">
<button :class="{ active: activeTab === 'usb' }" @click="activeTab = 'usb'" :disabled="!peer.online && !channelAlive">
<el-icon><Cellphone /></el-icon><span>USB</span>
</button>
<button :class="{ active: activeTab === 'drive' }" @click="activeTab = 'drive'" :disabled="!peer.online && !channelAlive">
<el-icon><FolderOpened /></el-icon><span>网盘</span>
</button>
</nav>
</header>
@@ -211,6 +214,8 @@ async function onGlobalDrop(e: DragEvent) {
<ForwardPanel v-else-if="activeTab === 'forward'" :peer="peer" />
<!-- usb tab -->
<UsbPanel v-else-if="activeTab === 'usb'" :peer="peer" />
<!-- drive tab: 浏览对方网盘 + 选文件下载 -->
<DrivePanel v-else-if="activeTab === 'drive'" :peer="peer" />
</main>
<main class="chat-main chat-main-empty" v-else>
<el-empty :image-size="120" description="选择一个设备开始聊天">
@@ -21,6 +21,8 @@ interface FormState {
forwardMaxBytesPerSec: number
terminalReadOnlyByDefault: boolean
auditRetentionDays: number
driveEnabled: boolean
driveDir: string
}
const form = ref<FormState>({
@@ -35,6 +37,8 @@ const form = ref<FormState>({
forwardMaxBytesPerSec: 10 * 1024 * 1024,
terminalReadOnlyByDefault: false,
auditRetentionDays: 30,
driveEnabled: true,
driveDir: '',
})
const loading = ref(true)
@@ -70,6 +74,8 @@ function hydrate(s: any) {
forwardMaxBytesPerSec: s.forwardMaxBytesPerSec ?? 10 * 1024 * 1024,
terminalReadOnlyByDefault: s.terminalReadOnlyByDefault ?? false,
auditRetentionDays: s.auditRetentionDays ?? 30,
driveEnabled: s.driveEnabled ?? true,
driveDir: s.driveDir ?? '',
}
hydrating.value = false
}
@@ -87,6 +93,11 @@ async function loadIfaces() {
}
}
async function pickDriveDir() {
const p = await window.api.drivePickDir()
if (p) form.value.driveDir = p
}
async function pickDir() {
const p = await window.api.chooseDownloadDir()
if (p) form.value.downloadDir = p
@@ -103,6 +114,8 @@ async function save() {
theme: form.value.theme,
autoStart: form.value.autoStart,
remoteEnabled: form.value.remoteEnabled,
driveEnabled: form.value.driveEnabled,
driveDir: form.value.driveDir,
forwardDefaultTtlSec: form.value.forwardDefaultTtlSec,
forwardMaxBytesPerSec: form.value.forwardMaxBytesPerSec,
terminalReadOnlyByDefault: form.value.terminalReadOnlyByDefault,
@@ -193,6 +206,23 @@ const currentAddress = computed(() => device.self?.address || '-')
</el-form-item>
</el-form>
<el-divider content-position="left">网盘</el-divider>
<el-form label-position="left" :label-width="formLabelWidth">
<el-form-item label="启用网盘">
<el-switch v-model="form.driveEnabled" />
<span class="form-hint">关闭后对方无法浏览/下载你的共享目录</span>
</el-form-item>
<el-form-item label="共享目录">
<el-input v-model="form.driveDir" readonly>
<template #append>
<el-button @click="pickDriveDir">选择…</el-button>
</template>
</el-input>
<span class="form-hint">对方只能看到这个目录里的内容 (子目录也看不到上级)</span>
</el-form-item>
</el-form>
<el-divider content-position="left">远程 (终端 / 转发 / USB)</el-divider>
<el-form label-position="left" :label-width="formLabelWidth">
@@ -0,0 +1,297 @@
<script setup lang="ts">
// 网盘面板: 浏览对方共享的目录, 勾选文件后下载到本机
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import type { DeviceView, DriveEntry } from '@/api'
import { ElMessage } from 'element-plus'
import { Folder, FolderOpened, Document, Refresh, Download } from '@element-plus/icons-vue'
const props = defineProps<{ peer: DeviceView | null }>()
const entries = ref<DriveEntry[]>([])
const currentPath = ref('')
const loading = ref(false)
const errorMsg = ref('')
const unsupported = ref(false)
const selected = ref<Set<string>>(new Set())
const rootDir = ref('')
const myDriveDir = ref('')
// 下载任务: relPath -> { sent, total, status }
const tasks = ref<Record<string, { sent: number; total: number; status: 'running' | 'done' | 'error'; reason?: string }>>({})
const downloading = ref(false)
let unsubs: (() => void)[] = []
const isRoot = computed(() => !currentPath.value)
const selectedList = computed(() => [...selected.value])
// 勾选目录时大小未知 (要递归才知道), 这里只统计已知的文件大小
const totalSelectedSize = computed(() =>
entries.value.filter(e => selected.value.has(e.path) && !e.isDir).reduce((s, e) => s + e.size, 0))
const hasDirSelected = computed(() =>
entries.value.some(e => selected.value.has(e.path) && e.isDir))
function fmtBytes(n: number) {
if (!n) return '-'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function fmtTime(ts: number) {
if (!ts) return '-'
const d = new Date(ts)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
async function load(path = '') {
if (!props.peer) return
loading.value = true
errorMsg.value = ''
const r = await window.api.driveList(props.peer.deviceId, path)
loading.value = false
if (!r.ok) {
entries.value = []
errorMsg.value = r.reason || '读取失败'
unsupported.value = /不支持|未开启/.test(r.reason || '')
return
}
unsupported.value = false
currentPath.value = r.path ?? path
entries.value = r.entries || []
// 换目录后清掉选择 (避免选到别的目录的同名文件)
selected.value = new Set()
}
function enter(entry: DriveEntry) {
if (!entry.isDir) return
load(entry.path)
}
function goUp() {
if (isRoot.value) return
const p = currentPath.value.replace(/[/\\]+$/, '')
const idx = p.lastIndexOf('/')
load(idx < 0 ? '' : p.slice(0, idx))
}
function toggle(entry: DriveEntry, checked: boolean) {
const s = new Set(selected.value)
if (checked) s.add(entry.path)
else s.delete(entry.path)
selected.value = s
}
function isSelected(path: string) { return selected.value.has(path) }
function rowClass({ row }: { row: DriveEntry }) { return row.isDir ? 'dir-row' : '' }
function onRowClick(row: DriveEntry) { if (row.isDir) enter(row) }
function onCheckChange(row: DriveEntry, v: unknown) { toggle(row, !!v) }
async function downloadSelected() {
if (!props.peer || selectedList.value.length === 0) return
downloading.value = true
for (const p of selectedList.value) {
tasks.value = { ...tasks.value, [p]: { sent: 0, total: 0, status: 'running' } }
}
const r = await window.api.driveDownload(props.peer.deviceId, selectedList.value)
downloading.value = false
const results = r.results || []
let okCount = 0
for (const it of results) {
const cur = tasks.value[it.relPath] || { sent: 0, total: 0 }
tasks.value = {
...tasks.value,
[it.relPath]: { sent: cur.sent, total: cur.total, status: it.ok ? 'done' : 'error', reason: it.reason },
}
if (it.ok) okCount++
}
if (okCount > 0) ElMessage.success(`已下载 ${okCount} 个文件到本机下载目录`)
if (okCount < results.length) ElMessage.warning(`${results.length - okCount} 个文件下载失败`)
selected.value = new Set()
}
async function openMyDrive() {
await window.api.driveOpenFolder()
}
async function pickMyDrive() {
const p = await window.api.drivePickDir()
if (p) {
myDriveDir.value = p
ElMessage.success('网盘目录已更换')
}
}
watch(() => props.peer?.deviceId, () => {
entries.value = []
currentPath.value = ''
selected.value = new Set()
tasks.value = {}
if (props.peer) load('')
})
onMounted(async () => {
// 进度事件
unsubs.push(window.api.on('drive:progress', (p: { relPath: string; sent: number; total: number }) => {
tasks.value = {
...tasks.value,
[p.relPath]: { sent: p.sent, total: p.total, status: 'running' },
}
}))
// 对方从我的网盘下载了文件
unsubs.push(window.api.on('drive:inbound', (p: { name: string; size: number; peerName: string }) => {
ElMessage.info(`${p.peerName} 从你的网盘下载了 ${p.name} (${fmtBytes(p.size)})`)
}))
const s = await window.api.getSettings()
myDriveDir.value = (s as any).driveDir || ''
if (props.peer) {
load('')
const r = await window.api.driveRoot(props.peer.deviceId)
if (r.ok) rootDir.value = r.dir || ''
}
})
onUnmounted(() => { unsubs.forEach(u => u()); unsubs = [] })
</script>
<template>
<div class="drive-panel">
<!-- 我的网盘 (本机) -->
<div class="my-drive">
<div class="my-drive-left">
<span class="my-drive-label">我的网盘</span>
<code class="my-drive-dir">{{ myDriveDir || '未设置' }}</code>
</div>
<div class="my-drive-ops">
<el-button size="small" @click="openMyDrive">打开文件夹</el-button>
<el-button size="small" @click="pickMyDrive">更换目录</el-button>
</div>
</div>
<!-- 对方网盘 -->
<div class="peer-drive">
<div class="toolbar">
<el-button size="small" :disabled="isRoot" @click="goUp">
<el-icon><FolderOpened /></el-icon><span style="margin-left: 4px">上一级</span>
</el-button>
<el-button size="small" :loading="loading" @click="load(currentPath)">
<el-icon><Refresh /></el-icon>
</el-button>
<span class="crumb">
<span class="crumb-root" @click="load('')">对方网盘</span>
<template v-if="currentPath">
<span class="crumb-sep">/</span><span class="crumb-cur">{{ currentPath }}</span>
</template>
</span>
<div class="spacer" />
<span class="sel-info">
<template v-if="selectedList.length">
已选 {{ selectedList.length }} 项<template v-if="hasDirSelected"> (含目录, 整个目录会一起下载)</template><template v-else> ({{ fmtBytes(totalSelectedSize) }})</template>
</template>
<template v-else>下载到本机接收目录</template>
</span>
<el-button
type="primary" size="small" :disabled="!selectedList.length" :loading="downloading"
@click="downloadSelected"
>
<el-icon style="margin-right: 4px"><Download /></el-icon>下载选中
</el-button>
</div>
<el-alert
v-if="unsupported" type="info" :closable="false" show-icon
title="对方版本暂不支持网盘" description="升级到最新版本后即可浏览对方的共享目录"
/>
<el-alert v-else-if="errorMsg" type="error" :closable="false" show-icon :title="errorMsg" />
<el-table
v-else
:data="entries" size="small" stripe
:row-class-name="rowClass"
@row-click="onRowClick"
>
<el-table-column width="46">
<template #default="{ row }">
<el-checkbox
:model-value="isSelected(row.path)"
@change="(v: any) => onCheckChange(row, v)" @click.stop
/>
</template>
</el-table-column>
<el-table-column label="名称" min-width="240">
<template #default="{ row }">
<span class="fname">
<el-icon><component :is="row.isDir ? 'Folder' : 'Document'" /></el-icon>
<span :class="{ 'is-dir': row.isDir }">{{ row.name }}</span>
</span>
</template>
</el-table-column>
<el-table-column label="大小" width="110">
<template #default="{ row }">{{ row.isDir ? '-' : fmtBytes(row.size) }}</template>
</el-table-column>
<el-table-column label="修改时间" width="160">
<template #default="{ row }">{{ fmtTime(row.mtime) }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!unsupported && !errorMsg && !loading && !entries.length" description="对方网盘是空的" :image-size="50" />
<!-- 下载进度 -->
<div v-if="Object.keys(tasks).length" class="tasks">
<div class="tasks-title">下载任务</div>
<div v-for="(t, path) in tasks" :key="path" class="task-row">
<span class="task-name">{{ path }}</span>
<el-progress
:percentage="t.total ? Math.min(100, Math.round((t.sent / t.total) * 100)) : 0"
:status="t.status === 'done' ? 'success' : t.status === 'error' ? 'exception' : undefined"
:stroke-width="10"
/>
<span class="task-state">
<template v-if="t.status === 'done'">完成</template>
<template v-else-if="t.status === 'error'">{{ t.reason || '失败' }}</template>
<template v-else>{{ fmtBytes(t.sent) }} / {{ fmtBytes(t.total) }}</template>
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.drive-panel { padding: 16px; overflow-y: auto; height: 100%; }
.my-drive {
display: flex; align-items: center; gap: 12px;
background: var(--el-fill-color-light); padding: 10px 14px; border-radius: 8px; margin-bottom: 12px;
}
.my-drive-left { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
.my-drive-label { font-size: 13px; font-weight: 500; white-space: nowrap; }
.my-drive-dir {
font-size: 12px; color: var(--el-text-color-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.my-drive-ops { display: flex; gap: 8px; flex-shrink: 0; }
.peer-drive { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; padding: 12px; }
.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.crumb { font-size: 13px; color: var(--el-text-color-primary); }
.crumb-root { color: var(--el-color-primary); cursor: pointer; }
.crumb-sep { margin: 0 4px; color: var(--el-text-color-secondary); }
.crumb-cur { color: var(--el-text-color-regular); }
.spacer { flex: 1; }
.sel-info { font-size: 12px; color: var(--el-text-color-secondary); }
.fname { display: inline-flex; align-items: center; gap: 6px; }
.fname .is-dir { color: var(--el-color-primary); }
.dir-mark { display: inline-block; width: 14px; }
:deep(.dir-row) { cursor: pointer; }
.tasks { margin-top: 12px; border-top: 1px dashed var(--el-border-color-lighter); padding-top: 10px; }
.tasks-title { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 6px; }
.task-row { display: grid; grid-template-columns: 200px 1fr 130px; align-items: center; gap: 10px; margin-bottom: 6px; }
.task-name {
font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.task-state { font-size: 12px; color: var(--el-text-color-secondary); text-align: right; }
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { DeviceView, ForwardSessionInfo, ForwardDirection } from '@/api'
import { useForwardStore } from '@/stores/remote'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -15,14 +15,43 @@ const ttlSec = ref(3600)
const creating = ref(false)
async function refresh() { await store.refresh() }
onMounted(refresh)
let timer = 0
onMounted(() => {
refresh()
// 会话列表靠轮询保持新鲜: 流量计数 / TTL 剩余 / 对方新建的会话都要能看到
timer = window.setInterval(() => { void refresh() }, 3000)
})
onUnmounted(() => { if (timer) window.clearInterval(timer) })
// 当前会话 = 我方为 client (我发起的) + 对方为 client (我接收的) 中所有涉及当前 peer 的
const myClient = computed(() => store.client.filter(s => s.peerId === props.peer?.deviceId))
const peerClient = computed(() => store.server.filter(s => s.peerId === props.peer?.deviceId))
function directionLabel(d: ForwardDirection) {
return d === 'self-out' ? '我方代理对方' : '对方代理我方'
// 端口转发只有两种拓扑:
// self-out — 发起方 bind 监听端口, 目标在【被控端】机器上
// self-in — 被控端 bind 监听端口, 目标在【发起方】机器上
// side 标明谁发起的 (client = 我发起, server = 对方发起), 下面按视角换算归属,
// 免得把"对方机器上的 127.0.0.1"显示成"本机的 127.0.0.1", 用户白跑一趟
function entryIsLocal(row: ForwardSessionInfo) {
return row.side === 'client' ? row.direction === 'self-out' : row.direction === 'self-in'
}
function targetIsLocal(row: ForwardSessionInfo) {
return row.side === 'client' ? row.direction === 'self-in' : row.direction === 'self-out'
}
function entryLabel(row: ForwardSessionInfo) {
return `${entryIsLocal(row) ? '本机' : '对方'} 127.0.0.1:${row.listenPort}`
}
function targetLabel(row: ForwardSessionInfo) {
return `${targetIsLocal(row) ? '本机' : '对方'} ${row.targetHost}:${row.targetPort}`
}
function directionLabel(row: ForwardSessionInfo) {
if (row.side === 'client') {
return row.direction === 'self-out' ? '我开端口 → 对方服务' : '对方开端口 → 我的服务'
}
return row.direction === 'self-out' ? '对方开端口 → 对方服务' : '我开端口 → 对方服务'
}
function directionType(row: ForwardSessionInfo) {
return row.direction === 'self-out' ? 'primary' : 'success'
}
async function createForward() {
@@ -48,12 +77,17 @@ async function createForward() {
ElMessage.error('转发失败: ' + (r.reason || ''))
return
}
ElMessage.success('已建立转发')
// 告诉用户入口到底在哪台机器上
const entry = direction.value === 'self-out'
? `本机 127.0.0.1:${listenPort.value}`
: `对方 127.0.0.1:${listenPort.value}`
ElMessage.success(`已建立转发, 入口: ${entry}`)
}
async function stop(s: ForwardSessionInfo) {
await store.close(s.sessionId)
ElMessage.success('已关闭')
const ok = await store.close(s.sessionId)
if (ok) ElMessage.success('已关闭')
else ElMessage.warning('该会话已不存在')
}
function fmtBytes(n: number) {
@@ -81,10 +115,10 @@ function ttlLabel(expiresAt: number) {
<label class="dir-label">方向</label>
<div class="dir-toggle">
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">
我方代理对方
我开端口 → 对方服务
</button>
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">
对方代理我方
对方开端口 → 我的服务
</button>
</div>
<span class="dir-hint">
@@ -96,25 +130,25 @@ function ttlLabel(expiresAt: number) {
<div class="form-row">
<template v-if="direction === 'self-out'">
<label>我方监听</label>
<el-input v-model.number="listenPort" size="small" style="width: 120px">
<el-input v-model.number="listenPort" size="small" style="width: 150px">
<template #prepend>127.0.0.1:</template>
</el-input>
<span class="arrow">→</span>
<label>对方目标</label>
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
<span>:</span>
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 90px" />
</template>
<template v-else>
<label>对方监听</label>
<el-input v-model.number="listenPort" size="small" style="width: 120px">
<el-input v-model.number="listenPort" size="small" style="width: 150px">
<template #prepend>127.0.0.1:</template>
</el-input>
<span class="arrow">→</span>
<label>我方目标</label>
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
<span>:</span>
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 90px" />
</template>
<span>TTL</span>
<el-input v-model.number="ttlSec" placeholder="秒" size="small" style="width: 80px" />
@@ -126,24 +160,24 @@ function ttlLabel(expiresAt: number) {
<div class="section">
<h3>我发起的 ({{ myClient.length }})</h3>
<p class="section-hint">我方代理对方的服务 (self-out) / 把我的服务暴露给对方 (self-in)。任何一方都能关闭。</p>
<p class="section-hint">我开端口代理对方的服务 (self-out) / 把我的服务暴露给对方 (self-in)。任何一方都能关闭。</p>
<el-table v-if="myClient.length" :data="myClient" size="small" stripe>
<el-table-column label="方向" width="120">
<el-table-column label="方向" width="170">
<template #default="{ row }">
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
<el-tag size="small" :type="directionType(row)">{{ directionLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="入口">
<el-table-column label="入口" min-width="170">
<template #default="{ row }">
<code>127.0.0.1:{{ row.listenPort }}</code>
<code>{{ entryLabel(row) }}</code>
</template>
</el-table-column>
<el-table-column label="目标">
<el-table-column label="目标" min-width="170">
<template #default="{ row }">
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
<code>{{ targetLabel(row) }}</code>
</template>
</el-table-column>
<el-table-column label="in/out" width="160">
<el-table-column label="发出/收到" width="160">
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
</el-table-column>
<el-table-column label="剩余" width="80">
@@ -162,24 +196,27 @@ function ttlLabel(expiresAt: number) {
<h3>对方发起的 ({{ peerClient.length }})</h3>
<p class="section-hint">对方正在使用 / 暴露的转发. 你也可以关闭.</p>
<el-table v-if="peerClient.length" :data="peerClient" size="small" stripe>
<el-table-column label="方向" width="120">
<el-table-column label="方向" width="170">
<template #default="{ row }">
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
<el-tag size="small" :type="directionType(row)">{{ directionLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="入口">
<el-table-column label="入口" min-width="170">
<template #default="{ row }">
<code>127.0.0.1:{{ row.listenPort }}</code>
<code>{{ entryLabel(row) }}</code>
</template>
</el-table-column>
<el-table-column label="目标">
<el-table-column label="目标" min-width="170">
<template #default="{ row }">
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
<code>{{ targetLabel(row) }}</code>
</template>
</el-table-column>
<el-table-column label="in/out" width="160">
<el-table-column label="发出/收到" width="160">
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
</el-table-column>
<el-table-column label="剩余" width="80">
<template #default="{ row }">{{ ttlLabel(row.expiresAt) }}</template>
</el-table-column>
<el-table-column label="" width="80">
<template #default="{ row }">
<el-button size="small" link type="danger" @click="stop(row)">关闭</el-button>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick, h } from 'vue'
import type { DeviceView, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbDirection, UsbAttachConfig, UsbEndpointInfo } from '@/api'
import { useUsbStore } from '@/stores/remote'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -297,7 +297,13 @@ function clearHex() { hexBuf.value = [] }
function clearUsbLog() { usbLog.value = [] }
async function refresh() { await store.refresh() }
onMounted(refresh)
let timer = 0
onMounted(() => {
refresh()
// 轮询保持会话列表和流量计数新鲜
timer = window.setInterval(() => { void refresh() }, 3000)
})
onUnmounted(() => { if (timer) window.clearInterval(timer) })
function directionLabel(d: UsbDirection) {
return d === 'self-out' ? '我用对方的' : '对方用我的'
+15 -3
View File
@@ -9,8 +9,9 @@ export const useDeviceStore = defineStore('device', () => {
const unread = ref<Record<string, number>>({})
const scanning = ref(false)
const lastScanAt = ref(0)
// 我们 outgoing WS 接通状态 (与 discovery.online 区分: UDP 在 ≠ WS 通)
// 默认乐观地认为 WS 通的 (首次进入 chat 还没收到 device:wsState 事件前), 直到第一次 close
// 我们与各对端的"消息通道"状态 (与 discovery.online 区分: UDP 在 ≠ WS 通)
// key = deviceId, value = 通道是否可用 (outgoing 或 inbound 任一 WS 活着即为 true)
// 没收到过状态快照的设备一律按 false 处理 (UI 显示"连接中"), 不猜"在线"
const wsOpen = ref<Record<string, boolean>>({})
async function refresh() {
@@ -39,11 +40,22 @@ export const useDeviceStore = defineStore('device', () => {
wsOpen.value = { ...wsOpen.value, [deviceId]: open }
}
// 应用全量快照 (启动时从主进程拉取, 或收到 device:wsState 快照)
// 全量替换而不是增量合并: 快照里没有的设备就是"没连上", 增量合并会留下过期状态
function setWsStates(map: Record<string, boolean>) {
wsOpen.value = { ...map }
}
// 消息通道是否可用 (outgoing / inbound 任一活着)
function channelAlive(deviceId: string): boolean {
return wsOpen.value[deviceId] === true
}
const onlineDevices = computed(() => devices.value.filter(d => d.online))
return {
self, devices, settings, unread, scanning, lastScanAt, wsOpen,
refresh, loadSelf, triggerScan, setWsOpen,
refresh, loadSelf, triggerScan, setWsOpen, setWsStates, channelAlive,
onlineDevices,
}
})
+13 -3
View File
@@ -121,10 +121,13 @@ export const useForwardStore = defineStore('forward', () => {
const sessions = computed(() => [...client.value, ...server.value])
function onOpened(_info: any) { /* no-op */ }
function onOpened(_info: any) {
// 主进程广播了 sessionOpened (我发起的 / 对方发起的) —— 必须重新拉列表, 否则对方发起的转发不显示
void refresh()
}
function onClosed(_info: any) {
// 局部更新; 完整刷新由 forward:opened/closed 事件触发, 这里 best-effort
// 局部更新 (立即反馈流量), 再整体刷新 (会话已从主进程移除)
const idx1 = client.value.findIndex(s => s.sessionId === _info.sessionId)
if (idx1 >= 0) {
const cur = client.value[idx1]
@@ -135,6 +138,7 @@ export const useForwardStore = defineStore('forward', () => {
const cur = server.value[idx2]
server.value[idx2] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut }
}
void refresh()
}
return { client, server, sessions, refresh, open, close, onOpened, onClosed }
@@ -220,6 +224,11 @@ export const useUsbStore = defineStore('usb', () => {
return out
}
function onOpened(_info: any) {
// 对方发起 attach 时主进程会广播 sessionOpened —— 不刷新的话面板上看不到这条 session
void refresh()
}
function onClosed(_info: any) {
const upd = (arr: typeof client.value) => {
const idx = arr.findIndex(s => s.sessionId === _info.sessionId)
@@ -230,13 +239,14 @@ export const useUsbStore = defineStore('usb', () => {
}
upd(client.value)
upd(server.value)
void refresh()
}
return {
devicesByPeer, myLocalDevices, client, server, sessions, buffers,
list, listLocal, refresh, attach,
serialSend, ctrlOut, ctrlIn, bulkOut, bulkIn,
detach, onOutput, takeBuffer, onClosed,
detach, onOutput, takeBuffer, onOpened, onClosed,
}
})