diff --git a/.gitignore b/.gitignore index c2c2b9d..df2f817 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ data/ !data/.gitkeep .cache/ .vscode/ +.idea/ +scripts/selftest/.out/ diff --git a/AGENTS.md b/AGENTS.md index bfa317e..3259f66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/x/apps/.png`,而单张 PNG 交给 `app-builder` 时它解析不出尺寸(返回 `size: 0`),于是装进 `hicolor/0x0/` 这种非法目录,桌面环境根本找不到 → 启动器没图标。正确做法是给一个**目录**,里面按 `x.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 WM_CLASS` 确认真实值,然后在 `build.linux.desktop.StartupWMClass` 里写它。 + +Windows / macOS 仍用单张 `resources/icon.png`(那里不按尺寸分目录)。 diff --git a/docs/connection-status.md b/docs/connection-status.md new file mode 100644 index 0000000..dee476e --- /dev/null +++ b/docs/connection-status.md @@ -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` | 顶部的点与文案 | diff --git a/docs/drive.md b/docs/drive.md new file mode 100644 index 0000000..e7b485f --- /dev/null +++ b/docs/drive.md @@ -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`)。 diff --git a/docs/remote-terminal-forward-usb.md b/docs/remote-terminal-forward-usb.md index d9ec740..78448db 100644 --- a/docs/remote-terminal-forward-usb.md +++ b/docs/remote-terminal-forward-usb.md @@ -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 提示符 | diff --git a/package.json b/package.json index 2812d3a..8c302f4 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.1.0", "private": true, "description": "局域网即时通讯 (Electron + Vue 3) - 飞书风格", + "homepage": "https://github.com/localnetmsg/local-net-msg", + "author": "LocalNetMsg ", "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 ", + "desktop": { + "StartupWMClass": "local-net-msg" + } } } } diff --git a/resources/icon-256.png b/resources/icon-256.png deleted file mode 100644 index 565a114..0000000 Binary files a/resources/icon-256.png and /dev/null differ diff --git a/resources/icons/128x128.png b/resources/icons/128x128.png new file mode 100644 index 0000000..555ef4f Binary files /dev/null and b/resources/icons/128x128.png differ diff --git a/resources/icons/16x16.png b/resources/icons/16x16.png new file mode 100644 index 0000000..daf7e39 Binary files /dev/null and b/resources/icons/16x16.png differ diff --git a/resources/icons/24x24.png b/resources/icons/24x24.png new file mode 100644 index 0000000..634c9dd Binary files /dev/null and b/resources/icons/24x24.png differ diff --git a/resources/icons/256x256.png b/resources/icons/256x256.png new file mode 100644 index 0000000..1b06904 Binary files /dev/null and b/resources/icons/256x256.png differ diff --git a/resources/icons/32x32.png b/resources/icons/32x32.png new file mode 100644 index 0000000..821ba48 Binary files /dev/null and b/resources/icons/32x32.png differ diff --git a/resources/icons/48x48.png b/resources/icons/48x48.png new file mode 100644 index 0000000..376ad5b Binary files /dev/null and b/resources/icons/48x48.png differ diff --git a/resources/icons/512x512.png b/resources/icons/512x512.png new file mode 100644 index 0000000..4e00110 Binary files /dev/null and b/resources/icons/512x512.png differ diff --git a/resources/icons/64x64.png b/resources/icons/64x64.png new file mode 100644 index 0000000..4628da4 Binary files /dev/null and b/resources/icons/64x64.png differ diff --git a/scripts/make-icon.js b/scripts/make-icon.js index 23a1230..00fbe72 100644 --- a/scripts/make-icon.js +++ b/scripts/make-icon.js @@ -1,28 +1,77 @@ -// 把候选 logo 处理成 app icon: -// - 转 PNG (白色背景填充为透明) -// - 缩放到 1024x1024 (electron-builder 推荐) -// - 顺便输出 256x256 给 renderer 用 -const sharp = require('sharp') +// 生成 Linux 多尺寸图标集 -> resources/icons/x.png +// +// 为什么需要多尺寸: electron-builder 的 Linux 打包会把图标装进 +// /usr/share/icons/hicolor/x/apps/.png。 +// 如果 linux.icon 指向"单张 PNG", app-builder 解析不出尺寸 (返回 size=0), +// 图标就被装到 hicolor/0x0/ 这种非法目录里, 桌面环境根本找不到 -> 安装后没图标。 +// 指向一个"目录 + x.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() diff --git a/scripts/selftest/electron-stub.cjs b/scripts/selftest/electron-stub.cjs new file mode 100644 index 0000000..2415942 --- /dev/null +++ b/scripts/selftest/electron-stub.cjs @@ -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() {} }, +} diff --git a/scripts/selftest/main.ts b/scripts/selftest/main.ts new file mode 100644 index 0000000..1b225b8 --- /dev/null +++ b/scripts/selftest/main.ts @@ -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) { + 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 { + 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 { + 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((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 | 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) +}) diff --git a/scripts/selftest/pty-shim.cjs b/scripts/selftest/pty-shim.cjs new file mode 100644 index 0000000..090b986 --- /dev/null +++ b/scripts/selftest/pty-shim.cjs @@ -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, +} diff --git a/src/main/chat-server.ts b/src/main/chat-server.ts index e476571..ab34060 100644 --- a/src/main/chat-server.ts +++ b/src/main/chat-server.ts @@ -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 { 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 {} diff --git a/src/main/db.ts b/src/main/db.ts index 228cf81..1da5610 100644 --- a/src/main/db.ts +++ b/src/main/db.ts @@ -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) { diff --git a/src/main/drive.ts b/src/main/drive.ts new file mode 100644 index 0000000..d3ce9f4 --- /dev/null +++ b/src/main/drive.ts @@ -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 { + 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() +} diff --git a/src/main/file-server.ts b/src/main/file-server.ts index 3f3f9ef..2d8fe6b 100644 --- a/src/main/file-server.ts +++ b/src/main/file-server.ts @@ -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 { @@ -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') diff --git a/src/main/index.ts b/src/main/index.ts index 3b5cb5a..584ca41 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(), }) } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7ddac2e..aca8cfd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 { + 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', () => { diff --git a/src/main/paths.ts b/src/main/paths.ts index e118eec..59b3bd1 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -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 } diff --git a/src/main/protocol.ts b/src/main/protocol.ts index 645816a..d58a5bd 100644 --- a/src/main/protocol.ts +++ b/src/main/protocol.ts @@ -23,6 +23,8 @@ export interface DeviceInfo { chatPort: number filePort: number ts: number // 上次心跳时间 + /** 网盘访问令牌 (对端用它调 /drive/* 接口; 没有这个字段 = 老版本, 不支持网盘) */ + driveToken?: string } // ============ WebSocket 协议 ============ diff --git a/src/main/remote/forward.ts b/src/main/remote/forward.ts index 0f054fb..179afff 100644 --- a/src/main/remote/forward.ts +++ b/src/main/remote/forward.ts @@ -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) } } diff --git a/src/main/remote/terminal.ts b/src/main/remote/terminal.ts index dd59729..1ec9dbf 100644 --- a/src/main/remote/terminal.ts +++ b/src/main/remote/terminal.ts @@ -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 + } } } diff --git a/src/main/remote/usb.ts b/src/main/remote/usb.ts index 2809090..b330b04 100644 --- a/src/main/remote/usb.ts +++ b/src/main/remote/usb.ts @@ -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) { 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' diff --git a/src/main/settings.ts b/src/main/settings.ts index 75f634d..431c54b 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -26,6 +26,10 @@ export interface Settings { terminalReadOnlyByDefault: boolean /** 审计日志保留天数 */ auditRetentionDays: number + /** 网盘总开关 (关闭后不再对外提供目录浏览/下载) */ + driveEnabled: boolean + /** 网盘根目录 (对外共享的目录, 对端只能看到这个目录里的内容) */ + driveDir: string } export const store = new Store({ @@ -46,6 +50,8 @@ export const store = new Store({ 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 +} diff --git a/src/main/ws-state.ts b/src/main/ws-state.ts new file mode 100644 index 0000000..dc70cb8 --- /dev/null +++ b/src/main/ws-state.ts @@ -0,0 +1,31 @@ +// 消息通道状态计算 (纯逻辑, 不依赖 electron, 便于自测) +// +// 通道 = outgoing WS (我们主动连过去的) 或 inbound WS (对方连过来的), 任何一条活的就说明消息能通。 +// 之前 UI 只盯着 outgoing, 结果对方只连进来、我们没连上时, 会误报"等待重连"。 + +export type WsClientStatus = Record + +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 { + const out: Record = {} + 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 +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 35a77b0..b2dbfee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index c84a980..0b72cd9 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -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 | 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 }) => { + 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 }) => { + 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(() => {}) }) diff --git a/src/renderer/src/api.ts b/src/renderer/src/api.ts index 4bb2248..70d6e44 100644 --- a/src/renderer/src/api.ts +++ b/src/renderer/src/api.ts @@ -7,6 +7,8 @@ export type LnmApiBase = { self: () => Promise listDevices: () => Promise + // 主进程当前状态快照 (ws = 各设备的消息通道是否可用) + appState: () => Promise triggerScan: () => Promise 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 +} + 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 + 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 + drivePickDir: () => Promise + remoteApprovalList: () => Promise remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue index cd1578e..6db9b7d 100644 --- a/src/renderer/src/components/ChatView.vue +++ b/src/renderer/src/components/ChatView.vue @@ -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('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) {
-