dsh-napcat-keeper 0.0.0-stage → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ ## 2.0.2
4
+
5
+ - `package.json` 补 `repository` / `homepage` / `bugs`,npm 页面能直接跳 GitHub 仓库。
6
+ - 纯元数据变更,功能与 2.0.1 一致。
7
+
8
+ ## 2.0.1 — 桌面不再多一个黑框
9
+
10
+ - 简介改成:发现没有能够随着 dsh 打开自动启动 napcat 的插件,遂使用大肥鱼制作此插件,建议搭配 Yuuyuko-uu 的 dsh-qq-bridge 使用~;`author` 署名为「熙的大肥鱼」。
11
+ - 发布前清洗:包内不含任何本机信息(QQ 号、昵称、用户名、令牌、会话 id 零命中);示例路径统一写成 `C:/Users/你/NapCat/...`。
12
+ - **拉起时不再弹出 cmd 黑窗**:计划任务改成先跑一个 `napcat-keeper-launch.vbs`(`sh.Run cmd, 0, False`),控制台以隐藏窗口启动。`hideConsole` 开关可关掉(默认开)。壳文件写在 `~/.dsh/napcat-keeper-launch.vbs`,UTF-16LE 存盘,中文路径也认。
13
+ - 新增动作 `action=hideConsole`,设置页对应按钮「收起黑框」:把已经在跑的 NapCat 控制台窗口收起来(老链条留下的也能收,不用重启 QQ)。
14
+ - `ensureTask` 遇到「任务正在运行、`/f` 覆盖被拒」时会先 `/end` 再重建一次。
15
+ - 修:`taskWrapperPath` 里错用了不存在的 `homeDir()`(现为 `dshHome()`)。
16
+
17
+ ## 2.0.0 — 可以给别人的通用版
18
+
19
+ - **新增设置界面「设置 → napcat桥」**(`lib/client.js`):状态卡 + 全部配置项 + 一键动作 + 日志尾部,保存即热生效(不用重启 DSH)。
20
+ - **不再写死任何本机路径**:`shellDir` / `launcherScript` / 进程路径前缀全部改成配置项,留空则自动探测(`~/NapCat`、`Documents/NapCat`、`C:\NapCat`、`%LOCALAPPDATA%\NapCat` …)。
21
+ - **只填 QQ 号也能用**:`launchAccount` 填上后,会自动生成调用 `NapCatWinBootMain.exe <号>` 的启动脚本;探测启动脚本时也优先挑「写死了这个号」的那个 bat。
22
+ - **自动建计划任务**:`autoCreateTask`,用户级、不需要管理员,并把 `ExecutionTimeLimit` 设成不限,避免长跑被任务计划掐断。
23
+ - 新增 HTTP 接口:`/dsh-napcat-keeper/state`、`/save`、`/action`(probe/launch/restart/kill/dedupe/createTask/detect)、`/log`。
24
+ - 修:PowerShell `-like` 只认反斜杠,路径前缀换成 `\` 再传,否则杀进程范围永远匹配不到。
25
+ - 修:设置页保存的配置(`~/.dsh/napcat-keeper-config.json`)优先级最高,其次 cordis 里的 `config`,最后是内置默认。
26
+
27
+ ## 1.1.2
28
+
29
+ - `killNapcat` 返回「是否真清干净」;没清干净就不拉新链(再拉会互踢),三个调用点都按这个判断走。
30
+ - 在线时每 60s 复查一次重复链,只杀晚启动的那几条(`killNewerChains`)。
31
+ - 版本号统一从 `version` 取,日志里不再写死。
32
+
33
+ ## 1.1.1
34
+
35
+ - 修:`package.json` / `cordis.patch.yml` / `lib/index.js` 带 UTF-8 BOM 时,Node 的 `JSON.parse` 与 YAML 解析直接报 `Unexpected token ''`,插件装不上。全部改成无 BOM。
36
+ - 改用计划任务拉起,彻底脱离 DSH 进程树(DSH 重启不再带走 QQ)。
37
+
38
+ ## 1.1.0
39
+
40
+ - 修:重复拉起 —— 两条 QQ 链并存会互踢、掉进扫码态。改为按启动时间聚类判重 + 跨进程锁。
41
+ - 修:卡在扫码不再静默,超时杀掉重来,上限后明确 giveup。
42
+ - 修:杀进程的路径前缀多套了一层,会误伤用户目录下任何 `QQ.exe`。
43
+ - 加:状态快照、二维码副本、心跳日志。
44
+
45
+ ## 1.0.0
46
+
47
+ - 首个版本:探 OneBot 端口 → 不通就 `cscript` 拉起 `napcat.bat` → 45s 复查。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-napcat-keeper contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,148 @@
1
- # Temporary Holding Version
1
+ # dsh-napcat-keeper
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 发现没有能够随着dsh打开自动启动napcat的插件,遂使用大肥鱼制作此插件,建议搭配[Yuuyuko-uu 的 dsh-qq-bridge](https://github.com/Yuuyuko-uu/dsh-qq-bridge)使用~
4
+
5
+ 给 [NapCat](https://napneko.github.io/)(QQ 协议端)配一个看门狗:**DSH 在,QQ 就在**。
6
+
7
+ 端口不通就拉起来,进程重复就杀掉多余的,卡在扫码就重来,扫完码以后又掉线也会自己恢复。
8
+ 所有需要人填的东西都在 **DSH → 设置 → napcat桥** 一个页面里,保存即生效,不用重启。
9
+
10
+ > 常配套 `dsh-qq-bridge`(把 QQ 和 DSH 会话接通):那个负责「说话」,这个负责「别掉线」。
11
+ > 也可以单独用——只要你想让 NapCat 一直活着。
12
+
13
+ ---
14
+
15
+ ## 它到底管什么
16
+
17
+ | 情况 | 看门狗怎么做 |
18
+ | --- | --- |
19
+ | OneBot 端口不通、也没有 NapCat 进程 | 拉起来(优先走计划任务,其次 cscript) |
20
+ | 刚拉起、端口还没通 | 给宽限期(默认 150s),期间不重复拉 |
21
+ | 拉到一半卡在扫码 | 记一笔 `need-scan`;超过 `scanTimeoutSec` 就杀掉重来,重试 `maxScanRetries` 次后放弃并写日志 |
22
+ | 有两条 QQ 进程链(客户端互踢的根源) | 按启动时间聚类判重,只杀晚出现的那几条,保留正在服务的那条 |
23
+ | 进程在、端口不通、也没有任何新信号 | 判为卡死,杀掉重来 |
24
+ | 一切正常 | 只记心跳(默认 10 分钟一行),并顺手检查 `dsh-qq-bridge` 的轮询是否还新鲜 |
25
+
26
+ 看门狗自己不会把 DSH 启动搞崩:外部调用都有超时、异常都吞掉写日志、跨进程还有一把锁防止两个 DSH 实例同时拉。
27
+
28
+ ## 前置:先把 NapCat 装好
29
+
30
+ 1. 装 [NapCat.Shell](https://github.com/NapNeko/NapCatQQ/releases)(Windows 有一键安装包,会顺带装 QQ)。
31
+ 2. 打开 NapCat 的 WebUI → **网络配置** → 加一个 **HTTP 服务器**:
32
+ - host `127.0.0.1`,port `3000`(或你喜欢的端口)
33
+ - 需要的话设一个 token,记下来
34
+ 3. 手动跑一次、扫码登录一次(之后有登录缓存就不用再扫)。
35
+
36
+ ## 安装
37
+
38
+ ```bash
39
+ # 从 npm 装(发布后)
40
+ dsh plugin --profile <profile> add dsh-napcat-keeper
41
+
42
+ # 或从本地目录装(开发时)
43
+ dsh plugin --profile <profile> add C:\path\to\dsh-napcat-keeper
44
+ ```
45
+
46
+ 装完重启一次 DSH(第一次装载才生效),然后去 **设置 → napcat桥**。
47
+
48
+ ## 首次配置:其实就三个空
49
+
50
+ | 填什么 | 说明 |
51
+ | --- | --- |
52
+ | **NapCat Shell 目录** | 点旁边的「自动探测」多半就填好了;也可手填,例如 `C:/Users/你/NapCat/OneKey/NapCat.<版本>.Shell` |
53
+ | **要快登的 QQ 号** | 填了它,插件优先挑「写死了这个号」的启动脚本;连脚本都没有时,自动生成一个直接调 `NapCatWinBootMain.exe <号>` 的脚本 |
54
+ | **OneBot HTTP 端口** | 跟 NapCat 网络配置里的 httpServers 一致(默认 3000) |
55
+
56
+ 点 **保存设置** → 存到 `~/.dsh/napcat-keeper-config.json`,立刻热生效。
57
+ 回「状态」看:在线会显示昵称和 QQ 号;离线就点「拉起 NapCat」。
58
+
59
+ ## 页面上的按钮
60
+
61
+ | 按钮 | 作用 |
62
+ | --- | --- |
63
+ | 自动探测 | 扫常见位置找 NapCat Shell 目录,找到就填进输入框(尚未保存) |
64
+ | 重新探测 | 立刻跑一轮 tick(探端口 + 探进程) |
65
+ | 拉起 NapCat | 手动下达拉起指令 |
66
+ | 杀掉重拉 | 干净地杀掉整条链再拉一条(杀不干净就不拉,避免互踢) |
67
+ | 去重复链 | 只杀掉晚出现的那几条链,保留最早那条 |
68
+ | 建计划任务 | 现在就把计划任务建好(用户级,不需要管理员) |
69
+ | 收起黑框 | 把已经在跑的控制台窗口藏起来(不用重启 QQ) |
70
+ | 全部杀掉 | 只杀,不拉 |
71
+
72
+ ## 桌面上那个黑框
73
+
74
+ cscript / cmd 直接拉 NapCat,会多出一个控制台窗口挂在桌面上。默认行为是**不显示**:
75
+ 计划任务的动作写的是 `wscript.exe "<dsh家目录>\napcat-keeper-launch.vbs"`,这个壳里用
76
+ `sh.Run cmd, 0, False` 把真正的 cmd 以隐藏窗口启动(`0` = SW_HIDE)。壳文件用 UTF-16LE 存,中文路径也认。
77
+
78
+ 不想要隐藏就把「拉起时不显示黑框」关掉,任务会退回直接跑 `cmd.exe`;已经跑起来的那条链想收窗口,
79
+ 点「收起黑框」(`action=hideConsole`)即可。
80
+
81
+ ## 计划任务为什么重要
82
+
83
+ `napcat.bat` / `NapCatWinBootMain.exe` 拉起的进程链会跟着「拉起它的进程」一起死。
84
+ 要是由 DSH 自己 `spawn`,DSH 一重启 QQ 就跟着没——这就是「刚才还好好的,重启完就不通」最常见的原因。
85
+
86
+ 所以默认由计划任务(`NapCat-Keeper-Launch`)来拉,父进程是任务计划服务,DSH 重启牵连不到。
87
+ 任务不存在时插件自动创建,也可以点「建计划任务」;不想用就把「自动创建计划任务」和「计划任务名」清掉,退回 cscript(能用,但 DSH 退出会带走 QQ)。
88
+
89
+ ## 给 agent 用的三个工具
90
+
91
+ | 工具 | 作用 |
92
+ | --- | --- |
93
+ | `napcat_status` | 端口/登录态、进程链、二维码副本路径、桥接心跳、日志尾部 |
94
+ | `napcat_restart` | 干净地杀掉整条链并重拉,然后等它上线 |
95
+ | `napcat_dedupe` | 只在真有重复链时才杀(没重复就什么都不做) |
96
+
97
+ ## 配置项
98
+
99
+ | 键 | 默认 | 说明 |
100
+ | --- | --- | --- |
101
+ | `enabled` | `true` | 总开关;关掉只保留状态观察,不自动拉也不自动杀 |
102
+ | `shellDir` | 自动探测 | NapCat Shell 目录(启动脚本所在目录) |
103
+ | `launcherScript` | 自动探测 | 启动脚本名或绝对路径 |
104
+ | `launchAccount` | 空 | 想快登的 QQ 号 |
105
+ | `launchArgs` | 空 | 追加给启动脚本的参数 |
106
+ | `onebotPort` | `3000` | OneBot 11 HTTP 端口 |
107
+ | `accessToken` | 空 | OneBot access token |
108
+ | `taskName` | `NapCat-Keeper-Launch` | 负责拉起的计划任务名,留空 = 不用 |
109
+ | `autoCreateTask` | `true` | 任务不存在时自动创建 |
110
+ | `probeIntervalSec` | `45` | 复查间隔 |
111
+ | `launchGraceSec` | `150` | 拉起后的宽限期 |
112
+ | `scanTimeoutSec` | `300` | 扫码等待上限 |
113
+ | `maxScanRetries` | `2` | 扫码重试上限 |
114
+ | `stuckSec` | `420` | 卡死判定 |
115
+ | `chainGapSec` | `120` | 重复链判定窗口 |
116
+ | `procPathMatch` | 空(由 shellDir 推两层) | 只在这个路径前缀下找 QQ/NapCat 进程 |
117
+ | `bridgeStaleSec` | `180` | `qq-bridge` 的 state 多久没更新就警告 |
118
+
119
+ ## 文件都在哪
120
+
121
+ ```
122
+ ~/.dsh/napcat-keeper.log 看门狗日志(超 1MB 自动滚动)
123
+ ~/.dsh/napcat-keeper-launch.log 启动脚本的原始输出(扫码提示都在这)
124
+ ~/.dsh/napcat-keeper-config.json 设置页保存的配置
125
+ ~/.dsh/napcat-keeper-state.json 每轮 tick 的状态快照
126
+ ~/.dsh/napcat-qr.png 最近一张二维码副本(扫码时会更新)
127
+ ```
128
+
129
+ ## 常见问题
130
+
131
+ **「离线」但进程明明还在?**
132
+ 端口没通。看 `napcat-keeper-launch.log`——多半是卡在扫码,或者 QQ 弹了验证窗。
133
+
134
+ **刚重启 DSH,QQ 就掉了?**
135
+ 说明这条链还是 DSH 拉起来的。填上「计划任务名」、点「建计划任务」,以后交给任务计划服务。
136
+
137
+ **两个 QQ 客户端互相踢?**
138
+ 去重会兜住,但根因通常是有人手动又点了一次 `napcat.bat`。点「去重复链」立刻收敛。
139
+
140
+ **换端口了?**
141
+ 改「OneBot HTTP 端口」保存即可;如果同时换了 Shell 目录,跨进程锁会自动清掉。
142
+
143
+ **能在 Linux / macOS 上用吗?**
144
+ 看门狗本身是 node 实现,但「进程路径探测 / 计划任务 / cscript」这几处是为 Windows 写的;其它平台目前只保证「探端口 + 判活」这一条路。
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,27 @@
1
+ # dsh-napcat-keeper 的 bundle patch:把看门狗这一行插进 profile 的插件名册。
2
+ # 装法:dsh plugin --profile <profile> add <本目录或 npm 包名>
3
+ # (bundle patch 会自动应用,别再在 profile 的 cordis.patch.yml 里手插一遍,重复 id 会让 loader 崩)
4
+ #
5
+ # 这里的 config 只是「初始默认」。装完之后请在
6
+ # DSH → 设置 → napcat桥
7
+ # 里填配置并保存 —— 那份(~/.dsh/napcat-keeper-config.json)优先级更高,
8
+ # 改完立刻生效,不用重启 DSH。
9
+ - insert:
10
+ - id: napcat-keeper
11
+ name: 'dsh-napcat-keeper'
12
+ config:
13
+ enabled: true
14
+ # ↓ 全部可留空 = 自动探测;下面这些只是给「不想用界面」的人参考的字段
15
+ # shellDir: 'C:/Users/你/NapCat/OneKey/NapCat.<版本>.Shell'
16
+ # launcherScript: 'napcat.quick.bat'
17
+ # launchAccount: '10001'
18
+ # onebotPort: 3000
19
+ # accessToken: ''
20
+ # taskName: 'NapCat-Keeper-Launch'
21
+ # autoCreateTask: true
22
+ # probeIntervalSec: 45
23
+ # launchGraceSec: 150
24
+ # scanTimeoutSec: 300
25
+ # maxScanRetries: 2
26
+ # stuckSec: 420
27
+ # chainGapSec: 120
package/lib/client.js ADDED
@@ -0,0 +1,345 @@
1
+ // DSH「napcat桥」· 网页半边
2
+ //
3
+ // 在 **设置 → napcat桥** 里:看状态、填配置、点一下就能探测/拉起/杀掉重拉。
4
+ // 这里不碰会话、不碰进程,只是往宿主那几条 /dsh-napcat-keeper/* 路由上读写。
5
+ //
6
+ // 对第一次用的人:只要三个空 —— NapCat Shell 目录、QQ 号、OneBot 端口,
7
+ // 保存完就能用;其它都能自动探测。
8
+
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-napcat-keeper',
11
+ factory: (require) => {
12
+ var module = { exports: {} }
13
+ var exports = module.exports
14
+
15
+ const React = require('react')
16
+ const inject = ['slots']
17
+
18
+ const B = '/dsh-napcat-keeper'
19
+ const get = (p) => fetch(B + p, { cache: 'no-store' }).then((r) => r.json()).catch(() => null)
20
+ const post = (p, body) =>
21
+ fetch(B + p, {
22
+ method: 'POST',
23
+ headers: { 'content-type': 'application/json' },
24
+ body: JSON.stringify(body || {}),
25
+ })
26
+ .then((r) => r.json())
27
+ .catch(() => null)
28
+
29
+ const h = React.createElement
30
+
31
+ const S = {
32
+ wrap: {
33
+ padding: '12px 14px 24px',
34
+ fontSize: 13,
35
+ lineHeight: 1.7,
36
+ background: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,.92))',
37
+ border: '1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08))',
38
+ borderRadius: 12,
39
+ },
40
+ h: { fontSize: 14, fontWeight: 600, margin: '18px 0 8px' },
41
+ dim: { opacity: 0.62, fontSize: 12 },
42
+ card: {
43
+ border: '1px solid var(--dsw-alias-border-l2)',
44
+ borderRadius: 8,
45
+ padding: '10px 12px',
46
+ marginBottom: 10,
47
+ background: 'var(--dsw-alias-bg-layer-1)',
48
+ },
49
+ row: { display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 10, flexWrap: 'wrap' },
50
+ lab: { minWidth: 200, paddingTop: 4, fontSize: 12.5 },
51
+ ctl: { flex: '1 1 300px', minWidth: 220 },
52
+ hint: { opacity: 0.6, fontSize: 11.5, marginTop: 3 },
53
+ input: {
54
+ border: '1px solid var(--dsw-alias-border-l2)',
55
+ borderRadius: 6,
56
+ padding: '5px 8px',
57
+ background: 'transparent',
58
+ color: 'inherit',
59
+ fontSize: 12.5,
60
+ width: '100%',
61
+ boxSizing: 'border-box',
62
+ fontFamily: 'inherit',
63
+ },
64
+ btn: {
65
+ border: '1px solid var(--dsw-alias-border-l2)',
66
+ borderRadius: 6,
67
+ padding: '4px 12px',
68
+ background: 'transparent',
69
+ color: 'inherit',
70
+ fontSize: 12.5,
71
+ cursor: 'pointer',
72
+ },
73
+ btnOn: {
74
+ border: '1px solid #2f6f4f',
75
+ borderRadius: 6,
76
+ padding: '4px 12px',
77
+ background: '#2f6f4f',
78
+ color: '#fff',
79
+ fontSize: 12.5,
80
+ cursor: 'pointer',
81
+ },
82
+ ok: { color: '#2f8f5b', fontWeight: 600 },
83
+ bad: { color: '#c0392b', fontWeight: 600 },
84
+ warn: { color: '#b8860b', fontWeight: 600 },
85
+ kv: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 14px', fontSize: 12.5 },
86
+ log: {
87
+ fontFamily: 'ui-monospace, Consolas, monospace',
88
+ fontSize: 11.5,
89
+ lineHeight: 1.5,
90
+ maxHeight: 220,
91
+ overflow: 'auto',
92
+ whiteSpace: 'pre-wrap',
93
+ wordBreak: 'break-all',
94
+ margin: 0,
95
+ padding: '8px 10px',
96
+ border: '1px solid var(--dsw-alias-border-l2)',
97
+ borderRadius: 8,
98
+ background: 'rgba(0,0,0,.03)',
99
+ },
100
+ float: {
101
+ position: 'sticky',
102
+ bottom: 0,
103
+ display: 'flex',
104
+ gap: 10,
105
+ alignItems: 'center',
106
+ marginTop: 12,
107
+ padding: '8px 12px',
108
+ borderRadius: 8,
109
+ border: '1px solid #c0392b',
110
+ background: 'var(--dsw-alias-bg-layer-1, #fff)',
111
+ },
112
+ }
113
+
114
+ const 字段 = [
115
+ {
116
+ group: '连接',
117
+ note: '这三项填完就能跑:NapCat 装在哪、要拉哪个 QQ、OneBot 开在哪个端口。',
118
+ items: [
119
+ ['shellDir', 'NapCat Shell 目录', '例如 C:/Users/你/NapCat/OneKey/NapCat.<版本>.Shell —— 里面有 napcat*.bat', 'text', true],
120
+ ['launcherScript', '启动脚本', '留空自动挑(quick 优先,其次 napcat*.bat)', 'text'],
121
+ ['launchAccount', '要快登的 QQ 号', '填了它、脚本又找不到时,会自动生成一个直接调 NapCatWinBootMain.exe 的脚本', 'text'],
122
+ ['onebotPort', 'OneBot HTTP 端口', 'NapCat 里 onebot11 的 httpServers 端口,默认 3000', 'number', true],
123
+ ['accessToken', 'OneBot 访问令牌', '没设就留空;设了要跟 NapCat 里填的一致', 'password'],
124
+ ],
125
+ },
126
+ {
127
+ group: '看护行为',
128
+ note: '',
129
+ items: [
130
+ ['enabled', '开启自动看护', '关掉 = 只留着看状态,不自动拉也不自动杀', 'bool'],
131
+ ['autoCreateTask', '自动创建计划任务', '让拉起的 QQ 链挂在任务计划服务下面,DSH 重启不会把它带走', 'bool'],
132
+ ['hideConsole', '拉起时不显示黑框', '用 wscript 壳把控制台窗口藏起来,桌面上不会多出那个 cmd 窗口', 'bool'],
133
+ ['taskName', '计划任务名', '留空 = 不用计划任务,退回 cscript 拉起(DSH 一退 QQ 就跟着退)', 'text'],
134
+ ['probeIntervalSec', '复查间隔(秒)', '多久检查一次端口通不通', 'number'],
135
+ ['launchGraceSec', '拉起后宽限期(秒)', '刚拉起时端口还没通,这期间不重复拉', 'number'],
136
+ ],
137
+ },
138
+ {
139
+ group: '高级',
140
+ note: '一般不用动。',
141
+ items: [
142
+ ['scanTimeoutSec', '扫码等待上限(秒)', '卡在扫码超过这个时间就杀掉重来', 'number'],
143
+ ['maxScanRetries', '扫码重试上限', '重试这么多次还不行就放弃并记日志,不再反复杀进程', 'number'],
144
+ ['stuckSec', '卡死判定(秒)', '进程在、端口不通、又没有任何新信号,判为卡死', 'number'],
145
+ ['chainGapSec', '重复链判定窗口(秒)', '两批 QQ 进程启动时间相隔超过它才算两条链', 'number'],
146
+ ['procPathMatch', '进程路径前缀', '留空 = 由 Shell 目录往上两层推导;填错会导致看不到进程', 'text'],
147
+ ],
148
+ },
149
+ ]
150
+
151
+ const 全部键 = 字段.reduce((acc, g) => acc.concat(g.items.map((i) => i[0])), [])
152
+
153
+ function 取(obj, key) {
154
+ return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : ''
155
+ }
156
+
157
+ function 状态条(runtime) {
158
+ if (!runtime) return null
159
+ const 在 = runtime.online
160
+ return h('div', { style: S.card },
161
+ h('div', { style: { display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' } },
162
+ h('span', { style: 在 ? S.ok : S.bad }, 在 ? '● 在线' : '○ 离线'),
163
+ 在 ? h('span', null, String(runtime.nickname || '') + '(' + String(runtime.userId || '') + ')') : null,
164
+ h('span', { style: S.dim }, '阶段:' + String(runtime.phase || '?')),
165
+ h('span', { style: S.dim }, '启动 ' + String(runtime.launchCount || 0) + ' 次'),
166
+ runtime.dedupeCount ? h('span', { style: S.dim }, '去重 ' + String(runtime.dedupeCount) + ' 次') : null,
167
+ ),
168
+ h('div', { style: { ...S.kv, marginTop: 8 } },
169
+ h('span', { style: S.dim }, '进程'),
170
+ h('span', null,
171
+ 'NapCatWinBootMain ' + String((runtime.procs && runtime.procs.boot) || 0) +
172
+ ' | QQ ' + String((runtime.procs && runtime.procs.qq) || 0) +
173
+ ' | 链 ' + String((runtime.procs && runtime.procs.chains) || 0) +
174
+ (runtime.procs && runtime.procs.dup ? ' ⚠ 有重复链' : '')),
175
+ h('span', { style: S.dim }, '路径前缀'),
176
+ h('span', { style: { wordBreak: 'break-all' } }, String(runtime.procRoot || '(空)')),
177
+ h('span', { style: S.dim }, '启动脚本'),
178
+ h('span', { style: { wordBreak: 'break-all' } }, String(runtime.launcherFound || '⚠ 没找到')),
179
+ h('span', { style: S.dim }, '计划任务'),
180
+ h('span', null, String(runtime.taskName || '(不用)') + (runtime.taskReady ? ' ✓ 就绪' : '(还没建)')),
181
+ h('span', { style: S.dim }, '二维码'),
182
+ h('span', null, runtime.qr ? '最新一张 ' + String(runtime.qr.ageSec) + ' 秒前' : '还没有(没进扫码态)'),
183
+ h('span', { style: S.dim }, '桥接心跳'),
184
+ h('span', null, runtime.bridge && runtime.bridge.ageSec !== null ? String(runtime.bridge.ageSec) + ' 秒前' : '没读到'),
185
+ ),
186
+ runtime.error ? h('div', { style: { ...S.bad, marginTop: 6 } }, '最近错误:' + String(runtime.error)) : null,
187
+ )
188
+ }
189
+
190
+ function Section() {
191
+ const [态, set态] = React.useState(null)
192
+ const [草, set草] = React.useState(null)
193
+ const [忙, set忙] = React.useState(false)
194
+ const [说, set说] = React.useState('')
195
+ const [开日志, set开日志] = React.useState(false)
196
+
197
+ const 装 = (data) => {
198
+ set态(data)
199
+ if (data && data.config) {
200
+ const base = {}
201
+ 全部键.forEach((k) => {
202
+ base[k] = 取(data.config, k)
203
+ })
204
+ set草(base)
205
+ }
206
+ }
207
+
208
+ React.useEffect(() => {
209
+ get('/state').then((d) => d && 装(d))
210
+ }, [])
211
+
212
+ const 脏 = Boolean(态 && 草 && 全部键.some((k) => String(取(草, k)) !== String(取(态.config, k))))
213
+
214
+ const 存 = async () => {
215
+ if (!草 || 忙) return
216
+ set忙(true)
217
+ const r = await post('/save', { patch: 草 })
218
+ if (r) 装(r)
219
+ set说(r && r.config ? '设置已保存并生效' : '保存失败,看看日志')
220
+ set忙(false)
221
+ }
222
+
223
+ const 动 = async (doIt) => {
224
+ if (忙) return
225
+ set忙(true)
226
+ set说('执行中:' + doIt + ' …')
227
+ const r = await post('/action', { do: doIt })
228
+ if (r) 装(r)
229
+ set说((r && r.said) || '做完了')
230
+ set忙(false)
231
+ }
232
+
233
+ const 探测 = async () => {
234
+ set忙(true)
235
+ const r = await post('/action', { do: 'detect' })
236
+ if (r) {
237
+ 装(r)
238
+ if (r.extra) set草((prev) => ({ ...(prev || {}), shellDir: r.extra }))
239
+ }
240
+ set说((r && r.said) || '探测完了')
241
+ set忙(false)
242
+ }
243
+
244
+ if (!态) {
245
+ return h('div', { style: S.wrap }, h('div', { style: S.dim }, '正在读取 napcat 看门狗状态 …'))
246
+ }
247
+
248
+ const 改 = (k, v) => set草((prev) => ({ ...(prev || {}), [k]: v }))
249
+
250
+ const 渲染项 = (item) => {
251
+ const [key, label, hint, type] = item
252
+ const val = 取(草, key)
253
+ let ctl
254
+ if (type === 'bool') {
255
+ ctl = h('label', { style: { display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 } },
256
+ h('input', {
257
+ type: 'checkbox',
258
+ checked: val !== false && String(val) !== 'false',
259
+ onChange: (e) => 改(key, e.target.checked),
260
+ }),
261
+ h('span', null, val !== false && String(val) !== 'false' ? '开' : '关'))
262
+ } else {
263
+ ctl = h('input', {
264
+ style: S.input,
265
+ type: type === 'number' ? 'number' : type === 'password' ? 'password' : 'text',
266
+ value: val === null || val === undefined ? '' : String(val),
267
+ placeholder: type === 'number' ? '' : '留空 = 自动',
268
+ spellCheck: false,
269
+ onChange: (e) => 改(key, type === 'number' ? e.target.value : e.target.value),
270
+ })
271
+ }
272
+ return h('div', { style: S.row, key },
273
+ h('div', { style: S.lab },
274
+ h('div', null, label),
275
+ hint ? h('div', { style: S.hint }, hint) : null),
276
+ h('div', { style: S.ctl },
277
+ ctl,
278
+ key === 'shellDir' ? h('div', { style: { marginTop: 6 } },
279
+ h('button', { type: 'button', style: S.btn, onClick: 探测, disabled: 忙 }, '自动探测')) : null),
280
+ )
281
+ }
282
+
283
+ return h('div', { style: S.wrap },
284
+ h('div', { style: S.h }, 'napcat桥'),
285
+ h('div', { style: S.dim },
286
+ '让 NapCat(QQ 协议端)跟着 DSH 一起活着:不通就拉、重复就杀、卡住就重来。'
287
+ + '版本 ' + String(态.version || '?') + '|配置:' + String(取(态.config, 'configPath'))),
288
+
289
+ 状态条(态.runtime),
290
+
291
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', margin: '10px 0' } },
292
+ h('button', { type: 'button', style: S.btn, disabled: 忙, onClick: () => 动('probe') }, '重新探测'),
293
+ h('button', { type: 'button', style: S.btn, disabled: 忙, onClick: () => 动('launch') }, '拉起 NapCat'),
294
+ h('button', { type: 'button', style: S.btn, disabled: 忙, onClick: () => 动('restart') }, '杀掉重拉'),
295
+ h('button', { type: 'button', style: S.btn, disabled: 忙, onClick: () => 动('dedupe') }, '去重复链'),
296
+ h('button', { type: 'button', style: S.btn, disabled: 忙, onClick: () => 动('createTask') }, '建计划任务'),
297
+ h('button', { type: 'button', style: S.btn, disabled: 忙, onClick: () => 动('hideConsole') }, '收起黑框'),
298
+ h('button', { type: 'button', style: S.btn, disabled: 忙, onClick: () => 动('kill') }, '全部杀掉'),
299
+ ),
300
+ 说 ? h('div', { style: { ...S.dim, marginBottom: 6 } }, 说) : null,
301
+
302
+ 字段.map((g) => h('div', { key: g.group },
303
+ h('div', { style: S.h }, g.group),
304
+ g.note ? h('div', { style: { ...S.dim, marginBottom: 8 } }, g.note) : null,
305
+ g.items.map(渲染项),
306
+ )),
307
+
308
+ h('div', { style: S.h }, '日志'),
309
+ h('div', { style: { display: 'flex', gap: 8, marginBottom: 6 } },
310
+ h('button', { type: 'button', style: S.btn, onClick: () => set开日志(!开日志) }, 开日志 ? '收起' : '展开'),
311
+ h('button', {
312
+ type: 'button',
313
+ style: S.btn,
314
+ onClick: () => get('/log?lines=200').then((d) => {
315
+ if (d && d.lines) set态((prev) => ({ ...prev, log: d.lines }))
316
+ }),
317
+ }, '刷新'),
318
+ h('span', { style: S.dim }, String(取(态.config, 'logPath'))),
319
+ ),
320
+ 开日志 ? h('pre', { style: S.log }, (态.log || []).join('\n') || '(还没有日志)') : null,
321
+
322
+ 脏 ? h('div', { style: S.float },
323
+ h('span', { style: S.bad }, '⚠ 有改动还没保存'),
324
+ h('span', { style: S.dim }, '改完点右边就行,保存后立刻生效,不用重启 DSH'),
325
+ h('button', { type: 'button', style: S.btnOn, onClick: 存, disabled: 忙 }, 忙 ? '保存中…' : '保存设置'),
326
+ ) : h('div', { style: { ...S.dim, marginTop: 12 } }, '设置已同步 ✓'),
327
+ )
328
+ }
329
+
330
+ function apply(ctx) {
331
+ const slots = ctx.get('slots')
332
+ if (slots === undefined) return
333
+ slots.inject('settings.section', () =>
334
+ slots.register(
335
+ { name: 'settings.section', id: 'napcat-keeper', order: 53, label: () => 'napcat桥' },
336
+ Section,
337
+ ),
338
+ )
339
+ }
340
+
341
+ exports.apply = apply
342
+ exports.inject = inject
343
+ return module.exports
344
+ },
345
+ })