diff --git a/docs/remote-terminal-forward-usb.md b/docs/remote-terminal-forward-usb.md new file mode 100644 index 0000000..d9ec740 --- /dev/null +++ b/docs/remote-terminal-forward-usb.md @@ -0,0 +1,623 @@ +# 远程终端、端口转发、USB 透传 规划文档 + +> 目标:在 LocalNetMsg 中实现三类“远程使用对方设备”的能力——**对方机器的本地终端(shell)**、**TCP 端口转发**、**USB 设备透传**。本文先定整体架构、协议、库选型,再分阶段落地。 +> +> 适配范围:Windows / macOS / Linux 三端。被控端的 shell 按平台决定:Windows → PowerShell(默认 `pwsh.exe`,回退 `powershell.exe`),macOS → zsh(回退 bash),Linux → bash(按 `SHELL` 环境变量)。 + +--- + +## 1. 设计目标与边界 + +| 项目 | MVP(v0.2.0) | v0.3.0 | 不做(明确放弃) | +|---|---|---|---| +| 远程终端 | 单 PTY、单 session、只读/可写 | 多 session、窗口大小同步、UTF-8 CJK、ANSI 颜色、剪贴板同步 | 远程录制/回放(交给 asciinema) | +| 端口转发 | TCP `127.0.0.1` 监听、字节计数、TTL | 多并发、IPv6、UDP | SOCKS / HTTP CONNECT / 公网映射 | +| USB 透传 | HID 设备名单 + “绑定本机、远程附加” | 串口、复合设备过滤 | Isochronous、复杂描述符自定义 | + +不做 = 让用户走更合适的工具(AnyDesk/RustDesk 远控、Serveo/Cloudflared Tunnel、VirtualHere)。本地调试范围以**单局域网、单设备对、单设备**为限。 + +--- + +## 2. 选型与依赖 + +### 2.1 库与版本(2026-07 通过 `registry.npmmirror.com` 核实) + +| 用途 | 包 | 版本 | 备注 | +|---|---|---|---| +| PTY 后端(被控端) | `@lydell/node-pty` | `1.2.0-beta.12`(`beta=1.2.0-beta.14`,可选升级) | 自带六平台预编译:win32-x64/arm64、darwin-x64/arm64、linux-x64/arm64;`latest` 是 `1.2.0-beta.12`。**优先 `latest`**,只在 win11 ARM 用户出问题时再切 beta | +| 终端前端(控制端) | `@xterm/xterm` | `6.0.0` | xterm.js | +| 终端自适应 | `@xterm/addon-fit` | `0.11.0` | 让终端随容器尺寸变化 | +| 终端链接 | `@xterm/addon-web-links` | `0.12.0` | 终端内 URL 自动可点击 | +| 端口转发 / TCP 隧道 | Node 内置 `net` 模块 | — | 不引入额外依赖;与现有 `ws`、`file-server` 同风格 | +| USB 后端(被控端) | `usb`(node-usb v3.0.1,Rust N-API 重写版) | `3.0.1` | 同时提供 `usb`(自由访问)和 `webusb`(需授权)两种 API | +| HID 快速通道 | `node-hid`(v3.3.0) | `3.3.0` | 只针对 HID 类(键盘/鼠标/游戏手柄/自定义 HID)速度更快、API 更友好;非 HID 设备走 `usb` | +| 串口 / USB-Serial | `serialport`(v13.0.0) | `13.0.0` | 需要 Node ≥20 | +| 公网隧道(可选 v0.3+) | `localtunnel` | `2.0.2` | 公共中继;不推荐默认开,作为开发期选项 | + +> 镜像访问:`https://registry.npmmirror.com//latest` 可直接读 JSON,元数据字段含 `dist-tags.latest`、`engines`、`os`、`cpu`、`napi.targets`,比 GitHub 页面稳定。GitHub raw/README 多数代理 403/Cloudflare 拦截,npm 元数据是兜底。 + +### 2.2 不选 / 避坑 + +- **`node-pty`** 上游 `microsoft/node-pty` `latest=1.1.0`,仅 `beta=1.2.0-beta.14`。`@lydell/node-pty` 是社区 fork,包更小、预编译更全。直接用社区版,等 `microsoft/node-pty` 进入 stable 再切。 +- **`robotjs`**:维护停滞,对 Electron 32 ABI 兼容性差。 +- **`usbipd-win`**:Windows 官方 USB/IP,原理正确,但与我们 Node 进程隔离;MVP 不集成,后续可作为被控端可选依赖以获得 Isochronous 能力。 +- **FreeRDP / libvncserver / RustDesk**:都是完整远控项目,体积与许可(AGPL)不适合直接嵌入。 +- **`@lydell/node-pty-*-*`** 这些平台包随主包自动拉,无需单独声明。 + +--- + +## 3. 整体架构 + +### 3.1 分层 + +``` +┌──────────────────────────────────────────┐ +│ Renderer (Vue 3) │ UI: TerminalPanel / PortForwardPanel / UsbPanel +│ xterm.js + addon-fit + addon-web-links │ Pinia stores: terminal / forward / usb +└───────────────▲──────────────────────────┘ + │ IPC (contextBridge) +┌───────────────┴──────────────────────────┐ +│ Preload (typed window.api) │ +└───────────────▲──────────────────────────┘ + │ ipcMain.handle / broadcastToRenderer +┌───────────────┴────────────────────────────────────────────────────────┐ +│ Main (Node) │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ RemoteTerminal │ │ PortForward │ │ UsbForward │ │ +│ │ (manager.ts) │ │ (manager.ts) │ │ (manager.ts) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ ┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐ │ +│ │ @lydell/node-pty│ │ net (TCP) │ │ usb / node-hid │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ │ │ │ +│ └──────── WS 信令(复用 chat-client.ts:91)──┘ │ +│ src/main/protocol.ts WsFrame 新增 terminal / forward / usb 控制帧 │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 与现有代码的衔接 + +| 现有 | 复用方式 | +|---|---| +| `src/main/protocol.ts:30` `WsFrame` union | 扩展三条新控制帧:`terminal`、`forward`、`usb` | +| `src/main/chat-client.ts:91` `ws://${peer.address}:${peer.chatPort}` | 复用同一 outgoing WS;多路复用消息类型 | +| `src/main/ipc.ts:59` `bindNetworkContext` | 在末尾给三个新 manager 各建一组 `c.chatServer.on('xxx', ...)` 与 IPC handler | +| `src/main/discovery.ts` UDP 广播 | 不动;新功能依然走 LAN 局域网 | +| `src/main/db.ts` | 新增 `audit_log` 表 + 写入 helper,记录 terminal/forward/usb 事件 | +| `src/renderer/src/stores/*` | 新增 `useTerminalStore`、`useForwardStore`、`useUsbStore` | +| `src/renderer/src/App.vue` | 在 sidebar 增加“工具”下拉,包含三个子入口 | +| `src/renderer/src/components/SettingsView.vue` | 增加“远程”开关 + 默认 TTL/最大带宽/允许的设备名单 | + +--- + +## 4. 协议扩展(WsFrame 新增) + +```ts +// src/main/protocol.ts(追加,不改 PROTOCOL_VERSION) +export type TerminalFrame = + | { type: 'terminal.open'; sessionId: string; rows: number; cols: number; shell?: string } + | { type: 'terminal.input'; sessionId: string; data: string /* base64 of bytes */ } + | { type: 'terminal.output'; sessionId: string; data: string /* base64 of bytes */ } + | { type: 'terminal.resize'; sessionId: string; rows: number; cols: number } + | { type: 'terminal.close'; sessionId: string; reason?: string } + | { type: 'terminal.ack'; sessionId: string; ok: boolean; reason?: string } + +export type ForwardFrame = + | { type: 'forward.open'; sessionId: string; listenPort: number; targetHost: string; targetPort: number; ttlSec: number } + | { type: 'forward.data'; sessionId: string; dir: 'c2s' | 's2c'; data: string /* base64 */; fin?: boolean } + | { type: 'forward.close'; sessionId: string; reason?: string; bytesIn: number; bytesOut: number } + | { type: 'forward.ack'; sessionId: string; ok: boolean; reason?: string } + +export type UsbFrame = + | { type: 'usb.list'; reqId: string } + | { type: 'usb.devices'; reqId: string; devices: Array<{ busId: string; vid: number; pid: number; class: number; subclass: number; product?: string; manufacturer?: string; serial?: string }> } + | { type: 'usb.attach'; sessionId: string; busId: string } + | { type: 'usb.detach'; sessionId: string } + | { type: 'usb.transfer'; sessionId: string; dir: 'host->dev' | 'dev->host'; data?: string; fin?: boolean; status?: string } + | { type: 'usb.ack'; reqId?: string; sessionId?: string; ok: boolean; reason?: string } +``` + +`WsFrame` 联合类型追加上面三个分支。**信令用 JSON**,**数据帧也用 JSON 但 payload 用 base64**——避免把 WS 当二进制流用,简化现有 codec。 + +### 4.1 控制权与角色 + +每条 `*.open` 帧必须包含以下 meta,由发送方填: + +- `fromDeviceId`(自动从握手记录填) +- `appVersion`、`nonce`(每次 `open` 重新生成) + +被控端收到 `open` 后: +1. 校验对端是否在 `usb/terminal/forward.allowPeers` 中(设置项)。 +2. 弹窗确认(被控端的 window),10 秒倒计时。 +3. 通过/拒绝,发送 ack。 + +--- + +## 5. 模块设计 + +### 5.1 Remote Terminal(`src/main/remote/terminal.ts`) + +``` +class TerminalManager { + // 被控端: 接受 open 时新建 pty + handleOpen(env: MessageEnvelope): void + // 控制端: 写 stdin / 收 stdout + sendInput(sessionId: string, bytes: Buffer): void + onOutput(cb: (sessionId: string, bytes: Buffer) => void): void + resize(sessionId: number, rows: number, cols: number): void + close(sessionId: string, reason?: string): void +} +``` + +平台默认 shell: + +```ts +// src/main/remote/shell.ts +import { platform } from 'node:process' +import { execSync } from 'node:child_process' + +export function defaultShell(): { file: string; args: string[] } { + switch (platform) { + case 'win32': { + const file = execSync('where pwsh.exe', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString().trim() ? 'pwsh.exe' : 'powershell.exe' + return { file, args: [] } + } + case 'darwin': + case 'linux': { + return { file: process.env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash'), args: [] } + } + } +} +``` + +被控端启动逻辑: + +```ts +import * as pty from '@lydell/node-pty' + +const env = { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8' } +const proc = pty.spawn(shell.file, shell.args, { + name: 'xterm-256color', + cols: frame.cols, rows: frame.rows, + cwd: os.homedir(), env, encoding: null /* bytes */, +}) +proc.onData((data: string) => send('terminal.output', { sessionId, data: Buffer.from(data, 'utf8').toString('base64') })) +proc.onExit(({ exitCode }) => send('terminal.close', { sessionId, reason: `exit ${exitCode}` })) +``` + +控制端写入: + +```ts +proc.write(Buffer.from(frame.data, 'base64').toString('utf8')) +``` + +> ConPTY 是 Windows 10+ 默认;xp/7 用 winpty(`@lydell/node-pty` 自动选)。`encoding: null` 让数据以 string 但保留字节流语义;解码统一在两端做。 + +### 5.2 Port Forward(`src/main/remote/forward.ts`) + +``` +class ForwardManager { + // 申请方(控制端) + async request(peerDeviceId, { listenPort, targetHost, targetPort, ttlSec }): Promise + // 数据流 + onData(sessionId, dir, bytes) + sendData(sessionId, dir, bytes, fin?) + close(sessionId, reason?) +} +``` + +申请方作为 TCP server(`net.createServer`,`host: '127.0.0.1'`),被控端作为 TCP client: + +``` +控制端浏览器/应用 -> 127.0.0.1:listenPort + ↓ + ForwardManager (net.Server) + ↓ (base64 frame) + chat-client.ts:91 (现有 WS) + ↓ + 对方 ForwardManager (net.Socket) + ↓ + 对方 127.0.0.1:targetPort +``` + +要点: +- 单 forward session 一个 TCP 连接,复用现有 WS 子协议;帧不超过 16 KB/帧,超出分片。 +- 字节计数:双向分别累计;TTL 到期(默认 1 小时)自动关闭。 +- 端口黑名单:禁止 `22, 23, 53, 80, 135, 139, 443, 445, 3389, 5900, 5985/5986`,被控端在 `forward.ack` 里拒。 +- 不开 `0.0.0.0`;v0.3+ 再加 LAN 提示。 +- 关闭时若仍有未消费字节,发送 `fin:true` 让对端 socket 半关闭,不丢数据。 + +### 5.3 USB / 串口 共享(`src/main/remote/usb.ts`) + +**老实说在前**:纯 Node.js 在 Windows 上做不到"USB 设备完全透明地变成本机 USB"——那是商业软件 +(VirtualHere / USB Network Gate / FlexiHub)或 Linux usbip + WSL 才有的能力。我们提供三种模式,按场景取用: + +| 模式 | 能力 | 平台 | 透明度 | +|---|---|---|---| +| **`serial`** | 真正的双向字节流转发:server 端 `SerialPort.open`,client 端发 hex 字符串 | 全平台 | ✓ 完整(OS 看来就是普通串口) | +| **`usb`** | libusb 字节桥:server 端 `node-usb` open + claim interface,client 端发 controlTransfer/bulkTransfer 请求 | 全平台 | ✗ 不是透明 USB——是远程调设备 | +| **`usbip`** | 调系统 `usbip` CLI,让 Linux 内核接管(vhci_hcd 模块) | Linux only | ✓ 真透明(设备出现在本机 lsusb) | + +#### 5.3.1 `serial` 模式(最实用) + +覆盖所有 USB-串口适配器(CH340 / CP210x / FTDI / PL2303)和原生 COM/tty。**对端不用装任何驱动**——`serialport` 走 OS 标准串口 API,自动枚举。 +对硬件开发者来说这就是"远程调试 Arduino / STM32 / ESP32 / 串口打印机 / 串口屏"。 + +``` +控制端 被控端 +usb.attach(busId, { kind: 'serial', baudRate: 115200 }) + └→ chatClient.send(usb.attach) + └→ 弹窗确认 + └→ new SerialPort({ path, baudRate: ... }).open() + └→ sp.on('data', buf) → usb.data { dir: 'dev->host' } +usb.serialSend(sid, bytes) + └→ chatClient.send(usb.data { dir: 'host->dev' }) + └→ sp.write(buf) +``` + +#### 5.3.2 `usb` 模式(libusb 字节桥) + +适合访问自定义 USB 设备(单片机编程器、调试器、JTAG、自定义硬件)。 +**不是透明 USB**——是 `node-usb` 的 controlTransfer / bulkTransfer 调用搬到对端机器上。 +要"像本机 USB 一样"必须走 usbip 或商业软件。 + +``` +控制端 被控端 +usb.attach(busId, { kind: 'usb', interfaceNumber: 0 }) + └→ chatClient.send(usb.attach) + └→ node-usb.open(vid,pid) + └→ claimInterface(0) + └→ 返回 endpoints 列表 +usb.ctrlIn(sid, { requestType, request, value, index }, length) + └→ chatClient.send(usb.ctrlIn { setup, length }) + └→ dev.nativeControlTransferIn(setup, 5000, length) + └→ usb.ctrlResult { ok, data, status } +usb.bulkIn(sid, endpoint, length) 类似 +usb.bulkOut(sid, endpoint, bytes) 类似 +``` + +#### 5.3.3 `usbip` 模式(Linux only) + +真透明 USB 透传。`usbip attach -r -b ` 让本机内核接管,设备出现在 `lsusb`。 +**前置条件**:用户机器装了 `usbip` 包 + 加载 `vhci_hcd` 内核模块。我们的应用首次用时自动检测 + 提示安装(`permissions.ts` 里的 `diagnoseAndFixUsbAttach`)。 +**macOS/Windows 默认不支持**——UI 会显示提示但禁用该选项。 + +#### 5.3.4 协议帧 + +```ts +type UsbFrame = + | { 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: 'detach'; sessionId: string; reason?: string } + | { type: 'detached'; sessionId: string; ok: boolean; reason?: string } + // 字节流 (serial 模式) + | { type: 'data'; sessionId: string; dir: 'host->dev' | 'dev->host'; data: string; 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'; ok: boolean; reason?: string } + | { type: 'error'; sessionId: string; reason: string } +``` + +--- + +## 6. 数据流与生命周期 + +### 6.1 远程终端 + +```text +控制端 被控端 +ui-click "打开终端" + └→ TerminalPanel.onOpen() + └→ window.api.terminal.open(peerId, { rows, cols }) + └→ ipcMain: terminal.open + └→ chatClient.send(terminal.open frame) + └→ ChatServer → TerminalManager.handleOpen + ├→ 检查 peer 在 allowPeers + ├→ 弹窗 (被控 UI) + ├→ pty.spawn(defaultShell()) + └→ send(terminal.ack { ok: true }) +ui 显示 ack + xterm 开始渲染 +ui-keypress + └→ window.api.terminal.sendInput(sid, bytes) + └→ chatClient.send(terminal.input frame) + └→ TerminalManager → pty.write(bytes) +pty.onData(bytes) + └→ chatClient.send(terminal.output frame) +ui: xterm.write(bytes) +``` + +### 6.2 端口转发 + +```text +控制端 被控端 +window.api.forward.open(peerId, { listenPort, targetHost, targetPort, ttlSec }) + └→ chatClient.send(forward.open frame) + └→ ack { ok }(先校验端口黑名单、TTL) + +ui → curl 127.0.0.1:listenPort + └→ net.Server connection + └→ ForwardManager 维护 sessionId, socket map + └→ chatClient.send(forward.data { dir: 'c2s', data: base64, fin }) + └→ net.Socket.write(data) + └→ target process + response bytes → chatClient.send(forward.data { dir: 's2c', data }) +ui ← net.Server socket.write(data) +``` + +### 6.3 USB / 串口 共享 + +#### 6.3.1 serial 模式 (字节流) + +```text +控制端 被控端 +window.api.usb.list(peerId) + └→ chatClient.send(usb.list) + └→ SerialPort.list() + (libusb if any) + (usbip if linux) + └→ usb.devices { kind: 'serial' | 'usb' | 'usbip', ... } + +ui 选 serial 设备 + 波特率 + └→ window.api.usb.attach(peerId, busId, { config: { kind: 'serial', baudRate: 115200 } }) + └→ chatClient.send(usb.attach { config }) + └→ 弹窗确认 + └→ new SerialPort(...).open() + └→ sp.on('data', buf) → usb.data { dir: 'dev->host', data: base64 } + └→ usb.attached { ok, info: { kind: 'serial', serialPath } } + +ui 写字节 + └→ window.api.usbSerialSend(sid, bytes) + └→ chatClient.send(usb.data { dir: 'host->dev', data }) + └→ sp.write(buf) + +ui 关闭 + └→ window.api.usb.detach(sid) → chatClient.send(usb.detach) + └→ sp.close() → cleanupServerSession +``` + +#### 6.3.2 usb 模式 (libusb 字节桥) + +```text +控制端 被控端 +ui 选 usb 设备 + └→ window.api.usb.attach(peerId, busId, { config: { kind: 'usb', interfaceNumber: 0 } }) + └→ chatClient.send(usb.attach { config }) + └→ 弹窗确认 + └→ node-usb.findDeviceByIds(vid, pid) + └→ dev.open() → selectConfiguration(1) → claimInterface(0) + └→ 枚举 endpoints + └→ usb.attached { ok, info: { kind: 'usb', endpoints: [...] } } + +ui 发 control IN (例: GET_DESCRIPTOR) + └→ window.api.usbCtrlIn(sid, { requestType: 0x80, request: 0x06, value: 0x0100, index: 0 }, 18) + └→ chatClient.send(usb.ctrlIn { setup, length: 18 }) + └→ dev.nativeControlTransferIn(setup, 5000, 18) + └→ usb.ctrlResult { ok, data, status: 18 } + +ui 发 bulk IN + └→ window.api.usbBulkIn(sid, 0x81, 64) → 类似 ctrlIn + +ui 发 bulk OUT + └→ window.api.usbBulkOut(sid, 0x01, hexData) → 走 ctrlOut-style request-response +``` + +#### 6.3.3 usbip 模式 (Linux only) + +```text +控制端 (Linux 客户端) 被控端 (Linux 设备持有方) +ui 选 usbip 设备 + └→ window.api.usb.attach(peerId, busId, { config: { kind: 'usbip' } }) + └→ chatClient.send(usb.attach { config: { kind: 'usbip' } }) + └→ 弹窗确认 → 仅记录 (不直接 open) + └→ usb.attached { ok } + └→ permissions.diagnoseAndFixUsbAttach() — 检查 usbip + vhci_hcd + └→ `usbip attach -r -b ` (spawn sudo-prompt) + └─ → 内核 vhci_hcd 接收 → 设备出现在 `lsusb` +``` + +--- + +## 7. UI 设计 + +### 7.1 Sidebar 增加“工具”tab + +``` +设备 文件 工具 设置 + └─ 终端 + └─ 端口转发 + └─ USB 设备 +``` + +### 7.2 TerminalPanel.vue + +```vue + + + +``` + +### 7.3 ForwardPanel.vue + +- “+ 新建转发”按钮 → 弹表单(peer / 监听端口 / 目标主机 / 目标端口 / TTL) +- 列表展示:`peerName, listen:127.0.0.1:PORT0 → peer:HOST:PORT1`,剩余 TTL,in/out 字节数 +- 行内操作:停用、复制链接 + +### 7.4 UsbPanel.vue + +顶部方向切换 (我用对方的 / 对方用我的) + 模式切换 (串口 / USB / USB/IP) + 设备扫描按钮。 + +**串口模式**(最实用): +- 设备表 + 波特率下拉 + Attach 按钮 +- 附加后显示 hex 流(serial 字节流可视化) +- "发送文本"按钮快速发 ASCII/UTF-8 字节 + +**USB 模式**(libusb 字节桥): +- 设备表 + Interface # 输入 + "Linux detach 内核驱动" 复选 +- 附加后显示: + - **Endpoints 列表**(从 attach 帧的 `info.endpoints` 拿到,提示用户 EP 地址和方向) + - **控制传输表单**:方向(IN/OUT)+ type(standard/class/vendor)+ recipient(device/interface/endpoint/other)+ request + wValue + wIndex + data(hex) / length + - **批量/中断传输表单**:方向(IN/OUT)+ EP 地址 + length + timeout + data(hex) + - **调用日志**:每次 ctrl/bulk 调用的时间、tag、status、hex dump + +**USB/IP 模式**(Linux): +- 顶部显示提示:"USB/IP 是 Linux 内核自带的真透明 USB 透传...需要装 `usbip` + 加载 `vhci_hcd` 模块" +- 设备表 + Attach 按钮 +- 附加后由内核接管,本应用不再做数据转发 + +底部始终有"对方正在使用我的设备"横条(当 server-side session 存在时),可一键 stop。 + +--- + +## 8. 安全模型 + +> 本项目原本 LAN 信任域 = 同一子网任意主机;启用终端/转发/USB 后需要更明确的边界。 + +### 8.1 信任与确认 + +- 每条 `*.open` 必须在**被控端**弹窗(默认聚焦窗口、10 秒倒计时、不可超时自动通过)。 +- 设置项 `remote.allowPeers: Record`,默认全 false。 +- 同一对端 24 小时内只能弹一次允许(除非重新启动应用)。 +- “仅只读终端”开关:禁用 `terminal.input`(被控端拒绝写入帧)。 +- `forward.open` 必须显式选择目标端口范围 + TTL;被控端按白名单校验 host。 + +### 8.2 端口安全 + +- 监听 host 永远 `127.0.0.1`,不开 `0.0.0.0`。 +- 只挡自家端口 `SELF_PORTS = {47800, 47900, 47901}`(防把消息/文件端口转发造成死循环/桥接冲突),**其他端口一律不挡**(用户的 SSH/RDP/MySQL 等服务我们不去判断)。 +- 默认 TTL 1 小时,最大 24 小时。 +- 默认带宽 10 MB/s,最大 100 MB/s,按 session 计数。 + +### 8.3 USB / 串口 安全 + +- 每条 `usb.list` / `usb.attach` 必须**被控端**显式授权(弹窗)。 +- `serial` 模式:被控端打开串口前弹窗告知"对方将通过此串口发送/接收字节",用户勾"记住"后 24h 内不再弹。 +- `usb` 模式:被控端 claim interface 前弹窗,告知"对方将通过本机的 USB 设备 X 发送控制/批量传输"。 +- `usbip` 模式:弹窗告知"对方将 attach 你的 USB 设备 X 到他自己的机器上"(设备会从你机器上消失)。 +- 设备列表本身(vendor/product/serial)属于"信息泄露"敏感字段,要求接收端在 `usb.list` 时显式授权一次。 +- 被控端顶部横幅"USB/串口 设备已被 X 远程使用中"(chat header 同时显示)。 +- session 30 秒无活动自动 detach。 + +### 8.4 审计 + +新增表(`db.ts` 内 `initAudit()`): + +```sql +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + action TEXT NOT NULL, -- terminal.open / forward.open / usb.attach ... + source_device_id TEXT, + target_device_id TEXT, + session_id TEXT, + payload_json TEXT, + result TEXT, -- ok / denied / error / closed + bytes_in INTEGER DEFAULT 0, + bytes_out INTEGER DEFAULT 0 +); +CREATE INDEX idx_audit_ts ON audit_log(ts DESC); +``` + +保留 30 天(设置项可改),手动“导出 audit.zip”。 + +--- + +## 9. 与现有 pitfalls 的对齐 + +| Pitfall | 对策 | +|---|---| +| #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` 兜底 | +| #4 dist-Electron 路径 | 不动 | +| #5 nsis oneClick false | 不动 | +| #6 dragenter/dragleave 深度计数 | 不涉及 | +| #7 渲染层只暴露 在线/离线 两种状态 | 新增的三个 UI 独立成 tab,不污染 ChatView/Sidebar 的现有状态机 | +| #8 文件冲突命名 | 新增 `downloadDir` 复用 `file-server.ts` 既有目录,不改 | + +--- + +## 10. 落地节奏 + +### v0.2.0 — 单平台、单 session、只读终端 + 本机转发 + +1. 加 `@lydell/node-pty`、`@xterm/xterm`、`@xterm/addon-fit`、`@xterm/addon-web-links`、`usb`、`node-hid`、`serialport`。 +2. `src/main/protocol.ts` 加 `WsFrame` 三类控制帧。 +3. `src/main/remote/{terminal,forward,usb}.ts` 三个 manager。 +4. `src/main/db.ts` 加 `audit_log` 表。 +5. `src/main/ipc.ts` 增 `terminal:*`、`forward:*`、`usb:*` 共约 10 个 handler。 +6. `src/renderer/src/components/remote/{TerminalPanel,ForwardPanel,UsbPanel}.vue`。 +7. `src/renderer/src/stores/{terminal,forward,usb}.ts`。 +8. `SettingsView.vue` 增加“远程”开关与 allowPeers 列表。 +9. `npm run typecheck` 通过;手动两机对测。 + +### v0.3.0 — 多 session、带宽/TTL、串口透传 + +1. `forward.maxBandwidth`、`forward.defaultTtl` 设置项。 +2. `serialport` 路径完善(Linux udev 提示)。 +3. xterm.js 同步主题、深色模式。 + +### v0.4.0 — 公网可选 + +1. `localtunnel` 接入(仅转发;终端和 USB 不走公网)。 +2. 文案与免责声明。 + +### 不做 + +- 屏幕/桌面、鼠标键盘输入、剪贴板双向同步、音频、文件双向同步——都留给 RustDesk / AnyDesk。 +- 公网中继自建(成本过高,Y 不如直接用现成 SaaS)。 +- 远程重启、远程安装驱动、远程执行任意脚本。 + +--- + +## 11. 验证清单(手动两机) + +| # | 场景 | 期望 | +|---|---|---| +| T1 | 控制端请求被控端终端 → 被控端允许 | xterm 渲染 PowerShell/zsh/bash 提示符 | +| T2 | 控制端输入 `ls -la` | 双端能正确显示(中文文件名 OK) | +| T3 | 控制端把窗口拉大 | 终端按比例刷新(fit + resize 帧) | +| T4 | 控制端断开 | 被控端 PTY 收到 SIGHUP(win 下 ConPTY 退出) | +| F1 | 控制端 127.0.0.1:5180 → 被控 127.0.0.1:22 | ack 被控拒(黑名单) | +| F2 | 控制端 127.0.0.1:5180 → 被控 127.0.0.1:3000 | 浏览器开 5180 看到 3000 内容 | +| F3 | TTL 到期 | listener 自动释放;`netstat` 看不到端口 | +| U1 | 列出被控端 USB 设备 | 控制端看到 product/serial | +| U2 | attach 串口 /dev/ttyUSB0 | 被控端 z串口被占;控制端能 write | +| U3 | 被控端关闭应用 | 控制端收到 `usb.close { reason: 'peer-down' }` | +| S1 | 控制端不在 allowPeers | 被控端收到 `open` 直接 ack false,写 audit | +| S2 | 控制端伪造 `fromDeviceId` | 被控端按 WS 握手记录覆盖,伪造失败 | +| S3 | 同时 5 个并发 forward | 全部独立,UI 列表正确 | + +--- + +## 12. 参考 + +- `@lydell/node-pty` README & 平台包列表:`https://github.com/lydell/node-pty` +- `@xterm/xterm` v6.0.0:`https://xtermjs.org/` +- `usb@3.0.1`(Rust 重写版):`https://github.com/node-usb/node-usb-rs` +- `node-hid@3.3.0`:`https://github.com/node-hid/node-hid` +- `serialport@13.0.0`:`https://serialport.io/` +- `localtunnel@2.0.2`:`https://github.com/localtunnel/localtunnel` +- 现有项目入口:`AGENTS.md`、`src/main/protocol.ts`、`src/main/chat-client.ts:91`、`src/main/ipc.ts:59` \ No newline at end of file diff --git a/src/main/permissions.ts b/src/main/permissions.ts new file mode 100644 index 0000000..ccc4f60 --- /dev/null +++ b/src/main/permissions.ts @@ -0,0 +1,283 @@ +// 跨平台权限辅助: 调 sudo-prompt 提权; 仅在确实需要时 (Linux 串口/HID/USB) +// Windows 的串口/HID 大多数情况不需要 admin; macOS 系统弹窗 OS 接管, 我们不参与. +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) + +// sudo-prompt 是 CJS, 默认导出 +// @ts-ignore - 没有 .d.ts +import sudo from 'sudo-prompt' + +interface ElevationResult { ok: boolean; stdout?: string; stderr?: string; code?: number } + +function runElevated(cmd: string, name = 'LocalNetMsg'): Promise { + return new Promise((resolve) => { + try { + sudo.exec(cmd, { name }, (error?: Error | undefined, stdout?: string | Buffer, stderr?: string | Buffer) => { + const out = stdout ? String(stdout) : '' + const errOut = stderr ? String(stderr) : '' + if (error) { + const code = (error as any).code + resolve({ ok: false, stdout: out, stderr: errOut, code }) + } else { + resolve({ ok: true, stdout: out, stderr: errOut }) + } + }) + } catch (e: any) { + resolve({ ok: false, stderr: String(e?.message || e) }) + } + }) +} + +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 { + platform: NodeJS.Platform + ok: boolean + issues: PermIssue[] + notes: string[] +} + +export async function diagnoseSerial(): Promise { + const issues: PermIssue[] = [] + const notes: string[] = [] + let serialCount = 0 + 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*', + }) + } + } catch (e: any) { + notes.push(`serialport 加载失败: ${e?.message || e}`) + } + + return { + platform: platform as NodeJS.Platform, + ok: issues.length === 0, + issues, + notes, + } +} + +export async function diagnoseUsb(vendorId?: number, productId?: number): Promise { + const issues: PermIssue[] = [] + const notes: string[] = [] + let found = 0 + + // Linux: 测一下能否读 /sys/bus/usb/devices (普通用户可读, 但创建设备文件需要 udev) + if (platform === 'linux') { + 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`) + } catch {} + } + } catch {} + if (found === 0) { + notes.push('无法读取 /sys/bus/usb/devices — 不太常见, 请检查 udev 是否运行') + } else if (vendorId && productId) { + // 设备存在但能否被 node-usb 打开? 试一下 + 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, + }) + } + } + } + + if (platform === 'win32') { + notes.push('Windows: USB/HID 通常无需 admin; 若 node-hid 找不到设备, 用 Zadig 安装 WinUSB 驱动 (一次性, 需 admin)') + } + if (platform === 'darwin') { + notes.push('macOS: 首次访问 USB 设备时系统会弹权限对话框, 在 系统设置 → 隐私与安全 中允许') + } + + return { + platform: platform as NodeJS.Platform, + ok: issues.length === 0, + issues, + notes, + } +} + +function formatHex(v: number) { + return v.toString(16).padStart(4, '0').toLowerCase() +} + +// 把用户加入 dialout 组 (Linux); 一次性, 需要登出登入生效 +export async function fixLinuxDialout(): Promise { + if (platform !== 'linux') { + return { ok: false, stderr: '仅 Linux 需要此修复' } + } + // 1. 检查用户是否已在 dialout + try { + const { stdout } = await execAsync('groups') + if (/\bdialout\b/.test(stdout)) { + return { ok: true, stdout: '已在 dialout 组, 无需操作' } + } + } catch {} + // 2. 提权 usermod + const userCmd = process.env.USER || process.env.LOGNAME || '$USER' + const r = await runElevated(`usermod -aG dialout ${userCmd}`) + if (r.ok) { + return { ...r, stdout: (r.stdout || '') + '\n已加入 dialout 组. 请 登出并重新登录 系统后生效.' } + } + return r +} + +// 写 udev 规则 (Linux); 一次写永久生效 +export async function fixLinuxUdevRule(vendorId: number, productId: number, mode: 'usb' | 'hid' = 'usb'): Promise { + 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 { + // 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((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 + if (d.issues.some(i => i.kind === 'linux.dialout')) { + fix = await fixLinuxDialout() + } + 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 -b ` 让内核接管. +// 用户机器需要先装 `usbip` 包 + 加载 vhci_hcd 模块. 一次性. + +// Linux: 检查 + 装 usbip + 加载内核模块 (vhci_hcd, usbip_core) +export async function diagnoseAndFixUsbAttach(): Promise { + if (platform !== 'linux') return { ok: true, stdout: '非 Linux 不支持 USB/IP' } + // 检查 usbip 命令 + 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') + } 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 { ok: true, stdout: 'usbip + vhci_hcd 已就绪' } +} \ No newline at end of file diff --git a/src/main/remote/approval.ts b/src/main/remote/approval.ts new file mode 100644 index 0000000..66c8e26 --- /dev/null +++ b/src/main/remote/approval.ts @@ -0,0 +1,121 @@ +// 远程操作授权闸门: 决定一个对端能不能开 terminal/forward/usb +// 规则: +// 1. settings.remoteEnabled === false -> 全部拒绝 +// 2. settings.remoteAllowPeers[peerId][kind] === true -> 自动放行 +// 3. 否则弹窗 (主进程打开一个 BrowserWindow 询问); 用户可勾选 "记住此对端" +// 4. 同一对端 + 同一 kind 24h 内只弹一次 (除非显式 reset) + +import { BrowserWindow } from 'electron' +import { getSettings } from '../settings' + +export type RemoteKind = 'terminal' | 'forward' | 'usb' + +interface Pending { + resolve: (ok: boolean, remember?: boolean) => void + timer: NodeJS.Timeout +} + +const pending = new Map() +const approvedAt = new Map() +const APPROVE_TTL_MS = 24 * 3600_000 + +function cacheKey(peerId: string, kind: RemoteKind, nonce?: string) { + // nonce 用于 usb.attach 这种需要每次重新确认的场景 + return nonce ? `${peerId}::${kind}::${nonce}` : `${peerId}::${kind}` +} + +export function isAutoAllowed(peerId: string, kind: RemoteKind): 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 { + const k = cacheKey(peerId, kind) + const ts = approvedAt.get(k) + if (!ts) return false + if (Date.now() - ts > APPROVE_TTL_MS) { + approvedAt.delete(k) + return false + } + return true +} + +export function markApproved(peerId: string, kind: RemoteKind) { + approvedAt.set(cacheKey(peerId, kind), Date.now()) +} + +export function resetApproval(peerId: string, kind?: RemoteKind) { + for (const k of approvedAt.keys()) { + if (k.startsWith(`${peerId}::`)) { + if (!kind || k.startsWith(`${peerId}::${kind}`)) approvedAt.delete(k) + } + } +} + +// 同步决策: 自动允许 / 缓存允许 -> 直接 true; 否则 false (UI 需要主动 ask) +export function shouldAsk(peerId: string, kind: RemoteKind): boolean { + if (isAutoAllowed(peerId, kind)) return false + if (isRemembered(peerId, kind)) { + markApproved(peerId, kind) + return false + } + return true +} + +const ASK_TIMEOUT_MS = 30_000 + +// 让 UI 弹一个授权对话框; resolve(true) 同意, resolve(false) 拒绝/超时 +// 这里用 BrowserWindow 直接构造, 渲染层可以通过 IPC 拿到队列并显示 +export interface ApprovalRequest { + requestId: string + peerId: string + peerName: string + kind: RemoteKind + detail?: string + ts: number +} + +const waitingQueue: ApprovalRequest[] = [] +const waitersByReq = new Map() + +export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; remember?: boolean }> { + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(req.requestId) + waitersByReq.delete(req.requestId) + // 从队列移除 + const i = waitingQueue.findIndex(x => x.requestId === req.requestId) + if (i >= 0) waitingQueue.splice(i, 1) + resolve({ ok: false }) + }, ASK_TIMEOUT_MS) + pending.set(req.requestId, { resolve: (ok, remember) => resolve({ ok, remember }), timer }) + waitersByReq.set(req.requestId, pending.get(req.requestId)!) + waitingQueue.push(req) + }) +} + +export function listPendingApprovals(): ApprovalRequest[] { + return [...waitingQueue] +} + +export function replyApproval(requestId: string, ok: boolean, remember?: boolean): boolean { + const p = pending.get(requestId) + if (!p) return false + clearTimeout(p.timer) + pending.delete(requestId) + waitersByReq.delete(requestId) + const i = waitingQueue.findIndex(x => x.requestId === requestId) + if (i >= 0) waitingQueue.splice(i, 1) + p.resolve(ok, remember) + return true +} + +export function focusMain() { + const w = BrowserWindow.getAllWindows()[0] + if (!w) return + if (w.isMinimized()) w.restore() + w.show() + w.focus() +} \ No newline at end of file diff --git a/src/main/remote/forward.ts b/src/main/remote/forward.ts new file mode 100644 index 0000000..0f054fb --- /dev/null +++ b/src/main/remote/forward.ts @@ -0,0 +1,565 @@ +// 端口转发: 双向支持 +// self-out (默认): 控制端开 127.0.0.1 TCP server, 被控端连自己的目标 — 把对方服务代理到本地 +// self-in: 被控端开 127.0.0.1 TCP server, 控制端连自己的目标 — 把本地服务暴露到对方 +// 单流: 同时只支持一个 TCP 连接 (MVP, 多流需要扩展协议加 streamId) +import { EventEmitter } from 'node:events' +import { randomUUID } from 'node:crypto' +import { createServer, createConnection, Server, Socket } from 'node:net' +import type { DeviceInfo, ForwardFrame, WsFrame, ForwardDirection } from '../protocol' +import type { ChatClient } from '../chat-client' +import { getSettings } from '../settings' +import { shouldAsk, markApproved, enqueueApproval, resetApproval } from './approval' +import { recordAudit } from '../db' + +type OpenFrame = Extract +type DataFrame = Extract +type CloseF = Extract +type AckFrame = Extract + +// 仅防本地端口转发到自己三端口 (死循环 / 桥接冲突), 其他一概不拦 +const SELF_PORTS = new Set([47800, 47900, 47901]) +const MAX_FRAME_BYTES = 64 * 1024 + +interface ClientSession { + sessionId: string + peerId: string + direction: ForwardDirection + listenPort?: number // 自-out: 控制端 listenPort; 自-in: 对方 listenPort + targetHost: string + targetPort: number + // TCP server (自-out): 我方 127.0.0.1 + // TCP client (自-in): 我方 → 我方 target + server?: Server + client?: Socket + activeStream?: Socket + expiresAt: number + bytesIn: number + bytesOut: number + speed?: { tokens: number; last: number } +} + +interface ServerSession { + sessionId: string + ownerId: string + peerId: string + direction: ForwardDirection + listenPort?: number // 自-in: 对方 listenPort (我方 bind); 自-out: 我方 listenPort (我方 bind? no, 自-out peer bind) + targetHost: string + targetPort: number + // TCP server (自-in): 我方 127.0.0.1 + // TCP client (自-out): 我方 → 我方 target + server?: Server + client?: Socket + activeStream?: Socket + expiresAt: number + bytesIn: number + bytesOut: number +} + +export interface ForwardManagerEvents { + approvalRequested: (req: { peerId: string; peerName: string; kind: 'forward'; detail: string; requestId: string }) => void + sessionOpened: (info: { sessionId: string; peerId: string; direction: ForwardDirection; listenPort?: number; targetHost: string; targetPort: number; side: 'client' | 'server' }) => void + sessionClosed: (info: { sessionId: string; peerId: string; direction: ForwardDirection; reason?: string; bytesIn: number; bytesOut: number; side: 'client' | 'server' }) => void + sessionError: (info: { sessionId: string; peerId: string; error: string }) => void +} + +export class ForwardManager extends EventEmitter { + private clientSessions = new Map() + private serverSessions = new Map() + private chatClient: ChatClient + + constructor(chatClient: ChatClient) { + super() + this.chatClient = chatClient + } + + // ===== 控制端 ===== + + async openOnPeer(peerId: string, opts: { + direction?: ForwardDirection, + listenPort: number, + targetHost: string, + targetPort: number, + ttlSec?: number, + }): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { + const direction: ForwardDirection = opts.direction || 'self-out' + const s = getSettings() + if (!s.remoteEnabled) return { ok: false, reason: '远程功能已关闭' } + if (!Number.isInteger(opts.listenPort) || opts.listenPort < 1 || opts.listenPort > 65535) { + return { ok: false, reason: '监听端口非法' } + } + if (!Number.isInteger(opts.targetPort) || opts.targetPort < 1 || opts.targetPort > 65535) { + return { ok: false, reason: '目标端口非法' } + } + // 两端都不能指向自身端口 + if (SELF_PORTS.has(opts.listenPort)) { + return { ok: false, reason: `监听端口 ${opts.listenPort} 是本机占用, 不能转发` } + } + if (SELF_PORTS.has(opts.targetPort)) { + return { ok: false, reason: `目标端口 ${opts.targetPort} 是本机占用, 不能转发` } + } + const ttl = Math.min(Math.max(opts.ttlSec ?? s.forwardDefaultTtlSec, 30), 24 * 3600) + + const sessionId = randomUUID() + + if (direction === 'self-out') { + // 控制端 = server (bind 127.0.0.1:listenPort); 对方 = client (连自己的 targetHost:targetPort) + const session: ClientSession = { + sessionId, + peerId, + direction, + listenPort: opts.listenPort, + targetHost: opts.targetHost, + targetPort: opts.targetPort, + expiresAt: Date.now() + ttl * 1000, + bytesIn: 0, bytesOut: 0, + } + this.clientSessions.set(sessionId, session) + try { + session.server = await this.bindLocalServer(sessionId, opts.listenPort) + } catch (e: any) { + this.clientSessions.delete(sessionId) + recordAudit({ action: 'forward.open', target: peerId, sessionId, result: 'error', payload: { phase: 'local-bind', error: String(e?.message || e) } }) + return { ok: false, reason: `本地监听失败: ${e?.message || e}` } + } + const frame: OpenFrame = { type: 'open', sessionId, direction, listenPort: opts.listenPort, targetHost: opts.targetHost, targetPort: opts.targetPort, ttlSec: ttl } + if (!this.chatClient.send({ type: 'forward', payload: frame } as WsFrame, peerId)) { + try { session.server.close() } catch {} + this.clientSessions.delete(sessionId) + return { ok: false, reason: '对方离线' } + } + } else { + // self-in: 控制端 = client (连自己的 targetHost:targetPort); 对方 = server (bind) + const session: ClientSession = { + sessionId, + peerId, + direction, + listenPort: opts.listenPort, // 对方 listenPort + targetHost: opts.targetHost, + targetPort: opts.targetPort, + expiresAt: Date.now() + ttl * 1000, + bytesIn: 0, bytesOut: 0, + } + this.clientSessions.set(sessionId, session) + const frame: OpenFrame = { type: 'open', sessionId, direction, listenPort: opts.listenPort, targetHost: opts.targetHost, targetPort: opts.targetPort, ttlSec: ttl } + if (!this.chatClient.send({ type: 'forward', payload: frame } as WsFrame, peerId)) { + this.clientSessions.delete(sessionId) + return { ok: false, reason: '对方离线' } + } + } + + // 等对方 ack + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (this.clientSessions.has(sessionId)) { + this.cleanupClientSession(sessionId, '对方 30s 内未响应') + resolve({ ok: false, reason: '对方 30s 内未响应' }) + } + }, 30_000) + const onAck = (ok: boolean, reason?: string) => { + clearTimeout(timer) + this.off(`ack:${sessionId}`, onAck as any) + if (!ok) { + this.cleanupClientSession(sessionId, reason || '对方拒绝') + resolve({ ok: false, reason: reason || '对方拒绝' }) + return + } + const sess = this.clientSessions.get(sessionId) + if (sess && sess.direction === 'self-in') { + // self-in: 我方主动连自己的 target + try { + sess.client = this.connectLocalClient(sessionId, sess.peerId, sess.targetHost, sess.targetPort) + } catch (e: any) { + this.cleanupClientSession(sessionId, `本地连接目标失败: ${e?.message || e}`) + resolve({ ok: false, reason: `本地连接目标失败: ${e?.message || e}` }) + return + } + } + setTimeout(() => this.cleanupClientSession(sessionId, 'ttl-expired'), ttl * 1000).unref() + resolve({ ok: true, sessionId }) + } + this.on(`ack:${sessionId}`, onAck as any) + }) + } + + private bindLocalServer(sessionId: string, port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer((sock: Socket) => { + const sess = this.clientSessions.get(sessionId) + if (!sess) { sock.destroy(); return } + // 单流: 同一 session 只允许一个活跃连接 + if (sess.activeStream) { + try { sock.write('Forward: 抢占中, 新连接已替换\r\n') } catch {} + try { sock.destroy() } catch {} + return + } + sess.activeStream = sock + console.log(`[forward] client connected to 127.0.0.1:${port} (self-out session=${sessionId.slice(0, 8)})`) + sock.on('data', (buf: Buffer) => { + if (buf.length === 0) return + sess.bytesIn += buf.length + if (!this.applyRateLimit(sess, buf.length)) return + // 字节: 本地客户端 → 控制端 → WS (c2s) → 对方 client → 对方目标 + this.sendChunked(sess.peerId, sessionId, 'c2s', buf, false) + }) + sock.on('close', () => { + if (sess.activeStream === sock) sess.activeStream = undefined + console.log(`[forward] client disconnected (session=${sessionId.slice(0, 8)})`) + // 通知对方: c2s 半关 + const f: ForwardFrame = { type: 'data', sessionId, dir: 'c2s', data: '', fin: true } + this.chatClient.send({ type: 'forward', payload: f } as WsFrame, sess.peerId) + }) + sock.on('error', () => { try { sock.destroy() } catch {} }) + }) + server.on('error', (e) => { try { server.close() } catch {} ; reject(e) }) + server.listen(port, '127.0.0.1', () => resolve(server)) + }) + } + + // self-in: 我方 (控制端) 连自己的 targetHost:targetPort + private connectLocalClient(sessionId: string, peerId: string, host: string, port: number): Socket { + const sess = this.clientSessions.get(sessionId) + if (!sess) throw new Error('session not found') + const sock = createConnection({ host, port }) + console.log(`[forward] self-in: control connecting to ${host}:${port} (session=${sessionId.slice(0, 8)})`) + sock.on('connect', () => { + sess.activeStream = sock + console.log(`[forward] self-in: control connected to ${host}:${port}`) + }) + sock.on('data', (buf: Buffer) => { + if (buf.length === 0) return + sess.bytesIn += buf.length // bytesIn 此时含义: 控制端 TCP client 收到的字节 + if (!this.applyRateLimit(sess, buf.length)) return + // self-in: 控制端 TCP client (本地 target) → WS (s2c) → 对方 server → 对方 client (访问 127.0.0.1:listenPort 的 app) + this.sendChunked(peerId, sessionId, 's2c', buf, false) + }) + sock.on('close', () => { + if (sess.activeStream === sock) sess.activeStream = undefined + console.log(`[forward] self-in: control local client closed`) + const f: ForwardFrame = { type: 'data', sessionId, dir: 's2c', data: '', fin: true } + this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId) + this.cleanupClientSession(sessionId, 'local-target-closed') + }) + sock.on('error', (e) => { + console.warn(`[forward] self-in local client error: ${e.message}`) + this.emit('sessionError', { sessionId, peerId, error: e.message }) + try { sock.destroy() } catch {} + }) + return sock + } + + private sendChunked(peerId: string, sessionId: string, dir: 'c2s' | 's2c', buf: Buffer, fin: boolean) { + let remaining = buf + while (remaining.length > 0) { + const chunk = remaining.subarray(0, MAX_FRAME_BYTES) + remaining = remaining.subarray(MAX_FRAME_BYTES) + const lastAndFin = fin && remaining.length === 0 + const f: ForwardFrame = { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin } + this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId) + } + if (fin && buf.length === 0) { + const f: ForwardFrame = { type: 'data', sessionId, dir, data: '', fin: true } + this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId) + } + } + + private applyRateLimit(sess: ClientSession, bytes: number): boolean { + const s = getSettings() + if (!s.forwardMaxBytesPerSec || s.forwardMaxBytesPerSec <= 0) return true + if (!sess.speed) sess.speed = { tokens: s.forwardMaxBytesPerSec, last: Date.now() } + const now = Date.now() + const dt = (now - sess.speed.last) / 1000 + sess.speed.tokens = Math.min(s.forwardMaxBytesPerSec, sess.speed.tokens + dt * s.forwardMaxBytesPerSec) + sess.speed.last = now + if (sess.speed.tokens < bytes) return false + sess.speed.tokens -= bytes + return true + } + + cleanupClientSession(sessionId: string, reason = 'user') { + const s = this.clientSessions.get(sessionId) + if (!s) return + try { s.server?.close() } catch {} + try { s.client?.destroy() } catch {} + try { s.activeStream?.destroy() } catch {} + this.clientSessions.delete(sessionId) + // 通知对方 + const f: CloseF = { type: 'close', sessionId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut } + this.chatClient.send({ type: 'forward', payload: f } as WsFrame, s.peerId) + this.emit('sessionClosed', { sessionId, peerId: s.peerId, direction: s.direction, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' }) + recordAudit({ action: 'forward.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } }) + } + + // UI 列出 (控制端) 全部 session: 包括 self-out (我 server) 和 self-in (我 client) + listClientSessions() { + return Array.from(this.clientSessions.values()).map(s => ({ + sessionId: s.sessionId, + peerId: s.peerId, + direction: s.direction, + listenPort: s.listenPort, + targetHost: s.targetHost, + targetPort: s.targetPort, + bytesIn: s.bytesIn, + bytesOut: s.bytesOut, + expiresAt: s.expiresAt, + side: 'client' as const, + })) + } + + // UI 列出 (被控端) 全部 session: self-out (我 client 连自己 target) 和 self-in (我 server bind) + listServerSessions() { + return Array.from(this.serverSessions.values()).map(s => ({ + sessionId: s.sessionId, + peerId: s.peerId, + direction: s.direction, + listenPort: s.listenPort, + targetHost: s.targetHost, + targetPort: s.targetPort, + bytesIn: s.bytesIn, + bytesOut: s.bytesOut, + expiresAt: s.expiresAt, + side: 'server' as const, + })) + } + + // ===== 被控端 / 控制端 共用: 根据 sessionId 落点分发 ===== + handleIncoming(peer: DeviceInfo, f: ForwardFrame) { + switch (f.type) { + case 'open': + // open 永远是被控端收到 + return this.handleOpen(peer, f) + case 'data': { + // 数据帧: 哪个 side 持有 sessionId 就交给哪个 side 处理 + if (this.serverSessions.has(f.sessionId)) return this.handleServerData(peer, f) + if (this.clientSessions.has(f.sessionId)) return this.handleClientData(peer, f) + // 未知 session, 忽略 (可能已经关闭) + return + } + case 'close': { + // close 两边都可能收到 (任意一方主动关闭) + if (this.serverSessions.has(f.sessionId)) { + this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer') + return + } + if (this.clientSessions.has(f.sessionId)) { + this.cleanupClientSession(f.sessionId, f.reason || 'closed-by-peer') + return + } + return + } + case 'ack': + this.emit(`ack:${f.sessionId}`, f.ok, f.reason) + return + } + } + + // 控制端 (clientSessions) 收到的 data 帧 → 写到 activeStream (TCP server 已 accept 的连接 或 self-in 下的 TCP client) + private handleClientData(_peer: DeviceInfo, f: DataFrame) { + const sess = this.clientSessions.get(f.sessionId) + if (!sess || !sess.activeStream) return + if (f.fin) { + try { sess.activeStream.end() } catch {} + return + } + try { + const buf = Buffer.from(f.data, 'base64') + sess.bytesOut += buf.length + if (!sess.activeStream.destroyed) sess.activeStream.write(buf) + } catch (e: any) { + console.warn('[forward] client-side write failed:', e?.message) + } + } + + private async handleOpen(peer: DeviceInfo, f: OpenFrame) { + const s = getSettings() + if (!s.remoteEnabled) { + this.sendAck(peer.deviceId, f.sessionId, false, '对方关闭了远程功能') + recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied', payload: { reason: 'remote-disabled' } }) + return + } + if (SELF_PORTS.has(f.listenPort) || SELF_PORTS.has(f.targetPort)) { + this.sendAck(peer.deviceId, f.sessionId, false, '端口指向本机自身端口') + recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied', payload: { reason: 'self-port' } }) + return + } + + if (shouldAsk(peer.deviceId, 'forward')) { + const detail = f.direction === 'self-in' + ? `对方请求把自己的 ${f.targetHost}:${f.targetPort} 暴露到我方 127.0.0.1:${f.listenPort}` + : `对方请求访问我方 ${f.targetHost}:${f.targetPort} (经由对方 127.0.0.1:${f.listenPort})` + const reqId = randomUUID() + const reqPromise = enqueueApproval({ + requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'forward', detail, ts: Date.now(), + }) + this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'forward', detail }) + const verdict = await reqPromise + if (!verdict.ok) { + this.sendAck(peer.deviceId, f.sessionId, false, '用户拒绝') + recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' }) + return + } + if (verdict.remember) markApproved(peer.deviceId, 'forward') + } + + const session: ServerSession = { + sessionId: f.sessionId, + ownerId: peer.deviceId, + peerId: peer.deviceId, + direction: f.direction, + listenPort: f.listenPort, + targetHost: f.targetHost, + targetPort: f.targetPort, + expiresAt: Date.now() + f.ttlSec * 1000, + bytesIn: 0, bytesOut: 0, + } + + if (f.direction === 'self-out') { + // self-out: 我方 = client (连自己的 targetHost:targetPort) + const sock = createConnection({ host: f.targetHost, port: f.targetPort }) + sock.on('connect', () => { + session.client = sock + session.activeStream = sock + this.serverSessions.set(f.sessionId, session) + this.sendAck(peer.deviceId, f.sessionId, true) + this.emit('sessionOpened', { sessionId: f.sessionId, peerId: peer.deviceId, direction: f.direction, listenPort: f.listenPort, targetHost: f.targetHost, targetPort: f.targetPort, side: 'server' }) + setTimeout(() => this.cleanupServerSession(f.sessionId, 'ttl-expired'), f.ttlSec * 1000).unref() + recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'ok', payload: { direction: f.direction, targetHost: f.targetHost, targetPort: f.targetPort } }) + }) + sock.on('data', (buf: Buffer) => { + if (buf.length === 0) return + session.bytesOut += buf.length + // self-out: 我方 (server side) client 收到目标 → s2c → 控制端 → 控制端 server → 控制端 app + this.sendChunked(peer.deviceId, f.sessionId, 's2c', buf, false) + }) + const onEnd = (reason: string) => { try { sock.destroy() } catch {} ; this.cleanupServerSession(f.sessionId, reason) } + sock.on('close', () => onEnd('target-closed')) + sock.on('error', (e: Error) => { + this.sendAck(peer.deviceId, f.sessionId, false, `连目标失败: ${e.message}`) + this.emit('sessionError', { sessionId: f.sessionId, peerId: peer.deviceId, error: e.message }) + recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { error: e.message } }) + onEnd('target-error') + }) + } else { + // self-in: 我方 = server (bind 127.0.0.1:listenPort) + try { + const server = await this.bindServerSideServer(session, peer) + session.server = server + this.serverSessions.set(f.sessionId, session) + this.sendAck(peer.deviceId, f.sessionId, true) + this.emit('sessionOpened', { sessionId: f.sessionId, peerId: peer.deviceId, direction: f.direction, listenPort: f.listenPort, targetHost: f.targetHost, targetPort: f.targetPort, side: 'server' }) + setTimeout(() => this.cleanupServerSession(f.sessionId, 'ttl-expired'), f.ttlSec * 1000).unref() + recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'ok', payload: { direction: f.direction, listenPort: f.listenPort } }) + } catch (e: any) { + this.sendAck(peer.deviceId, f.sessionId, false, `本地监听失败: ${e?.message || e}`) + recordAudit({ action: 'forward.open', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { phase: 'local-bind', error: String(e?.message || e) } }) + } + } + } + + private bindServerSideServer(session: ServerSession, peer: DeviceInfo): Promise { + return new Promise((resolve, reject) => { + const port = session.listenPort! + const server = createServer((sock: Socket) => { + if (session.activeStream) { + try { sock.write('Forward: 抢占中, 新连接已替换\r\n') } catch {} + try { sock.destroy() } catch {} + return + } + session.activeStream = sock + console.log(`[forward] self-in: peer-side app connected to 127.0.0.1:${port}`) + sock.on('data', (buf: Buffer) => { + if (buf.length === 0) return + session.bytesIn += buf.length + // 对方 app → 我方 server → WS (c2s) → 控制端 client → 控制端目标 + this.sendChunked(peer.deviceId, session.sessionId, 'c2s', buf, false) + }) + sock.on('close', () => { + if (session.activeStream === sock) session.activeStream = undefined + const f: ForwardFrame = { type: 'data', sessionId: session.sessionId, dir: 'c2s', data: '', fin: true } + this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peer.deviceId) + }) + sock.on('error', () => { try { sock.destroy() } catch {} }) + }) + server.on('error', (e) => { try { server.close() } catch {} ; reject(e) }) + server.listen(port, '127.0.0.1', () => resolve(server)) + }) + } + + private handleServerData(_peer: DeviceInfo, f: DataFrame) { + const sess = this.serverSessions.get(f.sessionId) + if (!sess) return + if (f.fin) { + // 半关信号: 关掉我方相关的 socket + if (sess.direction === 'self-out' && sess.client) { try { sess.client.end() } catch {} } + if (sess.direction === 'self-in' && sess.activeStream) { try { sess.activeStream.end() } catch {} } + return + } + try { + const buf = Buffer.from(f.data, 'base64') + sess.bytesIn += buf.length + if (sess.direction === 'self-out') { + // self-out: 我方是 client → 写给自己的目标 + if (sess.client) sess.client.write(buf) + } else { + // self-in: 我方是 server → 写给访问我方 127.0.0.1:listenPort 的 app + if (sess.activeStream) sess.activeStream.write(buf) + } + } catch (e: any) { + console.warn('[forward] write failed:', e?.message) + } + } + + cleanupServerSession(sessionId: string, reason: string) { + const sess = this.serverSessions.get(sessionId) + if (!sess) return + try { sess.client?.destroy() } catch {} + try { sess.server?.close() } catch {} + try { sess.activeStream?.destroy() } catch {} + this.serverSessions.delete(sessionId) + this.emit('sessionClosed', { sessionId, peerId: sess.peerId, direction: sess.direction, reason, bytesIn: sess.bytesIn, bytesOut: sess.bytesOut, side: 'server' }) + recordAudit({ action: 'forward.close', source: sess.peerId, sessionId, result: 'closed', bytesIn: sess.bytesIn, bytesOut: sess.bytesOut }) + } + + private sendAck(peerId: string, sessionId: string, ok: boolean, reason?: string) { + const f: AckFrame = { type: 'ack', sessionId, ok, reason } + this.chatClient.send({ type: 'forward', payload: f } as WsFrame, peerId) + } + + // ===== 控制端收到对端发来的 data 帧 (s2c) → 写给本地 stream ===== + handleIncomingFromClient(_peerId: string, sessionId: string, dir: 's2c', data: string, fin?: boolean) { + const sess = this.clientSessions.get(sessionId) + if (!sess) return + if (!sess.activeStream) return + if (fin) { + try { sess.activeStream.end() } catch {} + return + } + try { + const buf = Buffer.from(data, 'base64') + sess.bytesOut += buf.length + sess.activeStream.write(buf) + } catch (e: any) { + console.warn('[forward] write local failed:', e?.message) + } + } + + // ===== 维护 ===== + + onPeerDisconnected(peerId: string) { + for (const [sid, sess] of this.clientSessions) { + if (sess.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected') + } + for (const [sid, sess] of this.serverSessions) { + if (sess.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') + } + + resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') { + resetApproval(peerId, kind) + } +} \ No newline at end of file diff --git a/src/main/remote/shell.ts b/src/main/remote/shell.ts new file mode 100644 index 0000000..8f9ca01 --- /dev/null +++ b/src/main/remote/shell.ts @@ -0,0 +1,54 @@ +// 跨平台默认 shell 选择 +import { platform } from 'node:process' +import { execSync } from 'node:child_process' + +export interface ShellSpec { + file: string + args: string[] + label: string +} + +function which(bin: string): string | null { + try { + const cmd = platform === 'win32' ? `where ${bin}` : `command -v ${bin}` + const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().split(/\r?\n/)[0] + return out || null + } catch { + return null + } +} + +export function defaultShell(): ShellSpec { + switch (platform) { + case 'win32': { + // 优先 PowerShell 7 (pwsh), 否则 Windows PowerShell 5 (powershell) + const pwsh = which('pwsh.exe') || which('pwsh') + if (pwsh) return { file: pwsh, args: ['-NoLogo'], label: 'PowerShell 7' } + const ps5 = which('powershell.exe') || which('powershell') + if (ps5) return { file: ps5, args: ['-NoLogo'], label: 'Windows PowerShell' } + return { file: 'powershell.exe', args: ['-NoLogo'], label: 'PowerShell' } + } + case 'darwin': + case 'linux': { + const envShell = process.env.SHELL + if (envShell) { + const name = envShell.split('/').pop() || envShell + return { file: envShell, args: [], label: name } + } + if (platform === 'darwin') { + const zsh = which('zsh') + if (zsh) return { file: zsh, args: [], label: 'zsh' } + const bash = which('bash') + if (bash) return { file: bash, args: ['-l'], label: 'bash' } + } else { + const bash = which('bash') + if (bash) return { file: bash, args: ['-l'], label: 'bash' } + const sh = which('sh') + if (sh) return { file: sh, args: [], label: 'sh' } + } + return { file: '/bin/sh', args: [], label: 'sh' } + } + default: + return { file: '/bin/sh', args: [], label: 'sh' } + } +} \ No newline at end of file diff --git a/src/main/remote/terminal.ts b/src/main/remote/terminal.ts new file mode 100644 index 0000000..dd59729 --- /dev/null +++ b/src/main/remote/terminal.ts @@ -0,0 +1,315 @@ +// 远程终端: 控制端发起 open, 被控端 spawn 本地 PTY, 双向字节流 (base64) 走现有 WS +import { EventEmitter } from 'node:events' +import { randomUUID } from 'node:crypto' +// @ts-ignore - @lydell/node-pty 没有自带 .d.ts, 这里只使用其运行时 API +import nodePty from '@lydell/node-pty' +import type { DeviceInfo, TerminalFrame, WsFrame } from '../protocol' +import type { ChatClient } from '../chat-client' +import { defaultShell } from './shell' +import { shouldAsk, markApproved, enqueueApproval, resetApproval } from './approval' +import { recordAudit } from '../db' + +// @lydell/node-pty 1.x 导出: const pty = require('@lydell/node-pty'); pty.spawn(...) +const pty: { spawn: (file: string, args: string[] | string, opts?: any) => any } = (nodePty as any).default ?? nodePty + +export interface TerminalManagerEvents { + // UI 收到这些事件做弹窗 / 显示状态 + approvalRequested: (req: { peerId: string; peerName: string; kind: 'terminal'; detail: string; requestId: string }) => void + sessionOpened: (sessionId: string, peerId: string, info: { shell: string; rows: number; cols: number }) => void + sessionClosed: (sessionId: string, peerId: string, reason?: string) => void + output: (sessionId: string, bytes: Buffer, peerId: string) => void +} + +interface Session { + sessionId: string + ownerId: string // 谁开的 (控制端 deviceId) + peerId: string // 被控端 deviceId (= self 本机, 当本机是 server) + proc: any // IPty from @lydell/node-pty + readOnly: boolean + shell: string + cols: number + rows: number + createdAt: number +} + +type OpenFrame = Extract +type InputFrame = Extract +type ResizeFrame = Extract +type CloseFrame = Extract +type AckFrame = Extract + +export class TerminalManager extends EventEmitter { + // 作为被控端 (server): 接收对端的 open + private sessions = new Map() + // 作为控制端 (client): 自己发起的 session + private outgoing = new Map() + private chatClient: ChatClient + private selfId: string + + constructor(chatClient: ChatClient, selfId: string) { + super() + this.chatClient = chatClient + this.selfId = selfId + } + + // ===== 控制端 ===== + + // 发起一次 open, 等被控端 ack; 被控端同意后立刻开始收 output + async openOnPeer(peerId: string, opts: { rows: number; cols: number; shell?: string; readOnly?: boolean } = { rows: 24, cols: 80 }): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { + const sessionId = randomUUID() + const frame: OpenFrame = { type: 'open', sessionId, rows: opts.rows, cols: opts.cols, ...(opts.shell ? { shell: opts.shell } : {}), readOnly: !!opts.readOnly } + const sent = this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, peerId) + if (!sent) { + recordAudit({ action: 'terminal.open', target: peerId, result: 'error', payload: { reason: 'ws closed' } }) + return { ok: false, reason: '对方离线' } + } + this.outgoing.set(sessionId, { + peerId, + createdAt: Date.now(), + rows: opts.rows, + cols: opts.cols, + shell: opts.shell || '', + readOnly: !!opts.readOnly, + }) + // 等待 ack: 由 handleIncoming 的 'ack' 分支 resolve + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (this.outgoing.has(sessionId)) { + this.outgoing.delete(sessionId) + resolve({ ok: false, reason: '超时 (对方 30s 内未响应)' }) + } + }, 30_000) + const onResolve = (ok: boolean, reason?: string) => { + clearTimeout(timer) + this.off(`ack:${sessionId}`, onResolve as any) + resolve({ ok, reason, sessionId }) + } + this.on(`ack:${sessionId}`, onResolve as any) + }) + } + + sendInput(sessionId: string, bytes: Buffer | string) { + const info = this.outgoing.get(sessionId) + if (!info) return false + if (info.readOnly) return false + const b = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes, 'utf8') + const frame: InputFrame = { type: 'input', sessionId, data: b.toString('base64') } + return this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, info.peerId) + } + + resize(sessionId: string, rows: number, cols: number) { + const info = this.outgoing.get(sessionId) + if (!info) return false + info.rows = rows; info.cols = cols + const frame: ResizeFrame = { type: 'resize', sessionId, rows, cols } + return this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, info.peerId) + } + + close(sessionId: string, reason = 'user') { + const info = this.outgoing.get(sessionId) + if (!info) return + this.outgoing.delete(sessionId) + const frame: CloseFrame = { type: 'close', sessionId, reason } + this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, info.peerId) + } + + // ===== 被控端 ===== + + // 收到来自对端的 terminal 帧 + async handleIncoming(peer: DeviceInfo, f: TerminalFrame) { + switch (f.type) { + case 'open': + return this.handleOpen(peer, f) + case 'input': + return this.handleInput(peer, f) + case 'resize': + return this.handleResize(peer, f) + case 'close': + return this.handleClose(peer, f) + case 'ack': + // 控制端收到我们的 open 后, ack 给对端 + this.emit(`ack:${f.sessionId}`, f.ok, f.reason) + return + case 'output': + return // 协议层不应该出现 + } + } + + private async handleOpen(peer: DeviceInfo, f: OpenFrame) { + // 1. 授权闸门 + if (shouldAsk(peer.deviceId, 'terminal')) { + const reqId = randomUUID() + const reqPromise = enqueueApproval({ + requestId: reqId, + peerId: peer.deviceId, + peerName: peer.name, + kind: 'terminal', + detail: `对方请求打开终端 (rows=${f.rows} cols=${f.cols}${f.readOnly ? ', 只读' : ''})`, + ts: Date.now(), + }) + this.emit('approvalRequested', { + requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'terminal', + detail: `请求打开终端 (rows=${f.rows} cols=${f.cols}${f.readOnly ? ', 只读' : ''})`, + }) + const verdict = await reqPromise + if (!verdict.ok) { + this.sendAck(peer.deviceId, f.sessionId, false, '用户拒绝') + recordAudit({ action: 'terminal.open', source: peer.deviceId, sessionId: f.sessionId, result: 'denied', payload: { rows: f.rows, cols: f.cols } }) + return + } + if (verdict.remember) markApproved(peer.deviceId, 'terminal') + } + + // 2. spawn pty + try { + const shell = defaultShell() + const proc = pty.spawn(shell.file, shell.args, { + name: 'xterm-256color', + cols: f.cols || 80, + rows: f.rows || 24, + cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(), + env: { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8', COLORTERM: 'truecolor' } as any, + encoding: null, + }) + const session: Session = { + sessionId: f.sessionId, + ownerId: peer.deviceId, + peerId: peer.deviceId, + proc, + readOnly: !!f.readOnly, + shell: shell.label, + cols: f.cols || 80, + rows: f.rows || 24, + createdAt: Date.now(), + } + this.sessions.set(f.sessionId, session) + this.sendAck(peer.deviceId, f.sessionId, true) + this.emit('sessionOpened', f.sessionId, peer.deviceId, { shell: shell.label, rows: session.rows, cols: session.cols }) + + proc.onData((data: string | Buffer) => { + // encoding:null 时 data 是 Buffer; encoding:'utf8' 时是 string + // 我们以 Buffer 形式发给对端, 对端 xterm.write 直接接 Uint8Array + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8') + const out: Extract = { type: 'output', sessionId: f.sessionId, data: buf.toString('base64') } + this.chatClient.send({ type: 'terminal', payload: out } as WsFrame, peer.deviceId) + this.emit('output', f.sessionId, buf, peer.deviceId) + }) + proc.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => { + const reason = `退出 (code=${exitCode}${signal ? `, signal=${signal}` : ''})` + this.sessions.delete(f.sessionId) + const out: CloseFrame = { type: 'close', sessionId: f.sessionId, reason } + this.chatClient.send({ type: 'terminal', payload: out } as WsFrame, peer.deviceId) + this.emit('sessionClosed', f.sessionId, peer.deviceId, reason) + recordAudit({ action: 'terminal.exit', source: peer.deviceId, sessionId: f.sessionId, result: 'closed', payload: { exitCode, signal } }) + }) + + recordAudit({ action: 'terminal.open', source: peer.deviceId, sessionId: f.sessionId, result: 'ok', payload: { shell: shell.label, rows: session.rows, cols: session.cols } }) + } catch (e: any) { + this.sendAck(peer.deviceId, f.sessionId, false, `spawn 失败: ${e?.message || e}`) + recordAudit({ action: 'terminal.open', source: peer.deviceId, sessionId: f.sessionId, result: 'error', payload: { error: String(e?.message || e) } }) + } + } + + private handleInput(_peer: DeviceInfo, f: InputFrame) { + const s = this.sessions.get(f.sessionId) + if (!s) return + if (s.readOnly) return + try { + const buf = Buffer.from(f.data, 'base64') + s.proc.write(buf) + } catch (e: any) { + console.warn('[terminal] write failed:', e?.message) + } + } + + private handleResize(_peer: DeviceInfo, f: ResizeFrame) { + const s = this.sessions.get(f.sessionId) + if (!s) return + try { + s.proc.resize(Math.max(2, f.cols), Math.max(2, f.rows)) + s.rows = f.rows; s.cols = f.cols + } catch (e: any) { + console.warn('[terminal] resize failed:', e?.message) + } + } + + private handleClose(peer: DeviceInfo, f: CloseFrame) { + const s = this.sessions.get(f.sessionId) + if (!s) return + try { s.proc.kill() } catch {} + this.sessions.delete(f.sessionId) + this.emit('sessionClosed', f.sessionId, peer.deviceId, f.reason) + recordAudit({ action: 'terminal.close', source: peer.deviceId, sessionId: f.sessionId, result: 'closed', payload: { reason: f.reason } }) + } + + // 控制端收到被控端的 output -> 转发给 UI + onRemoteOutput(cb: (sessionId: string, bytes: Buffer, peerId: string) => void) { + this.on('output', cb) + } + + private sendAck(peerId: string, sessionId: string, ok: boolean, reason?: string) { + const frame: AckFrame = { type: 'ack', sessionId, ok, reason } + this.chatClient.send({ type: 'terminal', payload: frame } as WsFrame, peerId) + } + + // ===== 维护 ===== + + stop() { + for (const s of this.sessions.values()) { + try { s.proc.kill() } catch {} + } + this.sessions.clear() + this.outgoing.clear() + } + + // 被控端 ws 断线 -> 关掉所有属于这个对端的 session + onPeerDisconnected(peerId: string) { + for (const [sid, s] of this.sessions) { + if (s.peerId === peerId) { + try { s.proc.kill() } catch {} + this.sessions.delete(sid) + this.emit('sessionClosed', sid, peerId, 'peer-disconnected') + } + } + // 控制端: 告知 UI (renderer 通过 device:wsState 也会刷新) + for (const [sid, info] of this.outgoing) { + if (info.peerId === peerId) { + this.outgoing.delete(sid) + } + } + } + + // 列出本机作为被控端当前所有 session (UI: 谁正在用我) + listServerSessions() { + return Array.from(this.sessions.values()).map(s => ({ + sessionId: s.sessionId, + peerId: s.peerId, + shell: s.shell, + rows: s.rows, + cols: s.cols, + readOnly: s.readOnly, + createdAt: s.createdAt, + })) + } + + listClientSessions() { + return Array.from(this.outgoing.entries()).map(([sid, info]) => ({ + sessionId: sid, + peerId: info.peerId, + shell: info.shell, + rows: info.rows, + cols: info.cols, + readOnly: info.readOnly, + createdAt: info.createdAt, + })) + } + + hasClientSession(sessionId: string) { + return this.outgoing.has(sessionId) + } + + // 用户在 UI 上点 "拒绝所有未决请求" + resetPeer(peerId: string, kind?: 'terminal' | 'forward' | 'usb') { + resetApproval(peerId, kind) + } +} \ No newline at end of file diff --git a/src/main/remote/usb.ts b/src/main/remote/usb.ts new file mode 100644 index 0000000..2809090 --- /dev/null +++ b/src/main/remote/usb.ts @@ -0,0 +1,944 @@ +// 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 + } +} + +// ===== 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 + // USB 控制/批量 reqId -> 配对 response + pendingCtrl: Map void> + pendingBulk: Map 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() + private serverSessions = new Map() + private chatClient: ChatClient + // list 请求的待回包 + private pendingLists = new Map void>() + + constructor(chatClient: ChatClient) { + super() + this.chatClient = chatClient + } + + // ====== 本地设备列举 ====== + + // 列本机的: 串口 + libusb 设备 (合并) + async listLocal(): Promise { + 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 (用 webusb.getDevices, 不会 claim interface, 安全) + if (usbMod) { + try { + // 用 webusb.getDevices() 不需要 detach kernel driver, 是只读枚举 + const devices: any[] = await usbMod.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 = { 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 -b ` 真正 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 + 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, 设备在我方 + private async serverAttach(peerId: string, busId: string, kind: 'serial'|'usb'|'usbip', config: UsbAttachConfig): Promise<{ ok: boolean; reason?: string; sessionId?: string }> { + // 1. 解析 busId 找到本机真实路径/handle + const local = await this.listLocal() + const dev = local.find(d => d.busId === busId) + if (!dev) { + recordAudit({ action: 'usb.attach', target: peerId, sessionId: randomUUID(), result: 'error', payload: { phase: 'no-device', busId, kind } }) + return { ok: false, reason: '本机找不到该设备' } + } + + // 2. 开设备 + const sessionId = randomUUID() + 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 + 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((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 + // 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 configVal = c.configurationValue ?? dev2.configuration?.configurationValue ?? 1 + await dev2.selectConfiguration(configVal) + const ifaceNum = c.interfaceNumber ?? 0 + if (platform === 'linux' && c.detachKernelDriver !== false) { + try { await dev2.detachKernelDriver(ifaceNum) } catch {} + } + 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) { + 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) { + 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 一样的开设备流程 + const r = await this.serverAttach(peer.deviceId, f.busId, f.config.kind, f.config) + 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) { + 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) { + try { + const dev = sess.usbDev + const setup: any = { + requestType: f.setup.requestType, + recipient: (f.setup.requestType & 0x1f) as any, // low 5 bits + request: f.setup.request, + value: f.setup.value, + index: f.setup.index, + } + 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) { + 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 { + 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): 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((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((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 -b ` (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() +} + +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 模块)` +} diff --git a/src/main/remote/vcom.ts b/src/main/remote/vcom.ts new file mode 100644 index 0000000..652ec0d --- /dev/null +++ b/src/main/remote/vcom.ts @@ -0,0 +1,249 @@ +// 虚拟串口对 (一对 PTY/COM, 一头给本机 app, 一头给我们读) +// Linux/macOS: 调 socat 创建 PTY 配对 (link 到稳定路径, 不需 root) +// Windows: 自动下载 com0com 装, 然后用 setupc.exe 创建 COM 配对 (需 admin) +import { spawn, exec as execCb, ChildProcess } from 'node:child_process' +import { existsSync, mkdirSync, unlinkSync, rmdirSync, createWriteStream, statSync, openSync, readSync, closeSync } from 'node:fs' +import { promises as fsp } from 'node:fs' +import { promisify } from 'node:util' +import { platform } from 'node:process' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomBytes } from 'node:crypto' +// @ts-ignore - sudo-prompt 是 CJS, 没自带 .d.ts +import sudo from 'sudo-prompt' + +const execAsync = promisify(execCb) + +export interface VirtualSerialInfo { + // 给用户 app 用的路径 (e.g. COM5, /tmp/lnm-vcom-xxx/user) + userPath: string + // 我们自己开的另一头 (socat 模式, 这是内部 link; com0com 模式, 这是 COM6 这种) + internalPath: string + platform: 'linux' | 'darwin' | 'win32' + cleanup: () => Promise +} + +const DEFAULT_BAUD = 115200 + +function runElevated(cmd: string): Promise<{ ok: boolean; stdout?: string; stderr?: string }> { + return new Promise((resolve) => { + try { + sudo.exec(cmd, { name: 'LocalNetMsg' }, (error?: Error | undefined, stdout?: string | Buffer, stderr?: string | Buffer) => { + const out = stdout ? String(stdout) : '' + const errOut = stderr ? String(stderr) : '' + if (error) resolve({ ok: false, stdout: out, stderr: errOut || error.message }) + else resolve({ ok: true, stdout: out, stderr: errOut }) + }) + } catch (e: any) { + resolve({ ok: false, stderr: String(e?.message || e) }) + } + }) +} + +export async function createVirtualSerialPair(opts: { + userPath?: string // 可选: 用户指定名字 (e.g. "COM5"); 不指定则自动分配 +}): Promise { + if (platform === 'linux' || platform === 'darwin') { + return createViaSocat(opts) + } + if (platform === 'win32') { + return createViaCom0com(opts) + } + throw new Error(`当前平台不支持虚拟串口: ${platform}`) +} + +async function checkSocat(): Promise { + // 1. PATH + try { + const { stdout } = await execAsync('which socat || true') + if (stdout.trim()) return stdout.trim() + } catch {} + // 2. macOS 常见位置 + if (platform === 'darwin') { + const candidates = ['/opt/homebrew/bin/socat', '/usr/local/bin/socat', '/usr/bin/socat'] + for (const c of candidates) if (existsSync(c)) return c + } + // 3. Linux 常见位置 + if (platform === 'linux') { + const candidates = ['/usr/bin/socat', '/usr/local/bin/socat', '/snap/bin/socat'] + for (const c of candidates) if (existsSync(c)) return c + } + throw new Error('需要 socat 来创建虚拟串口. Linux: apt install socat (Debian/Ubuntu) | dnf install socat (Fedora). macOS: brew install socat') +} + +async function createViaSocat(opts: { userPath?: string }): Promise { + const socatPath = await checkSocat() + const id = randomBytes(3).toString('hex') + const dir = join(tmpdir(), `lnm-vcom-${id}`) + mkdirSync(dir, { recursive: true }) + + // 用户端口路径: 如果用户指定就用, 否则用 dir 下的 link + const userPath = opts.userPath || join(dir, 'user') + // 内部端口路径: dir 下的另一个 link + const internalPath = join(dir, 'internal') + + // socat 命令: 两个 PTY, 双向, raw 模式 (无字符处理) + // -d -d 输出详细日志到 stderr + const proc: ChildProcess = spawn(socatPath, [ + '-d', '-d', + `pty,raw,echo=0,link=${userPath}`, + `pty,raw,echo=0,link=${internalPath}`, + ], { stdio: ['ignore', 'pipe', 'pipe'], detached: false }) + + // 收集 stderr (出错时给提示) + let stderrBuf = '' + proc.stderr?.on('data', (b: Buffer) => { stderrBuf += b.toString() }) + + // 等待 socat 把两个 link 都建好 (socat 是先 fork, 然后再 link) + let retries = 50 + while (retries > 0) { + if (existsSync(userPath) && existsSync(internalPath)) break + if (proc.exitCode !== null) { + // socat 退出了 + try { rmdirSync(dir) } catch {} + throw new Error(`socat 启动失败: ${stderrBuf.trim() || '未知错误'}`) + } + await new Promise(r => setTimeout(r, 100)) + retries-- + } + if (!existsSync(userPath) || !existsSync(internalPath)) { + try { proc.kill() } catch {} + try { rmdirSync(dir) } catch {} + throw new Error('socat 启动超时 (5s 内未创建 link). 详细: ' + (stderrBuf.trim() || '无')) + } + + return { + userPath, + internalPath, + platform: platform as 'linux' | 'darwin', + cleanup: async () => { + try { proc.kill() } catch {} + await new Promise(r => setTimeout(r, 100)) + try { unlinkSync(userPath) } catch {} + try { unlinkSync(internalPath) } catch {} + try { rmdirSync(dir) } catch {} + }, + } +} + +// ============== Windows com0com ============== + +async function findCom0comSetupc(): Promise { + const candidates = [ + 'C:\\Program Files\\com0com\\setupc.exe', + 'C:\\Program Files (x86)\\com0com\\setupc.exe', + ] + for (const p of candidates) { + if (existsSync(p)) return p + } + return null +} + +async function downloadCom0comInstaller(): Promise { + const destDir = join(homedir(), 'AppData', 'Local', 'LocalNetMsg', 'tools') + await fsp.mkdir(destDir, { recursive: true }) + const dest = join(destDir, 'com0com-setup.exe') + + if (existsSync(dest) && statSync(dest).size > 100_000) { + return dest // 已下载 + } + + // com0com 3.0.0 signed installer + const url = 'https://sourceforge.net/projects/com0com/files/com0com/3.0.0/com0com-3.0.0.0-i386-and-x64-signed.exe/download' + const https = await import('node:https') + await new Promise((resolve, reject) => { + const file = createWriteStream(dest) + const get = (u: string) => (https as any).get(u, (res: any) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + file.close(); get(res.headers.location); return + } + if (res.statusCode !== 200) { reject(new Error('HTTP ' + res.statusCode)); return } + res.pipe(file) + file.on('finish', () => file.close(() => resolve())) + }) + get(url).on('error', reject) + }) + return dest +} + +async function installCom0com(): Promise { + // 检查是否已装 + const existing = await findCom0comSetupc() + if (existing) return existing + + // 下载 + const installer = await downloadCom0comInstaller() + + // 静默安装 (需要 admin); com0com 安装器支持 /S + const r = await runElevated(`"${installer}" /S`) + if (!r.ok) { + throw new Error('com0com 安装失败: ' + (r.stderr || r.stdout || '未知错误')) + } + + // 等安装完 + let retries = 30 + while (retries > 0) { + const p = await findCom0comSetupc() + if (p) return p + await new Promise(r => setTimeout(r, 500)) + retries-- + } + throw new Error('com0com 安装超时, 找不到 setupc.exe') +} + +async function findFreeComNumber(start = 10, end = 99): Promise { + // 列出已用的 COM 端口名 + const used = new Set() + try { + const { stdout } = await execAsync('powershell -NoProfile -Command "[System.IO.Ports.SerialPort]::GetPortNames()"') + for (const m of stdout.matchAll(/COM(\d+)/gi)) { + used.add(parseInt(m[1], 10)) + } + } catch {} + // 也检查 com0com 已有的对 + const setupc = await findCom0comSetupc() + if (setupc) { + try { + const out = await runElevated(`"${setupc}" list`) + for (const m of (out.stdout || '').matchAll(/CNC(\d+)/gi)) { + const n = parseInt(m[1], 10) + // com0com 用内部名 CNC0, CNC1; OS 看到的可能是 COM10, COM11 这种 + // 我们不太能从 CNC 编号反推 COM 编号, 留个安全余量 + if (n > 5) used.add(n) + } + } catch {} + } + for (let i = start; i <= end; i++) { + if (!used.has(i)) return i + } + throw new Error('找不到空闲的 COM 端口号') +} + +async function createViaCom0com(opts: { userPath?: string }): Promise { + const setupc = await installCom0com() + // 找两个连续的 COM 号 + const num1 = await findFreeComNumber(10, 90) + const num2 = await findFreeComNumber(num1 + 1, 99) + if (num2 <= num1) throw new Error('找不到两个连续的 COM 端口号') + const name1 = `CNC${num1}` + const name2 = `CNC${num2}` + + // 创建端口对, 给用户用 COM{num1}, 我们用 COM{num2} + const r = await runElevated(`"${setupc}" install ${name1}=${name2} Permanent`) + if (!r.ok) { + throw new Error('创建 com0com 端口对失败: ' + (r.stderr || r.stdout || '未知错误')) + } + + const userPath = opts.userPath || `COM${num1}` + const internalPath = `COM${num2}` + + return { + userPath, + internalPath, + platform: 'win32', + cleanup: async () => { + // 移除端口对 + await runElevated(`"${setupc}" remove ${name1}`).catch(() => {}) + }, + } +} diff --git a/src/renderer/src/components/remote/ApprovalDialog.vue b/src/renderer/src/components/remote/ApprovalDialog.vue new file mode 100644 index 0000000..3f5c13d --- /dev/null +++ b/src/renderer/src/components/remote/ApprovalDialog.vue @@ -0,0 +1,80 @@ + + + + + \ No newline at end of file diff --git a/src/renderer/src/components/remote/ForwardPanel.vue b/src/renderer/src/components/remote/ForwardPanel.vue new file mode 100644 index 0000000..30cddc9 --- /dev/null +++ b/src/renderer/src/components/remote/ForwardPanel.vue @@ -0,0 +1,211 @@ + + + + + \ No newline at end of file diff --git a/src/renderer/src/components/remote/TerminalPanel.vue b/src/renderer/src/components/remote/TerminalPanel.vue new file mode 100644 index 0000000..296c65f --- /dev/null +++ b/src/renderer/src/components/remote/TerminalPanel.vue @@ -0,0 +1,193 @@ + + + + + \ No newline at end of file diff --git a/src/renderer/src/components/remote/UsbPanel.vue b/src/renderer/src/components/remote/UsbPanel.vue new file mode 100644 index 0000000..a7357b1 --- /dev/null +++ b/src/renderer/src/components/remote/UsbPanel.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/src/renderer/src/stores/remote.ts b/src/renderer/src/stores/remote.ts new file mode 100644 index 0000000..106ad10 --- /dev/null +++ b/src/renderer/src/stores/remote.ts @@ -0,0 +1,274 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { TerminalSessionInfo, ForwardSessionInfo, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbAttachConfig, ApprovalRequestView, AuditEntry } from '@/api' + +interface TerminalChannelState { + buffer: string[] // 字节 base64 数组, UI 端自行解码 + lastWrite: number + openedAt: number + shell: string + rows: number + cols: number + readOnly: boolean + closed: boolean +} + +export const useTerminalStore = defineStore('terminal', () => { + // 客户端: sessionId -> peerId / 状态 + const sessions = ref>({}) + // 字节缓冲: sessionId -> base64 字符串数组 (UI 渲染时一次性 concat -> utf8 -> xterm.write) + const buffers = ref>({}) + + async function open(peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) { + const r = await window.api.terminalOpen(peerId, opts) + if (r.ok && r.sessionId) { + sessions.value[r.sessionId] = { + peerId, + shell: '', + rows: opts?.rows ?? 24, + cols: opts?.cols ?? 80, + readOnly: !!opts?.readOnly, + openedAt: Date.now(), + closed: false, + } + buffers.value[r.sessionId] = [] + } + return r + } + + function onOpened(p: { sessionId: string; peerId: string; shell: string; rows: number; cols: number }) { + const s = sessions.value[p.sessionId] + if (!s) return + s.shell = p.shell; s.rows = p.rows; s.cols = p.cols + } + + function onOutput(p: { sessionId: string; peerId: string; data: string }) { + const buf = buffers.value[p.sessionId] + if (!buf) buffers.value[p.sessionId] = [p.data] + else buf.push(p.data) + } + + function onClosed(p: { sessionId: string; peerId: string; reason?: string }) { + const s = sessions.value[p.sessionId] + if (s) s.closed = true + } + + function takeBuffer(sessionId: string): string { + const buf = buffers.value[sessionId] + if (!buf) return '' + const out = buf.join('') + buffers.value[sessionId] = [] + return out + } + + function appendBuffer(sessionId: string, chunk: string) { + const buf = buffers.value[sessionId] || (buffers.value[sessionId] = []) + buf.push(chunk) + } + + async function input(sessionId: string, dataBase64: string) { + return window.api.terminalInput(sessionId, dataBase64) + } + + async function resize(sessionId: string, rows: number, cols: number) { + return window.api.terminalResize(sessionId, rows, cols) + } + + async function close(sessionId: string, reason?: string) { + return window.api.terminalClose(sessionId, reason) + } + + async function refresh() { + const r = await window.api.terminalListSessions() + // 仅补充 server 端已知 (本机被控) 状态, 不覆盖 client 端 (UI 已建) + return r + } + + const list = computed(() => Object.entries(sessions.value).map(([id, s]) => ({ sessionId: id, ...s }))) + + return { + sessions, buffers, + open, onOpened, onOutput, onClosed, + takeBuffer, appendBuffer, + input, resize, close, refresh, + list, + } +}) + +export const useForwardStore = defineStore('forward', () => { + // 我发起的 (clientSessions): 我方是 TCP server (self-out) 或 TCP client (self-in) + const client = ref([]) + // 对方发起的 (serverSessions): 我方是 TCP client (self-out) 或 TCP server (self-in) + const server = ref([]) + + async function refresh() { + const r = await window.api.forwardListSessions() + client.value = r.client || [] + server.value = r.server || [] + } + + async function open(peerId: string, args: { direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) { + const r = await window.api.forwardOpen(peerId, args) + if (r.ok) await refresh() + return r + } + + async function close(sessionId: string) { + const r = await window.api.forwardClose(sessionId) + await refresh() + return r + } + + const sessions = computed(() => [...client.value, ...server.value]) + + function onOpened(_info: any) { /* no-op */ } + + function onClosed(_info: any) { + // 局部更新; 完整刷新由 forward:opened/closed 事件触发, 这里 best-effort + const idx1 = client.value.findIndex(s => s.sessionId === _info.sessionId) + if (idx1 >= 0) { + const cur = client.value[idx1] + client.value[idx1] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut } + } + const idx2 = server.value.findIndex(s => s.sessionId === _info.sessionId) + if (idx2 >= 0) { + const cur = server.value[idx2] + server.value[idx2] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut } + } + } + + return { client, server, sessions, refresh, open, close, onOpened, onClosed } +}) + +export const useUsbStore = defineStore('usb', () => { + const devicesByPeer = ref>({}) + const myLocalDevices = ref([]) + const client = ref([]) // 我发起的 + const server = ref([]) // 对方发起的 + const sessions = computed(() => [...client.value, ...server.value]) + // sessionId -> base64 字符串缓冲 (串口 hex 显示用) + const buffers = ref>({}) + + async function list(peerId: string) { + const r = await window.api.usbList(peerId) + if (r.ok && r.devices) devicesByPeer.value[peerId] = r.devices + return r + } + + async function listLocal() { + const r = await window.api.usbListLocal() + if (r.ok && r.devices) myLocalDevices.value = r.devices + return r + } + + async function refresh() { + const r = await window.api.usbListSessions() + if (Array.isArray(r)) { + client.value = r + server.value = [] + } else { + client.value = r.client || [] + server.value = r.server || [] + } + } + + 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 }) + 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 detach(sessionId: string) { + const r = await window.api.usbDetach(sessionId) + await refresh() + return r + } + + function onOutput(p: { sessionId: string; peerId: string; data: string }) { + const buf = buffers.value[p.sessionId] || (buffers.value[p.sessionId] = []) + buf.push(p.data) + } + + function takeBuffer(sessionId: string): string { + const buf = buffers.value[sessionId] + if (!buf) return '' + const out = buf.join('') + buffers.value[sessionId] = [] + return out + } + + function onClosed(_info: any) { + const upd = (arr: typeof client.value) => { + const idx = arr.findIndex(s => s.sessionId === _info.sessionId) + if (idx >= 0) { + const cur = arr[idx] + arr[idx] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut } + } + } + upd(client.value) + upd(server.value) + } + + return { + devicesByPeer, myLocalDevices, client, server, sessions, buffers, + list, listLocal, refresh, attach, + serialSend, ctrlOut, ctrlIn, bulkOut, bulkIn, + detach, onOutput, takeBuffer, onClosed, + } +}) + +export const useApprovalStore = defineStore('approval', () => { + const queue = ref([]) + + async function refresh() { + queue.value = await window.api.remoteApprovalList() + } + + function add(req: ApprovalRequestView) { + queue.value = [...queue.value, req] + } + + async function reply(requestId: string, ok: boolean, remember = false) { + await window.api.remoteApprovalReply(requestId, ok, remember) + queue.value = queue.value.filter(r => r.requestId !== requestId) + } + + return { queue, refresh, add, reply } +}) + +export const useAuditStore = defineStore('audit', () => { + const entries = ref([]) + + async function refresh(limit = 200, since?: number) { + entries.value = await window.api.auditList({ limit, since }) + } + + async function prune(days?: number) { + return window.api.auditPrune(days ? days * 24 * 3600 * 1000 : undefined) + } + + return { entries, refresh, prune } +}) \ No newline at end of file