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 条用例通过
This commit is contained in:
2026-10-20 16:59:26 +08:00
parent a79086e4e8
commit f158377742
10 changed files with 390 additions and 8 deletions
+124 -1
View File
@@ -11,7 +11,7 @@ import * as fs from 'node:fs'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { connect } from 'node:net'
import { createServer, Server } from 'node:http'
import { createServer, Server, request as httpRequest } from 'node:http'
import { createHash } from 'node:crypto'
import { ChatServer } from '../../src/main/chat-server'
@@ -19,6 +19,8 @@ import { ChatClient } from '../../src/main/chat-client'
import { FileServer } from '../../src/main/file-server'
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
import { computeWsState } from '../../src/main/ws-state'
import { safeFileName } from '../../src/main/safe-name'
import { defaultCwd, defaultShell } from '../../src/main/remote/shell'
import { TerminalManager } from '../../src/main/remote/terminal'
import { ForwardManager } from '../../src/main/remote/forward'
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
@@ -645,6 +647,127 @@ async function main() {
})
}
// ============ 跨平台兼容 ============
if (want('win') || want('compat') || want('platform')) {
console.log('\n\x1b[1m[跨平台兼容]\x1b[0m')
await runCase('S1 Windows 保留设备名被改写 (CON/NUL/COM1 带扩展名也算)', async () => {
for (const n of ['CON', 'con.txt', 'NUL', 'AUX', 'COM1', 'LPT1', 'clock$.log']) {
const out = safeFileName(n, { platform: 'win32' })
assert(!/^(CON|NUL|AUX|COM1|LPT1|CLOCK\$)/i.test(out), `"${n}" 没被处理: ${out}`)
assert(out.startsWith('_'), `"${n}" 应该加前缀: ${out}`)
}
})
await runCase('S2 Windows 尾部点/空格被去掉 (Win32 会悄悄吞掉它们)', async () => {
assert(safeFileName('report.', { platform: 'win32' }) === 'report', '尾部点没去掉')
assert(safeFileName('report ', { platform: 'win32' }) === 'report', '尾部空格没去掉')
assert(safeFileName('a b ', { platform: 'win32' }) === 'a b', '不该动中间的空格')
})
await runCase('S3 非法字符全平台都清 (含路径分隔符)', async () => {
for (const plat of ['win32', 'linux', 'darwin'] as NodeJS.Platform[]) {
const out = safeFileName('a/b\\c:d*e?f"g<h>i|j', { platform: plat })
assert(!/[/\\:*?"<>|]/.test(out), `${plat} 上还有非法字符: ${out}`)
}
})
await runCase('S4 Linux/macOS 不乱改合法文件名 (CON 在 Linux 上是合法文件)', async () => {
assert(safeFileName('CON', { platform: 'linux' }) === 'CON', 'Linux 上不该改 CON')
assert(safeFileName('a:b', { platform: 'darwin' }) === 'a_b', '冒号还是该清')
})
await runCase('S5 超长文件名被截断且不超上限', async () => {
const out = safeFileName('x'.repeat(500) + '.txt', { platform: 'linux' })
assert(out.length <= 200, `没截断: ${out.length}`)
assert(out.endsWith('.txt'), '扩展名丢了')
})
await runCase('S6 默认 shell / cwd 可用', async () => {
const sh = defaultShell()
assert(sh && sh.file, '没选到 shell')
const cwd = defaultCwd()
assert(cwd && existsSync(cwd), `cwd 不存在: ${cwd}`)
})
await runCase('S7 打包配置: node-pty 平台包必须递归解包 (否则 Windows 终端起不来)', async () => {
// 打包后 __dirname 在 scripts/selftest/.out, 向上找到带 build.asarUnpack 的 package.json
let dir = __dirname
let pkg: any = null
for (let i = 0; i < 6; i++) {
const f = join(dir, 'package.json')
if (existsSync(f)) {
try {
const j = JSON.parse(readFileSync(f, 'utf8'))
if (j?.build?.asarUnpack) { pkg = j; break }
} catch {}
}
dir = dirname(dir)
}
assert(pkg, '没找到带 build.asarUnpack 的 package.json')
const pats: string[] = pkg.build.asarUnpack || []
const ptyPat = pats.find(p => p.includes('@lydell/node-pty-*'))
assert(ptyPat, `asarUnpack 里没有 node-pty 平台包的规则: ${pats.join(' | ')}`)
assert(
ptyPat.endsWith('/**/*'),
`node-pty 平台包规则必须递归 (/**/*), 否则 prebuilds/<plat>/ 里的 .node/.dll/.exe 会留在 asar 里: ${ptyPat}`,
)
})
await runCase('S8 上传落盘: 文件名按本机平台清洗, 且不逃出接收目录', async () => {
const root = join(tmpdir(), `lnm-selftest-up-${Date.now()}`)
mkdirSync(root, { recursive: true })
const fsrv = new FileServer(0, root, {
getDriveRoot: () => '', getDriveToken: () => '', isDriveEnabled: () => false,
})
const port = await fsrv.start()
const upload = async (name: string, body: string) => {
const buf = Buffer.from(body)
return new Promise<any>((resolve, reject) => {
const req = httpRequest({
host: '127.0.0.1', port, path: '/upload', method: 'POST',
headers: {
'x-file-id': `selftest-up-${Math.random().toString(36).slice(2)}`,
'x-file-name': encodeURIComponent(name),
'x-file-size': String(buf.length),
'x-file-mime': 'text/plain',
'Content-Length': String(buf.length),
},
}, r => {
let b = ''
r.on('data', d => (b += d))
r.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
})
req.on('error', reject)
req.end(buf)
})
}
try {
// 1) Windows 保留设备名: 在 Linux 上是合法文件名, 不该被改;
// 在 Windows 上必须被改写 (S1 已覆盖 win32 分支)。这里校验"落盘名 == 清洗结果"。
const r1 = await upload('CON.txt', 'UPLOAD_BODY')
assert(r1?.ok, `上传失败: ${JSON.stringify(r1)}`)
const saved1: string = r1.savedPath
assert(existsSync(saved1), `文件没落盘: ${saved1}`)
assert(readFileSync(saved1, 'utf8') === 'UPLOAD_BODY', '内容不对')
const expect1 = safeFileName('CON.txt', { platform: process.platform })
assert((saved1.split(/[\\/]/).pop() || '') === expect1,
`落盘名与清洗结果不一致: ${saved1} vs ${expect1}`)
// 2) 路径穿越: 文件名里带 ../ 必须被清洗掉, 不能写到接收目录外面
const r2 = await upload('../../evil.txt', 'EVIL')
assert(r2?.ok, `上传失败: ${JSON.stringify(r2)}`)
const saved2: string = r2.savedPath
assert(existsSync(saved2), `文件没落盘: ${saved2}`)
const rel = saved2.slice(root.length).replace(/^[\\/]+/, '')
assert(!rel.split(/[\\/]/).includes('..'), `路径穿越没挡住: ${saved2}`)
assert(!saved2.includes(`${root}..`), `路径穿越没挡住: ${saved2}`)
} finally {
await fsrv.stop()
try { rmSync(root, { recursive: true, force: true }) } catch {}
}
})
}
// ============ 审计 ============
if (want('audit')) {
console.log('\n\x1b[1m[审计]\x1b[0m')