@napuketto/loader 0.0.1 → 0.0.2
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/README.md +11 -0
- package/dist/host/self-host.cjs +464 -208
- package/dist/index.d.mts +62 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +107 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @napuketto/loader
|
|
2
|
+
|
|
3
|
+
NapukettoQQ 引导组件——**自建宿主**:标准 Node + stub QQNT.dll 直接 `dlopen` `wrapper.node` 并启动 kernel,不拉起 QQ、不注入。
|
|
4
|
+
|
|
5
|
+
- **launcher** — 装配环境变量 + PATH 前置 stub + spawn 标准 node
|
|
6
|
+
- **locate-qq** — 定位 QQ 安装与版本
|
|
7
|
+
- **host** — 自建宿主入口(self-host / session / login / protocols / bootstrap / smoke)
|
|
8
|
+
|
|
9
|
+
## 注意
|
|
10
|
+
|
|
11
|
+
`native/` 为 Git Submodule(private 仓库 `Oppenheymu/NapukettoQQ-Native`),含 stub QQNT.dll 源码与编译产物。clone 后需 `git submodule update --init --recursive`。
|
package/dist/host/self-host.cjs
CHANGED
|
@@ -1,6 +1,134 @@
|
|
|
1
|
-
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
//#endregion
|
|
2
23
|
let node_fs = require("node:fs");
|
|
24
|
+
let node_path = require("node:path");
|
|
25
|
+
let node_process = require("node:process");
|
|
26
|
+
node_process = __toESM(node_process, 1);
|
|
3
27
|
let node_os = require("node:os");
|
|
28
|
+
//#region src/instance-lock.ts
|
|
29
|
+
/**
|
|
30
|
+
* instance-lock.ts:单实例锁(2026-08-07 根治「多实例抢数据目录锁挂起」)
|
|
31
|
+
*
|
|
32
|
+
* 背景(实测):同一账号数据目录(~/.napuketto/<uin>)只能被一个实例持有——
|
|
33
|
+
* QQ 原生层(MMKV / 登录单例 / 数据库)有锁,第二个实例抢不到锁会卡在
|
|
34
|
+
* loginService.initConfig 之后无响应(挂起)。此前 create-napukettoqq 生成的
|
|
35
|
+
* 副本实例常驻占锁,导致用户后续启动全部卡死。
|
|
36
|
+
*
|
|
37
|
+
* 方案:以数据目录为粒度加锁文件 `<dataDir>/instance.lock`(JSON 内容:
|
|
38
|
+
* pid / startedAt / cmdline 摘要)。启动前 checkInstanceLock:
|
|
39
|
+
* - 锁不存在 → 可启动(然后 acquireInstanceLock 写入)
|
|
40
|
+
* - 锁存在但 PID 已死 → 崩溃残留,自动清理(rm + 重新获取)
|
|
41
|
+
* - 锁存在且 PID 存活 → 已被占用,拒绝启动(返回占用 PID 供提示)
|
|
42
|
+
*
|
|
43
|
+
* 退出清理:进程正常退出时 releaseInstanceLock 删除锁文件;崩溃/强杀残留
|
|
44
|
+
* 由下次启动的「PID 存活检测」兜底(PID 已死 → 残留视为过期)。
|
|
45
|
+
*
|
|
46
|
+
* 放置说明:放 loader 而非 kernel——self-host.cjs 是 rolldown 内联 bundle
|
|
47
|
+
* (只内联 src/host/ 依赖树),静态 import kernel 会留 external 缺口;
|
|
48
|
+
* 放 loader 则 self-host(内联)与 cli(@napuketto/loader 导入)双入口可用。
|
|
49
|
+
*/
|
|
50
|
+
/** 锁文件名(置于数据目录内)。 */
|
|
51
|
+
const INSTANCE_LOCK_FILE = "instance.lock";
|
|
52
|
+
/**
|
|
53
|
+
* 检测指定数据目录是否已被其他实例占用。
|
|
54
|
+
*
|
|
55
|
+
* @param dataDir 数据目录(账号级,如 ~/.napuketto/3567141148)
|
|
56
|
+
*/
|
|
57
|
+
function checkInstanceLock(dataDir) {
|
|
58
|
+
const lockPath = (0, node_path.join)(dataDir, INSTANCE_LOCK_FILE);
|
|
59
|
+
let info = null;
|
|
60
|
+
try {
|
|
61
|
+
info = JSON.parse((0, node_fs.readFileSync)(lockPath, "utf-8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return {
|
|
64
|
+
occupied: false,
|
|
65
|
+
pid: null,
|
|
66
|
+
lockPath
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (typeof info.pid !== "number" || Number.isNaN(info.pid) || info.pid <= 0) return {
|
|
70
|
+
occupied: false,
|
|
71
|
+
pid: null,
|
|
72
|
+
lockPath
|
|
73
|
+
};
|
|
74
|
+
let alive = false;
|
|
75
|
+
try {
|
|
76
|
+
node_process.default.kill(info.pid, 0);
|
|
77
|
+
alive = true;
|
|
78
|
+
} catch {
|
|
79
|
+
alive = false;
|
|
80
|
+
}
|
|
81
|
+
if (!alive) return {
|
|
82
|
+
occupied: false,
|
|
83
|
+
pid: null,
|
|
84
|
+
lockPath
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
occupied: true,
|
|
88
|
+
pid: info.pid,
|
|
89
|
+
lockPath
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 获取数据目录锁。占用(其他实例存活)返回 false;成功或清理残留后返回 true。
|
|
94
|
+
* 幂等:本进程已持有则直接返回 true(不重复写)。
|
|
95
|
+
*/
|
|
96
|
+
function acquireInstanceLock(dataDir, cmdline) {
|
|
97
|
+
const { occupied, pid, lockPath } = checkInstanceLock(dataDir);
|
|
98
|
+
if (occupied) {
|
|
99
|
+
if (pid === node_process.default.pid) return true;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const info = {
|
|
103
|
+
pid: node_process.default.pid,
|
|
104
|
+
startedAt: Date.now(),
|
|
105
|
+
...cmdline !== void 0 && cmdline !== "" ? { cmdline } : {}
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
(0, node_fs.rmSync)(lockPath, { force: true });
|
|
109
|
+
(0, node_fs.writeFileSync)(lockPath, JSON.stringify(info), "utf-8");
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 释放数据目录锁(正常退出时调用)。仅当持有者是本进程时才删除,
|
|
117
|
+
* 避免误删新持有者的锁(PID 复用 / 快速重启场景)。
|
|
118
|
+
*/
|
|
119
|
+
function releaseInstanceLock(dataDir) {
|
|
120
|
+
const lockPath = (0, node_path.join)(dataDir, INSTANCE_LOCK_FILE);
|
|
121
|
+
try {
|
|
122
|
+
if (JSON.parse((0, node_fs.readFileSync)(lockPath, "utf-8")).pid === node_process.default.pid) (0, node_fs.rmSync)(lockPath, { force: true });
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
/** 注册进程退出清理(SIGINT/SIGTERM/exit 均触发;崩溃强杀由残留检测兜底)。 */
|
|
126
|
+
function registerLockCleanup(dataDir) {
|
|
127
|
+
node_process.default.on("exit", () => {
|
|
128
|
+
releaseInstanceLock(dataDir);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
4
132
|
//#region src/host/env.ts
|
|
5
133
|
/**
|
|
6
134
|
* env.ts:引导进程环境变量访问层(2026-08-07 阶段 2)。
|
|
@@ -60,6 +188,17 @@ function log(msg) {
|
|
|
60
188
|
function errMsg(e) {
|
|
61
189
|
return `${e?.message ?? e}`;
|
|
62
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* 逐条处理消息回调参数(onRecvMsg 为消息数组——2026-08-07 运行时实证;
|
|
193
|
+
* 兼容单条对象,过滤无效项)。
|
|
194
|
+
*/
|
|
195
|
+
function forEachRawMessage(msgs, handler) {
|
|
196
|
+
const list = Array.isArray(msgs) ? msgs : [msgs];
|
|
197
|
+
for (const msg of list) {
|
|
198
|
+
if (!msg || typeof msg !== "object") continue;
|
|
199
|
+
handler(msg);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
63
202
|
function createState() {
|
|
64
203
|
return {
|
|
65
204
|
wrapperExports: null,
|
|
@@ -112,26 +251,99 @@ async function doLogin(core, opts) {
|
|
|
112
251
|
}
|
|
113
252
|
}
|
|
114
253
|
//#endregion
|
|
115
|
-
//#region src/host/
|
|
254
|
+
//#region src/host/load-config.ts
|
|
116
255
|
/**
|
|
117
|
-
* protocols.ts
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
* 终端 ANSI 颜色(消息日志颜色分层,2026-08-07 用户定稿):
|
|
124
|
-
* `loader | 接收 <- 群聊` 灰色(背景噪音)/ `[会话]` 青色 / `[发送者]` 绿色 / 内容默认色。
|
|
125
|
-
* 仅 console 版嵌入;boot 文件日志用纯文本版(log),不污染文件。
|
|
256
|
+
* 协议配置段读取(从 protocols.ts 拆分,2026-08-08 FTA 优化)
|
|
257
|
+
*
|
|
258
|
+
* 全局 TOML 配置(<项目根>/napuketto.toml,2026-08-07 用户拍板:配置文件放项目根):
|
|
259
|
+
* 读 [onebot11] / [satori] 段,装配时 zod 校验后作 seed。路径优先级:NAPKETTO_CONFIG
|
|
260
|
+
* (launcher 注入)> 装配链自身探测(kernel.resolveConfigPath)> NAPUTO_CFG_DIR 兜底
|
|
261
|
+
* (旧行为兼容)。(ConfigBase seed 模式:load() 直接用内存值,不再读写独立协议文件)
|
|
126
262
|
*/
|
|
263
|
+
/** 读取全局 TOML 的协议段(读取失败用默认空段,不阻塞装配)。 */
|
|
264
|
+
function loadProtocolSections(kernel) {
|
|
265
|
+
const cfgFile = env.NAPKETTO_CONFIG || (0, node_path.join)(env.NAPUTO_CFG_DIR || ".", "napuketto.toml");
|
|
266
|
+
let ob11Section = {};
|
|
267
|
+
let satoriSection = {};
|
|
268
|
+
try {
|
|
269
|
+
const raw = (0, node_fs.readFileSync)(cfgFile, "utf8");
|
|
270
|
+
const parsed = kernel.parseToml(raw);
|
|
271
|
+
const ob11 = parsed["onebot11"];
|
|
272
|
+
const satori = parsed["satori"];
|
|
273
|
+
if (ob11 !== void 0 && ob11 !== null && typeof ob11 === "object") ob11Section = ob11;
|
|
274
|
+
if (satori !== void 0 && satori !== null && typeof satori === "object") satoriSection = satori;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
log(`bootstrap: 全局配置读取失败(用默认 ob11/satori 配置): ${errMsg(e)}`);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
cfgFile,
|
|
280
|
+
ob11Section,
|
|
281
|
+
satoriSection
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/host/msg-log.ts
|
|
286
|
+
/** 终端 ANSI 颜色(消息日志颜色分层,2026-08-07 用户定稿)。 */
|
|
127
287
|
const ANSI = {
|
|
128
288
|
gray: "\x1B[90m",
|
|
129
289
|
cyan: "\x1B[36m",
|
|
130
290
|
green: "\x1B[32m",
|
|
131
291
|
reset: "\x1B[0m"
|
|
132
292
|
};
|
|
293
|
+
/** canonical 元素 → 日志占位文本。 */
|
|
294
|
+
function elementToText(el) {
|
|
295
|
+
if (el.type === "text") return el.text ?? "";
|
|
296
|
+
if (el.type === "at") return `@${el.display ?? (el.target === "all" ? "全体" : el.target)}`;
|
|
297
|
+
if (el.type === "file") return `[文件${el.name ?? ""}]`;
|
|
298
|
+
if (el.type === "forward") return "[合并转发]";
|
|
299
|
+
return `[${el.type}]`;
|
|
300
|
+
}
|
|
301
|
+
/** 渲染消息为单行文本(text 拼接,at/媒体元素转占位)。 */
|
|
302
|
+
function renderMessage(kernel, msg) {
|
|
303
|
+
let rendered = "";
|
|
304
|
+
for (const el of kernel.toCanonicalElements(msg)) rendered += elementToText(el);
|
|
305
|
+
return rendered === "" ? "[空消息/媒体]" : rendered;
|
|
306
|
+
}
|
|
133
307
|
/**
|
|
134
|
-
*
|
|
308
|
+
* 消息日志订阅(onRecvMsg 回调参数为消息数组——2026-08-07 运行时实证,遍历逐条打印)。
|
|
309
|
+
*/
|
|
310
|
+
function setupMsgLogging(kernel, channel, logger) {
|
|
311
|
+
channel.on("Msg/onRecvMsg", (msgs) => {
|
|
312
|
+
forEachRawMessage(msgs, (m) => {
|
|
313
|
+
try {
|
|
314
|
+
const raw = m;
|
|
315
|
+
const rendered = renderMessage(kernel, m);
|
|
316
|
+
const isGroup = raw.chatType === kernel.ChatType.GROUP;
|
|
317
|
+
const kind = isGroup ? "群聊" : "私聊";
|
|
318
|
+
const peerUin = String(raw.peerUin ?? "");
|
|
319
|
+
const senderUin = String(raw.senderUin ?? (peerUin || "未知"));
|
|
320
|
+
const plainTags = isGroup ? `[群${peerUin}] [用户${senderUin}]` : `[用户${senderUin}]`;
|
|
321
|
+
const coloredTags = isGroup ? `${ANSI.cyan}[群${peerUin}]${ANSI.reset} ${ANSI.green}[用户${senderUin}]${ANSI.reset}` : `${ANSI.green}[用户${senderUin}]${ANSI.reset}`;
|
|
322
|
+
const content = rendered.replace(/\n/g, "\n ");
|
|
323
|
+
const plain = `接收 <- ${kind} ${plainTags}: ${content}`;
|
|
324
|
+
const colored = `${ANSI.gray}loader | 接收 <- ${kind}${ANSI.reset} ${coloredTags}: ${content}`;
|
|
325
|
+
log(plain);
|
|
326
|
+
logger?.info(colored);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
const line2 = `接收消息(解析失败: ${errMsg(e)})`;
|
|
329
|
+
log(line2);
|
|
330
|
+
logger?.warn(line2);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/host/protocols.ts
|
|
337
|
+
/**
|
|
338
|
+
* protocols.ts:协议装配(登录成功后)。
|
|
339
|
+
* 2026-08-07 阶段 2:由 runtime/boot-protocols.js TS 化(零语义改动)。
|
|
340
|
+
* 动态 import adapter/network 入口,装配 OB11 / Satori 适配器。
|
|
341
|
+
* 依赖 launcher 注入的 NAPUTO_ADAPTER_ENTRY / NAPUTO_NETWORK_ENTRY。
|
|
342
|
+
*
|
|
343
|
+
* 2026-08-08 FTA 优化:消息日志渲染 → msg-log.ts;TOML 配置段读取 → load-config.ts。
|
|
344
|
+
*/
|
|
345
|
+
/**
|
|
346
|
+
* 登录成功后装配协议:kernel 各 Api + network 广播 + OB11 / Satori 适配器。
|
|
135
347
|
*/
|
|
136
348
|
async function startProtocols(kernel, ctx, loginResult) {
|
|
137
349
|
const logger = kernel.createLogger?.({
|
|
@@ -147,8 +359,10 @@ async function startProtocols(kernel, ctx, loginResult) {
|
|
|
147
359
|
try {
|
|
148
360
|
const network = await import(`file://${networkEntry.replace(/\\/g, "/")}`);
|
|
149
361
|
const onebot11Entry = adapterEntry.replace(/index\.mjs$/, "onebot11/index.mjs");
|
|
362
|
+
const satoriEntry = adapterEntry.replace(/index\.mjs$/, "satori/index.mjs");
|
|
150
363
|
const coreEntry = adapterEntry.replace(/index\.mjs$/, "core/index.mjs");
|
|
151
364
|
const adapter = await import(`file://${onebot11Entry.replace(/\\/g, "/")}`);
|
|
365
|
+
const satoriAdapter = await import(`file://${satoriEntry.replace(/\\/g, "/")}`);
|
|
152
366
|
const adapterCore = await import(`file://${coreEntry.replace(/\\/g, "/")}`);
|
|
153
367
|
const session = ctx.session;
|
|
154
368
|
if (!session) {
|
|
@@ -157,44 +371,7 @@ async function startProtocols(kernel, ctx, loginResult) {
|
|
|
157
371
|
}
|
|
158
372
|
const channel = new kernel.NTEventChannel("Msg");
|
|
159
373
|
new kernel.MsgBridge(session, channel).register();
|
|
160
|
-
|
|
161
|
-
const list = Array.isArray(msgs) ? msgs : [msgs];
|
|
162
|
-
for (const msg of list) {
|
|
163
|
-
if (!msg || typeof msg !== "object") continue;
|
|
164
|
-
try {
|
|
165
|
-
let rendered = "";
|
|
166
|
-
for (const el of kernel.toCanonicalElements(msg)) if (el.type === "text") rendered += el.text;
|
|
167
|
-
else if (el.type === "at") {
|
|
168
|
-
const name = el.display ?? (el.target === "all" ? "全体" : el.target);
|
|
169
|
-
rendered += `@${name}`;
|
|
170
|
-
} else if (el.type === "face") rendered += "[表情]";
|
|
171
|
-
else if (el.type === "image") rendered += "[图片]";
|
|
172
|
-
else if (el.type === "voice") rendered += "[语音]";
|
|
173
|
-
else if (el.type === "video") rendered += "[视频]";
|
|
174
|
-
else if (el.type === "reply") rendered += "[回复]";
|
|
175
|
-
else if (el.type === "file") rendered += `[文件${el.name ?? ""}]`;
|
|
176
|
-
else if (el.type === "forward") rendered += "[合并转发]";
|
|
177
|
-
else if (el.type === "json") rendered += "[json]";
|
|
178
|
-
else if (el.type === "xml") rendered += "[xml]";
|
|
179
|
-
if (rendered === "") rendered = "[空消息/媒体]";
|
|
180
|
-
const isGroup = msg.chatType === kernel.ChatType.GROUP;
|
|
181
|
-
const kind = isGroup ? "群聊" : "私聊";
|
|
182
|
-
const peerUin = String(msg.peerUin ?? "");
|
|
183
|
-
const senderUin = String(msg.senderUin ?? (peerUin || "未知"));
|
|
184
|
-
const plainTags = isGroup ? `[群${peerUin}] [用户${senderUin}]` : `[用户${senderUin}]`;
|
|
185
|
-
const coloredTags = isGroup ? `${ANSI.cyan}[群${peerUin}]${ANSI.reset} ${ANSI.green}[用户${senderUin}]${ANSI.reset}` : `${ANSI.green}[用户${senderUin}]${ANSI.reset}`;
|
|
186
|
-
const content = rendered.replace(/\n/g, "\n ");
|
|
187
|
-
const plain = `接收 <- ${kind} ${plainTags}: ${content}`;
|
|
188
|
-
const colored = `${ANSI.gray}loader | 接收 <- ${kind}${ANSI.reset} ${coloredTags}: ${content}`;
|
|
189
|
-
log(plain);
|
|
190
|
-
logger?.info(colored);
|
|
191
|
-
} catch (e) {
|
|
192
|
-
const line2 = `接收消息(解析失败: ${errMsg(e)})`;
|
|
193
|
-
log(line2);
|
|
194
|
-
logger?.warn(line2);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
});
|
|
374
|
+
setupMsgLogging(kernel, channel, logger);
|
|
198
375
|
const groupApi = new kernel.GroupApi(session);
|
|
199
376
|
const msgApi = new kernel.MsgApi(session);
|
|
200
377
|
const friendApi = new kernel.FriendApi(session, { uidToUin: (uids) => groupApi.uidToUin(uids) });
|
|
@@ -212,15 +389,7 @@ async function startProtocols(kernel, ctx, loginResult) {
|
|
|
212
389
|
const profileLikeApi = new kernel.ProfileLikeApi(session);
|
|
213
390
|
const webApi = new kernel.WebApi({ getCookies: (domain) => ticketApi.getCookies(domain, loginResult.uin) });
|
|
214
391
|
const broadcaster = new network.EventBroadcaster();
|
|
215
|
-
|
|
216
|
-
const cfgFile = env.NAPKETTO_CONFIG || (0, node_path.join)(env.NAPUTO_CFG_DIR || ".", "napuketto.toml");
|
|
217
|
-
try {
|
|
218
|
-
const raw = (0, node_fs.readFileSync)(cfgFile, "utf8");
|
|
219
|
-
const parsed = kernel.parseToml(raw);
|
|
220
|
-
if (parsed && typeof parsed["onebot11"] === "object" && parsed["onebot11"] !== null) ob11Section = parsed["onebot11"];
|
|
221
|
-
} catch (e) {
|
|
222
|
-
log(`bootstrap: 全局配置读取失败(用默认 ob11 配置): ${errMsg(e)}`);
|
|
223
|
-
}
|
|
392
|
+
const { cfgFile, ob11Section, satoriSection } = loadProtocolSections(kernel);
|
|
224
393
|
const ob11Config = new adapterCore.ProtocolConfig({
|
|
225
394
|
path: cfgFile,
|
|
226
395
|
schema: adapter.ob11ConfigSchema,
|
|
@@ -265,6 +434,30 @@ async function startProtocols(kernel, ctx, loginResult) {
|
|
|
265
434
|
groupCache
|
|
266
435
|
}).start();
|
|
267
436
|
log("bootstrap: onebot11 adapter started");
|
|
437
|
+
const satoriCacheDir = typeof satoriSection["cacheDir"] === "string" && satoriSection["cacheDir"] !== "" ? satoriSection["cacheDir"] : (0, node_path.join)(env.NAPUTO_CFG_DIR || ".", "cache");
|
|
438
|
+
const satoriConfig = new adapterCore.ProtocolConfig({
|
|
439
|
+
path: cfgFile,
|
|
440
|
+
schema: satoriAdapter.satoriConfigSchema,
|
|
441
|
+
defaults: satoriAdapter.satoriConfigSchema.parse({}),
|
|
442
|
+
seed: satoriAdapter.satoriConfigSchema.parse(satoriSection)
|
|
443
|
+
});
|
|
444
|
+
await new satoriAdapter.NapukettoSatoriAdapter({
|
|
445
|
+
config: satoriConfig,
|
|
446
|
+
broadcaster,
|
|
447
|
+
msgChannel: channel,
|
|
448
|
+
msgApi,
|
|
449
|
+
groupApi,
|
|
450
|
+
groupNotifyApi,
|
|
451
|
+
friendApi,
|
|
452
|
+
profileApi,
|
|
453
|
+
self: {
|
|
454
|
+
uin: loginResult.uin,
|
|
455
|
+
nickname: loginResult.nick
|
|
456
|
+
},
|
|
457
|
+
cacheDir: satoriCacheDir,
|
|
458
|
+
groupCache
|
|
459
|
+
}).start();
|
|
460
|
+
log("bootstrap: satori adapter started");
|
|
268
461
|
} catch (e) {
|
|
269
462
|
log(`bootstrap: 协议装配失败: ${errMsg(e)}`);
|
|
270
463
|
}
|
|
@@ -434,6 +627,10 @@ function parseSmokeTarget(envPeer, selfUin) {
|
|
|
434
627
|
uin: selfUin
|
|
435
628
|
};
|
|
436
629
|
}
|
|
630
|
+
/** 提取消息纯文本(text 元素拼接,onRecvMsg 与落库核对共用)。 */
|
|
631
|
+
function extractTexts(kernel, msg) {
|
|
632
|
+
return kernel.toCanonicalElements(msg).filter((el) => el.type === "text").map((el) => el.text).join("");
|
|
633
|
+
}
|
|
437
634
|
/** uin → uid(c2c 目标用;group 直接拿群号作 peerUid)。 */
|
|
438
635
|
async function resolvePeerUid(kernel, ctx, target, selfUid) {
|
|
439
636
|
const groupApi = new kernel.GroupApi(ctx.session);
|
|
@@ -471,18 +668,16 @@ async function runSmokeTest(kernel, ctx, loginResult) {
|
|
|
471
668
|
let received = 0;
|
|
472
669
|
let receivedText = "";
|
|
473
670
|
channel.on("Msg/onRecvMsg", (msgs) => {
|
|
474
|
-
|
|
475
|
-
for (const msg of list) {
|
|
476
|
-
if (!msg || typeof msg !== "object") continue;
|
|
671
|
+
forEachRawMessage(msgs, (m) => {
|
|
477
672
|
received += 1;
|
|
478
673
|
try {
|
|
479
|
-
const texts = kernel
|
|
674
|
+
const texts = extractTexts(kernel, m);
|
|
480
675
|
if (texts) receivedText = texts;
|
|
481
676
|
} catch (e) {
|
|
482
677
|
log(`smoke: onRecvMsg 解析失败: ${errMsg(e)}`);
|
|
483
678
|
}
|
|
484
|
-
log(`smoke: 📥 onRecvMsg 收到 #${received}(msgId=${
|
|
485
|
-
}
|
|
679
|
+
log(`smoke: 📥 onRecvMsg 收到 #${received}(msgId=${m.msgId ?? "?"} seq=${m.msgSeq ?? "?"} text="${receivedText}")`);
|
|
680
|
+
});
|
|
486
681
|
});
|
|
487
682
|
const msgApi = new kernel.MsgApi(session);
|
|
488
683
|
const peer = await resolvePeerUid(kernel, ctx, target, loginResult.uid);
|
|
@@ -504,10 +699,7 @@ async function runSmokeTest(kernel, ctx, loginResult) {
|
|
|
504
699
|
const recent = await msgApi.fetchMessages(peer, { count: 10 });
|
|
505
700
|
landed = recent.some((m) => m.msgId === sentMsgId || m.msgSeq === sentMsgId);
|
|
506
701
|
log(`smoke: 落库验证: 最近 ${recent.length} 条${landed ? " ✅ 含刚发消息" : " ❌ 未找到(可能同步延迟)"}`);
|
|
507
|
-
if (!landed) for (const m of recent.slice(0, 3)) {
|
|
508
|
-
const texts = kernel.toCanonicalElements(m).filter((el) => el.type === "text").map((el) => el.text).join("");
|
|
509
|
-
log(`smoke: 最近消息 msgId=${m.msgId} seq=${m.msgSeq} text="${texts}"`);
|
|
510
|
-
}
|
|
702
|
+
if (!landed) for (const m of recent.slice(0, 3)) log(`smoke: 最近消息 msgId=${m.msgId} seq=${m.msgSeq} text="${extractTexts(kernel, m)}"`);
|
|
511
703
|
} catch (e) {
|
|
512
704
|
log(`smoke: 落库验证失败: ${errMsg(e)}`);
|
|
513
705
|
}
|
|
@@ -522,6 +714,146 @@ async function runSmokeTest(kernel, ctx, loginResult) {
|
|
|
522
714
|
return ok;
|
|
523
715
|
}
|
|
524
716
|
//#endregion
|
|
717
|
+
//#region src/host/bootstrap-core.ts
|
|
718
|
+
/**
|
|
719
|
+
* bootstrap-core.ts:NapukettoCore 装配路径(从 bootstrap.ts 拆分,2026-08-08 FTA 优化)。
|
|
720
|
+
*
|
|
721
|
+
* NapukettoCore.create → attachWrapper → 登录(pickLoginAccount + doLogin)→
|
|
722
|
+
* session 替换/激活 → 就绪等待 → 冒烟自检 → 协议装配 → 探测模式。
|
|
723
|
+
*/
|
|
724
|
+
/** 探测:create() 与捕获的 QQ session 关系(确认 create() 是否干扰 QQ)。 */
|
|
725
|
+
function probeCreatedSession(state, ctx) {
|
|
726
|
+
try {
|
|
727
|
+
const S2 = state.wrapperExports?.["NodeIQQNTWrapperSession"];
|
|
728
|
+
const created = S2 && typeof S2.create === "function" ? S2.create() : null;
|
|
729
|
+
log(`BOOT: create()===qqSession? ${created === state.qqSession} | create()===ctx.session? ${created === ctx.session} | qqSession===ctx.session? ${state.qqSession === ctx.session}`);
|
|
730
|
+
const sess = created;
|
|
731
|
+
const svc = sess && typeof sess.getMsgService === "function" ? sess.getMsgService() : null;
|
|
732
|
+
const qs = state.qqSession;
|
|
733
|
+
log(`BOOT: create().getMsgService=${svc !== null && svc !== void 0 ? "ready" : "null"} qqSession.getMsgService=${qs && typeof qs.getMsgService === "function" && qs.getMsgService() !== null && qs.getMsgService() !== void 0 ? "ready" : "null"}`);
|
|
734
|
+
} catch (e) {
|
|
735
|
+
log(`BOOT: create() 探测失败: ${errMsg(e)}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/** 登录参数(NAPUTO_QUICK_UIN 强制指定 / ref 目标 / 默认)。 */
|
|
739
|
+
function buildLoginOpts(Appid, forcedUin, ref) {
|
|
740
|
+
const quickUin = forcedUin ?? ref.targetUin;
|
|
741
|
+
return {
|
|
742
|
+
appid: Appid,
|
|
743
|
+
initTimeoutMs: 2e4,
|
|
744
|
+
...quickUin !== void 0 ? { quickUin } : {}
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
/** V2 登录后替换 session:优先 QQ 主 session,其次 vehicle 单例表;自建宿主例外。 */
|
|
748
|
+
function replaceSession(kernel, core, state, ctx) {
|
|
749
|
+
if (env.NAPUTO_SELF_HOST === "1") return null;
|
|
750
|
+
const chosen = pickBestSession(collectCandidateSessions(state, kernel, ctx));
|
|
751
|
+
if (chosen && chosen.s !== ctx.session) {
|
|
752
|
+
core.setSession(chosen.s);
|
|
753
|
+
log(`bootstrap: 已替换 session(来源=${chosen.tag},msgSvc=${isSessionUsable(chosen.s) ? "READY" : "null"})`);
|
|
754
|
+
} else if (chosen) log(`bootstrap: 候选 session 与 ctx.session 相同(${chosen.tag})`);
|
|
755
|
+
else log("bootstrap: 无有效候选 session(保留 kernel 自建 session)");
|
|
756
|
+
return chosen;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* 激活目标 session(先 init 后 startupSession.start,HANDOVER-V6 三要素)。
|
|
760
|
+
* 自建宿主无候选时对 kernel 自建 session 走 NapCat 激活。
|
|
761
|
+
*/
|
|
762
|
+
async function activateSession(kernel, state, ctx, chosen, loginResult, Appid, bootEnv) {
|
|
763
|
+
const activateTarget = chosen ?? {
|
|
764
|
+
s: ctx.session,
|
|
765
|
+
tag: "kernel 自建 session"
|
|
766
|
+
};
|
|
767
|
+
if (!activateTarget.s || isSessionUsable(activateTarget.s)) return;
|
|
768
|
+
log(`bootstrap: 激活 session(${activateTarget.tag},先 init 后 startupSession.start)...`);
|
|
769
|
+
try {
|
|
770
|
+
const qqDataRoot = typeof kernel.resolveQqUserDataRoot === "function" ? kernel.resolveQqUserDataRoot(state.wrapperExports) : null;
|
|
771
|
+
const accountPath = qqDataRoot && typeof kernel.resolveQqGlobalPath === "function" ? kernel.resolveQqGlobalPath(qqDataRoot) : bootEnv.dataDir || ".";
|
|
772
|
+
const sessionConfig = kernel.buildSessionConfig({
|
|
773
|
+
appid: Appid,
|
|
774
|
+
fullVersion: bootEnv.qqVersion || "",
|
|
775
|
+
selfUin: loginResult.uin,
|
|
776
|
+
selfUid: loginResult.uid,
|
|
777
|
+
accountPath,
|
|
778
|
+
downloadPath: (0, node_path.join)(accountPath, "NapCat", "temp")
|
|
779
|
+
});
|
|
780
|
+
const listener = kernel.createLifecycleSessionListener();
|
|
781
|
+
await kernel.initAndStartSession(ctx, sessionConfig, listener, { timeoutMs: 2e4 });
|
|
782
|
+
log("bootstrap: 激活 session init + start 完成");
|
|
783
|
+
} catch (initErr) {
|
|
784
|
+
log(`bootstrap: 激活 session init 失败: ${errMsg(initErr)}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/** 等 session 就绪(getMsgService 非 null)——init 完成后才有。 */
|
|
788
|
+
async function waitSessionReady(kernel, ctx) {
|
|
789
|
+
try {
|
|
790
|
+
await kernel.waitSessionReady(ctx, { timeoutMs: 3e4 });
|
|
791
|
+
log("bootstrap: QQ session 就绪(getMsgService 可用)");
|
|
792
|
+
} catch (readyErr) {
|
|
793
|
+
log(`bootstrap: 等待 session 就绪失败: ${errMsg(readyErr)}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/** P2-1 收发消息冒烟自检(NAPUTO_SMOKE=1):MsgBridge + MsgApi 真发/收一条。 */
|
|
797
|
+
async function runSmokeIfEnabled(kernel, ctx, loginResult) {
|
|
798
|
+
if (env.NAPUTO_SMOKE !== "1") return;
|
|
799
|
+
try {
|
|
800
|
+
log(`bootstrap: 冒烟自检${await runSmokeTest(kernel, ctx, loginResult) ? "通过" : "未完全通过"}(见上文 smoke 日志)`);
|
|
801
|
+
} catch (smokeErr) {
|
|
802
|
+
log(`bootstrap: 冒烟自检异常: ${errMsg(smokeErr)}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/** 探测模式(NAPUTO_PROBE=1):立即探测 + 35s 后晚探测(napuketto-probe-late.json)。 */
|
|
806
|
+
function runProbePhase(kernel, ctx) {
|
|
807
|
+
if (env.NAPUTO_PROBE !== "1" || typeof kernel.probeRuntime !== "function") return;
|
|
808
|
+
try {
|
|
809
|
+
const probe = kernel.probeRuntime(ctx);
|
|
810
|
+
log(`bootstrap: probe done, session=${probe.session ? "ok" : "null"}, services=${Object.keys(probe.services ?? {}).length}`);
|
|
811
|
+
setTimeout(() => {
|
|
812
|
+
try {
|
|
813
|
+
if (typeof kernel.probeRuntime !== "function") return;
|
|
814
|
+
const late = kernel.probeRuntime(ctx, "napuketto-probe-late.json");
|
|
815
|
+
log(`bootstrap: probe-late done, session=${late.session ? "ok" : "null"}, services=${Object.keys(late.services ?? {}).length}`);
|
|
816
|
+
} catch (e2) {
|
|
817
|
+
log(`bootstrap: probe-late error: ${errMsg(e2)}`);
|
|
818
|
+
}
|
|
819
|
+
}, 35e3);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
log(`bootstrap: probe error: ${errMsg(e)}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** NapukettoCore 装配路径:create → attachWrapper → 登录 → 激活 → 协议 → 探测。 */
|
|
825
|
+
async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
|
|
826
|
+
const coreCtor = kernel.NapukettoCore;
|
|
827
|
+
if (coreCtor === void 0) return;
|
|
828
|
+
const core = coreCtor.create({
|
|
829
|
+
paths: { dataRoot: bootEnv.dataDir },
|
|
830
|
+
logLevel: "info"
|
|
831
|
+
});
|
|
832
|
+
const ctx = core.attachWrapper(state.wrapperExports, { ...bootEnv });
|
|
833
|
+
log(`bootstrap: attachWrapper OK, engine=${typeof ctx.engine}, session=${ctx.session !== null}`);
|
|
834
|
+
probeCreatedSession(state, ctx);
|
|
835
|
+
startSessionProbe(state, ctx);
|
|
836
|
+
probeSessionMethods(ctx);
|
|
837
|
+
if (typeof core.login !== "function") {
|
|
838
|
+
log("bootstrap: kernel core missing login fn");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const forcedUin = env.NAPUTO_QUICK_UIN;
|
|
842
|
+
const ref = { targetUin: void 0 };
|
|
843
|
+
await pickLoginAccount(kernel, ctx, ref, forcedUin);
|
|
844
|
+
const loginResult = await doLogin(core, buildLoginOpts(Appid, forcedUin, ref));
|
|
845
|
+
if (loginResult === null) {
|
|
846
|
+
log("bootstrap: 登录失败,引导中止");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
log(`bootstrap: 登录成功 uin=${loginResult.uin} uid=${loginResult.uid} nick=${loginResult.nick}`);
|
|
850
|
+
await activateSession(kernel, state, ctx, replaceSession(kernel, core, state, ctx), loginResult, Appid, bootEnv);
|
|
851
|
+
await waitSessionReady(kernel, ctx);
|
|
852
|
+
await runSmokeIfEnabled(kernel, ctx, loginResult);
|
|
853
|
+
await startProtocols(kernel, ctx, loginResult);
|
|
854
|
+
runProbePhase(kernel, ctx);
|
|
855
|
+
}
|
|
856
|
+
//#endregion
|
|
525
857
|
//#region src/host/bootstrap.ts
|
|
526
858
|
/**
|
|
527
859
|
* bootstrap.ts:kernel 引导核心(import kernel → 装配 → 登录 → session 替换 → 协议装配)。
|
|
@@ -532,6 +864,7 @@ async function runSmokeTest(kernel, ctx, loginResult) {
|
|
|
532
864
|
* - 登录流程(选账号 / 快速登录 / QR 回退)→ login.ts
|
|
533
865
|
* - session 选择 / 探测 / 就绪 → session.ts
|
|
534
866
|
* - 协议装配 → protocols.ts;冒烟自检 → smoke.ts
|
|
867
|
+
* 2026-08-08 FTA 优化:NapukettoCore 装配路径 → bootstrap-core.ts(本文件留入口 + fallback)。
|
|
535
868
|
*
|
|
536
869
|
* session 来源(2026-08-07 实测结论;V1 Proxy 捕获与 vehicle 载具已归档 archive/):
|
|
537
870
|
* 登录成功后按优先级取 session:
|
|
@@ -540,6 +873,52 @@ async function runSmokeTest(kernel, ctx, loginResult) {
|
|
|
540
873
|
* ② get() / qqSession(V1 兼容)
|
|
541
874
|
* 取到后 core.setSession 替换 → waitSessionReady 确认 getMsgService READY。
|
|
542
875
|
*/
|
|
876
|
+
/** ⭐ appid 动态解析(2026-08-06 P2-0 实测:硬编码 537237765 在 9.9.33 扫码失败
|
|
877
|
+
* 「请下载最新版」;major.node 解析的 537376818 成功)。自研,参考 NapCat 思路。 */
|
|
878
|
+
function resolveAppid(kernel, bootEnv) {
|
|
879
|
+
return typeof kernel.parseAppidFromMajor === "function" && bootEnv.wrapperPath && kernel.parseAppidFromMajor((0, node_path.join)((0, node_path.dirname)(bootEnv.wrapperPath), "major.node")) || typeof kernel.resolveAppidQua === "function" && kernel.resolveAppidQua(bootEnv.qqVersion || "").appid || "537237765";
|
|
880
|
+
}
|
|
881
|
+
/** 回退:旧装配路径(startNapuketto + 手工 lifecycle)。 */
|
|
882
|
+
async function bootstrapFallback(kernel, state, bootEnv, Appid) {
|
|
883
|
+
log("bootstrap: kernel has no NapukettoCore, falling back");
|
|
884
|
+
const ctx = kernel.startNapuketto?.({
|
|
885
|
+
wrapperExports: state.wrapperExports,
|
|
886
|
+
env: { ...bootEnv }
|
|
887
|
+
});
|
|
888
|
+
if (ctx === void 0) {
|
|
889
|
+
log("bootstrap: startNapuketto 不可用");
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
log(`bootstrap: startNapuketto OK, engine=${typeof ctx.engine}, session=${ctx.session !== null}`);
|
|
893
|
+
if (typeof kernel.quickLogin === "function" && typeof kernel.initAndStartSession === "function") {
|
|
894
|
+
if (typeof kernel.buildLoginConfig === "function" && ctx.loginService) {
|
|
895
|
+
const loginCfg = kernel.buildLoginConfig(Appid, bootEnv.qqVersion || "", bootEnv.dataDir || ".");
|
|
896
|
+
if (typeof ctx.loginService.initConfig === "function") {
|
|
897
|
+
ctx.loginService.initConfig(loginCfg);
|
|
898
|
+
log("bootstrap: loginService.initConfig OK");
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const loginResult = await kernel.quickLogin(ctx, {});
|
|
902
|
+
log(`bootstrap: quickLogin OK, uin=${loginResult.uin}, uid=${loginResult.uid}`);
|
|
903
|
+
const sessionConfig = kernel.buildSessionConfig({
|
|
904
|
+
appid: Appid,
|
|
905
|
+
fullVersion: bootEnv.qqVersion || "",
|
|
906
|
+
selfUin: loginResult.uin,
|
|
907
|
+
selfUid: loginResult.uid,
|
|
908
|
+
accountPath: bootEnv.dataDir || ".",
|
|
909
|
+
downloadPath: (0, node_path.join)(bootEnv.dataDir || ".", "temp")
|
|
910
|
+
});
|
|
911
|
+
const listener = kernel.createLifecycleSessionListener();
|
|
912
|
+
await kernel.initAndStartSession(ctx, sessionConfig, listener, { timeoutMs: 2e4 });
|
|
913
|
+
log("bootstrap: session init + startNT OK!");
|
|
914
|
+
} else log("bootstrap: kernel missing lifecycle fns (quickLogin/initAndStartSession)");
|
|
915
|
+
if (env.NAPUTO_PROBE === "1" && typeof kernel.probeRuntime === "function") try {
|
|
916
|
+
const probe = kernel.probeRuntime(ctx);
|
|
917
|
+
log(`bootstrap: probe done, session=${probe.session ? "ok" : "null"}, services=${Object.keys(probe.services ?? {}).length}`);
|
|
918
|
+
} catch (e) {
|
|
919
|
+
log(`bootstrap: probe error: ${errMsg(e)}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
543
922
|
/** 核心引导:import kernel → 装配 → 登录 → 替换 session → 等待就绪 → 协议装配。 */
|
|
544
923
|
async function bootstrap(state) {
|
|
545
924
|
const kernelEntry = env.NAPUTO_KERNEL_ENTRY;
|
|
@@ -555,148 +934,15 @@ async function bootstrap(state) {
|
|
|
555
934
|
return;
|
|
556
935
|
}
|
|
557
936
|
const bootEnv = {
|
|
558
|
-
qqVersion: env.NAPUTO_QQ_VERSION,
|
|
559
|
-
dataDir: env.NAPUTO_CFG_DIR,
|
|
560
|
-
wrapperPath: env.NAPUTO_WRAPPER_PATH
|
|
937
|
+
qqVersion: env.NAPUTO_QQ_VERSION || "",
|
|
938
|
+
dataDir: env.NAPUTO_CFG_DIR || "",
|
|
939
|
+
wrapperPath: env.NAPUTO_WRAPPER_PATH || ""
|
|
561
940
|
};
|
|
562
|
-
const Appid =
|
|
941
|
+
const Appid = resolveAppid(kernel, bootEnv);
|
|
563
942
|
log(`bootstrap: appid=${Appid}(wrapper=${bootEnv.wrapperPath})`);
|
|
564
943
|
try {
|
|
565
|
-
if (kernel.NapukettoCore !== void 0)
|
|
566
|
-
|
|
567
|
-
paths: { dataRoot: bootEnv.dataDir },
|
|
568
|
-
logLevel: "info"
|
|
569
|
-
});
|
|
570
|
-
const ctx = core.attachWrapper(state.wrapperExports, bootEnv);
|
|
571
|
-
log(`bootstrap: attachWrapper OK, engine=${typeof ctx.engine}, session=${ctx.session !== null}`);
|
|
572
|
-
try {
|
|
573
|
-
const S2 = state.wrapperExports?.["NodeIQQNTWrapperSession"];
|
|
574
|
-
const created = S2 && typeof S2.create === "function" ? S2.create() : null;
|
|
575
|
-
log(`BOOT: create()===qqSession? ${created === state.qqSession} | create()===ctx.session? ${created === ctx.session} | qqSession===ctx.session? ${state.qqSession === ctx.session}`);
|
|
576
|
-
const sess = created;
|
|
577
|
-
const svc = sess && typeof sess.getMsgService === "function" ? sess.getMsgService() : null;
|
|
578
|
-
const qs = state.qqSession;
|
|
579
|
-
log(`BOOT: create().getMsgService=${svc !== null && svc !== void 0 ? "ready" : "null"} qqSession.getMsgService=${qs && typeof qs.getMsgService === "function" && qs.getMsgService() !== null && qs.getMsgService() !== void 0 ? "ready" : "null"}`);
|
|
580
|
-
} catch (e) {
|
|
581
|
-
log(`BOOT: create() 探测失败: ${errMsg(e)}`);
|
|
582
|
-
}
|
|
583
|
-
startSessionProbe(state, ctx);
|
|
584
|
-
probeSessionMethods(ctx);
|
|
585
|
-
let loginResult = null;
|
|
586
|
-
if (typeof core.login === "function") {
|
|
587
|
-
const forcedUin = env.NAPUTO_QUICK_UIN;
|
|
588
|
-
const ref = { targetUin: void 0 };
|
|
589
|
-
await pickLoginAccount(kernel, ctx, ref, forcedUin);
|
|
590
|
-
loginResult = await doLogin(core, {
|
|
591
|
-
appid: Appid,
|
|
592
|
-
initTimeoutMs: 2e4,
|
|
593
|
-
...forcedUin ? { quickUin: forcedUin } : ref.targetUin !== void 0 ? { quickUin: ref.targetUin } : {}
|
|
594
|
-
});
|
|
595
|
-
if (loginResult === null) {
|
|
596
|
-
log("bootstrap: 登录失败,引导中止");
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
log(`bootstrap: 登录成功 uin=${loginResult.uin} uid=${loginResult.uid} nick=${loginResult.nick}`);
|
|
600
|
-
const isSelfHost = env.NAPUTO_SELF_HOST === "1";
|
|
601
|
-
let chosen = null;
|
|
602
|
-
if (!isSelfHost) chosen = pickBestSession(collectCandidateSessions(state, kernel, ctx));
|
|
603
|
-
if (chosen && chosen.s !== ctx.session) {
|
|
604
|
-
core.setSession(chosen.s);
|
|
605
|
-
log(`bootstrap: 已替换 session(来源=${chosen.tag},msgSvc=${isSessionUsable(chosen.s) ? "READY" : "null"})`);
|
|
606
|
-
} else if (chosen) log(`bootstrap: 候选 session 与 ctx.session 相同(${chosen.tag})`);
|
|
607
|
-
else log("bootstrap: 无有效候选 session(保留 kernel 自建 session)");
|
|
608
|
-
const activateTarget = chosen ?? {
|
|
609
|
-
s: ctx.session,
|
|
610
|
-
tag: "kernel 自建 session"
|
|
611
|
-
};
|
|
612
|
-
if (activateTarget.s && !isSessionUsable(activateTarget.s)) {
|
|
613
|
-
log(`bootstrap: 激活 session(${activateTarget.tag},先 init 后 startupSession.start)...`);
|
|
614
|
-
try {
|
|
615
|
-
const qqDataRoot = typeof kernel.resolveQqUserDataRoot === "function" ? kernel.resolveQqUserDataRoot(state.wrapperExports) : null;
|
|
616
|
-
const accountPath = qqDataRoot && typeof kernel.resolveQqGlobalPath === "function" ? kernel.resolveQqGlobalPath(qqDataRoot) : bootEnv.dataDir || ".";
|
|
617
|
-
const sessionConfig = kernel.buildSessionConfig({
|
|
618
|
-
appid: Appid,
|
|
619
|
-
fullVersion: bootEnv.qqVersion || "",
|
|
620
|
-
selfUin: loginResult.uin,
|
|
621
|
-
selfUid: loginResult.uid,
|
|
622
|
-
accountPath,
|
|
623
|
-
downloadPath: (0, node_path.join)(accountPath, "NapCat", "temp")
|
|
624
|
-
});
|
|
625
|
-
const listener = kernel.createLifecycleSessionListener();
|
|
626
|
-
await kernel.initAndStartSession(ctx, sessionConfig, listener, { timeoutMs: 2e4 });
|
|
627
|
-
log("bootstrap: 激活 session init + start 完成");
|
|
628
|
-
} catch (initErr) {
|
|
629
|
-
log(`bootstrap: 激活 session init 失败: ${errMsg(initErr)}`);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
try {
|
|
633
|
-
await kernel.waitSessionReady(ctx, { timeoutMs: 3e4 });
|
|
634
|
-
log("bootstrap: QQ session 就绪(getMsgService 可用)");
|
|
635
|
-
} catch (readyErr) {
|
|
636
|
-
log(`bootstrap: 等待 session 就绪失败: ${errMsg(readyErr)}`);
|
|
637
|
-
}
|
|
638
|
-
if (env.NAPUTO_SMOKE === "1" && loginResult !== null) try {
|
|
639
|
-
log(`bootstrap: 冒烟自检${await runSmokeTest(kernel, ctx, loginResult) ? "通过" : "未完全通过"}(见上文 smoke 日志)`);
|
|
640
|
-
} catch (smokeErr) {
|
|
641
|
-
log(`bootstrap: 冒烟自检异常: ${errMsg(smokeErr)}`);
|
|
642
|
-
}
|
|
643
|
-
} else log("bootstrap: kernel core missing login fn");
|
|
644
|
-
if (loginResult !== null) await startProtocols(kernel, ctx, loginResult);
|
|
645
|
-
if (env.NAPUTO_PROBE === "1" && typeof kernel.probeRuntime === "function") try {
|
|
646
|
-
const probe = kernel.probeRuntime(ctx);
|
|
647
|
-
log(`bootstrap: probe done, session=${probe.session ? "ok" : "null"}, services=${Object.keys(probe.services ?? {}).length}`);
|
|
648
|
-
setTimeout(() => {
|
|
649
|
-
try {
|
|
650
|
-
if (typeof kernel.probeRuntime !== "function") return;
|
|
651
|
-
const late = kernel.probeRuntime(ctx, "napuketto-probe-late.json");
|
|
652
|
-
log(`bootstrap: probe-late done, session=${late.session ? "ok" : "null"}, services=${Object.keys(late.services ?? {}).length}`);
|
|
653
|
-
} catch (e2) {
|
|
654
|
-
log(`bootstrap: probe-late error: ${errMsg(e2)}`);
|
|
655
|
-
}
|
|
656
|
-
}, 35e3);
|
|
657
|
-
} catch (e) {
|
|
658
|
-
log(`bootstrap: probe error: ${errMsg(e)}`);
|
|
659
|
-
}
|
|
660
|
-
} else {
|
|
661
|
-
log("bootstrap: kernel has no NapukettoCore, falling back");
|
|
662
|
-
const ctx = kernel.startNapuketto?.({
|
|
663
|
-
wrapperExports: state.wrapperExports,
|
|
664
|
-
env: bootEnv
|
|
665
|
-
});
|
|
666
|
-
if (ctx === void 0) {
|
|
667
|
-
log("bootstrap: startNapuketto 不可用");
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
log(`bootstrap: startNapuketto OK, engine=${typeof ctx.engine}, session=${ctx.session !== null}`);
|
|
671
|
-
if (typeof kernel.quickLogin === "function" && typeof kernel.initAndStartSession === "function") {
|
|
672
|
-
if (typeof kernel.buildLoginConfig === "function" && ctx.loginService) {
|
|
673
|
-
const loginCfg = kernel.buildLoginConfig(Appid, bootEnv.qqVersion || "", bootEnv.dataDir || ".");
|
|
674
|
-
if (typeof ctx.loginService.initConfig === "function") {
|
|
675
|
-
ctx.loginService.initConfig(loginCfg);
|
|
676
|
-
log("bootstrap: loginService.initConfig OK");
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
const loginResult = await kernel.quickLogin(ctx, {});
|
|
680
|
-
log(`bootstrap: quickLogin OK, uin=${loginResult.uin}, uid=${loginResult.uid}`);
|
|
681
|
-
const sessionConfig = kernel.buildSessionConfig({
|
|
682
|
-
appid: Appid,
|
|
683
|
-
fullVersion: bootEnv.qqVersion || "",
|
|
684
|
-
selfUin: loginResult.uin,
|
|
685
|
-
selfUid: loginResult.uid,
|
|
686
|
-
accountPath: bootEnv.dataDir || ".",
|
|
687
|
-
downloadPath: (0, node_path.join)(bootEnv.dataDir || ".", "temp")
|
|
688
|
-
});
|
|
689
|
-
const listener = kernel.createLifecycleSessionListener();
|
|
690
|
-
await kernel.initAndStartSession(ctx, sessionConfig, listener, { timeoutMs: 2e4 });
|
|
691
|
-
log("bootstrap: session init + startNT OK!");
|
|
692
|
-
} else log("bootstrap: kernel missing lifecycle fns (quickLogin/initAndStartSession)");
|
|
693
|
-
if (env.NAPUTO_PROBE === "1" && typeof kernel.probeRuntime === "function") try {
|
|
694
|
-
const probe = kernel.probeRuntime(ctx);
|
|
695
|
-
log(`bootstrap: probe done, session=${probe.session ? "ok" : "null"}, services=${Object.keys(probe.services ?? {}).length}`);
|
|
696
|
-
} catch (e) {
|
|
697
|
-
log(`bootstrap: probe error: ${errMsg(e)}`);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
944
|
+
if (kernel.NapukettoCore !== void 0) await bootstrapWithCore(kernel, state, bootEnv, Appid);
|
|
945
|
+
else await bootstrapFallback(kernel, state, bootEnv, Appid);
|
|
700
946
|
} catch (e) {
|
|
701
947
|
log(`bootstrap: lifecycle error: ${errMsg(e)}`);
|
|
702
948
|
}
|
|
@@ -732,6 +978,16 @@ async function bootstrap(state) {
|
|
|
732
978
|
const state = createState();
|
|
733
979
|
log(`[self-host] 启动 @ ${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} node=${process.version} type=${process.type ?? "(标准 node)"}`);
|
|
734
980
|
log(`[self-host] stub 目录: ${env.NAPUTO_STUB_DIR ?? "(PATH 前置,未记录)"}`);
|
|
981
|
+
const lockDataDir = env.NAPUTO_CFG_DIR;
|
|
982
|
+
if (lockDataDir) {
|
|
983
|
+
if (!acquireInstanceLock(lockDataDir)) {
|
|
984
|
+
const { pid: holderPid } = checkInstanceLock(lockDataDir);
|
|
985
|
+
log(`[self-host] ❌ 数据目录已被其他实例占用(pid=${holderPid ?? "?"}),退出;同一账号数据目录仅允许一个实例,请先停止已有实例`);
|
|
986
|
+
process.exit(1);
|
|
987
|
+
}
|
|
988
|
+
registerLockCleanup(lockDataDir);
|
|
989
|
+
log(`[self-host] ✅ 已获取数据目录锁(${lockDataDir})`);
|
|
990
|
+
} else log("[self-host] ⚠️ NAPUTO_CFG_DIR 未设置,跳过单实例锁");
|
|
735
991
|
const wrapperPath = env.NAPUTO_WRAPPER_PATH;
|
|
736
992
|
if (!wrapperPath) {
|
|
737
993
|
log("[self-host] NAPUTO_WRAPPER_PATH 未设置,退出");
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,65 @@
|
|
|
1
1
|
import { StdioOptions, spawn } from "node:child_process";
|
|
2
|
+
//#region src/instance-lock.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* instance-lock.ts:单实例锁(2026-08-07 根治「多实例抢数据目录锁挂起」)
|
|
5
|
+
*
|
|
6
|
+
* 背景(实测):同一账号数据目录(~/.napuketto/<uin>)只能被一个实例持有——
|
|
7
|
+
* QQ 原生层(MMKV / 登录单例 / 数据库)有锁,第二个实例抢不到锁会卡在
|
|
8
|
+
* loginService.initConfig 之后无响应(挂起)。此前 create-napukettoqq 生成的
|
|
9
|
+
* 副本实例常驻占锁,导致用户后续启动全部卡死。
|
|
10
|
+
*
|
|
11
|
+
* 方案:以数据目录为粒度加锁文件 `<dataDir>/instance.lock`(JSON 内容:
|
|
12
|
+
* pid / startedAt / cmdline 摘要)。启动前 checkInstanceLock:
|
|
13
|
+
* - 锁不存在 → 可启动(然后 acquireInstanceLock 写入)
|
|
14
|
+
* - 锁存在但 PID 已死 → 崩溃残留,自动清理(rm + 重新获取)
|
|
15
|
+
* - 锁存在且 PID 存活 → 已被占用,拒绝启动(返回占用 PID 供提示)
|
|
16
|
+
*
|
|
17
|
+
* 退出清理:进程正常退出时 releaseInstanceLock 删除锁文件;崩溃/强杀残留
|
|
18
|
+
* 由下次启动的「PID 存活检测」兜底(PID 已死 → 残留视为过期)。
|
|
19
|
+
*
|
|
20
|
+
* 放置说明:放 loader 而非 kernel——self-host.cjs 是 rolldown 内联 bundle
|
|
21
|
+
* (只内联 src/host/ 依赖树),静态 import kernel 会留 external 缺口;
|
|
22
|
+
* 放 loader 则 self-host(内联)与 cli(@napuketto/loader 导入)双入口可用。
|
|
23
|
+
*/
|
|
24
|
+
/** 锁文件名(置于数据目录内)。 */
|
|
25
|
+
declare const INSTANCE_LOCK_FILE = "instance.lock";
|
|
26
|
+
/** 锁文件内容。 */
|
|
27
|
+
interface InstanceLockInfo {
|
|
28
|
+
/** 持有实例的进程 PID。 */
|
|
29
|
+
pid: number;
|
|
30
|
+
/** 获取锁的时间(epoch ms)。 */
|
|
31
|
+
startedAt: number;
|
|
32
|
+
/** 命令行摘要(诊断用,可选)。 */
|
|
33
|
+
cmdline?: string;
|
|
34
|
+
}
|
|
35
|
+
/** 数据目录锁检查结果。 */
|
|
36
|
+
interface InstanceLockCheck {
|
|
37
|
+
/** 是否被其他实例占用。 */
|
|
38
|
+
occupied: boolean;
|
|
39
|
+
/** 占用进程 PID(occupied=true 时有效)。 */
|
|
40
|
+
pid: number | null;
|
|
41
|
+
/** 锁文件完整路径(提示/清理用)。 */
|
|
42
|
+
lockPath: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 检测指定数据目录是否已被其他实例占用。
|
|
46
|
+
*
|
|
47
|
+
* @param dataDir 数据目录(账号级,如 ~/.napuketto/3567141148)
|
|
48
|
+
*/
|
|
49
|
+
declare function checkInstanceLock(dataDir: string): InstanceLockCheck;
|
|
50
|
+
/**
|
|
51
|
+
* 获取数据目录锁。占用(其他实例存活)返回 false;成功或清理残留后返回 true。
|
|
52
|
+
* 幂等:本进程已持有则直接返回 true(不重复写)。
|
|
53
|
+
*/
|
|
54
|
+
declare function acquireInstanceLock(dataDir: string, cmdline?: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* 释放数据目录锁(正常退出时调用)。仅当持有者是本进程时才删除,
|
|
57
|
+
* 避免误删新持有者的锁(PID 复用 / 快速重启场景)。
|
|
58
|
+
*/
|
|
59
|
+
declare function releaseInstanceLock(dataDir: string): void;
|
|
60
|
+
/** 注册进程退出清理(SIGINT/SIGTERM/exit 均触发;崩溃强杀由残留检测兜底)。 */
|
|
61
|
+
declare function registerLockCleanup(dataDir: string): void;
|
|
62
|
+
//#endregion
|
|
2
63
|
//#region src/locate-qq.d.ts
|
|
3
64
|
/** QQ 安装目录探测结果。 */
|
|
4
65
|
interface QqInstallInfo {
|
|
@@ -95,5 +156,5 @@ declare const ENV: {
|
|
|
95
156
|
readonly QUICK_UIN: "NAPUTO_QUICK_UIN";
|
|
96
157
|
};
|
|
97
158
|
//#endregion
|
|
98
|
-
export { ENV, type LaunchOptions, type LaunchResult, type QqInstallInfo, defaultStubDir, launchSelfHost, locateQqPath, resolveQqInstall };
|
|
159
|
+
export { ENV, INSTANCE_LOCK_FILE, type InstanceLockCheck, type InstanceLockInfo, type LaunchOptions, type LaunchResult, type QqInstallInfo, acquireInstanceLock, checkInstanceLock, defaultStubDir, launchSelfHost, locateQqPath, registerLockCleanup, releaseInstanceLock, resolveQqInstall };
|
|
99
160
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/locate-qq.ts","../src/launcher.ts"],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/instance-lock.ts","../src/locate-qq.ts","../src/launcher.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;cA2Ba;;UAGI;;EAEb;;EAEA;;EAEA;;;UAIa;;EAEb;;EAEA;;EAEA;;;;;;;iBAQY,kBAAkB,kBAAkB;;;;;iBA+BpC,oBAAoB,iBAAiB;;;;;iBA4BrC,oBAAoB;;iBAapB,oBAAoB;;;;UCvEnB;;EAEb;;EAEA;;EAEA;;EAEA;;;iBAIY;;iBAaA,iBAAiB,kBAAkB;;;UC9DlC;;EAEb,IAAI;;EAEJ;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;EAQA;;;;;;EAMA,QAAQ;;UAGK;;EAEb,OAAO,kBAAkB;;EAEzB;;;;;;;;;;;iBAYY,eAAe,SAAS,gBAAgB;;;;;iBAiDxC;;cAqCH;WACT;WACA;WACA;WACA;WACA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,112 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { dirname, join, resolve } from "node:path";
|
|
4
3
|
import process from "node:process";
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
//#region src/instance-lock.ts
|
|
7
|
+
/**
|
|
8
|
+
* instance-lock.ts:单实例锁(2026-08-07 根治「多实例抢数据目录锁挂起」)
|
|
9
|
+
*
|
|
10
|
+
* 背景(实测):同一账号数据目录(~/.napuketto/<uin>)只能被一个实例持有——
|
|
11
|
+
* QQ 原生层(MMKV / 登录单例 / 数据库)有锁,第二个实例抢不到锁会卡在
|
|
12
|
+
* loginService.initConfig 之后无响应(挂起)。此前 create-napukettoqq 生成的
|
|
13
|
+
* 副本实例常驻占锁,导致用户后续启动全部卡死。
|
|
14
|
+
*
|
|
15
|
+
* 方案:以数据目录为粒度加锁文件 `<dataDir>/instance.lock`(JSON 内容:
|
|
16
|
+
* pid / startedAt / cmdline 摘要)。启动前 checkInstanceLock:
|
|
17
|
+
* - 锁不存在 → 可启动(然后 acquireInstanceLock 写入)
|
|
18
|
+
* - 锁存在但 PID 已死 → 崩溃残留,自动清理(rm + 重新获取)
|
|
19
|
+
* - 锁存在且 PID 存活 → 已被占用,拒绝启动(返回占用 PID 供提示)
|
|
20
|
+
*
|
|
21
|
+
* 退出清理:进程正常退出时 releaseInstanceLock 删除锁文件;崩溃/强杀残留
|
|
22
|
+
* 由下次启动的「PID 存活检测」兜底(PID 已死 → 残留视为过期)。
|
|
23
|
+
*
|
|
24
|
+
* 放置说明:放 loader 而非 kernel——self-host.cjs 是 rolldown 内联 bundle
|
|
25
|
+
* (只内联 src/host/ 依赖树),静态 import kernel 会留 external 缺口;
|
|
26
|
+
* 放 loader 则 self-host(内联)与 cli(@napuketto/loader 导入)双入口可用。
|
|
27
|
+
*/
|
|
28
|
+
/** 锁文件名(置于数据目录内)。 */
|
|
29
|
+
const INSTANCE_LOCK_FILE = "instance.lock";
|
|
30
|
+
/**
|
|
31
|
+
* 检测指定数据目录是否已被其他实例占用。
|
|
32
|
+
*
|
|
33
|
+
* @param dataDir 数据目录(账号级,如 ~/.napuketto/3567141148)
|
|
34
|
+
*/
|
|
35
|
+
function checkInstanceLock(dataDir) {
|
|
36
|
+
const lockPath = join(dataDir, INSTANCE_LOCK_FILE);
|
|
37
|
+
let info = null;
|
|
38
|
+
try {
|
|
39
|
+
info = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return {
|
|
42
|
+
occupied: false,
|
|
43
|
+
pid: null,
|
|
44
|
+
lockPath
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (typeof info.pid !== "number" || Number.isNaN(info.pid) || info.pid <= 0) return {
|
|
48
|
+
occupied: false,
|
|
49
|
+
pid: null,
|
|
50
|
+
lockPath
|
|
51
|
+
};
|
|
52
|
+
let alive = false;
|
|
53
|
+
try {
|
|
54
|
+
process.kill(info.pid, 0);
|
|
55
|
+
alive = true;
|
|
56
|
+
} catch {
|
|
57
|
+
alive = false;
|
|
58
|
+
}
|
|
59
|
+
if (!alive) return {
|
|
60
|
+
occupied: false,
|
|
61
|
+
pid: null,
|
|
62
|
+
lockPath
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
occupied: true,
|
|
66
|
+
pid: info.pid,
|
|
67
|
+
lockPath
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 获取数据目录锁。占用(其他实例存活)返回 false;成功或清理残留后返回 true。
|
|
72
|
+
* 幂等:本进程已持有则直接返回 true(不重复写)。
|
|
73
|
+
*/
|
|
74
|
+
function acquireInstanceLock(dataDir, cmdline) {
|
|
75
|
+
const { occupied, pid, lockPath } = checkInstanceLock(dataDir);
|
|
76
|
+
if (occupied) {
|
|
77
|
+
if (pid === process.pid) return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const info = {
|
|
81
|
+
pid: process.pid,
|
|
82
|
+
startedAt: Date.now(),
|
|
83
|
+
...cmdline !== void 0 && cmdline !== "" ? { cmdline } : {}
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
rmSync(lockPath, { force: true });
|
|
87
|
+
writeFileSync(lockPath, JSON.stringify(info), "utf-8");
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 释放数据目录锁(正常退出时调用)。仅当持有者是本进程时才删除,
|
|
95
|
+
* 避免误删新持有者的锁(PID 复用 / 快速重启场景)。
|
|
96
|
+
*/
|
|
97
|
+
function releaseInstanceLock(dataDir) {
|
|
98
|
+
const lockPath = join(dataDir, INSTANCE_LOCK_FILE);
|
|
99
|
+
try {
|
|
100
|
+
if (JSON.parse(readFileSync(lockPath, "utf-8")).pid === process.pid) rmSync(lockPath, { force: true });
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
/** 注册进程退出清理(SIGINT/SIGTERM/exit 均触发;崩溃强杀由残留检测兜底)。 */
|
|
104
|
+
function registerLockCleanup(dataDir) {
|
|
105
|
+
process.on("exit", () => {
|
|
106
|
+
releaseInstanceLock(dataDir);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
6
110
|
//#region src/launcher.ts
|
|
7
111
|
/**
|
|
8
112
|
* launcher:自建宿主引导(2026-08-07 唯一路线)——spawn 标准 node 跑 self-host.cjs。
|
|
@@ -154,6 +258,6 @@ function resolveQqInstall(qqPath) {
|
|
|
154
258
|
};
|
|
155
259
|
}
|
|
156
260
|
//#endregion
|
|
157
|
-
export { ENV, defaultStubDir, launchSelfHost, locateQqPath, resolveQqInstall };
|
|
261
|
+
export { ENV, INSTANCE_LOCK_FILE, acquireInstanceLock, checkInstanceLock, defaultStubDir, launchSelfHost, locateQqPath, registerLockCleanup, releaseInstanceLock, resolveQqInstall };
|
|
158
262
|
|
|
159
263
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/launcher.ts","../src/locate-qq.ts"],"sourcesContent":["/**\n * launcher:自建宿主引导(2026-08-07 唯一路线)——spawn 标准 node 跑 self-host.cjs。\n *\n * 历史(V1/路线 B 事实链,已归档 archive/):\n * 1. wrapper.node 只能在 QQ 定制版 Electron 里注册(实测纯 Node/普通 Electron 均 self-register 失败)\n * 2. QQ 是打包应用,禁 NODE_OPTIONS(实测 stderr)\n * → 旧方案:NapukettoBootMain.exe 启动 QQ + 注入 NapukettoWinBootHook.dll(已废弃)\n * → 现方案:stub QQNT.dll 转发宿主符号 → 标准 node 直接 dlopen wrapper.node(自建宿主)\n */\nimport { type StdioOptions, spawn } from \"node:child_process\";\nimport { existsSync, mkdirSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport process from \"node:process\";\nimport { fileURLToPath } from \"node:url\";\nimport type { QqInstallInfo } from \"./locate-qq.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\nexport interface LaunchOptions {\n /** QQ 安装信息(qqPath/version/wrapperPath)。 */\n qq: QqInstallInfo;\n /** kernel 入口(.mjs,self-host.cjs 里 import)。 */\n kernelEntry: string;\n /** 配置目录(账号数据目录,NAPUTO_CFG_DIR)。 */\n cfgDir: string;\n /** 全局配置文件路径(项目根 napuketto.toml,注入 NAPKETTO_CONFIG 供装配链读取)。 */\n configPath?: string;\n /** adapter 入口(.mjs,协议装配用)。 */\n adapterEntry?: string;\n /** network 入口(.mjs,协议装配用)。 */\n networkEntry?: string;\n /** 自建宿主:标准 node + stub QQNT.dll 直接引导,不拉起 QQ(2026-08-07 唯一路线)。 */\n selfHost?: boolean;\n /** stub QQNT.dll 目录(PATH 前置,转发 napi_*、uv_* 符号到 node.exe)。 */\n stubDir?: string;\n /** 强制指定快速登录账号(注入 NAPUTO_QUICK_UIN;cli `-q <uin>` 透传,2026-08-07)。 */\n quickUin?: string;\n /** 自建宿主入口(默认 dist/host/self-host.cjs)。 */\n selfHostEntry?: string;\n /**\n * 子进程工作目录(缺省继承父进程 cwd)。\n *\n * ⚠️ 必须指向数据根(~/.napuketto):QQ 原生层在账号上下文就绪前会\n * fallback 到进程 cwd 落盘(如频道库 guild1.db),不指定则污染调用方\n * 目录(实测写入项目根,guild1.db 08-07)。cli 传 dataRoot 根治。\n */\n cwd?: string;\n /**\n * 子进程 stdio(默认 \"inherit\")。cli 传 [\"inherit\",\"pipe\",\"pipe\"] 并接管\n * 子进程 stdout/stderr:逐行过滤 wrapper 原生噪音(MMKV 刷屏、Node 符号\n * 查找失败警告),其余转发——原生 printf 直写 fd 的字节无法从 JS 层拦截。\n */\n stdio?: StdioOptions;\n}\n\nexport interface LaunchResult {\n /** 子进程句柄(自建宿主 node 进程)。 */\n child: ReturnType<typeof spawn>;\n /** 自建宿主入口路径(self-host.cjs)。 */\n bootJsPath: string;\n}\n\n/**\n * 启动自建宿主(路线 A,2026-08-07 产品化):spawn 标准 node 直接跑 self-host.cjs,\n * 不拉起 QQ / 不注入。\n *\n * 关键(HANDOVER-V7 技术数据):wrapper.node 从 QQNT.dll 导入 99 符号\n * (napi_*×40 + uv_*×56 + qq_magic×1 + v8/node mangled×2)——标准 node 无这些宿主\n * 符号,必须 PATH 前置 stub QQNT.dll 目录(转发到 node.exe)+ QQ resources\\app\n * (wrapper.node 同目录依赖 DLL)才能 dlopen 成功。\n */\nexport function launchSelfHost(options: LaunchOptions): LaunchResult {\n const selfHostPath =\n options.selfHostEntry ?? join(__dirname, \"..\", \"dist\", \"host\", \"self-host.cjs\");\n if (!existsSync(selfHostPath)) {\n throw new Error(\n `self-host.cjs 缺失: ${selfHostPath}(先运行 pnpm --filter @napuketto/loader build)`,\n );\n }\n\n // stub QQNT.dll 校验(自建宿主必需:PATH 前置 stub 转发 napi_* 到 node.exe,\n // 否则 wrapper.node dlopen 失败)。默认 loader 包内闭源 submodule native/stub-test-env,\n // 缺失时提示 --stub-dir / NAPUTO_STUB_DIR 指定。\n const stub = options.stubDir ?? defaultStubDir();\n if (!existsSync(join(stub, \"QQNT.dll\"))) {\n throw new Error(\n `stub QQNT.dll 未找到: ${stub}(自建宿主必需:stub 转发 QQNT.dll 符号到 node.exe;` +\n \"请用 --stub-dir 或环境变量 NAPUTO_STUB_DIR 指定 stub 目录)\",\n );\n }\n\n const env = buildLaunchEnv(options);\n // PATH 前置 stub 目录(stub QQNT.dll 转发)+ QQ resources\\app(wrapper.node 依赖 DLL)\n const pathEntries = [stub, dirname(options.qq.wrapperPath), process.env[\"PATH\"] ?? \"\"]\n .filter((p) => p !== undefined && p !== \"\")\n .join(\";\");\n env[\"PATH\"] = pathEntries;\n env[ENV.STUB_DIR] = stub;\n\n // 标准 node 直接跑入口(不拉起 QQ,不注入)\n // cwd 显式指向数据根(cli 传入):QQ 原生层 fallback 落盘(guild1.db 等)\n // 不再污染项目根目录(实测 08-07,GPro 模块初始化早于账号上下文)。\n if (options.cwd !== undefined) {\n mkdirSync(options.cwd, { recursive: true });\n }\n const child = spawn(process.execPath, [selfHostPath], {\n // exactOptionalPropertyTypes:未传 cwd 时不显式写入 undefined(继承父进程)\n ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),\n env,\n stdio: options.stdio ?? \"inherit\",\n windowsHide: false,\n });\n\n return { child, bootJsPath: selfHostPath };\n}\n\n/**\n * 默认 stub QQNT.dll 目录(loader 包内闭源 native/build/stub-test-env,开发机默认)。\n * src 与 native 同层,构建后 dist 与 native 同层——`../native` 恒正确。\n */\nexport function defaultStubDir(): string {\n return join(__dirname, \"..\", \"native\", \"build\", \"stub-test-env\");\n}\n\n/** 装配自建宿主环境变量。 */\nfunction buildLaunchEnv(options: LaunchOptions): Record<string, string> {\n // 配置目录兜底\n const cfg = resolve(options.cfgDir);\n mkdirSync(cfg, { recursive: true });\n\n const env: Record<string, string> = {\n ...process.env,\n [ENV.QQ_PATH]: options.qq.qqPath,\n [ENV.KERNEL_ENTRY]: resolve(options.kernelEntry),\n [ENV.CFG_DIR]: cfg,\n [ENV.QQ_VERSION]: options.qq.version,\n [ENV.WRAPPER_PATH]: options.qq.wrapperPath,\n };\n if (options.selfHost === true) {\n env[ENV.SELF_HOST] = \"1\";\n }\n if (options.adapterEntry !== undefined) {\n env[ENV.ADAPTER_ENTRY] = resolve(options.adapterEntry);\n }\n if (options.networkEntry !== undefined) {\n env[ENV.NETWORK_ENTRY] = resolve(options.networkEntry);\n }\n if (options.configPath !== undefined) {\n env[ENV.CONFIG_PATH] = resolve(options.configPath);\n }\n if (options.quickUin !== undefined) {\n env[ENV.QUICK_UIN] = options.quickUin;\n }\n return env;\n}\n\n/** 环境变量名(self-host.cjs 与 kernel 引导读取)。 */\nexport const ENV = {\n QQ_PATH: \"NAPUTO_QQ_PATH\",\n KERNEL_ENTRY: \"NAPUTO_KERNEL_ENTRY\",\n CFG_DIR: \"NAPUTO_CFG_DIR\",\n QQ_VERSION: \"NAPUTO_QQ_VERSION\",\n WRAPPER_PATH: \"NAPUTO_WRAPPER_PATH\",\n /** 自建宿主:标准 node + stub QQNT.dll 引导(self-host.cjs 分支)。 */\n SELF_HOST: \"NAPUTO_SELF_HOST\",\n /** stub QQNT.dll 目录(launchSelfHost 注入,self-host.cjs 诊断用)。 */\n STUB_DIR: \"NAPUTO_STUB_DIR\",\n /** adapter 包入口(协议装配用)。 */\n ADAPTER_ENTRY: \"NAPUTO_ADAPTER_ENTRY\",\n /** network 包入口(协议装配用)。 */\n NETWORK_ENTRY: \"NAPUTO_NETWORK_ENTRY\",\n /** 全局配置文件路径(项目根 napuketto.toml,boot-protocols 读取)。 */\n CONFIG_PATH: \"NAPKETTO_CONFIG\",\n /** 强制指定快速登录账号(cli `-q <uin>` 透传,bootstrap 登录用)。 */\n QUICK_UIN: \"NAPUTO_QUICK_UIN\",\n} as const;\n","/**\n * 定位 QQ 安装目录与版本(复用 kernel wrapper-version 的探测逻辑)\n */\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readdirSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport process from \"node:process\";\n\n/** 注册表 UninstallString 查询(QQ 官方安装路径)。 */\nconst REG_QUERY_RE = /\"([^\"]+)\"/;\n\n/** 注册表 UninstallString 查询(QQ 官方安装路径)。 */\nfunction findQqViaRegistry(): string | null {\n try {\n const out = execFileSync(\"reg\", [\n \"query\",\n \"HKLM\\\\SOFTWARE\\\\WOW6432Node\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall\\\\QQ\",\n \"/v\",\n \"UninstallString\",\n ]).toString();\n const m = out.match(REG_QUERY_RE);\n const exe = m?.[1];\n if (exe !== undefined) {\n // UninstallString 形如 \"C:\\...\\uninst.exe\" 或带参数\n const dir = exe.replaceAll('\"', \"\").slice(0, exe.lastIndexOf(\"\\\\\"));\n const qq = join(dir, \"QQ.exe\");\n if (existsSync(qq)) {\n return qq;\n }\n }\n } catch {\n // 注册表查询失败,走常见路径\n }\n return null;\n}\n\n/** 常见安装路径探测。 */\nfunction findQqViaCommonPaths(): string | null {\n const localAppData = process.env[\"LOCALAPPDATA\"] ?? \"\";\n const candidates = [\n \"C:/Program Files/Tencent/QQNT/QQ.exe\",\n \"C:/Program Files (x86)/Tencent/QQNT/QQ.exe\",\n join(localAppData, \"Programs\", \"Tencent\", \"QQNT\", \"QQ.exe\"),\n // 开发机目录(2026-08-07 环境事实:QQ 9.9.33-51802 在 C:\\Dev\\QQBot-Dev\\QQNT)\n \"C:/Dev/QQBot-Dev/QQNT/QQ.exe\",\n ];\n for (const p of candidates) {\n if (existsSync(p)) {\n return p;\n }\n }\n return null;\n}\n\n/** QQ 安装目录探测结果。 */\nexport interface QqInstallInfo {\n /** QQ.exe 绝对路径。 */\n qqPath: string;\n /** 安装根目录。 */\n installDir: string;\n /** 当前版本目录名(如 9.9.31-49919)。 */\n version: string;\n /** wrapper.node 绝对路径。 */\n wrapperPath: string;\n}\n\n/** 找 QQ.exe 路径。 */\nexport function locateQqPath(): string {\n const viaReg = findQqViaRegistry();\n if (viaReg) {\n return viaReg;\n }\n const viaCommon = findQqViaCommonPaths();\n if (viaCommon) {\n return viaCommon;\n }\n throw new Error(\"未找到 QQ.exe(请先安装 QQ,或通过 NAPUTO_QQ_PATH 指定)\");\n}\n\n/** 从 QQ.exe 路径推导安装目录,并探测当前版本。 */\nexport function resolveQqInstall(qqPath?: string): QqInstallInfo {\n const qq = qqPath ?? process.env[\"NAPUTO_QQ_PATH\"] ?? locateQqPath();\n const installDir = qq.slice(0, qq.lastIndexOf(\"\\\\\"));\n const versionsDir = join(installDir, \"versions\");\n if (!existsSync(versionsDir)) {\n throw new Error(`QQ 版本目录不存在: ${versionsDir}`);\n }\n const versions = readdirSync(versionsDir)\n .filter((v) => statSync(join(versionsDir, v)).isDirectory())\n .sort()\n .reverse();\n const [first] = versions;\n if (first === undefined) {\n throw new Error(`QQ 版本目录为空: ${versionsDir}`);\n }\n const wrapperPath = join(versionsDir, first, \"resources\", \"app\", \"wrapper.node\");\n if (!existsSync(wrapperPath)) {\n throw new Error(`未找到 wrapper.node: ${wrapperPath}`);\n }\n return { qqPath: qq, installDir, version: first, wrapperPath };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/instance-lock.ts","../src/launcher.ts","../src/locate-qq.ts"],"sourcesContent":["/**\n * instance-lock.ts:单实例锁(2026-08-07 根治「多实例抢数据目录锁挂起」)\n *\n * 背景(实测):同一账号数据目录(~/.napuketto/<uin>)只能被一个实例持有——\n * QQ 原生层(MMKV / 登录单例 / 数据库)有锁,第二个实例抢不到锁会卡在\n * loginService.initConfig 之后无响应(挂起)。此前 create-napukettoqq 生成的\n * 副本实例常驻占锁,导致用户后续启动全部卡死。\n *\n * 方案:以数据目录为粒度加锁文件 `<dataDir>/instance.lock`(JSON 内容:\n * pid / startedAt / cmdline 摘要)。启动前 checkInstanceLock:\n * - 锁不存在 → 可启动(然后 acquireInstanceLock 写入)\n * - 锁存在但 PID 已死 → 崩溃残留,自动清理(rm + 重新获取)\n * - 锁存在且 PID 存活 → 已被占用,拒绝启动(返回占用 PID 供提示)\n *\n * 退出清理:进程正常退出时 releaseInstanceLock 删除锁文件;崩溃/强杀残留\n * 由下次启动的「PID 存活检测」兜底(PID 已死 → 残留视为过期)。\n *\n * 放置说明:放 loader 而非 kernel——self-host.cjs 是 rolldown 内联 bundle\n * (只内联 src/host/ 依赖树),静态 import kernel 会留 external 缺口;\n * 放 loader 则 self-host(内联)与 cli(@napuketto/loader 导入)双入口可用。\n */\n\nimport { readFileSync, rmSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport process from \"node:process\";\n\n/** 锁文件名(置于数据目录内)。 */\nexport const INSTANCE_LOCK_FILE = \"instance.lock\";\n\n/** 锁文件内容。 */\nexport interface InstanceLockInfo {\n /** 持有实例的进程 PID。 */\n pid: number;\n /** 获取锁的时间(epoch ms)。 */\n startedAt: number;\n /** 命令行摘要(诊断用,可选)。 */\n cmdline?: string;\n}\n\n/** 数据目录锁检查结果。 */\nexport interface InstanceLockCheck {\n /** 是否被其他实例占用。 */\n occupied: boolean;\n /** 占用进程 PID(occupied=true 时有效)。 */\n pid: number | null;\n /** 锁文件完整路径(提示/清理用)。 */\n lockPath: string;\n}\n\n/**\n * 检测指定数据目录是否已被其他实例占用。\n *\n * @param dataDir 数据目录(账号级,如 ~/.napuketto/3567141148)\n */\nexport function checkInstanceLock(dataDir: string): InstanceLockCheck {\n const lockPath = join(dataDir, INSTANCE_LOCK_FILE);\n let info: InstanceLockInfo | null = null;\n try {\n info = JSON.parse(readFileSync(lockPath, \"utf-8\")) as InstanceLockInfo;\n } catch {\n // 文件不存在 / 内容损坏 → 视为可启动(损坏锁由 acquire 覆盖)\n return { occupied: false, pid: null, lockPath };\n }\n if (typeof info.pid !== \"number\" || Number.isNaN(info.pid) || info.pid <= 0) {\n return { occupied: false, pid: null, lockPath };\n }\n // PID 存活判定(Windows 下 process.kill(pid, 0) 探测)\n let alive = false;\n try {\n process.kill(info.pid, 0);\n alive = true;\n } catch {\n alive = false;\n }\n if (!alive) {\n // 崩溃残留:持有者已死,可安全接管\n return { occupied: false, pid: null, lockPath };\n }\n return { occupied: true, pid: info.pid, lockPath };\n}\n\n/**\n * 获取数据目录锁。占用(其他实例存活)返回 false;成功或清理残留后返回 true。\n * 幂等:本进程已持有则直接返回 true(不重复写)。\n */\nexport function acquireInstanceLock(dataDir: string, cmdline?: string): boolean {\n const { occupied, pid, lockPath } = checkInstanceLock(dataDir);\n if (occupied) {\n // 已占用:除非占用者是自己(PID 复用保护),否则拒绝\n if (pid === process.pid) {\n return true;\n }\n return false;\n }\n // 残留锁(持有者已死 / 无锁):写入\n const info: InstanceLockInfo = {\n pid: process.pid,\n startedAt: Date.now(),\n ...(cmdline !== undefined && cmdline !== \"\" ? { cmdline } : {}),\n };\n try {\n rmSync(lockPath, { force: true });\n writeFileSync(lockPath, JSON.stringify(info), \"utf-8\");\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * 释放数据目录锁(正常退出时调用)。仅当持有者是本进程时才删除,\n * 避免误删新持有者的锁(PID 复用 / 快速重启场景)。\n */\nexport function releaseInstanceLock(dataDir: string): void {\n const lockPath = join(dataDir, INSTANCE_LOCK_FILE);\n try {\n const info = JSON.parse(readFileSync(lockPath, \"utf-8\")) as InstanceLockInfo;\n if (info.pid === process.pid) {\n rmSync(lockPath, { force: true });\n }\n } catch {\n // 文件不存在 / 内容损坏 → 无需清理\n }\n}\n\n/** 注册进程退出清理(SIGINT/SIGTERM/exit 均触发;崩溃强杀由残留检测兜底)。 */\nexport function registerLockCleanup(dataDir: string): void {\n process.on(\"exit\", () => {\n releaseInstanceLock(dataDir);\n });\n}\n","/**\n * launcher:自建宿主引导(2026-08-07 唯一路线)——spawn 标准 node 跑 self-host.cjs。\n *\n * 历史(V1/路线 B 事实链,已归档 archive/):\n * 1. wrapper.node 只能在 QQ 定制版 Electron 里注册(实测纯 Node/普通 Electron 均 self-register 失败)\n * 2. QQ 是打包应用,禁 NODE_OPTIONS(实测 stderr)\n * → 旧方案:NapukettoBootMain.exe 启动 QQ + 注入 NapukettoWinBootHook.dll(已废弃)\n * → 现方案:stub QQNT.dll 转发宿主符号 → 标准 node 直接 dlopen wrapper.node(自建宿主)\n */\nimport { type StdioOptions, spawn } from \"node:child_process\";\nimport { existsSync, mkdirSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport process from \"node:process\";\nimport { fileURLToPath } from \"node:url\";\nimport type { QqInstallInfo } from \"./locate-qq.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\nexport interface LaunchOptions {\n /** QQ 安装信息(qqPath/version/wrapperPath)。 */\n qq: QqInstallInfo;\n /** kernel 入口(.mjs,self-host.cjs 里 import)。 */\n kernelEntry: string;\n /** 配置目录(账号数据目录,NAPUTO_CFG_DIR)。 */\n cfgDir: string;\n /** 全局配置文件路径(项目根 napuketto.toml,注入 NAPKETTO_CONFIG 供装配链读取)。 */\n configPath?: string;\n /** adapter 入口(.mjs,协议装配用)。 */\n adapterEntry?: string;\n /** network 入口(.mjs,协议装配用)。 */\n networkEntry?: string;\n /** 自建宿主:标准 node + stub QQNT.dll 直接引导,不拉起 QQ(2026-08-07 唯一路线)。 */\n selfHost?: boolean;\n /** stub QQNT.dll 目录(PATH 前置,转发 napi_*、uv_* 符号到 node.exe)。 */\n stubDir?: string;\n /** 强制指定快速登录账号(注入 NAPUTO_QUICK_UIN;cli `-q <uin>` 透传,2026-08-07)。 */\n quickUin?: string;\n /** 自建宿主入口(默认 dist/host/self-host.cjs)。 */\n selfHostEntry?: string;\n /**\n * 子进程工作目录(缺省继承父进程 cwd)。\n *\n * ⚠️ 必须指向数据根(~/.napuketto):QQ 原生层在账号上下文就绪前会\n * fallback 到进程 cwd 落盘(如频道库 guild1.db),不指定则污染调用方\n * 目录(实测写入项目根,guild1.db 08-07)。cli 传 dataRoot 根治。\n */\n cwd?: string;\n /**\n * 子进程 stdio(默认 \"inherit\")。cli 传 [\"inherit\",\"pipe\",\"pipe\"] 并接管\n * 子进程 stdout/stderr:逐行过滤 wrapper 原生噪音(MMKV 刷屏、Node 符号\n * 查找失败警告),其余转发——原生 printf 直写 fd 的字节无法从 JS 层拦截。\n */\n stdio?: StdioOptions;\n}\n\nexport interface LaunchResult {\n /** 子进程句柄(自建宿主 node 进程)。 */\n child: ReturnType<typeof spawn>;\n /** 自建宿主入口路径(self-host.cjs)。 */\n bootJsPath: string;\n}\n\n/**\n * 启动自建宿主(路线 A,2026-08-07 产品化):spawn 标准 node 直接跑 self-host.cjs,\n * 不拉起 QQ / 不注入。\n *\n * 关键(HANDOVER-V7 技术数据):wrapper.node 从 QQNT.dll 导入 99 符号\n * (napi_*×40 + uv_*×56 + qq_magic×1 + v8/node mangled×2)——标准 node 无这些宿主\n * 符号,必须 PATH 前置 stub QQNT.dll 目录(转发到 node.exe)+ QQ resources\\app\n * (wrapper.node 同目录依赖 DLL)才能 dlopen 成功。\n */\nexport function launchSelfHost(options: LaunchOptions): LaunchResult {\n const selfHostPath =\n options.selfHostEntry ?? join(__dirname, \"..\", \"dist\", \"host\", \"self-host.cjs\");\n if (!existsSync(selfHostPath)) {\n throw new Error(\n `self-host.cjs 缺失: ${selfHostPath}(先运行 pnpm --filter @napuketto/loader build)`,\n );\n }\n\n // stub QQNT.dll 校验(自建宿主必需:PATH 前置 stub 转发 napi_* 到 node.exe,\n // 否则 wrapper.node dlopen 失败)。默认 loader 包内闭源 submodule native/stub-test-env,\n // 缺失时提示 --stub-dir / NAPUTO_STUB_DIR 指定。\n const stub = options.stubDir ?? defaultStubDir();\n if (!existsSync(join(stub, \"QQNT.dll\"))) {\n throw new Error(\n `stub QQNT.dll 未找到: ${stub}(自建宿主必需:stub 转发 QQNT.dll 符号到 node.exe;` +\n \"请用 --stub-dir 或环境变量 NAPUTO_STUB_DIR 指定 stub 目录)\",\n );\n }\n\n const env = buildLaunchEnv(options);\n // PATH 前置 stub 目录(stub QQNT.dll 转发)+ QQ resources\\app(wrapper.node 依赖 DLL)\n const pathEntries = [stub, dirname(options.qq.wrapperPath), process.env[\"PATH\"] ?? \"\"]\n .filter((p) => p !== undefined && p !== \"\")\n .join(\";\");\n env[\"PATH\"] = pathEntries;\n env[ENV.STUB_DIR] = stub;\n\n // 标准 node 直接跑入口(不拉起 QQ,不注入)\n // cwd 显式指向数据根(cli 传入):QQ 原生层 fallback 落盘(guild1.db 等)\n // 不再污染项目根目录(实测 08-07,GPro 模块初始化早于账号上下文)。\n if (options.cwd !== undefined) {\n mkdirSync(options.cwd, { recursive: true });\n }\n const child = spawn(process.execPath, [selfHostPath], {\n // exactOptionalPropertyTypes:未传 cwd 时不显式写入 undefined(继承父进程)\n ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),\n env,\n stdio: options.stdio ?? \"inherit\",\n windowsHide: false,\n });\n\n return { child, bootJsPath: selfHostPath };\n}\n\n/**\n * 默认 stub QQNT.dll 目录(loader 包内闭源 native/build/stub-test-env,开发机默认)。\n * src 与 native 同层,构建后 dist 与 native 同层——`../native` 恒正确。\n */\nexport function defaultStubDir(): string {\n return join(__dirname, \"..\", \"native\", \"build\", \"stub-test-env\");\n}\n\n/** 装配自建宿主环境变量。 */\nfunction buildLaunchEnv(options: LaunchOptions): Record<string, string> {\n // 配置目录兜底\n const cfg = resolve(options.cfgDir);\n mkdirSync(cfg, { recursive: true });\n\n const env: Record<string, string> = {\n ...process.env,\n [ENV.QQ_PATH]: options.qq.qqPath,\n [ENV.KERNEL_ENTRY]: resolve(options.kernelEntry),\n [ENV.CFG_DIR]: cfg,\n [ENV.QQ_VERSION]: options.qq.version,\n [ENV.WRAPPER_PATH]: options.qq.wrapperPath,\n };\n if (options.selfHost === true) {\n env[ENV.SELF_HOST] = \"1\";\n }\n if (options.adapterEntry !== undefined) {\n env[ENV.ADAPTER_ENTRY] = resolve(options.adapterEntry);\n }\n if (options.networkEntry !== undefined) {\n env[ENV.NETWORK_ENTRY] = resolve(options.networkEntry);\n }\n if (options.configPath !== undefined) {\n env[ENV.CONFIG_PATH] = resolve(options.configPath);\n }\n if (options.quickUin !== undefined) {\n env[ENV.QUICK_UIN] = options.quickUin;\n }\n return env;\n}\n\n/** 环境变量名(self-host.cjs 与 kernel 引导读取)。 */\nexport const ENV = {\n QQ_PATH: \"NAPUTO_QQ_PATH\",\n KERNEL_ENTRY: \"NAPUTO_KERNEL_ENTRY\",\n CFG_DIR: \"NAPUTO_CFG_DIR\",\n QQ_VERSION: \"NAPUTO_QQ_VERSION\",\n WRAPPER_PATH: \"NAPUTO_WRAPPER_PATH\",\n /** 自建宿主:标准 node + stub QQNT.dll 引导(self-host.cjs 分支)。 */\n SELF_HOST: \"NAPUTO_SELF_HOST\",\n /** stub QQNT.dll 目录(launchSelfHost 注入,self-host.cjs 诊断用)。 */\n STUB_DIR: \"NAPUTO_STUB_DIR\",\n /** adapter 包入口(协议装配用)。 */\n ADAPTER_ENTRY: \"NAPUTO_ADAPTER_ENTRY\",\n /** network 包入口(协议装配用)。 */\n NETWORK_ENTRY: \"NAPUTO_NETWORK_ENTRY\",\n /** 全局配置文件路径(项目根 napuketto.toml,boot-protocols 读取)。 */\n CONFIG_PATH: \"NAPKETTO_CONFIG\",\n /** 强制指定快速登录账号(cli `-q <uin>` 透传,bootstrap 登录用)。 */\n QUICK_UIN: \"NAPUTO_QUICK_UIN\",\n} as const;\n","/**\n * 定位 QQ 安装目录与版本(复用 kernel wrapper-version 的探测逻辑)\n */\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readdirSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport process from \"node:process\";\n\n/** 注册表 UninstallString 查询(QQ 官方安装路径)。 */\nconst REG_QUERY_RE = /\"([^\"]+)\"/;\n\n/** 注册表 UninstallString 查询(QQ 官方安装路径)。 */\nfunction findQqViaRegistry(): string | null {\n try {\n const out = execFileSync(\"reg\", [\n \"query\",\n \"HKLM\\\\SOFTWARE\\\\WOW6432Node\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall\\\\QQ\",\n \"/v\",\n \"UninstallString\",\n ]).toString();\n const m = out.match(REG_QUERY_RE);\n const exe = m?.[1];\n if (exe !== undefined) {\n // UninstallString 形如 \"C:\\...\\uninst.exe\" 或带参数\n const dir = exe.replaceAll('\"', \"\").slice(0, exe.lastIndexOf(\"\\\\\"));\n const qq = join(dir, \"QQ.exe\");\n if (existsSync(qq)) {\n return qq;\n }\n }\n } catch {\n // 注册表查询失败,走常见路径\n }\n return null;\n}\n\n/** 常见安装路径探测。 */\nfunction findQqViaCommonPaths(): string | null {\n const localAppData = process.env[\"LOCALAPPDATA\"] ?? \"\";\n const candidates = [\n \"C:/Program Files/Tencent/QQNT/QQ.exe\",\n \"C:/Program Files (x86)/Tencent/QQNT/QQ.exe\",\n join(localAppData, \"Programs\", \"Tencent\", \"QQNT\", \"QQ.exe\"),\n // 开发机目录(2026-08-07 环境事实:QQ 9.9.33-51802 在 C:\\Dev\\QQBot-Dev\\QQNT)\n \"C:/Dev/QQBot-Dev/QQNT/QQ.exe\",\n ];\n for (const p of candidates) {\n if (existsSync(p)) {\n return p;\n }\n }\n return null;\n}\n\n/** QQ 安装目录探测结果。 */\nexport interface QqInstallInfo {\n /** QQ.exe 绝对路径。 */\n qqPath: string;\n /** 安装根目录。 */\n installDir: string;\n /** 当前版本目录名(如 9.9.31-49919)。 */\n version: string;\n /** wrapper.node 绝对路径。 */\n wrapperPath: string;\n}\n\n/** 找 QQ.exe 路径。 */\nexport function locateQqPath(): string {\n const viaReg = findQqViaRegistry();\n if (viaReg) {\n return viaReg;\n }\n const viaCommon = findQqViaCommonPaths();\n if (viaCommon) {\n return viaCommon;\n }\n throw new Error(\"未找到 QQ.exe(请先安装 QQ,或通过 NAPUTO_QQ_PATH 指定)\");\n}\n\n/** 从 QQ.exe 路径推导安装目录,并探测当前版本。 */\nexport function resolveQqInstall(qqPath?: string): QqInstallInfo {\n const qq = qqPath ?? process.env[\"NAPUTO_QQ_PATH\"] ?? locateQqPath();\n const installDir = qq.slice(0, qq.lastIndexOf(\"\\\\\"));\n const versionsDir = join(installDir, \"versions\");\n if (!existsSync(versionsDir)) {\n throw new Error(`QQ 版本目录不存在: ${versionsDir}`);\n }\n const versions = readdirSync(versionsDir)\n .filter((v) => statSync(join(versionsDir, v)).isDirectory())\n .sort()\n .reverse();\n const [first] = versions;\n if (first === undefined) {\n throw new Error(`QQ 版本目录为空: ${versionsDir}`);\n }\n const wrapperPath = join(versionsDir, first, \"resources\", \"app\", \"wrapper.node\");\n if (!existsSync(wrapperPath)) {\n throw new Error(`未找到 wrapper.node: ${wrapperPath}`);\n }\n return { qqPath: qq, installDir, version: first, wrapperPath };\n}\n"],"mappings":"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"}
|