dsh-notify-say 0.4.55 → 0.4.57

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,10 @@
3
3
  > 本文件由 `tools/release-build.ps1` 从开发提交自动生成,**请勿手工编辑**。
4
4
  > 只列面向使用者的变更(新增功能与修复),不含内部维护提交。
5
5
 
6
+ ## v0.4.57 — 2026-10-18
7
+
8
+ - fix(reload): v0.4.57 —— 自重载改为「点一次重载一次」(修宿主崩溃根因)
9
+
6
10
  ## v0.4.54 — 2026-10-18
7
11
 
8
12
  - 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
- **414 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
339
+ **426 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
334
340
 
335
341
  少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
336
342
  找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
package/lib/client.js CHANGED
@@ -194,7 +194,8 @@ window.__ModuleLoader__.load({
194
194
  ok: { color: '#1e8449', fontSize: '12px', marginTop: '6px' },
195
195
  foot: { opacity: 0.65, marginTop: '12px', fontSize: '12.5px', lineHeight: '1.6' },
196
196
  // 事件开关:勾选框 + 可换行的说明文字
197
- toggleRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '8px 0' },
197
+ // 行间距略放大(v0.4.56):420px 下说明文字密集,靠间距而不是缩字号。
198
+ toggleRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '10px 0' },
198
199
  toggleBox: { marginTop: '3px', flex: '0 0 auto' },
199
200
  toggleText: { flex: '1', lineHeight: '1.5' },
200
201
  toggleLabel: { fontWeight: 500 },
@@ -598,6 +599,23 @@ window.__ModuleLoader__.load({
598
599
  var hvset = hvst[1]
599
600
  var advHover = hvst[0].id || ""
600
601
 
602
+ // 声音设置区的展开状态(v0.4.56)。
603
+ //
604
+ // `null` = 跟随默认:声音提醒开着就展开、关了就收起。
605
+ // 用户手动点过之后才固定成 true/false(不覆盖用户的显式选择)。
606
+ //
607
+ // ⚠️ 收起**只是隐藏**,配置一律保留;试听也在里面,
608
+ // 用户想看随时能展开 —— 不禁用任何操作。
609
+ var sndst = React.useState({ open: null })
610
+ var sndset = sndst[1]
611
+ var sndPref = sndst[0].open
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
+
601
619
  var st = React.useState({
602
620
  loaded: false,
603
621
  values: {
@@ -953,6 +971,46 @@ window.__ModuleLoader__.load({
953
971
  //
954
972
  // 只检查、不安装。升级由用户自己执行 DSH 命令。
955
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
+
956
1014
  function checkVersion(force) {
957
1015
  vset(function (p) {
958
1016
  return { info: p.info, loading: true, copied: false }
@@ -1087,6 +1145,31 @@ window.__ModuleLoader__.load({
1087
1145
  // ── 二、声音设置 ────────────────────────────────────────────────
1088
1146
  rows.push(section('sound', '声音设置', '音量、音色与试听;语音包可按需下载。'))
1089
1147
 
1148
+ // 声音提醒关掉时,把音频控件收起来(v0.4.56)。
1149
+ //
1150
+ // 用户指出:两个提醒都关了,下面还铺着整套 TTS 设置,视觉噪音大。
1151
+ // 但**不能删**配置 —— 用户可能只是暂时静音,回头还要用。
1152
+ var voiceOff = v.voice === false
1153
+ var sndOpen = sndPref === null ? !voiceOff : sndPref === true
1154
+ if (voiceOff) {
1155
+ rows.push(h('div', { key: 'snd-off-head', style: S.foldHead,
1156
+ onClick: function () { sndset({ open: !sndOpen }) },
1157
+ role: 'button', tabIndex: 0,
1158
+ 'aria-expanded': sndOpen ? 'true' : 'false',
1159
+ onKeyDown: function (e) {
1160
+ if (e.key === 'Enter' || e.key === ' ') {
1161
+ e.preventDefault()
1162
+ sndset({ open: !sndOpen })
1163
+ }
1164
+ },
1165
+ },
1166
+ h('span', { style: { fontSize: '13px', opacity: 0.9 } },
1167
+ '声音提醒已关闭,原有配置已保留'),
1168
+ h('span', { style: S.foldArrow }, sndOpen ? '▾' : '▸'),
1169
+ ))
1170
+ }
1171
+
1172
+ if (sndOpen) {
1090
1173
  pushIf(curSource !== 'none', h('div', { key: 'vol', style: S.row },
1091
1174
  h('span', { style: S.label }, '音量'),
1092
1175
  h('input', {
@@ -1312,10 +1395,21 @@ window.__ModuleLoader__.load({
1312
1395
  },
1313
1396
  }, p.installed ? '卸载' : (p.downloadable ? '下载' : '不可用')),
1314
1397
  ),
1315
- // 署名与条款:小字,不抢视觉但随时可查
1398
+ // 署名与条款:小字,不抢视觉但随时可查。
1399
+ //
1400
+ // ⚠️ 占位提示的判据是「**能下载却没有条款**」(v0.4.56):
1401
+ // 那才是真的数据问题,值得提示。
1402
+ //
1403
+ // 原来判据是「没有 termsUrl 就提示」,于是**离线**时
1404
+ // (索引取不到 → 已安装的包也没有 termsUrl)界面会显示
1405
+ // 「条款链接待补」,看起来像插件发布缺字段 —— 虚报。
1406
+ //
1407
+ // 许可安全本身不受影响:下载按钮要求 p.downloadable,
1408
+ // 而 downloadable 只可能来自带 terms 的远程索引。
1316
1409
  h('div', { style: S.subHint },
1317
1410
  (p.credit ? '署名:' + p.credit : '')
1318
- + (p.termsUrl ? '' : ' (条款链接待补)'),
1411
+ // 内联判断(这里是表达式上下文,不能写 var)
1412
+ + ((p.downloadable && !p.termsUrl) ? ' (条款链接待补)' : ''),
1319
1413
  p.termsUrl
1320
1414
  ? h('a', {
1321
1415
  href: p.termsUrl,
@@ -1541,6 +1635,8 @@ window.__ModuleLoader__.load({
1541
1635
  notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
1542
1636
  subagents: false, idleGate: true,
1543
1637
  }
1638
+ } // ← 声音设置区展开/收起(v0.4.56)
1639
+
1544
1640
  // ── 三、提醒规则 ────────────────────────────────────────────────
1545
1641
  rows.push(section('rules', '提醒规则', '哪些事件要提醒你。默认只提醒需要你关注的那些。'))
1546
1642
  // 按「什么时候会用到」分三组渲染(v0.4.54)。
@@ -1803,7 +1899,8 @@ window.__ModuleLoader__.load({
1803
1899
  var KIND_ROWS = [['Done', '任务完成'], ['Fail', '任务失败'],
1804
1900
  ['Question', '向你提问'], ['Approval', '待你批准'], ['Blocked', '被拦截']]
1805
1901
  var FOLLOW = '__follow__'
1806
- rows.push(h('div', { key: 'adv-group', style: S.groupLabel }, '按事件覆盖声音来源'))
1902
+ // 内层不再重复标题 —— 二级分组已经叫「按事件定制声音」(v0.4.56)。
1903
+ // 只保留说明,直接接五个事件选择器。
1807
1904
  rows.push(h('div', { key: 'advhint', style: S.subHint },
1808
1905
  '默认跟随上面的「声音来源」。只有你改过的那几行才会单独生效。'))
1809
1906
  KIND_ROWS.forEach(function (pair) {
@@ -2225,25 +2322,38 @@ window.__ModuleLoader__.load({
2225
2322
  rows.push(h('div', { key: 'dev-warn', style: S.subHint },
2226
2323
  '⚠️ 实验性:依赖宿主内部结构与 Node 模块加载机制,'
2227
2324
  + 'DSH 升级后可能失效。失效时只报错,不影响播报。'))
2228
- // ── 开发选项:热重载 ────────────────────────────────────────────────
2229
- //
2230
- // 让用户自己选,而不是我替他决定。默认关(生产安装不需要)。
2231
- // 开启后:改插件源码保存 → 自动换成新代码,不用重启 DSH。
2232
- rows.push(h('div', { key: 'sepHr', style: { margin: '10px 0 4px', borderTop: '1px solid rgba(128,128,128,.25)' } }))
2233
- rows.push(h('div', { key: 'dr', style: S.toggleRow },
2234
- h('input', {
2235
- type: 'checkbox',
2236
- checked: v.devReload === true,
2237
- disabled: s.busy,
2238
- style: S.toggleBox,
2239
- onChange: function (e) { apply({ devReload: !!e.target.checked }, '热重载设置已更新') },
2240
- }),
2241
- h('div', { style: S.toggleText },
2242
- h('span', { style: S.toggleLabel }, '改代码后自动热重载(实验性)'),
2243
- h('span', { style: S.toggleDesc },
2244
- '保存插件源码后自动生效,不必重启 DSH。'),
2245
- ),
2246
- ))
2325
+ // ── 一次性自重载(v0.4.57)────────────────────────────────
2326
+ //
2327
+ // 用户 2026-10-18 明确要求:「点一下重载一下」,
2328
+ // 不要"开了就一直重载"。所以这里**没有开关**,只有一个按钮。
2329
+ //
2330
+ // 为什么把自动监听删了:2026-10-18 实测它把宿主拖崩 ——
2331
+ // watcher 在我改 UI 期间触发了 40 多次失败重载,每次 dispose
2332
+ // 都释放宿主仍在用的会话引用,最后渲染进程崩在损坏的 client 包上。
2333
+ rows.push(h('div', { key: 'dr-row', style: S.row },
2334
+ h('button', {
2335
+ type: 'button',
2336
+ disabled: rlState.busy,
2337
+ style: S.btn,
2338
+ onClick: function () { doReload() },
2339
+ }, rlState.busy ? '重载中…' : '重载现在生效'),
2340
+ h('span', { style: S.hint }, '点一次试一次,不会自动重复'),
2341
+ ))
2342
+ rows.push(h('div', { key: 'dr-hint', style: S.subHint },
2343
+ '用途:改完 lib/ 下的代码后,不想重启 DSH 就点一下。'
2344
+ + '**只对同一路径上的代码有效**;如果刚用 dsh plugin update '
2345
+ + '升级了版本,新版本装在另一个路径,自重载做不到,必须重启。'))
2346
+ if (rlState.msg) {
2347
+ rows.push(h('div', { key: 'dr-msg', style: S.ok }, rlState.msg))
2348
+ }
2349
+ if (rlState.err) {
2350
+ rows.push(h('div', { key: 'dr-err', style: S.err }, rlState.err))
2351
+ }
2352
+ // 重载后宿主里的会话引用会被释放 —— 用户必须知道要刷新页面,
2353
+ // 否则会遇到"侧边栏点不开"却不知道为什么。
2354
+ rows.push(h('div', { key: 'dr-warn', style: S.subHint },
2355
+ '重载会重建插件实例,宿主里已打开的会话引用会失效。'
2356
+ + '重载成功后请**刷新页面**;不行就重启 DSH。'))
2247
2357
  }
2248
2358
  }
2249
2359
  })
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.55",
3
+ "version": "0.4.57",
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
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * 定稿前的收尾行为测试(v0.4.56)。
3
+ *
4
+ * 这两条来自用户评审:
5
+ * ① 静音时音频设置应收起,但**不能禁用试听**
6
+ * ② 「条款链接待补」不能虚报
7
+ */
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { readFileSync } from 'node:fs';
11
+ import { resolve, dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
16
+
17
+ function fakeReact() {
18
+ const createElement = (type, props, ...children) => ({
19
+ $$typeof: Symbol.for('react.element'),
20
+ type,
21
+ props: { ...(props || {}), children },
22
+ });
23
+ const hooks = { slots: [], index: 0, eff: 0, ranEff: new Set(), rerender: null };
24
+ const useState = (init) => {
25
+ const i = hooks.index++;
26
+ if (hooks.slots[i] === undefined) {
27
+ hooks.slots[i] = { value: typeof init === 'function' ? init() : init };
28
+ }
29
+ const slot = hooks.slots[i];
30
+ const set = (updater) => {
31
+ slot.value = typeof updater === 'function' ? updater(slot.value) : updater;
32
+ if (typeof hooks.rerender === 'function') hooks.rerender();
33
+ };
34
+ return [slot.value, set];
35
+ };
36
+ // 只跑一次(模拟 [] 依赖);不调 cleanup(否则 alive=false,配置永远不合并)
37
+ const useEffect = (fn) => {
38
+ const i = hooks.eff++;
39
+ if (hooks.ranEff.has(i)) return;
40
+ hooks.ranEff.add(i);
41
+ try { fn(); } catch { /* ignore */ }
42
+ };
43
+ const resetIndex = () => { hooks.index = 0; hooks.eff = 0; };
44
+ return { createElement, useState, useEffect, __hooks: hooks, __resetIndex: resetIndex };
45
+ }
46
+
47
+ const FULL = {
48
+ voice: true, voiceMode: 'fixed', voiceName: 'zundamon-normal', volume: 80,
49
+ notifyOnComplete: true, notifyOnError: true, notifyOnBlocked: true,
50
+ notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
51
+ subagents: false, idleGate: true,
52
+ audioSource: 'tts', showToast: true, ttsVoice: '', ttsRate: 0, devReload: false,
53
+ logLevel: 'info', voiceGain: 2.0, normalizeCustomAudio: false,
54
+ jumpWhenBackground: true, jumpWhenForeground: false,
55
+ debounceSeconds: 60, globalCooldownSeconds: 0,
56
+ audioSourceByKind: {}, ttsTexts: {}, kindSystemSound: {}, kindVoiceName: {},
57
+ customFiles: {},
58
+ };
59
+
60
+ function texts(node, out) {
61
+ const acc = out || [];
62
+ if (node === null || node === undefined || node === false) return acc;
63
+ if (typeof node === 'string' || typeof node === 'number') { acc.push(String(node)); return acc; }
64
+ if (Array.isArray(node)) { node.forEach((n) => texts(n, acc)); return acc; }
65
+ texts(node.props && node.props.children, acc);
66
+ return acc;
67
+ }
68
+
69
+ function elements(node, out) {
70
+ const acc = out || [];
71
+ if (!node || typeof node !== 'object') return acc;
72
+ if (Array.isArray(node)) { node.forEach((n) => elements(n, acc)); return acc; }
73
+ if (node.type) acc.push(node);
74
+ elements(node.props && node.props.children, acc);
75
+ return acc;
76
+ }
77
+
78
+ /** 按形状找主 state 槽(组件有多个 useState,不能写死索引)。 */
79
+ function findMainSlot(card) {
80
+ const s = card.r.__hooks.slots.find(
81
+ (x) => x && x.value && typeof x.value === 'object'
82
+ && 'values' in x.value && 'loaded' in x.value,
83
+ );
84
+ if (!s) throw new Error('找不到主 state 槽');
85
+ return s;
86
+ }
87
+
88
+ /** 按形状找「声音设置展开状态」槽({ open: ... })。 */
89
+ function findSoundSlot(card) {
90
+ const s = card.r.__hooks.slots.find(
91
+ (x) => x && x.value && typeof x.value === 'object'
92
+ && 'open' in x.value && Object.keys(x.value).length === 1,
93
+ );
94
+ if (!s) throw new Error('找不到声音区 state 槽');
95
+ return s;
96
+ }
97
+
98
+ async function loadCard() {
99
+ let captured = null;
100
+ new Function('window', 'document', CLIENT)(
101
+ { __ModuleLoader__: { load(def) { captured = def; } }, addEventListener() {}, removeEventListener() {} },
102
+ undefined,
103
+ );
104
+ if (!captured) throw new Error('client.js 没有注册');
105
+
106
+ const r = fakeReact();
107
+ const mod = captured.factory((id) => {
108
+ if (id === 'react') return r;
109
+ throw new Error('client 不应 require: ' + id);
110
+ });
111
+
112
+ let component = null;
113
+ mod.apply({
114
+ get(id) {
115
+ if (id === 'slots') return { inject(_n, fn) { fn(); }, register(_m, c) { component = c; } };
116
+ return undefined;
117
+ },
118
+ });
119
+ if (!component) throw new Error('没有注册设置卡');
120
+
121
+ globalThis.fetch = (url) => Promise.resolve({
122
+ ok: true, status: 200,
123
+ json: () => Promise.resolve(
124
+ String(url).includes('version')
125
+ ? {
126
+ ok: true, running: '0.4.56', installed: '0.4.56', latest: '0.4.56',
127
+ hasUpdate: false, needsRestart: false, npmLatest: '0.4.56',
128
+ githubLatest: '0.4.56', installMode: 'managed', changelogUrl: 'x',
129
+ }
130
+ : { ok: true, values: FULL, packs: [] },
131
+ ),
132
+ });
133
+
134
+ const render = () => { r.__resetIndex(); return component({}); };
135
+ render();
136
+ r.__hooks.rerender = render;
137
+ for (let i = 0; i < 8; i++) await Promise.resolve();
138
+ return { r, render };
139
+ }
140
+
141
+ const has = (text, s) => text.includes(s);
142
+
143
+ /** 设 state 后渲染。 */
144
+ function renderWith(card, main, sound) {
145
+ findMainSlot(card).value = { ...findMainSlot(card).value, ...main };
146
+ if (sound !== undefined) findSoundSlot(card).value = { open: sound };
147
+ const tree = card.render();
148
+ return { text: texts(tree).join(' '), els: elements(tree) };
149
+ }
150
+
151
+ // ── N6: 静音时收起声音设置,但不禁用试听 ────────────────────────────────
152
+ test('N6: 声音提醒关闭时收起音频设置,试听仍可用', async () => {
153
+ const card = await loadCard();
154
+
155
+ // 声音开着 → 正常展开;手动把 open 置 null(跟随默认)
156
+ {
157
+ const { text } = renderWith(card,
158
+ { values: { ...FULL, audioSource: 'tts', voice: true } }, null);
159
+ assert.ok(has(text, '朗读声音'), '声音开着时应正常显示音频控件');
160
+ assert.ok(!has(text, '声音提醒已关闭'), '声音开着时不该出现收起提示');
161
+ }
162
+
163
+ // 声音关掉 → 收起,但说明"配置还在"
164
+ {
165
+ const { text } = renderWith(card,
166
+ { values: { ...FULL, audioSource: 'tts', voice: false } }, null);
167
+ assert.ok(has(text, '声音提醒已关闭,原有配置已保留'),
168
+ '静音时应说明「配置还在」,否则用户以为被清掉了');
169
+ assert.ok(!has(text, '朗读声音'), '静音时应收起音频控件');
170
+ }
171
+
172
+ // 用户手动展开 → 控件回来,且试听**没有**被禁用
173
+ {
174
+ const { text, els } = renderWith(card,
175
+ { values: { ...FULL, audioSource: 'tts', voice: false } }, true);
176
+ assert.ok(has(text, '朗读声音'), '手动展开后应能看到音频控件');
177
+
178
+ const pvButtons = els.filter((e) => {
179
+ if (e.type !== 'button') return false;
180
+ const kids = e.props.children || [];
181
+ return ['完成', '失败', '提问', '批准', '拦截'].includes(kids[0]);
182
+ });
183
+ assert.ok(pvButtons.length >= 5, `五个试听按钮都应存在(实际 ${pvButtons.length})`);
184
+ assert.ok(pvButtons.every((b) => !b.props.disabled),
185
+ '静音**不得禁用试听** —— 试听是测试配置的行为,与通知开关无关');
186
+ }
187
+ });
188
+
189
+ // ── N7: 条款占位不虚报 ──────────────────────────────────────────────────
190
+ test('N7: 条款占位只在「能下载却没条款」时出现', () => {
191
+ // 用户评审时看到「条款链接待补」,要求核实是不是发布缺字段。
192
+ //
193
+ // 核实结论:真实 packs.json 的 13 个音色都有 terms,服务端也转成 termsUrl
194
+ // (R7 用真实请求钉住)。但**离线**时索引取不到,已安装的包没有 termsUrl ——
195
+ // 原来会显示占位,看起来像发布缺字段。
196
+ //
197
+ // 现在判据收窄为「能下载 && 没条款」。
198
+ assert.match(
199
+ CLIENT,
200
+ /\(p\.downloadable && !p\.termsUrl\)/,
201
+ '占位提示必须同时要求 downloadable —— 否则离线时会虚报',
202
+ );
203
+ // 反面断言:不能退回「没有 termsUrl 就报」
204
+ assert.doesNotMatch(
205
+ CLIENT,
206
+ /\+ \(p\.termsUrl \? '' : ' (条款链接待补)'\)/,
207
+ '不应退回旧的「没 termsUrl 就提示」判据',
208
+ );
209
+ });
210
+
211
+ // ── N8: 高级区不再有重复标题 ────────────────────────────────────────────
212
+ test('N8: 高级区四个子组没有重复的内部标题', async () => {
213
+ const card = await loadCard();
214
+ const { text } = renderWith(card, { advOpen: true }, undefined);
215
+ // 子组标题「按事件定制声音」只应出现一次(内部不再重复「按事件覆盖声音来源」)
216
+ const occurrences = text.split('按事件定制声音').length - 1;
217
+ assert.equal(occurrences, 1, `「按事件定制声音」应只出现一次(实际 ${occurrences})`);
218
+ assert.doesNotMatch(CLIENT, /'按事件覆盖声音来源'/,
219
+ '内部标题应已删除 —— 与二级分组标题重复');
220
+ });
@@ -182,3 +182,49 @@ test('R7: 安装索引里没有的音色返回 404', async () => {
182
182
  `应是 404 或 502,实际 ${res.statusCode}`);
183
183
  });
184
184
  });
185
+
186
+ // ── R8: 索引里的 terms → termsUrl(前端读的是这个字段名)─────────────────
187
+ test('R8: 路由把 packs.json 的 terms 映射成前端要的 termsUrl', async () => {
188
+ // ⚠️ 用户评审时看到界面显示「条款链接待补」,要求核实是不是发布缺字段。
189
+ //
190
+ // 核实结论:真实 packs.json 的 13 个音色**都有** terms,
191
+ // 但字段名是 `terms`,而前端读的是 `termsUrl` ——
192
+ // 转名这一步在 voice-pack-route.js 里(`termsUrl: p.terms`)。
193
+ //
194
+ // 这步一旦被删,界面会对**所有**音色显示「条款链接待补」。
195
+ // 用真实请求 + 打桩 fetch 把这条跨文件契约钉住。
196
+ await withDataDir(async () => {
197
+ const realFetch = globalThis.fetch;
198
+ const FAKE_TERMS = 'https://example.invalid/voice-terms';
199
+ globalThis.fetch = async () => ({
200
+ ok: true,
201
+ status: 200,
202
+ headers: { get: () => null },
203
+ json: async () => ({
204
+ packs: [{
205
+ key: 'zundamon-normal',
206
+ name: 'ずんだもん(ノーマル)',
207
+ file: 'zundamon-normal.zip',
208
+ bytes: 100,
209
+ sha256: 'a'.repeat(64),
210
+ wavs: 1,
211
+ credit: 'VOICEVOX:ずんだもん',
212
+ terms: FAKE_TERMS,
213
+ }],
214
+ }),
215
+ });
216
+ try {
217
+ const route = createVoicePackRoute({ pluginRoot: '.', logger: { info() {}, warn() {} } });
218
+ const res = await call(route, 'GET', 'index');
219
+ assert.equal(res.statusCode, 200);
220
+ const doc = res.json();
221
+ assert.equal(doc.indexOk, true, '索引应可用(fetch 已打桩)');
222
+ const p = (doc.packs || []).find((x) => x.key === 'zundamon-normal');
223
+ assert.ok(p, '应返回该音色');
224
+ assert.equal(p.termsUrl, FAKE_TERMS,
225
+ 'packs.json 里叫 terms,前端读的是 termsUrl —— 名字不对会让界面显示「条款链接待补」');
226
+ } finally {
227
+ globalThis.fetch = realFetch;
228
+ }
229
+ });
230
+ });