From dfc62318c9addabd4ca5dec5e866d5e9cc88a6bf Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:40:54 +0800 Subject: [PATCH 01/13] fix: preserve TLS node fields and ignore stale user responses --- .../src/mihomo/certificate-fingerprint.ts | 11 ++++ packages/core/src/mihomo/proxy-sanitizer.ts | 12 +---- packages/core/src/parser/canonical-fields.ts | 10 +++- packages/core/src/parser/protocols/vless.ts | 2 + .../src/parser/tls-field-roundtrip.test.ts | 44 ++++++++++++++++ packages/ui/src/store/user-store.test.ts | 52 +++++++++++++++++++ packages/ui/src/store/user-store.ts | 18 +++++-- 7 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/mihomo/certificate-fingerprint.ts create mode 100644 packages/core/src/parser/tls-field-roundtrip.test.ts diff --git a/packages/core/src/mihomo/certificate-fingerprint.ts b/packages/core/src/mihomo/certificate-fingerprint.ts new file mode 100644 index 00000000..edd8a25f --- /dev/null +++ b/packages/core/src/mihomo/certificate-fingerprint.ts @@ -0,0 +1,11 @@ +/** Normalize a full-certificate SHA256 fingerprint, not a client uTLS name or HPKP pin. */ +export function normalizeCertificateFingerprint(value: unknown): string | null { + if (typeof value !== "string") return null; + const raw = value.trim(); + if (!raw) return null; + const withoutPrefix = raw + .replace(/^sha256\s+fingerprint\s*=\s*/i, "") + .replace(/^sha256[:=]\s*/i, ""); + const compact = withoutPrefix.replace(/:/g, "").toLowerCase(); + return /^[A-Fa-f0-9]{64}$/.test(compact) ? compact : null; +} diff --git a/packages/core/src/mihomo/proxy-sanitizer.ts b/packages/core/src/mihomo/proxy-sanitizer.ts index 3d8f941b..500dc5c6 100644 --- a/packages/core/src/mihomo/proxy-sanitizer.ts +++ b/packages/core/src/mihomo/proxy-sanitizer.ts @@ -2,10 +2,10 @@ import type { ParsedNode } from "@subboost/core/types/node"; import { splitWsPathEarlyData } from "@subboost/core/parser/ws-early-data"; import { isMihomoEchQueryServerName, isStandardBase64String } from "./ech"; import { normalizeRealityShortId } from "./reality"; +import { normalizeCertificateFingerprint } from "./certificate-fingerprint"; const REALITY_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/; const WIREGUARD_KEY_PATTERN = /^[A-Za-z0-9+/]{43}=$/; -const CERTIFICATE_FINGERPRINT_HEX_PATTERN = /^[A-Fa-f0-9]{64}$/; const SSH_SERVER_FINGERPRINT_PATTERN = /^SHA256:[A-Za-z0-9+/]{43}=?$/; const VLESS_ENCRYPTION_PATTERN = /^mlkem768x25519plus\.(?:native|xorpub|random)\.(?:1rtt|0rtt)\.[A-Za-z0-9+/=_-]+(?:\.[A-Za-z0-9+/=_-]+)*$/; @@ -72,16 +72,6 @@ function normalizeClientFingerprintAlias(value: unknown): string | null { return normalized; } -function normalizeCertificateFingerprint(value: unknown): string | null { - const raw = normalizeString(value); - if (!raw) return null; - const withoutPrefix = raw - .replace(/^sha256\s+fingerprint\s*=\s*/i, "") - .replace(/^sha256[:=]\s*/i, ""); - const compact = withoutPrefix.replace(/:/g, "").toLowerCase(); - return CERTIFICATE_FINGERPRINT_HEX_PATTERN.test(compact) ? compact : null; -} - function normalizeWireGuardKey(value: unknown): string | null { const normalized = normalizeString(value); if (!normalized || !WIREGUARD_KEY_PATTERN.test(normalized)) return null; diff --git a/packages/core/src/parser/canonical-fields.ts b/packages/core/src/parser/canonical-fields.ts index 34f0dc0a..d87c554f 100644 --- a/packages/core/src/parser/canonical-fields.ts +++ b/packages/core/src/parser/canonical-fields.ts @@ -1,4 +1,5 @@ import type { ParsedNode } from "@subboost/core/types/node"; +import { normalizeCertificateFingerprint } from "@subboost/core/mihomo/certificate-fingerprint"; type FieldPath = readonly string[]; @@ -187,13 +188,18 @@ export function canonicalizeParsedNode = { ...node }; + const hasCertificateFingerprint = normalizeCertificateFingerprint(out.fingerprint) !== null; for (const rule of PROTOCOL_FIELD_ALIAS_RULES) { if (!ruleApplies(rule, type)) continue; - const value = pickAliasValue(out, [rule.canonical, ...rule.aliases]); + // A certificate hash is an independent TLS field, not a client fingerprint alias. + const aliases = hasCertificateFingerprint + ? rule.aliases.filter((alias) => !pathEquals(alias, ["fingerprint"])) + : rule.aliases; + const value = pickAliasValue(out, [rule.canonical, ...aliases]); if (!hasValue(value)) continue; setPath(out, rule.canonical, value); - for (const alias of rule.aliases) { + for (const alias of aliases) { if (!pathEquals(alias, rule.canonical)) deletePath(out, alias); } } diff --git a/packages/core/src/parser/protocols/vless.ts b/packages/core/src/parser/protocols/vless.ts index 073ad867..8fe33674 100644 --- a/packages/core/src/parser/protocols/vless.ts +++ b/packages/core/src/parser/protocols/vless.ts @@ -330,6 +330,8 @@ export function parseVLESS(uri: string): VLESSNode { } if (allowInsecure) node["skip-cert-verify"] = true; + const tfo = parseBoolish(pickQueryValue(params, ["tfo", "fast-open", "fast_open", "fastOpen"])); + if (tfo !== undefined) node.tfo = tfo; if (sni) node.servername = sni; if (flow) node.flow = flow; if (encryption) node.encryption = encryption; diff --git a/packages/core/src/parser/tls-field-roundtrip.test.ts b/packages/core/src/parser/tls-field-roundtrip.test.ts new file mode 100644 index 00000000..b712ead4 --- /dev/null +++ b/packages/core/src/parser/tls-field-roundtrip.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import yaml from "js-yaml"; +import { parseSubscription } from "./index"; +import { generateClashYaml } from "../generator"; + +const UUID = "11111111-1111-4111-8111-111111111111"; + +function importAndExport(input: string) { + const result = parseSubscription(input); + expect(result.errors).toEqual([]); + expect(result.nodes).toHaveLength(1); + const output = yaml.load(generateClashYaml({ nodes: result.nodes })) as { + proxies: Array>; + }; + expect(output.proxies).toHaveLength(1); + return output.proxies[0]; +} + +describe("subscription TLS field round trips", () => { + it.each(["vless", "vmess", "trojan", "anytls"])( + "preserves certificate and client fingerprints independently for %s YAML", + (type) => { + const proxy = importAndExport(yaml.dump({ proxies: [{ + name: "TLS test", type, server: "local.subboost.test", port: 443, + uuid: UUID, password: "synthetic-password", cipher: "auto", tls: true, + fingerprint: "AB".repeat(32), "client-fingerprint": "chrome", + }] })); + expect(proxy.fingerprint).toBe("ab".repeat(32)); + expect(proxy["client-fingerprint"]).toBe("chrome"); + }, + ); + + it.each([ + ["tfo=true", true], ["tfo=false", false], + ["fast-open=1", true], ["fast_open=0", false], ["fastOpen=yes", true], + ["tfo=false&fast-open=true", false], + ["tfo=invalid", undefined], ["", undefined], + ])("preserves VLESS transport setting %s through export", (query, expected) => { + const proxy = importAndExport( + `vless://${UUID}@local.subboost.test:443?security=tls&${query}#VLESS`, + ); + expect(proxy.tfo).toBe(expected); + }); +}); diff --git a/packages/ui/src/store/user-store.test.ts b/packages/ui/src/store/user-store.test.ts index ccb06b26..c00f4d11 100644 --- a/packages/ui/src/store/user-store.test.ts +++ b/packages/ui/src/store/user-store.test.ts @@ -30,15 +30,67 @@ function user(overrides: Partial = {}): User { } function resetStore() { + useUserStore.getState().clearUser(); useUserStore.setState({ user: null, isLoading: false, error: null }); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + describe("user store", () => { beforeEach(() => { vi.restoreAllMocks(); resetStore(); }); + it("ignores an older user response after successful logout", async () => { + const pending = deferred(); + vi.stubGlobal("fetch", vi.fn() + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce({ ok: true })); + useUserStore.setState({ user: user() }); + const request = useUserStore.getState().fetchUser(); + await useUserStore.getState().logout(); + pending.resolve(Response.json({ user: user() })); + await request; + expect(useUserStore.getState()).toMatchObject({ user: null, error: null, isLoading: false }); + }); + + it("ignores a response whose body finishes after clearUser", async () => { + const body = deferred<{ user: User }>(); + const json = vi.fn(() => body.promise); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json })); + const request = useUserStore.getState().fetchUser(); + await vi.waitFor(() => expect(json).toHaveBeenCalled()); + useUserStore.getState().clearUser(); + body.resolve({ user: user() }); + await request; + expect(useUserStore.getState()).toMatchObject({ user: null, error: null, isLoading: false }); + }); + + it.each(["http", "network"])("an old %s failure cannot clear a newer request", async (failure) => { + const older = deferred(); + const newer = deferred(); + const fetchMock = vi.fn().mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + vi.stubGlobal("fetch", fetchMock); + const first = useUserStore.getState().fetchUser(); + useUserStore.getState().clearUser(); + const second = useUserStore.getState().fetchUser(); + if (failure === "http") older.resolve(new Response(null, { status: 401 })); + else older.reject(new Error("old network failure")); + await first; + expect(useUserStore.getState()).toMatchObject({ isLoading: true, error: null }); + const third = useUserStore.getState().fetchUser(); + expect(fetchMock).toHaveBeenCalledTimes(2); + newer.resolve(Response.json({ user: user({ id: "new-user" }) })); + await Promise.all([second, third]); + expect(useUserStore.getState()).toMatchObject({ user: { id: "new-user" }, isLoading: false, error: null }); + }); + it("fetches the authenticated user and deduplicates concurrent requests", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/ui/src/store/user-store.ts b/packages/ui/src/store/user-store.ts index ab8085a6..da53f8a8 100644 --- a/packages/ui/src/store/user-store.ts +++ b/packages/ui/src/store/user-store.ts @@ -54,6 +54,12 @@ interface UserState { // 防止并发请求的单例 Promise let fetchUserPromise: Promise | null = null; +let userRequestGeneration = 0; + +function invalidateUserRequest() { + userRequestGeneration += 1; + fetchUserPromise = null; +} export const useUserStore = create((set) => ({ user: null, @@ -63,26 +69,30 @@ export const useUserStore = create((set) => ({ fetchUser: async () => { // 防止并发请求 if (fetchUserPromise) return fetchUserPromise; + const generation = userRequestGeneration; set({ isLoading: true, error: null }); fetchUserPromise = (async () => { try { const response = await fetch("/api/auth/me", { cache: "no-store" }); + if (generation !== userRequestGeneration) return; if (!response.ok) { set({ user: null, error: `请求失败 (HTTP ${response.status})`, isLoading: false }); return; } const data = (await response.json().catch(() => ({}))) as { user?: User | null }; + if (generation !== userRequestGeneration) return; set({ user: data?.user ?? null, isLoading: false }); } catch (error) { + if (generation !== userRequestGeneration) return; set({ user: null, error: error instanceof Error ? error.message : "获取用户信息失败", isLoading: false, }); } finally { - fetchUserPromise = null; + if (generation === userRequestGeneration) fetchUserPromise = null; } })(); @@ -100,7 +110,8 @@ export const useUserStore = create((set) => ({ : `退出登录失败 (HTTP ${response.status})`; throw new Error(message); } - set({ user: null, error: null }); + invalidateUserRequest(); + set({ user: null, error: null, isLoading: false }); } catch (error) { const message = error instanceof Error ? error.message : "退出登录失败"; set({ error: message }); @@ -109,7 +120,8 @@ export const useUserStore = create((set) => ({ }, clearUser: () => { - set({ user: null, error: null }); + invalidateUserRequest(); + set({ user: null, error: null, isLoading: false }); }, updateAiAssistantEnabled: (enabled: boolean) => { From be3eceb8dc242264d76c967b43e3acb36f58098e Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:18:44 +0800 Subject: [PATCH 02/13] fix: keep moved preset rules active in their destination groups --- .../core/src/generator/module-rules.test.ts | 13 ++++ packages/core/src/generator/module-rules.ts | 4 +- .../sections/proxy-groups-categories.test.ts | 63 ++++++++++++++++++- .../sections/proxy-groups-categories.tsx | 31 +++++---- .../proxy-groups-custom-groups-panel.test.ts | 41 +++++++++++- .../proxy-groups-custom-groups-panel.tsx | 7 ++- .../ui/builtin-rule-move-lifecycle.test.ts | 51 +++++++++++++++ 7 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 test/regression/ui/builtin-rule-move-lifecycle.test.ts diff --git a/packages/core/src/generator/module-rules.test.ts b/packages/core/src/generator/module-rules.test.ts index 7ff37f2d..a0320e47 100644 --- a/packages/core/src/generator/module-rules.test.ts +++ b/packages/core/src/generator/module-rules.test.ts @@ -79,10 +79,23 @@ describe("module rule helpers", () => { }); it("detects rules moved to another module", () => { + expect(isModuleRuleMovedFrom("media", "youtube")).toBe(false); + expect(isModuleRuleMovedFrom("media", "youtube", { other: [null as never, { id: "hulu" }] })).toBe(false); expect(isModuleRuleMovedFrom("media", "youtube", { other: [{ id: "youtube" }] })).toBe(true); expect(isModuleRuleMovedFrom("media", "youtube", { media: [{ id: "youtube" }] })).toBe(false); expect(isModuleRuleMovedFrom("", "youtube", { other: [{ id: "youtube" }] })).toBe(false); expect(isModuleRuleMovedFrom("media", " ", { other: [{ id: "youtube" }] })).toBe(false); expect(isModuleRuleMovedFrom("media", "youtube", { other: "bad" as never })).toBe(false); }); + + it("preserves the preset label for incoming rules and keeps user additions custom", () => { + const incoming = { ...customRules[1], source: "preset" as const }; + const target = { ...proxyModule, rules: [] }; + expect(getEffectiveModuleRuleItems(target, { media: [incoming, customRules[0]] }) + .map(({ id, source }) => ({ id, source }))).toEqual([ + { id: "hulu", source: "preset" }, + { id: "netflix", source: "custom" }, + ]); + expect(getEffectiveModuleRules(target, { media: [incoming] })).toEqual([customRules[1]]); + }); }); diff --git a/packages/core/src/generator/module-rules.ts b/packages/core/src/generator/module-rules.ts index 9a29cefa..6c42bbd4 100644 --- a/packages/core/src/generator/module-rules.ts +++ b/packages/core/src/generator/module-rules.ts @@ -90,7 +90,7 @@ export function getModuleRuleById( export function getEffectiveModuleRuleItems( module: ProxyGroupModule, - ruleSetsByTarget?: Record, + ruleSetsByTarget?: Record, hiddenPresetRuleIds?: HiddenPresetRuleIds ): EffectiveModuleRule[] { const excluded = getExcludedModuleRuleIds(module.id, hiddenPresetRuleIds); @@ -107,7 +107,7 @@ export function getEffectiveModuleRuleItems( for (const rule of extraRules) { if (!rule?.id || seen.has(rule.id)) continue; seen.add(rule.id); - out.push({ ...rule, source: "custom" }); + out.push({ ...rule, source: rule.source ?? "custom" }); } return out; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts index 3d572bf8..363b33fd 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts @@ -96,6 +96,9 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ { id: "moved-rule", name: "Moved Rule", behavior: "domain", path: "geosite/moved-rule.mrs" }, ] }, { id: "fallback", name: "Fallback", category: "core", rules: [] }, + { id: "cn", name: "CN", category: "service", rules: [ + { id: "cn-ip", name: "CN IP", behavior: "ipcidr", path: "geoip/cn.mrs", noResolve: true }, + ] }, ], generateProxyGroups: vi.fn(() => []), })); @@ -142,6 +145,7 @@ vi.mock("./group-advanced-settings-dialog", () => ({ import { ProxyGroupsCategories } from "./proxy-groups-categories"; import { generateProxyGroups } from "@subboost/core/generator/proxy-groups"; +import { getEffectiveModuleRuleItems, isModuleRuleMovedFrom } from "@subboost/core/generator/module-rules"; function renderCategories(overrides: Record = {}) { stateMock.enabled = true; @@ -462,6 +466,63 @@ describe("ProxyGroupsCategories", () => { expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { sourceIds: ["source-a"] }); }); + it.each(["module", "custom"])("distinguishes moved and removed rules targeting a %s group", (kind) => { + mocks.store.hiddenProxyGroups = []; + const targetId = kind === "module" ? "fallback" : "custom-1"; + mocks.store.customProxyGroups = [{ id: "custom-1", name: "Custom", groupType: "select" }]; + mocks.store.builtinRuleEdits = { + "module:auto:moved-rule": { target: { kind, id: targetId } }, + }; + renderCategories({ 0: new Set(["core"]) }); + let card = mocks.captures.moduleCards[0]; + expect(isModuleRuleMovedFrom("auto", "moved-rule", card.ruleSetsByTarget)).toBe(true); + expect(card.ruleSetsByTarget[targetId].map((rule: any) => rule.id)).toContain("moved-rule"); + if (kind === "module") { + const targetCard = mocks.captures.moduleCards.find((item: any) => item.module.id === targetId); + expect(getEffectiveModuleRuleItems(targetCard.module, card.ruleSetsByTarget, card.hiddenPresetRuleIds) + .find((rule) => rule.id === "moved-rule")?.source).toBe("preset"); + } + + mocks.store.builtinRuleEdits["module:auto:moved-rule"].enabled = false; + renderCategories({ 0: new Set(["core"]) }); + card = mocks.captures.moduleCards[0]; + expect(card.hiddenPresetRuleIds.auto).toContain("moved-rule"); + expect(card.ruleSetsByTarget[targetId] ?? []).toEqual([]); + expect(isModuleRuleMovedFrom("auto", "moved-rule", card.ruleSetsByTarget)).toBe(false); + + mocks.store.builtinRuleEdits = {}; + renderCategories({ 0: new Set(["core"]) }); + card = mocks.captures.moduleCards[0]; + expect(getEffectiveModuleRuleItems(card.module, card.ruleSetsByTarget, card.hiddenPresetRuleIds) + .map((rule) => rule.id)).toContain("moved-rule"); + expect(card.ruleSetsByTarget[targetId] ?? []).toEqual([]); + }); + + it.each([true, false])("projects moved CN IP no-resolve=%s into the target group", (noResolve) => { + mocks.store.customRuleSets = []; + mocks.store.cnIpNoResolve = noResolve; + mocks.store.builtinRuleEdits = { + "module:cn:cn-ip": { target: { kind: "module", id: "auto" } }, + }; + renderCategories({ 0: new Set(["core"]) }); + expect(mocks.captures.moduleCards[0].ruleSetsByTarget.auto).toEqual([ + expect.objectContaining({ id: "cn-ip", noResolve }), + ]); + }); + + it.each([{ kind: "module", id: "auto" }, { kind: "custom", id: "missing" }])( + "keeps the source active when the saved target resolves to its default: %j", (target) => { + mocks.store.customRuleSets = []; + mocks.store.builtinRuleEdits = { "module:auto:moved-rule": { target } }; + renderCategories({ 0: new Set(["core"]) }); + const card = mocks.captures.moduleCards[0]; + expect(card.hiddenPresetRuleIds.auto ?? []).toEqual([]); + expect(isModuleRuleMovedFrom("auto", "moved-rule", card.ruleSetsByTarget)).toBe(false); + expect(getEffectiveModuleRuleItems(card.module, card.ruleSetsByTarget, card.hiddenPresetRuleIds) + .filter((rule) => rule.id === "moved-rule")).toHaveLength(1); + }, + ); + it("builds proxy-group previews from effective nodes", () => { mocks.store.nodes = [ { @@ -541,7 +602,7 @@ describe("ProxyGroupsCategories", () => { expect(setters[0]).toHaveBeenCalledWith(expect.any(Function)); expect((setters[0] as any).lastValue.has("core")).toBe(false); - categoryButtons[1].props.onClick(); + categoryButtons[2].props.onClick(); expect((setters[0] as any).lastValue.has("custom")).toBe(true); }); }); 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 9d86e7f9..1a469945 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 @@ -23,7 +23,7 @@ import { PROXY_GROUP_MODULES, generateProxyGroups, } from "@subboost/core/generator/proxy-groups"; -import type { HiddenPresetRuleIds } from "@subboost/core/generator/module-rules"; +import type { EffectiveModuleRuleSource, HiddenPresetRuleIds } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { resolveNodeNameFilter } from "@subboost/core/subscription/node-name-filter"; @@ -227,7 +227,7 @@ export function ProxyGroupsCategories() { moduleNames[proxyModule.id] = name; } - const pushRuleSetForTarget = (moduleId: string, rule: RuleSetDraft) => { + const pushRuleSetForTarget = (moduleId: string, rule: RuleSetDraft & { source?: EffectiveModuleRuleSource }) => { ruleSetsByTarget[moduleId] = [...(ruleSetsByTarget[moduleId] || []), rule]; }; const hidePresetRule = (moduleId: string, ruleId: string) => { @@ -242,13 +242,8 @@ export function ProxyGroupsCategories() { }); const moduleId = moduleNameToId.get(targetName); if (!moduleId) continue; - pushRuleSetForTarget(moduleId, { - id: ruleSet.id, - name: ruleSet.name, - behavior: ruleSet.behavior, - path: ruleSet.path, - ...(ruleSet.noResolve ? { noResolve: true } : {}), - }); + const { target: _target, ...rule } = ruleSet; + pushRuleSetForTarget(moduleId, rule); } for (const [key, edit] of Object.entries(builtinRuleEdits || {})) { @@ -267,17 +262,20 @@ export function ProxyGroupsCategories() { }) : ""; - if (edit.enabled === false) hidePresetRule(sourceModuleId, ruleId); + if (edit.enabled === false) { + hidePresetRule(sourceModuleId, ruleId); + continue; + } if (editTarget && editTarget !== defaultTarget) { hidePresetRule(sourceModuleId, ruleId); - const targetModuleId = moduleNameToId.get(editTarget); + const targetModuleId = moduleNameToId.get(editTarget) + || customProxyGroups.find((group) => group.name === editTarget)?.id; if (targetModuleId) { pushRuleSetForTarget(targetModuleId, { - id: sourceRule.id, - name: sourceRule.name, - behavior: sourceRule.behavior, - path: sourceRule.path, - ...(sourceRule.noResolve ? { noResolve: true } : {}), + ...sourceRule, + source: "preset", + noResolve: sourceModuleId === "cn" && ruleId === "cn-ip" + ? cnIpNoResolve : sourceRule.noResolve, }); } } @@ -286,6 +284,7 @@ export function ProxyGroupsCategories() { return { ruleSetsByTarget, hiddenPresetRuleIds }; }, [ builtinRuleEdits, + cnIpNoResolve, customRuleSets, customProxyGroups, resolveModuleDisplayName, diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts index fe8fd2f1..61cdd516 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts @@ -74,6 +74,9 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ rules: [{ id: "builtin-a", name: "Builtin A", behavior: "domain", path: "geosite/builtin-a.mrs" }], }, { id: "fallback", name: "Fallback", rules: [] }, + { id: "cn", name: "CN", rules: [ + { id: "cn-ip", name: "CN IP", behavior: "ipcidr", path: "geoip/cn.mrs", noResolve: true }, + ] }, ], })); vi.mock("@subboost/core/proxy-group-name", () => ({ @@ -342,17 +345,22 @@ describe("ProxyGroupsCustomGroupsPanel", () => { expect(mocks.captures.ruleRows).toEqual( expect.arrayContaining([ - expect.objectContaining({ name: "Builtin A", source: "preset", state: "moved" }), + expect.objectContaining({ name: "Builtin A", source: "preset", state: "active" }), ]), ); const builtinMove = mocks.captures.moveMenus.find((menu: any) => menu.ariaLabel === "移动 Builtin A 规则集"); builtinMove.onMove({ kind: "module", id: "fallback", name: "Fallback" }); - expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("auto", "builtin-a", { + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("custom-1", "builtin-a", { kind: "module", id: "fallback", name: "Fallback", }); + builtinMove.onMove({ kind: "module", id: "auto", name: "Auto" }); + expect(mocks.store.moveModuleRule).toHaveBeenLastCalledWith("custom-1", "builtin-a", { + kind: "module", id: "auto", name: "Auto", + }); + mocks.captures.buttons.find((button: any) => button["aria-label"] === "删除 Builtin A 规则集").onClick(); expect(mocks.store.removeModuleRule).toHaveBeenCalledWith("custom-1", "builtin-a"); }); @@ -378,6 +386,35 @@ describe("ProxyGroupsCustomGroupsPanel", () => { expect(mocks.captures.ruleRows).toEqual([]); }); + it.each([true, false])("keeps moved CN IP no-resolve=%s and hides disabled rules", (noResolve) => { + mocks.store.cnIpNoResolve = noResolve; + mocks.store.builtinRuleEdits = { + "module:cn:cn-ip": { target: { kind: "custom", id: "custom-1" } }, + }; + renderPanel({ 0: new Set(["custom-1"]) }); + const row = mocks.captures.ruleRows.find((item: any) => item.name === "CN IP"); + expect(row.state).toBe("active"); + expect(row.noResolve).toBe(noResolve); + + mocks.store.builtinRuleEdits["module:cn:cn-ip"].enabled = false; + renderPanel({ 0: new Set(["custom-1"]) }); + expect(mocks.captures.ruleRows.some((item: any) => item.name === "CN IP")).toBe(false); + }); + + it("follows custom target identity after rename and retargeting", () => { + mocks.store.builtinRuleEdits = { + "module:auto:builtin-a": { target: { kind: "custom", id: "custom-1" } }, + }; + mocks.store.customProxyGroups = [{ ...customGroup, name: "Renamed" }, targetGroup]; + renderPanel({ 0: new Set(["custom-1", "custom-2"]) }); + const getMenu = () => mocks.captures.moveMenus.find((menu: any) => menu.ariaLabel === "移动 Builtin A 规则集"); + expect(getMenu().currentTarget).toEqual({ kind: "custom", id: "custom-1", name: "Renamed" }); + mocks.store.builtinRuleEdits["module:auto:builtin-a"].target.id = "custom-2"; + renderPanel({ 0: new Set(["custom-1", "custom-2"]) }); + expect(getMenu().currentTarget.id).toBe("custom-2"); + expect(mocks.captures.ruleRows.filter((item: any) => item.name === "Builtin A")).toHaveLength(1); + }); + it("covers custom group edit controls and duplicate rename guard", () => { mocks.store.customProxyGroups = [customGroup, { ...targetGroup, name: "🧩 Target" }]; const { setters } = renderPanel({ 0: new Set(["custom-1"]), 3: "custom-1", 4: "🧩 Target", 5: "" }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx index 2e2ff2be..cb59c5f1 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx @@ -52,6 +52,7 @@ export function ProxyGroupsCustomGroupsPanel({ customRules = [], customRuleSets = [], builtinRuleEdits = {}, + cnIpNoResolve, customProxyGroups = [], addCustomProxyGroup, removeCustomProxyGroup, @@ -361,8 +362,8 @@ export function ProxyGroupsCustomGroupsPanel({ path={rule.path} source="preset" behavior={rule.behavior} - noResolve={rule.noResolve} - state="moved" + noResolve={sourceModule.id === "cn" && rule.id === "cn-ip" ? cnIpNoResolve : rule.noResolve} + state="active" actions={ <> { if (isRuleSetMoveTarget(target)) { - moveModuleRule(sourceModule.id, rule.id, target); + moveModuleRule(group.id, rule.id, target); } }} /> diff --git a/test/regression/ui/builtin-rule-move-lifecycle.test.ts b/test/regression/ui/builtin-rule-move-lifecycle.test.ts new file mode 100644 index 00000000..025d35bf --- /dev/null +++ b/test/regression/ui/builtin-rule-move-lifecycle.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { buildGeneratedRuleEntries } from "@subboost/core/generator/rules"; +import { initialState } from "@subboost/ui/store/config-store/definitions"; +import { createProxyGroupActions } from "@subboost/ui/store/config-store/actions/proxy-group-actions"; +import { createCustomActions } from "@subboost/ui/store/config-store/actions/custom-actions"; +import type { SetAndGenerateConfig, StoreState } from "@subboost/ui/store/config-store/store-types"; + +describe("builtin rule move lifecycle", () => { + it("keeps one rule through custom and builtin targets, rename, removal and recovery", () => { + let state = { + ...structuredClone(initialState), + enabledProxyGroups: ["select", "ai", "cn"], + customProxyGroups: [ + { id: "first", name: "First", emoji: "", groupType: "select" }, + { id: "second", name: "Second", emoji: "", groupType: "select" }, + ], + } as StoreState; + const update: SetAndGenerateConfig = (updater) => { + state = { ...state, ...updater(state) }; + }; + const actions = createProxyGroupActions(() => undefined, () => state, update); + const custom = createCustomActions(() => undefined, () => state, update); + const rule = (expectedCount = 1) => { + const matches = buildGeneratedRuleEntries({ ...state, enabledModules: state.enabledProxyGroups }) + .filter((entry) => entry.key === "module:ai:openai"); + expect(matches).toHaveLength(expectedCount); + return matches[0]; + }; + const originalTarget = rule().target; + + actions.moveModuleRule("ai", "openai", { kind: "custom", id: "first" }); + expect(rule()).toMatchObject({ target: "First", enabled: true }); + custom.updateCustomProxyGroup("first", { name: "Renamed" }); + expect(rule().target).toBe("Renamed"); + actions.moveModuleRule("first", "openai", { kind: "custom", id: "second" }); + expect(rule().target).toBe("Second"); + actions.moveModuleRule("second", "openai", { kind: "module", id: "cn" }); + expect(rule().target).not.toBe("Second"); + actions.removeModuleRule("cn", "openai"); + expect(rule(0)).toBeUndefined(); + actions.restoreModuleRule("ai", "openai"); + expect(rule().enabled).toBe(true); + actions.resetModuleRuleTarget("ai", "openai"); + expect(rule()).toMatchObject({ target: originalTarget, enabled: true }); + + actions.moveModuleRule("ai", "openai", { kind: "custom", id: "first" }); + actions.moveModuleRule("first", "openai", { kind: "module", id: "ai" }); + expect(rule()).toMatchObject({ target: originalTarget, enabled: true }); + expect(state.customRuleSets).toEqual([]); + }); +}); From 3caef30c4e48a0a937e02fccef8ab047c76efb6a Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:22:10 +0800 Subject: [PATCH 03/13] feat: improve subscription sharing and rule configuration --- local/package.json | 1 + package-lock.json | 12 ++++ package.json | 1 + packages/core/src/generator/chain.ts | 2 +- .../core/src/generator/proxy-groups.test.ts | 17 +++++ packages/core/src/generator/proxy-groups.ts | 10 +-- packages/core/src/proxy-group-advanced.ts | 13 ++++ .../rules/custom-rule-batch-import.test.ts | 49 ++++++++++++++ .../src/rules/custom-rule-batch-import.ts | 56 ++++++++++++---- .../src/rules/custom-rule-helpers.test.ts | 7 +- .../core/src/rules/manual-rule-set.test.ts | 13 ++++ packages/core/src/rules/rule-model.ts | 25 +++++++ packages/core/src/rules/rules-utils.test.ts | 10 +-- .../src/subscription/node-name-filter.test.ts | 24 +++++++ .../core/src/subscription/node-name-filter.ts | 30 ++++++++- .../src/templates/config-template.test.ts | 15 +++++ .../core/src/templates/config-template.ts | 3 + packages/core/src/types/config.ts | 1 + packages/core/src/types/template-config.ts | 1 + packages/ui/package.json | 1 + .../ui/src/components/ui/form-field.test.ts | 18 +++++ packages/ui/src/components/ui/form-field.tsx | 7 +- packages/ui/src/components/ui/popover.tsx | 1 + .../subscription-dashboard-surface.test.ts | 2 + .../subscription-dashboard-surface.tsx | 2 + .../sections/dialer-proxy-groups-section.tsx | 9 ++- .../group-advanced-settings-dialog.test.ts | 15 ++++- .../group-advanced-settings-dialog.tsx | 20 +++++- .../sections/node-management-section.test.ts | 7 ++ .../sections/node-management-section.tsx | 9 ++- .../auto-processing-dialog.test.ts | 32 ++++++++- .../auto-processing-dialog.tsx | 45 ++++++++++--- .../sections/proxy-group-rule-row.tsx | 2 +- .../sections/proxy-groups-categories.tsx | 9 ++- .../proxy-groups-custom-groups-panel.tsx | 4 +- ...y-groups-custom-rules-batch-dialog.test.ts | 11 ++++ ...proxy-groups-custom-rules-batch-dialog.tsx | 24 +++++-- .../proxy-groups-custom-rules.test.ts | 65 ++++++++++++++++++- .../sections/proxy-groups-custom-rules.tsx | 51 +++++++++++++-- .../sections/proxy-groups-module-card.test.ts | 1 + .../sections/proxy-groups-module-card.tsx | 24 ++----- .../proxy-groups-module-rules-help.tsx | 1 - .../proxy-groups-module-rules-panel.tsx | 4 +- .../home/subscription-link-dialog.test.ts | 3 + .../product/home/subscription-link-dialog.tsx | 2 + .../subscription/subscription-qr-code.tsx | 38 +++++++++++ packages/ui/src/styles/globals.css | 18 +++++ .../subscription-helper-edge-matrix.test.ts | 2 +- .../rule-import-and-ssrf-ip.test.ts | 6 +- ...dashboard-action-failure-and-dedup.test.ts | 2 + test/regression/ui/dashboard-surface.test.ts | 2 + 51 files changed, 630 insertions(+), 97 deletions(-) create mode 100644 packages/core/src/rules/custom-rule-batch-import.test.ts create mode 100644 packages/core/src/rules/manual-rule-set.test.ts create mode 100644 packages/ui/src/components/ui/form-field.test.ts create mode 100644 packages/ui/src/product/subscription/subscription-qr-code.tsx diff --git a/local/package.json b/local/package.json index dae9d4af..735fff1a 100644 --- a/local/package.json +++ b/local/package.json @@ -49,6 +49,7 @@ "next": "^16.3.4", "peggy": "^5.1.0", "prisma": "^7.9.0", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", diff --git a/package-lock.json b/package-lock.json index 9be1de10..aa18078d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "lucide-react": "^1.17.0", "next": "^16.3.4", "peggy": "^5.1.0", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", @@ -96,6 +97,7 @@ "next": "^16.3.4", "peggy": "^5.1.0", "prisma": "^7.9.0", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", @@ -8753,6 +8755,15 @@ ], "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10884,6 +10895,7 @@ "clsx": "^2.1.0", "lucide-react": "^1.17.0", "next": "^16.3.4", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", diff --git a/package.json b/package.json index 5a276d2e..eb835941 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", + "qrcode.react": "^4.2.0", "tailwind-merge": "^3.6.0", "zustand": "^5.0.14" }, diff --git a/packages/core/src/generator/chain.ts b/packages/core/src/generator/chain.ts index 4eb3487a..038ed325 100644 --- a/packages/core/src/generator/chain.ts +++ b/packages/core/src/generator/chain.ts @@ -44,7 +44,7 @@ export function generateDialerProxyGroups( name: group.group.name, groupType: group.group.type, proxies: group.proxies, - testUrl, + testUrl: group.group.type === "url-test" ? group.group.testUrl || testUrl : testUrl, testInterval, strategy: group.group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, extraFields: providerUse, diff --git a/packages/core/src/generator/proxy-groups.test.ts b/packages/core/src/generator/proxy-groups.test.ts index 07a881a0..d547c289 100644 --- a/packages/core/src/generator/proxy-groups.test.ts +++ b/packages/core/src/generator/proxy-groups.test.ts @@ -32,6 +32,23 @@ function customGroup(id: string, groupType: CustomProxyGroup["groupType"]): Cust } describe("proxy group generator", () => { + it("uses per-group test URLs only for automatic testing", () => { + const groups = generateProxyGroups({ + nodes: [node("A")], enabledModules: ["select", "auto", "ai"], + ruleProviderBaseUrl: "https://local.subboost.test/rules", testUrl: "https://local.subboost.test/default", testInterval: 60, + proxyGroupAdvanced: { auto: { testUrl: "https://local.subboost.test/auto" }, ai: { testUrl: "https://local.subboost.test/ignored" } }, + customProxyGroups: [ + { ...customGroup("url", "url-test"), advanced: { testUrl: "https://local.subboost.test/custom" } }, + { ...customGroup("filtered", "url-test"), memberSource: "filtered-nodes", advanced: { testUrl: "https://local.subboost.test/filtered" } }, + { ...customGroup("fallback", "fallback"), advanced: { testUrl: "https://local.subboost.test/ignored" } }, + ], + }); + expect(groups.find((group) => group.name === "⚡ 自动选择")?.url).toBe("https://local.subboost.test/auto"); + expect(groups.find((group) => group.name === "Custom url")?.url).toBe("https://local.subboost.test/custom"); + expect(groups.find((group) => group.name === "Custom filtered")?.url).toBe("https://local.subboost.test/filtered"); + expect(groups.find((group) => group.name === "Custom fallback")?.url).toBe("https://local.subboost.test/default"); + expect(groups.filter((group) => group.type === "select").every((group) => group.url === undefined)).toBe(true); + }); 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 167549bb..2f4afec5 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -230,13 +230,14 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr groupType: ProxyGroupGroupType, proxies: string[], strategy?: LoadBalanceStrategy, - extraFields: Record = providerUse + extraFields: Record = providerUse, + groupTestUrl?: string, ): ProxyGroup => buildTypedProxyGroup({ name, groupType, proxies, - testUrl, + testUrl: groupType === "url-test" ? groupTestUrl || testUrl : testUrl, testInterval, strategy, extraFields, @@ -292,6 +293,7 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr id: module.id, name: moduleName, }), + undefined, providerUse, advanced?.testUrl, )); break; @@ -372,7 +374,7 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr if (usesFilteredNodeMembers(customGroup)) { if (customGroup.groupType === "url-test" || customGroup.groupType === "fallback") { - return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, {}); + return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, {}, customGroup.advanced?.testUrl); } if (customGroup.groupType === "load-balance") { return createGeneratedProxyGroup( @@ -392,7 +394,7 @@ export function generateProxyGroups(options: ProxyGroupGenerateOptions): ProxyGr }; } if (customGroup.groupType === "url-test") { - return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames)); + return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, providerUse, customGroup.advanced?.testUrl); } if (customGroup.groupType === "fallback") { return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames)); diff --git a/packages/core/src/proxy-group-advanced.ts b/packages/core/src/proxy-group-advanced.ts index 1b2187e1..de627b5b 100644 --- a/packages/core/src/proxy-group-advanced.ts +++ b/packages/core/src/proxy-group-advanced.ts @@ -166,6 +166,17 @@ function normalizeMemberList(value: unknown): ProxyGroupMemberRef[] { return out; } +export function normalizeGroupTestUrl(value: unknown): string | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + try { + const url = new URL(value.trim()); + return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password + ? value.trim() : undefined; + } catch { + return undefined; + } +} + export function normalizeProxyGroupAdvancedConfig(value: unknown): ProxyGroupAdvancedConfig { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const item = value as Record; @@ -182,7 +193,9 @@ export function normalizeProxyGroupAdvancedConfig(value: unknown): ProxyGroupAdv const extraMembers = normalizeMemberList(item.extraMembers); const excludedMembers = normalizeMemberList(item.excludedMembers); const memberOrder = normalizeMemberList(item.memberOrder); + const testUrl = normalizeGroupTestUrl(item.testUrl); return { + ...(testUrl ? { testUrl } : {}), ...(sourceIds.length > 0 ? { sourceIds } : {}), ...(regions.length > 0 ? { regions } : {}), ...(includeRegex ? { includeRegex } : {}), diff --git a/packages/core/src/rules/custom-rule-batch-import.test.ts b/packages/core/src/rules/custom-rule-batch-import.test.ts new file mode 100644 index 00000000..79eb8ab9 --- /dev/null +++ b/packages/core/src/rules/custom-rule-batch-import.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { parseCustomRuleBatchImport, type ParseCustomRuleBatchImportOptions } from "./custom-rule-batch-import"; +import { CUSTOM_RULE_TYPES } from "./custom-rule-utils"; +import { allocateRuleSetId } from "./rule-model"; + +const url = "https://local.subboost.test/geosite/udemy.mrs"; +function parse(text: string, overrides: Partial = {}) { + return parseCustomRuleBatchImport({ text, defaultType: "RULE-SET", defaultTarget: "Proxy", defaultNoResolve: true, + targetOptions: ["Proxy", "Other", "DIRECT"], existingRules: [], ...overrides }); +} + +describe("batch import current selections", () => { + it.each(CUSTOM_RULE_TYPES)("inherits the selected %s type, target and no-resolve for plain values", (defaultType) => { + const result = parse("value", { defaultType }); + expect(result.canImport).toBe(true); + expect(result.rules).toEqual([{ id: "", type: defaultType, value: "value", target: "Proxy", noResolve: true }]); + }); + it("creates named providers for plain URLs and explicit RULE-SET rows, including empty target columns", () => { + const result = parse(`${url}\nRULE-SET,${url}?v=2,\nRULE-SET,${url}?v=3,Other,no-resolve`, { reservedRuleSetIds: ["udemy"] }); + expect(result.canImport).toBe(true); + expect(result.rules).toEqual([]); + expect(result.ruleSets.map((rule) => [rule.id, rule.name, rule.target])).toEqual([ + ["udemy-2", "udemy-2", "Proxy"], ["udemy-3", "udemy-3", "Proxy"], ["udemy-4", "udemy-4", "Other"], + ]); + expect(result.items.every((item) => item.rule?.type === "RULE-SET")).toBe(true); + expect(result.ruleSets.every((rule) => rule.noResolve)).toBe(true); + }); + it("preserves explicit types in a mixed batch and uses the selected target when omitted", () => { + const result = parse(`${url}\nDOMAIN,example.com,\nIP-CIDR,203.0.113.0/24,DIRECT,no-resolve`); + expect(result.canImport).toBe(true); + expect(result.readyCount).toBe(3); + expect(result.rules.map((rule) => [rule.type, rule.target])).toEqual([["DOMAIN", "Proxy"], ["IP-CIDR", "DIRECT"]]); + expect(result.ruleSets[0].id).toBe("udemy"); + }); + it("blocks duplicate providers and invalid targets without silently treating them as domains", () => { + expect(parse(`${url}\n${url}`)).toMatchObject({ canImport: false, duplicateCount: 1 }); + expect(parse(url, { existingRuleSets: [{ id: "udemy", name: "udemy", path: url, behavior: "domain", target: "Proxy", noResolve: true }] })) + .toMatchObject({ canImport: false, duplicateCount: 1 }); + expect(parse(url, { defaultTarget: "DIRECT" })).toMatchObject({ canImport: false, errorCount: 1 }); + expect(parse("RULE-SET,unknown")).toMatchObject({ canImport: false, errorCount: 1 }); + expect(parse(url, { ruleSetTargetOptions: ["Other"] })).toMatchObject({ canImport: false, errorCount: 1 }); + expect(parse("value", { defaultType: "DOMAIN", defaultTarget: "" })).toMatchObject({ canImport: false, errorCount: 1 }); + }); + it("allocates readable unique IDs and removes unsafe filename characters", () => { + expect(allocateRuleSetId("udemy", ["udemy", "udemy-2"])).toBe("udemy-3"); + expect(allocateRuleSetId("../udemy,one", [])).toBe("-udemy-one"); + expect(allocateRuleSetId("...", [])).toBe("ruleset"); + }); +}); diff --git a/packages/core/src/rules/custom-rule-batch-import.ts b/packages/core/src/rules/custom-rule-batch-import.ts index 5235961c..6969fe20 100644 --- a/packages/core/src/rules/custom-rule-batch-import.ts +++ b/packages/core/src/rules/custom-rule-batch-import.ts @@ -1,5 +1,8 @@ -import type { CustomRule } from "@subboost/core/types/config"; +import type { CustomRule, CustomRuleSet } from "@subboost/core/types/config"; import { isCustomRuleType } from "./custom-rule-utils"; +import { allocateRuleSetId, parseManualRuleSetUrl } from "./rule-model"; + +type ImportRule = Omit & { type: CustomRule["type"] | "RULE-SET" }; export type CustomRuleBatchImportPreviewStatus = | "ready" @@ -12,20 +15,24 @@ export type CustomRuleBatchImportPreviewItem = { raw: string; status: CustomRuleBatchImportPreviewStatus; message: string; - rule?: CustomRule; + rule?: ImportRule; }; export type ParseCustomRuleBatchImportOptions = { text: string; - defaultType: CustomRule["type"]; + defaultType: ImportRule["type"]; defaultTarget: string; defaultNoResolve: boolean; targetOptions: string[]; + ruleSetTargetOptions?: string[]; existingRules: CustomRule[]; + existingRuleSets?: CustomRuleSet[]; + reservedRuleSetIds?: string[]; }; export type CustomRuleBatchImportResult = { rules: CustomRule[]; + ruleSets: CustomRuleSet[]; items: CustomRuleBatchImportPreviewItem[]; readyCount: number; skippedCount: number; @@ -98,7 +105,7 @@ function getTargetKey(target: CustomRule["target"]): string { return `${target.kind}:${target.id.trim()}`; } -function getRuleKey(rule: Pick): string { +function getRuleKey(rule: Pick): string { return [ rule.type, rule.value.trim(), @@ -110,7 +117,7 @@ function getRuleKey(rule: Pick | string { +): Pick | string { if (parts.length === 0) return "规则为空"; const first = parts[0]?.trim() ?? ""; @@ -125,7 +132,7 @@ function buildRuleFromParts( }; } - if (!isCustomRuleType(first)) { + if (!isCustomRuleType(first) && first !== "RULE-SET") { return `未知规则类型:${first}`; } @@ -134,11 +141,11 @@ function buildRuleFromParts( } const value = parts[1]?.trim() ?? ""; - const target = parts.length >= 3 ? parts[2]?.trim() ?? "" : options.defaultTarget.trim(); + const target = parts[2]?.trim() || options.defaultTarget.trim(); const noResolve = parts.length >= 4 ? normalizeNoResolve(parts[3] ?? "") - : parts.length >= 3 + : parts[2]?.trim() ? false : Boolean(options.defaultNoResolve); @@ -163,9 +170,14 @@ export function parseCustomRuleBatchImport( .filter(Boolean), ); const existingKeys = new Set(options.existingRules.map((rule) => getRuleKey(rule))); + for (const rule of options.existingRuleSets ?? []) { + existingKeys.add(getRuleKey({ type: "RULE-SET", value: rule.path, target: rule.target, noResolve: rule.noResolve })); + } + const usedIds = new Set([...(options.reservedRuleSetIds ?? []), ...(options.existingRuleSets ?? []).map((rule) => rule.id)]); const batchKeys = new Set(); const items: CustomRuleBatchImportPreviewItem[] = []; const rules: CustomRule[] = []; + const ruleSets: CustomRuleSet[] = []; let skippedCount = 0; let errorCount = 0; let duplicateCount = 0; @@ -278,7 +290,21 @@ export function parseCustomRuleBatchImport( return; } - const rule: CustomRule = { + let ruleSet: CustomRuleSet | undefined; + if (draft.type === "RULE-SET") { + try { + if (["DIRECT", "REJECT"].includes(target)) throw new Error("请选择规则集使用的代理组"); + if (options.ruleSetTargetOptions && !options.ruleSetTargetOptions.includes(target)) throw new Error(`未知规则集目标:${target}`); + const metadata = parseManualRuleSetUrl(draft.value); + const id = allocateRuleSetId(metadata.name, usedIds); + ruleSet = { ...metadata, id, name: id, target, noResolve: Boolean(draft.noResolve) }; + } catch (error) { + errorCount += 1; + items.push({ lineNumber, raw: rawLine, status: "error", message: error instanceof Error ? error.message : "规则集链接无效" }); + return; + } + } + const rule: ImportRule = { id: "", type: draft.type, value: draft.value.trim(), @@ -299,7 +325,12 @@ export function parseCustomRuleBatchImport( } batchKeys.add(key); - rules.push(rule); + if (ruleSet) { + usedIds.add(ruleSet.id); + ruleSets.push(ruleSet); + } else if (rule.type !== "RULE-SET") { + rules.push({ ...rule, type: rule.type }); + } items.push({ lineNumber, raw: rawLine, @@ -311,11 +342,12 @@ export function parseCustomRuleBatchImport( return { rules, + ruleSets, items, - readyCount: rules.length, + readyCount: rules.length + ruleSets.length, skippedCount, errorCount, duplicateCount, - canImport: rules.length > 0 && errorCount === 0 && duplicateCount === 0, + canImport: rules.length + ruleSets.length > 0 && errorCount === 0 && duplicateCount === 0, }; } diff --git a/packages/core/src/rules/custom-rule-helpers.test.ts b/packages/core/src/rules/custom-rule-helpers.test.ts index c4fbce63..ebdc6dc7 100644 --- a/packages/core/src/rules/custom-rule-helpers.test.ts +++ b/packages/core/src/rules/custom-rule-helpers.test.ts @@ -186,9 +186,9 @@ describe("custom rule batch import", () => { existingRules, }); - expect(result.readyCount).toBe(4); + expect(result.readyCount).toBe(5); expect(result.skippedCount).toBe(3); - expect(result.errorCount).toBe(7); + expect(result.errorCount).toBe(6); expect(result.duplicateCount).toBe(2); expect(result.canImport).toBe(false); expect(result.items.map((item) => item.status)).toEqual([ @@ -199,7 +199,7 @@ describe("custom rule batch import", () => { "ready", "error", "error", - "error", + "ready", "error", "error", "error", @@ -211,6 +211,7 @@ describe("custom rule batch import", () => { ]); expect(result.rules).toEqual([ expect.objectContaining({ type: "DOMAIN-SUFFIX", value: "example.org", target: "PROXY", noResolve: true }), + expect.objectContaining({ type: "DOMAIN", value: "example.com", target: "PROXY", noResolve: true }), expect.objectContaining({ type: "DOMAIN", value: "batch.com", target: "PROXY", noResolve: false }), expect.objectContaining({ type: "DOMAIN-SUFFIX", value: "quoted,domain", target: "DIRECT", noResolve: false }), expect.objectContaining({ type: "DOMAIN", value: "a\"b.com", target: "PROXY", noResolve: false }), diff --git a/packages/core/src/rules/manual-rule-set.test.ts b/packages/core/src/rules/manual-rule-set.test.ts new file mode 100644 index 00000000..853e4c44 --- /dev/null +++ b/packages/core/src/rules/manual-rule-set.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { parseManualRuleSetUrl } from "./rule-model"; + +describe("manual rule set URL", () => { + it("takes the display name from the URL filename and preserves its query", () => { + const path = "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/meta/geo/geosite/udemy.mrs?download=1"; + expect(parseManualRuleSetUrl(` ${path} `)).toEqual({ name: "udemy", path, behavior: "domain" }); + expect(parseManualRuleSetUrl("https://rules.example/geoip/%E6%97%A5%E6%9C%AC.mrs")).toMatchObject({ name: "日本", behavior: "ipcidr" }); + }); + it.each(["broken", "ftp://rules.example/geosite/a.mrs", "https://a:b@rules.example/geosite/a.mrs", + "https://rules.example/geosite/%xx.mrs", "https://rules.example/geosite/a.txt", "https://rules.example/a.mrs"]) + ("rejects unsupported or ambiguous inputs: %s", (value) => expect(() => parseManualRuleSetUrl(value)).toThrow()); +}); diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts index 91a65a3e..fdcb6eca 100644 --- a/packages/core/src/rules/rule-model.ts +++ b/packages/core/src/rules/rule-model.ts @@ -54,6 +54,31 @@ export function buildRuleSetUrlFromPath(path: string, baseUrl: string): string { return `${trimTrailingSlashes(baseUrl)}/${normalizedPath}`; } +export function allocateRuleSetId(name: string, usedIds: Iterable): string { + const used = new Set(usedIds); + const base = name.replace(/[^\p{L}\p{N}_.@-]/gu, "-").replace(/^\.+/, "") || "ruleset"; + let id = base; + for (let suffix = 2; used.has(id); suffix += 1) id = `${base}-${suffix}`; + return id; +} + +/** Metadata for the existing MRS provider model, without changing the supplied URL. */ +export function parseManualRuleSetUrl(input: string): Pick { + const path = input.trim(); + let url: URL; + try { url = new URL(path); } catch { throw new Error("请输入有效的规则集 HTTP/HTTPS 链接"); } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) { + throw new Error("请输入有效的规则集 HTTP/HTTPS 链接"); + } + let pathname: string; + try { pathname = decodeURIComponent(url.pathname); } catch { throw new Error("规则集链接的文件名编码无效"); } + const filename = pathname.split("/").pop() ?? ""; + if (!/^.+\.mrs$/i.test(filename)) throw new Error("目前支持 .mrs 格式的规则集链接"); + const behavior = /\/geoip\//i.test(pathname) ? "ipcidr" : /\/geosite\//i.test(pathname) ? "domain" : null; + if (!behavior) throw new Error("链接需包含 geosite 或 geoip 路径,以便自动识别规则集类型"); + return { name: filename.slice(0, -4), path, behavior }; +} + function normalizeBehavior(value: unknown): RuleSetBehavior | null { if (value === "domain" || value === "ipcidr") return value; return null; diff --git a/packages/core/src/rules/rules-utils.test.ts b/packages/core/src/rules/rules-utils.test.ts index 01efd23e..fbb02e1f 100644 --- a/packages/core/src/rules/rules-utils.test.ts +++ b/packages/core/src/rules/rules-utils.test.ts @@ -124,11 +124,11 @@ describe("custom rule helpers", () => { }); expect(result.skippedCount).toBe(2); - expect(result.errorCount).toBe(5); + expect(result.errorCount).toBe(4); expect(result.canImport).toBe(false); expect(result.items.map((item) => item.message)).toContain("规则列数过多"); expect(result.items.map((item) => item.message)).toContain("规则值不能为空"); - expect(result.items.map((item) => item.message)).toContain("目标不能为空"); + expect(result.rules[0]).toMatchObject({ target: "DIRECT", value: "example.com" }); expect(result.items.map((item) => item.message)).toContain("未知目标:Unknown"); expect(result.items.map((item) => item.message)).toContain("不支持的尾列:bad-tail"); }); @@ -154,9 +154,9 @@ describe("custom rule helpers", () => { expect(result.canImport).toBe(false); expect(result.items[0]).toMatchObject({ status: "skipped", message: "rules 块标记" }); expect(result.items.slice(1).map((item) => item.message)).toEqual([ - "未知规则类型:RULE-SET", - "未知规则类型:RULE-SET", - "未知规则类型:RULE-SET", + "请输入有效的规则集 HTTP/HTTPS 链接", + "请输入有效的规则集 HTTP/HTTPS 链接", + "请输入有效的规则集 HTTP/HTTPS 链接", ]); expect(result.rules).toEqual([]); }); diff --git a/packages/core/src/subscription/node-name-filter.test.ts b/packages/core/src/subscription/node-name-filter.test.ts index 49e96fa5..7b9805e9 100644 --- a/packages/core/src/subscription/node-name-filter.test.ts +++ b/packages/core/src/subscription/node-name-filter.test.ts @@ -26,6 +26,30 @@ function unsafeNestedQuantifierPattern(): string { } describe("node name filter config", () => { + it("keeps independent switches and patterns across normalization and applies only enabled filters", () => { + const nodes = [node("日本"), node("日本 过期"), node("美国")]; + const config = { enabled: true, includeEnabled: false, excludeEnabled: false, includeRegexes: ["日本"], excludeRegexes: ["过期"] }; + expect(parseNodeNameFilterConfig(config)).toEqual(config); + expect(resolveNodeNameFilter(nodes, config).effectiveCount).toBe(3); + expect(resolveNodeNameFilter(nodes, { ...config, includeEnabled: true }).effectiveCount).toBe(2); + expect(resolveNodeNameFilter(nodes, { ...config, excludeEnabled: true }).effectiveCount).toBe(2); + expect(resolveNodeNameFilter(nodes, { ...config, includeEnabled: true, excludeEnabled: true }).effectiveCount).toBe(1); + expect(validateNodeNameFilterConfig({ ...config, includeEnabled: "yes" }).ok).toBe(false); + expect(validateNodeNameFilterConfig({ ...config, excludeEnabled: 1 }).ok).toBe(false); + }); + it("keeps any included original name, with exclusions taking precedence", () => { + const nodes = [node("renamed", "日本 01"), node("新加坡 02"), node("日本 过期"), node("美国 01")]; + const config = { enabled: true, includeRegexes: [" 日本|新加坡 ", "日本|新加坡"], excludeRegexes: ["过期"] }; + expect(validateNodeNameFilterConfig(config)).toEqual({ ok: true, config: { enabled: true, includeRegexes: ["日本|新加坡"], excludeRegexes: ["过期"] } }); + expect(parseNodeNameFilterConfig(config).includeRegexes).toEqual(["日本|新加坡"]); + expect(resolveNodeNameFilter(nodes, config).effectiveNodes).toEqual(nodes.slice(0, 2)); + expect(resolveNodeNameFilter(nodes, { ...config, excludeRegexes: [] }).effectiveCount).toBe(3); + expect(resolveNodeNameFilter(nodes, { ...config, includeRegexes: ["不存在"] }).effectiveCount).toBe(0); + expect(resolveNodeNameFilter(nodes, { ...config, enabled: false }).effectiveCount).toBe(4); + expect(validateNodeNameFilterConfig({ ...config, includeRegexes: ["["] })).toMatchObject({ + ok: false, errors: [{ field: "includeRegexes", code: "invalid_regex", line: 1 }], + }); + }); it("treats a missing config as disabled and disables an empty enabled config", () => { expect(parseNodeNameFilterConfig(undefined)).toEqual({ enabled: false, diff --git a/packages/core/src/subscription/node-name-filter.ts b/packages/core/src/subscription/node-name-filter.ts index 9d2defa6..cefd472f 100644 --- a/packages/core/src/subscription/node-name-filter.ts +++ b/packages/core/src/subscription/node-name-filter.ts @@ -7,6 +7,9 @@ export const NODE_NAME_FILTER_MAX_REGEX_LENGTH = 200; export type NodeNameFilterConfig = { enabled: boolean; + includeEnabled?: boolean; + excludeEnabled?: boolean; + includeRegexes?: string[]; excludeRegexes: string[]; }; @@ -27,6 +30,7 @@ export type NodeNameFilterValidationError = { code: NodeNameFilterValidationErrorCode; message: string; line?: number; + field?: "includeRegexes" | "excludeRegexes"; }; export type NodeNameFilterValidationResult = @@ -51,6 +55,7 @@ export type NodeNameFilterResult = { type ParsedNodeNameFilterConfig = { config: NodeNameFilterConfig; compiledRegexes: RegExp[]; + compiledIncludes: RegExp[]; }; export class NodeNameFilterConfigError extends Error { @@ -89,6 +94,7 @@ function inspectNodeNameFilterConfig( parsed: { config: defaultConfig(), compiledRegexes: [], + compiledIncludes: [], }, }; } @@ -101,7 +107,19 @@ function inspectNodeNameFilterConfig( } const errors: NodeNameFilterValidationError[] = []; + const includes = value.includeRegexes === undefined + ? undefined + : inspectNodeNameFilterConfig({ enabled: true, excludeRegexes: value.includeRegexes }); + if (includes && !includes.ok) { + return { ok: false, errors: includes.errors.map((error) => ({ ...error, field: "includeRegexes" })) }; + } + const includeRegexes = includes?.ok ? includes.parsed.config.excludeRegexes : []; const enabled = value.enabled === true; + for (const field of ["includeEnabled", "excludeEnabled"] as const) { + if (value[field] !== undefined && typeof value[field] !== "boolean") { + errors.push({ code: "invalid_config", message: `${field} 必须是布尔值` }); + } + } if (typeof value.enabled !== "boolean") { errors.push({ code: "invalid_config", message: "enabled 必须是布尔值" }); } @@ -185,10 +203,14 @@ function inspectNodeNameFilterConfig( ok: true, parsed: { config: { - enabled: enabled && excludeRegexes.length > 0, + enabled: enabled && (excludeRegexes.length > 0 || includeRegexes.length > 0), + ...(value.includeEnabled !== undefined ? { includeEnabled: value.includeEnabled === true } : {}), + ...(value.excludeEnabled !== undefined ? { excludeEnabled: value.excludeEnabled === true } : {}), + ...(includeRegexes.length > 0 ? { includeRegexes } : {}), excludeRegexes, }, - compiledRegexes, + compiledRegexes: value.excludeEnabled === false ? [] : compiledRegexes, + compiledIncludes: value.includeEnabled !== false && includes?.ok ? includes.parsed.compiledRegexes : [], }, }; } @@ -233,7 +255,9 @@ export function resolveNodeNameFilter( const excludedNodes: ParsedNode[] = []; for (const node of rawNodes) { const originName = getNodeOriginName(node); - if (parsed.parsed.compiledRegexes.some((regex) => regex.test(originName))) { + const { compiledIncludes, compiledRegexes } = parsed.parsed; + if ((compiledIncludes.length > 0 && !compiledIncludes.some((regex) => regex.test(originName))) || + compiledRegexes.some((regex) => regex.test(originName))) { excludedNodes.push(node); } else { effectiveNodes.push(node); diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index e1e0981e..fdc621a4 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -8,6 +8,21 @@ import { DEFAULT_LOAD_BALANCE_STRATEGY } from "@subboost/core/types/config"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; describe("validateSubBoostTemplateConfig", () => { + it("round-trips independent test URLs and rejects malformed dialer addresses", () => { + const testUrl = "https://local.subboost.test/probe"; + const dialer = { id: "relay", name: "Relay", type: "url-test" as const, relayNodes: ["A"], targetNodes: ["B"], testUrl }; + const result = validateSubBoostTemplateConfig(validConfig({ + proxyGroupAdvanced: { auto: { testUrl } }, + customProxyGroups: [{ id: "custom", name: "Custom", emoji: "", groupType: "url-test", advanced: { testUrl } }], + dialerProxyGroups: [dialer], + })); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.error); + expect(result.config.proxyGroupAdvanced?.auto.testUrl).toBe(testUrl); + expect(result.config.customProxyGroups[0].advanced?.testUrl).toBe(testUrl); + expect(result.config.dialerProxyGroups[0].testUrl).toBe(testUrl); + expect(validateSubBoostTemplateConfig(validConfig({dialerProxyGroups: [{...dialer, testUrl: "invalid"}]})).ok).toBe(false); + }); it("accepts a minimal v1 config template", () => { const result = validateSubBoostTemplateConfig(validConfig()); diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 85d26538..75ae709e 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -437,6 +437,8 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG if (!relayNodes.ok) return relayNodes; const targetNodes = parseStringArray(item.targetNodes, "dialerProxyGroups.targetNodes"); if (!targetNodes.ok) return targetNodes; + const groupTestUrl = item.testUrl === undefined ? undefined : parseHttpUrlString(item.testUrl, "dialerProxyGroups.testUrl"); + if (groupTestUrl && !groupTestUrl.ok) return groupTestUrl; const enabled = parseOptionalBoolean(item.enabled, "dialerProxyGroups.enabled"); if (!enabled.ok) return enabled; out.push({ @@ -448,6 +450,7 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG : {}), relayNodes: relayNodes.value, targetNodes: targetNodes.value, + ...(groupTestUrl?.ok ? { testUrl: groupTestUrl.value } : {}), ...(enabled.value !== undefined ? { enabled: enabled.value } : {}), }); } diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index a4c0316e..6f4ffacc 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -262,6 +262,7 @@ export function isProxyGroupGroupType(value: unknown): value is ProxyGroupGroupT } export interface ProxyGroupAdvancedConfig { + testUrl?: string; sourceIds?: string[]; regions?: NodeRegion[]; includeRegex?: string; diff --git a/packages/core/src/types/template-config.ts b/packages/core/src/types/template-config.ts index 81b278db..979d7d62 100644 --- a/packages/core/src/types/template-config.ts +++ b/packages/core/src/types/template-config.ts @@ -17,6 +17,7 @@ export interface DialerProxyGroup { relayNodes: string[]; // 用于中转的节点名称列表 type: ProxyGroupGroupType; // 组类型 strategy?: LoadBalanceStrategy; // 负载均衡策略,仅 type=load-balance 时生效 + testUrl?: string; targetNodes: string[]; // 使用此中转的落地节点名称列表 } diff --git a/packages/ui/package.json b/packages/ui/package.json index 2f6878ae..debbea71 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,6 +25,7 @@ "clsx": "^2.1.0", "lucide-react": "^1.17.0", "next": "^16.3.4", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-remove-scroll-bar": "^2.3.8", diff --git a/packages/ui/src/components/ui/form-field.test.ts b/packages/ui/src/components/ui/form-field.test.ts new file mode 100644 index 00000000..960a29a4 --- /dev/null +++ b/packages/ui/src/components/ui/form-field.test.ts @@ -0,0 +1,18 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { FormField } from "./form-field"; + +describe("FormField label actions", () => { + it("keeps the field label associated with its input and the toggle outside that label", () => { + const html = renderToStaticMarkup(React.createElement(FormField, { + id: "keep-regex", label: "保留正则", description: "每行一条。", descriptionPlacement: "before-control", + labelAction: React.createElement("button", { type: "button", role: "switch", "aria-label": "启用保留正则", "aria-checked": true }), + }, React.createElement("textarea"))); + expect(html).toContain('for="keep-regex"'); + expect(html).toContain('id="keep-regex"'); + expect(html).toContain('aria-describedby="keep-regex-description"'); + expect(html.indexOf("")).toBeLessThan(html.indexOf(') { function FormField({ id, label, + labelAction, description, descriptionPlacement = "after-control", error, @@ -53,12 +55,15 @@ function FormField({ "aria-invalid": error ? true : controlChild.props["aria-invalid"], "aria-required": required || controlChild.props["aria-required"] || undefined, }); - const labelElement = ( + const labelContent = ( ); + const labelElement = labelAction ? ( +
{labelContent}{labelAction}
+ ) : labelContent; const descriptionElement = description ? (

{description} diff --git a/packages/ui/src/components/ui/popover.tsx b/packages/ui/src/components/ui/popover.tsx index b1f76353..9721404f 100644 --- a/packages/ui/src/components/ui/popover.tsx +++ b/packages/ui/src/components/ui/popover.tsx @@ -71,6 +71,7 @@ function HelpPopover({ event.stopPropagation()} className={cn("h-6 w-6 rounded-md text-white/40 hover:text-white/80", className)} >

+ diff --git a/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.test.ts index 5d25b11d..e9be5f12 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.test.ts @@ -128,7 +128,7 @@ describe("NodeManagementAutoProcessingDialog", () => { it("renders the compact wording, counts, and matching display/origin names", () => { const { html } = renderDialog(); const helperText = - "每行一条,按节点导入时的原始名称匹配;命中节点会在生成配置时全局排除,关闭后恢复。"; + "每行一条,命中节点会被全局排除。"; expect(html).toContain("自动处理"); expect(html).toContain("启用"); @@ -169,11 +169,39 @@ describe("NodeManagementAutoProcessingDialog", () => { expect(props.onSave).toHaveBeenCalledWith({ enabled: true, + includeEnabled: true, + excludeEnabled: true, excludeRegexes: ["Alpha"], }); expect(props.onOpenChange).toHaveBeenCalledWith(false); }); + it("saves a keep-only filter and describes excluded nodes", () => { + const { html, props } = renderDialog({ config: { enabled: true, includeRegexes: [" Alpha ", "Alpha"], excludeRegexes: [] } }); + expect(html).toContain("导入 2 · 排除 1 · 保留 1"); + expect(html).toContain("排除的节点"); + findButton("保存")?.onClick(); + expect(props.onSave).toHaveBeenCalledWith({ enabled: true, includeEnabled: true, excludeEnabled: true, includeRegexes: ["Alpha"], excludeRegexes: [] }); + }); + + it("saves independent switch states while preserving both regex lists", () => { + const { props, html } = renderDialog({ config: { enabled: true, includeEnabled: false, excludeEnabled: false, + includeRegexes: ["Alpha"], excludeRegexes: ["Beta"] } }); + expect(html).toContain("导入 2 · 排除 0 · 保留 2"); + expect(mocks.formField?.labelAction).toBeDefined(); + findButton("保存")?.onClick(); + expect(props.onSave).toHaveBeenCalledWith({ enabled: true, includeEnabled: false, excludeEnabled: false, + includeRegexes: ["Alpha"], excludeRegexes: ["Beta"] }); + }); + + it.each(["[", "x".repeat(201), Array.from({length: 21}, (_, i) => `node-${i}`).join("\n")])("identifies invalid keep conditions", (pattern) => { + const { html, props } = renderDialog({ config: { enabled: true, includeRegexes: [pattern], excludeRegexes: [] } }); + expect(html).toContain("保留正则:"); + expect(findButton("保存")?.disabled).toBe(true); + findButton("保存")?.onClick(); + expect(props.onSave).not.toHaveBeenCalled(); + }); + it.each([ { pattern: "[", message: "第 2 行正则无效" }, { pattern: "(a+)+$", message: "第 2 行正则过于复杂" }, @@ -208,6 +236,8 @@ describe("NodeManagementAutoProcessingDialog", () => { providerSaveButton?.onClick(); expect(result.props.onSave).toHaveBeenCalledWith({ enabled: true, + includeEnabled: true, + excludeEnabled: true, excludeRegexes: [".*"], }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.tsx b/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.tsx index 6334a344..6f0bf995 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.tsx @@ -21,6 +21,7 @@ import { } from "@subboost/ui/components/ui/dialog"; import { FormField } from "@subboost/ui/components/ui/form-field"; import { SwitchField } from "@subboost/ui/components/ui/switch-field"; +import { Switch } from "@subboost/ui/components/ui/switch"; import { Textarea } from "@subboost/ui/components/ui/textarea"; type NodeManagementAutoProcessingDialogProps = { @@ -51,19 +52,29 @@ export function NodeManagementAutoProcessingDialog({ onSave, }: NodeManagementAutoProcessingDialogProps) { const configText = config.excludeRegexes.join("\n"); + const includeText = (config.includeRegexes ?? []).join("\n"); const [enabled, setEnabled] = React.useState(config.enabled); + const [includeEnabled, setIncludeEnabled] = React.useState(config.includeEnabled !== false); + const [excludeEnabled, setExcludeEnabled] = React.useState(config.excludeEnabled !== false); + const [includeRegexText, setIncludeRegexText] = React.useState(includeText); const [excludeRegexText, setExcludeRegexText] = React.useState(configText); React.useEffect(() => { if (!open) return; setEnabled(config.enabled); + setIncludeEnabled(config.includeEnabled !== false); + setExcludeEnabled(config.excludeEnabled !== false); + setIncludeRegexText(includeText); setExcludeRegexText(configText); - }, [config.enabled, configText, open]); + }, [config.enabled, config.includeEnabled, config.excludeEnabled, configText, includeText, open]); const plan = React.useMemo(() => { try { const parsedConfig = parseNodeNameFilterConfig({ enabled, + includeEnabled, + excludeEnabled, + includeRegexes: includeRegexText.split("\n"), excludeRegexes: excludeRegexText.split("\n"), }); return { @@ -81,10 +92,10 @@ export function NodeManagementAutoProcessingDialog({ : [{ code: "invalid_config" as const, message: "配置格式无效" }], }; } - }, [enabled, excludeRegexText, nodes]); + }, [enabled, includeEnabled, excludeEnabled, excludeRegexText, includeRegexText, nodes]); const validationMessage = - plan.errors.length > 0 ? plan.errors.map(formatValidationError).join(";") : undefined; + plan.errors.length > 0 ? plan.errors.map((error) => `${error.field === "includeRegexes" ? "保留正则:" : "排除正则:"}${formatValidationError(error)}`).join(";") : undefined; const blocksEmptyResult = Boolean( plan.config?.enabled && plan.result && @@ -106,7 +117,7 @@ export function NodeManagementAutoProcessingDialog({ 自动处理 - 设置按导入名称自动排除节点的规则 + 设置按导入名称自动保留或排除节点的规则 @@ -116,9 +127,25 @@ export function NodeManagementAutoProcessingDialog({ onCheckedChange={setEnabled} /> + } + description="每行一条,匹配任意一条即可保留。" + descriptionPlacement="before-control" + > +