dsh-napcat-keeper 2.1.2 → 2.5.1
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/CHANGELOG.md +95 -0
- package/README.md +123 -23
- package/lib/client.js +355 -30
- package/lib/index.js +1572 -67
- package/package.json +11 -1
- package/scripts/check-manifest.mjs +400 -0
- package/scripts/check-privacy.mjs +176 -0
- package/scripts/selftest-scan.mjs +69 -0
- package/scripts/selftest-setup.mjs +210 -0
- package/scripts/smoke-client.mjs +197 -0
package/lib/index.js
CHANGED
|
@@ -1,9 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-napcat-keeper —— NapCat 看门狗 v2.
|
|
2
|
+
* dsh-napcat-keeper —— NapCat 看门狗 v2.5.0(可发布通用版)
|
|
3
3
|
*
|
|
4
4
|
* 解决的问题:DSH 是桌面应用,重启 DSH 时 NapCat(QQ 协议端) 不会跟着起来,
|
|
5
5
|
* 于是 dsh-qq-bridge 一直轮询一个死端口,你在 QQ 里说的话就石沉大海。
|
|
6
6
|
*
|
|
7
|
+
* v2.5.0:统一用 DSH 自带的 Node 运行时
|
|
8
|
+
* - 以前凡是「要跑一段 JS」的地方都默认那台机器上有 node(或者干脆蹭 NapCat / QQ 的私有环境),
|
|
9
|
+
* 换台机器、换个装法就哑火;现在统一走 resolveNode():
|
|
10
|
+
* 手填 nodePath → DSH 自带(<DSH NEXT>\resources\runtime\<arch>\primary-runtime\dependencies\node\bin\node.exe)
|
|
11
|
+
* → 系统 PATH → 常见安装目录 → NapCat 目录兜底;
|
|
12
|
+
* - 顺带补上「Shell 型」NapCat(目录**顶层**有 napcat.mjs,官方 NapCat.Shell 那种):
|
|
13
|
+
* 以前只认 NapCatWinBootMain.exe,遇到 node 型安装就报「找不到启动脚本」;
|
|
14
|
+
* 现在用这份 node 生成 napcat-autogen-node.bat 去跑 napcat.mjs;
|
|
15
|
+
* - 注入型(OneKey)不受影响:NapCat 跑在 QQ.exe 自己的 Node service
|
|
16
|
+
* (`--type=utility --utility-sub-type=node.mojom.NodeService`)里,磁盘上没有 node.exe,
|
|
17
|
+
* 外部的 node 也替不了它 —— 这条链路插件只负责调 bootmain,不碰 node;
|
|
18
|
+
* - 设置页新增「Node 运行时」一栏和「测一下 node」按钮,状态条里直接显示用的是哪一份、什么版本。
|
|
19
|
+
*
|
|
20
|
+
* v2.4.1:package.json 被 PowerShell 重写成乱码 + 断引号,同一个位置再炸一次
|
|
21
|
+
* - 事故:`Bad control character in string literal in JSON at position 899 (line 36 column 23)`。
|
|
22
|
+
* 不是插件跑起来才炸,是 DSH 读清单时就炸 —— 插件整段加载失败,看门狗跟着消失;
|
|
23
|
+
* - 现场:第 36 行 `"author": "鐔欑殑澶ц偉楸?,` —— 值被 UTF-8→ANSI(GBK) 双重编码成乱码,行尾那个标记是给体检脚本的豁免(事故原文引用)check-manifest:ignore-mojibake
|
|
24
|
+
* 末尾「鱼」的字节 E9 B1 BC 在 GBK 里凑不成整字,最后一个字节连**收尾的引号**一起被吞,
|
|
25
|
+
* 于是字符串没有结束引号,后面的换行成了字符串里的裸控制字符(就是报错里那个 position 899);
|
|
26
|
+
* - 根因还是 PowerShell 5.1:`Get-Content` 默认按 ANSI 读、`Set-Content -Encoding utf8` 再写回,
|
|
27
|
+
* 一趟往返就把中文变成「鐔欑殑澶ц偉楸」这种双层乱码(BOM 那次的亲戚;check-manifest:ignore-mojibake)
|
|
28
|
+
* - 这次把防线补在 scripts/check-manifest.mjs:除 BOM 外再查「UTF-8 合不合法 / 是不是双重编码 /
|
|
29
|
+
* JSON 错在第几行第几列(带指针)/ 清单版本与代码里的 version 同不同步」,出问题先跑它。
|
|
30
|
+
*
|
|
31
|
+
* v2.3.2:清单带 BOM 会把整个插件打死(DSH 的 JSON.parse 直接抛 Unexpected token)
|
|
32
|
+
* - 根因是本机 PowerShell 5.1 的 Set-Content/Out-File -Encoding utf8 默认写 BOM;
|
|
33
|
+
* - 新增 scripts/check-manifest.mjs:查 BOM / 校验 JSON / 核对入口文件,--fix 只抹 BOM;
|
|
34
|
+
* - 改本插件任何 json/js 请用 write/edit 工具或 [IO.File]::WriteAllText + UTF8Encoding($false);
|
|
35
|
+
*
|
|
36
|
+
* v2.3.0:提权交接 —— 需要管理员的活,交给「你点一下」的脚本
|
|
37
|
+
* - 插件不再自己尝试要管理员(它本来也要不到):把要做的事摊成步骤写进
|
|
38
|
+
* ~/.dsh/napcat-keeper-setup/plan.json,生成 apply.ps1 + apply-admin.cmd + undo-admin.cmd;
|
|
39
|
+
* - 脚本先数一遍有没有真需要管理员的步骤:没有就直接干;有才 Start-Process -Verb RunAs
|
|
40
|
+
* 自我提权 —— UAC 那个「是」就是用户亲手按的确认键,插件全程普通权限;
|
|
41
|
+
* - 每步结果写 result.json,设置页「读结果」说清楚哪条成了、哪条没成、有没有拿到管理员;
|
|
42
|
+
* - undo-admin.cmd 按相反顺序把建过的文件/任务/防火墙规则撤掉;
|
|
43
|
+
* - 能做的事(needs):补启动脚本 / 写「记住密码」中间层 / 建计划任务 / 放行 OneBot 入站端口。
|
|
44
|
+
*
|
|
45
|
+
* v2.2.2:目录只认「人选」
|
|
46
|
+
* - 去掉「自动探测 NapCat Shell 目录」:不再替用户猜位置、也不再在配置里悄悄填,
|
|
47
|
+
* shellDir 空着就是空着,由用户自己决定;
|
|
48
|
+
* - 只留「分盘扫描」:本机有几块盘就列几个按钮,点哪个扫哪个;
|
|
49
|
+
* - 盘符改成向系统要(.NET DriveInfo,含光驱 / 移动盘 / BitLocker 锁着的盘),
|
|
50
|
+
* A–Z 根目录探测只当 PowerShell 不可用时的兜底,保证「系统里有几块盘就显示几块」;
|
|
51
|
+
* - 新增 drives 动作:插拔移动盘后设置页点「重新读盘」就能刷新,不用重启 DSH。
|
|
52
|
+
*
|
|
53
|
+
* v2.2.0:分盘深扫 —— 常见位置找不到时,用户可选 C:/D:/E: 甚至全部盘各扫一遍,
|
|
54
|
+
* 深度 7 层、跳过系统目录、单盘 90 秒预算,命中就停在那条路径,结果点一下直接用。
|
|
55
|
+
*
|
|
7
56
|
* v2.1.0:可选「记住密码」
|
|
8
57
|
* - NapCat 的快速登录 token 会失效(实测几小时就掉),失效后只剩手机扫码一条路;
|
|
9
58
|
* - 打开 rememberPassword 后,拉起时会通过环境变量 ACCOUNT / NAPCAT_QUICK_PASSWORD
|
|
@@ -30,11 +79,16 @@
|
|
|
30
79
|
import { createHash } from 'node:crypto'
|
|
31
80
|
import { execFileSync, spawn } from 'node:child_process'
|
|
32
81
|
import {
|
|
82
|
+
accessSync,
|
|
33
83
|
appendFileSync,
|
|
84
|
+
closeSync,
|
|
85
|
+
constants as fsConstants,
|
|
34
86
|
copyFileSync,
|
|
35
87
|
existsSync,
|
|
36
88
|
mkdirSync,
|
|
89
|
+
openSync,
|
|
37
90
|
readFileSync,
|
|
91
|
+
readSync,
|
|
38
92
|
readdirSync,
|
|
39
93
|
statSync,
|
|
40
94
|
unlinkSync,
|
|
@@ -46,17 +100,24 @@ import { homedir, tmpdir } from 'node:os'
|
|
|
46
100
|
import path from 'node:path'
|
|
47
101
|
|
|
48
102
|
export const name = 'napcat-keeper'
|
|
49
|
-
export const version = '2.1
|
|
103
|
+
export const version = '2.5.1'
|
|
50
104
|
|
|
51
105
|
/** tools 用来注册 napcat_* 工具;webServer 用来挂设置界面接口。拿不到也不至于把 DSH 拖崩。 */
|
|
52
106
|
export const inject = ['tools', 'webServer']
|
|
53
107
|
|
|
54
108
|
const DEFAULTS = {
|
|
55
109
|
enabled: true,
|
|
56
|
-
/** NapCat Shell
|
|
110
|
+
/** NapCat Shell 目录(启动脚本所在目录);不再自动探测,留空就表示还没选 */
|
|
57
111
|
shellDir: '',
|
|
58
112
|
/** 启动脚本名或绝对路径;留空则自动探测(quick / napcat*.bat) */
|
|
59
113
|
launcherScript: '',
|
|
114
|
+
/**
|
|
115
|
+
* Node 运行时(node.exe)的位置;留空 = 自动。
|
|
116
|
+
* 自动顺序:DSH 自带的那份 → 系统 PATH → 常见安装目录 → NapCat 目录里自带的。
|
|
117
|
+
* 只在「Shell 型」NapCat(目录顶层有 napcat.mjs,需要自己用 node 跑)时才会真的用到;
|
|
118
|
+
* OneKey 那种注入式是 QQ 自己去跑 NapCat,跟这里无关(见文件头 v2.5.0 的说明)。
|
|
119
|
+
*/
|
|
120
|
+
nodePath: '',
|
|
60
121
|
/** 想快登的 QQ 号;填了且启动脚本不存在时,会自动生成一个直接调 NapCatWinBootMain.exe 的 bat */
|
|
61
122
|
launchAccount: '',
|
|
62
123
|
/** 追加给启动脚本的参数(空格分隔),一般不用填 */
|
|
@@ -92,10 +153,14 @@ const DEFAULTS = {
|
|
|
92
153
|
chainGapSec: 120,
|
|
93
154
|
/** 单条链应有的 QQ.exe 进程数上限(仅作参考,判断重复用启动时间聚类) */
|
|
94
155
|
maxQqPerChain: 4,
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
156
|
+
/**
|
|
157
|
+
* 拉起方式。默认留空 = 用 cscript 直接拉,QQ 链挂在 DSH 下面:
|
|
158
|
+
* DSH 起来 → 拉 QQ;DSH 关掉 → QQ 跟着走。本插件就是「开机接上 QQ」用的,不需要它替 QQ 续命。
|
|
159
|
+
* 填了任务名才走计划任务(父进程是任务计划服务,DSH 退出后 QQ 还活着)——那是另一套用法,想要才填。
|
|
160
|
+
*/
|
|
161
|
+
taskName: '',
|
|
162
|
+
/** 只有填了 taskName 才有意义:任务不存在时自动创建(用户级,不需要管理员);默认关 */
|
|
163
|
+
autoCreateTask: false,
|
|
99
164
|
/** 拉起时不显示黑框:用 wscript 壳把控制台窗口藏起来(桌面干净) */
|
|
100
165
|
hideConsole: true,
|
|
101
166
|
/** 设置界面存下来的配置(优先级高于 cordis 里的 config) */
|
|
@@ -118,22 +183,31 @@ const DEFAULTS = {
|
|
|
118
183
|
maxLogBytes: 1024 * 1024,
|
|
119
184
|
}
|
|
120
185
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
186
|
+
/**
|
|
187
|
+
* 模块级状态放进 globalThis 一份:DSH 改了插件文件会热重载(旧模块实例丢掉、
|
|
188
|
+
* 新模块重新 apply),如果状态(尤其 timer)跟着模块走,每次热重载都会多留下
|
|
189
|
+
* 一个还活着的 45s 定时器 + 各自的 tick → 越改越卡,最后把整个 DSH 拖住。
|
|
190
|
+
* 共用同一份状态后,armTimer 里 clearInterval(state.timer) 能真的清掉旧实例的定时器。
|
|
191
|
+
*/
|
|
192
|
+
const STATE_KEY = Symbol.for('dsh.napcat-keeper.state')
|
|
193
|
+
const state =
|
|
194
|
+
globalThis[STATE_KEY] ||
|
|
195
|
+
(globalThis[STATE_KEY] = {
|
|
196
|
+
phase: 'idle', // idle | online | launching | starting | need-scan | need-scan-giveup | offline
|
|
197
|
+
lastProbeAt: null,
|
|
198
|
+
lastOnlineAt: 0,
|
|
199
|
+
lastLaunchAt: 0,
|
|
200
|
+
lastError: '',
|
|
201
|
+
launchCount: 0,
|
|
202
|
+
dedupeCount: 0,
|
|
203
|
+
scanSince: 0,
|
|
204
|
+
scanRetries: 0,
|
|
205
|
+
lastHeartbeatAt: 0,
|
|
206
|
+
lastDupCheckAt: 0,
|
|
207
|
+
timer: null,
|
|
208
|
+
cfg: null,
|
|
209
|
+
lastSnapshot: null,
|
|
210
|
+
})
|
|
137
211
|
|
|
138
212
|
/* ------------------------------------------------------------------ */
|
|
139
213
|
/* 基础设施 */
|
|
@@ -204,18 +278,80 @@ function findShellDirs(root, depth, out) {
|
|
|
204
278
|
}
|
|
205
279
|
}
|
|
206
280
|
|
|
207
|
-
/**
|
|
281
|
+
/**
|
|
282
|
+
* 同步跑一条命令抓 stdout。失败一律返回空串 —— 这里全是「问一下系统」的活,
|
|
283
|
+
* 问不到就走兜底,绝不能影响看门狗本身。
|
|
284
|
+
*/
|
|
285
|
+
function captureSync(file, args, timeoutMs = 4000) {
|
|
286
|
+
try {
|
|
287
|
+
return String(
|
|
288
|
+
execFileSync(file, args, {
|
|
289
|
+
timeout: timeoutMs,
|
|
290
|
+
windowsHide: true,
|
|
291
|
+
encoding: 'utf8',
|
|
292
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
293
|
+
}) || '',
|
|
294
|
+
)
|
|
295
|
+
} catch {
|
|
296
|
+
return ''
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 本机有哪些盘 —— 系统里有几块就报几块。
|
|
302
|
+
*
|
|
303
|
+
* 优先问 .NET:`[System.IO.DriveInfo]::GetDrives()` 是系统视角的完整列表,
|
|
304
|
+
* 光驱、移动硬盘、BitLocker 上着锁的盘都会出现(资源管理器里看得见就该看得见);
|
|
305
|
+
* 只有 PowerShell 起不来时才退回 A–Z 根目录探测 —— 那条路对锁着的盘、无介质的
|
|
306
|
+
* 光驱、某些虚拟盘都会 stat 失败,于是「有盘却不显示」,正是要避免的。
|
|
307
|
+
*/
|
|
308
|
+
function listDrives() {
|
|
309
|
+
const found = new Set()
|
|
310
|
+
const add = (value) => {
|
|
311
|
+
const match = String(value || '').trim().match(/^([A-Za-z])[::]/)
|
|
312
|
+
if (match) found.add(`${match[1].toUpperCase()}:`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const out = captureSync('powershell.exe', [
|
|
316
|
+
'-NoProfile',
|
|
317
|
+
'-NonInteractive',
|
|
318
|
+
'-Command',
|
|
319
|
+
'[System.IO.DriveInfo]::GetDrives() | ForEach-Object { $_.Name }',
|
|
320
|
+
])
|
|
321
|
+
for (const line of out.split(/\r?\n/)) add(line)
|
|
322
|
+
|
|
323
|
+
if (!found.size) {
|
|
324
|
+
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
325
|
+
try {
|
|
326
|
+
if (statSync(`${letter}:\\`).isDirectory()) add(`${letter}:`)
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return [...found].sort()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* v2.2.2 起已停用:设置页不再替用户「自动探测」目录,只在用户点某个盘时深扫。
|
|
335
|
+
* 函数留着是给 __internals / 调试脚本用,正常运行路径(resolveConfig、/state、/action)
|
|
336
|
+
* 都不会再调用它,配置里的 shellDir 也不会被它悄悄填上。
|
|
337
|
+
*/
|
|
208
338
|
function detectShellDir() {
|
|
209
339
|
const home = homedir()
|
|
210
340
|
const roots = [
|
|
211
341
|
path.join(home, 'NapCat'),
|
|
212
342
|
path.join(home, 'Documents', 'NapCat'),
|
|
213
343
|
path.join(home, 'Desktop', 'NapCat'),
|
|
214
|
-
'
|
|
215
|
-
'D:\\NapCat',
|
|
344
|
+
path.join(home, 'Downloads', 'NapCat'),
|
|
216
345
|
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'NapCat') : '',
|
|
346
|
+
process.env.ProgramData ? path.join(process.env.ProgramData, 'NapCat') : '',
|
|
217
347
|
process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'NapCat') : '',
|
|
348
|
+
'C:\\Program Files (x86)\\NapCat',
|
|
218
349
|
].filter(Boolean)
|
|
350
|
+
for (const drive of listDrives()) {
|
|
351
|
+
for (const sub of ['NapCat', 'Tools\\NapCat', 'Soft\\NapCat', 'Program Files\\NapCat', 'Program Files (x86)\\NapCat']) {
|
|
352
|
+
roots.push(`${drive}\\${sub}`)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
219
355
|
for (const root of roots) {
|
|
220
356
|
const out = []
|
|
221
357
|
try {
|
|
@@ -233,8 +369,220 @@ function detectShellDir() {
|
|
|
233
369
|
return ''
|
|
234
370
|
}
|
|
235
371
|
|
|
236
|
-
|
|
237
|
-
|
|
372
|
+
/* ---- 分盘深扫:用户点「扫 C 盘 / 扫 D 盘」,边扫边报进度 ------------- */
|
|
373
|
+
/**
|
|
374
|
+
* 为什么要有这个:常见位置只有那么几个,装到「D:\Games\NapCat\OneKey\NapCat.x.Shell」
|
|
375
|
+
* 这种地方的人(包括我自己)永远探测不到。于是给按钮,让人自己选盘、自己点,扫的时候
|
|
376
|
+
* 深度限制 + 目录黑名单 + 时间预算,边扫边把命中的目录吐给界面,命中一个就能直接用。
|
|
377
|
+
*/
|
|
378
|
+
const SCAN_SKIP_NAMES = new Set([
|
|
379
|
+
'windows',
|
|
380
|
+
'$recycle.bin',
|
|
381
|
+
'system volume information',
|
|
382
|
+
'$windows.~bt',
|
|
383
|
+
'$windows.~ws',
|
|
384
|
+
'windows.old',
|
|
385
|
+
'recovery',
|
|
386
|
+
'msocache',
|
|
387
|
+
'perflogs',
|
|
388
|
+
'winsxs',
|
|
389
|
+
'driverstore',
|
|
390
|
+
'windowsapps',
|
|
391
|
+
'node_modules',
|
|
392
|
+
'.git',
|
|
393
|
+
'.svn',
|
|
394
|
+
'.hg',
|
|
395
|
+
'package cache',
|
|
396
|
+
'temp',
|
|
397
|
+
'tmp',
|
|
398
|
+
'cache',
|
|
399
|
+
'.cache',
|
|
400
|
+
'$extend',
|
|
401
|
+
])
|
|
402
|
+
|
|
403
|
+
const scan = {
|
|
404
|
+
running: false,
|
|
405
|
+
drives: [],
|
|
406
|
+
driveIndex: 0,
|
|
407
|
+
drive: '',
|
|
408
|
+
visited: 0,
|
|
409
|
+
found: [],
|
|
410
|
+
startedAt: 0,
|
|
411
|
+
endedAt: 0,
|
|
412
|
+
elapsedMs: 0,
|
|
413
|
+
cancelled: false,
|
|
414
|
+
limitHit: false,
|
|
415
|
+
error: '',
|
|
416
|
+
account: '',
|
|
417
|
+
maxDepth: 7,
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 给界面看的扫描快照 */
|
|
421
|
+
function scanPublic() {
|
|
422
|
+
return {
|
|
423
|
+
running: scan.running,
|
|
424
|
+
drives: scan.drives.slice(),
|
|
425
|
+
drive: scan.drive,
|
|
426
|
+
driveIndex: scan.driveIndex,
|
|
427
|
+
visited: scan.visited,
|
|
428
|
+
maxDepth: scan.maxDepth,
|
|
429
|
+
// 真 Shell 排前面:扫到的顺序是磁盘遍历顺序,壳模板往往比真 Shell 先撞上
|
|
430
|
+
found: scan.found
|
|
431
|
+
.slice()
|
|
432
|
+
.sort((a, b) => Number(b.shell) - Number(a.shell) || String(a.path).localeCompare(String(b.path)))
|
|
433
|
+
.slice(0, 20),
|
|
434
|
+
elapsedMs: scan.running ? Date.now() - scan.startedAt : scan.elapsedMs,
|
|
435
|
+
cancelled: scan.cancelled,
|
|
436
|
+
limitHit: scan.limitHit,
|
|
437
|
+
error: scan.error,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 认一个目录是不是 NapCat Shell:名字像 napcat,或有 NapCatWinBootMain.exe,并且带 .bat */
|
|
442
|
+
function looksLikeShellDirLoose(dir, names) {
|
|
443
|
+
const boot = names.some((n) => /^NapCatWinBootMain\.exe$/i.test(n))
|
|
444
|
+
if (!boot && !/napcat/i.test(path.basename(dir))) return false
|
|
445
|
+
return names.some((n) => /\.bat$/i.test(n))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 真 Shell 的强证据。OneKey 那套会同时留下两种目录:
|
|
450
|
+
* - `bootmain/`:安装器解出来的壳模板,只有 NapCatWinBootMain.exe + 几个 bat,没有 QQ 本体
|
|
451
|
+
* - `NapCat.<版本>.Shell/`:真正的 Shell,有 versions/ 或顶层 QQ.exe(napcat.mjs 在 versions 里)
|
|
452
|
+
* 光看「有 bootmain.exe + 有 bat」分不出这两者,所以再数一遍强证据。
|
|
453
|
+
*/
|
|
454
|
+
function shellEvidence(entries) {
|
|
455
|
+
const evidence = []
|
|
456
|
+
const hasDir = (re) => entries.some((e) => e.isDirectory() && re.test(e.name))
|
|
457
|
+
const hasFile = (re) => entries.some((e) => !e.isDirectory() && re.test(e.name))
|
|
458
|
+
if (hasDir(/^versions$/i)) evidence.push('versions/')
|
|
459
|
+
if (hasFile(/^QQ\.exe$/i)) evidence.push('QQ.exe')
|
|
460
|
+
if (hasFile(/^napcat\.mjs$/i)) evidence.push('napcat.mjs')
|
|
461
|
+
return { shell: evidence.length > 0, evidence }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 扫一个盘。BFS + 深度限制 + 名字黑名单;每 4000 个条目让一次事件循环,
|
|
466
|
+
* 否则设置页那边会因为主线程被 IO 占满而看起来卡死。
|
|
467
|
+
*/
|
|
468
|
+
async function scanOneDrive(drive, { maxDepth = 7, maxDirs = 250000, budgetMs = 90000 } = {}) {
|
|
469
|
+
const root = /[\\/]$/.test(drive) ? drive : `${drive}\\`
|
|
470
|
+
const queue = [{ dir: root, depth: 0 }]
|
|
471
|
+
let head = 0
|
|
472
|
+
let ops = 0
|
|
473
|
+
const started = Date.now()
|
|
474
|
+
scan.drive = drive.replace(/[\\/]+$/, '')
|
|
475
|
+
scan.maxDepth = maxDepth
|
|
476
|
+
while (head < queue.length) {
|
|
477
|
+
if (scan.cancelled) return
|
|
478
|
+
if (scan.visited >= maxDirs || Date.now() - started > budgetMs) {
|
|
479
|
+
scan.limitHit = true
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
const item = queue[head++]
|
|
483
|
+
let entries = []
|
|
484
|
+
try {
|
|
485
|
+
entries = readdirSync(item.dir, { withFileTypes: true })
|
|
486
|
+
} catch {
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
scan.visited += 1
|
|
490
|
+
const names = entries.map((e) => e.name)
|
|
491
|
+
if (looksLikeShellDirLoose(item.dir, names)) {
|
|
492
|
+
const full = item.dir.replace(/\\/g, '/')
|
|
493
|
+
if (!scan.found.some((f) => f.path === full)) {
|
|
494
|
+
const ev = shellEvidence(entries)
|
|
495
|
+
scan.found.push({
|
|
496
|
+
path: full,
|
|
497
|
+
name: path.basename(item.dir),
|
|
498
|
+
launcher: detectLauncher(item.dir, scan.account),
|
|
499
|
+
boot: names.some((n) => /^NapCatWinBootMain\.exe$/i.test(n)),
|
|
500
|
+
shell: ev.shell,
|
|
501
|
+
evidence: ev.evidence,
|
|
502
|
+
drive: String(drive).slice(0, 2),
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
continue // 命中就不再往下钻,免得在 QQ 的几千个文件里打转
|
|
506
|
+
}
|
|
507
|
+
if (item.depth >= maxDepth) continue
|
|
508
|
+
for (const entry of entries) {
|
|
509
|
+
if (!entry.isDirectory()) continue // 符号链接/联接点也别跟,容易绕圈
|
|
510
|
+
if (SCAN_SKIP_NAMES.has(entry.name.toLowerCase())) continue
|
|
511
|
+
queue.push({ dir: path.join(item.dir, entry.name), depth: item.depth + 1 })
|
|
512
|
+
}
|
|
513
|
+
ops += entries.length
|
|
514
|
+
if (ops >= 4000) {
|
|
515
|
+
ops = 0
|
|
516
|
+
scan.visited = scan.visited
|
|
517
|
+
await new Promise((r) => setImmediate(r))
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** 开扫(不 await,立刻返回;进度从 /state 的 scan 段拿) */
|
|
523
|
+
async function startScan(cfg, driveSpec) {
|
|
524
|
+
if (scan.running) {
|
|
525
|
+
return { ok: false, said: `已经在扫 ${scan.drive || scan.drives.join('、')} 了,等我扫完这一轮` }
|
|
526
|
+
}
|
|
527
|
+
const drives = listDrives()
|
|
528
|
+
if (!drives.length) return { ok: false, said: '一个盘都没读出来(权限问题?)' }
|
|
529
|
+
const want = String(driveSpec || '')
|
|
530
|
+
.trim()
|
|
531
|
+
.toUpperCase()
|
|
532
|
+
.replace(/[\\/]/g, '')
|
|
533
|
+
const wantNorm = want && !/[::]$/.test(want) ? `${want.slice(0, 1)}:` : want.replace(':', ':')
|
|
534
|
+
let targets
|
|
535
|
+
if (!want || want === 'ALL') targets = drives.slice()
|
|
536
|
+
else if (drives.includes(wantNorm)) targets = [wantNorm]
|
|
537
|
+
else return { ok: false, said: `本机没有 ${wantNorm || driveSpec} 这个盘(现有:${drives.join(' ')})` }
|
|
538
|
+
|
|
539
|
+
Object.assign(scan, {
|
|
540
|
+
running: true,
|
|
541
|
+
drives: targets,
|
|
542
|
+
driveIndex: 0,
|
|
543
|
+
drive: '',
|
|
544
|
+
visited: 0,
|
|
545
|
+
found: [],
|
|
546
|
+
startedAt: Date.now(),
|
|
547
|
+
endedAt: 0,
|
|
548
|
+
elapsedMs: 0,
|
|
549
|
+
cancelled: false,
|
|
550
|
+
limitHit: false,
|
|
551
|
+
error: '',
|
|
552
|
+
account: (cfg && cfg.launchAccount) || '',
|
|
553
|
+
})
|
|
554
|
+
log(cfg, `scan: start ${targets.join(',')} (maxDepth=${scan.maxDepth})`)
|
|
555
|
+
const budget = targets.length > 1 ? 45000 : 90000
|
|
556
|
+
;(async () => {
|
|
557
|
+
try {
|
|
558
|
+
for (let i = 0; i < targets.length; i++) {
|
|
559
|
+
if (scan.cancelled) break
|
|
560
|
+
scan.driveIndex = i
|
|
561
|
+
await scanOneDrive(targets[i], { budgetMs: budget })
|
|
562
|
+
}
|
|
563
|
+
} catch (error) {
|
|
564
|
+
scan.error = String((error && error.message) || error)
|
|
565
|
+
log(cfg, `scan: error ${scan.error}`)
|
|
566
|
+
} finally {
|
|
567
|
+
scan.running = false
|
|
568
|
+
scan.endedAt = Date.now()
|
|
569
|
+
scan.elapsedMs = scan.endedAt - scan.startedAt
|
|
570
|
+
log(
|
|
571
|
+
cfg,
|
|
572
|
+
`scan: done drives=${scan.drives.join(',')} visited=${scan.visited} found=${scan.found.length}` +
|
|
573
|
+
`${scan.limitHit ? ' limit-hit' : ''}${scan.cancelled ? ' cancelled' : ''}`,
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
})()
|
|
577
|
+
return {
|
|
578
|
+
ok: true,
|
|
579
|
+
said:
|
|
580
|
+
`开始扫 ${targets.join('、')}(深度 ${scan.maxDepth} 层,跳过系统目录,` +
|
|
581
|
+
`单盘最多 ${Math.round(budget / 1000)} 秒,命中就停在那条路径不再往下钻)`,
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** 在 shellDir 里挑启动脚本:先挑「写死了这个 QQ 号」的,其次 quick,其次 napcat*.bat */function detectLauncher(shellDir, account = '') {
|
|
238
586
|
if (!shellDir) return ''
|
|
239
587
|
let files = []
|
|
240
588
|
try {
|
|
@@ -285,6 +633,7 @@ function normalizeConfig(cfg) {
|
|
|
285
633
|
cfg.autoCreateTask = cfg.autoCreateTask !== false && cfg.autoCreateTask !== 'false'
|
|
286
634
|
cfg.shellDir = String(cfg.shellDir || '').trim().replace(/"/g, '')
|
|
287
635
|
cfg.launcherScript = String(cfg.launcherScript || '').trim().replace(/"/g, '')
|
|
636
|
+
cfg.nodePath = String(cfg.nodePath || '').trim().replace(/"/g, '')
|
|
288
637
|
cfg.launchAccount = String(cfg.launchAccount || '').trim().replace(/\D/g, '')
|
|
289
638
|
cfg.launchArgs = String(cfg.launchArgs || '').trim()
|
|
290
639
|
cfg.accessToken = String(cfg.accessToken || '').trim()
|
|
@@ -315,7 +664,7 @@ function resolveConfig(raw) {
|
|
|
315
664
|
// 优先级:内置默认 < cordis 里的 config < 设置界面存下来的 json
|
|
316
665
|
const cfg = { ...DEFAULTS, ...(raw || {}), ...readConfigFile(home) }
|
|
317
666
|
normalizeConfig(cfg)
|
|
318
|
-
|
|
667
|
+
// v2.2.2:shellDir 不再自动探测/自动填,空着就等用户在设置页里扫盘选或者手填
|
|
319
668
|
if (!cfg.launcherScript) cfg.launcherScript = detectLauncher(cfg.shellDir, cfg.launchAccount)
|
|
320
669
|
if (!cfg.logPath) cfg.logPath = path.join(home, 'napcat-keeper.log')
|
|
321
670
|
if (!cfg.launcherLog) cfg.launcherLog = path.join(home, 'napcat-keeper-launch.log')
|
|
@@ -462,7 +811,8 @@ function napcatRoot(cfg) {
|
|
|
462
811
|
|
|
463
812
|
const PS_PROCS =
|
|
464
813
|
"$ErrorActionPreference='SilentlyContinue';" +
|
|
465
|
-
|
|
814
|
+
// node 也要算:无头版(Node 包)只有 node.exe 一条进程,没有 QQ.exe
|
|
815
|
+
"Get-Process QQ,NapCatWinBootMain,node | " +
|
|
466
816
|
'ForEach-Object { $p=$null; try { $p=$_.Path } catch {}; ' +
|
|
467
817
|
'[pscustomobject]@{ n=$_.ProcessName; id=$_.Id; t=[int64]($_.StartTime.ToUniversalTime() - ' +
|
|
468
818
|
'([datetime]::new(1970,1,1,0,0,0,[System.DateTimeKind]::Utc))).TotalMilliseconds; p=$p } } | ' +
|
|
@@ -522,6 +872,11 @@ async function probeProcs(cfg) {
|
|
|
522
872
|
result.chains = clusters.length
|
|
523
873
|
result.clusters = clusters.map((c) => ({ firstMs: c.firstMs, lastMs: c.lastMs, count: c.count }))
|
|
524
874
|
result.dup = result.boot > 1 || clusters.length > 1
|
|
875
|
+
// 顺手喂给设置界面的缓存:看门狗每轮的探测结果直接复用,面板不再另开 PowerShell
|
|
876
|
+
try {
|
|
877
|
+
uiCache.procs = result
|
|
878
|
+
uiCache.procsAt = Date.now()
|
|
879
|
+
} catch {}
|
|
525
880
|
return result
|
|
526
881
|
}
|
|
527
882
|
|
|
@@ -529,7 +884,25 @@ async function probeProcs(cfg) {
|
|
|
529
884
|
function qrInfo(cfg) {
|
|
530
885
|
try {
|
|
531
886
|
const versionsDir = path.join(cfg.shellDir, 'versions')
|
|
532
|
-
if (!existsSync(versionsDir))
|
|
887
|
+
if (!existsSync(versionsDir)) {
|
|
888
|
+
// 无头版(Node 包)没有 versions/ 层:二维码落在 <shellDir>\napcat\cache\qrcode.png
|
|
889
|
+
const direct = path.join(cfg.shellDir, 'napcat', 'cache', 'qrcode.png')
|
|
890
|
+
try {
|
|
891
|
+
const st = statSync(direct)
|
|
892
|
+
const age = Math.round((Date.now() - st.mtimeMs) / 1000)
|
|
893
|
+
let copyDirect = ''
|
|
894
|
+
if (age <= cfg.qrFreshSec) {
|
|
895
|
+
try {
|
|
896
|
+
mkdirSync(path.dirname(cfg.qrCopyPath), { recursive: true })
|
|
897
|
+
copyFileSync(direct, cfg.qrCopyPath)
|
|
898
|
+
copyDirect = cfg.qrCopyPath
|
|
899
|
+
} catch {}
|
|
900
|
+
}
|
|
901
|
+
return { file: direct, ageSec: age, copy: copyDirect }
|
|
902
|
+
} catch {
|
|
903
|
+
return null
|
|
904
|
+
}
|
|
905
|
+
}
|
|
533
906
|
let newest = null
|
|
534
907
|
for (const entry of readdirSync(versionsDir, { withFileTypes: true })) {
|
|
535
908
|
if (!entry.isDirectory()) continue
|
|
@@ -644,6 +1017,31 @@ function resolveLauncherPath(cfg) {
|
|
|
644
1017
|
const asIs = path.isAbsolute(raw) ? raw : path.join(cfg.shellDir || '', raw)
|
|
645
1018
|
if (existsSync(asIs)) return asIs
|
|
646
1019
|
}
|
|
1020
|
+
// Shell 型(顶层有 napcat.mjs):直接用自己的 node 跑,不依赖那台机器装没装 node
|
|
1021
|
+
const standaloneMjs = findStandaloneNapcatMjs(cfg.shellDir)
|
|
1022
|
+
if (standaloneMjs) {
|
|
1023
|
+
const node = resolveNode(cfg)
|
|
1024
|
+
if (node.path) {
|
|
1025
|
+
const file = path.join(dshHome(), 'napcat-autogen-node.bat')
|
|
1026
|
+
const args = String(cfg.launchArgs || '').trim()
|
|
1027
|
+
const body =
|
|
1028
|
+
'@echo off\r\n' +
|
|
1029
|
+
'chcp 65001 >nul\r\n' +
|
|
1030
|
+
'rem dsh-napcat-keeper 自动生成:Shell 型 NapCat,用下面这份 node 跑 napcat.mjs\r\n' +
|
|
1031
|
+
`rem node(${node.source}):${node.path}\r\n` +
|
|
1032
|
+
`cd /d "${cfg.shellDir}"\r\n` +
|
|
1033
|
+
`"${node.path}" "${standaloneMjs}"${args ? ` ${args}` : ''}\r\n`
|
|
1034
|
+
try {
|
|
1035
|
+
writeFileSync(file, body, 'utf8')
|
|
1036
|
+
log(cfg, `autogen node launcher: ${file} (node ${node.source} → ${node.path})`)
|
|
1037
|
+
return file
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
log(cfg, `autogen node launcher 写不出来(${String((error && error.message) || error)}),退回找现成的 bat`)
|
|
1040
|
+
}
|
|
1041
|
+
} else {
|
|
1042
|
+
log(cfg, 'Shell 型 NapCat(找到 napcat.mjs)但一个 node.exe 都没找到 —— 在设置里手填「Node 运行时」')
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
647
1045
|
if (cfg.shellDir) {
|
|
648
1046
|
const guessed = detectLauncher(cfg.shellDir, cfg.launchAccount)
|
|
649
1047
|
if (guessed) return path.join(cfg.shellDir, guessed)
|
|
@@ -689,6 +1087,171 @@ function findBootMain(dir) {
|
|
|
689
1087
|
return ''
|
|
690
1088
|
}
|
|
691
1089
|
|
|
1090
|
+
/* ── Node 运行时:优先用 DSH 自己带的那一份 ──────────────────────────────────
|
|
1091
|
+
*
|
|
1092
|
+
* 为什么要有这一段:插件里凡是「要跑一段 JS」的活,都不该指望系统 PATH 里恰好装过 node,
|
|
1093
|
+
* 更不该去蹭 NapCat / QQ 那边的运行时 —— 那边的 Node 是 QQ.exe 自己的私有环境
|
|
1094
|
+
* (OneKey 注入版的 NapCat 干脆就跑在 `QQ.exe --type=utility --utility-sub-type=node.mojom.NodeService`
|
|
1095
|
+
* 这个进程里,磁盘上根本没有 node.exe 给你调)。而 DSH NEXT 自带一份 node:
|
|
1096
|
+
* <DSH NEXT>\resources\runtime\<arch>\primary-runtime\dependencies\node\bin\node.exe
|
|
1097
|
+
* 用它最稳,版本还跟 DSH 本身一致:只要 DSH 在,node 就在,换机器换人装都不用额外安装。
|
|
1098
|
+
*
|
|
1099
|
+
* 顺序:手填 → DSH 自带(含「当前进程就是 node」)→ 系统 PATH → 常见安装目录 → NapCat 目录兜底。
|
|
1100
|
+
*/
|
|
1101
|
+
|
|
1102
|
+
/** DSH 自带 node 的候选路径(按可能性排序,去重) */
|
|
1103
|
+
function dshBundledNodeCandidates() {
|
|
1104
|
+
const out = []
|
|
1105
|
+
const push = (p) => {
|
|
1106
|
+
const v = String(p || '').trim()
|
|
1107
|
+
if (v && !out.includes(v)) out.push(v)
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const exeName = process.platform === 'win32' ? 'node.exe' : 'node'
|
|
1111
|
+
const rel = (arch) => path.join('runtime', arch, 'primary-runtime', 'dependencies', 'node', 'bin', exeName)
|
|
1112
|
+
|
|
1113
|
+
// ① 插件本身就跑在 DSH 的运行时里(DSH 用 utility process 载入插件时,这个最准)
|
|
1114
|
+
// 注意:只有路径里带 primary-runtime 才算,否则 process.execPath 只是「外面随手起的一个 node」,
|
|
1115
|
+
// 把它当 DSH 自带会指错地方(也把系统 node 冒充成 DSH 的)。
|
|
1116
|
+
const self = String(process.execPath || '')
|
|
1117
|
+
if (/[\\/]primary-runtime[\\/]/i.test(self)) push(self)
|
|
1118
|
+
|
|
1119
|
+
// ② 显式指定(排查时最省事)
|
|
1120
|
+
push(process.env.DSH_NODE_PATH)
|
|
1121
|
+
push(process.env.DSH_NODE)
|
|
1122
|
+
|
|
1123
|
+
// ③ 按 DSH 的安装布局推算:<DSH NEXT>\resources\runtime\<arch>\primary-runtime\dependencies\node\bin\node.exe
|
|
1124
|
+
const roots = []
|
|
1125
|
+
if (process.resourcesPath) roots.push(process.resourcesPath)
|
|
1126
|
+
if (self) {
|
|
1127
|
+
const exeDir = path.dirname(self)
|
|
1128
|
+
roots.push(exeDir)
|
|
1129
|
+
if (/[\\/]resources$/i.test(exeDir)) roots.push(path.join(path.dirname(exeDir), 'resources'))
|
|
1130
|
+
}
|
|
1131
|
+
const archs = [process.arch === 'arm64' ? 'arm64' : process.arch === 'ia32' ? 'ia32' : 'x64']
|
|
1132
|
+
if (archs[0] !== 'x64') archs.push('x64')
|
|
1133
|
+
for (const root of roots) {
|
|
1134
|
+
for (const arch of archs) {
|
|
1135
|
+
push(path.join(root, rel(arch)))
|
|
1136
|
+
push(path.join(root, 'app.asar.unpacked', rel(arch)))
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return out
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function nodeCacheKey(cfg) {
|
|
1143
|
+
return `${(cfg && cfg.nodePath) || ''}|${(cfg && cfg.shellDir) || ''}`
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* 解析出一个能用的 node.exe:{ path, source, tried }。
|
|
1148
|
+
* path 为空表示一个都没找到(source 也为空,tried 里是找过的位置,方便在设置页里说清楚)。
|
|
1149
|
+
* 结果缓存 60 秒:设置页 2 秒轮询一次,不能每次都去 where.exe 一遍。
|
|
1150
|
+
*/
|
|
1151
|
+
function resolveNode(cfg, maxAgeMs = 60000) {
|
|
1152
|
+
const key = nodeCacheKey(cfg)
|
|
1153
|
+
const now = Date.now()
|
|
1154
|
+
if (uiCache.node && uiCache.nodeKey === key && now - (uiCache.nodeAt || 0) < maxAgeMs) {
|
|
1155
|
+
return uiCache.node
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const tried = []
|
|
1159
|
+
const consider = (candidate, source) => {
|
|
1160
|
+
const p = String(candidate || '').trim()
|
|
1161
|
+
if (!p) return null
|
|
1162
|
+
tried.push(`${source}:${p}`)
|
|
1163
|
+
try {
|
|
1164
|
+
if (existsSync(p) && statSync(p).isFile()) return { path: p, source, tried }
|
|
1165
|
+
} catch {}
|
|
1166
|
+
return null
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
let hit = null
|
|
1170
|
+
const manual = String((cfg && cfg.nodePath) || '').trim()
|
|
1171
|
+
if (manual) hit = consider(manual, '手填')
|
|
1172
|
+
|
|
1173
|
+
if (!hit) {
|
|
1174
|
+
for (const candidate of dshBundledNodeCandidates()) {
|
|
1175
|
+
hit = consider(candidate, 'DSH 自带')
|
|
1176
|
+
if (hit) break
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (!hit) {
|
|
1180
|
+
const where = captureSync('where.exe', ['node'], 4000)
|
|
1181
|
+
for (const line of String(where || '').split(/\r?\n/)) {
|
|
1182
|
+
hit = consider(line.trim(), '系统 PATH')
|
|
1183
|
+
if (hit) break
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (!hit) {
|
|
1187
|
+
const common = [
|
|
1188
|
+
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'nodejs', 'node.exe'),
|
|
1189
|
+
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs', 'nodejs', 'node.exe'),
|
|
1190
|
+
'C:\\Program Files\\nodejs\\node.exe',
|
|
1191
|
+
'C:\\Program Files (x86)\\nodejs\\node.exe',
|
|
1192
|
+
]
|
|
1193
|
+
for (const candidate of common) {
|
|
1194
|
+
hit = consider(candidate, '系统安装目录')
|
|
1195
|
+
if (hit) break
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (!hit && cfg && cfg.shellDir) {
|
|
1199
|
+
for (const candidate of [
|
|
1200
|
+
path.join(cfg.shellDir, 'node.exe'),
|
|
1201
|
+
path.join(cfg.shellDir, 'runtime', 'node.exe'),
|
|
1202
|
+
]) {
|
|
1203
|
+
hit = consider(candidate, 'NapCat 目录')
|
|
1204
|
+
if (hit) break
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const result = hit ? { ...hit, tried } : { path: '', source: '', tried }
|
|
1209
|
+
uiCache.node = result
|
|
1210
|
+
uiCache.nodeKey = key
|
|
1211
|
+
uiCache.nodeAt = now
|
|
1212
|
+
return result
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** node -v 的版本号(缓存 10 分钟;这是个要起进程的操作) */
|
|
1216
|
+
function nodeVersion(nodeExe) {
|
|
1217
|
+
const p = String(nodeExe || '').trim()
|
|
1218
|
+
if (!p) return ''
|
|
1219
|
+
if (uiCache.nodeVersion && uiCache.nodeVersionPath === p && Date.now() - (uiCache.nodeVersionAt || 0) < 600000) {
|
|
1220
|
+
return uiCache.nodeVersion
|
|
1221
|
+
}
|
|
1222
|
+
const raw = captureSync(p, ['-v'], 5000)
|
|
1223
|
+
const version = String(raw || '').trim().split(/\s+/)[0] || ''
|
|
1224
|
+
uiCache.nodeVersion = version
|
|
1225
|
+
uiCache.nodeVersionPath = p
|
|
1226
|
+
uiCache.nodeVersionAt = Date.now()
|
|
1227
|
+
return version
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** 给设置页 / 状态接口看的 node 摘要 */
|
|
1231
|
+
function nodePublic(cfg) {
|
|
1232
|
+
const found = resolveNode(cfg)
|
|
1233
|
+
return {
|
|
1234
|
+
path: found.path,
|
|
1235
|
+
source: found.source,
|
|
1236
|
+
version: found.path ? nodeVersion(found.path) : '',
|
|
1237
|
+
tried: found.tried || [],
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Shell 型 NapCat 的入口:只认**顶层**的 napcat.mjs。
|
|
1243
|
+
* 注入版把 napcat.mjs 塞在 versions\<版本>\resources\app\napcat\ 下面 ——
|
|
1244
|
+
* 那份是等着被 QQ 注入的,用普通 node 去跑只会报找不到 QQ 的原生模块,绝对不能碰。
|
|
1245
|
+
*/
|
|
1246
|
+
function findStandaloneNapcatMjs(dir) {
|
|
1247
|
+
if (!dir) return ''
|
|
1248
|
+
const direct = path.join(dir, 'napcat.mjs')
|
|
1249
|
+
try {
|
|
1250
|
+
if (existsSync(direct) && statSync(direct).isFile()) return direct
|
|
1251
|
+
} catch {}
|
|
1252
|
+
return ''
|
|
1253
|
+
}
|
|
1254
|
+
|
|
692
1255
|
/** 无窗口壳 .vbs 的路径(跟日志放一起) */
|
|
693
1256
|
function taskWrapperPath(cfg) {
|
|
694
1257
|
const dir = path.dirname(cfg.launcherLog || path.join(dshHome(), 'napcat-keeper-launch.log'))
|
|
@@ -721,6 +1284,19 @@ function batValue(value) {
|
|
|
721
1284
|
.replace(/%/g, '%%')
|
|
722
1285
|
}
|
|
723
1286
|
|
|
1287
|
+
/** 中间层 bat 的内容(writeEnvBat 与「提权交接」脚本共用,保证两边一模一样) */
|
|
1288
|
+
function envBatLines(cfg, target) {
|
|
1289
|
+
return [
|
|
1290
|
+
'@echo off',
|
|
1291
|
+
'rem dsh-napcat-keeper: 自动生成,改了也会被覆盖;关掉「记住密码」这个文件会被删掉',
|
|
1292
|
+
`set "ACCOUNT=${batValue(cfg.launchAccount)}"`,
|
|
1293
|
+
`set "NAPCAT_QUICK_PASSWORD=${batValue(cfg.quickPassword)}"`,
|
|
1294
|
+
`set "NAPCAT_QUICK_PASSWORD_MD5=${md5Hex(cfg.quickPassword)}"`,
|
|
1295
|
+
`call "${target}"${cfg.launchArgs ? ` ${cfg.launchArgs}` : ''}`,
|
|
1296
|
+
'',
|
|
1297
|
+
]
|
|
1298
|
+
}
|
|
1299
|
+
|
|
724
1300
|
/**
|
|
725
1301
|
* 写「带密码」的启动壳。
|
|
726
1302
|
* 为什么不直接改用户的 napcat.quick2.bat:那是人家自己的文件,插件只生成自己的中间层,
|
|
@@ -730,15 +1306,7 @@ function writeEnvBat(cfg, bat) {
|
|
|
730
1306
|
const target = bat || resolveLauncherPath(cfg)
|
|
731
1307
|
if (!passwordActive(cfg) || !target) return ''
|
|
732
1308
|
const file = envBatPath(cfg)
|
|
733
|
-
const lines =
|
|
734
|
-
'@echo off',
|
|
735
|
-
'rem dsh-napcat-keeper: 自动生成,改了也会被覆盖;关掉「记住密码」这个文件会被删掉',
|
|
736
|
-
`set "ACCOUNT=${batValue(cfg.launchAccount)}"`,
|
|
737
|
-
`set "NAPCAT_QUICK_PASSWORD=${batValue(cfg.quickPassword)}"`,
|
|
738
|
-
`set "NAPCAT_QUICK_PASSWORD_MD5=${md5Hex(cfg.quickPassword)}"`,
|
|
739
|
-
`call "${target}"${cfg.launchArgs ? ` ${cfg.launchArgs}` : ''}`,
|
|
740
|
-
'',
|
|
741
|
-
]
|
|
1309
|
+
const lines = envBatLines(cfg, target)
|
|
742
1310
|
try {
|
|
743
1311
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
744
1312
|
writeFileSync(file, lines.join('\r\n'), 'utf8')
|
|
@@ -850,6 +1418,632 @@ function ensureTask(cfg) {
|
|
|
850
1418
|
return { ok: true, created: true }
|
|
851
1419
|
}
|
|
852
1420
|
|
|
1421
|
+
/* ── 提权交接:插件不自己去要管理员,改成给你一个双击就行的脚本 ──────────────
|
|
1422
|
+
*
|
|
1423
|
+
* 为什么要这样:DSH 自己是普通权限进程。插件偶尔要做几件「可能要管理员」的事
|
|
1424
|
+
* (往 Program Files 里的 NapCat 目录补启动脚本、建计划任务、给 OneBot 端口开防火墙),
|
|
1425
|
+
* 而插件既不该、也没法替人按下 UAC 里那个「是」。
|
|
1426
|
+
*
|
|
1427
|
+
* 所以改成这样:
|
|
1428
|
+
* 1. 插件把要做的每一步写进 plan.json(步骤由插件自己生成,来源可信);
|
|
1429
|
+
* 2. 生成 apply.ps1:先数一遍「这些步骤里有没有真需要管理员的」,
|
|
1430
|
+
* 没有就直接干;有才 Start-Process -Verb RunAs 自我提权 ——
|
|
1431
|
+
* UAC 弹窗就是用户亲手点的那一下确认;
|
|
1432
|
+
* 3. 生成 apply-admin.cmd:双击入口,省得用户去右键「以管理员身份运行」;
|
|
1433
|
+
* 4. 每步结果写 result.json,设置页读回来说清楚哪条成了、哪条没成;
|
|
1434
|
+
* 5. 另外给一个 undo-admin.cmd,反着把插件建过的东西删掉。
|
|
1435
|
+
*
|
|
1436
|
+
* 所有产物只在 ~/.dsh/napcat-keeper-setup 一个目录里,整个删掉就等于撤回。
|
|
1437
|
+
*/
|
|
1438
|
+
|
|
1439
|
+
const SETUP_NEEDS = {
|
|
1440
|
+
launcher: {
|
|
1441
|
+
label: '补一个启动脚本',
|
|
1442
|
+
hint: '往 Shell 目录写 napcat.quick.auto.bat,直接调 NapCatWinBootMain.exe(目录里没有别的 bat 时才需要)',
|
|
1443
|
+
},
|
|
1444
|
+
envbat: {
|
|
1445
|
+
label: '写「记住密码」中间层',
|
|
1446
|
+
hint: 'napcat-keeper-env.bat:账号与密码走环境变量交给 NapCat,密码只落这一个文件',
|
|
1447
|
+
},
|
|
1448
|
+
task: {
|
|
1449
|
+
label: '建 / 重建计划任务',
|
|
1450
|
+
hint: '开机不靠 DSH 也能拉起 QQ;用户级任务,通常不需要管理员',
|
|
1451
|
+
},
|
|
1452
|
+
firewall: {
|
|
1453
|
+
label: '放行 OneBot 端口(入站)',
|
|
1454
|
+
hint: '别的设备要连这个端口时才需要;这条一定会弹 UAC',
|
|
1455
|
+
},
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function setupPaths() {
|
|
1459
|
+
const dir = path.join(dshHome(), 'napcat-keeper-setup')
|
|
1460
|
+
return {
|
|
1461
|
+
dir,
|
|
1462
|
+
ps1: path.join(dir, 'apply.ps1'),
|
|
1463
|
+
cmd: path.join(dir, 'apply-admin.cmd'),
|
|
1464
|
+
undoCmd: path.join(dir, 'undo-admin.cmd'),
|
|
1465
|
+
plan: path.join(dir, 'plan.json'),
|
|
1466
|
+
result: path.join(dir, 'result.json'),
|
|
1467
|
+
runLog: path.join(dir, 'run.log'),
|
|
1468
|
+
readme: path.join(dir, 'README.txt'),
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** 目录能不能写:自己不存在就往上看,找一个存在的祖先试(免得把「没建过」当成「不可写」) */
|
|
1473
|
+
function dirWritable(dir) {
|
|
1474
|
+
if (!dir) return false
|
|
1475
|
+
let probe = dir
|
|
1476
|
+
for (let i = 0; i < 8; i += 1) {
|
|
1477
|
+
if (existsSync(probe)) {
|
|
1478
|
+
try {
|
|
1479
|
+
accessSync(probe, fsConstants.W_OK)
|
|
1480
|
+
return true
|
|
1481
|
+
} catch {
|
|
1482
|
+
return false
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
const up = path.dirname(probe)
|
|
1486
|
+
if (up === probe || !up) break
|
|
1487
|
+
probe = up
|
|
1488
|
+
}
|
|
1489
|
+
return false
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
/** findBootMain 会递归翻目录,设置页每次刷新都调太贵 —— 缓存 60 秒,换了 Shell 目录就重算 */
|
|
1493
|
+
function cachedBootMain(cfg, maxAgeMs = 60000) {
|
|
1494
|
+
const key = cfg.shellDir || ''
|
|
1495
|
+
const now = Date.now()
|
|
1496
|
+
if (
|
|
1497
|
+
uiCache.bootMain !== undefined &&
|
|
1498
|
+
uiCache.bootMainKey === key &&
|
|
1499
|
+
now - (uiCache.bootMainAt || 0) < maxAgeMs
|
|
1500
|
+
) {
|
|
1501
|
+
return uiCache.bootMain
|
|
1502
|
+
}
|
|
1503
|
+
let found = ''
|
|
1504
|
+
if (key) {
|
|
1505
|
+
try {
|
|
1506
|
+
found = findBootMain(key)
|
|
1507
|
+
} catch {
|
|
1508
|
+
found = ''
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
uiCache.bootMain = found
|
|
1512
|
+
uiCache.bootMainKey = key
|
|
1513
|
+
uiCache.bootMainAt = now
|
|
1514
|
+
return found
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** 没有别的启动脚本时用的那个 bat:直接调 NapCatWinBootMain.exe */
|
|
1518
|
+
function launcherBatLines(cfg, boot) {
|
|
1519
|
+
const account = batValue(cfg.launchAccount)
|
|
1520
|
+
const args = String(cfg.launchArgs || '').trim()
|
|
1521
|
+
return [
|
|
1522
|
+
'@echo off',
|
|
1523
|
+
'chcp 65001 >nul',
|
|
1524
|
+
'rem dsh-napcat-keeper 自动生成:目录里没有别的启动脚本时才用它,直接调 NapCatWinBootMain.exe',
|
|
1525
|
+
'cd /d "%~dp0"',
|
|
1526
|
+
`set "ACCOUNT=${account}"`,
|
|
1527
|
+
`start "" "${boot}"${account ? ` ${account}` : ''}${args ? ` ${args}` : ''}`,
|
|
1528
|
+
'',
|
|
1529
|
+
]
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** 任务里那条命令(纯字符串版本:不写盘,供「脚本」复用 taskCommandLine 的规则) */
|
|
1533
|
+
function taskCommandLinePure(cfg, bat) {
|
|
1534
|
+
const target = passwordActive(cfg) && bat ? envBatPath(cfg) : bat
|
|
1535
|
+
return (
|
|
1536
|
+
`cmd.exe /c chcp 65001 >nul & cd /d "${cfg.shellDir}" & call "${target}" ` +
|
|
1537
|
+
`>> "${cfg.launcherLog}" 2>&1`
|
|
1538
|
+
)
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/** 给设置页看的清单:每个需要能不能做、要不要管理员 */
|
|
1542
|
+
function needCatalog(cfg) {
|
|
1543
|
+
const boot = (() => {
|
|
1544
|
+
try {
|
|
1545
|
+
return cachedBootMain(cfg)
|
|
1546
|
+
} catch {
|
|
1547
|
+
return ''
|
|
1548
|
+
}
|
|
1549
|
+
})()
|
|
1550
|
+
const bat = resolveLauncherPath(cfg)
|
|
1551
|
+
return Object.entries(SETUP_NEEDS).map(([id, meta]) => {
|
|
1552
|
+
let ready = true
|
|
1553
|
+
let why = ''
|
|
1554
|
+
let needAdmin = false
|
|
1555
|
+
if (id === 'launcher') {
|
|
1556
|
+
ready = Boolean(boot)
|
|
1557
|
+
if (!boot) why = 'Shell 目录里没找到 NapCatWinBootMain.exe'
|
|
1558
|
+
needAdmin = Boolean(boot) && !dirWritable(cfg.shellDir)
|
|
1559
|
+
if (needAdmin) why = 'Shell 目录当前不可写(大概在 Program Files 里,要管理员)'
|
|
1560
|
+
} else if (id === 'envbat') {
|
|
1561
|
+
ready = passwordActive(cfg) && Boolean(bat)
|
|
1562
|
+
if (!passwordActive(cfg)) why = '先打开「记住密码」并填好账号'
|
|
1563
|
+
else if (!bat) why = '先在 Shell 目录里找到启动脚本'
|
|
1564
|
+
needAdmin = Boolean(ready) && !dirWritable(path.dirname(envBatPath(cfg)))
|
|
1565
|
+
} else if (id === 'task') {
|
|
1566
|
+
ready = Boolean(cfg.taskName) && Boolean(bat)
|
|
1567
|
+
if (!cfg.taskName) why = '先填计划任务名'
|
|
1568
|
+
else if (!bat) why = '先填 Shell 目录(找得到启动脚本才建任务)'
|
|
1569
|
+
} else if (id === 'firewall') {
|
|
1570
|
+
ready = Boolean(cfg.onebotPort)
|
|
1571
|
+
if (!ready) why = '先填 OneBot 端口'
|
|
1572
|
+
needAdmin = true
|
|
1573
|
+
}
|
|
1574
|
+
return { id, label: meta.label, hint: meta.hint, ready, why, needAdmin }
|
|
1575
|
+
})
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** 把「要做的事」摊成步骤数组(plan.json 里的 steps) */
|
|
1579
|
+
function buildSetupSteps(cfg, wants) {
|
|
1580
|
+
const picked = Array.isArray(wants) && wants.length ? wants : Object.keys(SETUP_NEEDS)
|
|
1581
|
+
const want = new Set(picked.filter((id) => SETUP_NEEDS[id]))
|
|
1582
|
+
const steps = []
|
|
1583
|
+
const bat = resolveLauncherPath(cfg)
|
|
1584
|
+
|
|
1585
|
+
if (want.has('launcher')) {
|
|
1586
|
+
const boot = (() => {
|
|
1587
|
+
try {
|
|
1588
|
+
return cachedBootMain(cfg)
|
|
1589
|
+
} catch {
|
|
1590
|
+
return ''
|
|
1591
|
+
}
|
|
1592
|
+
})()
|
|
1593
|
+
if (boot) {
|
|
1594
|
+
const file = path.join(cfg.shellDir, 'napcat.quick.auto.bat')
|
|
1595
|
+
steps.push({
|
|
1596
|
+
id: 'launcher',
|
|
1597
|
+
label: '补启动脚本 napcat.quick.auto.bat',
|
|
1598
|
+
kind: 'writeFile',
|
|
1599
|
+
path: file,
|
|
1600
|
+
admin: !dirWritable(cfg.shellDir),
|
|
1601
|
+
note: '直接调 NapCatWinBootMain.exe,账号走环境变量',
|
|
1602
|
+
contentBase64: Buffer.from(launcherBatLines(cfg, boot).join('\r\n'), 'utf8').toString('base64'),
|
|
1603
|
+
})
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (want.has('envbat') && passwordActive(cfg) && bat) {
|
|
1608
|
+
const file = envBatPath(cfg)
|
|
1609
|
+
steps.push({
|
|
1610
|
+
id: 'envbat',
|
|
1611
|
+
label: '写「记住密码」中间层 napcat-keeper-env.bat',
|
|
1612
|
+
kind: 'writeFile',
|
|
1613
|
+
path: file,
|
|
1614
|
+
admin: !dirWritable(path.dirname(file)),
|
|
1615
|
+
note: '只在本机磁盘里,关掉「记住密码」或点「清除密码」就删',
|
|
1616
|
+
secret: true,
|
|
1617
|
+
contentBase64: Buffer.from(envBatLines(cfg, bat).join('\r\n'), 'utf8').toString('base64'),
|
|
1618
|
+
})
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (want.has('task') && cfg.taskName && bat) {
|
|
1622
|
+
const requires = []
|
|
1623
|
+
let inner = ''
|
|
1624
|
+
if (cfg.hideConsole !== false) {
|
|
1625
|
+
const vbs = taskWrapperPath(cfg)
|
|
1626
|
+
const cmdLine = taskCommandLinePure(cfg, bat).replace(/"/g, '""')
|
|
1627
|
+
const body = [
|
|
1628
|
+
'Option Explicit',
|
|
1629
|
+
'Dim sh',
|
|
1630
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
1631
|
+
`sh.Run "${cmdLine}", 0, False`,
|
|
1632
|
+
'',
|
|
1633
|
+
].join('\r\n')
|
|
1634
|
+
steps.push({
|
|
1635
|
+
id: 'task-wrapper',
|
|
1636
|
+
label: '写无窗口壳 napcat-keeper-launch.vbs',
|
|
1637
|
+
kind: 'writeFile',
|
|
1638
|
+
path: vbs,
|
|
1639
|
+
admin: !dirWritable(path.dirname(vbs)),
|
|
1640
|
+
note: '计划任务跑它,桌面才不会挂一个黑框',
|
|
1641
|
+
contentBase64: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(body, 'utf16le')]).toString(
|
|
1642
|
+
'base64',
|
|
1643
|
+
),
|
|
1644
|
+
})
|
|
1645
|
+
requires.push('task-wrapper')
|
|
1646
|
+
inner = `wscript.exe "${vbs}"`
|
|
1647
|
+
} else {
|
|
1648
|
+
inner = taskCommandLinePure(cfg, bat)
|
|
1649
|
+
}
|
|
1650
|
+
if (passwordActive(cfg)) requires.push('envbat')
|
|
1651
|
+
steps.push({
|
|
1652
|
+
id: 'task',
|
|
1653
|
+
label: `建计划任务「${cfg.taskName}」`,
|
|
1654
|
+
kind: 'scheduledTask',
|
|
1655
|
+
admin: false,
|
|
1656
|
+
note: '用户级任务,一般不需要管理员;系统策略挡着时脚本会把错误原样告诉你',
|
|
1657
|
+
requires,
|
|
1658
|
+
name: cfg.taskName,
|
|
1659
|
+
command: inner,
|
|
1660
|
+
})
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if (want.has('firewall') && cfg.onebotPort) {
|
|
1664
|
+
const port = Number(cfg.onebotPort) || 3000
|
|
1665
|
+
steps.push({
|
|
1666
|
+
id: 'firewall',
|
|
1667
|
+
label: `放行 TCP ${port} 入站`,
|
|
1668
|
+
kind: 'firewallRule',
|
|
1669
|
+
admin: true,
|
|
1670
|
+
note: '只有别的设备要连 OneBot 端口时才需要;这条一定会弹 UAC',
|
|
1671
|
+
name: `dsh-napcat-keeper OneBot ${port}`,
|
|
1672
|
+
port,
|
|
1673
|
+
})
|
|
1674
|
+
}
|
|
1675
|
+
return steps
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** apply.ps1 的全文:这段是插件的固定产物,跟 plan.json 一起决定脚本会干什么 */
|
|
1679
|
+
function applyPs1Source() {
|
|
1680
|
+
return `# dsh-napcat-keeper 提权交接脚本(插件自动生成,改了也会被覆盖)
|
|
1681
|
+
# 双击同目录的 apply-admin.cmd 干活;想撤干净就双击 undo-admin.cmd。
|
|
1682
|
+
param([switch]$Elevated, [switch]$Undo)
|
|
1683
|
+
$ErrorActionPreference = 'Continue'
|
|
1684
|
+
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
1685
|
+
$planPath = Join-Path $here 'plan.json'
|
|
1686
|
+
$resultPath = Join-Path $here 'result.json'
|
|
1687
|
+
$runLog = Join-Path $here 'run.log'
|
|
1688
|
+
# run.log 只追加不滚动会长到没人看:超过 256KB 就换新的一份
|
|
1689
|
+
try {
|
|
1690
|
+
if ((Test-Path -LiteralPath $runLog) -and ((Get-Item -LiteralPath $runLog).Length -gt 262144)) {
|
|
1691
|
+
Remove-Item -LiteralPath $runLog -Force -ErrorAction SilentlyContinue
|
|
1692
|
+
}
|
|
1693
|
+
} catch {}
|
|
1694
|
+
|
|
1695
|
+
function Say($text) {
|
|
1696
|
+
$line = ('[{0}] {1}' -f (Get-Date -Format 'HH:mm:ss'), $text)
|
|
1697
|
+
Write-Host $line
|
|
1698
|
+
try { Add-Content -LiteralPath $runLog -Value $line -Encoding UTF8 } catch {}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function Wait-Key {
|
|
1702
|
+
if ($env:NPK_NO_PAUSE -eq '1') { return }
|
|
1703
|
+
Write-Host ''
|
|
1704
|
+
Write-Host '按任意键关掉这个窗口。'
|
|
1705
|
+
try { $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } catch {}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (-not (Test-Path -LiteralPath $planPath)) {
|
|
1709
|
+
Say 'plan.json 不在了 —— 回 DSH 设置页重新生成一次。'
|
|
1710
|
+
Wait-Key
|
|
1711
|
+
exit 2
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
$plan = Get-Content -LiteralPath $planPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
1715
|
+
$steps = @($plan.steps)
|
|
1716
|
+
$mode = 'apply'
|
|
1717
|
+
if ($Undo) {
|
|
1718
|
+
$mode = 'undo'
|
|
1719
|
+
$rev = @()
|
|
1720
|
+
for ($i = $steps.Count - 1; $i -ge 0; $i--) { $rev += $steps[$i] }
|
|
1721
|
+
$steps = $rev
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
Say ('开始(' + $mode + '):' + $steps.Count + ' 步')
|
|
1725
|
+
|
|
1726
|
+
$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
1727
|
+
$needAdmin = @($steps | Where-Object { $_.admin -eq $true }).Count -gt 0
|
|
1728
|
+
|
|
1729
|
+
if ((!$Undo) -and $needAdmin -and (-not $admin) -and (-not $Elevated)) {
|
|
1730
|
+
Say '这批步骤里有要管理员才行的,马上弹 UAC —— 请点「是」;点「否」就什么都不改。'
|
|
1731
|
+
$argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $PSCommandPath + '"'), '-Elevated')
|
|
1732
|
+
try {
|
|
1733
|
+
Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $argList
|
|
1734
|
+
Say '已经把活交给提权那个窗口了,跑完看同目录的 result.json。'
|
|
1735
|
+
} catch {
|
|
1736
|
+
Say '没拿到管理员权限(UAC 被拒或这台机器不给提权),这次什么都没改。'
|
|
1737
|
+
}
|
|
1738
|
+
Wait-Key
|
|
1739
|
+
exit 0
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
$results = @()
|
|
1743
|
+
$failed = @{}
|
|
1744
|
+
foreach ($s in $steps) {
|
|
1745
|
+
$entry = [ordered]@{ id = $s.id; label = $s.label; ok = $false; skipped = $false; message = '' }
|
|
1746
|
+
if ((!$Undo) -and $s.requires) {
|
|
1747
|
+
$miss = @($s.requires | Where-Object { $failed.ContainsKey($_) })
|
|
1748
|
+
if ($miss.Count -gt 0) {
|
|
1749
|
+
$entry.skipped = $true
|
|
1750
|
+
$entry.message = '前一步没成(' + ($miss -join '、') + '),这步跳过'
|
|
1751
|
+
Say ('跳过 ' + $s.label + ':' + $entry.message)
|
|
1752
|
+
$results += $entry
|
|
1753
|
+
continue
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
try {
|
|
1757
|
+
switch ($s.kind) {
|
|
1758
|
+
'writeFile' {
|
|
1759
|
+
if ($Undo) {
|
|
1760
|
+
if (Test-Path -LiteralPath $s.path) {
|
|
1761
|
+
Remove-Item -LiteralPath $s.path -Force
|
|
1762
|
+
$entry.message = '已删除'
|
|
1763
|
+
} else {
|
|
1764
|
+
$entry.message = '本来就没有'
|
|
1765
|
+
}
|
|
1766
|
+
} else {
|
|
1767
|
+
$dir = Split-Path -Parent $s.path
|
|
1768
|
+
if ($dir -and (-not (Test-Path -LiteralPath $dir))) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
|
1769
|
+
$bytes = [Convert]::FromBase64String($s.contentBase64)
|
|
1770
|
+
[IO.File]::WriteAllBytes($s.path, $bytes)
|
|
1771
|
+
if ($s.secret) { $entry.message = '已写入(内容不外显)' } else { $entry.message = '已写入' }
|
|
1772
|
+
}
|
|
1773
|
+
$entry.ok = $true
|
|
1774
|
+
}
|
|
1775
|
+
'mkdir' {
|
|
1776
|
+
if (-not $Undo) {
|
|
1777
|
+
New-Item -ItemType Directory -Force -Path $s.path | Out-Null
|
|
1778
|
+
$entry.message = '已建目录'
|
|
1779
|
+
} else {
|
|
1780
|
+
$entry.message = '不用撤'
|
|
1781
|
+
}
|
|
1782
|
+
$entry.ok = $true
|
|
1783
|
+
}
|
|
1784
|
+
'scheduledTask' {
|
|
1785
|
+
if ($Undo) {
|
|
1786
|
+
$null = & schtasks.exe /delete /tn $s.name /f 2>&1
|
|
1787
|
+
$entry.message = '任务已删(本来没有也算)'
|
|
1788
|
+
} else {
|
|
1789
|
+
# 先结束同名旧任务(正在跑时 /f 覆盖会被拒);/st 00:00 会带一句
|
|
1790
|
+
# 「时间已过」的自我提醒,成功时不必让用户看见,失败时才把原始输出抛出来
|
|
1791
|
+
$null = & schtasks.exe /end /tn $s.name 2>&1
|
|
1792
|
+
$createOut = (& schtasks.exe /create /tn $s.name /tr $s.command /sc ONCE /st 00:00 /f 2>&1 | Out-String)
|
|
1793
|
+
if ($LASTEXITCODE -ne 0) { throw ('schtasks 失败(退出码 ' + $LASTEXITCODE + '):' + $createOut.Trim()) }
|
|
1794
|
+
$entry.message = '任务已就绪'
|
|
1795
|
+
}
|
|
1796
|
+
$entry.ok = $true
|
|
1797
|
+
}
|
|
1798
|
+
'firewallRule' {
|
|
1799
|
+
if ($Undo) {
|
|
1800
|
+
Get-NetFirewallRule -DisplayName $s.name -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
|
1801
|
+
$entry.message = '规则已删(本来没有也算)'
|
|
1802
|
+
} else {
|
|
1803
|
+
Get-NetFirewallRule -DisplayName $s.name -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue
|
|
1804
|
+
New-NetFirewallRule -DisplayName $s.name -Direction Inbound -Action Allow -Protocol TCP -LocalPort ([int]$s.port) -Profile Private, Domain -ErrorAction Stop | Out-Null
|
|
1805
|
+
$entry.message = ('已放行 TCP ' + [int]$s.port)
|
|
1806
|
+
}
|
|
1807
|
+
$entry.ok = $true
|
|
1808
|
+
}
|
|
1809
|
+
default {
|
|
1810
|
+
$entry.message = ('不认识的步骤类型 ' + $s.kind)
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
} catch {
|
|
1814
|
+
$entry.ok = $false
|
|
1815
|
+
$entry.message = '出错:' + $_.Exception.Message
|
|
1816
|
+
}
|
|
1817
|
+
if (-not $entry.ok) { $failed[$s.id] = $true }
|
|
1818
|
+
if ($entry.ok) { Say ('好了 ' + $s.label) } else { Say ('没成 ' + $s.label + ':' + $entry.message) }
|
|
1819
|
+
$results += $entry
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
$bad = @($results | Where-Object { -not $_.ok })
|
|
1823
|
+
$payload = [ordered]@{
|
|
1824
|
+
at = (Get-Date).ToString('o')
|
|
1825
|
+
mode = $mode
|
|
1826
|
+
elevated = $admin
|
|
1827
|
+
adminNeeded = $needAdmin
|
|
1828
|
+
ok = ($bad.Count -eq 0)
|
|
1829
|
+
results = $results
|
|
1830
|
+
}
|
|
1831
|
+
try {
|
|
1832
|
+
$json = $payload | ConvertTo-Json -Depth 8
|
|
1833
|
+
[IO.File]::WriteAllText($resultPath, $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
1834
|
+
Say ('收工:' + $bad.Count + ' 步没成;结果已写回 result.json,回 DSH 设置页点「读结果」就行。')
|
|
1835
|
+
} catch {
|
|
1836
|
+
Say ('结果没写出来:' + $_.Exception.Message)
|
|
1837
|
+
}
|
|
1838
|
+
Wait-Key
|
|
1839
|
+
`
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** 双击入口 .cmd:内容全 ASCII,cmd 才不会把它显示成乱码 */
|
|
1843
|
+
function setupCmdSource() {
|
|
1844
|
+
return [
|
|
1845
|
+
'@echo off',
|
|
1846
|
+
'rem dsh-napcat-keeper - elevation handoff (auto-generated; edits get overwritten)',
|
|
1847
|
+
'rem Double-click this file. PowerShell asks for admin only if the plan really needs it.',
|
|
1848
|
+
'title dsh-napcat-keeper setup',
|
|
1849
|
+
'cd /d "%~dp0"',
|
|
1850
|
+
'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0apply.ps1"',
|
|
1851
|
+
'if errorlevel 1 pause',
|
|
1852
|
+
'',
|
|
1853
|
+
].join('\r\n')
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
function setupUndoCmdSource() {
|
|
1857
|
+
return [
|
|
1858
|
+
'@echo off',
|
|
1859
|
+
'rem dsh-napcat-keeper - undo what this plan created (reverse order)',
|
|
1860
|
+
'title dsh-napcat-keeper undo',
|
|
1861
|
+
'cd /d "%~dp0"',
|
|
1862
|
+
'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0apply.ps1" -Undo',
|
|
1863
|
+
'if errorlevel 1 pause',
|
|
1864
|
+
'',
|
|
1865
|
+
].join('\r\n')
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** 说明文件:给人看的,写清楚会动哪些文件、怎么撤回 */
|
|
1869
|
+
function setupReadmeText(cfg, plan) {
|
|
1870
|
+
const lines = [
|
|
1871
|
+
'dsh-napcat-keeper 提权交接目录',
|
|
1872
|
+
'',
|
|
1873
|
+
'这是什么:',
|
|
1874
|
+
' DSH 自己是普通权限进程,插件偶尔要做几件「可能要管理员」的事。',
|
|
1875
|
+
' 插件不自己去要管理员,而是把要做的事写在这里,由你双击一下、在 UAC 上点「是」。',
|
|
1876
|
+
'',
|
|
1877
|
+
'怎么用:',
|
|
1878
|
+
' 1) 双击 apply-admin.cmd;',
|
|
1879
|
+
' 2) 如果这批步骤需要管理员,会弹 UAC —— 点「是」;点「否」就什么都不会改;',
|
|
1880
|
+
' 3) 跑完回 DSH 设置页点「读结果」,能看到每一步成没成。',
|
|
1881
|
+
'',
|
|
1882
|
+
'想撤回:双击 undo-admin.cmd(按相反顺序把这次建的东西删掉)。',
|
|
1883
|
+
'',
|
|
1884
|
+
`生成时间:${plan.createdAt}`,
|
|
1885
|
+
`Shell 目录:${cfg.shellDir || '(未配置)'}`,
|
|
1886
|
+
`这批步骤${plan.needAdmin ? '需要' : '不需要'}管理员权限。`,
|
|
1887
|
+
'',
|
|
1888
|
+
'会动的文件 / 规则:',
|
|
1889
|
+
...plan.steps.map((s, i) => {
|
|
1890
|
+
const where = s.path || s.name || s.kind
|
|
1891
|
+
return ` ${i + 1}. [${s.kind}] ${where}${s.admin ? '(要管理员)' : ''}`
|
|
1892
|
+
}),
|
|
1893
|
+
'',
|
|
1894
|
+
'这几个文件里没有密码明文说明:napcat-keeper-env.bat 里有(那是给 NapCat 用的),',
|
|
1895
|
+
'所以这个目录别往外发;关掉「记住密码」或在设置页点「清除密码」就会删掉它。',
|
|
1896
|
+
'',
|
|
1897
|
+
]
|
|
1898
|
+
return lines.join('\r\n')
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/** 生成整套脚本,返回给设置页用的摘要(不含任何 secret 内容) */
|
|
1902
|
+
function writeSetupBundle(cfg, wants) {
|
|
1903
|
+
if (process.platform !== 'win32') {
|
|
1904
|
+
return { ok: false, error: '这套脚本只对 Windows 有意义' }
|
|
1905
|
+
}
|
|
1906
|
+
const steps = buildSetupSteps(cfg, wants)
|
|
1907
|
+
if (!steps.length) {
|
|
1908
|
+
return { ok: false, error: '这次一条可做的步骤都没有(先把 Shell 目录 / 账号 / 任务名填上)' }
|
|
1909
|
+
}
|
|
1910
|
+
const p = setupPaths()
|
|
1911
|
+
const plan = {
|
|
1912
|
+
plugin: 'dsh-napcat-keeper',
|
|
1913
|
+
version,
|
|
1914
|
+
createdAt: new Date().toISOString(),
|
|
1915
|
+
shellDir: cfg.shellDir || '',
|
|
1916
|
+
account: cfg.launchAccount || '',
|
|
1917
|
+
needAdmin: steps.some((s) => s.admin === true),
|
|
1918
|
+
steps,
|
|
1919
|
+
}
|
|
1920
|
+
try {
|
|
1921
|
+
mkdirSync(p.dir, { recursive: true })
|
|
1922
|
+
writeFileSync(p.plan, `${JSON.stringify(plan, null, 2)}\n`, 'utf8')
|
|
1923
|
+
// ps1 必须带 BOM:PowerShell 5.1 没有 BOM 会按 ANSI 解,中文全变乱码
|
|
1924
|
+
writeFileSync(
|
|
1925
|
+
p.ps1,
|
|
1926
|
+
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(applyPs1Source(), 'utf8')]),
|
|
1927
|
+
)
|
|
1928
|
+
writeFileSync(p.cmd, setupCmdSource(), 'utf8')
|
|
1929
|
+
writeFileSync(p.undoCmd, setupUndoCmdSource(), 'utf8')
|
|
1930
|
+
writeFileSync(
|
|
1931
|
+
p.readme,
|
|
1932
|
+
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(setupReadmeText(cfg, plan), 'utf8')]),
|
|
1933
|
+
)
|
|
1934
|
+
try {
|
|
1935
|
+
if (existsSync(p.result)) unlinkSync(p.result)
|
|
1936
|
+
} catch {}
|
|
1937
|
+
log(cfg, `setup bundle 写好了:${p.dir}(${steps.length} 步,needAdmin=${plan.needAdmin})`)
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
const msg = String((error && error.message) || error)
|
|
1940
|
+
log(cfg, `setup bundle 写不出来:${msg}`)
|
|
1941
|
+
return { ok: false, error: msg }
|
|
1942
|
+
}
|
|
1943
|
+
return {
|
|
1944
|
+
ok: true,
|
|
1945
|
+
dir: p.dir,
|
|
1946
|
+
cmd: p.cmd,
|
|
1947
|
+
ps1: p.ps1,
|
|
1948
|
+
undoCmd: p.undoCmd,
|
|
1949
|
+
needAdmin: plan.needAdmin,
|
|
1950
|
+
steps: steps.map(({ contentBase64, ...rest }) => rest),
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/** 读脚本写回来的结果(不存在就是「还没跑」) */
|
|
1955
|
+
function readSetupResult() {
|
|
1956
|
+
const p = setupPaths()
|
|
1957
|
+
try {
|
|
1958
|
+
if (!existsSync(p.result)) return null
|
|
1959
|
+
const text = readFileSync(p.result, 'utf8').replace(/^\uFEFF/, '')
|
|
1960
|
+
const parsed = JSON.parse(text)
|
|
1961
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
1962
|
+
let results = parsed.results
|
|
1963
|
+
if (!Array.isArray(results)) results = results ? [results] : []
|
|
1964
|
+
let mtimeMs = 0
|
|
1965
|
+
try {
|
|
1966
|
+
mtimeMs = statSync(p.result).mtimeMs
|
|
1967
|
+
} catch {}
|
|
1968
|
+
return {
|
|
1969
|
+
at: String(parsed.at || ''),
|
|
1970
|
+
mode: String(parsed.mode || 'apply'),
|
|
1971
|
+
elevated: Boolean(parsed.elevated),
|
|
1972
|
+
ok: Boolean(parsed.ok),
|
|
1973
|
+
mtimeMs,
|
|
1974
|
+
results: results.map((r) => ({
|
|
1975
|
+
id: String((r && r.id) || ''),
|
|
1976
|
+
label: String((r && r.label) || ''),
|
|
1977
|
+
ok: Boolean(r && r.ok),
|
|
1978
|
+
skipped: Boolean(r && r.skipped),
|
|
1979
|
+
message: String((r && r.message) || ''),
|
|
1980
|
+
})),
|
|
1981
|
+
}
|
|
1982
|
+
} catch {
|
|
1983
|
+
return null
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
/** 上一次生成的那份计划(给设置页显示「你上次要它做什么」) */
|
|
1988
|
+
function readSetupPlan() {
|
|
1989
|
+
const p = setupPaths()
|
|
1990
|
+
try {
|
|
1991
|
+
if (!existsSync(p.plan)) return null
|
|
1992
|
+
const parsed = JSON.parse(readFileSync(p.plan, 'utf8').replace(/^\uFEFF/, ''))
|
|
1993
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
1994
|
+
const steps = Array.isArray(parsed.steps) ? parsed.steps : []
|
|
1995
|
+
return {
|
|
1996
|
+
createdAt: String(parsed.createdAt || ''),
|
|
1997
|
+
needAdmin: Boolean(parsed.needAdmin),
|
|
1998
|
+
steps: steps.map((s) => ({
|
|
1999
|
+
id: String((s && s.id) || ''),
|
|
2000
|
+
label: String((s && s.label) || ''),
|
|
2001
|
+
kind: String((s && s.kind) || ''),
|
|
2002
|
+
where: String((s && (s.path || s.name)) || ''),
|
|
2003
|
+
admin: Boolean(s && s.admin),
|
|
2004
|
+
})),
|
|
2005
|
+
}
|
|
2006
|
+
} catch {
|
|
2007
|
+
return null
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function setupState(cfg) {
|
|
2012
|
+
const p = setupPaths()
|
|
2013
|
+
let exists = false
|
|
2014
|
+
try {
|
|
2015
|
+
exists = existsSync(p.cmd)
|
|
2016
|
+
} catch {}
|
|
2017
|
+
return {
|
|
2018
|
+
dir: p.dir,
|
|
2019
|
+
cmdPath: p.cmd,
|
|
2020
|
+
ps1Path: p.ps1,
|
|
2021
|
+
undoCmdPath: p.undoCmd,
|
|
2022
|
+
exists,
|
|
2023
|
+
needs: needCatalog(cfg),
|
|
2024
|
+
plan: readSetupPlan(),
|
|
2025
|
+
lastRun: readSetupResult(),
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/** 在资源管理器里选中那个 cmd(不是只打开目录,免得用户还得自己找) */
|
|
2030
|
+
function openSetupFolder() {
|
|
2031
|
+
const p = setupPaths()
|
|
2032
|
+
try {
|
|
2033
|
+
mkdirSync(p.dir, { recursive: true })
|
|
2034
|
+
const hasCmd = existsSync(p.cmd)
|
|
2035
|
+
const child = spawn('explorer.exe', [hasCmd ? `/select,${p.cmd}` : p.dir], {
|
|
2036
|
+
detached: true,
|
|
2037
|
+
stdio: 'ignore',
|
|
2038
|
+
windowsHide: false,
|
|
2039
|
+
})
|
|
2040
|
+
child.unref()
|
|
2041
|
+
return { ok: true, dir: p.dir, cmd: p.cmd, selected: hasCmd }
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
return { ok: false, error: String((error && error.message) || error), dir: p.dir }
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
853
2047
|
/** 把已经在跑的 NapCat 控制台窗口藏起来(老链条留下的黑框也能收掉,不用重启 QQ) */
|
|
854
2048
|
function hideConsoleWindows(cfg) {
|
|
855
2049
|
const ps = [
|
|
@@ -988,7 +2182,7 @@ async function killNewerChains(cfg, oldestLastMs) {
|
|
|
988
2182
|
const script =
|
|
989
2183
|
"$ErrorActionPreference='SilentlyContinue';" +
|
|
990
2184
|
`$cut=([datetime]'1970-01-01').AddMilliseconds(${Math.round(oldestLastMs)});` +
|
|
991
|
-
'Get-Process QQ,NapCatWinBootMain | ' +
|
|
2185
|
+
'Get-Process QQ,NapCatWinBootMain,node | ' +
|
|
992
2186
|
`Where-Object { $_.Path -like '${root}*' -and $_.StartTime -gt $cut } | ` +
|
|
993
2187
|
'ForEach-Object { try { Stop-Process -Id $_.Id -Force -ErrorAction Stop } catch {} }'
|
|
994
2188
|
await runCapture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 20000)
|
|
@@ -1003,7 +2197,7 @@ async function killNapcat(cfg) {
|
|
|
1003
2197
|
}
|
|
1004
2198
|
const root = psPath(base).replace(/'/g, "''")
|
|
1005
2199
|
const script =
|
|
1006
|
-
"Get-Process QQ,NapCatWinBootMain -ErrorAction SilentlyContinue | " +
|
|
2200
|
+
"Get-Process QQ,NapCatWinBootMain,node -ErrorAction SilentlyContinue | " +
|
|
1007
2201
|
`Where-Object { $_.Path -like '${root}*' } | ` +
|
|
1008
2202
|
'ForEach-Object { try { Stop-Process -Id $_.Id -Force -ErrorAction Stop } catch {} }'
|
|
1009
2203
|
await runCapture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 20000)
|
|
@@ -1046,6 +2240,11 @@ function writeSnapshot(cfg, payload) {
|
|
|
1046
2240
|
}
|
|
1047
2241
|
|
|
1048
2242
|
async function tick(cfg) {
|
|
2243
|
+
// 每轮都从配置文件重读一次:改完配置(换目录/换启动脚本)不用重启 DSH 就能生效
|
|
2244
|
+
try {
|
|
2245
|
+
const fresh = resolveConfig(state.patchConfig || {})
|
|
2246
|
+
if (fresh && fresh.enabled === cfg.enabled) cfg = fresh
|
|
2247
|
+
} catch {}
|
|
1049
2248
|
state.cfg = cfg
|
|
1050
2249
|
state.lastProbeAt = new Date().toISOString()
|
|
1051
2250
|
const health = await probe(cfg)
|
|
@@ -1246,6 +2445,23 @@ async function tick(cfg) {
|
|
|
1246
2445
|
/* 工具(拿得到 @deepseek-ai/dsh-tools 才注册) */
|
|
1247
2446
|
/* ------------------------------------------------------------------ */
|
|
1248
2447
|
|
|
2448
|
+
/**
|
|
2449
|
+
* DSH NEXT 已经不带 @deepseek-ai/dsh-tools 了(旧版有),所以这里不再硬依赖它:
|
|
2450
|
+
* 拿得到就用它的 defineTool,拿不到就把定义原样交给 ctx.tools.register —— 跟
|
|
2451
|
+
* dsh-memory-evolve / dsh-cost-meter 一个 shape(name / description / parameters /
|
|
2452
|
+
* output.schema+render / execute)。之前缺这个包就直接放弃注册,napcat_status 和
|
|
2453
|
+
* napcat_restart 两个工具在设置页里看不见、agent 也调不到。
|
|
2454
|
+
*/
|
|
2455
|
+
function normalizeToolDefinition(def) {
|
|
2456
|
+
const parameters =
|
|
2457
|
+
def.parameters && typeof def.parameters === 'object'
|
|
2458
|
+
? def.parameters
|
|
2459
|
+
: { type: 'object', properties: {} }
|
|
2460
|
+
if (!parameters.type) parameters.type = 'object'
|
|
2461
|
+
if (!parameters.properties) parameters.properties = {}
|
|
2462
|
+
return { ...def, parameters }
|
|
2463
|
+
}
|
|
2464
|
+
|
|
1249
2465
|
function loadDefineTool() {
|
|
1250
2466
|
const candidates = [
|
|
1251
2467
|
path.join(dshHome(), 'profiles', process.env.DSH_PROFILE || 'desktop', 'package.json'),
|
|
@@ -1267,22 +2483,35 @@ const renderText = (value) => [
|
|
|
1267
2483
|
]
|
|
1268
2484
|
|
|
1269
2485
|
function registerTools(ctx, cfg) {
|
|
1270
|
-
|
|
1271
|
-
if (!defineTool || !ctx || !ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
2486
|
+
if (!ctx || !ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
1272
2487
|
log(cfg, 'tools: unavailable, watchdog-only mode')
|
|
1273
2488
|
return
|
|
1274
2489
|
}
|
|
2490
|
+
const rawDefine = loadDefineTool()
|
|
2491
|
+
// 有 dsh-tools 就走它;没有就直接用裸定义(这才是现在 DSH NEXT 的常态)
|
|
2492
|
+
const defineTool = (def) => normalizeToolDefinition(rawDefine ? rawDefine(def) : def)
|
|
1275
2493
|
const offs = []
|
|
1276
2494
|
const push = (off) => {
|
|
1277
2495
|
if (typeof off === 'function') offs.push(off)
|
|
1278
2496
|
}
|
|
2497
|
+
// 一个工具注册失败(例如热重载时重名)不该把另外两个也带走
|
|
2498
|
+
const tools = {
|
|
2499
|
+
register: (def) => {
|
|
2500
|
+
try {
|
|
2501
|
+
return ctx.tools.register(def)
|
|
2502
|
+
} catch (error) {
|
|
2503
|
+
log(cfg, `tools: ${def && def.name} register failed: ${String((error && error.message) || error)}`)
|
|
2504
|
+
return undefined
|
|
2505
|
+
}
|
|
2506
|
+
},
|
|
2507
|
+
}
|
|
1279
2508
|
try {
|
|
1280
2509
|
push(
|
|
1281
|
-
|
|
2510
|
+
tools.register(
|
|
1282
2511
|
defineTool({
|
|
1283
2512
|
name: 'napcat_status',
|
|
1284
2513
|
description:
|
|
1285
|
-
'看 NapCat(QQ) 看门狗此刻的状态:OneBot 是否在线、登录的是哪个号、进程链数量(有无重复链)、二维码是否在等人扫(含副本路径)、dsh-qq-bridge
|
|
2514
|
+
'看 NapCat(QQ) 看门狗此刻的状态:OneBot 是否在线、登录的是哪个号、进程链数量(有无重复链)、二维码是否在等人扫(含副本路径)、dsh-qq-bridge 的轮询有多久没更新、这次用的是哪份 Node 运行时(DSH 自带 / 系统 / 手填),以及最近看门狗日志。',
|
|
1286
2515
|
parameters: {},
|
|
1287
2516
|
output: { schema: { type: 'string' }, render: (_args, value) => renderText(value) },
|
|
1288
2517
|
async execute() {
|
|
@@ -1308,6 +2537,7 @@ function registerTools(ctx, cfg) {
|
|
|
1308
2537
|
error: health.error,
|
|
1309
2538
|
},
|
|
1310
2539
|
procs,
|
|
2540
|
+
node: nodePublic(cfg),
|
|
1311
2541
|
qr,
|
|
1312
2542
|
bridge: bridgeAge(cfg),
|
|
1313
2543
|
autoLogin: {
|
|
@@ -1346,7 +2576,7 @@ function registerTools(ctx, cfg) {
|
|
|
1346
2576
|
),
|
|
1347
2577
|
)
|
|
1348
2578
|
push(
|
|
1349
|
-
|
|
2579
|
+
tools.register(
|
|
1350
2580
|
defineTool({
|
|
1351
2581
|
name: 'napcat_restart',
|
|
1352
2582
|
description:
|
|
@@ -1385,7 +2615,7 @@ function registerTools(ctx, cfg) {
|
|
|
1385
2615
|
),
|
|
1386
2616
|
)
|
|
1387
2617
|
push(
|
|
1388
|
-
|
|
2618
|
+
tools.register(
|
|
1389
2619
|
defineTool({
|
|
1390
2620
|
name: 'napcat_dedupe',
|
|
1391
2621
|
description:
|
|
@@ -1504,6 +2734,7 @@ function publicConfig(cfg) {
|
|
|
1504
2734
|
enabled: cfg.enabled,
|
|
1505
2735
|
shellDir: cfg.shellDir,
|
|
1506
2736
|
launcherScript: cfg.launcherScript,
|
|
2737
|
+
nodePath: cfg.nodePath,
|
|
1507
2738
|
launchAccount: cfg.launchAccount,
|
|
1508
2739
|
launchArgs: cfg.launchArgs,
|
|
1509
2740
|
rememberPassword: cfg.rememberPassword,
|
|
@@ -1534,22 +2765,145 @@ function publicConfig(cfg) {
|
|
|
1534
2765
|
}
|
|
1535
2766
|
|
|
1536
2767
|
function logTail(cfg, lines = 60) {
|
|
2768
|
+
const want = Math.max(1, Math.min(400, lines))
|
|
1537
2769
|
try {
|
|
1538
|
-
const
|
|
1539
|
-
|
|
1540
|
-
|
|
2770
|
+
const size = statSync(cfg.logPath).size
|
|
2771
|
+
// 只读文件尾巴(≤128KB):日志攒到几 MB 以后全量读会把事件循环按住
|
|
2772
|
+
const take = Math.min(size, 131072)
|
|
2773
|
+
const fd = openSync(cfg.logPath, 'r')
|
|
2774
|
+
let text = ''
|
|
2775
|
+
try {
|
|
2776
|
+
const buf = Buffer.alloc(take)
|
|
2777
|
+
readSync(fd, buf, 0, take, size - take)
|
|
2778
|
+
text = buf.toString('utf8')
|
|
2779
|
+
} finally {
|
|
2780
|
+
closeSync(fd)
|
|
2781
|
+
}
|
|
2782
|
+
if (size > take) {
|
|
2783
|
+
const nl = text.indexOf('\n')
|
|
2784
|
+
if (nl >= 0) text = text.slice(nl + 1)
|
|
2785
|
+
}
|
|
2786
|
+
return text
|
|
2787
|
+
.replace(/^\uFEFF/, '')
|
|
2788
|
+
.split(/\r?\n/)
|
|
2789
|
+
.filter(Boolean)
|
|
2790
|
+
.slice(-want)
|
|
1541
2791
|
} catch {
|
|
1542
2792
|
return []
|
|
1543
2793
|
}
|
|
1544
2794
|
}
|
|
1545
2795
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
2796
|
+
/* ---- 设置界面缓存层 ---------------------------------------------------
|
|
2797
|
+
* 设置面板每 1.5s 轮询 /state,而 probeProcs 每次要新开一个 powershell.exe
|
|
2798
|
+
* (≈300ms CPU / 几十 MB 内存)。面板一旦因为别的原因重挂载,就是一场
|
|
2799
|
+
* PowerShell 风暴,会把同一个进程里的 GUI + 全部插件一起拖死。
|
|
2800
|
+
* 所以:探测结果缓存 3s + 同刻只允许一个子进程;目录探测 60s 缓存;
|
|
2801
|
+
* 磁盘列表 30s 缓存;日志 2s 缓存;schtasks 只读缓存、异步补齐。
|
|
2802
|
+
* -------------------------------------------------------------------- */
|
|
2803
|
+
const uiCache = {
|
|
2804
|
+
procs: null,
|
|
2805
|
+
procsAt: 0,
|
|
2806
|
+
procsInflight: null,
|
|
2807
|
+
drives: null,
|
|
2808
|
+
drivesAt: 0,
|
|
2809
|
+
bootMain: undefined,
|
|
2810
|
+
bootMainKey: '',
|
|
2811
|
+
bootMainAt: 0,
|
|
2812
|
+
setup: null,
|
|
2813
|
+
setupAt: 0,
|
|
2814
|
+
qr: null,
|
|
2815
|
+
qrAt: 0,
|
|
2816
|
+
log: null,
|
|
2817
|
+
logAt: 0,
|
|
2818
|
+
logLines: 0,
|
|
2819
|
+
fullInflight: null,
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
/** 带缓存的进程探测,single-flight:并发请求共用同一次子进程。 */
|
|
2823
|
+
function cachedProcs(cfg, maxAgeMs = 3000) {
|
|
2824
|
+
if (uiCache.procs && Date.now() - uiCache.procsAt <= maxAgeMs) {
|
|
2825
|
+
return Promise.resolve(uiCache.procs)
|
|
2826
|
+
}
|
|
2827
|
+
if (uiCache.procsInflight) return uiCache.procsInflight
|
|
2828
|
+
uiCache.procsInflight = probeProcs(cfg)
|
|
2829
|
+
.then((p) => {
|
|
2830
|
+
uiCache.procs = p
|
|
2831
|
+
uiCache.procsAt = Date.now()
|
|
2832
|
+
return p
|
|
2833
|
+
})
|
|
2834
|
+
.catch(() => uiCache.procs || { boot: 0, qq: 0, chains: 0, dup: false })
|
|
2835
|
+
.finally(() => {
|
|
2836
|
+
uiCache.procsInflight = null
|
|
2837
|
+
})
|
|
2838
|
+
return uiCache.procsInflight
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
/** 盘符列表缓存:插拔移动盘后由 /action 的 drives 动作主动作废 */
|
|
2842
|
+
function cachedDrives(maxAgeMs = 60000) {
|
|
2843
|
+
if (uiCache.drives && Date.now() - uiCache.drivesAt <= maxAgeMs) return uiCache.drives
|
|
2844
|
+
try {
|
|
2845
|
+
uiCache.drives = listDrives()
|
|
2846
|
+
} catch {
|
|
2847
|
+
uiCache.drives = uiCache.drives || []
|
|
2848
|
+
}
|
|
2849
|
+
uiCache.drivesAt = Date.now()
|
|
2850
|
+
return uiCache.drives
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
function cachedQr(cfg, maxAgeMs = 3000) {
|
|
2854
|
+
if (uiCache.qr && Date.now() - uiCache.qrAt <= maxAgeMs) return uiCache.qr
|
|
2855
|
+
uiCache.qr = qrInfo(cfg)
|
|
2856
|
+
uiCache.qrAt = Date.now()
|
|
2857
|
+
return uiCache.qr
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function cachedLog(cfg, lines = 40, maxAgeMs = 2000) {
|
|
2861
|
+
if (uiCache.log && uiCache.logLines >= lines && Date.now() - uiCache.logAt <= maxAgeMs) {
|
|
2862
|
+
return uiCache.log
|
|
2863
|
+
}
|
|
2864
|
+
uiCache.log = logTail(cfg, lines)
|
|
2865
|
+
uiCache.logLines = lines
|
|
2866
|
+
uiCache.logAt = Date.now()
|
|
2867
|
+
return uiCache.log
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/** schtasks 只读缓存:首次不再同步阻塞 8s,改为后台补齐 + 下次请求就能读到。 */
|
|
2871
|
+
function taskReadyCached(cfg) {
|
|
2872
|
+
if (!cfg.taskName) return false
|
|
2873
|
+
if (state.taskReady !== undefined) return state.taskReady
|
|
2874
|
+
if (!state.taskReadyRefreshing) {
|
|
2875
|
+
state.taskReadyRefreshing = true
|
|
2876
|
+
runCapture('schtasks.exe', ['/query', '/tn', cfg.taskName], 8000)
|
|
2877
|
+
.then((out) => {
|
|
2878
|
+
state.taskReady = Boolean(out && out.trim())
|
|
2879
|
+
})
|
|
2880
|
+
.catch(() => {
|
|
2881
|
+
state.taskReady = false
|
|
2882
|
+
})
|
|
2883
|
+
.finally(() => {
|
|
2884
|
+
state.taskReadyRefreshing = false
|
|
2885
|
+
})
|
|
2886
|
+
}
|
|
2887
|
+
return null
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/**
|
|
2891
|
+
* 设置界面用的状态快照。
|
|
2892
|
+
* light=true 走轮询用的轻量档:不新开 PowerShell、不扫目录、不发同步子进程,
|
|
2893
|
+
* 只带扫描进度 / 运行态 / 已有缓存,用它替代每 1.5s 一次的重量级 /state。
|
|
2894
|
+
*/
|
|
2895
|
+
async function uiState(cfg, options = {}) {
|
|
2896
|
+
const light = Boolean(options && options.light)
|
|
2897
|
+
const health = await probe(cfg)
|
|
2898
|
+
const procs = light ? uiCache.procs || null : await cachedProcs(cfg)
|
|
2899
|
+
const known = Boolean(procs)
|
|
2900
|
+
const p = procs || { boot: 0, qq: 0, chains: 0, dup: false }
|
|
2901
|
+
const qr = cachedQr(cfg)
|
|
1549
2902
|
const bridge = bridgeAge(cfg)
|
|
1550
2903
|
return {
|
|
1551
2904
|
ok: true,
|
|
1552
2905
|
version,
|
|
2906
|
+
light,
|
|
1553
2907
|
config: publicConfig(cfg),
|
|
1554
2908
|
runtime: {
|
|
1555
2909
|
enabled: cfg.enabled,
|
|
@@ -1564,17 +2918,42 @@ async function uiState(cfg) {
|
|
|
1564
2918
|
lastLaunchAt: state.lastLaunchAt ? new Date(state.lastLaunchAt).toISOString() : '',
|
|
1565
2919
|
lastOnlineAt: state.lastOnlineAt ? new Date(state.lastOnlineAt).toISOString() : '',
|
|
1566
2920
|
lastProbeAt: state.lastProbeAt || '',
|
|
1567
|
-
procs: { boot:
|
|
2921
|
+
procs: { boot: p.boot, qq: p.qq, chains: p.chains, dup: p.dup, known },
|
|
1568
2922
|
qr: qr,
|
|
1569
2923
|
bridge: bridge,
|
|
1570
|
-
procRoot: procRootRaw(cfg),
|
|
1571
|
-
launcherFound: resolveLauncherPath(cfg),
|
|
2924
|
+
procRoot: light ? '' : procRootRaw(cfg),
|
|
2925
|
+
launcherFound: light ? '' : resolveLauncherPath(cfg),
|
|
2926
|
+
node: light ? uiCache.node || nodePublic(cfg) : nodePublic(cfg),
|
|
1572
2927
|
taskName: cfg.taskName,
|
|
1573
|
-
taskReady:
|
|
2928
|
+
taskReady: taskReadyCached(cfg),
|
|
1574
2929
|
},
|
|
1575
|
-
|
|
1576
|
-
|
|
2930
|
+
scan: scanPublic(),
|
|
2931
|
+
drives: cachedDrives(),
|
|
2932
|
+
setup: cachedSetup(cfg, light),
|
|
2933
|
+
log: cachedLog(cfg, 40),
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
/** 提权交接那块的状态:light(轮询)时放宽缓存,免得每 2 秒去翻一次 NapCat 目录 */
|
|
2938
|
+
function cachedSetup(cfg, light) {
|
|
2939
|
+
const maxAgeMs = light ? 10000 : 2000
|
|
2940
|
+
if (uiCache.setup && Date.now() - (uiCache.setupAt || 0) < maxAgeMs) return uiCache.setup
|
|
2941
|
+
try {
|
|
2942
|
+
uiCache.setup = setupState(cfg)
|
|
2943
|
+
} catch {
|
|
2944
|
+
uiCache.setup = uiCache.setup || null
|
|
1577
2945
|
}
|
|
2946
|
+
uiCache.setupAt = Date.now()
|
|
2947
|
+
return uiCache.setup
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
/** 并发 /state 合并成一次:崩溃重挂载时不会叠加成一串请求。 */
|
|
2951
|
+
function uiStateFull(cfg) {
|
|
2952
|
+
if (uiCache.fullInflight) return uiCache.fullInflight
|
|
2953
|
+
uiCache.fullInflight = uiState(cfg, { light: false }).finally(() => {
|
|
2954
|
+
uiCache.fullInflight = null
|
|
2955
|
+
})
|
|
2956
|
+
return uiCache.fullInflight
|
|
1578
2957
|
}
|
|
1579
2958
|
|
|
1580
2959
|
/** 保存配置:写文件 → 重新解析 → 热应用(不用重启 DSH) */
|
|
@@ -1653,7 +3032,7 @@ function saveAndApply(ctx, patch) {
|
|
|
1653
3032
|
return next
|
|
1654
3033
|
}
|
|
1655
3034
|
|
|
1656
|
-
async function doAction(ctx, cfg, action) {
|
|
3035
|
+
async function doAction(ctx, cfg, action, payload) {
|
|
1657
3036
|
switch (String(action || '')) {
|
|
1658
3037
|
case 'probe':
|
|
1659
3038
|
await tick(cfg)
|
|
@@ -1716,9 +3095,105 @@ async function doAction(ctx, cfg, action) {
|
|
|
1716
3095
|
: `密码已删,但任务重建失败:${made.error || ''}`,
|
|
1717
3096
|
}
|
|
1718
3097
|
}
|
|
1719
|
-
case '
|
|
1720
|
-
|
|
1721
|
-
|
|
3098
|
+
case 'makeSetup': {
|
|
3099
|
+
// 生成「提权交接」脚本:插件自己不动系统,把活写成脚本,由人点一下
|
|
3100
|
+
let wants = []
|
|
3101
|
+
if (payload && Array.isArray(payload.needs)) wants = payload.needs
|
|
3102
|
+
else if (payload && typeof payload.needs === 'string' && payload.needs.trim()) {
|
|
3103
|
+
wants = payload.needs.split(',').map((s) => s.trim()).filter(Boolean)
|
|
3104
|
+
}
|
|
3105
|
+
// 显式传了空数组 = 一条都没勾,别当成「全要」
|
|
3106
|
+
if (payload && Array.isArray(payload.needs) && payload.needs.length === 0) {
|
|
3107
|
+
return { said: '没勾任何要做的事 —— 先在「需要你点一下的脚本」里勾一条,再点生成' }
|
|
3108
|
+
}
|
|
3109
|
+
const made = writeSetupBundle(cfg, wants)
|
|
3110
|
+
uiCache.setup = null
|
|
3111
|
+
uiCache.setupAt = 0
|
|
3112
|
+
if (!made.ok) return { said: `没生成:${made.error}`, setup: made }
|
|
3113
|
+
if (payload && payload.open) openSetupFolder()
|
|
3114
|
+
const adminNote = made.needAdmin ? '(这批要点 UAC 的「是」)' : '(这几步不需要管理员,双击就跑)'
|
|
3115
|
+
return {
|
|
3116
|
+
said: `脚本写好了:${made.dir} —— 双击 apply-admin.cmd ${adminNote},跑完回来点「读结果」`,
|
|
3117
|
+
setup: made,
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
case 'openSetup': {
|
|
3121
|
+
const r = openSetupFolder()
|
|
3122
|
+
uiCache.setup = null
|
|
3123
|
+
uiCache.setupAt = 0
|
|
3124
|
+
return { said: r.ok ? `已打开 ${r.dir}` : `打开失败:${r.error}`, opened: r }
|
|
3125
|
+
}
|
|
3126
|
+
case 'setupResult': {
|
|
3127
|
+
uiCache.setup = null
|
|
3128
|
+
uiCache.setupAt = 0
|
|
3129
|
+
const r = readSetupResult()
|
|
3130
|
+
if (!r) return { said: '还没跑过(目录里没有 result.json)' }
|
|
3131
|
+
const bad = r.results.filter((x) => !x.ok)
|
|
3132
|
+
const skipped = r.results.filter((x) => x.skipped)
|
|
3133
|
+
const head = r.mode === 'undo' ? '撤回结果' : '执行结果'
|
|
3134
|
+
return {
|
|
3135
|
+
said:
|
|
3136
|
+
`${head}:${r.results.length - bad.length}/${r.results.length} 步成` +
|
|
3137
|
+
(bad.length
|
|
3138
|
+
? `,没成:${bad.map((x) => `${x.label}(${x.message})`).join(';')}`
|
|
3139
|
+
: '') +
|
|
3140
|
+
(skipped.length ? `,跳过 ${skipped.length} 步` : '') +
|
|
3141
|
+
(r.elevated ? '|拿到管理员' : '|没用管理员'),
|
|
3142
|
+
setupRun: r,
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
case 'checkNode': {
|
|
3146
|
+
// 清掉缓存再解析一次:用户刚装完 node / 刚改了路径时,想让结果立刻变
|
|
3147
|
+
uiCache.node = null
|
|
3148
|
+
uiCache.nodeKey = ''
|
|
3149
|
+
uiCache.nodeAt = 0
|
|
3150
|
+
uiCache.nodeVersion = ''
|
|
3151
|
+
const info = nodePublic(cfg)
|
|
3152
|
+
if (!info.path) {
|
|
3153
|
+
const shellNote = findStandaloneNapcatMjs(cfg.shellDir)
|
|
3154
|
+
? '而且这个 Shell 目录是 node 型的(顶层有 napcat.mjs),没 node 就拉不起来'
|
|
3155
|
+
: '(当前的 NapCat 是注入型,跑在 QQ 自带的 Node 里,其实不需要它)'
|
|
3156
|
+
return {
|
|
3157
|
+
said: `没找到 node.exe。找过这些地方:${info.tried.join(';') || '(无)'}${shellNote}`,
|
|
3158
|
+
node: info,
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
log(cfg, `node runtime: ${info.version || '(版本未知)'} ← ${info.source} ${info.path}`)
|
|
3162
|
+
return {
|
|
3163
|
+
said:
|
|
3164
|
+
`找到 node ${info.version || ''}(${info.source}):${info.path}` +
|
|
3165
|
+
(findStandaloneNapcatMjs(cfg.shellDir) ? '|Shell 型安装会用它跑 napcat.mjs' : ''),
|
|
3166
|
+
node: info,
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
case 'drives': {
|
|
3170
|
+
// 重新读盘:作废缓存后直接问系统。插上移动盘 / 映射了新网络盘之后不用重启 DSH。
|
|
3171
|
+
uiCache.drives = null
|
|
3172
|
+
uiCache.drivesAt = 0
|
|
3173
|
+
const list = cachedDrives(0)
|
|
3174
|
+
return {
|
|
3175
|
+
said: list.length
|
|
3176
|
+
? `读到 ${list.length} 块盘:${list.join(' ')}`
|
|
3177
|
+
: '一块盘都没读到(PowerShell 起不来?)',
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
case 'scan': {
|
|
3181
|
+
const r = await startScan(cfg, (payload && (payload.drive || payload.scope)) || '')
|
|
3182
|
+
return { said: r.said, scanStarted: Boolean(r.ok) }
|
|
3183
|
+
}
|
|
3184
|
+
case 'scanStop': {
|
|
3185
|
+
if (!scan.running) return { said: '现在没有在扫' }
|
|
3186
|
+
scan.cancelled = true
|
|
3187
|
+
return { said: '已让它停,扫完当前这个目录就收工' }
|
|
3188
|
+
}
|
|
3189
|
+
case 'useShell': {
|
|
3190
|
+
const target = String((payload && (payload.shellDir || payload.path)) || '')
|
|
3191
|
+
.trim()
|
|
3192
|
+
.replace(/"/g, '')
|
|
3193
|
+
if (!target) return { said: '没给目录' }
|
|
3194
|
+
const next = saveAndApply(ctx, { shellDir: target, launcherScript: '' })
|
|
3195
|
+
await tick(next)
|
|
3196
|
+
return { said: `已切到 ${target}${next.launcherScript ? `,启动脚本 ${next.launcherScript}` : ''}` }
|
|
1722
3197
|
}
|
|
1723
3198
|
default:
|
|
1724
3199
|
return { said: `不认识的 action:${action}` }
|
|
@@ -1751,7 +3226,10 @@ function registerRoutes(ctx) {
|
|
|
1751
3226
|
}
|
|
1752
3227
|
|
|
1753
3228
|
route('/state', async (req, res) => {
|
|
1754
|
-
|
|
3229
|
+
// ?light=1 = 轮询用的轻量档(不开子进程);不带参数就按完整档(带缓存 + 并发合并)
|
|
3230
|
+
const light = /[?&]light=1/.test(String(req.url || ''))
|
|
3231
|
+
const payload = light ? await uiState(state.cfg, { light: true }) : await uiStateFull(state.cfg)
|
|
3232
|
+
sendJson(res, 200, payload)
|
|
1755
3233
|
})
|
|
1756
3234
|
|
|
1757
3235
|
route('/save', async (req, res) => {
|
|
@@ -1762,7 +3240,7 @@ function registerRoutes(ctx) {
|
|
|
1762
3240
|
|
|
1763
3241
|
route('/action', async (req, res) => {
|
|
1764
3242
|
const body = await readBody(req)
|
|
1765
|
-
const result = await doAction(ctx, state.cfg, body.do)
|
|
3243
|
+
const result = await doAction(ctx, state.cfg, body.do, body)
|
|
1766
3244
|
const payload = await uiState(state.cfg)
|
|
1767
3245
|
sendJson(res, 200, { ...payload, said: result.said, extra: result.shellDir || '' })
|
|
1768
3246
|
})
|
|
@@ -1779,10 +3257,12 @@ export function apply(ctx, config) {
|
|
|
1779
3257
|
state.patchConfig = config || {}
|
|
1780
3258
|
const cfg = resolveConfig(state.patchConfig)
|
|
1781
3259
|
state.cfg = cfg
|
|
3260
|
+
const node = nodePublic(cfg)
|
|
1782
3261
|
log(
|
|
1783
3262
|
cfg,
|
|
1784
3263
|
`boot: watchdog v${version} (enabled=${cfg.enabled}, port=${cfg.onebotPort}, every ${cfg.probeIntervalSec}s, ` +
|
|
1785
|
-
`scanTimeout=${cfg.scanTimeoutSec}s, shell=${cfg.shellDir || '(未配置)'}, task=${cfg.taskName || '(不用)'}
|
|
3264
|
+
`scanTimeout=${cfg.scanTimeoutSec}s, shell=${cfg.shellDir || '(未配置)'}, task=${cfg.taskName || '(不用)'}, ` +
|
|
3265
|
+
`node=${node.path ? `${node.version || '?'}(${node.source})` : '(没找到)'})`,
|
|
1786
3266
|
)
|
|
1787
3267
|
registerTools(ctx, cfg)
|
|
1788
3268
|
registerRoutes(ctx)
|
|
@@ -1801,9 +3281,22 @@ export function apply(ctx, config) {
|
|
|
1801
3281
|
export const __internals = {
|
|
1802
3282
|
resolveConfig,
|
|
1803
3283
|
normalizeConfig,
|
|
3284
|
+
doAction,
|
|
1804
3285
|
detectShellDir,
|
|
1805
3286
|
detectLauncher,
|
|
1806
3287
|
resolveLauncherPath,
|
|
3288
|
+
resolveNode,
|
|
3289
|
+
nodePublic,
|
|
3290
|
+
nodeVersion,
|
|
3291
|
+
findStandaloneNapcatMjs,
|
|
3292
|
+
dshBundledNodeCandidates,
|
|
3293
|
+
listDrives,
|
|
3294
|
+
startScan,
|
|
3295
|
+
scanOneDrive,
|
|
3296
|
+
shellEvidence,
|
|
3297
|
+
looksLikeShellDirLoose,
|
|
3298
|
+
scanPublic,
|
|
3299
|
+
scan,
|
|
1807
3300
|
ensureTask,
|
|
1808
3301
|
readConfigFile,
|
|
1809
3302
|
writeConfigFile,
|
|
@@ -1823,5 +3316,17 @@ export const __internals = {
|
|
|
1823
3316
|
removeEnvBat,
|
|
1824
3317
|
envBatPath,
|
|
1825
3318
|
md5Hex,
|
|
3319
|
+
SETUP_NEEDS,
|
|
3320
|
+
setupPaths,
|
|
3321
|
+
needCatalog,
|
|
3322
|
+
buildSetupSteps,
|
|
3323
|
+
writeSetupBundle,
|
|
3324
|
+
readSetupResult,
|
|
3325
|
+
readSetupPlan,
|
|
3326
|
+
setupState,
|
|
3327
|
+
openSetupFolder,
|
|
3328
|
+
applyPs1Source,
|
|
3329
|
+
dirWritable,
|
|
3330
|
+
cachedBootMain,
|
|
1826
3331
|
state,
|
|
1827
3332
|
}
|