dshmarket 1.0.2 → 1.1.0

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/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="assets/logo.svg" width="96" alt="dsh-market logo">
3
+ </p>
4
+
1
5
  # dsh-market
2
6
 
3
7
  English | [中文](README.zh.md)
package/README.zh.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="assets/logo.svg" width="96" alt="dsh-market logo">
3
+ </p>
4
+
1
5
  # dsh-market
2
6
 
3
7
  [English](README.md) | 中文
package/client/client.js CHANGED
@@ -58,8 +58,14 @@ const zh = {
58
58
  sortHot: '最热',
59
59
  sortNew: '最新',
60
60
  marketUpdate: '市场有新版本,升级',
61
+ updateAll: '全部更新',
62
+ tabThemes: '主题',
63
+ themeApply: '使用',
64
+ themeActive: '使用中',
65
+ themeEmpty: '目录里暂时还没有主题,敬请期待',
61
66
  progressHint: '首次安装需要下载与解析依赖,大插件可能要 1-3 分钟',
62
67
  toastReady: '已装好并已生效',
68
+ toastTheme: '已启用。到 设置 → 插件市场 → 主题 可随时切换',
63
69
  gotIt: '知道了',
64
70
  }
65
71
 
@@ -107,8 +113,14 @@ const en = {
107
113
  sortHot: 'Top',
108
114
  sortNew: 'New',
109
115
  marketUpdate: 'Market update available — upgrade',
116
+ updateAll: 'Update all',
117
+ tabThemes: 'Themes',
118
+ themeApply: 'Use',
119
+ themeActive: 'Active',
120
+ themeEmpty: 'No more theme plugins in the catalog yet — stay tuned',
110
121
  progressHint: 'First installs download and resolve dependencies — large plugins can take 1-3 minutes',
111
122
  toastReady: 'installed and live',
123
+ toastTheme: 'is now active. Switch any time in Settings → Plugin Market → Themes',
112
124
  gotIt: 'Got it',
113
125
  }
114
126
 
@@ -133,8 +145,12 @@ const CSS = `
133
145
  .dshm-top{position:absolute;right:18px;bottom:18px;z-index:20;width:38px;height:38px;border-radius:99px;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-secondary,#6b7280);font-size:16px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.12)}
134
146
  .dshm-top:hover{color:var(--dsw-alias-brand-primary,#4f6ef7)}
135
147
  .dshm-chip{font:inherit;font-size:12px;border:1px solid var(--dsw-alias-border-l1,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);border-radius:99px;padding:3px 11px;cursor:pointer;color:var(--dsw-alias-label-secondary,#6b7280)}
136
- .dshm-chip.on{background:var(--dsw-alias-brand-primary,#4f6ef7);border-color:var(--dsw-alias-brand-primary,#4f6ef7);color:#fff}
148
+ .dshm-chip.on{background:var(--dsw-alias-button-primary-fill,#4f6ef7);border-color:var(--dsw-alias-button-primary-fill,#4f6ef7);color:var(--dsw-alias-label-primary-foreground,#fff)}
137
149
  .dshm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px}
150
+ .dshm-sect{font-size:12px;color:var(--dsw-alias-label-secondary,#6b7280);margin:14px 2px 8px;font-weight:600}
151
+ .dshm-sect:first-child{margin-top:2px}
152
+ .dshm-swatches{display:flex;gap:0;height:34px;border-radius:8px;overflow:hidden;border:1px solid var(--dsw-alias-border-l1,#e5e7eb)}
153
+ .dshm-swatches i{flex:1}
138
154
  .dshm-card{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l1,#e5e7eb);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}
139
155
  .dshm-row1{display:flex;align-items:center;gap:9px;min-width:0}
140
156
  .dshm-av{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-weight:700;color:#fff;font-size:14px;flex-shrink:0}
@@ -147,7 +163,7 @@ const CSS = `
147
163
  .dshm-src{font-size:11px;color:var(--dsw-alias-label-secondary,#9ca3af);text-decoration:none}
148
164
  .dshm-src:hover{color:var(--dsw-alias-brand-primary,#4f6ef7)}
149
165
  .dshm-btn{border:none;border-radius:7px;padding:5px 14px;font:inherit;font-size:12px;cursor:pointer;font-weight:600}
150
- .dshm-btn.install{background:var(--dsw-alias-brand-primary,#4f6ef7);color:#fff}
166
+ .dshm-btn.install{background:var(--dsw-alias-button-primary-fill,#4f6ef7);color:var(--dsw-alias-label-primary-foreground,#fff)}
151
167
  .dshm-btn.busy{opacity:.65;cursor:default}
152
168
  .dshm-btn.done{background:transparent;color:var(--dsw-alias-state-success-primary,#16a34a);cursor:default}
153
169
  .dshm-btn.ghost{background:var(--dsw-alias-bg-layer-2,#f3f4f6);color:var(--dsw-alias-label-secondary,#6b7280)}
@@ -162,6 +178,7 @@ const CSS = `
162
178
  .dshm-progress .dshm-spin{width:14px;height:14px;border-width:2px;flex-shrink:0}
163
179
  .dshm-progress code{font-family:ui-monospace,Menlo,monospace;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
164
180
  .dshm-toast{position:fixed;right:22px;bottom:22px;z-index:2000;background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l1,#e5e7eb);border-radius:12px;padding:13px 16px;box-shadow:0 12px 40px rgba(0,0,0,.18);display:flex;align-items:center;gap:10px;font-size:13px;color:var(--dsw-alias-label-primary,#1f2328);pointer-events:auto;max-width:340px}
181
+ .dshm-toast .dshm-btn{white-space:nowrap;flex-shrink:0}
165
182
  .dshm-empty{color:var(--dsw-alias-label-secondary,#9ca3af);font-size:13px;padding:32px;text-align:center}
166
183
  .dshm-err{color:var(--dsw-alias-state-error-primary,#dc2626);font-size:12px;margin:8px 0;white-space:pre-wrap;word-break:break-all}
167
184
  .dshm-mask{position:fixed;inset:0;background:rgba(15,18,25,.4);display:flex;align-items:center;justify-content:center;z-index:1000}
@@ -216,6 +233,25 @@ function isInstalled(plugin, installed) {
216
233
  return Object.values(installed).some(spec => String(spec).toLowerCase().includes(needle))
217
234
  }
218
235
 
236
+ /**
237
+ * The brand mark (assets/logo.svg — shared block-grid mark with
238
+ * awesome-dsh-plugin), inlined so the header needs no extra request.
239
+ */
240
+ const LOGO_URI = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="28" fill="#f6f2ea"/><g transform="translate(15.7 16.7) scale(0.3) translate(-112 -78)"><g fill="#2b2620"><rect x="112" y="112" width="88" height="88" rx="14"/><rect x="212" y="112" width="88" height="88" rx="14"/><rect x="112" y="212" width="88" height="88" rx="14"/><rect x="212" y="212" width="88" height="88" rx="14"/><rect x="112" y="312" width="88" height="88" rx="14"/><rect x="212" y="312" width="88" height="88" rx="14"/><rect x="312" y="212" width="88" height="88" rx="14"/><rect x="312" y="312" width="88" height="88" rx="14"/></g><rect x="346" y="78" width="88" height="88" rx="14" fill="#c0392b" transform="rotate(9 390 122)"/></g></svg>')
241
+
242
+ /** Four representative colors for a theme card's preview strip. */
243
+ function themeSwatch(def) {
244
+ const tk = def.tokens || {}
245
+ const pick = (names) => { for (const n of names) { if (tk[n]) return tk[n] } return null }
246
+ const dark = def.colorScheme === 'dark'
247
+ return [
248
+ pick(['--dsw-alias-bg-base', '--dsw-alias-bg-layer-1']) || (dark ? '#0f1115' : '#ffffff'),
249
+ pick(['--dsw-alias-bg-layer-2', '--dsw-alias-bg-overlay']) || (dark ? '#1a1d23' : '#f3f4f6'),
250
+ pick(['--dsw-alias-brand-primary']) || '#4f6ef7',
251
+ pick(['--dsw-alias-label-primary']) || (dark ? '#e5e7eb' : '#1f2328'),
252
+ ]
253
+ }
254
+
219
255
  function MarketSection(props) {
220
256
  const t = props.t
221
257
  const localeSnap = React.useSyncExternalStore(
@@ -223,9 +259,15 @@ function MarketSection(props) {
223
259
  () => props.locale.getSnapshot(),
224
260
  )
225
261
  const lang = String(localeSnap.active).toLowerCase().startsWith('zh') ? 'zh' : 'en'
262
+ // null when the composition has no theme service — the Themes tab hides.
263
+ const themeSnap = React.useSyncExternalStore(
264
+ props.themeStore.subscribe,
265
+ props.themeStore.getSnapshot,
266
+ )
226
267
  const [data, setData] = useState(null)
227
268
  const [loadError, setLoadError] = useState(false)
228
269
  const [installed, setInstalled] = useState({})
270
+ const [skins, setSkins] = useState([])
229
271
  const [tab, setTab] = useState(() => {
230
272
  const saved = sessionStorage.getItem('dshm-tab')
231
273
  if (saved !== null) sessionStorage.removeItem('dshm-tab')
@@ -239,6 +281,7 @@ function MarketSection(props) {
239
281
  const [installError, setInstallError] = useState(null)
240
282
  const [updates, setUpdates] = useState({})
241
283
  const [updatingName, setUpdatingName] = useState(null)
284
+ const [updatingAll, setUpdatingAll] = useState(false)
242
285
  const [updatedNames, setUpdatedNames] = useState([])
243
286
  const [hotUrls, setHotUrls] = useState([])
244
287
  const [hotNames, setHotNames] = useState([])
@@ -257,7 +300,10 @@ function MarketSection(props) {
257
300
  const refreshInstalled = useCallback((force) => {
258
301
  fetch('/dsh-market/installed', { cache: 'no-store' })
259
302
  .then(res => res.json())
260
- .then(body => setInstalled(body.installed || {}))
303
+ .then(body => {
304
+ setInstalled(body.installed || {})
305
+ setSkins(body.live || [])
306
+ })
261
307
  .catch(() => {})
262
308
  fetch('/dsh-market/updates' + (force === true ? '?force=1' : ''), { cache: 'no-store' })
263
309
  .then(res => res.json())
@@ -393,6 +439,14 @@ function MarketSection(props) {
393
439
  .then(res => res.json().then(body => ({ status: res.status, body })))
394
440
  .then(({ status, body }) => {
395
441
  sessionStorage.removeItem('dshm-pending')
442
+ if (status === 200 && body.ok && body.hot && plugin.category === 'theme') {
443
+ // Themes auto-activate on install; reload straight into the Themes
444
+ // tab so the new look is on screen immediately.
445
+ sessionStorage.setItem('dshm-toast', JSON.stringify([plugin.name]))
446
+ sessionStorage.setItem('dshm-tab', 'themes')
447
+ location.reload()
448
+ return
449
+ }
396
450
  if (status === 200 && body.ok) {
397
451
  sessionStorage.setItem('dshm-tab', 'installed')
398
452
  if (body.hot) {
@@ -418,7 +472,7 @@ function MarketSection(props) {
418
472
  const doUpdate = useCallback((name) => {
419
473
  setInstallError(null)
420
474
  setUpdatingName(name)
421
- fetch('/dsh-market/update', {
475
+ return fetch('/dsh-market/update', {
422
476
  method: 'POST',
423
477
  headers: { 'content-type': 'application/json' },
424
478
  body: JSON.stringify({ name }),
@@ -438,11 +492,32 @@ function MarketSection(props) {
438
492
  .finally(() => setUpdatingName(null))
439
493
  }, [refreshInstalled, t])
440
494
 
495
+ const doUseSkin = useCallback((name) => {
496
+ setInstallError(null)
497
+ fetch('/dsh-market/use-skin', {
498
+ method: 'POST',
499
+ headers: { 'content-type': 'application/json' },
500
+ body: JSON.stringify({ name }),
501
+ })
502
+ .then(res => res.json().then(body => ({ status: res.status, body })))
503
+ .then(({ status, body }) => {
504
+ if (status === 200 && body.ok) {
505
+ sessionStorage.setItem('dshm-toast', JSON.stringify([name]))
506
+ sessionStorage.setItem('dshm-toast-mode', 'theme')
507
+ sessionStorage.setItem('dshm-tab', 'themes')
508
+ location.reload()
509
+ } else {
510
+ setInstallError(String(body.error || 'failed'))
511
+ }
512
+ })
513
+ .catch(error => setInstallError(String(error)))
514
+ }, [])
515
+
441
516
  const doUninstall = useCallback((name) => {
442
517
  setRemoveArmed(null)
443
518
  setInstallError(null)
444
519
  setRemovingName(name)
445
- fetch('/dsh-market/uninstall', {
520
+ return fetch('/dsh-market/uninstall', {
446
521
  method: 'POST',
447
522
  headers: { 'content-type': 'application/json' },
448
523
  body: JSON.stringify({ name }),
@@ -461,16 +536,147 @@ function MarketSection(props) {
461
536
  .finally(() => setRemovingName(null))
462
537
  }, [refreshInstalled])
463
538
 
539
+ // The market itself stays out of the batch: its update reloads this page
540
+ // mid-run, which would strand the remaining items.
541
+ const selfName = installed['dshmarket'] !== undefined ? 'dshmarket' : 'dsh-market'
542
+ const updatableNames = Object.keys(installed).filter(
543
+ name => name !== selfName && !updatedNames.includes(name) && updates[name] && updates[name].updateAvailable,
544
+ )
545
+
546
+ const doUpdateAll = useCallback(() => {
547
+ const names = updatableNames.slice()
548
+ setUpdatingAll(true)
549
+ const next = () => {
550
+ const name = names.shift()
551
+ if (name === undefined) {
552
+ setUpdatingAll(false)
553
+ return
554
+ }
555
+ doUpdate(name).then(next, next)
556
+ }
557
+ next()
558
+ }, [updatableNames, doUpdate])
559
+
464
560
  const pendingRestart = doneUrls.length + updatedNames.length + removedCount
465
561
  const hasUpdates = Object.keys(installed).some(
466
562
  name => !updatedNames.includes(name) && updates[name] && updates[name].updateAvailable,
467
563
  )
468
564
 
565
+ const themePlugins = data === null ? [] : data.plugins
566
+ .filter(p => p.category === 'theme')
567
+ .sort((a, b) => (b.stars || 0) - (a.stars || 0))
568
+
569
+ const pluginCard = (p) => {
570
+ const desc = (p.description && (p.description[lang] || p.description.en)) || ''
571
+ const done = doneUrls.includes(p.url) || hotUrls.includes(p.url)
572
+ const already = isInstalled(p, installed)
573
+ const busy = busyUrl === p.url
574
+ return h('div', { key: p.url, className: 'dshm-card' },
575
+ h('div', { className: 'dshm-row1' },
576
+ h('div', { className: 'dshm-av', style: { background: avatarColor(p.name) } },
577
+ p.name.replace(/^dsh[-_]/i, '').charAt(0).toUpperCase() || 'P'),
578
+ h('div', { style: { minWidth: 0 } },
579
+ h('div', { className: 'dshm-nm' }, p.name),
580
+ h('div', { className: 'dshm-owner' }, p.owner,
581
+ typeof p.stars === 'number' && h('span', { className: 'dshm-star' }, ' · ★ ' + p.stars))),
582
+ h('span', { className: 'dshm-grow' }),
583
+ h('a', { className: 'dshm-src', href: p.url, target: '_blank', rel: 'noreferrer', style: { alignSelf: 'flex-start', flexShrink: 0 } }, t('viewSource'))),
584
+ h('div', { className: 'dshm-desc' }, desc),
585
+ h('div', { className: 'dshm-foot' },
586
+ h('span', { className: 'dshm-cat' },
587
+ (data.categories[p.category] && (data.categories[p.category][lang] || data.categories[p.category].en)) || p.category),
588
+ h('span', { className: 'dshm-grow' }),
589
+ done
590
+ ? h('button', { className: 'dshm-btn done' }, t('installedBadge'))
591
+ : already
592
+ ? h('button', { className: 'dshm-btn done' }, t('alreadyInstalled'))
593
+ : busy
594
+ ? h('button', { className: 'dshm-btn install busy' }, t('installing'))
595
+ : h('button', {
596
+ className: 'dshm-btn install',
597
+ disabled: busyUrl !== null || !envReady,
598
+ onClick: () => setConfirming(p),
599
+ }, t('install'))),
600
+ busy && h('div', { className: 'dshm-progress', style: { margin: '6px 0 0' } },
601
+ h('span', { className: 'dshm-spin' }),
602
+ h('code', { className: 'dshm-grow' }, progressLine || t('progressHint'))))
603
+ }
604
+
605
+ const installedNameOf = (p) => {
606
+ if (installed[p.name] !== undefined) return p.name
607
+ const repo = repoOf(p.url)
608
+ if (repo === null) return null
609
+ const needle = ('github:' + repo).toLowerCase()
610
+ for (const [name, spec] of Object.entries(installed)) {
611
+ if (String(spec).toLowerCase().includes(needle)) return name
612
+ }
613
+ return null
614
+ }
615
+
616
+ // Plugins loaded at boot (bundle-layer skins) aren't in the shim list but
617
+ // are just as live; the boot manifest is the page's own record of them.
618
+ const bootEntries = (typeof window !== 'undefined' && window.__DSH_BOOT__ && Array.isArray(window.__DSH_BOOT__.entries))
619
+ ? window.__DSH_BOOT__.entries
620
+ : []
621
+
622
+ // Unified card for the Themes tab: install → use/in-use → uninstall.
623
+ const themePluginCard = (p) => {
624
+ const instName = installedNameOf(p)
625
+ if (instName === null) return pluginCard(p)
626
+ const mounted = skins.includes(instName) || bootEntries.some(e => e.id === instName)
627
+ const desc = (p.description && (p.description[lang] || p.description.en)) || ''
628
+ return h('div', { key: p.url, className: 'dshm-card' },
629
+ h('div', { className: 'dshm-row1' },
630
+ h('div', { className: 'dshm-av', style: { background: avatarColor(p.name) } },
631
+ p.name.replace(/^dsh[-_]/i, '').charAt(0).toUpperCase() || 'P'),
632
+ h('div', { style: { minWidth: 0 } },
633
+ h('div', { className: 'dshm-nm' }, p.name),
634
+ h('div', { className: 'dshm-owner' }, p.owner,
635
+ typeof p.stars === 'number' && h('span', { className: 'dshm-star' }, ' · ★ ' + p.stars))),
636
+ h('span', { className: 'dshm-grow' }),
637
+ h('a', { className: 'dshm-src', href: p.url, target: '_blank', rel: 'noreferrer', style: { alignSelf: 'flex-start', flexShrink: 0 } }, t('viewSource'))),
638
+ h('div', { className: 'dshm-desc' }, desc),
639
+ h('div', { className: 'dshm-foot' },
640
+ h('span', { className: 'dshm-grow' }),
641
+ removingName === instName
642
+ ? h('button', { className: 'dshm-btn danger busy' }, t('uninstalling'))
643
+ : removeArmed === instName
644
+ ? h('button', {
645
+ className: 'dshm-btn danger armed',
646
+ onClick: () => doUninstall(instName).then(() => {
647
+ if (mounted) {
648
+ sessionStorage.setItem('dshm-tab', 'themes')
649
+ location.reload()
650
+ }
651
+ }),
652
+ }, t('confirmRemove'))
653
+ : h('button', { className: 'dshm-btn danger', onClick: () => setRemoveArmed(instName) }, t('uninstall')),
654
+ mounted
655
+ ? h('button', { className: 'dshm-btn done' }, t('themeActive'))
656
+ : h('button', { className: 'dshm-btn install', onClick: () => doUseSkin(instName) }, t('themeApply'))))
657
+ }
658
+
659
+ const themeCard = (id, label, swatch) => {
660
+ const active = themeSnap !== null && themeSnap.preference === id
661
+ return h('div', { key: 'th-' + id, className: 'dshm-card' },
662
+ h('div', { className: 'dshm-swatches' }, swatch.map((c, i) => h('i', { key: i, style: { background: c } }))),
663
+ h('div', { className: 'dshm-foot' },
664
+ h('span', { className: 'dshm-nm' }, label),
665
+ h('span', { className: 'dshm-grow' }),
666
+ active
667
+ ? h('button', { className: 'dshm-btn done' }, t('themeActive'))
668
+ : h('button', {
669
+ className: 'dshm-btn install',
670
+ onClick: () => { try { props.theme.setTheme(id) } catch (error) { setInstallError(String(error)) } },
671
+ }, t('themeApply'))))
672
+ }
673
+
469
674
  const categories = data === null ? [] : Object.keys(data.categories)
470
675
 
471
676
  return h('div', { className: 'dshm-root' },
472
677
  h('div', { className: 'dshm-head' },
473
678
  h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
679
+ h('img', { src: LOGO_URI, width: 22, height: 22, alt: '', style: { borderRadius: '5px', flexShrink: 0 } }),
474
680
  h('h2', { className: 'dshm-title' }, t('nav')),
475
681
  (() => {
476
682
  const self = installed['dshmarket'] !== undefined ? 'dshmarket' : 'dsh-market'
@@ -481,7 +687,13 @@ function MarketSection(props) {
481
687
  disabled: updatingName !== null || busyUrl !== null,
482
688
  onClick: () => { setTab('installed'); doUpdate(self) },
483
689
  }, updatingName === self ? t('updating') : t('marketUpdate'))
484
- })()),
690
+ })(),
691
+ updatableNames.length >= 2 && h('button', {
692
+ className: 'dshm-btn upd',
693
+ style: { fontSize: '11px', padding: '3px 10px' },
694
+ disabled: updatingAll || updatingName !== null || busyUrl !== null || removingName !== null,
695
+ onClick: () => { setTab('installed'); doUpdateAll() },
696
+ }, updatingAll ? t('updating') : t('updateAll') + ' (' + updatableNames.length + ')')),
485
697
  h('div', { className: 'dshm-sub' },
486
698
  t('subtitle') + (data ? ' · ' + data.count : '') + ' · ',
487
699
  h('a', { className: 'dshm-src', href: '/dsh-market/logs', download: 'dsh-market-log.txt' }, t('exportLog'))),
@@ -489,6 +701,7 @@ function MarketSection(props) {
489
701
  h('input', { placeholder: t('searchPh'), value: q, onChange: e => setQ(e.target.value) })),
490
702
  h('div', { className: 'dshm-tabs' },
491
703
  h('button', { className: 'dshm-tab' + (tab === 'discover' ? ' on' : ''), onClick: () => setTab('discover') }, t('tabDiscover')),
704
+ themeSnap !== null && h('button', { className: 'dshm-tab' + (tab === 'themes' ? ' on' : ''), onClick: () => setTab('themes') }, t('tabThemes')),
492
705
  h('button', { className: 'dshm-tab' + (tab === 'installed' ? ' on' : ''), onClick: () => { setTab('installed'); refreshInstalled(true) } },
493
706
  t('tabInstalled') + (Object.keys(installed).length > 0 ? ' (' + Object.keys(installed).length + ')' : ''),
494
707
  hasUpdates && h('span', { className: 'dshm-dot' }))),
@@ -542,41 +755,22 @@ function MarketSection(props) {
542
755
  }, t(key === 'hot' ? 'sortHot' : 'sortNew'))))),
543
756
  plugins.length === 0
544
757
  ? h('div', { className: 'dshm-empty' }, t('empty'))
545
- : h('div', { className: 'dshm-grid' }, plugins.map(p => {
546
- const desc = (p.description && (p.description[lang] || p.description.en)) || ''
547
- const done = doneUrls.includes(p.url) || hotUrls.includes(p.url)
548
- const already = isInstalled(p, installed)
549
- const busy = busyUrl === p.url
550
- return h('div', { key: p.url, className: 'dshm-card' },
551
- h('div', { className: 'dshm-row1' },
552
- h('div', { className: 'dshm-av', style: { background: avatarColor(p.name) } },
553
- p.name.replace(/^dsh[-_]/i, '').charAt(0).toUpperCase() || 'P'),
554
- h('div', { style: { minWidth: 0 } },
555
- h('div', { className: 'dshm-nm' }, p.name),
556
- h('div', { className: 'dshm-owner' }, p.owner,
557
- typeof p.stars === 'number' && h('span', { className: 'dshm-star' }, ' · ★ ' + p.stars))),
558
- h('span', { className: 'dshm-grow' }),
559
- h('a', { className: 'dshm-src', href: p.url, target: '_blank', rel: 'noreferrer', style: { alignSelf: 'flex-start', flexShrink: 0 } }, t('viewSource'))),
560
- h('div', { className: 'dshm-desc' }, desc),
561
- h('div', { className: 'dshm-foot' },
562
- h('span', { className: 'dshm-cat' },
563
- (data.categories[p.category] && (data.categories[p.category][lang] || data.categories[p.category].en)) || p.category),
564
- h('span', { className: 'dshm-grow' }),
565
- done
566
- ? h('button', { className: 'dshm-btn done' }, t('installedBadge'))
567
- : already
568
- ? h('button', { className: 'dshm-btn done' }, t('alreadyInstalled'))
569
- : busy
570
- ? h('button', { className: 'dshm-btn install busy' }, t('installing'))
571
- : h('button', {
572
- className: 'dshm-btn install',
573
- disabled: busyUrl !== null || !envReady,
574
- onClick: () => setConfirming(p),
575
- }, t('install'))),
576
- busy && h('div', { className: 'dshm-progress', style: { margin: '6px 0 0' } },
577
- h('span', { className: 'dshm-spin' }),
578
- h('code', { className: 'dshm-grow' }, progressLine || t('progressHint'))))
579
- })))
758
+ : h('div', { className: 'dshm-grid' }, plugins.map(pluginCard)))
759
+ : tab === 'themes' && themeSnap !== null
760
+ ? h(React.Fragment, null,
761
+ // Light/dark/system live in the official Appearance setting; this
762
+ // tab only shows what that setting can't: registered third-party
763
+ // palettes (none in the wild yet) and installable theme plugins.
764
+ (() => {
765
+ const extra = themeSnap.themes.filter(def => def.id !== 'light' && def.id !== 'dark')
766
+ return extra.length > 0 && h('div', { className: 'dshm-grid', style: { marginBottom: 10 } },
767
+ extra.map(def => themeCard(def.id, def.id, themeSwatch(def))))
768
+ })(),
769
+ data === null
770
+ ? h('div', { className: 'dshm-loading' }, h('span', { className: 'dshm-spin' }), t('loading'))
771
+ : themePlugins.length === 0
772
+ ? h('div', { className: 'dshm-empty' }, t('themeEmpty'))
773
+ : h('div', { className: 'dshm-grid' }, themePlugins.map(themePluginCard)))
580
774
  : Object.keys(installed).length === 0
581
775
  ? h('div', { className: 'dshm-empty' }, t('installedEmpty'))
582
776
  : Object.entries(installed).map(([name, spec]) => {
@@ -648,7 +842,10 @@ function MarketSection(props) {
648
842
  }
649
843
 
650
844
  exports.name = 'dsh-market'
651
- exports.inject = ['slots', 'locale']
845
+ // 'theme' is safe to require: ui-layout (mandatory in every web composition)
846
+ // already hard-depends on it. This cordis's object-form inject means
847
+ // intercept config, NOT {required,optional} — do not use it here.
848
+ exports.inject = ['slots', 'locale', 'theme']
652
849
  exports.apply = function apply(ctx) {
653
850
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-market: dictionaries')
654
851
  const t = ctx.locale.bind(NS)
@@ -660,12 +857,25 @@ exports.apply = function apply(ctx) {
660
857
  label: () => t('nav'),
661
858
  locale: NS,
662
859
  inject: () => ({ t }),
663
- }, () => h(MarketSection, { t, locale: ctx.locale })))
860
+ }, () => h(MarketSection, {
861
+ t,
862
+ locale: ctx.locale,
863
+ theme: ctx.theme,
864
+ themeStore: {
865
+ subscribe: (cb) => ctx.on('theme/change', cb),
866
+ getSnapshot: () => ctx.theme.getTheme(),
867
+ },
868
+ })))
664
869
 
665
870
  // Post-reload confirmation: a floating "installed and live" card in the
666
871
  // shell overlay layer, shown once after the refresh that follows a hot
667
872
  // install, so the user lands back in their flow with visible proof.
668
873
  function InstallToast() {
874
+ const [mode] = useState(() => {
875
+ const value = sessionStorage.getItem('dshm-toast-mode')
876
+ sessionStorage.removeItem('dshm-toast-mode')
877
+ return value
878
+ })
669
879
  const [names, setNames] = useState(() => {
670
880
  const value = readSession('dshm-toast')
671
881
  sessionStorage.removeItem('dshm-toast')
@@ -680,7 +890,7 @@ exports.apply = function apply(ctx) {
680
890
  if (names.length === 0) return null
681
891
  return h('div', { className: 'dshm-toast' },
682
892
  h('span', null, '✨'),
683
- h('span', null, names.join(', ') + ' ' + t('toastReady')),
893
+ h('span', null, names.join(', ') + ' ' + t(mode === 'theme' ? 'toastTheme' : 'toastReady')),
684
894
  h('button', { className: 'dshm-btn install', onClick: () => setNames([]) }, t('gotIt')))
685
895
  }
686
896
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
package/lib/hot.js CHANGED
@@ -21,7 +21,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
21
21
  }
22
22
  return path;
23
23
  };
24
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import { pathToFileURL } from 'node:url';
27
27
  import { logEvent } from './log.js';
@@ -31,6 +31,13 @@ let hotTreeClass;
31
31
  * The Include subclass, built once per process; null when the loader's include
32
32
  * plugin is not importable (older harness) — callers fall back to restart.
33
33
  */
34
+ /**
35
+ * Packages whose host import is replaced by a no-op shim. Client-only plugins
36
+ * (`dsh.client` without `dsh.bundle`) have no importable host half, but
37
+ * client-modules only serves bundles for packages with a live loader entry —
38
+ * the shim fiber exists purely to satisfy that registration.
39
+ */
40
+ const shimNames = new Set();
34
41
  async function loadHotTreeClass() {
35
42
  if (hotTreeClass !== undefined)
36
43
  return hotTreeClass;
@@ -46,6 +53,11 @@ async function loadHotTreeClass() {
46
53
  class MarketHotTree extends Include {
47
54
  /** Runtime-only mount list; the bundle layer owns persistence. */
48
55
  write() { }
56
+ import(name, getOuterStack) {
57
+ if (shimNames.has(name))
58
+ return { name, apply: () => { } };
59
+ return super.import(name, getOuterStack);
60
+ }
49
61
  }
50
62
  hotTreeClass = MarketHotTree;
51
63
  }
@@ -54,6 +66,16 @@ async function loadHotTreeClass() {
54
66
  }
55
67
  return hotTreeClass;
56
68
  }
69
+ /** The `dsh` declaration block of an installed package, or null when unreadable. */
70
+ function readPkgDsh(profileDir, packageName) {
71
+ try {
72
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'node_modules', packageName, 'package.json'), 'utf8'));
73
+ return manifest.dsh ?? {};
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
57
79
  /**
58
80
  * Insert rows of a plugin's bundle patch, or null when the patch contains
59
81
  * anything beyond plain `id`/`name` insert rows (config blocks, disables,
@@ -87,9 +109,44 @@ export function parseSimplePatch(patchText) {
87
109
  return null;
88
110
  return rows;
89
111
  }
90
- /** Wipe leftover hot-mount inputs; call once when the market host starts. */
112
+ /**
113
+ * Wipe leftover hot-mount inputs; call once when the market host starts.
114
+ * `state.json` (skin enable/disable choices) deliberately survives.
115
+ */
91
116
  export function cleanHotDir(profileDir) {
92
- rmSync(join(profileDir, HOT_DIR), { force: true, recursive: true });
117
+ const dir = join(profileDir, HOT_DIR);
118
+ let entries;
119
+ try {
120
+ entries = readdirSync(dir);
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ for (const name of entries) {
126
+ if (/^hot-\d+\.yml$/.test(name))
127
+ rmSync(join(dir, name), { force: true });
128
+ }
129
+ }
130
+ function stateFile(profileDir) {
131
+ return join(profileDir, HOT_DIR, 'state.json');
132
+ }
133
+ /** Themes the user switched away from; skipped by the boot re-mount. */
134
+ export function readDisabledThemes(profileDir) {
135
+ try {
136
+ const state = JSON.parse(readFileSync(stateFile(profileDir), 'utf8'));
137
+ return new Set(Array.isArray(state.disabledSkins) ? state.disabledSkins : []);
138
+ }
139
+ catch {
140
+ return new Set();
141
+ }
142
+ }
143
+ export function writeDisabledThemes(profileDir, disabled) {
144
+ mkdirSync(join(profileDir, HOT_DIR), { recursive: true, mode: 0o700 });
145
+ writeFileSync(stateFile(profileDir), JSON.stringify({ disabledSkins: [...disabled] }));
146
+ }
147
+ /** Package names currently live through a market hot mount (patch or shim). */
148
+ export function listHotMounts() {
149
+ return [...hotHandles.keys()];
93
150
  }
94
151
  let hotSequence = 0;
95
152
  const hotHandles = new Map();
@@ -104,6 +161,7 @@ export async function hotUnmount(packageName) {
104
161
  if (handle === undefined)
105
162
  return false;
106
163
  hotHandles.delete(packageName);
164
+ shimNames.delete(packageName);
107
165
  try {
108
166
  await handle.dispose();
109
167
  logEvent('info', 'hot-unmount', `${packageName}: removed live`);
@@ -128,23 +186,43 @@ export async function hotMount(ctx, profileDir, packageName) {
128
186
  const HotTree = await loadHotTreeClass();
129
187
  if (HotTree === null)
130
188
  return false;
131
- const patchText = readFileSync(join(profileDir, 'node_modules', packageName, 'cordis.patch.yml'), 'utf8');
132
- const rows = parseSimplePatch(patchText);
133
- if (rows === null)
134
- return false;
189
+ let patchText;
190
+ try {
191
+ patchText = readFileSync(join(profileDir, 'node_modules', packageName, 'cordis.patch.yml'), 'utf8');
192
+ }
193
+ catch {
194
+ patchText = null;
195
+ }
196
+ let rows;
197
+ if (patchText !== null) {
198
+ rows = parseSimplePatch(patchText);
199
+ if (rows === null)
200
+ return false;
201
+ }
202
+ else {
203
+ // No host patch. Client-only packages (dsh.client, no dsh.bundle) never
204
+ // get a loader entry — not even after a restart — so client-modules
205
+ // would never serve their bundle. A shim entry under the package's name
206
+ // is the whole activation.
207
+ const dsh = readPkgDsh(profileDir, packageName);
208
+ if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined)
209
+ return false;
210
+ shimNames.add(packageName);
211
+ rows = [{ id: `client-${packageName.replace(/[^A-Za-z0-9_.-]/g, '-')}`, name: packageName }];
212
+ }
135
213
  const dir = join(profileDir, HOT_DIR);
136
214
  mkdirSync(dir, { recursive: true, mode: 0o700 });
137
215
  hotSequence += 1;
138
216
  const file = join(dir, `hot-${String(hotSequence)}.yml`);
139
217
  const yml = rows
140
- .map(row => `- id: mkt-${row.id}\n name: '${row.name}'\n`)
218
+ .map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
141
219
  .join('');
142
220
  writeFileSync(file, yml);
143
221
  const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href });
144
222
  await handle.await();
145
223
  hotHandles.set(packageName, handle);
146
224
  ctx.logger?.info?.(`[dsh-market] hot-mounted ${packageName}`);
147
- logEvent('info', 'hot-mount', `${packageName}: live`);
225
+ logEvent('info', 'hot-mount', `${packageName}: live${shimNames.has(packageName) ? ' (client-only shim)' : ''}`);
148
226
  return true;
149
227
  }
150
228
  catch (error) {
@@ -154,3 +232,33 @@ export async function hotMount(ctx, profileDir, packageName) {
154
232
  return false;
155
233
  }
156
234
  }
235
+ /**
236
+ * Mount every installed client-only package (`dsh.client` without
237
+ * `dsh.bundle`) at market startup. The bundle reconcile skips these packages
238
+ * entirely, so without the market's shim their client bundles are unreachable
239
+ * in every boot — this is what makes them behave like normal plugins.
240
+ * @returns names that were mounted.
241
+ */
242
+ export async function mountClientOnlyDeps(ctx, profileDir) {
243
+ let deps;
244
+ try {
245
+ const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
246
+ const bundles = new Set(manifest.dsh?.profile?.bundles ?? []);
247
+ deps = Object.keys(manifest.dependencies ?? {}).filter(name => !bundles.has(name));
248
+ }
249
+ catch {
250
+ return [];
251
+ }
252
+ const disabled = readDisabledThemes(profileDir);
253
+ const mounted = [];
254
+ for (const name of deps) {
255
+ if (hotHandles.has(name) || disabled.has(name))
256
+ continue;
257
+ const dsh = readPkgDsh(profileDir, name);
258
+ if (dsh === null || dsh.client === undefined || dsh.bundle !== undefined)
259
+ continue;
260
+ if (await hotMount(ctx, profileDir, name))
261
+ mounted.push(name);
262
+ }
263
+ return mounted;
264
+ }