dsh-notify-say 0.4.46 → 0.4.48
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 +4 -0
- package/README.md +388 -219
- package/lib/client.js +141 -1
- package/lib/index.js +45 -1
- package/lib/update-check.js +296 -0
- package/package.json +1 -1
- package/test/client-contract.test.mjs +56 -0
- package/test/docs-consistency.test.mjs +99 -0
- package/test/update-check.test.mjs +243 -0
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 = []
|
|
@@ -1192,7 +1256,7 @@ window.__ModuleLoader__.load({
|
|
|
1192
1256
|
} else {
|
|
1193
1257
|
set(function (prev) {
|
|
1194
1258
|
return {
|
|
1195
|
-
loaded: prev.loaded, values: prev.values, advOpen: prev.advOpen, packs:
|
|
1259
|
+
loaded: prev.loaded, values: prev.values, advOpen: prev.advOpen, packs: prev.packs,
|
|
1196
1260
|
termsAck: prev.termsAck, termsPending: prev.termsPending,
|
|
1197
1261
|
busy: false, msg: '',
|
|
1198
1262
|
err: '没有取到文件路径。若点了没反应,可手动在配置里填该事件的 customFile。',
|
|
@@ -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
|
-
|
|
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,296 @@
|
|
|
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
|
+
// 先 npm,失败回退 GitHub
|
|
263
|
+
let r = await fetchFromNpm(timed);
|
|
264
|
+
let source = 'npm';
|
|
265
|
+
if (!r.ok) {
|
|
266
|
+
const r2 = await fetchFromGithub(timed);
|
|
267
|
+
if (r2.ok) { r = r2; source = 'github'; }
|
|
268
|
+
else {
|
|
269
|
+
// 两个都失败:明确报告,但**不影响插件任何功能**
|
|
270
|
+
const result = {
|
|
271
|
+
ok: false,
|
|
272
|
+
...base,
|
|
273
|
+
error: `npm(${r.error}) / github(${r2.error})`,
|
|
274
|
+
cached: false,
|
|
275
|
+
checkedAt: now,
|
|
276
|
+
};
|
|
277
|
+
if (cache) { cache.at = now; cache.result = result; }
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const cmp = compareVersions(r.version, current);
|
|
283
|
+
const result = {
|
|
284
|
+
ok: true,
|
|
285
|
+
...base,
|
|
286
|
+
latest: r.version,
|
|
287
|
+
// 解析不出(比如本地是 0.0.0-stage)时不下"有新版"的结论
|
|
288
|
+
hasUpdate: cmp === null ? null : cmp < 0,
|
|
289
|
+
source,
|
|
290
|
+
cached: false,
|
|
291
|
+
checkedAt: now,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (cache) { cache.at = now; cache.result = result; }
|
|
295
|
+
return result;
|
|
296
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-say",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.48",
|
|
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",
|
|
@@ -329,4 +329,60 @@ test('C42: 点遮罩外部可取消,点卡片内部不取消', () => {
|
|
|
329
329
|
'应区分"点遮罩"与"点卡片内部" —— 否则点卡片任意位置都会关掉弹窗');
|
|
330
330
|
assert.match(seg, /cancelTerms\(\)/,
|
|
331
331
|
'点外部应取消');
|
|
332
|
+
});
|
|
333
|
+
// ── C43: set() 里的状态字段必须来自它自己的参数 ─────────────────────────
|
|
334
|
+
test('C43: 状态字段引用必须用 set() 自己的参数,不得用外层变量', () => {
|
|
335
|
+
// ⚠️ 2026-10-18 真实 bug(外部审阅发现):
|
|
336
|
+
//
|
|
337
|
+
// pickCustomFile().then(function (p) { // p 是文件路径字符串
|
|
338
|
+
// set(function (prev) { // 状态参数叫 prev
|
|
339
|
+
// return { ..., packs: p.packs, ... } // ← 用错的变量!
|
|
340
|
+
// })
|
|
341
|
+
// })
|
|
342
|
+
//
|
|
343
|
+
// `p` 是路径字符串(取消时是空串),`p.packs` 恒为 undefined →
|
|
344
|
+
// 用户取消选择自定义音频后,**已加载的语音包状态被清掉**。
|
|
345
|
+
//
|
|
346
|
+
// 这类"状态字段遗漏/用错变量"已经出现 4 次(advOpen / packs /
|
|
347
|
+
// termsAck / 这次的 p.packs)。所以用静态扫描固定住,而不是靠人记得。
|
|
348
|
+
const lines = client.split(/\r?\n/);
|
|
349
|
+
const FIELDS = ['loaded', 'values', 'advOpen', 'packs', 'termsAck', 'termsPending', 'busy', 'msg', 'err'];
|
|
350
|
+
|
|
351
|
+
// 允许引用非状态参数的对象:它们是**接口响应**,恰好也有同名字段。
|
|
352
|
+
// r —— saveConfig 的返回值(含 values)
|
|
353
|
+
// doc —— fetchVoicePacks 的返回值(含 packs)
|
|
354
|
+
const ALLOW = new Set(['r', 'doc']);
|
|
355
|
+
|
|
356
|
+
const bad = [];
|
|
357
|
+
for (let i = 0; i < lines.length; i++) {
|
|
358
|
+
const m = lines[i].match(/set\(function \((\w+)\) \{/);
|
|
359
|
+
if (!m) continue;
|
|
360
|
+
const param = m[1];
|
|
361
|
+
|
|
362
|
+
// 取该 set 的块
|
|
363
|
+
let depth = 0, started = false, end = i;
|
|
364
|
+
for (let j = i; j < Math.min(i + 30, lines.length); j++) {
|
|
365
|
+
for (const ch of lines[j]) {
|
|
366
|
+
if (ch === '{') { depth++; started = true; }
|
|
367
|
+
else if (ch === '}') { depth--; }
|
|
368
|
+
}
|
|
369
|
+
if (started && depth === 0) { end = j; break; }
|
|
370
|
+
}
|
|
371
|
+
const block = lines.slice(i, end + 1).join('\n');
|
|
372
|
+
|
|
373
|
+
for (const f of FIELDS) {
|
|
374
|
+
const re = new RegExp(`([A-Za-z_$][\\w$]*)\\.${f}\\b`, 'g');
|
|
375
|
+
let mm;
|
|
376
|
+
while ((mm = re.exec(block)) !== null) {
|
|
377
|
+
const ident = mm[1];
|
|
378
|
+
if (ident === param || ALLOW.has(ident)) continue;
|
|
379
|
+
bad.push(`L${i + 1}: 参数=${param} 但用了 ${mm[0]}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
assert.deepEqual(bad, [],
|
|
385
|
+
'这些 set() 引用了不属于它自己状态参数的字段 —— '
|
|
386
|
+
+ '典型后果是把已加载的状态清成 undefined(语音包列表消失等)。\n'
|
|
387
|
+
+ bad.join('\n'));
|
|
332
388
|
});
|