Compare commits

...

5 Commits

Author SHA1 Message Date
xiaoshuai 82e330c7d0 docs(git): 记下 push 走 SSH + 拉对端串口列表要对方批准
两个同范围的坑, 顺手记掉:

1. **push 走 SSH.** 远端 HTTPS 没配 credential helper, 非交互式 push 会卡在
   "could not read Username"。已经把 origin 的 push URL 指到 SSH (fetch 仍走 HTTPS),
   直接 `git push` 就能用; 也记了显式 URL 的退回写法, 以及 fetch 要用 SSH 才能刷新
   `origin/main` (否则 git status 里的"领先 N"是假的)。

2. **拉对端的串口列表需要对方用户批准** (新旧版本都是), 对方没批准就是 15 秒超时 ->
   列表空。这看着像 bug 其实不是, 别去加"自动跳过审批"。
2026-10-21 18:16:47 +08:00
xiaoshuai 2a641b1eff feat(serial): USB 页面下线, 改成串口映射 + 串口助手
## USB 整体下线

把 USB 功能真删掉, 不是只藏 UI:

- 删 `src/main/remote/usb.ts` + `src/renderer/src/components/remote/UsbPanel.vue`
- 删 `usb` (node-usb) 和 `node-hid` 两个依赖 —— 查过, 本来就没被真正用起来
- `asarUnpack` 去掉 node-usb/usb/node-hid 的规则
- ChatView 的 tab 由 "USB" 改成 "串口"

## 串口映射 + 串口助手 (新)

`src/main/remote/serial.ts` (取代 usb.ts) 只做串口, 一个文件两件事:

- **串口映射**: 把一端的真实串口映射成另一端可用的虚拟串口
  (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com 建 COM 对)。
  方向做成 "我用对方的" / "对方用我的" 两种, 两边都能发起。
- **串口助手**: 不建虚拟口时, 应用自己当助手 —— 字节流直接显示,
  能发文本/HEX, 能追加 LF, 能循环发送, 能清屏/存日志。

`src/renderer/src/components/remote/SerialPanel.vue` (新) 把两者做在一个面板里:
设备表 (串口 / VID:PID / 产品 / 制造商)、串口参数 (波特率/数据位/停止位/校验)、
TX/RX 时间戳 + hex + ascii 视图、虚拟口路径横幅 (可一键复制)。

## 串口助手要能调 Linux

Linux 上调串口最常见的两个坑, 都做成产品功能:

- **没权限**: 诊断 dialout 组 + socat + 串口列表, 一键提权修复
  (`sudo usermod -aG dialout $USER`, 提示要重新登录才生效)。
  "设备没插" 和 "没权限" 分开报错, 不再混成一句 "打开失败"。
- **缺 socat**: 建虚拟口前先探 socat, 没有就弹安装指引 (apt/dnf),
  免得 attach 做到一半才失败。

平台化文案也分开了: Linux 提示 dialout, Windows 提示设备管理器, macOS 提示隐私授权。

## 线上格式一个字节没动 (兼容性关键)

局域网里常有一台机器跑旧版本, 所以:

- `PROTOCOL_VERSION` 仍然 = 1
- WS 帧类型仍然叫 `'usb'`
- 只把内部类型改名 (`SerialDeviceInfo` / `SerialAttachConfig` / ...)

代价: 混版本时已下线的 USB 字节桥不可用。换来: 新旧版本聊天照常,
串口映射也照常, 不会因为升协议被踢下线。**别手贱顺手改对** (AGENTS.md 坑 #10)。

## 其它

- 主进程 `output` 事件带方向 (rx/tx), 助手才能分清"我发的"和"设备回的";
  客户端自己发出的字节不回显成 rx (无回环, S5 用例守住)
- 虚拟串口 (vcom) 模式下 app 不读内部端, 避免和 socat 抢数据
- `src/main/permissions.ts` 重写成串口专用, 删掉 USB/HID 相关诊断与修复
- 保留 `src/main/remote/vcom.ts` (socat/com0com 配对) 不动, 与 serial.ts 分离

## 验证

- npm run typecheck        ✅
- npm run selftest         ✅ 79 通过 / 0 失败 (USB 用例整体换成 S1-S9 串口用例)
- npm run build            ✅
- npm run package:linux    ✅ 新 deb (84MB)
- npm run selftest:package ✅ 4/4 (改成校验 serialport 原生绑定已解包)

真机 (CDP 驱动真实窗口) 验证过: 串口 tab 能打开, 设备表列到 32 个 /dev/ttyS*,
助手区 + hex 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全,
方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
2026-10-21 18:04:55 +08:00
xiaoshuai 2b63bf7d0b feat(image): 图片直接显示 + 右键复制图片; 修未读角标不清
## 图片直显 + 右键复制

以前图片消息基本只能看到 "[图片] xxx.png" 的文件卡片, 而且右键没有"复制"。

根因有两层:

1. `asImage` 从来没被设成 true —— 用"发送文件"选中的图片是当**普通文件**发的,
   对方只能看到文件卡片。
2. 拖拽/粘贴的图走 `sendBuffer`, 只在 <200KB 时才带 `thumbDataUrl`,
   大图没有缩略图 -> 渲染层 fallback 成文件卡片。

改法:

- 缩略图随消息体走 (`body.thumbDataUrl`): **发送方生成一次, 对方直接拿来显示**,
  对端不用再解码, 老版本对端也看得到图。
- 缩略图用 Electron 的 `nativeImage` 生成 (`src/main/image.ts`), 不引 sharp/jimp:
  宽度上限 480, **PNG 优先 (保透明 —— 截图转 JPEG 会变黑底)**, PNG 超 60KB 才退 JPEG,
  然后按"降质量 -> 缩尺寸"的阶梯找一档够小的。
  阈值/编码选择/选图源抽到 `src/main/image-core.ts` (纯逻辑, 不 import electron, 可单测)。
  源文件 >20MB 不生成 —— nativeImage 解码会把整张图铺成 RGBA 内存。
- 拖拽/选中的图片改走 `sendFile(asImage: true)` (有真实路径, 流式上传),
  只有粘贴的图片才走 `sendBuffer` (整图 base64 进 WS 帧, 大图撑爆带宽)。
- 右键"复制图片" -> `image:copy` IPC -> `clipboard.writeImage`, 写的是**原图**
  (本地文件优先, 退回 dataBase64), 不是气泡里那个缩略图。
- 缩略图缺失时 (老消息/老版本对端) 渲染层退到 `image:thumb` 现场生成,
  失败才显示文件卡片。点开看大图用 `image:dataUrl` 读本地原图。

## 修未读角标不清

用户正看着某个会话, dock/任务栏角标的数字却不消。

根因: `App.vue` 的 `message:received` 里先判 `if (!savedPath) return` (等文件落地再算未读),
而**文字消息没有 savedPath** —— 正好在"清未读"之前就 return 了。
文件/图片消息因为第二帧带 savedPath 才碰巧能清掉, 所以只有文字消息暴露这个 bug。

改法:

- 决策抽成纯函数 `src/renderer/src/utils/unread.ts` 的 `unreadActionForIncoming()`
  (`clear` / `wait` / `count`): **先判 isActive -> 清未读 + 回已读回执, 再判 savedPath**。
- 侧边栏**重复点同一个设备**时 activePeerId 不变, watch 不触发 -> 显式清一次。
- 主进程 `device:delete` 清了 unread 但忘了 `updateBadge()` -> 角标残留, 补上。

## 验证

- npm run typecheck        ✅
- npm run build            ✅
- npm run selftest         ✅ 76 通过 / 0 失败 (新增 I1-I8 图片 + U1-U4 角标)
- npm run selftest:sync    ✅ 全部通过
- npm run selftest:image   ✅ 真 Electron 下验 nativeImage (新增命令)
- npm run package:linux    ✅ 新 deb
- npm run selftest:package ✅ 4/4

真机 (CDP 驱动真实窗口) 验证过: 图片气泡内联显示 (640x420, fallback 0 个),
右键菜单含"复制图片", 点下去系统剪贴板里是 **640x420 原图** (不是缩略图)。
2026-10-21 14:52:37 +08:00
xiaoshuai d854c95ee6 fix(build): 修好 npm install 挂掉 (postinstall 只重建 better-sqlite3)
`npm install` 在 postinstall 阶段直接失败退出:

    node-hid 编译报错 -> node-addon-api/napi.h:2477 expected unqualified-id

根因不是 node-hid 坏了, 是重建范围错了:

1. postinstall 跑的是 `electron-builder install-app-deps`, 它重建**所有**原生模块;
2. 它给子进程传 `npm_config_build_from_source=false` —— 但这是**字符串** "false",
   npm 11 对未知的 npm_config_* 不做布尔转换;
3. pkg-prebuilds-verify 用 `if (process.env.npm_config_build_from_source)` 判断,
   字符串 "false" 在 JS 里是真值 -> 直接 exit 1 -> 强制源码编译;
4. node-hid 于是去编译它自带的 node-addon-api 3.2.1, 和现代 Node 头文件不兼容 -> 编译失败
   -> 整个 npm install 挂掉。

而 node-hid 根本不需要编译 —— 它自带 N-API 预编译产物, 任何 ABI 都能加载。

真正需要按 Electron ABI 重编的只有 better-sqlite3 (非 N-API, 绑死 V8/ABI,
Electron 32 -> node ABI 128); 其余 (@lydell/node-pty / node-hid /
@serialport/bindings-cpp / usb) 都是 N-API 预编译, 重编既没必要又有害。

改动:

- postinstall 换成 `node scripts/postinstall.js`:
  只重建 better-sqlite3, 装完**在 Electron 下真跑一次读写校验**, 失败就 exit 1
  —— 把"启动即崩"提前到 install 阶段暴露
- package.json 加 `allowScripts` 白名单 (npm 11 新机制, 目前只提醒不拦截,
  但迟早会拦; 顺便让 npm install 输出干净)
- AGENTS.md 坑 #3 改写成新规矩 + 完整根因链
- docs/windows-compat.md / docs/remote-terminal-forward-usb.md 里过时的
  "postinstall 走 install-app-deps" 表述一并纠正

验证:

- npm run build            ✅
- npm run typecheck        ✅ node + web
- npm run selftest         ✅ 64 通过 / 0 失败
- npm run selftest:sync    ✅ 全部通过
- npm run package:linux    ✅ 新 deb
- npm run selftest:package ✅ 4/4 (从 app.asar 真实 spawn pty)
2026-10-21 09:32:32 +08:00
xiaoshuai 4fc86edd4e feat(sync): 同步盘 + 关系层 (先建关系, 再做文件同步)
## 关系层 (src/main/relations.ts, relation-service.ts)

原来"发现即信任": UDP 广播包里明文带着 driveToken, 同网段任何进程拿到就能浏览
你的整个网盘, 而且没有"授权"这个概念。现在拆成三层:

  发现 (临时) -> 关系 (持久, 用户显式建立) -> 授权 (分级, 双方各自独立)

建立关系的方式跟远程终端的授权闸门**完全一致** —— 对方直接弹窗, 不用邀请码:
A 点"发起同步关系" -> WS 帧 relation:request -> B 侧弹窗 (复用 ApprovalDialog,
kind='relation') -> 同意 -> relation:accept -> 双方落库并交换 per-peer 令牌。

- 令牌: myToken=我签发给对方的, peerToken=对方签发给我的
- **driveToken 不再进广播包**; file-server 在关系令牌匹配不上时回退到旧的全局
  token, 但只允许"只读浏览网盘" —— 不给同步区, 也不给写/删
- 关系存 JSON 文件 (原子写), 不做 schema 迁移
- 授权能力: drive(浏览我的网盘) / sync(与我同步文件), 双方各自独立勾选
- 远程终端/端口转发/USB 仍走原来的 remoteAllowPeers 白名单, 没有并进关系层
- 同一对端 60s 内只弹一次窗 (WS 是"发现即建立"的, 不限流就能被刷屏)

## 同步区: 独立于网盘的第二套空间

用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘目录里。

  /drive/*  网盘   -> 共享目录, 要 drive 授权
  /sync/*   同步区 -> 某关系专属的同步目录, 要 sync 授权, 路径严格限制在该目录内

顶层目录可以同时和多个设备同步: 每个关系自动分配一个子目录 (~/备份/MECHREVO/,
~/备份/lenovo/), 物理隔开, 同名文件不会互相覆盖 —— "统一目录多个聊天备份"成立。

## 文件关系表 (src/main/sync-manifest.ts) —— 同步盘地基

核心是**区分"文件被删了"和"文件本来就不存在"**:

- **墓碑**: 删除不抹记录, 只标 state='deleted'。直接抹掉的话, 对方离线回来会把
  已删文件当成"新增"重新下载 -> 删除永远删不掉; 对方从备份恢复也会让删除被
  静默撤销。有墓碑 + seq 比较就都能挡住。
- **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定
  删除方向全靠它。扫描顺序必须是 advanceClock -> reconcileLocal -> mergePeer
  (先收消息再产生事件), 否则跨机器的 seq 不可比, 删除方向会判反。
- **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线才退回 mtime 兜底。
- 墓碑留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。
- **删除传播**: 对方推关系表过来时, 比对基线确认我没改过才删本地那份; 改过则
  报冲突 —— 绝不静默丢数据。
- 冲突: 手动同步弹窗对比让用户选方向; 自动同步跳过并记录, 不替用户决定。

## 其余

- 自动同步 (src/main/auto-sync.ts): 60s 轮询 + 指数退避, 对方上线/开关打开时
  立刻跑一轮。
- 同步面板 (SyncPanel.vue): 关系区 + 变更列表 (含"将删除对方/本机") + 冲突对比
  弹窗 + 自动同步开关。
- 关系授权弹窗复用远程终端那套 ApprovalDialog。
- 目录穿越防护: 上传/下载/删除的路径都严格限制在各自根目录内, 且**软链归一后
  再比一次** (同步目录是用户任意文件夹, 里面出现软链很常见)。
- 跨关系校验: 令牌与 relationId 绑定, A 关系的对方不能读写 B 关系的同步目录。
- 关系令牌在 IPC 返回给渲染层时剥掉, 界面拿不到凭据。
- 建关系/授权/同步/删除传播都写 audit_log。

## 测试

- npm run selftest       64 通过 / 0 失败 (R关系 / M墓碑删除传播 / Y同步盘)
- npm run selftest:sync  26 条全通过 (双节点端到端: 建立关系->同步->删->传播->防复活)

## 文档

docs/relation-and-sync.md —— 三层模型 / 关系建立 / 墓碑与 seq / 三方对比判定表 /
安全要点 / 已知不一致。
2026-10-20 19:30:25 +08:00
49 changed files with 9234 additions and 2233 deletions
+116 -4
View File
@@ -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.
+1 -1
View File
@@ -62,7 +62,7 @@ WS 通常几十毫秒内就握手成功 → 主进程广播 `device:wsState` →
| 设备在线但通道不通 | `在线 · 192.168.0.7 (连接中…)`(黄点,后台在 backoff 重连) |
| 都不可用 | `离线 · 12:05`(灰点) |
远程功能(终端 / 转发 / USB / 网盘)都走 WS 通道,所以入口按钮在
远程功能(终端 / 转发 / 串口 / 网盘)都走 WS 通道,所以入口按钮在
`设备离线 && 通道不通` 时才禁用——只看设备在线会把"能连但没连上"的情况误判成可用。
## 4. 回归测试
+601
View File
@@ -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 分支) |
+11 -1
View File
@@ -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 深度计数 | 不涉及 |
+8 -1
View File
@@ -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
View File
@@ -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": [
{
+153
View File
@@ -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')
+175
View File
@@ -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) })
+95
View File
@@ -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,
}
+138
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+179
View File
@@ -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)
})
+6 -1
View File
@@ -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} 个原生文件`
})
+270
View File
@@ -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) })
+177
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+107
View File
@@ -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
}
+194
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
+501
View File
@@ -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' })
}
}
+307
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
}
+654
View File
@@ -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) 映射到你的机器上`
}
+1 -1
View File
@@ -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)
}
}
-965
View File
@@ -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 模块)`
}
+9 -3
View File
@@ -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: '',
},
})
+640
View File
@@ -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
}
+754
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
}
+22 -8
View File
@@ -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="选择一个设备开始聊天">
+13 -1
View File
@@ -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)
+84 -10
View 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">
+4 -3
View File
@@ -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
}
+8
View File
@@ -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 &lt;busid&gt;</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>
+26 -44
View File
@@ -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)
}
+27
View File
@@ -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()] || ''
}
+32
View File
@@ -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
}