dsh-plugin 2.0.4 → 2.0.6
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.en.md +2 -0
- package/README.md +2 -0
- package/client/client.js +382 -332
- package/client/client.js.map +1 -1
- package/lib/http/catalog.js +22 -3
- package/lib/http/diagnostics.js +36 -9
- package/lib/services/install/preflight.js +36 -0
- package/lib/services/install/task-queue.js +24 -19
- package/lib/services/probe.js +10 -5
- package/lib/services/profile/profile.js +14 -0
- package/lib/types/services/install/install-types.d.ts +3 -1
- package/lib/types/services/install/preflight.d.ts +11 -0
- package/lib/types/services/probe.d.ts +2 -1
- package/lib/types/services/profile/profile.d.ts +3 -0
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +6 -1
- package/src/client/components/catalog/CatalogList.tsx +5 -1
- package/src/client/components/layout/SectionTabs.tsx +2 -1
- package/src/client/components/modals/modals.tsx +8 -2
- package/src/client/components/views/MarketView.tsx +1 -0
- package/src/client/data/catalog.ts +9 -7
- package/src/client/hooks/useCatalog.ts +10 -4
- package/src/client/hooks/useTaskQueue.ts +10 -5
- package/src/client/locales.ts +12 -4
- package/src/client/logic/failures.ts +12 -3
- package/src/client/logic/installed.ts +10 -6
- package/src/client/styles/Modal.module.css +9 -0
- package/src/client/styles/SectionTabs.module.css +18 -0
- package/src/server/http/catalog.ts +24 -3
- package/src/server/http/diagnostics.ts +36 -10
- package/src/server/services/install/install-types.ts +3 -1
- package/src/server/services/install/preflight.ts +37 -0
- package/src/server/services/install/task-queue.ts +23 -18
- package/src/server/services/probe.ts +10 -5
- package/src/server/services/profile/profile.ts +15 -0
package/lib/http/catalog.js
CHANGED
|
@@ -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
|
|
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
|
|
123
|
+
const r = await fetchCatalogOnce(target, proxy);
|
|
105
124
|
if (!r.ok || r.body === '') {
|
|
106
125
|
// 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
|
|
107
126
|
// 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
|
package/lib/http/diagnostics.js
CHANGED
|
@@ -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
|
-
//
|
|
117
|
-
const registry = settings.npmRegistry.replace(/\/+$/, '') ||
|
|
118
|
-
// 连通性自检打各通道的轻量资源:npm 源拉包元数据、GitHub
|
|
119
|
-
//
|
|
120
|
-
// npm 行 display
|
|
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
|
-
|
|
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 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 安装前置探测:命令是否存在、安装目标是否需要 git。
|
|
3
|
+
*
|
|
4
|
+
* 命令缺失时各平台报错形态不同(Windows 只回 GBK 乱码、POSIX 才抛 spawn ENOENT),
|
|
5
|
+
* 统一在 spawn 前用 which/where 探测,缺失则打 ASCII 标记,前端归类不受编码影响。
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
/** 缺 git 的失败标记:前端按 `[git-missing]` 归类并给「安装 git」指引(乱码免疫)。 */
|
|
9
|
+
export const GIT_MISSING_DETAIL = '[git-missing] the git CLI was not found on this machine (install git and add it to the PATH, then retry)';
|
|
10
|
+
/** 命令存在性探测:which/where 找 PATH 里的命令。
|
|
11
|
+
* 探测自身异常(which/where 不可用等)返回 true,不拦截安装,交给装后校验兜底。 */
|
|
12
|
+
export function commandOnPath(cmd, env) {
|
|
13
|
+
try {
|
|
14
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
15
|
+
const res = spawnSync(probe, [cmd], { env, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
16
|
+
return res.status === 0 && (res.stdout ?? '').trim().length > 0;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** 该安装目标是否需要 git:github: / git+ / git@ 与 owner/repo 简写都要先 clone;
|
|
23
|
+
* release 直链(https://…releases/download/…tgz)由 pnpm 直接下载,不需要 git。
|
|
24
|
+
* owner/repo 段首必须是字母数字:`./x`、`../x` 这类相对路径是本地目录,不能误判。 */
|
|
25
|
+
export function needsGit(target) {
|
|
26
|
+
const v = target.trim();
|
|
27
|
+
if (/^(github:|git\+|git@)/i.test(v))
|
|
28
|
+
return true;
|
|
29
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/.test(v);
|
|
30
|
+
}
|
|
31
|
+
/** git 源安装前置检查:需要 git 但本机 PATH 里没有 → 返回 [git-missing] 文本;否则 null。 */
|
|
32
|
+
export function gitMissingDetail(target, env) {
|
|
33
|
+
if (!needsGit(target))
|
|
34
|
+
return null;
|
|
35
|
+
return commandOnPath('git', env) ? null : GIT_MISSING_DETAIL;
|
|
36
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 每个任务一次 `dsh plugin --profile <profile> <action> <target>` 子进程,
|
|
8
8
|
* 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
|
|
9
9
|
*/
|
|
10
|
-
import { spawn
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
11
|
import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.js';
|
|
12
12
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -18,8 +18,9 @@ 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
|
+
import { commandOnPath, gitMissingDetail } from './preflight.js';
|
|
23
24
|
/** 内存任务注册表,按任务 id 索引。 */
|
|
24
25
|
const tasks = new Map();
|
|
25
26
|
let nextTaskId = 1;
|
|
@@ -194,22 +195,6 @@ export function desktopCliBootstrap(entry) {
|
|
|
194
195
|
}
|
|
195
196
|
return null;
|
|
196
197
|
}
|
|
197
|
-
/**
|
|
198
|
-
* dsh 命令存在性探测:which/where 找 PATH 里的 dsh。
|
|
199
|
-
* 命令缺失时各平台报错形态不同(Windows shell 只回 GBK 乱码、POSIX 才抛 spawn ENOENT),
|
|
200
|
-
* 统一在 spawn 前用 which/where 探测,命令不存在则直接失败并打 ASCII 标记 [dsh-missing](乱码免疫)。
|
|
201
|
-
* 探测自身异常(which/where 不可用等)不拦截安装,交给装后校验兜底。
|
|
202
|
-
*/
|
|
203
|
-
function dshOnPath(env) {
|
|
204
|
-
try {
|
|
205
|
-
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
206
|
-
const res = spawnSync(probe, ['dsh'], { env, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
207
|
-
return res.status === 0 && (res.stdout ?? '').trim().length > 0;
|
|
208
|
-
}
|
|
209
|
-
catch {
|
|
210
|
-
return true;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
198
|
function stopChild(child) {
|
|
214
199
|
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
215
200
|
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
|
@@ -400,7 +385,7 @@ function spawnMutation(options) {
|
|
|
400
385
|
// 独立 CLI 场景(file: 'dsh',宿主加载器走 node bin 不经过这里):先探测 dsh 是否在 PATH。
|
|
401
386
|
// 命令缺失时各平台报错形态不同(GBK 乱码 / spawn ENOENT),统一前置拦截并打 ASCII 标记
|
|
402
387
|
// [dsh-missing],前端据此精准提示「dsh 未加入 PATH」而不是笼统的插件失败。
|
|
403
|
-
if (invocation.file === 'dsh' && !
|
|
388
|
+
if (invocation.file === 'dsh' && !commandOnPath('dsh', env)) {
|
|
404
389
|
const detail = '[dsh-missing] the dsh CLI was not found on this machine (DeepSeek Harness not installed, or dsh not on the PATH)';
|
|
405
390
|
if (task) {
|
|
406
391
|
task.status = 'failed';
|
|
@@ -411,6 +396,21 @@ function spawnMutation(options) {
|
|
|
411
396
|
resolveOnce({ exitCode: 1, timedOut: false, error: detail, stdout: '', stderr: detail });
|
|
412
397
|
return;
|
|
413
398
|
}
|
|
399
|
+
// git 源安装:pnpm 需要调用 git 解析仓库;Windows 缺 git 只回 GBK 乱码,前端认不出来。
|
|
400
|
+
// 前置探测并打 ASCII 标记 [git-missing],给出装 git 的指引而不是笼统的安装失败。
|
|
401
|
+
if (!isGlobalNpm && action !== 'remove') {
|
|
402
|
+
const gitMissing = gitMissingDetail(target, env);
|
|
403
|
+
if (gitMissing !== null) {
|
|
404
|
+
if (task) {
|
|
405
|
+
task.status = 'failed';
|
|
406
|
+
task.exitCode = null;
|
|
407
|
+
task.progress = 0;
|
|
408
|
+
pushLine(task, gitMissing);
|
|
409
|
+
}
|
|
410
|
+
resolveOnce({ exitCode: 1, timedOut: false, error: gitMissing, stdout: '', stderr: gitMissing });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
414
|
child = spawn(invocation.file, attemptArgs, {
|
|
415
415
|
cwd: invocation.cwd,
|
|
416
416
|
// invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
|
|
@@ -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;
|
package/lib/services/probe.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** 缺 git 的失败标记:前端按 `[git-missing]` 归类并给「安装 git」指引(乱码免疫)。 */
|
|
2
|
+
export declare const GIT_MISSING_DETAIL = "[git-missing] the git CLI was not found on this machine (install git and add it to the PATH, then retry)";
|
|
3
|
+
/** 命令存在性探测:which/where 找 PATH 里的命令。
|
|
4
|
+
* 探测自身异常(which/where 不可用等)返回 true,不拦截安装,交给装后校验兜底。 */
|
|
5
|
+
export declare function commandOnPath(cmd: string, env?: NodeJS.ProcessEnv): boolean;
|
|
6
|
+
/** 该安装目标是否需要 git:github: / git+ / git@ 与 owner/repo 简写都要先 clone;
|
|
7
|
+
* release 直链(https://…releases/download/…tgz)由 pnpm 直接下载,不需要 git。
|
|
8
|
+
* owner/repo 段首必须是字母数字:`./x`、`../x` 这类相对路径是本地目录,不能误判。 */
|
|
9
|
+
export declare function needsGit(target: string): boolean;
|
|
10
|
+
/** git 源安装前置检查:需要 git 但本机 PATH 里没有 → 返回 [git-missing] 文本;否则 null。 */
|
|
11
|
+
export declare function gitMissingDetail(target: string, env?: NodeJS.ProcessEnv): string | null;
|
|
@@ -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.
|
|
3
|
+
"version": "2.0.6",
|
|
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
|
})
|
|
@@ -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
|
-
|
|
16
|
-
const
|
|
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
|
-
|
|
27
|
-
|
|
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 上限
|
|
56
|
-
const LOAD_TIMEOUT_MS =
|
|
57
|
-
/** 挂起后最多自动重试次数:避免代理持续不通时无限循环刷新(每次重试仍
|
|
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,
|