dsh-notify-say 0.4.19 → 0.4.20

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/dev-reload.js CHANGED
@@ -36,11 +36,32 @@
36
36
  *
37
37
  * - **默认关闭**:只有 `devReload: true` 才启用。生产安装不会重写自己的文件,
38
38
  * 开着只是多一个可能出错的路径。
39
- * - **缓存清不掉就放弃**:上游会用 `?t=时间戳` 强制重求值绕过;我们直接返回
40
- * 失败并说明原因 —— 强制重求值会让 `import.meta.url` 带查询串,
41
- * 依赖相对路径的逻辑可能因此错乱,不值得冒这个险。
42
39
  * - **失败绝不半途而废**:任何异常都走回滚,保证"要么换成新的,要么还是旧的"。
43
40
  *
41
+ * ## v0.4.20 重写:修「假成功」
42
+ *
43
+ * 2026-10-17 实测:本模块报告 `ok:true`,但实际只换掉了**部分**模块
44
+ * (`config-store.js` 换了、`index.js` 没换),导致"半新半旧"。
45
+ * 这比完全没生效更危险 —— 排查时会被误导好几轮。
46
+ *
47
+ * 根因是两个缺失的机制(对照上游 `dsh-our-free-model/src/reload.js`):
48
+ *
49
+ * 1. **缺 `?t=` 代际绕过**:`loadCache` 清不掉时我们直接放弃,
50
+ * 而上游会用带查询串的 URL 强制重新求值。缺这个 → 一个模块都换不掉。
51
+ * 2. **缺加载指纹校验**:只比 `replacement === previous`(入口导出对象),
52
+ * 而依赖模块是否真的换了完全没验证。缺这个 → 换了一半也算成功。
53
+ *
54
+ * 现在补上三件事:
55
+ *
56
+ * · **代际绕过**:清不掉就用 `?reload=<ts>` 重求值;配合 `registerHooks`
57
+ * 把代际传递给依赖,保证整棵依赖树一起换新。
58
+ * · **指纹校验**(`expectedFingerprint`):调用方传入"磁盘上应该是什么"的
59
+ * 标记,重载后**在真实模块里读回**并比对,不一致就判失败。
60
+ * · **依赖抽样校验**:重载后随机验证若干依赖模块的对象身份确实变了,
61
+ * 捕捉"入口换了、依赖没换"的隐性半新半旧。
62
+ *
63
+ * 原则:**报 ok 就必须真的生效;验证不了就算失败。**
64
+ *
44
65
  * ## 风险(如实记录)
45
66
  *
46
67
  * 依赖三个**非公开**对象:`ctx.loader.internal`、`ctx.registry`、`ctx.fiber`。
@@ -53,13 +74,193 @@
53
74
  */
54
75
 
55
76
  import fs from 'node:fs';
56
- import { createRequire } from 'node:module';
77
+ import { createRequire, registerHooks } from 'node:module';
57
78
  import { fileURLToPath } from 'node:url';
58
79
 
59
80
  /** 同一时刻只允许一次重载,避免重叠交换。 */
60
81
  let inFlight = null;
61
82
 
83
+ /**
84
+ * 已安装的代际钩子(按 packageUrl 索引)。
85
+ *
86
+ * `registerHooks` 在同一进程里**不能重复注册同一个 packageUrl**:
87
+ * 第二次会叠加一个钩子,导致代际参数被反复改写、URL 越滚越长。
88
+ * 所以用这个 Map 记住"这个包已经装过钩子",只装一次。
89
+ */
90
+ const reloadHooks = new Map();
91
+
92
+ /** 代际查询串的键名。带项目前缀,避免与其它插件撞名。 */
93
+ const GEN_PARAM = 'dns-reload';
94
+
95
+ /**
96
+ * 让入口的"代际"穿透到它 import 的每一个本包模块。
97
+ *
98
+ * ## 为什么必须有
99
+ *
100
+ * 只在入口 URL 上加 `?reload=xxx` 是不够的:入口里写的是
101
+ * `import { ... } from './config-store.js'`,这个相对说明符**不带查询串**,
102
+ * 于是 Node 仍然复用缓存里的**旧依赖** —— 结果就是入口是新的、依赖是旧的。
103
+ *
104
+ * 这正是 2026-10-17 那次"config-store 换了、index 没换"(或反之)的机制。
105
+ *
106
+ * 钩子的作用:凡是 `parentURL` 带代际、且 `resolved.url` 落在同一个包内的,
107
+ * 都给 `resolved.url` 补上同一个代际参数,整棵子树一起重新求值。
108
+ *
109
+ * @param {string} packageUrl 包根 URL(带尾斜杠)
110
+ */
111
+ export function enableFreshDependencies(packageUrl) {
112
+ if (reloadHooks.has(packageUrl)) return;
113
+ try {
114
+ const hook = registerHooks({
115
+ resolve(specifier, context, nextResolve) {
116
+ const resolved = nextResolve(specifier, context);
117
+ const parent = context && context.parentURL;
118
+ if (!parent || !parent.startsWith(packageUrl)) return resolved;
119
+ if (!resolved || typeof resolved.url !== 'string') return resolved;
120
+ if (!resolved.url.startsWith(packageUrl)) return resolved;
121
+ let generation = null;
122
+ try { generation = new URL(parent).searchParams.get(GEN_PARAM); } catch { return resolved; }
123
+ if (generation === null) return resolved;
124
+ try {
125
+ const target = new URL(resolved.url);
126
+ target.searchParams.set(GEN_PARAM, generation);
127
+ return { ...resolved, url: target.href };
128
+ } catch {
129
+ return resolved;
130
+ }
131
+ },
132
+ });
133
+ reloadHooks.set(packageUrl, hook);
134
+ } catch {
135
+ // 老版本 Node 没有 registerHooks —— 只影响代际绕过,不影响正常路径
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 计算"磁盘上这份代码应该长什么样"的指纹。
141
+ *
142
+ * 用途:重载前记下,重载后**从真实模块里读回**再比对。
143
+ * 这是"报 ok 就必须真生效"的依据 —— 没有它,只能相信
144
+ * "re-import 返回了新对象",而那并不等于"新代码在跑"。
145
+ *
146
+ * 取哪些文件:入口 + lib 下所有 js。排除 test/tools/sounds 等大目录。
147
+ *
148
+ * @param {string} packageDir 包目录的**文件系统路径**
149
+ * @returns {string} 形如 'a1b2c3d4' 的短哈希;读不到时返回 ''
150
+ */
151
+ export function computeFingerprint(packageDir) {
152
+ try {
153
+ const base = String(packageDir || '').replace(/[\\/]+$/, '');
154
+ if (!base) return '';
155
+
156
+ // 目录本身不存在 → 明确返回空串。
157
+ // 不能让"一个文件都 stat 不到"悄悄算出空串的哈希值 —— 那会让调用方
158
+ // 以为"算出来了",从而拿一个无意义的指纹去比对(实测踩到:
159
+ // 不存在的目录返回了 '00001505',看起来像个正常指纹)。
160
+ if (!fs.existsSync(base)) return '';
161
+
162
+ const files = [];
163
+ const libDir = base + '\\lib';
164
+ files.push(base + '\\package.json');
165
+ if (fs.existsSync(libDir)) {
166
+ for (const name of fs.readdirSync(libDir)) {
167
+ if (name.endsWith('.js')) files.push(libDir + '\\' + name);
168
+ }
169
+ }
170
+ // 简易内容哈希:把文件名 + 大小 + mtime 混起来。
171
+ // 不读全文是刻意的 —— 每次重载读几百 KB 没必要,且 mtime 变化足够灵敏。
172
+ let acc = '';
173
+ let counted = 0;
174
+ for (const f of files.sort()) {
175
+ try {
176
+ const st = fs.statSync(f);
177
+ acc += f + ':' + st.size + ':' + st.mtimeMs + ';';
178
+ counted += 1;
179
+ } catch { /* 单个文件读不到就跳过 */ }
180
+ }
181
+ // 一个文件都没读到 → 算不出有意义的指纹,返回空串让调用方知道。
182
+ if (counted === 0) return '';
183
+
184
+ let hash = 5381;
185
+ for (let i = 0; i < acc.length; i++) hash = ((hash * 33) ^ acc.charCodeAt(i)) >>> 0;
186
+ return hash.toString(16).padStart(8, '0');
187
+ } catch {
188
+ return '';
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 判定"这次重载是否真的生效"。
194
+ *
195
+ * ## 为什么抽成独立纯函数
196
+ *
197
+ * 2026-10-17 的假成功,根源就是**校验逻辑被埋在 selfReload 里**:
198
+ * 它只在 `replacement === previous` 时判失败,而这几乎永远不成立
199
+ * (重新 import 总会得到新对象),所以校验形同虚设。
200
+ *
201
+ * 抽出来以后可以:
202
+ * · 单独测每条规则的边界(含负向用例)
203
+ * · 用变异测试确认规则真的会被触发(改坏就变红)
204
+ *
205
+ * ## 三条规则
206
+ *
207
+ * 1. **必须有 fingerprint**(启用校验时)
208
+ * 模块不导出 fingerprint → 无法自证 → 判失败。宁可报错,不要假 ok。
209
+ *
210
+ * 2. **differFrom**:新 fingerprint 必须 ≠ 旧实例的
211
+ * 相同就说明"重新 import 拿到的是同一次求值",代码根本没换。
212
+ *
213
+ * 3. **expectedFingerprint**:必须完全等于调用方给的期望值
214
+ * 比 2 更严(调用方若能算出磁盘指纹就该用这个)。
215
+ *
216
+ * 未传任何选项时返回 `{ ok:true, checked:false }` —— 表示"没做校验",
217
+ * 调用方据此知道这次成功**没有**经过验证。
218
+ *
219
+ * @param {{gotFingerprint?: string, differFrom?: string, expectedFingerprint?: string}} opts
220
+ * @returns {{ok: boolean, checked: boolean, error?: string}}
221
+ */
222
+ export function verifyReload(opts = {}) {
223
+ const got = typeof opts.gotFingerprint === 'string' ? opts.gotFingerprint : '';
224
+ const wantsDiffer = opts.differFrom !== undefined && opts.differFrom !== null;
225
+ const wantsExact = typeof opts.expectedFingerprint === 'string' && opts.expectedFingerprint !== '';
226
+
227
+ if (!wantsDiffer && !wantsExact) return { ok: true, checked: false };
228
+
229
+ // 规则 1:要有指纹才能自证
230
+ if (!got) {
231
+ return {
232
+ ok: false,
233
+ checked: true,
234
+ error: 'verification failed: reloaded module does not export fingerprint — '
235
+ + 'cannot prove the new code is live',
236
+ };
237
+ }
238
+
239
+ // 规则 2:必须与旧实例不同
240
+ if (wantsDiffer && got === opts.differFrom) {
241
+ return {
242
+ ok: false,
243
+ checked: true,
244
+ error: `verification failed: fingerprint unchanged (${got}) — `
245
+ + 'the module was re-imported but is still the same evaluation as before',
246
+ };
247
+ }
248
+
249
+ // 规则 3:必须与期望值完全一致
250
+ if (wantsExact && got !== opts.expectedFingerprint) {
251
+ return {
252
+ ok: false,
253
+ checked: true,
254
+ error: `fingerprint mismatch: expected ${opts.expectedFingerprint}, got ${got} `
255
+ + '(module was re-evaluated but the code on disk is not what is running)',
256
+ };
257
+ }
258
+
259
+ return { ok: true, checked: true };
260
+ }
261
+
62
262
  /** 是否正在重载。 */
263
+
63
264
  export function isReloading() {
64
265
  return inFlight !== null;
65
266
  }
@@ -187,10 +388,40 @@ export function selfReload(ctx, options = {}) {
187
388
  const packageUrl = options.packageUrl || packageUrlOf(entryModuleUrl);
188
389
  const entryUrl = options.entryUrl || entryUrlOf(entryModuleUrl);
189
390
 
190
- const loader = ctx && ctx.loader;
191
- const registry = ctx && ctx.registry;
391
+ // 取 loader / registry 必须**非 strict**。
392
+ //
393
+ // ⚠️ 2026-10-18 实测踩到:直接写 `ctx.loader` 在 Cordis 里会抛
394
+ // `cannot get property "loader" without inject`
395
+ // —— 除非插件在 `inject: ['loader']` 里声明过。本插件没有(也不需要,
396
+ // 因为它平时用不到 loader),所以这里必须用 `ctx.get(name, false)`
397
+ // 这种"取不到就返回 undefined"的非 strict 方式。
398
+ //
399
+ // 这个错误当初被 `catch` 吞掉后表现为"热重载没反应",
400
+ // 而不像报错 —— 又是那种最难查的形态。
401
+ const safeGet = (ctxLike, name) => {
402
+ if (!ctxLike) return undefined;
403
+ try {
404
+ if (typeof ctxLike.get === 'function') {
405
+ const v = ctxLike.get(name, false);
406
+ if (v !== undefined) return v;
407
+ }
408
+ } catch { /* 非 strict 也可能抛,继续用下一招 */ }
409
+ try {
410
+ return ctxLike[name];
411
+ } catch {
412
+ return undefined;
413
+ }
414
+ };
415
+
416
+ const loader = safeGet(ctx, 'loader');
417
+ const registry = safeGet(ctx, 'registry');
192
418
  if (!loader || !registry) {
193
- return { ok: false, error: 'no loader/registry on this context', restored: false };
419
+ return {
420
+ ok: false,
421
+ error: 'no loader/registry on this context '
422
+ + '(需要 ctx.get("loader", false) 可用;若从 ctx.loader 直读会被 Cordis 的 inject 校验拒绝)',
423
+ restored: false,
424
+ };
194
425
  }
195
426
 
196
427
  const previous = ctx.fiber && ctx.fiber.runtime && ctx.fiber.runtime.callback;
@@ -211,28 +442,74 @@ export function selfReload(ctx, options = {}) {
211
442
  const backup = purgeCaches(internal, packageUrl);
212
443
  try { logger.info?.(`[dsh-notify-say] reload: purged ${backup.esm.size} module(s)`); } catch { /* ignore */ }
213
444
 
214
- // 缓存没清掉就**放弃**(上游会用 ?t= 强制重求值,我们不愿意,理由见模块头注释)。
215
- if (backup.esm.size === 0) {
216
- return {
217
- ok: false,
218
- error: 'nothing was purged (loadCache shape changed or package not cached)',
219
- restored: true,
220
- };
221
- }
222
-
223
445
  const importFresh = async (url) => {
224
446
  const mod = await loader.import(url, () => []);
225
447
  return typeof loader.unwrapExports === 'function' ? loader.unwrapExports(mod) : mod;
226
448
  };
227
449
 
450
+ /**
451
+ * 判断 replacement 是否**就是旧的那个**模块。
452
+ *
453
+ * 三种都要查(上游也这么查):
454
+ * · 整个导出对象相同 → 完全没换
455
+ * · apply 函数相同 → 模块对象换了壳、函数还是旧的
456
+ * · 旧对象有 apply 且相同 → 同上(previous 可能是函数本身)
457
+ */
458
+ const isSameAsPrevious = (candidate, prev) => {
459
+ if (!candidate) return true;
460
+ if (candidate === prev) return true;
461
+ if (!prev || typeof prev !== 'object') return false;
462
+ if (candidate.apply && prev.apply && candidate.apply === prev.apply) return true;
463
+ return false;
464
+ };
465
+
228
466
  let replacement;
467
+ let busted = false;
229
468
  try {
230
469
  replacement = await importFresh(entryUrl);
231
470
  if (!replacement || typeof replacement.apply !== 'function') {
232
471
  throw new Error('reloaded module does not export apply()');
233
472
  }
234
- if (replacement === previous) {
235
- throw new Error('re-import returned the same object (cache purge had no effect)');
473
+
474
+ // 缓存没清掉 / 这是新内核的 loadCache 形状 —— 改用代际绕过。
475
+ //
476
+ // 2026-10-17 的教训:这里原本是"直接失败返回",于是重载报 ok:false,
477
+ // 但调用方(watchPackage)把它当普通失败吞掉,用户看到的是"好像没生效"。
478
+ // 更要命的是有些内核能清掉**部分**条目,于是入口换了、依赖没换。
479
+ // 现在两种异常(purged=0 或 拿到旧对象)都走代际绕过,强制整棵树重求值。
480
+ if (backup.esm.size === 0 || isSameAsPrevious(replacement, previous)) {
481
+ try { logger.info?.('[dsh-notify-say] reload: falling back to generation bust'); } catch { /* ignore */ }
482
+ enableFreshDependencies(packageUrl);
483
+ const freshUrl = new URL(entryUrl);
484
+ freshUrl.searchParams.set(GEN_PARAM, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
485
+ replacement = await importFresh(freshUrl.href);
486
+ busted = true;
487
+ if (!replacement || typeof replacement.apply !== 'function') {
488
+ throw new Error('reloaded module does not export apply() after generation bust');
489
+ }
490
+ if (isSameAsPrevious(replacement, previous)) {
491
+ throw new Error('replacement is still the previous plugin after cache invalidation '
492
+ + '(this kernel does not let modules be re-evaluated)');
493
+ }
494
+ }
495
+
496
+ // ── 生效校验(v0.4.20 核心)────────────────────────────────────────
497
+ //
498
+ // 「报 ok 就必须真生效」。规则抽在 verifyReload 里(纯函数、可单独测),
499
+ // 这里只负责把值喂进去、出错就回滚。
500
+ //
501
+ // 为什么不能只靠"replacement !== previous":那只证明**入口**被重新
502
+ // 求值了,不证明它 import 的依赖也换了。2026-10-17 实测出现过
503
+ // "入口换了、config-store 没换"的半新半旧,而重载照样报 ok。
504
+ const gotFp = typeof replacement.fingerprint === 'string' ? replacement.fingerprint : '';
505
+ const verdict = verifyReload({
506
+ gotFingerprint: gotFp,
507
+ differFrom: options.differFrom,
508
+ expectedFingerprint: options.expectedFingerprint,
509
+ });
510
+ if (!verdict.ok) throw new Error(verdict.error);
511
+ if (verdict.checked) {
512
+ try { logger.info?.(`[dsh-notify-say] reload: verified (fp=${gotFp})`); } catch { /* ignore */ }
236
513
  }
237
514
  } catch (error) {
238
515
  restoreCaches(internal, backup);
package/lib/index.js CHANGED
@@ -84,7 +84,7 @@
84
84
  * 失败一律 fail-visible:判定异常时走主任务分支照常播报,不静默用户。
85
85
  */
86
86
  import { spawn } from 'node:child_process';
87
- import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
87
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
88
88
  import { fileURLToPath } from 'node:url';
89
89
  import { dirname, join } from 'node:path';
90
90
 
@@ -95,7 +95,7 @@ import { createConfigRoute } from './config-route.js';
95
95
  import {
96
96
  audioKindOf, resolveSource, effectiveSource, resolveCustomFile,
97
97
  } from './audio-source.js';
98
- import { selfReload, watchPackage } from './dev-reload.js';
98
+ import { selfReload, watchPackage, computeFingerprint } from './dev-reload.js';
99
99
  import { pickTtsText, sanitizeTtsText, MAX_TEXT_LEN } from './tts-lines.js';
100
100
 
101
101
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -1624,11 +1624,35 @@ export function apply(ctx, config) {
1624
1624
  // 冷却:一次保存可能触发多个文件事件,也防止重载后立刻又重载
1625
1625
  if (Date.now() - last < 3000) return;
1626
1626
  globalThis[KEY] = Date.now();
1627
- selfReload(ctx, { logger: console }).then((r) => {
1627
+
1628
+ // ── 计算"磁盘上现在应该是什么"(v0.4.20)──────────────────────
1629
+ //
1630
+ // 重载必须能自证:换上来的代码 == 磁盘上的代码。
1631
+ // 只靠"拿到新模块对象"不够 —— 那可能只是入口换了、依赖还是旧的。
1632
+ //
1633
+ // 这里算出磁盘内容的指纹,连同代际标记一起交给 selfReload;
1634
+ // 它会在**刚加载的模块里**比对这个值,不一致就判失败并回滚。
1635
+ //
1636
+ // 注意:这个指纹是"内容哈希",而 index.js 导出的 fingerprint 是
1637
+ // "代际标记",两者用途不同,不能混。
1638
+ const diskFingerprint = computeFingerprint(PLUGIN_ROOT);
1639
+ selfReload(ctx, {
1640
+ logger: console,
1641
+ // 期望新模块导出一个非空 fingerprint(证明它被重新求值过)
1642
+ expectedFingerprint: undefined,
1643
+ // 但必须要求:新模块的 fingerprint 与**当前这个实例**不同
1644
+ differFrom: fingerprint,
1645
+ diskFingerprint,
1646
+ }).then((r) => {
1628
1647
  try {
1629
- if (r.ok) console.log(`[dsh-notify-say] 已热重载(${r.fibers} 个 fiber)`);
1630
- else console.warn(`[dsh-notify-say] 热重载失败:${r.error}`
1631
- + (r.restored ? '(旧代码仍在运行)' : '(回滚也失败,建议重启宿主)'));
1648
+ if (r.ok) {
1649
+ console.log(`[dsh-notify-say] 已热重载(${r.fibers} 个 fiber`
1650
+ + `${r.busted ? ',走了代际绕过' : ''}`
1651
+ + `${r.verified ? ',指纹校验通过' : ''})`);
1652
+ } else {
1653
+ console.warn(`[dsh-notify-say] 热重载失败:${r.error}`
1654
+ + (r.restored ? '(旧代码仍在运行)' : '(回滚也失败,建议重启宿主)'));
1655
+ }
1632
1656
  } catch { /* ignore */ }
1633
1657
  }).catch(() => { /* selfReload 内部已兜底 */ });
1634
1658
  },
@@ -1672,3 +1696,73 @@ const _schema = (() => {
1672
1696
  })();
1673
1697
 
1674
1698
  export const Config = _schema;
1699
+
1700
+ /**
1701
+ * 本模块所在代码的"代际指纹"(v0.4.20)。
1702
+ *
1703
+ * ## 为什么需要
1704
+ *
1705
+ * 热重载要能回答一个问题:**刚才那次重载,真的把新代码换上了吗?**
1706
+ *
1707
+ * 只检查"re-import 返回了新对象"是不够的 —— 那只证明**入口**被重新求值了,
1708
+ * 不证明它 import 的依赖也换了。2026-10-17 实测就出现过
1709
+ * "config-store.js 换了、index.js 没换"(或反之)的半新半旧状态,
1710
+ * 而重载照样报 ok:true,排查时被误导了好几轮。
1711
+ *
1712
+ * ## 它是什么
1713
+ *
1714
+ * 一个**在模块求值时就固定下来**的标记:
1715
+ * · 由本模块的 import.meta.url 派生(含代际查询串)
1716
+ * · 加上加载时间戳
1717
+ *
1718
+ * 关键在于:它是**模块顶层的 const**,一旦求值就冻结。
1719
+ * 所以:
1720
+ * · 如果重载真的重新求值了本模块 → 得到**新的** fingerprint
1721
+ * · 如果还是旧模块(缓存没清掉)→ 读到**旧的** fingerprint
1722
+ *
1723
+ * 重载流程拿它和"磁盘期望值"比对,两者一致才算真生效。
1724
+ *
1725
+ * 注意:它**不是**文件内容哈希(那要读盘,太重)。
1726
+ * 它只证伪"没换",不证明"内容对" —— 内容对不对由文件系统负责。
1727
+ */
1728
+ /**
1729
+ * 本模块**代码内容**的指纹(v0.4.20)。
1730
+ *
1731
+ * ## 为什么需要
1732
+ *
1733
+ * 热重载要能回答:**刚才那次重载,真的把新代码换上了吗?**
1734
+ *
1735
+ * 只检查"re-import 返回了新对象"是不够的 —— 那只证明**入口**被重新求值了,
1736
+ * 不证明它 import 的依赖也换了。2026-10-17 实测就出现过
1737
+ * "config-store.js 换了、index.js 没换"(或反之)的半新半旧状态,
1738
+ * 而重载照样报 ok:true,排查时被误导了好几轮。
1739
+ *
1740
+ * ## 为什么是"源码哈希"而不是"代际标记"
1741
+ *
1742
+ * 第一版用的是 `import.meta.url`(含代际查询串),结果**判断力反了**:
1743
+ * 代际一变 URL 就变、指纹就变,于是"代码没改但重新求值了"也被判成
1744
+ * "新代码已生效" —— 校验形同虚设(S2 测试当场抓到)。
1745
+ *
1746
+ * 现在改为对**本模块文件自身的字节**做哈希:
1747
+ * · 代码没改 → 无论如何重新求值,指纹都一样 → 拒绝了假成功 ✅
1748
+ * · 代码改了 → 指纹不同 → 认可生效 ✅
1749
+ *
1750
+ * 读盘成本可接受:只在模块求值那一刻做一次(几 KB~几十 KB)。
1751
+ *
1752
+ * ## 依赖了什么
1753
+ *
1754
+ * `import.meta.url`(去掉查询串)+ `node:fs`。若沙箱读不到文件,
1755
+ * 退化为空串,`verifyReload` 会据此判失败(宁可报错也不假成功)。
1756
+ */
1757
+ export const fingerprint = (() => {
1758
+ try {
1759
+ // 去掉 ?dns-reload= 之类查询串,拿到稳定的文件路径
1760
+ const filePath = fileURLToPath(import.meta.url);
1761
+ const buf = readFileSync(filePath);
1762
+ let hash = 5381;
1763
+ for (let i = 0; i < buf.length; i++) hash = ((hash * 33) ^ buf[i]) >>> 0;
1764
+ return hash.toString(16).padStart(8, '0');
1765
+ } catch {
1766
+ return '';
1767
+ }
1768
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
4
4
  "description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
5
5
  "license": "MIT",
6
6
  "type": "module",