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:
@@ -94,3 +94,11 @@ Ubuntu 装完"没图标"是个坑,有两个独立原因:
|
||||
2. **`StartupWMClass` 要跟窗口真实的 WM_CLASS 一致。** electron-builder 默认填 `productName`(`LocalNetMsg`),但 Electron 实际设的是**可执行文件名**(`local-net-msg`)—— 对不上,任务栏就找不到对应启动器,显示成通用图标。用 `xprop -id <wid> WM_CLASS` 确认真实值,然后在 `build.linux.desktop.StartupWMClass` 里写它。
|
||||
|
||||
Windows / macOS 仍用单张 `resources/icon.png`(那里不按尺寸分目录)。
|
||||
|
||||
## Windows 兼容性
|
||||
|
||||
完整的分析、已修问题和待验证清单见 `docs/windows-compat.md`。最容易踩的两个:
|
||||
|
||||
1. **`build.asarUnpack` 里的原生模块规则必须递归(`**/*`)。** node-pty 1.2 是"主包 + 平台包"拆包架构,平台包 `@lydell/node-pty-<platform>-<arch>` 里除了 `.node` 还有 `conpty.dll` / `OpenConsole.exe`,而 node-pty 的 ConPTY worker 用 `worker_threads.Worker` 起脚本——都必须是磁盘上的真实文件。规则写成 `.../node-pty-*/*`(单层)会把 `prebuilds/<plat>/` 下的东西留在 asar 里,Windows 终端直接起不来(Linux 上看不出来,因为只需要一个 `.node`)。自测 S7 守着这条规则。
|
||||
|
||||
2. **Windows 保留设备名**:`CON` `PRN` `AUX` `NUL` `COM1-9` `LPT1-9`(带扩展名也算)不能直接当文件名——对它们 `open()` 打开的是设备不是文件。落盘前统一走 `src/main/safe-name.ts` 的 `safeFileName()`(按**本机**平台决定要不要改写,Linux 上 `CON` 是合法文件名,不该动)。
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Windows 兼容性分析与注意事项
|
||||
|
||||
LocalNetMsg 是跨平台应用 (Electron 32 / Node 20 / Chromium 128)。这份文档记录
|
||||
**Windows 上能跑什么、哪些地方有坑、以及已经修过的地方**,避免以后改代码时把 Windows 弄坏。
|
||||
|
||||
测试环境:Ubuntu 22.04 (x64) + Electron 32.3.3。Windows 侧结论基于
|
||||
依赖包源码与打包产物的静态验证,**未在真实 Windows 机器上跑过**(见文末"待验证")。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论速览
|
||||
|
||||
| 功能 | Windows | 说明 |
|
||||
|---|---|---|
|
||||
| 聊天 / 文件传输 / 网盘 | ✅ | 纯 Node HTTP/WS,无平台依赖 |
|
||||
| 局域网发现 | ✅ | UDP 广播,多网卡 + 255.255.255.255 兜底 |
|
||||
| 远程终端 | ✅(修过) | 需 ConPTY (Win10 1809+),打包规则曾有坑,见 §3.1 |
|
||||
| 串口转发 | ✅ | serialport 有 win32 预编译;虚拟串口走 com0com |
|
||||
| USB (libusb 字节桥) | ⚠️ | node-usb 可用,但需 WinUSB 驱动 (Zadig) |
|
||||
| USB/IP 真透传 | ❌ | Linux 内核特性,代码里已明确拒绝 |
|
||||
| 开机自启 | ✅ | `app.setLoginItemSettings` |
|
||||
| 托盘 / 通知 | ✅ | 托盘图标走 `resources/icon.png` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 原生模块:全是 N-API 预编译,不需要针对 Electron 重编
|
||||
|
||||
这是 Windows 打包能不能成的第一道关。`build.npmRebuild` 是 `false`
|
||||
(原生模块已在 Electron ABI 上,重编反而坏事),所以**所有原生模块必须自带
|
||||
跨 ABI 的预编译二进制**。现状:
|
||||
|
||||
| 模块 | Windows 产物 | ABI | 是否需要编译 |
|
||||
|---|---|---|---|
|
||||
| `@lydell/node-pty` | `@lydell/node-pty-win32-x64` | N-API (`napi_register_module_v1`) | 否 |
|
||||
| `node-hid` | `prebuilds/HID-win32-x64/node-napi-v4.node` | N-API | 否 |
|
||||
| `@serialport/bindings-cpp` | `prebuilds/win32-x64/*.node` | N-API | 否 |
|
||||
| `usb` (node-usb 3) | `@node-usb/usb-win32-x64-msvc` | napi-rs | 否 |
|
||||
| `better-sqlite3` | 源码编译 | **非 N-API** | 是,靠 `postinstall` 的 `electron-builder install-app-deps` |
|
||||
|
||||
> **坑**:`better-sqlite3` 是唯一需要编译的。`npmRebuild: false` 只影响打包期,
|
||||
> `postinstall` 仍然会重编它。**如果用 `npm ci --ignore-scripts`,应用会启动即崩**
|
||||
> (sqlite 打开失败)。
|
||||
|
||||
node-pty 1.2 换成了"主包 + 平台包"拆包架构:主包只是 loader,
|
||||
真正的二进制在 `@lydell/node-pty-<platform>-<arch>` 里。**换平台时别忘了
|
||||
平台包也要在 asarUnpack 名单里**(见 §3.1)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 已修复的 Windows 阻塞项
|
||||
|
||||
### 3.1 打包:node-pty 平台包没被解包 → Windows 终端起不来
|
||||
|
||||
**现象**:Linux 上终端正常,Windows 上一点就报 spawn 失败。
|
||||
|
||||
**根因**:`build.asarUnpack` 里写的是
|
||||
|
||||
```json
|
||||
"**/node_modules/@lydell/node-pty-*/*" // ❌ 只匹配一层
|
||||
```
|
||||
|
||||
`/*` 只匹配直接子项,于是 `prebuilds/` 目录本身匹配上了,但
|
||||
`prebuilds/win32-x64/conpty.node` 这种**两层深**的文件没匹配上 → 留在 asar 里。
|
||||
|
||||
**为什么 Linux 上没暴露**:Linux 的 PTY 只需要一个 `pty.node`,
|
||||
Electron 的 asar 层能 dlopen 归档里的 `.node`(实测可用)。
|
||||
Windows 就不行了:
|
||||
|
||||
- `conpty.node` 要加载同目录的 **`conpty.dll`**(OS loader 从真实路径加载,asar 读不了)
|
||||
- 还要 **`OpenConsole.exe`**(`CreateProcess` 必须是磁盘上的真文件)
|
||||
- node-pty 自己就写了 `__dirname.replace('node_modules.asar', 'node_modules.asar.unpacked')`
|
||||
—— 它**明确假设**平台包在 unpacked 目录里,因为 `worker_threads.Worker`
|
||||
的脚本路径也必须是真实文件
|
||||
|
||||
**修复**:
|
||||
|
||||
```json
|
||||
"**/node_modules/@lydell/node-pty-*/**/*" // ✅ 递归
|
||||
```
|
||||
|
||||
自测 S7 会守住这条规则(防止以后又被改回单层)。
|
||||
|
||||
### 3.2 文件名:Windows 保留设备名 + 尾部点空格
|
||||
|
||||
Win32 的两个坑,Linux 上完全不存在:
|
||||
|
||||
- **保留设备名**:`CON` `PRN` `AUX` `NUL` `COM1-9` `LPT1-9` `CLOCK$`,
|
||||
**带扩展名也算**(`CON.txt` 依然是控制台设备)。对这类名字 `open()` 打开的是设备,
|
||||
数据会写进控制台或者直接失败。
|
||||
- **尾部点/空格被悄悄吞掉**:`report.` 实际落到 `report`,
|
||||
于是 `existsSync('report.')` 为真,而我们以为写的是另一个文件 —— 重名判断和实际落盘对不上。
|
||||
|
||||
**修复**:新增 `src/main/safe-name.ts` 的 `safeFileName()`,
|
||||
在两处落盘前调用:
|
||||
|
||||
- `file-server.ts` 的 `targetPath()` —— 接收方保存上传文件
|
||||
- `drive.ts` 的 `downloadDriveFile()` —— 名字来自**对端共享目录**,属于不可信输入
|
||||
|
||||
清洗策略是"最小必要":非法字符 `\ / : * ? " < > |` 全平台都清;
|
||||
保留名和尾点尾空格**只在 win32 上处理**,避免无谓改掉 Linux 上合法的文件名。
|
||||
|
||||
### 3.3 终端工作目录
|
||||
|
||||
`process.env.HOME` 在 Windows 上通常不存在,但装了 Git Bash / MSYS 之后
|
||||
可能被设成 POSIX 路径(`/c/Users/xxx`)—— 那个路径在 Win32 下不存在,pty 起不来。
|
||||
|
||||
**修复**:`shell.ts` 新增 `defaultCwd()`,win32 优先 `USERPROFILE`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已有 Windows 分支的地方(别改坏)
|
||||
|
||||
| 文件 | Windows 处理 |
|
||||
|---|---|
|
||||
| `remote/shell.ts` | `defaultShell()` 优先 PowerShell 7 (`pwsh`),其次 Windows PowerShell 5;用 `where` 而不是 `command -v` |
|
||||
| `remote/vcom.ts` | 虚拟串口走 com0com(自动下载安装器 + `setupc.exe` 配对),Linux/macOS 走 socat |
|
||||
| `permissions.ts` | `fixWindowsHID()` 用 Zadig 装 WinUSB 驱动;`diagnoseUsb()` 给 Windows 提示 |
|
||||
| `notify.ts` | win32 分支单独处理通知 |
|
||||
| `discovery.ts` | UDP `setBroadcast(true)`,同时发 255.255.255.255 / 127.0.0.1 / 每接口子网广播 |
|
||||
|
||||
Linux 专属逻辑(udev 规则、dialout 组、usbip、/sys/bus/usb 读取)都在
|
||||
`platform === 'linux'` 判断内,Windows 不会走到。
|
||||
|
||||
---
|
||||
|
||||
## 5. 已知限制与注意事项
|
||||
|
||||
1. **ConPTY 需要 Windows 10 1809+**。更老的 Windows(Server 2016 及以前)
|
||||
没有 ConPTY,node-pty 1.2 的 Windows 实现直接依赖它 → 终端不可用。
|
||||
2. **Windows 防火墙**首次运行会弹窗问是否允许 Electron 访问网络。
|
||||
用户若选了"取消",UDP 发现和 WS 都连不上 —— 这是最常见的"装完连不上"原因。
|
||||
3. **USB 真透传需要 WinUSB 驱动**。node-usb 走 libusb/WinUSB,
|
||||
Windows 上必须用 Zadig 把设备驱动换成 WinUSB,否则 `loadDevices()` 枚举不到
|
||||
(代码里已能自动下载 Zadig 并提权安装)。
|
||||
4. **虚拟串口要 admin**。com0com 装驱动必须管理员,代码走 `sudo-prompt` 提权。
|
||||
5. **`i.family` 的取值**。`discovery.ts` 里判断 `i.family !== 'IPv4'`,
|
||||
Node 18+ 才把 `family` 从数字 4 改成字符串 `'IPv4'`。Electron 32 = Node 20 ✓,
|
||||
但别把这行改成数字比较。
|
||||
6. **长路径**。Windows 默认 260 字符上限,`safeFileName` 把文件名截到 200 留余量,
|
||||
但"深目录 + 长文件名"仍可能超限,需要系统开启 LongPathsEnabled。
|
||||
7. **路径大小写不敏感**。`existsSync` 在 Windows 上大小写不敏感,
|
||||
所以 `A.apk` 和 `a.apk` 会被当成同一个文件 —— 重名自增逻辑能正确处理,
|
||||
但 Linux/macOS 上会存成两个文件。两端行为不一致,属预期。
|
||||
|
||||
---
|
||||
|
||||
## 6. 在 Windows 上验证的清单
|
||||
|
||||
代码改完,Windows 侧至少要过这一遍(本机无法替代):
|
||||
|
||||
- [ ] 安装后能启动、图标正常(见 `docs/connection-status.md` 里的图标坑)
|
||||
- [ ] 侧边栏能发现到对端设备(UDP 广播 + 防火墙)
|
||||
- [ ] 收发文字消息正常
|
||||
- [ ] 收发文件正常,文件名带中文/特殊字符/`CON` 也不出错
|
||||
- [ ] 网盘能浏览对方目录、能下载(含整目录保持层级)
|
||||
- [ ] 远程终端能打开、能看到回显、能输入
|
||||
- [ ] 串口列表能看到 COM 口(设备管理器里有)
|
||||
- [ ] USB 模式能枚举到设备(装了 WinUSB 的话)
|
||||
- [ ] 托盘图标正常、右键菜单能唤出窗口
|
||||
- [ ] 开机自启开关生效
|
||||
|
||||
---
|
||||
|
||||
## 7. 相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `src/main/safe-name.ts` | 跨平台文件名清洗(Windows 保留名 / 尾点空格) |
|
||||
| `src/main/remote/shell.ts` | 跨平台 shell 选择 + 默认工作目录 |
|
||||
| `src/main/remote/vcom.ts` | 虚拟串口(socat / com0com) |
|
||||
| `src/main/permissions.ts` | 权限诊断与一键修复(udev / dialout / WinUSB) |
|
||||
| `build.asarUnpack` (package.json) | 原生模块解包规则,**必须递归** |
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
],
|
||||
"asarUnpack": [
|
||||
"**/node_modules/@lydell/node-pty/**/*",
|
||||
"**/node_modules/@lydell/node-pty-*/*",
|
||||
"**/node_modules/@lydell/node-pty-*/**/*",
|
||||
"**/node_modules/@node-usb/**/*",
|
||||
"**/node_modules/usb/prebuilds/**/*",
|
||||
"**/node_modules/@serialport/**/prebuilds/**/*",
|
||||
|
||||
+124
-1
@@ -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')
|
||||
|
||||
+4
-1
@@ -12,6 +12,7 @@ import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs'
|
||||
import { join, basename, dirname } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { DeviceInfo } from './protocol'
|
||||
import { safeFileName } from './safe-name'
|
||||
|
||||
export interface DriveEntryView {
|
||||
name: string
|
||||
@@ -137,7 +138,9 @@ export function downloadDriveFile(opts: DriveDownloadOptions): Promise<{ ok: boo
|
||||
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
|
||||
if (!existsSync(opts.destDir)) mkdirSync(opts.destDir, { recursive: true })
|
||||
|
||||
const name = basename(opts.relPath) || 'file'
|
||||
// 名字来自对端的共享目录, 属于不可信输入 —— 落盘前必须清洗
|
||||
// (Windows 上 "CON"/"NUL" 这类保留名会写到设备而不是文件, 尾点尾空格会被系统吞掉)
|
||||
const name = safeFileName(basename(opts.relPath) || 'file')
|
||||
// 保持目录层级: destSubPath 里的目录要先建出来
|
||||
const subDir = opts.destSubPath ? join(opts.destDir, opts.destSubPath) : opts.destDir
|
||||
if (!existsSync(subDir)) mkdirSync(subDir, { recursive: true })
|
||||
|
||||
@@ -16,6 +16,7 @@ import { mkdir, stat, unlink, rename } from 'node:fs/promises'
|
||||
import { join, extname, resolve, basename, sep } from 'node:path'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { getSettings } from './settings'
|
||||
import { safeFileName } from './safe-name'
|
||||
|
||||
export interface FileServerEvents {
|
||||
received: [info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }]
|
||||
@@ -113,8 +114,8 @@ export class FileServer extends EventEmitter {
|
||||
const dir = join(this.root, sub)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
|
||||
// 清洗非法字符 + 截断
|
||||
const safe = name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200)
|
||||
// 清洗非法字符 + 截断 (+ Windows 保留设备名 / 尾部点空格)
|
||||
const safe = safeFileName(name)
|
||||
|
||||
// 分离 stem / ext (按第一个 .)
|
||||
// archive.tar.gz -> stem="archive" ext=".tar.gz" -> archive_1.tar.gz
|
||||
|
||||
@@ -51,4 +51,14 @@ export function defaultShell(): ShellSpec {
|
||||
default:
|
||||
return { file: '/bin/sh', args: [], label: 'sh' }
|
||||
}
|
||||
}
|
||||
|
||||
// 终端的初始工作目录
|
||||
// Windows 上通常没有 HOME, 但装了 Git Bash / MSYS 之后 HOME 可能被设成 POSIX 路径
|
||||
// (比如 /c/Users/xxx), 那个路径在 Win32 下不存在 -> pty 起不来, 所以 win32 优先 USERPROFILE。
|
||||
export function defaultCwd(): string {
|
||||
const home = platform === 'win32'
|
||||
? (process.env.USERPROFILE || process.env.HOME)
|
||||
: (process.env.HOME || process.env.USERPROFILE)
|
||||
return home || process.cwd()
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import nodePty from '@lydell/node-pty'
|
||||
import type { DeviceInfo, TerminalFrame, WsFrame } from '../protocol'
|
||||
import type { ChatClient } from '../chat-client'
|
||||
import { defaultShell } from './shell'
|
||||
import { defaultShell, defaultCwd } from './shell'
|
||||
import { shouldAsk, markApproved, enqueueApproval, resetApproval } from './approval'
|
||||
import { recordAudit } from '../db'
|
||||
|
||||
@@ -175,7 +175,7 @@ export class TerminalManager extends EventEmitter {
|
||||
name: 'xterm-256color',
|
||||
cols: f.cols || 80,
|
||||
rows: f.rows || 24,
|
||||
cwd: process.env.HOME || process.env.USERPROFILE || process.cwd(),
|
||||
cwd: defaultCwd(),
|
||||
env: { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8', COLORTERM: 'truecolor' } as any,
|
||||
encoding: null,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 文件名清洗 (跨平台安全)
|
||||
//
|
||||
// 为什么要专门处理 Windows:
|
||||
// - Win32 保留设备名: CON / PRN / AUX / NUL / COM1-9 / LPT1-9 (带扩展名也算, 例如 "CON.txt"),
|
||||
// 对这些名字 open() 打开的是**设备**而不是文件 —— 数据会写进控制台, 或者直接失败。
|
||||
// - Win32 会**悄悄吃掉文件名结尾的点和空格**: "report." 实际落到 "report",
|
||||
// 于是 existsSync("report.") 为真、而我们以为写的是另一个文件, 冲突判断和实际落盘对不上。
|
||||
// - 另外还有 \ / : * ? " < > | 这些非法字符, 以及 0x00-0x1f 控制字符。
|
||||
//
|
||||
// Linux/macOS 上这些名字完全合法, 所以清洗只在 Windows 上做"最小必要"的处理:
|
||||
// 非法字符全平台都清, 保留名/尾点尾空格只在 win32 上清, 避免无谓地改掉用户的文件名。
|
||||
|
||||
import { platform } from 'node:process'
|
||||
|
||||
// CON/PRN/AUX/NUL/CLOCK$/CONIN$/CONOUT$ + COM1-9/LPT1-9, 可带扩展名
|
||||
const WINDOWS_RESERVED = /^(CON|PRN|AUX|NUL|CLOCK\$|CONIN\$|CONOUT\$|COM[1-9]|LPT[1-9])(\..*)?$/i
|
||||
|
||||
export function isWindows(): boolean {
|
||||
return platform === 'win32'
|
||||
}
|
||||
|
||||
/**
|
||||
* 把来自网络/对端的文件名清洗成可以安全落盘的名字。
|
||||
* - 非法字符 (\ / : * ? " < > | 和控制字符) -> '_'
|
||||
* - Windows: 去掉结尾的点和空格; 保留设备名前加 '_'
|
||||
* - 长度上限 200 (留出目录和后缀空间)
|
||||
*
|
||||
* @param opts.platform 覆盖平台判定 (自测在 Linux 上跑 Windows 分支时用)
|
||||
*/
|
||||
export function safeFileName(
|
||||
name: string,
|
||||
opts: { maxLen?: number; platform?: NodeJS.Platform } = {},
|
||||
): string {
|
||||
const plat = opts.platform || platform
|
||||
const maxLen = opts.maxLen ?? 200
|
||||
let s = (name || '').replace(/[\\/:*?"<>|\x00-\x1f]/g, '_')
|
||||
|
||||
if (plat === 'win32') {
|
||||
// Win32 结尾的点和空格会被吞掉, 先去掉, 否则后面的重名判断会跟实际落盘不一致
|
||||
s = s.replace(/[. ]+$/, '')
|
||||
// 保留设备名: 加前缀 '_' 变成普通文件
|
||||
if (WINDOWS_RESERVED.test(s)) s = `_${s}`
|
||||
}
|
||||
|
||||
if (!s || s === '.' || s === '..') s = 'file'
|
||||
|
||||
// 超长截断: 留一点余量给扩展名
|
||||
if (s.length > maxLen) {
|
||||
const dot = s.lastIndexOf('.')
|
||||
if (dot > 0 && s.length - dot <= 16) {
|
||||
s = s.slice(0, maxLen - (s.length - dot)) + s.slice(dot)
|
||||
} else {
|
||||
s = s.slice(0, maxLen)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -7,6 +7,14 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const store = useUsbStore()
|
||||
|
||||
// 串口看不到设备时的提示要按平台给, Windows/macOS 给 Linux 的建议是误导
|
||||
const isLinux = (window.api?.platform || '') === 'linux'
|
||||
const serialEmptyHint = computed(() => {
|
||||
if (isLinux) return 'Linux 用户如未看到 /dev/ttyUSB*, 需要在 dialout 组.'
|
||||
if ((window.api?.platform || '') === 'win32') return 'Windows: 检查设备管理器里的 COM 口; 若用 USB 透传, 需用 Zadig 装 WinUSB 驱动.'
|
||||
return 'macOS: 首次访问串口时系统会弹权限对话框, 需在 系统设置 → 隐私与安全 中允许.'
|
||||
})
|
||||
|
||||
type Mode = 'serial' | 'usb' | 'usbip'
|
||||
const mode = ref<Mode>('serial')
|
||||
const direction = ref<UsbDirection>('self-out')
|
||||
@@ -349,7 +357,7 @@ const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?
|
||||
<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>
|
||||
<small v-if="direction === 'self-in' && mode === 'serial'">{{ serialEmptyHint }}</small>
|
||||
</div>
|
||||
|
||||
<el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId">
|
||||
|
||||
Reference in New Issue
Block a user