Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec9f854136 | |||
| d12c74649a | |||
| 565d044f18 |
@@ -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/<pkg>/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<SessionId>
|
||||||
|
// 数据流
|
||||||
|
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 <peer> -b <busid>` 让本机内核接管,设备出现在 `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 <peerLanIp> -b <busId>` (spawn sudo-prompt)
|
||||||
|
└─ → 内核 vhci_hcd 接收 → 设备出现在 `lsusb`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UI 设计
|
||||||
|
|
||||||
|
### 7.1 Sidebar 增加“工具”tab
|
||||||
|
|
||||||
|
```
|
||||||
|
设备 文件 工具 设置
|
||||||
|
└─ 终端
|
||||||
|
└─ 端口转发
|
||||||
|
└─ USB 设备
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 TerminalPanel.vue
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="term-panel">
|
||||||
|
<div class="term-status">
|
||||||
|
<span :class="state">{{ stateLabel }}</span>
|
||||||
|
<span>{{ peer.name }} · {{ shellLabel }}</span>
|
||||||
|
<el-button size="small" @click="disconnect">断开</el-button>
|
||||||
|
</div>
|
||||||
|
<div ref="hostEl" class="term-host" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
// ... 见 src/renderer/src/components/remote/TerminalPanel.vue
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<DeviceId, { terminal: bool; forward: bool; usb: bool }>`,默认全 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`
|
||||||
Generated
+658
-2
@@ -10,12 +10,20 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@lydell/node-pty": "^1.2.0-beta.12",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"element-plus": "^2.8.4",
|
"element-plus": "^2.8.4",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
|
"node-hid": "^3.3.0",
|
||||||
"pinia": "^2.2.4",
|
"pinia": "^2.2.4",
|
||||||
|
"serialport": "^13.0.0",
|
||||||
|
"sudo-prompt": "^9.2.1",
|
||||||
|
"usb": "^3.0.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1174,6 +1182,98 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lydell/node-pty": {
|
||||||
|
"version": "1.2.0-beta.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@lydell/node-pty/-/node-pty-1.2.0-beta.12.tgz",
|
||||||
|
"integrity": "sha512-qIK890UwPupoj07osVvgOIa++1mxeHbcGry4PKRHhNVNs81V2SCG34eJr46GybiOmBtc8Sj5PB1/GGM5PL549g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-win32-x64": "1.2.0-beta.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lydell/node-pty-darwin-arm64": {
|
||||||
|
"version": "1.2.0-beta.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.12.tgz",
|
||||||
|
"integrity": "sha512-tqaifcY9Cr41SblO1+FLzh8oxxtkNhuW9Dhl22lKme9BreYvKvxEZcdPIXTuqkJc5tagOEC4QHShKmJjLyLXLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@lydell/node-pty-darwin-x64": {
|
||||||
|
"version": "1.2.0-beta.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.12.tgz",
|
||||||
|
"integrity": "sha512-4LrS5pCJwqHKDVf1zS2gyNV0m4hKAXch+XZNhbZ6LY8uwVL8BhchzQBO40Os5anuRxRCWzHpw4Sp64Ie8q7E4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@lydell/node-pty-linux-arm64": {
|
||||||
|
"version": "1.2.0-beta.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.12.tgz",
|
||||||
|
"integrity": "sha512-Sx+A71x5BDGHt9ansfrtGxwq2VFVDWvJUAdlUL0Hv0qeiJUfts+hgopx+CgT4PSwahKjdEgtu0+FAfY9rICKRw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@lydell/node-pty-linux-x64": {
|
||||||
|
"version": "1.2.0-beta.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.12.tgz",
|
||||||
|
"integrity": "sha512-bJzs94njofYhGg/UDqW1nj0dtvvu+2OvxMY+RlLS1T17VgcktKoIR6PuenTwE5HJ/D6StCPADmXcT0nNsCKmIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@lydell/node-pty-win32-arm64": {
|
||||||
|
"version": "1.2.0-beta.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.12.tgz",
|
||||||
|
"integrity": "sha512-p7POgjVEiFaBC3/y+AKuV1FzePCsJ6HmZDv2XK+jBZSfwP8+uBAw181ZiKYN1YuRa/XpmBGaWezcI8hZkbW++g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@lydell/node-pty-win32-x64": {
|
||||||
|
"version": "1.2.0-beta.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.12.tgz",
|
||||||
|
"integrity": "sha512-IDFa00g7qUDGUYgByrUBJtC+mOjYVt/8KYyWivCg5JjGOHbBUACUQZLl0jTWmnr+tld/UyTpX90a2PY6oTVtRw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@malept/cross-spawn-promise": {
|
"node_modules/@malept/cross-spawn-promise": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
|
||||||
@@ -1252,6 +1352,166 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@node-usb/usb-darwin-arm64": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-darwin-arm64/-/usb-darwin-arm64-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GB+MGKaXvEtaDOWng7nCPxWvnXWhZtbFL0FW9ncDmGYtFl3OW+xfLZGhd5Y9VKG6GXXIYS3x1FsBI+zkcxVMhA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-darwin-x64": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-darwin-x64/-/usb-darwin-x64-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-oTY8vLoigjYFwNft1nZNNje0zAXjSQy9u3swe+pcCfZp6qQbEoSLegLEBr94arEgQloQabaq4VA2JZVA8cUQqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-linux-arm-gnueabihf": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-arm-gnueabihf/-/usb-linux-arm-gnueabihf-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GN5dTxc/FhjmC9NP7Xx1esGAhpIhnNvAf02kk/0i7Cz0w1VaJ/I0XbIVAYIJ5zhDg/oSYkhdILHbOS9+mlWCRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-linux-arm64-gnu": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-arm64-gnu/-/usb-linux-arm64-gnu-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-7cL85qb/yrBXvssZwL3k62duBxT/LaCY/Vcfv/Du9MLFdHhqU0zdYX5fLch/zaW5euYXQE0gmCoTfimfAjlxGQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-linux-arm64-musl": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-arm64-musl/-/usb-linux-arm64-musl-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-EweA4HeTwzLiRHsU2DRFflJ3vx0shB5Z9Lt5/P/bkoUtSNt8uGjFCmsCjqoO068ul9oejsBMJg22rNLD3+iFqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-linux-x64-gnu": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-x64-gnu/-/usb-linux-x64-gnu-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-cEE7GyMhtfq5OM/MXZn3RmYBqPvId0CMuoBx7biazPvlnf0iEWYjpma0WvVMWad/f1QIcC45WgrazHaVv6gAPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-linux-x64-musl": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-linux-x64-musl/-/usb-linux-x64-musl-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-zGWlk1mzElTPO4wxlRcd1C7xd7xDxzgpOXbQfE+NbClOUJ71NkbnahWN2ef2LcJvFESYg/CV9Dv/ULKSpJWEEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-win32-arm64-msvc": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-win32-arm64-msvc/-/usb-win32-arm64-msvc-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ayXNeb+KvdkZLXzTWyttNONUPG8/x9PwuP+GvUeiYCQuyLMqmbGeMsO7IiuBWTnxLFtCdep+UBpcL8XMcYdTiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-win32-ia32-msvc": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-win32-ia32-msvc/-/usb-win32-ia32-msvc-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-U5281i6yY7gtoTwLom8nLF2o+ILI/g4OFpbAJBr4GbVPOTu+CSNF2Ea+dLEAG30148mp8v79NEsIiAbWXS/1aw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-usb/usb-win32-x64-msvc": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@node-usb/usb-win32-x64-msvc/-/usb-win32-x64-msvc-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-T1jzCBwgnA6E5otxJS/j44b//6fGzHSDmLQtNN1fX6JvdQIisgtbf2+iKD/YI7E247j6Yi9Ixt+MKjHt64hugA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@@ -1624,6 +1884,254 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@serialport/binding-mock": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/binding-mock/-/binding-mock-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@serialport/bindings-interface": "^1.2.1",
|
||||||
|
"debug": "^4.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/bindings-cpp": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@serialport/bindings-interface": "1.2.2",
|
||||||
|
"@serialport/parser-readline": "12.0.0",
|
||||||
|
"debug": "4.4.0",
|
||||||
|
"node-addon-api": "8.3.0",
|
||||||
|
"node-gyp-build": "4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": {
|
||||||
|
"version": "12.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz",
|
||||||
|
"integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": {
|
||||||
|
"version": "12.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-readline/-/parser-readline-12.0.0.tgz",
|
||||||
|
"integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@serialport/parser-delimiter": "12.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/bindings-cpp/node_modules/debug": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/bindings-cpp/node_modules/node-addon-api": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/bindings-interface": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22 || ^14.13 || >=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-byte-length": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-cctalk": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-delimiter": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-inter-byte-timeout": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-packet-length": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-readline": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-readline/-/parser-readline-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@serialport/parser-delimiter": "13.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-ready": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-ready/-/parser-ready-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-regex": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-regex/-/parser-regex-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-slip-encoder": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/parser-spacepacket": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/stream": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@serialport/stream/-/stream-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@serialport/bindings-interface": "1.2.2",
|
||||||
|
"debug": "4.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@serialport/stream/node_modules/debug": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sindresorhus/is": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||||
@@ -1814,6 +2322,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/w3c-web-usb": {
|
||||||
|
"version": "1.0.14",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
|
||||||
|
"integrity": "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.16",
|
"version": "0.0.16",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
|
||||||
@@ -2140,6 +2654,27 @@
|
|||||||
"node": ">=14.6"
|
"node": ">=14.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-web-links": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/7zip-bin": {
|
"node_modules/7zip-bin": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||||
@@ -3277,7 +3812,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -5338,7 +5872,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
@@ -5404,6 +5937,40 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-hid": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-hid/-/node-hid-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "(MIT OR X11)",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^3.2.1",
|
||||||
|
"pkg-prebuilds": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"hid-showdevices": "src/show-devices.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-hid/node_modules/node-addon-api": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.51",
|
"version": "2.0.51",
|
||||||
"resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.51.tgz",
|
"resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.51.tgz",
|
||||||
@@ -5666,6 +6233,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkg-prebuilds": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pkg-prebuilds/-/pkg-prebuilds-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-jyai+KTQ2OwbN6iRYw88XbYOMgtpoSYJpjYebx7d9ihqz3txNi3ucsBt3va0iVWe6svSlaqpijMHFF/eJCMZzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"pkg-prebuilds-copy": "bin/copy.mjs",
|
||||||
|
"pkg-prebuilds-verify": "bin/verify.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pkg-up": {
|
"node_modules/pkg-up": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/pkg-up/-/pkg-up-3.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/pkg-up/-/pkg-up-3.1.0.tgz",
|
||||||
@@ -6088,6 +6668,51 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/serialport": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/serialport/-/serialport-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@serialport/binding-mock": "10.2.2",
|
||||||
|
"@serialport/bindings-cpp": "13.0.0",
|
||||||
|
"@serialport/parser-byte-length": "13.0.0",
|
||||||
|
"@serialport/parser-cctalk": "13.0.0",
|
||||||
|
"@serialport/parser-delimiter": "13.0.0",
|
||||||
|
"@serialport/parser-inter-byte-timeout": "13.0.0",
|
||||||
|
"@serialport/parser-packet-length": "13.0.0",
|
||||||
|
"@serialport/parser-readline": "13.0.0",
|
||||||
|
"@serialport/parser-ready": "13.0.0",
|
||||||
|
"@serialport/parser-regex": "13.0.0",
|
||||||
|
"@serialport/parser-slip-encoder": "13.0.0",
|
||||||
|
"@serialport/parser-spacepacket": "13.0.0",
|
||||||
|
"@serialport/stream": "13.0.0",
|
||||||
|
"debug": "4.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/serialport/donate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/serialport/node_modules/debug": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -6347,6 +6972,13 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sudo-prompt": {
|
||||||
|
"version": "9.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
|
||||||
|
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sumchecker": {
|
"node_modules/sumchecker": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||||
@@ -6613,6 +7245,30 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/usb": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/usb/-/usb-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-IQIi5EraqmKssMNcXe7afKhriHFAE5WdfykhvgkOschJyHBkJXtk/PDx/DzVNyfI7qdr+BUbmLziXA+I3z02lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/w3c-web-usb": "^1.0.14"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@node-usb/usb-darwin-arm64": "3.0.1",
|
||||||
|
"@node-usb/usb-darwin-x64": "3.0.1",
|
||||||
|
"@node-usb/usb-linux-arm-gnueabihf": "3.0.1",
|
||||||
|
"@node-usb/usb-linux-arm64-gnu": "3.0.1",
|
||||||
|
"@node-usb/usb-linux-arm64-musl": "3.0.1",
|
||||||
|
"@node-usb/usb-linux-x64-gnu": "3.0.1",
|
||||||
|
"@node-usb/usb-linux-x64-musl": "3.0.1",
|
||||||
|
"@node-usb/usb-win32-arm64-msvc": "3.0.1",
|
||||||
|
"@node-usb/usb-win32-ia32-msvc": "3.0.1",
|
||||||
|
"@node-usb/usb-win32-x64-msvc": "3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/utf8-byte-length": {
|
"node_modules/utf8-byte-length": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||||
|
|||||||
+25
-5
@@ -9,26 +9,31 @@
|
|||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
|
|
||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||||
"typecheck:web": "vue-tsc --noEmit -p src/renderer/tsconfig.json",
|
"typecheck:web": "vue-tsc --noEmit -p src/renderer/tsconfig.json",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
|
||||||
"package:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --win --x64",
|
"package:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --win --x64",
|
||||||
"package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac",
|
"package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac",
|
||||||
"package:linux": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --linux",
|
"package:linux": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --linux",
|
||||||
"package:dir": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --dir",
|
"package:dir": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --dir",
|
||||||
|
|
||||||
"postinstall": "electron-builder install-app-deps"
|
"postinstall": "electron-builder install-app-deps"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@lydell/node-pty": "^1.2.0-beta.12",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"element-plus": "^2.8.4",
|
"element-plus": "^2.8.4",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
"markdown-it": "^14.3.0",
|
"markdown-it": "^14.3.0",
|
||||||
|
"node-hid": "^3.3.0",
|
||||||
"pinia": "^2.2.4",
|
"pinia": "^2.2.4",
|
||||||
|
"serialport": "^13.0.0",
|
||||||
|
"sudo-prompt": "^9.2.1",
|
||||||
|
"usb": "^3.0.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -59,6 +64,14 @@
|
|||||||
"out/**/*",
|
"out/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"**/node_modules/@lydell/node-pty/**/*",
|
||||||
|
"**/node_modules/@lydell/node-pty-*/*",
|
||||||
|
"**/node_modules/@node-usb/**/*",
|
||||||
|
"**/node_modules/usb/prebuilds/**/*",
|
||||||
|
"**/node_modules/@serialport/**/prebuilds/**/*",
|
||||||
|
"**/node_modules/node-hid/prebuilds/**/*"
|
||||||
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "resources/icon.png",
|
"from": "resources/icon.png",
|
||||||
@@ -68,7 +81,12 @@
|
|||||||
"win": {
|
"win": {
|
||||||
"icon": "resources/icon.png",
|
"icon": "resources/icon.png",
|
||||||
"target": [
|
"target": [
|
||||||
{ "target": "nsis", "arch": ["x64"] }
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "${productName}-${version}-Setup.${ext}"
|
"artifactName": "${productName}-${version}-Setup.${ext}"
|
||||||
},
|
},
|
||||||
@@ -88,7 +106,9 @@
|
|||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"icon": "resources/icon.png",
|
"icon": "resources/icon.png",
|
||||||
"target": ["AppImage"]
|
"target": [
|
||||||
|
"AppImage"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-5
@@ -14,6 +14,7 @@ interface Entry {
|
|||||||
peer: DeviceInfo
|
peer: DeviceInfo
|
||||||
ws: WebSocket | null
|
ws: WebSocket | null
|
||||||
retryTimer: NodeJS.Timeout | null
|
retryTimer: NodeJS.Timeout | null
|
||||||
|
pingTimer: NodeJS.Timeout | null
|
||||||
backoff: number
|
backoff: number
|
||||||
alive: boolean
|
alive: boolean
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ export class ChatClient extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.scheduleConnect(e, e.backoff)
|
this.scheduleConnect(e, e.backoff)
|
||||||
} else {
|
} else {
|
||||||
e = { peer, ws: null, retryTimer: null, backoff: 1000, alive: true }
|
e = { peer, ws: null, retryTimer: null, pingTimer: null, backoff: 1000, alive: true }
|
||||||
this.entries.set(peer.deviceId, e)
|
this.entries.set(peer.deviceId, e)
|
||||||
this.scheduleConnect(e, 0)
|
this.scheduleConnect(e, 0)
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,7 @@ export class ChatClient extends EventEmitter {
|
|||||||
if (!e) return
|
if (!e) return
|
||||||
e.alive = false
|
e.alive = false
|
||||||
if (e.retryTimer) clearTimeout(e.retryTimer)
|
if (e.retryTimer) clearTimeout(e.retryTimer)
|
||||||
|
this.stopPing(e)
|
||||||
try { e.ws?.close() } catch {}
|
try { e.ws?.close() } catch {}
|
||||||
this.entries.delete(deviceId)
|
this.entries.delete(deviceId)
|
||||||
}
|
}
|
||||||
@@ -90,13 +92,33 @@ export class ChatClient extends EventEmitter {
|
|||||||
const ws = new WebSocket(url, { handshakeTimeout: 5000 })
|
const ws = new WebSocket(url, { handshakeTimeout: 5000 })
|
||||||
e.ws = ws
|
e.ws = ws
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
|
try {
|
||||||
ws.send(JSON.stringify({ type: 'hello', from: this.self }))
|
ws.send(JSON.stringify({ type: 'hello', from: this.self }))
|
||||||
|
} catch {}
|
||||||
e.backoff = 2000
|
e.backoff = 2000
|
||||||
this.emit('open', e.peer)
|
this.emit('open', e.peer)
|
||||||
|
// NAT keepalive: 每 25s 发一次 WS 控制层 ping frame (比应用层 ping 更省, NAT 路由/防火墙
|
||||||
|
// 能识别 ping 控制帧, 不会因为"空闲太久"砍掉连接 — 这是 WS 抖动最常见的隐形原因)
|
||||||
|
// 顺便: 应用层 ping (JSON) 也发一份, 让对方知道我们活着 (双保险)
|
||||||
|
this.stopPing(e)
|
||||||
|
e.pingTimer = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
try { ws.ping() } catch {}
|
||||||
|
try { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })) } catch {}
|
||||||
|
}
|
||||||
|
}, 25_000)
|
||||||
|
})
|
||||||
|
ws.on('pong', () => {
|
||||||
|
// 对端回了 pong, 标记连接健康 (留个 hook, 后续可加 ws-down 检测)
|
||||||
})
|
})
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
try {
|
try {
|
||||||
const frame = JSON.parse(raw.toString('utf8')) as WsFrame
|
const frame = JSON.parse(raw.toString('utf8')) as WsFrame
|
||||||
|
if (frame.type === 'ping') {
|
||||||
|
// 应用层 ping: 回 pong
|
||||||
|
try { ws.send(JSON.stringify({ type: 'pong', ts: frame.ts })) } catch {}
|
||||||
|
return
|
||||||
|
}
|
||||||
// 更新对端地址 (可能 ip 变了)
|
// 更新对端地址 (可能 ip 变了)
|
||||||
if (frame.type === 'hello' && frame.from) {
|
if (frame.type === 'hello' && frame.from) {
|
||||||
e.peer = { ...peer, ...frame.from, address: peer.address }
|
e.peer = { ...peer, ...frame.from, address: peer.address }
|
||||||
@@ -105,10 +127,11 @@ export class ChatClient extends EventEmitter {
|
|||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
this.stopPing(e)
|
||||||
e.ws = null
|
e.ws = null
|
||||||
this.emit('close', e.peer)
|
this.emit('close', e.peer)
|
||||||
if (e.alive) this.scheduleConnect(e, e.backoff)
|
if (e.alive) this.scheduleConnect(e, e.backoff)
|
||||||
e.backoff = Math.min(e.backoff * 2, 30_000)
|
e.backoff = Math.min(e.backoff * 2, 5_000)
|
||||||
})
|
})
|
||||||
ws.on('error', (err: Error) => {
|
ws.on('error', (err: Error) => {
|
||||||
this.emit('error', e.peer, err)
|
this.emit('error', e.peer, err)
|
||||||
@@ -116,12 +139,27 @@ export class ChatClient extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
send(frame: WsFrame, deviceId?: string) {
|
private stopPing(e: Entry) {
|
||||||
|
if (e.pingTimer) {
|
||||||
|
clearInterval(e.pingTimer)
|
||||||
|
e.pingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(frame: WsFrame, deviceId?: string): boolean {
|
||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
const e = this.entries.get(deviceId)
|
const e = this.entries.get(deviceId)
|
||||||
if (e?.ws && e.ws.readyState === WebSocket.OPEN) {
|
if (e?.ws && e.ws.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
e.ws.send(JSON.stringify(frame))
|
e.ws.send(JSON.stringify(frame))
|
||||||
return true
|
return true
|
||||||
|
} catch {
|
||||||
|
// send 抛错 (例如连接刚挂但 readyState 还没更新) — 关掉这个 entry 让下一次 flush 重连
|
||||||
|
try { e.ws.terminate() } catch {}
|
||||||
|
e.ws = null
|
||||||
|
if (e.alive) this.scheduleConnect(e, 0)
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -129,8 +167,7 @@ export class ChatClient extends EventEmitter {
|
|||||||
let any = false
|
let any = false
|
||||||
for (const e of this.entries.values()) {
|
for (const e of this.entries.values()) {
|
||||||
if (e.ws && e.ws.readyState === WebSocket.OPEN) {
|
if (e.ws && e.ws.readyState === WebSocket.OPEN) {
|
||||||
e.ws.send(JSON.stringify(frame))
|
try { e.ws.send(JSON.stringify(frame)); any = true } catch {}
|
||||||
any = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return any
|
return any
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface ChatServerEvents {
|
|||||||
message: (from: DeviceInfo, msg: MessageEnvelope) => void
|
message: (from: DeviceInfo, msg: MessageEnvelope) => void
|
||||||
recall: (from: DeviceInfo, messageId: string) => void
|
recall: (from: DeviceInfo, messageId: string) => void
|
||||||
typing: (from: DeviceInfo) => void
|
typing: (from: DeviceInfo) => void
|
||||||
|
read: (from: DeviceInfo, upToMessageId: string) => void
|
||||||
|
// 任意 ws 帧 (含 terminal/forward/usb 等), 让上层路由分发
|
||||||
|
frame: (from: DeviceInfo, frame: WsFrame) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatServer extends EventEmitter {
|
export class ChatServer extends EventEmitter {
|
||||||
@@ -51,6 +54,8 @@ export class ChatServer extends EventEmitter {
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 通用: 任何帧都抛 'frame', 上层 (ipc.bindNetworkContext) 自行路由
|
||||||
|
if (peer) this.emit('frame', peer, frame)
|
||||||
switch (frame.type) {
|
switch (frame.type) {
|
||||||
case 'hello':
|
case 'hello':
|
||||||
if (frame.from && frame.from.deviceId) {
|
if (frame.from && frame.from.deviceId) {
|
||||||
@@ -77,9 +82,13 @@ export class ChatServer extends EventEmitter {
|
|||||||
case 'recall':
|
case 'recall':
|
||||||
if (peer) this.emit('recall', peer, frame.messageId)
|
if (peer) this.emit('recall', peer, frame.messageId)
|
||||||
break
|
break
|
||||||
|
case 'read':
|
||||||
|
if (peer) this.emit('read', peer, frame.upTo)
|
||||||
|
break
|
||||||
case 'typing':
|
case 'typing':
|
||||||
if (peer) this.emit('typing', peer)
|
if (peer) this.emit('typing', peer)
|
||||||
break
|
break
|
||||||
|
// terminal / forward / usb 由 'frame' 事件统一交给上层处理
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+104
-2
@@ -42,6 +42,23 @@ CREATE TABLE IF NOT EXISTS unread (
|
|||||||
device_id TEXT PRIMARY KEY,
|
device_id TEXT PRIMARY KEY,
|
||||||
count INTEGER NOT NULL DEFAULT 0
|
count INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 远程操作审计 (terminal / forward / usb)
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
source_device TEXT,
|
||||||
|
target_device TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
payload_json TEXT,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
bytes_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_out INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 迁移: 给老库补 ignored 列 (用 try/catch 包住, 已存在会抛)
|
// 迁移: 给老库补 ignored 列 (用 try/catch 包住, 已存在会抛)
|
||||||
@@ -80,8 +97,11 @@ export function upsertDevice(d: DeviceRow) {
|
|||||||
upsertDeviceStmt.run(d)
|
upsertDeviceStmt.run(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listDevices(): DeviceRow[] {
|
export function listDevices(includeIgnored = false): DeviceRow[] {
|
||||||
return db.prepare(`SELECT * FROM devices ORDER BY last_seen DESC`).all() as DeviceRow[]
|
const sql = includeIgnored
|
||||||
|
? `SELECT * FROM devices ORDER BY last_seen DESC`
|
||||||
|
: `SELECT * FROM devices WHERE ignored = 0 ORDER BY last_seen DESC`
|
||||||
|
return db.prepare(sql).all() as DeviceRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDevice(id: string): DeviceRow | undefined {
|
export function getDevice(id: string): DeviceRow | undefined {
|
||||||
@@ -178,6 +198,19 @@ export function listPendingMessages(peerId: string, selfId: string): MessageRow[
|
|||||||
).all(key) as MessageRow[]
|
).all(key) as MessageRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已上传完但 WS update 帧 (带 savedPath) 没送达: metadata 帧 status='sent',
|
||||||
|
// body.savedPath 已填, 重发完整 envelope 让收件方出 "打开/位置"
|
||||||
|
export function listSentFileUpdates(selfId: string, peerId: string): MessageRow[] {
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT * FROM messages
|
||||||
|
WHERE from_id = ? AND to_id = ?
|
||||||
|
AND status = 'sent'
|
||||||
|
AND type IN ('file', 'image')
|
||||||
|
AND json_extract(body_json, '$.savedPath') IS NOT NULL
|
||||||
|
ORDER BY ts ASC`
|
||||||
|
).all(selfId, peerId) as MessageRow[]
|
||||||
|
}
|
||||||
|
|
||||||
export function listAllPending(selfId: string): MessageRow[] {
|
export function listAllPending(selfId: string): MessageRow[] {
|
||||||
return db.prepare(
|
return db.prepare(
|
||||||
`SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC`
|
`SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC`
|
||||||
@@ -224,3 +257,72 @@ export function totalUnread(): number {
|
|||||||
const r = db.prepare(`SELECT COALESCE(SUM(count), 0) AS s FROM unread`).get() as { s: number }
|
const r = db.prepare(`SELECT COALESCE(SUM(count), 0) AS s FROM unread`).get() as { s: number }
|
||||||
return r.s
|
return r.s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 审计日志 (远程操作: terminal / forward / usb)
|
||||||
|
export interface AuditRow {
|
||||||
|
id: number
|
||||||
|
ts: number
|
||||||
|
action: string
|
||||||
|
source_device: string | null
|
||||||
|
target_device: string | null
|
||||||
|
session_id: string | null
|
||||||
|
payload_json: string | null
|
||||||
|
result: string
|
||||||
|
bytes_in: number
|
||||||
|
bytes_out: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertAuditStmt = db.prepare(`
|
||||||
|
INSERT INTO audit_log
|
||||||
|
(ts, action, source_device, target_device, session_id, payload_json, result, bytes_in, bytes_out)
|
||||||
|
VALUES
|
||||||
|
(@ts, @action, @source_device, @target_device, @session_id, @payload_json, @result, @bytes_in, @bytes_out)
|
||||||
|
`)
|
||||||
|
|
||||||
|
export function recordAudit(args: {
|
||||||
|
action: string
|
||||||
|
source?: string | null
|
||||||
|
target?: string | null
|
||||||
|
sessionId?: string | null
|
||||||
|
payload?: any
|
||||||
|
result: 'ok' | 'denied' | 'error' | 'closed'
|
||||||
|
bytesIn?: number
|
||||||
|
bytesOut?: number
|
||||||
|
}): void {
|
||||||
|
insertAuditStmt.run({
|
||||||
|
ts: Date.now(),
|
||||||
|
action: args.action,
|
||||||
|
source_device: args.source ?? null,
|
||||||
|
target_device: args.target ?? null,
|
||||||
|
session_id: args.sessionId ?? null,
|
||||||
|
payload_json: args.payload !== undefined ? JSON.stringify(args.payload) : null,
|
||||||
|
result: args.result,
|
||||||
|
bytes_in: args.bytesIn ?? 0,
|
||||||
|
bytes_out: args.bytesOut ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAudit(opts: { limit?: number; since?: number; sessionId?: string } = {}): AuditRow[] {
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 2000)
|
||||||
|
if (opts.sessionId) {
|
||||||
|
return db.prepare(`SELECT * FROM audit_log WHERE session_id = ? ORDER BY ts DESC LIMIT ?`).all(opts.sessionId, limit) as AuditRow[]
|
||||||
|
}
|
||||||
|
if (opts.since) {
|
||||||
|
return db.prepare(`SELECT * FROM audit_log WHERE ts >= ? ORDER BY ts DESC LIMIT ?`).all(opts.since, limit) as AuditRow[]
|
||||||
|
}
|
||||||
|
return db.prepare(`SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?`).all(limit) as AuditRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneAudit(olderThanMs: number): number {
|
||||||
|
const r = db.prepare(`DELETE FROM audit_log WHERE ts < ?`).run(Date.now() - olderThanMs)
|
||||||
|
return r.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程操作白名单 (按 deviceId -> { terminal, forward, usb })
|
||||||
|
// 写入 settings.json (electron-store) 而不是 DB, 便于跨设备查看与回滚
|
||||||
|
export interface RemoteAllowRow {
|
||||||
|
device_id: string
|
||||||
|
terminal: number // 0/1
|
||||||
|
forward: number
|
||||||
|
usb: number
|
||||||
|
}
|
||||||
|
|||||||
+22
-3
@@ -14,8 +14,11 @@ import { Discovery } from './discovery'
|
|||||||
import { ChatServer } from './chat-server'
|
import { ChatServer } from './chat-server'
|
||||||
import { ChatClient } from './chat-client'
|
import { ChatClient } from './chat-client'
|
||||||
import { FileServer } from './file-server'
|
import { FileServer } from './file-server'
|
||||||
|
import { TerminalManager } from './remote/terminal'
|
||||||
|
import { ForwardManager } from './remote/forward'
|
||||||
|
import { UsbBridgeManager } from './remote/usb'
|
||||||
import { registerIpc, bindNetworkContext } from './ipc'
|
import { registerIpc, bindNetworkContext } from './ipc'
|
||||||
import { listDevices, totalUnread, getUnread } from './db'
|
import { listDevices, totalUnread, getUnread, pruneAudit } from './db'
|
||||||
import { setBadgeCount } from './notify'
|
import { setBadgeCount } from './notify'
|
||||||
import { DEFAULT_PORTS, PROTOCOL_VERSION } from './protocol'
|
import { DEFAULT_PORTS, PROTOCOL_VERSION } from './protocol'
|
||||||
import type { DeviceInfo } from './protocol'
|
import type { DeviceInfo } from './protocol'
|
||||||
@@ -58,6 +61,9 @@ async function main() {
|
|||||||
const chatServer = new ChatServer(DEFAULT_PORTS.chat)
|
const chatServer = new ChatServer(DEFAULT_PORTS.chat)
|
||||||
const chatClient = new ChatClient(self)
|
const chatClient = new ChatClient(self)
|
||||||
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir())
|
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir())
|
||||||
|
const terminal = new TerminalManager(chatClient, self.deviceId)
|
||||||
|
const forward = new ForwardManager(chatClient)
|
||||||
|
const usb = new UsbBridgeManager(chatClient)
|
||||||
|
|
||||||
const chatPort = await chatServer.start()
|
const chatPort = await chatServer.start()
|
||||||
const filePort = await fileServer.start()
|
const filePort = await fileServer.start()
|
||||||
@@ -65,10 +71,18 @@ async function main() {
|
|||||||
self.filePort = filePort
|
self.filePort = filePort
|
||||||
// 后台清理 24h+ 残留 .tmp (不阻塞启动)
|
// 后台清理 24h+ 残留 .tmp (不阻塞启动)
|
||||||
fileServer.cleanupStaleTmp().catch(() => {})
|
fileServer.cleanupStaleTmp().catch(() => {})
|
||||||
stopFns.push(() => chatServer.stop(), () => chatClient.stop(), () => discovery.stop(), () => fileServer.stop())
|
stopFns.push(
|
||||||
|
() => chatServer.stop(),
|
||||||
|
() => chatClient.stop(),
|
||||||
|
() => discovery.stop(),
|
||||||
|
() => fileServer.stop(),
|
||||||
|
() => terminal.stop(),
|
||||||
|
() => forward.stop(),
|
||||||
|
() => usb.stop(),
|
||||||
|
)
|
||||||
|
|
||||||
await discovery.start()
|
await discovery.start()
|
||||||
bindNetworkContext({ self, discovery, chatServer, chatClient, fileServer })
|
bindNetworkContext({ self, discovery, chatServer, chatClient, fileServer, terminal, forward, usb })
|
||||||
|
|
||||||
// 已入库的设备: 启动后主动尝试连接
|
// 已入库的设备: 启动后主动尝试连接
|
||||||
for (const d of listDevices()) {
|
for (const d of listDevices()) {
|
||||||
@@ -91,6 +105,11 @@ async function main() {
|
|||||||
createTray()
|
createTray()
|
||||||
registerIpc()
|
registerIpc()
|
||||||
|
|
||||||
|
// 启动时按 auditRetentionDays 清理一次旧审计
|
||||||
|
const s = getSettings()
|
||||||
|
const retentionMs = Math.max(1, s.auditRetentionDays) * 24 * 3600 * 1000
|
||||||
|
pruneAudit(retentionMs)
|
||||||
|
|
||||||
setTimeout(pushInitialState, 400)
|
setTimeout(pushInitialState, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+379
-24
@@ -9,8 +9,9 @@ import { getSettings, updateSettings, ensureDownloadDir } from './settings'
|
|||||||
import {
|
import {
|
||||||
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
|
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
|
||||||
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
|
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
|
||||||
getUnread, totalUnread, listPendingMessages, listAllPending,
|
getUnread, totalUnread, listPendingMessages, listAllPending, listSentFileUpdates,
|
||||||
setDeviceIgnored, listIgnoredDevices, deleteConversation
|
setDeviceIgnored, listIgnoredDevices, getDevice, deleteConversation,
|
||||||
|
recordAudit, listAudit, pruneAudit
|
||||||
} from './db'
|
} from './db'
|
||||||
import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
|
import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
|
||||||
import { ChatServer } from './chat-server'
|
import { ChatServer } from './chat-server'
|
||||||
@@ -19,7 +20,11 @@ import { FileServer, uploadFile, uploadBuffer } from './file-server'
|
|||||||
import { broadcastToRenderer, focusMainWindow } from './window'
|
import { broadcastToRenderer, focusMainWindow } from './window'
|
||||||
import { rebuildMenu } from './tray'
|
import { rebuildMenu } from './tray'
|
||||||
import { notify, setBadgeCount } from './notify'
|
import { notify, setBadgeCount } from './notify'
|
||||||
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage } from './protocol'
|
import { TerminalManager } from './remote/terminal'
|
||||||
|
import { ForwardManager } from './remote/forward'
|
||||||
|
import { UsbBridgeManager } from './remote/usb'
|
||||||
|
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
|
||||||
|
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
|
||||||
|
|
||||||
interface NetCtx {
|
interface NetCtx {
|
||||||
self: DeviceInfo
|
self: DeviceInfo
|
||||||
@@ -27,12 +32,16 @@ interface NetCtx {
|
|||||||
chatServer: ChatServer
|
chatServer: ChatServer
|
||||||
chatClient: ChatClient
|
chatClient: ChatClient
|
||||||
fileServer: FileServer
|
fileServer: FileServer
|
||||||
|
terminal: TerminalManager
|
||||||
|
forward: ForwardManager
|
||||||
|
usb: UsbBridgeManager
|
||||||
}
|
}
|
||||||
|
|
||||||
let ctx: NetCtx | null = null
|
let ctx: NetCtx | null = null
|
||||||
|
|
||||||
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
|
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
|
||||||
// 任一进度事件或 received 完成事件取消; 到了 -> mark failed 并删消息, 避免气泡永远 "receiving"
|
// 任一进度事件或 received 完成事件取消; 到了 -> 只通知 sender, 不自动删消息
|
||||||
|
// 气泡留在 receiving 状态, 让用户右键手动删
|
||||||
const PENDING_FILE_TIMEOUT_MS = 60_000
|
const PENDING_FILE_TIMEOUT_MS = 60_000
|
||||||
const pendingMetaTimers = new Map<string, { timer: NodeJS.Timeout; messageId: string; fromDeviceId: string }>()
|
const pendingMetaTimers = new Map<string, { timer: NodeJS.Timeout; messageId: string; fromDeviceId: string }>()
|
||||||
|
|
||||||
@@ -40,10 +49,8 @@ function armPendingMetaTimer(fileId: string, messageId: string, fromDeviceId: st
|
|||||||
disarmPendingMetaTimer(fileId)
|
disarmPendingMetaTimer(fileId)
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
pendingMetaTimers.delete(fileId)
|
pendingMetaTimers.delete(fileId)
|
||||||
console.warn(`[ipc] file ${fileId} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s, marking failed`)
|
console.warn(`[ipc] file ${fileId.slice(0, 8)} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s; bubble stays for user to delete`)
|
||||||
try { deleteMessage(messageId) } catch {}
|
// 通知 sender 标记失败 (sender 在线时能把 status='sent' 升级到 'failed')
|
||||||
broadcastToRenderer('message:recalled', { messageId, conversationKey: '' })
|
|
||||||
// 也告诉 sender: 这文件永远来不了 (如果是 sender 在线, 它能 mark failed)
|
|
||||||
ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId)
|
ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId)
|
||||||
}, PENDING_FILE_TIMEOUT_MS)
|
}, PENDING_FILE_TIMEOUT_MS)
|
||||||
pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId })
|
pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId })
|
||||||
@@ -61,6 +68,8 @@ export function bindNetworkContext(c: NetCtx) {
|
|||||||
ctx = c
|
ctx = c
|
||||||
// 网络事件 -> 渲染进程
|
// 网络事件 -> 渲染进程
|
||||||
c.discovery.on('found', (d) => {
|
c.discovery.on('found', (d) => {
|
||||||
|
// 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连
|
||||||
|
if (getDevice(d.deviceId)?.ignored) return
|
||||||
upsertDevice({
|
upsertDevice({
|
||||||
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||||
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
||||||
@@ -69,6 +78,7 @@ export function bindNetworkContext(c: NetCtx) {
|
|||||||
c.chatClient.connectTo(d)
|
c.chatClient.connectTo(d)
|
||||||
})
|
})
|
||||||
c.discovery.on('updated', (d: DeviceInfo) => {
|
c.discovery.on('updated', (d: DeviceInfo) => {
|
||||||
|
if (getDevice(d.deviceId)?.ignored) return
|
||||||
upsertDevice({
|
upsertDevice({
|
||||||
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||||
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
||||||
@@ -77,6 +87,7 @@ export function bindNetworkContext(c: NetCtx) {
|
|||||||
c.chatClient.connectTo(d)
|
c.chatClient.connectTo(d)
|
||||||
})
|
})
|
||||||
c.discovery.on('lost', (id) => {
|
c.discovery.on('lost', (id) => {
|
||||||
|
if (getDevice(id)?.ignored) return
|
||||||
broadcastToRenderer('device:lost', { deviceId: id })
|
broadcastToRenderer('device:lost', { deviceId: id })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -156,6 +167,40 @@ export function bindNetworkContext(c: NetCtx) {
|
|||||||
else nextStatus = 'delivered'
|
else nextStatus = 'delivered'
|
||||||
updateMessageStatus(messageId, nextStatus)
|
updateMessageStatus(messageId, nextStatus)
|
||||||
broadcastToRenderer('message:statusChanged', { messageId, status: nextStatus })
|
broadcastToRenderer('message:statusChanged', { messageId, status: nextStatus })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type === 'terminal') {
|
||||||
|
ctx!.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
|
||||||
|
.catch((e) => console.warn('[terminal] handle error:', e?.message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type === 'forward') {
|
||||||
|
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type === 'usb') {
|
||||||
|
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// chat-server 收到的所有 ws 帧 (来自其他设备的入站)
|
||||||
|
// 这里分发 remote 帧给本地 manager (peer -> 我们) — 控制端发的请求落在这里
|
||||||
|
c.chatServer.on('frame', (peer, frame) => {
|
||||||
|
if (frame.type === 'terminal') {
|
||||||
|
console.log(`[ipc] incoming terminal frame from ${peer.name}: ${frame.payload.type}`)
|
||||||
|
ctx!.terminal.handleIncoming(peer, frame.payload as TerminalFrame)
|
||||||
|
.catch((e) => console.warn('[terminal] handle error:', e?.message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type === 'forward') {
|
||||||
|
console.log(`[ipc] incoming forward frame from ${peer.name}: ${(frame.payload as any).type}`)
|
||||||
|
ctx!.forward.handleIncoming(peer, frame.payload as ForwardFrame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (frame.type === 'usb') {
|
||||||
|
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -172,6 +217,17 @@ export function bindNetworkContext(c: NetCtx) {
|
|||||||
console.warn(`[chat-client] ws error to ${peer.name} (${peer.address}:${peer.chatPort}): ${err.message}`)
|
console.warn(`[chat-client] ws error to ${peer.name} (${peer.address}:${peer.chatPort}): ${err.message}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 我们这端 outgoing WS 接通 / 断开 — 都要广播给 renderer, 让 UI 能区分
|
||||||
|
// "discovery 在线" 和 "WS 在线"; 同时 open 时主动 flush 一次待发消息 (双端 WS 都建好后必走 chat-server.on('connect') 那条路,
|
||||||
|
// 但如果对方先来认我们这边, 我们 outgoing 还没通, flush 在自己这条线路上反而能立刻推)
|
||||||
|
c.chatClient.on('open', (peer) => {
|
||||||
|
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: true })
|
||||||
|
flushPendingFor(peer.deviceId)
|
||||||
|
})
|
||||||
|
c.chatClient.on('close', (peer) => {
|
||||||
|
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: false })
|
||||||
|
})
|
||||||
|
|
||||||
c.chatServer.on('disconnect', (id) => {
|
c.chatServer.on('disconnect', (id) => {
|
||||||
broadcastToRenderer('device:offline', { deviceId: id })
|
broadcastToRenderer('device:offline', { deviceId: id })
|
||||||
})
|
})
|
||||||
@@ -189,6 +245,88 @@ export function bindNetworkContext(c: NetCtx) {
|
|||||||
c.fileServer.on('received', (info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }) => {
|
c.fileServer.on('received', (info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }) => {
|
||||||
disarmPendingMetaTimer(info.fileId)
|
disarmPendingMetaTimer(info.fileId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
|
||||||
|
c.chatServer.on('recall', (from, messageId) => {
|
||||||
|
const row = getMessage(messageId)
|
||||||
|
if (!row) return
|
||||||
|
let savedPath: string | undefined
|
||||||
|
try { savedPath = (JSON.parse(row.body_json) as any)?.savedPath } catch {}
|
||||||
|
deleteMessage(messageId)
|
||||||
|
if (savedPath) {
|
||||||
|
fsp.unlink(savedPath).catch((e) => {
|
||||||
|
if (e?.code !== 'ENOENT') console.warn(`[recall] unlink ${savedPath} failed: ${e.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 已读回执: 对端说"我看到 upTo 了", 把我方所有 from=self, to=peer, ts<=read.ts, status='delivered' 的消息升 'read'
|
||||||
|
c.chatServer.on('read', (from, upToMessageId) => {
|
||||||
|
if (!ctx) return
|
||||||
|
const row = getMessage(upToMessageId)
|
||||||
|
if (!row) return
|
||||||
|
const upToTs = row.ts
|
||||||
|
// 只升级 delivered -> read, sent 留给 ack 处理
|
||||||
|
const stmt = db.prepare(`UPDATE messages SET status = 'read' WHERE from_id = ? AND to_id = ? AND status = 'delivered' AND ts <= ?`)
|
||||||
|
stmt.run(ctx.self.deviceId, from.deviceId, upToTs)
|
||||||
|
// 把这次升级告诉 renderer (逐条 statusChanged, 复用现有渲染层逻辑)
|
||||||
|
const updated = db.prepare(`SELECT message_id FROM messages WHERE from_id = ? AND to_id = ? AND ts <= ? AND status = 'read'`).all(ctx.self.deviceId, from.deviceId, upToTs) as Array<{ message_id: string }>
|
||||||
|
for (const r of updated) {
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId: r.message_id, status: 'read' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== Remote: terminal / forward / usb 事件桥接 =====
|
||||||
|
c.terminal.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
|
||||||
|
// 控制端从被控端收到 output -> 给 UI
|
||||||
|
broadcastToRenderer('remote:terminal:output', { sessionId, peerId, data: bytes.toString('base64') })
|
||||||
|
})
|
||||||
|
c.terminal.on('sessionOpened', (sessionId: string, peerId: string, info: { shell: string; rows: number; cols: number }) => {
|
||||||
|
broadcastToRenderer('remote:terminal:opened', { sessionId, peerId, ...info })
|
||||||
|
})
|
||||||
|
c.terminal.on('sessionClosed', (sessionId: string, peerId: string, reason?: string) => {
|
||||||
|
broadcastToRenderer('remote:terminal:closed', { sessionId, peerId, reason })
|
||||||
|
})
|
||||||
|
c.terminal.on('approvalRequested', (req) => {
|
||||||
|
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'terminal' })
|
||||||
|
})
|
||||||
|
|
||||||
|
c.forward.on('sessionOpened', (info) => {
|
||||||
|
broadcastToRenderer('remote:forward:opened', info)
|
||||||
|
})
|
||||||
|
c.forward.on('sessionClosed', (info) => {
|
||||||
|
broadcastToRenderer('remote:forward:closed', info)
|
||||||
|
})
|
||||||
|
c.forward.on('sessionError', (info) => {
|
||||||
|
broadcastToRenderer('remote:forward:error', info)
|
||||||
|
})
|
||||||
|
c.forward.on('approvalRequested', (req) => {
|
||||||
|
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'forward' })
|
||||||
|
})
|
||||||
|
|
||||||
|
c.usb.on('output', (sessionId: string, bytes: Buffer, peerId: string) => {
|
||||||
|
broadcastToRenderer('remote:usb:output', { sessionId, peerId, data: bytes.toString('base64') })
|
||||||
|
})
|
||||||
|
c.usb.on('sessionOpened', (info) => {
|
||||||
|
broadcastToRenderer('remote:usb:opened', info)
|
||||||
|
})
|
||||||
|
c.usb.on('sessionClosed', (info) => {
|
||||||
|
broadcastToRenderer('remote:usb:closed', info)
|
||||||
|
})
|
||||||
|
c.usb.on('sessionError', (info) => {
|
||||||
|
broadcastToRenderer('remote:usb:error', info)
|
||||||
|
})
|
||||||
|
c.usb.on('approvalRequested', (req) => {
|
||||||
|
broadcastToRenderer('remote:approval:requested', { ...req, kind: 'usb' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对端 ws 断开 -> 清理该对端的所有 session
|
||||||
|
c.chatClient.on('close', (peer) => {
|
||||||
|
c.terminal.onPeerDisconnected(peer.deviceId)
|
||||||
|
c.forward.onPeerDisconnected(peer.deviceId)
|
||||||
|
c.usb.onPeerDisconnected(peer.deviceId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) {
|
function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) {
|
||||||
@@ -229,8 +367,10 @@ async function deliverAndStore(env: MessageEnvelope): Promise<{ ok: boolean; rea
|
|||||||
// 2. 通过 ws 发送
|
// 2. 通过 ws 发送
|
||||||
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId)
|
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId)
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
// 对方不在线, 保持 pending, 等 flush
|
// WS 没开 — 消息已在 DB 里 status='pending', flushPendingFor 在对端 outgoing WS 接通时会自动重发.
|
||||||
return { ok: false, reason: 'peer offline', status: 'pending' }
|
// 不当作 "失败" 报给前端, 避免误导 (因为对方 UDP 可能还在, 只是 WS 抖了).
|
||||||
|
// 副作用: 渲染端的 "待对方上线" 气泡/重试按钮照常显示; 不再弹 "发送失败" toast.
|
||||||
|
return { ok: true, status: 'pending' }
|
||||||
}
|
}
|
||||||
// 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck)
|
// 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck)
|
||||||
updateMessageStatus(env.messageId, 'sent')
|
updateMessageStatus(env.messageId, 'sent')
|
||||||
@@ -267,10 +407,10 @@ async function flushPendingFor(peerId: string) {
|
|||||||
}
|
}
|
||||||
// 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent')
|
// 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent')
|
||||||
// 实际是 WS metadata 已发, 接收方在等 savedPath; 重连后重发完整 envelope 让他出 "打开/位置"
|
// 实际是 WS metadata 已发, 接收方在等 savedPath; 重连后重发完整 envelope 让他出 "打开/位置"
|
||||||
for (const row of rows) {
|
// 注意: rows 上面已经被 status='pending' 过滤, 这里必须重新查 status='sent' + savedPath 已填的文件
|
||||||
|
const updateRows = listSentFileUpdates(ctx.self.deviceId, peerId)
|
||||||
|
for (const row of updateRows) {
|
||||||
const body = JSON.parse(row.body_json)
|
const body = JSON.parse(row.body_json)
|
||||||
const fileBody = body as any
|
|
||||||
if ((row.type === 'file' || row.type === 'image') && fileBody.savedPath) {
|
|
||||||
const env: MessageEnvelope = {
|
const env: MessageEnvelope = {
|
||||||
messageId: row.message_id,
|
messageId: row.message_id,
|
||||||
type: row.type as any,
|
type: row.type as any,
|
||||||
@@ -286,7 +426,6 @@ async function flushPendingFor(peerId: string) {
|
|||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (count > 0) console.log(`[ipc] flushed ${count} pending/update messages to ${peerId.slice(0, 8)}`)
|
if (count > 0) console.log(`[ipc] flushed ${count} pending/update messages to ${peerId.slice(0, 8)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +465,56 @@ export function registerIpc() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 删除设备: 标记 ignored=1, 停掉后台重连, 从 sidebar 消失
|
||||||
|
// UDP 后续再发现也不会重新弹 (discovery 上游已过滤 ignored)
|
||||||
|
ipcMain.handle('device:delete', (_e, deviceId: string) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
if (deviceId === ctx.self.deviceId) return { ok: false, reason: '不能删除本机' }
|
||||||
|
setDeviceIgnored(deviceId, true)
|
||||||
|
ctx.chatClient.remove(deviceId) // 停掉 backoff 重连
|
||||||
|
ctx.discovery.forget(deviceId) // 从 in-memory peers Map 移除
|
||||||
|
broadcastToRenderer('device:lost', { deviceId }) // 通知 renderer 把它从 device store 摘掉
|
||||||
|
// 清掉对应未读
|
||||||
|
clearUnread(deviceId)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除单条消息 (本地) — 不广播, 不撤回对端
|
||||||
|
// opts.deleteFile: 同时删掉磁盘上的文件 (按 msg.body.savedPath)
|
||||||
|
// 返回 { ok, deletedFile? } 让 UI 在弹框里展示真实路径
|
||||||
|
ipcMain.handle('message:delete', async (_e, messageId: string, opts?: { deleteFile?: boolean }) => {
|
||||||
|
const row = getMessage(messageId)
|
||||||
|
if (!row) return { ok: false, reason: '消息不存在' }
|
||||||
|
let deletedFile: string | undefined
|
||||||
|
if (opts?.deleteFile) {
|
||||||
|
const body = (() => { try { return JSON.parse(row.body_json) } catch { return null } })()
|
||||||
|
const filePath: string | undefined = body?.savedPath
|
||||||
|
if (filePath) {
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath)
|
||||||
|
deletedFile = filePath
|
||||||
|
} catch (e: any) {
|
||||||
|
// 文件不在磁盘上 (用户手动删了/换盘) 不视为失败 — 把消息也删了
|
||||||
|
if (e?.code !== 'ENOENT') return { ok: false, reason: `删除文件失败: ${e?.message || e}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteMessage(messageId)
|
||||||
|
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
|
||||||
|
return { ok: true, deletedFile }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 已读回执: 本机在 active 对话里看到了新消息, 告诉对端把 upTo (含) 之前的消息标 'read'
|
||||||
|
ipcMain.handle('message:markRead', (_e, args: { peerId: string; upToMessageId: string }) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
if (!args?.peerId || !args?.upToMessageId) return { ok: false, reason: '参数缺失' }
|
||||||
|
const sent = ctx.chatClient.send(
|
||||||
|
{ type: 'read', upTo: args.upToMessageId, ts: Date.now() },
|
||||||
|
args.peerId
|
||||||
|
)
|
||||||
|
return { ok: sent, reason: sent ? undefined : '对方离线' }
|
||||||
|
})
|
||||||
|
|
||||||
// ---- 消息 ----
|
// ---- 消息 ----
|
||||||
ipcMain.handle('message:list', (_e, peerId: string) => {
|
ipcMain.handle('message:list', (_e, peerId: string) => {
|
||||||
if (!ctx) return []
|
if (!ctx) return []
|
||||||
@@ -333,7 +522,7 @@ export function registerIpc() {
|
|||||||
return rows.map(rowToEnvelope)
|
return rows.map(rowToEnvelope)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string }) => {
|
ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string; replyTo?: ReplyRef }) => {
|
||||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
const env: MessageEnvelope = {
|
const env: MessageEnvelope = {
|
||||||
messageId: randomUUID(),
|
messageId: randomUUID(),
|
||||||
@@ -345,6 +534,7 @@ export function registerIpc() {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
|
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||||
} as TextMessage,
|
} as TextMessage,
|
||||||
}
|
}
|
||||||
;(env.body as TextMessage).messageId = env.messageId
|
;(env.body as TextMessage).messageId = env.messageId
|
||||||
@@ -365,6 +555,7 @@ export function registerIpc() {
|
|||||||
mime?: string
|
mime?: string
|
||||||
asImage?: boolean
|
asImage?: boolean
|
||||||
onProgress?: boolean
|
onProgress?: boolean
|
||||||
|
replyTo?: ReplyRef
|
||||||
}) => {
|
}) => {
|
||||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
|
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
|
||||||
@@ -387,6 +578,7 @@ export function registerIpc() {
|
|||||||
savedPath: null as string | null,
|
savedPath: null as string | null,
|
||||||
hash: null as string | null,
|
hash: null as string | null,
|
||||||
localPath: args.localPath, // retry 时复用
|
localPath: args.localPath, // retry 时复用
|
||||||
|
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||||
}
|
}
|
||||||
const env: MessageEnvelope = {
|
const env: MessageEnvelope = {
|
||||||
messageId,
|
messageId,
|
||||||
@@ -453,8 +645,12 @@ export function registerIpc() {
|
|||||||
}
|
}
|
||||||
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
||||||
// 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开")
|
// 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开")
|
||||||
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
|
// 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发
|
||||||
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId)
|
||||||
|
? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
||||||
|
: false
|
||||||
|
if (!sentUpdate) {
|
||||||
|
console.warn(`[sendFile] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`)
|
||||||
}
|
}
|
||||||
// 不用再 broadcast statusChanged: 上一帧的 status='sent' 已经是终态之一, 等 ack 升级 delivered
|
// 不用再 broadcast statusChanged: 上一帧的 status='sent' 已经是终态之一, 等 ack 升级 delivered
|
||||||
void fileId // suppress unused warning
|
void fileId // suppress unused warning
|
||||||
@@ -473,6 +669,7 @@ export function registerIpc() {
|
|||||||
dataBase64: string
|
dataBase64: string
|
||||||
name: string
|
name: string
|
||||||
mime: string
|
mime: string
|
||||||
|
replyTo?: ReplyRef
|
||||||
}) => {
|
}) => {
|
||||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
|
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
|
||||||
@@ -490,6 +687,7 @@ export function registerIpc() {
|
|||||||
fileId, name: args.name, size: buf.length, mime: args.mime,
|
fileId, name: args.name, size: buf.length, mime: args.mime,
|
||||||
thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
|
thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
|
||||||
dataBase64: args.dataBase64,
|
dataBase64: args.dataBase64,
|
||||||
|
...(args.replyTo ? { replyTo: args.replyTo } : {}),
|
||||||
}
|
}
|
||||||
const env: MessageEnvelope = {
|
const env: MessageEnvelope = {
|
||||||
messageId, type: 'image',
|
messageId, type: 'image',
|
||||||
@@ -540,8 +738,12 @@ export function registerIpc() {
|
|||||||
body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any },
|
body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any },
|
||||||
}
|
}
|
||||||
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
||||||
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
|
// 此刻 WS 不开 -> DB 里 status='sent'+savedPath, 留给 flushPendingFor 在对端重连后补发
|
||||||
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
const sentUpdate = ctx!.chatClient.isOpen(args.toDeviceId)
|
||||||
|
? ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
||||||
|
: false
|
||||||
|
if (!sentUpdate) {
|
||||||
|
console.warn(`[sendBuffer] ws closed, update frame will retry on reconnect (mid=${messageId.slice(0, 8)})`)
|
||||||
}
|
}
|
||||||
void fileId
|
void fileId
|
||||||
})().catch((e: any) => {
|
})().catch((e: any) => {
|
||||||
@@ -556,11 +758,17 @@ export function registerIpc() {
|
|||||||
ipcMain.handle('message:recall', async (_e, messageId: string) => {
|
ipcMain.handle('message:recall', async (_e, messageId: string) => {
|
||||||
if (!ctx) return { ok: false }
|
if (!ctx) return { ok: false }
|
||||||
const row = getMessage(messageId)
|
const row = getMessage(messageId)
|
||||||
if (!row) return { ok: false, reason: 'no message' }
|
if (!row) return { ok: false, reason: '消息不存在' }
|
||||||
if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' }
|
if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: '只能撤回自己发的消息' }
|
||||||
|
// 微信式 2 分钟时限
|
||||||
|
const RECALL_LIMIT_MS = 2 * 60_000
|
||||||
|
if (Date.now() - row.ts > RECALL_LIMIT_MS) {
|
||||||
|
const ageMin = Math.round((Date.now() - row.ts) / 60_000)
|
||||||
|
return { ok: false, reason: `超过 2 分钟无法撤回 (已 ${ageMin} 分钟)` }
|
||||||
|
}
|
||||||
// 删除本地
|
// 删除本地
|
||||||
deleteMessage(messageId)
|
deleteMessage(messageId)
|
||||||
// 通知对端
|
// 通知对端 (不传 savedPath 让对端从 DB 自行取 — 这里的 row 还在; 但保险起见让对端查自己的 DB)
|
||||||
ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id)
|
ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id)
|
||||||
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
|
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
@@ -599,7 +807,7 @@ export function registerIpc() {
|
|||||||
const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id)
|
const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id)
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
setStatus('pending')
|
setStatus('pending')
|
||||||
return { ok: false, reason: 'peer offline', status: 'pending' }
|
return { ok: false, reason: '对方设备不在线', status: 'pending', wsDown: true }
|
||||||
}
|
}
|
||||||
if (!ctx.chatClient.isOpen(row.to_id)) {
|
if (!ctx.chatClient.isOpen(row.to_id)) {
|
||||||
setStatus('pending')
|
setStatus('pending')
|
||||||
@@ -701,7 +909,13 @@ export function registerIpc() {
|
|||||||
ensureDownloadDir()
|
ensureDownloadDir()
|
||||||
if (patch.deviceName) {
|
if (patch.deviceName) {
|
||||||
ctx!.self.name = patch.deviceName
|
ctx!.self.name = patch.deviceName
|
||||||
// 通知前端并重启 broadcast 频次
|
}
|
||||||
|
if ('autoStart' in patch && app.isPackaged) {
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({ openAtLogin: !!patch.autoStart, path: process.execPath })
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[settings:set] setLoginItemSettings failed:', e?.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rebuildMenu()
|
rebuildMenu()
|
||||||
broadcastToRenderer('settings:changed', ns)
|
broadcastToRenderer('settings:changed', ns)
|
||||||
@@ -790,6 +1004,147 @@ export function registerIpc() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== 远程: terminal =====
|
||||||
|
|
||||||
|
ipcMain.handle('terminal:open', async (_e, args: { peerId: string; rows?: number; cols?: number; readOnly?: boolean }) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
return ctx.terminal.openOnPeer(args.peerId, {
|
||||||
|
rows: args.rows ?? 24,
|
||||||
|
cols: args.cols ?? 80,
|
||||||
|
readOnly: args.readOnly,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('terminal:input', (_e, args: { sessionId: string; dataBase64: string }) => {
|
||||||
|
if (!ctx) return false
|
||||||
|
if (!args?.sessionId || !args?.dataBase64) return false
|
||||||
|
const buf = Buffer.from(args.dataBase64, 'base64')
|
||||||
|
return ctx.terminal.sendInput(args.sessionId, buf)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('terminal:resize', (_e, args: { sessionId: string; rows: number; cols: number }) => {
|
||||||
|
if (!ctx) return false
|
||||||
|
return ctx.terminal.resize(args.sessionId, args.rows, args.cols)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('terminal:close', (_e, args: { sessionId: string; reason?: string }) => {
|
||||||
|
if (!ctx) return false
|
||||||
|
ctx.terminal.close(args.sessionId, args.reason)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('terminal:listSessions', () => {
|
||||||
|
if (!ctx) return { client: [], server: [] }
|
||||||
|
return {
|
||||||
|
client: ctx.terminal.listClientSessions(),
|
||||||
|
server: ctx.terminal.listServerSessions(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 远程: forward =====
|
||||||
|
|
||||||
|
ipcMain.handle('forward:open', async (_e, args: { peerId: string; direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
return ctx.forward.openOnPeer(args.peerId, args)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('forward:close', (_e, args: { sessionId: string }) => {
|
||||||
|
if (!ctx) return false
|
||||||
|
// 控制端: 关 clientSession
|
||||||
|
ctx.forward.cleanupClientSession(args.sessionId, 'user')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('forward:listSessions', () => {
|
||||||
|
if (!ctx) return { client: [], server: [] }
|
||||||
|
return {
|
||||||
|
client: ctx.forward.listClientSessions(),
|
||||||
|
server: ctx.forward.listServerSessions(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 远程: usb (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传) =====
|
||||||
|
|
||||||
|
ipcMain.handle('usb:list', async (_e, args: { peerId: string }) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
return ctx.usb.listOnPeer(args.peerId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 列本机 (串口 + libusb + linux usbip) — self-in 模式用
|
||||||
|
ipcMain.handle('usb:listLocal', async () => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
try {
|
||||||
|
const devices = await ctx.usb.listLocal()
|
||||||
|
return { ok: true, devices }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, reason: String(e?.message || e), devices: [] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// attach: 由 renderer 把 UsbAttachConfig 传过来
|
||||||
|
ipcMain.handle('usb:attach', async (_e, args: { peerId: string; direction?: UsbDirection; busId: string; config: UsbAttachConfig }) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
return ctx.usb.attach(args.peerId, { direction: args.direction, busId: args.busId, config: args.config })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 串口: 发送字节 (client 端)
|
||||||
|
ipcMain.handle('usb:serialSend', (_e, args: { sessionId: string; dataBase64: string }) => {
|
||||||
|
if (!ctx) return false
|
||||||
|
const buf = Buffer.from(args.dataBase64, 'base64')
|
||||||
|
return ctx.usb.sendSerialBytes(args.sessionId, buf)
|
||||||
|
})
|
||||||
|
|
||||||
|
// USB 控制传输 (control OUT / control IN)
|
||||||
|
ipcMain.handle('usb:ctrlOut', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; dataBase64?: string }) => {
|
||||||
|
if (!ctx) return { ok: false, status: -1 }
|
||||||
|
return ctx.usb.usbCtrlTransfer(args.sessionId, args.setup, args.dataBase64)
|
||||||
|
})
|
||||||
|
ipcMain.handle('usb:ctrlIn', async (_e, args: { sessionId: string; setup: { requestType: number; request: number; value: number; index: number }; length: number }) => {
|
||||||
|
if (!ctx) return { ok: false, status: -1 }
|
||||||
|
return ctx.usb.usbCtrlIn(args.sessionId, args.setup, args.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
// USB 批量传输 (bulk OUT / bulk IN)
|
||||||
|
ipcMain.handle('usb:bulkOut', async (_e, args: { sessionId: string; endpoint: number; dataBase64: string }) => {
|
||||||
|
if (!ctx) return { ok: false, status: -1 }
|
||||||
|
return ctx.usb.usbBulkOut(args.sessionId, args.endpoint, args.dataBase64)
|
||||||
|
})
|
||||||
|
ipcMain.handle('usb:bulkIn', async (_e, args: { sessionId: string; endpoint: number; length: number; timeoutMs?: number }) => {
|
||||||
|
if (!ctx) return { ok: false, status: -1 }
|
||||||
|
return ctx.usb.usbBulkIn(args.sessionId, args.endpoint, args.length, args.timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('usb:detach', async (_e, args: { sessionId: string }) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
return ctx.usb.detach(args.sessionId, 'user')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('usb:listSessions', () => {
|
||||||
|
if (!ctx) return { client: [], server: [] }
|
||||||
|
return {
|
||||||
|
client: ctx.usb.listClientSessions(),
|
||||||
|
server: ctx.usb.listServerSessions(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 授权 / 审计 =====
|
||||||
|
|
||||||
|
ipcMain.handle('remote:approval:list', () => listPendingApprovals())
|
||||||
|
|
||||||
|
ipcMain.handle('remote:approval:reply', (_e, args: { requestId: string; ok: boolean; remember?: boolean }) => {
|
||||||
|
return replyApproval(args.requestId, args.ok, args.remember)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('audit:list', (_e, args: { limit?: number; since?: number; sessionId?: string } = {}) => {
|
||||||
|
return listAudit(args || {})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('audit:prune', (_e, olderThanMs?: number) => {
|
||||||
|
const days = getSettings().auditRetentionDays
|
||||||
|
const ms = olderThanMs ?? Math.max(1, days) * 24 * 3600 * 1000
|
||||||
|
return pruneAudit(ms)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToEnvelope(row: any): MessageEnvelope {
|
function rowToEnvelope(row: any): MessageEnvelope {
|
||||||
|
|||||||
@@ -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<ElevationResult> {
|
||||||
|
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<DiagnoseResult> {
|
||||||
|
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<DiagnoseResult> {
|
||||||
|
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<ElevationResult> {
|
||||||
|
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<ElevationResult> {
|
||||||
|
if (platform !== 'linux') {
|
||||||
|
return { ok: false, stderr: '仅 Linux 需要此修复' }
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(vendorId) || !Number.isInteger(productId)) {
|
||||||
|
return { ok: false, stderr: 'vid/pid 非法' }
|
||||||
|
}
|
||||||
|
const vid = formatHex(vendorId)
|
||||||
|
const pid = formatHex(productId)
|
||||||
|
// 现有规则文件: /etc/udev/rules.d/99-localnetmsg.rules
|
||||||
|
const RULE_FILE = '/etc/udev/rules.d/99-localnetmsg.rules'
|
||||||
|
const modeLine = mode === 'hid'
|
||||||
|
? `KERNEL=="hidraw*", ATTRS{idVendor}=="${vid}", ATTRS{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"`
|
||||||
|
: `SUBSYSTEM=="usb", ATTR{idVendor}=="${vid}", ATTR{idProduct}=="${pid}", MODE="0666", TAG+="uaccess"`
|
||||||
|
|
||||||
|
// 用临时脚本追加, 再 mv (避免 echo > /etc ... 的转义问题)
|
||||||
|
const tmpFile = join(tmpdir(), `lnm-udev-${Date.now()}.rules`)
|
||||||
|
const banner = `\n# LocalNetMsg auto-rule for ${vid}:${pid} (${new Date().toISOString()})\n${modeLine}\n`
|
||||||
|
writeFileSync(tmpFile, banner)
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
let existing = ''
|
||||||
|
try { existing = readFileSync(RULE_FILE, 'utf8') } catch {}
|
||||||
|
if (existing.includes(`ATTR{idVendor}=="${vid}"`) && existing.includes(`ATTR{idProduct}=="${pid}"`)) {
|
||||||
|
return { ok: true, stdout: '规则已存在, 无需再次添加' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 复制临时文件到 /etc/udev/rules.d/ (需要 root)
|
||||||
|
const cpCmd = `cat '${tmpFile}' >> '${RULE_FILE}' && udevadm control --reload-rules && udevadm trigger`
|
||||||
|
const r = await runElevated(cpCmd)
|
||||||
|
if (!r.ok) return r
|
||||||
|
return { ok: true, stdout: `已写入 ${RULE_FILE}; 已 reload udev 规则.\n${modeLine}\n请重新插拔设备 或 等待几秒后刷新列表.` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows: 串口 (COM) 一般免 admin; HID/raw USB 偶尔需要装 WinUSB 驱动 (一次性, 由 Zadig CLI 或 wdi-simple 自动装)
|
||||||
|
// 这里优先尝试自动装; 失败也给一句"自动修失败, 你可以..."的提示, 不留"请手动下载"的死路
|
||||||
|
export async function fixWindowsHID(vendorId?: number, productId?: number): Promise<ElevationResult> {
|
||||||
|
// 1. 优先尝试用本机已存在的 Zadig.exe (PATH 或 resources/zadig.exe)
|
||||||
|
const candidates = [
|
||||||
|
join(process.resourcesPath || '', 'zadig.exe'),
|
||||||
|
'C:\\Program Files\\Zadig\\Zadig.exe',
|
||||||
|
]
|
||||||
|
let zadigPath: string | null = null
|
||||||
|
for (const p of candidates) {
|
||||||
|
try { if (existsSync(p)) { zadigPath = p; break } } catch {}
|
||||||
|
}
|
||||||
|
if (!zadigPath) {
|
||||||
|
// 没有 Zadig: 静默下载到 userData 并运行 (不再让用户手动下)
|
||||||
|
try {
|
||||||
|
const dl = await import('node:https')
|
||||||
|
const dest = join(process.env.APPDATA || process.env.HOME || tmpdir(), 'LocalNetMsg', 'zadig.exe')
|
||||||
|
zadigPath = await new Promise<string>((resolve, reject) => {
|
||||||
|
const file = require('node:fs').createWriteStream(dest)
|
||||||
|
const req = dl.get('https://github.com/pbatard/libwdi/releases/download/v1.5.1/zadig-2.9.exe', (res: any) => {
|
||||||
|
if (res.statusCode !== 200) { reject(new Error(`下载失败: ${res.statusCode}`)); return }
|
||||||
|
res.pipe(file)
|
||||||
|
file.on('finish', () => file.close(() => resolve(dest)))
|
||||||
|
})
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, stderr: `自动获取驱动安装器失败: ${e?.message || e}. 设备可能仍可用 — 内置 HID 类驱动对常见设备已足够.` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 用 Zadig CLI 安装 WinUSB (需要 admin)
|
||||||
|
const cmd = vendorId && productId
|
||||||
|
? `"${zadigPath}" --mode=install --vid=${vendorId} --pid=${productId} --driver=winusb`
|
||||||
|
: `"${zadigPath}" --mode=install --driver=winusb`
|
||||||
|
const r = await runElevated(cmd)
|
||||||
|
if (r.ok) return { ...r, stdout: (r.stdout || '') + '\nWinUSB 驱动已安装, 请重新插拔设备.' }
|
||||||
|
return { ...r, stderr: (r.stderr || '') + '\n自动装驱动失败 — 内置 HID 类驱动对常见键鼠手柄已足够, 可直接重试.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 诊断 + 一键修复 串口 权限
|
||||||
|
export async function diagnoseAndFixSerial(): Promise<{ diagnose: DiagnoseResult; fix?: ElevationResult }> {
|
||||||
|
const d = await diagnoseSerial()
|
||||||
|
let fix: ElevationResult | undefined
|
||||||
|
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 <peer> -b <busid>` 让内核接管.
|
||||||
|
// 用户机器需要先装 `usbip` 包 + 加载 vhci_hcd 模块. 一次性.
|
||||||
|
|
||||||
|
// Linux: 检查 + 装 usbip + 加载内核模块 (vhci_hcd, usbip_core)
|
||||||
|
export async function diagnoseAndFixUsbAttach(): Promise<ElevationResult> {
|
||||||
|
if (platform !== 'linux') return { ok: true, stdout: '非 Linux 不支持 USB/IP' }
|
||||||
|
// 检查 usbip 命令
|
||||||
|
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 已就绪' }
|
||||||
|
}
|
||||||
@@ -35,17 +35,157 @@ export type WsFrame =
|
|||||||
| { type: 'ack'; messageId: string; status: 'sent' | 'delivered' | 'read' }
|
| { type: 'ack'; messageId: string; status: 'sent' | 'delivered' | 'read' }
|
||||||
| { type: 'recall'; messageId: string; ts: number }
|
| { type: 'recall'; messageId: string; ts: number }
|
||||||
| { type: 'typing'; from: string; ts: number }
|
| { type: 'typing'; from: string; ts: number }
|
||||||
|
| { type: 'read'; upTo: string; ts: number } // 接收方发, upTo = 看到的最后一条 messageId; ≤ upTo 的发送方消息视为已读
|
||||||
|
| { type: 'terminal'; payload: TerminalFrame } // 远程终端 (双向多路复用)
|
||||||
|
| { type: 'forward'; payload: ForwardFrame } // 端口转发
|
||||||
|
| { type: 'usb'; payload: UsbFrame } // USB 设备透传
|
||||||
|
|
||||||
|
// ============ 远程终端 ============
|
||||||
|
|
||||||
|
export type TerminalFrame =
|
||||||
|
| { type: 'open'; sessionId: string; rows: number; cols: number; shell?: string; readOnly?: boolean }
|
||||||
|
| { type: 'input'; sessionId: string; data: string /* base64 of bytes */ }
|
||||||
|
| { type: 'output'; sessionId: string; data: string /* base64 of bytes */ }
|
||||||
|
| { type: 'resize'; sessionId: string; rows: number; cols: number }
|
||||||
|
| { type: 'close'; sessionId: string; reason?: string }
|
||||||
|
| { type: 'ack'; sessionId: string; ok: boolean; reason?: string }
|
||||||
|
|
||||||
|
// ============ 端口转发 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转发方向:
|
||||||
|
* 'self-out': 控制端作为代理入口 — 控制端开 127.0.0.1 TCP server, 被控端连自己的目标
|
||||||
|
* 用途: 把对方的 127.0.0.1:8080 "挪到" 自己的 localhost 访问
|
||||||
|
* 'self-in': 控制端作为被代理目标 — 控制端连自己的目标, 被控端开 127.0.0.1 TCP server
|
||||||
|
* 用途: 把自己的 127.0.0.1:8080 暴露到对方的 localhost 让对方访问
|
||||||
|
*/
|
||||||
|
export type ForwardDirection = 'self-out' | 'self-in'
|
||||||
|
|
||||||
|
export type ForwardFrame =
|
||||||
|
| { type: 'open'; sessionId: string; direction: ForwardDirection; listenPort: number; targetHost: string; targetPort: number; ttlSec: number }
|
||||||
|
| { type: 'data'; sessionId: string; dir: 'c2s' | 's2c'; data: string /* base64 */; fin?: boolean }
|
||||||
|
| { type: 'close'; sessionId: string; reason?: string; bytesIn?: number; bytesOut?: number }
|
||||||
|
| { type: 'ack'; sessionId: string; ok: boolean; reason?: string }
|
||||||
|
|
||||||
|
// ============ USB / 串口 共享 ============
|
||||||
|
//
|
||||||
|
// 老实说: 纯 Node.js 在 Windows 上做不到 "USB 设备完全透明地变成本机 USB" — 那是商业软件
|
||||||
|
// (VirtualHere / USB Network Gate / FlexiHub) 或 Linux usbip + WSL 才有的能力.
|
||||||
|
//
|
||||||
|
// 我们提供三种能力, 按场景取用:
|
||||||
|
// 1. 'serial' — 真正的双向字节流转发, 适合 USB-串口适配器 (CH340/CP210x/FTDI) 和原生串口.
|
||||||
|
// 在 OS 看来就是普通的串口, 桥接完整 (无协议).
|
||||||
|
// 2. 'usb' — libusb 字节桥: server 端 node-usb open + claim interface, client 端发
|
||||||
|
// controlTransfer / bulkTransfer 请求并收结果. 适合访问自定义 USB 设备
|
||||||
|
// (单片机, 编程器, 调试器). 不是透明 USB — 是远程调设备.
|
||||||
|
// 3. 'usbip' — Linux only, 调系统 usbip CLI 做真透明 USB 透传. 用户机器必须装了
|
||||||
|
// usbip + 加载 vhci_hcd 模块. macOS/Windows 默认不可用, UI 会隐藏.
|
||||||
|
|
||||||
|
export interface UsbDeviceInfo {
|
||||||
|
busId: string // 总线 id, 用于唯一识别 + attach
|
||||||
|
vid: number
|
||||||
|
pid: number
|
||||||
|
deviceClass: number
|
||||||
|
deviceSubclass: number
|
||||||
|
product?: string
|
||||||
|
manufacturer?: string
|
||||||
|
serialNumber?: string
|
||||||
|
port?: string // node-hid 的 path (HID 模式)
|
||||||
|
/** 'serial' = 字节流转发, 'usb' = libusb 字节桥 (control/bulk), 'usbip' = Linux 透明透传 */
|
||||||
|
kind: 'serial' | 'usb' | 'usbip'
|
||||||
|
serialPath?: string // 串口路径, e.g. COM3 / /dev/ttyUSB0
|
||||||
|
baudRate?: number // 串口默认波特率
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USB 方向: 同 Forward
|
||||||
|
* 'self-out': 控制端使用对端的设备 (对端打开真实设备)
|
||||||
|
* 'self-in': 对端使用控制端的设备 (控制端打开真实设备)
|
||||||
|
*/
|
||||||
|
export type UsbDirection = 'self-out' | 'self-in'
|
||||||
|
|
||||||
|
// attach 时附带的配置 (按 kind 区分)
|
||||||
|
export type UsbAttachConfig =
|
||||||
|
| { kind: 'serial'; baudRate?: number; dataBits?: 5|6|7|8; stopBits?: 1|2; parity?: 'none'|'even'|'odd'|'mark'|'space'; createVirtual?: boolean; virtualName?: string }
|
||||||
|
| { kind: 'usb'; configurationValue?: number; interfaceNumber?: number; detachKernelDriver?: boolean }
|
||||||
|
| { kind: 'usbip' }
|
||||||
|
|
||||||
|
// attached 帧带回的设备元信息 (主要用于 usb 模式, 让 UI 知道有哪些 endpoint)
|
||||||
|
export interface UsbEndpointInfo {
|
||||||
|
endpointNumber: number
|
||||||
|
direction: 'in' | 'out'
|
||||||
|
transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous'
|
||||||
|
packetSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsbAttachedInfo {
|
||||||
|
kind: 'serial' | 'usb' | 'usbip'
|
||||||
|
// usb 模式: 设备的 endpoint 列表
|
||||||
|
endpoints?: UsbEndpointInfo[]
|
||||||
|
// 串口模式: 远端真实串口路径
|
||||||
|
serialPath?: string
|
||||||
|
// 串口模式: 本机虚拟串口路径 (createVirtual=true 时填)
|
||||||
|
userVirtualPath?: string
|
||||||
|
// 设备基础信息
|
||||||
|
vid?: number
|
||||||
|
pid?: number
|
||||||
|
product?: string
|
||||||
|
manufacturer?: string
|
||||||
|
serialNumber?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// USB 控制传输 setup 包 (libusb 风格)
|
||||||
|
export interface UsbControlSetup {
|
||||||
|
requestType: number // bmRequestType: 方向 | 类型 | 接收方
|
||||||
|
request: number // bRequest
|
||||||
|
value: number // wValue
|
||||||
|
index: number // wIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 模式: 双向, 半关时 fin=true; usb 模式: 不用, 用 control/bulk)
|
||||||
|
| { type: 'data'; sessionId: string; dir: 'host->dev' | 'dev->host'; data: string /* base64 */; fin?: boolean }
|
||||||
|
|
||||||
|
// USB 控制传输 (request-response, 用 reqId 配对)
|
||||||
|
| { type: 'ctrlOut'; sessionId: string; reqId: string; setup: UsbControlSetup; data?: string }
|
||||||
|
| { type: 'ctrlIn'; sessionId: string; reqId: string; setup: UsbControlSetup; length: number }
|
||||||
|
| { type: 'ctrlResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
|
||||||
|
|
||||||
|
// USB 批量/中断传输 (request-response, 用 reqId 配对)
|
||||||
|
| { type: 'bulkOut'; sessionId: string; reqId: string; endpoint: number; data: string }
|
||||||
|
| { type: 'bulkIn'; sessionId: string; reqId: string; endpoint: number; length: number; timeoutMs?: number }
|
||||||
|
| { type: 'bulkResult';sessionId: string; reqId: string; ok: boolean; data?: string; status?: number }
|
||||||
|
|
||||||
|
| { type: 'ack'; reqId?: string; sessionId?: string; ok: boolean; reason?: string }
|
||||||
|
| { type: 'error'; sessionId: string; reason: string }
|
||||||
|
|
||||||
// ============ 消息 ============
|
// ============ 消息 ============
|
||||||
|
|
||||||
export type MessageType = 'text' | 'image' | 'file' | 'system'
|
export type MessageType = 'text' | 'image' | 'file' | 'system'
|
||||||
|
|
||||||
|
// 引用回复的快照: 只携带"足够渲染"作者 + 内容预览, 不依赖原消息存在/可见
|
||||||
|
// (原消息被撤回/删除也不影响这条引用)
|
||||||
|
export interface ReplyRef {
|
||||||
|
messageId: string
|
||||||
|
authorName: string
|
||||||
|
type: MessageType // 只能是 text/file/image, 不允许 system/system
|
||||||
|
preview: string // text: 截断的正文; file/image: 文件名; 多行用空格折
|
||||||
|
}
|
||||||
|
|
||||||
interface BaseMessage {
|
interface BaseMessage {
|
||||||
messageId: string
|
messageId: string
|
||||||
type: MessageType
|
type: MessageType
|
||||||
fromDeviceId: string
|
fromDeviceId: string
|
||||||
toDeviceId: string
|
toDeviceId: string
|
||||||
ts: number
|
ts: number
|
||||||
|
replyTo?: ReplyRef // 可选引用
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextMessage extends BaseMessage {
|
export interface TextMessage extends BaseMessage {
|
||||||
|
|||||||
@@ -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<string, Pending>()
|
||||||
|
const approvedAt = new Map<string, number>()
|
||||||
|
const APPROVE_TTL_MS = 24 * 3600_000
|
||||||
|
|
||||||
|
function cacheKey(peerId: string, kind: RemoteKind, nonce?: string) {
|
||||||
|
// nonce 用于 usb.attach 这种需要每次重新确认的场景
|
||||||
|
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<string, Pending>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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<ForwardFrame, { type: 'open' }>
|
||||||
|
type DataFrame = Extract<ForwardFrame, { type: 'data' }>
|
||||||
|
type CloseF = Extract<ForwardFrame, { type: 'close' }>
|
||||||
|
type AckFrame = Extract<ForwardFrame, { type: 'ack' }>
|
||||||
|
|
||||||
|
// 仅防本地端口转发到自己三端口 (死循环 / 桥接冲突), 其他一概不拦
|
||||||
|
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<string, ClientSession>()
|
||||||
|
private serverSessions = new Map<string, ServerSession>()
|
||||||
|
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<Server> {
|
||||||
|
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<Server> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TerminalFrame, { type: 'open' }>
|
||||||
|
type InputFrame = Extract<TerminalFrame, { type: 'input' }>
|
||||||
|
type ResizeFrame = Extract<TerminalFrame, { type: 'resize' }>
|
||||||
|
type CloseFrame = Extract<TerminalFrame, { type: 'close' }>
|
||||||
|
type AckFrame = Extract<TerminalFrame, { type: 'ack' }>
|
||||||
|
|
||||||
|
export class TerminalManager extends EventEmitter {
|
||||||
|
// 作为被控端 (server): 接收对端的 open
|
||||||
|
private sessions = new Map<string, Session>()
|
||||||
|
// 作为控制端 (client): 自己发起的 session
|
||||||
|
private outgoing = new Map<string, { peerId: string; createdAt: number; rows: number; cols: number; shell: string; readOnly: boolean }>()
|
||||||
|
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<TerminalFrame, { type: 'output' }> = { 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Server (对端发起的, 我打开真实设备) =====
|
||||||
|
interface ServerSession {
|
||||||
|
sessionId: string
|
||||||
|
ownerId: string
|
||||||
|
peerId: string
|
||||||
|
busId: string
|
||||||
|
kind: 'serial'|'usb'|'usbip'
|
||||||
|
// 串口: SerialPort 实例
|
||||||
|
serial?: any
|
||||||
|
// USB: 句柄 + interface
|
||||||
|
usbDev?: any
|
||||||
|
usbInterface?: number
|
||||||
|
bytesIn: number
|
||||||
|
bytesOut: number
|
||||||
|
// 设备元信息 (attach 成功时填, 给 UI 看的 endpoint 列表等)
|
||||||
|
info?: UsbAttachedInfo
|
||||||
|
// 串口 关闭 promise (等数据 drain)
|
||||||
|
serialClosing?: Promise<void>
|
||||||
|
// USB 控制/批量 reqId -> 配对 response
|
||||||
|
pendingCtrl: Map<string, (r: { ok: boolean; data?: Buffer; status?: number }) => void>
|
||||||
|
pendingBulk: Map<string, (r: { ok: boolean; data?: Buffer; status?: number }) => void>
|
||||||
|
// client 端正在等的 attach 结果
|
||||||
|
attachResolver?: (r: { ok: boolean; reason?: string; info?: UsbAttachedInfo }) => void
|
||||||
|
attachRejecter?: (e: Error) => void
|
||||||
|
// USB detach 流程: 我们 detach 时不让 OS 拉回去 — 留给 client
|
||||||
|
detachResolver?: (r: { ok: boolean; reason?: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsbBridgeManager extends EventEmitter {
|
||||||
|
private clientSessions = new Map<string, ClientSession>()
|
||||||
|
private serverSessions = new Map<string, ServerSession>()
|
||||||
|
private chatClient: ChatClient
|
||||||
|
// list 请求的待回包
|
||||||
|
private pendingLists = new Map<string, (r: { ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }) => void>()
|
||||||
|
|
||||||
|
constructor(chatClient: ChatClient) {
|
||||||
|
super()
|
||||||
|
this.chatClient = chatClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 本地设备列举 ======
|
||||||
|
|
||||||
|
// 列本机的: 串口 + libusb 设备 (合并)
|
||||||
|
async listLocal(): Promise<UsbDeviceInfo[]> {
|
||||||
|
const out: UsbDeviceInfo[] = []
|
||||||
|
// 串口
|
||||||
|
if (spMod) {
|
||||||
|
try {
|
||||||
|
const ports: any[] = await spMod.SerialPort.list()
|
||||||
|
for (const p of ports) {
|
||||||
|
out.push({
|
||||||
|
busId: `serial:${p.path}`,
|
||||||
|
vid: p.vendorId ? parseInt(p.vendorId, 16) || 0 : 0,
|
||||||
|
pid: p.productId ? parseInt(p.productId, 16) || 0 : 0,
|
||||||
|
deviceClass: 0x02, deviceSubclass: 0x02,
|
||||||
|
product: p.friendlyName || p.productId,
|
||||||
|
manufacturer: p.manufacturer,
|
||||||
|
serialNumber: p.serialNumber,
|
||||||
|
kind: 'serial',
|
||||||
|
serialPath: p.path,
|
||||||
|
baudRate: 115200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[usb] serialport.list failed:', e?.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// libusb (用 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<UsbFrame, { type: 'attach' }> = { type: 'attach', sessionId, direction: 'self-out', busId, config }
|
||||||
|
const sent = this.chatClient.send({ type: 'usb', payload: frame } as WsFrame, peerId)
|
||||||
|
if (!sent) return Promise.resolve({ ok: false, reason: '对方离线' })
|
||||||
|
|
||||||
|
const sess: ClientSession = {
|
||||||
|
sessionId, peerId, direction: 'self-out', busId, kind,
|
||||||
|
bytesIn: 0, bytesOut: 0, createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
this.clientSessions.set(sessionId, sess)
|
||||||
|
// 不写 audit: 还没成功, 也不在失败语义里; 真正的 result 在 waitAttached 里写
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (this.clientSessions.has(sessionId)) {
|
||||||
|
this.cleanupClientSession(sessionId, '对方 30s 内未响应')
|
||||||
|
resolve({ ok: false, reason: '对方 30s 内未响应' })
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
// 单独的 resolver: 控制端由 'attached' 帧 (被控端 attach-ack) resolve
|
||||||
|
sess.info = undefined
|
||||||
|
const waitAttached = (p: any) => {
|
||||||
|
if (p.sessionId !== sessionId) return
|
||||||
|
this.chatClient.off('usb:attached' as any, waitAttached as any)
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (p.ok) {
|
||||||
|
sess.info = p.info
|
||||||
|
// usbip: 调 `usbip attach -r <peer> -b <busid>` 真正 attach
|
||||||
|
if (kind === 'usbip') {
|
||||||
|
this.shellUsbipAttach(peerId, busId).then((r) => {
|
||||||
|
if (!r.ok) {
|
||||||
|
this.cleanupClientSession(sessionId, r.reason || 'usbip attach 失败')
|
||||||
|
resolve({ ok: false, reason: r.reason })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' })
|
||||||
|
recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } })
|
||||||
|
resolve({ ok: true, sessionId })
|
||||||
|
}).catch((e) => {
|
||||||
|
this.cleanupClientSession(sessionId, e?.message || 'usbip 失败')
|
||||||
|
resolve({ ok: false, reason: e?.message || 'usbip 失败' })
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// serial + createVirtual: 先建本机虚拟串口, 再开内部端, 再 resolve
|
||||||
|
if (kind === 'serial') {
|
||||||
|
const sCfg = config as Extract<UsbAttachConfig, { kind: 'serial' }>
|
||||||
|
if (sCfg.createVirtual) {
|
||||||
|
this.setupLocalVirtualSerial(sessionId, sCfg).then((r: { ok: boolean; reason?: string; userPath?: string }) => {
|
||||||
|
if (!r.ok) {
|
||||||
|
this.cleanupClientSession(sessionId, r.reason || '虚拟串口创建失败')
|
||||||
|
resolve({ ok: false, reason: r.reason })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess.info = { ...(p.info || {}), kind: 'serial', userVirtualPath: r.userPath } as any
|
||||||
|
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: sess.info, side: 'client' })
|
||||||
|
recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out', virtualPath: r.userPath } })
|
||||||
|
resolve({ ok: true, sessionId })
|
||||||
|
}).catch((e: Error) => {
|
||||||
|
this.cleanupClientSession(sessionId, e?.message || '虚拟串口异常')
|
||||||
|
resolve({ ok: false, reason: e?.message || '虚拟串口异常' })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit('sessionOpened', { sessionId, peerId, direction: 'self-out', busId, kind, info: p.info, side: 'client' })
|
||||||
|
recordAudit({ action: 'usb.attach', target: peerId, sessionId, result: 'ok', payload: { kind, busId, direction: 'self-out' } })
|
||||||
|
resolve({ ok: true, sessionId })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.clientSessions.delete(sessionId)
|
||||||
|
resolve({ ok: false, reason: p.reason || '对方拒绝' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.chatClient.on('usb:attached' as any, waitAttached as any)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// self-in: 我方是 server, 设备在我方
|
||||||
|
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<UsbAttachConfig, { kind: 'serial' }>
|
||||||
|
const sp = new spMod.SerialPort({
|
||||||
|
path: dev.serialPath!,
|
||||||
|
baudRate: c.baudRate || 115200,
|
||||||
|
dataBits: c.dataBits || 8,
|
||||||
|
stopBits: c.stopBits || 1,
|
||||||
|
parity: c.parity || 'none',
|
||||||
|
autoOpen: false,
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
sp.open((err: any) => err ? reject(err) : resolve())
|
||||||
|
})
|
||||||
|
sess.serial = sp
|
||||||
|
// 接收字节 → 发给 client
|
||||||
|
sp.on('data', (buf: Buffer) => {
|
||||||
|
if (buf.length === 0) return
|
||||||
|
sess.bytesIn += buf.length
|
||||||
|
this.sendChunked(peerId, sessionId, 'dev->host', buf, false)
|
||||||
|
this.emit('output', sessionId, buf, peerId)
|
||||||
|
})
|
||||||
|
sp.on('close', () => {
|
||||||
|
this.sendControl(peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true })
|
||||||
|
this.cleanupServerSession(sessionId, 'serial-closed')
|
||||||
|
})
|
||||||
|
sp.on('error', (e: any) => {
|
||||||
|
this.emit('sessionError', { sessionId, peerId, error: e.message })
|
||||||
|
recordAudit({ action: 'usb.attach', source: peerId, sessionId, result: 'error', payload: { kind, error: e.message } })
|
||||||
|
})
|
||||||
|
sess.info = { kind: 'serial', serialPath: dev.serialPath, vid: dev.vid, pid: dev.pid, product: dev.product, manufacturer: dev.manufacturer, serialNumber: dev.serialNumber }
|
||||||
|
} else if (kind === 'usb') {
|
||||||
|
if (!usbMod) throw new Error('node-usb 未安装')
|
||||||
|
const c = config as Extract<UsbAttachConfig, { kind: 'usb' }>
|
||||||
|
// find by vendorId/productId
|
||||||
|
const dev2: any = await usbMod.webusb.findDeviceByIds(dev.vid, dev.pid).catch(() => null)
|
||||||
|
if (!dev2) throw new Error('libusb 找不到该设备')
|
||||||
|
await dev2.open()
|
||||||
|
const 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<UsbFrame, { type: 'list' }>) {
|
||||||
|
const s = getSettings()
|
||||||
|
if (!s.remoteEnabled) return
|
||||||
|
if (shouldAsk(peer.deviceId, 'usb')) {
|
||||||
|
const reqId = randomUUID()
|
||||||
|
const detail = '对方请求查看本机的 USB / 串口 设备列表'
|
||||||
|
const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() })
|
||||||
|
this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail })
|
||||||
|
const verdict = await reqPromise
|
||||||
|
if (!verdict.ok) return
|
||||||
|
}
|
||||||
|
const devices = await this.listLocal()
|
||||||
|
this.sendControl(peer.deviceId, { type: 'devices', reqId: f.reqId, devices })
|
||||||
|
recordAudit({ action: 'usb.list', source: peer.deviceId, result: 'ok', payload: { count: devices.length } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被控端处理 attach 请求
|
||||||
|
private async handleAttachReq(peer: DeviceInfo, f: Extract<UsbFrame, { type: 'attach' }>) {
|
||||||
|
const s = getSettings()
|
||||||
|
if (!s.remoteEnabled) {
|
||||||
|
this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '对方关闭了远程功能' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (shouldAsk(peer.deviceId, 'usb')) {
|
||||||
|
const detail = describeAttach(f.direction, f.config, f.busId)
|
||||||
|
const reqId = randomUUID()
|
||||||
|
const reqPromise = enqueueApproval({ requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail, ts: Date.now() })
|
||||||
|
this.emit('approvalRequested', { requestId: reqId, peerId: peer.deviceId, peerName: peer.name, kind: 'usb', detail })
|
||||||
|
const verdict = await reqPromise
|
||||||
|
if (!verdict.ok) {
|
||||||
|
this.sendControl(peer.deviceId, { type: 'attached', sessionId: f.sessionId, ok: false, reason: '用户拒绝' })
|
||||||
|
recordAudit({ action: 'usb.attach', source: peer.deviceId, sessionId: f.sessionId, result: 'denied' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (verdict.remember) markApproved(peer.deviceId, 'usb')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 走和 self-in 一样的开设备流程
|
||||||
|
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<UsbFrame, { type: 'detach' }>) {
|
||||||
|
const sess = this.serverSessions.get(f.sessionId)
|
||||||
|
if (!sess) {
|
||||||
|
this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: false, reason: 'session 不存在' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.cleanupServerSession(f.sessionId, f.reason || 'closed-by-peer')
|
||||||
|
this.sendControl(peer.deviceId, { type: 'detached', sessionId: f.sessionId, ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// USB 控制传输: 对方发请求 → 我执行 → 回 ctrlResult
|
||||||
|
private async handleCtrlReq(sess: ServerSession, peer: DeviceInfo, f: Extract<UsbFrame, { type: 'ctrlOut' | 'ctrlIn' }>) {
|
||||||
|
try {
|
||||||
|
const dev = sess.usbDev
|
||||||
|
const setup: any = {
|
||||||
|
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<UsbFrame, { type: 'bulkOut' | 'bulkIn' }>) {
|
||||||
|
try {
|
||||||
|
const dev = sess.usbDev
|
||||||
|
if (f.type === 'bulkOut') {
|
||||||
|
const buf = Buffer.from(f.data, 'base64')
|
||||||
|
const written = await dev.nativeTransferOut(f.endpoint, f.type === 'bulkOut' ? 5000 : 5000, new Uint8Array(buf))
|
||||||
|
sess.bytesOut += written || 0
|
||||||
|
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, status: written || 0 })
|
||||||
|
this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, status: written || 0, bulkIn: false })
|
||||||
|
} else {
|
||||||
|
const timeout = f.timeoutMs || 5000
|
||||||
|
const buf = await dev.nativeTransferIn(f.endpoint, timeout, f.length)
|
||||||
|
if (buf) {
|
||||||
|
const b = Buffer.from(buf)
|
||||||
|
sess.bytesIn += b.length
|
||||||
|
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: true, data: b.toString('base64'), status: b.length })
|
||||||
|
this.emit('usbEvent', sess.sessionId, peer.deviceId, { ok: true, data: b, status: b.length, bulkIn: true })
|
||||||
|
} else {
|
||||||
|
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.sendControl(peer.deviceId, { type: 'bulkResult', sessionId: sess.sessionId, reqId: f.reqId, ok: false, status: -1 })
|
||||||
|
this.emit('sessionError', { sessionId: sess.sessionId, peerId: peer.deviceId, error: `bulk: ${e?.message || e}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 我方作为控制端, 给对端发 control/bulk 请求 ======
|
||||||
|
|
||||||
|
async usbCtrlTransfer(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, data?: string): Promise<{ ok: boolean; data?: Buffer; status?: number }> {
|
||||||
|
const sess = this.clientSessions.get(sessionId)
|
||||||
|
if (!sess) return { ok: false, status: -1 }
|
||||||
|
const reqId = randomUUID()
|
||||||
|
if (data) {
|
||||||
|
this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup, data })
|
||||||
|
} else {
|
||||||
|
// 没有 data = host 想读; 但读要 length — 暂时用 0 (调用方应该用 ctrlIn)
|
||||||
|
this.sendControl(sess.peerId, { type: 'ctrlOut', sessionId, reqId, setup })
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve({ ok: false, status: -1 })
|
||||||
|
}, 5000)
|
||||||
|
const onEvt = (p: any) => {
|
||||||
|
if (p.controlIn !== true) return
|
||||||
|
// 注意: 我们 emit usbEvent 是用 (sessionId, peerId, payload) 三参, 这里 listener 形参 1 是 sessionId, 但我们只比较 sessionId
|
||||||
|
// 简化: 一个内部 map
|
||||||
|
}
|
||||||
|
void onEvt
|
||||||
|
// 直接挂在 self: 用 'once' 等一个标记
|
||||||
|
const handler = (sid: string, _pid: string, payload: any) => {
|
||||||
|
if (sid !== sessionId) return
|
||||||
|
if (payload.status === undefined) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.off('usbEvent', handler as any)
|
||||||
|
resolve({ ok: payload.ok, data: payload.data, status: payload.status })
|
||||||
|
}
|
||||||
|
this.on('usbEvent', handler as any)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async usbCtrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number): Promise<{ ok: boolean; data?: Buffer; status?: number }> {
|
||||||
|
const sess = this.clientSessions.get(sessionId)
|
||||||
|
if (!sess) return { ok: false, status: -1 }
|
||||||
|
const reqId = randomUUID()
|
||||||
|
this.sendControl(sess.peerId, { type: 'ctrlIn', sessionId, reqId, setup, length })
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
resolve({ ok: false, status: -1 })
|
||||||
|
}, 5000)
|
||||||
|
const handler = (sid: string, _pid: string, payload: any) => {
|
||||||
|
if (sid !== sessionId) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.off('usbEvent', handler as any)
|
||||||
|
resolve({ ok: payload.ok, data: payload.data, status: payload.status })
|
||||||
|
}
|
||||||
|
this.on('usbEvent', handler as any)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async usbBulkOut(sessionId: string, endpoint: number, data: string): Promise<{ ok: boolean; status?: number }> {
|
||||||
|
const sess = this.clientSessions.get(sessionId)
|
||||||
|
if (!sess) return { ok: false, status: -1 }
|
||||||
|
const reqId = randomUUID()
|
||||||
|
this.sendControl(sess.peerId, { type: 'bulkOut', sessionId, reqId, endpoint, data })
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve({ ok: false, status: -1 }), 5000)
|
||||||
|
const handler = (sid: string, _pid: string, payload: any) => {
|
||||||
|
if (sid !== sessionId) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.off('usbEvent', handler as any)
|
||||||
|
resolve({ ok: payload.ok, status: payload.status })
|
||||||
|
}
|
||||||
|
this.on('usbEvent', handler as any)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async usbBulkIn(sessionId: string, endpoint: number, length: number, timeoutMs = 5000): Promise<{ ok: boolean; data?: Buffer; status?: number }> {
|
||||||
|
const sess = this.clientSessions.get(sessionId)
|
||||||
|
if (!sess) return { ok: false, status: -1 }
|
||||||
|
const reqId = randomUUID()
|
||||||
|
this.sendControl(sess.peerId, { type: 'bulkIn', sessionId, reqId, endpoint, length, timeoutMs })
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve({ ok: false, status: -1 }), timeoutMs + 1000)
|
||||||
|
const handler = (sid: string, _pid: string, payload: any) => {
|
||||||
|
if (sid !== sessionId) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
this.off('usbEvent', handler as any)
|
||||||
|
resolve({ ok: payload.ok, data: payload.data, status: payload.status })
|
||||||
|
}
|
||||||
|
this.on('usbEvent', handler as any)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 串口: 发送字节 (client 端)
|
||||||
|
async sendSerialBytes(sessionId: string, buf: Buffer): Promise<boolean> {
|
||||||
|
const sess = this.clientSessions.get(sessionId)
|
||||||
|
if (!sess || sess.kind !== 'serial') return false
|
||||||
|
sess.bytesOut += buf.length
|
||||||
|
this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 串口: 在本机创建虚拟串口, 打开内部端做桥接 (self-out + createVirtual)
|
||||||
|
private async setupLocalVirtualSerial(sessionId: string, config: Extract<UsbAttachConfig, { kind: 'serial' }>): Promise<{ ok: boolean; reason?: string; userPath?: string }> {
|
||||||
|
if (!spMod) return { ok: false, reason: 'serialport 未安装' }
|
||||||
|
const sess = this.clientSessions.get(sessionId)
|
||||||
|
if (!sess) return { ok: false, reason: 'session 不存在' }
|
||||||
|
|
||||||
|
// 1. 创建 PTY 配对 / com0com
|
||||||
|
let vcom: VirtualSerialInfo
|
||||||
|
try {
|
||||||
|
vcom = await createVirtualSerialPair({ userPath: config.virtualName })
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, reason: `创建虚拟串口失败: ${e?.message || e}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 用我们传的 baudRate 打开内部端
|
||||||
|
const baudRate = config.baudRate || 115200
|
||||||
|
let sp: any
|
||||||
|
try {
|
||||||
|
sp = new spMod.SerialPort({
|
||||||
|
path: vcom.internalPath,
|
||||||
|
baudRate,
|
||||||
|
dataBits: config.dataBits || 8,
|
||||||
|
stopBits: config.stopBits || 1,
|
||||||
|
parity: config.parity || 'none',
|
||||||
|
autoOpen: false,
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
sp.open((err: any) => err ? reject(err) : resolve())
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
await vcom.cleanup().catch(() => {})
|
||||||
|
return { ok: false, reason: `打开内部虚拟端失败: ${e?.message || e}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 数据桥接
|
||||||
|
// 内部端收到字节 (用户 app 写进来的) → WS host->dev → 远端写真实串口
|
||||||
|
sp.on('data', (buf: Buffer) => {
|
||||||
|
if (buf.length === 0) return
|
||||||
|
sess.bytesOut += buf.length
|
||||||
|
this.sendChunked(sess.peerId, sessionId, 'host->dev', buf, false)
|
||||||
|
this.emit('output', sessionId, buf, sess.peerId)
|
||||||
|
})
|
||||||
|
sp.on('close', () => {
|
||||||
|
// 内部端被关 (用户 app 关闭了虚拟串口?) -> 通知远端
|
||||||
|
this.sendControl(sess.peerId, { type: 'data', sessionId, dir: 'host->dev', data: '', fin: true })
|
||||||
|
this.cleanupClientSession(sessionId, 'virtual-serial-closed-by-user-app')
|
||||||
|
})
|
||||||
|
sp.on('error', (e: any) => {
|
||||||
|
this.emit('sessionError', { sessionId, peerId: sess.peerId, error: `virtual-serial: ${e.message}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. 存到 session, detach 时关
|
||||||
|
sess.localVirtual = {
|
||||||
|
port: sp,
|
||||||
|
userPath: vcom.userPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
try { sp.removeAllListeners() } catch {}
|
||||||
|
try { if (sp.isOpen) await new Promise<void>((r) => sp.close(() => r())) } catch {}
|
||||||
|
await vcom.cleanup().catch(() => {})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAudit({ action: 'usb.vcom.create', target: sess.peerId, sessionId, result: 'ok', payload: { userPath: vcom.userPath, internalPath: vcom.internalPath, platform: vcom.platform } })
|
||||||
|
return { ok: true, userPath: vcom.userPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== detach ======
|
||||||
|
|
||||||
|
async detach(sessionId: string, reason = 'user'): Promise<{ ok: boolean; reason?: string }> {
|
||||||
|
if (this.clientSessions.has(sessionId)) {
|
||||||
|
const sess = this.clientSessions.get(sessionId)!
|
||||||
|
// usbip 模式: detach 本地
|
||||||
|
if (sess.kind === 'usbip') {
|
||||||
|
try {
|
||||||
|
const { exec: execSync } = await import('node:child_process')
|
||||||
|
const { promisify } = await import('node:util')
|
||||||
|
const execAsync = promisify(execSync as any)
|
||||||
|
await execAsync('usbip detach --all 2>/dev/null || true').catch(() => {})
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// 发 detach 给对端
|
||||||
|
this.sendControl(sess.peerId, { type: 'detach', sessionId, reason })
|
||||||
|
this.cleanupClientSession(sessionId, reason)
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
if (this.serverSessions.has(sessionId)) {
|
||||||
|
const sess = this.serverSessions.get(sessionId)!
|
||||||
|
this.cleanupServerSession(sessionId, reason)
|
||||||
|
// 通知对端
|
||||||
|
this.sendControl(sess.peerId, { type: 'detached', sessionId, ok: true, reason })
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
return { ok: false, reason: 'session 不存在' }
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupClientSession(sessionId: string, reason = 'user') {
|
||||||
|
const s = this.clientSessions.get(sessionId)
|
||||||
|
if (!s) return
|
||||||
|
// 关掉本机虚拟串口 (如果有)
|
||||||
|
if (s.localVirtual) {
|
||||||
|
s.localVirtual.cleanup().catch(() => {})
|
||||||
|
}
|
||||||
|
this.clientSessions.delete(sessionId)
|
||||||
|
this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'client' })
|
||||||
|
recordAudit({ action: 'usb.close', target: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupServerSession(sessionId: string, reason = 'user') {
|
||||||
|
const s = this.serverSessions.get(sessionId)
|
||||||
|
if (!s) return
|
||||||
|
// 关掉所有 pending 请求
|
||||||
|
for (const r of s.pendingCtrl.values()) r({ ok: false, status: -1 })
|
||||||
|
for (const r of s.pendingBulk.values()) r({ ok: false, status: -1 })
|
||||||
|
s.pendingCtrl.clear(); s.pendingBulk.clear()
|
||||||
|
// 关设备
|
||||||
|
if (s.serial) {
|
||||||
|
try { s.serial.close() } catch {}
|
||||||
|
}
|
||||||
|
if (s.usbDev) {
|
||||||
|
try { s.usbDev.close() } catch (e: any) { console.warn('[usb] close device:', e?.message) }
|
||||||
|
}
|
||||||
|
this.serverSessions.delete(sessionId)
|
||||||
|
this.emit('sessionClosed', { sessionId, peerId: s.peerId, reason, bytesIn: s.bytesIn, bytesOut: s.bytesOut, side: 'server' })
|
||||||
|
recordAudit({ action: 'usb.close', source: s.peerId, sessionId, result: 'closed', bytesIn: s.bytesIn, bytesOut: s.bytesOut, payload: { reason } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 维护 ======
|
||||||
|
|
||||||
|
listClientSessions() {
|
||||||
|
return Array.from(this.clientSessions.values()).map(s => ({
|
||||||
|
sessionId: s.sessionId, peerId: s.peerId, direction: s.direction, busId: s.busId,
|
||||||
|
kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: s.createdAt, side: 'client' as const,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
listServerSessions() {
|
||||||
|
return Array.from(this.serverSessions.values()).map(s => ({
|
||||||
|
sessionId: s.sessionId, peerId: s.peerId, direction: 'self-in' as UsbDirection, busId: s.busId,
|
||||||
|
kind: s.kind, info: s.info, bytesIn: s.bytesIn, bytesOut: s.bytesOut, createdAt: Date.now(), side: 'server' as const,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
onPeerDisconnected(peerId: string) {
|
||||||
|
for (const [sid, s] of this.clientSessions) {
|
||||||
|
if (s.peerId === peerId) this.cleanupClientSession(sid, 'peer-disconnected')
|
||||||
|
}
|
||||||
|
for (const [sid, s] of this.serverSessions) {
|
||||||
|
if (s.peerId === peerId) this.cleanupServerSession(sid, 'peer-disconnected')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
for (const sid of this.clientSessions.keys()) this.cleanupClientSession(sid, 'shutdown')
|
||||||
|
for (const sid of this.serverSessions.keys()) this.cleanupServerSession(sid, 'shutdown')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 工具 ======
|
||||||
|
|
||||||
|
private sendControl(peerId: string, payload: UsbFrame) {
|
||||||
|
return this.chatClient.send({ type: 'usb', payload } as WsFrame, peerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendChunked(peerId: string, sessionId: string, dir: 'host->dev' | 'dev->host', buf: Buffer, fin: boolean) {
|
||||||
|
let remaining = buf
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
const chunk = remaining.subarray(0, MAX_DATA_CHUNK)
|
||||||
|
remaining = remaining.subarray(MAX_DATA_CHUNK)
|
||||||
|
const lastAndFin = fin && remaining.length === 0
|
||||||
|
this.sendControl(peerId, { type: 'data', sessionId, dir, data: chunk.toString('base64'), fin: lastAndFin })
|
||||||
|
}
|
||||||
|
if (fin && buf.length === 0) {
|
||||||
|
this.sendControl(peerId, { type: 'data', sessionId, dir, data: '', fin: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shellUsbipAttach(peerLanIp: string, busId: string): Promise<{ ok: boolean; reason?: string }> {
|
||||||
|
if (platform !== 'linux') return { ok: false, reason: 'usbip 仅 Linux 可用' }
|
||||||
|
// 1. 确保 vhci_hcd 加载
|
||||||
|
const fix = await diagnoseAndFixUsbAttach()
|
||||||
|
if (!fix.ok) return { ok: false, reason: fix.stderr }
|
||||||
|
// 2. 调 `usbip attach -r <peer> -b <busid>` (busId 要去掉 "usbip:" 前缀)
|
||||||
|
const realBusId = busId.startsWith('usbip:') ? busId.slice(6) : busId
|
||||||
|
try {
|
||||||
|
const { exec: execSync } = await import('node:child_process')
|
||||||
|
const { promisify } = await import('node:util')
|
||||||
|
const execAsync = promisify(execSync as any)
|
||||||
|
const { stdout, stderr } = await execAsync(`usbip attach -r ${peerLanIp} -b ${realBusId}`)
|
||||||
|
return { ok: true, reason: stdout + (stderr || '') }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, reason: `usbip attach 失败: ${e?.message || e}; 输出: ${e?.stdout || ''} ${e?.stderr || ''}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad4(n: number): string {
|
||||||
|
return (n || 0).toString(16).padStart(4, '0').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 模块)`
|
||||||
|
}
|
||||||
@@ -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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<VirtualSerialInfo> {
|
||||||
|
if (platform === 'linux' || platform === 'darwin') {
|
||||||
|
return createViaSocat(opts)
|
||||||
|
}
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return createViaCom0com(opts)
|
||||||
|
}
|
||||||
|
throw new Error(`当前平台不支持虚拟串口: ${platform}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSocat(): Promise<string> {
|
||||||
|
// 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<VirtualSerialInfo> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string> {
|
||||||
|
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<void>((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<string> {
|
||||||
|
// 检查是否已装
|
||||||
|
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<number> {
|
||||||
|
// 列出已用的 COM 端口名
|
||||||
|
const used = new Set<number>()
|
||||||
|
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<VirtualSerialInfo> {
|
||||||
|
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(() => {})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,18 @@ export interface Settings {
|
|||||||
preferredInterface: string
|
preferredInterface: string
|
||||||
/** 备选: 用户也可指定精确地址 (避免接口名漂移) */
|
/** 备选: 用户也可指定精确地址 (避免接口名漂移) */
|
||||||
preferredAddress: string
|
preferredAddress: string
|
||||||
|
/** 远程功能总开关 (false = 完全禁用 terminal/forward/usb, 即使 allowPeers 里有白名单) */
|
||||||
|
remoteEnabled: boolean
|
||||||
|
/** 每个对端的远程操作授权, key = deviceId, value = { terminal, forward, usb } */
|
||||||
|
remoteAllowPeers: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
|
||||||
|
/** 端口转发默认 TTL (秒), 上限 24h */
|
||||||
|
forwardDefaultTtlSec: number
|
||||||
|
/** 端口转发默认字节速率上限 (bytes/s), 0 = 不限 */
|
||||||
|
forwardMaxBytesPerSec: number
|
||||||
|
/** 远程终端 read-only 默认 (禁用 input) */
|
||||||
|
terminalReadOnlyByDefault: boolean
|
||||||
|
/** 审计日志保留天数 */
|
||||||
|
auditRetentionDays: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const store = new Store<Settings>({
|
export const store = new Store<Settings>({
|
||||||
@@ -28,6 +40,12 @@ export const store = new Store<Settings>({
|
|||||||
theme: 'light',
|
theme: 'light',
|
||||||
preferredInterface: '',
|
preferredInterface: '',
|
||||||
preferredAddress: '',
|
preferredAddress: '',
|
||||||
|
remoteEnabled: true,
|
||||||
|
remoteAllowPeers: {},
|
||||||
|
forwardDefaultTtlSec: 3600,
|
||||||
|
forwardMaxBytesPerSec: 10 * 1024 * 1024,
|
||||||
|
terminalReadOnlyByDefault: false,
|
||||||
|
auditRetentionDays: 30,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+54
-4
@@ -9,15 +9,19 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
self: () => ipcRenderer.invoke('self:info'),
|
self: () => ipcRenderer.invoke('self:info'),
|
||||||
listDevices: () => ipcRenderer.invoke('device:list'),
|
listDevices: () => ipcRenderer.invoke('device:list'),
|
||||||
triggerScan: () => ipcRenderer.invoke('device:triggerScan'),
|
triggerScan: () => ipcRenderer.invoke('device:triggerScan'),
|
||||||
|
deleteDevice: (deviceId: string) => ipcRenderer.invoke('device:delete', deviceId),
|
||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId),
|
listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId),
|
||||||
sendText: (toDeviceId: string, content: string) => ipcRenderer.invoke('message:sendText', { toDeviceId, content }),
|
sendText: (toDeviceId: string, content: string, opts?: { replyTo?: any }) =>
|
||||||
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) =>
|
ipcRenderer.invoke('message:sendText', { toDeviceId, content, replyTo: opts?.replyTo }),
|
||||||
|
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean; replyTo?: any }) =>
|
||||||
ipcRenderer.invoke('message:sendFile', { toDeviceId, localPath, ...(opts || {}) }),
|
ipcRenderer.invoke('message:sendFile', { toDeviceId, localPath, ...(opts || {}) }),
|
||||||
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) =>
|
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: any }) =>
|
||||||
ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime }),
|
ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime, replyTo: opts?.replyTo }),
|
||||||
recall: (messageId: string) => ipcRenderer.invoke('message:recall', messageId),
|
recall: (messageId: string) => ipcRenderer.invoke('message:recall', messageId),
|
||||||
|
deleteMessage: (messageId: string, opts?: { deleteFile?: boolean }) => ipcRenderer.invoke('message:delete', messageId, opts),
|
||||||
|
markRead: (peerId: string, upToMessageId: string) => ipcRenderer.invoke('message:markRead', { peerId, upToMessageId }),
|
||||||
retry: (messageId: string) => ipcRenderer.invoke('message:retry', messageId),
|
retry: (messageId: string) => ipcRenderer.invoke('message:retry', messageId),
|
||||||
typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId),
|
typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId),
|
||||||
|
|
||||||
@@ -61,4 +65,50 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
|
|
||||||
// 离线消息: 启动后让主进程把 pending 重新尝试一遍
|
// 离线消息: 启动后让主进程把 pending 重新尝试一遍
|
||||||
flushPending: () => ipcRenderer.invoke('message:flushPending'),
|
flushPending: () => ipcRenderer.invoke('message:flushPending'),
|
||||||
|
|
||||||
|
// 远程: 终端
|
||||||
|
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
|
||||||
|
ipcRenderer.invoke('terminal:open', { peerId, ...(opts || {}) }),
|
||||||
|
terminalInput: (sessionId: string, dataBase64: string) =>
|
||||||
|
ipcRenderer.invoke('terminal:input', { sessionId, dataBase64 }),
|
||||||
|
terminalResize: (sessionId: string, rows: number, cols: number) =>
|
||||||
|
ipcRenderer.invoke('terminal:resize', { sessionId, rows, cols }),
|
||||||
|
terminalClose: (sessionId: string, reason?: string) =>
|
||||||
|
ipcRenderer.invoke('terminal:close', { sessionId, reason }),
|
||||||
|
terminalListSessions: () => ipcRenderer.invoke('terminal:listSessions'),
|
||||||
|
|
||||||
|
// 远程: 端口转发
|
||||||
|
forwardOpen: (peerId: string, args: { direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) =>
|
||||||
|
ipcRenderer.invoke('forward:open', { peerId, ...args }),
|
||||||
|
forwardClose: (sessionId: string) => ipcRenderer.invoke('forward:close', { sessionId }),
|
||||||
|
forwardListSessions: () => ipcRenderer.invoke('forward:listSessions'),
|
||||||
|
|
||||||
|
// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传)
|
||||||
|
usbList: (peerId: string) => ipcRenderer.invoke('usb:list', { peerId }),
|
||||||
|
usbListLocal: () => ipcRenderer.invoke('usb:listLocal'),
|
||||||
|
usbAttach: (peerId: string, busId: string, opts: { direction?: 'self-out' | 'self-in'; config: any }) =>
|
||||||
|
ipcRenderer.invoke('usb:attach', { peerId, busId, ...(opts || {}) }),
|
||||||
|
// 串口: 发送字节
|
||||||
|
usbSerialSend: (sessionId: string, dataBase64: string) =>
|
||||||
|
ipcRenderer.invoke('usb:serialSend', { sessionId, dataBase64 }),
|
||||||
|
// USB: 控制传输
|
||||||
|
usbCtrlOut: (sessionId: string, setup: any, dataBase64?: string) =>
|
||||||
|
ipcRenderer.invoke('usb:ctrlOut', { sessionId, setup, dataBase64 }),
|
||||||
|
usbCtrlIn: (sessionId: string, setup: any, length: number) =>
|
||||||
|
ipcRenderer.invoke('usb:ctrlIn', { sessionId, setup, length }),
|
||||||
|
// USB: 批量传输
|
||||||
|
usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) =>
|
||||||
|
ipcRenderer.invoke('usb:bulkOut', { sessionId, endpoint, dataBase64 }),
|
||||||
|
usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) =>
|
||||||
|
ipcRenderer.invoke('usb:bulkIn', { sessionId, endpoint, length, timeoutMs }),
|
||||||
|
usbDetach: (sessionId: string) => ipcRenderer.invoke('usb:detach', { sessionId }),
|
||||||
|
usbListSessions: () => ipcRenderer.invoke('usb:listSessions'),
|
||||||
|
|
||||||
|
// 授权 / 审计
|
||||||
|
remoteApprovalList: () => ipcRenderer.invoke('remote:approval:list'),
|
||||||
|
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) =>
|
||||||
|
ipcRenderer.invoke('remote:approval:reply', { requestId, ok, remember }),
|
||||||
|
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) =>
|
||||||
|
ipcRenderer.invoke('audit:list', args || {}),
|
||||||
|
auditPrune: (olderThanMs?: number) => ipcRenderer.invoke('audit:prune', olderThanMs),
|
||||||
})
|
})
|
||||||
|
|||||||
+75
-25
@@ -1,17 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
import { onMounted, ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import { useDeviceStore } from '@/stores/device'
|
import { useDeviceStore } from '@/stores/device'
|
||||||
import { useMessageStore } from '@/stores/message'
|
import { useMessageStore } from '@/stores/message'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { useTerminalStore, useForwardStore, useUsbStore, useApprovalStore } from '@/stores/remote'
|
||||||
import Sidebar from '@/components/Sidebar.vue'
|
import Sidebar from '@/components/Sidebar.vue'
|
||||||
import ChatView from '@/components/ChatView.vue'
|
import ChatView from '@/components/ChatView.vue'
|
||||||
import SettingsView from '@/components/SettingsView.vue'
|
import SettingsView from '@/components/SettingsView.vue'
|
||||||
|
import ContextMenu from '@/components/ContextMenu.vue'
|
||||||
|
import ApprovalDialog from '@/components/remote/ApprovalDialog.vue'
|
||||||
import { formatTime } from '@/utils/format'
|
import { formatTime } from '@/utils/format'
|
||||||
import type { DeviceView, MessageView, Settings } from '@/api'
|
import type { DeviceView, MessageView, Settings } from '@/api'
|
||||||
|
|
||||||
const device = useDeviceStore()
|
const device = useDeviceStore()
|
||||||
const message = useMessageStore()
|
const message = useMessageStore()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const terminal = useTerminalStore()
|
||||||
|
const forward = useForwardStore()
|
||||||
|
const usb = useUsbStore()
|
||||||
|
const approval = useApprovalStore()
|
||||||
|
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
const imageViewer = ref<string | null>(null)
|
const imageViewer = ref<string | null>(null)
|
||||||
@@ -29,6 +36,8 @@ function showToast(text: string) {
|
|||||||
setTimeout(() => el.remove(), 2200)
|
setTimeout(() => el.remove(), 2200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unsubs: Array<() => void> = []
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await device.loadSelf()
|
await device.loadSelf()
|
||||||
if (device.self) message.setSelf(device.self.deviceId)
|
if (device.self) message.setSelf(device.self.deviceId)
|
||||||
@@ -36,35 +45,43 @@ onMounted(async () => {
|
|||||||
// 启动后尝试一次 flush pending
|
// 启动后尝试一次 flush pending
|
||||||
setTimeout(() => { window.api.flushPending() }, 1500)
|
setTimeout(() => { window.api.flushPending() }, 1500)
|
||||||
|
|
||||||
window.api.on('boot:ready', () => { device.refresh() })
|
unsubs.push(window.api.on('boot:ready', () => { device.refresh() }))
|
||||||
window.api.on('device:found', (d: DeviceView) => {
|
unsubs.push(window.api.on('device:found', (d: DeviceView) => {
|
||||||
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
||||||
const merged: DeviceView = { ...(device.devices[idx] || ({} as DeviceView)), ...d, online: true, lastSeen: Date.now() }
|
const merged: DeviceView = { ...(device.devices[idx] || ({} as DeviceView)), ...d, online: true, lastSeen: Date.now() }
|
||||||
if (idx >= 0) device.devices[idx] = merged
|
if (idx >= 0) device.devices[idx] = merged
|
||||||
else device.devices.push(merged)
|
else device.devices.push(merged)
|
||||||
})
|
}))
|
||||||
window.api.on('device:updated', (d: DeviceView) => {
|
unsubs.push(window.api.on('device:updated', (d: DeviceView) => {
|
||||||
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
||||||
if (idx >= 0) device.devices[idx] = { ...device.devices[idx], ...d, online: true, lastSeen: Date.now() }
|
if (idx >= 0) device.devices[idx] = { ...device.devices[idx], ...d, online: true, lastSeen: Date.now() }
|
||||||
})
|
}))
|
||||||
window.api.on('device:lost', ({ deviceId }: { deviceId: string }) => {
|
unsubs.push(window.api.on('device:lost', ({ deviceId }: { deviceId: string }) => {
|
||||||
const d = device.devices.find(x => x.deviceId === deviceId)
|
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||||
if (d) d.online = false
|
if (d) d.online = false
|
||||||
})
|
}))
|
||||||
window.api.on('device:online', ({ deviceId }: { deviceId: string }) => {
|
unsubs.push(window.api.on('device:online', ({ deviceId }: { deviceId: string }) => {
|
||||||
const d = device.devices.find(x => x.deviceId === deviceId)
|
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||||
if (d) d.online = true
|
if (d) d.online = true
|
||||||
})
|
}))
|
||||||
window.api.on('device:offline', ({ deviceId }: { deviceId: string }) => {
|
unsubs.push(window.api.on('device:offline', ({ deviceId }: { deviceId: string }) => {
|
||||||
const d = device.devices.find(x => x.deviceId === deviceId)
|
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||||
if (d) d.online = false
|
if (d) d.online = false
|
||||||
})
|
}))
|
||||||
window.api.on('message:received', (env: MessageView) => {
|
// 我们 outgoing WS 接通/断开 — 让 chat header 能区分 "discovery 在线" 和 "WS 在线"
|
||||||
|
unsubs.push(window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => {
|
||||||
|
device.setWsOpen(deviceId, wsOpen)
|
||||||
|
}))
|
||||||
|
unsubs.push(window.api.on('message:received', (env: MessageView) => {
|
||||||
const fromId = env.fromDeviceId
|
const fromId = env.fromDeviceId
|
||||||
// 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次
|
// 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次
|
||||||
// 第二次带 savedPath, 气泡自动有"打开/位置"
|
// 第二次带 savedPath, 气泡自动有"打开/位置"
|
||||||
const fid = (env.body as any)?.fileId
|
const fid = (env.body as any)?.fileId
|
||||||
if (fid && !(fid in message.progressByFileId)) {
|
const savedPath = (env.body as any)?.savedPath
|
||||||
|
if (fid && savedPath) {
|
||||||
|
// 完整帧: 进度条直接清, 防止进度 entry 在 setProgress(0) 时被复活成 sent=0 幽灵
|
||||||
|
message.clearProgress(fid)
|
||||||
|
} else if (fid && !(fid in message.progressByFileId)) {
|
||||||
message.setProgress(fid, 0, (env.body as any)?.size || 0)
|
message.setProgress(fid, 0, (env.body as any)?.size || 0)
|
||||||
}
|
}
|
||||||
message.upsert(env)
|
message.upsert(env)
|
||||||
@@ -83,6 +100,10 @@ onMounted(async () => {
|
|||||||
device.unread = next
|
device.unread = next
|
||||||
}
|
}
|
||||||
window.api.clearUnread(fromId)
|
window.api.clearUnread(fromId)
|
||||||
|
// 已读回执: 当前在 active 对话里看到了新消息, 告诉对端
|
||||||
|
if (env.type !== 'system') {
|
||||||
|
window.api.markRead(fromId, env.messageId).catch(() => {})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
device.refresh().then(() => {
|
device.refresh().then(() => {
|
||||||
@@ -94,8 +115,8 @@ onMounted(async () => {
|
|||||||
else if (env.type === 'file') body = `[文件] ${(env.body as any).name || ''}`
|
else if (env.type === 'file') body = `[文件] ${(env.body as any).name || ''}`
|
||||||
if (body) showToast(`${name}: ${body}`)
|
if (body) showToast(`${name}: ${body}`)
|
||||||
})
|
})
|
||||||
})
|
}))
|
||||||
window.api.on('message:local', (env: MessageView) => {
|
unsubs.push(window.api.on('message:local', (env: MessageView) => {
|
||||||
// env 里的 status 是后端给的, 通常是 'pending' (离线) 或 'sent' (已发送)
|
// env 里的 status 是后端给的, 通常是 'pending' (离线) 或 'sent' (已发送)
|
||||||
const fid = (env.body as any)?.fileId
|
const fid = (env.body as any)?.fileId
|
||||||
const savedPath = (env.body as any)?.savedPath
|
const savedPath = (env.body as any)?.savedPath
|
||||||
@@ -106,15 +127,15 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
// 用 upsert: 同一个 messageId 可能来多次 (第一次 status='sending'/savedPath=null, 第二次已 fill)
|
// 用 upsert: 同一个 messageId 可能来多次 (第一次 status='sending'/savedPath=null, 第二次已 fill)
|
||||||
message.upsert({ ...env, status: env.status || 'pending' })
|
message.upsert({ ...env, status: env.status || 'pending' })
|
||||||
})
|
}))
|
||||||
window.api.on('message:recalled', ({ messageId }: { messageId: string }) => {
|
unsubs.push(window.api.on('message:recalled', ({ messageId }: { messageId: string }) => {
|
||||||
message.remove(messageId)
|
message.remove(messageId)
|
||||||
})
|
}))
|
||||||
window.api.on('message:progress', (e: any) => {
|
unsubs.push(window.api.on('message:progress', (e: any) => {
|
||||||
// { toDeviceId, fileId, sent, total }
|
// { toDeviceId, fileId, sent, total }
|
||||||
message.setProgress(e.fileId, e.sent, e.total)
|
message.setProgress(e.fileId, e.sent, e.total)
|
||||||
})
|
}))
|
||||||
window.api.on('message:statusChanged', ({ messageId, status }: { messageId: string; status: string }) => {
|
unsubs.push(window.api.on('message:statusChanged', ({ messageId, status }: { messageId: string; status: string }) => {
|
||||||
// 终态才清 progress: 'sent' 在新流程里出现得太早 (WS metadata 已发, 但上传还在后台跑)
|
// 终态才清 progress: 'sent' 在新流程里出现得太早 (WS metadata 已发, 但上传还在后台跑)
|
||||||
// 让 setProgress 在 100% 时自动清, 避免误清
|
// 让 setProgress 在 100% 时自动清, 避免误清
|
||||||
if (status === 'delivered' || status === 'failed') {
|
if (status === 'delivered' || status === 'failed') {
|
||||||
@@ -124,11 +145,30 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
message.updateStatus(messageId, status)
|
message.updateStatus(messageId, status)
|
||||||
})
|
}))
|
||||||
window.api.on('message:focus', ({ fromDeviceId }: { fromDeviceId: string }) => {
|
unsubs.push(window.api.on('message:focus', ({ fromDeviceId }: { fromDeviceId: string }) => {
|
||||||
session.setActive(fromDeviceId)
|
session.setActive(fromDeviceId)
|
||||||
|
}))
|
||||||
|
unsubs.push(window.api.on('settings:changed', (s: Settings) => { device.settings = s }))
|
||||||
|
|
||||||
|
// 远程事件订阅
|
||||||
|
unsubs.push(window.api.on('remote:terminal:output', (p) => terminal.onOutput(p)))
|
||||||
|
unsubs.push(window.api.on('remote:terminal:opened', (p) => terminal.onOpened(p)))
|
||||||
|
unsubs.push(window.api.on('remote:terminal:closed', (p) => terminal.onClosed(p)))
|
||||||
|
unsubs.push(window.api.on('remote:forward:opened', (p) => forward.onOpened(p)))
|
||||||
|
unsubs.push(window.api.on('remote:forward:closed', (p) => forward.onClosed(p)))
|
||||||
|
unsubs.push(window.api.on('remote:usb:opened', () => { /* no-op; refresh in component */ }))
|
||||||
|
unsubs.push(window.api.on('remote:usb:closed', (p) => usb.onClosed(p)))
|
||||||
|
unsubs.push(window.api.on('remote:usb:output', (p) => usb.onOutput(p)))
|
||||||
|
unsubs.push(window.api.on('remote:usb:error', (p) => { /* surface via console for now */ console.warn('[usb]', p) }))
|
||||||
|
unsubs.push(window.api.on('remote:approval:requested', (req) => approval.add(req)))
|
||||||
|
|
||||||
|
// 启动时拉取当前 approval 队列 (防止刚启动就出现未读请求)
|
||||||
|
approval.refresh().catch(() => {})
|
||||||
})
|
})
|
||||||
window.api.on('settings:changed', (s: Settings) => { device.settings = s })
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const u of unsubs) try { u() } catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => session.activePeerId, async (id) => {
|
watch(() => session.activePeerId, async (id) => {
|
||||||
@@ -141,6 +181,14 @@ watch(() => session.activePeerId, async (id) => {
|
|||||||
}
|
}
|
||||||
await message.ensureLoaded(id)
|
await message.ensureLoaded(id)
|
||||||
await window.api.clearUnread(id)
|
await window.api.clearUnread(id)
|
||||||
|
// 已读回执: 切到对话时把看到的最后一条发给对端; 对端会把 ≤ upTo.ts 的发送方消息标 'read'
|
||||||
|
const msgs = message.byPeer[id] || []
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
const latest = msgs[msgs.length - 1]
|
||||||
|
if (latest.type !== 'system') {
|
||||||
|
window.api.markRead(id, latest.messageId).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
@@ -168,5 +216,7 @@ document.addEventListener('click', (e) => {
|
|||||||
<div v-if="imageViewer" class="image-viewer" @click="imageViewer = null">
|
<div v-if="imageViewer" class="image-viewer" @click="imageViewer = null">
|
||||||
<img :src="imageViewer" />
|
<img :src="imageViewer" />
|
||||||
</div>
|
</div>
|
||||||
|
<ContextMenu />
|
||||||
|
<ApprovalDialog />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+194
-5
@@ -1,17 +1,22 @@
|
|||||||
// 与主进程 preload 暴露的 window.api 类型一一对应
|
// 与主进程 preload 暴露的 window.api 类型一一对应
|
||||||
export type LnmApi = {
|
export type LnmApi = LnmApiBase & LnmApiExtra
|
||||||
|
|
||||||
|
export type LnmApiBase = {
|
||||||
platform: string
|
platform: string
|
||||||
versions: Record<string, string | undefined>
|
versions: Record<string, string | undefined>
|
||||||
|
|
||||||
self: () => Promise<DeviceSelf | null>
|
self: () => Promise<DeviceSelf | null>
|
||||||
listDevices: () => Promise<DeviceView[]>
|
listDevices: () => Promise<DeviceView[]>
|
||||||
triggerScan: () => Promise<boolean>
|
triggerScan: () => Promise<boolean>
|
||||||
|
deleteDevice: (deviceId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
|
||||||
listMessages: (peerId: string) => Promise<MessageView[]>
|
listMessages: (peerId: string) => Promise<MessageView[]>
|
||||||
sendText: (toDeviceId: string, content: string) => Promise<{ ok: boolean; reason?: string }>
|
sendText: (toDeviceId: string, content: string, opts?: { replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) => Promise<{ ok: boolean; reason?: string }>
|
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean; replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) => Promise<{ ok: boolean; reason?: string }>
|
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string, opts?: { replyTo?: ReplyRef }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
recall: (messageId: string) => Promise<{ ok: boolean; reason?: string }>
|
recall: (messageId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
deleteMessage: (messageId: string, opts?: { deleteFile?: boolean }) => Promise<{ ok: boolean; reason?: string; deletedFile?: string }>
|
||||||
|
markRead: (peerId: string, upToMessageId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||||
retry: (messageId: string) => Promise<{ ok: boolean; reason?: string; status?: string }>
|
retry: (messageId: string) => Promise<{ ok: boolean; reason?: string; status?: string }>
|
||||||
typing: (toDeviceId: string) => Promise<void>
|
typing: (toDeviceId: string) => Promise<void>
|
||||||
|
|
||||||
@@ -38,7 +43,6 @@ export type LnmApi = {
|
|||||||
|
|
||||||
on: (channel: string, cb: (payload: any) => void) => () => void
|
on: (channel: string, cb: (payload: any) => void) => () => void
|
||||||
|
|
||||||
// 触发主进程对所有 pending 消息做一次 flush 尝试
|
|
||||||
flushPending: () => Promise<{ ok: boolean; flushed: number }>
|
flushPending: () => Promise<{ ok: boolean; flushed: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +89,12 @@ export interface Settings {
|
|||||||
theme: 'light' | 'dark'
|
theme: 'light' | 'dark'
|
||||||
preferredInterface?: string
|
preferredInterface?: string
|
||||||
preferredAddress?: string
|
preferredAddress?: string
|
||||||
|
remoteEnabled?: boolean
|
||||||
|
remoteAllowPeers?: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
|
||||||
|
forwardDefaultTtlSec?: number
|
||||||
|
forwardMaxBytesPerSec?: number
|
||||||
|
terminalReadOnlyByDefault?: boolean
|
||||||
|
auditRetentionDays?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetInterface {
|
export interface NetInterface {
|
||||||
@@ -104,3 +114,182 @@ export interface ProgressEvent {
|
|||||||
sent: number
|
sent: number
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 引用回复快照 (与 main/protocol.ts 的 ReplyRef 保持一致)
|
||||||
|
export interface ReplyRef {
|
||||||
|
messageId: string
|
||||||
|
authorName: string
|
||||||
|
type: MessageType
|
||||||
|
preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程: 终端
|
||||||
|
export interface TerminalSessionInfo {
|
||||||
|
sessionId: string
|
||||||
|
peerId: string
|
||||||
|
shell: string
|
||||||
|
rows: number
|
||||||
|
cols: number
|
||||||
|
readOnly: boolean
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程: 端口转发
|
||||||
|
export type ForwardDirection = 'self-out' | 'self-in'
|
||||||
|
|
||||||
|
export interface ForwardSessionInfo {
|
||||||
|
sessionId: string
|
||||||
|
peerId: string
|
||||||
|
direction: ForwardDirection
|
||||||
|
listenPort?: number
|
||||||
|
targetHost: string
|
||||||
|
targetPort: number
|
||||||
|
bytesIn: number
|
||||||
|
bytesOut: number
|
||||||
|
expiresAt: number
|
||||||
|
side: 'client' | 'server'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传)
|
||||||
|
export type UsbDirection = 'self-out' | 'self-in'
|
||||||
|
export type UsbKind = 'serial' | 'usb' | 'usbip'
|
||||||
|
|
||||||
|
export interface UsbDeviceInfo {
|
||||||
|
busId: string
|
||||||
|
vid: number
|
||||||
|
pid: number
|
||||||
|
deviceClass: number
|
||||||
|
deviceSubclass: number
|
||||||
|
product?: string
|
||||||
|
manufacturer?: string
|
||||||
|
serialNumber?: string
|
||||||
|
kind: UsbKind
|
||||||
|
serialPath?: string
|
||||||
|
baudRate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsbEndpointInfo {
|
||||||
|
endpointNumber: number
|
||||||
|
direction: 'in' | 'out'
|
||||||
|
transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous'
|
||||||
|
packetSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsbAttachedInfo {
|
||||||
|
kind: UsbKind
|
||||||
|
endpoints?: UsbEndpointInfo[]
|
||||||
|
serialPath?: string
|
||||||
|
userVirtualPath?: string // 本机虚拟串口路径 (createVirtual=true 时填)
|
||||||
|
vid?: number
|
||||||
|
pid?: number
|
||||||
|
product?: string
|
||||||
|
manufacturer?: string
|
||||||
|
serialNumber?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsbAttachConfig =
|
||||||
|
// serial: 字节流转发
|
||||||
|
| {
|
||||||
|
kind: 'serial'
|
||||||
|
baudRate?: number
|
||||||
|
dataBits?: 5 | 6 | 7 | 8
|
||||||
|
stopBits?: 1 | 2
|
||||||
|
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space'
|
||||||
|
/** 在本机创建一个虚拟串口, 桥接到远端的真实串口 — 用户可在 PuTTY/Arduino IDE 打开该路径 */
|
||||||
|
createVirtual?: boolean
|
||||||
|
/** 可选: 用户指定的虚拟串口名 (Windows: "COM5"; Linux/macOS: 留空自动分配) */
|
||||||
|
virtualName?: string
|
||||||
|
}
|
||||||
|
// usb: libusb 字节桥
|
||||||
|
| {
|
||||||
|
kind: 'usb'
|
||||||
|
configurationValue?: number
|
||||||
|
interfaceNumber?: number
|
||||||
|
detachKernelDriver?: boolean
|
||||||
|
}
|
||||||
|
// usbip: Linux only, 真透明透传 (需内核模块)
|
||||||
|
| {
|
||||||
|
kind: 'usbip'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsbSessionInfo {
|
||||||
|
sessionId: string
|
||||||
|
peerId: string
|
||||||
|
direction: UsbDirection
|
||||||
|
busId: string
|
||||||
|
kind: UsbKind
|
||||||
|
info?: UsbAttachedInfo
|
||||||
|
bytesIn: number
|
||||||
|
bytesOut: number
|
||||||
|
createdAt: number
|
||||||
|
side: 'client' | 'server'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsbTransferResult {
|
||||||
|
ok: boolean
|
||||||
|
data?: number[] // bytes (UI 解码为 Uint8Array, 因为 renderer 端没 Buffer)
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程: 授权请求
|
||||||
|
export interface ApprovalRequestView {
|
||||||
|
requestId: string
|
||||||
|
peerId: string
|
||||||
|
peerName: string
|
||||||
|
kind: 'terminal' | 'forward' | 'usb'
|
||||||
|
detail: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程: 审计
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number
|
||||||
|
ts: number
|
||||||
|
action: string
|
||||||
|
source_device: string | null
|
||||||
|
target_device: string | null
|
||||||
|
session_id: string | null
|
||||||
|
payload_json: string | null
|
||||||
|
result: string
|
||||||
|
bytes_in: number
|
||||||
|
bytes_out: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展 API
|
||||||
|
export type LnmApiExtra = {
|
||||||
|
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
|
||||||
|
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||||
|
terminalInput: (sessionId: string, dataBase64: string) => Promise<boolean>
|
||||||
|
terminalResize: (sessionId: string, rows: number, cols: number) => Promise<boolean>
|
||||||
|
terminalClose: (sessionId: string, reason?: string) => Promise<boolean>
|
||||||
|
terminalListSessions: () => Promise<{ client: TerminalSessionInfo[]; server: TerminalSessionInfo[] }>
|
||||||
|
|
||||||
|
forwardOpen: (peerId: string, args: { direction?: ForwardDirection; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) =>
|
||||||
|
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||||
|
forwardClose: (sessionId: string) => Promise<boolean>
|
||||||
|
forwardListSessions: () => Promise<{ client: ForwardSessionInfo[]; server: ForwardSessionInfo[] }>
|
||||||
|
|
||||||
|
usbList: (peerId: string) => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
|
||||||
|
usbListLocal: () => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
|
||||||
|
usbAttach: (peerId: string, busId: string, opts: { direction?: UsbDirection; config: UsbAttachConfig }) =>
|
||||||
|
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||||
|
// 串口: 发送字节
|
||||||
|
usbSerialSend: (sessionId: string, dataBase64: string) => Promise<boolean>
|
||||||
|
// USB: 控制传输
|
||||||
|
usbCtrlOut: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) =>
|
||||||
|
Promise<{ ok: boolean; status?: number }>
|
||||||
|
usbCtrlIn: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) =>
|
||||||
|
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
|
||||||
|
// USB: 批量传输
|
||||||
|
usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) =>
|
||||||
|
Promise<{ ok: boolean; status?: number }>
|
||||||
|
usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) =>
|
||||||
|
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
|
||||||
|
usbDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
usbListSessions: () => Promise<{ client: UsbSessionInfo[]; server: UsbSessionInfo[] }>
|
||||||
|
|
||||||
|
remoteApprovalList: () => Promise<ApprovalRequestView[]>
|
||||||
|
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise<boolean>
|
||||||
|
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise<AuditEntry[]>
|
||||||
|
auditPrune: (olderThanMs?: number) => Promise<number>
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ import { useMessageStore } from '@/stores/message'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import MessageItem from './MessageItem.vue'
|
import MessageItem from './MessageItem.vue'
|
||||||
import MessageInput from './MessageInput.vue'
|
import MessageInput from './MessageInput.vue'
|
||||||
|
import TerminalPanel from './remote/TerminalPanel.vue'
|
||||||
|
import ForwardPanel from './remote/ForwardPanel.vue'
|
||||||
|
import UsbPanel from './remote/UsbPanel.vue'
|
||||||
import type { DeviceView } from '@/api'
|
import type { DeviceView } from '@/api'
|
||||||
import { initialsOf, colorFor } from '@/utils/format'
|
import { initialsOf, colorFor, formatTime } from '@/utils/format'
|
||||||
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
|
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
|
||||||
import { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue'
|
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const props = defineProps<{ peer: DeviceView | null }>()
|
const props = defineProps<{ peer: DeviceView | null }>()
|
||||||
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
||||||
@@ -17,9 +20,21 @@ const device = useDeviceStore()
|
|||||||
const message = useMessageStore()
|
const message = useMessageStore()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
|
||||||
|
type Tab = 'chat' | 'terminal' | 'forward' | 'usb'
|
||||||
|
const activeTab = ref<Tab>('chat')
|
||||||
|
// 切到别的设备时回到 chat tab
|
||||||
|
watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
|
||||||
|
|
||||||
const self = computed(() => device.self)
|
const self = computed(() => device.self)
|
||||||
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
|
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
|
||||||
const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false)
|
const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false)
|
||||||
|
// 我方 outgoing WS 是否连通 — 严格默认 false (从没收到过 device:wsState 时,
|
||||||
|
// 显示 "等待重连" 而不是骗用户 "在线"; 看到首次 open 事件后才会变成 true)
|
||||||
|
const wsOpen = computed(() => {
|
||||||
|
if (!props.peer) return false
|
||||||
|
const v = device.wsOpen[props.peer.deviceId]
|
||||||
|
return v === undefined ? false : v
|
||||||
|
})
|
||||||
|
|
||||||
const bodyEl = ref<{ scrollTo: (opts: { top: number }) => void } | null>(null)
|
const bodyEl = ref<{ scrollTo: (opts: { top: number }) => void } | null>(null)
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
@@ -28,6 +43,24 @@ function scrollToBottom() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 点击回复卡片 → 滚到原消息 (并短闪高亮)
|
||||||
|
function onScrollToMessage(messageId: string) {
|
||||||
|
const inner = document.querySelector('.chat-body-inner')
|
||||||
|
if (!inner) return
|
||||||
|
const target = inner.querySelector(`[data-message-id="${CSS.escape(messageId)}"]`) as HTMLElement | null
|
||||||
|
if (!target) {
|
||||||
|
ElMessage.info('原消息已撤回或不在当前会话')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 滚到目标附近 (让浏览器负责处理 scrollable 容器)
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
// 短闪高亮 (重启动画 — 强制 reflow 去掉旧 class)
|
||||||
|
target.classList.remove('flash-target')
|
||||||
|
void (target as HTMLElement).offsetWidth
|
||||||
|
target.classList.add('flash-target')
|
||||||
|
setTimeout(() => target.classList.remove('flash-target'), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => session.activePeerId, () => scrollToBottom())
|
watch(() => session.activePeerId, () => scrollToBottom())
|
||||||
watch(() => messages.value.length, () => scrollToBottom())
|
watch(() => messages.value.length, () => scrollToBottom())
|
||||||
|
|
||||||
@@ -105,13 +138,39 @@ async function onGlobalDrop(e: DragEvent) {
|
|||||||
<div class="header-info">
|
<div class="header-info">
|
||||||
<div class="header-name">{{ peer.name }}</div>
|
<div class="header-name">{{ peer.name }}</div>
|
||||||
<div class="header-meta">
|
<div class="header-meta">
|
||||||
<span class="status-dot" :class="{ online: peer.online }"></span>
|
<span
|
||||||
<template v-if="peer.online">在线 · {{ peer.address }}</template>
|
class="status-dot"
|
||||||
<template v-else>离线</template>
|
:class="{ online: peer.online && wsOpen, 'ws-down': peer.online && !wsOpen, offline: !peer.online }"
|
||||||
|
/>
|
||||||
|
<template v-if="!peer.online">
|
||||||
|
离线 · {{ formatTime(peer.lastSeen) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="!wsOpen">
|
||||||
|
在线 · {{ peer.address }} <span class="ws-note">(等待重连…)</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
在线 · {{ peer.address }}
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="header-tabs">
|
||||||
|
<button :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">
|
||||||
|
<el-icon><ChatLineRound /></el-icon><span>聊天</span>
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: activeTab === 'terminal' }" @click="activeTab = 'terminal'" :disabled="!peer.online">
|
||||||
|
<el-icon><Position /></el-icon><span>终端</span>
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: activeTab === 'forward' }" @click="activeTab = 'forward'" :disabled="!peer.online">
|
||||||
|
<el-icon><Connection /></el-icon><span>转发</span>
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: activeTab === 'usb' }" @click="activeTab = 'usb'" :disabled="!peer.online">
|
||||||
|
<el-icon><Cellphone /></el-icon><span>USB</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- chat tab -->
|
||||||
|
<template v-if="activeTab === 'chat'">
|
||||||
<el-scrollbar ref="bodyEl" class="chat-body">
|
<el-scrollbar ref="bodyEl" class="chat-body">
|
||||||
<div class="chat-body-inner">
|
<div class="chat-body-inner">
|
||||||
<MessageItem
|
<MessageItem
|
||||||
@@ -123,6 +182,7 @@ async function onGlobalDrop(e: DragEvent) {
|
|||||||
:peer="peer"
|
:peer="peer"
|
||||||
:self="self!"
|
:self="self!"
|
||||||
@open-image="emit('open-image', $event)"
|
@open-image="emit('open-image', $event)"
|
||||||
|
@scroll-to-message="onScrollToMessage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
@@ -143,6 +203,14 @@ async function onGlobalDrop(e: DragEvent) {
|
|||||||
<el-icon :size="48" color="#fff"><UploadFilled /></el-icon>
|
<el-icon :size="48" color="#fff"><UploadFilled /></el-icon>
|
||||||
<div class="global-drop-hint">松开发送到 {{ peer.name }}</div>
|
<div class="global-drop-hint">松开发送到 {{ peer.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- terminal tab -->
|
||||||
|
<TerminalPanel v-else-if="activeTab === 'terminal'" :peer="peer" :active="activeTab === 'terminal'" />
|
||||||
|
<!-- forward tab -->
|
||||||
|
<ForwardPanel v-else-if="activeTab === 'forward'" :peer="peer" />
|
||||||
|
<!-- usb tab -->
|
||||||
|
<UsbPanel v-else-if="activeTab === 'usb'" :peer="peer" />
|
||||||
</main>
|
</main>
|
||||||
<main class="chat-main chat-main-empty" v-else>
|
<main class="chat-main chat-main-empty" v-else>
|
||||||
<el-empty :image-size="120" description="选择一个设备开始聊天">
|
<el-empty :image-size="120" description="选择一个设备开始聊天">
|
||||||
@@ -196,6 +264,49 @@ async function onGlobalDrop(e: DragEvent) {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.status-dot.online { background: var(--el-color-success); }
|
.status-dot.online { background: var(--el-color-success); }
|
||||||
|
.status-dot.ws-down {
|
||||||
|
background: var(--el-color-warning);
|
||||||
|
animation: ws-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ws-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.ws-note {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tabs {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.header-tabs button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.header-tabs button:hover:not(:disabled) { color: var(--el-text-color-primary); }
|
||||||
|
.header-tabs button.active {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.header-tabs button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
.chat-body { flex: 1; min-height: 0; }
|
.chat-body { flex: 1; min-height: 0; }
|
||||||
.chat-body-inner { padding: 16px 20px 0; }
|
.chat-body-inner { padding: 16px 20px 0; }
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import { ctxMenuState, pickContextMenu, closeContextMenu } from '@/composables/contextMenu'
|
||||||
|
|
||||||
|
const menuEl = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
// 菜单展示后, 调整位置不让菜单跑出屏幕外
|
||||||
|
watch(() => ctxMenuState.visible, async (v) => {
|
||||||
|
if (!v) return
|
||||||
|
await nextTick()
|
||||||
|
if (!menuEl.value) return
|
||||||
|
const rect = menuEl.value.getBoundingClientRect()
|
||||||
|
let nx = ctxMenuState.x, ny = ctxMenuState.y
|
||||||
|
// 优先"右击位置为菜单的左上角"; 如果右侧跑出去, 左移菜单; 下侧跑出去, 上移菜单
|
||||||
|
if (rect.right > window.innerWidth - 8) nx = window.innerWidth - rect.width - 8
|
||||||
|
if (rect.bottom > window.innerHeight - 8) ny = window.innerHeight - rect.height - 8
|
||||||
|
if (nx < 8) nx = 8
|
||||||
|
if (ny < 8) ny = 8
|
||||||
|
if (nx !== ctxMenuState.x || ny !== ctxMenuState.y) {
|
||||||
|
menuEl.value.style.left = nx + 'px'
|
||||||
|
menuEl.value.style.top = ny + 'px'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function pick(cmd: string) { pickContextMenu(cmd) }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- 透明遮罩: 点空白或右键空白都关闭 -->
|
||||||
|
<div
|
||||||
|
v-if="ctxMenuState.visible"
|
||||||
|
class="ctx-overlay"
|
||||||
|
@click="closeContextMenu"
|
||||||
|
@contextmenu.prevent="closeContextMenu"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="ctxMenuState.visible"
|
||||||
|
ref="menuEl"
|
||||||
|
class="ctx-menu"
|
||||||
|
:style="{ left: ctxMenuState.x + 'px', top: ctxMenuState.y + 'px' }"
|
||||||
|
@contextmenu.prevent.stop
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in ctxMenuState.items"
|
||||||
|
:key="item.cmd"
|
||||||
|
class="ctx-item"
|
||||||
|
:class="{ 'ctx-danger': item.danger, 'ctx-divided': item.divided }"
|
||||||
|
@click="pick(item.cmd)"
|
||||||
|
>
|
||||||
|
<el-icon v-if="item.icon" :size="14" class="ctx-icon">
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ctx-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
.ctx-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
min-width: 160px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ctx-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.ctx-item:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
.ctx-icon {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.ctx-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
.ctx-danger .ctx-icon {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
.ctx-danger:hover {
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
.ctx-divided {
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import type { DeviceView } from '@/api'
|
import type { DeviceView, MessageView, ReplyRef } from '@/api'
|
||||||
import { useDeviceStore } from '@/stores/device'
|
import { useDeviceStore } from '@/stores/device'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus'
|
import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus'
|
||||||
import { Link, Picture, Promotion, ChatLineRound, UploadFilled } from '@element-plus/icons-vue'
|
import { Link, Picture, Promotion, ChatLineRound, UploadFilled, Close } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const props = defineProps<{ peer: DeviceView }>()
|
const props = defineProps<{ peer: DeviceView }>()
|
||||||
const device = useDeviceStore()
|
const device = useDeviceStore()
|
||||||
|
const session = useSessionStore()
|
||||||
|
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
@@ -19,6 +21,50 @@ const uploading = ref(false)
|
|||||||
const dragDepth = ref(0) // 用 depth 计数避免子元素进出时误判
|
const dragDepth = ref(0) // 用 depth 计数避免子元素进出时误判
|
||||||
const isDragging = computed(() => dragDepth.value > 0)
|
const isDragging = computed(() => dragDepth.value > 0)
|
||||||
|
|
||||||
|
// 引用回复: 显示被引用条 + × 取消
|
||||||
|
const replyTo = computed(() => session.replyTo)
|
||||||
|
const replyPreview = computed(() => {
|
||||||
|
const m = replyTo.value
|
||||||
|
if (!m) return { author: '', text: '', icon: '' }
|
||||||
|
const author = m.fromDeviceId === device.self?.deviceId
|
||||||
|
? (device.self?.name || '我')
|
||||||
|
: (props.peer.name || '对方')
|
||||||
|
const b = m.body
|
||||||
|
let text = ''
|
||||||
|
let icon = ''
|
||||||
|
if (m.type === 'text') {
|
||||||
|
text = (b?.content || '').replace(/\s+/g, ' ').slice(0, 80)
|
||||||
|
icon = '💬'
|
||||||
|
} else if (m.type === 'image') {
|
||||||
|
text = `[图片] ${b?.name || ''}`
|
||||||
|
icon = '🖼'
|
||||||
|
} else if (m.type === 'file') {
|
||||||
|
text = `[文件] ${b?.name || ''}`
|
||||||
|
icon = '📎'
|
||||||
|
}
|
||||||
|
return { author, text, icon }
|
||||||
|
})
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
session.setReplyTo(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切到别的设备时清掉 replyTo (跨对话的引用不合法)
|
||||||
|
watch(() => session.activePeerId, () => session.setReplyTo(null))
|
||||||
|
|
||||||
|
function buildReplyRef(msg: MessageView): ReplyRef {
|
||||||
|
const b = msg.body
|
||||||
|
let preview = ''
|
||||||
|
if (msg.type === 'text') preview = (b?.content || '').replace(/\s+/g, ' ').slice(0, 200)
|
||||||
|
else if (msg.type === 'image') preview = `[图片] ${b?.name || ''}`
|
||||||
|
else if (msg.type === 'file') preview = `[文件] ${b?.name || ''}`
|
||||||
|
// 用真实设备名 (而不是硬编码"我"), 接收方看到的是清晰的人名
|
||||||
|
const authorName = msg.fromDeviceId === device.self?.deviceId
|
||||||
|
? (device.self?.name || '我')
|
||||||
|
: (props.peer.name || '对方')
|
||||||
|
return { messageId: msg.messageId, authorName, type: msg.type, preview }
|
||||||
|
}
|
||||||
|
|
||||||
function autoSize() {
|
function autoSize() {
|
||||||
if (!inputEl.value) return
|
if (!inputEl.value) return
|
||||||
inputEl.value.style.height = 'auto'
|
inputEl.value.style.height = 'auto'
|
||||||
@@ -35,17 +81,20 @@ async function send() {
|
|||||||
const t = text.value.trim()
|
const t = text.value.trim()
|
||||||
if (!t && pendingImages.value.length === 0) return
|
if (!t && pendingImages.value.length === 0) return
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
|
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
|
||||||
try {
|
try {
|
||||||
if (t) {
|
if (t) {
|
||||||
const r = await window.api.sendText(props.peer.deviceId, t)
|
const r = await window.api.sendText(props.peer.deviceId, t, replyRef ? { replyTo: replyRef } : undefined)
|
||||||
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
||||||
}
|
}
|
||||||
for (const img of pendingImages.value) {
|
for (const img of pendingImages.value) {
|
||||||
const r = await window.api.sendBuffer(props.peer.deviceId, img.dataBase64, img.name, img.mime)
|
const r = await window.api.sendBuffer(props.peer.deviceId, img.dataBase64, img.name, img.mime,
|
||||||
|
replyRef ? { replyTo: replyRef } : undefined)
|
||||||
if (!r.ok) toast('图片发送失败: ' + (r.reason || ''), 'warning')
|
if (!r.ok) toast('图片发送失败: ' + (r.reason || ''), 'warning')
|
||||||
}
|
}
|
||||||
text.value = ''
|
text.value = ''
|
||||||
pendingImages.value = []
|
pendingImages.value = []
|
||||||
|
session.setReplyTo(null)
|
||||||
autoSize()
|
autoSize()
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
@@ -63,7 +112,11 @@ async function sendLocalFile(localPath: string, name: string, asImage: boolean)
|
|||||||
toast('无法获取文件路径', 'warning')
|
toast('无法获取文件路径', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const r = await window.api.sendFile(props.peer.deviceId, localPath, { asImage, withProgress: false })
|
const replyRef: ReplyRef | undefined = replyTo.value ? buildReplyRef(replyTo.value) : undefined
|
||||||
|
const r = await window.api.sendFile(props.peer.deviceId, localPath, {
|
||||||
|
asImage, withProgress: false,
|
||||||
|
...(replyRef ? { replyTo: replyRef } : {}),
|
||||||
|
})
|
||||||
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +271,13 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
<span class="x" @click="removePending(img.id)" title="移除">×</span>
|
<span class="x" @click="removePending(img.id)" title="移除">×</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="replyTo" class="reply-bar">
|
||||||
|
<div class="reply-bar-text">
|
||||||
|
<div class="reply-bar-author">回复 {{ replyPreview.author }}:</div>
|
||||||
|
<div class="reply-bar-preview">{{ replyPreview.text }}</div>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Close" size="small" plain circle @click="cancelReply" />
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
class="composer-input"
|
class="composer-input"
|
||||||
@@ -350,6 +410,34 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.pending-item .x:hover { background: var(--el-color-danger); }
|
.pending-item .x:hover { background: var(--el-color-danger); }
|
||||||
|
|
||||||
|
.reply-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
border-left: 3px solid var(--el-color-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 6px 10px 0;
|
||||||
|
}
|
||||||
|
.reply-bar-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.reply-bar-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
.reply-bar-preview {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.composer-footer {
|
.composer-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { MessageView, DeviceView, DeviceSelf } from '@/api'
|
import type { MessageView, DeviceView, DeviceSelf, ReplyRef } from '@/api'
|
||||||
import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format'
|
import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format'
|
||||||
import MarkdownView from './MarkdownView.vue'
|
import MarkdownView from './MarkdownView.vue'
|
||||||
import { ElAvatar, ElButton, ElMessage, ElProgress } from 'element-plus'
|
import { ElAvatar, ElButton, ElMessage, ElProgress, ElMessageBox } from 'element-plus'
|
||||||
|
import { DocumentCopy, Delete, DocumentRemove, ChatLineSquare, ArrowLeft } from '@element-plus/icons-vue'
|
||||||
import { useMessageStore } from '@/stores/message'
|
import { useMessageStore } from '@/stores/message'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { showContextMenu, type ContextMenuItem } from '@/composables/contextMenu'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
msg: MessageView
|
msg: MessageView
|
||||||
@@ -13,13 +16,21 @@ const props = defineProps<{
|
|||||||
peer: DeviceView
|
peer: DeviceView
|
||||||
self: DeviceSelf
|
self: DeviceSelf
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
const emit = defineEmits<{
|
||||||
|
(e: 'open-image', url: string): void
|
||||||
|
(e: 'scroll-to-message', messageId: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
|
const session = useSessionStore()
|
||||||
|
|
||||||
const api = window.api
|
const api = window.api
|
||||||
|
|
||||||
const isSelf = computed(() => props.msg.fromDeviceId === props.self.deviceId)
|
const isSelf = computed(() => props.msg.fromDeviceId === props.self.deviceId)
|
||||||
|
const isFileLike = computed(() => props.msg.type === 'file' || props.msg.type === 'image')
|
||||||
|
const isReplyable = computed(() => props.msg.type === 'text' || props.msg.type === 'file' || props.msg.type === 'image')
|
||||||
|
const canRecall = computed(() => isSelf.value && props.msg.type !== 'system')
|
||||||
|
const replyTo = computed<ReplyRef | undefined>(() => (props.msg.body as any)?.replyTo)
|
||||||
|
|
||||||
// 进度条: 文件/图片 + 在传中 (sender: pending/sending/sent, receiver: receiving), 100% 自动消失
|
// 进度条: 文件/图片 + 在传中 (sender: pending/sending/sent, receiver: receiving), 100% 自动消失
|
||||||
const progress = computed(() => {
|
const progress = computed(() => {
|
||||||
@@ -27,9 +38,11 @@ const progress = computed(() => {
|
|||||||
if (!fid) return null
|
if (!fid) return null
|
||||||
const st = props.msg.status
|
const st = props.msg.status
|
||||||
// sender: pending/sending/sent 都在传中; delivered 后等下次刷新就清掉了
|
// sender: pending/sending/sent 都在传中; delivered 后等下次刷新就清掉了
|
||||||
// receiver: 没 status 字段, 有进度记录就显示
|
// receiver: 没 status 字段, 有进度记录就显示 — 但 savedPath 已落地就视为完成, 隐藏进度
|
||||||
if (isSelf.value) {
|
if (isSelf.value) {
|
||||||
if (st && !['pending', 'sending', 'sent'].includes(st)) return null
|
if (st && !['pending', 'sending', 'sent'].includes(st)) return null
|
||||||
|
} else if ((props.msg.body as any)?.savedPath) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
const p = messageStore.progressByFileId[fid]
|
const p = messageStore.progressByFileId[fid]
|
||||||
if (!p || !p.total) return null
|
if (!p || !p.total) return null
|
||||||
@@ -76,10 +89,11 @@ const imageUrl = computed(() => {
|
|||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
switch (props.msg.status) {
|
switch (props.msg.status) {
|
||||||
case 'pending': return '待对方上线'
|
case 'pending': return '等待连接'
|
||||||
case 'sending': return '发送中'
|
case 'sending': return '发送中'
|
||||||
case 'sent': return '已发送'
|
case 'sent': return '已发送'
|
||||||
case 'delivered': return '已送达'
|
case 'delivered': return '已送达'
|
||||||
|
case 'read': return '已读'
|
||||||
case 'failed': return '失败'
|
case 'failed': return '失败'
|
||||||
default: return props.msg.status || ''
|
default: return props.msg.status || ''
|
||||||
}
|
}
|
||||||
@@ -126,6 +140,116 @@ async function onRetry() {
|
|||||||
retrying.value = false
|
retrying.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 右键菜单: 复制 / 删除 / 删除消息+文件 ----
|
||||||
|
|
||||||
|
async function copyText() {
|
||||||
|
const text = (props.msg.body as any).content || ''
|
||||||
|
if (!text) {
|
||||||
|
ElMessage.warning('没有可复制的文本')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败, 请检查剪贴板权限')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMessage(opts: { deleteFile?: boolean }) {
|
||||||
|
const m = props.msg
|
||||||
|
const filePath: string | undefined = opts.deleteFile ? (m.body as any).savedPath : undefined
|
||||||
|
if (opts.deleteFile && !filePath) {
|
||||||
|
ElMessage.warning('本条消息没有磁盘文件可删 (文件未落地或被清理)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const title = opts.deleteFile ? '删除消息和文件' : '删除消息'
|
||||||
|
const body = opts.deleteFile
|
||||||
|
? `确定删除此消息 + 磁盘上的文件吗?\n\n文件路径:\n${filePath}`
|
||||||
|
: '确定删除此消息吗?\n\n消息将在本机消失 (不会撤回对端设备上的副本)。'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(body, title, {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
confirmButtonClass: 'el-button--danger',
|
||||||
|
dangerouslyUseHTMLString: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const r = await api.deleteMessage(m.messageId, opts)
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.error(r.reason || '删除失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (opts.deleteFile && r.deletedFile) {
|
||||||
|
ElMessage.success(`已删除文件: ${r.deletedFile}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('消息已删除')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMsgCtxMenu(e: MouseEvent) {
|
||||||
|
const m = props.msg
|
||||||
|
const items: ContextMenuItem[] = []
|
||||||
|
if (m.type === 'text' || m.type === 'file' || m.type === 'image') {
|
||||||
|
items.push({ cmd: 'reply', label: '回复', icon: ChatLineSquare })
|
||||||
|
}
|
||||||
|
if (m.type === 'text') {
|
||||||
|
items.push({ cmd: 'copy', label: '复制', icon: DocumentCopy })
|
||||||
|
}
|
||||||
|
if (isSelf.value && m.type !== 'system') {
|
||||||
|
items.push({ cmd: 'recall', label: '撤回', icon: ArrowLeft })
|
||||||
|
}
|
||||||
|
items.push({ cmd: 'delete', label: '删除消息', icon: Delete, divided: true })
|
||||||
|
if (m.type === 'file' || m.type === 'image') {
|
||||||
|
items.push({ cmd: 'deleteFile', label: '删除消息和文件', icon: DocumentRemove })
|
||||||
|
}
|
||||||
|
showContextMenu(e, items, m, (cmd) => {
|
||||||
|
if (cmd === 'copy') copyText()
|
||||||
|
else if (cmd === 'reply') replyToMsg()
|
||||||
|
else if (cmd === 'recall') recallMsg()
|
||||||
|
else if (cmd === 'delete') deleteMessage({})
|
||||||
|
else if (cmd === 'deleteFile') deleteMessage({ deleteFile: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 引用回复 ----
|
||||||
|
function replyToMsg() {
|
||||||
|
session.setReplyTo(props.msg)
|
||||||
|
ElMessage.info(`已挂起对 ${props.msg.fromDeviceId === props.self.deviceId ? '我' : props.peer.name} 的回复`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyPreview(r: ReplyRef): string {
|
||||||
|
if (r.type === 'text') return r.preview
|
||||||
|
if (r.type === 'image') return `[图片] ${r.preview}`
|
||||||
|
if (r.type === 'file') return `[文件] ${r.preview}`
|
||||||
|
return r.preview
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击引用卡片 → 通知 ChatView 滚动到原消息 (高亮 + 滚入可见区域)
|
||||||
|
function onQuoteClick(r: ReplyRef) {
|
||||||
|
emit('scroll-to-message', r.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 撤回 ----
|
||||||
|
async function recallMsg() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定撤回这条消息吗?\n对方消息记录会一并删除 (包括对方已接收的文件)。\n仅 2 分钟内可撤回。',
|
||||||
|
'撤回消息',
|
||||||
|
{ type: 'warning', confirmButtonText: '撤回', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
const r = await api.recall(props.msg.messageId)
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.error(r.reason || '撤回失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已撤回')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -144,6 +268,9 @@ async function onRetry() {
|
|||||||
self: isSelf,
|
self: isSelf,
|
||||||
grouped: isConsecutive,
|
grouped: isConsecutive,
|
||||||
}"
|
}"
|
||||||
|
@contextmenu.prevent="onMsgCtxMenu"
|
||||||
|
:data-message-id="msg.messageId"
|
||||||
|
:data-from-device="msg.fromDeviceId"
|
||||||
>
|
>
|
||||||
<!-- 头像: 同人连续消息时折叠 -->
|
<!-- 头像: 同人连续消息时折叠 -->
|
||||||
<div class="msg-avatar-col">
|
<div class="msg-avatar-col">
|
||||||
@@ -162,12 +289,28 @@ async function onRetry() {
|
|||||||
|
|
||||||
<div v-if="msg.type === 'text'" class="bubble-wrap">
|
<div v-if="msg.type === 'text'" class="bubble-wrap">
|
||||||
<div class="bubble text">
|
<div class="bubble text">
|
||||||
|
<div
|
||||||
|
v-if="replyTo"
|
||||||
|
class="reply-quote"
|
||||||
|
@click.stop="onQuoteClick(replyTo)"
|
||||||
|
>
|
||||||
|
<div class="reply-author">{{ replyTo.authorName }}</div>
|
||||||
|
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
|
||||||
|
</div>
|
||||||
<MarkdownView :source="text" />
|
<MarkdownView :source="text" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="msg.type === 'image'" class="bubble-wrap">
|
<div v-else-if="msg.type === 'image'" class="bubble-wrap">
|
||||||
<div class="bubble image">
|
<div class="bubble image">
|
||||||
|
<div
|
||||||
|
v-if="replyTo"
|
||||||
|
class="reply-quote reply-quote-on-image"
|
||||||
|
@click.stop="onQuoteClick(replyTo)"
|
||||||
|
>
|
||||||
|
<div class="reply-author">{{ replyTo.authorName }}</div>
|
||||||
|
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
|
||||||
|
</div>
|
||||||
<img
|
<img
|
||||||
v-if="imageUrl"
|
v-if="imageUrl"
|
||||||
:src="imageUrl"
|
:src="imageUrl"
|
||||||
@@ -198,6 +341,14 @@ async function onRetry() {
|
|||||||
|
|
||||||
<div v-else-if="msg.type === 'file'" class="bubble-wrap">
|
<div v-else-if="msg.type === 'file'" class="bubble-wrap">
|
||||||
<div class="bubble file-card">
|
<div class="bubble file-card">
|
||||||
|
<div
|
||||||
|
v-if="replyTo"
|
||||||
|
class="reply-quote reply-quote-on-file"
|
||||||
|
@click.stop="onQuoteClick(replyTo)"
|
||||||
|
>
|
||||||
|
<div class="reply-author">{{ replyTo.authorName }}</div>
|
||||||
|
<div class="reply-preview">{{ replyPreview(replyTo) }}</div>
|
||||||
|
</div>
|
||||||
<div class="file-row">
|
<div class="file-row">
|
||||||
<div class="file-icon">
|
<div class="file-icon">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
@@ -249,9 +400,17 @@ async function onRetry() {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
transition: background-color 1.4s ease-out;
|
||||||
}
|
}
|
||||||
.msg.self { flex-direction: row-reverse; }
|
.msg.self { flex-direction: row-reverse; }
|
||||||
.msg.grouped { margin-top: 2px; }
|
.msg.grouped { margin-top: 2px; }
|
||||||
|
.msg.flash-target {
|
||||||
|
animation: msg-flash 1.4s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes msg-flash {
|
||||||
|
0% { background-color: rgba(51, 112, 255, 0.18); }
|
||||||
|
100% { background-color: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
.msg-avatar-col {
|
.msg-avatar-col {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -351,6 +510,41 @@ async function onRetry() {
|
|||||||
.msg-progress :deep(.el-progress-bar__outer) {
|
.msg-progress :deep(.el-progress-bar__outer) {
|
||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 引用回复条 — WeChat / 飞书风格, 左边一条竖线 + 作者名 + 内容预览 */
|
||||||
|
.reply-quote {
|
||||||
|
border-left: 3px solid var(--el-color-primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.reply-quote:hover {
|
||||||
|
background: rgba(51, 112, 255, 0.08);
|
||||||
|
}
|
||||||
|
.reply-quote-on-image,
|
||||||
|
.reply-quote-on-file {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.reply-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.reply-preview {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.msg-progress-meta {
|
.msg-progress-meta {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -389,6 +583,7 @@ async function onRetry() {
|
|||||||
.msg-status.pending { color: var(--el-color-warning); }
|
.msg-status.pending { color: var(--el-color-warning); }
|
||||||
.msg-status.sending { color: var(--el-text-color-secondary); }
|
.msg-status.sending { color: var(--el-text-color-secondary); }
|
||||||
.msg-status.delivered { color: var(--el-color-success); }
|
.msg-status.delivered { color: var(--el-color-success); }
|
||||||
|
.msg-status.read { color: var(--el-color-primary); }
|
||||||
.msg-status :deep(.el-button) {
|
.msg-status :deep(.el-button) {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, computed } from 'vue'
|
import { ref, watch, onMounted, computed } from 'vue'
|
||||||
import { useDeviceStore } from '@/stores/device'
|
import { useDeviceStore } from '@/stores/device'
|
||||||
|
import { useAuditStore } from '@/stores/remote'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { NetInterface } from '@/api'
|
import type { NetInterface, AuditEntry } from '@/api'
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
const device = useDeviceStore()
|
const device = useDeviceStore()
|
||||||
|
const audit = useAuditStore()
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
deviceName: string
|
deviceName: string
|
||||||
@@ -14,6 +16,11 @@ interface FormState {
|
|||||||
sound: boolean
|
sound: boolean
|
||||||
autoStart: boolean
|
autoStart: boolean
|
||||||
theme: 'light' | 'dark'
|
theme: 'light' | 'dark'
|
||||||
|
remoteEnabled: boolean
|
||||||
|
forwardDefaultTtlSec: number
|
||||||
|
forwardMaxBytesPerSec: number
|
||||||
|
terminalReadOnlyByDefault: boolean
|
||||||
|
auditRetentionDays: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = ref<FormState>({
|
const form = ref<FormState>({
|
||||||
@@ -23,13 +30,17 @@ const form = ref<FormState>({
|
|||||||
sound: true,
|
sound: true,
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
|
remoteEnabled: true,
|
||||||
|
forwardDefaultTtlSec: 3600,
|
||||||
|
forwardMaxBytesPerSec: 10 * 1024 * 1024,
|
||||||
|
terminalReadOnlyByDefault: false,
|
||||||
|
auditRetentionDays: 30,
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const autoStartAvailable = ref(false)
|
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
const initialAutoStart = ref(false)
|
const hydrating = ref(false)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
const ifaces = ref<NetInterface[]>([])
|
const ifaces = ref<NetInterface[]>([])
|
||||||
@@ -40,24 +51,30 @@ watch(() => device.settings, (s) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
|
||||||
initialAutoStart.value = await window.api.getAutoStart()
|
|
||||||
autoStartAvailable.value = true
|
|
||||||
} catch {
|
|
||||||
autoStartAvailable.value = false
|
|
||||||
}
|
|
||||||
await loadIfaces()
|
await loadIfaces()
|
||||||
|
await audit.refresh(100)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
function hydrate(s: any) {
|
function hydrate(s: any) {
|
||||||
|
hydrating.value = true
|
||||||
form.value = {
|
form.value = {
|
||||||
...s,
|
deviceName: s.deviceName ?? '',
|
||||||
autoStart: initialAutoStart.value,
|
downloadDir: s.downloadDir ?? '',
|
||||||
|
notifications: s.notifications ?? true,
|
||||||
|
sound: s.sound ?? true,
|
||||||
|
autoStart: s.autoStart ?? false,
|
||||||
|
theme: s.theme ?? 'light',
|
||||||
|
remoteEnabled: s.remoteEnabled ?? true,
|
||||||
|
forwardDefaultTtlSec: s.forwardDefaultTtlSec ?? 3600,
|
||||||
|
forwardMaxBytesPerSec: s.forwardMaxBytesPerSec ?? 10 * 1024 * 1024,
|
||||||
|
terminalReadOnlyByDefault: s.terminalReadOnlyByDefault ?? false,
|
||||||
|
auditRetentionDays: s.auditRetentionDays ?? 30,
|
||||||
}
|
}
|
||||||
|
hydrating.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(form, () => { dirty.value = true }, { deep: true })
|
watch(form, () => { if (!hydrating.value) dirty.value = true }, { deep: true })
|
||||||
|
|
||||||
async function loadIfaces() {
|
async function loadIfaces() {
|
||||||
loadingIfaces.value = true
|
loadingIfaces.value = true
|
||||||
@@ -78,11 +95,6 @@ async function pickDir() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (autoStartAvailable.value && form.value.autoStart !== initialAutoStart.value) {
|
|
||||||
const ok = await window.api.setAutoStart(form.value.autoStart)
|
|
||||||
if (ok) initialAutoStart.value = form.value.autoStart
|
|
||||||
else form.value.autoStart = initialAutoStart.value
|
|
||||||
}
|
|
||||||
const r = await window.api.setSettings({
|
const r = await window.api.setSettings({
|
||||||
deviceName: form.value.deviceName,
|
deviceName: form.value.deviceName,
|
||||||
downloadDir: form.value.downloadDir,
|
downloadDir: form.value.downloadDir,
|
||||||
@@ -90,7 +102,12 @@ async function save() {
|
|||||||
sound: form.value.sound,
|
sound: form.value.sound,
|
||||||
theme: form.value.theme,
|
theme: form.value.theme,
|
||||||
autoStart: form.value.autoStart,
|
autoStart: form.value.autoStart,
|
||||||
} as any)
|
remoteEnabled: form.value.remoteEnabled,
|
||||||
|
forwardDefaultTtlSec: form.value.forwardDefaultTtlSec,
|
||||||
|
forwardMaxBytesPerSec: form.value.forwardMaxBytesPerSec,
|
||||||
|
terminalReadOnlyByDefault: form.value.terminalReadOnlyByDefault,
|
||||||
|
auditRetentionDays: form.value.auditRetentionDays,
|
||||||
|
})
|
||||||
device.settings = r
|
device.settings = r
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
ElMessage.success('设置已保存')
|
ElMessage.success('设置已保存')
|
||||||
@@ -101,7 +118,30 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formLabelWidth = '90px'
|
async function pruneAudit() {
|
||||||
|
const n = await audit.prune()
|
||||||
|
ElMessage.success(`已清理 ${n} 条审计`)
|
||||||
|
await audit.refresh(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts: number) {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(a: string) {
|
||||||
|
return ({
|
||||||
|
'terminal.open': '终端 打开',
|
||||||
|
'terminal.exit': '终端 退出',
|
||||||
|
'terminal.close': '终端 关闭',
|
||||||
|
'forward.open': '转发 开启',
|
||||||
|
'forward.close': '转发 关闭',
|
||||||
|
'usb.list': 'USB 列表',
|
||||||
|
'usb.attach': 'USB 附加',
|
||||||
|
'usb.detach': 'USB 分离',
|
||||||
|
} as Record<string, string>)[a] || a
|
||||||
|
}
|
||||||
|
|
||||||
|
const formLabelWidth = '110px'
|
||||||
|
|
||||||
const externalIfaces = computed(() => ifaces.value.filter(i => !i.internal))
|
const externalIfaces = computed(() => ifaces.value.filter(i => !i.internal))
|
||||||
const currentAddress = computed(() => device.self?.address || '-')
|
const currentAddress = computed(() => device.self?.address || '-')
|
||||||
@@ -148,14 +188,69 @@ const currentAddress = computed(() => device.self?.address || '-')
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="开机自启">
|
<el-form-item label="开机自启">
|
||||||
<el-switch v-model="form.autoStart" :disabled="!autoStartAvailable" />
|
<el-switch v-model="form.autoStart" />
|
||||||
<span class="form-hint">
|
<span class="form-hint">登录系统时自动启动 LocalNetMsg</span>
|
||||||
<template v-if="!autoStartAvailable">仅在打包后可用</template>
|
|
||||||
<template v-else>登录系统时自动启动 LocalNetMsg</template>
|
|
||||||
</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<el-divider content-position="left">远程 (终端 / 转发 / USB)</el-divider>
|
||||||
|
|
||||||
|
<el-form label-position="left" :label-width="formLabelWidth">
|
||||||
|
<el-form-item label="启用远程">
|
||||||
|
<el-switch v-model="form.remoteEnabled" />
|
||||||
|
<span class="form-hint">关闭后所有远程请求一律拒绝 (对方仍可尝试连接)</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认 TTL">
|
||||||
|
<el-input-number v-model="form.forwardDefaultTtlSec" :min="30" :max="86400" />
|
||||||
|
<span class="form-hint">秒, 默认 1h, 最大 24h</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="速率上限">
|
||||||
|
<el-input-number v-model="form.forwardMaxBytesPerSec" :min="0" :step="1024 * 1024" />
|
||||||
|
<span class="form-hint">bytes/s, 0 = 不限</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="终端默认只读">
|
||||||
|
<el-switch v-model="form.terminalReadOnlyByDefault" />
|
||||||
|
<span class="form-hint">默认对方只能看不能输入</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="审计保留">
|
||||||
|
<el-input-number v-model="form.auditRetentionDays" :min="1" :max="365" />
|
||||||
|
<span class="form-hint">天, 启动时自动清理</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-divider content-position="left">最近审计</el-divider>
|
||||||
|
|
||||||
|
<div class="audit-section">
|
||||||
|
<el-button link size="small" @click="audit.refresh(100)">刷新</el-button>
|
||||||
|
<el-button link size="small" type="danger" @click="pruneAudit">清理过期</el-button>
|
||||||
|
<el-table v-if="audit.entries.length" :data="audit.entries" size="small" max-height="260" stripe>
|
||||||
|
<el-table-column label="时间" width="160">
|
||||||
|
<template #default="{ row }">{{ fmtTime(row.ts) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="动作" width="120">
|
||||||
|
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="来源" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code>{{ (row.source_device || '').slice(0, 8) }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="目标" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code>{{ (row.target_device || '').slice(0, 8) }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结果" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.result === 'ok' ? 'success' : row.result === 'denied' ? 'danger' : 'info'" size="small">
|
||||||
|
{{ row.result }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无审计记录" :image-size="60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-divider content-position="left">网络</el-divider>
|
<el-divider content-position="left">网络</el-divider>
|
||||||
|
|
||||||
<div class="net-section">
|
<div class="net-section">
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { useDeviceStore } from '@/stores/device'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { useMessageStore } from '@/stores/message'
|
import { useMessageStore } from '@/stores/message'
|
||||||
import { initialsOf, colorFor, formatTime } from '@/utils/format'
|
import { initialsOf, colorFor, formatTime } from '@/utils/format'
|
||||||
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag } from 'element-plus'
|
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag, ElMessageBox, ElMessage } from 'element-plus'
|
||||||
import { Refresh, Setting, UserFilled } from '@element-plus/icons-vue'
|
import { Refresh, Setting, UserFilled, Delete } from '@element-plus/icons-vue'
|
||||||
|
import { showContextMenu } from '@/composables/contextMenu'
|
||||||
|
import type { DeviceView } from '@/api'
|
||||||
|
|
||||||
const device = useDeviceStore()
|
const device = useDeviceStore()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const message = useMessageStore()
|
const message = useMessageStore()
|
||||||
defineEmits<{ (e: 'open-settings'): void }>()
|
const emit = defineEmits<{ (e: 'open-settings'): void }>()
|
||||||
|
|
||||||
const self = computed(() => device.self)
|
const self = computed(() => device.self)
|
||||||
|
|
||||||
@@ -24,6 +26,42 @@ function isSelf(id: string) {
|
|||||||
|
|
||||||
const totalUnread = computed(() => Object.values(device.unread).reduce((a, b) => a + b, 0))
|
const totalUnread = computed(() => Object.values(device.unread).reduce((a, b) => a + b, 0))
|
||||||
|
|
||||||
|
function onDeviceCtxMenu(e: MouseEvent, d: DeviceView) {
|
||||||
|
showContextMenu(e, [
|
||||||
|
{ cmd: 'delete', label: '删除设备', icon: Delete, danger: true, divided: true },
|
||||||
|
], d, (cmd, payload) => {
|
||||||
|
if (cmd !== 'delete') return
|
||||||
|
const target = payload as DeviceView
|
||||||
|
onDeviceDelete(target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeviceDelete(d: DeviceView) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定从设备列表移除 "${d.name}" 吗?\n\n移除后将停止后台连接, 局域网内再次被发现也不会自动恢复, 如需恢复需要手动添加。`,
|
||||||
|
'删除设备',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const r = await window.api.deleteDevice(d.deviceId)
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.error(r.reason || '删除失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 本地 store 也清掉 (主进程发 device:lost 只置 offline, 不会移除)
|
||||||
|
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
||||||
|
if (idx >= 0) device.devices.splice(idx, 1)
|
||||||
|
const u = { ...device.unread }
|
||||||
|
delete u[d.deviceId]
|
||||||
|
device.unread = u
|
||||||
|
// 如果当前正跟这台设备聊天, 切回空状态
|
||||||
|
if (session.activePeerId === d.deviceId) session.setActive(null)
|
||||||
|
ElMessage.success(`已移除 ${d.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!self.value) device.loadSelf().then(() => {
|
if (!self.value) device.loadSelf().then(() => {
|
||||||
if (self.value) message.setSelf(self.value.deviceId)
|
if (self.value) message.setSelf(self.value.deviceId)
|
||||||
@@ -74,6 +112,7 @@ onMounted(() => {
|
|||||||
class="device-item"
|
class="device-item"
|
||||||
:class="{ active: session.activePeerId === d.deviceId }"
|
:class="{ active: session.activePeerId === d.deviceId }"
|
||||||
@click="pick(d)"
|
@click="pick(d)"
|
||||||
|
@contextmenu.prevent="onDeviceCtxMenu($event, d)"
|
||||||
>
|
>
|
||||||
<el-avatar
|
<el-avatar
|
||||||
:size="40"
|
:size="40"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useApprovalStore } from '@/stores/remote'
|
||||||
|
|
||||||
|
const store = useApprovalStore()
|
||||||
|
const remember = ref(false)
|
||||||
|
|
||||||
|
const queue = computed(() => store.queue)
|
||||||
|
const current = computed(() => queue.value[0])
|
||||||
|
|
||||||
|
async function reply(ok: boolean) {
|
||||||
|
if (!current.value) return
|
||||||
|
await store.reply(current.value.requestId, ok, remember.value)
|
||||||
|
remember.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="current" class="approval-mask">
|
||||||
|
<div class="approval-card">
|
||||||
|
<header>
|
||||||
|
<strong>{{ current.peerName }}</strong> 请求{{
|
||||||
|
current.kind === 'terminal' ? '打开终端' :
|
||||||
|
current.kind === 'forward' ? '建立端口转发' :
|
||||||
|
'附加 USB / 串口'
|
||||||
|
}}
|
||||||
|
</header>
|
||||||
|
<div class="detail">{{ current.detail }}</div>
|
||||||
|
<div class="warn">
|
||||||
|
⚠ 本次操作在 <b>你的本机</b> 上执行; 对方只能看到你授权范围内的内容。
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<label class="remember">
|
||||||
|
<input type="checkbox" v-model="remember" />
|
||||||
|
24h 内自动允许同类型请求
|
||||||
|
</label>
|
||||||
|
<div class="spacer" />
|
||||||
|
<button class="btn-reject" @click="reply(false)">拒绝</button>
|
||||||
|
<button class="btn-allow" @click="reply(true)">允许</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.approval-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.approval-card {
|
||||||
|
width: 420px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
header { font-size: 15px; margin-bottom: 8px; }
|
||||||
|
.detail { font-size: 13px; color: var(--el-text-color-regular); padding: 8px 0; }
|
||||||
|
.warn { font-size: 12px; color: var(--el-color-warning); background: rgba(230, 162, 60, 0.08); padding: 8px 10px; border-radius: 6px; }
|
||||||
|
footer { display: flex; align-items: center; margin-top: 12px; gap: 8px; }
|
||||||
|
.remember { font-size: 12px; display: flex; align-items: center; gap: 4px; color: var(--el-text-color-secondary); }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.btn-reject, .btn-allow {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-reject { background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
|
||||||
|
.btn-allow { background: var(--el-color-primary); color: #fff; }
|
||||||
|
.btn-allow:hover { filter: brightness(1.05); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import type { DeviceView, ForwardSessionInfo, ForwardDirection } from '@/api'
|
||||||
|
import { useForwardStore } from '@/stores/remote'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{ peer: DeviceView | null }>()
|
||||||
|
const store = useForwardStore()
|
||||||
|
|
||||||
|
const direction = ref<ForwardDirection>('self-out')
|
||||||
|
const listenPort = ref(5180)
|
||||||
|
const targetHost = ref('127.0.0.1')
|
||||||
|
const targetPort = ref(80)
|
||||||
|
const ttlSec = ref(3600)
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
|
async function refresh() { await store.refresh() }
|
||||||
|
onMounted(refresh)
|
||||||
|
|
||||||
|
// 当前会话 = 我方为 client (我发起的) + 对方为 client (我接收的) 中所有涉及当前 peer 的
|
||||||
|
const myClient = computed(() => store.client.filter(s => s.peerId === props.peer?.deviceId))
|
||||||
|
const peerClient = computed(() => store.server.filter(s => s.peerId === props.peer?.deviceId))
|
||||||
|
|
||||||
|
function directionLabel(d: ForwardDirection) {
|
||||||
|
return d === 'self-out' ? '我方代理对方' : '对方代理我方'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createForward() {
|
||||||
|
if (!props.peer) return
|
||||||
|
if (!Number.isInteger(listenPort.value) || listenPort.value < 1 || listenPort.value > 65535) {
|
||||||
|
ElMessage.warning('监听端口非法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(targetPort.value) || targetPort.value < 1 || targetPort.value > 65535) {
|
||||||
|
ElMessage.warning('目标端口非法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creating.value = true
|
||||||
|
const r = await store.open(props.peer.deviceId, {
|
||||||
|
direction: direction.value,
|
||||||
|
listenPort: listenPort.value,
|
||||||
|
targetHost: targetHost.value || '127.0.0.1',
|
||||||
|
targetPort: targetPort.value,
|
||||||
|
ttlSec: ttlSec.value,
|
||||||
|
})
|
||||||
|
creating.value = false
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.error('转发失败: ' + (r.reason || ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已建立转发')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(s: ForwardSessionInfo) {
|
||||||
|
await store.close(s.sessionId)
|
||||||
|
ElMessage.success('已关闭')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBytes(n: number) {
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||||
|
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ttlLabel(expiresAt: number) {
|
||||||
|
const ms = expiresAt - Date.now()
|
||||||
|
if (ms <= 0) return '已到期'
|
||||||
|
const sec = Math.round(ms / 1000)
|
||||||
|
if (sec < 60) return `${sec}s`
|
||||||
|
if (sec < 3600) return `${Math.round(sec / 60)}m`
|
||||||
|
return `${(sec / 3600).toFixed(1)}h`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="forward-panel">
|
||||||
|
<div class="create-box">
|
||||||
|
<h3>新建端口转发</h3>
|
||||||
|
<div class="dir-row">
|
||||||
|
<label class="dir-label">方向</label>
|
||||||
|
<div class="dir-toggle">
|
||||||
|
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">
|
||||||
|
我方代理对方
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">
|
||||||
|
对方代理我方
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="dir-hint">
|
||||||
|
<template v-if="direction === 'self-out'">我开 127.0.0.1 端口 ↔ 对方的目标端口</template>
|
||||||
|
<template v-else>对方开 127.0.0.1 端口 ↔ 我方的目标端口</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<template v-if="direction === 'self-out'">
|
||||||
|
<label>我方监听</label>
|
||||||
|
<el-input v-model.number="listenPort" size="small" style="width: 120px">
|
||||||
|
<template #prepend>127.0.0.1:</template>
|
||||||
|
</el-input>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<label>对方目标</label>
|
||||||
|
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
|
||||||
|
<span>:</span>
|
||||||
|
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label>对方监听</label>
|
||||||
|
<el-input v-model.number="listenPort" size="small" style="width: 120px">
|
||||||
|
<template #prepend>127.0.0.1:</template>
|
||||||
|
</el-input>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<label>我方目标</label>
|
||||||
|
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
|
||||||
|
<span>:</span>
|
||||||
|
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
|
||||||
|
</template>
|
||||||
|
<span>TTL</span>
|
||||||
|
<el-input v-model.number="ttlSec" placeholder="秒" size="small" style="width: 80px" />
|
||||||
|
<el-button type="primary" :loading="creating" size="small" @click="createForward" :disabled="!peer">
|
||||||
|
建立
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>我发起的 ({{ myClient.length }})</h3>
|
||||||
|
<p class="section-hint">我方代理对方的服务 (self-out) / 把我的服务暴露给对方 (self-in)。任何一方都能关闭。</p>
|
||||||
|
<el-table v-if="myClient.length" :data="myClient" size="small" stripe>
|
||||||
|
<el-table-column label="方向" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="入口">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code>127.0.0.1:{{ row.listenPort }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="目标">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="in/out" width="160">
|
||||||
|
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="剩余" width="80">
|
||||||
|
<template #default="{ row }">{{ ttlLabel(row.expiresAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" link type="danger" @click="stop(row)">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="暂无我发起的转发" :image-size="50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>对方发起的 ({{ peerClient.length }})</h3>
|
||||||
|
<p class="section-hint">对方正在使用 / 暴露的转发. 你也可以关闭.</p>
|
||||||
|
<el-table v-if="peerClient.length" :data="peerClient" size="small" stripe>
|
||||||
|
<el-table-column label="方向" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="入口">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code>127.0.0.1:{{ row.listenPort }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="目标">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="in/out" width="160">
|
||||||
|
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" link type="danger" @click="stop(row)">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-else description="对方没发起任何转发" :image-size="50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forward-panel { padding: 16px; overflow-y: auto; height: 100%; }
|
||||||
|
.create-box { background: var(--el-fill-color-light); padding: 14px; border-radius: 8px; margin-bottom: 16px; }
|
||||||
|
.create-box h3, .section h3 { font-size: 14px; margin: 0 0 8px 0; }
|
||||||
|
.section { margin-bottom: 16px; }
|
||||||
|
.section-hint { font-size: 12px; color: var(--el-text-color-secondary); margin: 0 0 8px 0; }
|
||||||
|
|
||||||
|
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); width: 32px; }
|
||||||
|
.dir-toggle { display: flex; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 6px; padding: 2px; }
|
||||||
|
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
|
||||||
|
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
|
||||||
|
.dir-hint { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
|
||||||
|
.form-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.form-row label { font-size: 12px; color: var(--el-text-color-secondary); margin-right: 4px; }
|
||||||
|
.form-row .arrow { font-size: 16px; color: var(--el-color-primary); margin: 0 6px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||||
|
// @ts-ignore
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
// @ts-ignore
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
// @ts-ignore
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
|
// @ts-ignore
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
import type { DeviceView } from '@/api'
|
||||||
|
import { useTerminalStore } from '@/stores/remote'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{ peer: DeviceView; active?: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'closed'): void }>()
|
||||||
|
|
||||||
|
const store = useTerminalStore()
|
||||||
|
|
||||||
|
const hostEl = ref<HTMLDivElement | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
let term: Terminal | null = null
|
||||||
|
let fit: FitAddon | null = null
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
|
||||||
|
const sessionId = ref<string | null>(null)
|
||||||
|
const sessionInfo = ref<{ shell: string; rows: number; cols: number; readOnly: boolean } | null>(null)
|
||||||
|
const state = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle')
|
||||||
|
|
||||||
|
let outputQueue: Uint8Array[] = []
|
||||||
|
let flushTimer: any = null
|
||||||
|
let outputPollTimer: any = null
|
||||||
|
|
||||||
|
function b64ToBytes(b64: string): Uint8Array {
|
||||||
|
const bin = atob(b64)
|
||||||
|
const out = new Uint8Array(bin.length)
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushOutput() {
|
||||||
|
flushTimer = null
|
||||||
|
if (!term || outputQueue.length === 0) return
|
||||||
|
let total = 0
|
||||||
|
for (const b of outputQueue) total += b.length
|
||||||
|
if (total === 0) { outputQueue = []; return }
|
||||||
|
const combined = new Uint8Array(total)
|
||||||
|
let off = 0
|
||||||
|
for (const b of outputQueue) { combined.set(b, off); off += b.length }
|
||||||
|
outputQueue = []
|
||||||
|
try { term.write(combined) } catch (e) { console.warn('[terminal] write failed', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush() {
|
||||||
|
if (flushTimer) return
|
||||||
|
flushTimer = setTimeout(flushOutput, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSession() {
|
||||||
|
if (!props.peer) return
|
||||||
|
loading.value = true
|
||||||
|
const r = await store.open(props.peer.deviceId, { rows: 24, cols: 80 })
|
||||||
|
loading.value = false
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.error('打开终端失败: ' + (r.reason || ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionId.value = r.sessionId!
|
||||||
|
state.value = 'connecting'
|
||||||
|
await nextTick()
|
||||||
|
initTerm(r.sessionId!)
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTerm(sid: string) {
|
||||||
|
if (!hostEl.value) {
|
||||||
|
console.warn('[terminal] hostEl not ready, retry')
|
||||||
|
setTimeout(() => initTerm(sid), 50)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
term = new Terminal({
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Consolas, "Courier New", monospace',
|
||||||
|
theme: { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#d4d4d4', selectionBackground: '#264f78' },
|
||||||
|
cursorBlink: true,
|
||||||
|
convertEol: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
})
|
||||||
|
fit = new FitAddon()
|
||||||
|
term.loadAddon(fit)
|
||||||
|
term.loadAddon(new WebLinksAddon())
|
||||||
|
term.open(hostEl.value)
|
||||||
|
requestAnimationFrame(() => { try { fit?.fit() } catch {} })
|
||||||
|
|
||||||
|
term.onData((data: string) => {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
const b64 = btoa(unescape(encodeURIComponent(data)))
|
||||||
|
store.input(sessionId.value, b64)
|
||||||
|
})
|
||||||
|
|
||||||
|
ro = new ResizeObserver(() => { try { fit?.fit() } catch {} })
|
||||||
|
ro.observe(hostEl.value)
|
||||||
|
|
||||||
|
state.value = 'open'
|
||||||
|
|
||||||
|
// 启动轮询: 从 store 拿缓冲, 写到 xterm. 轮询兜底, 万一 watch 没触发也能跑
|
||||||
|
if (outputPollTimer) clearInterval(outputPollTimer)
|
||||||
|
outputPollTimer = setInterval(() => {
|
||||||
|
if (!sessionId.value || !term) return
|
||||||
|
const sid = sessionId.value
|
||||||
|
const buf = store.buffers[sid]
|
||||||
|
if (!buf || buf.length === 0) return
|
||||||
|
for (const b64 of buf) {
|
||||||
|
try { outputQueue.push(b64ToBytes(b64)) } catch {}
|
||||||
|
}
|
||||||
|
store.buffers[sid] = []
|
||||||
|
scheduleFlush()
|
||||||
|
}, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => sessionInfo.value, (s) => {
|
||||||
|
if (s && term) {
|
||||||
|
try { term.resize(s.cols, s.rows) } catch {}
|
||||||
|
ElMessage.success(`已连接到 ${s.shell}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// tab 切回时 refit
|
||||||
|
watch(() => props.active, (a) => {
|
||||||
|
if (a && term && fit) requestAnimationFrame(() => { try { fit?.fit() } catch {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
function closeSession(reason?: string) {
|
||||||
|
if (sessionId.value) {
|
||||||
|
store.close(sessionId.value, reason)
|
||||||
|
sessionId.value = null
|
||||||
|
}
|
||||||
|
if (term) { try { term.dispose() } catch {} ; term = null }
|
||||||
|
state.value = 'closed'
|
||||||
|
emit('closed')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (outputPollTimer) clearInterval(outputPollTimer)
|
||||||
|
if (flushTimer) clearTimeout(flushTimer)
|
||||||
|
if (ro) ro.disconnect()
|
||||||
|
if (term) { try { term.dispose() } catch {} }
|
||||||
|
closeSession('component-unmount')
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ openSession, closeSession })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="term-panel">
|
||||||
|
<div class="term-header">
|
||||||
|
<span class="peer-name">{{ peer?.name }}</span>
|
||||||
|
<span class="state" :class="state">{{
|
||||||
|
state === 'idle' ? '未连接' :
|
||||||
|
state === 'connecting' ? '连接中…' :
|
||||||
|
state === 'open' ? `已连接 · ${sessionInfo?.shell || 'shell'}` :
|
||||||
|
'已关闭'
|
||||||
|
}}</span>
|
||||||
|
<div class="spacer" />
|
||||||
|
<el-button v-if="state === 'idle'" type="primary" :loading="loading" size="small" @click="openSession">
|
||||||
|
打开终端
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else size="small" @click="closeSession">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
<div ref="hostEl" class="term-host" />
|
||||||
|
<div v-if="state === 'idle'" class="term-empty">
|
||||||
|
<div class="empty-tip">
|
||||||
|
点击 "打开终端" 在 <b>{{ peer?.name }}</b> 上启动一个本地 shell 会话。
|
||||||
|
<br><br>
|
||||||
|
<small>对方需要先在弹窗中同意; 24h 内可设置自动允许。</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.term-panel { display: flex; flex-direction: column; height: 100%; background: #1e1e1e; }
|
||||||
|
.term-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #2d2d2d; border-bottom: 1px solid #1a1a1a; color: #d4d4d4; }
|
||||||
|
.peer-name { font-weight: 600; }
|
||||||
|
.state { font-size: 12px; padding: 2px 8px; border-radius: 8px; background: #555; color: #ccc; }
|
||||||
|
.state.open { background: #2d7a2d; color: #fff; }
|
||||||
|
.state.connecting { background: #7a6d2d; color: #fff; }
|
||||||
|
.state.closed { background: #7a2d2d; color: #fff; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.term-host { flex: 1; min-height: 0; padding: 4px; overflow: hidden; }
|
||||||
|
.term-host :deep(.xterm) { height: 100%; }
|
||||||
|
.term-empty { position: absolute; inset: 40px 0 0 0; display: flex; align-items: center; justify-content: center; pointer-events: none; color: #888; text-align: center; padding: 24px; }
|
||||||
|
.empty-tip { background: #2d2d2d; border: 1px solid #444; padding: 16px 24px; border-radius: 8px; pointer-events: auto; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
|
||||||
|
import type { DeviceView, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbDirection, UsbAttachConfig, UsbEndpointInfo } from '@/api'
|
||||||
|
import { useUsbStore } from '@/stores/remote'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{ peer: DeviceView | null }>()
|
||||||
|
const store = useUsbStore()
|
||||||
|
|
||||||
|
type Mode = 'serial' | 'usb' | 'usbip'
|
||||||
|
const mode = ref<Mode>('serial')
|
||||||
|
const direction = ref<UsbDirection>('self-out')
|
||||||
|
const scanning = ref(false)
|
||||||
|
const peerDevices = ref<UsbDeviceInfo[]>([])
|
||||||
|
const myDevices = ref<UsbDeviceInfo[]>([])
|
||||||
|
const selectedBus = ref<string>('')
|
||||||
|
const baudRate = ref(115200)
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
|
// USB 模式专属
|
||||||
|
const usbIfaceNum = ref(0)
|
||||||
|
const detachKern = ref(true)
|
||||||
|
|
||||||
|
const hexEl = ref<HTMLDivElement | null>(null)
|
||||||
|
const hexBuf = ref<{ ascii: string; hex: string; ts: number }[]>([])
|
||||||
|
const HEX_LIMIT = 200
|
||||||
|
|
||||||
|
// USB 操作历史
|
||||||
|
const usbLog = ref<{ ts: number; tag: string; ok: boolean; text: string; hex?: string }[]>([])
|
||||||
|
const MAX_USB_LOG = 80
|
||||||
|
|
||||||
|
// 当前 active 客户端 session (只展示一个)
|
||||||
|
const myClientSession = computed<UsbSessionInfo | undefined>(() =>
|
||||||
|
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'client')
|
||||||
|
)
|
||||||
|
|
||||||
|
// 当前对端发起的 session (server side, 展示我方被控情况)
|
||||||
|
const serverSession = computed<UsbSessionInfo | undefined>(() =>
|
||||||
|
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'server')
|
||||||
|
)
|
||||||
|
|
||||||
|
// 不同 mode 的设备过滤
|
||||||
|
const visibleDevices = computed<UsbDeviceInfo[]>(() => {
|
||||||
|
const all = direction.value === 'self-out' ? peerDevices.value : myDevices.value
|
||||||
|
return all.filter(d => d.kind === mode.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverSerialBytes = ref<{ direction: 'in' | 'out'; data: string; ts: number }[]>([])
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
if (!props.peer) return
|
||||||
|
scanning.value = true
|
||||||
|
if (direction.value === 'self-out') {
|
||||||
|
const r = await store.list(props.peer.deviceId)
|
||||||
|
scanning.value = false
|
||||||
|
if (!r.ok) { ElMessage.error('列出对端设备失败: ' + (r.reason || '')); peerDevices.value = []; return }
|
||||||
|
peerDevices.value = r.devices || []
|
||||||
|
} else {
|
||||||
|
const r = await store.listLocal()
|
||||||
|
scanning.value = false
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.error('列出本机设备失败: ' + (r.reason || ''))
|
||||||
|
myDevices.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
myDevices.value = r.devices || []
|
||||||
|
}
|
||||||
|
// 选第一个匹配的 mode
|
||||||
|
const first = visibleDevices.value[0]
|
||||||
|
if (first && !visibleDevices.value.find(d => d.busId === selectedBus.value)) {
|
||||||
|
selectedBus.value = first.busId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach() {
|
||||||
|
if (!props.peer || !selectedBus.value) return
|
||||||
|
if (myClientSession.value) {
|
||||||
|
ElMessage.warning('已有活跃 session, 请先关闭')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalBaudRate = baudRate.value
|
||||||
|
|
||||||
|
// 串口模式: 弹窗选波特率
|
||||||
|
if (isSerial.value) {
|
||||||
|
const BAUD_OPTIONS = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]
|
||||||
|
const selected = ref(BAUD_OPTIONS.indexOf(baudRate.value) >= 0 ? baudRate.value : 115200)
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
h('div', [
|
||||||
|
h('p', { style: 'margin-bottom:10px' }, '选择本机虚拟串口的波特率'),
|
||||||
|
h('el-select', {
|
||||||
|
modelValue: selected.value,
|
||||||
|
'onUpdate:modelValue': (v: number) => { selected.value = v },
|
||||||
|
style: 'width:160px',
|
||||||
|
}, BAUD_OPTIONS.map(b => h('el-option', { key: b, label: String(b), value: b }))),
|
||||||
|
]),
|
||||||
|
'创建虚拟串口',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
customClass: 'baud-box',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finalBaudRate = selected.value
|
||||||
|
} catch {
|
||||||
|
return // 取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
const config = buildConfig(finalBaudRate)
|
||||||
|
if (!config) { creating.value = false; return }
|
||||||
|
const r = await store.attach(props.peer.deviceId, {
|
||||||
|
direction: direction.value,
|
||||||
|
busId: selectedBus.value,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
creating.value = false
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.error('附加失败: ' + (r.reason || ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('已附加')
|
||||||
|
await nextTick()
|
||||||
|
hexEl.value?.scrollTo({ top: 0 })
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'attach', ok: true, text: `${mode.value} attach OK` })
|
||||||
|
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(br: number): UsbAttachConfig | null {
|
||||||
|
if (mode.value === 'serial') {
|
||||||
|
return {
|
||||||
|
kind: 'serial',
|
||||||
|
baudRate: br,
|
||||||
|
dataBits: 8,
|
||||||
|
stopBits: 1,
|
||||||
|
parity: 'none',
|
||||||
|
createVirtual: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mode.value === 'usb') {
|
||||||
|
return { kind: 'usb', interfaceNumber: usbIfaceNum.value, detachKernelDriver: detachKern.value }
|
||||||
|
}
|
||||||
|
if (mode.value === 'usbip') {
|
||||||
|
return { kind: 'usbip' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detach(s: UsbSessionInfo) {
|
||||||
|
await store.detach(s.sessionId)
|
||||||
|
ElMessage.success('已关闭')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendText() {
|
||||||
|
if (!myClientSession.value || myClientSession.value.kind !== 'serial') return
|
||||||
|
const sid = myClientSession.value.sessionId
|
||||||
|
const text = prompt('输入要发送的文本 (UTF-8):', 'AT\r\n')
|
||||||
|
if (text === null) return
|
||||||
|
const buf = new TextEncoder().encode(text)
|
||||||
|
let bin = ''
|
||||||
|
for (const b of buf) bin += String.fromCharCode(b)
|
||||||
|
await store.serialSend(sid, btoa(bin))
|
||||||
|
ElMessage.success(`已发送 ${buf.length} 字节`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== USB 字节桥面板 =====
|
||||||
|
|
||||||
|
const reqType = ref<'standard' | 'class' | 'vendor' | 'reserved'>('standard')
|
||||||
|
const recipient = ref<'device' | 'interface' | 'endpoint' | 'other'>('device')
|
||||||
|
const directionBit = ref<'host-to-device' | 'device-to-host'>('device-to-host')
|
||||||
|
const bRequest = ref(0)
|
||||||
|
const wValue = ref(0)
|
||||||
|
const wIndex = ref(0)
|
||||||
|
const ctrlData = ref('') // hex 文本
|
||||||
|
const ctrlLength = ref(64)
|
||||||
|
|
||||||
|
const bulkEndpoint = ref(0x81)
|
||||||
|
const bulkData = ref('') // hex 文本
|
||||||
|
const bulkLength = ref(64)
|
||||||
|
const bulkTimeout = ref(5000)
|
||||||
|
const bulkDirection = ref<'in' | 'out'>('in')
|
||||||
|
|
||||||
|
function hexToBase64(hex: string): string {
|
||||||
|
const clean = hex.replace(/[^0-9a-fA-F]/g, '')
|
||||||
|
const padded = clean.length % 2 ? '0' + clean : clean
|
||||||
|
let bin = ''
|
||||||
|
for (let i = 0; i < padded.length; i += 2) {
|
||||||
|
bin += String.fromCharCode(parseInt(padded.substr(i, 2), 16))
|
||||||
|
}
|
||||||
|
return btoa(bin)
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToHex(b64: string): string {
|
||||||
|
if (!b64) return ''
|
||||||
|
try {
|
||||||
|
const bin = atob(b64)
|
||||||
|
let hex = ''
|
||||||
|
for (let i = 0; i < bin.length; i++) {
|
||||||
|
hex += bin.charCodeAt(i).toString(16).padStart(2, '0') + ' '
|
||||||
|
}
|
||||||
|
return hex.trim()
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequestType(): number {
|
||||||
|
// bmRequestType: bit7 = direction, bits 6-5 = type, bits 4-0 = recipient
|
||||||
|
let rt = 0
|
||||||
|
if (directionBit.value === 'host-to-device') rt |= 0x00
|
||||||
|
else rt |= 0x80
|
||||||
|
if (reqType.value === 'standard') rt |= 0x00
|
||||||
|
else if (reqType.value === 'class') rt |= 0x20
|
||||||
|
else if (reqType.value === 'vendor') rt |= 0x40
|
||||||
|
else if (reqType.value === 'reserved') rt |= 0x60
|
||||||
|
if (recipient.value === 'device') rt |= 0x00
|
||||||
|
else if (recipient.value === 'interface') rt |= 0x01
|
||||||
|
else if (recipient.value === 'endpoint') rt |= 0x02
|
||||||
|
else if (recipient.value === 'other') rt |= 0x03
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCtrlIn() {
|
||||||
|
if (!myClientSession.value) return
|
||||||
|
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} len=${ctrlLength.value}` })
|
||||||
|
const r = await store.ctrlIn(myClientSession.value.sessionId, setup, ctrlLength.value)
|
||||||
|
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||||
|
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||||
|
if (hex) {
|
||||||
|
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||||
|
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCtrlOut() {
|
||||||
|
if (!myClientSession.value) return
|
||||||
|
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||||
|
const dataB64 = ctrlData.value.trim() ? hexToBase64(ctrlData.value) : undefined
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} data=${dataB64 ? ctrlData.value : '<none>'}` })
|
||||||
|
const r = await store.ctrlOut(myClientSession.value.sessionId, setup, dataB64)
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||||
|
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doBulkTransfer() {
|
||||||
|
if (!myClientSession.value) return
|
||||||
|
if (bulkDirection.value === 'in') {
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} len=${bulkLength.value}` })
|
||||||
|
const r = await store.bulkIn(myClientSession.value.sessionId, bulkEndpoint.value, bulkLength.value, bulkTimeout.value)
|
||||||
|
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||||
|
if (hex) {
|
||||||
|
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||||
|
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const b64 = bulkData.value.trim() ? hexToBase64(bulkData.value) : ''
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} data=${bulkData.value}` })
|
||||||
|
const r = await store.bulkOut(myClientSession.value.sessionId, bulkEndpoint.value, b64)
|
||||||
|
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||||
|
}
|
||||||
|
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 串口 hex 显示 =====
|
||||||
|
function pushHex(base64: string) {
|
||||||
|
if (!base64) return
|
||||||
|
const bin = atob(base64)
|
||||||
|
let hex = ''
|
||||||
|
let ascii = ''
|
||||||
|
for (let i = 0; i < bin.length; i++) {
|
||||||
|
const c = bin.charCodeAt(i)
|
||||||
|
hex += c.toString(16).padStart(2, '0') + ' '
|
||||||
|
ascii += c >= 0x20 && c < 0x7f ? bin[i] : '.'
|
||||||
|
if ((i + 1) % 16 === 0) {
|
||||||
|
hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||||
|
hex = ''; ascii = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hex) hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||||
|
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => store.buffers, (b) => {
|
||||||
|
if (!myClientSession.value) return
|
||||||
|
const sid = myClientSession.value.sessionId
|
||||||
|
const buf = b[sid]
|
||||||
|
if (!buf || buf.length === 0) return
|
||||||
|
const all = buf.join('')
|
||||||
|
b[sid] = []
|
||||||
|
pushHex(all)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
function clearHex() { hexBuf.value = [] }
|
||||||
|
function clearUsbLog() { usbLog.value = [] }
|
||||||
|
|
||||||
|
async function refresh() { await store.refresh() }
|
||||||
|
onMounted(refresh)
|
||||||
|
|
||||||
|
function directionLabel(d: UsbDirection) {
|
||||||
|
return d === 'self-out' ? '我用对方的' : '对方用我的'
|
||||||
|
}
|
||||||
|
function modeLabel(m: Mode) {
|
||||||
|
return m === 'serial' ? '串口' : m === 'usb' ? 'USB 字节桥' : 'USB/IP (Linux)'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSerial = computed(() => mode.value === 'serial')
|
||||||
|
const isUsb = computed(() => mode.value === 'usb')
|
||||||
|
const isUsbip = computed(() => mode.value === 'usbip')
|
||||||
|
|
||||||
|
const endpoints = computed<UsbEndpointInfo[]>(() => myClientSession.value?.info?.endpoints || [])
|
||||||
|
const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?.userVirtualPath)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="usb-panel">
|
||||||
|
<!-- 方向 + 模式 -->
|
||||||
|
<div class="dir-row">
|
||||||
|
<span class="dir-label">方向</span>
|
||||||
|
<div class="dir-toggle">
|
||||||
|
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">我用对方的</button>
|
||||||
|
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">对方用我的</button>
|
||||||
|
</div>
|
||||||
|
<span class="dir-label" style="margin-left: 8px">类型</span>
|
||||||
|
<div class="dir-toggle">
|
||||||
|
<button :class="{ active: mode === 'serial' }" @click="mode = 'serial'">串口</button>
|
||||||
|
<button :class="{ active: mode === 'usb' }" @click="mode = 'usb'">USB</button>
|
||||||
|
<button :class="{ active: mode === 'usbip' }" @click="mode = 'usbip'">USB/IP</button>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" :loading="scanning" @click="scan" :disabled="!peer">
|
||||||
|
{{ direction === 'self-out' ? '拉取对端' : '刷新本机' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isUsbip" class="hint">
|
||||||
|
USB/IP 是 Linux 内核自带的真透明 USB 透传 (设备出现在本机 lsusb).
|
||||||
|
macOS/Windows 默认不支持. Linux 用户需手动装 <code>usbip</code> 包 + 加载 <code>vhci_hcd</code> 模块.
|
||||||
|
设备端需先 <code>usbip bind -b <busid></code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!visibleDevices.length" class="empty-tip">
|
||||||
|
<p v-if="!peer">请先在 sidebar 选择一台设备</p>
|
||||||
|
<p v-else>暂无可用的 {{ modeLabel(mode) }} 设备</p>
|
||||||
|
<small v-if="direction === 'self-in' && mode === 'serial'">Linux 用户如未看到 /dev/ttyUSB*, 需要在 dialout 组.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId">
|
||||||
|
<el-table-column prop="busId" label="设备" width="220" />
|
||||||
|
<el-table-column label="VID:PID" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code>{{ row.vid.toString(16).padStart(4,'0') }}:{{ row.pid.toString(16).padStart(4,'0') }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="product" label="产品" />
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" :disabled="!!myClientSession" @click.stop="() => { selectedBus = row.busId; baudRate = row.baudRate || 9600; attach() }">
|
||||||
|
附加
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="isSerial && visibleDevices.length" class="baud-row">
|
||||||
|
<span>默认波特率</span>
|
||||||
|
<el-select v-model.number="baudRate" size="small" style="width: 120px">
|
||||||
|
<el-option label="9600" :value="9600" />
|
||||||
|
<el-option label="19200" :value="19200" />
|
||||||
|
<el-option label="38400" :value="38400" />
|
||||||
|
<el-option label="57600" :value="57600" />
|
||||||
|
<el-option label="74880" :value="74880" />
|
||||||
|
<el-option label="115200" :value="115200" />
|
||||||
|
<el-option label="230400" :value="230400" />
|
||||||
|
<el-option label="460800" :value="460800" />
|
||||||
|
<el-option label="921600" :value="921600" />
|
||||||
|
</el-select>
|
||||||
|
<small style="color:var(--el-text-color-secondary)">点击附加后弹窗确认</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isUsb && visibleDevices.length" class="baud-row">
|
||||||
|
<span>Interface #</span>
|
||||||
|
<el-input-number v-model="usbIfaceNum" :min="0" :max="15" size="small" controls-position="right" style="width: 90px" />
|
||||||
|
<el-checkbox v-model="detachKern" style="margin-left: 8px">Linux 自动 detach 内核驱动</el-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已有 session: 显示状态 + 操作 -->
|
||||||
|
<div v-if="myClientSession" class="attach-box">
|
||||||
|
<div class="attach-row">
|
||||||
|
<span>已附加: <code>{{ myClientSession.busId }}</code> · {{ directionLabel(myClientSession.direction) }} · <code>{{ modeLabel(myClientSession.kind) }}</code></span>
|
||||||
|
<span>流量: in {{ myClientSession.bytesIn }}B / out {{ myClientSession.bytesOut }}B</span>
|
||||||
|
<div class="spacer" />
|
||||||
|
<el-button v-if="isSerial && !vcomPath" size="small" @click="sendText">发送文本</el-button>
|
||||||
|
<el-button v-if="isSerial && !vcomPath" size="small" @click="clearHex">清屏</el-button>
|
||||||
|
<el-button v-if="isUsb" size="small" @click="clearUsbLog">清日志</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="detach(myClientSession)">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 虚拟串口: 醒目的路径展示 -->
|
||||||
|
<div v-if="isSerial && vcomPath" class="vcom-banner">
|
||||||
|
<div class="vcom-banner-row">
|
||||||
|
<span class="vcom-banner-label">本机虚拟串口 (PuTTY / Arduino IDE / screen 直接打开):</span>
|
||||||
|
</div>
|
||||||
|
<div class="vcom-banner-row">
|
||||||
|
<code class="vcom-banner-path">{{ vcomPath }}</code>
|
||||||
|
<small class="vcom-hint">波特率 {{ baudRate }}, 8N1</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 串口: hex 流 -->
|
||||||
|
<div v-if="isSerial" ref="hexEl" class="hex-view">
|
||||||
|
<div v-if="!hexBuf.length" class="hex-empty">等待串口数据…</div>
|
||||||
|
<div v-for="(line, i) in hexBuf" :key="i" class="hex-line">
|
||||||
|
<span class="hex-bytes">{{ line.hex }}</span>
|
||||||
|
<span class="hex-ascii">{{ line.ascii }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USB 字节桥: endpoint 列表 + 控制/批量表单 -->
|
||||||
|
<div v-if="isUsb" class="usb-bridge">
|
||||||
|
<div v-if="endpoints.length" class="ep-list">
|
||||||
|
<strong>Endpoints:</strong>
|
||||||
|
<code v-for="ep in endpoints" :key="ep.endpointNumber" class="ep">
|
||||||
|
EP{{ ep.endpointNumber.toString(16).toUpperCase() }} ({{ ep.direction === 'in' ? 'IN' : 'OUT' }}, {{ ep.transferType }}, {{ ep.packetSize }}B)
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ep-list"><em>无 endpoint (可能需要先选择 interface)</em></div>
|
||||||
|
|
||||||
|
<!-- 控制传输 -->
|
||||||
|
<div class="usb-section">
|
||||||
|
<h4>控制传输 (Control Transfer)</h4>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<el-select v-model="directionBit" size="small" style="width: 130px">
|
||||||
|
<el-option label="device-to-host (IN)" value="device-to-host" />
|
||||||
|
<el-option label="host-to-device (OUT)" value="host-to-device" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="reqType" size="small" style="width: 90px">
|
||||||
|
<el-option label="standard" value="standard" />
|
||||||
|
<el-option label="class" value="class" />
|
||||||
|
<el-option label="vendor" value="vendor" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="recipient" size="small" style="width: 90px">
|
||||||
|
<el-option label="device" value="device" />
|
||||||
|
<el-option label="interface" value="interface" />
|
||||||
|
<el-option label="endpoint" value="endpoint" />
|
||||||
|
<el-option label="other" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<span>request</span>
|
||||||
|
<el-input-number v-model="bRequest" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||||
|
<span>wValue</span>
|
||||||
|
<el-input-number v-model="wValue" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||||
|
<span>wIndex</span>
|
||||||
|
<el-input-number v-model="wIndex" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||||
|
</div>
|
||||||
|
<div v-if="directionBit === 'host-to-device'" class="ctrl-row">
|
||||||
|
<span>data (hex)</span>
|
||||||
|
<el-input v-model="ctrlData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="ctrl-row">
|
||||||
|
<span>length</span>
|
||||||
|
<el-input-number v-model="ctrlLength" :min="1" :max="4096" size="small" controls-position="right" style="width: 100px" />
|
||||||
|
</div>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<el-button size="small" type="primary" @click="directionBit === 'host-to-device' ? doCtrlOut() : doCtrlIn()">
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量传输 -->
|
||||||
|
<div class="usb-section">
|
||||||
|
<h4>批量 / 中断 传输 (Bulk / Interrupt)</h4>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<el-radio-group v-model="bulkDirection" size="small">
|
||||||
|
<el-radio-button label="in" value="in">IN (读)</el-radio-button>
|
||||||
|
<el-radio-button label="out" value="out">OUT (写)</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<span>EP</span>
|
||||||
|
<el-input-number v-model="bulkEndpoint" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||||
|
<span v-if="bulkDirection === 'in'">length</span>
|
||||||
|
<el-input-number v-if="bulkDirection === 'in'" v-model="bulkLength" :min="1" :max="65536" size="small" controls-position="right" style="width: 100px" />
|
||||||
|
<span>timeout(ms)</span>
|
||||||
|
<el-input-number v-model="bulkTimeout" :min="100" :max="60000" :step="500" size="small" controls-position="right" style="width: 120px" />
|
||||||
|
</div>
|
||||||
|
<div v-if="bulkDirection === 'out'" class="ctrl-row">
|
||||||
|
<span>data (hex)</span>
|
||||||
|
<el-input v-model="bulkData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||||
|
</div>
|
||||||
|
<div class="ctrl-row">
|
||||||
|
<el-button size="small" type="primary" @click="doBulkTransfer">发送</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日志 -->
|
||||||
|
<div class="usb-log">
|
||||||
|
<h4>调用日志</h4>
|
||||||
|
<div class="log-list">
|
||||||
|
<div v-for="(l, i) in usbLog" :key="i" class="log-line" :class="{ ok: l.ok, fail: !l.ok }">
|
||||||
|
<span class="log-ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||||
|
<span class="log-tag">{{ l.tag }}</span>
|
||||||
|
<span class="log-text">{{ l.text }}</span>
|
||||||
|
<pre v-if="l.hex" class="log-hex">{{ l.hex }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对端发起的 session -->
|
||||||
|
<div v-if="serverSession" class="server-box">
|
||||||
|
<strong>对方正在使用我的设备:</strong>
|
||||||
|
<code>{{ serverSession.busId }}</code> · {{ directionLabel('self-in') }} · {{ modeLabel(serverSession.kind) }}
|
||||||
|
<span style="margin-left: 8px">流量: in {{ serverSession.bytesIn }}B / out {{ serverSession.bytesOut }}B</span>
|
||||||
|
<div class="spacer" />
|
||||||
|
<el-button size="small" type="danger" @click="detach(serverSession)">停止</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.usb-panel { padding: 16px; overflow-y: auto; height: 100%; }
|
||||||
|
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.dir-toggle { display: flex; background: var(--el-fill-color-light); border-radius: 6px; padding: 2px; }
|
||||||
|
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
|
||||||
|
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
|
||||||
|
.hint { background: var(--el-fill-color-light); padding: 8px 12px; border-radius: 6px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 12px; }
|
||||||
|
.hint code { background: #1e1e1e10; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||||
|
.empty-tip { padding: 24px; text-align: center; color: var(--el-text-color-secondary); }
|
||||||
|
.empty-tip small { display: block; margin-top: 8px; font-size: 11px; }
|
||||||
|
.baud-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 12px; color: var(--el-text-color-secondary); flex-wrap: wrap; }
|
||||||
|
.vcom-row { margin: 8px 0; padding: 8px 12px; background: var(--el-color-primary-light-9); border: 1px solid var(--el-color-primary-light-5); border-radius: 6px; }
|
||||||
|
.vcom-row .vcom-options { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.vcom-banner { background: var(--el-color-success-light-9); border-top: 1px solid var(--el-color-success-light-5); padding: 12px 16px; }
|
||||||
|
.vcom-banner-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||||
|
.vcom-banner-row:last-child { margin-bottom: 0; }
|
||||||
|
.vcom-banner-label { font-size: 12px; color: var(--el-text-color-secondary); font-weight: 500; }
|
||||||
|
.vcom-banner-path { font-family: ui-monospace, Consolas, monospace; font-size: 16px; font-weight: 600; color: var(--el-color-success); background: #fff; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--el-color-success-light-5); user-select: all; }
|
||||||
|
.vcom-hint { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||||
|
.attach-box { margin-top: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
|
||||||
|
.attach-row { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); font-size: 12px; flex-wrap: wrap; }
|
||||||
|
.attach-row .spacer { flex: 1; }
|
||||||
|
.server-box { margin-top: 12px; padding: 8px 12px; background: var(--el-color-warning-light-9); border: 1px solid var(--el-color-warning-light-5); border-radius: 6px; display: flex; align-items: center; font-size: 12px; }
|
||||||
|
.server-box .spacer { flex: 1; }
|
||||||
|
|
||||||
|
.hex-view {
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
color: #d4d4d4;
|
||||||
|
height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.hex-empty { color: #888; text-align: center; padding: 24px; }
|
||||||
|
.hex-line { display: flex; gap: 12px; line-height: 1.4; }
|
||||||
|
.hex-bytes { color: #79c0ff; min-width: 360px; word-break: break-all; }
|
||||||
|
.hex-ascii { color: #ffa657; }
|
||||||
|
|
||||||
|
.usb-bridge { padding: 12px; background: var(--el-bg-color); }
|
||||||
|
.ep-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: 12px; }
|
||||||
|
.ep { background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
||||||
|
.usb-section { border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; margin-top: 12px; }
|
||||||
|
.usb-section h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||||
|
.ctrl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; font-size: 12px; }
|
||||||
|
.usb-log { margin-top: 16px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
|
||||||
|
.usb-log h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||||
|
.log-list { font-family: ui-monospace, Consolas, monospace; font-size: 11px; max-height: 200px; overflow-y: auto; background: #0d0d0d; padding: 6px; border-radius: 4px; }
|
||||||
|
.log-line { display: flex; gap: 8px; padding: 2px 0; line-height: 1.4; }
|
||||||
|
.log-line.fail { color: #f97583; }
|
||||||
|
.log-line.ok { color: #85e89d; }
|
||||||
|
.log-ts { color: #888; }
|
||||||
|
.log-tag { color: #79c0ff; min-width: 70px; }
|
||||||
|
.log-text { color: #d4d4d4; flex: 1; word-break: break-word; }
|
||||||
|
.log-hex { margin: 2px 0 2px 80px; color: #79c0ff; white-space: pre-wrap; word-break: break-all; font-size: 11px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 全局单实例右键菜单 store
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// const items = [
|
||||||
|
// { cmd: 'delete', label: '删除', danger: true, divided: true },
|
||||||
|
// ...
|
||||||
|
// ]
|
||||||
|
// function onCtx(e: MouseEvent) {
|
||||||
|
// useContextMenu().show(e, items, payload /* 任意 */, (cmd, payload) => { ... })
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 单实例避免多菜单并开;position 跟鼠标坐标走,不存在 "右侧空白" / "最左侧展开" 这类
|
||||||
|
// el-dropdown 在 v-for 里 + trigger=contextmenu 的玄学行为
|
||||||
|
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
cmd: string
|
||||||
|
label: string
|
||||||
|
icon?: any
|
||||||
|
danger?: boolean
|
||||||
|
divided?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ctxMenuState = reactive({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
items: [] as ContextMenuItem[],
|
||||||
|
payload: null as unknown,
|
||||||
|
})
|
||||||
|
|
||||||
|
let pendingHandler: ((cmd: string, payload: unknown) => void) | null = null
|
||||||
|
|
||||||
|
// 同步切换: 关掉旧菜单再开新的, 避免同一个菜单组件里瞬间出现两次条目
|
||||||
|
export function showContextMenu(
|
||||||
|
e: MouseEvent,
|
||||||
|
items: ContextMenuItem[],
|
||||||
|
payload: unknown,
|
||||||
|
handler: (cmd: string, payload: unknown) => void,
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
pendingHandler = handler
|
||||||
|
if (ctxMenuState.visible) {
|
||||||
|
// 已经在显示中 — 直接改位置 + 条目即可 (避免叠两层)
|
||||||
|
ctxMenuState.x = e.clientX
|
||||||
|
ctxMenuState.y = e.clientY
|
||||||
|
ctxMenuState.items = items
|
||||||
|
ctxMenuState.payload = payload
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctxMenuState.x = e.clientX
|
||||||
|
ctxMenuState.y = e.clientY
|
||||||
|
ctxMenuState.items = items
|
||||||
|
ctxMenuState.payload = payload
|
||||||
|
ctxMenuState.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickContextMenu(cmd: string) {
|
||||||
|
const h = pendingHandler
|
||||||
|
const p = ctxMenuState.payload
|
||||||
|
ctxMenuState.visible = false
|
||||||
|
ctxMenuState.payload = null
|
||||||
|
ctxMenuState.items = []
|
||||||
|
pendingHandler = null
|
||||||
|
if (h) h(cmd, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeContextMenu() {
|
||||||
|
ctxMenuState.visible = false
|
||||||
|
ctxMenuState.payload = null
|
||||||
|
ctxMenuState.items = []
|
||||||
|
pendingHandler = null
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ export const useDeviceStore = defineStore('device', () => {
|
|||||||
const unread = ref<Record<string, number>>({})
|
const unread = ref<Record<string, number>>({})
|
||||||
const scanning = ref(false)
|
const scanning = ref(false)
|
||||||
const lastScanAt = ref(0)
|
const lastScanAt = ref(0)
|
||||||
|
// 我们 outgoing WS 接通状态 (与 discovery.online 区分: UDP 在 ≠ WS 通)
|
||||||
|
// 默认乐观地认为 WS 通的 (首次进入 chat 还没收到 device:wsState 事件前), 直到第一次 close
|
||||||
|
const wsOpen = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const [d, s, u] = await Promise.all([
|
const [d, s, u] = await Promise.all([
|
||||||
@@ -32,11 +35,15 @@ export const useDeviceStore = defineStore('device', () => {
|
|||||||
setTimeout(() => { scanning.value = false }, 3000)
|
setTimeout(() => { scanning.value = false }, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWsOpen(deviceId: string, open: boolean) {
|
||||||
|
wsOpen.value = { ...wsOpen.value, [deviceId]: open }
|
||||||
|
}
|
||||||
|
|
||||||
const onlineDevices = computed(() => devices.value.filter(d => d.online))
|
const onlineDevices = computed(() => devices.value.filter(d => d.online))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
self, devices, settings, unread, scanning, lastScanAt,
|
self, devices, settings, unread, scanning, lastScanAt, wsOpen,
|
||||||
refresh, loadSelf, triggerScan,
|
refresh, loadSelf, triggerScan, setWsOpen,
|
||||||
onlineDevices,
|
onlineDevices,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<Record<string, { peerId: string; shell: string; rows: number; cols: number; readOnly: boolean; openedAt: number; closed: boolean }>>({})
|
||||||
|
// 字节缓冲: sessionId -> base64 字符串数组 (UI 渲染时一次性 concat -> utf8 -> xterm.write)
|
||||||
|
const buffers = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
|
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<ForwardSessionInfo[]>([])
|
||||||
|
// 对方发起的 (serverSessions): 我方是 TCP client (self-out) 或 TCP server (self-in)
|
||||||
|
const server = ref<ForwardSessionInfo[]>([])
|
||||||
|
|
||||||
|
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<Record<string, UsbDeviceInfo[]>>({})
|
||||||
|
const myLocalDevices = ref<UsbDeviceInfo[]>([])
|
||||||
|
const client = ref<UsbSessionInfo[]>([]) // 我发起的
|
||||||
|
const server = ref<UsbSessionInfo[]>([]) // 对方发起的
|
||||||
|
const sessions = computed(() => [...client.value, ...server.value])
|
||||||
|
// sessionId -> base64 字符串缓冲 (串口 hex 显示用)
|
||||||
|
const buffers = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
|
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<ApprovalRequestView[]>([])
|
||||||
|
|
||||||
|
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<AuditEntry[]>([])
|
||||||
|
|
||||||
|
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 }
|
||||||
|
})
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import type { MessageView } from '@/api'
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', () => {
|
export const useSessionStore = defineStore('session', () => {
|
||||||
const activePeerId = ref<string | null>(null)
|
const activePeerId = ref<string | null>(null)
|
||||||
const peerTyping = ref<Record<string, boolean>>({})
|
const peerTyping = ref<Record<string, boolean>>({})
|
||||||
|
// 当前会话准备要回复的消息 (引用输入框里的预览条). null = 没有挂起的回复
|
||||||
|
const replyTo = ref<MessageView | null>(null)
|
||||||
|
|
||||||
function setActive(id: string | null) { activePeerId.value = id }
|
function setActive(id: string | null) { activePeerId.value = id }
|
||||||
function setTyping(deviceId: string, v: boolean) {
|
function setTyping(deviceId: string, v: boolean) {
|
||||||
peerTyping.value[deviceId] = v
|
peerTyping.value[deviceId] = v
|
||||||
}
|
}
|
||||||
|
function setReplyTo(msg: MessageView | null) { replyTo.value = msg }
|
||||||
|
function clearReplyTo() { replyTo.value = null }
|
||||||
|
|
||||||
return { activePeerId, peerTyping, setActive, setTyping }
|
return { activePeerId, peerTyping, replyTo, setActive, setTyping, setReplyTo, clearReplyTo }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user