fix(build): 修好 npm install 挂掉 (postinstall 只重建 better-sqlite3)

`npm install` 在 postinstall 阶段直接失败退出:

    node-hid 编译报错 -> node-addon-api/napi.h:2477 expected unqualified-id

根因不是 node-hid 坏了, 是重建范围错了:

1. postinstall 跑的是 `electron-builder install-app-deps`, 它重建**所有**原生模块;
2. 它给子进程传 `npm_config_build_from_source=false` —— 但这是**字符串** "false",
   npm 11 对未知的 npm_config_* 不做布尔转换;
3. pkg-prebuilds-verify 用 `if (process.env.npm_config_build_from_source)` 判断,
   字符串 "false" 在 JS 里是真值 -> 直接 exit 1 -> 强制源码编译;
4. node-hid 于是去编译它自带的 node-addon-api 3.2.1, 和现代 Node 头文件不兼容 -> 编译失败
   -> 整个 npm install 挂掉。

而 node-hid 根本不需要编译 —— 它自带 N-API 预编译产物, 任何 ABI 都能加载。

真正需要按 Electron ABI 重编的只有 better-sqlite3 (非 N-API, 绑死 V8/ABI,
Electron 32 -> node ABI 128); 其余 (@lydell/node-pty / node-hid /
@serialport/bindings-cpp / usb) 都是 N-API 预编译, 重编既没必要又有害。

改动:

- postinstall 换成 `node scripts/postinstall.js`:
  只重建 better-sqlite3, 装完**在 Electron 下真跑一次读写校验**, 失败就 exit 1
  —— 把"启动即崩"提前到 install 阶段暴露
- package.json 加 `allowScripts` 白名单 (npm 11 新机制, 目前只提醒不拦截,
  但迟早会拦; 顺便让 npm install 输出干净)
- AGENTS.md 坑 #3 改写成新规矩 + 完整根因链
- docs/windows-compat.md / docs/remote-terminal-forward-usb.md 里过时的
  "postinstall 走 install-app-deps" 表述一并纠正

验证:

- npm run build            ✅
- npm run typecheck        ✅ node + web
- npm run selftest         ✅ 64 通过 / 0 失败
- npm run selftest:sync    ✅ 全部通过
- npm run package:linux    ✅ 新 deb
- npm run selftest:package ✅ 4/4 (从 app.asar 真实 spawn pty)
This commit is contained in:
2026-10-21 09:32:32 +08:00
parent 4fc86edd4e
commit d854c95ee6
5 changed files with 186 additions and 4 deletions
+15 -1
View File
@@ -99,7 +99,21 @@ UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All th
2. **Drag-drop file path.** Electron 32 removed `File.path`. Use `webUtils.getPathForFile(file)` from `electron` — exposed in `src/preload/index.ts` as `getPathForFile(file)`. Callers must read it via `window.api.getPathForFile(f)` BEFORE the await boundary.
3. **Native rebuild.** `postinstall` runs `electron-builder install-app-deps` to rebuild `better-sqlite3` against the bundled Electron ABI. After `npm i` or upgrading Electron, this must run. If you forget, sqlite open will throw on launch.
3. **Native rebuild —— 只重建 `better-sqlite3`, 别用 `electron-builder install-app-deps`.**
`better-sqlite3` 不是 N-API, 绑死 V8/ABI (Electron 32 → node ABI 128), 必须对着 Electron
头文件编译; `npm i` 或升级 Electron 后必须重编, 否则 sqlite 打开即崩。
`postinstall` 现在是 `node scripts/postinstall.js`: 只重建 better-sqlite3, 装完**在 Electron 下
真跑一次读写校验** (失败就 exit 1, 把"启动即崩"提前到 install 阶段暴露)。
**别改回 `electron-builder install-app-deps`** —— 它重建**所有**原生模块, 且给子进程塞
`npm_config_build_from_source=false`; npm 11 对未知 npm_config_* 保留原始字符串, 于是子进程里
它是字符串 `"false"` —— JS 里是真值 —— `pkg-prebuilds-verify` 直接 exit 1
(`node_modules/pkg-prebuilds/bin/verify.mjs:14`), 强制源码编译。node-hid 于是去编译它自带的
node-addon-api 3.2.1, 和现代 Node 头文件不兼容, 编译失败, 整个 `npm install` 挂掉 ——
而它本来根本不需要编译 (自带 N-API 预编译产物, 任何 ABI 都能加载)。
同理, node-pty 1.2 的二进制在**平台包** `@lydell/node-pty-<plat>-<arch>` 里, 主包没有
`prebuilds/` 目录 —— 按主包去找只会误报。
npm 11 还有个 `allowScripts` 机制 (根 package.json 的 `allowScripts` 字段): 没被显式列入的
安装脚本只会被"提醒"而非拦截, 但建议把信任的包列进白名单, 免得将来 npm 真的开始拦截。
4. **`mkdir` for Windows reserved names.** Output dir is `dist-Electron\` (escaped; `dist\electron` chokes on Windows). Don't rename without verifying the build still produces a valid path.
+1 -1
View File
@@ -550,7 +550,7 @@ CREATE INDEX idx_audit_ts ON audit_log(ts DESC);
|---|---|
| #1 `chat-client` 必带 `'error'` 监听 | 新增帧引入不影响;已有 on('error') 已稳 |
| #2 drag-drop file path | 不涉及 |
| #3 native rebuild | `@lydell/node-pty`、`usb`、`node-hid`、`serialport` 都是 native,必须走现有 `postinstall` 的 `electron-builder install-app-deps`;CI 加 `npm rebuild --runtime=electron` 兜底 |
| #3 native rebuild | `@lydell/node-pty`、`usb`、`node-hid`、`serialport` 都是 N-API 预编译,**不需要**按 Electron 重编;只有 `better-sqlite3`(非 N-API)要编,由 `postinstall` 的 `scripts/postinstall.js` 单独重建并在 Electron 下自校验。**别用 `electron-builder install-app-deps`**(会把 N-API 模块也拖进源码编译,node-hid 编译失败,根因见 AGENTS.md 坑 #3)。CI 兜底直接 `npm ci`(会触发 postinstall → `scripts/postinstall.js`),或手动 `node scripts/postinstall.js` |
| #4 dist-Electron 路径 | 不动 |
| #5 nsis oneClick false | 不动 |
| #6 dragenter/dragleave 深度计数 | 不涉及 |
+8 -1
View File
@@ -35,11 +35,18 @@ LocalNetMsg 是跨平台应用 (Electron 32 / Node 20 / Chromium 128)。这份
| `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` | 源码编译 | **非 N-API** | 是,靠 `postinstall` 的 `scripts/postinstall.js` |
> **坑**:`better-sqlite3` 是唯一需要编译的。`npmRebuild: false` 只影响打包期,
> `postinstall` 仍然会重编它。**如果用 `npm ci --ignore-scripts`,应用会启动即崩**
> (sqlite 打开失败)。
>
> `postinstall` **只重建 better-sqlite3 这一个包**,不是 `electron-builder install-app-deps`。
> 后者会重建所有原生模块,并给子进程传 `npm_config_build_from_source=false`;
> npm 11 对未知的 `npm_config_*` 保留原始字符串,子进程里它是字符串 `"false"`(JS 真值),
> `pkg-prebuilds-verify` 直接 exit 1 强制源码编译,node-hid 编译失败把整个
> `npm install` 拖垮 —— 而它自带 N-API 预编译产物,本来根本不用编译。
> 完整根因见 `AGENTS.md` 坑 #3。
node-pty 1.2 换成了"主包 + 平台包"拆包架构:主包只是 loader,
真正的二进制在 `@lydell/node-pty-<platform>-<arch>` 里。**换平台时别忘了
+9 -1
View File
@@ -6,6 +6,14 @@
"homepage": "https://github.com/localnetmsg/local-net-msg",
"author": "LocalNetMsg <dev@localnetmsg.local>",
"main": "out/main/index.js",
"allowScripts": {
"better-sqlite3": true,
"electron": true,
"esbuild": true,
"node-hid": true,
"@serialport/bindings-cpp": true,
"vue-demi": true
},
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
@@ -23,7 +31,7 @@
"package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac",
"package:linux": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --linux",
"package:dir": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --dir",
"postinstall": "electron-builder install-app-deps"
"postinstall": "node scripts/postinstall.js"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env node
/**
* postinstall —— 只重建"必须按 Electron ABI 重编"的原生模块, 其余一概不碰。
*
* 为什么不用 `electron-builder install-app-deps`:
* 它会重建**所有**原生模块, 并给子进程带上 `npm_config_build_from_source=false`。
* npm 11 对未知的 npm_config_* 保留原始字符串, 于是子进程里它是字符串 "false" ——
* 在 JS 里是真值 —— pkg-prebuilds-verify 直接 exit 1
* (node_modules/pkg-prebuilds/bin/verify.mjs:14), 强制走源码编译。
* node-hid 于是去编译它自带的 node-addon-api 3.2.1, 和现代 Node 头文件不兼容, 编译失败,
* 整个 `npm install` 挂掉 —— 而它本来根本不需要编译。
*
* 真正需要按 Electron ABI 重编的只有 better-sqlite3: 它不是 N-API, 绑死 V8/ABI
* (Electron 32 → node ABI 128), 必须对着 Electron 头文件编译。
* 其余原生模块 (@lydell/node-pty / node-hid / @serialport/bindings-cpp / usb)
* 自带 N-API 预编译产物, 任何 ABI 都能加载 —— 重编既没必要, 又有害。
*
* 环境变量:
* LNM_ELECTRON_DISTURL 覆盖 electron 头文件源 (默认走 npmmirror 镜像)
*/
'use strict'
const { execFileSync } = require('child_process')
const fs = require('fs')
const os = require('os')
const path = require('path')
const root = path.resolve(__dirname, '..')
const log = (...a) => console.log('[postinstall]', ...a)
function readJSON(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return null
}
}
function npmExec() {
const cli = process.env.npm_execpath
if (cli && fs.existsSync(cli)) {
return { cmd: process.execPath, args: [cli] }
}
return { cmd: process.platform === 'win32' ? 'npm.cmd' : 'npm', args: [] }
}
function electronBin() {
const base = path.join(root, 'node_modules/electron/dist')
const name = process.platform === 'win32' ? 'electron.exe' : 'electron'
const p = path.join(base, name)
return fs.existsSync(p) ? p : null
}
// ---------------------------------------------------------------- electron 版本
const electronPkg = readJSON(path.join(root, 'node_modules/electron/package.json'))
if (!electronPkg) {
log('electron 未安装, 跳过原生模块重建')
process.exit(0)
}
const electronVersion = electronPkg.version
log(`electron ${electronVersion}`)
// ------------------------------------------------------- 只重建 better-sqlite3
const disturl =
process.env.LNM_ELECTRON_DISTURL ||
process.env.ELECTRON_MIRROR ||
'https://npmmirror.com/mirrors/electron'
const env = {
...process.env,
npm_config_runtime: 'electron',
npm_config_target: electronVersion,
npm_config_arch: process.arch,
npm_config_target_arch: process.arch,
npm_config_platform: process.platform,
npm_config_target_platform: process.platform,
npm_config_disturl: disturl,
npm_config_devdir: path.join(process.env.HOME || process.env.USERPROFILE || '', '.electron-gyp'),
// better-sqlite3 必须真编译 (prebuild-install 的 electron 预编译产物不一定覆盖当前 ABI)。
// 注意: 这个开关只作用于下面显式重建的 better-sqlite3 一个包, 不会波及 node-hid 等
// N-API 模块 —— 那些包根本不进这条路径, 继续用自带预编译产物。
npm_config_build_from_source: 'true',
npm_config_update_binary: 'true',
npm_config_fallback_to_build: 'true',
}
const npm = npmExec()
log('重建 better-sqlite3 (Electron ABI) ...')
try {
execFileSync(npm.cmd, [...npm.args, 'rebuild', 'better-sqlite3'], {
cwd: root,
env,
stdio: 'inherit',
})
} catch (err) {
console.error('[postinstall] better-sqlite3 重建失败 —— 应用启动会因 sqlite 打不开而崩。')
console.error('[postinstall] 手动修复: cd node_modules/better-sqlite3 && npx node-gyp rebuild --release')
process.exit(1)
}
// --------------------------------------------------------------- 装完就验一次
const bin = electronBin()
if (!bin) {
log('找不到 electron 可执行文件, 跳过 sqlite 校验 (不影响开发)')
} else {
const probe = path.join(os.tmpdir(), `lnm-sqlite-probe-${process.pid}.js`)
// 探针放在系统临时目录, 所以不能用裸 require —— 解析路径会以 /tmp 为基准,
// 找不到项目里的 node_modules。这里写绝对路径。
const sqlitePath = path.join(root, 'node_modules', 'better-sqlite3')
fs.writeFileSync(
probe,
`const D=require(${JSON.stringify(sqlitePath)});const db=new D(':memory:');db.exec('create table t(a)');db.prepare('insert into t values (?)').run(1);if(db.prepare('select count(*) c from t').get().c!==1)throw new Error('sqlite write/read mismatch');console.log('sqlite-ok');`
)
try {
const out = execFileSync(bin, [probe], {
cwd: root,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
})
if (!out.includes('sqlite-ok')) throw new Error(`unexpected output: ${out}`)
log('better-sqlite3 在 Electron ABI 下验证通过')
} catch (err) {
console.error('[postinstall] better-sqlite3 在 Electron 下加载/读写失败:', err.message)
process.exit(1)
} finally {
fs.rmSync(probe, { force: true })
}
}
// ------------------------------------------- N-API 模块: 确认预编译产物还在
// node-pty 1.2 是"主包 + 平台包"拆包架构: 二进制在 @lydell/node-pty-<plat>-<arch> 里,
// 主包本身没有 prebuilds/ 目录 —— 别按主包去找, 否则只会误报。
const prebuilt = [
['node-hid', 'prebuilds'],
['@serialport/bindings-cpp', 'prebuilds'],
]
for (const [pkg, sub] of prebuilt) {
const dir = path.join(root, 'node_modules', pkg, sub)
if (!fs.existsSync(dir)) {
log(`提醒: ${pkg} 缺少 ${sub}/ 预编译产物, 该功能可能不可用`)
}
}
// node-pty: 二进制在平台包里 (node-pty-<plat>-<arch>/prebuilds/<plat>-<arch>/pty.node)
const ptyPlat = `@lydell/node-pty-${process.platform}-${process.arch}`
const ptyBin = path.join(root, 'node_modules', ptyPlat, 'prebuilds', `${process.platform}-${process.arch}`)
if (!fs.existsSync(ptyBin)) {
log(`提醒: ${ptyPlat} 缺少 ${path.relative(root, ptyBin)}, 远程终端可能不可用`)
}
log('done')