dsh-plugin 1.4.6 → 1.4.7

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.
@@ -84,12 +84,13 @@ const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000;
84
84
  function catalogCacheFile(profile, key) {
85
85
  return join(profileDirectory(profile), 'cache', `catalog-${key}.json`);
86
86
  }
87
- /** 读缓存:文件存在、JSON 合法、未过期 → 返回原始响应字符串;否则 null(视为 miss)。 */
88
- function readCatalogCache(file) {
87
+ /** 读缓存:文件存在、JSON 合法、未超过 maxAgeMs → 返回原始响应字符串;否则 null(视为 miss)。
88
+ * maxAgeMs 传 Infinity 即「任意年龄的缓存」,供实时拉取失败时兜底使用。 */
89
+ function readCatalogCache(file, maxAgeMs) {
89
90
  try {
90
91
  const entry = JSON.parse(readFileSync(file, 'utf8'));
91
92
  if (entry && typeof entry.at === 'number' && typeof entry.body === 'string'
92
- && Date.now() - entry.at < CATALOG_CACHE_TTL_MS) {
93
+ && Date.now() - entry.at < maxAgeMs) {
93
94
  return entry.body;
94
95
  }
95
96
  }
@@ -563,8 +564,16 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
563
564
  ? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
564
565
  : await probe(c.url, c.proxy);
565
566
  results.push({ key: c.key, ok: r.ok });
566
- // git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码
567
- send({ type: r.ok ? 'ok' : 'fail', key: c.key, display: c.display, ms: r.ms, status: c.git ? null : r.status });
567
+ // git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码。
568
+ // reason 透出失败细分原因(如 Windows 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
569
+ send({
570
+ type: r.ok ? 'ok' : 'fail',
571
+ key: c.key,
572
+ display: c.display,
573
+ ms: r.ms,
574
+ status: c.git ? null : r.status,
575
+ reason: r.ok ? null : (r.reason ?? null),
576
+ });
568
577
  }
569
578
  send({ type: 'end', at: Date.now() });
570
579
  response.end();
@@ -609,9 +618,10 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
609
618
  sendJson(response, 200, { ok: false, ms: null, status: null, target, reason: 'empty' });
610
619
  return;
611
620
  }
612
- // 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址
621
+ // 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址。
622
+ // reason 带上失败细分原因(如证书吊销受阻),设置页才能提示「不是代理地址的问题」。
613
623
  const r = await probeUrl(target, proxy, 6000);
614
- sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target });
624
+ sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target, reason: r.reason ?? null });
615
625
  },
616
626
  }),
617
627
  webServer.register({
@@ -636,7 +646,7 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
636
646
  : `https://api.dsh-plugin.org/plugins.${lang}.json`;
637
647
  // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
638
648
  const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`);
639
- const cached = readCatalogCache(cacheFile);
649
+ const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS);
640
650
  if (cached !== null) {
641
651
  try {
642
652
  sendJson(response, 200, JSON.parse(cached));
@@ -648,6 +658,18 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
648
658
  }
649
659
  const r = await fetchViaCurl(target, proxy, 20000);
650
660
  if (!r.ok || r.body === '') {
661
+ // 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
662
+ // 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
663
+ const stale = readCatalogCache(cacheFile, Number.POSITIVE_INFINITY);
664
+ if (stale !== null) {
665
+ try {
666
+ sendJson(response, 200, JSON.parse(stale));
667
+ return;
668
+ }
669
+ catch {
670
+ // 兜底缓存也损坏:落到下面的 502
671
+ }
672
+ }
651
673
  sendJson(response, 502, { error: 'catalog fetch failed' });
652
674
  return;
653
675
  }
@@ -14,6 +14,38 @@
14
14
  */
15
15
  import { spawn, spawnSync } from 'node:child_process';
16
16
  const probeFail = () => ({ ok: false, ms: null, status: null });
17
+ /**
18
+ * Windows 的 curl 走 Schannel 做 TLS:握手阶段强制获取证书吊销信息(CRL/OCSP),
19
+ * 代理受限或吊销服务不可达时整个握手直接失败(CRYPT_E_NO_REVOCATION_CHECK),
20
+ * 表现为 `curl: (35) schannel: next InitializeSecurityContext failed` —— 而 Node/npm/
21
+ * 浏览器各有自己的 TLS 实现,照常可用,用户只会看到「插件市场打不开」。
22
+ * --ssl-revoke-best-effort(curl ≥ 7.70)把吊销检查降级为尽力而为,握手不再被它拦死;
23
+ * 其它平台后端(SecureTransport / OpenSSL)没有这个强制行为,不加参数。
24
+ * 运行时读 process.platform(而非模块级常量),便于测试覆盖两条分支。
25
+ */
26
+ export function curlTlsArgs() {
27
+ return process.platform === 'win32' ? ['--ssl-revoke-best-effort'] : [];
28
+ }
29
+ /** 本机 curl 太老、不认识 --ssl-revoke-best-effort(curl < 7.70):退出码 2 + stderr 指向该参数。 */
30
+ export function curlRevokeFlagUnsupported(code, stderr) {
31
+ return code === 2 && /ssl-revoke-best-effort/i.test(stderr) && /unknown|unrecognized/i.test(stderr);
32
+ }
33
+ /** stderr 是否指向 Schannel 取不到证书吊销信息(据此归因为环境 TLS 问题,而非网络不通)。 */
34
+ export function tlsRevocationBlocked(stderr) {
35
+ return /CRYPT_E_NO_REVOCATION_CHECK|CRYPT_E_REVOCATION_OFFLINE|0x80092012/i.test(stderr);
36
+ }
37
+ /** 探测/安装同一套代理口径:curl 与 git 子进程都读 HTTP(S)_PROXY 环境变量。 */
38
+ function proxyEnv(proxy) {
39
+ if (proxy === '')
40
+ return { ...process.env };
41
+ return {
42
+ ...process.env,
43
+ HTTP_PROXY: proxy,
44
+ HTTPS_PROXY: proxy,
45
+ http_proxy: proxy,
46
+ https_proxy: proxy,
47
+ };
48
+ }
17
49
  /** macOS 系统代理:scutil --proxy 输出里的 HTTP(S) 代理;未开启返回 null。 */
18
50
  function macSystemProxy() {
19
51
  try {
@@ -69,46 +101,65 @@ export function systemProxy() {
69
101
  return winSystemProxy();
70
102
  return null;
71
103
  }
72
- /**
73
- * curl 子进程探测:spawn curl -s -w '%{http_code}' 并注入代理 env。
74
- * curl 读 HTTP_PROXY/HTTPS_PROXY 是原生行为,与 pnpm/git 安装通道同一套代理口径;
75
- * 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
76
- */
77
- function curlProbe(url, proxy, timeoutMs) {
104
+ /** 跑一次 curl 子进程,收集 stdout 与 stderr(stderr 是识别 Schannel 类 TLS 失败的唯一线索)。 */
105
+ function runCurl(args, env, timeoutMs) {
78
106
  return new Promise((resolve) => {
79
- const started = Date.now();
80
- const env = { ...process.env };
81
- if (proxy !== '') {
82
- env.HTTP_PROXY = proxy;
83
- env.HTTPS_PROXY = proxy;
84
- env.http_proxy = proxy;
85
- env.https_proxy = proxy;
86
- }
87
- const discard = process.platform === 'win32' ? 'NUL' : '/dev/null';
88
- const child = spawn('curl', [
89
- '-s', '-o', discard, '-w', '%{http_code}',
90
- '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
91
- url,
92
- ], { env });
93
- let code = '';
94
- child.stdout.on('data', (c) => { code += c.toString(); });
107
+ let stdout = '';
108
+ let stderr = '';
95
109
  let done = false;
96
- const finish = (r) => {
110
+ const finish = (code) => {
97
111
  if (done)
98
112
  return;
99
113
  done = true;
100
114
  clearTimeout(timer);
101
- resolve(r);
115
+ resolve({ code, stdout, stderr });
102
116
  };
103
- child.on('error', () => finish(probeFail()));
104
- child.on('close', () => {
105
- const status = /^\d+$/.test(code.trim()) ? Number(code.trim()) : null;
106
- finish({ ok: status !== null && status >= 100 && status < 400, ms: Date.now() - started, status });
107
- });
117
+ const child = spawn('curl', args, { env });
118
+ child.stdout.on('data', (c) => { stdout += c.toString(); });
119
+ child.stderr.on('data', (c) => { stderr += c.toString(); });
120
+ child.on('error', () => finish(null));
121
+ child.on('close', (code) => finish(code));
108
122
  // 超时兜底:curl --max-time 到点自行退出,此处再设一个进程级超时防止悬挂
109
- const timer = setTimeout(() => { child.kill(); finish(probeFail()); }, timeoutMs + 1000);
123
+ const timer = setTimeout(() => { child.kill(); finish(null); }, timeoutMs + 1000);
110
124
  });
111
125
  }
126
+ /** 带 TLS 兜底的 curl 调用:先按平台参数跑(Windows 带吊销检查降级),
127
+ * 若本机 curl 不认这个参数(curl < 7.70)则去掉参数重跑一次,保证老版本不因此直接失败。 */
128
+ async function curlWithTlsFallback(args, env, timeoutMs) {
129
+ const tlsArgs = curlTlsArgs();
130
+ const first = await runCurl([...tlsArgs, ...args], env, timeoutMs);
131
+ if (tlsArgs.length === 0 || !curlRevokeFlagUnsupported(first.code, first.stderr))
132
+ return first;
133
+ return runCurl(args, env, timeoutMs);
134
+ }
135
+ /**
136
+ * curl 子进程探测:spawn curl -s -w '%{http_code}' 并注入代理 env。
137
+ * curl 读 HTTP_PROXY/HTTPS_PROXY 是原生行为,与 pnpm/git 安装通道同一套代理口径;
138
+ * 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
139
+ * 证书吊销检查受阻(Windows Schannel)另记 reason,供诊断给出对症提示。
140
+ */
141
+ async function curlProbe(url, proxy, timeoutMs) {
142
+ const started = Date.now();
143
+ const discard = process.platform === 'win32' ? 'NUL' : '/dev/null';
144
+ // -sS(而非仅 -s):silent 会把连接/TLS 错误一并吞掉,而识别证书吊销受阻正需要 stderr;
145
+ // 子进程 stderr 由我们收集、不会回显给用户,所以补 -S 不产生噪音。
146
+ const r = await curlWithTlsFallback([
147
+ '-sS', '-o', discard, '-w', '%{http_code}',
148
+ '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
149
+ url,
150
+ ], proxyEnv(proxy), timeoutMs);
151
+ // 子进程没起来 / 超时被杀:没有任何可诊断信息,按不可达处理
152
+ if (r.code === null)
153
+ return probeFail();
154
+ const status = /^\d+$/.test(r.stdout.trim()) ? Number(r.stdout.trim()) : null;
155
+ const ok = status !== null && status >= 100 && status < 400;
156
+ return {
157
+ ok,
158
+ ms: Date.now() - started,
159
+ status,
160
+ reason: !ok && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
161
+ };
162
+ }
112
163
  /**
113
164
  * 探测一个 HTTPS 目标:proxy 非空注入给 curl 子进程走代理,否则直连。
114
165
  * 供系统诊断 /diagnostics 使用;目标 URL 非法返回不可达。
@@ -136,13 +187,7 @@ export function probeUrl(url, proxy, timeoutMs) {
136
187
  export function gitLsRemote(url, proxy, timeoutMs) {
137
188
  return new Promise((resolve) => {
138
189
  const started = Date.now();
139
- const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
140
- if (proxy !== '') {
141
- env.HTTP_PROXY = proxy;
142
- env.HTTPS_PROXY = proxy;
143
- env.http_proxy = proxy;
144
- env.https_proxy = proxy;
145
- }
190
+ const env = { ...proxyEnv(proxy), GIT_TERMINAL_PROMPT: '0' };
146
191
  const child = spawn('git', ['ls-remote', '--exit-code', url, 'HEAD'], { env });
147
192
  let out = '';
148
193
  child.stdout.on('data', (c) => { out += c.toString(); });
@@ -167,34 +212,18 @@ export function gitLsRemote(url, proxy, timeoutMs) {
167
212
  });
168
213
  }
169
214
  /**
170
- * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入)。
215
+ * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入与 TLS 参数兜底)。
171
216
  * 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
172
217
  * 使「目录数据请求走设置里的代理」与 npm / git 安装通道口径一致。
173
- * 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错。
218
+ * 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错;
219
+ * reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定兜底策略。
174
220
  */
175
221
  export function fetchViaCurl(url, proxy, timeoutMs) {
176
- return new Promise((resolve) => {
177
- const env = { ...process.env };
178
- if (proxy !== '') {
179
- env.HTTP_PROXY = proxy;
180
- env.HTTPS_PROXY = proxy;
181
- env.http_proxy = proxy;
182
- env.https_proxy = proxy;
183
- }
184
- const child = spawn('curl', ['-s', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url], { env });
185
- let body = '';
186
- child.stdout.on('data', (c) => { body += c.toString(); });
187
- let done = false;
188
- const finish = (ok) => {
189
- if (done)
190
- return;
191
- done = true;
192
- clearTimeout(timer);
193
- resolve({ ok, body });
194
- };
195
- child.on('error', () => finish(false));
196
- child.on('close', (code) => finish(code === 0));
197
- // 超时兜底:curl --max-time 到点自行退出,此处再设一个进程级超时防止悬挂
198
- const timer = setTimeout(() => { child.kill(); finish(false); }, timeoutMs + 1000);
199
- });
222
+ // 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)
223
+ const args = ['-sS', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url];
224
+ return curlWithTlsFallback(args, proxyEnv(proxy), timeoutMs).then((r) => ({
225
+ ok: r.code === 0,
226
+ body: r.stdout,
227
+ reason: r.code !== 0 && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
228
+ }));
200
229
  }
@@ -5,7 +5,28 @@ export interface ProbeResult {
5
5
  ms: number | null;
6
6
  /** HTTP 状态码;失败为 null */
7
7
  status: number | null;
8
+ /** 失败细分原因:仅当能确认为环境侧 TLS 问题时才有值,普通不可达为 null */
9
+ reason?: ProbeFailReason | null;
8
10
  }
11
+ /**
12
+ * 不可达的细分原因。把「证书吊销检查被挡」这类本机环境问题与「网络不通」分开,
13
+ * 诊断列表才能给出对症提示,而不是笼统显示不可达。
14
+ */
15
+ export type ProbeFailReason = 'tlsRevocation';
16
+ /**
17
+ * Windows 的 curl 走 Schannel 做 TLS:握手阶段强制获取证书吊销信息(CRL/OCSP),
18
+ * 代理受限或吊销服务不可达时整个握手直接失败(CRYPT_E_NO_REVOCATION_CHECK),
19
+ * 表现为 `curl: (35) schannel: next InitializeSecurityContext failed` —— 而 Node/npm/
20
+ * 浏览器各有自己的 TLS 实现,照常可用,用户只会看到「插件市场打不开」。
21
+ * --ssl-revoke-best-effort(curl ≥ 7.70)把吊销检查降级为尽力而为,握手不再被它拦死;
22
+ * 其它平台后端(SecureTransport / OpenSSL)没有这个强制行为,不加参数。
23
+ * 运行时读 process.platform(而非模块级常量),便于测试覆盖两条分支。
24
+ */
25
+ export declare function curlTlsArgs(): string[];
26
+ /** 本机 curl 太老、不认识 --ssl-revoke-best-effort(curl < 7.70):退出码 2 + stderr 指向该参数。 */
27
+ export declare function curlRevokeFlagUnsupported(code: number | null, stderr: string): boolean;
28
+ /** stderr 是否指向 Schannel 取不到证书吊销信息(据此归因为环境 TLS 问题,而非网络不通)。 */
29
+ export declare function tlsRevocationBlocked(stderr: string): boolean;
9
30
  /**
10
31
  * 操作系统级代理(macOS 系统网络设置 / Windows Internet 设置)。
11
32
  * Node 内置 http(s) 不读系统代理,浏览器挂的代理 Node 看不见 —— 这里读出来
@@ -30,12 +51,14 @@ export declare function probeUrl(url: string, proxy: string, timeoutMs: number):
30
51
  */
31
52
  export declare function gitLsRemote(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult>;
32
53
  /**
33
- * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入)。
54
+ * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入与 TLS 参数兜底)。
34
55
  * 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
35
56
  * 使「目录数据请求走设置里的代理」与 npm / git 安装通道口径一致。
36
- * 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错。
57
+ * 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错;
58
+ * reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定兜底策略。
37
59
  */
38
60
  export declare function fetchViaCurl(url: string, proxy: string, timeoutMs: number): Promise<{
39
61
  ok: boolean;
40
62
  body: string;
63
+ reason: ProbeFailReason | null;
41
64
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.4.6",
3
+ "version": "1.4.7",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 9000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):9000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -37,6 +37,8 @@ interface RowState {
37
37
  status: 'idle' | 'running' | 'ok' | 'fail'
38
38
  ms: number | null
39
39
  statusCode: number | null
40
+ /** 失败细分原因:'tlsRevocation' = 证书吊销检查受阻(本机 TLS 环境问题,不是网络不通) */
41
+ reason?: string | null
40
42
  }
41
43
 
42
44
  const INITIAL_ROWS: RowState[] = [
@@ -74,7 +76,7 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
74
76
  runningRef.current?.abort()
75
77
  const controller = new AbortController()
76
78
  runningRef.current = controller
77
- patchRows(key, { status: 'running', ms: null, statusCode: null })
79
+ patchRows(key, { status: 'running', ms: null, statusCode: null, reason: null })
78
80
 
79
81
  void (async () => {
80
82
  try {
@@ -98,7 +100,7 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
98
100
  const raw = buf.slice(0, nl).trim()
99
101
  buf = buf.slice(nl + 1)
100
102
  if (raw !== '') {
101
- const ev = JSON.parse(raw) as { type?: string; key?: string; ms?: number | null; status?: number | null; display?: string }
103
+ const ev = JSON.parse(raw) as { type?: string; key?: string; ms?: number | null; status?: number | null; display?: string; reason?: string | null }
102
104
  if (ev.type === 'ok' || ev.type === 'fail') {
103
105
  patchRows(ev.key as ProbeKey, {
104
106
  status: ev.type === 'ok' ? 'ok' : 'fail',
@@ -106,6 +108,8 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
106
108
  statusCode: typeof ev.status === 'number' ? ev.status : null,
107
109
  // 服务端带出实际使用的源地址(配置镜像 → 镜像地址;未配置 → 空串)
108
110
  display: typeof ev.display === 'string' ? ev.display : '',
111
+ // 失败细分原因:证书吊销受阻时给对症提示,而非笼统「不可达」
112
+ reason: typeof ev.reason === 'string' ? ev.reason : null,
109
113
  })
110
114
  }
111
115
  }
@@ -155,13 +159,16 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
155
159
  }, t('diagRunAll')),
156
160
  ),
157
161
  rows.map((r) => {
162
+ // 证书吊销检查受阻:badge 与悬浮提示都换成对症文案,避免用户朝「网络不通」方向排查
163
+ const revokeBlocked = r.status === 'fail' && r.reason === 'tlsRevocation'
158
164
  const badge = r.status === 'running'
159
165
  ? h('span', { className: `${styles.badge} ${styles.badgeRunning}` }, t('diagChecking'))
160
166
  : r.status === 'ok'
161
167
  ? h('span', { className: `${styles.badge} ${styles.badgeOk}` },
162
168
  r.statusCode !== null ? `HTTP ${r.statusCode} ${t('diagOk')}` : t('diagOk'))
163
169
  : r.status === 'fail'
164
- ? h('span', { className: `${styles.badge} ${styles.badgeFail}` }, t('settingsDiagFail'))
170
+ ? h('span', { className: `${styles.badge} ${styles.badgeFail}` },
171
+ revokeBlocked ? t('diagFailRevocation') : t('settingsDiagFail'))
165
172
  : h('span', { className: `${styles.badge} ${styles.badgeIdle}` }, t('diagIdle'))
166
173
  return h('button', {
167
174
  key: r.key,
@@ -169,7 +176,7 @@ export function DiagnosticsView({ t, env, proxy, onCopy }: {
169
176
  className: styles.row,
170
177
  disabled: r.status === 'running',
171
178
  onClick: () => run(r.key),
172
- title: t('diagRecheck'),
179
+ title: revokeBlocked ? t('diagFailRevocationHint') : t('diagRecheck'),
173
180
  },
174
181
  h('span', { className: styles.name }, t(r.nameKey)),
175
182
  // npm 行未配置镜像:display 为空 → 显示「未配置(跟随本机)」,不误导成官方源;
@@ -94,6 +94,8 @@ export function SettingsView({ t, settings, update, reset, env, onCopy, openSect
94
94
  // —— HTTP 代理连通性:输入停笔后防抖实时探测(服务端 /proxy-check 用该代理打 github.com)。
95
95
  // 结果只作提示、绝不阻断保存 —— 用户可能是先填地址后开代理,保存后再去开代理完全合法。
96
96
  const [proxyProbe, setProxyProbe] = useState<'idle' | 'checking' | 'ok' | 'fail'>('idle')
97
+ // 失败细分原因(如 'tlsRevocation'):证书吊销受阻时提示「不是代理地址不通」,避免误导排查方向
98
+ const [proxyProbeReason, setProxyProbeReason] = useState<string | null>(null)
97
99
  const proxyProbeTimer = useRef<number | null>(null)
98
100
  const proxyProbeSeq = useRef(0)
99
101
  // 卸载时清掉未触发的防抖计时器,避免卸载后 setState
@@ -103,25 +105,31 @@ export function SettingsView({ t, settings, update, reset, env, onCopy, openSect
103
105
  }, [])
104
106
  // 设置被外部重置(恢复默认/清空代理)时同步清掉探测状态,避免残留过期的「不通」提示
105
107
  useEffect(() => {
106
- if (settings.proxy === '') setProxyProbe('idle')
108
+ if (settings.proxy === '') { setProxyProbe('idle'); setProxyProbeReason(null) }
107
109
  }, [settings.proxy])
108
110
 
109
111
  const probeProxyNow = (value: string) => {
110
112
  const v = value.trim()
111
- if (v === '') { setProxyProbe('idle'); return }
113
+ if (v === '') { setProxyProbe('idle'); setProxyProbeReason(null); return }
112
114
  const seq = ++proxyProbeSeq.current
113
115
  setProxyProbe('checking')
116
+ setProxyProbeReason(null)
114
117
  void fetch('/dsh-plugin-hub/proxy-check', {
115
118
  method: 'POST',
116
119
  headers: { 'content-type': 'application/json' },
117
120
  body: JSON.stringify({ proxy: v }),
118
121
  cache: 'no-store',
119
122
  }).then((res) => res.json())
120
- .then((data: { ok?: boolean }) => {
123
+ .then((data: { ok?: boolean; reason?: string | null }) => {
121
124
  if (seq !== proxyProbeSeq.current) return
122
125
  setProxyProbe(data.ok ? 'ok' : 'fail')
126
+ setProxyProbeReason(typeof data.reason === 'string' ? data.reason : null)
127
+ })
128
+ .catch(() => {
129
+ if (seq !== proxyProbeSeq.current) return
130
+ setProxyProbe('fail')
131
+ setProxyProbeReason(null)
123
132
  })
124
- .catch(() => { if (seq === proxyProbeSeq.current) setProxyProbe('fail') })
125
133
  }
126
134
 
127
135
  const onProxyChange = (e: ChangeEvent<HTMLInputElement>) => {
@@ -191,7 +199,8 @@ export function SettingsView({ t, settings, update, reset, env, onCopy, openSect
191
199
  : proxyProbe === 'ok'
192
200
  ? h('div', { className: `${styles.proxyHint} ${styles.proxyHintOk}` }, t('proxyCheckOk'))
193
201
  : proxyProbe === 'fail'
194
- ? h('div', { className: `${styles.proxyHint} ${styles.proxyHintFail}` }, t('proxyCheckFail'))
202
+ ? h('div', { className: `${styles.proxyHint} ${styles.proxyHintFail}` },
203
+ proxyProbeReason === 'tlsRevocation' ? t('proxyCheckRevocation') : t('proxyCheckFail'))
195
204
  : null,
196
205
  ),
197
206
  }),
@@ -116,6 +116,8 @@ export const zh = {
116
116
  proxyCheckChecking: '正在测试该代理地址…',
117
117
  proxyCheckOk: '测试可达,安装与目录请求将统一走该代理',
118
118
  proxyCheckFail: '此地址测试不通,仍可保存;请确认代理已开启、地址与端口无误',
119
+ // 失败细分原因:Windows Schannel 必须取到证书吊销信息,取不到即中断握手 —— 不是代理地址不通
120
+ proxyCheckRevocation: '证书吊销检查受阻(Windows Schannel 取不到 CRL/OCSP),握手被中断;属本机 TLS 环境问题,非代理地址不通',
119
121
  settingsMirror: 'npm 镜像源',
120
122
  settingsMirrorDesc: '默认跟随本机 npm 配置;官方源慢可换国内镜像',
121
123
  mirrorNone: '未配置(跟随本机 npm 配置)',
@@ -203,6 +205,9 @@ export const zh = {
203
205
  diagSummaryRunning: '正在检测通道…',
204
206
  diagSummaryOk: '全部通道正常',
205
207
  diagSummaryFail: '存在不可达通道,点击对应行可重测',
208
+ // 失败细分原因:Windows Schannel 必须取到证书吊销信息才能握手,取不到即中断(易被误当成网络不通)
209
+ diagFailRevocation: '证书校验受阻',
210
+ diagFailRevocationHint: '系统 curl 取不到证书吊销信息(Windows Schannel 必需 CRL/OCSP),TLS 握手被中断 —— 这不是网络不通,npm/git 可能照常可用。升级 curl 到 7.70+ 可自动按「尽力而为」处理,或换一个能访问吊销服务的网络。',
206
211
  settingsEnvSnapshot: '系统版本',
207
212
  settingsEnvSnapshotDesc: '复制宿主版本与系统信息,提交 Issue 时粘贴到正文便于复现',
208
213
  settingsEnvCopy: '复制',
@@ -483,6 +488,8 @@ export const en = {
483
488
  proxyCheckChecking: 'Testing this proxy address…',
484
489
  proxyCheckOk: 'Reachable — installs and catalog requests will route through it',
485
490
  proxyCheckFail: 'Unreachable, but you can still save it; make sure the proxy is running and the address and port are correct',
491
+ // Failure reason: Windows Schannel aborts the handshake when revocation info (CRL/OCSP) is unreachable — not a bad proxy address
492
+ proxyCheckRevocation: 'Certificate revocation check blocked (Windows Schannel could not fetch CRL/OCSP), so the handshake was aborted — a local TLS issue, not a bad proxy address',
486
493
  settingsMirror: 'npm mirror',
487
494
  settingsMirrorDesc: 'Unset by default — follows local npm config; pick a domestic mirror if slow',
488
495
  mirrorNone: 'Unset (follow local npm config)',
@@ -570,6 +577,9 @@ export const en = {
570
577
  diagSummaryRunning: 'Checking channels…',
571
578
  diagSummaryOk: 'All channels reachable',
572
579
  diagSummaryFail: 'Unreachable channel(s) — click a row to re-check',
580
+ // Failure reason: Windows Schannel aborts the handshake when revocation info (CRL/OCSP) is unreachable (easily mistaken for a network outage)
581
+ diagFailRevocation: 'Cert check blocked',
582
+ diagFailRevocationHint: 'The system curl cannot fetch certificate revocation info (CRL/OCSP, required by Windows Schannel), so the TLS handshake is aborted — this is not a network outage, and npm/git may still work. curl 7.70+ downgrades the check to best-effort, or switch to a network that can reach the revocation service.',
573
583
  settingsEnvSnapshot: 'System version',
574
584
  settingsEnvSnapshotDesc: 'Copy host version and system info for pasting into issue reports',
575
585
  settingsEnvCopy: 'Copy',
@@ -107,12 +107,13 @@ function catalogCacheFile(profile: string, key: string): string {
107
107
  return join(profileDirectory(profile), 'cache', `catalog-${key}.json`)
108
108
  }
109
109
 
110
- /** 读缓存:文件存在、JSON 合法、未过期 → 返回原始响应字符串;否则 null(视为 miss)。 */
111
- function readCatalogCache(file: string): string | null {
110
+ /** 读缓存:文件存在、JSON 合法、未超过 maxAgeMs → 返回原始响应字符串;否则 null(视为 miss)。
111
+ * maxAgeMs 传 Infinity 即「任意年龄的缓存」,供实时拉取失败时兜底使用。 */
112
+ function readCatalogCache(file: string, maxAgeMs: number): string | null {
112
113
  try {
113
114
  const entry = JSON.parse(readFileSync(file, 'utf8')) as CatalogCacheEntry
114
115
  if (entry && typeof entry.at === 'number' && typeof entry.body === 'string'
115
- && Date.now() - entry.at < CATALOG_CACHE_TTL_MS) {
116
+ && Date.now() - entry.at < maxAgeMs) {
116
117
  return entry.body
117
118
  }
118
119
  } catch {
@@ -567,8 +568,16 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
567
568
  ? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
568
569
  : await probe(c.url, c.proxy)
569
570
  results.push({ key: c.key, ok: r.ok })
570
- // git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码
571
- send({ type: r.ok ? 'ok' : 'fail', key: c.key, display: c.display, ms: r.ms, status: c.git ? null : r.status })
571
+ // git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码。
572
+ // reason 透出失败细分原因(如 Windows 证书吊销受阻),客户端据此给对症提示而非笼统「不可达」。
573
+ send({
574
+ type: r.ok ? 'ok' : 'fail',
575
+ key: c.key,
576
+ display: c.display,
577
+ ms: r.ms,
578
+ status: c.git ? null : r.status,
579
+ reason: r.ok ? null : (r.reason ?? null),
580
+ })
572
581
  }
573
582
  send({ type: 'end', at: Date.now() })
574
583
  response.end()
@@ -609,9 +618,10 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
609
618
  sendJson(response, 200, { ok: false, ms: null, status: null, target, reason: 'empty' })
610
619
  return
611
620
  }
612
- // 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址
621
+ // 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址。
622
+ // reason 带上失败细分原因(如证书吊销受阻),设置页才能提示「不是代理地址的问题」。
613
623
  const r = await probeUrl(target, proxy, 6000)
614
- sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target })
624
+ sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target, reason: r.reason ?? null })
615
625
  },
616
626
  }),
617
627
  webServer.register({
@@ -635,7 +645,7 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
635
645
  : `https://api.dsh-plugin.org/plugins.${lang}.json`
636
646
  // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
637
647
  const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`)
638
- const cached = readCatalogCache(cacheFile)
648
+ const cached = readCatalogCache(cacheFile, CATALOG_CACHE_TTL_MS)
639
649
  if (cached !== null) {
640
650
  try {
641
651
  sendJson(response, 200, JSON.parse(cached))
@@ -646,6 +656,17 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
646
656
  }
647
657
  const r = await fetchViaCurl(target, proxy, 20000)
648
658
  if (!r.ok || r.body === '') {
659
+ // 实时拉取失败:退回任意年龄的本地缓存(上次成功数据),
660
+ // 避免证书吊销受阻 / 网络抖动时整个插件市场打不开 —— 数据可能过期,但好过空白。
661
+ const stale = readCatalogCache(cacheFile, Number.POSITIVE_INFINITY)
662
+ if (stale !== null) {
663
+ try {
664
+ sendJson(response, 200, JSON.parse(stale))
665
+ return
666
+ } catch {
667
+ // 兜底缓存也损坏:落到下面的 502
668
+ }
669
+ }
649
670
  sendJson(response, 502, { error: 'catalog fetch failed' })
650
671
  return
651
672
  }