dsh-notify-say 0.4.47 → 0.4.50

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,18 @@
3
3
  > 本文件由 `tools/release-build.ps1` 从开发提交自动生成,**请勿手工编辑**。
4
4
  > 只列面向使用者的变更(新增功能与修复),不含内部维护提交。
5
5
 
6
+ ## v0.4.50 — 2026-10-18
7
+
8
+ - fix(update): v0.4.50 —— 两个源并行查、取较高版本
9
+
10
+ ## v0.4.49 — 2026-10-18
11
+
12
+ - fix(update): v0.4.49 —— hasUpdate 方向反了(实测抓到)
13
+
14
+ ## v0.4.48 — 2026-10-18
15
+
16
+ - feat(update): v0.4.48 —— 检查更新 + 引导升级(不做自动更新)
17
+
6
18
  ## v0.4.46 — 2026-10-18
7
19
 
8
20
  - fix(test): v0.4.46 —— 修「本地绿、CI 红」+ 发版流程补发布包内测试
package/README.md CHANGED
@@ -55,6 +55,7 @@
55
55
  | **许可先告知** | 下载音色前弹出条款确认,写明署名要求与官方条款链接 |
56
56
  | **诊断导出** | 一键导出脱敏日志(用户名/路径已替换),方便提 Issue |
57
57
  | **日志隐私分级** | 默认只记元数据,**不记台词正文**;要详细日志需手动开 debug |
58
+ | **检查更新** | 显示当前/最新版本,有新版时给出升级命令(**不自动更新**,见[下文](#有新版本了怎么办)) |
58
59
 
59
60
  > **联网说明**:播放、通知、TTS 全部离线。只有**下载语音包**时需要访问 GitHub。
60
61
  > 不下载语音包的话,这个插件不会联网。
@@ -106,6 +107,7 @@ D:\Program Files (x86)\DSH\profiles\desktop\package.json
106
107
  | 出错时也要语音提醒 | 「出错」开关(默认已开) |
107
108
  | 让子代理结束也提醒 | 「子代理」开关(默认关) |
108
109
  | 少响一点 | 「同会话去抖」调大 |
110
+ | 看看有没有新版 | 高级区「版本与更新」→ 检查更新 |
109
111
 
110
112
  ---
111
113
 
@@ -290,6 +292,23 @@ VOICEVOX 有几十个角色,但每个的条款都要单独读原文核对。
290
292
  我们只收录**条款明确允许本插件这样分发**的,宁可少也不冒险。
291
293
  被排除的例子:有 2 个明确禁止再分发,1 个身份存疑。
292
294
 
295
+ **有新版本了怎么办?**
296
+
297
+ 设置页 → 高级 → **版本与更新** → 点「检查更新」。有新版时那里会显示版本号、
298
+ `CHANGELOG.md` 链接,以及一条升级命令(可一键复制)。
299
+
300
+ **插件不会自己更新自己。** 原因很实在:
301
+
302
+ - 装上你的机器后,插件不知道自己是通过 npm、GitHub 还是 `link:` 装的
303
+ - 正在运行的程序替换自己的文件,失败后很难恢复
304
+ - DSH 官方 CLI 已经能升级插件(`dsh plugin --profile <名字> update dsh-notify-say`)—— 重复实现没意义
305
+
306
+ 所以分工是:**插件负责告诉你有新版;你执行命令;DSH 完成升级。**
307
+ 更新后需要重启对应的 DSH profile。
308
+
309
+ > 如果你是从源码 `link:` 安装的(开发模式),插件会认出来并**不给命令** ——
310
+ > 那种情况请直接更新源码目录,用插件命令会覆盖你的源码。
311
+
293
312
  **能加我喜欢的音色吗?**
294
313
  可以提 Issue。但要走审核流程(读官方条款原文、留档),不是加个下载链接的事。
295
314
 
@@ -306,7 +325,7 @@ npm run test:route # 需要 --import loader 的接线测试
306
325
  npm run test:audio-wire
307
326
  ```
308
327
 
309
- **375 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
328
+ **374 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
310
329
 
311
330
  少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
312
331
  找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
package/lib/client.js CHANGED
@@ -137,6 +137,15 @@ window.__ModuleLoader__.load({
137
137
  }).then(function (r) { return r ? r.json() : null }).catch(function () { return null })
138
138
  }
139
139
 
140
+ // 检查更新(v0.4.48)。只读:不下载、不覆盖文件、不执行命令。
141
+ // force=true 表示用户手动点,绕过宿主侧 24 小时缓存。
142
+ function fetchVersion(force) {
143
+ var url = apiUrl('version') + (force ? '?force=1' : '')
144
+ return fetch(url, { cache: 'no-store', credentials: 'same-origin' })
145
+ .then(function (r) { return r && r.ok ? r.json() : null })
146
+ .catch(function () { return null })
147
+ }
148
+
140
149
  function openSession(ctx, sessionId) {
141
150
  try {
142
151
  if (!sessionId) return false
@@ -386,6 +395,20 @@ window.__ModuleLoader__.load({
386
395
  // ⚠️ 真实值必须以 host 返回的**生效值**为准(见下方 useEffect 的 merge)。
387
396
  // 这些占位数字**不代表实际配置**:例如 patch 里 volume 可能是 100,
388
397
  // 这里写 80 只是防 undefined;生效值到达后会被覆盖。
398
+ // 版本与更新(v0.4.48)—— **独立 state**,与主 state 隔离。
399
+ //
400
+ // 为什么不用主 state:主 state 有 25 处 set() 逐字段重建,
401
+ // 加一个字段要改 25 处,已因此出过 4 次 bug。
402
+ // 独立 useState 能彻底避开这个坑。
403
+ var vst = React.useState({
404
+ // null = 还没查过;失败时是 { ok:false, error }
405
+ info: null,
406
+ loading: false,
407
+ copied: false,
408
+ })
409
+ var vset = vst[1]
410
+ var vs = vst[0]
411
+
389
412
  var st = React.useState({
390
413
  loaded: false,
391
414
  values: {
@@ -411,6 +434,15 @@ window.__ModuleLoader__.load({
411
434
 
412
435
  React.useEffect(function () {
413
436
  var alive = true
437
+ // 版本信息:挂载时查一次(宿主侧有 24 小时缓存,很便宜)。
438
+ //
439
+ // ⚠️ 必须走 Promise:直接 set 会触发重渲染 → effect 重跑 → 死循环
440
+ // (v0.4.33 语音包那块就是这么把 4GB 堆打满的)。
441
+ // 异步 .then 里 set 是安全的 —— 那时 effect 已经跑完了。
442
+ fetchVersion(false).then(function (r) {
443
+ if (!alive || !r) return
444
+ vset(function (p) { return { info: r, loading: false, copied: p.copied } })
445
+ })
414
446
  loadConfig().then(function (v) {
415
447
  if (!alive) return
416
448
  set(function (p) {
@@ -728,6 +760,38 @@ window.__ModuleLoader__.load({
728
760
  })
729
761
  })
730
762
  }
763
+ // ── 版本与更新(v0.4.48)────────────────────────────────────────
764
+ //
765
+ // 只检查、不安装。升级由用户自己执行 DSH 命令。
766
+ // 详见 lib/update-check.js 头注释(为什么不自动更新)。
767
+ function checkVersion(force) {
768
+ vset(function (p) {
769
+ return { info: p.info, loading: true, copied: false }
770
+ })
771
+ fetchVersion(force).then(function (r) {
772
+ vset(function (p) {
773
+ return { info: r || { ok: false, error: "无法连接" }, loading: false, copied: p.copied }
774
+ })
775
+ })
776
+ }
777
+
778
+ // 复制升级命令。失败就退回"显示命令让用户自己选"。
779
+ function copyUpgradeCommand(cmd) {
780
+ var done = function (ok) {
781
+ vset(function (p) { return { info: p.info, loading: p.loading, copied: ok } })
782
+ if (!ok) {
783
+ // 复制失败:至少把命令显示出来(界面上本来就有)
784
+ console.warn("[dsh-notify-say] 复制失败,请手动选择命令文本")
785
+ }
786
+ }
787
+ try {
788
+ if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
789
+ navigator.clipboard.writeText(cmd).then(function () { done(true) }).catch(function () { done(false) })
790
+ return
791
+ }
792
+ } catch (e) { /* 继续用兜底 */ }
793
+ done(false)
794
+ }
731
795
 
732
796
  var v = s.values || {}
733
797
  var rows = []
@@ -1714,6 +1778,82 @@ window.__ModuleLoader__.load({
1714
1778
  rows.push(h('div', { key: 'dg-hint', style: S.subHint },
1715
1779
  '导出到日志同目录(%TEMP%\\dsh-notify-say\\)。会自动把用户名替换为 <user>、'
1716
1780
  + '绝对路径替换为 <path>,但**仍请自己再看一遍**再上传。'))
1781
+
1782
+ // ── 版本与更新(v0.4.48)─────────────────────────────────────
1783
+ //
1784
+ // 只显示版本状态 + 给升级指引。**不自动下载、不覆盖文件**:
1785
+ // 本机可能是 Junction 指向开发源码,自动覆盖会直接毁掉源码;
1786
+ // 而且 DSH 官方 CLI 已经能升级插件。
1787
+ rows.push(h('div', { key: 'up-title', style: S.label }, '版本与更新'))
1788
+ var vi = vs.info
1789
+ var cur = vi && vi.current ? vi.current : "(未知)"
1790
+ var lat = vi && vi.latest ? vi.latest : null
1791
+
1792
+ rows.push(h('div', { key: 'up-cur', style: S.row },
1793
+ h('span', { style: S.label }, '当前版本'),
1794
+ h('span', null, 'v' + cur)))
1795
+
1796
+ if (vs.loading) {
1797
+ rows.push(h('div', { key: 'up-loading', style: S.subHint }, '正在检查…'))
1798
+ } else if (!vi) {
1799
+ rows.push(h('div', { key: 'up-idle', style: S.subHint },
1800
+ '还没检查过。点下面的按钮查一下有没有新版本。'))
1801
+ } else if (!vi.ok) {
1802
+ rows.push(h('div', { key: 'up-err', style: S.subHint },
1803
+ '暂时无法检查(' + (vi.error || '网络不可用') + ')。'
1804
+ + '本地通知和语音不依赖它,照常工作。'))
1805
+ } else if (vi.hasUpdate === true) {
1806
+ rows.push(h('div', { key: 'up-new', style: S.row },
1807
+ h('span', { style: S.label }, '最新版本'),
1808
+ h('span', { style: { color: '#e0a030', fontWeight: 600 } }, 'v' + lat + '(有新版)')))
1809
+ } else if (vi.hasUpdate === false) {
1810
+ rows.push(h('div', { key: 'up-same', style: S.row },
1811
+ h('span', { style: S.label }, '最新版本'),
1812
+ h('span', { style: { color: '#2a7' } }, 'v' + lat + '(已是最新)')))
1813
+ } else {
1814
+ rows.push(h('div', { key: 'up-unknown', style: S.subHint },
1815
+ '最新版本 v' + lat + '(本地版本格式无法比较)'))
1816
+ }
1817
+
1818
+ // 有新版时给命令;开发模式给说明而不是命令(不能覆盖源码)
1819
+ if (vi && vi.hasUpdate === true) {
1820
+ if (vi.command) {
1821
+ rows.push(h('div', { key: 'up-cmd', style: {
1822
+ marginTop: '4px', padding: '8px', borderRadius: '4px',
1823
+ background: 'rgba(255,255,255,.06)',
1824
+ fontFamily: 'monospace', fontSize: '12px', wordBreak: 'break-all',
1825
+ } }, vi.command))
1826
+ }
1827
+ rows.push(h('div', { key: 'up-note', style: S.subHint }, vi.note || ''))
1828
+ } else if (vi && vi.note) {
1829
+ rows.push(h('div', { key: 'up-mode', style: S.subHint }, vi.note))
1830
+ }
1831
+
1832
+ rows.push(h('div', { key: 'up-btn', style: S.row },
1833
+ h('button', {
1834
+ type: 'button',
1835
+ disabled: vs.loading,
1836
+ style: S.btn,
1837
+ onClick: function () { checkVersion(true) },
1838
+ }, vs.loading ? '检查中…' : '检查更新'),
1839
+ vi && vi.hasUpdate === true && vi.command
1840
+ ? h('button', {
1841
+ type: 'button',
1842
+ style: S.btn,
1843
+ onClick: function () { copyUpgradeCommand(vi.command) },
1844
+ }, vs.copied ? '已复制' : '复制命令')
1845
+ : null,
1846
+ vi && vi.changelogUrl
1847
+ ? h('a', {
1848
+ href: vi.changelogUrl,
1849
+ target: '_blank',
1850
+ rel: 'noreferrer noopener',
1851
+ style: { color: '#4a9', fontSize: '12px' },
1852
+ }, '更新日志 ↗')
1853
+ : null,
1854
+ ))
1855
+ rows.push(h('div', { key: 'up-offline', style: S.subHint },
1856
+ '本地通知与语音不依赖更新检查;网络不可用时照常工作。'))
1717
1857
  }
1718
1858
 
1719
1859
  if (s.msg) rows.push(h('div', { key: 'msg', style: S.ok }, s.msg))
package/lib/index.js CHANGED
@@ -98,6 +98,7 @@ import {
98
98
  import { checkLocalOrigin, throttle } from './http-guard.js';
99
99
  import { poolSummary } from './voice-pack-store.js';
100
100
  import { voiceRootsArg as computeVoiceRootsArg } from './voice-regions.js';
101
+ import { checkForUpdate } from './update-check.js';
101
102
  import { createVoicePackRoute } from './voice-pack-route.js';
102
103
  import { isDebug, redactText } from './log-privacy.js';
103
104
  import { exportDiagnostics } from './diagnostics.js';
@@ -106,6 +107,14 @@ import { pickTtsText, sanitizeTtsText, MAX_TEXT_LEN } from './tts-lines.js';
106
107
 
107
108
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
108
109
 
110
+
111
+ // 检查更新的宿主侧缓存(v0.4.48)。
112
+ //
113
+ // 为什么放宿主侧而不是浏览器:
114
+ // · 一天最多查一次 —— 不管用户开几次设置页,都不该反复打 npm/GitHub
115
+ // · 报错也不影响功能:查不到就是查不到,通知与语音照常
116
+ // · 缓存跨会话存活(模块级),设置页重开不必重新联网
117
+ const UPDATE_CACHE = { at: 0, result: null };
109
118
  // 插件版本(诊断导出用)。读 package.json,失败就返回 unknown ——
110
119
  // 这只是个信息字段,不值得为它抛异常。
111
120
  const PLUGIN_VERSION = (() => {
@@ -1611,7 +1620,42 @@ function hasVoiceLine(psKind) {
1611
1620
  return;
1612
1621
  }
1613
1622
 
1614
- if (head === 'config') return configRoute.handler(req, res);
1623
+ // ── 检查更新(v0.4.48)──────────────────────────────────
1624
+ //
1625
+ // **只读**:不下载、不覆盖文件、不执行命令。
1626
+ // 也刻意不放进 isWriteRoute —— 它没有任何副作用。
1627
+ //
1628
+ // 为什么不接受客户端传 URL:那等于让本机访问任意地址(SSRF)。
1629
+ // 查询地址固定在 update-check.js 里。
1630
+ if (head === 'version') {
1631
+ // ?force=1 表示用户手动点了「检查更新」,绕过 24 小时缓存
1632
+ const force = /[?&]force=1\b/.test(String((req && req.url) || ''));
1633
+ //
1634
+ // ⚠️ 必须包一层 catch:检查更新永远不该把设置页弄崩。
1635
+ // 网络不通是常态(用户可能在离线环境),此时只报错误,
1636
+ // 通知与语音完全不受影响。
1637
+ Promise.resolve()
1638
+ .then(() => checkForUpdate({
1639
+ pluginRoot: PLUGIN_ROOT,
1640
+ force,
1641
+ cache: UPDATE_CACHE,
1642
+ }))
1643
+ .then((r) => {
1644
+ try {
1645
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1646
+ res.end(JSON.stringify(r));
1647
+ } catch { /* 响应已发出 */ }
1648
+ })
1649
+ .catch((e) => {
1650
+ try {
1651
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
1652
+ res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
1653
+ } catch { /* 响应已发出 */ }
1654
+ });
1655
+ return;
1656
+ }
1657
+
1658
+ if (head === 'config') return configRoute.handler(req, res);
1615
1659
 
1616
1660
  // ── 自测端点(v0.4.11)────────────────────────────────────
1617
1661
  //
@@ -0,0 +1,324 @@
1
+ /**
2
+ * 检查更新(v0.4.48)。
3
+ *
4
+ * ## 定位:只检查,不安装
5
+ *
6
+ * 本模块**只做三件事**:读出当前版本、查出最新版本、给出升级指引。
7
+ * 它不下载、不覆盖文件、不执行命令、不重启宿主。
8
+ *
9
+ * 为什么不做自动更新:
10
+ * · 插件正在运行时替换自己的文件,失败后很难恢复
11
+ * · 用户可能装在任意 profile,插件无从得知
12
+ * · 本机安装可能是 Junction(指向开发源码),覆盖会直接毁掉开发目录
13
+ * · DSH 官方 CLI 已经能升级插件(`dsh plugin update`),重复实现没意义
14
+ *
15
+ * 所以分工是:**本插件发现新版本 + 说明变更 + 告诉用户命令;DSH 执行升级。**
16
+ *
17
+ * ## 为什么不用 GitHub Releases 的 latest
18
+ *
19
+ * ⚠️ 实测(2026-10-18):本仓库的 `/releases/latest` 指向 **`voice-packs`**
20
+ * —— 那是语音包索引的固定 tag,跟插件版本毫无关系。
21
+ * 仓库里只有 `v0.4.30`(语音包下载支持那一版)和 `voice-packs` 两个 Release,
22
+ * 插件版本只打 git tag、不发 Release。
23
+ *
24
+ * 所以用 `/releases/latest` 做更新检查会得出荒谬结论("最新版是 voice-packs")。
25
+ *
26
+ * ## 用什么当权威源
27
+ *
28
+ * 两个来源,都是"发布时同步更新"的:
29
+ * ① npm registry 的 `dist-tags.latest`(npm 安装的用户)
30
+ * ② GitHub 仓库 main 分支的 package.json(GitHub / link 安装的用户)
31
+ *
32
+ * 先查 ①,失败回退 ②。两者都由 `tools/release.ps1` 同时更新,正常情况下一致。
33
+ *
34
+ * ## 缓存
35
+ *
36
+ * 宿主侧缓存 24 小时:GUI 每次打开不必真的联网,
37
+ * 但一天最多查一次,足够"发现有新版"。
38
+ * 用户手动点「检查更新」时强制绕过缓存。
39
+ *
40
+ * @module lib/update-check.js
41
+ */
42
+
43
+ import { readFileSync, realpathSync } from 'node:fs';
44
+ import { join } from 'node:path';
45
+
46
+ /** npm registry 的包元数据地址。 */
47
+ export const NPM_REGISTRY_URL = 'https://registry.npmjs.org/dsh-notify-say';
48
+
49
+ /** GitHub main 分支的 package.json(GitHub 安装的用户看这个)。 */
50
+ export const GITHUB_RAW_PKG_URL =
51
+ 'https://raw.githubusercontent.com/unifilitence/dsh-notify-say/main/package.json';
52
+
53
+ /** 更新日志(人看的页面)。 */
54
+ export const CHANGELOG_URL =
55
+ 'https://github.com/unifilitence/dsh-notify-say/blob/main/CHANGELOG.md';
56
+
57
+ /** 单次查询超时。网络不通时不要拖住界面。 */
58
+ export const FETCH_TIMEOUT_MS = 5000;
59
+
60
+ /** 自动检查的最小间隔:一天。 */
61
+ export const AUTO_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
62
+
63
+ /**
64
+ * 读当前版本(读自己的 package.json,不联网)。
65
+ *
66
+ * @param {string} pluginRoot
67
+ * @returns {string} 读不到时返回空串
68
+ */
69
+ export function currentVersion(pluginRoot) {
70
+ try {
71
+ const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
72
+ return typeof pkg.version === 'string' ? pkg.version : '';
73
+ } catch {
74
+ return '';
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 把 `1.2.3` / `v1.2.3` 规整成可比较的数组。
80
+ *
81
+ * 只处理数字点分版本;带 `-rc.1` 之类后缀时只取前面的数字段
82
+ * (预发布版不该被建议"升级到正式版"以外的东西,这里不做精细 semver)。
83
+ *
84
+ * @param {string} v
85
+ * @returns {number[] | null}
86
+ */
87
+ export function parseVersion(v) {
88
+ const s = String(v || '').trim().replace(/^v/i, '');
89
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(s);
90
+ if (!m) return null;
91
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
92
+ }
93
+
94
+ /**
95
+ * 比较两个版本。返回 -1 / 0 / 1;任一不可解析时返回 null。
96
+ *
97
+ * @param {string} a
98
+ * @param {string} b
99
+ * @returns {number | null}
100
+ */
101
+ export function compareVersions(a, b) {
102
+ const pa = parseVersion(a);
103
+ const pb = parseVersion(b);
104
+ if (!pa || !pb) return null;
105
+ for (let i = 0; i < 3; i++) {
106
+ if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1;
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ /**
112
+ * 判断当前安装形态,决定给什么升级建议。
113
+ *
114
+ * 三种情况:
115
+ * · `dev` —— 目录是 junction/symlink,或本身就是 git 仓库
116
+ * (开发模式:**绝不能**用升级命令覆盖源码)
117
+ * · `npm` —— 其余情况,按 npm 包给出命令
118
+ *
119
+ * 判断失败一律当 `dev`(保守:宁可让用户看到"请手动处理",
120
+ * 也不要给一条会覆盖开发目录的命令)。
121
+ *
122
+ * @param {string} pluginRoot
123
+ * @returns {'dev' | 'npm'}
124
+ */
125
+ export function detectInstallMode(pluginRoot) {
126
+ try {
127
+ // ① 自身或父目录是 git 仓库 → 源码安装
128
+ let dir = pluginRoot;
129
+ for (let i = 0; i < 6; i++) {
130
+ try {
131
+ readFileSync(join(dir, '.git', 'HEAD'));
132
+ return 'dev';
133
+ } catch { /* 继续往上 */ }
134
+ const parent = join(dir, '..');
135
+ if (parent === dir) break;
136
+ dir = parent;
137
+ }
138
+ } catch { /* ignore */ }
139
+
140
+ try {
141
+ // ② 真实路径与自身不同 → junction / symlink
142
+ const real = realpathSync(pluginRoot);
143
+ if (real && real !== pluginRoot) return 'dev';
144
+ } catch { /* 拿不到就当 npm */ }
145
+
146
+ return 'npm';
147
+ }
148
+
149
+ /**
150
+ * 从 npm registry 取最新版本。
151
+ *
152
+ * @param {Function} doFetch
153
+ * @returns {Promise<{ok:boolean, version?:string, error?:string}>}
154
+ */
155
+ async function fetchFromNpm(doFetch) {
156
+ try {
157
+ const res = await doFetch(NPM_REGISTRY_URL, { cache: 'no-store' });
158
+ if (!res || !res.ok) return { ok: false, error: `npm-http-${res ? res.status : 'none'}` };
159
+ const doc = await res.json();
160
+ const v = doc && doc['dist-tags'] ? doc['dist-tags'].latest : '';
161
+ if (!v) return { ok: false, error: 'npm-no-latest-tag' };
162
+ return { ok: true, version: String(v) };
163
+ } catch (e) {
164
+ return { ok: false, error: `npm: ${(e && e.message) || e}` };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * 从 GitHub main 的 package.json 取版本。
170
+ *
171
+ * @param {Function} doFetch
172
+ * @returns {Promise<{ok:boolean, version?:string, error?:string}>}
173
+ */
174
+ async function fetchFromGithub(doFetch) {
175
+ try {
176
+ const res = await doFetch(GITHUB_RAW_PKG_URL, { cache: 'no-store' });
177
+ if (!res || !res.ok) return { ok: false, error: `gh-http-${res ? res.status : 'none'}` };
178
+ const doc = await res.json();
179
+ const v = doc && doc.version;
180
+ if (!v) return { ok: false, error: 'gh-no-version' };
181
+ return { ok: true, version: String(v) };
182
+ } catch (e) {
183
+ return { ok: false, error: `gh: ${(e && e.message) || e}` };
184
+ }
185
+ }
186
+
187
+ /** 带超时的 fetch 包装(超时不 abort 也没关系,只是不再等)。 */
188
+ function withTimeout(doFetch, ms) {
189
+ return (url, opts) => Promise.race([
190
+ doFetch(url, opts),
191
+ new Promise((_, reject) => {
192
+ const t = setTimeout(() => reject(new Error('timeout')), ms);
193
+ if (t && typeof t.unref === 'function') t.unref();
194
+ }),
195
+ ]);
196
+ }
197
+
198
+ /**
199
+ * 给某个安装形态的升级指引。
200
+ *
201
+ * ⚠️ profile 名无法可靠得知,所以命令里用占位符并说明要替换。
202
+ * 不要假装知道用户装在哪。
203
+ *
204
+ * @param {'dev' | 'npm'} mode
205
+ * @returns {{ command: string, note: string }}
206
+ */
207
+ export function upgradeHint(mode) {
208
+ if (mode === 'dev') {
209
+ return {
210
+ command: '',
211
+ note: '检测到源码/链接安装(开发模式)。请直接更新源码目录后重启 DSH —— '
212
+ + '**不要**用插件升级命令,那会覆盖你的源码。',
213
+ };
214
+ }
215
+ return {
216
+ command: 'dsh plugin --profile <你的 profile> update dsh-notify-say',
217
+ note: '把 <你的 profile> 换成实际名称(常见是 desktop)。'
218
+ + '更新后需要重启对应的 DSH profile 才会完整生效。',
219
+ };
220
+ }
221
+
222
+ /**
223
+ * 检查更新。
224
+ *
225
+ * @param {object} opts
226
+ * @param {string} opts.pluginRoot
227
+ * @param {boolean} [opts.force] 绕过缓存(用户手动点「检查更新」)
228
+ * @param {Function} [opts.fetchImpl]
229
+ * @param {object} [opts.cache] 宿主持有的缓存对象(`{ at, result }`)
230
+ * @param {number} [opts.now]
231
+ * @returns {Promise<object>} 结构化结果,**永不抛异常**
232
+ */
233
+ export async function checkForUpdate(opts = {}) {
234
+ const pluginRoot = opts.pluginRoot || '';
235
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
236
+ const current = currentVersion(pluginRoot);
237
+ const mode = detectInstallMode(pluginRoot);
238
+
239
+ const base = {
240
+ current,
241
+ installMode: mode,
242
+ changelogUrl: CHANGELOG_URL,
243
+ ...upgradeHint(mode),
244
+ };
245
+
246
+ // 缓存命中(未强制刷新且未过期)
247
+ const cache = opts.cache;
248
+ if (!opts.force && cache && cache.at && cache.result
249
+ && (now - cache.at) < AUTO_CHECK_TTL_MS) {
250
+ return { ...cache.result, cached: true, checkedAt: cache.at };
251
+ }
252
+
253
+ const doFetch = typeof (opts.fetchImpl || globalThis.fetch) === 'function'
254
+ ? (opts.fetchImpl || globalThis.fetch)
255
+ : null;
256
+ if (!doFetch) {
257
+ return { ok: false, ...base, error: 'fetch-unavailable', cached: false, checkedAt: now };
258
+ }
259
+
260
+ const timed = withTimeout(doFetch, opts.timeoutMs || FETCH_TIMEOUT_MS);
261
+
262
+ // ── 并行查两个源,取较高版本 ─────────────────────────────────────────
263
+ //
264
+ // ⚠️ 不能"先 npm、失败才回退 GitHub"。实测(2026-10-18):
265
+ // npm latest = 0.4.47(暂存队列要过一阵才上架)
266
+ // GitHub main = 0.4.48
267
+ // 只查 npm 的话,这期间**所有人都看不到 0.4.48 可用** ——
268
+ // 而 GitHub 安装的用户本来就是以 main 为准的。
269
+ //
270
+ // 两个源都是同一套发布流程产出的正式版本,所以取较新的那个是安全的。
271
+ const [npmR, ghR] = await Promise.all([
272
+ fetchFromNpm(timed).catch((e) => ({ ok: false, error: String((e && e.message) || e) })),
273
+ fetchFromGithub(timed).catch((e) => ({ ok: false, error: String((e && e.message) || e) })),
274
+ ]);
275
+
276
+ // 挑出可用的版本,取最高的
277
+ const candidates = [];
278
+ if (npmR.ok) candidates.push({ version: npmR.version, source: 'npm' });
279
+ if (ghR.ok) candidates.push({ version: ghR.version, source: 'github' });
280
+
281
+ if (candidates.length === 0) {
282
+ // 两个都失败:明确报告,但**不影响插件任何功能**
283
+ const result = {
284
+ ok: false,
285
+ ...base,
286
+ error: `npm(${npmR.error}) / github(${ghR.error})`,
287
+ cached: false,
288
+ checkedAt: now,
289
+ };
290
+ if (cache) { cache.at = now; cache.result = result; }
291
+ return result;
292
+ }
293
+
294
+ let best = candidates[0];
295
+ for (const c of candidates.slice(1)) {
296
+ const d = compareVersions(c.version, best.version);
297
+ // 解析不出时保留先到的(npm 优先),不因为格式怪就丢掉可用信息
298
+ if (d === 1) best = c;
299
+ }
300
+
301
+ const r = { version: best.version };
302
+ const source = candidates.length > 1 ? `${best.source}+${candidates.length - 1}` : best.source;
303
+
304
+ const cmp = compareVersions(r.version, current);
305
+ const result = {
306
+ ok: true,
307
+ ...base,
308
+ latest: r.version,
309
+ // ⚠️ 方向是 `latest > current` 才算有新版。
310
+ //
311
+ // 第一版写成 `cmp < 0` —— **反了**。实测才发现:本地 0.4.48、
312
+ // npm 还在暂存队列里是 0.4.47,界面却显示"有新版"。
313
+ // 也就是说:npm 落后时装作有新版本,npm 超前时反而不提示。
314
+ //
315
+ // 解析不出(比如本地是 0.0.0-stage)时不下"有新版"的结论。
316
+ hasUpdate: cmp === null ? null : cmp > 0,
317
+ source,
318
+ cached: false,
319
+ checkedAt: now,
320
+ };
321
+
322
+ if (cache) { cache.at = now; cache.result = result; }
323
+ return result;
324
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.47",
3
+ "version": "0.4.50",
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",
@@ -0,0 +1,358 @@
1
+ /**
2
+ * 检查更新(v0.4.48)。
3
+ *
4
+ * ## 这个功能最需要测什么
5
+ *
6
+ * 不是"能不能查到版本"——那是网络问题,测不出什么。
7
+ * 真正要锁的是**它不会做危险的事**:
8
+ * · 不能自己下载/覆盖文件/执行命令
9
+ * · 不能走 /releases/latest(实测那指向 voice-packs,不是插件版本)
10
+ * · 不能因为检查失败影响通知与语音
11
+ * · 开发模式(Junction)绝对不能给出会覆盖源码的命令
12
+ * · 不能接受客户端传的 URL(SSRF)
13
+ */
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
17
+ import { join, resolve, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
21
+ const INDEX = readFileSync(join(ROOT, 'lib', 'index.js'), 'utf8');
22
+ const CLIENT = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8');
23
+ const UC = readFileSync(join(ROOT, 'lib', 'update-check.js'), 'utf8');
24
+
25
+ // ── U1: 版本解析与比较 ──────────────────────────────────────────────────
26
+ test('U1: 版本比较正确处理 v 前缀与非法输入', async () => {
27
+ const { parseVersion, compareVersions } = await import('../lib/update-check.js');
28
+
29
+ assert.deepEqual(parseVersion('1.2.3'), [1, 2, 3]);
30
+ assert.deepEqual(parseVersion('v1.2.3'), [1, 2, 3]);
31
+ assert.equal(parseVersion('abc'), null);
32
+ assert.equal(parseVersion(''), null);
33
+
34
+ assert.equal(compareVersions('1.2.3', '1.2.3'), 0);
35
+ assert.equal(compareVersions('1.2.3', '1.2.4'), -1);
36
+ assert.equal(compareVersions('1.3.0', '1.2.9'), 1);
37
+ assert.equal(compareVersions('v0.4.47', '0.4.46'), 1);
38
+ // 解析不出就返回 null —— 调用方据此**不下"有新版"的结论**
39
+ assert.equal(compareVersions('abc', '1.0.0'), null);
40
+ });
41
+
42
+ // ── U2: 不解析的版本不能误报"有新版" ────────────────────────────────────
43
+ test('U2: 版本格式异常时 hasUpdate 为 null(不误报)', async () => {
44
+ const { checkForUpdate } = await import('../lib/update-check.js');
45
+
46
+ const tmp = join(ROOT, '.tmp-u2-' + Math.random().toString(36).slice(2, 8));
47
+ try {
48
+ mkdirSync(tmp, { recursive: true });
49
+ writeFileSync(join(tmp, 'package.json'), JSON.stringify({ version: 'weird' }));
50
+
51
+ const fake = async () => ({ ok: true, json: async () => ({ 'dist-tags': { latest: '9.9.9' } }) });
52
+ const r = await checkForUpdate({ pluginRoot: tmp, force: true, fetchImpl: fake });
53
+ assert.equal(r.ok, true);
54
+ assert.equal(r.hasUpdate, null,
55
+ '本地版本解析不出时不能判定"有新版",否则用户会去升级一个根本没装的东西');
56
+ } finally {
57
+ rmSync(tmp, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ // ── U3: 开发模式(Junction / 源码)不给升级命令 ─────────────────────────
62
+ test('U3: 开发模式不给出会覆盖源码的命令', async () => {
63
+ const { upgradeHint, detectInstallMode } = await import('../lib/update-check.js');
64
+
65
+ const dev = upgradeHint('dev');
66
+ assert.equal(dev.command, '',
67
+ '开发模式必须**没有**命令 —— 本机是 Junction 指向开发源码,'
68
+ + '用 dsh plugin update 会直接覆盖掉源码');
69
+ assert.match(dev.note, /不要/,
70
+ '必须明确劝阻用插件升级命令');
71
+
72
+ const npm = upgradeHint('npm');
73
+ assert.match(npm.command, /dsh plugin .*update dsh-notify-say/,
74
+ 'npm 安装才给 dsh 的升级命令');
75
+ assert.match(npm.command, /<.*profile.*>/,
76
+ 'profile 名无法可靠得知,必须用占位符而不是假装知道');
77
+ assert.match(npm.note, /重启/,
78
+ '要提示更新后需重启 profile');
79
+ });
80
+
81
+ // ── U4: 源失败要回退,两个都失败要如实报错 ──────────────────────────────
82
+ test('U4: npm 失败回退 GitHub;都失败时如实报错', async () => {
83
+ const { checkForUpdate, NPM_REGISTRY_URL, GITHUB_RAW_PKG_URL } =
84
+ await import('../lib/update-check.js');
85
+
86
+ // ① npm 挂、GitHub 通
87
+ const seen = [];
88
+ const f1 = async (url) => {
89
+ seen.push(url);
90
+ if (url === NPM_REGISTRY_URL) return { ok: false, status: 500 };
91
+ return { ok: true, json: async () => ({ version: '0.4.99' }) };
92
+ };
93
+ const r1 = await checkForUpdate({ pluginRoot: ROOT, force: true, fetchImpl: f1 });
94
+ assert.equal(r1.ok, true);
95
+ assert.equal(r1.source, 'github', 'npm 挂了应标明用的是 GitHub');
96
+ assert.ok(seen.includes(GITHUB_RAW_PKG_URL));
97
+
98
+ // ② 两个都挂
99
+ const f2 = async () => ({ ok: false, status: 503 });
100
+ const r2 = await checkForUpdate({ pluginRoot: ROOT, force: true, fetchImpl: f2 });
101
+ assert.equal(r2.ok, false);
102
+ assert.match(String(r2.error), /npm|github/, '应把两个源的失败都说出来');
103
+ });
104
+
105
+ // ── U5: 检查更新永不抛异常(不能把设置页弄崩)───────────────────────────
106
+ test('U5: fetch 抛异常时返回结构化错误,不抛出', async () => {
107
+ const { checkForUpdate } = await import('../lib/update-check.js');
108
+ const boom = async () => { throw new Error('network down'); };
109
+
110
+ const r = await checkForUpdate({ pluginRoot: ROOT, force: true, fetchImpl: boom });
111
+ assert.equal(r.ok, false);
112
+ assert.ok(r.error, '应给出错误原因');
113
+ // 关键:仍然带出当前版本 —— 界面要能显示"你装的是哪个版本"
114
+ assert.ok(r.current, '失败时也要报告当前版本');
115
+ });
116
+
117
+ // ── U6: 缓存 24 小时,force 可绕过 ──────────────────────────────────────
118
+ test('U6: 宿主侧缓存 24 小时,force 绕过', async () => {
119
+ const { checkForUpdate, AUTO_CHECK_TTL_MS, NPM_REGISTRY_URL } = await import('../lib/update-check.js');
120
+ const cache = { at: 0, result: null };
121
+
122
+ // ⚠️ 现在一次检查会**并行查两个源**(npm + GitHub),所以不能用总请求数
123
+ // 衡量"查了几次"。只数 npm 那个请求 —— 每次检查恰好发一次。
124
+ let calls = 0;
125
+ const fake = async (url) => {
126
+ if (url === NPM_REGISTRY_URL) calls++;
127
+ if (url === NPM_REGISTRY_URL) {
128
+ return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.4.99' } }) };
129
+ }
130
+ return { ok: true, json: async () => ({ version: '0.4.99' }) };
131
+ };
132
+
133
+ const t0 = 1_700_000_000_000;
134
+ await checkForUpdate({ pluginRoot: ROOT, fetchImpl: fake, cache, now: t0 });
135
+ assert.equal(calls, 1);
136
+
137
+ // TTL 内再来一次:用缓存,不再联网
138
+ const r2 = await checkForUpdate({ pluginRoot: ROOT, fetchImpl: fake, cache, now: t0 + 1000 });
139
+ assert.equal(calls, 1, 'TTL 内不应重复联网');
140
+ assert.equal(r2.cached, true);
141
+
142
+ // force 强制联网
143
+ await checkForUpdate({ pluginRoot: ROOT, fetchImpl: fake, cache, now: t0 + 2000, force: true });
144
+ assert.equal(calls, 2, 'force 应绕过缓存');
145
+
146
+ // 过期后重新联网
147
+ // ⚠️ 注意基准:上面 force 那次把 cache.at 刷新到 t0+2000,
148
+ // 所以要相对**它**算过期,而不是相对 t0(第一版算错,误判成"未过期")。
149
+ await checkForUpdate({
150
+ pluginRoot: ROOT, fetchImpl: fake, cache,
151
+ now: t0 + 2000 + AUTO_CHECK_TTL_MS + 1,
152
+ });
153
+ assert.equal(calls, 3, '超过 TTL 应重新联网');
154
+ });
155
+
156
+ // ── U7: 不用 /releases/latest(实测那指向 voice-packs)──────────────────
157
+ test('U7: 权威源不是 GitHub Releases 的 latest', () => {
158
+ // ⚠️ 实测(2026-10-18):本仓库 /releases/latest 返回 tag `voice-packs`,
159
+ // 那是语音包索引,跟插件版本毫无关系。
160
+ // 用它会得出"最新版是 voice-packs"这种荒谬结论。
161
+ // ⚠️ 只查代码,不查注释 —— 注释里正是在解释"为什么不能用它"。
162
+ const code = UC
163
+ .replace(/\/\*[\s\S]*?\*\//g, '')
164
+ .replace(/^\s*\/\/.*$/gm, '');
165
+ assert.doesNotMatch(code, /releases\/latest/,
166
+ '不得使用 /releases/latest 作为插件版本来源(它指向 voice-packs)');
167
+
168
+ assert.match(UC, /registry\.npmjs\.org/,
169
+ '应查 npm registry');
170
+ assert.match(UC, /raw\.githubusercontent\.com/,
171
+ '应查 GitHub main 的 package.json');
172
+ });
173
+
174
+ // ── U8: 只读,不做任何危险动作 ──────────────────────────────────────────
175
+ test('U8: update-check 不下载、不写盘、不执行命令', () => {
176
+ // 这是这个功能的**核心安全边界**:它只报告,不行动。
177
+ for (const danger of [
178
+ /writeFileSync/,
179
+ /mkdirSync/,
180
+ /unlinkSync/,
181
+ /rmSync/,
182
+ /child_process/,
183
+ /execSync/,
184
+ /spawn/,
185
+ ]) {
186
+ assert.doesNotMatch(UC, danger,
187
+ `update-check 不应包含 ${danger} —— 它只检查,不安装`);
188
+ }
189
+ });
190
+
191
+ // ── U9: /version 路由是只读的 ───────────────────────────────────────────
192
+ test('U9: /version 不在写操作白名单里,且不接受客户端 URL', () => {
193
+ // isWriteRoute 里不应出现 version
194
+ const at = INDEX.indexOf('const isWriteRoute');
195
+ assert.ok(at > 0);
196
+ const seg = INDEX.slice(at, at + 700);
197
+ assert.doesNotMatch(seg, /'version'/,
198
+ '/version 没有任何副作用,不该算写操作(否则会被强制要求 JSON 与 Origin)');
199
+
200
+ // 路由内部不能从请求里读 URL —— 那等于让本机访问任意地址(SSRF)
201
+ const vAt = INDEX.indexOf("if (head === 'version') {");
202
+ assert.ok(vAt > 0, '应有 /version 路由');
203
+ const vSeg = INDEX.slice(vAt, vAt + 1200);
204
+ assert.doesNotMatch(vSeg, /searchParams\.get\(['"]url/,
205
+ '不得接受客户端传入的查询地址');
206
+ assert.doesNotMatch(vSeg, /req\.url.*https?:/,
207
+ '不得把请求里的 URL 直接当抓取目标');
208
+ });
209
+
210
+ // ── U10: 前端只读结果,不执行任何安装动作 ───────────────────────────────
211
+ test('U10: GUI 只显示版本与命令,绝不自动安装', () => {
212
+ // 界面上有版本卡片
213
+ assert.match(CLIENT, /版本与更新/);
214
+ assert.match(CLIENT, /检查更新/);
215
+
216
+ // 但**不能**有任何"自动更新"的调用
217
+ for (const danger of [
218
+ /updatePlugin\s*\(/,
219
+ /installUpdate\s*\(/,
220
+ /applyUpdate\s*\(/,
221
+ ]) {
222
+ assert.doesNotMatch(CLIENT, danger,
223
+ 'GUI 不得有自动安装/自动更新的入口');
224
+ }
225
+
226
+ // 升级命令只能是"显示"和"复制",不能被执行
227
+ const at = CLIENT.indexOf('copyUpgradeCommand');
228
+ assert.ok(at > 0, '应有复制命令的动作');
229
+ const seg = CLIENT.slice(at, at + 800);
230
+ assert.doesNotMatch(seg, /exec|spawn|shell/i,
231
+ '复制命令只能写剪贴板,不能执行');
232
+ });
233
+
234
+ // ── U11: 版本状态用独立 state,不掺进主 state ───────────────────────────
235
+ test('U11: 版本信息用独立 useState(避开 25 处 set 的字段遗漏坑)', () => {
236
+ // 主 state 有 25 处 set() 逐字段重建,加字段要改 25 处 ——
237
+ // 已因此出过 4 次 bug。版本信息走独立 state 就一劳永逸。
238
+ assert.match(CLIENT, /var vst = React\.useState\(/,
239
+ '版本信息应有独立的 useState');
240
+ assert.match(CLIENT, /var vset = vst\[1\]/);
241
+
242
+ // 主 state 里不应出现 version 字段(那会要求 25 处都带上它)
243
+ const stAt = CLIENT.indexOf('var st = React.useState({');
244
+ const stEnd = CLIENT.indexOf('})', stAt);
245
+ const mainState = CLIENT.slice(stAt, stEnd);
246
+ assert.doesNotMatch(mainState, /^\s*version:/m,
247
+ '版本字段不该塞进主 state');
248
+ });
249
+
250
+ // ── U12: hasUpdate 的方向必须正确(本地旧→true,本地新→false)──────────
251
+ test('U12: hasUpdate 方向 —— 只在远端更新时为 true', async () => {
252
+ // ⚠️ 2026-10-18 实测抓到的真 bug:第一版写成 `cmp < 0`,**方向反了**。
253
+ //
254
+ // 表现:本地 0.4.48、npm 还在暂存队列里是 0.4.47 ——
255
+ // 界面却显示"有新版"。也就是 npm 落后时装作有新版本,
256
+ // npm 超前时反而不提示。
257
+ //
258
+ // 这个 bug 能溜过去,是因为 U1 只测了 compareVersions 本身,
259
+ // 没有任何用例断言 hasUpdate 的**方向**。所以补上。
260
+ const { checkForUpdate } = await import('../lib/update-check.js');
261
+
262
+ const tmp = join(ROOT, '.tmp-u12-' + Math.random().toString(36).slice(2, 8));
263
+ try {
264
+ mkdirSync(tmp, { recursive: true });
265
+
266
+ // ① 远端更新(0.9.9 > 0.4.48)→ 有新版
267
+ writeFileSync(join(tmp, 'package.json'), JSON.stringify({ version: '0.4.48' }));
268
+ const newer = async () => ({ ok: true, json: async () => ({ 'dist-tags': { latest: '0.9.9' } }) });
269
+ const r1 = await checkForUpdate({ pluginRoot: tmp, force: true, fetchImpl: newer });
270
+ assert.equal(r1.hasUpdate, true,
271
+ '远端 0.9.9 > 本地 0.4.48,必须提示有新版');
272
+
273
+ // ② 远端落后(npm 暂存队列期间会这样)→ 不能提示有新版
274
+ const older = async () => ({ ok: true, json: async () => ({ 'dist-tags': { latest: '0.4.40' } }) });
275
+ const r2 = await checkForUpdate({ pluginRoot: tmp, force: true, fetchImpl: older });
276
+ assert.equal(r2.hasUpdate, false,
277
+ '远端 0.4.40 < 本地 0.4.48,**不能**提示有新版 —— '
278
+ + 'npm 暂存队列期间就会出现这种"远端落后",提示更新会让用户困惑');
279
+
280
+ // ③ 相同版本 → 无新版
281
+ const same = async () => ({ ok: true, json: async () => ({ 'dist-tags': { latest: '0.4.48' } }) });
282
+ const r3 = await checkForUpdate({ pluginRoot: tmp, force: true, fetchImpl: same });
283
+ assert.equal(r3.hasUpdate, false, '版本相同不应提示更新');
284
+ } finally {
285
+ rmSync(tmp, { recursive: true, force: true });
286
+ }
287
+ });
288
+ // ── U13: 两个源都查,取较高版本(npm 暂存队列期间不能漏更新)────────────
289
+ test('U13: 并行查两个源,取较高版本', async () => {
290
+ // 实测场景(2026-10-18):npm 有暂存队列,发布后 latest 会落后一阵。
291
+ // npm latest = 0.4.47
292
+ // GitHub main = 0.4.48
293
+ // 若"先 npm、失败才回退 GitHub",这期间**所有人都看不到 0.4.48 可用**,
294
+ // 而 GitHub 安装的用户本来就是以 main 为准的。
295
+ //
296
+ // 所以并行查两个源、取较高版本。两个源都是同一套发布流程的正式产物。
297
+ const { checkForUpdate, NPM_REGISTRY_URL, GITHUB_RAW_PKG_URL } =
298
+ await import('../lib/update-check.js');
299
+
300
+ const tmp = join(ROOT, '.tmp-u13-' + Math.random().toString(36).slice(2, 8));
301
+ try {
302
+ mkdirSync(tmp, { recursive: true });
303
+ writeFileSync(join(tmp, 'package.json'), JSON.stringify({ version: '0.4.47' }));
304
+
305
+ const seen = [];
306
+ // npm 落后、GitHub 较新
307
+ const fake = async (url) => {
308
+ seen.push(url);
309
+ if (url === NPM_REGISTRY_URL) {
310
+ return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.4.47' } }) };
311
+ }
312
+ if (url === GITHUB_RAW_PKG_URL) {
313
+ return { ok: true, json: async () => ({ version: '0.4.48' }) };
314
+ }
315
+ return { ok: false, status: 404 };
316
+ };
317
+
318
+ const r = await checkForUpdate({ pluginRoot: tmp, force: true, fetchImpl: fake });
319
+
320
+ assert.equal(r.ok, true);
321
+ assert.equal(r.latest, '0.4.48',
322
+ 'npm 落后时应采信 GitHub 的更高版本 —— 否则用户永远等不到更新提示');
323
+ assert.equal(r.hasUpdate, true, '0.4.48 > 0.4.47,应有新版');
324
+ assert.match(r.source, /github/,
325
+ 'source 应标明采用了 GitHub 的版本');
326
+
327
+ // 两个源都要真的查过(不是串行等待)
328
+ assert.ok(seen.includes(NPM_REGISTRY_URL), '应查 npm');
329
+ assert.ok(seen.includes(GITHUB_RAW_PKG_URL), '应查 GitHub');
330
+ } finally {
331
+ rmSync(tmp, { recursive: true, force: true });
332
+ }
333
+ });
334
+
335
+ // ── U14: 反向 —— 本地较新时不提示(npm 超前也不会误报)──────────────────
336
+ test('U14: 两个源都落后于本地时不提示更新', async () => {
337
+ const { checkForUpdate, NPM_REGISTRY_URL, GITHUB_RAW_PKG_URL } =
338
+ await import('../lib/update-check.js');
339
+
340
+ const tmp = join(ROOT, '.tmp-u14-' + Math.random().toString(36).slice(2, 8));
341
+ try {
342
+ mkdirSync(tmp, { recursive: true });
343
+ writeFileSync(join(tmp, 'package.json'), JSON.stringify({ version: '0.5.0' }));
344
+
345
+ const fake = async (url) => {
346
+ if (url === NPM_REGISTRY_URL) {
347
+ return { ok: true, json: async () => ({ 'dist-tags': { latest: '0.4.40' } }) };
348
+ }
349
+ return { ok: true, json: async () => ({ version: '0.4.48' }) };
350
+ };
351
+
352
+ const r = await checkForUpdate({ pluginRoot: tmp, force: true, fetchImpl: fake });
353
+ assert.equal(r.latest, '0.4.48', '取较高的那个(0.4.48 > 0.4.40)');
354
+ assert.equal(r.hasUpdate, false, '两个源都低于本地 0.5.0,不该提示更新');
355
+ } finally {
356
+ rmSync(tmp, { recursive: true, force: true });
357
+ }
358
+ });