@napuketto/loader 0.0.29 → 0.0.31
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/host/self-host.cjs +361 -70
- package/dist/index.cjs +97 -1
- package/dist/index.d.cts +16 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +16 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +97 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/host/self-host.cjs
CHANGED
|
@@ -24,9 +24,89 @@ let node_fs = require("node:fs");
|
|
|
24
24
|
let node_path = require("node:path");
|
|
25
25
|
let node_process = require("node:process");
|
|
26
26
|
node_process = __toESM(node_process, 1);
|
|
27
|
+
let node_child_process = require("node:child_process");
|
|
27
28
|
let node_os = require("node:os");
|
|
28
29
|
let zod = require("zod");
|
|
29
30
|
let node_readline = require("node:readline");
|
|
31
|
+
//#region src/pid-cmdline.ts
|
|
32
|
+
/**
|
|
33
|
+
* pid-cmdline.ts:跨进程命令行查询(instance-lock pid 复用根治,2026-09-10)。
|
|
34
|
+
*
|
|
35
|
+
* 背景(2026-09-08 实测事故):锁内 pid 的持有者已死、pid 被 Windows 复用给
|
|
36
|
+
* 无关进程时,process.kill(pid, 0) 探活成功 → 误判存活 → 新实例永久拒绝启动
|
|
37
|
+
* (只能手动删锁恢复)。根治:探活通过后再查该 pid 当前的真实命令行,与锁内
|
|
38
|
+
* cmdline 摘要比对——查不到(进程已退/查询失败)或不一致(pid 复用)均视为
|
|
39
|
+
* 持有者已死,允许接管。误判方向取舍:误判「死亡」→ 双实例抢 QQ 原生锁,
|
|
40
|
+
* 后启动者挂起退出(可恢复);误判「存活」→ 永久锁死需人工干预——前者代价
|
|
41
|
+
* 远低,故查询失败宁可接管。
|
|
42
|
+
*
|
|
43
|
+
* 平台实现:Windows 走 PowerShell Get-CimInstance Win32_Process 跨进程查询
|
|
44
|
+
* (仅在启动判活时调用一次,冷启动 ~0.5-2s 可接受;wmic 在新 Win11 已移除,
|
|
45
|
+
* 不可用)。Linux 走 /proc/<pid>/cmdline(\0 分隔)。其余平台(darwin 等)
|
|
46
|
+
* 不支持——判活退回纯 pid 探测(旧行为)。
|
|
47
|
+
*
|
|
48
|
+
* 比对不要求逐字符相等:锁内摘要来自 process.execArgv/argv join,查询侧
|
|
49
|
+
* 拿到的是操作系统记录的原始命令行,两者存在引号 / 大小写 / 路径分隔符差异
|
|
50
|
+
* ——统一经 normalizeCmdline 规范化后比对。残余差异源(8.3 短路径、相对
|
|
51
|
+
* 启动路径)极罕见,误判后果同上(可恢复),接受。
|
|
52
|
+
*/
|
|
53
|
+
/** 当前平台是否支持跨进程 cmdline 查询(不支持则判活退回纯 pid 探测)。 */
|
|
54
|
+
const CMDLINE_PROBE_SUPPORTED = node_process.default.platform === "win32" || node_process.default.platform === "linux";
|
|
55
|
+
/**
|
|
56
|
+
* 查询指定 pid 的当前命令行。
|
|
57
|
+
*
|
|
58
|
+
* @returns 命令行原文;进程不存在 / 命令行不可得(权限、系统进程 null)/
|
|
59
|
+
* 查询机制失败(超时、PowerShell 缺失)均返回 null(调用方视为持有者已死)
|
|
60
|
+
*/
|
|
61
|
+
function readCmdlineForPid(pid) {
|
|
62
|
+
if (node_process.default.platform === "win32") return readWindowsCmdline(pid);
|
|
63
|
+
if (node_process.default.platform === "linux") return readLinuxCmdline(pid);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** Windows:PowerShell Get-CimInstance 查询(进程不存在时输出空 → null)。 */
|
|
67
|
+
function readWindowsCmdline(pid) {
|
|
68
|
+
try {
|
|
69
|
+
const cmdline = (0, node_child_process.execFileSync)("powershell.exe", [
|
|
70
|
+
"-NoProfile",
|
|
71
|
+
"-NonInteractive",
|
|
72
|
+
"-ExecutionPolicy",
|
|
73
|
+
"Bypass",
|
|
74
|
+
"-Command",
|
|
75
|
+
`[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`
|
|
76
|
+
], {
|
|
77
|
+
encoding: "utf-8",
|
|
78
|
+
timeout: 1e4,
|
|
79
|
+
windowsHide: true
|
|
80
|
+
}).trim();
|
|
81
|
+
return cmdline === "" ? null : cmdline;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Linux:/proc/<pid>/cmdline(\0 分隔转空格;进程不存在抛错 → null)。 */
|
|
87
|
+
function readLinuxCmdline(pid) {
|
|
88
|
+
try {
|
|
89
|
+
const cmdline = (0, node_fs.readFileSync)(`/proc/${pid}/cmdline`, "utf-8").replaceAll("\0", " ").trim();
|
|
90
|
+
return cmdline === "" ? null : cmdline;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 命令行规范化(比对用):统一大小写 / 去引号 / 路径分隔符归一 / 折叠空白。
|
|
97
|
+
* Windows 路径大小写不敏感,lowercase 安全。
|
|
98
|
+
*/
|
|
99
|
+
function normalizeCmdline(cmdline) {
|
|
100
|
+
return cmdline.toLowerCase().replaceAll("\"", "").replaceAll("\\", "/").replace(/\s+/g, " ").trim();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 本进程命令行摘要(写锁用):execArgv(node 选项)+ argv(脚本与参数),
|
|
104
|
+
* 与操作系统记录的完整命令行结构一致(规范化后可比对)。
|
|
105
|
+
*/
|
|
106
|
+
function selfCmdline() {
|
|
107
|
+
return [...node_process.default.execArgv, ...node_process.default.argv].join(" ").trim();
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
30
110
|
//#region src/instance-lock.ts
|
|
31
111
|
/**
|
|
32
112
|
* instance-lock.ts:单实例锁(2026-08-07 根治「多实例抢数据目录锁挂起」)
|
|
@@ -42,6 +122,12 @@ let node_readline = require("node:readline");
|
|
|
42
122
|
* - 锁存在但 PID 已死 → 崩溃残留,自动清理(rm + 重新获取)
|
|
43
123
|
* - 锁存在且 PID 存活 → 已被占用,拒绝启动(返回占用 PID 供提示)
|
|
44
124
|
*
|
|
125
|
+
* PID 复用根治(2026-09-10):PID 探活通过后再比对锁内 cmdline 摘要与该
|
|
126
|
+
* PID 当前真实命令行(pid-cmdline.ts)——查不到或不一致 → PID 已被无关
|
|
127
|
+
* 进程复用(持有者已死),视为残留允许接管。旧锁(无 cmdline 字段)无法
|
|
128
|
+
* 校验,保守维持「占用」判定(一次性过渡:新锁写入时自动带摘要)。详见
|
|
129
|
+
* pid-cmdline.ts 头注释的误判代价取舍。
|
|
130
|
+
*
|
|
45
131
|
* 退出清理:进程正常退出时 releaseInstanceLock 删除锁文件;崩溃/强杀残留
|
|
46
132
|
* 由下次启动的「PID 存活检测」兜底(PID 已死 → 残留视为过期)。
|
|
47
133
|
*
|
|
@@ -85,6 +171,15 @@ function checkInstanceLock(dataDir) {
|
|
|
85
171
|
pid: null,
|
|
86
172
|
lockPath
|
|
87
173
|
};
|
|
174
|
+
const lockCmdline = typeof info.cmdline === "string" && info.cmdline !== "" ? info.cmdline : void 0;
|
|
175
|
+
if (lockCmdline !== void 0 && CMDLINE_PROBE_SUPPORTED) {
|
|
176
|
+
const current = readCmdlineForPid(info.pid);
|
|
177
|
+
if (current === null || normalizeCmdline(current) !== normalizeCmdline(lockCmdline)) return {
|
|
178
|
+
occupied: false,
|
|
179
|
+
pid: null,
|
|
180
|
+
lockPath
|
|
181
|
+
};
|
|
182
|
+
}
|
|
88
183
|
return {
|
|
89
184
|
occupied: true,
|
|
90
185
|
pid: info.pid,
|
|
@@ -101,10 +196,11 @@ function acquireInstanceLock(dataDir, cmdline) {
|
|
|
101
196
|
if (pid === node_process.default.pid) return true;
|
|
102
197
|
return false;
|
|
103
198
|
}
|
|
199
|
+
const effectiveCmdline = cmdline !== void 0 && cmdline !== "" ? cmdline : selfCmdline();
|
|
104
200
|
const info = {
|
|
105
201
|
pid: node_process.default.pid,
|
|
106
202
|
startedAt: Date.now(),
|
|
107
|
-
...
|
|
203
|
+
...effectiveCmdline !== "" ? { cmdline: effectiveCmdline } : {}
|
|
108
204
|
};
|
|
109
205
|
try {
|
|
110
206
|
(0, node_fs.rmSync)(lockPath, { force: true });
|
|
@@ -856,6 +952,7 @@ function attachIpcServices(actions, services) {
|
|
|
856
952
|
forwardChannel(services.friendChannel, stops);
|
|
857
953
|
return () => {
|
|
858
954
|
for (const stop of stops) stop();
|
|
955
|
+
for (const name of full.keys()) actions.delete(name);
|
|
859
956
|
};
|
|
860
957
|
}
|
|
861
958
|
//#endregion
|
|
@@ -950,6 +1047,7 @@ async function attachOb11IpcBridge(actions, services, deps = {}) {
|
|
|
950
1047
|
msgChannel: services.channel,
|
|
951
1048
|
groupChannel: services.groupChannel,
|
|
952
1049
|
friendChannel: services.friendChannel,
|
|
1050
|
+
buddyCacheEvents: services.buddyCache?.events,
|
|
953
1051
|
logger: services.logger,
|
|
954
1052
|
msgApi: services.msgApi,
|
|
955
1053
|
groupApi: services.groupApi,
|
|
@@ -999,6 +1097,7 @@ async function attachOb11IpcBridge(actions, services, deps = {}) {
|
|
|
999
1097
|
return () => {
|
|
1000
1098
|
broadcaster.unregister(bridgeAdapter);
|
|
1001
1099
|
ob11.unsubscribeOnly();
|
|
1100
|
+
for (const name of ob11.registry.names) actions.delete(name);
|
|
1002
1101
|
};
|
|
1003
1102
|
} catch (err) {
|
|
1004
1103
|
log(`ipc-ob11: OB11 动作桥装配失败(降级纯 kernel 动作面): ${errMsg(err)}`);
|
|
@@ -1166,13 +1265,23 @@ function loadProtocolSections(kernel, uin) {
|
|
|
1166
1265
|
const BACKSLASH_RE = /\\/g;
|
|
1167
1266
|
/** adapter 入口 index.mjs 后缀(子路径导出替换用)。 */
|
|
1168
1267
|
const INDEX_MJS_RE = /index\.mjs$/;
|
|
1169
|
-
/**
|
|
1268
|
+
/**
|
|
1269
|
+
* 装配 OB11 + Satori 适配器(登录成功后,非 IPC 模式)。
|
|
1270
|
+
*
|
|
1271
|
+
* 返回停止函数(ob11/satori 适配器 stop:传输关闭 + 订阅清理;装配跳过时
|
|
1272
|
+
* no-op)——startProtocols 存入 services.stopAdapters,供未来非 IPC 软重登
|
|
1273
|
+
* 清理(当前软重登仅 IPC 模式有触发通道,见 relogin.ts)。
|
|
1274
|
+
*
|
|
1275
|
+
* ⚠️ 装配失败**抛出**(2026-09-08 修复):cli 模式跑宿主就是为了 OB11/Satori
|
|
1276
|
+
* 服务,此处吞错会让 startProtocols 误判成功、以假 ready 留驻(内部 catch
|
|
1277
|
+
* 曾把 protocols.ts「装配失败返回 null 判引导失败」的意图整个短路)。
|
|
1278
|
+
*/
|
|
1170
1279
|
async function assembleOb11AndSatori(kernel, services, loginResult) {
|
|
1171
1280
|
const adapterEntry = env.NAPUTO_ADAPTER_ENTRY;
|
|
1172
1281
|
const networkEntry = env.NAPUTO_NETWORK_ENTRY;
|
|
1173
1282
|
if (!adapterEntry || !networkEntry) {
|
|
1174
1283
|
log("bootstrap: NAPUTO_ADAPTER_ENTRY/NETWORK_ENTRY 未设置,跳过协议装配");
|
|
1175
|
-
return;
|
|
1284
|
+
return async () => {};
|
|
1176
1285
|
}
|
|
1177
1286
|
try {
|
|
1178
1287
|
const network = await import(`file://${networkEntry.replace(BACKSLASH_RE, "/")}`);
|
|
@@ -1194,12 +1303,13 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
|
|
|
1194
1303
|
const wrapperExports = services.ctx.wrapper?.exports;
|
|
1195
1304
|
const qqRoot = kernel.resolveQqUserDataRoot?.(wrapperExports) ?? null;
|
|
1196
1305
|
const mediaBaseDir = qqRoot !== null ? kernel.resolveQqGlobalPath?.(qqRoot) : void 0;
|
|
1197
|
-
|
|
1306
|
+
const ob11 = new adapter.NapukettoOneBot11Adapter({
|
|
1198
1307
|
config: ob11Config,
|
|
1199
1308
|
broadcaster,
|
|
1200
1309
|
msgChannel: channel,
|
|
1201
1310
|
groupChannel: services.groupChannel,
|
|
1202
1311
|
friendChannel: services.friendChannel,
|
|
1312
|
+
buddyCacheEvents: services.buddyCache?.events,
|
|
1203
1313
|
logger: services.logger,
|
|
1204
1314
|
msgApi: services.msgApi,
|
|
1205
1315
|
groupApi: services.groupApi,
|
|
@@ -1231,7 +1341,8 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
|
|
|
1231
1341
|
}
|
|
1232
1342
|
},
|
|
1233
1343
|
groupCache
|
|
1234
|
-
})
|
|
1344
|
+
});
|
|
1345
|
+
await ob11.start();
|
|
1235
1346
|
log("bootstrap: onebot11 adapter started");
|
|
1236
1347
|
const satoriCacheDir = typeof satoriSection["cacheDir"] === "string" && satoriSection["cacheDir"] !== "" ? satoriSection["cacheDir"] : (0, node_path.join)(env.NAPUTO_CFG_DIR || ".", "cache");
|
|
1237
1348
|
const satoriConfig = new adapterCore.ProtocolConfig({
|
|
@@ -1240,7 +1351,7 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
|
|
|
1240
1351
|
defaults: satoriAdapter.satoriConfigSchema.parse({}),
|
|
1241
1352
|
seed: satoriAdapter.satoriConfigSchema.parse(satoriSection)
|
|
1242
1353
|
});
|
|
1243
|
-
|
|
1354
|
+
const satori = new satoriAdapter.NapukettoSatoriAdapter({
|
|
1244
1355
|
config: satoriConfig,
|
|
1245
1356
|
broadcaster,
|
|
1246
1357
|
msgChannel: channel,
|
|
@@ -1252,10 +1363,16 @@ async function assembleOb11AndSatori(kernel, services, loginResult) {
|
|
|
1252
1363
|
self: services.self,
|
|
1253
1364
|
cacheDir: satoriCacheDir,
|
|
1254
1365
|
groupCache
|
|
1255
|
-
})
|
|
1366
|
+
});
|
|
1367
|
+
await satori.start();
|
|
1256
1368
|
log("bootstrap: satori adapter started");
|
|
1369
|
+
return async () => {
|
|
1370
|
+
await ob11.stop();
|
|
1371
|
+
await satori.stop();
|
|
1372
|
+
};
|
|
1257
1373
|
} catch (e) {
|
|
1258
1374
|
log(`bootstrap: 协议装配失败: ${errMsg(e)}`);
|
|
1375
|
+
throw e;
|
|
1259
1376
|
}
|
|
1260
1377
|
}
|
|
1261
1378
|
//#endregion
|
|
@@ -1299,9 +1416,11 @@ function formatMessageLog(kernel, msg) {
|
|
|
1299
1416
|
}
|
|
1300
1417
|
/**
|
|
1301
1418
|
* 消息日志订阅(onRecvMsg 回调参数为消息数组——2026-08-07 运行时实证,遍历逐条打印)。
|
|
1419
|
+
* 返回退订函数(软重登重装配时清理旧订阅,2026-09-08);通道返回面宽松
|
|
1420
|
+
* (EventChannelLike.on 返回 unknown),非函数时退化为 no-op。
|
|
1302
1421
|
*/
|
|
1303
1422
|
function setupMsgLogging(kernel, channel, logger, colorize) {
|
|
1304
|
-
channel.on("Msg/onRecvMsg", (msgs) => {
|
|
1423
|
+
const off = channel.on("Msg/onRecvMsg", (msgs) => {
|
|
1305
1424
|
forEachRawMessage(msgs, (m) => {
|
|
1306
1425
|
try {
|
|
1307
1426
|
const { plain, colored } = formatMessageLog(kernel, m);
|
|
@@ -1314,6 +1433,7 @@ function setupMsgLogging(kernel, channel, logger, colorize) {
|
|
|
1314
1433
|
}
|
|
1315
1434
|
});
|
|
1316
1435
|
});
|
|
1436
|
+
return typeof off === "function" ? off : () => {};
|
|
1317
1437
|
}
|
|
1318
1438
|
//#endregion
|
|
1319
1439
|
//#region src/host/core/kernel-services.ts
|
|
@@ -1327,9 +1447,10 @@ function setupMsgLogging(kernel, channel, logger, colorize) {
|
|
|
1327
1447
|
/** 登录成功后创建 kernel 业务服务(channel/bridge/cache/apis)。失败返回 null。 */
|
|
1328
1448
|
async function createKernelServices(kernel, ctx, loginResult) {
|
|
1329
1449
|
const ipcMode = env.NAPUTO_IPC === "1";
|
|
1450
|
+
const logFile = env.NAPUTO_CFG_DIR !== void 0 ? (0, node_path.join)(env.NAPUTO_CFG_DIR, "logs", "loader.log") : (0, node_path.join)((0, node_os.tmpdir)(), "napuketto-loader.log");
|
|
1330
1451
|
const logger = kernel.createLogger?.({
|
|
1331
1452
|
console: !ipcMode,
|
|
1332
|
-
|
|
1453
|
+
file: logFile,
|
|
1333
1454
|
base: { name: "loader" }
|
|
1334
1455
|
});
|
|
1335
1456
|
const session = ctx.session;
|
|
@@ -1338,21 +1459,29 @@ async function createKernelServices(kernel, ctx, loginResult) {
|
|
|
1338
1459
|
return null;
|
|
1339
1460
|
}
|
|
1340
1461
|
const channel = new kernel.NTEventChannel("Msg");
|
|
1341
|
-
new kernel.MsgBridge(session, channel)
|
|
1342
|
-
|
|
1462
|
+
const bridge = new kernel.MsgBridge(session, channel);
|
|
1463
|
+
bridge.register();
|
|
1464
|
+
const offMsgLogging = setupMsgLogging(kernel, channel, logger, !ipcMode);
|
|
1343
1465
|
const groupApi = new kernel.GroupApi(session);
|
|
1344
1466
|
const util = ctx.exports?.NodeQQNTWrapperUtil;
|
|
1345
1467
|
const msgApi = new kernel.MsgApi(session, channel, util);
|
|
1346
1468
|
const friendApi = new kernel.FriendApi(session, { uidToUin: (uids) => groupApi.uidToUin(uids) });
|
|
1347
1469
|
const groupChannel = new kernel.NTEventChannel("Group");
|
|
1348
|
-
new kernel.GroupBridge(session, groupChannel)
|
|
1470
|
+
const groupBridge = new kernel.GroupBridge(session, groupChannel);
|
|
1471
|
+
groupBridge.register();
|
|
1349
1472
|
const friendChannel = new kernel.NTEventChannel("Buddy");
|
|
1350
|
-
new kernel.FriendBridge(session, friendChannel)
|
|
1473
|
+
const friendBridge = new kernel.FriendBridge(session, friendChannel);
|
|
1474
|
+
friendBridge.register();
|
|
1351
1475
|
const groupCache = new kernel.GroupCache({
|
|
1352
1476
|
channel: groupChannel,
|
|
1353
1477
|
groupApi
|
|
1354
1478
|
});
|
|
1355
1479
|
groupCache.register();
|
|
1480
|
+
const buddyCache = new kernel.BuddyCache({
|
|
1481
|
+
channel: friendChannel,
|
|
1482
|
+
logger
|
|
1483
|
+
});
|
|
1484
|
+
buddyCache.register();
|
|
1356
1485
|
const groupNotifyApi = new kernel.GroupNotifyApi(session);
|
|
1357
1486
|
const ticketApi = new kernel.TicketApi(session);
|
|
1358
1487
|
return {
|
|
@@ -1366,6 +1495,7 @@ async function createKernelServices(kernel, ctx, loginResult) {
|
|
|
1366
1495
|
groupApi,
|
|
1367
1496
|
friendApi,
|
|
1368
1497
|
groupCache,
|
|
1498
|
+
buddyCache,
|
|
1369
1499
|
groupNotifyApi,
|
|
1370
1500
|
ticketApi,
|
|
1371
1501
|
richMediaApi: new kernel.RichMediaApi(session),
|
|
@@ -1378,7 +1508,15 @@ async function createKernelServices(kernel, ctx, loginResult) {
|
|
|
1378
1508
|
},
|
|
1379
1509
|
session,
|
|
1380
1510
|
engine: ctx.engine,
|
|
1381
|
-
util: ctx.exports?.NodeQQNTWrapperUtil
|
|
1511
|
+
util: ctx.exports?.NodeQQNTWrapperUtil,
|
|
1512
|
+
dispose: () => {
|
|
1513
|
+
bridge.unregister();
|
|
1514
|
+
groupBridge.unregister();
|
|
1515
|
+
friendBridge.unregister();
|
|
1516
|
+
groupCache.unregister();
|
|
1517
|
+
buddyCache.unregister();
|
|
1518
|
+
offMsgLogging();
|
|
1519
|
+
}
|
|
1382
1520
|
};
|
|
1383
1521
|
}
|
|
1384
1522
|
//#endregion
|
|
@@ -1408,7 +1546,7 @@ async function startProtocols(kernel, ctx, loginResult) {
|
|
|
1408
1546
|
return services;
|
|
1409
1547
|
}
|
|
1410
1548
|
try {
|
|
1411
|
-
await assembleOb11AndSatori(kernel, services, loginResult);
|
|
1549
|
+
services.stopAdapters = await assembleOb11AndSatori(kernel, services, loginResult);
|
|
1412
1550
|
} catch (e) {
|
|
1413
1551
|
log(`bootstrap: 协议装配失败: ${errMsg(e)}`);
|
|
1414
1552
|
return null;
|
|
@@ -1416,6 +1554,156 @@ async function startProtocols(kernel, ctx, loginResult) {
|
|
|
1416
1554
|
return services;
|
|
1417
1555
|
}
|
|
1418
1556
|
//#endregion
|
|
1557
|
+
//#region src/host/core/relogin.ts
|
|
1558
|
+
/**
|
|
1559
|
+
* relogin.ts:登录控制相位机 + control login 指令处理(2026-09-08 A1/A2)。
|
|
1560
|
+
*
|
|
1561
|
+
* control login(koishi 面板「重新登录/扫码登录」→ IPC control login 指令)
|
|
1562
|
+
* 的成功结果按引导相位分派三条路径:
|
|
1563
|
+
* ① login-race(初始登录竞速中):结果经 preemptRef 接管引导链——快速登录
|
|
1564
|
+
* 风控挂起时强制扫码也能走完装配链(2026-09-08 T2 既有语义)。
|
|
1565
|
+
* ② ready(引导已完成):**软重登**——重装配(清理旧服务 + 用新结果重跑
|
|
1566
|
+
* 装配链),不再整进程重启;完成后重播 ready + logged_in。
|
|
1567
|
+
* ③ assembling/aborted:迟到结果**不上报**(A1 修复)——引导失败进程将退出,
|
|
1568
|
+
* 无条件 sendLogin("logged_in") 会制造 failed→logged_in 的误导序列
|
|
1569
|
+
* (koishi driver 收到 logged_in 但子进程无协议装配,上线后所有请求失败);
|
|
1570
|
+
* 装配进行中的并发结果同样忽略(不干扰初次装配链)。
|
|
1571
|
+
*
|
|
1572
|
+
* 防重入(2026-09-08 拍板):control login 全程互斥(claim/release)——在途
|
|
1573
|
+
* 登录/重装配期间的新指令直接忽略(仅留日志,不发登录消息,面板随首个流程
|
|
1574
|
+
* 的真实状态收敛)。
|
|
1575
|
+
*/
|
|
1576
|
+
/** 登录控制相位机(bootstrapWithCore 持有,handler 与引导链共享)。 */
|
|
1577
|
+
var LoginControl = class {
|
|
1578
|
+
phase = "login-race";
|
|
1579
|
+
claimed = false;
|
|
1580
|
+
preempt;
|
|
1581
|
+
constructor(preempt) {
|
|
1582
|
+
this.preempt = preempt;
|
|
1583
|
+
}
|
|
1584
|
+
/** 当前相位。 */
|
|
1585
|
+
get currentPhase() {
|
|
1586
|
+
return this.phase;
|
|
1587
|
+
}
|
|
1588
|
+
/** 取走竞速抢占口(登录期 control login 成功接管引导链用);竞速已结束返回 null。 */
|
|
1589
|
+
takePreempt() {
|
|
1590
|
+
const resolve = this.preempt.resolve;
|
|
1591
|
+
if (resolve === null) return null;
|
|
1592
|
+
this.preempt.resolve = null;
|
|
1593
|
+
return resolve;
|
|
1594
|
+
}
|
|
1595
|
+
/** 竞速结束(初始登录 settle):关抢占口,进入装配期。 */
|
|
1596
|
+
beginAssembly() {
|
|
1597
|
+
this.preempt.resolve = null;
|
|
1598
|
+
this.phase = "assembling";
|
|
1599
|
+
}
|
|
1600
|
+
/** 装配完成:开启软重登窗口。 */
|
|
1601
|
+
markReady() {
|
|
1602
|
+
this.phase = "ready";
|
|
1603
|
+
}
|
|
1604
|
+
/** 引导失败(进程将退出):关抢占口,迟到 control login 结果不再上报。 */
|
|
1605
|
+
abort() {
|
|
1606
|
+
this.preempt.resolve = null;
|
|
1607
|
+
this.phase = "aborted";
|
|
1608
|
+
}
|
|
1609
|
+
/** control login 互斥(在途防重入):同一时刻只允许一个 control login 流程。 */
|
|
1610
|
+
claim() {
|
|
1611
|
+
if (this.claimed) return false;
|
|
1612
|
+
this.claimed = true;
|
|
1613
|
+
return true;
|
|
1614
|
+
}
|
|
1615
|
+
/** 释放互斥(core.login settle 后调用,含软重登重装配完成)。 */
|
|
1616
|
+
release() {
|
|
1617
|
+
this.claimed = false;
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
/** 登录状态字面量(IPC login 消息,与 kernel LoginState 对齐)。 */
|
|
1621
|
+
const LOGIN_STATES = [
|
|
1622
|
+
"idle",
|
|
1623
|
+
"waiting_scan",
|
|
1624
|
+
"scanned",
|
|
1625
|
+
"logged_in",
|
|
1626
|
+
"failed"
|
|
1627
|
+
];
|
|
1628
|
+
/** 宽松收窄(kernel onLoginProgress.state 是 string;bootstrap-core 登录回调共用)。 */
|
|
1629
|
+
function isLoginState(value) {
|
|
1630
|
+
return LOGIN_STATES.includes(value);
|
|
1631
|
+
}
|
|
1632
|
+
/** ready 态软重登:重装配 → 成功重播 ready/logged_in,失败交失败处理。 */
|
|
1633
|
+
async function softReloginAndReport(loginResult, selfInfo, deps) {
|
|
1634
|
+
if (!await deps.reassemble(loginResult)) {
|
|
1635
|
+
(deps.onReassembleFailed ?? defaultReassembleFailed)();
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
sendStatus("ready");
|
|
1639
|
+
sendLogin("logged_in", selfInfo);
|
|
1640
|
+
}
|
|
1641
|
+
/** control login 成功结果按相位分派(抽取自 handler,控制认知复杂度)。 */
|
|
1642
|
+
async function dispatchLoginSuccess(result, control, deps) {
|
|
1643
|
+
const selfInfo = {
|
|
1644
|
+
uin: result.uin,
|
|
1645
|
+
uid: result.uid,
|
|
1646
|
+
nick: result.nick ?? ""
|
|
1647
|
+
};
|
|
1648
|
+
const preemptResolve = control.takePreempt();
|
|
1649
|
+
if (preemptResolve !== null) {
|
|
1650
|
+
sendLogin("logged_in", selfInfo);
|
|
1651
|
+
preemptResolve(result);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if (control.currentPhase === "ready") {
|
|
1655
|
+
await softReloginAndReport(result, selfInfo, deps);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
log(`relogin: control login 成功但引导相位=${control.currentPhase},忽略结果`);
|
|
1659
|
+
}
|
|
1660
|
+
/** 重装配失败默认处理:上报 failed + 退出进程(koishi driver 重启循环回收)。 */
|
|
1661
|
+
function defaultReassembleFailed() {
|
|
1662
|
+
log("relogin: 软重登重装配失败,上报 failed 并退出(driver 重启循环接管)");
|
|
1663
|
+
sendStatus("failed", "软重登重装配失败", {
|
|
1664
|
+
code: "UNKNOWN",
|
|
1665
|
+
message: "软重登重装配失败,详见 napuketto-boot.log"
|
|
1666
|
+
});
|
|
1667
|
+
node_process.default.exit(1);
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* control login 指令 → 重新登录(qr=true 强制扫码跳过快速登录,uin 指定账号)。
|
|
1671
|
+
* 成功结果按 LoginControl 相位分派(登录期抢占 / ready 软重登 / 忽略迟到)。
|
|
1672
|
+
* 该 handler 仅在 IPC 模式经 startIpcServer 的 onLogin 注册(见 bootstrapWithCore),
|
|
1673
|
+
* 非 IPC 模式无 control 通道不会触达,故无条件走 JSON 行协议、无需再判 ipcMode。
|
|
1674
|
+
*/
|
|
1675
|
+
function createLoginControlHandler(core, Appid, control, deps) {
|
|
1676
|
+
return (payload) => {
|
|
1677
|
+
if (!control.claim()) {
|
|
1678
|
+
log("relogin: control login 在途(登录/软重登进行中),忽略本次请求");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const opts = {
|
|
1682
|
+
appid: String(Appid),
|
|
1683
|
+
initTimeoutMs: 2e4,
|
|
1684
|
+
qrFallback: true,
|
|
1685
|
+
...payload.uin !== void 0 ? { quickUin: payload.uin } : {},
|
|
1686
|
+
...payload.qr === true ? { qrOnly: true } : {},
|
|
1687
|
+
onLoginProgress: (progress) => {
|
|
1688
|
+
if (progress.qr !== void 0) sendQr(progress.qr.pngBase64, progress.qr.qrcodeUrl);
|
|
1689
|
+
if (isLoginState(progress.state)) sendLogin(progress.state, progress.selfInfo, progress.message);
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
core.login(opts).then(async (result) => {
|
|
1693
|
+
if (result === null) {
|
|
1694
|
+
sendLogin("failed", void 0, "登录返回空结果");
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
await dispatchLoginSuccess(result, control, deps);
|
|
1698
|
+
}).catch((err) => {
|
|
1699
|
+
log(`bootstrap: control login 失败: ${errMsg(err)}`);
|
|
1700
|
+
sendLogin("failed", void 0, errMsg(err));
|
|
1701
|
+
}).finally(() => {
|
|
1702
|
+
control.release();
|
|
1703
|
+
});
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
//#endregion
|
|
1419
1707
|
//#region src/host/core/session.ts
|
|
1420
1708
|
/** 对象有效性判断:getMsgService() 调用不抛断言(cpp_impl 已激活)。
|
|
1421
1709
|
* 与 isSessionUsable 的区别:这里允许返回 null(未 init 但对象有效)。 */
|
|
@@ -1688,18 +1976,6 @@ function probeCreatedSession(state, ctx) {
|
|
|
1688
1976
|
log(`BOOT: create() 探测失败: ${errMsg(e)}`);
|
|
1689
1977
|
}
|
|
1690
1978
|
}
|
|
1691
|
-
/** 登录状态字面量(IPC login 消息,与 kernel LoginState 对齐)。 */
|
|
1692
|
-
const LOGIN_STATES = [
|
|
1693
|
-
"idle",
|
|
1694
|
-
"waiting_scan",
|
|
1695
|
-
"scanned",
|
|
1696
|
-
"logged_in",
|
|
1697
|
-
"failed"
|
|
1698
|
-
];
|
|
1699
|
-
/** 宽松收窄(kernel onLoginProgress.state 是 string)。 */
|
|
1700
|
-
function isLoginState(value) {
|
|
1701
|
-
return LOGIN_STATES.includes(value);
|
|
1702
|
-
}
|
|
1703
1979
|
/** 非 IPC 模式 QR 透出标记行前缀(cli forwardFiltered 解析后终端渲染)。 */
|
|
1704
1980
|
const QR_LINE_PREFIX = "NAPUTO_QR ";
|
|
1705
1981
|
/** 登录参数(NAPUTO_QUICK_UIN 强制指定 / ref 目标 / qrOnly 强制扫码 / 默认)。 */
|
|
@@ -1724,36 +2000,6 @@ function buildLoginOpts(Appid, forcedUin, ref, qrOnly = false) {
|
|
|
1724
2000
|
};
|
|
1725
2001
|
return opts;
|
|
1726
2002
|
}
|
|
1727
|
-
/** control login 指令 → 重新登录(qr=true 强制扫码跳过快速登录,uin 指定账号)。
|
|
1728
|
-
* 登录期(初始 doLogin 竞速中)成功结果经 preemptRef 接管引导链。 */
|
|
1729
|
-
function createLoginControlHandler(core, Appid, preemptRef) {
|
|
1730
|
-
return (payload) => {
|
|
1731
|
-
const opts = {
|
|
1732
|
-
appid: String(Appid),
|
|
1733
|
-
initTimeoutMs: 2e4,
|
|
1734
|
-
qrFallback: true,
|
|
1735
|
-
...payload.uin !== void 0 ? { quickUin: payload.uin } : {},
|
|
1736
|
-
...payload.qr === true ? { qrOnly: true } : {},
|
|
1737
|
-
onLoginProgress: (progress) => {
|
|
1738
|
-
if (progress.qr !== void 0) sendQr(progress.qr.pngBase64, progress.qr.qrcodeUrl);
|
|
1739
|
-
if (isLoginState(progress.state)) sendLogin(progress.state, progress.selfInfo, progress.message);
|
|
1740
|
-
}
|
|
1741
|
-
};
|
|
1742
|
-
core.login(opts).then((result) => {
|
|
1743
|
-
if (result !== null) {
|
|
1744
|
-
sendLogin("logged_in", {
|
|
1745
|
-
uin: result.uin,
|
|
1746
|
-
uid: result.uid,
|
|
1747
|
-
nick: result.nick ?? ""
|
|
1748
|
-
});
|
|
1749
|
-
preemptRef?.resolve?.(result);
|
|
1750
|
-
} else sendLogin("failed", void 0, "登录返回空结果");
|
|
1751
|
-
}).catch((err) => {
|
|
1752
|
-
log(`bootstrap: control login 失败: ${errMsg(err)}`);
|
|
1753
|
-
sendLogin("failed", void 0, errMsg(err));
|
|
1754
|
-
});
|
|
1755
|
-
};
|
|
1756
|
-
}
|
|
1757
2003
|
/** V2 登录后替换 session:优先 QQ 主 session,其次 vehicle 单例表;自建宿主例外。 */
|
|
1758
2004
|
function replaceSession(kernel, core, state, ctx) {
|
|
1759
2005
|
if (env.NAPUTO_SELF_HOST === "1") return null;
|
|
@@ -1849,20 +2095,72 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
|
|
|
1849
2095
|
const preemptLogin = new Promise((resolve) => {
|
|
1850
2096
|
preempt.resolve = resolve;
|
|
1851
2097
|
});
|
|
2098
|
+
const control = new LoginControl(preempt);
|
|
2099
|
+
const reassembleRef = { current: null };
|
|
1852
2100
|
if (env.NAPUTO_IPC === "1") {
|
|
1853
2101
|
ipcActions = createIpcActionsForCore(core);
|
|
1854
2102
|
startIpcServer({
|
|
1855
2103
|
actions: ipcActions,
|
|
1856
|
-
onLogin: createLoginControlHandler(core, Appid,
|
|
2104
|
+
onLogin: createLoginControlHandler(core, Appid, control, { reassemble: (loginResult) => {
|
|
2105
|
+
const fn = reassembleRef.current;
|
|
2106
|
+
if (fn === null) {
|
|
2107
|
+
log("bootstrap: softRelogin 未就绪(理论不可达:相位机 ready 守卫)");
|
|
2108
|
+
return Promise.resolve(false);
|
|
2109
|
+
}
|
|
2110
|
+
return fn(loginResult);
|
|
2111
|
+
} })
|
|
1857
2112
|
});
|
|
1858
2113
|
}
|
|
1859
2114
|
const ctx = core.attachWrapper(state.wrapperExports, { ...bootEnv });
|
|
1860
2115
|
log(`bootstrap: attachWrapper OK, engine=${typeof ctx.engine}, session=${ctx.session !== null}`);
|
|
2116
|
+
let services = null;
|
|
2117
|
+
let stopIpcServices = null;
|
|
2118
|
+
let stopOb11Bridge = null;
|
|
2119
|
+
/**
|
|
2120
|
+
* 装配链尾部(登录成功后;初次引导与软重登共用)。
|
|
2121
|
+
* @returns 装配是否成功(协议服务未装配 = false,调用方判引导失败)。
|
|
2122
|
+
*/
|
|
2123
|
+
const runAssembly = async (loginResult, initial) => {
|
|
2124
|
+
const chosen = replaceSession(kernel, core, state, ctx);
|
|
2125
|
+
await activateSession(kernel, state, ctx, chosen, loginResult, Appid, bootEnv);
|
|
2126
|
+
await waitSessionReady(kernel, ctx);
|
|
2127
|
+
if (env.NAPUTO_IPC === "1") sendStatus("sessioning");
|
|
2128
|
+
if (initial) await runSmokeIfEnabled(kernel, ctx, loginResult);
|
|
2129
|
+
const newServices = await startProtocols(kernel, ctx, loginResult);
|
|
2130
|
+
if (newServices === null) {
|
|
2131
|
+
log("bootstrap: 协议服务装配失败,引导中止");
|
|
2132
|
+
return false;
|
|
2133
|
+
}
|
|
2134
|
+
if (env.NAPUTO_IPC === "1" && ipcActions !== null) {
|
|
2135
|
+
stopIpcServices = attachIpcServices(ipcActions, newServices);
|
|
2136
|
+
stopOb11Bridge = await attachOb11IpcBridge(ipcActions, newServices);
|
|
2137
|
+
}
|
|
2138
|
+
services = newServices;
|
|
2139
|
+
return true;
|
|
2140
|
+
};
|
|
2141
|
+
/**
|
|
2142
|
+
* 软重登重装配(ready 态 control login 成功,relogin.ts 经 deps 调用):
|
|
2143
|
+
* 清理旧装配面 → 用新登录结果重跑装配链(A2,2026-09-08)。
|
|
2144
|
+
* 清理顺序:协议消费方(OB11 桥退订+动作表移除)→ IPC 事件转发/动作表
|
|
2145
|
+
* → kernel 桥/缓存/消息日志(dispose,幂等)。失败返回 false(上报退出)。
|
|
2146
|
+
*/
|
|
2147
|
+
const softRelogin = async (loginResult) => {
|
|
2148
|
+
log(`bootstrap: 软重登 uin=${loginResult.uin}——清理旧装配并重跑装配链`);
|
|
2149
|
+
stopOb11Bridge?.();
|
|
2150
|
+
stopOb11Bridge = null;
|
|
2151
|
+
stopIpcServices?.();
|
|
2152
|
+
stopIpcServices = null;
|
|
2153
|
+
services?.dispose();
|
|
2154
|
+
services = null;
|
|
2155
|
+
return await runAssembly(loginResult, false);
|
|
2156
|
+
};
|
|
2157
|
+
reassembleRef.current = softRelogin;
|
|
1861
2158
|
probeCreatedSession(state, ctx);
|
|
1862
2159
|
startSessionProbe(state, ctx);
|
|
1863
2160
|
probeSessionMethods(ctx);
|
|
1864
2161
|
if (typeof core.login !== "function") {
|
|
1865
2162
|
log("bootstrap: kernel core missing login fn");
|
|
2163
|
+
control.abort();
|
|
1866
2164
|
return false;
|
|
1867
2165
|
}
|
|
1868
2166
|
const forcedUin = env.NAPUTO_QUICK_UIN;
|
|
@@ -1871,8 +2169,9 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
|
|
|
1871
2169
|
await pickLoginAccount(kernel, ctx, ref, forcedUin);
|
|
1872
2170
|
if (env.NAPUTO_IPC === "1") sendStatus("logging");
|
|
1873
2171
|
const loginResult = await Promise.race([doLogin(core, buildLoginOpts(Appid, forcedUin, ref, qrOnly)), preemptLogin]);
|
|
1874
|
-
|
|
2172
|
+
control.beginAssembly();
|
|
1875
2173
|
if (loginResult === null) {
|
|
2174
|
+
control.abort();
|
|
1876
2175
|
log("bootstrap: 登录失败,引导中止");
|
|
1877
2176
|
if (env.NAPUTO_IPC === "1") sendStatus("failed", "登录失败", {
|
|
1878
2177
|
code: "NOT_LOGIN",
|
|
@@ -1886,19 +2185,11 @@ async function bootstrapWithCore(kernel, state, bootEnv, Appid) {
|
|
|
1886
2185
|
uid: loginResult.uid,
|
|
1887
2186
|
nick: loginResult.nick ?? ""
|
|
1888
2187
|
});
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
if (env.NAPUTO_IPC === "1") sendStatus("sessioning");
|
|
1892
|
-
await runSmokeIfEnabled(kernel, ctx, loginResult);
|
|
1893
|
-
const services = await startProtocols(kernel, ctx, loginResult);
|
|
1894
|
-
if (services === null) {
|
|
1895
|
-
log("bootstrap: 协议服务装配失败,引导中止");
|
|
2188
|
+
if (!await runAssembly(loginResult, true)) {
|
|
2189
|
+
control.abort();
|
|
1896
2190
|
return false;
|
|
1897
2191
|
}
|
|
1898
|
-
|
|
1899
|
-
attachIpcServices(ipcActions, services);
|
|
1900
|
-
await attachOb11IpcBridge(ipcActions, services);
|
|
1901
|
-
}
|
|
2192
|
+
control.markReady();
|
|
1902
2193
|
runProbePhase(kernel, ctx);
|
|
1903
2194
|
return true;
|
|
1904
2195
|
}
|