dsh-notify-say 0.4.21 → 0.4.24

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/client.js CHANGED
@@ -58,7 +58,21 @@ window.__ModuleLoader__.load({
58
58
  }
59
59
  }
60
60
 
61
- /** 取「最近一条通知对应的会话」;消费即清,避免重复跳转。 */
61
+ /**
62
+ * 取「最近一条通知对应的会话」;消费即清,避免重复跳转。
63
+ *
64
+ * ⚠️ 返回的是**对象** `{ sessionId, at }`,不是裸字符串。
65
+ *
66
+ * 2026-10-18 外部审阅发现一个 P0 bug:这里原本 `return doc.sessionId`
67
+ * (字符串),但调用方写的是 `if (!doc || !doc.sessionId) return`。
68
+ * 字符串没有 `.sessionId` 属性 → 恒为 undefined → **直接 return**。
69
+ * 结果是会话跳转功能**从未生效过**,而且服务端信号已被 consume 清除,
70
+ * 连重试的机会都没有。
71
+ *
72
+ * 教训:这类"返回类型与调用方期望不符"的 bug,静态审阅一眼可见,
73
+ * 但没有任何单元测试覆盖到(因为两端各自都"看起来对")。
74
+ * 已加 C27 测试锁住返回形状。
75
+ */
62
76
  function consumeLastSession() {
63
77
  return fetch(apiUrl('consume'), { cache: 'no-store', credentials: 'same-origin' })
64
78
  .then(function (res) {
@@ -68,7 +82,10 @@ window.__ModuleLoader__.load({
68
82
  })
69
83
  .then(function (doc) {
70
84
  if (!doc || typeof doc.sessionId !== 'string' || !doc.sessionId) return null
71
- return doc.sessionId
85
+ return {
86
+ sessionId: doc.sessionId,
87
+ at: Number(doc.at) || 0,
88
+ }
72
89
  })
73
90
  .catch(function () { return null })
74
91
  }
@@ -270,10 +287,20 @@ window.__ModuleLoader__.load({
270
287
  }
271
288
 
272
289
  /** 音色候选(与 sounds/voice-lines 下的基础音色一致;可手输其他 key)。 */
290
+ // 可下载/可选的音色清单(**只列审核通过的**)。
291
+ //
292
+ // ⚠️ 2026-10-18:移除 kasukabe-tsumugi 与 mochiko ——
293
+ // · 春日部つむぎ:官方规约第 6 条禁止「二次配布」
294
+ // · もち子さん:官方规约禁止「音声素材を無償配布」
295
+ // 审核结论见 docs/audio-audit/README.md。
296
+ //
297
+ // 注意:这个列表只影响 GUI 下拉里的候选项。真正的分发拦截在
298
+ // release-build.ps1 的 $NeverShipVoicePrefixes(构建期硬阻断),
299
+ // 两处都要维护,但**构建期那道是安全网**。
273
300
  var VOICE_KEYS = [
274
301
  'zundamon-normal', 'zundamon-amama', 'ankomon', 'goki-nuigurumi',
275
- 'kasukabe-tsumugi', 'kyushu-sora', 'manbetsu-hanamaru-genki',
276
- 'meimei-meria', 'mochiko', 'shikoku-metang', 'ameharu-hau',
302
+ 'kyushu-sora', 'manbetsu-hanamaru-genki', 'meimei-meria',
303
+ 'shikoku-metang', 'ameharu-hau', 'namiine-ritsu', 'shirakami-kotaro',
277
304
  ]
278
305
 
279
306
  /** 事件开关定义(键 → 中文名)。 */
@@ -1409,10 +1436,16 @@ window.__ModuleLoader__.load({
1409
1436
  */
1410
1437
  function jumpEnabled() {
1411
1438
  return loadConfig().then(function (v) {
1412
- var cfg = v || {}
1439
+ // ⚠️ 2026-10-18 外部审阅指出逻辑矛盾:loadConfig 失败返回 {},
1440
+ // 而 {}.jumpWhenBackground !== false 求值为 true —— 与"拿不到就不跳"正好相反。
1441
+ // 现在显式区分"读到了"与"没读到"。
1442
+ if (!v || typeof v !== "object" || Object.keys(v).length === 0) {
1443
+ return { jumpWhenBackground: false, jumpWhenForeground: false }
1444
+ }
1413
1445
  return {
1414
- jumpWhenBackground: cfg.jumpWhenBackground !== false,
1415
- jumpWhenForeground: cfg.jumpWhenForeground === true,
1446
+ // 只有明确读到 true 才跳;缺失或 null 都不跳
1447
+ jumpWhenBackground: v.jumpWhenBackground === true,
1448
+ jumpWhenForeground: v.jumpWhenForeground === true,
1416
1449
  }
1417
1450
  }).catch(function () {
1418
1451
  return { jumpWhenBackground: false, jumpWhenForeground: false }
@@ -1,209 +1,248 @@
1
- /**
2
- * 配置读写 route(host 半侧):给 client 半侧的设置卡片提供真正的读写能力。
3
- *
4
- * ## 为什么需要它
5
- *
6
- * 官方 `Config` schema 生成的是**插件配置编辑器**里的表单;本插件以 bundle 形态
7
- * 挂载时,那个表单并不出现在设置页的分区里(实测:分区里只有我们自己画的卡)。
8
- * 用户要的是"点一下就能改音量",所以卡片必须自带控件。
9
- *
10
- * 而 client 跑在沙箱渲染进程里(无 process、无 fs),改不了 storages 下的文件,
11
- * 所以要开一条 host route:client `fetch` 读写,host 落地到
12
- * `$DSH_HOME/storages/dsh-notify-desktop/config.json`。
13
- *
14
- * ## 为什么写这个文件是安全的
15
- *
16
- * - 只写**本插件自己的**状态文件(与 config-store 同一路径、同一白名单校验);
17
- * - 不碰 `cordis.patch.yml`、不碰凭据、不碰别的插件;
18
- * - 写入前 `sanitize()` 过滤:未知键丢弃、越界值拒绝;
19
- * - 文件损坏时 `writeConfig` 拒绝覆盖(保留用户原文件供人工救)。
20
- *
21
- * ## 与三源合并的关系
22
- *
23
- * 这里写的是**第 4 层(最高优先级)**。插件每轮播报前 `refreshConfig()` 重读,
24
- * 所以**改完立即生效,不需要重启**。想交还控制权给设置页 / YAML:`{ clear: true }`。
25
- */
26
-
27
- import { existsSync, unlinkSync } from 'node:fs';
28
-
29
- import {
30
- readConfig, writeConfig, sanitize, configPath, CONFIG_KEYS,
31
- } from './config-store.js';
32
- import { ROUTE_PREFIX } from './session-bridge.js';
33
-
34
- /** 读配置。 */
35
- export const ROUTE_CONFIG = ROUTE_PREFIX + '/config';
36
- /** 写配置。 */
37
- export const ROUTE_CONFIG_SET = ROUTE_PREFIX + '/config/set';
38
-
39
- /**
40
- * 从 Node 的 req 上把 body 读完。
41
- * @param {object} req
42
- * @returns {Promise<object>}
43
- */
44
- function readJsonBody(req) {
45
- return new Promise((resolve) => {
46
- try {
47
- if (!req) return resolve({});
48
- if (req.body != null && typeof req.body === 'object') return resolve(req.body);
49
-
50
- const chunks = [];
51
- let done = false;
52
- const finish = () => {
53
- if (done) return;
54
- done = true;
55
- try {
56
- const text = Buffer.concat(chunks).toString('utf8');
57
- resolve(text ? JSON.parse(text) : {});
58
- } catch {
59
- resolve({});
60
- }
61
- };
62
- if (typeof req.on !== 'function') return resolve({});
63
- req.on('data', (c) => {
64
- try { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))); } catch { /* ignore */ }
65
- });
66
- req.on('end', finish);
67
- req.on('error', finish);
68
- setTimeout(finish, 3000); // 防御:3 秒还没 end 就放弃
69
- } catch {
70
- resolve({});
71
- }
72
- });
73
- }
74
-
75
- /**
76
- * 写入响应(兼容两种 res 形态)。
77
- */
78
- function send(res, code, body) {
79
- try {
80
- const text = body === undefined ? '' : JSON.stringify(body);
81
- if (res && typeof res.writeHead === 'function') {
82
- res.writeHead(code, {
83
- 'content-type': 'application/json; charset=utf-8',
84
- 'cache-control': 'no-store',
85
- });
86
- res.end(text);
87
- return;
88
- }
89
- if (res && typeof res.setHeader === 'function' && typeof res.end === 'function') {
90
- res.statusCode = code;
91
- res.setHeader('content-type', 'application/json; charset=utf-8');
92
- res.setHeader('cache-control', 'no-store');
93
- res.end(text);
94
- }
95
- } catch { /* 响应失败不影响后续 */ }
96
- }
97
-
98
- /**
99
- * 清空运行时覆盖层:删除本插件的 config.json。
100
- * 与 `notify-config.ps1 -Clear` 语义一致。
101
- * @returns {{ ok: boolean, reason?: string }}
102
- */
103
- export function clearOverrides() {
104
- try {
105
- const p = configPath();
106
- if (!p) return { ok: false, reason: 'no-home' };
107
- if (existsSync(p)) unlinkSync(p);
108
- return { ok: true };
109
- } catch (ex) {
110
- return { ok: false, reason: String((ex && ex.message) || ex) };
111
- }
112
- }
113
-
114
- /**
115
- * 创建配置 route 的 handler。
116
- *
117
- * @param {object} [opts]
118
- * @param {() => void} [opts.onChanged] 配置变更后的回调(让插件立即重读)
119
- * @param {() => object} [opts.getEffective]
120
- * 返回**当前生效的完整配置**(三层合并结果)。
121
- *
122
- * ⚠️ 为什么必须有它(独立审查 2026-10-17 指出,会导致用户配置被改坏):
123
- * `readConfig()` 只读第 4 层(`storages/.../config.json` 的覆盖值)。
124
- * 但实际生效值是「schema 默认 → cordis.patch.yml → 设置页 → 文件」四层合并。
125
- * 于是 GUI 打开时看到的是**文件层**(常常是空的),比如 patch 里 `volume: 100`
126
- * 而文件没存过 volume,GUI 就显示硬编码默认 80;用户一拖滑块,
127
- * 就把真实的 100 覆盖成 80 —— 他以为自己只是"确认一下"。
128
- *
129
- * @returns {{ handler: Function }}
130
- */
131
- export function createConfigRoute(opts) {
132
- const onChanged = opts && typeof opts.onChanged === 'function' ? opts.onChanged : null;
133
- const getEffective = opts && typeof opts.getEffective === 'function' ? opts.getEffective : null;
134
- const notifyChanged = () => { try { if (onChanged) onChanged(); } catch { /* ignore */ } };
135
-
136
- /** 生效值(拿不到就退回文件层,保证有值可显示)。 */
137
- const effective = () => {
138
- try {
139
- if (getEffective) {
140
- const v = getEffective();
141
- if (v && typeof v === 'object') return v;
142
- }
143
- } catch { /* 落到文件层 */ }
144
- try { return readConfig().value || {}; } catch { return {}; }
145
- };
146
-
147
- async function handler(...args) {
148
- // 兼容 {request,response} 与 (req,res) 两种 register 风格(与 session-bridge 一致)
149
- const req = args[0] && args[0].request ? args[0].request
150
- : (args[0] && args[0].req ? args[0].req : args[0]);
151
- const res = args[0] && args[0].response ? args[0].response
152
- : (args[0] && args[0].res ? args[0].res : args[1]);
153
-
154
- try {
155
- const url = String((req && (req.url || req.path)) || '');
156
- const method = String((req && req.method) || 'GET').toUpperCase();
157
- // 只认 /config/set 为写;不再把"POST 到 /config"也算写(语义更自洽)。
158
- const isWrite = /\/config\/set(\/|$)/.test(url.split('?')[0]);
159
-
160
- if (isWrite && method !== 'POST') {
161
- return send(res, 405, { ok: false, reason: 'method-not-allowed' });
162
- }
163
-
164
- if (isWrite) {
165
- const body = await readJsonBody(req);
166
-
167
- // 交还控制权:删掉文件层覆盖,回到 patch/schema 的值
168
- if (body && body.clear === true) {
169
- const c = clearOverrides();
170
- if (!c.ok) return send(res, 500, { ok: false, reason: c.reason });
171
- notifyChanged();
172
- // 清空后返回的是**生效值**(即 patch 里的值),不是空对象
173
- return send(res, 200, { ok: true, values: effective() });
174
- }
175
-
176
- const patch = body && body.values && typeof body.values === 'object' ? body.values : body;
177
- const s = sanitize(patch);
178
- if (!s.ok) return send(res, 400, { ok: false, reason: 'bad-input', dropped: s.dropped });
179
- if (Object.keys(s.value).length === 0) {
180
- return send(res, 400, { ok: false, reason: 'nothing-to-write', dropped: s.dropped });
181
- }
182
-
183
- const w = writeConfig(s.value);
184
- if (!w.ok) return send(res, 409, { ok: false, reason: w.reason });
185
- notifyChanged();
186
- // ⚠️ 回**生效值**而不是 `w.value`(文件层快照)。
187
- // 否则:改一个开关后,没存过的键(volume 等)会从响应里消失,
188
- // client 用它整体替换本地态 → 滑块/下拉立刻退回硬编码默认值。
189
- return send(res, 200, { ok: true, values: effective() });
190
- }
191
-
192
- // 读:返回生效值 + 文件层覆盖值(后者用于判断"哪些是用户显式设过的")
193
- const cur = readConfig();
194
- let overrides = {};
195
- try { overrides = cur.ok ? (cur.value || {}) : {}; } catch { overrides = {}; }
196
- return send(res, 200, {
197
- ok: true,
198
- values: effective(),
199
- overrides,
200
- corrupt: cur.ok ? false : cur.reason === 'corrupt',
201
- keys: CONFIG_KEYS,
202
- });
203
- } catch {
204
- return send(res, 500, { ok: false, reason: 'internal' });
205
- }
206
- }
207
-
208
- return { handler };
209
- }
1
+ /**
2
+ * 配置读写 route(host 半侧):给 client 半侧的设置卡片提供真正的读写能力。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 官方 `Config` schema 生成的是**插件配置编辑器**里的表单;本插件以 bundle 形态
7
+ * 挂载时,那个表单并不出现在设置页的分区里(实测:分区里只有我们自己画的卡)。
8
+ * 用户要的是"点一下就能改音量",所以卡片必须自带控件。
9
+ *
10
+ * 而 client 跑在沙箱渲染进程里(无 process、无 fs),改不了 storages 下的文件,
11
+ * 所以要开一条 host route:client `fetch` 读写,host 落地到
12
+ * `$DSH_HOME/storages/dsh-notify-desktop/config.json`。
13
+ *
14
+ * ## 为什么写这个文件是安全的
15
+ *
16
+ * - 只写**本插件自己的**状态文件(与 config-store 同一路径、同一白名单校验);
17
+ * - 不碰 `cordis.patch.yml`、不碰凭据、不碰别的插件;
18
+ * - 写入前 `sanitize()` 过滤:未知键丢弃、越界值拒绝;
19
+ * - 文件损坏时 `writeConfig` 拒绝覆盖(保留用户原文件供人工救)。
20
+ *
21
+ * ## 与三源合并的关系
22
+ *
23
+ * 这里写的是**第 4 层(最高优先级)**。插件每轮播报前 `refreshConfig()` 重读,
24
+ * 所以**改完立即生效,不需要重启**。想交还控制权给设置页 / YAML:`{ clear: true }`。
25
+ */
26
+
27
+ import { existsSync, unlinkSync } from 'node:fs';
28
+
29
+ import {
30
+ readConfig, writeConfig, sanitize, configPath, CONFIG_KEYS,
31
+ } from './config-store.js';
32
+ import { ROUTE_PREFIX } from './session-bridge.js';
33
+
34
+ /** 读配置。 */
35
+ export const ROUTE_CONFIG = ROUTE_PREFIX + '/config';
36
+ /** 写配置。 */
37
+ export const ROUTE_CONFIG_SET = ROUTE_PREFIX + '/config/set';
38
+
39
+ /**
40
+ * 请求体上限(字节)。
41
+ *
42
+ * 配置写入的实际负载是几百字节的 JSON;给 64 KB 已经宽松 100 倍。
43
+ *
44
+ * ⚠️ 2026-10-18 外部审阅指出:原实现只有「3 秒超时」,**没有大小上限**。
45
+ * 超时不等于限流 —— 攻击者可以在 3 秒内灌几十 MB,`chunks.push` 无上限累积,
46
+ * 先吃满内存再被超时收割。现在两者都要有。
47
+ */
48
+ const MAX_BODY_BYTES = 64 * 1024;
49
+
50
+ /**
51
+ * 从 Node 的 req 上把 body 读完,**带累计大小上限**。
52
+ *
53
+ * 超限行为:立即停止累积、返回 `{ __tooLarge: true }`,
54
+ * 由调用方回 413。不抛异常,避免把大小问题伪装成"解析失败"。
55
+ *
56
+ * @param {object} req
57
+ * @returns {Promise<object>}
58
+ */
59
+ function readJsonBody(req) {
60
+ return new Promise((resolve) => {
61
+ try {
62
+ if (!req) return resolve({});
63
+ if (req.body != null && typeof req.body === 'object') return resolve(req.body);
64
+
65
+ const chunks = [];
66
+ let total = 0;
67
+ let done = false;
68
+ const finish = (value) => {
69
+ if (done) return;
70
+ done = true;
71
+ if (value !== undefined) return resolve(value);
72
+ try {
73
+ const text = Buffer.concat(chunks).toString('utf8');
74
+ resolve(text ? JSON.parse(text) : {});
75
+ } catch {
76
+ resolve({});
77
+ }
78
+ };
79
+ if (typeof req.on !== 'function') return resolve({});
80
+
81
+ req.on('data', (c) => {
82
+ if (done) return;
83
+ try {
84
+ const buf = Buffer.isBuffer(c) ? c : Buffer.from(String(c));
85
+ total += buf.length;
86
+ if (total > MAX_BODY_BYTES) {
87
+ // 停止累积并让调用方回 413;不再继续吃内存
88
+ chunks.length = 0;
89
+ finish({ __tooLarge: true });
90
+ try { req.destroy?.(); } catch { /* ignore */ }
91
+ return;
92
+ }
93
+ chunks.push(buf);
94
+ } catch { /* ignore */ }
95
+ });
96
+ req.on('end', () => finish());
97
+ req.on('error', () => finish());
98
+ setTimeout(() => finish(), 3000); // 防御:3 秒还没 end 就放弃
99
+ } catch {
100
+ resolve({});
101
+ }
102
+ });
103
+ }
104
+
105
+ /**
106
+ * 写入响应(兼容两种 res 形态)。
107
+ */
108
+ function send(res, code, body) {
109
+ try {
110
+ const text = body === undefined ? '' : JSON.stringify(body);
111
+ if (res && typeof res.writeHead === 'function') {
112
+ res.writeHead(code, {
113
+ 'content-type': 'application/json; charset=utf-8',
114
+ 'cache-control': 'no-store',
115
+ });
116
+ res.end(text);
117
+ return;
118
+ }
119
+ if (res && typeof res.setHeader === 'function' && typeof res.end === 'function') {
120
+ res.statusCode = code;
121
+ res.setHeader('content-type', 'application/json; charset=utf-8');
122
+ res.setHeader('cache-control', 'no-store');
123
+ res.end(text);
124
+ }
125
+ } catch { /* 响应失败不影响后续 */ }
126
+ }
127
+
128
+ /**
129
+ * 清空运行时覆盖层:删除本插件的 config.json。
130
+ * 与 `notify-config.ps1 -Clear` 语义一致。
131
+ * @returns {{ ok: boolean, reason?: string }}
132
+ */
133
+ export function clearOverrides() {
134
+ try {
135
+ const p = configPath();
136
+ if (!p) return { ok: false, reason: 'no-home' };
137
+ if (existsSync(p)) unlinkSync(p);
138
+ return { ok: true };
139
+ } catch (ex) {
140
+ return { ok: false, reason: String((ex && ex.message) || ex) };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 创建配置 route 的 handler。
146
+ *
147
+ * @param {object} [opts]
148
+ * @param {() => void} [opts.onChanged] 配置变更后的回调(让插件立即重读)
149
+ * @param {() => object} [opts.getEffective]
150
+ * 返回**当前生效的完整配置**(三层合并结果)。
151
+ *
152
+ * ⚠️ 为什么必须有它(独立审查 2026-10-17 指出,会导致用户配置被改坏):
153
+ * `readConfig()` 只读第 4 层(`storages/.../config.json` 的覆盖值)。
154
+ * 但实际生效值是「schema 默认 → cordis.patch.yml → 设置页 → 文件」四层合并。
155
+ * 于是 GUI 打开时看到的是**文件层**(常常是空的),比如 patch 里 `volume: 100`
156
+ * 而文件没存过 volume,GUI 就显示硬编码默认 80;用户一拖滑块,
157
+ * 就把真实的 100 覆盖成 80 —— 他以为自己只是"确认一下"。
158
+ *
159
+ * @returns {{ handler: Function }}
160
+ */
161
+ export function createConfigRoute(opts) {
162
+ const onChanged = opts && typeof opts.onChanged === 'function' ? opts.onChanged : null;
163
+ const getEffective = opts && typeof opts.getEffective === 'function' ? opts.getEffective : null;
164
+ const notifyChanged = () => { try { if (onChanged) onChanged(); } catch { /* ignore */ } };
165
+
166
+ /** 生效值(拿不到就退回文件层,保证有值可显示)。 */
167
+ const effective = () => {
168
+ try {
169
+ if (getEffective) {
170
+ const v = getEffective();
171
+ if (v && typeof v === 'object') return v;
172
+ }
173
+ } catch { /* 落到文件层 */ }
174
+ try { return readConfig().value || {}; } catch { return {}; }
175
+ };
176
+
177
+ async function handler(...args) {
178
+ // 兼容 {request,response} 与 (req,res) 两种 register 风格(与 session-bridge 一致)
179
+ const req = args[0] && args[0].request ? args[0].request
180
+ : (args[0] && args[0].req ? args[0].req : args[0]);
181
+ const res = args[0] && args[0].response ? args[0].response
182
+ : (args[0] && args[0].res ? args[0].res : args[1]);
183
+
184
+ try {
185
+ const url = String((req && (req.url || req.path)) || '');
186
+ const method = String((req && req.method) || 'GET').toUpperCase();
187
+ // 只认 /config/set 为写;不再把"POST 到 /config"也算写(语义更自洽)。
188
+ const isWrite = /\/config\/set(\/|$)/.test(url.split('?')[0]);
189
+
190
+ if (isWrite && method !== 'POST') {
191
+ return send(res, 405, { ok: false, reason: 'method-not-allowed' });
192
+ }
193
+
194
+ if (isWrite) {
195
+ const body = await readJsonBody(req);
196
+
197
+ // 请求体超限:413 而不是 400 —— 让调用方区分"太大"和"格式错"
198
+ if (body && body.__tooLarge === true) {
199
+ return send(res, 413, {
200
+ ok: false,
201
+ reason: 'body-too-large',
202
+ limitBytes: MAX_BODY_BYTES,
203
+ });
204
+ }
205
+
206
+ // 交还控制权:删掉文件层覆盖,回到 patch/schema 的值
207
+ if (body && body.clear === true) {
208
+ const c = clearOverrides();
209
+ if (!c.ok) return send(res, 500, { ok: false, reason: c.reason });
210
+ notifyChanged();
211
+ // 清空后返回的是**生效值**(即 patch 里的值),不是空对象
212
+ return send(res, 200, { ok: true, values: effective() });
213
+ }
214
+
215
+ const patch = body && body.values && typeof body.values === 'object' ? body.values : body;
216
+ const s = sanitize(patch);
217
+ if (!s.ok) return send(res, 400, { ok: false, reason: 'bad-input', dropped: s.dropped });
218
+ if (Object.keys(s.value).length === 0) {
219
+ return send(res, 400, { ok: false, reason: 'nothing-to-write', dropped: s.dropped });
220
+ }
221
+
222
+ const w = writeConfig(s.value);
223
+ if (!w.ok) return send(res, 409, { ok: false, reason: w.reason });
224
+ notifyChanged();
225
+ // ⚠️ 回**生效值**而不是 `w.value`(文件层快照)。
226
+ // 否则:改一个开关后,没存过的键(volume 等)会从响应里消失,
227
+ // client 用它整体替换本地态 → 滑块/下拉立刻退回硬编码默认值。
228
+ return send(res, 200, { ok: true, values: effective() });
229
+ }
230
+
231
+ // 读:返回生效值 + 文件层覆盖值(后者用于判断"哪些是用户显式设过的")
232
+ const cur = readConfig();
233
+ let overrides = {};
234
+ try { overrides = cur.ok ? (cur.value || {}) : {}; } catch { overrides = {}; }
235
+ return send(res, 200, {
236
+ ok: true,
237
+ values: effective(),
238
+ overrides,
239
+ corrupt: cur.ok ? false : cur.reason === 'corrupt',
240
+ keys: CONFIG_KEYS,
241
+ });
242
+ } catch {
243
+ return send(res, 500, { ok: false, reason: 'internal' });
244
+ }
245
+ }
246
+
247
+ return { handler };
248
+ }