dsh-provider-proxy 0.4.0 → 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": "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": "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.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.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}`);
@@ -197,21 +231,99 @@ async function fetchCatalog() {
197
231
  if (typeof count === "number" && Number.isFinite(count)) hits[id] = count;
198
232
  }
199
233
  }
200
- return { rows, hits };
234
+ const problems = {};
235
+ const rejected = body?.problems;
236
+ if (rejected !== null && typeof rejected === "object") {
237
+ for (const [id, message] of Object.entries(rejected)) {
238
+ if (typeof message === "string" && message.length > 0) problems[id] = message;
239
+ }
240
+ }
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);
201
274
  }
202
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
+ }
203
279
  function view(route) {
204
280
  return {
205
281
  enabled: route?.enabled === true,
282
+ useDefault: route?.useDefault === true,
206
283
  protocol: PROTOCOLS.includes(route?.protocol) ? route.protocol : "http",
207
284
  host: route?.host ?? "",
208
285
  port: route?.port ?? 0,
209
286
  username: route?.username ?? "",
210
287
  password: route?.password ?? "",
211
288
  onlyHosts: route?.onlyHosts ?? "",
212
- bypassHosts: route?.bypassHosts ?? ""
289
+ bypassHosts: route?.bypassHosts ?? "",
290
+ testTarget: route?.testTarget ?? ""
291
+ };
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)
213
323
  };
214
324
  }
325
+ var WRITE_DELAY_MS = 300;
326
+ var pendingWrites = /* @__PURE__ */ new Map();
215
327
  var diagnostics = [];
216
328
  function note(message) {
217
329
  if (!diagnostics.includes(message)) diagnostics.push(message);
@@ -288,6 +400,19 @@ var S = {
288
400
  color: "var(--dsw-alias-label-secondary)",
289
401
  wordBreak: "break-all"
290
402
  },
403
+ /**
404
+ * A failed check is the answer, not decoration: it takes the error colour and
405
+ * a heavier weight, unlike the latency band colour which only tints a number
406
+ * that is printed anyway.
407
+ */
408
+ testFailure: {
409
+ color: "var(--dsw-alias-state-error-primary, #f85149)",
410
+ fontWeight: "500"
411
+ },
412
+ /** A rejected route is a failure of the settings, not a warning about them. */
413
+ diagProblem: {
414
+ color: "var(--dsw-alias-state-error-primary, #f85149)"
415
+ },
291
416
  advanced: {
292
417
  marginTop: "4px",
293
418
  fontSize: "12px"
@@ -314,6 +439,40 @@ var S = {
314
439
  color: "var(--dsw-alias-label-secondary)"
315
440
  },
316
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
+ },
317
476
  toggle: {
318
477
  display: "flex",
319
478
  alignItems: "center",
@@ -363,21 +522,52 @@ function renderPage(ctx) {
363
522
  }]))(() => {
364
523
  void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch((error) => {
365
524
  note(`provider \u5217\u8868\u83B7\u53D6\u5931\u8D25\uFF1A${error?.message ?? error}`);
366
- catalogueSetter?.({ rows: [], hits: {} });
525
+ catalogueSetter?.({ rows: [], hits: {}, problems: {}, lastUse: {} });
367
526
  });
368
- return { rows: void 0, hits: {}, pending: true };
527
+ return {
528
+ rows: void 0,
529
+ hits: {},
530
+ problems: {},
531
+ lastUse: {},
532
+ pending: true
533
+ };
369
534
  });
370
- catalogueSetter = (catalog) => setState({ rows: catalog.rows, hits: catalog.hits, 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();
371
543
  const published = state.rows ?? [];
372
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
+ };
553
+ const problems = state.problems ?? {};
554
+ const problemLines = Object.entries(problems).map(
555
+ ([id, message]) => h("li", { key: `p:${id}`, style: S.diagProblem }, `${id}\uFF1A${message}`)
556
+ );
557
+ const problemCount = problemLines.length;
373
558
  note(
374
559
  `provider \u5217\u8868\uFF1A\u6765\u81EA Host \u5171 ${published.length} \u4E2A\uFF08${published.map((r) => r.id).join("\u3001") || "\u65E0"}\uFF09`
375
560
  );
376
561
  const [draft, setDraft] = (React.useState ?? ((v) => [v, () => {
377
562
  }]))({});
378
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 }));
379
567
  const [tests, setTests] = (React.useState ?? ((v) => [v, () => {
380
568
  }]))({});
569
+ const [filter, setFilter] = (React.useState ?? ((v) => [v, () => {
570
+ }]))("");
381
571
  const runTest = (id) => {
382
572
  setTests((current) => ({ ...current, [id]: { pending: true } }));
383
573
  void (async () => {
@@ -389,10 +579,6 @@ function renderPage(ctx) {
389
579
  });
390
580
  const body2 = await response.json();
391
581
  setTests((current) => ({ ...current, [id]: testResultFrom(body2, t) }));
392
- if (body2?.ok === true) {
393
- void fetchCatalog().then((catalog) => catalogueSetter?.(catalog)).catch(() => {
394
- });
395
- }
396
582
  } catch (error) {
397
583
  setTests((current) => ({
398
584
  ...current,
@@ -402,34 +588,293 @@ function renderPage(ctx) {
402
588
  })();
403
589
  };
404
590
  const rows = knownProviders({ ...configured, ...draft }, published);
405
- const save = (id, field2, value) => {
406
- void writeConfig(ctx, [{ op: "set", path: ["providers", id, field2], value }]).then(() => {
407
- note(`\u5DF2\u4FDD\u5B58 ${id}.${field2} = ${JSON.stringify(value)}`);
408
- }).catch((error) => {
409
- note(`\u4FDD\u5B58\u5931\u8D25 ${id}.${field2}\uFF1A${error?.message ?? error}`);
410
- setDraft((current) => {
411
- const next = { ...current };
412
- delete next[id];
413
- 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();
414
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;
415
615
  });
416
616
  };
417
617
  const bind = (id, field2) => (value) => {
418
618
  edit(id, field2, value);
419
- save(id, field2, value);
619
+ save(
620
+ `${id}\0${field2}`,
621
+ ["providers", id, field2],
622
+ `${id}.${field2}`,
623
+ value,
624
+ () => rollbackRouteField(id, field2)
625
+ );
626
+ };
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);
420
733
  };
421
- const field = (label, control, hint) => h(
734
+ const field = (label, control, hint, hintError) => h(
422
735
  "label",
423
736
  { className: import_client.default.field, style: S.field, key: label },
424
737
  h("span", { className: import_client.default.label, style: S.label }, label),
425
738
  control,
426
739
  hint === void 0 ? null : h(
427
740
  "span",
428
- { 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
+ },
429
751
  hint
430
752
  )
431
753
  );
432
- 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 ? [
433
878
  h(
434
879
  "p",
435
880
  {
@@ -445,57 +890,18 @@ function renderPage(ctx) {
445
890
  color: "var(--dsw-alias-state-idle-primary)"
446
891
  }
447
892
  },
448
- t.empty
893
+ query.length > 0 ? t.noMatch : t.empty
449
894
  )
450
- ] : rows.map((row) => {
895
+ ] : shown.map((row) => {
451
896
  const route = view({ ...configured[row.id], ...draft[row.id] });
452
- const disabled = !route.enabled;
897
+ const lastText = lastUseText(row.id) ?? (route.enabled ? t.noRecord : void 0);
453
898
  const result = tests[row.id];
454
- return h(
455
- "section",
456
- {
457
- className: route.enabled ? import_client.default.cardActive : import_client.default.card,
458
- style: route.enabled ? { ...S.card, ...S.cardActive } : S.card,
459
- key: row.id
460
- },
461
- h(
462
- "header",
463
- { className: import_client.default.cardHead, style: S.head },
464
- h(
465
- "div",
466
- { className: import_client.default.cardTitles, style: S.titles },
467
- h("span", { className: import_client.default.name, style: S.name }, row.name),
468
- h("span", { className: import_client.default.id, style: S.id }, row.id),
469
- // A disabled route can never have hits, so "0" there would only add
470
- // noise; the count shows once the route is on (or has counted).
471
- route.enabled || (hits[row.id] ?? 0) > 0 ? h(
472
- "span",
473
- { className: import_client.default.hits, style: S.hits },
474
- `${t.hitCountPrefix}${hits[row.id] ?? 0}${t.hitCountSuffix}`
475
- ) : null
476
- ),
477
- h(
478
- "span",
479
- {
480
- className: route.enabled ? import_client.default.pillOn : row.live ? import_client.default.pillLive : import_client.default.pillOff,
481
- style: {
482
- ...S.pill,
483
- ...route.enabled ? S.pillOn : row.live ? S.pillLive : S.pillOff
484
- }
485
- },
486
- route.enabled ? t.on : row.live ? t.live : t.off
487
- )
488
- ),
489
- h(
490
- "label",
491
- { className: import_client.default.toggle, style: S.toggle },
492
- h("input", {
493
- type: "checkbox",
494
- checked: route.enabled,
495
- onChange: (event) => bind(row.id, "enabled")(event.target.checked === true)
496
- }),
497
- h("span", null, t.useProxy)
498
- ),
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 = [
499
905
  field(
500
906
  t.protocol,
501
907
  h(
@@ -503,7 +909,6 @@ function renderPage(ctx) {
503
909
  {
504
910
  className: import_client.default.input,
505
911
  style: S.input,
506
- disabled,
507
912
  value: route.protocol,
508
913
  onChange: (event) => bind(row.id, "protocol")(event.target.value)
509
914
  },
@@ -518,10 +923,11 @@ function renderPage(ctx) {
518
923
  h("input", {
519
924
  className: import_client.default.input,
520
925
  style: S.input,
521
- disabled,
522
926
  placeholder: "127.0.0.1",
523
927
  value: route.host,
524
- 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))
525
931
  }),
526
932
  t.hostHint
527
933
  ),
@@ -530,7 +936,6 @@ function renderPage(ctx) {
530
936
  h("input", {
531
937
  className: import_client.default.input,
532
938
  style: S.input,
533
- disabled,
534
939
  type: "number",
535
940
  min: 1,
536
941
  max: 65535,
@@ -547,13 +952,12 @@ function renderPage(ctx) {
547
952
  h("input", {
548
953
  className: import_client.default.input,
549
954
  style: S.input,
550
- disabled,
551
955
  autocomplete: "off",
552
956
  value: route.username,
553
957
  onChange: (event) => bind(row.id, "username")(event.target.value)
554
958
  })
555
959
  ),
556
- // Every transport accepts a password now: SOCKS5 hands it to
960
+ // Every transport accepts a password: SOCKS5 hands it to
557
961
  // Socks5ProxyAgent as an option, HTTP/HTTPS embed it in the proxy URL
558
962
  // userinfo (undici turns that into Proxy-Authorization). It is stored on
559
963
  // the route, so it lands in the settings file with the rest of the
@@ -563,19 +967,106 @@ function renderPage(ctx) {
563
967
  h("input", {
564
968
  className: import_client.default.input,
565
969
  style: S.input,
566
- disabled,
567
970
  type: "password",
568
971
  autocomplete: "off",
569
972
  value: route.password,
570
973
  onChange: (event) => bind(row.id, "password")(event.target.value)
571
974
  }),
572
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
+ )
1024
+ ),
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)
573
1048
  ),
574
- // The self-check only makes sense for a route that is ON; a disabled
575
- // route is not in the Host's table, so there is nothing to test.
576
- route.enabled ? h(
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(
577
1065
  "div",
578
- { className: import_client.default.testRow, style: S.testRow, key: `${row.id}:test` },
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),
579
1070
  h(
580
1071
  "button",
581
1072
  {
@@ -589,7 +1080,7 @@ function renderPage(ctx) {
589
1080
  ),
590
1081
  result?.pending === false ? h(
591
1082
  "span",
592
- { className: import_client.default.testResult, style: S.testResult },
1083
+ { style: S.testResult },
593
1084
  result.ok === true ? [
594
1085
  // Only the latency carries the band colour; the exit IP
595
1086
  // stays in the secondary label colour.
@@ -598,38 +1089,66 @@ function renderPage(ctx) {
598
1089
  { key: "lat", style: { color: latencyColor(result.ms) } },
599
1090
  `\u2713 ${typeof result.ms === "number" ? result.ms : "\u2014"} ms`
600
1091
  ),
601
- result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`
602
- ] : `\u2717 ${result.error}`
1092
+ result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`,
1093
+ result.direct === true ? ` \xB7 ${t.directLabel}` : null
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
603
1108
  ) : null
604
1109
  ) : null,
605
1110
  // Target-host rules, collapsed so the common case stays one short card.
606
1111
  h(
607
1112
  "details",
608
- { className: import_client.default.advanced, style: S.advanced, key: `${row.id}:rules` },
609
- h("summary", { className: import_client.default.advancedSummary, style: S.advancedSummary }, t.advanced),
1113
+ { style: S.advanced, key: `${row.id}:rules` },
1114
+ h("summary", { style: S.advancedSummary }, t.advanced),
610
1115
  field(
611
1116
  t.onlyHosts,
612
1117
  h("input", {
613
1118
  className: import_client.default.input,
614
1119
  style: S.input,
615
- disabled,
616
1120
  placeholder: "api.example.com",
617
1121
  value: route.onlyHosts,
618
1122
  onChange: (event) => bind(row.id, "onlyHosts")(event.target.value)
619
1123
  }),
620
- t.onlyHostsHint
1124
+ onlyDead.length === 0 ? t.onlyHostsHint : `${t.ruleDeadHint}${onlyDead.join("\u3001")}`,
1125
+ onlyDead.length > 0
621
1126
  ),
622
1127
  field(
623
1128
  t.bypassHosts,
624
1129
  h("input", {
625
1130
  className: import_client.default.input,
626
1131
  style: S.input,
627
- disabled,
628
1132
  placeholder: "localhost",
629
1133
  value: route.bypassHosts,
630
1134
  onChange: (event) => bind(row.id, "bypassHosts")(event.target.value)
631
1135
  }),
632
- 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
633
1152
  )
634
1153
  )
635
1154
  );
@@ -638,6 +1157,8 @@ function renderPage(ctx) {
638
1157
  "div",
639
1158
  { className: import_client.default.root, style: S.root },
640
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,
641
1162
  ...body,
642
1163
  h(
643
1164
  "details",
@@ -653,38 +1174,71 @@ function renderPage(ctx) {
653
1174
  color: "var(--dsw-alias-label-secondary)"
654
1175
  }
655
1176
  },
656
- h("summary", { className: import_client.default.diagSummary }, t.diagnostics),
1177
+ // The count is the only visible signal while the panel is collapsed: a
1178
+ // rejected route is not a page that refuses to render, it is one that
1179
+ // quietly does nothing.
1180
+ h(
1181
+ "summary",
1182
+ { className: import_client.default.diagSummary },
1183
+ problemCount === 0 ? t.diagnostics : `${t.diagnostics} (${problemCount})`
1184
+ ),
657
1185
  h(
658
1186
  "ul",
659
1187
  { className: import_client.default.diagList, style: { margin: "6px 0 0", paddingLeft: "18px" } },
660
- ...diagnostics.length === 0 ? [h("li", { key: "none" }, t.diagPending)] : diagnostics.map((line, index) => h("li", { key: `d${index}` }, line))
1188
+ ...problemCount === 0 && diagnostics.length === 0 ? [h("li", { key: "none" }, t.diagPending)] : [
1189
+ ...problemLines,
1190
+ ...diagnostics.map((line, index) => h("li", { key: `d${index}` }, line))
1191
+ ]
661
1192
  )
662
1193
  )
663
1194
  );
664
1195
  }
665
1196
  var EN = {
666
- 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.",
667
1198
  empty: "No providers found yet.",
668
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",
669
1210
  protocol: "Type",
670
1211
  host: "IP / host",
671
1212
  port: "Port",
672
1213
  username: "Username",
673
1214
  password: "Password",
674
- 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.",
675
1216
  passwordHint: "Sent to the SOCKS5 proxy for authentication; stored with this setting.",
676
1217
  passwordHintProxy: "Sent to the proxy as an authentication password; stored with this setting.",
677
1218
  hitCountPrefix: "",
678
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",
679
1228
  testButton: "Test",
680
1229
  testRunning: "Testing\u2026",
1230
+ testDisabledNote: "Not enabled \u2014 test only: ",
681
1231
  testFailed: "Test failed",
682
1232
  exitIpLabel: "exit IP ",
1233
+ directLabel: "direct (not through the proxy)",
683
1234
  advanced: "Advanced: target host rules",
684
1235
  onlyHosts: "Proxy only",
685
1236
  onlyHostsHint: "Comma/space separated; when set, only requests to these hosts are proxied (suffix match, * for all).",
686
1237
  bypassHosts: "Bypass",
687
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.",
688
1242
  socks5Hint: "SOCKS5 tunnels the connection; the proxy resolves the target host name.",
689
1243
  on: "proxied",
690
1244
  live: "active",
@@ -693,28 +1247,51 @@ var EN = {
693
1247
  diagPending: "(collecting\u2026)"
694
1248
  };
695
1249
  var ZH = {
696
- 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",
697
1251
  empty: "\u6CA1\u6709\u68C0\u6D4B\u5230\u4EFB\u4F55 provider\u3002",
698
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",
699
1263
  protocol: "\u7C7B\u578B",
700
1264
  host: "IP / \u4E3B\u673A",
701
1265
  port: "\u7AEF\u53E3",
702
1266
  username: "\u7528\u6237\u540D",
703
1267
  password: "\u5BC6\u7801",
704
- 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",
705
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",
706
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",
707
1271
  hitCountPrefix: "\u5DF2\u4EE3\u7406 ",
708
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",
709
1281
  testButton: "\u6D4B\u8BD5",
710
1282
  testRunning: "\u6D4B\u8BD5\u4E2D\u2026",
1283
+ testDisabledNote: "\u672A\u542F\u7528\uFF0C\u4EC5\u6D4B\u8BD5\uFF1A",
711
1284
  testFailed: "\u6D4B\u8BD5\u5931\u8D25",
712
1285
  exitIpLabel: "\u51FA\u53E3 IP ",
1286
+ directLabel: "\u76F4\u8FDE\uFF08\u672A\u7ECF\u8FC7\u4EE3\u7406\uFF09",
713
1287
  advanced: "\u9AD8\u7EA7\uFF1A\u76EE\u6807\u57DF\u540D\u89C4\u5219",
714
1288
  onlyHosts: "\u4EC5\u8D70\u4EE3\u7406",
715
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",
716
1290
  bypassHosts: "\u76F4\u8FDE",
717
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",
718
1295
  socks5Hint: "SOCKS5 \u4F1A\u5EFA\u7ACB\u96A7\u9053\uFF0C\u76EE\u6807\u57DF\u540D\u7531\u4EE3\u7406\u7AEF\u89E3\u6790\u3002",
719
1296
  on: "\u8D70\u4EE3\u7406",
720
1297
  live: "\u542F\u7528\u4E2D",