dsh-plugin 1.3.6 → 1.3.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.
@@ -12,7 +12,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
12
  import { homedir, release } from 'node:os';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { spawn } from 'node:child_process';
15
- import { fetchViaCurl, probeUrl, systemProxy } from '../services/probe.js';
15
+ import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
16
16
  import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
17
17
  import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.js';
18
18
  import { resolveNpmPackage } from '../services/install/npm-resolve.js';
@@ -492,9 +492,17 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
492
492
  // npm 行 display:配置了镜像就显示镜像地址;未配置置空 —— 客户端显示「未配置(跟随本机 npm 配置)」。
493
493
  const allChecks = [
494
494
  { key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' ? registry : '', cmd: `npm view dsh-plugin version --registry ${registry}` },
495
- { key: 'github', url: 'https://github.com/dshplugin/dsh-plugin-hub', display: 'github.com', cmd: 'git ls-remote https://github.com/dshplugin/dsh-plugin-hub' },
495
+ // GitHub 行用真实 git 克隆握手(git ls-remote)打 dshplugin/hello-dsh 小仓库:
496
+ // 「网页能打开」和「git 能克隆」是两码事,握手成功才算通道通;小仓库秒级完成,不打 17MB 的 dsh-plugin-hub。
497
+ { key: 'github', url: 'https://github.com/dshplugin/hello-dsh', display: 'github.com/dshplugin/hello-dsh', cmd: 'git ls-remote https://github.com/dshplugin/hello-dsh', git: true },
496
498
  { key: 'catalog', url: 'https://api.dsh-plugin.org/stats.json', display: 'api.dsh-plugin.org', cmd: 'curl -s https://api.dsh-plugin.org/stats.json' },
497
499
  ];
500
+ // 配置了 HTTP 代理:追加一行代理诊断 —— 用该代理打 github.com(安装通道真实访问的地址),
501
+ // 验证「代理能不能把请求带出去」。与安装同口径:curl 子进程注入该代理 env。
502
+ // 设置里未配置代理时不显示该行(跟随系统代理/直连,无「代理本身」可验证)。
503
+ if (settings.proxy !== '') {
504
+ allChecks.push({ key: 'proxy', url: 'https://github.com', display: settings.proxy, cmd: `curl -s -o /dev/null -x ${settings.proxy} https://github.com`, proxy: settings.proxy });
505
+ }
498
506
  const checks = onlyKey !== '' ? allChecks.filter((c) => c.key === onlyKey) : allChecks;
499
507
  response.writeHead(200, {
500
508
  'content-type': 'application/x-ndjson; charset=utf-8',
@@ -504,17 +512,22 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
504
512
  // 探测走代理的优先级:设置里的代理 → 系统代理(macOS scutil / Windows 注册表)→ 环境变量 HTTPS_PROXY → 直连。
505
513
  // 与安装通道一致(npm/git 靠 HTTP_PROXY 环境变量走代理),且自动跟随系统代理 ——
506
514
  // 浏览器挂着代理能开、诊断就能测通,不用用户手动把代理填进设置。
515
+ // proxy 行例外:它验证的正是「设置里配的那个代理」,必须显式用它(而非 effectiveProxy)去探测。
507
516
  const effectiveProxy = settings.proxy !== ''
508
517
  ? settings.proxy
509
518
  : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
510
- const probe = (url) => probeUrl(url, effectiveProxy, 6000);
519
+ const probe = (url, proxy) => probeUrl(url, proxy ?? effectiveProxy, 6000);
511
520
  // 逐路串行:一条命令一条结果地推送给终端,保持「程序在跑」的真实感
512
521
  const results = [];
513
522
  for (const c of checks) {
514
523
  send({ type: 'probe', key: c.key, cmd: c.cmd, display: c.display });
515
- const r = await probe(c.url);
524
+ // git 行走真实克隆握手(gitLsRemote),其余走 curl 探测,两路都带超时兜底
525
+ const r = c.git
526
+ ? await gitLsRemote(c.url, c.proxy ?? effectiveProxy, 6000)
527
+ : await probe(c.url, c.proxy);
516
528
  results.push({ key: c.key, ok: r.ok });
517
- send({ type: r.ok ? 'ok' : 'fail', key: c.key, display: c.display, ms: r.ms, status: r.status });
529
+ // git 行的 status 是退出码,客户端 git 行只显示 OK / 不可达,不显示成 HTTP 码
530
+ send({ type: r.ok ? 'ok' : 'fail', key: c.key, display: c.display, ms: r.ms, status: c.git ? null : r.status });
518
531
  }
519
532
  send({ type: 'end', at: Date.now() });
520
533
  response.end();
@@ -531,6 +544,39 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
531
544
  });
532
545
  },
533
546
  }),
547
+ webServer.register({
548
+ kind: 'exact',
549
+ path: '/dsh-plugin-hub/proxy-check',
550
+ handler: async (request, response) => {
551
+ if (!requireTrustedPost(request, response))
552
+ return;
553
+ // 代理连通性校验:用待测代理打 dshplugin/hello-dsh 真实仓库页(权威可达目标),
554
+ // 供设置页输入代理时实时反馈「这个地址通不通」。不通过也允许保存 ——
555
+ // 用户可能是先填地址后开代理,因此这里只报告探测结果、不拦截保存。
556
+ let proxy = '';
557
+ let target = 'https://github.com/dshplugin/hello-dsh';
558
+ try {
559
+ const body = await readJsonBody(request);
560
+ if (body !== null && typeof body === 'object') {
561
+ const b = body;
562
+ if (typeof b.proxy === 'string')
563
+ proxy = b.proxy.trim();
564
+ if (typeof b.target === 'string' && b.target.trim() !== '')
565
+ target = b.target.trim();
566
+ }
567
+ }
568
+ catch {
569
+ // 空 body / 非 JSON:按默认目标探测
570
+ }
571
+ if (proxy === '') {
572
+ sendJson(response, 200, { ok: false, ms: null, status: null, target, reason: 'empty' });
573
+ return;
574
+ }
575
+ // 探测走该代理本身(不是 effectiveProxy):验证的就是用户填的这个地址
576
+ const r = await probeUrl(target, proxy, 6000);
577
+ sendJson(response, 200, { ok: r.ok, ms: r.ms, status: r.status, target });
578
+ },
579
+ }),
534
580
  webServer.register({
535
581
  kind: 'exact',
536
582
  path: '/dsh-plugin-hub/catalog',
@@ -605,6 +651,10 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
605
651
  ? body.source
606
652
  : '';
607
653
  const settings = loadSettings(profile);
654
+ // 界面语言(客户端随请求携带):错误消息按用户当前语言提示(中文界面给中文,英文界面给英文)
655
+ const lang = typeof body === 'object' && body !== null && body.lang === 'en'
656
+ ? 'en'
657
+ : 'zh';
608
658
  // 全局 npm 安装(官方 README 的 `npm install -g <pkgs>`,如 dsh-tui 的安装命令):
609
659
  // 直接从命令框输入格式执行全局安装,不进任何 profile、无需宿主重启。
610
660
  // 包列表优先取 body.globalNpm(客户端命令框解析后显式携带);兜底再从 rawRepo 解析,
@@ -704,6 +754,35 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
704
754
  sendJson(response, 409, { error: `already queued: ${target}` });
705
755
  return;
706
756
  }
757
+ // 安装前网络连通性预检:npm 通道探 registry、git 通道探 github.com 连通性。
758
+ // 探测目标固定为通道的稳定入口(registry 根 / github.com 主页),不探具体包或仓库
759
+ // 页 —— 404 表示「目标不存在」而非网络不通,不该被当成网络故障拦截。
760
+ // 仅新安装预检(更新是已信任目标的覆盖重装,跳过);不通直接 400 拦下并打
761
+ // [network] 标记,客户端据此提示「你的网络不通」,而不是把网络失败当成
762
+ // 插件侧问题引导去作者仓库提 Issue(issue #10:git fetch 超时被误报为插件问题)。
763
+ // 代理口径与诊断一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
764
+ if (already === null) {
765
+ const effectiveProxy = settings.proxy !== ''
766
+ ? settings.proxy
767
+ : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
768
+ const isGitChannel = repoTarget !== null && target === repoTarget;
769
+ const probeTarget = isGitChannel
770
+ ? 'https://github.com/'
771
+ : `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`;
772
+ const net = await probeUrl(probeTarget, effectiveProxy, 6000);
773
+ if (!net.ok) {
774
+ // 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
775
+ // 不再写死英文(用户一眼看懂是网络问题,而不是插件问题)
776
+ const channelName = isGitChannel
777
+ ? (lang === 'zh' ? 'GitHub' : 'github.com')
778
+ : (lang === 'zh' ? 'npm 源' : 'the npm registry');
779
+ const error = lang === 'zh'
780
+ ? `[network] 安装已中止:安装前无法连接到 ${channelName}(${probeTarget})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
781
+ : `[network] install aborted: cannot reach ${channelName} (${probeTarget}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`;
782
+ sendJson(response, 400, { error, attempts });
783
+ return;
784
+ }
785
+ }
707
786
  // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
708
787
  // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
709
788
  // npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
@@ -123,6 +123,49 @@ export function probeUrl(url, proxy, timeoutMs) {
123
123
  }
124
124
  return curlProbe(target.href, proxy, timeoutMs);
125
125
  }
126
+ /**
127
+ * git 通道真实克隆握手探测:spawn git ls-remote(https 传输,与 pnpm 克隆前的
128
+ * ref 握手一致),注入代理 env,GIT_TERMINAL_PROMPT=0 防凭据提示挂起。
129
+ *
130
+ * 为什么要真实 git 而非 curl 打网页:网页「能打开」和 git「能克隆」是两码事 ——
131
+ * 防火墙/代理常按端口与协议区分,HTTP 页可达不代表 git 传输可达。这里测的就是
132
+ * 克隆握手本身,回答「github:owner/repo 装不装得动」。
133
+ * 供系统诊断 GitHub 通道使用;探测目标用 dshplugin/hello-dsh 小仓库(秒级完成,
134
+ * 不打 17MB 的 dsh-plugin-hub 主页)。
135
+ */
136
+ export function gitLsRemote(url, proxy, timeoutMs) {
137
+ return new Promise((resolve) => {
138
+ 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
+ }
146
+ const child = spawn('git', ['ls-remote', '--exit-code', url, 'HEAD'], { env });
147
+ let out = '';
148
+ child.stdout.on('data', (c) => { out += c.toString(); });
149
+ // git 的报错不透传给终端:结果以退出码 + HEAD ref 是否返回为准
150
+ child.stderr.on('data', () => { });
151
+ let done = false;
152
+ const finish = (r) => {
153
+ if (done)
154
+ return;
155
+ done = true;
156
+ clearTimeout(timer);
157
+ resolve(r);
158
+ };
159
+ child.on('error', () => finish(probeFail()));
160
+ child.on('close', (code) => {
161
+ const ok = code === 0 && out.trim() !== '';
162
+ // status 复用为 git 退出码(0 = 克隆握手成功);客户端 git 行不把它显示成 HTTP 码
163
+ finish({ ok, ms: Date.now() - started, status: code });
164
+ });
165
+ // 超时兜底:与 curlProbe 同口径,防止 git 挂起
166
+ const timer = setTimeout(() => { child.kill(); finish(probeFail()); }, timeoutMs + 1000);
167
+ });
168
+ }
126
169
  /**
127
170
  * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入)。
128
171
  * 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
@@ -18,6 +18,17 @@ export declare function systemProxy(): string | null;
18
18
  * 供系统诊断 /diagnostics 使用;目标 URL 非法返回不可达。
19
19
  */
20
20
  export declare function probeUrl(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult>;
21
+ /**
22
+ * git 通道真实克隆握手探测:spawn git ls-remote(https 传输,与 pnpm 克隆前的
23
+ * ref 握手一致),注入代理 env,GIT_TERMINAL_PROMPT=0 防凭据提示挂起。
24
+ *
25
+ * 为什么要真实 git 而非 curl 打网页:网页「能打开」和 git「能克隆」是两码事 ——
26
+ * 防火墙/代理常按端口与协议区分,HTTP 页可达不代表 git 传输可达。这里测的就是
27
+ * 克隆握手本身,回答「github:owner/repo 装不装得动」。
28
+ * 供系统诊断 GitHub 通道使用;探测目标用 dshplugin/hello-dsh 小仓库(秒级完成,
29
+ * 不打 17MB 的 dsh-plugin-hub 主页)。
30
+ */
31
+ export declare function gitLsRemote(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult>;
21
32
  /**
22
33
  * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入)。
23
34
  * 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -44,8 +44,8 @@
44
44
  "build:server": "rm -rf lib && tsc -p tsconfig.server.json",
45
45
  "build:client": "tsdown && node scripts/tools/normalize-client-banner.mjs",
46
46
  "readme:stats": "node scripts/tools/sync-readme-stats.mjs",
47
- "reload": "bash scripts/run/reload-dsh.sh",
48
- "dev": "bash scripts/run/dev-dsh.sh",
47
+ "reload": "bash scripts/run/restart-dev.sh",
48
+ "dev": "bash scripts/run/restart-dev.sh",
49
49
  "test": "node --test",
50
50
  "test:watch": "node --test --watch",
51
51
  "typecheck": "npm run typecheck:client && npm run typecheck:server && npm run typecheck:test",
@@ -35,6 +35,7 @@ import type { SectionView } from './layout/SectionTabs.tsx'
35
35
  import { InstalledView } from './views/InstalledView.tsx'
36
36
  import { CustomInstallView } from './views/CustomInstallView.tsx'
37
37
  import { SettingsView } from './views/SettingsView.tsx'
38
+ import type { SettingsSection } from './views/SettingsView.tsx'
38
39
  import { CloseIcon, ConfirmIcon } from './ui/icons.tsx'
39
40
  import type { InstalledItem } from '../logic/installed.ts'
40
41
  import { issueRepoOf, pluginOfItem } from '../logic/installed.ts'
@@ -47,6 +48,27 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
47
48
 
48
49
  /** 一级导航:插件中心 / 已安装 / 自定义安装 / 设置 */
49
50
  const [view, setView] = useState<SectionView>('market')
51
+ /** 外部跳转设置分组的一次性信号(错误弹窗「去系统诊断」→ 设置 → 系统诊断):
52
+ * 传给 SettingsView 消费后即清空,避免下次进设置被强制带跳 */
53
+ const [settingsSection, setSettingsSection] = useState<SettingsSection | null>(null)
54
+ /** 网络不通提示的「去系统诊断」直达:关掉所有弹窗(错误/通知/安装确认进度等,
55
+ * 否则 InstallModal 失败后仍停驻原地盖住设置页)→ 切到设置页 → 切到系统诊断分组(自动跑连通性检测) */
56
+ const openDiagnostics = () => {
57
+ setErrorMsg(null)
58
+ setShowNotifications(false)
59
+ setConfirmPlugin(null)
60
+ setConfirmCustomTarget(null)
61
+ setConfirmGlobalNpm(null)
62
+ setUninstallPlugin(null)
63
+ setUninstallItem(null)
64
+ setDetailItem(null)
65
+ setShowHubUpdate(false)
66
+ setShowAbout(false)
67
+ setIgnoreTarget(null)
68
+ setShowRestartConfirm(false)
69
+ setView('settings')
70
+ setSettingsSection('diagnostics')
71
+ }
50
72
 
51
73
  /** 全局反馈 Toast:{id} 用于重复触发时重新走入场动画,kind 决定文案与配色 */
52
74
  const [toast, setToast] = useState<ToastState | null>(null)
@@ -106,6 +128,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
106
128
 
107
129
  const queue = useTaskQueue({
108
130
  t,
131
+ // 界面语言带给服务端:网络预检等错误消息按用户语言提示(中文界面给中文,英文界面给英文)
132
+ langKey,
109
133
  refreshInstalled: catalog.refreshInstalled,
110
134
  onInstallDone: (viaModal, repo, needsRestart, update) => {
111
135
  setInstallNeedsRestart(needsRestart)
@@ -462,6 +486,9 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
462
486
  doCopy(text)
463
487
  setToast({ id: Date.now(), kind: 'copied' })
464
488
  },
489
+ // 网络不通提示「去系统诊断」→ 设置页并切到系统诊断分组(DiagnosticsView 挂载即自动跑探测)
490
+ openSection: settingsSection,
491
+ onConsumedOpenSection: () => setSettingsSection(null),
465
492
  }),
466
493
  // Hub 版本信息弹窗:版本号/徽标点击打开 —— 有更新显示新版本 + 变更记录 + 「直接更新」,
467
494
  // 无更新显示当前版本 + 记录 + 「已是最新」。Worker 拉取失败时兜底展示当前版本号。
@@ -521,6 +548,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
521
548
  cancelTask: queue.cancelTask,
522
549
  restarting,
523
550
  onRestart: () => setShowRestartConfirm(true),
551
+ // 网络不通记录的「去系统诊断」直达:关通知中心 → 设置 → 系统诊断
552
+ onRunDiagnostics: openDiagnostics,
524
553
  // 失败记录仓库反查:历史记录可能存的是裸 npm 包名,反查成 owner/repo 才显示仓库链接
525
554
  // 与提 Issue 按钮;查不到则隐藏(防提到错误仓库)
526
555
  resolveRepo: (repo: string) => issueRepoOf(repo, catalog.installedItems, catalog.plugins),
@@ -704,6 +733,8 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
704
733
  doCopy(text)
705
734
  setToast({ id: Date.now(), kind: 'errCopied' })
706
735
  },
736
+ // 网络不通错误的「去系统诊断」直达:关错误弹窗 → 设置 → 系统诊断
737
+ onRunDiagnostics: openDiagnostics,
707
738
  onClose: () => setErrorMsg(null),
708
739
  }),
709
740
  // 待重启确认弹窗:已安装列表行内「重启」按钮点击后弹出,稍后重启 / 立即重启
@@ -18,7 +18,7 @@ import type { MouseEvent } from 'react'
18
18
  import styles from '../../styles/Modal.module.css'
19
19
  import type { EnvInfo, Translate } from '../../types.ts'
20
20
  import type { NotificationRecord } from '../../logic/failures.ts'
21
- import { classifyFailure, npmTooLowVersion } from '../../logic/failures.ts'
21
+ import { classifyFailure, npmTooLowVersion, unreachableTargetOf } from '../../logic/failures.ts'
22
22
  import type { PendingRestart, QueueTask } from '../../hooks/useTaskQueue.ts'
23
23
  import { pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
24
24
  import { CloseIcon } from '../ui/icons.tsx'
@@ -56,7 +56,7 @@ function BadgeGlyph({ ok }: { ok: boolean }) {
56
56
  }))
57
57
  }
58
58
 
59
- export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, onUpdate, onIgnoreUpdate, cancelTask, restarting, onRestart, resolveRepo }: {
59
+ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, onClose, onCopy, onClear, onRemove, onUpdate, onIgnoreUpdate, cancelTask, restarting, onRestart, onRunDiagnostics, resolveRepo }: {
60
60
  records: NotificationRecord[]
61
61
  /** 进行中的安装/卸载任务(实时进度,与队列弹窗同一数据源) */
62
62
  tasks: QueueTask[]
@@ -76,6 +76,8 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
76
76
  cancelTask: (id: number) => void
77
77
  restarting: boolean
78
78
  onRestart: () => void
79
+ /** 网络不通时的「去系统诊断」直达按钮:跳到设置 → 系统诊断跑连通性检测(由宿主提供) */
80
+ onRunDiagnostics?: () => void
79
81
  /** 失败记录 repo 反查:npm 包名(历史记录存的是裸包名)反查成 owner/repo 再显示/提 Issue;
80
82
  * 查不到返回 null,按钮与仓库链接一并隐藏(防提到错误仓库)。不传则按记录原样展示。 */
81
83
  resolveRepo?: (repo: string) => string | null
@@ -264,6 +266,20 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
264
266
  return h('div', { className: styles.failPrepareHint },
265
267
  t(v ? 'failNpmTooLowV' : 'failNpmTooLow', v ? { v } : undefined))
266
268
  }
269
+ if (kind === 'network') {
270
+ // 安装前预检 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
271
+ // 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
272
+ // 不引导提 Issue
273
+ return h('div', null, [
274
+ // 「无法访问 …」醒目行:精准告诉用户具体哪个地址连不上(消息里取不到地址就跳过这行)
275
+ (() => { const target = unreachableTargetOf(r.message); return target ? h('div', { className: styles.failNetworkTarget }, t('failNetworkTarget', { url: target })) : null })(),
276
+ h('div', { className: styles.failPrepareHint }, t('failNetworkHint')),
277
+ onRunDiagnostics ? h('button', {
278
+ className: styles.failDiagBtn,
279
+ onClick: onRunDiagnostics,
280
+ }, t('failNetworkRunDiag')) : null,
281
+ ])
282
+ }
267
283
  if (kind === 'pluginPrepare' || kind === 'pnpmIgnoredBuild') {
268
284
  // [packaging](预检/装后校验拦截:git 分发缺产物)与
269
285
  // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
@@ -16,7 +16,7 @@ import { CloseIcon, ConfirmIcon, CopyIcon, LinkIcon } from '../ui/icons.tsx'
16
16
  import { ProgressView } from './ProgressView.tsx'
17
17
  import { installCommandOf } from '../../logic/install-command.ts'
18
18
  import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
19
- import { classifyFailure, npmTooLowVersion } from '../../logic/failures.ts'
19
+ import { classifyFailure, npmTooLowVersion, unreachableTargetOf } from '../../logic/failures.ts'
20
20
 
21
21
  /** 完成结果视图:绿色对勾 + 标题/描述;
22
22
  * needsRestart=true(插件需重启才生效)→ 「稍后重启 / 立即重启」按钮对,点稍后重启后
@@ -320,7 +320,7 @@ export function UninstallModal(props: UninstallModalProps) {
320
320
 
321
321
  /** 预填插件仓库的 GitHub Issue 链接:标题带插件名,正文附完整错误信息,方便用户一键反馈。 */
322
322
  /** 安装/卸载失败弹窗:布局与失败记录一致(类型徽标 + 仓库超链接 + 隐蔽复制按钮),报错完整展示,底部一键提交 Issue。 */
323
- export function ErrorModal({ message, repo, kind, command, attempts, t, env, onCopy, onClose }: {
323
+ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onCopy, onClose, onRunDiagnostics }: {
324
324
  message: string
325
325
  repo: string | null
326
326
  kind: 'install' | 'uninstall'
@@ -331,6 +331,8 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
331
331
  env: EnvInfo | null
332
332
  onCopy: (text: string) => void
333
333
  onClose: () => void
334
+ /** 网络不通时的「去系统诊断」直达按钮:跳到设置 → 系统诊断跑连通性检测(由宿主提供) */
335
+ onRunDiagnostics?: () => void
334
336
  }) {
335
337
  // 失败归类:当前安装通道(npm/git)装不上 = 插件分发/依赖的问题,一律引导提 Issue
336
338
  const failureKind = classifyFailure(message)
@@ -385,7 +387,20 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
385
387
  failureKind === 'npmTooOld'
386
388
  ? h('div', { className: styles.failPrepareHint },
387
389
  t(npmVersion ? 'failNpmTooLowV' : 'failNpmTooLow', npmVersion ? { v: npmVersion } : undefined))
388
- : failureKind === 'pluginPrepare' || failureKind === 'pnpmIgnoredBuild'
390
+ : failureKind === 'network'
391
+ // 安装前预检 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
392
+ // 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
393
+ // 不引导提 Issue
394
+ ? h('div', null, [
395
+ // 「无法访问 …」醒目行:精准告诉用户具体哪个地址连不上(消息里取不到地址就跳过这行)
396
+ (() => { const target = unreachableTargetOf(message); return target ? h('div', { className: styles.failNetworkTarget }, t('failNetworkTarget', { url: target })) : null })(),
397
+ h('div', { className: styles.failPrepareHint }, t('failNetworkHint')),
398
+ onRunDiagnostics ? h('button', {
399
+ className: styles.failDiagBtn,
400
+ onClick: onRunDiagnostics,
401
+ }, t('failNetworkRunDiag')) : null,
402
+ ])
403
+ : failureKind === 'pluginPrepare' || failureKind === 'pnpmIgnoredBuild'
389
404
  ? h('div', null, [
390
405
  // [packaging](预检/装后校验拦截:git 分发缺产物)与
391
406
  // prepare 构建脚本实际执行失败 / 原生依赖构建被 pnpm 拦截:都是插件打包分发问题 —— 先说明原因,按钮在下方提交
@@ -15,7 +15,7 @@ import styles from '../../styles/DiagnosticsView.module.css'
15
15
  import type { EnvInfo, Translate } from '../../types.ts'
16
16
  import { PLUGIN_VERSION } from '../../logic/constants.ts'
17
17
 
18
- type ProbeKey = 'npm' | 'github' | 'catalog'
18
+ type ProbeKey = 'npm' | 'github' | 'catalog' | 'proxy'
19
19
 
20
20
  /** 系统版本文本:提交 Issue 时粘贴到正文,方便作者复现。 */
21
21
  function formatEnv(env: EnvInfo): string {
@@ -42,16 +42,26 @@ interface RowState {
42
42
  const INITIAL_ROWS: RowState[] = [
43
43
  // npm 行 display 初始置空:设置里未配置镜像时显示「未配置(跟随本机)」,探测结果回来再用服务端 display 覆盖
44
44
  { key: 'npm', display: '', nameKey: 'diagNpm', status: 'idle', ms: null, statusCode: null },
45
- { key: 'github', display: 'github.com', nameKey: 'diagGithub', status: 'idle', ms: null, statusCode: null },
45
+ { key: 'github', display: 'github.com/dshplugin/hello-dsh', nameKey: 'diagGithub', status: 'idle', ms: null, statusCode: null },
46
46
  { key: 'catalog', display: 'api.dsh-plugin.org', nameKey: 'diagCatalog', status: 'idle', ms: null, statusCode: null },
47
47
  ]
48
48
 
49
- export function DiagnosticsView({ t, env, onCopy }: {
49
+ export function DiagnosticsView({ t, env, proxy, onCopy }: {
50
50
  t: Translate
51
51
  env: EnvInfo | null
52
+ /** 设置里配置的 HTTP 代理地址;留空(未配置)不显示代理行 */
53
+ proxy: string
52
54
  onCopy: (text: string) => void
53
55
  }) {
54
- const [rows, setRows] = useState<RowState[]>(INITIAL_ROWS)
56
+ // 代理行仅在配置了 HTTP 代理时出现:用该代理探测 github.com,验证「代理能不能把请求带出去」。
57
+ // 未配置(留空直连)没有可验证的代理,不渲染这一行,避免无意义的诊断项。
58
+ const [rows, setRows] = useState<RowState[]>(() => {
59
+ const base = INITIAL_ROWS.map((r) => ({ ...r }))
60
+ if (proxy.trim() !== '') {
61
+ base.push({ key: 'proxy', display: proxy.trim(), nameKey: 'diagProxy', status: 'idle', ms: null, statusCode: null })
62
+ }
63
+ return base
64
+ })
55
65
  /** 当前探测请求:重测/卸载时 abort 旧请求,避免乱序结果覆盖 */
56
66
  const runningRef = useRef<AbortController | null>(null)
57
67
 
@@ -15,7 +15,7 @@
15
15
  * Diagnostics — live connectivity probe + environment snapshot
16
16
  * Logs — system log viewer + storage location
17
17
  */
18
- import { createElement as h, useState } from 'react'
18
+ import { createElement as h, useEffect, useRef, useState } from 'react'
19
19
  import type { ChangeEvent, MouseEvent, ReactNode } from 'react'
20
20
  import styles from '../../styles/SettingsView.module.css'
21
21
  import dropdownStyles from '../../styles/Dropdown.module.css'
@@ -30,7 +30,8 @@ import {
30
30
  import type { EnvInfo, Translate } from '../../types.ts'
31
31
  import type { HubSettings } from '../../hooks/useSettings.ts'
32
32
 
33
- type SettingsSection = 'updates' | 'security' | 'diagnostics' | 'logs' | 'reset'
33
+ /** 设置分组导航 key:顺序即展示顺序。导出供外部(如错误弹窗「去系统诊断」)跳转指定分组。 */
34
+ export type SettingsSection = 'updates' | 'security' | 'diagnostics' | 'logs' | 'reset'
34
35
 
35
36
  /** npm 镜像源预设:空串 = 未配置(跟随用户本机 npm 配置,本插件不注入任何 registry)。
36
37
  * 只提供常见国内顶级镜像下拉,不做自定义输入 —— 想用其它镜像直接在 ~/.npmrc 配置即可。 */
@@ -67,20 +68,70 @@ function SettingRow({ title, desc, children, stack = false }: {
67
68
  )
68
69
  }
69
70
 
70
- export function SettingsView({ t, settings, update, reset, env, onCopy }: {
71
+ export function SettingsView({ t, settings, update, reset, env, onCopy, openSection, onConsumedOpenSection }: {
71
72
  t: Translate
72
73
  settings: HubSettings
73
74
  update: (patch: Partial<HubSettings>) => void
74
75
  reset: () => void
75
76
  env: EnvInfo | null
76
77
  onCopy: (text: string) => void
78
+ /** 外部跳转信号(如错误弹窗「去系统诊断」):非空时切到对应分组,消费后通过 onConsumedOpenSection 清空 */
79
+ openSection?: SettingsSection | null
80
+ onConsumedOpenSection?: () => void
77
81
  }) {
78
82
  /** 当前激活的分组:左侧导航点击切换,右侧只渲染这一组 */
79
83
  const [section, setSection] = useState<SettingsSection>('updates')
80
84
  /** 恢复默认确认弹窗:点按钮先弹窗询问,确认后才真正 reset(不做两段式按钮) */
81
85
  const [resetConfirm, setResetConfirm] = useState(false)
82
86
 
83
- const onProxyChange = (e: ChangeEvent<HTMLInputElement>) => update({ proxy: e.target.value })
87
+ // 外部跳转(错误弹窗「去系统诊断」→ 设置 → 系统诊断):切到对应分组并清空信号,避免下次进入设置被强制带跳
88
+ useEffect(() => {
89
+ if (!openSection) return
90
+ setSection(openSection)
91
+ onConsumedOpenSection?.()
92
+ }, [openSection, onConsumedOpenSection])
93
+
94
+ // —— HTTP 代理连通性:输入停笔后防抖实时探测(服务端 /proxy-check 用该代理打 github.com)。
95
+ // 结果只作提示、绝不阻断保存 —— 用户可能是先填地址后开代理,保存后再去开代理完全合法。
96
+ const [proxyProbe, setProxyProbe] = useState<'idle' | 'checking' | 'ok' | 'fail'>('idle')
97
+ const proxyProbeTimer = useRef<number | null>(null)
98
+ const proxyProbeSeq = useRef(0)
99
+ // 卸载时清掉未触发的防抖计时器,避免卸载后 setState
100
+ useEffect(() => () => {
101
+ if (proxyProbeTimer.current !== null) window.clearTimeout(proxyProbeTimer.current)
102
+ proxyProbeSeq.current += 1
103
+ }, [])
104
+ // 设置被外部重置(恢复默认/清空代理)时同步清掉探测状态,避免残留过期的「不通」提示
105
+ useEffect(() => {
106
+ if (settings.proxy === '') setProxyProbe('idle')
107
+ }, [settings.proxy])
108
+
109
+ const probeProxyNow = (value: string) => {
110
+ const v = value.trim()
111
+ if (v === '') { setProxyProbe('idle'); return }
112
+ const seq = ++proxyProbeSeq.current
113
+ setProxyProbe('checking')
114
+ void fetch('/dsh-plugin-hub/proxy-check', {
115
+ method: 'POST',
116
+ headers: { 'content-type': 'application/json' },
117
+ body: JSON.stringify({ proxy: v }),
118
+ cache: 'no-store',
119
+ }).then((res) => res.json())
120
+ .then((data: { ok?: boolean }) => {
121
+ if (seq !== proxyProbeSeq.current) return
122
+ setProxyProbe(data.ok ? 'ok' : 'fail')
123
+ })
124
+ .catch(() => { if (seq === proxyProbeSeq.current) setProxyProbe('fail') })
125
+ }
126
+
127
+ const onProxyChange = (e: ChangeEvent<HTMLInputElement>) => {
128
+ const value = e.target.value
129
+ // 保存照常:留空直连、填了就走该代理(先填后开代理也允许,仅提示不通)
130
+ update({ proxy: value })
131
+ if (proxyProbeTimer.current !== null) window.clearTimeout(proxyProbeTimer.current)
132
+ // 停笔 700ms 再探测,避免每敲一个字符都发请求
133
+ proxyProbeTimer.current = window.setTimeout(() => probeProxyNow(value), 700)
134
+ }
84
135
  const onMirrorChange = (value: string) => update({ npmRegistry: value })
85
136
 
86
137
  /** 每个分组的页面副标题(导航标题之上再补一句说明,保持桌面设置质感) */
@@ -120,19 +171,29 @@ export function SettingsView({ t, settings, update, reset, env, onCopy }: {
120
171
  className: `${styles.controlDropdown} ${dropdownStyles.dropdownFill}`,
121
172
  }),
122
173
  }),
123
- // 代理地址:文本框独占一行,整行输入,便于粘贴长代理地址
174
+ // 代理地址:文本框独占一行,整行输入,便于粘贴长代理地址;停笔后实时探测连通性,
175
+ // 不通仅提示(可保存)—— 用户可能先填地址后开代理
124
176
  h(SettingRow, {
125
177
  title: t('settingsProxy'),
126
178
  desc: t('settingsProxyDesc'),
127
179
  stack: true,
128
- children: h('input', {
129
- className: styles.textInput,
130
- type: 'text',
131
- value: settings.proxy,
132
- placeholder: 'http://127.0.0.1:7890',
133
- spellCheck: false,
134
- onChange: onProxyChange,
135
- }),
180
+ children: h('div', { className: styles.proxyControl },
181
+ h('input', {
182
+ className: styles.textInput,
183
+ type: 'text',
184
+ value: settings.proxy,
185
+ placeholder: 'http://127.0.0.1:7890',
186
+ spellCheck: false,
187
+ onChange: onProxyChange,
188
+ }),
189
+ proxyProbe === 'checking'
190
+ ? h('div', { className: styles.proxyHint }, t('proxyCheckChecking'))
191
+ : proxyProbe === 'ok'
192
+ ? h('div', { className: `${styles.proxyHint} ${styles.proxyHintOk}` }, t('proxyCheckOk'))
193
+ : proxyProbe === 'fail'
194
+ ? h('div', { className: `${styles.proxyHint} ${styles.proxyHintFail}` }, t('proxyCheckFail'))
195
+ : null,
196
+ ),
136
197
  }),
137
198
  )
138
199
 
@@ -172,7 +233,7 @@ export function SettingsView({ t, settings, update, reset, env, onCopy }: {
172
233
 
173
234
  const diagnosticsCard = h('div', { className: styles.card },
174
235
  // 系统版本(最顶部)+ 通道状态列表:进入页面自动探测一轮,整行可点击重测单项,实时反馈
175
- h(DiagnosticsView, { t, env, onCopy }),
236
+ h(DiagnosticsView, { t, env, proxy: settings.proxy, onCopy }),
176
237
  )
177
238
 
178
239
  const logsCard = h('div', { className: styles.card },
@@ -10,7 +10,7 @@
10
10
  * uninstall, cancel) plus the modal task lookups used by the dialogs.
11
11
  */
12
12
  import { useEffect, useRef, useState } from 'react'
13
- import type { HubPlugin, Translate } from '../types.ts'
13
+ import type { HubPlugin, LocaleId, Translate } from '../types.ts'
14
14
  import { installCommandOf, installTargetOf, repoFromInstallTarget } from '../logic/install-command.ts'
15
15
  import type { InstalledItem } from '../logic/installed.ts'
16
16
 
@@ -64,6 +64,8 @@ export interface PendingRestart {
64
64
 
65
65
  export interface TaskQueueOptions {
66
66
  t: Translate
67
+ /** 界面语言(zh/en):随安装请求带给服务端,网络预检等错误消息按用户语言提示 */
68
+ langKey?: LocaleId
67
69
  refreshInstalled: () => void
68
70
  /** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录;
69
71
  * needsRestart 表示该任务完成后是否仍需宿主重启(结果视图据此给「重启 / 仅完成」)。
@@ -89,7 +91,7 @@ export interface TaskQueueOptions {
89
91
 
90
92
  export function useTaskQueue(opts: TaskQueueOptions) {
91
93
  const {
92
- t, refreshInstalled, onInstallDone, onUninstallDone, onError,
94
+ t, langKey, refreshInstalled, onInstallDone, onUninstallDone, onError,
93
95
  installPlugin, installCustomTarget, installGlobalTarget, uninstallPlugin, uninstallName, installedName, resolvePending,
94
96
  } = opts
95
97
  const [queue, setQueue] = useState<QueueTask[]>([])
@@ -442,6 +444,8 @@ export function useTaskQueue(opts: TaskQueueOptions) {
442
444
  display: input.repo,
443
445
  source: input.source,
444
446
  mode: input.update ? 'update' : undefined,
447
+ // 界面语言带给服务端:网络预检等错误消息按用户当前语言返回(中文界面给中文,英文界面给英文)
448
+ ...(langKey ? { lang: langKey } : {}),
445
449
  ...(input.globalNpm && input.globalNpm.length > 0 ? { globalNpm: input.globalNpm } : {}),
446
450
  }),
447
451
  signal: controller.signal,