Compare commits
5 Commits
090bc3593f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 82e330c7d0 | |||
| 2a641b1eff | |||
| 2b63bf7d0b | |||
| d854c95ee6 | |||
| 4fc86edd4e |
@@ -11,18 +11,32 @@ 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` |
|
||||
| Remote feature self-test (terminal / forward / serial) | `npm run selftest` |
|
||||
| Sync-drive end-to-end test (relation -> sync -> delete propagation) | `npm run selftest:sync` |
|
||||
| Packaged-artifact smoke check (asar unpack + real pty spawn) | `npm run selftest:package` (run after `npm run package:*`) |
|
||||
| Real-Electron nativeImage check (thumbnail encode / transparency / clipboard) | `npm run selftest:image` (needs a display) |
|
||||
| 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.
|
||||
|
||||
`npm run selftest` runs the remote (terminal / port-forward / USB) self-test: one process, two virtual
|
||||
`npm run selftest` runs the remote (terminal / port-forward / serial) 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`.
|
||||
`npm run selftest:sync` runs the sync-drive end-to-end test (`scripts/selftest/sync-e2e.ts`): two virtual
|
||||
nodes, real WS + HTTP, covering **建立关系 → 双向同步 → 改文件 → 删文件 → 删除传播 → 墓碑防复活**.
|
||||
改同步引擎后跑它 —— 它守住的是"删了能删掉、删了不会复活"这两条命门。
|
||||
|
||||
`npm run selftest:image` 在**真 Electron**(需要显示器)下验 `nativeImage` 的实际行为: 缩略图编码阶梯、
|
||||
PNG 透明通道、转 JPEG 丢透明、剪贴板写读、坏文件降级。它打的是**生产代码**
|
||||
(`src/main/image.ts`, esbuild 打包且 `--external:electron`), 不是桩 —— 自测里的 electron 是桩, 那套只能验逻辑。
|
||||
|
||||
视觉验证 (UI 改动后用): `scripts/selftest/inject-image-msg.cjs` 往 App 的 SQLite 注入演示消息,
|
||||
`scripts/selftest/cdp-shot.cjs` 用 CDP 驱动真实窗口点按钮/右键并截图 (环境里没有 xdotool)。
|
||||
用完记得把注入的数据清掉。
|
||||
|
||||
Verify changes via `npm run typecheck` + `npm run selftest` + `npm run selftest:sync` + manual `npm run dev`.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -36,8 +50,15 @@ src/
|
||||
db.ts SQLite via better-sqlite3 (data/app.db)
|
||||
settings.ts electron-store wrapper (~/AppData/Roaming/local-net-msg/config.json)
|
||||
notify.ts System Notification + Windows overlay badge
|
||||
image.ts nativeImage 封装: 缩略图生成 + 复制到剪贴板 + 读原图
|
||||
image-core.ts 图片纯逻辑 (阈值/编码选择/选图源) —— 不 import electron, 可单测
|
||||
ipc.ts All ipcMain.handle() wiring
|
||||
protocol.ts MessageEnvelope, WsFrame, DeviceInfo types
|
||||
relations.ts 关系层: 持久绑定 + per-peer 令牌 + 分级授权 (JSON 存储)
|
||||
relation-service.ts 关系服务(类): 建立/接受/拒绝/吊销 + 应用对方关系表
|
||||
sync-manifest.ts 文件关系表: 墓碑 + Lamport seq + 三方对比 (同步盘地基)
|
||||
sync.ts 同步引擎: 扫描 -> 三方对比 -> 上传/下载/删除传播
|
||||
auto-sync.ts 自动同步定时器 (60s 轮询 + 指数退避)
|
||||
preload/ contextBridge exposing typed API to renderer
|
||||
renderer/ Vue 3 SPA (Vite root)
|
||||
src/
|
||||
@@ -47,11 +68,37 @@ src/
|
||||
stores/ Pinia: device.ts, message.ts, session.ts
|
||||
api.ts IPC typings (mirrors preload)
|
||||
utils/ format.ts (colorFor, initialsOf, formatSize, ...)
|
||||
unread.ts (未读处理策略: unreadActionForIncoming)
|
||||
electron.vite.config.ts Three builds → out/{main,preload,renderer}/
|
||||
```
|
||||
|
||||
Renderer alias: `@` → `src/renderer/src`.
|
||||
|
||||
## 同步盘 (sync drive) — 三层模型
|
||||
|
||||
**发现 ≠ 信任**。拆成三层: 发现(临时) → 关系(持久, 用户显式建立) → 授权(分级, 双方各自独立)。
|
||||
|
||||
- **关系怎么建立**: 跟远程终端的授权闸门**完全一致** —— 对方**直接弹窗**, 不用邀请码/6位码/系统消息卡片。
|
||||
`A 点"发起同步关系"` → WS 帧 `relation:request` → B 侧 `enqueueApproval()`(kind='relation') → 弹窗同意 → 回 `relation:accept`。
|
||||
帧定义在 `protocol.ts`, 路由在 `ipc.ts` 的 `routeRelationFrame()`。
|
||||
- **令牌**: `myToken`=我签发给对方的, `peerToken`=对方签发给我的。旧的全局 `driveToken`
|
||||
**不再进 UDP 广播包** (同网段任何进程拿到广播包就能浏览你整个网盘)。file-server 在关系令牌
|
||||
匹配不上时回退到旧的全局 token, 给还没升级的对端留过渡通道。
|
||||
- **两套独立空间**:
|
||||
- `/drive/*` 网盘 —— 要 `drive` 授权, 路径限制在网盘根内
|
||||
- `/sync/*` 同步区 —— 要 `sync` 授权, 路径限制在**该关系的同步目录**内 (任意位置, 不必在网盘里)
|
||||
只给 `sync` 时**完全不能**浏览网盘。
|
||||
- **文件关系表 (manifest)**: 每个关系一份 (key=relationId, 所以同一个本地目录跟多个关系同步也不串)。
|
||||
- **墓碑**: 删除不抹记录, 只标 `state='deleted'` —— 直接抹掉的话, 对方离线回来会把已删文件
|
||||
当成"新增"重新下载, **删除永远删不掉**; 对方从备份恢复也会让删除被静默撤销。
|
||||
- **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定删除方向的关键。
|
||||
- **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线时退回 mtime 兜底 (宁可多弹窗)。
|
||||
- 墓碑保留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。
|
||||
- 删除传播落点: `RelationService.applyPeerManifest()` —— 对方说删了, 我比对基线确认没改过才删本地;
|
||||
改过则报冲突, **绝不静默丢数据**。
|
||||
|
||||
详细设计见 `docs/relation-and-sync.md`。
|
||||
|
||||
## Network protocol
|
||||
|
||||
UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All three bind `0.0.0.0` and auto-shift on conflict.
|
||||
@@ -64,7 +111,21 @@ UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All th
|
||||
|
||||
2. **Drag-drop file path.** Electron 32 removed `File.path`. Use `webUtils.getPathForFile(file)` from `electron` — exposed in `src/preload/index.ts` as `getPathForFile(file)`. Callers must read it via `window.api.getPathForFile(f)` BEFORE the await boundary.
|
||||
|
||||
3. **Native rebuild.** `postinstall` runs `electron-builder install-app-deps` to rebuild `better-sqlite3` against the bundled Electron ABI. After `npm i` or upgrading Electron, this must run. If you forget, sqlite open will throw on launch.
|
||||
3. **Native rebuild —— 只重建 `better-sqlite3`, 别用 `electron-builder install-app-deps`.**
|
||||
`better-sqlite3` 不是 N-API, 绑死 V8/ABI (Electron 32 → node ABI 128), 必须对着 Electron
|
||||
头文件编译; `npm i` 或升级 Electron 后必须重编, 否则 sqlite 打开即崩。
|
||||
`postinstall` 现在是 `node scripts/postinstall.js`: 只重建 better-sqlite3, 装完**在 Electron 下
|
||||
真跑一次读写校验** (失败就 exit 1, 把"启动即崩"提前到 install 阶段暴露)。
|
||||
**别改回 `electron-builder install-app-deps`** —— 它重建**所有**原生模块, 且给子进程塞
|
||||
`npm_config_build_from_source=false`; npm 11 对未知 npm_config_* 保留原始字符串, 于是子进程里
|
||||
它是字符串 `"false"` —— JS 里是真值 —— `pkg-prebuilds-verify` 直接 exit 1
|
||||
(`node_modules/pkg-prebuilds/bin/verify.mjs:14`), 强制源码编译。node-hid 于是去编译它自带的
|
||||
node-addon-api 3.2.1, 和现代 Node 头文件不兼容, 编译失败, 整个 `npm install` 挂掉 ——
|
||||
而它本来根本不需要编译 (自带 N-API 预编译产物, 任何 ABI 都能加载)。
|
||||
同理, node-pty 1.2 的二进制在**平台包** `@lydell/node-pty-<plat>-<arch>` 里, 主包没有
|
||||
`prebuilds/` 目录 —— 按主包去找只会误报。
|
||||
npm 11 还有个 `allowScripts` 机制 (根 package.json 的 `allowScripts` 字段): 没被显式列入的
|
||||
安装脚本只会被"提醒"而非拦截, 但建议把信任的包列进白名单, 免得将来 npm 真的开始拦截。
|
||||
|
||||
4. **`mkdir` for Windows reserved names.** Output dir is `dist-Electron\` (escaped; `dist\electron` chokes on Windows). Don't rename without verifying the build still produces a valid path.
|
||||
|
||||
@@ -76,12 +137,63 @@ UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All th
|
||||
|
||||
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()`.
|
||||
|
||||
9. **图片消息: 缩略图随消息体走, 右键复制写的是原图.**
|
||||
气泡里显示的是 `body.thumbDataUrl` (data URL), **发送方生成一次, 对方直接拿来显示** ——
|
||||
所以对端不用再解码一遍, 老版本对端也看得到图。
|
||||
缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp:
|
||||
宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG,
|
||||
然后按"降质量 → 缩尺寸"的阶梯找一档够小的 (`src/main/image-core.ts` 里的纯逻辑, 可单测)。
|
||||
源文件超过 20MB 不生成缩略图 —— nativeImage 解码会把整张图铺成 RGBA 内存。
|
||||
**拖拽/选中的图片必须走 `sendFile(asImage: true)`** (有真实路径, 流式上传),
|
||||
只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图会撑爆带宽)。
|
||||
右键"复制图片"走 `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图**
|
||||
(本地文件优先, 退回 `dataBase64`), 不是气泡里那个缩略图。
|
||||
缩略图缺失时 (老消息 / 老版本对端) 渲染层退到 `image:thumb` 现场生成, 失败才显示文件卡片。
|
||||
|
||||
10. **USB 字节桥和 USB/IP 已下线, 只剩串口.** v0.2.0 把 `src/main/remote/usb.ts` 换成了
|
||||
`src/main/remote/serial.ts` —— 只做**串口映射** (Linux/macOS 用 socat 建 PTY 配对,
|
||||
Windows 用 com0com) + **串口助手** (应用自己收发字节: 文本/HEX、循环发送、保存日志)。
|
||||
删掉的: node-usb 的 control/bulk 字节桥、USB/IP 真透传, 以及 `node-usb` / `node-hid`
|
||||
两个原生依赖 (它们其实压根没被真正用到)。
|
||||
**线上格式一个字节都没动** —— WS 帧类型仍然叫 `'usb'`, `PROTOCOL_VERSION` 仍是 1,
|
||||
内部类型全改名成 `Serial*`。原因: 局域网里常有一台机器还跑着旧版本, 升协议版本会让
|
||||
两边连不上 (连文字消息都发不了), 代价太大。现在新旧版本之间: 聊天照常, **串口映射也照常**
|
||||
(老版本本来就支持 `kind:'serial'`), 只有 USB 字节桥在混版本时用不了 —— 而那功能已下线。
|
||||
代价: 协议里那个 `'usb'` 帧名和实际内容对不上, 改的时候别手贱"顺手改对" —— 会把
|
||||
所有还在跑旧版本的对端一起踢下线。
|
||||
另注: **拉对端的串口列表要对方用户批准** (新旧版本都这样), 对方没点批准就是 15 秒超时 ->
|
||||
列表空 + 报"超时"。这是审批机制不是 bug, 别去加"自动跳过审批"的逻辑。
|
||||
|
||||
11. **未读/角标: 正在看的会话必须立刻清未读, 且不能被 `savedPath` 拦下来.**
|
||||
踩过的坑: `App.vue` 的 `message:received` 里先判断 `if (!savedPath) return` (等文件落地再算未读),
|
||||
结果**文字消息没有 `savedPath`** —— 正好在"清未读"之前就 return 了, 于是用户明明在看这个会话,
|
||||
角标数字却一直挂着。文件/图片消息因为第二帧带 `savedPath` 才碰巧能清掉, 所以只有文字消息暴露这个 bug。
|
||||
规矩: **先判 `isActive` -> 清未读 + 回已读回执, 再判 `savedPath`**。
|
||||
决策抽成了纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()`
|
||||
(`clear` / `wait` / `count`), 自测 U1/U2 守着它 —— 别再把顺序写回 if 里。
|
||||
另外两处也要清: 侧边栏**重复点同一个设备**时 `activePeerId` 不变, watch 不触发, 得显式清一次;
|
||||
主进程 `device:delete` 清了 unread 但**忘了 `updateBadge()`**, 角标数字会残留。
|
||||
|
||||
## Configuration knobs
|
||||
|
||||
- `LNM_MAX_FILE_SIZE=<bytes>` env var overrides the 100 GB default in `file-server.ts`. Useful for testing the limit path.
|
||||
- `deviceName`, `downloadDir`, `notifications`, `autoStart` live in `~/AppData/Roaming/local-net-msg/config.json` via `electron-store`. Edit the file directly while dev is stopped.
|
||||
- `data/app.db` — SQLite DB. DELETE to reset all devices/messages. Auto-migrates on next launch.
|
||||
|
||||
## 提交与推送
|
||||
|
||||
远端是自建 Gitea (`https://git.xsinfo.vip/xiaoshuai/LocalNetMsg.git`)。**HTTPS 没有配 credential helper**,非交互式 `git push` 会卡在 `could not read Username` 上 —— 所以 push URL 已经指到 SSH:
|
||||
|
||||
```
|
||||
origin https://git.xsinfo.vip/xiaoshuai/LocalNetMsg.git (fetch)
|
||||
origin git@git.xsinfo.vip:xiaoshuai/LocalNetMsg.git (push)
|
||||
```
|
||||
|
||||
fetch 走 HTTPS、push 走 SSH,直接 `git push` 就能用。SSH key 失效时退回:
|
||||
`git push git@git.xsinfo.vip:xiaoshuai/LocalNetMsg.git main:main`(显式指定 URL 不改 remote 配置)。
|
||||
|
||||
拉远端最新提交用 `git fetch git@git.xsinfo.vip:xiaoshuai/LocalNetMsg.git main:refs/remotes/origin/main` —— 否则 `origin/main` 是陈的,`git status` 里的 "领先 N" 是假的。
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -62,7 +62,7 @@ WS 通常几十毫秒内就握手成功 → 主进程广播 `device:wsState` →
|
||||
| 设备在线但通道不通 | `在线 · 192.168.0.7 (连接中…)`(黄点,后台在 backoff 重连) |
|
||||
| 都不可用 | `离线 · 12:05`(灰点) |
|
||||
|
||||
远程功能(终端 / 转发 / USB / 网盘)都走 WS 通道,所以入口按钮在
|
||||
远程功能(终端 / 转发 / 串口 / 网盘)都走 WS 通道,所以入口按钮在
|
||||
`设备离线 && 通道不通` 时才禁用——只看设备在线会把"能连但没连上"的情况误判成可用。
|
||||
|
||||
## 4. 回归测试
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
# 关系模型 + 同步区设计
|
||||
|
||||
> 目标: 把"发现"和"信任"彻底分开。发现只是知道对方存在; **关系**必须由用户显式建立;
|
||||
> **同步区**在关系之上, 靠持久化的文件关系表 (manifest) 正确处理增删改, 尤其**删除**。
|
||||
>
|
||||
> 这份文档描述的是**已经落地的实现**。早期设计稿里的"邀请码"和"网盘子目录"
|
||||
> 两套方案**没有采用**, 原因分别在 §3.1 和 §4.1。代码位置见 §13。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要改 (现状的问题)
|
||||
|
||||
改造前是隐式全自动的:
|
||||
|
||||
```
|
||||
设备启动 → UDP 广播 announce{deviceId, name, address, chatPort, filePort, driveToken}
|
||||
→ 局域网所有机器收到 → 自动进"最近设备" → 自动建 WS → 消息双向开放
|
||||
→ 网盘 token 在广播包里明文传 → 局域网任何人拿它就能浏览你的整个共享目录
|
||||
```
|
||||
|
||||
问题:
|
||||
|
||||
1. **发现 = 信任**。同网段任何进程发个广播包就成了"联系人", 无法选择"我只跟谁建立关系"。
|
||||
2. **网盘凭据全局广播**, 而远程终端/串口 却有授权闸门 —— **两套标准**。网盘等于对整个局域网敞开。
|
||||
3. **没有确认环节**, 无法表达"我认识这个人, 我要跟他同步文件夹"。
|
||||
4. **授权粒度不统一**: 远程操作有 per-peer 白名单, 网盘和消息完全没有。
|
||||
5. **删除无法传播**。没有 manifest 就没有"这个文件之前存在过、现在被谁删了"的记录。
|
||||
|
||||
对应的解法就是下面的三层模型 + 关系级授权 + 文件关系表。
|
||||
|
||||
---
|
||||
|
||||
## 2. 三层模型
|
||||
|
||||
| 层 | 含义 | 生命周期 | 产生信任吗 |
|
||||
|---|---|---|---|
|
||||
| **发现 (Discovery)** | 知道对方存在 | 临时 (15s 心跳) | 否 |
|
||||
| **关系 (Relation)** | 用户显式建立的一次绑定 | 持久 | 是 |
|
||||
| **授权 (Grant)** | 关系上的具体能力 | 随关系 | — |
|
||||
|
||||
关系层目前只有两项能力, **双方各自独立**授权 (A 允许 B 看网盘 ≠ B 允许 A 看网盘):
|
||||
|
||||
| 能力 | 含义 |
|
||||
|---|---|
|
||||
| `drive` | 允许对方浏览我的网盘 (共享目录) |
|
||||
| `sync` | 允许与对方同步文件 (走该关系专属的同步区) |
|
||||
|
||||
远程终端 / 端口转发 / 串口 **没有**并进关系层, 它们仍然走原来的
|
||||
`settings.remoteAllowPeers` 白名单 + 弹窗闸门 (`src/main/remote/approval.ts`)。
|
||||
理由: 那三个是"一次性操作请求", 关系是"长期绑定", 生命周期和撤销语义都不一样,
|
||||
硬合并会让远程操作每次都要查关系表, 反而更绕。
|
||||
|
||||
**关键改动**: 全局 `driveToken` **不再出现在广播包里**。`index.ts` 的 announce 包
|
||||
故意不带这个字段, 网盘接口改成校验**关系令牌** (per-peer, 对方签发给我的那个)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 建立关系: 对方直接弹窗 (没有邀请码)
|
||||
|
||||
### 3.1 为什么不用邀请码
|
||||
|
||||
早期设计稿走的是"6 位数字码 + 聊天系统消息卡片 + 一次性邀请表"那一套, 实现时全部砍掉了:
|
||||
|
||||
1. **远程终端已经有一套成熟的授权闸门** —— 白名单 + 24h 记忆 + 弹窗
|
||||
(`approval.ts` + `ApprovalDialog.vue`)。关系层直接复用同一个队列、同一个弹窗组件,
|
||||
用户的心智模型是一致的: "对方弹窗问我要不要给权限"。
|
||||
2. **邀请码要额外维护一整套状态机**: 码的生成/分发/过期/一次性/防爆破/聊天消息卡片/
|
||||
待确认表。而这套状态机解决的问题, 弹窗一步就解决了 —— 授权范围本来就是**当场选**的事。
|
||||
3. **同步本来就需要一条 WS 通道**。请求走已有的通道比走"码"更直接, 而且天然知道
|
||||
对方在不在线: 帧发不出去就直接失败回收关系, 不用等邀请码超时。
|
||||
4. 用户还得想办法把码告诉对方 (念给对方听?), 反而比"对方弹个窗"麻烦。
|
||||
|
||||
所以最终形态是: **A 发起 → B 弹窗 → B 同意 → 双方各自落库**。
|
||||
|
||||
### 3.2 完整交互流程
|
||||
|
||||
```
|
||||
A 侧 (发起方) B 侧 (被请求方)
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
同步面板 → "建立同步关系"
|
||||
选本机同步目录
|
||||
勾选"我开放给对方的能力"
|
||||
[x] 允许浏览我的网盘 (drive)
|
||||
[x] 允许与我同步文件 (sync)
|
||||
点"发起同步关系"
|
||||
│
|
||||
│ WS 帧 relation:request
|
||||
│ (含 relationId / 我签发的 token / 我开放的能力 /
|
||||
│ 我的同步目录 / 我提议的目录名)
|
||||
▼
|
||||
主进程收到 → 直接弹窗
|
||||
(ApprovalDialog, kind='relation')
|
||||
- 显示对方要什么
|
||||
- 我开放什么 (可改勾选)
|
||||
- 我的本机同步目录 (可填)
|
||||
点"同意"
|
||||
│ WS 帧 relation:accept
|
||||
│ (含 B 签发的 token / B 开放的能力 / B 的同步目录)
|
||||
▼
|
||||
A: 关系落库 state='active'
|
||||
记 peerToken = B 签发的那个
|
||||
B: 关系落库 state='active'
|
||||
记 myToken = 自己签发的那个
|
||||
```
|
||||
|
||||
拒绝时 B 回 `relation:reject`, A 侧把 pending 关系**删掉** (不留残条)。
|
||||
发起方在等待期间的关系状态是 `pending`; 一旦 accept 到达就转 `active`。
|
||||
|
||||
已建立过关系的对端再发 `relation:request` 会被**直接回绝** (不发弹窗),
|
||||
避免重复弹窗刷屏。
|
||||
|
||||
### 3.3 WS 帧
|
||||
|
||||
| 帧 | 方向 | 载荷要点 |
|
||||
|---|---|---|
|
||||
| `relation:request` | 发起方 → 对方 | `relationId`, `token`(我签发的), `grants`, `syncLocalDir`, `syncRemotePath`, `peer`(我的设备信息) |
|
||||
| `relation:accept` | 对方 → 发起方 | `relationId`, `token`(我签发的), `grants`, `syncLocalDir`, `syncRemotePath`, `peer` |
|
||||
| `relation:reject` | 对方 → 发起方 | `relationId`, `reason` |
|
||||
|
||||
三个帧都在 `src/main/protocol.ts` 定义, 由 `ipc.ts` 的 `routeRelationFrame()` 分发。
|
||||
|
||||
### 3.4 令牌模型
|
||||
|
||||
| 令牌 | 谁持有 | 作用 |
|
||||
|---|---|---|
|
||||
| `myToken` | 对方 (我签发给它的) | 它用来访问**我的**网盘/同步区 |
|
||||
| `peerToken` | 我 (对方签发给我的) | 我用来访问**对方的**网盘/同步区 |
|
||||
| `driveToken` (旧) | ~~全局广播~~ | **废弃**, 不再出现在发现包里 |
|
||||
|
||||
- 每个关系两个令牌, 各自独立签发 (32 位 hex)。令牌比较用 `timingSafeEqual`
|
||||
(`relations.ts` 的 `safeEqual`)。
|
||||
- **吊销是单边的**: 我吊销关系 → 我的 `myToken` 被换掉 + `state='revoked'`,
|
||||
对方手里的旧令牌立刻访问不了我; 但我持有的对方的 `peerToken` 仍然可用,
|
||||
要等**对方也吊销**才失效。
|
||||
- **过渡期**: 老版本对端没有"关系"概念, 但本机缓存的旧 `drive.token` 仍然有效
|
||||
(`file-server.ts` 的 `authDrive` 里先试关系令牌, 失败再退回旧的全局 token)。
|
||||
这样可以渐进式升级, 不用一刀切。
|
||||
|
||||
### 3.5 授权粒度
|
||||
|
||||
关系上只有 `drive` 和 `sync` 两项, 而且**双方各自独立**:
|
||||
|
||||
- `myGrants` = 我开放给对方的能力
|
||||
- `peerGrants` = 对方开放给我的能力
|
||||
|
||||
网盘浏览和文件同步是**两套独立授权**:
|
||||
|
||||
- 只给 `sync` → 对方**完全不能**浏览我的网盘 (`scopeRoot='none'`, 直接 403 "未授权浏览网盘"),
|
||||
但可以通过 `/sync/*` 访问该关系专属的同步目录。
|
||||
- 只给 `drive` → 对方能浏览我的整个网盘共享目录, 但访问不了同步区。
|
||||
|
||||
授权可以在同步面板里改 (`relation:setGrants`), 改完立即生效。
|
||||
|
||||
---
|
||||
|
||||
## 4. 同步区: 独立于网盘的第二套空间
|
||||
|
||||
### 4.1 为什么改成独立同步区
|
||||
|
||||
早期设计稿是"在**对方的网盘**里开一个子目录当同步区"。实现时改成了**独立的同步区**, 原因:
|
||||
|
||||
1. **用户的同步目录不必待在网盘里**。网盘共享目录是"我愿意公开给人看的东西",
|
||||
而同步目录是"我自己的工作目录" (比如 `~/文档/工作`)。把工作目录塞进公开共享区,
|
||||
既不合逻辑也很别扭。
|
||||
2. **浏览和同步是两回事, 授权应该拆开**。让对方浏览我的网盘 ≠ 让对方把我的目录同步走。
|
||||
合成一个能力就没法表达"只同步不浏览"。
|
||||
3. **路径边界更清楚**。网盘的边界是"网盘根", 同步区的边界是"这个关系专属的同步目录",
|
||||
两个边界互不重叠, 穿越防护也只需要各自校验一次。
|
||||
|
||||
### 4.2 两套空间的对照
|
||||
|
||||
| | 网盘 | 同步区 |
|
||||
|---|---|---|
|
||||
| 路由前缀 | `/drive/*` | `/sync/*` |
|
||||
| 需要的授权 | `drive` | `sync` |
|
||||
| 路径根 | 网盘共享目录 (全局一个) | **该关系专属的同步目录** (`syncLocalDir`, 位置任意) |
|
||||
| 谁能访问 | 拿到 `drive` 的关系对端 | 拿到 `sync` 的关系对端 |
|
||||
| 用途 | 浏览 / 挑选下载 | 双向同步 |
|
||||
|
||||
同步区的服务端路径解析 (`file-server.ts` 的 `resolveSyncPath`) 严格限制在
|
||||
`getSyncScope(relationId)` 返回的目录内 —— 这个目录来自关系记录的 `syncLocalDir`,
|
||||
所以**同步目录可以是磁盘上任意一个文件夹**。
|
||||
|
||||
### 4.3 目录约定
|
||||
关系记录里保留两个目录字段 (对称, 不需要协商):
|
||||
|
||||
| 字段 | 含义 (我这侧视角) |
|
||||
|---|---|
|
||||
| `syncLocalDir` | **我的**同步目录 (绝对路径, 我这边的) |
|
||||
| `syncRemotePath` | 我在**对方**网盘里用的子目录 (提议值, 默认 `同步/<我的名字>`) |
|
||||
| `peerSyncRoot` | 对方在我网盘里用的子目录 (对方提议的) |
|
||||
|
||||
发起方提议自己的 `syncRemotePath`, 接受方可以改成自己的并给出自己的,
|
||||
于是双方各自算出:
|
||||
|
||||
```
|
||||
发起方: syncRemotePath = 自己提的 peerSyncRoot = 对方给的
|
||||
接受方: syncRemotePath = 自己给的 peerSyncRoot = 对方提的
|
||||
```
|
||||
|
||||
> 注意: 独立同步区方案落地后, **文件实际落在对方的 `syncLocalDir` 里**
|
||||
> (`/sync/file` 的路径是相对该目录的), `syncRemotePath` / `peerSyncRoot`
|
||||
> 目前只作为**双方协商与展示用的约定**保留在关系记录和帧里, 不再决定数据落点。
|
||||
> 见 §12 的"已知不一致"。
|
||||
|
||||
#### 4.3.1 一个顶层目录可以管多个聊天备份 (按关系分子目录)
|
||||
|
||||
顶层目录**可以**同时和多个设备同步 —— 每个关系在自己的**子目录**里, 物理上隔开:
|
||||
|
||||
```
|
||||
~/备份/MECHREVO/ <- 和 MECHREVO 同步的数据
|
||||
~/备份/lenovo/ <- 和 lenovo 同步的数据
|
||||
```
|
||||
|
||||
这样"统一目录多个聊天备份"就成立了, 而且两条数据不会互相覆盖:
|
||||
|
||||
- 同步区接口的路径**相对这个子目录**, 不是相对顶层目录。
|
||||
- 关系记录里的 `syncSubdir` 就是这个子目录名 (默认对方设备名, 重名加 id 前缀)。
|
||||
- 实际同步根 = `join(syncLocalDir, syncSubdir)`, 由 `RelationService.localRootOf()` 统一算,
|
||||
服务端和客户端都用它 —— **两边必须算成同一个**, 否则路径会错位。
|
||||
|
||||
> 踩过的坑: 登记本地文件时如果用顶层目录去扫, relPath 会带上子目录名 (比如 `MECHREVO/a.txt`),
|
||||
> 污染关系表, 对方会看到一个"凭空多出来的路径"。**扫描一律要用实际同步根**。
|
||||
|
||||
### 4.4 服务端接口
|
||||
|
||||
同步区走的是已有的文件服务器 (HTTP, 默认 47901), 请求都要带
|
||||
`token` + `peerId` + `relationId`:
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /sync/scan?token=&peerId=&relationId=&hash=1` | 递归列出该关系的同步目录 (相对路径 / 大小 / mtime / 可选 sha256) |
|
||||
| `GET /sync/file?token=&peerId=&relationId=&path=` | 下载一个文件 (`path` 相对同步目录根) |
|
||||
| `POST /sync/file?token=&peerId=&relationId=` | 上传。目标相对路径在 header `x-file-rel-path`; 带 `x-file-action: delete` 时执行**删除** |
|
||||
| `GET /sync/manifest?token=&peerId=&relationId=` | 拉取对方的文件关系表 (含墓碑和序号) |
|
||||
| `POST /sync/manifest?token=&peerId=&relationId=` | 把我的文件关系表推给对方 (**删除就是这样传播的**) |
|
||||
|
||||
鉴权: `authSync()` = 有效关系 + 关系里有 `sync` 能力, 否则 403。
|
||||
路径: `resolveSyncPath()` 把相对路径 resolve 到同步目录根, 越界 (含 `..`) 直接 400。
|
||||
|
||||
### 4.5 老对端降级
|
||||
|
||||
- 对方是旧版本 (没有 `/sync/*`): 扫描失败, 界面提示"对方需要升级到带同步盘的版本"。
|
||||
- 对方能扫但没有 `/sync/manifest` (404): 远端视图退化成"只有实际扫描结果",
|
||||
没有墓碑也没有序号 —— 此时删除无法可靠传播, 首次同步也没有基线, 退回 mtime 兜底判定。
|
||||
- 网盘浏览另有老通道: 老版本对端仍可用本机缓存的旧 `driveToken` 访问 `/drive/*`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件关系表 (manifest): 三方对比 + 墓碑 + seq
|
||||
|
||||
这是整个同步功能的**地基**。没有它就没法正确处理删除。
|
||||
|
||||
### 5.1 存储
|
||||
|
||||
`src/main/sync-manifest.ts`, 按 `relationId` 分开存:
|
||||
|
||||
```ts
|
||||
interface Manifest {
|
||||
relationId: string
|
||||
clock: number // 我的 Lamport 时钟
|
||||
remoteSeq: number // 水位线: 我已处理到对方的哪个序号
|
||||
entries: Record<string, ManifestEntry> // key = relPath
|
||||
}
|
||||
```
|
||||
|
||||
**按 relationId 分开是关键**: 一个本地目录同时跟多个关系同步时, 各自的基线 / 墓碑 /
|
||||
序号互不干扰, 不会串。
|
||||
|
||||
### 5.2 单条记录
|
||||
|
||||
```ts
|
||||
interface ManifestEntry {
|
||||
fileId: string // 稳定 id: 首次见到该路径时生成, 重命名后不变
|
||||
relPath: string // 相对同步目录的路径 (同时是 entries 的 key)
|
||||
|
||||
baseHash: string // 基线: 上次双方内容一致时的 hash (空 = 从来没同步过)
|
||||
baseSize: number
|
||||
baseMtime: number
|
||||
|
||||
myHash: string // 我这边最后一次观察到的内容
|
||||
mySize: number
|
||||
myMtime: number
|
||||
myState: 'present' | 'deleted' // 我这边的当前状态
|
||||
mySeq: number // 我这边最后一次变更的序号
|
||||
|
||||
peerHash: string // 对方最后一次广播过来的内容
|
||||
peerSize: number
|
||||
peerMtime: number
|
||||
peerState: 'present' | 'deleted' // 对方最后一次广播的状态
|
||||
peerSeq: number // 对方最后一次变更的序号
|
||||
|
||||
deletedAt: number // 墓碑时间 (任一侧删除的时间), 0 = 没有墓碑
|
||||
}
|
||||
```
|
||||
|
||||
一句话概括: **基线 (base) + 我 (my) + 对方 (peer)**, 三份信息各自带 hash 和序号,
|
||||
判定时三方对比。
|
||||
|
||||
### 5.3 为什么要有 seq (变更序号)
|
||||
|
||||
- **内容 hash 只能回答"一样不一样"**, 回答不了"谁更晚"。
|
||||
- 判定删除方向恰恰需要"谁更晚": 对方 seq 5 的删除 vs 我 seq 3 的修改 → 删除更晚, 删除赢。
|
||||
|
||||
实现用 **Lamport 时钟**:
|
||||
|
||||
```
|
||||
本地每变一次 (增/改/删/移动) → clock++
|
||||
收到对方的 manifest → clock = max(clock, 对方 seq)
|
||||
remoteSeq = max(remoteSeq, 对方 seq)
|
||||
```
|
||||
|
||||
于是本地时钟永远 ≥ 见过的对方序号, 两边不会因为时钟漂移而"分叉"。
|
||||
**删除也是一个 seq 事件**, 所以"删除"和"修改"可以用同一套规则比新旧。
|
||||
|
||||
水位线 (`remoteSeq` + 每条的 `peerSeq`) 同时防重复和乱序: 重复同步同一批事件不会出错。
|
||||
|
||||
#### 5.3.1 顺序有讲究: 先收消息, 再产生事件
|
||||
|
||||
Lamport 时钟要求"收到消息之后才能产生事件", 否则跨机器的 seq 不可比。代码里体现为**固定的三步**:
|
||||
|
||||
```
|
||||
1) advanceClock(manifest, 对方的条目) // 先把本地时钟推到 >= 对方当前 seq
|
||||
2) reconcileLocal(manifest, 本地文件) // 再产生本地事件 (增/改/删), 分配的 mySeq 一定更大
|
||||
3) mergePeer(manifest, 对方的条目) // 最后合并对方字段
|
||||
```
|
||||
|
||||
两个顺序陷阱 (都踩过):
|
||||
|
||||
- **不能把 `mergePeer` 提到 `reconcileLocal` 前面**。`mergePeer` 会为"对方有、我没见过"的路径建条目,
|
||||
新条目的 `myState` 只能是 `deleted` (意思是"我这边没有这个文件")。如果它先跑, `reconcileLocal`
|
||||
就看不到"本地本来有这个文件"的事实 → **本地的删除事件被吞掉** → 墓碑建不出来 → 删除传播不出去。
|
||||
- **不能把 `reconcileLocal` 提到 `advanceClock` 前面**。那样本地事件分到的 seq 可能小于对方已知的 seq,
|
||||
比较时"对方刚下载过这个文件"的 seq 反而比"我刚删掉它"的大 → **我的删除被误判成"对方复活了"** → 删除被撤销。
|
||||
|
||||
#### 5.3.2 本地文件必须先登记进关系表
|
||||
|
||||
关系表不能只记录"同步过程中见过的文件"。如果对方知道某个文件、我本地也有, 但我的关系表里还没有它,
|
||||
那么我本地把它删掉时, 关系表看不出这是"删除"还是"从来没过" → 墓碑建不出来 → **删除传播不出去**。
|
||||
|
||||
所以对方把关系表推过来时 (`applyPeerManifest`), 要先用 `reconcileLocal` 把本地实际有的文件登记进去,
|
||||
再合并对方字段。
|
||||
|
||||
### 5.4 墓碑 (tombstone): 删除为什么不直接抹掉
|
||||
|
||||
删除时**不删记录**, 只把状态标成 `deleted` 并记 `deletedAt`。直接抹掉会有两个致命问题:
|
||||
|
||||
**问题 A: 删除传不出去**
|
||||
|
||||
B 离线时 A 删了 `x.txt`。如果 A 直接抹掉记录, B 回来同步时:
|
||||
|
||||
- B 的 manifest 里有 `x.txt` (B 认为它存在)
|
||||
- A 的 manifest 里没有 → A 理解成"这是 B 新增的" → **把已删除的文件重新下载回来**
|
||||
- 删除被撤销了, 用户永远删不掉一个文件
|
||||
|
||||
有墓碑时: A 保留 `x.txt` 的记录并标 `state='deleted'`, B 同步拉到墓碑 → 删本地文件 →
|
||||
自己也记墓碑 → 删除成功传播。
|
||||
|
||||
**问题 B: "删除"会被"复活"**
|
||||
|
||||
A 删了 `x.txt` (有墓碑)。B 离线, 不知道这件事。B 的本地因为某种原因又出现了 `x.txt`
|
||||
(从备份恢复 / 手动放回), B 上传它 → A 收到:
|
||||
|
||||
- 如果 A 只看"对方有这个文件, 我没有" → A 当成新增接受 → **删除被静默撤销**
|
||||
- 有墓碑 + seq 比较: A 的墓碑 seq 更高 → A 拒绝接受, 保持删除状态 → 正确
|
||||
|
||||
### 5.5 墓碑优先规则
|
||||
|
||||
我这边有墓碑、对方声称文件还在时, **比 seq**:
|
||||
|
||||
| 情况 | 判定 | 动作 |
|
||||
|---|---|---|
|
||||
| 对方 seq > 我的墓碑 seq | 对方真的恢复了文件 | `download` (接受) |
|
||||
| 对方 seq ≤ 我的墓碑 seq, 且对方内容也变了 | 双方都动过 | `conflict` (不静默丢数据) |
|
||||
| 对方 seq ≤ 我的墓碑 seq, 对方内容没变 | 删除更晚, 删除赢 | `delete-remote` (删掉对方那份) |
|
||||
|
||||
### 5.6 墓碑保留多久
|
||||
|
||||
- 大品牌: Google Drive 回收站 30 天, OneDrive 30 天, Dropbox 30 天, Syncthing tombstone 30 天。
|
||||
- 本项目取 **90 天** (更保守, 覆盖长时间离线的设备)。常量 `TOMBSTONE_TTL_MS`。
|
||||
- 清理条件 (`pruneTombstones`): **超过 90 天** **且** **对方已经越过这个序号**
|
||||
(对方也知道这件事了, `peerState==='deleted'` 且 `peerMaxSeq > mySeq`)。
|
||||
- 对方一直没上线的, 墓碑一直留着 —— 这正是我们要的。
|
||||
|
||||
### 5.7 三方对比 (不是两方对比)
|
||||
|
||||
普通同步只比"本地 vs 远端"是不够的 —— 两边都改过的时候分不清谁赢。
|
||||
**三方对比** = 基线 (上次同步时两边一致的状态) + 本地现状 + 远端现状。
|
||||
|
||||
| 本地 | 远端 | 基线 | 判定 | 动作 |
|
||||
|---|---|---|---|---|
|
||||
| 有 | 有 | 一致 | 相同 | 无 |
|
||||
| 有 | 有 | 只有本地改 | 本地新 | upload |
|
||||
| 有 | 有 | 只有远端改 | 远端新 | download |
|
||||
| 有 | 有 | 都改过 | 冲突 | conflict |
|
||||
| 无 | 有 | 有, 且我没改 | 我删了 | delete-remote (传播删除) |
|
||||
| 无 | 有 | 有, 且对方改过 | 冲突 | conflict |
|
||||
| 无 | 有 | 无 | 对方新增 | download |
|
||||
| 有 | 无 | 有, 且我没改 | 对方删了 | delete-local (传播删除) |
|
||||
| 有 | 无 | 有, 且我改过 | 冲突 | conflict |
|
||||
| 有 | 无 | 无 | 本地新增 | upload |
|
||||
| 无 | 无 | 有 | 两边都删了 | 无 (墓碑到期可清理) |
|
||||
|
||||
**没有基线时 (首次同步)**: 退回 mtime 兜底 —— 差得远 (≥2s) 当单向, 接近当冲突。
|
||||
方向是"宁可多弹窗", 静默覆盖丢数据更糟。
|
||||
|
||||
### 5.8 冲突怎么办
|
||||
|
||||
两边都改过且内容不同:
|
||||
|
||||
- **绝不静默丢数据**。
|
||||
- **手动同步时**: 弹窗对比 (大小 / 修改时间 / hash), 用户选方向 (本地覆盖对方 / 对方覆盖本地 / 跳过)。
|
||||
这就是同步面板里的"冲突对比弹窗"。
|
||||
- **自动同步时**: 保留两份 —— 本地那份留 `x.txt`, 远端那份存成 `x (冲突 2026-10-20 17-30).txt`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 同步一轮的完整流程
|
||||
|
||||
```
|
||||
[本地] 扫描磁盘 → 实际文件集 (scanLocalDir)
|
||||
↓
|
||||
[本地] 对齐关系表 (reconcileLocal) —— 只改关系表, 不动磁盘:
|
||||
manifest 有 + 磁盘没了 → 删除事件 (seq++, 记墓碑) ← 关键: 区分"被删"和"从来没有"
|
||||
磁盘有 + manifest 没有 → 新增事件 (seq++)
|
||||
磁盘有 + hash 变了 → 修改事件 (seq++)
|
||||
manifest 是墓碑 + 磁盘有了 → 复活事件 (seq++) ← 用户手动放回来了
|
||||
消失的路径 + hash 相同的新路径 → 重命名 (保留 fileId, 不产生假删除)
|
||||
↓
|
||||
[远端] 组装对方视图 (buildPeerView):
|
||||
先取对方 manifest (带墓碑/序号) → 再用 /sync/scan 的实际扫描覆盖内容信息
|
||||
(实际存在但对方说删了 → 按"存在"处理, 序号沿用对方的, 避免误判)
|
||||
↓
|
||||
[合并] mergePeer: 只接受 seq > peerSeq 的事件; clock/remoteSeq 推到 max
|
||||
↓
|
||||
[判定] planSync: 三方对比 → 一份可执行的计划 (§5.7 的表)
|
||||
↓
|
||||
[执行] 用户勾选后 applySyncItems:
|
||||
upload → POST /sync/file (x-file-rel-path)
|
||||
download → GET /sync/file?path=
|
||||
delete-local → 删本地文件 + 记墓碑
|
||||
delete-remote → POST /sync/file (x-file-action: delete)
|
||||
成功后更新基线 (baseHash = 同步后的内容 hash) —— 下次就能精确判断"谁改了"
|
||||
↓
|
||||
[清理] pruneTombstones (90 天 且 对方已越过该序号)
|
||||
↓
|
||||
[推送] POST /sync/manifest 把我的关系表推给对方
|
||||
→ 对方那边 applyPeerManifest() 应用 (删除就是这样跨机器传播的)
|
||||
```
|
||||
|
||||
**幂等性**: 重复同步同一批事件不会出错 (水位线过滤)。
|
||||
**乱序容忍**: seq 单调, 小的先到先忽略。
|
||||
|
||||
---
|
||||
|
||||
## 7. 删除传播的落点
|
||||
|
||||
`RelationService.applyPeerManifest()` (`src/main/relation-service.ts`) 是对方推来关系表时的处理点:
|
||||
|
||||
```
|
||||
对方说"这个文件我删了" (peerState='deleted'):
|
||||
我这边没有 → 什么都不做 (幂等)
|
||||
我这边有 + 从没同步过 → 不动 (那本来就是两个各自独立的文件, 不是同一个)
|
||||
我这边有 + 同步过 + 我改过 → 冲突, 上报给界面, 不静默丢数据
|
||||
我这边有 + 同步过 + 我没改过 → 删本地文件 + 记墓碑
|
||||
```
|
||||
|
||||
删除动作会写审计 (`sync.delete.propagated`), 并给渲染层发 `sync:applied` 事件。
|
||||
|
||||
---
|
||||
|
||||
## 8. 自动同步
|
||||
|
||||
关系上的 `syncEnabled` 开关就是入口 (同步面板里一个开关, 勾上即生效)。
|
||||
实现是 `src/main/auto-sync.ts` 的 `AutoSync` 类, 在 `ipc.ts` 的 `bindNetworkContext()`
|
||||
里创建并 `start()`。
|
||||
|
||||
### 8.1 触发
|
||||
|
||||
| 触发 | 行为 |
|
||||
|---|---|
|
||||
| 定时轮询 | 默认 60s 一轮, 只跑 `state='active'` + `syncEnabled` + 配好了同步目录的关系 |
|
||||
| 对方上线 | WS 状态转在线时立刻 `trigger()` 试一次, 不用等下一个轮询周期 |
|
||||
| 用户刚打开开关 | `relation:setSync` 里立刻 `trigger(relationId)` 跑一次 |
|
||||
| 失败重试 | 指数退避 1min → 2min → 4min … 上限 5min |
|
||||
|
||||
### 8.2 一轮自动同步做什么
|
||||
|
||||
```
|
||||
scanSync() → 拿到变更列表
|
||||
├─ conflict → **跳过**, 只记数并提示
|
||||
├─ upload / download / delete-local / delete-remote → 直接执行
|
||||
└─ same → 跳过
|
||||
执行完 → 写回关系的 lastSyncAt / lastError, 并给界面发 sync:auto 事件
|
||||
```
|
||||
|
||||
- **冲突永远留给用户手动选方向** (手动同步时弹窗对比)。自动同步不替用户决定丢哪一份,
|
||||
绝不静默覆盖任何一方。
|
||||
- 同一关系有防重入 (正在跑就直接跳过这一轮)。
|
||||
|
||||
### 8.3 不做的
|
||||
|
||||
- 不做多目录对多目录的一对多同步。
|
||||
- **没有**本地目录变化的事件监听 (`fs.watch`): 目前只有 60s 轮询 + 对方上线触发。
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据落地
|
||||
|
||||
关系和关系表都存 **JSON 文件 (原子写)**, 不是数据库:
|
||||
|
||||
| 文件 | 位置 | 内容 |
|
||||
|---|---|---|
|
||||
| `relations.json` | `paths.appData/relations.json` | 全部关系记录 |
|
||||
| `sync-manifest.json` | `paths.appData/sync-manifest.json` | 全部关系的文件关系表 (key = relationId) |
|
||||
| `drive.token` | `paths.appData/drive.token` | 旧的全局网盘 token (只作老对端兼容) |
|
||||
|
||||
`paths.appData` = `app.getPath('userData')/data`。
|
||||
|
||||
**为什么用 JSON 文件而不是 DB**:
|
||||
|
||||
1. **关系是"少量、需要原子整体替换"的状态**, 不是需要 SQL 聚合 / 索引 / 事务的数据。
|
||||
几十条关系, 一次性读写完全够用。
|
||||
2. **原子写很简单**: 先写 `xxx.json.tmp-<pid>` 再 `rename` 覆盖, 崩溃不会写坏文件。
|
||||
文件损坏时还会自动备份成 `xxx.json.broken-<时间戳>` 并从空状态启动 ——
|
||||
应用不能因为一个坏文件起不来。
|
||||
3. **不用做 schema 迁移**。关系结构以后还会演进, JSON 加个字段就行;
|
||||
DB 一旦改了表结构, 老用户升级就得写迁移脚本。
|
||||
4. **单测可以直接指定临时文件路径**, 不碰用户真实数据。
|
||||
5. 有先例: 远程操作白名单 `remoteAllowPeers` 也存在 `settings.json` 里, 不是 DB。
|
||||
|
||||
`devices` 表仍然保留 (设备缓存, 含历史 `drive_token`), 但新关系不再用它。
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全要点
|
||||
|
||||
| 要点 | 做法 |
|
||||
|---|---|
|
||||
| 网盘 / 同步区鉴权 | `token` + `peerId` → 查关系 → 必须有 `drive` / `sync` 授权 → 否则 403。令牌用 `timingSafeEqual` 比较 |
|
||||
| 网盘 token 不再广播 | 发现包不带 `driveToken`; 同网段任何进程拿不到网盘凭据 |
|
||||
| 目录穿越 | 上传 / 下载 / 删除的路径都 resolve 后校验前缀, 必须严格落在**各自的根目录**内 (网盘根 / 该关系的同步目录), `..` 一律拒绝 |
|
||||
| 令牌不进渲染层 | IPC 返回关系时用 `publicRelation()` 剥掉 `myToken` / `peerToken`, 界面拿不到凭据 |
|
||||
| 吊销立即生效 | 吊销时换掉 `myToken` 并置 `state='revoked'`, 对方手里旧令牌立刻失效; 同时清掉该关系的关系表 |
|
||||
| 墓碑不泄密 | 墓碑只记路径和 hash, 不含内容 |
|
||||
| 审计 | 建关系 / 接受 / 拒绝 / 吊销 (`relation.*`)、手动同步 (`sync.apply`)、删除传播 (`sync.delete.propagated`) 都写 `audit_log` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前进度
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| P0 | 关系层: 关系模型 + per-peer 令牌 + 网盘按关系授权 (`relations.ts`, `relation-service.ts`) | ✅ 已完成 |
|
||||
| P0 | 同步层: manifest + 墓碑 + Lamport seq + 三方对比 (`sync-manifest.ts`, `sync.ts`) | ✅ 已完成 |
|
||||
| P0 | 同步区接口: `/sync/scan` `/sync/file` `/sync/manifest` + 路径防护 (`file-server.ts`) | ✅ 已完成 |
|
||||
| P1 | 弹窗 UI: `ApprovalDialog.vue` 的 relation 分支 + `SyncPanel.vue` (发起/接受/扫描/同步/冲突弹窗) | ✅ 已完成 |
|
||||
| P1 | 自动同步: `AutoSync` (60s 轮询 + 上线触发 + 冲突跳过) (`auto-sync.ts`) | ✅ 已完成 |
|
||||
| P1 | 自测用例 (关系建立、删除传播、墓碑、冲突) | ⬜ 待做 |
|
||||
| P2 | 关系管理页 (查看 / 吊销 / 改权限) —— 目前只在同步面板里 | ⬜ 待做 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 已知不一致 (实现与设计有出入, 未改代码)
|
||||
|
||||
写这份文档时对出来的几处出入, **只记录, 没有改代码**:
|
||||
|
||||
1. `scripts/selftest/main.ts` 里新加的 Y1–Y10 用例 import 了 `compareSync`,
|
||||
但 `src/main/sync.ts` **没有这个导出** (两方对比已被 `sync-manifest.ts` 的
|
||||
`planSync` 三方对比取代)。跑 `npm run selftest -- sync` 会直接失败。
|
||||
用例本身也还是按旧的"网盘子目录 + driveToken"写的, 需要按新方案重写。
|
||||
2. `relations.ts` 的 `Relation` 上有 `localSeq` / `remoteSeq` 两个序号字段,
|
||||
但**实际没有任何地方更新它们** —— 真正的时钟在 manifest 的 `clock` / `remoteSeq`
|
||||
里 (`lastSyncAt` / `lastError` 由自动同步在写)。这两个字段目前是死字段。
|
||||
3. `syncRemotePath` / `peerSyncRoot` 这套"网盘子目录"约定还留在关系记录和帧里,
|
||||
但独立同步区落地后 `sync.ts` 的 `remoteTarget()` 直接返回 `relPath`,
|
||||
文件实际落在对方的 `syncLocalDir` 里 —— 这两个字段目前只是协商/展示用的残留。
|
||||
4. 接受方在弹窗里不填同步目录时, `syncLocalDir` 会留空, 之后对方的 `/sync/scan`
|
||||
会返回 400 "同步目录未设置" —— 需要在同步面板里补填才能同步。
|
||||
5. `src/main/sync-state.ts` (`FileSyncStateStore`) 是一套**旧的**基线存储
|
||||
(`localHash` / `remoteHash`), 新的 manifest 已经自带基线, 这个文件看起来没被接进新链路。
|
||||
6. **自动同步跑的那一轮不写审计**。`sync.apply` 是在 `ipc.ts` 的 `sync:apply` IPC 里记的,
|
||||
而 `auto-sync.ts` 直接调 `applySyncItems()`, 绕过了那层 —— 自动同步的每一次自动上传 /
|
||||
下载 / 删除传播都没有审计记录 (删除传播那条 `sync.delete.propagated` 是对端记的)。
|
||||
|
||||
---
|
||||
|
||||
## 13. 相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `src/main/relations.ts` | 关系模型 + `RelationStore` (JSON 原子写) + 令牌生成 / 比较 + 网盘授权判定 |
|
||||
| `src/main/relation-service.ts` | 建 / 接受 / 拒绝 / 吊销关系, `applyPeerManifest()` (删除传播落点) |
|
||||
| `src/main/sync-manifest.ts` | 文件关系表: `reconcileLocal` / `mergePeer` / `planSync` / `updateBaseline` / `pruneTombstones` |
|
||||
| `src/main/sync.ts` | 同步引擎: 本地扫描、远端视图组装、上传 / 下载 / 删除、manifest 推送 |
|
||||
| `src/main/auto-sync.ts` | 自动同步: 轮询 / 上线触发 / 退避重试 / 冲突跳过 |
|
||||
| `src/main/file-server.ts` | `/drive/*` 与 `/sync/*` 路由 + 鉴权 + 目录穿越防护 |
|
||||
| `src/main/remote/approval.ts` | 授权闸门队列 (关系弹窗复用它, `kind='relation'`) |
|
||||
| `src/main/ipc.ts` | 关系 / 同步的 IPC 通道, `publicRelation()` (剥令牌) |
|
||||
| `src/renderer/src/components/remote/SyncPanel.vue` | 同步面板: 发起关系 / 扫描变更 / 勾选同步 / 冲突弹窗 |
|
||||
| `src/renderer/src/components/remote/ApprovalDialog.vue` | 授权弹窗 (含 relation 分支) |
|
||||
@@ -1,5 +1,15 @@
|
||||
# 远程终端、端口转发、USB 透传 规划文档
|
||||
|
||||
> **⚠️ 现状 (v0.2.0)**: 本文是当初的**设计文档**, 其中"USB 透传"那部分**已经下线** ——
|
||||
> node-usb 的 control/bulk 字节桥和 USB/IP 真透传都删了, `node-usb` / `node-hid` 两个原生
|
||||
> 依赖也摘掉了。现在只保留**串口**:
|
||||
> - 串口映射 (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com) —— 实现见
|
||||
> `src/main/remote/serial.ts` + `src/main/remote/vcom.ts`
|
||||
> - 串口助手 (应用自己收发字节) —— UI 见 `src/renderer/src/components/remote/SerialPanel.vue`
|
||||
>
|
||||
> **线上协议格式没变** (WS 帧类型仍叫 `'usb'`, 版本仍是 1), 所以新旧版本之间串口照样能用。
|
||||
> 下文凡是讲 USB 字节桥 / USB/IP 的部分, 都只当历史文档看。
|
||||
|
||||
> 目标:在 LocalNetMsg 中实现三类“远程使用对方设备”的能力——**对方机器的本地终端(shell)**、**TCP 端口转发**、**USB 设备透传**。本文先定整体架构、协议、库选型,再分阶段落地。
|
||||
>
|
||||
> 适配范围:Windows / macOS / Linux 三端。被控端的 shell 按平台决定:Windows → PowerShell(默认 `pwsh.exe`,回退 `powershell.exe`),macOS → zsh(回退 bash),Linux → bash(按 `SHELL` 环境变量)。
|
||||
@@ -550,7 +560,7 @@ CREATE INDEX idx_audit_ts ON audit_log(ts DESC);
|
||||
|---|---|
|
||||
| #1 `chat-client` 必带 `'error'` 监听 | 新增帧引入不影响;已有 on('error') 已稳 |
|
||||
| #2 drag-drop file path | 不涉及 |
|
||||
| #3 native rebuild | `@lydell/node-pty`、`usb`、`node-hid`、`serialport` 都是 native,必须走现有 `postinstall` 的 `electron-builder install-app-deps`;CI 加 `npm rebuild --runtime=electron` 兜底 |
|
||||
| #3 native rebuild | `@lydell/node-pty`、`usb`、`node-hid`、`serialport` 都是 N-API 预编译,**不需要**按 Electron 重编;只有 `better-sqlite3`(非 N-API)要编,由 `postinstall` 的 `scripts/postinstall.js` 单独重建并在 Electron 下自校验。**别用 `electron-builder install-app-deps`**(会把 N-API 模块也拖进源码编译,node-hid 编译失败,根因见 AGENTS.md 坑 #3)。CI 兜底直接 `npm ci`(会触发 postinstall → `scripts/postinstall.js`),或手动 `node scripts/postinstall.js` |
|
||||
| #4 dist-Electron 路径 | 不动 |
|
||||
| #5 nsis oneClick false | 不动 |
|
||||
| #6 dragenter/dragleave 深度计数 | 不涉及 |
|
||||
|
||||
@@ -35,11 +35,18 @@ LocalNetMsg 是跨平台应用 (Electron 32 / Node 20 / Chromium 128)。这份
|
||||
| `node-hid` | `prebuilds/HID-win32-x64/node-napi-v4.node` | N-API | 否 |
|
||||
| `@serialport/bindings-cpp` | `prebuilds/win32-x64/*.node` | N-API | 否 |
|
||||
| `usb` (node-usb 3) | `@node-usb/usb-win32-x64-msvc` | napi-rs | 否 |
|
||||
| `better-sqlite3` | 源码编译 | **非 N-API** | 是,靠 `postinstall` 的 `electron-builder install-app-deps` |
|
||||
| `better-sqlite3` | 源码编译 | **非 N-API** | 是,靠 `postinstall` 的 `scripts/postinstall.js` |
|
||||
|
||||
> **坑**:`better-sqlite3` 是唯一需要编译的。`npmRebuild: false` 只影响打包期,
|
||||
> `postinstall` 仍然会重编它。**如果用 `npm ci --ignore-scripts`,应用会启动即崩**
|
||||
> (sqlite 打开失败)。
|
||||
>
|
||||
> `postinstall` **只重建 better-sqlite3 这一个包**,不是 `electron-builder install-app-deps`。
|
||||
> 后者会重建所有原生模块,并给子进程传 `npm_config_build_from_source=false`;
|
||||
> npm 11 对未知的 `npm_config_*` 保留原始字符串,子进程里它是字符串 `"false"`(JS 真值),
|
||||
> `pkg-prebuilds-verify` 直接 exit 1 强制源码编译,node-hid 编译失败把整个
|
||||
> `npm install` 拖垮 —— 而它自带 N-API 预编译产物,本来根本不用编译。
|
||||
> 完整根因见 `AGENTS.md` 坑 #3。
|
||||
|
||||
node-pty 1.2 换成了"主包 + 平台包"拆包架构:主包只是 loader,
|
||||
真正的二进制在 `@lydell/node-pty-<platform>-<arch>` 里。**换平台时别忘了
|
||||
|
||||
+14
-8
@@ -6,6 +6,13 @@
|
||||
"homepage": "https://github.com/localnetmsg/local-net-msg",
|
||||
"author": "LocalNetMsg <dev@localnetmsg.local>",
|
||||
"main": "out/main/index.js",
|
||||
"allowScripts": {
|
||||
"better-sqlite3": true,
|
||||
"electron": true,
|
||||
"esbuild": true,
|
||||
"@serialport/bindings-cpp": true,
|
||||
"vue-demi": true
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
@@ -14,14 +21,18 @@
|
||||
"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: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:sudo-prompt --external:ws",
|
||||
"selftest": "npm run selftest:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/selftest.cjs",
|
||||
"selftest:sync:build": "esbuild scripts/selftest/sync-e2e.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/sync-e2e.cjs --alias:electron=./scripts/selftest/electron-stub.cjs --external:better-sqlite3 --external:serialport --external:sudo-prompt --external:ws",
|
||||
"selftest:sync": "npm run selftest:sync:build && cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/.out/sync-e2e.cjs",
|
||||
"selftest:package": "cross-env ELECTRON_RUN_AS_NODE=1 electron scripts/selftest/packaging-check.cjs",
|
||||
"selftest:image:build": "esbuild src/main/image.ts --bundle --platform=node --format=cjs --target=node20 --outfile=scripts/selftest/.out/image-native.cjs --external:electron",
|
||||
"selftest:image": "npm run selftest:image:build && electron scripts/selftest/native-image-check.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",
|
||||
"package:dir": "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 --dir",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
@@ -34,11 +45,9 @@
|
||||
"element-plus": "^2.8.4",
|
||||
"highlight.js": "^11.10.0",
|
||||
"markdown-it": "^14.3.0",
|
||||
"node-hid": "^3.3.0",
|
||||
"pinia": "^2.2.4",
|
||||
"serialport": "^13.0.0",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"usb": "^3.0.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,10 +82,7 @@
|
||||
"asarUnpack": [
|
||||
"**/node_modules/@lydell/node-pty/**/*",
|
||||
"**/node_modules/@lydell/node-pty-*/**/*",
|
||||
"**/node_modules/@node-usb/**/*",
|
||||
"**/node_modules/usb/prebuilds/**/*",
|
||||
"**/node_modules/@serialport/**/prebuilds/**/*",
|
||||
"**/node_modules/node-hid/prebuilds/**/*"
|
||||
"**/node_modules/@serialport/**/prebuilds/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* postinstall —— 只重建"必须按 Electron ABI 重编"的原生模块, 其余一概不碰。
|
||||
*
|
||||
* 为什么不用 `electron-builder install-app-deps`:
|
||||
* 它会重建**所有**原生模块, 并给子进程带上 `npm_config_build_from_source=false`。
|
||||
* npm 11 对未知的 npm_config_* 保留原始字符串, 于是子进程里它是字符串 "false" ——
|
||||
* 在 JS 里是真值 —— pkg-prebuilds-verify 直接 exit 1
|
||||
* (node_modules/pkg-prebuilds/bin/verify.mjs:14), 强制走源码编译。
|
||||
* node-hid 于是去编译它自带的 node-addon-api 3.2.1, 和现代 Node 头文件不兼容, 编译失败,
|
||||
* 整个 `npm install` 挂掉 —— 而它本来根本不需要编译。
|
||||
*
|
||||
* 真正需要按 Electron ABI 重编的只有 better-sqlite3: 它不是 N-API, 绑死 V8/ABI
|
||||
* (Electron 32 → node ABI 128), 必须对着 Electron 头文件编译。
|
||||
* 其余原生模块 (@lydell/node-pty / node-hid / @serialport/bindings-cpp / usb)
|
||||
* 自带 N-API 预编译产物, 任何 ABI 都能加载 —— 重编既没必要, 又有害。
|
||||
*
|
||||
* 环境变量:
|
||||
* LNM_ELECTRON_DISTURL 覆盖 electron 头文件源 (默认走 npmmirror 镜像)
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { execFileSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const log = (...a) => console.log('[postinstall]', ...a)
|
||||
|
||||
function readJSON(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function npmExec() {
|
||||
const cli = process.env.npm_execpath
|
||||
if (cli && fs.existsSync(cli)) {
|
||||
return { cmd: process.execPath, args: [cli] }
|
||||
}
|
||||
return { cmd: process.platform === 'win32' ? 'npm.cmd' : 'npm', args: [] }
|
||||
}
|
||||
|
||||
function electronBin() {
|
||||
const base = path.join(root, 'node_modules/electron/dist')
|
||||
const name = process.platform === 'win32' ? 'electron.exe' : 'electron'
|
||||
const p = path.join(base, name)
|
||||
return fs.existsSync(p) ? p : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- electron 版本
|
||||
const electronPkg = readJSON(path.join(root, 'node_modules/electron/package.json'))
|
||||
if (!electronPkg) {
|
||||
log('electron 未安装, 跳过原生模块重建')
|
||||
process.exit(0)
|
||||
}
|
||||
const electronVersion = electronPkg.version
|
||||
log(`electron ${electronVersion}`)
|
||||
|
||||
// ------------------------------------------------------- 只重建 better-sqlite3
|
||||
const disturl =
|
||||
process.env.LNM_ELECTRON_DISTURL ||
|
||||
process.env.ELECTRON_MIRROR ||
|
||||
'https://npmmirror.com/mirrors/electron'
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
npm_config_runtime: 'electron',
|
||||
npm_config_target: electronVersion,
|
||||
npm_config_arch: process.arch,
|
||||
npm_config_target_arch: process.arch,
|
||||
npm_config_platform: process.platform,
|
||||
npm_config_target_platform: process.platform,
|
||||
npm_config_disturl: disturl,
|
||||
npm_config_devdir: path.join(process.env.HOME || process.env.USERPROFILE || '', '.electron-gyp'),
|
||||
// better-sqlite3 必须真编译 (prebuild-install 的 electron 预编译产物不一定覆盖当前 ABI)。
|
||||
// 注意: 这个开关只作用于下面显式重建的 better-sqlite3 一个包, 不会波及 node-hid 等
|
||||
// N-API 模块 —— 那些包根本不进这条路径, 继续用自带预编译产物。
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_update_binary: 'true',
|
||||
npm_config_fallback_to_build: 'true',
|
||||
}
|
||||
|
||||
const npm = npmExec()
|
||||
log('重建 better-sqlite3 (Electron ABI) ...')
|
||||
try {
|
||||
execFileSync(npm.cmd, [...npm.args, 'rebuild', 'better-sqlite3'], {
|
||||
cwd: root,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[postinstall] better-sqlite3 重建失败 —— 应用启动会因 sqlite 打不开而崩。')
|
||||
console.error('[postinstall] 手动修复: cd node_modules/better-sqlite3 && npx node-gyp rebuild --release')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 装完就验一次
|
||||
const bin = electronBin()
|
||||
if (!bin) {
|
||||
log('找不到 electron 可执行文件, 跳过 sqlite 校验 (不影响开发)')
|
||||
} else {
|
||||
const probe = path.join(os.tmpdir(), `lnm-sqlite-probe-${process.pid}.js`)
|
||||
// 探针放在系统临时目录, 所以不能用裸 require —— 解析路径会以 /tmp 为基准,
|
||||
// 找不到项目里的 node_modules。这里写绝对路径。
|
||||
const sqlitePath = path.join(root, 'node_modules', 'better-sqlite3')
|
||||
fs.writeFileSync(
|
||||
probe,
|
||||
`const D=require(${JSON.stringify(sqlitePath)});const db=new D(':memory:');db.exec('create table t(a)');db.prepare('insert into t values (?)').run(1);if(db.prepare('select count(*) c from t').get().c!==1)throw new Error('sqlite write/read mismatch');console.log('sqlite-ok');`
|
||||
)
|
||||
try {
|
||||
const out = execFileSync(bin, [probe], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
})
|
||||
if (!out.includes('sqlite-ok')) throw new Error(`unexpected output: ${out}`)
|
||||
log('better-sqlite3 在 Electron ABI 下验证通过')
|
||||
} catch (err) {
|
||||
console.error('[postinstall] better-sqlite3 在 Electron 下加载/读写失败:', err.message)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
fs.rmSync(probe, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------- N-API 模块: 确认预编译产物还在
|
||||
// node-pty 1.2 是"主包 + 平台包"拆包架构: 二进制在 @lydell/node-pty-<plat>-<arch> 里,
|
||||
// 主包本身没有 prebuilds/ 目录 —— 别按主包去找, 否则只会误报。
|
||||
const prebuilt = [
|
||||
['node-hid', 'prebuilds'],
|
||||
['@serialport/bindings-cpp', 'prebuilds'],
|
||||
]
|
||||
for (const [pkg, sub] of prebuilt) {
|
||||
const dir = path.join(root, 'node_modules', pkg, sub)
|
||||
if (!fs.existsSync(dir)) {
|
||||
log(`提醒: ${pkg} 缺少 ${sub}/ 预编译产物, 该功能可能不可用`)
|
||||
}
|
||||
}
|
||||
|
||||
// node-pty: 二进制在平台包里 (node-pty-<plat>-<arch>/prebuilds/<plat>-<arch>/pty.node)
|
||||
const ptyPlat = `@lydell/node-pty-${process.platform}-${process.arch}`
|
||||
const ptyBin = path.join(root, 'node_modules', ptyPlat, 'prebuilds', `${process.platform}-${process.arch}`)
|
||||
if (!fs.existsSync(ptyBin)) {
|
||||
log(`提醒: ${ptyPlat} 缺少 ${path.relative(root, ptyBin)}, 远程终端可能不可用`)
|
||||
}
|
||||
|
||||
log('done')
|
||||
@@ -0,0 +1,175 @@
|
||||
// 用 CDP (Chrome DevTools Protocol) 驱动真实 Electron 窗口:
|
||||
// 点开设备 -> 截图 -> 右键图片 -> 再截图。
|
||||
//
|
||||
// 用途: 肉眼验证"图片直接显示 + 右键能复制图片"。环境里没有 xdotool,
|
||||
// 所以走 DevTools 协议自己点。
|
||||
//
|
||||
// 用法: node scripts/selftest/cdp-shot.cjs <outDir> [port]
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const WebSocket = require('ws')
|
||||
|
||||
const outDir = process.argv[2] || '/tmp/lnm-shots'
|
||||
const port = process.argv[3] || '9222'
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function findPage() {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/list`)
|
||||
const list = await res.json()
|
||||
const page = list.find(t => t.type === 'page' && t.webSocketDebuggerUrl)
|
||||
if (page) return page
|
||||
} catch { /* 还没起来 */ }
|
||||
await sleep(500)
|
||||
}
|
||||
throw new Error('连不上 CDP (应用没起来?)')
|
||||
}
|
||||
|
||||
function connect(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 })
|
||||
ws.once('open', () => resolve(ws))
|
||||
ws.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(outDir, { recursive: true })
|
||||
const page = await findPage()
|
||||
const ws = await connect(page.webSocketDebuggerUrl)
|
||||
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString())
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
})
|
||||
const send = (method, params) => new Promise((resolve, reject) => {
|
||||
const mid = ++id
|
||||
pending.set(mid, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id: mid, method, params: params || {} }))
|
||||
})
|
||||
const evaluate = async (expr) => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ' ' + JSON.stringify(r.exceptionDetails.exception?.description || ''))
|
||||
return r.result?.value
|
||||
}
|
||||
const shot = async (name) => {
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' })
|
||||
const p = path.join(outDir, name)
|
||||
fs.writeFileSync(p, Buffer.from(r.data, 'base64'))
|
||||
console.log('截图: ' + p)
|
||||
return p
|
||||
}
|
||||
|
||||
await send('Page.enable')
|
||||
await send('Runtime.enable')
|
||||
|
||||
// 等 Vue 挂载
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ok = await evaluate(`!!document.querySelector('.device-item')`)
|
||||
if (ok) break
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
// 1) 点开设备 (侧边栏第一项)
|
||||
const clicked = await evaluate(`(() => {
|
||||
const el = document.querySelector('.device-item')
|
||||
if (!el) return false
|
||||
el.click()
|
||||
return true
|
||||
})()`)
|
||||
console.log('点击设备项: ' + clicked)
|
||||
await sleep(1500)
|
||||
|
||||
// 2) 看看聊天区里有没有图片气泡
|
||||
const info = await evaluate(`(() => {
|
||||
const imgs = [...document.querySelectorAll('.bubble.image img')]
|
||||
return {
|
||||
imageCount: imgs.length,
|
||||
srcPrefix: imgs[0] ? imgs[0].src.slice(0, 30) : '',
|
||||
naturalW: imgs[0] ? imgs[0].naturalWidth : 0,
|
||||
naturalH: imgs[0] ? imgs[0].naturalHeight : 0,
|
||||
fallbackCount: document.querySelectorAll('.image-fallback').length,
|
||||
bubbleText: [...document.querySelectorAll('.bubble')].map(b => b.innerText.trim().slice(0, 40)),
|
||||
}
|
||||
})()`)
|
||||
console.log('页面状态: ' + JSON.stringify(info, null, 2))
|
||||
|
||||
await shot('01-chat-image.png')
|
||||
|
||||
// 3) 右键点图片 -> 看右键菜单
|
||||
const ctx = await evaluate(`(() => {
|
||||
const img = document.querySelector('.bubble.image img')
|
||||
if (!img) return null
|
||||
const r = img.getBoundingClientRect()
|
||||
const x = Math.round(r.left + r.width / 2)
|
||||
const y = Math.round(r.top + r.height / 2)
|
||||
img.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y }))
|
||||
return { x, y }
|
||||
})()`)
|
||||
console.log('右键坐标: ' + JSON.stringify(ctx))
|
||||
await sleep(800)
|
||||
|
||||
const menu = await evaluate(`(() => {
|
||||
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')].map(e => e.innerText.trim())
|
||||
return { items, hasCopyImage: items.includes('复制图片') }
|
||||
})()`)
|
||||
console.log('右键菜单: ' + JSON.stringify(menu, null, 2))
|
||||
await shot('02-context-menu.png')
|
||||
|
||||
// 4) 点"复制图片" -> 看提示条 (提示只在 imageCopy 返回 ok 时才会出现)
|
||||
const clickedCopy = await evaluate(`(() => {
|
||||
const items = [...document.querySelectorAll('.ctx-menu .ctx-item')]
|
||||
const it = items.find(e => e.innerText.trim() === '复制图片')
|
||||
if (!it) return false
|
||||
it.click()
|
||||
return true
|
||||
})()`)
|
||||
console.log('点击"复制图片": ' + clickedCopy)
|
||||
await sleep(1500)
|
||||
const toast = await evaluate(`(() => {
|
||||
const t = document.body.innerText
|
||||
return { copied: t.includes('图片已复制到剪贴板'), failed: t.includes('复制失败') || t.includes('还没有可复制') }
|
||||
})()`)
|
||||
console.log('提示条: ' + JSON.stringify(toast))
|
||||
await shot('03-after-copy.png')
|
||||
|
||||
// 5) 切到串口 tab -> 看看串口面板
|
||||
const tab = await evaluate(`(() => {
|
||||
const btns = [...document.querySelectorAll('.header-tabs button')]
|
||||
const b = btns.find(x => x.innerText.trim() === '串口')
|
||||
if (!b) return { found: false, tabs: btns.map(x => x.innerText.trim()) }
|
||||
b.click()
|
||||
return { found: true }
|
||||
})()`)
|
||||
console.log('串口 tab: ' + JSON.stringify(tab))
|
||||
await sleep(1500)
|
||||
const panel = await evaluate(`(() => {
|
||||
const p = document.querySelector('.serial-panel')
|
||||
if (!p) return { found: false }
|
||||
return {
|
||||
found: true,
|
||||
hasDeviceTable: !!p.querySelector('.el-table'),
|
||||
rows: p.querySelectorAll('.el-table tbody tr').length,
|
||||
hasAssistant: !!p.querySelector('.assistant'),
|
||||
hasHexView: !!p.querySelector('.hex-view'),
|
||||
buttons: [...p.querySelectorAll('button')].map(b => b.innerText.trim()).filter(Boolean),
|
||||
text: p.innerText.slice(0, 300),
|
||||
}
|
||||
})()`)
|
||||
console.log('串口面板: ' + JSON.stringify(panel, null, 2))
|
||||
await shot('04-serial-panel.png')
|
||||
|
||||
ws.close()
|
||||
console.log('done')
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('CDP 失败:', e.message); process.exit(1) })
|
||||
@@ -12,6 +12,65 @@ function ensure(p) {
|
||||
return p
|
||||
}
|
||||
|
||||
// ---- nativeImage / clipboard 桩 ----
|
||||
//
|
||||
// 真的 Electron 用 Chromium 的图片解码器。桩里只按**魔数**认 PNG/JPEG (别的格式
|
||||
// 一律给空图, 和真实现一样), 尺寸和编码后体积按**源文件大小**等比给 —— 目的不是
|
||||
// 还原真图, 而是让自测能确定性地覆盖 "选 PNG 还是 JPEG / 降级重缩 / 复制" 这几条分支。
|
||||
function emptyImage() {
|
||||
return {
|
||||
isEmpty: () => true,
|
||||
getSize: () => ({ width: 0, height: 0 }),
|
||||
resize: () => emptyImage(),
|
||||
toPNG: () => Buffer.alloc(0),
|
||||
toJPEG: () => Buffer.alloc(0),
|
||||
toDataURL: () => '',
|
||||
}
|
||||
}
|
||||
|
||||
function fakeImage(opts) {
|
||||
const { width, height, pngSize, jpegSize } = opts
|
||||
return {
|
||||
isEmpty: () => false,
|
||||
getSize: () => ({ width, height }),
|
||||
resize: (o) => fakeImage({
|
||||
width: o.width || width,
|
||||
height: Math.round(height * ((o.width || width) / width)),
|
||||
pngSize,
|
||||
jpegSize,
|
||||
}),
|
||||
toPNG: () => Buffer.alloc(pngSize, 0x89),
|
||||
toJPEG: (q) => Buffer.alloc(Math.round(jpegSize * ((q == null ? 80 : q) / 80)), 0xff),
|
||||
toDataURL: () => 'data:image/png;base64,' + Buffer.alloc(32, 1).toString('base64'),
|
||||
}
|
||||
}
|
||||
|
||||
function sniffImage(p) {
|
||||
try {
|
||||
const fd = fs.openSync(p, 'r')
|
||||
const buf = Buffer.alloc(8)
|
||||
fs.readSync(fd, buf, 0, 8, 0)
|
||||
fs.closeSync(fd)
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png'
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpeg'
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function fakeImageForPath(p) {
|
||||
const size = fs.statSync(p).size
|
||||
return fakeImage({
|
||||
width: 1200, height: 900,
|
||||
// PNG 通常比源文件还大 (照片尤其), JPEG 大约三成 —— 够触发不同分支
|
||||
pngSize: Math.round(size * 1.5),
|
||||
jpegSize: Math.round(size * 0.3),
|
||||
})
|
||||
}
|
||||
|
||||
const clipboardState = { image: null, text: '' }
|
||||
|
||||
module.exports = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
@@ -45,4 +104,40 @@ module.exports = {
|
||||
dialog: { showErrorBox: () => {}, showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||
shell: { openPath: async () => '', showItemInFolder: () => {} },
|
||||
Notification: class { static isSupported() { return false } constructor() {} show() {} },
|
||||
nativeImage: {
|
||||
createFromPath(p) {
|
||||
try {
|
||||
if (!fs.existsSync(p)) return emptyImage()
|
||||
if (!sniffImage(p)) return emptyImage()
|
||||
return fakeImageForPath(p)
|
||||
} catch {
|
||||
return emptyImage()
|
||||
}
|
||||
},
|
||||
createFromDataUrl(u) {
|
||||
// 只认 data:image/png;base64 和 data:image/jpeg;base64
|
||||
const m = /^data:image\/(png|jpeg);base64,/.exec(String(u || ''))
|
||||
if (!m) return emptyImage()
|
||||
return fakeImage({ width: 1200, height: 900, pngSize: 40_000, jpegSize: 12_000 })
|
||||
},
|
||||
createFromBuffer(b) {
|
||||
if (!b || b.length < 8) return emptyImage()
|
||||
const kind = b[0] === 0x89 && b[1] === 0x50 ? 'png' : (b[0] === 0xff && b[1] === 0xd8 ? 'jpeg' : null)
|
||||
if (!kind) return emptyImage()
|
||||
return fakeImage({
|
||||
width: 1200, height: 900,
|
||||
pngSize: Math.round(b.length * 1.5),
|
||||
jpegSize: Math.round(b.length * 0.3),
|
||||
})
|
||||
},
|
||||
},
|
||||
clipboard: {
|
||||
writeImage(img) { clipboardState.image = img },
|
||||
readImage() { return clipboardState.image },
|
||||
writeText(t) { clipboardState.text = String(t) },
|
||||
readText() { return clipboardState.text },
|
||||
},
|
||||
// 只给自测用: 看剪贴板里到底写进去了什么
|
||||
__clipboardState: clipboardState,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 往 App 的 SQLite 里注入一条"对方发来的图片消息", 用来肉眼验证:
|
||||
// 1. 图片气泡是不是直接显示图 (而不是 "[图片] xxx" 的文件卡片)
|
||||
// 2. 右键菜单里有没有"复制图片"
|
||||
//
|
||||
// 用法: node scripts/selftest/inject-image-msg.cjs <dbPath>
|
||||
//
|
||||
// 注入完让 App 重启一次 —— 设备列表是启动时从 DB 读的。
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const zlib = require('node:zlib')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const dbPath = process.argv[2]
|
||||
if (!dbPath) {
|
||||
console.error('用法: node scripts/selftest/inject-image-msg.cjs <dbPath>')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`找不到 DB: ${dbPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ---- 手写一张 PNG (红黄渐变 + 蓝色方块), 当作"对方发来的图" ----
|
||||
let crcTable = null
|
||||
function crc32(buf) {
|
||||
if (!crcTable) {
|
||||
crcTable = new Int32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
crcTable[n] = c
|
||||
}
|
||||
}
|
||||
let c = -1
|
||||
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
|
||||
return c ^ -1
|
||||
}
|
||||
function makePng(w, h, pixel) {
|
||||
const raw = Buffer.alloc((w * 3 + 1) * h)
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rs = y * (w * 3 + 1)
|
||||
raw[rs] = 0
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b] = pixel(x, y)
|
||||
const o = rs + 1 + x * 3
|
||||
raw[o] = r; raw[o + 1] = g; raw[o + 2] = b
|
||||
}
|
||||
}
|
||||
const chunk = (type, data) => {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
|
||||
const td = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td) >>> 0)
|
||||
return Buffer.concat([len, td, crc])
|
||||
}
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4)
|
||||
ihdr[8] = 8; ihdr[9] = 2
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', zlib.deflateSync(raw)),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
])
|
||||
}
|
||||
|
||||
const W = 640, H = 420
|
||||
const png = makePng(W, H, (x, y) => {
|
||||
// 渐变底 + 中间一个深色方块 + 底下画几道白条 (能看出缩放后的锯齿)
|
||||
const r = 240 - Math.floor((x / W) * 180)
|
||||
const g = 120 + Math.floor((y / H) * 120)
|
||||
const b = 60 + Math.floor((x / W) * 150)
|
||||
if (x > W * 0.28 && x < W * 0.72 && y > H * 0.22 && y < H * 0.66) return [30, 40, 90]
|
||||
if (y > H * 0.78 && y < H * 0.84 && x > W * 0.1 && x < W * 0.9) return [255, 255, 255]
|
||||
if (y > H * 0.88 && y < H * 0.93 && x > W * 0.1 && x < W * 0.7) return [255, 255, 255]
|
||||
return [r, g, b]
|
||||
})
|
||||
const thumbDataUrl = 'data:image/png;base64,' + png.toString('base64')
|
||||
|
||||
const db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
// 本机 deviceId (持久化在 data/device.id) —— conversation_key 要用它算
|
||||
const idFile = path.join(path.dirname(dbPath), 'device.id')
|
||||
const selfId = fs.readFileSync(idFile, 'utf8').trim()
|
||||
const peerId = 'peer-demo1111-0000-4000-8000-0000000000aa'
|
||||
const now = Date.now()
|
||||
const convKey = [peerId, selfId].sort().join('::')
|
||||
|
||||
db.prepare(`INSERT OR REPLACE INTO devices
|
||||
(device_id, name, hostname, platform, app_version, last_ip, last_seen, drive_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
peerId, '测试设备B', 'demo-b', 'linux', '0.1.0', '192.168.1.88', now, null,
|
||||
)
|
||||
|
||||
// 把原图也落到磁盘 —— 这样"右键复制"走的是**本地文件**那条路 (复制原图, 不是缩略图)
|
||||
const imgFile = path.join(path.dirname(dbPath), 'demo-image.png')
|
||||
fs.writeFileSync(imgFile, png)
|
||||
|
||||
const body = {
|
||||
type: 'image',
|
||||
messageId: 'msg-demo-image-0001',
|
||||
ts: now,
|
||||
fromDeviceId: peerId,
|
||||
toDeviceId: selfId,
|
||||
fileId: 'file-demo-image-0001',
|
||||
name: '演示图片.png',
|
||||
size: png.length,
|
||||
mime: 'image/png',
|
||||
width: W,
|
||||
height: H,
|
||||
// 关键: 消息体里带缩略图 -> 气泡直接显示图
|
||||
thumbDataUrl,
|
||||
// 本地原图 (接收端落地路径) —— 供"复制图片"和"点开看大图"用
|
||||
savedPath: imgFile,
|
||||
}
|
||||
|
||||
db.prepare(`INSERT OR REPLACE INTO messages
|
||||
(message_id, type, from_id, to_id, ts, body_json, status, conversation_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
'msg-demo-image-0001', 'image', peerId, selfId, now, JSON.stringify(body), 'delivered', convKey,
|
||||
)
|
||||
|
||||
// 再来一条文字消息, 方便看气泡排版
|
||||
db.prepare(`INSERT OR REPLACE INTO messages
|
||||
(message_id, type, from_id, to_id, ts, body_json, status, conversation_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
'msg-demo-text-0001', 'text', peerId, selfId, now - 1000,
|
||||
JSON.stringify({
|
||||
type: 'text', messageId: 'msg-demo-text-0001', ts: now - 1000,
|
||||
fromDeviceId: peerId, toDeviceId: selfId, content: '看这张图 👆',
|
||||
}), 'delivered', convKey,
|
||||
)
|
||||
|
||||
db.close()
|
||||
console.log(`已注入: 设备 "${peerId}" + 1 条图片消息 (${Math.round(png.length / 1024)}KB) + 1 条文字消息`)
|
||||
console.log(`self=${selfId} conversation_key=${convKey}`)
|
||||
console.log(`原图: ${imgFile}`)
|
||||
console.log(`DB: ${dbPath}`)
|
||||
+1051
-81
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
// 真实 Electron 下验证 nativeImage 的实际行为 (一次性校验脚本, 不是自测的一部分)
|
||||
//
|
||||
// 为什么要单独验: 自测里 electron 是**桩**, nativeImage 是假的。
|
||||
// 这里用真的 Electron 跑, 确认:
|
||||
// 1. nativeImage 能解 JPEG / PNG
|
||||
// 2. resize 后 toPNG/toJPEG 的体积落在预期范围
|
||||
// 3. **真照片** PNG 编码远大于 JPEG -> 所以缩略图优先 PNG、超限才退 JPEG
|
||||
// 4. **透明 PNG 转 JPEG 会丢透明** (alpha 全变 255, 透明区变黑)
|
||||
// -> 所以透明截图必须走 PNG, 这是"优先 PNG"那条规则的原因
|
||||
// 5. clipboard.writeImage 真能写进系统剪贴板, 且写的是原图
|
||||
// 6. 不存在/非图片的文件 -> 空图, 不抛异常
|
||||
//
|
||||
// 用法: npm run selftest:image
|
||||
const { app, nativeImage, clipboard } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const zlib = require('node:zlib')
|
||||
|
||||
// 生产代码本体 (esbuild 打的包, --external:electron -> 这里拿到的是**真** electron)
|
||||
const imageMod = require('./.out/image-native.cjs')
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lnm-native-'))
|
||||
let failed = false
|
||||
|
||||
function check(name, cond, detail) {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`)
|
||||
if (!cond) failed = true
|
||||
}
|
||||
|
||||
// ---- 手写 PNG 编码器 (够用即可: 8bit RGB / RGBA, filter=none) ----
|
||||
let crcTable = null
|
||||
function crc32(buf) {
|
||||
if (!crcTable) {
|
||||
crcTable = new Int32Array(256)
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
crcTable[n] = c
|
||||
}
|
||||
}
|
||||
let c = -1
|
||||
for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
|
||||
return c ^ -1
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {(x:number,y:number)=>[number,number,number,number]} pixel -> r,g,b,a
|
||||
*/
|
||||
function writePng(file, w, h, pixel) {
|
||||
// 逐行扫一遍, 看有没有半透明/全透明像素 —— 有就写 RGBA, 否则写 RGB
|
||||
let hasAlpha = false
|
||||
for (let y = 0; y < h && !hasAlpha; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (pixel(x, y)[3] < 255) { hasAlpha = true; break }
|
||||
}
|
||||
}
|
||||
const bpp = hasAlpha ? 4 : 3
|
||||
const raw = Buffer.alloc((w * bpp + 1) * h)
|
||||
for (let y = 0; y < h; y++) {
|
||||
const rowStart = y * (w * bpp + 1)
|
||||
raw[rowStart] = 0 // filter: none
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b, a] = pixel(x, y)
|
||||
const o = rowStart + 1 + x * bpp
|
||||
raw[o] = r; raw[o + 1] = g; raw[o + 2] = b
|
||||
if (hasAlpha) raw[o + 3] = a
|
||||
}
|
||||
}
|
||||
const chunk = (type, data) => {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
|
||||
const td = Buffer.concat([Buffer.from(type, 'ascii'), data])
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td) >>> 0)
|
||||
return Buffer.concat([len, td, crc])
|
||||
}
|
||||
const ihdr = Buffer.alloc(13)
|
||||
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4)
|
||||
ihdr[8] = 8; ihdr[9] = hasAlpha ? 6 : 2
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', ihdr),
|
||||
chunk('IDAT', zlib.deflateSync(raw, { level: 0 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
])
|
||||
fs.writeFileSync(file, png)
|
||||
return png
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// ---------- 1) 照片样图 (有噪声): PNG 编码远大于 JPEG ----------
|
||||
const photoPath = path.join(dir, 'photo.png')
|
||||
let seed = 12345
|
||||
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff }
|
||||
const photoBytes = writePng(photoPath, 1200, 900, () => [
|
||||
Math.floor(rnd() * 256), Math.floor(rnd() * 256), Math.floor(rnd() * 256), 255,
|
||||
])
|
||||
|
||||
// 透明图 (下半区全透明) —— 用来验 "PNG 优先, 不转 JPEG"
|
||||
const alphaPath = path.join(dir, 'alpha.png')
|
||||
writePng(alphaPath, 400, 300, (x, y) => (y < 150 ? [255, 0, 0, 255] : [0, 255, 0, 0]))
|
||||
|
||||
const img = nativeImage.createFromPath(photoPath)
|
||||
check('nativeImage 能解 PNG', !img.isEmpty())
|
||||
const size = img.getSize()
|
||||
check('尺寸读得对', size.width === 1200 && size.height === 900, `${size.width}x${size.height}`)
|
||||
|
||||
const small = img.resize({ width: 480, quality: 'good' })
|
||||
const ss = small.getSize()
|
||||
check('resize 到 480 宽 (高按比例)', ss.width === 480 && ss.height === 360, `${ss.width}x${ss.height}`)
|
||||
|
||||
const pngOut = small.toPNG()
|
||||
const jpegOut = small.toJPEG(80)
|
||||
console.log(` 照片缩略图体积: PNG=${pngOut.length}B JPEG=${jpegOut.length}B 源PNG=${photoBytes.length}B`)
|
||||
check('照片场景 JPEG 明显小于 PNG', jpegOut.length < pngOut.length / 2, `JPEG=${jpegOut.length}B PNG=${pngOut.length}B`)
|
||||
|
||||
// ---------- 1b) 走**生产代码**的缩略图生成 (质量阶梯) ----------
|
||||
// 纯噪声是最坏情况: q80 会超 60KB, 阶梯应该降质量/缩尺寸把它压回来
|
||||
const thumb = imageMod.makeThumbFromFile(photoPath)
|
||||
check('生产代码能出缩略图', !!thumb && thumb.startsWith('data:image/'), (thumb || '').slice(0, 30))
|
||||
if (thumb) {
|
||||
const bytes = Buffer.from(thumb.split(',')[1], 'base64').length
|
||||
console.log(` 生产缩略图: ${bytes}B (${thumb.slice(0, 22)})`)
|
||||
check('生产缩略图压到 60KB 内', bytes <= 60_000, `${bytes}B`)
|
||||
}
|
||||
// 透明 PNG: 生产代码必须走 PNG (保住透明), 不能转 JPEG
|
||||
const alphaThumb = imageMod.makeThumbFromFile(alphaPath)
|
||||
check('透明图走 PNG (保透明)', !!alphaThumb && alphaThumb.startsWith('data:image/png;base64,'), (alphaThumb || '').slice(0, 24))
|
||||
|
||||
// ---------- 2) 透明 PNG: 转 JPEG 会丢透明 (这就是"优先 PNG"的原因) ----------
|
||||
const alphaImg = nativeImage.createFromPath(alphaPath)
|
||||
check('透明 PNG 能解', !alphaImg.isEmpty())
|
||||
|
||||
// toBitmap 恒为 BGRA (4 字节/像素), 不管原格式
|
||||
const bmp = alphaImg.toBitmap()
|
||||
const bpp = 4
|
||||
const px = (b, x, y) => {
|
||||
const o = (y * alphaImg.getSize().width + x) * bpp
|
||||
return { b: b[o], g: b[o + 1], r: b[o + 2], a: b[o + 3] }
|
||||
}
|
||||
check('PNG 位图里下半区是全透明 (alpha=0)', px(bmp, 200, 250).a === 0, `alpha=${px(bmp, 200, 250).a}`)
|
||||
check('PNG 位图里上半区不透明', px(bmp, 200, 50).a === 255, `alpha=${px(bmp, 200, 50).a}`)
|
||||
|
||||
const alphaJpeg = alphaImg.toJPEG(80)
|
||||
const alphaBmp = alphaImg.toBitmap() // 再取一次 (原图)
|
||||
check('JPEG 编码后长度合理', alphaJpeg.length > 0)
|
||||
// 把 JPEG 解回来看 alpha 变成什么
|
||||
const roundTrip = nativeImage.createFromBuffer(alphaJpeg)
|
||||
const rtBmp = roundTrip.toBitmap()
|
||||
const rtPx = px(rtBmp, 200, 250)
|
||||
check('透明区转 JPEG 后 alpha 变成不透明 (透明丢了)', rtPx.a === 255, `alpha=${rtPx.a}`)
|
||||
void alphaBmp
|
||||
|
||||
// ---------- 3) data URL ----------
|
||||
const durl = small.toDataURL()
|
||||
check('toDataURL 产出 data:image/png', durl.startsWith('data:image/png;base64,'), durl.slice(0, 30))
|
||||
// base64 长度 = ceil(len/3)*4, 再加 "data:image/png;base64," 前缀
|
||||
const expectLen = Math.ceil(pngOut.length / 3) * 4 + 'data:image/png;base64,'.length
|
||||
check('data URL 长度与 PNG 字节数吻合', durl.length === expectLen, `${durl.length} vs ${expectLen}`)
|
||||
|
||||
// ---------- 4) 剪贴板: 写进去再读回来 ----------
|
||||
clipboard.writeImage(img)
|
||||
const back = clipboard.readImage()
|
||||
check('剪贴板写入后可读回', !back.isEmpty())
|
||||
const bs = back.getSize()
|
||||
check('剪贴板里是原图尺寸 (不是缩略图)', bs.width === 1200 && bs.height === 900, `${bs.width}x${bs.height}`)
|
||||
|
||||
// ---------- 5) 坏输入: 空图而不是抛异常 ----------
|
||||
const missing = nativeImage.createFromPath(path.join(dir, 'nope.png'))
|
||||
check('不存在的文件 -> 空图而不是抛异常', missing.isEmpty())
|
||||
const txt = path.join(dir, 'a.txt')
|
||||
fs.writeFileSync(txt, 'hello')
|
||||
check('非图片 -> 空图', nativeImage.createFromPath(txt).isEmpty())
|
||||
check('空 buffer -> 空图', nativeImage.createFromBuffer(Buffer.alloc(0)).isEmpty())
|
||||
|
||||
console.log(failed ? '\n有失败项' : '\n全部通过')
|
||||
app.exit(failed ? 1 : 0)
|
||||
})
|
||||
@@ -65,7 +65,7 @@ async function main() {
|
||||
})
|
||||
|
||||
// 2) 其它原生模块也要解包 (Electron 的 smart unpack 不一定覆盖到)
|
||||
await check('其它原生模块已解包 (node-hid / serialport / node-usb)', () => {
|
||||
await check('其它原生模块已解包 (serialport)', () => {
|
||||
const nm = path.join(unpacked, 'node_modules')
|
||||
assert(fs.existsSync(nm), 'unpacked 里没有 node_modules')
|
||||
const found = []
|
||||
@@ -79,6 +79,11 @@ async function main() {
|
||||
}
|
||||
walk(nm, 0)
|
||||
assert(found.length > 0, '没有任何 .node 被解包')
|
||||
// 串口功能依赖 serialport 的原生绑定, 必须在 (USB 字节桥已下线, 不再需要 node-hid / node-usb)
|
||||
assert(
|
||||
found.some(f => f.includes('@serialport')),
|
||||
`serialport 的原生绑定没被解包, 串口功能会起不来: ${found.join(', ')}`,
|
||||
)
|
||||
return `${found.length} 个原生文件`
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// 同步盘端到端测试 (两个虚拟节点 A/B, 走真实的 WS + HTTP 通道)
|
||||
//
|
||||
// 覆盖的是"用户真正会遇到的路径":
|
||||
// 对方弹窗同意 -> 建立关系 -> 双向同步 -> 改文件 -> 删文件 -> 删除传播 -> 墓碑防复活
|
||||
// 和 scripts/selftest/main.ts 的定位不同: 那边是逐条断言, 这边是**完整链路**,
|
||||
// 用来守住"删了能删掉、删了不会复活"这两条命门。
|
||||
//
|
||||
// 运行: npm run selftest:sync
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
import { ChatServer } from '../../src/main/chat-server'
|
||||
import { ChatClient } from '../../src/main/chat-client'
|
||||
import { FileServer } from '../../src/main/file-server'
|
||||
import { RelationStore } from '../../src/main/relations'
|
||||
import { ManifestStore } from '../../src/main/sync-manifest'
|
||||
import { RelationService } from '../../src/main/relation-service'
|
||||
import { scanSync, applySyncItems } from '../../src/main/sync'
|
||||
import { listPendingApprovals, replyApproval } from '../../src/main/remote/approval'
|
||||
import type { DeviceInfo, WsFrame } from '../../src/main/protocol'
|
||||
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
let failures = 0
|
||||
function check(name: string, cond: any, extra?: string) {
|
||||
if (cond) { console.log(` PASS ${name}`) }
|
||||
else { failures++; console.log(` FAIL ${name}${extra ? ' :: ' + extra : ''}`) }
|
||||
}
|
||||
|
||||
class Node {
|
||||
info: DeviceInfo
|
||||
server: ChatServer
|
||||
client: ChatClient
|
||||
fileServer: FileServer
|
||||
relations: RelationStore
|
||||
manifestStore: ManifestStore
|
||||
relationService!: RelationService
|
||||
syncDir = ''
|
||||
base = ''
|
||||
|
||||
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.base = join(tmpdir(), `lnm-smoke-${id.slice(-4)}-${Date.now()}`)
|
||||
rmSync(this.base, { recursive: true, force: true })
|
||||
mkdirSync(this.base, { recursive: true })
|
||||
this.relations = new RelationStore(join(this.base, 'relations.json'))
|
||||
this.manifestStore = new ManifestStore(join(this.base, 'manifest.json'))
|
||||
this.server = new ChatServer(0)
|
||||
this.client = new ChatClient(this.info)
|
||||
this.fileServer = new FileServer(0, join(this.base, 'dl'), {
|
||||
getDriveRoot: () => join(this.base, 'drive'),
|
||||
getDriveToken: () => `tok-${id}`,
|
||||
isDriveEnabled: () => true,
|
||||
authorize: (token, peerId) => this.relationService
|
||||
? this.relationService.authorizeDrive(token, peerId)
|
||||
: { ok: false, reason: '未就绪' },
|
||||
getSyncScope: (relationId, peerId) => this.relationService?.getSyncScope(relationId, peerId) ?? null,
|
||||
getSyncManifest: ({ relationId }) => this.relationService?.getSyncManifest(relationId) ?? null,
|
||||
onSyncManifest: ({ relationId, wire }) => this.relationService
|
||||
? this.relationService.applyPeerManifest(relationId, wire)
|
||||
: { applied: [], conflicts: [] },
|
||||
})
|
||||
}
|
||||
|
||||
async start() {
|
||||
const cp = await this.server.start()
|
||||
const fp = await this.fileServer.start()
|
||||
this.info.chatPort = cp
|
||||
this.info.filePort = fp
|
||||
mkdirSync(join(this.base, 'drive'), { recursive: true })
|
||||
this.syncDir = join(this.base, 'sync')
|
||||
mkdirSync(this.syncDir, { recursive: true })
|
||||
this.relationService = new RelationService({
|
||||
store: this.relations,
|
||||
manifestStore: this.manifestStore,
|
||||
self: this.info,
|
||||
send: (peerId, frame) => this.client.send(frame, peerId),
|
||||
notify: () => {},
|
||||
})
|
||||
const route = (peer: DeviceInfo, frame: WsFrame) => {
|
||||
if (frame.type === 'relation:request') {
|
||||
this.relationService.handleRelationRequest(peer, frame.payload).catch(() => {})
|
||||
} else if (frame.type === 'relation:accept') {
|
||||
this.relationService.handleRelationAccept(peer, frame.payload)
|
||||
} else if (frame.type === 'relation:reject') {
|
||||
this.relationService.handleRelationReject(peer, frame.payload)
|
||||
}
|
||||
}
|
||||
this.server.on('frame', route)
|
||||
this.client.on('message', route)
|
||||
this.client.on('error', () => {})
|
||||
}
|
||||
|
||||
connectTo(other: Node) { this.client.connectTo({ ...other.info }) }
|
||||
async stop() { try { await this.server.stop() } catch {} ; try { this.client.stop() } catch {} }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const A = new Node('aaaaaaaa-1111-2222-3333-444444444444', 'A机')
|
||||
const B = new Node('bbbbbbbb-1111-2222-3333-444444444444', 'B机')
|
||||
await A.start()
|
||||
await B.start()
|
||||
A.connectTo(B)
|
||||
B.connectTo(A)
|
||||
await sleep(700)
|
||||
|
||||
// ---- 1) 建立关系 (模拟对方在弹窗里点同意) ----
|
||||
const req = A.relationService.requestRelation({
|
||||
peerId: B.info.deviceId, peerName: B.info.name,
|
||||
peerAddress: '127.0.0.1', peerChatPort: B.info.chatPort, peerFilePort: B.info.filePort,
|
||||
syncLocalDir: A.syncDir, syncRemotePath: '', grants: ['drive', 'sync'],
|
||||
})
|
||||
check('发起关系请求成功', req.ok, JSON.stringify(req))
|
||||
await sleep(500)
|
||||
const ask = listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === A.info.deviceId)
|
||||
check('对方弹出了关系授权请求', !!ask)
|
||||
if (ask) {
|
||||
replyApproval(ask.requestId, true, { grants: ['drive', 'sync'], syncLocalDir: B.syncDir, syncRemotePath: '' })
|
||||
}
|
||||
await sleep(700)
|
||||
const relA = A.relations.getRelationByPeer(B.info.deviceId)
|
||||
const relB = B.relations.getRelationByPeer(A.info.deviceId)
|
||||
// 实际同步根 = 顶层目录 + 按关系隔离的子目录 (一个顶层目录管多个聊天备份时各自隔开)
|
||||
const aRoot = join(A.syncDir, relA?.syncSubdir || '')
|
||||
const bRoot = join(B.syncDir, relB?.syncSubdir || '')
|
||||
mkdirSync(aRoot, { recursive: true })
|
||||
mkdirSync(bRoot, { recursive: true })
|
||||
check('A 侧关系 active', relA?.state === 'active', relA?.state)
|
||||
check('B 侧关系 active', relB?.state === 'active', relB?.state)
|
||||
check('双方令牌已交换', !!relA?.peerToken && !!relB?.peerToken)
|
||||
if (!relA || !relB) { await A.stop(); await B.stop(); process.exit(1) }
|
||||
|
||||
// ---- 2) 同步: A 的文件传到 B ----
|
||||
writeFileSync(join(aRoot, 'hello.txt'), 'HELLO-FROM-A')
|
||||
writeFileSync(join(bRoot, 'only-b.txt'), 'ONLY-FROM-B')
|
||||
const ctxA = {
|
||||
peer: B.info, localDir: aRoot, token: relA.peerToken,
|
||||
relationId: relA.relationId, store: A.manifestStore,
|
||||
selfDeviceId: A.info.deviceId,
|
||||
}
|
||||
const scan1 = await scanSync(ctxA)
|
||||
check('扫描成功', scan1.ok, scan1.reason)
|
||||
check('hello.txt 判为待上传', scan1.items.find(i => i.relPath === 'hello.txt')?.action === 'upload',
|
||||
JSON.stringify(scan1.items.map(i => [i.relPath, i.action])))
|
||||
check('only-b.txt 判为待下载', scan1.items.find(i => i.relPath === 'only-b.txt')?.action === 'download',
|
||||
JSON.stringify(scan1.items.map(i => [i.relPath, i.action])))
|
||||
const items1 = scan1.items
|
||||
.filter(i => i.action === 'upload' || i.action === 'download' || i.action === 'delete-local' || i.action === 'delete-remote')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as any }))
|
||||
const ap1 = await applySyncItems({ ...ctxA, items: items1 })
|
||||
check('同步执行成功', ap1.ok, JSON.stringify(ap1.results))
|
||||
check('B 收到了 hello.txt', existsSync(join(bRoot, 'hello.txt')))
|
||||
check('A 收到了 only-b.txt', existsSync(join(aRoot, 'only-b.txt')))
|
||||
check('内容正确', readFileSync(join(bRoot, 'hello.txt'), 'utf8') === 'HELLO-FROM-A')
|
||||
|
||||
// ---- 3) 本地改了 -> 应判 upload (有基线了) ----
|
||||
writeFileSync(join(aRoot, 'hello.txt'), 'HELLO-FROM-A-V2')
|
||||
const scan2 = await scanSync(ctxA)
|
||||
check('改过的文件判为待上传(不是冲突)',
|
||||
scan2.items.find(i => i.relPath === 'hello.txt')?.action === 'upload',
|
||||
JSON.stringify(scan2.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
// ---- 4) 删除传播: A 删了 -> B 那份也要没 ----
|
||||
rmSync(join(aRoot, 'hello.txt'))
|
||||
const scan3 = await scanSync(ctxA)
|
||||
check('删掉的文件判为"将删除对方"',
|
||||
scan3.items.find(i => i.relPath === 'hello.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(scan3.items.map(i => [i.relPath, i.action])))
|
||||
const ap3 = await applySyncItems({
|
||||
...ctxA,
|
||||
items: scan3.items.filter(i => i.action === 'delete-remote' || i.action === 'delete-local' || i.action === 'upload' || i.action === 'download')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as any })),
|
||||
})
|
||||
check('删除传播执行成功', ap3.ok, JSON.stringify(ap3.results))
|
||||
check('B 那份也被删了', !existsSync(join(bRoot, 'hello.txt')))
|
||||
|
||||
// ---- 5) 墓碑: 再扫一次不该把它当成"对方新增"重新下载 ----
|
||||
const scan4 = await scanSync(ctxA)
|
||||
const again = scan4.items.find(i => i.relPath === 'hello.txt')
|
||||
check('墓碑生效: 不会被重新下载', !again || again.action === 'same',
|
||||
JSON.stringify(scan4.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
// ---- 6) 授权边界: 错令牌被拒 ----
|
||||
const bad = await scanSync({ ...ctxA, token: 'wrong-token' })
|
||||
check('错令牌被拒', !bad.ok, bad.reason)
|
||||
|
||||
// ---- 7) 反向删除传播: B 删了 -> A 那份也要没 ----
|
||||
rmSync(join(bRoot, 'only-b.txt'))
|
||||
const ctxB = {
|
||||
peer: A.info, localDir: bRoot, token: relB.peerToken,
|
||||
relationId: relB.relationId, store: B.manifestStore,
|
||||
selfDeviceId: B.info.deviceId,
|
||||
}
|
||||
const scanB = await scanSync(ctxB)
|
||||
check('B 侧识别出"我删了 only-b.txt, 要传播给对方"',
|
||||
scanB.items.find(i => i.relPath === 'only-b.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(scanB.items.map(i => [i.relPath, i.action])))
|
||||
await applySyncItems({
|
||||
...ctxB,
|
||||
items: scanB.items.filter(i => i.action !== 'same').map(i => ({ relPath: i.relPath, action: i.action as any })),
|
||||
})
|
||||
check('A 那份 only-b.txt 也被删了', !existsSync(join(aRoot, 'only-b.txt')))
|
||||
|
||||
// ---- 7b) 完整删除传播链路: A 删 -> A 推关系表 -> B 那边自动删掉本地那份 ----
|
||||
writeFileSync(join(aRoot, 'both.txt'), 'SHARED')
|
||||
await applySyncItems({ ...ctxA, items: [{ relPath: 'both.txt', action: 'upload' }] })
|
||||
const scanB2 = await scanSync(ctxB) // B 拉一次, 建立基线
|
||||
await applySyncItems({
|
||||
...ctxB,
|
||||
items: scanB2.items.filter(i => i.action === 'download')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as any })),
|
||||
})
|
||||
check('both.txt 同步到了 B', existsSync(join(bRoot, 'both.txt')))
|
||||
rmSync(join(aRoot, 'both.txt'))
|
||||
const scanDel = await scanSync(ctxA)
|
||||
check('A 侧判为"将删除对方"',
|
||||
scanDel.items.find(i => i.relPath === 'both.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(scanDel.items.map(i => [i.relPath, i.action])))
|
||||
await applySyncItems({ ...ctxA, items: [{ relPath: 'both.txt', action: 'delete-remote' }] })
|
||||
await sleep(400) // 等对方收到并应用
|
||||
check('删除传播到 B: B 本地那份也没了', !existsSync(join(bRoot, 'both.txt')))
|
||||
|
||||
// ---- 8) 冲突: 两边都改同一个文件 -> 判 conflict, 不静默覆盖 ----
|
||||
writeFileSync(join(aRoot, 'conf.txt'), 'A-VERSION')
|
||||
await applySyncItems({ ...ctxA, items: [{ relPath: 'conf.txt', action: 'upload' }] })
|
||||
writeFileSync(join(aRoot, 'conf.txt'), 'A-VERSION-2')
|
||||
writeFileSync(join(bRoot, 'conf.txt'), 'B-VERSION-2')
|
||||
const scanC = await scanSync(ctxA)
|
||||
check('两边都改过 -> 判冲突',
|
||||
scanC.items.find(i => i.relPath === 'conf.txt')?.action === 'conflict',
|
||||
JSON.stringify(scanC.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
// ---- 9) 复活防护: 对方拿更早的 seq 声称文件还在 -> 墓碑赢 ----
|
||||
const M = require('../../src/main/sync-manifest')
|
||||
const man = { relationId: 'r1', clock: 0, remoteSeq: 0, entries: {} as any }
|
||||
const snap = (relPath: string, hash: string) => ({ relPath, size: 10, mtime: Date.now(), hash })
|
||||
// 先建立基线: 两边一致
|
||||
M.reconcileLocal(man, [snap('z.txt', 'H1')])
|
||||
const peerMap = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H1', size: 10, mtime: 1, state: 'present', seq: 1, deletedAt: 0 }]])
|
||||
M.mergePeer(man, peerMap)
|
||||
M.updateBaseline(man, 'z.txt', 'H1', 10, 1)
|
||||
// 本地删除 -> 墓碑
|
||||
M.reconcileLocal(man, [])
|
||||
const tombSeq = man.entries['z.txt'].mySeq
|
||||
check('本地删除记成了墓碑', man.entries['z.txt'].myState === 'deleted' && tombSeq > 0)
|
||||
// 对方用一个更早的 seq 声称文件还在 -> 墓碑应该赢
|
||||
const stalePeer = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H1', size: 10, mtime: 1, state: 'present', seq: 1, deletedAt: 0 }]])
|
||||
M.mergePeer(man, stalePeer)
|
||||
const planStale = M.planSync(man, [], stalePeer)
|
||||
check('墓碑赢: 不会让已删文件复活',
|
||||
planStale.items.find(i => i.relPath === 'z.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(planStale.items.map(i => [i.relPath, i.action])))
|
||||
// 对方用更新的 seq 声称文件还在 -> 接受复活
|
||||
const freshPeer = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H2', size: 10, mtime: 2, state: 'present', seq: tombSeq + 5, deletedAt: 0 }]])
|
||||
M.mergePeer(man, freshPeer)
|
||||
const planFresh = M.planSync(man, [], freshPeer)
|
||||
check('对方 seq 更新 -> 接受复活(下载)',
|
||||
planFresh.items.find(i => i.relPath === 'z.txt')?.action === 'download',
|
||||
JSON.stringify(planFresh.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
await A.stop()
|
||||
await B.stop()
|
||||
console.log(failures === 0 ? '\n全部通过' : `\n${failures} 条失败`)
|
||||
process.exit(failures === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('崩溃:', e); process.exit(1) })
|
||||
@@ -0,0 +1,177 @@
|
||||
// 自动同步: 关系开了"自动同步"就定时跑一轮
|
||||
//
|
||||
// 触发:
|
||||
// - 定时轮询 (默认 60s)
|
||||
// - 对方上线时 (ipc.ts 调 trigger)
|
||||
// - 用户刚开启自动同步时
|
||||
//
|
||||
// 失败处理: 指数退避 (1min -> 2min -> 4min ... 上限 5min), 不刷屏。
|
||||
// 冲突处理: **跳过并记录**, 不静默覆盖任何一方 —— 自动同步绝不能替用户决定丢哪份。
|
||||
// 用户在同步面板里手动选方向即可。
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { RelationStore, type Relation } from './relations'
|
||||
import { ManifestStore } from './sync-manifest'
|
||||
import { scanSync, applySyncItems } from './sync'
|
||||
import { recordAudit } from './db'
|
||||
import type { DeviceInfo } from './protocol'
|
||||
|
||||
export interface AutoSyncOptions {
|
||||
relations: RelationStore
|
||||
manifestStore: ManifestStore
|
||||
/** 找到对方 (含 address / port / 令牌等) */
|
||||
findPeer: (peerId: string) => DeviceInfo | null
|
||||
/** 通知渲染层 */
|
||||
notify: (event: string, payload?: any) => void
|
||||
/** 轮询间隔 (毫秒), 默认 60s */
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
/** 退避上限 5 分钟 */
|
||||
const BACKOFF_MAX_MS = 5 * 60_000
|
||||
const BACKOFF_BASE_MS = 60_000
|
||||
|
||||
export class AutoSync {
|
||||
private relations: RelationStore
|
||||
private manifestStore: ManifestStore
|
||||
private findPeer: (peerId: string) => DeviceInfo | null
|
||||
private notify: (event: string, payload?: any) => void
|
||||
private intervalMs: number
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
/** relationId -> 下次允许重试的时间戳 */
|
||||
private nextTryAt = new Map<string, number>()
|
||||
/** relationId -> 当前连续失败次数 */
|
||||
private failures = new Map<string, number>()
|
||||
/** 正在跑的关系 (防重入) */
|
||||
private running = new Set<string>()
|
||||
|
||||
constructor(opts: AutoSyncOptions) {
|
||||
this.relations = opts.relations
|
||||
this.manifestStore = opts.manifestStore
|
||||
this.findPeer = opts.findPeer
|
||||
this.notify = opts.notify
|
||||
this.intervalMs = opts.intervalMs ?? 60_000
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return
|
||||
this.timer = setInterval(() => { this.tick().catch(() => {}) }, this.intervalMs)
|
||||
// 别让定时器拖住进程退出
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) { clearInterval(this.timer); this.timer = null }
|
||||
}
|
||||
|
||||
/** 对方上线 / 用户刚开开关时: 立刻试一次 (不受退避限制) */
|
||||
trigger(relationId?: string): void {
|
||||
if (relationId) {
|
||||
this.nextTryAt.delete(relationId)
|
||||
this.runOne(relationId).catch(() => {})
|
||||
return
|
||||
}
|
||||
this.tick(true).catch(() => {})
|
||||
}
|
||||
|
||||
/** 跑一轮所有该跑的关系 */
|
||||
async tick(force = false): Promise<void> {
|
||||
const list = this.relations.listActiveRelations().filter(r => r.syncEnabled && r.syncLocalDir)
|
||||
for (const rel of list) {
|
||||
if (!force) {
|
||||
const wait = this.nextTryAt.get(rel.relationId) || 0
|
||||
if (Date.now() < wait) continue
|
||||
}
|
||||
await this.runOne(rel.relationId).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** 跑一个关系的同步 */
|
||||
async runOne(relationId: string): Promise<void> {
|
||||
if (this.running.has(relationId)) return
|
||||
const rel = this.relations.getRelation(relationId)
|
||||
if (!rel || rel.state !== 'active' || !rel.syncEnabled) return
|
||||
if (!rel.syncLocalDir || !rel.peerToken) return
|
||||
const peer = this.findPeer(rel.peerId)
|
||||
if (!peer) return
|
||||
|
||||
this.running.add(relationId)
|
||||
try {
|
||||
const ctx = {
|
||||
peer,
|
||||
// 实际同步根 = 顶层目录 + 按关系隔离的子目录
|
||||
localDir: this.localRootOf(rel),
|
||||
remotePath: rel.syncRemotePath,
|
||||
token: rel.peerToken,
|
||||
relationId: rel.relationId,
|
||||
store: this.manifestStore,
|
||||
}
|
||||
const scan = await scanSync(ctx)
|
||||
if (!scan.ok) {
|
||||
this.fail(rel, scan.reason || '扫描失败')
|
||||
return
|
||||
}
|
||||
// 冲突跳过: 自动同步不替用户决定丢哪一份
|
||||
const conflicts = scan.items.filter(i => i.action === 'conflict')
|
||||
const actions = scan.items
|
||||
.filter(i => i.action === 'upload' || i.action === 'download' || i.action === 'delete-local' || i.action === 'delete-remote')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' | 'delete-local' | 'delete-remote' }))
|
||||
|
||||
let failed = 0
|
||||
if (actions.length) {
|
||||
const r = await applySyncItems({ ...ctx, items: actions })
|
||||
failed = r.failed
|
||||
}
|
||||
|
||||
const errText = conflicts.length
|
||||
? `有 ${conflicts.length} 个文件两边都改过, 需在同步面板里手动选择方向`
|
||||
: (failed ? `同步失败 ${failed} 项` : '')
|
||||
this.relations.updateRelation(relationId, {
|
||||
lastSyncAt: Date.now(),
|
||||
lastError: errText,
|
||||
})
|
||||
if (!errText) {
|
||||
this.failures.delete(relationId)
|
||||
this.nextTryAt.delete(relationId)
|
||||
} else {
|
||||
this.scheduleRetry(relationId)
|
||||
}
|
||||
this.notify('sync:auto', {
|
||||
relationId, peerId: rel.peerId,
|
||||
changed: actions.length, failed, conflicts: conflicts.map(c => c.relPath),
|
||||
})
|
||||
// 自动同步那一轮也要留审计 (否则界面上看不到"自动同步干了啥")
|
||||
recordAudit({
|
||||
action: 'sync.auto', source: this.relations.getRelation(relationId)?.peerId || rel.peerId,
|
||||
target: rel.peerId,
|
||||
result: failed === 0 ? 'ok' : 'error',
|
||||
payload: {
|
||||
relationId, changed: actions.length, failed,
|
||||
conflicts: conflicts.map(c => c.relPath).slice(0, 20),
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
this.running.delete(relationId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 实际同步根 = 顶层目录 + 按关系隔离的子目录 */
|
||||
private localRootOf(rel: Relation): string {
|
||||
const base = resolve(rel.syncLocalDir || '')
|
||||
const sub = (rel.syncSubdir || '').replace(/^[/\\]+/, '').replace(/[/\\]+$/, '')
|
||||
return sub ? join(base, sub) : base
|
||||
}
|
||||
|
||||
/** 记一次失败并安排退避重试 */
|
||||
private fail(rel: Relation, reason: string): void {
|
||||
this.relations.updateRelation(rel.relationId, { lastError: reason })
|
||||
this.scheduleRetry(rel.relationId)
|
||||
}
|
||||
|
||||
private scheduleRetry(relationId: string): void {
|
||||
const n = (this.failures.get(relationId) || 0) + 1
|
||||
this.failures.set(relationId, n)
|
||||
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, n - 1), BACKOFF_MAX_MS)
|
||||
this.nextTryAt.set(relationId, Date.now() + delay)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface ChatServerEvents {
|
||||
recall: (from: DeviceInfo, messageId: string) => void
|
||||
typing: (from: DeviceInfo) => void
|
||||
read: (from: DeviceInfo, upToMessageId: string) => void
|
||||
// 任意 ws 帧 (含 terminal/forward/usb 等), 让上层路由分发
|
||||
// 任意 ws 帧 (含 terminal/forward/serial 等), 让上层路由分发
|
||||
frame: (from: DeviceInfo, frame: WsFrame) => void
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export class ChatServer extends EventEmitter {
|
||||
case 'typing':
|
||||
if (peer) this.emit('typing', peer)
|
||||
break
|
||||
// terminal / forward / usb 由 'frame' 事件统一交给上层处理
|
||||
// terminal / forward / serial 由 'frame' 事件统一交给上层处理
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+4
-4
@@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS unread (
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- 远程操作审计 (terminal / forward / usb)
|
||||
-- 远程操作审计 (terminal / forward / serial)
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
@@ -263,7 +263,7 @@ export function totalUnread(): number {
|
||||
return r.s
|
||||
}
|
||||
|
||||
// 审计日志 (远程操作: terminal / forward / usb)
|
||||
// 审计日志 (远程操作: terminal / forward / serial)
|
||||
export interface AuditRow {
|
||||
id: number
|
||||
ts: number
|
||||
@@ -323,11 +323,11 @@ export function pruneAudit(olderThanMs: number): number {
|
||||
return r.changes
|
||||
}
|
||||
|
||||
// 远程操作白名单 (按 deviceId -> { terminal, forward, usb })
|
||||
// 远程操作白名单 (按 deviceId -> { terminal, forward, serial })
|
||||
// 写入 settings.json (electron-store) 而不是 DB, 便于跨设备查看与回滚
|
||||
export interface RemoteAllowRow {
|
||||
device_id: string
|
||||
terminal: number // 0/1
|
||||
forward: number
|
||||
usb: number
|
||||
serial: number
|
||||
}
|
||||
|
||||
+601
-27
@@ -10,10 +10,10 @@
|
||||
|
||||
import { createServer, IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createWriteStream, existsSync, statSync, mkdirSync, readdirSync } from 'node:fs'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createWriteStream, existsSync, statSync, mkdirSync, readdirSync, openSync, readSync, closeSync } from 'node:fs'
|
||||
import { createReadStream, lstatSync, realpathSync } from 'node:fs'
|
||||
import { mkdir, stat, unlink, rename } from 'node:fs/promises'
|
||||
import { join, extname, resolve, basename, sep } from 'node:path'
|
||||
import { join, extname, resolve, basename, sep, dirname } from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { getSettings } from './settings'
|
||||
import { safeFileName } from './safe-name'
|
||||
@@ -23,16 +23,47 @@ export interface FileServerEvents {
|
||||
progress: [info: { fileId: string; sent: number; total: number; fromAddr: string }]
|
||||
/** 对端从我的网盘下载了一个文件 */
|
||||
driveDownloaded: [info: { name: string; size: number; relPath: string; fromAddr: string }]
|
||||
/** 对端往我的网盘上传了一个文件 (文件夹同步的上行方向) */
|
||||
driveUploaded: [info: { relPath: string; size: number; fromAddr: string }]
|
||||
/** 对端让我删掉了网盘里的一个文件 (删除传播的落地) */
|
||||
driveDeleted: [info: { relPath: string; size: number; fromAddr: string }]
|
||||
}
|
||||
|
||||
/** 网盘接口的回调 (设置项运行时可变, 所以用 getter 而不是固定值) */
|
||||
export interface DriveOptions {
|
||||
/** 网盘根目录 (对端只能看到这个目录里的内容) */
|
||||
getDriveRoot: () => string
|
||||
/** 访问令牌; 返回空 = 不提供网盘服务 */
|
||||
/** 访问令牌; 返回空 = 不提供网盘服务 (旧版全局 token, 只作兼容) */
|
||||
getDriveToken: () => string | undefined
|
||||
/** 总开关 */
|
||||
isDriveEnabled: () => boolean
|
||||
/**
|
||||
* 新版: 按对方签发的 per-peer 令牌鉴权。
|
||||
* 返回 ok=false 就一律 403; scopeRoot 非空表示把访问限制在该子目录内
|
||||
* (只给了 sync 授权时, 对方只能碰这个关系专属的目录)。
|
||||
*/
|
||||
authorize?: (token: string | null, peerId: string | null) => DriveAuthResult
|
||||
/**
|
||||
* 同步区: 某个关系在我这边的同步目录 (绝对路径)。
|
||||
* /sync/file 的所有路径都限制在这个目录内 —— 这样"同步目录"可以是
|
||||
* 用户任意一个文件夹 (比如 ~/文档/工作), 不必待在网盘目录里面。
|
||||
*/
|
||||
getSyncScope?: (relationId: string, peerId: string) => string | null
|
||||
/** 同步: 对方来拉我的文件关系表 */
|
||||
getSyncManifest?: (args: { peerId: string; relationId: string }) => unknown | null
|
||||
/** 同步: 对方把它的文件关系表推给我 -> 我这边应用 (删本地文件等) */
|
||||
onSyncManifest?: (args: { peerId: string; relationId: string; wire: any }) => { applied?: string[]; conflicts?: string[]; reason?: string }
|
||||
}
|
||||
|
||||
export interface DriveAuthResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
/** 限定在这个子目录内 (相对网盘根); 空 = 不限制 */
|
||||
scopeRoot?: string
|
||||
relationId?: string
|
||||
peerId?: string
|
||||
/** 被授权的能力 (drive / sync) */
|
||||
grants?: string[]
|
||||
}
|
||||
|
||||
/** 网盘目录里的一个条目 */
|
||||
@@ -45,6 +76,36 @@ export interface DriveEntry {
|
||||
isDir: boolean
|
||||
}
|
||||
|
||||
/** 目录同步: 扫描结果里的一个文件 */
|
||||
export interface DriveScanFile {
|
||||
/** 相对网盘根目录的路径 */
|
||||
relPath: string
|
||||
size: number
|
||||
mtime: number
|
||||
/** sha256; 文件太大时不算 (见 HASH_MAX_SIZE) */
|
||||
hash?: string
|
||||
}
|
||||
|
||||
/** 超过这个大小就不算 hash 了 (算起来慢, 同步时靠 size+mtime 兜底) */
|
||||
const HASH_MAX_SIZE = 256 * 1024 * 1024
|
||||
|
||||
/** 算文件的 sha256 (同步) */
|
||||
export function hashFileSync(p: string): string {
|
||||
const h = createHash('sha256')
|
||||
const buf = Buffer.alloc(1024 * 1024)
|
||||
const fd = openSync(p, 'r')
|
||||
try {
|
||||
for (;;) {
|
||||
const n = readSync(fd, buf, 0, buf.length, null)
|
||||
if (n <= 0) break
|
||||
h.update(buf.subarray(0, n))
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
return h.digest('hex')
|
||||
}
|
||||
|
||||
// 单文件大小上限, 默认 100GB (LAN 局域网传大视频常见, 不限太死)
|
||||
// 覆盖方式: 环境变量 LNM_MAX_FILE_SIZE=<bytes>
|
||||
function resolveMaxSize(): number {
|
||||
@@ -77,11 +138,57 @@ function safeDecode(s: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 软链安全的路径归一。
|
||||
* 目标可能还不存在 (上传时), 这时逐级向上找到最近的存在父目录, 归一它再拼回去。
|
||||
* 返回 null 表示归不出来 (路径不存在且父目录也不存在)。
|
||||
*/
|
||||
function realpathSafe(p: string): string | null {
|
||||
try { return realpathSync(p) } catch { /* 继续往下 */ }
|
||||
let cur = resolve(p)
|
||||
const tail: string[] = []
|
||||
for (;;) {
|
||||
const parent = dirname(cur)
|
||||
if (parent === cur) return null
|
||||
tail.unshift(basename(cur))
|
||||
cur = parent
|
||||
try { return join(realpathSync(cur), ...tail) } catch { /* 再往上找 */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 流式计算 SHA-256 hex (上传时, 一边传一边 hash, 不预读阻塞)
|
||||
function sha256Buf(buf: Buffer): string {
|
||||
return createHash('sha256').update(buf).digest('hex')
|
||||
}
|
||||
|
||||
// 读 JSON 请求体 (同步关系表用); 超限或解析失败返回 null
|
||||
function readJsonBody(req: IncomingMessage, limit = 8 * 1024 * 1024): Promise<any | null> {
|
||||
return new Promise((resolve) => {
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
let done = false
|
||||
const finish = (v: any | null) => {
|
||||
if (done) return
|
||||
done = true
|
||||
resolve(v)
|
||||
}
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length
|
||||
if (size > limit) {
|
||||
try { req.destroy() } catch {}
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
chunks.push(c)
|
||||
})
|
||||
req.on('end', () => {
|
||||
try { finish(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch { finish(null) }
|
||||
})
|
||||
req.on('error', () => finish(null))
|
||||
req.on('aborted', () => finish(null))
|
||||
})
|
||||
}
|
||||
|
||||
export class FileServer extends EventEmitter {
|
||||
private server = createServer((req, res) => this.handle(req, res))
|
||||
private port: number
|
||||
@@ -160,6 +267,32 @@ export class FileServer extends EventEmitter {
|
||||
await this.handleDrive(req, url, res)
|
||||
return
|
||||
}
|
||||
if (url.startsWith('/drive/') && req.method === 'POST') {
|
||||
await this.handleDrivePost(req, url, res)
|
||||
return
|
||||
}
|
||||
// ---- 同步: 文件关系表 (manifest) ----
|
||||
if (url.startsWith('/sync/manifest') && req.method === 'GET') {
|
||||
await this.handleSyncManifestGet(url, res)
|
||||
return
|
||||
}
|
||||
if (url.startsWith('/sync/manifest') && req.method === 'POST') {
|
||||
await this.handleSyncManifestPost(req, url, res)
|
||||
return
|
||||
}
|
||||
// ---- 同步区: 扫描 + 文件读写 (路径限制在关系的同步目录内, 与网盘无关) ----
|
||||
if (url.startsWith('/sync/scan') && req.method === 'GET') {
|
||||
await this.handleSyncScan(url, res)
|
||||
return
|
||||
}
|
||||
if (url.startsWith('/sync/file') && req.method === 'GET') {
|
||||
await this.handleSyncFileGet(req, url, res)
|
||||
return
|
||||
}
|
||||
if (url.startsWith('/sync/file') && req.method === 'POST') {
|
||||
await this.handleSyncFilePost(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' })
|
||||
@@ -169,37 +302,123 @@ export class FileServer extends EventEmitter {
|
||||
// ========== 网盘 ==========
|
||||
|
||||
// 把对端传来的相对路径解析成绝对路径, 并确保没有跳出网盘根目录
|
||||
private resolveDrivePath(rel: string): { abs: string; root: string } | null {
|
||||
// scopeRoot 非空时, 还要确保没有跳出这个关系的专属子目录
|
||||
private resolveDrivePath(rel: string, scopeRoot?: 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
|
||||
if (scopeRoot) {
|
||||
// 'none' 是哨兵值: 表示"完全不开放网盘浏览"(只给了 sync 授权)
|
||||
if (scopeRoot === 'none') return null
|
||||
const scopeAbs = resolve(join(root, scopeRoot.replace(/^[/\\]+/, '')))
|
||||
if (abs !== scopeAbs && !abs.startsWith(scopeAbs + sep)) return null
|
||||
}
|
||||
// 软链逃逸防护: 路径前缀检查对软链无效, 归一后再比一次
|
||||
const realRoot = realpathSafe(root)
|
||||
const realAbs = realpathSafe(abs)
|
||||
if (!realRoot || !realAbs) return null
|
||||
if (realAbs !== realRoot && !realAbs.startsWith(realRoot + sep)) return null
|
||||
return { abs, root }
|
||||
}
|
||||
|
||||
private driveTokenOk(token: string | null): boolean {
|
||||
if (!this.drive) return false
|
||||
if (!this.drive.isDriveEnabled()) return false
|
||||
/**
|
||||
* 同步区的路径解析: 根是这个关系在我这边的同步目录 (绝对路径, 位置任意)。
|
||||
* 与网盘完全独立 —— 用户的同步目录不必待在网盘目录里面。
|
||||
* 必须有 sync 授权 (由 authorize 回调判定), 路径再限制在这个目录内。
|
||||
*/
|
||||
private resolveSyncPath(relationId: string, rel: string, peerId: string): { abs: string; root: string } | null {
|
||||
if (!this.drive?.getSyncScope) return null
|
||||
const scope = this.drive.getSyncScope(relationId, peerId)
|
||||
if (!scope) return null
|
||||
const root = resolve(scope)
|
||||
const clean = (rel || '').replace(/^[/\\]+/, '')
|
||||
const abs = resolve(join(root, clean))
|
||||
if (abs !== root && !abs.startsWith(root + sep)) return null
|
||||
// 软链逃逸防护: 同步目录是用户任意文件夹, 里面出现软链很常见。
|
||||
// 路径前缀检查对软链无效 —— 归一后必须仍在 root 内。
|
||||
const realRoot = realpathSafe(root)
|
||||
const realAbs = realpathSafe(abs)
|
||||
if (!realRoot || !realAbs) return null
|
||||
if (realAbs !== realRoot && !realAbs.startsWith(realRoot + sep)) return null
|
||||
return { abs, root }
|
||||
}
|
||||
|
||||
/** 发一个文件给对端 */
|
||||
private serveDownload(req: IncomingMessage, res: ServerResponse, r: { abs: string; root: string }, rel: string) {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 网盘访问鉴权。
|
||||
* 新版走 per-peer 关系令牌 (谁签发给我的、给��授权了什么都查得到);
|
||||
* 没接 authorize 回调时退回旧的全局 token (兼容还没升级的对端)。
|
||||
*/
|
||||
private authDrive(token: string | null, peerId: string | null, need?: 'drive' | 'drive-write' | 'sync'): {
|
||||
ok: boolean; reason?: string; scopeRoot?: string; grants?: string[]; relationId?: string; legacy?: boolean
|
||||
} {
|
||||
if (!this.drive) return { ok: false, reason: '网盘未开启' }
|
||||
if (!this.drive.isDriveEnabled()) return { ok: false, reason: '网盘未开启' }
|
||||
if (this.drive.authorize) {
|
||||
const r = this.drive.authorize(token, peerId)
|
||||
if (r?.ok) {
|
||||
// 只给了 sync 的话, 网盘浏览完全不给 (scopeRoot='none' 表示任何路径都越界)
|
||||
if (need === 'drive' && r.grants && !r.grants.includes('drive')) {
|
||||
return { ok: false, reason: '未授权浏览网盘' }
|
||||
}
|
||||
if (need === 'drive-write' && r.grants && !r.grants.includes('drive')) {
|
||||
return { ok: false, reason: '未授权写入网盘' }
|
||||
}
|
||||
if (need === 'sync' && r.grants && !r.grants.includes('sync')) {
|
||||
return { ok: false, reason: '未授权同步' }
|
||||
}
|
||||
return { ok: true, reason: r.reason, scopeRoot: r.scopeRoot, grants: r.grants, relationId: r.relationId }
|
||||
}
|
||||
// 过渡期兼容: 对方还没建立关系 (比如还没升级的老版本对端) -> 退回旧的全局 token。
|
||||
// **只允许只读浏览网盘**: 这个 token 曾经在 UDP 广播里明文传过, 不能让它碰到
|
||||
// 同步区 (用户任意文件夹) 或写/删这类破坏性接口。
|
||||
const legacy = this.drive.getDriveToken()
|
||||
if (legacy && token && token === legacy && need === 'drive') {
|
||||
return { ok: true, scopeRoot: '', grants: undefined, legacy: true }
|
||||
}
|
||||
return { ok: false, reason: r?.reason }
|
||||
}
|
||||
const expect = this.drive.getDriveToken()
|
||||
if (!expect) return false
|
||||
return !!token && token === expect
|
||||
if (!expect) return { ok: false, reason: '网盘令牌无效' }
|
||||
if (!token || token !== expect) return { ok: false, reason: '网盘令牌无效' }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
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')
|
||||
const peerId = params.get('peerId')
|
||||
|
||||
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: '网盘令牌无效' })
|
||||
const auth = this.authDrive(token, peerId, 'drive')
|
||||
if (!auth.ok) {
|
||||
this.json(res, 403, { ok: false, error: auth.reason || '网盘令牌无效' })
|
||||
return
|
||||
}
|
||||
const scope = auth.scopeRoot
|
||||
|
||||
const route = url.slice('/drive/'.length, qIdx < 0 ? undefined : qIdx)
|
||||
if (route === 'meta') {
|
||||
@@ -208,7 +427,7 @@ export class FileServer extends EventEmitter {
|
||||
}
|
||||
if (route === 'list') {
|
||||
const rel = params.get('path') || ''
|
||||
const r = this.resolveDrivePath(rel)
|
||||
const r = this.resolveDrivePath(rel, scope)
|
||||
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 }
|
||||
@@ -231,28 +450,52 @@ export class FileServer extends EventEmitter {
|
||||
this.json(res, 200, { ok: true, path: rel, entries })
|
||||
return
|
||||
}
|
||||
if (route === 'scan') {
|
||||
await this.handleDriveScan(url, res, scope)
|
||||
return
|
||||
}
|
||||
if (route === 'download') {
|
||||
const rel = params.get('path') || ''
|
||||
const r = this.resolveDrivePath(rel)
|
||||
const r = this.resolveDrivePath(rel, scope)
|
||||
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)
|
||||
this.serveDownload(req, res, r, rel)
|
||||
return
|
||||
}
|
||||
this.json(res, 404, { ok: false, error: 'not found' })
|
||||
}
|
||||
|
||||
private async handleDrivePost(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')
|
||||
const peerId = params.get('peerId')
|
||||
|
||||
if (!this.drive || !this.drive.isDriveEnabled()) {
|
||||
this.json(res, 404, { ok: false, error: '网盘未开启' })
|
||||
req.resume()
|
||||
return
|
||||
}
|
||||
const auth = this.authDrive(token, peerId, 'drive-write')
|
||||
if (!auth.ok) {
|
||||
this.json(res, 403, { ok: false, error: auth.reason || '网盘令牌无效' })
|
||||
req.resume()
|
||||
return
|
||||
}
|
||||
const scope = auth.scopeRoot
|
||||
|
||||
const route = url.slice('/drive/'.length, qIdx < 0 ? undefined : qIdx)
|
||||
if (route === 'upload') {
|
||||
await this.handleDriveUpload(req, res, scope)
|
||||
return
|
||||
}
|
||||
if (route === 'delete') {
|
||||
await this.handleDriveDelete(req, url, res, scope)
|
||||
return
|
||||
}
|
||||
this.json(res, 404, { ok: false, error: 'not found' })
|
||||
req.resume()
|
||||
}
|
||||
|
||||
private json(res: ServerResponse, code: number, data: any) {
|
||||
res.statusCode = code
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
@@ -260,6 +503,337 @@ export class FileServer extends EventEmitter {
|
||||
res.end(JSON.stringify(data))
|
||||
}
|
||||
|
||||
// ========== 网盘: 目录同步用的扩展接口 ==========
|
||||
|
||||
/**
|
||||
* GET /drive/scan?token=&path=&hash=1
|
||||
* 递归列出目录下的所有文件 (相对路径 / 大小 / 修改时间 / 可选 sha256)。
|
||||
* 同步靠它拿到"对方那侧"的完整快照, 才能跟本地做对比。
|
||||
*/
|
||||
private async handleDriveScan(url: string, res: ServerResponse, scope?: string) {
|
||||
const qIdx = url.indexOf('?')
|
||||
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
|
||||
const rel = params.get('path') || ''
|
||||
const wantHash = params.get('hash') !== '0'
|
||||
const r = this.resolveDrivePath(rel, scope)
|
||||
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 }
|
||||
this.scanInto(res, r.abs, rel.replace(/[/\\]+$/, ''), wantHash)
|
||||
}
|
||||
|
||||
/** 递归列目录 (网盘和同步区共用) */
|
||||
private scanInto(res: ServerResponse, rootAbs: string, relBase: string, wantHash: boolean) {
|
||||
const files: DriveScanFile[] = []
|
||||
const dirs: string[] = []
|
||||
let skipped = 0
|
||||
const MAX_ENTRIES = 20000
|
||||
// 目录深度上限, 防止软链接环 / 超深目录把服务拖死
|
||||
const walk = (abs: string, relBase2: string, depth: number) => {
|
||||
if (depth > 16 || files.length >= MAX_ENTRIES) return
|
||||
let names: string[] = []
|
||||
try { names = readdirSync(abs) } catch { skipped++; return }
|
||||
for (const name of names) {
|
||||
if (files.length >= MAX_ENTRIES) return
|
||||
// 跳过自己写的临时文件
|
||||
if (name.startsWith('.')) continue
|
||||
const childAbs = join(abs, name)
|
||||
const childRel = relBase2 ? `${relBase2}/${name}` : name
|
||||
let s: any
|
||||
// 用 lstat: 软链不跟随 (软链能指向同步目录外面 -> 会漏出去)
|
||||
try { s = lstatSync(childAbs) } catch { skipped++; continue }
|
||||
if (s.isSymbolicLink()) { skipped++; continue }
|
||||
if (s.isDirectory()) {
|
||||
dirs.push(childRel)
|
||||
walk(childAbs, childRel, depth + 1)
|
||||
} else if (s.isFile()) {
|
||||
const entry: DriveScanFile = {
|
||||
relPath: childRel, size: s.size, mtime: s.mtimeMs,
|
||||
}
|
||||
// 大文件不算 hash (太慢), 只靠 size+mtime 判定
|
||||
if (wantHash && s.size <= HASH_MAX_SIZE) {
|
||||
try { entry.hash = hashFileSync(childAbs) } catch { skipped++ }
|
||||
}
|
||||
files.push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rootAbs, relBase, 0)
|
||||
files.sort((a, b) => a.relPath.localeCompare(b.relPath))
|
||||
dirs.sort()
|
||||
this.json(res, 200, { ok: true, path: relBase, files, dirs, skipped, root: rootAbs })
|
||||
}
|
||||
|
||||
// ========== 同步区 (/sync/*) ==========
|
||||
//
|
||||
// 同步区和网盘是**两套独立的空间**:
|
||||
// - 网盘 (/drive/*): 共享目录, 要 drive 授权才能浏览
|
||||
// - 同步区 (/sync/*): 某个关系专属的同步目录, 要 sync 授权, 且路径严格限制在该目录内
|
||||
// 这样用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘里。
|
||||
|
||||
/**
|
||||
* 同步区鉴权: 必须是有效关系 + 有 sync 能力 + **relationId 与令牌绑定的关系一致**。
|
||||
*
|
||||
* 最后一条最关键: 令牌本身只证明"这个令牌有效", 不证明"这个令牌属于哪个关系"。
|
||||
* 不校验的话, A 关系的对方拿 A 的令牌就能读写 B 关系的同步目录 (跨关系越权)。
|
||||
*/
|
||||
private authSync(params: URLSearchParams): { ok: boolean; reason?: string; relationId?: string } {
|
||||
if (!this.drive?.getSyncScope) return { ok: false, reason: '不支持同步' }
|
||||
const auth = this.authDrive(params.get('token'), params.get('peerId'), 'sync')
|
||||
if (!auth.ok) return { ok: false, reason: auth.reason }
|
||||
if (auth.grants && !auth.grants.includes('sync')) return { ok: false, reason: '未授权同步' }
|
||||
// 令牌绑定了唯一的关系, 请求里的 relationId 必须就是它
|
||||
const want = params.get('relationId') || ''
|
||||
if (!auth.relationId) return { ok: false, reason: '令牌未绑定关系' }
|
||||
if (!want || want !== auth.relationId) return { ok: false, reason: '关系不匹配' }
|
||||
return { ok: true, relationId: auth.relationId }
|
||||
}
|
||||
|
||||
/** GET /sync/scan?token=&peerId=&relationId=&hash=1 */
|
||||
private async handleSyncScan(url: string, res: ServerResponse) {
|
||||
const qIdx = url.indexOf('?')
|
||||
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
|
||||
const auth = this.authSync(params)
|
||||
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '未授权' }); return }
|
||||
const relationId = params.get('relationId') || ''
|
||||
const r = this.resolveSyncPath(relationId, '', params.get('peerId') || '')
|
||||
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 }
|
||||
this.scanInto(res, r.abs, '', params.get('hash') !== '0')
|
||||
}
|
||||
|
||||
/** GET /sync/file?token=&peerId=&relationId=&path= */
|
||||
private async handleSyncFileGet(req: IncomingMessage, url: string, res: ServerResponse) {
|
||||
const qIdx = url.indexOf('?')
|
||||
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
|
||||
const auth = this.authSync(params)
|
||||
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '未授权' }); return }
|
||||
const rel = params.get('path') || ''
|
||||
const r = this.resolveSyncPath(params.get('relationId') || '', rel, params.get('peerId') || '')
|
||||
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
|
||||
this.serveDownload(req, res, r, rel)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /sync/file?token=&peerId=&relationId=
|
||||
* Header: x-file-rel-path 目标相对路径
|
||||
* x-file-action=delete 时执行删除 (删除传播)
|
||||
*/
|
||||
private async handleSyncFilePost(req: IncomingMessage, url: string, res: ServerResponse) {
|
||||
const qIdx = url.indexOf('?')
|
||||
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
|
||||
const auth = this.authSync(params)
|
||||
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '未授权' }); req.resume(); return }
|
||||
const relationId = params.get('relationId') || ''
|
||||
const rel = safeDecode(req.headers['x-file-rel-path']?.toString() || '')
|
||||
if (!rel) { this.json(res, 400, { ok: false, error: '缺少目标路径' }); req.resume(); return }
|
||||
const r = this.resolveSyncPath(relationId, rel, params.get('peerId') || '')
|
||||
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); req.resume(); return }
|
||||
const action = req.headers['x-file-action']?.toString() || 'upload'
|
||||
if (action === 'delete') {
|
||||
req.resume()
|
||||
await this.removeFile(res, r, rel, req.socket.remoteAddress || '')
|
||||
return
|
||||
}
|
||||
await this.writeUpload(req, res, r, rel)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /drive/upload
|
||||
* 把文件写进对方网盘里的**指定相对路径** (文件夹同步的上行方向)。
|
||||
* Header:
|
||||
* x-file-rel-path 目标相对路径 (相对网盘根, 含目录), 必填
|
||||
* x-file-size 文件大小
|
||||
* x-file-sha256 可选, 传了就校验内容
|
||||
* x-file-overwrite 1 = 允许覆盖已存在文件
|
||||
*/
|
||||
private async handleDriveUpload(req: IncomingMessage, res: ServerResponse, scope?: string) {
|
||||
const rel = safeDecode(req.headers['x-file-rel-path']?.toString() || '')
|
||||
// 目录穿越防护: 目标必须严格在网盘根内 (有 scope 时还要在 scope 内)
|
||||
const r = this.resolveDrivePath(rel, scope)
|
||||
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); req.resume(); return }
|
||||
await this.writeUpload(req, res, r, rel)
|
||||
}
|
||||
|
||||
/** 真正把请求体写成文件 (网盘和同步区共用) */
|
||||
private async writeUpload(
|
||||
req: IncomingMessage, res: ServerResponse, r: { abs: string; root: string }, rel: string,
|
||||
) {
|
||||
const size = Number(req.headers['x-file-size'] || '0') || 0
|
||||
const expectHash = safeDecode(req.headers['x-file-sha256']?.toString() || '')
|
||||
const overwrite = req.headers['x-file-overwrite']?.toString() === '1'
|
||||
|
||||
if (!rel) { this.json(res, 400, { ok: false, error: '缺少目标路径' }); req.resume(); return }
|
||||
if (size > MAX_SIZE) {
|
||||
this.json(res, 413, { ok: false, error: `文件超过限制 ${fmtSize(MAX_SIZE)}` })
|
||||
req.resume()
|
||||
return
|
||||
}
|
||||
if (r.abs === r.root) { this.json(res, 400, { ok: false, error: '非法路径' }); req.resume(); return }
|
||||
|
||||
const exists = existsSync(r.abs)
|
||||
if (exists && !overwrite) {
|
||||
this.json(res, 409, { ok: false, error: '目标已存在' })
|
||||
req.resume()
|
||||
return
|
||||
}
|
||||
|
||||
// 先写 .tmp 再 rename, 中途崩了不会留半拉文件
|
||||
const tmpDir = join(r.root, '.tmp')
|
||||
await mkdir(tmpDir, { recursive: true })
|
||||
const tmpTarget = join(tmpDir, `sync-${randomUUID()}.part`)
|
||||
const written = await this.writeBodyToFile(req, tmpTarget, size)
|
||||
if (!written.ok) {
|
||||
try { await unlink(tmpTarget) } catch {}
|
||||
this.json(res, written.status, { ok: false, error: written.error })
|
||||
return
|
||||
}
|
||||
if (expectHash) {
|
||||
const actual = hashFileSync(tmpTarget)
|
||||
if (actual !== expectHash) {
|
||||
try { await unlink(tmpTarget) } catch {}
|
||||
this.json(res, 400, { ok: false, error: '内容校验失败 (hash 不一致)' })
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await mkdir(resolve(r.abs, '..'), { recursive: true })
|
||||
await rename(tmpTarget, r.abs)
|
||||
} catch (e: any) {
|
||||
try { await unlink(tmpTarget) } catch {}
|
||||
this.json(res, 500, { ok: false, error: `保存失败: ${e.message}` })
|
||||
return
|
||||
}
|
||||
let finalSize = 0
|
||||
let finalMtime = Date.now()
|
||||
try { const s = statSync(r.abs); finalSize = s.size; finalMtime = s.mtimeMs } catch {}
|
||||
this.emit('driveUploaded', {
|
||||
relPath: rel, size: finalSize, fromAddr: req.socket.remoteAddress || '',
|
||||
})
|
||||
this.json(res, 200, { ok: true, relPath: rel, size: finalSize, mtime: finalMtime })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /drive/delete?token=&path=
|
||||
* 删掉网盘里的一个文件 —— 这是**删除传播**的落点:
|
||||
* 我这边删了文件 -> 同步时通知对方把它那份也删掉。
|
||||
* 路径同样受 scope 限制 (只许删关系专属目录里的东西)。
|
||||
* 文件本来就不存在 -> 幂等返回成功 (删除传播要能重复执行)。
|
||||
*/
|
||||
private async handleDriveDelete(req: IncomingMessage, url: string, res: ServerResponse, scope?: string) {
|
||||
req.resume()
|
||||
const qIdx = url.indexOf('?')
|
||||
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
|
||||
const rel = params.get('path') || ''
|
||||
if (!rel) { this.json(res, 400, { ok: false, error: '缺少目标路径' }); return }
|
||||
const r = this.resolveDrivePath(rel, scope)
|
||||
if (!r) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
|
||||
await this.removeFile(res, r, rel, req.socket.remoteAddress || '')
|
||||
}
|
||||
|
||||
/** 真正删文件 (网盘和同步区共用)。文件本来就不存在 -> 幂等返回成功 */
|
||||
private async removeFile(
|
||||
res: ServerResponse, r: { abs: string; root: string }, rel: string, fromAddr: string,
|
||||
) {
|
||||
if (r.abs === r.root) { this.json(res, 400, { ok: false, error: '非法路径' }); return }
|
||||
let st: any
|
||||
try { st = statSync(r.abs) } catch {
|
||||
this.json(res, 200, { ok: true, relPath: rel, deleted: false, reason: '文件不存在' })
|
||||
return
|
||||
}
|
||||
if (st.isDirectory()) { this.json(res, 400, { ok: false, error: '不能删除目录' }); return }
|
||||
try {
|
||||
await unlink(r.abs)
|
||||
} catch (e: any) {
|
||||
this.json(res, 500, { ok: false, error: `删除失败: ${e?.message || e}` })
|
||||
return
|
||||
}
|
||||
this.emit('driveDeleted', { relPath: rel, size: st.size, fromAddr })
|
||||
this.json(res, 200, { ok: true, relPath: rel, deleted: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /sync/manifest?token=&peerId=&relationId=
|
||||
* 对方来拉我的文件关系表 (含墓碑 + 序号)。
|
||||
* 同步的两边各维护一份自己的视角, 靠这个接口互换。
|
||||
*/
|
||||
private async handleSyncManifestGet(url: string, res: ServerResponse) {
|
||||
const qIdx = url.indexOf('?')
|
||||
const params = new URLSearchParams(qIdx < 0 ? '' : url.slice(qIdx + 1))
|
||||
const token = params.get('token')
|
||||
const peerId = params.get('peerId')
|
||||
const relationId = params.get('relationId') || ''
|
||||
if (!this.drive || !this.drive.isDriveEnabled()) {
|
||||
this.json(res, 404, { ok: false, error: '网盘未开启' })
|
||||
return
|
||||
}
|
||||
const auth = this.authSync(params)
|
||||
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '令牌无效' }); return }
|
||||
if (!this.drive.getSyncManifest) { this.json(res, 404, { ok: false, error: '不支持关系表' }); return }
|
||||
const manifest = this.drive.getSyncManifest({ peerId: peerId || '', relationId })
|
||||
if (!manifest) { this.json(res, 404, { ok: false, error: '关系不存在' }); return }
|
||||
this.json(res, 200, { ok: true, manifest })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /sync/manifest?token=&peerId=&relationId=
|
||||
* 对方把它的文件关系表推给我 -> 我这边应用 (按墓碑删本地文件等)。
|
||||
* 这是删除能跨机器传播的关键一步。
|
||||
*/
|
||||
private async handleSyncManifestPost(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')
|
||||
const peerId = params.get('peerId')
|
||||
const relationId = params.get('relationId') || ''
|
||||
if (!this.drive || !this.drive.isDriveEnabled()) {
|
||||
this.json(res, 404, { ok: false, error: '网盘未开启' })
|
||||
req.resume()
|
||||
return
|
||||
}
|
||||
const auth = this.authSync(params)
|
||||
if (!auth.ok) { this.json(res, 403, { ok: false, error: auth.reason || '令牌无效' }); req.resume(); return }
|
||||
if (!this.drive.onSyncManifest) { this.json(res, 404, { ok: false, error: '不支持关系表' }); req.resume(); return }
|
||||
const body = await readJsonBody(req)
|
||||
req.resume()
|
||||
if (!body || !body.relationId) { this.json(res, 400, { ok: false, error: '关系表为空' }); return }
|
||||
const r = this.drive.onSyncManifest({ peerId: peerId || '', relationId, wire: body })
|
||||
this.json(res, 200, { ok: true, applied: r.applied || [], conflicts: r.conflicts || [] })
|
||||
}
|
||||
|
||||
// 把请求体写进文件, 返回 { ok, status, error }
|
||||
private writeBodyToFile(
|
||||
req: IncomingMessage, target: string, declaredSize: number,
|
||||
): Promise<{ ok: boolean; status: number; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = createWriteStream(target)
|
||||
const hash = createHash('sha256')
|
||||
const limit = Math.max(MAX_SIZE, declaredSize || 0)
|
||||
let written = 0
|
||||
let aborted = false
|
||||
const fail = (status: number, error: string) => {
|
||||
if (aborted) return
|
||||
aborted = true
|
||||
try { ws.destroy() } catch {}
|
||||
try { req.destroy() } catch {}
|
||||
resolve({ ok: false, status, error })
|
||||
}
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
written += chunk.length
|
||||
hash.update(chunk)
|
||||
if (written > limit) { fail(413, `文件过大, 已超过 ${fmtSize(limit)}`); return }
|
||||
if (!ws.write(chunk)) { req.pause(); ws.once('drain', () => req.resume()) }
|
||||
})
|
||||
req.on('aborted', () => fail(400, '传输中断'))
|
||||
ws.on('error', (e) => fail(500, `写入失败: ${e?.message || e}`))
|
||||
ws.on('finish', () => resolve({ ok: true, status: 200 }))
|
||||
req.on('end', () => ws.end())
|
||||
})
|
||||
}
|
||||
|
||||
private async handleUpload(req: IncomingMessage, res: ServerResponse) {
|
||||
const fileId = req.headers['x-file-id']?.toString() || randomUUID()
|
||||
// HTTP header 是 ASCII, 非 ASCII 字符 (中文文件名等) 发送前 encodeURIComponent
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 图片处理的**纯逻辑** (不 import electron, 所以能在无头自测里直接跑)
|
||||
//
|
||||
// 为什么要拆出来: 真正吃 nativeImage 的那层 (image.ts) 在自测环境里只有桩,
|
||||
// 阈值/编码选择/源挑选这些"容易算错"的部分必须能独立验证。
|
||||
|
||||
/** 缩略图最大宽度 (像素)。气泡里最多显示 320px 高, 留一倍余量给高分屏 */
|
||||
export const THUMB_MAX_WIDTH = 480
|
||||
|
||||
/** 单张缩略图的字节上限 (base64 之前)。超了就再缩一档 */
|
||||
export const THUMB_MAX_BYTES = 60_000
|
||||
|
||||
/**
|
||||
* 源文件字节上限。nativeImage 解码会把整张图铺成 RGBA 内存
|
||||
* (4000x3000 ≈ 48MB), 太大的图直接放弃缩略图, 走"文件卡片"降级。
|
||||
*/
|
||||
export const THUMB_MAX_SOURCE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
/** JPEG 编码质量 */
|
||||
export const THUMB_JPEG_QUALITY = 80
|
||||
|
||||
/** 缩略图还是太大时的降级宽度 */
|
||||
export const THUMB_FALLBACK_WIDTH = 320
|
||||
|
||||
const IMAGE_MIME_RE = /^image\/(png|jpe?g|gif|webp|bmp|avif|svg\+xml)$/i
|
||||
|
||||
/** 是不是能当图片处理的 mime */
|
||||
export function isImageMime(mime: string | undefined | null): boolean {
|
||||
if (!mime) return false
|
||||
return IMAGE_MIME_RE.test(mime.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩略图目标宽度: 只缩不放 (放大会糊, 而且白白变大)
|
||||
*/
|
||||
export function pickThumbWidth(srcWidth: number, maxWidth: number = THUMB_MAX_WIDTH): number {
|
||||
if (!Number.isFinite(srcWidth) || srcWidth <= 0) return maxWidth
|
||||
return Math.min(srcWidth, maxWidth)
|
||||
}
|
||||
|
||||
/**
|
||||
* 选缩略图编码。
|
||||
*
|
||||
* 优先 PNG —— 它保透明通道 (截图转 JPEG 会变成黑底, 很难看);
|
||||
* 但 PNG 体积大, 照片类会超限, 那就退 JPEG。
|
||||
*
|
||||
* @returns 'png' | 'jpeg' | null (两种都超限 -> 调用方再缩一档)
|
||||
*/
|
||||
export function chooseThumbEncoding(
|
||||
pngBytes: number,
|
||||
jpegBytes: number,
|
||||
limit: number = THUMB_MAX_BYTES,
|
||||
): 'png' | 'jpeg' | null {
|
||||
if (pngBytes <= limit) return 'png'
|
||||
if (jpegBytes <= limit) return 'jpeg'
|
||||
return null
|
||||
}
|
||||
|
||||
export type ImageSourceKind = 'file' | 'dataUrl'
|
||||
|
||||
export interface ImageSource {
|
||||
kind: ImageSourceKind
|
||||
/** kind='file' 时: 本机磁盘上的图片文件 */
|
||||
path?: string
|
||||
/** kind='dataUrl' 时: 完整的 data URL */
|
||||
dataUrl?: string
|
||||
mime?: string
|
||||
/** 原始字节的 base64 (自带 body.dataBase64 的粘贴图片才有) */
|
||||
base64?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息体里挑"这张图的来源"。
|
||||
*
|
||||
* 优先级: **本地文件 > 内嵌数据**。
|
||||
* - 本地文件: 自己发的用 localPath (原图), 对方发的用 savedPath (落地后的原图)
|
||||
* - 内嵌数据: thumbDataUrl (缩略图) 或 dataBase64 (粘贴的原始字节)
|
||||
*
|
||||
* 注意: 发送方 DB 里的 savedPath 其实是**接收方**的磁盘路径 (来自上传响应),
|
||||
* 对发送方自己没用 —— 所以自己发的消息必须优先用 localPath。
|
||||
*/
|
||||
export function pickImageSource(body: any, isSelf: boolean): ImageSource | null {
|
||||
const b = body || {}
|
||||
const mime: string | undefined = typeof b.mime === 'string' ? b.mime : undefined
|
||||
|
||||
const localPath = typeof b.localPath === 'string' && b.localPath ? b.localPath : undefined
|
||||
const savedPath = typeof b.savedPath === 'string' && b.savedPath ? b.savedPath : undefined
|
||||
// 自己发的优先 localPath, 对方发的优先 savedPath
|
||||
const path = (isSelf ? (localPath || savedPath) : (savedPath || localPath))
|
||||
|
||||
const thumb = typeof b.thumbDataUrl === 'string' && b.thumbDataUrl.startsWith('data:image/')
|
||||
? b.thumbDataUrl : undefined
|
||||
const raw = typeof b.dataBase64 === 'string' && b.dataBase64 ? b.dataBase64 : undefined
|
||||
|
||||
if (path) return { kind: 'file', path, mime, dataUrl: thumb, base64: raw }
|
||||
if (raw && mime) return { kind: 'dataUrl', dataUrl: `data:${mime};base64,${raw}`, mime }
|
||||
if (thumb) return { kind: 'dataUrl', dataUrl: thumb, mime }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息体里有没有"能直接显示"的缩略图。
|
||||
* 没有的话渲染层会走 IPC 现场生成 (老消息 / 老版本对端都没带缩略图)。
|
||||
*/
|
||||
export function hasInlineThumb(body: any): boolean {
|
||||
const t = (body || {}).thumbDataUrl
|
||||
return typeof t === 'string' && t.startsWith('data:image/') && t.length > 32
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// 图片的"重活"都在这: 解码、缩略图、剪贴板。
|
||||
//
|
||||
// 用 Electron 的 nativeImage (Chromium 的图片解码器) 而不是引入 sharp/jimp ——
|
||||
// Electron 本来就带着它, 零额外依赖, 而且 JPEG/PNG/WebP/BMP/GIF(首帧)都能解。
|
||||
//
|
||||
// 两个用途:
|
||||
// 1. makeThumb* —— 生成缩略图 data URL, 塞进消息体, 气泡里直接显示
|
||||
// 2. copyToClipboard —— 右键"复制图片", 写的是**原图**不是缩略图
|
||||
|
||||
import { nativeImage, clipboard } from 'electron'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import {
|
||||
THUMB_MAX_BYTES, THUMB_MAX_SOURCE_BYTES, THUMB_JPEG_QUALITY, THUMB_FALLBACK_WIDTH,
|
||||
pickThumbWidth, chooseThumbEncoding, type ImageSource,
|
||||
} from './image-core'
|
||||
|
||||
/** 缩略图缓存: key = 路径+大小+mtime。同一张图只解码一次 */
|
||||
const thumbCache = new Map<string, string>()
|
||||
const THUMB_CACHE_MAX = 200
|
||||
|
||||
function cachePut(key: string, val: string) {
|
||||
if (thumbCache.size >= THUMB_CACHE_MAX) {
|
||||
// 简单 FIFO 淘汰 (Map 保持插入顺序)
|
||||
const first = thumbCache.keys().next()
|
||||
if (!first.done) thumbCache.delete(first.value)
|
||||
}
|
||||
thumbCache.set(key, val)
|
||||
}
|
||||
|
||||
/** 缩略图 data URL; 解不了/太大就返回 undefined (调用方降级成文件卡片) */
|
||||
export function makeThumbFromFile(filePath: string): string | undefined {
|
||||
if (!filePath) return undefined
|
||||
try {
|
||||
if (!existsSync(filePath)) return undefined
|
||||
const st = statSync(filePath)
|
||||
if (!st.isFile()) return undefined
|
||||
if (st.size > THUMB_MAX_SOURCE_BYTES) return undefined
|
||||
|
||||
const key = `${filePath}|${st.size}|${st.mtimeMs}`
|
||||
const hit = thumbCache.get(key)
|
||||
if (hit) return hit
|
||||
|
||||
const img = nativeImage.createFromPath(filePath)
|
||||
if (!img || img.isEmpty()) return undefined
|
||||
const out = encodeThumb(img)
|
||||
if (out) cachePut(key, out)
|
||||
return out
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 data URL 生成缩略图 (粘贴图片 / 已有缩略图再缩) */
|
||||
export function makeThumbFromDataUrl(dataUrl: string): string | undefined {
|
||||
if (!dataUrl || !dataUrl.startsWith('data:image/')) return undefined
|
||||
try {
|
||||
const img = nativeImage.createFromDataURL(dataUrl)
|
||||
if (!img || img.isEmpty()) return undefined
|
||||
return encodeThumb(img)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 从原始字节生成缩略图 (剪贴板粘贴的图片) */
|
||||
export function makeThumbFromBuffer(buf: Buffer): string | undefined {
|
||||
if (!buf || buf.length === 0) return undefined
|
||||
if (buf.length > THUMB_MAX_SOURCE_BYTES) return undefined
|
||||
try {
|
||||
const img = nativeImage.createFromBuffer(buf)
|
||||
if (!img || img.isEmpty()) return undefined
|
||||
return encodeThumb(img)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一入口: 拿一个来源, 尽量给它出个缩略图 */
|
||||
export function makeThumb(src: ImageSource | null | undefined): string | undefined {
|
||||
if (!src) return undefined
|
||||
if (src.kind === 'file' && src.path) {
|
||||
const t = makeThumbFromFile(src.path)
|
||||
if (t) return t
|
||||
// 本地文件解不了 -> 退回内嵌数据
|
||||
if (src.base64 && src.mime) return makeThumbFromBuffer(Buffer.from(src.base64, 'base64'))
|
||||
if (src.dataUrl) return makeThumbFromDataUrl(src.dataUrl)
|
||||
return undefined
|
||||
}
|
||||
if (src.dataUrl) {
|
||||
// 已经是 data URL: 如果本身就是小图, 直接用; 否则再缩
|
||||
if (src.dataUrl.length <= THUMB_MAX_BYTES * 2) return src.dataUrl
|
||||
return makeThumbFromDataUrl(src.dataUrl)
|
||||
}
|
||||
if (src.base64) return makeThumbFromBuffer(Buffer.from(src.base64, 'base64'))
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩略图编码: 缩到目标宽度 -> 按"质量阶梯"找一个够小的编码.
|
||||
*
|
||||
* 阶梯顺序是有讲究的:
|
||||
* 1) PNG 优先 —— 保透明通道 (截图转 JPEG 会变黑底, 很难看); 够小就直接用
|
||||
* 2) JPEG 降质量 (80 -> 60) —— 比缩尺寸更能保住细节
|
||||
* 3) 再缩尺寸 (320 -> 240)
|
||||
* 4) 兜底给最小的一档 (宁可超限也别没有图)
|
||||
*/
|
||||
function encodeThumb(img: Electron.NativeImage): string | undefined {
|
||||
const { width, height } = img.getSize()
|
||||
if (!width || !height) return undefined
|
||||
const target = pickThumbWidth(width)
|
||||
const resized = target < width ? img.resize({ width: target, quality: 'good' }) : img
|
||||
|
||||
// 1) 先按"PNG 优先, 超限退 JPEG"选一次
|
||||
const png = resized.toPNG()
|
||||
const jpeg = resized.toJPEG(THUMB_JPEG_QUALITY)
|
||||
const enc = chooseThumbEncoding(png.length, jpeg.length)
|
||||
if (enc === 'png') return `data:image/png;base64,${png.toString('base64')}`
|
||||
if (enc === 'jpeg') return `data:image/jpeg;base64,${jpeg.toString('base64')}`
|
||||
|
||||
// 2) JPEG 降质量 (比缩尺寸更能保住细节)
|
||||
for (const q of [60]) {
|
||||
const j2 = resized.toJPEG(q)
|
||||
if (j2.length <= THUMB_MAX_BYTES) return `data:image/jpeg;base64,${j2.toString('base64')}`
|
||||
}
|
||||
|
||||
// 3) 缩尺寸再试
|
||||
for (const w of [THUMB_FALLBACK_WIDTH, 240]) {
|
||||
const small = img.resize({ width: w, quality: 'good' })
|
||||
const j3 = small.toJPEG(70)
|
||||
if (j3.length <= THUMB_MAX_BYTES) return `data:image/jpeg;base64,${j3.toString('base64')}`
|
||||
const p2 = small.toPNG()
|
||||
if (p2.length <= THUMB_MAX_BYTES) return `data:image/png;base64,${p2.toString('base64')}`
|
||||
}
|
||||
|
||||
// 4) 兜底: 最小的一档, 宁可略超限也别让气泡退回文件卡片
|
||||
const tiny = img.resize({ width: THUMB_FALLBACK_WIDTH, quality: 'good' }).toJPEG(60)
|
||||
return `data:image/jpeg;base64,${tiny.toString('base64')}`
|
||||
}
|
||||
|
||||
export interface CopyResult { ok: boolean; reason?: string }
|
||||
|
||||
/**
|
||||
* 复制图片到系统剪贴板。
|
||||
*
|
||||
* 写的是**原图** (本地文件 / 原始字节), 不是气泡里那个缩略图 —— 粘到
|
||||
* 微信/文档里要能直接用。原生图解不了时退回缩略图。
|
||||
*/
|
||||
export function copyToClipboard(src: ImageSource | null | undefined): CopyResult {
|
||||
if (!src) return { ok: false, reason: '没有可复制的图片' }
|
||||
try {
|
||||
let img: Electron.NativeImage | null = null
|
||||
|
||||
if (src.kind === 'file' && src.path) {
|
||||
img = nativeImage.createFromPath(src.path)
|
||||
}
|
||||
if ((!img || img.isEmpty()) && src.base64) {
|
||||
img = nativeImage.createFromBuffer(Buffer.from(src.base64, 'base64'))
|
||||
}
|
||||
if ((!img || img.isEmpty()) && src.dataUrl) {
|
||||
img = nativeImage.createFromDataURL(src.dataUrl)
|
||||
}
|
||||
if (!img || img.isEmpty()) {
|
||||
return { ok: false, reason: '图片无法解码 (格式不支持, 或文件已被删除)' }
|
||||
}
|
||||
clipboard.writeImage(img)
|
||||
return { ok: true }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读本地图片成 data URL —— 给"点开看大图"用 (气泡里是缩略图, 放大要原图)。
|
||||
* 超大文件直接拒绝, 免得把几百 MB 塞进 IPC。
|
||||
*/
|
||||
export function readImageDataUrl(filePath: string, maxBytes = THUMB_MAX_SOURCE_BYTES): { ok: boolean; dataUrl?: string; reason?: string } {
|
||||
if (!filePath) return { ok: false, reason: '没有本地文件' }
|
||||
try {
|
||||
if (!existsSync(filePath)) return { ok: false, reason: '文件不存在' }
|
||||
const st = statSync(filePath)
|
||||
if (!st.isFile()) return { ok: false, reason: '不是文件' }
|
||||
if (st.size > maxBytes) return { ok: false, reason: '图片太大, 无法预览原图' }
|
||||
const img = nativeImage.createFromPath(filePath)
|
||||
if (!img || img.isEmpty()) return { ok: false, reason: '图片无法解码' }
|
||||
return { ok: true, dataUrl: img.toDataURL() }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 只给自测用: 清缓存 */
|
||||
export function __clearThumbCache() {
|
||||
thumbCache.clear()
|
||||
}
|
||||
+31
-5
@@ -16,9 +16,12 @@ import { ChatClient } from './chat-client'
|
||||
import { FileServer } from './file-server'
|
||||
import { TerminalManager } from './remote/terminal'
|
||||
import { ForwardManager } from './remote/forward'
|
||||
import { UsbBridgeManager } from './remote/usb'
|
||||
import { SerialManager } from './remote/serial'
|
||||
import { registerIpc, bindNetworkContext, collectWsState } from './ipc'
|
||||
import { listDevices, totalUnread, getUnread, pruneAudit } from './db'
|
||||
import { RelationStore } from './relations'
|
||||
import { ManifestStore } from './sync-manifest'
|
||||
import { RelationService } from './relation-service'
|
||||
import { setBadgeCount } from './notify'
|
||||
import { DEFAULT_PORTS, PROTOCOL_VERSION } from './protocol'
|
||||
import type { DeviceInfo } from './protocol'
|
||||
@@ -60,14 +63,31 @@ async function main() {
|
||||
const discovery = new Discovery(self)
|
||||
const chatServer = new ChatServer(DEFAULT_PORTS.chat)
|
||||
const chatClient = new ChatClient(self)
|
||||
|
||||
// 关系层: 设备之间的持久绑定 + 分级授权 (网盘/同步都按关系授权)
|
||||
const relations = new RelationStore(join(paths.appData, 'relations.json'))
|
||||
const manifestStore = new ManifestStore(join(paths.appData, 'sync-manifest.json'))
|
||||
const relationService = new RelationService({
|
||||
store: relations,
|
||||
manifestStore,
|
||||
self,
|
||||
send: (peerId, frame) => chatClient.send(frame, peerId),
|
||||
notify: (event, payload) => broadcastToRenderer(event, payload),
|
||||
})
|
||||
|
||||
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir(), {
|
||||
getDriveRoot: () => ensureDriveDir(),
|
||||
getDriveToken: () => getDriveToken(),
|
||||
isDriveEnabled: () => getSettings().driveEnabled !== false,
|
||||
// 新版: 按对方签发的 per-peer 令牌鉴权 (关系表 + 授权范围)
|
||||
authorize: (token, peerId) => relationService.authorizeDrive(token, peerId),
|
||||
getSyncScope: (relationId, peerId) => relationService.getSyncScope(relationId, peerId),
|
||||
getSyncManifest: ({ relationId, peerId }) => relationService.getSyncManifest(relationId, peerId),
|
||||
onSyncManifest: ({ relationId, peerId, wire }) => relationService.applyPeerManifest(relationId, wire, peerId),
|
||||
})
|
||||
const terminal = new TerminalManager(chatClient, self.deviceId)
|
||||
const forward = new ForwardManager(chatClient)
|
||||
const usb = new UsbBridgeManager(chatClient)
|
||||
const serial = new SerialManager(chatClient)
|
||||
|
||||
const chatPort = await chatServer.start()
|
||||
const filePort = await fileServer.start()
|
||||
@@ -82,11 +102,14 @@ async function main() {
|
||||
() => fileServer.stop(),
|
||||
() => terminal.stop(),
|
||||
() => forward.stop(),
|
||||
() => usb.stop(),
|
||||
() => serial.stop(),
|
||||
)
|
||||
|
||||
await discovery.start()
|
||||
bindNetworkContext({ self, discovery, chatServer, chatClient, fileServer, terminal, forward, usb })
|
||||
bindNetworkContext({
|
||||
self, discovery, chatServer, chatClient, fileServer, terminal, forward, serial,
|
||||
relations, manifestStore, relationService,
|
||||
})
|
||||
|
||||
// 已入库的设备: 启动后主动尝试连接
|
||||
for (const d of listDevices()) {
|
||||
@@ -134,7 +157,10 @@ function buildSelf(): DeviceInfo {
|
||||
chatPort: 0,
|
||||
filePort: 0,
|
||||
ts: Date.now(),
|
||||
driveToken: getDriveToken(),
|
||||
// 注意: 这里**故意不带 driveToken**。
|
||||
// 网盘访问改成"按对方签发的 per-peer 令牌"(关系层), 全局 token 不再明文广播,
|
||||
// 否则同网段任何进程拿到广播包就能浏览你的整个网盘。
|
||||
// 老版本对端仍可以用本机已缓存的旧 token 访问 (渐进式升级)。
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+368
-71
@@ -18,15 +18,23 @@ 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 { scanSync, applySyncItems, type SyncApplyItem } from './sync'
|
||||
import { RelationStore, type Grant } from './relations'
|
||||
import { ManifestStore } from './sync-manifest'
|
||||
import { AutoSync } from './auto-sync'
|
||||
import { RelationService } from './relation-service'
|
||||
import { broadcastToRenderer, focusMainWindow } from './window'
|
||||
import { rebuildMenu } from './tray'
|
||||
import { notify, setBadgeCount } from './notify'
|
||||
import { TerminalManager } from './remote/terminal'
|
||||
import { ForwardManager } from './remote/forward'
|
||||
import { UsbBridgeManager } from './remote/usb'
|
||||
import { SerialManager } from './remote/serial'
|
||||
import { diagnoseSerial, diagnoseAndFixSerial, hasSocat, virtualSerialHint } from './permissions'
|
||||
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 { makeThumb, makeThumbFromFile, makeThumbFromBuffer, copyToClipboard, readImageDataUrl } from './image'
|
||||
import type { ImageSource } from './image-core'
|
||||
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, SerialFrame, SerialDirection, SerialAttachConfig, WsFrame } from './protocol'
|
||||
import { DEFAULT_PORTS } from './protocol'
|
||||
|
||||
interface NetCtx {
|
||||
@@ -37,7 +45,12 @@ interface NetCtx {
|
||||
fileServer: FileServer
|
||||
terminal: TerminalManager
|
||||
forward: ForwardManager
|
||||
usb: UsbBridgeManager
|
||||
serial: SerialManager
|
||||
relations: RelationStore
|
||||
manifestStore: ManifestStore
|
||||
relationService: RelationService
|
||||
/** 自动同步 (在 bindNetworkContext 里创建) */
|
||||
autoSync?: AutoSync
|
||||
}
|
||||
|
||||
let ctx: NetCtx | null = null
|
||||
@@ -93,8 +106,60 @@ function disarmPendingMetaTimer(fileId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 找一个对端 (优先用在线的 discovery 信息, 离线时退回设备表)。
|
||||
* 有关系时把 driveToken 换成关系签发的 per-peer 令牌。
|
||||
*/
|
||||
function findPeer(peerId: string): DeviceInfo | null {
|
||||
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
|
||||
if (p) {
|
||||
// 有关系时用关系签发的 per-peer 令牌 (广播包里已经不再带全局 driveToken 了)
|
||||
const rel = ctx?.relations.getActiveRelationByPeer(peerId)
|
||||
if (rel?.peerToken) return { ...(p as DeviceInfo), driveToken: rel.peerToken }
|
||||
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
|
||||
}
|
||||
|
||||
// 关系对渲染层的视图: **剥掉令牌** (界面不需要, 也不该拿到凭据)
|
||||
function publicRelation(r: any) {
|
||||
if (!r) return r
|
||||
const { myToken, peerToken, ...rest } = r
|
||||
return rest
|
||||
}
|
||||
|
||||
// 关系帧路由: 建立 / 接受 / 拒绝 (两个方向都走这里)
|
||||
// 返回 true = 这个帧已经处理完了, 调用方不用再管
|
||||
function routeRelationFrame(peer: DeviceInfo, frame: WsFrame): boolean { if (frame.type === 'relation:request') {
|
||||
ctx?.relationService.handleRelationRequest(peer, frame.payload)
|
||||
.catch((e) => console.warn('[relation] handle request error:', e?.message))
|
||||
return true
|
||||
}
|
||||
if (frame.type === 'relation:accept') { ctx?.relationService.handleRelationAccept(peer, frame.payload); return true }
|
||||
if (frame.type === 'relation:reject') { ctx?.relationService.handleRelationReject(peer, frame.payload); return true }
|
||||
if (frame.type === 'relation:revoked') { ctx?.relationService.handleRelationRevoked(peer, frame.payload.relationId); return true }
|
||||
return false
|
||||
}
|
||||
|
||||
export function bindNetworkContext(c: NetCtx) {
|
||||
ctx = c
|
||||
|
||||
// 自动同步: 关系开了开关就定时跑一轮 (冲突跳过, 不静默覆盖)
|
||||
c.autoSync = new AutoSync({
|
||||
relations: c.relations,
|
||||
manifestStore: c.manifestStore,
|
||||
findPeer: (peerId) => findPeer(peerId),
|
||||
notify: (event, payload) => broadcastToRenderer(event, payload),
|
||||
})
|
||||
c.autoSync.start()
|
||||
|
||||
// 网络事件 -> 渲染进程
|
||||
c.discovery.on('found', (d) => {
|
||||
// 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连
|
||||
@@ -209,10 +274,12 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
|
||||
return
|
||||
}
|
||||
// 线上帧类型还是 'usb' (兼容老版本), 载荷是串口专用的
|
||||
if (frame.type === 'usb') {
|
||||
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||
ctx!.serial.handleIncoming(peer, frame.payload as SerialFrame)
|
||||
return
|
||||
}
|
||||
if (routeRelationFrame(peer, frame)) return
|
||||
})
|
||||
|
||||
// chat-server 收到的所有 ws 帧 (来自其他设备的入站)
|
||||
@@ -229,10 +296,12 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
|
||||
return
|
||||
}
|
||||
// 线上帧类型还是 'usb' (兼容老版本), 载荷是串口专用的
|
||||
if (frame.type === 'usb') {
|
||||
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||
ctx!.serial.handleIncoming(peer, frame.payload as SerialFrame)
|
||||
return
|
||||
}
|
||||
if (routeRelationFrame(peer, frame)) return
|
||||
})
|
||||
|
||||
c.chatServer.on('connect', (_ws, peer) => {
|
||||
@@ -242,6 +311,8 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
|
||||
// 对端上线 -> 触发离线消息 flush
|
||||
flushPendingFor(peer.deviceId)
|
||||
// 对端上线 -> 立刻试一次同步 (不用等下一个轮询周期)
|
||||
c.autoSync?.trigger()
|
||||
})
|
||||
|
||||
// chat-client WS 状态不广播给渲染层, 渲染层只看 discovery 的 online/offline
|
||||
@@ -298,6 +369,18 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
broadcastToRenderer('drive:inbound', { name: info.name, size: info.size, peerId: peer?.deviceId, peerName: who })
|
||||
})
|
||||
|
||||
// 对端往我的网盘上传了文件 (文件夹同步的上行) -> 本机提示 + 审计
|
||||
c.fileServer.on('driveUploaded', (info: { relPath: string; size: number; fromAddr: string }) => {
|
||||
const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr)
|
||||
const who = peer ? peer.name : info.fromAddr || '某台设备'
|
||||
recordAudit({
|
||||
action: 'drive.upload', source: peer?.deviceId || info.fromAddr,
|
||||
result: 'ok',
|
||||
payload: { size: info.size, path: info.relPath },
|
||||
})
|
||||
broadcastToRenderer('drive:inbound', { name: basename(info.relPath), size: info.size, peerId: peer?.deviceId, peerName: who })
|
||||
})
|
||||
|
||||
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
|
||||
c.chatServer.on('recall', (from, messageId) => {
|
||||
const row = getMessage(messageId)
|
||||
@@ -329,7 +412,7 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
}
|
||||
})
|
||||
|
||||
// ===== Remote: terminal / forward / usb 事件桥接 =====
|
||||
// ===== Remote: terminal / forward / serial 事件桥接 =====
|
||||
c.terminal.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
|
||||
// 控制端从被控端收到 output -> 给 UI
|
||||
broadcastToRenderer('remote:terminal:output', { sessionId, peerId, data: bytes.toString('base64') })
|
||||
@@ -357,27 +440,27 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'forward' })
|
||||
})
|
||||
|
||||
c.usb.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
|
||||
broadcastToRenderer('remote:usb:output', { sessionId, peerId, data: bytes.toString('base64') })
|
||||
c.serial.on('output', (sessionId: string, bytes: Buffer, peerId: string, dir: 'rx' | 'tx') => {
|
||||
broadcastToRenderer('remote:serial:output', { sessionId, peerId, dir, data: bytes.toString('base64') })
|
||||
})
|
||||
c.usb.on('sessionOpened', (info) => {
|
||||
broadcastToRenderer('remote:usb:opened', info)
|
||||
c.serial.on('sessionOpened', (info) => {
|
||||
broadcastToRenderer('remote:serial:opened', info)
|
||||
})
|
||||
c.usb.on('sessionClosed', (info) => {
|
||||
broadcastToRenderer('remote:usb:closed', info)
|
||||
c.serial.on('sessionClosed', (info) => {
|
||||
broadcastToRenderer('remote:serial:closed', info)
|
||||
})
|
||||
c.usb.on('sessionError', (info) => {
|
||||
broadcastToRenderer('remote:usb:error', info)
|
||||
c.serial.on('sessionError', (info) => {
|
||||
broadcastToRenderer('remote:serial:error', info)
|
||||
})
|
||||
c.usb.on('approvalRequested', (req) => {
|
||||
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'usb' })
|
||||
c.serial.on('approvalRequested', (req) => {
|
||||
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'serial' })
|
||||
})
|
||||
|
||||
// 对端 ws 断开 -> 清理该对端的所有 session
|
||||
c.chatClient.on('close', (peer) => {
|
||||
c.terminal.onPeerDisconnected(peer.deviceId)
|
||||
c.forward.onPeerDisconnected(peer.deviceId)
|
||||
c.usb.onPeerDisconnected(peer.deviceId)
|
||||
c.serial.onPeerDisconnected(peer.deviceId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -531,8 +614,9 @@ export function registerIpc() {
|
||||
ctx.chatClient.remove(deviceId) // 停掉 backoff 重连
|
||||
ctx.discovery.forget(deviceId) // 从 in-memory peers Map 移除
|
||||
broadcastToRenderer('device:lost', { deviceId }) // 通知 renderer 把它从 device store 摘掉
|
||||
// 清掉对应未读
|
||||
// 清掉对应未读 (顺手刷一次任务栏角标 —— 少了这步, 删了设备角标数字还挂着)
|
||||
clearUnread(deviceId)
|
||||
updateBadge()
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -626,6 +710,10 @@ export function registerIpc() {
|
||||
const messageId = randomUUID()
|
||||
const peerInfo: any = peer
|
||||
|
||||
// 图片: 现场生成缩略图塞进消息体 —— 发送方生成一次, 对方直接拿来显示,
|
||||
// 不用在对端再解码一遍 (也顺带让"老版本对端"能显示)。
|
||||
const thumbDataUrl = args.asImage ? makeThumbFromFile(args.localPath) : undefined
|
||||
|
||||
// 1. 构造初始 envelope (savedPath=null, hash=null) - 气泡先占位, 进度条 0%
|
||||
const baseBody = {
|
||||
type: args.asImage ? 'image' : 'file',
|
||||
@@ -635,6 +723,7 @@ export function registerIpc() {
|
||||
savedPath: null as string | null,
|
||||
hash: null as string | null,
|
||||
localPath: args.localPath, // retry 时复用
|
||||
...(thumbDataUrl ? { thumbDataUrl } : {}),
|
||||
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||
}
|
||||
const env: MessageEnvelope = {
|
||||
@@ -737,12 +826,18 @@ export function registerIpc() {
|
||||
const fileId = randomUUID()
|
||||
const messageId = randomUUID()
|
||||
|
||||
// 缩略图: 小图直接用原字节 (省一次重编码), 大图现生成缩略图 ——
|
||||
// 以前大图不带缩略图, 气泡只能显示成"[图片] xxx.png"的文件卡片。
|
||||
const thumbDataUrl = buf.length < 200_000
|
||||
? `data:${args.mime};base64,${args.dataBase64}`
|
||||
: makeThumbFromBuffer(buf)
|
||||
|
||||
const baseBody: ImageMessage = {
|
||||
type: 'image',
|
||||
messageId, ts: Date.now(),
|
||||
fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||
fileId, name: args.name, size: buf.length, mime: args.mime,
|
||||
thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
|
||||
thumbDataUrl,
|
||||
dataBase64: args.dataBase64,
|
||||
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||
}
|
||||
@@ -1031,26 +1126,54 @@ export function registerIpc() {
|
||||
return { path: p, name: basename(p), size: st.size }
|
||||
})
|
||||
|
||||
// ===== 图片: 缩略图 / 复制 / 看原图 =====
|
||||
//
|
||||
// 消息体里的 thumbDataUrl 是"发送方生成、双方共用"的缩略图。
|
||||
// 但老消息 (落库时还没有缩略图) 和老版本对端发来的消息都没有它 ——
|
||||
// 这时渲染层会退到 image:thumb 现场生成。
|
||||
|
||||
// 现场生成缩略图 (渲染层懒加载兜底)
|
||||
ipcMain.handle('image:thumb', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => {
|
||||
try {
|
||||
const src: ImageSource = args.path
|
||||
? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 }
|
||||
: { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 }
|
||||
const dataUrl = makeThumb(src)
|
||||
if (!dataUrl) return { ok: false, reason: '无法生成缩略图' }
|
||||
return { ok: true, dataUrl }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 右键"复制图片" —— 写的是原图, 不是缩略图
|
||||
ipcMain.handle('image:copy', async (_e, args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) => {
|
||||
try {
|
||||
const src: ImageSource = args.path
|
||||
? { kind: 'file', path: args.path, mime: args.mime, dataUrl: args.dataUrl, base64: args.base64 }
|
||||
: { kind: 'dataUrl', dataUrl: args.dataUrl, mime: args.mime, base64: args.base64 }
|
||||
return copyToClipboard(src)
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 点开看大图 —— 读本地原图成 data URL (气泡里是缩略图)
|
||||
ipcMain.handle('image:dataUrl', async (_e, args: { path?: string }) => {
|
||||
if (!args?.path) return { ok: false, reason: '没有本地文件' }
|
||||
return readImageDataUrl(args.path)
|
||||
})
|
||||
|
||||
// ===== 网盘 (浏览对方共享目录 + 选文件下载) =====
|
||||
|
||||
// 找 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: '找不到该设备' }
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
|
||||
return listDrive(peer, args.path || '')
|
||||
})
|
||||
|
||||
@@ -1058,6 +1181,7 @@ export function registerIpc() {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
|
||||
return getDriveRoot(peer)
|
||||
})
|
||||
|
||||
@@ -1127,6 +1251,150 @@ export function registerIpc() {
|
||||
return r.filePaths[0]
|
||||
})
|
||||
|
||||
// ===== 文件夹同步 (本地目录 <-> 对方网盘目录) =====
|
||||
|
||||
ipcMain.handle('sync:pickLocalDir', async () => {
|
||||
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||
if (!w) return null
|
||||
const cur = getSettings().syncLocalDir
|
||||
const r = await dialog.showOpenDialog(w, {
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: cur && existsSync(cur) ? cur : undefined,
|
||||
})
|
||||
if (r.canceled || !r.filePaths[0]) return null
|
||||
return r.filePaths[0]
|
||||
})
|
||||
|
||||
// 扫描两边, 生成变更列表 (只读磁盘; 关系表会被更新, 但那只是记录"我这边变了")
|
||||
ipcMain.handle('sync:scan', async (_e, args: {
|
||||
peerId: string; localDir?: string; remotePath?: string; relationId?: string
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
|
||||
// 有关系就用关系签发的 per-peer 令牌; 没有关系退回旧的全局 token (老版本对端)
|
||||
let token = ''
|
||||
if (rel && rel.state === 'active') {
|
||||
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系' }
|
||||
token = rel.peerToken
|
||||
} else {
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
|
||||
token = peer.driveToken
|
||||
}
|
||||
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
|
||||
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
|
||||
if (!localDir) return { ok: false, reason: '还没选本地目录' }
|
||||
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
|
||||
const r = await scanSync({
|
||||
peer, localDir, remotePath, token,
|
||||
relationId: rel?.relationId,
|
||||
store: ctx.manifestStore,
|
||||
selfDeviceId: ctx.self.deviceId,
|
||||
})
|
||||
if (r.ok && !rel) {
|
||||
updateSettings({ syncLocalDir: r.localDir, syncRemotePath: r.remotePath })
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
// 执行用户勾选的同步项
|
||||
ipcMain.handle('sync:apply', async (_e, args: {
|
||||
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
|
||||
items: SyncApplyItem[]
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx', results: [], done: 0, failed: 0 }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备', results: [], done: 0, failed: 0 }
|
||||
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
|
||||
let token = ''
|
||||
if (rel && rel.state === 'active') {
|
||||
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系', results: [], done: 0, failed: 0 }
|
||||
token = rel.peerToken
|
||||
} else {
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系', results: [], done: 0, failed: 0 }
|
||||
token = peer.driveToken
|
||||
}
|
||||
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
|
||||
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
|
||||
if (!localDir) return { ok: false, reason: '还没选本地目录', results: [], done: 0, failed: 0 }
|
||||
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
|
||||
const items = (args.items || []).filter(i => i && i.relPath)
|
||||
const r = await applySyncItems({
|
||||
peer, localDir, remotePath, token,
|
||||
relationId: rel?.relationId,
|
||||
store: ctx.manifestStore,
|
||||
selfDeviceId: ctx.self.deviceId,
|
||||
items,
|
||||
onProgress: (relPath, sent, total) => {
|
||||
broadcastToRenderer('sync:progress', { peerId: args.peerId, relPath, sent, total })
|
||||
},
|
||||
})
|
||||
recordAudit({
|
||||
action: 'sync.apply', target: args.peerId,
|
||||
result: r.failed === 0 ? 'ok' : 'error',
|
||||
payload: { count: items.length, done: r.done, failed: r.failed, localDir, remotePath },
|
||||
})
|
||||
return r
|
||||
})
|
||||
|
||||
// ===== 关系 (同步盘) =====
|
||||
//
|
||||
// 关系 = 两个设备之间的持久绑定 + 分级授权。
|
||||
// 建立方式跟远程终端一样: **对方直接弹窗**, 不用邀请码。
|
||||
|
||||
ipcMain.handle('relation:list', () => (ctx?.relations.listRelations() || []).map(publicRelation))
|
||||
|
||||
// 发起建立关系 -> 对方那边弹窗
|
||||
ipcMain.handle('relation:request', (_e, args: {
|
||||
peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: Grant[]
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
return ctx.relationService.requestRelation({
|
||||
peerId: peer.deviceId,
|
||||
peerName: peer.name,
|
||||
peerAddress: peer.address,
|
||||
peerChatPort: peer.chatPort,
|
||||
peerFilePort: peer.filePort,
|
||||
syncLocalDir: args.syncLocalDir || '',
|
||||
syncRemotePath: args.syncRemotePath || '',
|
||||
grants: (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
|
||||
})
|
||||
})
|
||||
|
||||
// 改我开放给对方的能力
|
||||
ipcMain.handle('relation:setGrants', (_e, args: { relationId: string; grants: Grant[] }) => {
|
||||
const grants = (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync')
|
||||
const r = ctx?.relations.setMyGrants(args.relationId, grants)
|
||||
return { ok: !!r, relation: publicRelation(r) }
|
||||
})
|
||||
|
||||
// 改同步配置 (本地目录 / 对方网盘子目录 / 自动同步开关)
|
||||
ipcMain.handle('relation:setSync', (_e, args: {
|
||||
relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean
|
||||
}) => {
|
||||
const patch: any = {}
|
||||
if (args.syncLocalDir !== undefined) patch.syncLocalDir = args.syncLocalDir
|
||||
if (args.syncRemotePath !== undefined) patch.syncRemotePath = args.syncRemotePath
|
||||
if (args.syncEnabled !== undefined) patch.syncEnabled = !!args.syncEnabled
|
||||
const r = ctx?.relations.setSyncConfig(args.relationId, patch)
|
||||
if (r?.syncEnabled) ctx?.autoSync?.trigger(r.relationId)
|
||||
return { ok: !!r, relation: publicRelation(r) }
|
||||
})
|
||||
|
||||
// 吊销关系 (令牌立刻失效)
|
||||
ipcMain.handle('relation:revoke', (_e, args: { relationId: string }) =>
|
||||
ctx?.relationService.revokeRelation(args.relationId) || { ok: false, reason: 'no ctx' })
|
||||
|
||||
// 彻底删除关系记录
|
||||
ipcMain.handle('relation:delete', (_e, args: { relationId: string }) => {
|
||||
const ok = ctx?.relations.deleteRelation(args.relationId) || false
|
||||
try { ctx?.manifestStore.deleteManifest(args.relationId) } catch {}
|
||||
return { ok }
|
||||
})
|
||||
|
||||
// ---- 应用控制 ----
|
||||
ipcMain.handle('app:hide', () => {
|
||||
const w = BrowserWindow.getAllWindows()[0]
|
||||
@@ -1216,67 +1484,88 @@ export function registerIpc() {
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 远程: usb (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传) =====
|
||||
// ===== 远程: 串口 (映射 + 串口助手) =====
|
||||
|
||||
ipcMain.handle('usb:list', async (_e, args: { peerId: string }) => {
|
||||
// 列对方的串口
|
||||
ipcMain.handle('serial:list', async (_e, args: { peerId: string }) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
return ctx.usb.listOnPeer(args.peerId)
|
||||
return ctx.serial.listOnPeer(args.peerId)
|
||||
})
|
||||
|
||||
// 列本机 (串口 + libusb + linux usbip) — self-in 模式用
|
||||
ipcMain.handle('usb:listLocal', async () => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
// 列本机串口 — self-in 方向用
|
||||
ipcMain.handle('serial:listLocal', async () => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx', devices: [] }
|
||||
try {
|
||||
const devices = await ctx.usb.listLocal()
|
||||
const devices = await ctx.serial.listLocal()
|
||||
return { ok: true, devices }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e), devices: [] }
|
||||
}
|
||||
})
|
||||
|
||||
// attach: 由 renderer 把 UsbAttachConfig 传过来
|
||||
ipcMain.handle('usb:attach', async (_e, args: { peerId: string; direction?: UsbDirection; busId: string; config: UsbAttachConfig }) => {
|
||||
// attach
|
||||
ipcMain.handle('serial:attach', async (_e, args: { peerId: string; direction?: SerialDirection; busId: string; config: SerialAttachConfig }) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
return ctx.usb.attach(args.peerId, { direction: args.direction, busId: args.busId, config: args.config })
|
||||
return ctx.serial.attach(args.peerId, { direction: args.direction, busId: args.busId, config: args.config })
|
||||
})
|
||||
|
||||
// 串口: 发送字节 (client 端)
|
||||
ipcMain.handle('usb:serialSend', (_e, args: { sessionId: string; dataBase64: string }) => {
|
||||
// 串口助手: 发送字节
|
||||
ipcMain.handle('serial:send', (_e, args: { sessionId: string; dataBase64: string }) => {
|
||||
if (!ctx) return false
|
||||
const buf = Buffer.from(args.dataBase64, 'base64')
|
||||
return ctx.usb.sendSerialBytes(args.sessionId, buf)
|
||||
return ctx.serial.sendSerialBytes(args.sessionId, buf)
|
||||
})
|
||||
|
||||
// USB 控制传输 (control OUT / control IN)
|
||||
ipcMain.handle('usb:ctrlOut', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; dataBase64?: string }) => {
|
||||
if (!ctx) return { ok: false, status: -1 }
|
||||
return ctx.usb.usbCtrlTransfer(args.sessionId, args.setup, args.dataBase64)
|
||||
})
|
||||
ipcMain.handle('usb:ctrlIn', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; length: number }) => {
|
||||
if (!ctx) return { ok: false, status: -1 }
|
||||
return ctx.usb.usbCtrlIn(args.sessionId, args.setup, args.length)
|
||||
})
|
||||
|
||||
// USB 批量传输 (bulk OUT / bulk IN)
|
||||
ipcMain.handle('usb:bulkOut', async (_e, args: { sessionId: string; endpoint: number; dataBase64: string }) => {
|
||||
if (!ctx) return { ok: false, status: -1 }
|
||||
return ctx.usb.usbBulkOut(args.sessionId, args.endpoint, args.dataBase64)
|
||||
})
|
||||
ipcMain.handle('usb:bulkIn', async (_e, args: { sessionId: string; endpoint: number; length: number; timeoutMs?: number }) => {
|
||||
if (!ctx) return { ok: false, status: -1 }
|
||||
return ctx.usb.usbBulkIn(args.sessionId, args.endpoint, args.length, args.timeoutMs)
|
||||
})
|
||||
|
||||
ipcMain.handle('usb:detach', async (_e, args: { sessionId: string }) => {
|
||||
ipcMain.handle('serial:detach', async (_e, args: { sessionId: string }) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
return ctx.usb.detach(args.sessionId, 'user')
|
||||
return ctx.serial.detach(args.sessionId, 'user')
|
||||
})
|
||||
|
||||
ipcMain.handle('usb:listSessions', () => {
|
||||
ipcMain.handle('serial:listSessions', () => {
|
||||
if (!ctx) return { client: [], server: [] }
|
||||
return {
|
||||
client: ctx.usb.listClientSessions(),
|
||||
server: ctx.usb.listServerSessions(),
|
||||
client: ctx.serial.listClientSessions(),
|
||||
server: ctx.serial.listServerSessions(),
|
||||
}
|
||||
})
|
||||
|
||||
// 串口权限诊断 (Linux 的 dialout 组是串口助手最常见的坑)
|
||||
ipcMain.handle('serial:diagnose', async () => {
|
||||
try {
|
||||
return { ok: true, diagnose: await diagnoseSerial() }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 一键修复: 把当前用户加进 dialout 组 (Linux) — 需要提权
|
||||
ipcMain.handle('serial:fixPerms', async () => {
|
||||
try {
|
||||
const r = await diagnoseAndFixSerial()
|
||||
return { ok: r.diagnose.ok, diagnose: r.diagnose, fix: r.fix }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 环境提示: 有没有 socat (建虚拟串口用) + 虚拟串口怎么用
|
||||
ipcMain.handle('serial:envInfo', async () => {
|
||||
return { ok: true, socat: await hasSocat(), hint: virtualSerialHint() }
|
||||
})
|
||||
|
||||
// 串口助手: 保存日志到文件
|
||||
ipcMain.handle('serial:saveLog', async (_e, args: { content: string; suggestedName?: string }) => {
|
||||
try {
|
||||
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||
const defaultName = args.suggestedName || `serial-${Date.now()}.log`
|
||||
const r = w
|
||||
? await dialog.showSaveDialog(w, { defaultPath: join(app.getPath('documents'), defaultName), filters: [{ name: '日志', extensions: ['log', 'txt'] }] })
|
||||
: await dialog.showSaveDialog({ defaultPath: defaultName })
|
||||
if (r.canceled || !r.filePath) return { ok: false, reason: '已取消' }
|
||||
await fsp.writeFile(r.filePath, args.content || '', 'utf8')
|
||||
return { ok: true, path: r.filePath }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: String(e?.message || e) }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1284,8 +1573,16 @@ export function registerIpc() {
|
||||
|
||||
ipcMain.handle('remote:approval:list', () => listPendingApprovals())
|
||||
|
||||
ipcMain.handle('remote:approval:reply', (_e, args: { requestId: string; ok: boolean; remember?: boolean }) => {
|
||||
return replyApproval(args.requestId, args.ok, args.remember)
|
||||
ipcMain.handle('remote:approval:reply', (_e, args: {
|
||||
requestId: string; ok: boolean; remember?: boolean
|
||||
grants?: string[]; syncLocalDir?: string; syncRemotePath?: string
|
||||
}) => {
|
||||
return replyApproval(args.requestId, args.ok, {
|
||||
remember: args.remember,
|
||||
grants: args.grants,
|
||||
syncLocalDir: args.syncLocalDir,
|
||||
syncRemotePath: args.syncRemotePath,
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('audit:list', (_e, args: { limit?: number; since?: number; sessionId?: string } = {}) => {
|
||||
|
||||
+65
-177
@@ -1,11 +1,13 @@
|
||||
// 跨平台权限辅助: 调 sudo-prompt 提权; 仅在确实需要时 (Linux 串口/HID/USB)
|
||||
// Windows 的串口/HID 大多数情况不需要 admin; macOS 系统弹窗 OS 接管, 我们不参与.
|
||||
// 跨平台权限辅助: 调 sudo-prompt 提权。
|
||||
//
|
||||
// 只服务**串口** (USB 字节桥/USB-IP 已下线)。
|
||||
// 串口助手最常见的翻车点就是 Linux 的权限:
|
||||
// - 找不到串口 -> 设备没插 / udev 没建节点
|
||||
// - 找到了但打不开 -> 当前用户不在 dialout 组 (Permission denied)
|
||||
// 这两件事必须分开说, 否则用户会一直以为是设备坏了。
|
||||
import { platform } from 'node:process'
|
||||
import { exec } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
@@ -36,8 +38,6 @@ function runElevated(cmd: string, name = 'LocalNetMsg'): Promise<ElevationResult
|
||||
|
||||
export type PermIssue =
|
||||
| { kind: 'linux.dialout'; detail: string }
|
||||
| { kind: 'linux.udev'; detail: string; vendorId?: number; productId?: number }
|
||||
| { kind: 'windows.driver'; detail: string; vendorId?: number; productId?: number }
|
||||
| { kind: 'macos.tcc'; detail: string }
|
||||
|
||||
export interface DiagnoseResult {
|
||||
@@ -47,81 +47,66 @@ export interface DiagnoseResult {
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断本机串口可用性。
|
||||
* 重点是 Linux: 查 dialout 组 + socat, 并把"没插设备"和"没权限"分开报。
|
||||
*/
|
||||
export async function diagnoseSerial(): Promise<DiagnoseResult> {
|
||||
const issues: PermIssue[] = []
|
||||
const notes: string[] = []
|
||||
let serialCount = 0
|
||||
const ports: { path: string; manufacturer?: string; product?: string }[] = []
|
||||
|
||||
// 1. 列串口 (serialport 可能没装)
|
||||
try {
|
||||
// 不在主进程 require serialport (可能没装); 试一下
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const spMod = require('serialport')
|
||||
const ports = await spMod.SerialPort.list()
|
||||
serialCount = ports.length
|
||||
if (platform === 'linux' && serialCount === 0) {
|
||||
issues.push({
|
||||
kind: 'linux.dialout',
|
||||
detail: '未列出任何串口. Linux 用户通常需要在 dialout 组才能访问 /dev/ttyUSB* / /dev/ttyACM*',
|
||||
})
|
||||
const list: any[] = await spMod.SerialPort.list()
|
||||
for (const p of list) {
|
||||
ports.push({ path: p.path, manufacturer: p.manufacturer, product: p.friendlyName || p.productId })
|
||||
}
|
||||
} catch (e: any) {
|
||||
notes.push(`serialport 加载失败: ${e?.message || e}`)
|
||||
}
|
||||
notes.push(ports.length
|
||||
? `发现 ${ports.length} 个串口: ${ports.map(p => p.path).join(', ')}`
|
||||
: '没有发现任何串口')
|
||||
|
||||
return {
|
||||
platform: platform as NodeJS.Platform,
|
||||
ok: issues.length === 0,
|
||||
issues,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
export async function diagnoseUsb(vendorId?: number, productId?: number): Promise<DiagnoseResult> {
|
||||
const issues: PermIssue[] = []
|
||||
const notes: string[] = []
|
||||
let found = 0
|
||||
|
||||
// Linux: 测一下能否读 /sys/bus/usb/devices (普通用户可读, 但创建设备文件需要 udev)
|
||||
// 2. Linux: dialout 组 —— 串口助手最常见的翻车点
|
||||
if (platform === 'linux') {
|
||||
let inDialout = false
|
||||
try {
|
||||
const { stdout } = await execAsync('ls /sys/bus/usb/devices 2>/dev/null | wc -l')
|
||||
found = parseInt(stdout.trim() || '0', 10)
|
||||
if (vendorId && productId) {
|
||||
const id = `${formatHex(vendorId)}:${formatHex(productId)}`
|
||||
try {
|
||||
await execAsync(`ls /sys/bus/usb/devices/*/idVendor 2>/dev/null | xargs -I{} sh -c 'cat {} | tr -d "\\n"; echo " {}"' | grep -i "^${id}" || true`)
|
||||
const { stdout } = await execAsync('id -Gn 2>/dev/null || groups 2>/dev/null || true')
|
||||
inDialout = /\bdialout\b/.test(stdout)
|
||||
} catch {}
|
||||
if (!inDialout) {
|
||||
const fix = 'sudo usermod -aG dialout $USER (改完要重新登录, 或重启该设备上的 App)'
|
||||
issues.push({
|
||||
kind: 'linux.dialout',
|
||||
detail: ports.length
|
||||
? `当前用户不在 dialout 组, 打开 ${ports.slice(0, 3).map(p => p.path).join(', ')} 会被拒 (Permission denied)。修复: ${fix}`
|
||||
: `当前用户不在 dialout 组 —— 插上 USB-串口设备 (/dev/ttyUSB*, /dev/ttyACM*) 后大概率打不开。修复: ${fix}`,
|
||||
})
|
||||
} else {
|
||||
notes.push('已在 dialout 组')
|
||||
}
|
||||
} catch {}
|
||||
if (found === 0) {
|
||||
notes.push('无法读取 /sys/bus/usb/devices — 不太常见, 请检查 udev 是否运行')
|
||||
} else if (vendorId && productId) {
|
||||
// 设备存在但能否被 node-usb 打开? 试一下
|
||||
|
||||
// 3. socat: 建本机虚拟串口 (串口映射) 靠它
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const usbMod = require('usb')
|
||||
const device = await usbMod.webusb.findDeviceByIds(vendorId, productId).catch(() => null)
|
||||
if (!device) {
|
||||
issues.push({
|
||||
kind: 'linux.udev',
|
||||
detail: `未找到设备 ${formatHex(vendorId)}:${formatHex(productId)}. 可能需要 udev 规则 (例如 SUBSYSTEM=="usb", ATTR{idVendor}=="...").`,
|
||||
vendorId, productId,
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
issues.push({
|
||||
kind: 'linux.udev',
|
||||
detail: `USB 设备访问失败: ${e?.message || e}`,
|
||||
vendorId, productId,
|
||||
})
|
||||
}
|
||||
}
|
||||
const { stdout } = await execAsync('which socat 2>/dev/null || true')
|
||||
notes.push(stdout.trim()
|
||||
? `socat: ${stdout.trim()}`
|
||||
: '未找到 socat —— 创建本机虚拟串口 (串口映射) 会失败。安装: sudo apt install socat (Debian/Ubuntu) | sudo dnf install socat (Fedora)')
|
||||
} catch {}
|
||||
|
||||
// 4. 内置串口 (/dev/ttyS*) 的设备节点一直在, 常被误当成"没有设备"
|
||||
notes.push('注: /dev/ttyS0 这类内置口即使没插线缆, 设备节点也一直存在')
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
notes.push('Windows: USB/HID 通常无需 admin; 若 node-hid 找不到设备, 用 Zadig 安装 WinUSB 驱动 (一次性, 需 admin)')
|
||||
notes.push('Windows: 串口 (COM) 一般免 admin; 若设备管理器里看不到口, 可能是驱动没装')
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
notes.push('macOS: 首次访问 USB 设备时系统会弹权限对话框, 在 系统设置 → 隐私与安全 中允许')
|
||||
notes.push('macOS: 首次访问串口时系统会弹权限对话框, 需在 系统设置 → 隐私与安全 中允许')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -132,10 +117,6 @@ export async function diagnoseUsb(vendorId?: number, productId?: number): Promis
|
||||
}
|
||||
}
|
||||
|
||||
function formatHex(v: number) {
|
||||
return v.toString(16).padStart(4, '0').toLowerCase()
|
||||
}
|
||||
|
||||
// 把用户加入 dialout 组 (Linux); 一次性, 需要登出登入生效
|
||||
export async function fixLinuxDialout(): Promise<ElevationResult> {
|
||||
if (platform !== 'linux') {
|
||||
@@ -143,7 +124,7 @@ export async function fixLinuxDialout(): Promise<ElevationResult> {
|
||||
}
|
||||
// 1. 检查用户是否已在 dialout
|
||||
try {
|
||||
const { stdout } = await execAsync('groups')
|
||||
const { stdout } = await execAsync('id -Gn 2>/dev/null || groups 2>/dev/null || true')
|
||||
if (/\bdialout\b/.test(stdout)) {
|
||||
return { ok: true, stdout: '已在 dialout 组, 无需操作' }
|
||||
}
|
||||
@@ -157,81 +138,7 @@ export async function fixLinuxDialout(): Promise<ElevationResult> {
|
||||
return r
|
||||
}
|
||||
|
||||
// 写 udev 规则 (Linux); 一次写永久生效
|
||||
export async function fixLinuxUdevRule(vendorId: number, productId: number, mode: 'usb' | 'hid' = 'usb'): Promise<ElevationResult> {
|
||||
if (platform !== 'linux') {
|
||||
return { ok: false, stderr: '仅 Linux 需要此修复' }
|
||||
}
|
||||
if (!Number.isInteger(vendorId) || !Number.isInteger(productId)) {
|
||||
return { ok: false, stderr: 'vid/pid 非法' }
|
||||
}
|
||||
const vid = formatHex(vendorId)
|
||||
const pid = formatHex(productId)
|
||||
// 现有规则文件: /etc/udev/rules.d/99-localnetmsg.rules
|
||||
const RULE_FILE = '/etc/udev/rules.d/99-localnetmsg.rules'
|
||||
const modeLine = mode === 'hid'
|
||||
? `KERNEL=="hidraw*", ATTRS{idVendor}=="${vid}", ATTRS{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"`
|
||||
: `SUBSYSTEM=="usb", ATTR{idVendor}=="${vid}", ATTR{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"`
|
||||
|
||||
// 用临时脚本追加, 再 mv (避免 echo > /etc ... 的转义问题)
|
||||
const tmpFile = join(tmpdir(), `lnm-udev-${Date.now()}.rules`)
|
||||
const banner = `\n# LocalNetMsg auto-rule for ${vid}:${pid} (${new Date().toISOString()})\n${modeLine}\n`
|
||||
writeFileSync(tmpFile, banner)
|
||||
|
||||
// 检查是否已存在
|
||||
let existing = ''
|
||||
try { existing = readFileSync(RULE_FILE, 'utf8') } catch {}
|
||||
if (existing.includes(`ATTR{idVendor}=="${vid}"`) && existing.includes(`ATTR{idProduct}=="${pid}"`)) {
|
||||
return { ok: true, stdout: '规则已存在, 无需再次添加' }
|
||||
}
|
||||
|
||||
// 1. 复制临时文件到 /etc/udev/rules.d/ (需要 root)
|
||||
const cpCmd = `cat '${tmpFile}' >> '${RULE_FILE}' && udevadm control --reload-rules && udevadm trigger`
|
||||
const r = await runElevated(cpCmd)
|
||||
if (!r.ok) return r
|
||||
return { ok: true, stdout: `已写入 ${RULE_FILE}; 已 reload udev 规则.\n${modeLine}\n请重新插拔设备 或 等待几秒后刷新列表.` }
|
||||
}
|
||||
|
||||
// Windows: 串口 (COM) 一般免 admin; HID/raw USB 偶尔需要装 WinUSB 驱动 (一次性, 由 Zadig CLI 或 wdi-simple 自动装)
|
||||
// 这里优先尝试自动装; 失败也给一句"自动修失败, 你可以..."的提示, 不留"请手动下载"的死路
|
||||
export async function fixWindowsHID(vendorId?: number, productId?: number): Promise<ElevationResult> {
|
||||
// 1. 优先尝试用本机已存在的 Zadig.exe (PATH 或 resources/zadig.exe)
|
||||
const candidates = [
|
||||
join(process.resourcesPath || '', 'zadig.exe'),
|
||||
'C:\\Program Files\\Zadig\\Zadig.exe',
|
||||
]
|
||||
let zadigPath: string | null = null
|
||||
for (const p of candidates) {
|
||||
try { if (existsSync(p)) { zadigPath = p; break } } catch {}
|
||||
}
|
||||
if (!zadigPath) {
|
||||
// 没有 Zadig: 静默下载到 userData 并运行 (不再让用户手动下)
|
||||
try {
|
||||
const dl = await import('node:https')
|
||||
const dest = join(process.env.APPDATA || process.env.HOME || tmpdir(), 'LocalNetMsg', 'zadig.exe')
|
||||
zadigPath = await new Promise<string>((resolve, reject) => {
|
||||
const file = require('node:fs').createWriteStream(dest)
|
||||
const req = dl.get('https://github.com/pbatard/libwdi/releases/download/v1.5.1/zadig-2.9.exe', (res: any) => {
|
||||
if (res.statusCode !== 200) { reject(new Error(`下载失败: ${res.statusCode}`)); return }
|
||||
res.pipe(file)
|
||||
file.on('finish', () => file.close(() => resolve(dest)))
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
} catch (e: any) {
|
||||
return { ok: false, stderr: `自动获取驱动安装器失败: ${e?.message || e}. 设备可能仍可用 — 内置 HID 类驱动对常见设备已足够.` }
|
||||
}
|
||||
}
|
||||
// 2. 用 Zadig CLI 安装 WinUSB (需要 admin)
|
||||
const cmd = vendorId && productId
|
||||
? `"${zadigPath}" --mode=install --vid=${vendorId} --pid=${productId} --driver=winusb`
|
||||
: `"${zadigPath}" --mode=install --driver=winusb`
|
||||
const r = await runElevated(cmd)
|
||||
if (r.ok) return { ...r, stdout: (r.stdout || '') + '\nWinUSB 驱动已安装, 请重新插拔设备.' }
|
||||
return { ...r, stderr: (r.stderr || '') + '\n自动装驱动失败 — 内置 HID 类驱动对常见键鼠手柄已足够, 可直接重试.' }
|
||||
}
|
||||
|
||||
// 诊断 + 一键修复 串口 权限
|
||||
/** 诊断 + 一键修复 串口权限 */
|
||||
export async function diagnoseAndFixSerial(): Promise<{ diagnose: DiagnoseResult; fix?: ElevationResult }> {
|
||||
const d = await diagnoseSerial()
|
||||
let fix: ElevationResult | undefined
|
||||
@@ -241,43 +148,24 @@ export async function diagnoseAndFixSerial(): Promise<{ diagnose: DiagnoseResult
|
||||
return { diagnose: d, fix }
|
||||
}
|
||||
|
||||
export async function diagnoseAndFixUsb(vendorId?: number, productId?: number): Promise<{ diagnose: DiagnoseResult; fix?: ElevationResult }> {
|
||||
const d = await diagnoseUsb(vendorId, productId)
|
||||
let fix: ElevationResult | undefined
|
||||
const issue = d.issues.find(i => i.kind === 'linux.udev')
|
||||
if (issue && issue.kind === 'linux.udev' && vendorId && productId) {
|
||||
fix = await fixLinuxUdevRule(vendorId, productId, 'usb')
|
||||
} else if (d.issues.some(i => i.kind === 'windows.driver')) {
|
||||
fix = await fixWindowsHID(vendorId, productId)
|
||||
}
|
||||
return { diagnose: d, fix }
|
||||
}
|
||||
|
||||
// ====== USB/IP 真透传 (Linux only) ======
|
||||
//
|
||||
// 老实说: USB/IP 是 Linux kernel 自带的 USB-over-IP, 真透明 (设备出现在本机 lsusb).
|
||||
// 我们的应用只是封装: 列设备 → 调 `usbip attach -r <peer> -b <busid>` 让内核接管.
|
||||
// 用户机器需要先装 `usbip` 包 + 加载 vhci_hcd 模块. 一次性.
|
||||
|
||||
// Linux: 检查 + 装 usbip + 加载内核模块 (vhci_hcd, usbip_core)
|
||||
export async function diagnoseAndFixUsbAttach(): Promise<ElevationResult> {
|
||||
if (platform !== 'linux') return { ok: true, stdout: '非 Linux 不支持 USB/IP' }
|
||||
// 检查 usbip 命令
|
||||
/** Linux 上有没有 socat (建虚拟串口用) —— UI 用来提前提示, 免得 attach 到一半才报错 */
|
||||
export async function hasSocat(): Promise<boolean> {
|
||||
if (platform !== 'linux' && platform !== 'darwin') return true
|
||||
try {
|
||||
const { stdout } = await execAsync('which usbip || true')
|
||||
if (!stdout.trim()) {
|
||||
const install = await runElevated('sh -c "if command -v apt-get >/dev/null 2>&1; then apt-get update && apt-get install -y usbip; elif command -v dnf >/dev/null 2>&1; then dnf install -y usbip; elif command -v yum >/dev/null 2>&1; then yum install -y usbip; else echo NO_PKG_MGR; fi"')
|
||||
if (!install.ok) return { ok: false, stderr: '未找到 usbip 且自动安装失败: ' + install.stderr }
|
||||
}
|
||||
} catch {}
|
||||
// 检查 /dev/vhci_hcd 存在 (vhci_hcd 模块加载标志)
|
||||
try {
|
||||
await execAsync('test -e /dev/vhci_hcd')
|
||||
const { stdout } = await execAsync('which socat 2>/dev/null || true')
|
||||
return !!stdout.trim()
|
||||
} catch {
|
||||
const m1 = await runElevated('modprobe usbip_core')
|
||||
const m2 = await runElevated('modprobe vhci_hcd')
|
||||
if (!m2.ok) return { ok: false, stderr: '加载 vhci_hcd 模块失败: ' + m2.stderr }
|
||||
await runElevated('sh -c "echo usbip_core > /etc/modules-load.d/usbip.conf && echo vhci_hcd >> /etc/modules-load.d/usbip.conf"').catch(() => {})
|
||||
return false
|
||||
}
|
||||
return { ok: true, stdout: 'usbip + vhci_hcd 已就绪' }
|
||||
}
|
||||
|
||||
/** 虚拟串口能放在哪 (给 UI 提示用) */
|
||||
export function virtualSerialHint(): string {
|
||||
if (platform === 'linux') {
|
||||
return '本机会创建一个 socat PTY 配对 (/tmp/lnm-vcom-*/user)。用 minicom / screen / picocom 打开那个路径即可。'
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return '本机会创建一个 socat PTY 配对。用 screen / minicom 打开那个路径即可。'
|
||||
}
|
||||
return '本机会创建一个 com0com 虚拟 COM 口 (需要管理员权限安装 com0com)。用 PuTTY / Arduino IDE 打开那个 COM 号即可。'
|
||||
}
|
||||
+115
-66
@@ -40,7 +40,70 @@ export type WsFrame =
|
||||
| { type: 'read'; upTo: string; ts: number } // 接收方发, upTo = 看到的最后一条 messageId; ≤ upTo 的发送方消息视为已读
|
||||
| { type: 'terminal'; payload: TerminalFrame } // 远程终端 (双向多路复用)
|
||||
| { type: 'forward'; payload: ForwardFrame } // 端口转发
|
||||
| { type: 'usb'; payload: UsbFrame } // USB 设备透传
|
||||
// 线上帧类型**故意还是叫 'usb'** —— 为了跟老版本对端兼容。
|
||||
// 载荷已经是串口专用的 (SerialFrame); 老版本收到的也是同样的 'usb' 帧 + kind:'serial',
|
||||
// 所以**新旧版本之间的串口映射照样能用**, 断的只有 USB 字节桥 (那个功能下线了)。
|
||||
| { type: 'usb'; payload: SerialFrame } // 串口映射
|
||||
| { type: 'relation:request'; payload: RelationRequestFrame } // 我想跟你建立同步关系
|
||||
| { type: 'relation:accept'; payload: RelationAcceptFrame } // 我同意了
|
||||
| { type: 'relation:reject'; payload: RelationRejectFrame } // 我拒绝了
|
||||
| { type: 'relation:revoked'; payload: RelationRevokedFrame } // 我把关系吊销了 (你那边也作废)
|
||||
|
||||
// ============ 关系 (同步盘) ============
|
||||
//
|
||||
// 关系 = 两个设备之间的持久绑定 + 分级授权。
|
||||
// 建立方式跟远程终端的授权闸门一致: **对方直接弹窗**, 不用邀请码。
|
||||
//
|
||||
// A 点"建立同步关系" -> 选本地目录 -> 发 relation:request
|
||||
// B 那边弹窗 (授权范围 + B 自己的本地目录) -> 同意 -> relation:accept
|
||||
// A 收到 accept -> 双方各自落库, 交换 per-peer 令牌 -> 之后按关系授权访问网盘
|
||||
//
|
||||
// 令牌:
|
||||
// token = 发起方签发的令牌, 对方用它访问发起方的网盘/同步区
|
||||
// 两边各签一个, 各自保存对方的那个 (myToken / peerToken)
|
||||
|
||||
export interface RelationPeerInfo {
|
||||
deviceId: string
|
||||
name: string
|
||||
address: string
|
||||
chatPort: number
|
||||
filePort: number
|
||||
}
|
||||
|
||||
export interface RelationRequestFrame {
|
||||
relationId: string
|
||||
/** 我签发的令牌 (对方用它访问我的网盘) */
|
||||
token: string
|
||||
/** 我开放给对方的能力: 'drive' | 'sync' */
|
||||
grants: string[]
|
||||
/** 我的同步目录 (本机绝对路径) */
|
||||
syncLocalDir: string
|
||||
/** 我想用你网盘里的哪个子目录 */
|
||||
syncRemotePath: string
|
||||
peer: RelationPeerInfo
|
||||
}
|
||||
|
||||
export interface RelationAcceptFrame {
|
||||
relationId: string
|
||||
/** 对方签发的令牌 (我用它访问对方网盘) */
|
||||
token: string
|
||||
/** 对方开放给我的能力 */
|
||||
grants: string[]
|
||||
/** 对方的同步目录 (本机绝对路径) */
|
||||
syncLocalDir: string
|
||||
/** 对方想用我网盘里的哪个子目录 */
|
||||
syncRemotePath: string
|
||||
peer: RelationPeerInfo
|
||||
}
|
||||
|
||||
export interface RelationRejectFrame {
|
||||
relationId: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface RelationRevokedFrame {
|
||||
relationId: string
|
||||
}
|
||||
|
||||
// ============ 远程终端 ============
|
||||
|
||||
@@ -69,64 +132,67 @@ export type ForwardFrame =
|
||||
| { type: 'close'; sessionId: string; reason?: string; bytesIn?: number; bytesOut?: number }
|
||||
| { type: 'ack'; sessionId: string; ok: boolean; reason?: string }
|
||||
|
||||
// ============ USB / 串口 共享 ============
|
||||
// ============ 串口 (serial) ============
|
||||
//
|
||||
// 老实说: 纯 Node.js 在 Windows 上做不到 "USB 设备完全透明地变成本机 USB" — 那是商业软件
|
||||
// (VirtualHere / USB Network Gate / FlexiHub) 或 Linux usbip + WSL 才有的能力.
|
||||
// 只做一件事: **串口映射** —— 把一端的真实串口, 映射到另一端可以当本地串口用.
|
||||
//
|
||||
// 我们提供三种能力, 按场景取用:
|
||||
// 1. 'serial' — 真正的双向字节流转发, 适合 USB-串口适配器 (CH340/CP210x/FTDI) 和原生串口.
|
||||
// 在 OS 看来就是普通的串口, 桥接完整 (无协议).
|
||||
// 2. 'usb' — libusb 字节桥: server 端 node-usb open + claim interface, client 端发
|
||||
// controlTransfer / bulkTransfer 请求并收结果. 适合访问自定义 USB 设备
|
||||
// (单片机, 编程器, 调试器). 不是透明 USB — 是远程调设备.
|
||||
// 3. 'usbip' — Linux only, 调系统 usbip CLI 做真透明 USB 透传. 用户机器必须装了
|
||||
// usbip + 加载 vhci_hcd 模块. macOS/Windows 默认不可用, UI 会隐藏.
|
||||
// self-out: 我用对方的串口 (对端打开真实串口, 字节流转发给我)
|
||||
// → 我这边可以:
|
||||
// a) 建一个本机虚拟串口 (Linux/macOS: socat PTY; Windows: com0com),
|
||||
// 本机任何工具 (PuTTY / minicom / screen / 自己的脚本) 直接打开它
|
||||
// b) 或者不建虚拟口, 应用自己当"串口助手"收发字节
|
||||
// self-in: 对方用我的串口 (我打开真实串口, 字节流转发给对方)
|
||||
//
|
||||
// **USB 字节桥 (libusb control/bulk) 和 USB/IP 透传已下线** (v0.2.0 移除).
|
||||
// 纯 Node.js 在 Windows/macOS 上做不了"USB 设备完全透明地变成本机 USB"
|
||||
// (那是 VirtualHere / USB Network Gate / FlexiHub 的商业能力), 字节桥又只对
|
||||
// 会写控制传输的人有意义, 使用面太窄 —— 不如把串口这条真正好用的路做扎实.
|
||||
//
|
||||
// **协议版本没动 (仍是 1), 线上帧类型也没动 (仍叫 'usb')**: 局域网里常有一台机器
|
||||
// 还跑着旧版本, 升协议版本会让两边连不上 (连文字消息都发不了), 代价太大。
|
||||
// 现在新旧版本之间: 聊天照常, **串口映射也照常能用** (老版本本来就支持 kind:'serial'),
|
||||
// 只有 USB 字节桥在混版本时用不了 —— 而那个功能本来就下线了.
|
||||
// 所以: 内部类型全部改名成 Serial*, 线上格式一个字节都没动.
|
||||
|
||||
export interface UsbDeviceInfo {
|
||||
busId: string // 总线 id, 用于唯一识别 + attach
|
||||
vid: number
|
||||
pid: number
|
||||
deviceClass: number
|
||||
deviceSubclass: number
|
||||
export interface SerialDeviceInfo {
|
||||
busId: string // 'serial:/dev/ttyUSB0' —— 唯一标识 + attach 用
|
||||
serialPath: string // 真实路径: COM3 / /dev/ttyUSB0 / /dev/ttyACM0
|
||||
vid?: number
|
||||
pid?: number
|
||||
product?: string
|
||||
manufacturer?: string
|
||||
serialNumber?: string
|
||||
port?: string // node-hid 的 path (HID 模式)
|
||||
/** 'serial' = 字节流转发, 'usb' = libusb 字节桥 (control/bulk), 'usbip' = Linux 透明透传 */
|
||||
kind: 'serial' | 'usb' | 'usbip'
|
||||
serialPath?: string // 串口路径, e.g. COM3 / /dev/ttyUSB0
|
||||
baudRate?: number // 串口默认波特率
|
||||
baudRate?: number // 建议波特率 (串口本身不携带这个信息, 默认给个常见的)
|
||||
}
|
||||
|
||||
/**
|
||||
* USB 方向: 同 Forward
|
||||
* 'self-out': 控制端使用对端的设备 (对端打开真实设备)
|
||||
* 'self-in': 对端使用控制端的设备 (控制端打开真实设备)
|
||||
* 方向: 同 Forward
|
||||
* 'self-out': 控制端使用对端的设备 (对端打开真实串口)
|
||||
* 'self-in': 对端使用控制端的设备 (控制端打开真实串口)
|
||||
*/
|
||||
export type UsbDirection = 'self-out' | 'self-in'
|
||||
export type SerialDirection = 'self-out' | 'self-in'
|
||||
|
||||
// attach 时附带的配置 (按 kind 区分)
|
||||
export type UsbAttachConfig =
|
||||
| { kind: 'serial'; baudRate?: number; dataBits?: 5|6|7|8; stopBits?: 1|2; parity?: 'none'|'even'|'odd'|'mark'|'space'; createVirtual?: boolean; virtualName?: string }
|
||||
| { kind: 'usb'; configurationValue?: number; interfaceNumber?: number; detachKernelDriver?: boolean }
|
||||
| { kind: 'usbip' }
|
||||
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space'
|
||||
|
||||
// attached 帧带回的设备元信息 (主要用于 usb 模式, 让 UI 知道有哪些 endpoint)
|
||||
export interface UsbEndpointInfo {
|
||||
endpointNumber: number
|
||||
direction: 'in' | 'out'
|
||||
transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous'
|
||||
packetSize: number
|
||||
// attach 时附带的配置
|
||||
export interface SerialAttachConfig {
|
||||
kind: 'serial'
|
||||
baudRate?: number
|
||||
dataBits?: 5 | 6 | 7 | 8
|
||||
stopBits?: 1 | 2
|
||||
parity?: SerialParity
|
||||
/** 在控制端本机建一个虚拟串口 (串口映射) */
|
||||
createVirtual?: boolean
|
||||
/** 用户想指定虚拟口的名字 (e.g. "COM5" / "/tmp/mydev") */
|
||||
virtualName?: string
|
||||
}
|
||||
|
||||
export interface UsbAttachedInfo {
|
||||
kind: 'serial' | 'usb' | 'usbip'
|
||||
// usb 模式: 设备的 endpoint 列表
|
||||
endpoints?: UsbEndpointInfo[]
|
||||
// 串口模式: 远端真实串口路径
|
||||
// attached 帧带回的设备元信息
|
||||
export interface SerialAttachedInfo {
|
||||
kind: 'serial'
|
||||
// 远端真实串口路径
|
||||
serialPath?: string
|
||||
// 串口模式: 本机虚拟串口路径 (createVirtual=true 时填)
|
||||
// 控制端本机虚拟串口路径 (createVirtual=true 时填)
|
||||
userVirtualPath?: string
|
||||
// 设备基础信息
|
||||
vid?: number
|
||||
@@ -136,34 +202,17 @@ export interface UsbAttachedInfo {
|
||||
serialNumber?: string
|
||||
}
|
||||
|
||||
// USB 控制传输 setup 包 (libusb 风格)
|
||||
export interface UsbControlSetup {
|
||||
requestType: number // bmRequestType: 方向 | 类型 | 接收方
|
||||
request: number // bRequest
|
||||
value: number // wValue
|
||||
index: number // wIndex
|
||||
}
|
||||
|
||||
export type UsbFrame =
|
||||
export type SerialFrame =
|
||||
| { type: 'list'; reqId: string }
|
||||
| { type: 'devices'; reqId: string; devices: UsbDeviceInfo[] }
|
||||
| { type: 'attach'; sessionId: string; direction: UsbDirection; busId: string; config: UsbAttachConfig }
|
||||
| { type: 'attached'; sessionId: string; ok: boolean; reason?: string; info?: UsbAttachedInfo }
|
||||
| { type: 'devices'; reqId: string; devices: SerialDeviceInfo[] }
|
||||
| { type: 'attach'; sessionId: string; direction: SerialDirection; busId: string; config: SerialAttachConfig }
|
||||
| { type: 'attached'; sessionId: string; ok: boolean; reason?: string; info?: SerialAttachedInfo }
|
||||
| { type: 'detach'; sessionId: string; reason?: string }
|
||||
| { type: 'detached'; sessionId: string; ok: boolean; reason?: string }
|
||||
|
||||
// 字节流 (serial 模式: 双向, 半关时 fin=true; usb 模式: 不用, 用 control/bulk)
|
||||
// 字节流 (双向; fin=true 表示对端那半边关了)
|
||||
| { type: 'data'; sessionId: string; dir: 'host->dev' | 'dev->host'; data: string /* base64 */; fin?: boolean }
|
||||
|
||||
// USB 控制传输 (request-response, 用 reqId 配对)
|
||||
| { type: 'ctrlOut'; sessionId: string; reqId: string; setup: UsbControlSetup; data?: string }
|
||||
| { type: 'ctrlIn'; sessionId: string; reqId: string; setup: UsbControlSetup; length: number }
|
||||
| { type: 'ctrlResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
|
||||
|
||||
// USB 批量/中断传输 (request-response, 用 reqId 配对)
|
||||
| { type: 'bulkOut'; sessionId: string; reqId: string; endpoint: number; data: string }
|
||||
| { type: 'bulkIn'; sessionId: string; reqId: string; endpoint: number; length: number; timeoutMs?: number }
|
||||
| { type: 'bulkResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
|
||||
|
||||
| { type: 'ack'; reqId?: string; sessionId?: string; ok: boolean; reason?: string }
|
||||
| { type: 'error'; sessionId: string; reason: string }
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
// 关系服务: 建立/接受/吊销关系 + 应用对方推来的文件关系表
|
||||
//
|
||||
// 交互流程 (跟远程终端的授权闸门一个路子 —— **对方直接弹窗**, 不用邀请码):
|
||||
//
|
||||
// A: 同步面板点"建立同步关系" -> 选本地目录 -> 勾选我要开放的能力
|
||||
// -> requestRelation() -> 发 relation:request 帧
|
||||
// B: 收到帧 -> 弹窗 (对方要什么 + B 自己的本地目录 + B 开放什么)
|
||||
// -> 用户同意 -> 回 relation:accept 帧
|
||||
// A: 收到 accept -> 关系落库 (active) -> 通知界面
|
||||
// B: 拒绝 -> 回 relation:reject -> A 侧把 pending 关系撤掉
|
||||
//
|
||||
// 双方各自保存: 对方是谁 / 我开放了什么 / 对方开放了什么 / 两个同步目录 / 对方的令牌。
|
||||
//
|
||||
// 目录约定 (对称, 不用协商):
|
||||
// syncRemotePath = "我在**对方**网盘里用的子目录" (我这侧视角)
|
||||
// peerSyncRoot = "对方在我网盘里用的子目录" (我这侧视角)
|
||||
// 发起方提议自己的 syncRemotePath; 接受方可以改, 并给出自己的 syncRemotePath。
|
||||
// 于是发起方: syncRemotePath = 自己提的, peerSyncRoot = 对方给的
|
||||
// 接受方: syncRemotePath = 自己给的, peerSyncRoot = 对方提的
|
||||
//
|
||||
// 删除传播的落点在 applyPeerManifest(): 对方说"这个文件我删了"(墓碑),
|
||||
// 我这边比对基线确认自己没改过 -> 删本地文件; 改过 -> 报冲突, 不静默丢数据。
|
||||
//
|
||||
// 注意: 这是**类**不是单例 —— 一个进程里要能跑多个节点 (自测就是 A/B 两个虚拟设备)。
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
|
||||
import {
|
||||
RelationStore, newRelationToken, authorizeDriveAccess, normRelDir,
|
||||
type Relation, type Grant,
|
||||
} from './relations'
|
||||
import {
|
||||
ManifestStore, toWire, fromWire, mergePeer, advanceClock, reconcileLocal, markLocalDeleted,
|
||||
type ManifestWire,
|
||||
} from './sync-manifest'
|
||||
import { scanLocalDir } from './sync'
|
||||
import { enqueueApproval } from './remote/approval'
|
||||
import { recordAudit } from './db'
|
||||
import type { DeviceInfo, WsFrame, RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame } from './protocol'
|
||||
|
||||
// ============ 工具 ============
|
||||
|
||||
function peerInfoOf(d: DeviceInfo) {
|
||||
return {
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
address: d.address,
|
||||
chatPort: d.chatPort,
|
||||
filePort: d.filePort,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 我在**对方网盘**里用的默认子目录: 同步/<我的名字>。
|
||||
* 这样在对方网盘里一眼能看出这个目录是谁的数据 ("同步/我的电脑" = 我放过去的)。
|
||||
*/
|
||||
function defaultRemotePath(selfName: string): string {
|
||||
const safe = (selfName || '设备').replace(/[\\/:*?"<>|]/g, '_').trim() || '设备'
|
||||
return `同步/${safe}`
|
||||
}
|
||||
|
||||
/** 把相对路径解析到本地同步目录内, 挡住目录穿越 */
|
||||
function localTarget(localDir: string, relPath: string): string | null {
|
||||
const root = resolve(localDir)
|
||||
const clean = relPath.replace(/^[/\\]+/, '')
|
||||
const abs = resolve(join(root, clean))
|
||||
if (abs !== root && !abs.startsWith(root + sep)) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
// ============ 服务 ============
|
||||
|
||||
/** 同一个对端两次关系授权弹窗的最小间隔 (防重复弹窗骚扰) */
|
||||
const ASK_COOLDOWN_MS = 60_000
|
||||
|
||||
export interface RelationServiceOptions {
|
||||
store: RelationStore
|
||||
manifestStore: ManifestStore
|
||||
self: DeviceInfo
|
||||
/** 发 WS 帧给某个设备 */
|
||||
send: (peerId: string, frame: WsFrame) => boolean
|
||||
/** 通知渲染层 */
|
||||
notify: (event: string, payload?: any) => void
|
||||
}
|
||||
|
||||
export class RelationService {
|
||||
private store: RelationStore
|
||||
private manifestStore: ManifestStore
|
||||
private self: DeviceInfo
|
||||
private send: (peerId: string, frame: WsFrame) => boolean
|
||||
private notify: (event: string, payload?: any) => void
|
||||
/** peerId -> 上次弹出关系授权请求的时间 (防重复弹窗骚扰) */
|
||||
private lastAskAt = new Map<string, number>()
|
||||
|
||||
constructor(opts: RelationServiceOptions) {
|
||||
this.store = opts.store
|
||||
this.manifestStore = opts.manifestStore
|
||||
this.self = opts.self
|
||||
this.send = opts.send
|
||||
this.notify = opts.notify
|
||||
}
|
||||
|
||||
get relations(): RelationStore { return this.store }
|
||||
get manifests(): ManifestStore { return this.manifestStore }
|
||||
|
||||
/**
|
||||
* 某个关系在我这边的同步目录 (绝对路径)。
|
||||
* 服务端用它把 /sync/file 的所有路径限制在这个目录内 ——
|
||||
* 用户的同步目录可以是任意文件夹, 不必待在网盘目录里面。
|
||||
*
|
||||
* peerId 必须匹配: 否则 A 关系的对方能拿自己的令牌来读 B 关系的同步目录。
|
||||
*/
|
||||
getSyncScope(relationId: string, peerId?: string): string | null {
|
||||
const rel = this.store.getRelation(relationId)
|
||||
if (!rel || rel.state !== 'active') return null
|
||||
if (peerId && rel.peerId !== peerId) return null
|
||||
if (!rel.syncLocalDir) return null
|
||||
return this.localRootOf(rel)
|
||||
}
|
||||
|
||||
/** 对方网盘里给我预留的子目录 (用来算我请求的路径) */
|
||||
remotePathOf(rel: Relation): string {
|
||||
return normRelDir(rel.syncRemotePath)
|
||||
}
|
||||
|
||||
/** 我的网盘里给对方预留的子目录 (授权范围) */
|
||||
scopeRootOf(rel: Relation): string {
|
||||
return normRelDir(rel.peerSyncRoot)
|
||||
}
|
||||
|
||||
/**
|
||||
* 我这边的**实际同步根** = 用户选的顶层目录 + 按关系隔离的子目录。
|
||||
* (顶层目录管多个聊天备份时, 每个关系在自己的子目录里, 互不干扰)
|
||||
*/
|
||||
localRootOf(rel: Relation): string {
|
||||
const base = resolve(rel.syncLocalDir || '')
|
||||
const sub = normRelDir(rel.syncSubdir || '')
|
||||
const root = sub ? join(base, sub) : base
|
||||
// 顺手把目录建好: 关系子目录是自动分配的, 用户不会提前建
|
||||
try { mkdirSync(root, { recursive: true }) } catch { /* 建不了就让它在扫描时报错 */ }
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* 给某个 peer 挑一个不冲突的子目录名 (默认对方设备名, 重名加 id 前缀)。
|
||||
* 顶层目录相同也不怕 —— 子目录不同, 物理上就隔开了。
|
||||
*/
|
||||
private pickSubdir(peerName: string, peerId: string, localDir: string): string {
|
||||
const safe = (peerName || '设备').replace(/[\\/:*?"<>|]/g, '_').trim() || '设备'
|
||||
const base = resolve(localDir || '')
|
||||
const used = new Set(
|
||||
this.store.listActiveRelations()
|
||||
.filter(r => r.syncLocalDir && r.syncSubdir)
|
||||
.map(r => `${resolve(r.syncLocalDir)}::${r.syncSubdir}`),
|
||||
)
|
||||
if (!used.has(`${base}::${safe}`)) return safe
|
||||
const withId = `${safe}_${(peerId || '').slice(0, 4)}`
|
||||
if (!used.has(`${base}::${withId}`)) return withId
|
||||
return `${withId}_${randomUUID().slice(0, 4)}`
|
||||
}
|
||||
|
||||
// ---- 发起 ----
|
||||
|
||||
requestRelation(args: {
|
||||
peerId: string
|
||||
peerName: string
|
||||
peerAddress?: string
|
||||
peerChatPort?: number
|
||||
peerFilePort?: number
|
||||
/** 我这边的同步目录 */
|
||||
syncLocalDir: string
|
||||
/** 我想在对方网盘里用的子目录 */
|
||||
syncRemotePath: string
|
||||
/** 我开放给对方的能力 */
|
||||
grants: Grant[]
|
||||
}): { ok: boolean; reason?: string; relationId?: string } {
|
||||
if (!args.syncLocalDir) return { ok: false, reason: '请先选择本机同步目录' }
|
||||
|
||||
// 顶层目录**可以**和别的关系共用 —— 每个关系在自己的子目录里, 物理上隔开:
|
||||
// ~/备份/MECHREVO/ <- 和 MECHREVO 同步的数据
|
||||
// ~/备份/lenovo/ <- 和 lenovo 同步的数据
|
||||
// 这样"一个目录管多个聊天备份"成立, 两条数据也不会互相覆盖。
|
||||
const subdir = this.pickSubdir(args.peerName, args.peerId, args.syncLocalDir)
|
||||
|
||||
const relationId = randomUUID()
|
||||
const token = newRelationToken()
|
||||
const t = Date.now()
|
||||
const rel: Relation = {
|
||||
relationId,
|
||||
peerId: args.peerId,
|
||||
peerName: args.peerName || '',
|
||||
peerAddress: args.peerAddress || '',
|
||||
peerChatPort: args.peerChatPort || 0,
|
||||
peerFilePort: args.peerFilePort || 0,
|
||||
state: 'pending',
|
||||
myGrants: args.grants,
|
||||
peerGrants: [],
|
||||
myToken: token,
|
||||
peerToken: '',
|
||||
syncLocalDir: args.syncLocalDir,
|
||||
syncSubdir: subdir,
|
||||
syncRemotePath: normRelDir(args.syncRemotePath) || defaultRemotePath(this.self.name),
|
||||
peerSyncRoot: '',
|
||||
syncEnabled: false,
|
||||
localSeq: 0,
|
||||
remoteSeq: 0,
|
||||
createdAt: t,
|
||||
updatedAt: t,
|
||||
lastSyncAt: 0,
|
||||
lastError: '',
|
||||
}
|
||||
this.store.upsertRelation(rel)
|
||||
|
||||
const payload: RelationRequestFrame = {
|
||||
relationId,
|
||||
token,
|
||||
grants: args.grants,
|
||||
syncLocalDir: args.syncLocalDir,
|
||||
syncRemotePath: rel.syncRemotePath,
|
||||
peer: peerInfoOf(this.self),
|
||||
}
|
||||
if (!this.send(args.peerId, { type: 'relation:request', payload })) {
|
||||
this.store.deleteRelation(relationId)
|
||||
return { ok: false, reason: '对方不在线, 请求没发出去' }
|
||||
}
|
||||
recordAudit({
|
||||
action: 'relation.request', source: this.self.deviceId, target: args.peerId,
|
||||
result: 'ok', payload: { relationId, grants: args.grants, syncLocalDir: args.syncLocalDir },
|
||||
})
|
||||
this.notify('relation:pending', { relationId, peerId: args.peerId, peerName: args.peerName })
|
||||
return { ok: true, relationId }
|
||||
}
|
||||
|
||||
// ---- 对方来请求 (我这边弹窗) ----
|
||||
|
||||
async handleRelationRequest(peer: DeviceInfo, frame: RelationRequestFrame): Promise<void> {
|
||||
// 已经有关系了就别重复弹 (直接回绝)
|
||||
const existing = this.store.getActiveRelationByPeer(peer.deviceId)
|
||||
if (existing) {
|
||||
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '已建立关系' } })
|
||||
return
|
||||
}
|
||||
// relationId 由发起方指定 -> 不校验的话, 对方可以用一个已占用的 id
|
||||
// 把**别的关系整个顶掉**(原 peer 立刻失去访问, 配置被清空)。
|
||||
const occupied = this.store.getRelation(frame.relationId)
|
||||
if (occupied) {
|
||||
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '关系标识冲突' } })
|
||||
return
|
||||
}
|
||||
// 防骚扰: WS 连接是"发现即建立"的, 同网段任何设备都能反复发请求刷弹窗。
|
||||
// 同一个对端 60s 内只弹一次, 之后的直接回绝。
|
||||
const last = this.lastAskAt.get(peer.deviceId) || 0
|
||||
if (Date.now() - last < ASK_COOLDOWN_MS) {
|
||||
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '请求过于频繁' } })
|
||||
return
|
||||
}
|
||||
this.lastAskAt.set(peer.deviceId, Date.now())
|
||||
|
||||
const reqId = randomUUID()
|
||||
const detail = `对方想和你建立同步关系 · 它的同步目录: ${frame.syncLocalDir || '(未设置)'}`
|
||||
const p = enqueueApproval({
|
||||
requestId: reqId,
|
||||
peerId: peer.deviceId,
|
||||
peerName: peer.name,
|
||||
kind: 'relation',
|
||||
detail,
|
||||
ts: Date.now(),
|
||||
grants: frame.grants,
|
||||
syncLocalDir: frame.syncLocalDir,
|
||||
syncRemotePath: frame.syncRemotePath,
|
||||
relationId: frame.relationId,
|
||||
})
|
||||
// 用现有的审批事件名, 渲染层的 ApprovalDialog 直接就能显示 (跟远程终端同一个弹窗)
|
||||
this.notify('remote:approval:requested', {
|
||||
requestId: reqId,
|
||||
peerId: peer.deviceId,
|
||||
peerName: peer.name,
|
||||
kind: 'relation',
|
||||
detail,
|
||||
grants: frame.grants,
|
||||
syncLocalDir: frame.syncLocalDir,
|
||||
syncRemotePath: frame.syncRemotePath,
|
||||
relationId: frame.relationId,
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
const verdict = await p
|
||||
if (!verdict.ok) {
|
||||
this.send(peer.deviceId, { type: 'relation:reject', payload: { relationId: frame.relationId, reason: '对方拒绝' } })
|
||||
recordAudit({
|
||||
action: 'relation.reject', source: peer.deviceId, target: this.self.deviceId,
|
||||
result: 'denied', payload: { relationId: frame.relationId },
|
||||
})
|
||||
this.notify('relation:rejected', { relationId: frame.relationId, peerId: peer.deviceId, byMe: true })
|
||||
return
|
||||
}
|
||||
|
||||
// 用户勾选的能力 (只认白名单里的)
|
||||
const grants: Grant[] = (verdict.grants || frame.grants || []).filter(
|
||||
(g): g is Grant => g === 'drive' || g === 'sync',
|
||||
)
|
||||
const myToken = newRelationToken()
|
||||
// 注意: 这里**不能**回退到 frame.syncLocalDir —— 那是**对方机器上**的路径,
|
||||
// 在我这边根本不存在。对方没填就留空, 之后在同步面板里补上。
|
||||
const myLocalDir = verdict.syncLocalDir || ''
|
||||
const myRemotePath = normRelDir(verdict.syncRemotePath ?? frame.syncRemotePath ?? '') || defaultRemotePath(this.self.name)
|
||||
|
||||
const rel: Relation = {
|
||||
relationId: frame.relationId,
|
||||
peerId: peer.deviceId,
|
||||
peerName: peer.name,
|
||||
peerAddress: peer.address,
|
||||
peerChatPort: peer.chatPort,
|
||||
peerFilePort: peer.filePort,
|
||||
state: 'active',
|
||||
myGrants: grants,
|
||||
peerGrants: (frame.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
|
||||
myToken,
|
||||
peerToken: frame.token || '',
|
||||
syncLocalDir: myLocalDir,
|
||||
syncSubdir: this.pickSubdir(peer.name, peer.deviceId, myLocalDir),
|
||||
syncRemotePath: myRemotePath,
|
||||
// 对方提议的目录 = 我网盘里给对方用的那个
|
||||
peerSyncRoot: normRelDir(frame.syncRemotePath),
|
||||
syncEnabled: false,
|
||||
localSeq: 0,
|
||||
remoteSeq: 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
lastSyncAt: 0,
|
||||
lastError: '',
|
||||
}
|
||||
this.store.upsertRelation(rel)
|
||||
|
||||
const payload: RelationAcceptFrame = {
|
||||
relationId: frame.relationId,
|
||||
token: myToken,
|
||||
grants,
|
||||
syncLocalDir: myLocalDir,
|
||||
syncRemotePath: myRemotePath,
|
||||
peer: peerInfoOf(this.self),
|
||||
}
|
||||
this.send(peer.deviceId, { type: 'relation:accept', payload })
|
||||
recordAudit({
|
||||
action: 'relation.accept', source: peer.deviceId, target: this.self.deviceId,
|
||||
result: 'ok', payload: { relationId: frame.relationId, grants, syncLocalDir: myLocalDir },
|
||||
})
|
||||
this.notify('relation:changed', { relationId: frame.relationId, peerId: peer.deviceId, state: 'active' })
|
||||
}
|
||||
|
||||
// ---- 对方接受了 ----
|
||||
|
||||
handleRelationAccept(peer: DeviceInfo, frame: RelationAcceptFrame): void {
|
||||
const rel = this.store.getRelation(frame.relationId)
|
||||
if (!rel) return
|
||||
this.store.updateRelation(frame.relationId, {
|
||||
state: 'active',
|
||||
peerGrants: (frame.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
|
||||
peerToken: frame.token || '',
|
||||
// 对方给的目录 = 我网盘里给对方用的那个
|
||||
peerSyncRoot: normRelDir(frame.syncRemotePath),
|
||||
peerName: frame.peer?.name || rel.peerName,
|
||||
peerAddress: frame.peer?.address || rel.peerAddress,
|
||||
peerChatPort: frame.peer?.chatPort || rel.peerChatPort,
|
||||
peerFilePort: frame.peer?.filePort || rel.peerFilePort,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
recordAudit({
|
||||
action: 'relation.established', source: peer.deviceId, target: this.self.deviceId,
|
||||
result: 'ok', payload: { relationId: frame.relationId, grants: frame.grants },
|
||||
})
|
||||
this.notify('relation:changed', { relationId: frame.relationId, peerId: peer.deviceId, state: 'active' })
|
||||
}
|
||||
|
||||
// ---- 对方拒绝了 ----
|
||||
|
||||
handleRelationReject(peer: DeviceInfo, frame: RelationRejectFrame): void {
|
||||
const rel = this.store.getRelation(frame.relationId)
|
||||
if (rel && rel.state === 'pending') this.store.deleteRelation(frame.relationId)
|
||||
recordAudit({
|
||||
action: 'relation.rejected', source: peer.deviceId, target: frame.relationId,
|
||||
result: 'denied', payload: { reason: frame.reason },
|
||||
})
|
||||
this.notify('relation:rejected', { relationId: frame.relationId, peerId: peer.deviceId, byMe: false, reason: frame.reason })
|
||||
}
|
||||
|
||||
// ---- 网盘授权 ----
|
||||
|
||||
authorizeDrive(token: string | null, peerId: string | null) {
|
||||
return authorizeDriveAccess(this.store, token, peerId)
|
||||
}
|
||||
|
||||
// ---- 关系表 (manifest) ----
|
||||
|
||||
/** 对方来拉我的关系表 (peerId 必须匹配, 防止跨关系拉表) */
|
||||
getSyncManifest(relationId: string, peerId?: string): ManifestWire | null {
|
||||
const rel = this.store.getRelation(relationId)
|
||||
if (!rel || rel.state !== 'active') return null
|
||||
if (peerId && rel.peerId !== peerId) return null
|
||||
return toWire(this.manifestStore.loadManifest(relationId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用对方推来的关系表 —— **删除传播的落点**。
|
||||
*
|
||||
* 对方说"这个文件我删了"(墓碑):
|
||||
* 我这边有 + 从没同步过 (没有基线) -> 不动 (那是两个各自独立的文件)
|
||||
* 我这边有 + 同步过 + 我改过 -> 冲突, 不静默丢数据
|
||||
* 我这边有 + 同步过 + 我没改过 -> 删本地文件, 同步记墓碑
|
||||
*/
|
||||
applyPeerManifest(relationId: string, wire: ManifestWire, peerId?: string): { applied: string[]; conflicts: string[] } {
|
||||
const rel = this.store.getRelation(relationId)
|
||||
if (!rel || rel.state !== 'active') return { applied: [], conflicts: [] }
|
||||
// peerId 必须匹配: 否则 A 关系的对方能拿自己的令牌来删 B 关系的文件
|
||||
if (peerId && rel.peerId !== peerId) return { applied: [], conflicts: [] }
|
||||
|
||||
const manifest = this.manifestStore.loadManifest(relationId)
|
||||
// 先收消息 (推高时钟), 再合并字段 —— 和扫描流程保持同一个因果顺序
|
||||
const peerEntries = fromWire(wire)
|
||||
advanceClock(manifest, peerEntries)
|
||||
// **先把本地实际有的文件登记进来**。
|
||||
// 不登记的话: 对方知道这个文件、我本地也有, 但关系表里没有它的记录 ->
|
||||
// 我本地把它删了时, 关系表看不出"这是删除" -> 删除传播不出去 (墓碑建不出来)。
|
||||
try {
|
||||
// 注意: 必须扫**实际同步根** (顶层目录 + 关系子目录), 用顶层目录会把
|
||||
// 子目录名写进 relPath (比如 "A机/hello.txt"), 污染关系表。
|
||||
const local = scanLocalDir(this.localRootOf(rel), true)
|
||||
if (local.ok) reconcileLocal(manifest, local.files)
|
||||
} catch { /* 同步目录还没建好就跳过, 下一轮再登记 */ }
|
||||
mergePeer(manifest, peerEntries)
|
||||
|
||||
const applied: string[] = []
|
||||
const conflicts: string[] = []
|
||||
for (const [p, e] of Object.entries(manifest.entries)) {
|
||||
if (e.peerState !== 'deleted') continue
|
||||
if (e.myState !== 'present') continue // 我这边本来就没有
|
||||
if (!e.baseHash) continue // 没同步过 -> 不是同一个文件
|
||||
if (e.myHash && e.myHash !== e.baseHash) {
|
||||
conflicts.push(p) // 我改过 -> 冲突, 交给用户
|
||||
continue
|
||||
}
|
||||
const target = localTarget(this.localRootOf(rel), p)
|
||||
if (target) {
|
||||
try {
|
||||
if (existsSync(target)) unlinkSync(target)
|
||||
} catch { /* 删不掉就当没删, 下次再说 */ }
|
||||
}
|
||||
markLocalDeleted(manifest, p)
|
||||
applied.push(p)
|
||||
}
|
||||
this.manifestStore.saveManifest(manifest)
|
||||
|
||||
if (applied.length) {
|
||||
recordAudit({
|
||||
action: 'sync.delete.propagated', source: rel.peerId, target: rel.peerId,
|
||||
result: 'ok', payload: { relationId, files: applied.slice(0, 50), count: applied.length },
|
||||
})
|
||||
this.notify('sync:applied', { relationId, peerId: rel.peerId, applied })
|
||||
}
|
||||
if (conflicts.length) {
|
||||
this.notify('sync:conflict', { relationId, peerId: rel.peerId, conflicts })
|
||||
}
|
||||
return { applied, conflicts }
|
||||
}
|
||||
|
||||
// ---- 吊销 ----
|
||||
|
||||
revokeRelation(relationId: string): { ok: boolean; reason?: string } {
|
||||
const rel = this.store.getRelation(relationId)
|
||||
if (!rel) return { ok: false, reason: '关系不存在' }
|
||||
// 换掉我方签发的令牌 (对方手里那份立刻失效), 同时清掉我手里的对方令牌
|
||||
// —— 不清的话, 我吊销了却还能读写对方的网盘/同步区, 不对称。
|
||||
this.store.revokeRelation(relationId)
|
||||
this.store.updateRelation(relationId, { peerToken: '' })
|
||||
// 关系没了, 关系表也清掉 (下次重新建立时从干净状态开始)
|
||||
try { this.manifestStore.deleteManifest(relationId) } catch {}
|
||||
// 通知对端: 让对方也把这关系作废 (不然对方界面还显示"已建立", 还拿着旧令牌)
|
||||
try { this.send(rel.peerId, { type: 'relation:revoked', payload: { relationId } }) } catch { /* 对方不在线就算了 */ }
|
||||
recordAudit({
|
||||
action: 'relation.revoke', source: this.self.deviceId, target: rel.peerId,
|
||||
result: 'ok', payload: { relationId },
|
||||
})
|
||||
this.notify('relation:changed', { relationId, peerId: rel.peerId, state: 'revoked' })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** 对端告诉我它把关系吊销了 -> 我这边也作废 */
|
||||
handleRelationRevoked(peer: DeviceInfo, relationId: string): void {
|
||||
const rel = this.store.getRelation(relationId)
|
||||
if (!rel) return
|
||||
this.store.updateRelation(relationId, { state: 'revoked', myToken: newRelationToken(), peerToken: '' })
|
||||
try { this.manifestStore.deleteManifest(relationId) } catch {}
|
||||
recordAudit({
|
||||
action: 'relation.revoked.byPeer', source: peer.deviceId, target: this.self.deviceId,
|
||||
result: 'ok', payload: { relationId },
|
||||
})
|
||||
this.notify('relation:changed', { relationId, peerId: peer.deviceId, state: 'revoked' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// 关系层: 设备之间的**持久绑定** + **分级授权**
|
||||
//
|
||||
// 为什么要有这层 (原来最大的问题):
|
||||
// 设备启动 -> UDP 广播 announce{deviceId, name, ..., driveToken}
|
||||
// -> 局域网所有机器收到 -> 自动进"最近设备" -> 自动建 WS
|
||||
// -> 消息双向开放; 网盘 token 在广播包里明文传
|
||||
// 结果: 同网段任何进程拿到广播包就能浏览你的整个网盘, 而且没有"授权"这个概念。
|
||||
//
|
||||
// 现在拆成三层:
|
||||
// 发现 (Discovery) 知道对方存在, 临时 (15s 心跳), **不产生信任**
|
||||
// 关系 (Relation) 用户显式建立的一次绑定, 持久
|
||||
// 授权 (Grant) 关系上的能力, 双方**各自独立**授权
|
||||
//
|
||||
// 建立关系的方式 —— 和远程终端的授权闸门完全一致 (不搞邀请码那一套):
|
||||
// A 在同步面板点"建立同步关系" -> 选本地目录 -> 发请求
|
||||
// -> B 那边**直接弹窗** (跟远程终端那个弹窗是同一套组件)
|
||||
// -> B 勾选要开放的能力 + 选自己的本地目录 -> 同意/拒绝
|
||||
// 双方各自落库, 交换 per-peer 令牌 -> 之后网盘和同步都按关系授权。
|
||||
//
|
||||
// 令牌模型:
|
||||
// myToken 我签发给对方, 对方用它访问我的网盘/同步区
|
||||
// peerToken 对方签发给我, 我用它访问对方的网盘/同步区
|
||||
// 旧的全局 driveToken **不再广播**, 只作"对方还没升级"时的兼容通道。
|
||||
//
|
||||
// 存储: JSON 文件 (原子写)。选文件而不是 DB 的理由:
|
||||
// - 关系是"少量、需要原子整体替换"的状态, 不是需要 SQL 聚合的数据
|
||||
// - 不用做 schema 迁移, 老版本升级零风险
|
||||
// - 单测可以直接指定临时文件路径, 不碰用户真实数据
|
||||
// (先例: 远程操作白名单 remoteAllowPeers 也存在 settings.json 而不是 DB)
|
||||
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
// ============ 类型 ============
|
||||
|
||||
/** 关系上可以开放的能力 */
|
||||
export type Grant = 'drive' | 'sync'
|
||||
|
||||
export const ALL_GRANTS: Grant[] = ['drive', 'sync']
|
||||
|
||||
export const GRANT_LABEL: Record<Grant, string> = {
|
||||
drive: '允许浏览我的网盘',
|
||||
sync: '允许与我同步文件',
|
||||
}
|
||||
|
||||
export type RelationState = 'pending' | 'active' | 'revoked'
|
||||
|
||||
export interface Relation {
|
||||
relationId: string
|
||||
/** 对方 deviceId */
|
||||
peerId: string
|
||||
peerName: string
|
||||
peerAddress: string
|
||||
peerChatPort: number
|
||||
peerFilePort: number
|
||||
state: RelationState
|
||||
/** 我开放给对方的能力 */
|
||||
myGrants: Grant[]
|
||||
/** 对方开放给我的能力 */
|
||||
peerGrants: Grant[]
|
||||
/** 我签发给对方的令牌 (对方用它访问我的网盘) */
|
||||
myToken: string
|
||||
/** 对方签发给我的令牌 (我用它访问对方的网盘) */
|
||||
peerToken: string
|
||||
/** 我这边的同步目录 (绝对路径) —— 同步区就是以它为根 */
|
||||
syncLocalDir: string
|
||||
/**
|
||||
* 同步目录下的**按关系隔离的子目录** (通常是对方设备名)。
|
||||
*
|
||||
* 这样"一个顶层目录管多个聊天备份"才真正成立:
|
||||
* ~/备份/MECHREVO/ <- 和 MECHREVO 同步的数据
|
||||
* ~/备份/lenovo/ <- 和 lenovo 同步的数据
|
||||
* 物理隔离, 两个 peer 的同名文件不会互相覆盖, 各关系的关系表也不会串。
|
||||
*/
|
||||
syncSubdir: string
|
||||
/**
|
||||
* 下面两个"网盘子目录"字段是**早期设计残留**, 现在**不再决定数据落点**:
|
||||
* 同步走的是独立的同步区 (/sync/*), 路径相对对方的 syncLocalDir。
|
||||
* 保留它们是为了老数据反序列化不报错, 不要拿它们算路径。
|
||||
*/
|
||||
syncRemotePath: string
|
||||
peerSyncRoot: string
|
||||
/** 是否开启自动同步 */
|
||||
syncEnabled: boolean
|
||||
/**
|
||||
* 下面两个序号是早期设计留下的空壳 —— 真正的变更序号 (Lamport 时钟) 在
|
||||
* 文件关系表 (sync-manifest.ts) 的 manifest.clock / manifest.remoteSeq 里。
|
||||
* 保留字段是为了老数据反序列化不报错, 不要往里写东西。
|
||||
*/
|
||||
localSeq: number
|
||||
remoteSeq: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
lastSyncAt: number
|
||||
lastError: string
|
||||
}
|
||||
|
||||
export interface RelationsFile {
|
||||
version: number
|
||||
relations: Relation[]
|
||||
}
|
||||
|
||||
// ============ 常量 ============
|
||||
|
||||
/** 关系令牌长度 (hex 字符数) */
|
||||
const TOKEN_LEN = 32
|
||||
|
||||
// ============ 工具 ============
|
||||
|
||||
export function newRelationToken(): string {
|
||||
return randomUUID().replace(/-/g, '').slice(0, TOKEN_LEN)
|
||||
}
|
||||
|
||||
/** 定时安全比较 (长度不同直接 false, 不抛) */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
if (!a || !b) return false
|
||||
const ba = Buffer.from(a, 'utf8')
|
||||
const bb = Buffer.from(b, 'utf8')
|
||||
if (ba.length !== bb.length) return false
|
||||
try { return timingSafeEqual(ba, bb) } catch { return false }
|
||||
}
|
||||
|
||||
function now(): number { return Date.now() }
|
||||
|
||||
/** 清掉路径里的前后空白和尾部斜杠 (子目录约定用) */
|
||||
export function normRelDir(p: string): string {
|
||||
return (p || '').trim().replace(/^[/\\]+/, '').replace(/[/\\]+$/, '')
|
||||
}
|
||||
|
||||
// ============ 存储 ============
|
||||
|
||||
export class RelationStore {
|
||||
private file: string
|
||||
|
||||
constructor(file: string) {
|
||||
this.file = file
|
||||
}
|
||||
|
||||
private load(): RelationsFile {
|
||||
try {
|
||||
if (!existsSync(this.file)) return { version: 1, relations: [] }
|
||||
const raw = JSON.parse(readFileSync(this.file, 'utf8'))
|
||||
if (!raw || typeof raw !== 'object') return { version: 1, relations: [] }
|
||||
return {
|
||||
version: Number(raw.version) || 1,
|
||||
relations: Array.isArray(raw.relations) ? raw.relations : [],
|
||||
}
|
||||
} catch {
|
||||
// 文件损坏不能让应用起不来: 备份一份坏的, 从空状态开始
|
||||
try {
|
||||
if (existsSync(this.file)) renameSync(this.file, `${this.file}.broken-${now()}`)
|
||||
} catch { /* ignore */ }
|
||||
return { version: 1, relations: [] }
|
||||
}
|
||||
}
|
||||
|
||||
private save(data: RelationsFile): void {
|
||||
const dir = dirname(this.file)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
// 原子写: 先写临时文件再 rename, 避免中途崩溃写坏
|
||||
const tmp = `${this.file}.tmp-${process.pid}`
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
|
||||
renameSync(tmp, this.file)
|
||||
}
|
||||
|
||||
private mutate(fn: (d: RelationsFile) => void): void {
|
||||
const d = this.load()
|
||||
fn(d)
|
||||
this.save(d)
|
||||
}
|
||||
|
||||
// ---- 关系 ----
|
||||
|
||||
listRelations(): Relation[] {
|
||||
return this.load().relations.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
/** 只列有效关系 */
|
||||
listActiveRelations(): Relation[] {
|
||||
return this.listRelations().filter(r => r.state === 'active')
|
||||
}
|
||||
|
||||
getRelation(relationId: string): Relation | undefined {
|
||||
return this.load().relations.find(r => r.relationId === relationId)
|
||||
}
|
||||
|
||||
/** 按对方 deviceId 找关系 (任一状态, active 优先, 新的优先) */
|
||||
getRelationByPeer(peerId: string): Relation | undefined {
|
||||
const list = this.load().relations
|
||||
.filter(r => r.peerId === peerId)
|
||||
.sort((a, b) => (b.state === 'active' ? 1 : 0) - (a.state === 'active' ? 1 : 0) || b.updatedAt - a.updatedAt)
|
||||
return list[0]
|
||||
}
|
||||
|
||||
/** 按对方 deviceId 找**有效**关系 */
|
||||
getActiveRelationByPeer(peerId: string): Relation | undefined {
|
||||
const list = this.load().relations
|
||||
.filter(r => r.peerId === peerId && r.state === 'active')
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return list[0]
|
||||
}
|
||||
|
||||
upsertRelation(r: Relation): Relation {
|
||||
this.mutate(d => {
|
||||
const i = d.relations.findIndex(x => x.relationId === r.relationId)
|
||||
if (i >= 0) d.relations[i] = r
|
||||
else d.relations.push(r)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
updateRelation(relationId: string, patch: Partial<Relation>): Relation | undefined {
|
||||
this.mutate(d => {
|
||||
const i = d.relations.findIndex(x => x.relationId === relationId)
|
||||
if (i < 0) return
|
||||
d.relations[i] = { ...d.relations[i], ...patch, updatedAt: now() }
|
||||
})
|
||||
return this.getRelation(relationId)
|
||||
}
|
||||
|
||||
/** 吊销关系: 令牌立刻失效, 对方的请求直接 403 */
|
||||
revokeRelation(relationId: string): boolean {
|
||||
const r = this.getRelation(relationId)
|
||||
if (!r) return false
|
||||
// 换掉令牌: 即使对方还拿着旧的也访问不了
|
||||
this.updateRelation(relationId, { state: 'revoked', myToken: newRelationToken(), updatedAt: now() })
|
||||
return true
|
||||
}
|
||||
|
||||
deleteRelation(relationId: string): boolean {
|
||||
let ok = false
|
||||
this.mutate(d => {
|
||||
const n = d.relations.length
|
||||
d.relations = d.relations.filter(r => r.relationId !== relationId)
|
||||
ok = d.relations.length !== n
|
||||
})
|
||||
return ok
|
||||
}
|
||||
|
||||
/** 改我开放给对方的能力 */
|
||||
setMyGrants(relationId: string, grants: Grant[]): Relation | undefined {
|
||||
return this.updateRelation(relationId, { myGrants: grants })
|
||||
}
|
||||
|
||||
/** 改同步配置 (本地目录 / 对方网盘子目录 / 自动同步开关) */
|
||||
setSyncConfig(relationId: string, patch: {
|
||||
syncLocalDir?: string
|
||||
syncRemotePath?: string
|
||||
peerSyncRoot?: string
|
||||
syncEnabled?: boolean
|
||||
}): Relation | undefined {
|
||||
return this.updateRelation(relationId, patch)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 授权判定 ============
|
||||
|
||||
export interface DriveAuth {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
peerId?: string
|
||||
relationId?: string
|
||||
/** 限定在这个子目录内 (相对网盘根); 空 = 不限制 */
|
||||
scopeRoot?: string
|
||||
grants?: Grant[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 网盘/同步区访问授权判定。
|
||||
*
|
||||
* token 必须是"我签发给对方"的那个 per-peer 令牌, 且关系有效、且对方被授予了
|
||||
* drive 或 sync 能力。只有 sync 能力时, 访问被限制在该关系的专用子目录内
|
||||
* (peerSyncRoot), 对方碰不到我网盘里的其它东西。
|
||||
*/
|
||||
export function authorizeDriveAccess(store: RelationStore, token: string | null, peerId?: string | null): DriveAuth {
|
||||
if (!token) return { ok: false, reason: '缺少令牌' }
|
||||
const relations = store.listRelations().filter(r => r.state === 'active')
|
||||
const rel = relations.find(r => safeEqual(r.myToken, token))
|
||||
if (!rel) return { ok: false, reason: '令牌无效' }
|
||||
if (peerId && rel.peerId !== peerId) return { ok: false, reason: '令牌与设备不匹配' }
|
||||
if (!rel.myGrants.length) return { ok: false, reason: '未授权访问网盘' }
|
||||
// 网盘浏览的权限范围: 给了 drive 就是整个网盘; 只给 sync 的话网盘浏览**完全不给**,
|
||||
// 对方只能通过 /sync/* 访问本关系专属的同步目录。
|
||||
const scopeRoot = rel.myGrants.includes('drive') ? '' : 'none'
|
||||
return {
|
||||
ok: true, peerId: rel.peerId, relationId: rel.relationId,
|
||||
scopeRoot, grants: rel.myGrants,
|
||||
}
|
||||
}
|
||||
|
||||
/** 对方开放给我的能力 */
|
||||
export function peerGrantsOf(store: RelationStore, peerId: string): Grant[] {
|
||||
const rel = store.getActiveRelationByPeer(peerId)
|
||||
return rel ? rel.peerGrants : []
|
||||
}
|
||||
|
||||
/** 我开放给对方的能力 */
|
||||
export function myGrantsOf(store: RelationStore, peerId: string): Grant[] {
|
||||
const rel = store.getActiveRelationByPeer(peerId)
|
||||
return rel ? rel.myGrants : []
|
||||
}
|
||||
|
||||
/** 对方是否给了我某项能力 */
|
||||
export function peerCan(store: RelationStore, peerId: string, grant: Grant): boolean {
|
||||
return peerGrantsOf(store, peerId).includes(grant)
|
||||
}
|
||||
+45
-14
@@ -1,4 +1,4 @@
|
||||
// 远程操作授权闸门: 决定一个对端能不能开 terminal/forward/usb
|
||||
// 远程操作授权闸门: 决定一个对端能不能开 terminal/forward/serial
|
||||
// 规则:
|
||||
// 1. settings.remoteEnabled === false -> 全部拒绝
|
||||
// 2. settings.remoteAllowPeers[peerId][kind] === true -> 自动放行
|
||||
@@ -8,30 +8,49 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { getSettings } from '../settings'
|
||||
|
||||
export type RemoteKind = 'terminal' | 'forward' | 'usb'
|
||||
export type RemoteKind = 'terminal' | 'forward' | 'serial' | 'relation'
|
||||
|
||||
/** 需要走"远程操作白名单"的种类 (relation 走关系层自己的授权, 不在这张表里) */
|
||||
export type RemoteGateKind = Exclude<RemoteKind, 'relation'>
|
||||
|
||||
interface Pending {
|
||||
resolve: (ok: boolean, remember?: boolean) => void
|
||||
resolve: (reply: ApprovalReply) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户对授权请求的回复。
|
||||
* terminal/forward/serial 只用到 ok + remember;
|
||||
* relation (建立同步关系) 还会带上用户勾选的授权范围和他自己的同步目录。
|
||||
*/
|
||||
export interface ApprovalReply {
|
||||
ok: boolean
|
||||
remember?: boolean
|
||||
/** relation: 用户开放给对方的能力 */
|
||||
grants?: string[]
|
||||
/** relation: 用户这边的同步目录 */
|
||||
syncLocalDir?: string
|
||||
/** relation: 允许对方用我网盘里的哪个子目录 */
|
||||
syncRemotePath?: string
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>()
|
||||
const approvedAt = new Map<string, number>()
|
||||
const APPROVE_TTL_MS = 24 * 3600_000
|
||||
|
||||
function cacheKey(peerId: string, kind: RemoteKind, nonce?: string) {
|
||||
// nonce 用于 usb.attach 这种需要每次重新确认的场景
|
||||
function cacheKey(peerId: string, kind: RemoteGateKind, nonce?: string) {
|
||||
// nonce 用于 attach 这种需要每次重新确认的场景
|
||||
return nonce ? `${peerId}::${kind}::${nonce}` : `${peerId}::${kind}`
|
||||
}
|
||||
|
||||
export function isAutoAllowed(peerId: string, kind: RemoteKind): boolean {
|
||||
export function isAutoAllowed(peerId: string, kind: RemoteGateKind): boolean {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) return false
|
||||
const allow = s.remoteAllowPeers?.[peerId]?.[kind]
|
||||
return !!allow
|
||||
}
|
||||
|
||||
export function isRemembered(peerId: string, kind: RemoteKind): boolean {
|
||||
export function isRemembered(peerId: string, kind: RemoteGateKind): boolean {
|
||||
const k = cacheKey(peerId, kind)
|
||||
const ts = approvedAt.get(k)
|
||||
if (!ts) return false
|
||||
@@ -42,11 +61,11 @@ export function isRemembered(peerId: string, kind: RemoteKind): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function markApproved(peerId: string, kind: RemoteKind) {
|
||||
export function markApproved(peerId: string, kind: RemoteGateKind) {
|
||||
approvedAt.set(cacheKey(peerId, kind), Date.now())
|
||||
}
|
||||
|
||||
export function resetApproval(peerId: string, kind?: RemoteKind) {
|
||||
export function resetApproval(peerId: string, kind?: RemoteGateKind) {
|
||||
for (const k of approvedAt.keys()) {
|
||||
if (k.startsWith(`${peerId}::`)) {
|
||||
if (!kind || k.startsWith(`${peerId}::${kind}`)) approvedAt.delete(k)
|
||||
@@ -55,7 +74,7 @@ export function resetApproval(peerId: string, kind?: RemoteKind) {
|
||||
}
|
||||
|
||||
// 同步决策: 自动允许 / 缓存允许 -> 直接 true; 否则 false (UI 需要主动 ask)
|
||||
export function shouldAsk(peerId: string, kind: RemoteKind): boolean {
|
||||
export function shouldAsk(peerId: string, kind: RemoteGateKind): boolean {
|
||||
if (isAutoAllowed(peerId, kind)) return false
|
||||
if (isRemembered(peerId, kind)) {
|
||||
markApproved(peerId, kind)
|
||||
@@ -75,12 +94,20 @@ export interface ApprovalRequest {
|
||||
kind: RemoteKind
|
||||
detail?: string
|
||||
ts: number
|
||||
/** relation: 对方想要的能力 */
|
||||
grants?: string[]
|
||||
/** relation: 对方的同步目录 */
|
||||
syncLocalDir?: string
|
||||
/** relation: 对方想用我网盘里的哪个子目录 */
|
||||
syncRemotePath?: string
|
||||
/** relation: 关系 id */
|
||||
relationId?: string
|
||||
}
|
||||
|
||||
const waitingQueue: ApprovalRequest[] = []
|
||||
const waitersByReq = new Map<string, Pending>()
|
||||
|
||||
export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; remember?: boolean }> {
|
||||
export function enqueueApproval(req: ApprovalRequest): Promise<ApprovalReply> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(req.requestId)
|
||||
@@ -90,7 +117,7 @@ export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; re
|
||||
if (i >= 0) waitingQueue.splice(i, 1)
|
||||
resolve({ ok: false })
|
||||
}, ASK_TIMEOUT_MS)
|
||||
pending.set(req.requestId, { resolve: (ok, remember) => resolve({ ok, remember }), timer })
|
||||
pending.set(req.requestId, { resolve, timer })
|
||||
waitersByReq.set(req.requestId, pending.get(req.requestId)!)
|
||||
waitingQueue.push(req)
|
||||
})
|
||||
@@ -100,7 +127,11 @@ export function listPendingApprovals(): ApprovalRequest[] {
|
||||
return [...waitingQueue]
|
||||
}
|
||||
|
||||
export function replyApproval(requestId: string, ok: boolean, remember?: boolean): boolean {
|
||||
export function replyApproval(
|
||||
requestId: string,
|
||||
ok: boolean,
|
||||
extra?: { remember?: boolean; grants?: string[]; syncLocalDir?: string; syncRemotePath?: string },
|
||||
): boolean {
|
||||
const p = pending.get(requestId)
|
||||
if (!p) return false
|
||||
clearTimeout(p.timer)
|
||||
@@ -108,7 +139,7 @@ export function replyApproval(requestId: string, ok: boolean, remember?: boolean
|
||||
waitersByReq.delete(requestId)
|
||||
const i = waitingQueue.findIndex(x => x.requestId === requestId)
|
||||
if (i >= 0) waitingQueue.splice(i, 1)
|
||||
p.resolve(ok, remember)
|
||||
p.resolve({ ok, ...(extra || {}) })
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -575,7 +575,7 @@ export class ForwardManager extends EventEmitter {
|
||||
for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown')
|
||||
}
|
||||
|
||||
resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') {
|
||||
resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'serial') {
|
||||
resetApproval(peerId, kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
// 串口映射 + 串口助手 (主进程侧)
|
||||
//
|
||||
// 只做串口。USB 字节桥 (libusb control/bulk) 和 USB/IP 透传在 v0.2.0 下线了 ——
|
||||
// 纯 Node.js 在 Windows/macOS 上做不了真透明 USB, 而字节桥只对会写控制传输的人
|
||||
// 有意义, 使用面太窄。不如把串口这条真正好用的路做扎实。
|
||||
//
|
||||
// 线上帧类型仍然是 'usb' (见 protocol.ts 的说明): 为了跟老版本对端兼容,
|
||||
// 内部只做串口, 但线上一个字节都没改 —— 新旧版本之间串口映射照样能用。
|
||||
//
|
||||
// 两种方向:
|
||||
// self-out: 我用对方的串口 —— 对端打开真实串口, 字节流转发给我。
|
||||
// 我这边可以 (a) 建一个本机虚拟串口做"串口映射", 本机任何工具直接打开;
|
||||
// (b) 不建虚拟口, 应用自己当串口助手收发字节。
|
||||
// self-in: 对方用我的串口 —— 我打开真实串口, 字节流转发给对方。
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { platform } from 'node:process'
|
||||
import type {
|
||||
DeviceInfo, SerialFrame, SerialDeviceInfo, WsFrame, SerialDirection,
|
||||
SerialAttachConfig, SerialAttachedInfo,
|
||||
} from '../protocol'
|
||||
import type { ChatClient } from '../chat-client'
|
||||
import { getSettings } from '../settings'
|
||||
import { shouldAsk, enqueueApproval, markApproved } from './approval'
|
||||
import { recordAudit } from '../db'
|
||||
import { createVirtualSerialPair, type VirtualSerialInfo } from './vcom'
|
||||
|
||||
// 串口: 试着 require (可缺失 — 那就列不出设备)
|
||||
let spMod: any = null
|
||||
try { spMod = require('serialport') } catch { /* ignored */ }
|
||||
|
||||
const MAX_DATA_CHUNK = 32 * 1024
|
||||
|
||||
export interface SerialManagerEvents {
|
||||
approvalRequested: (req: { peerId: string; peerName: string; kind: 'serial'; detail: string; requestId: string }) => void
|
||||
sessionOpened: (info: {
|
||||
sessionId: string; peerId: string; direction: SerialDirection; busId: string
|
||||
info?: SerialAttachedInfo; side: 'client' | 'server'
|
||||
}) => void
|
||||
sessionClosed: (info: { sessionId: string; peerId: string; reason?: string; bytesIn: number; bytesOut: number; side: 'client' | 'server' }) => void
|
||||
sessionError: (info: { sessionId: string; peerId: string; error: string }) => void
|
||||
/** 串口字节流 —— UI 用它显示收发 (串口助手的流量视图)
|
||||
* dir: 'rx' = 设备发来的 (对端设备 -> 我), 'tx' = 我发出去的 */
|
||||
output: (sessionId: string, bytes: Buffer, peerId: string, dir: 'rx' | 'tx') => void
|
||||
}
|
||||
|
||||
// ===== Client (我发起的, 设备在对端) =====
|
||||
interface ClientSession {
|
||||
sessionId: string
|
||||
peerId: string
|
||||
direction: SerialDirection
|
||||
busId: string
|
||||
info?: SerialAttachedInfo
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
createdAt: number
|
||||
// 串口映射: 本机的虚拟串口 (self-out + createVirtual). 我们持有内部那端
|
||||
localVirtual?: {
|
||||
port: any // SerialPort 实例 (内部端)
|
||||
userPath: string // 给用户工具用的路径 (COM5 / /tmp/lnm-vcom-x/user)
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Server (对端发起的, 真实串口在我这) =====
|
||||
interface ServerSession {
|
||||
sessionId: string
|
||||
ownerId: string
|
||||
peerId: string
|
||||
busId: string
|
||||
// 串口: SerialPort 实例
|
||||
serial?: any
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
info?: SerialAttachedInfo
|
||||
// 串口关闭 promise (等数据 drain)
|
||||
serialClosing?: Promise<void>
|
||||
attachResolver?: (r: { ok: boolean; reason?: string; info?: SerialAttachedInfo }) => void
|
||||
attachRejecter?: (e: Error) => void
|
||||
}
|
||||
|
||||
export class SerialManager extends EventEmitter {
|
||||
private clientSessions = new Map<string, ClientSession>()
|
||||
private serverSessions = new Map<string, ServerSession>()
|
||||
private chatClient: ChatClient
|
||||
// list 请求的待回包
|
||||
private pendingLists = new Map<string, (r: { ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }) => void>()
|
||||
|
||||
constructor(chatClient: ChatClient) {
|
||||
super()
|
||||
this.chatClient = chatClient
|
||||
}
|
||||
|
||||
// ====== 本机设备列举 ======
|
||||
|
||||
/** 列本机的串口 */
|
||||
async listLocal(): Promise<SerialDeviceInfo[]> {
|
||||
const out: SerialDeviceInfo[] = []
|
||||
if (!spMod) return out
|
||||
try {
|
||||
const ports: any[] = await spMod.SerialPort.list()
|
||||
for (const p of ports) {
|
||||
out.push({
|
||||
busId: `serial:${p.path}`,
|
||||
serialPath: p.path,
|
||||
vid: p.vendorId ? parseInt(p.vendorId, 16) || 0 : undefined,
|
||||
pid: p.productId ? parseInt(p.productId, 16) || 0 : undefined,
|
||||
product: p.friendlyName || p.productId,
|
||||
manufacturer: p.manufacturer,
|
||||
serialNumber: p.serialNumber,
|
||||
baudRate: 115200,
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[serial] serialport.list failed:', e?.message)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 问对端要它的串口列表 */
|
||||
async listOnPeer(peerId: string): Promise<{ ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }> {
|
||||
const reqId = randomUUID()
|
||||
const sent = this.chatClient.send({ type: 'usb', payload: { type: 'list', reqId } } as WsFrame, peerId)
|
||||
if (!sent) return { ok: false, reason: '对方离线' }
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingLists.delete(reqId)
|
||||
resolve({ ok: false, reason: '超时' })
|
||||
}, 15_000)
|
||||
this.pendingLists.set(reqId, (r) => {
|
||||
clearTimeout(timer)
|
||||
resolve(r)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ====== attach ======
|
||||
|
||||
/**
|
||||
* attach:
|
||||
* self-out -> 对端打开真实串口, 我收字节 (可选: 在我本机建虚拟串口做映射)
|
||||
* self-in -> 我打开真实串口, 对端收字节
|
||||
*/
|
||||
async attach(peerId: string, opts: {
|
||||
direction?: SerialDirection
|
||||
busId: string
|
||||
config: SerialAttachConfig
|
||||
}): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) return { ok: false, reason: '远程功能已关闭' }
|
||||
const direction: SerialDirection = opts.direction || 'self-out'
|
||||
if (!opts.busId) return { ok: false, reason: 'busId 不能为空' }
|
||||
if (opts.config?.kind !== 'serial') return { ok: false, reason: '只支持串口' }
|
||||
if (direction === 'self-in') {
|
||||
// 我方是设备持有方: 我打开真实串口
|
||||
return this.serverAttach(peerId, opts.busId, opts.config)
|
||||
}
|
||||
// 我方是控制方: 对端打开真实串口
|
||||
return this.clientAttach(peerId, opts.busId, opts.config)
|
||||
}
|
||||
|
||||
/** self-out: 控制端是 client; 设备在对端 */
|
||||
private clientAttach(peerId: string, busId: string, config: SerialAttachConfig): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
|
||||
const sessionId = randomUUID()
|
||||
const frame: Extract<SerialFrame, { type: 'attach' }> = { type: 'attach', sessionId, direction: 'self-out', busId, config }
|
||||
const sent = this.chatClient.send({ type: 'usb', payload: frame } as WsFrame, peerId)
|
||||
if (!sent) return Promise.resolve({ ok: false, reason: '对方离线' })
|
||||
|
||||
const sess: ClientSession = {
|
||||
sessionId, peerId, direction: 'self-out', busId,
|
||||
bytesIn: 0, bytesOut: 0, createdAt: Date.now(),
|
||||
}
|
||||
this.clientSessions.set(sessionId, sess)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.clientSessions.has(sessionId)) {
|
||||
this.cleanupClientSession(sessionId, '对方 30s 内未响应')
|
||||
resolve({ ok: false, reason: '对方 30s 内未响应' })
|
||||
}
|
||||
}, 30_000)
|
||||
// 单独的 resolver: 控制端由 'attached' 帧 (被控端 attach-ack) resolve
|
||||
sess.info = undefined
|
||||
const waitAttached = (p: any) => {
|
||||
if (p.sessionId !== sessionId) return
|
||||
this.chatClient.off('serial:attached' as any, waitAttached as any)
|
||||
clearTimeout(timer)
|
||||
if (!p.ok) {
|
||||
this.clientSessions.delete(sessionId)
|
||||
resolve({ ok: false, reason: p.reason || '对方拒绝' })
|
||||
return
|
||||
}
|
||||
sess.info = p.info
|
||||
// 串口映射: 先建本机虚拟串口, 再 resolve
|
||||
if (config.createVirtual) {
|
||||
this.setupLocalVirtualSerial(sessionId, config).then((r: { ok: boolean; reason?: string; userPath?: string }) => {
|
||||
if (!r.ok) {
|
||||
this.cleanupClientSession(sessionId, r.reason || '虚拟串口创建失败')
|
||||
resolve({ ok: false, reason: r.reason })
|
||||
return
|
||||
}
|
||||
sess.info = { ...(p.info || {}), kind: 'serial', userVirtualPath: r.userPath } as any
|
||||
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, info: sess.info, side: 'client' })
|
||||
recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'ok', payload: { busId, direction: 'self-out', virtualPath: r.userPath } })
|
||||
resolve({ ok: true, sessionId })
|
||||
}).catch((e: Error) => {
|
||||
this.cleanupClientSession(sessionId, e?.message || '虚拟串口异常')
|
||||
resolve({ ok: false, reason: e?.message || '虚拟串口异常' })
|
||||
})
|
||||
return
|
||||
}
|
||||
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, info: p.info, side: 'client' })
|
||||
recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'ok', payload: { busId, direction: 'self-out' } })
|
||||
resolve({ ok: true, sessionId })
|
||||
}
|
||||
this.chatClient.on('serial:attached' as any, waitAttached as any)
|
||||
})
|
||||
}
|
||||
|
||||
/** self-in: 我方是 server, 真实串口在我方 */
|
||||
private async serverAttach(peerId: string, busId: string, config: SerialAttachConfig, sessionId: string = randomUUID()): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
|
||||
// 1. 解析 busId 找到本机真实路径
|
||||
const local = await this.listLocal()
|
||||
const dev = local.find(d => d.busId === busId)
|
||||
if (!dev) {
|
||||
recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'error', payload: { phase: 'no-device', busId } })
|
||||
return { ok: false, reason: '本机找不到该串口' }
|
||||
}
|
||||
if (!spMod) {
|
||||
return { ok: false, reason: 'serialport 模块未安装' }
|
||||
}
|
||||
|
||||
// 2. 开串口
|
||||
const sess: ServerSession = {
|
||||
sessionId, ownerId: peerId, peerId, busId,
|
||||
bytesIn: 0, bytesOut: 0,
|
||||
}
|
||||
this.serverSessions.set(sessionId, sess)
|
||||
|
||||
try {
|
||||
const sp = new spMod.SerialPort({
|
||||
path: dev.serialPath,
|
||||
baudRate: config.baudRate || 115200,
|
||||
dataBits: config.dataBits || 8,
|
||||
stopBits: config.stopBits || 1,
|
||||
parity: config.parity || 'none',
|
||||
autoOpen: false,
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sp.open((err: any) => err ? reject(err) : resolve())
|
||||
})
|
||||
sess.serial = sp
|
||||
// 串口读到的字节 -> 发给对端
|
||||
sp.on('data', (buf: Buffer) => {
|
||||
if (!buf || buf.length === 0) return
|
||||
sess.bytesIn += buf.length
|
||||
this.sendChunked(peerId, sessionId, 'dev->host', buf, false)
|
||||
this.emit('output', sessionId, buf, peerId, 'rx')
|
||||
})
|
||||
sp.on('close', () => {
|
||||
this.sendControl(peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true })
|
||||
this.cleanupServerSession(sessionId, 'serial-closed')
|
||||
})
|
||||
sp.on('error', (e: any) => {
|
||||
this.emit('sessionError', { sessionId, peerId, error: e.message })
|
||||
recordAudit({ action: 'serial.attach', source: peerId, sessionId, result: 'error', payload: { error: e.message } })
|
||||
})
|
||||
sess.info = {
|
||||
kind: 'serial',
|
||||
serialPath: dev.serialPath,
|
||||
vid: dev.vid, pid: dev.pid,
|
||||
product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber,
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.serverSessions.delete(sessionId)
|
||||
const reason = describeOpenError(dev.serialPath, e)
|
||||
recordAudit({ action: 'serial.attach', target: peerId, sessionId, result: 'error', payload: { phase: 'open', reason } })
|
||||
return { ok: false, reason }
|
||||
}
|
||||
|
||||
return { ok: true, sessionId }
|
||||
}
|
||||
|
||||
// ====== 帧处理 ======
|
||||
|
||||
handleIncoming(peer: DeviceInfo, f: SerialFrame) {
|
||||
switch (f.type) {
|
||||
case 'list':
|
||||
this.handlePeerList(peer, f)
|
||||
return
|
||||
case 'devices': {
|
||||
const cb = this.pendingLists.get(f.reqId)
|
||||
if (cb) {
|
||||
this.pendingLists.delete(f.reqId)
|
||||
cb({ ok: true, devices: f.devices })
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'attach':
|
||||
this.handleAttachReq(peer, f)
|
||||
return
|
||||
case 'attached': {
|
||||
// 控制端等的就是这个 (clientAttach 监听的是 chatClient 上的同名事件)
|
||||
this.chatClient.emit('serial:attached' as any, f)
|
||||
return
|
||||
}
|
||||
case 'detach':
|
||||
this.handleDetachReq(peer, f)
|
||||
return
|
||||
case 'detached': {
|
||||
this.chatClient.emit('serial:detached' as any, f)
|
||||
return
|
||||
}
|
||||
case 'data': {
|
||||
this.handleData(f)
|
||||
return
|
||||
}
|
||||
case 'error': {
|
||||
this.emit('sessionError', { sessionId: f.sessionId, peerId: peer.deviceId, error: f.reason })
|
||||
return
|
||||
}
|
||||
case 'ack':
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private handleData(f: Extract<SerialFrame, { type: 'data' }>) {
|
||||
const cs = this.clientSessions.get(f.sessionId)
|
||||
const ss = this.serverSessions.get(f.sessionId)
|
||||
if (!cs && !ss) return
|
||||
|
||||
if (f.dir === 'dev->host') {
|
||||
// 设备 -> 控制端
|
||||
if (cs) {
|
||||
if (f.fin) {
|
||||
// 半关信号: 关掉我方虚拟串口
|
||||
if (cs.localVirtual) {
|
||||
try { if (cs.localVirtual.port.isOpen) cs.localVirtual.port.close() } catch {}
|
||||
}
|
||||
return
|
||||
}
|
||||
const buf = Buffer.from(f.data || '', 'base64')
|
||||
cs.bytesIn += buf.length
|
||||
// 优先写虚拟串口 (用户工具在读)
|
||||
if (cs.localVirtual && cs.localVirtual.port.isOpen) {
|
||||
try { cs.localVirtual.port.write(buf) } catch (e: any) {
|
||||
this.emit('sessionError', { sessionId: cs.sessionId, peerId: cs.peerId, error: e.message })
|
||||
}
|
||||
}
|
||||
// 同时给 UI 缓冲 (串口助手的流量视图)
|
||||
this.emit('output', f.sessionId, buf, cs.peerId, 'rx')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// host->dev: 控制端 -> 设备
|
||||
if (ss) {
|
||||
if (f.fin) { try { ss.serial?.close() } catch {}; return }
|
||||
const buf = Buffer.from(f.data || '', 'base64')
|
||||
ss.bytesOut += buf.length
|
||||
try { ss.serial?.write(buf) } catch (e: any) {
|
||||
this.emit('sessionError', { sessionId: ss.sessionId, peerId: ss.peerId, error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 被控端处理 list 请求 */
|
||||
private async handlePeerList(peer: DeviceInfo, f: Extract<SerialFrame, { type: 'list' }>) {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) return
|
||||
if (shouldAsk(peer.deviceId, 'serial')) {
|
||||
const reqId = randomUUID()
|
||||
const detail = '对方请求查看本机的串口列表'
|
||||
const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail, ts: Date.now() })
|
||||
this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail })
|
||||
const verdict = await reqPromise
|
||||
if (!verdict.ok) return
|
||||
if (verdict.remember) markApproved(peer.deviceId, 'serial')
|
||||
}
|
||||
const devices = await this.listLocal()
|
||||
this.sendControl(peer.deviceId, { type: 'devices', reqId: f.reqId, devices })
|
||||
recordAudit({ action: 'serial.list', source: peer.deviceId, result: 'ok', payload: { count: devices.length } })
|
||||
}
|
||||
|
||||
/** 被控端处理 attach 请求 */
|
||||
private async handleAttachReq(peer: DeviceInfo, f: Extract<SerialFrame, { type: 'attach' }>) {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) {
|
||||
this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '对方关闭了远程功能' })
|
||||
return
|
||||
}
|
||||
if (shouldAsk(peer.deviceId, 'serial')) {
|
||||
const detail = describeAttach(f.direction, f.config, f.busId)
|
||||
const reqId = randomUUID()
|
||||
const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail, ts: Date.now() })
|
||||
this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'serial', detail })
|
||||
const verdict = await reqPromise
|
||||
if (!verdict.ok) {
|
||||
this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '用户拒绝' })
|
||||
recordAudit({ action: 'serial.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' })
|
||||
return
|
||||
}
|
||||
if (verdict.remember) markApproved(peer.deviceId, 'serial')
|
||||
}
|
||||
|
||||
// 老版本对端可能还在请求 USB 字节桥 —— 明确拒掉, 别让它卡在超时里
|
||||
if (f.config?.kind !== 'serial') {
|
||||
this.sendControl(peer.deviceId, {
|
||||
type: 'attached', sessionId: f.sessionId, ok: false,
|
||||
reason: `对方请求的是 ${f.config?.kind || '未知'} 模式, 本版本已下线该功能 (只支持串口)`,
|
||||
})
|
||||
recordAudit({ action: 'serial.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'unsupported-kind', kind: f.config?.kind } })
|
||||
return
|
||||
}
|
||||
|
||||
// 走和 self-in 一样的开串口流程 (沿用对端给的 sessionId, 两端 key 必须一致)
|
||||
const r = await this.serverAttach(peer.deviceId, f.busId, 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: 'serial.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'open', reason: r.reason } })
|
||||
}
|
||||
}
|
||||
|
||||
private handleDetachReq(peer: DeviceInfo, f: Extract<SerialFrame, { type: 'detach' }>) {
|
||||
const sess = this.serverSessions.get(f.sessionId)
|
||||
if (!sess) {
|
||||
this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: false, reason: 'session 不存在' })
|
||||
return
|
||||
}
|
||||
this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer')
|
||||
this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: true })
|
||||
}
|
||||
|
||||
// ====== 串口助手: 发字节 ======
|
||||
|
||||
/**
|
||||
* 发字节。
|
||||
* client session (self-out): 我的字节 -> 远端设备 (host->dev)
|
||||
* server session (self-in): 我的字节 -> 对端视图 (当作设备吐出来的, dev->host)
|
||||
*/
|
||||
async sendSerialBytes(sessionId: string, buf: Buffer): Promise<boolean> {
|
||||
if (!buf || buf.length === 0) return false
|
||||
const cs = this.clientSessions.get(sessionId)
|
||||
if (cs) {
|
||||
cs.bytesOut += buf.length
|
||||
this.sendChunked(cs.peerId, sessionId, 'host->dev', buf, false)
|
||||
return true
|
||||
}
|
||||
const ss = this.serverSessions.get(sessionId)
|
||||
if (ss) {
|
||||
ss.bytesOut += buf.length
|
||||
this.sendChunked(ss.peerId, sessionId, 'dev->host', buf, false)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 串口映射: 在本机创建虚拟串口, 打开内部端做桥接 (self-out + createVirtual) */
|
||||
private async setupLocalVirtualSerial(sessionId: string, config: SerialAttachConfig): Promise<{ ok: boolean; reason?: string; userPath?: string }> {
|
||||
if (!spMod) return { ok: false, reason: 'serialport 未安装' }
|
||||
const sess = this.clientSessions.get(sessionId)
|
||||
if (!sess) return { ok: false, reason: 'session 不存在' }
|
||||
|
||||
// 1. 创建 PTY 配对 (Linux/macOS) / com0com (Windows)
|
||||
let vcom: VirtualSerialInfo
|
||||
try {
|
||||
vcom = await createVirtualSerialPair({ userPath: config.virtualName })
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: `创建虚拟串口失败: ${e?.message || e}` }
|
||||
}
|
||||
|
||||
// 2. 用我们传的 baudRate 打开内部端
|
||||
const baudRate = config.baudRate || 115200
|
||||
let sp: any
|
||||
try {
|
||||
sp = new spMod.SerialPort({
|
||||
path: vcom.internalPath,
|
||||
baudRate,
|
||||
dataBits: config.dataBits || 8,
|
||||
stopBits: config.stopBits || 1,
|
||||
parity: config.parity || 'none',
|
||||
autoOpen: false,
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sp.open((err: any) => err ? reject(err) : resolve())
|
||||
})
|
||||
} catch (e: any) {
|
||||
await vcom.cleanup().catch(() => {})
|
||||
return { ok: false, reason: `打开内部虚拟端失败: ${e?.message || e}` }
|
||||
}
|
||||
|
||||
// 3. 数据桥接
|
||||
// 内部端收到字节 (用户工具写进来的) -> WS host->dev -> 远端写真实串口
|
||||
sp.on('data', (buf: Buffer) => {
|
||||
if (!buf || buf.length === 0) return
|
||||
sess.bytesOut += buf.length
|
||||
this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false)
|
||||
this.emit('output', sessionId, buf, sess.peerId, 'tx')
|
||||
})
|
||||
sp.on('close', () => {
|
||||
// 内部端被关 (用户工具关闭了虚拟串口?) -> 通知远端
|
||||
this.sendControl(sess.peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true })
|
||||
this.cleanupClientSession(sessionId, 'virtual-serial-closed-by-user-app')
|
||||
})
|
||||
sp.on('error', (e: any) => {
|
||||
this.emit('sessionError', { sessionId, peerId: sess.peerId, error: `virtual-serial: ${e.message}` })
|
||||
})
|
||||
|
||||
// 4. 存到 session, detach 时关
|
||||
sess.localVirtual = {
|
||||
port: sp,
|
||||
userPath: vcom.userPath,
|
||||
cleanup: async () => {
|
||||
try { sp.removeAllListeners() } catch {}
|
||||
try { if (sp.isOpen) await new Promise<void>((r) => sp.close(() => r())) } catch {}
|
||||
await vcom.cleanup().catch(() => {})
|
||||
},
|
||||
}
|
||||
|
||||
recordAudit({ action: 'serial.vcom.create', target: sess.peerId, sessionId, result: 'ok', payload: { userPath: vcom.userPath, internalPath: vcom.internalPath, platform: vcom.platform } })
|
||||
return { ok: true, userPath: vcom.userPath }
|
||||
}
|
||||
|
||||
// ====== detach ======
|
||||
|
||||
async detach(sessionId: string, reason = 'user'): Promise<{ ok: boolean; reason?: string }> {
|
||||
const cs = this.clientSessions.get(sessionId)
|
||||
if (cs) {
|
||||
// 发 detach 给对端
|
||||
this.sendControl(cs.peerId, { type: 'detach', sessionId, reason })
|
||||
this.cleanupClientSession(sessionId, reason)
|
||||
return { ok: true }
|
||||
}
|
||||
const ss = this.serverSessions.get(sessionId)
|
||||
if (ss) {
|
||||
this.cleanupServerSession(sessionId, reason)
|
||||
// 通知对端
|
||||
this.sendControl(ss.peerId, { type: 'detached', sessionId, ok: true, reason })
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: false, reason: 'session 不存在' }
|
||||
}
|
||||
|
||||
cleanupClientSession(sessionId: string, reason: string) {
|
||||
const s = this.clientSessions.get(sessionId)
|
||||
if (!s) return
|
||||
// 关掉本机虚拟串口 (如果有)
|
||||
if (s.localVirtual) {
|
||||
s.localVirtual.cleanup().catch(() => {})
|
||||
}
|
||||
this.clientSessions.delete(sessionId)
|
||||
this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' })
|
||||
recordAudit({ action: 'serial.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
|
||||
}
|
||||
|
||||
cleanupServerSession(sessionId: string, reason: string) {
|
||||
const s = this.serverSessions.get(sessionId)
|
||||
if (!s) return
|
||||
// 关串口
|
||||
if (s.serial) {
|
||||
try { s.serial.close() } catch {}
|
||||
}
|
||||
this.serverSessions.delete(sessionId)
|
||||
this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'server' })
|
||||
recordAudit({ action: 'serial.close', source: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
|
||||
}
|
||||
|
||||
// ====== 维护 ======
|
||||
|
||||
listClientSessions() {
|
||||
return Array.from(this.clientSessions.values()).map(s => ({
|
||||
sessionId: s.sessionId, peerId: s.peerId, direction: s.direction, busId: s.busId,
|
||||
info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: s.createdAt, side: 'client' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
listServerSessions() {
|
||||
return Array.from(this.serverSessions.values()).map(s => ({
|
||||
sessionId: s.sessionId, peerId: s.peerId, direction: 'self-in' as SerialDirection, busId: s.busId,
|
||||
info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: Date.now(), side: 'server' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
onPeerDisconnected(peerId: string) {
|
||||
for (const [sid, s] of this.clientSessions) {
|
||||
if (s.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected')
|
||||
}
|
||||
for (const [sid, s] of this.serverSessions) {
|
||||
if (s.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const sid of this.clientSessions.keys()) this.cleanupClientSession(sid, 'shutdown')
|
||||
for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown')
|
||||
}
|
||||
|
||||
// ====== 工具 ======
|
||||
|
||||
private sendControl(peerId: string, payload: SerialFrame) {
|
||||
return this.chatClient.send({ type: 'usb', payload } as WsFrame, peerId)
|
||||
}
|
||||
|
||||
private sendChunked(peerId: string, sessionId: string, dir: 'host->dev' | 'dev->host', buf: Buffer, fin: boolean) {
|
||||
let remaining = buf
|
||||
while (remaining.length > 0) {
|
||||
const chunk = remaining.subarray(0, MAX_DATA_CHUNK)
|
||||
remaining = remaining.subarray(MAX_DATA_CHUNK)
|
||||
const lastAndFin = fin && remaining.length === 0
|
||||
this.sendControl(peerId, { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin })
|
||||
}
|
||||
if (fin && buf.length === 0) {
|
||||
this.sendControl(peerId, { type: 'data', sessionId, dir, data: '', fin: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开串口失败的原因要能指导用户 —— 尤其 Linux 的权限问题。
|
||||
* 串口助手最常见的翻车点就是 "对方打开 /dev/ttyUSB0 失败: Permission denied",
|
||||
* 而解法 (加 dialout 组) 跟 Windows/macOS 完全不同。
|
||||
*/
|
||||
export function describeOpenError(path: string | undefined, e: any): string {
|
||||
const raw = String(e?.message || e || '未知错误')
|
||||
const isPermission = /EACCES|EPERM|permission denied|access denied/i.test(raw)
|
||||
if (isPermission && platform === 'linux') {
|
||||
return `${raw}\n\n该串口 (${path || ''}) 需要权限。Linux 上把当前用户加入 dialout 组即可:\n` +
|
||||
` sudo usermod -aG dialout $USER\n` +
|
||||
`然后**重新登录** (或重启该设备上的 App) 让组权限生效。`
|
||||
}
|
||||
if (isPermission && platform === 'darwin') {
|
||||
return `${raw}\n\nmacOS 首次访问串口会弹系统权限框, 需要在 系统设置 → 隐私与安全 中允许。`
|
||||
}
|
||||
if (/busy|EBUSY|Resource temporarily unavailable/i.test(raw)) {
|
||||
return `${raw}\n\n该串口正被别的程序占用 (minicom / screen / PuTTY / 另一个终端窗口)。先关掉占用它的程序。`
|
||||
}
|
||||
if (/ENOENT|no such file/i.test(raw)) {
|
||||
return `${raw}\n\n串口 ${path || ''} 不存在 —— 设备可能已拔出, 或者 udev 还没建出节点 (等几秒再刷新)。`
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function describeAttach(dir: SerialDirection, config: SerialAttachConfig, busId: string): string {
|
||||
const br = config.baudRate || 115200
|
||||
if (dir === 'self-out') {
|
||||
return config.createVirtual
|
||||
? `对方想用你的串口 ${busId} (${br} 8N1), 并在**自己机器上**建一个虚拟串口来映射`
|
||||
: `对方想用你的串口 ${busId} (${br} 8N1), 直接收发字节`
|
||||
}
|
||||
return `对方想把自己的串口 ${busId} (${br} 8N1) 映射到你的机器上`
|
||||
}
|
||||
@@ -317,7 +317,7 @@ export class TerminalManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// 用户在 UI 上点 "拒绝所有未决请求"
|
||||
resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') {
|
||||
resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'serial') {
|
||||
resetApproval(peerId, kind)
|
||||
}
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
// USB / 串口 共享 (3 种模式):
|
||||
// 1. 'serial' — 双向字节流. server 端 SerialPort.open, client 端收发 hex 流.
|
||||
// 覆盖所有 USB-串口适配器 (CH340/CP210x/FTDI) 和原生 COM/tty.
|
||||
// 2. 'usb' — libusb 字节桥. server 端 node-usb open + claim interface,
|
||||
// client 端发 controlTransfer / bulkTransfer 请求, server 返回结果.
|
||||
// 不是透明 USB, 但能远程调自定义 USB 设备 (单片机/编程器/调试器).
|
||||
// 3. 'usbip' — Linux only, 调 `usbip attach` 让内核接管. 真透明透传, 但需要内核模块.
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { platform } from 'node:process'
|
||||
import type {
|
||||
DeviceInfo, UsbFrame, UsbDeviceInfo, WsFrame, UsbDirection,
|
||||
UsbAttachConfig, UsbAttachedInfo, UsbEndpointInfo,
|
||||
} from '../protocol'
|
||||
import type { ChatClient } from '../chat-client'
|
||||
import { getSettings } from '../settings'
|
||||
import { shouldAsk, enqueueApproval, markApproved } from './approval'
|
||||
import { recordAudit } from '../db'
|
||||
import { diagnoseAndFixUsbAttach } from '../permissions'
|
||||
import { createVirtualSerialPair, type VirtualSerialInfo } from './vcom'
|
||||
|
||||
// 串口: 试着 require (可缺失 — 走别的路径)
|
||||
let spMod: any = null
|
||||
try { spMod = require('serialport') } catch { /* ignored */ }
|
||||
|
||||
// libusb: 试着 require
|
||||
let usbMod: any = null
|
||||
try { usbMod = require('usb') } catch { /* ignored */ }
|
||||
|
||||
const MAX_DATA_CHUNK = 32 * 1024
|
||||
|
||||
export interface UsbManagerEvents {
|
||||
approvalRequested: (req: { peerId: string; peerName: string; kind: 'usb'; detail: string; requestId: string }) => void
|
||||
sessionOpened: (info: { sessionId: string; peerId: string; direction: UsbDirection; busId: string; kind: 'serial'|'usb'|'usbip'; info?: UsbAttachedInfo; side: 'client'|'server' }) => void
|
||||
sessionClosed: (info: { sessionId: string; peerId: string; reason?: string; bytesIn: number; bytesOut: number; side: 'client'|'server' }) => void
|
||||
sessionError: (info: { sessionId: string; peerId: string; error: string }) => void
|
||||
/** 串口/UART 字节流 (仅 serial 模式). UI 用来显示 hex. */
|
||||
output: (sessionId: string, bytes: Buffer, peerId: string) => void
|
||||
/** USB 设备事件 (仅 usb 模式). 透传给 UI. */
|
||||
usbEvent: (sessionId: string, peerId: string, payload: { controlIn?: boolean; bulkIn?: boolean; ok: boolean; data?: Buffer; status?: number }) => void
|
||||
}
|
||||
|
||||
// ===== Client (我发起的, 自己是 '我') =====
|
||||
interface ClientSession {
|
||||
sessionId: string
|
||||
peerId: string
|
||||
direction: UsbDirection
|
||||
busId: string
|
||||
kind: 'serial'|'usb'|'usbip'
|
||||
info?: UsbAttachedInfo
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
createdAt: number
|
||||
// 串口: 本机的"虚拟串口" (self-out + createVirtual). 我们的 SerialPort 实例 + 清理回调
|
||||
localVirtual?: {
|
||||
port: any // SerialPort 实例 (我们开的内部那端)
|
||||
userPath: string // 给用户 app 用的路径
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Server (对端发起的, 我打开真实设备) =====
|
||||
interface ServerSession {
|
||||
sessionId: string
|
||||
ownerId: string
|
||||
peerId: string
|
||||
busId: string
|
||||
kind: 'serial'|'usb'|'usbip'
|
||||
// 串口: SerialPort 实例
|
||||
serial?: any
|
||||
// USB: 句柄 + interface
|
||||
usbDev?: any
|
||||
usbInterface?: number
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
// 设备元信息 (attach 成功时填, 给 UI 看的 endpoint 列表等)
|
||||
info?: UsbAttachedInfo
|
||||
// 串口 关闭 promise (等数据 drain)
|
||||
serialClosing?: Promise<void>
|
||||
// USB 控制/批量 reqId -> 配对 response
|
||||
pendingCtrl: Map<string, (r: { ok: boolean; data?: Buffer; status?: number }) => void>
|
||||
pendingBulk: Map<string, (r: { ok: boolean; data?: Buffer; status?: number }) => void>
|
||||
// client 端正在等的 attach 结果
|
||||
attachResolver?: (r: { ok: boolean; reason?: string; info?: UsbAttachedInfo }) => void
|
||||
attachRejecter?: (e: Error) => void
|
||||
// USB detach 流程: 我们 detach 时不让 OS 拉回去 — 留给 client
|
||||
detachResolver?: (r: { ok: boolean; reason?: string }) => void
|
||||
}
|
||||
|
||||
export class UsbBridgeManager extends EventEmitter {
|
||||
private clientSessions = new Map<string, ClientSession>()
|
||||
private serverSessions = new Map<string, ServerSession>()
|
||||
private chatClient: ChatClient
|
||||
// list 请求的待回包
|
||||
private pendingLists = new Map<string, (r: { ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }) => void>()
|
||||
|
||||
constructor(chatClient: ChatClient) {
|
||||
super()
|
||||
this.chatClient = chatClient
|
||||
}
|
||||
|
||||
// ====== 本地设备列举 ======
|
||||
|
||||
// 列本机的: 串口 + libusb 设备 (合并)
|
||||
async listLocal(): Promise<UsbDeviceInfo[]> {
|
||||
const out: UsbDeviceInfo[] = []
|
||||
// 串口
|
||||
if (spMod) {
|
||||
try {
|
||||
const ports: any[] = await spMod.SerialPort.list()
|
||||
for (const p of ports) {
|
||||
out.push({
|
||||
busId: `serial:${p.path}`,
|
||||
vid: p.vendorId ? parseInt(p.vendorId, 16) || 0 : 0,
|
||||
pid: p.productId ? parseInt(p.productId, 16) || 0 : 0,
|
||||
deviceClass: 0x02, deviceSubclass: 0x02,
|
||||
product: p.friendlyName || p.productId,
|
||||
manufacturer: p.manufacturer,
|
||||
serialNumber: p.serialNumber,
|
||||
kind: 'serial',
|
||||
serialPath: p.path,
|
||||
baudRate: 115200,
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[usb] serialport.list failed:', e?.message)
|
||||
}
|
||||
}
|
||||
// libusb (只读枚举, 不 claim interface, 安全)
|
||||
if (usbMod) {
|
||||
try {
|
||||
// 注意: 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 看来是串口)
|
||||
const vid = d.vendorId || 0
|
||||
const pid = d.productId || 0
|
||||
const isDuplicate = out.some(s => s.vid === vid && s.pid === pid && s.kind === 'serial')
|
||||
if (isDuplicate) continue
|
||||
out.push({
|
||||
busId,
|
||||
vid,
|
||||
pid,
|
||||
deviceClass: d.deviceClass || 0,
|
||||
deviceSubclass: d.deviceSubclass || 0,
|
||||
product: d.productName,
|
||||
manufacturer: d.manufacturerName,
|
||||
serialNumber: d.serialNumber,
|
||||
kind: 'usb',
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[usb] webusb.getDevices failed:', e?.message)
|
||||
}
|
||||
}
|
||||
// Linux: 追加 usbip (用系统命令列可分享的设备)
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const { exec: execSync } = await import('node:child_process')
|
||||
const { promisify } = await import('node:util')
|
||||
const execAsync = promisify(execSync as any)
|
||||
const { stdout } = await execAsync('usbip list -l 2>/dev/null || true')
|
||||
// 格式: " - busid 1-1 (1234:5678) 01:00:00 3D /sys/..."
|
||||
// vendor:vendor product
|
||||
const lines = stdout.split(/\r?\n/)
|
||||
for (const line of lines) {
|
||||
const m = line.match(/busid\s+(\S+)\s+\(([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\)/)
|
||||
if (m) {
|
||||
out.push({
|
||||
busId: `usbip:${m[1]}`,
|
||||
vid: parseInt(m[2], 16),
|
||||
pid: parseInt(m[3], 16),
|
||||
deviceClass: 0,
|
||||
deviceSubclass: 0,
|
||||
kind: 'usbip',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 问对端设备列表
|
||||
async listOnPeer(peerId: string): Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }> {
|
||||
const reqId = randomUUID()
|
||||
const sent = this.chatClient.send({ type: 'usb', payload: { type: 'list', reqId } } as WsFrame, peerId)
|
||||
if (!sent) return { ok: false, reason: '对方离线' }
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingLists.delete(reqId)
|
||||
resolve({ ok: false, reason: '超时' })
|
||||
}, 15_000)
|
||||
this.pendingLists.set(reqId, (r) => {
|
||||
clearTimeout(timer)
|
||||
resolve(r)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ====== 我方作为控制端 (发起 attach) ======
|
||||
|
||||
// attach: 三种模式按 config.kind 走不同路径
|
||||
async attach(peerId: string, opts: { direction?: UsbDirection; busId: string; config: UsbAttachConfig }): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) return { ok: false, reason: '远程功能已关闭' }
|
||||
const direction: UsbDirection = opts.direction || 'self-out'
|
||||
if (!opts.busId) return { ok: false, reason: 'busId 不能为空' }
|
||||
const kind = opts.config.kind
|
||||
// self-in 模式: 我方是 server, 需要我自己先把设备 open 好, 再让对端 attach
|
||||
// self-out 模式: 我方是 client, 对端 open 设备, 我方收字节/调 USB
|
||||
if (direction === 'self-in') {
|
||||
// 我方是设备持有方: 走 server attach
|
||||
return this.serverAttach(peerId, opts.busId, kind, opts.config)
|
||||
} else {
|
||||
// 我方是控制方: 走 client attach
|
||||
return this.clientAttach(peerId, opts.busId, kind, opts.config)
|
||||
}
|
||||
}
|
||||
|
||||
// self-out: 控制端是 client; 设备在对端
|
||||
private clientAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig): Promise<{ ok: boolean; reason?: string; sessionId?: string }> {
|
||||
const sessionId = randomUUID()
|
||||
const frame: Extract<UsbFrame, { type: 'attach' }> = { type: 'attach', sessionId, direction: 'self-out', busId, config }
|
||||
const sent = this.chatClient.send({ type: 'usb', payload: frame } as WsFrame, peerId)
|
||||
if (!sent) return Promise.resolve({ ok: false, reason: '对方离线' })
|
||||
|
||||
const sess: ClientSession = {
|
||||
sessionId, peerId, direction: 'self-out', busId, kind,
|
||||
bytesIn: 0, bytesOut: 0, createdAt: Date.now(),
|
||||
}
|
||||
this.clientSessions.set(sessionId, sess)
|
||||
// 不写 audit: 还没成功, 也不在失败语义里; 真正的 result 在 waitAttached 里写
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.clientSessions.has(sessionId)) {
|
||||
this.cleanupClientSession(sessionId, '对方 30s 内未响应')
|
||||
resolve({ ok: false, reason: '对方 30s 内未响应' })
|
||||
}
|
||||
}, 30_000)
|
||||
// 单独的 resolver: 控制端由 'attached' 帧 (被控端 attach-ack) resolve
|
||||
sess.info = undefined
|
||||
const waitAttached = (p: any) => {
|
||||
if (p.sessionId !== sessionId) return
|
||||
this.chatClient.off('usb:attached' as any, waitAttached as any)
|
||||
clearTimeout(timer)
|
||||
if (p.ok) {
|
||||
sess.info = p.info
|
||||
// usbip: 调 `usbip attach -r <peer> -b <busid>` 真正 attach
|
||||
if (kind === 'usbip') {
|
||||
this.shellUsbipAttach(peerId, busId).then((r) => {
|
||||
if (!r.ok) {
|
||||
this.cleanupClientSession(sessionId, r.reason || 'usbip attach 失败')
|
||||
resolve({ ok: false, reason: r.reason })
|
||||
return
|
||||
}
|
||||
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' })
|
||||
recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } })
|
||||
resolve({ ok: true, sessionId })
|
||||
}).catch((e) => {
|
||||
this.cleanupClientSession(sessionId, e?.message || 'usbip 失败')
|
||||
resolve({ ok: false, reason: e?.message || 'usbip 失败' })
|
||||
})
|
||||
} else {
|
||||
// serial + createVirtual: 先建本机虚拟串口, 再开内部端, 再 resolve
|
||||
if (kind === 'serial') {
|
||||
const sCfg = config as Extract<UsbAttachConfig, { kind: 'serial' }>
|
||||
if (sCfg.createVirtual) {
|
||||
this.setupLocalVirtualSerial(sessionId, sCfg).then((r: { ok: boolean; reason?: string; userPath?: string }) => {
|
||||
if (!r.ok) {
|
||||
this.cleanupClientSession(sessionId, r.reason || '虚拟串口创建失败')
|
||||
resolve({ ok: false, reason: r.reason })
|
||||
return
|
||||
}
|
||||
sess.info = { ...(p.info || {}), kind: 'serial', userVirtualPath: r.userPath } as any
|
||||
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: sess.info, side: 'client' })
|
||||
recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out', virtualPath: r.userPath } })
|
||||
resolve({ ok: true, sessionId })
|
||||
}).catch((e: Error) => {
|
||||
this.cleanupClientSession(sessionId, e?.message || '虚拟串口异常')
|
||||
resolve({ ok: false, reason: e?.message || '虚拟串口异常' })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' })
|
||||
recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } })
|
||||
resolve({ ok: true, sessionId })
|
||||
}
|
||||
} else {
|
||||
this.clientSessions.delete(sessionId)
|
||||
resolve({ ok: false, reason: p.reason || '对方拒绝' })
|
||||
}
|
||||
}
|
||||
this.chatClient.on('usb:attached' as any, waitAttached as any)
|
||||
})
|
||||
}
|
||||
|
||||
// self-in: 我方是 server, 设备在我方
|
||||
// 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, result: 'error', payload: { phase: 'no-device', busId, kind } })
|
||||
return { ok: false, reason: '本机找不到该设备' }
|
||||
}
|
||||
|
||||
// 2. 开设备
|
||||
const sess: ServerSession = {
|
||||
sessionId, ownerId: peerId, peerId, busId, kind,
|
||||
bytesIn: 0, bytesOut: 0,
|
||||
pendingCtrl: new Map(), pendingBulk: new Map(),
|
||||
}
|
||||
this.serverSessions.set(sessionId, sess)
|
||||
|
||||
try {
|
||||
if (kind === 'serial') {
|
||||
const c = config as Extract<UsbAttachConfig, { kind: 'serial' }>
|
||||
const sp = new spMod.SerialPort({
|
||||
path: dev.serialPath!,
|
||||
baudRate: c.baudRate || 115200,
|
||||
dataBits: c.dataBits || 8,
|
||||
stopBits: c.stopBits || 1,
|
||||
parity: c.parity || 'none',
|
||||
autoOpen: false,
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sp.open((err: any) => err ? reject(err) : resolve())
|
||||
})
|
||||
sess.serial = sp
|
||||
// 接收字节 → 发给 client
|
||||
sp.on('data', (buf: Buffer) => {
|
||||
if (buf.length === 0) return
|
||||
sess.bytesIn += buf.length
|
||||
this.sendChunked(peerId, sessionId, 'dev->host', buf, false)
|
||||
this.emit('output', sessionId, buf, peerId)
|
||||
})
|
||||
sp.on('close', () => {
|
||||
this.sendControl(peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true })
|
||||
this.cleanupServerSession(sessionId, 'serial-closed')
|
||||
})
|
||||
sp.on('error', (e: any) => {
|
||||
this.emit('sessionError', { sessionId, peerId, error: e.message })
|
||||
recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'error', payload: { kind, error: e.message } })
|
||||
})
|
||||
sess.info = { kind: 'serial', serialPath: dev.serialPath, vid: dev.vid, pid: dev.pid, product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber }
|
||||
} else if (kind === 'usb') {
|
||||
if (!usbMod) throw new Error('node-usb 未安装')
|
||||
const c = config as Extract<UsbAttachConfig, { kind: 'usb' }>
|
||||
// find by vendorId/productId
|
||||
const dev2: any = await usbMod.webusb.findDeviceByIds(dev.vid, dev.pid).catch(() => null)
|
||||
if (!dev2) throw new Error('libusb 找不到该设备')
|
||||
await dev2.open()
|
||||
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
|
||||
// 枚举 endpoints
|
||||
const endpoints: UsbEndpointInfo[] = []
|
||||
const cfg = dev2.configuration
|
||||
if (cfg) {
|
||||
const iface = cfg.interfaces?.find((x: any) => x.interfaceNumber === ifaceNum)
|
||||
const alt = iface?.alternate
|
||||
for (const ep of alt?.endpoints || []) {
|
||||
endpoints.push({
|
||||
endpointNumber: ep.endpointNumber,
|
||||
direction: ep.direction === 'in' ? 'in' : 'out',
|
||||
transferType: ep.type || 'bulk',
|
||||
packetSize: ep.packetSize || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
sess.info = { kind: 'usb', endpoints, vid: dev.vid, pid: dev.pid, product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber }
|
||||
} else if (kind === 'usbip') {
|
||||
// self-in 模式我不直接 attach; 我只是把设备标记为 share (调 `usbip bind` 替代: 由 peer 端 attach)
|
||||
// 实际 server attach (本端用对端设备) 不支持 self-in 模式
|
||||
// 因为 usbip 的 server 是持有设备方, attach 是 client 端
|
||||
// 这里 self-in 实际意义: 我把设备给对端, 我这边只是 declare share
|
||||
// 直接在 device-holder 端 (我) 不需要做什么, 真正 attach 是 client 端 (`usbip attach -r <对方>`)
|
||||
// 我们只需要告诉对方: 这是个 usbip 设备, 你来 attach
|
||||
sess.info = { kind: 'usbip', vid: dev.vid, pid: dev.pid }
|
||||
}
|
||||
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-in', busId, kind, info: sess.info, side: 'server' })
|
||||
recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-in' } })
|
||||
return { ok: true, sessionId }
|
||||
} catch (e: any) {
|
||||
this.serverSessions.delete(sessionId)
|
||||
recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'error', payload: { phase: 'open', error: String(e?.message || e), kind } })
|
||||
return { ok: false, reason: `打开设备失败: ${e?.message || e}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 我方作为被控端 (收到 attach) ======
|
||||
|
||||
handleIncoming(peer: DeviceInfo, f: UsbFrame) {
|
||||
switch (f.type) {
|
||||
case 'list': return this.handlePeerList(peer, f)
|
||||
case 'devices': {
|
||||
const r = this.pendingLists.get(f.reqId)
|
||||
if (r) { r({ ok: true, devices: f.devices }); this.pendingLists.delete(f.reqId) }
|
||||
return
|
||||
}
|
||||
case 'attach': return this.handleAttachReq(peer, f)
|
||||
case 'attached': {
|
||||
// 控制端收到被控端的 ack
|
||||
this.chatClient.emit('usb:attached' as any, f)
|
||||
return
|
||||
}
|
||||
case 'detach': return this.handleDetachReq(peer, f)
|
||||
case 'detached': {
|
||||
this.chatClient.emit('usb:detached' as any, f)
|
||||
return
|
||||
}
|
||||
case 'data': {
|
||||
// 串口字节流
|
||||
const sess = this.serverSessions.get(f.sessionId) || this.clientSessions.get(f.sessionId) as any
|
||||
if (!sess) return
|
||||
if (f.dir === 'dev->host') {
|
||||
// 设备 → 控制端: 远端从真实串口读到的字节
|
||||
// 1) 落到 server-side session (self-in): 我方是设备持有方, 我方自己读到了; 这个分支不会到这里 (我方自己读的数据不走 WS)
|
||||
// 2) 落到 client-side session (self-out): 我方是控制方, 远端发过来的字节要写到我方的虚拟串口 / hex 缓冲
|
||||
const cs = this.clientSessions.get(f.sessionId)
|
||||
if (cs && cs.kind === 'serial') {
|
||||
if (f.fin) {
|
||||
// 半关信号: 关掉我方虚拟串口
|
||||
if (cs.localVirtual) {
|
||||
try { if (cs.localVirtual.port.isOpen) cs.localVirtual.port.close() } catch {}
|
||||
}
|
||||
return
|
||||
}
|
||||
const buf = Buffer.from(f.data || '', 'base64')
|
||||
cs.bytesIn += buf.length
|
||||
// 优先写虚拟串口 (用户 app 在读)
|
||||
if (cs.localVirtual && cs.localVirtual.port.isOpen) {
|
||||
try { cs.localVirtual.port.write(buf) } catch (e: any) {
|
||||
this.emit('sessionError', { sessionId: cs.sessionId, peerId: cs.peerId, error: e.message })
|
||||
}
|
||||
}
|
||||
// 同时给 UI 缓冲 (hex 显示)
|
||||
this.emit('output', f.sessionId, buf, cs.peerId)
|
||||
}
|
||||
return
|
||||
}
|
||||
// host->dev: 控制端 -> 设备
|
||||
if (this.serverSessions.has(f.sessionId)) {
|
||||
const s = this.serverSessions.get(f.sessionId)!
|
||||
if (s.kind === 'serial' && s.serial) {
|
||||
if (f.fin) { try { s.serial.close() } catch {} ; return }
|
||||
const buf = Buffer.from(f.data || '', 'base64')
|
||||
s.bytesOut += buf.length
|
||||
try { s.serial.write(buf) } catch (e: any) {
|
||||
this.emit('sessionError', { sessionId: s.sessionId, peerId: s.peerId, error: e.message })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// client 端 (我们发起 self-in): 我方持有设备, client 这边收 host->dev 字节 (对端发来的)
|
||||
// 但 client session 的 host->dev 是对端 app -> 我方; 不直接写本地
|
||||
// self-in: 我方持有设备, 对端是控制方, 对端写 host->dev 字节会被我方 (server) 收
|
||||
// client 这边忽略
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'ctrlOut':
|
||||
case 'ctrlIn': {
|
||||
const s = this.serverSessions.get(f.sessionId)
|
||||
if (!s || s.kind !== 'usb' || !s.usbDev) {
|
||||
this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: f.sessionId, reqId: f.reqId, ok: false, status: -1 })
|
||||
return
|
||||
}
|
||||
this.handleCtrlReq(s, peer, f)
|
||||
return
|
||||
}
|
||||
case 'bulkOut':
|
||||
case 'bulkIn': {
|
||||
const s = this.serverSessions.get(f.sessionId)
|
||||
if (!s || s.kind !== 'usb' || !s.usbDev) {
|
||||
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: f.sessionId, reqId: f.reqId, ok: false, status: -1 })
|
||||
return
|
||||
}
|
||||
this.handleBulkReq(s, peer, f)
|
||||
return
|
||||
}
|
||||
case 'ctrlResult': {
|
||||
const s = this.clientSessions.get(f.sessionId) as any
|
||||
if (s && s.kind === 'usb') {
|
||||
// 不会到这里: client 端 (我们 self-out) 直接等 server 返回
|
||||
}
|
||||
// 控制端 client: 收到被控端的 ctrlResult → resolve
|
||||
const sess = this.clientSessions.get(f.sessionId) as any
|
||||
if (sess && sess.kind === 'usb') {
|
||||
// 实际上 client 端我们不存 pendingCtrl, 因为是 process 直接 await
|
||||
// 走 usbEvent 事件
|
||||
}
|
||||
this.emit('usbEvent', f.sessionId, peer.deviceId, { ok: f.ok, data: f.data ? Buffer.from(f.data, 'base64') : undefined, status: f.status, controlIn: true })
|
||||
return
|
||||
}
|
||||
case 'bulkResult': {
|
||||
this.emit('usbEvent', f.sessionId, peer.deviceId, { ok: f.ok, data: f.data ? Buffer.from(f.data, 'base64') : undefined, status: f.status, bulkIn: true })
|
||||
return
|
||||
}
|
||||
case 'error': {
|
||||
this.emit('sessionError', { sessionId: f.sessionId, peerId: peer.deviceId, error: f.reason })
|
||||
return
|
||||
}
|
||||
case 'ack': {
|
||||
// 通用 ack
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 被控端处理 list 请求
|
||||
private async handlePeerList(peer: DeviceInfo, f: Extract<UsbFrame, { type: 'list' }>) {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) return
|
||||
if (shouldAsk(peer.deviceId, 'usb')) {
|
||||
const reqId = randomUUID()
|
||||
const detail = '对方请求查看本机的 USB / 串口 设备列表'
|
||||
const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() })
|
||||
this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail })
|
||||
const verdict = await reqPromise
|
||||
if (!verdict.ok) return
|
||||
}
|
||||
const devices = await this.listLocal()
|
||||
this.sendControl(peer.deviceId, { type: 'devices', reqId: f.reqId, devices })
|
||||
recordAudit({ action: 'usb.list', source: peer.deviceId, result: 'ok', payload: { count: devices.length } })
|
||||
}
|
||||
|
||||
// 被控端处理 attach 请求
|
||||
private async handleAttachReq(peer: DeviceInfo, f: Extract<UsbFrame, { type: 'attach' }>) {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) {
|
||||
this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '对方关闭了远程功能' })
|
||||
return
|
||||
}
|
||||
if (shouldAsk(peer.deviceId, 'usb')) {
|
||||
const detail = describeAttach(f.direction, f.config, f.busId)
|
||||
const reqId = randomUUID()
|
||||
const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() })
|
||||
this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail })
|
||||
const verdict = await reqPromise
|
||||
if (!verdict.ok) {
|
||||
this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '用户拒绝' })
|
||||
recordAudit({ action: 'usb.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' })
|
||||
return
|
||||
}
|
||||
if (verdict.remember) markApproved(peer.deviceId, 'usb')
|
||||
}
|
||||
|
||||
// 走和 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 } })
|
||||
}
|
||||
}
|
||||
|
||||
private handleDetachReq(peer: DeviceInfo, f: Extract<UsbFrame, { type: 'detach' }>) {
|
||||
const sess = this.serverSessions.get(f.sessionId)
|
||||
if (!sess) {
|
||||
this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: false, reason: 'session 不存在' })
|
||||
return
|
||||
}
|
||||
this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer')
|
||||
this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: true })
|
||||
}
|
||||
|
||||
// USB 控制传输: 对方发请求 → 我执行 → 回 ctrlResult
|
||||
private async handleCtrlReq(sess: ServerSession, peer: DeviceInfo, f: Extract<UsbFrame, { type: 'ctrlOut' | 'ctrlIn' }>) {
|
||||
try {
|
||||
const dev = sess.usbDev
|
||||
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)
|
||||
sess.bytesOut += written || 0
|
||||
this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, status: written || 0 })
|
||||
this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, status: written || 0, controlIn: false })
|
||||
} else {
|
||||
const buf = await dev.nativeControlTransferIn(setup, 5000, f.length)
|
||||
if (buf) {
|
||||
const b = Buffer.from(buf)
|
||||
sess.bytesIn += b.length
|
||||
this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, data: b.toString('base64'), status: b.length })
|
||||
this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, data: b, status: b.length, controlIn: true })
|
||||
} else {
|
||||
this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: 0 })
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.sendControl(peer.deviceId, { type: 'ctrlResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: -1 })
|
||||
this.emit('sessionError', { sessionId: sess.sessionId, peerId: peer.deviceId, error: `ctrl: ${e?.message || e}` })
|
||||
}
|
||||
}
|
||||
|
||||
private async handleBulkReq(sess: ServerSession, peer: DeviceInfo, f: Extract<UsbFrame, { type: 'bulkOut' | 'bulkIn' }>) {
|
||||
try {
|
||||
const dev = sess.usbDev
|
||||
if (f.type === 'bulkOut') {
|
||||
const buf = Buffer.from(f.data, 'base64')
|
||||
const written = await dev.nativeTransferOut(f.endpoint, f.type === 'bulkOut' ? 5000 : 5000, new Uint8Array(buf))
|
||||
sess.bytesOut += written || 0
|
||||
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, status: written || 0 })
|
||||
this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, status: written || 0, bulkIn: false })
|
||||
} else {
|
||||
const timeout = f.timeoutMs || 5000
|
||||
const buf = await dev.nativeTransferIn(f.endpoint, timeout, f.length)
|
||||
if (buf) {
|
||||
const b = Buffer.from(buf)
|
||||
sess.bytesIn += b.length
|
||||
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, data: b.toString('base64'), status: b.length })
|
||||
this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, data: b, status: b.length, bulkIn: true })
|
||||
} else {
|
||||
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: 0 })
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: -1 })
|
||||
this.emit('sessionError', { sessionId: sess.sessionId, peerId: peer.deviceId, error: `bulk: ${e?.message || e}` })
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 我方作为控制端, 给对端发 control/bulk 请求 ======
|
||||
|
||||
async usbCtrlTransfer(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, data?: string): Promise<{ ok: boolean; data?: Buffer; status?: number }> {
|
||||
const sess = this.clientSessions.get(sessionId)
|
||||
if (!sess) return { ok: false, status: -1 }
|
||||
const reqId = randomUUID()
|
||||
if (data) {
|
||||
this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup, data })
|
||||
} else {
|
||||
// 没有 data = host 想读; 但读要 length — 暂时用 0 (调用方应该用 ctrlIn)
|
||||
this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup })
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({ ok: false, status: -1 })
|
||||
}, 5000)
|
||||
const onEvt = (p: any) => {
|
||||
if (p.controlIn !== true) return
|
||||
// 注意: 我们 emit usbEvent 是用 (sessionId, peerId, payload) 三参, 这里 listener 形参 1 是 sessionId, 但我们只比较 sessionId
|
||||
// 简化: 一个内部 map
|
||||
}
|
||||
void onEvt
|
||||
// 直接挂在 self: 用 'once' 等一个标记
|
||||
const handler = (sid: string, _pid: string, payload: any) => {
|
||||
if (sid !== sessionId) return
|
||||
if (payload.status === undefined) return
|
||||
clearTimeout(timer)
|
||||
this.off('usbEvent', handler as any)
|
||||
resolve({ ok: payload.ok, data: payload.data, status: payload.status })
|
||||
}
|
||||
this.on('usbEvent', handler as any)
|
||||
})
|
||||
}
|
||||
|
||||
async usbCtrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number): Promise<{ ok: boolean; data?: Buffer; status?: number }> {
|
||||
const sess = this.clientSessions.get(sessionId)
|
||||
if (!sess) return { ok: false, status: -1 }
|
||||
const reqId = randomUUID()
|
||||
this.sendControl(sess.peerId, { type: 'ctrlIn', sessionId, reqId, setup, length })
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({ ok: false, status: -1 })
|
||||
}, 5000)
|
||||
const handler = (sid: string, _pid: string, payload: any) => {
|
||||
if (sid !== sessionId) return
|
||||
clearTimeout(timer)
|
||||
this.off('usbEvent', handler as any)
|
||||
resolve({ ok: payload.ok, data: payload.data, status: payload.status })
|
||||
}
|
||||
this.on('usbEvent', handler as any)
|
||||
})
|
||||
}
|
||||
|
||||
async usbBulkOut(sessionId: string, endpoint: number, data: string): Promise<{ ok: boolean; status?: number }> {
|
||||
const sess = this.clientSessions.get(sessionId)
|
||||
if (!sess) return { ok: false, status: -1 }
|
||||
const reqId = randomUUID()
|
||||
this.sendControl(sess.peerId, { type: 'bulkOut', sessionId, reqId, endpoint, data })
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve({ ok: false, status: -1 }), 5000)
|
||||
const handler = (sid: string, _pid: string, payload: any) => {
|
||||
if (sid !== sessionId) return
|
||||
clearTimeout(timer)
|
||||
this.off('usbEvent', handler as any)
|
||||
resolve({ ok: payload.ok, status: payload.status })
|
||||
}
|
||||
this.on('usbEvent', handler as any)
|
||||
})
|
||||
}
|
||||
|
||||
async usbBulkIn(sessionId: string, endpoint: number, length: number, timeoutMs = 5000): Promise<{ ok: boolean; data?: Buffer; status?: number }> {
|
||||
const sess = this.clientSessions.get(sessionId)
|
||||
if (!sess) return { ok: false, status: -1 }
|
||||
const reqId = randomUUID()
|
||||
this.sendControl(sess.peerId, { type: 'bulkIn', sessionId, reqId, endpoint, length, timeoutMs })
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve({ ok: false, status: -1 }), timeoutMs + 1000)
|
||||
const handler = (sid: string, _pid: string, payload: any) => {
|
||||
if (sid !== sessionId) return
|
||||
clearTimeout(timer)
|
||||
this.off('usbEvent', handler as any)
|
||||
resolve({ ok: payload.ok, data: payload.data, status: payload.status })
|
||||
}
|
||||
this.on('usbEvent', handler as any)
|
||||
})
|
||||
}
|
||||
|
||||
// 串口: 发送字节 (client 端)
|
||||
async sendSerialBytes(sessionId: string, buf: Buffer): Promise<boolean> {
|
||||
const sess = this.clientSessions.get(sessionId)
|
||||
if (!sess || sess.kind !== 'serial') return false
|
||||
sess.bytesOut += buf.length
|
||||
this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false)
|
||||
return true
|
||||
}
|
||||
|
||||
// 串口: 在本机创建虚拟串口, 打开内部端做桥接 (self-out + createVirtual)
|
||||
private async setupLocalVirtualSerial(sessionId: string, config: Extract<UsbAttachConfig, { kind: 'serial' }>): Promise<{ ok: boolean; reason?: string; userPath?: string }> {
|
||||
if (!spMod) return { ok: false, reason: 'serialport 未安装' }
|
||||
const sess = this.clientSessions.get(sessionId)
|
||||
if (!sess) return { ok: false, reason: 'session 不存在' }
|
||||
|
||||
// 1. 创建 PTY 配对 / com0com
|
||||
let vcom: VirtualSerialInfo
|
||||
try {
|
||||
vcom = await createVirtualSerialPair({ userPath: config.virtualName })
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: `创建虚拟串口失败: ${e?.message || e}` }
|
||||
}
|
||||
|
||||
// 2. 用我们传的 baudRate 打开内部端
|
||||
const baudRate = config.baudRate || 115200
|
||||
let sp: any
|
||||
try {
|
||||
sp = new spMod.SerialPort({
|
||||
path: vcom.internalPath,
|
||||
baudRate,
|
||||
dataBits: config.dataBits || 8,
|
||||
stopBits: config.stopBits || 1,
|
||||
parity: config.parity || 'none',
|
||||
autoOpen: false,
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sp.open((err: any) => err ? reject(err) : resolve())
|
||||
})
|
||||
} catch (e: any) {
|
||||
await vcom.cleanup().catch(() => {})
|
||||
return { ok: false, reason: `打开内部虚拟端失败: ${e?.message || e}` }
|
||||
}
|
||||
|
||||
// 3. 数据桥接
|
||||
// 内部端收到字节 (用户 app 写进来的) → WS host->dev → 远端写真实串口
|
||||
sp.on('data', (buf: Buffer) => {
|
||||
if (buf.length === 0) return
|
||||
sess.bytesOut += buf.length
|
||||
this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false)
|
||||
this.emit('output', sessionId, buf, sess.peerId)
|
||||
})
|
||||
sp.on('close', () => {
|
||||
// 内部端被关 (用户 app 关闭了虚拟串口?) -> 通知远端
|
||||
this.sendControl(sess.peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true })
|
||||
this.cleanupClientSession(sessionId, 'virtual-serial-closed-by-user-app')
|
||||
})
|
||||
sp.on('error', (e: any) => {
|
||||
this.emit('sessionError', { sessionId, peerId: sess.peerId, error: `virtual-serial: ${e.message}` })
|
||||
})
|
||||
|
||||
// 4. 存到 session, detach 时关
|
||||
sess.localVirtual = {
|
||||
port: sp,
|
||||
userPath: vcom.userPath,
|
||||
cleanup: async () => {
|
||||
try { sp.removeAllListeners() } catch {}
|
||||
try { if (sp.isOpen) await new Promise<void>((r) => sp.close(() => r())) } catch {}
|
||||
await vcom.cleanup().catch(() => {})
|
||||
},
|
||||
}
|
||||
|
||||
recordAudit({ action: 'usb.vcom.create', target: sess.peerId, sessionId, result: 'ok', payload: { userPath: vcom.userPath, internalPath: vcom.internalPath, platform: vcom.platform } })
|
||||
return { ok: true, userPath: vcom.userPath }
|
||||
}
|
||||
|
||||
// ====== detach ======
|
||||
|
||||
async detach(sessionId: string, reason = 'user'): Promise<{ ok: boolean; reason?: string }> {
|
||||
if (this.clientSessions.has(sessionId)) {
|
||||
const sess = this.clientSessions.get(sessionId)!
|
||||
// usbip 模式: detach 本地
|
||||
if (sess.kind === 'usbip') {
|
||||
try {
|
||||
const { exec: execSync } = await import('node:child_process')
|
||||
const { promisify } = await import('node:util')
|
||||
const execAsync = promisify(execSync as any)
|
||||
await execAsync('usbip detach --all 2>/dev/null || true').catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
// 发 detach 给对端
|
||||
this.sendControl(sess.peerId, { type: 'detach', sessionId, reason })
|
||||
this.cleanupClientSession(sessionId, reason)
|
||||
return { ok: true }
|
||||
}
|
||||
if (this.serverSessions.has(sessionId)) {
|
||||
const sess = this.serverSessions.get(sessionId)!
|
||||
this.cleanupServerSession(sessionId, reason)
|
||||
// 通知对端
|
||||
this.sendControl(sess.peerId, { type: 'detached', sessionId, ok: true, reason })
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: false, reason: 'session 不存在' }
|
||||
}
|
||||
|
||||
cleanupClientSession(sessionId: string, reason = 'user') {
|
||||
const s = this.clientSessions.get(sessionId)
|
||||
if (!s) return
|
||||
// 关掉本机虚拟串口 (如果有)
|
||||
if (s.localVirtual) {
|
||||
s.localVirtual.cleanup().catch(() => {})
|
||||
}
|
||||
this.clientSessions.delete(sessionId)
|
||||
this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' })
|
||||
recordAudit({ action: 'usb.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
|
||||
}
|
||||
|
||||
cleanupServerSession(sessionId: string, reason = 'user') {
|
||||
const s = this.serverSessions.get(sessionId)
|
||||
if (!s) return
|
||||
// 关掉所有 pending 请求
|
||||
for (const r of s.pendingCtrl.values()) r({ ok: false, status: -1 })
|
||||
for (const r of s.pendingBulk.values()) r({ ok: false, status: -1 })
|
||||
s.pendingCtrl.clear(); s.pendingBulk.clear()
|
||||
// 关设备
|
||||
if (s.serial) {
|
||||
try { s.serial.close() } catch {}
|
||||
}
|
||||
if (s.usbDev) {
|
||||
try { s.usbDev.close() } catch (e: any) { console.warn('[usb] close device:', e?.message) }
|
||||
}
|
||||
this.serverSessions.delete(sessionId)
|
||||
this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'server' })
|
||||
recordAudit({ action: 'usb.close', source: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
|
||||
}
|
||||
|
||||
// ====== 维护 ======
|
||||
|
||||
listClientSessions() {
|
||||
return Array.from(this.clientSessions.values()).map(s => ({
|
||||
sessionId: s.sessionId, peerId: s.peerId, direction: s.direction, busId: s.busId,
|
||||
kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: s.createdAt, side: 'client' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
listServerSessions() {
|
||||
return Array.from(this.serverSessions.values()).map(s => ({
|
||||
sessionId: s.sessionId, peerId: s.peerId, direction: 'self-in' as UsbDirection, busId: s.busId,
|
||||
kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: Date.now(), side: 'server' as const,
|
||||
}))
|
||||
}
|
||||
|
||||
onPeerDisconnected(peerId: string) {
|
||||
for (const [sid, s] of this.clientSessions) {
|
||||
if (s.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected')
|
||||
}
|
||||
for (const [sid, s] of this.serverSessions) {
|
||||
if (s.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const sid of this.clientSessions.keys()) this.cleanupClientSession(sid, 'shutdown')
|
||||
for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown')
|
||||
}
|
||||
|
||||
// ====== 工具 ======
|
||||
|
||||
private sendControl(peerId: string, payload: UsbFrame) {
|
||||
return this.chatClient.send({ type: 'usb', payload } as WsFrame, peerId)
|
||||
}
|
||||
|
||||
private sendChunked(peerId: string, sessionId: string, dir: 'host->dev' | 'dev->host', buf: Buffer, fin: boolean) {
|
||||
let remaining = buf
|
||||
while (remaining.length > 0) {
|
||||
const chunk = remaining.subarray(0, MAX_DATA_CHUNK)
|
||||
remaining = remaining.subarray(MAX_DATA_CHUNK)
|
||||
const lastAndFin = fin && remaining.length === 0
|
||||
this.sendControl(peerId, { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin })
|
||||
}
|
||||
if (fin && buf.length === 0) {
|
||||
this.sendControl(peerId, { type: 'data', sessionId, dir, data: '', fin: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async shellUsbipAttach(peerLanIp: string, busId: string): Promise<{ ok: boolean; reason?: string }> {
|
||||
if (platform !== 'linux') return { ok: false, reason: 'usbip 仅 Linux 可用' }
|
||||
// 1. 确保 vhci_hcd 加载
|
||||
const fix = await diagnoseAndFixUsbAttach()
|
||||
if (!fix.ok) return { ok: false, reason: fix.stderr }
|
||||
// 2. 调 `usbip attach -r <peer> -b <busid>` (busId 要去掉 "usbip:" 前缀)
|
||||
const realBusId = busId.startsWith('usbip:') ? busId.slice(6) : busId
|
||||
try {
|
||||
const { exec: execSync } = await import('node:child_process')
|
||||
const { promisify } = await import('node:util')
|
||||
const execAsync = promisify(execSync as any)
|
||||
const { stdout, stderr } = await execAsync(`usbip attach -r ${peerLanIp} -b ${realBusId}`)
|
||||
return { ok: true, reason: stdout + (stderr || '') }
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: `usbip attach 失败: ${e?.message || e}; 输出: ${e?.stdout || ''} ${e?.stderr || ''}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
? `对方请求使用本机的串口 ${config.baudRate || 9600} baud`
|
||||
: `对方请求把自己的串口暴露给本机 (${config.baudRate || 9600} baud)`
|
||||
}
|
||||
if (config.kind === 'usb') {
|
||||
return dir === 'self-out'
|
||||
? `对方请求通过本机的 USB 设备 ${busId} 发送控制/批量传输`
|
||||
: `对方请求把自己的 USB 设备 ${busId} 共享给本机`
|
||||
}
|
||||
return `对方请求 USB/IP 透传 ${busId} (需要本机 vhci_hcd 模块)`
|
||||
}
|
||||
@@ -14,10 +14,10 @@ export interface Settings {
|
||||
preferredInterface: string
|
||||
/** 备选: 用户也可指定精确地址 (避免接口名漂移) */
|
||||
preferredAddress: string
|
||||
/** 远程功能总开关 (false = 完全禁用 terminal/forward/usb, 即使 allowPeers 里有白名单) */
|
||||
/** 远程功能总开关 (false = 完全禁用 terminal/forward/serial, 即使 allowPeers 里有白名单) */
|
||||
remoteEnabled: boolean
|
||||
/** 每个对端的远程操作授权, key = deviceId, value = { terminal, forward, usb } */
|
||||
remoteAllowPeers: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
|
||||
/** 每个对端的远程操作授权, key = deviceId, value = { terminal, forward, serial } */
|
||||
remoteAllowPeers: Record<string, { terminal?: boolean; forward?: boolean; serial?: boolean }>
|
||||
/** 端口转发默认 TTL (秒), 上限 24h */
|
||||
forwardDefaultTtlSec: number
|
||||
/** 端口转发默认字节速率上限 (bytes/s), 0 = 不限 */
|
||||
@@ -30,6 +30,10 @@ export interface Settings {
|
||||
driveEnabled: boolean
|
||||
/** 网盘根目录 (对外共享的目录, 对端只能看到这个目录里的内容) */
|
||||
driveDir: string
|
||||
/** 文件夹同步: 上次用的本地目录 */
|
||||
syncLocalDir: string
|
||||
/** 文件夹同步: 上次用的对方网盘子目录 (相对网盘根) */
|
||||
syncRemotePath: string
|
||||
}
|
||||
|
||||
export const store = new Store<Settings>({
|
||||
@@ -52,6 +56,8 @@ export const store = new Store<Settings>({
|
||||
auditRetentionDays: 30,
|
||||
driveEnabled: true,
|
||||
driveDir: paths.defaultDriveDir,
|
||||
syncLocalDir: '',
|
||||
syncRemotePath: '',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
// 文件关系表 (manifest): 同步盘的地基
|
||||
//
|
||||
// 这个文件解决的核心问题: **怎么区分"文件被删除了"和"文件本来就不存在"**。
|
||||
//
|
||||
// 三方对比 (不是两方):
|
||||
// 基线 (上次同步后两边一致的状态) + 本地现状 + 远端现状
|
||||
//
|
||||
// 墓碑 (tombstone): 删除不抹记录, 只标 state='deleted'。
|
||||
// 直接抹掉会有两个致命问题:
|
||||
// A. 删除传不出去 —— 对方离线时我删了 x.txt, 抹掉记录后对方回来说"我有 x.txt",
|
||||
// 我会以为是新增 -> 重新下载 -> 删除被撤销, 用户永远删不掉这个文件。
|
||||
// B. 删除会被复活 —— 对方从备份恢复了 x.txt 并上传, 我这边墓碑还在,
|
||||
// 光看"对方有我没有"会当成新增接受 -> 删除被静默撤销。
|
||||
// 有墓碑 + seq 比较就都能挡住 (见 planSync)。
|
||||
//
|
||||
// seq (变更序号): Lamport 时钟。
|
||||
// hash 只能回答"一样不一样", seq 才能回答"谁更晚" —— 这正是判定删除方向的关键。
|
||||
// 本地每变一次 seq++; 收到对方的 seq 时把本地时钟推到 max(local, remote)。
|
||||
// 于是"对方 seq 5 的删除" vs "我 seq 3 的修改" -> 删除更晚 -> 删除赢。
|
||||
//
|
||||
// 存储: 每个关系一份 manifest, 放同一个 JSON 文件里 (原子写)。
|
||||
// key = relationId, 所以**一个本地目录同时跟多个关系同步也不会串**:
|
||||
// 各自有各自的基线/墓碑/序号, 互不干扰。
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
// ============ 类型 ============
|
||||
|
||||
/** 一个文件的快照 */
|
||||
export interface SyncFileInfo {
|
||||
relPath: string
|
||||
size: number
|
||||
mtime: number
|
||||
/** sha256; 文件太大时可能没有 */
|
||||
hash?: string
|
||||
}
|
||||
|
||||
/** 单个文件的关系记录 (key = relPath) */
|
||||
export interface ManifestEntry {
|
||||
/** 稳定 id (首次见到这个路径时生成) */
|
||||
fileId: string
|
||||
relPath: string
|
||||
/** 基线: 上次双方内容一致时的 hash (空 = 从来没同步过) */
|
||||
baseHash: string
|
||||
baseSize: number
|
||||
baseMtime: number
|
||||
/** 我这边最后一次观察到自己的内容 */
|
||||
myHash: string
|
||||
mySize: number
|
||||
myMtime: number
|
||||
/** 我这边当前状态 */
|
||||
myState: 'present' | 'deleted'
|
||||
/** 我这边最后一次变更的序号 */
|
||||
mySeq: number
|
||||
/** 对方最后一次广播过来的状态 */
|
||||
peerHash: string
|
||||
peerSize: number
|
||||
peerMtime: number
|
||||
peerState: 'present' | 'deleted'
|
||||
/** 对方最后一次变更的序号 */
|
||||
peerSeq: number
|
||||
/** 墓碑时间 (任一侧删除的时间) */
|
||||
deletedAt: number
|
||||
}
|
||||
|
||||
/** 一个关系的完整关系表 */
|
||||
export interface Manifest {
|
||||
relationId: string
|
||||
/** 我的 Lamport 时钟 */
|
||||
clock: number
|
||||
/** 水位线: 我已处理到对方的哪个序号 */
|
||||
remoteSeq: number
|
||||
/** key = relPath */
|
||||
entries: Record<string, ManifestEntry>
|
||||
}
|
||||
|
||||
/** 发给对方的"我的当前状态" */
|
||||
export interface ManifestWireEntry {
|
||||
fileId: string
|
||||
relPath: string
|
||||
hash: string
|
||||
size: number
|
||||
mtime: number
|
||||
state: 'present' | 'deleted'
|
||||
seq: number
|
||||
deletedAt: number
|
||||
}
|
||||
|
||||
export interface ManifestWire {
|
||||
relationId: string
|
||||
/** 我的时钟当前值 (对端拿它推进自己的时钟) */
|
||||
seq: number
|
||||
entries: ManifestWireEntry[]
|
||||
}
|
||||
|
||||
// ============ 常量 ============
|
||||
|
||||
/** 墓碑保留天数 (比大品牌默认的 30 天更保守, 覆盖长时间离线的设备) */
|
||||
export const TOMBSTONE_TTL_MS = 90 * 24 * 3600 * 1000
|
||||
/** 首次同步 (没有基线) 时, mtime 相差小于这个秒数算"同时改" -> 冲突 */
|
||||
export const MTIME_TIE_SEC = 2
|
||||
|
||||
// ============ 存储 ============
|
||||
|
||||
interface ManifestFile {
|
||||
version: number
|
||||
manifests: Record<string, Manifest>
|
||||
}
|
||||
|
||||
export class ManifestStore {
|
||||
private file: string
|
||||
|
||||
constructor(file: string) {
|
||||
this.file = file
|
||||
}
|
||||
|
||||
private load(): ManifestFile {
|
||||
try {
|
||||
if (!existsSync(this.file)) return { version: 1, manifests: {} }
|
||||
const raw = JSON.parse(readFileSync(this.file, 'utf8'))
|
||||
if (!raw || typeof raw !== 'object') return { version: 1, manifests: {} }
|
||||
const m = raw.manifests && typeof raw.manifests === 'object' ? raw.manifests : {}
|
||||
return { version: Number(raw.version) || 1, manifests: m }
|
||||
} catch {
|
||||
try {
|
||||
if (existsSync(this.file)) renameSync(this.file, `${this.file}.broken-${Date.now()}`)
|
||||
} catch { /* ignore */ }
|
||||
return { version: 1, manifests: {} }
|
||||
}
|
||||
}
|
||||
|
||||
private save(d: ManifestFile): void {
|
||||
const dir = dirname(this.file)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
const tmp = `${this.file}.tmp-${process.pid}`
|
||||
writeFileSync(tmp, JSON.stringify(d, null, 2), 'utf8')
|
||||
renameSync(tmp, this.file)
|
||||
}
|
||||
|
||||
loadManifest(relationId: string): Manifest {
|
||||
const all = this.load().manifests
|
||||
const m = all[relationId]
|
||||
if (!m || typeof m !== 'object') return { relationId, clock: 0, remoteSeq: 0, entries: {} }
|
||||
return {
|
||||
relationId,
|
||||
clock: Number(m.clock) || 0,
|
||||
remoteSeq: Number(m.remoteSeq) || 0,
|
||||
entries: m.entries && typeof m.entries === 'object' ? m.entries : {},
|
||||
}
|
||||
}
|
||||
|
||||
saveManifest(manifest: Manifest): void {
|
||||
const d = this.load()
|
||||
d.manifests[manifest.relationId] = manifest
|
||||
this.save(d)
|
||||
}
|
||||
|
||||
deleteManifest(relationId: string): void {
|
||||
const d = this.load()
|
||||
delete d.manifests[relationId]
|
||||
this.save(d)
|
||||
}
|
||||
|
||||
/** 列出所有有墓碑的关系 (清理用) */
|
||||
listManifestIds(): string[] {
|
||||
return Object.keys(this.load().manifests)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 关系表操作 ============
|
||||
|
||||
function emptyEntry(relPath: string): ManifestEntry {
|
||||
return {
|
||||
fileId: randomUUID(),
|
||||
relPath,
|
||||
baseHash: '', baseSize: 0, baseMtime: 0,
|
||||
myHash: '', mySize: 0, myMtime: 0,
|
||||
myState: 'deleted', mySeq: 0,
|
||||
peerHash: '', peerSize: 0, peerMtime: 0,
|
||||
peerState: 'deleted', peerSeq: 0,
|
||||
deletedAt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 把我的当前状态导出成发给对方的格式 */
|
||||
export function toWire(manifest: Manifest): ManifestWire {
|
||||
const entries: ManifestWireEntry[] = []
|
||||
for (const e of Object.values(manifest.entries)) {
|
||||
entries.push({
|
||||
fileId: e.fileId,
|
||||
relPath: e.relPath,
|
||||
hash: e.myState === 'present' ? e.myHash : '',
|
||||
size: e.myState === 'present' ? e.mySize : 0,
|
||||
mtime: e.myState === 'present' ? e.myMtime : 0,
|
||||
state: e.myState,
|
||||
seq: e.mySeq,
|
||||
deletedAt: e.deletedAt,
|
||||
})
|
||||
}
|
||||
return { relationId: manifest.relationId, seq: manifest.clock, entries }
|
||||
}
|
||||
|
||||
/** 从对方发来的格式导入 (解析成对方的视图) */
|
||||
export function fromWire(wire: ManifestWire): Map<string, ManifestWireEntry> {
|
||||
const m = new Map<string, ManifestWireEntry>()
|
||||
for (const e of wire?.entries || []) {
|
||||
if (!e || typeof e.relPath !== 'string' || !e.relPath) continue
|
||||
m.set(e.relPath, e)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
/** 本地这次的变化 (用于写审计/提示) */
|
||||
added: string[]
|
||||
modified: string[]
|
||||
deleted: string[]
|
||||
restored: string[]
|
||||
/** 检测到的重命名 (旧路径 -> 新路径) */
|
||||
renamed: { from: string; to: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本地磁盘现状和关系表对齐 (只改关系表, 不动磁盘)。
|
||||
*
|
||||
* 这一步是"区分被删 vs 从来没有"的地方:
|
||||
* - 关系表里有 + 磁盘没了 -> **删除事件** (记墓碑, seq++)
|
||||
* - 磁盘有 + 关系表里没有 -> **新增事件** (seq++)
|
||||
* - 磁盘有 + 内容变了 -> **修改事件** (seq++)
|
||||
* - 关系表是墓碑 + 磁盘有了 -> **复活事件** (seq++) ← 用户把文件放回来了
|
||||
*/
|
||||
export function reconcileLocal(
|
||||
manifest: Manifest,
|
||||
localFiles: SyncFileInfo[],
|
||||
opts?: { detectRename?: boolean },
|
||||
): ReconcileResult {
|
||||
const res: ReconcileResult = { added: [], modified: [], deleted: [], restored: [], renamed: [] }
|
||||
const localMap = new Map(localFiles.map(f => [f.relPath, f]))
|
||||
let clock = manifest.clock
|
||||
|
||||
// ---- 先找重命名 (避免改名产生假删除) ----
|
||||
const renames = new Map<string, string>() // oldPath -> newPath
|
||||
if (opts?.detectRename !== false) {
|
||||
const gonePaths: string[] = []
|
||||
for (const [relPath, e] of Object.entries(manifest.entries)) {
|
||||
if (e.myState === 'present' && !localMap.has(relPath) && e.baseHash) gonePaths.push(relPath)
|
||||
}
|
||||
for (const [relPath, l] of localMap) {
|
||||
if (manifest.entries[relPath]) continue // 不是新增
|
||||
// 新出现的路径: 看有没有"消失的路径"的基线 hash 和它一样
|
||||
for (const gone of gonePaths) {
|
||||
const ge = manifest.entries[gone]
|
||||
if (!ge || !ge.baseHash) continue
|
||||
if (l.hash && l.hash === ge.baseHash && !renames.has(gone)) {
|
||||
renames.set(gone, relPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 已有的条目: 判定 删除 / 修改 / 复活 ----
|
||||
for (const [relPath, e] of Object.entries(manifest.entries)) {
|
||||
const l = localMap.get(relPath)
|
||||
if (e.myState === 'present') {
|
||||
if (!l) {
|
||||
if (renames.has(relPath)) continue // 走重命名分支, 不算删除
|
||||
// 磁盘上没了 -> **删除事件** (这是最关键的一条)
|
||||
e.myState = 'deleted'
|
||||
e.mySeq = ++clock
|
||||
e.deletedAt = Date.now()
|
||||
e.myHash = ''
|
||||
e.mySize = 0
|
||||
e.myMtime = 0
|
||||
res.deleted.push(relPath)
|
||||
} else if (l.hash && e.myHash && l.hash !== e.myHash) {
|
||||
// 内容变了 -> 修改事件
|
||||
e.myHash = l.hash
|
||||
e.mySize = l.size
|
||||
e.myMtime = l.mtime
|
||||
e.mySeq = ++clock
|
||||
res.modified.push(relPath)
|
||||
} else if (!e.myHash && l.hash) {
|
||||
// 补上之前没记到的 hash (老数据)
|
||||
e.myHash = l.hash
|
||||
e.mySize = l.size
|
||||
e.myMtime = l.mtime
|
||||
}
|
||||
} else {
|
||||
// 之前记的是"删除"状态
|
||||
if (l) {
|
||||
// 真的墓碑 (有过删除记录) -> 复活; 否则只是"第一次见到这个路径" -> 新增
|
||||
const isTombstone = e.mySeq > 0 && e.deletedAt > 0
|
||||
e.myState = 'present'
|
||||
e.mySeq = ++clock
|
||||
e.myHash = l.hash || ''
|
||||
e.mySize = l.size
|
||||
e.myMtime = l.mtime
|
||||
if (isTombstone) {
|
||||
e.deletedAt = 0
|
||||
res.restored.push(relPath)
|
||||
} else {
|
||||
res.added.push(relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 应用重命名 (换路径, 保留 fileId, **旧路径要留墓碑**) ----
|
||||
for (const [from, to] of renames) {
|
||||
const e = manifest.entries[from]
|
||||
if (!e) continue
|
||||
const l = localMap.get(to)
|
||||
// 旧路径留一条墓碑: 不留的话对方永远看不到"这个文件没了" ->
|
||||
// 对方网盘里的旧文件会永久残留, 删除传播不出去。
|
||||
manifest.entries[from] = {
|
||||
...e,
|
||||
relPath: from,
|
||||
myState: 'deleted',
|
||||
mySeq: ++clock,
|
||||
deletedAt: Date.now(),
|
||||
myHash: '',
|
||||
mySize: 0,
|
||||
myMtime: 0,
|
||||
}
|
||||
e.relPath = to
|
||||
e.myState = 'present'
|
||||
e.mySeq = ++clock
|
||||
e.myHash = l?.hash || e.myHash
|
||||
e.mySize = l?.size || e.mySize
|
||||
e.myMtime = l?.mtime || e.myMtime
|
||||
manifest.entries[to] = e
|
||||
res.renamed.push({ from, to })
|
||||
}
|
||||
|
||||
// ---- 新增 ----
|
||||
for (const l of localFiles) {
|
||||
if (manifest.entries[l.relPath]) continue
|
||||
const e = emptyEntry(l.relPath)
|
||||
e.myState = 'present'
|
||||
e.mySeq = ++clock
|
||||
e.myHash = l.hash || ''
|
||||
e.mySize = l.size
|
||||
e.myMtime = l.mtime
|
||||
manifest.entries[l.relPath] = e
|
||||
res.added.push(l.relPath)
|
||||
}
|
||||
|
||||
manifest.clock = clock
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 只做一件事: 把本地 Lamport 时钟推到"不小于对方当前的 seq"。
|
||||
*
|
||||
* Lamport 时钟的标准用法是"**先收消息, 再产生事件**":
|
||||
* 本地每产生一个变更事件, 它的 seq 必须大于我已知的对方最大 seq,
|
||||
* 否则跨机器的 seq 就不可比, 删除方向会判反。
|
||||
* 所以扫描的顺序必须是: advanceClock -> reconcileLocal -> mergePeer。
|
||||
*/
|
||||
export function advanceClock(manifest: Manifest, peerEntries: Map<string, ManifestWireEntry>): void {
|
||||
let maxPeerSeq = 0
|
||||
for (const p of peerEntries.values()) if (p.seq > maxPeerSeq) maxPeerSeq = p.seq
|
||||
if (maxPeerSeq > manifest.clock) manifest.clock = maxPeerSeq
|
||||
if (maxPeerSeq > manifest.remoteSeq) manifest.remoteSeq = maxPeerSeq
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并对方广播过来的状态。
|
||||
* 只接受 seq 更大的事件 (水位线过滤) -> 重复同步同一批事件不会出错。
|
||||
*
|
||||
* 注意: 这里新建的条目 (对方有、我从没见过的路径) 的 myState 是 'deleted',
|
||||
* 意思是"**我这边从来没有这个文件**", 不是"我把它删了" —— 真正的墓碑有 deletedAt。
|
||||
*/
|
||||
export function mergePeer(manifest: Manifest, peerEntries: Map<string, ManifestWireEntry>): void {
|
||||
for (const p of peerEntries.values()) {
|
||||
let e = manifest.entries[p.relPath]
|
||||
if (!e) {
|
||||
// 对方有、我从没见过的路径
|
||||
e = emptyEntry(p.relPath)
|
||||
e.fileId = p.fileId || e.fileId
|
||||
manifest.entries[p.relPath] = e
|
||||
}
|
||||
if (p.seq <= e.peerSeq) continue // 老事件, 忽略
|
||||
e.peerSeq = p.seq
|
||||
e.peerState = p.state === 'present' ? 'present' : 'deleted'
|
||||
e.peerHash = p.hash || ''
|
||||
e.peerSize = p.size || 0
|
||||
e.peerMtime = p.mtime || 0
|
||||
if (p.state === 'deleted' && p.deletedAt && !e.deletedAt) e.deletedAt = p.deletedAt
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 三方对比 ============
|
||||
|
||||
export type SyncAction =
|
||||
| 'same' // 两边一致
|
||||
| 'upload' // 本地 -> 对方 (覆盖)
|
||||
| 'download' // 对方 -> 本地 (覆盖)
|
||||
| 'delete-local' // 对方删了 -> 删我本地
|
||||
| 'delete-remote' // 我删了 -> 删对方
|
||||
| 'conflict' // 两边都动过 -> 用户选
|
||||
|
||||
export interface SyncPlanItem {
|
||||
relPath: string
|
||||
action: SyncAction
|
||||
local?: SyncFileInfo
|
||||
remote?: SyncFileInfo
|
||||
/** 我这边状态 */
|
||||
myState: 'present' | 'deleted'
|
||||
/** 对方状态 */
|
||||
peerState: 'present' | 'deleted'
|
||||
/** 基线 hash (空 = 从来没同步过) */
|
||||
baseHash: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface SyncPlan {
|
||||
items: SyncPlanItem[]
|
||||
stats: {
|
||||
same: number
|
||||
upload: number
|
||||
download: number
|
||||
deleteLocal: number
|
||||
deleteRemote: number
|
||||
conflict: number
|
||||
}
|
||||
}
|
||||
|
||||
function sameContent(a: SyncFileInfo, b: SyncFileInfo): boolean {
|
||||
if (a.hash && b.hash) return a.hash === b.hash
|
||||
// 一边没 hash (超大文件) -> 退回 size + mtime
|
||||
return a.size === b.size && Math.abs(a.mtime - b.mtime) < 1000
|
||||
}
|
||||
|
||||
/** 没有基线时的兜底: 看 mtime 谁新 (差得远当单向, 接近当冲突) */
|
||||
function decideByMtime(l: SyncFileInfo, r: SyncFileInfo): { action: SyncAction; note: string } {
|
||||
const diff = l.mtime - r.mtime
|
||||
if (Math.abs(diff) < MTIME_TIE_SEC * 1000) {
|
||||
return { action: 'conflict', note: '内容不同且修改时间接近 (冲突)' }
|
||||
}
|
||||
if (diff < 0) return { action: 'download', note: '对方更新, 下载覆盖本地' }
|
||||
return { action: 'upload', note: '本地更新, 上传覆盖对方' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 三方对比: 基线 + 本地现状 + 远端现状 -> 一份可执行的同步计划。
|
||||
*
|
||||
* 判定表 (base = 上次两边一致的 hash):
|
||||
*
|
||||
* 本地 远端 基线 判定 动作
|
||||
* 有 有 一致 相同 无
|
||||
* 有 有 只有本地改 本地新 upload
|
||||
* 有 有 只有远端改 远端新 download
|
||||
* 有 有 都改过 冲突 conflict
|
||||
* 无 有 有 我删了 delete-remote (对方没改) / conflict (对方改了)
|
||||
* 无 有 无 远端新增 download
|
||||
* 有 无 有 对方删了 delete-local (我没改) / conflict (我改了)
|
||||
* 有 无 无 本地新增 upload
|
||||
* 无 无 有 两边都删了 无 (墓碑到期可清理)
|
||||
*
|
||||
* 墓碑优先 (防止删除被复活):
|
||||
* 我有墓碑 (myState='deleted') 且 对方 seq <= 我的墓碑 seq -> 删除赢, 删对方那份
|
||||
* 对方 seq > 我的墓碑 seq -> 对方真的恢复了文件 -> 接受, 下载
|
||||
*/
|
||||
export function planSync(
|
||||
manifest: Manifest,
|
||||
localFiles: SyncFileInfo[],
|
||||
peerEntries: Map<string, ManifestWireEntry>,
|
||||
): SyncPlan {
|
||||
const localMap = new Map(localFiles.map(f => [f.relPath, f]))
|
||||
const allPaths = new Set<string>([
|
||||
...Object.keys(manifest.entries),
|
||||
...peerEntries.keys(),
|
||||
])
|
||||
const items: SyncPlanItem[] = []
|
||||
const stats = { same: 0, upload: 0, download: 0, deleteLocal: 0, deleteRemote: 0, conflict: 0 }
|
||||
|
||||
for (const p of Array.from(allPaths).sort()) {
|
||||
const e = manifest.entries[p]
|
||||
const l = localMap.get(p)
|
||||
const pw = peerEntries.get(p)
|
||||
|
||||
const myAlive = !!l
|
||||
const peerAlive = !!pw && pw.state === 'present'
|
||||
const base = e?.baseHash || ''
|
||||
|
||||
let action: SyncAction
|
||||
let note: string
|
||||
|
||||
if (myAlive && peerAlive) {
|
||||
const lh = l!.hash || ''
|
||||
const ph = pw!.hash || ''
|
||||
if (lh && ph ? lh === ph : sameContent(l!, { relPath: p, size: pw!.size, mtime: pw!.mtime, hash: ph })) {
|
||||
action = 'same'
|
||||
note = '内容一致'
|
||||
} else {
|
||||
const myChanged = base ? lh !== base : true
|
||||
const peerChanged = base ? ph !== base : true
|
||||
if (base && myChanged && peerChanged) {
|
||||
action = 'conflict'
|
||||
note = '两边都改过 (冲突)'
|
||||
} else if (base && myChanged) {
|
||||
action = 'upload'
|
||||
note = '只有本地改过, 上传覆盖对方'
|
||||
} else if (base && peerChanged) {
|
||||
action = 'download'
|
||||
note = '只有对方改过, 下载覆盖本地'
|
||||
} else if (base) {
|
||||
action = 'conflict'
|
||||
note = '内容不一致 (冲突)'
|
||||
} else {
|
||||
const r = decideByMtime(l!, { relPath: p, size: pw!.size, mtime: pw!.mtime, hash: ph })
|
||||
action = r.action
|
||||
note = r.note + ' (首次同步, 无基线)'
|
||||
}
|
||||
}
|
||||
} else if (!myAlive && peerAlive) {
|
||||
// 对方有, 我没有
|
||||
const peerChanged = base ? (pw!.hash || '') !== base : false
|
||||
if (e && e.deletedAt > 0) {
|
||||
// 我这边有**真墓碑** (是我删的, 有删除时间戳); 比 seq 决定删除还是复活
|
||||
if (pw!.seq > e.mySeq) {
|
||||
action = 'download'
|
||||
note = '对方恢复了该文件 (对方序号更新), 下载'
|
||||
} else if (peerChanged) {
|
||||
// 墓碑更晚, 但对方改过内容 -> 不静默丢数据, 交给用户
|
||||
action = 'conflict'
|
||||
note = '我已删除, 对方又改过 (冲突)'
|
||||
} else {
|
||||
action = 'delete-remote'
|
||||
note = '我已删除, 传播删除到对方'
|
||||
}
|
||||
} else if (base && peerChanged) {
|
||||
action = 'conflict'
|
||||
note = '对方改过, 但我这边删了 (冲突)'
|
||||
} else if (base) {
|
||||
action = 'delete-remote'
|
||||
note = '我已删除, 传播删除到对方'
|
||||
} else {
|
||||
action = 'download'
|
||||
note = '对方新增, 下载'
|
||||
}
|
||||
} else if (myAlive && !peerAlive) {
|
||||
// 我有, 对方没有
|
||||
const myChanged = base ? (l!.hash || '') !== base : false
|
||||
if (base && myChanged) {
|
||||
action = 'conflict'
|
||||
note = '我改过, 对方删了 (冲突)'
|
||||
} else if (base) {
|
||||
action = 'delete-local'
|
||||
note = '对方已删除, 传播删除到本地'
|
||||
} else if (e && e.peerState === 'deleted' && e.peerSeq > 0) {
|
||||
// 对方**明确说了删了**, 但我们俩从没同步过这个文件 -> 这是两个各自独立的
|
||||
// 同名文件, 不是"同一个文件的两份"。不自动上传, 免得把对方的删除撤销掉。
|
||||
action = 'conflict'
|
||||
note = '对方删除了同名文件, 但我们从没同步过 (冲突)'
|
||||
} else {
|
||||
action = 'upload'
|
||||
note = '本地新增, 上传'
|
||||
}
|
||||
} else {
|
||||
// 两边都没有
|
||||
action = 'same'
|
||||
note = '两边都已删除'
|
||||
}
|
||||
|
||||
items.push({
|
||||
relPath: p,
|
||||
action,
|
||||
local: l,
|
||||
remote: peerAlive ? { relPath: p, size: pw!.size, mtime: pw!.mtime, hash: pw!.hash } : undefined,
|
||||
myState: myAlive ? 'present' : 'deleted',
|
||||
peerState: peerAlive ? 'present' : 'deleted',
|
||||
baseHash: base,
|
||||
note,
|
||||
})
|
||||
|
||||
if (action === 'same') stats.same++
|
||||
else if (action === 'upload') stats.upload++
|
||||
else if (action === 'download') stats.download++
|
||||
else if (action === 'delete-local') stats.deleteLocal++
|
||||
else if (action === 'delete-remote') stats.deleteRemote++
|
||||
else if (action === 'conflict') stats.conflict++
|
||||
}
|
||||
|
||||
return { items, stats }
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步成功后更新基线 (两边内容一致了, 下次就能精确判断"谁改了")。
|
||||
*/
|
||||
export function updateBaseline(
|
||||
manifest: Manifest,
|
||||
relPath: string,
|
||||
hash: string,
|
||||
size = 0,
|
||||
mtime = 0,
|
||||
): void {
|
||||
const e = manifest.entries[relPath]
|
||||
if (!e) return
|
||||
// 没有 hash (超大文件超了 hash 上限) 就**不要**把基线写成空 ——
|
||||
// 基线空了, 三方对比就退化成 mtime 判断, 会静默覆盖, 违背"绝不静默丢数据"。
|
||||
if (!hash) return
|
||||
e.baseHash = hash
|
||||
e.baseSize = size
|
||||
e.baseMtime = mtime
|
||||
}
|
||||
|
||||
/** 记录"我这边刚删了" (apply delete-local / delete-remote 成功后用) */
|
||||
export function markLocalDeleted(manifest: Manifest, relPath: string): void {
|
||||
const e = manifest.entries[relPath]
|
||||
if (!e) return
|
||||
if (e.myState !== 'deleted') {
|
||||
e.myState = 'deleted'
|
||||
e.mySeq = ++manifest.clock
|
||||
}
|
||||
e.deletedAt = e.deletedAt || Date.now()
|
||||
e.myHash = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理到期墓碑。
|
||||
* 条件: 墓碑超过保留期 **且** 对方已经越过这个序号 (对方也知道这件事了)。
|
||||
* 对方没上线的, 墓碑一直留着 —— 这正是我们要的。
|
||||
*/
|
||||
export function pruneTombstones(manifest: Manifest, peerMaxSeq: number, now = Date.now()): string[] {
|
||||
const removed: string[] = []
|
||||
for (const [relPath, e] of Object.entries(manifest.entries)) {
|
||||
if (e.myState !== 'deleted' || !e.deletedAt) continue
|
||||
if (e.peerState !== 'deleted') continue // 对方还没删, 不能清
|
||||
if (peerMaxSeq <= e.mySeq) continue // 对方还没越过这个序号
|
||||
if (now - e.deletedAt < TOMBSTONE_TTL_MS) continue
|
||||
delete manifest.entries[relPath]
|
||||
removed.push(relPath)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
// 文件夹同步引擎 (本地目录 <-> 对方网盘目录)
|
||||
//
|
||||
// 定位: 复用网盘已有的 HTTP 通道 (47901), 在其上扩展几个接口:
|
||||
// GET /drive/scan?token=&path= -> 递归清单 (relPath/size/mtime/hash)
|
||||
// POST /drive/upload?token= -> 写进对方网盘的指定相对路径
|
||||
// POST /drive/delete?token= -> 删掉对方网盘里的指定文件 ← 删除传播
|
||||
// GET /sync/manifest?token= -> 拿对方的文件关系表 (含墓碑 + 序号)
|
||||
// POST /sync/manifest?token= -> 把我的文件关系表推给对方
|
||||
//
|
||||
// 同步判定是**三方对比** (基线 + 本地 + 远端), 全部逻辑在 sync-manifest.ts:
|
||||
// - 基线 = 上次同步后两边内容一致的 hash
|
||||
// - 墓碑 = 文件被删了但记录还在, 保证删除能传播、不会被复活
|
||||
// - 序号 = Lamport 时钟, 用来判定"谁更晚" (尤其是删除 vs 修改)
|
||||
//
|
||||
// 同步是**手动触发**的: 先扫描出变更列表 (显示双方文件信息对比),
|
||||
// 用户勾选/逐条确认后点"同步"才真的动数据。冲突 (两边都改过) 会弹窗让用户选方向。
|
||||
//
|
||||
// 没有关系 (老版本对端 / 还没建立关系) 时降级:
|
||||
// - 拿不到对方的 manifest -> 用 /drive/scan 的结果当远端视图 (没有墓碑/序号)
|
||||
// - 没有基线 -> 退回 mtime 兜底 (差得远当单向, 接近当冲突)
|
||||
|
||||
import { request as httpRequest, get as httpGet } from 'node:http'
|
||||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs'
|
||||
import { join, relative, resolve, sep, dirname, basename } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { DeviceInfo } from './protocol'
|
||||
import { safeFileName } from './safe-name'
|
||||
import {
|
||||
ManifestStore, toWire, fromWire, reconcileLocal, mergePeer, advanceClock, planSync,
|
||||
updateBaseline, markLocalDeleted, pruneTombstones,
|
||||
type Manifest, type ManifestWire, type ManifestWireEntry, type SyncFileInfo, type SyncAction,
|
||||
} from './sync-manifest'
|
||||
|
||||
export type { SyncFileInfo } from './sync-manifest'
|
||||
export { ManifestStore } from './sync-manifest'
|
||||
|
||||
// ============ 类型 ============
|
||||
|
||||
/** 一条变更 */
|
||||
export interface SyncDiffItem {
|
||||
relPath: string
|
||||
/** 本地/远端都有, 还是只有一边有 */
|
||||
presence: 'both' | 'local-only' | 'remote-only'
|
||||
/** 建议动作 */
|
||||
action: SyncAction
|
||||
local?: SyncFileInfo
|
||||
remote?: SyncFileInfo
|
||||
/** 给用户看的一句话说明 */
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
localOnly: number
|
||||
remoteOnly: number
|
||||
same: number
|
||||
upload: number
|
||||
download: number
|
||||
conflict: number
|
||||
/** 对方删了 -> 我这边要删 */
|
||||
deleteLocal: number
|
||||
/** 我删了 -> 对方要删 */
|
||||
deleteRemote: number
|
||||
}
|
||||
|
||||
export interface SyncScanResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
localDir: string
|
||||
remotePath: string
|
||||
/** 对方网盘根目录 (提示用) */
|
||||
remoteRoot?: string
|
||||
items: SyncDiffItem[]
|
||||
stats: SyncStats
|
||||
}
|
||||
|
||||
/** 同步的一次上下文 (谁 + 哪两个目录 + 用什么令牌) */
|
||||
export interface SyncContext {
|
||||
peer: DeviceInfo
|
||||
localDir: string
|
||||
/** 对方网盘里, 我这份数据放在哪个子目录 */
|
||||
remotePath?: string
|
||||
/** 访问对方网盘的令牌 (关系的 peerToken; 老对端用 driveToken) */
|
||||
token?: string
|
||||
/** 关系 id (有的话才走 manifest 推送) */
|
||||
relationId?: string
|
||||
/**
|
||||
* **我方**的 deviceId。请求要带上它, 服务端才能确认
|
||||
* "这个令牌确实是签给对方的" (peerId 传错会被判成令牌与设备不匹配)。
|
||||
*/
|
||||
selfDeviceId?: string
|
||||
/** 关系表仓库 */
|
||||
store?: ManifestStore
|
||||
/** 对方网盘里给我预留的子目录 (授权范围校验用) */
|
||||
peerSyncRoot?: string
|
||||
}
|
||||
|
||||
// ============ 常量 ============
|
||||
|
||||
/** 超过这个大小不算 hash (太慢) */
|
||||
const HASH_MAX_SIZE = 256 * 1024 * 1024
|
||||
/** 目录扫描深度上限 */
|
||||
const MAX_DEPTH = 16
|
||||
/** 扫描条目数上限 */
|
||||
const MAX_ENTRIES = 20000
|
||||
|
||||
// ============ 本地扫描 ============
|
||||
|
||||
function hashFileSync(p: string): string | undefined {
|
||||
try {
|
||||
const st = statSync(p)
|
||||
if (st.size > HASH_MAX_SIZE) return undefined
|
||||
const h = createHash('sha256')
|
||||
const buf = Buffer.alloc(1024 * 1024)
|
||||
const fd = openSync(p, 'r')
|
||||
try {
|
||||
for (;;) {
|
||||
const n = readSync(fd, buf, 0, buf.length, null)
|
||||
if (n <= 0) break
|
||||
h.update(buf.subarray(0, n))
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
return h.digest('hex')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归扫描本地目录, 返回相对路径清单 */
|
||||
export function scanLocalDir(localDir: string, wantHash = true): { ok: boolean; reason?: string; files: SyncFileInfo[]; dirs: string[] } {
|
||||
const root = resolve(localDir)
|
||||
if (!existsSync(root)) return { ok: false, reason: '本地目录不存在', files: [], dirs: [] }
|
||||
let st: any
|
||||
try { st = statSync(root) } catch { return { ok: false, reason: '本地目录打不开', files: [], dirs: [] } }
|
||||
if (!st.isDirectory()) return { ok: false, reason: '本地路径不是目录', files: [], dirs: [] }
|
||||
|
||||
const files: SyncFileInfo[] = []
|
||||
const dirs: string[] = []
|
||||
const walk = (abs: string, relBase: string, depth: number) => {
|
||||
if (depth > MAX_DEPTH || files.length >= MAX_ENTRIES) return
|
||||
let names: string[] = []
|
||||
try { names = readdirSync(abs) } catch { return }
|
||||
for (const name of names) {
|
||||
if (files.length >= MAX_ENTRIES) return
|
||||
if (name.startsWith('.')) continue // 跳过隐藏文件/临时文件
|
||||
const childAbs = join(abs, name)
|
||||
const childRel = relBase ? `${relBase}/${name}` : name
|
||||
let s: any
|
||||
try { s = statSync(childAbs) } catch { continue }
|
||||
if (s.isDirectory()) {
|
||||
dirs.push(childRel)
|
||||
walk(childAbs, childRel, depth + 1)
|
||||
} else if (s.isFile()) {
|
||||
const info: SyncFileInfo = { relPath: childRel, size: s.size, mtime: s.mtimeMs }
|
||||
if (wantHash) {
|
||||
const h = hashFileSync(childAbs)
|
||||
if (h) info.hash = h
|
||||
}
|
||||
files.push(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, '', 0)
|
||||
files.sort((a, b) => a.relPath.localeCompare(b.relPath))
|
||||
dirs.sort()
|
||||
return { ok: true, files, dirs }
|
||||
}
|
||||
|
||||
// ============ 远端 ============
|
||||
|
||||
function peerBase(ctx: SyncContext): { host: string; port: number; token: string } | null {
|
||||
const token = ctx.token || ctx.peer.driveToken
|
||||
if (!token || !ctx.peer.address || !ctx.peer.filePort) return null
|
||||
return { host: ctx.peer.address, port: ctx.peer.filePort, token }
|
||||
}
|
||||
|
||||
/** 请求参数: token + peerId (服务端要靠 peerId 确认"这个令牌确实是给对方签的") */
|
||||
function peerQuery(ctx: SyncContext, extra?: string): string {
|
||||
const base = peerBase(ctx)!
|
||||
// peerId 是**请求方**(我) 的 deviceId, 不是对端的
|
||||
const self = ctx.selfDeviceId
|
||||
const q = self
|
||||
? `token=${encodeURIComponent(base.token)}&peerId=${encodeURIComponent(self)}`
|
||||
: `token=${encodeURIComponent(base.token)}`
|
||||
return extra ? `${q}&${extra}` : q
|
||||
}
|
||||
|
||||
function httpGetJson(url: string, timeoutMs = 60_000): Promise<{ ok: boolean; status: number; data: any; reason?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const req = httpGet(url, { timeout: timeoutMs }, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
let data: any = null
|
||||
try { data = JSON.parse(text) } catch { /* 非 JSON */ }
|
||||
resolve({ ok: res.statusCode === 200, status: res.statusCode || 0, data })
|
||||
})
|
||||
})
|
||||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, status: 0, data: null, reason: '请求超时' }) })
|
||||
req.on('error', (e) => resolve({ ok: false, status: 0, data: null, reason: e?.message || '连接失败' }))
|
||||
})
|
||||
}
|
||||
|
||||
/** 递归扫描对方网盘目录 (实际内容) */
|
||||
export async function scanRemoteDir(peer: DeviceInfo, remotePath = '', timeoutMs = 60_000): Promise<{ ok: boolean; reason?: string; files: SyncFileInfo[]; dirs: string[]; root?: string }> {
|
||||
const token = peer.driveToken
|
||||
if (!token || !peer.address || !peer.filePort) {
|
||||
return { ok: false, reason: '对方版本不支持网盘', files: [], dirs: [] }
|
||||
}
|
||||
const q = `token=${encodeURIComponent(token)}&path=${encodeURIComponent(remotePath)}&hash=1`
|
||||
const url = `http://${peer.address}:${peer.filePort}/drive/scan?${q}`
|
||||
const r = await httpGetJson(url, timeoutMs)
|
||||
if (!r.ok) {
|
||||
const reason = r.data?.error || r.reason || `HTTP ${r.status}`
|
||||
return { ok: false, reason: r.status === 403 ? (r.data?.error || '网盘令牌无效') : reason, files: [], dirs: [] }
|
||||
}
|
||||
const data = r.data
|
||||
if (!data?.ok) return { ok: false, reason: data?.error || '读取失败', files: [], dirs: [] }
|
||||
return {
|
||||
ok: true,
|
||||
files: (data.files || []).map((f: any) => ({ relPath: String(f.relPath), size: Number(f.size) || 0, mtime: Number(f.mtime) || 0, hash: f.hash })),
|
||||
dirs: data.dirs || [],
|
||||
root: data.root,
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取对方的文件关系表 (含墓碑 + 序号); 老对端没有这个接口 -> 返回 null (降级) */
|
||||
export async function fetchRemoteManifest(ctx: SyncContext): Promise<{ ok: boolean; wire: ManifestWire | null; reason?: string }> {
|
||||
const base = peerBase(ctx)
|
||||
if (!base || !ctx.relationId) return { ok: true, wire: null }
|
||||
const q = peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId)}`)
|
||||
const url = `http://${base.host}:${base.port}/sync/manifest?${q}`
|
||||
const r = await httpGetJson(url, 30_000)
|
||||
if (!r.ok) {
|
||||
// 404/403 = 对方不支持或没授权 -> 降级成"只有实际扫描"
|
||||
if (r.status === 404) return { ok: true, wire: null }
|
||||
return { ok: false, wire: null, reason: r.data?.error || r.reason || `HTTP ${r.status}` }
|
||||
}
|
||||
if (!r.data?.ok) return { ok: false, wire: null, reason: r.data?.error || '读取关系表失败' }
|
||||
return { ok: true, wire: r.data.manifest || null }
|
||||
}
|
||||
|
||||
/** 把我的文件关系表推给对方 (让对方知道我的删除) */
|
||||
export async function pushRemoteManifest(ctx: SyncContext, manifest: Manifest): Promise<{ ok: boolean; reason?: string; applied?: string[]; conflicts?: string[] }> {
|
||||
const base = peerBase(ctx)
|
||||
if (!base || !ctx.relationId) return { ok: true }
|
||||
const wire = toWire(manifest)
|
||||
const body = Buffer.from(JSON.stringify(wire), 'utf8')
|
||||
const q = peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId)}`)
|
||||
const path = `/sync/manifest?${q}`
|
||||
return new Promise((resolve) => {
|
||||
const req = httpRequest({
|
||||
host: base.host, port: base.port, method: 'POST', path, timeout: 60_000,
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': String(body.length) },
|
||||
})
|
||||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: '推送关系表超时' }) })
|
||||
req.on('error', (e) => resolve({ ok: false, reason: e?.message || '推送失败' }))
|
||||
req.on('response', (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
let data: any = null
|
||||
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {}
|
||||
if (res.statusCode !== 200) {
|
||||
resolve({ ok: false, reason: data?.error || `HTTP ${res.statusCode}` })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true, applied: data?.applied || [], conflicts: data?.conflicts || [] })
|
||||
})
|
||||
})
|
||||
req.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装"远端视图": 对方关系表 + 对方实际扫描。
|
||||
*
|
||||
* 为什么要两份:
|
||||
* - 关系表里有**墓碑和序号**, 用来判定删除方向和防止复活
|
||||
* - 实际扫描是**当下真实存在什么**, 首次同步时对方关系表是空的, 只能靠它
|
||||
* 实际扫描的内容覆盖关系表的内容 (更准), 但保留关系表的序号/状态。
|
||||
*/
|
||||
export async function buildPeerView(ctx: SyncContext): Promise<{
|
||||
ok: boolean
|
||||
reason?: string
|
||||
entries: Map<string, ManifestWireEntry>
|
||||
root?: string
|
||||
}> {
|
||||
const base = peerBase(ctx)
|
||||
if (!base) return { ok: false, reason: '对方版本不支持网盘', entries: new Map() }
|
||||
|
||||
// 同步区的扫描: 路径相对对方的同步目录根
|
||||
const scanQ = peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}&hash=1`)
|
||||
const scanUrl = `http://${base.host}:${base.port}/sync/scan?${scanQ}`
|
||||
const scanRes = await httpGetJson(scanUrl, 60_000)
|
||||
if (!scanRes.ok) {
|
||||
const reason = scanRes.data?.error || scanRes.reason || `HTTP ${scanRes.status}`
|
||||
return { ok: false, reason: scanRes.status === 403 ? (scanRes.data?.error || '网盘令牌无效') : reason, entries: new Map() }
|
||||
}
|
||||
if (!scanRes.data?.ok) return { ok: false, reason: scanRes.data?.error || '读取失败', entries: new Map() }
|
||||
|
||||
const entries = new Map<string, ManifestWireEntry>()
|
||||
|
||||
// 1) 先放对方关系表 (带墓碑/序号)
|
||||
const man = await fetchRemoteManifest(ctx)
|
||||
if (!man.ok) return { ok: false, reason: man.reason, entries: new Map() }
|
||||
if (man.wire) {
|
||||
for (const [p, e] of fromWire(man.wire)) entries.set(p, { ...e })
|
||||
}
|
||||
|
||||
// 2) 实际扫描覆盖内容信息 (保留序号/状态)
|
||||
for (const f of (scanRes.data.files || []) as SyncFileInfo[]) {
|
||||
const old = entries.get(f.relPath)
|
||||
if (old && old.state === 'deleted') {
|
||||
// 对方说删了, 但实际还在 -> 对方改了还没更新关系表
|
||||
// 按"实际存在"处理, 但序号沿用对方的 (后面比 seq 时不会误判)
|
||||
entries.set(f.relPath, { ...old, state: 'present', hash: f.hash || old.hash, size: f.size, mtime: f.mtime })
|
||||
continue
|
||||
}
|
||||
entries.set(f.relPath, {
|
||||
fileId: old?.fileId || '',
|
||||
relPath: f.relPath,
|
||||
hash: f.hash || old?.hash || '',
|
||||
size: f.size,
|
||||
mtime: f.mtime,
|
||||
state: 'present',
|
||||
seq: old?.seq || 0,
|
||||
deletedAt: old?.deletedAt || 0,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, entries, root: scanRes.data.root }
|
||||
}
|
||||
|
||||
// ============ 扫描 + 计划 ============
|
||||
|
||||
function emptyStats(): SyncStats {
|
||||
return { localOnly: 0, remoteOnly: 0, same: 0, upload: 0, download: 0, conflict: 0, deleteLocal: 0, deleteRemote: 0 }
|
||||
}
|
||||
|
||||
function planToItems(plan: { items: any[] }): SyncDiffItem[] {
|
||||
return plan.items.map((it: any) => {
|
||||
const presence: SyncDiffItem['presence'] =
|
||||
it.local && it.remote ? 'both' : it.local ? 'local-only' : 'remote-only'
|
||||
return {
|
||||
relPath: it.relPath,
|
||||
presence,
|
||||
action: it.action,
|
||||
local: it.local,
|
||||
remote: it.remote,
|
||||
note: it.note,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并对比: 本地目录 <-> 对方网盘目录。
|
||||
* 只读磁盘 (关系表会被更新, 但那只是记录"我这边变了"), 不动对方数据。
|
||||
*/
|
||||
export async function scanSync(args: SyncContext & { wantHash?: boolean }): Promise<SyncScanResult> {
|
||||
const remotePath = args.remotePath || ''
|
||||
const localRes = scanLocalDir(args.localDir, args.wantHash !== false)
|
||||
if (!localRes.ok) {
|
||||
return { ok: false, reason: localRes.reason, localDir: args.localDir, remotePath, items: [], stats: emptyStats() }
|
||||
}
|
||||
const peerView = await buildPeerView(args)
|
||||
if (!peerView.ok) {
|
||||
return { ok: false, reason: peerView.reason, localDir: args.localDir, remotePath, items: [], stats: emptyStats() }
|
||||
}
|
||||
|
||||
const manifest = getManifest(args)
|
||||
// 顺序很重要 (Lamport 时钟的标准用法: **先收消息, 再产生事件**):
|
||||
// 1) advanceClock: 先把本地时钟推到 >= 对方当前的 seq
|
||||
// 2) reconcileLocal: 再产生本地事件 (本地新增/修改/删除), 分配的 mySeq 一定更大
|
||||
// 3) mergePeer: 最后合并对方的字段 (为"对方有我没有"的路径建条目)
|
||||
// 顺序反了会有两个问题:
|
||||
// - mergePeer 先建条目会把"我这边从来没有"标成 deleted, 本地删除事件就被吞了
|
||||
// - reconcileLocal 先分配 seq 的话, 我的 seq 可能小于对方已知的 seq, 删除方向判反
|
||||
advanceClock(manifest, peerView.entries)
|
||||
reconcileLocal(manifest, localRes.files)
|
||||
mergePeer(manifest, peerView.entries)
|
||||
// 三方对比
|
||||
const plan = planSync(manifest, localRes.files, peerView.entries)
|
||||
// 存回
|
||||
saveManifest(args, manifest)
|
||||
|
||||
const items = planToItems(plan)
|
||||
const stats = emptyStats()
|
||||
for (const it of items) {
|
||||
if (it.action === 'same') stats.same++
|
||||
else if (it.action === 'upload') stats.upload++
|
||||
else if (it.action === 'download') stats.download++
|
||||
else if (it.action === 'conflict') stats.conflict++
|
||||
else if (it.action === 'delete-local') stats.deleteLocal++
|
||||
else if (it.action === 'delete-remote') stats.deleteRemote++
|
||||
if (it.presence === 'local-only') stats.localOnly++
|
||||
else if (it.presence === 'remote-only') stats.remoteOnly++
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
localDir: resolve(args.localDir),
|
||||
remotePath,
|
||||
remoteRoot: peerView.root,
|
||||
items,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 关系表读写 ============
|
||||
|
||||
function manifestKey(args: SyncContext): string {
|
||||
return args.relationId || `adhoc:${args.peer.deviceId}:${resolve(args.localDir)}:${args.remotePath || ''}`
|
||||
}
|
||||
|
||||
function getManifest(args: SyncContext): Manifest {
|
||||
if (!args.store) {
|
||||
// 没有仓库 (临时对比): 用内存的, 至少单次调用内一致
|
||||
const g = globalThis as any
|
||||
if (!g.__lnmMemManifests) g.__lnmMemManifests = new Map<string, Manifest>()
|
||||
const key = manifestKey(args)
|
||||
if (!g.__lnmMemManifests.has(key)) {
|
||||
g.__lnmMemManifests.set(key, { relationId: key, clock: 0, remoteSeq: 0, entries: {} })
|
||||
}
|
||||
return g.__lnmMemManifests.get(key)
|
||||
}
|
||||
return args.store.loadManifest(manifestKey(args))
|
||||
}
|
||||
|
||||
function saveManifest(args: SyncContext, manifest: Manifest): void {
|
||||
if (!args.store) {
|
||||
const g = globalThis as any
|
||||
if (g.__lnmMemManifests) g.__lnmMemManifests.set(manifestKey(args), manifest)
|
||||
return
|
||||
}
|
||||
args.store.saveManifest(manifest)
|
||||
}
|
||||
|
||||
// ============ 执行同步 ============
|
||||
|
||||
export interface SyncApplyItem {
|
||||
relPath: string
|
||||
/** 上传/下载/删除; 冲突项由用户选好方向后再传 */
|
||||
action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip'
|
||||
}
|
||||
|
||||
export interface SyncApplyResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
results: { relPath: string; ok: boolean; action: string; reason?: string }[]
|
||||
done: number
|
||||
failed: number
|
||||
/** 推给对方后, 对方那边发现的冲突 */
|
||||
peerConflicts?: string[]
|
||||
}
|
||||
|
||||
/** 把相对路径解析到本地目录内, 挡住目录穿越 */
|
||||
function localTarget(localDir: string, relPath: string): string | null {
|
||||
const root = resolve(localDir)
|
||||
const clean = relPath.replace(/^[/\\]+/, '')
|
||||
const abs = resolve(join(root, clean))
|
||||
if (abs !== root && !abs.startsWith(root + sep)) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
/** 拼出对方网盘里的目标相对路径 */
|
||||
function remoteTarget(_remotePath: string, relPath: string): string {
|
||||
return relPath
|
||||
}
|
||||
|
||||
/** 上传一个本地文件到对方网盘 */
|
||||
function uploadToDrive(
|
||||
ctx: SyncContext, relPath: string, localFile: string,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const base = peerBase(ctx)
|
||||
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
|
||||
let size = 0
|
||||
let hash = ''
|
||||
try {
|
||||
const st = statSync(localFile)
|
||||
size = st.size
|
||||
if (st.size <= HASH_MAX_SIZE) hash = hashFileSync(localFile) || ''
|
||||
} catch (e: any) {
|
||||
return Promise.resolve({ ok: false, reason: `读不了本地文件: ${e?.message || e}` })
|
||||
}
|
||||
const target = remoteTarget(ctx.remotePath || '', relPath)
|
||||
const path = `/sync/file?${peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}`)}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let sent = 0
|
||||
let failed = false
|
||||
let lastEmit = 0
|
||||
const fail = (reason: string) => {
|
||||
if (failed) return
|
||||
failed = true
|
||||
try { req.destroy() } catch {}
|
||||
resolve({ ok: false, reason })
|
||||
}
|
||||
const req = httpRequest({
|
||||
host: base.host, port: base.port, method: 'POST', path, timeout: 300_000,
|
||||
headers: {
|
||||
'x-file-rel-path': encodeURIComponent(target),
|
||||
'x-file-size': String(size),
|
||||
...(hash ? { 'x-file-sha256': hash } : {}),
|
||||
'x-file-overwrite': '1',
|
||||
'Content-Length': String(size),
|
||||
},
|
||||
})
|
||||
req.on('timeout', () => fail('上传超时'))
|
||||
req.on('error', (e) => fail(e?.message || '上传失败'))
|
||||
req.on('response', (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
let msg = `HTTP ${res.statusCode}`
|
||||
try { msg = JSON.parse(Buffer.concat(chunks).toString('utf8'))?.error || msg } catch {}
|
||||
fail(msg)
|
||||
return
|
||||
}
|
||||
resolve({ ok: true })
|
||||
})
|
||||
})
|
||||
const rs = createReadStream(localFile)
|
||||
rs.on('error', (e) => fail(e?.message || '读文件失败'))
|
||||
rs.on('data', (chunk: Buffer | string) => {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
sent += buf.length
|
||||
const now = Date.now()
|
||||
if (now - lastEmit >= 100 || sent >= size) {
|
||||
lastEmit = now
|
||||
onProgress?.(sent, size || sent)
|
||||
}
|
||||
})
|
||||
rs.pipe(req)
|
||||
})
|
||||
}
|
||||
|
||||
/** 从对方网盘下载一个文件到本地 */
|
||||
function downloadFromDrive(
|
||||
ctx: SyncContext, relPath: string,
|
||||
onProgress?: (sent: number, total: number) => void,
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const base = peerBase(ctx)
|
||||
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
|
||||
const target = localTarget(ctx.localDir, relPath)
|
||||
if (!target) return Promise.resolve({ ok: false, reason: '非法路径' })
|
||||
const remote = remoteTarget(ctx.remotePath || '', relPath)
|
||||
const path = `/sync/file?${peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}&path=${encodeURIComponent(remote)}`)}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let sent = 0
|
||||
let failed = false
|
||||
let lastEmit = 0
|
||||
const fail = (reason: string) => {
|
||||
if (failed) return
|
||||
failed = true
|
||||
try { ws.destroy() } catch {}
|
||||
try { unlinkSync(target) } catch {}
|
||||
resolve({ ok: false, reason })
|
||||
}
|
||||
try {
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
} catch (e: any) {
|
||||
resolve({ ok: false, reason: `建目录失败: ${e?.message || e}` })
|
||||
return
|
||||
}
|
||||
const ws = createWriteStream(target)
|
||||
const req = httpRequest({ host: base.host, port: base.port, method: 'GET', path, timeout: 300_000 })
|
||||
req.on('timeout', () => 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']
|
||||
const 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
|
||||
onProgress?.(sent, total || sent)
|
||||
}
|
||||
})
|
||||
res.pipe(ws)
|
||||
ws.on('finish', () => { onProgress?.(sent, total || sent); resolve({ ok: true }) })
|
||||
ws.on('error', (e) => fail(e?.message || '写入失败'))
|
||||
})
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** 让对方删掉它那边的那份 (删除传播) */
|
||||
export function deleteFromDrive(ctx: SyncContext, relPath: string): Promise<{ ok: boolean; reason?: string }> {
|
||||
const base = peerBase(ctx)
|
||||
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
|
||||
const remote = remoteTarget(ctx.remotePath || '', relPath)
|
||||
const path = `/sync/file?${peerQuery(ctx, `relationId=${encodeURIComponent(ctx.relationId || '')}`)}`
|
||||
const body = Buffer.alloc(0)
|
||||
return new Promise((resolve) => {
|
||||
const req = httpRequest({
|
||||
host: base.host, port: base.port, method: 'POST', path, timeout: 60_000,
|
||||
headers: {
|
||||
'Content-Length': '0',
|
||||
'x-file-rel-path': encodeURIComponent(remote),
|
||||
'x-file-action': 'delete',
|
||||
},
|
||||
})
|
||||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: '删除超时' }) })
|
||||
req.on('error', (e) => resolve({ ok: false, reason: e?.message || '删除失败' }))
|
||||
req.on('response', (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
let data: any = null
|
||||
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {}
|
||||
if (res.statusCode !== 200) {
|
||||
resolve({ ok: false, reason: data?.error || `HTTP ${res.statusCode}` })
|
||||
return
|
||||
}
|
||||
resolve({ ok: true })
|
||||
})
|
||||
})
|
||||
req.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一批同步项 (用户勾选的那些)。
|
||||
* - upload: 本地 -> 对方网盘 (覆盖)
|
||||
* - download: 对方网盘 -> 本地 (覆盖)
|
||||
* - delete-local: 删本地文件 (对方删了, 传播到本地)
|
||||
* - delete-remote: 让对方删掉它那份 (我删了, 传播到对方)
|
||||
* - skip: 跳过
|
||||
*/
|
||||
export async function applySyncItems(args: SyncContext & {
|
||||
items: SyncApplyItem[]
|
||||
onProgress?: (relPath: string, sent: number, total: number) => void
|
||||
/** 冲突时保留两份的命名 (自动同步用) */
|
||||
conflictSuffix?: string
|
||||
}): Promise<SyncApplyResult> {
|
||||
const results: SyncApplyResult['results'] = []
|
||||
let done = 0
|
||||
let failed = 0
|
||||
const manifest = getManifest(args)
|
||||
|
||||
for (const it of args.items) {
|
||||
if (it.action === 'skip') {
|
||||
results.push({ relPath: it.relPath, ok: true, action: 'skip', reason: '已跳过' })
|
||||
continue
|
||||
}
|
||||
let r: { ok: boolean; reason?: string }
|
||||
if (it.action === 'upload') {
|
||||
const localFile = localTarget(args.localDir, it.relPath)
|
||||
if (!localFile) {
|
||||
r = { ok: false, reason: '非法路径' }
|
||||
} else if (!existsSync(localFile)) {
|
||||
r = { ok: false, reason: '本地文件已不存在' }
|
||||
} else {
|
||||
r = await uploadToDrive(args, it.relPath, localFile,
|
||||
(sent, total) => args.onProgress?.(it.relPath, sent, total))
|
||||
}
|
||||
} else if (it.action === 'download') {
|
||||
r = await downloadFromDrive(args, it.relPath,
|
||||
(sent, total) => args.onProgress?.(it.relPath, sent, total))
|
||||
} else if (it.action === 'delete-local') {
|
||||
const localFile = localTarget(args.localDir, it.relPath)
|
||||
if (!localFile) {
|
||||
r = { ok: false, reason: '非法路径' }
|
||||
} else {
|
||||
try {
|
||||
if (existsSync(localFile)) unlinkSync(localFile)
|
||||
r = { ok: true }
|
||||
} catch (e: any) {
|
||||
r = { ok: false, reason: `删除失败: ${e?.message || e}` }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// delete-remote
|
||||
r = await deleteFromDrive(args, it.relPath)
|
||||
}
|
||||
|
||||
if (r.ok) done++
|
||||
else failed++
|
||||
results.push({ relPath: it.relPath, ok: r.ok, action: it.action, reason: r.reason })
|
||||
|
||||
// ---- 成功后更新关系表 ----
|
||||
if (r.ok) {
|
||||
const e = manifest.entries[it.relPath]
|
||||
if (e) {
|
||||
if (it.action === 'upload') {
|
||||
e.myState = 'present'
|
||||
// 基线 = 上传内容的 hash (超大文件可能没 hash -> 保持原基线, 别写成空)
|
||||
if (e.myHash) {
|
||||
updateBaseline(manifest, it.relPath, e.myHash, e.mySize, e.myMtime)
|
||||
e.peerHash = e.myHash
|
||||
e.peerState = 'present'
|
||||
}
|
||||
} else if (it.action === 'download') {
|
||||
// 下载完本地多了一个文件 -> 本地状态变了, 要 bump seq (否则跨机器比不了)
|
||||
// 只 hash 刚下载的这一个文件 —— 全目录重扫是 O(n^2), 几百个文件就能把同步卡死
|
||||
const target = localTarget(args.localDir, it.relPath)
|
||||
const h = target && existsSync(target) ? hashFileSync(target) : undefined
|
||||
if (h) {
|
||||
const st = statSync(target!)
|
||||
e.myHash = h
|
||||
e.mySize = st.size
|
||||
e.myMtime = st.mtimeMs
|
||||
e.myState = 'present'
|
||||
if (e.mySeq === 0) e.mySeq = ++manifest.clock
|
||||
updateBaseline(manifest, it.relPath, e.myHash, e.mySize, e.myMtime)
|
||||
e.peerHash = e.myHash
|
||||
e.peerState = 'present'
|
||||
}
|
||||
} else if (it.action === 'delete-local') {
|
||||
// 对方删了 -> 我这边删, 记墓碑
|
||||
markLocalDeleted(manifest, it.relPath)
|
||||
} else if (it.action === 'delete-remote') {
|
||||
// 我删了 -> 传播给对方, 我这边保持墓碑
|
||||
markLocalDeleted(manifest, it.relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理到期墓碑 (对方已经越过这个序号 + 超过保留期)
|
||||
let peerMaxSeq = manifest.remoteSeq
|
||||
try {
|
||||
const man = await fetchRemoteManifest(args)
|
||||
if (man.wire && typeof man.wire.seq === 'number') peerMaxSeq = Math.max(peerMaxSeq, man.wire.seq)
|
||||
} catch { /* 拿不到就按现有水位线来 */ }
|
||||
pruneTombstones(manifest, peerMaxSeq)
|
||||
saveManifest(args, manifest)
|
||||
|
||||
// 把我的关系表推给对方 (删除就是这样传播过去的)
|
||||
let peerConflicts: string[] = []
|
||||
try {
|
||||
const p = await pushRemoteManifest(args, manifest)
|
||||
if (p.ok && p.conflicts?.length) peerConflicts = p.conflicts
|
||||
} catch { /* 推送失败不影响本次同步结果 */ }
|
||||
|
||||
return { ok: failed === 0, results, done, failed, peerConflicts }
|
||||
}
|
||||
+44
-22
@@ -42,6 +42,13 @@ contextBridge.exposeInMainWorld('api', {
|
||||
readBase64: (p: string) => ipcRenderer.invoke('file:readBase64', p),
|
||||
pickFile: (opts?: { image?: boolean }) => ipcRenderer.invoke('file:pick', opts),
|
||||
|
||||
// 图片: 缩略图 / 复制到剪贴板 / 看原图
|
||||
imageThumb: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
|
||||
ipcRenderer.invoke('image:thumb', args),
|
||||
imageCopy: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
|
||||
ipcRenderer.invoke('image:copy', args),
|
||||
imageDataUrl: (path: string) => ipcRenderer.invoke('image:dataUrl', { path }),
|
||||
|
||||
// 拖拽文件 -> 真实磁盘路径 (Electron 32+ 必须用 webUtils.getPathForFile)
|
||||
getPathForFile: (file: File): string => {
|
||||
try { return webUtils.getPathForFile(file) || '' } catch { return '' }
|
||||
@@ -85,26 +92,21 @@ contextBridge.exposeInMainWorld('api', {
|
||||
forwardClose: (sessionId: string) => ipcRenderer.invoke('forward:close', { sessionId }),
|
||||
forwardListSessions: () => ipcRenderer.invoke('forward:listSessions'),
|
||||
|
||||
// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传)
|
||||
usbList: (peerId: string) => ipcRenderer.invoke('usb:list', { peerId }),
|
||||
usbListLocal: () => ipcRenderer.invoke('usb:listLocal'),
|
||||
usbAttach: (peerId: string, busId: string, opts: { direction?: 'self-out' | 'self-in'; config: any }) =>
|
||||
ipcRenderer.invoke('usb:attach', { peerId, busId, ...(opts || {}) }),
|
||||
// 串口: 发送字节
|
||||
usbSerialSend: (sessionId: string, dataBase64: string) =>
|
||||
ipcRenderer.invoke('usb:serialSend', { sessionId, dataBase64 }),
|
||||
// USB: 控制传输
|
||||
usbCtrlOut: (sessionId: string, setup: any, dataBase64?: string) =>
|
||||
ipcRenderer.invoke('usb:ctrlOut', { sessionId, setup, dataBase64 }),
|
||||
usbCtrlIn: (sessionId: string, setup: any, length: number) =>
|
||||
ipcRenderer.invoke('usb:ctrlIn', { sessionId, setup, length }),
|
||||
// USB: 批量传输
|
||||
usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) =>
|
||||
ipcRenderer.invoke('usb:bulkOut', { sessionId, endpoint, dataBase64 }),
|
||||
usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) =>
|
||||
ipcRenderer.invoke('usb:bulkIn', { sessionId, endpoint, length, timeoutMs }),
|
||||
usbDetach: (sessionId: string) => ipcRenderer.invoke('usb:detach', { sessionId }),
|
||||
usbListSessions: () => ipcRenderer.invoke('usb:listSessions'),
|
||||
// 远程: 串口 (映射 + 串口助手)
|
||||
// 串口: 列表 / 映射 / 助手
|
||||
serialList: (peerId: string) => ipcRenderer.invoke('serial:list', { peerId }),
|
||||
serialListLocal: () => ipcRenderer.invoke('serial:listLocal'),
|
||||
serialAttach: (peerId: string, busId: string, opts: { direction?: 'self-out' | 'self-in'; config: any }) =>
|
||||
ipcRenderer.invoke('serial:attach', { peerId, busId, ...(opts || {}) }),
|
||||
serialSend: (sessionId: string, dataBase64: string) =>
|
||||
ipcRenderer.invoke('serial:send', { sessionId, dataBase64 }),
|
||||
serialDetach: (sessionId: string) => ipcRenderer.invoke('serial:detach', { sessionId }),
|
||||
serialListSessions: () => ipcRenderer.invoke('serial:listSessions'),
|
||||
serialDiagnose: () => ipcRenderer.invoke('serial:diagnose'),
|
||||
serialFixPerms: () => ipcRenderer.invoke('serial:fixPerms'),
|
||||
serialEnvInfo: () => ipcRenderer.invoke('serial:envInfo'),
|
||||
serialSaveLog: (content: string, suggestedName?: string) =>
|
||||
ipcRenderer.invoke('serial:saveLog', { content, suggestedName }),
|
||||
|
||||
// 网盘: 浏览对方共享目录 + 选文件下载
|
||||
driveList: (peerId: string, path?: string) => ipcRenderer.invoke('drive:list', { peerId, path: path || '' }),
|
||||
@@ -113,10 +115,30 @@ contextBridge.exposeInMainWorld('api', {
|
||||
driveOpenFolder: () => ipcRenderer.invoke('drive:openFolder'),
|
||||
drivePickDir: () => ipcRenderer.invoke('drive:pickDir'),
|
||||
|
||||
// 文件夹同步: 本地目录 <-> 对方网盘目录
|
||||
syncPickLocalDir: () => ipcRenderer.invoke('sync:pickLocalDir'),
|
||||
syncScan: (args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string }) =>
|
||||
ipcRenderer.invoke('sync:scan', args),
|
||||
syncApply: (args: {
|
||||
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
|
||||
items: { relPath: string; action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip' }[]
|
||||
}) => ipcRenderer.invoke('sync:apply', args),
|
||||
|
||||
// 关系 (同步盘): 建立/授权/吊销 —— 对方那边直接弹窗, 不用邀请码
|
||||
relationList: () => ipcRenderer.invoke('relation:list'),
|
||||
relationRequest: (args: { peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: string[] }) =>
|
||||
ipcRenderer.invoke('relation:request', args),
|
||||
relationSetGrants: (args: { relationId: string; grants: string[] }) =>
|
||||
ipcRenderer.invoke('relation:setGrants', args),
|
||||
relationSetSync: (args: { relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean }) =>
|
||||
ipcRenderer.invoke('relation:setSync', args),
|
||||
relationRevoke: (relationId: string) => ipcRenderer.invoke('relation:revoke', { relationId }),
|
||||
relationDelete: (relationId: string) => ipcRenderer.invoke('relation:delete', { relationId }),
|
||||
|
||||
// 授权 / 审计
|
||||
remoteApprovalList: () => ipcRenderer.invoke('remote:approval:list'),
|
||||
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) =>
|
||||
ipcRenderer.invoke('remote:approval:reply', { requestId, ok, remember }),
|
||||
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) =>
|
||||
ipcRenderer.invoke('remote:approval:reply', { requestId, ok, remember, ...(extra || {}) }),
|
||||
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) =>
|
||||
ipcRenderer.invoke('audit:list', args || {}),
|
||||
auditPrune: (olderThanMs?: number) => ipcRenderer.invoke('audit:prune', olderThanMs),
|
||||
|
||||
+23
-19
@@ -3,13 +3,14 @@ import { onMounted, ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useDeviceStore } from '@/stores/device'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useTerminalStore, useForwardStore, useUsbStore, useApprovalStore } from '@/stores/remote'
|
||||
import { useTerminalStore, useForwardStore, useSerialStore, useApprovalStore } from '@/stores/remote'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
import ChatView from '@/components/ChatView.vue'
|
||||
import SettingsView from '@/components/SettingsView.vue'
|
||||
import ContextMenu from '@/components/ContextMenu.vue'
|
||||
import ApprovalDialog from '@/components/remote/ApprovalDialog.vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { unreadActionForIncoming } from '@/utils/unread'
|
||||
import type { DeviceView, MessageView, Settings } from '@/api'
|
||||
|
||||
const device = useDeviceStore()
|
||||
@@ -17,7 +18,7 @@ const message = useMessageStore()
|
||||
const session = useSessionStore()
|
||||
const terminal = useTerminalStore()
|
||||
const forward = useForwardStore()
|
||||
const usb = useUsbStore()
|
||||
const serial = useSerialStore()
|
||||
const approval = useApprovalStore()
|
||||
|
||||
const showSettings = ref(false)
|
||||
@@ -101,14 +102,18 @@ onMounted(async () => {
|
||||
}
|
||||
message.upsert(env)
|
||||
|
||||
// 仅在 savedPath 已 ready 时算接收完成, 否则不提示/加未读 (等真正拿到文件再算)
|
||||
if (!(env.body as any)?.savedPath) {
|
||||
// 文件还在路上: 仅显示气泡占位, 等下一帧再算 unread
|
||||
return
|
||||
}
|
||||
// 用户正看着这个会话 -> 立刻清未读 + 回已读回执。
|
||||
//
|
||||
// 这段必须在"文件还没落地就 return"**之前**:
|
||||
// 文字消息没有 savedPath, 以前在这里就 return 了, 导致正在看的会话
|
||||
// 永远清不掉未读 -> dock/任务栏角标的数字一直挂着 (文件/图片消息因为
|
||||
// 第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息会暴露这个 bug)。
|
||||
const action = unreadActionForIncoming({
|
||||
isActive: fromId === session.activePeerId,
|
||||
hasSavedPath: Boolean(savedPath),
|
||||
})
|
||||
|
||||
const isActive = fromId === session.activePeerId
|
||||
if (isActive) {
|
||||
if (action === 'clear') {
|
||||
if (device.unread[fromId]) {
|
||||
const next = { ...device.unread }
|
||||
delete next[fromId]
|
||||
@@ -121,6 +126,10 @@ onMounted(async () => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 不在当前会话: 文件还没落地就先只显示气泡占位, 等第二帧再算未读
|
||||
if (action === 'wait') return
|
||||
|
||||
device.refresh().then(() => {
|
||||
const sender = device.devices.find(d => d.deviceId === fromId)
|
||||
const name = sender?.name || '新消息'
|
||||
@@ -172,10 +181,10 @@ 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', (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:serial:opened', (p) => serial.onOpened(p)))
|
||||
unsubs.push(window.api.on('remote:serial:closed', (p) => serial.onClosed(p)))
|
||||
unsubs.push(window.api.on('remote:serial:output', (p) => serial.onOutput(p)))
|
||||
unsubs.push(window.api.on('remote:serial:error', (p) => { /* surface via console for now */ console.warn('[serial]', p) }))
|
||||
unsubs.push(window.api.on('remote:approval:requested', (req) => approval.add(req)))
|
||||
|
||||
// ---- 初始状态拉取 (监听器已就位, 这里只补数据) ----
|
||||
@@ -218,12 +227,7 @@ watch(() => session.activePeerId, async (id) => {
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const t = e.target as HTMLElement
|
||||
if (t && t.tagName === 'IMG' && (t as any).dataset?.viewer) {
|
||||
imageViewer.value = (t as HTMLImageElement).src
|
||||
}
|
||||
})
|
||||
// 图片放大: 由 MessageItem 主动 emit (它要先去主进程取原图, 不能靠全局 click 猜)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+164
-67
@@ -35,6 +35,13 @@ export type LnmApiBase = {
|
||||
pickFile: (opts?: { image?: boolean }) => Promise<{ path: string; name: string; size: number } | null>
|
||||
getPathForFile: (file: File) => string
|
||||
|
||||
// 图片: 缩略图 (老消息/老版本对端没带缩略图时现场生成) / 复制原图 / 读原图
|
||||
imageThumb: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
|
||||
Promise<{ ok: boolean; dataUrl?: string; reason?: string }>
|
||||
imageCopy: (args: { path?: string; base64?: string; mime?: string; dataUrl?: string }) =>
|
||||
Promise<{ ok: boolean; reason?: string }>
|
||||
imageDataUrl: (path: string) => Promise<{ ok: boolean; dataUrl?: string; reason?: string }>
|
||||
|
||||
hide: () => Promise<void>
|
||||
appVersion: () => Promise<string>
|
||||
|
||||
@@ -98,7 +105,7 @@ export interface Settings {
|
||||
preferredInterface?: string
|
||||
preferredAddress?: string
|
||||
remoteEnabled?: boolean
|
||||
remoteAllowPeers?: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
|
||||
remoteAllowPeers?: Record<string, { terminal?: boolean; forward?: boolean; serial?: boolean }>
|
||||
forwardDefaultTtlSec?: number
|
||||
forwardMaxBytesPerSec?: number
|
||||
terminalReadOnlyByDefault?: boolean
|
||||
@@ -160,36 +167,26 @@ export interface ForwardSessionInfo {
|
||||
side: 'client' | 'server'
|
||||
}
|
||||
|
||||
// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传)
|
||||
export type UsbDirection = 'self-out' | 'self-in'
|
||||
export type UsbKind = 'serial' | 'usb' | 'usbip'
|
||||
// 远程: 串口 (映射 + 串口助手)
|
||||
export type SerialDirection = 'self-out' | 'self-in'
|
||||
|
||||
export interface UsbDeviceInfo {
|
||||
busId: string
|
||||
vid: number
|
||||
pid: number
|
||||
deviceClass: number
|
||||
deviceSubclass: number
|
||||
export interface SerialDeviceInfo {
|
||||
busId: string // 'serial:/dev/ttyUSB0'
|
||||
serialPath: string // COM3 / /dev/ttyUSB0 / /dev/ttyACM0
|
||||
vid?: number
|
||||
pid?: number
|
||||
product?: string
|
||||
manufacturer?: string
|
||||
serialNumber?: string
|
||||
kind: UsbKind
|
||||
serialPath?: string
|
||||
baudRate?: number
|
||||
}
|
||||
|
||||
export interface UsbEndpointInfo {
|
||||
endpointNumber: number
|
||||
direction: 'in' | 'out'
|
||||
transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous'
|
||||
packetSize: number
|
||||
}
|
||||
|
||||
export interface UsbAttachedInfo {
|
||||
kind: UsbKind
|
||||
endpoints?: UsbEndpointInfo[]
|
||||
export interface SerialAttachedInfo {
|
||||
kind: 'serial'
|
||||
/** 远端真实串口路径 */
|
||||
serialPath?: string
|
||||
userVirtualPath?: string // 本机虚拟串口路径 (createVirtual=true 时填)
|
||||
/** 本机虚拟串口路径 (串口映射, createVirtual=true 时填) */
|
||||
userVirtualPath?: string
|
||||
vid?: number
|
||||
pid?: number
|
||||
product?: string
|
||||
@@ -197,58 +194,55 @@ export interface UsbAttachedInfo {
|
||||
serialNumber?: string
|
||||
}
|
||||
|
||||
export type UsbAttachConfig =
|
||||
// serial: 字节流转发
|
||||
| {
|
||||
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space'
|
||||
|
||||
export interface SerialAttachConfig {
|
||||
kind: 'serial'
|
||||
baudRate?: number
|
||||
dataBits?: 5 | 6 | 7 | 8
|
||||
stopBits?: 1 | 2
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space'
|
||||
/** 在本机创建一个虚拟串口, 桥接到远端的真实串口 — 用户可在 PuTTY/Arduino IDE 打开该路径 */
|
||||
parity?: SerialParity
|
||||
/** 在本机创建一个虚拟串口, 桥接到远端的真实串口 — 本机任何工具都能打开那个路径 */
|
||||
createVirtual?: boolean
|
||||
/** 可选: 用户指定的虚拟串口名 (Windows: "COM5"; Linux/macOS: 留空自动分配) */
|
||||
virtualName?: string
|
||||
}
|
||||
// usb: libusb 字节桥
|
||||
| {
|
||||
kind: 'usb'
|
||||
configurationValue?: number
|
||||
interfaceNumber?: number
|
||||
detachKernelDriver?: boolean
|
||||
}
|
||||
// usbip: Linux only, 真透明透传 (需内核模块)
|
||||
| {
|
||||
kind: 'usbip'
|
||||
|
||||
export interface SerialDiagnose {
|
||||
platform: string
|
||||
ok: boolean
|
||||
issues: { kind: string; detail: string }[]
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
export interface UsbSessionInfo {
|
||||
export interface SerialSessionInfo {
|
||||
sessionId: string
|
||||
peerId: string
|
||||
direction: UsbDirection
|
||||
direction: SerialDirection
|
||||
busId: string
|
||||
kind: UsbKind
|
||||
info?: UsbAttachedInfo
|
||||
info?: SerialAttachedInfo
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
createdAt: number
|
||||
side: 'client' | 'server'
|
||||
}
|
||||
|
||||
export interface UsbTransferResult {
|
||||
ok: boolean
|
||||
data?: number[] // bytes (UI 解码为 Uint8Array, 因为 renderer 端没 Buffer)
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 远程: 授权请求
|
||||
export interface ApprovalRequestView {
|
||||
requestId: string
|
||||
peerId: string
|
||||
peerName: string
|
||||
kind: 'terminal' | 'forward' | 'usb'
|
||||
kind: 'terminal' | 'forward' | 'serial' | 'relation'
|
||||
detail: string
|
||||
ts: number
|
||||
/** relation: 对方想要的能力 */
|
||||
grants?: string[]
|
||||
/** relation: 对方的同步目录 */
|
||||
syncLocalDir?: string
|
||||
/** relation: 对方想用我网盘里的哪个子目录 */
|
||||
syncRemotePath?: string
|
||||
/** relation: 关系 id */
|
||||
relationId?: string
|
||||
}
|
||||
|
||||
// 远程: 审计
|
||||
@@ -282,7 +276,94 @@ export interface DriveListResult {
|
||||
root?: string
|
||||
}
|
||||
|
||||
// 扩展 API
|
||||
// 关系 (同步盘): 设备之间的持久绑定 + 分级授权
|
||||
export type RelationGrant = 'drive' | 'sync'
|
||||
|
||||
export const RELATION_GRANT_LABEL: Record<RelationGrant, string> = {
|
||||
drive: '允许浏览我的网盘',
|
||||
sync: '允许与我同步文件',
|
||||
}
|
||||
|
||||
export interface RelationView {
|
||||
relationId: string
|
||||
peerId: string
|
||||
peerName: string
|
||||
peerAddress: string
|
||||
state: 'pending' | 'active' | 'revoked'
|
||||
/** 我开放给对方的能力 */
|
||||
myGrants: RelationGrant[]
|
||||
/** 对方开放给我的能力 */
|
||||
peerGrants: RelationGrant[]
|
||||
/** 我的同步目录 (顶层目录) */
|
||||
syncLocalDir: string
|
||||
/** 同步目录下的按关系隔离的子目录 (通常是对方设备名) */
|
||||
syncSubdir: string
|
||||
/** 我在对方网盘里用的子目录 */
|
||||
syncRemotePath: string
|
||||
/** 对方在我网盘里用的子目录 */
|
||||
peerSyncRoot: string
|
||||
syncEnabled: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
lastSyncAt: number
|
||||
lastError: string
|
||||
}
|
||||
|
||||
export interface SyncScanResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
localDir?: string
|
||||
remotePath?: string
|
||||
remoteRoot?: string
|
||||
items: SyncDiffItem[]
|
||||
stats: SyncStats
|
||||
}
|
||||
|
||||
export interface SyncApplyResult {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
results: { relPath: string; ok: boolean; action: string; reason?: string }[]
|
||||
done: number
|
||||
failed: number
|
||||
peerConflicts?: string[]
|
||||
}
|
||||
|
||||
// 远程: 文件夹同步
|
||||
export interface SyncFileInfo {
|
||||
relPath: string
|
||||
size: number
|
||||
mtime: number
|
||||
hash?: string
|
||||
}
|
||||
|
||||
export type SyncAction =
|
||||
| 'upload' // 本地 -> 对方
|
||||
| 'download' // 对方 -> 本地
|
||||
| 'delete-local' // 对方删了 -> 删我本地
|
||||
| 'delete-remote' // 我删了 -> 删对方
|
||||
| 'conflict'
|
||||
| 'same'
|
||||
|
||||
export interface SyncDiffItem {
|
||||
relPath: string
|
||||
presence: 'both' | 'local-only' | 'remote-only'
|
||||
action: SyncAction
|
||||
local?: SyncFileInfo
|
||||
remote?: SyncFileInfo
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
localOnly: number
|
||||
remoteOnly: number
|
||||
same: number
|
||||
upload: number
|
||||
download: number
|
||||
conflict: number
|
||||
deleteLocal: number
|
||||
deleteRemote: number
|
||||
}
|
||||
|
||||
export type LnmApiExtra = {
|
||||
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
|
||||
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||
@@ -296,24 +377,21 @@ export type LnmApiExtra = {
|
||||
forwardClose: (sessionId: string) => Promise<boolean>
|
||||
forwardListSessions: () => Promise<{ client: ForwardSessionInfo[]; server: ForwardSessionInfo[] }>
|
||||
|
||||
usbList: (peerId: string) => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
|
||||
usbListLocal: () => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
|
||||
usbAttach: (peerId: string, busId: string, opts: { direction?: UsbDirection; config: UsbAttachConfig }) =>
|
||||
serialList: (peerId: string) => Promise<{ ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }>
|
||||
serialListLocal: () => Promise<{ ok: boolean; reason?: string; devices?: SerialDeviceInfo[] }>
|
||||
serialAttach: (peerId: string, busId: string, opts: { direction?: SerialDirection; config: SerialAttachConfig }) =>
|
||||
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||
// 串口: 发送字节
|
||||
usbSerialSend: (sessionId: string, dataBase64: string) => Promise<boolean>
|
||||
// USB: 控制传输
|
||||
usbCtrlOut: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) =>
|
||||
Promise<{ ok: boolean; status?: number }>
|
||||
usbCtrlIn: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) =>
|
||||
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
|
||||
// USB: 批量传输
|
||||
usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) =>
|
||||
Promise<{ ok: boolean; status?: number }>
|
||||
usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) =>
|
||||
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
|
||||
usbDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||
usbListSessions: () => Promise<{ client: UsbSessionInfo[]; server: UsbSessionInfo[] }>
|
||||
serialSend: (sessionId: string, dataBase64: string) => Promise<boolean>
|
||||
serialDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||
serialListSessions: () => Promise<{ client: SerialSessionInfo[]; server: SerialSessionInfo[] }>
|
||||
/** 串口权限诊断 (Linux 的 dialout 组是串口助手最常见的坑) */
|
||||
serialDiagnose: () => Promise<{ ok: boolean; diagnose?: SerialDiagnose; reason?: string }>
|
||||
/** 一键修复: 把当前用户加进 dialout 组 (Linux, 需要提权) */
|
||||
serialFixPerms: () => Promise<{ ok: boolean; diagnose?: SerialDiagnose; fix?: { ok: boolean; stdout?: string; stderr?: string }; reason?: string }>
|
||||
/** 环境信息: 有没有 socat / 虚拟串口怎么用 */
|
||||
serialEnvInfo: () => Promise<{ ok: boolean; socat?: boolean; hint?: string }>
|
||||
serialSaveLog: (content: string, suggestedName?: string) => Promise<{ ok: boolean; path?: string; reason?: string }>
|
||||
|
||||
// 网盘
|
||||
driveList: (peerId: string, path?: string) => Promise<DriveListResult>
|
||||
@@ -326,8 +404,27 @@ export type LnmApiExtra = {
|
||||
driveOpenFolder: () => Promise<boolean>
|
||||
drivePickDir: () => Promise<string | null>
|
||||
|
||||
// 文件夹同步
|
||||
syncPickLocalDir: () => Promise<string | null>
|
||||
syncScan: (args: { peerId: string; localDir?: string; remotePath?: string; relationId?: string }) => Promise<SyncScanResult>
|
||||
syncApply: (args: {
|
||||
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
|
||||
items: { relPath: string; action: 'upload' | 'download' | 'delete-local' | 'delete-remote' | 'skip' }[]
|
||||
}) => Promise<SyncApplyResult>
|
||||
|
||||
// 关系 (同步盘): 对方那边直接弹窗, 不用邀请码
|
||||
relationList: () => Promise<RelationView[]>
|
||||
relationRequest: (args: { peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: string[] }) =>
|
||||
Promise<{ ok: boolean; reason?: string; relationId?: string }>
|
||||
relationSetGrants: (args: { relationId: string; grants: string[] }) =>
|
||||
Promise<{ ok: boolean; relation?: RelationView }>
|
||||
relationSetSync: (args: { relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean }) =>
|
||||
Promise<{ ok: boolean; relation?: RelationView }>
|
||||
relationRevoke: (relationId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||
relationDelete: (relationId: string) => Promise<{ ok: boolean }>
|
||||
|
||||
remoteApprovalList: () => Promise<ApprovalRequestView[]>
|
||||
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise<boolean>
|
||||
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) => Promise<boolean>
|
||||
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise<AuditEntry[]>
|
||||
auditPrune: (olderThanMs?: number) => Promise<number>
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import MessageItem from './MessageItem.vue'
|
||||
import MessageInput from './MessageInput.vue'
|
||||
import TerminalPanel from './remote/TerminalPanel.vue'
|
||||
import ForwardPanel from './remote/ForwardPanel.vue'
|
||||
import UsbPanel from './remote/UsbPanel.vue'
|
||||
import SerialPanel from './remote/SerialPanel.vue'
|
||||
import DrivePanel from './remote/DrivePanel.vue'
|
||||
import SyncPanel from './remote/SyncPanel.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, FolderOpened } from '@element-plus/icons-vue'
|
||||
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone, FolderOpened, RefreshRight } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
||||
@@ -21,7 +22,7 @@ const device = useDeviceStore()
|
||||
const message = useMessageStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
type Tab = 'chat' | 'terminal' | 'forward' | 'usb' | 'drive'
|
||||
type Tab = 'chat' | 'terminal' | 'forward' | 'serial' | 'drive' | 'sync'
|
||||
const activeTab = ref<Tab>('chat')
|
||||
// 切到别的设备时回到 chat tab
|
||||
watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
|
||||
@@ -100,8 +101,16 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
if (!files || files.length === 0) return
|
||||
let imgN = 0, fileN = 0
|
||||
for (const file of Array.from(files)) {
|
||||
const fpath = window.api.getPathForFile(file)
|
||||
if (file.type.startsWith('image/')) {
|
||||
imgN++
|
||||
// 图片走文件通道 (asImage=true): 流式上传 + 自动生成缩略图, 对方能直接看到图
|
||||
if (fpath) {
|
||||
window.api.sendFile(props.peer!.deviceId, fpath, {
|
||||
asImage: true, withProgress: false, mime: file.type || undefined,
|
||||
}).then(r => { if (!r.ok) ElMessage.warning('图片发送失败: ' + (r.reason || '')) })
|
||||
} else {
|
||||
// 拿不到路径 -> 退回 buffer 通道
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string
|
||||
@@ -110,9 +119,9 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
.then(r => { if (!r.ok) ElMessage.warning('图片发送失败: ' + (r.reason || '')) })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} else {
|
||||
fileN++
|
||||
const fpath = window.api.getPathForFile(file)
|
||||
if (fpath) {
|
||||
window.api.sendFile(props.peer!.deviceId, fpath, { asImage: false, withProgress: false })
|
||||
.then(r => { if (!r.ok) ElMessage.warning('发送失败: ' + (r.reason || '')) })
|
||||
@@ -163,12 +172,15 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
<button :class="{ active: activeTab === 'forward' }" @click="activeTab = 'forward'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><Connection /></el-icon><span>转发</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'usb' }" @click="activeTab = 'usb'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><Cellphone /></el-icon><span>USB</span>
|
||||
<button :class="{ active: activeTab === 'serial' }" @click="activeTab = 'serial'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><Cellphone /></el-icon><span>串口</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'drive' }" @click="activeTab = 'drive'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><FolderOpened /></el-icon><span>网盘</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'sync' }" @click="activeTab = 'sync'" :disabled="!peer.online && !channelAlive">
|
||||
<el-icon><RefreshRight /></el-icon><span>同步</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -212,10 +224,12 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
<TerminalPanel v-else-if="activeTab === 'terminal'" :peer="peer" :active="activeTab === 'terminal'" />
|
||||
<!-- forward tab -->
|
||||
<ForwardPanel v-else-if="activeTab === 'forward'" :peer="peer" />
|
||||
<!-- usb tab -->
|
||||
<UsbPanel v-else-if="activeTab === 'usb'" :peer="peer" />
|
||||
<!-- serial tab: 串口映射 + 串口助手 -->
|
||||
<SerialPanel v-else-if="activeTab === 'serial'" :peer="peer" />
|
||||
<!-- drive tab: 浏览对方网盘 + 选文件下载 -->
|
||||
<DrivePanel v-else-if="activeTab === 'drive'" :peer="peer" />
|
||||
<!-- sync tab: 本地目录 <-> 对方网盘目录 的手动同步 -->
|
||||
<SyncPanel v-else-if="activeTab === 'sync'" :peer="peer" />
|
||||
</main>
|
||||
<main class="chat-main chat-main-empty" v-else>
|
||||
<el-empty :image-size="120" description="选择一个设备开始聊天">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import type { DeviceView, MessageView, ReplyRef } from '@/api'
|
||||
import { useDeviceStore } from '@/stores/device'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { isImageFileName, imageMimeOf } from '@/utils/format'
|
||||
import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus'
|
||||
import { Link, Picture, Promotion, ChatLineRound, UploadFilled, Close } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -104,7 +105,9 @@ async function send() {
|
||||
async function pickFile() {
|
||||
const f = await window.api.pickFile()
|
||||
if (!f) return
|
||||
await sendLocalFile(f.path, f.name, false)
|
||||
// 图片要按"图片消息"发 (asImage=true), 否则对方只能看到一个文件卡片
|
||||
const asImage = isImageFileName(f.name)
|
||||
await sendLocalFile(f.path, f.name, asImage)
|
||||
}
|
||||
|
||||
async function sendLocalFile(localPath: string, name: string, asImage: boolean) {
|
||||
@@ -115,6 +118,7 @@ async function sendLocalFile(localPath: string, name: string, asImage: boolean)
|
||||
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
|
||||
const r = await window.api.sendFile(props.peer.deviceId, localPath, {
|
||||
asImage, withProgress: false,
|
||||
mime: asImage ? (imageMimeOf(name) || undefined) : undefined,
|
||||
...(replyRef ? { replyTo: replyRef } : {}),
|
||||
})
|
||||
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
||||
@@ -186,7 +190,15 @@ async function onDrop(e: DragEvent) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
imageCount++
|
||||
// 有真实路径的图片直接走文件通道 (流式上传 + 缩略图),
|
||||
// 别再把整张图读成 base64 塞进 WS 帧 —— 大图会撑爆内存和带宽。
|
||||
const fpath = window.api.getPathForFile(file)
|
||||
if (fpath) {
|
||||
sendLocalFile(fpath, file.name, true)
|
||||
} else {
|
||||
// 拿不到路径 (比如从别处粘贴的虚拟文件) -> 退回 buffer 通道
|
||||
await readImageAsPending(file)
|
||||
}
|
||||
} else {
|
||||
fileCount++
|
||||
const fpath = window.api.getPathForFile(file)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import type { MessageView, DeviceView, DeviceSelf, ReplyRef } from '@/api'
|
||||
import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format'
|
||||
import MarkdownView from './MarkdownView.vue'
|
||||
import { ElAvatar, ElButton, ElMessage, ElProgress, ElMessageBox } from 'element-plus'
|
||||
import { DocumentCopy, Delete, DocumentRemove, ChatLineSquare, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { DocumentCopy, Delete, DocumentRemove, ChatLineSquare, ArrowLeft, Picture } from '@element-plus/icons-vue'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { showContextMenu, type ContextMenuItem } from '@/composables/contextMenu'
|
||||
@@ -82,9 +82,10 @@ const isConsecutive = computed(() => {
|
||||
})
|
||||
|
||||
const text = computed(() => (props.msg.body as any).content || '')
|
||||
const imageUrl = computed(() => {
|
||||
if (props.msg.type !== 'image') return ''
|
||||
return (props.msg.body as any).thumbDataUrl || ''
|
||||
// 消息体自带的缩略图 (发送方生成, 双方共用) —— 有就直接显示
|
||||
const inlineThumb = computed(() => {
|
||||
const t = (props.msg.body as any)?.thumbDataUrl
|
||||
return typeof t === 'string' && t.startsWith('data:image/') ? t : ''
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
@@ -114,6 +115,74 @@ const localFilePath = computed(() => {
|
||||
|
||||
const hasLocalFile = computed(() => Boolean(localFilePath.value))
|
||||
|
||||
// ---- 图片显示 ----
|
||||
// 1) 消息体自带缩略图 -> 直接显示 (发送方生成一次, 双方共用)
|
||||
// 2) 没有 (老消息 / 老版本对端发来的) -> 用本地文件现场生成, 只做一次
|
||||
const thumbUrl = ref('')
|
||||
const thumbState = ref<'idle' | 'loading' | 'ready' | 'failed'>('idle')
|
||||
|
||||
async function ensureThumb() {
|
||||
if (props.msg.type !== 'image') return
|
||||
if (inlineThumb.value) {
|
||||
thumbUrl.value = inlineThumb.value
|
||||
thumbState.value = 'ready'
|
||||
return
|
||||
}
|
||||
if (thumbState.value === 'loading' || thumbState.value === 'ready' || thumbState.value === 'failed') return
|
||||
const p = localFilePath.value
|
||||
if (!p) {
|
||||
thumbState.value = 'failed'
|
||||
return
|
||||
}
|
||||
thumbState.value = 'loading'
|
||||
try {
|
||||
const r = await api.imageThumb({ path: p, mime: (props.msg.body as any)?.mime })
|
||||
if (r?.ok && r.dataUrl) {
|
||||
thumbUrl.value = r.dataUrl
|
||||
thumbState.value = 'ready'
|
||||
} else {
|
||||
thumbState.value = 'failed'
|
||||
}
|
||||
} catch {
|
||||
thumbState.value = 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(ensureThumb)
|
||||
// 接收方的 savedPath 是第二帧才到 (文件落地后), 到了再补一次缩略图
|
||||
watch(() => (props.msg.body as any)?.savedPath, ensureThumb)
|
||||
watch(() => (props.msg.body as any)?.thumbDataUrl, ensureThumb)
|
||||
|
||||
// 点开看大图: 气泡里是缩略图, 放大要看原图 (本地有文件才拿得到)
|
||||
async function openViewer() {
|
||||
// 先用缩略图立刻打开 (别让用户干等 IPC), 原图到了再替换上去
|
||||
if (thumbUrl.value) emit('open-image', thumbUrl.value)
|
||||
const p = localFilePath.value
|
||||
if (!p) return
|
||||
try {
|
||||
const r = await api.imageDataUrl(p)
|
||||
if (r?.ok && r.dataUrl) emit('open-image', r.dataUrl)
|
||||
} catch { /* 保持缩略图 */ }
|
||||
}
|
||||
|
||||
// 右键"复制图片" —— 复制的是**原图** (本地文件/原始字节), 不是气泡里的缩略图
|
||||
async function copyImage() {
|
||||
const b = props.msg.body as any
|
||||
const args = {
|
||||
path: localFilePath.value || undefined,
|
||||
base64: typeof b?.dataBase64 === 'string' && b.dataBase64 ? b.dataBase64 : undefined,
|
||||
mime: typeof b?.mime === 'string' ? b.mime : undefined,
|
||||
dataUrl: thumbUrl.value || undefined,
|
||||
}
|
||||
if (!args.path && !args.base64 && !args.dataUrl) {
|
||||
ElMessage.warning('这张图还没有可复制的内容')
|
||||
return
|
||||
}
|
||||
const r = await api.imageCopy(args)
|
||||
if (r?.ok) ElMessage.success('图片已复制到剪贴板')
|
||||
else ElMessage.warning(r?.reason || '复制失败')
|
||||
}
|
||||
|
||||
async function openLocal() {
|
||||
if (!localFilePath.value) return
|
||||
const ok = await api.open(localFilePath.value)
|
||||
@@ -200,6 +269,9 @@ function onMsgCtxMenu(e: MouseEvent) {
|
||||
if (m.type === 'text') {
|
||||
items.push({ cmd: 'copy', label: '复制', icon: DocumentCopy })
|
||||
}
|
||||
if (m.type === 'image') {
|
||||
items.push({ cmd: 'copyImage', label: '复制图片', icon: Picture })
|
||||
}
|
||||
if (isSelf.value && m.type !== 'system') {
|
||||
items.push({ cmd: 'recall', label: '撤回', icon: ArrowLeft })
|
||||
}
|
||||
@@ -209,6 +281,7 @@ function onMsgCtxMenu(e: MouseEvent) {
|
||||
}
|
||||
showContextMenu(e, items, m, (cmd) => {
|
||||
if (cmd === 'copy') copyText()
|
||||
else if (cmd === 'copyImage') copyImage()
|
||||
else if (cmd === 'reply') replyToMsg()
|
||||
else if (cmd === 'recall') recallMsg()
|
||||
else if (cmd === 'delete') deleteMessage({})
|
||||
@@ -312,18 +385,20 @@ async function recallMsg() {
|
||||
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
|
||||
</div>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
data-viewer="1"
|
||||
@click="emit('open-image', imageUrl)"
|
||||
v-if="thumbUrl"
|
||||
:src="thumbUrl"
|
||||
loading="lazy"
|
||||
@click="openViewer"
|
||||
/>
|
||||
<div v-else class="image-fallback">
|
||||
<div class="image-fallback-name">[图片] {{ (msg.body as any).name }}</div>
|
||||
<div class="image-fallback-meta">{{ formatSize((msg.body as any).size) }}</div>
|
||||
<div v-if="thumbState === 'loading'" class="image-fallback-meta">正在生成预览…</div>
|
||||
<div v-if="hasLocalFile" class="image-fallback-actions">
|
||||
<el-button size="small" link @click="openLocal">打开</el-button>
|
||||
<el-button size="small" link @click="revealLocal">在文件夹中显示</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-progress
|
||||
v-if="progress"
|
||||
:percentage="progressPct"
|
||||
@@ -337,7 +412,6 @@ async function recallMsg() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="msg.type === 'file'" class="bubble-wrap">
|
||||
<div class="bubble file-card">
|
||||
|
||||
@@ -148,9 +148,10 @@ function actionLabel(a: string) {
|
||||
'terminal.close': '终端 关闭',
|
||||
'forward.open': '转发 开启',
|
||||
'forward.close': '转发 关闭',
|
||||
'usb.list': 'USB 列表',
|
||||
'usb.attach': 'USB 附加',
|
||||
'usb.detach': 'USB 分离',
|
||||
'serial.list': '串口列表',
|
||||
'serial.attach': '串口连接',
|
||||
'serial.vcom.create': '虚拟串口创建',
|
||||
'serial.close': '串口关闭',
|
||||
} as Record<string, string>)[a] || a
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,14 @@ const self = computed(() => device.self)
|
||||
|
||||
function pick(d: { deviceId: string }) {
|
||||
session.setActive(d.deviceId)
|
||||
// 重复点同一个设备时 activePeerId 没变, watch 不会触发 -> 这里显式清一次。
|
||||
// 不然"点开了会话但角标数字还在"。
|
||||
if (device.unread[d.deviceId]) {
|
||||
const next = { ...device.unread }
|
||||
delete next[d.deviceId]
|
||||
device.unread = next
|
||||
}
|
||||
window.api.clearUnread(d.deviceId)
|
||||
}
|
||||
|
||||
function isSelf(id: string) {
|
||||
|
||||
@@ -1,16 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useApprovalStore } from '@/stores/remote'
|
||||
import { RELATION_GRANT_LABEL, type RelationGrant, type Settings } from '@/api'
|
||||
import { Folder, Connection } from '@element-plus/icons-vue'
|
||||
|
||||
const store = useApprovalStore()
|
||||
const remember = ref(false)
|
||||
|
||||
const queue = computed(() => store.queue)
|
||||
const current = computed(() => queue.value[0])
|
||||
// relation 分支专用: 只有 kind==='relation' 才有值, 模板里就不用反复判空
|
||||
const relationReq = computed(() => {
|
||||
const r = current.value
|
||||
return r && r.kind === 'relation' ? r : null
|
||||
})
|
||||
|
||||
// ---- relation 分支的表单状态 ----
|
||||
// 可勾选的能力全集
|
||||
const ALL_GRANTS: RelationGrant[] = ['drive', 'sync']
|
||||
// 我决定开放给对方的能力
|
||||
const selectedGrants = ref<RelationGrant[]>([])
|
||||
// 我这边的同步目录
|
||||
const myLocalDir = ref('')
|
||||
// 我想在**对方**网盘里用的子目录 (留空 = 主进程兜底成 同步/<我的设备名>)
|
||||
// 一项能力都不开放的话关系没有意义, 直接禁用"允许"
|
||||
const canAllow = computed(() => selectedGrants.value.length > 0)
|
||||
|
||||
// 队列里换了一条请求就重置表单, 避免上一条的选择串到下一条
|
||||
watch(() => current.value?.requestId, () => {
|
||||
remember.value = false
|
||||
const r = relationReq.value
|
||||
if (!r) return
|
||||
// 默认按对方请求的能力预勾选, 用户可以改
|
||||
selectedGrants.value = ALL_GRANTS.filter(g => (r.grants || []).includes(g))
|
||||
myLocalDir.value = ''
|
||||
// 把上次用过的同步目录带出来, 省得每次都重选
|
||||
void loadMyLocalDir()
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadMyLocalDir() {
|
||||
try {
|
||||
// 渲染层的 Settings 类型还没跟上同步盘字段, 这里只取需要的那个
|
||||
const st = await window.api.getSettings() as Settings & { syncLocalDir?: string }
|
||||
if (st?.syncLocalDir && !myLocalDir.value) myLocalDir.value = st.syncLocalDir
|
||||
} catch {
|
||||
// 读不到设置就留空, 让用户自己选
|
||||
}
|
||||
}
|
||||
|
||||
// el-checkbox-group 回传的是 (string|number)[], 这里只留白名单里的能力
|
||||
function onGrantsChange(val: (string | number)[]) {
|
||||
selectedGrants.value = ALL_GRANTS.filter(g => val.includes(g))
|
||||
}
|
||||
|
||||
async function pickLocalDir() {
|
||||
const p = await window.api.syncPickLocalDir()
|
||||
if (p) myLocalDir.value = p
|
||||
}
|
||||
|
||||
async function reply(ok: boolean) {
|
||||
if (!current.value) return
|
||||
await store.reply(current.value.requestId, ok, remember.value)
|
||||
const req = current.value
|
||||
if (!req) return
|
||||
if (req.kind === 'relation') {
|
||||
if (ok) {
|
||||
// 关系是长期授权: 不带 24h 记忆, 只回传我开放的范围和目录
|
||||
await store.reply(req.requestId, true, false, {
|
||||
grants: selectedGrants.value,
|
||||
// 留空就传空串: 传 undefined 会被主进程当成"沿用对方的提议值"
|
||||
syncLocalDir: myLocalDir.value.trim(),
|
||||
})
|
||||
} else {
|
||||
await store.reply(req.requestId, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
await store.reply(req.requestId, ok, remember.value)
|
||||
remember.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -18,7 +82,47 @@ async function reply(ok: boolean) {
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="current" class="approval-mask">
|
||||
<div class="approval-card">
|
||||
<!-- 建立同步关系: 单独一套 UI, 因为用户要决定开放哪些能力 -->
|
||||
<div v-if="relationReq" class="approval-card approval-card--relation">
|
||||
<header>
|
||||
<el-icon class="hi"><Connection /></el-icon>
|
||||
<strong>{{ relationReq.peerName }}</strong> 想和你建立同步关系
|
||||
</header>
|
||||
|
||||
<div v-if="relationReq.syncLocalDir" class="peer-dir">
|
||||
<el-icon><Folder /></el-icon>
|
||||
<span class="lbl">对方的同步目录</span>
|
||||
<span class="val">{{ relationReq.syncLocalDir }}</span>
|
||||
</div>
|
||||
|
||||
<section class="sec">
|
||||
<div class="sec-title">开放给对方的能力</div>
|
||||
<el-checkbox-group :model-value="selectedGrants" @update:model-value="onGrantsChange">
|
||||
<el-checkbox v-for="g in ALL_GRANTS" :key="g" :value="g">{{ RELATION_GRANT_LABEL[g] }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<p class="tip">已按对方的请求预勾选, 可以取消你不想开放的能力。</p>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<div class="sec-title">我的同步目录</div>
|
||||
<el-input v-model="myLocalDir" size="small" placeholder="选择要同步的本机文件夹">
|
||||
<template #append>
|
||||
<el-button @click="pickLocalDir"><el-icon><Folder /></el-icon>选择</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<p v-if="!myLocalDir" class="tip">留空 = 之后再在同步面板里设置</p>
|
||||
<p class="tip">同步范围 = 对方为这个关系指定的目录, 你只看到对方愿意开放的那部分。</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="spacer" />
|
||||
<button class="btn-reject" @click="reply(false)">拒绝</button>
|
||||
<button class="btn-allow" :disabled="!canAllow" @click="reply(true)">允许</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- 远程操作 (terminal / forward / serial) -->
|
||||
<div v-else class="approval-card">
|
||||
<header>
|
||||
<strong>{{ current.peerName }}</strong> 请求{{
|
||||
current.kind === 'terminal' ? '打开终端' :
|
||||
@@ -61,9 +165,17 @@ async function reply(ok: boolean) {
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
/* 关系弹窗内容更多, 放宽一点 */
|
||||
.approval-card--relation { width: 460px; }
|
||||
header { font-size: 15px; margin-bottom: 8px; }
|
||||
header .hi { vertical-align: -2px; margin-right: 4px; color: var(--el-color-primary); }
|
||||
.detail { font-size: 13px; color: var(--el-text-color-regular); padding: 8px 0; }
|
||||
.warn { font-size: 12px; color: var(--el-color-warning); background: rgba(230, 162, 60, 0.08); padding: 8px 10px; border-radius: 6px; }
|
||||
.peer-dir { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--el-text-color-secondary); margin: 4px 0 2px; }
|
||||
.peer-dir .val { color: var(--el-text-color-primary); word-break: break-all; }
|
||||
.sec { margin-top: 12px; }
|
||||
.sec-title { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 6px; }
|
||||
.tip { margin: 6px 0 0; font-size: 11px; color: var(--el-text-color-placeholder); line-height: 1.5; }
|
||||
footer { display: flex; align-items: center; margin-top: 12px; gap: 8px; }
|
||||
.remember { font-size: 12px; display: flex; align-items: center; gap: 4px; color: var(--el-text-color-secondary); }
|
||||
.spacer { flex: 1; }
|
||||
@@ -77,4 +189,5 @@ footer { display: flex; align-items: center; margin-top: 12px; gap: 8px; }
|
||||
.btn-reject { background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
|
||||
.btn-allow { background: var(--el-color-primary); color: #fff; }
|
||||
.btn-allow:hover { filter: brightness(1.05); }
|
||||
.btn-allow[disabled] { opacity: 0.5; cursor: not-allowed; filter: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,614 @@
|
||||
<script setup lang="ts">
|
||||
// 串口面板: **串口映射** + **串口助手**
|
||||
//
|
||||
// 串口映射 —— 把对端 (或本机) 的真实串口, 在本机变成一个"能用串口工具打开"的虚拟口。
|
||||
// Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com 建 COM 对。
|
||||
// 串口助手 —— 不建虚拟口时, 应用自己当串口助手: 收发的字节流直接显示, 能发文本/HEX。
|
||||
//
|
||||
// 只做串口。USB 字节桥和 USB/IP 透传已经下线了。
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import type {
|
||||
DeviceView, SerialDeviceInfo, SerialSessionInfo, SerialDirection,
|
||||
SerialAttachConfig, SerialParity, SerialDiagnose,
|
||||
} from '@/api'
|
||||
import { useSerialStore } from '@/stores/remote'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const store = useSerialStore()
|
||||
|
||||
const platform = window.api?.platform || ''
|
||||
const isLinux = platform === 'linux'
|
||||
|
||||
// ===== 状态 =====
|
||||
const direction = ref<SerialDirection>('self-out')
|
||||
const scanning = ref(false)
|
||||
const peerDevices = ref<SerialDeviceInfo[]>([])
|
||||
const myDevices = ref<SerialDeviceInfo[]>([])
|
||||
const selectedPath = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
// 串口参数
|
||||
const baudRate = ref(115200)
|
||||
const dataBits = ref<5 | 6 | 7 | 8>(8)
|
||||
const stopBits = ref<1 | 2>(1)
|
||||
const parity = ref<SerialParity>('none')
|
||||
const createVirtual = ref(true)
|
||||
|
||||
// 串口助手
|
||||
const sendText = ref('')
|
||||
const sendMode = ref<'text' | 'hex'>('text')
|
||||
const appendNewline = ref(true)
|
||||
const repeatMs = ref(0) // 0 = 不循环
|
||||
const logLines = ref<{ ts: number; dir: 'rx' | 'tx'; hex: string; ascii: string }[]>([])
|
||||
const MAX_LOG = 500
|
||||
const hexEl = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// 环境信息 (socat / 虚拟口用法)
|
||||
const envInfo = ref<{ socat?: boolean; hint?: string }>({})
|
||||
|
||||
const BAUD_OPTIONS = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]
|
||||
|
||||
// 当前会话: 我发起的 (client) 或对方发起的 (server) —— 助手两边都能用
|
||||
const myClientSession = computed<SerialSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'client')
|
||||
)
|
||||
const serverSession = computed<SerialSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'server')
|
||||
)
|
||||
const activeSession = computed<SerialSessionInfo | undefined>(() => myClientSession.value || serverSession.value)
|
||||
|
||||
const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?.userVirtualPath)
|
||||
const remotePath = computed<string | undefined>(() => activeSession.value?.info?.serialPath)
|
||||
|
||||
// 设备列表: 按方向取对方的还是本机的
|
||||
const visibleDevices = computed<SerialDeviceInfo[]>(() =>
|
||||
direction.value === 'self-out' ? peerDevices.value : myDevices.value
|
||||
)
|
||||
|
||||
// ===== 设备列表 =====
|
||||
async function scan() {
|
||||
if (!props.peer) return
|
||||
scanning.value = true
|
||||
try {
|
||||
if (direction.value === 'self-out') {
|
||||
const r = await store.list(props.peer.deviceId)
|
||||
if (!r.ok) {
|
||||
ElMessage.error('拉取对方串口失败: ' + (r.reason || ''))
|
||||
peerDevices.value = []
|
||||
return
|
||||
}
|
||||
peerDevices.value = r.devices || []
|
||||
} else {
|
||||
const r = await store.listLocal()
|
||||
if (!r.ok) {
|
||||
ElMessage.error('列出本机串口失败: ' + (r.reason || ''))
|
||||
myDevices.value = []
|
||||
return
|
||||
}
|
||||
myDevices.value = r.devices || []
|
||||
}
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
const first = visibleDevices.value[0]
|
||||
if (first && !visibleDevices.value.find(d => d.serialPath === selectedPath.value)) {
|
||||
selectedPath.value = first.serialPath
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 连接 =====
|
||||
async function connect() {
|
||||
if (!props.peer) return
|
||||
if (!selectedPath.value) {
|
||||
ElMessage.warning('先选一个串口')
|
||||
return
|
||||
}
|
||||
if (activeSession.value) {
|
||||
ElMessage.warning('已有活跃会话, 请先断开')
|
||||
return
|
||||
}
|
||||
// Linux 上建虚拟口需要 socat —— 提前说清楚, 免得 attach 到一半才失败
|
||||
if (createVirtual.value && isLinux && envInfo.value.socat === false) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'本机没有 socat, 创建虚拟串口会失败。\n\n' +
|
||||
'安装: sudo apt install socat (Debian/Ubuntu) | sudo dnf install socat (Fedora)\n\n' +
|
||||
'要现在去装吗? (也可以先取消, 勾掉"创建本机虚拟串口"只用内置助手)',
|
||||
'缺少 socat',
|
||||
{ confirmButtonText: '去安装', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 用户点了"去安装" -> 交给系统包管理器 (需要提权, 这里只给命令)
|
||||
ElMessageBox.alert('请在终端执行:\n\n sudo apt install socat\n\n装完回到这里刷新即可。', '安装 socat')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const config: SerialAttachConfig = {
|
||||
kind: 'serial',
|
||||
baudRate: baudRate.value,
|
||||
dataBits: dataBits.value,
|
||||
stopBits: stopBits.value,
|
||||
parity: parity.value,
|
||||
createVirtual: createVirtual.value,
|
||||
}
|
||||
const r = await store.attach(props.peer.deviceId, {
|
||||
busId: `serial:${selectedPath.value}`,
|
||||
direction: direction.value,
|
||||
config,
|
||||
})
|
||||
creating.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('连接失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
ElMessage.success('已连接')
|
||||
await nextTick()
|
||||
hexEl.value?.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
async function disconnect(s: SerialSessionInfo) {
|
||||
const r = await store.detach(s.sessionId)
|
||||
if (r.ok) ElMessage.success('已断开')
|
||||
else ElMessage.warning(r.reason || '断开失败')
|
||||
}
|
||||
|
||||
// ===== 串口助手: 收 =====
|
||||
function bytesToHex(bin: string): { hex: string; ascii: string } {
|
||||
let hex = ''
|
||||
let ascii = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
const c = bin.charCodeAt(i)
|
||||
hex += c.toString(16).padStart(2, '0') + ' '
|
||||
ascii += c >= 0x20 && c < 0x7f ? bin[i] : '.'
|
||||
}
|
||||
return { hex: hex.trimEnd(), ascii }
|
||||
}
|
||||
|
||||
function pushLog(dir: 'rx' | 'tx', base64: string) {
|
||||
if (!base64) return
|
||||
let bin = ''
|
||||
try { bin = atob(base64) } catch { return }
|
||||
// 每 16 字节一行, 按行切开
|
||||
for (let i = 0; i < bin.length; i += 16) {
|
||||
const chunk = bin.slice(i, i + 16)
|
||||
const { hex, ascii } = bytesToHex(chunk)
|
||||
logLines.value.unshift({ ts: Date.now(), dir, hex, ascii })
|
||||
}
|
||||
while (logLines.value.length > MAX_LOG) logLines.value.pop()
|
||||
}
|
||||
|
||||
// 收: store 的 buffer (主进程推过来的字节流)
|
||||
watch(() => store.buffers, (b) => {
|
||||
const sid = activeSession.value?.sessionId
|
||||
if (!sid) return
|
||||
const buf = b[sid]
|
||||
if (!buf || buf.length === 0) return
|
||||
for (const item of buf) pushLog(item.dir, item.data)
|
||||
b[sid] = []
|
||||
}, { deep: true })
|
||||
|
||||
// ===== 串口助手: 发 =====
|
||||
function buildPayload(): Uint8Array | null {
|
||||
let raw = sendText.value
|
||||
if (sendMode.value === 'hex') {
|
||||
const clean = raw.replace(/[^0-9a-fA-F]/g, '')
|
||||
if (!clean) return null
|
||||
if (clean.length % 2) {
|
||||
ElMessage.warning('HEX 必须是偶数个字符')
|
||||
return null
|
||||
}
|
||||
const out = new Uint8Array(clean.length / 2)
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16)
|
||||
return out
|
||||
}
|
||||
return new TextEncoder().encode(raw)
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let bin = ''
|
||||
for (const b of bytes) bin += String.fromCharCode(b)
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
const sess = activeSession.value
|
||||
if (!sess) {
|
||||
ElMessage.warning('还没连接串口')
|
||||
return
|
||||
}
|
||||
let bytes = buildPayload()
|
||||
if (!bytes || bytes.length === 0) {
|
||||
ElMessage.warning('没有要发送的内容')
|
||||
return
|
||||
}
|
||||
if (appendNewline.value) {
|
||||
// 调试设备最常见的两个结尾: CRLF 和 LF
|
||||
const nl = new Uint8Array(bytes.length + 1)
|
||||
nl.set(bytes)
|
||||
nl[bytes.length] = 0x0a
|
||||
bytes = nl
|
||||
}
|
||||
const b64 = toBase64(bytes)
|
||||
const ok = await store.send(sess.sessionId, b64)
|
||||
if (!ok) {
|
||||
ElMessage.error('发送失败')
|
||||
return
|
||||
}
|
||||
// 自己发出去的也记一行 (TX) —— 串口助手要能对照"我发了什么/设备回了什么"
|
||||
pushLog('tx', b64)
|
||||
sendText.value = ''
|
||||
}
|
||||
|
||||
// 循环发送
|
||||
let repeatTimer: any = null
|
||||
function toggleRepeat() {
|
||||
if (repeatTimer) {
|
||||
clearInterval(repeatTimer)
|
||||
repeatTimer = null
|
||||
ElMessage.info('已停止循环发送')
|
||||
return
|
||||
}
|
||||
if (repeatMs.value <= 0) {
|
||||
ElMessage.warning('先设置循环间隔 (毫秒)')
|
||||
return
|
||||
}
|
||||
if (!sendText.value) {
|
||||
ElMessage.warning('先填要循环发送的内容')
|
||||
return
|
||||
}
|
||||
repeatTimer = setInterval(() => { void doSendKeepText() }, Math.max(200, repeatMs.value))
|
||||
ElMessage.success(`开始循环发送 (每 ${repeatMs.value}ms)`)
|
||||
}
|
||||
async function doSendKeepText() {
|
||||
const sess = activeSession.value
|
||||
if (!sess) { toggleRepeat(); return }
|
||||
let bytes = buildPayload()
|
||||
if (!bytes || bytes.length === 0) return
|
||||
if (appendNewline.value) {
|
||||
const nl = new Uint8Array(bytes.length + 1)
|
||||
nl.set(bytes)
|
||||
nl[bytes.length] = 0x0a
|
||||
bytes = nl
|
||||
}
|
||||
const b64 = toBase64(bytes)
|
||||
const ok = await store.send(sess.sessionId, b64)
|
||||
if (ok) pushLog('tx', b64)
|
||||
}
|
||||
onUnmounted(() => { if (repeatTimer) clearInterval(repeatTimer) })
|
||||
|
||||
// ===== 日志 =====
|
||||
function clearLog() { logLines.value = [] }
|
||||
|
||||
async function saveLog() {
|
||||
if (!logLines.value.length) {
|
||||
ElMessage.warning('还没有日志')
|
||||
return
|
||||
}
|
||||
// 旧的在前 (logLines 是 unshift 存的, 新的在前) -> 保存时翻回来
|
||||
const text = [...logLines.value].reverse().map(l => {
|
||||
const t = new Date(l.ts).toLocaleTimeString()
|
||||
const tag = l.dir === 'rx' ? 'RX' : 'TX'
|
||||
return `${t} ${tag} ${l.hex.padEnd(48, ' ')} ${l.ascii}`
|
||||
}).join('\n')
|
||||
const r = await window.api.serialSaveLog(text, `serial-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.log`)
|
||||
if (r.ok) ElMessage.success('已保存: ' + r.path)
|
||||
else if (r.reason !== '已取消') ElMessage.error('保存失败: ' + (r.reason || ''))
|
||||
}
|
||||
|
||||
async function copyVcomPath() {
|
||||
if (!vcomPath.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(vcomPath.value)
|
||||
ElMessage.success('路径已复制')
|
||||
} catch {
|
||||
ElMessage.warning('复制失败, 请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 权限诊断 (Linux 的 dialout 是串口助手最常见的坑) =====
|
||||
const diagnose = ref<SerialDiagnose | null>(null)
|
||||
const diagnosing = ref(false)
|
||||
|
||||
async function runDiagnose() {
|
||||
diagnosing.value = true
|
||||
try {
|
||||
const r = await window.api.serialDiagnose()
|
||||
if (!r.ok || !r.diagnose) {
|
||||
ElMessage.error('诊断失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
diagnose.value = r.diagnose
|
||||
const d = r.diagnose
|
||||
const lines = [
|
||||
...d.issues.map(i => `⚠️ ${i.detail}`),
|
||||
...(d.notes.length ? ['', ...d.notes.map(n => `· ${n}`)] : []),
|
||||
].join('\n')
|
||||
if (d.issues.length) {
|
||||
ElMessageBox.alert(lines, '串口诊断', {
|
||||
confirmButtonText: d.issues.some(i => i.kind === 'linux.dialout') ? '尝试修复 (需管理员)' : '知道了',
|
||||
cancelButtonText: '关闭',
|
||||
dangerouslyUseHTMLString: false,
|
||||
}).then(async () => {
|
||||
if (!d.issues.some(i => i.kind === 'linux.dialout')) return
|
||||
const fix = await window.api.serialFixPerms()
|
||||
if (fix.ok) {
|
||||
ElMessage.success('已修复: ' + ((fix.fix?.stdout || '') + (fix.fix?.stderr || '')).trim())
|
||||
} else {
|
||||
ElMessage.warning('修复未完成: ' + ((fix.fix?.stderr || fix.reason || '')).trim())
|
||||
}
|
||||
await scan()
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
ElMessageBox.alert(lines, '串口诊断 (一切正常)', { confirmButtonText: '好', showCancelButton: false })
|
||||
}
|
||||
} finally {
|
||||
diagnosing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 空列表提示: 按平台给, 别给 Linux 用户 Windows 的建议
|
||||
const emptyHint = computed(() => {
|
||||
if (!props.peer) return '请先在左侧选一台设备'
|
||||
if (direction.value === 'self-out') {
|
||||
return isLinux
|
||||
? '对方没报出串口。Linux 上常见原因: 对方 App 不在 dialout 组, 或者设备没插。'
|
||||
: '对方没报出串口。'
|
||||
}
|
||||
if (isLinux) return '本机没有串口。Linux 用户需在 dialout 组, 否则看不到 /dev/ttyUSB* —— 点"诊断权限"看看。'
|
||||
if (platform === 'win32') return '本机没有串口。检查设备管理器里的 COM 口。'
|
||||
return '本机没有串口。macOS 首次访问会弹权限框, 需在 系统设置 → 隐私与安全 中允许。'
|
||||
})
|
||||
|
||||
// ===== 生命周期 =====
|
||||
async function refresh() { await store.refresh() }
|
||||
let timer = 0
|
||||
onMounted(async () => {
|
||||
refresh()
|
||||
const e = await window.api.serialEnvInfo()
|
||||
envInfo.value = e
|
||||
timer = window.setInterval(() => { void refresh() }, 3000)
|
||||
})
|
||||
onUnmounted(() => { if (timer) window.clearInterval(timer) })
|
||||
|
||||
// 切换对端时清掉上一次的日志
|
||||
watch(() => props.peer?.deviceId, () => {
|
||||
logLines.value = []
|
||||
selectedPath.value = ''
|
||||
peerDevices.value = []
|
||||
})
|
||||
// 切换方向时重新列设备
|
||||
watch(direction, () => {
|
||||
selectedPath.value = ''
|
||||
void scan()
|
||||
})
|
||||
|
||||
function directionLabel(d: SerialDirection) {
|
||||
return d === 'self-out' ? '我用对方的' : '对方用我的'
|
||||
}
|
||||
function fmtTime(ts: number) {
|
||||
const d = new Date(ts)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="serial-panel">
|
||||
<!-- 方向 + 操作 -->
|
||||
<div class="dir-row">
|
||||
<span class="dir-label">方向</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">我用对方的</button>
|
||||
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">对方用我的</button>
|
||||
</div>
|
||||
<el-button size="small" :loading="scanning" @click="scan" :disabled="!peer">
|
||||
{{ direction === 'self-out' ? '拉取对端串口' : '刷新本机串口' }}
|
||||
</el-button>
|
||||
<el-button size="small" :loading="diagnosing" @click="runDiagnose">诊断权限</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!visibleDevices.length" class="empty-tip">
|
||||
<p>{{ emptyHint }}</p>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="visibleDevices"
|
||||
size="small"
|
||||
highlight-current-row
|
||||
@row-click="(row: SerialDeviceInfo) => selectedPath = row.serialPath"
|
||||
>
|
||||
<el-table-column prop="serialPath" label="串口" width="200" />
|
||||
<el-table-column label="VID:PID" width="120">
|
||||
<template #default="{ row }">
|
||||
<code v-if="row.vid">{{ row.vid.toString(16).padStart(4,'0') }}:{{ (row.pid||0).toString(16).padStart(4,'0') }}</code>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product" label="产品" />
|
||||
<el-table-column prop="manufacturer" label="制造商" width="140" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :disabled="!!activeSession" @click.stop="() => { selectedPath = row.serialPath; connect() }">
|
||||
连接
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 串口参数 + 映射开关 -->
|
||||
<div v-if="visibleDevices.length" class="param-row">
|
||||
<span>波特率</span>
|
||||
<el-select v-model.number="baudRate" size="small" style="width: 110px">
|
||||
<el-option v-for="b in BAUD_OPTIONS" :key="b" :label="String(b)" :value="b" />
|
||||
</el-select>
|
||||
<span>数据位</span>
|
||||
<el-select v-model.number="dataBits" size="small" style="width: 80px">
|
||||
<el-option label="5" :value="5" /><el-option label="6" :value="6" />
|
||||
<el-option label="7" :value="7" /><el-option label="8" :value="8" />
|
||||
</el-select>
|
||||
<span>停止位</span>
|
||||
<el-select v-model.number="stopBits" size="small" style="width: 76px">
|
||||
<el-option label="1" :value="1" /><el-option label="2" :value="2" />
|
||||
</el-select>
|
||||
<span>校验</span>
|
||||
<el-select v-model="parity" size="small" style="width: 90px">
|
||||
<el-option label="无" value="none" /><el-option label="偶" value="even" />
|
||||
<el-option label="奇" value="odd" /><el-option label="标记" value="mark" />
|
||||
<el-option label="空格" value="space" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="createVirtual" style="margin-left: 10px">
|
||||
创建本机虚拟串口 (串口映射)
|
||||
</el-checkbox>
|
||||
<el-button size="small" type="primary" :loading="creating" :disabled="!!activeSession" @click="connect">连接</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 会话状态 -->
|
||||
<div v-if="activeSession" class="attach-box">
|
||||
<div class="attach-row">
|
||||
<span>
|
||||
已连接: <code>{{ activeSession.busId }}</code> · {{ directionLabel(activeSession.direction) }}
|
||||
<span v-if="remotePath"> · 远端 <code>{{ remotePath }}</code></span>
|
||||
</span>
|
||||
<span>收 {{ activeSession.bytesIn }}B / 发 {{ activeSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" type="danger" @click="disconnect(activeSession)">断开</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟串口: 醒目的路径 + 用法 -->
|
||||
<div v-if="vcomPath" class="vcom-banner">
|
||||
<div class="vcom-banner-row">
|
||||
<span class="vcom-banner-label">本机虚拟串口 (串口工具直接打开这个路径):</span>
|
||||
</div>
|
||||
<div class="vcom-banner-row">
|
||||
<code class="vcom-banner-path">{{ vcomPath }}</code>
|
||||
<el-button size="small" link @click="copyVcomPath">复制路径</el-button>
|
||||
<small class="vcom-hint">{{ baudRate }} 8{{ dataBits }}{{ parity === 'none' ? 'N' : parity[0].toUpperCase() }}{{ stopBits }}</small>
|
||||
</div>
|
||||
<div class="vcom-banner-row">
|
||||
<small class="vcom-hint">{{ envInfo.hint || '用 minicom / screen / PuTTY 打开上面的路径即可。' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="activeSession.side === 'server'" class="vcom-banner">
|
||||
<div class="vcom-banner-row">
|
||||
<small class="vcom-hint">对方已连上你的串口。下面是双向流量 (TX = 你发出的, RX = 对方发来的)。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 串口助手 ===== -->
|
||||
<div class="assistant">
|
||||
<div class="assistant-head">
|
||||
<span class="assistant-title">串口助手</span>
|
||||
<span class="assistant-sub">TX = 我发出 · RX = 设备发回</span>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" @click="clearLog">清屏</el-button>
|
||||
<el-button size="small" @click="saveLog">保存日志</el-button>
|
||||
</div>
|
||||
|
||||
<div ref="hexEl" class="hex-view">
|
||||
<div v-if="!logLines.length" class="hex-empty">
|
||||
{{ activeSession ? '等待串口数据…' : '连接一个串口后开始收发' }}
|
||||
</div>
|
||||
<div v-for="(line, i) in logLines" :key="i" class="hex-line" :class="line.dir">
|
||||
<span class="hex-time">{{ fmtTime(line.ts) }}</span>
|
||||
<span class="hex-dir">{{ line.dir === 'rx' ? 'RX' : 'TX' }}</span>
|
||||
<span class="hex-bytes">{{ line.hex }}</span>
|
||||
<span class="hex-ascii">{{ line.ascii }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="send-row">
|
||||
<el-input
|
||||
v-model="sendText"
|
||||
size="small"
|
||||
class="send-input"
|
||||
:placeholder="sendMode === 'hex' ? 'HEX, 如 A1 03 00 FF' : '要发送的文本'"
|
||||
@keydown.enter.exact.prevent="doSend"
|
||||
/>
|
||||
<el-radio-group v-model="sendMode" size="small">
|
||||
<el-radio-button value="text">文本</el-radio-button>
|
||||
<el-radio-button value="hex">HEX</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-checkbox v-model="appendNewline">追加 LF</el-checkbox>
|
||||
<el-button size="small" type="primary" :disabled="!activeSession" @click="doSend">发送</el-button>
|
||||
</div>
|
||||
<div class="send-row">
|
||||
<span class="dir-label">循环发送</span>
|
||||
<el-input-number v-model="repeatMs" :min="0" :step="100" size="small" controls-position="right" style="width: 110px" />
|
||||
<span class="dir-label">ms</span>
|
||||
<el-button size="small" :disabled="!activeSession" @click="toggleRepeat">
|
||||
{{ repeatTimer ? '停止循环' : '开始循环' }}
|
||||
</el-button>
|
||||
<small style="color:var(--el-text-color-secondary)">循环发送时不会清空输入框</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.serial-panel {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dir-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.dir-label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.dir-toggle { display: inline-flex; border: 1px solid var(--el-border-color); border-radius: 4px; overflow: hidden; }
|
||||
.dir-toggle button {
|
||||
border: none; background: transparent; padding: 5px 12px; cursor: pointer;
|
||||
font-size: 12px; color: var(--el-text-color-regular);
|
||||
}
|
||||
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; }
|
||||
.empty-tip {
|
||||
padding: 14px; border: 1px dashed var(--el-border-color); border-radius: 6px;
|
||||
color: var(--el-text-color-secondary); font-size: 13px;
|
||||
}
|
||||
.empty-tip p { margin: 0; }
|
||||
.param-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; }
|
||||
.attach-box {
|
||||
border: 1px solid var(--el-border-color); border-radius: 6px; padding: 8px 10px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.attach-row { display: flex; align-items: center; gap: 10px; font-size: 12px; flex-wrap: wrap; }
|
||||
.spacer { flex: 1; }
|
||||
.vcom-banner {
|
||||
background: var(--el-color-success-light-9); border: 1px solid var(--el-color-success-light-7);
|
||||
border-radius: 6px; padding: 8px 10px;
|
||||
}
|
||||
.vcom-banner-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.vcom-banner-label { font-size: 12px; color: var(--el-color-success-dark-2); }
|
||||
.vcom-banner-path {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; font-weight: 600;
|
||||
background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.vcom-hint { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
|
||||
/* 串口助手 */
|
||||
.assistant { display: flex; flex-direction: column; gap: 6px; flex: 1; min-height: 260px; }
|
||||
.assistant-head { display: flex; align-items: center; gap: 8px; }
|
||||
.assistant-title { font-size: 13px; font-weight: 600; }
|
||||
.assistant-sub { font-size: 11px; color: var(--el-text-color-secondary); }
|
||||
.hex-view {
|
||||
flex: 1; min-height: 160px; max-height: 46vh; overflow-y: auto;
|
||||
background: var(--el-fill-color-blank); border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px; padding: 6px 8px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5;
|
||||
}
|
||||
.hex-empty { color: var(--el-text-color-secondary); padding: 10px; font-size: 12px; }
|
||||
.hex-line { display: flex; gap: 8px; white-space: pre; }
|
||||
.hex-line.tx .hex-dir { color: var(--el-color-warning); }
|
||||
.hex-line.rx .hex-dir { color: var(--el-color-primary); }
|
||||
.hex-time { color: var(--el-text-color-secondary); }
|
||||
.hex-dir { font-weight: 600; }
|
||||
.hex-bytes { color: var(--el-text-color-primary); }
|
||||
.hex-ascii { color: var(--el-color-success); }
|
||||
.send-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.send-input { flex: 1; min-width: 220px; }
|
||||
</style>
|
||||
@@ -0,0 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
// 同步面板: **先建立关系, 再同步文件**
|
||||
//
|
||||
// 两段式:
|
||||
// 1) 关系区: 跟这个设备的关系状态、我开放了什么、对方开放了什么、同步目录在哪
|
||||
// 没有关系 -> 填本地目录 + 勾选开放的能力 -> 点"发起请求" -> 对方那边直接弹窗
|
||||
// 2) 同步区: 扫描变更 (三方对比: 基线 + 本地 + 远端) -> 勾选 -> 同步
|
||||
//
|
||||
// 删除的处理 (和主流同步盘一致):
|
||||
// - 本地删了、对方还在 -> "将删除对方" (把删除传播过去)
|
||||
// - 对方删了、本地还在 -> "将删除本机"
|
||||
// - 两边都改过 -> 冲突, 弹窗对比后由用户选方向, 绝不静默覆盖
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { DeviceView, SyncDiffItem, SyncScanResult, RelationView, RelationGrant } from '@/api'
|
||||
import { RELATION_GRANT_LABEL } from '@/api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Folder, Refresh, Upload, Download, Warning, Delete, Lock, Connection } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
|
||||
const ALL_GRANTS: RelationGrant[] = ['drive', 'sync']
|
||||
|
||||
// ============ 关系 ============
|
||||
|
||||
const relations = ref<RelationView[]>([])
|
||||
/** 当前 peer 的有效关系 */
|
||||
const relation = computed<RelationView | null>(() =>
|
||||
relations.value.find(r => r.peerId === props.peer?.deviceId && r.state === 'active') || null)
|
||||
/** 等待对方确认的请求 */
|
||||
const pendingRelation = computed<RelationView | null>(() =>
|
||||
relations.value.find(r => r.peerId === props.peer?.deviceId && r.state === 'pending') || null)
|
||||
|
||||
const newLocalDir = ref('')
|
||||
const newGrants = ref<RelationGrant[]>(['drive', 'sync'])
|
||||
const myGrants = ref<RelationGrant[]>([])
|
||||
const sending = ref(false)
|
||||
|
||||
async function loadRelations() {
|
||||
try {
|
||||
relations.value = await window.api.relationList()
|
||||
} catch {
|
||||
relations.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** 发起建立关系 -> 对方那边弹窗 */
|
||||
async function pickNewLocalDir() {
|
||||
const p = await window.api.syncPickLocalDir()
|
||||
if (p) newLocalDir.value = p
|
||||
}
|
||||
|
||||
async function requestRelation() {
|
||||
if (!props.peer) return
|
||||
if (!newLocalDir.value) {
|
||||
const p = await window.api.syncPickLocalDir()
|
||||
if (!p) { ElMessage.warning('先选一个本机同步目录'); return }
|
||||
newLocalDir.value = p
|
||||
}
|
||||
if (newGrants.value.length === 0) {
|
||||
ElMessage.warning('至少开放一项能力, 否则对方没法用')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
const r = await window.api.relationRequest({
|
||||
peerId: props.peer.deviceId,
|
||||
syncLocalDir: newLocalDir.value,
|
||||
grants: newGrants.value,
|
||||
})
|
||||
sending.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error(r.reason || '发起失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('请求已发出, 等对方确认')
|
||||
await loadRelations()
|
||||
}
|
||||
|
||||
async function saveGrants() {
|
||||
if (!relation.value) return
|
||||
await window.api.relationSetGrants({ relationId: relation.value.relationId, grants: myGrants.value })
|
||||
ElMessage.success('已保存')
|
||||
await loadRelations()
|
||||
}
|
||||
|
||||
async function changeLocalDir() {
|
||||
if (!relation.value) return
|
||||
const p = await window.api.syncPickLocalDir()
|
||||
if (!p) return
|
||||
await window.api.relationSetSync({ relationId: relation.value.relationId, syncLocalDir: p })
|
||||
await loadRelations()
|
||||
}
|
||||
|
||||
/** 自动同步开关 */
|
||||
const autoSyncOn = ref(false)
|
||||
|
||||
async function toggleAutoSync(on: boolean) {
|
||||
if (!relation.value) return
|
||||
const r = await window.api.relationSetSync({ relationId: relation.value.relationId, syncEnabled: on })
|
||||
if (!r.ok) {
|
||||
ElMessage.error('设置失败')
|
||||
autoSyncOn.value = !on
|
||||
return
|
||||
}
|
||||
await loadRelations()
|
||||
ElMessage.success(on ? '已开启自动同步' : '已关闭自动同步')
|
||||
}
|
||||
|
||||
async function revokeRelation() {
|
||||
if (!relation.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'吊销后对方立刻不能再访问你的网盘和同步目录, 已同步的文件不会删除。要重新同步需要重新建立关系。',
|
||||
'吊销关系',
|
||||
{ type: 'warning', confirmButtonText: '吊销', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch { return }
|
||||
await window.api.relationRevoke(relation.value.relationId)
|
||||
ElMessage.success('已吊销')
|
||||
await loadRelations()
|
||||
}
|
||||
|
||||
// ============ 同步 ============
|
||||
|
||||
const localDir = ref('')
|
||||
const scanning = ref(false)
|
||||
const syncing = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const result = ref<SyncScanResult | null>(null)
|
||||
|
||||
type PickAction = 'upload' | 'download' | 'delete-local' | 'delete-remote'
|
||||
/** 用户勾选要同步的项 */
|
||||
const picks = ref<Record<string, PickAction>>({})
|
||||
/** 冲突项的处理决定 */
|
||||
const conflictChoice = ref<Record<string, 'upload' | 'download' | 'skip'>>({})
|
||||
const conflictItem = ref<SyncDiffItem | null>(null)
|
||||
const conflictVisible = ref(false)
|
||||
const progress = ref<Record<string, { sent: number; total: number }>>({})
|
||||
let unsubs: (() => void)[] = []
|
||||
|
||||
const items = computed<SyncDiffItem[]>(() => result.value?.items || [])
|
||||
const changes = computed(() => items.value.filter(i => i.action !== 'same'))
|
||||
const stats = computed(() => result.value?.stats)
|
||||
|
||||
const pickedCount = computed(() => Object.keys(picks.value).length)
|
||||
const unresolvedConflicts = computed(() =>
|
||||
items.value.filter(i => i.action === 'conflict' && !conflictChoice.value[i.relPath]))
|
||||
|
||||
const canSync = computed(() => {
|
||||
if (!props.peer || pickedCount.value === 0) return false
|
||||
return !Object.keys(picks.value).some(p => {
|
||||
const it = items.value.find(x => x.relPath === p)
|
||||
return it?.action === 'conflict' && !conflictChoice.value[p]
|
||||
})
|
||||
})
|
||||
|
||||
function fmtBytes(n?: number) {
|
||||
if (n === undefined || n === null) return '-'
|
||||
if (!n) return '0 B'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function fmtTime(ts?: number) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts)
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function shortHash(h?: string) {
|
||||
if (!h) return '未计算'
|
||||
return h.slice(0, 12)
|
||||
}
|
||||
|
||||
function statusTag(item: SyncDiffItem) {
|
||||
switch (item.action) {
|
||||
case 'upload': return { text: '待上传', type: 'warning' as const }
|
||||
case 'download': return { text: '待下载', type: 'primary' as const }
|
||||
case 'delete-local': return { text: '删本机', type: 'danger' as const }
|
||||
case 'delete-remote': return { text: '删对方', type: 'danger' as const }
|
||||
case 'conflict': return { text: '冲突', type: 'danger' as const }
|
||||
default: return { text: '一致', type: 'info' as const }
|
||||
}
|
||||
}
|
||||
|
||||
function isPicked(p: string) { return !!picks.value[p] }
|
||||
|
||||
function togglePick(item: SyncDiffItem, checked: boolean) {
|
||||
const next = { ...picks.value }
|
||||
if (checked) {
|
||||
if (item.action === 'conflict') {
|
||||
const c = conflictChoice.value[item.relPath]
|
||||
if (!c || c === 'skip') { openConflict(item); return }
|
||||
next[item.relPath] = c
|
||||
} else if (
|
||||
item.action === 'upload' || item.action === 'download' ||
|
||||
item.action === 'delete-local' || item.action === 'delete-remote'
|
||||
) {
|
||||
next[item.relPath] = item.action
|
||||
}
|
||||
} else {
|
||||
delete next[item.relPath]
|
||||
}
|
||||
picks.value = next
|
||||
}
|
||||
|
||||
function pickAll(val: boolean) {
|
||||
if (!val) { picks.value = {}; return }
|
||||
const next: Record<string, PickAction> = {}
|
||||
for (const it of changes.value) {
|
||||
if (
|
||||
it.action === 'upload' || it.action === 'download' ||
|
||||
it.action === 'delete-local' || it.action === 'delete-remote'
|
||||
) {
|
||||
next[it.relPath] = it.action
|
||||
} else if (it.action === 'conflict' && conflictChoice.value[it.relPath] && conflictChoice.value[it.relPath] !== 'skip') {
|
||||
next[it.relPath] = conflictChoice.value[it.relPath] as 'upload' | 'download'
|
||||
}
|
||||
}
|
||||
picks.value = next
|
||||
}
|
||||
|
||||
function openConflict(item: SyncDiffItem) {
|
||||
conflictItem.value = item
|
||||
conflictVisible.value = true
|
||||
}
|
||||
|
||||
function resolveConflict(choice: 'upload' | 'download' | 'skip') {
|
||||
const item = conflictItem.value
|
||||
if (!item) return
|
||||
conflictChoice.value = { ...conflictChoice.value, [item.relPath]: choice }
|
||||
const next = { ...picks.value }
|
||||
if (choice === 'skip') delete next[item.relPath]
|
||||
else next[item.relPath] = choice
|
||||
picks.value = next
|
||||
conflictVisible.value = false
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
if (!props.peer) return
|
||||
scanning.value = true
|
||||
errorMsg.value = ''
|
||||
const r = await window.api.syncScan({
|
||||
peerId: props.peer.deviceId,
|
||||
relationId: relation.value?.relationId,
|
||||
localDir: relation.value?.syncLocalDir || localDir.value || undefined,
|
||||
})
|
||||
scanning.value = false
|
||||
if (!r.ok) {
|
||||
result.value = null
|
||||
errorMsg.value = r.reason || '扫描失败'
|
||||
return
|
||||
}
|
||||
result.value = r
|
||||
if (r.localDir) localDir.value = r.localDir
|
||||
picks.value = {}
|
||||
conflictChoice.value = {}
|
||||
progress.value = {}
|
||||
const s = r.stats
|
||||
const del = (s.deleteLocal || 0) + (s.deleteRemote || 0)
|
||||
ElMessage.info(
|
||||
`扫描完成: 待上传 ${s.upload} · 待下载 ${s.download} · 将删除 ${del} · 冲突 ${s.conflict} · 一致 ${s.same}`
|
||||
)
|
||||
}
|
||||
|
||||
async function syncSelected() {
|
||||
if (!props.peer || pickedCount.value === 0) return
|
||||
if (unresolvedConflicts.value.length > 0) {
|
||||
ElMessage.warning(`还有 ${unresolvedConflicts.value.length} 个冲突没选方向`)
|
||||
return
|
||||
}
|
||||
// 有删除动作时二次确认 (删除会真删文件)
|
||||
const dels = Object.values(picks.value).filter(a => a === 'delete-local' || a === 'delete-remote').length
|
||||
if (dels > 0) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`本次会删除 ${dels} 个文件 (删除会传播到对方, 对方那边也会删)。确定继续?`,
|
||||
'确认删除',
|
||||
{ type: 'warning', confirmButtonText: '确定删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch { return }
|
||||
}
|
||||
const itemsToSend = Object.entries(picks.value).map(([relPath, action]) => ({ relPath, action }))
|
||||
syncing.value = true
|
||||
progress.value = {}
|
||||
const r = await window.api.syncApply({
|
||||
peerId: props.peer.deviceId,
|
||||
relationId: relation.value?.relationId,
|
||||
localDir: relation.value?.syncLocalDir || localDir.value || undefined,
|
||||
items: itemsToSend,
|
||||
})
|
||||
syncing.value = false
|
||||
if (r.done > 0) ElMessage.success(`同步完成 ${r.done} 项`)
|
||||
if (r.failed > 0) {
|
||||
const firstErr = (r.results || []).find(x => !x.ok)
|
||||
ElMessage.error(`失败 ${r.failed} 个${firstErr?.reason ? ' (首个错误: ' + firstErr.reason + ')' : ''}`)
|
||||
}
|
||||
if (r.peerConflicts?.length) {
|
||||
ElMessage.warning(`对方那边有 ${r.peerConflicts.length} 个文件也改过, 需要双方一起处理`)
|
||||
}
|
||||
await scan()
|
||||
}
|
||||
|
||||
function onRowClick(row: SyncDiffItem) {
|
||||
if (row.action === 'conflict') openConflict(row)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
unsubs.push(window.api.on('sync:progress', (p: { relPath: string; sent: number; total: number }) => {
|
||||
progress.value = { ...progress.value, [p.relPath]: { sent: p.sent, total: p.total } }
|
||||
}))
|
||||
// 关系变化 (对方接受/拒绝/被吊销) -> 刷新面板
|
||||
unsubs.push(window.api.on('relation:changed', () => { loadRelations() }))
|
||||
unsubs.push(window.api.on('relation:rejected', (p: any) => {
|
||||
ElMessage.warning(p?.byMe ? '你拒绝了对方的请求' : '对方拒绝了你的请求')
|
||||
loadRelations()
|
||||
}))
|
||||
// 对方删了文件推过来 -> 本地已按规则处理
|
||||
unsubs.push(window.api.on('sync:applied', (p: any) => {
|
||||
const n = p?.applied?.length || 0
|
||||
if (n > 0) ElMessage.info(`对方删除了 ${n} 个文件, 已按规则同步处理`)
|
||||
}))
|
||||
await loadRelations()
|
||||
})
|
||||
|
||||
onUnmounted(() => { for (const u of unsubs) try { u() } catch {} })
|
||||
|
||||
watch(() => props.peer?.deviceId, async () => {
|
||||
result.value = null
|
||||
picks.value = {}
|
||||
conflictChoice.value = {}
|
||||
errorMsg.value = ''
|
||||
await loadRelations()
|
||||
})
|
||||
|
||||
watch(relation, (r) => {
|
||||
if (r) {
|
||||
myGrants.value = [...(r.myGrants || [])]
|
||||
localDir.value = r.syncLocalDir || ''
|
||||
autoSyncOn.value = !!r.syncEnabled
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-panel">
|
||||
<!-- ===== 关系区 ===== -->
|
||||
<section class="relation-box">
|
||||
<header class="rhead">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>同步关系</span>
|
||||
<el-tag v-if="relation" type="success" size="small" disable-transitions>已建立</el-tag>
|
||||
<el-tag v-else-if="pendingRelation" type="warning" size="small" disable-transitions>等待对方确认</el-tag>
|
||||
<el-tag v-else type="info" size="small" disable-transitions>未建立</el-tag>
|
||||
</header>
|
||||
|
||||
<!-- 已建立: 配置 -->
|
||||
<div v-if="relation" class="rbody">
|
||||
<div class="line">
|
||||
<span class="k">我的同步目录</span>
|
||||
<span class="v mono">{{ relation.syncLocalDir || '(未设置)' }}</span>
|
||||
<el-button size="small" link @click="changeLocalDir"><el-icon><Folder /></el-icon>更换</el-button>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="k">实际同步位置</span>
|
||||
<span class="v mono dim">{{ relation.syncLocalDir }}/{{ relation.syncSubdir }}</span>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="k">同步范围</span>
|
||||
<span class="v dim">对方为这个关系开放的目录 (只读, 对方决定)</span>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="k">对方开放给我</span>
|
||||
<span class="v">
|
||||
<el-tag v-if="relation.peerGrants.includes('drive')" size="small" type="info" disable-transitions>浏览网盘</el-tag>
|
||||
<el-tag v-if="relation.peerGrants.includes('sync')" size="small" type="info" disable-transitions>同步文件</el-tag>
|
||||
<span v-if="!relation.peerGrants.length" class="none">无</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="k">我开放给对方</span>
|
||||
<span class="v grants">
|
||||
<el-checkbox-group v-model="myGrants" size="small">
|
||||
<el-checkbox v-for="g in ALL_GRANTS" :key="g" :label="g" :value="g">{{ RELATION_GRANT_LABEL[g] }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-button size="small" link type="primary" @click="saveGrants">保存</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="k">自动同步</span>
|
||||
<span class="v">
|
||||
<el-switch v-model="autoSyncOn" @change="toggleAutoSync" />
|
||||
<span class="dim">每分钟检查一次; 两边都改过的文件会跳过, 不会自动覆盖</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="k">上次同步</span>
|
||||
<span class="v dim">
|
||||
{{ relation.lastSyncAt ? fmtTime(relation.lastSyncAt) : '还没同步过' }}
|
||||
<el-tag v-if="relation.lastError" type="danger" size="small" disable-transitions>{{ relation.lastError }}</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="line">
|
||||
<el-button size="small" type="danger" plain @click="revokeRelation">
|
||||
<el-icon><Lock /></el-icon>吊销关系
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等待确认 -->
|
||||
<div v-else-if="pendingRelation" class="rbody">
|
||||
<p class="hint">已向对方发出请求, 等对方在弹窗里确认。</p>
|
||||
<div class="line">
|
||||
<span class="k">我的同步目录</span>
|
||||
<span class="v mono">{{ pendingRelation.syncLocalDir }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未建立: 发起 -->
|
||||
<div v-else class="rbody">
|
||||
<p class="hint">
|
||||
同步需要先建立关系 —— 点下面的按钮, <b>对方会直接弹窗</b>确认, 不用邀请码。
|
||||
</p>
|
||||
<div class="line">
|
||||
<span class="k">本机同步目录</span>
|
||||
<el-input v-model="newLocalDir" placeholder="选择要同步的本机文件夹" size="small" style="flex:1">
|
||||
<template #append>
|
||||
<el-button @click="pickNewLocalDir">
|
||||
<el-icon><Folder /></el-icon>选择
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="k">我开放给对方</span>
|
||||
<span class="v grants">
|
||||
<el-checkbox-group v-model="newGrants" size="small">
|
||||
<el-checkbox v-for="g in ALL_GRANTS" :key="g" :label="g" :value="g">{{ RELATION_GRANT_LABEL[g] }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</span>
|
||||
</div>
|
||||
<div class="line">
|
||||
<el-button type="primary" size="small" :loading="sending" @click="requestRelation">
|
||||
<el-icon><Connection /></el-icon>发起同步关系
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 同步区 ===== -->
|
||||
<section class="sync-box">
|
||||
<div class="row actions">
|
||||
<el-button type="primary" :loading="scanning" :disabled="!props.peer" @click="scan">
|
||||
<el-icon><Refresh /></el-icon>扫描变更
|
||||
</el-button>
|
||||
<el-button type="success" :loading="syncing" :disabled="!canSync" @click="syncSelected">
|
||||
<el-icon><Upload /></el-icon>同步所选 ({{ pickedCount }})
|
||||
</el-button>
|
||||
<el-button link @click="pickAll(true)">全选可同步项</el-button>
|
||||
<el-button link @click="pickAll(false)">清空</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="empty-tip warn">
|
||||
<p>{{ errorMsg }}</p>
|
||||
<small v-if="!relation">对方需要升级到带同步盘的版本, 并和你建立关系</small>
|
||||
</div>
|
||||
<div v-else-if="!result" class="empty-tip">
|
||||
<p>点"扫描变更"看看两边有什么不同</p>
|
||||
<small>扫描只读取双方文件信息, 不会改动任何数据</small>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="stats-bar">
|
||||
<span class="stat upload">待上传 <b>{{ stats?.upload ?? 0 }}</b></span>
|
||||
<span class="stat download">待下载 <b>{{ stats?.download ?? 0 }}</b></span>
|
||||
<span class="stat del">将删除 <b>{{ (stats?.deleteLocal ?? 0) + (stats?.deleteRemote ?? 0) }}</b></span>
|
||||
<span class="stat conflict">冲突 <b>{{ stats?.conflict ?? 0 }}</b></span>
|
||||
<span class="stat same">一致 <b>{{ stats?.same ?? 0 }}</b></span>
|
||||
<span class="spacer" />
|
||||
<el-button v-if="unresolvedConflicts.length" type="danger" link @click="openConflict(unresolvedConflicts[0])">
|
||||
<el-icon><Warning /></el-icon>{{ unresolvedConflicts.length }} 个冲突待处理
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="changes" size="small" highlight-current-row @row-click="onRowClick" class="sync-table">
|
||||
<el-table-column width="40" align="center">
|
||||
<template #header>
|
||||
<el-checkbox :model-value="pickedCount > 0" @change="pickAll(!!$event)" />
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<el-checkbox
|
||||
:model-value="isPicked(row.relPath)"
|
||||
:disabled="row.action === 'same'"
|
||||
@change="togglePick(row, !!$event)"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="relPath" label="文件 (相对路径)" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row).type" size="small" disable-transitions>{{ statusTag(row).text }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本机" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.local" class="side">
|
||||
<span class="sz">{{ fmtBytes(row.local.size) }}</span>
|
||||
<span class="tm">{{ fmtTime(row.local.mtime) }}</span>
|
||||
</div>
|
||||
<span v-else class="side none">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对方" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.remote" class="side">
|
||||
<span class="sz">{{ fmtBytes(row.remote.size) }}</span>
|
||||
<span class="tm">{{ fmtTime(row.remote.mtime) }}</span>
|
||||
</div>
|
||||
<span v-else class="side none">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="note">{{ row.note }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.action === 'conflict'" size="small" type="danger" link @click.stop="openConflict(row)">对比</el-button>
|
||||
<el-progress
|
||||
v-else-if="progress[row.relPath]"
|
||||
:percentage="progress[row.relPath].total ? Math.round(progress[row.relPath].sent / progress[row.relPath].total * 100) : 0"
|
||||
:stroke-width="6"
|
||||
style="width: 70px"
|
||||
/>
|
||||
<span v-else-if="row.action === 'upload'" class="dir-hint"><el-icon><Upload /></el-icon></span>
|
||||
<span v-else-if="row.action === 'download'" class="dir-hint"><el-icon><Download /></el-icon></span>
|
||||
<span v-else-if="row.action === 'delete-local' || row.action === 'delete-remote'" class="dir-hint del">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p class="tip">
|
||||
删除会传播: 你这边删掉的, 对方那份也会被删 (反过来也一样)。两边都改过的文件会标成冲突, 让你选方向, 不会静默覆盖。
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- 冲突对比弹窗 -->
|
||||
<el-dialog v-model="conflictVisible" title="文件冲突 — 两边都改过" width="620px">
|
||||
<div v-if="conflictItem" class="conflict-box">
|
||||
<div class="cpath">{{ conflictItem.relPath }}</div>
|
||||
<div class="cmp">
|
||||
<div class="col">
|
||||
<div class="chead">本机</div>
|
||||
<div class="row"><span>大小</span><b>{{ fmtBytes(conflictItem.local?.size) }}</b></div>
|
||||
<div class="row"><span>修改时间</span><b>{{ fmtTime(conflictItem.local?.mtime) }}</b></div>
|
||||
<div class="row"><span>SHA256</span><b class="mono">{{ shortHash(conflictItem.local?.hash) }}</b></div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="chead">对方</div>
|
||||
<div class="row"><span>大小</span><b>{{ fmtBytes(conflictItem.remote?.size) }}</b></div>
|
||||
<div class="row"><span>修改时间</span><b>{{ fmtTime(conflictItem.remote?.mtime) }}</b></div>
|
||||
<div class="row"><span>SHA256</span><b class="mono">{{ shortHash(conflictItem.remote?.hash) }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cwarn">两边都改过, 没法自动判断谁该覆盖谁 —— 请手动选一个方向, 另一份会保留。</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="resolveConflict('skip')">跳过这个文件</el-button>
|
||||
<el-button type="warning" @click="resolveConflict('download')">
|
||||
<el-icon><Download /></el-icon>用对方覆盖本机
|
||||
</el-button>
|
||||
<el-button type="primary" @click="resolveConflict('upload')">
|
||||
<el-icon><Upload /></el-icon>用本机覆盖对方
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-panel { display: flex; flex-direction: column; height: 100%; gap: 10px; padding: 10px 12px; box-sizing: border-box; overflow: hidden; }
|
||||
|
||||
.relation-box { border: 1px solid var(--el-border-color-lighter); border-radius: 8px; padding: 10px 12px; flex-shrink: 0; }
|
||||
.rhead { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
||||
.rbody { display: flex; flex-direction: column; gap: 6px; }
|
||||
.line { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.line .k { width: 92px; color: var(--el-text-color-secondary); flex-shrink: 0; }
|
||||
.line .v { flex: 1; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.line .v.mono { font-family: ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
|
||||
.line .v.none { color: var(--el-text-color-placeholder); }
|
||||
.line .v.dim { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
.grants { gap: 10px; }
|
||||
.hint { margin: 0 0 6px; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.6; }
|
||||
|
||||
.sync-box { display: flex; flex-direction: column; gap: 8px; flex: 1; min-height: 0; }
|
||||
.row { display: flex; align-items: center; gap: 8px; }
|
||||
.row.actions { gap: 10px; }
|
||||
.stats-bar { display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.stats-bar .stat b { color: var(--el-text-color-primary); }
|
||||
.stats-bar .spacer { flex: 1; }
|
||||
.stat.upload b { color: var(--el-color-warning); }
|
||||
.stat.download b { color: var(--el-color-primary); }
|
||||
.stat.del b { color: var(--el-color-danger); }
|
||||
.stat.conflict b { color: var(--el-color-danger); }
|
||||
.sync-table { flex: 1; min-height: 0; }
|
||||
.side { display: flex; flex-direction: column; line-height: 1.35; }
|
||||
.side .sz { font-size: 12px; }
|
||||
.side .tm { font-size: 11px; color: var(--el-text-color-secondary); }
|
||||
.side.none { color: var(--el-text-color-placeholder); }
|
||||
.note { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.dir-hint { color: var(--el-text-color-placeholder); }
|
||||
.dir-hint.del { color: var(--el-color-danger); }
|
||||
.tip { margin: 0; font-size: 11px; color: var(--el-text-color-placeholder); line-height: 1.5; }
|
||||
.empty-tip { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--el-text-color-secondary); }
|
||||
.empty-tip p { margin: 0; }
|
||||
.empty-tip small { color: var(--el-text-color-placeholder); }
|
||||
.empty-tip.warn p { color: var(--el-color-danger); }
|
||||
.conflict-box { display: flex; flex-direction: column; gap: 12px; }
|
||||
.cpath { font-size: 13px; font-weight: 600; word-break: break-all; }
|
||||
.cmp { display: flex; gap: 12px; }
|
||||
.cmp .col { flex: 1; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; padding: 10px 12px; }
|
||||
.cmp .chead { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 8px; }
|
||||
.cmp .row { display: flex; justify-content: space-between; font-size: 12px; line-height: 2; }
|
||||
.cmp .row span { color: var(--el-text-color-secondary); }
|
||||
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; }
|
||||
.cwarn { margin: 0; font-size: 12px; color: var(--el-color-warning); line-height: 1.6; }
|
||||
</style>
|
||||
@@ -1,593 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick, h } from 'vue'
|
||||
import type { DeviceView, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbDirection, UsbAttachConfig, UsbEndpointInfo } from '@/api'
|
||||
import { useUsbStore } from '@/stores/remote'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const store = useUsbStore()
|
||||
|
||||
// 串口看不到设备时的提示要按平台给, Windows/macOS 给 Linux 的建议是误导
|
||||
const isLinux = (window.api?.platform || '') === 'linux'
|
||||
const serialEmptyHint = computed(() => {
|
||||
if (isLinux) return 'Linux 用户如未看到 /dev/ttyUSB*, 需要在 dialout 组.'
|
||||
if ((window.api?.platform || '') === 'win32') return 'Windows: 检查设备管理器里的 COM 口; 若用 USB 透传, 需用 Zadig 装 WinUSB 驱动.'
|
||||
return 'macOS: 首次访问串口时系统会弹权限对话框, 需在 系统设置 → 隐私与安全 中允许.'
|
||||
})
|
||||
|
||||
type Mode = 'serial' | 'usb' | 'usbip'
|
||||
const mode = ref<Mode>('serial')
|
||||
const direction = ref<UsbDirection>('self-out')
|
||||
const scanning = ref(false)
|
||||
const peerDevices = ref<UsbDeviceInfo[]>([])
|
||||
const myDevices = ref<UsbDeviceInfo[]>([])
|
||||
const selectedBus = ref<string>('')
|
||||
const baudRate = ref(115200)
|
||||
const creating = ref(false)
|
||||
|
||||
// USB 模式专属
|
||||
const usbIfaceNum = ref(0)
|
||||
const detachKern = ref(true)
|
||||
|
||||
const hexEl = ref<HTMLDivElement | null>(null)
|
||||
const hexBuf = ref<{ ascii: string; hex: string; ts: number }[]>([])
|
||||
const HEX_LIMIT = 200
|
||||
|
||||
// USB 操作历史
|
||||
const usbLog = ref<{ ts: number; tag: string; ok: boolean; text: string; hex?: string }[]>([])
|
||||
const MAX_USB_LOG = 80
|
||||
|
||||
// 当前 active 客户端 session (只展示一个)
|
||||
const myClientSession = computed<UsbSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'client')
|
||||
)
|
||||
|
||||
// 当前对端发起的 session (server side, 展示我方被控情况)
|
||||
const serverSession = computed<UsbSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'server')
|
||||
)
|
||||
|
||||
// 不同 mode 的设备过滤
|
||||
const visibleDevices = computed<UsbDeviceInfo[]>(() => {
|
||||
const all = direction.value === 'self-out' ? peerDevices.value : myDevices.value
|
||||
return all.filter(d => d.kind === mode.value)
|
||||
})
|
||||
|
||||
const serverSerialBytes = ref<{ direction: 'in' | 'out'; data: string; ts: number }[]>([])
|
||||
|
||||
async function scan() {
|
||||
if (!props.peer) return
|
||||
scanning.value = true
|
||||
if (direction.value === 'self-out') {
|
||||
const r = await store.list(props.peer.deviceId)
|
||||
scanning.value = false
|
||||
if (!r.ok) { ElMessage.error('列出对端设备失败: ' + (r.reason || '')); peerDevices.value = []; return }
|
||||
peerDevices.value = r.devices || []
|
||||
} else {
|
||||
const r = await store.listLocal()
|
||||
scanning.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('列出本机设备失败: ' + (r.reason || ''))
|
||||
myDevices.value = []
|
||||
return
|
||||
}
|
||||
myDevices.value = r.devices || []
|
||||
}
|
||||
// 选第一个匹配的 mode
|
||||
const first = visibleDevices.value[0]
|
||||
if (first && !visibleDevices.value.find(d => d.busId === selectedBus.value)) {
|
||||
selectedBus.value = first.busId
|
||||
}
|
||||
}
|
||||
|
||||
async function attach() {
|
||||
if (!props.peer || !selectedBus.value) return
|
||||
if (myClientSession.value) {
|
||||
ElMessage.warning('已有活跃 session, 请先关闭')
|
||||
return
|
||||
}
|
||||
|
||||
let finalBaudRate = baudRate.value
|
||||
|
||||
// 串口模式: 弹窗选波特率
|
||||
if (isSerial.value) {
|
||||
const BAUD_OPTIONS = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]
|
||||
const selected = ref(BAUD_OPTIONS.indexOf(baudRate.value) >= 0 ? baudRate.value : 115200)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
h('div', [
|
||||
h('p', { style: 'margin-bottom:10px' }, '选择本机虚拟串口的波特率'),
|
||||
h('el-select', {
|
||||
modelValue: selected.value,
|
||||
'onUpdate:modelValue': (v: number) => { selected.value = v },
|
||||
style: 'width:160px',
|
||||
}, BAUD_OPTIONS.map(b => h('el-option', { key: b, label: String(b), value: b }))),
|
||||
]),
|
||||
'创建虚拟串口',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
customClass: 'baud-box',
|
||||
}
|
||||
)
|
||||
finalBaudRate = selected.value
|
||||
} catch {
|
||||
return // 取消
|
||||
}
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const config = buildConfig(finalBaudRate)
|
||||
if (!config) { creating.value = false; return }
|
||||
const r = await store.attach(props.peer.deviceId, {
|
||||
direction: direction.value,
|
||||
busId: selectedBus.value,
|
||||
config,
|
||||
})
|
||||
creating.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('附加失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
ElMessage.success('已附加')
|
||||
await nextTick()
|
||||
hexEl.value?.scrollTo({ top: 0 })
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'attach', ok: true, text: `${mode.value} attach OK` })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
function buildConfig(br: number): UsbAttachConfig | null {
|
||||
if (mode.value === 'serial') {
|
||||
return {
|
||||
kind: 'serial',
|
||||
baudRate: br,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
createVirtual: true,
|
||||
}
|
||||
}
|
||||
if (mode.value === 'usb') {
|
||||
return { kind: 'usb', interfaceNumber: usbIfaceNum.value, detachKernelDriver: detachKern.value }
|
||||
}
|
||||
if (mode.value === 'usbip') {
|
||||
return { kind: 'usbip' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function detach(s: UsbSessionInfo) {
|
||||
await store.detach(s.sessionId)
|
||||
ElMessage.success('已关闭')
|
||||
}
|
||||
|
||||
async function sendText() {
|
||||
if (!myClientSession.value || myClientSession.value.kind !== 'serial') return
|
||||
const sid = myClientSession.value.sessionId
|
||||
const text = prompt('输入要发送的文本 (UTF-8):', 'AT\r\n')
|
||||
if (text === null) return
|
||||
const buf = new TextEncoder().encode(text)
|
||||
let bin = ''
|
||||
for (const b of buf) bin += String.fromCharCode(b)
|
||||
await store.serialSend(sid, btoa(bin))
|
||||
ElMessage.success(`已发送 ${buf.length} 字节`)
|
||||
}
|
||||
|
||||
// ===== USB 字节桥面板 =====
|
||||
|
||||
const reqType = ref<'standard' | 'class' | 'vendor' | 'reserved'>('standard')
|
||||
const recipient = ref<'device' | 'interface' | 'endpoint' | 'other'>('device')
|
||||
const directionBit = ref<'host-to-device' | 'device-to-host'>('device-to-host')
|
||||
const bRequest = ref(0)
|
||||
const wValue = ref(0)
|
||||
const wIndex = ref(0)
|
||||
const ctrlData = ref('') // hex 文本
|
||||
const ctrlLength = ref(64)
|
||||
|
||||
const bulkEndpoint = ref(0x81)
|
||||
const bulkData = ref('') // hex 文本
|
||||
const bulkLength = ref(64)
|
||||
const bulkTimeout = ref(5000)
|
||||
const bulkDirection = ref<'in' | 'out'>('in')
|
||||
|
||||
function hexToBase64(hex: string): string {
|
||||
const clean = hex.replace(/[^0-9a-fA-F]/g, '')
|
||||
const padded = clean.length % 2 ? '0' + clean : clean
|
||||
let bin = ''
|
||||
for (let i = 0; i < padded.length; i += 2) {
|
||||
bin += String.fromCharCode(parseInt(padded.substr(i, 2), 16))
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
function base64ToHex(b64: string): string {
|
||||
if (!b64) return ''
|
||||
try {
|
||||
const bin = atob(b64)
|
||||
let hex = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
hex += bin.charCodeAt(i).toString(16).padStart(2, '0') + ' '
|
||||
}
|
||||
return hex.trim()
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function buildRequestType(): number {
|
||||
// bmRequestType: bit7 = direction, bits 6-5 = type, bits 4-0 = recipient
|
||||
let rt = 0
|
||||
if (directionBit.value === 'host-to-device') rt |= 0x00
|
||||
else rt |= 0x80
|
||||
if (reqType.value === 'standard') rt |= 0x00
|
||||
else if (reqType.value === 'class') rt |= 0x20
|
||||
else if (reqType.value === 'vendor') rt |= 0x40
|
||||
else if (reqType.value === 'reserved') rt |= 0x60
|
||||
if (recipient.value === 'device') rt |= 0x00
|
||||
else if (recipient.value === 'interface') rt |= 0x01
|
||||
else if (recipient.value === 'endpoint') rt |= 0x02
|
||||
else if (recipient.value === 'other') rt |= 0x03
|
||||
return rt
|
||||
}
|
||||
|
||||
async function doCtrlIn() {
|
||||
if (!myClientSession.value) return
|
||||
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} len=${ctrlLength.value}` })
|
||||
const r = await store.ctrlIn(myClientSession.value.sessionId, setup, ctrlLength.value)
|
||||
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
if (hex) {
|
||||
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
async function doCtrlOut() {
|
||||
if (!myClientSession.value) return
|
||||
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||
const dataB64 = ctrlData.value.trim() ? hexToBase64(ctrlData.value) : undefined
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} data=${dataB64 ? ctrlData.value : '<none>'}` })
|
||||
const r = await store.ctrlOut(myClientSession.value.sessionId, setup, dataB64)
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
async function doBulkTransfer() {
|
||||
if (!myClientSession.value) return
|
||||
if (bulkDirection.value === 'in') {
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} len=${bulkLength.value}` })
|
||||
const r = await store.bulkIn(myClientSession.value.sessionId, bulkEndpoint.value, bulkLength.value, bulkTimeout.value)
|
||||
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||
if (hex) {
|
||||
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
} else {
|
||||
const b64 = bulkData.value.trim() ? hexToBase64(bulkData.value) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} data=${bulkData.value}` })
|
||||
const r = await store.bulkOut(myClientSession.value.sessionId, bulkEndpoint.value, b64)
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||
}
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
// ===== 串口 hex 显示 =====
|
||||
function pushHex(base64: string) {
|
||||
if (!base64) return
|
||||
const bin = atob(base64)
|
||||
let hex = ''
|
||||
let ascii = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
const c = bin.charCodeAt(i)
|
||||
hex += c.toString(16).padStart(2, '0') + ' '
|
||||
ascii += c >= 0x20 && c < 0x7f ? bin[i] : '.'
|
||||
if ((i + 1) % 16 === 0) {
|
||||
hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||
hex = ''; ascii = ''
|
||||
}
|
||||
}
|
||||
if (hex) hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
|
||||
watch(() => store.buffers, (b) => {
|
||||
if (!myClientSession.value) return
|
||||
const sid = myClientSession.value.sessionId
|
||||
const buf = b[sid]
|
||||
if (!buf || buf.length === 0) return
|
||||
const all = buf.join('')
|
||||
b[sid] = []
|
||||
pushHex(all)
|
||||
}, { deep: true })
|
||||
|
||||
function clearHex() { hexBuf.value = [] }
|
||||
function clearUsbLog() { usbLog.value = [] }
|
||||
|
||||
async function refresh() { await store.refresh() }
|
||||
let timer = 0
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
// 轮询保持会话列表和流量计数新鲜
|
||||
timer = window.setInterval(() => { void refresh() }, 3000)
|
||||
})
|
||||
onUnmounted(() => { if (timer) window.clearInterval(timer) })
|
||||
|
||||
function directionLabel(d: UsbDirection) {
|
||||
return d === 'self-out' ? '我用对方的' : '对方用我的'
|
||||
}
|
||||
function modeLabel(m: Mode) {
|
||||
return m === 'serial' ? '串口' : m === 'usb' ? 'USB 字节桥' : 'USB/IP (Linux)'
|
||||
}
|
||||
|
||||
const isSerial = computed(() => mode.value === 'serial')
|
||||
const isUsb = computed(() => mode.value === 'usb')
|
||||
const isUsbip = computed(() => mode.value === 'usbip')
|
||||
|
||||
const endpoints = computed<UsbEndpointInfo[]>(() => myClientSession.value?.info?.endpoints || [])
|
||||
const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?.userVirtualPath)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="usb-panel">
|
||||
<!-- 方向 + 模式 -->
|
||||
<div class="dir-row">
|
||||
<span class="dir-label">方向</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">我用对方的</button>
|
||||
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">对方用我的</button>
|
||||
</div>
|
||||
<span class="dir-label" style="margin-left: 8px">类型</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: mode === 'serial' }" @click="mode = 'serial'">串口</button>
|
||||
<button :class="{ active: mode === 'usb' }" @click="mode = 'usb'">USB</button>
|
||||
<button :class="{ active: mode === 'usbip' }" @click="mode = 'usbip'">USB/IP</button>
|
||||
</div>
|
||||
<el-button size="small" :loading="scanning" @click="scan" :disabled="!peer">
|
||||
{{ direction === 'self-out' ? '拉取对端' : '刷新本机' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isUsbip" class="hint">
|
||||
USB/IP 是 Linux 内核自带的真透明 USB 透传 (设备出现在本机 lsusb).
|
||||
macOS/Windows 默认不支持. Linux 用户需手动装 <code>usbip</code> 包 + 加载 <code>vhci_hcd</code> 模块.
|
||||
设备端需先 <code>usbip bind -b <busid></code>.
|
||||
</div>
|
||||
|
||||
<div v-if="!visibleDevices.length" class="empty-tip">
|
||||
<p v-if="!peer">请先在 sidebar 选择一台设备</p>
|
||||
<p v-else>暂无可用的 {{ modeLabel(mode) }} 设备</p>
|
||||
<small v-if="direction === 'self-in' && mode === 'serial'">{{ serialEmptyHint }}</small>
|
||||
</div>
|
||||
|
||||
<el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId">
|
||||
<el-table-column prop="busId" label="设备" width="220" />
|
||||
<el-table-column label="VID:PID" width="120">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.vid.toString(16).padStart(4,'0') }}:{{ row.pid.toString(16).padStart(4,'0') }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product" label="产品" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :disabled="!!myClientSession" @click.stop="() => { selectedBus = row.busId; baudRate = row.baudRate || 9600; attach() }">
|
||||
附加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="isSerial && visibleDevices.length" class="baud-row">
|
||||
<span>默认波特率</span>
|
||||
<el-select v-model.number="baudRate" size="small" style="width: 120px">
|
||||
<el-option label="9600" :value="9600" />
|
||||
<el-option label="19200" :value="19200" />
|
||||
<el-option label="38400" :value="38400" />
|
||||
<el-option label="57600" :value="57600" />
|
||||
<el-option label="74880" :value="74880" />
|
||||
<el-option label="115200" :value="115200" />
|
||||
<el-option label="230400" :value="230400" />
|
||||
<el-option label="460800" :value="460800" />
|
||||
<el-option label="921600" :value="921600" />
|
||||
</el-select>
|
||||
<small style="color:var(--el-text-color-secondary)">点击附加后弹窗确认</small>
|
||||
</div>
|
||||
|
||||
<div v-if="isUsb && visibleDevices.length" class="baud-row">
|
||||
<span>Interface #</span>
|
||||
<el-input-number v-model="usbIfaceNum" :min="0" :max="15" size="small" controls-position="right" style="width: 90px" />
|
||||
<el-checkbox v-model="detachKern" style="margin-left: 8px">Linux 自动 detach 内核驱动</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 已有 session: 显示状态 + 操作 -->
|
||||
<div v-if="myClientSession" class="attach-box">
|
||||
<div class="attach-row">
|
||||
<span>已附加: <code>{{ myClientSession.busId }}</code> · {{ directionLabel(myClientSession.direction) }} · <code>{{ modeLabel(myClientSession.kind) }}</code></span>
|
||||
<span>流量: in {{ myClientSession.bytesIn }}B / out {{ myClientSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button v-if="isSerial && !vcomPath" size="small" @click="sendText">发送文本</el-button>
|
||||
<el-button v-if="isSerial && !vcomPath" size="small" @click="clearHex">清屏</el-button>
|
||||
<el-button v-if="isUsb" size="small" @click="clearUsbLog">清日志</el-button>
|
||||
<el-button size="small" type="danger" @click="detach(myClientSession)">关闭</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟串口: 醒目的路径展示 -->
|
||||
<div v-if="isSerial && vcomPath" class="vcom-banner">
|
||||
<div class="vcom-banner-row">
|
||||
<span class="vcom-banner-label">本机虚拟串口 (PuTTY / Arduino IDE / screen 直接打开):</span>
|
||||
</div>
|
||||
<div class="vcom-banner-row">
|
||||
<code class="vcom-banner-path">{{ vcomPath }}</code>
|
||||
<small class="vcom-hint">波特率 {{ baudRate }}, 8N1</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 串口: hex 流 -->
|
||||
<div v-if="isSerial" ref="hexEl" class="hex-view">
|
||||
<div v-if="!hexBuf.length" class="hex-empty">等待串口数据…</div>
|
||||
<div v-for="(line, i) in hexBuf" :key="i" class="hex-line">
|
||||
<span class="hex-bytes">{{ line.hex }}</span>
|
||||
<span class="hex-ascii">{{ line.ascii }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB 字节桥: endpoint 列表 + 控制/批量表单 -->
|
||||
<div v-if="isUsb" class="usb-bridge">
|
||||
<div v-if="endpoints.length" class="ep-list">
|
||||
<strong>Endpoints:</strong>
|
||||
<code v-for="ep in endpoints" :key="ep.endpointNumber" class="ep">
|
||||
EP{{ ep.endpointNumber.toString(16).toUpperCase() }} ({{ ep.direction === 'in' ? 'IN' : 'OUT' }}, {{ ep.transferType }}, {{ ep.packetSize }}B)
|
||||
</code>
|
||||
</div>
|
||||
<div v-else class="ep-list"><em>无 endpoint (可能需要先选择 interface)</em></div>
|
||||
|
||||
<!-- 控制传输 -->
|
||||
<div class="usb-section">
|
||||
<h4>控制传输 (Control Transfer)</h4>
|
||||
<div class="ctrl-row">
|
||||
<el-select v-model="directionBit" size="small" style="width: 130px">
|
||||
<el-option label="device-to-host (IN)" value="device-to-host" />
|
||||
<el-option label="host-to-device (OUT)" value="host-to-device" />
|
||||
</el-select>
|
||||
<el-select v-model="reqType" size="small" style="width: 90px">
|
||||
<el-option label="standard" value="standard" />
|
||||
<el-option label="class" value="class" />
|
||||
<el-option label="vendor" value="vendor" />
|
||||
</el-select>
|
||||
<el-select v-model="recipient" size="small" style="width: 90px">
|
||||
<el-option label="device" value="device" />
|
||||
<el-option label="interface" value="interface" />
|
||||
<el-option label="endpoint" value="endpoint" />
|
||||
<el-option label="other" value="other" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<span>request</span>
|
||||
<el-input-number v-model="bRequest" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||
<span>wValue</span>
|
||||
<el-input-number v-model="wValue" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||
<span>wIndex</span>
|
||||
<el-input-number v-model="wIndex" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||
</div>
|
||||
<div v-if="directionBit === 'host-to-device'" class="ctrl-row">
|
||||
<span>data (hex)</span>
|
||||
<el-input v-model="ctrlData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||
</div>
|
||||
<div v-else class="ctrl-row">
|
||||
<span>length</span>
|
||||
<el-input-number v-model="ctrlLength" :min="1" :max="4096" size="small" controls-position="right" style="width: 100px" />
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<el-button size="small" type="primary" @click="directionBit === 'host-to-device' ? doCtrlOut() : doCtrlIn()">
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量传输 -->
|
||||
<div class="usb-section">
|
||||
<h4>批量 / 中断 传输 (Bulk / Interrupt)</h4>
|
||||
<div class="ctrl-row">
|
||||
<el-radio-group v-model="bulkDirection" size="small">
|
||||
<el-radio-button label="in" value="in">IN (读)</el-radio-button>
|
||||
<el-radio-button label="out" value="out">OUT (写)</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span>EP</span>
|
||||
<el-input-number v-model="bulkEndpoint" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||
<span v-if="bulkDirection === 'in'">length</span>
|
||||
<el-input-number v-if="bulkDirection === 'in'" v-model="bulkLength" :min="1" :max="65536" size="small" controls-position="right" style="width: 100px" />
|
||||
<span>timeout(ms)</span>
|
||||
<el-input-number v-model="bulkTimeout" :min="100" :max="60000" :step="500" size="small" controls-position="right" style="width: 120px" />
|
||||
</div>
|
||||
<div v-if="bulkDirection === 'out'" class="ctrl-row">
|
||||
<span>data (hex)</span>
|
||||
<el-input v-model="bulkData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<el-button size="small" type="primary" @click="doBulkTransfer">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="usb-log">
|
||||
<h4>调用日志</h4>
|
||||
<div class="log-list">
|
||||
<div v-for="(l, i) in usbLog" :key="i" class="log-line" :class="{ ok: l.ok, fail: !l.ok }">
|
||||
<span class="log-ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||
<span class="log-tag">{{ l.tag }}</span>
|
||||
<span class="log-text">{{ l.text }}</span>
|
||||
<pre v-if="l.hex" class="log-hex">{{ l.hex }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对端发起的 session -->
|
||||
<div v-if="serverSession" class="server-box">
|
||||
<strong>对方正在使用我的设备:</strong>
|
||||
<code>{{ serverSession.busId }}</code> · {{ directionLabel('self-in') }} · {{ modeLabel(serverSession.kind) }}
|
||||
<span style="margin-left: 8px">流量: in {{ serverSession.bytesIn }}B / out {{ serverSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" type="danger" @click="detach(serverSession)">停止</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usb-panel { padding: 16px; overflow-y: auto; height: 100%; }
|
||||
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.dir-toggle { display: flex; background: var(--el-fill-color-light); border-radius: 6px; padding: 2px; }
|
||||
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
|
||||
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
|
||||
.hint { background: var(--el-fill-color-light); padding: 8px 12px; border-radius: 6px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 12px; }
|
||||
.hint code { background: #1e1e1e10; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.empty-tip { padding: 24px; text-align: center; color: var(--el-text-color-secondary); }
|
||||
.empty-tip small { display: block; margin-top: 8px; font-size: 11px; }
|
||||
.baud-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 12px; color: var(--el-text-color-secondary); flex-wrap: wrap; }
|
||||
.vcom-row { margin: 8px 0; padding: 8px 12px; background: var(--el-color-primary-light-9); border: 1px solid var(--el-color-primary-light-5); border-radius: 6px; }
|
||||
.vcom-row .vcom-options { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.vcom-banner { background: var(--el-color-success-light-9); border-top: 1px solid var(--el-color-success-light-5); padding: 12px 16px; }
|
||||
.vcom-banner-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
.vcom-banner-row:last-child { margin-bottom: 0; }
|
||||
.vcom-banner-label { font-size: 12px; color: var(--el-text-color-secondary); font-weight: 500; }
|
||||
.vcom-banner-path { font-family: ui-monospace, Consolas, monospace; font-size: 16px; font-weight: 600; color: var(--el-color-success); background: #fff; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--el-color-success-light-5); user-select: all; }
|
||||
.vcom-hint { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
.attach-box { margin-top: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
|
||||
.attach-row { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); font-size: 12px; flex-wrap: wrap; }
|
||||
.attach-row .spacer { flex: 1; }
|
||||
.server-box { margin-top: 12px; padding: 8px 12px; background: var(--el-color-warning-light-9); border: 1px solid var(--el-color-warning-light-5); border-radius: 6px; display: flex; align-items: center; font-size: 12px; }
|
||||
.server-box .spacer { flex: 1; }
|
||||
|
||||
.hex-view {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #0d0d0d;
|
||||
color: #d4d4d4;
|
||||
height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.hex-empty { color: #888; text-align: center; padding: 24px; }
|
||||
.hex-line { display: flex; gap: 12px; line-height: 1.4; }
|
||||
.hex-bytes { color: #79c0ff; min-width: 360px; word-break: break-all; }
|
||||
.hex-ascii { color: #ffa657; }
|
||||
|
||||
.usb-bridge { padding: 12px; background: var(--el-bg-color); }
|
||||
.ep-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: 12px; }
|
||||
.ep { background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
||||
.usb-section { border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; margin-top: 12px; }
|
||||
.usb-section h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||
.ctrl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; font-size: 12px; }
|
||||
.usb-log { margin-top: 16px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
|
||||
.usb-log h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||
.log-list { font-family: ui-monospace, Consolas, monospace; font-size: 11px; max-height: 200px; overflow-y: auto; background: #0d0d0d; padding: 6px; border-radius: 4px; }
|
||||
.log-line { display: flex; gap: 8px; padding: 2px 0; line-height: 1.4; }
|
||||
.log-line.fail { color: #f97583; }
|
||||
.log-line.ok { color: #85e89d; }
|
||||
.log-ts { color: #888; }
|
||||
.log-tag { color: #79c0ff; min-width: 70px; }
|
||||
.log-text { color: #d4d4d4; flex: 1; word-break: break-word; }
|
||||
.log-hex { margin: 2px 0 2px 80px; color: #79c0ff; white-space: pre-wrap; word-break: break-all; font-size: 11px; }
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TerminalSessionInfo, ForwardSessionInfo, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbAttachConfig, ApprovalRequestView, AuditEntry } from '@/api'
|
||||
import type { TerminalSessionInfo, ForwardSessionInfo, SerialDeviceInfo, SerialSessionInfo, SerialDirection, SerialAttachConfig, ApprovalRequestView, AuditEntry } from '@/api'
|
||||
|
||||
interface TerminalChannelState {
|
||||
buffer: string[] // 字节 base64 数组, UI 端自行解码
|
||||
@@ -144,29 +144,29 @@ export const useForwardStore = defineStore('forward', () => {
|
||||
return { client, server, sessions, refresh, open, close, onOpened, onClosed }
|
||||
})
|
||||
|
||||
export const useUsbStore = defineStore('usb', () => {
|
||||
const devicesByPeer = ref<Record<string, UsbDeviceInfo[]>>({})
|
||||
const myLocalDevices = ref<UsbDeviceInfo[]>([])
|
||||
const client = ref<UsbSessionInfo[]>([]) // 我发起的
|
||||
const server = ref<UsbSessionInfo[]>([]) // 对方发起的
|
||||
export const useSerialStore = defineStore('serial', () => {
|
||||
const devicesByPeer = ref<Record<string, SerialDeviceInfo[]>>({})
|
||||
const myLocalDevices = ref<SerialDeviceInfo[]>([])
|
||||
const client = ref<SerialSessionInfo[]>([]) // 我发起的
|
||||
const server = ref<SerialSessionInfo[]>([]) // 对方发起的
|
||||
const sessions = computed(() => [...client.value, ...server.value])
|
||||
// sessionId -> base64 字符串缓冲 (串口 hex 显示用)
|
||||
const buffers = ref<Record<string, string[]>>({})
|
||||
// sessionId -> base64 缓冲 (串口助手的收发显示用). dir 区分是设备发来的还是我发出去的
|
||||
const buffers = ref<Record<string, { data: string; dir: 'rx' | 'tx' }[]>>({})
|
||||
|
||||
async function list(peerId: string) {
|
||||
const r = await window.api.usbList(peerId)
|
||||
const r = await window.api.serialList(peerId)
|
||||
if (r.ok && r.devices) devicesByPeer.value[peerId] = r.devices
|
||||
return r
|
||||
}
|
||||
|
||||
async function listLocal() {
|
||||
const r = await window.api.usbListLocal()
|
||||
const r = await window.api.serialListLocal()
|
||||
if (r.ok && r.devices) myLocalDevices.value = r.devices
|
||||
return r
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const r = await window.api.usbListSessions()
|
||||
const r = await window.api.serialListSessions()
|
||||
if (Array.isArray(r)) {
|
||||
client.value = r
|
||||
server.value = []
|
||||
@@ -176,50 +176,32 @@ export const useUsbStore = defineStore('usb', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function attach(peerId: string, args: { busId: string; direction?: 'self-out' | 'self-in'; config: UsbAttachConfig }) {
|
||||
const r = await window.api.usbAttach(peerId, args.busId, { direction: args.direction, config: args.config })
|
||||
async function attach(peerId: string, args: { busId: string; direction?: SerialDirection; config: SerialAttachConfig }) {
|
||||
const r = await window.api.serialAttach(peerId, args.busId, { direction: args.direction, config: args.config })
|
||||
if (r.ok) await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
// 串口: 发送字节
|
||||
async function serialSend(sessionId: string, dataBase64: string) {
|
||||
return window.api.usbSerialSend(sessionId, dataBase64)
|
||||
}
|
||||
|
||||
// USB: 控制传输
|
||||
async function ctrlOut(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) {
|
||||
return window.api.usbCtrlOut(sessionId, setup, dataBase64)
|
||||
}
|
||||
async function ctrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) {
|
||||
const r = await window.api.usbCtrlIn(sessionId, setup, length)
|
||||
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
|
||||
}
|
||||
|
||||
// USB: 批量传输
|
||||
async function bulkOut(sessionId: string, endpoint: number, dataBase64: string) {
|
||||
return window.api.usbBulkOut(sessionId, endpoint, dataBase64)
|
||||
}
|
||||
async function bulkIn(sessionId: string, endpoint: number, length: number, timeoutMs?: number) {
|
||||
const r = await window.api.usbBulkIn(sessionId, endpoint, length, timeoutMs)
|
||||
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
|
||||
// 串口助手: 发送字节
|
||||
async function send(sessionId: string, dataBase64: string) {
|
||||
return window.api.serialSend(sessionId, dataBase64)
|
||||
}
|
||||
|
||||
async function detach(sessionId: string) {
|
||||
const r = await window.api.usbDetach(sessionId)
|
||||
const r = await window.api.serialDetach(sessionId)
|
||||
await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
function onOutput(p: { sessionId: string; peerId: string; data: string }) {
|
||||
function onOutput(p: { sessionId: string; peerId: string; dir: 'rx' | 'tx'; data: string }) {
|
||||
const buf = buffers.value[p.sessionId] || (buffers.value[p.sessionId] = [])
|
||||
buf.push(p.data)
|
||||
buf.push({ data: p.data, dir: p.dir || 'rx' })
|
||||
}
|
||||
|
||||
function takeBuffer(sessionId: string): string {
|
||||
function takeBuffer(sessionId: string): { data: string; dir: 'rx' | 'tx' }[] {
|
||||
const buf = buffers.value[sessionId]
|
||||
if (!buf) return ''
|
||||
const out = buf.join('')
|
||||
if (!buf) return []
|
||||
const out = [...buf]
|
||||
buffers.value[sessionId] = []
|
||||
return out
|
||||
}
|
||||
@@ -245,8 +227,7 @@ export const useUsbStore = defineStore('usb', () => {
|
||||
return {
|
||||
devicesByPeer, myLocalDevices, client, server, sessions, buffers,
|
||||
list, listLocal, refresh, attach,
|
||||
serialSend, ctrlOut, ctrlIn, bulkOut, bulkIn,
|
||||
detach, onOutput, takeBuffer, onOpened, onClosed,
|
||||
send, detach, onOutput, takeBuffer, onOpened, onClosed,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -261,8 +242,9 @@ export const useApprovalStore = defineStore('approval', () => {
|
||||
queue.value = [...queue.value, req]
|
||||
}
|
||||
|
||||
async function reply(requestId: string, ok: boolean, remember = false) {
|
||||
await window.api.remoteApprovalReply(requestId, ok, remember)
|
||||
// extra: relation 请求要带回"我开放给对方的能力"和我这边的同步目录
|
||||
async function reply(requestId: string, ok: boolean, remember = false, extra?: { grants?: string[]; syncLocalDir?: string; syncRemotePath?: string }) {
|
||||
await window.api.remoteApprovalReply(requestId, ok, remember, extra)
|
||||
queue.value = queue.value.filter(r => r.requestId !== requestId)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,3 +51,30 @@ export function formatSize(bytes: number): string {
|
||||
export function isImage(mime: string): boolean {
|
||||
return /^image\//i.test(mime)
|
||||
}
|
||||
|
||||
// 常见图片扩展名 -> mime (选中文件时只能拿到路径, 没有 mime)
|
||||
const IMAGE_MIME_BY_EXT: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
avif: 'image/avif',
|
||||
ico: 'image/x-icon',
|
||||
}
|
||||
|
||||
/** 按扩展名判断是不是图片 (决定消息类型: image 还是 file) */
|
||||
export function isImageFileName(name: string): boolean {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(name || '')
|
||||
if (!m) return false
|
||||
return !!IMAGE_MIME_BY_EXT[m[1].toLowerCase()]
|
||||
}
|
||||
|
||||
/** 按扩展名猜图片的 mime; 不是图片就返回空 */
|
||||
export function imageMimeOf(name: string): string {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(name || '')
|
||||
if (!m) return ''
|
||||
return IMAGE_MIME_BY_EXT[m[1].toLowerCase()] || ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// 收到一条消息时的未读处理策略 (纯函数, 不依赖 vue/electron, 所以能在无头自测里跑)
|
||||
//
|
||||
// 这个策略以前是散在 App.vue 里的 if, 出过一次很典型的 bug:
|
||||
// "用户正看着当前会话, 但消息没有 savedPath (文字消息就没有) -> 提前 return
|
||||
// -> 清未读的逻辑永远走不到 -> dock/任务栏角标的数字一直挂着"。
|
||||
// 抽成函数后, 顺序问题就变成了可以断言的行为。
|
||||
|
||||
export type UnreadAction =
|
||||
/** 用户正看着这个会话: 立刻清未读 + 回已读回执 (不看有没有 savedPath) */
|
||||
| 'clear'
|
||||
/** 文件类消息还没落地: 先只显示气泡占位, 等第二帧再算 */
|
||||
| 'wait'
|
||||
/** 不在当前会话: 加未读 + 弹提示 */
|
||||
| 'count'
|
||||
|
||||
export function unreadActionForIncoming(opts: {
|
||||
/** 用户当前是不是正看着这个会话 */
|
||||
isActive: boolean
|
||||
/** 文件类消息: 文件落地了没 (文字消息永远是 false) */
|
||||
hasSavedPath: boolean
|
||||
}): UnreadAction {
|
||||
// 注意: 正在看的时候**必须**先清未读, 不能被 hasSavedPath 拦下来 ——
|
||||
// 文字消息没有 savedPath, 之前就是在这里被提前 return 掉的。
|
||||
if (opts.isActive) return 'clear'
|
||||
if (!opts.hasSavedPath) return 'wait'
|
||||
return 'count'
|
||||
}
|
||||
|
||||
/** 打开/停留在某个会话时, 要不要顺手把它的未读清掉 */
|
||||
export function shouldClearOnOpen(hasUnread: boolean): boolean {
|
||||
return hasUnread
|
||||
}
|
||||
Reference in New Issue
Block a user