diff --git a/packages/core/src/generator/chain.test.ts b/packages/core/src/generator/chain.test.ts index b0141fa..67ba0db 100644 --- a/packages/core/src/generator/chain.test.ts +++ b/packages/core/src/generator/chain.test.ts @@ -33,6 +33,40 @@ function dialerGroup(patch: Partial = {}): DialerProxyGroup { } describe("dialer proxy chain helpers", () => { + it("applies global url-test overrides without changing legacy defaults", () => { + const groups = [ + dialerGroup({ + id: "auto-relay", + name: "Auto Relay", + type: "url-test", + relayNodes: ["Relay A"], + }), + ]; + + expect( + generateDialerProxyGroups( + groups, + "https://probe.example.com/204", + 120, + [], + { urlTestLazy: false, urlTestTolerance: 50 }, + ), + ).toEqual([ + { + name: "Auto Relay", + type: "url-test", + proxies: ["Relay A"], + url: "https://probe.example.com/204", + interval: 120, + lazy: false, + tolerance: 50, + }, + ]); + + expect(generateDialerProxyGroups(groups)[0]).not.toHaveProperty("tolerance"); + expect(generateDialerProxyGroups(groups)[0]).toMatchObject({ lazy: true }); + }); + it("generates typed dialer groups with shared proxy group fields", () => { expect( generateDialerProxyGroups( diff --git a/packages/core/src/generator/chain.ts b/packages/core/src/generator/chain.ts index 6ddd21f..6f7f347 100644 --- a/packages/core/src/generator/chain.ts +++ b/packages/core/src/generator/chain.ts @@ -33,7 +33,8 @@ export function generateDialerProxyGroups( groups: DialerProxyGroup[], testUrl: string = DEFAULT_SUBBOOST_CONFIG.testUrl, testInterval: number = DEFAULT_SUBBOOST_CONFIG.testInterval, - proxyProviderNames: string[] = [] + proxyProviderNames: string[] = [], + urlTestOptions: { urlTestLazy?: boolean; urlTestTolerance?: number } = {}, ): Array> { const providerUse = proxyProviderNames.length > 0 ? { use: proxyProviderNames } : {}; return groups @@ -48,7 +49,8 @@ export function generateDialerProxyGroups( testInterval, strategy: group.group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, extraFields: providerUse, - urlTestLazy: true, + urlTestLazy: urlTestOptions.urlTestLazy ?? true, + urlTestTolerance: urlTestOptions.urlTestTolerance, }); }); } diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index 19ddd86..1cba1ed 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -17,6 +17,39 @@ function ssNode(patch: Partial = {}): ParsedNode { const REALITY_PUBLIC_KEY = "A".repeat(43); describe("generateClashConfig", () => { + it("applies global url-test overrides to regular and dialer groups", () => { + const config = generateClashConfig({ + nodes: [ + ssNode({ name: "Relay" }), + ssNode({ name: "Target", server: "target.example.com" }), + ], + userConfig: { + dnsYaml: "", + enabledGroups: ["auto"], + urlTestLazy: true, + urlTestTolerance: 50, + }, + dialerProxyGroups: [ + { + id: "relay", + name: "Relay Auto", + type: "url-test", + relayNodes: ["Relay"], + targetNodes: ["Target"], + }, + ], + }); + + const urlTestGroups = (config["proxy-groups"] ?? []).filter((group) => group.type === "url-test"); + expect(urlTestGroups.length).toBeGreaterThanOrEqual(2); + expect(urlTestGroups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "⚡ 自动选择", lazy: true, tolerance: 50 }), + expect.objectContaining({ name: "Relay Auto", lazy: true, tolerance: 50 }), + ]), + ); + }); + it("treats explicit empty base YAML as a strict patch instead of re-adding defaults", () => { const config = generateClashConfig({ nodes: [ssNode()], diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 5157b1c..603bdba 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -343,7 +343,11 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { sanitizedDialerProxyGroups, config.testUrl, config.testInterval, - proxyProviderNames + proxyProviderNames, + { + urlTestLazy: config.urlTestLazy, + urlTestTolerance: config.urlTestTolerance, + }, ) as unknown as ProxyGroup[]; // 生成选项 @@ -354,6 +358,8 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { ruleProviderBaseUrl: config.ruleProviderBaseUrl, testUrl: config.testUrl, testInterval: config.testInterval, + urlTestLazy: config.urlTestLazy, + urlTestTolerance: config.urlTestTolerance, customProxyGroups, customRuleSets, proxyGroupAdvanced, diff --git a/packages/core/src/generator/proxy-group-type.ts b/packages/core/src/generator/proxy-group-type.ts index dac8ae1..63209a1 100644 --- a/packages/core/src/generator/proxy-group-type.ts +++ b/packages/core/src/generator/proxy-group-type.ts @@ -14,6 +14,7 @@ type BuildTypedProxyGroupOptions = { strategy?: LoadBalanceStrategy; extraFields?: Record; urlTestLazy?: boolean; + urlTestTolerance?: number; }; export function uniqueProxyNames(values: string[]): string[] { @@ -36,6 +37,7 @@ export function buildTypedProxyGroup({ strategy, extraFields = {}, urlTestLazy = false, + urlTestTolerance, }: BuildTypedProxyGroupOptions): ProxyGroup { const base: ProxyGroup = { name, @@ -52,6 +54,7 @@ export function buildTypedProxyGroup({ url: testUrl, interval: testInterval, lazy: urlTestLazy, + ...(urlTestTolerance !== undefined ? { tolerance: urlTestTolerance } : {}), }; case "fallback": return { diff --git a/packages/core/src/generator/proxy-groups.test.ts b/packages/core/src/generator/proxy-groups.test.ts index 07a881a..6f9ad6e 100644 --- a/packages/core/src/generator/proxy-groups.test.ts +++ b/packages/core/src/generator/proxy-groups.test.ts @@ -32,6 +32,24 @@ function customGroup(id: string, groupType: CustomProxyGroup["groupType"]): Cust } describe("proxy group generator", () => { + it("applies global url-test overrides when configured", () => { + const groups = generateProxyGroups({ + nodes: [node("Node A")], + enabledModules: ["auto"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + urlTestLazy: true, + urlTestTolerance: 50, + }); + + expect(groups.find((group) => group.name.includes("自动选择"))).toMatchObject({ + type: "url-test", + lazy: true, + tolerance: 50, + }); + }); + it("generates module, custom, advanced-filtered, and provider-backed groups", () => { const groups = generateProxyGroups({ nodes: [node("Node A"), node("Node B")], diff --git a/packages/core/src/generator/proxy-groups.ts b/packages/core/src/generator/proxy-groups.ts index 63d56d5..84ae023 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -55,6 +55,8 @@ export interface GenerateOptions { ruleProviderBaseUrl: string; testUrl: string; testInterval: number; + urlTestLazy?: boolean; + urlTestTolerance?: number; customProxyGroups?: CustomProxyGroup[]; customRuleSets?: CustomRuleSet[]; proxyGroupAdvanced?: Record; @@ -137,6 +139,8 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { enabledModules, testUrl, testInterval, + urlTestLazy, + urlTestTolerance, customProxyGroups = [], proxyGroupAdvanced = {}, proxyGroupNameOverrides, @@ -237,7 +241,8 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { testInterval, strategy, extraFields, - urlTestLazy: false, + urlTestLazy: urlTestLazy ?? false, + urlTestTolerance, }); // 辅助函数:生成单个代理组 diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 27bbf7a..1a2b9b3 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -98,6 +98,8 @@ describe("subscription config utils", () => { ruleProviderBaseUrl: " https://rules.example.com ", testUrl: "https://cp.cloudflare.com", testInterval: 180, + urlTestLazy: true, + urlTestTolerance: 50, }, { nodes: [node()] } ); @@ -117,6 +119,8 @@ describe("subscription config utils", () => { experimentalCnUseCnRuleSet: true, testUrl: "https://cp.cloudflare.com", testInterval: 180, + urlTestLazy: true, + urlTestTolerance: 50, listenerPorts: { Node: 12000 }, }); expect(userConfig.customRules?.[0]).toMatchObject({ diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 09d4517..1e715ab 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -265,6 +265,13 @@ export function buildGenerateOptionsFromConfig( const cnIpNoResolve = typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : undefined; const experimentalCnUseCnRuleSet = typeof config.experimentalCnUseCnRuleSet === "boolean" ? config.experimentalCnUseCnRuleSet : undefined; + const urlTestLazy = typeof config.urlTestLazy === "boolean" ? config.urlTestLazy : undefined; + const urlTestTolerance = + typeof config.urlTestTolerance === "number" && + Number.isInteger(config.urlTestTolerance) && + config.urlTestTolerance >= 0 + ? config.urlTestTolerance + : undefined; const proxyGroupNameOverrides = normalizeProxyGroupNameOverrides(config.proxyGroupNameOverrides); const listenerPorts = normalizeListenerPorts(config.listenerPorts); const ruleOrder = normalizePersistedRuleOrder({ @@ -290,6 +297,8 @@ export function buildGenerateOptionsFromConfig( ...(autoSelectStrategy ? { autoSelectStrategy } : {}), testUrl, testInterval, + ...(urlTestLazy !== undefined ? { urlTestLazy } : {}), + ...(urlTestTolerance !== undefined ? { urlTestTolerance } : {}), ...(cnIpNoResolve !== undefined ? { cnIpNoResolve } : {}), ...(experimentalCnUseCnRuleSet !== undefined ? { experimentalCnUseCnRuleSet } : {}), ...(normalizePort(config.mixedPort) !== undefined ? { mixedPort: normalizePort(config.mixedPort) as number } : {}), diff --git a/packages/core/src/templates/config-template.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index a81947b..bd25ed3 100644 --- a/packages/core/src/templates/config-template.fields.test.ts +++ b/packages/core/src/templates/config-template.fields.test.ts @@ -3,6 +3,20 @@ import { validateSubBoostTemplateConfig } from "./config-template"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; describe("validateSubBoostTemplateConfig field validation", () => { + it("accepts optional global url-test overrides and validates tolerance", () => { + const result = validateSubBoostTemplateConfig( + validConfig({ urlTestLazy: true, urlTestTolerance: 50 }), + ); + + expect(result.ok && result.config).toMatchObject({ + urlTestLazy: true, + urlTestTolerance: 50, + }); + expectInvalid({ urlTestLazy: "yes" as never }, "urlTestLazy 必须是布尔值"); + expectInvalid({ urlTestTolerance: -1 }, "urlTestTolerance 必须是非负整数"); + expectInvalid({ urlTestTolerance: 1.5 }, "urlTestTolerance 必须是非负整数"); + }); + it("rejects invalid dialer, module rule, scalar, and URL fields", () => { expectInvalid({ dialerProxyGroups: "bad" as never }, "dialerProxyGroups 必须是数组"); expectInvalid({ dialerProxyGroups: [1 as never] }, "dialerProxyGroups 只能包含对象"); diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 0af2254..2fd704b 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -92,6 +92,10 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult if (!testUrl.ok) return testUrl; const testInterval = parsePositiveInteger(value.testInterval, "testInterval"); if (!testInterval.ok) return testInterval; + const urlTestLazy = parseOptionalBoolean(value.urlTestLazy, "urlTestLazy"); + if (!urlTestLazy.ok) return urlTestLazy; + const urlTestTolerance = parseOptionalNonNegativeInteger(value.urlTestTolerance, "urlTestTolerance"); + if (!urlTestTolerance.ok) return urlTestTolerance; const ruleProviderBaseUrl = parseHttpUrlString(value.ruleProviderBaseUrl, "ruleProviderBaseUrl"); if (!ruleProviderBaseUrl.ok) return ruleProviderBaseUrl; const cnIpNoResolve = parseOptionalBoolean(value.cnIpNoResolve, "cnIpNoResolve"); @@ -142,6 +146,8 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult allowLan: allowLan.value, testUrl: testUrl.value, testInterval: testInterval.value, + ...(urlTestLazy.value !== undefined ? { urlTestLazy: urlTestLazy.value } : {}), + ...(urlTestTolerance.value !== undefined ? { urlTestTolerance: urlTestTolerance.value } : {}), ruleProviderBaseUrl: ruleProviderBaseUrl.value, }, }; @@ -209,6 +215,17 @@ function parsePositiveInteger( return { ok: true, value }; } +function parseOptionalNonNegativeInteger( + value: unknown, + field: string +): { ok: true; value?: number } | { ok: false; error: string } { + if (value === undefined) return { ok: true, value: undefined }; + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + return invalid(`${field} 必须是非负整数`); + } + return { ok: true, value }; +} + function parsePort(value: unknown, field: string): { ok: true; value: number } | { ok: false; error: string } { const parsed = parsePositiveInteger(value, field); if (!parsed.ok) return parsed; diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 49babde..5d3955d 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -164,6 +164,8 @@ export interface UserConfig { autoSelectStrategy: "url-test" | "fallback" | "load-balance"; testUrl: string; testInterval: number; + urlTestLazy?: boolean; + urlTestTolerance?: number; // 规则设置 ruleProviderBaseUrl: string; diff --git a/packages/core/src/types/template-config.ts b/packages/core/src/types/template-config.ts index 81b278d..7870d6e 100644 --- a/packages/core/src/types/template-config.ts +++ b/packages/core/src/types/template-config.ts @@ -41,5 +41,7 @@ export type SubBoostTemplateConfig = { allowLan: boolean; testUrl: string; testInterval: number; + urlTestLazy?: boolean; + urlTestTolerance?: number; ruleProviderBaseUrl: string; }; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx index 96b8c29..f09452f 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx @@ -34,6 +34,7 @@ import { ProxyGroupsCustomGroupsPanel } from "./proxy-groups-custom-groups-panel import { ProxyGroupsCustomRoutingRules } from "./proxy-groups-custom-routing-rules"; import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; +import { ProxyGroupsUrlTestSettings } from "./proxy-groups-url-test-settings"; const PROXY_GROUP_SECTION_LABEL_ROW_CLASS = "flex min-h-7 items-center gap-2"; const PROXY_GROUP_SECTION_LABEL_CLASS = "text-xs text-white/50"; @@ -315,6 +316,8 @@ export function ProxyGroupsCategories() { + +
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-url-test-settings.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-url-test-settings.test.ts new file mode 100644 index 0000000..54c638a --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-url-test-settings.test.ts @@ -0,0 +1,86 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + switches: [] as Array>, + inputs: [] as Array>, + setUrlTestLazy: vi.fn(), + setUrlTestTolerance: vi.fn(), +})); + +vi.mock("@subboost/ui/store/config-store", () => ({ + useConfigStore: () => ({ + ...mocks.state, + setUrlTestLazy: mocks.setUrlTestLazy, + setUrlTestTolerance: mocks.setUrlTestTolerance, + }), +})); + +vi.mock("@subboost/ui/components/ui/switch", () => ({ + Switch: (props: Record) => { + mocks.switches.push(props); + return React.createElement("button", { "data-checked": String(Boolean(props.checked)) }); + }, +})); + +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: Record) => { + mocks.inputs.push(props); + return React.createElement("input", props); + }, +})); + +import { ProxyGroupsUrlTestSettings } from "./proxy-groups-url-test-settings"; + +describe("ProxyGroupsUrlTestSettings", () => { + beforeEach(() => { + mocks.state = { urlTestLazy: undefined, urlTestTolerance: undefined }; + mocks.switches = []; + mocks.inputs = []; + vi.clearAllMocks(); + }); + + it("keeps legacy behavior until the global override is enabled", () => { + const html = renderToStaticMarkup(React.createElement(ProxyGroupsUrlTestSettings)); + + expect(html).toContain("URL-Test 全局参数"); + expect(html).toContain("兼容现有默认值"); + expect(mocks.switches).toHaveLength(1); + expect(mocks.inputs).toHaveLength(0); + + mocks.switches[0].onCheckedChange(true); + expect(mocks.setUrlTestLazy).toHaveBeenCalledWith(false); + }); + + it("edits lazy and tolerance and clears both when disabled", () => { + mocks.state = { urlTestLazy: false, urlTestTolerance: undefined }; + renderToStaticMarkup(React.createElement(ProxyGroupsUrlTestSettings)); + + expect(mocks.switches).toHaveLength(2); + expect(mocks.inputs).toHaveLength(1); + expect(mocks.inputs[0]).toMatchObject({ type: "number", min: 0, value: "" }); + + mocks.switches[1].onCheckedChange(true); + mocks.inputs[0].onChange({ target: { value: "50" } }); + expect(mocks.setUrlTestLazy).toHaveBeenCalledWith(true); + expect(mocks.setUrlTestTolerance).toHaveBeenCalledWith(50); + + mocks.switches[0].onCheckedChange(false); + expect(mocks.setUrlTestLazy).toHaveBeenCalledWith(undefined); + expect(mocks.setUrlTestTolerance).toHaveBeenCalledWith(undefined); + }); + + it("ignores invalid tolerance input", () => { + mocks.state = { urlTestLazy: true, urlTestTolerance: 50 }; + renderToStaticMarkup(React.createElement(ProxyGroupsUrlTestSettings)); + + mocks.inputs[0].onChange({ target: { value: "-1" } }); + mocks.inputs[0].onChange({ target: { value: "1.5" } }); + expect(mocks.setUrlTestTolerance).not.toHaveBeenCalled(); + + mocks.inputs[0].onChange({ target: { value: "" } }); + expect(mocks.setUrlTestTolerance).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-url-test-settings.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-url-test-settings.tsx new file mode 100644 index 0000000..babcb12 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-url-test-settings.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Input } from "@subboost/ui/components/ui/input"; +import { Switch } from "@subboost/ui/components/ui/switch"; +import { useConfigStore } from "@subboost/ui/store/config-store"; +import { useShallow } from "zustand/react/shallow"; + +export function ProxyGroupsUrlTestSettings() { + const { + urlTestLazy, + urlTestTolerance, + setUrlTestLazy, + setUrlTestTolerance, + } = useConfigStore( + useShallow((state) => ({ + urlTestLazy: state.urlTestLazy, + urlTestTolerance: state.urlTestTolerance, + setUrlTestLazy: state.setUrlTestLazy, + setUrlTestTolerance: state.setUrlTestTolerance, + })), + ); + const overrideEnabled = urlTestLazy !== undefined || urlTestTolerance !== undefined; + + const setOverrideEnabled = (enabled: boolean) => { + if (enabled) { + setUrlTestLazy(false); + return; + } + setUrlTestLazy(undefined); + setUrlTestTolerance(undefined); + }; + + const setToleranceFromInput = (raw: string) => { + if (raw === "") { + setUrlTestTolerance(undefined); + return; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return; + setUrlTestTolerance(value); + }; + + return ( +
+
+
+
URL-Test 全局参数
+
+ {overrideEnabled ? "统一覆盖所有自动测速代理组" : "兼容现有默认值"} +
+
+ +
+ + {overrideEnabled && ( +
+
+
+
lazy
+
按需触发测速
+
+ +
+ +
+ )} +
+ ); +} diff --git a/packages/ui/src/product/home/home-surface.tsx b/packages/ui/src/product/home/home-surface.tsx index ea578f6..1e94e27 100644 --- a/packages/ui/src/product/home/home-surface.tsx +++ b/packages/ui/src/product/home/home-surface.tsx @@ -76,6 +76,8 @@ function HomeSurfaceInner({ adapter }: Props) { ruleProviderBaseUrl, testUrl, testInterval, + urlTestLazy, + urlTestTolerance, cnIpNoResolve, experimentalCnUseCnRuleSet, proxyGroupNameOverrides, @@ -121,6 +123,8 @@ function HomeSurfaceInner({ adapter }: Props) { ruleProviderBaseUrl, testUrl, testInterval, + urlTestLazy, + urlTestTolerance, cnIpNoResolve, experimentalCnUseCnRuleSet, }); diff --git a/packages/ui/src/product/home/use-editing-subscription-loader.test.ts b/packages/ui/src/product/home/use-editing-subscription-loader.test.ts index 87d54d3..08e9542 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.test.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.test.ts @@ -232,6 +232,8 @@ describe("useEditingSubscriptionLoader", () => { ruleProviderBaseUrl: "https://rules.example.com", testUrl: "https://test.example.com", testInterval: 600, + urlTestLazy: true, + urlTestTolerance: 50, cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, smartNodeMatchingEnabled: false, @@ -289,6 +291,8 @@ describe("useEditingSubscriptionLoader", () => { ruleProviderBaseUrl: "https://rules.example.com", testUrl: "https://test.example.com", testInterval: 600, + urlTestLazy: true, + urlTestTolerance: 50, cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, }); diff --git a/packages/ui/src/product/home/use-editing-subscription-loader.ts b/packages/ui/src/product/home/use-editing-subscription-loader.ts index 5a615c7..d1219a3 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.ts @@ -540,6 +540,12 @@ export function useEditingSubscriptionLoader({ typeof cfg.ruleProviderBaseUrl === "string" ? (cfg.ruleProviderBaseUrl as string) : state.ruleProviderBaseUrl, testUrl: typeof cfg.testUrl === "string" ? (cfg.testUrl as string) : state.testUrl, testInterval: typeof cfg.testInterval === "number" ? (cfg.testInterval as number) : state.testInterval, + urlTestLazy: + typeof cfg.urlTestLazy === "boolean" ? (cfg.urlTestLazy as boolean) : state.urlTestLazy, + urlTestTolerance: + typeof cfg.urlTestTolerance === "number" && Number.isInteger(cfg.urlTestTolerance) && cfg.urlTestTolerance >= 0 + ? (cfg.urlTestTolerance as number) + : state.urlTestTolerance, cnIpNoResolve: typeof (cfg as any).cnIpNoResolve === "boolean" ? Boolean((cfg as any).cnIpNoResolve) : state.cnIpNoResolve, experimentalCnUseCnRuleSet: typeof (cfg as any).experimentalCnUseCnRuleSet === "boolean" diff --git a/packages/ui/src/product/home/use-subscription-link.test-helpers.ts b/packages/ui/src/product/home/use-subscription-link.test-helpers.ts index e91fc01..c2ab1e0 100644 --- a/packages/ui/src/product/home/use-subscription-link.test-helpers.ts +++ b/packages/ui/src/product/home/use-subscription-link.test-helpers.ts @@ -67,6 +67,8 @@ export function makeOptions(overrides: Record = {}) { ruleProviderBaseUrl: "https://rules.example.com", testUrl: "https://test.example.com", testInterval: 600, + urlTestLazy: true, + urlTestTolerance: 50, cnIpNoResolve: true, experimentalCnUseCnRuleSet: false, ...overrides, diff --git a/packages/ui/src/product/home/use-subscription-link.test.ts b/packages/ui/src/product/home/use-subscription-link.test.ts index e91fb21..a9a19aa 100644 --- a/packages/ui/src/product/home/use-subscription-link.test.ts +++ b/packages/ui/src/product/home/use-subscription-link.test.ts @@ -260,6 +260,8 @@ describe("useSubscriptionLink", () => { proxyGroupAdvancedModeEnabled: true, listenerPorts: { "Node A": 41000 }, proxyGroupOrder: ["select", "auto"], + urlTestLazy: true, + urlTestTolerance: 50, }), }), }) diff --git a/packages/ui/src/product/home/use-subscription-link.tsx b/packages/ui/src/product/home/use-subscription-link.tsx index 8d0822e..20ba88c 100644 --- a/packages/ui/src/product/home/use-subscription-link.tsx +++ b/packages/ui/src/product/home/use-subscription-link.tsx @@ -87,6 +87,8 @@ type Options = { ruleProviderBaseUrl: string; testUrl: string; testInterval: number; + urlTestLazy?: boolean; + urlTestTolerance?: number; cnIpNoResolve: boolean; experimentalCnUseCnRuleSet: boolean; }; @@ -121,6 +123,8 @@ export function useSubscriptionLink({ ruleProviderBaseUrl, testUrl, testInterval, + urlTestLazy, + urlTestTolerance, cnIpNoResolve, experimentalCnUseCnRuleSet, }: Options) { @@ -392,6 +396,8 @@ export function useSubscriptionLink({ ruleProviderBaseUrl, testUrl, testInterval, + ...(urlTestLazy !== undefined ? { urlTestLazy } : {}), + ...(urlTestTolerance !== undefined ? { urlTestTolerance } : {}), cnIpNoResolve, experimentalCnUseCnRuleSet, autoSelectStrategy: "url-test", @@ -488,6 +494,8 @@ export function useSubscriptionLink({ template, testInterval, testUrl, + urlTestLazy, + urlTestTolerance, trackSubscriptionMutation, ]); diff --git a/packages/ui/src/store/config-store/actions/settings-actions.test.ts b/packages/ui/src/store/config-store/actions/settings-actions.test.ts index f17b2e0..45696b3 100644 --- a/packages/ui/src/store/config-store/actions/settings-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/settings-actions.test.ts @@ -24,6 +24,8 @@ describe("config store settings actions", () => { actions.setAllowLan(true); actions.setTestUrl("https://cp.cloudflare.com/generate_204"); actions.setTestInterval(600); + actions.setUrlTestLazy(true); + actions.setUrlTestTolerance(50); actions.setRuleProviderBaseUrl("https://rules.example.com"); actions.setCnIpNoResolve(true); actions.setExperimentalCnUseCnRuleSet(1 as unknown as boolean); @@ -34,11 +36,26 @@ describe("config store settings actions", () => { allowLan: true, testUrl: "https://cp.cloudflare.com/generate_204", testInterval: 600, + urlTestLazy: true, + urlTestTolerance: 50, ruleProviderBaseUrl: "https://rules.example.com", cnIpNoResolve: true, experimentalCnUseCnRuleSet: true, }); - expect(store.setAndGenerateConfig).toHaveBeenCalledTimes(8); + expect(store.setAndGenerateConfig).toHaveBeenCalledTimes(10); expect(store.set).not.toHaveBeenCalled(); }); + + it("can clear the optional global url-test overrides", () => { + const store = createStore({ urlTestLazy: true, urlTestTolerance: 50 }); + const actions = createSettingsActions(store.set as any, store.get as any, store.setAndGenerateConfig as any); + + actions.setUrlTestLazy(undefined); + actions.setUrlTestTolerance(undefined); + + expect(store.state()).toMatchObject({ + urlTestLazy: undefined, + urlTestTolerance: undefined, + }); + }); }); diff --git a/packages/ui/src/store/config-store/actions/settings-actions.ts b/packages/ui/src/store/config-store/actions/settings-actions.ts index c1508f2..86aa504 100644 --- a/packages/ui/src/store/config-store/actions/settings-actions.ts +++ b/packages/ui/src/store/config-store/actions/settings-actions.ts @@ -8,6 +8,8 @@ type SettingsActions = Pick< | "setAllowLan" | "setTestUrl" | "setTestInterval" + | "setUrlTestLazy" + | "setUrlTestTolerance" | "setRuleProviderBaseUrl" | "setProxyGroupAdvancedModeEnabled" | "setCnIpNoResolve" @@ -40,6 +42,14 @@ export function createSettingsActions( setAndGenerateConfig(() => ({ testInterval: interval })); }, + setUrlTestLazy: (lazy: boolean | undefined) => { + setAndGenerateConfig(() => ({ urlTestLazy: lazy })); + }, + + setUrlTestTolerance: (tolerance: number | undefined) => { + setAndGenerateConfig(() => ({ urlTestTolerance: tolerance })); + }, + setRuleProviderBaseUrl: (url: string) => { setAndGenerateConfig(() => ({ ruleProviderBaseUrl: url })); }, diff --git a/packages/ui/src/store/config-store/actions/template-actions.test.ts b/packages/ui/src/store/config-store/actions/template-actions.test.ts index 62fae65..b4e367c 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.test.ts @@ -118,6 +118,8 @@ describe("createTemplateActions", () => { allowLan: false, testUrl: "https://example.com/generate_204", testInterval: 60, + urlTestLazy: true, + urlTestTolerance: 50, ruleProviderBaseUrl: "https://example.com/rules", } as unknown as SubBoostTemplateConfig; @@ -157,6 +159,8 @@ describe("createTemplateActions", () => { allowLan: false, testUrl: "https://example.com/generate_204", testInterval: 60, + urlTestLazy: true, + urlTestTolerance: 50, ruleProviderBaseUrl: "https://example.com/rules", }); expect(getState().customRules).toEqual([ diff --git a/packages/ui/src/store/config-store/actions/template-actions.ts b/packages/ui/src/store/config-store/actions/template-actions.ts index 206bdcd..9e6d5af 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.ts @@ -172,6 +172,12 @@ export function createTemplateActions( testUrl: typeof config.testUrl === "string" ? config.testUrl : state.testUrl, testInterval: typeof config.testInterval === "number" ? config.testInterval : state.testInterval, + urlTestLazy: + typeof config.urlTestLazy === "boolean" ? config.urlTestLazy : state.urlTestLazy, + urlTestTolerance: + typeof config.urlTestTolerance === "number" + ? config.urlTestTolerance + : state.urlTestTolerance, ruleProviderBaseUrl: typeof config.ruleProviderBaseUrl === "string" ? config.ruleProviderBaseUrl diff --git a/packages/ui/src/store/config-store/auth-handoff.test.ts b/packages/ui/src/store/config-store/auth-handoff.test.ts index 9e805fb..05a8614 100644 --- a/packages/ui/src/store/config-store/auth-handoff.test.ts +++ b/packages/ui/src/store/config-store/auth-handoff.test.ts @@ -73,6 +73,8 @@ function meaningfulState(overrides: Record = {}) { allowLan: true, testUrl: "https://test.example.com", testInterval: 600, + urlTestLazy: true, + urlTestTolerance: 50, ruleProviderBaseUrl: "https://rules.example.com", cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, @@ -163,6 +165,8 @@ describe("auth config handoff", () => { allowLan: true, testUrl: "https://test.example.com", testInterval: 600, + urlTestLazy: true, + urlTestTolerance: 50, ruleProviderBaseUrl: "https://rules.example.com", cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index 79cee51..77e5163 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -131,6 +131,8 @@ function hasMeaningfulConfig(state: ConfigState): boolean { state.allowLan !== initialState.allowLan || state.testUrl !== initialState.testUrl || state.testInterval !== initialState.testInterval || + state.urlTestLazy !== initialState.urlTestLazy || + state.urlTestTolerance !== initialState.urlTestTolerance || state.ruleProviderBaseUrl !== initialState.ruleProviderBaseUrl || state.cnIpNoResolve !== initialState.cnIpNoResolve || state.experimentalCnUseCnRuleSet !== initialState.experimentalCnUseCnRuleSet || @@ -164,6 +166,8 @@ function buildHandoffState(state: ConfigState): Partial { allowLan: state.allowLan, testUrl: state.testUrl, testInterval: state.testInterval, + urlTestLazy: state.urlTestLazy, + urlTestTolerance: state.urlTestTolerance, ruleProviderBaseUrl: state.ruleProviderBaseUrl, cnIpNoResolve: state.cnIpNoResolve, experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, @@ -226,6 +230,10 @@ function normalizeHandoffState(raw: unknown): Partial | null { if (typeof raw.allowLan === "boolean") out.allowLan = raw.allowLan; if (typeof raw.testUrl === "string") out.testUrl = raw.testUrl; if (typeof raw.testInterval === "number" && Number.isFinite(raw.testInterval)) out.testInterval = raw.testInterval; + if (typeof raw.urlTestLazy === "boolean") out.urlTestLazy = raw.urlTestLazy; + if (typeof raw.urlTestTolerance === "number" && Number.isInteger(raw.urlTestTolerance) && raw.urlTestTolerance >= 0) { + out.urlTestTolerance = raw.urlTestTolerance; + } if (typeof raw.ruleProviderBaseUrl === "string") out.ruleProviderBaseUrl = raw.ruleProviderBaseUrl; if (typeof raw.cnIpNoResolve === "boolean") out.cnIpNoResolve = raw.cnIpNoResolve; if (typeof raw.experimentalCnUseCnRuleSet === "boolean") { diff --git a/packages/ui/src/store/config-store/definitions.ts b/packages/ui/src/store/config-store/definitions.ts index 91e2dc0..b90c391 100644 --- a/packages/ui/src/store/config-store/definitions.ts +++ b/packages/ui/src/store/config-store/definitions.ts @@ -205,6 +205,8 @@ export interface ConfigState { allowLan: boolean; testUrl: string; testInterval: number; + urlTestLazy?: boolean; + urlTestTolerance?: number; ruleProviderBaseUrl: string; cnIpNoResolve: boolean; experimentalCnUseCnRuleSet: boolean; @@ -294,6 +296,8 @@ export interface ConfigActions { setAllowLan: (allow: boolean) => void; setTestUrl: (url: string) => void; setTestInterval: (interval: number) => void; + setUrlTestLazy: (lazy: boolean | undefined) => void; + setUrlTestTolerance: (tolerance: number | undefined) => void; setRuleProviderBaseUrl: (url: string) => void; setProxyGroupAdvancedModeEnabled: (value: boolean) => void; setCnIpNoResolve: (value: boolean) => void; diff --git a/packages/ui/src/store/config-store/generated-yaml.test.ts b/packages/ui/src/store/config-store/generated-yaml.test.ts index dc4c0e8..045edf6 100644 --- a/packages/ui/src/store/config-store/generated-yaml.test.ts +++ b/packages/ui/src/store/config-store/generated-yaml.test.ts @@ -56,6 +56,8 @@ describe("computeGeneratedYamlResult", () => { ], testUrl: "https://cp.cloudflare.com/generate_204", testInterval: 600, + urlTestLazy: true, + urlTestTolerance: 50, enabledProxyGroups: ["select", "ai"], ruleOrder: ["module:ai"], }) @@ -84,6 +86,8 @@ describe("computeGeneratedYamlResult", () => { enabledRules: ["select", "ai"], ruleOrder: ["module:ai"], autoSelectStrategy: "url-test", + urlTestLazy: true, + urlTestTolerance: 50, }), }) ); diff --git a/packages/ui/src/store/config-store/generated-yaml.ts b/packages/ui/src/store/config-store/generated-yaml.ts index b202822..3cf4e5b 100644 --- a/packages/ui/src/store/config-store/generated-yaml.ts +++ b/packages/ui/src/store/config-store/generated-yaml.ts @@ -69,6 +69,8 @@ function buildGenerateClashYamlOptions( listenerPorts: state.listenerPorts, testUrl: state.testUrl, testInterval: state.testInterval, + urlTestLazy: state.urlTestLazy, + urlTestTolerance: state.urlTestTolerance, ruleProviderBaseUrl: state.ruleProviderBaseUrl, autoSelectStrategy: "url-test", }, diff --git a/packages/ui/src/store/config-store/persistence.test.ts b/packages/ui/src/store/config-store/persistence.test.ts new file mode 100644 index 0000000..c44e721 --- /dev/null +++ b/packages/ui/src/store/config-store/persistence.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { initialState } from "./definitions"; +import { normalizePersistedConfigState, partializeConfigState } from "./persistence"; + +describe("config store persistence", () => { + it("round-trips optional global url-test overrides", () => { + const state = { + ...structuredClone(initialState), + urlTestLazy: true, + urlTestTolerance: 50, + } as any; + + const persisted = partializeConfigState(state); + + expect(persisted).toMatchObject({ urlTestLazy: true, urlTestTolerance: 50 }); + expect(normalizePersistedConfigState(persisted)).toMatchObject({ + urlTestLazy: true, + urlTestTolerance: 50, + }); + }); + + it("drops malformed url-test overrides", () => { + const normalized = normalizePersistedConfigState({ + urlTestLazy: "yes", + urlTestTolerance: -1, + }); + + expect(normalized).not.toHaveProperty("urlTestLazy"); + expect(normalized).not.toHaveProperty("urlTestTolerance"); + }); +}); diff --git a/packages/ui/src/store/config-store/persistence.ts b/packages/ui/src/store/config-store/persistence.ts index 8f359f3..cd2c39c 100644 --- a/packages/ui/src/store/config-store/persistence.ts +++ b/packages/ui/src/store/config-store/persistence.ts @@ -54,6 +54,10 @@ export function normalizePersistedConfigState( ...(typeof state.allowLan === "boolean" ? { allowLan: state.allowLan } : {}), ...(typeof state.testUrl === "string" ? { testUrl: state.testUrl } : {}), ...(typeof state.testInterval === "number" ? { testInterval: state.testInterval } : {}), + ...(typeof state.urlTestLazy === "boolean" ? { urlTestLazy: state.urlTestLazy } : {}), + ...(typeof state.urlTestTolerance === "number" && Number.isInteger(state.urlTestTolerance) && state.urlTestTolerance >= 0 + ? { urlTestTolerance: state.urlTestTolerance } + : {}), ...(typeof state.ruleProviderBaseUrl === "string" ? { ruleProviderBaseUrl: state.ruleProviderBaseUrl } : {}), ...(typeof state.proxyGroupAdvancedModeEnabled === "boolean" ? { proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled } @@ -74,6 +78,8 @@ export function partializeConfigState(state: ConfigState): Partial allowLan: state.allowLan, testUrl: state.testUrl, testInterval: state.testInterval, + ...(state.urlTestLazy !== undefined ? { urlTestLazy: state.urlTestLazy } : {}), + ...(state.urlTestTolerance !== undefined ? { urlTestTolerance: state.urlTestTolerance } : {}), ruleProviderBaseUrl: state.ruleProviderBaseUrl, proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled, cnIpNoResolve: state.cnIpNoResolve, diff --git a/packages/ui/src/templates/template-library-surface.tsx b/packages/ui/src/templates/template-library-surface.tsx index 9cfcb72..d7e96bb 100644 --- a/packages/ui/src/templates/template-library-surface.tsx +++ b/packages/ui/src/templates/template-library-surface.tsx @@ -91,6 +91,8 @@ function TemplateLibraryInner({ adapter }: Props) { allowLan, testUrl, testInterval, + urlTestLazy, + urlTestTolerance, ruleProviderBaseUrl, cnIpNoResolve, experimentalCnUseCnRuleSet, @@ -312,6 +314,8 @@ function TemplateLibraryInner({ adapter }: Props) { allowLan, testUrl, testInterval, + ...(urlTestLazy !== undefined ? { urlTestLazy } : {}), + ...(urlTestTolerance !== undefined ? { urlTestTolerance } : {}), ruleProviderBaseUrl, cnIpNoResolve, experimentalCnUseCnRuleSet,