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
+8
View File
@@ -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` 里写它。 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 / 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` 是合法文件名,不该动)。
+172
View File
@@ -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
View File
@@ -71,7 +71,7 @@
], ],
"asarUnpack": [ "asarUnpack": [
"**/node_modules/@lydell/node-pty/**/*", "**/node_modules/@lydell/node-pty/**/*",
"**/node_modules/@lydell/node-pty-*/*", "**/node_modules/@lydell/node-pty-*/**/*",
"**/node_modules/@node-usb/**/*", "**/node_modules/@node-usb/**/*",
"**/node_modules/usb/prebuilds/**/*", "**/node_modules/usb/prebuilds/**/*",
"**/node_modules/@serialport/**/prebuilds/**/*", "**/node_modules/@serialport/**/prebuilds/**/*",
+124 -1
View File
@@ -11,7 +11,7 @@ import * as fs from 'node:fs'
import { join, dirname } from 'node:path' import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { connect } from 'node:net' 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 { createHash } from 'node:crypto'
import { ChatServer } from '../../src/main/chat-server' 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 { FileServer } from '../../src/main/file-server'
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive' import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
import { computeWsState } from '../../src/main/ws-state' 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 { TerminalManager } from '../../src/main/remote/terminal'
import { ForwardManager } from '../../src/main/remote/forward' import { ForwardManager } from '../../src/main/remote/forward'
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb' 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')) { if (want('audit')) {
console.log('\n\x1b[1m[审计]\x1b[0m') console.log('\n\x1b[1m[审计]\x1b[0m')
+4 -1
View File
@@ -12,6 +12,7 @@ import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs'
import { join, basename, dirname } from 'node:path' import { join, basename, dirname } from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import type { DeviceInfo } from './protocol' import type { DeviceInfo } from './protocol'
import { safeFileName } from './safe-name'
export interface DriveEntryView { export interface DriveEntryView {
name: string name: string
@@ -137,7 +138,9 @@ export function downloadDriveFile(opts: DriveDownloadOptions): Promise<{ ok: boo
if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' }) if (!base) return Promise.resolve({ ok: false, reason: '对方版本不支持网盘' })
if (!existsSync(opts.destDir)) mkdirSync(opts.destDir, { recursive: true }) 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 里的目录要先建出来 // 保持目录层级: destSubPath 里的目录要先建出来
const subDir = opts.destSubPath ? join(opts.destDir, opts.destSubPath) : opts.destDir const subDir = opts.destSubPath ? join(opts.destDir, opts.destSubPath) : opts.destDir
if (!existsSync(subDir)) mkdirSync(subDir, { recursive: true }) if (!existsSync(subDir)) mkdirSync(subDir, { recursive: true })
+3 -2
View File
@@ -16,6 +16,7 @@ import { mkdir, stat, unlink, rename } from 'node:fs/promises'
import { join, extname, resolve, basename, sep } from 'node:path' import { join, extname, resolve, basename, sep } from 'node:path'
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from 'node:crypto'
import { getSettings } from './settings' import { getSettings } from './settings'
import { safeFileName } from './safe-name'
export interface FileServerEvents { export interface FileServerEvents {
received: [info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }] 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) const dir = join(this.root, sub)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
// 清洗非法字符 + 截断 // 清洗非法字符 + 截断 (+ Windows 保留设备名 / 尾部点空格)
const safe = name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200) const safe = safeFileName(name)
// 分离 stem / ext (按第一个 .) // 分离 stem / ext (按第一个 .)
// archive.tar.gz -> stem="archive" ext=".tar.gz" -> archive_1.tar.gz // archive.tar.gz -> stem="archive" ext=".tar.gz" -> archive_1.tar.gz
+10
View File
@@ -51,4 +51,14 @@ export function defaultShell(): ShellSpec {
default: default:
return { file: '/bin/sh', args: [], label: 'sh' } 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()
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'
import nodePty from '@lydell/node-pty' import nodePty from '@lydell/node-pty'
import type { DeviceInfo, TerminalFrame, WsFrame } from '../protocol' import type { DeviceInfo, TerminalFrame, WsFrame } from '../protocol'
import type { ChatClient } from '../chat-client' import type { ChatClient } from '../chat-client'
import { defaultShell } from './shell' import { defaultShell, defaultCwd } from './shell'
import { shouldAsk, markApproved, enqueueApproval, resetApproval } from './approval' import { shouldAsk, markApproved, enqueueApproval, resetApproval } from './approval'
import { recordAudit } from '../db' import { recordAudit } from '../db'
@@ -175,7 +175,7 @@ export class TerminalManager extends EventEmitter {
name: 'xterm-256color', name: 'xterm-256color',
cols: f.cols || 80, cols: f.cols || 80,
rows: f.rows || 24, 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, env: { ...process.env, TERM: 'xterm-256color', LANG: process.env.LANG || 'en_US.UTF-8', COLORTERM: 'truecolor' } as any,
encoding: null, encoding: null,
}) })
+57
View File
@@ -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 props = defineProps<{ peer: DeviceView | null }>()
const store = useUsbStore() 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' type Mode = 'serial' | 'usb' | 'usbip'
const mode = ref<Mode>('serial') const mode = ref<Mode>('serial')
const direction = ref<UsbDirection>('self-out') 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"> <div v-if="!visibleDevices.length" class="empty-tip">
<p v-if="!peer">请先在 sidebar 选择一台设备</p> <p v-if="!peer">请先在 sidebar 选择一台设备</p>
<p v-else>暂无可用的 {{ modeLabel(mode) }} 设备</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> </div>
<el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId"> <el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId">