dsh-provider-proxy 0.3.0 → 0.4.1

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.md CHANGED
@@ -38,8 +38,8 @@ dsh plugin --profile web add dsh-provider-proxy
38
38
  | 类型 | `HTTP` / `HTTPS` / `SOCKS5` |
39
39
  | IP / 主机 | 只填主机名或 IP,不要带 `http://` 或路径 |
40
40
  | 端口 | 1–65535 的整数 |
41
- | 用户名 | 可选(SOCKS5 用,需与密码同时填写) |
42
- | 密码 | 可选(SOCKS5 用)。作为 SOCKS5 认证口令发给代理 |
41
+ | 用户名 | 可选(与密码成对填写) |
42
+ | 密码 | 可选(与用户名成对填写)。作为代理认证口令发给代理(`HTTP` / `HTTPS` / `SOCKS5` 均支持) |
43
43
 
44
44
  列表只包含**已激活**的 provider(`llm.listProviders()` 能列出的那些)。仅在配置里声明、
45
45
  但没有实际注册的可配置 provider 不显示——它的请求根本不会被发出去。
@@ -47,6 +47,19 @@ dsh plugin --profile web add dsh-provider-proxy
47
47
 
48
48
  保存立即对新请求生效,不需要重启;正在进行中的流会继续用它启动时的那条连接。
49
49
 
50
+ 每张卡片在**该 provider 已启用代理**时还有一个 **测试** 按钮:点它会让 Host 通过**该 provider 自己的代理**发一次请求,回报延迟与(探测目标返回时)出口 IP。探测目标默认是 `https://api.ipify.org?format=json`,可用顶层设置 `testUrl` 换成自建的回显服务。代理不可达时它会明确失败,而不是假通过。延迟数值按快慢着色——≤500 ms 绿、≤1500 ms 黄、更慢红——但数值始终照常显示,颜色只是辅助。
51
+
52
+ 卡片里还有一个折叠的 **高级:目标域名规则**,按**目标主机**决定是否走代理:
53
+
54
+ | 字段 | 说明 |
55
+ | --- | --- |
56
+ | `仅走代理`(`onlyHosts`) | 逗号或空格分隔。非空时,只有匹配这些主机的请求走代理,其余直连。 |
57
+ | `直连`(`bypassHosts`) | 逗号或空格分隔。匹配这些主机的请求直连,优先于 `onlyHosts`。 |
58
+
59
+ 两者都按后缀匹配(`example.com` 覆盖 `api.example.com`),`*` 表示全部,大小写不敏感。**默认是 fail-closed**:启用了代理的路由默认走代理,只有「`bypassHosts` 命中」与「`onlyHosts` 非空且未命中」两种显式例外才直连;解析不出目标主机时**仍走代理**,避免畸形 URL 把带凭据的请求悄悄直连出去。
60
+
61
+ 当组成里存在 web 服务时,列表还会多出一张 **Web 搜索** 卡片(保留 id `web-search`,字段与其它卡片完全相同,也支持自检、计数与域名规则)。它承载 `web_search` 工具的流量;不启用它,搜索保持直连。
62
+
50
63
  > **为什么是独立设置页,而不是内嵌在 Models 的 provider 卡片里?**
51
64
  > 本插件最早按卡片内嵌(`settings.models.provider-card`)实现,但那个槽位是 keyed 的、
52
65
  > 按 provider 命名空间派发,第三方注册在 0.2.0-rc.1 上始终匹配不上——不报错、不发请求、
@@ -76,14 +89,17 @@ dsh plugin --profile web add dsh-provider-proxy
76
89
  ## 已知限制
77
90
 
78
91
  - **SOCKS5 由 `undici` 内置的 `Socks5ProxyAgent` 实现**(RFC 1928 / RFC 1929),API 稳定性标记为 *Experimental*;`undici` 若在未来移除,需要同步调整。
79
- - **SOCKS5 的用户名必须与密码同时填写。** 只填用户名会被拒绝并给出提示,而不是静默降级为无认证——否则在强制认证的代理端,报错会指向代理而真正原因是设置缺项。
92
+ - **代理认证字段成对填写:用户名与密码要么都填,要么都不填。** 只填任一项都会被拒绝并给出提示。SOCKS5 侧的依据是 `Socks5ProxyAgent` 仅在两者齐备时才发起认证——只填用户名会静默广告无认证,只填密码则密码根本不会被发送(对开放代理能用,对要求认证的代理必然失败,且失败原因看起来像密码错误);HTTP/HTTPS 侧基本认证本可只发用户名(`alice:`,空密码),这里仍按成对拒绝:半填的凭据几乎总是漏填,三种传输共用一条规则比「哪种传输能承载哪种组合」更好记。代价如实写明——凭据真的使用空密码的代理无法在这里配置。不合规则的配置会被拒绝,原因列在设置页的「连接诊断」面板里(该路由的「测试」也会返回同一条),而不是被静默忽略——否则在强制认证的代理端,报错会指向代理而真正原因是设置缺项。
80
93
  - **SOCKS5 不支持 `socks://` 别名与 SOCKS5 over TLS**(`proxyTls`)。
94
+ - **`bypassHosts` 命中时由本机解析并连接目标**(DNS 不经过代理)。SOCKS5 下这一点后果最明显:命中的域名不再由代理端解析,本机 DNS 会暴露该次查询。
95
+ - **`web_search` 可以走代理,`web_fetch` 不行。** 搜索 provider(`dsh-web-search-deepseek`)用全局 `fetch`,插件通过包裹 `ctx.web.search` 把它送进代理作用域;这条成立的前提是:工具每次调用都重新取 `ctx.web.search`,且 provider 的 `search()` 是 await-to-completion(不是惰性消费的流)。`web_fetch` 则用 `undici` 自带的 `dispatcher`(来自官方的进程级 env 策略),没有 per-provider 注入点,本插件覆盖不到——需要覆盖它请改用 `HTTP_PROXY` 之类的环境变量。
96
+ - **保留 id `web-search`。** 它的路由存在 `providers` 字典里;真实 provider 若恰好撞名,会与该路由共用同一份配置,无害。
81
97
  - **上游若绕过 `globalThis.fetch` 自行建连,则不受影响。** `llm-deepseek` 确认是调用时解析全局 `fetch`;`llm-pi-ai` 走外部 SDK,抓取 `fetch` 的时机未能在源码层面确证。
82
98
  - **不支持 PAC**,也不读取系统代理设置。
83
99
  - **不改写 `baseURL`** —— 反向代理/中转请直接在 provider 上配置。
84
100
  - **代理不可达时**请求照常失败,DSH 既有的重试与错误码语义不变。
85
- - **SOCKS5 代理密码以明文随设置写入 `cordis.patch.yml`。** 它只会作为选项传给 `Socks5ProxyAgent`,不会出现在日志或诊断报告里的代理 URL 中。
86
- - **HTTP/HTTPS 代理仍只支持用户名**,密码只对 SOCKS5 生效。
101
+ - **连接自检依赖 `testUrl` 可达。** 默认的回显主机在部分网络下可能不可达,会让自检误报失败;请通过 `testUrl` 换成自建回显服务。自检只接受 `POST`,同一 provider 的多次点击会共享同一次在飞请求,不会被当成外呼放大器。
102
+ - **代理密码以明文随设置写入 `cordis.patch.yml`。** HTTP/HTTPS 的密码会拼进交给 `undici` 的代理 URL(`ProxyAgent` 由此生成 `Proxy-Authorization`),SOCKS5 的密码只作为选项传给 `Socks5ProxyAgent`;两者都不会出现在日志或诊断里的代理 URL——那里只显示不含凭据的 origin。
87
103
 
88
104
  ## 如何验证代理确实生效
89
105
 
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
- // Loader compatibility shim.
2
- //
3
- // cordis-plugin-loader resolves an entry as "<package>/index.js" by convention
4
- // and does NOT read package.json `main`/`exports`, so a package installed into
5
- // a profile must expose this file. The implementation lives in ./lib/index.js.
1
+ // Loader compatibility shim.
2
+ //
3
+ // cordis-plugin-loader resolves an entry as "<package>/index.js" by convention
4
+ // and does NOT read package.json `main`/`exports`, so a package installed into
5
+ // a profile must expose this file. The implementation lives in ./lib/index.js.
6
6
  export * from './lib/index.js'
package/lib/client.js CHANGED
@@ -39,13 +39,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
39
39
  var require_client = __commonJS({
40
40
  "src/client.css"(exports, module2) {
41
41
  "use strict";
42
- var __dshMap = { "root": "1b9ac577_root", "lead": "4a359f22_lead", "empty": "ffd21b3b_empty", "card": "16611c9b_card", "cardActive": "2d004466_cardActive", "cardHead": "640fee25_cardHead", "cardTitles": "f6fe8b3f_cardTitles", "name": "f9d2db09_name", "id": "c90ce2e0_id", "pillOn": "75541864_pillOn", "pillLive": "3097d90f_pillLive", "pillOff": "a58f86be_pillOff", "toggle": "e5b81c4b_toggle", "field": "f4d34839_field", "label": "a4061c91_label", "hint": "13bd3ec9_hint", "input": "851d7bd9_input", "diag": "f92a10d9_diag", "diagSummary": "5b66e072_diagSummary", "diagList": "dc8d3a6f_diagList" };
42
+ var __dshMap = { "root": "6d45ff27_root", "lead": "57c30143_lead", "empty": "b6600f2d_empty", "card": "0ba90fef_card", "cardActive": "923630a9_cardActive", "cardHead": "5af7e7c3_cardHead", "cardTitles": "8448919c_cardTitles", "name": "0b023e00_name", "id": "ffa6a1c5_id", "pillOn": "85972c09_pillOn", "pillLive": "d3644cdc_pillLive", "pillOff": "592bd7b2_pillOff", "toggle": "9efc135e_toggle", "field": "93a03683_field", "label": "0bd1d3ec_label", "hint": "52d56786_hint", "input": "f6748c5e_input", "testButton": "eb3b08bd_testButton", "diag": "4d4b11ef_diag", "diagSummary": "2e565ce0_diagSummary", "diagList": "fb9c7553_diagList" };
43
43
  var __dshStyles = globalThis.__dshPluginStyles || (globalThis.__dshPluginStyles = /* @__PURE__ */ new Set());
44
44
  if (!__dshStyles.has("dsh-provider-proxy/client.css")) {
45
45
  __dshStyles.add("dsh-provider-proxy/client.css");
46
46
  __dshStyle = document.createElement("style");
47
47
  __dshStyle.setAttribute("data-plugin-css", "dsh-provider-proxy/client.css");
48
- __dshStyle.textContent = "/* dsh-provider-proxy \u2014 provider proxy settings page.\n Tokens only: no literal colours, matching the shared --dsw-* system. */\n\n.1b9ac577_root {\n display: flex;\n flex-direction: column;\n gap: 12px;\n max-width: 640px;\n}\n\n.4a359f22_lead {\n margin: 0 0 4px;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-secondary);\n}\n\n.ffd21b3b_empty {\n margin: 0;\n padding: 16px;\n border: 1px dashed var(--dsw-alias-border-l1);\n border-radius: 8px;\n font-size: 12px;\n text-align: center;\n color: var(--dsw-alias-state-idle-primary);\n}\n\n/* ---- provider card ---- */\n\n.16611c9b_card,\n.2d004466_cardActive {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 12px 14px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-1);\n}\n\n.2d004466_cardActive {\n border-color: var(--dsw-alias-state-success-primary);\n background: var(--dsw-alias-bg-layer-2);\n}\n\n.640fee25_cardHead {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n\n.f6fe8b3f_cardTitles {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n}\n\n.f9d2db09_name {\n overflow: hidden;\n font-size: 13px;\n font-weight: 600;\n white-space: nowrap;\n text-overflow: ellipsis;\n color: var(--dsw-alias-label-primary);\n}\n\n.c90ce2e0_id {\n flex: none;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--dsw-alias-bg-layer-2);\n font-family: ui-monospace, monospace;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* ---- status pill ---- */\n\n.75541864_pillOn,\n.3097d90f_pillLive,\n.a58f86be_pillOff {\n flex: none;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 500;\n white-space: nowrap;\n}\n\n.75541864_pillOn {\n background: var(--dsw-alias-state-success-primary);\n color: var(--dsw-alias-bg-base);\n}\n\n.3097d90f_pillLive {\n border: 1px solid var(--dsw-alias-border-l2);\n color: var(--dsw-alias-label-secondary);\n}\n\n.a58f86be_pillOff {\n color: var(--dsw-alias-state-idle-primary);\n}\n\n/* ---- toggle ---- */\n\n.e5b81c4b_toggle {\n display: flex;\n align-items: center;\n gap: 8px;\n padding-top: 2px;\n font-size: 12px;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n}\n\n.e5b81c4b_toggle input {\n accent-color: var(--dsw-alias-brand-primary);\n cursor: pointer;\n}\n\n/* ---- fields ---- */\n\n.f4d34839_field {\n display: grid;\n grid-template-columns: 96px 1fr;\n align-items: center;\n gap: 10px;\n font-size: 12px;\n}\n\n.a4061c91_label {\n color: var(--dsw-alias-label-secondary);\n}\n\n.13bd3ec9_hint {\n grid-column: 2;\n margin-top: -2px;\n font-size: 11px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.851d7bd9_input {\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n padding: 5px 8px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 6px;\n outline: none;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 12px;\n transition: border-color 120ms ease, background 120ms ease;\n}\n\n.851d7bd9_input:hover:not(:disabled) {\n border-color: var(--dsw-alias-border-l2);\n}\n\n.851d7bd9_input:focus {\n border-color: var(--dsw-alias-brand-primary);\n}\n\n.851d7bd9_input:disabled {\n color: var(--dsw-alias-state-idle-primary);\n background: var(--dsw-alias-bg-layer-2);\n cursor: not-allowed;\n}\n\n/* ---- diagnostics ---- */\n\n.f92a10d9_diag {\n margin-top: 2px;\n padding: 8px 10px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 8px;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.5b66e072_diagSummary {\n cursor: pointer;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.dc8d3a6f_diagList {\n margin: 6px 0 0;\n padding-left: 18px;\n font-family: ui-monospace, monospace;\n word-break: break-all;\n}\n";
48
+ __dshStyle.textContent = "/* dsh-provider-proxy \u2014 provider proxy settings page.\n Tokens only: no literal colours, matching the shared --dsw-* system. */\n\n.6d45ff27_root {\n display: flex;\n flex-direction: column;\n gap: 12px;\n max-width: 640px;\n}\n\n.57c30143_lead {\n margin: 0 0 4px;\n font-size: 12px;\n line-height: 1.6;\n color: var(--dsw-alias-label-secondary);\n}\n\n.b6600f2d_empty {\n margin: 0;\n padding: 16px;\n border: 1px dashed var(--dsw-alias-border-l1);\n border-radius: 8px;\n font-size: 12px;\n text-align: center;\n color: var(--dsw-alias-state-idle-primary);\n}\n\n/* ---- provider card ---- */\n\n.0ba90fef_card,\n.923630a9_cardActive {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 12px 14px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-1);\n}\n\n.923630a9_cardActive {\n border-color: var(--dsw-alias-state-success-primary);\n background: var(--dsw-alias-bg-layer-2);\n}\n\n.5af7e7c3_cardHead {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n\n.8448919c_cardTitles {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n}\n\n.0b023e00_name {\n overflow: hidden;\n font-size: 13px;\n font-weight: 600;\n white-space: nowrap;\n text-overflow: ellipsis;\n color: var(--dsw-alias-label-primary);\n}\n\n.ffa6a1c5_id {\n flex: none;\n padding: 1px 6px;\n border-radius: 4px;\n background: var(--dsw-alias-bg-layer-2);\n font-family: ui-monospace, monospace;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n/* ---- status pill ---- */\n\n.85972c09_pillOn,\n.d3644cdc_pillLive,\n.592bd7b2_pillOff {\n flex: none;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 500;\n white-space: nowrap;\n}\n\n.85972c09_pillOn {\n background: var(--dsw-alias-state-success-primary);\n color: var(--dsw-alias-bg-base);\n}\n\n.d3644cdc_pillLive {\n border: 1px solid var(--dsw-alias-border-l2);\n color: var(--dsw-alias-label-secondary);\n}\n\n.592bd7b2_pillOff {\n color: var(--dsw-alias-state-idle-primary);\n}\n\n/* ---- toggle ---- */\n\n.9efc135e_toggle {\n display: flex;\n align-items: center;\n gap: 8px;\n padding-top: 2px;\n font-size: 12px;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n}\n\n.9efc135e_toggle input {\n accent-color: var(--dsw-alias-brand-primary);\n cursor: pointer;\n}\n\n/* ---- fields ---- */\n\n.93a03683_field {\n display: grid;\n grid-template-columns: 96px 1fr;\n align-items: center;\n gap: 10px;\n font-size: 12px;\n}\n\n.0bd1d3ec_label {\n color: var(--dsw-alias-label-secondary);\n}\n\n.52d56786_hint {\n grid-column: 2;\n margin-top: -2px;\n font-size: 11px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.f6748c5e_input {\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n padding: 5px 8px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 6px;\n outline: none;\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 12px;\n transition: border-color 120ms ease, background 120ms ease;\n}\n\n/* The base look of every input is INLINE too, so these three state rules only\n ever take effect as author-important declarations \u2014 same reason as the\n self-check button above. `cursor` has no inline base and needs none, and the\n hover border uses `l3` because `l1`\u2192`l2` is a 4%\u219210% black step nobody can\n see. */\n\n.f6748c5e_input:hover:not(:disabled) {\n border-color: var(--dsw-alias-border-l3) !important;\n}\n\n.f6748c5e_input:focus {\n border-color: var(--dsw-alias-brand-primary) !important;\n}\n\n.f6748c5e_input:disabled {\n color: var(--dsw-alias-state-idle-primary) !important;\n background: var(--dsw-alias-bg-layer-2) !important;\n cursor: not-allowed;\n}\n\n/* ---- self-check ---- */\n\n/* The hover state is the one thing an inline style cannot express, which is why\n this element carries a class at all. Layout stays inline.\n\n Two traps, both hit before this comment existed:\n - `!important` is load-bearing: the base border and background are INLINE, and\n the cascade sorts a style attribute after every author rule, so a plain\n declaration here would ship, match, and still lose.\n - the TOKENS must be visible ones. `bg-layer-1/2/3` all resolve to\n `neutral-bluish-00` in the light theme, so a layer swap is a no-op there, and\n `border-l1`\u2192`l2` is 4%\u219210% black on a 1px line. The app's own hovers use\n `interactive-bg-hover`; using it here is what makes the feedback exist. */\n.eb3b08bd_testButton {\n transition: border-color 120ms ease, background 120ms ease;\n}\n\n.eb3b08bd_testButton:hover:not(:disabled) {\n border-color: var(--dsw-alias-border-l3) !important;\n background: var(--dsw-alias-interactive-bg-hover) !important;\n}\n\n/* ---- diagnostics ---- */\n\n.4d4b11ef_diag {\n margin-top: 2px;\n padding: 8px 10px;\n border: 1px solid var(--dsw-alias-border-l1);\n border-radius: 8px;\n font-size: 11px;\n color: var(--dsw-alias-label-secondary);\n}\n\n.2e565ce0_diagSummary {\n cursor: pointer;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.fb9c7553_diagList {\n margin: 6px 0 0;\n padding-left: 18px;\n font-family: ui-monospace, monospace;\n word-break: break-all;\n}\n";
49
49
  document.head.appendChild(__dshStyle);
50
50
  }
51
51
  var __dshStyle;
@@ -159,18 +159,52 @@ function knownProviders(configured, published) {
159
159
  return [...rows.values()].sort(byName);
160
160
  }
161
161
  var CATALOG_PATH = "/dsh-provider-proxy/providers";
162
+ var TEST_PATH = "/dsh-provider-proxy/test";
163
+ var LATENCY_FAST_MS = 500;
164
+ var LATENCY_SLOW_MS = 1500;
165
+ function latencyColor(ms) {
166
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return "var(--dsw-alias-label-secondary)";
167
+ if (ms <= LATENCY_FAST_MS) return "var(--dsw-alias-state-success-primary, #2ea043)";
168
+ if (ms <= LATENCY_SLOW_MS) return "var(--dsw-alias-state-warn-primary, #d29922)";
169
+ return "var(--dsw-alias-state-error-primary, #f85149)";
170
+ }
171
+ function testResultFrom(body, strings) {
172
+ const ok = body?.ok === true;
173
+ return {
174
+ pending: false,
175
+ ok,
176
+ ms: typeof body?.ms === "number" && Number.isFinite(body.ms) ? body.ms : void 0,
177
+ ip: typeof body?.exitIp === "string" && body.exitIp.length > 0 ? body.exitIp : void 0,
178
+ error: ok ? void 0 : typeof body?.error === "string" && body.error.length > 0 ? body.error : strings.testFailed
179
+ };
180
+ }
162
181
  var catalogueSetter = () => {
163
182
  };
164
- async function fetchProviders() {
183
+ async function fetchCatalog() {
165
184
  const response = await globalThis.fetch(CATALOG_PATH, { headers: { accept: "application/json" } });
166
185
  if (!response.ok) throw new Error(`Host answered ${response.status}`);
167
186
  const body = await response.json();
168
187
  const list = Array.isArray(body?.providers) ? body.providers : [];
169
- return list.filter((row) => typeof row?.id === "string" && row.id.length > 0).map((row) => ({
188
+ const rows = list.filter((row) => typeof row?.id === "string" && row.id.length > 0).map((row) => ({
170
189
  id: row.id,
171
190
  name: typeof row.name === "string" && row.name.length > 0 ? row.name : row.id,
172
191
  live: row.live === true
173
192
  }));
193
+ const hits = {};
194
+ const published = body?.hits;
195
+ if (published !== null && typeof published === "object") {
196
+ for (const [id, count] of Object.entries(published)) {
197
+ if (typeof count === "number" && Number.isFinite(count)) hits[id] = count;
198
+ }
199
+ }
200
+ const problems = {};
201
+ const rejected = body?.problems;
202
+ if (rejected !== null && typeof rejected === "object") {
203
+ for (const [id, message] of Object.entries(rejected)) {
204
+ if (typeof message === "string" && message.length > 0) problems[id] = message;
205
+ }
206
+ }
207
+ return { rows, hits, problems };
174
208
  }
175
209
  var byName = (a, b) => a.name.localeCompare(b.name);
176
210
  function view(route) {
@@ -180,7 +214,9 @@ function view(route) {
180
214
  host: route?.host ?? "",
181
215
  port: route?.port ?? 0,
182
216
  username: route?.username ?? "",
183
- password: route?.password ?? ""
217
+ password: route?.password ?? "",
218
+ onlyHosts: route?.onlyHosts ?? "",
219
+ bypassHosts: route?.bypassHosts ?? ""
184
220
  };
185
221
  }
186
222
  var diagnostics = [];
@@ -232,6 +268,55 @@ var S = {
232
268
  fontSize: "11px",
233
269
  color: "var(--dsw-alias-label-secondary)"
234
270
  },
271
+ hits: {
272
+ flex: "none",
273
+ fontSize: "11px",
274
+ color: "var(--dsw-alias-label-secondary)",
275
+ whiteSpace: "nowrap"
276
+ },
277
+ testRow: {
278
+ display: "flex",
279
+ alignItems: "center",
280
+ gap: "10px",
281
+ flexWrap: "wrap"
282
+ },
283
+ testButton: {
284
+ padding: "5px 12px",
285
+ border: "1px solid var(--dsw-alias-border-l1)",
286
+ borderRadius: "6px",
287
+ background: "var(--dsw-alias-bg-layer-1)",
288
+ color: "var(--dsw-alias-label-primary)",
289
+ font: "inherit",
290
+ fontSize: "12px",
291
+ cursor: "pointer"
292
+ },
293
+ testResult: {
294
+ fontSize: "11px",
295
+ color: "var(--dsw-alias-label-secondary)",
296
+ wordBreak: "break-all"
297
+ },
298
+ /**
299
+ * A failed check is the answer, not decoration: it takes the error colour and
300
+ * a heavier weight, unlike the latency band colour which only tints a number
301
+ * that is printed anyway.
302
+ */
303
+ testFailure: {
304
+ color: "var(--dsw-alias-state-error-primary, #f85149)",
305
+ fontWeight: "500"
306
+ },
307
+ /** A rejected route is a failure of the settings, not a warning about them. */
308
+ diagProblem: {
309
+ color: "var(--dsw-alias-state-error-primary, #f85149)"
310
+ },
311
+ advanced: {
312
+ marginTop: "4px",
313
+ fontSize: "12px"
314
+ },
315
+ advancedSummary: {
316
+ cursor: "pointer",
317
+ fontSize: "11px",
318
+ color: "var(--dsw-alias-label-secondary)"
319
+ },
235
320
  pill: {
236
321
  flex: "none",
237
322
  padding: "2px 10px",
@@ -296,20 +381,52 @@ function renderPage(ctx) {
296
381
  );
297
382
  const [state, setState] = (React.useState ?? ((fn) => [fn(), () => {
298
383
  }]))(() => {
299
- void fetchProviders().then((rows2) => catalogueSetter?.(rows2)).catch((error) => {
384
+ void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch((error) => {
300
385
  note(`provider \u5217\u8868\u83B7\u53D6\u5931\u8D25\uFF1A${error?.message ?? error}`);
301
- catalogueSetter?.([]);
386
+ catalogueSetter?.({ rows: [], hits: {}, problems: {} });
302
387
  });
303
- return { rows: void 0, pending: true };
388
+ return {
389
+ rows: void 0,
390
+ hits: {},
391
+ problems: {},
392
+ pending: true
393
+ };
304
394
  });
305
- catalogueSetter = (rows2) => setState({ rows: rows2, pending: false });
395
+ catalogueSetter = (catalog) => setState({ rows: catalog.rows, hits: catalog.hits, problems: catalog.problems, pending: false });
306
396
  const published = state.rows ?? [];
397
+ const hits = state.hits ?? {};
398
+ const problems = state.problems ?? {};
399
+ const problemLines = Object.entries(problems).map(
400
+ ([id, message]) => h("li", { key: `p:${id}`, style: S.diagProblem }, `${id}\uFF1A${message}`)
401
+ );
402
+ const problemCount = problemLines.length;
307
403
  note(
308
404
  `provider \u5217\u8868\uFF1A\u6765\u81EA Host \u5171 ${published.length} \u4E2A\uFF08${published.map((r) => r.id).join("\u3001") || "\u65E0"}\uFF09`
309
405
  );
310
406
  const [draft, setDraft] = (React.useState ?? ((v) => [v, () => {
311
407
  }]))({});
312
408
  const edit = (id, field2, value) => setDraft((current) => ({ ...current, [id]: { ...current[id], [field2]: value } }));
409
+ const [tests, setTests] = (React.useState ?? ((v) => [v, () => {
410
+ }]))({});
411
+ const runTest = (id) => {
412
+ setTests((current) => ({ ...current, [id]: { pending: true } }));
413
+ void (async () => {
414
+ try {
415
+ const response = await globalThis.fetch(TEST_PATH, {
416
+ method: "POST",
417
+ headers: { "content-type": "application/json", accept: "application/json" },
418
+ body: JSON.stringify({ provider: id })
419
+ });
420
+ const body2 = await response.json();
421
+ setTests((current) => ({ ...current, [id]: testResultFrom(body2, t) }));
422
+ } catch (error) {
423
+ setTests((current) => ({
424
+ ...current,
425
+ [id]: { pending: false, ok: false, error: String(error?.message ?? error) }
426
+ }));
427
+ }
428
+ })();
429
+ };
313
430
  const rows = knownProviders({ ...configured, ...draft }, published);
314
431
  const save = (id, field2, value) => {
315
432
  void writeConfig(ctx, [{ op: "set", path: ["providers", id, field2], value }]).then(() => {
@@ -359,6 +476,7 @@ function renderPage(ctx) {
359
476
  ] : rows.map((row) => {
360
477
  const route = view({ ...configured[row.id], ...draft[row.id] });
361
478
  const disabled = !route.enabled;
479
+ const result = tests[row.id];
362
480
  return h(
363
481
  "section",
364
482
  {
@@ -373,7 +491,14 @@ function renderPage(ctx) {
373
491
  "div",
374
492
  { className: import_client.default.cardTitles, style: S.titles },
375
493
  h("span", { className: import_client.default.name, style: S.name }, row.name),
376
- h("span", { className: import_client.default.id, style: S.id }, row.id)
494
+ h("span", { className: import_client.default.id, style: S.id }, row.id),
495
+ // A disabled route can never have hits, so "0" there would only add
496
+ // noise; the count shows once the route is on (or has counted).
497
+ route.enabled || (hits[row.id] ?? 0) > 0 ? h(
498
+ "span",
499
+ { style: S.hits },
500
+ `${t.hitCountPrefix}${hits[row.id] ?? 0}${t.hitCountSuffix}`
501
+ ) : null
377
502
  ),
378
503
  h(
379
504
  "span",
@@ -454,13 +579,12 @@ function renderPage(ctx) {
454
579
  onChange: (event) => bind(row.id, "username")(event.target.value)
455
580
  })
456
581
  ),
457
- // Only SOCKS5 authenticates in-band, so only SOCKS5 shows this field.
458
- //
459
- // The password is stored on the route and therefore written into the
460
- // settings file with the rest of the config. It is handed to
461
- // Socks5ProxyAgent as an option, never embedded in the proxy URL that
462
- // the diagnostics surface.
463
- route.protocol === "socks5" ? field(
582
+ // Every transport accepts a password now: SOCKS5 hands it to
583
+ // Socks5ProxyAgent as an option, HTTP/HTTPS embed it in the proxy URL
584
+ // userinfo (undici turns that into Proxy-Authorization). It is stored on
585
+ // the route, so it lands in the settings file with the rest of the
586
+ // config; the diagnostics only ever surface a credential-free origin.
587
+ field(
464
588
  t.password,
465
589
  h("input", {
466
590
  className: import_client.default.input,
@@ -471,8 +595,69 @@ function renderPage(ctx) {
471
595
  value: route.password,
472
596
  onChange: (event) => bind(row.id, "password")(event.target.value)
473
597
  }),
474
- t.passwordHint
475
- ) : void 0
598
+ route.protocol === "socks5" ? t.passwordHint : t.passwordHintProxy
599
+ ),
600
+ // The self-check only makes sense for a route that is ON; a disabled
601
+ // route is not in the Host's table, so there is nothing to test.
602
+ route.enabled ? h(
603
+ "div",
604
+ { style: S.testRow, key: `${row.id}:test` },
605
+ h(
606
+ "button",
607
+ {
608
+ className: import_client.default.testButton,
609
+ style: S.testButton,
610
+ type: "button",
611
+ disabled: result?.pending === true,
612
+ onClick: () => runTest(row.id)
613
+ },
614
+ result?.pending === true ? t.testRunning : t.testButton
615
+ ),
616
+ result?.pending === false ? h(
617
+ "span",
618
+ { style: S.testResult },
619
+ result.ok === true ? [
620
+ // Only the latency carries the band colour; the exit IP
621
+ // stays in the secondary label colour.
622
+ h(
623
+ "span",
624
+ { key: "lat", style: { color: latencyColor(result.ms) } },
625
+ `\u2713 ${typeof result.ms === "number" ? result.ms : "\u2014"} ms`
626
+ ),
627
+ result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`
628
+ ] : h("span", { key: "err", style: S.testFailure }, `\u2717 ${result.error}`)
629
+ ) : null
630
+ ) : null,
631
+ // Target-host rules, collapsed so the common case stays one short card.
632
+ h(
633
+ "details",
634
+ { style: S.advanced, key: `${row.id}:rules` },
635
+ h("summary", { style: S.advancedSummary }, t.advanced),
636
+ field(
637
+ t.onlyHosts,
638
+ h("input", {
639
+ className: import_client.default.input,
640
+ style: S.input,
641
+ disabled,
642
+ placeholder: "api.example.com",
643
+ value: route.onlyHosts,
644
+ onChange: (event) => bind(row.id, "onlyHosts")(event.target.value)
645
+ }),
646
+ t.onlyHostsHint
647
+ ),
648
+ field(
649
+ t.bypassHosts,
650
+ h("input", {
651
+ className: import_client.default.input,
652
+ style: S.input,
653
+ disabled,
654
+ placeholder: "localhost",
655
+ value: route.bypassHosts,
656
+ onChange: (event) => bind(row.id, "bypassHosts")(event.target.value)
657
+ }),
658
+ t.bypassHostsHint
659
+ )
660
+ )
476
661
  );
477
662
  });
478
663
  return h(
@@ -494,11 +679,21 @@ function renderPage(ctx) {
494
679
  color: "var(--dsw-alias-label-secondary)"
495
680
  }
496
681
  },
497
- h("summary", { className: import_client.default.diagSummary }, t.diagnostics),
682
+ // The count is the only visible signal while the panel is collapsed: a
683
+ // rejected route is not a page that refuses to render, it is one that
684
+ // quietly does nothing.
685
+ h(
686
+ "summary",
687
+ { className: import_client.default.diagSummary },
688
+ problemCount === 0 ? t.diagnostics : `${t.diagnostics} (${problemCount})`
689
+ ),
498
690
  h(
499
691
  "ul",
500
692
  { className: import_client.default.diagList, style: { margin: "6px 0 0", paddingLeft: "18px" } },
501
- ...diagnostics.length === 0 ? [h("li", { key: "none" }, t.diagPending)] : diagnostics.map((line, index) => h("li", { key: `d${index}` }, line))
693
+ ...problemCount === 0 && diagnostics.length === 0 ? [h("li", { key: "none" }, t.diagPending)] : [
694
+ ...problemLines,
695
+ ...diagnostics.map((line, index) => h("li", { key: `d${index}` }, line))
696
+ ]
502
697
  )
503
698
  )
504
699
  );
@@ -514,6 +709,18 @@ var EN = {
514
709
  password: "Password",
515
710
  hostHint: "Host or IP only, without scheme or path.",
516
711
  passwordHint: "Sent to the SOCKS5 proxy for authentication; stored with this setting.",
712
+ passwordHintProxy: "Sent to the proxy as an authentication password; stored with this setting.",
713
+ hitCountPrefix: "",
714
+ hitCountSuffix: " proxied",
715
+ testButton: "Test",
716
+ testRunning: "Testing\u2026",
717
+ testFailed: "Test failed",
718
+ exitIpLabel: "exit IP ",
719
+ advanced: "Advanced: target host rules",
720
+ onlyHosts: "Proxy only",
721
+ onlyHostsHint: "Comma/space separated; when set, only requests to these hosts are proxied (suffix match, * for all).",
722
+ bypassHosts: "Bypass",
723
+ bypassHostsHint: "Comma/space separated; requests to these hosts go direct, taking priority.",
517
724
  socks5Hint: "SOCKS5 tunnels the connection; the proxy resolves the target host name.",
518
725
  on: "proxied",
519
726
  live: "active",
@@ -532,6 +739,18 @@ var ZH = {
532
739
  password: "\u5BC6\u7801",
533
740
  hostHint: "\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\u3002",
534
741
  passwordHint: "\u4EC5\u4F5C\u4E3A SOCKS5 \u8BA4\u8BC1\u53E3\u4EE4\u53D1\u9001\u7ED9\u4EE3\u7406\uFF1B\u968F\u8BE5\u8BBE\u7F6E\u4E00\u8D77\u4FDD\u5B58\u3002",
742
+ passwordHintProxy: "\u4F5C\u4E3A\u4EE3\u7406\u8BA4\u8BC1\u53E3\u4EE4\u53D1\u9001\u7ED9\u4EE3\u7406\uFF1B\u968F\u8BE5\u8BBE\u7F6E\u4E00\u8D77\u4FDD\u5B58\u3002",
743
+ hitCountPrefix: "\u5DF2\u4EE3\u7406 ",
744
+ hitCountSuffix: " \u6B21",
745
+ testButton: "\u6D4B\u8BD5",
746
+ testRunning: "\u6D4B\u8BD5\u4E2D\u2026",
747
+ testFailed: "\u6D4B\u8BD5\u5931\u8D25",
748
+ exitIpLabel: "\u51FA\u53E3 IP ",
749
+ advanced: "\u9AD8\u7EA7\uFF1A\u76EE\u6807\u57DF\u540D\u89C4\u5219",
750
+ onlyHosts: "\u4EC5\u8D70\u4EE3\u7406",
751
+ onlyHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u975E\u7A7A\u65F6\u53EA\u6709\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u8D70\u4EE3\u7406\uFF08\u540E\u7F00\u5339\u914D\uFF0C* \u8868\u793A\u5168\u90E8\uFF09\u3002",
752
+ bypassHosts: "\u76F4\u8FDE",
753
+ bypassHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u76F4\u8FDE\uFF0C\u4F18\u5148\u4E8E\u4E0A\u4E00\u9879\u3002",
535
754
  socks5Hint: "SOCKS5 \u4F1A\u5EFA\u7ACB\u96A7\u9053\uFF0C\u76EE\u6807\u57DF\u540D\u7531\u4EE3\u7406\u7AEF\u89E3\u6790\u3002",
536
755
  on: "\u8D70\u4EE3\u7406",
537
756
  live: "\u542F\u7528\u4E2D",