@napuketto/loader 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +869 -0
- package/dist/index.d.cts +365 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +22 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -6
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
//#endregion
|
|
24
|
+
let node_fs = require("node:fs");
|
|
25
|
+
let node_path = require("node:path");
|
|
26
|
+
let node_process = require("node:process");
|
|
27
|
+
node_process = __toESM(node_process, 1);
|
|
28
|
+
let node_child_process = require("node:child_process");
|
|
29
|
+
let node_url = require("node:url");
|
|
30
|
+
let node_fs_promises = require("node:fs/promises");
|
|
31
|
+
let node_crypto = require("node:crypto");
|
|
32
|
+
let node_https = require("node:https");
|
|
33
|
+
//#region src/instance-lock.ts
|
|
34
|
+
/**
|
|
35
|
+
* instance-lock.ts:单实例锁(2026-08-07 根治「多实例抢数据目录锁挂起」)
|
|
36
|
+
*
|
|
37
|
+
* 背景(实测):同一账号数据目录(~/.napuketto/<uin>)只能被一个实例持有——
|
|
38
|
+
* QQ 原生层(MMKV / 登录单例 / 数据库)有锁,第二个实例抢不到锁会卡在
|
|
39
|
+
* loginService.initConfig 之后无响应(挂起)。此前 create-napukettoqq 生成的
|
|
40
|
+
* 副本实例常驻占锁,导致用户后续启动全部卡死。
|
|
41
|
+
*
|
|
42
|
+
* 方案:以数据目录为粒度加锁文件 `<dataDir>/instance.lock`(JSON 内容:
|
|
43
|
+
* pid / startedAt / cmdline 摘要)。启动前 checkInstanceLock:
|
|
44
|
+
* - 锁不存在 → 可启动(然后 acquireInstanceLock 写入)
|
|
45
|
+
* - 锁存在但 PID 已死 → 崩溃残留,自动清理(rm + 重新获取)
|
|
46
|
+
* - 锁存在且 PID 存活 → 已被占用,拒绝启动(返回占用 PID 供提示)
|
|
47
|
+
*
|
|
48
|
+
* 退出清理:进程正常退出时 releaseInstanceLock 删除锁文件;崩溃/强杀残留
|
|
49
|
+
* 由下次启动的「PID 存活检测」兜底(PID 已死 → 残留视为过期)。
|
|
50
|
+
*
|
|
51
|
+
* 放置说明:放 loader 而非 kernel——self-host.cjs 是 rolldown 内联 bundle
|
|
52
|
+
* (只内联 src/host/ 依赖树),静态 import kernel 会留 external 缺口;
|
|
53
|
+
* 放 loader 则 self-host(内联)与 cli(@napuketto/loader 导入)双入口可用。
|
|
54
|
+
*/
|
|
55
|
+
/** 锁文件名(置于数据目录内)。 */
|
|
56
|
+
const INSTANCE_LOCK_FILE = "instance.lock";
|
|
57
|
+
/**
|
|
58
|
+
* 检测指定数据目录是否已被其他实例占用。
|
|
59
|
+
*
|
|
60
|
+
* @param dataDir 数据目录(账号级,如 ~/.napuketto/3567141148)
|
|
61
|
+
*/
|
|
62
|
+
function checkInstanceLock(dataDir) {
|
|
63
|
+
const lockPath = (0, node_path.join)(dataDir, INSTANCE_LOCK_FILE);
|
|
64
|
+
let info = null;
|
|
65
|
+
try {
|
|
66
|
+
info = JSON.parse((0, node_fs.readFileSync)(lockPath, "utf-8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return {
|
|
69
|
+
occupied: false,
|
|
70
|
+
pid: null,
|
|
71
|
+
lockPath
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (typeof info.pid !== "number" || Number.isNaN(info.pid) || info.pid <= 0) return {
|
|
75
|
+
occupied: false,
|
|
76
|
+
pid: null,
|
|
77
|
+
lockPath
|
|
78
|
+
};
|
|
79
|
+
let alive = false;
|
|
80
|
+
try {
|
|
81
|
+
node_process.default.kill(info.pid, 0);
|
|
82
|
+
alive = true;
|
|
83
|
+
} catch {
|
|
84
|
+
alive = false;
|
|
85
|
+
}
|
|
86
|
+
if (!alive) return {
|
|
87
|
+
occupied: false,
|
|
88
|
+
pid: null,
|
|
89
|
+
lockPath
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
occupied: true,
|
|
93
|
+
pid: info.pid,
|
|
94
|
+
lockPath
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 获取数据目录锁。占用(其他实例存活)返回 false;成功或清理残留后返回 true。
|
|
99
|
+
* 幂等:本进程已持有则直接返回 true(不重复写)。
|
|
100
|
+
*/
|
|
101
|
+
function acquireInstanceLock(dataDir, cmdline) {
|
|
102
|
+
const { occupied, pid, lockPath } = checkInstanceLock(dataDir);
|
|
103
|
+
if (occupied) {
|
|
104
|
+
if (pid === node_process.default.pid) return true;
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const info = {
|
|
108
|
+
pid: node_process.default.pid,
|
|
109
|
+
startedAt: Date.now(),
|
|
110
|
+
...cmdline !== void 0 && cmdline !== "" ? { cmdline } : {}
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
(0, node_fs.rmSync)(lockPath, { force: true });
|
|
114
|
+
(0, node_fs.writeFileSync)(lockPath, JSON.stringify(info), "utf-8");
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 释放数据目录锁(正常退出时调用)。仅当持有者是本进程时才删除,
|
|
122
|
+
* 避免误删新持有者的锁(PID 复用 / 快速重启场景)。
|
|
123
|
+
*/
|
|
124
|
+
function releaseInstanceLock(dataDir) {
|
|
125
|
+
const lockPath = (0, node_path.join)(dataDir, INSTANCE_LOCK_FILE);
|
|
126
|
+
try {
|
|
127
|
+
if (JSON.parse((0, node_fs.readFileSync)(lockPath, "utf-8")).pid === node_process.default.pid) (0, node_fs.rmSync)(lockPath, { force: true });
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
/** 注册进程退出清理(SIGINT/SIGTERM/exit 均触发;崩溃强杀由残留检测兜底)。 */
|
|
131
|
+
function registerLockCleanup(dataDir) {
|
|
132
|
+
node_process.default.on("exit", () => {
|
|
133
|
+
releaseInstanceLock(dataDir);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/qq-download.ts
|
|
138
|
+
/**
|
|
139
|
+
* qq-download.ts:QQ 官方安装包下载器(P1)。
|
|
140
|
+
*
|
|
141
|
+
* 无第三方依赖:Node 内置 https + crypto(sha256)。设计文档 §2.2。
|
|
142
|
+
* - https GET 流式写文件(低内存占用,313MB 安装包不整载入内存)
|
|
143
|
+
* - 下载同时流式累加 sha256,完成后与清单比对(防链接漂移劫持 / 下载不完整)
|
|
144
|
+
* - 支持 302 重定向(QQ CDN 可能跳转)
|
|
145
|
+
* - NAPUTO_QQ_URL 环境变量可覆盖下载地址(用户拿到新链接时用)
|
|
146
|
+
*/
|
|
147
|
+
/** 下载中断(超时 / 非 2xx / 网络错误)抛出的错误。 */
|
|
148
|
+
var DownloadError = class extends Error {
|
|
149
|
+
constructor(message) {
|
|
150
|
+
super(message);
|
|
151
|
+
this.name = "DownloadError";
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/** 发起一次 https GET,返回响应(跟随重定向,最多 5 跳)。 */
|
|
155
|
+
function httpsGet(url, timeoutMs) {
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
const request = (currentUrl, redirects) => {
|
|
158
|
+
const req = (0, node_https.get)(currentUrl, { headers: { "user-agent": "napuketto-loader/0.0.x" } }, (res) => {
|
|
159
|
+
const status = res.statusCode ?? 0;
|
|
160
|
+
if (status >= 300 && status < 400 && res.headers["location"] !== void 0) {
|
|
161
|
+
res.resume();
|
|
162
|
+
if (redirects >= 5) {
|
|
163
|
+
reject(new DownloadError(`重定向次数过多: ${currentUrl}`));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
request(new URL(res.headers["location"], currentUrl).toString(), redirects + 1);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (status !== 200) {
|
|
170
|
+
res.resume();
|
|
171
|
+
reject(new DownloadError(`HTTP ${status}: ${currentUrl}`));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
resolve(res);
|
|
175
|
+
});
|
|
176
|
+
req.setTimeout(timeoutMs, () => {
|
|
177
|
+
req.destroy(new DownloadError(`下载超时(${timeoutMs}ms): ${currentUrl}`));
|
|
178
|
+
});
|
|
179
|
+
req.on("error", (err) => {
|
|
180
|
+
reject(err instanceof Error ? err : new DownloadError(String(err)));
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
request(url, 0);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 下载文件并(可选)校验 sha256。失败时清理半成品文件。
|
|
188
|
+
* 返回实际 sha256(无论是否校验),便于调用方回填清单。
|
|
189
|
+
*/
|
|
190
|
+
async function downloadFile(options) {
|
|
191
|
+
const url = options.url ?? node_process.default.env["NAPUTO_QQ_URL"];
|
|
192
|
+
if (url === void 0 || url === "") throw new DownloadError("下载地址为空:请设置 NAPUTO_QQ_URL 或提供清单 URL");
|
|
193
|
+
const timeoutMs = options.timeoutMs ?? 6e4;
|
|
194
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(options.dest), { recursive: true });
|
|
195
|
+
const hash = (0, node_crypto.createHash)("sha256");
|
|
196
|
+
const fileStream = (0, node_fs.createWriteStream)(options.dest);
|
|
197
|
+
let fileFailed = false;
|
|
198
|
+
try {
|
|
199
|
+
const res = await httpsGet(url, timeoutMs);
|
|
200
|
+
await new Promise((resolve, reject) => {
|
|
201
|
+
res.on("data", (chunk) => {
|
|
202
|
+
hash.update(chunk);
|
|
203
|
+
});
|
|
204
|
+
res.on("error", (err) => {
|
|
205
|
+
fileFailed = true;
|
|
206
|
+
reject(err instanceof Error ? err : new DownloadError(String(err)));
|
|
207
|
+
});
|
|
208
|
+
fileStream.on("error", (err) => {
|
|
209
|
+
fileFailed = true;
|
|
210
|
+
reject(err);
|
|
211
|
+
});
|
|
212
|
+
fileStream.on("finish", () => {
|
|
213
|
+
resolve();
|
|
214
|
+
});
|
|
215
|
+
res.pipe(fileStream);
|
|
216
|
+
});
|
|
217
|
+
} catch (err) {
|
|
218
|
+
fileStream.destroy();
|
|
219
|
+
if (!fileFailed) await (0, node_fs_promises.rm)(options.dest, { force: true });
|
|
220
|
+
throw err instanceof Error ? err : new DownloadError(String(err));
|
|
221
|
+
}
|
|
222
|
+
const sha256 = hash.digest("hex");
|
|
223
|
+
const expected = options.expectedSha256;
|
|
224
|
+
const verified = expected === void 0 || expected === "" ? true : sha256.toLowerCase() === expected.toLowerCase();
|
|
225
|
+
if (!verified) {
|
|
226
|
+
await (0, node_fs_promises.rm)(options.dest, { force: true });
|
|
227
|
+
throw new DownloadError(`sha256 校验失败: ${options.dest}\n期望 ${expected}\n实际 ${sha256}`);
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
sha256,
|
|
231
|
+
verified
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/qq-extract.ts
|
|
236
|
+
/**
|
|
237
|
+
* qq-extract.ts:QQ 官方安装包解包与提取(P1)。
|
|
238
|
+
*
|
|
239
|
+
* ⚠️ 格式事实(2026-08-12 实测修正):QQ 官方安装包是 NSIS 自解压格式,
|
|
240
|
+
* **7za standalone 不支持 NSIS**(只支持 7z/zip/tar 等)——必须用完整版
|
|
241
|
+
* 7z(7z.exe + 7z.dll)。设计文档 §2.3 原「内置 7za.exe」修正为:
|
|
242
|
+
* - Windows:内置 assets/7zip/7z.exe + 7z.dll(7-Zip 官方,LGPL 合规可分发)
|
|
243
|
+
* - Linux/Docker:系统 p7zip-full(7z 完整版)
|
|
244
|
+
*
|
|
245
|
+
* ⚠️ 提取策略(2026-08-12 实测修正,与设计 §2.3 不同):
|
|
246
|
+
* 1. NSIS 解包产物在 <extracted>/Files/(NSIS 内部布局),Files/ = 安装根
|
|
247
|
+
* 2. 真实 QQNT.dll(214MB)位于 versions/<v>/QQNT.dll,**自建宿主不需要**
|
|
248
|
+
* ——stub QQNT.dll 已替代其宿主符号职责(wrapper.node 的 v8/node/napi/qq_magic
|
|
249
|
+
* 符号由 stub 转发到 node.exe)
|
|
250
|
+
* 3. 只提取 resources/app 顶层 *.node + *.dll(wrapper.node 的直接/传递/delay-load
|
|
251
|
+
* 依赖全覆盖,版本无关;跳过 wmpfsdk/avsdk 等大子目录,共约 235MB)
|
|
252
|
+
*
|
|
253
|
+
* 提取目标(与安装目录同构,resolveFromRoot 可直接消费):
|
|
254
|
+
* <数据根>/qq-files/<版本>/versions/<版本>/resources/app/ ← *.node + *.dll
|
|
255
|
+
*/
|
|
256
|
+
/** resources/app 下需要提取的文件扩展名(.node 原生模块 + .dll 依赖)。 */
|
|
257
|
+
const EXTRACT_EXTENSIONS = /* @__PURE__ */ new Set([".node", ".dll"]);
|
|
258
|
+
/**
|
|
259
|
+
* 探测 7z 可执行文件:
|
|
260
|
+
* NAPUTO_7Z_PATH 环境变量 > 内置资产(assets/7zip/7z.exe,Windows)> 系统 PATH 7z。
|
|
261
|
+
* Linux 上用 p7zip 提供的 7z 命令(系统 PATH)。
|
|
262
|
+
*/
|
|
263
|
+
function findSevenZip() {
|
|
264
|
+
const envPath = node_process.default.env["NAPUTO_7Z_PATH"];
|
|
265
|
+
if (envPath !== void 0 && envPath !== "" && (0, node_fs.existsSync)(envPath)) return {
|
|
266
|
+
exe: envPath,
|
|
267
|
+
source: "环境变量 NAPUTO_7Z_PATH"
|
|
268
|
+
};
|
|
269
|
+
if (node_process.default.platform === "win32") {
|
|
270
|
+
const bundled = (0, node_path.join)((0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href)), "..", "assets", "7zip", "7z.exe");
|
|
271
|
+
if ((0, node_fs.existsSync)(bundled)) return {
|
|
272
|
+
exe: bundled,
|
|
273
|
+
source: "内置资产 assets/7zip"
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
exe: "7z",
|
|
278
|
+
source: "系统 PATH"
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** 解包安装包到指定目录(spawn 7z x,失败抛错)。 */
|
|
282
|
+
async function extractInstaller(installerPath, destDir, sevenZipPath) {
|
|
283
|
+
const sevenZip = sevenZipPath ?? findSevenZip().exe;
|
|
284
|
+
await (0, node_fs_promises.mkdir)(destDir, { recursive: true });
|
|
285
|
+
await new Promise((resolve, reject) => {
|
|
286
|
+
(0, node_child_process.execFile)(sevenZip, [
|
|
287
|
+
"x",
|
|
288
|
+
installerPath,
|
|
289
|
+
`-o${destDir}`,
|
|
290
|
+
"-y"
|
|
291
|
+
], { maxBuffer: 67108864 }, (err, _stdout, stderr) => {
|
|
292
|
+
if (err !== null) {
|
|
293
|
+
const hint = err.code === "ENOENT" ? `未找到 7z 可执行文件: ${sevenZip}\n(Windows 请安装 7-Zip 或用 NAPUTO_7Z_PATH 指定;Linux 请安装 p7zip-full)` : "(QQ 安装包是 NSIS 格式,需完整版 7z 而非 7za)";
|
|
294
|
+
reject(/* @__PURE__ */ new Error(`7z 解包失败: ${installerPath}\n${stderr.trim()}\n${hint}`));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
resolve();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* 从解包目录提取运行所需文件到缓存:
|
|
303
|
+
* resources/app 顶层全部 *.node + *.dll(保持同目录结构,PATH 前置即可解析依赖)。
|
|
304
|
+
* 幂等:目标 wrapper.node 已存在则跳过。
|
|
305
|
+
*/
|
|
306
|
+
async function extractWrapperFiles(extractedDir, version, cacheRoot) {
|
|
307
|
+
const appDir = (0, node_path.join)(extractedDir, "Files", "versions", version, "resources", "app");
|
|
308
|
+
if (!(0, node_fs.existsSync)((0, node_path.join)(appDir, "wrapper.node"))) throw new Error(`解包产物缺少 wrapper.node: ${appDir}`);
|
|
309
|
+
const destAppDir = (0, node_path.join)(cacheRoot, version, "versions", version, "resources", "app");
|
|
310
|
+
const destWrapper = (0, node_path.join)(destAppDir, "wrapper.node");
|
|
311
|
+
if ((0, node_fs.existsSync)(destWrapper)) return buildCachedInfo(cacheRoot, version);
|
|
312
|
+
await (0, node_fs_promises.mkdir)(destAppDir, { recursive: true });
|
|
313
|
+
const entries = await (0, node_fs_promises.readdir)(appDir, { withFileTypes: true });
|
|
314
|
+
for (const entry of entries) {
|
|
315
|
+
if (!entry.isFile()) continue;
|
|
316
|
+
const dot = entry.name.lastIndexOf(".");
|
|
317
|
+
const ext = dot >= 0 ? entry.name.slice(dot).toLowerCase() : "";
|
|
318
|
+
if (!EXTRACT_EXTENSIONS.has(ext)) continue;
|
|
319
|
+
await (0, node_fs_promises.copyFile)((0, node_path.join)(appDir, entry.name), (0, node_path.join)(destAppDir, entry.name));
|
|
320
|
+
}
|
|
321
|
+
if (!(0, node_fs.existsSync)(destWrapper)) throw new Error(`提取失败:wrapper.node 未复制到 ${destWrapper}`);
|
|
322
|
+
return buildCachedInfo(cacheRoot, version);
|
|
323
|
+
}
|
|
324
|
+
/** 构造 cached 来源的 QqInstallInfo(wrapperPath 指向缓存中 wrapper.node)。 */
|
|
325
|
+
function buildCachedInfo(cacheRoot, version) {
|
|
326
|
+
const versionDir = (0, node_path.join)(cacheRoot, version);
|
|
327
|
+
return {
|
|
328
|
+
qqPath: (0, node_path.join)(versionDir, "QQ.exe"),
|
|
329
|
+
installDir: versionDir,
|
|
330
|
+
version,
|
|
331
|
+
wrapperPath: (0, node_path.join)(versionDir, "versions", version, "resources", "app", "wrapper.node"),
|
|
332
|
+
source: "cached"
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** 清理缓存版本目录(下载/解包失败残留,供调用方兜底)。 */
|
|
336
|
+
async function clearCacheVersion(cacheRoot, version) {
|
|
337
|
+
await (0, node_fs_promises.rm)((0, node_path.join)(cacheRoot, version), {
|
|
338
|
+
recursive: true,
|
|
339
|
+
force: true
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
//#endregion
|
|
343
|
+
//#region src/win-node.ts
|
|
344
|
+
/**
|
|
345
|
+
* win-node.ts:Windows 版 node.exe 获取(P2,Linux/wine 场景用)。
|
|
346
|
+
*
|
|
347
|
+
* wine 只能跑 Windows PE 程序——自建宿主在 Linux 上必须用 Windows 版 node.exe
|
|
348
|
+
* 作为宿主(wrapper.node 是 PE 原生模块,只能被 Windows node.exe dlopen)。
|
|
349
|
+
* 来源:nodejs.org 官方 zip(开源软件官方发行版,但为保守起见同样不内置,
|
|
350
|
+
* 运行时下载到 <数据根>/runtime/win-node/,可被 NAPUTO_WIN_NODE_PATH 覆盖)。
|
|
351
|
+
*
|
|
352
|
+
* 设计文档 §3.1。与 qq-download.ts 同构:https 下载 + 解压 + 缓存幂等。
|
|
353
|
+
*/
|
|
354
|
+
/** 目标 Windows node.exe 可执行文件名(zip 内)。 */
|
|
355
|
+
const WIN_NODE_EXE = "node.exe";
|
|
356
|
+
/** 运行时缓存子目录名(<数据根>/runtime/win-node/)。 */
|
|
357
|
+
const WIN_NODE_DIR_NAME = "win-node";
|
|
358
|
+
/** 解析 nodejs.org zip URL(与 dist 目录命名一致)。 */
|
|
359
|
+
function nodeZipUrl(version) {
|
|
360
|
+
return `https://nodejs.org/dist/${version}/node-${version}-win-x64.zip`;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 确保 Windows 版 node.exe 就绪(下载 + 解压 + 缓存)。
|
|
364
|
+
* 幂等:<数据根>/runtime/win-node/<version>/node.exe 已存在则直接返回。
|
|
365
|
+
*/
|
|
366
|
+
async function ensureWinNode(options = {}) {
|
|
367
|
+
const explicit = options.exePath ?? node_process.default.env["NAPUTO_WIN_NODE_PATH"];
|
|
368
|
+
if (explicit !== void 0 && explicit !== "" && (0, node_fs.existsSync)(explicit)) return {
|
|
369
|
+
exePath: (0, node_path.resolve)(explicit),
|
|
370
|
+
version: "explicit"
|
|
371
|
+
};
|
|
372
|
+
const version = options.version ?? node_process.default.env["NAPUTO_WIN_NODE_VERSION"] ?? DEFAULT_WIN_NODE_VERSION;
|
|
373
|
+
const dataRoot = resolveDataRoot(options.dataRoot);
|
|
374
|
+
const cacheDir = (0, node_path.join)(dataRoot, "runtime", WIN_NODE_DIR_NAME, version);
|
|
375
|
+
const exePath = (0, node_path.join)(cacheDir, WIN_NODE_EXE);
|
|
376
|
+
if ((0, node_fs.existsSync)(exePath)) return {
|
|
377
|
+
exePath,
|
|
378
|
+
version
|
|
379
|
+
};
|
|
380
|
+
const zipPath = (0, node_path.join)(cacheDir, `node-${version}-win-x64.zip`);
|
|
381
|
+
const url = node_process.default.env["NAPUTO_WIN_NODE_URL"] ?? nodeZipUrl(version);
|
|
382
|
+
(0, node_fs.mkdirSync)(cacheDir, { recursive: true });
|
|
383
|
+
try {
|
|
384
|
+
await downloadFile({
|
|
385
|
+
dest: zipPath,
|
|
386
|
+
url
|
|
387
|
+
});
|
|
388
|
+
await unzipNode(zipPath, cacheDir);
|
|
389
|
+
} finally {
|
|
390
|
+
await (0, node_fs_promises.rm)(zipPath, { force: true });
|
|
391
|
+
}
|
|
392
|
+
if (!(0, node_fs.existsSync)(exePath)) throw new Error(`解压后未找到 node.exe: ${exePath}`);
|
|
393
|
+
return {
|
|
394
|
+
exePath,
|
|
395
|
+
version
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** 解压 node zip(zip 内为 node-<version>-win-x64/ 目录,取其中 node.exe 移到缓存根)。 */
|
|
399
|
+
async function unzipNode(zipPath, cacheDir) {
|
|
400
|
+
const tmpDir = (0, node_path.join)(cacheDir, "_zip");
|
|
401
|
+
await extractInstaller(zipPath, tmpDir);
|
|
402
|
+
const nodeDir = (await (0, node_fs_promises.readdir)(tmpDir)).find((n) => n.startsWith("node-"));
|
|
403
|
+
const src = nodeDir === void 0 ? (0, node_path.join)(tmpDir, WIN_NODE_EXE) : (0, node_path.join)(tmpDir, nodeDir, WIN_NODE_EXE);
|
|
404
|
+
if (!(0, node_fs.existsSync)(src)) throw new Error(`node zip 解压后未找到 node.exe: ${src}`);
|
|
405
|
+
await (0, node_fs_promises.copyFile)(src, (0, node_path.join)(cacheDir, WIN_NODE_EXE));
|
|
406
|
+
await (0, node_fs_promises.rm)(tmpDir, {
|
|
407
|
+
recursive: true,
|
|
408
|
+
force: true
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/** 默认 Windows node 版本(latest v24,LTS 线;可被环境变量/参数覆盖)。 */
|
|
412
|
+
const DEFAULT_WIN_NODE_VERSION = "v24.16.0";
|
|
413
|
+
/** 轻量数据根解析(与 locate-qq 内同构,避免循环依赖)。 */
|
|
414
|
+
function resolveDataRoot(dataRoot) {
|
|
415
|
+
const explicit = dataRoot ?? node_process.default.env["NAPKETTO_DATA"];
|
|
416
|
+
if (explicit !== void 0 && explicit !== "") return (0, node_path.resolve)(explicit);
|
|
417
|
+
const projectRoot = findProjectRoot$1(node_process.default.cwd()) ?? node_process.default.cwd();
|
|
418
|
+
return (0, node_path.join)(projectRoot, ".napuketto");
|
|
419
|
+
}
|
|
420
|
+
/** 向上探测项目根(含 pnpm-workspace.yaml 或 package.json 的目录)。 */
|
|
421
|
+
function findProjectRoot$1(start) {
|
|
422
|
+
let dir = (0, node_path.resolve)(start);
|
|
423
|
+
for (;;) {
|
|
424
|
+
if ((0, node_fs.existsSync)((0, node_path.join)(dir, "pnpm-workspace.yaml")) || (0, node_fs.existsSync)((0, node_path.join)(dir, "package.json"))) return dir;
|
|
425
|
+
const parent = (0, node_path.dirname)(dir);
|
|
426
|
+
if (parent === dir) return null;
|
|
427
|
+
dir = parent;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/wine.ts
|
|
432
|
+
/**
|
|
433
|
+
* wine.ts:Linux 上经 wine 运行 Windows 版 node.exe 的路径与 spawn 组装(P2)。
|
|
434
|
+
*
|
|
435
|
+
* 设计文档 §3.2(2026-08-12 拍板确认)。核心认知:
|
|
436
|
+
* - Windows 版 node.exe 在 wine 内把 Linux 根目录挂载在 `Z:\`
|
|
437
|
+
* - process.dlopen / 环境变量路径都是 wine 视角的 Windows 路径
|
|
438
|
+
* - 传给 wine 子进程的**所有路径**(wrapperPath / cfgDir / stubDir / self-host.cjs / PATH)
|
|
439
|
+
* 都要过 toWinePath 转成 `Z:\...`
|
|
440
|
+
*
|
|
441
|
+
* 纯函数设计:不 import node:child_process,方便 Windows 上单测断言(P2 纯逻辑层)。
|
|
442
|
+
*/
|
|
443
|
+
/** Linux 绝对路径 → wine `Z:\` 路径(Windows 版 node.exe 视角)。幂等:已是 Windows 路径(含盘符)原样返回。 */
|
|
444
|
+
function toWinePath(linuxPath) {
|
|
445
|
+
if (DRIVE_LETTER_RE.test(linuxPath)) return linuxPath;
|
|
446
|
+
return `Z:\\${linuxPath.replaceAll("/", "\\").replace(LEADING_SLASH_RE, "")}`;
|
|
447
|
+
}
|
|
448
|
+
/** 前导斜杠(toWinePath 去根用,顶层常量避免每次调用重建)。 */
|
|
449
|
+
const LEADING_SLASH_RE = /^\\/;
|
|
450
|
+
/** 盘符前缀(C: / Z: 等,识别已转换的 Windows 路径)。 */
|
|
451
|
+
const DRIVE_LETTER_RE = /^[A-Za-z]:/;
|
|
452
|
+
/** 判断当前是否 Linux(需 wine 的场景)。 */
|
|
453
|
+
function isLinux() {
|
|
454
|
+
return process.platform === "linux";
|
|
455
|
+
}
|
|
456
|
+
/** wine 可执行文件(Linux 上 spawn 用;可被 NAPUTO_WINE 覆盖)。 */
|
|
457
|
+
function wineBinary() {
|
|
458
|
+
return process.env["NAPUTO_WINE"] ?? "wine";
|
|
459
|
+
}
|
|
460
|
+
/** 组装 spawn 命令(win32 vs linux 分支)。 */
|
|
461
|
+
function buildSpawnCommand(options) {
|
|
462
|
+
if ((options.platform ?? process.platform) === "win32") return {
|
|
463
|
+
command: options.winNodePath ?? process.execPath,
|
|
464
|
+
args: [options.selfHostPath]
|
|
465
|
+
};
|
|
466
|
+
return {
|
|
467
|
+
command: options.wine ?? wineBinary(),
|
|
468
|
+
args: [options.winNodePath ?? "node.exe", options.selfHostPath]
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/launcher.ts
|
|
473
|
+
/**
|
|
474
|
+
* launcher:自建宿主引导(2026-08-07 唯一路线)——spawn 标准 node 跑 self-host.cjs。
|
|
475
|
+
*
|
|
476
|
+
* 历史(V1/路线 B 事实链,已归档 archive/):
|
|
477
|
+
* 1. wrapper.node 只能在 QQ 定制版 Electron 里注册(实测纯 Node/普通 Electron 均 self-register 失败)
|
|
478
|
+
* 2. QQ 是打包应用,禁 NODE_OPTIONS(实测 stderr)
|
|
479
|
+
* → 旧方案:NapukettoBootMain.exe 启动 QQ + 注入 NapukettoWinBootHook.dll(已废弃)
|
|
480
|
+
* → 现方案:stub QQNT.dll 转发宿主符号 → 标准 node 直接 dlopen wrapper.node(自建宿主)
|
|
481
|
+
*
|
|
482
|
+
* P2(2026-08-12):平台分支——win32 本机 node;linux 经 wine 跑 Windows 版 node.exe
|
|
483
|
+
* (ensureWinNode 下载)。所有传给 wine 子进程的路径过 toWinePath(Z:\)。
|
|
484
|
+
*/
|
|
485
|
+
const __dirname$1 = (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
|
|
486
|
+
/**
|
|
487
|
+
* 启动自建宿主(路线 A):spawn 标准 node 直接跑 self-host.cjs,不拉起 QQ / 不注入。
|
|
488
|
+
*
|
|
489
|
+
* 关键(HANDOVER-V7 技术数据):wrapper.node 从 QQNT.dll 导入 99 符号
|
|
490
|
+
* (napi_*×40 + uv_*×56 + qq_magic×1 + v8/node mangled×2)——标准 node 无这些宿主
|
|
491
|
+
* 符号,必须 PATH 前置 stub QQNT.dll 目录(转发到 node.exe)+ QQ resources\app
|
|
492
|
+
* (wrapper.node 同目录依赖 DLL)才能 dlopen 成功。
|
|
493
|
+
*
|
|
494
|
+
* P2 平台分支:win32 用本机 node;linux 用 wine + Windows 版 node.exe
|
|
495
|
+
* (ensureWinNode 自动下载,路径过 toWinePath)。async(win-node 可能需下载)。
|
|
496
|
+
*/
|
|
497
|
+
async function launchSelfHost(options) {
|
|
498
|
+
const selfHostPath = options.selfHostEntry ?? (0, node_path.join)(__dirname$1, "..", "dist", "host", "self-host.cjs");
|
|
499
|
+
if (!(0, node_fs.existsSync)(selfHostPath)) throw new Error(`self-host.cjs 缺失: ${selfHostPath}(先运行 pnpm --filter @napuketto/loader build)`);
|
|
500
|
+
const stub = options.stubDir ?? defaultStubDir();
|
|
501
|
+
if (!(0, node_fs.existsSync)((0, node_path.join)(stub, "QQNT.dll"))) throw new Error(`stub QQNT.dll 未找到: ${stub}(自建宿主必需:stub 转发 QQNT.dll 符号到 node.exe;请用 --stub-dir 或环境变量 NAPUTO_STUB_DIR 指定 stub 目录)`);
|
|
502
|
+
const { useWine, winNodePath } = await resolveNodeExecutable(options);
|
|
503
|
+
const env = buildLaunchEnv(options, useWine);
|
|
504
|
+
applyPathEnv(env, {
|
|
505
|
+
stub,
|
|
506
|
+
wrapperPath: options.qq.wrapperPath,
|
|
507
|
+
useWine
|
|
508
|
+
});
|
|
509
|
+
if (options.cwd !== void 0) (0, node_fs.mkdirSync)(options.cwd, { recursive: true });
|
|
510
|
+
const { command, args } = buildSpawnCommand({
|
|
511
|
+
...useWine && winNodePath !== void 0 ? { winNodePath } : { winNodePath: node_process.default.execPath },
|
|
512
|
+
selfHostPath: useWine ? toWinePath(selfHostPath) : selfHostPath,
|
|
513
|
+
wine: wineBinary$1()
|
|
514
|
+
});
|
|
515
|
+
return {
|
|
516
|
+
child: (0, node_child_process.spawn)(command, args, {
|
|
517
|
+
...options.cwd !== void 0 ? { cwd: options.cwd } : {},
|
|
518
|
+
env,
|
|
519
|
+
stdio: options.stdio ?? "inherit",
|
|
520
|
+
windowsHide: false
|
|
521
|
+
}),
|
|
522
|
+
bootJsPath: selfHostPath
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/** 平台分支解析 node 可执行:win32 本机;linux wine + Windows 版 node.exe。 */
|
|
526
|
+
async function resolveNodeExecutable(options) {
|
|
527
|
+
if (!isLinux()) return {
|
|
528
|
+
useWine: false,
|
|
529
|
+
winNodePath: void 0
|
|
530
|
+
};
|
|
531
|
+
return {
|
|
532
|
+
useWine: true,
|
|
533
|
+
winNodePath: (await ensureWinNode({
|
|
534
|
+
...options.cwd !== void 0 ? { dataRoot: options.cwd } : {},
|
|
535
|
+
...options.winNodePath !== void 0 ? { exePath: options.winNodePath } : {}
|
|
536
|
+
})).exePath
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/** 注入 PATH(stub 目录 + wrapper.node 目录前置)与 STUB_DIR(wine 场景转 Z:\)。 */
|
|
540
|
+
function applyPathEnv(env, opts) {
|
|
541
|
+
const p = (linuxPath) => opts.useWine ? toWinePath(linuxPath) : linuxPath;
|
|
542
|
+
env["PATH"] = [
|
|
543
|
+
p(opts.stub),
|
|
544
|
+
p((0, node_path.dirname)(opts.wrapperPath)),
|
|
545
|
+
node_process.default.env["PATH"] ?? ""
|
|
546
|
+
].filter((entry) => entry !== void 0 && entry !== "").join(";");
|
|
547
|
+
env[ENV.STUB_DIR] = p(opts.stub);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* 默认 stub QQNT.dll 目录(loader 包内闭源 native/build/stub-test-env,开发机默认)。
|
|
551
|
+
* src 与 native 同层,构建后 dist 与 native 同层——`../native` 恒正确。
|
|
552
|
+
*/
|
|
553
|
+
function defaultStubDir() {
|
|
554
|
+
return (0, node_path.join)(__dirname$1, "..", "native", "build", "stub-test-env");
|
|
555
|
+
}
|
|
556
|
+
/** 装配自建宿主环境变量(wine 场景路径转 Z:\;win32 原样)。 */
|
|
557
|
+
function buildLaunchEnv(options, useWine) {
|
|
558
|
+
const cfg = (0, node_path.resolve)(options.cfgDir);
|
|
559
|
+
(0, node_fs.mkdirSync)(cfg, { recursive: true });
|
|
560
|
+
const p = (linuxPath) => useWine ? toWinePath(linuxPath) : linuxPath;
|
|
561
|
+
const env = {
|
|
562
|
+
...node_process.default.env,
|
|
563
|
+
[ENV.QQ_PATH]: p(options.qq.qqPath),
|
|
564
|
+
[ENV.KERNEL_ENTRY]: p((0, node_path.resolve)(options.kernelEntry)),
|
|
565
|
+
[ENV.CFG_DIR]: p(cfg),
|
|
566
|
+
[ENV.QQ_VERSION]: options.qq.version,
|
|
567
|
+
[ENV.WRAPPER_PATH]: p(options.qq.wrapperPath)
|
|
568
|
+
};
|
|
569
|
+
const optional = {
|
|
570
|
+
...options.selfHost === true ? { [ENV.SELF_HOST]: "1" } : {},
|
|
571
|
+
...options.adapterEntry !== void 0 ? { [ENV.ADAPTER_ENTRY]: p((0, node_path.resolve)(options.adapterEntry)) } : {},
|
|
572
|
+
...options.networkEntry !== void 0 ? { [ENV.NETWORK_ENTRY]: p((0, node_path.resolve)(options.networkEntry)) } : {},
|
|
573
|
+
...options.configPath !== void 0 ? { [ENV.CONFIG_PATH]: p((0, node_path.resolve)(options.configPath)) } : {},
|
|
574
|
+
...options.quickUin !== void 0 ? { [ENV.QUICK_UIN]: options.quickUin } : {},
|
|
575
|
+
...options.ipc === true ? { [ENV.IPC]: "1" } : {}
|
|
576
|
+
};
|
|
577
|
+
return {
|
|
578
|
+
...env,
|
|
579
|
+
...optional
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** wine 可执行文件(linux 场景;NAPUTO_WINE 可覆盖)。 */
|
|
583
|
+
function wineBinary$1() {
|
|
584
|
+
return node_process.default.env["NAPUTO_WINE"] ?? "wine";
|
|
585
|
+
}
|
|
586
|
+
/** 环境变量名(self-host.cjs 与 kernel 引导读取)。 */
|
|
587
|
+
const ENV = {
|
|
588
|
+
QQ_PATH: "NAPUTO_QQ_PATH",
|
|
589
|
+
KERNEL_ENTRY: "NAPUTO_KERNEL_ENTRY",
|
|
590
|
+
CFG_DIR: "NAPUTO_CFG_DIR",
|
|
591
|
+
QQ_VERSION: "NAPUTO_QQ_VERSION",
|
|
592
|
+
WRAPPER_PATH: "NAPUTO_WRAPPER_PATH",
|
|
593
|
+
/** 自建宿主:标准 node + stub QQNT.dll 引导(self-host.cjs 分支)。 */
|
|
594
|
+
SELF_HOST: "NAPUTO_SELF_HOST",
|
|
595
|
+
/** stub QQNT.dll 目录(launchSelfHost 注入,self-host.cjs 诊断用)。 */
|
|
596
|
+
STUB_DIR: "NAPUTO_STUB_DIR",
|
|
597
|
+
/** adapter 包入口(协议装配用)。 */
|
|
598
|
+
ADAPTER_ENTRY: "NAPUTO_ADAPTER_ENTRY",
|
|
599
|
+
/** network 包入口(协议装配用)。 */
|
|
600
|
+
NETWORK_ENTRY: "NAPUTO_NETWORK_ENTRY",
|
|
601
|
+
/** 全局配置文件路径(项目根 napuketto.toml,boot-protocols 读取)。 */
|
|
602
|
+
CONFIG_PATH: "NAPKETTO_CONFIG",
|
|
603
|
+
/** 强制指定快速登录账号(cli `-q <uin>` 透传,bootstrap 登录用)。 */
|
|
604
|
+
QUICK_UIN: "NAPUTO_QUICK_UIN",
|
|
605
|
+
/** IPC 子进程模式(koishi 插件驱动:stdout JSON 行 + stdin action/control)。 */
|
|
606
|
+
IPC: "NAPUTO_IPC"
|
|
607
|
+
};
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/qq-releases.ts
|
|
610
|
+
/**
|
|
611
|
+
* qq-releases.ts:版本清单读取(P1)。
|
|
612
|
+
*
|
|
613
|
+
* 运行时从包根 qq-releases.json 读取(发布时 files 含它,安装后位于
|
|
614
|
+
* node_modules/@napuketto/loader/qq-releases.json)。清单由 CI/社区 PR 维护
|
|
615
|
+
* (设计文档 §2.2)。NAPUTO_QQ_URL 可运行时覆盖下载地址。
|
|
616
|
+
*/
|
|
617
|
+
/** 清单解析失败。 */
|
|
618
|
+
var QqReleasesError = class extends Error {
|
|
619
|
+
constructor(message) {
|
|
620
|
+
super(message);
|
|
621
|
+
this.name = "QqReleasesError";
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
/** 包根路径(dist 与包根同层:dist/index.mjs → ../qq-releases.json)。 */
|
|
625
|
+
function packageRoot() {
|
|
626
|
+
return (0, node_path.dirname)((0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href)));
|
|
627
|
+
}
|
|
628
|
+
/** 读版本清单(同步,启动期调用一次即可)。 */
|
|
629
|
+
function loadQqReleases() {
|
|
630
|
+
const file = (0, node_path.join)(packageRoot(), "qq-releases.json");
|
|
631
|
+
let raw;
|
|
632
|
+
try {
|
|
633
|
+
raw = (0, node_fs.readFileSync)(file, "utf8");
|
|
634
|
+
} catch {
|
|
635
|
+
throw new QqReleasesError(`版本清单缺失: ${file}(@napuketto/loader 包安装不完整?)`);
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
const parsed = JSON.parse(raw);
|
|
639
|
+
if (parsed.schema !== 1 || !Array.isArray(parsed.known) || parsed.known.length === 0) throw new Error("结构不合法");
|
|
640
|
+
return parsed;
|
|
641
|
+
} catch {
|
|
642
|
+
throw new QqReleasesError(`版本清单解析失败: ${file}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/** 取最新已知可用版本(known 数组按录入顺序,取最后一项 = 最新)。 */
|
|
646
|
+
function latestRelease(releases) {
|
|
647
|
+
const [latest] = [...releases.known].reverse();
|
|
648
|
+
if (latest === void 0) throw new QqReleasesError("版本清单为空");
|
|
649
|
+
return latest;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* 解析实际下载 URL:NAPUTO_QQ_URL 环境变量覆盖 > 清单 URL。
|
|
653
|
+
* 供 downloadFile 使用(也支持按版本查询,当前固定取最新)。
|
|
654
|
+
*/
|
|
655
|
+
function resolveDownloadUrl(release) {
|
|
656
|
+
const override = node_process.default.env["NAPUTO_QQ_URL"];
|
|
657
|
+
return override !== void 0 && override !== "" ? override : release.url;
|
|
658
|
+
}
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/locate-qq.ts
|
|
661
|
+
/**
|
|
662
|
+
* 定位 QQ 原生文件(wrapper.node)来源(P0:多级来源纯定位,不下载)。
|
|
663
|
+
*
|
|
664
|
+
* 来源优先级(resolveQqFiles):
|
|
665
|
+
* L0: NAPUTO_QQ_FILES / qqFilesDir —— 显式指定含 versions/ 结构的文件根(缓存/拷贝)
|
|
666
|
+
* L1: 本机 QQ 安装 —— NAPUTO_QQ_PATH / 注册表 / 常见路径(既有逻辑保留)
|
|
667
|
+
* L2: 数据根缓存 <数据根>/qq-files/<版本> —— 下载解包产物(P1 落地后才有)
|
|
668
|
+
*/
|
|
669
|
+
/** 数据根下 QQ 文件缓存目录名(<数据根>/qq-files/<版本>/,P1 下载解包产物)。 */
|
|
670
|
+
const QQ_FILES_DIR_NAME = "qq-files";
|
|
671
|
+
/** 注册表 UninstallString 查询(QQ 官方安装路径)。 */
|
|
672
|
+
const REG_QUERY_RE = /"([^"]+)"/;
|
|
673
|
+
/** Windows 盘符路径正则(`C:/...` / `C:\...`,wslMappedPath 映射用,模块级常量)。 */
|
|
674
|
+
const DRIVE_PATH_RE = /^([A-Za-z]):[\\/](.*)$/;
|
|
675
|
+
/** 注册表 UninstallString 查询(QQ 官方安装路径)。 */
|
|
676
|
+
function findQqViaRegistry() {
|
|
677
|
+
try {
|
|
678
|
+
const exe = (0, node_child_process.execFileSync)("reg", [
|
|
679
|
+
"query",
|
|
680
|
+
"HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\QQ",
|
|
681
|
+
"/v",
|
|
682
|
+
"UninstallString"
|
|
683
|
+
]).toString().match(REG_QUERY_RE)?.[1];
|
|
684
|
+
if (exe !== void 0) {
|
|
685
|
+
const dir = exe.replaceAll("\"", "").slice(0, exe.lastIndexOf("\\"));
|
|
686
|
+
const qq = (0, node_path.join)(dir, "QQ.exe");
|
|
687
|
+
if ((0, node_fs.existsSync)(qq)) return qq;
|
|
688
|
+
}
|
|
689
|
+
} catch {}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
/** 常见安装路径探测。 */
|
|
693
|
+
function findQqViaCommonPaths() {
|
|
694
|
+
const localAppData = node_process.default.env["LOCALAPPDATA"] ?? "";
|
|
695
|
+
const candidates = [
|
|
696
|
+
"C:/Program Files/Tencent/QQNT/QQ.exe",
|
|
697
|
+
"C:/Program Files (x86)/Tencent/QQNT/QQ.exe",
|
|
698
|
+
(0, node_path.join)(localAppData, "Programs", "Tencent", "QQNT", "QQ.exe"),
|
|
699
|
+
"C:/Dev/QQBot-Dev/QQNT/QQ.exe"
|
|
700
|
+
];
|
|
701
|
+
for (const p of candidates) {
|
|
702
|
+
const wsl = wslMappedPath(p);
|
|
703
|
+
if ((0, node_fs.existsSync)(wsl)) return wsl;
|
|
704
|
+
if ((0, node_fs.existsSync)(p)) return p;
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Windows 路径 → WSL 挂载路径(`C:/...` → `/mnt/c/...`;非 Linux 或非盘符路径原样)。
|
|
710
|
+
* WSL 默认把 Windows 盘符挂载到 `/mnt/<小写盘符>/`,Linux 侧 `existsSync` 必须用映射路径。
|
|
711
|
+
* platform 参数可注入(测试用;缺省 process.platform)。
|
|
712
|
+
* 导出便于单测(locate-qq.test.ts)。
|
|
713
|
+
* ⚠️ 不用 join 拼 `/mnt/...`:测试在 Windows 跑,win32 join 会产生反斜杠;目标路径
|
|
714
|
+
* 是 Linux 侧路径(WSL 挂载点),必须恒正斜杠。
|
|
715
|
+
*/
|
|
716
|
+
function wslMappedPath(winPath, platform = node_process.default.platform) {
|
|
717
|
+
if (platform !== "linux") return winPath;
|
|
718
|
+
const [, drive, rest] = DRIVE_PATH_RE.exec(winPath) ?? [];
|
|
719
|
+
if (drive === void 0 || rest === void 0) return winPath;
|
|
720
|
+
return `/mnt/${drive.toLowerCase()}/${rest.replaceAll("\\", "/")}`;
|
|
721
|
+
}
|
|
722
|
+
/** 找 QQ.exe 路径。 */
|
|
723
|
+
function locateQqPath() {
|
|
724
|
+
const viaReg = findQqViaRegistry();
|
|
725
|
+
if (viaReg) return viaReg;
|
|
726
|
+
const viaCommon = findQqViaCommonPaths();
|
|
727
|
+
if (viaCommon) return viaCommon;
|
|
728
|
+
throw new Error("未找到 QQ.exe(请先安装 QQ,或通过 NAPUTO_QQ_PATH 指定)");
|
|
729
|
+
}
|
|
730
|
+
/** 从文件根定位最新版本 wrapper.node(local/cached 共用)。 */
|
|
731
|
+
function resolveFromRoot(rootDir, source) {
|
|
732
|
+
const versionsDir = (0, node_path.join)(rootDir, "versions");
|
|
733
|
+
if (!(0, node_fs.existsSync)(versionsDir)) throw new Error(`QQ 版本目录不存在: ${versionsDir}`);
|
|
734
|
+
const [first] = (0, node_fs.readdirSync)(versionsDir).filter((v) => (0, node_fs.statSync)((0, node_path.join)(versionsDir, v)).isDirectory()).sort().reverse();
|
|
735
|
+
if (first === void 0) throw new Error(`QQ 版本目录为空: ${versionsDir}`);
|
|
736
|
+
const wrapperPath = (0, node_path.join)(versionsDir, first, "resources", "app", "wrapper.node");
|
|
737
|
+
if (!(0, node_fs.existsSync)(wrapperPath)) throw new Error(`未找到 wrapper.node: ${wrapperPath}`);
|
|
738
|
+
return {
|
|
739
|
+
qqPath: (0, node_path.join)(rootDir, "QQ.exe"),
|
|
740
|
+
installDir: rootDir,
|
|
741
|
+
version: first,
|
|
742
|
+
wrapperPath,
|
|
743
|
+
source
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/** 从 QQ.exe 路径推导安装目录,并探测当前版本(L1:本机安装)。 */
|
|
747
|
+
function resolveQqInstall(qqPath) {
|
|
748
|
+
const qq = qqPath ?? node_process.default.env["NAPUTO_QQ_PATH"] ?? locateQqPath();
|
|
749
|
+
return resolveFromRoot((0, node_path.dirname)(qq), "local");
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* 多级来源定位 QQ 原生文件:
|
|
753
|
+
* L0 → L1 → L2 依次尝试,全部失败且 autoDownload 时自动进入下载流程(ensureQqFiles)。
|
|
754
|
+
* ⚠️ async:自动下载为异步流程(P1 起);纯定位场景用 resolveQqInstall(同步)。
|
|
755
|
+
*/
|
|
756
|
+
async function resolveQqFiles(options = {}) {
|
|
757
|
+
const explicitDir = options.qqFilesDir ?? node_process.default.env["NAPUTO_QQ_FILES"];
|
|
758
|
+
if (explicitDir !== void 0 && explicitDir !== "") return resolveFromRoot((0, node_path.resolve)(explicitDir), "cached");
|
|
759
|
+
const qqPath = options.qqPath ?? node_process.default.env["NAPUTO_QQ_PATH"];
|
|
760
|
+
if (qqPath !== void 0 && qqPath !== "") return resolveQqInstall(qqPath);
|
|
761
|
+
try {
|
|
762
|
+
return resolveQqInstall();
|
|
763
|
+
} catch {}
|
|
764
|
+
const dataRoot = resolveDataRootLight(options.dataRoot);
|
|
765
|
+
const cached = tryResolveCached(dataRoot);
|
|
766
|
+
if (cached !== null) return cached;
|
|
767
|
+
if (options.autoDownload !== false) return ensureQqFiles({
|
|
768
|
+
dataRoot,
|
|
769
|
+
...options.sevenZipPath !== void 0 ? { sevenZipPath: options.sevenZipPath } : {}
|
|
770
|
+
});
|
|
771
|
+
throw new Error("未找到 QQ 原生文件(wrapper.node):请安装 QQ、设置 NAPUTO_QQ_PATH/NAPUTO_QQ_FILES,或使用下载流程(ensureQqFiles)自动获取");
|
|
772
|
+
}
|
|
773
|
+
/** 扫描数据根缓存(L2);命中返回,未命中返回 null。 */
|
|
774
|
+
function tryResolveCached(dataRoot) {
|
|
775
|
+
const cacheRoot = (0, node_path.join)(dataRoot, QQ_FILES_DIR_NAME);
|
|
776
|
+
if (!(0, node_fs.existsSync)(cacheRoot)) return null;
|
|
777
|
+
const versionDirs = (0, node_fs.readdirSync)(cacheRoot).filter((v) => (0, node_fs.statSync)((0, node_path.join)(cacheRoot, v)).isDirectory()).sort().reverse();
|
|
778
|
+
for (const versionDir of versionDirs) try {
|
|
779
|
+
return resolveFromRoot((0, node_path.join)(cacheRoot, versionDir), "cached");
|
|
780
|
+
} catch {}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* 确保 QQ 原生文件就绪(P1):幂等缓存检查 → 下载官方安装包 → sha256 校验
|
|
785
|
+
* → 7z 解包 → 提取 wrapper.node/QQNT.dll → 缓存。返回缓存版本 QqInstallInfo。
|
|
786
|
+
*/
|
|
787
|
+
async function ensureQqFiles(options = {}) {
|
|
788
|
+
const dataRoot = resolveDataRootLight(options.dataRoot);
|
|
789
|
+
const cacheRoot = (0, node_path.join)(dataRoot, QQ_FILES_DIR_NAME);
|
|
790
|
+
(0, node_fs.mkdirSync)(cacheRoot, { recursive: true });
|
|
791
|
+
const existing = tryResolveCached(dataRoot);
|
|
792
|
+
if (existing !== null) return existing;
|
|
793
|
+
const release = latestRelease(loadQqReleases());
|
|
794
|
+
const url = resolveDownloadUrl(release);
|
|
795
|
+
const version = release.version;
|
|
796
|
+
const tmpDir = (0, node_path.join)(dataRoot, "tmp");
|
|
797
|
+
(0, node_fs.mkdirSync)(tmpDir, { recursive: true });
|
|
798
|
+
const installerPath = (0, node_path.join)(tmpDir, `qq-installer-${version}.exe`);
|
|
799
|
+
await downloadFile({
|
|
800
|
+
dest: installerPath,
|
|
801
|
+
url,
|
|
802
|
+
expectedSha256: release.sha256
|
|
803
|
+
});
|
|
804
|
+
const extractedDir = (0, node_path.join)(tmpDir, `qq-extracted-${version}`);
|
|
805
|
+
try {
|
|
806
|
+
await extractInstaller(installerPath, extractedDir, options.sevenZipPath);
|
|
807
|
+
return await extractWrapperFiles(extractedDir, version, cacheRoot);
|
|
808
|
+
} catch (err) {
|
|
809
|
+
await clearCacheVersion(cacheRoot, version);
|
|
810
|
+
throw err;
|
|
811
|
+
} finally {
|
|
812
|
+
await (0, node_fs_promises.rm)(installerPath, { force: true });
|
|
813
|
+
await (0, node_fs_promises.rm)(extractedDir, {
|
|
814
|
+
recursive: true,
|
|
815
|
+
force: true
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* 轻量数据根解析(loader 侧,避免新增 kernel 依赖;P2 若需完整语义再切换)。
|
|
821
|
+
* 优先级:显式参数 > NAPKETTO_DATA > 项目根/.napuketto > cwd 兜底。
|
|
822
|
+
*/
|
|
823
|
+
function resolveDataRootLight(dataRoot) {
|
|
824
|
+
const explicit = dataRoot ?? node_process.default.env["NAPKETTO_DATA"];
|
|
825
|
+
if (explicit !== void 0 && explicit !== "") return (0, node_path.resolve)(explicit);
|
|
826
|
+
const projectRoot = findProjectRoot(node_process.default.cwd()) ?? node_process.default.cwd();
|
|
827
|
+
return (0, node_path.join)(projectRoot, ".napuketto");
|
|
828
|
+
}
|
|
829
|
+
/** 向上探测项目根(含 pnpm-workspace.yaml 或 package.json 的目录)。 */
|
|
830
|
+
function findProjectRoot(start) {
|
|
831
|
+
let dir = (0, node_path.resolve)(start);
|
|
832
|
+
for (;;) {
|
|
833
|
+
if ((0, node_fs.existsSync)((0, node_path.join)(dir, "pnpm-workspace.yaml")) || (0, node_fs.existsSync)((0, node_path.join)(dir, "package.json"))) return dir;
|
|
834
|
+
const parent = (0, node_path.dirname)(dir);
|
|
835
|
+
if (parent === dir) return null;
|
|
836
|
+
dir = parent;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
//#endregion
|
|
840
|
+
exports.DownloadError = DownloadError;
|
|
841
|
+
exports.ENV = ENV;
|
|
842
|
+
exports.INSTANCE_LOCK_FILE = INSTANCE_LOCK_FILE;
|
|
843
|
+
exports.QQ_FILES_DIR_NAME = QQ_FILES_DIR_NAME;
|
|
844
|
+
exports.QqReleasesError = QqReleasesError;
|
|
845
|
+
exports.WIN_NODE_DIR_NAME = WIN_NODE_DIR_NAME;
|
|
846
|
+
exports.acquireInstanceLock = acquireInstanceLock;
|
|
847
|
+
exports.buildSpawnCommand = buildSpawnCommand;
|
|
848
|
+
exports.checkInstanceLock = checkInstanceLock;
|
|
849
|
+
exports.clearCacheVersion = clearCacheVersion;
|
|
850
|
+
exports.defaultStubDir = defaultStubDir;
|
|
851
|
+
exports.downloadFile = downloadFile;
|
|
852
|
+
exports.ensureQqFiles = ensureQqFiles;
|
|
853
|
+
exports.ensureWinNode = ensureWinNode;
|
|
854
|
+
exports.extractInstaller = extractInstaller;
|
|
855
|
+
exports.extractWrapperFiles = extractWrapperFiles;
|
|
856
|
+
exports.findSevenZip = findSevenZip;
|
|
857
|
+
exports.isLinux = isLinux;
|
|
858
|
+
exports.latestRelease = latestRelease;
|
|
859
|
+
exports.launchSelfHost = launchSelfHost;
|
|
860
|
+
exports.loadQqReleases = loadQqReleases;
|
|
861
|
+
exports.locateQqPath = locateQqPath;
|
|
862
|
+
exports.nodeZipUrl = nodeZipUrl;
|
|
863
|
+
exports.registerLockCleanup = registerLockCleanup;
|
|
864
|
+
exports.releaseInstanceLock = releaseInstanceLock;
|
|
865
|
+
exports.resolveDownloadUrl = resolveDownloadUrl;
|
|
866
|
+
exports.resolveQqFiles = resolveQqFiles;
|
|
867
|
+
exports.resolveQqInstall = resolveQqInstall;
|
|
868
|
+
exports.toWinePath = toWinePath;
|
|
869
|
+
exports.wineBinary = wineBinary;
|