@goodandready/dsh-tts 0.4.29 → 0.4.30

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
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.30
4
+
5
+ ### Fixed
6
+ - **Supply Chain Security & Updater** (#196, #199):
7
+ - Removed `--config.minimumReleaseAge=0` bypass in `lib/updater.js:installExact`, respecting pnpm supply-chain quarantine during updates.
8
+ - Consolidated updater endpoint to canonical `/api/dsh-tts/update`, removing legacy `/dsh-tts/updater` route alias.
9
+ - Implemented shared `updaterMutex` returning HTTP 409 `Install already in progress` across concurrent requests.
10
+ - **Theme Tokens & Design System** (#197):
11
+ - Replaced legacy CSS custom properties with canonical DSH core theme tokens (`--dsw-state-warn-primary`, `--dsw-brand-primary`, `--dsw-bg-layer-3`).
12
+ - **Client Networking & Timeout Safety** (#198):
13
+ - Wrapped all client `fetch` calls in `clientFetch(url, options)` utility with configurable 15-second `AbortController` timeout.
14
+ - Added error rejection handlers on fire-and-forget telemetry, settings, and player requests.
15
+
3
16
  ## 0.4.29
4
17
 
5
18
  ### Fixed
package/lib/client.js CHANGED
@@ -51,7 +51,24 @@ window.__ModuleLoader__.load({
51
51
  }
52
52
 
53
53
 
54
- function getModelHint(provider, t) {
54
+ function clientFetch(url, options) {
55
+ const opts = options || {}
56
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 15000
57
+ const controller = new AbortController()
58
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
59
+
60
+ if (opts.signal) {
61
+ if (opts.signal.aborted) controller.abort()
62
+ else opts.signal.addEventListener('abort', () => controller.abort(), { once: true })
63
+ }
64
+
65
+ const fetchOpts = Object.assign({}, opts, { signal: controller.signal })
66
+ delete fetchOpts.timeoutMs
67
+
68
+ return fetch(url, fetchOpts).finally(() => clearTimeout(timer))
69
+ }
70
+
71
+ function getModelHint(provider, t) {
55
72
  if (typeof t === 'function') {
56
73
  const loc = t('modelHint_' + provider)
57
74
  if (loc && !loc.startsWith('modelHint_')) return loc
@@ -122,12 +139,12 @@ window.__ModuleLoader__.load({
122
139
  '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0}' +
123
140
  '.dts-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
124
141
  '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
125
- '.dts-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
142
+ '.dts-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary, #6366f1)}' +
126
143
  '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:10px;padding:14px 0 4px}' +
127
144
  '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:500;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);transition:all .15s ease}' +
128
145
  '.dts-save:hover:not(:disabled){opacity:0.88}' +
129
146
  '.dts-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
130
- '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
147
+ '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
131
148
  '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
132
149
  '.dts-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}' +
133
150
  '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}' +
@@ -135,18 +152,18 @@ window.__ModuleLoader__.load({
135
152
  '.dts-row .dts-model{flex:1;min-width:120px}' +
136
153
  '.dts-row .dts-key{flex:1;min-width:180px}' +
137
154
  '.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s ease}' +
138
- '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2))}' +
155
+ '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2))}' +
139
156
  '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
140
157
  '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
141
158
  '.dts-badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
142
159
  '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
143
- '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
160
+ '.dts-badge-warn{color:var(--dsw-alias-state-warn-primary, #f59e0b);border-color:var(--dsw-alias-state-warn-primary, #f59e0b);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 8%, transparent)}' +
144
161
  '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
145
162
  '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
146
163
  '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
147
164
  '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
148
165
  '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
149
- '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
166
+ '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 10%, transparent);color:var(--dsw-alias-state-warn-primary, #f59e0b);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 20%, transparent)}' +
150
167
  '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
151
168
  '.dts-grow{flex:1;min-width:100px}'
152
169
 
@@ -543,7 +560,7 @@ window.__ModuleLoader__.load({
543
560
 
544
561
  async function pollPending() {
545
562
  try {
546
- const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
563
+ const st = await clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 })
547
564
  if (!st.ok) return
548
565
  const meta = await st.json()
549
566
  player.enabled = !!(meta && meta.speakReplies)
@@ -552,7 +569,7 @@ window.__ModuleLoader__.load({
552
569
  if (meta && meta.roles) player.roles = meta.roles
553
570
  if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
554
571
  if (!player.enabled) return
555
- const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
572
+ const res = await clientFetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store', timeoutMs: 8000 })
556
573
  if (!res.ok) return
557
574
  const data = await res.json()
558
575
  const items = data && Array.isArray(data.items) ? data.items : []
@@ -608,10 +625,11 @@ window.__ModuleLoader__.load({
608
625
  function playStandalone(text) {
609
626
  if (!text) return
610
627
  stopPlayback()
611
- fetch('/dsh-tts/speak', {
628
+ clientFetch('/dsh-tts/speak', {
612
629
  method: 'POST',
613
630
  headers: { 'Content-Type': 'application/json' },
614
631
  body: JSON.stringify({ text }),
632
+ timeoutMs: 15000,
615
633
  })
616
634
  .then((r) => r.json())
617
635
  .then((d) => {
@@ -623,15 +641,18 @@ window.__ModuleLoader__.load({
623
641
  audioBase64: d.audioBase64,
624
642
  })
625
643
  })
626
- .catch(() => {})
644
+ .catch((err) => {
645
+ console.warn('[dsh-tts] playStandalone fetch failed:', err)
646
+ })
627
647
  }
628
648
 
629
649
  function previewSpeech(provider, model, voice, text) {
630
650
  stopPlayback()
631
- return fetch('/dsh-tts/preview', {
651
+ return clientFetch('/dsh-tts/preview', {
632
652
  method: 'POST',
633
653
  headers: { 'Content-Type': 'application/json' },
634
654
  body: JSON.stringify({ provider, model, voice, text }),
655
+ timeoutMs: 15000,
635
656
  })
636
657
  .then((r) => r.json())
637
658
  .then((d) => {
@@ -657,10 +678,11 @@ window.__ModuleLoader__.load({
657
678
  if (list && list.items && list.items.length) phrase = list.items[0]
658
679
  }
659
680
  if (!phrase) return
660
- fetch('/dsh-tts/speak', {
681
+ clientFetch('/dsh-tts/speak', {
661
682
  method: 'POST',
662
683
  headers: { 'Content-Type': 'application/json' },
663
684
  body: JSON.stringify({ text: phrase }),
685
+ timeoutMs: 15000,
664
686
  })
665
687
  .then((r) => r.json())
666
688
  .then((d) => {
@@ -1219,8 +1241,8 @@ window.__ModuleLoader__.load({
1219
1241
  const [models, setModels] = React.useState({})
1220
1242
 
1221
1243
  const loadStatus = React.useCallback(() => {
1222
- if (typeof fetch === 'undefined') return
1223
- fetch('/dsh-tts/models/status')
1244
+ if (typeof clientFetch === 'undefined' && typeof fetch === 'undefined') return
1245
+ clientFetch('/dsh-tts/models/status', { timeoutMs: 8000 })
1224
1246
  .then((r) => r.json())
1225
1247
  .then((d) => { if (d && d.ok && d.models) setModels(d.models) })
1226
1248
  .catch(() => {}) // status poll best-effort
@@ -1236,11 +1258,14 @@ window.__ModuleLoader__.load({
1236
1258
  if (typeof window !== 'undefined' && window.confirm) {
1237
1259
  if (!window.confirm(t('confirmDeleteModel'))) return
1238
1260
  }
1239
- fetch('/dsh-tts/models/delete', {
1261
+ clientFetch('/dsh-tts/models/delete', {
1240
1262
  method: 'DELETE',
1241
1263
  headers: { 'content-type': 'application/json' },
1242
1264
  body: JSON.stringify({ engine }),
1243
- }).then(() => loadStatus()).catch(() => {})
1265
+ timeoutMs: 10000,
1266
+ }).then(() => loadStatus()).catch((err) => {
1267
+ console.warn('[dsh-tts] model delete failed:', err)
1268
+ })
1244
1269
  }
1245
1270
 
1246
1271
  const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
@@ -1480,7 +1505,7 @@ window.__ModuleLoader__.load({
1480
1505
  style: {
1481
1506
  padding: '10px 14px',
1482
1507
  borderRadius: '8px',
1483
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1508
+ border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
1484
1509
  background: 'var(--dsw-alias-bg-layer-2)',
1485
1510
  display: 'flex',
1486
1511
  flexDirection: 'column',
@@ -1488,7 +1513,7 @@ window.__ModuleLoader__.load({
1488
1513
  fontSize: '13px',
1489
1514
  },
1490
1515
  },
1491
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1516
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
1492
1517
  '⚠️ ' + t('voiceNotInstalledTitle')
1493
1518
  ),
1494
1519
  React.createElement('span', { className: 'dts-sub' },
@@ -1522,7 +1547,7 @@ window.__ModuleLoader__.load({
1522
1547
  style: {
1523
1548
  padding: '10px 14px',
1524
1549
  borderRadius: '8px',
1525
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1550
+ border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
1526
1551
  background: 'var(--dsw-alias-bg-layer-2)',
1527
1552
  display: 'flex',
1528
1553
  flexDirection: 'column',
@@ -1530,7 +1555,7 @@ window.__ModuleLoader__.load({
1530
1555
  fontSize: '13px',
1531
1556
  },
1532
1557
  },
1533
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1558
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
1534
1559
  '⚠️ ' + t('messengerNotInstalledTitle')
1535
1560
  ),
1536
1561
  React.createElement('span', { className: 'dts-sub' },
@@ -1565,8 +1590,9 @@ window.__ModuleLoader__.load({
1565
1590
  setLoading(true)
1566
1591
  setMsg(null)
1567
1592
  try {
1568
- const res = await fetch('/api/dsh-tts/update', {
1593
+ const res = await clientFetch('/api/dsh-tts/update', {
1569
1594
  headers: { 'x-dsh-plugin-update': '1' },
1595
+ timeoutMs: 10000,
1570
1596
  })
1571
1597
  if (!res.ok) throw new Error('HTTP ' + res.status)
1572
1598
  const data = await res.json()
@@ -1582,12 +1608,13 @@ window.__ModuleLoader__.load({
1582
1608
  setUpdating(true)
1583
1609
  setMsg(null)
1584
1610
  try {
1585
- const res = await fetch('/api/dsh-tts/update', {
1611
+ const res = await clientFetch('/api/dsh-tts/update', {
1586
1612
  method: 'POST',
1587
1613
  headers: {
1588
1614
  'x-dsh-plugin-update': '1',
1589
1615
  'content-type': 'application/json',
1590
1616
  },
1617
+ timeoutMs: 60000,
1591
1618
  })
1592
1619
  const data = await res.json()
1593
1620
  if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
@@ -1631,7 +1658,7 @@ window.__ModuleLoader__.load({
1631
1658
  {
1632
1659
  type: 'button',
1633
1660
  className: 'dts-btn',
1634
- style: { borderColor: 'var(--dsw-alias-state-warning-primary)', color: 'var(--dsw-alias-state-warning-primary)' },
1661
+ style: { borderColor: 'var(--dsw-alias-state-warn-primary, #f59e0b)', color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' },
1635
1662
  onClick: onUpdateNow,
1636
1663
  disabled: updating || loading,
1637
1664
  },
@@ -1661,11 +1688,11 @@ window.__ModuleLoader__.load({
1661
1688
  const t = props.t || ((key) => key)
1662
1689
  const [data, setData] = React.useState(null)
1663
1690
  const loadStats = () => {
1664
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1691
+ clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((r) => r.json()).then(setData).catch((err) => console.warn('[dsh-tts] loadStats failed:', err))
1665
1692
  }
1666
1693
  React.useEffect(() => { loadStats() }, [])
1667
1694
  const reset = () => {
1668
- fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1695
+ clientFetch('/dsh-tts/stats', { method: 'DELETE', timeoutMs: 8000 }).then(loadStats).catch((err) => console.warn('[dsh-tts] resetStats failed:', err))
1669
1696
  }
1670
1697
  return React.createElement('div', { className: 'dts-block' },
1671
1698
  React.createElement('span', { className: 'dts-sub' }, data
@@ -1751,18 +1778,18 @@ window.__ModuleLoader__.load({
1751
1778
 
1752
1779
  React.useEffect(() => {
1753
1780
  let alive = true
1754
- fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1781
+ clientFetch('/dsh-tts/config', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
1755
1782
  if (!alive) return
1756
1783
  applyPayload(data)
1757
1784
  }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
1758
1785
 
1759
- fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1786
+ clientFetch('/dsh-tts/integrations', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
1760
1787
  if (!alive) return
1761
1788
  if (data && data.ok) setIntegrations(data.integrations || data)
1762
1789
  }).catch(() => {})
1763
1790
 
1764
1791
  const loadTelemetry = () => {
1765
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1792
+ clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
1766
1793
  if (!alive || !st) return
1767
1794
  setTelemetry((prev) => Object.assign({}, prev, {
1768
1795
  cacheHits: st.cacheHits || 0,
@@ -1773,7 +1800,7 @@ window.__ModuleLoader__.load({
1773
1800
  }).catch(() => {
1774
1801
  if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
1775
1802
  })
1776
- fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1803
+ clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
1777
1804
  if (!alive || !st) return
1778
1805
  setTelemetry((prev) => Object.assign({}, prev, {
1779
1806
  sseClients: st.sseClients || 0,
@@ -1804,10 +1831,11 @@ window.__ModuleLoader__.load({
1804
1831
  const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
1805
1832
  if (!value) return
1806
1833
  setErr('')
1807
- const res = await fetch('/dsh-tts/credential', {
1834
+ const res = await clientFetch('/dsh-tts/credential', {
1808
1835
  method: 'PUT',
1809
1836
  headers: { 'Content-Type': 'application/json' },
1810
1837
  body: JSON.stringify({ provider: provider, value: value }),
1838
+ timeoutMs: 10000,
1811
1839
  })
1812
1840
  const data = await res.json().catch(() => ({ /* parse error */ }))
1813
1841
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1818,10 +1846,11 @@ window.__ModuleLoader__.load({
1818
1846
  const clearKey = async (provider) => {
1819
1847
  setErr('')
1820
1848
  try {
1821
- const res = await fetch('/dsh-tts/credential', {
1849
+ const res = await clientFetch('/dsh-tts/credential', {
1822
1850
  method: 'DELETE',
1823
1851
  headers: { 'Content-Type': 'application/json' },
1824
1852
  body: JSON.stringify({ provider: provider }),
1853
+ timeoutMs: 10000,
1825
1854
  })
1826
1855
  const data = await res.json().catch(() => ({ /* parse error */ }))
1827
1856
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1843,10 +1872,11 @@ window.__ModuleLoader__.load({
1843
1872
  if (revision !== null && revision !== undefined) {
1844
1873
  bodyPayload.revision = revision
1845
1874
  }
1846
- const res = await fetch('/dsh-tts/config', {
1875
+ const res = await clientFetch('/dsh-tts/config', {
1847
1876
  method: 'PUT',
1848
1877
  headers: { 'Content-Type': 'application/json' },
1849
1878
  body: JSON.stringify(bodyPayload),
1879
+ timeoutMs: 10000,
1850
1880
  })
1851
1881
  const data = await res.json().catch(() => ({ /* parse error */ }))
1852
1882
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1862,7 +1892,7 @@ window.__ModuleLoader__.load({
1862
1892
 
1863
1893
  const clearCacheNow = async () => {
1864
1894
  setErr('')
1865
- const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
1895
+ const res = await clientFetch('/dsh-tts/cache', { method: 'DELETE', timeoutMs: 10000 })
1866
1896
  if (!res.ok) throw new Error('HTTP ' + res.status)
1867
1897
  setSaved(true); setTimeout(() => setSaved(false), 2000)
1868
1898
  }
package/lib/routes.js CHANGED
@@ -495,15 +495,11 @@ export function registerHttpRoutes(ctx, api) {
495
495
  },
496
496
  }), 'dsh-tts: /speak route')
497
497
 
498
- ctx.effect(() => registerPluginUpdater(ctx, {
499
- endpoint: '/dsh-tts/updater',
500
- packageName: '@goodandready/dsh-tts',
501
- manifestUrl: new URL('../package.json', import.meta.url),
502
- }), 'dsh-tts: updater route /dsh-tts/updater')
503
-
498
+ const updaterMutex = { installing: false }
504
499
  ctx.effect(() => registerPluginUpdater(ctx, {
505
500
  endpoint: '/api/dsh-tts/update',
506
501
  packageName: '@goodandready/dsh-tts',
507
502
  manifestUrl: new URL('../package.json', import.meta.url),
503
+ mutex: updaterMutex,
508
504
  }), 'dsh-tts: updater route /api/dsh-tts/update')
509
505
  }
package/lib/updater.js CHANGED
@@ -225,7 +225,6 @@ async function installExact(target, packageSpec, options) {
225
225
  '--profile',
226
226
  target.profileName,
227
227
  'add',
228
- '--config.minimumReleaseAge=0',
229
228
  packageSpec,
230
229
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
231
230
  ],
@@ -268,7 +267,7 @@ function json(response, statusCode, value) {
268
267
  }
269
268
 
270
269
  export function registerPluginUpdater(ctx, options) {
271
- let installing = false
270
+ const mutex = options?.mutex || { installing: false }
272
271
  if (typeof ctx?.webServer?.register !== 'function') {
273
272
  ctx?.logger?.warn?.('[dsh-tts] webServer service unavailable, updater skipped')
274
273
  return () => {}
@@ -298,11 +297,11 @@ export function registerPluginUpdater(ctx, options) {
298
297
  json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
299
298
  return
300
299
  }
301
- if (installing) {
300
+ if (mutex.installing) {
302
301
  json(response, 409, { error: 'This plugin is already updating.' })
303
302
  return
304
303
  }
305
- installing = true
304
+ mutex.installing = true
306
305
  try {
307
306
  const before = await status(options, target)
308
307
  if (before.latestVersion === undefined) {
@@ -322,7 +321,7 @@ export function registerPluginUpdater(ctx, options) {
322
321
  } catch (err) {
323
322
  json(response, 503, { error: `Plugin update failed: ${String(err?.message || err)}` })
324
323
  } finally {
325
- installing = false
324
+ mutex.installing = false
326
325
  }
327
326
  } catch (error) {
328
327
  ctx?.logger?.warn?.(`[dsh-tts] plugin updater failed: ${String(error)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",