dsh-notify-say 0.4.56 → 0.4.58

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 CHANGED
@@ -3,6 +3,14 @@
3
3
  > 本文件由 `tools/release-build.ps1` 从开发提交自动生成,**请勿手工编辑**。
4
4
  > 只列面向使用者的变更(新增功能与修复),不含内部维护提交。
5
5
 
6
+ ## v0.4.58 — 2026-10-18
7
+
8
+ - feat(ui): v0.4.58 —— 自重载入口并入「版本与更新」
9
+
10
+ ## v0.4.57 — 2026-10-18
11
+
12
+ - fix(reload): v0.4.57 —— 自重载改为「点一次重载一次」(修宿主崩溃根因)
13
+
6
14
  ## v0.4.54 — 2026-10-18
7
15
 
8
16
  - feat(ui): v0.4.54 —— 第二阶段 UI:可读性、动态显示、二级分组
package/README.md CHANGED
@@ -266,8 +266,14 @@ Windows 的 Toast 协议激活是一个固定串(`dsh://open`),没有地
266
266
  - **跳会话依赖未文档化 API**(`uiWorkspace`),官方随时可能改;坏了会自动降级为"只唤起窗口"
267
267
  - `blocked`(回合被拦截)只用系统蜂鸣,不播失败语音 —— 语义是"被拦下"而非"失败"
268
268
  - **失败语音只覆盖部分音色**:选到未覆盖的音色时,会在同池内换一个音色播,不会哑掉、也不会播错语义
269
- - **开发期热重载是实验性功能**(`devReload`,默认关):它依赖 DSH/Cordis 内部结构与 Node 模块加载机制,
270
- 能工作但不算稳定能力。用户不需要它 —— 那是给改插件源码的人用的
269
+ - **自重载是实验性功能,而且只有"点一次"**:设置页「高级设置 → 开发者选项」里有个
270
+ 「重载现在生效」按钮,点一下尝试一次;**没有开关、不会自动监听文件**。
271
+
272
+ 为什么改成这样:2026-10-18 实测「监听文件自动重载」会把宿主拖崩 —— 一次改 UI 期间
273
+ 触发了 40 多次失败重载,每次销毁实例都会释放宿主仍在用的会话引用,最后渲染进程崩溃。
274
+ 危险的不是"重载失败",是"**反复**重载"
275
+ - **自重载不能替代重启**:它只能重新导入「同一路径上的」代码。用 `dsh plugin update`
276
+ 升级后,新版本装在 `.pnpm/<name>@<ver>/` 另一个路径,自重载做不到 —— 必须重启 DSH
271
277
  - **13 个音色的许可结论是条件性判断**(依据官方通用条款),不代表各权利人出具过专项授权
272
278
 
273
279
  ---
@@ -330,7 +336,7 @@ npm run test:route # 需要 --import loader 的接线测试
330
336
  npm run test:audio-wire
331
337
  ```
332
338
 
333
- **418 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
339
+ **426 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
334
340
 
335
341
  少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
336
342
  找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
package/lib/client.js CHANGED
@@ -610,6 +610,12 @@ window.__ModuleLoader__.load({
610
610
  var sndset = sndst[1]
611
611
  var sndPref = sndst[0].open
612
612
 
613
+ // 一次性重载的界面状态(v0.4.57)。独立 state:与配置无关,
614
+ // 也不该因为保存配置(主 state 25 处 set 重建)被清掉。
615
+ var rlst = React.useState({ busy: false, msg: "", err: "" })
616
+ var rlset = rlst[1]
617
+ var rlState = rlst[0]
618
+
613
619
  var st = React.useState({
614
620
  loaded: false,
615
621
  values: {
@@ -965,6 +971,46 @@ window.__ModuleLoader__.load({
965
971
  //
966
972
  // 只检查、不安装。升级由用户自己执行 DSH 命令。
967
973
  // 详见 lib/update-check.js 头注释(为什么不自动更新)。
974
+ /**
975
+ * 一次性自重载(v0.4.57)。
976
+ *
977
+ * 用户要求:「点一下重载一下」,不要"开了就一直重载"。
978
+ * 所以这是一次显式请求,服务端也只尝试一次、失败不重试。
979
+ *
980
+ * ⚠️ 成功后当前实例已被 dispose、新实例接管,**这个响应可能发不回来**。
981
+ * 所以 .catch 与 !r 都按"可能已经重载"提示用户刷新页面,
982
+ * 而不是报"失败" —— 否则用户会以为没生效,反复点。
983
+ */
984
+ function doReload() {
985
+ rlset({ busy: true, msg: "", err: "" })
986
+ fetch(cfgUrl('reload'), {
987
+ method: 'POST',
988
+ cache: 'no-store',
989
+ credentials: 'same-origin',
990
+ headers: { 'content-type': 'application/json' },
991
+ body: '{}',
992
+ })
993
+ .then(function (r) { return r ? r.json().catch(function () { return null }) : null })
994
+ .then(function (r) {
995
+ if (!r) {
996
+ rlset({ busy: false, msg: '', err: '没有收到回应 —— 可能已经重载,请刷新页面确认;若未生效请重启 DSH。' })
997
+ return
998
+ }
999
+ if (r.ok && r.noop) {
1000
+ rlset({ busy: false, msg: '磁盘上的代码与正在运行的一致,无需重载。', err: '' })
1001
+ return
1002
+ }
1003
+ if (r.ok) {
1004
+ rlset({ busy: false, msg: '已重载。请刷新页面,让设置界面也用新代码。', err: '' })
1005
+ return
1006
+ }
1007
+ rlset({ busy: false, msg: '', err: r.hint || '重载没有成功 —— 请重启 DSH。' })
1008
+ })
1009
+ .catch(function () {
1010
+ rlset({ busy: false, msg: '', err: '请求失败 —— 可能已经重载,请刷新页面确认;若未生效请重启 DSH。' })
1011
+ })
1012
+ }
1013
+
968
1014
  function checkVersion(force) {
969
1015
  vset(function (p) {
970
1016
  return { info: p.info, loading: true, copied: false }
@@ -1788,7 +1834,7 @@ window.__ModuleLoader__.load({
1788
1834
  onClick: toggleAdv,
1789
1835
  }, '高级设置' + (advOpen ? ' ▾' : ' ▸')),
1790
1836
  h('div', { style: S.sectionDesc },
1791
- '给每种提醒单独设声音来源、日志与诊断、版本更新、开发者选项。点标题展开。'),
1837
+ '给每种提醒单独设声音来源、看日志与诊断、检查版本更新。点标题展开。'),
1792
1838
  ))
1793
1839
 
1794
1840
  if (advOpen) {
@@ -1803,7 +1849,8 @@ window.__ModuleLoader__.load({
1803
1849
  ['sound', '按事件定制声音', '给每种提醒单独指定音源;不改就跟随上面的「声音来源」。'],
1804
1850
  ['diag', '诊断与日志', '日志详细度,以及导出脱敏后的诊断文件。'],
1805
1851
  ['update', '版本与更新', '查看运行中的版本、检查有没有新版。'],
1806
- ['dev', '开发者选项', '实验性功能,普通使用不需要开。'],
1852
+ // 开发者选项已合并(v0.4.58):它原本只有「实验性提示 + 重载按钮」,
1853
+ // 重载搬进「版本与更新」后就没有内容了 —— 留一个空组是纯噪音。
1807
1854
  ]
1808
1855
  function toggleAdvSec(id) {
1809
1856
  avset(function (prev) {
@@ -2270,31 +2317,40 @@ window.__ModuleLoader__.load({
2270
2317
  rows.push(h('div', { key: 'up-offline', style: S.subHint },
2271
2318
  '检查更新会访问 npm registry 与 GitHub(每 24 小时最多一次)。'
2272
2319
  + '通知与语音不依赖它;网络不可用时照常工作。'))
2273
- }
2274
- if (id === 'dev') {
2275
- // 明确告知这是实验性功能(用户要求,v0.4.54)。
2276
- rows.push(h('div', { key: 'dev-warn', style: S.subHint },
2277
- '⚠️ 实验性:依赖宿主内部结构与 Node 模块加载机制,'
2278
- + 'DSH 升级后可能失效。失效时只报错,不影响播报。'))
2279
- // ── 开发选项:热重载 ────────────────────────────────────────────────
2280
- //
2281
- // 让用户自己选,而不是我替他决定。默认关(生产安装不需要)。
2282
- // 开启后:改插件源码保存 → 自动换成新代码,不用重启 DSH。
2283
- rows.push(h('div', { key: 'sepHr', style: { margin: '10px 0 4px', borderTop: '1px solid rgba(128,128,128,.25)' } }))
2284
- rows.push(h('div', { key: 'dr', style: S.toggleRow },
2285
- h('input', {
2286
- type: 'checkbox',
2287
- checked: v.devReload === true,
2288
- disabled: s.busy,
2289
- style: S.toggleBox,
2290
- onChange: function (e) { apply({ devReload: !!e.target.checked }, '热重载设置已更新') },
2291
- }),
2292
- h('div', { style: S.toggleText },
2293
- h('span', { style: S.toggleLabel }, '改代码后自动热重载(实验性)'),
2294
- h('span', { style: S.toggleDesc },
2295
- '保存插件源码后自动生效,不必重启 DSH。'),
2296
- ),
2297
- ))
2320
+
2321
+ // ── 让新版本生效(v0.4.58 起从开发者选项搬来)──────────────
2322
+ //
2323
+ // 用户 2026-10-18 指出:既然重载改成"点一下试一次"了,
2324
+ // 那它就该在**更新流程**里,而不是藏在开发者选项。
2325
+ //
2326
+ // 这个判断是对的 —— 需要的时刻正是"我刚装完新版本,
2327
+ // 想让它生效"。**位置本身就是信息**。
2328
+ //
2329
+ // ⚠️ 能力边界写在旁边,不能让人以为点一下就能替代重启。
2330
+ rows.push(h('div', { key: 'rl-row', style: S.row },
2331
+ h('button', {
2332
+ type: 'button',
2333
+ disabled: rlState.busy,
2334
+ style: S.btn,
2335
+ onClick: function () { doReload() },
2336
+ }, rlState.busy ? '重载中…' : '重载现在生效'),
2337
+ h('span', { style: S.hint },
2338
+ (vi && vi.needsRestart)
2339
+ ? ('磁盘上是 v' + vIns + ',运行中是 v' + vRun + ' —— 点这里试着直接换过来')
2340
+ : '点一次试一次,不会自动重复'),
2341
+ ))
2342
+ if (rlState.msg) {
2343
+ rows.push(h('div', { key: 'rl-msg', style: S.ok }, rlState.msg))
2344
+ }
2345
+ if (rlState.err) {
2346
+ rows.push(h('div', { key: 'rl-err', style: S.err }, rlState.err))
2347
+ }
2348
+ rows.push(h('div', { key: 'rl-hint', style: S.subHint },
2349
+ '⚠️ 实验性:依赖宿主内部结构与 Node 模块加载机制,DSH 升级后可能失效;'
2350
+ + '失效时只报错,不影响播报。它只能重新导入「同一路径上的」代码 —— '
2351
+ + '若刚用 dsh plugin update 升级,新版本装在另一个路径,'
2352
+ + '这一步做不到,必须重启 DSH。'
2353
+ + '重载会重建插件实例,已打开的会话引用会失效,成功后请刷新页面。'))
2298
2354
  }
2299
2355
  }
2300
2356
  })
package/lib/dev-reload.js CHANGED
@@ -682,65 +682,71 @@ export function selfReload(ctx, options = {}) {
682
682
  return inFlight.finally(() => { inFlight = null; });
683
683
  }
684
684
 
685
- /**
686
- * 监听包目录,变更时触发回调(防抖)。
687
- *
688
- * 刻意**不自动重载**:由调用方决定怎么用(例如只在 devReload 开启时调 selfReload)。
689
- * 自动重载会在你保存到一半的文件上触发,拿到语法错误的半成品。
690
- *
691
- * @param {string} packageDir
692
- * @param {{ onChange?: Function, logger?: object, debounceMs?: number }} [opts]
693
- * @returns {() => void} 停止函数
694
- */
695
- export function watchPackage(packageDir, opts = {}) {
696
- const logger = opts.logger || console;
697
- const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 800;
698
- let timer;
699
- let paused = false;
700
- let watcher;
701
- try {
702
- // ⚠️ 2026-10-18 外部审阅指出:监听整个目录,但指纹只覆盖
703
- // package.json + lib/*.js。改文档或测试也会触发一次注定失败的重载。
704
- // 过滤在 schedule(filename) 里做(见 isSourceFile)。
705
- watcher = fs.watch(packageDir, { recursive: true });
706
- try { opts.diag?.('watching ', packageDir); } catch {}
707
- } catch (error) {
708
- try { logger.warn?.(`[dsh-notify-say] cannot watch ${packageDir}: ${(error && error.message) || error}`); } catch { /* ignore */ }
709
- return () => {};
685
+ // ── 重载决策(v0.4.57)──────────────────────────────────────────────────
686
+ //
687
+ // 把「这次点击该不该真的重载」抽成纯函数 —— 因为它正是上次崩溃的关键:
688
+ // 在自动监听时代,**每一次文件保存都直接进入重载流程**,没有做任何
689
+ // "现在真的需要重载吗"的判断,于是 40 多次保存换来 40 多次 dispose。
690
+ //
691
+ // 抽成纯函数的好处是它能被完整测试(不需要活宿主):
692
+ // · 代码没变 → noop(一次 dispose 都不做)
693
+ // · 插件根真实路径变了(pnpm 升级到新版本)→ 拒绝,并告知必须重启
694
+ // · 确实变了 → reload(一次)
695
+ //
696
+ // @param {{ diskFingerprint?: string, runningFingerprint?: string,
697
+ // resolvedRootAtLoad?: string, resolvedRootNow?: string }} input
698
+ // @returns {{ action: 'noop'|'refuse-path-changed'|'reload',
699
+ // reason: string, hint?: string }}
700
+ export function planReload(input = {}) {
701
+ const disk = String(input.diskFingerprint || '');
702
+ const running = String(input.runningFingerprint || '');
703
+
704
+ // ① 读不到磁盘指纹:无法证明"变了",但也不能断言"没变"。
705
+ // 按 reload 处理会让用户在只读沙箱里反复触发失败 ——
706
+ // 按 noop 处理则可能让用户以为已生效。
707
+ // 这里选 reload(唯一有机会真正生效的选择),失败会如实报错。
708
+ if (!disk) {
709
+ return { action: 'reload', reason: 'fingerprint-unreadable' };
710
710
  }
711
- // 只有这些文件变了才值得重载(与指纹覆盖范围保持一致)。
712
- const isSourceFile = (filename) => {
713
- if (!filename) return true; // 拿不到文件名时保守处理:当真
714
- const f = String(filename).replace(/\\/g, '/');
715
- if (f === 'package.json') return true;
716
- if (f.startsWith('lib/') && f.endsWith('.js')) return true;
717
- return false;
718
- };
719
-
720
- const schedule = (filename) => {
721
- if (paused) return;
722
- if (!isSourceFile(filename)) return; // 文档/测试变更不触发重载
723
- clearTimeout(timer);
724
- timer = setTimeout(() => {
725
- timer = undefined;
726
- if (paused) return;
727
- try { opts.onChange?.(); } catch (error) {
728
- try { logger.warn?.(`[dsh-notify-say] watch onChange threw: ${(error && error.message) || error}`); } catch { /* ignore */ }
729
- }
730
- }, debounceMs);
731
- if (timer && typeof timer.unref === 'function') timer.unref();
732
- };
733
- // fs.watch 的监听器签名是 (eventType, filename)
734
- watcher.on('change', (eventType, filename) => {
735
- try { opts.diag?.('event ', String(filename), ' accepted=', String(isSourceFile(filename))); } catch {}
736
- schedule(filename);
737
- });
738
- watcher.on('error', (error) => {
739
- try { logger.warn?.(`[dsh-notify-say] watcher error: ${(error && error.message) || error}`); } catch { /* ignore */ }
740
- });
741
- return () => {
742
- paused = true;
743
- clearTimeout(timer);
744
- try { watcher.close(); } catch { /* 已关闭 */ }
745
- };
711
+
712
+ // ② 磁盘内容 == 当前实例内容 → 毫无必要重载。
713
+ // **这一条就是上次 40 多次无效 dispose 的止血点。**
714
+ if (running && disk === running) {
715
+ return { action: 'noop', reason: 'already-current' };
716
+ }
717
+
718
+ // ③ 插件根的真实路径变了 → 自重载救不了。
719
+ //
720
+ // `dsh plugin update` 升级后 pnpm 会把包装到新的 .pnpm/<name>@<ver>/,
721
+ // 而我们手上只有加载时那个路径;从旧路径重新导入拿到的是旧代码。
722
+ // 与其让用户点完一个"看起来成功其实没换"的按钮,不如直接说清楚。
723
+ const atLoad = String(input.resolvedRootAtLoad || '');
724
+ const now = String(input.resolvedRootNow || '');
725
+ if (atLoad && now && atLoad !== now) {
726
+ return {
727
+ action: 'refuse-path-changed',
728
+ reason: 'path-changed',
729
+ hint: '新版本装在另一个路径,自重载无法生效 —— 请重启 DSH。',
730
+ };
731
+ }
732
+
733
+ return { action: 'reload', reason: 'changed' };
746
734
  }
735
+ // ── 自动监听已移除(v0.4.57)─────────────────────────────────────────────
736
+ //
737
+ // 这里原来导出 `watchPackage()`:监视包目录,文件一变就触发一次自重载。
738
+ //
739
+ // **2026-10-18 实测它把宿主拖崩了**,崩溃日志给的两条硬证据:
740
+ //
741
+ // ① 45 条「热重载失败:fingerprint unchanged(旧代码仍在运行)」
742
+ // —— 那天为调 UI 改了 40 多次文件,每次保存都触发一次注定失败的尝试;
743
+ // ② 同一时刻反复出现「Session reference "..." is released」
744
+ // —— 每次重载都 dispose 旧实例,而 dispose 会释放渲染进程仍在用的
745
+ // 会话引用。反复 45 次之后渲染进程拿到损坏的 client 包,
746
+ // 抛 SyntaxError,宿主退出。
747
+ //
748
+ // 结论:危险的不是「重载失败」,而是「**反复**重载」。
749
+ // 现在改由用户在设置页点一次按钮、调一次 `selfReload()`——
750
+ // 一次点击 = 一次尝试,最坏结果是「这次没成功」,不再累积。
751
+ //
752
+ // 相关:lib/index.js 的 `reloadOnce()` 与 `POST /reload` 路由。
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, readFileSync, renameSync, statSync } from 'node:fs';
87
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync } from 'node:fs';
88
88
  import { fileURLToPath } from 'node:url';
89
89
  import { dirname, join } from 'node:path';
90
90
 
@@ -102,11 +102,26 @@ import { checkForUpdate } from './update-check.js';
102
102
  import { createVoicePackRoute } from './voice-pack-route.js';
103
103
  import { isDebug, redactText } from './log-privacy.js';
104
104
  import { exportDiagnostics } from './diagnostics.js';
105
- import { selfReload, watchPackage, computeFingerprint } from './dev-reload.js';
105
+ // ⚠️ 不再导入 watchPackage(v0.4.57):自动监听重载已移除,见 dev-reload.js 末尾注释。
106
+ import { selfReload, computeFingerprint, planReload } from './dev-reload.js';
106
107
  import { pickTtsText, sanitizeTtsText, MAX_TEXT_LEN } from './tts-lines.js';
107
108
 
108
109
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
109
110
 
111
+ /**
112
+ * 加载那一刻插件根的**真实路径**(解开 Junction / 符号链接)。
113
+ *
114
+ * 用途:判断"自重载还能不能救"。
115
+ *
116
+ * 自重载只能重新导入「**同一路径上的**」代码。`dsh plugin update` 升级后,
117
+ * pnpm 会把包装到新的 `.pnpm/<name>@<ver>/` 下,而这里的 PLUGIN_ROOT
118
+ * 在加载时就已解析成旧路径 —— 那时从旧路径重新导入,拿到的仍是旧代码。
119
+ * 所以点按钮时比一次,路径变了就**如实说"必须重启"**,不给假希望。
120
+ */
121
+ const RESOLVED_PLUGIN_ROOT = (() => {
122
+ try { return realpathSync(PLUGIN_ROOT); } catch { return PLUGIN_ROOT; }
123
+ })();
124
+
110
125
 
111
126
  // 检查更新的宿主侧缓存(v0.4.48)。
112
127
  //
@@ -749,6 +764,11 @@ export function effectiveForClient(c) {
749
764
  }
750
765
 
751
766
  export function apply(ctx, config) {
767
+ // 一次性自重载的实现槽(v0.4.57)。
768
+ //
769
+ // 路由在 apply 中段注册、实现挂在 apply 末尾 —— 用 let 槽连接两者。
770
+ // (不写成函数声明:它包在 try 块里,严格模式下块级作用域不会提升出去。)
771
+ let reloadOnce = null;
752
772
  // ── 配置来源(v0.4.0 起三源合并)──────────────────────────────────────────
753
773
  //
754
774
  // 优先级(后覆盖前):
@@ -1610,7 +1630,10 @@ function hasVoiceLine(psKind) {
1610
1630
  (head === 'config' && /\/set\b/.test(pathname))
1611
1631
  || head === 'consume'
1612
1632
  || head === 'diagnostics'
1613
- || (head === 'voicepacks' && (/\/install\b/.test(pathname) || /\/uninstall\b/.test(pathname)));
1633
+ || (head === 'voicepacks' && (/\/install\b/.test(pathname) || /\/uninstall\b/.test(pathname)))
1634
+ // v0.4.57:一次性自重载会 dispose 当前实例并重新求值模块,
1635
+ // 是不可逆副作用,按写操作处理。
1636
+ || head === 'reload';
1614
1637
  const guard = checkLocalOrigin(req, { write: isWriteRoute });
1615
1638
  if (!guard.ok) {
1616
1639
  try {
@@ -1627,6 +1650,56 @@ function hasVoiceLine(psKind) {
1627
1650
  //
1628
1651
  // 为什么不接受客户端传 URL:那等于让本机访问任意地址(SSRF)。
1629
1652
  // 查询地址固定在 update-check.js 里。
1653
+ // ── 一次性自重载(v0.4.57)──────────────────────────────────
1654
+ //
1655
+ // 用户 2026-10-18 明确要求:「点一下重载一下」,不要"开了就一直重载"。
1656
+ // 所以这里是**纯显式动作**:没有任何自动触发(watcher 已删除)。
1657
+ //
1658
+ // 只接受 POST —— 它不可逆。
1659
+ if (head === 'reload') {
1660
+ if (String((req && req.method) || 'GET').toUpperCase() !== 'POST') {
1661
+ try {
1662
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', allow: 'POST' });
1663
+ res.end(JSON.stringify({ ok: false, reason: 'method-not-allowed' }));
1664
+ } catch { /* 响应已发出 */ }
1665
+ return;
1666
+ }
1667
+ if (!reloadOnce) {
1668
+ try {
1669
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1670
+ res.end(JSON.stringify({ ok: false, reason: 'reload-unavailable' }));
1671
+ } catch { /* 响应已发出 */ }
1672
+ return;
1673
+ }
1674
+ // 限流:5 秒内只允许一次(防连点、也防脚本反复触发 dispose)
1675
+ const rlGate = throttle('reload', 5000);
1676
+ if (!rlGate.ok) {
1677
+ try {
1678
+ res.writeHead(429, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'retry-after': String(Math.ceil((rlGate.retryAfterMs || 1000) / 1000)) });
1679
+ res.end(JSON.stringify({ ok: false, reason: 'throttled', retryAfterMs: rlGate.retryAfterMs }));
1680
+ } catch { /* 响应已发出 */ }
1681
+ return;
1682
+ }
1683
+ // ⚠️ 成功后当前实例已被 dispose、新实例接管 ——
1684
+ // 这个响应**可能发不出去**。客户端必须把"请求失败"也当作
1685
+ // "可能已重载",提示用户刷新页面。
1686
+ Promise.resolve()
1687
+ .then(() => reloadOnce())
1688
+ .then((r) => {
1689
+ try {
1690
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1691
+ res.end(JSON.stringify(r));
1692
+ } catch { /* 响应已发出 */ }
1693
+ })
1694
+ .catch((e) => {
1695
+ try {
1696
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1697
+ res.end(JSON.stringify({ ok: false, reason: 'exception', error: String((e && e.message) || e) }));
1698
+ } catch { /* 响应已发出 */ }
1699
+ });
1700
+ return;
1701
+ }
1702
+
1630
1703
  if (head === 'version') {
1631
1704
  // ?force=1 表示用户手动点了「检查更新」,绕过 24 小时缓存
1632
1705
  const force = /[?&]force=1\b/.test(String((req && req.url) || ''));
@@ -1878,92 +1951,90 @@ function hasVoiceLine(psKind) {
1878
1951
  // ⚠️ 防无限循环:重载后新实例的 apply 会**再次**读到 devReload=true,
1879
1952
  // 若不做标记就会重载→重载→…。故用 globalThis 上的时间戳做冷却:
1880
1953
  // 30 秒内已重载过就跳过。key 带插件名,避免与其它插件冲突。
1881
- try {
1882
- if (cfg.devReload) {
1883
- // ⚠️ 语义必须是「**改文件时**重载」,不是「启动后重载一次」。
1884
- //
1885
- // 第一版写成启动后 1.5 秒触发一次 —— 那是错的:没人改文件时重载毫无意义,
1886
- // 而且每次启动都白跑一次高风险操作。真正的需求是监听源码变更。
1887
- //
1888
- // 用 watchPackage(lib/dev-reload.js)监听包目录,变更 → 防抖 → 重载。
1889
- // 防抖 800ms:编辑器保存常触发多次事件,太灵敏会在写到一半时重载,
1890
- // 拿到语法不完整的文件。
1891
- const stop = watchPackage(PLUGIN_ROOT, {
1892
- // v0.4.44 诊断:把 watcher 的注册与事件写进 notify.log,
1893
- // 否则"重载没触发"和"重载触发了但失败"无法区分(我为此误判过一次)。
1894
- diag: (tag, a, b, c) => appendNotifyLog('[watch] ' + tag + (a || '') + (b || '') + (c || '')),
1954
+ // ── 一次性自重载的实现(v0.4.57)──────────────────────────────────────
1955
+ //
1956
+ // 用户要求:点一下重载一次;不点就不动。这里只是**把能力准备好**,
1957
+ // 不做任何自动触发 —— 触发点只有 `POST /reload`(设置页的按钮)。
1958
+ //
1959
+ // ## 为什么删掉 watcher
1960
+ //
1961
+ // 2026-10-18 实测它把宿主拖崩,崩溃日志两条硬证据:
1962
+ // ① 45 条「fingerprint unchanged(旧代码仍在运行)」
1963
+ // —— 那天为调 UI 改了 40 多次文件,每次保存触发一次注定失败的尝试;
1964
+ // ② 同一时刻反复「Session reference "..." is released」
1965
+ // —— 每次重载都 dispose 旧实例,而 dispose 会释放渲染进程仍在用的
1966
+ // 会话引用;反复几十次后渲染进程拿到损坏的 client 包,
1967
+ // 抛 SyntaxError,宿主退出。
1968
+ //
1969
+ // 结论:危险的不是"失败",是"**反复**"。一次点击一次尝试,不再累积。
1970
+ reloadOnce = async function () {
1971
+ // ① 前置:磁盘内容与当前实例相同 → 什么都不做
1972
+ //
1973
+ // 同一件事的"没变"判断放在最前面,因为它最省钱也最安全:
1974
+ // 代码没变就不该 dispose 任何东西。
1975
+ let diskFingerprint = '';
1976
+ try { diskFingerprint = computeFingerprint(PLUGIN_ROOT); } catch { /* 读不到就继续 */ }
1977
+ // ② 决策抽在 dev-reload.js 的 planReload() —— 纯函数,可完整测试。
1978
+ //
1979
+ // 代码没变 → noop(一次 dispose 都不做)
1980
+ // 插件根真实路径变了 → 拒绝,告知必须重启
1981
+ // 确实变了 → reload(一次)
1982
+ let nowReal = '';
1983
+ try { nowReal = realpathSync(PLUGIN_ROOT); } catch { /* 解不开就不比 */ }
1984
+ const plan = planReload({
1985
+ diskFingerprint,
1986
+ runningFingerprint: fingerprint,
1987
+ resolvedRootAtLoad: RESOLVED_PLUGIN_ROOT,
1988
+ resolvedRootNow: nowReal,
1989
+ });
1990
+ if (plan.action === 'noop') {
1991
+ return { ok: true, noop: true, reason: plan.reason, fingerprint };
1992
+ }
1993
+ if (plan.action === 'refuse-path-changed') {
1994
+ return { ok: false, reason: plan.reason, hint: plan.hint };
1995
+ }
1996
+
1997
+ // ③ 一次尝试。失败**不重试**,直接如实返回让用户去重启。
1998
+ const eventsBefore = readEventCount();
1999
+ let r;
2000
+ try {
2001
+ r = await selfReload(ctx, {
1895
2002
  logger: console,
1896
- debounceMs: 800,
1897
- onChange: () => {
1898
- const KEY = Symbol.for('dsh-notify-say.devReloadAt');
1899
- const last = Number(globalThis[KEY]) || 0;
1900
- // 冷却:一次保存可能触发多个文件事件,也防止重载后立刻又重载
1901
- if (Date.now() - last < 3000) return;
1902
- globalThis[KEY] = Date.now();
1903
-
1904
- // v0.4.36:记下重载前的事件计数,成功后算差值。
1905
- // 差值 > 0 说明重载窗口内有事件到达,那些播报可能丢了。
1906
- const eventsBeforeReload = readEventCount();
1907
-
1908
- // ── 计算"磁盘上现在应该是什么"(v0.4.20)──────────────────────
1909
- //
1910
- // 重载必须能自证:换上来的代码 == 磁盘上的代码。
1911
- // 只靠"拿到新模块对象"不够 —— 那可能只是入口换了、依赖还是旧的。
1912
- //
1913
- // 这里算出磁盘内容的指纹,连同代际标记一起交给 selfReload;
1914
- // 它会在**刚加载的模块里**比对这个值,不一致就判失败并回滚。
1915
- //
1916
- // 注意:这个指纹是"内容哈希",而 index.js 导出的 fingerprint 是
1917
- // "代际标记",两者用途不同,不能混。
1918
- const diskFingerprint = computeFingerprint(PLUGIN_ROOT);
1919
- selfReload(ctx, {
1920
- logger: console,
1921
- // 期望新模块导出一个非空 fingerprint(证明它被重新求值过)
1922
- expectedFingerprint: undefined,
1923
- // 但必须要求:新模块的 fingerprint 与**当前这个实例**不同
1924
- differFrom: fingerprint,
1925
- diskFingerprint,
1926
- }).then((r) => {
1927
- try {
1928
- if (r.ok) {
1929
- // 重载窗口内是否有事件被吞掉(见 EVENT_COUNTER_KEY 的注释)
1930
- try {
1931
- const missed = readEventCount() - eventsBeforeReload;
1932
- if (missed > 0) {
1933
- // ⚠️ 文案必须准确(外部审阅 2026-10-18 指出):
1934
- // 这个计数器只在 session/event 回调里 +1 ——
1935
- // 若重载期间**根本没有监听器**,事件被宿主发出但插件没收到,
1936
- // 计数不会增加,missed 却是 0(漏检)。
1937
- // 反过来旧/新实例正常处理了事件,计数增加但通知已发出(误报)。
1938
- //
1939
- // 所以它只能说明"重载窗口内插件观察到过事件",
1940
- // 不能当漏报数量。文案按这个口径写。
1941
- appendNotifyLog(`[reload] 重载窗口内观察到 ${missed} 个事件(非漏报数,仅供排查参考)`);
1942
- console.warn(`[dsh-notify-say] 重载窗口内观察到 ${missed} 个事件(非漏报数)`);
1943
- }
1944
- } catch { /* ignore */ }
1945
- console.log(`[dsh-notify-say] 已热重载(${r.fibers} 个 fiber`
1946
- + `${r.busted ? ',走了代际绕过' : ''}`
1947
- + `${r.verified ? ',指纹校验通过' : ''})`);
1948
- } else {
1949
- appendNotifyLog(`[reload] FAILED ${r.error}`);
1950
- appendNotifyLog(`[reload] FAILED ${r.error}${r.restored ? ' (旧代码仍在跑)' : ' (回滚也失败)'}`);
1951
- console.warn(`[dsh-notify-say] 热重载失败:${r.error}`
1952
- + (r.restored ? '(旧代码仍在运行)' : '(回滚也失败,建议重启宿主)'));
1953
- }
1954
- } catch { /* ignore */ }
1955
- }).catch(() => { /* selfReload 内部已兜底 */ });
1956
- },
2003
+ expectedFingerprint: undefined,
2004
+ differFrom: fingerprint,
2005
+ diskFingerprint,
1957
2006
  });
1958
- // 停掉监听挂到 effect 上:插件被卸载/重载时自动清理,否则旧实例的 watcher 会残留
1959
- try {
1960
- if (typeof ctx.effect === 'function') ctx.effect(() => stop, 'dsh-notify-say: dev-reload watcher');
1961
- else process.once('exit', () => { try { stop() } catch { /* ignore */ } });
1962
- } catch {
1963
- try { stop(); } catch { /* ignore */ }
1964
- }
2007
+ } catch (e) {
2008
+ return { ok: false, reason: 'exception', error: String((e && e.message) || e) };
2009
+ }
2010
+
2011
+ if (r && r.ok) {
2012
+ // 重载窗口内是否观察过事件(只作排查参考,不是漏报数 ——
2013
+ // 口径见 EVENT_COUNTER_KEY 的注释)。
2014
+ let observed = 0;
2015
+ try { observed = readEventCount() - eventsBefore; } catch { /* ignore */ }
2016
+ appendNotifyLog('[reload] OK by-user' + (observed > 0 ? ' observed=' + observed : ''));
2017
+ return {
2018
+ ok: true,
2019
+ reason: 'reloaded',
2020
+ fibers: r.fibers,
2021
+ busted: !!r.busted,
2022
+ verified: !!r.verified,
2023
+ observed,
2024
+ };
1965
2025
  }
1966
- } catch { /* 热重载不可用不影响主功能 */ }
2026
+
2027
+ const err = String((r && r.error) || 'unknown');
2028
+ appendNotifyLog('[reload] FAILED ' + err);
2029
+ return {
2030
+ ok: false,
2031
+ reason: 'reload-failed',
2032
+ error: err,
2033
+ restored: !!(r && r.restored),
2034
+ hint: '自重载没有成功,旧代码仍在运行 —— 请重启 DSH。',
2035
+ };
2036
+ };
2037
+
1967
2038
  }
1968
2039
 
1969
2040
  // ── 官方 Config schema 导出(v0.4.0)─────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.56",
3
+ "version": "0.4.58",
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",
@@ -555,12 +555,16 @@ test('C19: 高级折叠区含五种事件的音源覆盖', () => {
555
555
  // 用户 2026-10-17 要求:"UI 里面没有放上热重载吗?让他选支不支持"
556
556
  test('C20: GUI 有热重载开关,且说明其风险', () => {
557
557
  const src = readClient();
558
- assert.match(src, /apply\(\{ devReload: !!e\.target\.checked \}/, '应有 devReload 保存逻辑');
559
- // 文案在 v0.4.54 第二阶段 UI 里从「开发:改代码后自动热重载」改成
560
- // 「改代码后自动热重载(实验性)」—— 因为现在它在「开发者选项」子组里,
561
- // 「开发:」前缀冗余;「实验性」比「开发」更准确地表达了风险等级。
562
- // ⚠️ 改的只是措辞,下面「依赖内部结构 / 失效时只报错」的风险断言一字未动。
563
- assert.match(src, /改代码后自动热重载(实验性)/, '应有开关文案');
558
+ // ⚠️ v0.4.57:从「自动热重载开关」改成「点一次重载一次」的按钮。
559
+ //
560
+ // 原因(2026-10-18 实测宿主崩溃):watcher 在我改 UI 期间触发了 40 多次
561
+ // 失败重载,每次 dispose 都释放宿主仍在用的会话引用,最后渲染进程崩溃。
562
+ // 现在没有开关、没有自动触发,只有显式按钮。
563
+ assert.doesNotMatch(src, /apply\(\{ devReload: !!e\.target\.checked \}/,
564
+ '不应再有 devReload 的自动重载开关 —— 自动触发已被移除');
565
+ assert.match(src, /重载现在生效/, '应有一键重载按钮');
566
+ assert.match(src, /function doReload\(\)/, '按钮应触发一次显式重载请求');
567
+ assert.match(src, /cfgUrl\('reload'\)/, '重载应走 POST /reload');
564
568
  // 必须说明风险:依赖宿主内部结构、失效不影响播报
565
569
  assert.match(src, /依赖宿主内部结构/, '应说明它依赖内部结构');
566
570
  assert.match(src, /失效时只报错/, '应说明失效时的行为');
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 一次性自重载的行为测试(v0.4.57)。
3
+ *
4
+ * ## 为什么这个文件存在
5
+ *
6
+ * 2026-10-18 宿主崩了。崩溃日志给了两条硬证据:
7
+ *
8
+ * ① 45 条「热重载失败:fingerprint unchanged(旧代码仍在运行)」
9
+ * —— 那天为调 UI 改了 40 多次文件,每次保存都触发一次注定失败的尝试;
10
+ * ② 同一时刻反复「Session reference "..." is released」
11
+ * —— 每次重载都 dispose 旧实例,而 dispose 会释放渲染进程仍在用的
12
+ * 会话引用;反复几十次后渲染进程拿到损坏的 client 包,抛
13
+ * SyntaxError,宿主退出。
14
+ *
15
+ * **危险的不是"重载失败",是"反复重载"。**
16
+ *
17
+ * 所以设计改成:删掉 watcher,只在用户点按钮时尝试一次。
18
+ * 而"该不该真的试"的判断抽成 `planReload()` 纯函数 —— 它就是这个文件的主角:
19
+ *
20
+ * · 代码没变 → noop(**一次 dispose 都不做**,这是止血点)
21
+ * · 插件根路径变了(pnpm 升级)→ 拒绝,并如实说必须重启
22
+ * · 确实变了 → reload(一次,失败不重试)
23
+ */
24
+ import { test } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import { readFileSync } from 'node:fs';
27
+ import { resolve, dirname, join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ import { planReload } from '../lib/dev-reload.js';
31
+
32
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
33
+ const IDX = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
34
+ const DR = readFileSync(join(ROOT, 'lib', 'dev-reload.js'), 'utf8');
35
+ const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
36
+
37
+ /** 去掉注释,只看真正会执行的代码。 */
38
+ function code(src) {
39
+ return src
40
+ .replace(/\/\*[\s\S]*?\*\//g, '')
41
+ .split(/\r?\n/)
42
+ .filter((l) => !/^\s*\/\//.test(l))
43
+ .join('\n');
44
+ }
45
+
46
+ // ── P1: 代码没变 → 一次 dispose 都不做 ──────────────────────────────────
47
+ test('P1: 磁盘指纹与运行中一致时 → noop(不重载)', () => {
48
+ // 这是整个修复的止血点:上次那 45 次失败尝试,全部属于"点了但没必要"或
49
+ // "自动触发但没必要"的情形。noop 让它们连 dispose 都不会发生。
50
+ const r = planReload({
51
+ diskFingerprint: 'abc123',
52
+ runningFingerprint: 'abc123',
53
+ resolvedRootAtLoad: 'E:/p',
54
+ resolvedRootNow: 'E:/p',
55
+ });
56
+ assert.equal(r.action, 'noop');
57
+ assert.equal(r.reason, 'already-current');
58
+ });
59
+
60
+ // ── P2: 插件根路径变了 → 拒绝并说清原因 ─────────────────────────────────
61
+ test('P2: 插件根真实路径变了 → 拒绝重载,提示重启', () => {
62
+ // pnpm 升级会把包装到新的 .pnpm/<name>@<ver>/ 下。
63
+ // 我们从加载时的旧路径重新导入,拿到的仍是旧代码 —— 与其给一个
64
+ // "看起来成功其实没换"的按钮,不如直接说清楚。
65
+ const r = planReload({
66
+ diskFingerprint: 'NEW',
67
+ runningFingerprint: 'OLD',
68
+ resolvedRootAtLoad: 'C:/d/.pnpm/pkg@0.4.56/node_modules/pkg',
69
+ resolvedRootNow: 'C:/d/.pnpm/pkg@0.5.0/node_modules/pkg',
70
+ });
71
+ assert.equal(r.action, 'refuse-path-changed');
72
+ assert.equal(r.reason, 'path-changed');
73
+ assert.match(r.hint, /重启/, '必须明确告知只能重启');
74
+ });
75
+
76
+ // ── P3: 确实变了 → 重载一次 ─────────────────────────────────────────────
77
+ test('P3: 代码确实变了且路径未变 → reload', () => {
78
+ const r = planReload({
79
+ diskFingerprint: 'NEW',
80
+ runningFingerprint: 'OLD',
81
+ resolvedRootAtLoad: 'E:/p',
82
+ resolvedRootNow: 'E:/p',
83
+ });
84
+ assert.equal(r.action, 'reload');
85
+ });
86
+
87
+ // ── P4: 读不到指纹 → 不能假装"已是最新" ─────────────────────────────────
88
+ test('P4: 读不到磁盘指纹时不清空判断(按 reload 处理)', () => {
89
+ // 只读沙箱里 computeFingerprint 会返回 ''。
90
+ // 这时若判 noop,用户会以为已生效;判 reload 至少有机会真的生效,
91
+ // 失败也会如实报错。
92
+ const r = planReload({
93
+ diskFingerprint: '',
94
+ runningFingerprint: 'abc',
95
+ resolvedRootAtLoad: 'E:/p',
96
+ resolvedRootNow: 'E:/p',
97
+ });
98
+ assert.equal(r.action, 'reload', '不能因为读不到指纹就静默 noop');
99
+ assert.equal(r.reason, 'fingerprint-unreadable');
100
+ });
101
+
102
+ // ── P5: 唯一入口是 POST /reload,且算写操作 ─────────────────────────────
103
+ test('P5: 重载只经 POST /reload,且被当作写操作保护', () => {
104
+ assert.match(IDX, /head === 'reload'/, '应有 /reload 路由');
105
+ assert.match(IDX, /toUpperCase\(\) !== 'POST'/, '必须只接受 POST(不可逆操作)');
106
+ // 写操作 → 走本地来源防护(Origin/跨站检查)
107
+ const writeAt = IDX.indexOf('const isWriteRoute =');
108
+ assert.ok(writeAt > 0, '应能定位 isWriteRoute');
109
+ const seg = IDX.slice(writeAt, writeAt + 900);
110
+ assert.match(seg, /head === 'reload'/, '/reload 必须列进 isWriteRoute');
111
+ });
112
+
113
+ // ── P6: 没有任何自动触发路径 ────────────────────────────────────────────
114
+ test('P6: 没有任何自动触发(无 watcher、无定时器驱动重载)', () => {
115
+ // 这一条锁住"用户要求:点一下重载一下,不要一直在重载"。
116
+ const dr = code(DR);
117
+ assert.doesNotMatch(dr, /fs\.watch/, 'dev-reload.js 不应再监听文件系统');
118
+ assert.doesNotMatch(dr, /watchPackage/, 'watchPackage 应已彻底移除');
119
+
120
+ const idx = code(IDX);
121
+ assert.doesNotMatch(idx, /watchPackage/, 'index.js 不应再调用 watchPackage');
122
+ assert.doesNotMatch(idx, /devReload\)\s*\{\s*[\s\S]{0,200}selfReload/,
123
+ '不应再有「配置开着就自动重载」的路径');
124
+ });
125
+
126
+ // ── P7: GUI 只有一个按钮,没有开关 ──────────────────────────────────────
127
+ test('P7: GUI 是按钮而不是开关', () => {
128
+ assert.match(CLIENT, /重载现在生效/, '应有一次性重载按钮');
129
+ assert.doesNotMatch(CLIENT, /apply\(\{ devReload: !!e\.target\.checked \}/,
130
+ '不应再有自动热重载开关');
131
+ // 按钮必须说明它不会自动重复,以及"升级后必须重启"
132
+ assert.match(CLIENT, /不会自动重复/, '应说明不会自动重复');
133
+ assert.match(CLIENT, /新版本装在另一个路径/, '应说明升级场景自重载做不到');
134
+ });
135
+
136
+ // ── P8: 每次尝试都留可观测痕迹 ──────────────────────────────────────────
137
+ test('P8: 成功与失败都写进 notify.log', () => {
138
+ // watcher 时代的 `[watch]` 信号没了,可观测性要补在重载结果上,
139
+ // 否则"点了没反应"无法排查。
140
+ assert.match(IDX, /\[reload\] OK by-user/);
141
+ assert.match(IDX, /\[reload\] FAILED/);
142
+ const okAt = IDX.indexOf('[reload] OK by-user');
143
+ const seg = IDX.slice(Math.max(0, okAt - 400), okAt + 80);
144
+ assert.match(seg, /appendNotifyLog/, '必须写文件,不能只进 console');
145
+ });
@@ -97,26 +97,40 @@ test('X4: 事件计数日志如实说明「非漏报数」', () => {
97
97
  // · 重载期间没有监听器 → 事件没被观察到,missed=0(漏检)
98
98
  // · 旧/新实例正常处理了 → 计数增加但通知已发出(误报)
99
99
  // 所以它只能叫"观察到的事件数",不能说"漏报数"。
100
- assert.match(IDX, /非漏报数/,
100
+ assert.match(IDX, /非漏报数|不是漏报数/,
101
101
  '日志文案必须说明这个数字不是漏报数量 —— 否则排查时会被误导');
102
102
 
103
103
  assert.doesNotMatch(IDX, /对应播报可能已丢失/,
104
104
  '不能声称"播报已丢失"—— 计数器证明不了这件事');
105
105
  });
106
106
 
107
- // ── X5: 监听要有源码过滤 ────────────────────────────────────────────────
108
- test('X5: 只有源码变更才触发重载', () => {
109
- const c = code(DR);
110
-
111
- assert.match(c, /isSourceFile/,
112
- '应有源码文件过滤 —— 否则改文档/测试也会触发一次注定失败的重载');
107
+ // ── X5: 没有任何自动触发(只能用户点击)────────────────────────────────
113
108
 
114
- // 过滤要覆盖指纹的同样范围:package.json + lib/*.js
115
- const at = c.indexOf('isSourceFile');
116
- const seg = c.slice(at, at + 600);
117
- assert.match(seg, /package\.json/, '应包含 package.json');
118
- assert.match(seg, /lib\//, '应包含 lib/ 目录');
119
- assert.match(seg, /\.js/, '应限定 .js');
109
+ test('X5: 重载只能由用户点击触发,没有 watcher', () => {
110
+ // v0.4.57 之前这里是「监听要有源码过滤」。
111
+ // 那个保障针对的是"改了文档也会触发一次注定失败的重载"。
112
+ //
113
+ // 现在设计变了:**根本没有 watcher**,所以那类问题从根上消失。
114
+ // 保障升级为更强的一条:不许出现任何自动触发路径。
115
+ //
116
+ // 背景(2026-10-18 宿主崩溃):watcher 在我改 UI 期间触发了 40 多次
117
+ // 失败重载 —— 每次 dispose 都释放宿主仍在用的会话引用。
118
+ // 去掉自动触发不是"少了个功能",而是这个崩溃的根因修复。
119
+ const dr = code(DR);
120
+ assert.doesNotMatch(dr, /fs\.watch/,
121
+ 'dev-reload.js 不应再监听文件系统 —— 自动重载是崩溃根因');
122
+ assert.doesNotMatch(dr, /setInterval|setTimeout\([^)]*reload/i,
123
+ '不应有任何定时器驱动的重载');
124
+
125
+ // index.js 里也不能再启动 watcher
126
+ assert.doesNotMatch(code(IDX), /watchPackage\(/,
127
+ 'index.js 不应再调用 watchPackage');
128
+
129
+ // 唯一入口必须是显式路由
130
+ assert.match(IDX, /head === 'reload'/,
131
+ '重载只能经 POST /reload 触发');
132
+ assert.match(IDX, /toUpperCase\(\) !== 'POST'/,
133
+ '该路由必须只接受 POST(不可逆操作)');
120
134
  });
121
135
 
122
136
  // ── X6: 成功返回值要带 busted / verified ────────────────────────────────
@@ -137,22 +151,19 @@ test('X6: selfReload 成功时返回 busted 与 verified', () => {
137
151
  });
138
152
 
139
153
  // ── X7: watcher 必须可观测(区分"没触发"与"触发了但失败")──────────────
140
- test('X7: watcher 的注册与事件都写进 notify.log', () => {
141
- // 2026-10-18 我因为看错时间基准(把重启前的日志当成重启后),
142
- // 误判热重载"还是坏的"。真正的教训是:
143
- // **没有可观测信号时,只能靠猜。**
144
- //
145
- // 现在 watcher 起来会写 `[watch] watching <目录>`,
146
- // 收到事件会写 `[watch] event <文件> accepted=<bool>`。
147
- // 这样"没触发"和"触发了但失败"一眼可分。
148
- assert.match(DR, /opts\.diag\?\.\('watching '/,
149
- '应能观测到 watcher 注册成功');
150
- assert.match(DR, /opts\.diag\?\.\('event '/,
151
- '应能观测到收到文件事件(含是否被源码过滤接受)');
152
-
153
- // diag 必须接到真正的日志文件,而不是只进 console
154
- assert.match(IDX, /diag:\s*\([^)]*\)\s*=>\s*appendNotifyLog/,
155
- 'diag 必须写进 notify.log —— 只进 console 的话我们看不到');
156
- assert.match(IDX, /\[watch\]/,
157
- '日志前缀应是 [watch]');
158
- });
154
+ test('X7: 每次重载尝试的结果都写进 notify.log', () => {
155
+ // 原本这里断言 watcher 的 `[watch] watching/event` 可观测信号。
156
+ // watcher 删除后,需要同样的可观测性 —— 改到**重载结果**上:
157
+ // 成功与失败都必须落进日志文件,否则"点了没反应"无法排查。
158
+ assert.match(IDX, /\[reload\] OK by-user/,
159
+ '成功要写日志(带 by-user 标记,与旧的自动重载区分)');
160
+ assert.match(IDX, /\[reload\] FAILED/,
161
+ '失败也要写日志,并留下原因');
162
+
163
+ // 必须落文件,不能只进 console
164
+ const okAt = IDX.indexOf('[reload] OK by-user');
165
+ assert.ok(okAt > 0, '应能定位成功日志');
166
+ const seg = IDX.slice(Math.max(0, okAt - 400), okAt + 80);
167
+ assert.match(seg, /appendNotifyLog/,
168
+ '日志必须走 appendNotifyLog(写文件),只进 console 我们看不到');
169
+ });
@@ -282,9 +282,12 @@ test('N3: 高级设置默认折叠;展开后四个子组默认也收起', asyn
282
282
  // 展开高级设置:四个子组标题出现,但内容仍收起
283
283
  {
284
284
  const { text } = renderWith(card, 'tts', { advOpen: true });
285
- for (const t of ['按事件定制声音', '诊断与日志', '版本与更新', '开发者选项']) {
285
+ // v0.4.58:开发者选项已合并进「版本与更新」(它的唯一内容就是重载按钮,
286
+ // 而重载现在属于更新流程)。空组是纯噪音,所以从三个子组开始。
287
+ for (const t of ['按事件定制声音', '诊断与日志', '版本与更新']) {
286
288
  assert.ok(has(text, t), `展开后应出现子组「${t}」`);
287
289
  }
290
+ assert.ok(!has(text, '开发者选项'), '开发者选项子组应已合并');
288
291
  // 内容仍收起:不该看到具体控件
289
292
  assert.ok(!has(text, '跟随默认'), '子组默认收起,不应显示按事件覆盖的下拉');
290
293
  // ⚠️ 不能断言 '日志详细度':它出现在子组的**说明文字**里