@stolyarovmn/dsh-ui-registry-aggregator 0.5.0 → 0.5.2

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.
Files changed (4) hide show
  1. package/README.md +97 -52
  2. package/client.js +371 -21
  3. package/package.json +1 -1
  4. package/source-core.js +86 -10
package/README.md CHANGED
@@ -1,9 +1,20 @@
1
1
  # Registry Aggregator for DeepSeek Harness 0.2.x
2
2
 
3
- This branch is the clean DSH `0.2.0` implementation line. It does **not** carry compatibility code or UI workarounds from DSH `0.1.x`.
3
+ Registry Aggregator adds federated plugin discovery and update discovery around the native DeepSeek Harness Plugin Manager.
4
4
 
5
- Current stable version: `0.5.0`
6
- Current validation baseline: DSH `v0.2.0-rc.2`. Compatibility checks use the actual running DSH version from the active installation manifest rather than a hardcoded RC.
5
+ This is the clean DSH `0.2.0` implementation line. It does **not** carry DSH `0.1.x` compatibility code.
6
+
7
+ Current stable version: `0.5.2`
8
+ Current validation baseline: DSH `v0.2.0-rc.2`
9
+ Declared DSH compatibility: `>=0.2.0-rc.2 <0.3.0`
10
+
11
+ Compatibility badges are evaluated against the DSH version that is actually running rather than a hardcoded RC value.
12
+
13
+ ## Screenshot
14
+
15
+ Real Harness UI, manually verified on DSH `0.2.0-rc.2`:
16
+
17
+ ![Registry Aggregator integrated into the DSH 0.2 Plugin Manager](docs/screenshots/registry-aggregator-v0.5.2.webp)
7
18
 
8
19
  ## Install from npm
9
20
 
@@ -12,62 +23,84 @@ $DSH_VERSION = "0.2.0-rc.2"
12
23
  $PROFILE = "dsh-020-test"
13
24
  $env:DSH_HOME = "$env:USERPROFILE\.dsh-020-test"
14
25
 
15
- pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" plugin --profile $PROFILE add "@stolyarovmn/dsh-ui-registry-aggregator@0.5.0"
26
+ pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" plugin --profile $PROFILE add "@stolyarovmn/dsh-ui-registry-aggregator@0.5.2"
16
27
  pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" --profile $PROFILE
17
28
  ```
18
29
 
19
- The package declares DSH compatibility as `>=0.2.0-rc.2 <0.3.0`. Registry compatibility badges are evaluated against the DSH version that is actually running.
30
+ ## What it adds
31
+
32
+ The native Plugin Manager remains responsible for installation, enable/disable, uninstall, and bundle lifecycle. Registry Aggregator adds discovery, compatibility evidence, metadata, and update orchestration.
33
+
34
+ ### Main Plugins page
35
+
36
+ On DSH `0.2.0-rc.2`, Registry Aggregator extends the main Plugins list with:
20
37
 
21
- ## Current milestone
38
+ - `Installed | Sources | Browse | Updates` navigation using the native Installed heading/count visual language;
39
+ - the original native Installed cards as the **Installed** view;
40
+ - **Sources**, **Browse**, and **Updates** directly on the main Plugins page;
41
+ - a numeric update count beside **Updates**;
42
+ - `Update → vX.Y.Z` directly on an installed package when a newer eligible version is available;
43
+ - **Update all** for eligible updates, processed sequentially with Registry Aggregator updating itself last;
44
+ - automatic teardown of the main-page surface when the Plugin Manager opens a bundle/detail page.
22
45
 
23
- The native Plugin Manager remains responsible for package lifecycle. Registry Aggregator adds discovery capabilities around it.
46
+ The supported `plugins.bundle.config` detail view remains available as a fallback inside **Installed → Registry Aggregator**.
24
47
 
25
- Implemented in this milestone:
48
+ > **DSH 0.2.0-rc.2 integration note:** stock rc.2 does not expose a list-level Plugin Manager slot. The main-page integration is therefore an explicit rc.2 compatibility bridge. It waits until the native Plugin Manager inventory has finished loading, does not use a `MutationObserver`, and removes itself on detail pages. When DSH exposes an appropriate list-level slot, this bridge should be replaced by the supported slot API.
49
+
50
+ ### Sources
26
51
 
27
- - native `plugins.bundle.config` integration;
28
- - `Sources | Browse | Updates` navigation;
29
- - live source configuration through `ctx.configForms.get('registry-aggregator')`; the bundle slot is presentation-only because DSH does not guarantee a single `form` for bundle-wide pages;
30
52
  - built-in npm and GitHub sources;
31
53
  - custom JSON and corporate catalog sources;
32
- - Host-side health checks and discovered-item counts;
33
- - per-source enable/disable, add, remove, refresh, copy-address, count, and error state;
34
- - DSH 0.2-native switch geometry and icon-action sizing copied under the plugin namespace;
35
- - authenticated Host RPC over the DSH Connection service;
36
- - bounded responses, request timeouts, redirect validation, DNS pinning, and private-network blocking;
37
- - no duplicate `Installed` view;
38
- - live federated Browse search across enabled npm, GitHub, custom JSON, and corporate sources;
39
- - compact single-row source/release/freshness/tag/compatibility/page-size filters on desktop, with responsive wrapping below 900px, multi-source metadata, and 20/50/100 pagination; compatibility uses the native DSH shield contour as its neutral filter icon;
40
- - combinable multi-sort criteria for relevance, stars, downloads, freshness, and name with per-criterion direction; when several are active they contribute equally through percentile-normalized composite ranking, so every selected criterion can affect the order;
41
- - npm 30-day download enrichment plus lazy paired `30d | total` stats for visible npm rows; lifetime totals are summed from the package creation date (or npm's 2015-01-10 data floor) in bounded windows, and the UI never presents a partial lifetime sum as a real total;
42
- - package freshness distinguishes npm release time from GitHub repository push time, so GitHub metadata churn does not make every item look newly released;
43
- - best-effort package artwork discovery from the DSH top-level manifest `icon` field, returned to the Client as bounded data URLs with the same SVG/PNG/JPEG/WebP and 256 KiB policy as native DSH metadata;
44
- - popular/default Browse results when the query is empty;
45
- - real npm/GitHub source marks and a package icon exposed through the DSH 0.2 manifest contract;
46
- - horizontal layout containment for narrow Plugin Manager detail panes;
47
- - one-click Browse installation through the native DSH `remote.pluginManager` service: `inspect` → `installBundle`, installed-state synchronization via `listBundles` / `plugin-manager/changed`, and inline failure reporting;
48
- - icon-only Browse install states using the same local DSH-style glyph language as the rest of the page;
49
- - explicit `v<version>` package versions plus lazy manifest evidence for GitHub/npm rows;
50
- - compatibility evidence against the actual running DSH version, following DSH peer semantics for `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*`; Browse shows `Compatible`, `Incompatible`, or neutral `Not verified` as plain colored text, and can filter by compatibility;
51
- - live Updates discovery from native installed bundles plus latest npm manifests, with per-package update through `remote.pluginManager.installBundle`;
52
- - no runtime import of Harness Client implementation packages;
53
- - Harness-provided React and DSH theme tokens.
54
-
55
- Known limitations in `0.5.0`:
56
-
57
- - build-script approval UI remains in the native **Add plugin** dialog;
58
- - bulk **Update all** / cancel orchestration is not part of the DSH 0.2 line yet;
59
- - update discovery for GitHub-only installed dependencies without npm package identity is not implemented yet.
54
+ - enable/disable, add, remove, refresh, copy-address, package/repository count, health, and error state;
55
+ - live configuration through `ctx.configForms.get('registry-aggregator')`;
56
+ - authenticated Host RPC through the DSH Connection service;
57
+ - bounded responses, request timeouts, redirect validation, DNS pinning, and private-network blocking.
58
+
59
+ ### Browse
60
+
61
+ - federated search across enabled npm, GitHub, custom JSON, and corporate sources;
62
+ - popular/default results when the query is empty;
63
+ - compact source, release, freshness, tag, compatibility, and page-size filters;
64
+ - combinable sorting by relevance, stars, downloads, freshness, and name;
65
+ - multi-criterion percentile-normalized composite ranking;
66
+ - 20 / 50 / 100 pagination;
67
+ - explicit package versions;
68
+ - GitHub stars and release/repository freshness;
69
+ - npm `30d | total` download evidence resolved lazily for visible npm rows;
70
+ - package artwork from the DSH manifest icon contract, with safe repository-local README artwork fallback for npm packages that expose GitHub metadata;
71
+ - `Compatible`, `Incompatible`, and `Not verified` evidence against the actual running DSH version;
72
+ - compatibility filtering;
73
+ - icon-only one-click installation through native `remote.pluginManager.inspect()` → `installBundle()`;
74
+ - known-incompatible packages are disabled before installation is attempted.
75
+
76
+ ### Updates
77
+
78
+ - update discovery from native installed bundles plus latest npm manifests;
79
+ - per-package update through the native Plugin Manager;
80
+ - package Update uses a two-arrow glyph while content/list Refresh keeps the single-arrow glyph;
81
+ - native installed artwork is reused when available;
82
+ - sequential **Update all**;
83
+ - explicitly incompatible update targets remain disabled;
84
+ - Registry Aggregator updates itself last so it cannot interrupt the remaining queue.
60
85
 
61
86
  ## Architecture
62
87
 
63
88
  ```text
64
89
  DSH Plugins
65
- └─ Installed
66
- └─ Registry Aggregator
67
- └─ plugins.bundle.config
68
- ├─ Sources
69
- ├─ Browse
70
- └─ Updates
90
+ ├─ Installed | Sources | Browse | Updates
91
+ │ ├─ Installed
92
+ │ │ ├─ native DSH package cards
93
+ │ │ └─ Update → vX.Y.Z
94
+ │ ├─ Sources
95
+ │ ├─ Browse
96
+ │ └─ Updates
97
+ │ └─ Update all
98
+ │
99
+ └─ Installed → Registry Aggregator
100
+ └─ plugins.bundle.config
101
+ ├─ Sources
102
+ ├─ Browse
103
+ └─ Updates
71
104
 
72
105
  Sources UI
73
106
  ├─ ctx.configForms.get('registry-aggregator')
@@ -81,19 +114,33 @@ Sources UI
81
114
  ├─ custom-json
82
115
  └─ corporate
83
116
 
84
- Browse Install / Updates
117
+ Plugin lifecycle
85
118
  └─ native DSH remote.pluginManager
86
119
  ├─ inspect(spec)
87
120
  ├─ installBundle(spec)
88
121
  └─ listBundles() / plugin-manager/changed
89
-
90
- Enable / disable / uninstall
91
- └─ native DSH Plugin Manager
92
122
  ```
93
123
 
124
+ ## Known limitations
125
+
126
+ - build-script approval remains in the native **Add plugin** dialog;
127
+ - **Update all** cancellation is not implemented; eligible updates run sequentially once started;
128
+ - automatic update discovery does not yet cover GitHub-only installed dependencies without npm package identity;
129
+ - the main Plugins-list surface is a version-pinned DSH `0.2.0-rc.2` compatibility bridge because rc.2 has no supported list-level Plugin Manager slot.
130
+
131
+ ## Validation
132
+
133
+ `0.5.2` was promoted from the manually verified `0.5.2-rc.6` candidate after:
134
+
135
+ - JavaScript syntax checks;
136
+ - repository unit/client contract tests;
137
+ - `npm pack --dry-run` validation;
138
+ - isolated install smoke test against DSH `0.2.0-rc.2`;
139
+ - real Harness UI verification of the main Plugins-page Browse integration shown above.
140
+
94
141
  ## Test from GitHub
95
142
 
96
- Use a commit SHA from this branch rather than publishing a test package:
143
+ For unreleased development builds, use a commit SHA instead of publishing a test package:
97
144
 
98
145
  ```powershell
99
146
  $DSH_VERSION = "0.2.0-rc.2"
@@ -105,8 +152,6 @@ pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" plugin --profile $PROFILE add "github:S
105
152
  pnpm dlx "@deepseek-ai/dsh@$DSH_VERSION" --profile $PROFILE
106
153
  ```
107
154
 
108
- Then open **Plugins → Installed → Registry Aggregator** and verify **Sources**, **Browse**, installation, compatibility filtering, download statistics, and **Updates** in the real Harness UI.
109
-
110
155
  ## Historical lines
111
156
 
112
157
  - `dsh-0.1.7` — stable `v0.4.17` line for DSH `0.1.7-rc.2`.
package/client.js CHANGED
@@ -86,6 +86,9 @@ window.__ModuleLoader__.load({
86
86
  updateNone: 'Installed plugins are up to date',
87
87
  updateNoneBody: 'No newer package versions were found in the connected registries.',
88
88
  updateRefresh: 'Check for updates',
89
+ updatesAvailable: 'Updates: {count} available',
90
+ updateAll: 'Update all',
91
+ updatingAll: 'Updating all…',
89
92
  downloads30d: 'npm downloads in the last 30 days',
90
93
  downloadsTotal: 'npm total downloads',
91
94
  filterSource: 'Source',
@@ -198,6 +201,9 @@ window.__ModuleLoader__.load({
198
201
  updateNone: '已安装插件均为最新版本',
199
202
  updateNoneBody: '在已连接的注册表中没有发现更高版本。',
200
203
  updateRefresh: '检查更新',
204
+ updatesAvailable: '更新:{count} 个可用',
205
+ updateAll: '全部更新',
206
+ updatingAll: '正在全部更新…',
201
207
  downloads30d: 'npm 最近 30 天下载量',
202
208
  downloadsTotal: 'npm 总下载量',
203
209
  filterSource: '来源',
@@ -241,6 +247,8 @@ window.__ModuleLoader__.load({
241
247
  '.ra-root{box-sizing:border-box;min-width:0;max-width:100%;overflow-x:hidden;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;padding:2px 0 8px}',
242
248
  '.ra-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));width:min(100%,440px);padding:4px;margin:12px 0 24px;border-radius:12px;background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-layer-2))}',
243
249
  '.ra-tab{height:34px;padding:0 14px;border:.5px solid transparent;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer}',
250
+ '.ra-tab-label{display:inline-flex;align-items:center;justify-content:center;gap:6px}',
251
+ '.ra-update-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-foreground);font-size:10px;line-height:16px;font-weight:700}',
244
252
  '.ra-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
245
253
  '.ra-tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}',
246
254
  '.ra-tab[aria-selected=true]{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-weight:600}',
@@ -254,6 +262,7 @@ window.__ModuleLoader__.load({
254
262
  '.ra-button:focus-visible,.ra-input:focus-visible,.ra-select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}',
255
263
  '.ra-button:disabled{opacity:.5;cursor:not-allowed}',
256
264
  '.ra-button-primary{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-foreground)}',
265
+ '.ra-update-all{display:inline-flex;align-items:center;justify-content:center;gap:6px}',
257
266
  '.ra-panel{border:.5px solid var(--dsw-alias-border-l4);border-radius:12px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}',
258
267
  '.ra-add-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 13px}',
259
268
  '.ra-add-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(130px,180px) minmax(220px,1.2fr) auto;gap:10px;padding:12px 13px;border-top:.5px solid var(--dsw-alias-border-l4);align-items:end}',
@@ -615,6 +624,15 @@ window.__ModuleLoader__.load({
615
624
  ] })
616
625
  }
617
626
 
627
+ function IconUpdate({ size = 16 }) {
628
+ return h(SvgIcon, { size, children: [
629
+ h('path', { d: 'M13.5 5.5C12.6 3.2 10.5 1.75 8 1.75C5.9 1.75 4.05 2.75 2.9 4.3L1.75 5.75', stroke: 'currentColor' }),
630
+ h('path', { d: 'M1.75 2.75V5.75H4.75', stroke: 'currentColor' }),
631
+ h('path', { d: 'M2.5 10.5C3.4 12.8 5.5 14.25 8 14.25C10.1 14.25 11.95 13.25 13.1 11.7L14.25 10.25', stroke: 'currentColor' }),
632
+ h('path', { d: 'M14.25 13.25V10.25H11.25', stroke: 'currentColor' }),
633
+ ] })
634
+ }
635
+
618
636
  function IconPlus({ size = 16 }) {
619
637
  return h(SvgIcon, { size, children: [
620
638
  h('path', { d: 'M8 2V14', stroke: 'currentColor' }),
@@ -1669,12 +1687,12 @@ window.__ModuleLoader__.load({
1669
1687
  )
1670
1688
  }
1671
1689
 
1672
- function UpdatesView({ t }) {
1690
+ function useUpdateDiscovery(enabled = true) {
1673
1691
  const [revision, setRevision] = React.useState(0)
1674
- const [state, setState] = React.useState({ loading: true, items: [], error: '' })
1675
- const [operations, setOperations] = React.useState({})
1692
+ const [state, setState] = React.useState({ loading: enabled, items: [], error: '' })
1676
1693
 
1677
1694
  React.useEffect(() => {
1695
+ if (!enabled) return undefined
1678
1696
  let active = true
1679
1697
  const controller = new AbortController()
1680
1698
  const load = async () => {
@@ -1716,36 +1734,76 @@ window.__ModuleLoader__.load({
1716
1734
  controller.abort()
1717
1735
  if (typeof dispose === 'function') dispose()
1718
1736
  }
1719
- }, [revision])
1737
+ }, [enabled, revision])
1738
+
1739
+ const refresh = React.useCallback(() => setRevision(value => value + 1), [])
1740
+ return { state, refresh }
1741
+ }
1742
+
1743
+ function UpdatesView({ t, discovery }) {
1744
+ const { state, refresh } = discovery
1745
+ const [operations, setOperations] = React.useState({})
1746
+ const [bulkUpdating, setBulkUpdating] = React.useState(false)
1720
1747
 
1721
- const runUpdate = async item => {
1748
+ const runUpdate = async (item, refreshAfter = true) => {
1722
1749
  const key = item.bundle.name
1723
1750
  const current = operations[key]
1724
- if (['starting', 'installing', 'applying'].includes(current?.phase)) return
1751
+ if (['starting', 'installing', 'applying'].includes(current?.phase)) return false
1725
1752
  setOperations(value => ({ ...value, [key]: { phase: 'starting', error: '' } }))
1726
1753
  try {
1727
1754
  await updateInstalledPlugin(item.bundle, item.availableVersion, progress => {
1728
1755
  setOperations(value => ({ ...value, [key]: { ...(value[key] ?? {}), ...progress, error: '' } }))
1729
1756
  })
1730
1757
  setOperations(value => ({ ...value, [key]: { phase: 'done', error: '' } }))
1731
- setRevision(value => value + 1)
1758
+ if (refreshAfter) refresh()
1759
+ return true
1732
1760
  } catch (error) {
1733
1761
  setOperations(value => ({ ...value, [key]: { phase: 'failed', error: String(error?.message ?? error) } }))
1762
+ return false
1763
+ }
1764
+ }
1765
+
1766
+ const runUpdateAll = async () => {
1767
+ if (bulkUpdating || state.loading) return
1768
+ const queue = state.items
1769
+ .filter(item => item.metadata?.compatibility !== 'unsupported')
1770
+ .sort((left, right) => Number(left.bundle.name === PACKAGE) - Number(right.bundle.name === PACKAGE))
1771
+ if (!queue.length) return
1772
+ setBulkUpdating(true)
1773
+ try {
1774
+ for (const item of queue) await runUpdate(item, false)
1775
+ } finally {
1776
+ setBulkUpdating(false)
1777
+ refresh()
1734
1778
  }
1735
1779
  }
1736
1780
 
1781
+ const updatableCount = state.items.filter(item => item.metadata?.compatibility !== 'unsupported').length
1782
+
1737
1783
  return h('section', { className: 'ra-section', 'aria-labelledby': 'ra-updates-title' },
1738
1784
  h('div', { className: 'ra-section-head' },
1739
1785
  h('div', null,
1740
1786
  h('h3', { id: 'ra-updates-title', className: 'ra-heading' }, t('updatesTitle')),
1741
1787
  h('p', { className: 'ra-lead' }, t('updatesLead')),
1742
1788
  ),
1743
- h(IconButton, {
1744
- label: t('updateRefresh'),
1745
- disabled: state.loading,
1746
- onClick: () => setRevision(value => value + 1),
1747
- icon: h(IconRefresh, { size: 16, className: state.loading ? 'ra-spin' : undefined }),
1748
- }),
1789
+ h('div', { className: 'ra-actions' },
1790
+ state.items.length > 1 ? h('button', {
1791
+ type: 'button',
1792
+ className: 'ra-button ra-update-all',
1793
+ title: bulkUpdating ? t('updatingAll') : t('updateAll'),
1794
+ disabled: state.loading || bulkUpdating || updatableCount === 0,
1795
+ onClick: () => { void runUpdateAll() },
1796
+ },
1797
+ h(IconUpdate, { size: 14 }),
1798
+ h('span', null, bulkUpdating ? t('updatingAll') : t('updateAll')),
1799
+ ) : null,
1800
+ h(IconButton, {
1801
+ label: t('updateRefresh'),
1802
+ disabled: state.loading || bulkUpdating,
1803
+ onClick: refresh,
1804
+ icon: h(IconRefresh, { size: 16, className: state.loading ? 'ra-spin' : undefined }),
1805
+ }),
1806
+ ),
1749
1807
  ),
1750
1808
  state.loading
1751
1809
  ? h('div', { className: 'ra-plugin-meta', role: 'status' }, t('updateChecking'))
@@ -1775,7 +1833,7 @@ window.__ModuleLoader__.load({
1775
1833
  ? t('updateRetry')
1776
1834
  : format(t, 'updateAction', { version: item.availableVersion })
1777
1835
  return h('li', { key, className: 'ra-update-row' },
1778
- h('span', { className: 'ra-plugin-icon', 'aria-hidden': true }, h(IconPlugin, { size: 16 })),
1836
+ h('span', { className: 'ra-plugin-icon', 'aria-hidden': true }, h(PluginArtwork, { src: item.bundle?.meta?.icon })),
1779
1837
  h('div', { className: 'ra-update-main' },
1780
1838
  h('div', { className: 'ra-update-title' },
1781
1839
  h('span', null, key),
@@ -1790,13 +1848,13 @@ window.__ModuleLoader__.load({
1790
1848
  h(IconButton, {
1791
1849
  label,
1792
1850
  state: done ? 'done' : failed ? 'error' : undefined,
1793
- disabled: busy || done || incompatible,
1851
+ disabled: bulkUpdating || busy || done || incompatible,
1794
1852
  onClick: () => { void runUpdate(item) },
1795
1853
  icon: done
1796
1854
  ? h(IconCheck, { size: 16 })
1797
1855
  : busy
1798
1856
  ? h(IconRefresh, { size: 16, className: 'ra-spin' })
1799
- : h(IconRefresh, { size: 16 }),
1857
+ : h(IconUpdate, { size: 16 }),
1800
1858
  }),
1801
1859
  )
1802
1860
  }),
@@ -1806,6 +1864,7 @@ window.__ModuleLoader__.load({
1806
1864
 
1807
1865
  function RegistryAggregator({ t, view }) {
1808
1866
  const [tab, setTab] = React.useState('sources')
1867
+ const updateDiscovery = useUpdateDiscovery(view === 'page')
1809
1868
  const formState = React.useSyncExternalStore(
1810
1869
  listener => sourceConfigForm.subscribe(listener),
1811
1870
  () => sourceConfigForm.getSnapshot(),
@@ -1816,10 +1875,15 @@ window.__ModuleLoader__.load({
1816
1875
  mutate: (operations, expectedRevision) => sourceConfigForm.mutate(operations, expectedRevision),
1817
1876
  }), [formState])
1818
1877
  if (view !== 'page') return null
1878
+ const updateCount = updateDiscovery.state.items.length
1879
+ const updateLabel = h('span', { className: 'ra-tab-label' },
1880
+ t('updates'),
1881
+ updateCount > 0 ? h('span', { className: 'ra-update-badge', 'aria-hidden': true }, updateCount > 99 ? '99+' : String(updateCount)) : null,
1882
+ )
1819
1883
  const tabs = [
1820
- ['sources', t('sources')],
1821
- ['browse', t('browse')],
1822
- ['updates', t('updates')],
1884
+ ['sources', t('sources'), t('sources')],
1885
+ ['browse', t('browse'), t('browse')],
1886
+ ['updates', updateLabel, updateCount > 0 ? format(t, 'updatesAvailable', { count: updateCount }) : t('updates')],
1823
1887
  ]
1824
1888
  const selectRelative = delta => {
1825
1889
  const index = tabs.findIndex(item => item[0] === tab)
@@ -1829,18 +1893,19 @@ window.__ModuleLoader__.load({
1829
1893
  const body = tab === 'browse'
1830
1894
  ? h(BrowseView, { t })
1831
1895
  : tab === 'updates'
1832
- ? h(UpdatesView, { t })
1896
+ ? h(UpdatesView, { t, discovery: updateDiscovery })
1833
1897
  : h(SourcesView, { t, form })
1834
1898
 
1835
1899
  return h('div', { className: 'ra-root' },
1836
1900
  h('style', null, css),
1837
1901
  h('div', { className: 'ra-tabs', role: 'tablist', 'aria-label': 'Registry Aggregator' },
1838
- ...tabs.map(([id, label]) => h('button', {
1902
+ ...tabs.map(([id, label, ariaLabel]) => h('button', {
1839
1903
  key: id,
1840
1904
  type: 'button',
1841
1905
  role: 'tab',
1842
1906
  className: 'ra-tab',
1843
1907
  'aria-selected': tab === id,
1908
+ 'aria-label': ariaLabel,
1844
1909
  tabIndex: tab === id ? 0 : -1,
1845
1910
  onClick: () => setTab(id),
1846
1911
  onKeyDown: event => {
@@ -1858,6 +1923,290 @@ window.__ModuleLoader__.load({
1858
1923
  )
1859
1924
  }
1860
1925
 
1926
+
1927
+ // Temporary DSH 0.2.0-rc.2 compatibility surface.
1928
+ // Stock rc.2 has no supported list-level Plugin Manager slot, so this bridge
1929
+ // is deliberately version-pinned and mounts only after the native Installed
1930
+ // group exists. It never observes React DOM mutations and removes itself as
1931
+ // soon as the Plugin Manager navigates to a detail page.
1932
+ function PluginsPageBridgeSurface({ t, installed, cards }) {
1933
+ const [tab, setTab] = React.useState('installed')
1934
+ const discovery = useUpdateDiscovery(true)
1935
+ const [operations, setOperations] = React.useState({})
1936
+ const [bulkUpdating, setBulkUpdating] = React.useState(false)
1937
+ const formState = React.useSyncExternalStore(
1938
+ listener => sourceConfigForm.subscribe(listener),
1939
+ () => sourceConfigForm.getSnapshot(),
1940
+ () => sourceConfigForm.getSnapshot(),
1941
+ )
1942
+ const form = React.useMemo(() => ({
1943
+ state: formState,
1944
+ mutate: (operations, expectedRevision) => sourceConfigForm.mutate(operations, expectedRevision),
1945
+ }), [formState])
1946
+
1947
+ const installedCount = installed.querySelector('[data-plugin-count]')?.textContent
1948
+ ?? String(installed.querySelectorAll('[data-plugin-package]').length)
1949
+ const updateCount = discovery.state.items.length
1950
+ const updatableCount = discovery.state.items.filter(item => item.metadata?.compatibility !== 'unsupported').length
1951
+
1952
+ const runUpdate = async (item, refreshAfter = true) => {
1953
+ const key = item.bundle.name
1954
+ const current = operations[key]
1955
+ if (['starting', 'installing', 'applying'].includes(current?.phase)) return false
1956
+ setOperations(value => ({ ...value, [key]: { phase: 'starting', error: '' } }))
1957
+ try {
1958
+ await updateInstalledPlugin(item.bundle, item.availableVersion, progress => {
1959
+ setOperations(value => ({ ...value, [key]: { ...(value[key] ?? {}), ...progress, error: '' } }))
1960
+ })
1961
+ setOperations(value => ({ ...value, [key]: { phase: 'done', error: '' } }))
1962
+ if (refreshAfter) discovery.refresh()
1963
+ return true
1964
+ } catch (error) {
1965
+ setOperations(value => ({ ...value, [key]: { phase: 'failed', error: String(error?.message ?? error) } }))
1966
+ return false
1967
+ }
1968
+ }
1969
+
1970
+ const runUpdateAll = async () => {
1971
+ if (bulkUpdating || discovery.state.loading) return
1972
+ const queue = discovery.state.items
1973
+ .filter(item => item.metadata?.compatibility !== 'unsupported')
1974
+ .sort((left, right) => Number(left.bundle.name === PACKAGE) - Number(right.bundle.name === PACKAGE))
1975
+ if (!queue.length) return
1976
+ setBulkUpdating(true)
1977
+ try {
1978
+ for (const item of queue) await runUpdate(item, false)
1979
+ } finally {
1980
+ setBulkUpdating(false)
1981
+ discovery.refresh()
1982
+ }
1983
+ }
1984
+
1985
+ React.useEffect(() => {
1986
+ cards.style.display = tab === 'installed' ? '' : 'none'
1987
+ return () => { cards.style.display = '' }
1988
+ }, [cards, tab])
1989
+
1990
+ const updateSignature = discovery.state.items
1991
+ .map(item => [item.bundle?.name, item.bundle?.version, item.availableVersion, item.metadata?.compatibility].join(':'))
1992
+ .join('|')
1993
+ const operationSignature = Object.entries(operations)
1994
+ .map(([key, value]) => [key, value?.phase, value?.error].join(':'))
1995
+ .join('|')
1996
+
1997
+ React.useEffect(() => {
1998
+ const updateByName = new Map(discovery.state.items.map(item => [item.bundle?.name, item]))
1999
+ const created = []
2000
+ for (const card of installed.querySelectorAll('[data-plugin-package]')) {
2001
+ const packageName = card.getAttribute('data-plugin-package')
2002
+ const item = updateByName.get(packageName)
2003
+ const old = card.querySelector('[data-ra-installed-update]')
2004
+ if (old) old.remove()
2005
+ if (!item?.availableVersion) continue
2006
+
2007
+ const toggle = card.querySelector('[role="switch"]')
2008
+ const actionHost = toggle?.parentElement
2009
+ if (!actionHost) continue
2010
+
2011
+ const operation = operations[packageName] ?? {}
2012
+ const busy = bulkUpdating || ['starting', 'installing', 'applying'].includes(operation.phase)
2013
+ const incompatible = item.metadata?.compatibility === 'unsupported'
2014
+ const button = document.createElement('button')
2015
+ button.type = 'button'
2016
+ button.className = 'ra-installed-update'
2017
+ button.setAttribute('data-ra-installed-update', '')
2018
+ button.setAttribute('data-state', operation.phase === 'failed' ? 'error' : busy ? 'busy' : 'ready')
2019
+ button.textContent = 'Update → v' + item.availableVersion
2020
+ button.title = operation.error || (incompatible
2021
+ ? format(t, 'incompatibleTitle', { version: item.metadata?.runtimeVersion ?? '?' })
2022
+ : format(t, 'updateAction', { version: 'v' + item.availableVersion }))
2023
+ button.disabled = busy || incompatible
2024
+ button.addEventListener('click', event => {
2025
+ event.preventDefault()
2026
+ event.stopPropagation()
2027
+ void runUpdate(item)
2028
+ })
2029
+ actionHost.insertBefore(button, toggle)
2030
+ created.push(button)
2031
+ }
2032
+ return () => {
2033
+ for (const button of created) button.remove()
2034
+ }
2035
+ }, [installed, updateSignature, operationSignature, bulkUpdating])
2036
+
2037
+ const bridgeTabs = [
2038
+ ['installed', t('installed'), installedCount],
2039
+ ['sources', t('sources'), ''],
2040
+ ['browse', t('browse'), ''],
2041
+ ['updates', t('updates'), updateCount > 0 ? String(updateCount) : ''],
2042
+ ]
2043
+ const selectRelative = delta => {
2044
+ const index = bridgeTabs.findIndex(item => item[0] === tab)
2045
+ setTab(bridgeTabs[(index + delta + bridgeTabs.length) % bridgeTabs.length][0])
2046
+ }
2047
+ const body = tab === 'sources'
2048
+ ? h(SourcesView, { t, form })
2049
+ : tab === 'browse'
2050
+ ? h(BrowseView, { t })
2051
+ : tab === 'updates'
2052
+ ? h(UpdatesView, { t, discovery })
2053
+ : null
2054
+
2055
+ return h('div', { className: 'ra-native-surface' },
2056
+ h('div', { className: 'ra-native-nav-row' },
2057
+ h('div', { className: 'ra-native-tabs', role: 'tablist', 'aria-label': 'Plugin Manager views' },
2058
+ ...bridgeTabs.flatMap(([id, label, count], index) => [
2059
+ index === 0 ? null : h('span', { key: id + '-separator', className: 'ra-native-separator', 'aria-hidden': true }, '|'),
2060
+ h('button', {
2061
+ key: id,
2062
+ type: 'button',
2063
+ role: 'tab',
2064
+ className: 'ra-native-tab',
2065
+ 'aria-selected': tab === id,
2066
+ tabIndex: tab === id ? 0 : -1,
2067
+ onClick: () => setTab(id),
2068
+ onKeyDown: event => {
2069
+ if (event.key === 'ArrowRight') {
2070
+ event.preventDefault()
2071
+ selectRelative(1)
2072
+ } else if (event.key === 'ArrowLeft') {
2073
+ event.preventDefault()
2074
+ selectRelative(-1)
2075
+ }
2076
+ },
2077
+ },
2078
+ h('span', null, label),
2079
+ count ? h('span', { className: 'ra-native-count' }, count) : null,
2080
+ ),
2081
+ ].filter(Boolean)),
2082
+ ),
2083
+ tab === 'installed' && updateCount > 0
2084
+ ? h('button', {
2085
+ type: 'button',
2086
+ className: 'ra-native-update-all',
2087
+ disabled: discovery.state.loading || bulkUpdating || updatableCount === 0,
2088
+ onClick: () => { void runUpdateAll() },
2089
+ },
2090
+ h(IconUpdate, { size: 14 }),
2091
+ h('span', null, bulkUpdating ? t('updatingAll') : t('updateAll')),
2092
+ )
2093
+ : null,
2094
+ ),
2095
+ body ? h('div', { className: 'ra-native-body' }, body) : null,
2096
+ )
2097
+ }
2098
+
2099
+ function installPluginsPagePollingBridge(ctx) {
2100
+ const doc = window.document
2101
+ if (!doc?.querySelector) return () => {}
2102
+
2103
+ const HOST_ATTR = 'data-registry-aggregator-main-surface'
2104
+ const bridgeCss = [
2105
+ '[' + HOST_ATTR + ']{box-sizing:border-box;min-width:0;width:100%}',
2106
+ '[' + HOST_ATTR + '] .ra-native-surface{min-width:0;width:100%}',
2107
+ '[' + HOST_ATTR + '] .ra-native-nav-row{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:22px}',
2108
+ '[' + HOST_ATTR + '] .ra-native-tabs{display:flex;align-items:baseline;min-width:0;flex-wrap:wrap}',
2109
+ '[' + HOST_ATTR + '] .ra-native-tab{display:inline-flex;align-items:baseline;gap:8px;margin:0;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:14px;line-height:22px;font-weight:500;cursor:pointer}',
2110
+ '[' + HOST_ATTR + '] .ra-native-tab[aria-selected=true]{color:var(--dsw-alias-label-primary)}',
2111
+ '[' + HOST_ATTR + '] .ra-native-tab:hover{color:var(--dsw-alias-label-primary)}',
2112
+ '[' + HOST_ATTR + '] .ra-native-tab:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px;border-radius:var(--dsw-radius-xs)}',
2113
+ '[' + HOST_ATTR + '] .ra-native-count{color:var(--dsw-alias-label-caption);font-weight:400;font-variant-numeric:tabular-nums}',
2114
+ '[' + HOST_ATTR + '] .ra-native-separator{padding:0 10px;color:var(--dsw-alias-label-caption);font-size:12px;line-height:22px}',
2115
+ '[' + HOST_ATTR + '] .ra-native-update-all{display:inline-flex;align-items:center;justify-content:center;gap:6px;flex:none;height:30px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
2116
+ '[' + HOST_ATTR + '] .ra-native-update-all:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2117
+ '[' + HOST_ATTR + '] .ra-native-update-all:disabled{opacity:.5;cursor:default}',
2118
+ '[' + HOST_ATTR + '] .ra-native-body{padding-top:16px}',
2119
+ '.ra-installed-update{position:relative;z-index:2;display:inline-flex;align-items:center;justify-content:center;height:28px;padding:0 9px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;white-space:nowrap;cursor:pointer}',
2120
+ '.ra-installed-update:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
2121
+ '.ra-installed-update:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}',
2122
+ '.ra-installed-update:disabled{opacity:.5;cursor:default}',
2123
+ '.ra-installed-update[data-state=error]{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent)}',
2124
+ ].join('')
2125
+ let host
2126
+ let root
2127
+ let createRoot
2128
+ let installedGroup
2129
+ let installedHead
2130
+ let cardsList
2131
+ let headDisplay = ''
2132
+ let cardsDisplay = ''
2133
+ let disposed = false
2134
+
2135
+ const disposeRoot = () => {
2136
+ if (root) {
2137
+ try { root.unmount() } catch {}
2138
+ root = undefined
2139
+ }
2140
+ if (installedHead?.isConnected) installedHead.style.display = headDisplay
2141
+ if (cardsList?.isConnected) cardsList.style.display = cardsDisplay
2142
+ if (installedGroup?.querySelectorAll) {
2143
+ for (const button of installedGroup.querySelectorAll('[data-ra-installed-update]')) button.remove()
2144
+ }
2145
+ if (host?.isConnected) host.remove()
2146
+ host = undefined
2147
+ installedGroup = undefined
2148
+ installedHead = undefined
2149
+ cardsList = undefined
2150
+ }
2151
+
2152
+ const reconcile = () => {
2153
+ if (disposed) return
2154
+ const panel = doc.querySelector('section[data-plugin-panel]')
2155
+ if (!panel) {
2156
+ disposeRoot()
2157
+ return
2158
+ }
2159
+ if (panel.querySelector('[data-plugin-loading]')) return
2160
+
2161
+ const installed = panel.querySelector('section[data-plugin-group="bundles"]')
2162
+ if (!installed) {
2163
+ // Detail pages intentionally have no Installed group. Removing the
2164
+ // bridge here prevents the duplicate UI shown above bundle details.
2165
+ disposeRoot()
2166
+ return
2167
+ }
2168
+ const head = installed.firstElementChild
2169
+ const cards = Array.from(installed.children).find(element => element.tagName === 'UL')
2170
+ if (!head || !cards) return
2171
+ if (host?.isConnected && installedGroup === installed && host.parentElement === installed && head.nextElementSibling === host) return
2172
+
2173
+ disposeRoot()
2174
+ installedGroup = installed
2175
+ installedHead = head
2176
+ cardsList = cards
2177
+ headDisplay = head.style.display
2178
+ cardsDisplay = cards.style.display
2179
+ head.style.display = 'none'
2180
+
2181
+ host = doc.createElement('div')
2182
+ host.setAttribute(HOST_ATTR, '')
2183
+ host.setAttribute('aria-label', 'Registry Aggregator')
2184
+ head.insertAdjacentElement('afterend', host)
2185
+
2186
+ try {
2187
+ if (!createRoot) ({ createRoot } = require('react-dom/client'))
2188
+ if (typeof createRoot !== 'function') throw new Error('react-dom/client createRoot is unavailable')
2189
+ root = createRoot(host)
2190
+ root.render(h(React.Fragment, null,
2191
+ h('style', null, css + '\n' + bridgeCss),
2192
+ h(PluginsPageBridgeSurface, { t: ctx.locale.bind(NS), installed, cards }),
2193
+ ))
2194
+ } catch {
2195
+ disposeRoot()
2196
+ }
2197
+ }
2198
+
2199
+ const timer = window.setInterval(reconcile, 750)
2200
+ const initial = window.setTimeout(reconcile, 900)
2201
+
2202
+ return () => {
2203
+ disposed = true
2204
+ window.clearInterval(timer)
2205
+ window.clearTimeout(initial)
2206
+ disposeRoot()
2207
+ }
2208
+ }
2209
+
1861
2210
  return {
1862
2211
  inject: ['slots', 'locale', 'connection', 'configForms', 'remote', 'remote.pluginManager'],
1863
2212
  apply(ctx) {
@@ -1865,6 +2214,7 @@ window.__ModuleLoader__.load({
1865
2214
  remote = ctx.remote
1866
2215
  sourceConfigForm = ctx.configForms.get(HOST_ENTRY)
1867
2216
  ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'registry-aggregator: locale')
2217
+ ctx.effect(() => ctx.configForms.whileServed([HOST_ENTRY], () => installPluginsPagePollingBridge(ctx)), 'registry-aggregator: DSH 0.2.0-rc.2 Plugins page polling bridge')
1868
2218
  ctx.effect(() => ctx.configForms.whileServed([HOST_ENTRY], () =>
1869
2219
  ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
1870
2220
  name: 'plugins.bundle.config',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stolyarovmn/dsh-ui-registry-aggregator",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Federated plugin discovery and update UI for DeepSeek Harness 0.2.x, integrated with the native Plugin Manager.",
5
5
  "author": {
6
6
  "name": "Stolyarovmn",
package/source-core.js CHANGED
@@ -505,6 +505,13 @@ function cleanUrl(value) {
505
505
  }
506
506
  }
507
507
 
508
+ function manifestRepositoryUrl(manifest) {
509
+ const repository = manifest?.repository
510
+ if (typeof repository === 'string') return cleanUrl(repository)
511
+ if (repository && typeof repository === 'object' && !Array.isArray(repository)) return cleanUrl(repository.url)
512
+ return undefined
513
+ }
514
+
508
515
  function npmPlugin(entry, source) {
509
516
  const pkg = entry?.package
510
517
  const name = text(pkg?.name)
@@ -722,6 +729,41 @@ function safeIconPath(value) {
722
729
  return { path: clean, mediaType }
723
730
  }
724
731
 
732
+ function readmeIconPath(markdown) {
733
+ const source = String(markdown ?? '')
734
+ const candidates = []
735
+ const add = (raw, label = '') => {
736
+ const candidate = text(raw)?.replace(/^<|>$/g, '').split(/[?#]/u)[0]
737
+ const icon = safeIconPath(candidate)
738
+ if (!icon) return
739
+ const signal = (String(label) + ' ' + icon.path).toLowerCase()
740
+ if (!/(^|[\/_.\s-])(icon|logo|avatar|brand|mark)([\/_.\s-]|$)/u.test(signal)) return
741
+ const lowerPath = icon.path.toLowerCase()
742
+ const basename = lowerPath.split('/').pop() ?? ''
743
+ const score = /^(icon|logo)([._-]|$)/u.test(basename)
744
+ ? 3
745
+ : /(^|\/)(assets?|images?|img|media)\//u.test(lowerPath)
746
+ ? 2
747
+ : 1
748
+ candidates.push({ ...icon, score })
749
+ }
750
+
751
+ const htmlImage = /<img\b([^>]*?)>/giu
752
+ let match
753
+ while ((match = htmlImage.exec(source))) {
754
+ const attrs = match[1]
755
+ const src = /\bsrc\s*=\s*["']([^"']+)["']/iu.exec(attrs)?.[1]
756
+ const alt = /\balt\s*=\s*["']([^"']*)["']/iu.exec(attrs)?.[1] ?? ''
757
+ add(src, alt)
758
+ }
759
+
760
+ const markdownImage = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/gu
761
+ while ((match = markdownImage.exec(source))) add(match[2], match[1])
762
+
763
+ candidates.sort((left, right) => right.score - left.score)
764
+ return candidates[0]
765
+ }
766
+
725
767
  function encodedPath(path) {
726
768
  return path.split('/').map(part => encodeURIComponent(part)).join('/')
727
769
  }
@@ -900,8 +942,10 @@ export async function resolvePluginMetadata(items, options = {}) {
900
942
  }
901
943
 
902
944
  const compatibility = dshCompatibility(manifest, options.runtimeVersion)
945
+ const repository = manifestRepositoryUrl(manifest) ?? cleanUrl(item?.repository)
903
946
  const value = {
904
947
  ...(text(manifest?.version) ? { version: text(manifest.version) } : {}),
948
+ ...(repository ? { repository } : {}),
905
949
  compatibility: compatibility.status,
906
950
  ...(compatibility.peers.length ? { dshPeers: compatibility.peers } : {}),
907
951
  runtimeVersion: text(options.runtimeVersion),
@@ -917,22 +961,54 @@ async function resolveNpmIcon(item, options) {
917
961
  if (!packageName || !version) return undefined
918
962
  const manifest = await npmManifest(item, options)
919
963
  const icon = safeIconPath(manifest?.icon)
920
- if (!icon) return undefined
921
- const packagePath = packageName.startsWith('@')
922
- ? packageName.split('/').map(part => encodeURIComponent(part)).join('/')
923
- : encodeURIComponent(packageName)
924
- const iconUrl = 'https://unpkg.com/' + packagePath + '@' + encodeURIComponent(version) + '/' + encodedPath(icon.path)
925
- return iconDataFromUrl(iconUrl, icon.mediaType, options)
964
+ if (icon) {
965
+ const packagePath = packageName.startsWith('@')
966
+ ? packageName.split('/').map(part => encodeURIComponent(part)).join('/')
967
+ : encodeURIComponent(packageName)
968
+ const iconUrl = 'https://unpkg.com/' + packagePath + '@' + encodeURIComponent(version) + '/' + encodedPath(icon.path)
969
+ return iconDataFromUrl(iconUrl, icon.mediaType, options)
970
+ }
971
+ const parts = githubRepositoryParts(manifestRepositoryUrl(manifest))
972
+ return parts ? githubReadmeIcon(parts, options) : undefined
973
+ }
974
+
975
+ async function githubReadmeIcon(parts, options) {
976
+ const source = { id: 'plugin-readme-icon', name: 'plugin README icon', type: 'github', enabled: true }
977
+ for (const filename of ['README.md', 'readme.md']) {
978
+ try {
979
+ const readmeUrl = 'https://raw.githubusercontent.com/' + encodeURIComponent(parts.owner) + '/' + encodeURIComponent(parts.repo) + '/HEAD/' + filename
980
+ const bytes = await fetchBinary(readmeUrl, source, {
981
+ ...options,
982
+ authToken: undefined,
983
+ allowPrivateNetwork: false,
984
+ maxBytes: Math.min(options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, 512 * 1024),
985
+ })
986
+ const icon = readmeIconPath(new TextDecoder().decode(bytes))
987
+ if (!icon) continue
988
+ const iconUrl = 'https://raw.githubusercontent.com/' + encodeURIComponent(parts.owner) + '/' + encodeURIComponent(parts.repo) + '/HEAD/' + encodedPath(icon.path)
989
+ return await iconDataFromUrl(iconUrl, icon.mediaType, options)
990
+ } catch {
991
+ // README artwork is optional evidence; try the next conventional filename.
992
+ }
993
+ }
994
+ return undefined
926
995
  }
927
996
 
928
997
  async function resolveGithubIcon(item, options) {
929
998
  const parts = githubRepositoryParts(item?.repository)
930
999
  if (!parts) return undefined
931
- const manifest = await githubManifest(item, options)
1000
+ let manifest
1001
+ try {
1002
+ manifest = await githubManifest(item, options)
1003
+ } catch {
1004
+ manifest = undefined
1005
+ }
932
1006
  const icon = safeIconPath(manifest?.icon)
933
- if (!icon) return undefined
934
- const iconUrl = 'https://raw.githubusercontent.com/' + encodeURIComponent(parts.owner) + '/' + encodeURIComponent(parts.repo) + '/HEAD/' + encodedPath(icon.path)
935
- return iconDataFromUrl(iconUrl, icon.mediaType, options)
1007
+ if (icon) {
1008
+ const iconUrl = 'https://raw.githubusercontent.com/' + encodeURIComponent(parts.owner) + '/' + encodeURIComponent(parts.repo) + '/HEAD/' + encodedPath(icon.path)
1009
+ return iconDataFromUrl(iconUrl, icon.mediaType, options)
1010
+ }
1011
+ return githubReadmeIcon(parts, options)
936
1012
  }
937
1013
 
938
1014
  export async function resolvePluginIcons(items, options = {}) {