dsh-plugin 1.4.5 → 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.
- package/client/client.js +331 -302
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +30 -8
- package/lib/services/probe.js +92 -63
- package/lib/types/services/probe.d.ts +25 -2
- package/package.json +1 -1
- package/src/client/components/modals/NotificationsModal.tsx +5 -0
- package/src/client/components/modals/modals.tsx +4 -0
- package/src/client/components/views/DiagnosticsView.tsx +11 -4
- package/src/client/components/views/SettingsView.tsx +14 -5
- package/src/client/locales.ts +12 -0
- package/src/client/logic/failures.ts +14 -1
- package/src/client/logic/urls.ts +4 -1
- package/src/server/http/routes.ts +29 -8
- package/src/server/services/probe.ts +113 -63
|
@@ -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
|
|
111
|
-
|
|
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 <
|
|
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
|
-
|
|
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
|
}
|
|
@@ -21,10 +21,53 @@ export interface ProbeResult {
|
|
|
21
21
|
ms: number | null
|
|
22
22
|
/** HTTP 状态码;失败为 null */
|
|
23
23
|
status: number | null
|
|
24
|
+
/** 失败细分原因:仅当能确认为环境侧 TLS 问题时才有值,普通不可达为 null */
|
|
25
|
+
reason?: ProbeFailReason | null
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* 不可达的细分原因。把「证书吊销检查被挡」这类本机环境问题与「网络不通」分开,
|
|
30
|
+
* 诊断列表才能给出对症提示,而不是笼统显示不可达。
|
|
31
|
+
*/
|
|
32
|
+
export type ProbeFailReason = 'tlsRevocation'
|
|
33
|
+
|
|
26
34
|
const probeFail = (): ProbeResult => ({ ok: false, ms: null, status: null })
|
|
27
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Windows 的 curl 走 Schannel 做 TLS:握手阶段强制获取证书吊销信息(CRL/OCSP),
|
|
38
|
+
* 代理受限或吊销服务不可达时整个握手直接失败(CRYPT_E_NO_REVOCATION_CHECK),
|
|
39
|
+
* 表现为 `curl: (35) schannel: next InitializeSecurityContext failed` —— 而 Node/npm/
|
|
40
|
+
* 浏览器各有自己的 TLS 实现,照常可用,用户只会看到「插件市场打不开」。
|
|
41
|
+
* --ssl-revoke-best-effort(curl ≥ 7.70)把吊销检查降级为尽力而为,握手不再被它拦死;
|
|
42
|
+
* 其它平台后端(SecureTransport / OpenSSL)没有这个强制行为,不加参数。
|
|
43
|
+
* 运行时读 process.platform(而非模块级常量),便于测试覆盖两条分支。
|
|
44
|
+
*/
|
|
45
|
+
export function curlTlsArgs(): string[] {
|
|
46
|
+
return process.platform === 'win32' ? ['--ssl-revoke-best-effort'] : []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 本机 curl 太老、不认识 --ssl-revoke-best-effort(curl < 7.70):退出码 2 + stderr 指向该参数。 */
|
|
50
|
+
export function curlRevokeFlagUnsupported(code: number | null, stderr: string): boolean {
|
|
51
|
+
return code === 2 && /ssl-revoke-best-effort/i.test(stderr) && /unknown|unrecognized/i.test(stderr)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** stderr 是否指向 Schannel 取不到证书吊销信息(据此归因为环境 TLS 问题,而非网络不通)。 */
|
|
55
|
+
export function tlsRevocationBlocked(stderr: string): boolean {
|
|
56
|
+
return /CRYPT_E_NO_REVOCATION_CHECK|CRYPT_E_REVOCATION_OFFLINE|0x80092012/i.test(stderr)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 探测/安装同一套代理口径:curl 与 git 子进程都读 HTTP(S)_PROXY 环境变量。 */
|
|
60
|
+
function proxyEnv(proxy: string): NodeJS.ProcessEnv {
|
|
61
|
+
if (proxy === '') return { ...process.env }
|
|
62
|
+
return {
|
|
63
|
+
...process.env,
|
|
64
|
+
HTTP_PROXY: proxy,
|
|
65
|
+
HTTPS_PROXY: proxy,
|
|
66
|
+
http_proxy: proxy,
|
|
67
|
+
https_proxy: proxy,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
28
71
|
/** macOS 系统代理:scutil --proxy 输出里的 HTTP(S) 代理;未开启返回 null。 */
|
|
29
72
|
function macSystemProxy(): string | null {
|
|
30
73
|
try {
|
|
@@ -74,46 +117,72 @@ export function systemProxy(): string | null {
|
|
|
74
117
|
return null
|
|
75
118
|
}
|
|
76
119
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
120
|
+
/** curl 一次调用的原始结果:退出码(进程没起来 / 超时被杀为 null)+ 两路输出。 */
|
|
121
|
+
interface CurlRun {
|
|
122
|
+
code: number | null
|
|
123
|
+
stdout: string
|
|
124
|
+
stderr: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 跑一次 curl 子进程,收集 stdout 与 stderr(stderr 是识别 Schannel 类 TLS 失败的唯一线索)。 */
|
|
128
|
+
function runCurl(args: string[], env: NodeJS.ProcessEnv, timeoutMs: number): Promise<CurlRun> {
|
|
83
129
|
return new Promise((resolve) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (proxy !== '') {
|
|
87
|
-
env.HTTP_PROXY = proxy
|
|
88
|
-
env.HTTPS_PROXY = proxy
|
|
89
|
-
env.http_proxy = proxy
|
|
90
|
-
env.https_proxy = proxy
|
|
91
|
-
}
|
|
92
|
-
const discard = process.platform === 'win32' ? 'NUL' : '/dev/null'
|
|
93
|
-
const child = spawn('curl', [
|
|
94
|
-
'-s', '-o', discard, '-w', '%{http_code}',
|
|
95
|
-
'--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
|
|
96
|
-
url,
|
|
97
|
-
], { env })
|
|
98
|
-
let code = ''
|
|
99
|
-
child.stdout.on('data', (c: Buffer) => { code += c.toString() })
|
|
130
|
+
let stdout = ''
|
|
131
|
+
let stderr = ''
|
|
100
132
|
let done = false
|
|
101
|
-
const finish = (
|
|
133
|
+
const finish = (code: number | null) => {
|
|
102
134
|
if (done) return
|
|
103
135
|
done = true
|
|
104
136
|
clearTimeout(timer)
|
|
105
|
-
resolve(
|
|
137
|
+
resolve({ code, stdout, stderr })
|
|
106
138
|
}
|
|
107
|
-
child
|
|
108
|
-
child.on('
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
const child = spawn('curl', args, { env })
|
|
140
|
+
child.stdout.on('data', (c: Buffer) => { stdout += c.toString() })
|
|
141
|
+
child.stderr.on('data', (c: Buffer) => { stderr += c.toString() })
|
|
142
|
+
child.on('error', () => finish(null))
|
|
143
|
+
child.on('close', (code) => finish(code))
|
|
112
144
|
// 超时兜底:curl --max-time 到点自行退出,此处再设一个进程级超时防止悬挂
|
|
113
|
-
const timer = setTimeout(() => { child.kill(); finish(
|
|
145
|
+
const timer = setTimeout(() => { child.kill(); finish(null) }, timeoutMs + 1000)
|
|
114
146
|
})
|
|
115
147
|
}
|
|
116
148
|
|
|
149
|
+
/** 带 TLS 兜底的 curl 调用:先按平台参数跑(Windows 带吊销检查降级),
|
|
150
|
+
* 若本机 curl 不认这个参数(curl < 7.70)则去掉参数重跑一次,保证老版本不因此直接失败。 */
|
|
151
|
+
async function curlWithTlsFallback(args: string[], env: NodeJS.ProcessEnv, timeoutMs: number): Promise<CurlRun> {
|
|
152
|
+
const tlsArgs = curlTlsArgs()
|
|
153
|
+
const first = await runCurl([...tlsArgs, ...args], env, timeoutMs)
|
|
154
|
+
if (tlsArgs.length === 0 || !curlRevokeFlagUnsupported(first.code, first.stderr)) return first
|
|
155
|
+
return runCurl(args, env, timeoutMs)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* curl 子进程探测:spawn curl -s -w '%{http_code}' 并注入代理 env。
|
|
160
|
+
* curl 读 HTTP_PROXY/HTTPS_PROXY 是原生行为,与 pnpm/git 安装通道同一套代理口径;
|
|
161
|
+
* 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
|
|
162
|
+
* 证书吊销检查受阻(Windows Schannel)另记 reason,供诊断给出对症提示。
|
|
163
|
+
*/
|
|
164
|
+
async function curlProbe(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult> {
|
|
165
|
+
const started = Date.now()
|
|
166
|
+
const discard = process.platform === 'win32' ? 'NUL' : '/dev/null'
|
|
167
|
+
// -sS(而非仅 -s):silent 会把连接/TLS 错误一并吞掉,而识别证书吊销受阻正需要 stderr;
|
|
168
|
+
// 子进程 stderr 由我们收集、不会回显给用户,所以补 -S 不产生噪音。
|
|
169
|
+
const r = await curlWithTlsFallback([
|
|
170
|
+
'-sS', '-o', discard, '-w', '%{http_code}',
|
|
171
|
+
'--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))),
|
|
172
|
+
url,
|
|
173
|
+
], proxyEnv(proxy), timeoutMs)
|
|
174
|
+
// 子进程没起来 / 超时被杀:没有任何可诊断信息,按不可达处理
|
|
175
|
+
if (r.code === null) return probeFail()
|
|
176
|
+
const status = /^\d+$/.test(r.stdout.trim()) ? Number(r.stdout.trim()) : null
|
|
177
|
+
const ok = status !== null && status >= 100 && status < 400
|
|
178
|
+
return {
|
|
179
|
+
ok,
|
|
180
|
+
ms: Date.now() - started,
|
|
181
|
+
status,
|
|
182
|
+
reason: !ok && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
117
186
|
/**
|
|
118
187
|
* 探测一个 HTTPS 目标:proxy 非空注入给 curl 子进程走代理,否则直连。
|
|
119
188
|
* 供系统诊断 /diagnostics 使用;目标 URL 非法返回不可达。
|
|
@@ -137,13 +206,7 @@ export function probeUrl(url: string, proxy: string, timeoutMs: number): Promise
|
|
|
137
206
|
export function gitLsRemote(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult> {
|
|
138
207
|
return new Promise((resolve) => {
|
|
139
208
|
const started = Date.now()
|
|
140
|
-
const env: NodeJS.ProcessEnv = { ...
|
|
141
|
-
if (proxy !== '') {
|
|
142
|
-
env.HTTP_PROXY = proxy
|
|
143
|
-
env.HTTPS_PROXY = proxy
|
|
144
|
-
env.http_proxy = proxy
|
|
145
|
-
env.https_proxy = proxy
|
|
146
|
-
}
|
|
209
|
+
const env: NodeJS.ProcessEnv = { ...proxyEnv(proxy), GIT_TERMINAL_PROMPT: '0' }
|
|
147
210
|
const child = spawn('git', ['ls-remote', '--exit-code', url, 'HEAD'], { env })
|
|
148
211
|
let out = ''
|
|
149
212
|
child.stdout.on('data', (c: Buffer) => { out += c.toString() })
|
|
@@ -168,33 +231,20 @@ export function gitLsRemote(url: string, proxy: string, timeoutMs: number): Prom
|
|
|
168
231
|
}
|
|
169
232
|
|
|
170
233
|
/**
|
|
171
|
-
* curl 子进程抓取响应体(与 probeUrl 同一套代理 env
|
|
234
|
+
* curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入与 TLS 参数兜底)。
|
|
172
235
|
* 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
|
|
173
236
|
* 使「目录数据请求走设置里的代理」与 npm / git 安装通道口径一致。
|
|
174
|
-
* 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false
|
|
237
|
+
* 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错;
|
|
238
|
+
* reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定兜底策略。
|
|
175
239
|
*/
|
|
176
|
-
export function fetchViaCurl(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
let body = ''
|
|
187
|
-
child.stdout.on('data', (c: Buffer) => { body += c.toString() })
|
|
188
|
-
let done = false
|
|
189
|
-
const finish = (ok: boolean) => {
|
|
190
|
-
if (done) 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
|
-
})
|
|
240
|
+
export function fetchViaCurl(
|
|
241
|
+
url: string, proxy: string, timeoutMs: number,
|
|
242
|
+
): Promise<{ ok: boolean; body: string; reason: ProbeFailReason | null }> {
|
|
243
|
+
// 同样用 -sS:正文之外还要 stderr 里的 TLS 失败线索(被 stderr 收集,不外泄给用户)
|
|
244
|
+
const args = ['-sS', '--max-time', String(Math.max(1, Math.round(timeoutMs / 1000))), url]
|
|
245
|
+
return curlWithTlsFallback(args, proxyEnv(proxy), timeoutMs).then((r) => ({
|
|
246
|
+
ok: r.code === 0,
|
|
247
|
+
body: r.stdout,
|
|
248
|
+
reason: r.code !== 0 && tlsRevocationBlocked(r.stderr) ? 'tlsRevocation' : null,
|
|
249
|
+
}))
|
|
200
250
|
}
|