dsh-plugin 2.0.4 → 2.0.5

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.
@@ -15,6 +15,22 @@ import { CATALOG_CACHE_TTL_MS, requireMethod, sendJson } from './shared.js';
15
15
  function catalogCacheFile(profile, key) {
16
16
  return join(profileDirectory(profile), 'cache', `catalog-${key}.json`);
17
17
  }
18
+ /** 目录拉取超时:目录 JSON 约 3.6MB,20s 在弱网/代理链路上不够用;
19
+ * 客户端会在超时后重试,重试必须共享同一次拉取(见 fetchCatalogOnce)。 */
20
+ const CATALOG_FETCH_TIMEOUT_MS = 60_000;
21
+ /** 并发拉取去重:客户端超时重试会并发打进来,共享同一次 curl,
22
+ * 避免多份 3.6MB 下载互相抢带宽、越重试越慢。 */
23
+ const inflightCatalog = new Map();
24
+ function fetchCatalogOnce(target, proxy) {
25
+ const key = `${target}\n${proxy}`;
26
+ const running = inflightCatalog.get(key);
27
+ if (running !== undefined)
28
+ return running;
29
+ const task = fetchViaCurl(target, proxy, CATALOG_FETCH_TIMEOUT_MS)
30
+ .finally(() => { inflightCatalog.delete(key); });
31
+ inflightCatalog.set(key, task);
32
+ return task;
33
+ }
18
34
  /** 读缓存:文件存在、JSON 合法、未超过 maxAgeMs → 返回原始响应字符串;否则 null(视为 miss)。
19
35
  * maxAgeMs 传 Infinity 即「任意年龄的缓存」,供实时拉取失败时兜底使用。 */
20
36
  function readCatalogCache(file, maxAgeMs) {
@@ -89,9 +105,12 @@ export function registerCatalogRoutes(webServer, profile) {
89
105
  const target = isStats
90
106
  ? 'https://api.dsh-plugin.org/stats.json'
91
107
  : `https://api.dsh-plugin.org/plugins.${lang}.json`;
92
- // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
108
+ // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程。
109
+ // refresh=1 跳过 TTL 缓存直接回源(「强制刷新」用);成功后照样覆盖缓存,
110
+ // 失败仍回退旧缓存,所以强制刷新不会把可用的旧数据弄丢。
93
111
  const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`);
94
- const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS);
112
+ const forceRefresh = url.searchParams.get('refresh') === '1';
113
+ const cached = forceRefresh ? null : readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS);
95
114
  if (cached !== null) {
96
115
  try {
97
116
  sendJson(response, 200, JSON.parse(cached));
@@ -101,7 +120,7 @@ export function registerCatalogRoutes(webServer, profile) {
101
120
  // 缓存正文损坏:忽略,走重新拉取并覆盖写盘
102
121
  }
103
122
  }
104
- const r = await fetchViaCurl(target, proxy, 20000);
123
+ const r = await fetchCatalogOnce(target, proxy);
105
124
  if (!r.ok || r.body === '') {
106
125
  // 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
107
126
  // 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
@@ -74,6 +74,30 @@ function toolVersion(cmd, args) {
74
74
  return null;
75
75
  }
76
76
  }
77
+ /** 本机实际生效的 npm registry:诊断与安装同口径 —— 设置里留空时读用户 npm 配置
78
+ * (含 profile 目录的 .npmrc),而不是硬探官方源,避免「能装但诊断说源不通」。
79
+ * 读不到(npm 缺失/超时/输出异常)时回退官方源。 */
80
+ function configuredNpmRegistry(profile) {
81
+ const fromEnv = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? '';
82
+ const valid = (v) => (/^https?:\/\//.test(v) ? v.replace(/\/+$/, '') : null);
83
+ const viaEnv = valid(fromEnv);
84
+ if (viaEnv !== null)
85
+ return viaEnv;
86
+ try {
87
+ const cwd = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile);
88
+ const r = spawnSync('npm', ['config', 'get', 'registry'], {
89
+ timeout: 3000,
90
+ encoding: 'utf8',
91
+ cwd,
92
+ shell: process.platform === 'win32',
93
+ });
94
+ const out = (r.stdout ?? '').trim().split(/\r?\n/).pop() ?? '';
95
+ return valid(out) ?? 'https://registry.npmjs.org';
96
+ }
97
+ catch {
98
+ return 'https://registry.npmjs.org';
99
+ }
100
+ }
77
101
  /** 宿主环境快照:提交 bug 时随 issue 附上,便于作者复现(前端 /env 端点返回)。 */
78
102
  export function hostEnv(profile) {
79
103
  return {
@@ -113,17 +137,20 @@ export function registerDiagnosticsRoutes(webServer, profile) {
113
137
  }
114
138
  const settings = loadSettings(profile);
115
139
  // npm 通道:设置里配置了镜像源就探测该镜像(安装通道吃同一个 registry);
116
- // 未配置(空串)时探测官方源作为「跟随本机 npm 配置」的基础连通性参考。
117
- const registry = settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org';
118
- // 连通性自检打各通道的轻量资源:npm 源拉包元数据、GitHub 行探小仓库、目录站探
119
- // 小文件,避免拉取目录全量 JSON。
120
- // npm 行 display:配置了镜像就显示镜像地址;未配置置空 —— 客户端显示「未配置(跟随本机 npm 配置)」。
140
+ // 未配置(空串)时探测本机 npm 配置里实际生效的源 —— 与安装同口径。
141
+ const registry = settings.npmRegistry.replace(/\/+$/, '') || configuredNpmRegistry(profile);
142
+ // 连通性自检打各通道的轻量资源:npm 源拉包元数据、GitHub 行探小仓库、
143
+ // 目录站按 Range 取 1KB(探市场真实下载的 plugins.zh.json,但不整份下载)。
144
+ // npm 行 display:显式配置、或本机 npm 源不是官方源时显示实际地址,
145
+ // 让用户看到「诊断的到底是哪个源」;官方源且未配置时留空(显示「跟随本机 npm 配置」)。
121
146
  const allChecks = [
122
- { key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' ? registry : '', cmd: `npm view dsh-plugin version --registry ${registry}` },
147
+ { key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' || registry !== 'https://registry.npmjs.org' ? registry : '', cmd: `npm view dsh-plugin version --registry ${registry}` },
123
148
  // GitHub 行用真实 git 克隆握手(git ls-remote)打 dshplugin/hello-dsh 小仓库:
124
149
  // 「网页能打开」和「git 能克隆」是两码事,握手成功才算通道通;小仓库秒级完成,不打 17MB 的 dsh-plugin-hub。
125
150
  { key: 'github', url: 'https://github.com/dshplugin/hello-dsh', display: 'github.com', cmd: 'git ls-remote https://github.com/dshplugin/hello-dsh', git: true },
126
- { key: 'catalog', url: 'https://api.dsh-plugin.org/stats.json', display: '', cmd: 'curl -s https://api.dsh-plugin.org/stats.json' },
151
+ // 目录行探市场真正下载的那个文件(plugins.zh.json),只取前 1KB:
152
+ // 只探 stats.json 会出现「诊断绿、市场加载不出来」—— 两者端点与体积完全不同。
153
+ { key: 'catalog', url: 'https://api.dsh-plugin.org/plugins.zh.json', display: '', cmd: 'curl -r 0-1023 https://api.dsh-plugin.org/plugins.zh.json', range: '0-1023' },
127
154
  ];
128
155
  // 配置了 HTTP 代理:追加一行代理诊断 —— 用该代理打 github.com(安装通道真实访问的地址),
129
156
  // 验证「代理能不能把请求带出去」。与安装同口径:curl 子进程注入该代理 env。
@@ -144,7 +171,7 @@ export function registerDiagnosticsRoutes(webServer, profile) {
144
171
  const effectiveProxy = settings.proxy !== ''
145
172
  ? settings.proxy
146
173
  : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
147
- const probe = (url, proxy) => probeUrl(url, proxy ?? effectiveProxy, 6000);
174
+ const probe = (url, proxy, range) => probeUrl(url, proxy ?? effectiveProxy, 6000, range);
148
175
  // 逐路串行:一条命令一条结果地推送给终端,保持「程序在跑」的真实感
149
176
  const results = [];
150
177
  for (const c of checks) {
@@ -152,7 +179,7 @@ export function registerDiagnosticsRoutes(webServer, profile) {
152
179
  // git 行走真实克隆握手(gitLsRemote),其余走 curl 探测,两路都带超时兜底
153
180
  const r = c.git
154
181
  ? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
155
- : await probe(c.url, c.proxy);
182
+ : await probe(c.url, c.proxy, c.range);
156
183
  results.push({ key: c.key, ok: r.ok });
157
184
  // git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码。
158
185
  // reason 透出失败细分原因(如 Windows 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
@@ -18,7 +18,7 @@ import { backfillOperationLog, createOperationLogTap, operationLogPathOf } from
18
18
  import { cleanLine, estimateProgress } from '../profile/progress.js';
19
19
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.js';
20
20
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.js';
21
- import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.js';
21
+ import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.js';
22
22
  import { appendLog } from '../log.js';
23
23
  /** 内存任务注册表,按任务 id 索引。 */
24
24
  const tasks = new Map();
@@ -568,6 +568,11 @@ function spawnMutation(options) {
568
568
  task.status = result.timedOut || result.exitCode !== 0 ? 'failed' : 'done';
569
569
  if (task.status === 'done') {
570
570
  task.progress = 100;
571
+ // 市场自更新:若 bundle 已被取消勾选,重启后市场不会出现,结果页要明确提示
572
+ if ((action === 'add' || action === 'update') && (target === 'dsh-plugin' || target.startsWith('dsh-plugin@'))
573
+ && !bundleIncludes(profile, 'dsh-plugin')) {
574
+ task.bundleDeselected = true;
575
+ }
571
576
  if (isGlobalNpm) {
572
577
  // 全局 npm 安装:装的是本机 CLI 工具(如 dsh-tui),不进任何 profile,无需宿主重启
573
578
  task.needsRestart = false;
@@ -162,13 +162,16 @@ async function curlWithTlsFallback(args, env, timeoutMs) {
162
162
  * 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
163
163
  * 证书吊销检查受阻(Windows Schannel)另记 reason,供诊断给出对症提示。
164
164
  */
165
- async function curlProbe(url, proxy, timeoutMs) {
165
+ async function curlProbe(url, proxy, timeoutMs, range) {
166
166
  const started = Date.now();
167
167
  const discard = process.platform === 'win32' ? 'NUL' : '/dev/null';
168
168
  // -sS(而非仅 -s):silent 会把连接/TLS 错误一并吞掉,而识别证书吊销受阻正需要 stderr;
169
169
  // 子进程 stderr 由我们收集、不会回显给用户,所以补 -S 不产生噪音。
170
+ // range:目录通道路由探真实的 plugins.zh.json,但只取前 1KB(-r 0-1023,返回 206 即视为可达),
171
+ // 避免诊断把 3.6MB 全量目录下载一遍。
170
172
  const r = await curlWithTlsFallback([
171
173
  '-sS', '-o', discard, '-w', '%{http_code}',
174
+ ...(range !== undefined ? ['-r', range] : []),
172
175
  '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
173
176
  url,
174
177
  ], proxyEnv(proxy), timeoutMs);
@@ -187,8 +190,9 @@ async function curlProbe(url, proxy, timeoutMs) {
187
190
  /**
188
191
  * 探测一个 HTTPS 目标:proxy 非空注入给 curl 子进程走代理,否则直连。
189
192
  * 供系统诊断 /diagnostics 使用;目标 URL 非法返回不可达。
193
+ * range(如 '0-1023')只取前 N 字节,用于探大文件端点而不下载全量。
190
194
  */
191
- export function probeUrl(url, proxy, timeoutMs) {
195
+ export function probeUrl(url, proxy, timeoutMs, range) {
192
196
  let target;
193
197
  try {
194
198
  target = new URL(url);
@@ -196,7 +200,7 @@ export function probeUrl(url, proxy, timeoutMs) {
196
200
  catch {
197
201
  return Promise.resolve(probeFail());
198
202
  }
199
- return curlProbe(target.href, proxy, timeoutMs);
203
+ return curlProbe(target.href, proxy, timeoutMs, range);
200
204
  }
201
205
  /**
202
206
  * git 真实克隆握手探测:spawn git ls-remote(https 传输,与 pnpm 克隆前的
@@ -260,8 +264,9 @@ export async function finalUrl(url, proxy, timeoutMs) {
260
264
  * reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定兜底策略。
261
265
  */
262
266
  export function fetchViaCurl(url, proxy, timeoutMs) {
263
- // 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)
264
- const args = ['-sS', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url];
267
+ // 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)。
268
+ // --compressed:目录 JSON 约 3.6MB,源站支持 gzip,压缩后弱网下也能在超时内拉完。
269
+ const args = ['-sS', '--compressed', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url];
265
270
  return curlWithTlsFallback(args, proxyEnv(proxy), timeoutMs).then((r) => ({
266
271
  ok: r.code === 0,
267
272
  body: r.stdout,
@@ -50,6 +50,20 @@ export function profileFromArgv(argv, fallback = 'web') {
50
50
  export function profileDirectory(profile) {
51
51
  return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile);
52
52
  }
53
+ /** profile 的 dsh.profile.bundles 是否仍包含指定包:取消勾选后 bundle patch 不再执行。
54
+ * 没有该清单或读取失败时返回 true —— 不归我们判断的情况不报警。 */
55
+ export function bundleIncludes(profile, name) {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
58
+ const bundles = pkg.dsh?.profile?.bundles;
59
+ if (!Array.isArray(bundles))
60
+ return true;
61
+ return bundles.includes(name);
62
+ }
63
+ catch {
64
+ return true;
65
+ }
66
+ }
53
67
  /** 把仓库身份转成规范的 GitHub 目标(git+https),仓库不合法时返回 null。
54
68
  * 带 ref 时按 Git ref(`#tag` / `#commit`)拼接;安装通道已不再使用 git 目标,此函数仅做身份规范化。 */
55
69
  export function githubTarget(repo, ref) {
@@ -39,9 +39,11 @@ export interface InstallTask {
39
39
  /** 完成后是否仍需宿主重启才生效:仅「热挂载失败 / 非 dsh 插件 / 更新(ESM 缓存取不到新代码)/
40
40
  * 无 loader」等刷新解决不了的兜底场景为 true(弹窗给「立即重启」);其余一律 false。 */
41
41
  needsRestart: boolean;
42
- /** 完成后是否只需刷新页面即生效(插件已热挂进运行中 loader,仅剩客户端 UI 待页面重新加载,
42
+ /** 完成结果是否只需刷新页面即生效(插件已热挂进运行中 loader,仅剩客户端 UI 待页面重新加载,
43
43
  * 网页端与桌面端一致):true 时关闭结果弹窗即自动刷新页面,不给手动刷新按钮。 */
44
44
  needsReload?: boolean;
45
+ /** 自更新成功、但该 profile 的 dsh.profile.bundles 已不含 dsh-plugin:重启后市场不会出现 */
46
+ bundleDeselected?: boolean;
45
47
  /** 卸载入队时该包是否带客户端 UI(`dsh.client`):卸载后包已从磁盘删除,只能入队前快照,
46
48
  * 卸载成功且曾存活时据此决定是否需要刷新页面摘除插件面板。 */
47
49
  hadClientUi?: boolean;
@@ -46,8 +46,9 @@ export declare function systemProxy(): string | null;
46
46
  /**
47
47
  * 探测一个 HTTPS 目标:proxy 非空注入给 curl 子进程走代理,否则直连。
48
48
  * 供系统诊断 /diagnostics 使用;目标 URL 非法返回不可达。
49
+ * range(如 '0-1023')只取前 N 字节,用于探大文件端点而不下载全量。
49
50
  */
50
- export declare function probeUrl(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult>;
51
+ export declare function probeUrl(url: string, proxy: string, timeoutMs: number, range?: string): Promise<ProbeResult>;
51
52
  /**
52
53
  * git 真实克隆握手探测:spawn git ls-remote(https 传输,与 pnpm 克隆前的
53
54
  * ref 握手一致),注入代理 env,GIT_TERMINAL_PROMPT=0 防凭据提示挂起。
@@ -12,6 +12,9 @@ export declare function readProfileArg(fallback?: string): string;
12
12
  export declare function profileFromArgv(argv: readonly string[], fallback?: string): string;
13
13
  /** 解析 profile 目录(`DSH_HOME` 或 `~/.dsh`)。 */
14
14
  export declare function profileDirectory(profile: string): string;
15
+ /** profile 的 dsh.profile.bundles 是否仍包含指定包:取消勾选后 bundle patch 不再执行。
16
+ * 没有该清单或读取失败时返回 true —— 不归我们判断的情况不报警。 */
17
+ export declare function bundleIncludes(profile: string, name: string): boolean;
15
18
  /** 把仓库身份转成规范的 GitHub 目标(git+https),仓库不合法时返回 null。
16
19
  * 带 ref 时按 Git ref(`#tag` / `#commit`)拼接;安装通道已不再使用 git 目标,此函数仅做身份规范化。 */
17
20
  export declare function githubTarget(repo: string, ref?: string | null): string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -109,6 +109,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
109
109
  /** 结果视图是否显示「关闭后自动刷新」说明:插件已热挂进运行中 loader(安装)、或卸载后
110
110
  * 插件面板仍残留在当前页面(卸载)。关闭弹窗即整页刷新,不给手动刷新按钮。 */
111
111
  const [installNeedsReload, setInstallNeedsReload] = useState(false)
112
+ /** 市场自更新成功、但 profile 的 bundle 列表里已不含 dsh-plugin:结果页提示重新勾选 */
113
+ const [installBundleDeselected, setInstallBundleDeselected] = useState(false)
112
114
  const [uninstallNeedsReload, setUninstallNeedsReload] = useState(false)
113
115
  /** 结果视图「立即重启」:请求宿主重启后进入等待,服务回来后整页刷新 */
114
116
  const [restarting, setRestarting] = useState(false)
@@ -143,10 +145,11 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
143
145
  // 界面语言带给服务端:安装前网络探测等错误消息按用户语言提示(中文界面给中文,英文界面给英文)
144
146
  langKey,
145
147
  refreshInstalled: catalog.refreshInstalled,
146
- onInstallDone: (viaModal, repo, needsRestart, update, needsReload) => {
148
+ onInstallDone: (viaModal, repo, needsRestart, update, needsReload, bundleDeselected) => {
147
149
  const reload = needsReload === true
148
150
  setInstallNeedsRestart(needsRestart)
149
151
  setInstallNeedsReload(reload)
152
+ setInstallBundleDeselected(bundleDeselected === true)
150
153
  if (viaModal) setInstallDone(true)
151
154
  // 弹窗外的后台安装:给结果 toast;需要页面重载时(热挂载的客户端 UI)读完后自动刷新
152
155
  else setToast({
@@ -661,10 +664,12 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
661
664
  submitting: queue.submitting,
662
665
  needsRestart: installNeedsRestart,
663
666
  needsReload: installNeedsReload,
667
+ bundleDeselected: installBundleDeselected,
664
668
  // 结果弹窗关闭即生效:安装热挂载的插件要页面重载才渲染客户端 UI → 自动刷新,无需用户点按钮
665
669
  onClose: () => {
666
670
  const reload = installDone && installNeedsReload
667
671
  setConfirmPlugin(null)
672
+ setInstallBundleDeselected(false)
668
673
  if (reload) window.location.reload()
669
674
  },
670
675
  onCopy: () => copyCommand(confirmPlugin),
@@ -13,7 +13,7 @@ import { siteUrl } from '../../logic/urls.ts'
13
13
  import { useIncrementalList } from '../../hooks/useIncrementalList.ts'
14
14
  import { PluginCard } from './PluginCard.tsx'
15
15
 
16
- export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, category, copied, installedName, installedVersion, hasUpdate, langKey, onInstall, onUninstall, hasProxy, onOpenDiagnostics }: {
16
+ export function CatalogList({ plugins, failed, visible, total, t, langPath, reload, forceReload, category, copied, installedName, installedVersion, hasUpdate, langKey, onInstall, onUninstall, hasProxy, onOpenDiagnostics }: {
17
17
  plugins: HubPlugin[] | null
18
18
  failed: boolean
19
19
  visible: HubPlugin[]
@@ -21,6 +21,8 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
21
21
  t: Translate
22
22
  langPath: string
23
23
  reload: () => void
24
+ /** 跳过服务端缓存强制回源:缓存数据异常或弱网重试时用 */
25
+ forceReload: () => void
24
26
  category: string
25
27
  copied: string | null
26
28
  installedName: (p: HubPlugin) => string | null
@@ -52,6 +54,8 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
52
54
  h('div', { className: styles.stateDesc }, t(hasProxy ? 'failedDescProxy' : 'failedDescNoProxy')),
53
55
  h('div', { className: styles.stateActions },
54
56
  h('button', { className: styles.retryBtn, onClick: () => reload() }, t('retry')),
57
+ // 强制刷新:跳过服务端缓存直接回源,缓存异常/弱网时可绕过旧数据重新拉取
58
+ h('button', { className: styles.retryBtn, onClick: () => forceReload(), title: t('forceReloadHint') }, t('forceReload')),
55
59
  h('button', { className: styles.diagBtn, onClick: onOpenDiagnostics }, t('failNetworkRunDiag')),
56
60
  ),
57
61
  ),
@@ -36,11 +36,12 @@ export function SectionTabs({ view, setView, installedCount, t, noticeCount, onO
36
36
  role: 'tab',
37
37
  'aria-selected': view === id,
38
38
  title: t(hintKey),
39
+ 'aria-label': t(labelKey),
39
40
  className: view === id ? styles.tabActive : styles.tab,
40
41
  onClick: () => setView(id),
41
42
  },
42
43
  h('span', { className: styles.tabIcon }, h(Icon)),
43
- t(labelKey),
44
+ h('span', { className: styles.tabLabel }, t(labelKey)),
44
45
  id === 'installed' && installedCount > 0
45
46
  ? h('span', { className: view === id ? styles.tabCountActive : styles.tabCount }, installedCount)
46
47
  : null)),
@@ -25,7 +25,7 @@ import type { FailureAction } from '../../logic/failures.ts'
25
25
  * - 其余(含 needsReload=true 的「已热挂载、仅剩客户端 UI」)→ 仅「完成」按钮:关闭结果弹窗时
26
26
  * 上层自动刷新页面让插件生效,不再给「稍后/立即刷新」手动选择。 */
27
27
  function ResultView({
28
- title, desc, t, restarting, needsRestart, needsReload, onRestart, onClose,
28
+ title, desc, t, restarting, needsRestart, needsReload, bundleDeselected, onRestart, onClose,
29
29
  }: {
30
30
  title: string
31
31
  desc: string
@@ -34,6 +34,8 @@ function ResultView({
34
34
  needsRestart: boolean
35
35
  /** 仅刷新页面即生效(插件已热挂载):给一句自动刷新说明,按钮仍是「完成」 */
36
36
  needsReload?: boolean
37
+ /** 市场自更新成功、但 bundle 被取消勾选:重启后市场不会出现,提示重新勾选 */
38
+ bundleDeselected?: boolean
37
39
  onRestart: () => void
38
40
  onClose: () => void
39
41
  }) {
@@ -56,6 +58,7 @@ function ResultView({
56
58
  ),
57
59
  h('div', { className: styles.resultTitle }, title),
58
60
  desc ? h('div', { className: styles.resultDesc }, desc) : null,
61
+ bundleDeselected ? h('div', { className: styles.bundleDeselectedHint }, t('bundleDeselectedHint')) : null,
59
62
  needsRestart
60
63
  ? h('div', null, [
61
64
  h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
@@ -95,6 +98,8 @@ export interface InstallModalProps {
95
98
  needsRestart: boolean
96
99
  /** 完成结果是否只需刷新页面即生效(插件已热挂载):true → 结果视图显示自动刷新说明 */
97
100
  needsReload: boolean
101
+ /** 市场自更新成功、但 profile 的 bundle 列表已不含 dsh-plugin:结果页提示重新勾选 */
102
+ bundleDeselected?: boolean
98
103
  onClose: () => void
99
104
  onCopy: () => void
100
105
  onInstall: () => void
@@ -107,7 +112,7 @@ export interface InstallModalProps {
107
112
  * 目录插件与命令行安装(customTarget 模式)共用同一套确认/进度/结果流程。
108
113
  */
109
114
  export function InstallModal(props: InstallModalProps) {
110
- const { plugin, customTarget, globalNpm, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, needsReload, onClose, onCopy, onInstall, onRestart } = props
115
+ const { plugin, customTarget, globalNpm, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, needsReload, bundleDeselected, onClose, onCopy, onInstall, onRestart } = props
111
116
  const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
112
117
  // 进行中标题带上插件名(中文「XX 插件安装中」;英文状态词在前更自然),并用状态色区分;
113
118
  // 命令行安装无插件元数据 → 直接用输入的目标展示;全局 npm 安装 → 包列表空格拼接
@@ -148,6 +153,7 @@ export function InstallModal(props: InstallModalProps) {
148
153
  restarting,
149
154
  needsRestart,
150
155
  needsReload,
156
+ bundleDeselected,
151
157
  onRestart,
152
158
  onClose,
153
159
  })
@@ -53,6 +53,7 @@ export function MarketView({ catalog, t, langPath, langKey, copied, resultText,
53
53
  t,
54
54
  langPath,
55
55
  reload: catalog.reload,
56
+ forceReload: catalog.forceReload,
56
57
  category: catalog.category,
57
58
  copied,
58
59
  installedName: catalog.installedName,
@@ -12,8 +12,9 @@ import { normalize } from '../logic/normalize.ts'
12
12
  // 目录/统计数据经服务端 /catalog 代理路由获取(curl 子进程注入设置里的代理),
13
13
  // 与 npm / git 安装通道同一代理口径 —— 浏览器不直连 api.dsh-plugin.org。
14
14
  const PROXY_BASE = '/dsh-plugin-hub/catalog'
15
- const PLUGINS_URL = (lang: LocaleId) => `${PROXY_BASE}?lang=${lang}`
16
- const STATS_URL = `${PROXY_BASE}?stats=1`
15
+ // refresh=1:跳过服务端 1 小时缓存直接回源(失败仍回退旧缓存),供「强制刷新」使用
16
+ const PLUGINS_URL = (lang: LocaleId, refresh: boolean) => `${PROXY_BASE}?lang=${lang}${refresh ? '&refresh=1' : ''}`
17
+ const STATS_URL = (refresh: boolean) => `${PROXY_BASE}?stats=1${refresh ? '&refresh=1' : ''}`
17
18
 
18
19
  async function fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
19
20
  const res = await fetch(url, { cache: 'no-store', signal })
@@ -22,15 +23,16 @@ async function fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
22
23
  }
23
24
 
24
25
  /** 目录插件列表(在线 API 已只返回 verified;过滤与排序由上层负责)。
25
- * signal 可选:宿主重启/代理抖动导致请求挂起时,上层可中止旧请求释放连接。 */
26
- export async function fetchCatalog(lang: LocaleId, signal?: AbortSignal): Promise<HubPlugin[]> {
27
- const data = await fetchJson(PLUGINS_URL(lang), signal)
26
+ * signal 可选:宿主重启/代理抖动导致请求挂起时,上层可中止旧请求释放连接。
27
+ * refresh=true 跳过服务端缓存,强制回源。 */
28
+ export async function fetchCatalog(lang: LocaleId, signal?: AbortSignal, refresh = false): Promise<HubPlugin[]> {
29
+ const data = await fetchJson(PLUGINS_URL(lang, refresh), signal)
28
30
  return (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
29
31
  }
30
32
 
31
33
  /** 收录/精选统计(/api/stats.json);字段不完整返回 null。 */
32
- export async function fetchStats(signal?: AbortSignal): Promise<{ total: number; verified: number } | null> {
33
- const s = await fetchJson(STATS_URL, signal) as { total?: number; verified?: number }
34
+ export async function fetchStats(signal?: AbortSignal, refresh = false): Promise<{ total: number; verified: number } | null> {
35
+ const s = await fetchJson(STATS_URL(refresh), signal) as { total?: number; verified?: number }
34
36
  if (s && typeof s.total === 'number' && typeof s.verified === 'number') {
35
37
  return { total: s.total, verified: s.verified }
36
38
  }
@@ -52,10 +52,12 @@ export function useCatalog(lang: LocaleId) {
52
52
  * 界面停在「正在加载插件数据…」假死 —— 超时自动重拉,最多自动重试 2 次后转失败态;
53
53
  * 用户手动刷新(reload)时清零重新计数。 */
54
54
  const loadRetriesRef = useRef(0)
55
- /** 目录加载超时阈值:超过即判定挂起,强制刷新一次(服务端代理 curl 上限 20s,客户端 12s 先兜底) */
56
- const LOAD_TIMEOUT_MS = 12000
57
- /** 挂起后最多自动重试次数:避免代理持续不通时无限循环刷新(每次重试仍 12s,2 次后转失败界面可手动重试) */
55
+ /** 目录加载超时阈值:超过即判定挂起,强制刷新一次(服务端代理 curl 上限 60s,客户端 30s 先兜底) */
56
+ const LOAD_TIMEOUT_MS = 30000
57
+ /** 挂起后最多自动重试次数:避免代理持续不通时无限循环刷新(每次重试仍 30s,2 次后转失败界面可手动重试) */
58
58
  const MAX_LOAD_RETRIES = 2
59
+ /** 本次 reload 是否强制回源(跳过服务端 1 小时缓存):由「强制刷新」置位,读取后立即复位 */
60
+ const forceRefreshRef = useRef(false)
59
61
 
60
62
  /** 点击排序按钮:同一按钮切换正/倒序,新按钮用默认方向 */
61
63
  function toggleSort(key: SortKey) {
@@ -99,12 +101,14 @@ export function useCatalog(lang: LocaleId) {
99
101
  // 中止控制器:请求挂起时(宿主重启/代理抖动)超时自动重试会换新请求,
100
102
  // 旧请求在此中止释放连接,避免挂起堆积
101
103
  const controller = new AbortController()
104
+ const refresh = forceRefreshRef.current
105
+ forceRefreshRef.current = false
102
106
  setPlugins(null)
103
107
  setHubPlugin(null)
104
108
  setStats(null)
105
109
  setFailed(false)
106
110
  // 此处只拉目录与统计;hub 自身版本由独立 Worker 版本控制中心提供(见 fetchHubUpdate)。
107
- const load = () => Promise.all([fetchCatalog(lang, controller.signal), fetchStats(controller.signal)])
111
+ const load = () => Promise.all([fetchCatalog(lang, controller.signal, refresh), fetchStats(controller.signal, refresh)])
108
112
  load()
109
113
  .then(([list, stats]) => {
110
114
  if (cancelled) return
@@ -321,6 +325,8 @@ export function useCatalog(lang: LocaleId) {
321
325
  stats,
322
326
  failed,
323
327
  reload: () => { loadRetriesRef.current = 0; setReloadKey((k) => k + 1) },
328
+ /** 强制刷新:跳过服务端 1 小时缓存直接回源,成功后覆盖缓存(失败仍回退旧缓存) */
329
+ forceReload: () => { loadRetriesRef.current = 0; forceRefreshRef.current = true; setReloadKey((k) => k + 1) },
324
330
  installed,
325
331
  installedItems,
326
332
  installedName,
@@ -54,6 +54,8 @@ export interface QueueTask {
54
54
  /** 完成结果是否只需刷新页面即生效(插件已热挂进运行中 loader,或卸载后插件面板仍残留在当前页面):
55
55
  * true 时关闭结果弹窗 / Toast 消失后由上层自动刷新页面,不给手动刷新按钮。 */
56
56
  needsReload?: boolean
57
+ /** 市场自更新成功、但 profile 的 bundle 列表里已不含 dsh-plugin:重启后市场不会出现 */
58
+ bundleDeselected?: boolean
57
59
  }
58
60
 
59
61
  /** 客户端待重启项:镜像服务端内存列表 + 本地补齐的展示信息(简介/版本)。 */
@@ -76,7 +78,7 @@ export interface TaskQueueOptions {
76
78
  * needsRestart 表示该任务完成后是否仍需宿主重启(结果视图据此给「重启 / 仅完成」);
77
79
  * needsReload 表示是否只需刷新页面即生效(热挂载成功、或卸载后插件面板仍残留)。
78
80
  * update 表示实际执行的是更新(覆盖重装)而非首次安装:通知中心据此显示「更新成功」而非「安装成功」。 */
79
- onInstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean, update?: boolean, needsReload?: boolean) => void
81
+ onInstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean, update?: boolean, needsReload?: boolean, bundleDeselected?: boolean) => void
80
82
  onUninstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean, needsReload?: boolean) => void
81
83
  /** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)+ 实际执行的安装命令(issue 预填用,可缺省)+ 实际执行/重试过的安装命令(可缺省)+ 是否更新。 */
82
84
  onError: (message: string, repo: string | null, kind: 'install' | 'uninstall', command?: string, attempts?: string[], update?: boolean) => void
@@ -212,7 +214,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
212
214
  // 同时缓存展示信息,待重启列表(服务端已登记)合并时直接补齐简介/版本
213
215
  void syncInstalledVersion(q.repo, q.version, q.updatedAt)
214
216
  pendingInfoRef.current.set(q.target, { desc: q.desc, version: q.version })
215
- onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart, q.action === 'update', q.needsReload ?? false)
217
+ onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart, q.action === 'update', q.needsReload ?? false, q.bundleDeselected === true)
216
218
  }
217
219
  } else {
218
220
  // 失败:完整展示全部输出行(最新在前,逆序为日志阅读顺序),不裁剪
@@ -257,20 +259,22 @@ export function useTaskQueue(opts: TaskQueueOptions) {
257
259
  let status = 'failed'
258
260
  let lines: string[] = []
259
261
  let attempts: string[] | undefined
262
+ let bundleDeselected: boolean | undefined
260
263
  try {
261
264
  const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
262
265
  if (res.ok) {
263
- const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown; needsReload?: unknown } }
266
+ const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown; needsReload?: unknown; bundleDeselected?: unknown } }
264
267
  const task = data.task
265
268
  if (task !== undefined) {
266
269
  status = task.status ?? 'failed'
267
270
  lines = task.lines ?? []
268
271
  attempts = Array.isArray(task.attempts) ? task.attempts : undefined
272
+ bundleDeselected = task.bundleDeselected === true ? true : undefined
269
273
  // 终态带出的「是否需要重启 / 是否只需刷新」覆盖本地(服务端 done 时按 loader 热挂/即时移除情况设置)
270
274
  const needsRestart = typeof task.needsRestart === 'boolean' ? task.needsRestart : q.needsRestart
271
275
  const needsReload = typeof task.needsReload === 'boolean' ? task.needsReload : (q.needsReload ?? false)
272
276
  if (status === 'done') {
273
- const settled = { ...q, needsRestart, needsReload }
277
+ const settled = { ...q, needsRestart, needsReload, bundleDeselected: bundleDeselected === true ? true : q.bundleDeselected }
274
278
  // 卸载成功走进度过渡动画;安装成功直接收尾
275
279
  if (q.kind === 'uninstall') settleDone(settled, lines)
276
280
  else finishQueueTask(true, settled, lines)
@@ -297,7 +301,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
297
301
  const res = await fetch('/dsh-plugin-hub/active', { cache: 'no-store' })
298
302
  if (!res.ok) throw new Error(`active ${res.status}`)
299
303
  const data = await res.json() as {
300
- tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown; installChannel?: unknown }[]
304
+ tasks?: { id?: unknown; action?: unknown; target?: unknown; status?: unknown; progress?: unknown; lines?: unknown; attempts?: unknown; needsRestart?: unknown; installChannel?: unknown; bundleDeselected?: unknown }[]
301
305
  pendingRestarts?: unknown
302
306
  }
303
307
  const active = (data.tasks ?? []).filter((a) => typeof a.id === 'number')
@@ -327,6 +331,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
327
331
  lines: Array.isArray(a.lines) ? (a.lines as string[]) : (prevTask?.lines ?? []),
328
332
  attempts: Array.isArray(a.attempts) ? (a.attempts as string[]) : (prevTask?.attempts ?? []),
329
333
  needsRestart: typeof a.needsRestart === 'boolean' ? a.needsRestart : (prevTask?.needsRestart ?? true),
334
+ bundleDeselected: a.bundleDeselected === true || prevTask?.bundleDeselected === true,
330
335
  // 入口渠道:本地乐观入队已带;服务端 /active 也带回(刷新恢复用),服务端优先。
331
336
  // 旧服务端可能回传已废弃的 'git'/'dsh' 渠道,忽略即可、回退本地值。
332
337
  channel: a.installChannel === 'npm' ? 'npm' : prevTask?.channel,