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