Files
LocalNetMsg/src/main/remote/terminal.ts
T
xiaoshuai f158377742 fix(compat): Windows 兼容性修复 + 跨平台兼容分析
终端在 Windows 上起不来 (打包规则漏解包)
- 根因: build.asarUnpack 写的是 "**/node_modules/@lydell/node-pty-*/*", 只匹配一层,
  于是 prebuilds/<plat>/ 下的文件没被解包, 留在 app.asar 里
- Windows 上这些必须是磁盘上的真实文件: conpty.dll (OS loader 加载)、
  OpenConsole.exe (CreateProcess)、以及 node-pty 用 worker_threads.Worker 起的
  conoutSocketWorker.js —— node-pty 本身就写了
  __dirname.replace('node_modules.asar', 'node_modules.asar.unpacked') 来适配
- Linux 上看不出来: 只需要一个 pty.node, Electron 的 asar 层能 dlopen 归档里的 .node
- 修复: 规则递归化为 "**/node_modules/@lydell/node-pty-*/**/*"
- 验证: 重新打包后 pty.node 已落到 app.asar.unpacked, 安装版真实 spawn 成功

Windows 保留设备名 / 尾部点空格导致落盘写错
- 新增 src/main/safe-name.ts 的 safeFileName()
- Win32 把 CON/PRN/AUX/NUL/COM1-9/LPT1-9 (带扩展名也算) 当设备, open() 打开的不是文件;
  还会悄悄吃掉文件名结尾的点和空格, 导致 existsSync 判断和实际落盘对不上
- 两处落盘都接上: file-server.ts 的 targetPath()、drive.ts 的 downloadDriveFile()
  (后者名字来自对端共享目录, 属不可信输入)
- 策略是"最小必要": 非法字符全平台都清, 保留名和尾点空格只在 win32 上改写,
  避免动到 Linux 上合法的文件名

其他
- 终端 cwd: Windows 优先用 USERPROFILE (装了 Git Bash 时 HOME 可能是 POSIX 路径, Win32 下不存在)
- USB 面板空列表提示按平台给, 不再一律说"Linux 用户需要在 dialout 组"

分析文档与自测
- 新增 docs/windows-compat.md: 各原生模块的 N-API 情况、已有 win32 分支的位置、
  已知限制 (ConPTY 需 Win10 1809+、防火墙首次弹窗、USB 需 WinUSB 驱动等)、
  以及一份 Windows 真机验证清单
- 自测新增 S1-S8 组: 保留名改写、尾部点空格、非法字符、跨平台不过度清洗、
  超长截断、默认 shell/cwd、asarUnpack 规则回归护栏、上传落盘 + 路径穿越
- 全部 38 条用例通过
2026-10-20 16:59:26 +08:00

323 lines
13 KiB
TypeScript

// 远程终端: 控制端发起 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, defaultCwd } 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': {
// 控制端: 被控端发来的终端输出 -> 交给渲染层
// (ipc.ts 监听 TerminalManager 的 'output' 事件, 广播 'remote:terminal:output' 给 UI)
// 之前这里直接 return, 导致控制端永远收不到任何输出 —— 终端窗口一片空白
if (!f.data) return
const buf = Buffer.from(f.data, 'base64')
if (buf.length === 0) return
this.emit('output', f.sessionId, buf, peer.deviceId)
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: defaultCwd(),
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)
}
}