dsh-provider-proxy 0.4.1 → 0.5.0

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/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": "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" };
42
+ var __dshMap = { "root": "7a51762d_root", "lead": "c71659b2_lead", "empty": "c42c4abe_empty", "card": "53acf569_card", "cardActive": "bd4546f8_cardActive", "defaultCard": "1103ba6e_defaultCard", "defaultLead": "0b907dc6_defaultLead", "cardHead": "d06dccdb_cardHead", "cardTitles": "ff20149f_cardTitles", "name": "4e36a10e_name", "id": "79208064_id", "pillOn": "3e0969fd_pillOn", "pillLive": "895a1b6e_pillLive", "pillOff": "f5c4d227_pillOff", "toggle": "94e6424f_toggle", "field": "5dfb5268_field", "label": "2e13439d_label", "hint": "cdd952c6_hint", "input": "31dd2d99_input", "testButton": "028bc91c_testButton", "diag": "5d114b0b_diag", "diagSummary": "27518b39_diagSummary", "diagList": "b3005b62_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.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";
48
+ __dshStyle.textContent = '/* dsh-provider-proxy \u2014 provider proxy settings page.\n Tokens only: no literal colours, matching the shared --dsw-* system. */\n\n.7a51762d_root {\n display: flex;\n flex-direction: column;\n gap: 12px;\n max-width: 640px;\n}\n\n.c71659b2_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.c42c4abe_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.53acf569_card,\n.bd4546f8_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.bd4546f8_cardActive {\n border-color: var(--dsw-alias-state-success-primary);\n background: var(--dsw-alias-bg-layer-2);\n}\n\n/* ---- shared default endpoint ---- */\n\n/* A distinct outer layer, so it reads as "one setting for the page" rather than\n as one more provider card. */\n.1103ba6e_defaultCard {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 14px 16px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-alias-bg-layer-2);\n}\n\n.0b907dc6_defaultLead {\n margin: -2px 0 0;\n font-size: 11px;\n line-height: 1.5;\n color: var(--dsw-alias-label-secondary);\n}\n\n.d06dccdb_cardHead {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n\n.ff20149f_cardTitles {\n display: flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n}\n\n.4e36a10e_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.79208064_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.3e0969fd_pillOn,\n.895a1b6e_pillLive,\n.f5c4d227_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.3e0969fd_pillOn {\n background: var(--dsw-alias-state-success-primary);\n color: var(--dsw-alias-bg-base);\n}\n\n.895a1b6e_pillLive {\n border: 1px solid var(--dsw-alias-border-l2);\n color: var(--dsw-alias-label-secondary);\n}\n\n.f5c4d227_pillOff {\n color: var(--dsw-alias-state-idle-primary);\n}\n\n/* ---- toggle ---- */\n\n.94e6424f_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.94e6424f_toggle input {\n accent-color: var(--dsw-alias-brand-primary);\n cursor: pointer;\n}\n\n/* ---- fields ---- */\n\n.5dfb5268_field {\n display: grid;\n grid-template-columns: 96px 1fr;\n align-items: center;\n gap: 10px;\n font-size: 12px;\n}\n\n.2e13439d_label {\n color: var(--dsw-alias-label-secondary);\n}\n\n.cdd952c6_hint {\n grid-column: 2;\n margin-top: -2px;\n font-size: 11px;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.31dd2d99_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.31dd2d99_input:hover:not(:disabled) {\n border-color: var(--dsw-alias-border-l3) !important;\n}\n\n.31dd2d99_input:focus {\n border-color: var(--dsw-alias-brand-primary) !important;\n}\n\n.31dd2d99_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.028bc91c_testButton {\n transition: border-color 120ms ease, background 120ms ease;\n}\n\n.028bc91c_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.5d114b0b_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.27518b39_diagSummary {\n cursor: pointer;\n color: var(--dsw-alias-state-warn-primary);\n}\n\n.b3005b62_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;
@@ -63,6 +63,27 @@ __export(client_exports, {
63
63
  });
64
64
  module.exports = __toCommonJS(client_exports);
65
65
  var import_client = __toESM(require_client(), 1);
66
+
67
+ // src/proxy-address.ts
68
+ function defaultProxyProblem(endpoint) {
69
+ const host = (endpoint.host ?? "").trim();
70
+ if (host.length === 0) return "\u672A\u586B\u5199\u4EE3\u7406\u5730\u5740";
71
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(host) || host.includes("/")) {
72
+ return "\u4EE3\u7406\u5730\u5740\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84";
73
+ }
74
+ const port = endpoint.port ?? 0;
75
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
76
+ return "\u4EE3\u7406\u7AEF\u53E3\u65E0\u6548\uFF08\u9700\u4E3A 1-65535 \u7684\u6574\u6570\uFF09";
77
+ }
78
+ const hasUser = (endpoint.username ?? "").length > 0;
79
+ const hasPassword = (endpoint.password ?? "").length > 0;
80
+ if (hasUser !== hasPassword) {
81
+ return hasUser ? "\u914D\u7F6E\u4E86\u4EE3\u7406\u7528\u6237\u540D\u4F46\u6CA1\u6709\u5BC6\u7801\uFF1B\u8BF7\u8865\u5168\u5BC6\u7801\u6216\u6E05\u7A7A\u7528\u6237\u540D" : "\u914D\u7F6E\u4E86\u4EE3\u7406\u5BC6\u7801\u4F46\u6CA1\u6709\u7528\u6237\u540D\uFF1B\u8BF7\u8865\u5168\u7528\u6237\u540D\u6216\u6E05\u7A7A\u5BC6\u7801";
82
+ }
83
+ return void 0;
84
+ }
85
+
86
+ // src/client.ts
66
87
  var ReactCache;
67
88
  function react() {
68
89
  if (ReactCache === void 0) {
@@ -88,7 +109,7 @@ function apply(ctx) {
88
109
  name: "settings.section",
89
110
  id: NAMESPACE,
90
111
  order: 20,
91
- label: () => localeOf(ctx) === "zh" ? "Provider \u4EE3\u7406" : "Provider \u4EE3\u7406"
112
+ label: () => "Provider \u4EE3\u7406"
92
113
  },
93
114
  () => renderPage(ctx)
94
115
  )
@@ -175,11 +196,24 @@ function testResultFrom(body, strings) {
175
196
  ok,
176
197
  ms: typeof body?.ms === "number" && Number.isFinite(body.ms) ? body.ms : void 0,
177
198
  ip: typeof body?.exitIp === "string" && body.exitIp.length > 0 ? body.exitIp : void 0,
199
+ // The Host probes a target the way routing would: a loopback/private upstream
200
+ // (or a bypassHosts match) is reached DIRECT, not through the proxy. The card
201
+ // says so, so a green check is not read as "the proxy works".
202
+ direct: body?.viaProxy === false,
178
203
  error: ok ? void 0 : typeof body?.error === "string" && body.error.length > 0 ? body.error : strings.testFailed
179
204
  };
180
205
  }
181
206
  var catalogueSetter = () => {
182
207
  };
208
+ function relativeTime(at, t) {
209
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1e3));
210
+ if (seconds < 60) return t.justNow;
211
+ const minutes = Math.floor(seconds / 60);
212
+ if (minutes < 60) return `${minutes}${t.minutesAgo}`;
213
+ const hours = Math.floor(minutes / 60);
214
+ if (hours < 24) return `${hours}${t.hoursAgo}`;
215
+ return `${Math.floor(hours / 24)}${t.daysAgo}`;
216
+ }
183
217
  async function fetchCatalog() {
184
218
  const response = await globalThis.fetch(CATALOG_PATH, { headers: { accept: "application/json" } });
185
219
  if (!response.ok) throw new Error(`Host answered ${response.status}`);
@@ -204,21 +238,92 @@ async function fetchCatalog() {
204
238
  if (typeof message === "string" && message.length > 0) problems[id] = message;
205
239
  }
206
240
  }
207
- return { rows, hits, problems };
241
+ const lastUse = {};
242
+ const publishedLastUse = body?.lastUse;
243
+ if (publishedLastUse !== null && typeof publishedLastUse === "object") {
244
+ for (const [id, raw] of Object.entries(publishedLastUse)) {
245
+ const entry = raw;
246
+ if (entry === null || typeof entry !== "object") continue;
247
+ const at = typeof entry.at === "number" && Number.isFinite(entry.at) ? entry.at : void 0;
248
+ const startedAt = typeof entry.startedAt === "number" && Number.isFinite(entry.startedAt) ? entry.startedAt : void 0;
249
+ if (at === void 0 && startedAt === void 0) continue;
250
+ lastUse[id] = {
251
+ ...startedAt === void 0 ? {} : { startedAt },
252
+ ...at === void 0 ? {} : { at },
253
+ ...typeof entry.ok === "boolean" ? { ok: entry.ok } : {},
254
+ ...typeof entry.code === "string" && entry.code.length > 0 ? { code: entry.code } : {}
255
+ };
256
+ }
257
+ }
258
+ return { rows, hits, problems, lastUse };
259
+ }
260
+ var CATALOG_REFRESH_MS = 1e4;
261
+ var catalogPoll;
262
+ var catalogTouchedAt = 0;
263
+ function touchCatalogPolling() {
264
+ catalogTouchedAt = Date.now();
265
+ if (catalogPoll !== void 0) return;
266
+ catalogPoll = setInterval(() => {
267
+ if (Date.now() - catalogTouchedAt > CATALOG_REFRESH_MS * 2 + 1e3) {
268
+ clearInterval(catalogPoll);
269
+ catalogPoll = void 0;
270
+ return;
271
+ }
272
+ void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch(() => void 0);
273
+ }, CATALOG_REFRESH_MS);
208
274
  }
209
275
  var byName = (a, b) => a.name.localeCompare(b.name);
276
+ function deadRuleEntries(raw) {
277
+ return raw.split(/[,\s]+/).map((entry) => entry.trim()).filter((entry) => /^[a-z][a-z0-9+.-]*:\/\//i.test(entry) || entry.includes("/"));
278
+ }
210
279
  function view(route) {
211
280
  return {
212
281
  enabled: route?.enabled === true,
282
+ useDefault: route?.useDefault === true,
213
283
  protocol: PROTOCOLS.includes(route?.protocol) ? route.protocol : "http",
214
284
  host: route?.host ?? "",
215
285
  port: route?.port ?? 0,
216
286
  username: route?.username ?? "",
217
287
  password: route?.password ?? "",
218
288
  onlyHosts: route?.onlyHosts ?? "",
219
- bypassHosts: route?.bypassHosts ?? ""
289
+ bypassHosts: route?.bypassHosts ?? "",
290
+ testTarget: route?.testTarget ?? ""
220
291
  };
221
292
  }
293
+ function unbracket(hostname) {
294
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
295
+ }
296
+ function splitProxyUrl(text) {
297
+ const trimmed = text.trim();
298
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return void 0;
299
+ let url;
300
+ try {
301
+ url = new URL(trimmed);
302
+ } catch {
303
+ return void 0;
304
+ }
305
+ const scheme = url.protocol.slice(0, -1).toLowerCase();
306
+ const protocol = scheme === "socks" ? "socks5" : scheme;
307
+ if (!PROTOCOLS.includes(protocol)) return void 0;
308
+ if (url.hostname.length === 0) return void 0;
309
+ const decode = (value) => {
310
+ try {
311
+ return decodeURIComponent(value);
312
+ } catch {
313
+ return value;
314
+ }
315
+ };
316
+ const parsedPort = url.port.length > 0 ? Number.parseInt(url.port, 10) : 0;
317
+ return {
318
+ protocol,
319
+ host: unbracket(url.hostname),
320
+ port: Number.isNaN(parsedPort) ? 0 : parsedPort,
321
+ username: decode(url.username),
322
+ password: decode(url.password)
323
+ };
324
+ }
325
+ var WRITE_DELAY_MS = 300;
326
+ var pendingWrites = /* @__PURE__ */ new Map();
222
327
  var diagnostics = [];
223
328
  function note(message) {
224
329
  if (!diagnostics.includes(message)) diagnostics.push(message);
@@ -334,6 +439,40 @@ var S = {
334
439
  color: "var(--dsw-alias-label-secondary)"
335
440
  },
336
441
  pillOff: { color: "var(--dsw-alias-state-idle-primary)" },
442
+ /**
443
+ * The shared default endpoint sits on its own layer so it reads as global
444
+ * rather than as one more provider card — the mistake it exists to prevent is
445
+ * a user typing the same address into every card.
446
+ */
447
+ defaultCard: {
448
+ display: "flex",
449
+ flexDirection: "column",
450
+ gap: "10px",
451
+ padding: "14px 16px",
452
+ border: "1px solid var(--dsw-alias-border-l2)",
453
+ borderRadius: "10px",
454
+ background: "var(--dsw-alias-bg-layer-2)"
455
+ },
456
+ defaultLead: {
457
+ margin: "-2px 0 0",
458
+ fontSize: "11px",
459
+ lineHeight: "1.5",
460
+ color: "var(--dsw-alias-label-secondary)"
461
+ },
462
+ inheritNote: {
463
+ margin: "0",
464
+ fontSize: "11px",
465
+ lineHeight: "1.5",
466
+ color: "var(--dsw-alias-label-secondary)"
467
+ },
468
+ /** The default is not configured: an answer, so it takes the error colour. */
469
+ inheritWarning: {
470
+ margin: "0",
471
+ fontSize: "11px",
472
+ lineHeight: "1.5",
473
+ fontWeight: "500",
474
+ color: "var(--dsw-alias-state-error-primary, #f85149)"
475
+ },
337
476
  toggle: {
338
477
  display: "flex",
339
478
  alignItems: "center",
@@ -383,18 +522,34 @@ function renderPage(ctx) {
383
522
  }]))(() => {
384
523
  void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch((error) => {
385
524
  note(`provider \u5217\u8868\u83B7\u53D6\u5931\u8D25\uFF1A${error?.message ?? error}`);
386
- catalogueSetter?.({ rows: [], hits: {}, problems: {} });
525
+ catalogueSetter?.({ rows: [], hits: {}, problems: {}, lastUse: {} });
387
526
  });
388
527
  return {
389
528
  rows: void 0,
390
529
  hits: {},
391
530
  problems: {},
531
+ lastUse: {},
392
532
  pending: true
393
533
  };
394
534
  });
395
- catalogueSetter = (catalog) => setState({ rows: catalog.rows, hits: catalog.hits, problems: catalog.problems, pending: false });
535
+ catalogueSetter = (catalog) => setState({
536
+ rows: catalog.rows,
537
+ hits: catalog.hits,
538
+ problems: catalog.problems,
539
+ lastUse: catalog.lastUse,
540
+ pending: false
541
+ });
542
+ touchCatalogPolling();
396
543
  const published = state.rows ?? [];
397
544
  const hits = state.hits ?? {};
545
+ const lastUse = state.lastUse ?? {};
546
+ const lastUseText = (id) => {
547
+ const use = lastUse[id];
548
+ if (use === void 0) return void 0;
549
+ if (use.ok === true) return `${t.recentOk}${relativeTime(use.at ?? use.startedAt ?? Date.now(), t)}`;
550
+ if (use.ok === false) return `${t.recentFail}${use.code ?? "ERROR"}`;
551
+ return `${t.recentStarted}${relativeTime(use.startedAt ?? use.at ?? Date.now(), t)}`;
552
+ };
398
553
  const problems = state.problems ?? {};
399
554
  const problemLines = Object.entries(problems).map(
400
555
  ([id, message]) => h("li", { key: `p:${id}`, style: S.diagProblem }, `${id}\uFF1A${message}`)
@@ -406,8 +561,13 @@ function renderPage(ctx) {
406
561
  const [draft, setDraft] = (React.useState ?? ((v) => [v, () => {
407
562
  }]))({});
408
563
  const edit = (id, field2, value) => setDraft((current) => ({ ...current, [id]: { ...current[id], [field2]: value } }));
564
+ const [defaultDraft, setDefaultDraft] = (React.useState ?? ((v) => [v, () => {
565
+ }]))({});
566
+ const editDefault = (field2, value) => setDefaultDraft((current) => ({ ...current, [field2]: value }));
409
567
  const [tests, setTests] = (React.useState ?? ((v) => [v, () => {
410
568
  }]))({});
569
+ const [filter, setFilter] = (React.useState ?? ((v) => [v, () => {
570
+ }]))("");
411
571
  const runTest = (id) => {
412
572
  setTests((current) => ({ ...current, [id]: { pending: true } }));
413
573
  void (async () => {
@@ -428,34 +588,293 @@ function renderPage(ctx) {
428
588
  })();
429
589
  };
430
590
  const rows = knownProviders({ ...configured, ...draft }, published);
431
- const save = (id, field2, value) => {
432
- void writeConfig(ctx, [{ op: "set", path: ["providers", id, field2], value }]).then(() => {
433
- note(`\u5DF2\u4FDD\u5B58 ${id}.${field2} = ${JSON.stringify(value)}`);
434
- }).catch((error) => {
435
- note(`\u4FDD\u5B58\u5931\u8D25 ${id}.${field2}\uFF1A${error?.message ?? error}`);
436
- setDraft((current) => {
437
- const next = { ...current };
438
- delete next[id];
439
- return next;
591
+ const save = (key, path, label, value, onRefused) => {
592
+ const pending = pendingWrites.get(key);
593
+ if (pending !== void 0) clearTimeout(pending.timer);
594
+ const timer = setTimeout(() => {
595
+ pendingWrites.delete(key);
596
+ void writeConfig(ctx, [{ op: "set", path, value }]).then(() => {
597
+ note(`\u5DF2\u4FDD\u5B58 ${label} = ${JSON.stringify(value)}`);
598
+ }).catch((error) => {
599
+ note(`\u4FDD\u5B58\u5931\u8D25 ${label}\uFF1A${error?.message ?? error}`);
600
+ onRefused();
440
601
  });
602
+ }, WRITE_DELAY_MS);
603
+ pendingWrites.set(key, { timer });
604
+ };
605
+ const rollbackRouteField = (id, field2) => {
606
+ setDraft((current) => {
607
+ const entry = current[id];
608
+ if (entry === void 0 || entry[field2] === void 0) return current;
609
+ const nextEntry = { ...entry };
610
+ delete nextEntry[field2];
611
+ const next = { ...current };
612
+ if (Object.keys(nextEntry).length === 0) delete next[id];
613
+ else next[id] = nextEntry;
614
+ return next;
441
615
  });
442
616
  };
443
617
  const bind = (id, field2) => (value) => {
444
618
  edit(id, field2, value);
445
- save(id, field2, value);
619
+ save(
620
+ `${id}\0${field2}`,
621
+ ["providers", id, field2],
622
+ `${id}.${field2}`,
623
+ value,
624
+ () => rollbackRouteField(id, field2)
625
+ );
446
626
  };
447
- const field = (label, control, hint) => h(
627
+ const bindDefault = (field2) => (value) => {
628
+ editDefault(field2, value);
629
+ save(`\0default\0${field2}`, ["defaultProxy", field2], `defaultProxy.${field2}`, value, () => {
630
+ setDefaultDraft((current) => {
631
+ if (current[field2] === void 0) return current;
632
+ const next = { ...current };
633
+ delete next[field2];
634
+ return next;
635
+ });
636
+ });
637
+ };
638
+ const saveMany = (key, ops, label, onRefused) => {
639
+ const pending = pendingWrites.get(key);
640
+ if (pending !== void 0) clearTimeout(pending.timer);
641
+ const timer = setTimeout(() => {
642
+ pendingWrites.delete(key);
643
+ void writeConfig(ctx, ops).then(() => {
644
+ note(`\u5DF2\u4FDD\u5B58 ${label}`);
645
+ }).catch((error) => {
646
+ note(`\u4FDD\u5B58\u5931\u8D25 ${label}\uFF1A${error?.message ?? error}`);
647
+ onRefused();
648
+ });
649
+ }, WRITE_DELAY_MS);
650
+ pendingWrites.set(key, { timer });
651
+ };
652
+ const rollbackRouteFields = (id, fields) => {
653
+ setDraft((current) => {
654
+ const entry = current[id];
655
+ if (entry === void 0) return current;
656
+ const nextEntry = { ...entry };
657
+ for (const field2 of fields) delete nextEntry[field2];
658
+ const next = { ...current };
659
+ if (Object.keys(nextEntry).length === 0) delete next[id];
660
+ else next[id] = nextEntry;
661
+ return next;
662
+ });
663
+ };
664
+ const applyCardUrl = (id, parts) => {
665
+ setDraft((current) => ({ ...current, [id]: { ...current[id], ...parts } }));
666
+ const fields = Object.keys(parts);
667
+ saveMany(
668
+ `${id}\0*paste`,
669
+ fields.map((field2) => ({ op: "set", path: ["providers", id, field2], value: parts[field2] })),
670
+ `${id}.*\uFF08\u7C98\u8D34\u4EE3\u7406 URL\uFF09`,
671
+ () => rollbackRouteFields(id, fields)
672
+ );
673
+ };
674
+ const applyDefaultUrl = (parts) => {
675
+ setDefaultDraft((current) => ({ ...current, ...parts }));
676
+ const fields = Object.keys(parts);
677
+ saveMany(
678
+ "\0default\0*paste",
679
+ fields.map((field2) => ({ op: "set", path: ["defaultProxy", field2], value: parts[field2] })),
680
+ "\u9ED8\u8BA4\u4EE3\u7406\uFF08\u7C98\u8D34\u4EE3\u7406 URL\uFF09",
681
+ () => {
682
+ setDefaultDraft((current) => {
683
+ const next = { ...current };
684
+ for (const field2 of fields) delete next[field2];
685
+ return next;
686
+ });
687
+ }
688
+ );
689
+ };
690
+ const promoteToDefault = (id) => {
691
+ const route = view({ ...configured[id], ...draft[id] });
692
+ applyDefaultUrl({
693
+ protocol: route.protocol,
694
+ host: route.host.trim(),
695
+ port: route.port,
696
+ username: route.username,
697
+ password: route.password
698
+ });
699
+ };
700
+ const applyDefaultToAll = () => {
701
+ const ids = rows.map((row) => row.id);
702
+ if (ids.length === 0) return;
703
+ setDraft((current) => {
704
+ const next = { ...current };
705
+ for (const id of ids) next[id] = { ...next[id], useDefault: true };
706
+ return next;
707
+ });
708
+ saveMany(
709
+ "\0default\0applyAll",
710
+ ids.map((id) => ({ op: "set", path: ["providers", id, "useDefault"], value: true })),
711
+ "\u6240\u6709 provider \u7684 useDefault",
712
+ () => setDraft((current) => {
713
+ const next = { ...current };
714
+ for (const id of ids) {
715
+ const entry = next[id];
716
+ if (entry === void 0 || entry.useDefault === void 0) continue;
717
+ const nextEntry = { ...entry };
718
+ delete nextEntry.useDefault;
719
+ if (Object.keys(nextEntry).length === 0) delete next[id];
720
+ else next[id] = nextEntry;
721
+ }
722
+ return next;
723
+ })
724
+ );
725
+ };
726
+ const onProxyUrlPaste = (apply2) => (event) => {
727
+ const text = event?.clipboardData?.getData?.("text");
728
+ if (typeof text !== "string") return;
729
+ const parts = splitProxyUrl(text);
730
+ if (parts === void 0) return;
731
+ event.preventDefault?.();
732
+ apply2(parts);
733
+ };
734
+ const field = (label, control, hint, hintError) => h(
448
735
  "label",
449
736
  { className: import_client.default.field, style: S.field, key: label },
450
737
  h("span", { className: import_client.default.label, style: S.label }, label),
451
738
  control,
452
739
  hint === void 0 ? null : h(
453
740
  "span",
454
- { className: import_client.default.hint, style: { gridColumn: "2", fontSize: "11px" } },
741
+ {
742
+ className: import_client.default.hint,
743
+ style: {
744
+ gridColumn: "2",
745
+ fontSize: "11px",
746
+ // A warning that a value can never work is an answer, not
747
+ // decoration — same rule as the failed self-check text.
748
+ ...hintError === true ? { color: "var(--dsw-alias-state-error-primary, #f85149)" } : {}
749
+ }
750
+ },
455
751
  hint
456
752
  )
457
753
  );
458
- const body = rows.length === 0 ? [
754
+ const storedDefault = snapshot.value?.defaultProxy ?? {};
755
+ const defaultView = view({ ...storedDefault, ...defaultDraft });
756
+ const defaultWhy = defaultProxyProblem(defaultView);
757
+ const defaultUsable = defaultWhy === void 0;
758
+ const defaultOrigin = `${defaultView.protocol}://${defaultView.host}:${defaultView.port}`;
759
+ const defaultBlock = h(
760
+ "div",
761
+ { className: import_client.default.defaultCard, style: S.defaultCard, key: "default-proxy" },
762
+ h(
763
+ "header",
764
+ { className: import_client.default.cardHead, style: S.head },
765
+ h(
766
+ "div",
767
+ { className: import_client.default.cardTitles, style: S.titles },
768
+ h("span", { className: import_client.default.name, style: S.name }, t.defaultTitle)
769
+ ),
770
+ h(
771
+ "span",
772
+ {
773
+ className: defaultUsable ? import_client.default.pillOn : import_client.default.pillOff,
774
+ style: { ...S.pill, ...defaultUsable ? S.pillOn : S.pillOff }
775
+ },
776
+ defaultUsable ? t.on : t.off
777
+ )
778
+ ),
779
+ h("p", { className: import_client.default.defaultLead, style: S.defaultLead }, t.defaultLead),
780
+ field(
781
+ t.protocol,
782
+ h(
783
+ "select",
784
+ {
785
+ className: import_client.default.input,
786
+ style: S.input,
787
+ value: defaultView.protocol,
788
+ onChange: (event) => bindDefault("protocol")(event.target.value)
789
+ },
790
+ ...PROTOCOLS.map(
791
+ (protocol) => h("option", { key: protocol, value: protocol }, protocol.toUpperCase())
792
+ )
793
+ )
794
+ ),
795
+ field(
796
+ t.host,
797
+ h("input", {
798
+ className: import_client.default.input,
799
+ style: S.input,
800
+ placeholder: "127.0.0.1",
801
+ value: defaultView.host,
802
+ onChange: (event) => bindDefault("host")(event.target.value),
803
+ // Same convenience as the cards: a full URL fills the whole block.
804
+ onPaste: onProxyUrlPaste(applyDefaultUrl)
805
+ }),
806
+ t.hostHint
807
+ ),
808
+ field(
809
+ t.port,
810
+ h("input", {
811
+ className: import_client.default.input,
812
+ style: S.input,
813
+ type: "number",
814
+ min: 1,
815
+ max: 65535,
816
+ placeholder: "7890",
817
+ value: defaultView.port === 0 ? "" : String(defaultView.port),
818
+ onChange: (event) => {
819
+ const parsed = Number.parseInt(event.target.value, 10);
820
+ bindDefault("port")(Number.isNaN(parsed) ? 0 : parsed);
821
+ }
822
+ })
823
+ ),
824
+ field(
825
+ t.username,
826
+ h("input", {
827
+ className: import_client.default.input,
828
+ style: S.input,
829
+ autocomplete: "off",
830
+ value: defaultView.username,
831
+ onChange: (event) => bindDefault("username")(event.target.value)
832
+ })
833
+ ),
834
+ field(
835
+ t.password,
836
+ h("input", {
837
+ className: import_client.default.input,
838
+ style: S.input,
839
+ type: "password",
840
+ autocomplete: "off",
841
+ value: defaultView.password,
842
+ onChange: (event) => bindDefault("password")(event.target.value)
843
+ }),
844
+ t.passwordHintProxy
845
+ ),
846
+ // Bulk opt-in, in ONE write: a per-card walk would rebuild the table N times.
847
+ // Disabled while the default is unusable, because pointing every card at an
848
+ // empty endpoint would just refuse them all.
849
+ h(
850
+ "div",
851
+ { style: S.testRow, key: "default:apply" },
852
+ h(
853
+ "button",
854
+ {
855
+ className: import_client.default.testButton,
856
+ style: S.testButton,
857
+ type: "button",
858
+ disabled: !defaultUsable || rows.length === 0,
859
+ onClick: applyDefaultToAll
860
+ },
861
+ t.applyDefaultAll
862
+ )
863
+ )
864
+ );
865
+ const query = filter.trim().toLowerCase();
866
+ const shown = query.length === 0 ? rows : rows.filter((row) => `${row.id} ${row.name}`.toLowerCase().includes(query));
867
+ const filterRow = rows.length === 0 ? null : field(
868
+ t.filterLabel,
869
+ h("input", {
870
+ className: import_client.default.input,
871
+ style: S.input,
872
+ placeholder: t.filterPlaceholder,
873
+ value: filter,
874
+ onChange: (event) => setFilter(event.target.value)
875
+ })
876
+ );
877
+ const body = shown.length === 0 ? [
459
878
  h(
460
879
  "p",
461
880
  {
@@ -471,57 +890,18 @@ function renderPage(ctx) {
471
890
  color: "var(--dsw-alias-state-idle-primary)"
472
891
  }
473
892
  },
474
- t.empty
893
+ query.length > 0 ? t.noMatch : t.empty
475
894
  )
476
- ] : rows.map((row) => {
895
+ ] : shown.map((row) => {
477
896
  const route = view({ ...configured[row.id], ...draft[row.id] });
478
- const disabled = !route.enabled;
897
+ const lastText = lastUseText(row.id) ?? (route.enabled ? t.noRecord : void 0);
479
898
  const result = tests[row.id];
480
- return h(
481
- "section",
482
- {
483
- className: route.enabled ? import_client.default.cardActive : import_client.default.card,
484
- style: route.enabled ? { ...S.card, ...S.cardActive } : S.card,
485
- key: row.id
486
- },
487
- h(
488
- "header",
489
- { className: import_client.default.cardHead, style: S.head },
490
- h(
491
- "div",
492
- { className: import_client.default.cardTitles, style: S.titles },
493
- h("span", { className: import_client.default.name, style: S.name }, row.name),
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
502
- ),
503
- h(
504
- "span",
505
- {
506
- className: route.enabled ? import_client.default.pillOn : row.live ? import_client.default.pillLive : import_client.default.pillOff,
507
- style: {
508
- ...S.pill,
509
- ...route.enabled ? S.pillOn : row.live ? S.pillLive : S.pillOff
510
- }
511
- },
512
- route.enabled ? t.on : row.live ? t.live : t.off
513
- )
514
- ),
515
- h(
516
- "label",
517
- { className: import_client.default.toggle, style: S.toggle },
518
- h("input", {
519
- type: "checkbox",
520
- checked: route.enabled,
521
- onChange: (event) => bind(row.id, "enabled")(event.target.checked === true)
522
- }),
523
- h("span", null, t.useProxy)
524
- ),
899
+ const onlyDead = deadRuleEntries(route.onlyHosts);
900
+ const bypassDead = deadRuleEntries(route.bypassHosts);
901
+ const useDefault = route.useDefault === true;
902
+ const endpointStored = route.useDefault ? defaultUsable : route.host.trim().length > 0 && Number.isInteger(route.port) && route.port >= 1 && route.port <= 65535;
903
+ const showTest = route.enabled || endpointStored;
904
+ const endpointFields = [
525
905
  field(
526
906
  t.protocol,
527
907
  h(
@@ -529,7 +909,6 @@ function renderPage(ctx) {
529
909
  {
530
910
  className: import_client.default.input,
531
911
  style: S.input,
532
- disabled,
533
912
  value: route.protocol,
534
913
  onChange: (event) => bind(row.id, "protocol")(event.target.value)
535
914
  },
@@ -544,10 +923,11 @@ function renderPage(ctx) {
544
923
  h("input", {
545
924
  className: import_client.default.input,
546
925
  style: S.input,
547
- disabled,
548
926
  placeholder: "127.0.0.1",
549
927
  value: route.host,
550
- onChange: (event) => bind(row.id, "host")(event.target.value)
928
+ onChange: (event) => bind(row.id, "host")(event.target.value),
929
+ // A pasted full proxy URL fills every transport field at once.
930
+ onPaste: onProxyUrlPaste((parts) => applyCardUrl(row.id, parts))
551
931
  }),
552
932
  t.hostHint
553
933
  ),
@@ -556,7 +936,6 @@ function renderPage(ctx) {
556
936
  h("input", {
557
937
  className: import_client.default.input,
558
938
  style: S.input,
559
- disabled,
560
939
  type: "number",
561
940
  min: 1,
562
941
  max: 65535,
@@ -573,13 +952,12 @@ function renderPage(ctx) {
573
952
  h("input", {
574
953
  className: import_client.default.input,
575
954
  style: S.input,
576
- disabled,
577
955
  autocomplete: "off",
578
956
  value: route.username,
579
957
  onChange: (event) => bind(row.id, "username")(event.target.value)
580
958
  })
581
959
  ),
582
- // Every transport accepts a password now: SOCKS5 hands it to
960
+ // Every transport accepts a password: SOCKS5 hands it to
583
961
  // Socks5ProxyAgent as an option, HTTP/HTTPS embed it in the proxy URL
584
962
  // userinfo (undici turns that into Proxy-Authorization). It is stored on
585
963
  // the route, so it lands in the settings file with the rest of the
@@ -589,19 +967,106 @@ function renderPage(ctx) {
589
967
  h("input", {
590
968
  className: import_client.default.input,
591
969
  style: S.input,
592
- disabled,
593
970
  type: "password",
594
971
  autocomplete: "off",
595
972
  value: route.password,
596
973
  onChange: (event) => bind(row.id, "password")(event.target.value)
597
974
  }),
598
975
  route.protocol === "socks5" ? t.passwordHint : t.passwordHintProxy
976
+ )
977
+ ];
978
+ return h(
979
+ "section",
980
+ {
981
+ className: route.enabled ? import_client.default.cardActive : import_client.default.card,
982
+ style: route.enabled ? { ...S.card, ...S.cardActive } : S.card,
983
+ key: row.id
984
+ },
985
+ h(
986
+ "header",
987
+ { className: import_client.default.cardHead, style: S.head },
988
+ h(
989
+ "div",
990
+ { className: import_client.default.cardTitles, style: S.titles },
991
+ h("span", { className: import_client.default.name, style: S.name }, row.name),
992
+ h("span", { className: import_client.default.id, style: S.id }, row.id),
993
+ // A disabled route can never have hits, so "0" there would only add
994
+ // noise; the count shows once the route is on (or has counted).
995
+ route.enabled || (hits[row.id] ?? 0) > 0 ? h(
996
+ "span",
997
+ { style: S.hits },
998
+ `${t.hitCountPrefix}${hits[row.id] ?? 0}${t.hitCountSuffix}`
999
+ ) : null,
1000
+ // The last-use line. A failure takes the error colour, same rule as
1001
+ // a failed self-check: it is an answer, not decoration.
1002
+ lastText === void 0 ? null : h(
1003
+ "span",
1004
+ {
1005
+ style: {
1006
+ ...S.hits,
1007
+ ...lastUse[row.id]?.ok === false ? S.inheritWarning : {}
1008
+ }
1009
+ },
1010
+ lastText
1011
+ )
1012
+ ),
1013
+ h(
1014
+ "span",
1015
+ {
1016
+ className: route.enabled ? import_client.default.pillOn : row.live ? import_client.default.pillLive : import_client.default.pillOff,
1017
+ style: {
1018
+ ...S.pill,
1019
+ ...route.enabled ? S.pillOn : row.live ? S.pillLive : S.pillOff
1020
+ }
1021
+ },
1022
+ route.enabled ? t.on : row.live ? t.live : t.off
1023
+ )
599
1024
  ),
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(
1025
+ h(
1026
+ "label",
1027
+ { className: import_client.default.toggle, style: S.toggle },
1028
+ h("input", {
1029
+ type: "checkbox",
1030
+ checked: route.enabled,
1031
+ onChange: (event) => bind(row.id, "enabled")(event.target.checked === true)
1032
+ }),
1033
+ h("span", null, t.useProxy)
1034
+ ),
1035
+ // Opting into the shared endpoint. The card then shows the address it
1036
+ // will actually use — or, when the default is not configured, why the
1037
+ // route is refused — instead of a set of empty inputs the user never
1038
+ // filled and cannot interpret.
1039
+ h(
1040
+ "label",
1041
+ { className: import_client.default.toggle, style: S.toggle, key: `${row.id}:usedefault` },
1042
+ h("input", {
1043
+ type: "checkbox",
1044
+ checked: useDefault,
1045
+ onChange: (event) => bind(row.id, "useDefault")(event.target.checked === true)
1046
+ }),
1047
+ h("span", null, t.useDefault)
1048
+ ),
1049
+ ...useDefault ? [
1050
+ h(
1051
+ "p",
1052
+ {
1053
+ key: `${row.id}:inherit`,
1054
+ style: defaultUsable ? S.inheritNote : S.inheritWarning
1055
+ },
1056
+ defaultUsable ? `${t.inheritingDefault}${defaultOrigin}` : `${t.defaultMissing}${defaultWhy}`
1057
+ )
1058
+ ] : endpointFields,
1059
+ // The self-check is offered whenever there is something to check: an
1060
+ // enabled route (the Host's table carries it, and a rejected one still
1061
+ // reports WHY), or a disabled one whose endpoint is already complete —
1062
+ // the Host builds that one for the check alone. A disabled route with
1063
+ // no endpoint has nothing to test and would only answer "not enabled".
1064
+ showTest ? h(
603
1065
  "div",
604
1066
  { style: S.testRow, key: `${row.id}:test` },
1067
+ // A check on a route that is still off is a trial run, and it says
1068
+ // so — otherwise the result reads as though the route were live.
1069
+ route.enabled ? null : h("span", { style: S.testResult }, t.testDisabledNote),
605
1070
  h(
606
1071
  "button",
607
1072
  {
@@ -624,8 +1089,22 @@ function renderPage(ctx) {
624
1089
  { key: "lat", style: { color: latencyColor(result.ms) } },
625
1090
  `\u2713 ${typeof result.ms === "number" ? result.ms : "\u2014"} ms`
626
1091
  ),
627
- result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`
1092
+ result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`,
1093
+ result.direct === true ? ` \xB7 ${t.directLabel}` : null
628
1094
  ] : h("span", { key: "err", style: S.testFailure }, `\u2717 ${result.error}`)
1095
+ ) : null,
1096
+ // Promote this card's endpoint to the shared default. Offered only
1097
+ // when the card carries its OWN endpoint: an inheriting card would
1098
+ // just be copying the default onto itself.
1099
+ endpointStored && !useDefault ? h(
1100
+ "button",
1101
+ {
1102
+ className: import_client.default.testButton,
1103
+ style: S.testButton,
1104
+ type: "button",
1105
+ onClick: () => promoteToDefault(row.id)
1106
+ },
1107
+ t.setAsDefault
629
1108
  ) : null
630
1109
  ) : null,
631
1110
  // Target-host rules, collapsed so the common case stays one short card.
@@ -638,24 +1117,38 @@ function renderPage(ctx) {
638
1117
  h("input", {
639
1118
  className: import_client.default.input,
640
1119
  style: S.input,
641
- disabled,
642
1120
  placeholder: "api.example.com",
643
1121
  value: route.onlyHosts,
644
1122
  onChange: (event) => bind(row.id, "onlyHosts")(event.target.value)
645
1123
  }),
646
- t.onlyHostsHint
1124
+ onlyDead.length === 0 ? t.onlyHostsHint : `${t.ruleDeadHint}${onlyDead.join("\u3001")}`,
1125
+ onlyDead.length > 0
647
1126
  ),
648
1127
  field(
649
1128
  t.bypassHosts,
650
1129
  h("input", {
651
1130
  className: import_client.default.input,
652
1131
  style: S.input,
653
- disabled,
654
1132
  placeholder: "localhost",
655
1133
  value: route.bypassHosts,
656
1134
  onChange: (event) => bind(row.id, "bypassHosts")(event.target.value)
657
1135
  }),
658
- t.bypassHostsHint
1136
+ bypassDead.length === 0 ? t.bypassHostsHint : `${t.ruleDeadHint}${bypassDead.join("\u3001")}`,
1137
+ bypassDead.length > 0
1138
+ ),
1139
+ // The self-check target: empty uses the top-level testUrl, set makes
1140
+ // this card's Test request this URL — a real upstream proves the whole
1141
+ // path, not just the proxy hop.
1142
+ field(
1143
+ t.testTarget,
1144
+ h("input", {
1145
+ className: import_client.default.input,
1146
+ style: S.input,
1147
+ placeholder: "https://api.example.com",
1148
+ value: route.testTarget,
1149
+ onChange: (event) => bind(row.id, "testTarget")(event.target.value)
1150
+ }),
1151
+ t.testTargetHint
659
1152
  )
660
1153
  )
661
1154
  );
@@ -664,6 +1157,8 @@ function renderPage(ctx) {
664
1157
  "div",
665
1158
  { className: import_client.default.root, style: S.root },
666
1159
  h("p", { className: import_client.default.lead, style: { margin: "0 0 4px", fontSize: "12px", lineHeight: "1.6", color: "var(--dsw-alias-label-secondary)" } }, t.lead),
1160
+ defaultBlock,
1161
+ filterRow,
667
1162
  ...body,
668
1163
  h(
669
1164
  "details",
@@ -699,28 +1194,51 @@ function renderPage(ctx) {
699
1194
  );
700
1195
  }
701
1196
  var EN = {
702
- lead: "Route selected model providers through your own forward proxy. Other providers stay direct.",
1197
+ lead: "Route selected model providers through your own forward proxy. Other providers stay direct. Requests to loopback or private addresses (localhost, 127.x, private ranges) always go direct \u2014 a forward proxy cannot reach your own machine.",
703
1198
  empty: "No providers found yet.",
704
1199
  useProxy: "Use a proxy for this provider",
1200
+ useDefault: "Use the default proxy",
1201
+ defaultTitle: "Default proxy",
1202
+ defaultLead: 'One endpoint, reused by every provider that turns on "Use the default proxy".',
1203
+ inheritingDefault: "Using the default proxy: ",
1204
+ defaultMissing: "The default proxy cannot be used, so this provider is not proxied: ",
1205
+ filterLabel: "Filter",
1206
+ filterPlaceholder: "Filter by name or id\u2026",
1207
+ noMatch: "No provider matches the filter.",
1208
+ setAsDefault: "Set as the default proxy",
1209
+ applyDefaultAll: "Use the default proxy for all providers",
705
1210
  protocol: "Type",
706
1211
  host: "IP / host",
707
1212
  port: "Port",
708
1213
  username: "Username",
709
1214
  password: "Password",
710
- hostHint: "Host or IP only, without scheme or path.",
1215
+ hostHint: "Host or IP only, without scheme or path. A full proxy URL (socks5://user:pass@host:1080) can be pasted instead \u2014 it is split into the fields.",
711
1216
  passwordHint: "Sent to the SOCKS5 proxy for authentication; stored with this setting.",
712
1217
  passwordHintProxy: "Sent to the proxy as an authentication password; stored with this setting.",
713
1218
  hitCountPrefix: "",
714
1219
  hitCountSuffix: " proxied",
1220
+ recentOk: "last ok ",
1221
+ recentFail: "last failure ",
1222
+ recentStarted: "last call ",
1223
+ noRecord: "no proxied request yet",
1224
+ justNow: "just now",
1225
+ minutesAgo: " min ago",
1226
+ hoursAgo: " h ago",
1227
+ daysAgo: " d ago",
715
1228
  testButton: "Test",
716
1229
  testRunning: "Testing\u2026",
1230
+ testDisabledNote: "Not enabled \u2014 test only: ",
717
1231
  testFailed: "Test failed",
718
1232
  exitIpLabel: "exit IP ",
1233
+ directLabel: "direct (not through the proxy)",
719
1234
  advanced: "Advanced: target host rules",
720
1235
  onlyHosts: "Proxy only",
721
1236
  onlyHostsHint: "Comma/space separated; when set, only requests to these hosts are proxied (suffix match, * for all).",
722
1237
  bypassHosts: "Bypass",
723
1238
  bypassHostsHint: "Comma/space separated; requests to these hosts go direct, taking priority.",
1239
+ ruleDeadHint: "These entries never match \u2014 rules take host names only, without scheme or path: ",
1240
+ testTarget: "Self-check target",
1241
+ testTargetHint: "Empty uses the top-level testUrl; when set, this card's Test requests this URL instead \u2014 point it at a real upstream to verify the end-to-end path.",
724
1242
  socks5Hint: "SOCKS5 tunnels the connection; the proxy resolves the target host name.",
725
1243
  on: "proxied",
726
1244
  live: "active",
@@ -729,28 +1247,51 @@ var EN = {
729
1247
  diagPending: "(collecting\u2026)"
730
1248
  };
731
1249
  var ZH = {
732
- lead: "\u8BA9\u6307\u5B9A\u7684 provider \u8D70\u4F60\u81EA\u5DF1\u7684\u6B63\u5411\u4EE3\u7406\uFF0C\u5176\u5B83 provider \u4FDD\u6301\u76F4\u8FDE\u3002",
1250
+ lead: "\u8BA9\u6307\u5B9A\u7684 provider \u8D70\u4F60\u81EA\u5DF1\u7684\u6B63\u5411\u4EE3\u7406\uFF0C\u5176\u5B83 provider \u4FDD\u6301\u76F4\u8FDE\u3002\u6307\u5411\u672C\u673A\u6216\u5185\u7F51\u5730\u5740\uFF08localhost\u3001127.x\u3001\u79C1\u6709\u7F51\u6BB5\uFF09\u7684\u8BF7\u6C42\u59CB\u7EC8\u76F4\u8FDE\u2014\u2014\u6B63\u5411\u4EE3\u7406\u591F\u4E0D\u5230\u4F60\u81EA\u5DF1\u7684\u673A\u5668\u3002",
733
1251
  empty: "\u6CA1\u6709\u68C0\u6D4B\u5230\u4EFB\u4F55 provider\u3002",
734
1252
  useProxy: "\u4E3A\u8BE5 provider \u542F\u7528\u4EE3\u7406",
1253
+ useDefault: "\u4F7F\u7528\u9ED8\u8BA4\u4EE3\u7406",
1254
+ defaultTitle: "\u9ED8\u8BA4\u4EE3\u7406",
1255
+ defaultLead: "\u4E3A\u52FE\u9009\u300C\u4F7F\u7528\u9ED8\u8BA4\u4EE3\u7406\u300D\u7684 provider \u590D\u7528\u7684\u7AEF\u70B9\uFF0C\u53EA\u586B\u4E00\u6B21\u3002",
1256
+ inheritingDefault: "\u4F7F\u7528\u9ED8\u8BA4\u4EE3\u7406\uFF1A",
1257
+ defaultMissing: "\u9876\u5C42\u9ED8\u8BA4\u4EE3\u7406\u4E0D\u53EF\u7528\uFF0C\u8BE5 provider \u4E0D\u4F1A\u8D70\u4EE3\u7406\uFF1A",
1258
+ filterLabel: "\u8FC7\u6EE4",
1259
+ filterPlaceholder: "\u6309\u540D\u79F0\u6216 id \u8FC7\u6EE4\u2026",
1260
+ noMatch: "\u6CA1\u6709\u5339\u914D\u7684 provider\u3002",
1261
+ setAsDefault: "\u8BBE\u4E3A\u9ED8\u8BA4\u4EE3\u7406",
1262
+ applyDefaultAll: "\u6240\u6709 provider \u4F7F\u7528\u9ED8\u8BA4\u4EE3\u7406",
735
1263
  protocol: "\u7C7B\u578B",
736
1264
  host: "IP / \u4E3B\u673A",
737
1265
  port: "\u7AEF\u53E3",
738
1266
  username: "\u7528\u6237\u540D",
739
1267
  password: "\u5BC6\u7801",
740
- hostHint: "\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\u3002",
1268
+ hostHint: "\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\uFF1B\u4E5F\u53EF\u4EE5\u76F4\u63A5\u7C98\u8D34\u5B8C\u6574\u4EE3\u7406 URL\uFF08\u5982 socks5://user:pass@host:1080\uFF09\uFF0C\u4F1A\u81EA\u52A8\u62C6\u5206\u5230\u5404\u5B57\u6BB5\u3002",
741
1269
  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
1270
  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
1271
  hitCountPrefix: "\u5DF2\u4EE3\u7406 ",
744
1272
  hitCountSuffix: " \u6B21",
1273
+ recentOk: "\u6700\u8FD1\u6210\u529F ",
1274
+ recentFail: "\u6700\u8FD1\u5931\u8D25 ",
1275
+ recentStarted: "\u6700\u8FD1\u8C03\u7528 ",
1276
+ noRecord: "\u5C1A\u65E0\u4EE3\u7406\u8BB0\u5F55",
1277
+ justNow: "\u521A\u521A",
1278
+ minutesAgo: " \u5206\u949F\u524D",
1279
+ hoursAgo: " \u5C0F\u65F6\u524D",
1280
+ daysAgo: " \u5929\u524D",
745
1281
  testButton: "\u6D4B\u8BD5",
746
1282
  testRunning: "\u6D4B\u8BD5\u4E2D\u2026",
1283
+ testDisabledNote: "\u672A\u542F\u7528\uFF0C\u4EC5\u6D4B\u8BD5\uFF1A",
747
1284
  testFailed: "\u6D4B\u8BD5\u5931\u8D25",
748
1285
  exitIpLabel: "\u51FA\u53E3 IP ",
1286
+ directLabel: "\u76F4\u8FDE\uFF08\u672A\u7ECF\u8FC7\u4EE3\u7406\uFF09",
749
1287
  advanced: "\u9AD8\u7EA7\uFF1A\u76EE\u6807\u57DF\u540D\u89C4\u5219",
750
1288
  onlyHosts: "\u4EC5\u8D70\u4EE3\u7406",
751
1289
  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
1290
  bypassHosts: "\u76F4\u8FDE",
753
1291
  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",
1292
+ ruleDeadHint: "\u4EE5\u4E0B\u6761\u76EE\u6C38\u8FDC\u5339\u914D\u4E0D\u4E0A\u2014\u2014\u89C4\u5219\u53EA\u8BA4\u4E3B\u673A\u540D\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\uFF1A",
1293
+ testTarget: "\u81EA\u68C0\u76EE\u6807",
1294
+ testTargetHint: "\u7559\u7A7A\u7528\u9876\u5C42 testUrl\uFF1B\u586B\u5199\u540E\u8BE5\u5361\u7247\u7684\u300C\u6D4B\u8BD5\u300D\u6539\u4E3A\u8BF7\u6C42\u8FD9\u4E2A\u5730\u5740\uFF0C\u53EF\u62FF\u771F\u5B9E\u4E0A\u6E38\u9A8C\u8BC1\u7AEF\u5230\u7AEF\u8DEF\u5F84\u3002",
754
1295
  socks5Hint: "SOCKS5 \u4F1A\u5EFA\u7ACB\u96A7\u9053\uFF0C\u76EE\u6807\u57DF\u540D\u7531\u4EE3\u7406\u7AEF\u89E3\u6790\u3002",
755
1296
  on: "\u8D70\u4EE3\u7406",
756
1297
  live: "\u542F\u7528\u4E2D",