Files
LocalNetMsg/src/main/remote/approval.ts
T
xiaoshuai 2a641b1eff feat(serial): USB 页面下线, 改成串口映射 + 串口助手
## USB 整体下线

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

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

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

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

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

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

## 串口助手要能调 Linux

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

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

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

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

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

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

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

## 其它

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

## 验证

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

真机 (CDP 驱动真实窗口) 验证过: 串口 tab 能打开, 设备表列到 32 个 /dev/ttyS*,
助手区 + hex 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全,
方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
2026-10-21 18:04:55 +08:00

152 lines
4.7 KiB
TypeScript

// 远程操作授权闸门: 决定一个对端能不能开 terminal/forward/serial
// 规则:
// 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' | 'serial' | 'relation'
/** 需要走"远程操作白名单"的种类 (relation 走关系层自己的授权, 不在这张表里) */
export type RemoteGateKind = Exclude<RemoteKind, 'relation'>
interface Pending {
resolve: (reply: ApprovalReply) => void
timer: NodeJS.Timeout
}
/**
* 用户对授权请求的回复。
* terminal/forward/serial 只用到 ok + remember;
* relation (建立同步关系) 还会带上用户勾选的授权范围和他自己的同步目录。
*/
export interface ApprovalReply {
ok: boolean
remember?: boolean
/** relation: 用户开放给对方的能力 */
grants?: string[]
/** relation: 用户这边的同步目录 */
syncLocalDir?: string
/** relation: 允许对方用我网盘里的哪个子目录 */
syncRemotePath?: string
}
const pending = new Map<string, Pending>()
const approvedAt = new Map<string, number>()
const APPROVE_TTL_MS = 24 * 3600_000
function cacheKey(peerId: string, kind: RemoteGateKind, nonce?: string) {
// nonce 用于 attach 这种需要每次重新确认的场景
return nonce ? `${peerId}::${kind}::${nonce}` : `${peerId}::${kind}`
}
export function isAutoAllowed(peerId: string, kind: RemoteGateKind): boolean {
const s = getSettings()
if (!s.remoteEnabled) return false
const allow = s.remoteAllowPeers?.[peerId]?.[kind]
return !!allow
}
export function isRemembered(peerId: string, kind: RemoteGateKind): 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: RemoteGateKind) {
approvedAt.set(cacheKey(peerId, kind), Date.now())
}
export function resetApproval(peerId: string, kind?: RemoteGateKind) {
for (const k of approvedAt.keys()) {
if (k.startsWith(`${peerId}::`)) {
if (!kind || k.startsWith(`${peerId}::${kind}`)) approvedAt.delete(k)
}
}
}
// 同步决策: 自动允许 / 缓存允许 -> 直接 true; 否则 false (UI 需要主动 ask)
export function shouldAsk(peerId: string, kind: RemoteGateKind): 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
/** relation: 对方想要的能力 */
grants?: string[]
/** relation: 对方的同步目录 */
syncLocalDir?: string
/** relation: 对方想用我网盘里的哪个子目录 */
syncRemotePath?: string
/** relation: 关系 id */
relationId?: string
}
const waitingQueue: ApprovalRequest[] = []
const waitersByReq = new Map<string, Pending>()
export function enqueueApproval(req: ApprovalRequest): Promise<ApprovalReply> {
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, 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,
extra?: { remember?: boolean; grants?: string[]; syncLocalDir?: string; syncRemotePath?: string },
): 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, ...(extra || {}) })
return true
}
export function focusMain() {
const w = BrowserWindow.getAllWindows()[0]
if (!w) return
if (w.isMinimized()) w.restore()
w.show()
w.focus()
}