feat(remote): serial/USB/USBIP remote forwarding with virtual serial port

This commit is contained in:
2026-07-17 18:13:55 +08:00
parent 565d044f18
commit d12c74649a
13 changed files with 4491 additions and 0 deletions
+121
View File
@@ -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()
}