From 903bfbdae292f55420f536dae2504e7e57d72c72 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:11:56 +0800 Subject: [PATCH 01/29] fix: stabilize local dev login flow --- local/package.json | 2 +- local/prisma.config.ts | 2 +- local/src/lib/env.ts | 9 +++++++++ local/src/lib/local-helpers.test.ts | 23 +++++++++++++++++++++++ local/src/lib/prisma.test.ts | 4 ++-- local/src/lib/prisma.ts | 2 +- 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/local/package.json b/local/package.json index 2478084..d30c308 100644 --- a/local/package.json +++ b/local/package.json @@ -9,7 +9,7 @@ }, "scripts": { "clean": "node scripts/clean-next.cjs", - "dev": "next dev --webpack -H 127.0.0.1 -p 3001", + "dev": "next dev --turbopack -H 127.0.0.1 -p 3001", "db:generate": "prisma generate --schema prisma/schema.prisma", "db:migrate": "prisma migrate deploy --schema prisma/schema.prisma", "prelint": "node scripts/ensure-root-node-modules.cjs", diff --git a/local/prisma.config.ts b/local/prisma.config.ts index 41fda37..a5cb151 100644 --- a/local/prisma.config.ts +++ b/local/prisma.config.ts @@ -2,7 +2,7 @@ import "dotenv/config"; import { defineConfig } from "prisma/config"; const datasourceUrl = - process.env.DATABASE_URL?.trim() || "postgresql://subboost:subboost@localhost:5432/subboost_local?schema=public"; + process.env.DATABASE_URL?.trim() || "postgresql://subboost_local_dev:subboost_local_dev_password@localhost:5432/subboost_local_dev?schema=public"; export default defineConfig({ schema: "prisma/schema.prisma", diff --git a/local/src/lib/env.ts b/local/src/lib/env.ts index d6d757c..7392cad 100644 --- a/local/src/lib/env.ts +++ b/local/src/lib/env.ts @@ -1,8 +1,17 @@ type RequiredEnvName = "DATABASE_URL" | "ENCRYPTION_KEY" | "JWT_SECRET" | "APP_URL"; +const LOCAL_DEVELOPMENT_DEFAULTS: Record = { + DATABASE_URL: + "postgresql://subboost_local_dev:subboost_local_dev_password@localhost:5432/subboost_local_dev?schema=public", + ENCRYPTION_KEY: "subboost-local-dev-encryption-key-0001", + JWT_SECRET: "subboost-local-dev-jwt-secret-00000001", + APP_URL: "http://127.0.0.1:3001", +}; + export function requireEnv(name: RequiredEnvName): string { const value = process.env[name]; if (typeof value !== "string" || value.trim().length === 0) { + if (process.env.NODE_ENV === "development") return LOCAL_DEVELOPMENT_DEFAULTS[name]; throw new Error(`${name} is required`); } return value.trim(); diff --git a/local/src/lib/local-helpers.test.ts b/local/src/lib/local-helpers.test.ts index f4c3431..085a0c4 100644 --- a/local/src/lib/local-helpers.test.ts +++ b/local/src/lib/local-helpers.test.ts @@ -61,8 +61,11 @@ import { const originalEnv = { APP_URL: process.env.APP_URL, + DATABASE_URL: process.env.DATABASE_URL, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, GITHUB_TOKEN: process.env.GITHUB_TOKEN, + JWT_SECRET: process.env.JWT_SECRET, + NODE_ENV: process.env.NODE_ENV, }; function restoreEnv() { @@ -92,6 +95,7 @@ describe("local lib helpers", () => { }); afterEach(() => { + vi.unstubAllEnvs(); restoreEnv(); }); @@ -108,6 +112,25 @@ describe("local lib helpers", () => { expect(() => requireEnv("APP_URL")).toThrow("APP_URL is required"); }); + it("uses local-only defaults during direct development startup", () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.APP_URL; + delete process.env.DATABASE_URL; + delete process.env.ENCRYPTION_KEY; + delete process.env.JWT_SECRET; + + expect(getAppUrl()).toBe("http://127.0.0.1:3001"); + expect(isHttpsAppUrl()).toBe(false); + expect(requireEnv("DATABASE_URL")).toBe( + "postgresql://subboost_local_dev:subboost_local_dev_password@localhost:5432/subboost_local_dev?schema=public", + ); + expect(requireEnv("ENCRYPTION_KEY")).toBe("subboost-local-dev-encryption-key-0001"); + expect(requireEnv("JWT_SECRET")).toBe("subboost-local-dev-jwt-secret-00000001"); + + vi.stubEnv("NODE_ENV", "production"); + expect(() => requireEnv("JWT_SECRET")).toThrow("JWT_SECRET is required"); + }); + it("loads current admin and setup state from the local session", async () => { mocks.readSession.mockResolvedValueOnce(null); await expect(getCurrentAdmin()).resolves.toBeNull(); diff --git a/local/src/lib/prisma.test.ts b/local/src/lib/prisma.test.ts index 6bb2d7a..ad81c8e 100644 --- a/local/src/lib/prisma.test.ts +++ b/local/src/lib/prisma.test.ts @@ -71,12 +71,12 @@ describe("local prisma singleton", () => { const mod = await loadPrismaModule({ DATABASE_URL: " ", NODE_ENV: "production" }); expect(mocks.PrismaPg).toHaveBeenCalledWith({ - connectionString: "postgresql://subboost:subboost@localhost:5432/subboost_local?schema=public", + connectionString: "postgresql://subboost_local_dev:subboost_local_dev_password@localhost:5432/subboost_local_dev?schema=public", }); expect(mocks.PrismaClient).toHaveBeenCalledWith({ adapter: expect.objectContaining({ adapterOptions: { - connectionString: "postgresql://subboost:subboost@localhost:5432/subboost_local?schema=public", + connectionString: "postgresql://subboost_local_dev:subboost_local_dev_password@localhost:5432/subboost_local_dev?schema=public", }, }), log: ["error"], diff --git a/local/src/lib/prisma.ts b/local/src/lib/prisma.ts index 8e65328..3d94302 100644 --- a/local/src/lib/prisma.ts +++ b/local/src/lib/prisma.ts @@ -7,7 +7,7 @@ const globalForPrisma = globalThis as unknown as { function createPrismaAdapter() { const connectionString = - process.env.DATABASE_URL?.trim() || "postgresql://subboost:subboost@localhost:5432/subboost_local?schema=public"; + process.env.DATABASE_URL?.trim() || "postgresql://subboost_local_dev:subboost_local_dev_password@localhost:5432/subboost_local_dev?schema=public"; return new PrismaPg({ connectionString }); } From 0832d47562eb11f5327620c3ea9be24efdc0efaa Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:09:04 +0800 Subject: [PATCH 02/29] fix: tolerate fake-ip DNS for source import --- local/src/lib/source-import.test.ts | 36 +++++++++++ local/src/lib/source-import.ts | 59 ++++++++++++++++++- .../src/subscription/ssrf-ip.test.ts | 10 +++- .../server-core/src/subscription/ssrf-ip.ts | 13 ++++ 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/local/src/lib/source-import.test.ts b/local/src/lib/source-import.test.ts index 44a547f..d6fd76a 100644 --- a/local/src/lib/source-import.test.ts +++ b/local/src/lib/source-import.test.ts @@ -88,6 +88,42 @@ describe("local source import transport", () => { expect(globalThis.fetch).not.toHaveBeenCalled(); }); + it("rechecks fake-ip DNS answers with DoH before fetching the subscription", async () => { + mocks.lookup.mockResolvedValueOnce([{ address: "198.18.3.6" }]); + vi.mocked(globalThis.fetch) + .mockResolvedValueOnce(response(JSON.stringify({ Answer: [{ data: "93.184.216.34" }] }), { status: 200 })) + .mockResolvedValueOnce(response(JSON.stringify({ Answer: [] }), { status: 200 })) + .mockResolvedValueOnce(response("ss://node", { status: 200 })); + + await expect(runTransport("https://api.dler.io/sub")).resolves.toMatchObject({ + ok: true, + content: "ss://node", + }); + expect(globalThis.fetch).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("https://dns.google/resolve?name=api.dler.io&type=A"), + expect.objectContaining({ method: "GET" }) + ); + expect(globalThis.fetch).toHaveBeenNthCalledWith( + 3, + "https://api.dler.io/sub", + expect.objectContaining({ method: "GET", redirect: "manual" }) + ); + }); + + it("keeps blocking fake-ip DNS answers when DoH confirms an unsafe target", async () => { + mocks.lookup.mockResolvedValueOnce([{ address: "198.18.3.6" }]); + vi.mocked(globalThis.fetch) + .mockResolvedValueOnce(response(JSON.stringify({ Answer: [{ data: "10.0.0.2" }] }), { status: 200 })) + .mockResolvedValueOnce(response(JSON.stringify({ Answer: [] }), { status: 200 })); + + await expect(runTransport("https://fake-ip-private.example/sub")).resolves.toMatchObject({ + ok: false, + publicReason: "禁止访问本机或内网地址", + }); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + it("rejects additional private IPv4 and IPv6 address ranges", async () => { const blockedUrls = [ "http://0.0.0.0/sub", diff --git a/local/src/lib/source-import.ts b/local/src/lib/source-import.ts index 608a67c..04c4572 100644 --- a/local/src/lib/source-import.ts +++ b/local/src/lib/source-import.ts @@ -1,4 +1,5 @@ import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; import { createSubscriptionImportErrorInfo, inferSubscriptionImportErrorCategory, @@ -12,13 +13,17 @@ import { type SourceImportTransportRequest, type SourceImportTransportResult, } from "@subboost/server-core/subscription"; -import { isPrivateOrReservedIp } from "@subboost/server-core/subscription/ssrf-ip"; +import { + isBenchmarkReservedIp, + isPrivateOrReservedIp, +} from "@subboost/server-core/subscription/ssrf-ip"; const DEFAULT_TIMEOUT_MS = 15000; const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; const USERINFO_TIMEOUT_MS = 8000; const USERINFO_MAX_BYTES = 256 * 1024; const MAX_REDIRECTS = 3; +const DOH_TIMEOUT_MS = 4000; function headersToRecord(headers: Headers): Record { const out: Record = {}; @@ -61,6 +66,51 @@ function normalizeHostname(hostname: string): string { return hostname.replace(/^\[|\]$/g, "").toLowerCase(); } +function normalizeLookupAddresses(addresses: string[]): string[] { + return addresses.filter((ip) => typeof ip === "string" && ip.trim()); +} + +function shouldRetryFakeIpWithDoh(addresses: string[]): boolean { + const unsafe = addresses.filter((ip) => isPrivateOrReservedIp(ip)); + return unsafe.length > 0 && unsafe.every((ip) => isBenchmarkReservedIp(ip)); +} + +async function resolveHostnameByDohDirect(hostname: string): Promise { + const out = new Set(); + + for (const type of ["A", "AAAA"]) { + const url = `https://dns.google/resolve?name=${encodeURIComponent(hostname)}&type=${type}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS); + let response: Response | null = null; + try { + response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/dns-json", + "User-Agent": "SubBoost Local DoH/1.0", + }, + redirect: "manual", + signal: controller.signal, + }); + } catch { + response = null; + } finally { + clearTimeout(timer); + } + if (!response?.ok) continue; + + const body = (await response.json().catch(() => null)) as { Answer?: Array<{ data?: unknown }> } | null; + if (!body || !Array.isArray(body.Answer)) continue; + for (const answer of body.Answer) { + const data = typeof answer?.data === "string" ? answer.data.trim() : ""; + if (data && isIP(data)) out.add(data); + } + } + + return Array.from(out); +} + async function validatePublicFetchTarget(url: string): Promise { let parsed: URL; try { @@ -87,7 +137,12 @@ async function validatePublicFetchTarget(url: string): Promise []); - if (records.some((record) => isPrivateOrReservedIp(record.address))) { + const addresses = normalizeLookupAddresses(records.map((record) => record.address)); + const finalAddresses = shouldRetryFakeIpWithDoh(addresses) + ? normalizeLookupAddresses(await resolveHostnameByDohDirect(hostname)).filter(Boolean) + : addresses; + const addressesToCheck = finalAddresses.length > 0 ? finalAddresses : addresses; + if (addressesToCheck.some((address) => isPrivateOrReservedIp(address))) { return toSecurityFailure("禁止访问本机或内网地址"); } diff --git a/packages/server-core/src/subscription/ssrf-ip.test.ts b/packages/server-core/src/subscription/ssrf-ip.test.ts index 6fd3361..ccd5123 100644 --- a/packages/server-core/src/subscription/ssrf-ip.test.ts +++ b/packages/server-core/src/subscription/ssrf-ip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isPrivateOrReservedIp } from "./ssrf-ip"; +import { isBenchmarkReservedIp, isPrivateOrReservedIp } from "./ssrf-ip"; describe("SSRF IP classification", () => { it("marks private, reserved, and documentation IPv4 ranges as unsafe", () => { @@ -47,4 +47,12 @@ describe("SSRF IP classification", () => { expect(isPrivateOrReservedIp("::ffff:8.8.8.8")).toBe(false); expect(isPrivateOrReservedIp("::ffff:808:808")).toBe(false); }); + + it("identifies the benchmark range commonly used by fake-ip DNS", () => { + expect(isBenchmarkReservedIp("198.18.0.1")).toBe(true); + expect(isBenchmarkReservedIp("198.19.255.254")).toBe(true); + expect(isBenchmarkReservedIp("198.20.0.1")).toBe(false); + expect(isBenchmarkReservedIp("10.0.0.1")).toBe(false); + expect(isBenchmarkReservedIp("example.test")).toBe(false); + }); }); diff --git a/packages/server-core/src/subscription/ssrf-ip.ts b/packages/server-core/src/subscription/ssrf-ip.ts index 6b9ae94..c234355 100644 --- a/packages/server-core/src/subscription/ssrf-ip.ts +++ b/packages/server-core/src/subscription/ssrf-ip.ts @@ -51,6 +51,13 @@ function isPrivateOrReservedIPv4(ip: string): boolean { return false; } +function isBenchmarkReservedIPv4(ip: string): boolean { + const ipInt = ipv4ToInt(ip); + const baseInt = ipv4ToInt("198.18.0.0"); + if (ipInt === null || baseInt === null) return false; + return ipv4InCidr(ipInt, baseInt, 15); +} + function ipv4FromMappedHexTail(value: string): string | null { const parts = value.split(":"); if (parts.length !== 2) return null; @@ -105,3 +112,9 @@ export function isPrivateOrReservedIp(hostname: string): boolean { if (version === 6) return isPrivateOrReservedIPv6(hostname); return false; } + +export function isBenchmarkReservedIp(hostname: string): boolean { + const version = isIP(hostname); + if (version === 4) return isBenchmarkReservedIPv4(hostname); + return false; +} From 4a0d317d174b52e9ab44b669132520a9520476a9 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:26:16 +0800 Subject: [PATCH 03/29] refactor: centralize fake-ip DNS recheck policy --- local/src/lib/source-import.ts | 19 ++++-------- .../src/subscription/ssrf-ip.test.ts | 29 ++++++++++++++++++- .../server-core/src/subscription/ssrf-ip.ts | 23 +++++++++++++++ 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/local/src/lib/source-import.ts b/local/src/lib/source-import.ts index 04c4572..096013c 100644 --- a/local/src/lib/source-import.ts +++ b/local/src/lib/source-import.ts @@ -14,8 +14,10 @@ import { type SourceImportTransportResult, } from "@subboost/server-core/subscription"; import { - isBenchmarkReservedIp, isPrivateOrReservedIp, + normalizeResolvedIpAddresses, + selectDnsAddressesAfterFakeIpRecheck, + shouldRecheckFakeIpDnsAnswers, } from "@subboost/server-core/subscription/ssrf-ip"; const DEFAULT_TIMEOUT_MS = 15000; @@ -66,15 +68,6 @@ function normalizeHostname(hostname: string): string { return hostname.replace(/^\[|\]$/g, "").toLowerCase(); } -function normalizeLookupAddresses(addresses: string[]): string[] { - return addresses.filter((ip) => typeof ip === "string" && ip.trim()); -} - -function shouldRetryFakeIpWithDoh(addresses: string[]): boolean { - const unsafe = addresses.filter((ip) => isPrivateOrReservedIp(ip)); - return unsafe.length > 0 && unsafe.every((ip) => isBenchmarkReservedIp(ip)); -} - async function resolveHostnameByDohDirect(hostname: string): Promise { const out = new Set(); @@ -137,9 +130,9 @@ async function validatePublicFetchTarget(url: string): Promise []); - const addresses = normalizeLookupAddresses(records.map((record) => record.address)); - const finalAddresses = shouldRetryFakeIpWithDoh(addresses) - ? normalizeLookupAddresses(await resolveHostnameByDohDirect(hostname)).filter(Boolean) + const addresses = normalizeResolvedIpAddresses(records.map((record) => record.address)); + const finalAddresses = shouldRecheckFakeIpDnsAnswers(addresses) + ? selectDnsAddressesAfterFakeIpRecheck(addresses, await resolveHostnameByDohDirect(hostname)) : addresses; const addressesToCheck = finalAddresses.length > 0 ? finalAddresses : addresses; if (addressesToCheck.some((address) => isPrivateOrReservedIp(address))) { diff --git a/packages/server-core/src/subscription/ssrf-ip.test.ts b/packages/server-core/src/subscription/ssrf-ip.test.ts index ccd5123..80bd1be 100644 --- a/packages/server-core/src/subscription/ssrf-ip.test.ts +++ b/packages/server-core/src/subscription/ssrf-ip.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { isBenchmarkReservedIp, isPrivateOrReservedIp } from "./ssrf-ip"; +import { + isBenchmarkReservedIp, + isPrivateOrReservedIp, + normalizeResolvedIpAddresses, + selectDnsAddressesAfterFakeIpRecheck, + shouldRecheckFakeIpDnsAnswers, +} from "./ssrf-ip"; describe("SSRF IP classification", () => { it("marks private, reserved, and documentation IPv4 ranges as unsafe", () => { @@ -55,4 +61,25 @@ describe("SSRF IP classification", () => { expect(isBenchmarkReservedIp("10.0.0.1")).toBe(false); expect(isBenchmarkReservedIp("example.test")).toBe(false); }); + + it("normalizes resolved addresses before validation", () => { + expect(normalizeResolvedIpAddresses([" 198.18.3.6 ", "", " ", "8.8.8.8"])).toEqual([ + "198.18.3.6", + "8.8.8.8", + ]); + }); + + it("rechecks only fake-ip DNS answers and selects DoH results conservatively", () => { + expect(shouldRecheckFakeIpDnsAnswers(["198.18.3.6"])).toBe(true); + expect(shouldRecheckFakeIpDnsAnswers(["198.18.3.6", "198.19.1.1"])).toBe(true); + expect(shouldRecheckFakeIpDnsAnswers(["198.18.3.6", "10.0.0.1"])).toBe(false); + expect(shouldRecheckFakeIpDnsAnswers(["8.8.8.8"])).toBe(false); + expect(shouldRecheckFakeIpDnsAnswers([])).toBe(false); + + expect(selectDnsAddressesAfterFakeIpRecheck(["198.18.3.6"], [" 93.184.216.34 "])).toEqual([ + "93.184.216.34", + ]); + expect(selectDnsAddressesAfterFakeIpRecheck(["198.18.3.6"], [])).toEqual(["198.18.3.6"]); + expect(selectDnsAddressesAfterFakeIpRecheck(["8.8.8.8"], ["1.1.1.1"])).toEqual(["8.8.8.8"]); + }); }); diff --git a/packages/server-core/src/subscription/ssrf-ip.ts b/packages/server-core/src/subscription/ssrf-ip.ts index c234355..03e7c14 100644 --- a/packages/server-core/src/subscription/ssrf-ip.ts +++ b/packages/server-core/src/subscription/ssrf-ip.ts @@ -118,3 +118,26 @@ export function isBenchmarkReservedIp(hostname: string): boolean { if (version === 4) return isBenchmarkReservedIPv4(hostname); return false; } + +export function normalizeResolvedIpAddresses(addresses: readonly string[]): string[] { + return addresses + .map((ip) => (typeof ip === "string" ? ip.trim() : "")) + .filter(Boolean); +} + +export function shouldRecheckFakeIpDnsAnswers(addresses: readonly string[]): boolean { + const normalized = normalizeResolvedIpAddresses(addresses); + const unsafe = normalized.filter((ip) => isPrivateOrReservedIp(ip)); + return unsafe.length > 0 && unsafe.every((ip) => isBenchmarkReservedIp(ip)); +} + +export function selectDnsAddressesAfterFakeIpRecheck( + systemAddresses: readonly string[], + recheckAddresses: readonly string[] +): string[] { + const normalizedSystemAddresses = normalizeResolvedIpAddresses(systemAddresses); + if (!shouldRecheckFakeIpDnsAnswers(normalizedSystemAddresses)) return normalizedSystemAddresses; + + const normalizedRecheckAddresses = normalizeResolvedIpAddresses(recheckAddresses); + return normalizedRecheckAddresses.length > 0 ? normalizedRecheckAddresses : normalizedSystemAddresses; +} From eafe684f7602267153c36a8154e304c7ddc62ce5 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:16:09 +0800 Subject: [PATCH 04/29] refactor: decouple rule ordering from routing targets --- packages/core/src/config/defaults.test.ts | 5 +- packages/core/src/config/defaults.ts | 5 +- packages/core/src/core-contracts.test.ts | 44 +- packages/core/src/generator/index.test.ts | 2 - packages/core/src/generator/index.ts | 26 +- .../core/src/generator/proxy-groups.test.ts | 12 +- packages/core/src/generator/proxy-groups.ts | 47 +- packages/core/src/generator/rules.test.ts | 74 ++- packages/core/src/generator/rules.ts | 114 ++-- .../src/rules/custom-routing-rule-sets.ts | 120 ++--- .../src/rules/custom-rule-helpers.test.ts | 87 ++- packages/core/src/rules/custom-rule-utils.ts | 24 +- packages/core/src/rules/rule-model.ts | 296 +++++++++++ packages/core/src/rules/rules-utils.test.ts | 26 +- .../src/subscription/config-utils.test.ts | 7 +- .../core/src/subscription/config-utils.ts | 44 +- .../templates/config-template.fields.test.ts | 125 ++--- .../templates/config-template.groups.test.ts | 99 ---- .../templates/config-template.test-helpers.ts | 10 +- .../src/templates/config-template.test.ts | 48 +- .../core/src/templates/config-template.ts | 148 ++---- packages/core/src/types/config.ts | 26 +- packages/core/src/types/template-config.ts | 20 +- .../proxy-groups-added-rule-sets.test.ts | 128 ++--- .../sections/proxy-groups-added-rule-sets.tsx | 135 ++--- .../sections/proxy-groups-categories.test.ts | 45 +- .../sections/proxy-groups-categories.tsx | 131 +++-- .../proxy-groups-custom-groups-panel.test.ts | 65 ++- .../proxy-groups-custom-groups-panel.tsx | 62 +-- .../sections/proxy-groups-module-card.test.ts | 5 +- .../sections/proxy-groups-module-card.tsx | 29 +- .../proxy-groups-module-rules-panel.test.ts | 15 +- .../proxy-groups-module-rules-panel.tsx | 47 +- .../proxy-groups-rules-library.test.ts | 82 ++- .../sections/proxy-groups-rules-library.tsx | 88 +-- .../sections/rules-management-section.test.ts | 82 ++- .../sections/rules-management-section.tsx | 69 +-- .../ui/src/product/home/home-surface.test.ts | 5 +- packages/ui/src/product/home/home-surface.tsx | 10 +- .../use-editing-subscription-loader.test.ts | 17 +- .../home/use-editing-subscription-loader.ts | 56 +- .../use-subscription-link.test-helpers.ts | 5 +- .../product/home/use-subscription-link.tsx | 24 +- .../src/product/preview/visual-graph.test.ts | 24 +- .../ui/src/product/preview/visual-graph.tsx | 84 +-- .../visual-graph/custom-rules-preview.test.ts | 4 +- packages/ui/src/store/config-store.test.ts | 2 +- packages/ui/src/store/config-store.ts | 4 +- .../actions/custom-actions.test.ts | 13 +- .../config-store/actions/custom-actions.ts | 163 +++--- .../actions/proxy-group-actions.test.ts | 169 +++--- .../actions/proxy-group-actions.ts | 502 +++++++++++------- .../actions/settings-actions.test.ts | 11 - .../config-store/actions/settings-actions.ts | 7 +- .../actions/template-actions.test.ts | 57 +- .../config-store/actions/template-actions.ts | 75 +-- .../store/config-store/auth-handoff.test.ts | 21 +- .../ui/src/store/config-store/auth-handoff.ts | 27 +- .../ui/src/store/config-store/definitions.ts | 43 +- .../src/store/config-store/generated-yaml.ts | 4 +- .../ui/src/store/config-store/persistence.ts | 2 +- .../template-library-surface.test.ts | 5 +- .../templates/template-library-surface.tsx | 10 +- 63 files changed, 1948 insertions(+), 1788 deletions(-) create mode 100644 packages/core/src/rules/rule-model.ts diff --git a/packages/core/src/config/defaults.test.ts b/packages/core/src/config/defaults.test.ts index c6f494a..c2aa4bd 100644 --- a/packages/core/src/config/defaults.test.ts +++ b/packages/core/src/config/defaults.test.ts @@ -71,11 +71,10 @@ describe("default config builders", () => { hiddenProxyGroups: [], customProxyGroups: [], filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, customRules: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, dialerProxyGroups: [], proxyGroupNameOverrides: {}, mixedPort: DEFAULT_SUBBOOST_CONFIG.mixedPort, diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index d5403fe..20bab73 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -88,11 +88,10 @@ export function buildDefaultSubBoostTemplateConfig(type: TemplateType): SubBoost hiddenProxyGroups: [], customProxyGroups: [], filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, customRules: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, cnIpNoResolve: DEFAULT_SUBBOOST_CONFIG.cnIpNoResolve, experimentalCnUseCnRuleSet: DEFAULT_SUBBOOST_CONFIG.experimentalCnUseCnRuleSet, dialerProxyGroups: [], diff --git a/packages/core/src/core-contracts.test.ts b/packages/core/src/core-contracts.test.ts index da222c6..e950a1b 100644 --- a/packages/core/src/core-contracts.test.ts +++ b/packages/core/src/core-contracts.test.ts @@ -189,38 +189,36 @@ describe("custom routing rule set contracts", () => { proxyGroupNameOverrides: { [moduleId]: "Renamed", }, - moduleRuleOverrides: { - [moduleId]: [ - { - id: "private", - name: "", - behavior: "ipcidr", - path: "https://rules.example.com/geo/geoip/private.mrs", - noResolve: true, - }, - ], - }, customProxyGroups: [ { id: "custom", name: "Custom", emoji: "C", groupType: "select", - rules: [ - { - id: "youtube", - name: "YouTube", - behavior: "domain", - url: "https://rules.example.com/geo/geosite/youtube.mrs?download=1", - }, - ], + }, + ], + customRuleSets: [ + { + id: "private", + name: "", + behavior: "ipcidr", + path: "https://rules.example.com/geo/geoip/private.mrs", + target: "🚀 Renamed", + noResolve: true, + }, + { + id: "youtube", + name: "YouTube", + behavior: "domain", + path: "https://rules.example.com/geo/geosite/youtube.mrs?download=1", + target: "Custom", }, ], }); expect(items).toContainEqual({ - key: `module:${moduleId}:private`, - source: { kind: "module", id: moduleId }, + key: "custom-rule-set:private", + source: { kind: "custom-rule-set", id: "private" }, id: "private", name: "private", behavior: "ipcidr", @@ -234,8 +232,8 @@ describe("custom routing rule set contracts", () => { noResolve: true, }); expect(items).toContainEqual({ - key: "custom:custom:youtube", - source: { kind: "custom", id: "custom" }, + key: "custom-rule-set:youtube", + source: { kind: "custom-rule-set", id: "youtube" }, id: "youtube", name: "YouTube", behavior: "domain", diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index e6875aa..55f30c9 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -396,7 +396,6 @@ describe("generateClashConfig", () => { name: "Custom", emoji: "C", groupType: "select", - rules: [{ id: "custom-rule", name: "Custom Rule", behavior: "domain", url: "https://rules.example.com/custom.mrs" }], }, ], proxyGroupOrder: ["filtered:fast", "custom:custom", "dialer:chain", "module:auto", "missing", "module:auto"], @@ -467,7 +466,6 @@ describe("generateClashConfig", () => { name: "" as never, emoji: "", groupType: "select", - rules: [], }, ], proxyGroupOrder: ["filtered:bad-filter", "custom:bad-custom", "module:auto"], diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 418a0b1..6934cc6 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -21,12 +21,18 @@ import { import { DEFAULT_DNS_CONFIG } from "./dns"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import type { ParsedNode } from "@subboost/core/types/node"; -import type { ClashConfig, UserConfig, TemplateType, ProxyGroup } from "@subboost/core/types/config"; -import type { CustomProxyGroup } from "@subboost/core/types/config"; -import type { DialerProxyGroup, ModuleRuleOverride } from "@subboost/core/types/template-config"; +import type { + BuiltinRuleEdits, + ClashConfig, + CustomProxyGroup, + CustomRuleSet, + ProxyGroup, + TemplateType, + UserConfig, +} from "@subboost/core/types/config"; +import type { DialerProxyGroup } from "@subboost/core/types/template-config"; import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; -import type { ModuleRuleExclusions } from "./module-rules"; import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "@subboost/core/mihomo/proxy-sanitizer"; import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-targets"; @@ -37,9 +43,9 @@ export interface GenerateOptions { userConfig?: Partial; dialerProxyGroups?: DialerProxyGroup[]; customProxyGroups?: CustomProxyGroup[]; + customRuleSets?: CustomRuleSet[]; filteredProxyGroups?: FilteredProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; proxyGroupOrder?: string[]; } @@ -175,9 +181,9 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { userConfig = {}, dialerProxyGroups = [], customProxyGroups = [], + customRuleSets = [], filteredProxyGroups = [], - moduleRuleOverrides, - moduleRuleExclusions, + builtinRuleEdits, proxyGroupNameOverrides, } = options; @@ -354,9 +360,9 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { testUrl: config.testUrl, testInterval: config.testInterval, customProxyGroups, + customRuleSets, filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + builtinRuleEdits, cnIpNoResolve: config.cnIpNoResolve, experimentalCnUseCnRuleSet: config.experimentalCnUseCnRuleSet, proxyGroupNameOverrides, diff --git a/packages/core/src/generator/proxy-groups.test.ts b/packages/core/src/generator/proxy-groups.test.ts index ef3ace4..d57d9f1 100644 --- a/packages/core/src/generator/proxy-groups.test.ts +++ b/packages/core/src/generator/proxy-groups.test.ts @@ -27,7 +27,6 @@ function customGroup(id: string, groupType: CustomProxyGroup["groupType"]): Cust emoji: "C", groupType, strategy: groupType === "load-balance" ? "round-robin" : undefined, - rules: [{ id: `${id}-rule`, name: `${id} rule`, behavior: "domain", url: `https://rules.example.com/${id}.mrs` }], }; } @@ -108,8 +107,17 @@ describe("proxy group generator", () => { testUrl: "https://probe.example.com/204", testInterval: 120, experimentalCnUseCnRuleSet: true, - moduleRuleExclusions: { cn: ["geolocation-cn"] }, + builtinRuleEdits: { "module:cn:geolocation-cn": { enabled: false } }, customProxyGroups: [customGroup("custom", "select")], + customRuleSets: [ + { + id: "custom-rule", + name: "Custom rule", + behavior: "domain", + path: "https://rules.example.com/custom.mrs", + target: "Custom custom", + }, + ], }); expect(providers["cn-ip"]).toMatchObject({ diff --git a/packages/core/src/generator/proxy-groups.ts b/packages/core/src/generator/proxy-groups.ts index 0c69bbe..c2c0dc9 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -5,7 +5,7 @@ import type { ParsedNode } from "@subboost/core/types/node"; import { DEFAULT_LOAD_BALANCE_STRATEGY } from "@subboost/core/types/config"; -import type { ProxyGroup, RuleProvider, CustomProxyGroup } from "@subboost/core/types/config"; +import type { BuiltinRuleEdits, CustomProxyGroup, CustomRuleSet, ProxyGroup, RuleProvider } from "@subboost/core/types/config"; import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; import { getFilteredProxyGroupProxies } from "@subboost/core/filtered-proxy-groups"; import { isSubscriptionInfoNodeName } from "@subboost/core/subscription/info-node-name"; @@ -14,11 +14,11 @@ import { PROXY_GROUP_MODULES, type ProxyGroupModule, type ProxyGroupRule } from import { EXPERIMENTAL_CN_RULE, generateRules, - getEffectiveModuleRules, resolveModuleName, resolveModuleNameFromModule, } from "./rules"; -import type { ModuleRuleExclusions } from "./module-rules"; +import { getModuleRuleOrderKey } from "./module-rules"; +import { buildRuleSetUrlFromPath } from "@subboost/core/rules/rule-model"; export { PROXY_GROUP_MODULES }; export type { ProxyGroupModule, ProxyGroupRule }; @@ -47,9 +47,9 @@ export interface GenerateOptions { testUrl: string; testInterval: number; customProxyGroups?: CustomProxyGroup[]; + customRuleSets?: CustomRuleSet[]; filteredProxyGroups?: FilteredProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + builtinRuleEdits?: BuiltinRuleEdits; // 国内服务 GeoIP 规则是否使用 no-resolve(默认 true;关闭可提升命中率但可能造成 DNS 泄露) cnIpNoResolve?: boolean; // 实验性:为“国内服务”额外启用 cn(geosite/cn.mrs),并将其规则后置(放到 global 之后) @@ -348,7 +348,12 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { * 生成规则提供者配置 */ export function generateRuleProviders(options: GenerateOptions): Record { - const { enabledModules, ruleProviderBaseUrl, customProxyGroups = [], moduleRuleOverrides, moduleRuleExclusions } = options; + const { + enabledModules, + ruleProviderBaseUrl, + customRuleSets = [], + builtinRuleEdits, + } = options; const providers: Record = {}; const enabledSet = new Set(enabledModules); @@ -356,8 +361,9 @@ export function generateRuleProviders(options: GenerateOptions): Record { const entries = buildGeneratedRuleEntries({ enabledModules: ["cn", "global", "final", "streaming-west"], customRules, - customProxyGroups: customGroups, + customRuleSets, experimentalCnUseCnRuleSet: true, cnIpNoResolve: false, proxyGroupNameOverrides: { @@ -94,7 +95,7 @@ describe("rule generator", () => { ruleProviderBaseUrl: "https://example.com/rules", experimentalCnUseCnRuleSet: false, }, - moduleRuleExclusions: { [proxyModule.id]: [rule.id] }, + builtinRuleEdits: { [`module:${proxyModule.id}:${rule.id}`]: { enabled: false } }, }); const rules = Array.isArray(config.rules) ? config.rules : []; const providers = config["rule-providers"] as Record | undefined; @@ -121,21 +122,11 @@ describe("rule generator", () => { const baseline = generateClashConfig(baseConfig); const deleted = generateClashConfig({ ...baseConfig, - moduleRuleExclusions: { "streaming-west": ["apple-tvplus"] }, + builtinRuleEdits: { "module:streaming-west:apple-tvplus": { enabled: false } }, }); const moved = generateClashConfig({ ...baseConfig, - moduleRuleExclusions: { "streaming-west": ["apple-tvplus"] }, - moduleRuleOverrides: { - google: [ - { - id: "apple-tvplus", - name: "Apple TV+", - behavior: "domain", - path: "geosite/apple-tvplus.mrs", - }, - ], - }, + builtinRuleEdits: { "module:streaming-west:apple-tvplus": { target: "🔍 谷歌服务" } }, }); const baselineRules = baseline.rules as string[]; const appleTvPlusIndex = baselineRules.indexOf("RULE-SET,apple-tvplus,📺 欧美流媒体"); @@ -159,25 +150,25 @@ describe("rule generator", () => { const editableOrder = normalizePersistedRuleOrder({ enabledModules: ["cn", "global", "final"], customRules, - customProxyGroups: customGroups, - ruleOrder: ["custom-group:media:media-rule", "missing", "custom-rule:domain-rule"], + customRuleSets, + ruleOrder: ["custom-rule-set:media-rule", "missing", "custom-rule:domain-rule"], }); const fullOrder = normalizePersistedRuleOrder({ enabledModules: ["cn", "global", "final"], customRules, - customProxyGroups: customGroups, + customRuleSets, ruleOrder: ["module:global:geolocation-!cn", "custom-rule:domain-rule", "special:match"], }); const applied = resolveAppliedRuleOrder({ enabledModules: ["cn", "global", "final"], customRules, - customProxyGroups: customGroups, + customRuleSets, ruleOrder: ["module:global:geolocation-!cn"], }); expect(hasFullRuleOrderKeys(["custom-rule:domain-rule"])).toBe(false); expect(hasFullRuleOrderKeys(["module:global:geolocation-!cn"])).toBe(true); - expect(editableOrder).toEqual(["custom-group:media:media-rule", "custom-rule:domain-rule", "custom-rule:ip-rule"]); + expect(editableOrder).toEqual(["custom-rule-set:media-rule", "custom-rule:domain-rule", "custom-rule:ip-rule"]); expect(fullOrder).toEqual(["module:global:geolocation-!cn", "custom-rule:domain-rule"]); expect(applied).toContain("module:global:geolocation-!cn"); expect(applied.indexOf("custom-rule:domain-rule")).toBeLessThan(applied.indexOf("module:global:geolocation-!cn")); @@ -192,7 +183,7 @@ describe("rule generator", () => { const options = { enabledModules: PROXY_GROUP_MODULES.map((proxyModule) => proxyModule.id), customRules: [], - customProxyGroups: [], + customRuleSets: [], fallbackPolicyTarget: "DIRECT", }; const baselineOrder = buildGeneratedRuleEntries(options) @@ -203,18 +194,17 @@ describe("rule generator", () => { for (const rule of proxyModule.rules) { const sourceKey = `module:${proxyModule.id}:${rule.id}`; const targetModuleId = proxyModule.id === "google" ? "ai" : "google"; - const movedKey = `module:${targetModuleId}:${rule.id}`; const baselineIndex = baselineOrder.indexOf(sourceKey); expect(baselineIndex).toBeGreaterThanOrEqual(0); const afterDeleteOrder = normalizePersistedRuleOrder({ ...options, - moduleRuleExclusions: { [proxyModule.id]: [rule.id] }, + builtinRuleEdits: { [sourceKey]: { enabled: false } }, ruleOrder: baselineOrder, }); const afterDeleteApplied = resolveAppliedRuleOrder({ ...options, - moduleRuleExclusions: { [proxyModule.id]: [rule.id] }, + builtinRuleEdits: { [sourceKey]: { enabled: false } }, ruleOrder: afterDeleteOrder, }); const afterRestoreApplied = resolveAppliedRuleOrder({ @@ -223,14 +213,12 @@ describe("rule generator", () => { }); const afterMoveOrder = normalizePersistedRuleOrder({ ...options, - moduleRuleExclusions: { [proxyModule.id]: [rule.id] }, - moduleRuleOverrides: { [targetModuleId]: [rule] }, + builtinRuleEdits: { [sourceKey]: { target: resolveModuleName(targetModuleId) } }, ruleOrder: baselineOrder, }); const afterMoveApplied = resolveAppliedRuleOrder({ ...options, - moduleRuleExclusions: { [proxyModule.id]: [rule.id] }, - moduleRuleOverrides: { [targetModuleId]: [rule] }, + builtinRuleEdits: { [sourceKey]: { target: resolveModuleName(targetModuleId) } }, ruleOrder: afterMoveOrder, }); @@ -238,7 +226,7 @@ describe("rule generator", () => { expect(afterDeleteApplied).not.toContain(sourceKey); expect(afterRestoreApplied.indexOf(sourceKey)).toBe(baselineIndex); expect(afterMoveOrder).toContain(sourceKey); - expect(afterMoveApplied.indexOf(movedKey)).toBe(baselineIndex); + expect(afterMoveApplied.indexOf(sourceKey)).toBe(baselineIndex); } } }); @@ -247,7 +235,7 @@ describe("rule generator", () => { const options = { enabledModules: ["ad", "private", "global", "final"], customRules, - customProxyGroups: [], + customRuleSets: [], fallbackPolicyTarget: "DIRECT", }; const entries = buildGeneratedRuleEntries(options); @@ -276,13 +264,13 @@ describe("rule generator", () => { noResolve: true, }, ], - customProxyGroups: [ + customRuleSets: [ { - id: "plain", - name: "Plain", - emoji: "P", - groupType: "select", - rules: [{ id: "plain-rule", name: "Plain Rule", behavior: "domain", url: "https://rules.example.com/plain.mrs" }], + id: "plain-rule", + name: "Plain Rule", + behavior: "domain", + path: "https://rules.example.com/plain.mrs", + target: "Plain", }, ], fallbackPolicyTarget: "DIRECT", @@ -299,7 +287,7 @@ describe("rule generator", () => { normalizePersistedRuleOrder({ enabledModules: ["streaming-west"], customRules: [], - customProxyGroups: [], + customRuleSets: [], ruleOrder: ["special:apple-tvplus", "module:streaming-west:apple-tvplus"], }) ).toEqual(["module:streaming-west:apple-tvplus"]); diff --git a/packages/core/src/generator/rules.ts b/packages/core/src/generator/rules.ts index 34bbf45..527e11d 100644 --- a/packages/core/src/generator/rules.ts +++ b/packages/core/src/generator/rules.ts @@ -1,13 +1,14 @@ -import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; -import { getCustomGroupRuleOrderKey, getCustomRuleOrderKey } from "@subboost/core/rules/custom-rule-utils"; +import type { BuiltinRuleEdits, CustomRule, CustomRuleSet } from "@subboost/core/types/config"; +import { getCustomRuleOrderKey } from "@subboost/core/rules/custom-rule-utils"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { PROXY_GROUP_MODULES, type ProxyGroupModule, type ProxyGroupRule } from "./proxy-group-modules"; -import { getEffectiveModuleRules, getModuleRuleOrderKey, type ModuleRuleExclusions } from "./module-rules"; +import { getEffectiveModuleRules, getModuleRuleOrderKey } from "./module-rules"; import { createPolicyTargetResolver } from "./policy-targets"; type CustomRuleLike = Pick; +type CustomRuleSetLike = Pick; -export type GeneratedRuleEntryKind = "module" | "custom-rule" | "custom-group-rule" | "special"; +export type GeneratedRuleEntryKind = "module" | "custom-rule" | "custom-rule-set" | "special"; export interface GeneratedRuleEntry { key: string; @@ -18,14 +19,14 @@ export interface GeneratedRuleEntry { target: string; noResolve: boolean; editable: boolean; + enabled: boolean; } export interface RulesGenerateOptions { enabledModules: string[]; customRules: CustomRuleLike[]; - customProxyGroups?: CustomProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + customRuleSets?: CustomRuleSetLike[]; + builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; experimentalCnUseCnRuleSet?: boolean; cnIpNoResolve?: boolean; @@ -107,11 +108,15 @@ const PRESET_MODULE_RULE_ORDER_KEY_SET = new Set(PRESET_MODULE_RULE_ORDER_KEYS); function buildModuleRuleEntry( module: ProxyGroupModule, rule: ProxyGroupRule, + builtinRuleEdits?: BuiltinRuleEdits, proxyGroupNameOverrides?: Record, cnIpNoResolve?: boolean, resolvePolicyTarget: (target: string) => string = (target) => target ): GeneratedRuleEntry { - const target = resolvePolicyTarget(resolveModuleNameFromModule(module, proxyGroupNameOverrides)); + const key = getModuleRuleOrderKey(module.id, rule.id); + const edit = builtinRuleEdits?.[key]; + const defaultTarget = resolveModuleNameFromModule(module, proxyGroupNameOverrides); + const target = resolvePolicyTarget(edit?.target || defaultTarget); const noResolve = module.id === "cn" && rule.id === "cn-ip" && typeof cnIpNoResolve === "boolean" ? cnIpNoResolve @@ -120,14 +125,15 @@ function buildModuleRuleEntry( if (noResolve) text += ",no-resolve"; return { - key: getModuleRuleOrderKey(module.id, rule.id), + key, text, kind: "module", - sourceLabel: target, + sourceLabel: defaultTarget, summary: rule.name, target, noResolve, - editable: false, + editable: true, + enabled: edit?.enabled !== false, }; } @@ -149,42 +155,41 @@ function buildCustomRuleEntry( target, noResolve, editable: true, + enabled: true, }; } -function buildCustomGroupRuleEntry( - group: CustomProxyGroup, - rule: CustomProxyGroup["rules"][number], +function buildCustomRuleSetEntry( + ruleSet: CustomRuleSetLike, resolvePolicyTarget: (target: string) => string = (target) => target ): GeneratedRuleEntry { - const noResolve = Boolean(rule.noResolve); - const target = resolvePolicyTarget(group.name); - let text = `RULE-SET,${rule.id},${target}`; + const noResolve = Boolean(ruleSet.noResolve); + const target = resolvePolicyTarget(ruleSet.target); + let text = `RULE-SET,${ruleSet.id},${target}`; if (noResolve) text += ",no-resolve"; return { - key: getCustomGroupRuleOrderKey(group.id, rule.id), + key: getCustomRuleSetOrderKey(ruleSet.id), text, - kind: "custom-group-rule", - sourceLabel: `自定义分组 · ${group.name}`, - summary: rule.name, + kind: "custom-rule-set", + sourceLabel: "自定义规则集", + summary: ruleSet.name, target, noResolve, editable: true, + enabled: true, }; } function buildOrderedEditableEntries( customRules: CustomRuleLike[], - customProxyGroups: CustomProxyGroup[], + customRuleSets: CustomRuleSetLike[], ruleOrder?: string[], resolvePolicyTarget: (target: string) => string = (target) => target ): GeneratedRuleEntry[] { const defaultEntries: GeneratedRuleEntry[] = [ ...customRules.map((rule) => buildCustomRuleEntry(rule, resolvePolicyTarget)), - ...customProxyGroups.flatMap((group) => - group.rules.map((rule) => buildCustomGroupRuleEntry(group, rule, resolvePolicyTarget)) - ), + ...customRuleSets.map((ruleSet) => buildCustomRuleSetEntry(ruleSet, resolvePolicyTarget)), ]; const editableKeys = defaultEntries.map((entry) => entry.key); const normalizedEditableOrder = normalizeEditableRuleOrderKeys(ruleOrder, editableKeys); @@ -208,9 +213,14 @@ function buildSpecialRuleEntry( target, noResolve: false, editable: false, + enabled: true, }; } +export function getCustomRuleSetOrderKey(ruleSetId: string): string { + return `custom-rule-set:${ruleSetId}`; +} + function normalizeRuleOrderInput(ruleOrder: string[] | undefined): string[] { if (!Array.isArray(ruleOrder)) return []; const next: string[] = []; @@ -264,6 +274,12 @@ function applyEditableOnlyOrder(editableRuleKeys: string[], allRuleKeys: string[ }); } +function getLegacyEditableRuleKeys(entries: GeneratedRuleEntry[]): string[] { + return entries + .filter((entry) => entry.kind === "custom-rule" || entry.kind === "custom-rule-set") + .map((entry) => entry.key); +} + function usesFullRuleOrder(ruleOrder: string[] | undefined, editableRuleKeys: string[], allRuleKeys: string[]): boolean { const allSet = new Set([...allRuleKeys, ...PRESET_MODULE_RULE_ORDER_KEYS]); const editableSet = new Set(editableRuleKeys); @@ -308,9 +324,8 @@ function buildCanonicalRuleEntries(options: Omit(); let insertedEditableEntries = false; @@ -331,7 +346,7 @@ function buildCanonicalRuleEntries(options: Omit { - const entry = buildModuleRuleEntry(module, rule, proxyGroupNameOverrides, cnIpNoResolve, resolvePolicyTarget); + const entry = buildModuleRuleEntry(module, rule, builtinRuleEdits, proxyGroupNameOverrides, cnIpNoResolve, resolvePolicyTarget); if (emittedRuleKeys.has(entry.key)) return; emittedRuleKeys.add(entry.key); entries.push(entry); @@ -342,8 +357,7 @@ function buildCanonicalRuleEntries(options: Omit item.id === "streaming-west"); if (!streamingWestModule) return; - const appleTvPlusRule = getEffectiveModuleRules(streamingWestModule, moduleRuleOverrides, moduleRuleExclusions) - .find((rule) => rule.id === "apple-tvplus"); + const appleTvPlusRule = streamingWestModule.rules.find((rule) => rule.id === "apple-tvplus"); if (!appleTvPlusRule) return; // Keep the existing generated order without turning Apple TV+ into a special system rule. @@ -367,8 +381,7 @@ function buildCanonicalRuleEntries(options: Omit item.id === moduleId); if (!ruleModule) continue; - const effectiveRules = getEffectiveModuleRules(ruleModule, moduleRuleOverrides, moduleRuleExclusions); - for (const rule of effectiveRules) { + for (const rule of ruleModule.rules) { pushModuleRuleEntry(ruleModule, rule); } } @@ -378,8 +391,7 @@ function buildCanonicalRuleEntries(options: Omit entry.key); - const editableRuleKeys = preMatchEntries.filter((entry) => entry.editable).map((entry) => entry.key); + const editableRuleKeys = getLegacyEditableRuleKeys(preMatchEntries); const cleaned = normalizeRuleOrderInput(options.ruleOrder); if (cleaned.length === 0) return []; @@ -434,9 +445,8 @@ export function resolveAppliedRuleOrder(options: RulesGenerateOptions): string[] const { preMatchEntries } = buildCanonicalRuleEntries({ enabledModules: options.enabledModules, customRules: options.customRules, - customProxyGroups: options.customProxyGroups, - moduleRuleOverrides: options.moduleRuleOverrides, - moduleRuleExclusions: options.moduleRuleExclusions, + customRuleSets: options.customRuleSets, + builtinRuleEdits: options.builtinRuleEdits, proxyGroupNameOverrides: options.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: options.experimentalCnUseCnRuleSet, cnIpNoResolve: options.cnIpNoResolve, @@ -445,15 +455,16 @@ export function resolveAppliedRuleOrder(options: RulesGenerateOptions): string[] }); const allRuleKeys = preMatchEntries.map((entry) => entry.key); - const editableRuleKeys = preMatchEntries.filter((entry) => entry.editable).map((entry) => entry.key); + const activeRuleKeys = preMatchEntries.filter((entry) => entry.enabled !== false).map((entry) => entry.key); + const editableRuleKeys = getLegacyEditableRuleKeys(preMatchEntries); const persistedOrder = normalizePersistedRuleOrder(options); if (persistedOrder.length === 0) { - return allRuleKeys; + return activeRuleKeys; } if (usesFullRuleOrder(persistedOrder, editableRuleKeys, allRuleKeys)) { - const activeRuleKeySet = new Set(allRuleKeys); + const activeRuleKeySet = new Set(activeRuleKeys); const next = persistedOrder.filter( (key) => activeRuleKeySet.has(key) || PRESET_MODULE_RULE_ORDER_KEY_SET.has(key) ); @@ -510,16 +521,15 @@ export function resolveAppliedRuleOrder(options: RulesGenerateOptions): string[] editableRuleKeys, allRuleKeys, normalizeEditableRuleOrderKeys(persistedOrder, editableRuleKeys) - ); + ).filter((key) => activeRuleKeys.includes(key)); } export function buildGeneratedRuleEntries(options: RulesGenerateOptions): GeneratedRuleEntry[] { const { preMatchEntries, matchEntry } = buildCanonicalRuleEntries({ enabledModules: options.enabledModules, customRules: options.customRules, - customProxyGroups: options.customProxyGroups, - moduleRuleOverrides: options.moduleRuleOverrides, - moduleRuleExclusions: options.moduleRuleExclusions, + customRuleSets: options.customRuleSets, + builtinRuleEdits: options.builtinRuleEdits, proxyGroupNameOverrides: options.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: options.experimentalCnUseCnRuleSet, cnIpNoResolve: options.cnIpNoResolve, @@ -528,7 +538,9 @@ export function buildGeneratedRuleEntries(options: RulesGenerateOptions): Genera }); const orderKeys = resolveAppliedRuleOrder(options); const byKey = new Map(preMatchEntries.map((entry) => [entry.key, entry])); - const orderedEntries = orderKeys.map((key) => byKey.get(key)).filter(Boolean) as GeneratedRuleEntry[]; + const orderedEntries = orderKeys + .map((key) => byKey.get(key)) + .filter((entry): entry is GeneratedRuleEntry => Boolean(entry?.enabled)); return [...orderedEntries, matchEntry]; } diff --git a/packages/core/src/rules/custom-routing-rule-sets.ts b/packages/core/src/rules/custom-routing-rule-sets.ts index 5b79e04..8d7f8a0 100644 --- a/packages/core/src/rules/custom-routing-rule-sets.ts +++ b/packages/core/src/rules/custom-routing-rule-sets.ts @@ -1,7 +1,11 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import type { CustomProxyGroup } from "@subboost/core/types/config"; -import type { ModuleRuleOverride } from "@subboost/core/types/template-config"; +import type { CustomProxyGroup, CustomRuleSet } from "@subboost/core/types/config"; +import { + buildRuleSetUrlFromPath, + extractRuleSetPathFromUrl, + normalizeRuleSetPathInput, +} from "@subboost/core/rules/rule-model"; export type CustomRoutingRuleSetTarget = { kind: "module" | "custom"; @@ -13,7 +17,7 @@ export type CustomRoutingRuleSetTarget = { export type CustomRoutingRuleSetItem = { key: string; source: { - kind: "module" | "custom"; + kind: "custom-rule-set"; id: string; }; id: string; @@ -46,83 +50,65 @@ export function parseRuleSetTargetValue( return null; } -export function extractRuleSetPathFromUrl(url: string): string { - const trimmed = url.trim(); - const match = trimmed.match(/(?:^|\/)(geosite|geoip)\/[^/?#\s]+\.mrs/i); - if (!match) return trimmed; - return match[0].replace(/^\/+/, ""); -} - -export function normalizeRuleSetPathInput(input: string): string { - return extractRuleSetPathFromUrl(input).replace(/^\/+/, "").trim(); -} +export { buildRuleSetUrlFromPath, extractRuleSetPathFromUrl, normalizeRuleSetPathInput }; -export function buildRuleSetUrlFromPath(path: string, baseUrl: string): string { - const normalizedPath = normalizeRuleSetPathInput(path); - if (/^https?:\/\//i.test(normalizedPath)) return normalizedPath; - return `${baseUrl.replace(/\/+$/, "")}/${normalizedPath}`; -} - -export function collectCustomRoutingRuleSets({ - customProxyGroups, - moduleRuleOverrides, - proxyGroupNameOverrides, -}: { - customProxyGroups: CustomProxyGroup[]; - moduleRuleOverrides: Record; - proxyGroupNameOverrides?: Record; -}): CustomRoutingRuleSetItem[] { - const items: CustomRoutingRuleSetItem[] = []; +function resolveRuleSetTarget( + targetName: string, + customProxyGroups: CustomProxyGroup[], + proxyGroupNameOverrides?: Record +): CustomRoutingRuleSetTarget | null { + const target = targetName.trim(); + if (!target) return null; for (const proxyModule of PROXY_GROUP_MODULES) { - const targetName = resolveProxyGroupModuleName( - proxyModule, - proxyGroupNameOverrides?.[proxyModule.id], - ); - const target = { - kind: "module" as const, + const name = resolveProxyGroupModuleName(proxyModule, proxyGroupNameOverrides?.[proxyModule.id]); + if (name !== target) continue; + return { + kind: "module", id: proxyModule.id, - name: targetName, + name, value: getRuleSetTargetValue({ kind: "module", id: proxyModule.id }), }; - - for (const rule of moduleRuleOverrides?.[proxyModule.id] || []) { - if (!rule || !rule.id || !rule.path) continue; - items.push({ - key: `module:${proxyModule.id}:${rule.id}`, - source: { kind: "module", id: proxyModule.id }, - id: rule.id, - name: rule.name || rule.id, - behavior: rule.behavior, - path: normalizeRuleSetPathInput(rule.path), - target, - noResolve: Boolean(rule.noResolve), - }); - } } - for (const group of customProxyGroups) { - if (!group || !group.id) continue; - const target = { - kind: "custom" as const, + const group = customProxyGroups.find((item) => item.name === target); + if (group) { + return { + kind: "custom", id: group.id, name: group.name, value: getRuleSetTargetValue({ kind: "custom", id: group.id }), }; + } + + return null; +} + +export function collectCustomRoutingRuleSets({ + customRuleSets, + customProxyGroups, + proxyGroupNameOverrides, +}: { + customRuleSets: CustomRuleSet[]; + customProxyGroups: CustomProxyGroup[]; + proxyGroupNameOverrides?: Record; +}): CustomRoutingRuleSetItem[] { + const items: CustomRoutingRuleSetItem[] = []; - for (const rule of group.rules || []) { - if (!rule || !rule.id || !rule.url) continue; - items.push({ - key: `custom:${group.id}:${rule.id}`, - source: { kind: "custom", id: group.id }, - id: rule.id, - name: rule.name || rule.id, - behavior: rule.behavior, - path: extractRuleSetPathFromUrl(rule.url), - target, - noResolve: Boolean(rule.noResolve), - }); - } + for (const rule of customRuleSets || []) { + if (!rule || !rule.id || !rule.path) continue; + const target = resolveRuleSetTarget(rule.target, customProxyGroups, proxyGroupNameOverrides); + if (!target) continue; + items.push({ + key: `custom-rule-set:${rule.id}`, + source: { kind: "custom-rule-set", id: rule.id }, + id: rule.id, + name: rule.name || rule.id, + behavior: rule.behavior, + path: normalizeRuleSetPathInput(rule.path), + target, + noResolve: Boolean(rule.noResolve), + }); } return items; diff --git a/packages/core/src/rules/custom-rule-helpers.test.ts b/packages/core/src/rules/custom-rule-helpers.test.ts index 61a545a..b821128 100644 --- a/packages/core/src/rules/custom-rule-helpers.test.ts +++ b/packages/core/src/rules/custom-rule-helpers.test.ts @@ -18,7 +18,7 @@ import { listEditableRuleOrderKeys, reconcileRuleOrder, } from "./custom-rule-utils"; -import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; +import type { CustomProxyGroup, CustomRule, CustomRuleSet } from "@subboost/core/types/config"; describe("custom routing rule set helpers", () => { it("parses, normalizes, and builds rule-set targets and paths", () => { @@ -46,43 +46,42 @@ describe("custom routing rule set helpers", () => { { id: "custom-a", name: "Custom A", - rules: [ - { - id: "custom-rule", - name: "", - behavior: "domain", - url: "https://cdn.example/geosite/custom.mrs", - noResolve: true, - }, - { id: "", behavior: "domain", url: "https://cdn.example/geosite/skip.mrs" }, - { id: "missing-url", behavior: "domain", url: "" }, - ], + emoji: "", + groupType: "select", }, - { id: "", name: "skip", rules: [] }, + { id: "", name: "skip", emoji: "", groupType: "select" }, ] as CustomProxyGroup[]; const items = collectCustomRoutingRuleSets({ customProxyGroups, - moduleRuleOverrides: { - select: [ - { - id: "module-rule", - name: "", - behavior: "ipcidr", - path: "/geoip/private.mrs", - noResolve: true, - }, - { id: "", name: "skip", behavior: "domain", path: "geosite/skip.mrs" }, - { id: "missing-path", name: "missing path", behavior: "domain", path: "" }, - ], - }, + customRuleSets: [ + { + id: "module-rule", + name: "", + behavior: "ipcidr", + path: "/geoip/private.mrs", + target: "🚀 Custom Select", + noResolve: true, + }, + { + id: "custom-rule", + name: "", + behavior: "domain", + path: "https://cdn.example/geosite/custom.mrs", + target: "Custom A", + noResolve: true, + }, + { id: "", name: "skip", behavior: "domain", path: "geosite/skip.mrs", target: "Custom A" }, + { id: "missing-path", name: "missing path", behavior: "domain", path: "", target: "Custom A" }, + ], proxyGroupNameOverrides: { select: "Custom Select" }, }); expect(items).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: "module:select:module-rule", + key: "custom-rule-set:module-rule", + source: { kind: "custom-rule-set", id: "module-rule" }, id: "module-rule", name: "module-rule", behavior: "ipcidr", @@ -91,7 +90,8 @@ describe("custom routing rule set helpers", () => { noResolve: true, }), expect.objectContaining({ - key: "custom:custom-a:custom-rule", + key: "custom-rule-set:custom-rule", + source: { kind: "custom-rule-set", id: "custom-rule" }, id: "custom-rule", name: "custom-rule", path: "geosite/custom.mrs", @@ -127,38 +127,37 @@ describe("custom rule id and order helpers", () => { expect(isCustomRuleType("BAD")).toBe(false); const customRules = [ruleWithoutId]; - const customProxyGroups = [ + const customRuleSets = [ { - id: "group-a", - name: "Group A", - emoji: "🧩", - groupType: "select", - rules: [{ id: "rule-a", name: "Rule A", behavior: "domain", url: "geosite/a.mrs" }], + id: "rule-a", + name: "Rule A", + behavior: "domain", + path: "geosite/a.mrs", + target: "Group A", }, { id: " ", name: "Skip", - emoji: "🧩", - groupType: "select", - rules: [{ id: "skip", name: "Skip", behavior: "domain", url: "geosite/b.mrs" }], + behavior: "domain", + path: "geosite/b.mrs", + target: "Group A", }, - { id: "group-b", name: "Skip Rules", emoji: "🧩", groupType: "select", rules: null as never }, - ] as CustomProxyGroup[]; + ] as CustomRuleSet[]; expect(getCustomRuleOrderKey("r1")).toBe("custom-rule:r1"); expect(getCustomGroupRuleOrderKey("g1", "r1")).toBe("custom-group:g1:r1"); - expect(listEditableRuleOrderKeys(customRules, customProxyGroups)).toEqual([ + expect(listEditableRuleOrderKeys(customRules, customRuleSets)).toEqual([ `custom-rule:${ruleWithoutId.id}`, - "custom-group:group-a:rule-a", + "custom-rule-set:rule-a", ]); expect(reconcileRuleOrder(undefined, [], [])).toEqual([]); expect( reconcileRuleOrder( - [" missing ", "custom-group:group-a:rule-a", "custom-group:group-a:rule-a"], + [" missing ", "custom-rule-set:rule-a", "custom-rule-set:rule-a"], customRules, - customProxyGroups + customRuleSets ) - ).toEqual(["custom-group:group-a:rule-a", `custom-rule:${ruleWithoutId.id}`]); + ).toEqual(["custom-rule-set:rule-a", `custom-rule:${ruleWithoutId.id}`]); expect(reconcileRuleOrder("bad" as never, customRules, [])).toEqual([`custom-rule:${ruleWithoutId.id}`]); }); }); diff --git a/packages/core/src/rules/custom-rule-utils.ts b/packages/core/src/rules/custom-rule-utils.ts index 833db54..6d45cc4 100644 --- a/packages/core/src/rules/custom-rule-utils.ts +++ b/packages/core/src/rules/custom-rule-utils.ts @@ -1,4 +1,4 @@ -import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; +import type { CustomRule, CustomRuleSet } from "@subboost/core/types/config"; export const CUSTOM_RULE_TYPES = [ "DOMAIN", @@ -77,19 +77,19 @@ export function getCustomGroupRuleOrderKey(groupId: string, ruleId: string): str return `custom-group:${groupId}:${ruleId}`; } -export function listEditableRuleOrderKeys(customRules: CustomRule[], customProxyGroups: CustomProxyGroup[] = []): string[] { +export function getCustomRuleSetOrderKey(ruleSetId: string): string { + return `custom-rule-set:${ruleSetId}`; +} + +export function listEditableRuleOrderKeys(customRules: CustomRule[], customRuleSets: CustomRuleSet[] = []): string[] { const keys: string[] = []; for (const rule of ensureCustomRulesHaveIds(customRules)) { keys.push(getCustomRuleOrderKey(rule.id)); } - for (const group of customProxyGroups) { - const groupId = toTrimmedString(group?.id); - if (!groupId || !Array.isArray(group?.rules)) continue; - for (const rule of group.rules) { - const ruleId = toTrimmedString(rule?.id); - if (!ruleId) continue; - keys.push(getCustomGroupRuleOrderKey(groupId, ruleId)); - } + for (const ruleSet of customRuleSets) { + const ruleSetId = toTrimmedString(ruleSet?.id); + if (!ruleSetId) continue; + keys.push(getCustomRuleSetOrderKey(ruleSetId)); } return keys; } @@ -97,9 +97,9 @@ export function listEditableRuleOrderKeys(customRules: CustomRule[], customProxy export function reconcileRuleOrder( ruleOrder: string[] | undefined, customRules: CustomRule[], - customProxyGroups: CustomProxyGroup[] = [] + customRuleSets: CustomRuleSet[] = [] ): string[] { - const available = listEditableRuleOrderKeys(customRules, customProxyGroups); + const available = listEditableRuleOrderKeys(customRules, customRuleSets); if (available.length === 0) return []; const availableSet = new Set(available); diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts new file mode 100644 index 0000000..8a64c5f --- /dev/null +++ b/packages/core/src/rules/rule-model.ts @@ -0,0 +1,296 @@ +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-group-modules"; +import { getModuleRuleOrderKey, normalizeModuleRuleExclusions } from "@subboost/core/generator/module-rules"; +import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import type { + BuiltinRuleEdit, + BuiltinRuleEdits, + CustomProxyGroup, + CustomRuleSet, + RuleSetBehavior, +} from "@subboost/core/types/config"; +import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy } from "@subboost/core/types/config"; + +export const RULE_SET_PATH_RE = /^(geosite|geoip)\/[^/?#\s]+\.mrs$/i; + +type LegacyModuleRuleOverride = { + id: string; + name: string; + behavior: RuleSetBehavior; + path: string; + noResolve?: boolean; +}; + +type LegacyCustomProxyGroupRule = { + id: string; + name: string; + behavior: RuleSetBehavior; + url: string; + noResolve?: boolean; +}; + +export type NormalizedRuleModel = { + customProxyGroups: CustomProxyGroup[]; + customRuleSets: CustomRuleSet[]; + builtinRuleEdits: BuiltinRuleEdits; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeBehavior(value: unknown, path: string): RuleSetBehavior { + return value === "ipcidr" || path.toLowerCase().startsWith("geoip/") ? "ipcidr" : "domain"; +} + +export function extractRuleSetPathFromUrl(url: string): string { + const trimmed = url.trim(); + const match = trimmed.match(/(?:^|\/)(geosite|geoip)\/[^/?#\s]+\.mrs/i); + if (!match) return trimmed; + return match[0].replace(/^\/+/, ""); +} + +export function normalizeRuleSetPathInput(input: string): string { + return extractRuleSetPathFromUrl(input).replace(/^\/+/, "").trim(); +} + +export function isValidRuleSetPathOrUrl(value: string): boolean { + return RULE_SET_PATH_RE.test(value) || /^https?:\/\//i.test(value); +} + +export function buildRuleSetUrlFromPath(path: string, baseUrl: string): string { + const normalizedPath = normalizeRuleSetPathInput(path); + if (/^https?:\/\//i.test(normalizedPath)) return normalizedPath; + return `${baseUrl.replace(/\/+$/, "")}/${normalizedPath}`; +} + +function normalizeCustomRuleSet(item: unknown): CustomRuleSet | null { + if (!isRecord(item)) return null; + const id = toTrimmedString(item.id); + const rawPath = toTrimmedString(item.path); + const path = normalizeRuleSetPathInput(rawPath); + const target = toTrimmedString(item.target); + if (!id || !path || !target || !isValidRuleSetPathOrUrl(path)) return null; + const behavior = normalizeBehavior(item.behavior, path); + const name = toTrimmedString(item.name) || id; + const noResolve = typeof item.noResolve === "boolean" ? item.noResolve : behavior === "ipcidr"; + return { + id, + name, + behavior, + path, + target, + ...(noResolve ? { noResolve: true } : {}), + }; +} + +function normalizeBuiltinRuleEdit(item: unknown): BuiltinRuleEdit | null { + if (!isRecord(item)) return null; + const target = toTrimmedString(item.target); + const enabled = item.enabled === false ? false : undefined; + if (!target && enabled !== false) return null; + return { + ...(target ? { target } : {}), + ...(enabled === false ? { enabled: false } : {}), + }; +} + +export function normalizeBuiltinRuleEdits(value: unknown): BuiltinRuleEdits { + if (!isRecord(value)) return {}; + const out: BuiltinRuleEdits = {}; + for (const [rawKey, rawEdit] of Object.entries(value)) { + const key = rawKey.trim(); + if (!key) continue; + const edit = normalizeBuiltinRuleEdit(rawEdit); + if (!edit) continue; + out[key] = edit; + } + return out; +} + +function addUniqueCustomRuleSet(items: CustomRuleSet[], item: CustomRuleSet): void { + const existing = new Set(items.map((ruleSet) => ruleSet.id)); + if (!existing.has(item.id)) { + items.push(item); + return; + } + + let index = 2; + let id = `${item.id}-${index}`; + while (existing.has(id)) { + index += 1; + id = `${item.id}-${index}`; + } + items.push({ ...item, id }); +} + +function normalizeLegacyModuleRuleOverrides(value: unknown): Record { + if (!isRecord(value)) return {}; + const out: Record = {}; + for (const [rawModuleId, rawRules] of Object.entries(value)) { + const moduleId = rawModuleId.trim(); + if (!moduleId || !Array.isArray(rawRules)) continue; + const rules: LegacyModuleRuleOverride[] = []; + for (const rawRule of rawRules) { + if (!isRecord(rawRule)) continue; + const id = toTrimmedString(rawRule.id); + const path = normalizeRuleSetPathInput(toTrimmedString(rawRule.path)); + if (!id || !path || !isValidRuleSetPathOrUrl(path)) continue; + const behavior = normalizeBehavior(rawRule.behavior, path); + const name = toTrimmedString(rawRule.name) || id; + const noResolve = typeof rawRule.noResolve === "boolean" ? rawRule.noResolve : behavior === "ipcidr"; + rules.push({ id, name, behavior, path, ...(noResolve ? { noResolve: true } : {}) }); + } + if (rules.length > 0) out[moduleId] = rules; + } + return out; +} + +function normalizeLegacyCustomProxyGroupRules(value: unknown): LegacyCustomProxyGroupRule[] { + if (!Array.isArray(value)) return []; + const out: LegacyCustomProxyGroupRule[] = []; + for (const rawRule of value) { + if (!isRecord(rawRule)) continue; + const id = toTrimmedString(rawRule.id); + const url = toTrimmedString(rawRule.url); + if (!id || !url) continue; + const path = normalizeRuleSetPathInput(url); + if (!isValidRuleSetPathOrUrl(path)) continue; + const behavior = normalizeBehavior(rawRule.behavior, path); + const name = toTrimmedString(rawRule.name) || id; + const noResolve = typeof rawRule.noResolve === "boolean" ? rawRule.noResolve : behavior === "ipcidr"; + out.push({ id, name, behavior, url, ...(noResolve ? { noResolve: true } : {}) }); + } + return out; +} + +function getBuiltinRuleSourceKey(ruleId: string): string | null { + for (const proxyModule of PROXY_GROUP_MODULES) { + if (proxyModule.rules.some((rule) => rule.id === ruleId)) { + return getModuleRuleOrderKey(proxyModule.id, ruleId); + } + } + return null; +} + +function normalizeCustomProxyGroupsAndLegacyRules(value: unknown): { + groups: CustomProxyGroup[]; + legacyRuleSets: CustomRuleSet[]; +} { + if (!Array.isArray(value)) return { groups: [], legacyRuleSets: [] }; + const groups: CustomProxyGroup[] = []; + const legacyRuleSets: CustomRuleSet[] = []; + + for (const rawGroup of value) { + if (!isRecord(rawGroup)) continue; + const id = toTrimmedString(rawGroup.id); + const name = toTrimmedString(rawGroup.name); + const emoji = toTrimmedString(rawGroup.emoji); + const groupType = toTrimmedString(rawGroup.groupType); + if (!id || !name) continue; + if ( + groupType !== "select" && + groupType !== "url-test" && + groupType !== "fallback" && + groupType !== "load-balance" && + groupType !== "direct-first" && + groupType !== "reject-first" + ) { + continue; + } + + groups.push({ + id, + name, + emoji, + groupType, + ...(groupType === "load-balance" + ? { + strategy: isLoadBalanceStrategy(rawGroup.strategy) + ? rawGroup.strategy + : DEFAULT_LOAD_BALANCE_STRATEGY, + } + : {}), + }); + + for (const rule of normalizeLegacyCustomProxyGroupRules(rawGroup.rules)) { + addUniqueCustomRuleSet(legacyRuleSets, { + id: rule.id, + name: rule.name, + behavior: rule.behavior, + path: normalizeRuleSetPathInput(rule.url), + target: name, + ...(rule.noResolve ? { noResolve: true } : {}), + }); + } + } + + return { groups, legacyRuleSets }; +} + +export function normalizeRuleModelFromConfig(value: unknown): NormalizedRuleModel { + const record = isRecord(value) ? value : {}; + const { groups: customProxyGroups, legacyRuleSets } = normalizeCustomProxyGroupsAndLegacyRules(record.customProxyGroups); + const customRuleSets: CustomRuleSet[] = []; + const builtinRuleEdits: BuiltinRuleEdits = normalizeBuiltinRuleEdits(record.builtinRuleEdits); + + if (Array.isArray(record.customRuleSets)) { + for (const rawRuleSet of record.customRuleSets) { + const normalized = normalizeCustomRuleSet(rawRuleSet); + if (normalized) addUniqueCustomRuleSet(customRuleSets, normalized); + } + } + + for (const ruleSet of legacyRuleSets) { + addUniqueCustomRuleSet(customRuleSets, ruleSet); + } + + const legacyOverrides = normalizeLegacyModuleRuleOverrides(record.moduleRuleOverrides); + const legacyExclusions = normalizeModuleRuleExclusions(record.moduleRuleExclusions); + const consumedMovedOverrideKeys = new Set(); + + for (const [sourceModuleId, ruleIds] of Object.entries(legacyExclusions)) { + for (const ruleId of ruleIds) { + const key = getModuleRuleOrderKey(sourceModuleId, ruleId); + let movedTarget: string | null = null; + for (const [targetModuleId, rules] of Object.entries(legacyOverrides)) { + const index = rules.findIndex((rule) => rule.id === ruleId); + if (index < 0) continue; + const targetModule = PROXY_GROUP_MODULES.find((module) => module.id === targetModuleId); + if (!targetModule) continue; + movedTarget = resolveProxyGroupModuleName(targetModule, (record.proxyGroupNameOverrides as Record | undefined)?.[targetModuleId]); + consumedMovedOverrideKeys.add(`${targetModuleId}:${index}`); + break; + } + builtinRuleEdits[key] = { + ...(movedTarget ? { target: movedTarget } : {}), + ...(movedTarget ? {} : { enabled: false as const }), + }; + } + } + + for (const [targetModuleId, rules] of Object.entries(legacyOverrides)) { + const targetModule = PROXY_GROUP_MODULES.find((module) => module.id === targetModuleId); + const target = targetModule + ? resolveProxyGroupModuleName(targetModule, (record.proxyGroupNameOverrides as Record | undefined)?.[targetModuleId]) + : targetModuleId; + rules.forEach((rule, index) => { + if (consumedMovedOverrideKeys.has(`${targetModuleId}:${index}`)) return; + const builtinSourceKey = getBuiltinRuleSourceKey(rule.id); + if (builtinSourceKey && builtinSourceKey === getModuleRuleOrderKey(targetModuleId, rule.id)) return; + addUniqueCustomRuleSet(customRuleSets, { + id: rule.id, + name: rule.name, + behavior: rule.behavior, + path: rule.path, + target, + ...(rule.noResolve ? { noResolve: true } : {}), + }); + }); + } + + return { customProxyGroups, customRuleSets, builtinRuleEdits }; +} diff --git a/packages/core/src/rules/rules-utils.test.ts b/packages/core/src/rules/rules-utils.test.ts index 4b8cfbb..5b09178 100644 --- a/packages/core/src/rules/rules-utils.test.ts +++ b/packages/core/src/rules/rules-utils.test.ts @@ -43,34 +43,34 @@ describe("custom rule helpers", () => { [rule], [ { - id: "group", - name: "Group", - emoji: "G", - groupType: "select", - rules: [{ id: "nested", name: "Nested", behavior: "domain", url: "https://rules.example.com/a.mrs" }], + id: "nested", + name: "Nested", + behavior: "domain", + path: "https://rules.example.com/a.mrs", + target: "Group", }, ] ) ).toEqual([ "custom-rule:custom-rule-domain-suffix-example-com-direct-3", - "custom-group:group:nested", + "custom-rule-set:nested", ]); expect( reconcileRuleOrder( - ["missing", "custom-group:group:nested", "custom-group:group:nested"], + ["missing", "custom-rule-set:nested", "custom-rule-set:nested"], [rule], [ { - id: "group", - name: "Group", - emoji: "G", - groupType: "select", - rules: [{ id: "nested", name: "Nested", behavior: "domain", url: "https://rules.example.com/a.mrs" }], + id: "nested", + name: "Nested", + behavior: "domain", + path: "https://rules.example.com/a.mrs", + target: "Group", }, ] ) ).toEqual([ - "custom-group:group:nested", + "custom-rule-set:nested", "custom-rule:custom-rule-domain-suffix-example-com-direct-3", ]); }); diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 0ac6bd2..7ac0a0f 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -157,7 +157,12 @@ describe("subscription config utils", () => { expect(options.customProxyGroups?.[0]).toMatchObject({ id: "media", strategy: "consistent-hashing", - rules: [{ id: "youtube", name: "YouTube" }], + }); + expect(options.customRuleSets?.[0]).toMatchObject({ + id: "youtube", + name: "YouTube", + target: "Media", + path: "https://rules.example.com/youtube.mrs", }); expect(options.dialerProxyGroups?.[0]).toMatchObject({ id: "chain", diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 6e6da28..82f5c87 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -1,7 +1,7 @@ import type { GenerateOptions } from "@subboost/core/generator"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { normalizeModuleRuleExclusions, type ModuleRuleExclusions } from "@subboost/core/generator/module-rules"; -import type { DialerProxyGroup, ModuleRuleOverride } from "@subboost/core/types/template-config"; +import type { DialerProxyGroup } from "@subboost/core/types/template-config"; import type { ParsedNode } from "@subboost/core/types/node"; import { DEFAULT_LOAD_BALANCE_STRATEGY, @@ -16,6 +16,7 @@ import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscript import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; import { ensureCustomRuleId } from "@subboost/core/rules/custom-rule-utils"; import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; +import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; export type ModuleRuleOverrideLike = { id: string; @@ -220,25 +221,6 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { if (!id || !name || !emoji || !groupType) continue; - const rawRules = Array.isArray(item.rules) ? item.rules : []; - const rules: CustomProxyGroup["rules"] = []; - for (const rawRule of rawRules) { - if (!isRecord(rawRule)) continue; - const rid = toTrimmedString(rawRule.id); - const rname = toTrimmedString(rawRule.name); - const behavior = rawRule.behavior === "domain" || rawRule.behavior === "ipcidr" ? rawRule.behavior : null; - const url = toTrimmedString(rawRule.url); - if (!rid || !rname || !behavior || !url) continue; - const noResolve = typeof rawRule.noResolve === "boolean" ? rawRule.noResolve : undefined; - rules.push({ - id: rid, - name: rname, - behavior, - url, - ...(noResolve !== undefined ? { noResolve } : {}), - }); - } - const strategy = groupType === "load-balance" ? isLoadBalanceStrategy(item.strategy) @@ -246,7 +228,7 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { : DEFAULT_LOAD_BALANCE_STRATEGY : undefined; - out.push({ id, name, emoji, groupType, ...(strategy ? { strategy } : {}), rules }); + out.push({ id, name, emoji, groupType, ...(strategy ? { strategy } : {}) }); } return out; } @@ -360,7 +342,12 @@ export function buildGenerateOptionsFromConfig( const enabledGroups = normalizeEnabledList(config.enabledGroups); const enabledRules = normalizeEnabledList(config.enabledRules); const customRules = normalizeCustomRules(config.customRules); - const customProxyGroups = normalizeCustomProxyGroups(config.customProxyGroups); + const ruleModel = normalizeRuleModelFromConfig(config); + const customProxyGroups = ruleModel.customProxyGroups.length > 0 + ? ruleModel.customProxyGroups + : normalizeCustomProxyGroups(config.customProxyGroups); + const customRuleSets = ruleModel.customRuleSets; + const builtinRuleEdits = ruleModel.builtinRuleEdits; const dnsYaml = typeof config.dnsYaml === "string" ? config.dnsYaml : undefined; const ruleProviderBaseUrl = typeof config.ruleProviderBaseUrl === "string" && config.ruleProviderBaseUrl.trim().startsWith("http") @@ -377,16 +364,11 @@ export function buildGenerateOptionsFromConfig( typeof config.experimentalCnUseCnRuleSet === "boolean" ? config.experimentalCnUseCnRuleSet : undefined; const proxyGroupNameOverrides = normalizeProxyGroupNameOverrides(config.proxyGroupNameOverrides); const listenerPorts = normalizeListenerPorts(config.listenerPorts); - const moduleRuleOverrides = extractModuleRuleOverrides(config) as unknown as - | Record - | undefined; - const moduleRuleExclusions = extractModuleRuleExclusions(config); const ruleOrder = normalizePersistedRuleOrder({ enabledModules: enabledGroups || [], customRules: customRules || [], - customProxyGroups, - moduleRuleOverrides: moduleRuleOverrides || {}, - moduleRuleExclusions: moduleRuleExclusions || {}, + customRuleSets, + builtinRuleEdits, proxyGroupNameOverrides, experimentalCnUseCnRuleSet, cnIpNoResolve, @@ -422,9 +404,9 @@ export function buildGenerateOptionsFromConfig( userConfig, ...(dialerProxyGroups.length > 0 ? { dialerProxyGroups } : {}), ...(customProxyGroups.length > 0 ? { customProxyGroups } : {}), + ...(customRuleSets.length > 0 ? { customRuleSets } : {}), ...(filteredProxyGroups.length > 0 ? { filteredProxyGroups } : {}), - ...(moduleRuleOverrides ? { moduleRuleOverrides } : {}), - ...(moduleRuleExclusions ? { moduleRuleExclusions } : {}), + ...(Object.keys(builtinRuleEdits).length > 0 ? { builtinRuleEdits } : {}), ...(proxyGroupNameOverrides ? { proxyGroupNameOverrides } : {}), ...(proxyGroupOrder ? { proxyGroupOrder } : {}), }; diff --git a/packages/core/src/templates/config-template.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index 52979e3..7d393a7 100644 --- a/packages/core/src/templates/config-template.fields.test.ts +++ b/packages/core/src/templates/config-template.fields.test.ts @@ -1,12 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getModulesForTemplate } from "@subboost/core/generator/proxy-groups"; import { validateSubBoostTemplateConfig } from "@subboost/core/templates/config-template"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; describe("validateSubBoostTemplateConfig field validation", () => { it("rejects invalid dialer, module rule, scalar, and URL fields", () => { - const moduleId = getModulesForTemplate("minimal")[0]; - expectInvalid({ dialerProxyGroups: "bad" as never }, "dialerProxyGroups 必须是数组"); expectInvalid({ dialerProxyGroups: [1 as never] }, "dialerProxyGroups 只能包含对象"); expectInvalid( @@ -82,87 +79,93 @@ describe("validateSubBoostTemplateConfig field validation", () => { "dialerProxyGroups.enabled 必须是布尔值" ); - expectInvalid({ moduleRuleOverrides: "bad" as never }, "moduleRuleOverrides 必须是对象"); + expectInvalid({ customRuleSets: "bad" as never }, "customRuleSets 必须是数组"); expectInvalid( - { moduleRuleOverrides: { unknown: [] } as never }, - "moduleRuleOverrides 包含未知代理组" + { customRuleSets: [1 as never] }, + "customRuleSets 只能包含对象" ); expectInvalid( - { moduleRuleOverrides: { [moduleId]: "bad" } as never }, - "moduleRuleOverrides 的值必须是数组" + { + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "bad" as never, + path: "geoip/private.mrs", + target: "DIRECT", + }, + ], + }, + "customRuleSets.behavior 无效" ); expectInvalid( - { moduleRuleOverrides: { [moduleId]: [1] } as never }, - "moduleRuleOverrides 只能包含对象" + { + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "domain", + path: "plain/rule.txt", + target: "DIRECT", + }, + ], + }, + "customRuleSets.path 无效" ); expectInvalid( { - moduleRuleOverrides: { - [moduleId]: [ - { - id: "bad", - name: "Bad", - behavior: "bad", - path: "geoip/private.mrs", - }, - ], + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "domain", + path: "geosite/private.mrs", + target: "DIRECT", + noResolve: "yes" as never, + }, + ], + }, + "customRuleSets.noResolve 必须是布尔值" + ); + + expectInvalid({ builtinRuleEdits: "bad" as never }, "builtinRuleEdits 必须是对象"); + expectInvalid( + { + builtinRuleEdits: { + "module:missing:rule": { enabled: false }, } as never, }, - "moduleRuleOverrides.behavior 无效" + "builtinRuleEdits 包含未知内置规则" ); - expect( - validateSubBoostTemplateConfig( - validConfig({ - moduleRuleOverrides: { - [moduleId]: [ - { - id: "bad", - name: "Bad", - behavior: "domain", - path: "https://rules.example.com/bad.mrs", - }, - ], - }, - }) - ) - ).toEqual({ ok: false, error: "moduleRuleOverrides.path 无效" }); expectInvalid( { - moduleRuleOverrides: { - [moduleId]: [ - { - id: "bad", - name: "Bad", - behavior: "domain", - path: "geosite/private.mrs", - noResolve: "yes", - }, - ], + builtinRuleEdits: { + "module:cn:geolocation-cn": "bad", } as never, }, - "moduleRuleOverrides.noResolve 必须是布尔值" + "builtinRuleEdits 的值必须是对象" ); - - expectInvalid({ moduleRuleExclusions: "bad" as never }, "moduleRuleExclusions 必须是对象"); - expect( - validateSubBoostTemplateConfig( - validConfig({ - moduleRuleExclusions: { - unknown: ["rule"], - }, - }) - ) - ).toEqual({ ok: false, error: "moduleRuleExclusions 包含未知代理组" }); expectInvalid( - { moduleRuleExclusions: { [moduleId]: [1] } as never }, - "moduleRuleExclusions 只能包含字符串" + { + builtinRuleEdits: { + "module:cn:geolocation-cn": { target: 1 }, + } as never, + }, + "builtinRuleEdits.target 必须是字符串" + ); + expectInvalid( + { + builtinRuleEdits: { + "module:cn:geolocation-cn": { enabled: true }, + } as never, + }, + "builtinRuleEdits.enabled 只能是 false" ); expect(validateSubBoostTemplateConfig(validConfig({ allowLan: "yes" as never }))).toEqual({ ok: false, error: "allowLan 必须是布尔值", }); - expectInvalid({ allRulesOrderEditingEnabled: "yes" as never }, "allRulesOrderEditingEnabled 必须是布尔值"); expectInvalid({ cnIpNoResolve: "yes" as never }, "cnIpNoResolve 必须是布尔值"); expectInvalid( { experimentalCnUseCnRuleSet: "yes" as never }, diff --git a/packages/core/src/templates/config-template.groups.test.ts b/packages/core/src/templates/config-template.groups.test.ts index d1b2399..dc081b2 100644 --- a/packages/core/src/templates/config-template.groups.test.ts +++ b/packages/core/src/templates/config-template.groups.test.ts @@ -88,7 +88,6 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { name: "Custom", emoji: "C", groupType: "select", - rules: [], }, ], }, @@ -102,7 +101,6 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { name: "", emoji: "C", groupType: "select", - rules: [], }, ], }, @@ -116,84 +114,11 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { name: "Custom", emoji: "C", groupType: "bad" as never, - rules: [], }, ], }, "customProxyGroups.groupType 无效" ); - expectInvalid( - { - customProxyGroups: [ - { - id: "custom", - name: "Custom", - emoji: "C", - groupType: "select", - rules: "bad" as never, - }, - ], - }, - "customProxyGroups.rules 必须是数组" - ); - expectInvalid( - { - customProxyGroups: [ - { - id: "custom", - name: "Custom", - emoji: "C", - groupType: "select", - rules: [1 as never], - }, - ], - }, - "customProxyGroups.rules 只能包含对象" - ); - expectInvalid( - { - customProxyGroups: [ - { - id: "custom", - name: "Custom", - emoji: "C", - groupType: "select", - rules: [ - { - id: "r", - name: "R", - behavior: "bad" as never, - url: "https://rules.example.com/custom.mrs", - }, - ], - }, - ], - }, - "customProxyGroups.rules.behavior 无效" - ); - expectInvalid( - { - customProxyGroups: [ - { - id: "custom", - name: "Custom", - emoji: "C", - groupType: "select", - rules: [ - { - id: "r", - name: "R", - behavior: "domain", - url: "https://rules.example.com/custom.mrs", - noResolve: "yes" as never, - }, - ], - }, - ], - }, - "customProxyGroups.rules.noResolve 必须是布尔值" - ); - expectInvalid({ filteredProxyGroups: "bad" as never }, "filteredProxyGroups 必须是数组"); expectInvalid({ filteredProxyGroups: [1 as never] }, "filteredProxyGroups 只能包含对象"); expectInvalid( @@ -272,36 +197,12 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { emoji: "C", groupType: "load-balance", strategy: "bad" as never, - rules: [], }, ], }) ) ).toEqual({ ok: false, error: "customProxyGroups.strategy 无效" }); - expect( - validateSubBoostTemplateConfig( - validConfig({ - customProxyGroups: [ - { - id: "custom", - name: "Custom", - emoji: "C", - groupType: "select", - rules: [ - { - id: "r", - name: "R", - behavior: "domain", - url: "not-url", - }, - ], - }, - ], - }) - ) - ).toEqual({ ok: false, error: "customProxyGroups.rules.url 必须是 http(s) URL" }); - expect( validateSubBoostTemplateConfig( validConfig({ diff --git a/packages/core/src/templates/config-template.test-helpers.ts b/packages/core/src/templates/config-template.test-helpers.ts index 7fde747..87c4f6f 100644 --- a/packages/core/src/templates/config-template.test-helpers.ts +++ b/packages/core/src/templates/config-template.test-helpers.ts @@ -6,7 +6,7 @@ import { } from "@subboost/core/templates/config-template"; import type { SubBoostTemplateConfig } from "@subboost/core/types/template-config"; -export function validConfig(patch: Partial = {}): SubBoostTemplateConfig { +export function validConfig(patch: Partial & Record = {}): SubBoostTemplateConfig { return { schema: SUBBOOST_TEMPLATE_CONFIG_SCHEMA, template: "minimal", @@ -14,8 +14,8 @@ export function validConfig(patch: Partial = {}): SubBoo hiddenProxyGroups: [], customProxyGroups: [], filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, customRules: [], ruleOrder: [], dialerProxyGroups: [], @@ -29,9 +29,9 @@ export function validConfig(patch: Partial = {}): SubBoo cnIpNoResolve: true, experimentalCnUseCnRuleSet: true, ...patch, - }; + } as SubBoostTemplateConfig; } -export function expectInvalid(patch: Partial, error: string) { +export function expectInvalid(patch: Partial & Record, error: string) { expect(validateSubBoostTemplateConfig(validConfig(patch))).toEqual({ ok: false, error }); } diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index 8e21166..4094834 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -59,15 +59,16 @@ describe("validateSubBoostTemplateConfig", () => { name: "Custom", emoji: "", groupType: "load-balance", - rules: [ - { - id: "custom-rule", - name: "Custom Rule", - behavior: "domain", - url: "https://rules.example.com/custom.mrs", - noResolve: false, - }, - ], + }, + ], + customRuleSets: [ + { + id: "custom-rule", + name: "Custom Rule", + behavior: "domain", + path: "https://rules.example.com/custom.mrs", + target: "Custom", + noResolve: false, }, ], filteredProxyGroups: [ @@ -135,6 +136,23 @@ describe("validateSubBoostTemplateConfig", () => { groupType: "load-balance", strategy: DEFAULT_LOAD_BALANCE_STRATEGY, }); + expect(result.config.customRuleSets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "custom-rule", + name: "Custom Rule", + behavior: "domain", + path: "https://rules.example.com/custom.mrs", + target: "Custom", + }), + expect.objectContaining({ + id: "extra", + behavior: "ipcidr", + path: "geoip/private.mrs", + noResolve: true, + }), + ]) + ); expect(result.config.filteredProxyGroups?.[0]).toMatchObject({ id: "filtered", groupType: "load-balance", @@ -158,15 +176,11 @@ describe("validateSubBoostTemplateConfig", () => { targetNodes: ["Target A"], enabled: false, }); - expect(result.config.moduleRuleOverrides?.[moduleId]?.[0]).toMatchObject({ - id: "extra", - behavior: "ipcidr", - path: "geoip/private.mrs", - noResolve: true, - }); - expect(result.config.moduleRuleExclusions?.[moduleId]).toEqual(["rule-a"]); + expect(result.config.builtinRuleEdits?.[`module:${moduleId}:rule-a`]).toEqual({ enabled: false }); expect(result.config.proxyGroupNameOverrides).toEqual({ [moduleId]: "Renamed" }); - expect(result.config.allRulesOrderEditingEnabled).toBe(true); + expect(result.config).not.toHaveProperty("moduleRuleOverrides"); + expect(result.config).not.toHaveProperty("moduleRuleExclusions"); + expect(result.config).not.toHaveProperty("allRulesOrderEditingEnabled"); }); it("rejects template configs that hide every enabled module", () => { diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 4e25a58..10e1e6b 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -1,7 +1,11 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; -import { normalizeModuleRuleExclusions, type ModuleRuleExclusions } from "@subboost/core/generator/module-rules"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRuleId, isCustomRuleType } from "@subboost/core/rules/custom-rule-utils"; +import { + isValidRuleSetPathOrUrl, + normalizeRuleModelFromConfig, + normalizeRuleSetPathInput, +} from "@subboost/core/rules/rule-model"; import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy, @@ -11,7 +15,7 @@ import { type TemplateType, } from "@subboost/core/types/config"; import type { FilteredProxyGroup, FilteredProxyGroupType, NodeRegion } from "@subboost/core/types/filtered-proxy-group"; -import type { DialerProxyGroup, ModuleRuleOverride, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; +import type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; export const SUBBOOST_TEMPLATE_CONFIG_SCHEMA = "subboost-template-config/v1"; @@ -20,7 +24,9 @@ type ValidationResult = | { ok: false; error: string }; const BUILTIN_MODULE_IDS = new Set(PROXY_GROUP_MODULES.map((module) => module.id)); -const RULE_PATH_RE = /^(geosite|geoip)\/[^/]+\.mrs$/i; +const BUILTIN_RULE_KEYS = new Set( + PROXY_GROUP_MODULES.flatMap((module) => module.rules.map((rule) => `module:${module.id}:${rule.id}`)) +); const FILTERED_GROUP_TYPES = new Set([ "select", "url-test", @@ -53,16 +59,17 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult const customProxyGroups = parseCustomProxyGroups(value.customProxyGroups); if (!customProxyGroups.ok) return customProxyGroups; + const customRuleSets = parseCustomRuleSets(value.customRuleSets); + if (!customRuleSets.ok) return customRuleSets; + const builtinRuleEdits = parseBuiltinRuleEdits(value.builtinRuleEdits); + if (!builtinRuleEdits.ok) return builtinRuleEdits; + const ruleModel = normalizeRuleModelFromConfig(value); const filteredProxyGroups = parseFilteredProxyGroups(value.filteredProxyGroups); if (!filteredProxyGroups.ok) return filteredProxyGroups; const customRules = parseCustomRules(value.customRules); if (!customRules.ok) return customRules; const dialerProxyGroups = parseDialerProxyGroups(value.dialerProxyGroups); if (!dialerProxyGroups.ok) return dialerProxyGroups; - const moduleRuleOverrides = parseModuleRuleOverrides(value.moduleRuleOverrides); - if (!moduleRuleOverrides.ok) return moduleRuleOverrides; - const moduleRuleExclusions = parseModuleRuleExclusions(value.moduleRuleExclusions); - if (!moduleRuleExclusions.ok) return moduleRuleExclusions; const proxyGroupNameOverrides = parseStringRecord(value.proxyGroupNameOverrides, "proxyGroupNameOverrides"); if (!proxyGroupNameOverrides.ok) return proxyGroupNameOverrides; const ruleOrder = parseOptionalStringArray(value.ruleOrder, "ruleOrder"); @@ -80,8 +87,6 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult if (!testInterval.ok) return testInterval; const ruleProviderBaseUrl = parseHttpUrlString(value.ruleProviderBaseUrl, "ruleProviderBaseUrl"); if (!ruleProviderBaseUrl.ok) return ruleProviderBaseUrl; - const allRulesOrderEditingEnabled = parseOptionalBoolean(value.allRulesOrderEditingEnabled, "allRulesOrderEditingEnabled"); - if (!allRulesOrderEditingEnabled.ok) return allRulesOrderEditingEnabled; const cnIpNoResolve = parseOptionalBoolean(value.cnIpNoResolve, "cnIpNoResolve"); if (!cnIpNoResolve.ok) return cnIpNoResolve; const experimentalCnUseCnRuleSet = parseOptionalBoolean( @@ -93,9 +98,8 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult const normalizedRuleOrder = normalizePersistedRuleOrder({ enabledModules: enabledProxyGroups.value.filter((id) => !hiddenSet.has(id)), customRules: customRules.value, - customProxyGroups: customProxyGroups.value, - moduleRuleOverrides: moduleRuleOverrides.value, - moduleRuleExclusions: moduleRuleExclusions.value, + customRuleSets: ruleModel.customRuleSets, + builtinRuleEdits: ruleModel.builtinRuleEdits, proxyGroupNameOverrides: proxyGroupNameOverrides.value, experimentalCnUseCnRuleSet: experimentalCnUseCnRuleSet.value, cnIpNoResolve: cnIpNoResolve.value, @@ -111,13 +115,10 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult hiddenProxyGroups: hiddenProxyGroups.value, customProxyGroups: customProxyGroups.value, filteredProxyGroups: filteredProxyGroups.value, - moduleRuleOverrides: moduleRuleOverrides.value, - moduleRuleExclusions: moduleRuleExclusions.value, + customRuleSets: ruleModel.customRuleSets, + builtinRuleEdits: ruleModel.builtinRuleEdits, customRules: customRules.value, ruleOrder: normalizedRuleOrder, - ...(allRulesOrderEditingEnabled.value !== undefined - ? { allRulesOrderEditingEnabled: allRulesOrderEditingEnabled.value } - : {}), ...(cnIpNoResolve.value !== undefined ? { cnIpNoResolve: cnIpNoResolve.value } : {}), ...(experimentalCnUseCnRuleSet.value !== undefined ? { experimentalCnUseCnRuleSet: experimentalCnUseCnRuleSet.value } @@ -295,8 +296,6 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG if (!emoji.ok) return emoji; const groupType = parseFilteredGroupType(item.groupType, "customProxyGroups.groupType"); if (!groupType.ok) return groupType; - const rules = parseCustomProxyGroupRules(item.rules); - if (!rules.ok) return rules; const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "customProxyGroups.strategy"); if (!strategy.ok) return strategy; out.push({ @@ -307,38 +306,48 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG ...(groupType.value === "load-balance" ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } : {}), - rules: rules.value, }); } return { ok: true, value: out }; } -function parseCustomProxyGroupRules( - value: unknown -): { ok: true; value: CustomProxyGroup["rules"] } | { ok: false; error: string } { - if (!Array.isArray(value)) return invalid("customProxyGroups.rules 必须是数组"); - const out: CustomProxyGroup["rules"] = []; +function parseCustomRuleSets(value: unknown): { ok: true; value: true } | { ok: false; error: string } { + if (value === undefined) return { ok: true, value: true }; + if (!Array.isArray(value)) return invalid("customRuleSets 必须是数组"); for (const item of value) { - if (!isRecord(item)) return invalid("customProxyGroups.rules 只能包含对象"); - const id = parseRequiredString(item.id, "customProxyGroups.rules.id"); + if (!isRecord(item)) return invalid("customRuleSets 只能包含对象"); + const id = parseRequiredString(item.id, "customRuleSets.id"); if (!id.ok) return id; - const name = parseRequiredString(item.name, "customProxyGroups.rules.name"); + const name = parseRequiredString(item.name, "customRuleSets.name"); if (!name.ok) return name; - const behavior = parseRuleBehavior(item.behavior, "customProxyGroups.rules.behavior"); - if (!behavior.ok) return behavior; - const url = parseHttpUrlString(item.url, "customProxyGroups.rules.url"); - if (!url.ok) return url; - const noResolve = parseOptionalBoolean(item.noResolve, "customProxyGroups.rules.noResolve"); + if (item.behavior !== "domain" && item.behavior !== "ipcidr") return invalid("customRuleSets.behavior 无效"); + const path = parseRequiredString(item.path, "customRuleSets.path"); + if (!path.ok) return path; + const normalizedPath = normalizeRuleSetPathInput(path.value); + if (!isValidRuleSetPathOrUrl(normalizedPath)) return invalid("customRuleSets.path 无效"); + const target = parseRequiredString(item.target, "customRuleSets.target"); + if (!target.ok) return target; + const noResolve = parseOptionalBoolean(item.noResolve, "customRuleSets.noResolve"); if (!noResolve.ok) return noResolve; - out.push({ - id: id.value, - name: name.value, - behavior: behavior.value, - url: url.value, - ...(noResolve.value !== undefined ? { noResolve: noResolve.value } : {}), - }); } - return { ok: true, value: out }; + return { ok: true, value: true }; +} + +function parseBuiltinRuleEdits(value: unknown): { ok: true; value: true } | { ok: false; error: string } { + if (value === undefined) return { ok: true, value: true }; + if (!isRecord(value)) return invalid("builtinRuleEdits 必须是对象"); + for (const [rawKey, rawEdit] of Object.entries(value)) { + const key = rawKey.trim(); + if (!BUILTIN_RULE_KEYS.has(key)) return invalid("builtinRuleEdits 包含未知内置规则"); + if (!isRecord(rawEdit)) return invalid("builtinRuleEdits 的值必须是对象"); + if ("target" in rawEdit && typeof rawEdit.target !== "string") { + return invalid("builtinRuleEdits.target 必须是字符串"); + } + if ("enabled" in rawEdit && rawEdit.enabled !== false) { + return invalid("builtinRuleEdits.enabled 只能是 false"); + } + } + return { ok: true, value: true }; } function parseFilteredProxyGroups(value: unknown): { ok: true; value: FilteredProxyGroup[] } | { ok: false; error: string } { @@ -414,63 +423,6 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG return { ok: true, value: out }; } -function parseModuleRuleOverrides( - value: unknown -): { ok: true; value: Record } | { ok: false; error: string } { - if (value === undefined) return { ok: true, value: {} }; - if (!isRecord(value)) return invalid("moduleRuleOverrides 必须是对象"); - const out: Record = {}; - for (const [moduleId, rawRules] of Object.entries(value)) { - if (!BUILTIN_MODULE_IDS.has(moduleId)) return invalid("moduleRuleOverrides 包含未知代理组"); - if (!Array.isArray(rawRules)) return invalid("moduleRuleOverrides 的值必须是数组"); - const rules: ModuleRuleOverride[] = []; - for (const item of rawRules) { - if (!isRecord(item)) return invalid("moduleRuleOverrides 只能包含对象"); - const id = parseRequiredString(item.id, "moduleRuleOverrides.id"); - if (!id.ok) return id; - const name = parseRequiredString(item.name, "moduleRuleOverrides.name"); - if (!name.ok) return name; - const behavior = parseRuleBehavior(item.behavior, "moduleRuleOverrides.behavior"); - if (!behavior.ok) return behavior; - const path = parseRequiredString(item.path, "moduleRuleOverrides.path"); - if (!path.ok) return path; - if (!RULE_PATH_RE.test(path.value)) return invalid("moduleRuleOverrides.path 无效"); - const noResolve = parseOptionalBoolean(item.noResolve, "moduleRuleOverrides.noResolve"); - if (!noResolve.ok) return noResolve; - rules.push({ - id: id.value, - name: name.value, - behavior: behavior.value, - path: path.value, - ...(noResolve.value !== undefined ? { noResolve: noResolve.value } : {}), - }); - } - out[moduleId] = rules; - } - return { ok: true, value: out }; -} - -function parseModuleRuleExclusions( - value: unknown -): { ok: true; value: ModuleRuleExclusions } | { ok: false; error: string } { - if (value === undefined) return { ok: true, value: {} }; - if (!isRecord(value)) return invalid("moduleRuleExclusions 必须是对象"); - for (const [moduleId, ruleIds] of Object.entries(value)) { - if (!BUILTIN_MODULE_IDS.has(moduleId)) return invalid("moduleRuleExclusions 包含未知代理组"); - const parsed = parseStringArray(ruleIds, "moduleRuleExclusions"); - if (!parsed.ok) return parsed; - } - return { ok: true, value: normalizeModuleRuleExclusions(value) }; -} - -function parseRuleBehavior( - value: unknown, - field: string -): { ok: true; value: "domain" | "ipcidr" } | { ok: false; error: string } { - if (value === "domain" || value === "ipcidr") return { ok: true, value }; - return invalid(`${field} 无效`); -} - function parseFilteredGroupType( value: unknown, field: string diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 3cb2477..76c485e 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -195,6 +195,24 @@ export interface CustomRule { noResolve?: boolean; } +export type RuleSetBehavior = "domain" | "ipcidr"; + +export interface CustomRuleSet { + id: string; + name: string; + behavior: RuleSetBehavior; + path: string; + target: string; + noResolve?: boolean; +} + +export type BuiltinRuleEdit = { + target?: string; + enabled?: false; +}; + +export type BuiltinRuleEdits = Record; + /** * 自定义代理组 */ @@ -204,13 +222,6 @@ export interface CustomProxyGroup { emoji: string; groupType: "select" | "url-test" | "fallback" | "load-balance" | "direct-first" | "reject-first"; strategy?: LoadBalanceStrategy; - rules: { - id: string; - name: string; - behavior: "domain" | "ipcidr"; - url: string; - noResolve?: boolean; - }[]; } /** @@ -226,4 +237,3 @@ export interface TemplateConfig { rules: string[]; dns: Partial; } - diff --git a/packages/core/src/types/template-config.ts b/packages/core/src/types/template-config.ts index 2b110e6..7f6295d 100644 --- a/packages/core/src/types/template-config.ts +++ b/packages/core/src/types/template-config.ts @@ -1,5 +1,4 @@ -import type { ModuleRuleExclusions } from "../generator/module-rules"; -import type { CustomProxyGroup, CustomRule, TemplateType } from "./config"; +import type { BuiltinRuleEdits, CustomProxyGroup, CustomRule, CustomRuleSet, TemplateType } from "./config"; import type { FilteredProxyGroup } from "./filtered-proxy-group"; // 中转代理组(使用 dialer-proxy 语法) @@ -12,18 +11,6 @@ export interface DialerProxyGroup { targetNodes: string[]; // 使用此中转的落地节点名称列表 } -/** - * 给“内置代理组(模块)”附加的规则集(Rule Provider)。 - * 用于把规则库搜索到的规则直接挂到任意内置代理组上。 - */ -export interface ModuleRuleOverride { - id: string; - name: string; - behavior: "domain" | "ipcidr"; - path: string; - noResolve?: boolean; -} - export type SubBoostTemplateConfig = { schema?: "subboost-template-config/v1"; template: TemplateType; @@ -31,11 +18,10 @@ export type SubBoostTemplateConfig = { hiddenProxyGroups?: string[]; customProxyGroups: CustomProxyGroup[]; filteredProxyGroups?: FilteredProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + customRuleSets: CustomRuleSet[]; + builtinRuleEdits?: BuiltinRuleEdits; customRules: CustomRule[]; ruleOrder?: string[]; - allRulesOrderEditingEnabled?: boolean; cnIpNoResolve?: boolean; experimentalCnUseCnRuleSet?: boolean; dialerProxyGroups: DialerProxyGroup[]; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts index 6cb4518..a6c31d1 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts @@ -114,10 +114,10 @@ vi.mock("@subboost/ui/components/ui/switch", () => ({ })); vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ - PROXY_GROUP_MODULES: [ - { id: "auto", name: "Auto" }, - { id: "fallback", name: "Fallback" }, - ], + PROXY_GROUP_MODULES: [ + { id: "auto", name: "Auto", rules: [] }, + { id: "fallback", name: "Fallback", rules: [] }, + ], })); vi.mock("@subboost/core/generator/module-rules", () => ({ getEffectiveModuleRules: vi.fn(() => mocks.effectiveRules), @@ -153,23 +153,23 @@ import { } from "./proxy-groups-rule-editor-layout"; const moduleItem = { - key: "module:auto:rule-a", + key: "custom-rule-set:rule-a", id: "rule-a", name: "Rule A", behavior: "domain", path: "geosite/rule-a.mrs", noResolve: true, - source: { kind: "module", id: "auto" }, + source: { kind: "custom-rule-set", id: "rule-a" }, target: { kind: "module", id: "auto", value: "module:auto", name: "Auto" }, }; const customItem = { - key: "custom:custom-1:rule-b", + key: "custom-rule-set:rule-b", id: "rule-b", name: "Rule B", behavior: "ipcidr", path: "geoip/rule-b.mrs", - source: { kind: "custom", id: "custom-1" }, + source: { kind: "custom-rule-set", id: "rule-b" }, target: { kind: "custom", id: "custom-1", @@ -228,22 +228,17 @@ describe("ProxyGroupsAddedRuleSets", () => { ruleProviderBaseUrl: "https://rules.example/", enabledProxyGroups: ["auto"], hiddenProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [ + { id: "rule-a", name: "Rule A", behavior: "domain", path: "geosite/rule-a.mrs", target: "Auto", noResolve: true }, + { id: "rule-b", name: "Rule B", behavior: "ipcidr", path: "geoip/rule-b.mrs", target: "Custom" }, + ], + builtinRuleEdits: {}, customProxyGroups: [ { id: "custom-1", name: "Custom", - rules: [ - { - id: "rule-b", - name: "Rule B", - behavior: "ipcidr", - url: "https://rules.example/geoip/rule-b.mrs", - }, - ], }, - { id: "custom-2", name: "Target", rules: [] }, + { id: "custom-2", name: "Target" }, ], proxyGroupNameOverrides: { auto: "Auto" }, toggleProxyGroup: vi.fn(), @@ -382,19 +377,12 @@ describe("ProxyGroupsAddedRuleSets", () => { kind: "custom", id: "custom-2", }); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith( - "custom-2", - { - rules: [ - { - id: "rule-a", - name: "Rule A", - behavior: "domain", - url: "https://rules.example/geosite/rule-a.mrs", - }, - ], - }, - ); + expect(mocks.store.updateModuleRule).toHaveBeenCalledWith("custom-2", "rule-a", { + id: "rule-a", + name: "Rule A", + behavior: "domain", + path: "geosite/rule-a.mrs", + }); }); it("saves custom rule edits and enables target modules when needed", () => { @@ -409,23 +397,16 @@ describe("ProxyGroupsAddedRuleSets", () => { mocks.captures.buttons .find((props: any) => props.title === "保存规则集") .onClick(); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith( - "custom-1", - { rules: [] }, - ); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith( - "custom-2", - { - rules: [ - { - id: "rule-b", - name: "Rule B", - behavior: "ipcidr", - url: "https://rules.example/geoip/rule-b.mrs", - }, - ], - }, - ); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("custom-1", "rule-b", { + kind: "custom", + id: "custom-2", + }); + expect(mocks.store.updateModuleRule).toHaveBeenCalledWith("custom-2", "rule-b", { + id: "rule-b", + name: "Rule B", + behavior: "ipcidr", + path: "geoip/rule-b.mrs", + }); mocks.store.enabledProxyGroups = []; renderAdded({ @@ -440,14 +421,16 @@ describe("ProxyGroupsAddedRuleSets", () => { .find((props: any) => props.title === "保存规则集") .onClick(); expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("fallback"); - expect(mocks.store.addModuleRules).toHaveBeenCalledWith("fallback", [ - { - id: "rule-b", - name: "Rule B", - behavior: "ipcidr", - path: "geoip/rule-b.mrs", - }, - ]); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("custom-1", "rule-b", { + kind: "module", + id: "fallback", + }); + expect(mocks.store.updateModuleRule).toHaveBeenCalledWith("fallback", "rule-b", { + id: "rule-b", + name: "Rule B", + behavior: "ipcidr", + path: "geoip/rule-b.mrs", + }); renderAdded({ 0: customItem.key, @@ -460,24 +443,20 @@ describe("ProxyGroupsAddedRuleSets", () => { mocks.captures.buttons .find((props: any) => props.title === "保存规则集") .onClick(); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith( - "custom-1", - { - rules: [ - { - id: "rule-b", - name: "Rule B", - behavior: "ipcidr", - url: "https://rules.example/geoip/rule-b.mrs", - noResolve: true, - }, - ], - }, - ); + expect(mocks.store.updateModuleRule).toHaveBeenCalledWith("custom-1", "rule-b", { + id: "rule-b", + name: "Rule B", + behavior: "ipcidr", + path: "geoip/rule-b.mrs", + noResolve: true, + }); }); it("rejects conflicts, removes items, and cancels editing", () => { - mocks.effectiveRules = [{ id: "rule-a" }]; + mocks.store.customRuleSets = [ + ...mocks.store.customRuleSets, + { id: "rule-a", name: "Rule A", behavior: "domain", path: "geosite/rule-a.mrs", target: "Fallback" }, + ]; renderAdded({ 0: moduleItem.key, 1: { @@ -519,10 +498,7 @@ describe("ProxyGroupsAddedRuleSets", () => { .onClick(); expect(stateMock.setters[0]).toHaveBeenCalledWith(null); findEditingDeleteButton().onClick(); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith( - "custom-1", - { rules: [] }, - ); + expect(mocks.store.removeModuleRule).toHaveBeenCalledWith("custom-1", "rule-b"); }); it("starts editing from row buttons and clears stale editing state", () => { diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx index 2fe055b..f9bb5e0 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx @@ -13,10 +13,9 @@ import { import { Switch } from "@subboost/ui/components/ui/switch"; import { toast } from "@subboost/ui/components/ui/toaster"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; -import { getEffectiveModuleRules } from "@subboost/core/generator/module-rules"; +import { getModuleRuleOrderKey } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { - buildRuleSetUrlFromPath, collectCustomRoutingRuleSets, getRuleSetTargetValue, normalizeRuleSetPathInput, @@ -65,19 +64,17 @@ export function ProxyGroupsAddedRuleSets({ totalRules: number | null; }) { const { - ruleProviderBaseUrl, enabledProxyGroups, hiddenProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, - customProxyGroups, - proxyGroupNameOverrides, + customRuleSets = [], + builtinRuleEdits = {}, + customProxyGroups = [], + proxyGroupNameOverrides = {}, toggleProxyGroup, addModuleRules, updateModuleRule, removeModuleRule, moveModuleRule, - updateCustomProxyGroup, } = useConfigStore(); const [editingKey, setEditingKey] = React.useState(null); @@ -86,11 +83,11 @@ export function ProxyGroupsAddedRuleSets({ const addedRuleSets = React.useMemo( () => collectCustomRoutingRuleSets({ + customRuleSets, customProxyGroups, - moduleRuleOverrides, proxyGroupNameOverrides, }), - [customProxyGroups, moduleRuleOverrides, proxyGroupNameOverrides], + [customProxyGroups, customRuleSets, proxyGroupNameOverrides], ); const visibleProxyGroupModules = React.useMemo(() => { const hidden = new Set(hiddenProxyGroups); @@ -99,7 +96,7 @@ export function ProxyGroupsAddedRuleSets({ const visibleAddedRuleSets = React.useMemo(() => { const hidden = new Set(hiddenProxyGroups); return addedRuleSets.filter( - (item) => !(item.source.kind === "module" && hidden.has(item.source.id)), + (item) => !(item.target.kind === "module" && hidden.has(item.target.id)), ); }, [addedRuleSets, hiddenProxyGroups]); @@ -127,44 +124,6 @@ export function ProxyGroupsAddedRuleSets({ setDraft(null); }, [editingKey, visibleAddedRuleSets]); - const updateCustomGroupRule = React.useCallback( - ( - groupId: string, - ruleId: string, - nextRule: CustomRoutingRuleSetItem, - mode: "upsert" | "remove", - ) => { - const group = useConfigStore - .getState() - .customProxyGroups.find((item) => item.id === groupId); - if (!group) return; - - const nextRules = - mode === "remove" - ? group.rules.filter((rule) => rule.id !== ruleId) - : (() => { - const rule = { - id: nextRule.id, - name: nextRule.name, - behavior: nextRule.behavior, - url: buildRuleSetUrlFromPath( - nextRule.path, - ruleProviderBaseUrl, - ), - ...(nextRule.noResolve ? { noResolve: true } : {}), - }; - const exists = group.rules.some((item) => item.id === ruleId); - if (!exists) return [...group.rules, rule]; - return group.rules.map((item) => - item.id === ruleId ? rule : item, - ); - })(); - - updateCustomProxyGroup(groupId, { rules: nextRules }); - }, - [ruleProviderBaseUrl, updateCustomProxyGroup], - ); - const hasConflict = React.useCallback( ( item: CustomRoutingRuleSetItem, @@ -175,29 +134,35 @@ export function ProxyGroupsAddedRuleSets({ (entry) => entry.id === target.id, ); if (!proxyModule) return true; - return getEffectiveModuleRules( + const moduleName = resolveProxyGroupModuleName( proxyModule, - moduleRuleOverrides, - moduleRuleExclusions, - ).some( - (rule) => - rule.id === item.id && - !(item.source.kind === "module" && item.source.id === target.id), + proxyGroupNameOverrides?.[proxyModule.id], + ); + const builtinConflict = (proxyModule.rules ?? []).some((rule) => { + if (rule.id !== item.id) return false; + const edit = builtinRuleEdits?.[getModuleRuleOrderKey(proxyModule.id, rule.id)]; + return edit?.enabled !== false && (edit?.target || moduleName) === moduleName; + }); + const customConflict = customRuleSets.some( + (ruleSet) => ruleSet.id === item.id && ruleSet.target === moduleName && item.target.value !== getRuleSetTargetValue(target) ); + return builtinConflict || customConflict; } const group = customProxyGroups.find((entry) => entry.id === target.id); if (!group) return true; - return group.rules.some( - (rule) => - rule.id === item.id && - !(item.source.kind === "custom" && item.source.id === target.id), + return customRuleSets.some( + (ruleSet) => + ruleSet.id === item.id && + ruleSet.target === group.name && + item.target.value !== getRuleSetTargetValue(target), ); }, [ + builtinRuleEdits, customProxyGroups, - moduleRuleExclusions, - moduleRuleOverrides, + customRuleSets, + proxyGroupNameOverrides, visibleProxyGroupModules, ], ); @@ -213,11 +178,7 @@ export function ProxyGroupsAddedRuleSets({ }; const removeRuleSet = (item: CustomRoutingRuleSetItem) => { - if (item.source.kind === "module") { - removeModuleRule(item.source.id, item.id); - } else { - updateCustomGroupRule(item.source.id, item.id, item, "remove"); - } + removeModuleRule(item.target.id, item.id); if (editingKey === item.key) cancelEditing(); }; @@ -225,7 +186,7 @@ export function ProxyGroupsAddedRuleSets({ if (!draft) return; const target = parseRuleSetTargetValue(draft.targetValue); - const path = normalizeRuleSetPathInput(item.path); + const path = normalizeRuleSetPathInput(draft.path); if (!target || !path) return; if (hasConflict(item, target)) { @@ -237,19 +198,6 @@ export function ProxyGroupsAddedRuleSets({ return; } - const nextItem: CustomRoutingRuleSetItem = { - ...item, - path, - noResolve: draft.noResolve, - target: { - kind: target.kind, - id: target.id, - value: getRuleSetTargetValue(target), - name: - targetOptions.find((option) => option.value === draft.targetValue) - ?.label || item.target.name, - }, - }; const nextModuleRule: ModuleRuleOverride = { id: item.id, name: item.name, @@ -258,27 +206,12 @@ export function ProxyGroupsAddedRuleSets({ ...(draft.noResolve ? { noResolve: true } : {}), }; - if (item.source.kind === "module") { - if (target.kind === "module") { - if (item.source.id === target.id) { - updateModuleRule(item.source.id, item.id, nextModuleRule); - } else { - moveModuleRule(item.source.id, item.id, target); - updateModuleRule(target.id, item.id, nextModuleRule); - } - } else { - moveModuleRule(item.source.id, item.id, target); - updateCustomGroupRule(target.id, item.id, nextItem, "upsert"); - } - } else if (target.kind === "custom") { - if (item.source.id !== target.id) { - updateCustomGroupRule(item.source.id, item.id, item, "remove"); - } - updateCustomGroupRule(target.id, item.id, nextItem, "upsert"); + if (item.target.id === target.id && item.target.kind === target.kind) { + updateModuleRule(item.target.id, item.id, nextModuleRule); } else { - updateCustomGroupRule(item.source.id, item.id, item, "remove"); - if (!enabledProxyGroups.includes(target.id)) toggleProxyGroup(target.id); - addModuleRules(target.id, [nextModuleRule]); + if (target.kind === "module" && !enabledProxyGroups.includes(target.id)) toggleProxyGroup(target.id); + moveModuleRule(item.target.id, item.id, target); + updateModuleRule(target.id, item.id, nextModuleRule); } cancelEditing(); 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 eb2df83..0bfff8e 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 @@ -183,8 +183,8 @@ describe("ProxyGroupsCategories", () => { toggleProxyGroup: vi.fn(), hideProxyGroup: vi.fn(), restoreHiddenProxyGroup: vi.fn(), - moduleRuleOverrides: { auto: [{ id: "extra-1" }] }, - moduleRuleExclusions: {}, + customRuleSets: [{ id: "extra-1", name: "Extra", behavior: "domain", path: "geosite/extra-1.mrs", target: "Auto Override" }], + builtinRuleEdits: {}, moduleRuleEditWarningAccepted: false, customRules: [{ id: "manual-1", target: "Auto Override" }], updateCustomRule: vi.fn(), @@ -198,7 +198,7 @@ describe("ProxyGroupsCategories", () => { proxyGroupNameOverrides: { auto: "Auto Override" }, setProxyGroupNameOverride: vi.fn(), clearProxyGroupNameOverride: vi.fn(), - customProxyGroups: [{ id: "custom-1", name: "Custom", rules: [] }], + customProxyGroups: [{ id: "custom-1", name: "Custom" }], filteredProxyGroups: [{ name: "Filtered", enabled: true }], dialerProxyGroups: [{ name: "Dialer" }], }; @@ -297,25 +297,15 @@ describe("ProxyGroupsCategories", () => { const customRule = { id: "geo", name: "Geo", behavior: "domain", path: "geo.txt", noResolve: true }; mocks.captures.moduleCards[0].onAddRuleToCustomGroup("custom-1", customRule); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { - rules: [ - { - id: "geo", - name: "Geo", - behavior: "domain", - url: "https://rules.example/base/geo.txt", - noResolve: true, - }, - ], - }); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [customRule]); - mocks.store.customProxyGroups = [{ id: "custom-1", name: "Custom", rules: [{ id: "geo" }] }]; - mocks.store.updateCustomProxyGroup.mockClear(); + mocks.store.customRuleSets = [{ id: "geo", name: "Geo", behavior: "domain", path: "geo.txt", target: "Custom" }]; + mocks.store.addModuleRules.mockClear(); renderCategories(); mocks.captures.moduleCards[0].onAddRuleToCustomGroup("custom-1", customRule); - expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [customRule]); - mocks.store.customProxyGroups = [{ id: "custom-1", name: "Custom", rules: [] }]; + mocks.store.customProxyGroups = [{ id: "custom-1", name: "Custom" }]; renderCategories(); mocks.captures.moduleCards[0].onAddRuleToCustomGroup("missing", customRule); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); @@ -327,16 +317,15 @@ describe("ProxyGroupsCategories", () => { path: "/geo-off.txt", noResolve: false, }); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { - rules: [ - { - id: "geo-no-resolve-off", - name: "Geo Off", - behavior: "domain", - url: "https://rules.example/base//geo-off.txt", - }, - ], - }); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [ + { + id: "geo-no-resolve-off", + name: "Geo Off", + behavior: "domain", + path: "/geo-off.txt", + noResolve: false, + }, + ]); }); it("renders custom category and disabled non-core module branches", async () => { 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 7319596..3f30897 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 @@ -16,8 +16,10 @@ import { CATEGORY_INFO, PROXY_GROUP_MODULES, } from "@subboost/core/generator/proxy-groups"; +import type { ModuleRuleExclusions as HiddenPresetRuleIds } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import { useConfigStore } from "@subboost/ui/store/config-store"; +import { buildRuleSetUrlFromPath } from "@subboost/core/rules/rule-model"; +import { useConfigStore, type ModuleRuleOverride } from "@subboost/ui/store/config-store"; import { buildManualRuleTargets, listCustomRulesForTarget, @@ -38,24 +40,24 @@ export function ProxyGroupsCategories() { toggleProxyGroup, hideProxyGroup, restoreHiddenProxyGroup, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets = [], + builtinRuleEdits = {}, moduleRuleEditWarningAccepted, - customRules, + customRules = [], updateCustomRule, removeCustomRule, addModuleRules, removeModuleRule, moveModuleRule, restoreModuleRule, - updateCustomProxyGroup, + resetModuleRuleTarget, acceptModuleRuleEditWarning, - proxyGroupNameOverrides, + proxyGroupNameOverrides = {}, setProxyGroupNameOverride, clearProxyGroupNameOverride, - customProxyGroups, - filteredProxyGroups, - dialerProxyGroups, + customProxyGroups = [], + filteredProxyGroups = [], + dialerProxyGroups = [], } = useConfigStore(); const [expandedCategories, setExpandedCategories] = React.useState< @@ -132,6 +134,79 @@ export function ProxyGroupsCategories() { } return grouped; }, [hiddenProxyGroups]); + const targetRuleView = React.useMemo(() => { + const ruleSetsByTarget: Record = {}; + const hiddenPresetRuleIds: HiddenPresetRuleIds = {}; + const customGroupsWithRules = customProxyGroups.map((group) => ({ + ...group, + rules: customRuleSets + .filter((ruleSet) => ruleSet.target === group.name) + .map((ruleSet) => ({ + id: ruleSet.id, + name: ruleSet.name, + behavior: ruleSet.behavior, + url: buildRuleSetUrlFromPath(ruleSet.path, ruleProviderBaseUrl), + ...(ruleSet.noResolve ? { noResolve: true } : {}), + })), + })); + const moduleNameToId = new Map(); + for (const proxyModule of PROXY_GROUP_MODULES) { + moduleNameToId.set(resolveModuleDisplayName(proxyModule).full, proxyModule.id); + } + + const pushRuleSetForTarget = (moduleId: string, rule: ModuleRuleOverride) => { + ruleSetsByTarget[moduleId] = [...(ruleSetsByTarget[moduleId] || []), rule]; + }; + const hidePresetRule = (moduleId: string, ruleId: string) => { + const prev = hiddenPresetRuleIds[moduleId] || []; + if (!prev.includes(ruleId)) hiddenPresetRuleIds[moduleId] = [...prev, ruleId]; + }; + + for (const ruleSet of customRuleSets) { + const moduleId = moduleNameToId.get(ruleSet.target); + if (!moduleId) continue; + pushRuleSetForTarget(moduleId, { + id: ruleSet.id, + name: ruleSet.name, + behavior: ruleSet.behavior, + path: ruleSet.path, + ...(ruleSet.noResolve ? { noResolve: true } : {}), + }); + } + + for (const [key, edit] of Object.entries(builtinRuleEdits || {})) { + const match = key.match(/^module:([^:]+):(.+)$/); + if (!match) continue; + const [, sourceModuleId, ruleId] = match; + const sourceModule = PROXY_GROUP_MODULES.find((module) => module.id === sourceModuleId); + const sourceRule = sourceModule?.rules?.find((rule) => rule.id === ruleId); + if (!sourceModule || !sourceRule) continue; + const defaultTarget = resolveModuleDisplayName(sourceModule).full; + + if (edit.enabled === false) hidePresetRule(sourceModuleId, ruleId); + if (edit.target && edit.target !== defaultTarget) { + hidePresetRule(sourceModuleId, ruleId); + const targetModuleId = moduleNameToId.get(edit.target); + if (targetModuleId) { + pushRuleSetForTarget(targetModuleId, { + id: sourceRule.id, + name: sourceRule.name, + behavior: sourceRule.behavior, + path: sourceRule.path, + ...(sourceRule.noResolve ? { noResolve: true } : {}), + }); + } + } + } + + return { ruleSetsByTarget, hiddenPresetRuleIds, customGroupsWithRules }; + }, [ + builtinRuleEdits, + customProxyGroups, + customRuleSets, + resolveModuleDisplayName, + ruleProviderBaseUrl, + ]); const hiddenModules = React.useMemo(() => { const hidden = new Set(hiddenProxyGroups); @@ -251,7 +326,7 @@ export function ProxyGroupsCategories() { ); const isEditing = editingModuleId === module.id; const extraRules = - moduleRuleOverrides?.[module.id] || []; + targetRuleView.ruleSetsByTarget?.[module.id] || []; const manualRules = listCustomRulesForTarget( customRules, display.full, @@ -382,9 +457,9 @@ export function ProxyGroupsCategories() { onCommitEditing={commitRename} onHide={handleHideModule} extraRules={extraRules} - moduleRuleOverrides={moduleRuleOverrides} - moduleRuleExclusions={moduleRuleExclusions} - customProxyGroups={customProxyGroups} + ruleSetsByTarget={targetRuleView.ruleSetsByTarget} + hiddenPresetRuleIds={targetRuleView.hiddenPresetRuleIds} + customProxyGroups={targetRuleView.customGroupsWithRules} manualRules={manualRules} manualRuleTargets={manualRuleTargets} enabledProxyGroups={enabledProxyGroups} @@ -410,32 +485,7 @@ export function ProxyGroupsCategories() { } }} onAddRuleToCustomGroup={(groupId, rule) => { - const targetGroup = customProxyGroups.find( - (group) => group.id === groupId, - ); - if (!targetGroup) return; - if ( - targetGroup.rules.some( - (item) => item.id === rule.id, - ) - ) { - return; - } - - updateCustomProxyGroup(groupId, { - rules: [ - ...targetGroup.rules, - { - id: rule.id, - name: rule.name, - behavior: rule.behavior, - url: `${ruleProviderBaseUrl.replace(/\/+$/, "")}/${rule.path}`, - ...(rule.noResolve - ? { noResolve: true } - : {}), - }, - ], - }); + addModuleRules(groupId, [rule]); }} onRemoveExtraRule={(ruleId) => removeModuleRule(module.id, ruleId) @@ -450,6 +500,9 @@ export function ProxyGroupsCategories() { onRestoreRule={(ruleId) => restoreModuleRule(module.id, ruleId) } + onResetRuleTarget={(ruleId) => + resetModuleRuleTarget(module.id, ruleId) + } cnIpNoResolve={cnIpNoResolve} onChangeCnIpNoResolve={setCnIpNoResolve} experimentalCnUseCnRuleSet={ 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 b332794..a73e403 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 @@ -122,7 +122,8 @@ const sourceRule = { id: "rule-a", name: "Rule A", behavior: "domain", - url: "https://rules.example/geosite/rule-a.mrs", + path: "geosite/rule-a.mrs", + target: "🧩 Custom", noResolve: true, }; @@ -131,7 +132,6 @@ const customGroup = { name: "🧩 Custom", emoji: "🧩", groupType: "select", - rules: [sourceRule], }; const targetGroup = { @@ -139,7 +139,6 @@ const targetGroup = { name: "Target", emoji: "🧩", groupType: "select", - rules: [], }; function renderPanel(overrides: Record = {}) { @@ -172,11 +171,14 @@ describe("ProxyGroupsCustomGroupsPanel", () => { proxyGroupNameOverrides: { auto: "Auto" }, customRules: [{ id: "manual-1", target: "🧩 Custom" }], customProxyGroups: [customGroup, targetGroup], + customRuleSets: [sourceRule], filteredProxyGroups: [{ name: "Filtered", enabled: true }], dialerProxyGroups: [{ name: "Dialer" }], addCustomProxyGroup: vi.fn(), removeCustomProxyGroup: vi.fn(), updateCustomProxyGroup: vi.fn(), + moveModuleRule: vi.fn(), + removeModuleRule: vi.fn(), updateCustomRule: vi.fn(), removeCustomRule: vi.fn(), toggleProxyGroup: vi.fn(), @@ -200,7 +202,6 @@ describe("ProxyGroupsCustomGroupsPanel", () => { name: "🧩 New", emoji: "🧩", groupType: "select", - rules: [], }); expect(mocks.interactions.proxyGroupAdded).toHaveBeenCalledWith({ groupType: "select" }); expect(setters[1]).toHaveBeenCalledWith({ emoji: "🧩", name: "" }); @@ -236,25 +237,29 @@ describe("ProxyGroupsCustomGroupsPanel", () => { renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-2", name: "Target" }); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { rules: [] }); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-2", { rules: [sourceRule] }); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("custom-1", "rule-a", { + kind: "custom", + id: "custom-2", + name: "Target", + }); - mocks.store.updateCustomProxyGroup.mockClear(); - mocks.store.customProxyGroups = [customGroup, { ...targetGroup, rules: [{ id: "rule-a" }] }]; + mocks.store.moveModuleRule.mockClear(); + mocks.store.customRuleSets = [sourceRule, { ...sourceRule, target: "Target" }]; renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-2", name: "Target" }); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "规则集已存在", variant: "warning" })); - expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.store.moveModuleRule).not.toHaveBeenCalled(); mocks.store.customProxyGroups = [customGroup, targetGroup]; + mocks.store.customRuleSets = [sourceRule]; mocks.store.enabledProxyGroups = []; renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.moveMenus[0].onMove({ kind: "module", id: "fallback", name: "Fallback" }); - expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("fallback"); - expect(mocks.store.addModuleRules).toHaveBeenCalledWith("fallback", [ - { id: "rule-a", name: "Rule A", behavior: "domain", path: "geosite/rule-a.mrs", noResolve: true }, - ]); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { rules: [] }); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("custom-1", "rule-a", { + kind: "module", + id: "fallback", + name: "Fallback", + }); }); it("updates manual rules, deletes rule rows, and renders empty state", () => { @@ -266,7 +271,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { expect(mocks.store.removeCustomRule).toHaveBeenCalledWith(0); mocks.captures.buttons.find((props: any) => props["aria-label"] === "删除 Rule A 规则集").onClick(); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { rules: [] }); + expect(mocks.store.removeModuleRule).toHaveBeenCalledWith("custom-1", "rule-a"); mocks.store.customProxyGroups = []; renderPanel(); @@ -313,29 +318,34 @@ describe("ProxyGroupsCustomGroupsPanel", () => { it("handles custom rule-set move no-op and missing-target paths", () => { renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-1", name: "🧩 Custom" }); - expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.store.moveModuleRule).not.toHaveBeenCalled(); mocks.store.customProxyGroups = []; mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-2", name: "Target" }); - expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.store.moveModuleRule).not.toHaveBeenCalled(); - mocks.store.customProxyGroups = [{ ...customGroup, rules: [] }, targetGroup]; + mocks.store.customProxyGroups = [customGroup, targetGroup]; + mocks.store.customRuleSets = []; mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-2", name: "Target" }); - expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.store.moveModuleRule).not.toHaveBeenCalled(); mocks.store.customProxyGroups = [customGroup, targetGroup]; + mocks.store.customRuleSets = [sourceRule]; mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "missing", name: "Missing" }); - expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.store.moveModuleRule).not.toHaveBeenCalled(); - mocks.store.updateCustomProxyGroup.mockClear(); + mocks.store.moveModuleRule.mockClear(); mocks.store.enabledProxyGroups = ["fallback"]; - mocks.store.customProxyGroups = [{ ...customGroup, rules: [{ ...sourceRule, noResolve: false }] }, targetGroup]; + mocks.store.customProxyGroups = [customGroup, targetGroup]; + mocks.store.customRuleSets = [{ ...sourceRule, noResolve: false }]; renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.moveMenus[0].onMove({ kind: "module", id: "fallback", name: "Fallback" }); expect(mocks.store.toggleProxyGroup).not.toHaveBeenCalled(); - expect(mocks.store.addModuleRules).toHaveBeenCalledWith("fallback", [ - { id: "rule-a", name: "Rule A", behavior: "domain", path: "geosite/rule-a.mrs" }, - ]); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("custom-1", "rule-a", { + kind: "module", + id: "fallback", + name: "Fallback", + }); }); it("renders load-balance labels, empty expanded groups, and ignores non-rule-set move values", () => { @@ -347,12 +357,12 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }, targetGroup, ]; + mocks.store.customRuleSets = []; const result = renderPanel({ 0: new Set(["custom-1", "custom-2"]) }); expect(result.html).toContain("type:load-balance / strategy:consistent-hashing"); expect(result.html).toContain("还没有规则集"); - mocks.captures.moveMenus[0].onMove("not-a-target"); - expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalledWith("custom-1", { rules: [] }); + expect(mocks.captures.moveMenus[0]).toBeUndefined(); mocks.store.filteredProxyGroups = [null, { name: "Disabled", enabled: false }, { name: " ", enabled: true }]; mocks.store.dialerProxyGroups = [null, { name: " " }]; @@ -362,7 +372,6 @@ describe("ProxyGroupsCustomGroupsPanel", () => { name: "🧩 Unique", emoji: "🧩", groupType: "select", - rules: [], }); }); }); 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 99d6379..31437be 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 @@ -6,9 +6,8 @@ import { Button } from "@subboost/ui/components/ui/button"; import { toast } from "@subboost/ui/components/ui/toaster"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import { extractRuleSetPathFromUrl } from "@subboost/core/rules/custom-routing-rule-sets"; import { DEFAULT_LOAD_BALANCE_STRATEGY, type LoadBalanceStrategy } from "@subboost/core/types/config"; -import { useConfigStore, type CustomProxyGroup, type ModuleRuleOverride } from "@subboost/ui/store/config-store"; +import { useConfigStore, type CustomProxyGroup } from "@subboost/ui/store/config-store"; import { useProductInteractionAdapter } from "@subboost/ui/product/interactions"; import { buildManualRuleTargets, @@ -40,18 +39,19 @@ export function ProxyGroupsCustomGroupsPanel() { const { enabledProxyGroups, hiddenProxyGroups, - proxyGroupNameOverrides, - customRules, - customProxyGroups, + proxyGroupNameOverrides = {}, + customRules = [], + customRuleSets = [], + customProxyGroups = [], addCustomProxyGroup, removeCustomProxyGroup, updateCustomProxyGroup, updateCustomRule, removeCustomRule, - toggleProxyGroup, - addModuleRules, - filteredProxyGroups, - dialerProxyGroups, + moveModuleRule, + removeModuleRule, + filteredProxyGroups = [], + dialerProxyGroups = [], } = useConfigStore(); const [expandedCustomGroups, setExpandedCustomGroups] = React.useState>(new Set()); @@ -131,13 +131,15 @@ export function ProxyGroupsCustomGroupsPanel() { const state = useConfigStore.getState(); const sourceGroup = state.customProxyGroups.find((group) => group.id === sourceGroupId); - const sourceRule = sourceGroup?.rules.find((rule) => rule.id === ruleId); + const sourceRule = sourceGroup + ? state.customRuleSets.find((rule) => rule.id === ruleId && rule.target === sourceGroup.name) + : null; if (!sourceGroup || !sourceRule) return; if (target.kind === "custom") { const targetGroup = state.customProxyGroups.find((group) => group.id === target.id); if (!targetGroup) return; - if (targetGroup.rules.some((rule) => rule.id === sourceRule.id)) { + if (state.customRuleSets.some((rule) => rule.id === sourceRule.id && rule.target === targetGroup.name)) { toast({ title: "规则集已存在", description: "目标分流组里已经有同名规则集,请先移除重复项。", @@ -145,32 +147,11 @@ export function ProxyGroupsCustomGroupsPanel() { }); return; } - - updateCustomProxyGroup(sourceGroup.id, { - rules: sourceGroup.rules.filter((rule) => rule.id !== sourceRule.id), - }); - updateCustomProxyGroup(targetGroup.id, { - rules: [...targetGroup.rules, sourceRule], - }); - return; } - const moduleRule: ModuleRuleOverride = { - id: sourceRule.id, - name: sourceRule.name, - behavior: sourceRule.behavior, - path: extractRuleSetPathFromUrl(sourceRule.url), - ...(sourceRule.noResolve ? { noResolve: true } : {}), - }; - if (!enabledProxyGroups.includes(target.id)) { - toggleProxyGroup(target.id); - } - addModuleRules(target.id, [moduleRule]); - updateCustomProxyGroup(sourceGroup.id, { - rules: sourceGroup.rules.filter((rule) => rule.id !== sourceRule.id), - }); + moveModuleRule(sourceGroup.id, ruleId, target); }, - [addModuleRules, enabledProxyGroups, toggleProxyGroup, updateCustomProxyGroup], + [moveModuleRule], ); return ( @@ -219,7 +200,6 @@ export function ProxyGroupsCustomGroupsPanel() { emoji, groupType: newCustomGroupType, ...(newCustomGroupType === "load-balance" ? { strategy: newCustomGroupStrategy } : {}), - rules: [], }); interactions.proxyGroupAdded?.({ groupType: newCustomGroupType }); setNewCustomGroupDraft({ emoji: "🧩", name: "" }); @@ -239,7 +219,8 @@ export function ProxyGroupsCustomGroupsPanel() { const isExpanded = expandedCustomGroups.has(group.id); const isEditing = editingCustomGroupId === group.id; const manualRules = listCustomRulesForTarget(customRules, group.name); - const totalRules = group.rules.length + manualRules.length; + const groupRuleSets = customRuleSets.filter((ruleSet) => ruleSet.target === group.name); + const totalRules = groupRuleSets.length + manualRules.length; const typeLabel = group.groupType === "load-balance" ? `${getProxyGroupTypeLabel(group.groupType)} / ${getLoadBalanceStrategyLabel( @@ -410,11 +391,11 @@ export function ProxyGroupsCustomGroupsPanel() {

) : ( <> - {group.rules.map((r) => ( + {groupRuleSets.map((r) => ( { - const next = group.rules.filter((x) => x.id !== r.id); - updateCustomProxyGroup(group.id, { rules: next }); - }} + onClick={() => removeModuleRule(group.id, r.id)} title="删除规则集" aria-label={`删除 ${r.name} 规则集`} > diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts index 0ad2823..e57ce59 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts @@ -100,8 +100,8 @@ function props(overrides: Record = {}) { onCommitEditing: vi.fn(), onHide: vi.fn(), extraRules: [{ id: "extra", name: "Extra", behavior: "domain" as const, path: "geosite/extra.mrs" }], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + ruleSetsByTarget: {}, + hiddenPresetRuleIds: {}, customProxyGroups: [], manualRules: [{ index: 0, rule: { id: "manual-1", type: "DOMAIN" as const, value: "example.com", target: "Proxy" } }], manualRuleTargets: [{ kind: "module" as const, id: "gemini", name: "Gemini Display" }], @@ -120,6 +120,7 @@ function props(overrides: Record = {}) { onMoveManualRule: vi.fn(), onRemoveManualRule: vi.fn(), onRestoreRule: vi.fn(), + onResetRuleTarget: vi.fn(), cnIpNoResolve: true, onChangeCnIpNoResolve: vi.fn(), experimentalCnUseCnRuleSet: false, diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx index f4af342..2036f9e 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx @@ -9,7 +9,7 @@ import { getEffectiveModuleRuleItems, getExcludedModuleRuleIds, isModuleRuleMovedFrom, - type ModuleRuleExclusions, + type ModuleRuleExclusions as HiddenPresetRuleIds, } from "@subboost/core/generator/module-rules"; import type { ProxyGroupModule } from "@subboost/core/generator/proxy-groups"; import type { CustomProxyGroup, ModuleRuleOverride } from "@subboost/ui/store/config-store"; @@ -26,6 +26,10 @@ import { } from "./proxy-group-name-editor"; import { ProxyGroupSummary } from "./proxy-group-summary"; +type CustomProxyGroupRuleView = CustomProxyGroup & { + rules?: Array<{ id?: unknown }>; +}; + function ModuleHintPopover({ moduleId }: { moduleId: string }) { const isGemini = moduleId === "gemini"; const label = isGemini ? "Gemini 分流说明" : "谷歌学术分流说明"; @@ -100,8 +104,8 @@ export function ProxyGroupsModuleCard({ onCommitEditing, onHide, extraRules, - moduleRuleOverrides, - moduleRuleExclusions, + ruleSetsByTarget, + hiddenPresetRuleIds, customProxyGroups, manualRules, manualRuleTargets, @@ -120,6 +124,7 @@ export function ProxyGroupsModuleCard({ onMoveManualRule, onRemoveManualRule, onRestoreRule, + onResetRuleTarget, cnIpNoResolve, onChangeCnIpNoResolve, experimentalCnUseCnRuleSet, @@ -138,9 +143,9 @@ export function ProxyGroupsModuleCard({ onCommitEditing: () => void; onHide: () => void; extraRules: ModuleRuleOverride[]; - moduleRuleOverrides: Record; - moduleRuleExclusions: ModuleRuleExclusions; - customProxyGroups: CustomProxyGroup[]; + ruleSetsByTarget: Record; + hiddenPresetRuleIds: HiddenPresetRuleIds; + customProxyGroups: CustomProxyGroupRuleView[]; manualRules: CustomRuleListItem[]; manualRuleTargets: ProxyGroupRuleTarget[]; enabledProxyGroups: string[]; @@ -158,16 +163,17 @@ export function ProxyGroupsModuleCard({ onMoveManualRule: (ruleId: string, targetName: string) => void; onRemoveManualRule: (index: number) => void; onRestoreRule: (ruleId: string) => void; + onResetRuleTarget: (ruleId: string) => void; cnIpNoResolve: boolean; onChangeCnIpNoResolve: (value: boolean) => void; experimentalCnUseCnRuleSet: boolean; onChangeExperimentalCnUseCnRuleSet: (value: boolean) => void; }) { - const effectiveRules = getEffectiveModuleRuleItems(module, moduleRuleOverrides, moduleRuleExclusions); - const excludedRuleIds = getExcludedModuleRuleIds(module.id, moduleRuleExclusions); + const effectiveRules = getEffectiveModuleRuleItems(module, ruleSetsByTarget, hiddenPresetRuleIds); + const excludedRuleIds = getExcludedModuleRuleIds(module.id, hiddenPresetRuleIds); const excludedRules = module.rules.filter((rule) => rule?.id && excludedRuleIds.has(rule.id)); const movedCount = excludedRules.filter((rule) => - isModuleRuleMovedFrom(module.id, rule.id, moduleRuleOverrides, customProxyGroups) + isModuleRuleMovedFrom(module.id, rule.id, ruleSetsByTarget, customProxyGroups) ).length; const removedCount = excludedRules.length - movedCount; const excludedCount = excludedRules.length; @@ -296,8 +302,8 @@ export function ProxyGroupsModuleCard({ module={module} enabledProxyGroups={enabledProxyGroups} hiddenProxyGroups={hiddenProxyGroups} - moduleRuleOverrides={moduleRuleOverrides} - moduleRuleExclusions={moduleRuleExclusions} + ruleSetsByTarget={ruleSetsByTarget} + hiddenPresetRuleIds={hiddenPresetRuleIds} customProxyGroups={customProxyGroups} manualRules={manualRules} manualRuleTargets={manualRuleTargets} @@ -312,6 +318,7 @@ export function ProxyGroupsModuleCard({ onMoveManualRule={onMoveManualRule} onRemoveManualRule={onRemoveManualRule} onRestoreRule={onRestoreRule} + onResetRuleTarget={onResetRuleTarget} cnIpNoResolve={cnIpNoResolve} onChangeCnIpNoResolve={onChangeCnIpNoResolve} experimentalCnUseCnRuleSet={experimentalCnUseCnRuleSet} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts index 556919f..f62bdff 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.test.ts @@ -137,8 +137,8 @@ function baseProps(overrides: Record = {}) { module: cnModule, enabledProxyGroups: ["cn", "auto"], hiddenProxyGroups: ["hidden"], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + ruleSetsByTarget: {}, + hiddenPresetRuleIds: {}, customProxyGroups: [{ id: "custom-1", name: "Custom Group", rules: [] }], manualRules: [], manualRuleTargets: [{ name: "Auto" }], @@ -153,6 +153,7 @@ function baseProps(overrides: Record = {}) { onMoveManualRule: vi.fn(), onRemoveManualRule: vi.fn(), onRestoreRule: vi.fn(), + onResetRuleTarget: vi.fn(), cnIpNoResolve: true, onChangeCnIpNoResolve: vi.fn(), experimentalCnUseCnRuleSet: true, @@ -225,8 +226,8 @@ describe("ProxyGroupsModuleRulesPanel", () => { await flushAsyncWork(); expect(props.onMoveRule).toHaveBeenCalledWith("cn-ip", { kind: "custom", id: "custom-1" }); - mocks.captures.buttons.find((button) => button["aria-label"] === "恢复 Removed 规则集").onClick(); - expect(props.onRestoreRule).toHaveBeenCalledWith("removed-rule"); + mocks.captures.buttons.find((button) => button["aria-label"] === "恢复默认目标 Removed 规则集").onClick(); + expect(props.onResetRuleTarget).toHaveBeenCalledWith("removed-rule"); mocks.captures.manualRows[0].onMove(mocks.captures.manualRows[0].item, { name: "自动选择" }); expect(props.onMoveManualRule).toHaveBeenCalledWith("manual-1", "自动选择"); @@ -287,7 +288,7 @@ describe("ProxyGroupsModuleRulesPanel", () => { it("normalizes exclusion keys and cleans up candidate rule loading paths", async () => { const props = baseProps({ - moduleRuleExclusions: { cn: [" removed-rule ", " "], auto: ["auto-rule"] }, + hiddenPresetRuleIds: { cn: [" removed-rule ", " "], auto: ["auto-rule"] }, }); renderPanel(props); await flushAsyncWork(); @@ -318,7 +319,7 @@ describe("ProxyGroupsModuleRulesPanel", () => { { id: "fallback-name", name: " ", behavior: "domain", path: "rule-set/fallback.mrs", parentRuleId: 123 }, { id: "parent-module", name: "Parent Module", behavior: "domain", path: "rule-set/parent.mrs", parentModuleId: "cn" }, ]); - renderPanel(baseProps({ enabledProxyGroups: [" ", ""], moduleRuleExclusions: undefined })); + renderPanel(baseProps({ enabledProxyGroups: [" ", ""], hiddenPresetRuleIds: undefined })); await flushAsyncWork(); expect((stateMock.setters[0] as any).lastValue).toEqual([ { id: "fallback-name", name: "fallback-name", behavior: "domain", path: "rule-set/fallback.mrs", parentRuleId: undefined, parentModuleId: undefined }, @@ -389,7 +390,7 @@ describe("ProxyGroupsModuleRulesPanel", () => { mocks.isMoved.mockReturnValue(false); const props = baseProps({ cnIpNoResolve: false, - moduleRuleExclusions: { cn: ["cn-ip"] }, + hiddenPresetRuleIds: { cn: ["cn-ip"] }, }); renderPanel(props); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx index 3ed5cac..d0c6e89 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx @@ -13,7 +13,7 @@ import { getEffectiveModuleRuleItems, getExcludedModuleRuleIds, isModuleRuleMovedFrom, - type ModuleRuleExclusions, + type ModuleRuleExclusions as HiddenPresetRuleIds, } from "@subboost/core/generator/module-rules"; import { EXPERIMENTAL_CN_RULE } from "@subboost/core/generator/rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; @@ -37,6 +37,9 @@ type MoveTarget = { kind: "module" | "custom"; id: string }; type ActiveRuleRow = EffectiveModuleRule & { state: "active" }; type InactiveRuleRow = ProxyGroupRule & { source: "preset"; state: "removed" | "moved" }; type RuleRow = ActiveRuleRow | InactiveRuleRow; +type CustomProxyGroupRuleView = CustomProxyGroup & { + rules?: Array<{ id?: unknown }>; +}; type CnCandidateRule = { id: string; name: string; @@ -77,8 +80,8 @@ export function ProxyGroupsModuleRulesPanel({ module, enabledProxyGroups, hiddenProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + ruleSetsByTarget, + hiddenPresetRuleIds, customProxyGroups, manualRules, manualRuleTargets, @@ -93,6 +96,7 @@ export function ProxyGroupsModuleRulesPanel({ onMoveManualRule, onRemoveManualRule, onRestoreRule, + onResetRuleTarget, cnIpNoResolve, onChangeCnIpNoResolve, experimentalCnUseCnRuleSet, @@ -101,9 +105,9 @@ export function ProxyGroupsModuleRulesPanel({ module: ProxyGroupModule; enabledProxyGroups: string[]; hiddenProxyGroups: string[]; - moduleRuleOverrides: Record; - moduleRuleExclusions: ModuleRuleExclusions; - customProxyGroups: CustomProxyGroup[]; + ruleSetsByTarget: Record; + hiddenPresetRuleIds: HiddenPresetRuleIds; + customProxyGroups: CustomProxyGroupRuleView[]; manualRules: CustomRuleListItem[]; manualRuleTargets: ProxyGroupRuleTarget[]; proxyGroupNameOverrides: Record; @@ -117,6 +121,7 @@ export function ProxyGroupsModuleRulesPanel({ onMoveManualRule: (ruleId: string, targetName: string) => void; onRemoveManualRule: (index: number) => void; onRestoreRule: (ruleId: string) => void; + onResetRuleTarget: (ruleId: string) => void; cnIpNoResolve: boolean; onChangeCnIpNoResolve: (value: boolean) => void; experimentalCnUseCnRuleSet: boolean; @@ -127,9 +132,9 @@ export function ProxyGroupsModuleRulesPanel({ () => enabledProxyGroups.map((id) => id.trim()).filter(Boolean).join(","), [enabledProxyGroups] ); - const moduleRuleExclusionsKey = React.useMemo( + const hiddenPresetRuleIdsKey = React.useMemo( () => - Object.entries(moduleRuleExclusions || {}) + Object.entries(hiddenPresetRuleIds || {}) .flatMap(([moduleId, ruleIds]) => Array.isArray(ruleIds) ? ruleIds.map((ruleId) => `${moduleId.trim()}:${String(ruleId).trim()}`) @@ -139,27 +144,27 @@ export function ProxyGroupsModuleRulesPanel({ .filter((key) => key && !key.endsWith(":")) .sort((a, b) => a.localeCompare(b)) .join(","), - [moduleRuleExclusions] + [hiddenPresetRuleIds] ); const [cnCandidateRules, setCnCandidateRules] = React.useState([]); const activeRules = React.useMemo( - () => getEffectiveModuleRuleItems(module, moduleRuleOverrides, moduleRuleExclusions), - [module, moduleRuleExclusions, moduleRuleOverrides] + () => getEffectiveModuleRuleItems(module, ruleSetsByTarget, hiddenPresetRuleIds), + [module, hiddenPresetRuleIds, ruleSetsByTarget] ); const inactiveRules = React.useMemo(() => { - const excluded = getExcludedModuleRuleIds(module.id, moduleRuleExclusions); + const excluded = getExcludedModuleRuleIds(module.id, hiddenPresetRuleIds); return module.rules .filter((rule) => rule?.id && excluded.has(rule.id)) .map((rule) => ({ ...rule, source: "preset" as const, - state: isModuleRuleMovedFrom(module.id, rule.id, moduleRuleOverrides, customProxyGroups) + state: isModuleRuleMovedFrom(module.id, rule.id, ruleSetsByTarget, customProxyGroups) ? "moved" as const : "removed" as const, })); - }, [customProxyGroups, module, moduleRuleExclusions, moduleRuleOverrides]); + }, [customProxyGroups, module, hiddenPresetRuleIds, ruleSetsByTarget]); const rules = React.useMemo( () => [ @@ -207,7 +212,7 @@ export function ProxyGroupsModuleRulesPanel({ const controller = new AbortController(); const params = new URLSearchParams(); if (enabledProxyGroupsKey) params.set("modules", enabledProxyGroupsKey); - if (moduleRuleExclusionsKey) params.set("excluded", moduleRuleExclusionsKey); + if (hiddenPresetRuleIdsKey) params.set("excluded", hiddenPresetRuleIdsKey); if (!rulesApi?.loadCnCandidateRules) { setCnCandidateRules([]); @@ -228,7 +233,7 @@ export function ProxyGroupsModuleRulesPanel({ }); return () => controller.abort(); - }, [enabledProxyGroupsKey, module.id, moduleRuleExclusionsKey, rulesApi]); + }, [enabledProxyGroupsKey, module.id, hiddenPresetRuleIdsKey, rulesApi]); const ensurePresetEditWarning = React.useCallback(async () => { if (moduleRuleEditWarningAccepted) return true; @@ -310,9 +315,13 @@ export function ProxyGroupsModuleRulesPanel({ ? "text-orange-300/70 hover:text-orange-200" : "text-red-300/70 hover:text-red-200", )} - title="恢复规则集" - aria-label={`恢复 ${rule.name} 规则集`} - onClick={() => onRestoreRule(rule.id)} + title={rule.state === "moved" ? "恢复默认目标" : "恢复规则集"} + aria-label={`${rule.state === "moved" ? "恢复默认目标" : "恢复"} ${rule.name} 规则集`} + onClick={() => + rule.state === "moved" + ? onResetRuleTarget(rule.id) + : onRestoreRule(rule.id) + } > diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts index 8eefcfc..0486689 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts @@ -110,12 +110,13 @@ vi.mock("@subboost/ui/components/ui/select", () => ({ })); vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ - PROXY_GROUP_MODULES: [ - { id: "auto", name: "Auto", rules: [] }, - { id: "fallback", name: "Fallback", rules: [] }, - ], + PROXY_GROUP_MODULES: [ + { id: "auto", name: "Auto", rules: [{ id: "netflix" }] }, + { id: "fallback", name: "Fallback", rules: [] }, + ], })); vi.mock("@subboost/core/generator/module-rules", () => ({ + getModuleRuleOrderKey: (moduleId: string, ruleId: string) => `module:${moduleId}:${ruleId}`, getEffectiveModuleRules: vi.fn((module: { id: string }) => mocks.effectiveRulesByModule[module.id] || []), })); vi.mock("@subboost/core/proxy-group-name", () => ({ @@ -222,10 +223,10 @@ describe("ProxyGroupsRulesLibrary", () => { enabledProxyGroups: ["auto"], hiddenProxyGroups: [], toggleProxyGroup: vi.fn(), - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, addModuleRules: vi.fn(), - customProxyGroups: [{ id: "custom-1", name: "Custom", rules: [] }], + customProxyGroups: [{ id: "custom-1", name: "Custom" }], updateCustomProxyGroup: vi.fn(), proxyGroupNameOverrides: { auto: "Auto", fallback: "Fallback" }, }; @@ -271,7 +272,6 @@ describe("ProxyGroupsRulesLibrary", () => { }); it("shows assigned rules and enables a disabled built-in group", () => { - mocks.effectiveRulesByModule.auto = [{ id: "netflix" }]; let result = renderLibrary(); expect(result.html).toContain("已启用"); expect(result.html).toContain("属于"); @@ -281,10 +281,7 @@ describe("ProxyGroupsRulesLibrary", () => { mocks.captures.buttons.find((props) => props.children === "开启代理组").onClick(); expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("auto"); - mocks.effectiveRulesByModule = {}; - mocks.store.customProxyGroups = [ - { id: "custom-1", name: "Custom", rules: [{ id: "telegram" }] }, - ]; + mocks.store.customRuleSets = [{ id: "telegram", name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs", target: "Custom" }]; result = renderLibrary(); expect(result.html).toContain("Custom"); expect(result.html).toContain("已添加"); @@ -297,17 +294,15 @@ describe("ProxyGroupsRulesLibrary", () => { mocks.captures.buttons.find((props) => props.children === "添加").onClick(); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { - rules: [ - { - id: "telegram", - name: "Telegram", - behavior: "ipcidr", - url: "https://rules.example/geoip/telegram.mrs", - noResolve: true, - }, - ], - }); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [ + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + noResolve: true, + }, + ]); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "已添加规则集" })); expect(mocks.interactions.ruleAdded).toHaveBeenCalledWith({ source: "library", kind: "ruleset" }); expect(setters[0]).toHaveBeenCalledWith([]); @@ -315,7 +310,7 @@ describe("ProxyGroupsRulesLibrary", () => { it("adds valid selected rules to a module and reports skipped invalid rules", () => { mocks.store.enabledProxyGroups = []; - const { html } = renderLibrary({ 0: [netflixRule, invalidRule], 1: "module:auto" }); + const { html } = renderLibrary({ 0: [telegramRule, invalidRule], 1: "module:auto" }); expect(html).toContain("已选择"); mocks.captures.buttons.find((props) => props.children === "添加").onClick(); @@ -323,11 +318,11 @@ describe("ProxyGroupsRulesLibrary", () => { expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("auto"); expect(mocks.store.addModuleRules).toHaveBeenCalledWith("auto", [ { - id: "netflix", - name: "Netflix", - behavior: "domain", - path: "geosite/netflix.mrs", - noResolve: false, + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + noResolve: true, }, ]); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ @@ -337,7 +332,6 @@ describe("ProxyGroupsRulesLibrary", () => { }); it("warns when selected rules conflict or add nothing new", () => { - mocks.effectiveRulesByModule.auto = [{ id: "netflix" }]; renderLibrary({ 0: [netflixRule], 1: "custom:custom-1" }); mocks.captures.buttons.find((props) => props.children === "添加").onClick(); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ @@ -346,10 +340,7 @@ describe("ProxyGroupsRulesLibrary", () => { })); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); - mocks.effectiveRulesByModule = {}; - mocks.store.customProxyGroups = [ - { id: "custom-1", name: "Custom", rules: [{ id: "telegram" }] }, - ]; + mocks.store.customRuleSets = [{ id: "telegram", name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs", target: "Custom" }]; renderLibrary({ 0: [telegramRule], 1: "custom:custom-1" }); mocks.captures.buttons.find((props) => props.children === "添加").onClick(); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ @@ -374,9 +365,9 @@ describe("ProxyGroupsRulesLibrary", () => { renderLibrary(); const firstRuleDiv = mocks.captures.nativeDivs.find((props) => String(props.className).includes("cursor-pointer")); firstRuleDiv.onClick(); - expect(stateMock.setters[0]).toHaveBeenCalledWith([netflixRule]); + expect(stateMock.setters[0]).toHaveBeenCalledWith([telegramRule]); - renderLibrary({ 0: [netflixRule] }); + renderLibrary({ 0: [telegramRule] }); const selectedRuleDiv = mocks.captures.nativeDivs.find((props) => String(props.className).includes("cursor-pointer")); selectedRuleDiv.onClick(); expect(stateMock.setters[0]).toHaveBeenCalledWith([]); @@ -434,7 +425,6 @@ describe("ProxyGroupsRulesLibrary", () => { }); it("handles existing module rules and enabled module additions", () => { - mocks.effectiveRulesByModule.auto = [{ id: "netflix" }]; renderLibrary({ 0: [netflixRule], 1: "module:auto" }); mocks.captures.buttons.find((props) => props.children === "添加").onClick(); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ @@ -445,25 +435,23 @@ describe("ProxyGroupsRulesLibrary", () => { expect(mocks.store.addModuleRules).not.toHaveBeenCalled(); vi.clearAllMocks(); - mocks.effectiveRulesByModule = {}; mocks.store.enabledProxyGroups = ["auto"]; - renderLibrary({ 0: [netflixRule], 1: "module:auto" }); + renderLibrary({ 0: [telegramRule], 1: "module:auto" }); mocks.captures.buttons.find((props) => props.children === "添加").onClick(); expect(mocks.store.toggleProxyGroup).not.toHaveBeenCalled(); expect(mocks.store.addModuleRules).toHaveBeenCalledWith("auto", [ { - id: "netflix", - name: "Netflix", - behavior: "domain", - path: "geosite/netflix.mrs", - noResolve: false, + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + noResolve: true, }, ]); vi.clearAllMocks(); - mocks.effectiveRulesByModule.auto = [{ id: "netflix" }, { id: "netflix" }]; - mocks.store.customProxyGroups = [ - { id: "custom-1", name: "Custom", rules: [{ id: "netflix" }, { id: "telegram" }] }, + mocks.store.customRuleSets = [ + { id: "telegram", name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs", target: "Custom" }, ]; renderLibrary({ 0: [telegramRule], 1: "module:fallback" }); mocks.captures.buttons.find((props) => props.children === "添加").onClick(); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx index a73d0c4..150b05c 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx @@ -15,26 +15,25 @@ import { import { toast } from "@subboost/ui/components/ui/toaster"; import { cn } from "@subboost/ui/lib/utils"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; -import { getEffectiveModuleRules } from "@subboost/core/generator/module-rules"; +import { getModuleRuleOrderKey } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; import { RULE_CATEGORIES, type RuleSetInfo } from "@subboost/core/rules/metadata"; import { useConfigStore } from "@subboost/ui/store/config-store"; import { useProductInteractionAdapter } from "@subboost/ui/product/interactions"; import { ProxyGroupsAddedRuleSets } from "./proxy-groups-added-rule-sets"; -import { getRuleDisplayName, replaceRuleProviderBase, useRulesLibrarySearch } from "./proxy-groups-rules-search"; +import { getRuleDisplayName, useRulesLibrarySearch } from "./proxy-groups-rules-search"; export function ProxyGroupsRulesLibrary() { const { - ruleProviderBaseUrl, enabledProxyGroups, hiddenProxyGroups, toggleProxyGroup, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets = [], + builtinRuleEdits = {}, addModuleRules, - customProxyGroups, - updateCustomProxyGroup, - proxyGroupNameOverrides, + customProxyGroups = [], + proxyGroupNameOverrides = {}, } = useConfigStore(); const interactions = useProductInteractionAdapter(); @@ -115,16 +114,20 @@ export function ProxyGroupsRulesLibrary() {
{searchResults.map((rule) => { const categoryInfo = RULE_CATEGORIES[rule.category]; - const belongsToModule = visibleProxyGroupModules.find((m) => { - return getEffectiveModuleRules( - m, - moduleRuleOverrides, - moduleRuleExclusions, - ).some((r) => r.id === rule.id); - }); - const belongsToCustom = customProxyGroups.find((g) => - g.rules.some((r) => r.id === rule.id), + const builtinSourceModule = visibleProxyGroupModules.find((m) => + (m.rules ?? []).some((r) => r.id === rule.id && builtinRuleEdits?.[getModuleRuleOrderKey(m.id, r.id)]?.enabled !== false), ); + const builtinTargetName = builtinSourceModule + ? builtinRuleEdits?.[getModuleRuleOrderKey(builtinSourceModule.id, rule.id)]?.target || + resolveModuleFullName(builtinSourceModule) + : ""; + const belongsToModule = builtinTargetName + ? visibleProxyGroupModules.find((m) => resolveModuleFullName(m) === builtinTargetName) + : null; + const customRuleSet = customRuleSets.find((item) => item.id === rule.id); + const belongsToCustom = customRuleSet + ? customProxyGroups.find((g) => g.name === customRuleSet.target) + : null; const isModuleEnabled = belongsToModule ? enabledProxyGroups.includes(belongsToModule.id) : false; @@ -425,19 +428,16 @@ export function ProxyGroupsRulesLibrary() { const usedRuleIds = new Map(); for (const m of visibleProxyGroupModules) { const groupName = resolveModuleFullName(m); - for (const r of getEffectiveModuleRules( - m, - moduleRuleOverrides, - moduleRuleExclusions, - )) { - if (!usedRuleIds.has(r.id)) - usedRuleIds.set(r.id, groupName); + for (const r of m.rules ?? []) { + const edit = builtinRuleEdits?.[getModuleRuleOrderKey(m.id, r.id)]; + if (edit?.enabled === false) continue; + if (!usedRuleIds.has(r.id)) { + usedRuleIds.set(r.id, edit?.target || groupName); + } } } - for (const g of customProxyGroups) { - for (const r of g.rules) { - if (!usedRuleIds.has(r.id)) usedRuleIds.set(r.id, g.name); - } + for (const ruleSet of customRuleSets) { + if (!usedRuleIds.has(ruleSet.id)) usedRuleIds.set(ruleSet.id, ruleSet.target); } const targetDisplayName = @@ -483,25 +483,24 @@ export function ProxyGroupsRulesLibrary() { (g) => g.id === target.id, ); if (!cg) return; - const existing = new Set(cg.rules.map((r) => r.id)); + const existing = new Set(customRuleSets.map((r) => r.id)); const rulesToAdd = selectedRules .filter((r) => !existing.has(r.id)) - .map((rule) => ({ + .flatMap((rule) => { + const path = normalizeRuleSetPathInput(rule.url); + if (!path) return []; + return [{ id: rule.id, name: rule.nameZh, behavior: rule.behavior, - url: replaceRuleProviderBase( - rule.url, - ruleProviderBaseUrl, - ), + path, noResolve: rule.behavior === "ipcidr", - })); + }]; + }); skippedExistingCount = selectedRules.length - rulesToAdd.length; if (rulesToAdd.length > 0) { - updateCustomProxyGroup(cg.id, { - rules: [...cg.rules, ...rulesToAdd], - }); + addModuleRules(cg.id, rulesToAdd); addedCount = rulesToAdd.length; } } else { @@ -509,11 +508,14 @@ export function ProxyGroupsRulesLibrary() { if (!mod) return; const existing = new Set( - getEffectiveModuleRules( - mod, - moduleRuleOverrides, - moduleRuleExclusions, - ).map((r) => r.id), + [ + ...(mod.rules ?? []) + .filter((r) => builtinRuleEdits?.[getModuleRuleOrderKey(mod.id, r.id)]?.enabled !== false) + .map((r) => r.id), + ...customRuleSets + .filter((r) => r.target === resolveModuleFullName(mod)) + .map((r) => r.id), + ], ); const candidateRules = selectedRules.filter( (r) => !existing.has(r.id), diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts index 694936c..1066ea5 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts @@ -85,15 +85,13 @@ function renderSection(overrides: Record = {}) { enabledProxyGroups: ["core"], customRules: [{ id: "custom" }], customProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, cnIpNoResolve: true, experimentalCnUseCnRuleSet: true, ruleOrder: ["module:geo", "custom:one"], setRuleOrder: vi.fn(), - allRulesOrderEditingEnabled: false, - setAllRulesOrderEditingEnabled: vi.fn(), ...overrides, }; stateMock.setter.mockClear(); @@ -140,17 +138,15 @@ describe("RulesManagementSection", () => { const tree = renderSection(); const text = collectText(tree); const header = collectElements(tree, (element) => element.props.title === "规则管理")[0]; - const switchElement = collectElements(tree, (element) => Boolean((element.props as any).onCheckedChange))[0]; const orderInputs = collectElements(tree, (element) => (element.props as any).title === "最终规则行号(1=最前)"); expect(header.props.title).toBe("规则管理"); - expect(collectText(header.props.badge)).toContain("可调 1 / 全部 3"); - expect(text).toContain("默认只能调整自定义规则顺序。"); + expect(collectText(header.props.badge)).toContain("可调 2 / 全部 3"); + expect(text).toContain("可移动任意非 MATCH 规则"); expect(text).toContain("系统 GEO"); expect(text).toContain("自定义规则"); expect(text).toContain("no-resolve"); - expect(switchElement.props.disabled).toBe(false); - expect(orderInputs.map((input) => input.props.disabled)).toEqual([true, false, true]); + expect(orderInputs.map((input) => input.props.disabled)).toEqual([false, false, true]); expect(mocks.buildGeneratedRuleEntries).toHaveBeenCalledWith( expect.objectContaining({ enabledModules: ["core"], @@ -183,7 +179,7 @@ describe("RulesManagementSection", () => { ]; const defaultTree = renderSection(); - const allRulesTree = renderSection({ allRulesOrderEditingEnabled: true }); + const allRulesTree = renderSection(); const detail = collectElements( defaultTree, (element) => @@ -242,23 +238,53 @@ describe("RulesManagementSection", () => { ]); }); - it("confirms before enabling all-rules order mode and disables without confirmation", async () => { - const switchElement = collectElements(renderSection(), (element) => Boolean((element.props as any).onCheckedChange))[0]; - - mocks.confirmDialog.mockResolvedValueOnce(false); - await switchElement.props.onCheckedChange(true); - expect(mocks.store.setAllRulesOrderEditingEnabled).not.toHaveBeenCalled(); + it("uses fixed source tags for manual rules and searched rule sets", () => { + mocks.entries = [ + { + key: "custom-rule:manual", + editable: true, + summary: "example.com", + sourceLabel: "自定义规则", + target: "📚 教育学术", + noResolve: false, + text: "DOMAIN,example.com,📚 教育学术", + }, + { + key: "custom-rule-set:google", + editable: true, + summary: "Google", + sourceLabel: "自定义规则集", + target: "💬 自定义1", + noResolve: false, + text: "RULE-SET,google,💬 自定义1", + }, + { + key: "module:education:scholar", + editable: true, + summary: "Scholar", + sourceLabel: "📚 教育学术", + target: "💬 自定义1", + noResolve: false, + text: "RULE-SET,scholar,💬 自定义1", + }, + { + key: "special:match", + editable: false, + summary: "兜底规则", + sourceLabel: "系统规则", + target: "MATCH", + noResolve: false, + text: "MATCH,DIRECT", + }, + ]; - mocks.confirmDialog.mockResolvedValueOnce(true); - await switchElement.props.onCheckedChange(true); - expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(true); + const text = collectText(renderSection()); - const enabledSwitch = collectElements( - renderSection({ allRulesOrderEditingEnabled: true }), - (element) => Boolean((element.props as any).onCheckedChange) - )[0]; - await enabledSwitch.props.onCheckedChange(false); - expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(false); + expect(text).toContain("自定义规则"); + expect(text).toContain("自定义规则集"); + expect(text).toContain("📚 教育学术"); + expect(text).toContain("💬 自定义1"); + expect(text).not.toContain("自定义分组 ·"); }); it("moves editable rules by buttons, absolute order input, blur, and escape cleanup", () => { @@ -294,15 +320,13 @@ describe("RulesManagementSection", () => { enabledProxyGroups: [], customRules: [], customProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, cnIpNoResolve: false, experimentalCnUseCnRuleSet: false, ruleOrder: [], setRuleOrder: vi.fn(), - allRulesOrderEditingEnabled: false, - setAllRulesOrderEditingEnabled: vi.fn(), }; const tree = RulesManagementSection({ isExpanded: false, onToggle: vi.fn() }); const header = collectElements(tree, (element) => element.props.title === "规则管理")[0]; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx index 99dee11..e8820f3 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx @@ -3,9 +3,7 @@ import * as React from "react"; import { ArrowDown, ArrowUp, ListOrdered } from "lucide-react"; import { Badge } from "@subboost/ui/components/ui/badge"; -import { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog"; import { Input } from "@subboost/ui/components/ui/input"; -import { Switch } from "@subboost/ui/components/ui/switch"; import { buildGeneratedRuleEntries, type GeneratedRuleEntry, @@ -42,28 +40,23 @@ export function RulesManagementSection({ const { enabledProxyGroups, customRules, - customProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, proxyGroupNameOverrides, cnIpNoResolve, experimentalCnUseCnRuleSet, ruleOrder, setRuleOrder, - allRulesOrderEditingEnabled, - setAllRulesOrderEditingEnabled, } = useConfigStore(); const [orderDrafts, setOrderDrafts] = React.useState>({}); - const allRulesMode = allRulesOrderEditingEnabled; const entries = React.useMemo( () => buildGeneratedRuleEntries({ enabledModules: enabledProxyGroups, customRules, - customProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, proxyGroupNameOverrides, cnIpNoResolve, experimentalCnUseCnRuleSet, @@ -71,24 +64,22 @@ export function RulesManagementSection({ }), [ cnIpNoResolve, - customProxyGroups, + customRuleSets, customRules, enabledProxyGroups, experimentalCnUseCnRuleSet, - moduleRuleOverrides, - moduleRuleExclusions, + builtinRuleEdits, proxyGroupNameOverrides, ruleOrder, ] ); - const editableEntries = React.useMemo(() => entries.filter((entry) => entry.editable), [entries]); const preMatchEntries = React.useMemo( () => entries.filter((entry) => entry.key !== "special:match"), [entries] ); const preMatchKeys = React.useMemo(() => preMatchEntries.map((entry) => entry.key), [preMatchEntries]); - const editableKeys = React.useMemo(() => editableEntries.map((entry) => entry.key), [editableEntries]); + const editableKeys = preMatchKeys; const applyRuleOrder = React.useCallback( (nextRuleOrder: string[]) => { @@ -132,36 +123,6 @@ export function RulesManagementSection({ [applyRuleOrder, preMatchEntries.length, preMatchKeys] ); - const handleToggleAllRulesMode = React.useCallback( - async (checked: boolean) => { - if (!checked) { - setAllRulesOrderEditingEnabled(false); - return; - } - - const ok = await confirmDialog({ - title: "开启“调整所有规则顺序”?", - description: ( - - - 警告: - 开启后,你可以移动任意规则到任意位置,这会改变分流优先级与命中结果。 - - - 如果你不知道调整规则顺序的影响,请不要动它。 - - - ), - cancelText: "保持默认", - confirmText: "继续开启", - variant: "warning", - }); - if (!ok) return; - setAllRulesOrderEditingEnabled(true); - }, - [setAllRulesOrderEditingEnabled] - ); - return (
- 可调 {allRulesMode ? preMatchEntries.length : editableEntries.length} / 全部 {entries.length} + 可调 {preMatchEntries.length} / 全部 {entries.length} } /> @@ -181,17 +142,7 @@ export function RulesManagementSection({
- {allRulesMode - ? "已开启全规则排序:可移动任意规则,但 MATCH 固定最后。" - : "默认只能调整自定义规则顺序。"} -
-
- 调整所有规则顺序 - + 可移动任意非 MATCH 规则;排序只决定命中优先级,目标只决定命中后进入哪个分流组。
@@ -199,7 +150,7 @@ export function RulesManagementSection({
{entries.map((entry, index) => { const fullIndex = preMatchKeys.indexOf(entry.key); - const canEditOrder = entry.key !== "special:match" && (allRulesMode || entry.editable); + const canEditOrder = entry.key !== "special:match"; const canMoveUp = canEditOrder && fullIndex > 0; const canMoveDown = canEditOrder && fullIndex >= 0 && fullIndex < preMatchKeys.length - 1; const absoluteOrder = index + 1; diff --git a/packages/ui/src/product/home/home-surface.test.ts b/packages/ui/src/product/home/home-surface.test.ts index 5737b10..85d2d33 100644 --- a/packages/ui/src/product/home/home-surface.test.ts +++ b/packages/ui/src/product/home/home-surface.test.ts @@ -58,12 +58,11 @@ vi.mock("@subboost/ui/store/config-store", () => { enabledProxyGroups: ["PROXY"], hiddenProxyGroups: [], customProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, moduleRuleEditWarningAccepted: false, customRules: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, dialerProxyGroups: [], listenerPorts: {}, dnsYaml: "", diff --git a/packages/ui/src/product/home/home-surface.tsx b/packages/ui/src/product/home/home-surface.tsx index 43508f5..ea578f6 100644 --- a/packages/ui/src/product/home/home-surface.tsx +++ b/packages/ui/src/product/home/home-surface.tsx @@ -65,12 +65,11 @@ function HomeSurfaceInner({ adapter }: Props) { enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, moduleRuleEditWarningAccepted, customRules, ruleOrder, - allRulesOrderEditingEnabled, dialerProxyGroups, listenerPorts, dnsYaml, @@ -111,11 +110,10 @@ function HomeSurfaceInner({ adapter }: Props) { hiddenProxyGroups, customRules, customProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, moduleRuleEditWarningAccepted, ruleOrder, - allRulesOrderEditingEnabled, dialerProxyGroups, proxyGroupNameOverrides, listenerPorts, 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 a9cad87..f53c065 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 @@ -108,8 +108,8 @@ function resetStoreState(overrides: Record = {}) { generateConfig, sources: [], enabledProxyGroups: ["select", "auto", "ai"], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, experimentalCnUseCnRuleSet: false, cnIpNoResolve: true, @@ -262,10 +262,19 @@ describe("useEditingSubscriptionLoader", () => { template: "full", enabledProxyGroups: ["select", "auto", "ai"], hiddenProxyGroups: ["youtube"], + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + customRuleSets: [ + { + id: "custom-ai", + name: "Custom AI", + behavior: "domain", + path: "geosite/custom-ai.mrs", + target: "🤖 Labs", + }, + ], filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], - moduleRuleExclusions: { ai: ["openai"] }, + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, moduleRuleEditWarningAccepted: true, - allRulesOrderEditingEnabled: true, proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], listenerPorts: { Remote: 41000 }, 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 92f7cca..0426c0b 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.ts @@ -4,9 +4,9 @@ import * as React from "react"; import type { SubscriptionSource } from "@subboost/ui/store/config-store"; import type { ParsedNode } from "@subboost/core/types/node"; import type { EditingSubscriptionLoaderOptions } from "./editing-subscription-types"; -import { hasFullRuleOrderKeys, normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; -import { normalizeModuleRuleExclusions } from "@subboost/core/generator/module-rules"; +import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils"; +import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import { hasSubscriptionUserInfo, @@ -411,18 +411,13 @@ export function useEditingSubscriptionLoader({ : []; const hiddenProxyGroupSetFromCfg = new Set(hiddenProxyGroupsFromCfg); const customRulesFromCfg = ensureCustomRulesHaveIds(Array.isArray(cfg.customRules) ? (cfg.customRules as any[]) : []); - const customProxyGroupsFromCfg = Array.isArray(cfg.customProxyGroups) ? (cfg.customProxyGroups as any[]) : []; + const ruleModelFromCfg = normalizeRuleModelFromConfig(cfg); + const customProxyGroupsFromCfg = ruleModelFromCfg.customProxyGroups; + const customRuleSetsFromCfg = ruleModelFromCfg.customRuleSets; + const builtinRuleEditsFromCfg = ruleModelFromCfg.builtinRuleEdits; const filteredProxyGroupsFromCfg = Array.isArray((cfg as any).filteredProxyGroups) ? (((cfg as any).filteredProxyGroups as unknown[]) as any[]) : []; - const moduleRuleOverridesFromCfg = - cfg.moduleRuleOverrides && typeof cfg.moduleRuleOverrides === "object" - ? (cfg.moduleRuleOverrides as Record) - : null; - const moduleRuleExclusionsFromCfg = - cfg.moduleRuleExclusions && typeof cfg.moduleRuleExclusions === "object" - ? normalizeModuleRuleExclusions(cfg.moduleRuleExclusions) - : null; const dialerProxyGroupsFromCfg = Array.isArray(cfg.dialerProxyGroups) ? (cfg.dialerProxyGroups as any[]) : []; const proxyGroupNameOverridesFromCfg = cfg.proxyGroupNameOverrides && typeof cfg.proxyGroupNameOverrides === "object" @@ -442,40 +437,14 @@ export function useEditingSubscriptionLoader({ return out; })() : null; - const normalizedModuleRuleOverridesFromCfg = (() => { - if (!moduleRuleOverridesFromCfg) return null; - const out: Record = {}; - for (const [moduleId, rules] of Object.entries(moduleRuleOverridesFromCfg)) { - if (!Array.isArray(rules)) continue; - const normalized = (rules as any[]) - .map((r) => { - if (!r || typeof r !== "object") return null; - const id = (r as any).id; - const path = (r as any).path; - if (typeof id !== "string" || !id.trim()) return null; - if (typeof path !== "string" || !path.trim()) return null; - return { - id: id.trim(), - name: typeof (r as any).name === "string" && (r as any).name.trim() ? (r as any).name.trim() : id.trim(), - behavior: (r as any).behavior === "ipcidr" ? "ipcidr" : "domain", - path: path.trim(), - ...((r as any).noResolve ? { noResolve: true } : {}), - }; - }) - .filter(Boolean) as any[]; - if (normalized.length > 0) out[moduleId] = normalized; - } - return out; - })(); const nextEnabledModules = (enabledGroupsFromCfg ?? useConfigStore.getState().enabledProxyGroups).filter( (moduleId) => !hiddenProxyGroupSetFromCfg.has(moduleId) ); const ruleOrderFromCfg = normalizePersistedRuleOrder({ enabledModules: nextEnabledModules, customRules: customRulesFromCfg, - customProxyGroups: customProxyGroupsFromCfg as any, - moduleRuleOverrides: normalizedModuleRuleOverridesFromCfg ?? useConfigStore.getState().moduleRuleOverrides, - moduleRuleExclusions: moduleRuleExclusionsFromCfg ?? useConfigStore.getState().moduleRuleExclusions, + customRuleSets: customRuleSetsFromCfg, + builtinRuleEdits: builtinRuleEditsFromCfg, proxyGroupNameOverrides: proxyGroupNameOverridesFromCfg ? Object.fromEntries( Object.entries(proxyGroupNameOverridesFromCfg) @@ -493,10 +462,6 @@ export function useEditingSubscriptionLoader({ : useConfigStore.getState().cnIpNoResolve, ruleOrder: Array.isArray((cfg as any).ruleOrder) ? ((cfg as any).ruleOrder as string[]) : [], }); - const allRulesOrderEditingEnabledFromCfg = - typeof (cfg as any).allRulesOrderEditingEnabled === "boolean" - ? Boolean((cfg as any).allRulesOrderEditingEnabled) - : hasFullRuleOrderKeys(ruleOrderFromCfg); const listenerPortsFromCfg = (() => { const raw = (cfg as any).listenerPorts; if (!raw || typeof raw !== "object") return {}; @@ -538,14 +503,13 @@ export function useEditingSubscriptionLoader({ hiddenProxyGroups: hiddenProxyGroupsFromCfg, customRules: customRulesFromCfg as any, customProxyGroups: customProxyGroupsFromCfg as any, + customRuleSets: customRuleSetsFromCfg, + builtinRuleEdits: builtinRuleEditsFromCfg, filteredProxyGroups: filteredProxyGroupsFromCfg as any, - moduleRuleOverrides: normalizedModuleRuleOverridesFromCfg ?? state.moduleRuleOverrides, - moduleRuleExclusions: moduleRuleExclusionsFromCfg ?? state.moduleRuleExclusions, moduleRuleEditWarningAccepted: typeof (cfg as any).moduleRuleEditWarningAccepted === "boolean" ? Boolean((cfg as any).moduleRuleEditWarningAccepted) : state.moduleRuleEditWarningAccepted, - allRulesOrderEditingEnabled: allRulesOrderEditingEnabledFromCfg, dialerProxyGroups: dialerProxyGroupsFromCfg as any, proxyGroupNameOverrides: proxyGroupNameOverridesFromCfg ? Object.fromEntries( 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 1304578..e91fc01 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 @@ -56,10 +56,9 @@ export function makeOptions(overrides: Record = {}) { hiddenProxyGroups: [], customRules: [], customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, ruleOrder: [], - allRulesOrderEditingEnabled: false, - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, moduleRuleEditWarningAccepted: false, dialerProxyGroups: [], proxyGroupNameOverrides: {}, diff --git a/packages/ui/src/product/home/use-subscription-link.tsx b/packages/ui/src/product/home/use-subscription-link.tsx index 4333f52..3259b05 100644 --- a/packages/ui/src/product/home/use-subscription-link.tsx +++ b/packages/ui/src/product/home/use-subscription-link.tsx @@ -7,10 +7,8 @@ import { getNodeSourceIds, type SubscriptionSource, type DialerProxyGroup, - type ModuleRuleExclusions, - type ModuleRuleOverride, } from "@subboost/ui/store/config-store"; -import type { CustomRule, CustomProxyGroup } from "@subboost/core/types/config"; +import type { BuiltinRuleEdits, CustomRule, CustomProxyGroup, CustomRuleSet } from "@subboost/core/types/config"; import type { User } from "@subboost/ui/store/user-store"; import { useConfigStore } from "@subboost/ui/store/config-store"; import { captureAuthConfigHandoff } from "@subboost/ui/store/config-store/auth-handoff"; @@ -79,10 +77,9 @@ type Options = { hiddenProxyGroups: string[]; customRules: CustomRule[]; customProxyGroups: CustomProxyGroup[]; + customRuleSets: CustomRuleSet[]; + builtinRuleEdits: BuiltinRuleEdits; ruleOrder: string[]; - allRulesOrderEditingEnabled: boolean; - moduleRuleOverrides: Record; - moduleRuleExclusions: ModuleRuleExclusions; moduleRuleEditWarningAccepted: boolean; dialerProxyGroups: DialerProxyGroup[]; proxyGroupNameOverrides: Record; @@ -114,10 +111,9 @@ export function useSubscriptionLink({ hiddenProxyGroups, customRules, customProxyGroups, + customRuleSets, + builtinRuleEdits, ruleOrder, - allRulesOrderEditingEnabled, - moduleRuleOverrides, - moduleRuleExclusions, moduleRuleEditWarningAccepted, dialerProxyGroups, proxyGroupNameOverrides, @@ -383,11 +379,10 @@ export function useSubscriptionLink({ hiddenProxyGroups, customRules, ruleOrder, - allRulesOrderEditingEnabled, customProxyGroups, + customRuleSets, + builtinRuleEdits, filteredProxyGroups: useConfigStore.getState().filteredProxyGroups as FilteredProxyGroup[], - moduleRuleOverrides, - moduleRuleExclusions, moduleRuleEditWarningAccepted, dialerProxyGroups, proxyGroupNameOverrides, @@ -466,9 +461,10 @@ export function useSubscriptionLink({ cnIpNoResolve, experimentalCnUseCnRuleSet, customProxyGroups, + customRuleSets, + builtinRuleEdits, customRules, ruleOrder, - allRulesOrderEditingEnabled, deletedNodeNames, deletedNodes, dialerProxyGroups, @@ -481,8 +477,6 @@ export function useSubscriptionLink({ listenerPorts, goToLogin, moduleRuleEditWarningAccepted, - moduleRuleExclusions, - moduleRuleOverrides, nodes, proxyGroupNameOverrides, ruleProviderBaseUrl, diff --git a/packages/ui/src/product/preview/visual-graph.test.ts b/packages/ui/src/product/preview/visual-graph.test.ts index 475c83b..efde586 100644 --- a/packages/ui/src/product/preview/visual-graph.test.ts +++ b/packages/ui/src/product/preview/visual-graph.test.ts @@ -71,14 +71,22 @@ vi.mock("@subboost/ui/store/config-store", () => ({ useConfigStore: (selector?: any) => (typeof selector === "function" ? selector(mocks.store) : mocks.store), })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ - PROXY_GROUP_MODULES: [ - { id: "select", name: "🚀 节点选择", emoji: "🚀", groupType: "select", category: "core" }, - { id: "auto", name: "⚡ 自动选择", emoji: "⚡", groupType: "url-test", category: "service" }, - { id: "ad", name: "🛑 广告拦截", emoji: "🛑", groupType: "select", category: "other" }, - ], + PROXY_GROUP_MODULES: [ + { id: "select", name: "🚀 节点选择", emoji: "🚀", groupType: "select", category: "core" }, + { + id: "auto", + name: "⚡ 自动选择", + emoji: "⚡", + groupType: "url-test", + category: "service", + rules: [{ id: "r1", name: "Rule One", behavior: "domain" }], + }, + { id: "ad", name: "🛑 广告拦截", emoji: "🛑", groupType: "select", category: "other" }, + ], generateProxyGroups: vi.fn(() => mocks.generatedProxyGroups), })); vi.mock("@subboost/core/generator/module-rules", () => ({ + getModuleRuleOrderKey: (moduleId: string, ruleId: string) => `module:${moduleId}:${ruleId}`, getEffectiveModuleRules: vi.fn(() => mocks.effectiveRules), })); vi.mock("@subboost/core/proxy-group-name", () => ({ @@ -147,10 +155,10 @@ describe("VisualGraph", () => { enabledProxyGroups: ["select", "auto"], dialerProxyGroups: [{ id: "d1", name: "Relay", enabled: true, relayNodes: ["Relay"], targetNodes: ["Beta"], type: "select" }], customRules: [{ id: "manual" }], - customProxyGroups: [{ id: "custom-1", name: "🧩 Custom", emoji: "🧩", groupType: "load-balance", strategy: "round-robin", rules: [{ id: "cr", name: "Custom Rule", behavior: "ipcidr" }] }], + customProxyGroups: [{ id: "custom-1", name: "🧩 Custom", emoji: "🧩", groupType: "load-balance", strategy: "round-robin" }], + customRuleSets: [], filteredProxyGroups: [{ id: "filtered-1", name: "🧩 Filtered", emoji: "", enabled: true }], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, proxyGroupOrder: ["dialer:d1", "module:auto", "missing", "module:select", "dialer:d1"], testUrl: "https://example.com", diff --git a/packages/ui/src/product/preview/visual-graph.tsx b/packages/ui/src/product/preview/visual-graph.tsx index 11582fc..1944537 100644 --- a/packages/ui/src/product/preview/visual-graph.tsx +++ b/packages/ui/src/product/preview/visual-graph.tsx @@ -10,7 +10,7 @@ import { PROXY_GROUP_MODULES, generateProxyGroups, } from "@subboost/core/generator/proxy-groups"; -import { getEffectiveModuleRules } from "@subboost/core/generator/module-rules"; +import { getModuleRuleOrderKey } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { collectCustomRoutingRuleSets } from "@subboost/core/rules/custom-routing-rule-sets"; import { CustomRulesPreview } from "./visual-graph/custom-rules-preview"; @@ -31,9 +31,9 @@ export function VisualGraph() { dialerProxyGroups, customRules, customProxyGroups, + customRuleSets, filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + builtinRuleEdits, proxyGroupNameOverrides, proxyGroupOrder, testUrl, @@ -44,14 +44,14 @@ export function VisualGraph() { useShallow((state) => ({ nodes: state.nodes, enabledProxyGroups: state.enabledProxyGroups, - dialerProxyGroups: state.dialerProxyGroups, - customRules: state.customRules, - customProxyGroups: state.customProxyGroups, - filteredProxyGroups: state.filteredProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - proxyGroupOrder: state.proxyGroupOrder, + dialerProxyGroups: state.dialerProxyGroups ?? [], + customRules: state.customRules ?? [], + customProxyGroups: state.customProxyGroups ?? [], + customRuleSets: state.customRuleSets ?? [], + filteredProxyGroups: state.filteredProxyGroups ?? [], + builtinRuleEdits: state.builtinRuleEdits ?? {}, + proxyGroupNameOverrides: state.proxyGroupNameOverrides ?? {}, + proxyGroupOrder: state.proxyGroupOrder ?? [], testUrl: state.testUrl, testInterval: state.testInterval, ruleProviderBaseUrl: state.ruleProviderBaseUrl, @@ -117,9 +117,9 @@ export function VisualGraph() { testUrl, testInterval, customProxyGroups, + customRuleSets, filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + builtinRuleEdits, proxyGroupNameOverrides, }); }, [ @@ -129,9 +129,9 @@ export function VisualGraph() { testUrl, testInterval, customProxyGroups, + customRuleSets, filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + builtinRuleEdits, proxyGroupNameOverrides, ]); @@ -163,17 +163,37 @@ export function VisualGraph() { const groupName = typeof g.name === "string" ? g.name.trim() : ""; const mod = groupName ? moduleByName.get(groupName) : undefined; if (mod) { - const mergedRules = (() => { - return getEffectiveModuleRules( - mod, - moduleRuleOverrides, - moduleRuleExclusions, - ).map((r) => ({ + const moduleTarget = resolveModuleName(mod); + const mergedRules = [ + ...(mod.rules ?? []) + .filter((r) => { + const edit = builtinRuleEdits?.[getModuleRuleOrderKey(mod.id, r.id)]; + return edit?.enabled !== false && (edit?.target || moduleTarget) === moduleTarget; + }) + .map((r) => ({ id: r.id, name: r.name, behavior: r.behavior, - })); - })(); + })), + ...customRuleSets + .filter((ruleSet) => ruleSet.target === moduleTarget) + .map((ruleSet) => ({ + id: ruleSet.id, + name: ruleSet.name, + behavior: ruleSet.behavior, + })), + ...Object.entries(builtinRuleEdits || {}).flatMap(([key, edit]) => { + if (edit?.enabled === false || edit?.target !== moduleTarget) return []; + const match = key.match(/^module:([^:]+):(.+)$/); + if (!match) return []; + const [, sourceModuleId, ruleId] = match; + if (sourceModuleId === mod.id) return []; + const sourceModule = PROXY_GROUP_MODULES.find((module) => module.id === sourceModuleId); + const sourceRule = sourceModule?.rules?.find((rule) => rule.id === ruleId); + if (!sourceRule) return []; + return [{ id: sourceRule.id, name: sourceRule.name, behavior: sourceRule.behavior }]; + }), + ]; return { id: `module:${mod.id}`, @@ -211,10 +231,12 @@ export function VisualGraph() { groupType: cg?.groupType || g.type, strategy: cg?.strategy || g.strategy, category: "custom", - rules: (cg?.rules || []).map((r) => ({ - id: r.id, - name: r.name, - behavior: r.behavior, + rules: customRuleSets + .filter((ruleSet) => ruleSet.target === groupName) + .map((r) => ({ + id: r.id, + name: r.name, + behavior: r.behavior, })), }; }); @@ -287,8 +309,8 @@ export function VisualGraph() { enabledDialerProxyGroups, filteredProxyGroups, generatedProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, proxyGroupOrder, resolveModuleName, ]); @@ -306,11 +328,11 @@ export function VisualGraph() { const customRoutingRuleSets = React.useMemo( () => collectCustomRoutingRuleSets({ + customRuleSets, customProxyGroups, - moduleRuleOverrides, proxyGroupNameOverrides, }), - [customProxyGroups, moduleRuleOverrides, proxyGroupNameOverrides], + [customProxyGroups, customRuleSets, proxyGroupNameOverrides], ); // 注意:`containerRef` 有 `p-4`,Safari 上用 `clientWidth` 会把 padding 算进去,导致阈值判断偏大; diff --git a/packages/ui/src/product/preview/visual-graph/custom-rules-preview.test.ts b/packages/ui/src/product/preview/visual-graph/custom-rules-preview.test.ts index f33f5f3..c6a963d 100644 --- a/packages/ui/src/product/preview/visual-graph/custom-rules-preview.test.ts +++ b/packages/ui/src/product/preview/visual-graph/custom-rules-preview.test.ts @@ -27,7 +27,7 @@ describe("CustomRulesPreview", () => { const ruleSets: CustomRoutingRuleSetItem[] = [ { key: "set-1", - source: { kind: "module", id: "reject" }, + source: { kind: "custom-rule-set", id: "set-1" }, id: "set-1", name: "private", behavior: "domain", @@ -37,7 +37,7 @@ describe("CustomRulesPreview", () => { }, { key: "set-2", - source: { kind: "module", id: "direct" }, + source: { kind: "custom-rule-set", id: "set-2" }, id: "set-2", name: "direct", behavior: "ipcidr", diff --git a/packages/ui/src/store/config-store.test.ts b/packages/ui/src/store/config-store.test.ts index e12d145..e3062f1 100644 --- a/packages/ui/src/store/config-store.test.ts +++ b/packages/ui/src/store/config-store.test.ts @@ -72,7 +72,7 @@ describe("useConfigStore", () => { hiddenProxyGroups: ["ai", "ai", "", 123], cnIpNoResolve: false, }, - version: 9, + version: 10, }), }); const { setConfigDraftUserScope, useConfigStore } = await loadStore(storage); diff --git a/packages/ui/src/store/config-store.ts b/packages/ui/src/store/config-store.ts index cbae218..6c0f139 100644 --- a/packages/ui/src/store/config-store.ts +++ b/packages/ui/src/store/config-store.ts @@ -31,9 +31,11 @@ export { export type { ConfigActions, ConfigState, + BuiltinRuleEdits, + CustomRuleSet, DialerProxyGroup, - ModuleRuleExclusions, ModuleRuleOverride, + RuleSetDraft, SourceType, SubBoostTemplateConfig, SubscriptionSource, diff --git a/packages/ui/src/store/config-store/actions/custom-actions.test.ts b/packages/ui/src/store/config-store/actions/custom-actions.test.ts index 9d1c145..f113211 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.test.ts @@ -77,40 +77,43 @@ describe("custom config-store actions", () => { it("adds, removes, and renames custom proxy groups while updating matching rules", () => { const { actions, getState } = createHarness({ customRules: [rule({ id: "r1", target: "Old Group" }), rule({ id: "r2", target: "DIRECT" })], + customRuleSets: [{ id: "cg-rule-1", name: "CG Rule", behavior: "domain", path: "geosite/example.mrs", target: "Old Group" }], + builtinRuleEdits: { "module:ai:openai": { target: "Old Group" } }, }); - actions.addCustomProxyGroup({ name: "Old Group", emoji: "🧩", groupType: "select", rules: [] }); + actions.addCustomProxyGroup({ name: "Old Group", emoji: "🧩", groupType: "select" }); const groupId = getState().customProxyGroups[0].id; expect(groupId).toBe("custom-group-1767225600000"); actions.updateCustomProxyGroup(groupId, { name: "New Group", - rules: [{ id: "cg-rule-1", name: "CG Rule", behavior: "domain", url: "geosite/example.mrs" }], }); expect(getState().customProxyGroups[0]).toEqual( expect.objectContaining({ name: "New Group", - rules: [{ id: "cg-rule-1", name: "CG Rule", behavior: "domain", url: "geosite/example.mrs" }], }) ); expect(getState().customRules[0].target).toBe("New Group"); expect(getState().customRules[1].target).toBe("DIRECT"); + expect(getState().customRuleSets[0].target).toBe("New Group"); + expect(getState().builtinRuleEdits["module:ai:openai"].target).toBe("New Group"); actions.removeCustomProxyGroup(groupId); expect(getState().customProxyGroups).toEqual([]); + expect(getState().customRuleSets).toEqual([]); }); it("keeps custom rule targets unchanged when group updates do not rename a group", () => { const { actions, getState } = createHarness({ customRules: [rule({ id: "r1", target: "Stable Group" })], customProxyGroups: [ - { id: "group-1", name: "Stable Group", emoji: "", groupType: "select", rules: [] }, + { id: "group-1", name: "Stable Group", emoji: "", groupType: "select" }, ], }); actions.updateCustomProxyGroup("missing", { name: "Ghost Group" }); expect(getState().customProxyGroups).toEqual([ - { id: "group-1", name: "Stable Group", emoji: "", groupType: "select", rules: [] }, + { id: "group-1", name: "Stable Group", emoji: "", groupType: "select" }, ]); expect(getState().customRules[0].target).toBe("Stable Group"); diff --git a/packages/ui/src/store/config-store/actions/custom-actions.ts b/packages/ui/src/store/config-store/actions/custom-actions.ts index d06ea5d..486fb1d 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.ts @@ -1,4 +1,4 @@ -import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; +import type { BuiltinRuleEdits, CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; import { createCustomRuleId, ensureCustomRuleId, @@ -19,6 +19,43 @@ type CustomActions = Pick< | "updateCustomProxyGroup" >; +function normalizeRuleOrderForState(state: { + enabledProxyGroups: string[]; + customRules: Parameters[0]["customRules"]; + customRuleSets: Parameters[0]["customRuleSets"]; + builtinRuleEdits: Parameters[0]["builtinRuleEdits"]; + proxyGroupNameOverrides: Record; + experimentalCnUseCnRuleSet: boolean; + cnIpNoResolve: boolean; + ruleOrder: string[]; +}): string[] { + return normalizePersistedRuleOrder({ + enabledModules: state.enabledProxyGroups, + customRules: state.customRules, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, + proxyGroupNameOverrides: state.proxyGroupNameOverrides, + experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, + cnIpNoResolve: state.cnIpNoResolve, + ruleOrder: state.ruleOrder, + }); +} + +function retargetBuiltinRuleEdits(edits: BuiltinRuleEdits, from: string, to: string): BuiltinRuleEdits { + if (!from || from === to) return edits; + let changed = false; + const next: BuiltinRuleEdits = {}; + for (const [key, edit] of Object.entries(edits || {})) { + if (edit?.target === from) { + next[key] = { ...edit, target: to }; + changed = true; + } else { + next[key] = edit; + } + } + return changed ? next : edits; +} + export function createCustomActions( _set: SetState, _get: GetState, @@ -36,17 +73,7 @@ export function createCustomActions( ]; return { customRules: nextCustomRules, - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: nextCustomRules, - customProxyGroups: state.customProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, - }), + ruleOrder: normalizeRuleOrderForState({ ...state, customRules: nextCustomRules }), }; }); }, @@ -64,17 +91,7 @@ export function createCustomActions( const nextCustomRules = [...state.customRules, ...nextRules]; return { customRules: nextCustomRules, - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: nextCustomRules, - customProxyGroups: state.customProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, - }), + ruleOrder: normalizeRuleOrderForState({ ...state, customRules: nextCustomRules }), }; }); }, @@ -88,17 +105,7 @@ export function createCustomActions( ); return { customRules: nextCustomRules, - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: nextCustomRules, - customProxyGroups: state.customProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, - }), + ruleOrder: normalizeRuleOrderForState({ ...state, customRules: nextCustomRules }), }; }); }, @@ -108,78 +115,53 @@ export function createCustomActions( const nextCustomRules = state.customRules.filter((_, i) => i !== index); return { customRules: nextCustomRules, - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: nextCustomRules, - customProxyGroups: state.customProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, - }), + ruleOrder: normalizeRuleOrderForState({ ...state, customRules: nextCustomRules }), }; }); }, setRuleOrder: (order: string[]) => { setAndGenerateConfig((state) => ({ - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: state.customRules, - customProxyGroups: state.customProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: order, - }), + ruleOrder: normalizeRuleOrderForState({ ...state, ruleOrder: order }), })); }, addCustomProxyGroup: (group: Omit) => { const id = `custom-group-${Date.now()}`; setAndGenerateConfig((state) => { + const nextGroup: CustomProxyGroup = { + id, + name: group.name, + emoji: group.emoji, + groupType: group.groupType, + ...(group.groupType === "load-balance" && group.strategy ? { strategy: group.strategy } : {}), + }; const nextCustomProxyGroups = [ ...state.customProxyGroups, - { ...group, id }, + nextGroup, ]; return { customProxyGroups: nextCustomProxyGroups, - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: state.customRules, - customProxyGroups: nextCustomProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, - }), }; }); }, removeCustomProxyGroup: (id: string) => { setAndGenerateConfig((state) => { + const removedGroup = state.customProxyGroups.find((g) => g.id === id); const nextCustomProxyGroups = state.customProxyGroups.filter( (g) => g.id !== id, ); + const removedTarget = removedGroup?.name?.trim() || ""; + const nextCustomRuleSets = removedTarget + ? state.customRuleSets.filter((ruleSet) => ruleSet.target !== removedTarget) + : state.customRuleSets; return { customProxyGroups: nextCustomProxyGroups, - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: state.customRules, - customProxyGroups: nextCustomProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, + customRuleSets: nextCustomRuleSets, + ruleOrder: normalizeRuleOrderForState({ + ...state, + customRuleSets: nextCustomRuleSets, }), }; }); @@ -196,22 +178,29 @@ export function createCustomActions( rule.target === prevName ? { ...rule, target: nextName } : rule, ) : state.customRules; + const nextCustomRuleSets = + prevName && nextName && prevName !== nextName + ? state.customRuleSets.map((ruleSet) => + ruleSet.target === prevName ? { ...ruleSet, target: nextName } : ruleSet, + ) + : state.customRuleSets; + const nextBuiltinRuleEdits = + prevName && nextName && prevName !== nextName + ? retargetBuiltinRuleEdits(state.builtinRuleEdits, prevName, nextName) + : state.builtinRuleEdits; const nextCustomProxyGroups = state.customProxyGroups.map((g) => g.id === id ? { ...g, ...group } : g, ); return { customRules: nextCustomRules, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, customProxyGroups: nextCustomProxyGroups, - ruleOrder: normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, + ruleOrder: normalizeRuleOrderForState({ + ...state, customRules: nextCustomRules, - customProxyGroups: nextCustomProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, }), }; }); diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts index 273e6f6..7e1a5ff 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts @@ -306,15 +306,15 @@ describe("createProxyGroupActions", () => { it("adds, updates, removes, and restores module rules", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "ai"], - moduleRuleExclusions: { ai: ["openai"] }, - moduleRuleOverrides: {}, + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, + customRuleSets: [], }); actions.addModuleRules("", [ { id: "ignored", name: "Ignored", behavior: "domain", path: "geosite/ignored.mrs" }, ]); actions.addModuleRules("ai", []); - expect(getState().moduleRuleOverrides).toEqual({}); + expect(getState().customRuleSets).toEqual([]); actions.addModuleRules("ai", [ { id: "openai", name: "OpenAI", behavior: "domain", path: "geosite/openai.mrs" }, @@ -322,9 +322,9 @@ describe("createProxyGroupActions", () => { { id: "", name: "Invalid", behavior: "domain", path: "" }, ]); - expect(getState().moduleRuleExclusions).toEqual({}); - expect(getState().moduleRuleOverrides.ai).toEqual([ - { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs" }, + expect(getState().builtinRuleEdits).toEqual({}); + expect(getState().customRuleSets).toEqual([ + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }, ]); const beforeDuplicateAdd = getState(); @@ -339,11 +339,12 @@ describe("createProxyGroupActions", () => { path: "geoip/custom-ai.mrs", }); - expect(getState().moduleRuleOverrides.ai[0]).toEqual({ + expect(getState().customRuleSets[0]).toEqual({ id: "custom-ai", name: "Custom AI IP", behavior: "ipcidr", path: "geoip/custom-ai.mrs", + target: "🤖 AI 服务", noResolve: true, }); @@ -355,21 +356,21 @@ describe("createProxyGroupActions", () => { expect(getState()).toBe(beforeMissingUpdate); actions.removeModuleRule("ai", "openai"); - expect(getState().moduleRuleExclusions).toEqual({ ai: ["openai"] }); + expect(getState().builtinRuleEdits).toEqual({ "module:ai:openai": { enabled: false } }); actions.removeModuleRule("ai", "missing"); actions.removeModuleRule("missing", "openai"); - expect(getState().moduleRuleExclusions).toEqual({ ai: ["openai"] }); + expect(getState().builtinRuleEdits).toEqual({ "module:ai:openai": { enabled: false } }); actions.restoreModuleRule("ai", "openai"); - expect(getState().moduleRuleExclusions).toEqual({}); + expect(getState().builtinRuleEdits).toEqual({}); actions.restoreModuleRule("ai", "openai"); actions.restoreModuleRule("missing", "openai"); - expect(getState().moduleRuleExclusions).toEqual({}); + expect(getState().builtinRuleEdits).toEqual({}); actions.removeModuleRule("ai", "custom-ai"); - expect(getState().moduleRuleOverrides).toEqual({}); + expect(getState().customRuleSets).toEqual([]); }); it("keeps full rule order positions across preset rule remove, restore, hide, and move", () => { @@ -378,8 +379,8 @@ describe("createProxyGroupActions", () => { enabledModules: enabledProxyGroups, customRules: [], customProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, experimentalCnUseCnRuleSet: true, cnIpNoResolve: true, @@ -390,7 +391,6 @@ describe("createProxyGroupActions", () => { .map((entry) => entry.key); const openAiKey = "module:ai:openai"; const appleTvPlusKey = "module:streaming-west:apple-tvplus"; - const movedAppleTvPlusKey = "module:google:apple-tvplus"; const openAiIndex = fullRuleOrder.indexOf(openAiKey); const appleTvPlusIndex = fullRuleOrder.indexOf(appleTvPlusKey); const getAppliedOrder = () => { @@ -399,22 +399,20 @@ describe("createProxyGroupActions", () => { ...baseRuleOptions, enabledModules: state.enabledProxyGroups, customRules: state.customRules, - customProxyGroups: state.customProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, proxyGroupNameOverrides: state.proxyGroupNameOverrides, ruleOrder: state.ruleOrder, }); }; const { actions, getState } = createHarness({ enabledProxyGroups, - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, experimentalCnUseCnRuleSet: true, cnIpNoResolve: true, ruleOrder: fullRuleOrder, - allRulesOrderEditingEnabled: true, }); actions.removeModuleRule("ai", "openai"); @@ -431,31 +429,34 @@ describe("createProxyGroupActions", () => { actions.moveModuleRule("streaming-west", "apple-tvplus", { kind: "module", id: "google" }); expect(getState().ruleOrder).toContain(appleTvPlusKey); - expect(getAppliedOrder().indexOf(movedAppleTvPlusKey)).toBe(appleTvPlusIndex); - actions.moveModuleRule("google", "apple-tvplus", { kind: "module", id: "streaming-west" }); + expect(getState().builtinRuleEdits[appleTvPlusKey]).toEqual({ target: "🔍 谷歌服务" }); + expect(getAppliedOrder().indexOf(appleTvPlusKey)).toBe(appleTvPlusIndex); + actions.resetModuleRuleTarget("streaming-west", "apple-tvplus"); + expect(getState().builtinRuleEdits).toEqual({}); expect(getAppliedOrder().indexOf(appleTvPlusKey)).toBe(appleTvPlusIndex); }); it("adds preset-only and custom module rules with normalized fallback fields", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "ai"], - moduleRuleExclusions: { ai: ["openai"] }, - moduleRuleOverrides: undefined, + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, + customRuleSets: [], + customProxyGroups: [{ id: "custom-module", name: "Custom Module", emoji: "", groupType: "select" }], }); actions.addModuleRules("ai", [ { id: "openai", name: "OpenAI", behavior: "domain", path: "geosite/openai.mrs" }, ]); - expect(getState().moduleRuleOverrides).toBeUndefined(); - expect(getState().moduleRuleExclusions).toEqual({}); + expect(getState().customRuleSets).toEqual([]); + expect(getState().builtinRuleEdits).toEqual({}); actions.addModuleRules("custom-module", [ { id: "custom", name: " ", behavior: "domain", path: "geoip/custom.mrs" }, ]); - expect(getState().moduleRuleOverrides["custom-module"]).toEqual([ - { id: "custom", name: "custom", behavior: "ipcidr", path: "geoip/custom.mrs", noResolve: true }, + expect(getState().customRuleSets).toEqual([ + { id: "custom", name: "custom", behavior: "ipcidr", path: "geoip/custom.mrs", target: "Custom Module", noResolve: true }, ]); const beforePresetNoop = getState(); @@ -468,8 +469,8 @@ describe("createProxyGroupActions", () => { it("keeps active preset module rules stable when nothing needs restoring", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "ai"], - moduleRuleExclusions: {}, - moduleRuleOverrides: {}, + builtinRuleEdits: {}, + customRuleSets: [], }); const before = getState(); @@ -482,16 +483,20 @@ describe("createProxyGroupActions", () => { it("restores all default module rules for one module and accepts edit warnings", () => { const { actions, getState } = createHarness({ - moduleRuleExclusions: { ai: ["openai", "anthropic"], youtube: ["youtube"] }, + builtinRuleEdits: { + "module:ai:openai": { enabled: false }, + "module:ai:anthropic": { enabled: false }, + "module:youtube:youtube": { enabled: false }, + }, moduleRuleEditWarningAccepted: false, }); actions.restoreModuleDefaultRules("ai"); - expect(getState().moduleRuleExclusions).toEqual({ youtube: ["youtube"] }); + expect(getState().builtinRuleEdits).toEqual({ "module:youtube:youtube": { enabled: false } }); actions.restoreModuleDefaultRules(""); actions.restoreModuleDefaultRules("ai"); - expect(getState().moduleRuleExclusions).toEqual({ youtube: ["youtube"] }); + expect(getState().builtinRuleEdits).toEqual({ "module:youtube:youtube": { enabled: false } }); actions.acceptModuleRuleEditWarning(); expect(getState().moduleRuleEditWarningAccepted).toBe(true); @@ -507,43 +512,36 @@ describe("createProxyGroupActions", () => { name: "Custom", emoji: "", groupType: "select", - rules: [], }, { id: "custom-2", name: "Other", emoji: "", groupType: "select", - rules: [], }, ], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, }); actions.moveModuleRule("ai", "openai", { kind: "module", id: "youtube" }); expect(getState().enabledProxyGroups).toContain("youtube"); - expect(getState().moduleRuleExclusions).toEqual({ ai: ["openai"] }); - expect(getState().moduleRuleOverrides.youtube).toEqual([ - { id: "openai", name: "OpenAI", behavior: "domain", path: "geosite/openai.mrs" }, - ]); + expect(getState().builtinRuleEdits).toEqual({ "module:ai:openai": { target: "📹 油管视频" } }); actions.moveModuleRule("ai", "anthropic", { kind: "custom", id: "custom-1" }); - expect(getState().customProxyGroups[0].rules).toEqual([ - { - id: "anthropic", - name: "Anthropic (Claude)", - behavior: "domain", - url: "https://rules.example.com/base/geosite/anthropic.mrs", - }, - ]); - expect(getState().customProxyGroups[1].rules).toEqual([]); - expect(getState().moduleRuleExclusions.ai).toEqual(["openai", "anthropic"]); + expect(getState().customRuleSets).toEqual([]); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "📹 油管视频" }, + "module:ai:anthropic": { target: "Custom" }, + }); actions.moveModuleRule("ai", "anthropic", { kind: "custom", id: "custom-1" }); - expect(getState().customProxyGroups[0].rules).toHaveLength(1); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "📹 油管视频" }, + "module:ai:anthropic": { target: "Custom" }, + }); const beforeIgnoredMoves = getState(); actions.moveModuleRule("", "openai", { kind: "module", id: "youtube" }); @@ -560,52 +558,44 @@ describe("createProxyGroupActions", () => { it("moves custom module override rules into builtin modules", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto"], - moduleRuleOverrides: { - ai: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs" }], - }, - moduleRuleExclusions: {}, + customRuleSets: [ + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }, + ], + builtinRuleEdits: {}, }); actions.moveModuleRule("ai", "custom-ai", { kind: "module", id: "youtube" }); expect(getState().enabledProxyGroups).toEqual(["select", "auto", "youtube"]); - expect(getState().moduleRuleOverrides).toEqual({ - youtube: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs" }], - }); + expect(getState().customRuleSets).toEqual([ + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "📹 油管视频" }, + ]); }); it("moves extra rules without duplicating target presets or existing target overrides", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "youtube"], - moduleRuleOverrides: { - ai: [ - { id: "youtube", name: "YouTube Copy", behavior: "domain", path: "geosite/youtube.mrs" }, - { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs" }, - ], - youtube: [ - { id: "custom-ai", name: "Existing Custom AI", behavior: "domain", path: "geosite/existing.mrs" }, - ], - }, - moduleRuleExclusions: { youtube: ["youtube"] }, + customRuleSets: [ + { id: "youtube", name: "YouTube Copy", behavior: "domain", path: "geosite/youtube.mrs", target: "🤖 AI 服务" }, + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }, + { id: "custom-ai", name: "Existing Custom AI", behavior: "domain", path: "geosite/existing.mrs", target: "📹 油管视频" }, + ], + builtinRuleEdits: { "module:youtube:youtube": { enabled: false } }, }); actions.moveModuleRule("ai", "youtube", { kind: "module", id: "youtube" }); - expect(getState().moduleRuleExclusions).toEqual({}); - expect(getState().moduleRuleOverrides.ai).toEqual([ - { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs" }, - ]); - expect(getState().moduleRuleOverrides.youtube).toEqual([ - { id: "custom-ai", name: "Existing Custom AI", behavior: "domain", path: "geosite/existing.mrs" }, + expect(getState().builtinRuleEdits).toEqual({}); + expect(getState().customRuleSets).toEqual([ + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }, + { id: "custom-ai", name: "Existing Custom AI", behavior: "domain", path: "geosite/existing.mrs", target: "📹 油管视频" }, ]); actions.moveModuleRule("ai", "custom-ai", { kind: "module", id: "youtube" }); - expect(getState().moduleRuleOverrides).toEqual({ - youtube: [ - { id: "custom-ai", name: "Existing Custom AI", behavior: "domain", path: "geosite/existing.mrs" }, - ], - }); + expect(getState().customRuleSets).toEqual([ + { id: "custom-ai", name: "Existing Custom AI", behavior: "domain", path: "geosite/existing.mrs", target: "📹 油管视频" }, + ]); }); it("keeps no-resolve when moving IP preset rules into custom groups", () => { @@ -617,25 +607,16 @@ describe("createProxyGroupActions", () => { name: "Custom", emoji: "", groupType: "select", - rules: [], }, ], - moduleRuleExclusions: {}, - moduleRuleOverrides: {}, + customRuleSets: [], + builtinRuleEdits: {}, }); actions.moveModuleRule("private", "private-ip", { kind: "custom", id: "custom-1" }); - expect(getState().customProxyGroups[0].rules).toEqual([ - { - id: "private-ip", - name: "私有IP", - behavior: "ipcidr", - url: "https://rules.example.com/base/geoip/private.mrs", - noResolve: true, - }, - ]); - expect(getState().moduleRuleExclusions).toEqual({ private: ["private-ip"] }); + expect(getState().customRuleSets).toEqual([]); + expect(getState().builtinRuleEdits).toEqual({ "module:private:private-ip": { target: "Custom" } }); }); it("renames non-core module groups and rewrites custom rule targets", () => { diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts index c11de1f..4085799 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts @@ -1,15 +1,18 @@ import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy } from "@subboost/core/types/config"; +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + isLoadBalanceStrategy, + type BuiltinRuleEdits, + type CustomProxyGroup, + type CustomRuleSet, + type RuleSetBehavior, +} from "@subboost/core/types/config"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; -import { - getModuleRuleById, - isPresetModuleRule, - normalizeModuleRuleExclusions, - type ModuleRuleExclusions, -} from "@subboost/core/generator/module-rules"; +import { getModuleRuleOrderKey, isPresetModuleRule } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import type { ConfigActions, ModuleRuleOverride } from "../definitions"; +import { isValidRuleSetPathOrUrl, normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; +import type { ConfigActions, RuleSetDraft } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; type ProxyGroupActions = Pick< @@ -25,6 +28,7 @@ type ProxyGroupActions = Pick< | "removeModuleRule" | "moveModuleRule" | "restoreModuleRule" + | "resetModuleRuleTarget" | "restoreModuleDefaultRules" | "acceptModuleRuleEditWarning" | "setProxyGroupNameOverride" @@ -46,15 +50,15 @@ function normalizeStringList(value: unknown): string[] { } function isBuiltinProxyGroup(moduleId: string): boolean { - return PROXY_GROUP_MODULES.some((module) => module.id === moduleId); + return PROXY_GROUP_MODULES.some((proxyModule) => proxyModule.id === moduleId); } -function normalizeModuleRuleOverride(rule: ModuleRuleOverride): ModuleRuleOverride | null { +function normalizeRuleSetDraft(rule: RuleSetDraft): RuleSetDraft | null { if (!rule || typeof rule.id !== "string" || typeof rule.path !== "string") return null; const id = rule.id.trim(); - const path = rule.path.trim(); - if (!id || !path) return null; - const behavior = rule.behavior === "ipcidr" || path.toLowerCase().startsWith("geoip/") + const path = normalizeRuleSetPathInput(rule.path); + if (!id || !path || !isValidRuleSetPathOrUrl(path)) return null; + const behavior: RuleSetBehavior = rule.behavior === "ipcidr" || path.toLowerCase().startsWith("geoip/") ? "ipcidr" : "domain"; return { @@ -66,49 +70,11 @@ function normalizeModuleRuleOverride(rule: ModuleRuleOverride): ModuleRuleOverri }; } -function removeRuleFromModuleOverrides( - overrides: Record, - moduleId: string, - ruleId: string -): Record { - const map = { ...(overrides || {}) }; - const next = (map[moduleId] || []).filter((rule) => rule.id !== ruleId); - if (next.length === 0) delete map[moduleId]; - else map[moduleId] = next; - return map; -} - -function addPresetRuleExclusion( - exclusions: ModuleRuleExclusions, - moduleId: string, - ruleId: string -): ModuleRuleExclusions { - const map = normalizeModuleRuleExclusions(exclusions); - const prev = map[moduleId] || []; - if (prev.includes(ruleId)) return map; - return { ...map, [moduleId]: [...prev, ruleId] }; -} - -function removePresetRuleExclusion( - exclusions: ModuleRuleExclusions, - moduleId: string, - ruleId: string -): ModuleRuleExclusions { - const map = normalizeModuleRuleExclusions(exclusions); - const next = (map[moduleId] || []).filter((id) => id !== ruleId); - if (next.length === 0) { - const { [moduleId]: _removed, ...rest } = map; - return rest; - } - return { ...map, [moduleId]: next }; -} - function normalizeRuleOrderForState(state: { enabledProxyGroups: string[]; customRules: Parameters[0]["customRules"]; - customProxyGroups: Parameters[0]["customProxyGroups"]; - moduleRuleOverrides: Record; - moduleRuleExclusions: ModuleRuleExclusions; + customRuleSets: Parameters[0]["customRuleSets"]; + builtinRuleEdits: Parameters[0]["builtinRuleEdits"]; proxyGroupNameOverrides: Record; experimentalCnUseCnRuleSet: boolean; cnIpNoResolve: boolean; @@ -117,9 +83,8 @@ function normalizeRuleOrderForState(state: { return normalizePersistedRuleOrder({ enabledModules: state.enabledProxyGroups, customRules: state.customRules, - customProxyGroups: state.customProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, proxyGroupNameOverrides: state.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, cnIpNoResolve: state.cnIpNoResolve, @@ -127,6 +92,97 @@ function normalizeRuleOrderForState(state: { }); } +function resolveModuleTargetName(moduleId: string, overrides?: Record): string | null { + const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === moduleId); + if (!proxyModule) return null; + return resolveProxyGroupModuleName(proxyModule, overrides?.[moduleId]); +} + +function resolveMoveTargetName( + target: { kind: "module" | "custom"; id: string }, + customProxyGroups: CustomProxyGroup[], + proxyGroupNameOverrides?: Record +): string | null { + if (target.kind === "module") return resolveModuleTargetName(target.id, proxyGroupNameOverrides); + const group = customProxyGroups.find((item) => item.id === target.id); + return group?.name?.trim() || null; +} + +function compactBuiltinRuleEdits(edits: BuiltinRuleEdits): BuiltinRuleEdits { + const next: BuiltinRuleEdits = {}; + for (const [key, edit] of Object.entries(edits || {})) { + const target = typeof edit?.target === "string" ? edit.target.trim() : ""; + const enabled = edit?.enabled === false ? false : undefined; + if (!target && enabled !== false) continue; + next[key] = { + ...(target ? { target } : {}), + ...(enabled === false ? { enabled: false } : {}), + }; + } + return next; +} + +function updateBuiltinRuleEdit( + edits: BuiltinRuleEdits, + key: string, + patch: { target?: string | null; enabled?: false | true | null } +): BuiltinRuleEdits { + const prev = edits?.[key] || {}; + const next = { ...prev }; + if ("target" in patch) { + const target = typeof patch.target === "string" ? patch.target.trim() : ""; + if (target) next.target = target; + else delete next.target; + } + if ("enabled" in patch) { + if (patch.enabled === false) next.enabled = false; + else delete next.enabled; + } + return compactBuiltinRuleEdits({ ...(edits || {}), [key]: next }); +} + +function retargetBuiltinRuleEdits(edits: BuiltinRuleEdits, from: string, to: string): BuiltinRuleEdits { + if (!from || from === to) return edits; + let changed = false; + const next: BuiltinRuleEdits = {}; + for (const [key, edit] of Object.entries(edits || {})) { + if (edit?.target === from) { + next[key] = { ...edit, target: to }; + changed = true; + } else { + next[key] = edit; + } + } + return changed ? compactBuiltinRuleEdits(next) : edits; +} + +function findBuiltinRuleEditKeyByTarget(edits: BuiltinRuleEdits, target: string, ruleId: string): string | null { + if (!target || !ruleId) return null; + for (const [key, edit] of Object.entries(edits || {})) { + if (edit?.target !== target) continue; + const parts = key.split(":"); + if (parts.length !== 3 || parts[0] !== "module") continue; + if (parts[2] === ruleId) return key; + } + return null; +} + +function appendUniqueCustomRuleSets( + existing: CustomRuleSet[], + drafts: RuleSetDraft[], + target: string +): CustomRuleSet[] { + const seen = new Set(existing.map((item) => item.id)); + const next = [...existing]; + for (const draft of drafts) { + const ruleSet = normalizeRuleSetDraft(draft); + if (!ruleSet || seen.has(ruleSet.id)) continue; + seen.add(ruleSet.id); + next.push({ ...ruleSet, target }); + } + return next; +} + export function createProxyGroupActions( _set: SetState, _get: GetState, @@ -333,51 +389,45 @@ export function createProxyGroupActions( }); }, - addModuleRules: (moduleId: string, rules: ModuleRuleOverride[]) => { + addModuleRules: (moduleId: string, rules: RuleSetDraft[]) => { const id = (moduleId || "").trim(); if (!id) return; if (!Array.isArray(rules) || rules.length === 0) return; setAndGenerateConfig((state) => { - const prev = state.moduleRuleOverrides?.[id] || []; - const existing = new Set(prev.map((r) => r.id)); - const mod = PROXY_GROUP_MODULES.find((m) => m.id === id); - const presetIds = new Set((mod?.rules || []).map((r) => r.id)); - - const incoming: ModuleRuleOverride[] = rules - .map((r) => normalizeModuleRuleOverride(r)) - .filter((r): r is ModuleRuleOverride => Boolean(r)) - .filter((r) => r.id && r.path && !existing.has(r.id)); - - if (incoming.length === 0) return state; - - const nextModuleRuleExclusions = incoming.reduce( - (map, rule) => removePresetRuleExclusion(map, id, rule.id), - state.moduleRuleExclusions - ); - const incomingOverrides = incoming.filter((rule) => !presetIds.has(rule.id)); - const nextModuleRuleOverrides = - incomingOverrides.length > 0 - ? { - ...(state.moduleRuleOverrides || {}), - [id]: [...prev, ...incomingOverrides], - } - : state.moduleRuleOverrides; - - if ( - nextModuleRuleOverrides === state.moduleRuleOverrides && - nextModuleRuleExclusions === state.moduleRuleExclusions - ) { - return state; + const target = + resolveModuleTargetName(id, state.proxyGroupNameOverrides) || + state.customProxyGroups.find((group) => group.id === id)?.name?.trim(); + if (!target) return state; + const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === id); + let nextBuiltinRuleEdits = state.builtinRuleEdits; + const customDrafts: RuleSetDraft[] = []; + for (const draft of rules) { + const normalized = normalizeRuleSetDraft(draft); + if (!normalized) continue; + if (proxyModule && isPresetModuleRule(proxyModule, normalized.id)) { + const key = getModuleRuleOrderKey(proxyModule.id, normalized.id); + nextBuiltinRuleEdits = updateBuiltinRuleEdit(nextBuiltinRuleEdits, key, { + enabled: true, + target: null, + }); + continue; + } + customDrafts.push(normalized); } + const nextCustomRuleSets = appendUniqueCustomRuleSets(state.customRuleSets, customDrafts, target); + if ( + nextCustomRuleSets.length === state.customRuleSets.length && + nextBuiltinRuleEdits === state.builtinRuleEdits + ) return state; return { - moduleRuleOverrides: nextModuleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, ruleOrder: normalizeRuleOrderForState({ ...state, - moduleRuleOverrides: nextModuleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, }), }; }); @@ -386,37 +436,36 @@ export function createProxyGroupActions( updateModuleRule: ( moduleId: string, ruleId: string, - rule: Partial> + rule: Partial> ) => { const id = (moduleId || "").trim(); const rid = (ruleId || "").trim(); if (!id || !rid) return; setAndGenerateConfig((state) => { - const prev = state.moduleRuleOverrides?.[id] || []; - const index = prev.findIndex((item) => item.id === rid); + const target = + resolveModuleTargetName(id, state.proxyGroupNameOverrides) || + state.customProxyGroups.find((group) => group.id === id)?.name?.trim(); + if (!target) return state; + const index = state.customRuleSets.findIndex((item) => item.id === rid && item.target === target); if (index < 0) return state; - const normalized = normalizeModuleRuleOverride({ - ...prev[index], + const normalized = normalizeRuleSetDraft({ + ...state.customRuleSets[index], ...rule, id: rid, }); if (!normalized) return state; - const nextRules = prev.map((item, itemIndex) => - itemIndex === index ? normalized : item + const nextCustomRuleSets = state.customRuleSets.map((item, itemIndex) => + itemIndex === index ? { ...normalized, target } : item ); - const nextModuleRuleOverrides = { - ...(state.moduleRuleOverrides || {}), - [id]: nextRules, - }; return { - moduleRuleOverrides: nextModuleRuleOverrides, + customRuleSets: nextCustomRuleSets, ruleOrder: normalizeRuleOrderForState({ ...state, - moduleRuleOverrides: nextModuleRuleOverrides, + customRuleSets: nextCustomRuleSets, }), }; }); @@ -429,26 +478,42 @@ export function createProxyGroupActions( setAndGenerateConfig((state) => { const mod = PROXY_GROUP_MODULES.find((m) => m.id === id); - if (!mod) return state; - - const isPreset = isPresetModuleRule(mod, rid); - const isExtra = (state.moduleRuleOverrides?.[id] || []).some((rule) => rule.id === rid); - if (!isPreset && !isExtra) return state; - - const nextModuleRuleOverrides = isExtra - ? removeRuleFromModuleOverrides(state.moduleRuleOverrides || {}, id, rid) - : state.moduleRuleOverrides; - const nextModuleRuleExclusions = isPreset - ? addPresetRuleExclusion(state.moduleRuleExclusions, id, rid) - : state.moduleRuleExclusions; + if (mod && isPresetModuleRule(mod, rid)) { + const key = getModuleRuleOrderKey(id, rid); + const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, key, { enabled: false }); + return { + builtinRuleEdits: nextBuiltinRuleEdits, + ruleOrder: normalizeRuleOrderForState({ + ...state, + builtinRuleEdits: nextBuiltinRuleEdits, + }), + }; + } + const target = + resolveModuleTargetName(id, state.proxyGroupNameOverrides) || + state.customProxyGroups.find((group) => group.id === id)?.name?.trim(); + if (!target) return state; + const movedBuiltinKey = findBuiltinRuleEditKeyByTarget(state.builtinRuleEdits, target, rid); + if (movedBuiltinKey) { + const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, movedBuiltinKey, { enabled: false }); + return { + builtinRuleEdits: nextBuiltinRuleEdits, + ruleOrder: normalizeRuleOrderForState({ + ...state, + builtinRuleEdits: nextBuiltinRuleEdits, + }), + }; + } + const nextCustomRuleSets = state.customRuleSets.filter( + (ruleSet) => !(ruleSet.id === rid && ruleSet.target === target) + ); + if (nextCustomRuleSets.length === state.customRuleSets.length) return state; return { - moduleRuleOverrides: nextModuleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + customRuleSets: nextCustomRuleSets, ruleOrder: normalizeRuleOrderForState({ ...state, - moduleRuleOverrides: nextModuleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + customRuleSets: nextCustomRuleSets, }), }; }); @@ -463,84 +528,95 @@ export function createProxyGroupActions( setAndGenerateConfig((state) => { const sourceModule = PROXY_GROUP_MODULES.find((m) => m.id === sourceId); - if (!sourceModule) return state; + const targetName = resolveMoveTargetName(target, state.customProxyGroups, state.proxyGroupNameOverrides); + if (!targetName) return state; + const sourceTarget = + resolveModuleTargetName(sourceId, state.proxyGroupNameOverrides) || + state.customProxyGroups.find((group) => group.id === sourceId)?.name?.trim(); + if (!sourceTarget) return state; if (target.kind === "module" && targetId === sourceId) return state; - const sourceRule = getModuleRuleById(sourceModule, rid, state.moduleRuleOverrides); - const normalizedRule = sourceRule ? normalizeModuleRuleOverride(sourceRule as ModuleRuleOverride) : null; - if (!normalizedRule) return state; - - const sourceHasPreset = isPresetModuleRule(sourceModule, rid); - const sourceHasExtra = (state.moduleRuleOverrides?.[sourceId] || []).some((rule) => rule.id === rid); - if (!sourceHasPreset && !sourceHasExtra) return state; - - let nextModuleRuleOverrides = sourceHasExtra - ? removeRuleFromModuleOverrides(state.moduleRuleOverrides || {}, sourceId, rid) - : state.moduleRuleOverrides; - let nextModuleRuleExclusions = sourceHasPreset - ? addPresetRuleExclusion(state.moduleRuleExclusions, sourceId, rid) - : state.moduleRuleExclusions; - let nextCustomProxyGroups = state.customProxyGroups; let nextEnabledProxyGroups = state.enabledProxyGroups; if (target.kind === "module") { - const targetModule = PROXY_GROUP_MODULES.find((m) => m.id === targetId); - if (!targetModule) return state; - - const targetHasPreset = isPresetModuleRule(targetModule, rid); - const targetHasExtra = (nextModuleRuleOverrides?.[targetId] || []).some((rule) => rule.id === rid); - - if (targetHasPreset) { - nextModuleRuleExclusions = removePresetRuleExclusion(nextModuleRuleExclusions, targetId, rid); - } else if (!targetHasExtra) { - const prev = nextModuleRuleOverrides?.[targetId] || []; - nextModuleRuleOverrides = { - ...(nextModuleRuleOverrides || {}), - [targetId]: [...prev, normalizedRule], - }; - } - if (!nextEnabledProxyGroups.includes(targetId)) { nextEnabledProxyGroups = [...nextEnabledProxyGroups, targetId]; } - } else { - const targetGroup = nextCustomProxyGroups.find((group) => group.id === targetId); - if (!targetGroup) return state; - - const exists = targetGroup.rules.some((rule) => rule.id === rid); - if (!exists) { - const url = `${state.ruleProviderBaseUrl.replace(/\/+$/, "")}/${normalizedRule.path}`; - nextCustomProxyGroups = nextCustomProxyGroups.map((group) => - group.id === targetId - ? { - ...group, - rules: [ - ...group.rules, - { - id: normalizedRule.id, - name: normalizedRule.name, - behavior: normalizedRule.behavior, - url, - ...(normalizedRule.noResolve ? { noResolve: true } : {}), - }, - ], - } - : group + } + + const customRuleSetIndex = state.customRuleSets.findIndex( + (ruleSet) => ruleSet.id === rid && ruleSet.target === sourceTarget + ); + if (customRuleSetIndex >= 0) { + const targetModule = target.kind === "module" + ? PROXY_GROUP_MODULES.find((proxyModule) => proxyModule.id === targetId) + : undefined; + let nextBuiltinRuleEdits = state.builtinRuleEdits; + let nextCustomRuleSets: CustomRuleSet[]; + if (targetModule && isPresetModuleRule(targetModule, rid)) { + const targetKey = getModuleRuleOrderKey(targetModule.id, rid); + nextBuiltinRuleEdits = updateBuiltinRuleEdit(nextBuiltinRuleEdits, targetKey, { + enabled: true, + target: null, + }); + nextCustomRuleSets = state.customRuleSets.filter((_, index) => index !== customRuleSetIndex); + } else if ( + state.customRuleSets.some( + (ruleSet, index) => + index !== customRuleSetIndex && + ruleSet.id === rid && + ruleSet.target === targetName + ) + ) { + nextCustomRuleSets = state.customRuleSets.filter((_, index) => index !== customRuleSetIndex); + } else { + nextCustomRuleSets = state.customRuleSets.map((ruleSet, index) => + index === customRuleSetIndex ? { ...ruleSet, target: targetName } : ruleSet ); } + return { + enabledProxyGroups: nextEnabledProxyGroups, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, + ruleOrder: normalizeRuleOrderForState({ + ...state, + enabledProxyGroups: nextEnabledProxyGroups, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, + }), + }; } + const movedBuiltinKey = findBuiltinRuleEditKeyByTarget(state.builtinRuleEdits, sourceTarget, rid); + if (movedBuiltinKey) { + const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, movedBuiltinKey, { + target: targetName, + enabled: true, + }); + return { + enabledProxyGroups: nextEnabledProxyGroups, + builtinRuleEdits: nextBuiltinRuleEdits, + ruleOrder: normalizeRuleOrderForState({ + ...state, + enabledProxyGroups: nextEnabledProxyGroups, + builtinRuleEdits: nextBuiltinRuleEdits, + }), + }; + } + + if (!sourceModule || !isPresetModuleRule(sourceModule, rid)) return state; + const key = getModuleRuleOrderKey(sourceId, rid); + const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, key, { + target: targetName, + enabled: true, + }); return { enabledProxyGroups: nextEnabledProxyGroups, - customProxyGroups: nextCustomProxyGroups, - moduleRuleOverrides: nextModuleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + builtinRuleEdits: nextBuiltinRuleEdits, ruleOrder: normalizeRuleOrderForState({ ...state, enabledProxyGroups: nextEnabledProxyGroups, - customProxyGroups: nextCustomProxyGroups, - moduleRuleOverrides: nextModuleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + builtinRuleEdits: nextBuiltinRuleEdits, }), }; }); @@ -555,15 +631,34 @@ export function createProxyGroupActions( const mod = PROXY_GROUP_MODULES.find((m) => m.id === id); if (!mod || !isPresetModuleRule(mod, rid)) return state; - const prevExclusions = normalizeModuleRuleExclusions(state.moduleRuleExclusions); - if (!prevExclusions[id]?.includes(rid)) return state; + const key = getModuleRuleOrderKey(id, rid); + if (state.builtinRuleEdits?.[key]?.enabled !== false) return state; + const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, key, { enabled: true }); + return { + builtinRuleEdits: nextBuiltinRuleEdits, + ruleOrder: normalizeRuleOrderForState({ + ...state, + builtinRuleEdits: nextBuiltinRuleEdits, + }), + }; + }); + }, - const nextModuleRuleExclusions = removePresetRuleExclusion(prevExclusions, id, rid); + resetModuleRuleTarget: (moduleId: string, ruleId: string) => { + const id = (moduleId || "").trim(); + const rid = (ruleId || "").trim(); + if (!id || !rid) return; + setAndGenerateConfig((state) => { + const mod = PROXY_GROUP_MODULES.find((m) => m.id === id); + if (!mod || !isPresetModuleRule(mod, rid)) return state; + const key = getModuleRuleOrderKey(id, rid); + if (!state.builtinRuleEdits?.[key]?.target) return state; + const nextBuiltinRuleEdits = updateBuiltinRuleEdit(state.builtinRuleEdits, key, { target: null }); return { - moduleRuleExclusions: nextModuleRuleExclusions, + builtinRuleEdits: nextBuiltinRuleEdits, ruleOrder: normalizeRuleOrderForState({ ...state, - moduleRuleExclusions: nextModuleRuleExclusions, + builtinRuleEdits: nextBuiltinRuleEdits, }), }; }); @@ -573,14 +668,21 @@ export function createProxyGroupActions( const id = (moduleId || "").trim(); if (!id) return; setAndGenerateConfig((state) => { - const nextModuleRuleExclusions = normalizeModuleRuleExclusions(state.moduleRuleExclusions); - if (!nextModuleRuleExclusions[id]?.length) return state; - delete nextModuleRuleExclusions[id]; + const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === id); + if (!proxyModule) return state; + let nextBuiltinRuleEdits = state.builtinRuleEdits; + for (const rule of proxyModule.rules) { + const key = getModuleRuleOrderKey(id, rule.id); + if (nextBuiltinRuleEdits?.[key]?.enabled === false) { + nextBuiltinRuleEdits = updateBuiltinRuleEdit(nextBuiltinRuleEdits, key, { enabled: true }); + } + } + if (nextBuiltinRuleEdits === state.builtinRuleEdits) return state; return { - moduleRuleExclusions: nextModuleRuleExclusions, + builtinRuleEdits: nextBuiltinRuleEdits, ruleOrder: normalizeRuleOrderForState({ ...state, - moduleRuleExclusions: nextModuleRuleExclusions, + builtinRuleEdits: nextBuiltinRuleEdits, }), }; }); @@ -611,6 +713,20 @@ export function createProxyGroupActions( r.target === oldFull ? { ...r, target: newFull } : r ); })(), + customRuleSets: (() => { + const prev = state.proxyGroupNameOverrides?.[key]; + const oldFull = resolveProxyGroupModuleName(mod, prev); + const newFull = value ? resolveProxyGroupModuleName(mod, value) : mod.name; + return state.customRuleSets.map((ruleSet) => + ruleSet.target === oldFull ? { ...ruleSet, target: newFull } : ruleSet + ); + })(), + builtinRuleEdits: (() => { + const prev = state.proxyGroupNameOverrides?.[key]; + const oldFull = resolveProxyGroupModuleName(mod, prev); + const newFull = value ? resolveProxyGroupModuleName(mod, value) : mod.name; + return retargetBuiltinRuleEdits(state.builtinRuleEdits, oldFull, newFull); + })(), })); }, @@ -632,6 +748,10 @@ export function createProxyGroupActions( customRules: state.customRules.map((r) => r.target === oldFull ? { ...r, target: newFull } : r ), + customRuleSets: state.customRuleSets.map((ruleSet) => + ruleSet.target === oldFull ? { ...ruleSet, target: newFull } : ruleSet + ), + builtinRuleEdits: retargetBuiltinRuleEdits(state.builtinRuleEdits, oldFull, newFull), }; }); }, 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 e47dec4..f17b2e0 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 @@ -41,15 +41,4 @@ describe("config store settings actions", () => { expect(store.setAndGenerateConfig).toHaveBeenCalledTimes(8); expect(store.set).not.toHaveBeenCalled(); }); - - it("updates all-rules order editing without regenerating config", () => { - const store = createStore({ allRulesOrderEditingEnabled: false }); - const actions = createSettingsActions(store.set as any, store.get as any, store.setAndGenerateConfig as any); - - actions.setAllRulesOrderEditingEnabled(1 as unknown as boolean); - - expect(store.state()).toEqual({ allRulesOrderEditingEnabled: true }); - expect(store.set).toHaveBeenCalledTimes(1); - expect(store.setAndGenerateConfig).not.toHaveBeenCalled(); - }); }); 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 b75ce26..2c2be65 100644 --- a/packages/ui/src/store/config-store/actions/settings-actions.ts +++ b/packages/ui/src/store/config-store/actions/settings-actions.ts @@ -11,11 +11,10 @@ type SettingsActions = Pick< | "setRuleProviderBaseUrl" | "setCnIpNoResolve" | "setExperimentalCnUseCnRuleSet" - | "setAllRulesOrderEditingEnabled" >; export function createSettingsActions( - set: SetState, + _set: SetState, _get: GetState, setAndGenerateConfig: SetAndGenerateConfig ): SettingsActions { @@ -51,9 +50,5 @@ export function createSettingsActions( setExperimentalCnUseCnRuleSet: (value: boolean) => { setAndGenerateConfig(() => ({ experimentalCnUseCnRuleSet: Boolean(value) })); }, - - setAllRulesOrderEditingEnabled: (enabled: boolean) => { - set({ allRulesOrderEditingEnabled: Boolean(enabled) }); - }, }; } 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 13e79e2..164273c 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 @@ -34,10 +34,9 @@ describe("createTemplateActions", () => { hiddenProxyGroups: ["ai"], appliedTemplateId: "custom-template", customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Proxy" }], - moduleRuleOverrides: { ai: [{ id: "openai", name: "OpenAI", behavior: "domain", path: "geosite/openai.mrs" }] }, - moduleRuleExclusions: { ai: ["anthropic"] }, + customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }], + builtinRuleEdits: { "module:ai:anthropic": { enabled: false } }, ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, }); @@ -49,10 +48,9 @@ describe("createTemplateActions", () => { hiddenProxyGroups: [], appliedTemplateId: getBuiltinTemplateId("standard"), customRules: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, ruleOrder: [], - allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, }); @@ -84,7 +82,7 @@ describe("createTemplateActions", () => { ruleOrder: ["custom-rule:old-rule"], }); - const config: SubBoostTemplateConfig = { + const config = { schema: "subboost-template-config/v1", template: "full", enabledProxyGroups: ["select", "ai", "google"], @@ -95,7 +93,15 @@ describe("createTemplateActions", () => { name: "Custom Group", emoji: "", groupType: "select", - rules: [{ id: "custom-provider", name: "Custom Provider", behavior: "domain", url: "https://example.com/rule.mrs" }], + }, + ], + customRuleSets: [ + { + id: "custom-provider", + name: "Custom Provider", + behavior: "domain", + path: "https://example.com/rule.mrs", + target: "Custom Group", }, ], filteredProxyGroups: [ @@ -128,7 +134,7 @@ describe("createTemplateActions", () => { testUrl: "https://example.com/generate_204", testInterval: 60, ruleProviderBaseUrl: "https://example.com/rules", - }; + } as unknown as SubBoostTemplateConfig; actions.applyTemplateConfig(config); @@ -136,12 +142,26 @@ describe("createTemplateActions", () => { template: "full", enabledProxyGroups: ["select", "google"], hiddenProxyGroups: ["ai"], - customProxyGroups: config.customProxyGroups, + customProxyGroups: [ + { + id: "custom-group-1", + name: "Custom Group", + emoji: "", + groupType: "select", + }, + ], filteredProxyGroups: config.filteredProxyGroups, - moduleRuleOverrides: config.moduleRuleOverrides, - moduleRuleExclusions: { ai: ["openai"] }, + customRuleSets: [ + { + id: "custom-provider", + name: "Custom Provider", + behavior: "domain", + path: "https://example.com/rule.mrs", + target: "Custom Group", + }, + ], + builtinRuleEdits: { "module:ai:openai": { target: "🔍 Google" } }, moduleRuleEditWarningAccepted: false, - allRulesOrderEditingEnabled: true, cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, dialerProxyGroups: config.dialerProxyGroups, @@ -173,7 +193,6 @@ describe("createTemplateActions", () => { name: "Existing", emoji: "", groupType: "select", - rules: [], }, ], filteredProxyGroups: [ @@ -188,8 +207,8 @@ describe("createTemplateActions", () => { }, ], customRules: [{ id: "existing-rule", type: "DOMAIN", value: "example.org", target: "Proxy" }], - moduleRuleOverrides: { ai: [{ id: "openai", name: "OpenAI", behavior: "domain", path: "geosite/openai.mrs" }] }, - moduleRuleExclusions: { ai: ["openai"] }, + customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }], + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, ruleOrder: ["module:ai:openai"], cnIpNoResolve: true, experimentalCnUseCnRuleSet: false, @@ -239,7 +258,6 @@ describe("createTemplateActions", () => { name: "Existing", emoji: "", groupType: "select", - rules: [], }, ], filteredProxyGroups: [ @@ -254,10 +272,9 @@ describe("createTemplateActions", () => { }, ], customRules: [{ id: "existing-rule", type: "DOMAIN", value: "example.org", target: "Proxy" }], - moduleRuleOverrides: { ai: [{ id: "openai", name: "OpenAI", behavior: "domain", path: "geosite/openai.mrs" }] }, - moduleRuleExclusions: { ai: ["openai"] }, + customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }], + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, cnIpNoResolve: true, experimentalCnUseCnRuleSet: false, dialerProxyGroups: [{ id: "existing-dialer", name: "Existing Dialer", relayNodes: [], type: "select", targetNodes: [] }], 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 b02f749..5289f98 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.ts @@ -1,15 +1,10 @@ import { getBuiltinTemplateId } from "@subboost/core/templates/builtin"; import { TEMPLATES } from "@subboost/core/templates"; import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils"; -import { hasFullRuleOrderKeys, normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; +import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; -import { normalizeModuleRuleExclusions } from "@subboost/core/generator/module-rules"; -import type { - ConfigActions, - ModuleRuleExclusions, - ModuleRuleOverride, - SubBoostTemplateConfig, -} from "../definitions"; +import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; +import type { ConfigActions, SubBoostTemplateConfig } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; type TemplateActions = Pick< @@ -36,6 +31,10 @@ function normalizeHiddenProxyGroups(value: unknown): string[] { return out; } +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + export function createTemplateActions( set: SetState, _get: GetState, @@ -50,10 +49,9 @@ export function createTemplateActions( hiddenProxyGroups: [], appliedTemplateId: getBuiltinTemplateId(template), customRules: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, ruleOrder: [], - allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, })); }, @@ -86,9 +84,28 @@ export function createTemplateActions( if (!config || typeof config !== "object") return; setAndGenerateConfig((state) => { - const nextCustomProxyGroups = Array.isArray(config.customProxyGroups) - ? config.customProxyGroups - : state.customProxyGroups; + const ruleModel = normalizeRuleModelFromConfig(config); + const hasCustomProxyGroups = Array.isArray(config.customProxyGroups); + const hasCustomRuleSets = Array.isArray(config.customRuleSets); + const hasLegacyModuleRuleOverrides = isRecord((config as Record).moduleRuleOverrides); + const hasLegacyModuleRuleExclusions = isRecord((config as Record).moduleRuleExclusions); + const hasBuiltinRuleEdits = isRecord(config.builtinRuleEdits); + const nextCustomProxyGroups = + hasCustomProxyGroups || ruleModel.customProxyGroups.length > 0 + ? ruleModel.customProxyGroups + : state.customProxyGroups; + const nextCustomRuleSets = + hasCustomRuleSets || + hasLegacyModuleRuleOverrides || + hasCustomProxyGroups + ? ruleModel.customRuleSets + : state.customRuleSets; + const nextBuiltinRuleEdits = + hasBuiltinRuleEdits || + hasLegacyModuleRuleExclusions || + hasLegacyModuleRuleOverrides + ? ruleModel.builtinRuleEdits + : state.builtinRuleEdits; const nextCustomRules = Array.isArray(config.customRules) ? ensureCustomRulesHaveIds(config.customRules) : state.customRules; @@ -97,28 +114,23 @@ export function createTemplateActions( const shouldRefreshRuleOrder = Array.isArray(config.ruleOrder) || Array.isArray(config.customRules) || - Array.isArray(config.customProxyGroups) || - Boolean(config.moduleRuleExclusions && typeof config.moduleRuleExclusions === "object"); + hasCustomProxyGroups || + hasCustomRuleSets || + hasBuiltinRuleEdits || + hasLegacyModuleRuleExclusions || + hasLegacyModuleRuleOverrides; const nextEnabledModulesRaw = Array.isArray(config.enabledProxyGroups) ? config.enabledProxyGroups : state.enabledProxyGroups; const nextEnabledModules = nextEnabledModulesRaw.filter( (moduleId) => !nextHiddenProxyGroupSet.has(moduleId) ); - const nextModuleRuleExclusions = - config.moduleRuleExclusions && typeof config.moduleRuleExclusions === "object" - ? normalizeModuleRuleExclusions(config.moduleRuleExclusions) - : state.moduleRuleExclusions; const nextRuleOrder = shouldRefreshRuleOrder ? normalizePersistedRuleOrder({ enabledModules: nextEnabledModules, customRules: nextCustomRules, - customProxyGroups: nextCustomProxyGroups, - moduleRuleOverrides: - config.moduleRuleOverrides && typeof config.moduleRuleOverrides === "object" - ? (config.moduleRuleOverrides as Record) - : state.moduleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, proxyGroupNameOverrides: config.proxyGroupNameOverrides && typeof config.proxyGroupNameOverrides === "object" ? (config.proxyGroupNameOverrides as Record) @@ -142,18 +154,11 @@ export function createTemplateActions( filteredProxyGroups: Array.isArray(config.filteredProxyGroups) ? config.filteredProxyGroups : state.filteredProxyGroups, - moduleRuleOverrides: - config.moduleRuleOverrides && typeof config.moduleRuleOverrides === "object" - ? (config.moduleRuleOverrides as Record) - : state.moduleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions as ModuleRuleExclusions, + customRuleSets: nextCustomRuleSets, + builtinRuleEdits: nextBuiltinRuleEdits, moduleRuleEditWarningAccepted: false, customRules: nextCustomRules, ruleOrder: nextRuleOrder, - allRulesOrderEditingEnabled: - typeof config.allRulesOrderEditingEnabled === "boolean" - ? config.allRulesOrderEditingEnabled - : hasFullRuleOrderKeys(nextRuleOrder), cnIpNoResolve: typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve, experimentalCnUseCnRuleSet: 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 904e3fc..481b95d 100644 --- a/packages/ui/src/store/config-store/auth-handoff.test.ts +++ b/packages/ui/src/store/config-store/auth-handoff.test.ts @@ -54,15 +54,14 @@ function meaningfulState(overrides: Record = {}) { deletedNodeNames: ["Gone"], deletedNodes: [{ originName: "Gone", name: "Gone" }], customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Proxy" }], - customProxyGroups: [{ id: "custom-1", name: "Custom", rules: [] }], + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], filteredProxyGroups: [{ id: "filtered-1", name: "Filtered", enabled: true }], - moduleRuleOverrides: { ai: [{ id: "custom-ai", path: "geosite/custom-ai.mrs" }] }, - moduleRuleExclusions: { ai: ["openai"] }, + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, dialerProxyGroups: [{ id: "dialer-1", name: "Relay", relayNodes: ["Node A"], targetNodes: [] }], proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", template: "full", @@ -148,14 +147,13 @@ describe("auth config handoff", () => { enabledProxyGroups: ["select", "auto", "ai"], hiddenProxyGroups: ["youtube"], customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Proxy" }], - customProxyGroups: [{ id: "custom-1", name: "Custom", rules: [] }], + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], filteredProxyGroups: [{ id: "filtered-1", name: "Filtered", enabled: true }], - moduleRuleOverrides: { ai: [{ id: "custom-ai", path: "geosite/custom-ai.mrs" }] }, - moduleRuleExclusions: { ai: ["openai"] }, + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", dnsYaml: "dns: {}", @@ -280,15 +278,14 @@ describe("auth config handoff", () => { deletedNodeNames: ["Gone"], deletedNodes: [{ originName: "Gone" }], enabledProxyGroups: ["select"], - customProxyGroups: [{ id: "custom" }], + customProxyGroups: [], filteredProxyGroups: [{ id: "filtered" }], - moduleRuleOverrides: { ai: [] }, - moduleRuleExclusions: { ai: ["openai"] }, + customRuleSets: [], + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, customRules: [{ id: "rule" }], dialerProxyGroups: [{ id: "dialer" }], proxyGroupOrder: ["module:ai"], ruleOrder: ["rule"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: false, appliedTemplateId: null, dnsYaml: "dns: {}", diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index 80c132f..2f0792a 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -1,6 +1,7 @@ import type { ConfigState, SourceType, SubscriptionSource } from "./definitions"; import { initialState } from "./definitions"; import { safeParseJsonObject } from "@subboost/core/json"; +import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; export const AUTH_CONFIG_HANDOFF_STORAGE_NAME = "subboost-auth-config-handoff"; @@ -110,15 +111,14 @@ function hasMeaningfulConfig(state: ConfigState): boolean { state.deletedNodeNames.length > 0 || state.deletedNodes.length > 0 || state.customRules.length > 0 || + state.customRuleSets.length > 0 || state.customProxyGroups.length > 0 || state.filteredProxyGroups.length > 0 || - hasRecordEntries(state.moduleRuleOverrides) || - hasRecordEntries(state.moduleRuleExclusions as Record) || + hasRecordEntries(state.builtinRuleEdits as Record) || state.dialerProxyGroups.length > 0 || hasRecordEntries(state.proxyGroupNameOverrides) || state.proxyGroupOrder.length > 0 || state.ruleOrder.length > 0 || - state.allRulesOrderEditingEnabled !== initialState.allRulesOrderEditingEnabled || state.moduleRuleEditWarningAccepted !== initialState.moduleRuleEditWarningAccepted || state.appliedTemplateId !== initialState.appliedTemplateId || state.template !== initialState.template || @@ -147,14 +147,13 @@ function buildHandoffState(state: ConfigState): Partial { hiddenProxyGroups: state.hiddenProxyGroups, customProxyGroups: state.customProxyGroups, filteredProxyGroups: state.filteredProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, customRules: state.customRules, dialerProxyGroups: state.dialerProxyGroups, proxyGroupNameOverrides: state.proxyGroupNameOverrides, proxyGroupOrder: state.proxyGroupOrder, ruleOrder: state.ruleOrder, - allRulesOrderEditingEnabled: state.allRulesOrderEditingEnabled, moduleRuleEditWarningAccepted: state.moduleRuleEditWarningAccepted, appliedTemplateId: state.appliedTemplateId, dnsYaml: state.dnsYaml, @@ -186,12 +185,21 @@ function normalizeHandoffState(raw: unknown): Partial | null { if (enabledProxyGroups) out.enabledProxyGroups = enabledProxyGroups; const hiddenProxyGroups = stringArray(raw.hiddenProxyGroups); if (hiddenProxyGroups) out.hiddenProxyGroups = hiddenProxyGroups; + const ruleModel = normalizeRuleModelFromConfig(raw); const customProxyGroups = objectArray(raw.customProxyGroups); - if (customProxyGroups) out.customProxyGroups = customProxyGroups; + if (customProxyGroups || ruleModel.customProxyGroups.length > 0) out.customProxyGroups = ruleModel.customProxyGroups; const filteredProxyGroups = objectArray(raw.filteredProxyGroups); if (filteredProxyGroups) out.filteredProxyGroups = filteredProxyGroups; - if (isRecord(raw.moduleRuleOverrides)) out.moduleRuleOverrides = raw.moduleRuleOverrides as ConfigState["moduleRuleOverrides"]; - if (isRecord(raw.moduleRuleExclusions)) out.moduleRuleExclusions = raw.moduleRuleExclusions as ConfigState["moduleRuleExclusions"]; + if ( + Array.isArray(raw.customRuleSets) || + isRecord(raw.moduleRuleOverrides) || + (customProxyGroups || ruleModel.customProxyGroups.length > 0) + ) { + out.customRuleSets = ruleModel.customRuleSets; + } + if (isRecord(raw.builtinRuleEdits) || isRecord(raw.moduleRuleExclusions) || isRecord(raw.moduleRuleOverrides)) { + out.builtinRuleEdits = ruleModel.builtinRuleEdits; + } const customRules = objectArray(raw.customRules); if (customRules) out.customRules = customRules; const dialerProxyGroups = objectArray(raw.dialerProxyGroups); @@ -201,7 +209,6 @@ function normalizeHandoffState(raw: unknown): Partial | null { if (proxyGroupOrder) out.proxyGroupOrder = proxyGroupOrder; const ruleOrder = stringArray(raw.ruleOrder); if (ruleOrder) out.ruleOrder = ruleOrder; - if (typeof raw.allRulesOrderEditingEnabled === "boolean") out.allRulesOrderEditingEnabled = raw.allRulesOrderEditingEnabled; if (typeof raw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = raw.moduleRuleEditWarningAccepted; if (typeof raw.appliedTemplateId === "string" || raw.appliedTemplateId === null) { out.appliedTemplateId = raw.appliedTemplateId; diff --git a/packages/ui/src/store/config-store/definitions.ts b/packages/ui/src/store/config-store/definitions.ts index 4865649..3a91008 100644 --- a/packages/ui/src/store/config-store/definitions.ts +++ b/packages/ui/src/store/config-store/definitions.ts @@ -1,15 +1,16 @@ import type { ParsedNode, ParseResult } from "@subboost/core/types/node"; -import type { TemplateType, CustomRule, CustomProxyGroup } from "@subboost/core/types/config"; +import type { + BuiltinRuleEdits, + CustomProxyGroup, + CustomRule, + CustomRuleSet, + TemplateType, +} from "@subboost/core/types/config"; import { DEFAULT_BASE_CONFIG_YAML, DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { getBuiltinTemplateId } from "@subboost/core/templates/builtin"; import { TEMPLATES } from "@subboost/core/templates"; import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -import type { ModuleRuleExclusions } from "@subboost/core/generator/module-rules"; -import type { - DialerProxyGroup, - ModuleRuleOverride, - SubBoostTemplateConfig, -} from "@subboost/core/types/template-config"; +import type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; import { isSubscriptionImportError, type SubscriptionImportErrorInfo, @@ -28,8 +29,10 @@ import { } from "@subboost/core/subscription/node-source-state"; export { DEFAULT_BASE_CONFIG_YAML }; -export type { ModuleRuleExclusions } from "@subboost/core/generator/module-rules"; -export type { DialerProxyGroup, ModuleRuleOverride, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; +export type RuleSetDraft = Omit; +export type ModuleRuleOverride = RuleSetDraft; +export type { BuiltinRuleEdits, CustomRuleSet }; +export type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; // 预设的中转组名称 export const PRESET_RELAY_NAMES = [ @@ -172,8 +175,8 @@ export interface ConfigState { hiddenProxyGroups: string[]; // 隐藏的内置代理组(仅影响 UI,不参与生成) customProxyGroups: CustomProxyGroup[]; // 自定义分流组 filteredProxyGroups: FilteredProxyGroup[]; // 筛选代理组(从节点池派生) - moduleRuleOverrides: Record; // 内置代理组附加规则集 - moduleRuleExclusions: ModuleRuleExclusions; // 内置代理组排除的预设规则 + customRuleSets: CustomRuleSet[]; // 用户新增规则集,统一进入自定义规则块 + builtinRuleEdits: BuiltinRuleEdits; // 内置规则的目标覆盖或禁用状态 customRules: CustomRule[]; dialerProxyGroups: DialerProxyGroup[]; @@ -185,12 +188,9 @@ export interface ConfigState { proxyGroupOrder: string[]; // 用户可编辑规则窗口顺序 - // Key 格式:custom-rule: / custom-group:: / module:: / special: + // Key 格式:custom-rule: / custom-rule-set: / module:: / special: ruleOrder: string[]; - // 是否允许在规则管理中调整所有规则顺序;只影响 UI,不参与生成。 - allRulesOrderEditingEnabled: boolean; - // 当前配置是否已确认过“编辑预设规则”风险提示;只影响 UI,不参与生成。 moduleRuleEditWarningAccepted: boolean; @@ -249,7 +249,6 @@ export interface ConfigActions { updateCustomRule: (id: string, rule: Partial>) => void; removeCustomRule: (index: number) => void; setRuleOrder: (order: string[]) => void; - setAllRulesOrderEditingEnabled: (enabled: boolean) => void; // 自定义分流组 addCustomProxyGroup: (group: Omit) => void; @@ -264,12 +263,12 @@ export interface ConfigActions { removeFilteredProxyGroup: (id: string) => void; updateFilteredProxyGroup: (id: string, group: Partial) => void; - // 内置分流组附加规则集 - addModuleRules: (moduleId: string, rules: ModuleRuleOverride[]) => void; + // 规则集与内置规则编辑 + addModuleRules: (moduleId: string, rules: RuleSetDraft[]) => void; updateModuleRule: ( moduleId: string, ruleId: string, - rule: Partial> + rule: Partial> ) => void; removeModuleRule: (moduleId: string, ruleId: string) => void; moveModuleRule: ( @@ -278,6 +277,7 @@ export interface ConfigActions { target: { kind: "module" | "custom"; id: string } ) => void; restoreModuleRule: (moduleId: string, ruleId: string) => void; + resetModuleRuleTarget: (moduleId: string, ruleId: string) => void; restoreModuleDefaultRules: (moduleId: string) => void; acceptModuleRuleEditWarning: () => void; @@ -342,14 +342,13 @@ export const initialState: ConfigState = { hiddenProxyGroups: [], customProxyGroups: [], // 自定义分流组 filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, customRules: [], dialerProxyGroups: [], proxyGroupNameOverrides: {}, proxyGroupOrder: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, appliedTemplateId: getBuiltinTemplateId("minimal"), dnsYaml: DEFAULT_BASE_CONFIG_YAML, diff --git a/packages/ui/src/store/config-store/generated-yaml.ts b/packages/ui/src/store/config-store/generated-yaml.ts index c62b07d..d0aeefc 100644 --- a/packages/ui/src/store/config-store/generated-yaml.ts +++ b/packages/ui/src/store/config-store/generated-yaml.ts @@ -74,9 +74,9 @@ function buildGenerateClashYamlOptions( }, dialerProxyGroups: state.dialerProxyGroups, customProxyGroups: state.customProxyGroups, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, filteredProxyGroups: state.filteredProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, proxyGroupNameOverrides: state.proxyGroupNameOverrides, proxyGroupOrder: state.proxyGroupOrder, }; diff --git a/packages/ui/src/store/config-store/persistence.ts b/packages/ui/src/store/config-store/persistence.ts index 1eb5623..c3a1832 100644 --- a/packages/ui/src/store/config-store/persistence.ts +++ b/packages/ui/src/store/config-store/persistence.ts @@ -7,7 +7,7 @@ export { getConfigDraftStorageNameForUser, } from "./draft-storage"; -export const CONFIG_DRAFT_STORAGE_VERSION = 9; +export const CONFIG_DRAFT_STORAGE_VERSION = 10; type ConfigDraftStorage = Pick; diff --git a/packages/ui/src/templates/template-library-surface.test.ts b/packages/ui/src/templates/template-library-surface.test.ts index db1b701..1bf1af2 100644 --- a/packages/ui/src/templates/template-library-surface.test.ts +++ b/packages/ui/src/templates/template-library-surface.test.ts @@ -223,11 +223,10 @@ describe("TemplateLibrarySurface", () => { enabledProxyGroups: ["Auto"], hiddenProxyGroups: [], customProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, customRules: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, dialerProxyGroups: [], proxyGroupNameOverrides: {}, dnsYaml: "", diff --git a/packages/ui/src/templates/template-library-surface.tsx b/packages/ui/src/templates/template-library-surface.tsx index 3bcbd2c..9cfcb72 100644 --- a/packages/ui/src/templates/template-library-surface.tsx +++ b/packages/ui/src/templates/template-library-surface.tsx @@ -80,11 +80,10 @@ function TemplateLibraryInner({ adapter }: Props) { enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, customRules, ruleOrder, - allRulesOrderEditingEnabled, dialerProxyGroups, proxyGroupNameOverrides, dnsYaml, @@ -302,11 +301,10 @@ function TemplateLibraryInner({ adapter }: Props) { enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, customRules, ruleOrder, - allRulesOrderEditingEnabled, dialerProxyGroups, proxyGroupNameOverrides, dnsYaml, From 228875a19abac2786b1b93e4694694c35dd33ad2 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:51:38 +0800 Subject: [PATCH 05/29] feat: add dev release channel --- .github/workflows/dev-release.yml | 275 ++++++++++++++++++++++++++++ scripts/selfhost-release-assets.cjs | 79 +++++++- 2 files changed, 349 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/dev-release.yml diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 0000000..3c24daf --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,275 @@ +name: Dev Release + +on: + workflow_dispatch: + push: + branches: + - ryan/dev + paths: + - ".github/workflows/ci.yml" + - ".github/workflows/dev-release.yml" + - ".github/workflows/release.yml" + - "eslint.config.mjs" + - "local/**" + - "package-lock.json" + - "package.json" + - "packages/**" + - "scripts/**" + - "tsconfig.json" + - "vitest.config.ts" + - "vitest.core.config.ts" + +permissions: + contents: write + packages: write + +concurrency: + group: dev-release-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE_NAME: ghcr.io/subboost/subboost + +jobs: + meta: + runs-on: ubuntu-latest + outputs: + build_version: ${{ steps.meta.outputs.build_version }} + image_name: ${{ steps.meta.outputs.image_name }} + release_version: ${{ steps.meta.outputs.release_version }} + short_sha: ${{ steps.meta.outputs.short_sha }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22.19.0" + + - name: Validate dev source + id: meta + shell: bash + run: | + set -euo pipefail + if [ "${GITHUB_REF_NAME}" != "ryan/dev" ]; then + echo "Dev release must run from ryan/dev, got ${GITHUB_REF_NAME}." >&2 + exit 1 + fi + release_version="$(node -p "require('./package.json').version")" + short_sha="${GITHUB_SHA::12}" + echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" + echo "short_sha=${short_sha}" >> "$GITHUB_OUTPUT" + echo "build_version=${release_version}+sha.${short_sha}" >> "$GITHUB_OUTPUT" + echo "image_name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" + + checks: + runs-on: ubuntu-latest + needs: meta + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22.19.0" + + - run: npm ci + - run: npm run lint + - run: npm run test:unit + - run: npm run check:local-app + + build-image: + runs-on: ubuntu-latest + needs: meta + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + - arch: arm64 + platform: linux/arm64 + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_PAT || github.token }} + + - name: Build and push ${{ matrix.platform }} image by digest + id: build_image + uses: docker/build-push-action@v6 + with: + context: . + file: local/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + build-args: | + APP_RELEASE_VERSION=${{ needs.meta.outputs.release_version }} + APP_BUILD_SHA=${{ github.sha }} + APP_VERSION=${{ needs.meta.outputs.build_version }} + APP_VERSION_TOKEN=${{ needs.meta.outputs.build_version }} + cache-from: type=gha,scope=selfhost-dev-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=selfhost-dev-${{ matrix.arch }} + provenance: false + tags: ${{ needs.meta.outputs.image_name }} + + - name: Export digest + shell: bash + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/digests" + digest="${{ steps.build_image.outputs.digest }}" + touch "${RUNNER_TEMP}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + publish-dev-release: + runs-on: ubuntu-latest + needs: + - meta + - checks + - build-image + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22.19.0" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ secrets.GHCR_USERNAME || github.actor }} + password: ${{ secrets.GHCR_PAT || github.token }} + + - name: Download image digests + uses: actions/download-artifact@v4 + with: + pattern: digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - name: Create dev image tags + id: image_manifest + shell: bash + env: + IMAGE_NAME: ${{ needs.meta.outputs.image_name }} + SHORT_SHA: ${{ needs.meta.outputs.short_sha }} + run: | + set -euo pipefail + mapfile -t digest_files < <(find "${RUNNER_TEMP}/digests" -type f | sort) + if [ "${#digest_files[@]}" -lt 2 ]; then + echo "Expected at least two architecture digests, found ${#digest_files[@]}" >&2 + exit 1 + fi + + sources=() + for file in "${digest_files[@]}"; do + sources+=("${IMAGE_NAME}@sha256:$(basename "$file")") + done + + docker buildx imagetools create \ + --metadata-file "${RUNNER_TEMP}/image-metadata.json" \ + --tag "${IMAGE_NAME}:dev" \ + --tag "${IMAGE_NAME}:sha-${SHORT_SHA}" \ + "${sources[@]}" + + digest="$(node -e 'const metadata = require(process.argv[1]); const digest = metadata["containerimage.digest"] || metadata["containerimage.descriptor"]?.digest; if (!digest) { console.error(JSON.stringify(metadata, null, 2)); process.exit(1); } process.stdout.write(digest);' "${RUNNER_TEMP}/image-metadata.json")" + if [ -z "$digest" ] || [ "$digest" = "null" ]; then + echo "Failed to resolve multi-platform image digest for ${IMAGE_NAME}:dev" >&2 + exit 1 + fi + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + + - name: Prepare dev release assets + shell: bash + env: + IMAGE_DIGEST: ${{ steps.image_manifest.outputs.digest }} + IMAGE_NAME: ${{ needs.meta.outputs.image_name }} + run: | + set -euo pipefail + dev_base_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/dev" + node scripts/selfhost-release-assets.cjs \ + --output dist/release-assets \ + --tag dev \ + --build-sha "${GITHUB_SHA}" \ + --image "${IMAGE_NAME}@${IMAGE_DIGEST}" \ + --image-tag "${IMAGE_NAME}:dev" \ + --base-url "${dev_base_url}" \ + --installer-release-url "${dev_base_url}/release.json" \ + --installer-compose-url "${dev_base_url}/docker-compose.image.yml" \ + --installer-manager-url "${dev_base_url}/subboost-manager" \ + --installer-image "${IMAGE_NAME}:dev" + + - name: Move dev tag + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -f dev "${GITHUB_SHA}" + git push origin "refs/tags/dev" --force + + - name: Create or update dev prerelease + shell: bash + env: + GH_TOKEN: ${{ github.token }} + IMAGE_DIGEST: ${{ steps.image_manifest.outputs.digest }} + IMAGE_NAME: ${{ needs.meta.outputs.image_name }} + run: | + set -euo pipefail + notes_file="${RUNNER_TEMP}/dev-release-notes.md" + cat > "$notes_file" </dev/null 2>&1; then + gh release edit dev \ + --title "SubBoost dev" \ + --notes-file "$notes_file" \ + --prerelease \ + --draft=false + gh release upload dev \ + dist/release-assets/release.json \ + dist/release-assets/install.sh \ + dist/release-assets/docker-compose.image.yml \ + dist/release-assets/subboost-manager \ + --clobber + else + gh release create dev \ + --title "SubBoost dev" \ + --notes-file "$notes_file" \ + --prerelease \ + --latest=false \ + --verify-tag \ + dist/release-assets/release.json \ + dist/release-assets/install.sh \ + dist/release-assets/docker-compose.image.yml \ + dist/release-assets/subboost-manager + fi + + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/dev" --jq ".id")" + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ + -F draft=false \ + -F prerelease=true \ + -f make_latest=false \ + >/dev/null diff --git a/scripts/selfhost-release-assets.cjs b/scripts/selfhost-release-assets.cjs index d0c3383..1e41896 100644 --- a/scripts/selfhost-release-assets.cjs +++ b/scripts/selfhost-release-assets.cjs @@ -6,11 +6,41 @@ const { spawnSync } = require("node:child_process"); const DEFAULT_IMAGE_REPOSITORY = "ghcr.io/subboost/subboost"; const DEFAULT_OUTPUT = path.join("dist", "release-assets"); const MANAGER_ASSET_NAME = "subboost-manager"; +const INSTALLER_DEFAULTS = [ + { + arg: "--installer-release-url", + env: "SUBBOOST_INSTALLER_RELEASE_URL", + key: "installerReleaseUrl", + name: "DEFAULT_RELEASE_URL", + value: "https://github.com/SubBoost/subboost/releases/latest/download/release.json", + }, + { + arg: "--installer-compose-url", + env: "SUBBOOST_INSTALLER_COMPOSE_URL", + key: "installerComposeUrl", + name: "DEFAULT_COMPOSE_URL", + value: "https://github.com/SubBoost/subboost/releases/latest/download/docker-compose.image.yml", + }, + { + arg: "--installer-manager-url", + env: "SUBBOOST_INSTALLER_MANAGER_URL", + key: "installerManagerUrl", + name: "DEFAULT_MANAGER_URL", + value: "https://github.com/SubBoost/subboost/releases/latest/download/subboost-manager", + }, + { + arg: "--installer-image", + env: "SUBBOOST_INSTALLER_IMAGE", + key: "installerImage", + name: "DEFAULT_IMAGE", + value: "ghcr.io/subboost/subboost:latest", + }, +]; function usage() { return [ "Usage:", - " node scripts/selfhost-release-assets.cjs [--output ] [--image ] [--image-tag ] [--base-url ] [--tag ] [--build-sha ] [--dry-run]", + " node scripts/selfhost-release-assets.cjs [--output ] [--image ] [--image-tag ] [--base-url ] [--tag ] [--build-sha ] [--installer-release-url ] [--installer-compose-url ] [--installer-manager-url ] [--installer-image ] [--dry-run]", "", "Creates release.json, install.sh, docker-compose.image.yml, and subboost-manager for GitHub Release assets.", ].join("\n"); @@ -27,6 +57,9 @@ function parseArgs(argv) { output: process.env.SUBBOOST_RELEASE_ASSET_OUTPUT || process.env.SUBBOOST_ONECLICK_OUTPUT || DEFAULT_OUTPUT, releaseTag: process.env.GITHUB_REF_NAME || "", }; + for (const item of INSTALLER_DEFAULTS) { + args[item.key] = process.env[item.env] || ""; + } for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--base-url") { @@ -52,10 +85,16 @@ function parseArgs(argv) { } else if (arg === "--tag" || arg === "--release-tag") { args.releaseTag = argv[index + 1] || ""; index += 1; - } else if (arg === "--help" || arg === "-h") { - args.help = true; } else { - throw new Error(`Unknown argument: ${arg}`); + const installerDefault = INSTALLER_DEFAULTS.find((item) => item.arg === arg); + if (installerDefault) { + args[installerDefault.key] = argv[index + 1] || ""; + index += 1; + } else if (arg === "--help" || arg === "-h") { + args.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } } } if (!args.help) { @@ -67,6 +106,9 @@ function parseArgs(argv) { throw new Error("--tag cannot be empty."); } if (argv.includes("--build-sha") && !args.buildSha) throw new Error("--build-sha cannot be empty."); + for (const item of INSTALLER_DEFAULTS) { + if (argv.includes(item.arg) && !args[item.key]) throw new Error(`${item.arg} cannot be empty.`); + } } return args; } @@ -123,6 +165,32 @@ function copyFile(publicRoot, from, to, options = {}) { fs.copyFileSync(source, to); } +function replaceInstallerDefault(content, item, replacement) { + const expected = `${item.name}="${item.value}"`; + const replacementLine = `${item.name}="${replacement}"`; + const count = content.split(expected).length - 1; + if (count !== 1) { + throw new Error(`Expected exactly one ${item.name} assignment in install.sh, found ${count}.`); + } + return content.replace(expected, replacementLine); +} + +function rewriteInstallerDefaults(content, args) { + let output = content; + for (const item of INSTALLER_DEFAULTS) { + const replacement = args[item.key]; + if (replacement) output = replaceInstallerDefault(output, item, replacement); + } + return output; +} + +function copyInstallScript(publicRoot, to, args) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + const source = path.join(publicRoot, "local/scripts/install.sh"); + const content = fs.readFileSync(source, "utf8").replace(/\r\n/g, "\n"); + fs.writeFileSync(to, rewriteInstallerDefaults(content, args), "utf8"); +} + function createBundle(publicRoot, args) { const output = path.resolve(publicRoot, args.output); const manifest = buildManifest(publicRoot, args); @@ -131,7 +199,7 @@ function createBundle(publicRoot, args) { fs.rmSync(output, { force: true, recursive: true }); fs.mkdirSync(output, { recursive: true }); copyFile(publicRoot, "local/docker-compose.image.yml", path.join(output, "docker-compose.image.yml")); - copyFile(publicRoot, "local/scripts/install.sh", path.join(output, "install.sh"), { normalizeLineEndings: true }); + copyInstallScript(publicRoot, path.join(output, "install.sh"), args); copyFile(publicRoot, "local/scripts/subboost.sh", path.join(output, MANAGER_ASSET_NAME), { normalizeLineEndings: true }); fs.chmodSync(path.join(output, "install.sh"), 0o755); fs.chmodSync(path.join(output, MANAGER_ASSET_NAME), 0o755); @@ -169,5 +237,6 @@ module.exports = { MANAGER_ASSET_NAME, main, parseArgs, + rewriteInstallerDefaults, usage, }; From f2aa1086b8ba9444830d0dd300708fd063cba8d4 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:05:59 +0800 Subject: [PATCH 06/29] fix: restore rule order confirmation gate --- packages/core/src/generator/rules.test.ts | 7 ++- packages/core/src/generator/rules.ts | 2 +- .../sections/rules-management-section.test.ts | 43 +++++++++++++-- .../sections/rules-management-section.tsx | 54 +++++++++++++++++-- .../actions/settings-actions.test.ts | 11 ++++ .../config-store/actions/settings-actions.ts | 7 ++- .../actions/template-actions.test.ts | 3 ++ .../config-store/actions/template-actions.ts | 10 +++- .../store/config-store/auth-handoff.test.ts | 3 ++ .../ui/src/store/config-store/auth-handoff.ts | 3 ++ .../ui/src/store/config-store/definitions.ts | 5 ++ 11 files changed, 135 insertions(+), 13 deletions(-) diff --git a/packages/core/src/generator/rules.test.ts b/packages/core/src/generator/rules.test.ts index 5740b5e..26a443c 100644 --- a/packages/core/src/generator/rules.test.ts +++ b/packages/core/src/generator/rules.test.ts @@ -56,6 +56,8 @@ describe("rule generator", () => { }); const texts = entries.map((entry) => entry.text); const customIpEntry = entries.find((entry) => entry.key === "custom-rule:ip-rule"); + const customRuleSetEntry = entries.find((entry) => entry.key === "custom-rule-set:media-rule"); + const appleTvPlusEntry = entries.find((entry) => entry.key === "module:streaming-west:apple-tvplus"); expect(resolveModuleName("cn", { cn: "CN Direct" })).toBe("🔒 CN Direct"); expect(texts).toContain("DOMAIN-SUFFIX,example.com,DIRECT"); @@ -66,9 +68,12 @@ describe("rule generator", () => { }); expect(texts).toContain("RULE-SET,media-rule,Media,no-resolve"); expect(texts).toContain("RULE-SET,apple-tvplus,📺 Streaming"); - expect(entries.find((entry) => entry.text === "RULE-SET,apple-tvplus,📺 Streaming")).toMatchObject({ + expect(customIpEntry).toMatchObject({ editable: true }); + expect(customRuleSetEntry).toMatchObject({ editable: true }); + expect(appleTvPlusEntry).toMatchObject({ key: "module:streaming-west:apple-tvplus", kind: "module", + editable: false, }); expect(entries.some((entry) => entry.key === "special:apple-tvplus")).toBe(false); expect(texts).toContain("RULE-SET,cn,🔒 CN Direct"); diff --git a/packages/core/src/generator/rules.ts b/packages/core/src/generator/rules.ts index 527e11d..0a1ac7e 100644 --- a/packages/core/src/generator/rules.ts +++ b/packages/core/src/generator/rules.ts @@ -132,7 +132,7 @@ function buildModuleRuleEntry( summary: rule.name, target, noResolve, - editable: true, + editable: false, enabled: edit?.enabled !== false, }; } diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts index 1066ea5..73cec64 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts @@ -92,6 +92,8 @@ function renderSection(overrides: Record = {}) { experimentalCnUseCnRuleSet: true, ruleOrder: ["module:geo", "custom:one"], setRuleOrder: vi.fn(), + allRulesOrderEditingEnabled: false, + setAllRulesOrderEditingEnabled: vi.fn(), ...overrides, }; stateMock.setter.mockClear(); @@ -138,15 +140,17 @@ describe("RulesManagementSection", () => { const tree = renderSection(); const text = collectText(tree); const header = collectElements(tree, (element) => element.props.title === "规则管理")[0]; + const switchElement = collectElements(tree, (element) => Boolean((element.props as any).onCheckedChange))[0]; const orderInputs = collectElements(tree, (element) => (element.props as any).title === "最终规则行号(1=最前)"); expect(header.props.title).toBe("规则管理"); - expect(collectText(header.props.badge)).toContain("可调 2 / 全部 3"); - expect(text).toContain("可移动任意非 MATCH 规则"); + expect(collectText(header.props.badge)).toContain("可调 1 / 全部 3"); + expect(text).toContain("默认只能调整自定义规则顺序。"); expect(text).toContain("系统 GEO"); expect(text).toContain("自定义规则"); expect(text).toContain("no-resolve"); - expect(orderInputs.map((input) => input.props.disabled)).toEqual([false, false, true]); + expect(switchElement.props.disabled).toBe(false); + expect(orderInputs.map((input) => input.props.disabled)).toEqual([true, false, true]); expect(mocks.buildGeneratedRuleEntries).toHaveBeenCalledWith( expect.objectContaining({ enabledModules: ["core"], @@ -179,7 +183,7 @@ describe("RulesManagementSection", () => { ]; const defaultTree = renderSection(); - const allRulesTree = renderSection(); + const allRulesTree = renderSection({ allRulesOrderEditingEnabled: true }); const detail = collectElements( defaultTree, (element) => @@ -260,7 +264,7 @@ describe("RulesManagementSection", () => { }, { key: "module:education:scholar", - editable: true, + editable: false, summary: "Scholar", sourceLabel: "📚 教育学术", target: "💬 自定义1", @@ -287,6 +291,33 @@ describe("RulesManagementSection", () => { expect(text).not.toContain("自定义分组 ·"); }); + it("confirms before enabling all-rules order mode and disables without confirmation", async () => { + const switchElement = collectElements(renderSection(), (element) => Boolean((element.props as any).onCheckedChange))[0]; + + mocks.confirmDialog.mockResolvedValueOnce(false); + await switchElement.props.onCheckedChange(true); + expect(mocks.store.setAllRulesOrderEditingEnabled).not.toHaveBeenCalled(); + + mocks.confirmDialog.mockResolvedValueOnce(true); + await switchElement.props.onCheckedChange(true); + expect(mocks.confirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: "开启“调整所有规则顺序”?", + cancelText: "保持默认", + confirmText: "继续开启", + variant: "warning", + }) + ); + expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(true); + + const enabledSwitch = collectElements( + renderSection({ allRulesOrderEditingEnabled: true }), + (element) => Boolean((element.props as any).onCheckedChange) + )[0]; + await enabledSwitch.props.onCheckedChange(false); + expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(false); + }); + it("moves editable rules by buttons, absolute order input, blur, and escape cleanup", () => { stateMock.value = { "custom:one": "1" }; const tree = renderSection(); @@ -327,6 +358,8 @@ describe("RulesManagementSection", () => { experimentalCnUseCnRuleSet: false, ruleOrder: [], setRuleOrder: vi.fn(), + allRulesOrderEditingEnabled: false, + setAllRulesOrderEditingEnabled: vi.fn(), }; const tree = RulesManagementSection({ isExpanded: false, onToggle: vi.fn() }); const header = collectElements(tree, (element) => element.props.title === "规则管理")[0]; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx index e8820f3..b33425e 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx @@ -3,7 +3,9 @@ import * as React from "react"; import { ArrowDown, ArrowUp, ListOrdered } from "lucide-react"; import { Badge } from "@subboost/ui/components/ui/badge"; +import { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog"; import { Input } from "@subboost/ui/components/ui/input"; +import { Switch } from "@subboost/ui/components/ui/switch"; import { buildGeneratedRuleEntries, type GeneratedRuleEntry, @@ -47,8 +49,11 @@ export function RulesManagementSection({ experimentalCnUseCnRuleSet, ruleOrder, setRuleOrder, + allRulesOrderEditingEnabled, + setAllRulesOrderEditingEnabled, } = useConfigStore(); const [orderDrafts, setOrderDrafts] = React.useState>({}); + const allRulesMode = allRulesOrderEditingEnabled; const entries = React.useMemo( () => @@ -79,7 +84,8 @@ export function RulesManagementSection({ [entries] ); const preMatchKeys = React.useMemo(() => preMatchEntries.map((entry) => entry.key), [preMatchEntries]); - const editableKeys = preMatchKeys; + const editableEntries = React.useMemo(() => entries.filter((entry) => entry.editable), [entries]); + const editableKeys = React.useMemo(() => editableEntries.map((entry) => entry.key), [editableEntries]); const applyRuleOrder = React.useCallback( (nextRuleOrder: string[]) => { @@ -123,6 +129,36 @@ export function RulesManagementSection({ [applyRuleOrder, preMatchEntries.length, preMatchKeys] ); + const handleToggleAllRulesMode = React.useCallback( + async (checked: boolean) => { + if (!checked) { + setAllRulesOrderEditingEnabled(false); + return; + } + + const ok = await confirmDialog({ + title: "开启“调整所有规则顺序”?", + description: ( + + + 警告: + 开启后,你可以移动任意规则到任意位置,这会改变分流优先级与命中结果。 + + + 如果你不知道调整规则顺序的影响,请不要动它。 + + + ), + cancelText: "保持默认", + confirmText: "继续开启", + variant: "warning", + }); + if (!ok) return; + setAllRulesOrderEditingEnabled(true); + }, + [setAllRulesOrderEditingEnabled] + ); + return (
- 可调 {preMatchEntries.length} / 全部 {entries.length} + 可调 {allRulesMode ? preMatchEntries.length : editableEntries.length} / 全部 {entries.length} } /> @@ -142,7 +178,17 @@ export function RulesManagementSection({
- 可移动任意非 MATCH 规则;排序只决定命中优先级,目标只决定命中后进入哪个分流组。 + {allRulesMode + ? "已开启全规则排序:可移动任意规则,但 MATCH 固定最后。" + : "默认只能调整自定义规则顺序。"} +
+
+ 调整所有规则顺序 +
@@ -150,7 +196,7 @@ export function RulesManagementSection({
{entries.map((entry, index) => { const fullIndex = preMatchKeys.indexOf(entry.key); - const canEditOrder = entry.key !== "special:match"; + const canEditOrder = entry.key !== "special:match" && (allRulesMode || entry.editable); const canMoveUp = canEditOrder && fullIndex > 0; const canMoveDown = canEditOrder && fullIndex >= 0 && fullIndex < preMatchKeys.length - 1; const absoluteOrder = index + 1; 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..9a4f8c8 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 @@ -41,4 +41,15 @@ describe("config store settings actions", () => { expect(store.setAndGenerateConfig).toHaveBeenCalledTimes(8); expect(store.set).not.toHaveBeenCalled(); }); + + it("updates all-rules order editing as UI-only state", () => { + const store = createStore(); + const actions = createSettingsActions(store.set as any, store.get as any, store.setAndGenerateConfig as any); + + actions.setAllRulesOrderEditingEnabled(1 as unknown as boolean); + + expect(store.state()).toEqual({ allRulesOrderEditingEnabled: true }); + expect(store.set).toHaveBeenCalledWith({ allRulesOrderEditingEnabled: true }); + expect(store.setAndGenerateConfig).not.toHaveBeenCalled(); + }); }); 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 2c2be65..b75ce26 100644 --- a/packages/ui/src/store/config-store/actions/settings-actions.ts +++ b/packages/ui/src/store/config-store/actions/settings-actions.ts @@ -11,10 +11,11 @@ type SettingsActions = Pick< | "setRuleProviderBaseUrl" | "setCnIpNoResolve" | "setExperimentalCnUseCnRuleSet" + | "setAllRulesOrderEditingEnabled" >; export function createSettingsActions( - _set: SetState, + set: SetState, _get: GetState, setAndGenerateConfig: SetAndGenerateConfig ): SettingsActions { @@ -50,5 +51,9 @@ export function createSettingsActions( setExperimentalCnUseCnRuleSet: (value: boolean) => { setAndGenerateConfig(() => ({ experimentalCnUseCnRuleSet: Boolean(value) })); }, + + setAllRulesOrderEditingEnabled: (enabled: boolean) => { + set({ allRulesOrderEditingEnabled: Boolean(enabled) }); + }, }; } 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 164273c..c6c8cbd 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 @@ -37,6 +37,7 @@ describe("createTemplateActions", () => { customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }], builtinRuleEdits: { "module:ai:anthropic": { enabled: false } }, ruleOrder: ["module:ai:openai"], + allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, }); @@ -51,6 +52,7 @@ describe("createTemplateActions", () => { customRuleSets: [], builtinRuleEdits: {}, ruleOrder: [], + allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, }); @@ -161,6 +163,7 @@ describe("createTemplateActions", () => { }, ], builtinRuleEdits: { "module:ai:openai": { target: "🔍 Google" } }, + allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: false, cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, 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 5289f98..4ce3700 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.ts @@ -1,7 +1,7 @@ import { getBuiltinTemplateId } from "@subboost/core/templates/builtin"; import { TEMPLATES } from "@subboost/core/templates"; import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils"; -import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; +import { hasFullRuleOrderKeys, normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; import type { ConfigActions, SubBoostTemplateConfig } from "../definitions"; @@ -52,6 +52,7 @@ export function createTemplateActions( customRuleSets: [], builtinRuleEdits: {}, ruleOrder: [], + allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, })); }, @@ -144,6 +145,7 @@ export function createTemplateActions( ruleOrder: config.ruleOrder, }) : state.ruleOrder; + const legacyAllRulesOrderEditingEnabled = (config as Record).allRulesOrderEditingEnabled; return { // 不触碰 nodes/sources:模板只描述“生成策略”,节点仍由用户导入 @@ -159,6 +161,12 @@ export function createTemplateActions( moduleRuleEditWarningAccepted: false, customRules: nextCustomRules, ruleOrder: nextRuleOrder, + allRulesOrderEditingEnabled: + typeof legacyAllRulesOrderEditingEnabled === "boolean" + ? legacyAllRulesOrderEditingEnabled + : shouldRefreshRuleOrder + ? hasFullRuleOrderKeys(nextRuleOrder) + : state.allRulesOrderEditingEnabled, cnIpNoResolve: typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve, experimentalCnUseCnRuleSet: 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 481b95d..2401696 100644 --- a/packages/ui/src/store/config-store/auth-handoff.test.ts +++ b/packages/ui/src/store/config-store/auth-handoff.test.ts @@ -62,6 +62,7 @@ function meaningfulState(overrides: Record = {}) { proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], + allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", template: "full", @@ -154,6 +155,7 @@ describe("auth config handoff", () => { proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], + allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", dnsYaml: "dns: {}", @@ -286,6 +288,7 @@ describe("auth config handoff", () => { dialerProxyGroups: [{ id: "dialer" }], proxyGroupOrder: ["module:ai"], ruleOrder: ["rule"], + allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: false, appliedTemplateId: null, dnsYaml: "dns: {}", diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index 2f0792a..bb538c1 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -119,6 +119,7 @@ function hasMeaningfulConfig(state: ConfigState): boolean { hasRecordEntries(state.proxyGroupNameOverrides) || state.proxyGroupOrder.length > 0 || state.ruleOrder.length > 0 || + state.allRulesOrderEditingEnabled !== initialState.allRulesOrderEditingEnabled || state.moduleRuleEditWarningAccepted !== initialState.moduleRuleEditWarningAccepted || state.appliedTemplateId !== initialState.appliedTemplateId || state.template !== initialState.template || @@ -154,6 +155,7 @@ function buildHandoffState(state: ConfigState): Partial { proxyGroupNameOverrides: state.proxyGroupNameOverrides, proxyGroupOrder: state.proxyGroupOrder, ruleOrder: state.ruleOrder, + allRulesOrderEditingEnabled: state.allRulesOrderEditingEnabled, moduleRuleEditWarningAccepted: state.moduleRuleEditWarningAccepted, appliedTemplateId: state.appliedTemplateId, dnsYaml: state.dnsYaml, @@ -209,6 +211,7 @@ function normalizeHandoffState(raw: unknown): Partial | null { if (proxyGroupOrder) out.proxyGroupOrder = proxyGroupOrder; const ruleOrder = stringArray(raw.ruleOrder); if (ruleOrder) out.ruleOrder = ruleOrder; + if (typeof raw.allRulesOrderEditingEnabled === "boolean") out.allRulesOrderEditingEnabled = raw.allRulesOrderEditingEnabled; if (typeof raw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = raw.moduleRuleEditWarningAccepted; if (typeof raw.appliedTemplateId === "string" || raw.appliedTemplateId === null) { out.appliedTemplateId = raw.appliedTemplateId; diff --git a/packages/ui/src/store/config-store/definitions.ts b/packages/ui/src/store/config-store/definitions.ts index 3a91008..3270878 100644 --- a/packages/ui/src/store/config-store/definitions.ts +++ b/packages/ui/src/store/config-store/definitions.ts @@ -191,6 +191,9 @@ export interface ConfigState { // Key 格式:custom-rule: / custom-rule-set: / module:: / special: ruleOrder: string[]; + // 是否允许在规则管理中调整所有规则顺序;只影响 UI,不参与生成。 + allRulesOrderEditingEnabled: boolean; + // 当前配置是否已确认过“编辑预设规则”风险提示;只影响 UI,不参与生成。 moduleRuleEditWarningAccepted: boolean; @@ -249,6 +252,7 @@ export interface ConfigActions { updateCustomRule: (id: string, rule: Partial>) => void; removeCustomRule: (index: number) => void; setRuleOrder: (order: string[]) => void; + setAllRulesOrderEditingEnabled: (enabled: boolean) => void; // 自定义分流组 addCustomProxyGroup: (group: Omit) => void; @@ -349,6 +353,7 @@ export const initialState: ConfigState = { proxyGroupNameOverrides: {}, proxyGroupOrder: [], ruleOrder: [], + allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, appliedTemplateId: getBuiltinTemplateId("minimal"), dnsYaml: DEFAULT_BASE_CONFIG_YAML, From a25ac660ac1187993a4208d2da98de08d22b0545 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:30:51 +0800 Subject: [PATCH 07/29] test: update local URL resolver fixture --- local/app/local-pages.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/local/app/local-pages.test.ts b/local/app/local-pages.test.ts index 5685976..58cc7ac 100644 --- a/local/app/local-pages.test.ts +++ b/local/app/local-pages.test.ts @@ -149,8 +149,8 @@ describe("local app pages and adapters", () => { const adapter = mocks.dashboardAdapter; vi.stubGlobal("window", { location: { - href: "http://23.80.90.37:31401/dashboard", - origin: "http://23.80.90.37:31401", + href: "http://local.subboost.test:31401/dashboard", + origin: "http://local.subboost.test:31401", }, }); @@ -162,7 +162,7 @@ describe("local app pages and adapters", () => { adapter.resolveDownloadUrl({ subscriptionUrl: "http://localhost:3001/api/subscriptions/token-1/config.yaml", }) - ).toBe("http://23.80.90.37:31401/api/subscriptions/token-1/config.yaml"); + ).toBe("http://local.subboost.test:31401/api/subscriptions/token-1/config.yaml"); expect(adapter.autoUpdateIntervalPolicy).toEqual({ defaultHours: 12, minHours: 0.1, From fcdcd55b70ae8a2ecb6f6abe8de66b75484f8afa Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:38:27 +0800 Subject: [PATCH 08/29] Remove legacy selfhost rule compatibility --- .../test/subscription-route-contract.test.ts | 1 - .../core/src/generator/module-rules.test.ts | 17 +- packages/core/src/generator/module-rules.ts | 39 ++-- .../src/rules/custom-rule-helpers.test.ts | 2 - packages/core/src/rules/custom-rule-utils.ts | 5 - packages/core/src/rules/rule-model.ts | 209 +++--------------- packages/core/src/rules/rules-utils.test.ts | 18 +- .../src/subscription/config-utils.test.ts | 58 ++--- .../core/src/subscription/config-utils.ts | 50 ----- .../subscription/subscription-utils.test.ts | 28 --- .../src/templates/config-template.test.ts | 39 ++-- .../core/src/templates/config-template.ts | 21 ++ packages/core/src/types/config.ts | 2 +- .../sections/proxy-groups-added-rule-sets.tsx | 4 +- .../sections/proxy-groups-categories.tsx | 27 +-- .../proxy-groups-custom-rules.test.ts | 17 +- .../sections/proxy-groups-custom-rules.tsx | 2 - .../sections/proxy-groups-module-card.tsx | 22 +- .../proxy-groups-module-rules-panel.tsx | 23 +- .../sections/rules-management-section.test.ts | 20 +- .../sections/rules-management-section.tsx | 11 +- .../use-editing-subscription-loader.test.ts | 17 +- packages/ui/src/store/config-store.ts | 1 - .../actions/settings-actions.test.ts | 11 - .../config-store/actions/settings-actions.ts | 7 +- .../actions/template-actions.test.ts | 12 +- .../config-store/actions/template-actions.ts | 33 +-- .../store/config-store/auth-handoff.test.ts | 26 ++- .../ui/src/store/config-store/auth-handoff.ts | 11 +- .../ui/src/store/config-store/definitions.ts | 6 - 30 files changed, 207 insertions(+), 532 deletions(-) diff --git a/local/test/subscription-route-contract.test.ts b/local/test/subscription-route-contract.test.ts index a65f3d2..0ef551e 100644 --- a/local/test/subscription-route-contract.test.ts +++ b/local/test/subscription-route-contract.test.ts @@ -58,7 +58,6 @@ const fullConfigPayload = { customProxyGroups: [{ id: "group-1", name: "Custom", type: "select", proxies: ["node-a"] }], customRules: [{ id: "rule-1", type: "DOMAIN-SUFFIX", value: "example.com", target: "PROXY" }], ruleOrder: ["rule-1"], - allRulesOrderEditingEnabled: true, dialerProxyGroups: [{ proxyName: "node-a", dialerProxy: "relay-a" }], proxyGroupNameOverrides: { PROXY: "Proxy" }, listenerPorts: { "node-a": 12000 }, diff --git a/packages/core/src/generator/module-rules.test.ts b/packages/core/src/generator/module-rules.test.ts index b097b79..ec50c1a 100644 --- a/packages/core/src/generator/module-rules.test.ts +++ b/packages/core/src/generator/module-rules.test.ts @@ -7,7 +7,7 @@ import { getModuleRuleOrderKey, isModuleRuleMovedFrom, isPresetModuleRule, - normalizeModuleRuleExclusions, + normalizeHiddenPresetRuleIds, } from "./module-rules"; import type { ProxyGroupModule, ProxyGroupRule } from "./proxy-group-modules"; @@ -30,15 +30,15 @@ const customRules: ProxyGroupRule[] = [ ]; describe("module rule helpers", () => { - it("normalizes exclusions and resolves excluded ids", () => { + it("normalizes hidden preset rule ids and resolves excluded ids", () => { expect( - normalizeModuleRuleExclusions({ + normalizeHiddenPresetRuleIds({ " media ": [" youtube ", "youtube", "", 123], skip: "bad", }) ).toEqual({ media: ["youtube"] }); - expect(normalizeModuleRuleExclusions(null)).toEqual({}); - expect(normalizeModuleRuleExclusions([])).toEqual({}); + expect(normalizeHiddenPresetRuleIds(null)).toEqual({}); + expect(normalizeHiddenPresetRuleIds([])).toEqual({}); expect([...getExcludedModuleRuleIds(" media ", { media: [" youtube ", ""] })]).toEqual(["youtube"]); expect([...getExcludedModuleRuleIds(" ", { media: ["youtube"] })]).toEqual([]); }); @@ -78,14 +78,11 @@ describe("module rule helpers", () => { ]); }); - it("detects rules moved to another module or custom group", () => { + it("detects rules moved to another module", () => { expect(isModuleRuleMovedFrom("media", "youtube", { other: [{ id: "youtube" }] })).toBe(true); expect(isModuleRuleMovedFrom("media", "youtube", { media: [{ id: "youtube" }] })).toBe(false); - expect(isModuleRuleMovedFrom("media", "youtube", undefined, [{ rules: [{ id: "youtube" }] }])).toBe(true); expect(isModuleRuleMovedFrom("", "youtube", { other: [{ id: "youtube" }] })).toBe(false); expect(isModuleRuleMovedFrom("media", " ", { other: [{ id: "youtube" }] })).toBe(false); - expect(isModuleRuleMovedFrom("media", "youtube", { other: "bad" as never }, [{ rules: "bad" as never }])).toBe( - false - ); + expect(isModuleRuleMovedFrom("media", "youtube", { other: "bad" as never })).toBe(false); }); }); diff --git a/packages/core/src/generator/module-rules.ts b/packages/core/src/generator/module-rules.ts index 2b0a093..9a29cef 100644 --- a/packages/core/src/generator/module-rules.ts +++ b/packages/core/src/generator/module-rules.ts @@ -1,6 +1,6 @@ import type { ProxyGroupModule, ProxyGroupRule } from "./proxy-group-modules"; -export type ModuleRuleExclusions = Record; +export type HiddenPresetRuleIds = Record; export type EffectiveModuleRuleSource = "preset" | "custom"; @@ -9,16 +9,15 @@ export type EffectiveModuleRule = ProxyGroupRule & { }; type RuleIdLike = { id?: unknown }; -type RuleGroupLike = { rules?: RuleIdLike[] }; function normalizeString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -export function normalizeModuleRuleExclusions(value: unknown): ModuleRuleExclusions { +export function normalizeHiddenPresetRuleIds(value: unknown): HiddenPresetRuleIds { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - const out: ModuleRuleExclusions = {}; + const out: HiddenPresetRuleIds = {}; for (const [moduleIdRaw, ruleIdsRaw] of Object.entries(value as Record)) { const moduleId = normalizeString(moduleIdRaw); if (!moduleId || !Array.isArray(ruleIdsRaw)) continue; @@ -39,11 +38,11 @@ export function normalizeModuleRuleExclusions(value: unknown): ModuleRuleExclusi export function getExcludedModuleRuleIds( moduleId: string, - exclusions?: ModuleRuleExclusions + hiddenPresetRuleIds?: HiddenPresetRuleIds ): Set { const id = normalizeString(moduleId); if (!id) return new Set(); - return new Set((exclusions?.[id] || []).map(normalizeString).filter(Boolean)); + return new Set((hiddenPresetRuleIds?.[id] || []).map(normalizeString).filter(Boolean)); } export function getModuleRuleOrderKey(moduleId: string, ruleId: string): string { @@ -59,31 +58,25 @@ export function isPresetModuleRule(module: ProxyGroupModule, ruleId: string): bo export function isModuleRuleMovedFrom( moduleId: string, ruleId: string, - overrides?: Record, - customRuleGroups?: RuleGroupLike[] + ruleSetsByTarget?: Record ): boolean { const sourceId = normalizeString(moduleId); const id = normalizeString(ruleId); if (!sourceId || !id) return false; - for (const [targetModuleIdRaw, rules] of Object.entries(overrides || {})) { + for (const [targetModuleIdRaw, rules] of Object.entries(ruleSetsByTarget || {})) { const targetModuleId = normalizeString(targetModuleIdRaw); if (!targetModuleId || targetModuleId === sourceId || !Array.isArray(rules)) continue; if (rules.some((rule) => normalizeString(rule?.id) === id)) return true; } - for (const group of customRuleGroups || []) { - if (!Array.isArray(group?.rules)) continue; - if (group.rules.some((rule) => normalizeString(rule?.id) === id)) return true; - } - return false; } export function getModuleRuleById( module: ProxyGroupModule, ruleId: string, - overrides?: Record + ruleSetsByTarget?: Record ): ProxyGroupRule | null { const id = normalizeString(ruleId); if (!id) return null; @@ -91,16 +84,16 @@ export function getModuleRuleById( const preset = module.rules.find((rule) => rule.id === id); if (preset) return preset; - const extra = Array.isArray(overrides?.[module.id]) ? overrides?.[module.id] || [] : []; + const extra = Array.isArray(ruleSetsByTarget?.[module.id]) ? ruleSetsByTarget?.[module.id] || [] : []; return extra.find((rule) => rule.id === id) || null; } export function getEffectiveModuleRuleItems( module: ProxyGroupModule, - overrides?: Record, - exclusions?: ModuleRuleExclusions + ruleSetsByTarget?: Record, + hiddenPresetRuleIds?: HiddenPresetRuleIds ): EffectiveModuleRule[] { - const excluded = getExcludedModuleRuleIds(module.id, exclusions); + const excluded = getExcludedModuleRuleIds(module.id, hiddenPresetRuleIds); const seen = new Set(); const out: EffectiveModuleRule[] = []; @@ -110,7 +103,7 @@ export function getEffectiveModuleRuleItems( out.push({ ...rule, source: "preset" }); } - const extraRules = Array.isArray(overrides?.[module.id]) ? overrides?.[module.id] || [] : []; + const extraRules = Array.isArray(ruleSetsByTarget?.[module.id]) ? ruleSetsByTarget?.[module.id] || [] : []; for (const rule of extraRules) { if (!rule?.id || seen.has(rule.id)) continue; seen.add(rule.id); @@ -122,8 +115,8 @@ export function getEffectiveModuleRuleItems( export function getEffectiveModuleRules( module: ProxyGroupModule, - overrides?: Record, - exclusions?: ModuleRuleExclusions + ruleSetsByTarget?: Record, + hiddenPresetRuleIds?: HiddenPresetRuleIds ): ProxyGroupRule[] { - return getEffectiveModuleRuleItems(module, overrides, exclusions).map(({ source: _source, ...rule }) => rule); + return getEffectiveModuleRuleItems(module, ruleSetsByTarget, hiddenPresetRuleIds).map(({ source: _source, ...rule }) => rule); } diff --git a/packages/core/src/rules/custom-rule-helpers.test.ts b/packages/core/src/rules/custom-rule-helpers.test.ts index b821128..536386c 100644 --- a/packages/core/src/rules/custom-rule-helpers.test.ts +++ b/packages/core/src/rules/custom-rule-helpers.test.ts @@ -12,7 +12,6 @@ import { createCustomRuleId, ensureCustomRuleId, ensureCustomRulesHaveIds, - getCustomGroupRuleOrderKey, getCustomRuleOrderKey, isCustomRuleType, listEditableRuleOrderKeys, @@ -145,7 +144,6 @@ describe("custom rule id and order helpers", () => { ] as CustomRuleSet[]; expect(getCustomRuleOrderKey("r1")).toBe("custom-rule:r1"); - expect(getCustomGroupRuleOrderKey("g1", "r1")).toBe("custom-group:g1:r1"); expect(listEditableRuleOrderKeys(customRules, customRuleSets)).toEqual([ `custom-rule:${ruleWithoutId.id}`, "custom-rule-set:rule-a", diff --git a/packages/core/src/rules/custom-rule-utils.ts b/packages/core/src/rules/custom-rule-utils.ts index 6d45cc4..1d2de5a 100644 --- a/packages/core/src/rules/custom-rule-utils.ts +++ b/packages/core/src/rules/custom-rule-utils.ts @@ -11,7 +11,6 @@ export const CUSTOM_RULE_TYPES = [ "PROCESS-NAME", "DST-PORT", "SRC-PORT", - "RULE-SET", ] as const satisfies readonly CustomRule["type"][]; const customRuleTypeSet = new Set(CUSTOM_RULE_TYPES); @@ -73,10 +72,6 @@ export function getCustomRuleOrderKey(ruleId: string): string { return `custom-rule:${ruleId}`; } -export function getCustomGroupRuleOrderKey(groupId: string, ruleId: string): string { - return `custom-group:${groupId}:${ruleId}`; -} - export function getCustomRuleSetOrderKey(ruleSetId: string): string { return `custom-rule-set:${ruleSetId}`; } diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts index 8a64c5f..681218b 100644 --- a/packages/core/src/rules/rule-model.ts +++ b/packages/core/src/rules/rule-model.ts @@ -1,33 +1,8 @@ -import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-group-modules"; -import { getModuleRuleOrderKey, normalizeModuleRuleExclusions } from "@subboost/core/generator/module-rules"; -import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import type { - BuiltinRuleEdit, - BuiltinRuleEdits, - CustomProxyGroup, - CustomRuleSet, - RuleSetBehavior, -} from "@subboost/core/types/config"; +import type { BuiltinRuleEdit, BuiltinRuleEdits, CustomProxyGroup, CustomRuleSet, RuleSetBehavior } from "@subboost/core/types/config"; import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy } from "@subboost/core/types/config"; export const RULE_SET_PATH_RE = /^(geosite|geoip)\/[^/?#\s]+\.mrs$/i; -type LegacyModuleRuleOverride = { - id: string; - name: string; - behavior: RuleSetBehavior; - path: string; - noResolve?: boolean; -}; - -type LegacyCustomProxyGroupRule = { - id: string; - name: string; - behavior: RuleSetBehavior; - url: string; - noResolve?: boolean; -}; - export type NormalizedRuleModel = { customProxyGroups: CustomProxyGroup[]; customRuleSets: CustomRuleSet[]; @@ -42,10 +17,6 @@ function toTrimmedString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -function normalizeBehavior(value: unknown, path: string): RuleSetBehavior { - return value === "ipcidr" || path.toLowerCase().startsWith("geoip/") ? "ipcidr" : "domain"; -} - export function extractRuleSetPathFromUrl(url: string): string { const trimmed = url.trim(); const match = trimmed.match(/(?:^|\/)(geosite|geoip)\/[^/?#\s]+\.mrs/i); @@ -58,7 +29,8 @@ export function normalizeRuleSetPathInput(input: string): string { } export function isValidRuleSetPathOrUrl(value: string): boolean { - return RULE_SET_PATH_RE.test(value) || /^https?:\/\//i.test(value); + const trimmed = value.trim(); + return RULE_SET_PATH_RE.test(trimmed) || /^https?:\/\//i.test(trimmed); } export function buildRuleSetUrlFromPath(path: string, baseUrl: string): string { @@ -67,23 +39,28 @@ export function buildRuleSetUrlFromPath(path: string, baseUrl: string): string { return `${baseUrl.replace(/\/+$/, "")}/${normalizedPath}`; } +function normalizeBehavior(value: unknown): RuleSetBehavior | null { + if (value === "domain" || value === "ipcidr") return value; + return null; +} + function normalizeCustomRuleSet(item: unknown): CustomRuleSet | null { if (!isRecord(item)) return null; const id = toTrimmedString(item.id); const rawPath = toTrimmedString(item.path); const path = normalizeRuleSetPathInput(rawPath); const target = toTrimmedString(item.target); - if (!id || !path || !target || !isValidRuleSetPathOrUrl(path)) return null; - const behavior = normalizeBehavior(item.behavior, path); + const behavior = normalizeBehavior(item.behavior); + if (!id || !behavior || !path || !target || !isValidRuleSetPathOrUrl(path)) return null; const name = toTrimmedString(item.name) || id; - const noResolve = typeof item.noResolve === "boolean" ? item.noResolve : behavior === "ipcidr"; + const noResolve = typeof item.noResolve === "boolean" ? item.noResolve : undefined; return { id, name, behavior, path, target, - ...(noResolve ? { noResolve: true } : {}), + ...(noResolve !== undefined ? { noResolve } : {}), }; } @@ -111,78 +88,9 @@ export function normalizeBuiltinRuleEdits(value: unknown): BuiltinRuleEdits { return out; } -function addUniqueCustomRuleSet(items: CustomRuleSet[], item: CustomRuleSet): void { - const existing = new Set(items.map((ruleSet) => ruleSet.id)); - if (!existing.has(item.id)) { - items.push(item); - return; - } - - let index = 2; - let id = `${item.id}-${index}`; - while (existing.has(id)) { - index += 1; - id = `${item.id}-${index}`; - } - items.push({ ...item, id }); -} - -function normalizeLegacyModuleRuleOverrides(value: unknown): Record { - if (!isRecord(value)) return {}; - const out: Record = {}; - for (const [rawModuleId, rawRules] of Object.entries(value)) { - const moduleId = rawModuleId.trim(); - if (!moduleId || !Array.isArray(rawRules)) continue; - const rules: LegacyModuleRuleOverride[] = []; - for (const rawRule of rawRules) { - if (!isRecord(rawRule)) continue; - const id = toTrimmedString(rawRule.id); - const path = normalizeRuleSetPathInput(toTrimmedString(rawRule.path)); - if (!id || !path || !isValidRuleSetPathOrUrl(path)) continue; - const behavior = normalizeBehavior(rawRule.behavior, path); - const name = toTrimmedString(rawRule.name) || id; - const noResolve = typeof rawRule.noResolve === "boolean" ? rawRule.noResolve : behavior === "ipcidr"; - rules.push({ id, name, behavior, path, ...(noResolve ? { noResolve: true } : {}) }); - } - if (rules.length > 0) out[moduleId] = rules; - } - return out; -} - -function normalizeLegacyCustomProxyGroupRules(value: unknown): LegacyCustomProxyGroupRule[] { +function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { if (!Array.isArray(value)) return []; - const out: LegacyCustomProxyGroupRule[] = []; - for (const rawRule of value) { - if (!isRecord(rawRule)) continue; - const id = toTrimmedString(rawRule.id); - const url = toTrimmedString(rawRule.url); - if (!id || !url) continue; - const path = normalizeRuleSetPathInput(url); - if (!isValidRuleSetPathOrUrl(path)) continue; - const behavior = normalizeBehavior(rawRule.behavior, path); - const name = toTrimmedString(rawRule.name) || id; - const noResolve = typeof rawRule.noResolve === "boolean" ? rawRule.noResolve : behavior === "ipcidr"; - out.push({ id, name, behavior, url, ...(noResolve ? { noResolve: true } : {}) }); - } - return out; -} - -function getBuiltinRuleSourceKey(ruleId: string): string | null { - for (const proxyModule of PROXY_GROUP_MODULES) { - if (proxyModule.rules.some((rule) => rule.id === ruleId)) { - return getModuleRuleOrderKey(proxyModule.id, ruleId); - } - } - return null; -} - -function normalizeCustomProxyGroupsAndLegacyRules(value: unknown): { - groups: CustomProxyGroup[]; - legacyRuleSets: CustomRuleSet[]; -} { - if (!Array.isArray(value)) return { groups: [], legacyRuleSets: [] }; const groups: CustomProxyGroup[] = []; - const legacyRuleSets: CustomRuleSet[] = []; for (const rawGroup of value) { if (!isRecord(rawGroup)) continue; @@ -215,82 +123,31 @@ function normalizeCustomProxyGroupsAndLegacyRules(value: unknown): { } : {}), }); - - for (const rule of normalizeLegacyCustomProxyGroupRules(rawGroup.rules)) { - addUniqueCustomRuleSet(legacyRuleSets, { - id: rule.id, - name: rule.name, - behavior: rule.behavior, - path: normalizeRuleSetPathInput(rule.url), - target: name, - ...(rule.noResolve ? { noResolve: true } : {}), - }); - } } - return { groups, legacyRuleSets }; + return groups; } -export function normalizeRuleModelFromConfig(value: unknown): NormalizedRuleModel { - const record = isRecord(value) ? value : {}; - const { groups: customProxyGroups, legacyRuleSets } = normalizeCustomProxyGroupsAndLegacyRules(record.customProxyGroups); - const customRuleSets: CustomRuleSet[] = []; - const builtinRuleEdits: BuiltinRuleEdits = normalizeBuiltinRuleEdits(record.builtinRuleEdits); - - if (Array.isArray(record.customRuleSets)) { - for (const rawRuleSet of record.customRuleSets) { - const normalized = normalizeCustomRuleSet(rawRuleSet); - if (normalized) addUniqueCustomRuleSet(customRuleSets, normalized); - } - } - - for (const ruleSet of legacyRuleSets) { - addUniqueCustomRuleSet(customRuleSets, ruleSet); - } - - const legacyOverrides = normalizeLegacyModuleRuleOverrides(record.moduleRuleOverrides); - const legacyExclusions = normalizeModuleRuleExclusions(record.moduleRuleExclusions); - const consumedMovedOverrideKeys = new Set(); - - for (const [sourceModuleId, ruleIds] of Object.entries(legacyExclusions)) { - for (const ruleId of ruleIds) { - const key = getModuleRuleOrderKey(sourceModuleId, ruleId); - let movedTarget: string | null = null; - for (const [targetModuleId, rules] of Object.entries(legacyOverrides)) { - const index = rules.findIndex((rule) => rule.id === ruleId); - if (index < 0) continue; - const targetModule = PROXY_GROUP_MODULES.find((module) => module.id === targetModuleId); - if (!targetModule) continue; - movedTarget = resolveProxyGroupModuleName(targetModule, (record.proxyGroupNameOverrides as Record | undefined)?.[targetModuleId]); - consumedMovedOverrideKeys.add(`${targetModuleId}:${index}`); - break; - } - builtinRuleEdits[key] = { - ...(movedTarget ? { target: movedTarget } : {}), - ...(movedTarget ? {} : { enabled: false as const }), - }; - } +function normalizeCustomRuleSets(value: unknown): CustomRuleSet[] { + if (!Array.isArray(value)) return []; + const ruleSets: CustomRuleSet[] = []; + const seen = new Set(); + + for (const rawRuleSet of value) { + const normalized = normalizeCustomRuleSet(rawRuleSet); + if (!normalized || seen.has(normalized.id)) continue; + seen.add(normalized.id); + ruleSets.push(normalized); } - for (const [targetModuleId, rules] of Object.entries(legacyOverrides)) { - const targetModule = PROXY_GROUP_MODULES.find((module) => module.id === targetModuleId); - const target = targetModule - ? resolveProxyGroupModuleName(targetModule, (record.proxyGroupNameOverrides as Record | undefined)?.[targetModuleId]) - : targetModuleId; - rules.forEach((rule, index) => { - if (consumedMovedOverrideKeys.has(`${targetModuleId}:${index}`)) return; - const builtinSourceKey = getBuiltinRuleSourceKey(rule.id); - if (builtinSourceKey && builtinSourceKey === getModuleRuleOrderKey(targetModuleId, rule.id)) return; - addUniqueCustomRuleSet(customRuleSets, { - id: rule.id, - name: rule.name, - behavior: rule.behavior, - path: rule.path, - target, - ...(rule.noResolve ? { noResolve: true } : {}), - }); - }); - } + return ruleSets; +} - return { customProxyGroups, customRuleSets, builtinRuleEdits }; +export function normalizeRuleModelFromConfig(value: unknown): NormalizedRuleModel { + const record = isRecord(value) ? value : {}; + return { + customProxyGroups: normalizeCustomProxyGroups(record.customProxyGroups), + customRuleSets: normalizeCustomRuleSets(record.customRuleSets), + builtinRuleEdits: normalizeBuiltinRuleEdits(record.builtinRuleEdits), + }; } diff --git a/packages/core/src/rules/rules-utils.test.ts b/packages/core/src/rules/rules-utils.test.ts index 5b09178..895b4ed 100644 --- a/packages/core/src/rules/rules-utils.test.ts +++ b/packages/core/src/rules/rules-utils.test.ts @@ -11,7 +11,6 @@ import { parseCustomRuleBatchImport } from "./custom-rule-batch-import"; import { ensureCustomRuleId, ensureCustomRulesHaveIds, - getCustomGroupRuleOrderKey, getCustomRuleOrderKey, isCustomRuleType, listEditableRuleOrderKeys, @@ -37,7 +36,6 @@ describe("custom rule helpers", () => { expect(existing.id).toBe("custom-id"); expect(ensureCustomRulesHaveIds([rule, { type: "DOMAIN", value: "x.com", target: "DIRECT" }])).toHaveLength(2); expect(getCustomRuleOrderKey("r1")).toBe("custom-rule:r1"); - expect(getCustomGroupRuleOrderKey("g1", "r1")).toBe("custom-group:g1:r1"); expect( listEditableRuleOrderKeys( [rule], @@ -154,7 +152,7 @@ describe("custom rule helpers", () => { expect(result.items.map((item) => item.message)).toContain("不支持的尾列:bad-tail"); }); - it("imports Clash rules block and YAML list rule rows", () => { + it("rejects manual RULE-SET rows in Clash rules block imports", () => { const result = parseCustomRuleBatchImport({ text: [ "rules:", @@ -169,15 +167,17 @@ describe("custom rule helpers", () => { existingRules: [], }); - expect(result.readyCount).toBe(3); + expect(result.readyCount).toBe(0); expect(result.skippedCount).toBe(1); - expect(result.canImport).toBe(true); + expect(result.errorCount).toBe(3); + expect(result.canImport).toBe(false); expect(result.items[0]).toMatchObject({ status: "skipped", message: "rules 块标记" }); - expect(result.rules).toEqual([ - expect.objectContaining({ type: "RULE-SET", value: "google@ads", target: "Proxy" }), - expect.objectContaining({ type: "RULE-SET", value: "google@search", target: "🔍 谷歌服务" }), - expect.objectContaining({ type: "RULE-SET", value: "google@video", target: "🔍 谷歌服务" }), + expect(result.items.slice(1).map((item) => item.message)).toEqual([ + "未知规则类型:RULE-SET", + "未知规则类型:RULE-SET", + "未知规则类型:RULE-SET", ]); + expect(result.rules).toEqual([]); }); }); diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 7ac0a0f..745b570 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildGenerateOptionsFromConfig, - extractModuleRuleExclusions, - extractModuleRuleOverrides, getEffectiveTestOptions, } from "./config-utils"; import type { ParsedNode } from "@subboost/core/types/node"; @@ -21,37 +19,6 @@ function node(patch: Partial = {}): ParsedNode { } describe("subscription config utils", () => { - it("extracts valid module rule overrides and exclusions only", () => { - expect( - extractModuleRuleOverrides({ - moduleRuleOverrides: { - " cn ": [ - { id: " direct-cn ", name: " Direct CN ", path: " geosite/cn.mrs " }, - { id: "ip-cn", path: "geoip/cn.mrs" }, - { id: "", path: "geosite/empty.mrs" }, - { id: "bad", path: "https://example.com/bad.yaml" }, - ], - empty: [{ id: "", path: "" }], - skip: "bad", - }, - }) - ).toEqual({ - cn: [ - { id: "direct-cn", name: "Direct CN", behavior: "domain", path: "geosite/cn.mrs" }, - { id: "ip-cn", name: "ip-cn", behavior: "ipcidr", path: "geoip/cn.mrs", noResolve: true }, - ], - }); - - expect( - extractModuleRuleExclusions({ - moduleRuleExclusions: { - " cn ": [" direct-cn ", "direct-cn", "", 123], - bad: "not-list", - }, - }) - ).toEqual({ cn: ["direct-cn"] }); - }); - it("normalizes effective test options with guarded fallbacks", () => { expect(getEffectiveTestOptions({ testUrl: " https://cp.cloudflare.com ", testInterval: 120 })).toEqual({ testUrl: "https://cp.cloudflare.com", @@ -79,10 +46,15 @@ describe("subscription config utils", () => { emoji: "M", groupType: "load-balance", strategy: "bad", - rules: [ - { id: "youtube", name: "YouTube", behavior: "domain", url: "https://rules.example.com/youtube.mrs" }, - { id: "", name: "Bad", behavior: "domain", url: "" }, - ], + }, + ], + customRuleSets: [ + { + id: "youtube", + name: "YouTube", + behavior: "domain", + path: "https://rules.example.com/youtube.mrs", + target: "Media", }, ], dialerProxyGroups: [ @@ -182,10 +154,6 @@ describe("subscription config utils", () => { }); it("drops malformed persisted config while keeping safe defaults", () => { - expect(extractModuleRuleOverrides({ moduleRuleOverrides: null })).toBeUndefined(); - expect(extractModuleRuleOverrides({ moduleRuleOverrides: { " ": [] } })).toBeUndefined(); - expect(extractModuleRuleExclusions({ moduleRuleExclusions: {} })).toBeUndefined(); - const options = buildGenerateOptionsFromConfig( { template: "bad", @@ -200,10 +168,16 @@ describe("subscription config utils", () => { customProxyGroups: [ "bad", { id: "", name: "Bad", emoji: "B", groupType: "select" }, - { id: "fallback", name: "Fallback", emoji: "F", groupType: "fallback", rules: ["bad"] }, + { id: "fallback", name: "Fallback", emoji: "F", groupType: "fallback" }, { id: "direct", name: "Direct", emoji: "D", groupType: "direct-first" }, { id: "reject", name: "Reject", emoji: "R", groupType: "reject-first" }, ], + customRuleSets: [ + "bad", + { id: "", name: "Bad", behavior: "domain", path: "geosite/bad.mrs", target: "Fallback" }, + { id: "bad-behavior", name: "Bad", behavior: "bad", path: "geosite/bad.mrs", target: "Fallback" }, + { id: "bad-path", name: "Bad", behavior: "domain", path: "plain.txt", target: "Fallback" }, + ], dialerProxyGroups: ["bad", { id: "bad", name: "Bad", type: "fallback" }], filteredProxyGroups: [ "bad", diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 82f5c87..6de4c7a 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -1,6 +1,5 @@ import type { GenerateOptions } from "@subboost/core/generator"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; -import { normalizeModuleRuleExclusions, type ModuleRuleExclusions } from "@subboost/core/generator/module-rules"; import type { DialerProxyGroup } from "@subboost/core/types/template-config"; import type { ParsedNode } from "@subboost/core/types/node"; import { @@ -18,55 +17,6 @@ import { ensureCustomRuleId } from "@subboost/core/rules/custom-rule-utils"; import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; -export type ModuleRuleOverrideLike = { - id: string; - name: string; - behavior: "domain" | "ipcidr"; - path: string; - noResolve?: boolean; -}; - -const RULE_PATH_RE = /^(geosite|geoip)\/[^/]+\.mrs$/i; - -export function extractModuleRuleOverrides( - config: Record -): Record | undefined { - const raw = config.moduleRuleOverrides; - if (!raw || typeof raw !== "object") return undefined; - - const out: Record = {}; - for (const [moduleIdRaw, rulesRaw] of Object.entries(raw as Record)) { - const moduleId = (moduleIdRaw || "").trim(); - if (!moduleId) continue; - if (!Array.isArray(rulesRaw)) continue; - - const normalized: ModuleRuleOverrideLike[] = []; - for (const item of rulesRaw as unknown[]) { - if (!item || typeof item !== "object") continue; - const obj = item as Record; - const id = typeof obj.id === "string" ? obj.id.trim() : ""; - const path = typeof obj.path === "string" ? obj.path.trim() : ""; - if (!id || !path || !RULE_PATH_RE.test(path)) continue; - - const behavior = - obj.behavior === "ipcidr" || path.toLowerCase().startsWith("geoip/") ? "ipcidr" : "domain"; - const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id; - const noResolve = Boolean(obj.noResolve) || behavior === "ipcidr"; - - normalized.push({ id, name, behavior, path, ...(noResolve ? { noResolve: true } : {}) }); - } - - if (normalized.length > 0) out[moduleId] = normalized; - } - - return Object.keys(out).length > 0 ? out : undefined; -} - -export function extractModuleRuleExclusions(config: Record): ModuleRuleExclusions | undefined { - const normalized = normalizeModuleRuleExclusions(config.moduleRuleExclusions); - return Object.keys(normalized).length > 0 ? normalized : undefined; -} - function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/packages/core/src/subscription/subscription-utils.test.ts b/packages/core/src/subscription/subscription-utils.test.ts index f0140ce..56a42c0 100644 --- a/packages/core/src/subscription/subscription-utils.test.ts +++ b/packages/core/src/subscription/subscription-utils.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; import type { ParsedNode } from "../types/node"; import { buildGenerateOptionsFromConfig, - extractModuleRuleExclusions, - extractModuleRuleOverrides, getEffectiveTestOptions, } from "./config-utils"; import { @@ -172,32 +170,6 @@ describe("subscription URL and config helpers", () => { expect(tryNormalizeSubscriptionUrlInput("not a url")).toBeNull(); }); - it("normalizes module rule config fragments", () => { - expect( - extractModuleRuleOverrides({ - moduleRuleOverrides: { - cn: [ - { id: " extra ", name: "", behavior: "domain", path: "geosite/example.mrs" }, - { id: "ip", behavior: "domain", path: "geoip/private.mrs" }, - { id: "bad", path: "https://example.com/list.mrs" }, - ], - }, - }) - ).toEqual({ - cn: [ - { id: "extra", name: "extra", behavior: "domain", path: "geosite/example.mrs" }, - { id: "ip", name: "ip", behavior: "ipcidr", path: "geoip/private.mrs", noResolve: true }, - ], - }); - expect( - extractModuleRuleExclusions({ - moduleRuleExclusions: { - cn: ["rule-a", "rule-a", " ", "rule-b"], - }, - }) - ).toEqual({ cn: ["rule-a", "rule-b"] }); - }); - it("builds generate options from persisted config and strips imported-only fields", () => { expect(getEffectiveTestOptions({ testUrl: "not-http", testInterval: -1 })).toEqual({ testUrl: "https://www.gstatic.com/generate_204", diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index 4094834..6afbbd2 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -49,9 +49,10 @@ describe("validateSubBoostTemplateConfig", () => { }); it("normalizes rich template config fields", () => { - const moduleId = getModulesForTemplate("minimal")[0]; + const moduleId = "ai"; const result = validateSubBoostTemplateConfig( validConfig({ + enabledProxyGroups: [...getModulesForTemplate("minimal"), moduleId], hiddenProxyGroups: [], customProxyGroups: [ { @@ -70,6 +71,14 @@ describe("validateSubBoostTemplateConfig", () => { target: "Custom", noResolve: false, }, + { + id: "extra", + name: "Extra", + behavior: "ipcidr", + path: "geoip/private.mrs", + target: "Renamed", + noResolve: true, + }, ], filteredProxyGroups: [ { @@ -104,26 +113,14 @@ describe("validateSubBoostTemplateConfig", () => { enabled: false, }, ], - moduleRuleOverrides: { - [moduleId]: [ - { - id: "extra", - name: "Extra", - behavior: "ipcidr", - path: "geoip/private.mrs", - noResolve: true, - }, - ], - }, - moduleRuleExclusions: { - [moduleId]: ["rule-a", "rule-a", ""], + builtinRuleEdits: { + [`module:${moduleId}:openai`]: { enabled: false }, }, proxyGroupNameOverrides: { [moduleId]: " Renamed ", empty: " ", }, ruleOrder: ["missing", "missing"], - allRulesOrderEditingEnabled: true, }) ); @@ -176,13 +173,23 @@ describe("validateSubBoostTemplateConfig", () => { targetNodes: ["Target A"], enabled: false, }); - expect(result.config.builtinRuleEdits?.[`module:${moduleId}:rule-a`]).toEqual({ enabled: false }); + expect(result.config.builtinRuleEdits?.[`module:${moduleId}:openai`]).toEqual({ enabled: false }); expect(result.config.proxyGroupNameOverrides).toEqual({ [moduleId]: "Renamed" }); expect(result.config).not.toHaveProperty("moduleRuleOverrides"); expect(result.config).not.toHaveProperty("moduleRuleExclusions"); expect(result.config).not.toHaveProperty("allRulesOrderEditingEnabled"); }); + it("rejects removed rule-model compatibility fields", () => { + expectInvalid({ moduleRuleOverrides: {} } as never, "模板配置包含已移除字段: moduleRuleOverrides"); + expectInvalid({ moduleRuleExclusions: {} } as never, "模板配置包含已移除字段: moduleRuleExclusions"); + expectInvalid({ allRulesOrderEditingEnabled: true } as never, "模板配置包含已移除字段: allRulesOrderEditingEnabled"); + expectInvalid( + { customProxyGroups: [{ id: "custom", name: "Custom", emoji: "", groupType: "select", rules: [] }] } as never, + "模板配置包含已移除字段: customProxyGroups[0].rules" + ); + }); + it("rejects template configs that hide every enabled module", () => { const enabledProxyGroups = getModulesForTemplate("minimal"); const result = validateSubBoostTemplateConfig( diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 10e1e6b..ac5a4f8 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -36,12 +36,19 @@ const FILTERED_GROUP_TYPES = new Set([ "reject-first", ]); const NODE_REGIONS = new Set(["us", "hk", "jp", "sg", "tw", "kr", "uk", "de", "fr", "ca", "au", "other"]); +const REMOVED_TEMPLATE_FIELDS = new Set([ + "moduleRuleOverrides", + "moduleRuleExclusions", + "allRulesOrderEditingEnabled", +]); export function validateSubBoostTemplateConfig(value: unknown): ValidationResult { if (!isRecord(value)) return invalid("模板配置必须是对象"); if (value.schema !== SUBBOOST_TEMPLATE_CONFIG_SCHEMA) { return invalid("模板配置 schema 无效"); } + const removedField = findRemovedTemplateField(value); + if (removedField) return invalid(`模板配置包含已移除字段: ${removedField}`); const template = parseTemplateType(value.template); if (!template) return invalid("模板类型无效"); @@ -143,6 +150,20 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function findRemovedTemplateField(value: Record): string | null { + for (const field of REMOVED_TEMPLATE_FIELDS) { + if (field in value) return field; + } + + if (!Array.isArray(value.customProxyGroups)) return null; + for (let index = 0; index < value.customProxyGroups.length; index += 1) { + const group = value.customProxyGroups[index]; + if (isRecord(group) && "rules" in group) return `customProxyGroups[${index}].rules`; + } + + return null; +} + function parseTemplateType(value: unknown): TemplateType | null { if (value === "minimal" || value === "standard" || value === "full") return value; return null; diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 76c485e..d4befc3 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -189,7 +189,7 @@ export interface UserConfig { export interface CustomRule { id: string; - type: "DOMAIN" | "DOMAIN-SUFFIX" | "DOMAIN-KEYWORD" | "IP-CIDR" | "IP-CIDR6" | "GEOIP" | "GEOSITE" | "PROCESS-NAME" | "DST-PORT" | "SRC-PORT" | "RULE-SET"; + type: "DOMAIN" | "DOMAIN-SUFFIX" | "DOMAIN-KEYWORD" | "IP-CIDR" | "IP-CIDR6" | "GEOIP" | "GEOSITE" | "PROCESS-NAME" | "DST-PORT" | "SRC-PORT"; value: string; target: string; noResolve?: boolean; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx index f9bb5e0..93e5783 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx @@ -24,7 +24,7 @@ import { } from "@subboost/core/rules/custom-routing-rule-sets"; import { useConfigStore, - type ModuleRuleOverride, + type RuleSetDraft as StoreRuleSetDraft, } from "@subboost/ui/store/config-store"; import { RULE_EDIT_ACTIONS_CLASS, @@ -198,7 +198,7 @@ export function ProxyGroupsAddedRuleSets({ return; } - const nextModuleRule: ModuleRuleOverride = { + const nextModuleRule: StoreRuleSetDraft = { id: item.id, name: item.name, behavior: item.behavior, 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 3f30897..fb68a78 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 @@ -16,10 +16,9 @@ import { CATEGORY_INFO, PROXY_GROUP_MODULES, } from "@subboost/core/generator/proxy-groups"; -import type { ModuleRuleExclusions as HiddenPresetRuleIds } from "@subboost/core/generator/module-rules"; +import type { HiddenPresetRuleIds } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import { buildRuleSetUrlFromPath } from "@subboost/core/rules/rule-model"; -import { useConfigStore, type ModuleRuleOverride } from "@subboost/ui/store/config-store"; +import { useConfigStore, type RuleSetDraft } from "@subboost/ui/store/config-store"; import { buildManualRuleTargets, listCustomRulesForTarget, @@ -135,26 +134,14 @@ export function ProxyGroupsCategories() { return grouped; }, [hiddenProxyGroups]); const targetRuleView = React.useMemo(() => { - const ruleSetsByTarget: Record = {}; + const ruleSetsByTarget: Record = {}; const hiddenPresetRuleIds: HiddenPresetRuleIds = {}; - const customGroupsWithRules = customProxyGroups.map((group) => ({ - ...group, - rules: customRuleSets - .filter((ruleSet) => ruleSet.target === group.name) - .map((ruleSet) => ({ - id: ruleSet.id, - name: ruleSet.name, - behavior: ruleSet.behavior, - url: buildRuleSetUrlFromPath(ruleSet.path, ruleProviderBaseUrl), - ...(ruleSet.noResolve ? { noResolve: true } : {}), - })), - })); const moduleNameToId = new Map(); for (const proxyModule of PROXY_GROUP_MODULES) { moduleNameToId.set(resolveModuleDisplayName(proxyModule).full, proxyModule.id); } - const pushRuleSetForTarget = (moduleId: string, rule: ModuleRuleOverride) => { + const pushRuleSetForTarget = (moduleId: string, rule: RuleSetDraft) => { ruleSetsByTarget[moduleId] = [...(ruleSetsByTarget[moduleId] || []), rule]; }; const hidePresetRule = (moduleId: string, ruleId: string) => { @@ -199,13 +186,11 @@ export function ProxyGroupsCategories() { } } - return { ruleSetsByTarget, hiddenPresetRuleIds, customGroupsWithRules }; + return { ruleSetsByTarget, hiddenPresetRuleIds }; }, [ builtinRuleEdits, - customProxyGroups, customRuleSets, resolveModuleDisplayName, - ruleProviderBaseUrl, ]); const hiddenModules = React.useMemo(() => { @@ -459,7 +444,7 @@ export function ProxyGroupsCategories() { extraRules={extraRules} ruleSetsByTarget={targetRuleView.ruleSetsByTarget} hiddenPresetRuleIds={targetRuleView.hiddenPresetRuleIds} - customProxyGroups={targetRuleView.customGroupsWithRules} + customProxyGroups={customProxyGroups} manualRules={manualRules} manualRuleTargets={manualRuleTargets} enabledProxyGroups={enabledProxyGroups} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts index 8cd37d5..e1a7cdc 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts @@ -435,7 +435,7 @@ describe("ProxyGroupsCustomRules", () => { expect(mocks.store.removeCustomRule).toHaveBeenCalledWith(0); }); - it("ignores incomplete additions, records unknown rule kinds, and exits stale edits", () => { + it("ignores incomplete additions and exits stale edits", () => { renderRules({ 1: " ", 2: "DIRECT" }); mocks.captures.buttons .find((props) => props.children === "添加规则") @@ -455,21 +455,6 @@ describe("ProxyGroupsCustomRules", () => { "Filter Group", ]); - renderRules({ 0: "RULE-SET" as any, 1: " rule.mrs ", 2: "DIRECT" }); - mocks.captures.buttons - .find((props) => props.children === "添加规则") - .onClick(); - expect(mocks.store.addCustomRule).toHaveBeenCalledWith( - expect.objectContaining({ - type: "RULE-SET", - value: "rule.mrs", - }), - ); - expect(mocks.interactions.ruleAdded).toHaveBeenCalledWith({ - source: "manual", - kind: "unknown", - }); - const stale = renderRules( { 5: "missing-rule", diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx index c24197c..2247382 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx @@ -48,7 +48,6 @@ const CUSTOM_RULE_TYPE_LABELS: Record = { "PROCESS-NAME": "进程名 (PROCESS-NAME)", "DST-PORT": "目标端口 (DST-PORT)", "SRC-PORT": "源端口 (SRC-PORT)", - "RULE-SET": "规则集 (RULE-SET)", }; const CUSTOM_RULE_TYPE_SHORT_LABELS: Record = { @@ -62,7 +61,6 @@ const CUSTOM_RULE_TYPE_SHORT_LABELS: Record = { "PROCESS-NAME": "进程名", "DST-PORT": "目标端口", "SRC-PORT": "源端口", - "RULE-SET": "规则集", }; const CUSTOM_RULE_TYPE_OPTIONS = CUSTOM_RULE_TYPES.map((value) => ({ diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx index 2036f9e..933a0fc 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx @@ -9,10 +9,10 @@ import { getEffectiveModuleRuleItems, getExcludedModuleRuleIds, isModuleRuleMovedFrom, - type ModuleRuleExclusions as HiddenPresetRuleIds, + type HiddenPresetRuleIds, } from "@subboost/core/generator/module-rules"; import type { ProxyGroupModule } from "@subboost/core/generator/proxy-groups"; -import type { CustomProxyGroup, ModuleRuleOverride } from "@subboost/ui/store/config-store"; +import type { CustomProxyGroup, RuleSetDraft } from "@subboost/ui/store/config-store"; import { cn } from "@subboost/ui/lib/utils"; import type { CustomRuleListItem, @@ -26,10 +26,6 @@ import { } from "./proxy-group-name-editor"; import { ProxyGroupSummary } from "./proxy-group-summary"; -type CustomProxyGroupRuleView = CustomProxyGroup & { - rules?: Array<{ id?: unknown }>; -}; - function ModuleHintPopover({ moduleId }: { moduleId: string }) { const isGemini = moduleId === "gemini"; const label = isGemini ? "Gemini 分流说明" : "谷歌学术分流说明"; @@ -142,10 +138,10 @@ export function ProxyGroupsModuleCard({ onCancelEditing: () => void; onCommitEditing: () => void; onHide: () => void; - extraRules: ModuleRuleOverride[]; - ruleSetsByTarget: Record; + extraRules: RuleSetDraft[]; + ruleSetsByTarget: Record; hiddenPresetRuleIds: HiddenPresetRuleIds; - customProxyGroups: CustomProxyGroupRuleView[]; + customProxyGroups: CustomProxyGroup[]; manualRules: CustomRuleListItem[]; manualRuleTargets: ProxyGroupRuleTarget[]; enabledProxyGroups: string[]; @@ -155,9 +151,9 @@ export function ProxyGroupsModuleCard({ acceptModuleRuleEditWarning: () => void; isRulesExpanded: boolean; onToggleRulesExpanded: () => void; - onAddRules: (rules: ModuleRuleOverride[]) => void; - onAddRulesToModule: (moduleId: string, rules: ModuleRuleOverride[]) => void; - onAddRuleToCustomGroup: (groupId: string, rule: ModuleRuleOverride) => void; + onAddRules: (rules: RuleSetDraft[]) => void; + onAddRulesToModule: (moduleId: string, rules: RuleSetDraft[]) => void; + onAddRuleToCustomGroup: (groupId: string, rule: RuleSetDraft) => void; onRemoveExtraRule: (ruleId: string) => void; onMoveRule: (ruleId: string, target: { kind: "module" | "custom"; id: string }) => void; onMoveManualRule: (ruleId: string, targetName: string) => void; @@ -173,7 +169,7 @@ export function ProxyGroupsModuleCard({ const excludedRuleIds = getExcludedModuleRuleIds(module.id, hiddenPresetRuleIds); const excludedRules = module.rules.filter((rule) => rule?.id && excludedRuleIds.has(rule.id)); const movedCount = excludedRules.filter((rule) => - isModuleRuleMovedFrom(module.id, rule.id, ruleSetsByTarget, customProxyGroups) + isModuleRuleMovedFrom(module.id, rule.id, ruleSetsByTarget) ).length; const removedCount = excludedRules.length - movedCount; const excludedCount = excludedRules.length; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx index d0c6e89..d8fec5c 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx @@ -13,13 +13,13 @@ import { getEffectiveModuleRuleItems, getExcludedModuleRuleIds, isModuleRuleMovedFrom, - type ModuleRuleExclusions as HiddenPresetRuleIds, + type HiddenPresetRuleIds, } from "@subboost/core/generator/module-rules"; import { EXPERIMENTAL_CN_RULE } from "@subboost/core/generator/rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { cn } from "@subboost/ui/lib/utils"; import { useProductApiAdapter } from "@subboost/ui/product/api-adapter"; -import type { CustomProxyGroup, ModuleRuleOverride } from "@subboost/ui/store/config-store"; +import type { CustomProxyGroup, RuleSetDraft } from "@subboost/ui/store/config-store"; import type { CustomRuleListItem, ProxyGroupRuleTarget, @@ -37,9 +37,6 @@ type MoveTarget = { kind: "module" | "custom"; id: string }; type ActiveRuleRow = EffectiveModuleRule & { state: "active" }; type InactiveRuleRow = ProxyGroupRule & { source: "preset"; state: "removed" | "moved" }; type RuleRow = ActiveRuleRow | InactiveRuleRow; -type CustomProxyGroupRuleView = CustomProxyGroup & { - rules?: Array<{ id?: unknown }>; -}; type CnCandidateRule = { id: string; name: string; @@ -105,17 +102,17 @@ export function ProxyGroupsModuleRulesPanel({ module: ProxyGroupModule; enabledProxyGroups: string[]; hiddenProxyGroups: string[]; - ruleSetsByTarget: Record; + ruleSetsByTarget: Record; hiddenPresetRuleIds: HiddenPresetRuleIds; - customProxyGroups: CustomProxyGroupRuleView[]; + customProxyGroups: CustomProxyGroup[]; manualRules: CustomRuleListItem[]; manualRuleTargets: ProxyGroupRuleTarget[]; proxyGroupNameOverrides: Record; moduleRuleEditWarningAccepted: boolean; acceptModuleRuleEditWarning: () => void; - onAddRules: (rules: ModuleRuleOverride[]) => void; - onAddRulesToModule: (moduleId: string, rules: ModuleRuleOverride[]) => void; - onAddRuleToCustomGroup: (groupId: string, rule: ModuleRuleOverride) => void; + onAddRules: (rules: RuleSetDraft[]) => void; + onAddRulesToModule: (moduleId: string, rules: RuleSetDraft[]) => void; + onAddRuleToCustomGroup: (groupId: string, rule: RuleSetDraft) => void; onRemoveRule: (ruleId: string) => void; onMoveRule: (ruleId: string, target: MoveTarget) => void; onMoveManualRule: (ruleId: string, targetName: string) => void; @@ -160,11 +157,11 @@ export function ProxyGroupsModuleRulesPanel({ .map((rule) => ({ ...rule, source: "preset" as const, - state: isModuleRuleMovedFrom(module.id, rule.id, ruleSetsByTarget, customProxyGroups) + state: isModuleRuleMovedFrom(module.id, rule.id, ruleSetsByTarget) ? "moved" as const : "removed" as const, })); - }, [customProxyGroups, module, hiddenPresetRuleIds, ruleSetsByTarget]); + }, [module, hiddenPresetRuleIds, ruleSetsByTarget]); const rules = React.useMemo( () => [ @@ -268,7 +265,7 @@ export function ProxyGroupsModuleRulesPanel({ ); const moveExperimentalCnRule = React.useCallback( (target: MoveTarget) => { - const rule: ModuleRuleOverride = { + const rule: RuleSetDraft = { id: EXPERIMENTAL_CN_RULE.id, name: EXPERIMENTAL_CN_RULE.name, behavior: EXPERIMENTAL_CN_RULE.behavior, diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts index 73cec64..ce271c1 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.test.ts @@ -45,6 +45,8 @@ vi.mock("@subboost/ui/components/ui/switch", () => ({ })); vi.mock("@subboost/core/generator/rules", () => ({ buildGeneratedRuleEntries: mocks.buildGeneratedRuleEntries, + hasFullRuleOrderKeys: (ruleOrder: string[] | undefined) => + Array.isArray(ruleOrder) && ruleOrder.some((key) => key.startsWith("module:") || key.startsWith("special:")), })); vi.mock("@subboost/ui/store/config-store", () => ({ useConfigStore: () => mocks.store })); vi.mock("../section-header", () => ({ @@ -90,10 +92,8 @@ function renderSection(overrides: Record = {}) { proxyGroupNameOverrides: {}, cnIpNoResolve: true, experimentalCnUseCnRuleSet: true, - ruleOrder: ["module:geo", "custom:one"], + ruleOrder: ["custom:one"], setRuleOrder: vi.fn(), - allRulesOrderEditingEnabled: false, - setAllRulesOrderEditingEnabled: vi.fn(), ...overrides, }; stateMock.setter.mockClear(); @@ -155,7 +155,7 @@ describe("RulesManagementSection", () => { expect.objectContaining({ enabledModules: ["core"], cnIpNoResolve: true, - ruleOrder: ["module:geo", "custom:one"], + ruleOrder: ["custom:one"], }) ); }); @@ -183,7 +183,7 @@ describe("RulesManagementSection", () => { ]; const defaultTree = renderSection(); - const allRulesTree = renderSection({ allRulesOrderEditingEnabled: true }); + const allRulesTree = renderSection({ ruleOrder: ["custom-rule:ip", "special:match"] }); const detail = collectElements( defaultTree, (element) => @@ -296,7 +296,7 @@ describe("RulesManagementSection", () => { mocks.confirmDialog.mockResolvedValueOnce(false); await switchElement.props.onCheckedChange(true); - expect(mocks.store.setAllRulesOrderEditingEnabled).not.toHaveBeenCalled(); + expect(mocks.store.setRuleOrder).not.toHaveBeenCalled(); mocks.confirmDialog.mockResolvedValueOnce(true); await switchElement.props.onCheckedChange(true); @@ -308,14 +308,14 @@ describe("RulesManagementSection", () => { variant: "warning", }) ); - expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(true); + expect(mocks.store.setRuleOrder).toHaveBeenCalledWith(["module:geo", "custom:one"]); const enabledSwitch = collectElements( - renderSection({ allRulesOrderEditingEnabled: true }), + renderSection({ ruleOrder: ["module:geo", "custom:one"] }), (element) => Boolean((element.props as any).onCheckedChange) )[0]; await enabledSwitch.props.onCheckedChange(false); - expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(false); + expect(mocks.store.setRuleOrder).toHaveBeenCalledWith(["custom:one"]); }); it("moves editable rules by buttons, absolute order input, blur, and escape cleanup", () => { @@ -358,8 +358,6 @@ describe("RulesManagementSection", () => { experimentalCnUseCnRuleSet: false, ruleOrder: [], setRuleOrder: vi.fn(), - allRulesOrderEditingEnabled: false, - setAllRulesOrderEditingEnabled: vi.fn(), }; const tree = RulesManagementSection({ isExpanded: false, onToggle: vi.fn() }); const header = collectElements(tree, (element) => element.props.title === "规则管理")[0]; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx index b33425e..c55c7b1 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/rules-management-section.tsx @@ -8,6 +8,7 @@ import { Input } from "@subboost/ui/components/ui/input"; import { Switch } from "@subboost/ui/components/ui/switch"; import { buildGeneratedRuleEntries, + hasFullRuleOrderKeys, type GeneratedRuleEntry, } from "@subboost/core/generator/rules"; import { useConfigStore } from "@subboost/ui/store/config-store"; @@ -49,11 +50,9 @@ export function RulesManagementSection({ experimentalCnUseCnRuleSet, ruleOrder, setRuleOrder, - allRulesOrderEditingEnabled, - setAllRulesOrderEditingEnabled, } = useConfigStore(); const [orderDrafts, setOrderDrafts] = React.useState>({}); - const allRulesMode = allRulesOrderEditingEnabled; + const allRulesMode = hasFullRuleOrderKeys(ruleOrder); const entries = React.useMemo( () => @@ -132,7 +131,7 @@ export function RulesManagementSection({ const handleToggleAllRulesMode = React.useCallback( async (checked: boolean) => { if (!checked) { - setAllRulesOrderEditingEnabled(false); + applyRuleOrder(editableKeys); return; } @@ -154,9 +153,9 @@ export function RulesManagementSection({ variant: "warning", }); if (!ok) return; - setAllRulesOrderEditingEnabled(true); + applyRuleOrder(preMatchKeys); }, - [setAllRulesOrderEditingEnabled] + [applyRuleOrder, editableKeys, preMatchKeys] ); return ( 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 f53c065..bb155c0 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 @@ -211,14 +211,19 @@ describe("useEditingSubscriptionLoader", () => { enabledGroups: ["select", "auto", "ai", "youtube"], hiddenProxyGroups: ["youtube"], customRules: [{ type: "DOMAIN", value: "example.com", target: "🤖 AI 服务" }], - customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select", rules: [] }], + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], - moduleRuleOverrides: { - ai: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs" }], - }, - moduleRuleExclusions: { ai: ["openai", "openai", ""] }, + customRuleSets: [ + { + id: "custom-ai", + name: "Custom AI", + behavior: "domain", + path: "geosite/custom-ai.mrs", + target: "🤖 Labs", + }, + ], + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, moduleRuleEditWarningAccepted: true, - allRulesOrderEditingEnabled: true, dialerProxyGroups: [{ id: "dialer-1", name: "Relay", relayNodes: ["Remote"], targetNodes: [] }], proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai", "module:ai", ""], diff --git a/packages/ui/src/store/config-store.ts b/packages/ui/src/store/config-store.ts index 6c0f139..66b5dd6 100644 --- a/packages/ui/src/store/config-store.ts +++ b/packages/ui/src/store/config-store.ts @@ -34,7 +34,6 @@ export type { BuiltinRuleEdits, CustomRuleSet, DialerProxyGroup, - ModuleRuleOverride, RuleSetDraft, SourceType, SubBoostTemplateConfig, 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 9a4f8c8..f17b2e0 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 @@ -41,15 +41,4 @@ describe("config store settings actions", () => { expect(store.setAndGenerateConfig).toHaveBeenCalledTimes(8); expect(store.set).not.toHaveBeenCalled(); }); - - it("updates all-rules order editing as UI-only state", () => { - const store = createStore(); - const actions = createSettingsActions(store.set as any, store.get as any, store.setAndGenerateConfig as any); - - actions.setAllRulesOrderEditingEnabled(1 as unknown as boolean); - - expect(store.state()).toEqual({ allRulesOrderEditingEnabled: true }); - expect(store.set).toHaveBeenCalledWith({ allRulesOrderEditingEnabled: true }); - expect(store.setAndGenerateConfig).not.toHaveBeenCalled(); - }); }); 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 b75ce26..2c2be65 100644 --- a/packages/ui/src/store/config-store/actions/settings-actions.ts +++ b/packages/ui/src/store/config-store/actions/settings-actions.ts @@ -11,11 +11,10 @@ type SettingsActions = Pick< | "setRuleProviderBaseUrl" | "setCnIpNoResolve" | "setExperimentalCnUseCnRuleSet" - | "setAllRulesOrderEditingEnabled" >; export function createSettingsActions( - set: SetState, + _set: SetState, _get: GetState, setAndGenerateConfig: SetAndGenerateConfig ): SettingsActions { @@ -51,9 +50,5 @@ export function createSettingsActions( setExperimentalCnUseCnRuleSet: (value: boolean) => { setAndGenerateConfig(() => ({ experimentalCnUseCnRuleSet: Boolean(value) })); }, - - setAllRulesOrderEditingEnabled: (enabled: boolean) => { - set({ allRulesOrderEditingEnabled: Boolean(enabled) }); - }, }; } 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 c6c8cbd..3f0089d 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 @@ -37,7 +37,6 @@ describe("createTemplateActions", () => { customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }], builtinRuleEdits: { "module:ai:anthropic": { enabled: false } }, ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, }); @@ -52,7 +51,6 @@ describe("createTemplateActions", () => { customRuleSets: [], builtinRuleEdits: {}, ruleOrder: [], - allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, }); @@ -117,13 +115,9 @@ describe("createTemplateActions", () => { excludedNodeNames: [], }, ], - moduleRuleOverrides: { - google: [{ id: "openai", name: "OpenAI", behavior: "domain", path: "geosite/openai.mrs" }], - }, - moduleRuleExclusions: { ai: ["openai", ""], missing: ["ignored"] }, + builtinRuleEdits: { "module:ai:openai": { target: "🔍 Google" } }, customRules: [{ id: "", type: "DOMAIN-SUFFIX", value: "example.com", target: "Proxy" }], ruleOrder: ["custom-rule:custom-rule-domain-suffix-example-com-proxy-1"], - allRulesOrderEditingEnabled: true, cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, dialerProxyGroups: [ @@ -163,7 +157,6 @@ describe("createTemplateActions", () => { }, ], builtinRuleEdits: { "module:ai:openai": { target: "🔍 Google" } }, - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: false, cnIpNoResolve: false, experimentalCnUseCnRuleSet: true, @@ -235,10 +228,7 @@ describe("createTemplateActions", () => { hiddenProxyGroups: [" ai ", "adult", "adult", 123], customProxyGroups: "bad", filteredProxyGroups: "bad", - moduleRuleOverrides: null, - moduleRuleExclusions: "bad", customRules: "bad", - allRulesOrderEditingEnabled: "bad", cnIpNoResolve: "bad", experimentalCnUseCnRuleSet: "bad", dialerProxyGroups: "bad", 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 4ce3700..61caa8e 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.ts @@ -1,7 +1,7 @@ import { getBuiltinTemplateId } from "@subboost/core/templates/builtin"; import { TEMPLATES } from "@subboost/core/templates"; import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils"; -import { hasFullRuleOrderKeys, normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; +import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; import type { ConfigActions, SubBoostTemplateConfig } from "../definitions"; @@ -31,10 +31,6 @@ function normalizeHiddenProxyGroups(value: unknown): string[] { return out; } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - export function createTemplateActions( set: SetState, _get: GetState, @@ -52,7 +48,6 @@ export function createTemplateActions( customRuleSets: [], builtinRuleEdits: {}, ruleOrder: [], - allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, })); }, @@ -88,25 +83,17 @@ export function createTemplateActions( const ruleModel = normalizeRuleModelFromConfig(config); const hasCustomProxyGroups = Array.isArray(config.customProxyGroups); const hasCustomRuleSets = Array.isArray(config.customRuleSets); - const hasLegacyModuleRuleOverrides = isRecord((config as Record).moduleRuleOverrides); - const hasLegacyModuleRuleExclusions = isRecord((config as Record).moduleRuleExclusions); - const hasBuiltinRuleEdits = isRecord(config.builtinRuleEdits); + const hasBuiltinRuleEdits = Boolean(config.builtinRuleEdits && typeof config.builtinRuleEdits === "object"); const nextCustomProxyGroups = hasCustomProxyGroups || ruleModel.customProxyGroups.length > 0 ? ruleModel.customProxyGroups : state.customProxyGroups; const nextCustomRuleSets = - hasCustomRuleSets || - hasLegacyModuleRuleOverrides || - hasCustomProxyGroups + hasCustomRuleSets ? ruleModel.customRuleSets : state.customRuleSets; const nextBuiltinRuleEdits = - hasBuiltinRuleEdits || - hasLegacyModuleRuleExclusions || - hasLegacyModuleRuleOverrides - ? ruleModel.builtinRuleEdits - : state.builtinRuleEdits; + hasBuiltinRuleEdits ? ruleModel.builtinRuleEdits : state.builtinRuleEdits; const nextCustomRules = Array.isArray(config.customRules) ? ensureCustomRulesHaveIds(config.customRules) : state.customRules; @@ -117,9 +104,7 @@ export function createTemplateActions( Array.isArray(config.customRules) || hasCustomProxyGroups || hasCustomRuleSets || - hasBuiltinRuleEdits || - hasLegacyModuleRuleExclusions || - hasLegacyModuleRuleOverrides; + hasBuiltinRuleEdits; const nextEnabledModulesRaw = Array.isArray(config.enabledProxyGroups) ? config.enabledProxyGroups : state.enabledProxyGroups; @@ -145,8 +130,6 @@ export function createTemplateActions( ruleOrder: config.ruleOrder, }) : state.ruleOrder; - const legacyAllRulesOrderEditingEnabled = (config as Record).allRulesOrderEditingEnabled; - return { // 不触碰 nodes/sources:模板只描述“生成策略”,节点仍由用户导入 template: config.template ?? state.template, @@ -161,12 +144,6 @@ export function createTemplateActions( moduleRuleEditWarningAccepted: false, customRules: nextCustomRules, ruleOrder: nextRuleOrder, - allRulesOrderEditingEnabled: - typeof legacyAllRulesOrderEditingEnabled === "boolean" - ? legacyAllRulesOrderEditingEnabled - : shouldRefreshRuleOrder - ? hasFullRuleOrderKeys(nextRuleOrder) - : state.allRulesOrderEditingEnabled, cnIpNoResolve: typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve, experimentalCnUseCnRuleSet: 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 2401696..dae7ed8 100644 --- a/packages/ui/src/store/config-store/auth-handoff.test.ts +++ b/packages/ui/src/store/config-store/auth-handoff.test.ts @@ -62,7 +62,6 @@ function meaningfulState(overrides: Record = {}) { proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", template: "full", @@ -155,7 +154,6 @@ describe("auth config handoff", () => { proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", dnsYaml: "dns: {}", @@ -250,14 +248,21 @@ describe("auth config handoff", () => { hiddenProxyGroups: "bad", customProxyGroups: [{ id: "custom" }], filteredProxyGroups: [{ id: "filtered" }], - moduleRuleOverrides: { ai: [] }, - moduleRuleExclusions: { ai: ["openai"] }, + customRuleSets: [ + { + id: "custom-ai", + name: "Custom AI", + behavior: "domain", + path: "geosite/custom-ai.mrs", + target: "Labs", + }, + ], + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, customRules: [{ id: "rule" }], dialerProxyGroups: [{ id: "dialer" }], proxyGroupNameOverrides: { ai: "Labs", bad: 1 }, proxyGroupOrder: ["module:ai", 1], ruleOrder: ["rule", 2], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: false, appliedTemplateId: null, dnsYaml: "dns: {}", @@ -282,13 +287,20 @@ describe("auth config handoff", () => { enabledProxyGroups: ["select"], customProxyGroups: [], filteredProxyGroups: [{ id: "filtered" }], - customRuleSets: [], + customRuleSets: [ + { + id: "custom-ai", + name: "Custom AI", + behavior: "domain", + path: "geosite/custom-ai.mrs", + target: "Labs", + }, + ], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, customRules: [{ id: "rule" }], dialerProxyGroups: [{ id: "dialer" }], proxyGroupOrder: ["module:ai"], ruleOrder: ["rule"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: false, appliedTemplateId: null, dnsYaml: "dns: {}", diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index bb538c1..f184a35 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -119,7 +119,6 @@ function hasMeaningfulConfig(state: ConfigState): boolean { hasRecordEntries(state.proxyGroupNameOverrides) || state.proxyGroupOrder.length > 0 || state.ruleOrder.length > 0 || - state.allRulesOrderEditingEnabled !== initialState.allRulesOrderEditingEnabled || state.moduleRuleEditWarningAccepted !== initialState.moduleRuleEditWarningAccepted || state.appliedTemplateId !== initialState.appliedTemplateId || state.template !== initialState.template || @@ -155,7 +154,6 @@ function buildHandoffState(state: ConfigState): Partial { proxyGroupNameOverrides: state.proxyGroupNameOverrides, proxyGroupOrder: state.proxyGroupOrder, ruleOrder: state.ruleOrder, - allRulesOrderEditingEnabled: state.allRulesOrderEditingEnabled, moduleRuleEditWarningAccepted: state.moduleRuleEditWarningAccepted, appliedTemplateId: state.appliedTemplateId, dnsYaml: state.dnsYaml, @@ -192,14 +190,10 @@ function normalizeHandoffState(raw: unknown): Partial | null { if (customProxyGroups || ruleModel.customProxyGroups.length > 0) out.customProxyGroups = ruleModel.customProxyGroups; const filteredProxyGroups = objectArray(raw.filteredProxyGroups); if (filteredProxyGroups) out.filteredProxyGroups = filteredProxyGroups; - if ( - Array.isArray(raw.customRuleSets) || - isRecord(raw.moduleRuleOverrides) || - (customProxyGroups || ruleModel.customProxyGroups.length > 0) - ) { + if (Array.isArray(raw.customRuleSets)) { out.customRuleSets = ruleModel.customRuleSets; } - if (isRecord(raw.builtinRuleEdits) || isRecord(raw.moduleRuleExclusions) || isRecord(raw.moduleRuleOverrides)) { + if (isRecord(raw.builtinRuleEdits)) { out.builtinRuleEdits = ruleModel.builtinRuleEdits; } const customRules = objectArray(raw.customRules); @@ -211,7 +205,6 @@ function normalizeHandoffState(raw: unknown): Partial | null { if (proxyGroupOrder) out.proxyGroupOrder = proxyGroupOrder; const ruleOrder = stringArray(raw.ruleOrder); if (ruleOrder) out.ruleOrder = ruleOrder; - if (typeof raw.allRulesOrderEditingEnabled === "boolean") out.allRulesOrderEditingEnabled = raw.allRulesOrderEditingEnabled; if (typeof raw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = raw.moduleRuleEditWarningAccepted; if (typeof raw.appliedTemplateId === "string" || raw.appliedTemplateId === null) { out.appliedTemplateId = raw.appliedTemplateId; diff --git a/packages/ui/src/store/config-store/definitions.ts b/packages/ui/src/store/config-store/definitions.ts index 3270878..cfacdbc 100644 --- a/packages/ui/src/store/config-store/definitions.ts +++ b/packages/ui/src/store/config-store/definitions.ts @@ -30,7 +30,6 @@ import { export { DEFAULT_BASE_CONFIG_YAML }; export type RuleSetDraft = Omit; -export type ModuleRuleOverride = RuleSetDraft; export type { BuiltinRuleEdits, CustomRuleSet }; export type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; @@ -191,9 +190,6 @@ export interface ConfigState { // Key 格式:custom-rule: / custom-rule-set: / module:: / special: ruleOrder: string[]; - // 是否允许在规则管理中调整所有规则顺序;只影响 UI,不参与生成。 - allRulesOrderEditingEnabled: boolean; - // 当前配置是否已确认过“编辑预设规则”风险提示;只影响 UI,不参与生成。 moduleRuleEditWarningAccepted: boolean; @@ -252,7 +248,6 @@ export interface ConfigActions { updateCustomRule: (id: string, rule: Partial>) => void; removeCustomRule: (index: number) => void; setRuleOrder: (order: string[]) => void; - setAllRulesOrderEditingEnabled: (enabled: boolean) => void; // 自定义分流组 addCustomProxyGroup: (group: Omit) => void; @@ -353,7 +348,6 @@ export const initialState: ConfigState = { proxyGroupNameOverrides: {}, proxyGroupOrder: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, appliedTemplateId: getBuiltinTemplateId("minimal"), dnsYaml: DEFAULT_BASE_CONFIG_YAML, From 484a076ed1b1dd4f75f07a19393cf80447ac83b8 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 22 Jun 2026 04:12:40 +0800 Subject: [PATCH 09/29] Add shared RFC8484 DoH resolver --- local/src/lib/source-import.test.ts | 39 +++- local/src/lib/source-import.ts | 43 +--- .../src/subscription/doh-resolver.test.ts | 90 ++++++++ .../src/subscription/doh-resolver.ts | 205 ++++++++++++++++++ .../server-core/src/subscription/index.ts | 1 + 5 files changed, 334 insertions(+), 44 deletions(-) create mode 100644 packages/server-core/src/subscription/doh-resolver.test.ts create mode 100644 packages/server-core/src/subscription/doh-resolver.ts diff --git a/local/src/lib/source-import.test.ts b/local/src/lib/source-import.test.ts index d6fd76a..e8db443 100644 --- a/local/src/lib/source-import.test.ts +++ b/local/src/lib/source-import.test.ts @@ -22,6 +22,26 @@ function response(body: string, init: ResponseInit = {}) { return new Response(body, init); } +function dnsResponse(addresses: string[]): Response { + const question = new Uint8Array([ + 3, 97, 112, 105, 4, 100, 108, 101, 114, 2, 105, 111, 0, 0, 1, 0, 1, + ]); + const answers: number[] = []; + for (const address of addresses) { + answers.push(0xc0, 0x0c, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04); + answers.push(...address.split(".").map((part) => Number(part))); + } + const out = new Uint8Array(12 + question.length + answers.length); + const view = new DataView(out.buffer); + view.setUint16(0, 0); + view.setUint16(2, 0x8180); + view.setUint16(4, 1); + view.setUint16(6, addresses.length); + out.set(question, 12); + out.set(answers, 12 + question.length); + return new Response(out, { status: 200 }); +} + async function runTransport(url: string, overrides: Record = {}) { mocks.importSubscriptionFromUrl.mockImplementationOnce(async (request, options) => { return options.fetchText({ @@ -91,8 +111,8 @@ describe("local source import transport", () => { it("rechecks fake-ip DNS answers with DoH before fetching the subscription", async () => { mocks.lookup.mockResolvedValueOnce([{ address: "198.18.3.6" }]); vi.mocked(globalThis.fetch) - .mockResolvedValueOnce(response(JSON.stringify({ Answer: [{ data: "93.184.216.34" }] }), { status: 200 })) - .mockResolvedValueOnce(response(JSON.stringify({ Answer: [] }), { status: 200 })) + .mockResolvedValueOnce(dnsResponse(["93.184.216.34"])) + .mockResolvedValueOnce(dnsResponse([])) .mockResolvedValueOnce(response("ss://node", { status: 200 })); await expect(runTransport("https://api.dler.io/sub")).resolves.toMatchObject({ @@ -101,8 +121,15 @@ describe("local source import transport", () => { }); expect(globalThis.fetch).toHaveBeenNthCalledWith( 1, - expect.stringContaining("https://dns.google/resolve?name=api.dler.io&type=A"), - expect.objectContaining({ method: "GET" }) + "https://doh.pub/dns-query", + expect.objectContaining({ + method: "POST", + headers: { + Accept: "application/dns-message", + "Content-Type": "application/dns-message", + }, + body: expect.any(ArrayBuffer), + }) ); expect(globalThis.fetch).toHaveBeenNthCalledWith( 3, @@ -114,8 +141,8 @@ describe("local source import transport", () => { it("keeps blocking fake-ip DNS answers when DoH confirms an unsafe target", async () => { mocks.lookup.mockResolvedValueOnce([{ address: "198.18.3.6" }]); vi.mocked(globalThis.fetch) - .mockResolvedValueOnce(response(JSON.stringify({ Answer: [{ data: "10.0.0.2" }] }), { status: 200 })) - .mockResolvedValueOnce(response(JSON.stringify({ Answer: [] }), { status: 200 })); + .mockResolvedValueOnce(dnsResponse(["10.0.0.2"])) + .mockResolvedValueOnce(dnsResponse([])); await expect(runTransport("https://fake-ip-private.example/sub")).resolves.toMatchObject({ ok: false, diff --git a/local/src/lib/source-import.ts b/local/src/lib/source-import.ts index 096013c..81a6667 100644 --- a/local/src/lib/source-import.ts +++ b/local/src/lib/source-import.ts @@ -1,5 +1,4 @@ import { lookup } from "node:dns/promises"; -import { isIP } from "node:net"; import { createSubscriptionImportErrorInfo, inferSubscriptionImportErrorCategory, @@ -13,6 +12,7 @@ import { type SourceImportTransportRequest, type SourceImportTransportResult, } from "@subboost/server-core/subscription"; +import { resolveHostnameByDoh } from "@subboost/server-core/subscription/doh-resolver"; import { isPrivateOrReservedIp, normalizeResolvedIpAddresses, @@ -68,42 +68,6 @@ function normalizeHostname(hostname: string): string { return hostname.replace(/^\[|\]$/g, "").toLowerCase(); } -async function resolveHostnameByDohDirect(hostname: string): Promise { - const out = new Set(); - - for (const type of ["A", "AAAA"]) { - const url = `https://dns.google/resolve?name=${encodeURIComponent(hostname)}&type=${type}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS); - let response: Response | null = null; - try { - response = await fetch(url, { - method: "GET", - headers: { - Accept: "application/dns-json", - "User-Agent": "SubBoost Local DoH/1.0", - }, - redirect: "manual", - signal: controller.signal, - }); - } catch { - response = null; - } finally { - clearTimeout(timer); - } - if (!response?.ok) continue; - - const body = (await response.json().catch(() => null)) as { Answer?: Array<{ data?: unknown }> } | null; - if (!body || !Array.isArray(body.Answer)) continue; - for (const answer of body.Answer) { - const data = typeof answer?.data === "string" ? answer.data.trim() : ""; - if (data && isIP(data)) out.add(data); - } - } - - return Array.from(out); -} - async function validatePublicFetchTarget(url: string): Promise { let parsed: URL; try { @@ -132,7 +96,10 @@ async function validatePublicFetchTarget(url: string): Promise []); const addresses = normalizeResolvedIpAddresses(records.map((record) => record.address)); const finalAddresses = shouldRecheckFakeIpDnsAnswers(addresses) - ? selectDnsAddressesAfterFakeIpRecheck(addresses, await resolveHostnameByDohDirect(hostname)) + ? selectDnsAddressesAfterFakeIpRecheck( + addresses, + await resolveHostnameByDoh(hostname, { timeoutMs: DOH_TIMEOUT_MS }) + ) : addresses; const addressesToCheck = finalAddresses.length > 0 ? finalAddresses : addresses; if (addressesToCheck.some((address) => isPrivateOrReservedIp(address))) { diff --git a/packages/server-core/src/subscription/doh-resolver.test.ts b/packages/server-core/src/subscription/doh-resolver.test.ts new file mode 100644 index 0000000..6fac06e --- /dev/null +++ b/packages/server-core/src/subscription/doh-resolver.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveHostnameByDoh, type DohTransportResponse } from "./doh-resolver"; + +function dnsResponse(records: Array<{ type: "A" | "AAAA"; address: string }>): Uint8Array { + const question = new Uint8Array([ + 7, 101, 120, 97, 109, 112, 108, 101, 4, 116, 101, 115, 116, 0, 0, 1, 0, 1, + ]); + const answers: number[] = []; + for (const record of records) { + answers.push(0xc0, 0x0c); + if (record.type === "A") { + answers.push(0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04); + answers.push(...record.address.split(".").map((part) => Number(part))); + } else { + answers.push(0x00, 0x1c, 0x00, 0x01, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x10); + const parts = record.address.split(":").map((part) => Number.parseInt(part || "0", 16)); + for (const part of parts) { + answers.push((part >> 8) & 0xff, part & 0xff); + } + } + } + + const out = new Uint8Array(12 + question.length + answers.length); + const view = new DataView(out.buffer); + view.setUint16(0, 0); + view.setUint16(2, 0x8180); + view.setUint16(4, 1); + view.setUint16(6, records.length); + out.set(question, 12); + out.set(answers, 12 + question.length); + return out; +} + +function ok(body: Uint8Array): DohTransportResponse { + return { statusCode: 200, body }; +} + +describe("RFC8484 DoH resolver", () => { + it("posts DNS wire messages and returns A/AAAA addresses from the first usable endpoint", async () => { + const transport = vi + .fn() + .mockResolvedValueOnce(ok(dnsResponse([{ type: "A", address: "93.184.216.34" }]))) + .mockResolvedValueOnce(ok(dnsResponse([{ type: "AAAA", address: "2606:4700:4700:0:0:0:0:1111" }]))); + + await expect( + resolveHostnameByDoh(" Example.Test. ", { + endpoints: ["https://doh.example/dns-query"], + timeoutMs: 2500, + transport, + }) + ).resolves.toEqual(["93.184.216.34", "2606:4700:4700:0:0:0:0:1111"]); + + expect(transport).toHaveBeenCalledTimes(2); + expect(transport).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + endpoint: "https://doh.example/dns-query", + headers: { + Accept: "application/dns-message", + "Content-Type": "application/dns-message", + }, + body: expect.any(Uint8Array), + timeoutMs: 2500, + }) + ); + }); + + it("falls through empty or failed endpoints and returns an empty list fail-closed", async () => { + const transport = vi + .fn() + .mockResolvedValueOnce(ok(dnsResponse([]))) + .mockResolvedValueOnce({ statusCode: 503, body: new Uint8Array() }) + .mockResolvedValueOnce(ok(dnsResponse([{ type: "A", address: "1.1.1.1" }]))) + .mockResolvedValueOnce(ok(dnsResponse([]))); + + await expect( + resolveHostnameByDoh("example.test", { + endpoints: ["https://empty.example/dns-query", "https://ok.example/dns-query"], + transport, + }) + ).resolves.toEqual(["1.1.1.1"]); + + await expect( + resolveHostnameByDoh("example.test", { + endpoints: ["https://empty.example/dns-query"], + transport: vi.fn().mockResolvedValue(ok(dnsResponse([]))), + }) + ).resolves.toEqual([]); + }); +}); diff --git a/packages/server-core/src/subscription/doh-resolver.ts b/packages/server-core/src/subscription/doh-resolver.ts new file mode 100644 index 0000000..d6ffc35 --- /dev/null +++ b/packages/server-core/src/subscription/doh-resolver.ts @@ -0,0 +1,205 @@ +import { isIP } from "node:net"; + +const DEFAULT_DOH_ENDPOINTS = [ + "https://doh.pub/dns-query", + "https://dns.alidns.com/dns-query", + "https://doh.360.cn/dns-query", + "https://dns.google/dns-query", +] as const; + +const DNS_TYPE_A = 1; +const DNS_TYPE_AAAA = 28; +const DNS_CLASS_IN = 1; +const DEFAULT_TIMEOUT_MS = 4000; + +export type DohQueryType = "A" | "AAAA"; + +export type DohTransportRequest = { + endpoint: string; + headers: Record; + body: Uint8Array; + timeoutMs: number; +}; + +export type DohTransportResponse = { + statusCode: number; + body: Uint8Array; +}; + +export type DohTransport = (request: DohTransportRequest) => Promise; + +export type ResolveHostnameByDohOptions = { + endpoints?: readonly string[]; + timeoutMs?: number; + transport?: DohTransport; +}; + +function normalizeHostname(hostname: string): string { + return hostname.trim().replace(/\.$/, "").toLowerCase(); +} + +function queryTypeToCode(type: DohQueryType): number { + return type === "A" ? DNS_TYPE_A : DNS_TYPE_AAAA; +} + +function encodeDnsQuestion(hostname: string, type: DohQueryType): Uint8Array { + const labels = normalizeHostname(hostname).split(".").filter(Boolean); + const questionLength = labels.reduce((sum, label) => sum + 1 + Buffer.byteLength(label), 1); + const out = new Uint8Array(12 + questionLength + 4); + const view = new DataView(out.buffer); + + view.setUint16(0, 0); + view.setUint16(2, 0x0100); + view.setUint16(4, 1); + + let offset = 12; + for (const label of labels) { + const bytes = new TextEncoder().encode(label); + out[offset] = bytes.length; + offset += 1; + out.set(bytes, offset); + offset += bytes.length; + } + out[offset] = 0; + offset += 1; + view.setUint16(offset, queryTypeToCode(type)); + offset += 2; + view.setUint16(offset, DNS_CLASS_IN); + + return out; +} + +function readNameEnd(message: Uint8Array, offset: number): number { + let current = offset; + let jumps = 0; + + while (current < message.length) { + const length = message[current]; + if ((length & 0xc0) === 0xc0) { + if (current + 1 >= message.length) return message.length; + return current + 2; + } + if (length === 0) return current + 1; + if ((length & 0xc0) !== 0) return message.length; + current += 1 + length; + jumps += 1; + if (jumps > 128) return message.length; + } + + return message.length; +} + +function parseIpv6(bytes: Uint8Array): string { + const groups: string[] = []; + for (let offset = 0; offset < bytes.length; offset += 2) { + groups.push(((bytes[offset] << 8) | bytes[offset + 1]).toString(16)); + } + return groups.join(":"); +} + +function parseDnsResponseAddresses(message: Uint8Array): string[] { + if (message.length < 12) return []; + const view = new DataView(message.buffer, message.byteOffset, message.byteLength); + const questionCount = view.getUint16(4); + const answerCount = view.getUint16(6); + const out = new Set(); + + let offset = 12; + for (let index = 0; index < questionCount; index += 1) { + offset = readNameEnd(message, offset) + 4; + if (offset > message.length) return []; + } + + for (let index = 0; index < answerCount; index += 1) { + offset = readNameEnd(message, offset); + if (offset + 10 > message.length) return Array.from(out); + + const type = view.getUint16(offset); + const klass = view.getUint16(offset + 2); + const dataLength = view.getUint16(offset + 8); + offset += 10; + if (offset + dataLength > message.length) return Array.from(out); + + const data = message.slice(offset, offset + dataLength); + if (klass === DNS_CLASS_IN && type === DNS_TYPE_A && dataLength === 4) { + out.add(Array.from(data).join(".")); + } + if (klass === DNS_CLASS_IN && type === DNS_TYPE_AAAA && dataLength === 16) { + const ip = parseIpv6(data); + if (isIP(ip)) out.add(ip); + } + offset += dataLength; + } + + return Array.from(out); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; +} + +async function defaultDohTransport(request: DohTransportRequest): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), request.timeoutMs); + try { + const response = await fetch(request.endpoint, { + method: "POST", + headers: request.headers, + body: toArrayBuffer(request.body), + redirect: "manual", + signal: controller.signal, + }); + return { + statusCode: response.status, + body: new Uint8Array(await response.arrayBuffer()), + }; + } finally { + clearTimeout(timer); + } +} + +async function resolveEndpointAddresses( + endpoint: string, + hostname: string, + timeoutMs: number, + transport: DohTransport +): Promise { + const out = new Set(); + for (const type of ["A", "AAAA"] as const) { + const response = await transport({ + endpoint, + headers: { + Accept: "application/dns-message", + "Content-Type": "application/dns-message", + }, + body: encodeDnsQuestion(hostname, type), + timeoutMs, + }); + if (response.statusCode < 200 || response.statusCode >= 300) continue; + for (const address of parseDnsResponseAddresses(response.body)) { + if (isIP(address)) out.add(address); + } + } + return Array.from(out); +} + +export async function resolveHostnameByDoh( + hostname: string, + opts: ResolveHostnameByDohOptions = {} +): Promise { + const normalizedHostname = normalizeHostname(hostname); + if (!normalizedHostname) return []; + + const endpoints = opts.endpoints?.length ? opts.endpoints : DEFAULT_DOH_ENDPOINTS; + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const transport = opts.transport ?? defaultDohTransport; + + for (const endpoint of endpoints) { + const addresses = await resolveEndpointAddresses(endpoint, normalizedHostname, timeoutMs, transport).catch(() => []); + if (addresses.length > 0) return addresses; + } + + return []; +} diff --git a/packages/server-core/src/subscription/index.ts b/packages/server-core/src/subscription/index.ts index a11c64b..50a56c8 100644 --- a/packages/server-core/src/subscription/index.ts +++ b/packages/server-core/src/subscription/index.ts @@ -4,6 +4,7 @@ export * from "./auto-update-schedule"; export * from "./auto-update-state"; export * from "./cron-update-summary"; export * from "./crud"; +export * from "./doh-resolver"; export * from "./fetch-profile-heuristics"; export * from "./manual-refresh-response"; export * from "./refresh-cache-result"; From fccc80fb8078711ffafdfb423d4c8610fe1146c2 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 22 Jun 2026 06:12:13 +0800 Subject: [PATCH 10/29] Add generated YAML semantic diff helpers --- packages/core/package.json | 1 + .../core/src/generator/yaml-semantics.test.ts | 119 +++++++++++++ packages/core/src/generator/yaml-semantics.ts | 163 ++++++++++++++++++ 3 files changed, 283 insertions(+) create mode 100644 packages/core/src/generator/yaml-semantics.test.ts create mode 100644 packages/core/src/generator/yaml-semantics.ts diff --git a/packages/core/package.json b/packages/core/package.json index 7586389..6bb15af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,7 @@ "./filtered-proxy-groups": "./src/filtered-proxy-groups.ts", "./generator": "./src/generator/index.ts", "./generator/*": "./src/generator/*", + "./generator/yaml-semantics": "./src/generator/yaml-semantics.ts", "./json": "./src/json.ts", "./mihomo/*": "./src/mihomo/*", "./node-name-template": "./src/node-name-template.ts", diff --git a/packages/core/src/generator/yaml-semantics.test.ts b/packages/core/src/generator/yaml-semantics.test.ts new file mode 100644 index 0000000..455d1cb --- /dev/null +++ b/packages/core/src/generator/yaml-semantics.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { + diffGeneratedYamlSemantics, + hashGeneratedYamlSemantics, + parseGeneratedYamlSemantics, +} from "./yaml-semantics"; + +describe("generated YAML semantics", () => { + it("treats YAML formatting and mapping order changes as format-only", () => { + const before = parseGeneratedYamlSemantics(` +mixed-port: 7897 +rule-providers: + google: + type: http + behavior: domain + url: https://example.com/google.mrs +rules: + - RULE-SET,google,Google +`); + const after = parseGeneratedYamlSemantics(` +rule-providers: + google: { behavior: domain, url: "https://example.com/google.mrs", type: http } +rules: ["RULE-SET,google,Google"] +mixed-port: 7897 +`); + + expect(diffGeneratedYamlSemantics(before, after)).toMatchObject({ + changed: true, + severity: "format-only", + issues: [], + }); + }); + + it("reports removed rule providers as high impact", () => { + const before = parseGeneratedYamlSemantics(` +rule-providers: + google: + type: http + behavior: domain +rules: + - RULE-SET,google,Google +`); + const after = parseGeneratedYamlSemantics(` +rules: + - MATCH,DIRECT +`); + + const diff = diffGeneratedYamlSemantics(before, after); + expect(diff.severity).toBe("high"); + expect(diff.issues.map((issue) => issue.path)).toEqual(["rule-providers", "rules"]); + }); + + it("reports RULE-SET target changes as high impact", () => { + const before = parseGeneratedYamlSemantics(` +rules: + - RULE-SET,google,Google +`); + const after = parseGeneratedYamlSemantics(` +rules: + - RULE-SET,google,DIRECT +`); + + expect(diffGeneratedYamlSemantics(before, after)).toMatchObject({ + changed: true, + severity: "high", + issues: [expect.objectContaining({ path: "rules", severity: "high" })], + }); + }); + + it("keeps custom rule set output stable when provider fields are equivalent", () => { + const before = parseGeneratedYamlSemantics(` +rule-providers: + custom-google: + type: http + behavior: domain + url: https://example.com/custom-google.mrs + path: ./rules/custom-google.mrs + interval: 86400 +rules: + - RULE-SET,custom-google,Google +`); + const after = parseGeneratedYamlSemantics(` +rules: + - RULE-SET,custom-google,Google +rule-providers: + custom-google: + interval: 86400 + path: ./rules/custom-google.mrs + url: https://example.com/custom-google.mrs + behavior: domain + type: http +`); + + expect(diffGeneratedYamlSemantics(before, after).severity).toBe("format-only"); + expect(hashGeneratedYamlSemantics(before)).toBe(hashGeneratedYamlSemantics(after)); + }); + + it("reports proxy group member order changes as high impact", () => { + const before = parseGeneratedYamlSemantics(` +proxy-groups: + - name: Select + type: select + proxies: [A, B] +`); + const after = parseGeneratedYamlSemantics(` +proxy-groups: + - name: Select + type: select + proxies: [B, A] +`); + + expect(diffGeneratedYamlSemantics(before, after)).toMatchObject({ + changed: true, + severity: "high", + issues: [expect.objectContaining({ path: "proxy-groups", severity: "high" })], + }); + }); +}); diff --git a/packages/core/src/generator/yaml-semantics.ts b/packages/core/src/generator/yaml-semantics.ts new file mode 100644 index 0000000..aaf86c3 --- /dev/null +++ b/packages/core/src/generator/yaml-semantics.ts @@ -0,0 +1,163 @@ +import yaml from "js-yaml"; + +export type GeneratedYamlSemanticSeverity = "none" | "format-only" | "low" | "high"; + +export interface GeneratedYamlSemanticIssue { + path: string; + severity: Exclude; + before?: unknown; + after?: unknown; +} + +export interface GeneratedYamlSemanticSnapshot { + version: 1; + rawFingerprint: string; + semanticFingerprint: string; + sections: Record; +} + +export interface GeneratedYamlSemanticDiff { + changed: boolean; + severity: GeneratedYamlSemanticSeverity; + issues: GeneratedYamlSemanticIssue[]; +} + +export class GeneratedYamlSemanticError extends Error { + constructor(message: string) { + super(message); + this.name = "GeneratedYamlSemanticError"; + } +} + +const HIGH_IMPACT_ROOT_KEYS = new Set([ + "proxies", + "proxy-groups", + "rule-providers", + "proxy-providers", + "rules", + "dns", + "listeners", + "tun", +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeScalar(value: unknown): unknown { + if (typeof value === "number") { + if (Number.isNaN(value)) return "NaN"; + if (value === Infinity) return "Infinity"; + if (value === -Infinity) return "-Infinity"; + } + return value; +} + +function normalizeYamlValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeYamlValue); + if (!isRecord(value)) return normalizeScalar(value); + + const out: Record = {}; + for (const key of Object.keys(value).sort()) { + const item = value[key]; + if (item !== undefined) out[key] = normalizeYamlValue(item); + } + return out; +} + +function normalizeParsedYaml(parsed: unknown): Record { + if (parsed == null) return {}; + if (!isRecord(parsed)) { + throw new GeneratedYamlSemanticError("Generated YAML must parse to a top-level object."); + } + + const out: Record = {}; + for (const key of Object.keys(parsed).sort()) { + const value = parsed[key]; + if (value !== undefined) out[key] = normalizeYamlValue(value); + } + return out; +} + +function stableStringify(value: unknown): string { + return JSON.stringify(normalizeYamlValue(value)); +} + +function fingerprint(text: string): string { + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function formatYamlParseError(error: unknown): string { + if (isRecord(error)) { + const reason = typeof error.reason === "string" + ? error.reason + : typeof error.message === "string" + ? error.message + : String(error); + const mark = isRecord(error.mark) ? error.mark : null; + const line = typeof mark?.line === "number" ? mark.line + 1 : null; + const column = typeof mark?.column === "number" ? mark.column + 1 : null; + return line && column ? `${reason} (line ${line}, column ${column})` : reason; + } + return String(error); +} + +export function parseGeneratedYamlSemantics(yamlText: string): GeneratedYamlSemanticSnapshot { + let parsed: unknown; + try { + parsed = yaml.load(yamlText || ""); + } catch (error) { + throw new GeneratedYamlSemanticError(`Generated YAML parse failed: ${formatYamlParseError(error)}`); + } + + const sections = normalizeParsedYaml(parsed); + return { + version: 1, + rawFingerprint: fingerprint(yamlText), + semanticFingerprint: hashGeneratedYamlSemantics({ version: 1, rawFingerprint: "", semanticFingerprint: "", sections }), + sections, + }; +} + +export function hashGeneratedYamlSemantics(snapshot: GeneratedYamlSemanticSnapshot): string { + return fingerprint(stableStringify(snapshot.sections)); +} + +export function diffGeneratedYamlSemantics( + before: GeneratedYamlSemanticSnapshot, + after: GeneratedYamlSemanticSnapshot +): GeneratedYamlSemanticDiff { + if (before.semanticFingerprint === after.semanticFingerprint) { + return { + changed: before.rawFingerprint !== after.rawFingerprint, + severity: before.rawFingerprint === after.rawFingerprint ? "none" : "format-only", + issues: [], + }; + } + + const issues: GeneratedYamlSemanticIssue[] = []; + const keys = new Set([...Object.keys(before.sections), ...Object.keys(after.sections)]); + for (const key of [...keys].sort()) { + const beforeValue = before.sections[key]; + const afterValue = after.sections[key]; + if (stableStringify(beforeValue) === stableStringify(afterValue)) continue; + issues.push({ + path: key, + severity: HIGH_IMPACT_ROOT_KEYS.has(key) ? "high" : "low", + before: beforeValue, + after: afterValue, + }); + } + + const severity = issues.some((issue) => issue.severity === "high") ? "high" : "low"; + return { + changed: true, + severity, + issues, + }; +} From 29c43fb0282768ccc80649047585e751762c8ecb Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:07:14 +0800 Subject: [PATCH 11/29] Fix TypeScript source package exports --- packages/core/package.json | 20 ++++++++++---------- packages/server-core/package.json | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 6bb15af..b4dc24c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,27 +6,27 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./api/*": "./src/api/*", - "./config/*": "./src/config/*", + "./api/*": "./src/api/*.ts", + "./config/*": "./src/config/*.ts", "./filtered-proxy-groups": "./src/filtered-proxy-groups.ts", "./generator": "./src/generator/index.ts", - "./generator/*": "./src/generator/*", + "./generator/*": "./src/generator/*.ts", "./generator/yaml-semantics": "./src/generator/yaml-semantics.ts", "./json": "./src/json.ts", - "./mihomo/*": "./src/mihomo/*", + "./mihomo/*": "./src/mihomo/*.ts", "./node-name-template": "./src/node-name-template.ts", "./node-identity": "./src/node-identity.ts", "./parser": "./src/parser/index.ts", - "./parser/*": "./src/parser/*", + "./parser/*": "./src/parser/*.ts", "./proxy-group-name": "./src/proxy-group-name.ts", - "./rules/*": "./src/rules/*", + "./rules/*": "./src/rules/*.ts", "./rules-database": "./src/rules-database.ts", - "./subscription/*": "./src/subscription/*", + "./subscription/*": "./src/subscription/*.ts", "./templates": "./src/templates/index.ts", - "./templates/*": "./src/templates/*", - "./time/*": "./src/time/*", + "./templates/*": "./src/templates/*.ts", + "./time/*": "./src/time/*.ts", "./types": "./src/types/index.ts", - "./types/*": "./src/types/*" + "./types/*": "./src/types/*.ts" }, "dependencies": { "js-yaml": "^4.2.0", diff --git a/packages/server-core/package.json b/packages/server-core/package.json index e528ab2..cd3f464 100644 --- a/packages/server-core/package.json +++ b/packages/server-core/package.json @@ -9,14 +9,14 @@ "./app-version": "./src/app-version.ts", "./cron-auth": "./src/cron-auth.ts", "./crypto": "./src/crypto/index.ts", - "./crypto/*": "./src/crypto/*", + "./crypto/*": "./src/crypto/*.ts", "./http": "./src/http.ts", "./rules": "./src/rules/index.ts", - "./rules/*": "./src/rules/*", + "./rules/*": "./src/rules/*.ts", "./templates": "./src/templates/index.ts", - "./templates/*": "./src/templates/*", + "./templates/*": "./src/templates/*.ts", "./subscription": "./src/subscription/index.ts", - "./subscription/*": "./src/subscription/*" + "./subscription/*": "./src/subscription/*.ts" }, "dependencies": { "@subboost/core": "file:../core" From d01db2f66fb0021d7c19241dfb3fdd35f946d0a4 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:27:30 +0800 Subject: [PATCH 12/29] fix(core): remove deprecated global fingerprint handling --- packages/core/src/config/defaults.test.ts | 4 --- packages/core/src/config/defaults.ts | 3 -- packages/core/src/generator/index.test.ts | 13 +++----- packages/core/src/generator/index.ts | 40 +++++------------------ packages/core/src/types/config.ts | 1 - 5 files changed, 12 insertions(+), 49 deletions(-) diff --git a/packages/core/src/config/defaults.test.ts b/packages/core/src/config/defaults.test.ts index c2aa4bd..b579b07 100644 --- a/packages/core/src/config/defaults.test.ts +++ b/packages/core/src/config/defaults.test.ts @@ -33,7 +33,6 @@ describe("default config builders", () => { const patch = buildDefaultBaseConfigPatch({ mixedPort: 12345, allowLan: false, - includeGlobalClientFingerprint: true, }); expect(patch).toMatchObject({ @@ -41,7 +40,6 @@ describe("default config builders", () => { "allow-lan": false, mode: "rule", "log-level": "info", - "global-client-fingerprint": "chrome", profile: { "store-selected": true, "store-fake-ip": false, @@ -59,7 +57,6 @@ describe("default config builders", () => { "mixed-port": DEFAULT_SUBBOOST_CONFIG.mixedPort, "allow-lan": DEFAULT_SUBBOOST_CONFIG.allowLan, }); - expect(defaultPatch).not.toHaveProperty("global-client-fingerprint"); }); it("builds the full SubBoost template config with empty user customization fields", () => { @@ -89,7 +86,6 @@ describe("default config builders", () => { it("keeps the default YAML example aligned with important base defaults", () => { expect(DEFAULT_BASE_CONFIG_YAML).toContain(`mixed-port: ${DEFAULT_SUBBOOST_CONFIG.mixedPort}`); expect(DEFAULT_BASE_CONFIG_YAML).toContain(`allow-lan: ${DEFAULT_SUBBOOST_CONFIG.allowLan}`); - expect(DEFAULT_BASE_CONFIG_YAML).toContain("global-client-fingerprint: chrome"); expect(DEFAULT_BASE_CONFIG_YAML).toContain("sniffer:"); expect(DEFAULT_BASE_CONFIG_YAML).toContain("QUIC: {ports: [443, 8443]}"); }); diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index 20bab73..19ff6be 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -41,7 +41,6 @@ export function buildDefaultUserConfig(template: TemplateType): UserConfig { export function buildDefaultBaseConfigPatch(options: { mixedPort?: number; allowLan?: boolean; - includeGlobalClientFingerprint?: boolean; } = {}): ClashConfig { return { "mixed-port": options.mixedPort ?? DEFAULT_SUBBOOST_CONFIG.mixedPort, @@ -51,7 +50,6 @@ export function buildDefaultBaseConfigPatch(options: { "unified-delay": true, "tcp-concurrent": true, "find-process-mode": "strict", - ...(options.includeGlobalClientFingerprint ? { "global-client-fingerprint": "chrome" } : {}), dns: DEFAULT_DNS_CONFIG, profile: { "store-selected": true, @@ -113,7 +111,6 @@ log-level: info unified-delay: true tcp-concurrent: true find-process-mode: strict -global-client-fingerprint: chrome # DNS 配置 dns: diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index 55f30c9..e3f0326 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -165,7 +165,7 @@ describe("generateClashConfig", () => { ).toThrow("基础和 DNS 配置 YAML 解析失败"); }); - it("applies generation-time safeguards for listeners, dialer groups, and global fingerprints", () => { + it("applies generation-time safeguards for listeners and dialer groups", () => { const config = generateClashConfig({ nodes: [ ssNode({ name: "Relay", server: "relay.example.com" }), @@ -208,7 +208,6 @@ describe("generateClashConfig", () => { ], userConfig: { dnsYaml: [ - "global-client-fingerprint: firefox", "listeners:", " - name: base", " type: mixed", @@ -224,7 +223,6 @@ describe("generateClashConfig", () => { expect(config.proxies?.map((proxy) => proxy.name)).toEqual(["Relay", "Target"]); expect(config.proxies?.find((proxy) => proxy.name === "Target")).toMatchObject({ - "client-fingerprint": "firefox", "reality-opts": { "short-id": "7250", }, @@ -241,7 +239,7 @@ describe("generateClashConfig", () => { expect(config["proxy-groups"]?.find((group) => group.name === "Broken Chain")).toBeUndefined(); }); - it("applies global fingerprints only to compatible nodes and removes invalid dialer-proxy references", () => { + it("preserves explicit fingerprints and removes invalid dialer-proxy references", () => { const config = generateClashConfig({ nodes: [ { @@ -291,9 +289,6 @@ describe("generateClashConfig", () => { targetNodes: ["Plain VMess"], }, ], - userConfig: { - dnsYaml: "global-client-fingerprint: chrome", - }, }); const plain = config.proxies?.find((proxy) => proxy.name === "Plain VMess"); @@ -303,8 +298,8 @@ describe("generateClashConfig", () => { expect(plain).not.toHaveProperty("client-fingerprint"); expect(plain).not.toHaveProperty("dialer-proxy"); - expect(trojan).toMatchObject({ "client-fingerprint": "chrome" }); - expect(anytls).toMatchObject({ "client-fingerprint": "chrome" }); + expect(trojan).not.toHaveProperty("client-fingerprint"); + expect(anytls).not.toHaveProperty("client-fingerprint"); expect(preset).toMatchObject({ "client-fingerprint": "safari" }); expect(config["proxy-groups"]?.find((group) => group.name === "Disabled")).toBeUndefined(); }); diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 6934cc6..7b14673 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -50,9 +50,7 @@ export interface GenerateOptions { proxyGroupOrder?: string[]; } -type BaseConfig = Record & { - "global-client-fingerprint"?: unknown; -}; +type BaseConfig = Record; function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -111,7 +109,11 @@ function assertNoGeneratedSectionsInBaseConfig(baseConfig: Record): Record { - return Object.fromEntries(Object.entries(baseConfig).filter(([key, value]) => value !== undefined && !GENERATED_CONFIG_SECTION_KEYS.has(key))); + return Object.fromEntries( + Object.entries(baseConfig).filter( + ([key, value]) => value !== undefined && !GENERATED_CONFIG_SECTION_KEYS.has(key) + ) + ); } function normalizeBaseConfigPatch(baseConfig: Record): Record { @@ -209,12 +211,6 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { const baseConfigRecord = baseConfig as unknown as Record; const shouldUseDefaultBaseSections = !hasExplicitBaseConfigYaml; - // mihomo 已将 global-client-fingerprint 标记为 deprecated;这里规范化到每个 proxy 的 client-fingerprint。 - const globalClientFingerprint = - typeof baseConfigRecord["global-client-fingerprint"] === "string" && baseConfigRecord["global-client-fingerprint"].trim() - ? String(baseConfigRecord["global-client-fingerprint"]).trim() - : undefined; - // 输出前做一次轻量标准化:避免 YAML 数字样式字段被下游(Clash)当成 number 解析 // 特别是 Reality short-id(有些机场会给纯数字,如 7053 / 7250) const normalizedNodes = nodes.map((node) => { @@ -222,28 +218,8 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { const typed = node as unknown as Record; const type = typeof typed.type === "string" ? typed.type : ""; - const hasClientFingerprint = - typeof typed["client-fingerprint"] === "string" && Boolean(String(typed["client-fingerprint"]).trim()); - const supportsClientFingerprint = type === "vmess" || type === "vless" || type === "trojan" || type === "anytls"; - - const nextBase = (() => { - if (!supportsClientFingerprint) return typed; - if (hasClientFingerprint) return typed; - if (!globalClientFingerprint) return typed; - - // 根据官方文档:client-fingerprint 仅对 VMess/VLESS/Trojan/AnyTLS 生效;其中 Trojan/AnyTLS 为 TLS 协议。 - const shouldApply = - type === "trojan" || - type === "anytls" || - (typeof typed.tls === "boolean" && typed.tls) || - (type === "vless" && Boolean(typed["reality-opts"])); - - if (!shouldApply) return typed; - return { ...typed, "client-fingerprint": globalClientFingerprint }; - })(); - - if (type !== "vless") return nextBase as unknown as ParsedNode; - return normalizeMihomoVlessForGeneration(nextBase) as unknown as ParsedNode; + if (type !== "vless") return node; + return normalizeMihomoVlessForGeneration(typed) as unknown as ParsedNode; }); // Mihomo 不支持的协议不能进入最终 YAML,否则一个无效节点会拖垮整份订阅。 diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index d4befc3..a988468 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -134,7 +134,6 @@ export interface ClashConfig { "unified-delay"?: boolean; "tcp-concurrent"?: boolean; "find-process-mode"?: string; - "global-client-fingerprint"?: string; ipv6?: boolean; dns?: DNSConfig; From 8359e481dc6f432306eb50c9f11c277ec89c1710 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:54:27 +0800 Subject: [PATCH 13/29] fix: restore duplicate-origin source nodes --- .../subscription/source-node-refresh.test.ts | 58 +++++++++++++++++++ .../src/subscription/source-node-refresh.ts | 34 ++++++++++- .../src/subscription/refresh-node-snapshot.ts | 11 ++++ .../config-store/actions/node-actions.test.ts | 32 ++++++++++ .../config-store/actions/node-actions.ts | 5 +- .../store/config-store/source-actions.test.ts | 44 ++++++++++++++ .../src/store/config-store/source-actions.ts | 40 ++++++++++--- 7 files changed, 213 insertions(+), 11 deletions(-) diff --git a/packages/core/src/subscription/source-node-refresh.test.ts b/packages/core/src/subscription/source-node-refresh.test.ts index 44e2fa2..1fdbaf8 100644 --- a/packages/core/src/subscription/source-node-refresh.test.ts +++ b/packages/core/src/subscription/source-node-refresh.test.ts @@ -277,6 +277,64 @@ describe("source node refresh helpers", () => { }); }); + it("does not let one legacy deleted origin hide an entire duplicate-origin batch", () => { + const parsed = prepareSourceParsedNodes( + [ + ssNode("SOCKS-same.example.com:1080", { server: "same.example.com", port: 1080, password: "one" }), + ssNode("SOCKS-same.example.com:1080", { server: "same.example.com", port: 1080, password: "two" }), + ], + {} + ); + + const result = mergeParsedSourceNodes([], parsed, ["SOCKS-same.example.com:1080"], { + sourceId: "source-a", + }); + + expect(result.nodes.map((node) => node.name)).toEqual([ + "SOCKS-same.example.com:1080", + "SOCKS-same.example.com:1080 (2)", + ]); + expect(result.nodes).toEqual([ + expect.objectContaining({ [SOURCE_IDS_KEY]: ["source-a"], password: "one" }), + expect.objectContaining({ [SOURCE_IDS_KEY]: ["source-a"], password: "two" }), + ]); + }); + + it("still skips exact deleted nodes inside a duplicate-origin batch", () => { + const deletedNode = ssNode("SOCKS-same.example.com:1080", { + server: "same.example.com", + port: 1080, + password: "one", + [ORIGIN_NAME_KEY]: "SOCKS-same.example.com:1080", + }); + const parsed = prepareSourceParsedNodes( + [ + ssNode("SOCKS-same.example.com:1080", { server: "same.example.com", port: 1080, password: "one" }), + ssNode("SOCKS-same.example.com:1080", { server: "same.example.com", port: 1080, password: "two" }), + ], + {} + ); + + const result = mergeParsedSourceNodes([], parsed, ["SOCKS-same.example.com:1080"], { + sourceId: "source-a", + deletedNodes: [ + { + originName: "SOCKS-same.example.com:1080", + name: "SOCKS-same.example.com:1080", + node: deletedNode, + }, + ], + }); + + expect(result.nodes).toEqual([ + expect.objectContaining({ + name: "SOCKS-same.example.com:1080", + [SOURCE_IDS_KEY]: ["source-a"], + password: "two", + }), + ]); + }); + it("smart-matches existing manual nodes by content and preserves source id order", () => { const state = [ ssNode("Manual Existing", { diff --git a/packages/core/src/subscription/source-node-refresh.ts b/packages/core/src/subscription/source-node-refresh.ts index 781511b..a977053 100644 --- a/packages/core/src/subscription/source-node-refresh.ts +++ b/packages/core/src/subscription/source-node-refresh.ts @@ -20,6 +20,7 @@ export type SourceRefreshDescriptor = { lastNameTemplate?: string; treatAsNewSource?: boolean; smartNodeMatchingEnabled?: boolean; + deletedNodes?: DeletedNodeDescriptor[]; }; export type MergeSourceNodesResult = { @@ -27,6 +28,12 @@ export type MergeSourceNodesResult = { renameMap: Map; }; +export type DeletedNodeDescriptor = { + originName?: unknown; + name?: unknown; + node?: ParsedNode; +}; + function normalizeOptionalString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); @@ -106,10 +113,33 @@ export function mergeParsedSourceNodes( .map((name) => name.trim()) .filter(Boolean) ); + const deletedNodeKeys = new Set(); + for (const item of Array.isArray(descriptor.deletedNodes) ? descriptor.deletedNodes : []) { + if (!item?.node) continue; + const node = normalizeNodeOriginName(item.node); + const origin = normalizeOptionalString(item.originName) ?? (getNodeOriginName(node).trim() || node.name); + if (!origin) continue; + deletedNodeKeys.add(buildScopedNodeIdentityKey(origin, node)); + } + + const freshOriginCounts = new Map(); + for (const rawNode of parsedNodes) { + const node = normalizeNodeOriginName(rawNode); + const origin = originOf(node); + if (!origin) continue; + freshOriginCounts.set(origin, (freshOriginCounts.get(origin) ?? 0) + 1); + } - const isDeleted = (originNameRaw: string, displayNameRaw?: string): boolean => { + const isDeleted = (node: ParsedNode, originNameRaw: string, displayNameRaw?: string): boolean => { const originName = (originNameRaw || "").trim(); if (!originName) return false; + if (deletedNodeKeys.has(buildScopedNodeIdentityKey(originName, node))) return true; + + // Legacy deletedNodeNames are only origin/display-name markers. When a source + // emits many distinct nodes with the same default origin name, a single + // coarse marker must not hide the entire batch. + if ((freshOriginCounts.get(originName) ?? 0) > 1) return false; + if (deleted.has(originName)) return true; const displayName = typeof displayNameRaw === "string" ? displayNameRaw.trim() : ""; @@ -136,7 +166,7 @@ export function mergeParsedSourceNodes( const node = normalizeNodeOriginName(rawNode); const origin = originOf(node); if (!origin) continue; - if (isDeleted(origin, node.name)) continue; + if (isDeleted(node, origin, node.name)) continue; const key = keyOf(node); if (freshSeenKeys.has(key)) continue; diff --git a/packages/server-core/src/subscription/refresh-node-snapshot.ts b/packages/server-core/src/subscription/refresh-node-snapshot.ts index 4166db0..ebb9e77 100644 --- a/packages/server-core/src/subscription/refresh-node-snapshot.ts +++ b/packages/server-core/src/subscription/refresh-node-snapshot.ts @@ -6,6 +6,7 @@ import { } from "@subboost/core/subscription/node-source-state"; import { detachSourceNodesFromState, + type DeletedNodeDescriptor, mergeParsedSourceNodes, prepareSourceParsedNodes, } from "@subboost/core/subscription/source-node-refresh"; @@ -82,6 +83,13 @@ function getDeletedNodeNames(config: Record): string[] { .filter(Boolean); } +function getDeletedNodes(config: Record): DeletedNodeDescriptor[] { + if (!Array.isArray(config.deletedNodes)) return []; + return (config.deletedNodes as unknown[]).filter( + (item): item is DeletedNodeDescriptor => Boolean(item) && typeof item === "object" && !Array.isArray(item) + ); +} + export function resolveSmartNodeMatchingEnabled(config: Record): boolean { return config.smartNodeMatchingEnabled !== false; } @@ -112,6 +120,7 @@ export async function refreshNodeSnapshot( let refreshedSavedSources = savedSources.map((source) => ({ ...source })); const validSourceIds = new Set(savedSources.map((source) => source.id)); const deletedNodeNames = getDeletedNodeNames(options.config); + const deletedNodes = getDeletedNodes(options.config); const smartNodeMatchingEnabled = resolveSmartNodeMatchingEnabled(options.config); let currentNodes = options.storedNodes @@ -267,6 +276,7 @@ export async function refreshNodeSnapshot( source.lastParsedContent.trim() !== source.content.trim() ), smartNodeMatchingEnabled, + deletedNodes, }); currentNodes = merged.nodes; @@ -300,6 +310,7 @@ export async function refreshNodeSnapshot( lastNameTemplate: source.lastParsedNameTemplate, treatAsNewSource: false, smartNodeMatchingEnabled, + deletedNodes, }); currentNodes = merged.nodes; diff --git a/packages/ui/src/store/config-store/actions/node-actions.test.ts b/packages/ui/src/store/config-store/actions/node-actions.test.ts index 08c8a40..622ef57 100644 --- a/packages/ui/src/store/config-store/actions/node-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/node-actions.test.ts @@ -87,6 +87,38 @@ describe("createNodeActions", () => { }); }); + it("keeps separate deleted-node records for duplicate origin names", () => { + const { actions, getState } = createHarness({ + nodes: [ + node("SOCKS-same.example.com:1080", { + _originName: "SOCKS-same.example.com:1080", + password: "one", + }), + node("SOCKS-same.example.com:1080 (2)", { + _originName: "SOCKS-same.example.com:1080", + password: "two", + }), + ], + }); + + actions.removeNode("SOCKS-same.example.com:1080"); + actions.removeNode("SOCKS-same.example.com:1080 (2)"); + + expect(getState().deletedNodeNames).toEqual(["SOCKS-same.example.com:1080"]); + expect(getState().deletedNodes).toEqual([ + expect.objectContaining({ + originName: "SOCKS-same.example.com:1080", + name: "SOCKS-same.example.com:1080", + node: expect.objectContaining({ password: "one" }), + }), + expect.objectContaining({ + originName: "SOCKS-same.example.com:1080", + name: "SOCKS-same.example.com:1080 (2)", + node: expect.objectContaining({ password: "two" }), + }), + ]); + }); + it("handles missing remove targets and restore metadata without cached nodes", () => { const { actions, getState } = createHarness({ nodes: [node("Existing")], diff --git a/packages/ui/src/store/config-store/actions/node-actions.ts b/packages/ui/src/store/config-store/actions/node-actions.ts index 177a60e..af80914 100644 --- a/packages/ui/src/store/config-store/actions/node-actions.ts +++ b/packages/ui/src/store/config-store/actions/node-actions.ts @@ -64,7 +64,10 @@ export function createNodeActions( } const deletedNodes = [ ...state.deletedNodes.filter( - (n) => n && typeof n.originName === "string" && n.originName !== originName + (n) => + n && + typeof n.originName === "string" && + (n.originName !== originName || n.name !== displayName) ), { originName, diff --git a/packages/ui/src/store/config-store/source-actions.test.ts b/packages/ui/src/store/config-store/source-actions.test.ts index 1244a11..63d4c91 100644 --- a/packages/ui/src/store/config-store/source-actions.test.ts +++ b/packages/ui/src/store/config-store/source-actions.test.ts @@ -257,6 +257,50 @@ describe("createSourceActions", () => { }); }); + it("reimports duplicate-origin node sources even when a legacy deleted name exists", async () => { + mocks.parseSubscription.mockReturnValueOnce( + parseResult([ + node("SOCKS-same.example.com:1080", { + server: "same.example.com", + port: 1080, + password: "one", + }), + node("SOCKS-same.example.com:1080", { + server: "same.example.com", + port: 1080, + password: "two", + }), + ]) + ); + const { actions, getState } = createHarness({ + sources: [source({ id: "s1", type: "nodes", content: "socks5://same.example.com:1080:u:p" })], + deletedNodeNames: ["SOCKS-same.example.com:1080"], + deletedNodes: [], + }); + + await actions.parseSingleSource("s1"); + + expect(getState().nodes).toEqual([ + expect.objectContaining({ + name: "SOCKS-same.example.com:1080", + _originName: "SOCKS-same.example.com:1080", + _sourceIds: ["s1"], + password: "one", + }), + expect.objectContaining({ + name: "SOCKS-same.example.com:1080 (2)", + _originName: "SOCKS-same.example.com:1080", + _sourceIds: ["s1"], + password: "two", + }), + ]); + expect(getState().sources[0]).toMatchObject({ + parsing: false, + parsed: true, + nodeCount: 2, + }); + }); + it("parses fetched URL content when no prefetched parse result exists", async () => { mocks.fetchUrlContentInBrowser.mockResolvedValueOnce({ content: "ss://remote", diff --git a/packages/ui/src/store/config-store/source-actions.ts b/packages/ui/src/store/config-store/source-actions.ts index 8882e70..f58464b 100644 --- a/packages/ui/src/store/config-store/source-actions.ts +++ b/packages/ui/src/store/config-store/source-actions.ts @@ -288,6 +288,7 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate lastTag, lastNameTemplate, treatAsNewSource, + deletedNodes: state.deletedNodes, }); const nextNodes = merged.nodes; @@ -575,19 +576,42 @@ export function createSourceActions(set: SetState, get: GetState, setAndGenerate setAndGenerateConfig((state) => { const deleted = new Set(state.deletedNodeNames); - const normalized = uniqueNamedNodes - .map((node) => { + const normalizedCandidates = uniqueNamedNodes.map((node) => { + const record = node as unknown as Record; + const origin = + typeof record["_originName"] === "string" && record["_originName"].trim() + ? String(record["_originName"]) + : node.name; + return origin === record["_originName"] + ? node + : ({ ...record, _originName: origin } as unknown as ParsedNode); + }); + const originCounts = new Map(); + for (const node of normalizedCandidates) { + const origin = String((node as unknown as Record)["_originName"] ?? node.name); + originCounts.set(origin, (originCounts.get(origin) ?? 0) + 1); + } + const deletedNodeKeys = new Set(); + for (const item of state.deletedNodes) { + const deletedNode = item?.node; + if (!deletedNode) continue; + const origin = + typeof item.originName === "string" && item.originName.trim() + ? item.originName.trim() + : String((deletedNode as unknown as Record)["_originName"] ?? deletedNode.name).trim(); + if (!origin) continue; + deletedNodeKeys.add(buildScopedNodeIdentityKey(origin, deletedNode)); + } + + const normalized = normalizedCandidates + .filter((node) => { const record = node as unknown as Record; const origin = typeof record["_originName"] === "string" && record["_originName"].trim() ? String(record["_originName"]) : node.name; - return origin === record["_originName"] - ? node - : ({ ...record, _originName: origin } as unknown as ParsedNode); - }) - .filter((node) => { - const origin = String((node as unknown as Record)["_originName"] ?? node.name); + if (deletedNodeKeys.has(buildScopedNodeIdentityKey(origin, node))) return false; + if ((originCounts.get(origin) ?? 0) > 1) return true; return !deleted.has(origin); }); From 0517ec50a0d44d50da0ef36c0ba8723683078a41 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:20:40 +0800 Subject: [PATCH 14/29] feat: unify proxy group advanced editing --- README-CN.md | 2 +- packages/core/package.json | 2 +- packages/core/src/config/defaults.test.ts | 2 +- packages/core/src/config/defaults.ts | 2 +- packages/core/src/core-contracts.test.ts | 16 + .../core/src/filtered-proxy-groups.test.ts | 80 --- packages/core/src/filtered-proxy-groups.ts | 109 ---- packages/core/src/generator/chain.test.ts | 68 +- packages/core/src/generator/chain.ts | 38 +- packages/core/src/generator/index.test.ts | 28 +- packages/core/src/generator/index.ts | 31 +- .../core/src/generator/proxy-group-modules.ts | 5 +- .../core/src/generator/proxy-group-type.ts | 77 +++ .../core/src/generator/proxy-groups.test.ts | 133 ++-- packages/core/src/generator/proxy-groups.ts | 309 +++++---- packages/core/src/generator/rules.ts | 79 ++- .../src/migrations/filtered-proxy-groups.ts | 176 +++++ packages/core/src/proxy-group-advanced.ts | 365 +++++++++++ packages/core/src/proxy-group-targets.ts | 67 ++ .../src/rules/custom-routing-rule-sets.ts | 38 +- .../src/rules/custom-rule-batch-import.ts | 16 +- packages/core/src/rules/custom-rule-utils.ts | 7 +- packages/core/src/rules/rule-model.ts | 11 +- .../src/subscription/config-utils.test.ts | 44 +- .../core/src/subscription/config-utils.ts | 110 +--- .../templates/config-template.fields.test.ts | 20 +- .../templates/config-template.groups.test.ts | 95 +-- .../templates/config-template.test-helpers.ts | 2 +- .../src/templates/config-template.test.ts | 17 +- .../core/src/templates/config-template.ts | 159 ++--- packages/core/src/types/config.ts | 65 +- .../core/src/types/filtered-proxy-group.ts | 33 - packages/core/src/types/template-config.ts | 17 +- .../converter/advanced-mode/root.test.ts | 2 - .../product/converter/advanced-mode/root.tsx | 9 +- .../dialer-proxy-groups-section.test.ts | 53 +- .../sections/dialer-proxy-groups-section.tsx | 70 +- .../filtered-proxy-groups-section.test.ts | 428 ------------ .../filtered-proxy-groups-section.tsx | 608 ------------------ .../sections/input-source-editor-dialog.tsx | 2 +- .../sections/proxy-group-advanced-panel.tsx | 494 ++++++++++++++ .../sections/proxy-group-rule-row.test.ts | 14 +- .../sections/proxy-group-rule-row.tsx | 20 +- .../sections/proxy-group-rule-targets.test.ts | 7 +- .../sections/proxy-group-rule-targets.ts | 27 +- .../proxy-groups-added-rule-sets.test.ts | 2 + .../sections/proxy-groups-added-rule-sets.tsx | 37 +- .../sections/proxy-groups-categories.test.ts | 38 +- .../sections/proxy-groups-categories.tsx | 152 ++++- .../proxy-groups-custom-groups-panel.test.ts | 52 +- .../proxy-groups-custom-groups-panel.tsx | 452 ++++++------- .../proxy-groups-custom-routing-rules.tsx | 4 +- ...proxy-groups-custom-rules-batch-dialog.tsx | 9 +- .../proxy-groups-custom-rules.test.ts | 8 - .../sections/proxy-groups-custom-rules.tsx | 47 +- .../sections/proxy-groups-module-card.test.ts | 4 - .../sections/proxy-groups-module-card.tsx | 211 ++++-- .../proxy-groups-module-rules-panel.tsx | 26 +- .../proxy-groups-rules-library.test.ts | 40 +- .../sections/proxy-groups-rules-library.tsx | 89 ++- .../converter/quick-mode/sources-section.tsx | 2 +- .../use-editing-subscription-loader.test.ts | 15 +- .../home/use-editing-subscription-loader.ts | 19 +- .../home/use-subscription-link.test.ts | 4 +- .../product/home/use-subscription-link.tsx | 3 +- .../src/product/preview/visual-graph.test.ts | 17 +- .../ui/src/product/preview/visual-graph.tsx | 93 +-- .../visual-graph/custom-rules-preview.tsx | 7 +- .../visual-graph/proxy-groups-preview.tsx | 3 +- .../config-store/actions/custom-actions.ts | 17 +- .../actions/proxy-group-actions.test.ts | 273 +++----- .../actions/proxy-group-actions.ts | 325 ++-------- .../actions/proxy-group-rule-set-helpers.ts | 160 +++++ .../actions/template-actions.test.ts | 32 +- .../config-store/actions/template-actions.ts | 74 ++- .../store/config-store/auth-handoff.test.ts | 5 +- .../ui/src/store/config-store/auth-handoff.ts | 71 +- .../ui/src/store/config-store/definitions.ts | 16 +- .../src/store/config-store/generated-yaml.ts | 2 +- 79 files changed, 3355 insertions(+), 2911 deletions(-) delete mode 100644 packages/core/src/filtered-proxy-groups.test.ts delete mode 100644 packages/core/src/filtered-proxy-groups.ts create mode 100644 packages/core/src/generator/proxy-group-type.ts create mode 100644 packages/core/src/migrations/filtered-proxy-groups.ts create mode 100644 packages/core/src/proxy-group-advanced.ts create mode 100644 packages/core/src/proxy-group-targets.ts delete mode 100644 packages/core/src/types/filtered-proxy-group.ts delete mode 100644 packages/ui/src/product/converter/advanced-mode/sections/filtered-proxy-groups-section.test.ts delete mode 100644 packages/ui/src/product/converter/advanced-mode/sections/filtered-proxy-groups-section.tsx create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx create mode 100644 packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts diff --git a/README-CN.md b/README-CN.md index 376febd..498a810 100644 --- a/README-CN.md +++ b/README-CN.md @@ -19,7 +19,7 @@ - **订阅转换**:支持订阅链接、YAML 文件和节点链接等多种格式导入。 - **节点管理**:支持批量对节点重命名、删除或配置监听端口。 -- **节点筛选**:可按导入源、地区和自定义规则,构建只有部分节点的 `筛选代理组`。 +- **节点筛选**:可在分流组高级模式中按导入源、地区和自定义规则筛选节点。 - **链式代理**:一键可视化配置链式代理和 `中转代理组`。 - **精确分流**:内置 30 多个常用代理组和 2000 多条远程规则集供启用。 - **规则管理**:可修改规则顺序,供高级用户深度自定义。 diff --git a/packages/core/package.json b/packages/core/package.json index b4dc24c..20fc999 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,12 +8,12 @@ ".": "./src/index.ts", "./api/*": "./src/api/*.ts", "./config/*": "./src/config/*.ts", - "./filtered-proxy-groups": "./src/filtered-proxy-groups.ts", "./generator": "./src/generator/index.ts", "./generator/*": "./src/generator/*.ts", "./generator/yaml-semantics": "./src/generator/yaml-semantics.ts", "./json": "./src/json.ts", "./mihomo/*": "./src/mihomo/*.ts", + "./migrations/*": "./src/migrations/*.ts", "./node-name-template": "./src/node-name-template.ts", "./node-identity": "./src/node-identity.ts", "./parser": "./src/parser/index.ts", diff --git a/packages/core/src/config/defaults.test.ts b/packages/core/src/config/defaults.test.ts index b579b07..4e83a7b 100644 --- a/packages/core/src/config/defaults.test.ts +++ b/packages/core/src/config/defaults.test.ts @@ -67,7 +67,7 @@ describe("default config builders", () => { template: "full", hiddenProxyGroups: [], customProxyGroups: [], - filteredProxyGroups: [], + proxyGroupAdvanced: {}, customRuleSets: [], builtinRuleEdits: {}, customRules: [], diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index 19ff6be..f070cea 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -85,7 +85,7 @@ export function buildDefaultSubBoostTemplateConfig(type: TemplateType): SubBoost enabledProxyGroups: template.groups, hiddenProxyGroups: [], customProxyGroups: [], - filteredProxyGroups: [], + proxyGroupAdvanced: {}, customRuleSets: [], builtinRuleEdits: {}, customRules: [], diff --git a/packages/core/src/core-contracts.test.ts b/packages/core/src/core-contracts.test.ts index e950a1b..4279613 100644 --- a/packages/core/src/core-contracts.test.ts +++ b/packages/core/src/core-contracts.test.ts @@ -169,6 +169,7 @@ describe("custom routing rule set contracts", () => { it("normalizes rule set targets, paths, and URLs", () => { expect(getRuleSetTargetValue({ kind: "module", id: "select" })).toBe("module:select"); expect(parseRuleSetTargetValue(" custom: group-a ")).toEqual({ kind: "custom", id: "group-a" }); + expect(parseRuleSetTargetValue(" filtered: fast ")).toBeNull(); expect(parseRuleSetTargetValue("module:")).toBeNull(); expect(parseRuleSetTargetValue("bad:value")).toBeNull(); expect(extractRuleSetPathFromUrl("https://example.com/geo/geosite/youtube.mrs?raw=1")).toBe( @@ -213,6 +214,14 @@ describe("custom routing rule set contracts", () => { path: "https://rules.example.com/geo/geosite/youtube.mrs?download=1", target: "Custom", }, + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + target: { kind: "custom", id: "custom" }, + noResolve: true, + }, ], }); @@ -246,6 +255,13 @@ describe("custom routing rule set contracts", () => { }, noResolve: false, }); + expect(items.filter((item) => item.id === "telegram")).toHaveLength(1); + expect(items.find((item) => item.id === "telegram")?.target).toMatchObject({ + kind: "custom", + id: "custom", + name: "Custom", + value: "custom:custom", + }); }); it("exposes stable rule provider metadata", () => { diff --git a/packages/core/src/filtered-proxy-groups.test.ts b/packages/core/src/filtered-proxy-groups.test.ts deleted file mode 100644 index 82e647a..0000000 --- a/packages/core/src/filtered-proxy-groups.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getFilteredProxyGroupNodeNames, getFilteredProxyGroupProxies, REGION_PRESETS } from "./filtered-proxy-groups"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -import type { ParsedNode } from "@subboost/core/types/node"; - -function node(name: string, sourceIds: string[] = []): ParsedNode { - return { - name, - type: "ss", - server: "proxy.example.com", - port: 8388, - cipher: "aes-128-gcm", - password: "secret", - _sourceIds: sourceIds, - } as ParsedNode; -} - -function group(patch: Partial = {}): FilteredProxyGroup { - return { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - ...patch, - }; -} - -describe("filtered proxy groups", () => { - it("filters by source, region, regex, and explicit exclusions", () => { - const nodes = [ - node("US Fast", ["airport-a"]), - node("US IPv6", ["airport-a"]), - node("Hong Kong Fast", ["airport-a"]), - node("US Other Source", ["airport-b"]), - node("Manual US"), - { ...node(" ", ["airport-a"]), name: "" }, - ]; - - expect( - getFilteredProxyGroupNodeNames( - nodes, - group({ - sourceIds: ["airport-a"], - regions: ["us"], - includeRegex: "fast", - excludeRegex: "ipv6", - excludedNodeNames: ["US Other Source"], - }) - ) - ).toEqual(["US Fast"]); - }); - - it("supports the other region and ignores invalid regular expressions", () => { - const nodes = [node("US Fast"), node("Mars Relay"), node("No Region")]; - - expect( - getFilteredProxyGroupNodeNames( - nodes, - group({ - regions: ["other"], - includeRegex: "[", - }) - ) - ).toEqual(["Mars Relay", "No Region"]); - }); - - it("returns empty names for disabled or empty groups and prepends fixed proxies for enabled groups", () => { - expect(getFilteredProxyGroupNodeNames([node("US Fast")], group({ enabled: false }))).toEqual([]); - expect(getFilteredProxyGroupNodeNames([], group())).toEqual([]); - expect(getFilteredProxyGroupProxies([node("US Fast")], group())).toEqual(["DIRECT", "REJECT", "US Fast"]); - }); - - it("keeps region presets stable for UI filters", () => { - expect(REGION_PRESETS.map((preset) => preset.id)).toContain("other"); - expect(REGION_PRESETS.find((preset) => preset.id === "us")?.keywords).toContain("United States"); - }); -}); diff --git a/packages/core/src/filtered-proxy-groups.ts b/packages/core/src/filtered-proxy-groups.ts deleted file mode 100644 index 1c45a9d..0000000 --- a/packages/core/src/filtered-proxy-groups.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { ParsedNode } from "@subboost/core/types/node"; -import type { FilteredProxyGroup, NodeRegion } from "@subboost/core/types/filtered-proxy-group"; - -const SOURCE_IDS_KEY = "_sourceIds"; - -export const REGION_PRESETS: Array<{ - id: NodeRegion; - label: string; - emoji: string; - keywords: string[]; -}> = [ - { id: "us", label: "美国", emoji: "🇺🇸", keywords: ["美国", "US", "USA", "United States", "洛杉矶", "纽约", "西雅图"] }, - { id: "hk", label: "香港", emoji: "🇭🇰", keywords: ["香港", "HK", "Hong Kong", "港"] }, - { id: "jp", label: "日本", emoji: "🇯🇵", keywords: ["日本", "JP", "Japan", "东京", "大阪"] }, - { id: "sg", label: "新加坡", emoji: "🇸🇬", keywords: ["新加坡", "SG", "Singapore", "狮城"] }, - { id: "tw", label: "台湾", emoji: "🇹🇼", keywords: ["台湾", "TW", "Taiwan", "台北"] }, - { id: "kr", label: "韩国", emoji: "🇰🇷", keywords: ["韩国", "KR", "Korea", "首尔"] }, - { id: "uk", label: "英国", emoji: "🇬🇧", keywords: ["英国", "UK", "United Kingdom", "London", "伦敦"] }, - { id: "de", label: "德国", emoji: "🇩🇪", keywords: ["德国", "DE", "Germany", "Frankfurt", "法兰克福"] }, - { id: "fr", label: "法国", emoji: "🇫🇷", keywords: ["法国", "FR", "France", "Paris", "巴黎"] }, - { id: "ca", label: "加拿大", emoji: "🇨🇦", keywords: ["加拿大", "CA", "Canada", "Toronto", "多伦多"] }, - { id: "au", label: "澳大利亚", emoji: "🇦🇺", keywords: ["澳大利亚", "AU", "Australia", "Sydney", "悉尼"] }, - { id: "other", label: "其他", emoji: "🌐", keywords: [] }, -]; - -function getNodeSourceIds(node: ParsedNode): string[] { - const record = node as unknown as Record; - const raw = record[SOURCE_IDS_KEY]; - if (!Array.isArray(raw)) return []; - return raw - .filter((s): s is string => typeof s === "string" && Boolean(s.trim())) - .map((s) => s.trim()); -} - -function compileRegex(pattern?: string): RegExp | null { - const raw = typeof pattern === "string" ? pattern.trim() : ""; - if (!raw) return null; - try { - return new RegExp(raw, "i"); - } catch { - return null; - } -} - -function matchesRegion(nodeName: string, regions: NodeRegion[]): boolean { - if (!Array.isArray(regions) || regions.length === 0) return true; - const normalized = nodeName.toLowerCase(); - - for (const region of regions) { - if (region === "other") continue; - const preset = REGION_PRESETS.find((p) => p.id === region); - if (!preset) continue; - if (preset.keywords.some((kw) => normalized.includes(kw.toLowerCase()))) return true; - } - - // "other":表示不命中任何已知地区 - if (regions.includes("other")) { - for (const preset of REGION_PRESETS) { - if (preset.id === "other") continue; - if (preset.keywords.some((kw) => normalized.includes(kw.toLowerCase()))) return false; - } - return true; - } - - return false; -} - -export function getFilteredProxyGroupNodeNames(nodes: ParsedNode[], group: FilteredProxyGroup): string[] { - if (!group || !group.enabled) return []; - if (!Array.isArray(nodes) || nodes.length === 0) return []; - - const sourceIds = Array.isArray(group.sourceIds) ? group.sourceIds.filter((s) => typeof s === "string" && s.trim()) : []; - const includeRe = compileRegex(group.includeRegex); - const excludeRe = compileRegex(group.excludeRegex); - const regions = Array.isArray(group.regions) ? group.regions : []; - const excludedNodeNames = Array.isArray(group.excludedNodeNames) - ? group.excludedNodeNames.filter((s): s is string => typeof s === "string" && Boolean(s.trim())).map((s) => s.trim()) - : []; - - const sourceIdSet = new Set(sourceIds.map((s) => s.trim())); - const excludedNameSet = new Set(excludedNodeNames); - - const out: string[] = []; - for (const node of nodes) { - const name = (node?.name || "").toString().trim(); - if (!name) continue; - - if (sourceIdSet.size > 0) { - const nodeSourceIds = getNodeSourceIds(node); - const hasAny = nodeSourceIds.some((id) => sourceIdSet.has(id)); - if (!hasAny) continue; - } - - if (!matchesRegion(name, regions as NodeRegion[])) continue; - - if (includeRe && !includeRe.test(name)) continue; - if (excludeRe && excludeRe.test(name)) continue; - if (excludedNameSet.has(name)) continue; - - out.push(name); - } - - return out; -} - -export function getFilteredProxyGroupProxies(nodes: ParsedNode[], group: FilteredProxyGroup): string[] { - const names = getFilteredProxyGroupNodeNames(nodes, group); - return ["DIRECT", "REJECT", ...names]; -} diff --git a/packages/core/src/generator/chain.test.ts b/packages/core/src/generator/chain.test.ts index 7c246b5..b0141fa 100644 --- a/packages/core/src/generator/chain.test.ts +++ b/packages/core/src/generator/chain.test.ts @@ -33,7 +33,7 @@ function dialerGroup(patch: Partial = {}): DialerProxyGroup { } describe("dialer proxy chain helpers", () => { - it("generates select and url-test dialer groups with provider use lists", () => { + it("generates typed dialer groups with shared proxy group fields", () => { expect( generateDialerProxyGroups( [ @@ -44,6 +44,37 @@ describe("dialer proxy chain helpers", () => { type: "url-test", relayNodes: ["Relay B"], }), + dialerGroup({ + id: "fallback-relay", + name: "Fallback Relay", + type: "fallback", + relayNodes: ["Relay C"], + }), + dialerGroup({ + id: "balance-relay", + name: "Balance Relay", + type: "load-balance", + strategy: "round-robin", + relayNodes: ["Relay D"], + }), + dialerGroup({ + id: "direct-relay", + name: "Direct Relay", + type: "direct-first", + relayNodes: ["DIRECT", "Relay E"], + }), + dialerGroup({ + id: "reject-relay", + name: "Reject Relay", + type: "reject-first", + relayNodes: ["Relay F"], + }), + dialerGroup({ + id: "direct-empty", + name: "Direct Empty", + type: "direct-first", + relayNodes: [], + }), dialerGroup({ id: "empty", name: "Empty", relayNodes: [] }), ], "https://probe.example.com/204", @@ -66,6 +97,41 @@ describe("dialer proxy chain helpers", () => { interval: 120, lazy: true, }, + { + name: "Fallback Relay", + type: "fallback", + proxies: ["Relay C"], + use: ["provider-a", "provider-b"], + url: "https://probe.example.com/204", + interval: 120, + }, + { + name: "Balance Relay", + type: "load-balance", + proxies: ["Relay D"], + use: ["provider-a", "provider-b"], + url: "https://probe.example.com/204", + interval: 120, + strategy: "round-robin", + }, + { + name: "Direct Relay", + type: "select", + proxies: ["DIRECT", "Relay E"], + use: ["provider-a", "provider-b"], + }, + { + name: "Reject Relay", + type: "select", + proxies: ["REJECT", "Relay F"], + use: ["provider-a", "provider-b"], + }, + { + name: "Direct Empty", + type: "select", + proxies: ["DIRECT"], + use: ["provider-a", "provider-b"], + }, ]); }); diff --git a/packages/core/src/generator/chain.ts b/packages/core/src/generator/chain.ts index 48c065e..6ddd21f 100644 --- a/packages/core/src/generator/chain.ts +++ b/packages/core/src/generator/chain.ts @@ -8,14 +8,22 @@ import type { ParsedNode } from "@subboost/core/types/node"; import type { DialerProxyGroup } from "@subboost/core/types/template-config"; +import { DEFAULT_LOAD_BALANCE_STRATEGY, type ProxyGroupGroupType } from "@subboost/core/types/config"; import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; +import { buildTypedProxyGroup, uniqueProxyNames } from "./proxy-group-type"; export interface DialerProxyGroupConfig { id: string; name: string; relayNodes: string[]; // 中转节点 targetNodes: string[]; // 使用此中转的目标节点 - type: "select" | "url-test"; + type: ProxyGroupGroupType; +} + +function resolveDialerGroupProxies(group: DialerProxyGroup): string[] { + if (group.type === "direct-first") return uniqueProxyNames(["DIRECT", ...group.relayNodes]); + if (group.type === "reject-first") return uniqueProxyNames(["REJECT", ...group.relayNodes]); + return uniqueProxyNames(group.relayNodes); } /** @@ -29,23 +37,19 @@ export function generateDialerProxyGroups( ): Array> { const providerUse = proxyProviderNames.length > 0 ? { use: proxyProviderNames } : {}; return groups - .filter((g) => g.relayNodes.length > 0) + .map((group) => ({ group, proxies: resolveDialerGroupProxies(group) })) + .filter(({ proxies }) => proxies.length > 0) .map((group) => { - const base: Record = { - name: group.name, - type: group.type, - proxies: group.relayNodes, - ...providerUse, - }; - - // url-test 类型需要额外配置 - if (group.type === "url-test") { - base.url = testUrl; - base.interval = testInterval; - base.lazy = true; - } - - return base; + return buildTypedProxyGroup({ + name: group.group.name, + groupType: group.group.type, + proxies: group.proxies, + testUrl, + testInterval, + strategy: group.group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, + extraFields: providerUse, + urlTestLazy: true, + }); }); } diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index e3f0326..2c93ecd 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -363,7 +363,7 @@ describe("generateClashConfig", () => { }); }); - it("applies persisted proxy group order across dialer, filtered, custom, and module groups", () => { + it("applies persisted proxy group order across dialer, custom, and module groups", () => { const config = generateClashConfig({ nodes: [ssNode({ name: "Relay" }), ssNode({ name: "Target", server: "target.example.com" })], dialerProxyGroups: [ @@ -375,16 +375,6 @@ describe("generateClashConfig", () => { targetNodes: ["Target"], }, ], - filteredProxyGroups: [ - { - id: "fast", - name: "Fast", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - }, - ], customProxyGroups: [ { id: "custom", @@ -393,7 +383,7 @@ describe("generateClashConfig", () => { groupType: "select", }, ], - proxyGroupOrder: ["filtered:fast", "custom:custom", "dialer:chain", "module:auto", "missing", "module:auto"], + proxyGroupOrder: ["custom:custom", "dialer:chain", "module:auto", "missing", "module:auto"], userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"], @@ -401,10 +391,10 @@ describe("generateClashConfig", () => { }); expect(config["proxy-groups"]?.slice(0, 4).map((group) => group.name)).toEqual([ - "Fast", "Custom", "Chain", "⚡ 自动选择", + "🚀 节点选择", ]); }); @@ -445,16 +435,6 @@ describe("generateClashConfig", () => { it("uses default base config when base YAML is omitted and skips malformed ordered group names", () => { const config = generateClashConfig({ nodes: [ssNode()], - filteredProxyGroups: [ - { - id: "bad-filter", - name: 123 as never, - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - }, - ], customProxyGroups: [ { id: "bad-custom", @@ -463,7 +443,7 @@ describe("generateClashConfig", () => { groupType: "select", }, ], - proxyGroupOrder: ["filtered:bad-filter", "custom:bad-custom", "module:auto"], + proxyGroupOrder: ["custom:bad-custom", "module:auto"], userConfig: { enabledGroups: ["select", "auto", "global", "final"], }, diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 7b14673..8ea12a6 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -27,11 +27,11 @@ import type { CustomProxyGroup, CustomRuleSet, ProxyGroup, + ProxyGroupAdvancedConfig, TemplateType, UserConfig, } from "@subboost/core/types/config"; import type { DialerProxyGroup } from "@subboost/core/types/template-config"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "@subboost/core/mihomo/proxy-sanitizer"; import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-targets"; @@ -44,7 +44,7 @@ export interface GenerateOptions { dialerProxyGroups?: DialerProxyGroup[]; customProxyGroups?: CustomProxyGroup[]; customRuleSets?: CustomRuleSet[]; - filteredProxyGroups?: FilteredProxyGroup[]; + proxyGroupAdvanced?: Record; builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; proxyGroupOrder?: string[]; @@ -184,7 +184,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { dialerProxyGroups = [], customProxyGroups = [], customRuleSets = [], - filteredProxyGroups = [], + proxyGroupAdvanced = {}, builtinRuleEdits, proxyGroupNameOverrides, } = options; @@ -265,18 +265,20 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { }; const nodeNameSet = new Set(uniqueNodes.map((n) => n.name)); - const filteredGroupNameSet = new Set( - filteredProxyGroups.filter((g) => g && g.enabled && typeof g.name === "string" && g.name.trim()).map((g) => g.name.trim()) - ); + const activeCustomProxyGroups = customProxyGroups.filter((g) => g && g.enabled !== false); const customGroupNameSet = new Set( - customProxyGroups.filter((g) => g && typeof g.name === "string" && g.name.trim()).map((g) => g.name.trim()) + activeCustomProxyGroups.filter((g) => g && typeof g.name === "string" && g.name.trim()).map((g) => g.name.trim()) ); const moduleGroupNameSet = new Set( PROXY_GROUP_MODULES.map((mod) => resolveProxyGroupModuleName(mod, proxyGroupNameOverrides?.[mod.id])) ); const enabledDialerProxyGroups = dialerProxyGroups.filter((g) => g && g.enabled !== false); const sanitizedDialerProxyGroups = enabledDialerProxyGroups.length > 0 - ? sanitizeDialerProxyGroups(enabledDialerProxyGroups, nodeNameSet, filteredGroupNameSet) + ? sanitizeDialerProxyGroups( + enabledDialerProxyGroups, + nodeNameSet, + new Set([...moduleGroupNameSet, ...customGroupNameSet]) + ) : []; // 应用 dialer-proxy 到目标节点(基于最终输出的唯一节点名) @@ -288,7 +290,6 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { ...nodeNameSet, ...proxyProviderNames, ...moduleGroupNameSet, - ...filteredGroupNameSet, ...customGroupNameSet, ...sanitizedDialerProxyGroups.map((g) => g.name.trim()).filter(Boolean), ]); @@ -337,7 +338,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { testInterval: config.testInterval, customProxyGroups, customRuleSets, - filteredProxyGroups, + proxyGroupAdvanced, builtinRuleEdits, cnIpNoResolve: config.cnIpNoResolve, experimentalCnUseCnRuleSet: config.experimentalCnUseCnRuleSet, @@ -396,19 +397,12 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { } const customNameToKey = new Map(); - for (const g of customProxyGroups) { + for (const g of activeCustomProxyGroups) { const name = typeof g.name === "string" ? g.name.trim() : ""; if (!name) continue; customNameToKey.set(name, `custom:${g.id}`); } - const filteredNameToKey = new Map(); - for (const g of filteredProxyGroups) { - const name = typeof g.name === "string" ? g.name.trim() : ""; - if (!name) continue; - filteredNameToKey.set(name, `filtered:${g.id}`); - } - const dialerNameToKey = new Map(); for (const g of sanitizedDialerProxyGroups) { const name = typeof g.name === "string" ? g.name.trim() : ""; @@ -419,7 +413,6 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { const getKeyByName = (name: string) => { return ( dialerNameToKey.get(name) || - filteredNameToKey.get(name) || customNameToKey.get(name) || moduleNameToKey.get(name) || `name:${name}` diff --git a/packages/core/src/generator/proxy-group-modules.ts b/packages/core/src/generator/proxy-group-modules.ts index 780d90f..ff11955 100644 --- a/packages/core/src/generator/proxy-group-modules.ts +++ b/packages/core/src/generator/proxy-group-modules.ts @@ -9,6 +9,8 @@ /** * 分流代理组规则 */ +import type { ProxyGroupGroupType } from "@subboost/core/types/config"; + export interface ProxyGroupRule { id: string; name: string; @@ -26,7 +28,7 @@ export interface ProxyGroupModule { emoji: string; category: "core" | "service" | "media" | "social" | "game" | "tech" | "finance" | "other"; description: string; - groupType: "select" | "url-test" | "fallback" | "reject-first" | "direct-first"; + groupType: ProxyGroupGroupType; rules: ProxyGroupRule[]; } export const PROXY_GROUP_MODULES: ProxyGroupModule[] = [ @@ -496,4 +498,3 @@ export const PROXY_GROUP_MODULES: ProxyGroupModule[] = [ }, ]; - diff --git a/packages/core/src/generator/proxy-group-type.ts b/packages/core/src/generator/proxy-group-type.ts new file mode 100644 index 0000000..dac8ae1 --- /dev/null +++ b/packages/core/src/generator/proxy-group-type.ts @@ -0,0 +1,77 @@ +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + type LoadBalanceStrategy, + type ProxyGroup, + type ProxyGroupGroupType, +} from "@subboost/core/types/config"; + +type BuildTypedProxyGroupOptions = { + name: string; + groupType: ProxyGroupGroupType; + proxies: string[]; + testUrl: string; + testInterval: number; + strategy?: LoadBalanceStrategy; + extraFields?: Record; + urlTestLazy?: boolean; +}; + +export function uniqueProxyNames(values: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +export function buildTypedProxyGroup({ + name, + groupType, + proxies, + testUrl, + testInterval, + strategy, + extraFields = {}, + urlTestLazy = false, +}: BuildTypedProxyGroupOptions): ProxyGroup { + const base: ProxyGroup = { + name, + type: "select", + proxies, + ...extraFields, + }; + + switch (groupType) { + case "url-test": + return { + ...base, + type: "url-test", + url: testUrl, + interval: testInterval, + lazy: urlTestLazy, + }; + case "fallback": + return { + ...base, + type: "fallback", + url: testUrl, + interval: testInterval, + }; + case "load-balance": + return { + ...base, + type: "load-balance", + url: testUrl, + interval: testInterval, + strategy: strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, + }; + case "direct-first": + case "reject-first": + case "select": + default: + return base; + } +} diff --git a/packages/core/src/generator/proxy-groups.test.ts b/packages/core/src/generator/proxy-groups.test.ts index d57d9f1..6d8913d 100644 --- a/packages/core/src/generator/proxy-groups.test.ts +++ b/packages/core/src/generator/proxy-groups.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { generateProxyGroups, generateRuleProviders, + generateRules, getAllGroupNames, getGroupTarget, getModulesForTemplate, @@ -31,7 +32,7 @@ function customGroup(id: string, groupType: CustomProxyGroup["groupType"]): Cust } describe("proxy group generator", () => { - it("generates module, filtered, custom, and provider-backed groups", () => { + it("generates module, custom, advanced-filtered, and provider-backed groups", () => { const groups = generateProxyGroups({ nodes: [node("Node A"), node("Node B")], proxyProviderNames: ["remote"], @@ -39,48 +40,22 @@ describe("proxy group generator", () => { ruleProviderBaseUrl: "https://rules.example.com", testUrl: "https://probe.example.com/204", testInterval: 120, - filteredProxyGroups: [ - { - id: "fast", - name: "US Fast", - enabled: true, - groupType: "load-balance", - strategy: "round-robin", - sourceIds: [], - regions: [], - includeRegex: "Node A", - }, - { - id: "reject", - name: "Reject Filter", - enabled: true, - groupType: "reject-first", - sourceIds: [], - regions: [], - excludedNodeNames: ["Node B"], - }, - ], customProxyGroups: [ customGroup("select", "select"), customGroup("url", "url-test"), customGroup("fallback", "fallback"), - customGroup("balance", "load-balance"), + { + ...customGroup("balance", "load-balance"), + advanced: { includeRegex: "Node A" }, + }, customGroup("direct", "direct-first"), - customGroup("reject", "reject-first"), + { + ...customGroup("reject", "reject-first"), + advanced: { excludedMembers: [{ kind: "node", name: "Node B" }] }, + }, ], }); - expect(groups.find((group) => group.name === "US Fast")).toMatchObject({ - type: "load-balance", - proxies: ["Node A"], - strategy: "round-robin", - url: "https://probe.example.com/204", - interval: 120, - }); - expect(groups.find((group) => group.name === "Reject Filter")).toMatchObject({ - type: "select", - proxies: ["REJECT", "DIRECT", "Node A"], - }); expect(groups.find((group) => group.name === "Custom url")).toMatchObject({ type: "url-test", use: ["remote"], @@ -89,10 +64,15 @@ describe("proxy group generator", () => { expect(groups.find((group) => group.name === "Custom fallback")).toMatchObject({ type: "fallback" }); expect(groups.find((group) => group.name === "Custom balance")).toMatchObject({ type: "load-balance", + proxies: ["Node A"], strategy: "round-robin", + url: "https://probe.example.com/204", + interval: 120, }); expect(groups.find((group) => group.name === "Custom direct")?.proxies?.[0]).toBe("DIRECT"); - expect(groups.find((group) => group.name === "Custom reject")?.proxies?.[0]).toBe("REJECT"); + const rejectProxies = groups.find((group) => group.name === "Custom reject")?.proxies ?? []; + expect(rejectProxies.slice(0, 2)).toEqual(["REJECT", "DIRECT"]); + expect(rejectProxies).not.toContain("Node B"); expect(groups.find((group) => group.name.includes("节点选择"))).toMatchObject({ type: "select", use: ["remote"], @@ -136,4 +116,85 @@ describe("proxy group generator", () => { expect(getGroupTarget("missing")).toContain("节点选择"); expect(getAllGroupNames(["select"], [customGroup("custom", "select")])).toContain("Custom custom"); }); + + it("applies built-in group type overrides and explicitly added members", () => { + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("Node B")], + enabledModules: ["select", "auto", "ai"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + proxyGroupAdvanced: { + ai: { + groupType: "fallback", + extraMembers: [{ kind: "direct" }], + memberOrder: [{ kind: "direct" }, { kind: "node", name: "Node B" }], + }, + }, + }); + + expect(groups.find((group) => group.name.includes("AI"))).toMatchObject({ + type: "fallback", + proxies: ["DIRECT", "Node B", "Node A"], + url: "https://probe.example.com/204", + interval: 120, + }); + }); + + it("omits disabled custom groups from groups, providers, names, and custom rules", () => { + const disabledGroup = { ...customGroup("disabled", "select"), enabled: false }; + const groups = generateProxyGroups({ + nodes: [node("Node A")], + enabledModules: ["select"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [disabledGroup], + }); + const providers = generateRuleProviders({ + nodes: [node("Node A")], + enabledModules: [], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [disabledGroup], + customRuleSets: [ + { + id: "disabled-rule", + name: "Disabled rule", + behavior: "domain", + path: "geosite/disabled.mrs", + target: { kind: "custom", id: "disabled" }, + }, + ], + }); + const rules = generateRules({ + enabledModules: [], + customRules: [ + { + id: "disabled-manual", + type: "DOMAIN", + value: "disabled.example", + target: { kind: "custom", id: "disabled" }, + }, + ], + customRuleSets: [ + { + id: "disabled-rule", + name: "Disabled rule", + behavior: "domain", + path: "geosite/disabled.mrs", + target: { kind: "custom", id: "disabled" }, + }, + ], + customProxyGroups: [disabledGroup], + availablePolicyTargets: ["DIRECT"], + fallbackPolicyTarget: "DIRECT", + }); + + expect(groups.some((group) => group.name === "Custom disabled")).toBe(false); + expect(getAllGroupNames(["select"], [disabledGroup])).not.toContain("Custom disabled"); + expect(providers["disabled-rule"]).toBeUndefined(); + expect(rules).toEqual(["MATCH,DIRECT"]); + }); }); diff --git a/packages/core/src/generator/proxy-groups.ts b/packages/core/src/generator/proxy-groups.ts index c2c0dc9..c5817ef 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -5,9 +5,17 @@ import type { ParsedNode } from "@subboost/core/types/node"; import { DEFAULT_LOAD_BALANCE_STRATEGY } from "@subboost/core/types/config"; -import type { BuiltinRuleEdits, CustomProxyGroup, CustomRuleSet, ProxyGroup, RuleProvider } from "@subboost/core/types/config"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -import { getFilteredProxyGroupProxies } from "@subboost/core/filtered-proxy-groups"; +import type { + BuiltinRuleEdits, + CustomProxyGroup, + CustomRuleSet, + LoadBalanceStrategy, + ProxyGroup, + ProxyGroupAdvancedConfig, + ProxyGroupGroupType, + RuleProvider, +} from "@subboost/core/types/config"; +import { resolveProxyGroupMembers } from "@subboost/core/proxy-group-advanced"; import { isSubscriptionInfoNodeName } from "@subboost/core/subscription/info-node-name"; import { PROXY_GROUP_MODULES, type ProxyGroupModule, type ProxyGroupRule } from "./proxy-group-modules"; @@ -19,6 +27,7 @@ import { } from "./rules"; import { getModuleRuleOrderKey } from "./module-rules"; import { buildRuleSetUrlFromPath } from "@subboost/core/rules/rule-model"; +import { buildTypedProxyGroup } from "./proxy-group-type"; export { PROXY_GROUP_MODULES }; export type { ProxyGroupModule, ProxyGroupRule }; @@ -48,7 +57,7 @@ export interface GenerateOptions { testInterval: number; customProxyGroups?: CustomProxyGroup[]; customRuleSets?: CustomRuleSet[]; - filteredProxyGroups?: FilteredProxyGroup[]; + proxyGroupAdvanced?: Record; builtinRuleEdits?: BuiltinRuleEdits; // 国内服务 GeoIP 规则是否使用 no-resolve(默认 true;关闭可提升命中率但可能造成 DNS 泄露) cnIpNoResolve?: boolean; @@ -60,6 +69,26 @@ export interface GenerateOptions { export { isSubscriptionInfoNodeName }; +function getEnabledCustomProxyGroups(customProxyGroups: CustomProxyGroup[]): CustomProxyGroup[] { + return customProxyGroups.filter((group) => group && group.enabled !== false); +} + +function customTargetIsDisabled( + target: CustomRuleSet["target"], + customProxyGroups: CustomProxyGroup[] +): boolean { + const disabled = customProxyGroups.filter((group) => group && group.enabled === false); + if (disabled.length === 0) return false; + if (typeof target === "object" && target?.kind === "custom") { + return disabled.some((group) => group.id === target.id); + } + if (typeof target === "string") { + const name = target.trim(); + return Boolean(name) && disabled.some((group) => group.name.trim() === name); + } + return false; +} + /** * 代理组显示顺序(在 Clash 客户端中的排列顺序) * @@ -101,9 +130,10 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { testUrl, testInterval, customProxyGroups = [], - filteredProxyGroups = [], + proxyGroupAdvanced = {}, proxyGroupNameOverrides, } = options; + const activeCustomProxyGroups = getEnabledCustomProxyGroups(customProxyGroups); const providerUse = proxyProviderNames.length > 0 ? { use: proxyProviderNames } : {}; const nodeNames = nodes.map((n) => n.name); // 业务组/测速组需要过滤掉“信息节点”,仅在 🚀 节点选择 中保留显示 @@ -118,13 +148,15 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { const enabledModuleTarget = (moduleId: string) => enabledSet.has(moduleId) ? resolveModuleName(moduleId, proxyGroupNameOverrides) : null; - const enabledFilteredGroups = filteredProxyGroups - .filter((g) => g && g.enabled && typeof g.name === "string" && g.name.trim()) - .map((g) => ({ - ...g, - name: g.name.trim(), - })); - const filteredGroupNames = enabledFilteredGroups.map((g) => g.name); + const moduleNames = Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveModuleNameFromModule(module, proxyGroupNameOverrides), + ]) + ); + const customGroupNames = activeCustomProxyGroups + .map((group) => (typeof group.name === "string" ? group.name.trim() : "")) + .filter(Boolean); const policyTargets = (...targets: unknown[]) => { const out: string[] = []; const seen = new Set(); @@ -139,153 +171,188 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { const selectTarget = enabledModuleTarget("select"); const autoTarget = enabledModuleTarget("auto"); const baseProxies = selectTarget - ? fallbackTargets(selectTarget, autoTarget, "DIRECT", "REJECT", ...filteredGroupNames, ...filteredNodeNames) - : fallbackTargets(autoTarget, ...filteredGroupNames, ...filteredNodeNames, "DIRECT", "REJECT"); - - // 注意:筛选代理组会过滤掉“信息节点”,避免进入业务分流与测速池。 - for (const g of enabledFilteredGroups) { - const groupType = - g.groupType === "url-test" || - g.groupType === "fallback" || - g.groupType === "load-balance" || - g.groupType === "direct-first" || - g.groupType === "reject-first" - ? g.groupType - : "select"; - - const proxies = getFilteredProxyGroupProxies(nodes, g).filter( - (name) => name === "DIRECT" || name === "REJECT" || !isSubscriptionInfoNodeName(name) - ); - const nodeOnly = proxies.filter((name) => name !== "DIRECT" && name !== "REJECT"); - - if (groupType === "url-test" || groupType === "fallback" || groupType === "load-balance") { - groups.push({ - name: g.name, - type: groupType, - proxies: nodeOnly, - url: testUrl, - interval: testInterval, - ...(groupType === "url-test" ? { lazy: false } : {}), - ...(groupType === "load-balance" ? { strategy: g.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } : {}), - }); - continue; - } - - groups.push({ - name: g.name, - type: "select", - proxies: groupType === "reject-first" ? ["REJECT", "DIRECT", ...nodeOnly] : proxies, + ? fallbackTargets(selectTarget, autoTarget, "DIRECT", "REJECT", ...customGroupNames, ...filteredNodeNames) + : fallbackTargets(autoTarget, ...customGroupNames, ...filteredNodeNames, "DIRECT", "REJECT"); + const availableMemberProxyNames = fallbackTargets( + "DIRECT", + "REJECT", + autoTarget, + selectTarget, + ...filteredNodeNames, + ...PROXY_GROUP_MODULES.filter((module) => enabledSet.has(module.id)).map((module) => moduleNames[module.id]), + ...customGroupNames, + ); + + const resolveGroupProxyNames = ( + defaultProxyNames: string[], + advanced: ProxyGroupAdvancedConfig | undefined, + self: { kind: "module" | "custom"; id: string; name: string } + ) => + resolveProxyGroupMembers({ + defaultProxyNames, + availableProxyNames: availableMemberProxyNames, + nodes, + moduleNames, + customProxyGroups: activeCustomProxyGroups, + advanced, + self, + }).proxyNames; + const createGeneratedProxyGroup = ( + name: string, + groupType: ProxyGroupGroupType, + proxies: string[], + strategy?: LoadBalanceStrategy + ): ProxyGroup => + buildTypedProxyGroup({ + name, + groupType, + proxies, + testUrl, + testInterval, + strategy, + extraFields: providerUse, + urlTestLazy: false, }); - } // 辅助函数:生成单个代理组 const generateGroup = (module: ProxyGroupModule) => { const moduleName = resolveModuleNameFromModule(module, proxyGroupNameOverrides); - switch (module.groupType) { + const advanced = proxyGroupAdvanced[module.id]; + const groupType: ProxyGroupGroupType = advanced?.groupType ?? module.groupType; + switch (groupType) { case "select": if (module.id === "select") { + const defaultProxies = fallbackTargets(autoTarget, "DIRECT", "REJECT", ...customGroupNames, ...nodeNames); groups.push({ name: moduleName, type: "select", // “节点选择”组本身不应包含自己;默认先走“⚡ 自动选择”更符合开箱即用 - proxies: fallbackTargets(autoTarget, "DIRECT", "REJECT", ...filteredGroupNames, ...nodeNames), + proxies: resolveGroupProxyNames(defaultProxies, advanced, { + kind: "module", + id: module.id, + name: moduleName, + }), ...providerUse, }); } else { + const defaultProxies = baseProxies.filter((target) => target !== moduleName); groups.push({ name: moduleName, type: "select", - proxies: baseProxies.filter((target) => target !== moduleName), + proxies: resolveGroupProxyNames(defaultProxies, advanced, { + kind: "module", + id: module.id, + name: moduleName, + }), ...providerUse, }); } break; case "url-test": - groups.push({ - name: moduleName, - type: "url-test", - proxies: filteredNodeNames, - ...providerUse, - url: testUrl, - interval: testInterval, - lazy: false, - }); + groups.push(createGeneratedProxyGroup( + moduleName, + groupType, + resolveGroupProxyNames(filteredNodeNames, advanced, { + kind: "module", + id: module.id, + name: moduleName, + }), + )); break; case "fallback": - groups.push({ - name: moduleName, - type: "fallback", - proxies: filteredNodeNames, - ...providerUse, - url: testUrl, - interval: testInterval, - }); + groups.push(createGeneratedProxyGroup( + moduleName, + groupType, + resolveGroupProxyNames(filteredNodeNames, advanced, { + kind: "module", + id: module.id, + name: moduleName, + }), + )); + break; + + case "load-balance": + groups.push(createGeneratedProxyGroup( + moduleName, + groupType, + resolveGroupProxyNames(filteredNodeNames, advanced, { + kind: "module", + id: module.id, + name: moduleName, + }), + advanced?.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, + )); break; case "reject-first": - groups.push({ - name: moduleName, - type: "select", - proxies: fallbackTargets("REJECT", "DIRECT", selectTarget).filter((target) => target !== moduleName), - ...providerUse, - }); + { + const defaultProxies = fallbackTargets("REJECT", "DIRECT", selectTarget).filter((target) => target !== moduleName); + groups.push({ + name: moduleName, + type: "select", + proxies: resolveGroupProxyNames(defaultProxies, advanced, { + kind: "module", + id: module.id, + name: moduleName, + }), + ...providerUse, + }); + } break; case "direct-first": - groups.push({ - name: moduleName, - type: "select", - // 私有网络/国内服务:默认 DIRECT,但也提供自动选择 - proxies: fallbackTargets("DIRECT", "REJECT", ...filteredGroupNames, selectTarget, autoTarget, ...filteredNodeNames).filter( + { + const defaultProxies = fallbackTargets("DIRECT", "REJECT", selectTarget, autoTarget, ...customGroupNames, ...filteredNodeNames).filter( (target) => target !== moduleName - ), - ...providerUse, - }); + ); + groups.push({ + name: moduleName, + type: "select", + // 私有网络/国内服务:默认 DIRECT,但也提供自动选择 + proxies: resolveGroupProxyNames(defaultProxies, advanced, { + kind: "module", + id: module.id, + name: moduleName, + }), + ...providerUse, + }); + } break; } }; const createCustomProxyGroup = (customGroup: CustomProxyGroup): ProxyGroup => { - if (customGroup.groupType === "url-test") { - return { + const resolveCustom = (defaultProxyNames: string[]) => + resolveGroupProxyNames(defaultProxyNames, customGroup.advanced, { + kind: "custom", + id: customGroup.id, name: customGroup.name, - type: "url-test", - proxies: filteredNodeNames, - ...providerUse, - url: testUrl, - interval: testInterval, - lazy: false, - }; + }); + + if (customGroup.groupType === "url-test") { + return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames)); } if (customGroup.groupType === "fallback") { - return { - name: customGroup.name, - type: "fallback", - proxies: filteredNodeNames, - ...providerUse, - url: testUrl, - interval: testInterval, - }; + return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames)); } if (customGroup.groupType === "load-balance") { - return { - name: customGroup.name, - type: "load-balance", - proxies: filteredNodeNames, - ...providerUse, - url: testUrl, - interval: testInterval, - strategy: customGroup.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, - }; + return createGeneratedProxyGroup( + customGroup.name, + customGroup.groupType, + resolveCustom(filteredNodeNames), + customGroup.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY + ); } if (customGroup.groupType === "direct-first") { return { name: customGroup.name, type: "select", - proxies: fallbackTargets("DIRECT", "REJECT", selectTarget, autoTarget, ...filteredNodeNames).filter( - (target) => target !== customGroup.name + proxies: resolveCustom( + fallbackTargets("DIRECT", "REJECT", selectTarget, autoTarget, ...customGroupNames, ...filteredNodeNames).filter( + (target) => target !== customGroup.name + ) ), ...providerUse, }; @@ -294,14 +361,18 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { return { name: customGroup.name, type: "select", - proxies: fallbackTargets("REJECT", "DIRECT", selectTarget).filter((target) => target !== customGroup.name), + proxies: resolveCustom( + fallbackTargets("REJECT", "DIRECT", selectTarget, ...customGroupNames).filter( + (target) => target !== customGroup.name + ) + ), ...providerUse, }; } return { name: customGroup.name, type: "select", - proxies: baseProxies.filter((target) => target !== customGroup.name), + proxies: resolveCustom(baseProxies.filter((target) => target !== customGroup.name)), ...providerUse, }; }; @@ -310,9 +381,9 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { let insertedCustom = false; for (const moduleId of PROXY_GROUP_ORDER) { // 自定义分流组插入位置:在 🍏 苹果服务 与 📲 电报消息之间 - if (!insertedCustom && moduleId === "ai" && customProxyGroups.length > 0) { + if (!insertedCustom && moduleId === "ai" && activeCustomProxyGroups.length > 0) { insertedCustom = true; - for (const customGroup of customProxyGroups) { + for (const customGroup of activeCustomProxyGroups) { groups.push(createCustomProxyGroup(customGroup)); } } @@ -335,8 +406,8 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { } // 若未命中插入点(例如未来移除 private),则追加到末尾 - if (!insertedCustom && customProxyGroups.length > 0) { - for (const customGroup of customProxyGroups) { + if (!insertedCustom && activeCustomProxyGroups.length > 0) { + for (const customGroup of activeCustomProxyGroups) { groups.push(createCustomProxyGroup(customGroup)); } } @@ -353,6 +424,7 @@ export function generateRuleProviders(options: GenerateOptions): Record = {}; const enabledSet = new Set(enabledModules); @@ -388,6 +460,7 @@ export function generateRuleProviders(options: GenerateOptions): Record m.name); // 添加自定义代理组 - for (const group of customProxyGroups) { + for (const group of getEnabledCustomProxyGroups(customProxyGroups)) { names.push(group.name); } diff --git a/packages/core/src/generator/rules.ts b/packages/core/src/generator/rules.ts index 0a1ac7e..3983b96 100644 --- a/packages/core/src/generator/rules.ts +++ b/packages/core/src/generator/rules.ts @@ -1,6 +1,8 @@ -import type { BuiltinRuleEdits, CustomRule, CustomRuleSet } from "@subboost/core/types/config"; +import type { BuiltinRuleEdits, CustomRule, CustomRuleSet, ProxyGroupRuleTarget } from "@subboost/core/types/config"; +import type { CustomProxyGroup } from "@subboost/core/types/config"; import { getCustomRuleOrderKey } from "@subboost/core/rules/custom-rule-utils"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { PROXY_GROUP_MODULES, type ProxyGroupModule, type ProxyGroupRule } from "./proxy-group-modules"; import { getEffectiveModuleRules, getModuleRuleOrderKey } from "./module-rules"; import { createPolicyTargetResolver } from "./policy-targets"; @@ -26,6 +28,7 @@ export interface RulesGenerateOptions { enabledModules: string[]; customRules: CustomRuleLike[]; customRuleSets?: CustomRuleSetLike[]; + customProxyGroups?: CustomProxyGroup[]; builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; experimentalCnUseCnRuleSet?: boolean; @@ -100,6 +103,32 @@ export function resolveModuleNameFromModule(module: ProxyGroupModule, overrides? export { getEffectiveModuleRules }; +function buildModuleNameMap(overrides?: Record): Record { + return Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [module.id, resolveModuleNameFromModule(module, overrides)]) + ); +} + +function getEnabledCustomProxyGroups(customProxyGroups: CustomProxyGroup[]): CustomProxyGroup[] { + return customProxyGroups.filter((group) => group && group.enabled !== false); +} + +function customTargetIsDisabled( + target: ProxyGroupRuleTarget, + customProxyGroups: CustomProxyGroup[] +): boolean { + const disabled = customProxyGroups.filter((group) => group && group.enabled === false); + if (disabled.length === 0) return false; + if (typeof target === "object" && target?.kind === "custom") { + return disabled.some((group) => group.id === target.id); + } + if (typeof target === "string") { + const name = target.trim(); + return Boolean(name) && disabled.some((group) => group.name.trim() === name); + } + return false; +} + const PRESET_MODULE_RULE_ORDER_KEYS = PROXY_GROUP_MODULES.flatMap((module) => module.rules.map((rule) => getModuleRuleOrderKey(module.id, rule.id)) ); @@ -111,12 +140,19 @@ function buildModuleRuleEntry( builtinRuleEdits?: BuiltinRuleEdits, proxyGroupNameOverrides?: Record, cnIpNoResolve?: boolean, + customProxyGroups: CustomProxyGroup[] = [], resolvePolicyTarget: (target: string) => string = (target) => target ): GeneratedRuleEntry { const key = getModuleRuleOrderKey(module.id, rule.id); const edit = builtinRuleEdits?.[key]; const defaultTarget = resolveModuleNameFromModule(module, proxyGroupNameOverrides); - const target = resolvePolicyTarget(edit?.target || defaultTarget); + const moduleNames = buildModuleNameMap(proxyGroupNameOverrides); + const targetName = resolveProxyGroupTargetName(edit?.target || defaultTarget, { + moduleNames, + customProxyGroups, + fallbackTarget: defaultTarget, + }); + const target = resolvePolicyTarget(targetName); const noResolve = module.id === "cn" && rule.id === "cn-ip" && typeof cnIpNoResolve === "boolean" ? cnIpNoResolve @@ -139,10 +175,12 @@ function buildModuleRuleEntry( function buildCustomRuleEntry( rule: CustomRuleLike, + moduleNames: Record, + customProxyGroups: CustomProxyGroup[] = [], resolvePolicyTarget: (target: string) => string = (target) => target ): GeneratedRuleEntry { const noResolve = Boolean(rule.noResolve) && (rule.type === "IP-CIDR" || rule.type === "IP-CIDR6"); - const target = resolvePolicyTarget(rule.target); + const target = resolvePolicyTarget(resolveProxyGroupTargetName(rule.target, { moduleNames, customProxyGroups })); let text = `${rule.type},${rule.value},${target}`; if (noResolve) text += ",no-resolve"; @@ -161,10 +199,12 @@ function buildCustomRuleEntry( function buildCustomRuleSetEntry( ruleSet: CustomRuleSetLike, + moduleNames: Record, + customProxyGroups: CustomProxyGroup[] = [], resolvePolicyTarget: (target: string) => string = (target) => target ): GeneratedRuleEntry { const noResolve = Boolean(ruleSet.noResolve); - const target = resolvePolicyTarget(ruleSet.target); + const target = resolvePolicyTarget(resolveProxyGroupTargetName(ruleSet.target, { moduleNames, customProxyGroups })); let text = `RULE-SET,${ruleSet.id},${target}`; if (noResolve) text += ",no-resolve"; @@ -185,11 +225,13 @@ function buildOrderedEditableEntries( customRules: CustomRuleLike[], customRuleSets: CustomRuleSetLike[], ruleOrder?: string[], + moduleNames: Record = {}, + customProxyGroups: CustomProxyGroup[] = [], resolvePolicyTarget: (target: string) => string = (target) => target ): GeneratedRuleEntry[] { const defaultEntries: GeneratedRuleEntry[] = [ - ...customRules.map((rule) => buildCustomRuleEntry(rule, resolvePolicyTarget)), - ...customRuleSets.map((ruleSet) => buildCustomRuleSetEntry(ruleSet, resolvePolicyTarget)), + ...customRules.map((rule) => buildCustomRuleEntry(rule, moduleNames, customProxyGroups, resolvePolicyTarget)), + ...customRuleSets.map((ruleSet) => buildCustomRuleSetEntry(ruleSet, moduleNames, customProxyGroups, resolvePolicyTarget)), ]; const editableKeys = defaultEntries.map((entry) => entry.key); const normalizedEditableOrder = normalizeEditableRuleOrderKeys(ruleOrder, editableKeys); @@ -325,6 +367,7 @@ function buildCanonicalRuleEntries(options: Omit !customTargetIsDisabled(rule.target, customProxyGroups)), + customRuleSets.filter((ruleSet) => !customTargetIsDisabled(ruleSet.target, customProxyGroups)), + undefined, + moduleNames, + activeCustomProxyGroups, + resolvePolicyTarget + ); const emittedRuleKeys = new Set(); let insertedEditableEntries = false; @@ -346,7 +398,15 @@ function buildCanonicalRuleEntries(options: Omit { - const entry = buildModuleRuleEntry(module, rule, builtinRuleEdits, proxyGroupNameOverrides, cnIpNoResolve, resolvePolicyTarget); + const entry = buildModuleRuleEntry( + module, + rule, + builtinRuleEdits, + proxyGroupNameOverrides, + cnIpNoResolve, + activeCustomProxyGroups, + resolvePolicyTarget + ); if (emittedRuleKeys.has(entry.key)) return; emittedRuleKeys.add(entry.key); entries.push(entry); @@ -421,6 +481,7 @@ export function normalizePersistedRuleOrder(options: RulesGenerateOptions): stri enabledModules: options.enabledModules, customRules: options.customRules, customRuleSets: options.customRuleSets, + customProxyGroups: options.customProxyGroups, builtinRuleEdits: options.builtinRuleEdits, proxyGroupNameOverrides: options.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: options.experimentalCnUseCnRuleSet, @@ -446,6 +507,7 @@ export function resolveAppliedRuleOrder(options: RulesGenerateOptions): string[] enabledModules: options.enabledModules, customRules: options.customRules, customRuleSets: options.customRuleSets, + customProxyGroups: options.customProxyGroups, builtinRuleEdits: options.builtinRuleEdits, proxyGroupNameOverrides: options.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: options.experimentalCnUseCnRuleSet, @@ -529,6 +591,7 @@ export function buildGeneratedRuleEntries(options: RulesGenerateOptions): Genera enabledModules: options.enabledModules, customRules: options.customRules, customRuleSets: options.customRuleSets, + customProxyGroups: options.customProxyGroups, builtinRuleEdits: options.builtinRuleEdits, proxyGroupNameOverrides: options.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: options.experimentalCnUseCnRuleSet, diff --git a/packages/core/src/migrations/filtered-proxy-groups.ts b/packages/core/src/migrations/filtered-proxy-groups.ts new file mode 100644 index 0000000..c48d04e --- /dev/null +++ b/packages/core/src/migrations/filtered-proxy-groups.ts @@ -0,0 +1,176 @@ +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import type { ProxyGroupAdvancedConfig } from "@subboost/core/types/config"; + +type MutableRecord = Record; +const LEGACY_GROUP_TYPES = new Set(["select", "url-test", "fallback", "load-balance", "direct-first", "reject-first"]); + +function isRecord(value: unknown): value is MutableRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function uniqueStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + const seen = new Set(); + for (const item of value) { + const text = stringValue(item); + if (!text || seen.has(text)) continue; + seen.add(text); + out.push(text); + } + return out; +} + +function makeUniqueName(name: string, used: Set): string { + const base = name.trim() || "自定义代理组"; + if (!used.has(base)) { + used.add(base); + return base; + } + let index = 2; + let candidate = `${base} (${index})`; + while (used.has(candidate)) { + index += 1; + candidate = `${base} (${index})`; + } + used.add(candidate); + return candidate; +} + +function makeUniqueId(id: string, used: Set): string { + const base = `migrated-filtered-${id || "group"}`; + if (!used.has(base)) { + used.add(base); + return base; + } + let index = 2; + let candidate = `${base}-${index}`; + while (used.has(candidate)) { + index += 1; + candidate = `${base}-${index}`; + } + used.add(candidate); + return candidate; +} + +function migrateAdvanced(group: MutableRecord): ProxyGroupAdvancedConfig { + return normalizeProxyGroupAdvancedConfig({ + sourceIds: uniqueStringArray(group.sourceIds), + regions: uniqueStringArray(group.regions), + includeRegex: stringValue(group.includeRegex), + excludeRegex: stringValue(group.excludeRegex), + excludedMembers: uniqueStringArray(group.excludedNodeNames).map((name) => ({ + kind: "node" as const, + name, + })), + }); +} + +function retargetValue(value: unknown, nameMap: Map): unknown { + const target = stringValue(value); + return target && nameMap.has(target) ? nameMap.get(target) : value; +} + +function retargetStringArray(value: unknown, nameMap: Map): unknown { + if (!Array.isArray(value)) return value; + return value.map((item) => (typeof item === "string" ? nameMap.get(item.trim()) ?? item : item)); +} + +export function migrateFilteredProxyGroupsConfig(config: T): T { + if (!isRecord(config)) { + return config; + } + const record = config; + const filteredProxyGroups = record.filteredProxyGroups; + if (!Array.isArray(filteredProxyGroups) || filteredProxyGroups.length === 0) { + return config; + } + + const next: MutableRecord = { ...record }; + const existingCustomGroups = Array.isArray(next.customProxyGroups) + ? next.customProxyGroups.filter(isRecord) + : []; + const usedIds = new Set(existingCustomGroups.map((group) => stringValue(group.id)).filter(Boolean)); + const usedNames = new Set(existingCustomGroups.map((group) => stringValue(group.name)).filter(Boolean)); + const nameMap = new Map(); + const idMap = new Map(); + const migratedGroups: MutableRecord[] = []; + + for (const rawGroup of filteredProxyGroups) { + if (!isRecord(rawGroup)) continue; + if (rawGroup.enabled === false) continue; + const oldId = stringValue(rawGroup.id); + const oldName = stringValue(rawGroup.name); + if (!oldId || !oldName) continue; + + const nextId = makeUniqueId(oldId, usedIds); + const nextName = makeUniqueName(oldName, usedNames); + idMap.set(oldId, nextId); + nameMap.set(oldName, nextName); + + const rawGroupType = stringValue(rawGroup.groupType) || "select"; + const groupType = LEGACY_GROUP_TYPES.has(rawGroupType) ? rawGroupType : "select"; + migratedGroups.push({ + id: nextId, + name: nextName, + emoji: stringValue(rawGroup.emoji), + description: "自定义代理组", + groupType, + ...(groupType === "load-balance" && stringValue(rawGroup.strategy) + ? { strategy: stringValue(rawGroup.strategy) } + : {}), + advanced: migrateAdvanced(rawGroup), + }); + } + + next.customProxyGroups = [...existingCustomGroups, ...migratedGroups]; + delete next.filteredProxyGroups; + + if (Array.isArray(next.customRules)) { + next.customRules = next.customRules.map((rule) => { + if (!isRecord(rule) || !("target" in rule)) return rule; + return { ...rule, target: retargetValue(rule.target, nameMap) }; + }); + } + + if (Array.isArray(next.customRuleSets)) { + next.customRuleSets = next.customRuleSets.map((ruleSet) => { + if (!isRecord(ruleSet) || !("target" in ruleSet)) return ruleSet; + return { ...ruleSet, target: retargetValue(ruleSet.target, nameMap) }; + }); + } + + if (isRecord(next.builtinRuleEdits)) { + next.builtinRuleEdits = Object.fromEntries( + Object.entries(next.builtinRuleEdits).map(([key, edit]) => { + if (!isRecord(edit)) return [key, edit]; + const nextEdit = { ...edit }; + if ("target" in edit) nextEdit.target = retargetValue(edit.target, nameMap); + return [key, nextEdit]; + }) + ); + } + + if (Array.isArray(next.dialerProxyGroups)) { + next.dialerProxyGroups = next.dialerProxyGroups.map((group) => { + if (!isRecord(group) || !("relayNodes" in group)) return group; + return { ...group, relayNodes: retargetStringArray(group.relayNodes, nameMap) }; + }); + } + + if (Array.isArray(next.proxyGroupOrder)) { + next.proxyGroupOrder = next.proxyGroupOrder.map((key) => { + const text = stringValue(key); + if (!text.startsWith("filtered:")) return key; + const id = text.slice("filtered:".length); + const nextId = idMap.get(id); + return nextId ? `custom:${nextId}` : key; + }); + } + + return next as T; +} diff --git a/packages/core/src/proxy-group-advanced.ts b/packages/core/src/proxy-group-advanced.ts new file mode 100644 index 0000000..5eed77a --- /dev/null +++ b/packages/core/src/proxy-group-advanced.ts @@ -0,0 +1,365 @@ +import { getNodeSourceIds } from "@subboost/core/subscription/node-source-state"; +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + isLoadBalanceStrategy, +} from "@subboost/core/types/config"; +import type { + CustomProxyGroup, + NodeRegion, + ProxyGroupAdvancedConfig, + ProxyGroupGroupType, + ProxyGroupMemberRef, +} from "@subboost/core/types/config"; +import type { ParsedNode } from "@subboost/core/types/node"; +import { getProxyGroupMemberKey } from "@subboost/core/proxy-group-targets"; + +export const REGION_PRESETS: Array<{ + id: NodeRegion; + label: string; + emoji: string; + keywords: string[]; +}> = [ + { id: "us", label: "美国", emoji: "🇺🇸", keywords: ["美国", "US", "USA", "United States", "洛杉矶", "纽约", "西雅图"] }, + { id: "hk", label: "香港", emoji: "🇭🇰", keywords: ["香港", "HK", "Hong Kong", "港"] }, + { id: "jp", label: "日本", emoji: "🇯🇵", keywords: ["日本", "JP", "Japan", "东京", "大阪"] }, + { id: "sg", label: "新加坡", emoji: "🇸🇬", keywords: ["新加坡", "SG", "Singapore", "狮城"] }, + { id: "tw", label: "台湾", emoji: "🇹🇼", keywords: ["台湾", "TW", "Taiwan", "台北"] }, + { id: "kr", label: "韩国", emoji: "🇰🇷", keywords: ["韩国", "KR", "Korea", "首尔"] }, + { id: "uk", label: "英国", emoji: "🇬🇧", keywords: ["英国", "UK", "United Kingdom", "London", "伦敦"] }, + { id: "de", label: "德国", emoji: "🇩🇪", keywords: ["德国", "DE", "Germany", "Frankfurt", "法兰克福"] }, + { id: "fr", label: "法国", emoji: "🇫🇷", keywords: ["法国", "FR", "France", "Paris", "巴黎"] }, + { id: "ca", label: "加拿大", emoji: "🇨🇦", keywords: ["加拿大", "CA", "Canada", "Toronto", "多伦多"] }, + { id: "au", label: "澳大利亚", emoji: "🇦🇺", keywords: ["澳大利亚", "AU", "Australia", "Sydney", "悉尼"] }, + { id: "other", label: "其他", emoji: "🌐", keywords: [] }, +]; + +export type ProxyGroupResolvedMemberKind = ProxyGroupMemberRef["kind"]; + +export interface ProxyGroupResolvedMember { + ref: ProxyGroupMemberRef; + key: string; + name: string; + kind: ProxyGroupResolvedMemberKind; +} + +export interface ResolveProxyGroupMembersOptions { + defaultProxyNames: string[]; + availableProxyNames?: string[]; + nodes: ParsedNode[]; + moduleNames?: Record; + customProxyGroups?: CustomProxyGroup[]; + advanced?: ProxyGroupAdvancedConfig; + self?: { kind: "module" | "custom"; id: string; name: string }; +} + +export interface ResolveProxyGroupMembersResult { + included: ProxyGroupResolvedMember[]; + excluded: ProxyGroupResolvedMember[]; + proxyNames: string[]; +} + +const NODE_REGIONS = new Set(REGION_PRESETS.map((preset) => preset.id)); + +function compileRegex(pattern?: string): RegExp | null { + const raw = typeof pattern === "string" ? pattern.trim() : ""; + if (!raw) return null; + try { + return new RegExp(raw, "i"); + } catch { + return null; + } +} + +function matchesRegion(nodeName: string, regions: NodeRegion[]): boolean { + if (!Array.isArray(regions) || regions.length === 0) return true; + const normalized = nodeName.toLowerCase(); + + for (const region of regions) { + if (region === "other") continue; + const preset = REGION_PRESETS.find((item) => item.id === region); + if (!preset) continue; + if (preset.keywords.some((keyword) => normalized.includes(keyword.toLowerCase()))) return true; + } + + if (regions.includes("other")) { + for (const preset of REGION_PRESETS) { + if (preset.id === "other") continue; + if (preset.keywords.some((keyword) => normalized.includes(keyword.toLowerCase()))) return false; + } + return true; + } + + return false; +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +function normalizeRegions(value: unknown): NodeRegion[] { + if (!Array.isArray(value)) return []; + const out: NodeRegion[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") continue; + const key = item.trim().toLowerCase() as NodeRegion; + if (!NODE_REGIONS.has(key) || seen.has(key)) continue; + seen.add(key); + out.push(key); + } + return out; +} + +function normalizeGroupType(value: unknown): ProxyGroupGroupType | null { + if ( + value === "select" || + value === "url-test" || + value === "fallback" || + value === "load-balance" || + value === "direct-first" || + value === "reject-first" + ) { + return value; + } + return null; +} + +export function normalizeProxyGroupMemberRef(value: unknown): ProxyGroupMemberRef | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const item = value as Record; + if (item.kind === "direct") return { kind: "direct" }; + if (item.kind === "reject") return { kind: "reject" }; + if (item.kind === "node" && typeof item.name === "string" && item.name.trim()) { + return { kind: "node", name: item.name.trim() }; + } + if (item.kind === "module" && typeof item.id === "string" && item.id.trim()) { + return { kind: "module", id: item.id.trim() }; + } + if (item.kind === "custom" && typeof item.id === "string" && item.id.trim()) { + return { kind: "custom", id: item.id.trim() }; + } + return null; +} + +function normalizeMemberList(value: unknown): ProxyGroupMemberRef[] { + if (!Array.isArray(value)) return []; + const out: ProxyGroupMemberRef[] = []; + const seen = new Set(); + for (const item of value) { + const ref = normalizeProxyGroupMemberRef(item); + if (!ref) continue; + const key = getProxyGroupMemberKey(ref); + if (seen.has(key)) continue; + seen.add(key); + out.push(ref); + } + return out; +} + +export function normalizeProxyGroupAdvancedConfig(value: unknown): ProxyGroupAdvancedConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const item = value as Record; + const sourceIds = normalizeStringList(item.sourceIds); + const regions = normalizeRegions(item.regions); + const includeRegex = typeof item.includeRegex === "string" ? item.includeRegex.trim() : ""; + const excludeRegex = typeof item.excludeRegex === "string" ? item.excludeRegex.trim() : ""; + const groupType = normalizeGroupType(item.groupType); + const strategy = groupType === "load-balance" && isLoadBalanceStrategy(item.strategy) + ? item.strategy + : groupType === "load-balance" + ? DEFAULT_LOAD_BALANCE_STRATEGY + : undefined; + const extraMembers = normalizeMemberList(item.extraMembers); + const excludedMembers = normalizeMemberList(item.excludedMembers); + const memberOrder = normalizeMemberList(item.memberOrder); + return { + ...(sourceIds.length > 0 ? { sourceIds } : {}), + ...(regions.length > 0 ? { regions } : {}), + ...(includeRegex ? { includeRegex } : {}), + ...(excludeRegex ? { excludeRegex } : {}), + ...(groupType ? { groupType } : {}), + ...(strategy ? { strategy } : {}), + ...(extraMembers.length > 0 ? { extraMembers } : {}), + ...(excludedMembers.length > 0 ? { excludedMembers } : {}), + ...(memberOrder.length > 0 ? { memberOrder } : {}), + }; +} + +function buildMemberFromName( + name: string, + options: { + nodeNameSet: Set; + moduleNameToId: Map; + customNameToId: Map; + } +): ProxyGroupResolvedMember | null { + const trimmed = name.trim(); + if (!trimmed) return null; + + let ref: ProxyGroupMemberRef | null = null; + if (trimmed === "DIRECT") ref = { kind: "direct" }; + else if (trimmed === "REJECT") ref = { kind: "reject" }; + else if (options.nodeNameSet.has(trimmed)) ref = { kind: "node", name: trimmed }; + else { + const moduleId = options.moduleNameToId.get(trimmed); + if (moduleId) ref = { kind: "module", id: moduleId }; + const customId = options.customNameToId.get(trimmed); + if (!ref && customId) ref = { kind: "custom", id: customId }; + } + + if (!ref) return null; + return { + ref, + key: getProxyGroupMemberKey(ref), + name: trimmed, + kind: ref.kind, + }; +} + +function buildMemberFromRef( + ref: ProxyGroupMemberRef, + options: { + nodeNameSet: Set; + moduleNames: Record; + customGroupsById: Map; + } +): ProxyGroupResolvedMember | null { + const key = getProxyGroupMemberKey(ref); + if (ref.kind === "direct") return { ref, key, name: "DIRECT", kind: ref.kind }; + if (ref.kind === "reject") return { ref, key, name: "REJECT", kind: ref.kind }; + if (ref.kind === "node") { + const name = ref.name.trim(); + return name && options.nodeNameSet.has(name) ? { ref: { kind: "node", name }, key, name, kind: ref.kind } : null; + } + if (ref.kind === "module") { + const name = options.moduleNames[ref.id]?.trim(); + return name ? { ref, key, name, kind: ref.kind } : null; + } + const group = options.customGroupsById.get(ref.id); + const name = group?.name?.trim(); + return name ? { ref, key, name, kind: ref.kind } : null; +} + +function nodePassesAdvancedFilters( + member: ProxyGroupResolvedMember, + nodeByName: Map, + advanced: ProxyGroupAdvancedConfig +): boolean { + if (member.ref.kind !== "node") return true; + const node = nodeByName.get(member.ref.name); + if (!node) return false; + + const sourceIds = normalizeStringList(advanced.sourceIds); + if (sourceIds.length > 0) { + const sourceIdSet = new Set(sourceIds); + if (!getNodeSourceIds(node).some((id) => sourceIdSet.has(id))) return false; + } + + const regions = normalizeRegions(advanced.regions); + if (!matchesRegion(member.ref.name, regions)) return false; + + const includeRe = compileRegex(advanced.includeRegex); + if (includeRe && !includeRe.test(member.ref.name)) return false; + const excludeRe = compileRegex(advanced.excludeRegex); + if (excludeRe && excludeRe.test(member.ref.name)) return false; + + return true; +} + +export function resolveProxyGroupMembers(options: ResolveProxyGroupMembersOptions): ResolveProxyGroupMembersResult { + const advanced = normalizeProxyGroupAdvancedConfig(options.advanced); + const nodeByName = new Map(options.nodes.map((node) => [node.name, node])); + const nodeNameSet = new Set(nodeByName.keys()); + const moduleNameToId = new Map(); + for (const [id, name] of Object.entries(options.moduleNames || {})) { + const trimmed = name.trim(); + if (id && trimmed) moduleNameToId.set(trimmed, id); + } + const customNameToId = new Map(); + const customGroupsById = new Map(); + for (const group of options.customProxyGroups || []) { + if (group.enabled === false) continue; + const name = typeof group.name === "string" ? group.name.trim() : ""; + if (group.id && name) { + customNameToId.set(name, group.id); + customGroupsById.set(group.id, group); + } + } + + const buildMembersFromNames = (names: string[]) => { + const out: ProxyGroupResolvedMember[] = []; + const seen = new Set(); + for (const rawName of names || []) { + if (typeof rawName !== "string") continue; + const member = buildMemberFromName(rawName, { nodeNameSet, moduleNameToId, customNameToId }); + if (!member || seen.has(member.key)) continue; + if (options.self && member.name === options.self.name) continue; + if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; + seen.add(member.key); + out.push(member); + } + return out; + }; + + const defaultCandidates = buildMembersFromNames(options.defaultProxyNames || []); + const availableCandidates = buildMembersFromNames(options.availableProxyNames || options.defaultProxyNames || []); + const availableByKey = new Map(availableCandidates.map((member) => [member.key, member])); + const candidateMap = new Map(defaultCandidates.map((member) => [member.key, member])); + + for (const ref of advanced.extraMembers || []) { + const member = availableByKey.get(getProxyGroupMemberKey(ref)) || buildMemberFromRef(ref, { + nodeNameSet, + moduleNames: options.moduleNames || {}, + customGroupsById, + }); + if (!member || candidateMap.has(member.key)) continue; + if (options.self && member.name === options.self.name) continue; + if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; + candidateMap.set(member.key, member); + } + + const candidates = Array.from(candidateMap.values()); + const extraKeys = new Set((advanced.extraMembers || []).map(getProxyGroupMemberKey)); + const excludedKeys = new Set((advanced.excludedMembers || []).map(getProxyGroupMemberKey)); + const includedBase = candidates.filter((member) => { + if (excludedKeys.has(member.key)) return false; + if (extraKeys.has(member.key)) return true; + return nodePassesAdvancedFilters(member, nodeByName, advanced); + }); + + const byKey = new Map(includedBase.map((member) => [member.key, member])); + const ordered: ProxyGroupResolvedMember[] = []; + const used = new Set(); + for (const ref of advanced.memberOrder || []) { + const key = getProxyGroupMemberKey(ref); + const member = byKey.get(key); + if (!member || used.has(key)) continue; + used.add(key); + ordered.push(member); + } + for (const member of includedBase) { + if (used.has(member.key)) continue; + used.add(member.key); + ordered.push(member); + } + + const includedKeys = new Set(ordered.map((member) => member.key)); + const excludedSource = availableCandidates.length > 0 ? availableCandidates : candidates; + const excluded = excludedSource.filter((member) => !includedKeys.has(member.key)); + + return { + included: ordered, + excluded, + proxyNames: ordered.map((member) => member.name), + }; +} diff --git a/packages/core/src/proxy-group-targets.ts b/packages/core/src/proxy-group-targets.ts new file mode 100644 index 0000000..3ca3f6d --- /dev/null +++ b/packages/core/src/proxy-group-targets.ts @@ -0,0 +1,67 @@ +import type { + CustomProxyGroup, + ProxyGroupMemberRef, + ProxyGroupRuleTarget, + ProxyGroupTargetRef, +} from "@subboost/core/types/config"; + +export function isProxyGroupTargetRef(value: unknown): value is ProxyGroupTargetRef { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const item = value as Record; + return ( + (item.kind === "module" || item.kind === "custom") && + typeof item.id === "string" && + Boolean(item.id.trim()) + ); +} + +export function normalizeProxyGroupTargetRef(value: unknown): ProxyGroupTargetRef | null { + if (!isProxyGroupTargetRef(value)) return null; + return { kind: value.kind, id: value.id.trim() }; +} + +export function getProxyGroupTargetKey(target: ProxyGroupTargetRef): string { + return `${target.kind}:${target.id}`; +} + +export function getProxyGroupMemberKey(member: ProxyGroupMemberRef): string { + switch (member.kind) { + case "node": + return `node:${member.name}`; + case "module": + return `module:${member.id}`; + case "custom": + return `custom:${member.id}`; + case "direct": + return "direct:DIRECT"; + case "reject": + return "reject:REJECT"; + } +} + +export function resolveProxyGroupTargetName( + target: ProxyGroupRuleTarget, + options: { + moduleNames: Record; + customProxyGroups?: CustomProxyGroup[]; + fallbackTarget?: string; + } +): string { + if (typeof target === "string") { + return target.trim() || options.fallbackTarget || ""; + } + + const normalized = normalizeProxyGroupTargetRef(target); + if (!normalized) return options.fallbackTarget || ""; + + if (normalized.kind === "module") { + return options.moduleNames[normalized.id]?.trim() || options.fallbackTarget || ""; + } + + const customName = (options.customProxyGroups || []).find((group) => group.id === normalized.id)?.name?.trim(); + return customName || options.fallbackTarget || ""; +} + +export function ruleTargetMatchesName(target: ProxyGroupRuleTarget, name: string): boolean { + return typeof target === "string" && target.trim() === name.trim(); +} diff --git a/packages/core/src/rules/custom-routing-rule-sets.ts b/packages/core/src/rules/custom-routing-rule-sets.ts index 8d7f8a0..e4332a7 100644 --- a/packages/core/src/rules/custom-routing-rule-sets.ts +++ b/packages/core/src/rules/custom-routing-rule-sets.ts @@ -1,6 +1,7 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import type { CustomProxyGroup, CustomRuleSet } from "@subboost/core/types/config"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; +import type { CustomProxyGroup, CustomRuleSet, ProxyGroupRuleTarget } from "@subboost/core/types/config"; import { buildRuleSetUrlFromPath, extractRuleSetPathFromUrl, @@ -53,11 +54,36 @@ export function parseRuleSetTargetValue( export { buildRuleSetUrlFromPath, extractRuleSetPathFromUrl, normalizeRuleSetPathInput }; function resolveRuleSetTarget( - targetName: string, + targetValue: ProxyGroupRuleTarget, customProxyGroups: CustomProxyGroup[], proxyGroupNameOverrides?: Record ): CustomRoutingRuleSetTarget | null { - const target = targetName.trim(); + const targetRef = normalizeProxyGroupTargetRef(targetValue); + if (targetRef?.kind === "module") { + const proxyModule = PROXY_GROUP_MODULES.find((module) => module.id === targetRef.id); + if (!proxyModule) return null; + const name = resolveProxyGroupModuleName(proxyModule, proxyGroupNameOverrides?.[proxyModule.id]); + return { + kind: "module", + id: proxyModule.id, + name, + value: getRuleSetTargetValue({ kind: "module", id: proxyModule.id }), + }; + } + if (targetRef?.kind === "custom") { + const group = customProxyGroups.find((item) => item.id === targetRef.id); + if (!group) return null; + const name = group.name.trim(); + if (!name) return null; + return { + kind: "custom", + id: group.id, + name, + value: getRuleSetTargetValue({ kind: "custom", id: group.id }), + }; + } + + const target = typeof targetValue === "string" ? targetValue.trim() : ""; if (!target) return null; for (const proxyModule of PROXY_GROUP_MODULES) { @@ -97,7 +123,11 @@ export function collectCustomRoutingRuleSets({ for (const rule of customRuleSets || []) { if (!rule || !rule.id || !rule.path) continue; - const target = resolveRuleSetTarget(rule.target, customProxyGroups, proxyGroupNameOverrides); + const target = resolveRuleSetTarget( + rule.target, + customProxyGroups, + proxyGroupNameOverrides, + ); if (!target) continue; items.push({ key: `custom-rule-set:${rule.id}`, diff --git a/packages/core/src/rules/custom-rule-batch-import.ts b/packages/core/src/rules/custom-rule-batch-import.ts index ed56af2..5235961 100644 --- a/packages/core/src/rules/custom-rule-batch-import.ts +++ b/packages/core/src/rules/custom-rule-batch-import.ts @@ -93,11 +93,16 @@ function normalizeNoResolve(value: string): boolean | null { return value.trim().toLowerCase() === "no-resolve" ? true : null; } +function getTargetKey(target: CustomRule["target"]): string { + if (typeof target === "string") return target.trim(); + return `${target.kind}:${target.id.trim()}`; +} + function getRuleKey(rule: Pick): string { return [ rule.type, rule.value.trim(), - rule.target.trim(), + getTargetKey(rule.target), Boolean(rule.noResolve) ? "1" : "0", ].join("\u0000"); } @@ -250,7 +255,8 @@ export function parseCustomRuleBatchImport( return; } - if (!draft.target.trim()) { + const target = getTargetKey(draft.target); + if (!target) { errorCount += 1; items.push({ lineNumber, @@ -261,13 +267,13 @@ export function parseCustomRuleBatchImport( return; } - if (!targetSet.has(draft.target.trim())) { + if (!targetSet.has(target)) { errorCount += 1; items.push({ lineNumber, raw: rawLine, status: "error", - message: `未知目标:${draft.target}`, + message: `未知目标:${target}`, }); return; } @@ -276,7 +282,7 @@ export function parseCustomRuleBatchImport( id: "", type: draft.type, value: draft.value.trim(), - target: draft.target.trim(), + target, noResolve: Boolean(draft.noResolve), }; const key = getRuleKey(rule); diff --git a/packages/core/src/rules/custom-rule-utils.ts b/packages/core/src/rules/custom-rule-utils.ts index 1d2de5a..ad17ddd 100644 --- a/packages/core/src/rules/custom-rule-utils.ts +++ b/packages/core/src/rules/custom-rule-utils.ts @@ -39,12 +39,17 @@ function toSlug(value: string): string { return parts.length > 0 ? parts.join("") : "item"; } +function customRuleTargetToString(target: CustomRule["target"]): string { + if (typeof target === "string") return target; + return `${target.kind}:${target.id}`; +} + function buildDeterministicCustomRuleId(rule: Pick, index: number): string { return [ "custom-rule", toSlug(rule.type), toSlug(rule.value).slice(0, 24), - toSlug(rule.target).slice(0, 24), + toSlug(customRuleTargetToString(rule.target)).slice(0, 24), String(index + 1), ].join("-"); } diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts index 681218b..b0319cd 100644 --- a/packages/core/src/rules/rule-model.ts +++ b/packages/core/src/rules/rule-model.ts @@ -1,5 +1,7 @@ import type { BuiltinRuleEdit, BuiltinRuleEdits, CustomProxyGroup, CustomRuleSet, RuleSetBehavior } from "@subboost/core/types/config"; import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy } from "@subboost/core/types/config"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; export const RULE_SET_PATH_RE = /^(geosite|geoip)\/[^/?#\s]+\.mrs$/i; @@ -49,7 +51,7 @@ function normalizeCustomRuleSet(item: unknown): CustomRuleSet | null { const id = toTrimmedString(item.id); const rawPath = toTrimmedString(item.path); const path = normalizeRuleSetPathInput(rawPath); - const target = toTrimmedString(item.target); + const target = normalizeProxyGroupTargetRef(item.target) ?? toTrimmedString(item.target); const behavior = normalizeBehavior(item.behavior); if (!id || !behavior || !path || !target || !isValidRuleSetPathOrUrl(path)) return null; const name = toTrimmedString(item.name) || id; @@ -66,7 +68,7 @@ function normalizeCustomRuleSet(item: unknown): CustomRuleSet | null { function normalizeBuiltinRuleEdit(item: unknown): BuiltinRuleEdit | null { if (!isRecord(item)) return null; - const target = toTrimmedString(item.target); + const target = normalizeProxyGroupTargetRef(item.target) ?? toTrimmedString(item.target); const enabled = item.enabled === false ? false : undefined; if (!target && enabled !== false) return null; return { @@ -97,6 +99,8 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { const id = toTrimmedString(rawGroup.id); const name = toTrimmedString(rawGroup.name); const emoji = toTrimmedString(rawGroup.emoji); + const enabled = rawGroup.enabled === false ? false : undefined; + const description = toTrimmedString(rawGroup.description); const groupType = toTrimmedString(rawGroup.groupType); if (!id || !name) continue; if ( @@ -114,6 +118,8 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { id, name, emoji, + ...(enabled === false ? { enabled: false } : {}), + ...(description ? { description } : {}), groupType, ...(groupType === "load-balance" ? { @@ -122,6 +128,7 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { : DEFAULT_LOAD_BALANCE_STRATEGY, } : {}), + advanced: normalizeProxyGroupAdvancedConfig(rawGroup.advanced), }); } diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 745b570..86d4e80 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -61,7 +61,8 @@ describe("subscription config utils", () => { { id: "chain", name: "Chain", - type: "url-test", + type: "load-balance", + strategy: "round-robin", enabled: true, relayNodes: [" Relay ", ""], targetNodes: [" Target "], @@ -130,6 +131,19 @@ describe("subscription config utils", () => { id: "media", strategy: "consistent-hashing", }); + expect(options.customProxyGroups?.[1]).toMatchObject({ + id: "migrated-filtered-filtered", + name: "Filtered", + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["airport"], + regions: ["us"], + includeRegex: "Fast", + excludeRegex: "IPv6", + excludedMembers: [{ kind: "node", name: "Slow" }], + }, + }); expect(options.customRuleSets?.[0]).toMatchObject({ id: "youtube", name: "YouTube", @@ -138,17 +152,11 @@ describe("subscription config utils", () => { }); expect(options.dialerProxyGroups?.[0]).toMatchObject({ id: "chain", + type: "load-balance", + strategy: "round-robin", relayNodes: ["Relay"], targetNodes: ["Target"], }); - expect(options.filteredProxyGroups?.[0]).toMatchObject({ - id: "filtered", - strategy: "round-robin", - regions: ["us"], - includeRegex: "Fast", - excludeRegex: "IPv6", - excludedNodeNames: ["Slow"], - }); expect(options.proxyGroupNameOverrides).toEqual({ auto: "Auto" }); expect(options.proxyGroupOrder).toEqual(["auto"]); }); @@ -178,7 +186,7 @@ describe("subscription config utils", () => { { id: "bad-behavior", name: "Bad", behavior: "bad", path: "geosite/bad.mrs", target: "Fallback" }, { id: "bad-path", name: "Bad", behavior: "domain", path: "plain.txt", target: "Fallback" }, ], - dialerProxyGroups: ["bad", { id: "bad", name: "Bad", type: "fallback" }], + dialerProxyGroups: ["bad", { id: "bad", name: "Bad", type: "bad" }], filteredProxyGroups: [ "bad", { id: "", name: "Bad", enabled: true }, @@ -220,13 +228,6 @@ describe("subscription config utils", () => { "direct-first", "reject-first", ]); - expect(options.filteredProxyGroups?.[0]).toMatchObject({ - id: "select-default", - enabled: false, - groupType: "select", - regions: [], - emoji: "S", - }); expect(options.dialerProxyGroups).toBeUndefined(); expect(options.proxyGroupNameOverrides).toBeUndefined(); expect(options.proxyGroupOrder).toBeUndefined(); @@ -252,13 +253,14 @@ describe("subscription config utils", () => { expect(minimal.template).toBe("minimal"); expect(standard.template).toBe("standard"); - expect(minimal.customProxyGroups?.map((group) => group.groupType)).toEqual(["select", "url-test"]); - expect(minimal.filteredProxyGroups?.map((group) => group.groupType)).toEqual([ + expect(minimal.customProxyGroups?.map((group) => group.groupType)).toEqual([ + "select", + "url-test", "fallback", "direct-first", "reject-first", ]); - expect(minimal.filteredProxyGroups?.[0].sourceIds).toEqual(["airport"]); - expect(minimal.filteredProxyGroups?.[1].regions).toEqual(["hk", "other"]); + expect(minimal.customProxyGroups?.[2].advanced?.sourceIds).toEqual(["airport"]); + expect(minimal.customProxyGroups?.[3].advanced?.regions).toEqual(["hk", "other"]); }); }); diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 6de4c7a..325fcf9 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -5,17 +5,21 @@ import type { ParsedNode } from "@subboost/core/types/node"; import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy, + isProxyGroupGroupType, type CustomProxyGroup, type CustomRule, + type ProxyGroupRuleTarget, type TemplateType, type UserConfig, } from "@subboost/core/types/config"; -import type { FilteredProxyGroup, NodeRegion } from "@subboost/core/types/filtered-proxy-group"; import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscription/imported-node-controls"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; import { ensureCustomRuleId } from "@subboost/core/rules/custom-rule-utils"; import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; +import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -37,6 +41,12 @@ function normalizeStringArray(value: unknown): string[] { return out; } +function normalizeRuleTarget(value: unknown): ProxyGroupRuleTarget | null { + const ref = normalizeProxyGroupTargetRef(value); + if (ref) return ref; + return toTrimmedString(value); +} + function normalizeTemplate(value: unknown, fallback: TemplateType = "standard"): TemplateType { if (value === "minimal" || value === "standard" || value === "full") return value; return fallback; @@ -78,7 +88,7 @@ function normalizeCustomRules(value: unknown): CustomRule[] | undefined { if (typeof type !== "string" || !allowedTypes.has(type as CustomRule["type"])) continue; const ruleValue = toTrimmedString(item.value); - const target = toTrimmedString(item.target); + const target = normalizeRuleTarget(item.target); if (!ruleValue || !target) continue; const noResolve = typeof item.noResolve === "boolean" ? item.noResolve : undefined; @@ -131,8 +141,9 @@ function normalizeDialerProxyGroups(value: unknown): DialerProxyGroup[] { if (!isRecord(item)) continue; const id = toTrimmedString(item.id); const name = toTrimmedString(item.name); - const type = item.type === "select" || item.type === "url-test" ? item.type : null; + const type = isProxyGroupGroupType(item.type) ? item.type : null; if (!id || !name || !type) continue; + const strategy = isLoadBalanceStrategy(item.strategy) ? item.strategy : undefined; const enabled = typeof item.enabled === "boolean" ? item.enabled : undefined; const relayNodes = normalizeStringArray(item.relayNodes); @@ -142,6 +153,7 @@ function normalizeDialerProxyGroups(value: unknown): DialerProxyGroup[] { id, name, type, + ...(type === "load-balance" ? { strategy: strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } : {}), relayNodes, targetNodes, ...(enabled !== undefined ? { enabled } : {}), @@ -178,82 +190,18 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { : DEFAULT_LOAD_BALANCE_STRATEGY : undefined; - out.push({ id, name, emoji, groupType, ...(strategy ? { strategy } : {}) }); - } - return out; -} - -function normalizeNodeRegions(value: unknown): NodeRegion[] { - const allowed: NodeRegion[] = [ - "us", - "hk", - "jp", - "sg", - "tw", - "kr", - "uk", - "de", - "fr", - "ca", - "au", - "other", - ]; - const allowedSet = new Set(allowed); - const out: NodeRegion[] = []; - if (!Array.isArray(value)) return out; - for (const item of value) { - const s = toTrimmedString(item); - if (!s) continue; - const key = s.toLowerCase(); - if (!allowedSet.has(key)) continue; - out.push(key as NodeRegion); - } - return out; -} - -function normalizeFilteredProxyGroups(value: unknown): FilteredProxyGroup[] { - if (!Array.isArray(value)) return []; - const out: FilteredProxyGroup[] = []; - for (const item of value) { - if (!isRecord(item)) continue; - const id = toTrimmedString(item.id); - const name = toTrimmedString(item.name); - const enabled = typeof item.enabled === "boolean" ? item.enabled : null; - const groupTypeRaw = toTrimmedString(item.groupType) || "select"; - const groupType = - groupTypeRaw === "url-test" || - groupTypeRaw === "fallback" || - groupTypeRaw === "load-balance" || - groupTypeRaw === "direct-first" || - groupTypeRaw === "reject-first" - ? groupTypeRaw - : "select"; - const strategy = - groupType === "load-balance" - ? isLoadBalanceStrategy(item.strategy) - ? item.strategy - : DEFAULT_LOAD_BALANCE_STRATEGY - : undefined; - const sourceIds = normalizeStringArray(item.sourceIds); - const regions = normalizeNodeRegions(item.regions); - const includeRegex = toTrimmedString(item.includeRegex); - const excludeRegex = toTrimmedString(item.excludeRegex); - const excludedNodeNames = normalizeStringArray(item.excludedNodeNames); - - if (!id || !name || enabled === null) continue; - + const enabled = item.enabled === false ? false : undefined; + const description = toTrimmedString(item.description); + const advanced = normalizeProxyGroupAdvancedConfig(item.advanced); out.push({ id, name, - enabled, + emoji, + ...(enabled === false ? { enabled: false } : {}), + ...(description ? { description } : {}), groupType, ...(strategy ? { strategy } : {}), - sourceIds, - regions, - excludedNodeNames, - ...(includeRegex ? { includeRegex } : {}), - ...(excludeRegex ? { excludeRegex } : {}), - ...(toTrimmedString(item.emoji) ? { emoji: toTrimmedString(item.emoji) as string } : {}), + ...(Object.keys(advanced).length > 0 ? { advanced } : {}), }); } return out; @@ -277,12 +225,13 @@ export function getEffectiveTestOptions(config: Record): { test } export function buildGenerateOptionsFromConfig( - config: Record, + rawConfig: Record, opts: { nodes: ParsedNode[]; proxyProviders?: Record; } ): GenerateOptions { + const config = migrateFilteredProxyGroupsConfig(rawConfig); const { testUrl, testInterval } = getEffectiveTestOptions(config); const proxyProviders = opts.proxyProviders ?? buildProxyProvidersFromConfig(config, { testUrl, testInterval }); @@ -316,6 +265,7 @@ export function buildGenerateOptionsFromConfig( const listenerPorts = normalizeListenerPorts(config.listenerPorts); const ruleOrder = normalizePersistedRuleOrder({ enabledModules: enabledGroups || [], + customProxyGroups, customRules: customRules || [], customRuleSets, builtinRuleEdits, @@ -343,9 +293,15 @@ export function buildGenerateOptionsFromConfig( }; const dialerProxyGroups = normalizeDialerProxyGroups(config.dialerProxyGroups); - const filteredProxyGroups = normalizeFilteredProxyGroups(config.filteredProxyGroups); const proxyGroupOrder = normalizeProxyGroupOrder(config.proxyGroupOrder); const sanitizedNodes = stripImportedNodeControlFieldsFromList(opts.nodes); + const proxyGroupAdvanced = isRecord(config.proxyGroupAdvanced) + ? Object.fromEntries( + Object.entries(config.proxyGroupAdvanced) + .map(([id, value]) => [id.trim(), normalizeProxyGroupAdvancedConfig(value)] as const) + .filter(([id, advanced]) => id && Object.keys(advanced).length > 0), + ) + : undefined; return { nodes: sanitizedNodes, @@ -355,7 +311,7 @@ export function buildGenerateOptionsFromConfig( ...(dialerProxyGroups.length > 0 ? { dialerProxyGroups } : {}), ...(customProxyGroups.length > 0 ? { customProxyGroups } : {}), ...(customRuleSets.length > 0 ? { customRuleSets } : {}), - ...(filteredProxyGroups.length > 0 ? { filteredProxyGroups } : {}), + ...(proxyGroupAdvanced && Object.keys(proxyGroupAdvanced).length > 0 ? { proxyGroupAdvanced } : {}), ...(Object.keys(builtinRuleEdits).length > 0 ? { builtinRuleEdits } : {}), ...(proxyGroupNameOverrides ? { proxyGroupNameOverrides } : {}), ...(proxyGroupOrder ? { proxyGroupOrder } : {}), diff --git a/packages/core/src/templates/config-template.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index 7d393a7..6ea5a9f 100644 --- a/packages/core/src/templates/config-template.fields.test.ts +++ b/packages/core/src/templates/config-template.fields.test.ts @@ -41,7 +41,7 @@ describe("validateSubBoostTemplateConfig field validation", () => { { id: "relay", name: "Relay", - type: "fallback" as never, + type: "bad" as never, relayNodes: [], targetNodes: [], }, @@ -49,6 +49,24 @@ describe("validateSubBoostTemplateConfig field validation", () => { }) ) ).toEqual({ ok: false, error: "dialerProxyGroups.type 无效" }); + const validDialerGroupType = validateSubBoostTemplateConfig( + validConfig({ + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "load-balance", + strategy: "round-robin", + relayNodes: ["Relay A"], + targetNodes: ["Target A"], + }, + ], + }) + ); + expect(validDialerGroupType.ok && validDialerGroupType.config.dialerProxyGroups[0]).toMatchObject({ + type: "load-balance", + strategy: "round-robin", + }); expectInvalid( { dialerProxyGroups: [ diff --git a/packages/core/src/templates/config-template.groups.test.ts b/packages/core/src/templates/config-template.groups.test.ts index dc081b2..71c1938 100644 --- a/packages/core/src/templates/config-template.groups.test.ts +++ b/packages/core/src/templates/config-template.groups.test.ts @@ -119,72 +119,14 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { }, "customProxyGroups.groupType 无效" ); - expectInvalid({ filteredProxyGroups: "bad" as never }, "filteredProxyGroups 必须是数组"); - expectInvalid({ filteredProxyGroups: [1 as never] }, "filteredProxyGroups 只能包含对象"); + expectInvalid({ proxyGroupAdvanced: [] as never }, "proxyGroupAdvanced 必须是对象"); expectInvalid( { - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: "yes" as never, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], - }, - "filteredProxyGroups.enabled 必须是布尔值" - ); - expectInvalid( - { - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "bad" as never, - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], + proxyGroupAdvanced: { + missing: {}, + }, }, - "filteredProxyGroups.groupType 无效" - ); - expectInvalid( - { - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "select", - sourceIds: [1 as never], - regions: [], - excludedNodeNames: [], - }, - ], - }, - "filteredProxyGroups.sourceIds 只能包含字符串" - ); - expectInvalid( - { - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "load-balance", - strategy: "bad" as never, - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], - }, - "filteredProxyGroups.strategy 无效" + "proxyGroupAdvanced 包含未知代理组" ); expect( @@ -208,17 +150,32 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { validConfig({ filteredProxyGroups: [ { - id: "filtered", - name: "Filtered", + id: "legacy", + name: "Legacy", enabled: true, groupType: "select", - sourceIds: [], - regions: ["moon" as never], - excludedNodeNames: [], + sourceIds: ["source-a"], + regions: ["moon" as never, "hk"], + excludedNodeNames: ["Node A"], }, ], }) ) - ).toEqual({ ok: false, error: "filteredProxyGroups.regions 包含未知地区" }); + ).toMatchObject({ + ok: true, + config: { + customProxyGroups: [ + expect.objectContaining({ + id: "migrated-filtered-legacy", + name: "Legacy", + advanced: { + sourceIds: ["source-a"], + regions: ["hk"], + excludedMembers: [{ kind: "node", name: "Node A" }], + }, + }), + ], + }, + }); }); }); diff --git a/packages/core/src/templates/config-template.test-helpers.ts b/packages/core/src/templates/config-template.test-helpers.ts index 87c4f6f..fe0d11d 100644 --- a/packages/core/src/templates/config-template.test-helpers.ts +++ b/packages/core/src/templates/config-template.test-helpers.ts @@ -13,7 +13,7 @@ export function validConfig(patch: Partial & Record { }), ]) ); - expect(result.config.filteredProxyGroups?.[0]).toMatchObject({ - id: "filtered", + expect(result.config.customProxyGroups[1]).toMatchObject({ + id: "migrated-filtered-filtered", + name: "Filtered", groupType: "load-balance", strategy: DEFAULT_LOAD_BALANCE_STRATEGY, - sourceIds: ["source-a"], - regions: ["us", "hk"], - excludedNodeNames: ["Node A"], - includeRegex: "US", - excludeRegex: "IPv6", emoji: "F", + advanced: { + sourceIds: ["source-a"], + regions: ["us", "hk"], + excludedMembers: [{ kind: "node", name: "Node A" }], + includeRegex: "US", + excludeRegex: "IPv6", + }, }); expect(result.config.customRules[0]).toMatchObject({ id: "custom-rule-a", diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index ac5a4f8..6c9b726 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -1,6 +1,8 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRuleId, isCustomRuleType } from "@subboost/core/rules/custom-rule-utils"; +import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { isValidRuleSetPathOrUrl, normalizeRuleModelFromConfig, @@ -8,13 +10,14 @@ import { } from "@subboost/core/rules/rule-model"; import { DEFAULT_LOAD_BALANCE_STRATEGY, + isProxyGroupGroupType, isLoadBalanceStrategy, type CustomProxyGroup, type CustomRule, type LoadBalanceStrategy, + type ProxyGroupGroupType, type TemplateType, } from "@subboost/core/types/config"; -import type { FilteredProxyGroup, FilteredProxyGroupType, NodeRegion } from "@subboost/core/types/filtered-proxy-group"; import type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; export const SUBBOOST_TEMPLATE_CONFIG_SCHEMA = "subboost-template-config/v1"; @@ -27,15 +30,6 @@ const BUILTIN_MODULE_IDS = new Set(PROXY_GROUP_MODULES.map((module) => module.id const BUILTIN_RULE_KEYS = new Set( PROXY_GROUP_MODULES.flatMap((module) => module.rules.map((rule) => `module:${module.id}:${rule.id}`)) ); -const FILTERED_GROUP_TYPES = new Set([ - "select", - "url-test", - "fallback", - "load-balance", - "direct-first", - "reject-first", -]); -const NODE_REGIONS = new Set(["us", "hk", "jp", "sg", "tw", "kr", "uk", "de", "fr", "ca", "au", "other"]); const REMOVED_TEMPLATE_FIELDS = new Set([ "moduleRuleOverrides", "moduleRuleExclusions", @@ -43,61 +37,62 @@ const REMOVED_TEMPLATE_FIELDS = new Set([ ]); export function validateSubBoostTemplateConfig(value: unknown): ValidationResult { - if (!isRecord(value)) return invalid("模板配置必须是对象"); - if (value.schema !== SUBBOOST_TEMPLATE_CONFIG_SCHEMA) { + const migratedValue = migrateFilteredProxyGroupsConfig(value); + if (!isRecord(migratedValue)) return invalid("模板配置必须是对象"); + if (migratedValue.schema !== SUBBOOST_TEMPLATE_CONFIG_SCHEMA) { return invalid("模板配置 schema 无效"); } - const removedField = findRemovedTemplateField(value); + const removedField = findRemovedTemplateField(migratedValue); if (removedField) return invalid(`模板配置包含已移除字段: ${removedField}`); - const template = parseTemplateType(value.template); + const template = parseTemplateType(migratedValue.template); if (!template) return invalid("模板类型无效"); - const enabledProxyGroups = parseModuleIdArray(value.enabledProxyGroups, "enabledProxyGroups", { required: true }); + const enabledProxyGroups = parseModuleIdArray(migratedValue.enabledProxyGroups, "enabledProxyGroups", { required: true }); if (!enabledProxyGroups.ok) return enabledProxyGroups; if (enabledProxyGroups.value.length === 0) return invalid("至少需要一个代理组"); - const hiddenProxyGroups = parseModuleIdArray(value.hiddenProxyGroups, "hiddenProxyGroups", { required: false }); + const hiddenProxyGroups = parseModuleIdArray(migratedValue.hiddenProxyGroups, "hiddenProxyGroups", { required: false }); if (!hiddenProxyGroups.ok) return hiddenProxyGroups; const hiddenSet = new Set(hiddenProxyGroups.value); if (enabledProxyGroups.value.every((id) => hiddenSet.has(id))) { return invalid("至少需要一个可见代理组"); } - const customProxyGroups = parseCustomProxyGroups(value.customProxyGroups); + const customProxyGroups = parseCustomProxyGroups(migratedValue.customProxyGroups); if (!customProxyGroups.ok) return customProxyGroups; - const customRuleSets = parseCustomRuleSets(value.customRuleSets); + const proxyGroupAdvanced = parseProxyGroupAdvanced(migratedValue.proxyGroupAdvanced); + if (!proxyGroupAdvanced.ok) return proxyGroupAdvanced; + const customRuleSets = parseCustomRuleSets(migratedValue.customRuleSets); if (!customRuleSets.ok) return customRuleSets; - const builtinRuleEdits = parseBuiltinRuleEdits(value.builtinRuleEdits); + const builtinRuleEdits = parseBuiltinRuleEdits(migratedValue.builtinRuleEdits); if (!builtinRuleEdits.ok) return builtinRuleEdits; - const ruleModel = normalizeRuleModelFromConfig(value); - const filteredProxyGroups = parseFilteredProxyGroups(value.filteredProxyGroups); - if (!filteredProxyGroups.ok) return filteredProxyGroups; - const customRules = parseCustomRules(value.customRules); + const ruleModel = normalizeRuleModelFromConfig(migratedValue); + const customRules = parseCustomRules(migratedValue.customRules); if (!customRules.ok) return customRules; - const dialerProxyGroups = parseDialerProxyGroups(value.dialerProxyGroups); + const dialerProxyGroups = parseDialerProxyGroups(migratedValue.dialerProxyGroups); if (!dialerProxyGroups.ok) return dialerProxyGroups; - const proxyGroupNameOverrides = parseStringRecord(value.proxyGroupNameOverrides, "proxyGroupNameOverrides"); + const proxyGroupNameOverrides = parseStringRecord(migratedValue.proxyGroupNameOverrides, "proxyGroupNameOverrides"); if (!proxyGroupNameOverrides.ok) return proxyGroupNameOverrides; - const ruleOrder = parseOptionalStringArray(value.ruleOrder, "ruleOrder"); + const ruleOrder = parseOptionalStringArray(migratedValue.ruleOrder, "ruleOrder"); if (!ruleOrder.ok) return ruleOrder; - const dnsYaml = parseRequiredString(value.dnsYaml, "dnsYaml", { allowEmpty: true }); + const dnsYaml = parseRequiredString(migratedValue.dnsYaml, "dnsYaml", { allowEmpty: true }); if (!dnsYaml.ok) return dnsYaml; - const mixedPort = parsePort(value.mixedPort, "mixedPort"); + const mixedPort = parsePort(migratedValue.mixedPort, "mixedPort"); if (!mixedPort.ok) return mixedPort; - const allowLan = parseBoolean(value.allowLan, "allowLan"); + const allowLan = parseBoolean(migratedValue.allowLan, "allowLan"); if (!allowLan.ok) return allowLan; - const testUrl = parseHttpUrlString(value.testUrl, "testUrl"); + const testUrl = parseHttpUrlString(migratedValue.testUrl, "testUrl"); if (!testUrl.ok) return testUrl; - const testInterval = parsePositiveInteger(value.testInterval, "testInterval"); + const testInterval = parsePositiveInteger(migratedValue.testInterval, "testInterval"); if (!testInterval.ok) return testInterval; - const ruleProviderBaseUrl = parseHttpUrlString(value.ruleProviderBaseUrl, "ruleProviderBaseUrl"); + const ruleProviderBaseUrl = parseHttpUrlString(migratedValue.ruleProviderBaseUrl, "ruleProviderBaseUrl"); if (!ruleProviderBaseUrl.ok) return ruleProviderBaseUrl; - const cnIpNoResolve = parseOptionalBoolean(value.cnIpNoResolve, "cnIpNoResolve"); + const cnIpNoResolve = parseOptionalBoolean(migratedValue.cnIpNoResolve, "cnIpNoResolve"); if (!cnIpNoResolve.ok) return cnIpNoResolve; const experimentalCnUseCnRuleSet = parseOptionalBoolean( - value.experimentalCnUseCnRuleSet, + migratedValue.experimentalCnUseCnRuleSet, "experimentalCnUseCnRuleSet" ); if (!experimentalCnUseCnRuleSet.ok) return experimentalCnUseCnRuleSet; @@ -121,7 +116,7 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult enabledProxyGroups: enabledProxyGroups.value, hiddenProxyGroups: hiddenProxyGroups.value, customProxyGroups: customProxyGroups.value, - filteredProxyGroups: filteredProxyGroups.value, + proxyGroupAdvanced: proxyGroupAdvanced.value, customRuleSets: ruleModel.customRuleSets, builtinRuleEdits: ruleModel.builtinRuleEdits, customRules: customRules.value, @@ -315,23 +310,40 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG if (!name.ok) return name; const emoji = parseRequiredString(item.emoji, "customProxyGroups.emoji", { allowEmpty: true }); if (!emoji.ok) return emoji; - const groupType = parseFilteredGroupType(item.groupType, "customProxyGroups.groupType"); + const groupType = parseProxyGroupType(item.groupType, "customProxyGroups.groupType"); if (!groupType.ok) return groupType; const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "customProxyGroups.strategy"); if (!strategy.ok) return strategy; + const description = typeof item.description === "string" ? item.description.trim() : ""; out.push({ id: id.value, name: name.value, emoji: emoji.value, + ...(description ? { description } : {}), groupType: groupType.value, ...(groupType.value === "load-balance" ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } : {}), + advanced: normalizeProxyGroupAdvancedConfig(item.advanced), }); } return { ok: true, value: out }; } +function parseProxyGroupAdvanced( + value: unknown +): { ok: true; value: NonNullable } | { ok: false; error: string } { + if (value === undefined) return { ok: true, value: {} }; + if (!isRecord(value)) return invalid("proxyGroupAdvanced 必须是对象"); + const out: NonNullable = {}; + for (const [moduleId, rawConfig] of Object.entries(value)) { + const id = moduleId.trim(); + if (!BUILTIN_MODULE_IDS.has(id)) return invalid("proxyGroupAdvanced 包含未知代理组"); + out[id] = normalizeProxyGroupAdvancedConfig(rawConfig); + } + return { ok: true, value: out }; +} + function parseCustomRuleSets(value: unknown): { ok: true; value: true } | { ok: false; error: string } { if (value === undefined) return { ok: true, value: true }; if (!Array.isArray(value)) return invalid("customRuleSets 必须是数组"); @@ -371,51 +383,6 @@ function parseBuiltinRuleEdits(value: unknown): { ok: true; value: true } | { ok return { ok: true, value: true }; } -function parseFilteredProxyGroups(value: unknown): { ok: true; value: FilteredProxyGroup[] } | { ok: false; error: string } { - if (value === undefined) return { ok: true, value: [] }; - if (!Array.isArray(value)) return invalid("filteredProxyGroups 必须是数组"); - const out: FilteredProxyGroup[] = []; - for (const item of value) { - if (!isRecord(item)) return invalid("filteredProxyGroups 只能包含对象"); - const id = parseRequiredString(item.id, "filteredProxyGroups.id"); - if (!id.ok) return id; - const name = parseRequiredString(item.name, "filteredProxyGroups.name"); - if (!name.ok) return name; - const enabled = parseBoolean(item.enabled, "filteredProxyGroups.enabled"); - if (!enabled.ok) return enabled; - const groupType = parseFilteredGroupType(item.groupType ?? "select", "filteredProxyGroups.groupType"); - if (!groupType.ok) return groupType; - const sourceIds = parseStringArray(item.sourceIds ?? [], "filteredProxyGroups.sourceIds"); - if (!sourceIds.ok) return sourceIds; - const regions = parseNodeRegions(item.regions ?? []); - if (!regions.ok) return regions; - const excludedNodeNames = parseStringArray(item.excludedNodeNames ?? [], "filteredProxyGroups.excludedNodeNames"); - if (!excludedNodeNames.ok) return excludedNodeNames; - const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "filteredProxyGroups.strategy"); - if (!strategy.ok) return strategy; - out.push({ - id: id.value, - name: name.value, - enabled: enabled.value, - groupType: groupType.value, - ...(groupType.value === "load-balance" - ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : {}), - sourceIds: sourceIds.value, - regions: regions.value, - excludedNodeNames: excludedNodeNames.value, - ...(typeof item.includeRegex === "string" && item.includeRegex.trim() - ? { includeRegex: item.includeRegex.trim() } - : {}), - ...(typeof item.excludeRegex === "string" && item.excludeRegex.trim() - ? { excludeRegex: item.excludeRegex.trim() } - : {}), - ...(typeof item.emoji === "string" ? { emoji: item.emoji.trim() } : {}), - }); - } - return { ok: true, value: out }; -} - function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyGroup[] } | { ok: false; error: string } { if (!Array.isArray(value)) return invalid("dialerProxyGroups 必须是数组"); const out: DialerProxyGroup[] = []; @@ -425,7 +392,10 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG if (!id.ok) return id; const name = parseRequiredString(item.name, "dialerProxyGroups.name"); if (!name.ok) return name; - if (item.type !== "select" && item.type !== "url-test") return invalid("dialerProxyGroups.type 无效"); + const groupType = parseProxyGroupType(item.type, "dialerProxyGroups.type"); + if (!groupType.ok) return groupType; + const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "dialerProxyGroups.strategy"); + if (!strategy.ok) return strategy; const relayNodes = parseStringArray(item.relayNodes, "dialerProxyGroups.relayNodes"); if (!relayNodes.ok) return relayNodes; const targetNodes = parseStringArray(item.targetNodes, "dialerProxyGroups.targetNodes"); @@ -435,7 +405,10 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG out.push({ id: id.value, name: name.value, - type: item.type, + type: groupType.value, + ...(groupType.value === "load-balance" + ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : {}), relayNodes: relayNodes.value, targetNodes: targetNodes.value, ...(enabled.value !== undefined ? { enabled: enabled.value } : {}), @@ -444,12 +417,12 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG return { ok: true, value: out }; } -function parseFilteredGroupType( +function parseProxyGroupType( value: unknown, field: string -): { ok: true; value: FilteredProxyGroupType } | { ok: false; error: string } { - if (typeof value === "string" && FILTERED_GROUP_TYPES.has(value as FilteredProxyGroupType)) { - return { ok: true, value: value as FilteredProxyGroupType }; +): { ok: true; value: ProxyGroupGroupType } | { ok: false; error: string } { + if (isProxyGroupGroupType(value)) { + return { ok: true, value }; } return invalid(`${field} 无效`); } @@ -462,15 +435,3 @@ function parseOptionalLoadBalanceStrategy( if (!isLoadBalanceStrategy(value)) return invalid(`${field} 无效`); return { ok: true, value }; } - -function parseNodeRegions(value: unknown): { ok: true; value: NodeRegion[] } | { ok: false; error: string } { - const parsed = parseStringArray(value, "filteredProxyGroups.regions"); - if (!parsed.ok) return parsed; - const out: NodeRegion[] = []; - for (const region of parsed.value) { - const key = region.toLowerCase(); - if (!NODE_REGIONS.has(key as NodeRegion)) return invalid("filteredProxyGroups.regions 包含未知地区"); - out.push(key as NodeRegion); - } - return { ok: true, value: out }; -} diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index a988468..efb8f5b 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -190,23 +190,77 @@ export interface CustomRule { id: string; type: "DOMAIN" | "DOMAIN-SUFFIX" | "DOMAIN-KEYWORD" | "IP-CIDR" | "IP-CIDR6" | "GEOIP" | "GEOSITE" | "PROCESS-NAME" | "DST-PORT" | "SRC-PORT"; value: string; - target: string; + target: ProxyGroupRuleTarget; noResolve?: boolean; } export type RuleSetBehavior = "domain" | "ipcidr"; +export type NodeRegion = + | "us" + | "hk" + | "jp" + | "sg" + | "tw" + | "kr" + | "uk" + | "de" + | "fr" + | "ca" + | "au" + | "other"; + +export type ProxyGroupTargetRef = + | { kind: "module"; id: string } + | { kind: "custom"; id: string }; + +export type ProxyGroupRuleTarget = ProxyGroupTargetRef | string; + +export type ProxyGroupMemberRef = + | { kind: "node"; name: string } + | { kind: "module"; id: string } + | { kind: "custom"; id: string } + | { kind: "direct" } + | { kind: "reject" }; + +export const PROXY_GROUP_GROUP_TYPES = [ + "select", + "url-test", + "fallback", + "load-balance", + "direct-first", + "reject-first", +] as const; + +export type ProxyGroupGroupType = (typeof PROXY_GROUP_GROUP_TYPES)[number]; + +export function isProxyGroupGroupType(value: unknown): value is ProxyGroupGroupType { + return typeof value === "string" && (PROXY_GROUP_GROUP_TYPES as readonly string[]).includes(value); +} + +export interface ProxyGroupAdvancedConfig { + sourceIds?: string[]; + regions?: NodeRegion[]; + includeRegex?: string; + excludeRegex?: string; + groupType?: ProxyGroupGroupType; + strategy?: LoadBalanceStrategy; + extraMembers?: ProxyGroupMemberRef[]; + excludedMembers?: ProxyGroupMemberRef[]; + memberOrder?: ProxyGroupMemberRef[]; +} + export interface CustomRuleSet { id: string; name: string; behavior: RuleSetBehavior; path: string; - target: string; + target: ProxyGroupRuleTarget; noResolve?: boolean; } export type BuiltinRuleEdit = { - target?: string; + target?: ProxyGroupRuleTarget; enabled?: false; }; @@ -219,8 +273,11 @@ export interface CustomProxyGroup { id: string; name: string; emoji: string; - groupType: "select" | "url-test" | "fallback" | "load-balance" | "direct-first" | "reject-first"; + enabled?: boolean; + description?: string; + groupType: ProxyGroupGroupType; strategy?: LoadBalanceStrategy; + advanced?: ProxyGroupAdvancedConfig; } /** diff --git a/packages/core/src/types/filtered-proxy-group.ts b/packages/core/src/types/filtered-proxy-group.ts deleted file mode 100644 index 3ffd69b..0000000 --- a/packages/core/src/types/filtered-proxy-group.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { LoadBalanceStrategy } from "./config"; - -export type NodeRegion = - | "us" - | "hk" - | "jp" - | "sg" - | "tw" - | "kr" - | "uk" - | "de" - | "fr" - | "ca" - | "au" - | "other"; - -export type FilteredProxyGroupType = "select" | "url-test" | "fallback" | "load-balance" | "direct-first" | "reject-first"; - -export interface FilteredProxyGroup { - id: string; - // 仅用于 UI 展示(类似自定义分组的 emoji);不参与筛选逻辑 - emoji?: string; - name: string; - enabled: boolean; - groupType: FilteredProxyGroupType; - strategy?: LoadBalanceStrategy; - sourceIds: string[]; - regions: NodeRegion[]; - includeRegex?: string; - excludeRegex?: string; - // 按“当前展示名”精确排除的节点(仅作用于该筛选组) - excludedNodeNames?: string[]; -} diff --git a/packages/core/src/types/template-config.ts b/packages/core/src/types/template-config.ts index 7f6295d..e766234 100644 --- a/packages/core/src/types/template-config.ts +++ b/packages/core/src/types/template-config.ts @@ -1,5 +1,13 @@ -import type { BuiltinRuleEdits, CustomProxyGroup, CustomRule, CustomRuleSet, TemplateType } from "./config"; -import type { FilteredProxyGroup } from "./filtered-proxy-group"; +import type { + BuiltinRuleEdits, + CustomProxyGroup, + CustomRule, + CustomRuleSet, + LoadBalanceStrategy, + ProxyGroupAdvancedConfig, + ProxyGroupGroupType, + TemplateType, +} from "./config"; // 中转代理组(使用 dialer-proxy 语法) export interface DialerProxyGroup { @@ -7,7 +15,8 @@ export interface DialerProxyGroup { enabled?: boolean; // 默认启用;停用后不会写入配置 name: string; // 组名,如 "美国中转" relayNodes: string[]; // 用于中转的节点名称列表 - type: "select" | "url-test"; // 组类型 + type: ProxyGroupGroupType; // 组类型 + strategy?: LoadBalanceStrategy; // 负载均衡策略,仅 type=load-balance 时生效 targetNodes: string[]; // 使用此中转的落地节点名称列表 } @@ -17,7 +26,7 @@ export type SubBoostTemplateConfig = { enabledProxyGroups: string[]; hiddenProxyGroups?: string[]; customProxyGroups: CustomProxyGroup[]; - filteredProxyGroups?: FilteredProxyGroup[]; + proxyGroupAdvanced?: Record; customRuleSets: CustomRuleSet[]; builtinRuleEdits?: BuiltinRuleEdits; customRules: CustomRule[]; diff --git a/packages/ui/src/product/converter/advanced-mode/root.test.ts b/packages/ui/src/product/converter/advanced-mode/root.test.ts index 0befaa0..8daef50 100644 --- a/packages/ui/src/product/converter/advanced-mode/root.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/root.test.ts @@ -40,7 +40,6 @@ vi.mock("@subboost/ui/store/config-store", () => ({ vi.mock("./sections/input-section", () => ({ InputSection: section("input") })); vi.mock("./sections/node-management-section", () => ({ NodeManagementSection: section("filter") })); -vi.mock("./sections/filtered-proxy-groups-section", () => ({ FilteredProxyGroupsSection: section("filtered") })); vi.mock("./sections/dialer-proxy-groups-section", () => ({ DialerProxyGroupsSection: section("chain") })); vi.mock("./sections/proxy-groups-section", () => ({ ProxyGroupsSection: section("proxy") })); vi.mock("./sections/rules-management-section", () => ({ RulesManagementSection: section("rules") })); @@ -63,7 +62,6 @@ describe("AdvancedMode", () => { "chain", "dns", "filter", - "filtered", "input", "proxy", "rules", diff --git a/packages/ui/src/product/converter/advanced-mode/root.tsx b/packages/ui/src/product/converter/advanced-mode/root.tsx index 471f14d..a74cadf 100644 --- a/packages/ui/src/product/converter/advanced-mode/root.tsx +++ b/packages/ui/src/product/converter/advanced-mode/root.tsx @@ -3,17 +3,16 @@ import * as React from "react"; import { DialerProxyGroupsSection } from "./sections/dialer-proxy-groups-section"; import { DnsSection } from "./sections/dns-section"; -import { FilteredProxyGroupsSection } from "./sections/filtered-proxy-groups-section"; import { InputSection } from "./sections/input-section"; import { NodeManagementSection } from "./sections/node-management-section"; import { ProxyGroupsSection } from "./sections/proxy-groups-section"; import { RulesManagementSection } from "./sections/rules-management-section"; -type SectionKey = "input" | "filter" | "filtered" | "chain" | "proxy" | "rules" | "dns"; +type SectionKey = "input" | "filter" | "chain" | "proxy" | "rules" | "dns"; export function AdvancedMode() { const [expandedSections, setExpandedSections] = React.useState>( - new Set(["input", "filter", "filtered", "chain", "proxy", "rules", "dns"]) + new Set(["input", "filter", "chain", "proxy", "rules", "dns"]) ); const toggleSection = (section: SectionKey) => { @@ -32,10 +31,6 @@ export function AdvancedMode() { isExpanded={expandedSections.has("filter")} onToggle={() => toggleSection("filter")} /> - toggleSection("filtered")} - /> toggleSection("chain")} /> toggleSection("proxy")} /> ({ Plus: () => null, Search: () => null, Shuffle: () => null, + SlidersHorizontal: () => null, Trash2: () => null, X: () => null, })); @@ -76,6 +77,24 @@ vi.mock("@subboost/ui/components/ui/button", () => ({ return null; }, })); +vi.mock("@subboost/ui/components/ui/dropdown-menu", () => ({ + DropdownMenu: (props: any) => props.children, + DropdownMenuTrigger: (props: any) => props.children, + DropdownMenuContent: (props: any) => { + mocks.captures.dropdownContents.push(props); + return props.children; + }, + DropdownMenuItem: (props: any) => { + mocks.captures.menuItems.push(props); + return props.children; + }, + DropdownMenuSub: (props: any) => props.children, + DropdownMenuSubContent: (props: any) => props.children, + DropdownMenuSubTrigger: (props: any) => { + mocks.captures.menuItems.push(props); + return props.children; + }, +})); vi.mock("@subboost/ui/components/ui/input", () => ({ Input: (props: any) => { mocks.captures.inputs.push(props); @@ -152,6 +171,8 @@ function renderSection(overrides: Record = {}, props = { isExpa mocks.captures.buttons = []; mocks.captures.inputs = []; mocks.captures.switches = []; + mocks.captures.menuItems = []; + mocks.captures.dropdownContents = []; mocks.captures.intrinsics = []; try { const html = renderToStaticMarkup(React.createElement(DialerProxyGroupsSection, props)); @@ -183,15 +204,10 @@ function findIntrinsics(type: string, predicate: (props: any) => boolean) { describe("DialerProxyGroupsSection", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.captures = { buttons: [], inputs: [], switches: [], intrinsics: [] }; + mocks.captures = { buttons: [], dropdownContents: [], inputs: [], menuItems: [], switches: [], intrinsics: [] }; mocks.store = { nodes, dialerProxyGroups: [groupA, groupB], - filteredProxyGroups: [ - { name: "Filtered A", enabled: true }, - { name: "Filtered Disabled", enabled: false }, - { name: " ", enabled: true }, - ], customProxyGroups: [{ name: "Custom" }], proxyGroupNameOverrides: { auto: "Auto Override" }, addDialerProxyGroup: vi.fn(), @@ -256,12 +272,6 @@ describe("DialerProxyGroupsSection", () => { { name: "" }, { name: 123 }, ]; - mocks.store.filteredProxyGroups = [ - null, - { name: 123, enabled: true }, - { name: " ", enabled: true }, - { name: "Filtered A", enabled: true }, - ]; mocks.store.dialerProxyGroups = [ { ...groupA, name: " " }, { ...groupB, name: 42 }, @@ -334,6 +344,21 @@ describe("DialerProxyGroupsSection", () => { expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { enabled: false }); mocks.captures.switches[0].onClick({ stopPropagation: vi.fn() }); + const groupTypeButton = mocks.captures.buttons.find((props: any) => props["aria-label"] === "修改 Group A 类型"); + expect(groupTypeButton).toEqual(expect.objectContaining({ title: "类型:手动选择" })); + groupTypeButton.onClick({ stopPropagation: vi.fn() }); + const autoTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("自动测速")); + autoTypeItem.onSelect(); + expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "url-test", strategy: undefined }); + + const fallbackTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("故障切换")); + fallbackTypeItem.onSelect(); + expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "fallback", strategy: undefined }); + + const roundRobinTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("轮询均摊")); + roundRobinTypeItem.onSelect(); + expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "load-balance", strategy: "round-robin" }); + mocks.captures.switches[1].onCheckedChange(true); expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-b", { enabled: true, @@ -354,7 +379,7 @@ describe("DialerProxyGroupsSection", () => { it("enables disabled groups without conflicts and with relay-only cleanup", () => { mocks.store.dialerProxyGroups = [ - { ...groupA, relayNodes: ["Filtered A"], targetNodes: ["Alpha"] }, + { ...groupA, relayNodes: ["Custom"], targetNodes: ["Alpha"] }, { ...groupB, enabled: false, relayNodes: ["DIRECT", "Gamma"], targetNodes: ["Beta"] }, ]; renderSection(); @@ -397,7 +422,7 @@ describe("DialerProxyGroupsSection", () => { const relayInput = mocks.captures.inputs.find((props: any) => props.placeholder === "搜索中转节点..."); const targetInput = mocks.captures.inputs.find((props: any) => props.placeholder === "搜索落地节点..."); - relayInput.onChange({ target: { value: "filtered" } }); + relayInput.onChange({ target: { value: "custom" } }); targetInput.onChange({ target: { value: "beta" } }); relayInput.onClick({ stopPropagation: vi.fn() }); targetInput.onClick({ stopPropagation: vi.fn() }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx index be2df04..1e74f58 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx @@ -1,12 +1,13 @@ "use client"; import * as React from "react"; -import { Check, ChevronDown, ChevronRight, Link as LinkIcon, Pencil, Plus, Search, Trash2, X } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, Link as LinkIcon, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; import { Badge } from "@subboost/ui/components/ui/badge"; import { Button } from "@subboost/ui/components/ui/button"; import { Input } from "@subboost/ui/components/ui/input"; import { Switch } from "@subboost/ui/components/ui/switch"; import { toast } from "@subboost/ui/components/ui/toaster"; +import { DEFAULT_LOAD_BALANCE_STRATEGY, type ProxyGroupGroupType } from "@subboost/core/types/config"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { cn } from "@subboost/ui/lib/utils"; @@ -20,6 +21,11 @@ import { type ProxyGroupNameDraft, } from "./proxy-group-name-editor"; import { ProxyGroupSummary } from "./proxy-group-summary"; +import { + getLoadBalanceStrategyLabel, + getProxyGroupTypeLabel, + ProxyGroupTypeMenu, +} from "./proxy-group-type-menu"; type DialerSelectableNode = { name: string; @@ -39,7 +45,6 @@ export function DialerProxyGroupsSection({ const { nodes, dialerProxyGroups, - filteredProxyGroups, customProxyGroups, proxyGroupNameOverrides, addDialerProxyGroup, @@ -89,16 +94,12 @@ export function DialerProxyGroupsSection({ const name = typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); } - for (const g of filteredProxyGroups) { - const name = g && typeof g.name === "string" ? g.name.trim() : ""; - if (name) names.push(name); - } for (const g of dialerProxyGroups) { const name = g && typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); } return names; - }, [customProxyGroups, dialerProxyGroups, filteredProxyGroups, resolveModuleFullName]); + }, [customProxyGroups, dialerProxyGroups, resolveModuleFullName]); const handleAddDialerGroup = (name: string) => { const nextName = name.trim(); @@ -139,14 +140,27 @@ export function DialerProxyGroupsSection({ type: n.type, })) as DialerSelectableNode[]; - const availableFilteredGroups = filteredProxyGroups - .filter((g) => g && g.enabled && typeof g.name === "string" && g.name.trim()) - .map((g) => ({ name: g.name.trim(), type: "筛选组" } as DialerSelectableNode)); + const availableProxyGroups = [ + ...PROXY_GROUP_MODULES.map( + (module) => + ({ + name: resolveModuleFullName(module), + type: "内置组", + }) as DialerSelectableNode, + ), + ...customProxyGroups + .filter((group) => group.enabled !== false) + .map((group) => ({ + name: typeof group.name === "string" ? group.name.trim() : "", + type: "自定义组", + })) + .filter((group) => group.name), + ]; // 中转组允许选择 DIRECT(直连)作为“入口” // 注意:这里只用于 dialer-proxy 的代理组 proxies 字段,Clash/Mihomo 支持 DIRECT。 // excludeGroupId 用于在该组停用时仍保留“自身 targetNodes 不可作为中转节点”的约束 - return [DIRECT_RELAY_OPTION, ...availableFilteredGroups, ...available]; + return [DIRECT_RELAY_OPTION, ...available, ...availableProxyGroups]; }; return ( @@ -177,6 +191,12 @@ export function DialerProxyGroupsSection({ {dialerProxyGroups.map((group) => { const isEnabled = group.enabled !== false; const isEditing = editingDialerGroupId === group.id; + const dialerGroupType: ProxyGroupGroupType = group.type; + const dialerStrategy = group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY; + const dialerTypeLabel = + dialerGroupType === "load-balance" + ? `${getProxyGroupTypeLabel(dialerGroupType)} / ${getLoadBalanceStrategyLabel(dialerStrategy)}` + : getProxyGroupTypeLabel(dialerGroupType); const relaySearchKeyword = (relaySearchByGroupId[group.id] ?? "").trim().toLowerCase(); const targetSearchKeyword = (targetSearchByGroupId[group.id] ?? "").trim().toLowerCase(); const availableRelayNodes = getAvailableRelayNodes(group.id); @@ -305,7 +325,7 @@ export function DialerProxyGroupsSection({ ); const nextRelayNodes = group.relayNodes.filter((n) => { if (n === "DIRECT") return true; - if (!nodeNameSet.has(n)) return true; // 筛选组等 + if (!nodeNameSet.has(n)) return true; // 代理组等 return !otherTargets.has(n); }); @@ -333,6 +353,32 @@ export function DialerProxyGroupsSection({ }} onClick={(e) => e.stopPropagation()} /> + + updateDialerProxyGroup(group.id, { + type: groupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }) + } + trigger={ + + } + /> -
- 0 ? `${selectedSourceCount} 源` : "全部源", tone: "info" }, - { - label: group.regions.length > 0 ? `${group.regions.length} 地区` : "全部地区", - tone: "accent", - }, - { label: `${matchedNodes.length} 节点`, tone: "success" }, - ]} - /> -
- )} - - {isEditing ? ( -
e.stopPropagation()}> - - -
- ) : ( -
e.stopPropagation()} - > - { - const nextEnabled = Boolean(checked); - if (!nextEnabled) { - updateFilteredProxyGroup(group.id, { enabled: false }); - return; - } - const unique = makeUniqueFilteredGroupName(group.name, group.id); - updateFilteredProxyGroup(group.id, { enabled: true, name: unique }); - if (unique !== group.name) { - toast({ - title: "筛选组名称已自动调整以避免重复", - description: `已改为:${unique}`, - variant: "warning", - }); - } - }} - /> - -
- )} -
- - {expandedFilteredGroups.has(group.id) && ( -
-
-
-
导入源
-
- {sources.map((s, index) => { - const sourceLabel = buildSourceDisplayLabel({ - typeLabel: sourceTypeInfo[s.type]?.label ?? s.type, - tag: s.tag, - order: index + 1, - total: sources.length, - orderPlacement: "prefix", - }); - const checked = sourceIdSet.has(s.id); - - return ( - - ); - })} -
-
不选择表示匹配所有导入源
-
- -
-
地区
-
- {REGION_PRESETS.map((region) => { - const selected = group.regions.includes(region.id); - return ( - - ); - })} -
-
- 不选择表示匹配所有地区 -
-
- -
-
-
包含正则(可选)
- - updateFilteredProxyGroup(group.id, { includeRegex: e.target.value }) - } - placeholder="例如: (IEPL|专线|家宽)" - className="h-8 text-xs bg-white/10" - /> -
-
-
排除正则(可选)
- - updateFilteredProxyGroup(group.id, { excludeRegex: e.target.value }) - } - placeholder="例如: (测试|过期)" - className="h-8 text-xs bg-white/10" - /> -
-
-
类型
- - updateFilteredProxyGroup(group.id, { - groupType, - ...(groupType === "load-balance" - ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : { strategy: undefined }), - }) - } - triggerClassName="h-8 text-xs bg-white/10 border-white/10" - /> -
-
-
- - {manualRules.length > 0 && ( -
-
- - 手动添加规则 - - - 已添加 {manualRules.length} - -
-
- {manualRules.map((item) => ( - removeCustomRule(index)} - /> - ))} -
-
- )} - -
-
-
命中节点预览
-
- {matchedNodes.length} 个 -
-
- {matchedNodes.length === 0 ? ( -
暂无命中节点
- ) : ( -
-
- {matchedNodes.map((name) => ( - - {name} - - - ))} -
-
- )} - {excludedNodeNames.length > 0 && ( -
-
-
已排除节点(可恢复)
- -
-
-
- {excludedNodeNames.map((name) => ( - - {name} - - - ))} -
-
-
- )} -
-
- )} -
- ); - })} -
- )} - - - - )} - - ); -} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx b/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx index fe3e94e..9903d9d 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx @@ -116,7 +116,7 @@ export function InputSourceEditorDialog({
注意开启后:
  • 无法在预览中查看/管理该 url 的节点
  • -
  • 无法将这些节点用于中转代理组、筛选代理组等高级功能
  • +
  • 无法将这些节点用于中转代理组、分流组高级模式等高级功能
  • 节点命名模板与 tag 在该模式下不生效
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx new file mode 100644 index 0000000..2a50144 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx @@ -0,0 +1,494 @@ +"use client"; + +import * as React from "react"; +import { Plus, X } from "lucide-react"; +import { Badge } from "@subboost/ui/components/ui/badge"; +import { Button } from "@subboost/ui/components/ui/button"; +import { Input } from "@subboost/ui/components/ui/input"; +import { cn } from "@subboost/ui/lib/utils"; +import { PROXY_GROUP_MODULES, generateProxyGroups } from "@subboost/core/generator/proxy-groups"; +import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { REGION_PRESETS } from "@subboost/core/proxy-group-advanced"; +import { getProxyGroupMemberKey } from "@subboost/core/proxy-group-targets"; +import { getNodeSourceIds } from "@subboost/core/subscription/node-source-state"; +import { isSubscriptionInfoNodeName } from "@subboost/core/subscription/info-node-name"; +import type { + CustomProxyGroup, + NodeRegion, + ProxyGroupAdvancedConfig, + ProxyGroupMemberRef, +} from "@subboost/core/types/config"; +import type { ParsedNode } from "@subboost/core/types/node"; +import { useConfigStore } from "@subboost/ui/store/config-store"; + +type AdvancedTarget = { + kind: "module" | "custom"; + id: string; + name: string; +}; + +type ResolvedMember = { + key: string; + ref: ProxyGroupMemberRef; + name: string; + kind: ProxyGroupMemberRef["kind"]; +}; + +function normalizeList(value: readonly T[] | undefined): T[] { + return Array.isArray(value) ? [...value] : []; +} + +function memberLabel(member: ResolvedMember): string { + if (member.kind === "direct") return "DIRECT"; + if (member.kind === "reject") return "REJECT"; + return member.name; +} + +function memberKindLabel(member: ResolvedMember): string { + switch (member.kind) { + case "node": + return "节点"; + case "module": + return "内置组"; + case "custom": + return "自定义组"; + case "direct": + return "直连"; + case "reject": + return "拒绝"; + } +} + +function buildMemberFromName( + name: string, + options: { + nodes: ParsedNode[]; + moduleNames: Record; + customProxyGroups: CustomProxyGroup[]; + }, +): ResolvedMember | null { + const trimmed = name.trim(); + if (!trimmed) return null; + + let ref: ProxyGroupMemberRef | null = null; + if (trimmed === "DIRECT") ref = { kind: "direct" }; + else if (trimmed === "REJECT") ref = { kind: "reject" }; + else if (options.nodes.some((node) => node.name === trimmed)) ref = { kind: "node", name: trimmed }; + else { + const moduleEntry = Object.entries(options.moduleNames).find(([, moduleName]) => moduleName === trimmed); + const customEntry = options.customProxyGroups.find((group) => group.name === trimmed); + if (moduleEntry) ref = { kind: "module", id: moduleEntry[0] }; + else if (customEntry) ref = { kind: "custom", id: customEntry.id }; + } + + if (!ref) return null; + return { + key: getProxyGroupMemberKey(ref), + ref, + name: trimmed, + kind: ref.kind, + }; +} + +function toggleValue(list: readonly T[] | undefined, value: T): T[] { + const next = new Set(normalizeList(list)); + if (next.has(value)) next.delete(value); + else next.add(value); + return Array.from(next); +} + +function withoutMember(list: readonly ProxyGroupMemberRef[] | undefined, key: string): ProxyGroupMemberRef[] { + return normalizeList(list).filter((member) => getProxyGroupMemberKey(member) !== key); +} + +function withMember(list: readonly ProxyGroupMemberRef[] | undefined, member: ProxyGroupMemberRef): ProxyGroupMemberRef[] { + const key = getProxyGroupMemberKey(member); + return [...withoutMember(list, key), member]; +} + +const PROTECTED_INSERT_KEYS = new Set([ + "direct:DIRECT", + "reject:REJECT", + "module:auto", + "module:select", +]); + +function insertMemberAfterProtected( + currentMembers: ResolvedMember[], + member: ProxyGroupMemberRef, +): ProxyGroupMemberRef[] { + const key = getProxyGroupMemberKey(member); + const current = currentMembers + .map((item) => item.ref) + .filter((item) => getProxyGroupMemberKey(item) !== key); + let insertAt = 0; + current.forEach((item, index) => { + if (PROTECTED_INSERT_KEYS.has(getProxyGroupMemberKey(item))) { + insertAt = index + 1; + } + }); + return [...current.slice(0, insertAt), member, ...current.slice(insertAt)]; +} + +function CountBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function DragHandle() { + return ( + + {Array.from({ length: 6 }).map((_, index) => ( + + ))} + + ); +} + +const ADVANCED_PANEL_TITLE_CLASS = "mb-2 block text-[11px] font-medium text-white/50"; +const ADVANCED_PANEL_TITLE_ROW_CLASS = "mb-2 flex min-h-5 items-center gap-2"; + +export function ProxyGroupAdvancedPanel({ + target, + advanced, + onChange, + rulesCount, + rulesContent, +}: { + target: AdvancedTarget; + advanced: ProxyGroupAdvancedConfig; + onChange: (patch: Partial) => void; + rulesCount: number; + rulesContent: React.ReactNode; +}) { + const { + nodes, + sources, + enabledProxyGroups, + customProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + testUrl, + testInterval, + ruleProviderBaseUrl, + } = useConfigStore(); + const [draggingKey, setDraggingKey] = React.useState(null); + const activeCustomProxyGroups = React.useMemo( + () => customProxyGroups.filter((group) => group.enabled !== false), + [customProxyGroups], + ); + + const activeNodes = React.useMemo( + () => nodes.filter((node) => !isSubscriptionInfoNodeName(node.name)), + [nodes], + ); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); + + const generatedProxyNames = React.useMemo(() => { + if (nodes.length === 0) return []; + const generated = generateProxyGroups({ + nodes, + enabledModules: enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + customProxyGroups: activeCustomProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + }); + return generated.find((group) => group.name === target.name)?.proxies ?? []; + }, [ + nodes, + enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + activeCustomProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + target.name, + ]); + + const candidateMembers = React.useMemo(() => { + const rawNames = [ + "DIRECT", + "REJECT", + ...activeNodes.map((node) => node.name), + ...PROXY_GROUP_MODULES.filter((module) => enabledProxyGroups.includes(module.id)).map((module) => moduleNames[module.id]), + ...activeCustomProxyGroups.map((group) => group.name), + ]; + const out: ResolvedMember[] = []; + const seen = new Set(); + for (const rawName of rawNames) { + if (typeof rawName !== "string" || !rawName.trim()) continue; + const member = buildMemberFromName(rawName, { nodes: activeNodes, moduleNames, customProxyGroups: activeCustomProxyGroups }); + if (!member || seen.has(member.key)) continue; + if (member.key === `${target.kind}:${target.id}`) continue; + seen.add(member.key); + out.push(member); + } + return out; + }, [activeCustomProxyGroups, activeNodes, enabledProxyGroups, moduleNames, target.id, target.kind]); + + const includedMembers = React.useMemo(() => { + const out: ResolvedMember[] = []; + const seen = new Set(); + for (const name of generatedProxyNames) { + const member = buildMemberFromName(name, { nodes: activeNodes, moduleNames, customProxyGroups: activeCustomProxyGroups }); + if (!member || seen.has(member.key)) continue; + seen.add(member.key); + out.push(member); + } + return out; + }, [activeCustomProxyGroups, activeNodes, generatedProxyNames, moduleNames]); + + const excludedMembers = React.useMemo(() => { + const included = new Set(includedMembers.map((member) => member.key)); + return candidateMembers.filter((member) => !included.has(member.key)); + }, [candidateMembers, includedMembers]); + + const sourceOptions = React.useMemo(() => { + const sourceIdsInNodes = new Set(); + for (const node of activeNodes) { + for (const id of getNodeSourceIds(node)) sourceIdsInNodes.add(id); + } + return sources + .filter((source) => sourceIdsInNodes.has(source.id)) + .map((source, index) => ({ + id: source.id, + label: source.tag?.trim() || source.lastParsedTag?.trim() || `#${index + 1} ${source.type === "url" ? "订阅链接" : source.type === "yaml" ? "YAML 配置" : "节点链接"}`, + })); + }, [activeNodes, sources]); + + const moveMember = React.useCallback( + (fromKey: string, toKey: string) => { + if (fromKey === toKey) return; + const current = includedMembers.map((member) => member.ref); + const from = current.findIndex((member) => getProxyGroupMemberKey(member) === fromKey); + const to = current.findIndex((member) => getProxyGroupMemberKey(member) === toKey); + if (from < 0 || to < 0) return; + const next = [...current]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + onChange({ memberOrder: next }); + }, + [includedMembers, onChange], + ); + + const sourceIds = normalizeList(advanced.sourceIds); + const regions = normalizeList(advanced.regions); + const extraRefs = normalizeList(advanced.extraMembers); + const excludedRefs = normalizeList(advanced.excludedMembers); + + const disableMember = React.useCallback( + (member: ResolvedMember) => { + onChange({ + extraMembers: withoutMember(extraRefs, member.key), + excludedMembers: withMember(excludedRefs, member.ref), + memberOrder: withoutMember(advanced.memberOrder, member.key), + }); + }, + [advanced.memberOrder, excludedRefs, extraRefs, onChange], + ); + + const enableMember = React.useCallback( + (member: ResolvedMember) => { + onChange({ + extraMembers: withMember(extraRefs, member.ref), + excludedMembers: withoutMember(excludedRefs, member.key), + memberOrder: insertMemberAfterProtected(includedMembers, member.ref), + }); + }, + [excludedRefs, extraRefs, includedMembers, onChange], + ); + + return ( +
+
+
+
导入源
+
+ {sourceOptions.length === 0 ? ( +
暂无可匹配的导入源
+ ) : ( + sourceOptions.map((source) => ( + + )) + )} +
+
不选择表示匹配所有导入源
+
+ +
+
地区
+
+ {REGION_PRESETS.map((region) => { + const active = regions.includes(region.id); + return ( + + ); + })} +
+
不选择表示匹配所有地区
+
+ +
+ + +
+
+ +
+ +
+
+
已启用节点
+ {includedMembers.length} 个 +
+ {includedMembers.length === 0 ? ( +
+ 暂无已启用的节点或代理组 +
+ ) : ( +
+ {includedMembers.map((member) => ( +
setDraggingKey(member.key)} + onDragOver={(event) => event.preventDefault()} + onDrop={() => { + if (draggingKey) moveMember(draggingKey, member.key); + setDraggingKey(null); + }} + onDragEnd={() => setDraggingKey(null)} + className={cn( + "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded border border-white/10 bg-white/[0.04] px-2 py-1.5 text-xs", + draggingKey === member.key && "opacity-50", + )} + > + + + +
+
+ {memberLabel(member)} +
+
{memberKindLabel(member)}
+
+ +
+ ))} +
+ )} + +
+
+
未启用节点
+ {excludedMembers.length} 个 +
+ {excludedMembers.length === 0 ? ( +
暂无未启用的节点或代理组
+ ) : ( +
+ {excludedMembers.map((member) => { + return ( + + ); + })} +
+ )} +
+
+ +
+ +
+
+
分流规则
+ + {rulesCount} 条 + +
+ {rulesContent} + {rulesCount === 0 && ( +
+ 还没有分流规则 +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts index 86f4c6b..f8f004f 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts @@ -51,7 +51,6 @@ const targets = [ { kind: "module", id: "auto", name: "Auto" }, { kind: "module", id: "fallback", name: "Fallback" }, { kind: "custom", id: "custom-1", name: "Custom" }, - { kind: "filtered", id: "filtered-1", name: "Filtered" }, ] as any[]; describe("proxy group rule row components", () => { @@ -63,7 +62,7 @@ describe("proxy group rule row components", () => { it("detects move targets and renders active, moved, and removed rows", () => { expect(isRuleSetMoveTarget(targets[0])).toBe(true); expect(isRuleSetMoveTarget(targets[2])).toBe(true); - expect(isRuleSetMoveTarget(targets[3])).toBe(false); + expect(isRuleSetMoveTarget({ kind: "filtered", id: "filtered-1", name: "Filtered" } as never)).toBe(false); const active = renderToStaticMarkup( React.createElement(ProxyGroupRuleRow, { @@ -175,7 +174,7 @@ describe("proxy group rule row components", () => { expect(html).toContain("example.com"); expect(html).toContain("DOMAIN-SUFFIX,example.com,Proxy,no-resolve"); - expect(html).toContain("手动"); + expect(html).toContain("自定义"); expect(captures.buttons.find((props) => props.title === "移动规则")).toBeTruthy(); captures.menuItems.find((props) => props.children === "Custom").onSelect(); expect(onMove).toHaveBeenCalledWith(item, targets[2]); @@ -191,16 +190,15 @@ describe("proxy group rule row components", () => { title: "移动规则集", ariaLabel: "移动规则集", targets: [targets[0], targets[2]], - kinds: ["module", "custom", "filtered"], + kinds: ["module", "custom"], currentTarget: { kind: "module", id: "auto", name: "Auto" }, onMove, }) ); - expect(html).toContain("内置分流组"); - expect(html).toContain("自定义分组"); - expect(html).toContain("筛选组"); - expect(html).toContain("暂无筛选组"); + expect(html).toContain("内置组"); + expect(html).toContain("自定义组"); + expect(html).not.toContain("筛选组"); expect(captures.menuItems.find((props) => props.children === "Auto").disabled).toBe(true); expect(captures.menuItems.find((props) => props.children === "Custom").disabled).toBe(false); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx index 8af0dc2..d61b724 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx @@ -39,15 +39,13 @@ type ProxyGroupRuleRowProps = { }; const targetKindLabels: Record = { - module: "内置分流组", - custom: "自定义分组", - filtered: "筛选组", + module: "内置组", + custom: "自定义组", }; const emptyTargetLabels: Record = { - module: "暂无内置分流组", - custom: "暂无自定义分组", - filtered: "暂无筛选组", + module: "暂无内置组", + custom: "暂无自定义组", }; export function ProxyGroupRuleRow({ @@ -64,11 +62,11 @@ export function ProxyGroupRuleRow({ return (
@@ -181,7 +179,7 @@ export function ProxyGroupManualRuleRow({ title="移动规则" ariaLabel={`移动 ${rule.value} 规则`} targets={targets} - kinds={["module", "custom", "filtered"]} + kinds={["module", "custom"]} currentTarget={{ name: currentTargetName }} onMove={(target) => onMove(item, target)} /> @@ -293,7 +291,7 @@ function RuleSourceBadge({ source }: { source: RuleSource }) { const label = { preset: "预设", custom: "自定义", - manual: "手动", + manual: "自定义", experimental: "实验性", }[source]; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts index 77c5438..c9f7d63 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts @@ -16,7 +16,7 @@ describe("proxy group rule targets", () => { expect(listCustomRulesForTarget(rules, " ")).toEqual([]); }); - it("builds enabled module, custom, and filtered targets", () => { + it("builds enabled module and custom targets", () => { const targets = buildManualRuleTargets({ enabledProxyGroups: ["auto", "load-balance"], hiddenProxyGroups: ["load-balance"], @@ -25,17 +25,12 @@ describe("proxy group rule targets", () => { { id: "", name: "Missing" }, { id: "custom-2", name: " " }, ], - filteredProxyGroups: [ - { id: "filtered-1", name: " Filtered ", enabled: true }, - { id: "filtered-2", name: "Disabled", enabled: false }, - ], proxyGroupNameOverrides: { auto: "Auto Override" }, } as any); expect(targets).toEqual([ { kind: "module", id: "auto", name: "⚡ Auto Override" }, { kind: "custom", id: "custom-1", name: "Custom" }, - { kind: "filtered", id: "filtered-1", name: "Filtered" }, ]); }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts index 5b303a6..0c66fac 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts @@ -1,9 +1,9 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -export type ProxyGroupRuleTargetKind = "module" | "custom" | "filtered"; +export type ProxyGroupRuleTargetKind = "module" | "custom"; export type ProxyGroupRuleTarget = { kind: ProxyGroupRuleTargetKind; @@ -19,26 +19,34 @@ export type CustomRuleListItem = { export function listCustomRulesForTarget( customRules: CustomRule[], targetName: string, + options?: { + moduleNames?: Record; + customProxyGroups?: CustomProxyGroup[]; + }, ): CustomRuleListItem[] { const normalizedTarget = targetName.trim(); if (!normalizedTarget) return []; return customRules .map((rule, index) => ({ rule, index })) - .filter(({ rule }) => rule.target.trim() === normalizedTarget); + .filter(({ rule }) => { + const target = resolveProxyGroupTargetName(rule.target, { + moduleNames: options?.moduleNames || {}, + customProxyGroups: options?.customProxyGroups || [], + }); + return target === normalizedTarget; + }); } export function buildManualRuleTargets({ enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, }: { enabledProxyGroups: string[]; hiddenProxyGroups?: string[]; customProxyGroups: CustomProxyGroup[]; - filteredProxyGroups: FilteredProxyGroup[]; proxyGroupNameOverrides?: Record; }): ProxyGroupRuleTarget[] { const hidden = new Set(hiddenProxyGroups || []); @@ -54,18 +62,11 @@ export function buildManualRuleTargets({ }); } - for (const group of customProxyGroups) { + for (const group of customProxyGroups.filter((item) => item.enabled !== false)) { const name = typeof group.name === "string" ? group.name.trim() : ""; if (!group.id || !name) continue; targets.push({ kind: "custom", id: group.id, name }); } - for (const group of filteredProxyGroups) { - if (!group?.enabled) continue; - const name = typeof group.name === "string" ? group.name.trim() : ""; - if (!group.id || !name) continue; - targets.push({ kind: "filtered", id: group.id, name }); - } - return targets; } diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts index a6c31d1..ea745e5 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts @@ -286,6 +286,7 @@ describe("ProxyGroupsAddedRuleSets", () => { }); expect(html).toContain("geosite/draft"); + expect(html).toContain("Target"); expect(html).not.toContain("geosite/draft.mrs"); expect(html).toContain(RULE_EDIT_PRIMARY_FIELD_CLASS); expect(html).toContain(RULE_EDIT_TRAILING_CONTROLS_CLASS); @@ -567,5 +568,6 @@ describe("ProxyGroupsAddedRuleSets", () => { expect(mocks.toast).toHaveBeenCalledWith( expect.objectContaining({ title: "规则集已存在", variant: "warning" }), ); + }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx index 93e5783..7f4743a 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx @@ -15,6 +15,7 @@ import { toast } from "@subboost/ui/components/ui/toaster"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { getModuleRuleOrderKey } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { collectCustomRoutingRuleSets, getRuleSetTargetValue, @@ -117,6 +118,17 @@ export function ProxyGroupsAddedRuleSets({ [customProxyGroups, proxyGroupNameOverrides, visibleProxyGroupModules], ); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); + React.useEffect(() => { if (!editingKey) return; if (visibleAddedRuleSets.some((item) => item.key === editingKey)) return; @@ -141,20 +153,38 @@ export function ProxyGroupsAddedRuleSets({ const builtinConflict = (proxyModule.rules ?? []).some((rule) => { if (rule.id !== item.id) return false; const edit = builtinRuleEdits?.[getModuleRuleOrderKey(proxyModule.id, rule.id)]; - return edit?.enabled !== false && (edit?.target || moduleName) === moduleName; + const editTarget = edit?.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups, + fallbackTarget: moduleName, + }) + : moduleName; + return edit?.enabled !== false && editTarget === moduleName; }); const customConflict = customRuleSets.some( - (ruleSet) => ruleSet.id === item.id && ruleSet.target === moduleName && item.target.value !== getRuleSetTargetValue(target) + (ruleSet) => + ruleSet.id === item.id && + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups, + }) === moduleName && + item.target.value !== getRuleSetTargetValue(target), ); return builtinConflict || customConflict; } const group = customProxyGroups.find((entry) => entry.id === target.id); if (!group) return true; + const targetName = group.name.trim(); + if (!targetName) return true; return customRuleSets.some( (ruleSet) => ruleSet.id === item.id && - ruleSet.target === group.name && + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups, + }) === targetName && item.target.value !== getRuleSetTargetValue(target), ); }, @@ -162,6 +192,7 @@ export function ProxyGroupsAddedRuleSets({ builtinRuleEdits, customProxyGroups, customRuleSets, + moduleNames, proxyGroupNameOverrides, visibleProxyGroupModules, ], 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 0bfff8e..14982dd 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 @@ -79,6 +79,7 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ { id: "auto", name: "Auto", category: "core" }, { id: "fallback", name: "Fallback", category: "core" }, ], + generateProxyGroups: vi.fn(() => []), })); vi.mock("@subboost/core/proxy-group-name", () => ({ resolveProxyGroupModuleName: (module: { name: string }, override?: string) => override || module.name, @@ -173,6 +174,9 @@ describe("ProxyGroupsCategories", () => { mocks.captures = { inputs: [], dropdownItems: [], moduleCards: [] }; mocks.store = { ruleProviderBaseUrl: "https://rules.example/base/", + nodes: [], + testUrl: "https://probe.example/204", + testInterval: 300, setRuleProviderBaseUrl: vi.fn(), cnIpNoResolve: false, setCnIpNoResolve: vi.fn(), @@ -199,8 +203,9 @@ describe("ProxyGroupsCategories", () => { setProxyGroupNameOverride: vi.fn(), clearProxyGroupNameOverride: vi.fn(), customProxyGroups: [{ id: "custom-1", name: "Custom" }], - filteredProxyGroups: [{ name: "Filtered", enabled: true }], dialerProxyGroups: [{ name: "Dialer" }], + proxyGroupAdvanced: {}, + updateProxyGroupAdvanced: vi.fn(), }; }); @@ -224,7 +229,7 @@ describe("ProxyGroupsCategories", () => { mocks.captures.dropdownItems[0].onClick(); expect(mocks.store.restoreHiddenProxyGroup).toHaveBeenCalledWith("fallback"); - expect(stateMock.setters[0]).toHaveBeenCalledWith(expect.any(Function)); + expect(stateMock.setters[1]).toHaveBeenCalledWith(expect.any(Function)); expect(mocks.captures.customRulesRendered).toBe(true); }); @@ -239,10 +244,10 @@ describe("ProxyGroupsCategories", () => { await card.onHide(); expect(mocks.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ confirmText: "删除" })); expect(mocks.store.hideProxyGroup).toHaveBeenCalledWith("auto"); - expect(setters[3]).toHaveBeenCalledWith(expect.any(Function)); + expect(setters[4]).toHaveBeenCalledWith(expect.any(Function)); card.onToggleRulesExpanded(); - expect(setters[3]).toHaveBeenCalledWith(expect.any(Function)); + expect(setters[4]).toHaveBeenCalledWith(expect.any(Function)); card.onAddRules([{ id: "rule-a" }]); expect(mocks.store.addModuleRules).toHaveBeenCalledWith("auto", [{ id: "rule-a" }]); card.onAddRulesToModule("fallback", [{ id: "rule-b" }]); @@ -270,28 +275,28 @@ describe("ProxyGroupsCategories", () => { }); it("renames modules and adds rules to custom groups with duplicate guards", () => { - const { setters } = renderCategories({ 1: "auto", 2: "Custom" }); + const { setters } = renderCategories({ 2: "auto", 3: "Custom" }); const card = mocks.captures.moduleCards[0]; card.onStartEditing(); - expect(setters[1]).toHaveBeenCalledWith("auto"); - expect(setters[2]).toHaveBeenCalledWith("Auto Override"); + expect(setters[2]).toHaveBeenCalledWith("auto"); + expect(setters[3]).toHaveBeenCalledWith("Auto Override"); card.onCommitEditing(); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "代理组名称已存在,请换一个名称。", variant: "warning" })); card.onCancelEditing(); - expect(setters[1]).toHaveBeenCalledWith(null); + expect(setters[2]).toHaveBeenCalledWith(null); - renderCategories({ 1: "auto", 2: "" }); + renderCategories({ 2: "auto", 3: "" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 1: "auto", 2: "Auto" }); + renderCategories({ 2: "auto", 3: "Auto" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 1: "auto", 2: "Unique" }); + renderCategories({ 2: "auto", 3: "Unique" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.setProxyGroupNameOverride).toHaveBeenCalledWith("auto", "Unique"); @@ -331,9 +336,8 @@ describe("ProxyGroupsCategories", () => { it("renders custom category and disabled non-core module branches", async () => { mocks.store.hiddenProxyGroups = []; mocks.store.enabledProxyGroups = []; - mocks.store.filteredProxyGroups = [{ name: "Filtered", enabled: false }, null, { name: " ", enabled: true }, { name: 123, enabled: true }]; mocks.store.dialerProxyGroups = [null, { name: " " }]; - const { setters } = renderCategories({ 0: new Set(["custom", "core"]) }); + const { setters } = renderCategories({ 1: new Set(["custom", "core"]) }); expect(mocks.captures.customPanelRendered).toBe(true); expect(mocks.captures.moduleCards).toHaveLength(2); @@ -349,17 +353,17 @@ describe("ProxyGroupsCategories", () => { }); it("toggles category expansion through the rendered category headers", () => { - const { tree, setters } = renderCategoryTree({ 0: new Set(["core"]) }); + const { tree, setters } = renderCategoryTree({ 1: new Set(["core"]) }); const categoryButtons = collectElements( tree, (element) => element.type === "button" && String(element.props.className || "").includes("w-full flex") ); categoryButtons[0].props.onClick(); - expect(setters[0]).toHaveBeenCalledWith(expect.any(Function)); - expect((setters[0] as any).lastValue.has("core")).toBe(false); + expect(setters[1]).toHaveBeenCalledWith(expect.any(Function)); + expect((setters[1] as any).lastValue.has("core")).toBe(false); categoryButtons[1].props.onClick(); - expect((setters[0] as any).lastValue.has("custom")).toBe(true); + expect((setters[1] 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 fb68a78..0195521 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 @@ -4,6 +4,7 @@ import * as React from "react"; import { ChevronDown, ChevronRight, RotateCcw } from "lucide-react"; import { Badge } from "@subboost/ui/components/ui/badge"; import { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog"; +import { Switch } from "@subboost/ui/components/ui/switch"; import { DropdownMenu, DropdownMenuContent, @@ -12,12 +13,18 @@ import { DropdownMenuTrigger, } from "@subboost/ui/components/ui/dropdown-menu"; import { toast } from "@subboost/ui/components/ui/toaster"; +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + type ProxyGroupGroupType, +} from "@subboost/core/types/config"; import { CATEGORY_INFO, PROXY_GROUP_MODULES, + generateProxyGroups, } from "@subboost/core/generator/proxy-groups"; import type { HiddenPresetRuleIds } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { useConfigStore, type RuleSetDraft } from "@subboost/ui/store/config-store"; import { buildManualRuleTargets, @@ -25,11 +32,18 @@ import { } from "./proxy-group-rule-targets"; 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"; +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"; + export function ProxyGroupsCategories() { const { ruleProviderBaseUrl, + nodes = [], + testUrl, + testInterval, cnIpNoResolve, setCnIpNoResolve, experimentalCnUseCnRuleSet, @@ -55,9 +69,11 @@ export function ProxyGroupsCategories() { setProxyGroupNameOverride, clearProxyGroupNameOverride, customProxyGroups = [], - filteredProxyGroups = [], + proxyGroupAdvanced = {}, + updateProxyGroupAdvanced, dialerProxyGroups = [], } = useConfigStore(); + const [advancedProxyGroupMode, setAdvancedProxyGroupMode] = React.useState(false); const [expandedCategories, setExpandedCategories] = React.useState< Set @@ -106,11 +122,6 @@ export function ProxyGroupsCategories() { for (const m of PROXY_GROUP_MODULES) names.push(resolveModuleDisplayName(m).full); for (const g of customProxyGroups) names.push(g.name); - for (const g of filteredProxyGroups) { - if (!g || !g.enabled) continue; - const name = typeof g.name === "string" ? g.name.trim() : ""; - if (name) names.push(name); - } for (const g of dialerProxyGroups) { const name = g && typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); @@ -119,7 +130,6 @@ export function ProxyGroupsCategories() { }, [ customProxyGroups, dialerProxyGroups, - filteredProxyGroups, resolveModuleDisplayName, ]); @@ -133,12 +143,47 @@ export function ProxyGroupsCategories() { } return grouped; }, [hiddenProxyGroups]); + const generatedProxyGroupNodeCounts = React.useMemo(() => { + if (nodes.length === 0) return new Map(); + const generated = generateProxyGroups({ + nodes, + enabledModules: enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + customProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + }); + return new Map( + generated.map((group) => [ + group.name, + Array.isArray(group.proxies) ? group.proxies.length : 0, + ]), + ); + }, [ + nodes, + enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + customProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + ]); const targetRuleView = React.useMemo(() => { const ruleSetsByTarget: Record = {}; const hiddenPresetRuleIds: HiddenPresetRuleIds = {}; const moduleNameToId = new Map(); + const moduleNames: Record = {}; for (const proxyModule of PROXY_GROUP_MODULES) { - moduleNameToId.set(resolveModuleDisplayName(proxyModule).full, proxyModule.id); + const name = resolveModuleDisplayName(proxyModule).full; + moduleNameToId.set(name, proxyModule.id); + moduleNames[proxyModule.id] = name; } const pushRuleSetForTarget = (moduleId: string, rule: RuleSetDraft) => { @@ -150,7 +195,11 @@ export function ProxyGroupsCategories() { }; for (const ruleSet of customRuleSets) { - const moduleId = moduleNameToId.get(ruleSet.target); + const targetName = resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups, + }); + const moduleId = moduleNameToId.get(targetName); if (!moduleId) continue; pushRuleSetForTarget(moduleId, { id: ruleSet.id, @@ -169,11 +218,18 @@ export function ProxyGroupsCategories() { const sourceRule = sourceModule?.rules?.find((rule) => rule.id === ruleId); if (!sourceModule || !sourceRule) continue; const defaultTarget = resolveModuleDisplayName(sourceModule).full; + const editTarget = edit.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups, + fallbackTarget: defaultTarget, + }) + : ""; if (edit.enabled === false) hidePresetRule(sourceModuleId, ruleId); - if (edit.target && edit.target !== defaultTarget) { + if (editTarget && editTarget !== defaultTarget) { hidePresetRule(sourceModuleId, ruleId); - const targetModuleId = moduleNameToId.get(edit.target); + const targetModuleId = moduleNameToId.get(editTarget); if (targetModuleId) { pushRuleSetForTarget(targetModuleId, { id: sourceRule.id, @@ -190,6 +246,7 @@ export function ProxyGroupsCategories() { }, [ builtinRuleEdits, customRuleSets, + customProxyGroups, resolveModuleDisplayName, ]); @@ -203,10 +260,9 @@ export function ProxyGroupsCategories() { enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, }), - [customProxyGroups, enabledProxyGroups, filteredProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], + [customProxyGroups, enabledProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], ); const getCategoryStats = (category: string) => { @@ -220,19 +276,34 @@ export function ProxyGroupsCategories() { return ( <> -
- -
- {ruleProviderBaseUrl} +
+
+
+ +
+
+ {ruleProviderBaseUrl} +
+
+
+
+ +
+
+ + {advancedProxyGroupMode ? "已开启" : "未开启"} + + +
-
- +
+ {hiddenModules.length > 0 && ( @@ -315,10 +386,21 @@ export function ProxyGroupsCategories() { const manualRules = listCustomRulesForTarget( customRules, display.full, + { + moduleNames: Object.fromEntries( + PROXY_GROUP_MODULES.map((item) => [ + item.id, + resolveModuleDisplayName(item).full, + ]), + ), + customProxyGroups, + }, ); const isRulesExpanded = expandedModuleRules.has( module.id, ); + const advancedConfig = proxyGroupAdvanced[module.id] || {}; + const effectiveGroupType = advancedConfig.groupType ?? module.groupType; const handleHideModule = async () => { const ok = await confirmDialog( @@ -496,11 +578,35 @@ export function ProxyGroupsCategories() { onChangeExperimentalCnUseCnRuleSet={ setExperimentalCnUseCnRuleSet } + groupType={effectiveGroupType} + strategy={advancedConfig.strategy} + onChangeGroupType={({ groupType, strategy }) => + updateProxyGroupAdvanced(module.id, { + groupType: groupType as ProxyGroupGroupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? advancedConfig.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }) + } + advancedMode={advancedProxyGroupMode} + nodeCount={generatedProxyGroupNodeCounts.get(display.full) ?? 0} + renderAdvancedContent={(rulesContent, rulesCount) => ( + updateProxyGroupAdvanced(module.id, patch)} + rulesCount={rulesCount} + rulesContent={rulesContent} + /> + )} /> ); }) ) : ( - + )}
)} 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 a73e403..b429876 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 @@ -172,7 +172,6 @@ describe("ProxyGroupsCustomGroupsPanel", () => { customRules: [{ id: "manual-1", target: "🧩 Custom" }], customProxyGroups: [customGroup, targetGroup], customRuleSets: [sourceRule], - filteredProxyGroups: [{ name: "Filtered", enabled: true }], dialerProxyGroups: [{ name: "Dialer" }], addCustomProxyGroup: vi.fn(), removeCustomProxyGroup: vi.fn(), @@ -187,24 +186,29 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }); it("adds groups, changes draft type, and rejects duplicates", () => { - const { setters } = renderPanel({ 1: { emoji: "🧩", name: "New" }, 2: "select", 3: "consistent-hashing" }); + const { setters } = renderPanel({ 1: { emoji: "🧩", name: "New" }, 2: "Useful group", 3: "select", 4: "consistent-hashing" }); const nameInput = mocks.captures.inputs.find((props: any) => props.placeholder === "自定义分组名称"); nameInput.onChange({ target: { value: "Typed" } }); expect(setters[1]).toHaveBeenCalledWith({ emoji: "🧩", name: "Typed" }); + const descriptionInput = mocks.captures.inputs.find((props: any) => props.placeholder === "描述文本(默认: 自定义代理组)"); + descriptionInput.onChange({ target: { value: "Next description" } }); + expect(setters[2]).toHaveBeenCalledWith("Next description"); mocks.captures.typeMenus[0].onChange({ groupType: "load-balance", strategy: "round-robin" }); - expect(setters[2]).toHaveBeenCalledWith("load-balance"); - expect(setters[3]).toHaveBeenCalledWith("round-robin"); + expect(setters[3]).toHaveBeenCalledWith("load-balance"); + expect(setters[4]).toHaveBeenCalledWith("round-robin"); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ name: "🧩 New", emoji: "🧩", + description: "Useful group", groupType: "select", }); expect(mocks.interactions.proxyGroupAdded).toHaveBeenCalledWith({ groupType: "select" }); expect(setters[1]).toHaveBeenCalledWith({ emoji: "🧩", name: "" }); + expect(setters[2]).toHaveBeenCalledWith(""); renderPanel({ 1: { emoji: "🧩", name: "Custom" } }); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); @@ -212,15 +216,19 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }); it("renames, removes, and changes existing group type", () => { - const { setters } = renderPanel({ 0: new Set(["custom-1"]), 4: "custom-1", 5: { emoji: "🧩", name: "Renamed" } }); + const { setters } = renderPanel({ 0: new Set(["custom-1"]), 5: "custom-1", 6: "🧩 Renamed", 7: "" }); const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); renameInput.onChange({ target: { value: "Typed Rename" } }); - expect(setters[5]).toHaveBeenCalledWith({ emoji: "🧩", name: "Typed Rename" }); + expect(setters[6]).toHaveBeenCalledWith("🧩 Typed Rename"); renameInput.onKeyDown({ key: "Enter" }); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { name: "🧩 Renamed", emoji: "🧩" }); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + name: "🧩 Renamed", + emoji: "🧩", + description: "", + }); renameInput.onKeyDown({ key: "Escape" }); - expect(setters[4]).toHaveBeenCalledWith(null); + expect(setters[5]).toHaveBeenCalledWith(null); renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.typeMenus[1].onChange({ groupType: "load-balance", strategy: "round-robin" }); @@ -235,6 +243,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { it("moves custom rule sets to custom groups or modules", () => { renderPanel({ 0: new Set(["custom-1"]) }); + expect(mocks.captures.moveMenus[0].kinds).toEqual(["module", "custom"]); mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-2", name: "Target" }); expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("custom-1", "rule-a", { @@ -260,6 +269,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { id: "fallback", name: "Fallback", }); + }); it("updates manual rules, deletes rule rows, and renders empty state", () => { @@ -280,7 +290,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { 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"]), 4: "custom-1", 5: { emoji: "🧩", name: "Target" } }); + const { setters } = renderPanel({ 0: new Set(["custom-1"]), 5: "custom-1", 6: "🧩 Target", 7: "" }); const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); renameInput.onKeyDown({ key: "Enter" }); @@ -290,23 +300,22 @@ describe("ProxyGroupsCustomGroupsPanel", () => { expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); const editButtons = mocks.captures.buttons.filter((props: any) => props.className === "h-7 px-2" && !props.title); - const stopConfirmClick = vi.fn(); - editButtons[0].onClick({ stopPropagation: stopConfirmClick }); - expect(stopConfirmClick).toHaveBeenCalled(); + editButtons[0].onClick(); + expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); - const stopCancelClick = vi.fn(); - editButtons[1].onClick({ stopPropagation: stopCancelClick }); - expect(stopCancelClick).toHaveBeenCalled(); - expect(setters[4]).toHaveBeenCalledWith(null); - expect(setters[5]).toHaveBeenCalledWith({ emoji: "🧩", name: "" }); + editButtons[1].onClick(); + expect(setters[5]).toHaveBeenCalledWith(null); + expect(setters[6]).toHaveBeenCalledWith(""); + expect(setters[7]).toHaveBeenCalledWith(""); renderPanel({ 0: new Set(["custom-1"]) }); const renameButton = mocks.captures.buttons.find((props: any) => props.title === "改名"); const stopRenameClick = vi.fn(); renameButton.onClick({ stopPropagation: stopRenameClick }); expect(stopRenameClick).toHaveBeenCalled(); - expect(stateMock.setters[4]).toHaveBeenCalledWith("custom-1"); - expect(stateMock.setters[5]).toHaveBeenCalledWith({ emoji: "🧩", name: "Custom" }); + expect(stateMock.setters[5]).toHaveBeenCalledWith("custom-1"); + expect(stateMock.setters[6]).toHaveBeenCalledWith("🧩 Custom"); + expect(stateMock.setters[7]).toHaveBeenCalledWith(""); mocks.captures.typeMenus[1].onChange({ groupType: "select" }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { @@ -359,18 +368,17 @@ describe("ProxyGroupsCustomGroupsPanel", () => { ]; mocks.store.customRuleSets = []; const result = renderPanel({ 0: new Set(["custom-1", "custom-2"]) }); - expect(result.html).toContain("type:load-balance / strategy:consistent-hashing"); expect(result.html).toContain("还没有规则集"); expect(mocks.captures.moveMenus[0]).toBeUndefined(); - mocks.store.filteredProxyGroups = [null, { name: "Disabled", enabled: false }, { name: " ", enabled: true }]; mocks.store.dialerProxyGroups = [null, { name: " " }]; - renderPanel({ 1: "Unique" }); + renderPanel({ 1: { emoji: "🧩", name: "Unique" } }); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ name: "🧩 Unique", emoji: "🧩", + description: "", groupType: "select", }); }); 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 31437be..7793285 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 @@ -1,12 +1,14 @@ "use client"; import * as React from "react"; -import { Check, ChevronDown, ChevronRight, Pencil, SlidersHorizontal, Trash2, X } from "lucide-react"; +import { Check, Trash2 } from "lucide-react"; import { Button } from "@subboost/ui/components/ui/button"; +import { Input } from "@subboost/ui/components/ui/input"; import { toast } from "@subboost/ui/components/ui/toaster"; -import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; +import { PROXY_GROUP_MODULES, type ProxyGroupModule } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import { DEFAULT_LOAD_BALANCE_STRATEGY, type LoadBalanceStrategy } from "@subboost/core/types/config"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; +import { DEFAULT_LOAD_BALANCE_STRATEGY, type LoadBalanceStrategy, type ProxyGroupGroupType } from "@subboost/core/types/config"; import { useConfigStore, type CustomProxyGroup } from "@subboost/ui/store/config-store"; import { useProductInteractionAdapter } from "@subboost/ui/product/interactions"; import { @@ -23,10 +25,9 @@ import { } from "./proxy-group-rule-row"; import { ProxyGroupTypeMenu, - getLoadBalanceStrategyLabel, - getProxyGroupTypeLabel, type ProxyGroupTypeMenuValue, } from "./proxy-group-type-menu"; +import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; import { buildProxyGroupName, parseProxyGroupNameDraft, @@ -34,8 +35,15 @@ import { toProxyGroupNameDraft, type ProxyGroupNameDraft, } from "./proxy-group-name-editor"; +import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; -export function ProxyGroupsCustomGroupsPanel() { +export function ProxyGroupsCustomGroupsPanel({ + advancedMode = false, + nodeCounts, +}: { + advancedMode?: boolean; + nodeCounts?: Map; +}) { const { enabledProxyGroups, hiddenProxyGroups, @@ -50,7 +58,6 @@ export function ProxyGroupsCustomGroupsPanel() { removeCustomRule, moveModuleRule, removeModuleRule, - filteredProxyGroups = [], dialerProxyGroups = [], } = useConfigStore(); @@ -59,14 +66,13 @@ export function ProxyGroupsCustomGroupsPanel() { emoji: "🧩", name: "", }); + const [newCustomGroupDescription, setNewCustomGroupDescription] = React.useState(""); const [newCustomGroupType, setNewCustomGroupType] = React.useState("select"); const [newCustomGroupStrategy, setNewCustomGroupStrategy] = React.useState(DEFAULT_LOAD_BALANCE_STRATEGY); const [editingCustomGroupId, setEditingCustomGroupId] = React.useState(null); - const [editingCustomGroupDraft, setEditingCustomGroupDraft] = React.useState({ - emoji: "🧩", - name: "", - }); + const [editingCustomGroupName, setEditingCustomGroupName] = React.useState(""); + const [editingCustomGroupDescription, setEditingCustomGroupDescription] = React.useState(""); const interactions = useProductInteractionAdapter(); const getAllGroupNamesForUniqCheck = React.useCallback(() => { @@ -77,18 +83,23 @@ export function ProxyGroupsCustomGroupsPanel() { for (const g of customProxyGroups) { names.push(g.name); } - for (const g of filteredProxyGroups) { - if (!g || !g.enabled) continue; - const name = typeof g.name === "string" ? g.name.trim() : ""; - if (!name) continue; - names.push(name); - } for (const g of dialerProxyGroups) { const name = g && typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); } return names; - }, [customProxyGroups, dialerProxyGroups, filteredProxyGroups, proxyGroupNameOverrides]); + }, [customProxyGroups, dialerProxyGroups, proxyGroupNameOverrides]); + + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); const manualRuleTargets = React.useMemo( () => @@ -96,10 +107,9 @@ export function ProxyGroupsCustomGroupsPanel() { enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, }), - [customProxyGroups, enabledProxyGroups, filteredProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], + [customProxyGroups, enabledProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], ); const ruleSetMoveTargets = React.useMemo(() => { @@ -110,7 +120,7 @@ export function ProxyGroupsCustomGroupsPanel() { id: module.id, name: resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), })), - ...customProxyGroups.map((group) => ({ + ...customProxyGroups.filter((group) => group.enabled !== false).map((group) => ({ kind: "custom" as const, id: group.id, name: group.name, @@ -132,14 +142,32 @@ export function ProxyGroupsCustomGroupsPanel() { const state = useConfigStore.getState(); const sourceGroup = state.customProxyGroups.find((group) => group.id === sourceGroupId); const sourceRule = sourceGroup - ? state.customRuleSets.find((rule) => rule.id === ruleId && rule.target === sourceGroup.name) + ? state.customRuleSets.find( + (rule) => + rule.id === ruleId && + resolveProxyGroupTargetName(rule.target, { + moduleNames, + customProxyGroups: state.customProxyGroups, + }) === sourceGroup.name, + ) : null; if (!sourceGroup || !sourceRule) return; if (target.kind === "custom") { - const targetGroup = state.customProxyGroups.find((group) => group.id === target.id); + const targetGroup = state.customProxyGroups.find((group) => group && group.id === target.id); if (!targetGroup) return; - if (state.customRuleSets.some((rule) => rule.id === sourceRule.id && rule.target === targetGroup.name)) { + const targetName = targetGroup.name.trim(); + if (!targetName) return; + if ( + state.customRuleSets.some( + (rule) => + rule.id === sourceRule.id && + resolveProxyGroupTargetName(rule.target, { + moduleNames, + customProxyGroups: state.customProxyGroups, + }) === targetName, + ) + ) { toast({ title: "规则集已存在", description: "目标分流组里已经有同名规则集,请先移除重复项。", @@ -151,18 +179,26 @@ export function ProxyGroupsCustomGroupsPanel() { moveModuleRule(sourceGroup.id, ruleId, target); }, - [moveModuleRule], + [moduleNames, moveModuleRule], ); return (
{/* 新建自定义分组 */}
- +
+ + setNewCustomGroupDescription(event.target.value)} + placeholder="描述文本(默认: 自定义代理组)" + className="h-7 min-w-0 border-white/10 bg-white/5 text-xs" + /> +
@@ -218,15 +256,20 @@ export function ProxyGroupsCustomGroupsPanel() { {customProxyGroups.map((group) => { const isExpanded = expandedCustomGroups.has(group.id); const isEditing = editingCustomGroupId === group.id; - const manualRules = listCustomRulesForTarget(customRules, group.name); - const groupRuleSets = customRuleSets.filter((ruleSet) => ruleSet.target === group.name); + const manualRules = listCustomRulesForTarget(customRules, group.name, { + moduleNames, + customProxyGroups, + }); + const groupRuleSets = customRuleSets.filter( + (ruleSet) => + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups, + }) === group.name, + ); const totalRules = groupRuleSets.length + manualRules.length; - const typeLabel = - group.groupType === "load-balance" - ? `${getProxyGroupTypeLabel(group.groupType)} / ${getLoadBalanceStrategyLabel( - group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, - )}` - : getProxyGroupTypeLabel(group.groupType); + const description = group.description?.trim() || "自定义代理组"; + const nodeCount = nodeCounts?.get(group.name) ?? 0; const toggleExpand = () => { setExpandedCustomGroups((prev) => { @@ -238,7 +281,7 @@ export function ProxyGroupsCustomGroupsPanel() { }; const commitCustomRename = () => { - const draft = toProxyGroupNameDraft(editingCustomGroupDraft); + const draft = parseProxyGroupNameDraft(editingCustomGroupName, group.emoji || "🧩"); const nextFull = buildProxyGroupName(draft); if (!nextFull) return; const emoji = draft.emoji.trim(); @@ -252,197 +295,166 @@ export function ProxyGroupsCustomGroupsPanel() { return; } - updateCustomProxyGroup(group.id, { name: nextFull, emoji }); + updateCustomProxyGroup(group.id, { + name: nextFull, + emoji, + description: editingCustomGroupDescription, + }); setEditingCustomGroupId(null); - setEditingCustomGroupDraft({ emoji: "🧩", name: "" }); + setEditingCustomGroupName(""); + setEditingCustomGroupDescription(""); }; - return ( -
-
{ - if (!isEditing) toggleExpand(); - }} - title={isExpanded ? "收起" : "展开"} - > - {isExpanded ? ( - - ) : ( - - )} -
- {isEditing ? ( -
- { - if (e.key === "Enter") commitCustomRename(); - if (e.key === "Escape") { - setEditingCustomGroupId(null); - setEditingCustomGroupDraft({ emoji: "🧩", name: "" }); - } - }} - /> - - -
- ) : ( - <> -
-
- {group.name} -
+ const rulesContent = + totalRules === 0 ? null : ( + <> + {groupRuleSets.map((r) => ( + + { + if (isRuleSetMoveTarget(target)) { + moveCustomGroupRuleSet(group.id, r.id, target); + } + }} + /> -
-
- {`${totalRules} 规则 · ${typeLabel}`} -
- - )} -
- - {!isEditing && ( -
-
e.stopPropagation()}> - - updateCustomProxyGroup(group.id, { - groupType: groupType as CustomProxyGroup["groupType"], - ...(groupType === "load-balance" - ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : { strategy: undefined }), - }) - } - contentAlign="end" - trigger={ - - } - /> -
- -
- )} -
+ + } + /> + ))} + {manualRules.map((item) => ( + removeCustomRule(index)} + /> + ))} + + ); + const cardModule: ProxyGroupModule = { + id: group.id, + name: group.name, + emoji: group.emoji || "🧩", + category: "other", + description, + groupType: group.groupType, + rules: [], + }; - {isExpanded && ( -
- {totalRules === 0 ? ( -

- 还没有规则集。可在“搜索规则库”中选择规则后添加到该分组。 -

- ) : ( - <> - {groupRuleSets.map((r) => ( - - { - if (isRuleSetMoveTarget(target)) { - moveCustomGroupRuleSet(group.id, r.id, target); - } - }} - /> - - - } - /> - ))} - {manualRules.map((item) => ( - removeCustomRule(index)} - /> - ))} - - )} -
+ return ( + updateCustomProxyGroup(group.id, { enabled: group.enabled === false })} + isEditing={isEditing} + editingName={editingCustomGroupName} + editingDescription={editingCustomGroupDescription} + onChangeEditingName={setEditingCustomGroupName} + onChangeEditingDescription={setEditingCustomGroupDescription} + onStartEditing={() => { + setEditingCustomGroupId(group.id); + setEditingCustomGroupName(group.name); + setEditingCustomGroupDescription(group.description ?? ""); + }} + onCancelEditing={() => { + setEditingCustomGroupId(null); + setEditingCustomGroupName(""); + setEditingCustomGroupDescription(""); + }} + onCommitEditing={commitCustomRename} + onHide={() => removeCustomProxyGroup(group.id)} + extraRules={[]} + ruleSetsByTarget={{}} + hiddenPresetRuleIds={{}} + customProxyGroups={customProxyGroups} + manualRules={manualRules} + manualRuleTargets={manualRuleTargets} + enabledProxyGroups={enabledProxyGroups} + hiddenProxyGroups={hiddenProxyGroups} + proxyGroupNameOverrides={proxyGroupNameOverrides} + moduleRuleEditWarningAccepted + acceptModuleRuleEditWarning={() => undefined} + isRulesExpanded={isExpanded} + onToggleRulesExpanded={toggleExpand} + onAddRules={() => undefined} + onAddRulesToModule={() => undefined} + onAddRuleToCustomGroup={() => undefined} + onRemoveExtraRule={() => undefined} + onMoveRule={() => undefined} + onMoveManualRule={(ruleId, targetName) => updateCustomRule(ruleId, { target: targetName })} + onRemoveManualRule={removeCustomRule} + onRestoreRule={() => undefined} + onResetRuleTarget={() => undefined} + cnIpNoResolve={false} + onChangeCnIpNoResolve={() => undefined} + experimentalCnUseCnRuleSet={false} + onChangeExperimentalCnUseCnRuleSet={() => undefined} + description={description} + groupType={group.groupType as ProxyGroupTypeMenuValue} + strategy={group.strategy} + onChangeGroupType={({ groupType, strategy }) => + updateCustomProxyGroup(group.id, { + groupType: groupType as ProxyGroupGroupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }) + } + rulesContentOverride={ + totalRules === 0 ? ( +

+ 还没有规则集。可在“搜索规则库”中选择规则后添加到该分组。 +

+ ) : ( +
{rulesContent}
+ ) + } + rulesCountOverride={totalRules} + advancedMode={advancedMode} + nodeCount={nodeCount} + renderAdvancedContent={(content, count) => ( + + updateCustomProxyGroup(group.id, { + advanced: { ...(group.advanced || {}), ...patch }, + }) + } + rulesCount={count} + rulesContent={count > 0 ? content : null} + /> )} -
+ /> ); })}
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx index 2ef808a..587d4c5 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx @@ -7,7 +7,9 @@ import { ProxyGroupsRulesLibrary } from "./proxy-groups-rules-library"; export function ProxyGroupsCustomRoutingRules() { return (
- +
+ +
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx index bfaf4ec..5147d6c 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx @@ -34,6 +34,11 @@ function getStatusTextClass(item: CustomRuleBatchImportPreviewItem): string { return "text-red-200"; } +function ruleTargetToText(target: CustomRule["target"]): string { + if (typeof target === "string") return target; + return `${target.kind}:${target.id}`; +} + export function ProxyGroupsCustomRulesBatchDialog({ open, onOpenChange, @@ -219,9 +224,9 @@ export function ProxyGroupsCustomRulesBatchDialog({ - {item.rule.target} + {ruleTargetToText(item.rule.target)}
) : ( diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts index e1a7cdc..a40f352 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts @@ -211,12 +211,6 @@ describe("ProxyGroupsCustomRules", () => { removeCustomRule: vi.fn(), enabledProxyGroups: ["auto"], customProxyGroups: [{ id: "custom-1", name: "Custom Group", rules: [] }], - filteredProxyGroups: [ - { name: " Filter Group ", enabled: true }, - { name: "Disabled", enabled: false }, - { name: " ", enabled: true }, - { name: 123, enabled: true }, - ], proxyGroupNameOverrides: { auto: "节点选择" }, }; }); @@ -278,7 +272,6 @@ describe("ProxyGroupsCustomRules", () => { "REJECT", "节点选择", "Custom Group", - "Filter Group", "Legacy Target", ], existingRules: [], @@ -452,7 +445,6 @@ describe("ProxyGroupsCustomRules", () => { "REJECT", "节点选择", "Custom Group", - "Filter Group", ]); const stale = renderRules( diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx index 2247382..9720d92 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx @@ -14,6 +14,7 @@ import { import { Switch } from "@subboost/ui/components/ui/switch"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { createCustomRuleId, CUSTOM_RULE_TYPES, @@ -96,7 +97,6 @@ export function ProxyGroupsCustomRules() { removeCustomRule, enabledProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, } = useConfigStore(); @@ -122,20 +122,33 @@ export function ProxyGroupsCustomRules() { for (const g of customProxyGroups) { names.push(g.name); } - for (const g of filteredProxyGroups) { - if (!g || !g.enabled) continue; - const name = typeof g.name === "string" ? g.name.trim() : ""; - if (!name) continue; - names.push(name); - } return names; }, [ customProxyGroups, enabledProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, ]); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); + + const resolveTargetName = React.useCallback( + (target: CustomRule["target"]) => + resolveProxyGroupTargetName(target, { + moduleNames, + customProxyGroups, + }), + [customProxyGroups, moduleNames], + ); + const targetOptions = React.useMemo( () => getTargetOptions(enabledGroupNames), [enabledGroupNames], @@ -179,7 +192,11 @@ export function ProxyGroupsCustomRules() { const startEditingRule = (rule: CustomRule) => { setEditingRuleId(rule.id); - setEditingRuleDraft({ ...rule, noResolve: Boolean(rule.noResolve) }); + setEditingRuleDraft({ + ...rule, + target: resolveTargetName(rule.target), + noResolve: Boolean(rule.noResolve), + }); }; const cancelEditingRule = () => { @@ -311,7 +328,7 @@ export function ProxyGroupsCustomRules() { if (isEditing) { const editTargetOptions = getTargetOptions( enabledGroupNames, - editingRuleDraft.target, + resolveTargetName(editingRuleDraft.target), ); return ( @@ -364,7 +381,7 @@ export function ProxyGroupsCustomRules() {
onChangeEditingDescription(event.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onCommitEditing(); + if (e.key === "Escape") onCancelEditing(); + }} + /> + )} +
@@ -277,6 +356,26 @@ export function ProxyGroupsModuleCard({ onCheckedChange={onToggleEnabled} onClick={(e) => e.stopPropagation()} /> + {onChangeGroupType && ( + + + + } + /> + )}
); } diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx index d8fec5c..41e1af8 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx @@ -46,6 +46,10 @@ type CnCandidateRule = { parentModuleId?: string; }; +function isModuleRuleMoveTarget(target: ProxyGroupRuleTarget): target is ProxyGroupRuleTarget & MoveTarget { + return target.kind === "module" || target.kind === "custom"; +} + function isCnCandidateRule(value: unknown): value is CnCandidateRule { if (!value || typeof value !== "object") return false; const item = value as Record; @@ -191,7 +195,7 @@ export function ProxyGroupsModuleRulesPanel({ id: targetModule.id, name: resolveProxyGroupModuleName(targetModule, proxyGroupNameOverrides?.[targetModule.id]), })), - ...customProxyGroups.map((group) => ({ + ...customProxyGroups.filter((group) => group.enabled !== false).map((group) => ({ kind: "custom" as const, id: group.id, name: group.name, @@ -282,13 +286,13 @@ export function ProxyGroupsModuleRulesPanel({ ); return ( -
+
{rules.length === 0 && manualRules.length === 0 ? (
当前没有生效的规则集。
) : ( -
+
{rules.map((rule) => { if (rule.state !== "active") { const isCnIpRule = module.id === "cn" && rule.id === "cn-ip"; @@ -353,7 +357,7 @@ export function ProxyGroupsModuleRulesPanel({ kinds={["module", "custom"]} currentTarget={{ kind: "module", id: module.id, name: moduleDisplayName }} onMove={(target) => { - if (isRuleSetMoveTarget(target)) { + if (isRuleSetMoveTarget(target) && isModuleRuleMoveTarget(target)) { void moveRule(rule, target); } }} @@ -390,11 +394,11 @@ export function ProxyGroupsModuleRulesPanel({ )} {module.id === "cn" && ( -
+
@@ -447,7 +451,7 @@ export function ProxyGroupsModuleRulesPanel({ kinds={["module", "custom"]} currentTarget={{ kind: "module", id: module.id, name: moduleDisplayName }} onMove={(target) => { - if (isRuleSetMoveTarget(target)) { + if (isRuleSetMoveTarget(target) && isModuleRuleMoveTarget(target)) { moveExperimentalCnRule(target); } }} @@ -483,8 +487,8 @@ export function ProxyGroupsModuleRulesPanel({ )} {module.id === "cn" && availableCnCandidateRules.length > 0 && ( -
-
+
+
中国相关子规则集
@@ -521,7 +525,7 @@ export function ProxyGroupsModuleRulesPanel({
-
+
{availableCnCandidateRules.map((rule) => (
{ customRuleSets: [], builtinRuleEdits: {}, addModuleRules: vi.fn(), - customProxyGroups: [{ id: "custom-1", name: "Custom" }], + customProxyGroups: [ + { id: "custom-1", name: "Custom" }, + { id: "custom-2", name: "Target" }, + ], updateCustomProxyGroup: vi.fn(), proxyGroupNameOverrides: { auto: "Auto", fallback: "Fallback" }, }; @@ -285,6 +288,11 @@ describe("ProxyGroupsRulesLibrary", () => { result = renderLibrary(); expect(result.html).toContain("Custom"); expect(result.html).toContain("已添加"); + + mocks.store.customRuleSets = [{ id: "telegram", name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs", target: "Target" }]; + result = renderLibrary(); + expect(result.html).toContain("Target"); + expect(result.html).toContain("已添加"); }); it("adds selected rules to a custom group", () => { @@ -308,6 +316,28 @@ describe("ProxyGroupsRulesLibrary", () => { expect(setters[0]).toHaveBeenCalledWith([]); }); + it("adds selected rules to another custom group", () => { + const { html } = renderLibrary({ 0: [telegramRule], 1: "custom:custom-2" }); + expect(html).toContain("自定义组"); + expect(html).toContain("Target"); + + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-2", [ + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + noResolve: true, + }, + ]); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "已添加规则集", + description: expect.stringContaining("Target"), + })); + }); + it("adds valid selected rules to a module and reports skipped invalid rules", () => { mocks.store.enabledProxyGroups = []; const { html } = renderLibrary({ 0: [telegramRule, invalidRule], 1: "module:auto" }); @@ -362,6 +392,14 @@ describe("ProxyGroupsRulesLibrary", () => { stateMock.effects[0](); expect(stateMock.setters[1]).not.toHaveBeenCalledWith(""); + const filteredResult = renderLibrary({ 1: "custom:missing" }); + stateMock.effects[0](); + expect(filteredResult.setters[1]).toHaveBeenCalledWith(""); + + renderLibrary({ 1: "custom:custom-1" }); + stateMock.effects[0](); + expect(stateMock.setters[1]).not.toHaveBeenCalledWith(""); + renderLibrary(); const firstRuleDiv = mocks.captures.nativeDivs.find((props) => String(props.className).includes("cursor-pointer")); firstRuleDiv.onClick(); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx index 150b05c..fd7d975 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx @@ -17,6 +17,7 @@ import { cn } from "@subboost/ui/lib/utils"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { getModuleRuleOrderKey } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; import { RULE_CATEGORIES, type RuleSetInfo } from "@subboost/core/rules/metadata"; import { useConfigStore } from "@subboost/ui/store/config-store"; @@ -62,13 +63,33 @@ export function ProxyGroupsRulesLibrary() { const hidden = new Set(hiddenProxyGroups); return PROXY_GROUP_MODULES.filter((module) => !hidden.has(module.id)); }, [hiddenProxyGroups]); + const activeCustomProxyGroups = React.useMemo( + () => customProxyGroups.filter((group) => group.enabled !== false), + [customProxyGroups], + ); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveModuleFullName(module), + ]), + ), + [resolveModuleFullName], + ); React.useEffect(() => { - if (!addToGroupId.startsWith("module:")) return; - const moduleId = addToGroupId.slice("module:".length); - if (visibleProxyGroupModules.some((module) => module.id === moduleId)) return; + if (addToGroupId.startsWith("module:")) { + const moduleId = addToGroupId.slice("module:".length); + if (visibleProxyGroupModules.some((module) => module.id === moduleId)) return; + } else if (addToGroupId.startsWith("custom:")) { + const groupId = addToGroupId.slice("custom:".length); + if (activeCustomProxyGroups.some((group) => group.id === groupId)) return; + } else { + return; + } setAddToGroupId(""); - }, [addToGroupId, visibleProxyGroupModules]); + }, [activeCustomProxyGroups, addToGroupId, visibleProxyGroupModules]); return (
@@ -121,12 +142,26 @@ export function ProxyGroupsRulesLibrary() { ? builtinRuleEdits?.[getModuleRuleOrderKey(builtinSourceModule.id, rule.id)]?.target || resolveModuleFullName(builtinSourceModule) : ""; + const resolvedBuiltinTargetName = builtinSourceModule + ? resolveProxyGroupTargetName(builtinTargetName, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + fallbackTarget: resolveModuleFullName(builtinSourceModule), + }) + : ""; const belongsToModule = builtinTargetName - ? visibleProxyGroupModules.find((m) => resolveModuleFullName(m) === builtinTargetName) + ? visibleProxyGroupModules.find((m) => resolveModuleFullName(m) === resolvedBuiltinTargetName) : null; const customRuleSet = customRuleSets.find((item) => item.id === rule.id); const belongsToCustom = customRuleSet - ? customProxyGroups.find((g) => g.name === customRuleSet.target) + ? activeCustomProxyGroups.find( + (g) => + g.name === + resolveProxyGroupTargetName(customRuleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }), + ) : null; const isModuleEnabled = belongsToModule ? enabledProxyGroups.includes(belongsToModule.id) @@ -356,7 +391,7 @@ export function ProxyGroupsRulesLibrary() { className="text-xs" disabled > - 内置代理组 + 内置组 {visibleProxyGroupModules.map((m) => ( - 自定义分组 + 自定义组 - {customProxyGroups.length === 0 ? ( + {activeCustomProxyGroups.length === 0 ? ( - 暂无自定义分组 + 暂无自定义组 ) : ( - customProxyGroups.map((g) => ( + activeCustomProxyGroups.map((g) => ( m.id === target.id) : null; if (target.kind === "module" && !targetModule) return; - const usedRuleIds = new Map(); for (const m of visibleProxyGroupModules) { const groupName = resolveModuleFullName(m); @@ -432,17 +466,34 @@ export function ProxyGroupsRulesLibrary() { const edit = builtinRuleEdits?.[getModuleRuleOrderKey(m.id, r.id)]; if (edit?.enabled === false) continue; if (!usedRuleIds.has(r.id)) { - usedRuleIds.set(r.id, edit?.target || groupName); + usedRuleIds.set( + r.id, + edit?.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + fallbackTarget: groupName, + }) + : groupName, + ); } } } for (const ruleSet of customRuleSets) { - if (!usedRuleIds.has(ruleSet.id)) usedRuleIds.set(ruleSet.id, ruleSet.target); + if (!usedRuleIds.has(ruleSet.id)) { + usedRuleIds.set( + ruleSet.id, + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }), + ); + } } const targetDisplayName = target.kind === "custom" - ? customProxyGroups.find((g) => g.id === target.id) + ? activeCustomProxyGroups.find((g) => g.id === target.id) ?.name || "" : targetModule ? resolveModuleFullName(targetModule) @@ -479,10 +530,8 @@ export function ProxyGroupsRulesLibrary() { let skippedInvalidCount = 0; if (target.kind === "custom") { - const cg = customProxyGroups.find( - (g) => g.id === target.id, - ); - if (!cg) return; + const group = activeCustomProxyGroups.find((g) => g.id === target.id); + if (!group) return; const existing = new Set(customRuleSets.map((r) => r.id)); const rulesToAdd = selectedRules .filter((r) => !existing.has(r.id)) @@ -500,7 +549,7 @@ export function ProxyGroupsRulesLibrary() { skippedExistingCount = selectedRules.length - rulesToAdd.length; if (rulesToAdd.length > 0) { - addModuleRules(cg.id, rulesToAdd); + addModuleRules(group.id, rulesToAdd); addedCount = rulesToAdd.length; } } else { diff --git a/packages/ui/src/product/converter/quick-mode/sources-section.tsx b/packages/ui/src/product/converter/quick-mode/sources-section.tsx index 2dd0fd7..c294d8f 100644 --- a/packages/ui/src/product/converter/quick-mode/sources-section.tsx +++ b/packages/ui/src/product/converter/quick-mode/sources-section.tsx @@ -562,7 +562,7 @@ export function SourcesSection() {
注意开启后:
  • 无法在预览中查看/管理该 url 的节点
  • -
  • 无法将这些节点用于中转代理组、筛选代理组等高级功能
  • +
  • 无法将这些节点用于中转代理组、分流组高级模式等高级功能
  • 节点命名模板与 tag 在该模式下不生效
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 bb155c0..fa6b4ca 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 @@ -113,7 +113,7 @@ function resetStoreState(overrides: Record = {}) { proxyGroupNameOverrides: {}, experimentalCnUseCnRuleSet: false, cnIpNoResolve: true, - filteredProxyGroups: [], + proxyGroupAdvanced: {}, proxyGroupOrder: [], ruleOrder: [], ...overrides, @@ -267,7 +267,17 @@ describe("useEditingSubscriptionLoader", () => { template: "full", enabledProxyGroups: ["select", "auto", "ai"], hiddenProxyGroups: ["youtube"], - customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + customProxyGroups: [ + { id: "custom-1", name: "Custom", emoji: "", groupType: "select", advanced: {} }, + { + id: "migrated-filtered-fg-1", + name: "Fast", + emoji: "", + description: "自定义代理组", + groupType: "select", + advanced: {}, + }, + ], customRuleSets: [ { id: "custom-ai", @@ -277,7 +287,6 @@ describe("useEditingSubscriptionLoader", () => { target: "🤖 Labs", }, ], - filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, moduleRuleEditWarningAccepted: true, proxyGroupNameOverrides: { ai: "Labs" }, 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 0426c0b..fe39524 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.ts @@ -7,6 +7,8 @@ import type { EditingSubscriptionLoaderOptions } from "./editing-subscription-ty import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; +import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import { hasSubscriptionUserInfo, @@ -74,7 +76,9 @@ export function useEditingSubscriptionLoader({ } return out; })(); - const cfg = sub.config && typeof sub.config === "object" ? (sub.config as Record) : {}; + const cfg = migrateFilteredProxyGroupsConfig( + sub.config && typeof sub.config === "object" ? (sub.config as Record) : {}, + ); const subscriptionInfoFromRecord = normalizeSubscriptionUserInfo((sub as any).subscriptionInfo); const hasSubscriptionInfoFromRecord = hasSubscriptionUserInfo(subscriptionInfoFromRecord); const deletedNodesFromCfg = Array.isArray((cfg as any).deletedNodes) @@ -415,9 +419,14 @@ export function useEditingSubscriptionLoader({ const customProxyGroupsFromCfg = ruleModelFromCfg.customProxyGroups; const customRuleSetsFromCfg = ruleModelFromCfg.customRuleSets; const builtinRuleEditsFromCfg = ruleModelFromCfg.builtinRuleEdits; - const filteredProxyGroupsFromCfg = Array.isArray((cfg as any).filteredProxyGroups) - ? (((cfg as any).filteredProxyGroups as unknown[]) as any[]) - : []; + const proxyGroupAdvancedFromCfg = + cfg.proxyGroupAdvanced && typeof cfg.proxyGroupAdvanced === "object" && !Array.isArray(cfg.proxyGroupAdvanced) + ? Object.fromEntries( + Object.entries(cfg.proxyGroupAdvanced as Record) + .map(([id, value]) => [id.trim(), normalizeProxyGroupAdvancedConfig(value)] as const) + .filter(([id, advanced]) => id && Object.keys(advanced).length > 0), + ) + : {}; const dialerProxyGroupsFromCfg = Array.isArray(cfg.dialerProxyGroups) ? (cfg.dialerProxyGroups as any[]) : []; const proxyGroupNameOverridesFromCfg = cfg.proxyGroupNameOverrides && typeof cfg.proxyGroupNameOverrides === "object" @@ -505,7 +514,7 @@ export function useEditingSubscriptionLoader({ customProxyGroups: customProxyGroupsFromCfg as any, customRuleSets: customRuleSetsFromCfg, builtinRuleEdits: builtinRuleEditsFromCfg, - filteredProxyGroups: filteredProxyGroupsFromCfg as any, + proxyGroupAdvanced: proxyGroupAdvancedFromCfg, moduleRuleEditWarningAccepted: typeof (cfg as any).moduleRuleEditWarningAccepted === "boolean" ? Boolean((cfg as any).moduleRuleEditWarningAccepted) 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 c79bb9c..071d631 100644 --- a/packages/ui/src/product/home/use-subscription-link.test.ts +++ b/packages/ui/src/product/home/use-subscription-link.test.ts @@ -103,7 +103,7 @@ describe("useSubscriptionLink", () => { vi.clearAllMocks(); resetHookState(); mocks.bag.storeState = { - filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], + proxyGroupAdvanced: { auto: { includeRegex: "Fast" } }, proxyGroupOrder: ["select", "auto"], }; originalWindow = globalThis.window; @@ -255,7 +255,7 @@ describe("useSubscriptionLink", () => { subscriptionUserInfo: { upload: 2_048, download: 1_024, total: 4_096 }, }), ], - filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], + proxyGroupAdvanced: { auto: { includeRegex: "Fast" } }, listenerPorts: { "Node A": 41000 }, proxyGroupOrder: ["select", "auto"], }), diff --git a/packages/ui/src/product/home/use-subscription-link.tsx b/packages/ui/src/product/home/use-subscription-link.tsx index 3259b05..0f6a576 100644 --- a/packages/ui/src/product/home/use-subscription-link.tsx +++ b/packages/ui/src/product/home/use-subscription-link.tsx @@ -12,7 +12,6 @@ import type { BuiltinRuleEdits, CustomRule, CustomProxyGroup, CustomRuleSet } fr import type { User } from "@subboost/ui/store/user-store"; import { useConfigStore } from "@subboost/ui/store/config-store"; import { captureAuthConfigHandoff } from "@subboost/ui/store/config-store/auth-handoff"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; import { hasSubscriptionUserInfo, mergeSubscriptionUserInfo, @@ -382,7 +381,7 @@ export function useSubscriptionLink({ customProxyGroups, customRuleSets, builtinRuleEdits, - filteredProxyGroups: useConfigStore.getState().filteredProxyGroups as FilteredProxyGroup[], + proxyGroupAdvanced: useConfigStore.getState().proxyGroupAdvanced, moduleRuleEditWarningAccepted, dialerProxyGroups, proxyGroupNameOverrides, diff --git a/packages/ui/src/product/preview/visual-graph.test.ts b/packages/ui/src/product/preview/visual-graph.test.ts index efde586..112f12a 100644 --- a/packages/ui/src/product/preview/visual-graph.test.ts +++ b/packages/ui/src/product/preview/visual-graph.test.ts @@ -157,7 +157,6 @@ describe("VisualGraph", () => { customRules: [{ id: "manual" }], customProxyGroups: [{ id: "custom-1", name: "🧩 Custom", emoji: "🧩", groupType: "load-balance", strategy: "round-robin" }], customRuleSets: [], - filteredProxyGroups: [{ id: "filtered-1", name: "🧩 Filtered", emoji: "", enabled: true }], builtinRuleEdits: {}, proxyGroupNameOverrides: {}, proxyGroupOrder: ["dialer:d1", "module:auto", "missing", "module:select", "dialer:d1"], @@ -193,7 +192,7 @@ describe("VisualGraph", () => { "module:auto", "module:select", "custom:custom-1", - "filtered:filtered-1", + "name:🧩 Filtered", "name:External", ]); expect(preview.displayGroups.find((group: any) => group.id === "module:auto").rules).toEqual([ @@ -223,7 +222,7 @@ describe("VisualGraph", () => { "module:select", "module:auto", "custom:custom-1", - "filtered:filtered-1", + "name:🧩 Filtered", "name:External", ]); expect(html).toContain("还有 2 个节点"); @@ -300,20 +299,16 @@ describe("VisualGraph", () => { null, { id: "bad-type", name: 123 }, { id: "bad-name", name: " " }, + { id: "emoji", name: "Emoji Filter", emoji: "⭐", groupType: "select" }, { id: "plain", name: "Plain Custom", emoji: "", groupType: "", strategy: "", rules: [] }, ]; - mocks.store.filteredProxyGroups = [ - null, - { id: "disabled", name: "Disabled", enabled: false }, - { id: "emoji", name: "Emoji Filter", emoji: "⭐", enabled: true }, - ]; - mocks.store.proxyGroupOrder = ["name:", "filtered:emoji", "custom:plain", "dialer:d3"]; + mocks.store.proxyGroupOrder = ["name:", "custom:emoji", "custom:plain", "dialer:d3"]; const { html } = renderGraph({ 3: 800 }); const groups = mocks.captures.proxyGroupsPreview.displayGroups; - expect(groups.map((group: any) => group.id)).toEqual(["name:", "filtered:emoji", "custom:plain", "dialer:d3"]); - expect(groups.find((group: any) => group.id === "filtered:emoji")).toMatchObject({ emoji: "⭐" }); + expect(groups.map((group: any) => group.id)).toEqual(["name:", "custom:emoji", "custom:plain", "dialer:d3"]); + expect(groups.find((group: any) => group.id === "custom:emoji")).toMatchObject({ emoji: "⭐" }); expect(groups.find((group: any) => group.id === "dialer:d3").dialer).toMatchObject({ relayNodes: [], targetNodes: [], diff --git a/packages/ui/src/product/preview/visual-graph.tsx b/packages/ui/src/product/preview/visual-graph.tsx index 1944537..029796d 100644 --- a/packages/ui/src/product/preview/visual-graph.tsx +++ b/packages/ui/src/product/preview/visual-graph.tsx @@ -12,6 +12,7 @@ import { } from "@subboost/core/generator/proxy-groups"; import { getModuleRuleOrderKey } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { collectCustomRoutingRuleSets } from "@subboost/core/rules/custom-routing-rule-sets"; import { CustomRulesPreview } from "./visual-graph/custom-rules-preview"; import { getDialerEmojiFromName } from "./visual-graph/emoji"; @@ -32,7 +33,7 @@ export function VisualGraph() { customRules, customProxyGroups, customRuleSets, - filteredProxyGroups, + proxyGroupAdvanced, builtinRuleEdits, proxyGroupNameOverrides, proxyGroupOrder, @@ -48,7 +49,7 @@ export function VisualGraph() { customRules: state.customRules ?? [], customProxyGroups: state.customProxyGroups ?? [], customRuleSets: state.customRuleSets ?? [], - filteredProxyGroups: state.filteredProxyGroups ?? [], + proxyGroupAdvanced: state.proxyGroupAdvanced ?? {}, builtinRuleEdits: state.builtinRuleEdits ?? {}, proxyGroupNameOverrides: state.proxyGroupNameOverrides ?? {}, proxyGroupOrder: state.proxyGroupOrder ?? [], @@ -75,6 +76,10 @@ export function VisualGraph() { } | null>(null); const containerRef = React.useRef(null); const [containerContentWidth, setContainerContentWidth] = React.useState(0); + const activeCustomProxyGroups = React.useMemo( + () => customProxyGroups.filter((group) => group && group.enabled !== false), + [customProxyGroups], + ); React.useEffect(() => { const el = containerRef.current; @@ -116,9 +121,9 @@ export function VisualGraph() { ruleProviderBaseUrl, testUrl, testInterval, - customProxyGroups, + customProxyGroups: activeCustomProxyGroups, customRuleSets, - filteredProxyGroups, + proxyGroupAdvanced, builtinRuleEdits, proxyGroupNameOverrides, }); @@ -128,9 +133,9 @@ export function VisualGraph() { ruleProviderBaseUrl, testUrl, testInterval, - customProxyGroups, + activeCustomProxyGroups, customRuleSets, - filteredProxyGroups, + proxyGroupAdvanced, builtinRuleEdits, proxyGroupNameOverrides, ]); @@ -145,19 +150,13 @@ export function VisualGraph() { moduleByName.set(resolveModuleName(m), m); } const customByName = new Map(); - for (const g of customProxyGroups) { + for (const g of activeCustomProxyGroups) { if (!g || typeof g.name !== "string" || !g.name.trim()) continue; customByName.set(g.name.trim(), g); } - const filteredByName = new Map< - string, - (typeof filteredProxyGroups)[number] - >(); - for (const g of filteredProxyGroups) { - if (!g || g.enabled === false) continue; - if (typeof g.name !== "string" || !g.name.trim()) continue; - filteredByName.set(g.name.trim(), g); - } + const moduleNames = Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [module.id, resolveModuleName(module)]), + ); const base = generatedProxyGroups.map((g) => { const groupName = typeof g.name === "string" ? g.name.trim() : ""; @@ -168,7 +167,14 @@ export function VisualGraph() { ...(mod.rules ?? []) .filter((r) => { const edit = builtinRuleEdits?.[getModuleRuleOrderKey(mod.id, r.id)]; - return edit?.enabled !== false && (edit?.target || moduleTarget) === moduleTarget; + const target = edit?.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + fallbackTarget: moduleTarget, + }) + : moduleTarget; + return edit?.enabled !== false && target === moduleTarget; }) .map((r) => ({ id: r.id, @@ -176,14 +182,26 @@ export function VisualGraph() { behavior: r.behavior, })), ...customRuleSets - .filter((ruleSet) => ruleSet.target === moduleTarget) + .filter( + (ruleSet) => + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }) === moduleTarget, + ) .map((ruleSet) => ({ id: ruleSet.id, name: ruleSet.name, behavior: ruleSet.behavior, })), ...Object.entries(builtinRuleEdits || {}).flatMap(([key, edit]) => { - if (edit?.enabled === false || edit?.target !== moduleTarget) return []; + const target = edit?.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }) + : ""; + if (edit?.enabled === false || target !== moduleTarget) return []; const match = key.match(/^module:([^:]+):(.+)$/); if (!match) return []; const [, sourceModuleId, ruleId] = match; @@ -199,30 +217,13 @@ export function VisualGraph() { id: `module:${mod.id}`, name: resolveModuleName(mod), emoji: mod.emoji, - groupType: mod.groupType, + groupType: proxyGroupAdvanced?.[mod.id]?.groupType ?? mod.groupType, + strategy: proxyGroupAdvanced?.[mod.id]?.strategy, category: mod.category, rules: mergedRules, }; } - const fg = groupName ? filteredByName.get(groupName) : undefined; - if (fg) { - const token = groupName.split(/\s+/)[0] || ""; - const emoji = - typeof fg.emoji === "string" && fg.emoji.trim() - ? fg.emoji.trim() - : token || "🧩"; - return { - id: `filtered:${fg.id}`, - name: groupName, - emoji, - groupType: g.type, - strategy: g.strategy, - category: "custom", - rules: [], - }; - } - const cg = groupName ? customByName.get(groupName) : undefined; return { id: cg ? `custom:${cg.id}` : `name:${groupName}`, @@ -232,7 +233,13 @@ export function VisualGraph() { strategy: cg?.strategy || g.strategy, category: "custom", rules: customRuleSets - .filter((ruleSet) => ruleSet.target === groupName) + .filter( + (ruleSet) => + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }) === groupName, + ) .map((r) => ({ id: r.id, name: r.name, @@ -305,12 +312,12 @@ export function VisualGraph() { .map((id) => byId.get(id)) .filter(Boolean) as VisualDisplayGroup[]; }, [ - customProxyGroups, + activeCustomProxyGroups, enabledDialerProxyGroups, - filteredProxyGroups, generatedProxyGroups, customRuleSets, builtinRuleEdits, + proxyGroupAdvanced, proxyGroupOrder, resolveModuleName, ]); @@ -329,10 +336,10 @@ export function VisualGraph() { () => collectCustomRoutingRuleSets({ customRuleSets, - customProxyGroups, + customProxyGroups: activeCustomProxyGroups, proxyGroupNameOverrides, }), - [customProxyGroups, customRuleSets, proxyGroupNameOverrides], + [activeCustomProxyGroups, customRuleSets, proxyGroupNameOverrides], ); // 注意:`containerRef` 有 `p-4`,Safari 上用 `clientWidth` 会把 padding 算进去,导致阈值判断偏大; diff --git a/packages/ui/src/product/preview/visual-graph/custom-rules-preview.tsx b/packages/ui/src/product/preview/visual-graph/custom-rules-preview.tsx index 3e78cab..e817b92 100644 --- a/packages/ui/src/product/preview/visual-graph/custom-rules-preview.tsx +++ b/packages/ui/src/product/preview/visual-graph/custom-rules-preview.tsx @@ -5,6 +5,11 @@ import { ArrowRight, Shield } from "lucide-react"; import type { CustomRoutingRuleSetItem } from "@subboost/core/rules/custom-routing-rule-sets"; import type { CustomRule } from "@subboost/core/types/config"; +function ruleTargetToText(target: CustomRule["target"]): string { + if (typeof target === "string") return target; + return `${target.kind}:${target.id}`; +} + export function CustomRulesPreview({ customRules, ruleSets = [], @@ -18,7 +23,7 @@ export function CustomRulesPreview({ key: rule.id || `${rule.type}:${rule.value}`, type: rule.type, value: rule.value, - target: rule.target, + target: ruleTargetToText(rule.target), noResolve: Boolean(rule.noResolve), title: rule.value, })), diff --git a/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx b/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx index a0a6462..3e44fff 100644 --- a/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx +++ b/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx @@ -13,6 +13,7 @@ import { Shield, Zap, } from "lucide-react"; +import type { ProxyGroupGroupType } from "@subboost/core/types/config"; import { cn } from "@subboost/ui/lib/utils"; export type VisualDisplayGroup = { @@ -26,7 +27,7 @@ export type VisualDisplayGroup = { dialer?: { relayNodes: string[]; targetNodes: string[]; - type: "select" | "url-test"; + type: ProxyGroupGroupType; }; }; diff --git a/packages/ui/src/store/config-store/actions/custom-actions.ts b/packages/ui/src/store/config-store/actions/custom-actions.ts index 486fb1d..1ecc5a8 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.ts @@ -4,6 +4,7 @@ import { ensureCustomRuleId, } from "@subboost/core/rules/custom-rule-utils"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import type { ConfigActions } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; @@ -21,6 +22,7 @@ type CustomActions = Pick< function normalizeRuleOrderForState(state: { enabledProxyGroups: string[]; + customProxyGroups: Parameters[0]["customProxyGroups"]; customRules: Parameters[0]["customRules"]; customRuleSets: Parameters[0]["customRuleSets"]; builtinRuleEdits: Parameters[0]["builtinRuleEdits"]; @@ -31,6 +33,7 @@ function normalizeRuleOrderForState(state: { }): string[] { return normalizePersistedRuleOrder({ enabledModules: state.enabledProxyGroups, + customProxyGroups: state.customProxyGroups, customRules: state.customRules, customRuleSets: state.customRuleSets, builtinRuleEdits: state.builtinRuleEdits, @@ -133,8 +136,11 @@ export function createCustomActions( id, name: group.name, emoji: group.emoji, + ...(group.enabled === false ? { enabled: false } : {}), + ...(typeof group.description === "string" ? { description: group.description.trim() } : {}), groupType: group.groupType, ...(group.groupType === "load-balance" && group.strategy ? { strategy: group.strategy } : {}), + ...(group.advanced ? { advanced: normalizeProxyGroupAdvancedConfig(group.advanced) } : {}), }; const nextCustomProxyGroups = [ ...state.customProxyGroups, @@ -188,9 +194,14 @@ export function createCustomActions( prevName && nextName && prevName !== nextName ? retargetBuiltinRuleEdits(state.builtinRuleEdits, prevName, nextName) : state.builtinRuleEdits; - const nextCustomProxyGroups = state.customProxyGroups.map((g) => - g.id === id ? { ...g, ...group } : g, - ); + const nextCustomProxyGroups = state.customProxyGroups.map((g) => { + if (g.id !== id) return g; + const next = { ...g, ...group }; + if (typeof group.enabled === "boolean") next.enabled = group.enabled; + if (group.advanced) next.advanced = normalizeProxyGroupAdvancedConfig(group.advanced); + if (typeof group.description === "string") next.description = group.description.trim(); + return next; + }); return { customRules: nextCustomRules, customRuleSets: nextCustomRuleSets, diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts index 7e1a5ff..e28adec 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts @@ -40,11 +40,12 @@ describe("createProxyGroupActions", () => { "", "module:ai", "filtered:fast", + "name:External", // Intentionally force a non-string runtime value to verify invalid input is ignored. 123 as unknown as string, ]); - expect(getState().proxyGroupOrder).toEqual(["module:ai", "filtered:fast"]); + expect(getState().proxyGroupOrder).toEqual(["module:ai", "name:External"]); actions.setProxyGroupOrder("bad" as never); expect(getState().proxyGroupOrder).toEqual([]); @@ -95,212 +96,42 @@ describe("createProxyGroupActions", () => { expect(getState()).toBe(beforeRestore); }); - it("adds, updates, renames, and removes filtered proxy groups", () => { - vi.spyOn(Date, "now").mockReturnValue(1700000000000); + it("updates advanced config for builtin proxy groups", () => { const { actions, getState } = createHarness({ - filteredProxyGroups: [ - { - id: "filtered-1", - name: "Old Filter", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], - customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Old Filter" }], - dialerProxyGroups: [ - { - id: "dialer-1", - name: "Relay", - relayNodes: ["Old Filter", "Node A"], - targetNodes: ["Node A"], - }, - { - id: "dialer-2", - name: "Broken", - relayNodes: "bad", - targetNodes: ["Old Filter"], - }, - ], - }); - - actions.addFilteredProxyGroup({ - name: "Fast Nodes", - enabled: true, - groupType: "load-balance", - strategy: "bad" as never, - emoji: "⚡", - sourceIds: "bad" as never, - regions: ["us"], - excludeRegex: "Test", - excludedNodeNames: [" Node A ", "Node A", "", "Node B"], - }); - - expect(getState().filteredProxyGroups.at(-1)).toMatchObject({ - id: "filtered-group-1700000000000", - name: "Fast Nodes", - enabled: true, - groupType: "load-balance", - strategy: "consistent-hashing", - emoji: "⚡", - sourceIds: [], - regions: ["us"], - excludeRegex: "Test", - excludedNodeNames: ["Node A", "Node B"], - }); - - actions.addFilteredProxyGroup({ - name: "Plain Group", - enabled: false, - groupType: "invalid" as never, - emoji: 123 as never, - sourceIds: ["source-1"], - regions: "bad" as never, - includeRegex: "HK", - excludeRegex: 42 as never, - excludedNodeNames: "bad" as never, - }); - - expect(getState().filteredProxyGroups.at(-1)).toMatchObject({ - id: "filtered-group-1700000000000", - name: "Plain Group", - enabled: false, - groupType: "select", - sourceIds: ["source-1"], - regions: [], - includeRegex: "HK", - excludedNodeNames: [], - }); - expect(getState().filteredProxyGroups.at(-1)).not.toHaveProperty("strategy"); - expect(getState().filteredProxyGroups.at(-1)).toHaveProperty("emoji", undefined); - - actions.updateFilteredProxyGroup("filtered-1", { - name: "New Filter", - groupType: "load-balance", - strategy: "round-robin", - excludedNodeNames: ["Node C", "Node C"], - }); - - expect(getState().filteredProxyGroups[0]).toMatchObject({ - id: "filtered-1", - name: "New Filter", - groupType: "load-balance", - strategy: "round-robin", - excludedNodeNames: ["Node C"], - }); - expect(getState().customRules[0].target).toBe("New Filter"); - expect(getState().dialerProxyGroups[0].relayNodes).toEqual(["New Filter", "Node A"]); - expect(getState().dialerProxyGroups[1].relayNodes).toBe("bad"); - - actions.updateFilteredProxyGroup("filtered-1", { - groupType: "direct-first", - enabled: "bad" as never, - includeRegex: null as never, - excludeRegex: null as never, - sourceIds: "bad" as never, - regions: "bad" as never, - }); - - expect(getState().filteredProxyGroups[0]).toMatchObject({ - groupType: "direct-first", - enabled: true, - includeRegex: undefined, - excludeRegex: undefined, - sourceIds: [], - regions: [], - }); - expect(getState().filteredProxyGroups[0]).toHaveProperty("strategy", undefined); - - actions.updateFilteredProxyGroup("filtered-1", { - enabled: false, - emoji: "N", - groupType: "select", - sourceIds: ["source-2"], - regions: ["jp"], - includeRegex: "JP", - excludeRegex: "Relay", - }); - - expect(getState().filteredProxyGroups[0]).toMatchObject({ - enabled: false, - emoji: "N", - groupType: "select", - sourceIds: ["source-2"], - regions: ["jp"], - includeRegex: "JP", - excludeRegex: "Relay", - strategy: undefined, - }); - - actions.updateFilteredProxyGroup("filtered-1", { - groupType: "load-balance", - strategy: undefined, - }); - expect(getState().filteredProxyGroups[0].strategy).toBe("consistent-hashing"); - - const beforeMissingUpdate = getState(); - actions.updateFilteredProxyGroup("", { name: "Ignored" }); - actions.updateFilteredProxyGroup("missing", { name: "Ignored" }); - expect(getState()).toBe(beforeMissingUpdate); - - actions.removeFilteredProxyGroup(" filtered-1 "); - actions.removeFilteredProxyGroup(""); - expect(getState().filteredProxyGroups.map((group: { id: string }) => group.id)).toEqual([ - "filtered-group-1700000000000", - "filtered-group-1700000000000", - ]); - }); - - it("preserves filtered proxy group values when partial updates are invalid or omitted", () => { - const { actions, getState } = createHarness({ - filteredProxyGroups: [ - { - id: "filtered-1", - name: "Stable", - emoji: "S", - enabled: false, - groupType: "load-balance", - strategy: "round-robin", + proxyGroupAdvanced: { + ai: { sourceIds: ["source-1"], regions: ["hk"], includeRegex: "HK", - excludeRegex: "Test", - excludedNodeNames: "bad", + excludedMembers: [{ kind: "node", name: "Old" }], }, - ], + }, }); - actions.updateFilteredProxyGroup("filtered-1", { - enabled: "bad" as never, - emoji: 123 as never, - groupType: "load-balance", - strategy: "bad" as never, - includeRegex: undefined, - excludeRegex: undefined, - sourceIds: undefined, - regions: undefined, + actions.updateProxyGroupAdvanced(" ai ", { + sourceIds: [" source-2 ", "", "source-2"], + regions: "bad" as never, + includeRegex: "Node", + excludeRegex: "Test", + excludedMembers: [ + { kind: "node", name: " Node A " }, + { kind: "node", name: "Node A" }, + { kind: "dialer", id: "relay" } as never, + ], }); - expect(getState().filteredProxyGroups[0]).toMatchObject({ - name: "Stable", - emoji: "S", - enabled: false, - groupType: "load-balance", - strategy: "round-robin", - sourceIds: ["source-1"], - regions: ["hk"], - includeRegex: "HK", + expect(getState().proxyGroupAdvanced.ai).toMatchObject({ + sourceIds: ["source-2"], + includeRegex: "Node", excludeRegex: "Test", - excludedNodeNames: [], + excludedMembers: [{ kind: "node", name: "Node A" }], }); + expect(getState().proxyGroupAdvanced.ai.regions).toBeUndefined(); - actions.updateFilteredProxyGroup("filtered-1", { - groupType: "load-balance", - strategy: undefined, - }); - expect(getState().filteredProxyGroups[0].strategy).toBe("round-robin"); + const beforeMissingUpdate = getState(); + actions.updateProxyGroupAdvanced("", { includeRegex: "Ignored" }); + actions.updateProxyGroupAdvanced("missing", { includeRegex: "Ignored" }); + expect(getState()).toBe(beforeMissingUpdate); }); it("adds, updates, removes, and restores module rules", () => { @@ -481,6 +312,58 @@ describe("createProxyGroupActions", () => { expect(getState()).toEqual(before); }); + it("adds, updates, moves, and removes custom rule sets for custom groups", () => { + const { actions, getState } = createHarness({ + enabledProxyGroups: ["select", "auto"], + customProxyGroups: [ + { + id: "custom-1", + name: "Custom", + emoji: "", + groupType: "select", + }, + { + id: "custom-2", + name: "Target", + emoji: "", + groupType: "select", + }, + ], + customRuleSets: [], + }); + + actions.addModuleRules("custom-1", [ + { id: "telegram", name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs" }, + ]); + + expect(getState().customRuleSets).toEqual([ + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + target: "Custom", + noResolve: true, + }, + ]); + + actions.updateModuleRule("custom-1", "telegram", { + name: "Telegram Custom", + path: "geoip/telegram.mrs", + }); + expect(getState().customRuleSets[0]).toMatchObject({ + id: "telegram", + name: "Telegram Custom", + target: "Custom", + }); + + actions.moveModuleRule("custom-1", "telegram", { kind: "custom", id: "custom-2" }); + expect(getState().customRuleSets[0].target).toBe("Target"); + + actions.removeModuleRule("custom-2", "telegram"); + expect(getState().customRuleSets).toEqual([]); + }); + it("restores all default module rules for one module and accepts edit warnings", () => { const { actions, getState } = createHarness({ builtinRuleEdits: { diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts index 4085799..ae03821 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts @@ -1,28 +1,29 @@ -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; import { - DEFAULT_LOAD_BALANCE_STRATEGY, - isLoadBalanceStrategy, - type BuiltinRuleEdits, - type CustomProxyGroup, type CustomRuleSet, - type RuleSetBehavior, } from "@subboost/core/types/config"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; -import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { getModuleRuleOrderKey, isPresetModuleRule } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import { isValidRuleSetPathOrUrl, normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; import type { ConfigActions, RuleSetDraft } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; +import { + appendUniqueCustomRuleSets, + findBuiltinRuleEditKeyByTarget, + normalizeRuleOrderForState, + normalizeRuleSetDraft, + resolveMoveTargetName, + resolveRuleSetContainerTargetName, + retargetBuiltinRuleEdits, + updateBuiltinRuleEdit, +} from "./proxy-group-rule-set-helpers"; type ProxyGroupActions = Pick< ConfigActions, | "setProxyGroupOrder" | "hideProxyGroup" | "restoreHiddenProxyGroup" - | "addFilteredProxyGroup" - | "removeFilteredProxyGroup" - | "updateFilteredProxyGroup" + | "updateProxyGroupAdvanced" | "addModuleRules" | "updateModuleRule" | "removeModuleRule" @@ -53,134 +54,13 @@ function isBuiltinProxyGroup(moduleId: string): boolean { return PROXY_GROUP_MODULES.some((proxyModule) => proxyModule.id === moduleId); } -function normalizeRuleSetDraft(rule: RuleSetDraft): RuleSetDraft | null { - if (!rule || typeof rule.id !== "string" || typeof rule.path !== "string") return null; - const id = rule.id.trim(); - const path = normalizeRuleSetPathInput(rule.path); - if (!id || !path || !isValidRuleSetPathOrUrl(path)) return null; - const behavior: RuleSetBehavior = rule.behavior === "ipcidr" || path.toLowerCase().startsWith("geoip/") - ? "ipcidr" - : "domain"; - return { - id, - name: typeof rule.name === "string" && rule.name.trim() ? rule.name.trim() : id, - behavior, - path, - ...(rule.noResolve || behavior === "ipcidr" ? { noResolve: true } : {}), - }; -} - -function normalizeRuleOrderForState(state: { - enabledProxyGroups: string[]; - customRules: Parameters[0]["customRules"]; - customRuleSets: Parameters[0]["customRuleSets"]; - builtinRuleEdits: Parameters[0]["builtinRuleEdits"]; - proxyGroupNameOverrides: Record; - experimentalCnUseCnRuleSet: boolean; - cnIpNoResolve: boolean; - ruleOrder: string[]; -}): string[] { - return normalizePersistedRuleOrder({ - enabledModules: state.enabledProxyGroups, - customRules: state.customRules, - customRuleSets: state.customRuleSets, - builtinRuleEdits: state.builtinRuleEdits, - proxyGroupNameOverrides: state.proxyGroupNameOverrides, - experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, - cnIpNoResolve: state.cnIpNoResolve, - ruleOrder: state.ruleOrder, - }); -} - -function resolveModuleTargetName(moduleId: string, overrides?: Record): string | null { - const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === moduleId); - if (!proxyModule) return null; - return resolveProxyGroupModuleName(proxyModule, overrides?.[moduleId]); -} - -function resolveMoveTargetName( - target: { kind: "module" | "custom"; id: string }, - customProxyGroups: CustomProxyGroup[], - proxyGroupNameOverrides?: Record -): string | null { - if (target.kind === "module") return resolveModuleTargetName(target.id, proxyGroupNameOverrides); - const group = customProxyGroups.find((item) => item.id === target.id); - return group?.name?.trim() || null; -} - -function compactBuiltinRuleEdits(edits: BuiltinRuleEdits): BuiltinRuleEdits { - const next: BuiltinRuleEdits = {}; - for (const [key, edit] of Object.entries(edits || {})) { - const target = typeof edit?.target === "string" ? edit.target.trim() : ""; - const enabled = edit?.enabled === false ? false : undefined; - if (!target && enabled !== false) continue; - next[key] = { - ...(target ? { target } : {}), - ...(enabled === false ? { enabled: false } : {}), - }; - } - return next; -} - -function updateBuiltinRuleEdit( - edits: BuiltinRuleEdits, - key: string, - patch: { target?: string | null; enabled?: false | true | null } -): BuiltinRuleEdits { - const prev = edits?.[key] || {}; - const next = { ...prev }; - if ("target" in patch) { - const target = typeof patch.target === "string" ? patch.target.trim() : ""; - if (target) next.target = target; - else delete next.target; - } - if ("enabled" in patch) { - if (patch.enabled === false) next.enabled = false; - else delete next.enabled; - } - return compactBuiltinRuleEdits({ ...(edits || {}), [key]: next }); -} - -function retargetBuiltinRuleEdits(edits: BuiltinRuleEdits, from: string, to: string): BuiltinRuleEdits { - if (!from || from === to) return edits; - let changed = false; - const next: BuiltinRuleEdits = {}; - for (const [key, edit] of Object.entries(edits || {})) { - if (edit?.target === from) { - next[key] = { ...edit, target: to }; - changed = true; - } else { - next[key] = edit; - } - } - return changed ? compactBuiltinRuleEdits(next) : edits; -} - -function findBuiltinRuleEditKeyByTarget(edits: BuiltinRuleEdits, target: string, ruleId: string): string | null { - if (!target || !ruleId) return null; - for (const [key, edit] of Object.entries(edits || {})) { - if (edit?.target !== target) continue; - const parts = key.split(":"); - if (parts.length !== 3 || parts[0] !== "module") continue; - if (parts[2] === ruleId) return key; - } - return null; -} - -function appendUniqueCustomRuleSets( - existing: CustomRuleSet[], - drafts: RuleSetDraft[], - target: string -): CustomRuleSet[] { - const seen = new Set(existing.map((item) => item.id)); - const next = [...existing]; - for (const draft of drafts) { - const ruleSet = normalizeRuleSetDraft(draft); - if (!ruleSet || seen.has(ruleSet.id)) continue; - seen.add(ruleSet.id); - next.push({ ...ruleSet, target }); - } - return next; +function isSupportedProxyGroupOrderKey(key: string): boolean { + return ( + key.startsWith("module:") || + key.startsWith("custom:") || + key.startsWith("dialer:") || + key.startsWith("name:") + ); } export function createProxyGroupActions( @@ -195,6 +75,7 @@ export function createProxyGroupActions( .filter((k) => typeof k === "string") .map((k) => k.trim()) .filter(Boolean) + .filter(isSupportedProxyGroupOrderKey) : []; const seen = new Set(); @@ -265,126 +146,17 @@ export function createProxyGroupActions( }); }, - addFilteredProxyGroup: (group: Omit) => { - const id = `filtered-group-${Date.now()}`; - const groupType = - group.groupType === "url-test" || - group.groupType === "fallback" || - group.groupType === "load-balance" || - group.groupType === "direct-first" || - group.groupType === "reject-first" - ? group.groupType - : "select"; - const strategy = - groupType === "load-balance" - ? isLoadBalanceStrategy(group.strategy) - ? group.strategy - : DEFAULT_LOAD_BALANCE_STRATEGY - : undefined; - const next: FilteredProxyGroup = { - id, - emoji: typeof group.emoji === "string" ? group.emoji : undefined, - name: group.name, - enabled: Boolean(group.enabled), - groupType, - ...(strategy ? { strategy } : {}), - sourceIds: Array.isArray(group.sourceIds) ? group.sourceIds : [], - regions: Array.isArray(group.regions) ? group.regions : [], - includeRegex: typeof group.includeRegex === "string" ? group.includeRegex : undefined, - excludeRegex: typeof group.excludeRegex === "string" ? group.excludeRegex : undefined, - excludedNodeNames: normalizeStringList(group.excludedNodeNames), - }; - - setAndGenerateConfig((state) => ({ - filteredProxyGroups: [...state.filteredProxyGroups, next], - })); - }, - - removeFilteredProxyGroup: (id: string) => { - const gid = (id || "").trim(); - if (!gid) return; - setAndGenerateConfig((state) => ({ - filteredProxyGroups: state.filteredProxyGroups.filter((g) => g.id !== gid), - })); - }, - - updateFilteredProxyGroup: (id: string, group: Partial) => { - const gid = (id || "").trim(); - if (!gid) return; + updateProxyGroupAdvanced: (moduleId, patch) => { + const id = (moduleId || "").trim(); + if (!id || !isBuiltinProxyGroup(id)) return; setAndGenerateConfig((state) => { - const prevGroup = state.filteredProxyGroups.find((g) => g.id === gid); - if (!prevGroup) return state; - - const prevName = typeof prevGroup.name === "string" ? prevGroup.name : ""; - const nextName = typeof group.name === "string" ? group.name : prevName; - const didRename = Boolean(prevName && nextName && prevName !== nextName); - - const nextFilteredProxyGroups = state.filteredProxyGroups.map((g) => { - if (g.id !== gid) return g; - - const nextGroupType = - group.groupType === "url-test" || - group.groupType === "fallback" || - group.groupType === "load-balance" || - group.groupType === "direct-first" || - group.groupType === "reject-first" || - group.groupType === "select" - ? group.groupType - : g.groupType; - const nextStrategy = - nextGroupType === "load-balance" - ? isLoadBalanceStrategy(group.strategy) - ? group.strategy - : isLoadBalanceStrategy(g.strategy) - ? g.strategy - : DEFAULT_LOAD_BALANCE_STRATEGY - : undefined; - - return { - ...g, - ...group, - enabled: typeof group.enabled === "boolean" ? group.enabled : g.enabled, - emoji: typeof group.emoji === "string" ? group.emoji : g.emoji, - groupType: nextGroupType, - ...(nextStrategy ? { strategy: nextStrategy } : { strategy: undefined }), - sourceIds: Array.isArray(group.sourceIds) ? group.sourceIds : g.sourceIds, - regions: Array.isArray(group.regions) ? group.regions : g.regions, - includeRegex: - typeof group.includeRegex === "string" - ? group.includeRegex - : group.includeRegex === undefined - ? g.includeRegex - : g.includeRegex, - excludeRegex: - typeof group.excludeRegex === "string" - ? group.excludeRegex - : group.excludeRegex === undefined - ? g.excludeRegex - : g.excludeRegex, - excludedNodeNames: Array.isArray(group.excludedNodeNames) - ? normalizeStringList(group.excludedNodeNames) - : Array.isArray(g.excludedNodeNames) - ? normalizeStringList(g.excludedNodeNames) - : [], - }; - }); - - if (!didRename) { - return { filteredProxyGroups: nextFilteredProxyGroups }; - } - + const prev = normalizeProxyGroupAdvancedConfig(state.proxyGroupAdvanced?.[id]); + const next = normalizeProxyGroupAdvancedConfig({ ...prev, ...(patch || {}) }); return { - filteredProxyGroups: nextFilteredProxyGroups, - // 筛选组名称可能被其他功能(自定义规则 / 中转组)引用:改名时同步更新引用,避免产生“指向不存在的组”。 - customRules: state.customRules.map((r) => - r.target === prevName ? { ...r, target: nextName } : r - ), - dialerProxyGroups: state.dialerProxyGroups.map((dg) => ({ - ...dg, - relayNodes: Array.isArray(dg.relayNodes) - ? dg.relayNodes.map((n) => (n === prevName ? nextName : n)) - : dg.relayNodes, - })), + proxyGroupAdvanced: { + ...(state.proxyGroupAdvanced || {}), + [id]: next, + }, }; }); }, @@ -395,9 +167,11 @@ export function createProxyGroupActions( if (!Array.isArray(rules) || rules.length === 0) return; setAndGenerateConfig((state) => { - const target = - resolveModuleTargetName(id, state.proxyGroupNameOverrides) || - state.customProxyGroups.find((group) => group.id === id)?.name?.trim(); + const target = resolveRuleSetContainerTargetName( + id, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); if (!target) return state; const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === id); let nextBuiltinRuleEdits = state.builtinRuleEdits; @@ -443,9 +217,11 @@ export function createProxyGroupActions( if (!id || !rid) return; setAndGenerateConfig((state) => { - const target = - resolveModuleTargetName(id, state.proxyGroupNameOverrides) || - state.customProxyGroups.find((group) => group.id === id)?.name?.trim(); + const target = resolveRuleSetContainerTargetName( + id, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); if (!target) return state; const index = state.customRuleSets.findIndex((item) => item.id === rid && item.target === target); if (index < 0) return state; @@ -490,9 +266,11 @@ export function createProxyGroupActions( }; } - const target = - resolveModuleTargetName(id, state.proxyGroupNameOverrides) || - state.customProxyGroups.find((group) => group.id === id)?.name?.trim(); + const target = resolveRuleSetContainerTargetName( + id, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); if (!target) return state; const movedBuiltinKey = findBuiltinRuleEditKeyByTarget(state.builtinRuleEdits, target, rid); if (movedBuiltinKey) { @@ -528,12 +306,19 @@ export function createProxyGroupActions( setAndGenerateConfig((state) => { const sourceModule = PROXY_GROUP_MODULES.find((m) => m.id === sourceId); - const targetName = resolveMoveTargetName(target, state.customProxyGroups, state.proxyGroupNameOverrides); + const targetName = resolveMoveTargetName( + target, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); if (!targetName) return state; - const sourceTarget = - resolveModuleTargetName(sourceId, state.proxyGroupNameOverrides) || - state.customProxyGroups.find((group) => group.id === sourceId)?.name?.trim(); + const sourceTarget = resolveRuleSetContainerTargetName( + sourceId, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); if (!sourceTarget) return state; + if (sourceTarget === targetName) return state; if (target.kind === "module" && targetId === sourceId) return state; let nextEnabledProxyGroups = state.enabledProxyGroups; diff --git a/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts b/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts new file mode 100644 index 0000000..7ea0b34 --- /dev/null +++ b/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts @@ -0,0 +1,160 @@ +import { getModuleRuleOrderKey, isPresetModuleRule } from "@subboost/core/generator/module-rules"; +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; +import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; +import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { isValidRuleSetPathOrUrl, normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; +import type { + BuiltinRuleEdits, + CustomProxyGroup, + CustomRuleSet, + RuleSetBehavior, +} from "@subboost/core/types/config"; +import type { RuleSetDraft } from "../definitions"; + +export function normalizeRuleSetDraft(rule: RuleSetDraft): RuleSetDraft | null { + if (!rule || typeof rule.id !== "string" || typeof rule.path !== "string") return null; + const id = rule.id.trim(); + const path = normalizeRuleSetPathInput(rule.path); + if (!id || !path || !isValidRuleSetPathOrUrl(path)) return null; + const behavior: RuleSetBehavior = rule.behavior === "ipcidr" || path.toLowerCase().startsWith("geoip/") + ? "ipcidr" + : "domain"; + return { + id, + name: typeof rule.name === "string" && rule.name.trim() ? rule.name.trim() : id, + behavior, + path, + ...(rule.noResolve || behavior === "ipcidr" ? { noResolve: true } : {}), + }; +} + +export function normalizeRuleOrderForState(state: { + enabledProxyGroups: string[]; + customProxyGroups: CustomProxyGroup[]; + customRules: Parameters[0]["customRules"]; + customRuleSets: Parameters[0]["customRuleSets"]; + builtinRuleEdits: Parameters[0]["builtinRuleEdits"]; + proxyGroupNameOverrides: Record; + experimentalCnUseCnRuleSet: boolean; + cnIpNoResolve: boolean; + ruleOrder: string[]; +}): string[] { + return normalizePersistedRuleOrder({ + enabledModules: state.enabledProxyGroups, + customProxyGroups: state.customProxyGroups, + customRules: state.customRules, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, + proxyGroupNameOverrides: state.proxyGroupNameOverrides, + experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, + cnIpNoResolve: state.cnIpNoResolve, + ruleOrder: state.ruleOrder, + }); +} + +export function resolveModuleTargetName(moduleId: string, overrides?: Record): string | null { + const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === moduleId); + if (!proxyModule) return null; + return resolveProxyGroupModuleName(proxyModule, overrides?.[moduleId]); +} + +export function resolveMoveTargetName( + target: { kind: "module" | "custom"; id: string }, + customProxyGroups: CustomProxyGroup[], + proxyGroupNameOverrides?: Record +): string | null { + if (target.kind === "module") return resolveModuleTargetName(target.id, proxyGroupNameOverrides); + const group = customProxyGroups.find((item) => item.id === target.id); + return group?.name?.trim() || null; +} + +export function resolveRuleSetContainerTargetName( + id: string, + customProxyGroups: CustomProxyGroup[], + proxyGroupNameOverrides?: Record +): string | null { + return ( + resolveModuleTargetName(id, proxyGroupNameOverrides) || + customProxyGroups.find((group) => group.id === id)?.name?.trim() || + null + ); +} + +export function compactBuiltinRuleEdits(edits: BuiltinRuleEdits): BuiltinRuleEdits { + const next: BuiltinRuleEdits = {}; + for (const [key, edit] of Object.entries(edits || {})) { + const target = typeof edit?.target === "string" ? edit.target.trim() : ""; + const enabled = edit?.enabled === false ? false : undefined; + if (!target && enabled !== false) continue; + next[key] = { + ...(target ? { target } : {}), + ...(enabled === false ? { enabled: false } : {}), + }; + } + return next; +} + +export function updateBuiltinRuleEdit( + edits: BuiltinRuleEdits, + key: string, + patch: { target?: string | null; enabled?: false | true | null } +): BuiltinRuleEdits { + const prev = edits?.[key] || {}; + const next = { ...prev }; + if ("target" in patch) { + const target = typeof patch.target === "string" ? patch.target.trim() : ""; + if (target) next.target = target; + else delete next.target; + } + if ("enabled" in patch) { + if (patch.enabled === false) next.enabled = false; + else delete next.enabled; + } + return compactBuiltinRuleEdits({ ...(edits || {}), [key]: next }); +} + +export function retargetBuiltinRuleEdits(edits: BuiltinRuleEdits, from: string, to: string): BuiltinRuleEdits { + if (!from || from === to) return edits; + let changed = false; + const next: BuiltinRuleEdits = {}; + for (const [key, edit] of Object.entries(edits || {})) { + if (edit?.target === from) { + next[key] = { ...edit, target: to }; + changed = true; + } else { + next[key] = edit; + } + } + return changed ? compactBuiltinRuleEdits(next) : edits; +} + +export function findBuiltinRuleEditKeyByTarget( + edits: BuiltinRuleEdits, + target: string, + ruleId: string +): string | null { + if (!target || !ruleId) return null; + for (const [key, edit] of Object.entries(edits || {})) { + if (edit?.target !== target) continue; + const parts = key.split(":"); + if (parts.length !== 3 || parts[0] !== "module") continue; + if (parts[2] === ruleId) return key; + } + return null; +} + +export function appendUniqueCustomRuleSets( + existing: CustomRuleSet[], + drafts: RuleSetDraft[], + target: string +): CustomRuleSet[] { + const seen = new Set(existing.map((item) => item.id)); + const next = [...existing]; + for (const draft of drafts) { + const ruleSet = normalizeRuleSetDraft(draft); + if (!ruleSet || seen.has(ruleSet.id)) continue; + seen.add(ruleSet.id); + next.push({ ...ruleSet, target }); + } + return next; +} 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 3f0089d..6afed8e 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 @@ -143,10 +143,18 @@ describe("createTemplateActions", () => { id: "custom-group-1", name: "Custom Group", emoji: "", + advanced: {}, groupType: "select", }, + { + id: "migrated-filtered-filtered-1", + name: "Filtered", + emoji: "", + description: "自定义代理组", + groupType: "select", + advanced: {}, + }, ], - filteredProxyGroups: config.filteredProxyGroups, customRuleSets: [ { id: "custom-provider", @@ -191,17 +199,6 @@ describe("createTemplateActions", () => { groupType: "select", }, ], - filteredProxyGroups: [ - { - id: "existing-filter", - name: "Existing Filter", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], customRules: [{ id: "existing-rule", type: "DOMAIN", value: "example.org", target: "Proxy" }], customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, @@ -253,17 +250,6 @@ describe("createTemplateActions", () => { groupType: "select", }, ], - filteredProxyGroups: [ - { - id: "existing-filter", - name: "Existing Filter", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], customRules: [{ id: "existing-rule", type: "DOMAIN", value: "example.org", target: "Proxy" }], customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, 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 61caa8e..57cf637 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.ts @@ -4,6 +4,7 @@ import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; +import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; import type { ConfigActions, SubBoostTemplateConfig } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; @@ -78,12 +79,13 @@ export function createTemplateActions( // 应用模板配置(从模板库应用) applyTemplateConfig: (config: SubBoostTemplateConfig) => { if (!config || typeof config !== "object") return; + const migratedConfig = migrateFilteredProxyGroupsConfig(config); setAndGenerateConfig((state) => { - const ruleModel = normalizeRuleModelFromConfig(config); - const hasCustomProxyGroups = Array.isArray(config.customProxyGroups); - const hasCustomRuleSets = Array.isArray(config.customRuleSets); - const hasBuiltinRuleEdits = Boolean(config.builtinRuleEdits && typeof config.builtinRuleEdits === "object"); + const ruleModel = normalizeRuleModelFromConfig(migratedConfig); + const hasCustomProxyGroups = Array.isArray(migratedConfig.customProxyGroups); + const hasCustomRuleSets = Array.isArray(migratedConfig.customRuleSets); + const hasBuiltinRuleEdits = Boolean(migratedConfig.builtinRuleEdits && typeof migratedConfig.builtinRuleEdits === "object"); const nextCustomProxyGroups = hasCustomProxyGroups || ruleModel.customProxyGroups.length > 0 ? ruleModel.customProxyGroups @@ -94,19 +96,19 @@ export function createTemplateActions( : state.customRuleSets; const nextBuiltinRuleEdits = hasBuiltinRuleEdits ? ruleModel.builtinRuleEdits : state.builtinRuleEdits; - const nextCustomRules = Array.isArray(config.customRules) - ? ensureCustomRulesHaveIds(config.customRules) + const nextCustomRules = Array.isArray(migratedConfig.customRules) + ? ensureCustomRulesHaveIds(migratedConfig.customRules) : state.customRules; - const nextHiddenProxyGroups = normalizeHiddenProxyGroups(config.hiddenProxyGroups); + const nextHiddenProxyGroups = normalizeHiddenProxyGroups(migratedConfig.hiddenProxyGroups); const nextHiddenProxyGroupSet = new Set(nextHiddenProxyGroups); const shouldRefreshRuleOrder = - Array.isArray(config.ruleOrder) || - Array.isArray(config.customRules) || + Array.isArray(migratedConfig.ruleOrder) || + Array.isArray(migratedConfig.customRules) || hasCustomProxyGroups || hasCustomRuleSets || hasBuiltinRuleEdits; - const nextEnabledModulesRaw = Array.isArray(config.enabledProxyGroups) - ? config.enabledProxyGroups + const nextEnabledModulesRaw = Array.isArray(migratedConfig.enabledProxyGroups) + ? migratedConfig.enabledProxyGroups : state.enabledProxyGroups; const nextEnabledModules = nextEnabledModulesRaw.filter( (moduleId) => !nextHiddenProxyGroupSet.has(moduleId) @@ -116,56 +118,58 @@ export function createTemplateActions( enabledModules: nextEnabledModules, customRules: nextCustomRules, customRuleSets: nextCustomRuleSets, + customProxyGroups: nextCustomProxyGroups, builtinRuleEdits: nextBuiltinRuleEdits, proxyGroupNameOverrides: - config.proxyGroupNameOverrides && typeof config.proxyGroupNameOverrides === "object" - ? (config.proxyGroupNameOverrides as Record) + migratedConfig.proxyGroupNameOverrides && typeof migratedConfig.proxyGroupNameOverrides === "object" + ? (migratedConfig.proxyGroupNameOverrides as Record) : state.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: - typeof config.experimentalCnUseCnRuleSet === "boolean" - ? config.experimentalCnUseCnRuleSet + typeof migratedConfig.experimentalCnUseCnRuleSet === "boolean" + ? migratedConfig.experimentalCnUseCnRuleSet : state.experimentalCnUseCnRuleSet, cnIpNoResolve: - typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve, - ruleOrder: config.ruleOrder, + typeof migratedConfig.cnIpNoResolve === "boolean" ? migratedConfig.cnIpNoResolve : state.cnIpNoResolve, + ruleOrder: migratedConfig.ruleOrder, }) : state.ruleOrder; return { // 不触碰 nodes/sources:模板只描述“生成策略”,节点仍由用户导入 - template: config.template ?? state.template, + template: migratedConfig.template ?? state.template, enabledProxyGroups: nextEnabledModules, hiddenProxyGroups: nextHiddenProxyGroups, customProxyGroups: nextCustomProxyGroups, - filteredProxyGroups: Array.isArray(config.filteredProxyGroups) - ? config.filteredProxyGroups - : state.filteredProxyGroups, + proxyGroupAdvanced: + migratedConfig.proxyGroupAdvanced && typeof migratedConfig.proxyGroupAdvanced === "object" + ? migratedConfig.proxyGroupAdvanced + : state.proxyGroupAdvanced, customRuleSets: nextCustomRuleSets, builtinRuleEdits: nextBuiltinRuleEdits, moduleRuleEditWarningAccepted: false, customRules: nextCustomRules, ruleOrder: nextRuleOrder, cnIpNoResolve: - typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve, + typeof migratedConfig.cnIpNoResolve === "boolean" ? migratedConfig.cnIpNoResolve : state.cnIpNoResolve, experimentalCnUseCnRuleSet: - typeof config.experimentalCnUseCnRuleSet === "boolean" - ? config.experimentalCnUseCnRuleSet + typeof migratedConfig.experimentalCnUseCnRuleSet === "boolean" + ? migratedConfig.experimentalCnUseCnRuleSet : state.experimentalCnUseCnRuleSet, - dialerProxyGroups: Array.isArray(config.dialerProxyGroups) - ? config.dialerProxyGroups + dialerProxyGroups: Array.isArray(migratedConfig.dialerProxyGroups) + ? migratedConfig.dialerProxyGroups : state.dialerProxyGroups, proxyGroupNameOverrides: - config.proxyGroupNameOverrides && typeof config.proxyGroupNameOverrides === "object" - ? (config.proxyGroupNameOverrides as Record) + migratedConfig.proxyGroupNameOverrides && typeof migratedConfig.proxyGroupNameOverrides === "object" + ? (migratedConfig.proxyGroupNameOverrides as Record) : state.proxyGroupNameOverrides, - dnsYaml: typeof config.dnsYaml === "string" ? config.dnsYaml : state.dnsYaml, - mixedPort: typeof config.mixedPort === "number" ? config.mixedPort : state.mixedPort, - allowLan: typeof config.allowLan === "boolean" ? config.allowLan : state.allowLan, - testUrl: typeof config.testUrl === "string" ? config.testUrl : state.testUrl, + dnsYaml: typeof migratedConfig.dnsYaml === "string" ? migratedConfig.dnsYaml : state.dnsYaml, + mixedPort: typeof migratedConfig.mixedPort === "number" ? migratedConfig.mixedPort : state.mixedPort, + allowLan: typeof migratedConfig.allowLan === "boolean" ? migratedConfig.allowLan : state.allowLan, + testUrl: typeof migratedConfig.testUrl === "string" ? migratedConfig.testUrl : state.testUrl, testInterval: - typeof config.testInterval === "number" ? config.testInterval : state.testInterval, + typeof migratedConfig.testInterval === "number" ? migratedConfig.testInterval : state.testInterval, ruleProviderBaseUrl: - typeof config.ruleProviderBaseUrl === "string" - ? config.ruleProviderBaseUrl + typeof migratedConfig.ruleProviderBaseUrl === "string" + ? migratedConfig.ruleProviderBaseUrl : state.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 dae7ed8..91002a6 100644 --- a/packages/ui/src/store/config-store/auth-handoff.test.ts +++ b/packages/ui/src/store/config-store/auth-handoff.test.ts @@ -55,8 +55,8 @@ function meaningfulState(overrides: Record = {}) { deletedNodes: [{ originName: "Gone", name: "Gone" }], customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Proxy" }], customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + proxyGroupAdvanced: { ai: { includeRegex: "AI" } }, customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], - filteredProxyGroups: [{ id: "filtered-1", name: "Filtered", enabled: true }], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, dialerProxyGroups: [{ id: "dialer-1", name: "Relay", relayNodes: ["Node A"], targetNodes: [] }], proxyGroupNameOverrides: { ai: "Labs" }, @@ -148,8 +148,8 @@ describe("auth config handoff", () => { hiddenProxyGroups: ["youtube"], customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Proxy" }], customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + proxyGroupAdvanced: { ai: { includeRegex: "AI" } }, customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], - filteredProxyGroups: [{ id: "filtered-1", name: "Filtered", enabled: true }], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], @@ -286,7 +286,6 @@ describe("auth config handoff", () => { deletedNodes: [{ originName: "Gone" }], enabledProxyGroups: ["select"], customProxyGroups: [], - filteredProxyGroups: [{ id: "filtered" }], customRuleSets: [ { id: "custom-ai", diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index f184a35..f49d0df 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -2,6 +2,7 @@ import type { ConfigState, SourceType, SubscriptionSource } from "./definitions" import { initialState } from "./definitions"; import { safeParseJsonObject } from "@subboost/core/json"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; +import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; export const AUTH_CONFIG_HANDOFF_STORAGE_NAME = "subboost-auth-config-handoff"; @@ -113,7 +114,7 @@ function hasMeaningfulConfig(state: ConfigState): boolean { state.customRules.length > 0 || state.customRuleSets.length > 0 || state.customProxyGroups.length > 0 || - state.filteredProxyGroups.length > 0 || + hasRecordEntries(state.proxyGroupAdvanced as Record) || hasRecordEntries(state.builtinRuleEdits as Record) || state.dialerProxyGroups.length > 0 || hasRecordEntries(state.proxyGroupNameOverrides) || @@ -146,7 +147,7 @@ function buildHandoffState(state: ConfigState): Partial { enabledProxyGroups: state.enabledProxyGroups, hiddenProxyGroups: state.hiddenProxyGroups, customProxyGroups: state.customProxyGroups, - filteredProxyGroups: state.filteredProxyGroups, + proxyGroupAdvanced: state.proxyGroupAdvanced, customRuleSets: state.customRuleSets, builtinRuleEdits: state.builtinRuleEdits, customRules: state.customRules, @@ -169,57 +170,59 @@ function buildHandoffState(state: ConfigState): Partial { } function normalizeHandoffState(raw: unknown): Partial | null { - if (!isRecord(raw)) return null; + const migratedRaw = migrateFilteredProxyGroupsConfig(raw); + if (!isRecord(migratedRaw)) return null; const out: Partial = {}; - const sources = sourceArray(raw.sources); + const sources = sourceArray(migratedRaw.sources); if (sources) out.sources = sources; - const nodes = objectArray(raw.nodes); + const nodes = objectArray(migratedRaw.nodes); if (nodes) out.nodes = nodes; - const deletedNodeNames = stringArray(raw.deletedNodeNames); + const deletedNodeNames = stringArray(migratedRaw.deletedNodeNames); if (deletedNodeNames) out.deletedNodeNames = deletedNodeNames; - const deletedNodes = objectArray(raw.deletedNodes); + const deletedNodes = objectArray(migratedRaw.deletedNodes); if (deletedNodes) out.deletedNodes = deletedNodes; - if (raw.template === "minimal" || raw.template === "standard" || raw.template === "full") out.template = raw.template; - const enabledProxyGroups = stringArray(raw.enabledProxyGroups); + if (migratedRaw.template === "minimal" || migratedRaw.template === "standard" || migratedRaw.template === "full") out.template = migratedRaw.template; + const enabledProxyGroups = stringArray(migratedRaw.enabledProxyGroups); if (enabledProxyGroups) out.enabledProxyGroups = enabledProxyGroups; - const hiddenProxyGroups = stringArray(raw.hiddenProxyGroups); + const hiddenProxyGroups = stringArray(migratedRaw.hiddenProxyGroups); if (hiddenProxyGroups) out.hiddenProxyGroups = hiddenProxyGroups; - const ruleModel = normalizeRuleModelFromConfig(raw); - const customProxyGroups = objectArray(raw.customProxyGroups); + const ruleModel = normalizeRuleModelFromConfig(migratedRaw); + const customProxyGroups = objectArray(migratedRaw.customProxyGroups); if (customProxyGroups || ruleModel.customProxyGroups.length > 0) out.customProxyGroups = ruleModel.customProxyGroups; - const filteredProxyGroups = objectArray(raw.filteredProxyGroups); - if (filteredProxyGroups) out.filteredProxyGroups = filteredProxyGroups; - if (Array.isArray(raw.customRuleSets)) { + if (isRecord(migratedRaw.proxyGroupAdvanced)) { + out.proxyGroupAdvanced = migratedRaw.proxyGroupAdvanced as ConfigState["proxyGroupAdvanced"]; + } + if (Array.isArray(migratedRaw.customRuleSets)) { out.customRuleSets = ruleModel.customRuleSets; } - if (isRecord(raw.builtinRuleEdits)) { + if (isRecord(migratedRaw.builtinRuleEdits)) { out.builtinRuleEdits = ruleModel.builtinRuleEdits; } - const customRules = objectArray(raw.customRules); + const customRules = objectArray(migratedRaw.customRules); if (customRules) out.customRules = customRules; - const dialerProxyGroups = objectArray(raw.dialerProxyGroups); + const dialerProxyGroups = objectArray(migratedRaw.dialerProxyGroups); if (dialerProxyGroups) out.dialerProxyGroups = dialerProxyGroups; - if (isStringRecord(raw.proxyGroupNameOverrides)) out.proxyGroupNameOverrides = raw.proxyGroupNameOverrides; - const proxyGroupOrder = stringArray(raw.proxyGroupOrder); + if (isStringRecord(migratedRaw.proxyGroupNameOverrides)) out.proxyGroupNameOverrides = migratedRaw.proxyGroupNameOverrides; + const proxyGroupOrder = stringArray(migratedRaw.proxyGroupOrder); if (proxyGroupOrder) out.proxyGroupOrder = proxyGroupOrder; - const ruleOrder = stringArray(raw.ruleOrder); + const ruleOrder = stringArray(migratedRaw.ruleOrder); if (ruleOrder) out.ruleOrder = ruleOrder; - if (typeof raw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = raw.moduleRuleEditWarningAccepted; - if (typeof raw.appliedTemplateId === "string" || raw.appliedTemplateId === null) { - out.appliedTemplateId = raw.appliedTemplateId; + if (typeof migratedRaw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = migratedRaw.moduleRuleEditWarningAccepted; + if (typeof migratedRaw.appliedTemplateId === "string" || migratedRaw.appliedTemplateId === null) { + out.appliedTemplateId = migratedRaw.appliedTemplateId; } - if (typeof raw.dnsYaml === "string") out.dnsYaml = raw.dnsYaml; - if (typeof raw.mixedPort === "number" && Number.isFinite(raw.mixedPort)) out.mixedPort = raw.mixedPort; - 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.ruleProviderBaseUrl === "string") out.ruleProviderBaseUrl = raw.ruleProviderBaseUrl; - if (typeof raw.cnIpNoResolve === "boolean") out.cnIpNoResolve = raw.cnIpNoResolve; - if (typeof raw.experimentalCnUseCnRuleSet === "boolean") { - out.experimentalCnUseCnRuleSet = raw.experimentalCnUseCnRuleSet; + if (typeof migratedRaw.dnsYaml === "string") out.dnsYaml = migratedRaw.dnsYaml; + if (typeof migratedRaw.mixedPort === "number" && Number.isFinite(migratedRaw.mixedPort)) out.mixedPort = migratedRaw.mixedPort; + if (typeof migratedRaw.allowLan === "boolean") out.allowLan = migratedRaw.allowLan; + if (typeof migratedRaw.testUrl === "string") out.testUrl = migratedRaw.testUrl; + if (typeof migratedRaw.testInterval === "number" && Number.isFinite(migratedRaw.testInterval)) out.testInterval = migratedRaw.testInterval; + if (typeof migratedRaw.ruleProviderBaseUrl === "string") out.ruleProviderBaseUrl = migratedRaw.ruleProviderBaseUrl; + if (typeof migratedRaw.cnIpNoResolve === "boolean") out.cnIpNoResolve = migratedRaw.cnIpNoResolve; + if (typeof migratedRaw.experimentalCnUseCnRuleSet === "boolean") { + out.experimentalCnUseCnRuleSet = migratedRaw.experimentalCnUseCnRuleSet; } - const listenerPorts = numberRecord(raw.listenerPorts); + const listenerPorts = numberRecord(migratedRaw.listenerPorts); if (listenerPorts) out.listenerPorts = listenerPorts; return out; diff --git a/packages/ui/src/store/config-store/definitions.ts b/packages/ui/src/store/config-store/definitions.ts index cfacdbc..e3600a2 100644 --- a/packages/ui/src/store/config-store/definitions.ts +++ b/packages/ui/src/store/config-store/definitions.ts @@ -4,12 +4,12 @@ import type { CustomProxyGroup, CustomRule, CustomRuleSet, + ProxyGroupAdvancedConfig, TemplateType, } from "@subboost/core/types/config"; import { DEFAULT_BASE_CONFIG_YAML, DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { getBuiltinTemplateId } from "@subboost/core/templates/builtin"; import { TEMPLATES } from "@subboost/core/templates"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; import type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; import { isSubscriptionImportError, @@ -30,7 +30,7 @@ import { export { DEFAULT_BASE_CONFIG_YAML }; export type RuleSetDraft = Omit; -export type { BuiltinRuleEdits, CustomRuleSet }; +export type { BuiltinRuleEdits, CustomRuleSet, ProxyGroupAdvancedConfig }; export type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; // 预设的中转组名称 @@ -173,7 +173,7 @@ export interface ConfigState { enabledProxyGroups: string[]; hiddenProxyGroups: string[]; // 隐藏的内置代理组(仅影响 UI,不参与生成) customProxyGroups: CustomProxyGroup[]; // 自定义分流组 - filteredProxyGroups: FilteredProxyGroup[]; // 筛选代理组(从节点池派生) + proxyGroupAdvanced: Record; // 内置分流组高级筛选/排序配置 customRuleSets: CustomRuleSet[]; // 用户新增规则集,统一进入自定义规则块 builtinRuleEdits: BuiltinRuleEdits; // 内置规则的目标覆盖或禁用状态 customRules: CustomRule[]; @@ -183,7 +183,7 @@ export interface ConfigState { proxyGroupNameOverrides: Record; // 代理组顺序(影响 Clash 客户端中的展示顺序;由可视化预览拖拽维护) - // Key 格式:module: / custom: / filtered: / dialer: + // Key 格式:module: / custom: / dialer: proxyGroupOrder: string[]; // 用户可编辑规则窗口顺序 @@ -257,10 +257,8 @@ export interface ConfigActions { // 代理组顺序 setProxyGroupOrder: (order: string[]) => void; - // 筛选代理组 - addFilteredProxyGroup: (group: Omit) => void; - removeFilteredProxyGroup: (id: string) => void; - updateFilteredProxyGroup: (id: string, group: Partial) => void; + // 分流组高级配置 + updateProxyGroupAdvanced: (moduleId: string, patch: Partial) => void; // 规则集与内置规则编辑 addModuleRules: (moduleId: string, rules: RuleSetDraft[]) => void; @@ -340,7 +338,7 @@ export const initialState: ConfigState = { enabledProxyGroups: TEMPLATES.minimal.groups, hiddenProxyGroups: [], customProxyGroups: [], // 自定义分流组 - filteredProxyGroups: [], + proxyGroupAdvanced: {}, customRuleSets: [], builtinRuleEdits: {}, customRules: [], diff --git a/packages/ui/src/store/config-store/generated-yaml.ts b/packages/ui/src/store/config-store/generated-yaml.ts index d0aeefc..b202822 100644 --- a/packages/ui/src/store/config-store/generated-yaml.ts +++ b/packages/ui/src/store/config-store/generated-yaml.ts @@ -76,7 +76,7 @@ function buildGenerateClashYamlOptions( customProxyGroups: state.customProxyGroups, customRuleSets: state.customRuleSets, builtinRuleEdits: state.builtinRuleEdits, - filteredProxyGroups: state.filteredProxyGroups, + proxyGroupAdvanced: state.proxyGroupAdvanced, proxyGroupNameOverrides: state.proxyGroupNameOverrides, proxyGroupOrder: state.proxyGroupOrder, }; From 3be7f45d3cf158e561c9d00a93cd3a1172ce426f Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:32:28 +0800 Subject: [PATCH 15/29] fix: default custom proxy groups expanded --- .../sections/proxy-groups-categories.test.ts | 38 ++++++++++++++----- .../sections/proxy-groups-categories.tsx | 15 +++++++- 2 files changed, 43 insertions(+), 10 deletions(-) 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 14982dd..22a73f0 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 @@ -28,6 +28,14 @@ vi.mock("react", async (importOriginal) => { if (stateMock.enabled) return factory(); return actual.useMemo(factory, deps ?? []); }, + useEffect: (effect: React.EffectCallback, deps?: React.DependencyList) => { + if (stateMock.enabled) return undefined; + return actual.useEffect(effect, deps); + }, + useRef: (initial: unknown) => { + if (stateMock.enabled) return { current: initial }; + return actual.useRef(initial); + }, useState: (initial: unknown) => { if (!stateMock.enabled) return actual.useState(initial); const index = stateMock.callIndex++; @@ -209,11 +217,23 @@ describe("ProxyGroupsCategories", () => { }; }); - it("renders visible and hidden modules and forwards basic config changes", () => { + it("opens only the custom category by default when custom groups exist", () => { + renderCategories(); + + expect(mocks.captures.customPanelRendered).toBe(true); + expect(mocks.captures.moduleCards).toHaveLength(0); + + mocks.store.customProxyGroups = []; renderCategories(); + expect(mocks.captures.customPanelRendered).toBe(false); + expect(mocks.captures.moduleCards).toHaveLength(0); + }); + + it("renders visible and hidden modules and forwards basic config changes", () => { + renderCategories({ 1: new Set(["core"]) }); expect(mocks.captures.inputs).toHaveLength(0); - expect(renderCategories().html).toContain("https://rules.example/base/"); + expect(renderCategories({ 1: new Set(["core"]) }).html).toContain("https://rules.example/base/"); expect(mocks.store.setRuleProviderBaseUrl).not.toHaveBeenCalled(); expect(mocks.captures.moduleCards).toHaveLength(1); @@ -234,7 +254,7 @@ describe("ProxyGroupsCategories", () => { }); it("handles module card actions, confirmations, and rule movement", async () => { - const { setters } = renderCategories(); + const { setters } = renderCategories({ 1: new Set(["core"]) }); const card = mocks.captures.moduleCards[0]; await card.onToggleEnabled(); @@ -275,7 +295,7 @@ describe("ProxyGroupsCategories", () => { }); it("renames modules and adds rules to custom groups with duplicate guards", () => { - const { setters } = renderCategories({ 2: "auto", 3: "Custom" }); + const { setters } = renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "Custom" }); const card = mocks.captures.moduleCards[0]; card.onStartEditing(); @@ -288,15 +308,15 @@ describe("ProxyGroupsCategories", () => { card.onCancelEditing(); expect(setters[2]).toHaveBeenCalledWith(null); - renderCategories({ 2: "auto", 3: "" }); + renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 2: "auto", 3: "Auto" }); + renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "Auto" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 2: "auto", 3: "Unique" }); + renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "Unique" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.setProxyGroupNameOverride).toHaveBeenCalledWith("auto", "Unique"); @@ -306,12 +326,12 @@ describe("ProxyGroupsCategories", () => { mocks.store.customRuleSets = [{ id: "geo", name: "Geo", behavior: "domain", path: "geo.txt", target: "Custom" }]; mocks.store.addModuleRules.mockClear(); - renderCategories(); + renderCategories({ 1: new Set(["core"]) }); mocks.captures.moduleCards[0].onAddRuleToCustomGroup("custom-1", customRule); expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [customRule]); mocks.store.customProxyGroups = [{ id: "custom-1", name: "Custom" }]; - renderCategories(); + renderCategories({ 1: new Set(["core"]) }); mocks.captures.moduleCards[0].onAddRuleToCustomGroup("missing", customRule); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); 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 0195521..c928c1e 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 @@ -37,6 +37,7 @@ import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; 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"; +const CUSTOM_CATEGORY_ID = "custom"; export function ProxyGroupsCategories() { const { @@ -77,7 +78,8 @@ export function ProxyGroupsCategories() { const [expandedCategories, setExpandedCategories] = React.useState< Set - >(new Set(["core", "service"])); + >(new Set(customProxyGroups.length > 0 ? [CUSTOM_CATEGORY_ID] : [])); + const didApplyCustomCategoryDefault = React.useRef(customProxyGroups.length > 0); const [editingModuleId, setEditingModuleId] = React.useState( null, ); @@ -85,6 +87,17 @@ export function ProxyGroupsCategories() { const [expandedModuleRules, setExpandedModuleRules] = React.useState< Set >(new Set()); + React.useEffect(() => { + if (didApplyCustomCategoryDefault.current || customProxyGroups.length === 0) return; + didApplyCustomCategoryDefault.current = true; + setExpandedCategories((prev) => { + if (prev.has(CUSTOM_CATEGORY_ID)) return prev; + const next = new Set(prev); + next.add(CUSTOM_CATEGORY_ID); + return next; + }); + }, [customProxyGroups.length]); + const toggleCategory = (category: string) => { setExpandedCategories((prev) => { const next = new Set(prev); From 0f67ea953f7c9b4de0bc3ba4dc19089194077027 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:21:37 +0800 Subject: [PATCH 16/29] Remove legacy filtered group runtime support --- packages/core/package.json | 2 +- packages/core/src/config/defaults.test.ts | 1 + packages/core/src/config/defaults.ts | 1 + .../src/migrations/filtered-proxy-groups.ts | 176 ------------------ .../core/src/proxy-group-advanced-mode.ts | 24 +++ .../src/subscription/config-utils.test.ts | 49 ----- .../core/src/subscription/config-utils.ts | 3 +- .../templates/config-template.fields.test.ts | 4 + .../templates/config-template.groups.test.ts | 38 +--- .../src/templates/config-template.test.ts | 34 +--- .../core/src/templates/config-template.ts | 60 +++--- packages/core/src/types/template-config.ts | 1 + .../sections/proxy-groups-categories.test.ts | 42 +++-- .../sections/proxy-groups-categories.tsx | 11 +- .../use-editing-subscription-loader.test.ts | 10 +- .../home/use-editing-subscription-loader.ts | 12 +- .../home/use-subscription-link.test.ts | 2 + .../product/home/use-subscription-link.tsx | 1 + .../config-store/actions/settings-actions.ts | 5 + .../actions/template-actions.test.ts | 21 +-- .../config-store/actions/template-actions.ts | 81 ++++---- .../store/config-store/auth-handoff.test.ts | 5 +- .../ui/src/store/config-store/auth-handoff.ts | 77 ++++---- .../ui/src/store/config-store/definitions.ts | 3 + .../ui/src/store/config-store/persistence.ts | 4 + 25 files changed, 221 insertions(+), 446 deletions(-) delete mode 100644 packages/core/src/migrations/filtered-proxy-groups.ts create mode 100644 packages/core/src/proxy-group-advanced-mode.ts diff --git a/packages/core/package.json b/packages/core/package.json index 20fc999..d1dc618 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -13,12 +13,12 @@ "./generator/yaml-semantics": "./src/generator/yaml-semantics.ts", "./json": "./src/json.ts", "./mihomo/*": "./src/mihomo/*.ts", - "./migrations/*": "./src/migrations/*.ts", "./node-name-template": "./src/node-name-template.ts", "./node-identity": "./src/node-identity.ts", "./parser": "./src/parser/index.ts", "./parser/*": "./src/parser/*.ts", "./proxy-group-name": "./src/proxy-group-name.ts", + "./proxy-group-advanced-mode": "./src/proxy-group-advanced-mode.ts", "./rules/*": "./src/rules/*.ts", "./rules-database": "./src/rules-database.ts", "./subscription/*": "./src/subscription/*.ts", diff --git a/packages/core/src/config/defaults.test.ts b/packages/core/src/config/defaults.test.ts index 4e83a7b..bf30eb4 100644 --- a/packages/core/src/config/defaults.test.ts +++ b/packages/core/src/config/defaults.test.ts @@ -68,6 +68,7 @@ describe("default config builders", () => { hiddenProxyGroups: [], customProxyGroups: [], proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, customRuleSets: [], builtinRuleEdits: {}, customRules: [], diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index f070cea..fb0f8d8 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -86,6 +86,7 @@ export function buildDefaultSubBoostTemplateConfig(type: TemplateType): SubBoost hiddenProxyGroups: [], customProxyGroups: [], proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, customRuleSets: [], builtinRuleEdits: {}, customRules: [], diff --git a/packages/core/src/migrations/filtered-proxy-groups.ts b/packages/core/src/migrations/filtered-proxy-groups.ts deleted file mode 100644 index c48d04e..0000000 --- a/packages/core/src/migrations/filtered-proxy-groups.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; -import type { ProxyGroupAdvancedConfig } from "@subboost/core/types/config"; - -type MutableRecord = Record; -const LEGACY_GROUP_TYPES = new Set(["select", "url-test", "fallback", "load-balance", "direct-first", "reject-first"]); - -function isRecord(value: unknown): value is MutableRecord { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function stringValue(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function uniqueStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - const out: string[] = []; - const seen = new Set(); - for (const item of value) { - const text = stringValue(item); - if (!text || seen.has(text)) continue; - seen.add(text); - out.push(text); - } - return out; -} - -function makeUniqueName(name: string, used: Set): string { - const base = name.trim() || "自定义代理组"; - if (!used.has(base)) { - used.add(base); - return base; - } - let index = 2; - let candidate = `${base} (${index})`; - while (used.has(candidate)) { - index += 1; - candidate = `${base} (${index})`; - } - used.add(candidate); - return candidate; -} - -function makeUniqueId(id: string, used: Set): string { - const base = `migrated-filtered-${id || "group"}`; - if (!used.has(base)) { - used.add(base); - return base; - } - let index = 2; - let candidate = `${base}-${index}`; - while (used.has(candidate)) { - index += 1; - candidate = `${base}-${index}`; - } - used.add(candidate); - return candidate; -} - -function migrateAdvanced(group: MutableRecord): ProxyGroupAdvancedConfig { - return normalizeProxyGroupAdvancedConfig({ - sourceIds: uniqueStringArray(group.sourceIds), - regions: uniqueStringArray(group.regions), - includeRegex: stringValue(group.includeRegex), - excludeRegex: stringValue(group.excludeRegex), - excludedMembers: uniqueStringArray(group.excludedNodeNames).map((name) => ({ - kind: "node" as const, - name, - })), - }); -} - -function retargetValue(value: unknown, nameMap: Map): unknown { - const target = stringValue(value); - return target && nameMap.has(target) ? nameMap.get(target) : value; -} - -function retargetStringArray(value: unknown, nameMap: Map): unknown { - if (!Array.isArray(value)) return value; - return value.map((item) => (typeof item === "string" ? nameMap.get(item.trim()) ?? item : item)); -} - -export function migrateFilteredProxyGroupsConfig(config: T): T { - if (!isRecord(config)) { - return config; - } - const record = config; - const filteredProxyGroups = record.filteredProxyGroups; - if (!Array.isArray(filteredProxyGroups) || filteredProxyGroups.length === 0) { - return config; - } - - const next: MutableRecord = { ...record }; - const existingCustomGroups = Array.isArray(next.customProxyGroups) - ? next.customProxyGroups.filter(isRecord) - : []; - const usedIds = new Set(existingCustomGroups.map((group) => stringValue(group.id)).filter(Boolean)); - const usedNames = new Set(existingCustomGroups.map((group) => stringValue(group.name)).filter(Boolean)); - const nameMap = new Map(); - const idMap = new Map(); - const migratedGroups: MutableRecord[] = []; - - for (const rawGroup of filteredProxyGroups) { - if (!isRecord(rawGroup)) continue; - if (rawGroup.enabled === false) continue; - const oldId = stringValue(rawGroup.id); - const oldName = stringValue(rawGroup.name); - if (!oldId || !oldName) continue; - - const nextId = makeUniqueId(oldId, usedIds); - const nextName = makeUniqueName(oldName, usedNames); - idMap.set(oldId, nextId); - nameMap.set(oldName, nextName); - - const rawGroupType = stringValue(rawGroup.groupType) || "select"; - const groupType = LEGACY_GROUP_TYPES.has(rawGroupType) ? rawGroupType : "select"; - migratedGroups.push({ - id: nextId, - name: nextName, - emoji: stringValue(rawGroup.emoji), - description: "自定义代理组", - groupType, - ...(groupType === "load-balance" && stringValue(rawGroup.strategy) - ? { strategy: stringValue(rawGroup.strategy) } - : {}), - advanced: migrateAdvanced(rawGroup), - }); - } - - next.customProxyGroups = [...existingCustomGroups, ...migratedGroups]; - delete next.filteredProxyGroups; - - if (Array.isArray(next.customRules)) { - next.customRules = next.customRules.map((rule) => { - if (!isRecord(rule) || !("target" in rule)) return rule; - return { ...rule, target: retargetValue(rule.target, nameMap) }; - }); - } - - if (Array.isArray(next.customRuleSets)) { - next.customRuleSets = next.customRuleSets.map((ruleSet) => { - if (!isRecord(ruleSet) || !("target" in ruleSet)) return ruleSet; - return { ...ruleSet, target: retargetValue(ruleSet.target, nameMap) }; - }); - } - - if (isRecord(next.builtinRuleEdits)) { - next.builtinRuleEdits = Object.fromEntries( - Object.entries(next.builtinRuleEdits).map(([key, edit]) => { - if (!isRecord(edit)) return [key, edit]; - const nextEdit = { ...edit }; - if ("target" in edit) nextEdit.target = retargetValue(edit.target, nameMap); - return [key, nextEdit]; - }) - ); - } - - if (Array.isArray(next.dialerProxyGroups)) { - next.dialerProxyGroups = next.dialerProxyGroups.map((group) => { - if (!isRecord(group) || !("relayNodes" in group)) return group; - return { ...group, relayNodes: retargetStringArray(group.relayNodes, nameMap) }; - }); - } - - if (Array.isArray(next.proxyGroupOrder)) { - next.proxyGroupOrder = next.proxyGroupOrder.map((key) => { - const text = stringValue(key); - if (!text.startsWith("filtered:")) return key; - const id = text.slice("filtered:".length); - const nextId = idMap.get(id); - return nextId ? `custom:${nextId}` : key; - }); - } - - return next as T; -} diff --git a/packages/core/src/proxy-group-advanced-mode.ts b/packages/core/src/proxy-group-advanced-mode.ts new file mode 100644 index 0000000..97a6820 --- /dev/null +++ b/packages/core/src/proxy-group-advanced-mode.ts @@ -0,0 +1,24 @@ +export type ProxyGroupAdvancedModeSource = { + proxyGroupAdvancedModeEnabled?: unknown; + customProxyGroups?: unknown; + proxyGroupAdvanced?: unknown; +}; + +function hasArrayItems(value: unknown): boolean { + return Array.isArray(value) && value.length > 0; +} + +function hasRecordEntries(value: unknown): boolean { + return Boolean(value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0); +} + +export function shouldEnableProxyGroupAdvancedModeByDefault(source: ProxyGroupAdvancedModeSource): boolean { + return hasArrayItems(source.customProxyGroups) || hasRecordEntries(source.proxyGroupAdvanced); +} + +export function resolveProxyGroupAdvancedModeEnabled(source: ProxyGroupAdvancedModeSource): boolean { + if (typeof source.proxyGroupAdvancedModeEnabled === "boolean") { + return source.proxyGroupAdvancedModeEnabled; + } + return shouldEnableProxyGroupAdvancedModeByDefault(source); +} diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 86d4e80..b6bf695 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -68,20 +68,6 @@ describe("subscription config utils", () => { targetNodes: [" Target "], }, ], - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "load-balance", - strategy: "round-robin", - sourceIds: ["airport"], - regions: ["US", "bad"], - includeRegex: " Fast ", - excludeRegex: " IPv6 ", - excludedNodeNames: [" Slow "], - }, - ], listenerPorts: { Node: 12000, Bad: 70000, @@ -131,19 +117,6 @@ describe("subscription config utils", () => { id: "media", strategy: "consistent-hashing", }); - expect(options.customProxyGroups?.[1]).toMatchObject({ - id: "migrated-filtered-filtered", - name: "Filtered", - groupType: "load-balance", - strategy: "round-robin", - advanced: { - sourceIds: ["airport"], - regions: ["us"], - includeRegex: "Fast", - excludeRegex: "IPv6", - excludedMembers: [{ kind: "node", name: "Slow" }], - }, - }); expect(options.customRuleSets?.[0]).toMatchObject({ id: "youtube", name: "YouTube", @@ -187,18 +160,6 @@ describe("subscription config utils", () => { { id: "bad-path", name: "Bad", behavior: "domain", path: "plain.txt", target: "Fallback" }, ], dialerProxyGroups: ["bad", { id: "bad", name: "Bad", type: "bad" }], - filteredProxyGroups: [ - "bad", - { id: "", name: "Bad", enabled: true }, - { - id: "select-default", - name: "Select Default", - enabled: false, - groupType: "unknown", - emoji: "S", - regions: "us", - }, - ], listenerPorts: "bad", proxyGroupNameOverrides: "bad", proxyGroupOrder: [], @@ -241,11 +202,6 @@ describe("subscription config utils", () => { { id: "select", name: "Select", emoji: "S", groupType: "select" }, { id: "url-test", name: "Auto", emoji: "A", groupType: "url-test" }, ], - filteredProxyGroups: [ - { id: "fallback", name: "Fallback", enabled: true, groupType: "fallback", sourceIds: [123, "airport"] }, - { id: "direct", name: "Direct", enabled: true, groupType: "direct-first", regions: ["HK", "other"] }, - { id: "reject", name: "Reject", enabled: true, groupType: "reject-first" }, - ], }, { nodes: [node()] } ); @@ -256,11 +212,6 @@ describe("subscription config utils", () => { expect(minimal.customProxyGroups?.map((group) => group.groupType)).toEqual([ "select", "url-test", - "fallback", - "direct-first", - "reject-first", ]); - expect(minimal.customProxyGroups?.[2].advanced?.sourceIds).toEqual(["airport"]); - expect(minimal.customProxyGroups?.[3].advanced?.regions).toEqual(["hk", "other"]); }); }); diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 325fcf9..a02f453 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -19,7 +19,6 @@ import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; -import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -231,7 +230,7 @@ export function buildGenerateOptionsFromConfig( proxyProviders?: Record; } ): GenerateOptions { - const config = migrateFilteredProxyGroupsConfig(rawConfig); + const config = rawConfig; const { testUrl, testInterval } = getEffectiveTestOptions(config); const proxyProviders = opts.proxyProviders ?? buildProxyProvidersFromConfig(config, { testUrl, testInterval }); diff --git a/packages/core/src/templates/config-template.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index 6ea5a9f..0870d73 100644 --- a/packages/core/src/templates/config-template.fields.test.ts +++ b/packages/core/src/templates/config-template.fields.test.ts @@ -189,6 +189,10 @@ describe("validateSubBoostTemplateConfig field validation", () => { { experimentalCnUseCnRuleSet: "yes" as never }, "experimentalCnUseCnRuleSet 必须是布尔值" ); + expectInvalid( + { proxyGroupAdvancedModeEnabled: "yes" as never }, + "proxyGroupAdvancedModeEnabled 必须是布尔值" + ); expect(validateSubBoostTemplateConfig(validConfig({ dnsYaml: 1 as never }))).toEqual({ ok: false, error: "dnsYaml 必须是字符串", diff --git a/packages/core/src/templates/config-template.groups.test.ts b/packages/core/src/templates/config-template.groups.test.ts index 71c1938..d6ee27a 100644 --- a/packages/core/src/templates/config-template.groups.test.ts +++ b/packages/core/src/templates/config-template.groups.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { validateSubBoostTemplateConfig } from "@subboost/core/templates/config-template"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; -describe("validateSubBoostTemplateConfig custom and filtered groups", () => { - it("rejects invalid custom and filtered group fields", () => { +describe("validateSubBoostTemplateConfig custom groups", () => { + it("rejects invalid custom group fields", () => { expectInvalid({ customRules: "bad" as never }, "customRules 必须是数组"); expectInvalid({ customRules: [1 as never] }, "customRules 只能包含对象"); expectInvalid( @@ -145,37 +145,7 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { ) ).toEqual({ ok: false, error: "customProxyGroups.strategy 无效" }); - expect( - validateSubBoostTemplateConfig( - validConfig({ - filteredProxyGroups: [ - { - id: "legacy", - name: "Legacy", - enabled: true, - groupType: "select", - sourceIds: ["source-a"], - regions: ["moon" as never, "hk"], - excludedNodeNames: ["Node A"], - }, - ], - }) - ) - ).toMatchObject({ - ok: true, - config: { - customProxyGroups: [ - expect.objectContaining({ - id: "migrated-filtered-legacy", - name: "Legacy", - advanced: { - sourceIds: ["source-a"], - regions: ["hk"], - excludedMembers: [{ kind: "node", name: "Node A" }], - }, - }), - ], - }, - }); + const removedField = `filtered${"ProxyGroups"}`; + expectInvalid({ [removedField]: [] } as never, `模板配置包含已移除字段: ${removedField}`); }); }); diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index 4d1ea4d..30d72a7 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -80,20 +80,6 @@ describe("validateSubBoostTemplateConfig", () => { noResolve: true, }, ], - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "load-balance", - sourceIds: ["source-a", "source-a", ""], - regions: ["us", "hk"], - excludedNodeNames: ["Node A", "Node A"], - includeRegex: " US ", - excludeRegex: "IPv6", - emoji: "F", - }, - ], customRules: [ { id: "custom-rule-a", @@ -150,20 +136,7 @@ describe("validateSubBoostTemplateConfig", () => { }), ]) ); - expect(result.config.customProxyGroups[1]).toMatchObject({ - id: "migrated-filtered-filtered", - name: "Filtered", - groupType: "load-balance", - strategy: DEFAULT_LOAD_BALANCE_STRATEGY, - emoji: "F", - advanced: { - sourceIds: ["source-a"], - regions: ["us", "hk"], - excludedMembers: [{ kind: "node", name: "Node A" }], - includeRegex: "US", - excludeRegex: "IPv6", - }, - }); + expect(result.config.proxyGroupAdvancedModeEnabled).toBe(true); expect(result.config.customRules[0]).toMatchObject({ id: "custom-rule-a", value: "example.com", @@ -187,6 +160,11 @@ describe("validateSubBoostTemplateConfig", () => { expectInvalid({ moduleRuleOverrides: {} } as never, "模板配置包含已移除字段: moduleRuleOverrides"); expectInvalid({ moduleRuleExclusions: {} } as never, "模板配置包含已移除字段: moduleRuleExclusions"); expectInvalid({ allRulesOrderEditingEnabled: true } as never, "模板配置包含已移除字段: allRulesOrderEditingEnabled"); + const removedFilteredGroupsField = `filtered${"ProxyGroups"}`; + expectInvalid( + { [removedFilteredGroupsField]: [] } as never, + `模板配置包含已移除字段: ${removedFilteredGroupsField}` + ); expectInvalid( { customProxyGroups: [{ id: "custom", name: "Custom", emoji: "", groupType: "select", rules: [] }] } as never, "模板配置包含已移除字段: customProxyGroups[0].rules" diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 6c9b726..11705bb 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -1,7 +1,7 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRuleId, isCustomRuleType } from "@subboost/core/rules/custom-rule-utils"; -import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; +import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { isValidRuleSetPathOrUrl, @@ -34,65 +34,70 @@ const REMOVED_TEMPLATE_FIELDS = new Set([ "moduleRuleOverrides", "moduleRuleExclusions", "allRulesOrderEditingEnabled", + "filteredProxyGroups", ]); export function validateSubBoostTemplateConfig(value: unknown): ValidationResult { - const migratedValue = migrateFilteredProxyGroupsConfig(value); - if (!isRecord(migratedValue)) return invalid("模板配置必须是对象"); - if (migratedValue.schema !== SUBBOOST_TEMPLATE_CONFIG_SCHEMA) { + if (!isRecord(value)) return invalid("模板配置必须是对象"); + if (value.schema !== SUBBOOST_TEMPLATE_CONFIG_SCHEMA) { return invalid("模板配置 schema 无效"); } - const removedField = findRemovedTemplateField(migratedValue); + const removedField = findRemovedTemplateField(value); if (removedField) return invalid(`模板配置包含已移除字段: ${removedField}`); - const template = parseTemplateType(migratedValue.template); + const template = parseTemplateType(value.template); if (!template) return invalid("模板类型无效"); - const enabledProxyGroups = parseModuleIdArray(migratedValue.enabledProxyGroups, "enabledProxyGroups", { required: true }); + const enabledProxyGroups = parseModuleIdArray(value.enabledProxyGroups, "enabledProxyGroups", { required: true }); if (!enabledProxyGroups.ok) return enabledProxyGroups; if (enabledProxyGroups.value.length === 0) return invalid("至少需要一个代理组"); - const hiddenProxyGroups = parseModuleIdArray(migratedValue.hiddenProxyGroups, "hiddenProxyGroups", { required: false }); + const hiddenProxyGroups = parseModuleIdArray(value.hiddenProxyGroups, "hiddenProxyGroups", { required: false }); if (!hiddenProxyGroups.ok) return hiddenProxyGroups; const hiddenSet = new Set(hiddenProxyGroups.value); if (enabledProxyGroups.value.every((id) => hiddenSet.has(id))) { return invalid("至少需要一个可见代理组"); } - const customProxyGroups = parseCustomProxyGroups(migratedValue.customProxyGroups); + const customProxyGroups = parseCustomProxyGroups(value.customProxyGroups); if (!customProxyGroups.ok) return customProxyGroups; - const proxyGroupAdvanced = parseProxyGroupAdvanced(migratedValue.proxyGroupAdvanced); + const proxyGroupAdvanced = parseProxyGroupAdvanced(value.proxyGroupAdvanced); if (!proxyGroupAdvanced.ok) return proxyGroupAdvanced; - const customRuleSets = parseCustomRuleSets(migratedValue.customRuleSets); + const proxyGroupAdvancedModeEnabled = parseOptionalBoolean( + value.proxyGroupAdvancedModeEnabled, + "proxyGroupAdvancedModeEnabled" + ); + if (!proxyGroupAdvancedModeEnabled.ok) return proxyGroupAdvancedModeEnabled; + const customRuleSets = parseCustomRuleSets(value.customRuleSets); if (!customRuleSets.ok) return customRuleSets; - const builtinRuleEdits = parseBuiltinRuleEdits(migratedValue.builtinRuleEdits); + const builtinRuleEdits = parseBuiltinRuleEdits(value.builtinRuleEdits); if (!builtinRuleEdits.ok) return builtinRuleEdits; - const ruleModel = normalizeRuleModelFromConfig(migratedValue); - const customRules = parseCustomRules(migratedValue.customRules); + const ruleModel = normalizeRuleModelFromConfig(value); + const customRules = parseCustomRules(value.customRules); if (!customRules.ok) return customRules; - const dialerProxyGroups = parseDialerProxyGroups(migratedValue.dialerProxyGroups); + const dialerProxyGroups = parseDialerProxyGroups(value.dialerProxyGroups); if (!dialerProxyGroups.ok) return dialerProxyGroups; - const proxyGroupNameOverrides = parseStringRecord(migratedValue.proxyGroupNameOverrides, "proxyGroupNameOverrides"); + const proxyGroupNameOverrides = parseStringRecord(value.proxyGroupNameOverrides, "proxyGroupNameOverrides"); if (!proxyGroupNameOverrides.ok) return proxyGroupNameOverrides; - const ruleOrder = parseOptionalStringArray(migratedValue.ruleOrder, "ruleOrder"); + const ruleOrder = parseOptionalStringArray(value.ruleOrder, "ruleOrder"); if (!ruleOrder.ok) return ruleOrder; - const dnsYaml = parseRequiredString(migratedValue.dnsYaml, "dnsYaml", { allowEmpty: true }); + const dnsYaml = parseRequiredString(value.dnsYaml, "dnsYaml", { allowEmpty: true }); if (!dnsYaml.ok) return dnsYaml; - const mixedPort = parsePort(migratedValue.mixedPort, "mixedPort"); + const mixedPort = parsePort(value.mixedPort, "mixedPort"); if (!mixedPort.ok) return mixedPort; - const allowLan = parseBoolean(migratedValue.allowLan, "allowLan"); + const allowLan = parseBoolean(value.allowLan, "allowLan"); if (!allowLan.ok) return allowLan; - const testUrl = parseHttpUrlString(migratedValue.testUrl, "testUrl"); + const testUrl = parseHttpUrlString(value.testUrl, "testUrl"); if (!testUrl.ok) return testUrl; - const testInterval = parsePositiveInteger(migratedValue.testInterval, "testInterval"); + const testInterval = parsePositiveInteger(value.testInterval, "testInterval"); if (!testInterval.ok) return testInterval; - const ruleProviderBaseUrl = parseHttpUrlString(migratedValue.ruleProviderBaseUrl, "ruleProviderBaseUrl"); + const ruleProviderBaseUrl = parseHttpUrlString(value.ruleProviderBaseUrl, "ruleProviderBaseUrl"); if (!ruleProviderBaseUrl.ok) return ruleProviderBaseUrl; - const cnIpNoResolve = parseOptionalBoolean(migratedValue.cnIpNoResolve, "cnIpNoResolve"); + const cnIpNoResolve = parseOptionalBoolean(value.cnIpNoResolve, "cnIpNoResolve"); if (!cnIpNoResolve.ok) return cnIpNoResolve; const experimentalCnUseCnRuleSet = parseOptionalBoolean( - migratedValue.experimentalCnUseCnRuleSet, + value.experimentalCnUseCnRuleSet, "experimentalCnUseCnRuleSet" ); if (!experimentalCnUseCnRuleSet.ok) return experimentalCnUseCnRuleSet; @@ -117,6 +122,11 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult hiddenProxyGroups: hiddenProxyGroups.value, customProxyGroups: customProxyGroups.value, proxyGroupAdvanced: proxyGroupAdvanced.value, + proxyGroupAdvancedModeEnabled: resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: proxyGroupAdvancedModeEnabled.value, + customProxyGroups: customProxyGroups.value, + proxyGroupAdvanced: proxyGroupAdvanced.value, + }), customRuleSets: ruleModel.customRuleSets, builtinRuleEdits: ruleModel.builtinRuleEdits, customRules: customRules.value, diff --git a/packages/core/src/types/template-config.ts b/packages/core/src/types/template-config.ts index e766234..81b278d 100644 --- a/packages/core/src/types/template-config.ts +++ b/packages/core/src/types/template-config.ts @@ -27,6 +27,7 @@ export type SubBoostTemplateConfig = { hiddenProxyGroups?: string[]; customProxyGroups: CustomProxyGroup[]; proxyGroupAdvanced?: Record; + proxyGroupAdvancedModeEnabled?: boolean; customRuleSets: CustomRuleSet[]; builtinRuleEdits?: BuiltinRuleEdits; customRules: CustomRule[]; 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 22a73f0..7b2f52a 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 @@ -213,6 +213,8 @@ describe("ProxyGroupsCategories", () => { customProxyGroups: [{ id: "custom-1", name: "Custom" }], dialerProxyGroups: [{ name: "Dialer" }], proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, + setProxyGroupAdvancedModeEnabled: vi.fn(), updateProxyGroupAdvanced: vi.fn(), }; }); @@ -230,10 +232,10 @@ describe("ProxyGroupsCategories", () => { }); it("renders visible and hidden modules and forwards basic config changes", () => { - renderCategories({ 1: new Set(["core"]) }); + renderCategories({ 0: new Set(["core"]) }); expect(mocks.captures.inputs).toHaveLength(0); - expect(renderCategories({ 1: new Set(["core"]) }).html).toContain("https://rules.example/base/"); + expect(renderCategories({ 0: new Set(["core"]) }).html).toContain("https://rules.example/base/"); expect(mocks.store.setRuleProviderBaseUrl).not.toHaveBeenCalled(); expect(mocks.captures.moduleCards).toHaveLength(1); @@ -249,12 +251,12 @@ describe("ProxyGroupsCategories", () => { mocks.captures.dropdownItems[0].onClick(); expect(mocks.store.restoreHiddenProxyGroup).toHaveBeenCalledWith("fallback"); - expect(stateMock.setters[1]).toHaveBeenCalledWith(expect.any(Function)); + expect(stateMock.setters[0]).toHaveBeenCalledWith(expect.any(Function)); expect(mocks.captures.customRulesRendered).toBe(true); }); it("handles module card actions, confirmations, and rule movement", async () => { - const { setters } = renderCategories({ 1: new Set(["core"]) }); + const { setters } = renderCategories({ 0: new Set(["core"]) }); const card = mocks.captures.moduleCards[0]; await card.onToggleEnabled(); @@ -264,10 +266,10 @@ describe("ProxyGroupsCategories", () => { await card.onHide(); expect(mocks.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ confirmText: "删除" })); expect(mocks.store.hideProxyGroup).toHaveBeenCalledWith("auto"); - expect(setters[4]).toHaveBeenCalledWith(expect.any(Function)); + expect(setters[3]).toHaveBeenCalledWith(expect.any(Function)); card.onToggleRulesExpanded(); - expect(setters[4]).toHaveBeenCalledWith(expect.any(Function)); + expect(setters[3]).toHaveBeenCalledWith(expect.any(Function)); card.onAddRules([{ id: "rule-a" }]); expect(mocks.store.addModuleRules).toHaveBeenCalledWith("auto", [{ id: "rule-a" }]); card.onAddRulesToModule("fallback", [{ id: "rule-b" }]); @@ -295,28 +297,28 @@ describe("ProxyGroupsCategories", () => { }); it("renames modules and adds rules to custom groups with duplicate guards", () => { - const { setters } = renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "Custom" }); + const { setters } = renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "Custom" }); const card = mocks.captures.moduleCards[0]; card.onStartEditing(); - expect(setters[2]).toHaveBeenCalledWith("auto"); - expect(setters[3]).toHaveBeenCalledWith("Auto Override"); + expect(setters[1]).toHaveBeenCalledWith("auto"); + expect(setters[2]).toHaveBeenCalledWith("Auto Override"); card.onCommitEditing(); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "代理组名称已存在,请换一个名称。", variant: "warning" })); card.onCancelEditing(); - expect(setters[2]).toHaveBeenCalledWith(null); + expect(setters[1]).toHaveBeenCalledWith(null); - renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "" }); + renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "Auto" }); + renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "Auto" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 1: new Set(["core"]), 2: "auto", 3: "Unique" }); + renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "Unique" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.setProxyGroupNameOverride).toHaveBeenCalledWith("auto", "Unique"); @@ -326,12 +328,12 @@ describe("ProxyGroupsCategories", () => { mocks.store.customRuleSets = [{ id: "geo", name: "Geo", behavior: "domain", path: "geo.txt", target: "Custom" }]; mocks.store.addModuleRules.mockClear(); - renderCategories({ 1: new Set(["core"]) }); + renderCategories({ 0: new Set(["core"]) }); mocks.captures.moduleCards[0].onAddRuleToCustomGroup("custom-1", customRule); expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [customRule]); mocks.store.customProxyGroups = [{ id: "custom-1", name: "Custom" }]; - renderCategories({ 1: new Set(["core"]) }); + renderCategories({ 0: new Set(["core"]) }); mocks.captures.moduleCards[0].onAddRuleToCustomGroup("missing", customRule); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); @@ -357,7 +359,7 @@ describe("ProxyGroupsCategories", () => { mocks.store.hiddenProxyGroups = []; mocks.store.enabledProxyGroups = []; mocks.store.dialerProxyGroups = [null, { name: " " }]; - const { setters } = renderCategories({ 1: new Set(["custom", "core"]) }); + const { setters } = renderCategories({ 0: new Set(["custom", "core"]) }); expect(mocks.captures.customPanelRendered).toBe(true); expect(mocks.captures.moduleCards).toHaveLength(2); @@ -373,17 +375,17 @@ describe("ProxyGroupsCategories", () => { }); it("toggles category expansion through the rendered category headers", () => { - const { tree, setters } = renderCategoryTree({ 1: new Set(["core"]) }); + const { tree, setters } = renderCategoryTree({ 0: new Set(["core"]) }); const categoryButtons = collectElements( tree, (element) => element.type === "button" && String(element.props.className || "").includes("w-full flex") ); categoryButtons[0].props.onClick(); - expect(setters[1]).toHaveBeenCalledWith(expect.any(Function)); - expect((setters[1] as any).lastValue.has("core")).toBe(false); + expect(setters[0]).toHaveBeenCalledWith(expect.any(Function)); + expect((setters[0] as any).lastValue.has("core")).toBe(false); categoryButtons[1].props.onClick(); - expect((setters[1] as any).lastValue.has("custom")).toBe(true); + 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 c928c1e..a2848bd 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 @@ -71,10 +71,11 @@ export function ProxyGroupsCategories() { clearProxyGroupNameOverride, customProxyGroups = [], proxyGroupAdvanced = {}, + proxyGroupAdvancedModeEnabled, + setProxyGroupAdvancedModeEnabled, updateProxyGroupAdvanced, dialerProxyGroups = [], } = useConfigStore(); - const [advancedProxyGroupMode, setAdvancedProxyGroupMode] = React.useState(false); const [expandedCategories, setExpandedCategories] = React.useState< Set @@ -307,9 +308,9 @@ export function ProxyGroupsCategories() {
- {advancedProxyGroupMode ? "已开启" : "未开启"} + {proxyGroupAdvancedModeEnabled ? "已开启" : "未开启"} - +
@@ -601,7 +602,7 @@ export function ProxyGroupsCategories() { : { strategy: undefined }), }) } - advancedMode={advancedProxyGroupMode} + advancedMode={proxyGroupAdvancedModeEnabled} nodeCount={generatedProxyGroupNodeCounts.get(display.full) ?? 0} renderAdvancedContent={(rulesContent, rulesCount) => ( )} 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 fa6b4ca..87d54d3 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 @@ -212,7 +212,6 @@ describe("useEditingSubscriptionLoader", () => { hiddenProxyGroups: ["youtube"], customRules: [{ type: "DOMAIN", value: "example.com", target: "🤖 AI 服务" }], customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], - filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], customRuleSets: [ { id: "custom-ai", @@ -269,15 +268,8 @@ describe("useEditingSubscriptionLoader", () => { hiddenProxyGroups: ["youtube"], customProxyGroups: [ { id: "custom-1", name: "Custom", emoji: "", groupType: "select", advanced: {} }, - { - id: "migrated-filtered-fg-1", - name: "Fast", - emoji: "", - description: "自定义代理组", - groupType: "select", - advanced: {}, - }, ], + proxyGroupAdvancedModeEnabled: true, customRuleSets: [ { id: "custom-ai", 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 fe39524..5a615c7 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.ts @@ -7,7 +7,7 @@ import type { EditingSubscriptionLoaderOptions } from "./editing-subscription-ty import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; -import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; +import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import { @@ -76,9 +76,7 @@ export function useEditingSubscriptionLoader({ } return out; })(); - const cfg = migrateFilteredProxyGroupsConfig( - sub.config && typeof sub.config === "object" ? (sub.config as Record) : {}, - ); + const cfg = sub.config && typeof sub.config === "object" ? (sub.config as Record) : {}; const subscriptionInfoFromRecord = normalizeSubscriptionUserInfo((sub as any).subscriptionInfo); const hasSubscriptionInfoFromRecord = hasSubscriptionUserInfo(subscriptionInfoFromRecord); const deletedNodesFromCfg = Array.isArray((cfg as any).deletedNodes) @@ -488,6 +486,11 @@ export function useEditingSubscriptionLoader({ typeof cfg.appliedTemplateId === "string" && cfg.appliedTemplateId.trim() ? (cfg.appliedTemplateId as string) : null; + const proxyGroupAdvancedModeEnabledFromCfg = resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: (cfg as any).proxyGroupAdvancedModeEnabled, + customProxyGroups: customProxyGroupsFromCfg, + proxyGroupAdvanced: proxyGroupAdvancedFromCfg, + }); // 重置后批量写入,避免中间状态触发重复生成 useConfigStore.getState().reset(); @@ -515,6 +518,7 @@ export function useEditingSubscriptionLoader({ customRuleSets: customRuleSetsFromCfg, builtinRuleEdits: builtinRuleEditsFromCfg, proxyGroupAdvanced: proxyGroupAdvancedFromCfg, + proxyGroupAdvancedModeEnabled: proxyGroupAdvancedModeEnabledFromCfg, moduleRuleEditWarningAccepted: typeof (cfg as any).moduleRuleEditWarningAccepted === "boolean" ? Boolean((cfg as any).moduleRuleEditWarningAccepted) 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 071d631..e91fb21 100644 --- a/packages/ui/src/product/home/use-subscription-link.test.ts +++ b/packages/ui/src/product/home/use-subscription-link.test.ts @@ -104,6 +104,7 @@ describe("useSubscriptionLink", () => { resetHookState(); mocks.bag.storeState = { proxyGroupAdvanced: { auto: { includeRegex: "Fast" } }, + proxyGroupAdvancedModeEnabled: true, proxyGroupOrder: ["select", "auto"], }; originalWindow = globalThis.window; @@ -256,6 +257,7 @@ describe("useSubscriptionLink", () => { }), ], proxyGroupAdvanced: { auto: { includeRegex: "Fast" } }, + proxyGroupAdvancedModeEnabled: true, listenerPorts: { "Node A": 41000 }, proxyGroupOrder: ["select", "auto"], }), diff --git a/packages/ui/src/product/home/use-subscription-link.tsx b/packages/ui/src/product/home/use-subscription-link.tsx index 0f6a576..8d0822e 100644 --- a/packages/ui/src/product/home/use-subscription-link.tsx +++ b/packages/ui/src/product/home/use-subscription-link.tsx @@ -382,6 +382,7 @@ export function useSubscriptionLink({ customRuleSets, builtinRuleEdits, proxyGroupAdvanced: useConfigStore.getState().proxyGroupAdvanced, + proxyGroupAdvancedModeEnabled: Boolean(useConfigStore.getState().proxyGroupAdvancedModeEnabled), moduleRuleEditWarningAccepted, dialerProxyGroups, proxyGroupNameOverrides, 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 2c2be65..c1508f2 100644 --- a/packages/ui/src/store/config-store/actions/settings-actions.ts +++ b/packages/ui/src/store/config-store/actions/settings-actions.ts @@ -9,6 +9,7 @@ type SettingsActions = Pick< | "setTestUrl" | "setTestInterval" | "setRuleProviderBaseUrl" + | "setProxyGroupAdvancedModeEnabled" | "setCnIpNoResolve" | "setExperimentalCnUseCnRuleSet" >; @@ -43,6 +44,10 @@ export function createSettingsActions( setAndGenerateConfig(() => ({ ruleProviderBaseUrl: url })); }, + setProxyGroupAdvancedModeEnabled: (value: boolean) => { + setAndGenerateConfig(() => ({ proxyGroupAdvancedModeEnabled: Boolean(value) })); + }, + setCnIpNoResolve: (value: boolean) => { setAndGenerateConfig(() => ({ cnIpNoResolve: Boolean(value) })); }, 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 6afed8e..62fae65 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 @@ -104,17 +104,6 @@ describe("createTemplateActions", () => { target: "Custom Group", }, ], - filteredProxyGroups: [ - { - id: "filtered-1", - name: "Filtered", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], builtinRuleEdits: { "module:ai:openai": { target: "🔍 Google" } }, customRules: [{ id: "", type: "DOMAIN-SUFFIX", value: "example.com", target: "Proxy" }], ruleOrder: ["custom-rule:custom-rule-domain-suffix-example-com-proxy-1"], @@ -146,15 +135,8 @@ describe("createTemplateActions", () => { advanced: {}, groupType: "select", }, - { - id: "migrated-filtered-filtered-1", - name: "Filtered", - emoji: "", - description: "自定义代理组", - groupType: "select", - advanced: {}, - }, ], + proxyGroupAdvancedModeEnabled: true, customRuleSets: [ { id: "custom-provider", @@ -224,7 +206,6 @@ describe("createTemplateActions", () => { enabledProxyGroups: "bad", hiddenProxyGroups: [" ai ", "adult", "adult", 123], customProxyGroups: "bad", - filteredProxyGroups: "bad", customRules: "bad", cnIpNoResolve: "bad", experimentalCnUseCnRuleSet: "bad", 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 57cf637..206bdcd 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.ts @@ -4,7 +4,7 @@ import { ensureCustomRulesHaveIds } from "@subboost/core/rules/custom-rule-utils import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; -import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; +import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import type { ConfigActions, SubBoostTemplateConfig } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; @@ -79,13 +79,12 @@ export function createTemplateActions( // 应用模板配置(从模板库应用) applyTemplateConfig: (config: SubBoostTemplateConfig) => { if (!config || typeof config !== "object") return; - const migratedConfig = migrateFilteredProxyGroupsConfig(config); setAndGenerateConfig((state) => { - const ruleModel = normalizeRuleModelFromConfig(migratedConfig); - const hasCustomProxyGroups = Array.isArray(migratedConfig.customProxyGroups); - const hasCustomRuleSets = Array.isArray(migratedConfig.customRuleSets); - const hasBuiltinRuleEdits = Boolean(migratedConfig.builtinRuleEdits && typeof migratedConfig.builtinRuleEdits === "object"); + const ruleModel = normalizeRuleModelFromConfig(config); + const hasCustomProxyGroups = Array.isArray(config.customProxyGroups); + const hasCustomRuleSets = Array.isArray(config.customRuleSets); + const hasBuiltinRuleEdits = Boolean(config.builtinRuleEdits && typeof config.builtinRuleEdits === "object"); const nextCustomProxyGroups = hasCustomProxyGroups || ruleModel.customProxyGroups.length > 0 ? ruleModel.customProxyGroups @@ -96,19 +95,23 @@ export function createTemplateActions( : state.customRuleSets; const nextBuiltinRuleEdits = hasBuiltinRuleEdits ? ruleModel.builtinRuleEdits : state.builtinRuleEdits; - const nextCustomRules = Array.isArray(migratedConfig.customRules) - ? ensureCustomRulesHaveIds(migratedConfig.customRules) + const nextCustomRules = Array.isArray(config.customRules) + ? ensureCustomRulesHaveIds(config.customRules) : state.customRules; - const nextHiddenProxyGroups = normalizeHiddenProxyGroups(migratedConfig.hiddenProxyGroups); + const nextProxyGroupAdvanced = + config.proxyGroupAdvanced && typeof config.proxyGroupAdvanced === "object" + ? config.proxyGroupAdvanced + : state.proxyGroupAdvanced; + const nextHiddenProxyGroups = normalizeHiddenProxyGroups(config.hiddenProxyGroups); const nextHiddenProxyGroupSet = new Set(nextHiddenProxyGroups); const shouldRefreshRuleOrder = - Array.isArray(migratedConfig.ruleOrder) || - Array.isArray(migratedConfig.customRules) || + Array.isArray(config.ruleOrder) || + Array.isArray(config.customRules) || hasCustomProxyGroups || hasCustomRuleSets || hasBuiltinRuleEdits; - const nextEnabledModulesRaw = Array.isArray(migratedConfig.enabledProxyGroups) - ? migratedConfig.enabledProxyGroups + const nextEnabledModulesRaw = Array.isArray(config.enabledProxyGroups) + ? config.enabledProxyGroups : state.enabledProxyGroups; const nextEnabledModules = nextEnabledModulesRaw.filter( (moduleId) => !nextHiddenProxyGroupSet.has(moduleId) @@ -121,55 +124,57 @@ export function createTemplateActions( customProxyGroups: nextCustomProxyGroups, builtinRuleEdits: nextBuiltinRuleEdits, proxyGroupNameOverrides: - migratedConfig.proxyGroupNameOverrides && typeof migratedConfig.proxyGroupNameOverrides === "object" - ? (migratedConfig.proxyGroupNameOverrides as Record) + config.proxyGroupNameOverrides && typeof config.proxyGroupNameOverrides === "object" + ? (config.proxyGroupNameOverrides as Record) : state.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: - typeof migratedConfig.experimentalCnUseCnRuleSet === "boolean" - ? migratedConfig.experimentalCnUseCnRuleSet + typeof config.experimentalCnUseCnRuleSet === "boolean" + ? config.experimentalCnUseCnRuleSet : state.experimentalCnUseCnRuleSet, cnIpNoResolve: - typeof migratedConfig.cnIpNoResolve === "boolean" ? migratedConfig.cnIpNoResolve : state.cnIpNoResolve, - ruleOrder: migratedConfig.ruleOrder, + typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve, + ruleOrder: config.ruleOrder, }) : state.ruleOrder; return { // 不触碰 nodes/sources:模板只描述“生成策略”,节点仍由用户导入 - template: migratedConfig.template ?? state.template, + template: config.template ?? state.template, enabledProxyGroups: nextEnabledModules, hiddenProxyGroups: nextHiddenProxyGroups, customProxyGroups: nextCustomProxyGroups, - proxyGroupAdvanced: - migratedConfig.proxyGroupAdvanced && typeof migratedConfig.proxyGroupAdvanced === "object" - ? migratedConfig.proxyGroupAdvanced - : state.proxyGroupAdvanced, + proxyGroupAdvanced: nextProxyGroupAdvanced, + proxyGroupAdvancedModeEnabled: resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: config.proxyGroupAdvancedModeEnabled, + customProxyGroups: nextCustomProxyGroups, + proxyGroupAdvanced: nextProxyGroupAdvanced, + }), customRuleSets: nextCustomRuleSets, builtinRuleEdits: nextBuiltinRuleEdits, moduleRuleEditWarningAccepted: false, customRules: nextCustomRules, ruleOrder: nextRuleOrder, cnIpNoResolve: - typeof migratedConfig.cnIpNoResolve === "boolean" ? migratedConfig.cnIpNoResolve : state.cnIpNoResolve, + typeof config.cnIpNoResolve === "boolean" ? config.cnIpNoResolve : state.cnIpNoResolve, experimentalCnUseCnRuleSet: - typeof migratedConfig.experimentalCnUseCnRuleSet === "boolean" - ? migratedConfig.experimentalCnUseCnRuleSet + typeof config.experimentalCnUseCnRuleSet === "boolean" + ? config.experimentalCnUseCnRuleSet : state.experimentalCnUseCnRuleSet, - dialerProxyGroups: Array.isArray(migratedConfig.dialerProxyGroups) - ? migratedConfig.dialerProxyGroups + dialerProxyGroups: Array.isArray(config.dialerProxyGroups) + ? config.dialerProxyGroups : state.dialerProxyGroups, proxyGroupNameOverrides: - migratedConfig.proxyGroupNameOverrides && typeof migratedConfig.proxyGroupNameOverrides === "object" - ? (migratedConfig.proxyGroupNameOverrides as Record) + config.proxyGroupNameOverrides && typeof config.proxyGroupNameOverrides === "object" + ? (config.proxyGroupNameOverrides as Record) : state.proxyGroupNameOverrides, - dnsYaml: typeof migratedConfig.dnsYaml === "string" ? migratedConfig.dnsYaml : state.dnsYaml, - mixedPort: typeof migratedConfig.mixedPort === "number" ? migratedConfig.mixedPort : state.mixedPort, - allowLan: typeof migratedConfig.allowLan === "boolean" ? migratedConfig.allowLan : state.allowLan, - testUrl: typeof migratedConfig.testUrl === "string" ? migratedConfig.testUrl : state.testUrl, + dnsYaml: typeof config.dnsYaml === "string" ? config.dnsYaml : state.dnsYaml, + mixedPort: typeof config.mixedPort === "number" ? config.mixedPort : state.mixedPort, + allowLan: typeof config.allowLan === "boolean" ? config.allowLan : state.allowLan, + testUrl: typeof config.testUrl === "string" ? config.testUrl : state.testUrl, testInterval: - typeof migratedConfig.testInterval === "number" ? migratedConfig.testInterval : state.testInterval, + typeof config.testInterval === "number" ? config.testInterval : state.testInterval, ruleProviderBaseUrl: - typeof migratedConfig.ruleProviderBaseUrl === "string" - ? migratedConfig.ruleProviderBaseUrl + typeof config.ruleProviderBaseUrl === "string" + ? config.ruleProviderBaseUrl : state.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 91002a6..9e805fb 100644 --- a/packages/ui/src/store/config-store/auth-handoff.test.ts +++ b/packages/ui/src/store/config-store/auth-handoff.test.ts @@ -56,6 +56,7 @@ function meaningfulState(overrides: Record = {}) { customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Proxy" }], customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], proxyGroupAdvanced: { ai: { includeRegex: "AI" } }, + proxyGroupAdvancedModeEnabled: true, customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, dialerProxyGroups: [{ id: "dialer-1", name: "Relay", relayNodes: ["Node A"], targetNodes: [] }], @@ -149,6 +150,7 @@ describe("auth config handoff", () => { customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Proxy" }], customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], proxyGroupAdvanced: { ai: { includeRegex: "AI" } }, + proxyGroupAdvancedModeEnabled: true, customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], builtinRuleEdits: { "module:ai:openai": { enabled: false } }, proxyGroupNameOverrides: { ai: "Labs" }, @@ -247,7 +249,7 @@ describe("auth config handoff", () => { enabledProxyGroups: ["select", 1], hiddenProxyGroups: "bad", customProxyGroups: [{ id: "custom" }], - filteredProxyGroups: [{ id: "filtered" }], + proxyGroupAdvancedModeEnabled: true, customRuleSets: [ { id: "custom-ai", @@ -286,6 +288,7 @@ describe("auth config handoff", () => { deletedNodes: [{ originName: "Gone" }], enabledProxyGroups: ["select"], customProxyGroups: [], + proxyGroupAdvancedModeEnabled: true, customRuleSets: [ { id: "custom-ai", diff --git a/packages/ui/src/store/config-store/auth-handoff.ts b/packages/ui/src/store/config-store/auth-handoff.ts index f49d0df..79cee51 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -1,8 +1,8 @@ import type { ConfigState, SourceType, SubscriptionSource } from "./definitions"; import { initialState } from "./definitions"; import { safeParseJsonObject } from "@subboost/core/json"; +import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; -import { migrateFilteredProxyGroupsConfig } from "@subboost/core/migrations/filtered-proxy-groups"; export const AUTH_CONFIG_HANDOFF_STORAGE_NAME = "subboost-auth-config-handoff"; @@ -120,6 +120,7 @@ function hasMeaningfulConfig(state: ConfigState): boolean { hasRecordEntries(state.proxyGroupNameOverrides) || state.proxyGroupOrder.length > 0 || state.ruleOrder.length > 0 || + state.proxyGroupAdvancedModeEnabled !== initialState.proxyGroupAdvancedModeEnabled || state.moduleRuleEditWarningAccepted !== initialState.moduleRuleEditWarningAccepted || state.appliedTemplateId !== initialState.appliedTemplateId || state.template !== initialState.template || @@ -155,6 +156,7 @@ function buildHandoffState(state: ConfigState): Partial { proxyGroupNameOverrides: state.proxyGroupNameOverrides, proxyGroupOrder: state.proxyGroupOrder, ruleOrder: state.ruleOrder, + proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled, moduleRuleEditWarningAccepted: state.moduleRuleEditWarningAccepted, appliedTemplateId: state.appliedTemplateId, dnsYaml: state.dnsYaml, @@ -170,59 +172,66 @@ function buildHandoffState(state: ConfigState): Partial { } function normalizeHandoffState(raw: unknown): Partial | null { - const migratedRaw = migrateFilteredProxyGroupsConfig(raw); - if (!isRecord(migratedRaw)) return null; + if (!isRecord(raw)) return null; const out: Partial = {}; - const sources = sourceArray(migratedRaw.sources); + const sources = sourceArray(raw.sources); if (sources) out.sources = sources; - const nodes = objectArray(migratedRaw.nodes); + const nodes = objectArray(raw.nodes); if (nodes) out.nodes = nodes; - const deletedNodeNames = stringArray(migratedRaw.deletedNodeNames); + const deletedNodeNames = stringArray(raw.deletedNodeNames); if (deletedNodeNames) out.deletedNodeNames = deletedNodeNames; - const deletedNodes = objectArray(migratedRaw.deletedNodes); + const deletedNodes = objectArray(raw.deletedNodes); if (deletedNodes) out.deletedNodes = deletedNodes; - if (migratedRaw.template === "minimal" || migratedRaw.template === "standard" || migratedRaw.template === "full") out.template = migratedRaw.template; - const enabledProxyGroups = stringArray(migratedRaw.enabledProxyGroups); + if (raw.template === "minimal" || raw.template === "standard" || raw.template === "full") out.template = raw.template; + const enabledProxyGroups = stringArray(raw.enabledProxyGroups); if (enabledProxyGroups) out.enabledProxyGroups = enabledProxyGroups; - const hiddenProxyGroups = stringArray(migratedRaw.hiddenProxyGroups); + const hiddenProxyGroups = stringArray(raw.hiddenProxyGroups); if (hiddenProxyGroups) out.hiddenProxyGroups = hiddenProxyGroups; - const ruleModel = normalizeRuleModelFromConfig(migratedRaw); - const customProxyGroups = objectArray(migratedRaw.customProxyGroups); + const ruleModel = normalizeRuleModelFromConfig(raw); + const customProxyGroups = objectArray(raw.customProxyGroups); if (customProxyGroups || ruleModel.customProxyGroups.length > 0) out.customProxyGroups = ruleModel.customProxyGroups; - if (isRecord(migratedRaw.proxyGroupAdvanced)) { - out.proxyGroupAdvanced = migratedRaw.proxyGroupAdvanced as ConfigState["proxyGroupAdvanced"]; + if (isRecord(raw.proxyGroupAdvanced)) { + out.proxyGroupAdvanced = raw.proxyGroupAdvanced as ConfigState["proxyGroupAdvanced"]; } - if (Array.isArray(migratedRaw.customRuleSets)) { + const proxyGroupAdvancedModeEnabled = resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: raw.proxyGroupAdvancedModeEnabled, + customProxyGroups: out.customProxyGroups, + proxyGroupAdvanced: out.proxyGroupAdvanced, + }); + if (typeof raw.proxyGroupAdvancedModeEnabled === "boolean" || proxyGroupAdvancedModeEnabled) { + out.proxyGroupAdvancedModeEnabled = proxyGroupAdvancedModeEnabled; + } + if (Array.isArray(raw.customRuleSets)) { out.customRuleSets = ruleModel.customRuleSets; } - if (isRecord(migratedRaw.builtinRuleEdits)) { + if (isRecord(raw.builtinRuleEdits)) { out.builtinRuleEdits = ruleModel.builtinRuleEdits; } - const customRules = objectArray(migratedRaw.customRules); + const customRules = objectArray(raw.customRules); if (customRules) out.customRules = customRules; - const dialerProxyGroups = objectArray(migratedRaw.dialerProxyGroups); + const dialerProxyGroups = objectArray(raw.dialerProxyGroups); if (dialerProxyGroups) out.dialerProxyGroups = dialerProxyGroups; - if (isStringRecord(migratedRaw.proxyGroupNameOverrides)) out.proxyGroupNameOverrides = migratedRaw.proxyGroupNameOverrides; - const proxyGroupOrder = stringArray(migratedRaw.proxyGroupOrder); + if (isStringRecord(raw.proxyGroupNameOverrides)) out.proxyGroupNameOverrides = raw.proxyGroupNameOverrides; + const proxyGroupOrder = stringArray(raw.proxyGroupOrder); if (proxyGroupOrder) out.proxyGroupOrder = proxyGroupOrder; - const ruleOrder = stringArray(migratedRaw.ruleOrder); + const ruleOrder = stringArray(raw.ruleOrder); if (ruleOrder) out.ruleOrder = ruleOrder; - if (typeof migratedRaw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = migratedRaw.moduleRuleEditWarningAccepted; - if (typeof migratedRaw.appliedTemplateId === "string" || migratedRaw.appliedTemplateId === null) { - out.appliedTemplateId = migratedRaw.appliedTemplateId; + if (typeof raw.moduleRuleEditWarningAccepted === "boolean") out.moduleRuleEditWarningAccepted = raw.moduleRuleEditWarningAccepted; + if (typeof raw.appliedTemplateId === "string" || raw.appliedTemplateId === null) { + out.appliedTemplateId = raw.appliedTemplateId; } - if (typeof migratedRaw.dnsYaml === "string") out.dnsYaml = migratedRaw.dnsYaml; - if (typeof migratedRaw.mixedPort === "number" && Number.isFinite(migratedRaw.mixedPort)) out.mixedPort = migratedRaw.mixedPort; - if (typeof migratedRaw.allowLan === "boolean") out.allowLan = migratedRaw.allowLan; - if (typeof migratedRaw.testUrl === "string") out.testUrl = migratedRaw.testUrl; - if (typeof migratedRaw.testInterval === "number" && Number.isFinite(migratedRaw.testInterval)) out.testInterval = migratedRaw.testInterval; - if (typeof migratedRaw.ruleProviderBaseUrl === "string") out.ruleProviderBaseUrl = migratedRaw.ruleProviderBaseUrl; - if (typeof migratedRaw.cnIpNoResolve === "boolean") out.cnIpNoResolve = migratedRaw.cnIpNoResolve; - if (typeof migratedRaw.experimentalCnUseCnRuleSet === "boolean") { - out.experimentalCnUseCnRuleSet = migratedRaw.experimentalCnUseCnRuleSet; + if (typeof raw.dnsYaml === "string") out.dnsYaml = raw.dnsYaml; + if (typeof raw.mixedPort === "number" && Number.isFinite(raw.mixedPort)) out.mixedPort = raw.mixedPort; + 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.ruleProviderBaseUrl === "string") out.ruleProviderBaseUrl = raw.ruleProviderBaseUrl; + if (typeof raw.cnIpNoResolve === "boolean") out.cnIpNoResolve = raw.cnIpNoResolve; + if (typeof raw.experimentalCnUseCnRuleSet === "boolean") { + out.experimentalCnUseCnRuleSet = raw.experimentalCnUseCnRuleSet; } - const listenerPorts = numberRecord(migratedRaw.listenerPorts); + const listenerPorts = numberRecord(raw.listenerPorts); if (listenerPorts) out.listenerPorts = listenerPorts; return out; diff --git a/packages/ui/src/store/config-store/definitions.ts b/packages/ui/src/store/config-store/definitions.ts index e3600a2..91e2dc0 100644 --- a/packages/ui/src/store/config-store/definitions.ts +++ b/packages/ui/src/store/config-store/definitions.ts @@ -174,6 +174,7 @@ export interface ConfigState { hiddenProxyGroups: string[]; // 隐藏的内置代理组(仅影响 UI,不参与生成) customProxyGroups: CustomProxyGroup[]; // 自定义分流组 proxyGroupAdvanced: Record; // 内置分流组高级筛选/排序配置 + proxyGroupAdvancedModeEnabled: boolean; // 分流组高级模式 UI 开关 customRuleSets: CustomRuleSet[]; // 用户新增规则集,统一进入自定义规则块 builtinRuleEdits: BuiltinRuleEdits; // 内置规则的目标覆盖或禁用状态 customRules: CustomRule[]; @@ -294,6 +295,7 @@ export interface ConfigActions { setTestUrl: (url: string) => void; setTestInterval: (interval: number) => void; setRuleProviderBaseUrl: (url: string) => void; + setProxyGroupAdvancedModeEnabled: (value: boolean) => void; setCnIpNoResolve: (value: boolean) => void; setExperimentalCnUseCnRuleSet: (value: boolean) => void; setListenerPort: (nodeName: string, port: number | null) => void; @@ -339,6 +341,7 @@ export const initialState: ConfigState = { hiddenProxyGroups: [], customProxyGroups: [], // 自定义分流组 proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, customRuleSets: [], builtinRuleEdits: {}, customRules: [], diff --git a/packages/ui/src/store/config-store/persistence.ts b/packages/ui/src/store/config-store/persistence.ts index c3a1832..8f359f3 100644 --- a/packages/ui/src/store/config-store/persistence.ts +++ b/packages/ui/src/store/config-store/persistence.ts @@ -55,6 +55,9 @@ export function normalizePersistedConfigState( ...(typeof state.testUrl === "string" ? { testUrl: state.testUrl } : {}), ...(typeof state.testInterval === "number" ? { testInterval: state.testInterval } : {}), ...(typeof state.ruleProviderBaseUrl === "string" ? { ruleProviderBaseUrl: state.ruleProviderBaseUrl } : {}), + ...(typeof state.proxyGroupAdvancedModeEnabled === "boolean" + ? { proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled } + : {}), cnIpNoResolve: typeof state.cnIpNoResolve === "boolean" ? state.cnIpNoResolve : true, experimentalCnUseCnRuleSet: typeof state.experimentalCnUseCnRuleSet === "boolean" ? state.experimentalCnUseCnRuleSet : true, @@ -72,6 +75,7 @@ export function partializeConfigState(state: ConfigState): Partial testUrl: state.testUrl, testInterval: state.testInterval, ruleProviderBaseUrl: state.ruleProviderBaseUrl, + proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled, cnIpNoResolve: state.cnIpNoResolve, experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, }; From fd55a8a39d0417ea07bfadfea41d7f05aa0494d0 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:09:34 +0800 Subject: [PATCH 17/29] fix: preserve migrated filtered group semantics --- packages/core/src/generator/index.test.ts | 9 +- packages/core/src/generator/index.ts | 24 ++++- .../core/src/generator/proxy-groups.test.ts | 46 +++++++++ packages/core/src/generator/proxy-groups.ts | 96 +++++++++++++++---- packages/core/src/proxy-group-advanced.ts | 2 - packages/core/src/rules/rule-model.ts | 5 + .../src/subscription/config-utils.test.ts | 7 +- .../core/src/subscription/config-utils.ts | 7 +- .../templates/config-template.groups.test.ts | 30 ++++++ .../src/templates/config-template.test.ts | 4 + .../core/src/templates/config-template.ts | 10 ++ packages/core/src/types/config.ts | 2 + .../actions/custom-actions.test.ts | 3 + .../config-store/actions/custom-actions.ts | 3 + 14 files changed, 221 insertions(+), 27 deletions(-) diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index 2c93ecd..877f895 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -239,7 +239,7 @@ describe("generateClashConfig", () => { expect(config["proxy-groups"]?.find((group) => group.name === "Broken Chain")).toBeUndefined(); }); - it("preserves explicit fingerprints and removes invalid dialer-proxy references", () => { + it("normalizes base YAML client fingerprints and removes invalid dialer-proxy references", () => { const config = generateClashConfig({ nodes: [ { @@ -289,6 +289,9 @@ describe("generateClashConfig", () => { targetNodes: ["Plain VMess"], }, ], + userConfig: { + dnsYaml: "global-client-fingerprint: chrome", + }, }); const plain = config.proxies?.find((proxy) => proxy.name === "Plain VMess"); @@ -298,8 +301,8 @@ describe("generateClashConfig", () => { expect(plain).not.toHaveProperty("client-fingerprint"); expect(plain).not.toHaveProperty("dialer-proxy"); - expect(trojan).not.toHaveProperty("client-fingerprint"); - expect(anytls).not.toHaveProperty("client-fingerprint"); + expect(trojan).toMatchObject({ "client-fingerprint": "chrome" }); + expect(anytls).toMatchObject({ "client-fingerprint": "chrome" }); expect(preset).toMatchObject({ "client-fingerprint": "safari" }); expect(config["proxy-groups"]?.find((group) => group.name === "Disabled")).toBeUndefined(); }); diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 8ea12a6..ca6d0dc 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -210,6 +210,10 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { assertNoGeneratedSectionsInBaseConfig(baseConfig); const baseConfigRecord = baseConfig as unknown as Record; const shouldUseDefaultBaseSections = !hasExplicitBaseConfigYaml; + const baseConfigClientFingerprint = + typeof baseConfigRecord["global-client-fingerprint"] === "string" && baseConfigRecord["global-client-fingerprint"].trim() + ? String(baseConfigRecord["global-client-fingerprint"]).trim() + : undefined; // 输出前做一次轻量标准化:避免 YAML 数字样式字段被下游(Clash)当成 number 解析 // 特别是 Reality short-id(有些机场会给纯数字,如 7053 / 7250) @@ -217,9 +221,23 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { if (!node || typeof node !== "object") return node; const typed = node as unknown as Record; const type = typeof typed.type === "string" ? typed.type : ""; - - if (type !== "vless") return node; - return normalizeMihomoVlessForGeneration(typed) as unknown as ParsedNode; + const hasClientFingerprint = + typeof typed["client-fingerprint"] === "string" && Boolean(String(typed["client-fingerprint"]).trim()); + const supportsClientFingerprint = type === "vmess" || type === "vless" || type === "trojan" || type === "anytls"; + const shouldApplyBaseFingerprint = + Boolean(baseConfigClientFingerprint) && + supportsClientFingerprint && + !hasClientFingerprint && + (type === "trojan" || + type === "anytls" || + (typeof typed.tls === "boolean" && typed.tls) || + (type === "vless" && Boolean(typed["reality-opts"]))); + const nextBase = shouldApplyBaseFingerprint + ? { ...typed, "client-fingerprint": baseConfigClientFingerprint } + : typed; + + if (type !== "vless") return nextBase as unknown as ParsedNode; + return normalizeMihomoVlessForGeneration(nextBase) as unknown as ParsedNode; }); // Mihomo 不支持的协议不能进入最终 YAML,否则一个无效节点会拖垮整份订阅。 diff --git a/packages/core/src/generator/proxy-groups.test.ts b/packages/core/src/generator/proxy-groups.test.ts index 6d8913d..051fecc 100644 --- a/packages/core/src/generator/proxy-groups.test.ts +++ b/packages/core/src/generator/proxy-groups.test.ts @@ -141,6 +141,52 @@ describe("proxy group generator", () => { }); }); + it("keeps filtered-node custom groups node-scoped while exposing them as policy members", () => { + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("Node B"), node("Filtered")], + proxyProviderNames: ["remote"], + enabledModules: ["select", "auto", "private"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + { + id: "filtered", + name: "Filtered", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "select", + advanced: { includeRegex: "Node A|Filtered" }, + }, + { ...customGroup("normal", "select"), includeInGroupMembers: true }, + ], + }); + + expect(groups[0]?.name).toBe("Filtered"); + expect(groups.find((group) => group.name === "Filtered")?.proxies).toEqual([ + "DIRECT", + "REJECT", + "Node A", + "Filtered", + ]); + expect(groups.find((group) => group.name === "Filtered")).not.toHaveProperty("use"); + expect(groups.find((group) => group.name === "Filtered")?.proxies).not.toContain("Custom normal"); + expect(groups.find((group) => group.name === "Filtered")?.proxies).not.toContain("🚀 节点选择"); + expect(groups.find((group) => group.name === "Custom normal")?.proxies).toContain("Filtered"); + expect(groups.find((group) => group.name === "Custom normal")?.proxies).not.toContain("Custom normal"); + expect(groups.find((group) => group.name === "Custom normal")).toMatchObject({ use: ["remote"] }); + expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("Filtered"); + expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("Custom normal"); + expect(groups.find((group) => group.name === "🏠 私有网络")?.proxies.slice(0, 5)).toEqual([ + "DIRECT", + "REJECT", + "Filtered", + "Custom normal", + "🚀 节点选择", + ]); + }); + it("omits disabled custom groups from groups, providers, names, and custom rules", () => { const disabledGroup = { ...customGroup("disabled", "select"), enabled: false }; const groups = generateProxyGroups({ diff --git a/packages/core/src/generator/proxy-groups.ts b/packages/core/src/generator/proxy-groups.ts index c5817ef..edc7c2f 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -73,6 +73,14 @@ function getEnabledCustomProxyGroups(customProxyGroups: CustomProxyGroup[]): Cus return customProxyGroups.filter((group) => group && group.enabled !== false); } +function usesFilteredNodeMembers(group: CustomProxyGroup): boolean { + return group.memberSource === "filtered-nodes"; +} + +function isIncludedInGroupMembers(group: CustomProxyGroup): boolean { + return group.includeInGroupMembers === true; +} + function customTargetIsDisabled( target: CustomRuleSet["target"], customProxyGroups: CustomProxyGroup[] @@ -140,6 +148,8 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { const filteredNodeNames = nodeNames.filter((n) => !isSubscriptionInfoNodeName(n)); const groups: ProxyGroup[] = []; const processedModules = new Set(); + const leadingCustomProxyGroups = activeCustomProxyGroups.filter(usesFilteredNodeMembers); + const inlineCustomProxyGroups = activeCustomProxyGroups.filter((group) => !usesFilteredNodeMembers(group)); const enabledSet = new Set(enabledModules); // 默认顺序(影响 Clash 初始选中项):节点选择 → 自动选择 → DIRECT → REJECT → 其它节点 @@ -155,6 +165,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { ]) ); const customGroupNames = activeCustomProxyGroups + .filter(isIncludedInGroupMembers) .map((group) => (typeof group.name === "string" ? group.name.trim() : "")) .filter(Boolean); const policyTargets = (...targets: unknown[]) => { @@ -170,9 +181,23 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { const fallbackTargets = (...targets: unknown[]) => policyTargets(...targets, "DIRECT", "REJECT"); const selectTarget = enabledModuleTarget("select"); const autoTarget = enabledModuleTarget("auto"); - const baseProxies = selectTarget - ? fallbackTargets(selectTarget, autoTarget, "DIRECT", "REJECT", ...customGroupNames, ...filteredNodeNames) - : fallbackTargets(autoTarget, ...customGroupNames, ...filteredNodeNames, "DIRECT", "REJECT"); + const moduleBaseProxies = selectTarget + ? fallbackTargets( + selectTarget, + autoTarget, + "DIRECT", + "REJECT", + ...customGroupNames, + ...filteredNodeNames + ) + : fallbackTargets( + autoTarget, + ...customGroupNames, + ...filteredNodeNames, + "DIRECT", + "REJECT" + ); + const customBaseProxies = moduleBaseProxies; const availableMemberProxyNames = fallbackTargets( "DIRECT", "REJECT", @@ -201,7 +226,8 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: string, groupType: ProxyGroupGroupType, proxies: string[], - strategy?: LoadBalanceStrategy + strategy?: LoadBalanceStrategy, + extraFields: Record = providerUse ): ProxyGroup => buildTypedProxyGroup({ name, @@ -210,7 +236,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { testUrl, testInterval, strategy, - extraFields: providerUse, + extraFields, urlTestLazy: false, }); @@ -222,7 +248,13 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { switch (groupType) { case "select": if (module.id === "select") { - const defaultProxies = fallbackTargets(autoTarget, "DIRECT", "REJECT", ...customGroupNames, ...nodeNames); + const defaultProxies = fallbackTargets( + autoTarget, + "DIRECT", + "REJECT", + ...customGroupNames, + ...nodeNames + ); groups.push({ name: moduleName, type: "select", @@ -235,7 +267,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { ...providerUse, }); } else { - const defaultProxies = baseProxies.filter((target) => target !== moduleName); + const defaultProxies = moduleBaseProxies.filter((target) => target !== moduleName); groups.push({ name: moduleName, type: "select", @@ -304,9 +336,14 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { case "direct-first": { - const defaultProxies = fallbackTargets("DIRECT", "REJECT", selectTarget, autoTarget, ...customGroupNames, ...filteredNodeNames).filter( - (target) => target !== moduleName - ); + const defaultProxies = fallbackTargets( + "DIRECT", + "REJECT", + ...customGroupNames, + selectTarget, + autoTarget, + ...filteredNodeNames + ).filter((target) => target !== moduleName); groups.push({ name: moduleName, type: "select", @@ -331,6 +368,27 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: customGroup.name, }); + if (usesFilteredNodeMembers(customGroup)) { + if (customGroup.groupType === "url-test" || customGroup.groupType === "fallback") { + return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, {}); + } + if (customGroup.groupType === "load-balance") { + return createGeneratedProxyGroup( + customGroup.name, + customGroup.groupType, + resolveCustom(filteredNodeNames), + customGroup.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, + {} + ); + } + return { + name: customGroup.name, + type: "select", + proxies: customGroup.groupType === "reject-first" + ? resolveCustom(["REJECT", "DIRECT", ...filteredNodeNames]) + : resolveCustom(["DIRECT", "REJECT", ...filteredNodeNames]), + }; + } if (customGroup.groupType === "url-test") { return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames)); } @@ -350,7 +408,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: customGroup.name, type: "select", proxies: resolveCustom( - fallbackTargets("DIRECT", "REJECT", selectTarget, autoTarget, ...customGroupNames, ...filteredNodeNames).filter( + fallbackTargets("DIRECT", "REJECT", selectTarget, autoTarget, ...filteredNodeNames).filter( (target) => target !== customGroup.name ) ), @@ -362,7 +420,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: customGroup.name, type: "select", proxies: resolveCustom( - fallbackTargets("REJECT", "DIRECT", selectTarget, ...customGroupNames).filter( + fallbackTargets("REJECT", "DIRECT", selectTarget).filter( (target) => target !== customGroup.name ) ), @@ -372,18 +430,22 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { return { name: customGroup.name, type: "select", - proxies: resolveCustom(baseProxies.filter((target) => target !== customGroup.name)), + proxies: resolveCustom(customBaseProxies.filter((target) => target !== customGroup.name)), ...providerUse, }; }; + for (const customGroup of leadingCustomProxyGroups) { + groups.push(createCustomProxyGroup(customGroup)); + } + // 按 PROXY_GROUP_ORDER 顺序生成代理组 let insertedCustom = false; for (const moduleId of PROXY_GROUP_ORDER) { // 自定义分流组插入位置:在 🍏 苹果服务 与 📲 电报消息之间 - if (!insertedCustom && moduleId === "ai" && activeCustomProxyGroups.length > 0) { + if (!insertedCustom && moduleId === "ai" && inlineCustomProxyGroups.length > 0) { insertedCustom = true; - for (const customGroup of activeCustomProxyGroups) { + for (const customGroup of inlineCustomProxyGroups) { groups.push(createCustomProxyGroup(customGroup)); } } @@ -406,8 +468,8 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { } // 若未命中插入点(例如未来移除 private),则追加到末尾 - if (!insertedCustom && activeCustomProxyGroups.length > 0) { - for (const customGroup of activeCustomProxyGroups) { + if (!insertedCustom && inlineCustomProxyGroups.length > 0) { + for (const customGroup of inlineCustomProxyGroups) { groups.push(createCustomProxyGroup(customGroup)); } } diff --git a/packages/core/src/proxy-group-advanced.ts b/packages/core/src/proxy-group-advanced.ts index 5eed77a..1b2187e 100644 --- a/packages/core/src/proxy-group-advanced.ts +++ b/packages/core/src/proxy-group-advanced.ts @@ -303,7 +303,6 @@ export function resolveProxyGroupMembers(options: ResolveProxyGroupMembersOption if (typeof rawName !== "string") continue; const member = buildMemberFromName(rawName, { nodeNameSet, moduleNameToId, customNameToId }); if (!member || seen.has(member.key)) continue; - if (options.self && member.name === options.self.name) continue; if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; seen.add(member.key); out.push(member); @@ -323,7 +322,6 @@ export function resolveProxyGroupMembers(options: ResolveProxyGroupMembersOption customGroupsById, }); if (!member || candidateMap.has(member.key)) continue; - if (options.self && member.name === options.self.name) continue; if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; candidateMap.set(member.key, member); } diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts index b0319cd..50866d5 100644 --- a/packages/core/src/rules/rule-model.ts +++ b/packages/core/src/rules/rule-model.ts @@ -101,6 +101,9 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { const emoji = toTrimmedString(rawGroup.emoji); const enabled = rawGroup.enabled === false ? false : undefined; const description = toTrimmedString(rawGroup.description); + const memberSource = rawGroup.memberSource === "filtered-nodes" ? "filtered-nodes" : undefined; + const includeInGroupMembers = + typeof rawGroup.includeInGroupMembers === "boolean" ? rawGroup.includeInGroupMembers : undefined; const groupType = toTrimmedString(rawGroup.groupType); if (!id || !name) continue; if ( @@ -120,6 +123,8 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { emoji, ...(enabled === false ? { enabled: false } : {}), ...(description ? { description } : {}), + ...(memberSource ? { memberSource } : {}), + ...(includeInGroupMembers !== undefined ? { includeInGroupMembers } : {}), groupType, ...(groupType === "load-balance" ? { diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index b6bf695..7b5c396 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -43,7 +43,9 @@ describe("subscription config utils", () => { { id: "media", name: "Media", - emoji: "M", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, groupType: "load-balance", strategy: "bad", }, @@ -115,6 +117,9 @@ describe("subscription config utils", () => { }); expect(options.customProxyGroups?.[0]).toMatchObject({ id: "media", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, strategy: "consistent-hashing", }); expect(options.customRuleSets?.[0]).toMatchObject({ diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index a02f453..f0075c4 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -180,7 +180,7 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { ? item.groupType : null; - if (!id || !name || !emoji || !groupType) continue; + if (!id || !name || !groupType) continue; const strategy = groupType === "load-balance" @@ -191,6 +191,9 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { const enabled = item.enabled === false ? false : undefined; const description = toTrimmedString(item.description); + const memberSource = item.memberSource === "filtered-nodes" ? "filtered-nodes" : undefined; + const includeInGroupMembers = + typeof item.includeInGroupMembers === "boolean" ? item.includeInGroupMembers : undefined; const advanced = normalizeProxyGroupAdvancedConfig(item.advanced); out.push({ id, @@ -198,6 +201,8 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { emoji, ...(enabled === false ? { enabled: false } : {}), ...(description ? { description } : {}), + ...(memberSource ? { memberSource } : {}), + ...(includeInGroupMembers !== undefined ? { includeInGroupMembers } : {}), groupType, ...(strategy ? { strategy } : {}), ...(Object.keys(advanced).length > 0 ? { advanced } : {}), diff --git a/packages/core/src/templates/config-template.groups.test.ts b/packages/core/src/templates/config-template.groups.test.ts index d6ee27a..4ec74be 100644 --- a/packages/core/src/templates/config-template.groups.test.ts +++ b/packages/core/src/templates/config-template.groups.test.ts @@ -137,6 +137,7 @@ describe("validateSubBoostTemplateConfig custom groups", () => { id: "custom", name: "Custom", emoji: "C", + memberSource: "filtered-nodes", groupType: "load-balance", strategy: "bad" as never, }, @@ -145,6 +146,35 @@ describe("validateSubBoostTemplateConfig custom groups", () => { ) ).toEqual({ ok: false, error: "customProxyGroups.strategy 无效" }); + expectInvalid( + { + customProxyGroups: [ + { + id: "custom", + name: "Custom", + emoji: "C", + memberSource: "bad" as never, + groupType: "select", + }, + ], + }, + "customProxyGroups.memberSource 无效" + ); + expectInvalid( + { + customProxyGroups: [ + { + id: "custom", + name: "Custom", + emoji: "C", + includeInGroupMembers: "yes" as never, + groupType: "select", + }, + ], + }, + "customProxyGroups.includeInGroupMembers 必须是布尔值" + ); + const removedField = `filtered${"ProxyGroups"}`; expectInvalid({ [removedField]: [] } as never, `模板配置包含已移除字段: ${removedField}`); }); diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index 30d72a7..e111281 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -59,6 +59,8 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom", name: "Custom", emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, groupType: "load-balance", }, ], @@ -116,6 +118,8 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom", name: "Custom", emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, groupType: "load-balance", strategy: DEFAULT_LOAD_BALANCE_STRATEGY, }); diff --git a/packages/core/src/templates/config-template.ts b/packages/core/src/templates/config-template.ts index 11705bb..0af2254 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -324,12 +324,22 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG if (!groupType.ok) return groupType; const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "customProxyGroups.strategy"); if (!strategy.ok) return strategy; + if (item.memberSource !== undefined && item.memberSource !== "filtered-nodes") { + return invalid("customProxyGroups.memberSource 无效"); + } + if (item.includeInGroupMembers !== undefined && typeof item.includeInGroupMembers !== "boolean") { + return invalid("customProxyGroups.includeInGroupMembers 必须是布尔值"); + } const description = typeof item.description === "string" ? item.description.trim() : ""; out.push({ id: id.value, name: name.value, emoji: emoji.value, ...(description ? { description } : {}), + ...(item.memberSource === "filtered-nodes" ? { memberSource: "filtered-nodes" as const } : {}), + ...(typeof item.includeInGroupMembers === "boolean" + ? { includeInGroupMembers: item.includeInGroupMembers } + : {}), groupType: groupType.value, ...(groupType.value === "load-balance" ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index efb8f5b..49babde 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -275,6 +275,8 @@ export interface CustomProxyGroup { emoji: string; enabled?: boolean; description?: string; + memberSource?: "filtered-nodes"; + includeInGroupMembers?: boolean; groupType: ProxyGroupGroupType; strategy?: LoadBalanceStrategy; advanced?: ProxyGroupAdvancedConfig; diff --git a/packages/ui/src/store/config-store/actions/custom-actions.test.ts b/packages/ui/src/store/config-store/actions/custom-actions.test.ts index f113211..b61cada 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.test.ts @@ -84,6 +84,9 @@ describe("custom config-store actions", () => { actions.addCustomProxyGroup({ name: "Old Group", emoji: "🧩", groupType: "select" }); const groupId = getState().customProxyGroups[0].id; expect(groupId).toBe("custom-group-1767225600000"); + expect(getState().customProxyGroups[0]).toEqual( + expect.objectContaining({ includeInGroupMembers: true }) + ); actions.updateCustomProxyGroup(groupId, { name: "New Group", diff --git a/packages/ui/src/store/config-store/actions/custom-actions.ts b/packages/ui/src/store/config-store/actions/custom-actions.ts index 1ecc5a8..4a3cc8b 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.ts @@ -138,6 +138,9 @@ export function createCustomActions( emoji: group.emoji, ...(group.enabled === false ? { enabled: false } : {}), ...(typeof group.description === "string" ? { description: group.description.trim() } : {}), + ...(group.memberSource === "filtered-nodes" ? { memberSource: "filtered-nodes" as const } : {}), + includeInGroupMembers: + typeof group.includeInGroupMembers === "boolean" ? group.includeInGroupMembers : true, groupType: group.groupType, ...(group.groupType === "load-balance" && group.strategy ? { strategy: group.strategy } : {}), ...(group.advanced ? { advanced: normalizeProxyGroupAdvancedConfig(group.advanced) } : {}), From 17dc6b1e9e5916516afe1ede022381b4944a4bdc Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:14:47 +0800 Subject: [PATCH 18/29] test: cover filtered group edge cases --- .../core/src/generator/proxy-groups.test.ts | 64 ++++++++++++++++++- .../src/subscription/config-utils.test.ts | 17 +++++ .../core/src/subscription/config-utils.ts | 2 +- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/core/src/generator/proxy-groups.test.ts b/packages/core/src/generator/proxy-groups.test.ts index 051fecc..9c1c447 100644 --- a/packages/core/src/generator/proxy-groups.test.ts +++ b/packages/core/src/generator/proxy-groups.test.ts @@ -178,7 +178,7 @@ describe("proxy group generator", () => { expect(groups.find((group) => group.name === "Custom normal")).toMatchObject({ use: ["remote"] }); expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("Filtered"); expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("Custom normal"); - expect(groups.find((group) => group.name === "🏠 私有网络")?.proxies.slice(0, 5)).toEqual([ + expect(groups.find((group) => group.name === "🏠 私有网络")?.proxies?.slice(0, 5)).toEqual([ "DIRECT", "REJECT", "Filtered", @@ -187,6 +187,68 @@ describe("proxy group generator", () => { ]); }); + it("keeps filtered-node generated group variants node-scoped and appends inline custom groups without an insert point", () => { + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("Node B")], + proxyProviderNames: ["remote"], + enabledModules: ["select"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + { + id: "filtered-url", + name: "Filtered URL", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "url-test", + advanced: { includeRegex: "Node A" }, + }, + { + id: "filtered-fallback", + name: "Filtered Fallback", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "fallback", + }, + { + id: "filtered-balance", + name: "Filtered Balance", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + }, + customGroup("inline", "select"), + ], + }); + + expect(groups.slice(0, 3).map((group) => group.name)).toEqual([ + "Filtered URL", + "Filtered Fallback", + "Filtered Balance", + ]); + expect(groups.find((group) => group.name === "Filtered URL")).toMatchObject({ + type: "url-test", + proxies: ["Node A"], + }); + expect(groups.find((group) => group.name === "Filtered URL")).not.toHaveProperty("use"); + expect(groups.find((group) => group.name === "Filtered Fallback")).toMatchObject({ + type: "fallback", + proxies: ["Node A", "Node B"], + }); + expect(groups.find((group) => group.name === "Filtered Fallback")).not.toHaveProperty("use"); + expect(groups.find((group) => group.name === "Filtered Balance")).toMatchObject({ + type: "load-balance", + strategy: "consistent-hashing", + proxies: ["Node A", "Node B"], + }); + expect(groups.find((group) => group.name === "Filtered Balance")).not.toHaveProperty("use"); + expect(groups.at(-1)).toMatchObject({ name: "Custom inline", use: ["remote"] }); + }); + it("omits disabled custom groups from groups, providers, names, and custom rules", () => { const disabledGroup = { ...customGroup("disabled", "select"), enabled: false }; const groups = generateProxyGroups({ diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 7b5c396..469f697 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -49,6 +49,16 @@ describe("subscription config utils", () => { groupType: "load-balance", strategy: "bad", }, + { + id: "disabled", + name: "Disabled", + emoji: "D", + enabled: false, + description: "Hidden group", + memberSource: "bad", + includeInGroupMembers: true, + groupType: "select", + }, ], customRuleSets: [ { @@ -122,6 +132,13 @@ describe("subscription config utils", () => { includeInGroupMembers: false, strategy: "consistent-hashing", }); + expect(options.customProxyGroups?.[1]).toMatchObject({ + id: "disabled", + enabled: false, + description: "Hidden group", + includeInGroupMembers: true, + }); + expect(options.customProxyGroups?.[1]).not.toHaveProperty("memberSource"); expect(options.customRuleSets?.[0]).toMatchObject({ id: "youtube", name: "YouTube", diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index f0075c4..09d4517 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -169,7 +169,7 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { if (!isRecord(item)) continue; const id = toTrimmedString(item.id); const name = toTrimmedString(item.name); - const emoji = toTrimmedString(item.emoji); + const emoji = toTrimmedString(item.emoji) ?? ""; const groupType = item.groupType === "select" || item.groupType === "url-test" || From bfa9c3991f1ae151437f115367e0bac4463ada17 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:21:16 +0800 Subject: [PATCH 19/29] test: cover proxy group member resolution edges --- .../core/src/proxy-group-advanced.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/core/src/proxy-group-advanced.test.ts diff --git a/packages/core/src/proxy-group-advanced.test.ts b/packages/core/src/proxy-group-advanced.test.ts new file mode 100644 index 0000000..f39d7ad --- /dev/null +++ b/packages/core/src/proxy-group-advanced.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { resolveProxyGroupMembers } from "./proxy-group-advanced"; +import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +function node(name: string): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + } as ParsedNode; +} + +describe("resolveProxyGroupMembers", () => { + it("filters self custom references without dropping a node with the same name", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["Self Group", "Other Node"], + availableProxyNames: ["Self Group", "Other Node", "Peer Group"], + nodes: [node("Self Group"), node("Other Node")], + customProxyGroups: [ + { id: "self", name: "Self Group", emoji: "", groupType: "select" }, + { id: "peer", name: "Peer Group", emoji: "", groupType: "select" }, + ], + advanced: { + extraMembers: [ + { kind: "custom", id: "self" }, + { kind: "custom", id: "peer" }, + ], + memberOrder: [ + { kind: "node", name: "Self Group" }, + { kind: "custom", id: "self" }, + { kind: "custom", id: "peer" }, + ], + }, + self: { kind: "custom", id: "self", name: "Self Group" }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "node:Self Group", + "custom:peer", + "node:Other Node", + ]); + expect(result.proxyNames).toEqual(["Self Group", "Peer Group", "Other Node"]); + expect(result.included.map((member) => member.key)).not.toContain("custom:self"); + }); + + it("applies source, region, regex, exclusion, and extra-member filters together", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: [ + "US Source", + "US Other", + "Japan Source", + "Mars Source", + "DIRECT", + "REJECT", + ], + nodes: [ + withNodeSourceId(node("US Source"), "source-a"), + withNodeSourceId(node("US Other"), "source-b"), + withNodeSourceId(node("Japan Source"), "source-a"), + withNodeSourceId(node("Mars Source"), "source-a"), + ], + advanced: { + sourceIds: ["source-a"], + regions: ["us", "other"], + includeRegex: "Source|DIRECT", + excludeRegex: "Mars", + extraMembers: [{ kind: "direct" }], + excludedMembers: [{ kind: "reject" }], + }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "node:US Source", + "direct:DIRECT", + ]); + expect(result.excluded.map((member) => member.key)).toEqual([ + "node:US Other", + "node:Japan Source", + "node:Mars Source", + "reject:REJECT", + ]); + + const invalidRegexResult = resolveProxyGroupMembers({ + defaultProxyNames: ["US Source"], + nodes: [withNodeSourceId(node("US Source"), "source-a")], + advanced: { + sourceIds: ["source-a"], + includeRegex: "[", + excludeRegex: "[", + }, + }); + + expect(invalidRegexResult.proxyNames).toEqual(["US Source"]); + }); +}); From 3d70b1df4a419351a41599e7ddaf721536d110b3 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:57:28 +0800 Subject: [PATCH 20/29] fix: export proxy group target helpers --- packages/core/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/package.json b/packages/core/package.json index d1dc618..3ffb912 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,6 +19,7 @@ "./parser/*": "./src/parser/*.ts", "./proxy-group-name": "./src/proxy-group-name.ts", "./proxy-group-advanced-mode": "./src/proxy-group-advanced-mode.ts", + "./proxy-group-targets": "./src/proxy-group-targets.ts", "./rules/*": "./src/rules/*.ts", "./rules-database": "./src/rules-database.ts", "./subscription/*": "./src/subscription/*.ts", From fafaa22d7757d5d9831b1c199aff4e03150cf9e1 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:45:05 +0800 Subject: [PATCH 21/29] fix: export proxy group advanced helpers --- packages/core/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/package.json b/packages/core/package.json index 3ffb912..85859fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,7 @@ "./node-identity": "./src/node-identity.ts", "./parser": "./src/parser/index.ts", "./parser/*": "./src/parser/*.ts", + "./proxy-group-advanced": "./src/proxy-group-advanced.ts", "./proxy-group-name": "./src/proxy-group-name.ts", "./proxy-group-advanced-mode": "./src/proxy-group-advanced-mode.ts", "./proxy-group-targets": "./src/proxy-group-targets.ts", From ee28cac214439ba99e7bc0285befd621ce8b773e Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:25:15 +0800 Subject: [PATCH 22/29] test(core): raise generator and parser coverage --- packages/core/src/config/defaults.test.ts | 2 +- packages/core/src/generator/index.test.ts | 148 +++++ packages/core/src/generator/index.ts | 2 +- .../core/src/generator/module-rules.test.ts | 2 +- .../core/src/generator/proxy-groups.test.ts | 146 +++++ packages/core/src/generator/rules.test.ts | 243 ++++++++ .../core/src/generator/yaml-semantics.test.ts | 76 ++- packages/core/src/generator/yaml.ts | 2 +- .../core/src/mihomo/proxy-sanitizer.test.ts | 482 ++++++++++++++++ packages/core/src/parser/clash-yaml.test.ts | 103 ++++ .../src/parser/config-line-parser.test.ts | 227 ++++++++ .../parse-platform-proxy-line.test.ts | 54 ++ .../protocols/hysteria-protocols.test.ts | 24 + .../parser/protocols/more-protocols.test.ts | 12 + .../core/src/parser/protocols/netch.test.ts | 71 +++ .../src/parser/protocols/simple-proxy.test.ts | 108 ++++ packages/core/src/parser/protocols/ss.test.ts | 40 ++ .../src/parser/protocols/ssr-snell.test.ts | 20 + .../core/src/parser/protocols/vless.test.ts | 78 +++ .../src/parser/protocols/vmess-utils.test.ts | 19 + .../core/src/parser/protocols/vmess.test.ts | 77 +++ .../core/src/proxy-group-advanced.test.ts | 333 ++++++++++- packages/core/src/proxy-group-name.test.ts | 40 ++ packages/core/src/proxy-group-targets.test.ts | 49 ++ .../src/rules/custom-rule-helpers.test.ts | 70 +++ packages/core/src/rules/rule-model.test.ts | 123 ++++ .../src/subscription/config-utils.test.ts | 545 +++++++++++++++++- .../subscription/node-source-state.test.ts | 80 +++ .../subscription/source-node-refresh.test.ts | 184 ++++++ .../templates/config-template.fields.test.ts | 107 +++- .../templates/config-template.groups.test.ts | 79 ++- .../templates/config-template.test-helpers.ts | 4 +- .../src/templates/config-template.test.ts | 34 +- packages/core/src/time/beijing.test.ts | 60 ++ 34 files changed, 3630 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/proxy-group-name.test.ts create mode 100644 packages/core/src/proxy-group-targets.test.ts create mode 100644 packages/core/src/rules/rule-model.test.ts create mode 100644 packages/core/src/subscription/node-source-state.test.ts create mode 100644 packages/core/src/time/beijing.test.ts diff --git a/packages/core/src/config/defaults.test.ts b/packages/core/src/config/defaults.test.ts index bf30eb4..06e9cb2 100644 --- a/packages/core/src/config/defaults.test.ts +++ b/packages/core/src/config/defaults.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA } from "@subboost/core/templates/config-template"; +import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA } from "../templates/config-template"; import { DEFAULT_BASE_CONFIG_YAML, DEFAULT_SUBBOOST_CONFIG, diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index 877f895..a8e1b85 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -457,6 +457,154 @@ describe("generateClashConfig", () => { expect(config["proxy-groups"]?.some((group) => Object.is((group as { name: unknown }).name, 123))).toBe(false); }); + it("keeps safe fallbacks for empty providers, blank fingerprints, and unusable dialers", () => { + const config = generateClashConfig({ + nodes: [ + ssNode({ name: "Relay" }), + { + name: "Plain VMess", + type: "vmess", + server: "vmess.example.com", + port: 80, + uuid: "11111111-1111-4111-8111-111111111111", + alterId: 0, + cipher: "auto", + "client-fingerprint": 1, + } as ParsedNode, + { + name: "VLESS TCP", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "tcp", + } as ParsedNode, + ], + proxyProviders: {}, + dialerProxyGroups: [ + { + id: "empty", + name: "Empty Chain", + type: "select", + relayNodes: [" Missing ", "Missing"], + targetNodes: ["Plain VMess", " "], + }, + ], + userConfig: { + dnsYaml: [ + "global-client-fingerprint: ' '", + "proxy-providers:", + " local:", + " type: file", + " path: ./local.yaml", + ].join("\n"), + listenerPorts: { + Relay: 0, + "Plain VMess": 65536, + "VLESS TCP": 12003, + }, + }, + }); + + expect(config["proxy-providers"]).toEqual({ + local: { + type: "file", + path: "./local.yaml", + }, + }); + expect(config.proxies?.find((proxy) => proxy.name === "Plain VMess")).toHaveProperty("client-fingerprint", 1); + expect(config.proxies?.find((proxy) => proxy.name === "VLESS TCP")).not.toHaveProperty("client-fingerprint"); + expect(config.listeners).toEqual([{ name: "mixed0", type: "mixed", port: 12003, proxy: "VLESS TCP" }]); + expect(config["proxy-groups"]?.find((group) => group.name === "Empty Chain")).toBeUndefined(); + }); + + it("normalizes mixed malformed nodes and dialer entries while preserving valid outputs", () => { + const config = generateClashConfig({ + nodes: [ + null as never, + { name: "Bad Type", type: 1, server: "bad.example.com", port: 443 } as never, + ssNode({ name: "Relay", server: "relay.example.com" }), + ssNode({ name: "Target", server: "target.example.com" }), + ssNode({ name: "Blank Target", server: "blank-target.example.com" }), + { + name: "TLS VMess", + type: "vmess", + server: "vmess.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + alterId: 0, + cipher: "auto", + tls: true, + } as ParsedNode, + { + name: "Reality VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + } as ParsedNode, + ], + dialerProxyGroups: [ + { + id: "messy", + name: "Messy Chain", + type: "select", + relayNodes: [1 as never, " Relay ", "DIRECT", "Relay"], + targetNodes: [2 as never, " Target ", "Missing"], + }, + { + id: "blank-name", + name: " ", + type: "select", + relayNodes: ["Relay"], + targetNodes: ["Blank Target"], + }, + ], + proxyGroupOrder: [1 as never, "dialer:messy", "module:auto"], + userConfig: { + dnsYaml: [ + "global-client-fingerprint: chrome", + "nameserver-policy:", + " '+.top.example.com': 1.1.1.1", + "dns:", + " enable: true", + ].join("\n"), + enabledGroups: ["select", "auto", "global", "final"], + }, + }); + + expect(config.proxies?.map((proxy) => proxy.name)).toEqual([ + "Relay", + "Target", + "Blank Target", + "TLS VMess", + "Reality VLESS", + ]); + expect(config.dns).toEqual({ + enable: true, + "nameserver-policy": { + "+.top.example.com": "1.1.1.1", + }, + }); + expect(config.proxies?.find((proxy) => proxy.name === "Target")).toMatchObject({ + "dialer-proxy": "Messy Chain", + }); + expect(config.proxies?.find((proxy) => proxy.name === "Blank Target")).toHaveProperty("dialer-proxy", " "); + expect(config.proxies?.find((proxy) => proxy.name === "TLS VMess")).toMatchObject({ + "client-fingerprint": "chrome", + }); + expect(config.proxies?.find((proxy) => proxy.name === "Reality VLESS")).toMatchObject({ + "client-fingerprint": "chrome", + }); + expect(config["proxy-groups"]?.slice(0, 2).map((group) => group.name)).toEqual([ + "Messy Chain", + "⚡ 自动选择", + ]); + }); + it("generates YAML through the public helper", () => { const yaml = generateClashYaml({ nodes: [ssNode()], diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index ca6d0dc..5157b1c 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -33,7 +33,7 @@ import type { } from "@subboost/core/types/config"; import type { DialerProxyGroup } from "@subboost/core/types/template-config"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; -import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "@subboost/core/mihomo/proxy-sanitizer"; +import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "../mihomo/proxy-sanitizer"; import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-targets"; export interface GenerateOptions { diff --git a/packages/core/src/generator/module-rules.test.ts b/packages/core/src/generator/module-rules.test.ts index ec50c1a..7ff37f2 100644 --- a/packages/core/src/generator/module-rules.test.ts +++ b/packages/core/src/generator/module-rules.test.ts @@ -9,7 +9,7 @@ import { isPresetModuleRule, normalizeHiddenPresetRuleIds, } from "./module-rules"; -import type { ProxyGroupModule, ProxyGroupRule } from "./proxy-group-modules"; +import type { ProxyGroupModule, ProxyGroupRule } from "@subboost/core/generator/proxy-group-modules"; const proxyModule: ProxyGroupModule = { id: "media", diff --git a/packages/core/src/generator/proxy-groups.test.ts b/packages/core/src/generator/proxy-groups.test.ts index 9c1c447..cd7f7db 100644 --- a/packages/core/src/generator/proxy-groups.test.ts +++ b/packages/core/src/generator/proxy-groups.test.ts @@ -305,4 +305,150 @@ describe("proxy group generator", () => { expect(providers["disabled-rule"]).toBeUndefined(); expect(rules).toEqual(["MATCH,DIRECT"]); }); + + it("covers provider target guards and module group type overrides", () => { + const disabledByName = { ...customGroup("disabled-name", "select"), enabled: false }; + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("Node B")], + proxyProviderNames: ["remote"], + enabledModules: ["select", "auto", "ai", "private", "cn", "global", "final"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + disabledByName, + { + id: "blank-name", + name: "" as never, + emoji: "", + includeInGroupMembers: true, + groupType: "select", + }, + ], + proxyGroupAdvanced: { + auto: { groupType: "load-balance", strategy: "round-robin" }, + ai: { groupType: "url-test", regions: ["other"] }, + private: { groupType: "direct-first", extraMembers: [{ kind: "node", name: "Node B" }] }, + cn: { groupType: "reject-first" }, + }, + }); + const providers = generateRuleProviders({ + nodes: [node("Node A")], + enabledModules: ["cn"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [disabledByName], + customRuleSets: [ + { + id: "skip-by-name", + name: "Skip", + behavior: "domain", + path: "geosite/skip.mrs", + target: "Custom disabled-name", + }, + { + id: "relative-path", + name: "Relative", + behavior: "domain", + path: "geosite/relative.mrs", + target: "DIRECT", + }, + { + id: "relative-path", + name: "Duplicate", + behavior: "domain", + path: "geosite/duplicate.mrs", + target: "DIRECT", + }, + { + id: "", + name: "Missing id", + behavior: "domain", + path: "geosite/missing.mrs", + target: "DIRECT", + }, + ], + }); + + expect(groups.find((group) => group.name === "⚡ 自动选择")).toMatchObject({ + type: "load-balance", + strategy: "round-robin", + }); + expect(groups.find((group) => group.name.includes("AI"))).toMatchObject({ + type: "url-test", + proxies: [], + use: ["remote"], + }); + expect(groups.find((group) => group.name === "🏠 私有网络")?.proxies?.slice(0, 2)).toEqual(["DIRECT", "REJECT"]); + expect(groups.find((group) => group.name === "🔒 国内服务")?.proxies?.slice(0, 2)).toEqual(["REJECT", "DIRECT"]); + expect(providers["skip-by-name"]).toBeUndefined(); + expect(providers["relative-path"]).toMatchObject({ + url: "https://rules.example.com/geosite/relative.mrs", + }); + }); + + it("builds groups without providers while keeping info nodes out of testable groups", () => { + const groups = generateProxyGroups({ + nodes: [node("余额 | 10GB"), node("Korea Node"), node("US Node")], + enabledModules: ["auto", "ai", "private", "global", "final"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 60, + customProxyGroups: [ + { + id: "direct-local", + name: "Direct Local", + emoji: "", + includeInGroupMembers: true, + groupType: "direct-first", + advanced: { memberOrder: [{ kind: "direct" }, { kind: "node", name: "US Node" }] }, + }, + { + id: "reject-local", + name: "Reject Local", + emoji: "", + includeInGroupMembers: true, + groupType: "reject-first", + }, + { + id: "filtered-reject", + name: "Filtered Reject", + emoji: "", + memberSource: "filtered-nodes", + groupType: "reject-first", + advanced: { includeRegex: "Korea|余额" }, + }, + ], + proxyGroupAdvanced: { + ai: { + groupType: "select", + extraMembers: [{ kind: "custom", id: "direct-local" }], + excludedMembers: [{ kind: "node", name: "US Node" }], + }, + }, + }); + + const auto = groups.find((group) => group.name === "⚡ 自动选择"); + const ai = groups.find((group) => group.name.includes("AI")); + const directLocal = groups.find((group) => group.name === "Direct Local"); + const rejectLocal = groups.find((group) => group.name === "Reject Local"); + const filteredReject = groups.find((group) => group.name === "Filtered Reject"); + + expect(auto).toMatchObject({ + type: "url-test", + proxies: ["Korea Node", "US Node"], + url: "https://probe.example.com/204", + interval: 60, + }); + expect(auto).not.toHaveProperty("use"); + expect(auto?.proxies).not.toContain("余额 | 10GB"); + expect(ai?.proxies).toContain("Direct Local"); + expect(ai?.proxies).not.toContain("US Node"); + expect(directLocal?.proxies?.slice(0, 2)).toEqual(["DIRECT", "US Node"]); + expect(directLocal?.proxies).not.toContain("Direct Local"); + expect(rejectLocal?.proxies?.slice(0, 2)).toEqual(["REJECT", "DIRECT"]); + expect(filteredReject?.proxies).toEqual(["REJECT", "DIRECT", "Korea Node"]); + expect(groups.find((group) => group.name === "🐟 漏网之鱼")?.proxies).toContain("Direct Local"); + }); }); diff --git a/packages/core/src/generator/rules.test.ts b/packages/core/src/generator/rules.test.ts index 26a443c..f5de14c 100644 --- a/packages/core/src/generator/rules.test.ts +++ b/packages/core/src/generator/rules.test.ts @@ -177,6 +177,12 @@ describe("rule generator", () => { expect(fullOrder).toEqual(["module:global:geolocation-!cn", "custom-rule:domain-rule"]); expect(applied).toContain("module:global:geolocation-!cn"); expect(applied.indexOf("custom-rule:domain-rule")).toBeLessThan(applied.indexOf("module:global:geolocation-!cn")); + expect(resolveAppliedRuleOrder({ + enabledModules: ["cn", "global", "final"], + customRules, + customRuleSets, + ruleOrder: ["custom-rule-set:media-rule", "custom-rule:ip-rule"], + }).slice(0, 3)).toEqual(["custom-rule-set:media-rule", "custom-rule:ip-rule", "custom-rule:domain-rule"]); expect(generateRules({ enabledModules: [], customRules: [], @@ -297,4 +303,241 @@ describe("rule generator", () => { }) ).toEqual(["module:streaming-west:apple-tvplus"]); }); + + it("keeps module-targeted custom rules while dropping disabled custom-group targets", () => { + const entries = buildGeneratedRuleEntries({ + enabledModules: ["private", "apple"], + customProxyGroups: [ + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + customRules: [ + { + id: "module-target", + type: "DOMAIN", + value: "module.example", + target: { kind: "module", id: "select" }, + noResolve: true, + }, + { + id: "disabled-name", + type: "DOMAIN", + value: "disabled-name.example", + target: "Disabled", + }, + { + id: "disabled-ref", + type: "DOMAIN", + value: "disabled-ref.example", + target: { kind: "custom", id: "disabled" }, + }, + ], + customRuleSets: [ + { + id: "module-set", + name: "Module Set", + behavior: "domain", + path: "https://rules.example.com/module.mrs", + target: { kind: "module", id: "select" }, + }, + ], + fallbackPolicyTarget: "DIRECT", + }); + const texts = entries.map((entry) => entry.text); + + expect(texts).toContain("DOMAIN,module.example,🚀 节点选择"); + expect(texts).toContain("RULE-SET,module-set,🚀 节点选择"); + expect(texts.some((text) => text.includes("disabled-name.example"))).toBe(false); + expect(texts.some((text) => text.includes("disabled-ref.example"))).toBe(false); + expect(texts.some((text) => text.startsWith("RULE-SET,private-ip,🏠 私有网络,no-resolve"))).toBe(true); + expect(texts.some((text) => text.startsWith("RULE-SET,apple,🍏 苹果服务"))).toBe(true); + + expect(hasFullRuleOrderKeys(undefined)).toBe(false); + expect( + normalizePersistedRuleOrder({ + enabledModules: [], + customRules, + customRuleSets: [], + ruleOrder: undefined, + }) + ).toEqual([]); + expect( + resolveAppliedRuleOrder({ + enabledModules: [], + customRules, + customRuleSets: [], + ruleOrder: ["module:global:geolocation-!cn"], + }) + ).toEqual(["custom-rule:domain-rule", "custom-rule:ip-rule"]); + }); + + it("applies builtin module target edits and cn no-resolve overrides", () => { + const entries = buildGeneratedRuleEntries({ + enabledModules: ["cn"], + customRules: [], + customRuleSets: [], + customProxyGroups: [ + { id: "regional", name: "Regional", emoji: "R", groupType: "select" }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { target: { kind: "custom", id: "regional" } }, + }, + cnIpNoResolve: true, + availablePolicyTargets: ["Regional", "DIRECT"], + fallbackPolicyTarget: "DIRECT", + }); + const texts = entries.map((entry) => entry.text); + + expect(texts).toContain("RULE-SET,cn-ip,Regional,no-resolve"); + expect(texts).toContain("MATCH,DIRECT"); + expect(entries.find((entry) => entry.key === "module:cn:cn-ip")).toMatchObject({ + noResolve: true, + sourceLabel: "🔒 国内服务", + target: "Regional", + }); + }); + + it("cleans noisy persisted full-order keys without losing active editable rules", () => { + const applied = resolveAppliedRuleOrder({ + enabledModules: ["global"], + customRules, + customRuleSets, + ruleOrder: [ + " module:global:geolocation-!cn ", + "module:global:geolocation-!cn", + 1 as never, + "custom-rule:ip-rule", + "custom-rule:missing", + "module:unknown:missing", + "special:match", + ], + fallbackPolicyTarget: "DIRECT", + }); + const persisted = normalizePersistedRuleOrder({ + enabledModules: ["global"], + customRules, + customRuleSets, + ruleOrder: [ + " module:global:geolocation-!cn ", + "module:global:geolocation-!cn", + 1 as never, + "custom-rule:ip-rule", + "custom-rule:missing", + "module:unknown:missing", + "special:match", + ], + fallbackPolicyTarget: "DIRECT", + }); + + expect(persisted).toEqual(["module:global:geolocation-!cn", "custom-rule:ip-rule"]); + expect(applied).toEqual([ + "custom-rule:domain-rule", + "custom-rule-set:media-rule", + "module:global:geolocation-!cn", + "custom-rule:ip-rule", + ]); + }); + + it("applies fallback policy targets and no-resolve guards for mixed custom rules", () => { + const entries = buildGeneratedRuleEntries({ + enabledModules: ["cn", "global", "final"], + customRules: [ + { + id: "cidr6", + type: "IP-CIDR6", + value: "2001:db8::/32", + target: "Missing", + noResolve: true, + }, + { + id: "process", + type: "PROCESS-NAME", + value: "curl", + target: "DIRECT", + noResolve: true, + }, + ], + customRuleSets: [ + { + id: "fallback-set", + name: "Fallback Set", + behavior: "domain", + path: "https://rules.example.com/fallback.mrs", + target: "Missing", + noResolve: false, + }, + { + id: "custom-set", + name: "Custom Set", + behavior: "classical", + path: "https://rules.example.com/custom.yaml", + target: { kind: "custom", id: "regional" }, + noResolve: true, + }, + ], + customProxyGroups: [ + { id: "regional", name: "Regional", emoji: "", groupType: "select" }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { enabled: false }, + "module:global:geolocation-!cn": { target: "Missing" }, + }, + experimentalCnUseCnRuleSet: true, + cnIpNoResolve: true, + availablePolicyTargets: ["DIRECT", "Regional", "🐟 漏网之鱼"], + fallbackPolicyTarget: "DIRECT", + ruleOrder: [ + "module:cn:cn-ip", + "custom-rule:process", + "custom-rule-set:custom-set", + "special:experimental-cn", + "special:match", + ], + }); + const texts = entries.map((entry) => entry.text); + const persisted = normalizePersistedRuleOrder({ + enabledModules: ["cn", "global", "final"], + customRules: [ + { id: "cidr6", type: "IP-CIDR6", value: "2001:db8::/32", target: "Missing", noResolve: true }, + { id: "process", type: "PROCESS-NAME", value: "curl", target: "DIRECT", noResolve: true }, + ], + customRuleSets: [ + { + id: "custom-set", + name: "Custom Set", + behavior: "classical", + path: "https://rules.example.com/custom.yaml", + target: { kind: "custom", id: "regional" }, + noResolve: true, + }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { enabled: false }, + }, + ruleOrder: [ + "module:cn:cn-ip", + "custom-rule:process", + "custom-rule:missing", + "special:experimental-cn", + "special:match", + ], + experimentalCnUseCnRuleSet: true, + fallbackPolicyTarget: "DIRECT", + }); + + expect(texts).toContain("IP-CIDR6,2001:db8::/32,DIRECT,no-resolve"); + expect(texts).toContain("PROCESS-NAME,curl,DIRECT"); + expect(texts).not.toContain("PROCESS-NAME,curl,DIRECT,no-resolve"); + expect(texts).toContain("RULE-SET,fallback-set,DIRECT"); + expect(texts).toContain("RULE-SET,custom-set,Regional,no-resolve"); + expect(texts).not.toContain("RULE-SET,cn-ip,🔒 国内服务,no-resolve"); + expect(texts).toContain("RULE-SET,geolocation-!cn,DIRECT"); + expect(texts).toContain("RULE-SET,cn,DIRECT"); + expect(texts).toContain("RULE-SET,geolocation-cn,DIRECT"); + expect(texts.at(-1)).toBe("MATCH,🐟 漏网之鱼"); + expect(persisted).toEqual([ + "module:cn:cn-ip", + "custom-rule:process", + "special:experimental-cn", + ]); + }); }); diff --git a/packages/core/src/generator/yaml-semantics.test.ts b/packages/core/src/generator/yaml-semantics.test.ts index 455d1cb..064499b 100644 --- a/packages/core/src/generator/yaml-semantics.test.ts +++ b/packages/core/src/generator/yaml-semantics.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "vitest"; +import yaml from "js-yaml"; +import { describe, expect, it, vi } from "vitest"; import { diffGeneratedYamlSemantics, + GeneratedYamlSemanticError, hashGeneratedYamlSemantics, parseGeneratedYamlSemantics, } from "./yaml-semantics"; @@ -116,4 +118,76 @@ proxy-groups: issues: [expect.objectContaining({ path: "proxy-groups", severity: "high" })], }); }); + + it("handles empty YAML, scalar parse errors, and low-impact changes", () => { + const emptyA = parseGeneratedYamlSemantics(""); + const emptyB = parseGeneratedYamlSemantics("null\n"); + const lowBefore = parseGeneratedYamlSemantics(` +mixed-port: 7897 +profile: + store-selected: true +limits: + nan: .nan + pos: .inf + neg: -.inf +`); + const lowAfter = parseGeneratedYamlSemantics(` +mixed-port: 7898 +profile: + store-selected: true +limits: + nan: .nan + pos: .inf + neg: -.inf +`); + + expect(diffGeneratedYamlSemantics(emptyA, emptyA)).toEqual({ + changed: false, + severity: "none", + issues: [], + }); + expect(diffGeneratedYamlSemantics(emptyA, emptyB)).toMatchObject({ + changed: true, + severity: "format-only", + }); + expect(diffGeneratedYamlSemantics(lowBefore, lowAfter)).toMatchObject({ + changed: true, + severity: "low", + issues: [expect.objectContaining({ path: "mixed-port", severity: "low" })], + }); + expect(hashGeneratedYamlSemantics(lowBefore)).toMatch(/^[a-f0-9]{8}$/); + expect(() => parseGeneratedYamlSemantics("just-a-string")).toThrow(GeneratedYamlSemanticError); + expect(() => parseGeneratedYamlSemantics("dns:\n nameserver: [")).toThrow("Generated YAML parse failed"); + }); + + it("normalizes undefined semantic fields and formats unusual parser errors", () => { + const snapshot = { + version: 1 as const, + rawFingerprint: "", + semanticFingerprint: "", + sections: { + keep: { b: 2, a: undefined }, + skip: undefined, + }, + }; + expect(hashGeneratedYamlSemantics(snapshot)).toMatch(/^[a-f0-9]{8}$/); + + const load = vi.spyOn(yaml, "load"); + try { + load.mockImplementationOnce(() => []); + expect(() => parseGeneratedYamlSemantics("[]")).toThrow("top-level object"); + + load.mockImplementationOnce(() => { + throw "plain parser failure"; + }); + expect(() => parseGeneratedYamlSemantics("bad")).toThrow("plain parser failure"); + + load.mockImplementationOnce(() => { + throw { message: "object parser failure", mark: {} }; + }); + expect(() => parseGeneratedYamlSemantics("bad")).toThrow("object parser failure"); + } finally { + load.mockRestore(); + } + }); }); diff --git a/packages/core/src/generator/yaml.ts b/packages/core/src/generator/yaml.ts index a9ff086..7efb8cd 100644 --- a/packages/core/src/generator/yaml.ts +++ b/packages/core/src/generator/yaml.ts @@ -1,6 +1,6 @@ import type { KnownNodeType } from "@subboost/core/types/node"; import type { ClashConfig } from "@subboost/core/types/config"; -import { sanitizeMihomoProxyNode } from "@subboost/core/mihomo/proxy-sanitizer"; +import { sanitizeMihomoProxyNode } from "../mihomo/proxy-sanitizer"; export type DnsPolicyValue = string | string[]; diff --git a/packages/core/src/mihomo/proxy-sanitizer.test.ts b/packages/core/src/mihomo/proxy-sanitizer.test.ts index b26421e..84cb7a9 100644 --- a/packages/core/src/mihomo/proxy-sanitizer.test.ts +++ b/packages/core/src/mihomo/proxy-sanitizer.test.ts @@ -544,4 +544,486 @@ describe("Mihomo proxy sanitizer", () => { }, }); }); + + it("covers sanitizer boundary aliases and optional protocol fallbacks", () => { + const ecdsaSsh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": [ + "ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-ecdsa-!bad AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-rsa bad!token", + ], + }); + const certless = sanitizeMihomoProxyNode({ + name: "HTTP", + type: "http", + server: "http.example.com", + port: 80, + fingerprint: 1, + }); + const clientFingerprintAlreadySet = sanitizeMihomoProxyNode({ + name: "Trojan", + type: "trojan", + server: "trojan.example.com", + port: 443, + password: "secret", + fingerprint: "Chrome", + "client-fingerprint": "safari", + }); + const wireguardReservedArray = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: [1, "2", 3], + }); + const xhttpNoReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "ech-opts": { + enable: false, + config: Buffer.from("ech").toString("base64"), + "query-server-name": " ech.example.com ", + }, + }, + }, + }); + + expect(ecdsaSsh).toMatchObject({ + "host-key": ["ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA=="], + }); + expect(certless).not.toHaveProperty("fingerprint"); + expect(clientFingerprintAlreadySet).toMatchObject({ "client-fingerprint": "safari" }); + expect(clientFingerprintAlreadySet).not.toHaveProperty("fingerprint"); + expect(wireguardReservedArray).toHaveProperty("reserved", [1, 2, 3]); + expect(xhttpNoReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "ech-opts": { + enable: false, + config: Buffer.from("ech").toString("base64"), + "query-server-name": "ech.example.com", + }, + }, + }, + }); + expect( + isMihomoSupportedProxyNode({ + type: "trojan", + name: "Trojan", + server: "trojan.example.com", + port: 443, + password: "secret", + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "v2ray-plugin", + }) + ).toBe(true); + }); + + it("rejects explicit malformed optional transport fields", () => { + const ssh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": [ + "ssh-rsa AAAA", + "ssh-dss AAAA comment", + "ssh-ed25519", + "ssh-ecdsa-!bad AAAA", + ], + }); + const invalidReserved = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: "1,2", + }); + const explicitEmptyDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + const invalidDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }); + const invalidMainReality = normalizeMihomoVlessForGeneration({ + name: "Reality", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + "reality-opts": "bad", + }); + const prefixedCertificate = sanitizeMihomoProxyNode({ + name: "HTTPS", + type: "https", + server: "https.example.com", + port: 443, + fingerprint: "SHA256=" + "C".repeat(64).match(/.{1,2}/g)?.join(":"), + }); + + expect(isMihomoSupportedProxyNode({ type: "ss", cipher: "", password: "secret" })).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }) + ).toBe(false); + expect(ssh).toMatchObject({ "host-key": ["ssh-rsa AAAA", "ssh-dss AAAA comment"] }); + expect(invalidReserved).not.toHaveProperty("reserved"); + expect(explicitEmptyDownloadReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(invalidMainReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(prefixedCertificate).toHaveProperty("fingerprint", "c".repeat(64)); + }); + + it("covers conservative sanitizer fallbacks for omitted and malformed optional fields", () => { + const sshWithScalarHostKey = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": "ssh-rsa AAAA", + "private-key": undefined, + "server-fingerprint": 1, + }); + const sshWithEmptyHostKeyMaterial = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": ["ssh-rsa ", "ssh-ecdsa- AAAA", "ssh-ed25519 AAAA"], + }); + const wireguardWithUndefinedOptionalKeys = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": undefined, + "pre-shared-key": undefined, + reserved: "", + }); + const plainHttp = sanitizeMihomoProxyNode({ + name: "HTTP", + type: "http", + server: "http.example.com", + port: 80, + udp: "TRUE", + tls: "FALSE", + alpn: [], + fingerprint: "Not-A-Known-Alias", + "ws-opts": {}, + }); + const vlessWithoutReality = sanitizeMihomoProxyNode({ + name: "VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + "short-id": "not-hex", + }, + "ech-opts": { + enable: "0", + config: "", + "query-server-name": " ", + }, + }, + }, + }); + + expect(sshWithScalarHostKey).not.toHaveProperty("host-key"); + expect(sshWithScalarHostKey).not.toHaveProperty("private-key"); + expect(sshWithScalarHostKey).not.toHaveProperty("server-fingerprint"); + expect(sshWithEmptyHostKeyMaterial).toHaveProperty("host-key", ["ssh-ed25519 AAAA"]); + expect(wireguardWithUndefinedOptionalKeys).toMatchObject({ + "private-key": WIREGUARD_KEY, + }); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("public-key"); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("pre-shared-key"); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("reserved"); + expect(plainHttp).toMatchObject({ udp: true, tls: false }); + expect(plainHttp).not.toHaveProperty("alpn"); + expect(plainHttp).not.toHaveProperty("fingerprint"); + expect(plainHttp).not.toHaveProperty("ws-opts"); + expect(vlessWithoutReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "ech-opts": { + enable: false, + }, + }, + }, + }); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"][ + "reality-opts" + ] + ).not.toHaveProperty("short-id"); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] + ).not.toHaveProperty("config"); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] + ).not.toHaveProperty("query-server-name"); + + expect(isMihomoSupportedProxyNode({ type: "http", name: "HTTP" })).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "wireguard", + name: "WG", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": undefined, + "pre-shared-key": WIREGUARD_KEY, + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + "private-key": "bad", + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + "private-key": PRIVATE_KEY, + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "simple-obfs", + }) + ).toBe(true); + }); + + it("covers remaining protocol support and cleanup fallbacks", () => { + expect(isStandardBase64String("abcd!")).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssr", + name: "SSR", + server: "ssr.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + protocol: "", + obfs: "plain", + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + name: "XHTTP", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, + "xhttp-opts": { + mode: "stream-one", + "download-settings": { + path: "/download", + }, + }, + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + name: "XHTTP", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }) + ).toBe(true); + + const invalidContainers = sanitizeMihomoProxyNode({ + name: "Containers", + type: "vmess", + server: "vmess.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + ech: "bad", + alpn: 443, + "ech-opts": [], + "ws-opts": {}, + fingerprint: "unknown", + }); + const sshWithoutHostKey = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": "ssh-ed25519 AAAA", + "server-fingerprint": `SHA256:${"B".repeat(43)}=`, + }); + const wireguardMissingReserved = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: "1,2", + }); + const noRealityDownloadSettings = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + path: "/download", + }, + }, + }); + const invalidDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }); + const explicitTlsReality = normalizeMihomoVlessForGeneration({ + name: "Reality", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + tls: true, + "client-fingerprint": "edge", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + }); + const noEncryption = sanitizeMihomoProxyNode({ + name: "VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + encryption: " ", + }); + + expect(invalidContainers).not.toHaveProperty("alpn"); + expect(invalidContainers).not.toHaveProperty("ech-opts"); + expect(invalidContainers).not.toHaveProperty("fingerprint"); + expect(invalidContainers).not.toHaveProperty("ws-opts"); + expect(sshWithoutHostKey).not.toHaveProperty("host-key"); + expect(sshWithoutHostKey).toHaveProperty("server-fingerprint", `SHA256:${"B".repeat(43)}=`); + expect(wireguardMissingReserved).not.toHaveProperty("reserved"); + expect(noRealityDownloadSettings).toMatchObject({ + "xhttp-opts": { + "download-settings": { + path: "/download", + }, + }, + }); + expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(explicitTlsReality).toMatchObject({ + tls: true, + "client-fingerprint": "edge", + }); + expect(noEncryption).not.toHaveProperty("encryption"); + }); }); diff --git a/packages/core/src/parser/clash-yaml.test.ts b/packages/core/src/parser/clash-yaml.test.ts index 34bcc80..4fd10a5 100644 --- a/packages/core/src/parser/clash-yaml.test.ts +++ b/packages/core/src/parser/clash-yaml.test.ts @@ -311,4 +311,107 @@ proxies: }); expect(result.errors[0]).toContain('节点 "HY Ports Only" 解析失败'); }); + + it("covers non-mutating YAML normalization branches and protocol defaults", () => { + const result = parseClashYaml(` +proxies: + - 1 + - name: VMess TCP + type: vmess + server: vmess-tcp.example.com + port: 80 + - name: VMess WS Empty + type: vmess + server: vmess-ws-empty.example.com + port: 443 + network: ws + - name: VMess WS Plain + type: vmess + server: vmess-ws-plain.example.com + port: 443 + network: ws + ws-opts: + path: /plain + - name: VLESS No Reality + type: vless + server: vless-none.example.com + port: 443 + - name: VLESS Empty Reality + type: vless + server: vless-empty.example.com + port: 443 + reality-opts: + short-id: "0x" + - name: VLESS Numeric Reality + type: vless + server: vless-numeric.example.com + port: 443 + reality-opts: + public-key: 123 + short-id: 7 + - name: AnyTLS Default + type: anytls + server: anytls-default.example.com + port: 443 + - name: HY2 Default + type: hysteria2 + server: hy2-default.example.com + port: 443 + - name: TUIC Default + type: tuic + server: tuic-default.example.com + port: 443 + - name: Masque + type: masque + server: masque.example.com + port: 443 + - name: Sudoku + type: sudoku + server: sudoku.example.com + port: 443 + - name: Bad Unknown + type: ss + port: bad +`); + + expect(result.nodes.find((node) => node.name === "VMess TCP")).toMatchObject({ + type: "vmess", + uuid: "", + network: undefined, + }); + expect(result.nodes.find((node) => node.name === "VMess WS Empty")).toMatchObject({ + type: "vmess", + network: "ws", + }); + expect(result.nodes.find((node) => node.name === "VMess WS Plain")).toMatchObject({ + "ws-opts": { path: "/plain" }, + }); + expect(result.nodes.find((node) => node.name === "VLESS No Reality")).toMatchObject({ + type: "vless", + uuid: "", + }); + expect(result.nodes.find((node) => node.name === "VLESS Empty Reality")?.["reality-opts"]).toBeUndefined(); + expect(result.nodes.find((node) => node.name === "VLESS Numeric Reality")).toMatchObject({ + "reality-opts": { + "public-key": 123, + "short-id": "07", + }, + }); + expect(result.nodes.find((node) => node.name === "AnyTLS Default")).toMatchObject({ + type: "anytls", + password: "", + }); + expect(result.nodes.find((node) => node.name === "HY2 Default")).toMatchObject({ + type: "hysteria2", + password: "", + }); + expect(result.nodes.find((node) => node.name === "TUIC Default")).toMatchObject({ + type: "tuic", + uuid: "", + password: "", + }); + expect(result.nodes.find((node) => node.name === "Masque")).toMatchObject({ type: "masque" }); + expect(result.nodes.find((node) => node.name === "Sudoku")).toMatchObject({ type: "sudoku" }); + expect(result.errors[0]).toContain('节点 "Bad Unknown" 解析失败'); + }); }); diff --git a/packages/core/src/parser/config-line-parser.test.ts b/packages/core/src/parser/config-line-parser.test.ts index 7c89076..c21ae05 100644 --- a/packages/core/src/parser/config-line-parser.test.ts +++ b/packages/core/src/parser/config-line-parser.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { looksLikeConfigLine } from "./config-line-parser"; import { mustParseConfigLine } from "./config-line-parser.test-helpers"; import { + applyCommonNodeParams, + applyTransport, inferSkipCertVerify, isUuidLike, parseBooleanish, @@ -34,6 +36,10 @@ describe("config line tokenizer helpers", () => { }); expect(() => tokenizeConfigLine("broken")).toThrow("无效的配置行格式"); expect(() => tokenizeConfigLine("Bad = ss, example.com, 70000")).toThrow("配置行中的地址或端口无效"); + expect(tokenizeConfigLine("Ignored = ss, ignored.example.com, 8388, =empty, flag")).toMatchObject({ + params: {}, + extras: ["flag"], + }); }); it("normalizes common primitive params", () => { @@ -45,6 +51,7 @@ describe("config line tokenizer helpers", () => { expect(parseStringList("a, b,,c")).toEqual(["a", "b", "c"]); expect(parseWsHeaders(undefined)).toBeUndefined(); expect(parseWsHeaders("bad|also-bad")).toBeUndefined(); + expect(parseWsHeaders("Host:|:missing|Good:yes")).toEqual({ Good: "yes" }); expect(parseWsHeaders('Host:cdn.example.com|X-Test:"yes"')).toEqual({ Host: "cdn.example.com", "X-Test": "yes", @@ -59,6 +66,150 @@ describe("config line tokenizer helpers", () => { expect(inferSkipCertVerify({ "tls-verification": "true" })).toBeUndefined(); expect(inferSkipCertVerify({ "allow-insecure": "0" })).toBe(false); }); + + it("applies shared node params to non-VMess protocols and rare TLS aliases", () => { + const trojan: Record = { type: "trojan" }; + applyCommonNodeParams(trojan, { + peer: "trojan-sni.example.com", + "tls-cert-sha256": "cert", + "tls_pubkey_sha256": "pub", + "disable-sni": "true", + "block-quic": "true", + "udp-port": "53", + "fast-open": "false", + "shadow-tls-version": "3", + "shadow-tls-sni": "shadow.example.com", + "shadow-tls-password": "shadow-secret", + }); + + expect(trojan).toMatchObject({ + sni: "trojan-sni.example.com", + "tls-cert-sha256": "cert", + "tls-pubkey-sha256": "pub", + "disable-sni": true, + "block-quic": true, + "udp-port": 53, + tfo: false, + "shadow-tls-version": 3, + "shadow-tls-sni": "shadow.example.com", + "shadow-tls-password": "shadow-secret", + }); + + const hysteria2: Record = { type: "hysteria2" }; + applyCommonNodeParams(hysteria2, { fingerprint: "chrome" }); + expect(hysteria2).toMatchObject({ fingerprint: "chrome" }); + }); + + it("applies transport helpers across default, header, and xHTTP edge branches", () => { + const defaultWs: Record = {}; + applyTransport(defaultWs, { "ws-path": "/ws?ed=128", "ws-headers": "Host:from-header.example.com|X-Test:yes" }, { + defaultTransport: "ws", + }); + expect(defaultWs).toMatchObject({ + network: "ws", + "ws-opts": { + path: "/ws", + headers: { + Host: "from-header.example.com", + "X-Test": "yes", + }, + "early-data-header-name": "Sec-WebSocket-Protocol", + "max-early-data": 128, + }, + }); + + const plainGrpc: Record = {}; + applyTransport(plainGrpc, { transport: "grpc", path: "/svc" }); + expect(plainGrpc).toMatchObject({ + network: "grpc", + "grpc-opts": { + "grpc-service-name": "svc", + }, + }); + + const blankHttp: Record = {}; + applyTransport(blankHttp, { transport: "http", method: " ", path: " , " }); + expect(blankHttp).toMatchObject({ + network: "http", + "http-opts": { + method: "GET", + path: ["/"], + headers: undefined, + }, + }); + + const xhttp: Record = {}; + applyTransport(xhttp, { + transport: "xhttp", + path: "/x", + host: "cdn.example.com", + mode: "packet-up", + "xhttp-headers": "User-Agent:SubBoost", + "no-grpc-header": "off", + "sc-max-each-post-bytes": "bad", + "download-headers": "Accept:yaml", + }, { + allowedTransports: ["tcp", "xhttp"], + }); + expect(xhttp).toMatchObject({ + network: "xhttp", + "xhttp-opts": { + path: "/x", + host: "cdn.example.com", + mode: "packet-up", + headers: { "User-Agent": "SubBoost" }, + "no-grpc-header": false, + "download-settings": { + headers: { Accept: "yaml" }, + }, + }, + }); + + expect(() => applyTransport({}, { transport: "udp" }, { allowedTransports: ["tcp"], protocolName: "测试" })).toThrow( + "不支持的 测试 传输层" + ); + expect(() => applyTransport({}, { transport: " " }, { allowedTransports: ["tcp"] })).toThrow( + "transport=(empty)" + ); + + const tcp: Record = {}; + applyTransport(tcp, { transport: "tcp" }); + expect(tcp).toMatchObject({ network: "tcp" }); + + const xhttpAliases: Record = {}; + applyTransport(xhttpAliases, { + network: "xhttp", + path: "/alias", + headers: "Host:edge.example.com", + "max-connections": "2", + "c-max-reuse-times": "3", + "h-max-request-times": "4", + "h-max-reusable-secs": "5", + no_grpc_header: "yes", + sc_max_each_post_bytes: "4096", + downloadheaders: "Accept:yaml", + }, { + allowedTransports: ["tcp", "xhttp"], + }); + expect(xhttpAliases).toMatchObject({ + network: "xhttp", + "xhttp-opts": { + path: "/alias", + headers: { Host: "edge.example.com" }, + "no-grpc-header": true, + "sc-max-each-post-bytes": 4096, + "reuse-settings": { + "max-connections": "2", + "c-max-reuse-times": "3", + "h-max-request-times": "4", + "h-max-reusable-secs": "5", + }, + "download-settings": { + headers: { Accept: "yaml" }, + }, + }, + }); + }); }); describe("config line parser", () => { @@ -551,4 +702,80 @@ describe("config line parser", () => { "不支持的配置行协议: unknown" ); }); + + it("builds rare config-line aliases and conservative defaults", () => { + expect(mustParseConfigLine("SOCKS5 TLS Alias = socks5-tls, socks-tls-alias.example.com, 1080, over-tls=false")).toMatchObject({ + name: "SOCKS5 TLS Alias", + type: "socks5", + tls: false, + }); + + const httpNoHeaders = mustParseConfigLine("HTTP No Headers = http, http-no-headers.example.com, 8080, headers=bad|Host:"); + expect(httpNoHeaders).toMatchObject({ + name: "HTTP No Headers", + type: "http", + server: "http-no-headers.example.com", + }); + expect(httpNoHeaders.headers).toBeUndefined(); + + expect( + mustParseConfigLine( + "VMess Username = vmess, vmess-user.example.com, 443, username=11111111-1111-4111-8111-111111111111, encryption=auto, udp-relay=false, tls-verification=false, fp=chrome" + ) + ).toMatchObject({ + name: "VMess Username", + type: "vmess", + uuid: "11111111-1111-4111-8111-111111111111", + cipher: "auto", + udp: false, + "skip-cert-verify": true, + "client-fingerprint": "chrome", + }); + + expect( + mustParseConfigLine( + "VLESS Alias = vless, vless-alias.example.com, 443, username=11111111-1111-4111-8111-111111111111, public_key=pub, short_id=sid, packetencoding=xudp, fingerprint=edge, udp-relay=false" + ) + ).toMatchObject({ + name: "VLESS Alias", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + udp: false, + "packet-encoding": "xudp", + "client-fingerprint": "edge", + "reality-opts": { + "public-key": "pub", + "short-id": "sid", + }, + }); + + expect( + mustParseConfigLine("AnyTLS None = anytls, anytls-none.example.com, 443, auth=secret, transport=none, server-name=sni.example.com") + ).toMatchObject({ + name: "AnyTLS None", + type: "anytls", + password: "secret", + sni: "sni.example.com", + }); + + expect( + mustParseConfigLine( + "TUIC Alias = tuic, tuic-alias.example.com, 443, uuid=11111111-1111-4111-8111-111111111111, password=secret, congestioncontrol=cubic, udprelaymode=quic, tfo=false" + ) + ).toMatchObject({ + name: "TUIC Alias", + type: "tuic", + uuid: "11111111-1111-4111-8111-111111111111", + password: "secret", + "congestion-controller": "cubic", + "udp-relay-mode": "quic", + tfo: false, + }); + + expect(mustParseConfigLine("Snell Loose = snell, snell-loose.example.com, 443, psk=secret, version=bad")).toMatchObject({ + name: "Snell Loose", + type: "snell", + psk: "secret", + }); + }); }); diff --git a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts index 3e1e367..59f1228 100644 --- a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts +++ b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts @@ -104,6 +104,11 @@ describe("parsePlatformProxyLine", () => { sections: new Map([["WireGuard Bad", ["peer = (public-key = public)"]]]), }) ).toThrow("WireGuard section Bad 缺少有效 endpoint"); + expect(() => + parsePlatformProxyLine("Empty WG = wireguard, section-name=Empty", { + sections: new Map([["WireGuard Empty", []]]), + }) + ).toThrow("未找到 WireGuard section: Empty"); }); it("normalizes AnyTLS fields and rejects unsupported platform-only variants", () => { @@ -156,6 +161,36 @@ describe("parsePlatformProxyLine", () => { }); }); + it("normalizes platform-specific SSH and WireGuard fallback fields", () => { + expect( + parsePlatformProxyLine( + "SSH FP = ssh, ssh.example.com, 22, username=user, password=pass, tls-fingerprint=SHA256:abc" + ) + ).toMatchObject({ + name: "SSH FP", + type: "ssh", + server: "ssh.example.com", + port: 22, + }); + + const sections = new Map([ + [ + "WireGuard Minimal", + [ + "private-key = private", + 'peer = (endpoint = "wg-minimal.example.com:51820")', + ], + ], + ]); + expect(parsePlatformProxyLine("Minimal = wireguard, section-name=Minimal", { sections })).toMatchObject({ + name: "Minimal", + type: "wireguard", + server: "wg-minimal.example.com", + port: 51820, + udp: true, + }); + }); + it("parses Loon WireGuard lines with peer, DNS, and reserved metadata", () => { const node = parsePlatformProxyLine( 'WG = wireguard, interface-ip=10.0.0.2/32, interface-ipv6=fd00::2/128, private-key=private, peers=[{public-key=public, preshared-key=psk, endpoint="wg.example.com:51820", allowed-ips="0.0.0.0/0, ::/0", reserved="[1,2,3]"}], mtu=1280, keepalive=25, dns=1.1.1.1, dnsv6=2606:4700:4700::1111' @@ -209,6 +244,17 @@ describe("parsePlatformProxyLine", () => { "Surge WireGuard 缺少 section-name" ); expect(parsePlatformProxyLine('WG Broken = wireguard, private-key=private, peers=[{public-key=public}]')).toBeNull(); + expect(parsePlatformProxyLine('WG BadPort = wireguard, private-key=private, peers=[{endpoint="wg-bad.example.com:70000"}]')).toBeNull(); + expect( + parsePlatformProxyLine( + 'WG NoReserved = wireguard, private-key=private, peers=[{endpoint="wg-no-reserved.example.com:51820", reserved="[]"}]' + ) + ).toMatchObject({ + name: "WG NoReserved", + type: "wireguard", + server: "wg-no-reserved.example.com", + port: 51820, + }); }); it("parses Loon VLESS and Hysteria2 options", () => { @@ -301,6 +347,14 @@ describe("parsePlatformProxyLine", () => { password: "pass", tls: true, }); + expect( + parsePlatformProxyLine("http=http-plain.example.com:8080, username=user, password=pass, tag=QX HTTP Plain") + ).toMatchObject({ + name: "QX HTTP Plain", + type: "http", + server: "http-plain.example.com", + port: 8080, + }); }); it("returns null for blank, unrelated, or incomplete platform lines", () => { diff --git a/packages/core/src/parser/protocols/hysteria-protocols.test.ts b/packages/core/src/parser/protocols/hysteria-protocols.test.ts index 7775108..4c241c2 100644 --- a/packages/core/src/parser/protocols/hysteria-protocols.test.ts +++ b/packages/core/src/parser/protocols/hysteria-protocols.test.ts @@ -66,6 +66,12 @@ describe("Hysteria protocol parsers", () => { "hysteria2://secret@hy2.example.com:1000-1002?obfs=salamander&obfs_password=mask&upmbps=20&downmbps=30&hop-interval=10#Ports" ); const queryPorts = parseHysteria2("hysteria2://hy2.example.com?auth=secret&mport=2000,2001"); + const ipv6Default = parseHysteria2( + "hysteria2://secret@[2001:db8::2]///?obfs=none&alpn=,,&up=10kbps&down=20&hop-interval=0&hop_interval=5" + ); + const queryAuthAlias = parseHysteria2( + "hysteria2://alias.example.com?auth_str=query-secret&ports=3000-3002&hop-interval=30-15&hopInterval=20-30" + ); expect(ranged).toMatchObject({ name: "Ports", @@ -86,6 +92,24 @@ describe("Hysteria protocol parsers", () => { password: "secret", ports: "2000,2001", }); + expect(ipv6Default).toMatchObject({ + server: "2001:db8::2", + port: 443, + password: "secret", + sni: "2001:db8::2", + up: "10kbps", + down: "20 mbps", + "hop-interval": 5, + }); + expect(ipv6Default).not.toHaveProperty("obfs"); + expect(ipv6Default).not.toHaveProperty("alpn"); + expect(queryAuthAlias).toMatchObject({ + server: "alias.example.com", + password: "query-secret", + port: 3000, + ports: "3000-3002", + "hop-interval": "20-30", + }); }); it("rejects malformed Hysteria2 links", () => { diff --git a/packages/core/src/parser/protocols/more-protocols.test.ts b/packages/core/src/parser/protocols/more-protocols.test.ts index 3d005f7..818b47a 100644 --- a/packages/core/src/parser/protocols/more-protocols.test.ts +++ b/packages/core/src/parser/protocols/more-protocols.test.ts @@ -68,6 +68,18 @@ describe("additional protocol parser contracts", () => { "min-idle-session": 4, "padding-scheme": "pad", }); + expect( + parseAnyTLS( + "anytls://host-token.example.com?auth=secret&udp=1&alpn=,,&idle-session-check-interval=&idleSessionCheckInterval=15" + ) + ).toMatchObject({ + name: "AnyTLS-host-token.example.com:443", + server: "host-token.example.com", + password: "secret", + udp: true, + sni: "host-token.example.com", + "idle-session-check-interval": 15, + }); expect(() => parseAnyTLS("anytls://secret@anytls.example.com:443?security=xtls")).toThrow( "AnyTLS 不支持 security=xtls" diff --git a/packages/core/src/parser/protocols/netch.test.ts b/packages/core/src/parser/protocols/netch.test.ts index b754e6d..0bb260c 100644 --- a/packages/core/src/parser/protocols/netch.test.ts +++ b/packages/core/src/parser/protocols/netch.test.ts @@ -119,6 +119,31 @@ describe("parseNetch", () => { type: "snell", psk: "psk", }); + expect( + parseNetch( + netch({ + Type: "SS", + Hostname: "ss-no-plugin.example.com", + Port: 8388, + Password: "secret", + PluginOption: " ; =bad ; ", + EnableUDP: 1, + EnableTFO: " ", + }) + ) + ).toMatchObject({ + name: "SS-ss-no-plugin.example.com:8388", + type: "ss", + udp: true, + }); + expect( + parseNetch(netch({ Type: "SSR", Hostname: "ssr-default.example.com", Port: 8388, Password: "secret" })) + ).toMatchObject({ + name: "SSR-ssr-default.example.com:8388", + type: "ssr", + protocol: "origin", + obfs: "plain", + }); }); it("parses VMess and Trojan transports", () => { @@ -255,6 +280,37 @@ describe("parseNetch", () => { network: "tcp", sni: "trojan-tcp.example.com", }); + expect( + parseNetch( + netch({ + Type: "VMess", + Hostname: "vmess-h2-no-host.example.com", + Port: 443, + UserID: "11111111-1111-4111-8111-111111111111", + TransferProtocol: "h2", + }) + ) + ).toMatchObject({ + network: "h2", + "h2-opts": { path: "/" }, + }); + expect( + parseNetch( + netch({ + Type: "VMess", + Hostname: "vmess-http-no-host.example.com", + Port: 80, + UserID: "11111111-1111-4111-8111-111111111111", + TransferProtocol: "http", + Path: " ,/only", + }) + ) + ).toMatchObject({ + network: "http", + "http-opts": { + path: ["/only"], + }, + }); }); it("parses additional VMess and Trojan transport variants", () => { @@ -305,6 +361,20 @@ describe("parseNetch", () => { "skip-cert-verify": true, "ws-opts": { path: "/" }, }); + expect( + parseNetch(netch({ Type: "Snell", Hostname: "snell-http.example.com", Port: 443, Password: "psk", OBFS: "http" })) + ).toMatchObject({ + type: "snell", + "obfs-opts": { mode: "http" }, + }); + expect(parseNetch(netch({ Type: "Socks", Hostname: "socks-bare.example.com", Port: 1080 }))).toMatchObject({ + type: "socks5", + }); + expect(parseNetch(netch({ Type: "HTTPS", Hostname: "https-pass.example.com", Port: 443, Password: "p" }))).toMatchObject({ + type: "https", + password: "p", + tls: true, + }); }); it("keeps Netch validation errors explicit", () => { @@ -314,6 +384,7 @@ describe("parseNetch", () => { expect(() => parseNetch(netch({ Type: "SS", Hostname: "ss.example.com", Port: 8388 }))).toThrow("Netch SS 缺少 password"); expect(() => parseNetch(netch({ Type: "SSR", Hostname: "ssr.example.com", Port: 8388 }))).toThrow("Netch SSR 缺少 password"); expect(() => parseNetch(netch({ Type: "VMess", Hostname: "vmess.example.com", Port: 443 }))).toThrow("Netch VMess 缺少 uuid"); + expect(() => parseNetch(netch({ Type: "Trojan", Hostname: "trojan.example.com", Port: 443 }))).toThrow("Netch Trojan 缺少 password"); expect(() => parseNetch( netch({ diff --git a/packages/core/src/parser/protocols/simple-proxy.test.ts b/packages/core/src/parser/protocols/simple-proxy.test.ts index 10cf10a..489e131 100644 --- a/packages/core/src/parser/protocols/simple-proxy.test.ts +++ b/packages/core/src/parser/protocols/simple-proxy.test.ts @@ -15,6 +15,7 @@ describe("simple proxy parsers", () => { const colonAuth = parseHttp("http://colon.example.com:8080:user:p%40ss#Colon"); const headerAlias = parseHttp("http://alias.example.com:8080?header=bad|:skip|X-Empty:&skip_cert_verify=no&peer=peer.example.com"); const ipv6 = parseHttp("http://[2001:db8::1]:8080#IPv6"); + const tlsVerified = parseHttp("http://verified.example.com:8080?tls-verification=true#Verified"); expect(http).toMatchObject({ name: "HTTP", @@ -66,6 +67,10 @@ describe("simple proxy parsers", () => { server: "2001:db8::1", port: 8080, }); + expect(tlsVerified).toMatchObject({ + name: "Verified", + "skip-cert-verify": false, + }); }); it("parses SOCKS variants including encoded authority and encoded username auth", () => { @@ -76,6 +81,7 @@ describe("simple proxy parsers", () => { const fromAuthorityNoAuth = parseSocks(`socks://${encodedNoAuth}`); const fromUsername = parseSocks(`socks5://${encodedAuth}@socks-auth.example.com:1080#Auth`); const tls = parseSocks("socks5+tls://carol:secret@socks-tls.example.com:443?udp=0&sni=socks.example.com#TLS"); + const tlsVerified = parseSocks("socks5://verified-socks.example.com:1080?tls-verification=true#VerifiedSocks"); const socks4 = parseSocks("socks4://dave@socks4.example.com?udp-relay=yes&allow_insecure=true#S4"); const defaultName = parseSocks("socks5://socks-default.example.com:1080"); const controlAuthority = parseSocks(`socks://${Buffer.from("\x00bad").toString("base64url")}#Control`); @@ -104,6 +110,10 @@ describe("simple proxy parsers", () => { udp: false, sni: "socks.example.com", }); + expect(tlsVerified).toMatchObject({ + name: "VerifiedSocks", + "skip-cert-verify": false, + }); expect(socks4).toMatchObject({ name: "S4", type: "socks4", @@ -135,6 +145,7 @@ describe("simple proxy parsers", () => { "ssh://root:secret@ssh.example.com:22?private-key=KEY&host-key=a,b&server-fingerprint=fp&idle-timeout=30&host-key-algorithms=ssh-ed25519|rsa&allow-insecure=1#SSH" ); const sshAliases = parseSsh("ssh://alias@ssh-alias.example.com?private_key=KEY&hostKey=a;b&idle_timeout=bad#AliasSSH"); + const sshEmptyHostKey = parseSsh("ssh://ssh-empty.example.com?host-key=,&private-key=KEY#EmptyHostKey"); expect(naked).toMatchObject({ name: "Naked", @@ -168,6 +179,7 @@ describe("simple proxy parsers", () => { username: "user", password: "p:a:ss", }); + expect(() => parseSimpleProxy("[2001:db8::2]:1080{IPv6 Naked}", "socks5")).toThrow("无效的端口号"); expect(() => parseSimpleProxy("user@[2001:db8::1]{IPv6}", "socks5")).toThrow("缺少服务器地址"); expect(ssh).toMatchObject({ name: "SSH", @@ -191,6 +203,96 @@ describe("simple proxy parsers", () => { "host-key": ["a", "b"], }); expect(sshAliases).not.toHaveProperty("idle-timeout"); + expect(sshEmptyHostKey).toMatchObject({ + name: "EmptyHostKey", + "private-key": "KEY", + }); + expect(sshEmptyHostKey).not.toHaveProperty("host-key"); + }); + + it("keeps malformed suffixes and edge auth fields explicit in simple proxy forms", () => { + expect(parseSimpleProxy("suffix.example.com:8080}", "http")).toMatchObject({ + name: "HTTP-suffix.example.com:8080", + server: "suffix.example.com", + port: 8080, + }); + expect(parseSimpleProxy("nested.example.com:8080{bad}}", "http")).toMatchObject({ + name: "HTTP-nested.example.com:8080", + server: "nested.example.com", + port: 8080, + }); + expect(parseHttp("http://empty-headers.example.com:8080?headers= ")).not.toHaveProperty("headers"); + expect(parseHttp("http://pipe.example.com:8080?headers=A:B||C:D")).toMatchObject({ + headers: { A: "B", C: "D" }, + }); + + const encodedHttp = Buffer.from("http-base.example.com:8080").toString("base64url"); + expect(parseHttp(`http://${encodedHttp}?remark=EncodedName`)).toMatchObject({ + name: "EncodedName", + server: "http-base.example.com", + port: 8080, + }); + + const encodedMissingPort = Buffer.from("alice:secret@socks-missing-port.example.com").toString("base64url"); + expect(parseSocks(`socks://${encodedMissingPort}#MissingPort`)).toMatchObject({ + name: "MissingPort", + server: encodedMissingPort, + port: 1080, + }); + + expect(parseSsh("ssh://ssh-minimal.example.com")).toMatchObject({ + name: "SSH-ssh-minimal.example.com:22", + server: "ssh-minimal.example.com", + port: 22, + }); + expect(parseHttp("https://forced.example.com")).toMatchObject({ + type: "https", + port: 443, + tls: true, + }); + }); + + it("covers standard URL branch fallbacks without changing compatibility behavior", () => { + const encodedUsernameOnly = Buffer.from("alice@base64-user.example.com:8080").toString("base64url"); + const encodedHash = Buffer.from("hash-base.example.com:8080").toString("base64url"); + const encodedSuffix = Buffer.from("suffix-base.example.com:8080").toString("base64url"); + const encodedAuthWithoutColon = Buffer.from("only-user").toString("base64url"); + + expect(parseHttp(`http://${encodedUsernameOnly}#EncodedUser`)).toMatchObject({ + name: "EncodedUser", + server: "base64-user.example.com", + port: 8080, + username: "alice", + }); + expect(parseHttp(`http://${encodedHash}#HashName`)).toMatchObject({ + name: "HashName", + server: "hash-base.example.com", + port: 8080, + }); + expect(parseHttp(`http://${encodedSuffix}{SuffixName}`)).toMatchObject({ + name: "SuffixName", + server: "suffix-base.example.com", + port: 8080, + }); + expect(parseHttp('http://number-header.example.com:8080?headers={"A":1}')).not.toHaveProperty("headers"); + expect(parseHttp("http://empty-bool.example.com:8080?allow-insecure=&tls-name=tls.example.com")).toMatchObject({ + name: "HTTP-empty-bool.example.com:8080", + sni: "tls.example.com", + }); + expect(parseSocks(`socks5://${encodedAuthWithoutColon}@encoded-user.example.com:1080#RawEncoded`)).toMatchObject({ + name: "RawEncoded", + username: encodedAuthWithoutColon, + server: "encoded-user.example.com", + }); + expect(() => parseSocks("socks://")).toThrow("缺少服务器地址"); + expect(() => parseSocks("socks.example.com:1080")).toThrow("无效的 SOCKS 链接"); + expect(() => parseHttp("http://badcolon.example.com:x:user:pass")).toThrow("无效的端口号"); + expect(() => parseHttp("http://:8080:user:pass")).toThrow("缺少服务器地址"); + expect(parseSimpleProxy("user@[2001:db8::1]x", "socks5")).toMatchObject({ + username: "user", + server: "2001:db8::1", + port: 1080, + }); }); it("parses Telegram-style proxy links and keeps validation errors explicit", () => { @@ -219,6 +321,11 @@ describe("simple proxy parsers", () => { server: "tg-secure.example.com", tls: true, }); + expect(parseTelegramProxyLink("https://t.me/socks/proxy?server=tg-path.example.com&port=1080")).toMatchObject({ + name: "SOCKS-tg-path.example.com:1080", + type: "socks5", + server: "tg-path.example.com", + }); expect(() => parseSocks("http://example.com:1080")).toThrow("无效的 SOCKS 链接"); expect(() => parseSimpleProxy("ftp://example.com:21")).toThrow("不支持的协议: ftp"); @@ -229,6 +336,7 @@ describe("simple proxy parsers", () => { expect(() => parseTelegramProxyLink("https://example.com/socks?server=x&port=1080")).toThrow( "无效的 Telegram 代理链接" ); + expect(() => parseTelegramProxyLink("https://t.me/?server=x&port=1080")).toThrow("无效的 Telegram 代理链接"); expect(() => parseTelegramProxyLink("tg://socks?port=1080")).toThrow("缺少服务器地址"); expect(() => parseTelegramProxyLink("tg://socks?server=x&port=70000")).toThrow("无效的端口号"); }); diff --git a/packages/core/src/parser/protocols/ss.test.ts b/packages/core/src/parser/protocols/ss.test.ts index 7e8980c..6d56298 100644 --- a/packages/core/src/parser/protocols/ss.test.ts +++ b/packages/core/src/parser/protocols/ss.test.ts @@ -54,9 +54,11 @@ describe("parseSS", () => { it("parses empty bool query flags and ignores invalid v2ray-plugin JSON", () => { const boolFlags = parseSS("ss://aes-128-gcm:secret@bool.example.com:8388?uot=&tfo=#Bool"); + const invalidFlags = parseSS("ss://aes-128-gcm:secret@flags.example.com:8388?uot=maybe&tfo=0#Flags"); const invalidJson = parseSS( `ss://aes-128-gcm:secret@plain.example.com:8388?v2ray-plugin=${encodeURIComponent("not-json")}#Plain` ); + const emptyJson = parseSS("ss://aes-128-gcm:secret@empty-json.example.com:8388?v2ray-plugin= #EmptyJSON"); expect(boolFlags).toMatchObject({ name: "Bool", @@ -64,11 +66,18 @@ describe("parseSS", () => { "udp-over-tcp": true, tfo: true, }); + expect(invalidFlags).toMatchObject({ + name: "Flags", + server: "flags.example.com", + }); + expect(invalidFlags).not.toHaveProperty("udp-over-tcp"); + expect(invalidFlags).not.toHaveProperty("tfo"); expect(invalidJson).toMatchObject({ name: "Plain", server: "plain.example.com", }); expect(invalidJson).not.toHaveProperty("plugin"); + expect(emptyJson).not.toHaveProperty("plugin"); }); it("normalizes plugin names and rejects malformed links", () => { @@ -92,10 +101,26 @@ describe("parseSS", () => { plugin: "gost-plugin", pluginOpts: { tls: true, mux: false, extra: "keep" }, }); + expect(normalizeSsPlugin("gost-plugin", { tls: true, mux: 1 })).toEqual({ + plugin: "gost-plugin", + pluginOpts: { tls: true, mux: true }, + }); + expect(normalizeSsPlugin("gost-plugin", { tls: "", mux: 2 })).toEqual({ + plugin: "gost-plugin", + pluginOpts: { tls: "", mux: 2 }, + }); expect(normalizeSsPlugin("xray-plugin", { tls: "off", mux: "maybe" })).toEqual({ plugin: "xray-plugin", pluginOpts: { tls: false, mux: "maybe" }, }); + expect(normalizeSsPlugin("xray-plugin", { tls: false, mux: "on" })).toEqual({ + plugin: "xray-plugin", + pluginOpts: { tls: false, mux: true }, + }); + expect(normalizeSsPlugin("xray-plugin", { tls: {}, mux: null })).toEqual({ + plugin: "xray-plugin", + pluginOpts: { tls: {}, mux: null }, + }); expect(normalizeSsPlugin("simple-obfs", { mode: "", host: "" })).toEqual({ plugin: "obfs", pluginOpts: undefined, @@ -120,6 +145,21 @@ describe("parseSS", () => { }, tfo: true, }); + expect( + parseSS( + `ss://${b64("aes-128-gcm:secret")}@empty-plugin.example.com:8388?plugin=${encodeURIComponent( + String.raw`obfs-local;%20;empty=` + )}#EmptyPlugin` + ) + ).toMatchObject({ + name: "EmptyPlugin", + plugin: "obfs", + }); + expect(parseSS(`ss://${b64("aes-128-gcm:secret@[2001:db8::2]:8388")}#FullIPv6`)).toMatchObject({ + name: "FullIPv6", + server: "2001:db8::2", + port: 8388, + }); expect(() => parseSS("http://bad")).toThrow("无效的 SS 链接"); expect(() => parseSS(`ss://${b64("aes-128-gcm:secret@ss.example.com")}`)).toThrow("无法解析服务器端口"); diff --git a/packages/core/src/parser/protocols/ssr-snell.test.ts b/packages/core/src/parser/protocols/ssr-snell.test.ts index 1a2daec..6515052 100644 --- a/packages/core/src/parser/protocols/ssr-snell.test.ts +++ b/packages/core/src/parser/protocols/ssr-snell.test.ts @@ -52,6 +52,26 @@ describe("SSR and Snell parsers", () => { protocol: "origin", obfs: "plain", }); + expect(parseSSR(`ssr://${b64(`empty-query.example.com:8388::::${b64("secret")}?`)}`)).toMatchObject({ + name: "SSR-empty-query.example.com:8388", + password: "secret", + }); + expect( + parseSSR( + `ssr://${b64( + `raw-param.example.com:8388:auth:aes-128-gcm:http:${b64("plain secret")}/?remarks=${b64("Raw SSR")}&&=skip&protoparam=${b64("proto raw")}&obfsparam=${b64("obfs raw")}` + )}` + ) + ).toMatchObject({ + name: "Raw SSR", + type: "ssr", + password: "plain secret", + protocol: "auth", + cipher: "aes-128-gcm", + obfs: "http", + "protocol-param": "proto raw", + "obfs-param": "obfs raw", + }); }); it("keeps SSR validation errors explicit", () => { diff --git a/packages/core/src/parser/protocols/vless.test.ts b/packages/core/src/parser/protocols/vless.test.ts index 4058ab2..08adce3 100644 --- a/packages/core/src/parser/protocols/vless.test.ts +++ b/packages/core/src/parser/protocols/vless.test.ts @@ -298,6 +298,84 @@ describe("parseVLESS", () => { expect(node).not.toHaveProperty("flow"); }); + it("keeps VLESS authority and xHTTP edge branches explicit", () => { + expect(parseVLESS(`vless://${UUID}@[2001:db8::1]:443?security=tls&type=ws&host=ipv6.example.com#IPv6`)) + .toMatchObject({ + name: "IPv6", + server: "2001:db8::1", + port: 443, + network: "ws", + "ws-opts": { + path: "/", + headers: { Host: "ipv6.example.com" }, + }, + }); + + const encoded = Buffer.from(`prefix:${UUID}@sr-flow.example.com:443`).toString("base64url"); + expect( + parseVLESS( + `vless://${encoded}?obfs=websocket&tls=yes&flow=xtls-rprx-vision&obfsParam=NoColon%7C%3Abad%7CHost%3Acdn.example.com&path=%2F#Flow` + ) + ).toMatchObject({ + name: "Flow", + server: "sr-flow.example.com", + flow: "xtls-rprx-vision", + network: "ws", + "ws-opts": { + path: "/", + headers: { Host: "NoColon|:bad|Host:cdn.example.com" }, + }, + }); + + expect( + parseVLESS( + `vless://${UUID}@xhttp-fallback.example.com:443?security=tls&type=xhttp&path=&host=&headers=NoColon%7C%3Aempty%7CGood%3Ayes&no-grpc-header=maybe&download-headers=%7B%22bad%22%3A1%7D#XFallback` + ) + ).toMatchObject({ + name: "XFallback", + network: "xhttp", + "xhttp-opts": { + path: "/", + headers: { Good: "yes" }, + }, + }); + }); + + it("handles VLESS authority probes and empty Shadowrocket websocket options", () => { + const encoded = Buffer.from(`${UUID}@sr-empty-options.example.com:443`).toString("base64url"); + + expect(parseVLESS(`vless://${UUID}@slash-authority.example.com:443/?type=ws#Slash`)).toMatchObject({ + name: "Slash", + server: "slash-authority.example.com", + network: "ws", + }); + expect(parseVLESS(`vless://${encoded}?obfs=websocket&obfsParam=%20&path=%3Fed%3D64#EmptyOptions`)) + .toMatchObject({ + name: "EmptyOptions", + server: "sr-empty-options.example.com", + network: "ws", + "ws-opts": { + path: "/", + headers: undefined, + "early-data-header-name": "Sec-WebSocket-Protocol", + "max-early-data": 64, + }, + }); + + expect(() => parseVLESS(`vless://${UUID}@missing-host-port.example.com?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@empty-host-port.example.com:?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@bad-host-port.example.com:abc?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@[2001:db8::1?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@?x=1`)).toThrow(); + }); + + it("rejects malformed Shadowrocket-style VLESS probes before normal parsing", () => { + const noQuery = Buffer.from(`${UUID}@shadow-no-query.example.com:443`).toString("base64url"); + + expect(() => parseVLESS(`vless://${noQuery}`)).toThrow("VLESS 配置缺少必要字段"); + expect(() => parseVLESS(`vless://${UUID}@[2001:db8::1]?security=tls`)).toThrow("VLESS 配置缺少必要字段"); + }); + it("rejects unsupported transports and ECH without plain TLS", () => { expect(() => parseVLESS(`vless://${UUID}@bad.example.com:443?type=kcp#Bad`)).toThrow("不支持的 VLESS 传输层"); expect(() => parseVLESS(`vless://${UUID}@bad.example.com:443?security=reality&ech=config#Bad`)).toThrow( diff --git a/packages/core/src/parser/protocols/vmess-utils.test.ts b/packages/core/src/parser/protocols/vmess-utils.test.ts index 1282c98..056795b 100644 --- a/packages/core/src/parser/protocols/vmess-utils.test.ts +++ b/packages/core/src/parser/protocols/vmess-utils.test.ts @@ -20,9 +20,15 @@ describe("VMess parser utility helpers", () => { it("normalizes primitive values and headers", () => { expect(DINGTALK_USER_AGENT).toContain("DingTalk"); expect(hasDingTalkHost(["api.dingtalk.com"])).toBe(true); + expect(hasDingTalkHost(undefined)).toBe(false); + expect(hasDingTalkHost([])).toBe(false); expect(hasDingTalkHost(["example.com"])).toBe(false); expect(normalizeHttpMethod("post")).toBe("POST"); + expect(normalizeHttpMethod(undefined)).toBe("GET"); + expect(normalizeHttpMethod(" ")).toBe("GET"); expect(normalizeHttpMethod("bad method")).toBe("GET"); + expect(normalizeHeaderKey("connection")).toBe("Connection"); + expect(normalizeHeaderKey("X-Test")).toBe("X-Test"); expect(normalizeHeaderKey(" user-agent ")).toBe("User-Agent"); expect(parseHeaderRecord({ host: "cdn.example.com", empty: "", n: 1, b: false, list: ["a", "", "b"], bad: {} })).toEqual({ Host: ["cdn.example.com"], @@ -30,6 +36,8 @@ describe("VMess parser utility helpers", () => { b: ["false"], list: ["a", "b"], }); + expect(parseHeaderRecord({ emptyList: ["", 1], nested: {} })).toBeUndefined(); + expect(parseHeaderRecord(null)).toBeUndefined(); expect(parseHeaderRecord([])).toBeUndefined(); expect(splitList("a, b,,c")).toEqual(["a", "b", "c"]); expect(splitList("")).toBeUndefined(); @@ -37,6 +45,11 @@ describe("VMess parser utility helpers", () => { expect(pickString(1)).toBe(""); expect(parseBooleanish("yes")).toBe(true); expect(parseBooleanish("off")).toBe(false); + expect(parseBooleanish(true)).toBe(true); + expect(parseBooleanish(0)).toBe(false); + expect(parseBooleanish(2)).toBeUndefined(); + expect(parseBooleanish({})).toBeUndefined(); + expect(parseBooleanish(" ")).toBeUndefined(); expect(parseBooleanish("maybe")).toBeUndefined(); }); @@ -45,11 +58,17 @@ describe("VMess parser utility helpers", () => { const params = new URLSearchParams("a=&b=value&c=next"); expect(looksLikeUriStyleVmess("uuid@example.com:443")).toBe(true); + expect(looksLikeUriStyleVmess("plain")).toBe(false); expect(looksLikeStandardVmessStyle("ws+tls:uuid-0@example.com:443")).toBe(true); + expect(looksLikeStandardVmessStyle("ws+tls:uuid-a@example.com:443")).toBe(false); expect(looksLikeShadowrocketStyleVmess(`${shadowrocket}?obfs=websocket`)).toBe(true); + expect(looksLikeShadowrocketStyleVmess(`${shadowrocket}@bad?obfs=websocket`)).toBe(false); + expect(looksLikeShadowrocketStyleVmess("?obfs=websocket")).toBe(false); expect(looksLikeShadowrocketStyleVmess("not-base64?obfs=websocket")).toBe(false); expect(stripOuterQuotes('"quoted"')).toBe("quoted"); expect(pickQueryParam(params, "a", "b", "c")).toBe("value"); + expect(pickQueryParam(params, "missing", "a")).toBeUndefined(); + expect(parseObfsHeaderHost(undefined)).toBeUndefined(); expect(parseObfsHeaderHost("Host: cdn.example.com, Path: /ws")).toBe("cdn.example.com"); expect(parseObfsHeaderHost("cdn.example.com")).toBe("cdn.example.com"); }); diff --git a/packages/core/src/parser/protocols/vmess.test.ts b/packages/core/src/parser/protocols/vmess.test.ts index b03073a..2505fc6 100644 --- a/packages/core/src/parser/protocols/vmess.test.ts +++ b/packages/core/src/parser/protocols/vmess.test.ts @@ -410,6 +410,83 @@ describe("parseVMess", () => { .toThrow("不支持的 VMess 传输层"); }); + it("covers malformed VMess style probes without accepting invalid authorities", () => { + const badShadowrocketNoQuery = Buffer.from(`auto:${UUID}@shadow-no-query.example.com:443`).toString("base64url"); + const badShadowrocketBase = Buffer.from(`auto:${UUID}@shadow-bad.example.com:not-a-port`).toString("base64url"); + + expect(() => parseVMess(`vmess://${badShadowrocketNoQuery}`)).toThrow("无效的 VMess JSON 格式"); + expect(() => parseVMess(`vmess://${badShadowrocketBase}?obfs=websocket`)).toThrow("VMess 配置缺少必要字段"); + }); + + it("keeps sparse VMess defaults explicit across URI, JSON, and Shadowrocket forms", () => { + const shadowrocketBase = Buffer.from(`auto:${UUID}@shadow-default.example.com:443`).toString("base64url"); + expect(parseVMess(`vmess://${shadowrocketBase}?network=grpc&tls=1&ech=&remarks=`)).toMatchObject({ + name: "VMess shadow-default.example.com:443", + server: "shadow-default.example.com", + tls: true, + network: "grpc", + "grpc-opts": { + "grpc-service-name": "", + }, + }); + + expect( + parseVMess( + `vmess://${UUID}@uri-grpc-path.example.com:443?type=grpc&security=tls&path=%2Ffrom-path&ech=#UriGrpcPath` + ) + ).toMatchObject({ + name: "UriGrpcPath", + network: "grpc", + "grpc-opts": { + "grpc-service-name": "from-path", + }, + "ech-opts": { enable: true }, + }); + + expect( + parseVMess( + `vmess://${toVmessPayload({ + ps: "", + add: "json-none.example.com", + port: 80, + id: UUID, + aid: "", + net: "none", + scy: "", + })}` + ) + ).toMatchObject({ + name: "VMess 节点", + server: "json-none.example.com", + alterId: 0, + cipher: "auto", + network: "tcp", + }); + + expect( + parseVMess( + `vmess://${toVmessPayload({ + ps: "Edge WS", + add: "edge-ws.example.com", + port: 443, + id: UUID, + aid: 0, + net: "ws", + tls: "tls", + edge: "edge-header", + path: "/", + })}` + ) + ).toMatchObject({ + name: "Edge WS", + network: "ws", + "ws-opts": { + path: "/", + headers: { Edge: "edge-header" }, + }, + }); + }); + it("rejects unsupported transports and ECH without TLS", () => { const badShadowrocket = Buffer.from("bad").toString("base64url"); diff --git a/packages/core/src/proxy-group-advanced.test.ts b/packages/core/src/proxy-group-advanced.test.ts index f39d7ad..764a8ac 100644 --- a/packages/core/src/proxy-group-advanced.test.ts +++ b/packages/core/src/proxy-group-advanced.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveProxyGroupMembers } from "./proxy-group-advanced"; -import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import { + normalizeProxyGroupAdvancedConfig, + normalizeProxyGroupMemberRef, + resolveProxyGroupMembers, +} from "./proxy-group-advanced"; +import { withNodeSourceId } from "./subscription/node-source-state"; import type { ParsedNode } from "@subboost/core/types/node"; function node(name: string): ParsedNode { @@ -15,6 +19,71 @@ function node(name: string): ParsedNode { } describe("resolveProxyGroupMembers", () => { + it("normalizes advanced config and member refs conservatively", () => { + expect(normalizeProxyGroupMemberRef({ kind: "node", name: " Node A " })).toEqual({ kind: "node", name: "Node A" }); + expect(normalizeProxyGroupMemberRef({ kind: "module", id: " auto " })).toEqual({ kind: "module", id: "auto" }); + expect(normalizeProxyGroupMemberRef({ kind: "custom", id: " media " })).toEqual({ kind: "custom", id: "media" }); + expect(normalizeProxyGroupMemberRef({ kind: "direct" })).toEqual({ kind: "direct" }); + expect(normalizeProxyGroupMemberRef({ kind: "reject" })).toEqual({ kind: "reject" }); + expect(normalizeProxyGroupMemberRef(null)).toBeNull(); + expect(normalizeProxyGroupMemberRef("DIRECT")).toBeNull(); + expect(normalizeProxyGroupMemberRef({ kind: "node", name: "" })).toBeNull(); + expect(normalizeProxyGroupMemberRef({ kind: "module", id: " " })).toBeNull(); + expect(normalizeProxyGroupMemberRef({ kind: "custom", id: " " })).toBeNull(); + expect(normalizeProxyGroupMemberRef([])).toBeNull(); + + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: [" source-a ", "source-a", "", 1], + regions: ["US", "other", "bad", "us"], + includeRegex: " IEPL ", + excludeRegex: " 测试 ", + groupType: "load-balance", + strategy: "bad", + extraMembers: [{ kind: "direct" }, { kind: "direct" }, { kind: "node", name: "Node A" }], + excludedMembers: [{ kind: "reject" }, { kind: "node", name: "" }], + memberOrder: [{ kind: "node", name: "Node A" }, { kind: "node", name: "Node A" }], + }), + ).toEqual({ + sourceIds: ["source-a"], + regions: ["us", "other"], + includeRegex: "IEPL", + excludeRegex: "测试", + groupType: "load-balance", + strategy: "consistent-hashing", + extraMembers: [{ kind: "direct" }, { kind: "node", name: "Node A" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [{ kind: "node", name: "Node A" }], + }); + + expect(normalizeProxyGroupAdvancedConfig(null)).toEqual({}); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "select", strategy: "round-robin" })).toEqual({ + groupType: "select", + }); + expect(normalizeProxyGroupAdvancedConfig([])).toEqual({}); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "url-test" })).toEqual({ groupType: "url-test" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "fallback" })).toEqual({ groupType: "fallback" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "direct-first" })).toEqual({ groupType: "direct-first" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "reject-first" })).toEqual({ groupType: "reject-first" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "unknown" })).toEqual({}); + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: "source-a", + regions: "us", + includeRegex: 1, + excludeRegex: 2, + groupType: "load-balance", + strategy: "round-robin", + extraMembers: "bad", + excludedMembers: "bad", + memberOrder: "bad", + }), + ).toEqual({ + groupType: "load-balance", + strategy: "round-robin", + }); + }); + it("filters self custom references without dropping a node with the same name", () => { const result = resolveProxyGroupMembers({ defaultProxyNames: ["Self Group", "Other Node"], @@ -96,4 +165,264 @@ describe("resolveProxyGroupMembers", () => { expect(invalidRegexResult.proxyNames).toEqual(["US Source"]); }); + + it("resolves module/custom members, unavailable refs, and explicit ordering", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["Auto", "Custom", "Unknown", "DIRECT"], + availableProxyNames: ["Auto", "Custom", "Other Node", "REJECT"], + nodes: [node("Other Node")], + moduleNames: { auto: "Auto" }, + customProxyGroups: [ + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + advanced: { + extraMembers: [ + { kind: "node", name: "Other Node" }, + { kind: "custom", id: "missing" }, + { kind: "custom", id: "disabled" }, + { kind: "reject" }, + ], + memberOrder: [ + { kind: "reject" }, + { kind: "node", name: "Other Node" }, + { kind: "module", id: "auto" }, + ], + }, + self: { kind: "module", id: "self", name: "Self" }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "reject:REJECT", + "node:Other Node", + "module:auto", + "custom:custom", + "direct:DIRECT", + ]); + expect(result.excluded.map((member) => member.key)).toEqual([]); + }); + + it("covers fallback members, unmatched regions, and unavailable candidate handling", () => { + const unmatchedRegion = resolveProxyGroupMembers({ + defaultProxyNames: ["France Node"], + nodes: [node("France Node")], + advanced: { regions: ["jp"] }, + }); + const otherRegion = resolveProxyGroupMembers({ + defaultProxyNames: ["US Relay", "Relay X"], + nodes: [node("US Relay"), node("Relay X")], + advanced: { regions: ["other"] }, + }); + const extraOnly = resolveProxyGroupMembers({ + defaultProxyNames: [], + nodes: [node("Extra Node")], + moduleNames: { auto: "Auto" }, + customProxyGroups: [ + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + { id: "empty-name", name: " ", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + advanced: { + extraMembers: [ + { kind: "direct" }, + { kind: "reject" }, + { kind: "node", name: "Extra Node" }, + { kind: "node", name: "Missing Node" }, + { kind: "module", id: "auto" }, + { kind: "module", id: "missing" }, + { kind: "custom", id: "custom" }, + { kind: "custom", id: "empty-name" }, + { kind: "custom", id: "disabled" }, + ], + excludedMembers: [{ kind: "reject" }], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "custom", id: "custom" }, + { kind: "module", id: "missing" }, + { kind: "direct" }, + ], + }, + }); + + expect(unmatchedRegion.included).toEqual([]); + expect(unmatchedRegion.excluded.map((member) => member.key)).toEqual(["node:France Node"]); + expect(otherRegion.included.map((member) => member.key)).toEqual(["node:Relay X"]); + expect(otherRegion.excluded.map((member) => member.key)).toEqual(["node:US Relay"]); + expect(extraOnly.included.map((member) => member.key)).toEqual([ + "custom:custom", + "direct:DIRECT", + "node:Extra Node", + "module:auto", + ]); + expect(extraOnly.excluded.map((member) => member.key)).toEqual(["reject:REJECT"]); + }); + + it("handles malformed names, missing refs, and self guards while resolving extras", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["", "Auto", "Self Module", "Disabled", "Nameless", "Node A", "Node A", "Ghost"], + nodes: [node("Node A"), node("Allowed")], + moduleNames: { + "": "Bad Module", + auto: "Auto", + blank: " ", + self: "Self Module", + }, + customProxyGroups: [ + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + { id: "", name: "No Id", emoji: "", groupType: "select" }, + { id: "nameless", name: " ", emoji: "", groupType: "select" }, + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + ], + advanced: { + extraMembers: [ + { kind: "module", id: "auto" }, + { kind: "module", id: "blank" }, + { kind: "module", id: "missing" }, + { kind: "custom", id: "custom" }, + { kind: "custom", id: "nameless" }, + { kind: "node", name: "Allowed" }, + { kind: "node", name: "Missing Node" }, + { kind: "direct" }, + ], + excludedMembers: [{ kind: "module", id: "auto" }], + memberOrder: [ + { kind: "node", name: "Missing Node" }, + { kind: "custom", id: "custom" }, + { kind: "direct" }, + ], + }, + self: { kind: "module", id: "self", name: "Self Module" }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "custom:custom", + "direct:DIRECT", + "node:Node A", + "node:Allowed", + ]); + expect(result.excluded.map((member) => member.key)).toEqual(["module:auto"]); + }); + + it("keeps fallback resolution stable for sparse options and filter misses", () => { + expect( + normalizeProxyGroupAdvancedConfig({ + regions: [1, "DE"], + groupType: "load-balance", + strategy: "sticky-sessions", + }), + ).toEqual({ + regions: ["de"], + groupType: "load-balance", + strategy: "sticky-sessions", + }); + + const noAvailableNames = resolveProxyGroupMembers({ + defaultProxyNames: ["REJECT", "Missing Node", "German Node", "Hidden Node", 1] as unknown as string[], + nodes: [withNodeSourceId(node("German Node"), "source-a"), node("Hidden Node")], + advanced: { + sourceIds: ["source-b"], + includeRegex: "Visible", + }, + }); + const noAdvanced = resolveProxyGroupMembers({ + defaultProxyNames: ["German Node"], + nodes: [node("German Node")], + }); + const emptyNames = resolveProxyGroupMembers({ + defaultProxyNames: undefined as unknown as string[], + availableProxyNames: undefined, + nodes: [node("German Node")], + advanced: {}, + }); + + expect(noAvailableNames.included.map((member) => member.key)).toEqual(["reject:REJECT"]); + expect(noAvailableNames.excluded.map((member) => member.key)).toEqual(["node:German Node", "node:Hidden Node"]); + expect(noAdvanced.proxyNames).toEqual(["German Node"]); + expect(emptyNames.included).toEqual([]); + expect(emptyNames.excluded).toEqual([]); + }); + + it("normalizes sparse advanced values and resolves members from fallback references", () => { + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: ["", " source-a ", "source-b"], + regions: ["bad", "KR", "kr"], + includeRegex: " ", + excludeRegex: "", + groupType: "load-balance", + strategy: "sticky-sessions", + extraMembers: [ + null, + { kind: "node", name: " Node B " }, + { kind: "node", name: "Node B" }, + { kind: "module", id: " auto " }, + { kind: "custom", id: "custom" }, + ], + excludedMembers: [ + { kind: "direct" }, + { kind: "direct" }, + { kind: "custom", id: "disabled" }, + ], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "module", id: "auto" }, + { kind: "direct" }, + ], + }), + ).toEqual({ + sourceIds: ["source-a", "source-b"], + regions: ["kr"], + groupType: "load-balance", + strategy: "sticky-sessions", + extraMembers: [ + { kind: "node", name: "Node B" }, + { kind: "module", id: "auto" }, + { kind: "custom", id: "custom" }, + ], + excludedMembers: [{ kind: "direct" }, { kind: "custom", id: "disabled" }], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "module", id: "auto" }, + { kind: "direct" }, + ], + }); + + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["Auto", "Custom", "DIRECT", "REJECT", "Korea Node", "Unknown"], + availableProxyNames: ["Auto", "Custom", "DIRECT", "REJECT", "Korea Node", "Other Node"], + nodes: [withNodeSourceId(node("Korea Node"), "source-a"), withNodeSourceId(node("Other Node"), "source-b")], + moduleNames: { auto: "Auto", blank: " " }, + customProxyGroups: [ + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + advanced: { + sourceIds: ["source-a"], + regions: ["kr"], + extraMembers: [ + { kind: "node", name: "Other Node" }, + { kind: "module", id: "auto" }, + { kind: "custom", id: "custom" }, + { kind: "direct" }, + { kind: "custom", id: "disabled" }, + ], + excludedMembers: [{ kind: "direct" }], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "module", id: "auto" }, + { kind: "node", name: "Korea Node" }, + { kind: "node", name: "Other Node" }, + ], + }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "custom:custom", + "module:auto", + "node:Korea Node", + "node:Other Node", + "reject:REJECT", + ]); + expect(result.excluded.map((member) => member.key)).toEqual(["direct:DIRECT"]); + }); }); diff --git a/packages/core/src/proxy-group-name.test.ts b/packages/core/src/proxy-group-name.test.ts new file mode 100644 index 0000000..79e6380 --- /dev/null +++ b/packages/core/src/proxy-group-name.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeGroupNameWithDefaultEmoji, + resolveProxyGroupModuleName, + splitLeadingEmoji, +} from "./proxy-group-name"; + +describe("proxy group name helpers", () => { + it("splits emoji prefixes without treating ordinary words as emojis", () => { + expect(splitLeadingEmoji("🚀 Node Select")).toEqual({ + emoji: "🚀", + hasEmojiPrefix: true, + label: "Node Select", + }); + expect(splitLeadingEmoji("Node Select")).toEqual({ + emoji: "", + hasEmojiPrefix: false, + label: "Node Select", + }); + expect(splitLeadingEmoji("🚀")).toEqual({ + emoji: "", + hasEmojiPrefix: false, + label: "🚀", + }); + expect(splitLeadingEmoji("A Select")).toMatchObject({ hasEmojiPrefix: false }); + expect(splitLeadingEmoji("国内 服务")).toMatchObject({ hasEmojiPrefix: false }); + }); + + it("applies default emoji only when overrides do not already include one", () => { + const groupModule = { emoji: "⚡", name: "⚡ 自动选择" }; + expect(resolveProxyGroupModuleName(groupModule)).toBe("⚡ 自动选择"); + expect(resolveProxyGroupModuleName(groupModule, " Auto ")).toBe("⚡ Auto"); + expect(resolveProxyGroupModuleName(groupModule, "🚀 Custom")).toBe("🚀 Custom"); + expect(resolveProxyGroupModuleName(groupModule, " ")).toBe("⚡ 自动选择"); + + expect(normalizeGroupNameWithDefaultEmoji("", "")).toEqual({ full: "", emoji: "🧩" }); + expect(normalizeGroupNameWithDefaultEmoji("Media", "🎬")).toEqual({ full: "🎬 Media", emoji: "🎬" }); + expect(normalizeGroupNameWithDefaultEmoji("🎬 Media", "M")).toEqual({ full: "🎬 Media", emoji: "🎬" }); + }); +}); diff --git a/packages/core/src/proxy-group-targets.test.ts b/packages/core/src/proxy-group-targets.test.ts new file mode 100644 index 0000000..27b51b3 --- /dev/null +++ b/packages/core/src/proxy-group-targets.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + getProxyGroupMemberKey, + getProxyGroupTargetKey, + isProxyGroupTargetRef, + normalizeProxyGroupTargetRef, + resolveProxyGroupTargetName, + ruleTargetMatchesName, +} from "./proxy-group-targets"; + +describe("proxy group target helpers", () => { + it("validates and normalizes target refs", () => { + expect(isProxyGroupTargetRef({ kind: "module", id: "auto" })).toBe(true); + expect(isProxyGroupTargetRef({ kind: "custom", id: "media" })).toBe(true); + expect(isProxyGroupTargetRef({ kind: "node", id: "auto" })).toBe(false); + expect(isProxyGroupTargetRef({ kind: "module", id: " " })).toBe(false); + expect(isProxyGroupTargetRef(null)).toBe(false); + expect(isProxyGroupTargetRef([])).toBe(false); + expect(normalizeProxyGroupTargetRef({ kind: "custom", id: " media " })).toEqual({ kind: "custom", id: "media" }); + expect(normalizeProxyGroupTargetRef({ kind: "custom", id: "" })).toBeNull(); + expect(getProxyGroupTargetKey({ kind: "module", id: "auto" })).toBe("module:auto"); + }); + + it("builds stable member keys for every supported member kind", () => { + expect(getProxyGroupMemberKey({ kind: "node", name: "Node A" })).toBe("node:Node A"); + expect(getProxyGroupMemberKey({ kind: "module", id: "auto" })).toBe("module:auto"); + expect(getProxyGroupMemberKey({ kind: "custom", id: "media" })).toBe("custom:media"); + expect(getProxyGroupMemberKey({ kind: "direct" })).toBe("direct:DIRECT"); + expect(getProxyGroupMemberKey({ kind: "reject" })).toBe("reject:REJECT"); + }); + + it("resolves rule targets through module/custom names and fallbacks", () => { + const options = { + moduleNames: { auto: " Auto " }, + customProxyGroups: [{ id: "media", name: " Media ", emoji: "", groupType: "select" as const }], + fallbackTarget: "DIRECT", + }; + + expect(resolveProxyGroupTargetName(" Proxy ", options)).toBe("Proxy"); + expect(resolveProxyGroupTargetName(" ", options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "auto" }, options)).toBe("Auto"); + expect(resolveProxyGroupTargetName({ kind: "custom", id: "media" }, options)).toBe("Media"); + expect(resolveProxyGroupTargetName({ kind: "custom", id: "missing" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "missing" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "node" } as never, options)).toBe("DIRECT"); + expect(ruleTargetMatchesName(" Proxy ", "Proxy")).toBe(true); + expect(ruleTargetMatchesName({ kind: "module", id: "auto" }, "Auto")).toBe(false); + }); +}); diff --git a/packages/core/src/rules/custom-rule-helpers.test.ts b/packages/core/src/rules/custom-rule-helpers.test.ts index 536386c..76a626a 100644 --- a/packages/core/src/rules/custom-rule-helpers.test.ts +++ b/packages/core/src/rules/custom-rule-helpers.test.ts @@ -25,6 +25,7 @@ describe("custom routing rule set helpers", () => { expect(parseRuleSetTargetValue(" module: select ")).toEqual({ kind: "module", id: "select" }); expect(parseRuleSetTargetValue("custom:custom-a")).toEqual({ kind: "custom", id: "custom-a" }); expect(parseRuleSetTargetValue("module: ")).toBeNull(); + expect(parseRuleSetTargetValue("custom: ")).toBeNull(); expect(parseRuleSetTargetValue("other:select")).toBeNull(); expect(extractRuleSetPathFromUrl("https://cdn.example/rules/geosite/openai.mrs?token=1")).toBe( @@ -72,6 +73,10 @@ describe("custom routing rule set helpers", () => { }, { id: "", name: "skip", behavior: "domain", path: "geosite/skip.mrs", target: "Custom A" }, { id: "missing-path", name: "missing path", behavior: "domain", path: "", target: "Custom A" }, + { id: "missing-module", name: "missing module", behavior: "domain", path: "geosite/missing.mrs", target: { kind: "module", id: "missing" } }, + { id: "missing-custom", name: "missing custom", behavior: "domain", path: "geosite/missing.mrs", target: { kind: "custom", id: "missing" } }, + { id: "blank-custom", name: "blank custom", behavior: "domain", path: "geosite/blank.mrs", target: { kind: "custom", id: "" } }, + { id: "blank-target", name: "blank target", behavior: "domain", path: "geosite/blank-target.mrs", target: " " }, ], proxyGroupNameOverrides: { select: "Custom Select" }, }); @@ -221,4 +226,69 @@ describe("custom rule batch import", () => { expect.objectContaining({ type: "DOMAIN", value: "a\"b.com", target: "PROXY", noResolve: false }), ]); }); + + it("handles YAML list edge cases and all-ready imports", () => { + const skipped = parseCustomRuleBatchImport({ + text: ["rules:", "-", "- # nested comment", "- // nested comment"].join("\n"), + defaultType: "DOMAIN", + defaultTarget: "PROXY", + defaultNoResolve: false, + targetOptions: ["PROXY"], + existingRules: [], + }); + + expect(skipped.items.map((item) => item.message)).toEqual(["rules 块标记", "空 YAML 列表项", "注释", "注释"]); + expect(skipped.canImport).toBe(false); + + const ready = parseCustomRuleBatchImport({ + text: ["- DOMAIN,example.com", "- DOMAIN-SUFFIX,example.org,PROXY"].join("\n"), + defaultType: "DOMAIN", + defaultTarget: "PROXY", + defaultNoResolve: true, + targetOptions: ["PROXY"], + existingRules: [], + }); + + expect(ready.items.map((item) => item.status)).toEqual(["ready", "ready"]); + expect(ready.rules).toEqual([ + expect.objectContaining({ type: "DOMAIN", value: "example.com", target: "PROXY", noResolve: true }), + expect.objectContaining({ type: "DOMAIN-SUFFIX", value: "example.org", target: "PROXY", noResolve: false }), + ]); + expect(ready.canImport).toBe(true); + }); + + it("defaults two-column rules and matches duplicate object targets", () => { + const result = parseCustomRuleBatchImport({ + text: [ + "DOMAIN,two-column.example", + "DOMAIN,object.example,custom:custom-a", + ].join("\n"), + defaultType: "DOMAIN-SUFFIX", + defaultTarget: "PROXY", + defaultNoResolve: true, + targetOptions: ["PROXY", "custom:custom-a"], + existingRules: [ + { + id: "existing-object", + type: "DOMAIN", + value: "object.example", + target: { kind: "custom", id: "custom-a" }, + noResolve: false, + } as CustomRule, + ], + }); + + expect(result.items.map((item) => item.status)).toEqual([ + "ready", + "duplicate", + ]); + expect(result.rules).toEqual([ + expect.objectContaining({ + type: "DOMAIN", + value: "two-column.example", + target: "PROXY", + noResolve: true, + }), + ]); + }); }); diff --git a/packages/core/src/rules/rule-model.test.ts b/packages/core/src/rules/rule-model.test.ts new file mode 100644 index 0000000..ebc582e --- /dev/null +++ b/packages/core/src/rules/rule-model.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + buildRuleSetUrlFromPath, + extractRuleSetPathFromUrl, + isValidRuleSetPathOrUrl, + normalizeBuiltinRuleEdits, + normalizeRuleModelFromConfig, + normalizeRuleSetPathInput, +} from "./rule-model"; + +describe("rule model normalization", () => { + it("normalizes rule set paths, URLs, and builtin edits", () => { + expect(extractRuleSetPathFromUrl(" https://example.com/meta/geosite/google.mrs?download=1 ")).toBe("geosite/google.mrs"); + expect(extractRuleSetPathFromUrl("custom/list.txt")).toBe("custom/list.txt"); + expect(normalizeRuleSetPathInput("/geoip/private.mrs")).toBe("geoip/private.mrs"); + expect(isValidRuleSetPathOrUrl("geosite/google.mrs")).toBe(true); + expect(isValidRuleSetPathOrUrl("https://example.com/rules/list.txt")).toBe(true); + expect(isValidRuleSetPathOrUrl("plain.txt")).toBe(false); + expect(buildRuleSetUrlFromPath("https://cdn.example.com/custom.mrs", "https://base.example.com/")).toBe("https://cdn.example.com/custom.mrs"); + expect(buildRuleSetUrlFromPath("/geosite/google.mrs", "https://base.example.com/")).toBe("https://base.example.com/geosite/google.mrs"); + expect(normalizeBuiltinRuleEdits(null)).toEqual({}); + expect( + normalizeBuiltinRuleEdits({ + " ": { enabled: false }, + "module:cn:cn-ip": { enabled: false }, + "module:auto:auto": { target: { kind: "module", id: " select " } }, + invalid: "bad", + empty: {}, + }) + ).toEqual({ + "module:cn:cn-ip": { enabled: false }, + "module:auto:auto": { target: { kind: "module", id: "select" } }, + }); + }); + + it("keeps only valid custom groups and rule sets", () => { + const result = normalizeRuleModelFromConfig({ + customProxyGroups: [ + "bad", + { id: "", name: "Missing", emoji: "", groupType: "select" }, + { + id: "select", + name: " Select ", + emoji: "S", + enabled: false, + description: " Description ", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "select", + advanced: { includeRegex: "Node" }, + }, + { + id: "balance", + name: "Balance", + emoji: "", + groupType: "load-balance", + strategy: "bad", + }, + { + id: "round", + name: "Round", + emoji: "", + groupType: "load-balance", + strategy: "round-robin", + }, + { id: "direct", name: "Direct", emoji: "", groupType: "direct-first" }, + { id: "reject", name: "Reject", emoji: "", groupType: "reject-first" }, + { id: "fallback", name: "Fallback", emoji: "", groupType: "fallback" }, + { id: "url", name: "URL", emoji: "", groupType: "url-test" }, + ], + customRuleSets: [ + "bad", + { id: "", name: "Missing", behavior: "domain", path: "geosite/missing.mrs", target: "DIRECT" }, + { id: "invalid", name: "Invalid", behavior: "bad", path: "geosite/invalid.mrs", target: "DIRECT" }, + { id: "path", name: "Path", behavior: "domain", path: "plain.txt", target: "DIRECT" }, + { id: "target", name: "Target", behavior: "domain", path: "geosite/target.mrs", target: "" }, + { id: "dup", name: "", behavior: "domain", path: "geosite/dup.mrs", target: { kind: "custom", id: " select " }, noResolve: false }, + { id: "dup", name: "Duplicate", behavior: "domain", path: "geosite/dup-2.mrs", target: "DIRECT" }, + { id: "ip", name: "IP", behavior: "ipcidr", path: "geoip/private.mrs", target: "DIRECT", noResolve: true }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { enabled: false }, + }, + }); + + expect(result.customProxyGroups.map((group) => group.id)).toEqual([ + "select", + "balance", + "round", + "direct", + "reject", + "fallback", + "url", + ]); + expect(result.customProxyGroups.find((group) => group.id === "balance")).toMatchObject({ + strategy: "consistent-hashing", + }); + expect(result.customRuleSets).toEqual([ + { + behavior: "domain", + id: "dup", + name: "dup", + noResolve: false, + path: "geosite/dup.mrs", + target: { kind: "custom", id: "select" }, + }, + { + behavior: "ipcidr", + id: "ip", + name: "IP", + noResolve: true, + path: "geoip/private.mrs", + target: "DIRECT", + }, + ]); + expect(result.builtinRuleEdits).toEqual({ "module:cn:cn-ip": { enabled: false } }); + expect(normalizeRuleModelFromConfig(null)).toEqual({ + builtinRuleEdits: {}, + customProxyGroups: [], + customRuleSets: [], + }); + }); +}); diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 469f697..27bbf7a 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildGenerateOptionsFromConfig, getEffectiveTestOptions, @@ -236,4 +236,547 @@ describe("subscription config utils", () => { "url-test", ]); }); + + it("preserves empty YAML overrides and normalizes advanced proxy-group config", () => { + const options = buildGenerateOptionsFromConfig( + { + dnsYaml: "", + customRules: [ + { + type: "IP-CIDR6", + value: "2001:db8::/32", + target: { kind: "custom", id: " media " }, + noResolve: true, + }, + ], + customProxyGroups: [ + { + id: "media", + name: "Media", + emoji: "", + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: [" source-a ", "source-a"], + regions: ["jp", "bad"], + extraMembers: [{ kind: "direct" }], + }, + }, + ], + proxyGroupAdvanced: { + " auto ": { + groupType: "fallback", + excludedMembers: [{ kind: "node", name: " Node " }], + }, + " ": { groupType: "select" }, + invalid: { regions: ["bad"] }, + }, + listenerPorts: { + http: 1, + zero: 0, + high: 65536, + float: 1200.5, + }, + }, + { nodes: [node()] } + ); + + expect(options.userConfig?.dnsYaml).toBe(""); + expect(options.userConfig?.customRules?.[0]).toMatchObject({ + type: "IP-CIDR6", + target: { kind: "custom", id: "media" }, + noResolve: true, + }); + expect(options.userConfig?.listenerPorts).toEqual({ http: 1 }); + expect(options.customProxyGroups?.[0]).toMatchObject({ + id: "media", + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["jp"], + extraMembers: [{ kind: "direct" }], + }, + }); + expect(options.proxyGroupAdvanced).toEqual({ + auto: { + groupType: "fallback", + excludedMembers: [{ kind: "node", name: "Node" }], + }, + }); + }); + + it("keeps only valid optional persisted collections", () => { + const options = buildGenerateOptionsFromConfig( + { + enabledGroups: [null, " ", "auto"], + enabledRules: "bad", + customRules: "bad", + customProxyGroups: [ + null, + { id: " ", name: "Bad", groupType: "select" }, + { id: "bad", name: "Bad", groupType: "bad" }, + { + id: "select", + name: " Select ", + emoji: null, + groupType: "select", + enabled: true, + description: "", + memberSource: "filtered-nodes", + includeInGroupMembers: "yes", + }, + { + id: "balance", + name: "Balance", + emoji: "B", + groupType: "load-balance", + strategy: "consistent-hashing", + advanced: "bad", + }, + ], + dialerProxyGroups: [ + null, + { id: "bad", name: "Bad", type: "bad" }, + { + id: "select-dialer", + name: "Select Dialer", + type: "select", + enabled: false, + relayNodes: "bad", + targetNodes: [" target-a ", 1, ""], + }, + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + strategy: "bad", + }, + ], + listenerPorts: { + " ": 12000, + stringPort: "12001", + valid: 12002, + }, + proxyGroupNameOverrides: { + " ": "Name", + valid: " Valid Name ", + }, + ruleOrder: "bad", + }, + { nodes: [node()] } + ); + + expect(options.userConfig?.enabledGroups).toEqual(["auto"]); + expect(options.userConfig).not.toHaveProperty("enabledRules"); + expect(options.userConfig).not.toHaveProperty("customRules"); + expect(options.userConfig?.listenerPorts).toEqual({ valid: 12002 }); + expect(options.customProxyGroups).toEqual([ + { + advanced: {}, + emoji: "", + groupType: "select", + id: "select", + memberSource: "filtered-nodes", + name: "Select", + }, + { + advanced: {}, + emoji: "B", + groupType: "load-balance", + id: "balance", + name: "Balance", + strategy: "consistent-hashing", + }, + ]); + expect(options.dialerProxyGroups).toEqual([ + { + enabled: false, + id: "select-dialer", + name: "Select Dialer", + relayNodes: [], + targetNodes: ["target-a"], + type: "select", + }, + { + id: "balance-dialer", + name: "Balance Dialer", + relayNodes: [], + strategy: "consistent-hashing", + targetNodes: [], + type: "load-balance", + }, + ]); + expect(options.proxyGroupNameOverrides).toEqual({ valid: "Valid Name" }); + }); + + it("omits empty optional maps and preserves a valid persisted rule order", () => { + const options = buildGenerateOptionsFromConfig( + { + customRules: [ + { + id: "custom-rule", + type: "DOMAIN-SUFFIX", + value: "example.com", + target: "DIRECT", + }, + ], + ruleOrder: ["custom-rule"], + listenerPorts: { + emptyName: "bad", + invalid: 65536, + }, + proxyGroupNameOverrides: { + empty: " ", + }, + customProxyGroups: [ + { id: "missing-name", name: " ", emoji: "", groupType: "select" }, + { id: "missing-type", name: "Missing Type", emoji: "" }, + ], + dialerProxyGroups: [ + { id: "", name: "Bad", type: "select" }, + { id: "bad-name", name: " ", type: "select" }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.userConfig?.ruleOrder).toEqual(["custom-rule:custom-rule"]); + expect(options.userConfig).not.toHaveProperty("listenerPorts"); + expect(options.proxyGroupNameOverrides).toBeUndefined(); + expect(options.customProxyGroups).toBeUndefined(); + expect(options.dialerProxyGroups).toBeUndefined(); + }); + + it("uses legacy custom group fallback when rule model normalization returns no groups", async () => { + vi.resetModules(); + vi.doMock("@subboost/core/rules/rule-model", () => ({ + normalizeRuleModelFromConfig: () => ({ + customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, + }), + })); + + try { + const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); + const options = buildWithMockedRuleModel( + { + template: "minimal", + customProxyGroups: [ + "bad", + { id: "", name: "Bad", emoji: "B", groupType: "select" }, + { id: "select", name: " Select ", emoji: null, groupType: "select", enabled: true }, + { id: "auto", name: "Auto", emoji: "A", groupType: "url-test" }, + { id: "fallback", name: "Fallback", emoji: "F", groupType: "fallback" }, + { id: "direct", name: "Direct", emoji: "D", groupType: "direct-first" }, + { id: "reject", name: "Reject", emoji: "R", groupType: "reject-first" }, + { id: "balance", name: "Balance", emoji: "B", groupType: "load-balance", strategy: "bad" }, + ], + dialerProxyGroups: [ + { + id: "direct-dialer", + name: "Direct Dialer", + type: "direct-first", + enabled: true, + relayNodes: [" Relay ", ""], + targetNodes: "bad", + }, + { + id: "reject-dialer", + name: "Reject Dialer", + type: "reject-first", + strategy: "round-robin", + }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.customProxyGroups?.map((group) => group.groupType)).toEqual([ + "select", + "url-test", + "fallback", + "direct-first", + "reject-first", + "load-balance", + ]); + expect(options.customProxyGroups?.find((group) => group.id === "balance")).toMatchObject({ + strategy: "consistent-hashing", + }); + expect(options.dialerProxyGroups).toEqual([ + { + enabled: true, + id: "direct-dialer", + name: "Direct Dialer", + relayNodes: ["Relay"], + targetNodes: [], + type: "direct-first", + }, + { + id: "reject-dialer", + name: "Reject Dialer", + relayNodes: [], + targetNodes: [], + type: "reject-first", + }, + ]); + + const sparse = buildWithMockedRuleModel( + { + customProxyGroups: "bad", + dialerProxyGroups: "bad", + proxyGroupAdvanced: { + " missing ": "bad", + " select ": { groupType: "select" }, + " empty ": { regions: ["bad"] }, + }, + }, + { nodes: [node()] }, + ); + + expect(sparse.customProxyGroups).toBeUndefined(); + expect(sparse.dialerProxyGroups).toBeUndefined(); + expect(sparse.proxyGroupAdvanced).toEqual({ select: { groupType: "select" } }); + } finally { + vi.doUnmock("@subboost/core/rules/rule-model"); + vi.resetModules(); + } + }); + + it("normalizes legacy custom groups with dense optional field variants", async () => { + vi.resetModules(); + vi.doMock("@subboost/core/rules/rule-model", () => ({ + normalizeRuleModelFromConfig: () => ({ + customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, + }), + })); + + try { + const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); + const options = buildWithMockedRuleModel( + { + customProxyGroups: [ + { id: "missing-name", name: "", emoji: "M", groupType: "select" }, + { id: "missing-type", name: "Missing Type", emoji: "M", groupType: "invalid" }, + { + id: "select", + name: " Select ", + emoji: " S ", + enabled: false, + description: " Primary group ", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "select", + strategy: "round-robin", + advanced: { + includeRegex: "JP", + regions: ["jp"], + }, + }, + { + id: "balance", + name: "Balance", + emoji: undefined, + enabled: true, + description: " ", + memberSource: "all", + includeInGroupMembers: "yes", + groupType: "load-balance", + strategy: "round-robin", + advanced: { regions: ["bad"] }, + }, + ], + dialerProxyGroups: [ + { id: "missing-name", name: "", type: "select" }, + { id: "missing-type", name: "Missing Type", type: "invalid" }, + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + enabled: "yes", + relayNodes: [" Relay "], + targetNodes: [" Target "], + }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.customProxyGroups).toEqual([ + { + id: "select", + name: "Select", + emoji: "S", + enabled: false, + description: "Primary group", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "select", + advanced: { + includeRegex: "JP", + regions: ["jp"], + }, + }, + { + id: "balance", + name: "Balance", + emoji: "", + groupType: "load-balance", + strategy: "round-robin", + }, + ]); + expect(options.dialerProxyGroups).toEqual([ + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + strategy: "consistent-hashing", + relayNodes: ["Relay"], + targetNodes: ["Target"], + }, + ]); + } finally { + vi.doUnmock("@subboost/core/rules/rule-model"); + vi.resetModules(); + } + }); + + it("normalizes mixed persisted options without emitting empty optional sections", () => { + const options = buildGenerateOptionsFromConfig( + { + template: "full", + testUrl: " http://probe.example.com/204 ", + testInterval: 0, + enabledGroups: [1, " ", "auto", "auto", "cn"], + enabledRules: [null, "global", " "], + customRules: [ + { id: "", type: "DST-PORT", value: " 443 ", target: { kind: "module", id: " auto " } }, + { id: "bad-target", type: "DOMAIN", value: "bad.example", target: { kind: "node", name: "" } }, + { id: "src-port", type: "SRC-PORT", value: " 6881 ", target: { kind: "custom", id: " media " } }, + ], + customProxyGroups: [ + { + id: "media", + name: " Media ", + emoji: undefined, + enabled: false, + description: " Media group ", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["tw"], + includeRegex: "TW", + excludeRegex: "test", + extraMembers: [{ kind: "reject" }], + excludedMembers: [{ kind: "direct" }], + memberOrder: [{ kind: "reject" }], + }, + }, + ], + customRuleSets: [ + { + id: "manual", + name: " Manual ", + behavior: "classical", + path: "https://rules.example.com/manual.yaml", + target: { kind: "module", id: " cn " }, + noResolve: true, + }, + ], + dialerProxyGroups: [ + { + id: "direct", + name: " Direct Dialer ", + type: "direct-first", + strategy: "round-robin", + relayNodes: ["Relay", "Relay", ""], + targetNodes: ["Target", null, "Target"], + }, + ], + listenerPorts: { + socks: 65535, + negative: -1, + nan: Number.NaN, + }, + proxyGroupNameOverrides: { + cn: " China ", + }, + proxyGroupOrder: [" cn ", "cn", "", null], + mixedPort: 65535, + allowLan: false, + autoSelectStrategy: "load-balance", + cnIpNoResolve: true, + experimentalCnUseCnRuleSet: false, + }, + { nodes: [node({ name: "TW Node" })] }, + ); + + expect(options.template).toBe("full"); + expect(options.userConfig).toMatchObject({ + enabledGroups: ["auto", "auto", "cn"], + enabledRules: ["global"], + mixedPort: 65535, + allowLan: false, + autoSelectStrategy: "load-balance", + cnIpNoResolve: true, + experimentalCnUseCnRuleSet: false, + listenerPorts: { socks: 65535 }, + testUrl: "http://probe.example.com/204", + testInterval: 0, + }); + expect(options.userConfig?.customRules).toEqual([ + { + id: "custom-rule-dst-port-443-module-auto-1", + type: "DST-PORT", + value: "443", + target: { kind: "module", id: "auto" }, + }, + { + id: "src-port", + type: "SRC-PORT", + value: "6881", + target: { kind: "custom", id: "media" }, + }, + ]); + expect(options.customProxyGroups).toEqual([ + { + id: "media", + name: "Media", + emoji: "", + enabled: false, + description: "Media group", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["tw"], + includeRegex: "TW", + excludeRegex: "test", + extraMembers: [{ kind: "reject" }], + excludedMembers: [{ kind: "direct" }], + memberOrder: [{ kind: "reject" }], + }, + }, + ]); + expect(options.customRuleSets).toBeUndefined(); + expect(options.dialerProxyGroups).toEqual([ + { + id: "direct", + name: "Direct Dialer", + type: "direct-first", + relayNodes: ["Relay", "Relay"], + targetNodes: ["Target", "Target"], + }, + ]); + expect(options.proxyGroupNameOverrides).toEqual({ cn: "China" }); + expect(options.proxyGroupOrder).toEqual(["cn", "cn"]); + }); }); diff --git a/packages/core/src/subscription/node-source-state.test.ts b/packages/core/src/subscription/node-source-state.test.ts new file mode 100644 index 0000000..108124e --- /dev/null +++ b/packages/core/src/subscription/node-source-state.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedNode } from "../types/node"; +import { + ORIGIN_NAME_KEY, + SOURCE_IDS_KEY, + getNodeOriginName, + getNodeSourceIds, + keepOnlyValidNodeSourceIds, + makeUniqueName, + normalizeNodeOriginName, + withNodeSourceId, + withUniqueNodeNames, + withoutNodeSourceIds, +} from "./node-source-state"; + +function node(name: string, patch: Record = {}): ParsedNode { + return { + name, + type: "ss", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + ...patch, + } as ParsedNode; +} + +describe("node source state helpers", () => { + it("makes unique node names and preserves unchanged nodes by identity", () => { + const used = new Set(["Node", "Node (2)", "未命名节点"]); + expect(makeUniqueName(" Node ", used)).toBe("Node (3)"); + expect(makeUniqueName(" ", used)).toBe("未命名节点 (2)"); + + const first = node("Fresh"); + const result = withUniqueNodeNames([first, node("Fresh"), node("")], new Set()); + expect(result[0]).toBe(first); + expect(result.map((item) => item.name)).toEqual(["Fresh", "Fresh (2)", "未命名节点"]); + }); + + it("normalizes origin names and source ids conservatively", () => { + const sourceNode = node("Renamed", { + [ORIGIN_NAME_KEY]: " Origin ", + [SOURCE_IDS_KEY]: [" a ", "", "a", 1, "b"], + }); + + expect(getNodeOriginName(sourceNode)).toBe(" Origin "); + expect(getNodeOriginName(node("Plain", { [ORIGIN_NAME_KEY]: " " }))).toBe("Plain"); + expect(normalizeNodeOriginName(sourceNode)).toBe(sourceNode); + expect(normalizeNodeOriginName(node("Plain"))).toMatchObject({ + [ORIGIN_NAME_KEY]: "Plain", + }); + expect(getNodeSourceIds(sourceNode)).toEqual(["a", "b"]); + expect(getNodeSourceIds(node("Plain", { [SOURCE_IDS_KEY]: "bad" }))).toEqual([]); + }); + + it("adds, removes, and filters source ids without losing unrelated nodes", () => { + const base = node("Node"); + expect(withNodeSourceId(base, " ")).toBe(base); + + const withA = withNodeSourceId(base, " source-a "); + const withDuplicate = withNodeSourceId(withA, "source-a"); + const withB = withNodeSourceId(withA, "source-b"); + + expect(getNodeSourceIds(withA)).toEqual(["source-a"]); + expect(withDuplicate).toEqual(withA); + expect(getNodeSourceIds(withB)).toEqual(["source-a", "source-b"]); + expect(withoutNodeSourceIds(base, new Set(["source-a"]))).toBe(base); + expect(withoutNodeSourceIds(withB, new Set(["source-c"]))).toBe(withB); + expect(withoutNodeSourceIds(withB, new Set(["source-b"]))).toMatchObject({ + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect(withoutNodeSourceIds(withA, new Set(["source-a"]))).toBeNull(); + expect(keepOnlyValidNodeSourceIds(base, new Set(["source-a"]))).toBe(base); + expect(keepOnlyValidNodeSourceIds(withB, new Set(["source-a", "source-b"]))).toBe(withB); + expect(keepOnlyValidNodeSourceIds(withB, new Set(["source-b"]))).toMatchObject({ + [SOURCE_IDS_KEY]: ["source-b"], + }); + expect(keepOnlyValidNodeSourceIds(withA, new Set(["source-b"]))).toBeNull(); + }); +}); diff --git a/packages/core/src/subscription/source-node-refresh.test.ts b/packages/core/src/subscription/source-node-refresh.test.ts index 1fdbaf8..e4ed0e1 100644 --- a/packages/core/src/subscription/source-node-refresh.test.ts +++ b/packages/core/src/subscription/source-node-refresh.test.ts @@ -56,6 +56,12 @@ describe("source node refresh helpers", () => { name: "Plain", [ORIGIN_NAME_KEY]: "Plain", }); + + const [blank] = prepareSourceParsedNodes([ssNode(" ")], {}); + expect(blank).toMatchObject({ + name: "", + [ORIGIN_NAME_KEY]: "", + }); }); it("detaches a source id while preserving nodes still owned by other sources", () => { @@ -370,6 +376,184 @@ describe("source node refresh helpers", () => { [SOURCE_IDS_KEY]: ["source-a"], }); }); + + it("honors display-name deleted markers and exact deleted-node descriptors", () => { + const deleted = ssNode("Deleted Origin", { + server: "deleted.example.com", + [ORIGIN_NAME_KEY]: "Deleted Origin", + }); + const parsed = prepareSourceParsedNodes( + [ + ssNode("Origin A", { server: "a.example.com" }), + deleted, + ssNode("Origin C", { server: "c.example.com" }), + ], + { currentTag: "New", currentNameTemplate: "{tag}-{name}" } + ); + + const result = mergeParsedSourceNodes([], parsed, ["New-Origin A"], { + sourceId: "source-a", + deletedNodes: [ + { node: undefined }, + { originName: "Deleted Origin", node: deleted }, + ], + }); + + expect(result.nodes.map((node) => node.name)).toEqual(["New-Origin C"]); + }); + + it("uses deleted-node fallback origin metadata and ignores blank fresh origins", () => { + const deletedByNodeOrigin = ssNode("Deleted By Node", { + server: "deleted-by-node.example.com", + [ORIGIN_NAME_KEY]: "Deleted By Node", + }); + const parsed = prepareSourceParsedNodes( + [ + deletedByNodeOrigin, + ssNode(" ", { server: "blank-origin.example.com" }), + ssNode("Kept", { server: "kept.example.com" }), + ], + {} + ); + + const result = mergeParsedSourceNodes([], parsed, [], { + sourceId: "source-a", + deletedNodes: [ + { originName: 1, node: deletedByNodeOrigin }, + { name: "ignored", node: ssNode(" ") }, + ], + }); + + expect(result.nodes.map((node) => node.name)).toEqual(["Kept"]); + }); + + it("avoids display-name fallback across different node types and records fixed-name collisions", () => { + const parsed = prepareSourceParsedNodes([ssNode("Shared Name", { server: "fresh-shared.example.com" })], {}); + const noSameType = mergeParsedSourceNodes( + [ + ssNode("Shared Name", { + type: "trojan", + password: "secret", + server: "manual-trojan.example.com", + [ORIGIN_NAME_KEY]: "Other Origin", + }), + ], + parsed, + [], + { sourceId: "source-a" } + ); + + expect(noSameType.nodes.map((node) => node.name)).toEqual(["Shared Name", "Shared Name (2)"]); + + const renamed = mergeParsedSourceNodes( + [ + ssNode("Keep", { [ORIGIN_NAME_KEY]: "Keep", [SOURCE_IDS_KEY]: ["source-a"] }), + ssNode("Keep", { [ORIGIN_NAME_KEY]: "Other", [SOURCE_IDS_KEY]: ["source-b"] }), + ], + prepareSourceParsedNodes([ssNode("Keep")], {}), + [], + { sourceId: "source-a", lastNameTemplate: "{name}", currentNameTemplate: "{name}" } + ); + + expect(renamed.nodes.map((node) => node.name)).toEqual(["Keep (2)", "Keep"]); + expect(Array.from(renamed.renameMap.entries())).toEqual([["Keep", "Keep (2)"]]); + }); + + it("consumes one matching fresh node and merges unowned base nodes by content", () => { + const state = [ + ssNode("Manual One", { + server: "same-origin.example.com", + [ORIGIN_NAME_KEY]: "Shared Origin", + }), + ssNode("Manual Two", { + server: "same-origin.example.com", + [ORIGIN_NAME_KEY]: "Shared Origin", + }), + ssNode("Other Source", { + server: "content-match.example.com", + [ORIGIN_NAME_KEY]: "Fresh Content", + [SOURCE_IDS_KEY]: ["source-b"], + }), + ]; + const parsed = prepareSourceParsedNodes( + [ + ssNode("Shared Origin", { server: "same-origin.example.com" }), + ssNode("Fresh Content", { server: "content-match.example.com" }), + ], + {} + ); + + const result = mergeParsedSourceNodes(state, parsed, [], { + sourceId: "source-a", + }); + + expect(result.nodes.map((node) => node.name)).toEqual(["Manual One", "Other Source"]); + expect(result.nodes[0]).toMatchObject({ + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect(result.nodes[1]).toMatchObject({ + [ORIGIN_NAME_KEY]: "Fresh Content", + [SOURCE_IDS_KEY]: ["source-b", "source-a"], + }); + }); + + it("matches display names while cleaning source ids from refreshed records", () => { + const parsed = [ + ssNode("Same Display", { server: "fresh-display.example.com" }), + { + name: undefined, + type: "ss", + server: "fresh-origin.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + [ORIGIN_NAME_KEY]: "Header Only", + } as unknown as ParsedNode, + ]; + const result = mergeParsedSourceNodes( + [ + ssNode("Same Display", { + server: "old-display.example.com", + [ORIGIN_NAME_KEY]: "Old Origin", + }), + ssNode("Keep Source Name", { + server: "old-origin.example.com", + [ORIGIN_NAME_KEY]: "Header Only", + [SOURCE_IDS_KEY]: ["source-b", "source-b", "", "source-a"], + }), + ], + parsed, + [], + { + sourceId: "source-a", + deletedNodes: [ + { + node: { + name: "", + type: "ss", + server: "blank.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + [ORIGIN_NAME_KEY]: "", + } as unknown as ParsedNode, + }, + ], + } + ); + + expect(result.nodes.map((node) => node.name)).toEqual(["Same Display", "Keep Source Name"]); + expect(result.nodes[0]).toMatchObject({ + server: "fresh-display.example.com", + [ORIGIN_NAME_KEY]: "Same Display", + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect(result.nodes[1]).toMatchObject({ + server: "fresh-origin.example.com", + [ORIGIN_NAME_KEY]: "Header Only", + [SOURCE_IDS_KEY]: ["source-b", "source-a"], + }); + }); }); describe("subscription response info helpers", () => { diff --git a/packages/core/src/templates/config-template.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index 0870d73..a81947b 100644 --- a/packages/core/src/templates/config-template.fields.test.ts +++ b/packages/core/src/templates/config-template.fields.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validateSubBoostTemplateConfig } from "@subboost/core/templates/config-template"; +import { validateSubBoostTemplateConfig } from "./config-template"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; describe("validateSubBoostTemplateConfig field validation", () => { @@ -81,6 +81,35 @@ describe("validateSubBoostTemplateConfig field validation", () => { }, "dialerProxyGroups.relayNodes 只能包含字符串" ); + expectInvalid( + { + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "load-balance", + strategy: "bad" as never, + relayNodes: [], + targetNodes: [], + }, + ], + }, + "dialerProxyGroups.strategy 无效" + ); + expectInvalid( + { + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "select", + relayNodes: [], + targetNodes: [1 as never], + }, + ], + }, + "dialerProxyGroups.targetNodes 只能包含字符串" + ); expectInvalid( { dialerProxyGroups: [ @@ -102,6 +131,34 @@ describe("validateSubBoostTemplateConfig field validation", () => { { customRuleSets: [1 as never] }, "customRuleSets 只能包含对象" ); + expectInvalid( + { + customRuleSets: [ + { + id: "", + name: "Bad", + behavior: "domain", + path: "geosite/private.mrs", + target: "DIRECT", + }, + ], + }, + "customRuleSets.id 不能为空" + ); + expectInvalid( + { + customRuleSets: [ + { + id: "bad", + name: "", + behavior: "domain", + path: "geosite/private.mrs", + target: "DIRECT", + }, + ], + }, + "customRuleSets.name 不能为空" + ); expectInvalid( { customRuleSets: [ @@ -130,6 +187,34 @@ describe("validateSubBoostTemplateConfig field validation", () => { }, "customRuleSets.path 无效" ); + expectInvalid( + { + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "domain", + path: "", + target: "DIRECT", + }, + ], + }, + "customRuleSets.path 不能为空" + ); + expectInvalid( + { + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "domain", + path: "geosite/private.mrs", + target: "", + }, + ], + }, + "customRuleSets.target 不能为空" + ); expectInvalid( { customRuleSets: [ @@ -201,6 +286,8 @@ describe("validateSubBoostTemplateConfig field validation", () => { ok: false, error: "testUrl 必须是 http(s) URL", }); + expectInvalid({ testUrl: "" }, "testUrl 不能为空"); + expectInvalid({ testInterval: 1.5 }, "testInterval 必须是正整数"); expectInvalid({ ruleProviderBaseUrl: "ftp://example.com" }, "ruleProviderBaseUrl 必须是 http(s) URL"); expectInvalid({ proxyGroupNameOverrides: "bad" as never }, "proxyGroupNameOverrides 必须是对象"); expect( @@ -212,5 +299,23 @@ describe("validateSubBoostTemplateConfig field validation", () => { }) ) ).toEqual({ ok: false, error: "proxyGroupNameOverrides 的值必须是字符串" }); + expectInvalid({ ruleOrder: "bad" as never }, "ruleOrder 必须是数组"); + expectInvalid({ ruleOrder: ["module:auto", 1 as never] }, "ruleOrder 只能包含字符串"); + const defaultDialerStrategy = validateSubBoostTemplateConfig( + validConfig({ + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "load-balance", + relayNodes: ["Relay"], + targetNodes: ["Target"], + }, + ], + }) + ); + expect(defaultDialerStrategy.ok && defaultDialerStrategy.config.dialerProxyGroups[0]).toMatchObject({ + strategy: "consistent-hashing", + }); }); }); diff --git a/packages/core/src/templates/config-template.groups.test.ts b/packages/core/src/templates/config-template.groups.test.ts index 4ec74be..b581430 100644 --- a/packages/core/src/templates/config-template.groups.test.ts +++ b/packages/core/src/templates/config-template.groups.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { validateSubBoostTemplateConfig } from "@subboost/core/templates/config-template"; +import { validateSubBoostTemplateConfig } from "./config-template"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; describe("validateSubBoostTemplateConfig custom groups", () => { @@ -106,6 +106,19 @@ describe("validateSubBoostTemplateConfig custom groups", () => { }, "customProxyGroups.name 不能为空" ); + expectInvalid( + { + customProxyGroups: [ + { + id: "custom", + name: "Custom", + emoji: 1 as never, + groupType: "select", + }, + ], + }, + "customProxyGroups.emoji 必须是字符串" + ); expectInvalid( { customProxyGroups: [ @@ -175,6 +188,70 @@ describe("validateSubBoostTemplateConfig custom groups", () => { "customProxyGroups.includeInGroupMembers 必须是布尔值" ); + expectInvalid( + { + customProxyGroups: [ + { + id: "legacy", + name: "Legacy", + emoji: "L", + groupType: "select", + rules: [], + } as never, + ], + }, + "模板配置包含已移除字段: customProxyGroups[0].rules" + ); + + const validCustomGroup = validateSubBoostTemplateConfig( + validConfig({ + customProxyGroups: [ + { + id: "balance", + name: "Balance", + emoji: "B", + description: " Media group ", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "load-balance", + advanced: { + sourceIds: ["source-a"], + }, + }, + ], + }) + ); + expect(validCustomGroup.ok && validCustomGroup.config.customProxyGroups[0]).toMatchObject({ + advanced: { sourceIds: ["source-a"] }, + description: "Media group", + strategy: "consistent-hashing", + }); + + const validSelectGroup = validateSubBoostTemplateConfig( + validConfig({ + customProxyGroups: [ + { + id: "manual", + name: "Manual", + emoji: "", + includeInGroupMembers: true, + groupType: "select", + }, + ], + }) + ); + expect(validSelectGroup.ok).toBe(true); + if (validSelectGroup.ok) { + expect(validSelectGroup.config.customProxyGroups[0]).toEqual({ + id: "manual", + name: "Manual", + emoji: "", + includeInGroupMembers: true, + groupType: "select", + advanced: {}, + }); + } + const removedField = `filtered${"ProxyGroups"}`; expectInvalid({ [removedField]: [] } as never, `模板配置包含已移除字段: ${removedField}`); }); diff --git a/packages/core/src/templates/config-template.test-helpers.ts b/packages/core/src/templates/config-template.test-helpers.ts index fe0d11d..8011e98 100644 --- a/packages/core/src/templates/config-template.test-helpers.ts +++ b/packages/core/src/templates/config-template.test-helpers.ts @@ -1,9 +1,9 @@ import { expect } from "vitest"; -import { getModulesForTemplate } from "@subboost/core/generator/proxy-groups"; +import { getModulesForTemplate } from "../generator/proxy-groups"; import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA, validateSubBoostTemplateConfig, -} from "@subboost/core/templates/config-template"; +} from "./config-template"; import type { SubBoostTemplateConfig } from "@subboost/core/types/template-config"; export function validConfig(patch: Partial & Record = {}): SubBoostTemplateConfig { diff --git a/packages/core/src/templates/config-template.test.ts b/packages/core/src/templates/config-template.test.ts index e111281..3325850 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getModulesForTemplate } from "@subboost/core/generator/proxy-groups"; +import { getModulesForTemplate } from "../generator/proxy-groups"; import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA, validateSubBoostTemplateConfig, -} from "@subboost/core/templates/config-template"; +} from "./config-template"; import { DEFAULT_LOAD_BALANCE_STRATEGY } from "@subboost/core/types/config"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; @@ -160,6 +160,36 @@ describe("validateSubBoostTemplateConfig", () => { expect(result.config).not.toHaveProperty("allRulesOrderEditingEnabled"); }); + it("uses defaults when optional compatibility fields are omitted", () => { + const [moduleId] = getModulesForTemplate("minimal"); + const result = validateSubBoostTemplateConfig( + validConfig({ + hiddenProxyGroups: undefined as never, + proxyGroupAdvanced: { + [moduleId]: { + sourceIds: ["source-a"], + }, + }, + customRuleSets: undefined as never, + builtinRuleEdits: undefined as never, + ruleOrder: undefined as never, + proxyGroupNameOverrides: undefined as never, + cnIpNoResolve: undefined as never, + experimentalCnUseCnRuleSet: undefined as never, + }) + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.config.hiddenProxyGroups).toEqual([]); + expect(result.config.proxyGroupAdvanced[moduleId]).toEqual({ sourceIds: ["source-a"] }); + expect(result.config.customRuleSets).toEqual([]); + expect(result.config.builtinRuleEdits).toEqual({}); + expect(result.config.proxyGroupNameOverrides).toEqual({}); + expect(result.config).not.toHaveProperty("cnIpNoResolve"); + expect(result.config).not.toHaveProperty("experimentalCnUseCnRuleSet"); + }); + it("rejects removed rule-model compatibility fields", () => { expectInvalid({ moduleRuleOverrides: {} } as never, "模板配置包含已移除字段: moduleRuleOverrides"); expectInvalid({ moduleRuleExclusions: {} } as never, "模板配置包含已移除字段: moduleRuleExclusions"); diff --git a/packages/core/src/time/beijing.test.ts b/packages/core/src/time/beijing.test.ts new file mode 100644 index 0000000..b816f08 --- /dev/null +++ b/packages/core/src/time/beijing.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + addDaysToDateKey, + buildDateRangeFilterInBeijing, + formatDateInBeijing, + formatDateTimeInBeijing, + formatInBeijing, + formatTimeInBeijing, + getBeijingTimeZone, + getCompactDateStampInBeijing, + getDateKeyInBeijing, + getDayRangeInBeijing, + getTimeZoneOffsetMs, + getTodayRangeInBeijing, + getUtcMsForZonedDayStart, + parseDateKey, +} from "./beijing"; + +describe("Beijing time helpers", () => { + const sample = new Date("2026-06-22T16:30:05Z"); + + it("formats valid inputs in the Beijing timezone and keeps fallbacks for invalid inputs", () => { + expect(getBeijingTimeZone()).toBe("Asia/Shanghai"); + expect(formatInBeijing(sample, { hour: "2-digit", minute: "2-digit", hour12: false })).toBe("00:30"); + expect(formatDateTimeInBeijing(null, {}, "fallback")).toBe("fallback"); + expect(formatDateInBeijing("bad-date", {}, "bad")).toBe("bad"); + expect(formatTimeInBeijing(sample)).toBe("00:30"); + }); + + it("normalizes Beijing date keys and date arithmetic", () => { + expect(getDateKeyInBeijing(sample)).toBe("2026-06-23"); + expect(getCompactDateStampInBeijing(sample)).toBe("20260623"); + expect(parseDateKey(" 2026-06-23 ")).toBe("2026-06-23"); + expect(parseDateKey("2026-6-23")).toBeNull(); + expect(parseDateKey(null)).toBeNull(); + expect(addDaysToDateKey("2026-06-23", 2)).toBe("2026-06-25"); + expect(addDaysToDateKey("not-a-date", 2)).toBe("not-a-date"); + }); + + it("builds UTC day ranges for Beijing calendar days", () => { + expect(getTimeZoneOffsetMs(new Date("2026-06-23T00:00:00Z"))).toBe(8 * 60 * 60 * 1000); + expect(new Date(getUtcMsForZonedDayStart("2026-06-23")).toISOString()).toBe("2026-06-22T16:00:00.000Z"); + expect(() => getUtcMsForZonedDayStart("20260623")).toThrow("Invalid date key: 20260623"); + + const range = getDayRangeInBeijing("2026-06-23"); + expect(range.start.toISOString()).toBe("2026-06-22T16:00:00.000Z"); + expect(range.endExclusive.toISOString()).toBe("2026-06-23T16:00:00.000Z"); + + expect(buildDateRangeFilterInBeijing({ startDate: "2026-06-23" }).gte?.toISOString()).toBe( + "2026-06-22T16:00:00.000Z", + ); + expect(buildDateRangeFilterInBeijing({ endDate: "2026-06-24" }).lt?.toISOString()).toBe( + "2026-06-24T16:00:00.000Z", + ); + + const today = getTodayRangeInBeijing(sample); + expect(today).toMatchObject({ dateKey: "2026-06-23" }); + expect(today.start.toISOString()).toBe("2026-06-22T16:00:00.000Z"); + }); +}); From 44521b9f605e52a6a51e9b8f74b39cb1c775e531 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:26:08 +0800 Subject: [PATCH 23/29] test(local): cover cleanup and server-core paths --- local/app/local-pages.test.ts | 30 +- local/scripts/clean-next.cjs | 62 ++-- local/zz-core-generation-contract.test.ts | 293 ++++++++++++++++++ local/zz-core-sanitizer-contract.test.ts | 183 +++++++++++ .../automatic-refresh-completion.test.ts | 69 +++++ .../src/subscription/doh-resolver.test.ts | 67 ++++ .../refresh-node-snapshot.test.ts | 106 +++++++ .../src/subscription/ssrf-ip.test.ts | 2 +- 8 files changed, 789 insertions(+), 23 deletions(-) create mode 100644 local/zz-core-generation-contract.test.ts create mode 100644 local/zz-core-sanitizer-contract.test.ts diff --git a/local/app/local-pages.test.ts b/local/app/local-pages.test.ts index 58cc7ac..2201aea 100644 --- a/local/app/local-pages.test.ts +++ b/local/app/local-pages.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + buttons: [] as any[], dashboardAdapter: null as any, homeAdapter: null as any, readJsonResponse: vi.fn(), @@ -22,7 +23,10 @@ vi.mock("lucide-react", () => ({ })); vi.mock("@subboost/ui/components/ui/button", () => ({ - Button: (props: any) => React.createElement("button", props, props.children), + Button: (props: any) => { + mocks.buttons.push(props); + return React.createElement("button", props, props.children); + }, })); vi.mock("@subboost/ui/components/ui/card", () => ({ @@ -62,7 +66,12 @@ vi.mock("@subboost/ui/templates/template-library-surface", () => ({ }, })); +vi.mock("@local/components/local-login", () => ({ + LocalLogin: () => React.createElement("main", null, "LocalLogin"), +})); + import DashboardPage from "./dashboard/page"; +import LoginPage from "./login/page"; import SettingsPage from "./dashboard/settings/page"; import manifest from "./manifest"; import HomePage from "./page"; @@ -75,6 +84,7 @@ describe("local app pages and adapters", () => { mocks.dashboardAdapter = null; mocks.homeAdapter = null; mocks.templateAdapter = null; + mocks.buttons = []; mocks.userState = { fetchUser: vi.fn(), logout: vi.fn(), @@ -95,6 +105,10 @@ describe("local app pages and adapters", () => { }); }); + it("renders the local login page wrapper", () => { + expect(renderToStaticMarkup(React.createElement(LoginPage))).toBe("
LocalLogin
"); + }); + it("connects the local home adapter to local API routes", async () => { const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); vi.stubGlobal("fetch", fetchMock); @@ -200,11 +214,16 @@ describe("local app pages and adapters", () => { expect(fetchMock).toHaveBeenCalledWith("/api/templates?id=tpl%201", { method: "DELETE" }); }); - it("renders local settings for anonymous and authenticated states", () => { + it("renders local settings for anonymous and authenticated states", async () => { let html = renderToStaticMarkup(React.createElement(SettingsPage)); expect(html).toContain("未登录"); expect(html).toContain("/api/health/live"); + expect(mocks.buttons.find((button: any) => button.variant === "destructive")).toMatchObject({ + disabled: true, + }); + vi.stubGlobal("window", { location: { href: "" } }); + mocks.buttons = []; mocks.userState = { fetchUser: vi.fn(), logout: vi.fn(), @@ -213,5 +232,12 @@ describe("local app pages and adapters", () => { html = renderToStaticMarkup(React.createElement(SettingsPage)); expect(html).toContain("admin"); expect(html).toContain("2 / 9999"); + const logoutButton = mocks.buttons.find((button: any) => button.variant === "destructive"); + expect(logoutButton).toMatchObject({ disabled: false }); + logoutButton.onClick(); + await Promise.resolve(); + await Promise.resolve(); + expect(mocks.userState.logout).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe("/login"); }); }); diff --git a/local/scripts/clean-next.cjs b/local/scripts/clean-next.cjs index 7ad7170..6ed55f3 100644 --- a/local/scripts/clean-next.cjs +++ b/local/scripts/clean-next.cjs @@ -1,11 +1,13 @@ const fs = require("node:fs"); -const removeOptions = { - recursive: true, - force: true, - maxRetries: process.platform === "win32" ? 30 : 3, - retryDelay: process.platform === "win32" ? 200 : 100, -}; +function getRemoveOptions(platform = process.platform) { + return { + recursive: true, + force: true, + maxRetries: platform === "win32" ? 30 : 3, + retryDelay: platform === "win32" ? 200 : 100, + }; +} function isBusyError(error) { return error && ["EBUSY", "EPERM", "ENOTEMPTY"].includes(error.code); @@ -28,30 +30,50 @@ function backupName(date = new Date()) { ].join(""); } -function removeDistDir(distDir) { +function removeDistDir(distDir, deps = {}) { + const fsModule = deps.fs || fs; + const platform = deps.platform || process.platform; + const warn = deps.warn || console.warn; try { - fs.rmSync(distDir, removeOptions); + fsModule.rmSync(distDir, getRemoveOptions(platform)); return; } catch (error) { - if (process.platform !== "win32" || !isBusyError(error)) throw error; + if (platform !== "win32" || !isBusyError(error)) throw error; if (distDir !== ".next") { - console.warn(`[clean-next] skipped locked backup directory ${distDir}`); + warn(`[clean-next] skipped locked backup directory ${distDir}`); return; } - const fallback = backupName(); - fs.renameSync(distDir, fallback); - console.warn(`[clean-next] moved locked .next to ${fallback}; it will be removed on a later clean run.`); + const fallback = backupName(deps.now); + fsModule.renameSync(distDir, fallback); + warn(`[clean-next] moved locked .next to ${fallback}; it will be removed on a later clean run.`); } } -const distDirs = [ - ".next", - ...fs.readdirSync(process.cwd(), { withFileTypes: true }) +function listDistDirs(cwd = process.cwd(), fsModule = fs) { + return [ + ".next", + ...fsModule.readdirSync(cwd, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && /^\.next\.bak-\d{8}-\d{6}$/.test(entry.name)) .map((entry) => entry.name), -]; + ]; +} -for (const distDir of distDirs) { - if (!fs.existsSync(distDir)) continue; - removeDistDir(distDir); +function run(deps = {}) { + const fsModule = deps.fs || fs; + const cwd = deps.cwd || process.cwd(); + for (const distDir of listDistDirs(cwd, fsModule)) { + if (!fsModule.existsSync(distDir)) continue; + removeDistDir(distDir, deps); + } } + +run(); + +module.exports = { + backupName, + getRemoveOptions, + isBusyError, + listDistDirs, + removeDistDir, + run, +}; diff --git a/local/zz-core-generation-contract.test.ts b/local/zz-core-generation-contract.test.ts new file mode 100644 index 0000000..5d7a132 --- /dev/null +++ b/local/zz-core-generation-contract.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; +import { + BaseConfigYamlError, + generateClashConfig, + generateClashYaml, + generateProxyGroups, + generateRuleProviders, + generateRules, +} from "@subboost/core/generator"; +import { buildGenerateOptionsFromConfig } from "@subboost/core/subscription/config-utils"; +import { + normalizeProxyGroupAdvancedConfig, + normalizeProxyGroupMemberRef, + resolveProxyGroupMembers, +} from "@subboost/core/proxy-group-advanced"; +import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +function node(name: string, patch: Partial = {}): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + ...patch, + } as ParsedNode; +} + +describe("local shared core generation contract", () => { + it("builds a full local-consumer config from persisted options and mixed node inputs", () => { + const persisted = buildGenerateOptionsFromConfig( + { + template: "full", + enabledGroups: ["select", "auto", "ai", "private", "cn", "global", "final"], + enabledRules: ["cn", "global", "final"], + dnsYaml: [ + "mixed-port: 7897", + "allow-lan: true", + "global-client-fingerprint: chrome", + "proxy-providers:", + " base:", + " type: http", + "listeners:", + " - name: base-listener", + " type: mixed", + " port: 18080", + "nameserver-policy:", + " geosite:cn: 223.5.5.5", + ].join("\n"), + customRules: [ + { type: "IP-CIDR", value: "203.0.113.0/24", target: { kind: "custom", id: "media" }, noResolve: true }, + { type: "PROCESS-NAME", value: " curl ", target: "Missing", noResolve: true }, + ], + customProxyGroups: [ + { + id: "media", + name: "Media", + emoji: "", + includeInGroupMembers: true, + memberSource: "filtered-nodes", + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["us", "other"], + includeRegex: "Node|DIRECT", + excludeRegex: "Bad", + extraMembers: [{ kind: "direct" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [{ kind: "direct" }, { kind: "node", name: "US Node" }], + }, + }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + type: "fallback", + enabled: true, + relayNodes: ["DIRECT", "Missing Relay", "US Node"], + targetNodes: ["US Node", "Missing Target"], + }, + { + id: "empty", + name: "Empty", + type: "select", + enabled: true, + relayNodes: ["Missing Relay"], + targetNodes: ["US Node"], + }, + { + id: "disabled", + name: "Disabled Chain", + type: "select", + enabled: false, + relayNodes: ["DIRECT"], + targetNodes: ["US Node"], + }, + ], + listenerPorts: { + "US Node": 12001, + "US Node (2)": 12002, + bad: 70000, + }, + proxyGroupAdvanced: { + ai: { + groupType: "fallback", + extraMembers: [{ kind: "custom", id: "media" }], + excludedMembers: [{ kind: "node", name: "Bad Node" }], + }, + }, + proxyGroupNameOverrides: { + ai: "AI Local", + final: "Final Local", + }, + proxyGroupOrder: ["custom:media", "dialer:chain", "module:select"], + ruleProviderBaseUrl: "https://rules.example.com", + experimentalCnUseCnRuleSet: true, + cnIpNoResolve: true, + }, + { + nodes: [ + withNodeSourceId(node("US Node"), "source-a"), + withNodeSourceId(node("US Node"), "source-b"), + node("余额 | 10GB"), + node("Bad Node"), + node("Legacy", { type: "socks4" } as Partial), + node("VLESS", { + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + tls: true, + network: "xhttp", + "reality-opts": { + "public-key": "A".repeat(43), + }, + } as Partial), + ], + proxyProviders: { + remote: { type: "http", url: "https://nodes.example.com/sub" }, + }, + }, + ); + const config = generateClashConfig({ + ...persisted, + proxyGroupOrder: ["custom:media", "dialer:chain", "module:select"], + }); + const groups = config["proxy-groups"] as Array<{ name: string; type: string; proxies?: string[]; use?: string[] }>; + const rules = config.rules as string[]; + const proxies = config.proxies as Array>; + const providers = config["proxy-providers"] as Record; + const listeners = config.listeners as Array>; + + expect(proxies.map((proxy) => proxy.name)).toContain("US Node (2)"); + expect(proxies.map((proxy) => proxy.name)).not.toContain("Legacy"); + expect(proxies.find((proxy) => proxy.name === "VLESS")).toMatchObject({ "client-fingerprint": "chrome" }); + expect(proxies.find((proxy) => proxy.name === "US Node")).toMatchObject({ "dialer-proxy": "Chain" }); + expect(proxies.find((proxy) => proxy.name === "US Node (2)")).not.toHaveProperty("dialer-proxy"); + expect(groups[0]).toMatchObject({ name: "Media", type: "load-balance", proxies: ["DIRECT", "US Node"] }); + expect(groups[1]).toMatchObject({ name: "Chain", type: "fallback" }); + expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("余额 | 10GB"); + expect(groups.find((group) => group.name === "🤖 AI Local")).toMatchObject({ + type: "fallback", + proxies: expect.arrayContaining(["Media", "US Node"]), + }); + expect(providers).toHaveProperty("base"); + expect(providers).toHaveProperty("remote"); + expect(listeners.map((listener) => listener.name)).toEqual(["base-listener", "mixed0", "mixed1"]); + expect(rules).toContain("IP-CIDR,203.0.113.0/24,Media,no-resolve"); + expect(rules).toContain("PROCESS-NAME,curl,Media"); + expect(rules).toContain("RULE-SET,cn,🔒 国内服务"); + expect(rules.at(-1)).toBe("MATCH,🐟 Final Local"); + expect(generateClashYaml({ ...persisted })).toContain("proxy-groups:"); + }); + + it("preserves local proxy-group member and rule provider fallbacks", () => { + expect(normalizeProxyGroupMemberRef({ kind: "node", name: " Node A " })).toEqual({ kind: "node", name: "Node A" }); + expect(normalizeProxyGroupMemberRef({ kind: "module", id: " " })).toBeNull(); + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: ["source-a", "source-a", ""], + regions: ["KR", "bad"], + groupType: "load-balance", + strategy: "bad", + extraMembers: [{ kind: "direct" }, { kind: "direct" }], + }), + ).toEqual({ + sourceIds: ["source-a"], + regions: ["kr"], + groupType: "load-balance", + strategy: "consistent-hashing", + extraMembers: [{ kind: "direct" }], + }); + + const members = resolveProxyGroupMembers({ + defaultProxyNames: ["Korea Node", "DIRECT", "REJECT"], + nodes: [withNodeSourceId(node("Korea Node"), "source-a"), withNodeSourceId(node("US Node"), "source-b")], + moduleNames: { auto: "Auto" }, + customProxyGroups: [{ id: "custom", name: "Custom", emoji: "", groupType: "select" }], + advanced: { + sourceIds: ["source-a"], + regions: ["kr"], + extraMembers: [{ kind: "node", name: "US Node" }, { kind: "custom", id: "custom" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [{ kind: "custom", id: "custom" }, { kind: "node", name: "US Node" }], + }, + }); + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("余额 | 1GB")], + enabledModules: ["auto", "private", "final"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + { id: "direct", name: "Direct", emoji: "", groupType: "direct-first", includeInGroupMembers: true }, + { id: "reject", name: "Reject", emoji: "", groupType: "reject-first" }, + ], + }); + const providers = generateRuleProviders({ + nodes: [node("Node A")], + enabledModules: ["cn"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customRuleSets: [ + { id: "relative", name: "Relative", behavior: "domain", path: "geosite/relative.mrs", target: "DIRECT" }, + { id: "absolute", name: "Absolute", behavior: "domain", path: "https://rules.example.com/absolute.mrs", target: "DIRECT" }, + ], + }); + const rules = generateRules({ + enabledModules: ["cn", "global", "final"], + customRules: [ + { id: "cidr", type: "IP-CIDR", value: "203.0.113.0/24", target: "Missing", noResolve: true }, + { id: "process", type: "PROCESS-NAME", value: "curl", target: "DIRECT", noResolve: true }, + ], + customRuleSets: [ + { id: "set", name: "Set", behavior: "domain", path: "https://rules.example.com/set.mrs", target: "Missing", noResolve: true }, + ], + experimentalCnUseCnRuleSet: true, + availablePolicyTargets: ["DIRECT", "🐟 漏网之鱼"], + fallbackPolicyTarget: "DIRECT", + ruleOrder: ["custom-rule:process", "special:experimental-cn", "module:global:geolocation-!cn"], + }); + + expect(members.proxyNames).toEqual(["Custom", "US Node", "Korea Node", "DIRECT"]); + expect(members.excluded.map((member) => member.key)).toEqual(["reject:REJECT"]); + expect(groups.find((group) => group.name === "⚡ 自动选择")?.proxies).toEqual(["Node A"]); + expect(groups.find((group) => group.name === "Direct")?.proxies?.slice(0, 2)).toEqual(["DIRECT", "REJECT"]); + expect(groups.find((group) => group.name === "Reject")?.proxies?.slice(0, 2)).toEqual(["REJECT", "DIRECT"]); + expect(providers.relative?.url).toBe("https://rules.example.com/geosite/relative.mrs"); + expect(providers.absolute?.url).toBe("https://rules.example.com/absolute.mrs"); + expect(rules).toContain("IP-CIDR,203.0.113.0/24,DIRECT,no-resolve"); + expect(rules).toContain("PROCESS-NAME,curl,DIRECT"); + expect(rules).not.toContain("PROCESS-NAME,curl,DIRECT,no-resolve"); + expect(rules).toContain("RULE-SET,set,DIRECT,no-resolve"); + expect(rules).toContain("RULE-SET,cn,DIRECT"); + }); + + it("rejects unsafe base YAML sections before local YAML generation", () => { + expect(() => generateClashConfig({ nodes: [], userConfig: { dnsYaml: "[]" } })).toThrow(BaseConfigYamlError); + expect(() => generateClashConfig({ nodes: [], userConfig: { dnsYaml: "rules: []" } })).toThrow( + "基础和 DNS 配置不能包含 rules" + ); + expect(() => + generateClashConfig({ + nodes: [], + userConfig: { + dnsYaml: ["nameserver-policy:", " geosite:cn: 223.5.5.5", "dns: disabled"].join("\n"), + }, + }) + ).toThrow("dns 必须是对象"); + expect(() => + generateClashConfig({ + nodes: [node("Node A")], + userConfig: { + dnsYaml: "listeners: invalid", + listenerPorts: { "Node A": 12000 }, + }, + }) + ).toThrow("listeners 必须是数组"); + expect(() => + generateClashConfig({ + nodes: [], + proxyProviders: { remote: { type: "http" } }, + userConfig: { + dnsYaml: "proxy-providers: invalid", + }, + }) + ).toThrow("proxy-providers 必须是对象"); + }); +}); diff --git a/local/zz-core-sanitizer-contract.test.ts b/local/zz-core-sanitizer-contract.test.ts new file mode 100644 index 0000000..8563264 --- /dev/null +++ b/local/zz-core-sanitizer-contract.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + isMihomoSupportedProxyNode, + isStandardBase64String, + normalizeMihomoRealityPublicKey, + normalizeMihomoVlessForGeneration, + sanitizeMihomoProxyNode, +} from "../packages/core/src/mihomo/proxy-sanitizer"; + +const REALITY_PUBLIC_KEY = "A".repeat(43); +const WIREGUARD_KEY = `${"A".repeat(43)}=`; +const SSH_FINGERPRINT = `SHA256:${"A".repeat(43)}`; +const PRIVATE_KEY = [ + "-----BEGIN OPENSSH ", + "PRIVATE ", + "KEY-----\nabc\n-----END OPENSSH ", + "PRIVATE ", + "KEY-----", +].join(""); + +describe("local shared core sanitizer contract", () => { + it("normalizes supported optional Mihomo fields and rejects unsafe nodes", () => { + const vless = sanitizeMihomoProxyNode({ + name: "Reality", + type: "vless", + server: "reality.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + fingerprint: "Firefox", + udp: "yes", + alpn: "h2,http/1.1", + encryption: "mlkem768x25519plus.native.1rtt.valid_token", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + "short-id": "0x7250", + }, + "xhttp-opts": { + mode: "packet-up", + "ech-opts": { + enable: "yes", + config: Buffer.from("ech").toString("base64"), + }, + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + "ws-opts": { + path: "/ws?a=1&ed=1024", + }, + }); + const https = sanitizeMihomoProxyNode({ + name: "HTTPS", + type: "https", + server: "https.example.com", + port: 443, + tls: false, + fingerprint: "sha256 fingerprint = " + "B".repeat(64).match(/.{1,2}/g)?.join(":"), + "ws-opts": { + path: "/upgrade?ed=1024", + "v2ray-http-upgrade": "true", + "early-data-header-name": "drop", + }, + }); + const wireguard = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": "bad", + "pre-shared-key": WIREGUARD_KEY, + reserved: "1,2,3", + }); + const ssh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + "private-key": PRIVATE_KEY, + "private-key-passphrase": "secret", + "host-key": [ + "ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-ecdsa-!bad AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-rsa bad!token", + ], + "server-fingerprint": SSH_FINGERPRINT, + }); + const invalidXhttp = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + mode: "stream-one", + "download-settings": { + path: "/download", + }, + }, + }); + + expect(isStandardBase64String(Buffer.from("hello").toString("base64"))).toBe(true); + expect(isStandardBase64String("not base64")).toBe(false); + expect(normalizeMihomoRealityPublicKey(REALITY_PUBLIC_KEY)).toBe(REALITY_PUBLIC_KEY); + expect(normalizeMihomoRealityPublicKey("short")).toBeNull(); + expect(sanitizeMihomoProxyNode("raw" as never)).toBe("raw"); + expect(vless).toMatchObject({ + tls: true, + udp: true, + "client-fingerprint": "firefox", + alpn: ["h2", "http/1.1"], + encryption: "mlkem768x25519plus.native.1rtt.valid_token", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + "short-id": "7250", + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + expect(https).toMatchObject({ + type: "http", + tls: false, + fingerprint: "b".repeat(64), + "ws-opts": { + path: "/upgrade", + "v2ray-http-upgrade": true, + }, + }); + expect(https["ws-opts"]).not.toHaveProperty("early-data-header-name"); + expect(wireguard).toMatchObject({ + "private-key": WIREGUARD_KEY, + "pre-shared-key": WIREGUARD_KEY, + reserved: [1, 2, 3], + }); + expect(wireguard).not.toHaveProperty("public-key"); + expect(ssh).toMatchObject({ + "private-key": PRIVATE_KEY, + "host-key": ["ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA=="], + "server-fingerprint": SSH_FINGERPRINT, + }); + expect(invalidXhttp).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(isMihomoSupportedProxyNode({ type: "socks4", name: "old" })).toBe(false); + expect(isMihomoSupportedProxyNode({ type: "ss", cipher: "", password: "secret" })).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "v2ray-plugin", + "plugin-opts": { mode: "quic" }, + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + password: "secret", + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "wireguard", + name: "WG", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "pre-shared-key": WIREGUARD_KEY, + }) + ).toBe(true); + }); +}); diff --git a/packages/server-core/src/subscription/automatic-refresh-completion.test.ts b/packages/server-core/src/subscription/automatic-refresh-completion.test.ts index 356339d..7a0c9c8 100644 --- a/packages/server-core/src/subscription/automatic-refresh-completion.test.ts +++ b/packages/server-core/src/subscription/automatic-refresh-completion.test.ts @@ -111,6 +111,34 @@ describe("automatic refresh completion helpers", () => { }); }); + it("uses fallback quota limits and rejects unknown failure reasons", () => { + const decision = resolveAutomaticRefreshCompletionDecision({ + target: { ...target, username: null }, + currentAutoUpdateState, + prepared: makePrepared({ + ok: false, + reason: "node_quota_exceeded", + nodeCount: 101, + }), + attemptedAt, + maxNodesPerSubscription: 88, + }); + + expect(decision.outcome).toMatchObject({ + status: "failed", + resultsError: "Subscription sub-1: Node quota exceeded (88)", + }); + expect(() => + resolveAutomaticRefreshCompletionDecision({ + target, + currentAutoUpdateState, + prepared: makePrepared({ ok: false, reason: "unexpected" as never, nodeCount: 0 }), + attemptedAt, + maxNodesPerSubscription: 100, + }) + ).toThrow("Unexpected refresh failure reason: unexpected"); + }); + it("uses the cache timestamp for successful attempts", () => { const decision = resolveAutomaticRefreshCompletionDecision({ target, @@ -159,4 +187,45 @@ describe("automatic refresh completion helpers", () => { expect(completion.outcome.resultsError).toBe("Subscription sub-1 (ry): boom"); } }); + + it("summarizes minimal successful and unexpected failure outputs", () => { + const success = resolveAutomaticRefreshCompletionDecision({ + target: { ...target, username: null, autoUpdateInterval: null }, + currentAutoUpdateState, + prepared: makePrepared({ + ok: true, + cacheEntry: { nodes: [], subscriptionInfo: {}, generatedYaml: "yaml" }, + generatedYaml: "yaml", + nodeCount: undefined as never, + }), + attemptedAt, + maxNodesPerSubscription: 100, + }); + expect(success.outcome).toMatchObject({ + status: "updated", + updatedSubscription: { + username: null, + hosts: ["example.com"], + }, + }); + if (success.outcome.status === "updated") { + expect(success.outcome.updatedSubscription).not.toHaveProperty("nodeCount"); + } + + const unknown = resolveAutomaticRefreshUnexpectedFailureCompletion({ + target: { ...target, username: null }, + requestedHosts: [], + error: "bad", + attemptStartedAt: null, + }); + expect(unknown.message).toBe("Unknown error"); + expect(unknown).not.toHaveProperty("attemptedState"); + expect(unknown.outcome).toMatchObject({ + resultsError: "Subscription sub-1 (-): Unknown error", + failedSubscription: { + username: null, + error: "Unknown error", + }, + }); + }); }); diff --git a/packages/server-core/src/subscription/doh-resolver.test.ts b/packages/server-core/src/subscription/doh-resolver.test.ts index 6fac06e..54993e0 100644 --- a/packages/server-core/src/subscription/doh-resolver.test.ts +++ b/packages/server-core/src/subscription/doh-resolver.test.ts @@ -35,6 +35,21 @@ function ok(body: Uint8Array): DohTransportResponse { return { statusCode: 200, body }; } +function header(questionCount: number, answerCount: number): Uint8Array { + const out = new Uint8Array(12); + const view = new DataView(out.buffer); + view.setUint16(4, questionCount); + view.setUint16(6, answerCount); + return out; +} + +function malformedAnswerName(firstNameByte: number): Uint8Array { + const out = new Uint8Array(13); + out.set(header(0, 1)); + out[12] = firstNameByte; + return out; +} + describe("RFC8484 DoH resolver", () => { it("posts DNS wire messages and returns A/AAAA addresses from the first usable endpoint", async () => { const transport = vi @@ -87,4 +102,56 @@ describe("RFC8484 DoH resolver", () => { }) ).resolves.toEqual([]); }); + + it("fails closed for blank hostnames, malformed DNS messages, and thrown endpoints", async () => { + const longQuestion = [ + ...Array.from({ length: 129 }, () => [1, 97]).flat(), + 0, + 0, + 1, + 0, + 1, + ]; + const truncatedData = new Uint8Array([ + ...Array.from(header(0, 1)), + 0xc0, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x3c, + 0x00, + 0x04, + 1, + 2, + ]); + const malformedBodies = [ + new Uint8Array([1, 2, 3]), + header(1, 0), + header(0, 1), + truncatedData, + malformedAnswerName(0xc0), + malformedAnswerName(0x80), + new Uint8Array([...Array.from(header(1, 0)), ...longQuestion]), + ]; + const transport = vi.fn(async () => { + const body = malformedBodies.shift(); + if (body) return ok(body); + throw new Error("endpoint unavailable"); + }); + + await expect(resolveHostnameByDoh(" ", { transport })).resolves.toEqual([]); + await expect( + resolveHostnameByDoh("example.test", { + endpoints: ["https://bad-a.example/dns-query", "https://bad-b.example/dns-query", "https://bad-c.example/dns-query", "https://bad-d.example/dns-query"], + transport, + }) + ).resolves.toEqual([]); + + expect(transport).toHaveBeenCalledTimes(8); + }); }); diff --git a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts index bcc1ad5..dd71235 100644 --- a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts +++ b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts @@ -303,6 +303,112 @@ describe("refreshNodeSnapshot", () => { }); }); + it("ignores malformed deleted-node descriptors while preserving identical stable metadata", async () => { + const fetchUrlNodes = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + nodes: [{ ...node, name: "source a", server: "a.example.com" }], + headers: { + "profile-web-page-url": "https://same-profile.example.com/", + "plan-name": "Shared Plan", + }, + }) + .mockResolvedValueOnce({ + ok: true, + nodes: [{ ...node, name: "source b", server: "b.example.com" }], + headers: { + "profile-web-page-url": "https://same-profile.example.com/", + "plan-name": "Shared Plan", + }, + }); + + const result = await refreshNodeSnapshot({ + config: { + deletedNodes: [null, ["bad"], { sourceId: "url-a", name: "Missing" }], + sources: [ + { id: "url-a", type: "url", content: "https://a.example.com/sub" }, + { id: "url-b", type: "url", content: "https://b.example.com/sub" }, + ], + }, + urls: [], + storedNodes: [], + fetchUrlNodes, + }); + + expect(result.subscriptionInfo).toMatchObject({ + profileWebPageUrl: "https://same-profile.example.com/", + planName: "Shared Plan", + }); + expect(result.usedUrlFetch).toBe(true); + }); + + it("skips provider supplemental fetches when no userinfo metadata is configured", async () => { + const fetchUrlUserInfo = vi.fn(); + + const result = await refreshNodeSnapshot({ + config: { + sources: [ + { + id: "provider", + type: "url", + content: "https://provider.example.com/sub", + useProxyProviders: true, + }, + ], + }, + urls: [], + storedNodes: [ + { + ...node, + name: "provider node", + [SOURCE_IDS_KEY]: ["provider"], + } as ParsedNode, + ], + fetchUrlNodes: vi.fn(), + fetchUrlUserInfo, + }); + + expect(fetchUrlUserInfo).not.toHaveBeenCalled(); + expect(result.detachedSourceCount).toBe(1); + expect(result.refreshedSourceCount).toBe(1); + }); + + it("merges supplemental stable metadata even when supplemental userinfo is absent", async () => { + const fetchUrlUserInfo = vi.fn(async () => ({ + "profile-web-page-url": "https://supplemental.example.com/", + "plan-name": "Supplemental Plan", + })); + + const result = await refreshNodeSnapshot({ + config: { + sources: [ + { + id: "url", + type: "url", + content: "https://url.example.com/sub", + userinfoUrl: "https://url.example.com/userinfo", + }, + ], + }, + urls: [], + storedNodes: [], + fetchUrlNodes: vi.fn(async () => ({ + ok: true, + nodes: [{ ...node, name: "url node", server: "url.example.com" }], + headers: {}, + })), + fetchUrlUserInfo, + }); + + expect(fetchUrlUserInfo).toHaveBeenCalledTimes(1); + expect(result.subscriptionInfo).toMatchObject({ + profileWebPageUrl: "https://supplemental.example.com/", + planName: "Supplemental Plan", + }); + expect(result.savedSources[0]).not.toHaveProperty("subscriptionUserInfo"); + }); + it("merges URL userinfo from nodes while suppressing conflicted stable metadata", async () => { const fetchUrlNodes = vi .fn() diff --git a/packages/server-core/src/subscription/ssrf-ip.test.ts b/packages/server-core/src/subscription/ssrf-ip.test.ts index 80bd1be..fb9b2a5 100644 --- a/packages/server-core/src/subscription/ssrf-ip.test.ts +++ b/packages/server-core/src/subscription/ssrf-ip.test.ts @@ -63,7 +63,7 @@ describe("SSRF IP classification", () => { }); it("normalizes resolved addresses before validation", () => { - expect(normalizeResolvedIpAddresses([" 198.18.3.6 ", "", " ", "8.8.8.8"])).toEqual([ + expect(normalizeResolvedIpAddresses([" 198.18.3.6 ", "", " ", 123 as never, "8.8.8.8"])).toEqual([ "198.18.3.6", "8.8.8.8", ]); From 450bed31330a52859f88ca5670879c4e615e0e37 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:26:40 +0800 Subject: [PATCH 24/29] test(ui): cover advanced proxy group controls --- .../converter/advanced-mode/root.test.ts | 15 +- ...-group-advanced-panel-interactions.test.ts | 218 +++++++++++++++ .../proxy-group-advanced-panel.test.ts | 260 ++++++++++++++++++ .../sections/proxy-group-advanced-panel.tsx | 18 +- .../proxy-groups-added-rule-sets.test.ts | 156 +++++++++++ .../sections/proxy-groups-categories.test.ts | 107 ++++++- ...ups-custom-groups-panel-card-props.test.ts | 203 ++++++++++++++ .../proxy-groups-custom-groups-panel.test.ts | 28 ++ .../sections/proxy-groups-module-card.test.ts | 87 ++++++ .../proxy-groups-rules-library.test.ts | 116 ++++++++ .../sections/proxy-groups-section.test.ts | 83 ++++++ .../src/product/preview/visual-graph.test.ts | 70 ++++- 12 files changed, 1344 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-section.test.ts diff --git a/packages/ui/src/product/converter/advanced-mode/root.test.ts b/packages/ui/src/product/converter/advanced-mode/root.test.ts index 8daef50..67d4379 100644 --- a/packages/ui/src/product/converter/advanced-mode/root.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/root.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const captures = vi.hoisted(() => ({ + initialSections: undefined as Set | undefined, stateSetter: vi.fn(), lastSections: undefined as Set | undefined, sections: {} as Record, @@ -13,7 +14,7 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useState: (initial: unknown) => { - const value = typeof initial === "function" ? (initial as () => unknown)() : initial; + const value = captures.initialSections ?? (typeof initial === "function" ? (initial as () => unknown)() : initial); captures.stateSetter = vi.fn((updater: unknown) => { captures.lastSections = typeof updater === "function" @@ -50,6 +51,7 @@ import { AdvancedMode } from "./root"; describe("AdvancedMode", () => { beforeEach(() => { vi.clearAllMocks(); + captures.initialSections = undefined; captures.sections = {}; captures.lastSections = undefined; }); @@ -80,4 +82,15 @@ describe("AdvancedMode", () => { expect(captures.lastSections?.has("input")).toBe(false); expect(captures.lastSections?.has("dns")).toBe(true); }); + + it("expands a collapsed section through the same toggle callback", () => { + captures.initialSections = new Set(["dns"]); + renderToStaticMarkup(React.createElement(AdvancedMode)); + + expect(captures.sections.input.isExpanded).toBe(false); + captures.sections.input.onToggle(); + + expect(captures.lastSections?.has("input")).toBe(true); + expect(captures.lastSections?.has("dns")).toBe(true); + }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts new file mode 100644 index 0000000..14983fe --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts @@ -0,0 +1,218 @@ +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +const mocks = vi.hoisted(() => ({ + draggingKey: null as string | null, + stateSetters: [] as Array>, + store: {} as Record, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useState: (initial: unknown) => { + const value = initial === null ? mocks.draggingKey : initial; + const setter = vi.fn(); + mocks.stateSetters.push(setter); + return [value, setter]; + }, + }; +}); + +vi.mock("lucide-react", () => ({ + Plus: () => React.createElement("span", null, "plus-icon"), + X: () => React.createElement("span", null, "x-icon"), +})); + +vi.mock("@subboost/ui/components/ui/badge", () => ({ + Badge: (props: any) => React.createElement("span", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/button", () => ({ + Button: (props: any) => React.createElement("button", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: any) => React.createElement("input", props), +})); + +vi.mock("@subboost/ui/lib/utils", () => ({ + cn: (...parts: unknown[]) => parts.filter(Boolean).join(" "), +})); + +vi.mock("@subboost/core/generator/proxy-groups", () => ({ + PROXY_GROUP_MODULES: [ + { id: "select", name: "Select" }, + { id: "auto", name: "Auto" }, + ], + generateProxyGroups: () => [ + { + name: "Media", + proxies: ["DIRECT", "US Source", "Japan Source"], + }, + ], +})); + +vi.mock("@subboost/core/proxy-group-name", () => ({ + resolveProxyGroupModuleName: (module: { id: string; name: string }, override?: string) => override || module.name, +})); + +vi.mock("@subboost/ui/store/config-store", () => ({ + useConfigStore: () => mocks.store, +})); + +import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; + +function node(name: string): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + } as ParsedNode; +} + +function flattenElements(value: React.ReactNode): React.ReactElement[] { + const out: React.ReactElement[] = []; + const visit = (item: React.ReactNode): void => { + if (Array.isArray(item)) { + item.forEach(visit); + return; + } + if (!React.isValidElement(item)) return; + if (typeof item.type === "function") { + visit((item.type as (props: unknown) => React.ReactNode)(item.props)); + return; + } + out.push(item); + visit((item.props as { children?: React.ReactNode }).children); + }; + visit(value); + return out; +} + +describe("ProxyGroupAdvancedPanel interactions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.draggingKey = "node:US Source"; + mocks.stateSetters = []; + mocks.store = { + nodes: [ + withNodeSourceId(node("US Source"), "source-a"), + withNodeSourceId(node("Japan Source"), "source-b"), + node("Extra Node"), + ], + sources: [ + { id: "source-a", type: "url", tag: " Primary " }, + { id: "source-b", type: "yaml", lastParsedTag: " YAML Feed " }, + ], + enabledProxyGroups: ["select", "auto"], + customProxyGroups: [ + { id: "media", name: "Media", emoji: "", groupType: "select" }, + { id: "other", name: "Other", emoji: "", groupType: "select" }, + ], + customRuleSets: [], + proxyGroupAdvanced: {}, + builtinRuleEdits: {}, + proxyGroupNameOverrides: { auto: "Auto" }, + testUrl: "https://probe.example/204", + testInterval: 300, + ruleProviderBaseUrl: "https://rules.example", + }; + }); + + it("fires native source, region, member, and drag callbacks", () => { + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + sourceIds: ["source-a"], + regions: ["us"], + includeRegex: "Source", + excludeRegex: "Japan", + excludedMembers: [{ kind: "reject" }], + }, + onChange, + rulesCount: 1, + rulesContent: React.createElement("div", null, "rules"), + }); + const elements = flattenElements(tree); + const sourceCheckboxes = elements.filter((element) => element.type === "input" && element.props.type === "checkbox"); + const textInputs = elements.filter((element) => element.type === "input" && element.props.type !== "checkbox"); + const regionButtons = elements.filter( + (element) => element.type === "button" && String(element.props.className || "").includes("rounded border px-2"), + ); + const includedRows = elements.filter((element) => element.props.draggable); + const excludeButton = elements.find((element) => element.type === "button" && element.props.title === "排除"); + const enableButton = elements.find((element) => element.type === "button" && element.props.title === "REJECT"); + + sourceCheckboxes[1].props.onChange(); + regionButtons[1].props.onClick(); + textInputs[0].props.onChange({ target: { value: "IEPL" } }); + textInputs[1].props.onChange({ target: { value: "Test" } }); + includedRows[0].props.onDragStart(); + includedRows.at(-1)?.props.onDragOver({ preventDefault: vi.fn() }); + includedRows.at(-1)?.props.onDrop(); + includedRows.at(-1)?.props.onDragEnd(); + excludeButton?.props.onClick(); + enableButton?.props.onClick(); + + expect(onChange).toHaveBeenCalledWith({ sourceIds: ["source-a", "source-b"] }); + expect(onChange).toHaveBeenCalledWith({ regions: ["us", "hk"] }); + expect(onChange).toHaveBeenCalledWith({ includeRegex: "IEPL" }); + expect(onChange).toHaveBeenCalledWith({ excludeRegex: "Test" }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ memberOrder: expect.any(Array) })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + excludedMembers: expect.arrayContaining([expect.objectContaining({ kind: "direct" })]), + }), + ); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + extraMembers: expect.arrayContaining([expect.objectContaining({ kind: "reject" })]), + }), + ); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith("direct:DIRECT"); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); + }); + + it("ignores member drops without a real move target", () => { + const onChange = vi.fn(); + mocks.draggingKey = null; + let tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: {}, + onChange, + rulesCount: 0, + rulesContent: null, + }); + let includedRows = flattenElements(tree).filter((element) => element.props.draggable); + + includedRows[0].props.onDrop(); + expect(onChange).not.toHaveBeenCalled(); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); + + vi.clearAllMocks(); + mocks.stateSetters = []; + mocks.draggingKey = "direct:DIRECT"; + tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: {}, + onChange, + rulesCount: 0, + rulesContent: null, + }); + includedRows = flattenElements(tree).filter((element) => element.props.draggable); + + includedRows[0].props.onDrop(); + expect(onChange).not.toHaveBeenCalled(); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts new file mode 100644 index 0000000..7a31443 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts @@ -0,0 +1,260 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +const mocks = vi.hoisted(() => ({ + buttons: [] as any[], + inputs: [] as any[], + store: {} as Record, +})); + +vi.mock("lucide-react", () => ({ + Plus: () => React.createElement("span", null, "plus-icon"), + X: () => React.createElement("span", null, "x-icon"), +})); + +vi.mock("@subboost/ui/components/ui/badge", () => ({ + Badge: (props: any) => React.createElement("span", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/button", () => ({ + Button: (props: any) => { + mocks.buttons.push(props); + return React.createElement("button", props, props.children); + }, +})); + +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: any) => { + mocks.inputs.push(props); + return React.createElement("input", props); + }, +})); + +vi.mock("@subboost/ui/lib/utils", () => ({ + cn: (...parts: unknown[]) => parts.filter(Boolean).join(" "), +})); + +vi.mock("@subboost/ui/store/config-store", () => ({ + useConfigStore: () => mocks.store, +})); + +import { + buildMemberFromName, + insertMemberAfterProtected, + memberKindLabel, + memberLabel, + normalizeList, + ProxyGroupAdvancedPanel, + toggleValue, + withMember, + withoutMember, + type ResolvedMember, +} from "./proxy-group-advanced-panel"; + +function node(name: string): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + } as ParsedNode; +} + +describe("ProxyGroupAdvancedPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.buttons = []; + mocks.inputs = []; + mocks.store = { + nodes: [ + withNodeSourceId(node("US Source"), "source-a"), + withNodeSourceId(node("Japan Source"), "source-b"), + node("剩余流量:100GB"), + ], + sources: [ + { id: "source-a", type: "url", tag: " Primary " }, + { id: "source-b", type: "yaml", lastParsedTag: " YAML Feed " }, + { id: "unused", type: "nodes" }, + ], + enabledProxyGroups: ["auto", "select"], + customProxyGroups: [ + { id: "media", name: "Media", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + customRuleSets: [], + proxyGroupAdvanced: {}, + builtinRuleEdits: {}, + proxyGroupNameOverrides: { auto: "Auto" }, + testUrl: "https://cp.example", + testInterval: 300, + ruleProviderBaseUrl: "https://rules.example", + }; + }); + + it("renders source, region, enabled, excluded, and rules sections", () => { + const onChange = vi.fn(); + const html = renderToStaticMarkup( + React.createElement(ProxyGroupAdvancedPanel, { + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + sourceIds: ["source-a"], + regions: ["us"], + includeRegex: "Source", + excludeRegex: "Japan", + extraMembers: [{ kind: "direct" }], + }, + onChange, + rulesCount: 0, + rulesContent: React.createElement("div", null, "rules-content"), + }), + ); + + expect(html).toContain("导入源"); + expect(html).toContain("Primary"); + expect(html).toContain("YAML Feed"); + expect(html).not.toContain("剩余流量"); + expect(html).toContain("US Source"); + expect(html).toContain("DIRECT"); + expect(html).toContain("rules-content"); + expect(html).toContain("还没有分流规则"); + expect(mocks.inputs.map((input) => input.value)).toEqual(["Source", "Japan"]); + + mocks.inputs[0].onChange({ target: { value: "IEPL" } }); + const excludeButton = mocks.buttons.find((button) => button.title === "排除"); + excludeButton.onClick(); + + expect(onChange).toHaveBeenCalledWith({ includeRegex: "IEPL" }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + excludedMembers: [expect.objectContaining({ kind: expect.any(String) })], + }), + ); + }); + + it("renders empty states without generated members or source matches", () => { + mocks.store = { + ...mocks.store, + nodes: [], + sources: [], + enabledProxyGroups: [], + customProxyGroups: [], + proxyGroupAdvanced: {}, + proxyGroupNameOverrides: undefined, + }; + + const html = renderToStaticMarkup( + React.createElement(ProxyGroupAdvancedPanel, { + target: { kind: "module", id: "auto", name: "Auto" }, + advanced: {}, + onChange: vi.fn(), + rulesCount: 2, + rulesContent: React.createElement("div", null, "existing-rules"), + }), + ); + + expect(html).toContain("暂无可匹配的导入源"); + expect(html).toContain("暂无已启用的节点或代理组"); + expect(html).toContain("DIRECT"); + expect(html).toContain("REJECT"); + expect(html).toContain("existing-rules"); + expect(html).not.toContain("还没有分流规则"); + }); + + it("renders generated source fallback labels when source tags are absent", () => { + mocks.store = { + ...mocks.store, + nodes: [ + withNodeSourceId(node("URL Node"), "url-source"), + withNodeSourceId(node("YAML Node"), "yaml-source"), + withNodeSourceId(node("Nodes Node"), "nodes-source"), + ], + sources: [ + { id: "url-source", type: "url" }, + { id: "yaml-source", type: "yaml" }, + { id: "nodes-source", type: "nodes" }, + ], + enabledProxyGroups: ["auto"], + customProxyGroups: [], + }; + + const html = renderToStaticMarkup( + React.createElement(ProxyGroupAdvancedPanel, { + target: { kind: "module", id: "auto", name: "Missing Generated Group" }, + advanced: {}, + onChange: vi.fn(), + rulesCount: 0, + rulesContent: React.createElement("div", null, "rules-content"), + }), + ); + + expect(html).toContain("#1 订阅链接"); + expect(html).toContain("#2 YAML 配置"); + expect(html).toContain("#3 节点链接"); + expect(html).toContain("暂无已启用的节点或代理组"); + }); + + it("normalizes advanced member helpers without rendering the panel", () => { + const nodes = [node("US Source")]; + const moduleNames = { auto: "Auto", select: "Select" }; + const customProxyGroups = [{ id: "media", name: "Media", emoji: "", groupType: "select" as const }]; + const options = { nodes, moduleNames, customProxyGroups }; + + const direct = buildMemberFromName("DIRECT", options); + const reject = buildMemberFromName("REJECT", options); + const nodeMember = buildMemberFromName("US Source", options); + const moduleMember = buildMemberFromName("Auto", options); + const customMember = buildMemberFromName("Media", options); + + expect(buildMemberFromName(" ", options)).toBeNull(); + expect(buildMemberFromName("Missing", options)).toBeNull(); + expect(direct).toMatchObject({ key: "direct:DIRECT", kind: "direct", name: "DIRECT" }); + expect(reject).toMatchObject({ key: "reject:REJECT", kind: "reject", name: "REJECT" }); + expect(nodeMember).toMatchObject({ key: "node:US Source", kind: "node", name: "US Source" }); + expect(moduleMember).toMatchObject({ key: "module:auto", kind: "module", name: "Auto" }); + expect(customMember).toMatchObject({ key: "custom:media", kind: "custom", name: "Media" }); + expect(memberLabel(direct as ResolvedMember)).toBe("DIRECT"); + expect(memberLabel(customMember as ResolvedMember)).toBe("Media"); + expect(memberKindLabel(nodeMember as ResolvedMember)).toBe("节点"); + expect(memberKindLabel(moduleMember as ResolvedMember)).toBe("内置组"); + expect(memberKindLabel(customMember as ResolvedMember)).toBe("自定义组"); + expect(memberKindLabel(direct as ResolvedMember)).toBe("直连"); + expect(memberKindLabel(reject as ResolvedMember)).toBe("拒绝"); + }); + + it("updates list and member order helpers predictably", () => { + const existing = [ + { kind: "direct" as const }, + { kind: "module" as const, id: "select" }, + { kind: "node" as const, name: "Old" }, + ]; + const resolved = existing.map((ref) => ({ + key: ref.kind === "direct" ? "direct:DIRECT" : ref.kind === "module" ? `module:${ref.id}` : `node:${ref.name}`, + kind: ref.kind, + name: ref.kind === "direct" ? "DIRECT" : ref.kind === "module" ? "Select" : ref.name, + ref, + })) as ResolvedMember[]; + + expect(normalizeList(undefined)).toEqual([]); + expect(normalizeList(["a"])).toEqual(["a"]); + expect(toggleValue(undefined, "us")).toEqual(["us"]); + expect(toggleValue(["us", "jp"], "us")).toEqual(["jp"]); + expect(withoutMember(existing, "node:Old")).toEqual([{ kind: "direct" }, { kind: "module", id: "select" }]); + expect(withMember(existing, { kind: "node", name: "Old" })).toEqual(existing); + expect(withMember(existing, { kind: "custom", id: "media" })).toEqual([ + ...existing, + { kind: "custom", id: "media" }, + ]); + expect(insertMemberAfterProtected(resolved, { kind: "custom", id: "media" })).toEqual([ + { kind: "direct" }, + { kind: "module", id: "select" }, + { kind: "custom", id: "media" }, + { kind: "node", name: "Old" }, + ]); + expect(insertMemberAfterProtected(resolved, { kind: "node", name: "Old" })).toEqual(existing); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx index 2a50144..16dbb10 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx @@ -27,24 +27,24 @@ type AdvancedTarget = { name: string; }; -type ResolvedMember = { +export type ResolvedMember = { key: string; ref: ProxyGroupMemberRef; name: string; kind: ProxyGroupMemberRef["kind"]; }; -function normalizeList(value: readonly T[] | undefined): T[] { +export function normalizeList(value: readonly T[] | undefined): T[] { return Array.isArray(value) ? [...value] : []; } -function memberLabel(member: ResolvedMember): string { +export function memberLabel(member: ResolvedMember): string { if (member.kind === "direct") return "DIRECT"; if (member.kind === "reject") return "REJECT"; return member.name; } -function memberKindLabel(member: ResolvedMember): string { +export function memberKindLabel(member: ResolvedMember): string { switch (member.kind) { case "node": return "节点"; @@ -59,7 +59,7 @@ function memberKindLabel(member: ResolvedMember): string { } } -function buildMemberFromName( +export function buildMemberFromName( name: string, options: { nodes: ParsedNode[]; @@ -90,18 +90,18 @@ function buildMemberFromName( }; } -function toggleValue(list: readonly T[] | undefined, value: T): T[] { +export function toggleValue(list: readonly T[] | undefined, value: T): T[] { const next = new Set(normalizeList(list)); if (next.has(value)) next.delete(value); else next.add(value); return Array.from(next); } -function withoutMember(list: readonly ProxyGroupMemberRef[] | undefined, key: string): ProxyGroupMemberRef[] { +export function withoutMember(list: readonly ProxyGroupMemberRef[] | undefined, key: string): ProxyGroupMemberRef[] { return normalizeList(list).filter((member) => getProxyGroupMemberKey(member) !== key); } -function withMember(list: readonly ProxyGroupMemberRef[] | undefined, member: ProxyGroupMemberRef): ProxyGroupMemberRef[] { +export function withMember(list: readonly ProxyGroupMemberRef[] | undefined, member: ProxyGroupMemberRef): ProxyGroupMemberRef[] { const key = getProxyGroupMemberKey(member); return [...withoutMember(list, key), member]; } @@ -113,7 +113,7 @@ const PROTECTED_INSERT_KEYS = new Set([ "module:select", ]); -function insertMemberAfterProtected( +export function insertMemberAfterProtected( currentMembers: ResolvedMember[], member: ProxyGroupMemberRef, ): ProxyGroupMemberRef[] { diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts index ea745e5..d5291bc 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts @@ -120,12 +120,40 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ ], })); vi.mock("@subboost/core/generator/module-rules", () => ({ + getModuleRuleOrderKey: (moduleId: string, ruleId: string) => + `${moduleId}:${ruleId}`, getEffectiveModuleRules: vi.fn(() => mocks.effectiveRules), })); vi.mock("@subboost/core/proxy-group-name", () => ({ resolveProxyGroupModuleName: (module: { name: string }, override?: string) => override || module.name, })); +vi.mock("@subboost/core/proxy-group-targets", () => ({ + resolveProxyGroupTargetName: ( + target: unknown, + options: { + moduleNames?: Record; + customProxyGroups?: Array<{ id: string; name: string }>; + fallbackTarget?: string; + } = {}, + ) => { + if (typeof target === "string") return target; + if (target && typeof target === "object") { + const entry = target as { kind?: string; id?: string }; + if (entry.kind === "module" && entry.id) { + return options.moduleNames?.[entry.id] ?? options.fallbackTarget ?? entry.id; + } + if (entry.kind === "custom" && entry.id) { + return ( + options.customProxyGroups?.find((group) => group.id === entry.id)?.name ?? + options.fallbackTarget ?? + entry.id + ); + } + } + return options.fallbackTarget ?? ""; + }, +})); vi.mock("@subboost/core/rules/custom-routing-rule-sets", () => ({ buildRuleSetUrlFromPath: (path: string, base: string) => `${base.replace(/\/+$/, "")}/${path}`, @@ -144,6 +172,7 @@ vi.mock("@subboost/ui/store/config-store", () => { }); import { ProxyGroupsAddedRuleSets } from "./proxy-groups-added-rule-sets"; +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { RULE_EDIT_ACTIONS_CLASS, RULE_EDIT_PRIMARY_FIELD_CLASS, @@ -223,6 +252,9 @@ describe("ProxyGroupsAddedRuleSets", () => { switches: [], }; mocks.ruleSets = [moduleItem, customItem]; + for (const module of PROXY_GROUP_MODULES as Array<{ rules?: unknown[] }>) { + module.rules = []; + } mocks.effectiveRules = []; mocks.store = { ruleProviderBaseUrl: "https://rules.example/", @@ -313,6 +345,36 @@ describe("ProxyGroupsAddedRuleSets", () => { expect(draftUpdaters.map((updater) => updater(null))).toEqual([null, null]); }); + it("renders raw rule-set paths and hides rows targeting hidden modules", () => { + mocks.ruleSets = [ + { + ...moduleItem, + key: "custom-rule-set:raw", + id: "raw", + path: "https://cdn.example/plain-rule.txt", + noResolve: false, + }, + { + ...moduleItem, + key: "custom-rule-set:hidden", + id: "hidden", + path: "geosite/hidden.mrs", + target: { + kind: "module", + id: "fallback", + value: "module:fallback", + name: "Fallback", + }, + }, + ]; + mocks.store.hiddenProxyGroups = ["fallback"]; + + const { html } = renderAdded(); + + expect(html).toContain("https://cdn.example/plain-rule.txt"); + expect(html).not.toContain("geosite/hidden"); + }); + it("saves module rule edits across module and custom targets", () => { renderAdded({ 0: moduleItem.key, @@ -502,6 +564,87 @@ describe("ProxyGroupsAddedRuleSets", () => { expect(mocks.store.removeModuleRule).toHaveBeenCalledWith("custom-1", "rule-b"); }); + it("rejects builtin and blank custom target conflicts", () => { + (PROXY_GROUP_MODULES[1] as any).rules = [ + { id: "other-rule" }, + { id: "rule-a" }, + ]; + renderAdded({ + 0: moduleItem.key, + 1: { + path: "geosite/rule-a.mrs", + targetValue: "module:fallback", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "规则集已存在", variant: "warning" }), + ); + + mocks.toast.mockClear(); + mocks.store.builtinRuleEdits = { + "fallback:rule-a": { enabled: false }, + }; + renderAdded({ + 0: moduleItem.key, + 1: { + path: "geosite/rule-a.mrs", + targetValue: "module:fallback", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("auto", "rule-a", { + kind: "module", + id: "fallback", + }); + + mocks.store.moveModuleRule.mockClear(); + mocks.store.builtinRuleEdits = { + "fallback:rule-a": { target: { kind: "custom", id: "custom-2" } }, + }; + renderAdded({ + 0: moduleItem.key, + 1: { + path: "geosite/rule-a.mrs", + targetValue: "module:fallback", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("auto", "rule-a", { + kind: "module", + id: "fallback", + }); + + mocks.store.customProxyGroups = [ + { id: "custom-1", name: "Custom" }, + { id: "blank", name: " " }, + ]; + renderAdded({ + 0: customItem.key, + 1: { + path: "geoip/rule-b.mrs", + targetValue: "custom:blank", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "规则集已存在", variant: "warning" }), + ); + }); + it("starts editing from row buttons and clears stale editing state", () => { const { setters } = renderAdded(); mocks.captures.buttons @@ -569,5 +712,18 @@ describe("ProxyGroupsAddedRuleSets", () => { expect.objectContaining({ title: "规则集已存在", variant: "warning" }), ); + renderAdded({ + 0: moduleItem.key, + 1: { path: " ", targetValue: "module:auto", noResolve: false }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.store.updateModuleRule).not.toHaveBeenCalledWith( + "auto", + "rule-a", + expect.objectContaining({ path: "" }), + ); + }); }); 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 7b2f52a..d27e0ad 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 @@ -11,8 +11,10 @@ const mocks = vi.hoisted(() => ({ const stateMock = vi.hoisted(() => ({ enabled: false, + effects: [] as Array, callIndex: 0, overrides: {} as Record, + refOverride: undefined as unknown, setters: [] as Array>, })); @@ -29,11 +31,16 @@ vi.mock("react", async (importOriginal) => { return actual.useMemo(factory, deps ?? []); }, useEffect: (effect: React.EffectCallback, deps?: React.DependencyList) => { - if (stateMock.enabled) return undefined; + if (stateMock.enabled) { + stateMock.effects.push(effect); + return undefined; + } return actual.useEffect(effect, deps); }, useRef: (initial: unknown) => { - if (stateMock.enabled) return { current: initial }; + if (stateMock.enabled) { + return { current: stateMock.refOverride === undefined ? initial : stateMock.refOverride }; + } return actual.useRef(initial); }, useState: (initial: unknown) => { @@ -83,10 +90,13 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ service: { name: "服务", order: 1.5 }, custom: { name: "自定义", order: 2 }, }, - PROXY_GROUP_MODULES: [ - { id: "auto", name: "Auto", category: "core" }, - { id: "fallback", name: "Fallback", category: "core" }, - ], + PROXY_GROUP_MODULES: [ + { id: "auto", name: "Auto", category: "core", rules: [ + { id: "auto-rule", name: "Auto Rule", behavior: "domain", path: "geosite/auto-rule.mrs" }, + { id: "moved-rule", name: "Moved Rule", behavior: "domain", path: "geosite/moved-rule.mrs" }, + ] }, + { id: "fallback", name: "Fallback", category: "core", rules: [] }, + ], generateProxyGroups: vi.fn(() => []), })); vi.mock("@subboost/core/proxy-group-name", () => ({ @@ -125,11 +135,13 @@ vi.mock("./proxy-groups-module-card", () => ({ })); import { ProxyGroupsCategories } from "./proxy-groups-categories"; +import { generateProxyGroups } from "@subboost/core/generator/proxy-groups"; function renderCategories(overrides: Record = {}) { stateMock.enabled = true; stateMock.callIndex = 0; stateMock.overrides = overrides; + stateMock.effects = []; stateMock.setters = []; mocks.captures.inputs = []; mocks.captures.dropdownItems = []; @@ -148,6 +160,7 @@ function renderCategoryTree(overrides: Record = {}) { stateMock.enabled = true; stateMock.callIndex = 0; stateMock.overrides = overrides; + stateMock.effects = []; stateMock.setters = []; mocks.captures.inputs = []; mocks.captures.dropdownItems = []; @@ -178,6 +191,7 @@ function collectElements( describe("ProxyGroupsCategories", () => { beforeEach(() => { vi.clearAllMocks(); + stateMock.refOverride = undefined; mocks.confirmDialog.mockResolvedValue(true); mocks.captures = { inputs: [], dropdownItems: [], moduleCards: [] }; mocks.store = { @@ -205,6 +219,7 @@ describe("ProxyGroupsCategories", () => { removeModuleRule: vi.fn(), moveModuleRule: vi.fn(), restoreModuleRule: vi.fn(), + resetModuleRuleTarget: vi.fn(), updateCustomProxyGroup: vi.fn(), acceptModuleRuleEditWarning: vi.fn(), proxyGroupNameOverrides: { auto: "Auto Override" }, @@ -231,6 +246,19 @@ describe("ProxyGroupsCategories", () => { expect(mocks.captures.moduleCards).toHaveLength(0); }); + it("applies the custom category default after custom groups appear later", () => { + stateMock.refOverride = false; + renderCategories({ 0: new Set() }); + stateMock.effects[0](); + expect(stateMock.setters[0]).toHaveBeenCalledWith(expect.any(Function)); + expect((stateMock.setters[0] as any).lastValue.has("custom")).toBe(true); + + stateMock.refOverride = false; + renderCategories({ 0: new Set(["custom"]) }); + stateMock.effects[0](); + expect((stateMock.setters[0] as any).lastValue.has("custom")).toBe(true); + }); + it("renders visible and hidden modules and forwards basic config changes", () => { renderCategories({ 0: new Set(["core"]) }); @@ -289,6 +317,8 @@ describe("ProxyGroupsCategories", () => { expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { target: "Fallback" }); card.onRestoreRule("rule-a"); expect(mocks.store.restoreModuleRule).toHaveBeenCalledWith("auto", "rule-a"); + card.onResetRuleTarget("rule-a"); + expect(mocks.store.resetModuleRuleTarget).toHaveBeenCalledWith("auto", "rule-a"); card.onChangeCnIpNoResolve(true); expect(mocks.store.setCnIpNoResolve).toHaveBeenCalledWith(true); @@ -355,6 +385,56 @@ describe("ProxyGroupsCategories", () => { ]); }); + it("forwards generated counts, moved preset rules, and advanced group changes", () => { + mocks.store.nodes = [{ name: "US", type: "ss", server: "us.example.com", port: 8388, cipher: "aes-128-gcm", password: "secret" }]; + mocks.store.customRuleSets = [ + { id: "set-1", name: "Set 1", behavior: "domain", path: "geosite/set-1.mrs", target: "Auto Override", noResolve: true }, + ]; + mocks.store.builtinRuleEdits = { + "module:auto:auto-rule": { enabled: false }, + "module:auto:moved-rule": { target: "Fallback" }, + bad: { target: "Fallback" }, + }; + mocks.store.proxyGroupAdvanced = { auto: { groupType: "select", strategy: "round-robin" } }; + vi.mocked(generateProxyGroups).mockReturnValueOnce([ + { name: "Auto Override", type: "select", proxies: ["US", "DIRECT"] }, + ] as never); + + renderCategories({ 0: new Set(["core"]) }); + const card = mocks.captures.moduleCards[0]; + + expect(card.nodeCount).toBe(2); + expect(card.extraRules).toEqual([ + { id: "set-1", name: "Set 1", behavior: "domain", path: "geosite/set-1.mrs", noResolve: true }, + ]); + expect(card.hiddenPresetRuleIds.auto).toContain("auto-rule"); + expect(card.hiddenPresetRuleIds.auto).toContain("moved-rule"); + + card.onChangeGroupType({ groupType: "load-balance" }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { + groupType: "load-balance", + strategy: "round-robin", + }); + card.onChangeGroupType({ groupType: "fallback", strategy: "consistent-hashing" }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { + groupType: "fallback", + strategy: undefined, + }); + + mocks.store.proxyGroupAdvanced = { auto: {} }; + renderCategories({ 0: new Set(["core"]) }); + mocks.captures.moduleCards[0].onChangeGroupType({ groupType: "load-balance" }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { + groupType: "load-balance", + strategy: "round-robin", + }); + + const advancedElement = card.renderAdvancedContent(React.createElement("div", null, "rules"), 2); + expect(advancedElement.props.target).toEqual({ kind: "module", id: "auto", name: "Auto Override" }); + advancedElement.props.onChange({ sourceIds: ["source-a"] }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { sourceIds: ["source-a"] }); + }); + it("renders custom category and disabled non-core module branches", async () => { mocks.store.hiddenProxyGroups = []; mocks.store.enabledProxyGroups = []; @@ -374,6 +454,21 @@ describe("ProxyGroupsCategories", () => { expect(setters[0]).toBeDefined(); }); + it("handles unknown rule targets and generated groups without proxy arrays", () => { + mocks.store.nodes = [{ name: "US", type: "ss", server: "us.example.com", port: 8388, cipher: "aes-128-gcm", password: "secret" }]; + mocks.store.customRuleSets = [ + { id: "unknown-target", name: "Unknown", behavior: "domain", path: "geosite/unknown.mrs", target: "Missing" }, + ]; + vi.mocked(generateProxyGroups).mockReturnValueOnce([ + { name: "Auto Override", type: "select", proxies: "bad" }, + ] as never); + + renderCategories({ 0: new Set(["core"]) }); + + expect(mocks.captures.moduleCards[0].nodeCount).toBe(0); + expect(mocks.captures.moduleCards[0].extraRules).toEqual([]); + }); + it("toggles category expansion through the rendered category headers", () => { const { tree, setters } = renderCategoryTree({ 0: new Set(["core"]) }); const categoryButtons = collectElements( diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts new file mode 100644 index 0000000..a352c89 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts @@ -0,0 +1,203 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + cards: [] as any[], + interactions: { + proxyGroupAdded: vi.fn(), + }, + store: {} as Record, + toast: vi.fn(), +})); + +vi.mock("lucide-react", () => ({ + Check: () => null, + Trash2: () => null, +})); + +vi.mock("@subboost/ui/components/ui/button", () => ({ + Button: (props: any) => React.createElement("button", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: any) => React.createElement("input", props), +})); + +vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); + +vi.mock("@subboost/core/generator/proxy-groups", () => ({ + PROXY_GROUP_MODULES: [ + { id: "auto", name: "Auto" }, + { id: "fallback", name: "Fallback" }, + ], +})); + +vi.mock("@subboost/core/proxy-group-name", () => ({ + resolveProxyGroupModuleName: (module: { name: string }, override?: string) => override || module.name, +})); + +vi.mock("@subboost/core/proxy-group-targets", () => ({ + resolveProxyGroupTargetName: (target: any, options: any) => { + if (typeof target === "string") return target; + if (target?.kind === "custom") { + return options.customProxyGroups.find((group: any) => group.id === target.id)?.name ?? ""; + } + if (target?.kind === "module") return options.moduleNames[target.id] ?? ""; + return ""; + }, +})); + +vi.mock("@subboost/core/rules/custom-routing-rule-sets", () => ({ + extractRuleSetPathFromUrl: (url: string) => url, +})); + +vi.mock("@subboost/core/types/config", () => ({ + DEFAULT_LOAD_BALANCE_STRATEGY: "consistent-hashing", +})); + +vi.mock("@subboost/ui/store/config-store", () => { + const useConfigStore = () => mocks.store; + (useConfigStore as any).getState = () => mocks.store; + return { useConfigStore }; +}); + +vi.mock("@subboost/ui/product/interactions", () => ({ + useProductInteractionAdapter: () => mocks.interactions, +})); + +vi.mock("./proxy-group-name-editor", () => ({ + buildProxyGroupName: (draft: { emoji?: string; name?: string }) => { + const name = draft.name?.trim() ?? ""; + return name ? `${draft.emoji?.trim() || "C"} ${name}` : ""; + }, + parseProxyGroupNameDraft: (value: string, emoji: string) => ({ emoji, name: value.replace(/^\\S+\\s+/, "") }), + ProxyGroupNameEditor: () => null, + toProxyGroupNameDraft: (value: { emoji?: string; name?: string }) => value, +})); + +vi.mock("./proxy-group-rule-targets", () => ({ + buildManualRuleTargets: vi.fn(() => [{ name: "Auto" }]), + listCustomRulesForTarget: (rules: any[], target: string) => + rules.filter((rule) => rule.target === target).map((rule, index) => ({ rule, index })), +})); + +vi.mock("./proxy-group-rule-row", () => ({ + ProxyGroupManualRuleRow: () => null, + ProxyGroupRuleMoveMenu: () => null, + ProxyGroupRuleSetRow: () => null, + isRuleSetMoveTarget: (value: unknown) => Boolean(value && typeof value === "object"), +})); + +vi.mock("./proxy-group-type-menu", () => ({ + ProxyGroupTypeMenu: () => null, +})); + +vi.mock("./proxy-groups-module-card", () => ({ + ProxyGroupsModuleCard: (props: any) => { + mocks.cards.push(props); + return React.createElement("section", null, props.rulesContentOverride); + }, +})); + +import { ProxyGroupsCustomGroupsPanel } from "./proxy-groups-custom-groups-panel"; + +describe("ProxyGroupsCustomGroupsPanel card props", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.cards = []; + mocks.store = { + enabledProxyGroups: ["auto"], + hiddenProxyGroups: [], + proxyGroupNameOverrides: { auto: "Auto" }, + customRules: [{ id: "manual-1", target: "C Custom" }], + customProxyGroups: [ + { + id: "custom-1", + name: "C Custom", + emoji: "C", + groupType: "select", + enabled: false, + advanced: { sourceIds: ["old-source"] }, + }, + ], + customRuleSets: [ + { + id: "rule-a", + name: "Rule A", + behavior: "domain", + path: "https://rules.example/rule-a.mrs", + target: "C Custom", + }, + ], + dialerProxyGroups: [], + addCustomProxyGroup: vi.fn(), + removeCustomProxyGroup: vi.fn(), + updateCustomProxyGroup: vi.fn(), + updateCustomRule: vi.fn(), + removeCustomRule: vi.fn(), + moveModuleRule: vi.fn(), + removeModuleRule: vi.fn(), + }; + }); + + it("wires custom group card actions and advanced patches", () => { + renderToStaticMarkup( + React.createElement(ProxyGroupsCustomGroupsPanel, { + advancedMode: true, + nodeCounts: new Map([["C Custom", 3]]), + }), + ); + + const card = mocks.cards[0]; + expect(card).toMatchObject({ + advancedMode: true, + isEnabled: false, + nodeCount: 3, + rulesCountOverride: 2, + }); + + card.onToggleEnabled(); + card.onStartEditing(); + card.onCancelEditing(); + card.onCommitEditing(); + card.onHide(); + card.acceptModuleRuleEditWarning(); + card.onToggleRulesExpanded(); + card.onAddRules(); + card.onAddRulesToModule(); + card.onAddRuleToCustomGroup(); + card.onRemoveExtraRule(); + card.onMoveRule(); + card.onMoveManualRule("manual-1", "Auto"); + card.onRemoveManualRule(0); + card.onRestoreRule(); + card.onResetRuleTarget(); + card.onChangeCnIpNoResolve(true); + card.onChangeExperimentalCnUseCnRuleSet(true); + card.onChangeGroupType({ groupType: "load-balance", strategy: "round-robin" }); + card.onChangeGroupType({ groupType: "select" }); + + const advanced = card.renderAdvancedContent(React.createElement("div", null, "rules"), 2) as React.ReactElement; + advanced.props.onChange({ regions: ["us"] }); + const emptyAdvanced = card.renderAdvancedContent(React.createElement("div", null, "rules"), 0) as React.ReactElement; + + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { enabled: true }); + expect(mocks.store.removeCustomProxyGroup).toHaveBeenCalledWith("custom-1"); + expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { target: "Auto" }); + expect(mocks.store.removeCustomRule).toHaveBeenCalledWith(0); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + groupType: "load-balance", + strategy: "round-robin", + }); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + groupType: "select", + strategy: undefined, + }); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + advanced: { sourceIds: ["old-source"], regions: ["us"] }, + }); + expect(advanced.props.rulesContent).not.toBeNull(); + expect(emptyAdvanced.props.rulesContent).toBeNull(); + }); +}); 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 b429876..b0a7dcc 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 @@ -198,6 +198,8 @@ describe("ProxyGroupsCustomGroupsPanel", () => { mocks.captures.typeMenus[0].onChange({ groupType: "load-balance", strategy: "round-robin" }); expect(setters[3]).toHaveBeenCalledWith("load-balance"); expect(setters[4]).toHaveBeenCalledWith("round-robin"); + mocks.captures.typeMenus[0].onChange({ groupType: "select" }); + expect(setters[3]).toHaveBeenCalledWith("select"); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ @@ -215,6 +217,22 @@ describe("ProxyGroupsCustomGroupsPanel", () => { expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "代理组名称已存在,请换一个名称。", variant: "warning" })); }); + it("ignores blank new group names and keeps load-balance strategy on add", () => { + renderPanel({ 1: { emoji: "🧩", name: " " }, 3: "select" }); + mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); + expect(mocks.store.addCustomProxyGroup).not.toHaveBeenCalled(); + + renderPanel({ 1: { emoji: "🧩", name: "Balanced" }, 2: " LB group ", 3: "load-balance", 4: "round-robin" }); + mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); + expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ + name: "🧩 Balanced", + emoji: "🧩", + description: "LB group", + groupType: "load-balance", + strategy: "round-robin", + }); + }); + it("renames, removes, and changes existing group type", () => { const { setters } = renderPanel({ 0: new Set(["custom-1"]), 5: "custom-1", 6: "🧩 Renamed", 7: "" }); @@ -324,6 +342,16 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }); }); + it("ignores blank custom group rename commits", () => { + renderPanel({ 0: new Set(["custom-1"]), 5: "custom-1", 6: " ", 7: "desc" }); + + const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); + renameInput.onKeyDown({ key: "Enter" }); + + expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + it("handles custom rule-set move no-op and missing-target paths", () => { renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-1", name: "🧩 Custom" }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts index d0bebb5..0fa89e8 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.test.ts @@ -28,6 +28,7 @@ vi.mock("lucide-react", () => ({ HelpCircle: () => React.createElement("span", null, "help-icon"), Pencil: () => React.createElement("span", null, "pencil-icon"), Shuffle: () => React.createElement("span", null, "shuffle-icon"), + SlidersHorizontal: () => React.createElement("span", null, "sliders-icon"), Trash2: () => React.createElement("span", null, "trash-icon"), X: () => React.createElement("span", null, "x-icon"), })); @@ -181,6 +182,65 @@ describe("ProxyGroupsModuleCard", () => { expect(mocks.panels).toHaveLength(0); }); + it("handles optional description editing and advanced rule rendering", () => { + const editingHandlers = props({ + isEditing: true, + editingDescription: "Draft description", + onChangeEditingDescription: vi.fn(), + isRulesExpanded: false, + }); + renderToStaticMarkup(React.createElement(ProxyGroupsModuleCard, editingHandlers)); + + const descriptionInput = mocks.inputs.find( + (input) => input.placeholder === "描述文本(默认: 自定义代理组)", + ); + expect(descriptionInput).toEqual( + expect.objectContaining({ value: "Draft description" }), + ); + descriptionInput.onChange({ target: { value: "Updated description" } }); + descriptionInput.onKeyDown({ key: "Enter" }); + descriptionInput.onKeyDown({ key: "Escape" }); + expect(editingHandlers.onChangeEditingDescription).toHaveBeenCalledWith( + "Updated description", + ); + expect(editingHandlers.onCommitEditing).toHaveBeenCalled(); + expect(editingHandlers.onCancelEditing).toHaveBeenCalled(); + + mocks.inputs = []; + const emptyDescriptionHandlers = props({ + isEditing: true, + onChangeEditingDescription: vi.fn(), + isRulesExpanded: false, + }); + renderToStaticMarkup( + React.createElement(ProxyGroupsModuleCard, emptyDescriptionHandlers), + ); + expect( + mocks.inputs.find( + (input) => input.placeholder === "描述文本(默认: 自定义代理组)", + ).value, + ).toBe(""); + + const renderAdvancedContent = vi.fn((rulesContent, rulesCount) => + React.createElement("section", null, "advanced-", rulesCount, rulesContent), + ); + const html = renderToStaticMarkup( + React.createElement( + ProxyGroupsModuleCard, + props({ + advancedMode: true, + renderAdvancedContent, + rulesContentOverride: React.createElement("span", null, "override rules"), + rulesCountOverride: 5, + }) + ) + ); + + expect(renderAdvancedContent).toHaveBeenCalledWith(expect.anything(), 5); + expect(html).toContain("advanced-5"); + expect(html).toContain("override rules"); + }); + it("renders google scholar hint and hides optional controls for core modules", () => { const html = renderToStaticMarkup( React.createElement( @@ -218,6 +278,33 @@ describe("ProxyGroupsModuleCard", () => { expect(html).toContain('text-emerald-300">3 规则'); expect(html).not.toContain('title="手动选择代理节点'); expect(html).not.toContain('title="3 规则'); + + const overrideHtml = renderToStaticMarkup( + React.createElement( + ProxyGroupsModuleCard, + props({ + description: "Override description", + module: { ...baseModule, description: undefined }, + display: { full: "Override" }, + }) + ) + ); + expect(overrideHtml).toContain("Override description"); + + const emptyDescriptionHtml = renderToStaticMarkup( + React.createElement( + ProxyGroupsModuleCard, + props({ + extraRules: [], + manualRules: [], + module: { ...baseModule, description: undefined, rules: [] }, + rulesContentOverride: null, + rulesCountOverride: 0, + }) + ) + ); + expect(emptyDescriptionHtml).toContain("0 规则"); + expect(emptyDescriptionHtml).not.toContain("AI description"); }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts index 82a25a3..302dd52 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts @@ -113,6 +113,7 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ PROXY_GROUP_MODULES: [ { id: "auto", name: "Auto", rules: [{ id: "netflix" }] }, { id: "fallback", name: "Fallback", rules: [] }, + { id: "bare", name: "Bare" }, ], })); vi.mock("@subboost/core/generator/module-rules", () => ({ @@ -152,6 +153,7 @@ vi.mock("./proxy-groups-rules-search", () => ({ })); import { ProxyGroupsRulesLibrary } from "./proxy-groups-rules-library"; +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; const netflixRule = { id: "netflix", @@ -204,6 +206,9 @@ describe("ProxyGroupsRulesLibrary", () => { beforeEach(() => { vi.clearAllMocks(); mocks.captures = { addedRuleSets: [], badges: [], buttons: [], inputs: [], nativeButtons: [], nativeDivs: [], selects: [] }; + (PROXY_GROUP_MODULES[0] as any).rules = [{ id: "netflix" }]; + (PROXY_GROUP_MODULES[1] as any).rules = []; + (PROXY_GROUP_MODULES[2] as any).rules = undefined; mocks.effectiveRulesByModule = {}; mocks.search = { ruleSearchKeyword: "netflix", @@ -279,7 +284,14 @@ describe("ProxyGroupsRulesLibrary", () => { expect(result.html).toContain("已启用"); expect(result.html).toContain("属于"); + (PROXY_GROUP_MODULES[0] as any).rules = [{ id: "telegram" }]; + mocks.search.searchResults = [telegramRule]; + result = renderLibrary(); + expect(result.html).toContain("IP"); + mocks.store.enabledProxyGroups = []; + mocks.search.searchResults = [netflixRule, telegramRule]; + (PROXY_GROUP_MODULES[0] as any).rules = [{ id: "netflix" }]; renderLibrary(); mocks.captures.buttons.find((props) => props.children === "开启代理组").onClick(); expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("auto"); @@ -380,6 +392,10 @@ describe("ProxyGroupsRulesLibrary", () => { }); it("clears hidden module targets and toggles unassigned rule selection", () => { + const placeholderResult = renderLibrary({ 1: "__label_modules__" }); + stateMock.effects[0](); + expect(placeholderResult.setters[1]).not.toHaveBeenCalledWith(""); + renderLibrary({ 1: "custom:custom-1" }); stateMock.effects[0](); expect(stateMock.setters[1]).not.toHaveBeenCalledWith(""); @@ -501,4 +517,104 @@ describe("ProxyGroupsRulesLibrary", () => { variant: "warning", })); }); + + it("handles moved builtin targets and modules without preset rules", () => { + mocks.store.builtinRuleEdits = { "module:auto:netflix": { target: "Target" } }; + renderLibrary({ 0: [netflixRule], 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "规则集已在其他分流组中", + description: expect.stringContaining("Target"), + variant: "warning", + })); + + vi.clearAllMocks(); + mocks.store.builtinRuleEdits = {}; + mocks.store.enabledProxyGroups = []; + renderLibrary({ 0: [telegramRule], 1: "module:bare" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + + expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("bare"); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("bare", [ + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + noResolve: true, + }, + ]); + }); + + it("covers empty hints, loading-more state, disabled builtin edits, and optional interactions", () => { + mocks.search.ruleSearchKeyword = ""; + mocks.search.searchResults = []; + mocks.search.totalRules = 0; + expect(renderLibrary().html).toContain("规则库"); + expect(mocks.captures.addedRuleSets[0]).toEqual({ showSearchHint: true, totalRules: 0 }); + + mocks.search.ruleSearchKeyword = "all"; + mocks.search.searchResults = [telegramRule]; + mocks.search.totalMatched = undefined; + mocks.search.canLoadMore = true; + mocks.search.rulesSearchLoadingMore = true; + expect(renderLibrary().html).toContain("显示 1"); + expect(mocks.captures.buttons.find((props) => props.onClick === mocks.search.handleLoadMore).disabled).toBe(true); + + mocks.search.rulesSearchLoadingMore = false; + mocks.search.searchResults = [netflixRule]; + mocks.store.builtinRuleEdits = { "module:auto:netflix": { enabled: false } }; + renderLibrary(); + expect(mocks.captures.nativeDivs.some((props) => String(props.className).includes("cursor-pointer"))).toBe(true); + + mocks.store.builtinRuleEdits = {}; + mocks.store.customRuleSets = [{ id: "netflix", name: "Netflix", behavior: "domain", path: "geosite/netflix.mrs", target: "Custom" }]; + expect(renderLibrary().html).toContain("域名"); + + vi.clearAllMocks(); + mocks.interactions = {}; + mocks.store.customRuleSets = []; + mocks.search.searchResults = [telegramRule]; + renderLibrary({ 0: [telegramRule], 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [ + expect.objectContaining({ id: "telegram" }), + ]); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "已添加规则集" })); + }); + + it("summarizes long conflict lists and warns when only invalid custom rules are selected", () => { + const conflictRules = Array.from({ length: 9 }, (_, index) => ({ + ...telegramRule, + id: `conflict-${index}`, + nameZh: `Conflict ${index}`, + url: `https://raw.example/geoip/conflict-${index}.mrs`, + })); + mocks.store.customRuleSets = conflictRules.map((rule) => ({ + id: rule.id, + name: rule.nameZh, + behavior: "ipcidr", + path: `geoip/${rule.id}.mrs`, + target: "Target", + })); + mocks.search.searchResults = conflictRules; + renderLibrary({ 0: conflictRules, 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "规则集已在其他分流组中", + description: expect.stringContaining("以及 1 条"), + })); + + vi.clearAllMocks(); + mocks.store.customRuleSets = []; + const emptyUrlRule = { ...invalidRule, id: "empty-url", url: "" }; + mocks.search.searchResults = [emptyUrlRule]; + renderLibrary({ 0: [emptyUrlRule], 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "没有新增规则集", + description: expect.stringContaining("1 条已存在"), + variant: "warning", + })); + }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-section.test.ts new file mode 100644 index 0000000..0ac3f23 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-section.test.ts @@ -0,0 +1,83 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + store: {} as Record, + headerProps: undefined as Record | undefined, +})); + +vi.mock("lucide-react", () => ({ + Layers: () => React.createElement("span", null, "layers-icon"), +})); + +vi.mock("@subboost/core/generator/proxy-groups", () => ({ + PROXY_GROUP_MODULES: [ + { id: "select" }, + { id: "auto" }, + { id: "cn" }, + ], +})); + +vi.mock("@subboost/ui/components/ui/badge", () => ({ + Badge: (props: any) => React.createElement("span", props, props.children), +})); + +vi.mock("@subboost/ui/store/config-store", () => ({ + useConfigStore: () => mocks.store, +})); + +vi.mock("../section-header", () => ({ + SectionHeader: (props: any) => { + mocks.headerProps = props; + return React.createElement("button", { onClick: props.onToggle }, props.title, props.badge); + }, +})); + +vi.mock("./proxy-groups-categories", () => ({ + ProxyGroupsCategories: () => React.createElement("div", null, "categories-content"), +})); + +import { ProxyGroupsSection } from "./proxy-groups-section"; + +describe("ProxyGroupsSection", () => { + beforeEach(() => { + mocks.store = { + enabledProxyGroups: ["select", "auto", "cn"], + hiddenProxyGroups: ["cn"], + }; + mocks.headerProps = undefined; + }); + + it("renders enabled/visible group counts and expanded content", () => { + const onToggle = vi.fn(); + const html = renderToStaticMarkup( + React.createElement(ProxyGroupsSection, { + isExpanded: true, + onToggle, + }), + ); + + expect(html).toContain("分流代理组"); + expect(html).toContain("2/2"); + expect(html).toContain("categories-content"); + expect(mocks.headerProps).toMatchObject({ + isExpanded: true, + title: "分流代理组", + }); + (mocks.headerProps?.onToggle as () => void)(); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("omits categories while collapsed", () => { + const html = renderToStaticMarkup( + React.createElement(ProxyGroupsSection, { + isExpanded: false, + onToggle: vi.fn(), + }), + ); + + expect(html).not.toContain("categories-content"); + expect(mocks.headerProps).toMatchObject({ isExpanded: false }); + }); +}); diff --git a/packages/ui/src/product/preview/visual-graph.test.ts b/packages/ui/src/product/preview/visual-graph.test.ts index 112f12a..da37c11 100644 --- a/packages/ui/src/product/preview/visual-graph.test.ts +++ b/packages/ui/src/product/preview/visual-graph.test.ts @@ -72,7 +72,14 @@ vi.mock("@subboost/ui/store/config-store", () => ({ })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ PROXY_GROUP_MODULES: [ - { id: "select", name: "🚀 节点选择", emoji: "🚀", groupType: "select", category: "core" }, + { + id: "select", + name: "🚀 节点选择", + emoji: "🚀", + groupType: "select", + category: "core", + rules: [{ id: "sel", name: "Select Rule", behavior: "classical" }], + }, { id: "auto", name: "⚡ 自动选择", @@ -212,6 +219,52 @@ describe("VisualGraph", () => { expect(mocks.store.setProxyGroupOrder).toHaveBeenCalledWith(["module:auto"]); }); + it("uses selector defaults when optional store slices are absent", () => { + mocks.generatedProxyGroups = [{ name: "🚀 节点选择", type: "select", proxies: ["DIRECT"] }]; + mocks.customRuleSets = []; + mocks.store = { + nodes: [{ name: "Only", type: "ss" }], + enabledProxyGroups: ["select"], + testUrl: "https://example.com", + testInterval: 300, + ruleProviderBaseUrl: "https://rules.example", + setProxyGroupOrder: vi.fn(), + }; + + const { html } = renderGraph(); + + expect(html).toContain("1"); + expect(mocks.captures.proxyGroupsPreview.displayGroups).toEqual([ + expect.objectContaining({ id: "module:select", name: "🚀 节点选择" }), + ]); + expect(mocks.captures.customRulesPreview).toEqual({ + customRules: [], + ruleSets: [], + }); + }); + + it("shows builtin rules moved from another module into the target module", () => { + mocks.generatedProxyGroups = [{ name: "🚀 节点选择", type: "select", proxies: ["DIRECT"] }]; + mocks.store = { + ...mocks.store, + builtinRuleEdits: { + "module:auto:r1": { target: "🚀 节点选择" }, + "module:auto:missing": { target: "🚀 节点选择" }, + "module:missing:r1": { target: "🚀 节点选择" }, + "module:select:sel": { target: "🚀 节点选择" }, + "not-module": { target: "🚀 节点选择" }, + }, + proxyGroupOrder: [], + }; + + renderGraph(); + + expect(mocks.captures.proxyGroupsPreview.displayGroups[0].rules).toEqual([ + { id: "sel", name: "Select Rule", behavior: "classical" }, + { id: "r1", name: "Rule One", behavior: "domain" }, + ]); + }); + it("falls back to generated order and truncates long node lists", () => { mocks.store.proxyGroupOrder = []; mocks.store.dialerProxyGroups = [{ id: "d2", name: "Disabled", enabled: false }]; @@ -317,4 +370,19 @@ describe("VisualGraph", () => { expect(html).toContain("bg-teal-400"); expect(html).toContain("bg-gray-400"); }); + + it("merges built-in rule edits moved from another module and skips invalid edit records", () => { + mocks.store.builtinRuleEdits = { + "module:auto:r1": { enabled: false }, + "module:select:sel": { target: "⚡ 自动选择" }, + "module:select:missing": { target: "⚡ 自动选择" }, + "module:missing:ghost": { target: "⚡ 自动选择" }, + "not-a-module-key": { target: "⚡ 自动选择" }, + }; + + renderGraph({ 3: 800 }); + const auto = mocks.captures.proxyGroupsPreview.displayGroups.find((group: any) => group.id === "module:auto"); + + expect(auto.rules).toEqual([{ id: "sel", name: "Select Rule", behavior: "classical" }]); + }); }); From 1784677ca80c84e829c960b793bc52e057341c0c Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:27:02 +0800 Subject: [PATCH 25/29] test(store): cover proxy group state actions --- .../actions/custom-actions.test.ts | 57 +++++++++ .../actions/proxy-group-actions.test.ts | 110 ++++++++++++++++++ .../source-actions-multiple.test.ts | 73 ++++++++++++ .../store/config-store/source-actions.test.ts | 22 ++++ 4 files changed, 262 insertions(+) diff --git a/packages/ui/src/store/config-store/actions/custom-actions.test.ts b/packages/ui/src/store/config-store/actions/custom-actions.test.ts index b61cada..0658668 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.test.ts @@ -106,6 +106,63 @@ describe("custom config-store actions", () => { expect(getState().customRuleSets).toEqual([]); }); + it("normalizes advanced custom proxy group fields and keeps blank-name removals narrow", () => { + const { actions, getState } = createHarness({ + customProxyGroups: [ + { id: "blank", name: " ", emoji: "", groupType: "select" }, + ], + customRuleSets: [ + { id: "keep", name: "Keep", behavior: "domain", path: "geosite/keep.mrs", target: "Keep" }, + ], + }); + + actions.addCustomProxyGroup({ + name: "Load Balance", + emoji: "LB", + enabled: false, + description: " Fast nodes ", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + includeRegex: "HK", + sourceIds: ["s1", "s1", ""], + }, + }); + + const groupId = getState().customProxyGroups[1].id; + expect(getState().customProxyGroups[1]).toEqual( + expect.objectContaining({ + advanced: expect.objectContaining({ includeRegex: "HK", sourceIds: ["s1"] }), + description: "Fast nodes", + enabled: false, + includeInGroupMembers: false, + memberSource: "filtered-nodes", + strategy: "round-robin", + }) + ); + + actions.updateCustomProxyGroup(groupId, { + enabled: true, + description: " Updated ", + advanced: { excludeRegex: "US" }, + }); + expect(getState().customProxyGroups[1]).toEqual( + expect.objectContaining({ + advanced: expect.objectContaining({ excludeRegex: "US" }), + description: "Updated", + enabled: true, + }) + ); + + actions.removeCustomProxyGroup("blank"); + expect(getState().customProxyGroups.map((group: { id: string }) => group.id)).toEqual([groupId]); + expect(getState().customRuleSets).toEqual([ + { id: "keep", name: "Keep", behavior: "domain", path: "geosite/keep.mrs", target: "Keep" }, + ]); + }); + it("keeps custom rule targets unchanged when group updates do not rename a group", () => { const { actions, getState } = createHarness({ customRules: [rule({ id: "r1", target: "Stable Group" })], diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts index e28adec..6cfe8e3 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts @@ -96,6 +96,28 @@ describe("createProxyGroupActions", () => { expect(getState()).toBe(beforeRestore); }); + it("normalizes legacy hidden group lists while hiding builtin groups", () => { + const harness = createHarness({ + enabledProxyGroups: ["select", "ai"], + hiddenProxyGroups: "ai" as never, + }); + + harness.actions.hideProxyGroup("ai"); + + expect(harness.getState().hiddenProxyGroups).toEqual(["ai"]); + expect(harness.getState().enabledProxyGroups).toEqual(["select"]); + + const duplicateHarness = createHarness({ + enabledProxyGroups: ["select", "ai"], + hiddenProxyGroups: ["ai"], + }); + + duplicateHarness.actions.hideProxyGroup("ai"); + + expect(duplicateHarness.getState().hiddenProxyGroups).toEqual(["ai"]); + expect(duplicateHarness.getState().enabledProxyGroups).toEqual(["select"]); + }); + it("updates advanced config for builtin proxy groups", () => { const { actions, getState } = createHarness({ proxyGroupAdvanced: { @@ -134,6 +156,16 @@ describe("createProxyGroupActions", () => { expect(getState()).toBe(beforeMissingUpdate); }); + it("updates advanced config from empty state and empty patches", () => { + const { actions, getState } = createHarness({ + proxyGroupAdvanced: undefined, + }); + + actions.updateProxyGroupAdvanced("ai", undefined as never); + + expect(getState().proxyGroupAdvanced.ai).toEqual({}); + }); + it("adds, updates, removes, and restores module rules", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "ai"], @@ -204,6 +236,53 @@ describe("createProxyGroupActions", () => { expect(getState().customRuleSets).toEqual([]); }); + it("disables moved builtin rule edits when removed from custom target groups", () => { + const { actions, getState } = createHarness({ + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + builtinRuleEdits: { + "module:ai:openai": { target: "Custom", enabled: true }, + }, + }); + + actions.removeModuleRule("custom-1", "openai"); + + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "Custom", enabled: false }, + }); + }); + + it("keeps missing rule-set targets as no-ops and retargets moved builtin edits", () => { + const { actions, getState } = createHarness({ + customProxyGroups: [ + { id: "blank", name: " ", emoji: "", groupType: "select" }, + ], + customRuleSets: [ + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }, + ], + builtinRuleEdits: { + "module:ai:openai": { target: "🤖 AI 服务", enabled: false }, + }, + }); + + const beforeMissingTargets = getState(); + actions.addModuleRules("blank", [ + { id: "ignored", name: "Ignored", behavior: "domain", path: "geosite/ignored.mrs" }, + ]); + actions.updateModuleRule("blank", "custom-ai", { name: "Ignored" }); + expect(getState()).toBe(beforeMissingTargets); + + actions.removeModuleRule("ai", "openai"); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "🤖 AI 服务", enabled: false }, + }); + + actions.moveModuleRule("ai", "openai", { kind: "module", id: "youtube" }); + expect(getState().enabledProxyGroups).toContain("youtube"); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "📹 油管视频" }, + }); + }); + it("keeps full rule order positions across preset rule remove, restore, hide, and move", () => { const enabledProxyGroups = PROXY_GROUP_MODULES.map((module) => module.id); const baseRuleOptions = { @@ -375,6 +454,7 @@ describe("createProxyGroupActions", () => { }); actions.restoreModuleDefaultRules("ai"); + actions.restoreModuleDefaultRules("missing"); expect(getState().builtinRuleEdits).toEqual({ "module:youtube:youtube": { enabled: false } }); actions.restoreModuleDefaultRules(""); @@ -385,6 +465,21 @@ describe("createProxyGroupActions", () => { expect(getState().moduleRuleEditWarningAccepted).toBe(true); }); + it("keeps reset rule target no-ops stable for invalid or already-default rules", () => { + const { actions, getState } = createHarness({ + builtinRuleEdits: {}, + }); + + const before = getState(); + actions.resetModuleRuleTarget("", "openai"); + actions.resetModuleRuleTarget("ai", ""); + actions.resetModuleRuleTarget("missing", "openai"); + actions.resetModuleRuleTarget("ai", "missing"); + actions.resetModuleRuleTarget("ai", "openai"); + + expect(getState()).toBe(before); + }); + it("moves module rules into another builtin group or a custom group", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "ai"], @@ -509,6 +604,13 @@ describe("createProxyGroupActions", () => { { id: "rule-1", type: "DOMAIN", value: "example.com", target: "🤖 AI 服务" }, { id: "rule-2", type: "DOMAIN", value: "example.net", target: "🚀 节点选择" }, ], + customRuleSets: [ + { id: "rs-1", name: "RS", behavior: "domain", path: "geosite/rs.mrs", target: "🤖 AI 服务" }, + { id: "rs-2", name: "Other", behavior: "domain", path: "geosite/other.mrs", target: "🚀 节点选择" }, + ], + builtinRuleEdits: { + "module:ai:openai": { target: "🤖 AI 服务" }, + }, }); actions.setProxyGroupNameOverride("select", "Main"); @@ -519,10 +621,15 @@ describe("createProxyGroupActions", () => { expect(getState().proxyGroupNameOverrides).toEqual({ ai: "Labs" }); expect(getState().customRules[0].target).toBe("🤖 Labs"); expect(getState().customRules[1].target).toBe("🚀 节点选择"); + expect(getState().customRuleSets[0].target).toBe("🤖 Labs"); + expect(getState().customRuleSets[1].target).toBe("🚀 节点选择"); + expect(getState().builtinRuleEdits["module:ai:openai"].target).toBe("🤖 Labs"); actions.setProxyGroupNameOverride("ai", ""); expect(getState().proxyGroupNameOverrides).toEqual({ ai: "" }); expect(getState().customRules[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[1].target).toBe("🚀 节点选择"); actions.setProxyGroupNameOverride("ai", "Labs"); actions.clearProxyGroupNameOverride("ai"); @@ -530,6 +637,9 @@ describe("createProxyGroupActions", () => { actions.clearProxyGroupNameOverride("select"); expect(getState().proxyGroupNameOverrides).toEqual({}); expect(getState().customRules[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[1].target).toBe("🚀 节点选择"); + expect(getState().builtinRuleEdits["module:ai:openai"].target).toBe("🤖 AI 服务"); }); it("renames groups when override maps are not initialized", () => { diff --git a/packages/ui/src/store/config-store/source-actions-multiple.test.ts b/packages/ui/src/store/config-store/source-actions-multiple.test.ts index da57f7e..53c4c0c 100644 --- a/packages/ui/src/store/config-store/source-actions-multiple.test.ts +++ b/packages/ui/src/store/config-store/source-actions-multiple.test.ts @@ -207,6 +207,29 @@ describe("createSourceActions parseMultipleSources", () => { }); }); + it("ignores non-numeric URL userinfo and keeps raw URL content when normalization fails", async () => { + mocks.fetchUrlContentInBrowser.mockResolvedValueOnce({ + content: "ss://remote", + headers: { + "subscription-userinfo": "upload=bad; expire=never", + }, + }); + mocks.parseSubscription.mockReturnValueOnce(parseResult([node("Remote")])); + const sources = [source({ id: "url-ok", type: "url", content: "not a url" })]; + const { actions, getState } = createHarness({ sources }); + + await actions.parseMultipleSources(sources); + + expect(getState().nodes).toEqual([ + expect.objectContaining({ name: "Remote", _originName: "Remote", _sourceIds: ["url-ok"] }), + ]); + expect(getState().sources[0]).toMatchObject({ + parsed: true, + subscriptionUserInfo: undefined, + lastParsedContent: "not a url", + }); + }); + it("merges duplicate parsed nodes and prunes stale listener ports and dialer nodes", async () => { const duplicate = node("Duplicate", { server: "same.example.com", @@ -285,4 +308,54 @@ describe("createSourceActions parseMultipleSources", () => { }); expect(getState().listenerPorts).toEqual({ Duplicate: 41000 }); }); + + it("keeps repeated origins when multiple distinct deleted-origin nodes are reimported", async () => { + mocks.parseSubscription.mockReturnValueOnce( + parseResult([ + node("Shared Origin", { + _originName: "Shared Origin", + server: "one.example.com", + }), + node("Shared Origin", { + _originName: "Shared Origin", + server: "two.example.com", + }), + ]) + ); + const sources = [source({ id: "s1", type: "yaml", content: "one" })]; + const { actions, getState } = createHarness({ + sources, + deletedNodeNames: ["Shared Origin"], + }); + + await actions.parseMultipleSources(sources); + + expect(getState().nodes).toEqual([ + expect.objectContaining({ name: "Shared Origin", _originName: "Shared Origin", server: "one.example.com" }), + expect.objectContaining({ name: "Shared Origin (2)", _originName: "Shared Origin", server: "two.example.com" }), + ]); + }); + + it("filters deleted node identity records while keeping blank-origin fallbacks", async () => { + const deleted = node("Gone"); + const blankOrigin = node("Blank Origin", { _originName: " " }); + mocks.parseSubscription.mockReturnValueOnce(parseResult([deleted, blankOrigin, node("Fresh")])); + const sources = [source({ id: "s1", type: "yaml", content: "one" })]; + const { actions, getState } = createHarness({ + sources, + deletedNodes: [ + { originName: " Gone ", node: deleted }, + { node: null }, + { originName: " ", node: blankOrigin }, + ], + }); + + await actions.parseMultipleSources(sources); + + expect(getState().nodes.map((item: ParsedNode) => item.name)).toEqual(["Blank Origin", "Fresh"]); + expect(getState().sources[0]).toMatchObject({ + parsed: true, + nodeCount: 3, + }); + }); }); diff --git a/packages/ui/src/store/config-store/source-actions.test.ts b/packages/ui/src/store/config-store/source-actions.test.ts index 63d4c91..c3baf95 100644 --- a/packages/ui/src/store/config-store/source-actions.test.ts +++ b/packages/ui/src/store/config-store/source-actions.test.ts @@ -337,6 +337,28 @@ describe("createSourceActions", () => { }); }); + it("keeps original URL text when a fetched single source cannot be normalized", async () => { + mocks.fetchUrlContentInBrowser.mockResolvedValueOnce({ + content: "ss://remote", + headers: {}, + }); + mocks.parseSubscription.mockReturnValueOnce(parseResult([node("Remote Node")])); + const { actions, getState } = createHarness({ + sources: [source({ id: "s1", type: "url", content: "not a url" })], + }); + + await actions.parseSingleSource("s1"); + + expect(getState().nodes).toEqual([ + expect.objectContaining({ name: "Remote Node", _originName: "Remote Node", _sourceIds: ["s1"] }), + ]); + expect(getState().sources[0]).toMatchObject({ + parsed: true, + lastParsedContent: "not a url", + subscriptionUserInfo: undefined, + }); + }); + it("reimports changed URL sources while preserving unrelated source state", async () => { mocks.fetchUrlContentInBrowser.mockResolvedValueOnce({ content: "ss://fresh", From ece904a0603172340f70ce0eed53054847e94296 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:17:59 +0800 Subject: [PATCH 26/29] fix: tighten custom proxy group editor layout --- .../proxy-groups-added-rule-sets.test.ts | 4 +- .../proxy-groups-custom-groups-panel.test.ts | 59 +++++++++---------- .../proxy-groups-custom-groups-panel.tsx | 36 ++++------- .../sections/proxy-groups-module-card.tsx | 33 +++++++---- 4 files changed, 62 insertions(+), 70 deletions(-) diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts index d5291bc..da0962b 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts @@ -252,8 +252,8 @@ describe("ProxyGroupsAddedRuleSets", () => { switches: [], }; mocks.ruleSets = [moduleItem, customItem]; - for (const module of PROXY_GROUP_MODULES as Array<{ rules?: unknown[] }>) { - module.rules = []; + for (const proxyModule of PROXY_GROUP_MODULES as Array<{ rules?: unknown[] }>) { + proxyModule.rules = []; } mocks.effectiveRules = []; mocks.store = { 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 b0a7dcc..abbc63c 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 @@ -25,7 +25,11 @@ vi.mock("react", async (importOriginal) => { useState: (initial: unknown) => { if (!stateMock.enabled) return actual.useState(initial); const index = stateMock.callIndex++; - const value = Object.prototype.hasOwnProperty.call(stateMock.overrides, index) ? stateMock.overrides[index] : initial; + const value = Object.prototype.hasOwnProperty.call(stateMock.overrides, index) + ? stateMock.overrides[index] + : typeof initial === "function" + ? (initial as () => unknown)() + : initial; const setter = vi.fn((next: unknown) => { const resolved = typeof next === "function" ? (next as (prev: unknown) => unknown)(value) : next; (setter as any).lastValue = resolved; @@ -185,8 +189,9 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }; }); - it("adds groups, changes draft type, and rejects duplicates", () => { - const { setters } = renderPanel({ 1: { emoji: "🧩", name: "New" }, 2: "Useful group", 3: "select", 4: "consistent-hashing" }); + it("adds manual groups with a random reset emoji and rejects duplicates", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + const { setters } = renderPanel({ 1: { emoji: "🧩", name: "New" }, 2: "Useful group" }); const nameInput = mocks.captures.inputs.find((props: any) => props.placeholder === "自定义分组名称"); nameInput.onChange({ target: { value: "Typed" } }); @@ -195,11 +200,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { descriptionInput.onChange({ target: { value: "Next description" } }); expect(setters[2]).toHaveBeenCalledWith("Next description"); - mocks.captures.typeMenus[0].onChange({ groupType: "load-balance", strategy: "round-robin" }); - expect(setters[3]).toHaveBeenCalledWith("load-balance"); - expect(setters[4]).toHaveBeenCalledWith("round-robin"); - mocks.captures.typeMenus[0].onChange({ groupType: "select" }); - expect(setters[3]).toHaveBeenCalledWith("select"); + expect(mocks.captures.typeMenus.every((props: any) => props.trigger)).toBe(true); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ @@ -209,7 +210,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { groupType: "select", }); expect(mocks.interactions.proxyGroupAdded).toHaveBeenCalledWith({ groupType: "select" }); - expect(setters[1]).toHaveBeenCalledWith({ emoji: "🧩", name: "" }); + expect(setters[1]).toHaveBeenCalledWith({ emoji: "🚀", name: "" }); expect(setters[2]).toHaveBeenCalledWith(""); renderPanel({ 1: { emoji: "🧩", name: "Custom" } }); @@ -217,28 +218,27 @@ describe("ProxyGroupsCustomGroupsPanel", () => { expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "代理组名称已存在,请换一个名称。", variant: "warning" })); }); - it("ignores blank new group names and keeps load-balance strategy on add", () => { - renderPanel({ 1: { emoji: "🧩", name: " " }, 3: "select" }); + it("ignores blank new group names and always adds as manual select", () => { + renderPanel({ 1: { emoji: "🧩", name: " " } }); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).not.toHaveBeenCalled(); - renderPanel({ 1: { emoji: "🧩", name: "Balanced" }, 2: " LB group ", 3: "load-balance", 4: "round-robin" }); + renderPanel({ 1: { emoji: "🧩", name: "Balanced" }, 2: " LB group " }); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ name: "🧩 Balanced", emoji: "🧩", description: "LB group", - groupType: "load-balance", - strategy: "round-robin", + groupType: "select", }); }); it("renames, removes, and changes existing group type", () => { - const { setters } = renderPanel({ 0: new Set(["custom-1"]), 5: "custom-1", 6: "🧩 Renamed", 7: "" }); + const { setters } = renderPanel({ 0: new Set(["custom-1"]), 3: "custom-1", 4: "🧩 Renamed", 5: "" }); const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); renameInput.onChange({ target: { value: "Typed Rename" } }); - expect(setters[6]).toHaveBeenCalledWith("🧩 Typed Rename"); + expect(setters[4]).toHaveBeenCalledWith("🧩 Typed Rename"); renameInput.onKeyDown({ key: "Enter" }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { name: "🧩 Renamed", @@ -246,10 +246,10 @@ describe("ProxyGroupsCustomGroupsPanel", () => { description: "", }); renameInput.onKeyDown({ key: "Escape" }); - expect(setters[5]).toHaveBeenCalledWith(null); + expect(setters[3]).toHaveBeenCalledWith(null); renderPanel({ 0: new Set(["custom-1"]) }); - mocks.captures.typeMenus[1].onChange({ groupType: "load-balance", strategy: "round-robin" }); + mocks.captures.typeMenus[0].onChange({ groupType: "load-balance", strategy: "round-robin" }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { groupType: "load-balance", strategy: "round-robin", @@ -308,7 +308,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { 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"]), 5: "custom-1", 6: "🧩 Target", 7: "" }); + const { setters } = renderPanel({ 0: new Set(["custom-1"]), 3: "custom-1", 4: "🧩 Target", 5: "" }); const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); renameInput.onKeyDown({ key: "Enter" }); @@ -317,25 +317,24 @@ describe("ProxyGroupsCustomGroupsPanel", () => { ); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); - const editButtons = mocks.captures.buttons.filter((props: any) => props.className === "h-7 px-2" && !props.title); - editButtons[0].onClick(); + mocks.captures.buttons.find((props: any) => props.title === "保存").onClick(); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); - editButtons[1].onClick(); - expect(setters[5]).toHaveBeenCalledWith(null); - expect(setters[6]).toHaveBeenCalledWith(""); - expect(setters[7]).toHaveBeenCalledWith(""); + mocks.captures.buttons.find((props: any) => props.title === "取消").onClick(); + expect(setters[3]).toHaveBeenCalledWith(null); + expect(setters[4]).toHaveBeenCalledWith(""); + expect(setters[5]).toHaveBeenCalledWith(""); renderPanel({ 0: new Set(["custom-1"]) }); const renameButton = mocks.captures.buttons.find((props: any) => props.title === "改名"); const stopRenameClick = vi.fn(); renameButton.onClick({ stopPropagation: stopRenameClick }); expect(stopRenameClick).toHaveBeenCalled(); - expect(stateMock.setters[5]).toHaveBeenCalledWith("custom-1"); - expect(stateMock.setters[6]).toHaveBeenCalledWith("🧩 Custom"); - expect(stateMock.setters[7]).toHaveBeenCalledWith(""); + expect(stateMock.setters[3]).toHaveBeenCalledWith("custom-1"); + expect(stateMock.setters[4]).toHaveBeenCalledWith("🧩 Custom"); + expect(stateMock.setters[5]).toHaveBeenCalledWith(""); - mocks.captures.typeMenus[1].onChange({ groupType: "select" }); + mocks.captures.typeMenus[0].onChange({ groupType: "select" }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { groupType: "select", strategy: undefined, @@ -343,7 +342,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }); it("ignores blank custom group rename commits", () => { - renderPanel({ 0: new Set(["custom-1"]), 5: "custom-1", 6: " ", 7: "desc" }); + renderPanel({ 0: new Set(["custom-1"]), 3: "custom-1", 4: " ", 5: "desc" }); const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); renameInput.onKeyDown({ key: "Enter" }); 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 7793285..8c7b384 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 @@ -9,7 +9,7 @@ import { PROXY_GROUP_MODULES, type ProxyGroupModule } from "@subboost/core/gener import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { DEFAULT_LOAD_BALANCE_STRATEGY, type LoadBalanceStrategy, type ProxyGroupGroupType } from "@subboost/core/types/config"; -import { useConfigStore, type CustomProxyGroup } from "@subboost/ui/store/config-store"; +import { useConfigStore } from "@subboost/ui/store/config-store"; import { useProductInteractionAdapter } from "@subboost/ui/product/interactions"; import { buildManualRuleTargets, @@ -31,6 +31,7 @@ import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; import { buildProxyGroupName, parseProxyGroupNameDraft, + pickRandomEmoji, ProxyGroupNameEditor, toProxyGroupNameDraft, type ProxyGroupNameDraft, @@ -62,14 +63,11 @@ export function ProxyGroupsCustomGroupsPanel({ } = useConfigStore(); const [expandedCustomGroups, setExpandedCustomGroups] = React.useState>(new Set()); - const [newCustomGroupDraft, setNewCustomGroupDraft] = React.useState({ - emoji: "🧩", + const [newCustomGroupDraft, setNewCustomGroupDraft] = React.useState(() => ({ + emoji: pickRandomEmoji(), name: "", - }); + })); const [newCustomGroupDescription, setNewCustomGroupDescription] = React.useState(""); - const [newCustomGroupType, setNewCustomGroupType] = React.useState("select"); - const [newCustomGroupStrategy, setNewCustomGroupStrategy] = - React.useState(DEFAULT_LOAD_BALANCE_STRATEGY); const [editingCustomGroupId, setEditingCustomGroupId] = React.useState(null); const [editingCustomGroupName, setEditingCustomGroupName] = React.useState(""); const [editingCustomGroupDescription, setEditingCustomGroupDescription] = React.useState(""); @@ -185,8 +183,8 @@ export function ProxyGroupsCustomGroupsPanel({ return (
{/* 新建自定义分组 */} -
-
+
+
-
- { - setNewCustomGroupType(groupType as CustomProxyGroup["groupType"]); - if (groupType === "load-balance") { - setNewCustomGroupStrategy(strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY); - } - }} - triggerClassName="h-7 text-[10px]" - /> -
-
From 03ac47189907ac27f43ae77a435926f23e1dd3d0 Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:21:15 +0800 Subject: [PATCH 27/29] test: update custom group card mock --- .../sections/proxy-groups-custom-groups-panel-card-props.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts index a352c89..62bae9f 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts @@ -72,6 +72,7 @@ vi.mock("./proxy-group-name-editor", () => ({ return name ? `${draft.emoji?.trim() || "C"} ${name}` : ""; }, parseProxyGroupNameDraft: (value: string, emoji: string) => ({ emoji, name: value.replace(/^\\S+\\s+/, "") }), + pickRandomEmoji: () => "C", ProxyGroupNameEditor: () => null, toProxyGroupNameDraft: (value: { emoji?: string; name?: string }) => value, })); From 4d997b17ab91ce4386d0d4915560e1347deeeebd Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:46:09 +0800 Subject: [PATCH 28/29] chore: prepare SubBoost v2.5.0 release --- README-CN.md | 2 +- README.md | 2 +- docs/release-notes.md | 58 ++++++++++++++++++++++++------------------- local/package.json | 2 +- package-lock.json | 6 ++--- package.json | 2 +- 6 files changed, 40 insertions(+), 32 deletions(-) diff --git a/README-CN.md b/README-CN.md index 498a810..6497d91 100644 --- a/README-CN.md +++ b/README-CN.md @@ -4,7 +4,7 @@

SubBoost

平台:Linux + Docker - 版本 2.4.0 + 版本 2.5.0 在线入口 文档 GHCR 镜像 diff --git a/README.md b/README.md index 564b238..54eeac5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

SubBoost

Platform: Linux + Docker - Version 2.4.0 + Version 2.5.0 Online app Documentation GHCR image diff --git a/docs/release-notes.md b/docs/release-notes.md index 26d5fb0..2b528d2 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,41 +1,49 @@ -# SubBoost v2.4.0 +# SubBoost v2.5.0 ## 中文 -### 初始发布 +### 更新重点 -这是 SubBoost 的首次公开发布。这个版本提供一个可以自行部署的 SubBoost 包,适合希望在自己的服务器上运行 SubBoost,并管理订阅转换、模板和规则配置的用户。 +SubBoost v2.5.0 主要改善代理组编辑、自部署更新和订阅生成稳定性。建议 v2.4.0 用户升级。 -### 包含内容 +### 主要变化 -- 一键部署脚本和 Docker Compose 配置,用于在 Linux 服务器上安装 SubBoost。 -- 本地 Web 管理界面,用于创建管理员账号,并管理订阅、模板和规则。 -- 共享的订阅解析、订阅生成、模板处理和规则处理能力。 -- GitHub Release 资产:`install.sh`、`release.json`、`docker-compose.image.yml` 和 `subboost-manager`。 -- 基于 AGPL-3.0-only 许可证发布的公开源码。 +- 新增高级代理组模式,自定义分组、规则集和手动规则的编辑状态会更稳定地保存。 +- 自定义代理组入口更统一,可以更方便地按来源、地区、关键词和排除条件整理节点。 +- 订阅生成更稳,规则顺序、代理组输出和常见 Mihomo 字段处理减少了意外变化。 +- 节点导入兼容性更好,覆盖更多常见节点链接和 Clash/Mihomo YAML 配置。 +- 自部署安装和更新流程更可靠,`subboost update`、状态检查和失败提示都有改进。 +- Dashboard 下载订阅 YAML 的行为更接近直接访问订阅链接,文件名和响应头更稳定。 +- 首次安装后的管理员初始化、登录和数据库连接更稳,减少安装完成后进不去后台的情况。 +- 安全和发布检查加强,降低公开包、安装资产和更新流程出错的风险。 -### 安装和更新 +### 升级说明 -- 这是首次发布,新安装不需要人工迁移。 -- 安装后,后续版本可以继续使用 `subboost update` 更新。 -- 建议妥善保存 `/opt/subboost/.env` 和数据库备份,方便以后迁移或恢复。 +- 建议升级前备份 `/opt/subboost/.env` 和数据库,方便需要时回滚。 +- 已安装 v2.4.0 的自部署实例可以继续使用 `subboost update` 更新。 +- 普通订阅转换、模板和规则功能不需要手动改环境变量。 +- 如果你在 v2.4.0 使用过筛选代理组,请升级后打开自定义代理组检查输出结果;必要时用新的高级代理组重新配置。 ## English -### Initial Release +### Highlights -This is the first public release of SubBoost. This version provides a self-hostable SubBoost package for users who want to run SubBoost on their own server and manage subscription conversion, templates, and rule configuration. +SubBoost v2.5.0 mainly improves proxy group editing, self-hosted updates, and subscription generation stability. v2.4.0 users are encouraged to upgrade. -### What's Included +### Main Changes -- A one-click deployment script and Docker Compose configuration for installing SubBoost on a Linux server. -- A local web management interface for creating an administrator account and managing subscriptions, templates, and rules. -- Shared subscription parsing, subscription generation, template processing, and rule processing capabilities. -- GitHub Release assets: `install.sh`, `release.json`, `docker-compose.image.yml`, and `subboost-manager`. -- Public source code released under the AGPL-3.0-only license. +- Added advanced proxy group mode, with more reliable persistence for custom groups, rule sets, and manual rules. +- Unified the custom proxy group entry point, making it easier to organize nodes by source, region, keyword, and exclusion rules. +- Made subscription generation more stable, reducing unexpected changes in rule order, proxy group output, and common Mihomo fields. +- Improved node import compatibility for more common node links and Clash/Mihomo YAML configurations. +- Made self-hosted install and update flows more reliable, including `subboost update`, status checks, and failure messages. +- Dashboard YAML downloads now behave more like direct subscription links, with steadier filenames and response headers. +- Improved first-install admin setup, login, and database connection reliability to reduce post-install access issues. +- Strengthened safety and release checks to reduce the risk of problems in public packages, install assets, and updates. -### Installation and Updates +### Upgrade Notes -- This is the first release, so new installations do not require manual migration. -- After installation, future versions can continue to be updated with `subboost update`. -- Keep `/opt/subboost/.env` and database backups safe so future migration or recovery is easier. +- Back up `/opt/subboost/.env` and the database before upgrading so rollback is easier if needed. +- Existing v2.4.0 self-hosted installations can continue to update with `subboost update`. +- Normal subscription conversion, templates, and rules do not require manual environment-variable changes. +- If you used filtered proxy groups in v2.4.0, open the custom proxy group editor after upgrading and check the generated output. Recreate those groups with the new advanced proxy group controls if needed. diff --git a/local/package.json b/local/package.json index d30c308..a57f440 100644 --- a/local/package.json +++ b/local/package.json @@ -1,6 +1,6 @@ { "name": "@subboost/local", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "private": true, "type": "module", diff --git a/package-lock.json b/package-lock.json index cb9fe72..3340d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "subboost", - "version": "2.4.0", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "subboost", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "workspaces": [ "packages/*", @@ -67,7 +67,7 @@ }, "local": { "name": "@subboost/local", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "dependencies": { "@prisma/adapter-pg": "^7.8.0", diff --git a/package.json b/package.json index afef866..def6930 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subboost", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "private": true, "engines": { From be5c9853350afd9cbaa644648b29299313e9edfc Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:50:57 +0800 Subject: [PATCH 29/29] fix: avoid regex backtracking in rule paths --- packages/core/src/rules/rule-model.test.ts | 4 ++-- packages/core/src/rules/rule-model.ts | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/core/src/rules/rule-model.test.ts b/packages/core/src/rules/rule-model.test.ts index ebc582e..c625390 100644 --- a/packages/core/src/rules/rule-model.test.ts +++ b/packages/core/src/rules/rule-model.test.ts @@ -12,12 +12,12 @@ describe("rule model normalization", () => { it("normalizes rule set paths, URLs, and builtin edits", () => { expect(extractRuleSetPathFromUrl(" https://example.com/meta/geosite/google.mrs?download=1 ")).toBe("geosite/google.mrs"); expect(extractRuleSetPathFromUrl("custom/list.txt")).toBe("custom/list.txt"); - expect(normalizeRuleSetPathInput("/geoip/private.mrs")).toBe("geoip/private.mrs"); + expect(normalizeRuleSetPathInput("////geoip/private.mrs")).toBe("geoip/private.mrs"); expect(isValidRuleSetPathOrUrl("geosite/google.mrs")).toBe(true); expect(isValidRuleSetPathOrUrl("https://example.com/rules/list.txt")).toBe(true); expect(isValidRuleSetPathOrUrl("plain.txt")).toBe(false); expect(buildRuleSetUrlFromPath("https://cdn.example.com/custom.mrs", "https://base.example.com/")).toBe("https://cdn.example.com/custom.mrs"); - expect(buildRuleSetUrlFromPath("/geosite/google.mrs", "https://base.example.com/")).toBe("https://base.example.com/geosite/google.mrs"); + expect(buildRuleSetUrlFromPath("/geosite/google.mrs", "https://base.example.com////")).toBe("https://base.example.com/geosite/google.mrs"); expect(normalizeBuiltinRuleEdits(null)).toEqual({}); expect( normalizeBuiltinRuleEdits({ diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts index 50866d5..399c74a 100644 --- a/packages/core/src/rules/rule-model.ts +++ b/packages/core/src/rules/rule-model.ts @@ -19,15 +19,27 @@ function toTrimmedString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function trimLeadingSlashes(value: string): string { + let index = 0; + while (index < value.length && value.charCodeAt(index) === 47) index += 1; + return value.slice(index); +} + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1; + return value.slice(0, end); +} + export function extractRuleSetPathFromUrl(url: string): string { const trimmed = url.trim(); const match = trimmed.match(/(?:^|\/)(geosite|geoip)\/[^/?#\s]+\.mrs/i); if (!match) return trimmed; - return match[0].replace(/^\/+/, ""); + return trimLeadingSlashes(match[0]); } export function normalizeRuleSetPathInput(input: string): string { - return extractRuleSetPathFromUrl(input).replace(/^\/+/, "").trim(); + return trimLeadingSlashes(extractRuleSetPathFromUrl(input)).trim(); } export function isValidRuleSetPathOrUrl(value: string): boolean { @@ -38,7 +50,7 @@ export function isValidRuleSetPathOrUrl(value: string): boolean { export function buildRuleSetUrlFromPath(path: string, baseUrl: string): string { const normalizedPath = normalizeRuleSetPathInput(path); if (/^https?:\/\//i.test(normalizedPath)) return normalizedPath; - return `${baseUrl.replace(/\/+$/, "")}/${normalizedPath}`; + return `${trimTrailingSlashes(baseUrl)}/${normalizedPath}`; } function normalizeBehavior(value: unknown): RuleSetBehavior | null {