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/lib/index.js CHANGED
@@ -1,9 +1,58 @@
1
1
  /**
2
- * dsh-napcat-keeper —— NapCat 看门狗 v2.1.0(可发布通用版)
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.0'
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
- /** 优先用这个计划任务拉起(父进程是任务计划服务,不随 DSH 重启死掉);空 = 只用 cscript */
96
- taskName: 'NapCat-Keeper-Launch',
97
- /** 计划任务不存在时自动创建(用户级,不需要管理员) */
98
- autoCreateTask: true,
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
- const state = {
122
- phase: 'idle', // idle | online | launching | starting | need-scan | need-scan-giveup | offline
123
- lastProbeAt: null,
124
- lastOnlineAt: 0,
125
- lastLaunchAt: 0,
126
- lastError: '',
127
- launchCount: 0,
128
- dedupeCount: 0,
129
- scanSince: 0,
130
- scanRetries: 0,
131
- lastHeartbeatAt: 0,
132
- lastDupCheckAt: 0,
133
- timer: null,
134
- cfg: null,
135
- lastSnapshot: null,
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
- /** 扫常见位置找 NapCat Shell 目录 */
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
- 'C:\\NapCat',
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
- /** 在 shellDir 里挑启动脚本:先挑「写死了这个 QQ 号」的,其次 quick,其次 napcat*.bat */
237
- function detectLauncher(shellDir, account = '') {
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
- if (!cfg.shellDir) cfg.shellDir = detectShellDir()
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
- "Get-Process QQ,NapCatWinBootMain | " +
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)) return null
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
- const defineTool = loadDefineTool()
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
- ctx.tools.register(
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
- ctx.tools.register(
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
- ctx.tools.register(
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 text = readFileSync(cfg.logPath, 'utf8').replace(/^\uFEFF/, '')
1539
- const all = text.split(/\r?\n/).filter(Boolean)
1540
- return all.slice(-Math.max(1, Math.min(400, lines)))
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
- async function uiState(cfg) {
1547
- const [health, procs] = await Promise.all([probe(cfg), probeProcs(cfg)])
1548
- const qr = qrInfo(cfg)
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: procs.boot, qq: procs.qq, chains: procs.chains, dup: procs.dup },
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: cfg.taskName ? taskReady(cfg) : false,
2928
+ taskReady: taskReadyCached(cfg),
1574
2929
  },
1575
- detect: { shellDir: detectShellDir() },
1576
- log: logTail(cfg, 40),
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 'detect': {
1720
- const found = detectShellDir()
1721
- return { said: found ? `探测到:${found}` : '常见位置没找到 NapCat Shell 目录', shellDir: found }
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
- sendJson(res, 200, await uiState(state.cfg))
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
  }