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/README-CN.md b/README-CN.md index 376febd..6497d91 100644 --- a/README-CN.md +++ b/README-CN.md @@ -4,7 +4,7 @@

SubBoost

平台:Linux + Docker - 版本 2.4.0 + 版本 2.5.0 在线入口 文档 GHCR 镜像 @@ -19,7 +19,7 @@ - **订阅转换**:支持订阅链接、YAML 文件和节点链接等多种格式导入。 - **节点管理**:支持批量对节点重命名、删除或配置监听端口。 -- **节点筛选**:可按导入源、地区和自定义规则,构建只有部分节点的 `筛选代理组`。 +- **节点筛选**:可在分流组高级模式中按导入源、地区和自定义规则筛选节点。 - **链式代理**:一键可视化配置链式代理和 `中转代理组`。 - **精确分流**:内置 30 多个常用代理组和 2000 多条远程规则集供启用。 - **规则管理**:可修改规则顺序,供高级用户深度自定义。 diff --git a/README.md b/README.md index 564b238..54eeac5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

SubBoost

Platform: Linux + Docker - Version 2.4.0 + Version 2.5.0 Online app Documentation GHCR image diff --git a/docs/release-notes.md b/docs/release-notes.md index 26d5fb0..2b528d2 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,41 +1,49 @@ -# SubBoost v2.4.0 +# SubBoost v2.5.0 ## 中文 -### 初始发布 +### 更新重点 -这是 SubBoost 的首次公开发布。这个版本提供一个可以自行部署的 SubBoost 包,适合希望在自己的服务器上运行 SubBoost,并管理订阅转换、模板和规则配置的用户。 +SubBoost v2.5.0 主要改善代理组编辑、自部署更新和订阅生成稳定性。建议 v2.4.0 用户升级。 -### 包含内容 +### 主要变化 -- 一键部署脚本和 Docker Compose 配置,用于在 Linux 服务器上安装 SubBoost。 -- 本地 Web 管理界面,用于创建管理员账号,并管理订阅、模板和规则。 -- 共享的订阅解析、订阅生成、模板处理和规则处理能力。 -- GitHub Release 资产:`install.sh`、`release.json`、`docker-compose.image.yml` 和 `subboost-manager`。 -- 基于 AGPL-3.0-only 许可证发布的公开源码。 +- 新增高级代理组模式,自定义分组、规则集和手动规则的编辑状态会更稳定地保存。 +- 自定义代理组入口更统一,可以更方便地按来源、地区、关键词和排除条件整理节点。 +- 订阅生成更稳,规则顺序、代理组输出和常见 Mihomo 字段处理减少了意外变化。 +- 节点导入兼容性更好,覆盖更多常见节点链接和 Clash/Mihomo YAML 配置。 +- 自部署安装和更新流程更可靠,`subboost update`、状态检查和失败提示都有改进。 +- Dashboard 下载订阅 YAML 的行为更接近直接访问订阅链接,文件名和响应头更稳定。 +- 首次安装后的管理员初始化、登录和数据库连接更稳,减少安装完成后进不去后台的情况。 +- 安全和发布检查加强,降低公开包、安装资产和更新流程出错的风险。 -### 安装和更新 +### 升级说明 -- 这是首次发布,新安装不需要人工迁移。 -- 安装后,后续版本可以继续使用 `subboost update` 更新。 -- 建议妥善保存 `/opt/subboost/.env` 和数据库备份,方便以后迁移或恢复。 +- 建议升级前备份 `/opt/subboost/.env` 和数据库,方便需要时回滚。 +- 已安装 v2.4.0 的自部署实例可以继续使用 `subboost update` 更新。 +- 普通订阅转换、模板和规则功能不需要手动改环境变量。 +- 如果你在 v2.4.0 使用过筛选代理组,请升级后打开自定义代理组检查输出结果;必要时用新的高级代理组重新配置。 ## English -### Initial Release +### Highlights -This is the first public release of SubBoost. This version provides a self-hostable SubBoost package for users who want to run SubBoost on their own server and manage subscription conversion, templates, and rule configuration. +SubBoost v2.5.0 mainly improves proxy group editing, self-hosted updates, and subscription generation stability. v2.4.0 users are encouraged to upgrade. -### What's Included +### Main Changes -- A one-click deployment script and Docker Compose configuration for installing SubBoost on a Linux server. -- A local web management interface for creating an administrator account and managing subscriptions, templates, and rules. -- Shared subscription parsing, subscription generation, template processing, and rule processing capabilities. -- GitHub Release assets: `install.sh`, `release.json`, `docker-compose.image.yml`, and `subboost-manager`. -- Public source code released under the AGPL-3.0-only license. +- Added advanced proxy group mode, with more reliable persistence for custom groups, rule sets, and manual rules. +- Unified the custom proxy group entry point, making it easier to organize nodes by source, region, keyword, and exclusion rules. +- Made subscription generation more stable, reducing unexpected changes in rule order, proxy group output, and common Mihomo fields. +- Improved node import compatibility for more common node links and Clash/Mihomo YAML configurations. +- Made self-hosted install and update flows more reliable, including `subboost update`, status checks, and failure messages. +- Dashboard YAML downloads now behave more like direct subscription links, with steadier filenames and response headers. +- Improved first-install admin setup, login, and database connection reliability to reduce post-install access issues. +- Strengthened safety and release checks to reduce the risk of problems in public packages, install assets, and updates. -### Installation and Updates +### Upgrade Notes -- This is the first release, so new installations do not require manual migration. -- After installation, future versions can continue to be updated with `subboost update`. -- Keep `/opt/subboost/.env` and database backups safe so future migration or recovery is easier. +- Back up `/opt/subboost/.env` and the database before upgrading so rollback is easier if needed. +- Existing v2.4.0 self-hosted installations can continue to update with `subboost update`. +- Normal subscription conversion, templates, and rules do not require manual environment-variable changes. +- If you used filtered proxy groups in v2.4.0, open the custom proxy group editor after upgrading and check the generated output. Recreate those groups with the new advanced proxy group controls if needed. diff --git a/local/app/local-pages.test.ts b/local/app/local-pages.test.ts index 5685976..2201aea 100644 --- a/local/app/local-pages.test.ts +++ b/local/app/local-pages.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + buttons: [] as any[], dashboardAdapter: null as any, homeAdapter: null as any, readJsonResponse: vi.fn(), @@ -22,7 +23,10 @@ vi.mock("lucide-react", () => ({ })); vi.mock("@subboost/ui/components/ui/button", () => ({ - Button: (props: any) => React.createElement("button", props, props.children), + Button: (props: any) => { + mocks.buttons.push(props); + return React.createElement("button", props, props.children); + }, })); vi.mock("@subboost/ui/components/ui/card", () => ({ @@ -62,7 +66,12 @@ vi.mock("@subboost/ui/templates/template-library-surface", () => ({ }, })); +vi.mock("@local/components/local-login", () => ({ + LocalLogin: () => React.createElement("main", null, "LocalLogin"), +})); + import DashboardPage from "./dashboard/page"; +import LoginPage from "./login/page"; import SettingsPage from "./dashboard/settings/page"; import manifest from "./manifest"; import HomePage from "./page"; @@ -75,6 +84,7 @@ describe("local app pages and adapters", () => { mocks.dashboardAdapter = null; mocks.homeAdapter = null; mocks.templateAdapter = null; + mocks.buttons = []; mocks.userState = { fetchUser: vi.fn(), logout: vi.fn(), @@ -95,6 +105,10 @@ describe("local app pages and adapters", () => { }); }); + it("renders the local login page wrapper", () => { + expect(renderToStaticMarkup(React.createElement(LoginPage))).toBe("

LocalLogin
"); + }); + it("connects the local home adapter to local API routes", async () => { const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); vi.stubGlobal("fetch", fetchMock); @@ -149,8 +163,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 +176,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, @@ -200,11 +214,16 @@ describe("local app pages and adapters", () => { expect(fetchMock).toHaveBeenCalledWith("/api/templates?id=tpl%201", { method: "DELETE" }); }); - it("renders local settings for anonymous and authenticated states", () => { + it("renders local settings for anonymous and authenticated states", async () => { let html = renderToStaticMarkup(React.createElement(SettingsPage)); expect(html).toContain("未登录"); expect(html).toContain("/api/health/live"); + expect(mocks.buttons.find((button: any) => button.variant === "destructive")).toMatchObject({ + disabled: true, + }); + vi.stubGlobal("window", { location: { href: "" } }); + mocks.buttons = []; mocks.userState = { fetchUser: vi.fn(), logout: vi.fn(), @@ -213,5 +232,12 @@ describe("local app pages and adapters", () => { html = renderToStaticMarkup(React.createElement(SettingsPage)); expect(html).toContain("admin"); expect(html).toContain("2 / 9999"); + const logoutButton = mocks.buttons.find((button: any) => button.variant === "destructive"); + expect(logoutButton).toMatchObject({ disabled: false }); + logoutButton.onClick(); + await Promise.resolve(); + await Promise.resolve(); + expect(mocks.userState.logout).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe("/login"); }); }); diff --git a/local/package.json b/local/package.json index 2478084..a57f440 100644 --- a/local/package.json +++ b/local/package.json @@ -1,6 +1,6 @@ { "name": "@subboost/local", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "private": true, "type": "module", @@ -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/scripts/clean-next.cjs b/local/scripts/clean-next.cjs index 7ad7170..6ed55f3 100644 --- a/local/scripts/clean-next.cjs +++ b/local/scripts/clean-next.cjs @@ -1,11 +1,13 @@ const fs = require("node:fs"); -const removeOptions = { - recursive: true, - force: true, - maxRetries: process.platform === "win32" ? 30 : 3, - retryDelay: process.platform === "win32" ? 200 : 100, -}; +function getRemoveOptions(platform = process.platform) { + return { + recursive: true, + force: true, + maxRetries: platform === "win32" ? 30 : 3, + retryDelay: platform === "win32" ? 200 : 100, + }; +} function isBusyError(error) { return error && ["EBUSY", "EPERM", "ENOTEMPTY"].includes(error.code); @@ -28,30 +30,50 @@ function backupName(date = new Date()) { ].join(""); } -function removeDistDir(distDir) { +function removeDistDir(distDir, deps = {}) { + const fsModule = deps.fs || fs; + const platform = deps.platform || process.platform; + const warn = deps.warn || console.warn; try { - fs.rmSync(distDir, removeOptions); + fsModule.rmSync(distDir, getRemoveOptions(platform)); return; } catch (error) { - if (process.platform !== "win32" || !isBusyError(error)) throw error; + if (platform !== "win32" || !isBusyError(error)) throw error; if (distDir !== ".next") { - console.warn(`[clean-next] skipped locked backup directory ${distDir}`); + warn(`[clean-next] skipped locked backup directory ${distDir}`); return; } - const fallback = backupName(); - fs.renameSync(distDir, fallback); - console.warn(`[clean-next] moved locked .next to ${fallback}; it will be removed on a later clean run.`); + const fallback = backupName(deps.now); + fsModule.renameSync(distDir, fallback); + warn(`[clean-next] moved locked .next to ${fallback}; it will be removed on a later clean run.`); } } -const distDirs = [ - ".next", - ...fs.readdirSync(process.cwd(), { withFileTypes: true }) +function listDistDirs(cwd = process.cwd(), fsModule = fs) { + return [ + ".next", + ...fsModule.readdirSync(cwd, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && /^\.next\.bak-\d{8}-\d{6}$/.test(entry.name)) .map((entry) => entry.name), -]; + ]; +} -for (const distDir of distDirs) { - if (!fs.existsSync(distDir)) continue; - removeDistDir(distDir); +function run(deps = {}) { + const fsModule = deps.fs || fs; + const cwd = deps.cwd || process.cwd(); + for (const distDir of listDistDirs(cwd, fsModule)) { + if (!fsModule.existsSync(distDir)) continue; + removeDistDir(distDir, deps); + } } + +run(); + +module.exports = { + backupName, + getRemoveOptions, + isBusyError, + listDistDirs, + removeDistDir, + run, +}; diff --git a/local/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 }); } diff --git a/local/src/lib/source-import.test.ts b/local/src/lib/source-import.test.ts index 44a547f..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({ @@ -88,6 +108,49 @@ 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(dnsResponse(["93.184.216.34"])) + .mockResolvedValueOnce(dnsResponse([])) + .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, + "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, + "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(dnsResponse(["10.0.0.2"])) + .mockResolvedValueOnce(dnsResponse([])); + + 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..81a6667 100644 --- a/local/src/lib/source-import.ts +++ b/local/src/lib/source-import.ts @@ -12,13 +12,20 @@ import { type SourceImportTransportRequest, type SourceImportTransportResult, } from "@subboost/server-core/subscription"; -import { isPrivateOrReservedIp } from "@subboost/server-core/subscription/ssrf-ip"; +import { resolveHostnameByDoh } from "@subboost/server-core/subscription/doh-resolver"; +import { + isPrivateOrReservedIp, + normalizeResolvedIpAddresses, + selectDnsAddressesAfterFakeIpRecheck, + shouldRecheckFakeIpDnsAnswers, +} 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 = {}; @@ -87,7 +94,15 @@ async function validatePublicFetchTarget(url: string): Promise []); - if (records.some((record) => isPrivateOrReservedIp(record.address))) { + const addresses = normalizeResolvedIpAddresses(records.map((record) => record.address)); + const finalAddresses = shouldRecheckFakeIpDnsAnswers(addresses) + ? selectDnsAddressesAfterFakeIpRecheck( + addresses, + await resolveHostnameByDoh(hostname, { timeoutMs: DOH_TIMEOUT_MS }) + ) + : addresses; + const addressesToCheck = finalAddresses.length > 0 ? finalAddresses : addresses; + if (addressesToCheck.some((address) => isPrivateOrReservedIp(address))) { return toSecurityFailure("禁止访问本机或内网地址"); } 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/local/zz-core-generation-contract.test.ts b/local/zz-core-generation-contract.test.ts new file mode 100644 index 0000000..5d7a132 --- /dev/null +++ b/local/zz-core-generation-contract.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "vitest"; +import { + BaseConfigYamlError, + generateClashConfig, + generateClashYaml, + generateProxyGroups, + generateRuleProviders, + generateRules, +} from "@subboost/core/generator"; +import { buildGenerateOptionsFromConfig } from "@subboost/core/subscription/config-utils"; +import { + normalizeProxyGroupAdvancedConfig, + normalizeProxyGroupMemberRef, + resolveProxyGroupMembers, +} from "@subboost/core/proxy-group-advanced"; +import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +function node(name: string, patch: Partial = {}): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + ...patch, + } as ParsedNode; +} + +describe("local shared core generation contract", () => { + it("builds a full local-consumer config from persisted options and mixed node inputs", () => { + const persisted = buildGenerateOptionsFromConfig( + { + template: "full", + enabledGroups: ["select", "auto", "ai", "private", "cn", "global", "final"], + enabledRules: ["cn", "global", "final"], + dnsYaml: [ + "mixed-port: 7897", + "allow-lan: true", + "global-client-fingerprint: chrome", + "proxy-providers:", + " base:", + " type: http", + "listeners:", + " - name: base-listener", + " type: mixed", + " port: 18080", + "nameserver-policy:", + " geosite:cn: 223.5.5.5", + ].join("\n"), + customRules: [ + { type: "IP-CIDR", value: "203.0.113.0/24", target: { kind: "custom", id: "media" }, noResolve: true }, + { type: "PROCESS-NAME", value: " curl ", target: "Missing", noResolve: true }, + ], + customProxyGroups: [ + { + id: "media", + name: "Media", + emoji: "", + includeInGroupMembers: true, + memberSource: "filtered-nodes", + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["us", "other"], + includeRegex: "Node|DIRECT", + excludeRegex: "Bad", + extraMembers: [{ kind: "direct" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [{ kind: "direct" }, { kind: "node", name: "US Node" }], + }, + }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + dialerProxyGroups: [ + { + id: "chain", + name: "Chain", + type: "fallback", + enabled: true, + relayNodes: ["DIRECT", "Missing Relay", "US Node"], + targetNodes: ["US Node", "Missing Target"], + }, + { + id: "empty", + name: "Empty", + type: "select", + enabled: true, + relayNodes: ["Missing Relay"], + targetNodes: ["US Node"], + }, + { + id: "disabled", + name: "Disabled Chain", + type: "select", + enabled: false, + relayNodes: ["DIRECT"], + targetNodes: ["US Node"], + }, + ], + listenerPorts: { + "US Node": 12001, + "US Node (2)": 12002, + bad: 70000, + }, + proxyGroupAdvanced: { + ai: { + groupType: "fallback", + extraMembers: [{ kind: "custom", id: "media" }], + excludedMembers: [{ kind: "node", name: "Bad Node" }], + }, + }, + proxyGroupNameOverrides: { + ai: "AI Local", + final: "Final Local", + }, + proxyGroupOrder: ["custom:media", "dialer:chain", "module:select"], + ruleProviderBaseUrl: "https://rules.example.com", + experimentalCnUseCnRuleSet: true, + cnIpNoResolve: true, + }, + { + nodes: [ + withNodeSourceId(node("US Node"), "source-a"), + withNodeSourceId(node("US Node"), "source-b"), + node("余额 | 10GB"), + node("Bad Node"), + node("Legacy", { type: "socks4" } as Partial), + node("VLESS", { + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + tls: true, + network: "xhttp", + "reality-opts": { + "public-key": "A".repeat(43), + }, + } as Partial), + ], + proxyProviders: { + remote: { type: "http", url: "https://nodes.example.com/sub" }, + }, + }, + ); + const config = generateClashConfig({ + ...persisted, + proxyGroupOrder: ["custom:media", "dialer:chain", "module:select"], + }); + const groups = config["proxy-groups"] as Array<{ name: string; type: string; proxies?: string[]; use?: string[] }>; + const rules = config.rules as string[]; + const proxies = config.proxies as Array>; + const providers = config["proxy-providers"] as Record; + const listeners = config.listeners as Array>; + + expect(proxies.map((proxy) => proxy.name)).toContain("US Node (2)"); + expect(proxies.map((proxy) => proxy.name)).not.toContain("Legacy"); + expect(proxies.find((proxy) => proxy.name === "VLESS")).toMatchObject({ "client-fingerprint": "chrome" }); + expect(proxies.find((proxy) => proxy.name === "US Node")).toMatchObject({ "dialer-proxy": "Chain" }); + expect(proxies.find((proxy) => proxy.name === "US Node (2)")).not.toHaveProperty("dialer-proxy"); + expect(groups[0]).toMatchObject({ name: "Media", type: "load-balance", proxies: ["DIRECT", "US Node"] }); + expect(groups[1]).toMatchObject({ name: "Chain", type: "fallback" }); + expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("余额 | 10GB"); + expect(groups.find((group) => group.name === "🤖 AI Local")).toMatchObject({ + type: "fallback", + proxies: expect.arrayContaining(["Media", "US Node"]), + }); + expect(providers).toHaveProperty("base"); + expect(providers).toHaveProperty("remote"); + expect(listeners.map((listener) => listener.name)).toEqual(["base-listener", "mixed0", "mixed1"]); + expect(rules).toContain("IP-CIDR,203.0.113.0/24,Media,no-resolve"); + expect(rules).toContain("PROCESS-NAME,curl,Media"); + expect(rules).toContain("RULE-SET,cn,🔒 国内服务"); + expect(rules.at(-1)).toBe("MATCH,🐟 Final Local"); + expect(generateClashYaml({ ...persisted })).toContain("proxy-groups:"); + }); + + it("preserves local proxy-group member and rule provider fallbacks", () => { + expect(normalizeProxyGroupMemberRef({ kind: "node", name: " Node A " })).toEqual({ kind: "node", name: "Node A" }); + expect(normalizeProxyGroupMemberRef({ kind: "module", id: " " })).toBeNull(); + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: ["source-a", "source-a", ""], + regions: ["KR", "bad"], + groupType: "load-balance", + strategy: "bad", + extraMembers: [{ kind: "direct" }, { kind: "direct" }], + }), + ).toEqual({ + sourceIds: ["source-a"], + regions: ["kr"], + groupType: "load-balance", + strategy: "consistent-hashing", + extraMembers: [{ kind: "direct" }], + }); + + const members = resolveProxyGroupMembers({ + defaultProxyNames: ["Korea Node", "DIRECT", "REJECT"], + nodes: [withNodeSourceId(node("Korea Node"), "source-a"), withNodeSourceId(node("US Node"), "source-b")], + moduleNames: { auto: "Auto" }, + customProxyGroups: [{ id: "custom", name: "Custom", emoji: "", groupType: "select" }], + advanced: { + sourceIds: ["source-a"], + regions: ["kr"], + extraMembers: [{ kind: "node", name: "US Node" }, { kind: "custom", id: "custom" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [{ kind: "custom", id: "custom" }, { kind: "node", name: "US Node" }], + }, + }); + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("余额 | 1GB")], + enabledModules: ["auto", "private", "final"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + { id: "direct", name: "Direct", emoji: "", groupType: "direct-first", includeInGroupMembers: true }, + { id: "reject", name: "Reject", emoji: "", groupType: "reject-first" }, + ], + }); + const providers = generateRuleProviders({ + nodes: [node("Node A")], + enabledModules: ["cn"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customRuleSets: [ + { id: "relative", name: "Relative", behavior: "domain", path: "geosite/relative.mrs", target: "DIRECT" }, + { id: "absolute", name: "Absolute", behavior: "domain", path: "https://rules.example.com/absolute.mrs", target: "DIRECT" }, + ], + }); + const rules = generateRules({ + enabledModules: ["cn", "global", "final"], + customRules: [ + { id: "cidr", type: "IP-CIDR", value: "203.0.113.0/24", target: "Missing", noResolve: true }, + { id: "process", type: "PROCESS-NAME", value: "curl", target: "DIRECT", noResolve: true }, + ], + customRuleSets: [ + { id: "set", name: "Set", behavior: "domain", path: "https://rules.example.com/set.mrs", target: "Missing", noResolve: true }, + ], + experimentalCnUseCnRuleSet: true, + availablePolicyTargets: ["DIRECT", "🐟 漏网之鱼"], + fallbackPolicyTarget: "DIRECT", + ruleOrder: ["custom-rule:process", "special:experimental-cn", "module:global:geolocation-!cn"], + }); + + expect(members.proxyNames).toEqual(["Custom", "US Node", "Korea Node", "DIRECT"]); + expect(members.excluded.map((member) => member.key)).toEqual(["reject:REJECT"]); + expect(groups.find((group) => group.name === "⚡ 自动选择")?.proxies).toEqual(["Node A"]); + expect(groups.find((group) => group.name === "Direct")?.proxies?.slice(0, 2)).toEqual(["DIRECT", "REJECT"]); + expect(groups.find((group) => group.name === "Reject")?.proxies?.slice(0, 2)).toEqual(["REJECT", "DIRECT"]); + expect(providers.relative?.url).toBe("https://rules.example.com/geosite/relative.mrs"); + expect(providers.absolute?.url).toBe("https://rules.example.com/absolute.mrs"); + expect(rules).toContain("IP-CIDR,203.0.113.0/24,DIRECT,no-resolve"); + expect(rules).toContain("PROCESS-NAME,curl,DIRECT"); + expect(rules).not.toContain("PROCESS-NAME,curl,DIRECT,no-resolve"); + expect(rules).toContain("RULE-SET,set,DIRECT,no-resolve"); + expect(rules).toContain("RULE-SET,cn,DIRECT"); + }); + + it("rejects unsafe base YAML sections before local YAML generation", () => { + expect(() => generateClashConfig({ nodes: [], userConfig: { dnsYaml: "[]" } })).toThrow(BaseConfigYamlError); + expect(() => generateClashConfig({ nodes: [], userConfig: { dnsYaml: "rules: []" } })).toThrow( + "基础和 DNS 配置不能包含 rules" + ); + expect(() => + generateClashConfig({ + nodes: [], + userConfig: { + dnsYaml: ["nameserver-policy:", " geosite:cn: 223.5.5.5", "dns: disabled"].join("\n"), + }, + }) + ).toThrow("dns 必须是对象"); + expect(() => + generateClashConfig({ + nodes: [node("Node A")], + userConfig: { + dnsYaml: "listeners: invalid", + listenerPorts: { "Node A": 12000 }, + }, + }) + ).toThrow("listeners 必须是数组"); + expect(() => + generateClashConfig({ + nodes: [], + proxyProviders: { remote: { type: "http" } }, + userConfig: { + dnsYaml: "proxy-providers: invalid", + }, + }) + ).toThrow("proxy-providers 必须是对象"); + }); +}); diff --git a/local/zz-core-sanitizer-contract.test.ts b/local/zz-core-sanitizer-contract.test.ts new file mode 100644 index 0000000..8563264 --- /dev/null +++ b/local/zz-core-sanitizer-contract.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + isMihomoSupportedProxyNode, + isStandardBase64String, + normalizeMihomoRealityPublicKey, + normalizeMihomoVlessForGeneration, + sanitizeMihomoProxyNode, +} from "../packages/core/src/mihomo/proxy-sanitizer"; + +const REALITY_PUBLIC_KEY = "A".repeat(43); +const WIREGUARD_KEY = `${"A".repeat(43)}=`; +const SSH_FINGERPRINT = `SHA256:${"A".repeat(43)}`; +const PRIVATE_KEY = [ + "-----BEGIN OPENSSH ", + "PRIVATE ", + "KEY-----\nabc\n-----END OPENSSH ", + "PRIVATE ", + "KEY-----", +].join(""); + +describe("local shared core sanitizer contract", () => { + it("normalizes supported optional Mihomo fields and rejects unsafe nodes", () => { + const vless = sanitizeMihomoProxyNode({ + name: "Reality", + type: "vless", + server: "reality.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + fingerprint: "Firefox", + udp: "yes", + alpn: "h2,http/1.1", + encryption: "mlkem768x25519plus.native.1rtt.valid_token", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + "short-id": "0x7250", + }, + "xhttp-opts": { + mode: "packet-up", + "ech-opts": { + enable: "yes", + config: Buffer.from("ech").toString("base64"), + }, + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + "ws-opts": { + path: "/ws?a=1&ed=1024", + }, + }); + const https = sanitizeMihomoProxyNode({ + name: "HTTPS", + type: "https", + server: "https.example.com", + port: 443, + tls: false, + fingerprint: "sha256 fingerprint = " + "B".repeat(64).match(/.{1,2}/g)?.join(":"), + "ws-opts": { + path: "/upgrade?ed=1024", + "v2ray-http-upgrade": "true", + "early-data-header-name": "drop", + }, + }); + const wireguard = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": "bad", + "pre-shared-key": WIREGUARD_KEY, + reserved: "1,2,3", + }); + const ssh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + "private-key": PRIVATE_KEY, + "private-key-passphrase": "secret", + "host-key": [ + "ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-ecdsa-!bad AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-rsa bad!token", + ], + "server-fingerprint": SSH_FINGERPRINT, + }); + const invalidXhttp = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + mode: "stream-one", + "download-settings": { + path: "/download", + }, + }, + }); + + expect(isStandardBase64String(Buffer.from("hello").toString("base64"))).toBe(true); + expect(isStandardBase64String("not base64")).toBe(false); + expect(normalizeMihomoRealityPublicKey(REALITY_PUBLIC_KEY)).toBe(REALITY_PUBLIC_KEY); + expect(normalizeMihomoRealityPublicKey("short")).toBeNull(); + expect(sanitizeMihomoProxyNode("raw" as never)).toBe("raw"); + expect(vless).toMatchObject({ + tls: true, + udp: true, + "client-fingerprint": "firefox", + alpn: ["h2", "http/1.1"], + encryption: "mlkem768x25519plus.native.1rtt.valid_token", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + "short-id": "7250", + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + expect(https).toMatchObject({ + type: "http", + tls: false, + fingerprint: "b".repeat(64), + "ws-opts": { + path: "/upgrade", + "v2ray-http-upgrade": true, + }, + }); + expect(https["ws-opts"]).not.toHaveProperty("early-data-header-name"); + expect(wireguard).toMatchObject({ + "private-key": WIREGUARD_KEY, + "pre-shared-key": WIREGUARD_KEY, + reserved: [1, 2, 3], + }); + expect(wireguard).not.toHaveProperty("public-key"); + expect(ssh).toMatchObject({ + "private-key": PRIVATE_KEY, + "host-key": ["ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA=="], + "server-fingerprint": SSH_FINGERPRINT, + }); + expect(invalidXhttp).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(isMihomoSupportedProxyNode({ type: "socks4", name: "old" })).toBe(false); + expect(isMihomoSupportedProxyNode({ type: "ss", cipher: "", password: "secret" })).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "v2ray-plugin", + "plugin-opts": { mode: "quic" }, + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + password: "secret", + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "wireguard", + name: "WG", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "pre-shared-key": WIREGUARD_KEY, + }) + ).toBe(true); + }); +}); diff --git a/package-lock.json b/package-lock.json index cb9fe72..3340d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "subboost", - "version": "2.4.0", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "subboost", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "workspaces": [ "packages/*", @@ -67,7 +67,7 @@ }, "local": { "name": "@subboost/local", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "dependencies": { "@prisma/adapter-pg": "^7.8.0", diff --git a/package.json b/package.json index afef866..def6930 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subboost", - "version": "2.4.0", + "version": "2.5.0", "license": "AGPL-3.0-only", "private": true, "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index 7586389..85859fb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,26 +6,29 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./api/*": "./src/api/*", - "./config/*": "./src/config/*", - "./filtered-proxy-groups": "./src/filtered-proxy-groups.ts", + "./api/*": "./src/api/*.ts", + "./config/*": "./src/config/*.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-advanced": "./src/proxy-group-advanced.ts", "./proxy-group-name": "./src/proxy-group-name.ts", - "./rules/*": "./src/rules/*", + "./proxy-group-advanced-mode": "./src/proxy-group-advanced-mode.ts", + "./proxy-group-targets": "./src/proxy-group-targets.ts", + "./rules/*": "./src/rules/*.ts", "./rules-database": "./src/rules-database.ts", - "./subscription/*": "./src/subscription/*", + "./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/core/src/config/defaults.test.ts b/packages/core/src/config/defaults.test.ts index c6f494a..06e9cb2 100644 --- a/packages/core/src/config/defaults.test.ts +++ b/packages/core/src/config/defaults.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA } from "@subboost/core/templates/config-template"; +import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA } from "../templates/config-template"; import { DEFAULT_BASE_CONFIG_YAML, DEFAULT_SUBBOOST_CONFIG, @@ -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", () => { @@ -70,12 +67,12 @@ describe("default config builders", () => { template: "full", hiddenProxyGroups: [], customProxyGroups: [], - filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, + customRuleSets: [], + builtinRuleEdits: {}, customRules: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, dialerProxyGroups: [], proxyGroupNameOverrides: {}, mixedPort: DEFAULT_SUBBOOST_CONFIG.mixedPort, @@ -90,7 +87,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 d5403fe..fb0f8d8 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, @@ -87,12 +85,12 @@ export function buildDefaultSubBoostTemplateConfig(type: TemplateType): SubBoost enabledProxyGroups: template.groups, hiddenProxyGroups: [], customProxyGroups: [], - filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, + customRuleSets: [], + builtinRuleEdits: {}, customRules: [], ruleOrder: [], - allRulesOrderEditingEnabled: false, cnIpNoResolve: DEFAULT_SUBBOOST_CONFIG.cnIpNoResolve, experimentalCnUseCnRuleSet: DEFAULT_SUBBOOST_CONFIG.experimentalCnUseCnRuleSet, dialerProxyGroups: [], @@ -114,7 +112,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/core-contracts.test.ts b/packages/core/src/core-contracts.test.ts index da222c6..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( @@ -189,38 +190,44 @@ 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", + }, + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + target: { kind: "custom", id: "custom" }, + noResolve: true, }, ], }); 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 +241,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", @@ -248,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 e6875aa..a8e1b85 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("normalizes base YAML client fingerprints and removes invalid dialer-proxy references", () => { const config = generateClashConfig({ nodes: [ { @@ -368,7 +366,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: [ @@ -380,26 +378,15 @@ describe("generateClashConfig", () => { targetNodes: ["Target"], }, ], - filteredProxyGroups: [ - { - id: "fast", - name: "Fast", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - }, - ], customProxyGroups: [ { id: "custom", 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"], + proxyGroupOrder: ["custom:custom", "dialer:chain", "module:auto", "missing", "module:auto"], userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"], @@ -407,10 +394,10 @@ describe("generateClashConfig", () => { }); expect(config["proxy-groups"]?.slice(0, 4).map((group) => group.name)).toEqual([ - "Fast", "Custom", "Chain", "⚡ 自动选择", + "🚀 节点选择", ]); }); @@ -451,26 +438,15 @@ 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", name: "" as never, emoji: "", groupType: "select", - rules: [], }, ], - proxyGroupOrder: ["filtered:bad-filter", "custom:bad-custom", "module:auto"], + proxyGroupOrder: ["custom:bad-custom", "module:auto"], userConfig: { enabledGroups: ["select", "auto", "global", "final"], }, @@ -481,6 +457,154 @@ describe("generateClashConfig", () => { expect(config["proxy-groups"]?.some((group) => Object.is((group as { name: unknown }).name, 123))).toBe(false); }); + it("keeps safe fallbacks for empty providers, blank fingerprints, and unusable dialers", () => { + const config = generateClashConfig({ + nodes: [ + ssNode({ name: "Relay" }), + { + name: "Plain VMess", + type: "vmess", + server: "vmess.example.com", + port: 80, + uuid: "11111111-1111-4111-8111-111111111111", + alterId: 0, + cipher: "auto", + "client-fingerprint": 1, + } as ParsedNode, + { + name: "VLESS TCP", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "tcp", + } as ParsedNode, + ], + proxyProviders: {}, + dialerProxyGroups: [ + { + id: "empty", + name: "Empty Chain", + type: "select", + relayNodes: [" Missing ", "Missing"], + targetNodes: ["Plain VMess", " "], + }, + ], + userConfig: { + dnsYaml: [ + "global-client-fingerprint: ' '", + "proxy-providers:", + " local:", + " type: file", + " path: ./local.yaml", + ].join("\n"), + listenerPorts: { + Relay: 0, + "Plain VMess": 65536, + "VLESS TCP": 12003, + }, + }, + }); + + expect(config["proxy-providers"]).toEqual({ + local: { + type: "file", + path: "./local.yaml", + }, + }); + expect(config.proxies?.find((proxy) => proxy.name === "Plain VMess")).toHaveProperty("client-fingerprint", 1); + expect(config.proxies?.find((proxy) => proxy.name === "VLESS TCP")).not.toHaveProperty("client-fingerprint"); + expect(config.listeners).toEqual([{ name: "mixed0", type: "mixed", port: 12003, proxy: "VLESS TCP" }]); + expect(config["proxy-groups"]?.find((group) => group.name === "Empty Chain")).toBeUndefined(); + }); + + it("normalizes mixed malformed nodes and dialer entries while preserving valid outputs", () => { + const config = generateClashConfig({ + nodes: [ + null as never, + { name: "Bad Type", type: 1, server: "bad.example.com", port: 443 } as never, + ssNode({ name: "Relay", server: "relay.example.com" }), + ssNode({ name: "Target", server: "target.example.com" }), + ssNode({ name: "Blank Target", server: "blank-target.example.com" }), + { + name: "TLS VMess", + type: "vmess", + server: "vmess.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + alterId: 0, + cipher: "auto", + tls: true, + } as ParsedNode, + { + name: "Reality VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + } as ParsedNode, + ], + dialerProxyGroups: [ + { + id: "messy", + name: "Messy Chain", + type: "select", + relayNodes: [1 as never, " Relay ", "DIRECT", "Relay"], + targetNodes: [2 as never, " Target ", "Missing"], + }, + { + id: "blank-name", + name: " ", + type: "select", + relayNodes: ["Relay"], + targetNodes: ["Blank Target"], + }, + ], + proxyGroupOrder: [1 as never, "dialer:messy", "module:auto"], + userConfig: { + dnsYaml: [ + "global-client-fingerprint: chrome", + "nameserver-policy:", + " '+.top.example.com': 1.1.1.1", + "dns:", + " enable: true", + ].join("\n"), + enabledGroups: ["select", "auto", "global", "final"], + }, + }); + + expect(config.proxies?.map((proxy) => proxy.name)).toEqual([ + "Relay", + "Target", + "Blank Target", + "TLS VMess", + "Reality VLESS", + ]); + expect(config.dns).toEqual({ + enable: true, + "nameserver-policy": { + "+.top.example.com": "1.1.1.1", + }, + }); + expect(config.proxies?.find((proxy) => proxy.name === "Target")).toMatchObject({ + "dialer-proxy": "Messy Chain", + }); + expect(config.proxies?.find((proxy) => proxy.name === "Blank Target")).toHaveProperty("dialer-proxy", " "); + expect(config.proxies?.find((proxy) => proxy.name === "TLS VMess")).toMatchObject({ + "client-fingerprint": "chrome", + }); + expect(config.proxies?.find((proxy) => proxy.name === "Reality VLESS")).toMatchObject({ + "client-fingerprint": "chrome", + }); + expect(config["proxy-groups"]?.slice(0, 2).map((group) => group.name)).toEqual([ + "Messy Chain", + "⚡ 自动选择", + ]); + }); + it("generates YAML through the public helper", () => { const yaml = generateClashYaml({ nodes: [ssNode()], diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 418a0b1..5157b1c 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -21,13 +21,19 @@ 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 { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; +import type { + BuiltinRuleEdits, + ClashConfig, + CustomProxyGroup, + CustomRuleSet, + ProxyGroup, + ProxyGroupAdvancedConfig, + TemplateType, + UserConfig, +} from "@subboost/core/types/config"; +import type { DialerProxyGroup } from "@subboost/core/types/template-config"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; -import type { ModuleRuleExclusions } from "./module-rules"; -import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "@subboost/core/mihomo/proxy-sanitizer"; +import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "../mihomo/proxy-sanitizer"; import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-targets"; export interface GenerateOptions { @@ -37,16 +43,14 @@ export interface GenerateOptions { userConfig?: Partial; dialerProxyGroups?: DialerProxyGroup[]; customProxyGroups?: CustomProxyGroup[]; - filteredProxyGroups?: FilteredProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + customRuleSets?: CustomRuleSet[]; + proxyGroupAdvanced?: Record; + builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; 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); @@ -105,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 { @@ -175,9 +183,9 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { userConfig = {}, dialerProxyGroups = [], customProxyGroups = [], - filteredProxyGroups = [], - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets = [], + proxyGroupAdvanced = {}, + builtinRuleEdits, proxyGroupNameOverrides, } = options; @@ -202,9 +210,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { assertNoGeneratedSectionsInBaseConfig(baseConfig); const baseConfigRecord = baseConfig as unknown as Record; const shouldUseDefaultBaseSections = !hasExplicitBaseConfigYaml; - - // mihomo 已将 global-client-fingerprint 标记为 deprecated;这里规范化到每个 proxy 的 client-fingerprint。 - const globalClientFingerprint = + const baseConfigClientFingerprint = typeof baseConfigRecord["global-client-fingerprint"] === "string" && baseConfigRecord["global-client-fingerprint"].trim() ? String(baseConfigRecord["global-client-fingerprint"]).trim() : undefined; @@ -215,26 +221,20 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { if (!node || typeof node !== "object") return node; const typed = node as unknown as Record; const type = typeof typed.type === "string" ? typed.type : ""; - 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" || + const shouldApplyBaseFingerprint = + Boolean(baseConfigClientFingerprint) && + supportsClientFingerprint && + !hasClientFingerprint && + (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 }; - })(); + (type === "vless" && Boolean(typed["reality-opts"]))); + const nextBase = shouldApplyBaseFingerprint + ? { ...typed, "client-fingerprint": baseConfigClientFingerprint } + : typed; if (type !== "vless") return nextBase as unknown as ParsedNode; return normalizeMihomoVlessForGeneration(nextBase) as unknown as ParsedNode; @@ -283,18 +283,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 到目标节点(基于最终输出的唯一节点名) @@ -306,7 +308,6 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { ...nodeNameSet, ...proxyProviderNames, ...moduleGroupNameSet, - ...filteredGroupNameSet, ...customGroupNameSet, ...sanitizedDialerProxyGroups.map((g) => g.name.trim()).filter(Boolean), ]); @@ -354,9 +355,9 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { testUrl: config.testUrl, testInterval: config.testInterval, customProxyGroups, - filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, cnIpNoResolve: config.cnIpNoResolve, experimentalCnUseCnRuleSet: config.experimentalCnUseCnRuleSet, proxyGroupNameOverrides, @@ -414,19 +415,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() : ""; @@ -437,7 +431,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/module-rules.test.ts b/packages/core/src/generator/module-rules.test.ts index b097b79..7ff37f2 100644 --- a/packages/core/src/generator/module-rules.test.ts +++ b/packages/core/src/generator/module-rules.test.ts @@ -7,9 +7,9 @@ import { getModuleRuleOrderKey, isModuleRuleMovedFrom, isPresetModuleRule, - normalizeModuleRuleExclusions, + normalizeHiddenPresetRuleIds, } from "./module-rules"; -import type { ProxyGroupModule, ProxyGroupRule } from "./proxy-group-modules"; +import type { ProxyGroupModule, ProxyGroupRule } from "@subboost/core/generator/proxy-group-modules"; const proxyModule: ProxyGroupModule = { id: "media", @@ -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/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 ef3ace4..cd7f7db 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, @@ -27,12 +28,11 @@ 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` }], }; } 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"], @@ -40,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"], @@ -90,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"], @@ -108,8 +87,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({ @@ -128,4 +116,339 @@ 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("keeps filtered-node custom groups node-scoped while exposing them as policy members", () => { + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("Node B"), node("Filtered")], + proxyProviderNames: ["remote"], + enabledModules: ["select", "auto", "private"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + { + id: "filtered", + name: "Filtered", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "select", + advanced: { includeRegex: "Node A|Filtered" }, + }, + { ...customGroup("normal", "select"), includeInGroupMembers: true }, + ], + }); + + expect(groups[0]?.name).toBe("Filtered"); + expect(groups.find((group) => group.name === "Filtered")?.proxies).toEqual([ + "DIRECT", + "REJECT", + "Node A", + "Filtered", + ]); + expect(groups.find((group) => group.name === "Filtered")).not.toHaveProperty("use"); + expect(groups.find((group) => group.name === "Filtered")?.proxies).not.toContain("Custom normal"); + expect(groups.find((group) => group.name === "Filtered")?.proxies).not.toContain("🚀 节点选择"); + expect(groups.find((group) => group.name === "Custom normal")?.proxies).toContain("Filtered"); + expect(groups.find((group) => group.name === "Custom normal")?.proxies).not.toContain("Custom normal"); + expect(groups.find((group) => group.name === "Custom normal")).toMatchObject({ use: ["remote"] }); + expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("Filtered"); + expect(groups.find((group) => group.name === "🚀 节点选择")?.proxies).toContain("Custom normal"); + expect(groups.find((group) => group.name === "🏠 私有网络")?.proxies?.slice(0, 5)).toEqual([ + "DIRECT", + "REJECT", + "Filtered", + "Custom normal", + "🚀 节点选择", + ]); + }); + + it("keeps filtered-node generated group variants node-scoped and appends inline custom groups without an insert point", () => { + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("Node B")], + proxyProviderNames: ["remote"], + enabledModules: ["select"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + { + id: "filtered-url", + name: "Filtered URL", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "url-test", + advanced: { includeRegex: "Node A" }, + }, + { + id: "filtered-fallback", + name: "Filtered Fallback", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "fallback", + }, + { + id: "filtered-balance", + name: "Filtered Balance", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + }, + customGroup("inline", "select"), + ], + }); + + expect(groups.slice(0, 3).map((group) => group.name)).toEqual([ + "Filtered URL", + "Filtered Fallback", + "Filtered Balance", + ]); + expect(groups.find((group) => group.name === "Filtered URL")).toMatchObject({ + type: "url-test", + proxies: ["Node A"], + }); + expect(groups.find((group) => group.name === "Filtered URL")).not.toHaveProperty("use"); + expect(groups.find((group) => group.name === "Filtered Fallback")).toMatchObject({ + type: "fallback", + proxies: ["Node A", "Node B"], + }); + expect(groups.find((group) => group.name === "Filtered Fallback")).not.toHaveProperty("use"); + expect(groups.find((group) => group.name === "Filtered Balance")).toMatchObject({ + type: "load-balance", + strategy: "consistent-hashing", + proxies: ["Node A", "Node B"], + }); + expect(groups.find((group) => group.name === "Filtered Balance")).not.toHaveProperty("use"); + expect(groups.at(-1)).toMatchObject({ name: "Custom inline", use: ["remote"] }); + }); + + it("omits disabled custom groups from groups, providers, names, and custom rules", () => { + const disabledGroup = { ...customGroup("disabled", "select"), enabled: false }; + const groups = generateProxyGroups({ + 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"]); + }); + + it("covers provider target guards and module group type overrides", () => { + const disabledByName = { ...customGroup("disabled-name", "select"), enabled: false }; + const groups = generateProxyGroups({ + nodes: [node("Node A"), node("Node B")], + proxyProviderNames: ["remote"], + enabledModules: ["select", "auto", "ai", "private", "cn", "global", "final"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [ + disabledByName, + { + id: "blank-name", + name: "" as never, + emoji: "", + includeInGroupMembers: true, + groupType: "select", + }, + ], + proxyGroupAdvanced: { + auto: { groupType: "load-balance", strategy: "round-robin" }, + ai: { groupType: "url-test", regions: ["other"] }, + private: { groupType: "direct-first", extraMembers: [{ kind: "node", name: "Node B" }] }, + cn: { groupType: "reject-first" }, + }, + }); + const providers = generateRuleProviders({ + nodes: [node("Node A")], + enabledModules: ["cn"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 120, + customProxyGroups: [disabledByName], + customRuleSets: [ + { + id: "skip-by-name", + name: "Skip", + behavior: "domain", + path: "geosite/skip.mrs", + target: "Custom disabled-name", + }, + { + id: "relative-path", + name: "Relative", + behavior: "domain", + path: "geosite/relative.mrs", + target: "DIRECT", + }, + { + id: "relative-path", + name: "Duplicate", + behavior: "domain", + path: "geosite/duplicate.mrs", + target: "DIRECT", + }, + { + id: "", + name: "Missing id", + behavior: "domain", + path: "geosite/missing.mrs", + target: "DIRECT", + }, + ], + }); + + expect(groups.find((group) => group.name === "⚡ 自动选择")).toMatchObject({ + type: "load-balance", + strategy: "round-robin", + }); + expect(groups.find((group) => group.name.includes("AI"))).toMatchObject({ + type: "url-test", + proxies: [], + use: ["remote"], + }); + expect(groups.find((group) => group.name === "🏠 私有网络")?.proxies?.slice(0, 2)).toEqual(["DIRECT", "REJECT"]); + expect(groups.find((group) => group.name === "🔒 国内服务")?.proxies?.slice(0, 2)).toEqual(["REJECT", "DIRECT"]); + expect(providers["skip-by-name"]).toBeUndefined(); + expect(providers["relative-path"]).toMatchObject({ + url: "https://rules.example.com/geosite/relative.mrs", + }); + }); + + it("builds groups without providers while keeping info nodes out of testable groups", () => { + const groups = generateProxyGroups({ + nodes: [node("余额 | 10GB"), node("Korea Node"), node("US Node")], + enabledModules: ["auto", "ai", "private", "global", "final"], + ruleProviderBaseUrl: "https://rules.example.com", + testUrl: "https://probe.example.com/204", + testInterval: 60, + customProxyGroups: [ + { + id: "direct-local", + name: "Direct Local", + emoji: "", + includeInGroupMembers: true, + groupType: "direct-first", + advanced: { memberOrder: [{ kind: "direct" }, { kind: "node", name: "US Node" }] }, + }, + { + id: "reject-local", + name: "Reject Local", + emoji: "", + includeInGroupMembers: true, + groupType: "reject-first", + }, + { + id: "filtered-reject", + name: "Filtered Reject", + emoji: "", + memberSource: "filtered-nodes", + groupType: "reject-first", + advanced: { includeRegex: "Korea|余额" }, + }, + ], + proxyGroupAdvanced: { + ai: { + groupType: "select", + extraMembers: [{ kind: "custom", id: "direct-local" }], + excludedMembers: [{ kind: "node", name: "US Node" }], + }, + }, + }); + + const auto = groups.find((group) => group.name === "⚡ 自动选择"); + const ai = groups.find((group) => group.name.includes("AI")); + const directLocal = groups.find((group) => group.name === "Direct Local"); + const rejectLocal = groups.find((group) => group.name === "Reject Local"); + const filteredReject = groups.find((group) => group.name === "Filtered Reject"); + + expect(auto).toMatchObject({ + type: "url-test", + proxies: ["Korea Node", "US Node"], + url: "https://probe.example.com/204", + interval: 60, + }); + expect(auto).not.toHaveProperty("use"); + expect(auto?.proxies).not.toContain("余额 | 10GB"); + expect(ai?.proxies).toContain("Direct Local"); + expect(ai?.proxies).not.toContain("US Node"); + expect(directLocal?.proxies?.slice(0, 2)).toEqual(["DIRECT", "US Node"]); + expect(directLocal?.proxies).not.toContain("Direct Local"); + expect(rejectLocal?.proxies?.slice(0, 2)).toEqual(["REJECT", "DIRECT"]); + expect(filteredReject?.proxies).toEqual(["REJECT", "DIRECT", "Korea Node"]); + expect(groups.find((group) => group.name === "🐟 漏网之鱼")?.proxies).toContain("Direct Local"); + }); }); diff --git a/packages/core/src/generator/proxy-groups.ts b/packages/core/src/generator/proxy-groups.ts index 0c69bbe..edc7c2f 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -5,20 +5,29 @@ 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 { 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"; 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"; +import { buildTypedProxyGroup } from "./proxy-group-type"; export { PROXY_GROUP_MODULES }; export type { ProxyGroupModule, ProxyGroupRule }; @@ -47,9 +56,9 @@ export interface GenerateOptions { testUrl: string; testInterval: number; customProxyGroups?: CustomProxyGroup[]; - filteredProxyGroups?: FilteredProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + customRuleSets?: CustomRuleSet[]; + proxyGroupAdvanced?: Record; + builtinRuleEdits?: BuiltinRuleEdits; // 国内服务 GeoIP 规则是否使用 no-resolve(默认 true;关闭可提升命中率但可能造成 DNS 泄露) cnIpNoResolve?: boolean; // 实验性:为“国内服务”额外启用 cn(geosite/cn.mrs),并将其规则后置(放到 global 之后) @@ -60,6 +69,34 @@ export interface GenerateOptions { export { isSubscriptionInfoNodeName }; +function getEnabledCustomProxyGroups(customProxyGroups: CustomProxyGroup[]): CustomProxyGroup[] { + return customProxyGroups.filter((group) => group && group.enabled !== false); +} + +function usesFilteredNodeMembers(group: CustomProxyGroup): boolean { + return group.memberSource === "filtered-nodes"; +} + +function isIncludedInGroupMembers(group: CustomProxyGroup): boolean { + return group.includeInGroupMembers === true; +} + +function customTargetIsDisabled( + target: CustomRuleSet["target"], + customProxyGroups: CustomProxyGroup[] +): 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,15 +138,18 @@ 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); // 业务组/测速组需要过滤掉“信息节点”,仅在 🚀 节点选择 中保留显示 const filteredNodeNames = nodeNames.filter((n) => !isSubscriptionInfoNodeName(n)); const groups: ProxyGroup[] = []; const processedModules = new Set(); + const leadingCustomProxyGroups = activeCustomProxyGroups.filter(usesFilteredNodeMembers); + const inlineCustomProxyGroups = activeCustomProxyGroups.filter((group) => !usesFilteredNodeMembers(group)); const enabledSet = new Set(enabledModules); // 默认顺序(影响 Clash 初始选中项):节点选择 → 自动选择 → DIRECT → REJECT → 其它节点 @@ -118,13 +158,16 @@ 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 + .filter(isIncludedInGroupMembers) + .map((group) => (typeof group.name === "string" ? group.name.trim() : "")) + .filter(Boolean); const policyTargets = (...targets: unknown[]) => { const out: string[] = []; const seen = new Set(); @@ -138,154 +181,236 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { const fallbackTargets = (...targets: unknown[]) => policyTargets(...targets, "DIRECT", "REJECT"); const selectTarget = enabledModuleTarget("select"); const autoTarget = enabledModuleTarget("auto"); - const baseProxies = selectTarget - ? fallbackTargets(selectTarget, autoTarget, "DIRECT", "REJECT", ...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, + const moduleBaseProxies = selectTarget + ? fallbackTargets( + selectTarget, + autoTarget, + "DIRECT", + "REJECT", + ...customGroupNames, + ...filteredNodeNames + ) + : fallbackTargets( + autoTarget, + ...customGroupNames, + ...filteredNodeNames, + "DIRECT", + "REJECT" + ); + const customBaseProxies = moduleBaseProxies; + 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, + extraFields: Record = providerUse + ): ProxyGroup => + buildTypedProxyGroup({ + name, + groupType, + proxies, + testUrl, + testInterval, + strategy, + extraFields, + 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 = moduleBaseProxies.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( - (target) => target !== moduleName - ), - ...providerUse, - }); + { + const defaultProxies = fallbackTargets( + "DIRECT", + "REJECT", + ...customGroupNames, + selectTarget, + autoTarget, + ...filteredNodeNames + ).filter((target) => target !== moduleName); + 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") { + const resolveCustom = (defaultProxyNames: string[]) => + resolveGroupProxyNames(defaultProxyNames, customGroup.advanced, { + kind: "custom", + id: customGroup.id, + name: customGroup.name, + }); + + if (usesFilteredNodeMembers(customGroup)) { + if (customGroup.groupType === "url-test" || customGroup.groupType === "fallback") { + return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames), undefined, {}); + } + if (customGroup.groupType === "load-balance") { + return createGeneratedProxyGroup( + customGroup.name, + customGroup.groupType, + resolveCustom(filteredNodeNames), + customGroup.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY, + {} + ); + } return { name: customGroup.name, - type: "url-test", - proxies: filteredNodeNames, - ...providerUse, - url: testUrl, - interval: testInterval, - lazy: false, + type: "select", + proxies: customGroup.groupType === "reject-first" + ? resolveCustom(["REJECT", "DIRECT", ...filteredNodeNames]) + : resolveCustom(["DIRECT", "REJECT", ...filteredNodeNames]), }; } + if (customGroup.groupType === "url-test") { + return createGeneratedProxyGroup(customGroup.name, customGroup.groupType, resolveCustom(filteredNodeNames)); + } 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, ...filteredNodeNames).filter( + (target) => target !== customGroup.name + ) ), ...providerUse, }; @@ -294,25 +419,33 @@ 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).filter( + (target) => target !== customGroup.name + ) + ), ...providerUse, }; } return { name: customGroup.name, type: "select", - proxies: baseProxies.filter((target) => target !== customGroup.name), + proxies: resolveCustom(customBaseProxies.filter((target) => target !== customGroup.name)), ...providerUse, }; }; + for (const customGroup of leadingCustomProxyGroups) { + groups.push(createCustomProxyGroup(customGroup)); + } + // 按 PROXY_GROUP_ORDER 顺序生成代理组 let insertedCustom = false; for (const moduleId of PROXY_GROUP_ORDER) { // 自定义分流组插入位置:在 🍏 苹果服务 与 📲 电报消息之间 - if (!insertedCustom && moduleId === "ai" && customProxyGroups.length > 0) { + if (!insertedCustom && moduleId === "ai" && inlineCustomProxyGroups.length > 0) { insertedCustom = true; - for (const customGroup of customProxyGroups) { + for (const customGroup of inlineCustomProxyGroups) { groups.push(createCustomProxyGroup(customGroup)); } } @@ -335,8 +468,8 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { } // 若未命中插入点(例如未来移除 private),则追加到末尾 - if (!insertedCustom && customProxyGroups.length > 0) { - for (const customGroup of customProxyGroups) { + if (!insertedCustom && inlineCustomProxyGroups.length > 0) { + for (const customGroup of inlineCustomProxyGroups) { groups.push(createCustomProxyGroup(customGroup)); } } @@ -348,7 +481,13 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { * 生成规则提供者配置 */ export function generateRuleProviders(options: GenerateOptions): Record { - const { enabledModules, ruleProviderBaseUrl, customProxyGroups = [], moduleRuleOverrides, moduleRuleExclusions } = options; + const { + enabledModules, + ruleProviderBaseUrl, + customRuleSets = [], + builtinRuleEdits, + customProxyGroups = [], + } = options; const providers: Record = {}; const enabledSet = new Set(enabledModules); @@ -356,8 +495,9 @@ 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.test.ts b/packages/core/src/generator/rules.test.ts index 11ae51b..f5de14c 100644 --- a/packages/core/src/generator/rules.test.ts +++ b/packages/core/src/generator/rules.test.ts @@ -9,7 +9,7 @@ import { } from "./rules"; import { generateClashConfig } from "./index"; import { PROXY_GROUP_MODULES } from "./proxy-groups"; -import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; +import type { CustomRule, CustomRuleSet } from "@subboost/core/types/config"; const customRules: CustomRule[] = [ { @@ -27,13 +27,14 @@ const customRules: CustomRule[] = [ }, ]; -const customGroups: CustomProxyGroup[] = [ +const customRuleSets: CustomRuleSet[] = [ { - id: "media", - name: "Media", - emoji: "M", - groupType: "select", - rules: [{ id: "media-rule", name: "Media Rule", behavior: "domain", url: "https://rules.example.com/media.mrs", noResolve: true }], + id: "media-rule", + name: "Media Rule", + behavior: "domain", + path: "https://rules.example.com/media.mrs", + target: "Media", + noResolve: true, }, ]; @@ -42,7 +43,7 @@ describe("rule generator", () => { const entries = buildGeneratedRuleEntries({ enabledModules: ["cn", "global", "final", "streaming-west"], customRules, - customProxyGroups: customGroups, + customRuleSets, experimentalCnUseCnRuleSet: true, cnIpNoResolve: false, proxyGroupNameOverrides: { @@ -55,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"); @@ -65,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"); @@ -94,7 +100,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 +127,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,28 +155,34 @@ 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")); + expect(resolveAppliedRuleOrder({ + enabledModules: ["cn", "global", "final"], + customRules, + customRuleSets, + ruleOrder: ["custom-rule-set:media-rule", "custom-rule:ip-rule"], + }).slice(0, 3)).toEqual(["custom-rule-set:media-rule", "custom-rule:ip-rule", "custom-rule:domain-rule"]); expect(generateRules({ enabledModules: [], customRules: [], @@ -192,7 +194,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 +205,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 +224,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 +237,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 +246,7 @@ describe("rule generator", () => { const options = { enabledModules: ["ad", "private", "global", "final"], customRules, - customProxyGroups: [], + customRuleSets: [], fallbackPolicyTarget: "DIRECT", }; const entries = buildGeneratedRuleEntries(options); @@ -276,13 +275,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,9 +298,246 @@ 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"]); }); + + it("keeps module-targeted custom rules while dropping disabled custom-group targets", () => { + const entries = buildGeneratedRuleEntries({ + enabledModules: ["private", "apple"], + customProxyGroups: [ + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + customRules: [ + { + id: "module-target", + type: "DOMAIN", + value: "module.example", + target: { kind: "module", id: "select" }, + noResolve: true, + }, + { + id: "disabled-name", + type: "DOMAIN", + value: "disabled-name.example", + target: "Disabled", + }, + { + id: "disabled-ref", + type: "DOMAIN", + value: "disabled-ref.example", + target: { kind: "custom", id: "disabled" }, + }, + ], + customRuleSets: [ + { + id: "module-set", + name: "Module Set", + behavior: "domain", + path: "https://rules.example.com/module.mrs", + target: { kind: "module", id: "select" }, + }, + ], + fallbackPolicyTarget: "DIRECT", + }); + const texts = entries.map((entry) => entry.text); + + expect(texts).toContain("DOMAIN,module.example,🚀 节点选择"); + expect(texts).toContain("RULE-SET,module-set,🚀 节点选择"); + expect(texts.some((text) => text.includes("disabled-name.example"))).toBe(false); + expect(texts.some((text) => text.includes("disabled-ref.example"))).toBe(false); + expect(texts.some((text) => text.startsWith("RULE-SET,private-ip,🏠 私有网络,no-resolve"))).toBe(true); + expect(texts.some((text) => text.startsWith("RULE-SET,apple,🍏 苹果服务"))).toBe(true); + + expect(hasFullRuleOrderKeys(undefined)).toBe(false); + expect( + normalizePersistedRuleOrder({ + enabledModules: [], + customRules, + customRuleSets: [], + ruleOrder: undefined, + }) + ).toEqual([]); + expect( + resolveAppliedRuleOrder({ + enabledModules: [], + customRules, + customRuleSets: [], + ruleOrder: ["module:global:geolocation-!cn"], + }) + ).toEqual(["custom-rule:domain-rule", "custom-rule:ip-rule"]); + }); + + it("applies builtin module target edits and cn no-resolve overrides", () => { + const entries = buildGeneratedRuleEntries({ + enabledModules: ["cn"], + customRules: [], + customRuleSets: [], + customProxyGroups: [ + { id: "regional", name: "Regional", emoji: "R", groupType: "select" }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { target: { kind: "custom", id: "regional" } }, + }, + cnIpNoResolve: true, + availablePolicyTargets: ["Regional", "DIRECT"], + fallbackPolicyTarget: "DIRECT", + }); + const texts = entries.map((entry) => entry.text); + + expect(texts).toContain("RULE-SET,cn-ip,Regional,no-resolve"); + expect(texts).toContain("MATCH,DIRECT"); + expect(entries.find((entry) => entry.key === "module:cn:cn-ip")).toMatchObject({ + noResolve: true, + sourceLabel: "🔒 国内服务", + target: "Regional", + }); + }); + + it("cleans noisy persisted full-order keys without losing active editable rules", () => { + const applied = resolveAppliedRuleOrder({ + enabledModules: ["global"], + customRules, + customRuleSets, + ruleOrder: [ + " module:global:geolocation-!cn ", + "module:global:geolocation-!cn", + 1 as never, + "custom-rule:ip-rule", + "custom-rule:missing", + "module:unknown:missing", + "special:match", + ], + fallbackPolicyTarget: "DIRECT", + }); + const persisted = normalizePersistedRuleOrder({ + enabledModules: ["global"], + customRules, + customRuleSets, + ruleOrder: [ + " module:global:geolocation-!cn ", + "module:global:geolocation-!cn", + 1 as never, + "custom-rule:ip-rule", + "custom-rule:missing", + "module:unknown:missing", + "special:match", + ], + fallbackPolicyTarget: "DIRECT", + }); + + expect(persisted).toEqual(["module:global:geolocation-!cn", "custom-rule:ip-rule"]); + expect(applied).toEqual([ + "custom-rule:domain-rule", + "custom-rule-set:media-rule", + "module:global:geolocation-!cn", + "custom-rule:ip-rule", + ]); + }); + + it("applies fallback policy targets and no-resolve guards for mixed custom rules", () => { + const entries = buildGeneratedRuleEntries({ + enabledModules: ["cn", "global", "final"], + customRules: [ + { + id: "cidr6", + type: "IP-CIDR6", + value: "2001:db8::/32", + target: "Missing", + noResolve: true, + }, + { + id: "process", + type: "PROCESS-NAME", + value: "curl", + target: "DIRECT", + noResolve: true, + }, + ], + customRuleSets: [ + { + id: "fallback-set", + name: "Fallback Set", + behavior: "domain", + path: "https://rules.example.com/fallback.mrs", + target: "Missing", + noResolve: false, + }, + { + id: "custom-set", + name: "Custom Set", + behavior: "classical", + path: "https://rules.example.com/custom.yaml", + target: { kind: "custom", id: "regional" }, + noResolve: true, + }, + ], + customProxyGroups: [ + { id: "regional", name: "Regional", emoji: "", groupType: "select" }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { enabled: false }, + "module:global:geolocation-!cn": { target: "Missing" }, + }, + experimentalCnUseCnRuleSet: true, + cnIpNoResolve: true, + availablePolicyTargets: ["DIRECT", "Regional", "🐟 漏网之鱼"], + fallbackPolicyTarget: "DIRECT", + ruleOrder: [ + "module:cn:cn-ip", + "custom-rule:process", + "custom-rule-set:custom-set", + "special:experimental-cn", + "special:match", + ], + }); + const texts = entries.map((entry) => entry.text); + const persisted = normalizePersistedRuleOrder({ + enabledModules: ["cn", "global", "final"], + customRules: [ + { id: "cidr6", type: "IP-CIDR6", value: "2001:db8::/32", target: "Missing", noResolve: true }, + { id: "process", type: "PROCESS-NAME", value: "curl", target: "DIRECT", noResolve: true }, + ], + customRuleSets: [ + { + id: "custom-set", + name: "Custom Set", + behavior: "classical", + path: "https://rules.example.com/custom.yaml", + target: { kind: "custom", id: "regional" }, + noResolve: true, + }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { enabled: false }, + }, + ruleOrder: [ + "module:cn:cn-ip", + "custom-rule:process", + "custom-rule:missing", + "special:experimental-cn", + "special:match", + ], + experimentalCnUseCnRuleSet: true, + fallbackPolicyTarget: "DIRECT", + }); + + expect(texts).toContain("IP-CIDR6,2001:db8::/32,DIRECT,no-resolve"); + expect(texts).toContain("PROCESS-NAME,curl,DIRECT"); + expect(texts).not.toContain("PROCESS-NAME,curl,DIRECT,no-resolve"); + expect(texts).toContain("RULE-SET,fallback-set,DIRECT"); + expect(texts).toContain("RULE-SET,custom-set,Regional,no-resolve"); + expect(texts).not.toContain("RULE-SET,cn-ip,🔒 国内服务,no-resolve"); + expect(texts).toContain("RULE-SET,geolocation-!cn,DIRECT"); + expect(texts).toContain("RULE-SET,cn,DIRECT"); + expect(texts).toContain("RULE-SET,geolocation-cn,DIRECT"); + expect(texts.at(-1)).toBe("MATCH,🐟 漏网之鱼"); + expect(persisted).toEqual([ + "module:cn:cn-ip", + "custom-rule:process", + "special:experimental-cn", + ]); + }); }); diff --git a/packages/core/src/generator/rules.ts b/packages/core/src/generator/rules.ts index 34bbf45..3983b96 100644 --- a/packages/core/src/generator/rules.ts +++ b/packages/core/src/generator/rules.ts @@ -1,13 +1,16 @@ -import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; -import { getCustomGroupRuleOrderKey, getCustomRuleOrderKey } from "@subboost/core/rules/custom-rule-utils"; +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, 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 +21,15 @@ export interface GeneratedRuleEntry { target: string; noResolve: boolean; editable: boolean; + enabled: boolean; } export interface RulesGenerateOptions { enabledModules: string[]; customRules: CustomRuleLike[]; + customRuleSets?: CustomRuleSetLike[]; customProxyGroups?: CustomProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; experimentalCnUseCnRuleSet?: boolean; cnIpNoResolve?: boolean; @@ -99,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)) ); @@ -107,11 +137,22 @@ 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, + customProxyGroups: CustomProxyGroup[] = [], 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 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 @@ -120,23 +161,26 @@ 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, + enabled: edit?.enabled !== false, }; } 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"; @@ -149,42 +193,45 @@ function buildCustomRuleEntry( target, noResolve, editable: true, + enabled: true, }; } -function buildCustomGroupRuleEntry( - group: CustomProxyGroup, - rule: CustomProxyGroup["rules"][number], +function buildCustomRuleSetEntry( + ruleSet: CustomRuleSetLike, + moduleNames: Record, + customProxyGroups: CustomProxyGroup[] = [], 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(resolveProxyGroupTargetName(ruleSet.target, { moduleNames, customProxyGroups })); + 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[], + moduleNames: Record = {}, + customProxyGroups: CustomProxyGroup[] = [], 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)) - ), + ...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); @@ -208,9 +255,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 +316,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 +366,9 @@ 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; @@ -331,7 +398,15 @@ function buildCanonicalRuleEntries(options: Omit { - const entry = buildModuleRuleEntry(module, rule, 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); @@ -342,8 +417,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 +441,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 +451,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 +506,9 @@ export function resolveAppliedRuleOrder(options: RulesGenerateOptions): string[] const { preMatchEntries } = buildCanonicalRuleEntries({ enabledModules: options.enabledModules, customRules: options.customRules, + customRuleSets: options.customRuleSets, customProxyGroups: options.customProxyGroups, - moduleRuleOverrides: options.moduleRuleOverrides, - moduleRuleExclusions: options.moduleRuleExclusions, + builtinRuleEdits: options.builtinRuleEdits, proxyGroupNameOverrides: options.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: options.experimentalCnUseCnRuleSet, cnIpNoResolve: options.cnIpNoResolve, @@ -445,15 +517,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 +583,16 @@ 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, + customRuleSets: options.customRuleSets, customProxyGroups: options.customProxyGroups, - moduleRuleOverrides: options.moduleRuleOverrides, - moduleRuleExclusions: options.moduleRuleExclusions, + builtinRuleEdits: options.builtinRuleEdits, proxyGroupNameOverrides: options.proxyGroupNameOverrides, experimentalCnUseCnRuleSet: options.experimentalCnUseCnRuleSet, cnIpNoResolve: options.cnIpNoResolve, @@ -528,7 +601,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/generator/yaml-semantics.test.ts b/packages/core/src/generator/yaml-semantics.test.ts new file mode 100644 index 0000000..064499b --- /dev/null +++ b/packages/core/src/generator/yaml-semantics.test.ts @@ -0,0 +1,193 @@ +import yaml from "js-yaml"; +import { describe, expect, it, vi } from "vitest"; + +import { + diffGeneratedYamlSemantics, + GeneratedYamlSemanticError, + 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" })], + }); + }); + + it("handles empty YAML, scalar parse errors, and low-impact changes", () => { + const emptyA = parseGeneratedYamlSemantics(""); + const emptyB = parseGeneratedYamlSemantics("null\n"); + const lowBefore = parseGeneratedYamlSemantics(` +mixed-port: 7897 +profile: + store-selected: true +limits: + nan: .nan + pos: .inf + neg: -.inf +`); + const lowAfter = parseGeneratedYamlSemantics(` +mixed-port: 7898 +profile: + store-selected: true +limits: + nan: .nan + pos: .inf + neg: -.inf +`); + + expect(diffGeneratedYamlSemantics(emptyA, emptyA)).toEqual({ + changed: false, + severity: "none", + issues: [], + }); + expect(diffGeneratedYamlSemantics(emptyA, emptyB)).toMatchObject({ + changed: true, + severity: "format-only", + }); + expect(diffGeneratedYamlSemantics(lowBefore, lowAfter)).toMatchObject({ + changed: true, + severity: "low", + issues: [expect.objectContaining({ path: "mixed-port", severity: "low" })], + }); + expect(hashGeneratedYamlSemantics(lowBefore)).toMatch(/^[a-f0-9]{8}$/); + expect(() => parseGeneratedYamlSemantics("just-a-string")).toThrow(GeneratedYamlSemanticError); + expect(() => parseGeneratedYamlSemantics("dns:\n nameserver: [")).toThrow("Generated YAML parse failed"); + }); + + it("normalizes undefined semantic fields and formats unusual parser errors", () => { + const snapshot = { + version: 1 as const, + rawFingerprint: "", + semanticFingerprint: "", + sections: { + keep: { b: 2, a: undefined }, + skip: undefined, + }, + }; + expect(hashGeneratedYamlSemantics(snapshot)).toMatch(/^[a-f0-9]{8}$/); + + const load = vi.spyOn(yaml, "load"); + try { + load.mockImplementationOnce(() => []); + expect(() => parseGeneratedYamlSemantics("[]")).toThrow("top-level object"); + + load.mockImplementationOnce(() => { + throw "plain parser failure"; + }); + expect(() => parseGeneratedYamlSemantics("bad")).toThrow("plain parser failure"); + + load.mockImplementationOnce(() => { + throw { message: "object parser failure", mark: {} }; + }); + expect(() => parseGeneratedYamlSemantics("bad")).toThrow("object parser failure"); + } finally { + load.mockRestore(); + } + }); +}); diff --git a/packages/core/src/generator/yaml-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, + }; +} diff --git a/packages/core/src/generator/yaml.ts b/packages/core/src/generator/yaml.ts index a9ff086..7efb8cd 100644 --- a/packages/core/src/generator/yaml.ts +++ b/packages/core/src/generator/yaml.ts @@ -1,6 +1,6 @@ import type { KnownNodeType } from "@subboost/core/types/node"; import type { ClashConfig } from "@subboost/core/types/config"; -import { sanitizeMihomoProxyNode } from "@subboost/core/mihomo/proxy-sanitizer"; +import { sanitizeMihomoProxyNode } from "../mihomo/proxy-sanitizer"; export type DnsPolicyValue = string | string[]; diff --git a/packages/core/src/mihomo/proxy-sanitizer.test.ts b/packages/core/src/mihomo/proxy-sanitizer.test.ts index b26421e..84cb7a9 100644 --- a/packages/core/src/mihomo/proxy-sanitizer.test.ts +++ b/packages/core/src/mihomo/proxy-sanitizer.test.ts @@ -544,4 +544,486 @@ describe("Mihomo proxy sanitizer", () => { }, }); }); + + it("covers sanitizer boundary aliases and optional protocol fallbacks", () => { + const ecdsaSsh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": [ + "ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-ecdsa-!bad AAAAC3NzaC1lZDI1NTE5AAAAIA==", + "ssh-rsa bad!token", + ], + }); + const certless = sanitizeMihomoProxyNode({ + name: "HTTP", + type: "http", + server: "http.example.com", + port: 80, + fingerprint: 1, + }); + const clientFingerprintAlreadySet = sanitizeMihomoProxyNode({ + name: "Trojan", + type: "trojan", + server: "trojan.example.com", + port: 443, + password: "secret", + fingerprint: "Chrome", + "client-fingerprint": "safari", + }); + const wireguardReservedArray = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: [1, "2", 3], + }); + const xhttpNoReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "ech-opts": { + enable: false, + config: Buffer.from("ech").toString("base64"), + "query-server-name": " ech.example.com ", + }, + }, + }, + }); + + expect(ecdsaSsh).toMatchObject({ + "host-key": ["ssh-ecdsa-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIA=="], + }); + expect(certless).not.toHaveProperty("fingerprint"); + expect(clientFingerprintAlreadySet).toMatchObject({ "client-fingerprint": "safari" }); + expect(clientFingerprintAlreadySet).not.toHaveProperty("fingerprint"); + expect(wireguardReservedArray).toHaveProperty("reserved", [1, 2, 3]); + expect(xhttpNoReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "ech-opts": { + enable: false, + config: Buffer.from("ech").toString("base64"), + "query-server-name": "ech.example.com", + }, + }, + }, + }); + expect( + isMihomoSupportedProxyNode({ + type: "trojan", + name: "Trojan", + server: "trojan.example.com", + port: 443, + password: "secret", + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "v2ray-plugin", + }) + ).toBe(true); + }); + + it("rejects explicit malformed optional transport fields", () => { + const ssh = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": [ + "ssh-rsa AAAA", + "ssh-dss AAAA comment", + "ssh-ed25519", + "ssh-ecdsa-!bad AAAA", + ], + }); + const invalidReserved = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: "1,2", + }); + const explicitEmptyDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + const invalidDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }); + const invalidMainReality = normalizeMihomoVlessForGeneration({ + name: "Reality", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + "reality-opts": "bad", + }); + const prefixedCertificate = sanitizeMihomoProxyNode({ + name: "HTTPS", + type: "https", + server: "https.example.com", + port: 443, + fingerprint: "SHA256=" + "C".repeat(64).match(/.{1,2}/g)?.join(":"), + }); + + expect(isMihomoSupportedProxyNode({ type: "ss", cipher: "", password: "secret" })).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }) + ).toBe(false); + expect(ssh).toMatchObject({ "host-key": ["ssh-rsa AAAA", "ssh-dss AAAA comment"] }); + expect(invalidReserved).not.toHaveProperty("reserved"); + expect(explicitEmptyDownloadReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }); + expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(invalidMainReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(prefixedCertificate).toHaveProperty("fingerprint", "c".repeat(64)); + }); + + it("covers conservative sanitizer fallbacks for omitted and malformed optional fields", () => { + const sshWithScalarHostKey = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": "ssh-rsa AAAA", + "private-key": undefined, + "server-fingerprint": 1, + }); + const sshWithEmptyHostKeyMaterial = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": ["ssh-rsa ", "ssh-ecdsa- AAAA", "ssh-ed25519 AAAA"], + }); + const wireguardWithUndefinedOptionalKeys = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": undefined, + "pre-shared-key": undefined, + reserved: "", + }); + const plainHttp = sanitizeMihomoProxyNode({ + name: "HTTP", + type: "http", + server: "http.example.com", + port: 80, + udp: "TRUE", + tls: "FALSE", + alpn: [], + fingerprint: "Not-A-Known-Alias", + "ws-opts": {}, + }); + const vlessWithoutReality = sanitizeMihomoProxyNode({ + name: "VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + "short-id": "not-hex", + }, + "ech-opts": { + enable: "0", + config: "", + "query-server-name": " ", + }, + }, + }, + }); + + expect(sshWithScalarHostKey).not.toHaveProperty("host-key"); + expect(sshWithScalarHostKey).not.toHaveProperty("private-key"); + expect(sshWithScalarHostKey).not.toHaveProperty("server-fingerprint"); + expect(sshWithEmptyHostKeyMaterial).toHaveProperty("host-key", ["ssh-ed25519 AAAA"]); + expect(wireguardWithUndefinedOptionalKeys).toMatchObject({ + "private-key": WIREGUARD_KEY, + }); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("public-key"); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("pre-shared-key"); + expect(wireguardWithUndefinedOptionalKeys).not.toHaveProperty("reserved"); + expect(plainHttp).toMatchObject({ udp: true, tls: false }); + expect(plainHttp).not.toHaveProperty("alpn"); + expect(plainHttp).not.toHaveProperty("fingerprint"); + expect(plainHttp).not.toHaveProperty("ws-opts"); + expect(vlessWithoutReality).toMatchObject({ + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + "ech-opts": { + enable: false, + }, + }, + }, + }); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"][ + "reality-opts" + ] + ).not.toHaveProperty("short-id"); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] + ).not.toHaveProperty("config"); + expect( + (vlessWithoutReality["xhttp-opts"] as Record>)["download-settings"]["ech-opts"] + ).not.toHaveProperty("query-server-name"); + + expect(isMihomoSupportedProxyNode({ type: "http", name: "HTTP" })).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "wireguard", + name: "WG", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + "public-key": undefined, + "pre-shared-key": WIREGUARD_KEY, + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + "private-key": "bad", + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssh", + name: "SSH", + server: "ssh.example.com", + port: 22, + "private-key": PRIVATE_KEY, + }) + ).toBe(true); + expect( + isMihomoSupportedProxyNode({ + type: "ss", + name: "SS", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + plugin: "simple-obfs", + }) + ).toBe(true); + }); + + it("covers remaining protocol support and cleanup fallbacks", () => { + expect(isStandardBase64String("abcd!")).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "ssr", + name: "SSR", + server: "ssr.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + protocol: "", + obfs: "plain", + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + name: "XHTTP", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, + "xhttp-opts": { + mode: "stream-one", + "download-settings": { + path: "/download", + }, + }, + }) + ).toBe(false); + expect( + isMihomoSupportedProxyNode({ + type: "vless", + name: "XHTTP", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "reality-opts": { "public-key": REALITY_PUBLIC_KEY }, + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "", + }, + }, + }, + }) + ).toBe(true); + + const invalidContainers = sanitizeMihomoProxyNode({ + name: "Containers", + type: "vmess", + server: "vmess.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + ech: "bad", + alpn: 443, + "ech-opts": [], + "ws-opts": {}, + fingerprint: "unknown", + }); + const sshWithoutHostKey = sanitizeMihomoProxyNode({ + name: "SSH", + type: "ssh", + server: "ssh.example.com", + port: 22, + password: "secret", + "host-key": "ssh-ed25519 AAAA", + "server-fingerprint": `SHA256:${"B".repeat(43)}=`, + }); + const wireguardMissingReserved = sanitizeMihomoProxyNode({ + name: "WG", + type: "wireguard", + server: "wg.example.com", + port: 51820, + "private-key": WIREGUARD_KEY, + reserved: "1,2", + }); + const noRealityDownloadSettings = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + path: "/download", + }, + }, + }); + const invalidDownloadReality = normalizeMihomoVlessForGeneration({ + name: "XHTTP", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + network: "xhttp", + "xhttp-opts": { + "download-settings": { + "reality-opts": { + "public-key": "bad", + }, + }, + }, + }); + const explicitTlsReality = normalizeMihomoVlessForGeneration({ + name: "Reality", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + tls: true, + "client-fingerprint": "edge", + "reality-opts": { + "public-key": REALITY_PUBLIC_KEY, + }, + }); + const noEncryption = sanitizeMihomoProxyNode({ + name: "VLESS", + type: "vless", + server: "vless.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + encryption: " ", + }); + + expect(invalidContainers).not.toHaveProperty("alpn"); + expect(invalidContainers).not.toHaveProperty("ech-opts"); + expect(invalidContainers).not.toHaveProperty("fingerprint"); + expect(invalidContainers).not.toHaveProperty("ws-opts"); + expect(sshWithoutHostKey).not.toHaveProperty("host-key"); + expect(sshWithoutHostKey).toHaveProperty("server-fingerprint", `SHA256:${"B".repeat(43)}=`); + expect(wireguardMissingReserved).not.toHaveProperty("reserved"); + expect(noRealityDownloadSettings).toMatchObject({ + "xhttp-opts": { + "download-settings": { + path: "/download", + }, + }, + }); + expect(invalidDownloadReality).toHaveProperty("_subboost-invalid-mihomo-node", true); + expect(explicitTlsReality).toMatchObject({ + tls: true, + "client-fingerprint": "edge", + }); + expect(noEncryption).not.toHaveProperty("encryption"); + }); }); diff --git a/packages/core/src/parser/clash-yaml.test.ts b/packages/core/src/parser/clash-yaml.test.ts index 34bcc80..4fd10a5 100644 --- a/packages/core/src/parser/clash-yaml.test.ts +++ b/packages/core/src/parser/clash-yaml.test.ts @@ -311,4 +311,107 @@ proxies: }); expect(result.errors[0]).toContain('节点 "HY Ports Only" 解析失败'); }); + + it("covers non-mutating YAML normalization branches and protocol defaults", () => { + const result = parseClashYaml(` +proxies: + - 1 + - name: VMess TCP + type: vmess + server: vmess-tcp.example.com + port: 80 + - name: VMess WS Empty + type: vmess + server: vmess-ws-empty.example.com + port: 443 + network: ws + - name: VMess WS Plain + type: vmess + server: vmess-ws-plain.example.com + port: 443 + network: ws + ws-opts: + path: /plain + - name: VLESS No Reality + type: vless + server: vless-none.example.com + port: 443 + - name: VLESS Empty Reality + type: vless + server: vless-empty.example.com + port: 443 + reality-opts: + short-id: "0x" + - name: VLESS Numeric Reality + type: vless + server: vless-numeric.example.com + port: 443 + reality-opts: + public-key: 123 + short-id: 7 + - name: AnyTLS Default + type: anytls + server: anytls-default.example.com + port: 443 + - name: HY2 Default + type: hysteria2 + server: hy2-default.example.com + port: 443 + - name: TUIC Default + type: tuic + server: tuic-default.example.com + port: 443 + - name: Masque + type: masque + server: masque.example.com + port: 443 + - name: Sudoku + type: sudoku + server: sudoku.example.com + port: 443 + - name: Bad Unknown + type: ss + port: bad +`); + + expect(result.nodes.find((node) => node.name === "VMess TCP")).toMatchObject({ + type: "vmess", + uuid: "", + network: undefined, + }); + expect(result.nodes.find((node) => node.name === "VMess WS Empty")).toMatchObject({ + type: "vmess", + network: "ws", + }); + expect(result.nodes.find((node) => node.name === "VMess WS Plain")).toMatchObject({ + "ws-opts": { path: "/plain" }, + }); + expect(result.nodes.find((node) => node.name === "VLESS No Reality")).toMatchObject({ + type: "vless", + uuid: "", + }); + expect(result.nodes.find((node) => node.name === "VLESS Empty Reality")?.["reality-opts"]).toBeUndefined(); + expect(result.nodes.find((node) => node.name === "VLESS Numeric Reality")).toMatchObject({ + "reality-opts": { + "public-key": 123, + "short-id": "07", + }, + }); + expect(result.nodes.find((node) => node.name === "AnyTLS Default")).toMatchObject({ + type: "anytls", + password: "", + }); + expect(result.nodes.find((node) => node.name === "HY2 Default")).toMatchObject({ + type: "hysteria2", + password: "", + }); + expect(result.nodes.find((node) => node.name === "TUIC Default")).toMatchObject({ + type: "tuic", + uuid: "", + password: "", + }); + expect(result.nodes.find((node) => node.name === "Masque")).toMatchObject({ type: "masque" }); + expect(result.nodes.find((node) => node.name === "Sudoku")).toMatchObject({ type: "sudoku" }); + expect(result.errors[0]).toContain('节点 "Bad Unknown" 解析失败'); + }); }); diff --git a/packages/core/src/parser/config-line-parser.test.ts b/packages/core/src/parser/config-line-parser.test.ts index 7c89076..c21ae05 100644 --- a/packages/core/src/parser/config-line-parser.test.ts +++ b/packages/core/src/parser/config-line-parser.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { looksLikeConfigLine } from "./config-line-parser"; import { mustParseConfigLine } from "./config-line-parser.test-helpers"; import { + applyCommonNodeParams, + applyTransport, inferSkipCertVerify, isUuidLike, parseBooleanish, @@ -34,6 +36,10 @@ describe("config line tokenizer helpers", () => { }); expect(() => tokenizeConfigLine("broken")).toThrow("无效的配置行格式"); expect(() => tokenizeConfigLine("Bad = ss, example.com, 70000")).toThrow("配置行中的地址或端口无效"); + expect(tokenizeConfigLine("Ignored = ss, ignored.example.com, 8388, =empty, flag")).toMatchObject({ + params: {}, + extras: ["flag"], + }); }); it("normalizes common primitive params", () => { @@ -45,6 +51,7 @@ describe("config line tokenizer helpers", () => { expect(parseStringList("a, b,,c")).toEqual(["a", "b", "c"]); expect(parseWsHeaders(undefined)).toBeUndefined(); expect(parseWsHeaders("bad|also-bad")).toBeUndefined(); + expect(parseWsHeaders("Host:|:missing|Good:yes")).toEqual({ Good: "yes" }); expect(parseWsHeaders('Host:cdn.example.com|X-Test:"yes"')).toEqual({ Host: "cdn.example.com", "X-Test": "yes", @@ -59,6 +66,150 @@ describe("config line tokenizer helpers", () => { expect(inferSkipCertVerify({ "tls-verification": "true" })).toBeUndefined(); expect(inferSkipCertVerify({ "allow-insecure": "0" })).toBe(false); }); + + it("applies shared node params to non-VMess protocols and rare TLS aliases", () => { + const trojan: Record = { type: "trojan" }; + applyCommonNodeParams(trojan, { + peer: "trojan-sni.example.com", + "tls-cert-sha256": "cert", + "tls_pubkey_sha256": "pub", + "disable-sni": "true", + "block-quic": "true", + "udp-port": "53", + "fast-open": "false", + "shadow-tls-version": "3", + "shadow-tls-sni": "shadow.example.com", + "shadow-tls-password": "shadow-secret", + }); + + expect(trojan).toMatchObject({ + sni: "trojan-sni.example.com", + "tls-cert-sha256": "cert", + "tls-pubkey-sha256": "pub", + "disable-sni": true, + "block-quic": true, + "udp-port": 53, + tfo: false, + "shadow-tls-version": 3, + "shadow-tls-sni": "shadow.example.com", + "shadow-tls-password": "shadow-secret", + }); + + const hysteria2: Record = { type: "hysteria2" }; + applyCommonNodeParams(hysteria2, { fingerprint: "chrome" }); + expect(hysteria2).toMatchObject({ fingerprint: "chrome" }); + }); + + it("applies transport helpers across default, header, and xHTTP edge branches", () => { + const defaultWs: Record = {}; + applyTransport(defaultWs, { "ws-path": "/ws?ed=128", "ws-headers": "Host:from-header.example.com|X-Test:yes" }, { + defaultTransport: "ws", + }); + expect(defaultWs).toMatchObject({ + network: "ws", + "ws-opts": { + path: "/ws", + headers: { + Host: "from-header.example.com", + "X-Test": "yes", + }, + "early-data-header-name": "Sec-WebSocket-Protocol", + "max-early-data": 128, + }, + }); + + const plainGrpc: Record = {}; + applyTransport(plainGrpc, { transport: "grpc", path: "/svc" }); + expect(plainGrpc).toMatchObject({ + network: "grpc", + "grpc-opts": { + "grpc-service-name": "svc", + }, + }); + + const blankHttp: Record = {}; + applyTransport(blankHttp, { transport: "http", method: " ", path: " , " }); + expect(blankHttp).toMatchObject({ + network: "http", + "http-opts": { + method: "GET", + path: ["/"], + headers: undefined, + }, + }); + + const xhttp: Record = {}; + applyTransport(xhttp, { + transport: "xhttp", + path: "/x", + host: "cdn.example.com", + mode: "packet-up", + "xhttp-headers": "User-Agent:SubBoost", + "no-grpc-header": "off", + "sc-max-each-post-bytes": "bad", + "download-headers": "Accept:yaml", + }, { + allowedTransports: ["tcp", "xhttp"], + }); + expect(xhttp).toMatchObject({ + network: "xhttp", + "xhttp-opts": { + path: "/x", + host: "cdn.example.com", + mode: "packet-up", + headers: { "User-Agent": "SubBoost" }, + "no-grpc-header": false, + "download-settings": { + headers: { Accept: "yaml" }, + }, + }, + }); + + expect(() => applyTransport({}, { transport: "udp" }, { allowedTransports: ["tcp"], protocolName: "测试" })).toThrow( + "不支持的 测试 传输层" + ); + expect(() => applyTransport({}, { transport: " " }, { allowedTransports: ["tcp"] })).toThrow( + "transport=(empty)" + ); + + const tcp: Record = {}; + applyTransport(tcp, { transport: "tcp" }); + expect(tcp).toMatchObject({ network: "tcp" }); + + const xhttpAliases: Record = {}; + applyTransport(xhttpAliases, { + network: "xhttp", + path: "/alias", + headers: "Host:edge.example.com", + "max-connections": "2", + "c-max-reuse-times": "3", + "h-max-request-times": "4", + "h-max-reusable-secs": "5", + no_grpc_header: "yes", + sc_max_each_post_bytes: "4096", + downloadheaders: "Accept:yaml", + }, { + allowedTransports: ["tcp", "xhttp"], + }); + expect(xhttpAliases).toMatchObject({ + network: "xhttp", + "xhttp-opts": { + path: "/alias", + headers: { Host: "edge.example.com" }, + "no-grpc-header": true, + "sc-max-each-post-bytes": 4096, + "reuse-settings": { + "max-connections": "2", + "c-max-reuse-times": "3", + "h-max-request-times": "4", + "h-max-reusable-secs": "5", + }, + "download-settings": { + headers: { Accept: "yaml" }, + }, + }, + }); + }); }); describe("config line parser", () => { @@ -551,4 +702,80 @@ describe("config line parser", () => { "不支持的配置行协议: unknown" ); }); + + it("builds rare config-line aliases and conservative defaults", () => { + expect(mustParseConfigLine("SOCKS5 TLS Alias = socks5-tls, socks-tls-alias.example.com, 1080, over-tls=false")).toMatchObject({ + name: "SOCKS5 TLS Alias", + type: "socks5", + tls: false, + }); + + const httpNoHeaders = mustParseConfigLine("HTTP No Headers = http, http-no-headers.example.com, 8080, headers=bad|Host:"); + expect(httpNoHeaders).toMatchObject({ + name: "HTTP No Headers", + type: "http", + server: "http-no-headers.example.com", + }); + expect(httpNoHeaders.headers).toBeUndefined(); + + expect( + mustParseConfigLine( + "VMess Username = vmess, vmess-user.example.com, 443, username=11111111-1111-4111-8111-111111111111, encryption=auto, udp-relay=false, tls-verification=false, fp=chrome" + ) + ).toMatchObject({ + name: "VMess Username", + type: "vmess", + uuid: "11111111-1111-4111-8111-111111111111", + cipher: "auto", + udp: false, + "skip-cert-verify": true, + "client-fingerprint": "chrome", + }); + + expect( + mustParseConfigLine( + "VLESS Alias = vless, vless-alias.example.com, 443, username=11111111-1111-4111-8111-111111111111, public_key=pub, short_id=sid, packetencoding=xudp, fingerprint=edge, udp-relay=false" + ) + ).toMatchObject({ + name: "VLESS Alias", + type: "vless", + uuid: "11111111-1111-4111-8111-111111111111", + udp: false, + "packet-encoding": "xudp", + "client-fingerprint": "edge", + "reality-opts": { + "public-key": "pub", + "short-id": "sid", + }, + }); + + expect( + mustParseConfigLine("AnyTLS None = anytls, anytls-none.example.com, 443, auth=secret, transport=none, server-name=sni.example.com") + ).toMatchObject({ + name: "AnyTLS None", + type: "anytls", + password: "secret", + sni: "sni.example.com", + }); + + expect( + mustParseConfigLine( + "TUIC Alias = tuic, tuic-alias.example.com, 443, uuid=11111111-1111-4111-8111-111111111111, password=secret, congestioncontrol=cubic, udprelaymode=quic, tfo=false" + ) + ).toMatchObject({ + name: "TUIC Alias", + type: "tuic", + uuid: "11111111-1111-4111-8111-111111111111", + password: "secret", + "congestion-controller": "cubic", + "udp-relay-mode": "quic", + tfo: false, + }); + + expect(mustParseConfigLine("Snell Loose = snell, snell-loose.example.com, 443, psk=secret, version=bad")).toMatchObject({ + name: "Snell Loose", + type: "snell", + psk: "secret", + }); + }); }); diff --git a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts index 3e1e367..59f1228 100644 --- a/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts +++ b/packages/core/src/parser/platform/parse-platform-proxy-line.test.ts @@ -104,6 +104,11 @@ describe("parsePlatformProxyLine", () => { sections: new Map([["WireGuard Bad", ["peer = (public-key = public)"]]]), }) ).toThrow("WireGuard section Bad 缺少有效 endpoint"); + expect(() => + parsePlatformProxyLine("Empty WG = wireguard, section-name=Empty", { + sections: new Map([["WireGuard Empty", []]]), + }) + ).toThrow("未找到 WireGuard section: Empty"); }); it("normalizes AnyTLS fields and rejects unsupported platform-only variants", () => { @@ -156,6 +161,36 @@ describe("parsePlatformProxyLine", () => { }); }); + it("normalizes platform-specific SSH and WireGuard fallback fields", () => { + expect( + parsePlatformProxyLine( + "SSH FP = ssh, ssh.example.com, 22, username=user, password=pass, tls-fingerprint=SHA256:abc" + ) + ).toMatchObject({ + name: "SSH FP", + type: "ssh", + server: "ssh.example.com", + port: 22, + }); + + const sections = new Map([ + [ + "WireGuard Minimal", + [ + "private-key = private", + 'peer = (endpoint = "wg-minimal.example.com:51820")', + ], + ], + ]); + expect(parsePlatformProxyLine("Minimal = wireguard, section-name=Minimal", { sections })).toMatchObject({ + name: "Minimal", + type: "wireguard", + server: "wg-minimal.example.com", + port: 51820, + udp: true, + }); + }); + it("parses Loon WireGuard lines with peer, DNS, and reserved metadata", () => { const node = parsePlatformProxyLine( 'WG = wireguard, interface-ip=10.0.0.2/32, interface-ipv6=fd00::2/128, private-key=private, peers=[{public-key=public, preshared-key=psk, endpoint="wg.example.com:51820", allowed-ips="0.0.0.0/0, ::/0", reserved="[1,2,3]"}], mtu=1280, keepalive=25, dns=1.1.1.1, dnsv6=2606:4700:4700::1111' @@ -209,6 +244,17 @@ describe("parsePlatformProxyLine", () => { "Surge WireGuard 缺少 section-name" ); expect(parsePlatformProxyLine('WG Broken = wireguard, private-key=private, peers=[{public-key=public}]')).toBeNull(); + expect(parsePlatformProxyLine('WG BadPort = wireguard, private-key=private, peers=[{endpoint="wg-bad.example.com:70000"}]')).toBeNull(); + expect( + parsePlatformProxyLine( + 'WG NoReserved = wireguard, private-key=private, peers=[{endpoint="wg-no-reserved.example.com:51820", reserved="[]"}]' + ) + ).toMatchObject({ + name: "WG NoReserved", + type: "wireguard", + server: "wg-no-reserved.example.com", + port: 51820, + }); }); it("parses Loon VLESS and Hysteria2 options", () => { @@ -301,6 +347,14 @@ describe("parsePlatformProxyLine", () => { password: "pass", tls: true, }); + expect( + parsePlatformProxyLine("http=http-plain.example.com:8080, username=user, password=pass, tag=QX HTTP Plain") + ).toMatchObject({ + name: "QX HTTP Plain", + type: "http", + server: "http-plain.example.com", + port: 8080, + }); }); it("returns null for blank, unrelated, or incomplete platform lines", () => { diff --git a/packages/core/src/parser/protocols/hysteria-protocols.test.ts b/packages/core/src/parser/protocols/hysteria-protocols.test.ts index 7775108..4c241c2 100644 --- a/packages/core/src/parser/protocols/hysteria-protocols.test.ts +++ b/packages/core/src/parser/protocols/hysteria-protocols.test.ts @@ -66,6 +66,12 @@ describe("Hysteria protocol parsers", () => { "hysteria2://secret@hy2.example.com:1000-1002?obfs=salamander&obfs_password=mask&upmbps=20&downmbps=30&hop-interval=10#Ports" ); const queryPorts = parseHysteria2("hysteria2://hy2.example.com?auth=secret&mport=2000,2001"); + const ipv6Default = parseHysteria2( + "hysteria2://secret@[2001:db8::2]///?obfs=none&alpn=,,&up=10kbps&down=20&hop-interval=0&hop_interval=5" + ); + const queryAuthAlias = parseHysteria2( + "hysteria2://alias.example.com?auth_str=query-secret&ports=3000-3002&hop-interval=30-15&hopInterval=20-30" + ); expect(ranged).toMatchObject({ name: "Ports", @@ -86,6 +92,24 @@ describe("Hysteria protocol parsers", () => { password: "secret", ports: "2000,2001", }); + expect(ipv6Default).toMatchObject({ + server: "2001:db8::2", + port: 443, + password: "secret", + sni: "2001:db8::2", + up: "10kbps", + down: "20 mbps", + "hop-interval": 5, + }); + expect(ipv6Default).not.toHaveProperty("obfs"); + expect(ipv6Default).not.toHaveProperty("alpn"); + expect(queryAuthAlias).toMatchObject({ + server: "alias.example.com", + password: "query-secret", + port: 3000, + ports: "3000-3002", + "hop-interval": "20-30", + }); }); it("rejects malformed Hysteria2 links", () => { diff --git a/packages/core/src/parser/protocols/more-protocols.test.ts b/packages/core/src/parser/protocols/more-protocols.test.ts index 3d005f7..818b47a 100644 --- a/packages/core/src/parser/protocols/more-protocols.test.ts +++ b/packages/core/src/parser/protocols/more-protocols.test.ts @@ -68,6 +68,18 @@ describe("additional protocol parser contracts", () => { "min-idle-session": 4, "padding-scheme": "pad", }); + expect( + parseAnyTLS( + "anytls://host-token.example.com?auth=secret&udp=1&alpn=,,&idle-session-check-interval=&idleSessionCheckInterval=15" + ) + ).toMatchObject({ + name: "AnyTLS-host-token.example.com:443", + server: "host-token.example.com", + password: "secret", + udp: true, + sni: "host-token.example.com", + "idle-session-check-interval": 15, + }); expect(() => parseAnyTLS("anytls://secret@anytls.example.com:443?security=xtls")).toThrow( "AnyTLS 不支持 security=xtls" diff --git a/packages/core/src/parser/protocols/netch.test.ts b/packages/core/src/parser/protocols/netch.test.ts index b754e6d..0bb260c 100644 --- a/packages/core/src/parser/protocols/netch.test.ts +++ b/packages/core/src/parser/protocols/netch.test.ts @@ -119,6 +119,31 @@ describe("parseNetch", () => { type: "snell", psk: "psk", }); + expect( + parseNetch( + netch({ + Type: "SS", + Hostname: "ss-no-plugin.example.com", + Port: 8388, + Password: "secret", + PluginOption: " ; =bad ; ", + EnableUDP: 1, + EnableTFO: " ", + }) + ) + ).toMatchObject({ + name: "SS-ss-no-plugin.example.com:8388", + type: "ss", + udp: true, + }); + expect( + parseNetch(netch({ Type: "SSR", Hostname: "ssr-default.example.com", Port: 8388, Password: "secret" })) + ).toMatchObject({ + name: "SSR-ssr-default.example.com:8388", + type: "ssr", + protocol: "origin", + obfs: "plain", + }); }); it("parses VMess and Trojan transports", () => { @@ -255,6 +280,37 @@ describe("parseNetch", () => { network: "tcp", sni: "trojan-tcp.example.com", }); + expect( + parseNetch( + netch({ + Type: "VMess", + Hostname: "vmess-h2-no-host.example.com", + Port: 443, + UserID: "11111111-1111-4111-8111-111111111111", + TransferProtocol: "h2", + }) + ) + ).toMatchObject({ + network: "h2", + "h2-opts": { path: "/" }, + }); + expect( + parseNetch( + netch({ + Type: "VMess", + Hostname: "vmess-http-no-host.example.com", + Port: 80, + UserID: "11111111-1111-4111-8111-111111111111", + TransferProtocol: "http", + Path: " ,/only", + }) + ) + ).toMatchObject({ + network: "http", + "http-opts": { + path: ["/only"], + }, + }); }); it("parses additional VMess and Trojan transport variants", () => { @@ -305,6 +361,20 @@ describe("parseNetch", () => { "skip-cert-verify": true, "ws-opts": { path: "/" }, }); + expect( + parseNetch(netch({ Type: "Snell", Hostname: "snell-http.example.com", Port: 443, Password: "psk", OBFS: "http" })) + ).toMatchObject({ + type: "snell", + "obfs-opts": { mode: "http" }, + }); + expect(parseNetch(netch({ Type: "Socks", Hostname: "socks-bare.example.com", Port: 1080 }))).toMatchObject({ + type: "socks5", + }); + expect(parseNetch(netch({ Type: "HTTPS", Hostname: "https-pass.example.com", Port: 443, Password: "p" }))).toMatchObject({ + type: "https", + password: "p", + tls: true, + }); }); it("keeps Netch validation errors explicit", () => { @@ -314,6 +384,7 @@ describe("parseNetch", () => { expect(() => parseNetch(netch({ Type: "SS", Hostname: "ss.example.com", Port: 8388 }))).toThrow("Netch SS 缺少 password"); expect(() => parseNetch(netch({ Type: "SSR", Hostname: "ssr.example.com", Port: 8388 }))).toThrow("Netch SSR 缺少 password"); expect(() => parseNetch(netch({ Type: "VMess", Hostname: "vmess.example.com", Port: 443 }))).toThrow("Netch VMess 缺少 uuid"); + expect(() => parseNetch(netch({ Type: "Trojan", Hostname: "trojan.example.com", Port: 443 }))).toThrow("Netch Trojan 缺少 password"); expect(() => parseNetch( netch({ diff --git a/packages/core/src/parser/protocols/simple-proxy.test.ts b/packages/core/src/parser/protocols/simple-proxy.test.ts index 10cf10a..489e131 100644 --- a/packages/core/src/parser/protocols/simple-proxy.test.ts +++ b/packages/core/src/parser/protocols/simple-proxy.test.ts @@ -15,6 +15,7 @@ describe("simple proxy parsers", () => { const colonAuth = parseHttp("http://colon.example.com:8080:user:p%40ss#Colon"); const headerAlias = parseHttp("http://alias.example.com:8080?header=bad|:skip|X-Empty:&skip_cert_verify=no&peer=peer.example.com"); const ipv6 = parseHttp("http://[2001:db8::1]:8080#IPv6"); + const tlsVerified = parseHttp("http://verified.example.com:8080?tls-verification=true#Verified"); expect(http).toMatchObject({ name: "HTTP", @@ -66,6 +67,10 @@ describe("simple proxy parsers", () => { server: "2001:db8::1", port: 8080, }); + expect(tlsVerified).toMatchObject({ + name: "Verified", + "skip-cert-verify": false, + }); }); it("parses SOCKS variants including encoded authority and encoded username auth", () => { @@ -76,6 +81,7 @@ describe("simple proxy parsers", () => { const fromAuthorityNoAuth = parseSocks(`socks://${encodedNoAuth}`); const fromUsername = parseSocks(`socks5://${encodedAuth}@socks-auth.example.com:1080#Auth`); const tls = parseSocks("socks5+tls://carol:secret@socks-tls.example.com:443?udp=0&sni=socks.example.com#TLS"); + const tlsVerified = parseSocks("socks5://verified-socks.example.com:1080?tls-verification=true#VerifiedSocks"); const socks4 = parseSocks("socks4://dave@socks4.example.com?udp-relay=yes&allow_insecure=true#S4"); const defaultName = parseSocks("socks5://socks-default.example.com:1080"); const controlAuthority = parseSocks(`socks://${Buffer.from("\x00bad").toString("base64url")}#Control`); @@ -104,6 +110,10 @@ describe("simple proxy parsers", () => { udp: false, sni: "socks.example.com", }); + expect(tlsVerified).toMatchObject({ + name: "VerifiedSocks", + "skip-cert-verify": false, + }); expect(socks4).toMatchObject({ name: "S4", type: "socks4", @@ -135,6 +145,7 @@ describe("simple proxy parsers", () => { "ssh://root:secret@ssh.example.com:22?private-key=KEY&host-key=a,b&server-fingerprint=fp&idle-timeout=30&host-key-algorithms=ssh-ed25519|rsa&allow-insecure=1#SSH" ); const sshAliases = parseSsh("ssh://alias@ssh-alias.example.com?private_key=KEY&hostKey=a;b&idle_timeout=bad#AliasSSH"); + const sshEmptyHostKey = parseSsh("ssh://ssh-empty.example.com?host-key=,&private-key=KEY#EmptyHostKey"); expect(naked).toMatchObject({ name: "Naked", @@ -168,6 +179,7 @@ describe("simple proxy parsers", () => { username: "user", password: "p:a:ss", }); + expect(() => parseSimpleProxy("[2001:db8::2]:1080{IPv6 Naked}", "socks5")).toThrow("无效的端口号"); expect(() => parseSimpleProxy("user@[2001:db8::1]{IPv6}", "socks5")).toThrow("缺少服务器地址"); expect(ssh).toMatchObject({ name: "SSH", @@ -191,6 +203,96 @@ describe("simple proxy parsers", () => { "host-key": ["a", "b"], }); expect(sshAliases).not.toHaveProperty("idle-timeout"); + expect(sshEmptyHostKey).toMatchObject({ + name: "EmptyHostKey", + "private-key": "KEY", + }); + expect(sshEmptyHostKey).not.toHaveProperty("host-key"); + }); + + it("keeps malformed suffixes and edge auth fields explicit in simple proxy forms", () => { + expect(parseSimpleProxy("suffix.example.com:8080}", "http")).toMatchObject({ + name: "HTTP-suffix.example.com:8080", + server: "suffix.example.com", + port: 8080, + }); + expect(parseSimpleProxy("nested.example.com:8080{bad}}", "http")).toMatchObject({ + name: "HTTP-nested.example.com:8080", + server: "nested.example.com", + port: 8080, + }); + expect(parseHttp("http://empty-headers.example.com:8080?headers= ")).not.toHaveProperty("headers"); + expect(parseHttp("http://pipe.example.com:8080?headers=A:B||C:D")).toMatchObject({ + headers: { A: "B", C: "D" }, + }); + + const encodedHttp = Buffer.from("http-base.example.com:8080").toString("base64url"); + expect(parseHttp(`http://${encodedHttp}?remark=EncodedName`)).toMatchObject({ + name: "EncodedName", + server: "http-base.example.com", + port: 8080, + }); + + const encodedMissingPort = Buffer.from("alice:secret@socks-missing-port.example.com").toString("base64url"); + expect(parseSocks(`socks://${encodedMissingPort}#MissingPort`)).toMatchObject({ + name: "MissingPort", + server: encodedMissingPort, + port: 1080, + }); + + expect(parseSsh("ssh://ssh-minimal.example.com")).toMatchObject({ + name: "SSH-ssh-minimal.example.com:22", + server: "ssh-minimal.example.com", + port: 22, + }); + expect(parseHttp("https://forced.example.com")).toMatchObject({ + type: "https", + port: 443, + tls: true, + }); + }); + + it("covers standard URL branch fallbacks without changing compatibility behavior", () => { + const encodedUsernameOnly = Buffer.from("alice@base64-user.example.com:8080").toString("base64url"); + const encodedHash = Buffer.from("hash-base.example.com:8080").toString("base64url"); + const encodedSuffix = Buffer.from("suffix-base.example.com:8080").toString("base64url"); + const encodedAuthWithoutColon = Buffer.from("only-user").toString("base64url"); + + expect(parseHttp(`http://${encodedUsernameOnly}#EncodedUser`)).toMatchObject({ + name: "EncodedUser", + server: "base64-user.example.com", + port: 8080, + username: "alice", + }); + expect(parseHttp(`http://${encodedHash}#HashName`)).toMatchObject({ + name: "HashName", + server: "hash-base.example.com", + port: 8080, + }); + expect(parseHttp(`http://${encodedSuffix}{SuffixName}`)).toMatchObject({ + name: "SuffixName", + server: "suffix-base.example.com", + port: 8080, + }); + expect(parseHttp('http://number-header.example.com:8080?headers={"A":1}')).not.toHaveProperty("headers"); + expect(parseHttp("http://empty-bool.example.com:8080?allow-insecure=&tls-name=tls.example.com")).toMatchObject({ + name: "HTTP-empty-bool.example.com:8080", + sni: "tls.example.com", + }); + expect(parseSocks(`socks5://${encodedAuthWithoutColon}@encoded-user.example.com:1080#RawEncoded`)).toMatchObject({ + name: "RawEncoded", + username: encodedAuthWithoutColon, + server: "encoded-user.example.com", + }); + expect(() => parseSocks("socks://")).toThrow("缺少服务器地址"); + expect(() => parseSocks("socks.example.com:1080")).toThrow("无效的 SOCKS 链接"); + expect(() => parseHttp("http://badcolon.example.com:x:user:pass")).toThrow("无效的端口号"); + expect(() => parseHttp("http://:8080:user:pass")).toThrow("缺少服务器地址"); + expect(parseSimpleProxy("user@[2001:db8::1]x", "socks5")).toMatchObject({ + username: "user", + server: "2001:db8::1", + port: 1080, + }); }); it("parses Telegram-style proxy links and keeps validation errors explicit", () => { @@ -219,6 +321,11 @@ describe("simple proxy parsers", () => { server: "tg-secure.example.com", tls: true, }); + expect(parseTelegramProxyLink("https://t.me/socks/proxy?server=tg-path.example.com&port=1080")).toMatchObject({ + name: "SOCKS-tg-path.example.com:1080", + type: "socks5", + server: "tg-path.example.com", + }); expect(() => parseSocks("http://example.com:1080")).toThrow("无效的 SOCKS 链接"); expect(() => parseSimpleProxy("ftp://example.com:21")).toThrow("不支持的协议: ftp"); @@ -229,6 +336,7 @@ describe("simple proxy parsers", () => { expect(() => parseTelegramProxyLink("https://example.com/socks?server=x&port=1080")).toThrow( "无效的 Telegram 代理链接" ); + expect(() => parseTelegramProxyLink("https://t.me/?server=x&port=1080")).toThrow("无效的 Telegram 代理链接"); expect(() => parseTelegramProxyLink("tg://socks?port=1080")).toThrow("缺少服务器地址"); expect(() => parseTelegramProxyLink("tg://socks?server=x&port=70000")).toThrow("无效的端口号"); }); diff --git a/packages/core/src/parser/protocols/ss.test.ts b/packages/core/src/parser/protocols/ss.test.ts index 7e8980c..6d56298 100644 --- a/packages/core/src/parser/protocols/ss.test.ts +++ b/packages/core/src/parser/protocols/ss.test.ts @@ -54,9 +54,11 @@ describe("parseSS", () => { it("parses empty bool query flags and ignores invalid v2ray-plugin JSON", () => { const boolFlags = parseSS("ss://aes-128-gcm:secret@bool.example.com:8388?uot=&tfo=#Bool"); + const invalidFlags = parseSS("ss://aes-128-gcm:secret@flags.example.com:8388?uot=maybe&tfo=0#Flags"); const invalidJson = parseSS( `ss://aes-128-gcm:secret@plain.example.com:8388?v2ray-plugin=${encodeURIComponent("not-json")}#Plain` ); + const emptyJson = parseSS("ss://aes-128-gcm:secret@empty-json.example.com:8388?v2ray-plugin= #EmptyJSON"); expect(boolFlags).toMatchObject({ name: "Bool", @@ -64,11 +66,18 @@ describe("parseSS", () => { "udp-over-tcp": true, tfo: true, }); + expect(invalidFlags).toMatchObject({ + name: "Flags", + server: "flags.example.com", + }); + expect(invalidFlags).not.toHaveProperty("udp-over-tcp"); + expect(invalidFlags).not.toHaveProperty("tfo"); expect(invalidJson).toMatchObject({ name: "Plain", server: "plain.example.com", }); expect(invalidJson).not.toHaveProperty("plugin"); + expect(emptyJson).not.toHaveProperty("plugin"); }); it("normalizes plugin names and rejects malformed links", () => { @@ -92,10 +101,26 @@ describe("parseSS", () => { plugin: "gost-plugin", pluginOpts: { tls: true, mux: false, extra: "keep" }, }); + expect(normalizeSsPlugin("gost-plugin", { tls: true, mux: 1 })).toEqual({ + plugin: "gost-plugin", + pluginOpts: { tls: true, mux: true }, + }); + expect(normalizeSsPlugin("gost-plugin", { tls: "", mux: 2 })).toEqual({ + plugin: "gost-plugin", + pluginOpts: { tls: "", mux: 2 }, + }); expect(normalizeSsPlugin("xray-plugin", { tls: "off", mux: "maybe" })).toEqual({ plugin: "xray-plugin", pluginOpts: { tls: false, mux: "maybe" }, }); + expect(normalizeSsPlugin("xray-plugin", { tls: false, mux: "on" })).toEqual({ + plugin: "xray-plugin", + pluginOpts: { tls: false, mux: true }, + }); + expect(normalizeSsPlugin("xray-plugin", { tls: {}, mux: null })).toEqual({ + plugin: "xray-plugin", + pluginOpts: { tls: {}, mux: null }, + }); expect(normalizeSsPlugin("simple-obfs", { mode: "", host: "" })).toEqual({ plugin: "obfs", pluginOpts: undefined, @@ -120,6 +145,21 @@ describe("parseSS", () => { }, tfo: true, }); + expect( + parseSS( + `ss://${b64("aes-128-gcm:secret")}@empty-plugin.example.com:8388?plugin=${encodeURIComponent( + String.raw`obfs-local;%20;empty=` + )}#EmptyPlugin` + ) + ).toMatchObject({ + name: "EmptyPlugin", + plugin: "obfs", + }); + expect(parseSS(`ss://${b64("aes-128-gcm:secret@[2001:db8::2]:8388")}#FullIPv6`)).toMatchObject({ + name: "FullIPv6", + server: "2001:db8::2", + port: 8388, + }); expect(() => parseSS("http://bad")).toThrow("无效的 SS 链接"); expect(() => parseSS(`ss://${b64("aes-128-gcm:secret@ss.example.com")}`)).toThrow("无法解析服务器端口"); diff --git a/packages/core/src/parser/protocols/ssr-snell.test.ts b/packages/core/src/parser/protocols/ssr-snell.test.ts index 1a2daec..6515052 100644 --- a/packages/core/src/parser/protocols/ssr-snell.test.ts +++ b/packages/core/src/parser/protocols/ssr-snell.test.ts @@ -52,6 +52,26 @@ describe("SSR and Snell parsers", () => { protocol: "origin", obfs: "plain", }); + expect(parseSSR(`ssr://${b64(`empty-query.example.com:8388::::${b64("secret")}?`)}`)).toMatchObject({ + name: "SSR-empty-query.example.com:8388", + password: "secret", + }); + expect( + parseSSR( + `ssr://${b64( + `raw-param.example.com:8388:auth:aes-128-gcm:http:${b64("plain secret")}/?remarks=${b64("Raw SSR")}&&=skip&protoparam=${b64("proto raw")}&obfsparam=${b64("obfs raw")}` + )}` + ) + ).toMatchObject({ + name: "Raw SSR", + type: "ssr", + password: "plain secret", + protocol: "auth", + cipher: "aes-128-gcm", + obfs: "http", + "protocol-param": "proto raw", + "obfs-param": "obfs raw", + }); }); it("keeps SSR validation errors explicit", () => { diff --git a/packages/core/src/parser/protocols/vless.test.ts b/packages/core/src/parser/protocols/vless.test.ts index 4058ab2..08adce3 100644 --- a/packages/core/src/parser/protocols/vless.test.ts +++ b/packages/core/src/parser/protocols/vless.test.ts @@ -298,6 +298,84 @@ describe("parseVLESS", () => { expect(node).not.toHaveProperty("flow"); }); + it("keeps VLESS authority and xHTTP edge branches explicit", () => { + expect(parseVLESS(`vless://${UUID}@[2001:db8::1]:443?security=tls&type=ws&host=ipv6.example.com#IPv6`)) + .toMatchObject({ + name: "IPv6", + server: "2001:db8::1", + port: 443, + network: "ws", + "ws-opts": { + path: "/", + headers: { Host: "ipv6.example.com" }, + }, + }); + + const encoded = Buffer.from(`prefix:${UUID}@sr-flow.example.com:443`).toString("base64url"); + expect( + parseVLESS( + `vless://${encoded}?obfs=websocket&tls=yes&flow=xtls-rprx-vision&obfsParam=NoColon%7C%3Abad%7CHost%3Acdn.example.com&path=%2F#Flow` + ) + ).toMatchObject({ + name: "Flow", + server: "sr-flow.example.com", + flow: "xtls-rprx-vision", + network: "ws", + "ws-opts": { + path: "/", + headers: { Host: "NoColon|:bad|Host:cdn.example.com" }, + }, + }); + + expect( + parseVLESS( + `vless://${UUID}@xhttp-fallback.example.com:443?security=tls&type=xhttp&path=&host=&headers=NoColon%7C%3Aempty%7CGood%3Ayes&no-grpc-header=maybe&download-headers=%7B%22bad%22%3A1%7D#XFallback` + ) + ).toMatchObject({ + name: "XFallback", + network: "xhttp", + "xhttp-opts": { + path: "/", + headers: { Good: "yes" }, + }, + }); + }); + + it("handles VLESS authority probes and empty Shadowrocket websocket options", () => { + const encoded = Buffer.from(`${UUID}@sr-empty-options.example.com:443`).toString("base64url"); + + expect(parseVLESS(`vless://${UUID}@slash-authority.example.com:443/?type=ws#Slash`)).toMatchObject({ + name: "Slash", + server: "slash-authority.example.com", + network: "ws", + }); + expect(parseVLESS(`vless://${encoded}?obfs=websocket&obfsParam=%20&path=%3Fed%3D64#EmptyOptions`)) + .toMatchObject({ + name: "EmptyOptions", + server: "sr-empty-options.example.com", + network: "ws", + "ws-opts": { + path: "/", + headers: undefined, + "early-data-header-name": "Sec-WebSocket-Protocol", + "max-early-data": 64, + }, + }); + + expect(() => parseVLESS(`vless://${UUID}@missing-host-port.example.com?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@empty-host-port.example.com:?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@bad-host-port.example.com:abc?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@[2001:db8::1?x=1`)).toThrow(); + expect(() => parseVLESS(`vless://${UUID}@?x=1`)).toThrow(); + }); + + it("rejects malformed Shadowrocket-style VLESS probes before normal parsing", () => { + const noQuery = Buffer.from(`${UUID}@shadow-no-query.example.com:443`).toString("base64url"); + + expect(() => parseVLESS(`vless://${noQuery}`)).toThrow("VLESS 配置缺少必要字段"); + expect(() => parseVLESS(`vless://${UUID}@[2001:db8::1]?security=tls`)).toThrow("VLESS 配置缺少必要字段"); + }); + it("rejects unsupported transports and ECH without plain TLS", () => { expect(() => parseVLESS(`vless://${UUID}@bad.example.com:443?type=kcp#Bad`)).toThrow("不支持的 VLESS 传输层"); expect(() => parseVLESS(`vless://${UUID}@bad.example.com:443?security=reality&ech=config#Bad`)).toThrow( diff --git a/packages/core/src/parser/protocols/vmess-utils.test.ts b/packages/core/src/parser/protocols/vmess-utils.test.ts index 1282c98..056795b 100644 --- a/packages/core/src/parser/protocols/vmess-utils.test.ts +++ b/packages/core/src/parser/protocols/vmess-utils.test.ts @@ -20,9 +20,15 @@ describe("VMess parser utility helpers", () => { it("normalizes primitive values and headers", () => { expect(DINGTALK_USER_AGENT).toContain("DingTalk"); expect(hasDingTalkHost(["api.dingtalk.com"])).toBe(true); + expect(hasDingTalkHost(undefined)).toBe(false); + expect(hasDingTalkHost([])).toBe(false); expect(hasDingTalkHost(["example.com"])).toBe(false); expect(normalizeHttpMethod("post")).toBe("POST"); + expect(normalizeHttpMethod(undefined)).toBe("GET"); + expect(normalizeHttpMethod(" ")).toBe("GET"); expect(normalizeHttpMethod("bad method")).toBe("GET"); + expect(normalizeHeaderKey("connection")).toBe("Connection"); + expect(normalizeHeaderKey("X-Test")).toBe("X-Test"); expect(normalizeHeaderKey(" user-agent ")).toBe("User-Agent"); expect(parseHeaderRecord({ host: "cdn.example.com", empty: "", n: 1, b: false, list: ["a", "", "b"], bad: {} })).toEqual({ Host: ["cdn.example.com"], @@ -30,6 +36,8 @@ describe("VMess parser utility helpers", () => { b: ["false"], list: ["a", "b"], }); + expect(parseHeaderRecord({ emptyList: ["", 1], nested: {} })).toBeUndefined(); + expect(parseHeaderRecord(null)).toBeUndefined(); expect(parseHeaderRecord([])).toBeUndefined(); expect(splitList("a, b,,c")).toEqual(["a", "b", "c"]); expect(splitList("")).toBeUndefined(); @@ -37,6 +45,11 @@ describe("VMess parser utility helpers", () => { expect(pickString(1)).toBe(""); expect(parseBooleanish("yes")).toBe(true); expect(parseBooleanish("off")).toBe(false); + expect(parseBooleanish(true)).toBe(true); + expect(parseBooleanish(0)).toBe(false); + expect(parseBooleanish(2)).toBeUndefined(); + expect(parseBooleanish({})).toBeUndefined(); + expect(parseBooleanish(" ")).toBeUndefined(); expect(parseBooleanish("maybe")).toBeUndefined(); }); @@ -45,11 +58,17 @@ describe("VMess parser utility helpers", () => { const params = new URLSearchParams("a=&b=value&c=next"); expect(looksLikeUriStyleVmess("uuid@example.com:443")).toBe(true); + expect(looksLikeUriStyleVmess("plain")).toBe(false); expect(looksLikeStandardVmessStyle("ws+tls:uuid-0@example.com:443")).toBe(true); + expect(looksLikeStandardVmessStyle("ws+tls:uuid-a@example.com:443")).toBe(false); expect(looksLikeShadowrocketStyleVmess(`${shadowrocket}?obfs=websocket`)).toBe(true); + expect(looksLikeShadowrocketStyleVmess(`${shadowrocket}@bad?obfs=websocket`)).toBe(false); + expect(looksLikeShadowrocketStyleVmess("?obfs=websocket")).toBe(false); expect(looksLikeShadowrocketStyleVmess("not-base64?obfs=websocket")).toBe(false); expect(stripOuterQuotes('"quoted"')).toBe("quoted"); expect(pickQueryParam(params, "a", "b", "c")).toBe("value"); + expect(pickQueryParam(params, "missing", "a")).toBeUndefined(); + expect(parseObfsHeaderHost(undefined)).toBeUndefined(); expect(parseObfsHeaderHost("Host: cdn.example.com, Path: /ws")).toBe("cdn.example.com"); expect(parseObfsHeaderHost("cdn.example.com")).toBe("cdn.example.com"); }); diff --git a/packages/core/src/parser/protocols/vmess.test.ts b/packages/core/src/parser/protocols/vmess.test.ts index b03073a..2505fc6 100644 --- a/packages/core/src/parser/protocols/vmess.test.ts +++ b/packages/core/src/parser/protocols/vmess.test.ts @@ -410,6 +410,83 @@ describe("parseVMess", () => { .toThrow("不支持的 VMess 传输层"); }); + it("covers malformed VMess style probes without accepting invalid authorities", () => { + const badShadowrocketNoQuery = Buffer.from(`auto:${UUID}@shadow-no-query.example.com:443`).toString("base64url"); + const badShadowrocketBase = Buffer.from(`auto:${UUID}@shadow-bad.example.com:not-a-port`).toString("base64url"); + + expect(() => parseVMess(`vmess://${badShadowrocketNoQuery}`)).toThrow("无效的 VMess JSON 格式"); + expect(() => parseVMess(`vmess://${badShadowrocketBase}?obfs=websocket`)).toThrow("VMess 配置缺少必要字段"); + }); + + it("keeps sparse VMess defaults explicit across URI, JSON, and Shadowrocket forms", () => { + const shadowrocketBase = Buffer.from(`auto:${UUID}@shadow-default.example.com:443`).toString("base64url"); + expect(parseVMess(`vmess://${shadowrocketBase}?network=grpc&tls=1&ech=&remarks=`)).toMatchObject({ + name: "VMess shadow-default.example.com:443", + server: "shadow-default.example.com", + tls: true, + network: "grpc", + "grpc-opts": { + "grpc-service-name": "", + }, + }); + + expect( + parseVMess( + `vmess://${UUID}@uri-grpc-path.example.com:443?type=grpc&security=tls&path=%2Ffrom-path&ech=#UriGrpcPath` + ) + ).toMatchObject({ + name: "UriGrpcPath", + network: "grpc", + "grpc-opts": { + "grpc-service-name": "from-path", + }, + "ech-opts": { enable: true }, + }); + + expect( + parseVMess( + `vmess://${toVmessPayload({ + ps: "", + add: "json-none.example.com", + port: 80, + id: UUID, + aid: "", + net: "none", + scy: "", + })}` + ) + ).toMatchObject({ + name: "VMess 节点", + server: "json-none.example.com", + alterId: 0, + cipher: "auto", + network: "tcp", + }); + + expect( + parseVMess( + `vmess://${toVmessPayload({ + ps: "Edge WS", + add: "edge-ws.example.com", + port: 443, + id: UUID, + aid: 0, + net: "ws", + tls: "tls", + edge: "edge-header", + path: "/", + })}` + ) + ).toMatchObject({ + name: "Edge WS", + network: "ws", + "ws-opts": { + path: "/", + headers: { Edge: "edge-header" }, + }, + }); + }); + it("rejects unsupported transports and ECH without TLS", () => { const badShadowrocket = Buffer.from("bad").toString("base64url"); diff --git a/packages/core/src/proxy-group-advanced-mode.ts b/packages/core/src/proxy-group-advanced-mode.ts new file mode 100644 index 0000000..97a6820 --- /dev/null +++ b/packages/core/src/proxy-group-advanced-mode.ts @@ -0,0 +1,24 @@ +export type ProxyGroupAdvancedModeSource = { + proxyGroupAdvancedModeEnabled?: unknown; + customProxyGroups?: unknown; + proxyGroupAdvanced?: unknown; +}; + +function hasArrayItems(value: unknown): boolean { + return Array.isArray(value) && value.length > 0; +} + +function hasRecordEntries(value: unknown): boolean { + return Boolean(value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0); +} + +export function shouldEnableProxyGroupAdvancedModeByDefault(source: ProxyGroupAdvancedModeSource): boolean { + return hasArrayItems(source.customProxyGroups) || hasRecordEntries(source.proxyGroupAdvanced); +} + +export function resolveProxyGroupAdvancedModeEnabled(source: ProxyGroupAdvancedModeSource): boolean { + if (typeof source.proxyGroupAdvancedModeEnabled === "boolean") { + return source.proxyGroupAdvancedModeEnabled; + } + return shouldEnableProxyGroupAdvancedModeByDefault(source); +} diff --git a/packages/core/src/proxy-group-advanced.test.ts b/packages/core/src/proxy-group-advanced.test.ts new file mode 100644 index 0000000..764a8ac --- /dev/null +++ b/packages/core/src/proxy-group-advanced.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeProxyGroupAdvancedConfig, + normalizeProxyGroupMemberRef, + resolveProxyGroupMembers, +} from "./proxy-group-advanced"; +import { withNodeSourceId } from "./subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +function node(name: string): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + } as ParsedNode; +} + +describe("resolveProxyGroupMembers", () => { + it("normalizes advanced config and member refs conservatively", () => { + expect(normalizeProxyGroupMemberRef({ kind: "node", name: " Node A " })).toEqual({ kind: "node", name: "Node A" }); + expect(normalizeProxyGroupMemberRef({ kind: "module", id: " auto " })).toEqual({ kind: "module", id: "auto" }); + expect(normalizeProxyGroupMemberRef({ kind: "custom", id: " media " })).toEqual({ kind: "custom", id: "media" }); + expect(normalizeProxyGroupMemberRef({ kind: "direct" })).toEqual({ kind: "direct" }); + expect(normalizeProxyGroupMemberRef({ kind: "reject" })).toEqual({ kind: "reject" }); + expect(normalizeProxyGroupMemberRef(null)).toBeNull(); + expect(normalizeProxyGroupMemberRef("DIRECT")).toBeNull(); + expect(normalizeProxyGroupMemberRef({ kind: "node", name: "" })).toBeNull(); + expect(normalizeProxyGroupMemberRef({ kind: "module", id: " " })).toBeNull(); + expect(normalizeProxyGroupMemberRef({ kind: "custom", id: " " })).toBeNull(); + expect(normalizeProxyGroupMemberRef([])).toBeNull(); + + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: [" source-a ", "source-a", "", 1], + regions: ["US", "other", "bad", "us"], + includeRegex: " IEPL ", + excludeRegex: " 测试 ", + groupType: "load-balance", + strategy: "bad", + extraMembers: [{ kind: "direct" }, { kind: "direct" }, { kind: "node", name: "Node A" }], + excludedMembers: [{ kind: "reject" }, { kind: "node", name: "" }], + memberOrder: [{ kind: "node", name: "Node A" }, { kind: "node", name: "Node A" }], + }), + ).toEqual({ + sourceIds: ["source-a"], + regions: ["us", "other"], + includeRegex: "IEPL", + excludeRegex: "测试", + groupType: "load-balance", + strategy: "consistent-hashing", + extraMembers: [{ kind: "direct" }, { kind: "node", name: "Node A" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [{ kind: "node", name: "Node A" }], + }); + + expect(normalizeProxyGroupAdvancedConfig(null)).toEqual({}); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "select", strategy: "round-robin" })).toEqual({ + groupType: "select", + }); + expect(normalizeProxyGroupAdvancedConfig([])).toEqual({}); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "url-test" })).toEqual({ groupType: "url-test" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "fallback" })).toEqual({ groupType: "fallback" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "direct-first" })).toEqual({ groupType: "direct-first" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "reject-first" })).toEqual({ groupType: "reject-first" }); + expect(normalizeProxyGroupAdvancedConfig({ groupType: "unknown" })).toEqual({}); + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: "source-a", + regions: "us", + includeRegex: 1, + excludeRegex: 2, + groupType: "load-balance", + strategy: "round-robin", + extraMembers: "bad", + excludedMembers: "bad", + memberOrder: "bad", + }), + ).toEqual({ + groupType: "load-balance", + strategy: "round-robin", + }); + }); + + it("filters self custom references without dropping a node with the same name", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["Self Group", "Other Node"], + availableProxyNames: ["Self Group", "Other Node", "Peer Group"], + nodes: [node("Self Group"), node("Other Node")], + customProxyGroups: [ + { id: "self", name: "Self Group", emoji: "", groupType: "select" }, + { id: "peer", name: "Peer Group", emoji: "", groupType: "select" }, + ], + advanced: { + extraMembers: [ + { kind: "custom", id: "self" }, + { kind: "custom", id: "peer" }, + ], + memberOrder: [ + { kind: "node", name: "Self Group" }, + { kind: "custom", id: "self" }, + { kind: "custom", id: "peer" }, + ], + }, + self: { kind: "custom", id: "self", name: "Self Group" }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "node:Self Group", + "custom:peer", + "node:Other Node", + ]); + expect(result.proxyNames).toEqual(["Self Group", "Peer Group", "Other Node"]); + expect(result.included.map((member) => member.key)).not.toContain("custom:self"); + }); + + it("applies source, region, regex, exclusion, and extra-member filters together", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: [ + "US Source", + "US Other", + "Japan Source", + "Mars Source", + "DIRECT", + "REJECT", + ], + nodes: [ + withNodeSourceId(node("US Source"), "source-a"), + withNodeSourceId(node("US Other"), "source-b"), + withNodeSourceId(node("Japan Source"), "source-a"), + withNodeSourceId(node("Mars Source"), "source-a"), + ], + advanced: { + sourceIds: ["source-a"], + regions: ["us", "other"], + includeRegex: "Source|DIRECT", + excludeRegex: "Mars", + extraMembers: [{ kind: "direct" }], + excludedMembers: [{ kind: "reject" }], + }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "node:US Source", + "direct:DIRECT", + ]); + expect(result.excluded.map((member) => member.key)).toEqual([ + "node:US Other", + "node:Japan Source", + "node:Mars Source", + "reject:REJECT", + ]); + + const invalidRegexResult = resolveProxyGroupMembers({ + defaultProxyNames: ["US Source"], + nodes: [withNodeSourceId(node("US Source"), "source-a")], + advanced: { + sourceIds: ["source-a"], + includeRegex: "[", + excludeRegex: "[", + }, + }); + + expect(invalidRegexResult.proxyNames).toEqual(["US Source"]); + }); + + it("resolves module/custom members, unavailable refs, and explicit ordering", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["Auto", "Custom", "Unknown", "DIRECT"], + availableProxyNames: ["Auto", "Custom", "Other Node", "REJECT"], + nodes: [node("Other Node")], + moduleNames: { auto: "Auto" }, + customProxyGroups: [ + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + advanced: { + extraMembers: [ + { kind: "node", name: "Other Node" }, + { kind: "custom", id: "missing" }, + { kind: "custom", id: "disabled" }, + { kind: "reject" }, + ], + memberOrder: [ + { kind: "reject" }, + { kind: "node", name: "Other Node" }, + { kind: "module", id: "auto" }, + ], + }, + self: { kind: "module", id: "self", name: "Self" }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "reject:REJECT", + "node:Other Node", + "module:auto", + "custom:custom", + "direct:DIRECT", + ]); + expect(result.excluded.map((member) => member.key)).toEqual([]); + }); + + it("covers fallback members, unmatched regions, and unavailable candidate handling", () => { + const unmatchedRegion = resolveProxyGroupMembers({ + defaultProxyNames: ["France Node"], + nodes: [node("France Node")], + advanced: { regions: ["jp"] }, + }); + const otherRegion = resolveProxyGroupMembers({ + defaultProxyNames: ["US Relay", "Relay X"], + nodes: [node("US Relay"), node("Relay X")], + advanced: { regions: ["other"] }, + }); + const extraOnly = resolveProxyGroupMembers({ + defaultProxyNames: [], + nodes: [node("Extra Node")], + moduleNames: { auto: "Auto" }, + customProxyGroups: [ + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + { id: "empty-name", name: " ", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + advanced: { + extraMembers: [ + { kind: "direct" }, + { kind: "reject" }, + { kind: "node", name: "Extra Node" }, + { kind: "node", name: "Missing Node" }, + { kind: "module", id: "auto" }, + { kind: "module", id: "missing" }, + { kind: "custom", id: "custom" }, + { kind: "custom", id: "empty-name" }, + { kind: "custom", id: "disabled" }, + ], + excludedMembers: [{ kind: "reject" }], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "custom", id: "custom" }, + { kind: "module", id: "missing" }, + { kind: "direct" }, + ], + }, + }); + + expect(unmatchedRegion.included).toEqual([]); + expect(unmatchedRegion.excluded.map((member) => member.key)).toEqual(["node:France Node"]); + expect(otherRegion.included.map((member) => member.key)).toEqual(["node:Relay X"]); + expect(otherRegion.excluded.map((member) => member.key)).toEqual(["node:US Relay"]); + expect(extraOnly.included.map((member) => member.key)).toEqual([ + "custom:custom", + "direct:DIRECT", + "node:Extra Node", + "module:auto", + ]); + expect(extraOnly.excluded.map((member) => member.key)).toEqual(["reject:REJECT"]); + }); + + it("handles malformed names, missing refs, and self guards while resolving extras", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["", "Auto", "Self Module", "Disabled", "Nameless", "Node A", "Node A", "Ghost"], + nodes: [node("Node A"), node("Allowed")], + moduleNames: { + "": "Bad Module", + auto: "Auto", + blank: " ", + self: "Self Module", + }, + customProxyGroups: [ + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + { id: "", name: "No Id", emoji: "", groupType: "select" }, + { id: "nameless", name: " ", emoji: "", groupType: "select" }, + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + ], + advanced: { + extraMembers: [ + { kind: "module", id: "auto" }, + { kind: "module", id: "blank" }, + { kind: "module", id: "missing" }, + { kind: "custom", id: "custom" }, + { kind: "custom", id: "nameless" }, + { kind: "node", name: "Allowed" }, + { kind: "node", name: "Missing Node" }, + { kind: "direct" }, + ], + excludedMembers: [{ kind: "module", id: "auto" }], + memberOrder: [ + { kind: "node", name: "Missing Node" }, + { kind: "custom", id: "custom" }, + { kind: "direct" }, + ], + }, + self: { kind: "module", id: "self", name: "Self Module" }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "custom:custom", + "direct:DIRECT", + "node:Node A", + "node:Allowed", + ]); + expect(result.excluded.map((member) => member.key)).toEqual(["module:auto"]); + }); + + it("keeps fallback resolution stable for sparse options and filter misses", () => { + expect( + normalizeProxyGroupAdvancedConfig({ + regions: [1, "DE"], + groupType: "load-balance", + strategy: "sticky-sessions", + }), + ).toEqual({ + regions: ["de"], + groupType: "load-balance", + strategy: "sticky-sessions", + }); + + const noAvailableNames = resolveProxyGroupMembers({ + defaultProxyNames: ["REJECT", "Missing Node", "German Node", "Hidden Node", 1] as unknown as string[], + nodes: [withNodeSourceId(node("German Node"), "source-a"), node("Hidden Node")], + advanced: { + sourceIds: ["source-b"], + includeRegex: "Visible", + }, + }); + const noAdvanced = resolveProxyGroupMembers({ + defaultProxyNames: ["German Node"], + nodes: [node("German Node")], + }); + const emptyNames = resolveProxyGroupMembers({ + defaultProxyNames: undefined as unknown as string[], + availableProxyNames: undefined, + nodes: [node("German Node")], + advanced: {}, + }); + + expect(noAvailableNames.included.map((member) => member.key)).toEqual(["reject:REJECT"]); + expect(noAvailableNames.excluded.map((member) => member.key)).toEqual(["node:German Node", "node:Hidden Node"]); + expect(noAdvanced.proxyNames).toEqual(["German Node"]); + expect(emptyNames.included).toEqual([]); + expect(emptyNames.excluded).toEqual([]); + }); + + it("normalizes sparse advanced values and resolves members from fallback references", () => { + expect( + normalizeProxyGroupAdvancedConfig({ + sourceIds: ["", " source-a ", "source-b"], + regions: ["bad", "KR", "kr"], + includeRegex: " ", + excludeRegex: "", + groupType: "load-balance", + strategy: "sticky-sessions", + extraMembers: [ + null, + { kind: "node", name: " Node B " }, + { kind: "node", name: "Node B" }, + { kind: "module", id: " auto " }, + { kind: "custom", id: "custom" }, + ], + excludedMembers: [ + { kind: "direct" }, + { kind: "direct" }, + { kind: "custom", id: "disabled" }, + ], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "module", id: "auto" }, + { kind: "direct" }, + ], + }), + ).toEqual({ + sourceIds: ["source-a", "source-b"], + regions: ["kr"], + groupType: "load-balance", + strategy: "sticky-sessions", + extraMembers: [ + { kind: "node", name: "Node B" }, + { kind: "module", id: "auto" }, + { kind: "custom", id: "custom" }, + ], + excludedMembers: [{ kind: "direct" }, { kind: "custom", id: "disabled" }], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "module", id: "auto" }, + { kind: "direct" }, + ], + }); + + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["Auto", "Custom", "DIRECT", "REJECT", "Korea Node", "Unknown"], + availableProxyNames: ["Auto", "Custom", "DIRECT", "REJECT", "Korea Node", "Other Node"], + nodes: [withNodeSourceId(node("Korea Node"), "source-a"), withNodeSourceId(node("Other Node"), "source-b")], + moduleNames: { auto: "Auto", blank: " " }, + customProxyGroups: [ + { id: "custom", name: "Custom", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + advanced: { + sourceIds: ["source-a"], + regions: ["kr"], + extraMembers: [ + { kind: "node", name: "Other Node" }, + { kind: "module", id: "auto" }, + { kind: "custom", id: "custom" }, + { kind: "direct" }, + { kind: "custom", id: "disabled" }, + ], + excludedMembers: [{ kind: "direct" }], + memberOrder: [ + { kind: "custom", id: "custom" }, + { kind: "module", id: "auto" }, + { kind: "node", name: "Korea Node" }, + { kind: "node", name: "Other Node" }, + ], + }, + }); + + expect(result.included.map((member) => member.key)).toEqual([ + "custom:custom", + "module:auto", + "node:Korea Node", + "node:Other Node", + "reject:REJECT", + ]); + expect(result.excluded.map((member) => member.key)).toEqual(["direct:DIRECT"]); + }); +}); diff --git a/packages/core/src/proxy-group-advanced.ts b/packages/core/src/proxy-group-advanced.ts new file mode 100644 index 0000000..1b2187e --- /dev/null +++ b/packages/core/src/proxy-group-advanced.ts @@ -0,0 +1,363 @@ +import { getNodeSourceIds } from "@subboost/core/subscription/node-source-state"; +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + isLoadBalanceStrategy, +} from "@subboost/core/types/config"; +import type { + CustomProxyGroup, + NodeRegion, + ProxyGroupAdvancedConfig, + ProxyGroupGroupType, + ProxyGroupMemberRef, +} from "@subboost/core/types/config"; +import type { ParsedNode } from "@subboost/core/types/node"; +import { getProxyGroupMemberKey } from "@subboost/core/proxy-group-targets"; + +export const REGION_PRESETS: Array<{ + id: NodeRegion; + label: string; + emoji: string; + keywords: string[]; +}> = [ + { id: "us", label: "美国", emoji: "🇺🇸", keywords: ["美国", "US", "USA", "United States", "洛杉矶", "纽约", "西雅图"] }, + { id: "hk", label: "香港", emoji: "🇭🇰", keywords: ["香港", "HK", "Hong Kong", "港"] }, + { id: "jp", label: "日本", emoji: "🇯🇵", keywords: ["日本", "JP", "Japan", "东京", "大阪"] }, + { id: "sg", label: "新加坡", emoji: "🇸🇬", keywords: ["新加坡", "SG", "Singapore", "狮城"] }, + { id: "tw", label: "台湾", emoji: "🇹🇼", keywords: ["台湾", "TW", "Taiwan", "台北"] }, + { id: "kr", label: "韩国", emoji: "🇰🇷", keywords: ["韩国", "KR", "Korea", "首尔"] }, + { id: "uk", label: "英国", emoji: "🇬🇧", keywords: ["英国", "UK", "United Kingdom", "London", "伦敦"] }, + { id: "de", label: "德国", emoji: "🇩🇪", keywords: ["德国", "DE", "Germany", "Frankfurt", "法兰克福"] }, + { id: "fr", label: "法国", emoji: "🇫🇷", keywords: ["法国", "FR", "France", "Paris", "巴黎"] }, + { id: "ca", label: "加拿大", emoji: "🇨🇦", keywords: ["加拿大", "CA", "Canada", "Toronto", "多伦多"] }, + { id: "au", label: "澳大利亚", emoji: "🇦🇺", keywords: ["澳大利亚", "AU", "Australia", "Sydney", "悉尼"] }, + { id: "other", label: "其他", emoji: "🌐", keywords: [] }, +]; + +export type ProxyGroupResolvedMemberKind = ProxyGroupMemberRef["kind"]; + +export interface ProxyGroupResolvedMember { + ref: ProxyGroupMemberRef; + key: string; + name: string; + kind: ProxyGroupResolvedMemberKind; +} + +export interface ResolveProxyGroupMembersOptions { + defaultProxyNames: string[]; + availableProxyNames?: string[]; + nodes: ParsedNode[]; + moduleNames?: Record; + customProxyGroups?: CustomProxyGroup[]; + advanced?: ProxyGroupAdvancedConfig; + self?: { kind: "module" | "custom"; id: string; name: string }; +} + +export interface ResolveProxyGroupMembersResult { + included: ProxyGroupResolvedMember[]; + excluded: ProxyGroupResolvedMember[]; + proxyNames: string[]; +} + +const NODE_REGIONS = new Set(REGION_PRESETS.map((preset) => preset.id)); + +function compileRegex(pattern?: string): RegExp | null { + const raw = typeof pattern === "string" ? pattern.trim() : ""; + if (!raw) return null; + try { + return new RegExp(raw, "i"); + } catch { + return null; + } +} + +function matchesRegion(nodeName: string, regions: NodeRegion[]): boolean { + if (!Array.isArray(regions) || regions.length === 0) return true; + const normalized = nodeName.toLowerCase(); + + for (const region of regions) { + if (region === "other") continue; + const preset = REGION_PRESETS.find((item) => item.id === region); + if (!preset) continue; + if (preset.keywords.some((keyword) => normalized.includes(keyword.toLowerCase()))) return true; + } + + if (regions.includes("other")) { + for (const preset of REGION_PRESETS) { + if (preset.id === "other") continue; + if (preset.keywords.some((keyword) => normalized.includes(keyword.toLowerCase()))) return false; + } + return true; + } + + return false; +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +function normalizeRegions(value: unknown): NodeRegion[] { + if (!Array.isArray(value)) return []; + const out: NodeRegion[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string") continue; + const key = item.trim().toLowerCase() as NodeRegion; + if (!NODE_REGIONS.has(key) || seen.has(key)) continue; + seen.add(key); + out.push(key); + } + return out; +} + +function normalizeGroupType(value: unknown): ProxyGroupGroupType | null { + if ( + value === "select" || + value === "url-test" || + value === "fallback" || + value === "load-balance" || + value === "direct-first" || + value === "reject-first" + ) { + return value; + } + return null; +} + +export function normalizeProxyGroupMemberRef(value: unknown): ProxyGroupMemberRef | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const item = value as Record; + if (item.kind === "direct") return { kind: "direct" }; + if (item.kind === "reject") return { kind: "reject" }; + if (item.kind === "node" && typeof item.name === "string" && item.name.trim()) { + return { kind: "node", name: item.name.trim() }; + } + if (item.kind === "module" && typeof item.id === "string" && item.id.trim()) { + return { kind: "module", id: item.id.trim() }; + } + if (item.kind === "custom" && typeof item.id === "string" && item.id.trim()) { + return { kind: "custom", id: item.id.trim() }; + } + return null; +} + +function normalizeMemberList(value: unknown): ProxyGroupMemberRef[] { + if (!Array.isArray(value)) return []; + const out: ProxyGroupMemberRef[] = []; + const seen = new Set(); + for (const item of value) { + const ref = normalizeProxyGroupMemberRef(item); + if (!ref) continue; + const key = getProxyGroupMemberKey(ref); + if (seen.has(key)) continue; + seen.add(key); + out.push(ref); + } + return out; +} + +export function normalizeProxyGroupAdvancedConfig(value: unknown): ProxyGroupAdvancedConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const item = value as Record; + const sourceIds = normalizeStringList(item.sourceIds); + const regions = normalizeRegions(item.regions); + const includeRegex = typeof item.includeRegex === "string" ? item.includeRegex.trim() : ""; + const excludeRegex = typeof item.excludeRegex === "string" ? item.excludeRegex.trim() : ""; + const groupType = normalizeGroupType(item.groupType); + const strategy = groupType === "load-balance" && isLoadBalanceStrategy(item.strategy) + ? item.strategy + : groupType === "load-balance" + ? DEFAULT_LOAD_BALANCE_STRATEGY + : undefined; + const extraMembers = normalizeMemberList(item.extraMembers); + const excludedMembers = normalizeMemberList(item.excludedMembers); + const memberOrder = normalizeMemberList(item.memberOrder); + return { + ...(sourceIds.length > 0 ? { sourceIds } : {}), + ...(regions.length > 0 ? { regions } : {}), + ...(includeRegex ? { includeRegex } : {}), + ...(excludeRegex ? { excludeRegex } : {}), + ...(groupType ? { groupType } : {}), + ...(strategy ? { strategy } : {}), + ...(extraMembers.length > 0 ? { extraMembers } : {}), + ...(excludedMembers.length > 0 ? { excludedMembers } : {}), + ...(memberOrder.length > 0 ? { memberOrder } : {}), + }; +} + +function buildMemberFromName( + name: string, + options: { + nodeNameSet: Set; + moduleNameToId: Map; + customNameToId: Map; + } +): ProxyGroupResolvedMember | null { + const trimmed = name.trim(); + if (!trimmed) return null; + + let ref: ProxyGroupMemberRef | null = null; + if (trimmed === "DIRECT") ref = { kind: "direct" }; + else if (trimmed === "REJECT") ref = { kind: "reject" }; + else if (options.nodeNameSet.has(trimmed)) ref = { kind: "node", name: trimmed }; + else { + const moduleId = options.moduleNameToId.get(trimmed); + if (moduleId) ref = { kind: "module", id: moduleId }; + const customId = options.customNameToId.get(trimmed); + if (!ref && customId) ref = { kind: "custom", id: customId }; + } + + if (!ref) return null; + return { + ref, + key: getProxyGroupMemberKey(ref), + name: trimmed, + kind: ref.kind, + }; +} + +function buildMemberFromRef( + ref: ProxyGroupMemberRef, + options: { + nodeNameSet: Set; + moduleNames: Record; + customGroupsById: Map; + } +): ProxyGroupResolvedMember | null { + const key = getProxyGroupMemberKey(ref); + if (ref.kind === "direct") return { ref, key, name: "DIRECT", kind: ref.kind }; + if (ref.kind === "reject") return { ref, key, name: "REJECT", kind: ref.kind }; + if (ref.kind === "node") { + const name = ref.name.trim(); + return name && options.nodeNameSet.has(name) ? { ref: { kind: "node", name }, key, name, kind: ref.kind } : null; + } + if (ref.kind === "module") { + const name = options.moduleNames[ref.id]?.trim(); + return name ? { ref, key, name, kind: ref.kind } : null; + } + const group = options.customGroupsById.get(ref.id); + const name = group?.name?.trim(); + return name ? { ref, key, name, kind: ref.kind } : null; +} + +function nodePassesAdvancedFilters( + member: ProxyGroupResolvedMember, + nodeByName: Map, + advanced: ProxyGroupAdvancedConfig +): boolean { + if (member.ref.kind !== "node") return true; + const node = nodeByName.get(member.ref.name); + if (!node) return false; + + const sourceIds = normalizeStringList(advanced.sourceIds); + if (sourceIds.length > 0) { + const sourceIdSet = new Set(sourceIds); + if (!getNodeSourceIds(node).some((id) => sourceIdSet.has(id))) return false; + } + + const regions = normalizeRegions(advanced.regions); + if (!matchesRegion(member.ref.name, regions)) return false; + + const includeRe = compileRegex(advanced.includeRegex); + if (includeRe && !includeRe.test(member.ref.name)) return false; + const excludeRe = compileRegex(advanced.excludeRegex); + if (excludeRe && excludeRe.test(member.ref.name)) return false; + + return true; +} + +export function resolveProxyGroupMembers(options: ResolveProxyGroupMembersOptions): ResolveProxyGroupMembersResult { + const advanced = normalizeProxyGroupAdvancedConfig(options.advanced); + const nodeByName = new Map(options.nodes.map((node) => [node.name, node])); + const nodeNameSet = new Set(nodeByName.keys()); + const moduleNameToId = new Map(); + for (const [id, name] of Object.entries(options.moduleNames || {})) { + const trimmed = name.trim(); + if (id && trimmed) moduleNameToId.set(trimmed, id); + } + const customNameToId = new Map(); + const customGroupsById = new Map(); + for (const group of options.customProxyGroups || []) { + if (group.enabled === false) continue; + const name = typeof group.name === "string" ? group.name.trim() : ""; + if (group.id && name) { + customNameToId.set(name, group.id); + customGroupsById.set(group.id, group); + } + } + + const buildMembersFromNames = (names: string[]) => { + const out: ProxyGroupResolvedMember[] = []; + const seen = new Set(); + for (const rawName of names || []) { + if (typeof rawName !== "string") continue; + const member = buildMemberFromName(rawName, { nodeNameSet, moduleNameToId, customNameToId }); + if (!member || seen.has(member.key)) continue; + if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; + seen.add(member.key); + out.push(member); + } + return out; + }; + + const defaultCandidates = buildMembersFromNames(options.defaultProxyNames || []); + const availableCandidates = buildMembersFromNames(options.availableProxyNames || options.defaultProxyNames || []); + const availableByKey = new Map(availableCandidates.map((member) => [member.key, member])); + const candidateMap = new Map(defaultCandidates.map((member) => [member.key, member])); + + for (const ref of advanced.extraMembers || []) { + const member = availableByKey.get(getProxyGroupMemberKey(ref)) || buildMemberFromRef(ref, { + nodeNameSet, + moduleNames: options.moduleNames || {}, + customGroupsById, + }); + if (!member || candidateMap.has(member.key)) continue; + if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; + candidateMap.set(member.key, member); + } + + const candidates = Array.from(candidateMap.values()); + const extraKeys = new Set((advanced.extraMembers || []).map(getProxyGroupMemberKey)); + const excludedKeys = new Set((advanced.excludedMembers || []).map(getProxyGroupMemberKey)); + const includedBase = candidates.filter((member) => { + if (excludedKeys.has(member.key)) return false; + if (extraKeys.has(member.key)) return true; + return nodePassesAdvancedFilters(member, nodeByName, advanced); + }); + + const byKey = new Map(includedBase.map((member) => [member.key, member])); + const ordered: ProxyGroupResolvedMember[] = []; + const used = new Set(); + for (const ref of advanced.memberOrder || []) { + const key = getProxyGroupMemberKey(ref); + const member = byKey.get(key); + if (!member || used.has(key)) continue; + used.add(key); + ordered.push(member); + } + for (const member of includedBase) { + if (used.has(member.key)) continue; + used.add(member.key); + ordered.push(member); + } + + const includedKeys = new Set(ordered.map((member) => member.key)); + const excludedSource = availableCandidates.length > 0 ? availableCandidates : candidates; + const excluded = excludedSource.filter((member) => !includedKeys.has(member.key)); + + return { + included: ordered, + excluded, + proxyNames: ordered.map((member) => member.name), + }; +} diff --git a/packages/core/src/proxy-group-name.test.ts b/packages/core/src/proxy-group-name.test.ts new file mode 100644 index 0000000..79e6380 --- /dev/null +++ b/packages/core/src/proxy-group-name.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeGroupNameWithDefaultEmoji, + resolveProxyGroupModuleName, + splitLeadingEmoji, +} from "./proxy-group-name"; + +describe("proxy group name helpers", () => { + it("splits emoji prefixes without treating ordinary words as emojis", () => { + expect(splitLeadingEmoji("🚀 Node Select")).toEqual({ + emoji: "🚀", + hasEmojiPrefix: true, + label: "Node Select", + }); + expect(splitLeadingEmoji("Node Select")).toEqual({ + emoji: "", + hasEmojiPrefix: false, + label: "Node Select", + }); + expect(splitLeadingEmoji("🚀")).toEqual({ + emoji: "", + hasEmojiPrefix: false, + label: "🚀", + }); + expect(splitLeadingEmoji("A Select")).toMatchObject({ hasEmojiPrefix: false }); + expect(splitLeadingEmoji("国内 服务")).toMatchObject({ hasEmojiPrefix: false }); + }); + + it("applies default emoji only when overrides do not already include one", () => { + const groupModule = { emoji: "⚡", name: "⚡ 自动选择" }; + expect(resolveProxyGroupModuleName(groupModule)).toBe("⚡ 自动选择"); + expect(resolveProxyGroupModuleName(groupModule, " Auto ")).toBe("⚡ Auto"); + expect(resolveProxyGroupModuleName(groupModule, "🚀 Custom")).toBe("🚀 Custom"); + expect(resolveProxyGroupModuleName(groupModule, " ")).toBe("⚡ 自动选择"); + + expect(normalizeGroupNameWithDefaultEmoji("", "")).toEqual({ full: "", emoji: "🧩" }); + expect(normalizeGroupNameWithDefaultEmoji("Media", "🎬")).toEqual({ full: "🎬 Media", emoji: "🎬" }); + expect(normalizeGroupNameWithDefaultEmoji("🎬 Media", "M")).toEqual({ full: "🎬 Media", emoji: "🎬" }); + }); +}); diff --git a/packages/core/src/proxy-group-targets.test.ts b/packages/core/src/proxy-group-targets.test.ts new file mode 100644 index 0000000..27b51b3 --- /dev/null +++ b/packages/core/src/proxy-group-targets.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + getProxyGroupMemberKey, + getProxyGroupTargetKey, + isProxyGroupTargetRef, + normalizeProxyGroupTargetRef, + resolveProxyGroupTargetName, + ruleTargetMatchesName, +} from "./proxy-group-targets"; + +describe("proxy group target helpers", () => { + it("validates and normalizes target refs", () => { + expect(isProxyGroupTargetRef({ kind: "module", id: "auto" })).toBe(true); + expect(isProxyGroupTargetRef({ kind: "custom", id: "media" })).toBe(true); + expect(isProxyGroupTargetRef({ kind: "node", id: "auto" })).toBe(false); + expect(isProxyGroupTargetRef({ kind: "module", id: " " })).toBe(false); + expect(isProxyGroupTargetRef(null)).toBe(false); + expect(isProxyGroupTargetRef([])).toBe(false); + expect(normalizeProxyGroupTargetRef({ kind: "custom", id: " media " })).toEqual({ kind: "custom", id: "media" }); + expect(normalizeProxyGroupTargetRef({ kind: "custom", id: "" })).toBeNull(); + expect(getProxyGroupTargetKey({ kind: "module", id: "auto" })).toBe("module:auto"); + }); + + it("builds stable member keys for every supported member kind", () => { + expect(getProxyGroupMemberKey({ kind: "node", name: "Node A" })).toBe("node:Node A"); + expect(getProxyGroupMemberKey({ kind: "module", id: "auto" })).toBe("module:auto"); + expect(getProxyGroupMemberKey({ kind: "custom", id: "media" })).toBe("custom:media"); + expect(getProxyGroupMemberKey({ kind: "direct" })).toBe("direct:DIRECT"); + expect(getProxyGroupMemberKey({ kind: "reject" })).toBe("reject:REJECT"); + }); + + it("resolves rule targets through module/custom names and fallbacks", () => { + const options = { + moduleNames: { auto: " Auto " }, + customProxyGroups: [{ id: "media", name: " Media ", emoji: "", groupType: "select" as const }], + fallbackTarget: "DIRECT", + }; + + expect(resolveProxyGroupTargetName(" Proxy ", options)).toBe("Proxy"); + expect(resolveProxyGroupTargetName(" ", options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "auto" }, options)).toBe("Auto"); + expect(resolveProxyGroupTargetName({ kind: "custom", id: "media" }, options)).toBe("Media"); + expect(resolveProxyGroupTargetName({ kind: "custom", id: "missing" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "module", id: "missing" }, options)).toBe("DIRECT"); + expect(resolveProxyGroupTargetName({ kind: "node" } as never, options)).toBe("DIRECT"); + expect(ruleTargetMatchesName(" Proxy ", "Proxy")).toBe(true); + expect(ruleTargetMatchesName({ kind: "module", id: "auto" }, "Auto")).toBe(false); + }); +}); diff --git a/packages/core/src/proxy-group-targets.ts b/packages/core/src/proxy-group-targets.ts new file mode 100644 index 0000000..3ca3f6d --- /dev/null +++ b/packages/core/src/proxy-group-targets.ts @@ -0,0 +1,67 @@ +import type { + CustomProxyGroup, + ProxyGroupMemberRef, + ProxyGroupRuleTarget, + ProxyGroupTargetRef, +} from "@subboost/core/types/config"; + +export function isProxyGroupTargetRef(value: unknown): value is ProxyGroupTargetRef { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const item = value as Record; + return ( + (item.kind === "module" || item.kind === "custom") && + typeof item.id === "string" && + Boolean(item.id.trim()) + ); +} + +export function normalizeProxyGroupTargetRef(value: unknown): ProxyGroupTargetRef | null { + if (!isProxyGroupTargetRef(value)) return null; + return { kind: value.kind, id: value.id.trim() }; +} + +export function getProxyGroupTargetKey(target: ProxyGroupTargetRef): string { + return `${target.kind}:${target.id}`; +} + +export function getProxyGroupMemberKey(member: ProxyGroupMemberRef): string { + switch (member.kind) { + case "node": + return `node:${member.name}`; + case "module": + return `module:${member.id}`; + case "custom": + return `custom:${member.id}`; + case "direct": + return "direct:DIRECT"; + case "reject": + return "reject:REJECT"; + } +} + +export function resolveProxyGroupTargetName( + target: ProxyGroupRuleTarget, + options: { + moduleNames: Record; + customProxyGroups?: CustomProxyGroup[]; + fallbackTarget?: string; + } +): string { + if (typeof target === "string") { + return target.trim() || options.fallbackTarget || ""; + } + + const normalized = normalizeProxyGroupTargetRef(target); + if (!normalized) return options.fallbackTarget || ""; + + if (normalized.kind === "module") { + return options.moduleNames[normalized.id]?.trim() || options.fallbackTarget || ""; + } + + const customName = (options.customProxyGroups || []).find((group) => group.id === normalized.id)?.name?.trim(); + return customName || options.fallbackTarget || ""; +} + +export function ruleTargetMatchesName(target: ProxyGroupRuleTarget, name: string): boolean { + return typeof target === "string" && target.trim() === name.trim(); +} diff --git a/packages/core/src/rules/custom-routing-rule-sets.ts b/packages/core/src/rules/custom-routing-rule-sets.ts index 5b79e04..e4332a7 100644 --- a/packages/core/src/rules/custom-routing-rule-sets.ts +++ b/packages/core/src/rules/custom-routing-rule-sets.ts @@ -1,7 +1,12 @@ 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 { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; +import type { CustomProxyGroup, CustomRuleSet, ProxyGroupRuleTarget } from "@subboost/core/types/config"; +import { + buildRuleSetUrlFromPath, + extractRuleSetPathFromUrl, + normalizeRuleSetPathInput, +} from "@subboost/core/rules/rule-model"; export type CustomRoutingRuleSetTarget = { kind: "module" | "custom"; @@ -13,7 +18,7 @@ export type CustomRoutingRuleSetTarget = { export type CustomRoutingRuleSetItem = { key: string; source: { - kind: "module" | "custom"; + kind: "custom-rule-set"; id: string; }; id: string; @@ -46,83 +51,94 @@ 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}`; -} +function resolveRuleSetTarget( + targetValue: ProxyGroupRuleTarget, + customProxyGroups: CustomProxyGroup[], + proxyGroupNameOverrides?: Record +): CustomRoutingRuleSetTarget | null { + const targetRef = normalizeProxyGroupTargetRef(targetValue); + if (targetRef?.kind === "module") { + const proxyModule = PROXY_GROUP_MODULES.find((module) => module.id === targetRef.id); + if (!proxyModule) return null; + const name = resolveProxyGroupModuleName(proxyModule, proxyGroupNameOverrides?.[proxyModule.id]); + return { + kind: "module", + id: proxyModule.id, + name, + value: getRuleSetTargetValue({ kind: "module", id: proxyModule.id }), + }; + } + if (targetRef?.kind === "custom") { + const group = customProxyGroups.find((item) => item.id === targetRef.id); + if (!group) return null; + const name = group.name.trim(); + if (!name) return null; + return { + kind: "custom", + id: group.id, + name, + value: getRuleSetTargetValue({ kind: "custom", id: group.id }), + }; + } -export function collectCustomRoutingRuleSets({ - customProxyGroups, - moduleRuleOverrides, - proxyGroupNameOverrides, -}: { - customProxyGroups: CustomProxyGroup[]; - moduleRuleOverrides: Record; - proxyGroupNameOverrides?: Record; -}): CustomRoutingRuleSetItem[] { - const items: CustomRoutingRuleSetItem[] = []; + const target = typeof targetValue === "string" ? targetValue.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-batch-import.ts b/packages/core/src/rules/custom-rule-batch-import.ts index ed56af2..5235961 100644 --- a/packages/core/src/rules/custom-rule-batch-import.ts +++ b/packages/core/src/rules/custom-rule-batch-import.ts @@ -93,11 +93,16 @@ function normalizeNoResolve(value: string): boolean | null { return value.trim().toLowerCase() === "no-resolve" ? true : null; } +function getTargetKey(target: CustomRule["target"]): string { + if (typeof target === "string") return target.trim(); + return `${target.kind}:${target.id.trim()}`; +} + function getRuleKey(rule: Pick): string { return [ rule.type, rule.value.trim(), - rule.target.trim(), + getTargetKey(rule.target), Boolean(rule.noResolve) ? "1" : "0", ].join("\u0000"); } @@ -250,7 +255,8 @@ export function parseCustomRuleBatchImport( return; } - if (!draft.target.trim()) { + const target = getTargetKey(draft.target); + if (!target) { errorCount += 1; items.push({ lineNumber, @@ -261,13 +267,13 @@ export function parseCustomRuleBatchImport( return; } - if (!targetSet.has(draft.target.trim())) { + if (!targetSet.has(target)) { errorCount += 1; items.push({ lineNumber, raw: rawLine, status: "error", - message: `未知目标:${draft.target}`, + message: `未知目标:${target}`, }); return; } @@ -276,7 +282,7 @@ export function parseCustomRuleBatchImport( id: "", type: draft.type, value: draft.value.trim(), - target: draft.target.trim(), + target, noResolve: Boolean(draft.noResolve), }; const key = getRuleKey(rule); diff --git a/packages/core/src/rules/custom-rule-helpers.test.ts b/packages/core/src/rules/custom-rule-helpers.test.ts index 61a545a..76a626a 100644 --- a/packages/core/src/rules/custom-rule-helpers.test.ts +++ b/packages/core/src/rules/custom-rule-helpers.test.ts @@ -12,13 +12,12 @@ import { createCustomRuleId, ensureCustomRuleId, ensureCustomRulesHaveIds, - getCustomGroupRuleOrderKey, getCustomRuleOrderKey, isCustomRuleType, 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", () => { @@ -26,6 +25,7 @@ describe("custom routing rule set helpers", () => { expect(parseRuleSetTargetValue(" module: select ")).toEqual({ kind: "module", id: "select" }); expect(parseRuleSetTargetValue("custom:custom-a")).toEqual({ kind: "custom", id: "custom-a" }); expect(parseRuleSetTargetValue("module: ")).toBeNull(); + expect(parseRuleSetTargetValue("custom: ")).toBeNull(); expect(parseRuleSetTargetValue("other:select")).toBeNull(); expect(extractRuleSetPathFromUrl("https://cdn.example/rules/geosite/openai.mrs?token=1")).toBe( @@ -46,43 +46,46 @@ 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" }, + { id: "missing-module", name: "missing module", behavior: "domain", path: "geosite/missing.mrs", target: { kind: "module", id: "missing" } }, + { id: "missing-custom", name: "missing custom", behavior: "domain", path: "geosite/missing.mrs", target: { kind: "custom", id: "missing" } }, + { id: "blank-custom", name: "blank custom", behavior: "domain", path: "geosite/blank.mrs", target: { kind: "custom", id: "" } }, + { id: "blank-target", name: "blank target", behavior: "domain", path: "geosite/blank-target.mrs", target: " " }, + ], proxyGroupNameOverrides: { select: "Custom Select" }, }); 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 +94,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 +131,36 @@ 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}`]); }); }); @@ -224,4 +226,69 @@ describe("custom rule batch import", () => { expect.objectContaining({ type: "DOMAIN", value: "a\"b.com", target: "PROXY", noResolve: false }), ]); }); + + it("handles YAML list edge cases and all-ready imports", () => { + const skipped = parseCustomRuleBatchImport({ + text: ["rules:", "-", "- # nested comment", "- // nested comment"].join("\n"), + defaultType: "DOMAIN", + defaultTarget: "PROXY", + defaultNoResolve: false, + targetOptions: ["PROXY"], + existingRules: [], + }); + + expect(skipped.items.map((item) => item.message)).toEqual(["rules 块标记", "空 YAML 列表项", "注释", "注释"]); + expect(skipped.canImport).toBe(false); + + const ready = parseCustomRuleBatchImport({ + text: ["- DOMAIN,example.com", "- DOMAIN-SUFFIX,example.org,PROXY"].join("\n"), + defaultType: "DOMAIN", + defaultTarget: "PROXY", + defaultNoResolve: true, + targetOptions: ["PROXY"], + existingRules: [], + }); + + expect(ready.items.map((item) => item.status)).toEqual(["ready", "ready"]); + expect(ready.rules).toEqual([ + expect.objectContaining({ type: "DOMAIN", value: "example.com", target: "PROXY", noResolve: true }), + expect.objectContaining({ type: "DOMAIN-SUFFIX", value: "example.org", target: "PROXY", noResolve: false }), + ]); + expect(ready.canImport).toBe(true); + }); + + it("defaults two-column rules and matches duplicate object targets", () => { + const result = parseCustomRuleBatchImport({ + text: [ + "DOMAIN,two-column.example", + "DOMAIN,object.example,custom:custom-a", + ].join("\n"), + defaultType: "DOMAIN-SUFFIX", + defaultTarget: "PROXY", + defaultNoResolve: true, + targetOptions: ["PROXY", "custom:custom-a"], + existingRules: [ + { + id: "existing-object", + type: "DOMAIN", + value: "object.example", + target: { kind: "custom", id: "custom-a" }, + noResolve: false, + } as CustomRule, + ], + }); + + expect(result.items.map((item) => item.status)).toEqual([ + "ready", + "duplicate", + ]); + expect(result.rules).toEqual([ + expect.objectContaining({ + type: "DOMAIN", + value: "two-column.example", + target: "PROXY", + noResolve: true, + }), + ]); + }); }); diff --git a/packages/core/src/rules/custom-rule-utils.ts b/packages/core/src/rules/custom-rule-utils.ts index 833db54..ad17ddd 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", @@ -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); @@ -40,12 +39,17 @@ function toSlug(value: string): string { return parts.length > 0 ? parts.join("") : "item"; } +function customRuleTargetToString(target: CustomRule["target"]): string { + if (typeof target === "string") return target; + return `${target.kind}:${target.id}`; +} + function buildDeterministicCustomRuleId(rule: Pick, index: number): string { return [ "custom-rule", toSlug(rule.type), toSlug(rule.value).slice(0, 24), - toSlug(rule.target).slice(0, 24), + toSlug(customRuleTargetToString(rule.target)).slice(0, 24), String(index + 1), ].join("-"); } @@ -73,23 +77,19 @@ 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}`; } -export function listEditableRuleOrderKeys(customRules: CustomRule[], customProxyGroups: CustomProxyGroup[] = []): string[] { +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.test.ts b/packages/core/src/rules/rule-model.test.ts new file mode 100644 index 0000000..c625390 --- /dev/null +++ b/packages/core/src/rules/rule-model.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + buildRuleSetUrlFromPath, + extractRuleSetPathFromUrl, + isValidRuleSetPathOrUrl, + normalizeBuiltinRuleEdits, + normalizeRuleModelFromConfig, + normalizeRuleSetPathInput, +} from "./rule-model"; + +describe("rule model normalization", () => { + it("normalizes rule set paths, URLs, and builtin edits", () => { + expect(extractRuleSetPathFromUrl(" https://example.com/meta/geosite/google.mrs?download=1 ")).toBe("geosite/google.mrs"); + expect(extractRuleSetPathFromUrl("custom/list.txt")).toBe("custom/list.txt"); + expect(normalizeRuleSetPathInput("////geoip/private.mrs")).toBe("geoip/private.mrs"); + expect(isValidRuleSetPathOrUrl("geosite/google.mrs")).toBe(true); + expect(isValidRuleSetPathOrUrl("https://example.com/rules/list.txt")).toBe(true); + expect(isValidRuleSetPathOrUrl("plain.txt")).toBe(false); + expect(buildRuleSetUrlFromPath("https://cdn.example.com/custom.mrs", "https://base.example.com/")).toBe("https://cdn.example.com/custom.mrs"); + expect(buildRuleSetUrlFromPath("/geosite/google.mrs", "https://base.example.com////")).toBe("https://base.example.com/geosite/google.mrs"); + expect(normalizeBuiltinRuleEdits(null)).toEqual({}); + expect( + normalizeBuiltinRuleEdits({ + " ": { enabled: false }, + "module:cn:cn-ip": { enabled: false }, + "module:auto:auto": { target: { kind: "module", id: " select " } }, + invalid: "bad", + empty: {}, + }) + ).toEqual({ + "module:cn:cn-ip": { enabled: false }, + "module:auto:auto": { target: { kind: "module", id: "select" } }, + }); + }); + + it("keeps only valid custom groups and rule sets", () => { + const result = normalizeRuleModelFromConfig({ + customProxyGroups: [ + "bad", + { id: "", name: "Missing", emoji: "", groupType: "select" }, + { + id: "select", + name: " Select ", + emoji: "S", + enabled: false, + description: " Description ", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "select", + advanced: { includeRegex: "Node" }, + }, + { + id: "balance", + name: "Balance", + emoji: "", + groupType: "load-balance", + strategy: "bad", + }, + { + id: "round", + name: "Round", + emoji: "", + groupType: "load-balance", + strategy: "round-robin", + }, + { id: "direct", name: "Direct", emoji: "", groupType: "direct-first" }, + { id: "reject", name: "Reject", emoji: "", groupType: "reject-first" }, + { id: "fallback", name: "Fallback", emoji: "", groupType: "fallback" }, + { id: "url", name: "URL", emoji: "", groupType: "url-test" }, + ], + customRuleSets: [ + "bad", + { id: "", name: "Missing", behavior: "domain", path: "geosite/missing.mrs", target: "DIRECT" }, + { id: "invalid", name: "Invalid", behavior: "bad", path: "geosite/invalid.mrs", target: "DIRECT" }, + { id: "path", name: "Path", behavior: "domain", path: "plain.txt", target: "DIRECT" }, + { id: "target", name: "Target", behavior: "domain", path: "geosite/target.mrs", target: "" }, + { id: "dup", name: "", behavior: "domain", path: "geosite/dup.mrs", target: { kind: "custom", id: " select " }, noResolve: false }, + { id: "dup", name: "Duplicate", behavior: "domain", path: "geosite/dup-2.mrs", target: "DIRECT" }, + { id: "ip", name: "IP", behavior: "ipcidr", path: "geoip/private.mrs", target: "DIRECT", noResolve: true }, + ], + builtinRuleEdits: { + "module:cn:cn-ip": { enabled: false }, + }, + }); + + expect(result.customProxyGroups.map((group) => group.id)).toEqual([ + "select", + "balance", + "round", + "direct", + "reject", + "fallback", + "url", + ]); + expect(result.customProxyGroups.find((group) => group.id === "balance")).toMatchObject({ + strategy: "consistent-hashing", + }); + expect(result.customRuleSets).toEqual([ + { + behavior: "domain", + id: "dup", + name: "dup", + noResolve: false, + path: "geosite/dup.mrs", + target: { kind: "custom", id: "select" }, + }, + { + behavior: "ipcidr", + id: "ip", + name: "IP", + noResolve: true, + path: "geoip/private.mrs", + target: "DIRECT", + }, + ]); + expect(result.builtinRuleEdits).toEqual({ "module:cn:cn-ip": { enabled: false } }); + expect(normalizeRuleModelFromConfig(null)).toEqual({ + builtinRuleEdits: {}, + customProxyGroups: [], + customRuleSets: [], + }); + }); +}); diff --git a/packages/core/src/rules/rule-model.ts b/packages/core/src/rules/rule-model.ts new file mode 100644 index 0000000..399c74a --- /dev/null +++ b/packages/core/src/rules/rule-model.ts @@ -0,0 +1,177 @@ +import type { BuiltinRuleEdit, BuiltinRuleEdits, CustomProxyGroup, CustomRuleSet, RuleSetBehavior } from "@subboost/core/types/config"; +import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy } from "@subboost/core/types/config"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; + +export const RULE_SET_PATH_RE = /^(geosite|geoip)\/[^/?#\s]+\.mrs$/i; + +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 trimLeadingSlashes(value: string): string { + let index = 0; + while (index < value.length && value.charCodeAt(index) === 47) index += 1; + return value.slice(index); +} + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1; + return value.slice(0, end); +} + +export function extractRuleSetPathFromUrl(url: string): string { + const trimmed = url.trim(); + const match = trimmed.match(/(?:^|\/)(geosite|geoip)\/[^/?#\s]+\.mrs/i); + if (!match) return trimmed; + return trimLeadingSlashes(match[0]); +} + +export function normalizeRuleSetPathInput(input: string): string { + return trimLeadingSlashes(extractRuleSetPathFromUrl(input)).trim(); +} + +export function isValidRuleSetPathOrUrl(value: string): boolean { + const trimmed = value.trim(); + return RULE_SET_PATH_RE.test(trimmed) || /^https?:\/\//i.test(trimmed); +} + +export function buildRuleSetUrlFromPath(path: string, baseUrl: string): string { + const normalizedPath = normalizeRuleSetPathInput(path); + if (/^https?:\/\//i.test(normalizedPath)) return normalizedPath; + return `${trimTrailingSlashes(baseUrl)}/${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 = normalizeProxyGroupTargetRef(item.target) ?? toTrimmedString(item.target); + const behavior = normalizeBehavior(item.behavior); + if (!id || !behavior || !path || !target || !isValidRuleSetPathOrUrl(path)) return null; + const name = toTrimmedString(item.name) || id; + const noResolve = typeof item.noResolve === "boolean" ? item.noResolve : undefined; + return { + id, + name, + behavior, + path, + target, + ...(noResolve !== undefined ? { noResolve } : {}), + }; +} + +function normalizeBuiltinRuleEdit(item: unknown): BuiltinRuleEdit | null { + if (!isRecord(item)) return null; + const target = normalizeProxyGroupTargetRef(item.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 normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { + if (!Array.isArray(value)) return []; + const groups: CustomProxyGroup[] = []; + + 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 enabled = rawGroup.enabled === false ? false : undefined; + const description = toTrimmedString(rawGroup.description); + const memberSource = rawGroup.memberSource === "filtered-nodes" ? "filtered-nodes" : undefined; + const includeInGroupMembers = + typeof rawGroup.includeInGroupMembers === "boolean" ? rawGroup.includeInGroupMembers : undefined; + const groupType = toTrimmedString(rawGroup.groupType); + if (!id || !name) continue; + if ( + groupType !== "select" && + groupType !== "url-test" && + groupType !== "fallback" && + groupType !== "load-balance" && + groupType !== "direct-first" && + groupType !== "reject-first" + ) { + continue; + } + + groups.push({ + id, + name, + emoji, + ...(enabled === false ? { enabled: false } : {}), + ...(description ? { description } : {}), + ...(memberSource ? { memberSource } : {}), + ...(includeInGroupMembers !== undefined ? { includeInGroupMembers } : {}), + groupType, + ...(groupType === "load-balance" + ? { + strategy: isLoadBalanceStrategy(rawGroup.strategy) + ? rawGroup.strategy + : DEFAULT_LOAD_BALANCE_STRATEGY, + } + : {}), + advanced: normalizeProxyGroupAdvancedConfig(rawGroup.advanced), + }); + } + + return groups; +} + +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); + } + + return ruleSets; +} + +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 4b8cfbb..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,40 +36,39 @@ 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], [ { - 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", ]); }); @@ -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 0ac6bd2..27bbf7a 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 { describe, expect, it, vi } 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", @@ -76,39 +43,43 @@ describe("subscription config utils", () => { { id: "media", name: "Media", - emoji: "M", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, 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: "" }, - ], + }, + { + id: "disabled", + name: "Disabled", + emoji: "D", + enabled: false, + description: "Hidden group", + memberSource: "bad", + includeInGroupMembers: true, + groupType: "select", + }, + ], + customRuleSets: [ + { + id: "youtube", + name: "YouTube", + behavior: "domain", + path: "https://rules.example.com/youtube.mrs", + target: "Media", }, ], dialerProxyGroups: [ { id: "chain", name: "Chain", - type: "url-test", + type: "load-balance", + strategy: "round-robin", enabled: true, relayNodes: [" Relay ", ""], targetNodes: [" Target "], }, ], - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "load-balance", - strategy: "round-robin", - sourceIds: ["airport"], - regions: ["US", "bad"], - includeRegex: " Fast ", - excludeRegex: " IPv6 ", - excludedNodeNames: [" Slow "], - }, - ], listenerPorts: { Node: 12000, Bad: 70000, @@ -156,31 +127,36 @@ describe("subscription config utils", () => { }); expect(options.customProxyGroups?.[0]).toMatchObject({ id: "media", + emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, strategy: "consistent-hashing", - rules: [{ id: "youtube", name: "YouTube" }], + }); + expect(options.customProxyGroups?.[1]).toMatchObject({ + id: "disabled", + enabled: false, + description: "Hidden group", + includeInGroupMembers: true, + }); + expect(options.customProxyGroups?.[1]).not.toHaveProperty("memberSource"); + expect(options.customRuleSets?.[0]).toMatchObject({ + id: "youtube", + name: "YouTube", + target: "Media", + path: "https://rules.example.com/youtube.mrs", }); expect(options.dialerProxyGroups?.[0]).toMatchObject({ id: "chain", + type: "load-balance", + strategy: "round-robin", relayNodes: ["Relay"], targetNodes: ["Target"], }); - expect(options.filteredProxyGroups?.[0]).toMatchObject({ - id: "filtered", - strategy: "round-robin", - regions: ["us"], - includeRegex: "Fast", - excludeRegex: "IPv6", - excludedNodeNames: ["Slow"], - }); expect(options.proxyGroupNameOverrides).toEqual({ auto: "Auto" }); expect(options.proxyGroupOrder).toEqual(["auto"]); }); 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", @@ -195,23 +171,17 @@ 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" }, ], - dialerProxyGroups: ["bad", { id: "bad", name: "Bad", type: "fallback" }], - filteredProxyGroups: [ + customRuleSets: [ "bad", - { id: "", name: "Bad", enabled: true }, - { - id: "select-default", - name: "Select Default", - enabled: false, - groupType: "unknown", - emoji: "S", - regions: "us", - }, + { 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: "bad" }], listenerPorts: "bad", proxyGroupNameOverrides: "bad", proxyGroupOrder: [], @@ -241,13 +211,6 @@ describe("subscription config utils", () => { "direct-first", "reject-first", ]); - expect(options.filteredProxyGroups?.[0]).toMatchObject({ - id: "select-default", - enabled: false, - groupType: "select", - regions: [], - emoji: "S", - }); expect(options.dialerProxyGroups).toBeUndefined(); expect(options.proxyGroupNameOverrides).toBeUndefined(); expect(options.proxyGroupOrder).toBeUndefined(); @@ -261,11 +224,6 @@ describe("subscription config utils", () => { { id: "select", name: "Select", emoji: "S", groupType: "select" }, { id: "url-test", name: "Auto", emoji: "A", groupType: "url-test" }, ], - filteredProxyGroups: [ - { id: "fallback", name: "Fallback", enabled: true, groupType: "fallback", sourceIds: [123, "airport"] }, - { id: "direct", name: "Direct", enabled: true, groupType: "direct-first", regions: ["HK", "other"] }, - { id: "reject", name: "Reject", enabled: true, groupType: "reject-first" }, - ], }, { nodes: [node()] } ); @@ -273,13 +231,552 @@ describe("subscription config utils", () => { expect(minimal.template).toBe("minimal"); expect(standard.template).toBe("standard"); - expect(minimal.customProxyGroups?.map((group) => group.groupType)).toEqual(["select", "url-test"]); - expect(minimal.filteredProxyGroups?.map((group) => group.groupType)).toEqual([ - "fallback", - "direct-first", - "reject-first", + expect(minimal.customProxyGroups?.map((group) => group.groupType)).toEqual([ + "select", + "url-test", + ]); + }); + + it("preserves empty YAML overrides and normalizes advanced proxy-group config", () => { + const options = buildGenerateOptionsFromConfig( + { + dnsYaml: "", + customRules: [ + { + type: "IP-CIDR6", + value: "2001:db8::/32", + target: { kind: "custom", id: " media " }, + noResolve: true, + }, + ], + customProxyGroups: [ + { + id: "media", + name: "Media", + emoji: "", + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: [" source-a ", "source-a"], + regions: ["jp", "bad"], + extraMembers: [{ kind: "direct" }], + }, + }, + ], + proxyGroupAdvanced: { + " auto ": { + groupType: "fallback", + excludedMembers: [{ kind: "node", name: " Node " }], + }, + " ": { groupType: "select" }, + invalid: { regions: ["bad"] }, + }, + listenerPorts: { + http: 1, + zero: 0, + high: 65536, + float: 1200.5, + }, + }, + { nodes: [node()] } + ); + + expect(options.userConfig?.dnsYaml).toBe(""); + expect(options.userConfig?.customRules?.[0]).toMatchObject({ + type: "IP-CIDR6", + target: { kind: "custom", id: "media" }, + noResolve: true, + }); + expect(options.userConfig?.listenerPorts).toEqual({ http: 1 }); + expect(options.customProxyGroups?.[0]).toMatchObject({ + id: "media", + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["jp"], + extraMembers: [{ kind: "direct" }], + }, + }); + expect(options.proxyGroupAdvanced).toEqual({ + auto: { + groupType: "fallback", + excludedMembers: [{ kind: "node", name: "Node" }], + }, + }); + }); + + it("keeps only valid optional persisted collections", () => { + const options = buildGenerateOptionsFromConfig( + { + enabledGroups: [null, " ", "auto"], + enabledRules: "bad", + customRules: "bad", + customProxyGroups: [ + null, + { id: " ", name: "Bad", groupType: "select" }, + { id: "bad", name: "Bad", groupType: "bad" }, + { + id: "select", + name: " Select ", + emoji: null, + groupType: "select", + enabled: true, + description: "", + memberSource: "filtered-nodes", + includeInGroupMembers: "yes", + }, + { + id: "balance", + name: "Balance", + emoji: "B", + groupType: "load-balance", + strategy: "consistent-hashing", + advanced: "bad", + }, + ], + dialerProxyGroups: [ + null, + { id: "bad", name: "Bad", type: "bad" }, + { + id: "select-dialer", + name: "Select Dialer", + type: "select", + enabled: false, + relayNodes: "bad", + targetNodes: [" target-a ", 1, ""], + }, + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + strategy: "bad", + }, + ], + listenerPorts: { + " ": 12000, + stringPort: "12001", + valid: 12002, + }, + proxyGroupNameOverrides: { + " ": "Name", + valid: " Valid Name ", + }, + ruleOrder: "bad", + }, + { nodes: [node()] } + ); + + expect(options.userConfig?.enabledGroups).toEqual(["auto"]); + expect(options.userConfig).not.toHaveProperty("enabledRules"); + expect(options.userConfig).not.toHaveProperty("customRules"); + expect(options.userConfig?.listenerPorts).toEqual({ valid: 12002 }); + expect(options.customProxyGroups).toEqual([ + { + advanced: {}, + emoji: "", + groupType: "select", + id: "select", + memberSource: "filtered-nodes", + name: "Select", + }, + { + advanced: {}, + emoji: "B", + groupType: "load-balance", + id: "balance", + name: "Balance", + strategy: "consistent-hashing", + }, + ]); + expect(options.dialerProxyGroups).toEqual([ + { + enabled: false, + id: "select-dialer", + name: "Select Dialer", + relayNodes: [], + targetNodes: ["target-a"], + type: "select", + }, + { + id: "balance-dialer", + name: "Balance Dialer", + relayNodes: [], + strategy: "consistent-hashing", + targetNodes: [], + type: "load-balance", + }, + ]); + expect(options.proxyGroupNameOverrides).toEqual({ valid: "Valid Name" }); + }); + + it("omits empty optional maps and preserves a valid persisted rule order", () => { + const options = buildGenerateOptionsFromConfig( + { + customRules: [ + { + id: "custom-rule", + type: "DOMAIN-SUFFIX", + value: "example.com", + target: "DIRECT", + }, + ], + ruleOrder: ["custom-rule"], + listenerPorts: { + emptyName: "bad", + invalid: 65536, + }, + proxyGroupNameOverrides: { + empty: " ", + }, + customProxyGroups: [ + { id: "missing-name", name: " ", emoji: "", groupType: "select" }, + { id: "missing-type", name: "Missing Type", emoji: "" }, + ], + dialerProxyGroups: [ + { id: "", name: "Bad", type: "select" }, + { id: "bad-name", name: " ", type: "select" }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.userConfig?.ruleOrder).toEqual(["custom-rule:custom-rule"]); + expect(options.userConfig).not.toHaveProperty("listenerPorts"); + expect(options.proxyGroupNameOverrides).toBeUndefined(); + expect(options.customProxyGroups).toBeUndefined(); + expect(options.dialerProxyGroups).toBeUndefined(); + }); + + it("uses legacy custom group fallback when rule model normalization returns no groups", async () => { + vi.resetModules(); + vi.doMock("@subboost/core/rules/rule-model", () => ({ + normalizeRuleModelFromConfig: () => ({ + customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, + }), + })); + + try { + const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); + const options = buildWithMockedRuleModel( + { + template: "minimal", + customProxyGroups: [ + "bad", + { id: "", name: "Bad", emoji: "B", groupType: "select" }, + { id: "select", name: " Select ", emoji: null, groupType: "select", enabled: true }, + { id: "auto", name: "Auto", emoji: "A", groupType: "url-test" }, + { id: "fallback", name: "Fallback", emoji: "F", groupType: "fallback" }, + { id: "direct", name: "Direct", emoji: "D", groupType: "direct-first" }, + { id: "reject", name: "Reject", emoji: "R", groupType: "reject-first" }, + { id: "balance", name: "Balance", emoji: "B", groupType: "load-balance", strategy: "bad" }, + ], + dialerProxyGroups: [ + { + id: "direct-dialer", + name: "Direct Dialer", + type: "direct-first", + enabled: true, + relayNodes: [" Relay ", ""], + targetNodes: "bad", + }, + { + id: "reject-dialer", + name: "Reject Dialer", + type: "reject-first", + strategy: "round-robin", + }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.customProxyGroups?.map((group) => group.groupType)).toEqual([ + "select", + "url-test", + "fallback", + "direct-first", + "reject-first", + "load-balance", + ]); + expect(options.customProxyGroups?.find((group) => group.id === "balance")).toMatchObject({ + strategy: "consistent-hashing", + }); + expect(options.dialerProxyGroups).toEqual([ + { + enabled: true, + id: "direct-dialer", + name: "Direct Dialer", + relayNodes: ["Relay"], + targetNodes: [], + type: "direct-first", + }, + { + id: "reject-dialer", + name: "Reject Dialer", + relayNodes: [], + targetNodes: [], + type: "reject-first", + }, + ]); + + const sparse = buildWithMockedRuleModel( + { + customProxyGroups: "bad", + dialerProxyGroups: "bad", + proxyGroupAdvanced: { + " missing ": "bad", + " select ": { groupType: "select" }, + " empty ": { regions: ["bad"] }, + }, + }, + { nodes: [node()] }, + ); + + expect(sparse.customProxyGroups).toBeUndefined(); + expect(sparse.dialerProxyGroups).toBeUndefined(); + expect(sparse.proxyGroupAdvanced).toEqual({ select: { groupType: "select" } }); + } finally { + vi.doUnmock("@subboost/core/rules/rule-model"); + vi.resetModules(); + } + }); + + it("normalizes legacy custom groups with dense optional field variants", async () => { + vi.resetModules(); + vi.doMock("@subboost/core/rules/rule-model", () => ({ + normalizeRuleModelFromConfig: () => ({ + customProxyGroups: [], + customRuleSets: [], + builtinRuleEdits: {}, + }), + })); + + try { + const { buildGenerateOptionsFromConfig: buildWithMockedRuleModel } = await import("./config-utils"); + const options = buildWithMockedRuleModel( + { + customProxyGroups: [ + { id: "missing-name", name: "", emoji: "M", groupType: "select" }, + { id: "missing-type", name: "Missing Type", emoji: "M", groupType: "invalid" }, + { + id: "select", + name: " Select ", + emoji: " S ", + enabled: false, + description: " Primary group ", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "select", + strategy: "round-robin", + advanced: { + includeRegex: "JP", + regions: ["jp"], + }, + }, + { + id: "balance", + name: "Balance", + emoji: undefined, + enabled: true, + description: " ", + memberSource: "all", + includeInGroupMembers: "yes", + groupType: "load-balance", + strategy: "round-robin", + advanced: { regions: ["bad"] }, + }, + ], + dialerProxyGroups: [ + { id: "missing-name", name: "", type: "select" }, + { id: "missing-type", name: "Missing Type", type: "invalid" }, + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + enabled: "yes", + relayNodes: [" Relay "], + targetNodes: [" Target "], + }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.customProxyGroups).toEqual([ + { + id: "select", + name: "Select", + emoji: "S", + enabled: false, + description: "Primary group", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "select", + advanced: { + includeRegex: "JP", + regions: ["jp"], + }, + }, + { + id: "balance", + name: "Balance", + emoji: "", + groupType: "load-balance", + strategy: "round-robin", + }, + ]); + expect(options.dialerProxyGroups).toEqual([ + { + id: "balance-dialer", + name: "Balance Dialer", + type: "load-balance", + strategy: "consistent-hashing", + relayNodes: ["Relay"], + targetNodes: ["Target"], + }, + ]); + } finally { + vi.doUnmock("@subboost/core/rules/rule-model"); + vi.resetModules(); + } + }); + + it("normalizes mixed persisted options without emitting empty optional sections", () => { + const options = buildGenerateOptionsFromConfig( + { + template: "full", + testUrl: " http://probe.example.com/204 ", + testInterval: 0, + enabledGroups: [1, " ", "auto", "auto", "cn"], + enabledRules: [null, "global", " "], + customRules: [ + { id: "", type: "DST-PORT", value: " 443 ", target: { kind: "module", id: " auto " } }, + { id: "bad-target", type: "DOMAIN", value: "bad.example", target: { kind: "node", name: "" } }, + { id: "src-port", type: "SRC-PORT", value: " 6881 ", target: { kind: "custom", id: " media " } }, + ], + customProxyGroups: [ + { + id: "media", + name: " Media ", + emoji: undefined, + enabled: false, + description: " Media group ", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["tw"], + includeRegex: "TW", + excludeRegex: "test", + extraMembers: [{ kind: "reject" }], + excludedMembers: [{ kind: "direct" }], + memberOrder: [{ kind: "reject" }], + }, + }, + ], + customRuleSets: [ + { + id: "manual", + name: " Manual ", + behavior: "classical", + path: "https://rules.example.com/manual.yaml", + target: { kind: "module", id: " cn " }, + noResolve: true, + }, + ], + dialerProxyGroups: [ + { + id: "direct", + name: " Direct Dialer ", + type: "direct-first", + strategy: "round-robin", + relayNodes: ["Relay", "Relay", ""], + targetNodes: ["Target", null, "Target"], + }, + ], + listenerPorts: { + socks: 65535, + negative: -1, + nan: Number.NaN, + }, + proxyGroupNameOverrides: { + cn: " China ", + }, + proxyGroupOrder: [" cn ", "cn", "", null], + mixedPort: 65535, + allowLan: false, + autoSelectStrategy: "load-balance", + cnIpNoResolve: true, + experimentalCnUseCnRuleSet: false, + }, + { nodes: [node({ name: "TW Node" })] }, + ); + + expect(options.template).toBe("full"); + expect(options.userConfig).toMatchObject({ + enabledGroups: ["auto", "auto", "cn"], + enabledRules: ["global"], + mixedPort: 65535, + allowLan: false, + autoSelectStrategy: "load-balance", + cnIpNoResolve: true, + experimentalCnUseCnRuleSet: false, + listenerPorts: { socks: 65535 }, + testUrl: "http://probe.example.com/204", + testInterval: 0, + }); + expect(options.userConfig?.customRules).toEqual([ + { + id: "custom-rule-dst-port-443-module-auto-1", + type: "DST-PORT", + value: "443", + target: { kind: "module", id: "auto" }, + }, + { + id: "src-port", + type: "SRC-PORT", + value: "6881", + target: { kind: "custom", id: "media" }, + }, + ]); + expect(options.customProxyGroups).toEqual([ + { + id: "media", + name: "Media", + emoji: "", + enabled: false, + description: "Media group", + memberSource: "filtered-nodes", + includeInGroupMembers: true, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + sourceIds: ["source-a"], + regions: ["tw"], + includeRegex: "TW", + excludeRegex: "test", + extraMembers: [{ kind: "reject" }], + excludedMembers: [{ kind: "direct" }], + memberOrder: [{ kind: "reject" }], + }, + }, + ]); + expect(options.customRuleSets).toBeUndefined(); + expect(options.dialerProxyGroups).toEqual([ + { + id: "direct", + name: "Direct Dialer", + type: "direct-first", + relayNodes: ["Relay", "Relay"], + targetNodes: ["Target", "Target"], + }, ]); - expect(minimal.filteredProxyGroups?.[0].sourceIds).toEqual(["airport"]); - expect(minimal.filteredProxyGroups?.[1].regions).toEqual(["hk", "other"]); + expect(options.proxyGroupNameOverrides).toEqual({ cn: "China" }); + expect(options.proxyGroupOrder).toEqual(["cn", "cn"]); }); }); diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 6e6da28..09d4517 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -1,70 +1,24 @@ 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, isLoadBalanceStrategy, + isProxyGroupGroupType, type CustomProxyGroup, type CustomRule, + type ProxyGroupRuleTarget, type TemplateType, type UserConfig, } from "@subboost/core/types/config"; -import type { FilteredProxyGroup, NodeRegion } from "@subboost/core/types/filtered-proxy-group"; import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscription/imported-node-controls"; import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; import { ensureCustomRuleId } from "@subboost/core/rules/custom-rule-utils"; import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; - -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; -} +import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { normalizeProxyGroupTargetRef } from "@subboost/core/proxy-group-targets"; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -86,6 +40,12 @@ function normalizeStringArray(value: unknown): string[] { return out; } +function normalizeRuleTarget(value: unknown): ProxyGroupRuleTarget | null { + const ref = normalizeProxyGroupTargetRef(value); + if (ref) return ref; + return toTrimmedString(value); +} + function normalizeTemplate(value: unknown, fallback: TemplateType = "standard"): TemplateType { if (value === "minimal" || value === "standard" || value === "full") return value; return fallback; @@ -127,7 +87,7 @@ function normalizeCustomRules(value: unknown): CustomRule[] | undefined { if (typeof type !== "string" || !allowedTypes.has(type as CustomRule["type"])) continue; const ruleValue = toTrimmedString(item.value); - const target = toTrimmedString(item.target); + const target = normalizeRuleTarget(item.target); if (!ruleValue || !target) continue; const noResolve = typeof item.noResolve === "boolean" ? item.noResolve : undefined; @@ -180,8 +140,9 @@ function normalizeDialerProxyGroups(value: unknown): DialerProxyGroup[] { if (!isRecord(item)) continue; const id = toTrimmedString(item.id); const name = toTrimmedString(item.name); - const type = item.type === "select" || item.type === "url-test" ? item.type : null; + const type = isProxyGroupGroupType(item.type) ? item.type : null; if (!id || !name || !type) continue; + const strategy = isLoadBalanceStrategy(item.strategy) ? item.strategy : undefined; const enabled = typeof item.enabled === "boolean" ? item.enabled : undefined; const relayNodes = normalizeStringArray(item.relayNodes); @@ -191,6 +152,7 @@ function normalizeDialerProxyGroups(value: unknown): DialerProxyGroup[] { id, name, type, + ...(type === "load-balance" ? { strategy: strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } : {}), relayNodes, targetNodes, ...(enabled !== undefined ? { enabled } : {}), @@ -207,7 +169,7 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { if (!isRecord(item)) continue; const id = toTrimmedString(item.id); const name = toTrimmedString(item.name); - const emoji = toTrimmedString(item.emoji); + const emoji = toTrimmedString(item.emoji) ?? ""; const groupType = item.groupType === "select" || item.groupType === "url-test" || @@ -218,110 +180,32 @@ function normalizeCustomProxyGroups(value: unknown): CustomProxyGroup[] { ? item.groupType : null; - 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) - ? item.strategy - : DEFAULT_LOAD_BALANCE_STRATEGY - : undefined; - - out.push({ id, name, emoji, groupType, ...(strategy ? { strategy } : {}), rules }); - } - return out; -} + if (!id || !name || !groupType) continue; -function normalizeNodeRegions(value: unknown): NodeRegion[] { - const allowed: NodeRegion[] = [ - "us", - "hk", - "jp", - "sg", - "tw", - "kr", - "uk", - "de", - "fr", - "ca", - "au", - "other", - ]; - const allowedSet = new Set(allowed); - const out: NodeRegion[] = []; - if (!Array.isArray(value)) return out; - for (const item of value) { - const s = toTrimmedString(item); - if (!s) continue; - const key = s.toLowerCase(); - if (!allowedSet.has(key)) continue; - out.push(key as NodeRegion); - } - return out; -} - -function normalizeFilteredProxyGroups(value: unknown): FilteredProxyGroup[] { - if (!Array.isArray(value)) return []; - const out: FilteredProxyGroup[] = []; - for (const item of value) { - if (!isRecord(item)) continue; - const id = toTrimmedString(item.id); - const name = toTrimmedString(item.name); - const enabled = typeof item.enabled === "boolean" ? item.enabled : null; - const groupTypeRaw = toTrimmedString(item.groupType) || "select"; - const groupType = - groupTypeRaw === "url-test" || - groupTypeRaw === "fallback" || - groupTypeRaw === "load-balance" || - groupTypeRaw === "direct-first" || - groupTypeRaw === "reject-first" - ? groupTypeRaw - : "select"; const strategy = groupType === "load-balance" ? isLoadBalanceStrategy(item.strategy) ? item.strategy : DEFAULT_LOAD_BALANCE_STRATEGY : undefined; - const sourceIds = normalizeStringArray(item.sourceIds); - const regions = normalizeNodeRegions(item.regions); - const includeRegex = toTrimmedString(item.includeRegex); - const excludeRegex = toTrimmedString(item.excludeRegex); - const excludedNodeNames = normalizeStringArray(item.excludedNodeNames); - - if (!id || !name || enabled === null) continue; + const enabled = item.enabled === false ? false : undefined; + const description = toTrimmedString(item.description); + const memberSource = item.memberSource === "filtered-nodes" ? "filtered-nodes" : undefined; + const includeInGroupMembers = + typeof item.includeInGroupMembers === "boolean" ? item.includeInGroupMembers : undefined; + const advanced = normalizeProxyGroupAdvancedConfig(item.advanced); out.push({ id, name, - enabled, + emoji, + ...(enabled === false ? { enabled: false } : {}), + ...(description ? { description } : {}), + ...(memberSource ? { memberSource } : {}), + ...(includeInGroupMembers !== undefined ? { includeInGroupMembers } : {}), groupType, ...(strategy ? { strategy } : {}), - sourceIds, - regions, - excludedNodeNames, - ...(includeRegex ? { includeRegex } : {}), - ...(excludeRegex ? { excludeRegex } : {}), - ...(toTrimmedString(item.emoji) ? { emoji: toTrimmedString(item.emoji) as string } : {}), + ...(Object.keys(advanced).length > 0 ? { advanced } : {}), }); } return out; @@ -345,12 +229,13 @@ export function getEffectiveTestOptions(config: Record): { test } export function buildGenerateOptionsFromConfig( - config: Record, + rawConfig: Record, opts: { nodes: ParsedNode[]; proxyProviders?: Record; } ): GenerateOptions { + const config = rawConfig; const { testUrl, testInterval } = getEffectiveTestOptions(config); const proxyProviders = opts.proxyProviders ?? buildProxyProvidersFromConfig(config, { testUrl, testInterval }); @@ -360,7 +245,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 +267,12 @@ 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 || {}, + customRules: customRules || [], + customRuleSets, + builtinRuleEdits, proxyGroupNameOverrides, experimentalCnUseCnRuleSet, cnIpNoResolve, @@ -411,9 +297,15 @@ export function buildGenerateOptionsFromConfig( }; const dialerProxyGroups = normalizeDialerProxyGroups(config.dialerProxyGroups); - const filteredProxyGroups = normalizeFilteredProxyGroups(config.filteredProxyGroups); const proxyGroupOrder = normalizeProxyGroupOrder(config.proxyGroupOrder); const sanitizedNodes = stripImportedNodeControlFieldsFromList(opts.nodes); + const proxyGroupAdvanced = isRecord(config.proxyGroupAdvanced) + ? Object.fromEntries( + Object.entries(config.proxyGroupAdvanced) + .map(([id, value]) => [id.trim(), normalizeProxyGroupAdvancedConfig(value)] as const) + .filter(([id, advanced]) => id && Object.keys(advanced).length > 0), + ) + : undefined; return { nodes: sanitizedNodes, @@ -422,9 +314,9 @@ export function buildGenerateOptionsFromConfig( userConfig, ...(dialerProxyGroups.length > 0 ? { dialerProxyGroups } : {}), ...(customProxyGroups.length > 0 ? { customProxyGroups } : {}), - ...(filteredProxyGroups.length > 0 ? { filteredProxyGroups } : {}), - ...(moduleRuleOverrides ? { moduleRuleOverrides } : {}), - ...(moduleRuleExclusions ? { moduleRuleExclusions } : {}), + ...(customRuleSets.length > 0 ? { customRuleSets } : {}), + ...(proxyGroupAdvanced && Object.keys(proxyGroupAdvanced).length > 0 ? { proxyGroupAdvanced } : {}), + ...(Object.keys(builtinRuleEdits).length > 0 ? { builtinRuleEdits } : {}), ...(proxyGroupNameOverrides ? { proxyGroupNameOverrides } : {}), ...(proxyGroupOrder ? { proxyGroupOrder } : {}), }; diff --git a/packages/core/src/subscription/node-source-state.test.ts b/packages/core/src/subscription/node-source-state.test.ts new file mode 100644 index 0000000..108124e --- /dev/null +++ b/packages/core/src/subscription/node-source-state.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedNode } from "../types/node"; +import { + ORIGIN_NAME_KEY, + SOURCE_IDS_KEY, + getNodeOriginName, + getNodeSourceIds, + keepOnlyValidNodeSourceIds, + makeUniqueName, + normalizeNodeOriginName, + withNodeSourceId, + withUniqueNodeNames, + withoutNodeSourceIds, +} from "./node-source-state"; + +function node(name: string, patch: Record = {}): ParsedNode { + return { + name, + type: "ss", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + ...patch, + } as ParsedNode; +} + +describe("node source state helpers", () => { + it("makes unique node names and preserves unchanged nodes by identity", () => { + const used = new Set(["Node", "Node (2)", "未命名节点"]); + expect(makeUniqueName(" Node ", used)).toBe("Node (3)"); + expect(makeUniqueName(" ", used)).toBe("未命名节点 (2)"); + + const first = node("Fresh"); + const result = withUniqueNodeNames([first, node("Fresh"), node("")], new Set()); + expect(result[0]).toBe(first); + expect(result.map((item) => item.name)).toEqual(["Fresh", "Fresh (2)", "未命名节点"]); + }); + + it("normalizes origin names and source ids conservatively", () => { + const sourceNode = node("Renamed", { + [ORIGIN_NAME_KEY]: " Origin ", + [SOURCE_IDS_KEY]: [" a ", "", "a", 1, "b"], + }); + + expect(getNodeOriginName(sourceNode)).toBe(" Origin "); + expect(getNodeOriginName(node("Plain", { [ORIGIN_NAME_KEY]: " " }))).toBe("Plain"); + expect(normalizeNodeOriginName(sourceNode)).toBe(sourceNode); + expect(normalizeNodeOriginName(node("Plain"))).toMatchObject({ + [ORIGIN_NAME_KEY]: "Plain", + }); + expect(getNodeSourceIds(sourceNode)).toEqual(["a", "b"]); + expect(getNodeSourceIds(node("Plain", { [SOURCE_IDS_KEY]: "bad" }))).toEqual([]); + }); + + it("adds, removes, and filters source ids without losing unrelated nodes", () => { + const base = node("Node"); + expect(withNodeSourceId(base, " ")).toBe(base); + + const withA = withNodeSourceId(base, " source-a "); + const withDuplicate = withNodeSourceId(withA, "source-a"); + const withB = withNodeSourceId(withA, "source-b"); + + expect(getNodeSourceIds(withA)).toEqual(["source-a"]); + expect(withDuplicate).toEqual(withA); + expect(getNodeSourceIds(withB)).toEqual(["source-a", "source-b"]); + expect(withoutNodeSourceIds(base, new Set(["source-a"]))).toBe(base); + expect(withoutNodeSourceIds(withB, new Set(["source-c"]))).toBe(withB); + expect(withoutNodeSourceIds(withB, new Set(["source-b"]))).toMatchObject({ + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect(withoutNodeSourceIds(withA, new Set(["source-a"]))).toBeNull(); + expect(keepOnlyValidNodeSourceIds(base, new Set(["source-a"]))).toBe(base); + expect(keepOnlyValidNodeSourceIds(withB, new Set(["source-a", "source-b"]))).toBe(withB); + expect(keepOnlyValidNodeSourceIds(withB, new Set(["source-b"]))).toMatchObject({ + [SOURCE_IDS_KEY]: ["source-b"], + }); + expect(keepOnlyValidNodeSourceIds(withA, new Set(["source-b"]))).toBeNull(); + }); +}); diff --git a/packages/core/src/subscription/source-node-refresh.test.ts b/packages/core/src/subscription/source-node-refresh.test.ts index 44e2fa2..e4ed0e1 100644 --- a/packages/core/src/subscription/source-node-refresh.test.ts +++ b/packages/core/src/subscription/source-node-refresh.test.ts @@ -56,6 +56,12 @@ describe("source node refresh helpers", () => { name: "Plain", [ORIGIN_NAME_KEY]: "Plain", }); + + const [blank] = prepareSourceParsedNodes([ssNode(" ")], {}); + expect(blank).toMatchObject({ + name: "", + [ORIGIN_NAME_KEY]: "", + }); }); it("detaches a source id while preserving nodes still owned by other sources", () => { @@ -277,6 +283,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", { @@ -312,6 +376,184 @@ describe("source node refresh helpers", () => { [SOURCE_IDS_KEY]: ["source-a"], }); }); + + it("honors display-name deleted markers and exact deleted-node descriptors", () => { + const deleted = ssNode("Deleted Origin", { + server: "deleted.example.com", + [ORIGIN_NAME_KEY]: "Deleted Origin", + }); + const parsed = prepareSourceParsedNodes( + [ + ssNode("Origin A", { server: "a.example.com" }), + deleted, + ssNode("Origin C", { server: "c.example.com" }), + ], + { currentTag: "New", currentNameTemplate: "{tag}-{name}" } + ); + + const result = mergeParsedSourceNodes([], parsed, ["New-Origin A"], { + sourceId: "source-a", + deletedNodes: [ + { node: undefined }, + { originName: "Deleted Origin", node: deleted }, + ], + }); + + expect(result.nodes.map((node) => node.name)).toEqual(["New-Origin C"]); + }); + + it("uses deleted-node fallback origin metadata and ignores blank fresh origins", () => { + const deletedByNodeOrigin = ssNode("Deleted By Node", { + server: "deleted-by-node.example.com", + [ORIGIN_NAME_KEY]: "Deleted By Node", + }); + const parsed = prepareSourceParsedNodes( + [ + deletedByNodeOrigin, + ssNode(" ", { server: "blank-origin.example.com" }), + ssNode("Kept", { server: "kept.example.com" }), + ], + {} + ); + + const result = mergeParsedSourceNodes([], parsed, [], { + sourceId: "source-a", + deletedNodes: [ + { originName: 1, node: deletedByNodeOrigin }, + { name: "ignored", node: ssNode(" ") }, + ], + }); + + expect(result.nodes.map((node) => node.name)).toEqual(["Kept"]); + }); + + it("avoids display-name fallback across different node types and records fixed-name collisions", () => { + const parsed = prepareSourceParsedNodes([ssNode("Shared Name", { server: "fresh-shared.example.com" })], {}); + const noSameType = mergeParsedSourceNodes( + [ + ssNode("Shared Name", { + type: "trojan", + password: "secret", + server: "manual-trojan.example.com", + [ORIGIN_NAME_KEY]: "Other Origin", + }), + ], + parsed, + [], + { sourceId: "source-a" } + ); + + expect(noSameType.nodes.map((node) => node.name)).toEqual(["Shared Name", "Shared Name (2)"]); + + const renamed = mergeParsedSourceNodes( + [ + ssNode("Keep", { [ORIGIN_NAME_KEY]: "Keep", [SOURCE_IDS_KEY]: ["source-a"] }), + ssNode("Keep", { [ORIGIN_NAME_KEY]: "Other", [SOURCE_IDS_KEY]: ["source-b"] }), + ], + prepareSourceParsedNodes([ssNode("Keep")], {}), + [], + { sourceId: "source-a", lastNameTemplate: "{name}", currentNameTemplate: "{name}" } + ); + + expect(renamed.nodes.map((node) => node.name)).toEqual(["Keep (2)", "Keep"]); + expect(Array.from(renamed.renameMap.entries())).toEqual([["Keep", "Keep (2)"]]); + }); + + it("consumes one matching fresh node and merges unowned base nodes by content", () => { + const state = [ + ssNode("Manual One", { + server: "same-origin.example.com", + [ORIGIN_NAME_KEY]: "Shared Origin", + }), + ssNode("Manual Two", { + server: "same-origin.example.com", + [ORIGIN_NAME_KEY]: "Shared Origin", + }), + ssNode("Other Source", { + server: "content-match.example.com", + [ORIGIN_NAME_KEY]: "Fresh Content", + [SOURCE_IDS_KEY]: ["source-b"], + }), + ]; + const parsed = prepareSourceParsedNodes( + [ + ssNode("Shared Origin", { server: "same-origin.example.com" }), + ssNode("Fresh Content", { server: "content-match.example.com" }), + ], + {} + ); + + const result = mergeParsedSourceNodes(state, parsed, [], { + sourceId: "source-a", + }); + + expect(result.nodes.map((node) => node.name)).toEqual(["Manual One", "Other Source"]); + expect(result.nodes[0]).toMatchObject({ + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect(result.nodes[1]).toMatchObject({ + [ORIGIN_NAME_KEY]: "Fresh Content", + [SOURCE_IDS_KEY]: ["source-b", "source-a"], + }); + }); + + it("matches display names while cleaning source ids from refreshed records", () => { + const parsed = [ + ssNode("Same Display", { server: "fresh-display.example.com" }), + { + name: undefined, + type: "ss", + server: "fresh-origin.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + [ORIGIN_NAME_KEY]: "Header Only", + } as unknown as ParsedNode, + ]; + const result = mergeParsedSourceNodes( + [ + ssNode("Same Display", { + server: "old-display.example.com", + [ORIGIN_NAME_KEY]: "Old Origin", + }), + ssNode("Keep Source Name", { + server: "old-origin.example.com", + [ORIGIN_NAME_KEY]: "Header Only", + [SOURCE_IDS_KEY]: ["source-b", "source-b", "", "source-a"], + }), + ], + parsed, + [], + { + sourceId: "source-a", + deletedNodes: [ + { + node: { + name: "", + type: "ss", + server: "blank.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + [ORIGIN_NAME_KEY]: "", + } as unknown as ParsedNode, + }, + ], + } + ); + + expect(result.nodes.map((node) => node.name)).toEqual(["Same Display", "Keep Source Name"]); + expect(result.nodes[0]).toMatchObject({ + server: "fresh-display.example.com", + [ORIGIN_NAME_KEY]: "Same Display", + [SOURCE_IDS_KEY]: ["source-a"], + }); + expect(result.nodes[1]).toMatchObject({ + server: "fresh-origin.example.com", + [ORIGIN_NAME_KEY]: "Header Only", + [SOURCE_IDS_KEY]: ["source-b", "source-a"], + }); + }); }); describe("subscription response info helpers", () => { diff --git a/packages/core/src/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/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.fields.test.ts b/packages/core/src/templates/config-template.fields.test.ts index 52979e3..a81947b 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 { validateSubBoostTemplateConfig } from "./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( @@ -44,7 +41,7 @@ describe("validateSubBoostTemplateConfig field validation", () => { { id: "relay", name: "Relay", - type: "fallback" as never, + type: "bad" as never, relayNodes: [], targetNodes: [], }, @@ -52,6 +49,24 @@ describe("validateSubBoostTemplateConfig field validation", () => { }) ) ).toEqual({ ok: false, error: "dialerProxyGroups.type 无效" }); + const validDialerGroupType = validateSubBoostTemplateConfig( + validConfig({ + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "load-balance", + strategy: "round-robin", + relayNodes: ["Relay A"], + targetNodes: ["Target A"], + }, + ], + }) + ); + expect(validDialerGroupType.ok && validDialerGroupType.config.dialerProxyGroups[0]).toMatchObject({ + type: "load-balance", + strategy: "round-robin", + }); expectInvalid( { dialerProxyGroups: [ @@ -66,6 +81,35 @@ describe("validateSubBoostTemplateConfig field validation", () => { }, "dialerProxyGroups.relayNodes 只能包含字符串" ); + expectInvalid( + { + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "load-balance", + strategy: "bad" as never, + relayNodes: [], + targetNodes: [], + }, + ], + }, + "dialerProxyGroups.strategy 无效" + ); + expectInvalid( + { + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "select", + relayNodes: [], + targetNodes: [1 as never], + }, + ], + }, + "dialerProxyGroups.targetNodes 只能包含字符串" + ); expectInvalid( { dialerProxyGroups: [ @@ -82,92 +126,158 @@ 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: "", + name: "Bad", + behavior: "domain", + path: "geosite/private.mrs", + target: "DIRECT", + }, + ], + }, + "customRuleSets.id 不能为空" ); expectInvalid( - { moduleRuleOverrides: { [moduleId]: [1] } as never }, - "moduleRuleOverrides 只能包含对象" + { + customRuleSets: [ + { + id: "bad", + name: "", + behavior: "domain", + path: "geosite/private.mrs", + target: "DIRECT", + }, + ], + }, + "customRuleSets.name 不能为空" ); expectInvalid( { - moduleRuleOverrides: { - [moduleId]: [ - { - id: "bad", - name: "Bad", - behavior: "bad", - path: "geoip/private.mrs", - }, - ], - } as never, + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "bad" as never, + path: "geoip/private.mrs", + target: "DIRECT", + }, + ], }, - "moduleRuleOverrides.behavior 无效" + "customRuleSets.behavior 无效" ); - expect( - validateSubBoostTemplateConfig( - validConfig({ - moduleRuleOverrides: { - [moduleId]: [ - { - id: "bad", - name: "Bad", - behavior: "domain", - path: "https://rules.example.com/bad.mrs", - }, - ], + expectInvalid( + { + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "domain", + path: "plain/rule.txt", + target: "DIRECT", }, - }) - ) - ).toEqual({ ok: false, error: "moduleRuleOverrides.path 无效" }); + ], + }, + "customRuleSets.path 无效" + ); expectInvalid( { - moduleRuleOverrides: { - [moduleId]: [ - { - id: "bad", - name: "Bad", - behavior: "domain", - path: "geosite/private.mrs", - noResolve: "yes", - }, - ], - } as never, + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "domain", + path: "", + target: "DIRECT", + }, + ], }, - "moduleRuleOverrides.noResolve 必须是布尔值" + "customRuleSets.path 不能为空" ); - - expectInvalid({ moduleRuleExclusions: "bad" as never }, "moduleRuleExclusions 必须是对象"); - expect( - validateSubBoostTemplateConfig( - validConfig({ - moduleRuleExclusions: { - unknown: ["rule"], + expectInvalid( + { + customRuleSets: [ + { + id: "bad", + name: "Bad", + behavior: "domain", + path: "geosite/private.mrs", + target: "", }, - }) - ) - ).toEqual({ ok: false, error: "moduleRuleExclusions 包含未知代理组" }); + ], + }, + "customRuleSets.target 不能为空" + ); expectInvalid( - { moduleRuleExclusions: { [moduleId]: [1] } as never }, - "moduleRuleExclusions 只能包含字符串" + { + 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, + }, + "builtinRuleEdits 包含未知内置规则" + ); + expectInvalid( + { + builtinRuleEdits: { + "module:cn:geolocation-cn": "bad", + } as never, + }, + "builtinRuleEdits 的值必须是对象" + ); + expectInvalid( + { + 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 }, "experimentalCnUseCnRuleSet 必须是布尔值" ); + expectInvalid( + { proxyGroupAdvancedModeEnabled: "yes" as never }, + "proxyGroupAdvancedModeEnabled 必须是布尔值" + ); expect(validateSubBoostTemplateConfig(validConfig({ dnsYaml: 1 as never }))).toEqual({ ok: false, error: "dnsYaml 必须是字符串", @@ -176,6 +286,8 @@ describe("validateSubBoostTemplateConfig field validation", () => { ok: false, error: "testUrl 必须是 http(s) URL", }); + expectInvalid({ testUrl: "" }, "testUrl 不能为空"); + expectInvalid({ testInterval: 1.5 }, "testInterval 必须是正整数"); expectInvalid({ ruleProviderBaseUrl: "ftp://example.com" }, "ruleProviderBaseUrl 必须是 http(s) URL"); expectInvalid({ proxyGroupNameOverrides: "bad" as never }, "proxyGroupNameOverrides 必须是对象"); expect( @@ -187,5 +299,23 @@ describe("validateSubBoostTemplateConfig field validation", () => { }) ) ).toEqual({ ok: false, error: "proxyGroupNameOverrides 的值必须是字符串" }); + expectInvalid({ ruleOrder: "bad" as never }, "ruleOrder 必须是数组"); + expectInvalid({ ruleOrder: ["module:auto", 1 as never] }, "ruleOrder 只能包含字符串"); + const defaultDialerStrategy = validateSubBoostTemplateConfig( + validConfig({ + dialerProxyGroups: [ + { + id: "relay", + name: "Relay", + type: "load-balance", + relayNodes: ["Relay"], + targetNodes: ["Target"], + }, + ], + }) + ); + expect(defaultDialerStrategy.ok && defaultDialerStrategy.config.dialerProxyGroups[0]).toMatchObject({ + strategy: "consistent-hashing", + }); }); }); diff --git a/packages/core/src/templates/config-template.groups.test.ts b/packages/core/src/templates/config-template.groups.test.ts index d1b2399..b581430 100644 --- a/packages/core/src/templates/config-template.groups.test.ts +++ b/packages/core/src/templates/config-template.groups.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { validateSubBoostTemplateConfig } from "@subboost/core/templates/config-template"; +import { validateSubBoostTemplateConfig } from "./config-template"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; -describe("validateSubBoostTemplateConfig custom and filtered groups", () => { - it("rejects invalid custom and filtered group fields", () => { +describe("validateSubBoostTemplateConfig custom groups", () => { + it("rejects invalid custom group fields", () => { expectInvalid({ customRules: "bad" as never }, "customRules 必须是数组"); expectInvalid({ customRules: [1 as never] }, "customRules 只能包含对象"); expectInvalid( @@ -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: [], }, ], }, @@ -114,13 +112,12 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { { id: "custom", name: "Custom", - emoji: "C", - groupType: "bad" as never, - rules: [], + emoji: 1 as never, + groupType: "select", }, ], }, - "customProxyGroups.groupType 无效" + "customProxyGroups.emoji 必须是字符串" ); expectInvalid( { @@ -129,27 +126,39 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { id: "custom", name: "Custom", emoji: "C", - groupType: "select", - rules: "bad" as never, + groupType: "bad" as never, }, ], }, - "customProxyGroups.rules 必须是数组" + "customProxyGroups.groupType 无效" ); + expectInvalid({ proxyGroupAdvanced: [] as never }, "proxyGroupAdvanced 必须是对象"); expectInvalid( { - customProxyGroups: [ - { - id: "custom", - name: "Custom", - emoji: "C", - groupType: "select", - rules: [1 as never], - }, - ], + proxyGroupAdvanced: { + missing: {}, + }, }, - "customProxyGroups.rules 只能包含对象" + "proxyGroupAdvanced 包含未知代理组" ); + + expect( + validateSubBoostTemplateConfig( + validConfig({ + customProxyGroups: [ + { + id: "custom", + name: "Custom", + emoji: "C", + memberSource: "filtered-nodes", + groupType: "load-balance", + strategy: "bad" as never, + }, + ], + }) + ) + ).toEqual({ ok: false, error: "customProxyGroups.strategy 无效" }); + expectInvalid( { customProxyGroups: [ @@ -157,19 +166,12 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { id: "custom", name: "Custom", emoji: "C", + memberSource: "bad" as never, groupType: "select", - rules: [ - { - id: "r", - name: "R", - behavior: "bad" as never, - url: "https://rules.example.com/custom.mrs", - }, - ], }, ], }, - "customProxyGroups.rules.behavior 无效" + "customProxyGroups.memberSource 无效" ); expectInvalid( { @@ -178,146 +180,79 @@ describe("validateSubBoostTemplateConfig custom and filtered groups", () => { id: "custom", name: "Custom", emoji: "C", + includeInGroupMembers: "yes" as never, groupType: "select", - rules: [ - { - id: "r", - name: "R", - behavior: "domain", - url: "https://rules.example.com/custom.mrs", - noResolve: "yes" as never, - }, - ], }, ], }, - "customProxyGroups.rules.noResolve 必须是布尔值" + "customProxyGroups.includeInGroupMembers 必须是布尔值" ); - expectInvalid({ filteredProxyGroups: "bad" as never }, "filteredProxyGroups 必须是数组"); - expectInvalid({ filteredProxyGroups: [1 as never] }, "filteredProxyGroups 只能包含对象"); expectInvalid( { - filteredProxyGroups: [ + customProxyGroups: [ { - id: "filtered", - name: "Filtered", - enabled: "yes" as never, + id: "legacy", + name: "Legacy", + emoji: "L", groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, + rules: [], + } as never, ], }, - "filteredProxyGroups.enabled 必须是布尔值" + "模板配置包含已移除字段: customProxyGroups[0].rules" ); - expectInvalid( - { - filteredProxyGroups: [ + + const validCustomGroup = validateSubBoostTemplateConfig( + validConfig({ + customProxyGroups: [ { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "bad" as never, - sourceIds: [], - regions: [], - excludedNodeNames: [], + id: "balance", + name: "Balance", + emoji: "B", + description: " Media group ", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "load-balance", + advanced: { + sourceIds: ["source-a"], + }, }, ], - }, - "filteredProxyGroups.groupType 无效" + }) ); - expectInvalid( - { - filteredProxyGroups: [ + expect(validCustomGroup.ok && validCustomGroup.config.customProxyGroups[0]).toMatchObject({ + advanced: { sourceIds: ["source-a"] }, + description: "Media group", + strategy: "consistent-hashing", + }); + + const validSelectGroup = validateSubBoostTemplateConfig( + validConfig({ + customProxyGroups: [ { - id: "filtered", - name: "Filtered", - enabled: true, + id: "manual", + name: "Manual", + emoji: "", + includeInGroupMembers: true, groupType: "select", - sourceIds: [1 as never], - regions: [], - excludedNodeNames: [], }, ], - }, - "filteredProxyGroups.sourceIds 只能包含字符串" - ); - expectInvalid( - { - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "load-balance", - strategy: "bad" as never, - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], - }, - "filteredProxyGroups.strategy 无效" + }) ); + expect(validSelectGroup.ok).toBe(true); + if (validSelectGroup.ok) { + expect(validSelectGroup.config.customProxyGroups[0]).toEqual({ + id: "manual", + name: "Manual", + emoji: "", + includeInGroupMembers: true, + groupType: "select", + advanced: {}, + }); + } - expect( - validateSubBoostTemplateConfig( - validConfig({ - customProxyGroups: [ - { - id: "custom", - name: "Custom", - 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({ - filteredProxyGroups: [ - { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "select", - sourceIds: [], - regions: ["moon" as never], - excludedNodeNames: [], - }, - ], - }) - ) - ).toEqual({ ok: false, error: "filteredProxyGroups.regions 包含未知地区" }); + const removedField = `filtered${"ProxyGroups"}`; + expectInvalid({ [removedField]: [] } as never, `模板配置包含已移除字段: ${removedField}`); }); }); diff --git a/packages/core/src/templates/config-template.test-helpers.ts b/packages/core/src/templates/config-template.test-helpers.ts index 7fde747..8011e98 100644 --- a/packages/core/src/templates/config-template.test-helpers.ts +++ b/packages/core/src/templates/config-template.test-helpers.ts @@ -1,21 +1,21 @@ import { expect } from "vitest"; -import { getModulesForTemplate } from "@subboost/core/generator/proxy-groups"; +import { getModulesForTemplate } from "../generator/proxy-groups"; import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA, validateSubBoostTemplateConfig, -} from "@subboost/core/templates/config-template"; +} from "./config-template"; import type { SubBoostTemplateConfig } from "@subboost/core/types/template-config"; -export function validConfig(patch: Partial = {}): SubBoostTemplateConfig { +export function validConfig(patch: Partial & Record = {}): SubBoostTemplateConfig { return { schema: SUBBOOST_TEMPLATE_CONFIG_SCHEMA, template: "minimal", enabledProxyGroups: getModulesForTemplate("minimal"), hiddenProxyGroups: [], customProxyGroups: [], - filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + proxyGroupAdvanced: {}, + 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..3325850 100644 --- a/packages/core/src/templates/config-template.test.ts +++ b/packages/core/src/templates/config-template.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getModulesForTemplate } from "@subboost/core/generator/proxy-groups"; +import { getModulesForTemplate } from "../generator/proxy-groups"; import { SUBBOOST_TEMPLATE_CONFIG_SCHEMA, validateSubBoostTemplateConfig, -} from "@subboost/core/templates/config-template"; +} from "./config-template"; import { DEFAULT_LOAD_BALANCE_STRATEGY } from "@subboost/core/types/config"; import { expectInvalid, validConfig } from "./config-template.test-helpers"; @@ -49,39 +49,37 @@ 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: [ { id: "custom", name: "Custom", emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, groupType: "load-balance", - rules: [ - { - id: "custom-rule", - name: "Custom Rule", - behavior: "domain", - url: "https://rules.example.com/custom.mrs", - noResolve: false, - }, - ], }, ], - filteredProxyGroups: [ + customRuleSets: [ { - id: "filtered", - name: "Filtered", - enabled: true, - groupType: "load-balance", - sourceIds: ["source-a", "source-a", ""], - regions: ["us", "hk"], - excludedNodeNames: ["Node A", "Node A"], - includeRegex: " US ", - excludeRegex: "IPv6", - emoji: "F", + id: "custom-rule", + name: "Custom Rule", + behavior: "domain", + path: "https://rules.example.com/custom.mrs", + target: "Custom", + noResolve: false, + }, + { + id: "extra", + name: "Extra", + behavior: "ipcidr", + path: "geoip/private.mrs", + target: "Renamed", + noResolve: true, }, ], customRules: [ @@ -103,26 +101,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, }) ); @@ -132,20 +118,29 @@ describe("validateSubBoostTemplateConfig", () => { id: "custom", name: "Custom", emoji: "", + memberSource: "filtered-nodes", + includeInGroupMembers: false, groupType: "load-balance", strategy: DEFAULT_LOAD_BALANCE_STRATEGY, }); - expect(result.config.filteredProxyGroups?.[0]).toMatchObject({ - id: "filtered", - groupType: "load-balance", - strategy: DEFAULT_LOAD_BALANCE_STRATEGY, - sourceIds: ["source-a"], - regions: ["us", "hk"], - excludedNodeNames: ["Node A"], - includeRegex: "US", - excludeRegex: "IPv6", - emoji: "F", - }); + 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.proxyGroupAdvancedModeEnabled).toBe(true); expect(result.config.customRules[0]).toMatchObject({ id: "custom-rule-a", value: "example.com", @@ -158,15 +153,56 @@ 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}:openai`]).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("uses defaults when optional compatibility fields are omitted", () => { + const [moduleId] = getModulesForTemplate("minimal"); + const result = validateSubBoostTemplateConfig( + validConfig({ + hiddenProxyGroups: undefined as never, + proxyGroupAdvanced: { + [moduleId]: { + sourceIds: ["source-a"], + }, + }, + customRuleSets: undefined as never, + builtinRuleEdits: undefined as never, + ruleOrder: undefined as never, + proxyGroupNameOverrides: undefined as never, + cnIpNoResolve: undefined as never, + experimentalCnUseCnRuleSet: undefined as never, + }) + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.config.hiddenProxyGroups).toEqual([]); + expect(result.config.proxyGroupAdvanced[moduleId]).toEqual({ sourceIds: ["source-a"] }); + expect(result.config.customRuleSets).toEqual([]); + expect(result.config.builtinRuleEdits).toEqual({}); + expect(result.config.proxyGroupNameOverrides).toEqual({}); + expect(result.config).not.toHaveProperty("cnIpNoResolve"); + expect(result.config).not.toHaveProperty("experimentalCnUseCnRuleSet"); + }); + + it("rejects removed rule-model compatibility fields", () => { + expectInvalid({ moduleRuleOverrides: {} } as never, "模板配置包含已移除字段: moduleRuleOverrides"); + expectInvalid({ moduleRuleExclusions: {} } as never, "模板配置包含已移除字段: moduleRuleExclusions"); + expectInvalid({ allRulesOrderEditingEnabled: true } as never, "模板配置包含已移除字段: allRulesOrderEditingEnabled"); + const removedFilteredGroupsField = `filtered${"ProxyGroups"}`; + expectInvalid( + { [removedFilteredGroupsField]: [] } as never, + `模板配置包含已移除字段: ${removedFilteredGroupsField}` + ); + expectInvalid( + { customProxyGroups: [{ id: "custom", name: "Custom", emoji: "", groupType: "select", rules: [] }] } as never, + "模板配置包含已移除字段: customProxyGroups[0].rules" + ); }); 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..0af2254 100644 --- a/packages/core/src/templates/config-template.ts +++ b/packages/core/src/templates/config-template.ts @@ -1,17 +1,24 @@ 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 { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { + isValidRuleSetPathOrUrl, + normalizeRuleModelFromConfig, + normalizeRuleSetPathInput, +} from "@subboost/core/rules/rule-model"; import { DEFAULT_LOAD_BALANCE_STRATEGY, + isProxyGroupGroupType, isLoadBalanceStrategy, type CustomProxyGroup, type CustomRule, type LoadBalanceStrategy, + type ProxyGroupGroupType, type TemplateType, } from "@subboost/core/types/config"; -import type { FilteredProxyGroup, FilteredProxyGroupType, NodeRegion } from "@subboost/core/types/filtered-proxy-group"; -import type { DialerProxyGroup, 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,22 +27,23 @@ 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 FILTERED_GROUP_TYPES = new Set([ - "select", - "url-test", - "fallback", - "load-balance", - "direct-first", - "reject-first", +const BUILTIN_RULE_KEYS = new Set( + PROXY_GROUP_MODULES.flatMap((module) => module.rules.map((rule) => `module:${module.id}:${rule.id}`)) +); +const REMOVED_TEMPLATE_FIELDS = new Set([ + "moduleRuleOverrides", + "moduleRuleExclusions", + "allRulesOrderEditingEnabled", + "filteredProxyGroups", ]); -const NODE_REGIONS = new Set(["us", "hk", "jp", "sg", "tw", "kr", "uk", "de", "fr", "ca", "au", "other"]); 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("模板类型无效"); @@ -53,16 +61,22 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult const customProxyGroups = parseCustomProxyGroups(value.customProxyGroups); if (!customProxyGroups.ok) return customProxyGroups; - const filteredProxyGroups = parseFilteredProxyGroups(value.filteredProxyGroups); - if (!filteredProxyGroups.ok) return filteredProxyGroups; + const proxyGroupAdvanced = parseProxyGroupAdvanced(value.proxyGroupAdvanced); + if (!proxyGroupAdvanced.ok) return proxyGroupAdvanced; + const proxyGroupAdvancedModeEnabled = parseOptionalBoolean( + value.proxyGroupAdvancedModeEnabled, + "proxyGroupAdvancedModeEnabled" + ); + if (!proxyGroupAdvancedModeEnabled.ok) return proxyGroupAdvancedModeEnabled; + 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 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 +94,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 +105,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, @@ -110,14 +121,16 @@ export function validateSubBoostTemplateConfig(value: unknown): ValidationResult enabledProxyGroups: enabledProxyGroups.value, hiddenProxyGroups: hiddenProxyGroups.value, customProxyGroups: customProxyGroups.value, - filteredProxyGroups: filteredProxyGroups.value, - moduleRuleOverrides: moduleRuleOverrides.value, - moduleRuleExclusions: moduleRuleExclusions.value, + proxyGroupAdvanced: proxyGroupAdvanced.value, + proxyGroupAdvancedModeEnabled: resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: proxyGroupAdvancedModeEnabled.value, + customProxyGroups: customProxyGroups.value, + proxyGroupAdvanced: proxyGroupAdvanced.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 } @@ -142,6 +155,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; @@ -293,97 +320,87 @@ function parseCustomProxyGroups(value: unknown): { ok: true; value: CustomProxyG if (!name.ok) return name; const emoji = parseRequiredString(item.emoji, "customProxyGroups.emoji", { allowEmpty: true }); if (!emoji.ok) return emoji; - const groupType = parseFilteredGroupType(item.groupType, "customProxyGroups.groupType"); + const groupType = parseProxyGroupType(item.groupType, "customProxyGroups.groupType"); if (!groupType.ok) return groupType; - const rules = parseCustomProxyGroupRules(item.rules); - if (!rules.ok) return rules; const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "customProxyGroups.strategy"); if (!strategy.ok) return strategy; + if (item.memberSource !== undefined && item.memberSource !== "filtered-nodes") { + return invalid("customProxyGroups.memberSource 无效"); + } + if (item.includeInGroupMembers !== undefined && typeof item.includeInGroupMembers !== "boolean") { + return invalid("customProxyGroups.includeInGroupMembers 必须是布尔值"); + } + const description = typeof item.description === "string" ? item.description.trim() : ""; out.push({ id: id.value, name: name.value, emoji: emoji.value, + ...(description ? { description } : {}), + ...(item.memberSource === "filtered-nodes" ? { memberSource: "filtered-nodes" as const } : {}), + ...(typeof item.includeInGroupMembers === "boolean" + ? { includeInGroupMembers: item.includeInGroupMembers } + : {}), groupType: groupType.value, ...(groupType.value === "load-balance" ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } : {}), - rules: rules.value, + advanced: normalizeProxyGroupAdvancedConfig(item.advanced), }); } return { ok: true, value: out }; } -function parseCustomProxyGroupRules( +function parseProxyGroupAdvanced( value: unknown -): { ok: true; value: CustomProxyGroup["rules"] } | { ok: false; error: string } { - if (!Array.isArray(value)) return invalid("customProxyGroups.rules 必须是数组"); - const out: CustomProxyGroup["rules"] = []; - for (const item of value) { - if (!isRecord(item)) return invalid("customProxyGroups.rules 只能包含对象"); - const id = parseRequiredString(item.id, "customProxyGroups.rules.id"); - if (!id.ok) return id; - const name = parseRequiredString(item.name, "customProxyGroups.rules.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 (!noResolve.ok) return noResolve; - out.push({ - id: id.value, - name: name.value, - behavior: behavior.value, - url: url.value, - ...(noResolve.value !== undefined ? { noResolve: noResolve.value } : {}), - }); +): { ok: true; value: NonNullable } | { ok: false; error: string } { + if (value === undefined) return { ok: true, value: {} }; + if (!isRecord(value)) return invalid("proxyGroupAdvanced 必须是对象"); + const out: NonNullable = {}; + for (const [moduleId, rawConfig] of Object.entries(value)) { + const id = moduleId.trim(); + if (!BUILTIN_MODULE_IDS.has(id)) return invalid("proxyGroupAdvanced 包含未知代理组"); + out[id] = normalizeProxyGroupAdvancedConfig(rawConfig); } return { ok: true, value: out }; } -function parseFilteredProxyGroups(value: unknown): { ok: true; value: FilteredProxyGroup[] } | { ok: false; error: string } { - if (value === undefined) return { ok: true, value: [] }; - if (!Array.isArray(value)) return invalid("filteredProxyGroups 必须是数组"); - const out: FilteredProxyGroup[] = []; +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("filteredProxyGroups 只能包含对象"); - const id = parseRequiredString(item.id, "filteredProxyGroups.id"); + if (!isRecord(item)) return invalid("customRuleSets 只能包含对象"); + const id = parseRequiredString(item.id, "customRuleSets.id"); if (!id.ok) return id; - const name = parseRequiredString(item.name, "filteredProxyGroups.name"); + const name = parseRequiredString(item.name, "customRuleSets.name"); if (!name.ok) return name; - const enabled = parseBoolean(item.enabled, "filteredProxyGroups.enabled"); - if (!enabled.ok) return enabled; - const groupType = parseFilteredGroupType(item.groupType ?? "select", "filteredProxyGroups.groupType"); - if (!groupType.ok) return groupType; - const sourceIds = parseStringArray(item.sourceIds ?? [], "filteredProxyGroups.sourceIds"); - if (!sourceIds.ok) return sourceIds; - const regions = parseNodeRegions(item.regions ?? []); - if (!regions.ok) return regions; - const excludedNodeNames = parseStringArray(item.excludedNodeNames ?? [], "filteredProxyGroups.excludedNodeNames"); - if (!excludedNodeNames.ok) return excludedNodeNames; - const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "filteredProxyGroups.strategy"); - if (!strategy.ok) return strategy; - out.push({ - id: id.value, - name: name.value, - enabled: enabled.value, - groupType: groupType.value, - ...(groupType.value === "load-balance" - ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : {}), - sourceIds: sourceIds.value, - regions: regions.value, - excludedNodeNames: excludedNodeNames.value, - ...(typeof item.includeRegex === "string" && item.includeRegex.trim() - ? { includeRegex: item.includeRegex.trim() } - : {}), - ...(typeof item.excludeRegex === "string" && item.excludeRegex.trim() - ? { excludeRegex: item.excludeRegex.trim() } - : {}), - ...(typeof item.emoji === "string" ? { emoji: item.emoji.trim() } : {}), - }); + 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; } - 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 parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyGroup[] } | { ok: false; error: string } { @@ -395,7 +412,10 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG if (!id.ok) return id; const name = parseRequiredString(item.name, "dialerProxyGroups.name"); if (!name.ok) return name; - if (item.type !== "select" && item.type !== "url-test") return invalid("dialerProxyGroups.type 无效"); + const groupType = parseProxyGroupType(item.type, "dialerProxyGroups.type"); + if (!groupType.ok) return groupType; + const strategy = parseOptionalLoadBalanceStrategy(item.strategy, "dialerProxyGroups.strategy"); + if (!strategy.ok) return strategy; const relayNodes = parseStringArray(item.relayNodes, "dialerProxyGroups.relayNodes"); if (!relayNodes.ok) return relayNodes; const targetNodes = parseStringArray(item.targetNodes, "dialerProxyGroups.targetNodes"); @@ -405,7 +425,10 @@ function parseDialerProxyGroups(value: unknown): { ok: true; value: DialerProxyG out.push({ id: id.value, name: name.value, - type: item.type, + type: groupType.value, + ...(groupType.value === "load-balance" + ? { strategy: strategy.value ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : {}), relayNodes: relayNodes.value, targetNodes: targetNodes.value, ...(enabled.value !== undefined ? { enabled: enabled.value } : {}), @@ -414,69 +437,12 @@ 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( +function parseProxyGroupType( 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 -): { ok: true; value: FilteredProxyGroupType } | { ok: false; error: string } { - if (typeof value === "string" && FILTERED_GROUP_TYPES.has(value as FilteredProxyGroupType)) { - return { ok: true, value: value as FilteredProxyGroupType }; +): { ok: true; value: ProxyGroupGroupType } | { ok: false; error: string } { + if (isProxyGroupGroupType(value)) { + return { ok: true, value }; } return invalid(`${field} 无效`); } @@ -489,15 +455,3 @@ function parseOptionalLoadBalanceStrategy( if (!isLoadBalanceStrategy(value)) return invalid(`${field} 无效`); return { ok: true, value }; } - -function parseNodeRegions(value: unknown): { ok: true; value: NodeRegion[] } | { ok: false; error: string } { - const parsed = parseStringArray(value, "filteredProxyGroups.regions"); - if (!parsed.ok) return parsed; - const out: NodeRegion[] = []; - for (const region of parsed.value) { - const key = region.toLowerCase(); - if (!NODE_REGIONS.has(key as NodeRegion)) return invalid("filteredProxyGroups.regions 包含未知地区"); - out.push(key as NodeRegion); - } - return { ok: true, value: out }; -} diff --git a/packages/core/src/time/beijing.test.ts b/packages/core/src/time/beijing.test.ts new file mode 100644 index 0000000..b816f08 --- /dev/null +++ b/packages/core/src/time/beijing.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + addDaysToDateKey, + buildDateRangeFilterInBeijing, + formatDateInBeijing, + formatDateTimeInBeijing, + formatInBeijing, + formatTimeInBeijing, + getBeijingTimeZone, + getCompactDateStampInBeijing, + getDateKeyInBeijing, + getDayRangeInBeijing, + getTimeZoneOffsetMs, + getTodayRangeInBeijing, + getUtcMsForZonedDayStart, + parseDateKey, +} from "./beijing"; + +describe("Beijing time helpers", () => { + const sample = new Date("2026-06-22T16:30:05Z"); + + it("formats valid inputs in the Beijing timezone and keeps fallbacks for invalid inputs", () => { + expect(getBeijingTimeZone()).toBe("Asia/Shanghai"); + expect(formatInBeijing(sample, { hour: "2-digit", minute: "2-digit", hour12: false })).toBe("00:30"); + expect(formatDateTimeInBeijing(null, {}, "fallback")).toBe("fallback"); + expect(formatDateInBeijing("bad-date", {}, "bad")).toBe("bad"); + expect(formatTimeInBeijing(sample)).toBe("00:30"); + }); + + it("normalizes Beijing date keys and date arithmetic", () => { + expect(getDateKeyInBeijing(sample)).toBe("2026-06-23"); + expect(getCompactDateStampInBeijing(sample)).toBe("20260623"); + expect(parseDateKey(" 2026-06-23 ")).toBe("2026-06-23"); + expect(parseDateKey("2026-6-23")).toBeNull(); + expect(parseDateKey(null)).toBeNull(); + expect(addDaysToDateKey("2026-06-23", 2)).toBe("2026-06-25"); + expect(addDaysToDateKey("not-a-date", 2)).toBe("not-a-date"); + }); + + it("builds UTC day ranges for Beijing calendar days", () => { + expect(getTimeZoneOffsetMs(new Date("2026-06-23T00:00:00Z"))).toBe(8 * 60 * 60 * 1000); + expect(new Date(getUtcMsForZonedDayStart("2026-06-23")).toISOString()).toBe("2026-06-22T16:00:00.000Z"); + expect(() => getUtcMsForZonedDayStart("20260623")).toThrow("Invalid date key: 20260623"); + + const range = getDayRangeInBeijing("2026-06-23"); + expect(range.start.toISOString()).toBe("2026-06-22T16:00:00.000Z"); + expect(range.endExclusive.toISOString()).toBe("2026-06-23T16:00:00.000Z"); + + expect(buildDateRangeFilterInBeijing({ startDate: "2026-06-23" }).gte?.toISOString()).toBe( + "2026-06-22T16:00:00.000Z", + ); + expect(buildDateRangeFilterInBeijing({ endDate: "2026-06-24" }).lt?.toISOString()).toBe( + "2026-06-24T16:00:00.000Z", + ); + + const today = getTodayRangeInBeijing(sample); + expect(today).toMatchObject({ dateKey: "2026-06-23" }); + expect(today.start.toISOString()).toBe("2026-06-22T16:00:00.000Z"); + }); +}); diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 3cb2477..49babde 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; @@ -189,12 +188,84 @@ 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; + target: ProxyGroupRuleTarget; noResolve?: boolean; } +export type RuleSetBehavior = "domain" | "ipcidr"; + +export type NodeRegion = + | "us" + | "hk" + | "jp" + | "sg" + | "tw" + | "kr" + | "uk" + | "de" + | "fr" + | "ca" + | "au" + | "other"; + +export type ProxyGroupTargetRef = + | { kind: "module"; id: string } + | { kind: "custom"; id: string }; + +export type ProxyGroupRuleTarget = ProxyGroupTargetRef | string; + +export type ProxyGroupMemberRef = + | { kind: "node"; name: string } + | { kind: "module"; id: string } + | { kind: "custom"; id: string } + | { kind: "direct" } + | { kind: "reject" }; + +export const PROXY_GROUP_GROUP_TYPES = [ + "select", + "url-test", + "fallback", + "load-balance", + "direct-first", + "reject-first", +] as const; + +export type ProxyGroupGroupType = (typeof PROXY_GROUP_GROUP_TYPES)[number]; + +export function isProxyGroupGroupType(value: unknown): value is ProxyGroupGroupType { + return typeof value === "string" && (PROXY_GROUP_GROUP_TYPES as readonly string[]).includes(value); +} + +export interface ProxyGroupAdvancedConfig { + sourceIds?: string[]; + regions?: NodeRegion[]; + includeRegex?: string; + excludeRegex?: string; + groupType?: ProxyGroupGroupType; + strategy?: LoadBalanceStrategy; + extraMembers?: ProxyGroupMemberRef[]; + excludedMembers?: ProxyGroupMemberRef[]; + memberOrder?: ProxyGroupMemberRef[]; +} + +export interface CustomRuleSet { + id: string; + name: string; + behavior: RuleSetBehavior; + path: string; + target: ProxyGroupRuleTarget; + noResolve?: boolean; +} + +export type BuiltinRuleEdit = { + target?: ProxyGroupRuleTarget; + enabled?: false; +}; + +export type BuiltinRuleEdits = Record; + /** * 自定义代理组 */ @@ -202,15 +273,13 @@ export interface CustomProxyGroup { id: string; name: string; emoji: string; - groupType: "select" | "url-test" | "fallback" | "load-balance" | "direct-first" | "reject-first"; + enabled?: boolean; + description?: string; + memberSource?: "filtered-nodes"; + includeInGroupMembers?: boolean; + groupType: ProxyGroupGroupType; strategy?: LoadBalanceStrategy; - rules: { - id: string; - name: string; - behavior: "domain" | "ipcidr"; - url: string; - noResolve?: boolean; - }[]; + advanced?: ProxyGroupAdvancedConfig; } /** @@ -226,4 +295,3 @@ export interface TemplateConfig { rules: string[]; dns: Partial; } - diff --git a/packages/core/src/types/filtered-proxy-group.ts b/packages/core/src/types/filtered-proxy-group.ts deleted file mode 100644 index 3ffd69b..0000000 --- a/packages/core/src/types/filtered-proxy-group.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { LoadBalanceStrategy } from "./config"; - -export type NodeRegion = - | "us" - | "hk" - | "jp" - | "sg" - | "tw" - | "kr" - | "uk" - | "de" - | "fr" - | "ca" - | "au" - | "other"; - -export type FilteredProxyGroupType = "select" | "url-test" | "fallback" | "load-balance" | "direct-first" | "reject-first"; - -export interface FilteredProxyGroup { - id: string; - // 仅用于 UI 展示(类似自定义分组的 emoji);不参与筛选逻辑 - emoji?: string; - name: string; - enabled: boolean; - groupType: FilteredProxyGroupType; - strategy?: LoadBalanceStrategy; - sourceIds: string[]; - regions: NodeRegion[]; - includeRegex?: string; - excludeRegex?: string; - // 按“当前展示名”精确排除的节点(仅作用于该筛选组) - excludedNodeNames?: string[]; -} diff --git a/packages/core/src/types/template-config.ts b/packages/core/src/types/template-config.ts index 2b110e6..81b278d 100644 --- a/packages/core/src/types/template-config.ts +++ b/packages/core/src/types/template-config.ts @@ -1,6 +1,13 @@ -import type { ModuleRuleExclusions } from "../generator/module-rules"; -import type { CustomProxyGroup, CustomRule, TemplateType } from "./config"; -import type { FilteredProxyGroup } from "./filtered-proxy-group"; +import type { + BuiltinRuleEdits, + CustomProxyGroup, + CustomRule, + CustomRuleSet, + LoadBalanceStrategy, + ProxyGroupAdvancedConfig, + ProxyGroupGroupType, + TemplateType, +} from "./config"; // 中转代理组(使用 dialer-proxy 语法) export interface DialerProxyGroup { @@ -8,34 +15,23 @@ export interface DialerProxyGroup { enabled?: boolean; // 默认启用;停用后不会写入配置 name: string; // 组名,如 "美国中转" relayNodes: string[]; // 用于中转的节点名称列表 - type: "select" | "url-test"; // 组类型 + type: ProxyGroupGroupType; // 组类型 + strategy?: LoadBalanceStrategy; // 负载均衡策略,仅 type=load-balance 时生效 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; enabledProxyGroups: string[]; hiddenProxyGroups?: string[]; customProxyGroups: CustomProxyGroup[]; - filteredProxyGroups?: FilteredProxyGroup[]; - moduleRuleOverrides?: Record; - moduleRuleExclusions?: ModuleRuleExclusions; + proxyGroupAdvanced?: Record; + proxyGroupAdvancedModeEnabled?: boolean; + customRuleSets: CustomRuleSet[]; + builtinRuleEdits?: BuiltinRuleEdits; customRules: CustomRule[]; ruleOrder?: string[]; - allRulesOrderEditingEnabled?: boolean; cnIpNoResolve?: boolean; experimentalCnUseCnRuleSet?: boolean; dialerProxyGroups: DialerProxyGroup[]; 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" diff --git a/packages/server-core/src/subscription/automatic-refresh-completion.test.ts b/packages/server-core/src/subscription/automatic-refresh-completion.test.ts index 356339d..7a0c9c8 100644 --- a/packages/server-core/src/subscription/automatic-refresh-completion.test.ts +++ b/packages/server-core/src/subscription/automatic-refresh-completion.test.ts @@ -111,6 +111,34 @@ describe("automatic refresh completion helpers", () => { }); }); + it("uses fallback quota limits and rejects unknown failure reasons", () => { + const decision = resolveAutomaticRefreshCompletionDecision({ + target: { ...target, username: null }, + currentAutoUpdateState, + prepared: makePrepared({ + ok: false, + reason: "node_quota_exceeded", + nodeCount: 101, + }), + attemptedAt, + maxNodesPerSubscription: 88, + }); + + expect(decision.outcome).toMatchObject({ + status: "failed", + resultsError: "Subscription sub-1: Node quota exceeded (88)", + }); + expect(() => + resolveAutomaticRefreshCompletionDecision({ + target, + currentAutoUpdateState, + prepared: makePrepared({ ok: false, reason: "unexpected" as never, nodeCount: 0 }), + attemptedAt, + maxNodesPerSubscription: 100, + }) + ).toThrow("Unexpected refresh failure reason: unexpected"); + }); + it("uses the cache timestamp for successful attempts", () => { const decision = resolveAutomaticRefreshCompletionDecision({ target, @@ -159,4 +187,45 @@ describe("automatic refresh completion helpers", () => { expect(completion.outcome.resultsError).toBe("Subscription sub-1 (ry): boom"); } }); + + it("summarizes minimal successful and unexpected failure outputs", () => { + const success = resolveAutomaticRefreshCompletionDecision({ + target: { ...target, username: null, autoUpdateInterval: null }, + currentAutoUpdateState, + prepared: makePrepared({ + ok: true, + cacheEntry: { nodes: [], subscriptionInfo: {}, generatedYaml: "yaml" }, + generatedYaml: "yaml", + nodeCount: undefined as never, + }), + attemptedAt, + maxNodesPerSubscription: 100, + }); + expect(success.outcome).toMatchObject({ + status: "updated", + updatedSubscription: { + username: null, + hosts: ["example.com"], + }, + }); + if (success.outcome.status === "updated") { + expect(success.outcome.updatedSubscription).not.toHaveProperty("nodeCount"); + } + + const unknown = resolveAutomaticRefreshUnexpectedFailureCompletion({ + target: { ...target, username: null }, + requestedHosts: [], + error: "bad", + attemptStartedAt: null, + }); + expect(unknown.message).toBe("Unknown error"); + expect(unknown).not.toHaveProperty("attemptedState"); + expect(unknown.outcome).toMatchObject({ + resultsError: "Subscription sub-1 (-): Unknown error", + failedSubscription: { + username: null, + error: "Unknown error", + }, + }); + }); }); diff --git a/packages/server-core/src/subscription/doh-resolver.test.ts b/packages/server-core/src/subscription/doh-resolver.test.ts new file mode 100644 index 0000000..54993e0 --- /dev/null +++ b/packages/server-core/src/subscription/doh-resolver.test.ts @@ -0,0 +1,157 @@ +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 }; +} + +function header(questionCount: number, answerCount: number): Uint8Array { + const out = new Uint8Array(12); + const view = new DataView(out.buffer); + view.setUint16(4, questionCount); + view.setUint16(6, answerCount); + return out; +} + +function malformedAnswerName(firstNameByte: number): Uint8Array { + const out = new Uint8Array(13); + out.set(header(0, 1)); + out[12] = firstNameByte; + return out; +} + +describe("RFC8484 DoH resolver", () => { + it("posts DNS wire messages and returns A/AAAA addresses from the first usable endpoint", async () => { + const transport = vi + .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([]); + }); + + it("fails closed for blank hostnames, malformed DNS messages, and thrown endpoints", async () => { + const longQuestion = [ + ...Array.from({ length: 129 }, () => [1, 97]).flat(), + 0, + 0, + 1, + 0, + 1, + ]; + const truncatedData = new Uint8Array([ + ...Array.from(header(0, 1)), + 0xc0, + 0x0c, + 0x00, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x3c, + 0x00, + 0x04, + 1, + 2, + ]); + const malformedBodies = [ + new Uint8Array([1, 2, 3]), + header(1, 0), + header(0, 1), + truncatedData, + malformedAnswerName(0xc0), + malformedAnswerName(0x80), + new Uint8Array([...Array.from(header(1, 0)), ...longQuestion]), + ]; + const transport = vi.fn(async () => { + const body = malformedBodies.shift(); + if (body) return ok(body); + throw new Error("endpoint unavailable"); + }); + + await expect(resolveHostnameByDoh(" ", { transport })).resolves.toEqual([]); + await expect( + resolveHostnameByDoh("example.test", { + endpoints: ["https://bad-a.example/dns-query", "https://bad-b.example/dns-query", "https://bad-c.example/dns-query", "https://bad-d.example/dns-query"], + transport, + }) + ).resolves.toEqual([]); + + expect(transport).toHaveBeenCalledTimes(8); + }); +}); diff --git a/packages/server-core/src/subscription/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"; diff --git a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts index bcc1ad5..dd71235 100644 --- a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts +++ b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts @@ -303,6 +303,112 @@ describe("refreshNodeSnapshot", () => { }); }); + it("ignores malformed deleted-node descriptors while preserving identical stable metadata", async () => { + const fetchUrlNodes = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + nodes: [{ ...node, name: "source a", server: "a.example.com" }], + headers: { + "profile-web-page-url": "https://same-profile.example.com/", + "plan-name": "Shared Plan", + }, + }) + .mockResolvedValueOnce({ + ok: true, + nodes: [{ ...node, name: "source b", server: "b.example.com" }], + headers: { + "profile-web-page-url": "https://same-profile.example.com/", + "plan-name": "Shared Plan", + }, + }); + + const result = await refreshNodeSnapshot({ + config: { + deletedNodes: [null, ["bad"], { sourceId: "url-a", name: "Missing" }], + sources: [ + { id: "url-a", type: "url", content: "https://a.example.com/sub" }, + { id: "url-b", type: "url", content: "https://b.example.com/sub" }, + ], + }, + urls: [], + storedNodes: [], + fetchUrlNodes, + }); + + expect(result.subscriptionInfo).toMatchObject({ + profileWebPageUrl: "https://same-profile.example.com/", + planName: "Shared Plan", + }); + expect(result.usedUrlFetch).toBe(true); + }); + + it("skips provider supplemental fetches when no userinfo metadata is configured", async () => { + const fetchUrlUserInfo = vi.fn(); + + const result = await refreshNodeSnapshot({ + config: { + sources: [ + { + id: "provider", + type: "url", + content: "https://provider.example.com/sub", + useProxyProviders: true, + }, + ], + }, + urls: [], + storedNodes: [ + { + ...node, + name: "provider node", + [SOURCE_IDS_KEY]: ["provider"], + } as ParsedNode, + ], + fetchUrlNodes: vi.fn(), + fetchUrlUserInfo, + }); + + expect(fetchUrlUserInfo).not.toHaveBeenCalled(); + expect(result.detachedSourceCount).toBe(1); + expect(result.refreshedSourceCount).toBe(1); + }); + + it("merges supplemental stable metadata even when supplemental userinfo is absent", async () => { + const fetchUrlUserInfo = vi.fn(async () => ({ + "profile-web-page-url": "https://supplemental.example.com/", + "plan-name": "Supplemental Plan", + })); + + const result = await refreshNodeSnapshot({ + config: { + sources: [ + { + id: "url", + type: "url", + content: "https://url.example.com/sub", + userinfoUrl: "https://url.example.com/userinfo", + }, + ], + }, + urls: [], + storedNodes: [], + fetchUrlNodes: vi.fn(async () => ({ + ok: true, + nodes: [{ ...node, name: "url node", server: "url.example.com" }], + headers: {}, + })), + fetchUrlUserInfo, + }); + + expect(fetchUrlUserInfo).toHaveBeenCalledTimes(1); + expect(result.subscriptionInfo).toMatchObject({ + profileWebPageUrl: "https://supplemental.example.com/", + planName: "Supplemental Plan", + }); + expect(result.savedSources[0]).not.toHaveProperty("subscriptionUserInfo"); + }); + it("merges URL userinfo from nodes while suppressing conflicted stable metadata", async () => { const fetchUrlNodes = vi .fn() diff --git a/packages/server-core/src/subscription/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/server-core/src/subscription/ssrf-ip.test.ts b/packages/server-core/src/subscription/ssrf-ip.test.ts index 6fd3361..fb9b2a5 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 { 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", () => { @@ -47,4 +53,33 @@ 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); + }); + + it("normalizes resolved addresses before validation", () => { + expect(normalizeResolvedIpAddresses([" 198.18.3.6 ", "", " ", 123 as never, "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 6b9ae94..03e7c14 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,32 @@ 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; +} + +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; +} diff --git a/packages/ui/src/product/converter/advanced-mode/root.test.ts b/packages/ui/src/product/converter/advanced-mode/root.test.ts index 0befaa0..67d4379 100644 --- a/packages/ui/src/product/converter/advanced-mode/root.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/root.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const captures = vi.hoisted(() => ({ + initialSections: undefined as Set | undefined, stateSetter: vi.fn(), lastSections: undefined as Set | undefined, sections: {} as Record, @@ -13,7 +14,7 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useState: (initial: unknown) => { - const value = typeof initial === "function" ? (initial as () => unknown)() : initial; + const value = captures.initialSections ?? (typeof initial === "function" ? (initial as () => unknown)() : initial); captures.stateSetter = vi.fn((updater: unknown) => { captures.lastSections = typeof updater === "function" @@ -40,7 +41,6 @@ vi.mock("@subboost/ui/store/config-store", () => ({ vi.mock("./sections/input-section", () => ({ InputSection: section("input") })); vi.mock("./sections/node-management-section", () => ({ NodeManagementSection: section("filter") })); -vi.mock("./sections/filtered-proxy-groups-section", () => ({ FilteredProxyGroupsSection: section("filtered") })); vi.mock("./sections/dialer-proxy-groups-section", () => ({ DialerProxyGroupsSection: section("chain") })); vi.mock("./sections/proxy-groups-section", () => ({ ProxyGroupsSection: section("proxy") })); vi.mock("./sections/rules-management-section", () => ({ RulesManagementSection: section("rules") })); @@ -51,6 +51,7 @@ import { AdvancedMode } from "./root"; describe("AdvancedMode", () => { beforeEach(() => { vi.clearAllMocks(); + captures.initialSections = undefined; captures.sections = {}; captures.lastSections = undefined; }); @@ -63,7 +64,6 @@ describe("AdvancedMode", () => { "chain", "dns", "filter", - "filtered", "input", "proxy", "rules", @@ -82,4 +82,15 @@ describe("AdvancedMode", () => { expect(captures.lastSections?.has("input")).toBe(false); expect(captures.lastSections?.has("dns")).toBe(true); }); + + it("expands a collapsed section through the same toggle callback", () => { + captures.initialSections = new Set(["dns"]); + renderToStaticMarkup(React.createElement(AdvancedMode)); + + expect(captures.sections.input.isExpanded).toBe(false); + captures.sections.input.onToggle(); + + expect(captures.lastSections?.has("input")).toBe(true); + expect(captures.lastSections?.has("dns")).toBe(true); + }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/root.tsx b/packages/ui/src/product/converter/advanced-mode/root.tsx index 471f14d..a74cadf 100644 --- a/packages/ui/src/product/converter/advanced-mode/root.tsx +++ b/packages/ui/src/product/converter/advanced-mode/root.tsx @@ -3,17 +3,16 @@ import * as React from "react"; import { DialerProxyGroupsSection } from "./sections/dialer-proxy-groups-section"; import { DnsSection } from "./sections/dns-section"; -import { FilteredProxyGroupsSection } from "./sections/filtered-proxy-groups-section"; import { InputSection } from "./sections/input-section"; import { NodeManagementSection } from "./sections/node-management-section"; import { ProxyGroupsSection } from "./sections/proxy-groups-section"; import { RulesManagementSection } from "./sections/rules-management-section"; -type SectionKey = "input" | "filter" | "filtered" | "chain" | "proxy" | "rules" | "dns"; +type SectionKey = "input" | "filter" | "chain" | "proxy" | "rules" | "dns"; export function AdvancedMode() { const [expandedSections, setExpandedSections] = React.useState>( - new Set(["input", "filter", "filtered", "chain", "proxy", "rules", "dns"]) + new Set(["input", "filter", "chain", "proxy", "rules", "dns"]) ); const toggleSection = (section: SectionKey) => { @@ -32,10 +31,6 @@ export function AdvancedMode() { isExpanded={expandedSections.has("filter")} onToggle={() => toggleSection("filter")} /> - toggleSection("filtered")} - /> toggleSection("chain")} /> toggleSection("proxy")} /> ({ Plus: () => null, Search: () => null, Shuffle: () => null, + SlidersHorizontal: () => null, Trash2: () => null, X: () => null, })); @@ -76,6 +77,24 @@ vi.mock("@subboost/ui/components/ui/button", () => ({ return null; }, })); +vi.mock("@subboost/ui/components/ui/dropdown-menu", () => ({ + DropdownMenu: (props: any) => props.children, + DropdownMenuTrigger: (props: any) => props.children, + DropdownMenuContent: (props: any) => { + mocks.captures.dropdownContents.push(props); + return props.children; + }, + DropdownMenuItem: (props: any) => { + mocks.captures.menuItems.push(props); + return props.children; + }, + DropdownMenuSub: (props: any) => props.children, + DropdownMenuSubContent: (props: any) => props.children, + DropdownMenuSubTrigger: (props: any) => { + mocks.captures.menuItems.push(props); + return props.children; + }, +})); vi.mock("@subboost/ui/components/ui/input", () => ({ Input: (props: any) => { mocks.captures.inputs.push(props); @@ -152,6 +171,8 @@ function renderSection(overrides: Record = {}, props = { isExpa mocks.captures.buttons = []; mocks.captures.inputs = []; mocks.captures.switches = []; + mocks.captures.menuItems = []; + mocks.captures.dropdownContents = []; mocks.captures.intrinsics = []; try { const html = renderToStaticMarkup(React.createElement(DialerProxyGroupsSection, props)); @@ -183,15 +204,10 @@ function findIntrinsics(type: string, predicate: (props: any) => boolean) { describe("DialerProxyGroupsSection", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.captures = { buttons: [], inputs: [], switches: [], intrinsics: [] }; + mocks.captures = { buttons: [], dropdownContents: [], inputs: [], menuItems: [], switches: [], intrinsics: [] }; mocks.store = { nodes, dialerProxyGroups: [groupA, groupB], - filteredProxyGroups: [ - { name: "Filtered A", enabled: true }, - { name: "Filtered Disabled", enabled: false }, - { name: " ", enabled: true }, - ], customProxyGroups: [{ name: "Custom" }], proxyGroupNameOverrides: { auto: "Auto Override" }, addDialerProxyGroup: vi.fn(), @@ -256,12 +272,6 @@ describe("DialerProxyGroupsSection", () => { { name: "" }, { name: 123 }, ]; - mocks.store.filteredProxyGroups = [ - null, - { name: 123, enabled: true }, - { name: " ", enabled: true }, - { name: "Filtered A", enabled: true }, - ]; mocks.store.dialerProxyGroups = [ { ...groupA, name: " " }, { ...groupB, name: 42 }, @@ -334,6 +344,21 @@ describe("DialerProxyGroupsSection", () => { expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { enabled: false }); mocks.captures.switches[0].onClick({ stopPropagation: vi.fn() }); + const groupTypeButton = mocks.captures.buttons.find((props: any) => props["aria-label"] === "修改 Group A 类型"); + expect(groupTypeButton).toEqual(expect.objectContaining({ title: "类型:手动选择" })); + groupTypeButton.onClick({ stopPropagation: vi.fn() }); + const autoTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("自动测速")); + autoTypeItem.onSelect(); + expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "url-test", strategy: undefined }); + + const fallbackTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("故障切换")); + fallbackTypeItem.onSelect(); + expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "fallback", strategy: undefined }); + + const roundRobinTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("轮询均摊")); + roundRobinTypeItem.onSelect(); + expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "load-balance", strategy: "round-robin" }); + mocks.captures.switches[1].onCheckedChange(true); expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-b", { enabled: true, @@ -354,7 +379,7 @@ describe("DialerProxyGroupsSection", () => { it("enables disabled groups without conflicts and with relay-only cleanup", () => { mocks.store.dialerProxyGroups = [ - { ...groupA, relayNodes: ["Filtered A"], targetNodes: ["Alpha"] }, + { ...groupA, relayNodes: ["Custom"], targetNodes: ["Alpha"] }, { ...groupB, enabled: false, relayNodes: ["DIRECT", "Gamma"], targetNodes: ["Beta"] }, ]; renderSection(); @@ -397,7 +422,7 @@ describe("DialerProxyGroupsSection", () => { const relayInput = mocks.captures.inputs.find((props: any) => props.placeholder === "搜索中转节点..."); const targetInput = mocks.captures.inputs.find((props: any) => props.placeholder === "搜索落地节点..."); - relayInput.onChange({ target: { value: "filtered" } }); + relayInput.onChange({ target: { value: "custom" } }); targetInput.onChange({ target: { value: "beta" } }); relayInput.onClick({ stopPropagation: vi.fn() }); targetInput.onClick({ stopPropagation: vi.fn() }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx index be2df04..1e74f58 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx @@ -1,12 +1,13 @@ "use client"; import * as React from "react"; -import { Check, ChevronDown, ChevronRight, Link as LinkIcon, Pencil, Plus, Search, Trash2, X } from "lucide-react"; +import { Check, ChevronDown, ChevronRight, Link as LinkIcon, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; import { Badge } from "@subboost/ui/components/ui/badge"; import { Button } from "@subboost/ui/components/ui/button"; import { Input } from "@subboost/ui/components/ui/input"; import { Switch } from "@subboost/ui/components/ui/switch"; import { toast } from "@subboost/ui/components/ui/toaster"; +import { DEFAULT_LOAD_BALANCE_STRATEGY, type ProxyGroupGroupType } from "@subboost/core/types/config"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; import { cn } from "@subboost/ui/lib/utils"; @@ -20,6 +21,11 @@ import { type ProxyGroupNameDraft, } from "./proxy-group-name-editor"; import { ProxyGroupSummary } from "./proxy-group-summary"; +import { + getLoadBalanceStrategyLabel, + getProxyGroupTypeLabel, + ProxyGroupTypeMenu, +} from "./proxy-group-type-menu"; type DialerSelectableNode = { name: string; @@ -39,7 +45,6 @@ export function DialerProxyGroupsSection({ const { nodes, dialerProxyGroups, - filteredProxyGroups, customProxyGroups, proxyGroupNameOverrides, addDialerProxyGroup, @@ -89,16 +94,12 @@ export function DialerProxyGroupsSection({ const name = typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); } - for (const g of filteredProxyGroups) { - const name = g && typeof g.name === "string" ? g.name.trim() : ""; - if (name) names.push(name); - } for (const g of dialerProxyGroups) { const name = g && typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); } return names; - }, [customProxyGroups, dialerProxyGroups, filteredProxyGroups, resolveModuleFullName]); + }, [customProxyGroups, dialerProxyGroups, resolveModuleFullName]); const handleAddDialerGroup = (name: string) => { const nextName = name.trim(); @@ -139,14 +140,27 @@ export function DialerProxyGroupsSection({ type: n.type, })) as DialerSelectableNode[]; - const availableFilteredGroups = filteredProxyGroups - .filter((g) => g && g.enabled && typeof g.name === "string" && g.name.trim()) - .map((g) => ({ name: g.name.trim(), type: "筛选组" } as DialerSelectableNode)); + const availableProxyGroups = [ + ...PROXY_GROUP_MODULES.map( + (module) => + ({ + name: resolveModuleFullName(module), + type: "内置组", + }) as DialerSelectableNode, + ), + ...customProxyGroups + .filter((group) => group.enabled !== false) + .map((group) => ({ + name: typeof group.name === "string" ? group.name.trim() : "", + type: "自定义组", + })) + .filter((group) => group.name), + ]; // 中转组允许选择 DIRECT(直连)作为“入口” // 注意:这里只用于 dialer-proxy 的代理组 proxies 字段,Clash/Mihomo 支持 DIRECT。 // excludeGroupId 用于在该组停用时仍保留“自身 targetNodes 不可作为中转节点”的约束 - return [DIRECT_RELAY_OPTION, ...availableFilteredGroups, ...available]; + return [DIRECT_RELAY_OPTION, ...available, ...availableProxyGroups]; }; return ( @@ -177,6 +191,12 @@ export function DialerProxyGroupsSection({ {dialerProxyGroups.map((group) => { const isEnabled = group.enabled !== false; const isEditing = editingDialerGroupId === group.id; + const dialerGroupType: ProxyGroupGroupType = group.type; + const dialerStrategy = group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY; + const dialerTypeLabel = + dialerGroupType === "load-balance" + ? `${getProxyGroupTypeLabel(dialerGroupType)} / ${getLoadBalanceStrategyLabel(dialerStrategy)}` + : getProxyGroupTypeLabel(dialerGroupType); const relaySearchKeyword = (relaySearchByGroupId[group.id] ?? "").trim().toLowerCase(); const targetSearchKeyword = (targetSearchByGroupId[group.id] ?? "").trim().toLowerCase(); const availableRelayNodes = getAvailableRelayNodes(group.id); @@ -305,7 +325,7 @@ export function DialerProxyGroupsSection({ ); const nextRelayNodes = group.relayNodes.filter((n) => { if (n === "DIRECT") return true; - if (!nodeNameSet.has(n)) return true; // 筛选组等 + if (!nodeNameSet.has(n)) return true; // 代理组等 return !otherTargets.has(n); }); @@ -333,6 +353,32 @@ export function DialerProxyGroupsSection({ }} onClick={(e) => e.stopPropagation()} /> + + updateDialerProxyGroup(group.id, { + type: groupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }) + } + trigger={ + + } + /> - - 0 ? `${selectedSourceCount} 源` : "全部源", tone: "info" }, - { - label: group.regions.length > 0 ? `${group.regions.length} 地区` : "全部地区", - tone: "accent", - }, - { label: `${matchedNodes.length} 节点`, tone: "success" }, - ]} - /> - - )} - - {isEditing ? ( -
e.stopPropagation()}> - - -
- ) : ( -
e.stopPropagation()} - > - { - const nextEnabled = Boolean(checked); - if (!nextEnabled) { - updateFilteredProxyGroup(group.id, { enabled: false }); - return; - } - const unique = makeUniqueFilteredGroupName(group.name, group.id); - updateFilteredProxyGroup(group.id, { enabled: true, name: unique }); - if (unique !== group.name) { - toast({ - title: "筛选组名称已自动调整以避免重复", - description: `已改为:${unique}`, - variant: "warning", - }); - } - }} - /> - -
- )} - - - {expandedFilteredGroups.has(group.id) && ( -
-
-
-
导入源
-
- {sources.map((s, index) => { - const sourceLabel = buildSourceDisplayLabel({ - typeLabel: sourceTypeInfo[s.type]?.label ?? s.type, - tag: s.tag, - order: index + 1, - total: sources.length, - orderPlacement: "prefix", - }); - const checked = sourceIdSet.has(s.id); - - return ( - - ); - })} -
-
不选择表示匹配所有导入源
-
- -
-
地区
-
- {REGION_PRESETS.map((region) => { - const selected = group.regions.includes(region.id); - return ( - - ); - })} -
-
- 不选择表示匹配所有地区 -
-
- -
-
-
包含正则(可选)
- - updateFilteredProxyGroup(group.id, { includeRegex: e.target.value }) - } - placeholder="例如: (IEPL|专线|家宽)" - className="h-8 text-xs bg-white/10" - /> -
-
-
排除正则(可选)
- - updateFilteredProxyGroup(group.id, { excludeRegex: e.target.value }) - } - placeholder="例如: (测试|过期)" - className="h-8 text-xs bg-white/10" - /> -
-
-
类型
- - updateFilteredProxyGroup(group.id, { - groupType, - ...(groupType === "load-balance" - ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : { strategy: undefined }), - }) - } - triggerClassName="h-8 text-xs bg-white/10 border-white/10" - /> -
-
-
- - {manualRules.length > 0 && ( -
-
- - 手动添加规则 - - - 已添加 {manualRules.length} - -
-
- {manualRules.map((item) => ( - removeCustomRule(index)} - /> - ))} -
-
- )} - -
-
-
命中节点预览
-
- {matchedNodes.length} 个 -
-
- {matchedNodes.length === 0 ? ( -
暂无命中节点
- ) : ( -
-
- {matchedNodes.map((name) => ( - - {name} - - - ))} -
-
- )} - {excludedNodeNames.length > 0 && ( -
-
-
已排除节点(可恢复)
- -
-
-
- {excludedNodeNames.map((name) => ( - - {name} - - - ))} -
-
-
- )} -
-
- )} - - ); - })} - - )} - - - - )} - - ); -} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx b/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx index fe3e94e..9903d9d 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/input-source-editor-dialog.tsx @@ -116,7 +116,7 @@ export function InputSourceEditorDialog({
注意开启后:
  • 无法在预览中查看/管理该 url 的节点
  • -
  • 无法将这些节点用于中转代理组、筛选代理组等高级功能
  • +
  • 无法将这些节点用于中转代理组、分流组高级模式等高级功能
  • 节点命名模板与 tag 在该模式下不生效
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts new file mode 100644 index 0000000..14983fe --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts @@ -0,0 +1,218 @@ +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +const mocks = vi.hoisted(() => ({ + draggingKey: null as string | null, + stateSetters: [] as Array>, + store: {} as Record, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useState: (initial: unknown) => { + const value = initial === null ? mocks.draggingKey : initial; + const setter = vi.fn(); + mocks.stateSetters.push(setter); + return [value, setter]; + }, + }; +}); + +vi.mock("lucide-react", () => ({ + Plus: () => React.createElement("span", null, "plus-icon"), + X: () => React.createElement("span", null, "x-icon"), +})); + +vi.mock("@subboost/ui/components/ui/badge", () => ({ + Badge: (props: any) => React.createElement("span", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/button", () => ({ + Button: (props: any) => React.createElement("button", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: any) => React.createElement("input", props), +})); + +vi.mock("@subboost/ui/lib/utils", () => ({ + cn: (...parts: unknown[]) => parts.filter(Boolean).join(" "), +})); + +vi.mock("@subboost/core/generator/proxy-groups", () => ({ + PROXY_GROUP_MODULES: [ + { id: "select", name: "Select" }, + { id: "auto", name: "Auto" }, + ], + generateProxyGroups: () => [ + { + name: "Media", + proxies: ["DIRECT", "US Source", "Japan Source"], + }, + ], +})); + +vi.mock("@subboost/core/proxy-group-name", () => ({ + resolveProxyGroupModuleName: (module: { id: string; name: string }, override?: string) => override || module.name, +})); + +vi.mock("@subboost/ui/store/config-store", () => ({ + useConfigStore: () => mocks.store, +})); + +import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; + +function node(name: string): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + } as ParsedNode; +} + +function flattenElements(value: React.ReactNode): React.ReactElement[] { + const out: React.ReactElement[] = []; + const visit = (item: React.ReactNode): void => { + if (Array.isArray(item)) { + item.forEach(visit); + return; + } + if (!React.isValidElement(item)) return; + if (typeof item.type === "function") { + visit((item.type as (props: unknown) => React.ReactNode)(item.props)); + return; + } + out.push(item); + visit((item.props as { children?: React.ReactNode }).children); + }; + visit(value); + return out; +} + +describe("ProxyGroupAdvancedPanel interactions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.draggingKey = "node:US Source"; + mocks.stateSetters = []; + mocks.store = { + nodes: [ + withNodeSourceId(node("US Source"), "source-a"), + withNodeSourceId(node("Japan Source"), "source-b"), + node("Extra Node"), + ], + sources: [ + { id: "source-a", type: "url", tag: " Primary " }, + { id: "source-b", type: "yaml", lastParsedTag: " YAML Feed " }, + ], + enabledProxyGroups: ["select", "auto"], + customProxyGroups: [ + { id: "media", name: "Media", emoji: "", groupType: "select" }, + { id: "other", name: "Other", emoji: "", groupType: "select" }, + ], + customRuleSets: [], + proxyGroupAdvanced: {}, + builtinRuleEdits: {}, + proxyGroupNameOverrides: { auto: "Auto" }, + testUrl: "https://probe.example/204", + testInterval: 300, + ruleProviderBaseUrl: "https://rules.example", + }; + }); + + it("fires native source, region, member, and drag callbacks", () => { + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + sourceIds: ["source-a"], + regions: ["us"], + includeRegex: "Source", + excludeRegex: "Japan", + excludedMembers: [{ kind: "reject" }], + }, + onChange, + rulesCount: 1, + rulesContent: React.createElement("div", null, "rules"), + }); + const elements = flattenElements(tree); + const sourceCheckboxes = elements.filter((element) => element.type === "input" && element.props.type === "checkbox"); + const textInputs = elements.filter((element) => element.type === "input" && element.props.type !== "checkbox"); + const regionButtons = elements.filter( + (element) => element.type === "button" && String(element.props.className || "").includes("rounded border px-2"), + ); + const includedRows = elements.filter((element) => element.props.draggable); + const excludeButton = elements.find((element) => element.type === "button" && element.props.title === "排除"); + const enableButton = elements.find((element) => element.type === "button" && element.props.title === "REJECT"); + + sourceCheckboxes[1].props.onChange(); + regionButtons[1].props.onClick(); + textInputs[0].props.onChange({ target: { value: "IEPL" } }); + textInputs[1].props.onChange({ target: { value: "Test" } }); + includedRows[0].props.onDragStart(); + includedRows.at(-1)?.props.onDragOver({ preventDefault: vi.fn() }); + includedRows.at(-1)?.props.onDrop(); + includedRows.at(-1)?.props.onDragEnd(); + excludeButton?.props.onClick(); + enableButton?.props.onClick(); + + expect(onChange).toHaveBeenCalledWith({ sourceIds: ["source-a", "source-b"] }); + expect(onChange).toHaveBeenCalledWith({ regions: ["us", "hk"] }); + expect(onChange).toHaveBeenCalledWith({ includeRegex: "IEPL" }); + expect(onChange).toHaveBeenCalledWith({ excludeRegex: "Test" }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ memberOrder: expect.any(Array) })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + excludedMembers: expect.arrayContaining([expect.objectContaining({ kind: "direct" })]), + }), + ); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + extraMembers: expect.arrayContaining([expect.objectContaining({ kind: "reject" })]), + }), + ); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith("direct:DIRECT"); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); + }); + + it("ignores member drops without a real move target", () => { + const onChange = vi.fn(); + mocks.draggingKey = null; + let tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: {}, + onChange, + rulesCount: 0, + rulesContent: null, + }); + let includedRows = flattenElements(tree).filter((element) => element.props.draggable); + + includedRows[0].props.onDrop(); + expect(onChange).not.toHaveBeenCalled(); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); + + vi.clearAllMocks(); + mocks.stateSetters = []; + mocks.draggingKey = "direct:DIRECT"; + tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: {}, + onChange, + rulesCount: 0, + rulesContent: null, + }); + includedRows = flattenElements(tree).filter((element) => element.props.draggable); + + includedRows[0].props.onDrop(); + expect(onChange).not.toHaveBeenCalled(); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts new file mode 100644 index 0000000..7a31443 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts @@ -0,0 +1,260 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withNodeSourceId } from "@subboost/core/subscription/node-source-state"; +import type { ParsedNode } from "@subboost/core/types/node"; + +const mocks = vi.hoisted(() => ({ + buttons: [] as any[], + inputs: [] as any[], + store: {} as Record, +})); + +vi.mock("lucide-react", () => ({ + Plus: () => React.createElement("span", null, "plus-icon"), + X: () => React.createElement("span", null, "x-icon"), +})); + +vi.mock("@subboost/ui/components/ui/badge", () => ({ + Badge: (props: any) => React.createElement("span", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/button", () => ({ + Button: (props: any) => { + mocks.buttons.push(props); + return React.createElement("button", props, props.children); + }, +})); + +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: any) => { + mocks.inputs.push(props); + return React.createElement("input", props); + }, +})); + +vi.mock("@subboost/ui/lib/utils", () => ({ + cn: (...parts: unknown[]) => parts.filter(Boolean).join(" "), +})); + +vi.mock("@subboost/ui/store/config-store", () => ({ + useConfigStore: () => mocks.store, +})); + +import { + buildMemberFromName, + insertMemberAfterProtected, + memberKindLabel, + memberLabel, + normalizeList, + ProxyGroupAdvancedPanel, + toggleValue, + withMember, + withoutMember, + type ResolvedMember, +} from "./proxy-group-advanced-panel"; + +function node(name: string): ParsedNode { + return { + name, + type: "ss", + server: `${name.toLowerCase().replace(/\s+/g, "-")}.example.com`, + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + } as ParsedNode; +} + +describe("ProxyGroupAdvancedPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.buttons = []; + mocks.inputs = []; + mocks.store = { + nodes: [ + withNodeSourceId(node("US Source"), "source-a"), + withNodeSourceId(node("Japan Source"), "source-b"), + node("剩余流量:100GB"), + ], + sources: [ + { id: "source-a", type: "url", tag: " Primary " }, + { id: "source-b", type: "yaml", lastParsedTag: " YAML Feed " }, + { id: "unused", type: "nodes" }, + ], + enabledProxyGroups: ["auto", "select"], + customProxyGroups: [ + { id: "media", name: "Media", emoji: "", groupType: "select" }, + { id: "disabled", name: "Disabled", emoji: "", groupType: "select", enabled: false }, + ], + customRuleSets: [], + proxyGroupAdvanced: {}, + builtinRuleEdits: {}, + proxyGroupNameOverrides: { auto: "Auto" }, + testUrl: "https://cp.example", + testInterval: 300, + ruleProviderBaseUrl: "https://rules.example", + }; + }); + + it("renders source, region, enabled, excluded, and rules sections", () => { + const onChange = vi.fn(); + const html = renderToStaticMarkup( + React.createElement(ProxyGroupAdvancedPanel, { + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + sourceIds: ["source-a"], + regions: ["us"], + includeRegex: "Source", + excludeRegex: "Japan", + extraMembers: [{ kind: "direct" }], + }, + onChange, + rulesCount: 0, + rulesContent: React.createElement("div", null, "rules-content"), + }), + ); + + expect(html).toContain("导入源"); + expect(html).toContain("Primary"); + expect(html).toContain("YAML Feed"); + expect(html).not.toContain("剩余流量"); + expect(html).toContain("US Source"); + expect(html).toContain("DIRECT"); + expect(html).toContain("rules-content"); + expect(html).toContain("还没有分流规则"); + expect(mocks.inputs.map((input) => input.value)).toEqual(["Source", "Japan"]); + + mocks.inputs[0].onChange({ target: { value: "IEPL" } }); + const excludeButton = mocks.buttons.find((button) => button.title === "排除"); + excludeButton.onClick(); + + expect(onChange).toHaveBeenCalledWith({ includeRegex: "IEPL" }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + excludedMembers: [expect.objectContaining({ kind: expect.any(String) })], + }), + ); + }); + + it("renders empty states without generated members or source matches", () => { + mocks.store = { + ...mocks.store, + nodes: [], + sources: [], + enabledProxyGroups: [], + customProxyGroups: [], + proxyGroupAdvanced: {}, + proxyGroupNameOverrides: undefined, + }; + + const html = renderToStaticMarkup( + React.createElement(ProxyGroupAdvancedPanel, { + target: { kind: "module", id: "auto", name: "Auto" }, + advanced: {}, + onChange: vi.fn(), + rulesCount: 2, + rulesContent: React.createElement("div", null, "existing-rules"), + }), + ); + + expect(html).toContain("暂无可匹配的导入源"); + expect(html).toContain("暂无已启用的节点或代理组"); + expect(html).toContain("DIRECT"); + expect(html).toContain("REJECT"); + expect(html).toContain("existing-rules"); + expect(html).not.toContain("还没有分流规则"); + }); + + it("renders generated source fallback labels when source tags are absent", () => { + mocks.store = { + ...mocks.store, + nodes: [ + withNodeSourceId(node("URL Node"), "url-source"), + withNodeSourceId(node("YAML Node"), "yaml-source"), + withNodeSourceId(node("Nodes Node"), "nodes-source"), + ], + sources: [ + { id: "url-source", type: "url" }, + { id: "yaml-source", type: "yaml" }, + { id: "nodes-source", type: "nodes" }, + ], + enabledProxyGroups: ["auto"], + customProxyGroups: [], + }; + + const html = renderToStaticMarkup( + React.createElement(ProxyGroupAdvancedPanel, { + target: { kind: "module", id: "auto", name: "Missing Generated Group" }, + advanced: {}, + onChange: vi.fn(), + rulesCount: 0, + rulesContent: React.createElement("div", null, "rules-content"), + }), + ); + + expect(html).toContain("#1 订阅链接"); + expect(html).toContain("#2 YAML 配置"); + expect(html).toContain("#3 节点链接"); + expect(html).toContain("暂无已启用的节点或代理组"); + }); + + it("normalizes advanced member helpers without rendering the panel", () => { + const nodes = [node("US Source")]; + const moduleNames = { auto: "Auto", select: "Select" }; + const customProxyGroups = [{ id: "media", name: "Media", emoji: "", groupType: "select" as const }]; + const options = { nodes, moduleNames, customProxyGroups }; + + const direct = buildMemberFromName("DIRECT", options); + const reject = buildMemberFromName("REJECT", options); + const nodeMember = buildMemberFromName("US Source", options); + const moduleMember = buildMemberFromName("Auto", options); + const customMember = buildMemberFromName("Media", options); + + expect(buildMemberFromName(" ", options)).toBeNull(); + expect(buildMemberFromName("Missing", options)).toBeNull(); + expect(direct).toMatchObject({ key: "direct:DIRECT", kind: "direct", name: "DIRECT" }); + expect(reject).toMatchObject({ key: "reject:REJECT", kind: "reject", name: "REJECT" }); + expect(nodeMember).toMatchObject({ key: "node:US Source", kind: "node", name: "US Source" }); + expect(moduleMember).toMatchObject({ key: "module:auto", kind: "module", name: "Auto" }); + expect(customMember).toMatchObject({ key: "custom:media", kind: "custom", name: "Media" }); + expect(memberLabel(direct as ResolvedMember)).toBe("DIRECT"); + expect(memberLabel(customMember as ResolvedMember)).toBe("Media"); + expect(memberKindLabel(nodeMember as ResolvedMember)).toBe("节点"); + expect(memberKindLabel(moduleMember as ResolvedMember)).toBe("内置组"); + expect(memberKindLabel(customMember as ResolvedMember)).toBe("自定义组"); + expect(memberKindLabel(direct as ResolvedMember)).toBe("直连"); + expect(memberKindLabel(reject as ResolvedMember)).toBe("拒绝"); + }); + + it("updates list and member order helpers predictably", () => { + const existing = [ + { kind: "direct" as const }, + { kind: "module" as const, id: "select" }, + { kind: "node" as const, name: "Old" }, + ]; + const resolved = existing.map((ref) => ({ + key: ref.kind === "direct" ? "direct:DIRECT" : ref.kind === "module" ? `module:${ref.id}` : `node:${ref.name}`, + kind: ref.kind, + name: ref.kind === "direct" ? "DIRECT" : ref.kind === "module" ? "Select" : ref.name, + ref, + })) as ResolvedMember[]; + + expect(normalizeList(undefined)).toEqual([]); + expect(normalizeList(["a"])).toEqual(["a"]); + expect(toggleValue(undefined, "us")).toEqual(["us"]); + expect(toggleValue(["us", "jp"], "us")).toEqual(["jp"]); + expect(withoutMember(existing, "node:Old")).toEqual([{ kind: "direct" }, { kind: "module", id: "select" }]); + expect(withMember(existing, { kind: "node", name: "Old" })).toEqual(existing); + expect(withMember(existing, { kind: "custom", id: "media" })).toEqual([ + ...existing, + { kind: "custom", id: "media" }, + ]); + expect(insertMemberAfterProtected(resolved, { kind: "custom", id: "media" })).toEqual([ + { kind: "direct" }, + { kind: "module", id: "select" }, + { kind: "custom", id: "media" }, + { kind: "node", name: "Old" }, + ]); + expect(insertMemberAfterProtected(resolved, { kind: "node", name: "Old" })).toEqual(existing); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx new file mode 100644 index 0000000..16dbb10 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx @@ -0,0 +1,494 @@ +"use client"; + +import * as React from "react"; +import { Plus, X } from "lucide-react"; +import { Badge } from "@subboost/ui/components/ui/badge"; +import { Button } from "@subboost/ui/components/ui/button"; +import { Input } from "@subboost/ui/components/ui/input"; +import { cn } from "@subboost/ui/lib/utils"; +import { PROXY_GROUP_MODULES, generateProxyGroups } from "@subboost/core/generator/proxy-groups"; +import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { REGION_PRESETS } from "@subboost/core/proxy-group-advanced"; +import { getProxyGroupMemberKey } from "@subboost/core/proxy-group-targets"; +import { getNodeSourceIds } from "@subboost/core/subscription/node-source-state"; +import { isSubscriptionInfoNodeName } from "@subboost/core/subscription/info-node-name"; +import type { + CustomProxyGroup, + NodeRegion, + ProxyGroupAdvancedConfig, + ProxyGroupMemberRef, +} from "@subboost/core/types/config"; +import type { ParsedNode } from "@subboost/core/types/node"; +import { useConfigStore } from "@subboost/ui/store/config-store"; + +type AdvancedTarget = { + kind: "module" | "custom"; + id: string; + name: string; +}; + +export type ResolvedMember = { + key: string; + ref: ProxyGroupMemberRef; + name: string; + kind: ProxyGroupMemberRef["kind"]; +}; + +export function normalizeList(value: readonly T[] | undefined): T[] { + return Array.isArray(value) ? [...value] : []; +} + +export function memberLabel(member: ResolvedMember): string { + if (member.kind === "direct") return "DIRECT"; + if (member.kind === "reject") return "REJECT"; + return member.name; +} + +export function memberKindLabel(member: ResolvedMember): string { + switch (member.kind) { + case "node": + return "节点"; + case "module": + return "内置组"; + case "custom": + return "自定义组"; + case "direct": + return "直连"; + case "reject": + return "拒绝"; + } +} + +export function buildMemberFromName( + name: string, + options: { + nodes: ParsedNode[]; + moduleNames: Record; + customProxyGroups: CustomProxyGroup[]; + }, +): ResolvedMember | null { + const trimmed = name.trim(); + if (!trimmed) return null; + + let ref: ProxyGroupMemberRef | null = null; + if (trimmed === "DIRECT") ref = { kind: "direct" }; + else if (trimmed === "REJECT") ref = { kind: "reject" }; + else if (options.nodes.some((node) => node.name === trimmed)) ref = { kind: "node", name: trimmed }; + else { + const moduleEntry = Object.entries(options.moduleNames).find(([, moduleName]) => moduleName === trimmed); + const customEntry = options.customProxyGroups.find((group) => group.name === trimmed); + if (moduleEntry) ref = { kind: "module", id: moduleEntry[0] }; + else if (customEntry) ref = { kind: "custom", id: customEntry.id }; + } + + if (!ref) return null; + return { + key: getProxyGroupMemberKey(ref), + ref, + name: trimmed, + kind: ref.kind, + }; +} + +export function toggleValue(list: readonly T[] | undefined, value: T): T[] { + const next = new Set(normalizeList(list)); + if (next.has(value)) next.delete(value); + else next.add(value); + return Array.from(next); +} + +export function withoutMember(list: readonly ProxyGroupMemberRef[] | undefined, key: string): ProxyGroupMemberRef[] { + return normalizeList(list).filter((member) => getProxyGroupMemberKey(member) !== key); +} + +export function withMember(list: readonly ProxyGroupMemberRef[] | undefined, member: ProxyGroupMemberRef): ProxyGroupMemberRef[] { + const key = getProxyGroupMemberKey(member); + return [...withoutMember(list, key), member]; +} + +const PROTECTED_INSERT_KEYS = new Set([ + "direct:DIRECT", + "reject:REJECT", + "module:auto", + "module:select", +]); + +export function insertMemberAfterProtected( + currentMembers: ResolvedMember[], + member: ProxyGroupMemberRef, +): ProxyGroupMemberRef[] { + const key = getProxyGroupMemberKey(member); + const current = currentMembers + .map((item) => item.ref) + .filter((item) => getProxyGroupMemberKey(item) !== key); + let insertAt = 0; + current.forEach((item, index) => { + if (PROTECTED_INSERT_KEYS.has(getProxyGroupMemberKey(item))) { + insertAt = index + 1; + } + }); + return [...current.slice(0, insertAt), member, ...current.slice(insertAt)]; +} + +function CountBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function DragHandle() { + return ( + + {Array.from({ length: 6 }).map((_, index) => ( + + ))} + + ); +} + +const ADVANCED_PANEL_TITLE_CLASS = "mb-2 block text-[11px] font-medium text-white/50"; +const ADVANCED_PANEL_TITLE_ROW_CLASS = "mb-2 flex min-h-5 items-center gap-2"; + +export function ProxyGroupAdvancedPanel({ + target, + advanced, + onChange, + rulesCount, + rulesContent, +}: { + target: AdvancedTarget; + advanced: ProxyGroupAdvancedConfig; + onChange: (patch: Partial) => void; + rulesCount: number; + rulesContent: React.ReactNode; +}) { + const { + nodes, + sources, + enabledProxyGroups, + customProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + testUrl, + testInterval, + ruleProviderBaseUrl, + } = useConfigStore(); + const [draggingKey, setDraggingKey] = React.useState(null); + const activeCustomProxyGroups = React.useMemo( + () => customProxyGroups.filter((group) => group.enabled !== false), + [customProxyGroups], + ); + + const activeNodes = React.useMemo( + () => nodes.filter((node) => !isSubscriptionInfoNodeName(node.name)), + [nodes], + ); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); + + const generatedProxyNames = React.useMemo(() => { + if (nodes.length === 0) return []; + const generated = generateProxyGroups({ + nodes, + enabledModules: enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + customProxyGroups: activeCustomProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + }); + return generated.find((group) => group.name === target.name)?.proxies ?? []; + }, [ + nodes, + enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + activeCustomProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + target.name, + ]); + + const candidateMembers = React.useMemo(() => { + const rawNames = [ + "DIRECT", + "REJECT", + ...activeNodes.map((node) => node.name), + ...PROXY_GROUP_MODULES.filter((module) => enabledProxyGroups.includes(module.id)).map((module) => moduleNames[module.id]), + ...activeCustomProxyGroups.map((group) => group.name), + ]; + const out: ResolvedMember[] = []; + const seen = new Set(); + for (const rawName of rawNames) { + if (typeof rawName !== "string" || !rawName.trim()) continue; + const member = buildMemberFromName(rawName, { nodes: activeNodes, moduleNames, customProxyGroups: activeCustomProxyGroups }); + if (!member || seen.has(member.key)) continue; + if (member.key === `${target.kind}:${target.id}`) continue; + seen.add(member.key); + out.push(member); + } + return out; + }, [activeCustomProxyGroups, activeNodes, enabledProxyGroups, moduleNames, target.id, target.kind]); + + const includedMembers = React.useMemo(() => { + const out: ResolvedMember[] = []; + const seen = new Set(); + for (const name of generatedProxyNames) { + const member = buildMemberFromName(name, { nodes: activeNodes, moduleNames, customProxyGroups: activeCustomProxyGroups }); + if (!member || seen.has(member.key)) continue; + seen.add(member.key); + out.push(member); + } + return out; + }, [activeCustomProxyGroups, activeNodes, generatedProxyNames, moduleNames]); + + const excludedMembers = React.useMemo(() => { + const included = new Set(includedMembers.map((member) => member.key)); + return candidateMembers.filter((member) => !included.has(member.key)); + }, [candidateMembers, includedMembers]); + + const sourceOptions = React.useMemo(() => { + const sourceIdsInNodes = new Set(); + for (const node of activeNodes) { + for (const id of getNodeSourceIds(node)) sourceIdsInNodes.add(id); + } + return sources + .filter((source) => sourceIdsInNodes.has(source.id)) + .map((source, index) => ({ + id: source.id, + label: source.tag?.trim() || source.lastParsedTag?.trim() || `#${index + 1} ${source.type === "url" ? "订阅链接" : source.type === "yaml" ? "YAML 配置" : "节点链接"}`, + })); + }, [activeNodes, sources]); + + const moveMember = React.useCallback( + (fromKey: string, toKey: string) => { + if (fromKey === toKey) return; + const current = includedMembers.map((member) => member.ref); + const from = current.findIndex((member) => getProxyGroupMemberKey(member) === fromKey); + const to = current.findIndex((member) => getProxyGroupMemberKey(member) === toKey); + if (from < 0 || to < 0) return; + const next = [...current]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + onChange({ memberOrder: next }); + }, + [includedMembers, onChange], + ); + + const sourceIds = normalizeList(advanced.sourceIds); + const regions = normalizeList(advanced.regions); + const extraRefs = normalizeList(advanced.extraMembers); + const excludedRefs = normalizeList(advanced.excludedMembers); + + const disableMember = React.useCallback( + (member: ResolvedMember) => { + onChange({ + extraMembers: withoutMember(extraRefs, member.key), + excludedMembers: withMember(excludedRefs, member.ref), + memberOrder: withoutMember(advanced.memberOrder, member.key), + }); + }, + [advanced.memberOrder, excludedRefs, extraRefs, onChange], + ); + + const enableMember = React.useCallback( + (member: ResolvedMember) => { + onChange({ + extraMembers: withMember(extraRefs, member.ref), + excludedMembers: withoutMember(excludedRefs, member.key), + memberOrder: insertMemberAfterProtected(includedMembers, member.ref), + }); + }, + [excludedRefs, extraRefs, includedMembers, onChange], + ); + + return ( +
+
+
+
导入源
+
+ {sourceOptions.length === 0 ? ( +
暂无可匹配的导入源
+ ) : ( + sourceOptions.map((source) => ( + + )) + )} +
+
不选择表示匹配所有导入源
+
+ +
+
地区
+
+ {REGION_PRESETS.map((region) => { + const active = regions.includes(region.id); + return ( + + ); + })} +
+
不选择表示匹配所有地区
+
+ +
+ + +
+
+ +
+ +
+
+
已启用节点
+ {includedMembers.length} 个 +
+ {includedMembers.length === 0 ? ( +
+ 暂无已启用的节点或代理组 +
+ ) : ( +
+ {includedMembers.map((member) => ( +
setDraggingKey(member.key)} + onDragOver={(event) => event.preventDefault()} + onDrop={() => { + if (draggingKey) moveMember(draggingKey, member.key); + setDraggingKey(null); + }} + onDragEnd={() => setDraggingKey(null)} + className={cn( + "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded border border-white/10 bg-white/[0.04] px-2 py-1.5 text-xs", + draggingKey === member.key && "opacity-50", + )} + > + + + +
+
+ {memberLabel(member)} +
+
{memberKindLabel(member)}
+
+ +
+ ))} +
+ )} + +
+
+
未启用节点
+ {excludedMembers.length} 个 +
+ {excludedMembers.length === 0 ? ( +
暂无未启用的节点或代理组
+ ) : ( +
+ {excludedMembers.map((member) => { + return ( + + ); + })} +
+ )} +
+
+ +
+ +
+
+
分流规则
+ + {rulesCount} 条 + +
+ {rulesContent} + {rulesCount === 0 && ( +
+ 还没有分流规则 +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts index 86f4c6b..f8f004f 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.test.ts @@ -51,7 +51,6 @@ const targets = [ { kind: "module", id: "auto", name: "Auto" }, { kind: "module", id: "fallback", name: "Fallback" }, { kind: "custom", id: "custom-1", name: "Custom" }, - { kind: "filtered", id: "filtered-1", name: "Filtered" }, ] as any[]; describe("proxy group rule row components", () => { @@ -63,7 +62,7 @@ describe("proxy group rule row components", () => { it("detects move targets and renders active, moved, and removed rows", () => { expect(isRuleSetMoveTarget(targets[0])).toBe(true); expect(isRuleSetMoveTarget(targets[2])).toBe(true); - expect(isRuleSetMoveTarget(targets[3])).toBe(false); + expect(isRuleSetMoveTarget({ kind: "filtered", id: "filtered-1", name: "Filtered" } as never)).toBe(false); const active = renderToStaticMarkup( React.createElement(ProxyGroupRuleRow, { @@ -175,7 +174,7 @@ describe("proxy group rule row components", () => { expect(html).toContain("example.com"); expect(html).toContain("DOMAIN-SUFFIX,example.com,Proxy,no-resolve"); - expect(html).toContain("手动"); + expect(html).toContain("自定义"); expect(captures.buttons.find((props) => props.title === "移动规则")).toBeTruthy(); captures.menuItems.find((props) => props.children === "Custom").onSelect(); expect(onMove).toHaveBeenCalledWith(item, targets[2]); @@ -191,16 +190,15 @@ describe("proxy group rule row components", () => { title: "移动规则集", ariaLabel: "移动规则集", targets: [targets[0], targets[2]], - kinds: ["module", "custom", "filtered"], + kinds: ["module", "custom"], currentTarget: { kind: "module", id: "auto", name: "Auto" }, onMove, }) ); - expect(html).toContain("内置分流组"); - expect(html).toContain("自定义分组"); - expect(html).toContain("筛选组"); - expect(html).toContain("暂无筛选组"); + expect(html).toContain("内置组"); + expect(html).toContain("自定义组"); + expect(html).not.toContain("筛选组"); expect(captures.menuItems.find((props) => props.children === "Auto").disabled).toBe(true); expect(captures.menuItems.find((props) => props.children === "Custom").disabled).toBe(false); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx index 8af0dc2..d61b724 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-row.tsx @@ -39,15 +39,13 @@ type ProxyGroupRuleRowProps = { }; const targetKindLabels: Record = { - module: "内置分流组", - custom: "自定义分组", - filtered: "筛选组", + module: "内置组", + custom: "自定义组", }; const emptyTargetLabels: Record = { - module: "暂无内置分流组", - custom: "暂无自定义分组", - filtered: "暂无筛选组", + module: "暂无内置组", + custom: "暂无自定义组", }; export function ProxyGroupRuleRow({ @@ -64,11 +62,11 @@ export function ProxyGroupRuleRow({ return (
@@ -181,7 +179,7 @@ export function ProxyGroupManualRuleRow({ title="移动规则" ariaLabel={`移动 ${rule.value} 规则`} targets={targets} - kinds={["module", "custom", "filtered"]} + kinds={["module", "custom"]} currentTarget={{ name: currentTargetName }} onMove={(target) => onMove(item, target)} /> @@ -293,7 +291,7 @@ function RuleSourceBadge({ source }: { source: RuleSource }) { const label = { preset: "预设", custom: "自定义", - manual: "手动", + manual: "自定义", experimental: "实验性", }[source]; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts index 77c5438..c9f7d63 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.test.ts @@ -16,7 +16,7 @@ describe("proxy group rule targets", () => { expect(listCustomRulesForTarget(rules, " ")).toEqual([]); }); - it("builds enabled module, custom, and filtered targets", () => { + it("builds enabled module and custom targets", () => { const targets = buildManualRuleTargets({ enabledProxyGroups: ["auto", "load-balance"], hiddenProxyGroups: ["load-balance"], @@ -25,17 +25,12 @@ describe("proxy group rule targets", () => { { id: "", name: "Missing" }, { id: "custom-2", name: " " }, ], - filteredProxyGroups: [ - { id: "filtered-1", name: " Filtered ", enabled: true }, - { id: "filtered-2", name: "Disabled", enabled: false }, - ], proxyGroupNameOverrides: { auto: "Auto Override" }, } as any); expect(targets).toEqual([ { kind: "module", id: "auto", name: "⚡ Auto Override" }, { kind: "custom", id: "custom-1", name: "Custom" }, - { kind: "filtered", id: "filtered-1", name: "Filtered" }, ]); }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts index 5b303a6..0c66fac 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-rule-targets.ts @@ -1,9 +1,9 @@ import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -export type ProxyGroupRuleTargetKind = "module" | "custom" | "filtered"; +export type ProxyGroupRuleTargetKind = "module" | "custom"; export type ProxyGroupRuleTarget = { kind: ProxyGroupRuleTargetKind; @@ -19,26 +19,34 @@ export type CustomRuleListItem = { export function listCustomRulesForTarget( customRules: CustomRule[], targetName: string, + options?: { + moduleNames?: Record; + customProxyGroups?: CustomProxyGroup[]; + }, ): CustomRuleListItem[] { const normalizedTarget = targetName.trim(); if (!normalizedTarget) return []; return customRules .map((rule, index) => ({ rule, index })) - .filter(({ rule }) => rule.target.trim() === normalizedTarget); + .filter(({ rule }) => { + const target = resolveProxyGroupTargetName(rule.target, { + moduleNames: options?.moduleNames || {}, + customProxyGroups: options?.customProxyGroups || [], + }); + return target === normalizedTarget; + }); } export function buildManualRuleTargets({ enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, }: { enabledProxyGroups: string[]; hiddenProxyGroups?: string[]; customProxyGroups: CustomProxyGroup[]; - filteredProxyGroups: FilteredProxyGroup[]; proxyGroupNameOverrides?: Record; }): ProxyGroupRuleTarget[] { const hidden = new Set(hiddenProxyGroups || []); @@ -54,18 +62,11 @@ export function buildManualRuleTargets({ }); } - for (const group of customProxyGroups) { + for (const group of customProxyGroups.filter((item) => item.enabled !== false)) { const name = typeof group.name === "string" ? group.name.trim() : ""; if (!group.id || !name) continue; targets.push({ kind: "custom", id: group.id, name }); } - for (const group of filteredProxyGroups) { - if (!group?.enabled) continue; - const name = typeof group.name === "string" ? group.name.trim() : ""; - if (!group.id || !name) continue; - targets.push({ kind: "filtered", id: group.id, name }); - } - return targets; } diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts index 6cb4518..da0962b 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.test.ts @@ -114,18 +114,46 @@ 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", () => ({ + getModuleRuleOrderKey: (moduleId: string, ruleId: string) => + `${moduleId}:${ruleId}`, getEffectiveModuleRules: vi.fn(() => mocks.effectiveRules), })); vi.mock("@subboost/core/proxy-group-name", () => ({ resolveProxyGroupModuleName: (module: { name: string }, override?: string) => override || module.name, })); +vi.mock("@subboost/core/proxy-group-targets", () => ({ + resolveProxyGroupTargetName: ( + target: unknown, + options: { + moduleNames?: Record; + customProxyGroups?: Array<{ id: string; name: string }>; + fallbackTarget?: string; + } = {}, + ) => { + if (typeof target === "string") return target; + if (target && typeof target === "object") { + const entry = target as { kind?: string; id?: string }; + if (entry.kind === "module" && entry.id) { + return options.moduleNames?.[entry.id] ?? options.fallbackTarget ?? entry.id; + } + if (entry.kind === "custom" && entry.id) { + return ( + options.customProxyGroups?.find((group) => group.id === entry.id)?.name ?? + options.fallbackTarget ?? + entry.id + ); + } + } + return options.fallbackTarget ?? ""; + }, +})); vi.mock("@subboost/core/rules/custom-routing-rule-sets", () => ({ buildRuleSetUrlFromPath: (path: string, base: string) => `${base.replace(/\/+$/, "")}/${path}`, @@ -144,6 +172,7 @@ vi.mock("@subboost/ui/store/config-store", () => { }); import { ProxyGroupsAddedRuleSets } from "./proxy-groups-added-rule-sets"; +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { RULE_EDIT_ACTIONS_CLASS, RULE_EDIT_PRIMARY_FIELD_CLASS, @@ -153,23 +182,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", @@ -223,27 +252,25 @@ describe("ProxyGroupsAddedRuleSets", () => { switches: [], }; mocks.ruleSets = [moduleItem, customItem]; + for (const proxyModule of PROXY_GROUP_MODULES as Array<{ rules?: unknown[] }>) { + proxyModule.rules = []; + } mocks.effectiveRules = []; mocks.store = { 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(), @@ -291,6 +318,7 @@ describe("ProxyGroupsAddedRuleSets", () => { }); expect(html).toContain("geosite/draft"); + expect(html).toContain("Target"); expect(html).not.toContain("geosite/draft.mrs"); expect(html).toContain(RULE_EDIT_PRIMARY_FIELD_CLASS); expect(html).toContain(RULE_EDIT_TRAILING_CONTROLS_CLASS); @@ -317,6 +345,36 @@ describe("ProxyGroupsAddedRuleSets", () => { expect(draftUpdaters.map((updater) => updater(null))).toEqual([null, null]); }); + it("renders raw rule-set paths and hides rows targeting hidden modules", () => { + mocks.ruleSets = [ + { + ...moduleItem, + key: "custom-rule-set:raw", + id: "raw", + path: "https://cdn.example/plain-rule.txt", + noResolve: false, + }, + { + ...moduleItem, + key: "custom-rule-set:hidden", + id: "hidden", + path: "geosite/hidden.mrs", + target: { + kind: "module", + id: "fallback", + value: "module:fallback", + name: "Fallback", + }, + }, + ]; + mocks.store.hiddenProxyGroups = ["fallback"]; + + const { html } = renderAdded(); + + expect(html).toContain("https://cdn.example/plain-rule.txt"); + expect(html).not.toContain("geosite/hidden"); + }); + it("saves module rule edits across module and custom targets", () => { renderAdded({ 0: moduleItem.key, @@ -382,19 +440,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 +460,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 +484,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 +506,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,9 +561,87 @@ 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("rejects builtin and blank custom target conflicts", () => { + (PROXY_GROUP_MODULES[1] as any).rules = [ + { id: "other-rule" }, + { id: "rule-a" }, + ]; + renderAdded({ + 0: moduleItem.key, + 1: { + path: "geosite/rule-a.mrs", + targetValue: "module:fallback", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "规则集已存在", variant: "warning" }), + ); + + mocks.toast.mockClear(); + mocks.store.builtinRuleEdits = { + "fallback:rule-a": { enabled: false }, + }; + renderAdded({ + 0: moduleItem.key, + 1: { + path: "geosite/rule-a.mrs", + targetValue: "module:fallback", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("auto", "rule-a", { + kind: "module", + id: "fallback", + }); + + mocks.store.moveModuleRule.mockClear(); + mocks.store.builtinRuleEdits = { + "fallback:rule-a": { target: { kind: "custom", id: "custom-2" } }, + }; + renderAdded({ + 0: moduleItem.key, + 1: { + path: "geosite/rule-a.mrs", + targetValue: "module:fallback", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.store.moveModuleRule).toHaveBeenCalledWith("auto", "rule-a", { + kind: "module", + id: "fallback", + }); + + mocks.store.customProxyGroups = [ + { id: "custom-1", name: "Custom" }, + { id: "blank", name: " " }, + ]; + renderAdded({ + 0: customItem.key, + 1: { + path: "geoip/rule-b.mrs", + targetValue: "custom:blank", + noResolve: false, + }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "规则集已存在", variant: "warning" }), ); }); @@ -591,5 +711,19 @@ describe("ProxyGroupsAddedRuleSets", () => { expect(mocks.toast).toHaveBeenCalledWith( expect.objectContaining({ title: "规则集已存在", variant: "warning" }), ); + + renderAdded({ + 0: moduleItem.key, + 1: { path: " ", targetValue: "module:auto", noResolve: false }, + }); + mocks.captures.buttons + .find((props: any) => props.title === "保存规则集") + .onClick(); + expect(mocks.store.updateModuleRule).not.toHaveBeenCalledWith( + "auto", + "rule-a", + expect.objectContaining({ path: "" }), + ); + }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx index 2fe055b..7f4743a 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-added-rule-sets.tsx @@ -13,10 +13,10 @@ 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 { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { - buildRuleSetUrlFromPath, collectCustomRoutingRuleSets, getRuleSetTargetValue, normalizeRuleSetPathInput, @@ -25,7 +25,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, @@ -65,19 +65,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 +84,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 +97,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]); @@ -120,6 +118,17 @@ export function ProxyGroupsAddedRuleSets({ [customProxyGroups, proxyGroupNameOverrides, visibleProxyGroupModules], ); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); + React.useEffect(() => { if (!editingKey) return; if (visibleAddedRuleSets.some((item) => item.key === editingKey)) return; @@ -127,44 +136,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 +146,54 @@ 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)]; + const editTarget = edit?.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups, + fallbackTarget: moduleName, + }) + : moduleName; + return edit?.enabled !== false && editTarget === moduleName; + }); + const customConflict = customRuleSets.some( + (ruleSet) => + ruleSet.id === item.id && + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups, + }) === moduleName && + item.target.value !== getRuleSetTargetValue(target), ); + return builtinConflict || customConflict; } const group = customProxyGroups.find((entry) => entry.id === target.id); if (!group) return true; - return group.rules.some( - (rule) => - rule.id === item.id && - !(item.source.kind === "custom" && item.source.id === target.id), + const targetName = group.name.trim(); + if (!targetName) return true; + return customRuleSets.some( + (ruleSet) => + ruleSet.id === item.id && + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups, + }) === targetName && + item.target.value !== getRuleSetTargetValue(target), ); }, [ + builtinRuleEdits, customProxyGroups, - moduleRuleExclusions, - moduleRuleOverrides, + customRuleSets, + moduleNames, + proxyGroupNameOverrides, visibleProxyGroupModules, ], ); @@ -213,11 +209,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 +217,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,20 +229,7 @@ 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 = { + const nextModuleRule: StoreRuleSetDraft = { id: item.id, name: item.name, behavior: item.behavior, @@ -258,27 +237,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..d27e0ad 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts @@ -11,8 +11,10 @@ const mocks = vi.hoisted(() => ({ const stateMock = vi.hoisted(() => ({ enabled: false, + effects: [] as Array, callIndex: 0, overrides: {} as Record, + refOverride: undefined as unknown, setters: [] as Array>, })); @@ -28,6 +30,19 @@ vi.mock("react", async (importOriginal) => { if (stateMock.enabled) return factory(); return actual.useMemo(factory, deps ?? []); }, + useEffect: (effect: React.EffectCallback, deps?: React.DependencyList) => { + if (stateMock.enabled) { + stateMock.effects.push(effect); + return undefined; + } + return actual.useEffect(effect, deps); + }, + useRef: (initial: unknown) => { + if (stateMock.enabled) { + return { current: stateMock.refOverride === undefined ? initial : stateMock.refOverride }; + } + return actual.useRef(initial); + }, useState: (initial: unknown) => { if (!stateMock.enabled) return actual.useState(initial); const index = stateMock.callIndex++; @@ -75,10 +90,14 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ service: { name: "服务", order: 1.5 }, custom: { name: "自定义", order: 2 }, }, - PROXY_GROUP_MODULES: [ - { id: "auto", name: "Auto", category: "core" }, - { id: "fallback", name: "Fallback", category: "core" }, - ], + PROXY_GROUP_MODULES: [ + { id: "auto", name: "Auto", category: "core", rules: [ + { id: "auto-rule", name: "Auto Rule", behavior: "domain", path: "geosite/auto-rule.mrs" }, + { id: "moved-rule", name: "Moved Rule", behavior: "domain", path: "geosite/moved-rule.mrs" }, + ] }, + { id: "fallback", name: "Fallback", category: "core", rules: [] }, + ], + generateProxyGroups: vi.fn(() => []), })); vi.mock("@subboost/core/proxy-group-name", () => ({ resolveProxyGroupModuleName: (module: { name: string }, override?: string) => override || module.name, @@ -116,11 +135,13 @@ vi.mock("./proxy-groups-module-card", () => ({ })); import { ProxyGroupsCategories } from "./proxy-groups-categories"; +import { generateProxyGroups } from "@subboost/core/generator/proxy-groups"; function renderCategories(overrides: Record = {}) { stateMock.enabled = true; stateMock.callIndex = 0; stateMock.overrides = overrides; + stateMock.effects = []; stateMock.setters = []; mocks.captures.inputs = []; mocks.captures.dropdownItems = []; @@ -139,6 +160,7 @@ function renderCategoryTree(overrides: Record = {}) { stateMock.enabled = true; stateMock.callIndex = 0; stateMock.overrides = overrides; + stateMock.effects = []; stateMock.setters = []; mocks.captures.inputs = []; mocks.captures.dropdownItems = []; @@ -169,10 +191,14 @@ function collectElements( describe("ProxyGroupsCategories", () => { beforeEach(() => { vi.clearAllMocks(); + stateMock.refOverride = undefined; mocks.confirmDialog.mockResolvedValue(true); mocks.captures = { inputs: [], dropdownItems: [], moduleCards: [] }; mocks.store = { ruleProviderBaseUrl: "https://rules.example/base/", + nodes: [], + testUrl: "https://probe.example/204", + testInterval: 300, setRuleProviderBaseUrl: vi.fn(), cnIpNoResolve: false, setCnIpNoResolve: vi.fn(), @@ -183,8 +209,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(), @@ -193,22 +219,51 @@ describe("ProxyGroupsCategories", () => { removeModuleRule: vi.fn(), moveModuleRule: vi.fn(), restoreModuleRule: vi.fn(), + resetModuleRuleTarget: vi.fn(), updateCustomProxyGroup: vi.fn(), acceptModuleRuleEditWarning: vi.fn(), proxyGroupNameOverrides: { auto: "Auto Override" }, setProxyGroupNameOverride: vi.fn(), clearProxyGroupNameOverride: vi.fn(), - customProxyGroups: [{ id: "custom-1", name: "Custom", rules: [] }], - filteredProxyGroups: [{ name: "Filtered", enabled: true }], + customProxyGroups: [{ id: "custom-1", name: "Custom" }], dialerProxyGroups: [{ name: "Dialer" }], + proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, + setProxyGroupAdvancedModeEnabled: vi.fn(), + updateProxyGroupAdvanced: vi.fn(), }; }); - it("renders visible and hidden modules and forwards basic config changes", () => { + it("opens only the custom category by default when custom groups exist", () => { + renderCategories(); + + expect(mocks.captures.customPanelRendered).toBe(true); + expect(mocks.captures.moduleCards).toHaveLength(0); + + mocks.store.customProxyGroups = []; renderCategories(); + expect(mocks.captures.customPanelRendered).toBe(false); + expect(mocks.captures.moduleCards).toHaveLength(0); + }); + + it("applies the custom category default after custom groups appear later", () => { + stateMock.refOverride = false; + renderCategories({ 0: new Set() }); + stateMock.effects[0](); + expect(stateMock.setters[0]).toHaveBeenCalledWith(expect.any(Function)); + expect((stateMock.setters[0] as any).lastValue.has("custom")).toBe(true); + + stateMock.refOverride = false; + renderCategories({ 0: new Set(["custom"]) }); + stateMock.effects[0](); + expect((stateMock.setters[0] as any).lastValue.has("custom")).toBe(true); + }); + + it("renders visible and hidden modules and forwards basic config changes", () => { + renderCategories({ 0: new Set(["core"]) }); expect(mocks.captures.inputs).toHaveLength(0); - expect(renderCategories().html).toContain("https://rules.example/base/"); + expect(renderCategories({ 0: new Set(["core"]) }).html).toContain("https://rules.example/base/"); expect(mocks.store.setRuleProviderBaseUrl).not.toHaveBeenCalled(); expect(mocks.captures.moduleCards).toHaveLength(1); @@ -229,7 +284,7 @@ describe("ProxyGroupsCategories", () => { }); it("handles module card actions, confirmations, and rule movement", async () => { - const { setters } = renderCategories(); + const { setters } = renderCategories({ 0: new Set(["core"]) }); const card = mocks.captures.moduleCards[0]; await card.onToggleEnabled(); @@ -262,6 +317,8 @@ describe("ProxyGroupsCategories", () => { expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { target: "Fallback" }); card.onRestoreRule("rule-a"); expect(mocks.store.restoreModuleRule).toHaveBeenCalledWith("auto", "rule-a"); + card.onResetRuleTarget("rule-a"); + expect(mocks.store.resetModuleRuleTarget).toHaveBeenCalledWith("auto", "rule-a"); card.onChangeCnIpNoResolve(true); expect(mocks.store.setCnIpNoResolve).toHaveBeenCalledWith(true); @@ -270,7 +327,7 @@ describe("ProxyGroupsCategories", () => { }); it("renames modules and adds rules to custom groups with duplicate guards", () => { - const { setters } = renderCategories({ 1: "auto", 2: "Custom" }); + const { setters } = renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "Custom" }); const card = mocks.captures.moduleCards[0]; card.onStartEditing(); @@ -283,40 +340,30 @@ describe("ProxyGroupsCategories", () => { card.onCancelEditing(); expect(setters[1]).toHaveBeenCalledWith(null); - renderCategories({ 1: "auto", 2: "" }); + renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 1: "auto", 2: "Auto" }); + renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "Auto" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.clearProxyGroupNameOverride).toHaveBeenCalledWith("auto"); - renderCategories({ 1: "auto", 2: "Unique" }); + renderCategories({ 0: new Set(["core"]), 1: "auto", 2: "Unique" }); mocks.captures.moduleCards[0].onCommitEditing(); expect(mocks.store.setProxyGroupNameOverride).toHaveBeenCalledWith("auto", "Unique"); 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(); - renderCategories(); + mocks.store.customRuleSets = [{ id: "geo", name: "Geo", behavior: "domain", path: "geo.txt", target: "Custom" }]; + mocks.store.addModuleRules.mockClear(); + renderCategories({ 0: new Set(["core"]) }); 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: [] }]; - renderCategories(); + mocks.store.customProxyGroups = [{ id: "custom-1", name: "Custom" }]; + renderCategories({ 0: new Set(["core"]) }); mocks.captures.moduleCards[0].onAddRuleToCustomGroup("missing", customRule); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); @@ -327,22 +374,70 @@ 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("forwards generated counts, moved preset rules, and advanced group changes", () => { + mocks.store.nodes = [{ name: "US", type: "ss", server: "us.example.com", port: 8388, cipher: "aes-128-gcm", password: "secret" }]; + mocks.store.customRuleSets = [ + { id: "set-1", name: "Set 1", behavior: "domain", path: "geosite/set-1.mrs", target: "Auto Override", noResolve: true }, + ]; + mocks.store.builtinRuleEdits = { + "module:auto:auto-rule": { enabled: false }, + "module:auto:moved-rule": { target: "Fallback" }, + bad: { target: "Fallback" }, + }; + mocks.store.proxyGroupAdvanced = { auto: { groupType: "select", strategy: "round-robin" } }; + vi.mocked(generateProxyGroups).mockReturnValueOnce([ + { name: "Auto Override", type: "select", proxies: ["US", "DIRECT"] }, + ] as never); + + renderCategories({ 0: new Set(["core"]) }); + const card = mocks.captures.moduleCards[0]; + + expect(card.nodeCount).toBe(2); + expect(card.extraRules).toEqual([ + { id: "set-1", name: "Set 1", behavior: "domain", path: "geosite/set-1.mrs", noResolve: true }, + ]); + expect(card.hiddenPresetRuleIds.auto).toContain("auto-rule"); + expect(card.hiddenPresetRuleIds.auto).toContain("moved-rule"); + + card.onChangeGroupType({ groupType: "load-balance" }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { + groupType: "load-balance", + strategy: "round-robin", + }); + card.onChangeGroupType({ groupType: "fallback", strategy: "consistent-hashing" }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { + groupType: "fallback", + strategy: undefined, + }); + + mocks.store.proxyGroupAdvanced = { auto: {} }; + renderCategories({ 0: new Set(["core"]) }); + mocks.captures.moduleCards[0].onChangeGroupType({ groupType: "load-balance" }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { + groupType: "load-balance", + strategy: "round-robin", }); + + const advancedElement = card.renderAdvancedContent(React.createElement("div", null, "rules"), 2); + expect(advancedElement.props.target).toEqual({ kind: "module", id: "auto", name: "Auto Override" }); + advancedElement.props.onChange({ sourceIds: ["source-a"] }); + expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { sourceIds: ["source-a"] }); }); it("renders custom category and disabled non-core module branches", async () => { mocks.store.hiddenProxyGroups = []; mocks.store.enabledProxyGroups = []; - mocks.store.filteredProxyGroups = [{ name: "Filtered", enabled: false }, null, { name: " ", enabled: true }, { name: 123, enabled: true }]; mocks.store.dialerProxyGroups = [null, { name: " " }]; const { setters } = renderCategories({ 0: new Set(["custom", "core"]) }); expect(mocks.captures.customPanelRendered).toBe(true); @@ -359,6 +454,21 @@ describe("ProxyGroupsCategories", () => { expect(setters[0]).toBeDefined(); }); + it("handles unknown rule targets and generated groups without proxy arrays", () => { + mocks.store.nodes = [{ name: "US", type: "ss", server: "us.example.com", port: 8388, cipher: "aes-128-gcm", password: "secret" }]; + mocks.store.customRuleSets = [ + { id: "unknown-target", name: "Unknown", behavior: "domain", path: "geosite/unknown.mrs", target: "Missing" }, + ]; + vi.mocked(generateProxyGroups).mockReturnValueOnce([ + { name: "Auto Override", type: "select", proxies: "bad" }, + ] as never); + + renderCategories({ 0: new Set(["core"]) }); + + expect(mocks.captures.moduleCards[0].nodeCount).toBe(0); + expect(mocks.captures.moduleCards[0].extraRules).toEqual([]); + }); + it("toggles category expansion through the rendered category headers", () => { const { tree, setters } = renderCategoryTree({ 0: new Set(["core"]) }); const categoryButtons = collectElements( diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx index 7319596..a2848bd 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { ChevronDown, ChevronRight, RotateCcw } from "lucide-react"; import { Badge } from "@subboost/ui/components/ui/badge"; import { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog"; +import { Switch } from "@subboost/ui/components/ui/switch"; import { DropdownMenu, DropdownMenuContent, @@ -12,23 +13,38 @@ import { DropdownMenuTrigger, } from "@subboost/ui/components/ui/dropdown-menu"; import { toast } from "@subboost/ui/components/ui/toaster"; +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + type ProxyGroupGroupType, +} from "@subboost/core/types/config"; import { CATEGORY_INFO, PROXY_GROUP_MODULES, + generateProxyGroups, } from "@subboost/core/generator/proxy-groups"; +import type { HiddenPresetRuleIds } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import { useConfigStore } from "@subboost/ui/store/config-store"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; +import { useConfigStore, type RuleSetDraft } from "@subboost/ui/store/config-store"; import { buildManualRuleTargets, listCustomRulesForTarget, } from "./proxy-group-rule-targets"; import { ProxyGroupsCustomGroupsPanel } from "./proxy-groups-custom-groups-panel"; import { ProxyGroupsCustomRoutingRules } from "./proxy-groups-custom-routing-rules"; +import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; +const PROXY_GROUP_SECTION_LABEL_ROW_CLASS = "flex min-h-7 items-center gap-2"; +const PROXY_GROUP_SECTION_LABEL_CLASS = "text-xs text-white/50"; +const CUSTOM_CATEGORY_ID = "custom"; + export function ProxyGroupsCategories() { const { ruleProviderBaseUrl, + nodes = [], + testUrl, + testInterval, cnIpNoResolve, setCnIpNoResolve, experimentalCnUseCnRuleSet, @@ -38,29 +54,33 @@ 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 = [], + proxyGroupAdvanced = {}, + proxyGroupAdvancedModeEnabled, + setProxyGroupAdvancedModeEnabled, + updateProxyGroupAdvanced, + dialerProxyGroups = [], } = useConfigStore(); const [expandedCategories, setExpandedCategories] = React.useState< Set - >(new Set(["core", "service"])); + >(new Set(customProxyGroups.length > 0 ? [CUSTOM_CATEGORY_ID] : [])); + const didApplyCustomCategoryDefault = React.useRef(customProxyGroups.length > 0); const [editingModuleId, setEditingModuleId] = React.useState( null, ); @@ -68,6 +88,17 @@ export function ProxyGroupsCategories() { const [expandedModuleRules, setExpandedModuleRules] = React.useState< Set >(new Set()); + React.useEffect(() => { + if (didApplyCustomCategoryDefault.current || customProxyGroups.length === 0) return; + didApplyCustomCategoryDefault.current = true; + setExpandedCategories((prev) => { + if (prev.has(CUSTOM_CATEGORY_ID)) return prev; + const next = new Set(prev); + next.add(CUSTOM_CATEGORY_ID); + return next; + }); + }, [customProxyGroups.length]); + const toggleCategory = (category: string) => { setExpandedCategories((prev) => { const next = new Set(prev); @@ -105,11 +136,6 @@ export function ProxyGroupsCategories() { for (const m of PROXY_GROUP_MODULES) names.push(resolveModuleDisplayName(m).full); for (const g of customProxyGroups) names.push(g.name); - for (const g of filteredProxyGroups) { - if (!g || !g.enabled) continue; - const name = typeof g.name === "string" ? g.name.trim() : ""; - if (name) names.push(name); - } for (const g of dialerProxyGroups) { const name = g && typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); @@ -118,7 +144,6 @@ export function ProxyGroupsCategories() { }, [ customProxyGroups, dialerProxyGroups, - filteredProxyGroups, resolveModuleDisplayName, ]); @@ -132,6 +157,112 @@ export function ProxyGroupsCategories() { } return grouped; }, [hiddenProxyGroups]); + const generatedProxyGroupNodeCounts = React.useMemo(() => { + if (nodes.length === 0) return new Map(); + const generated = generateProxyGroups({ + nodes, + enabledModules: enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + customProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + }); + return new Map( + generated.map((group) => [ + group.name, + Array.isArray(group.proxies) ? group.proxies.length : 0, + ]), + ); + }, [ + nodes, + enabledProxyGroups, + ruleProviderBaseUrl, + testUrl, + testInterval, + customProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, + proxyGroupNameOverrides, + ]); + const targetRuleView = React.useMemo(() => { + const ruleSetsByTarget: Record = {}; + const hiddenPresetRuleIds: HiddenPresetRuleIds = {}; + const moduleNameToId = new Map(); + const moduleNames: Record = {}; + for (const proxyModule of PROXY_GROUP_MODULES) { + const name = resolveModuleDisplayName(proxyModule).full; + moduleNameToId.set(name, proxyModule.id); + moduleNames[proxyModule.id] = name; + } + + const pushRuleSetForTarget = (moduleId: string, rule: RuleSetDraft) => { + 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 targetName = resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups, + }); + const moduleId = moduleNameToId.get(targetName); + if (!moduleId) continue; + pushRuleSetForTarget(moduleId, { + id: ruleSet.id, + name: ruleSet.name, + behavior: ruleSet.behavior, + path: ruleSet.path, + ...(ruleSet.noResolve ? { noResolve: true } : {}), + }); + } + + 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; + const editTarget = edit.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups, + fallbackTarget: defaultTarget, + }) + : ""; + + if (edit.enabled === false) hidePresetRule(sourceModuleId, ruleId); + if (editTarget && editTarget !== defaultTarget) { + hidePresetRule(sourceModuleId, ruleId); + const targetModuleId = moduleNameToId.get(editTarget); + if (targetModuleId) { + pushRuleSetForTarget(targetModuleId, { + id: sourceRule.id, + name: sourceRule.name, + behavior: sourceRule.behavior, + path: sourceRule.path, + ...(sourceRule.noResolve ? { noResolve: true } : {}), + }); + } + } + } + + return { ruleSetsByTarget, hiddenPresetRuleIds }; + }, [ + builtinRuleEdits, + customRuleSets, + customProxyGroups, + resolveModuleDisplayName, + ]); const hiddenModules = React.useMemo(() => { const hidden = new Set(hiddenProxyGroups); @@ -143,10 +274,9 @@ export function ProxyGroupsCategories() { enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, }), - [customProxyGroups, enabledProxyGroups, filteredProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], + [customProxyGroups, enabledProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], ); const getCategoryStats = (category: string) => { @@ -160,19 +290,34 @@ export function ProxyGroupsCategories() { return ( <> -
- -
- {ruleProviderBaseUrl} +
+
+
+ +
+
+ {ruleProviderBaseUrl} +
+
+
+
+ +
+
+ + {proxyGroupAdvancedModeEnabled ? "已开启" : "未开启"} + + +
-
- +
+ {hiddenModules.length > 0 && ( @@ -251,14 +396,25 @@ export function ProxyGroupsCategories() { ); const isEditing = editingModuleId === module.id; const extraRules = - moduleRuleOverrides?.[module.id] || []; + targetRuleView.ruleSetsByTarget?.[module.id] || []; const manualRules = listCustomRulesForTarget( customRules, display.full, + { + moduleNames: Object.fromEntries( + PROXY_GROUP_MODULES.map((item) => [ + item.id, + resolveModuleDisplayName(item).full, + ]), + ), + customProxyGroups, + }, ); const isRulesExpanded = expandedModuleRules.has( module.id, ); + const advancedConfig = proxyGroupAdvanced[module.id] || {}; + const effectiveGroupType = advancedConfig.groupType ?? module.groupType; const handleHideModule = async () => { const ok = await confirmDialog( @@ -382,8 +538,8 @@ export function ProxyGroupsCategories() { onCommitEditing={commitRename} onHide={handleHideModule} extraRules={extraRules} - moduleRuleOverrides={moduleRuleOverrides} - moduleRuleExclusions={moduleRuleExclusions} + ruleSetsByTarget={targetRuleView.ruleSetsByTarget} + hiddenPresetRuleIds={targetRuleView.hiddenPresetRuleIds} customProxyGroups={customProxyGroups} manualRules={manualRules} manualRuleTargets={manualRuleTargets} @@ -410,32 +566,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 +581,9 @@ export function ProxyGroupsCategories() { onRestoreRule={(ruleId) => restoreModuleRule(module.id, ruleId) } + onResetRuleTarget={(ruleId) => + resetModuleRuleTarget(module.id, ruleId) + } cnIpNoResolve={cnIpNoResolve} onChangeCnIpNoResolve={setCnIpNoResolve} experimentalCnUseCnRuleSet={ @@ -458,11 +592,35 @@ export function ProxyGroupsCategories() { onChangeExperimentalCnUseCnRuleSet={ setExperimentalCnUseCnRuleSet } + groupType={effectiveGroupType} + strategy={advancedConfig.strategy} + onChangeGroupType={({ groupType, strategy }) => + updateProxyGroupAdvanced(module.id, { + groupType: groupType as ProxyGroupGroupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? advancedConfig.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }) + } + advancedMode={proxyGroupAdvancedModeEnabled} + nodeCount={generatedProxyGroupNodeCounts.get(display.full) ?? 0} + renderAdvancedContent={(rulesContent, rulesCount) => ( + updateProxyGroupAdvanced(module.id, patch)} + rulesCount={rulesCount} + rulesContent={rulesContent} + /> + )} /> ); }) ) : ( - + )}
)} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts new file mode 100644 index 0000000..62bae9f --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts @@ -0,0 +1,204 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + cards: [] as any[], + interactions: { + proxyGroupAdded: vi.fn(), + }, + store: {} as Record, + toast: vi.fn(), +})); + +vi.mock("lucide-react", () => ({ + Check: () => null, + Trash2: () => null, +})); + +vi.mock("@subboost/ui/components/ui/button", () => ({ + Button: (props: any) => React.createElement("button", props, props.children), +})); + +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: any) => React.createElement("input", props), +})); + +vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); + +vi.mock("@subboost/core/generator/proxy-groups", () => ({ + PROXY_GROUP_MODULES: [ + { id: "auto", name: "Auto" }, + { id: "fallback", name: "Fallback" }, + ], +})); + +vi.mock("@subboost/core/proxy-group-name", () => ({ + resolveProxyGroupModuleName: (module: { name: string }, override?: string) => override || module.name, +})); + +vi.mock("@subboost/core/proxy-group-targets", () => ({ + resolveProxyGroupTargetName: (target: any, options: any) => { + if (typeof target === "string") return target; + if (target?.kind === "custom") { + return options.customProxyGroups.find((group: any) => group.id === target.id)?.name ?? ""; + } + if (target?.kind === "module") return options.moduleNames[target.id] ?? ""; + return ""; + }, +})); + +vi.mock("@subboost/core/rules/custom-routing-rule-sets", () => ({ + extractRuleSetPathFromUrl: (url: string) => url, +})); + +vi.mock("@subboost/core/types/config", () => ({ + DEFAULT_LOAD_BALANCE_STRATEGY: "consistent-hashing", +})); + +vi.mock("@subboost/ui/store/config-store", () => { + const useConfigStore = () => mocks.store; + (useConfigStore as any).getState = () => mocks.store; + return { useConfigStore }; +}); + +vi.mock("@subboost/ui/product/interactions", () => ({ + useProductInteractionAdapter: () => mocks.interactions, +})); + +vi.mock("./proxy-group-name-editor", () => ({ + buildProxyGroupName: (draft: { emoji?: string; name?: string }) => { + const name = draft.name?.trim() ?? ""; + return name ? `${draft.emoji?.trim() || "C"} ${name}` : ""; + }, + parseProxyGroupNameDraft: (value: string, emoji: string) => ({ emoji, name: value.replace(/^\\S+\\s+/, "") }), + pickRandomEmoji: () => "C", + ProxyGroupNameEditor: () => null, + toProxyGroupNameDraft: (value: { emoji?: string; name?: string }) => value, +})); + +vi.mock("./proxy-group-rule-targets", () => ({ + buildManualRuleTargets: vi.fn(() => [{ name: "Auto" }]), + listCustomRulesForTarget: (rules: any[], target: string) => + rules.filter((rule) => rule.target === target).map((rule, index) => ({ rule, index })), +})); + +vi.mock("./proxy-group-rule-row", () => ({ + ProxyGroupManualRuleRow: () => null, + ProxyGroupRuleMoveMenu: () => null, + ProxyGroupRuleSetRow: () => null, + isRuleSetMoveTarget: (value: unknown) => Boolean(value && typeof value === "object"), +})); + +vi.mock("./proxy-group-type-menu", () => ({ + ProxyGroupTypeMenu: () => null, +})); + +vi.mock("./proxy-groups-module-card", () => ({ + ProxyGroupsModuleCard: (props: any) => { + mocks.cards.push(props); + return React.createElement("section", null, props.rulesContentOverride); + }, +})); + +import { ProxyGroupsCustomGroupsPanel } from "./proxy-groups-custom-groups-panel"; + +describe("ProxyGroupsCustomGroupsPanel card props", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.cards = []; + mocks.store = { + enabledProxyGroups: ["auto"], + hiddenProxyGroups: [], + proxyGroupNameOverrides: { auto: "Auto" }, + customRules: [{ id: "manual-1", target: "C Custom" }], + customProxyGroups: [ + { + id: "custom-1", + name: "C Custom", + emoji: "C", + groupType: "select", + enabled: false, + advanced: { sourceIds: ["old-source"] }, + }, + ], + customRuleSets: [ + { + id: "rule-a", + name: "Rule A", + behavior: "domain", + path: "https://rules.example/rule-a.mrs", + target: "C Custom", + }, + ], + dialerProxyGroups: [], + addCustomProxyGroup: vi.fn(), + removeCustomProxyGroup: vi.fn(), + updateCustomProxyGroup: vi.fn(), + updateCustomRule: vi.fn(), + removeCustomRule: vi.fn(), + moveModuleRule: vi.fn(), + removeModuleRule: vi.fn(), + }; + }); + + it("wires custom group card actions and advanced patches", () => { + renderToStaticMarkup( + React.createElement(ProxyGroupsCustomGroupsPanel, { + advancedMode: true, + nodeCounts: new Map([["C Custom", 3]]), + }), + ); + + const card = mocks.cards[0]; + expect(card).toMatchObject({ + advancedMode: true, + isEnabled: false, + nodeCount: 3, + rulesCountOverride: 2, + }); + + card.onToggleEnabled(); + card.onStartEditing(); + card.onCancelEditing(); + card.onCommitEditing(); + card.onHide(); + card.acceptModuleRuleEditWarning(); + card.onToggleRulesExpanded(); + card.onAddRules(); + card.onAddRulesToModule(); + card.onAddRuleToCustomGroup(); + card.onRemoveExtraRule(); + card.onMoveRule(); + card.onMoveManualRule("manual-1", "Auto"); + card.onRemoveManualRule(0); + card.onRestoreRule(); + card.onResetRuleTarget(); + card.onChangeCnIpNoResolve(true); + card.onChangeExperimentalCnUseCnRuleSet(true); + card.onChangeGroupType({ groupType: "load-balance", strategy: "round-robin" }); + card.onChangeGroupType({ groupType: "select" }); + + const advanced = card.renderAdvancedContent(React.createElement("div", null, "rules"), 2) as React.ReactElement; + advanced.props.onChange({ regions: ["us"] }); + const emptyAdvanced = card.renderAdvancedContent(React.createElement("div", null, "rules"), 0) as React.ReactElement; + + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { enabled: true }); + expect(mocks.store.removeCustomProxyGroup).toHaveBeenCalledWith("custom-1"); + expect(mocks.store.updateCustomRule).toHaveBeenCalledWith("manual-1", { target: "Auto" }); + expect(mocks.store.removeCustomRule).toHaveBeenCalledWith(0); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + groupType: "load-balance", + strategy: "round-robin", + }); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + groupType: "select", + strategy: undefined, + }); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + advanced: { sourceIds: ["old-source"], regions: ["us"] }, + }); + expect(advanced.props.rulesContent).not.toBeNull(); + expect(emptyAdvanced.props.rulesContent).toBeNull(); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts index b332794..abbc63c 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts @@ -25,7 +25,11 @@ vi.mock("react", async (importOriginal) => { useState: (initial: unknown) => { if (!stateMock.enabled) return actual.useState(initial); const index = stateMock.callIndex++; - const value = Object.prototype.hasOwnProperty.call(stateMock.overrides, index) ? stateMock.overrides[index] : initial; + const value = Object.prototype.hasOwnProperty.call(stateMock.overrides, index) + ? stateMock.overrides[index] + : typeof initial === "function" + ? (initial as () => unknown)() + : initial; const setter = vi.fn((next: unknown) => { const resolved = typeof next === "function" ? (next as (prev: unknown) => unknown)(value) : next; (setter as any).lastValue = resolved; @@ -122,7 +126,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 +136,6 @@ const customGroup = { name: "🧩 Custom", emoji: "🧩", groupType: "select", - rules: [sourceRule], }; const targetGroup = { @@ -139,7 +143,6 @@ const targetGroup = { name: "Target", emoji: "🧩", groupType: "select", - rules: [], }; function renderPanel(overrides: Record = {}) { @@ -172,11 +175,13 @@ describe("ProxyGroupsCustomGroupsPanel", () => { proxyGroupNameOverrides: { auto: "Auto" }, customRules: [{ id: "manual-1", target: "🧩 Custom" }], customProxyGroups: [customGroup, targetGroup], - filteredProxyGroups: [{ name: "Filtered", enabled: true }], + customRuleSets: [sourceRule], 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(), @@ -184,45 +189,67 @@ describe("ProxyGroupsCustomGroupsPanel", () => { }; }); - it("adds groups, changes draft type, and rejects duplicates", () => { - const { setters } = renderPanel({ 1: { emoji: "🧩", name: "New" }, 2: "select", 3: "consistent-hashing" }); + it("adds manual groups with a random reset emoji and rejects duplicates", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + const { setters } = renderPanel({ 1: { emoji: "🧩", name: "New" }, 2: "Useful group" }); const nameInput = mocks.captures.inputs.find((props: any) => props.placeholder === "自定义分组名称"); nameInput.onChange({ target: { value: "Typed" } }); expect(setters[1]).toHaveBeenCalledWith({ emoji: "🧩", name: "Typed" }); + const descriptionInput = mocks.captures.inputs.find((props: any) => props.placeholder === "描述文本(默认: 自定义代理组)"); + descriptionInput.onChange({ target: { value: "Next description" } }); + expect(setters[2]).toHaveBeenCalledWith("Next description"); - mocks.captures.typeMenus[0].onChange({ groupType: "load-balance", strategy: "round-robin" }); - expect(setters[2]).toHaveBeenCalledWith("load-balance"); - expect(setters[3]).toHaveBeenCalledWith("round-robin"); + expect(mocks.captures.typeMenus.every((props: any) => props.trigger)).toBe(true); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ name: "🧩 New", emoji: "🧩", + description: "Useful group", groupType: "select", - rules: [], }); expect(mocks.interactions.proxyGroupAdded).toHaveBeenCalledWith({ groupType: "select" }); - expect(setters[1]).toHaveBeenCalledWith({ emoji: "🧩", name: "" }); + expect(setters[1]).toHaveBeenCalledWith({ emoji: "🚀", name: "" }); + expect(setters[2]).toHaveBeenCalledWith(""); renderPanel({ 1: { emoji: "🧩", name: "Custom" } }); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "代理组名称已存在,请换一个名称。", variant: "warning" })); }); + it("ignores blank new group names and always adds as manual select", () => { + renderPanel({ 1: { emoji: "🧩", name: " " } }); + mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); + expect(mocks.store.addCustomProxyGroup).not.toHaveBeenCalled(); + + renderPanel({ 1: { emoji: "🧩", name: "Balanced" }, 2: " LB group " }); + mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); + expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ + name: "🧩 Balanced", + emoji: "🧩", + description: "LB group", + groupType: "select", + }); + }); + it("renames, removes, and changes existing group type", () => { - const { setters } = renderPanel({ 0: new Set(["custom-1"]), 4: "custom-1", 5: { emoji: "🧩", name: "Renamed" } }); + const { setters } = renderPanel({ 0: new Set(["custom-1"]), 3: "custom-1", 4: "🧩 Renamed", 5: "" }); const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); renameInput.onChange({ target: { value: "Typed Rename" } }); - expect(setters[5]).toHaveBeenCalledWith({ emoji: "🧩", name: "Typed Rename" }); + expect(setters[4]).toHaveBeenCalledWith("🧩 Typed Rename"); renameInput.onKeyDown({ key: "Enter" }); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { name: "🧩 Renamed", emoji: "🧩" }); + expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { + name: "🧩 Renamed", + emoji: "🧩", + description: "", + }); renameInput.onKeyDown({ key: "Escape" }); - expect(setters[4]).toHaveBeenCalledWith(null); + expect(setters[3]).toHaveBeenCalledWith(null); renderPanel({ 0: new Set(["custom-1"]) }); - mocks.captures.typeMenus[1].onChange({ groupType: "load-balance", strategy: "round-robin" }); + mocks.captures.typeMenus[0].onChange({ groupType: "load-balance", strategy: "round-robin" }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { groupType: "load-balance", strategy: "round-robin", @@ -234,27 +261,33 @@ describe("ProxyGroupsCustomGroupsPanel", () => { it("moves custom rule sets to custom groups or modules", () => { renderPanel({ 0: new Set(["custom-1"]) }); + expect(mocks.captures.moveMenus[0].kinds).toEqual(["module", "custom"]); mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-2", name: "Target" }); - expect(mocks.store.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 +299,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(); @@ -275,7 +308,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { it("covers custom group edit controls and duplicate rename guard", () => { mocks.store.customProxyGroups = [customGroup, { ...targetGroup, name: "🧩 Target" }]; - const { setters } = renderPanel({ 0: new Set(["custom-1"]), 4: "custom-1", 5: { emoji: "🧩", name: "Target" } }); + const { setters } = renderPanel({ 0: new Set(["custom-1"]), 3: "custom-1", 4: "🧩 Target", 5: "" }); const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); renameInput.onKeyDown({ key: "Enter" }); @@ -284,58 +317,71 @@ describe("ProxyGroupsCustomGroupsPanel", () => { ); expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); - const editButtons = mocks.captures.buttons.filter((props: any) => props.className === "h-7 px-2" && !props.title); - const stopConfirmClick = vi.fn(); - editButtons[0].onClick({ stopPropagation: stopConfirmClick }); - expect(stopConfirmClick).toHaveBeenCalled(); + mocks.captures.buttons.find((props: any) => props.title === "保存").onClick(); + expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); - const stopCancelClick = vi.fn(); - editButtons[1].onClick({ stopPropagation: stopCancelClick }); - expect(stopCancelClick).toHaveBeenCalled(); - expect(setters[4]).toHaveBeenCalledWith(null); - expect(setters[5]).toHaveBeenCalledWith({ emoji: "🧩", name: "" }); + mocks.captures.buttons.find((props: any) => props.title === "取消").onClick(); + expect(setters[3]).toHaveBeenCalledWith(null); + expect(setters[4]).toHaveBeenCalledWith(""); + expect(setters[5]).toHaveBeenCalledWith(""); renderPanel({ 0: new Set(["custom-1"]) }); const renameButton = mocks.captures.buttons.find((props: any) => props.title === "改名"); const stopRenameClick = vi.fn(); renameButton.onClick({ stopPropagation: stopRenameClick }); expect(stopRenameClick).toHaveBeenCalled(); - expect(stateMock.setters[4]).toHaveBeenCalledWith("custom-1"); - expect(stateMock.setters[5]).toHaveBeenCalledWith({ emoji: "🧩", name: "Custom" }); + expect(stateMock.setters[3]).toHaveBeenCalledWith("custom-1"); + expect(stateMock.setters[4]).toHaveBeenCalledWith("🧩 Custom"); + expect(stateMock.setters[5]).toHaveBeenCalledWith(""); - mocks.captures.typeMenus[1].onChange({ groupType: "select" }); + mocks.captures.typeMenus[0].onChange({ groupType: "select" }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { groupType: "select", strategy: undefined, }); }); + it("ignores blank custom group rename commits", () => { + renderPanel({ 0: new Set(["custom-1"]), 3: "custom-1", 4: " ", 5: "desc" }); + + const renameInput = mocks.captures.inputs.find((props: any) => props.autoFocus); + renameInput.onKeyDown({ key: "Enter" }); + + expect(mocks.store.updateCustomProxyGroup).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + it("handles custom rule-set move no-op and missing-target paths", () => { renderPanel({ 0: new Set(["custom-1"]) }); mocks.captures.moveMenus[0].onMove({ kind: "custom", id: "custom-1", name: "🧩 Custom" }); - 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,22 +393,20 @@ 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: " " }]; - renderPanel({ 1: "Unique" }); + renderPanel({ 1: { emoji: "🧩", name: "Unique" } }); mocks.captures.buttons.find((props: any) => props.title === "新增").onClick(); expect(mocks.store.addCustomProxyGroup).toHaveBeenCalledWith({ name: "🧩 Unique", emoji: "🧩", + description: "", groupType: "select", - 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..8c7b384 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx @@ -1,14 +1,15 @@ "use client"; import * as React from "react"; -import { Check, ChevronDown, ChevronRight, Pencil, SlidersHorizontal, Trash2, X } from "lucide-react"; +import { Check, Trash2 } from "lucide-react"; import { Button } from "@subboost/ui/components/ui/button"; +import { Input } from "@subboost/ui/components/ui/input"; import { toast } from "@subboost/ui/components/ui/toaster"; -import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; +import { PROXY_GROUP_MODULES, type ProxyGroupModule } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import { 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 { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; +import { DEFAULT_LOAD_BALANCE_STRATEGY, type LoadBalanceStrategy, type ProxyGroupGroupType } from "@subboost/core/types/config"; +import { useConfigStore } from "@subboost/ui/store/config-store"; import { useProductInteractionAdapter } from "@subboost/ui/product/interactions"; import { buildManualRuleTargets, @@ -24,49 +25,52 @@ import { } from "./proxy-group-rule-row"; import { ProxyGroupTypeMenu, - getLoadBalanceStrategyLabel, - getProxyGroupTypeLabel, type ProxyGroupTypeMenuValue, } from "./proxy-group-type-menu"; +import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; import { buildProxyGroupName, parseProxyGroupNameDraft, + pickRandomEmoji, ProxyGroupNameEditor, toProxyGroupNameDraft, type ProxyGroupNameDraft, } from "./proxy-group-name-editor"; +import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; -export function ProxyGroupsCustomGroupsPanel() { +export function ProxyGroupsCustomGroupsPanel({ + advancedMode = false, + nodeCounts, +}: { + advancedMode?: boolean; + nodeCounts?: Map; +}) { const { enabledProxyGroups, hiddenProxyGroups, - proxyGroupNameOverrides, - customRules, - customProxyGroups, + proxyGroupNameOverrides = {}, + customRules = [], + customRuleSets = [], + customProxyGroups = [], addCustomProxyGroup, removeCustomProxyGroup, updateCustomProxyGroup, updateCustomRule, removeCustomRule, - toggleProxyGroup, - addModuleRules, - filteredProxyGroups, - dialerProxyGroups, + moveModuleRule, + removeModuleRule, + dialerProxyGroups = [], } = useConfigStore(); const [expandedCustomGroups, setExpandedCustomGroups] = React.useState>(new Set()); - const [newCustomGroupDraft, setNewCustomGroupDraft] = React.useState({ - emoji: "🧩", + const [newCustomGroupDraft, setNewCustomGroupDraft] = React.useState(() => ({ + emoji: pickRandomEmoji(), name: "", - }); - const [newCustomGroupType, setNewCustomGroupType] = React.useState("select"); - const [newCustomGroupStrategy, setNewCustomGroupStrategy] = - React.useState(DEFAULT_LOAD_BALANCE_STRATEGY); + })); + const [newCustomGroupDescription, setNewCustomGroupDescription] = React.useState(""); const [editingCustomGroupId, setEditingCustomGroupId] = React.useState(null); - const [editingCustomGroupDraft, setEditingCustomGroupDraft] = React.useState({ - emoji: "🧩", - name: "", - }); + const [editingCustomGroupName, setEditingCustomGroupName] = React.useState(""); + const [editingCustomGroupDescription, setEditingCustomGroupDescription] = React.useState(""); const interactions = useProductInteractionAdapter(); const getAllGroupNamesForUniqCheck = React.useCallback(() => { @@ -77,18 +81,23 @@ export function ProxyGroupsCustomGroupsPanel() { for (const g of customProxyGroups) { names.push(g.name); } - for (const g of filteredProxyGroups) { - if (!g || !g.enabled) continue; - const name = typeof g.name === "string" ? g.name.trim() : ""; - if (!name) continue; - names.push(name); - } for (const g of dialerProxyGroups) { const name = g && typeof g.name === "string" ? g.name.trim() : ""; if (name) names.push(name); } return names; - }, [customProxyGroups, dialerProxyGroups, filteredProxyGroups, proxyGroupNameOverrides]); + }, [customProxyGroups, dialerProxyGroups, proxyGroupNameOverrides]); + + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); const manualRuleTargets = React.useMemo( () => @@ -96,10 +105,9 @@ export function ProxyGroupsCustomGroupsPanel() { enabledProxyGroups, hiddenProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, }), - [customProxyGroups, enabledProxyGroups, filteredProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], + [customProxyGroups, enabledProxyGroups, hiddenProxyGroups, proxyGroupNameOverrides], ); const ruleSetMoveTargets = React.useMemo(() => { @@ -110,7 +118,7 @@ export function ProxyGroupsCustomGroupsPanel() { id: module.id, name: resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), })), - ...customProxyGroups.map((group) => ({ + ...customProxyGroups.filter((group) => group.enabled !== false).map((group) => ({ kind: "custom" as const, id: group.id, name: group.name, @@ -131,13 +139,33 @@ 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 && + resolveProxyGroupTargetName(rule.target, { + moduleNames, + customProxyGroups: state.customProxyGroups, + }) === sourceGroup.name, + ) + : null; if (!sourceGroup || !sourceRule) return; if (target.kind === "custom") { - const targetGroup = state.customProxyGroups.find((group) => group.id === target.id); + const targetGroup = state.customProxyGroups.find((group) => group && group.id === target.id); if (!targetGroup) return; - if (targetGroup.rules.some((rule) => rule.id === sourceRule.id)) { + const targetName = targetGroup.name.trim(); + if (!targetName) return; + if ( + state.customRuleSets.some( + (rule) => + rule.id === sourceRule.id && + resolveProxyGroupTargetName(rule.target, { + moduleNames, + customProxyGroups: state.customProxyGroups, + }) === targetName, + ) + ) { toast({ title: "规则集已存在", description: "目标分流组里已经有同名规则集,请先移除重复项。", @@ -145,54 +173,28 @@ 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], + [moduleNames, moveModuleRule], ); return (
{/* 新建自定义分组 */} -
- -
- { - setNewCustomGroupType(groupType as CustomProxyGroup["groupType"]); - if (groupType === "load-balance") { - setNewCustomGroupStrategy(strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY); - } - }} - triggerClassName="h-7 text-[10px]" +
+
+ + setNewCustomGroupDescription(event.target.value)} + placeholder="描述文本(默认: 自定义代理组)" + className="h-7 min-w-0 border-white/10 bg-white/5 text-xs" />
- -
- ) : ( - <> -
-
- {group.name} -
+ const rulesContent = + totalRules === 0 ? null : ( + <> + {groupRuleSets.map((r) => ( + + { + if (isRuleSetMoveTarget(target)) { + moveCustomGroupRuleSet(group.id, r.id, target); + } + }} + /> -
-
- {`${totalRules} 规则 · ${typeLabel}`} -
- - )} -
- - {!isEditing && ( -
-
e.stopPropagation()}> - - updateCustomProxyGroup(group.id, { - groupType: groupType as CustomProxyGroup["groupType"], - ...(groupType === "load-balance" - ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : { strategy: undefined }), - }) - } - contentAlign="end" - trigger={ - - } - /> -
- -
- )} -
+ + } + /> + ))} + {manualRules.map((item) => ( + removeCustomRule(index)} + /> + ))} + + ); + const cardModule: ProxyGroupModule = { + id: group.id, + name: group.name, + emoji: group.emoji || "🧩", + category: "other", + description, + groupType: group.groupType, + rules: [], + }; - {isExpanded && ( -
- {totalRules === 0 ? ( -

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

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

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

+ ) : ( +
{rulesContent}
+ ) + } + rulesCountOverride={totalRules} + advancedMode={advancedMode} + nodeCount={nodeCount} + renderAdvancedContent={(content, count) => ( + + updateCustomProxyGroup(group.id, { + advanced: { ...(group.advanced || {}), ...patch }, + }) + } + rulesCount={count} + rulesContent={count > 0 ? content : null} + /> )} -
+ /> ); })}
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx index 2ef808a..587d4c5 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-routing-rules.tsx @@ -7,7 +7,9 @@ import { ProxyGroupsRulesLibrary } from "./proxy-groups-rules-library"; export function ProxyGroupsCustomRoutingRules() { return (
- +
+ +
diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx index bfaf4ec..5147d6c 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules-batch-dialog.tsx @@ -34,6 +34,11 @@ function getStatusTextClass(item: CustomRuleBatchImportPreviewItem): string { return "text-red-200"; } +function ruleTargetToText(target: CustomRule["target"]): string { + if (typeof target === "string") return target; + return `${target.kind}:${target.id}`; +} + export function ProxyGroupsCustomRulesBatchDialog({ open, onOpenChange, @@ -219,9 +224,9 @@ export function ProxyGroupsCustomRulesBatchDialog({ - {item.rule.target} + {ruleTargetToText(item.rule.target)}
) : ( diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts index 8cd37d5..a40f352 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.test.ts @@ -211,12 +211,6 @@ describe("ProxyGroupsCustomRules", () => { removeCustomRule: vi.fn(), enabledProxyGroups: ["auto"], customProxyGroups: [{ id: "custom-1", name: "Custom Group", rules: [] }], - filteredProxyGroups: [ - { name: " Filter Group ", enabled: true }, - { name: "Disabled", enabled: false }, - { name: " ", enabled: true }, - { name: 123, enabled: true }, - ], proxyGroupNameOverrides: { auto: "节点选择" }, }; }); @@ -278,7 +272,6 @@ describe("ProxyGroupsCustomRules", () => { "REJECT", "节点选择", "Custom Group", - "Filter Group", "Legacy Target", ], existingRules: [], @@ -435,7 +428,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 === "添加规则") @@ -452,24 +445,8 @@ describe("ProxyGroupsCustomRules", () => { "REJECT", "节点选择", "Custom Group", - "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..9720d92 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-rules.tsx @@ -14,6 +14,7 @@ import { import { Switch } from "@subboost/ui/components/ui/switch"; import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { createCustomRuleId, CUSTOM_RULE_TYPES, @@ -48,7 +49,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 +62,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) => ({ @@ -98,7 +97,6 @@ export function ProxyGroupsCustomRules() { removeCustomRule, enabledProxyGroups, customProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, } = useConfigStore(); @@ -124,20 +122,33 @@ export function ProxyGroupsCustomRules() { for (const g of customProxyGroups) { names.push(g.name); } - for (const g of filteredProxyGroups) { - if (!g || !g.enabled) continue; - const name = typeof g.name === "string" ? g.name.trim() : ""; - if (!name) continue; - names.push(name); - } return names; }, [ customProxyGroups, enabledProxyGroups, - filteredProxyGroups, proxyGroupNameOverrides, ]); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveProxyGroupModuleName(module, proxyGroupNameOverrides?.[module.id]), + ]), + ), + [proxyGroupNameOverrides], + ); + + const resolveTargetName = React.useCallback( + (target: CustomRule["target"]) => + resolveProxyGroupTargetName(target, { + moduleNames, + customProxyGroups, + }), + [customProxyGroups, moduleNames], + ); + const targetOptions = React.useMemo( () => getTargetOptions(enabledGroupNames), [enabledGroupNames], @@ -181,7 +192,11 @@ export function ProxyGroupsCustomRules() { const startEditingRule = (rule: CustomRule) => { setEditingRuleId(rule.id); - setEditingRuleDraft({ ...rule, noResolve: Boolean(rule.noResolve) }); + setEditingRuleDraft({ + ...rule, + target: resolveTargetName(rule.target), + noResolve: Boolean(rule.noResolve), + }); }; const cancelEditingRule = () => { @@ -313,7 +328,7 @@ export function ProxyGroupsCustomRules() { if (isEditing) { const editTargetOptions = getTargetOptions( enabledGroupNames, - editingRuleDraft.target, + resolveTargetName(editingRuleDraft.target), ); return ( @@ -366,7 +381,7 @@ export function ProxyGroupsCustomRules() {
onChangeEditingDescription(event.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onCommitEditing(); + if (e.key === "Escape") onCancelEditing(); + }} + /> + )} +
+ -
@@ -275,6 +365,26 @@ export function ProxyGroupsModuleCard({ onCheckedChange={onToggleEnabled} onClick={(e) => e.stopPropagation()} /> + {onChangeGroupType && ( + + + + } + /> + )}
); } diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.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..41e1af8 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-rules-panel.tsx @@ -13,13 +13,13 @@ import { getEffectiveModuleRuleItems, getExcludedModuleRuleIds, isModuleRuleMovedFrom, - type ModuleRuleExclusions, + 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, @@ -46,6 +46,10 @@ type CnCandidateRule = { parentModuleId?: string; }; +function isModuleRuleMoveTarget(target: ProxyGroupRuleTarget): target is ProxyGroupRuleTarget & MoveTarget { + return target.kind === "module" || target.kind === "custom"; +} + function isCnCandidateRule(value: unknown): value is CnCandidateRule { if (!value || typeof value !== "object") return false; const item = value as Record; @@ -77,8 +81,8 @@ export function ProxyGroupsModuleRulesPanel({ module, enabledProxyGroups, hiddenProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + ruleSetsByTarget, + hiddenPresetRuleIds, customProxyGroups, manualRules, manualRuleTargets, @@ -93,6 +97,7 @@ export function ProxyGroupsModuleRulesPanel({ onMoveManualRule, onRemoveManualRule, onRestoreRule, + onResetRuleTarget, cnIpNoResolve, onChangeCnIpNoResolve, experimentalCnUseCnRuleSet, @@ -101,22 +106,23 @@ export function ProxyGroupsModuleRulesPanel({ module: ProxyGroupModule; enabledProxyGroups: string[]; hiddenProxyGroups: string[]; - moduleRuleOverrides: Record; - moduleRuleExclusions: ModuleRuleExclusions; + ruleSetsByTarget: Record; + hiddenPresetRuleIds: HiddenPresetRuleIds; 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; onRemoveManualRule: (index: number) => void; onRestoreRule: (ruleId: string) => void; + onResetRuleTarget: (ruleId: string) => void; cnIpNoResolve: boolean; onChangeCnIpNoResolve: (value: boolean) => void; experimentalCnUseCnRuleSet: boolean; @@ -127,9 +133,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 +145,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) ? "moved" as const : "removed" as const, })); - }, [customProxyGroups, module, moduleRuleExclusions, moduleRuleOverrides]); + }, [module, hiddenPresetRuleIds, ruleSetsByTarget]); const rules = React.useMemo( () => [ @@ -189,7 +195,7 @@ export function ProxyGroupsModuleRulesPanel({ id: targetModule.id, name: resolveProxyGroupModuleName(targetModule, proxyGroupNameOverrides?.[targetModule.id]), })), - ...customProxyGroups.map((group) => ({ + ...customProxyGroups.filter((group) => group.enabled !== false).map((group) => ({ kind: "custom" as const, id: group.id, name: group.name, @@ -207,7 +213,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 +234,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; @@ -263,7 +269,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, @@ -280,13 +286,13 @@ export function ProxyGroupsModuleRulesPanel({ ); return ( -
+
{rules.length === 0 && manualRules.length === 0 ? (
当前没有生效的规则集。
) : ( -
+
{rules.map((rule) => { if (rule.state !== "active") { const isCnIpRule = module.id === "cn" && rule.id === "cn-ip"; @@ -310,9 +316,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) + } > @@ -347,7 +357,7 @@ export function ProxyGroupsModuleRulesPanel({ kinds={["module", "custom"]} currentTarget={{ kind: "module", id: module.id, name: moduleDisplayName }} onMove={(target) => { - if (isRuleSetMoveTarget(target)) { + if (isRuleSetMoveTarget(target) && isModuleRuleMoveTarget(target)) { void moveRule(rule, target); } }} @@ -384,11 +394,11 @@ export function ProxyGroupsModuleRulesPanel({ )} {module.id === "cn" && ( -
+
@@ -441,7 +451,7 @@ export function ProxyGroupsModuleRulesPanel({ kinds={["module", "custom"]} currentTarget={{ kind: "module", id: module.id, name: moduleDisplayName }} onMove={(target) => { - if (isRuleSetMoveTarget(target)) { + if (isRuleSetMoveTarget(target) && isModuleRuleMoveTarget(target)) { moveExperimentalCnRule(target); } }} @@ -477,8 +487,8 @@ export function ProxyGroupsModuleRulesPanel({ )} {module.id === "cn" && availableCnCandidateRules.length > 0 && ( -
-
+
+
中国相关子规则集
@@ -515,7 +525,7 @@ export function ProxyGroupsModuleRulesPanel({
-
+
{availableCnCandidateRules.map((rule) => (
({ })); 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: [] }, + { id: "bare", name: "Bare" }, + ], })); 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", () => ({ @@ -151,6 +153,7 @@ vi.mock("./proxy-groups-rules-search", () => ({ })); import { ProxyGroupsRulesLibrary } from "./proxy-groups-rules-library"; +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; const netflixRule = { id: "netflix", @@ -203,6 +206,9 @@ describe("ProxyGroupsRulesLibrary", () => { beforeEach(() => { vi.clearAllMocks(); mocks.captures = { addedRuleSets: [], badges: [], buttons: [], inputs: [], nativeButtons: [], nativeDivs: [], selects: [] }; + (PROXY_GROUP_MODULES[0] as any).rules = [{ id: "netflix" }]; + (PROXY_GROUP_MODULES[1] as any).rules = []; + (PROXY_GROUP_MODULES[2] as any).rules = undefined; mocks.effectiveRulesByModule = {}; mocks.search = { ruleSearchKeyword: "netflix", @@ -222,10 +228,13 @@ 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" }, + { id: "custom-2", name: "Target" }, + ], updateCustomProxyGroup: vi.fn(), proxyGroupNameOverrides: { auto: "Auto", fallback: "Fallback" }, }; @@ -271,23 +280,31 @@ 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("属于"); + (PROXY_GROUP_MODULES[0] as any).rules = [{ id: "telegram" }]; + mocks.search.searchResults = [telegramRule]; + result = renderLibrary(); + expect(result.html).toContain("IP"); + mocks.store.enabledProxyGroups = []; + mocks.search.searchResults = [netflixRule, telegramRule]; + (PROXY_GROUP_MODULES[0] as any).rules = [{ id: "netflix" }]; renderLibrary(); mocks.captures.buttons.find((props) => props.children === "开启代理组").onClick(); expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("auto"); - 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("已添加"); + + mocks.store.customRuleSets = [{ id: "telegram", name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs", target: "Target" }]; + result = renderLibrary(); + expect(result.html).toContain("Target"); + expect(result.html).toContain("已添加"); }); it("adds selected rules to a custom group", () => { @@ -297,25 +314,45 @@ 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([]); }); + it("adds selected rules to another custom group", () => { + const { html } = renderLibrary({ 0: [telegramRule], 1: "custom:custom-2" }); + expect(html).toContain("自定义组"); + expect(html).toContain("Target"); + + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-2", [ + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + noResolve: true, + }, + ]); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "已添加规则集", + description: expect.stringContaining("Target"), + })); + }); + it("adds valid selected rules to a module and reports skipped invalid rules", () => { mocks.store.enabledProxyGroups = []; - const { html } = renderLibrary({ 0: [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 +360,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 +374,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 +382,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({ @@ -359,6 +392,10 @@ describe("ProxyGroupsRulesLibrary", () => { }); it("clears hidden module targets and toggles unassigned rule selection", () => { + const placeholderResult = renderLibrary({ 1: "__label_modules__" }); + stateMock.effects[0](); + expect(placeholderResult.setters[1]).not.toHaveBeenCalledWith(""); + renderLibrary({ 1: "custom:custom-1" }); stateMock.effects[0](); expect(stateMock.setters[1]).not.toHaveBeenCalledWith(""); @@ -371,12 +408,20 @@ describe("ProxyGroupsRulesLibrary", () => { stateMock.effects[0](); expect(stateMock.setters[1]).not.toHaveBeenCalledWith(""); + const filteredResult = renderLibrary({ 1: "custom:missing" }); + stateMock.effects[0](); + expect(filteredResult.setters[1]).toHaveBeenCalledWith(""); + + renderLibrary({ 1: "custom:custom-1" }); + stateMock.effects[0](); + expect(stateMock.setters[1]).not.toHaveBeenCalledWith(""); + renderLibrary(); const firstRuleDiv = mocks.captures.nativeDivs.find((props) => String(props.className).includes("cursor-pointer")); firstRuleDiv.onClick(); - 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 +479,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 +489,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(); @@ -475,4 +517,104 @@ describe("ProxyGroupsRulesLibrary", () => { variant: "warning", })); }); + + it("handles moved builtin targets and modules without preset rules", () => { + mocks.store.builtinRuleEdits = { "module:auto:netflix": { target: "Target" } }; + renderLibrary({ 0: [netflixRule], 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "规则集已在其他分流组中", + description: expect.stringContaining("Target"), + variant: "warning", + })); + + vi.clearAllMocks(); + mocks.store.builtinRuleEdits = {}; + mocks.store.enabledProxyGroups = []; + renderLibrary({ 0: [telegramRule], 1: "module:bare" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + + expect(mocks.store.toggleProxyGroup).toHaveBeenCalledWith("bare"); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("bare", [ + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + noResolve: true, + }, + ]); + }); + + it("covers empty hints, loading-more state, disabled builtin edits, and optional interactions", () => { + mocks.search.ruleSearchKeyword = ""; + mocks.search.searchResults = []; + mocks.search.totalRules = 0; + expect(renderLibrary().html).toContain("规则库"); + expect(mocks.captures.addedRuleSets[0]).toEqual({ showSearchHint: true, totalRules: 0 }); + + mocks.search.ruleSearchKeyword = "all"; + mocks.search.searchResults = [telegramRule]; + mocks.search.totalMatched = undefined; + mocks.search.canLoadMore = true; + mocks.search.rulesSearchLoadingMore = true; + expect(renderLibrary().html).toContain("显示 1"); + expect(mocks.captures.buttons.find((props) => props.onClick === mocks.search.handleLoadMore).disabled).toBe(true); + + mocks.search.rulesSearchLoadingMore = false; + mocks.search.searchResults = [netflixRule]; + mocks.store.builtinRuleEdits = { "module:auto:netflix": { enabled: false } }; + renderLibrary(); + expect(mocks.captures.nativeDivs.some((props) => String(props.className).includes("cursor-pointer"))).toBe(true); + + mocks.store.builtinRuleEdits = {}; + mocks.store.customRuleSets = [{ id: "netflix", name: "Netflix", behavior: "domain", path: "geosite/netflix.mrs", target: "Custom" }]; + expect(renderLibrary().html).toContain("域名"); + + vi.clearAllMocks(); + mocks.interactions = {}; + mocks.store.customRuleSets = []; + mocks.search.searchResults = [telegramRule]; + renderLibrary({ 0: [telegramRule], 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.store.addModuleRules).toHaveBeenCalledWith("custom-1", [ + expect.objectContaining({ id: "telegram" }), + ]); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: "已添加规则集" })); + }); + + it("summarizes long conflict lists and warns when only invalid custom rules are selected", () => { + const conflictRules = Array.from({ length: 9 }, (_, index) => ({ + ...telegramRule, + id: `conflict-${index}`, + nameZh: `Conflict ${index}`, + url: `https://raw.example/geoip/conflict-${index}.mrs`, + })); + mocks.store.customRuleSets = conflictRules.map((rule) => ({ + id: rule.id, + name: rule.nameZh, + behavior: "ipcidr", + path: `geoip/${rule.id}.mrs`, + target: "Target", + })); + mocks.search.searchResults = conflictRules; + renderLibrary({ 0: conflictRules, 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "规则集已在其他分流组中", + description: expect.stringContaining("以及 1 条"), + })); + + vi.clearAllMocks(); + mocks.store.customRuleSets = []; + const emptyUrlRule = { ...invalidRule, id: "empty-url", url: "" }; + mocks.search.searchResults = [emptyUrlRule]; + renderLibrary({ 0: [emptyUrlRule], 1: "custom:custom-1" }); + mocks.captures.buttons.find((props) => props.children === "添加").onClick(); + expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ + title: "没有新增规则集", + description: expect.stringContaining("1 条已存在"), + variant: "warning", + })); + }); }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx index a73d0c4..fd7d975 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.tsx @@ -15,26 +15,26 @@ 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 { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; +import { normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; import { RULE_CATEGORIES, type RuleSetInfo } from "@subboost/core/rules/metadata"; import { useConfigStore } from "@subboost/ui/store/config-store"; 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(); @@ -63,13 +63,33 @@ export function ProxyGroupsRulesLibrary() { const hidden = new Set(hiddenProxyGroups); return PROXY_GROUP_MODULES.filter((module) => !hidden.has(module.id)); }, [hiddenProxyGroups]); + const activeCustomProxyGroups = React.useMemo( + () => customProxyGroups.filter((group) => group.enabled !== false), + [customProxyGroups], + ); + const moduleNames = React.useMemo( + () => + Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [ + module.id, + resolveModuleFullName(module), + ]), + ), + [resolveModuleFullName], + ); React.useEffect(() => { - if (!addToGroupId.startsWith("module:")) return; - const moduleId = addToGroupId.slice("module:".length); - if (visibleProxyGroupModules.some((module) => module.id === moduleId)) return; + if (addToGroupId.startsWith("module:")) { + const moduleId = addToGroupId.slice("module:".length); + if (visibleProxyGroupModules.some((module) => module.id === moduleId)) return; + } else if (addToGroupId.startsWith("custom:")) { + const groupId = addToGroupId.slice("custom:".length); + if (activeCustomProxyGroups.some((group) => group.id === groupId)) return; + } else { + return; + } setAddToGroupId(""); - }, [addToGroupId, visibleProxyGroupModules]); + }, [activeCustomProxyGroups, addToGroupId, visibleProxyGroupModules]); return (
@@ -115,16 +135,34 @@ 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 resolvedBuiltinTargetName = builtinSourceModule + ? resolveProxyGroupTargetName(builtinTargetName, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + fallbackTarget: resolveModuleFullName(builtinSourceModule), + }) + : ""; + const belongsToModule = builtinTargetName + ? visibleProxyGroupModules.find((m) => resolveModuleFullName(m) === resolvedBuiltinTargetName) + : null; + const customRuleSet = customRuleSets.find((item) => item.id === rule.id); + const belongsToCustom = customRuleSet + ? activeCustomProxyGroups.find( + (g) => + g.name === + resolveProxyGroupTargetName(customRuleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }), + ) + : null; const isModuleEnabled = belongsToModule ? enabledProxyGroups.includes(belongsToModule.id) : false; @@ -353,7 +391,7 @@ export function ProxyGroupsRulesLibrary() { className="text-xs" disabled > - 内置代理组 + 内置组 {visibleProxyGroupModules.map((m) => ( - 自定义分组 + 自定义组 - {customProxyGroups.length === 0 ? ( + {activeCustomProxyGroups.length === 0 ? ( - 暂无自定义分组 + 暂无自定义组 ) : ( - customProxyGroups.map((g) => ( + activeCustomProxyGroups.map((g) => ( m.id === target.id) : null; if (target.kind === "module" && !targetModule) return; - const usedRuleIds = new Map(); for (const m of visibleProxyGroupModules) { const groupName = resolveModuleFullName(m); - 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 + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + fallbackTarget: groupName, + }) + : 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, + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }), + ); } } const targetDisplayName = target.kind === "custom" - ? customProxyGroups.find((g) => g.id === target.id) + ? activeCustomProxyGroups.find((g) => g.id === target.id) ?.name || "" : targetModule ? resolveModuleFullName(targetModule) @@ -479,29 +530,26 @@ export function ProxyGroupsRulesLibrary() { let skippedInvalidCount = 0; if (target.kind === "custom") { - const cg = customProxyGroups.find( - (g) => g.id === target.id, - ); - if (!cg) return; - const existing = new Set(cg.rules.map((r) => r.id)); + const group = activeCustomProxyGroups.find((g) => g.id === target.id); + if (!group) return; + const existing = new Set(customRuleSets.map((r) => r.id)); const rulesToAdd = selectedRules .filter((r) => !existing.has(r.id)) - .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(group.id, rulesToAdd); addedCount = rulesToAdd.length; } } else { @@ -509,11 +557,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/proxy-groups-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-section.test.ts new file mode 100644 index 0000000..0ac3f23 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-section.test.ts @@ -0,0 +1,83 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + store: {} as Record, + headerProps: undefined as Record | undefined, +})); + +vi.mock("lucide-react", () => ({ + Layers: () => React.createElement("span", null, "layers-icon"), +})); + +vi.mock("@subboost/core/generator/proxy-groups", () => ({ + PROXY_GROUP_MODULES: [ + { id: "select" }, + { id: "auto" }, + { id: "cn" }, + ], +})); + +vi.mock("@subboost/ui/components/ui/badge", () => ({ + Badge: (props: any) => React.createElement("span", props, props.children), +})); + +vi.mock("@subboost/ui/store/config-store", () => ({ + useConfigStore: () => mocks.store, +})); + +vi.mock("../section-header", () => ({ + SectionHeader: (props: any) => { + mocks.headerProps = props; + return React.createElement("button", { onClick: props.onToggle }, props.title, props.badge); + }, +})); + +vi.mock("./proxy-groups-categories", () => ({ + ProxyGroupsCategories: () => React.createElement("div", null, "categories-content"), +})); + +import { ProxyGroupsSection } from "./proxy-groups-section"; + +describe("ProxyGroupsSection", () => { + beforeEach(() => { + mocks.store = { + enabledProxyGroups: ["select", "auto", "cn"], + hiddenProxyGroups: ["cn"], + }; + mocks.headerProps = undefined; + }); + + it("renders enabled/visible group counts and expanded content", () => { + const onToggle = vi.fn(); + const html = renderToStaticMarkup( + React.createElement(ProxyGroupsSection, { + isExpanded: true, + onToggle, + }), + ); + + expect(html).toContain("分流代理组"); + expect(html).toContain("2/2"); + expect(html).toContain("categories-content"); + expect(mocks.headerProps).toMatchObject({ + isExpanded: true, + title: "分流代理组", + }); + (mocks.headerProps?.onToggle as () => void)(); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("omits categories while collapsed", () => { + const html = renderToStaticMarkup( + React.createElement(ProxyGroupsSection, { + isExpanded: false, + onToggle: vi.fn(), + }), + ); + + expect(html).not.toContain("categories-content"); + expect(mocks.headerProps).toMatchObject({ isExpanded: false }); + }); +}); diff --git a/packages/ui/src/product/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..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", () => ({ @@ -85,15 +87,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"], + 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) => @@ -242,23 +242,80 @@ describe("RulesManagementSection", () => { ]); }); + 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: false, + 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", + }, + ]; + + const text = collectText(renderSection()); + + expect(text).toContain("自定义规则"); + expect(text).toContain("自定义规则集"); + expect(text).toContain("📚 教育学术"); + expect(text).toContain("💬 自定义1"); + 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(); + expect(mocks.store.setRuleOrder).not.toHaveBeenCalled(); mocks.confirmDialog.mockResolvedValueOnce(true); await switchElement.props.onCheckedChange(true); - expect(mocks.store.setAllRulesOrderEditingEnabled).toHaveBeenCalledWith(true); + expect(mocks.confirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: "开启“调整所有规则顺序”?", + cancelText: "保持默认", + confirmText: "继续开启", + variant: "warning", + }) + ); + 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", () => { @@ -294,15 +351,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..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"; @@ -42,28 +43,24 @@ 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 allRulesMode = hasFullRuleOrderKeys(ruleOrder); const entries = React.useMemo( () => buildGeneratedRuleEntries({ enabledModules: enabledProxyGroups, customRules, - customProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, proxyGroupNameOverrides, cnIpNoResolve, experimentalCnUseCnRuleSet, @@ -71,23 +68,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 editableEntries = React.useMemo(() => entries.filter((entry) => entry.editable), [entries]); const editableKeys = React.useMemo(() => editableEntries.map((entry) => entry.key), [editableEntries]); const applyRuleOrder = React.useCallback( @@ -135,7 +131,7 @@ export function RulesManagementSection({ const handleToggleAllRulesMode = React.useCallback( async (checked: boolean) => { if (!checked) { - setAllRulesOrderEditingEnabled(false); + applyRuleOrder(editableKeys); return; } @@ -157,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/converter/quick-mode/sources-section.tsx b/packages/ui/src/product/converter/quick-mode/sources-section.tsx index 2dd0fd7..c294d8f 100644 --- a/packages/ui/src/product/converter/quick-mode/sources-section.tsx +++ b/packages/ui/src/product/converter/quick-mode/sources-section.tsx @@ -562,7 +562,7 @@ export function SourcesSection() {
注意开启后:
  • 无法在预览中查看/管理该 url 的节点
  • -
  • 无法将这些节点用于中转代理组、筛选代理组等高级功能
  • +
  • 无法将这些节点用于中转代理组、分流组高级模式等高级功能
  • 节点命名模板与 tag 在该模式下不生效
diff --git a/packages/ui/src/product/home/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..87d54d3 100644 --- a/packages/ui/src/product/home/use-editing-subscription-loader.test.ts +++ b/packages/ui/src/product/home/use-editing-subscription-loader.test.ts @@ -108,12 +108,12 @@ function resetStoreState(overrides: Record = {}) { generateConfig, sources: [], enabledProxyGroups: ["select", "auto", "ai"], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, experimentalCnUseCnRuleSet: false, cnIpNoResolve: true, - filteredProxyGroups: [], + proxyGroupAdvanced: {}, proxyGroupOrder: [], ruleOrder: [], ...overrides, @@ -211,14 +211,18 @@ 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: [] }], - 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", ""] }, + 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", + }, + ], + 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", ""], @@ -262,10 +266,21 @@ describe("useEditingSubscriptionLoader", () => { template: "full", enabledProxyGroups: ["select", "auto", "ai"], hiddenProxyGroups: ["youtube"], - filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], - moduleRuleExclusions: { ai: ["openai"] }, + customProxyGroups: [ + { id: "custom-1", name: "Custom", emoji: "", groupType: "select", advanced: {} }, + ], + proxyGroupAdvancedModeEnabled: true, + 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, 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..5a615c7 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,11 @@ 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 { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import { hasSubscriptionUserInfo, @@ -411,18 +413,18 @@ 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 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 ruleModelFromCfg = normalizeRuleModelFromConfig(cfg); + const customProxyGroupsFromCfg = ruleModelFromCfg.customProxyGroups; + const customRuleSetsFromCfg = ruleModelFromCfg.customRuleSets; + const builtinRuleEditsFromCfg = ruleModelFromCfg.builtinRuleEdits; + const proxyGroupAdvancedFromCfg = + cfg.proxyGroupAdvanced && typeof cfg.proxyGroupAdvanced === "object" && !Array.isArray(cfg.proxyGroupAdvanced) + ? Object.fromEntries( + Object.entries(cfg.proxyGroupAdvanced as Record) + .map(([id, value]) => [id.trim(), normalizeProxyGroupAdvancedConfig(value)] as const) + .filter(([id, advanced]) => id && Object.keys(advanced).length > 0), + ) + : {}; const dialerProxyGroupsFromCfg = Array.isArray(cfg.dialerProxyGroups) ? (cfg.dialerProxyGroups as any[]) : []; const proxyGroupNameOverridesFromCfg = cfg.proxyGroupNameOverrides && typeof cfg.proxyGroupNameOverrides === "object" @@ -442,40 +444,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 +469,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 {}; @@ -514,6 +486,11 @@ export function useEditingSubscriptionLoader({ typeof cfg.appliedTemplateId === "string" && cfg.appliedTemplateId.trim() ? (cfg.appliedTemplateId as string) : null; + const proxyGroupAdvancedModeEnabledFromCfg = resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: (cfg as any).proxyGroupAdvancedModeEnabled, + customProxyGroups: customProxyGroupsFromCfg, + proxyGroupAdvanced: proxyGroupAdvancedFromCfg, + }); // 重置后批量写入,避免中间状态触发重复生成 useConfigStore.getState().reset(); @@ -538,14 +515,14 @@ export function useEditingSubscriptionLoader({ hiddenProxyGroups: hiddenProxyGroupsFromCfg, customRules: customRulesFromCfg as any, customProxyGroups: customProxyGroupsFromCfg as any, - filteredProxyGroups: filteredProxyGroupsFromCfg as any, - moduleRuleOverrides: normalizedModuleRuleOverridesFromCfg ?? state.moduleRuleOverrides, - moduleRuleExclusions: moduleRuleExclusionsFromCfg ?? state.moduleRuleExclusions, + customRuleSets: customRuleSetsFromCfg, + builtinRuleEdits: builtinRuleEditsFromCfg, + proxyGroupAdvanced: proxyGroupAdvancedFromCfg, + proxyGroupAdvancedModeEnabled: proxyGroupAdvancedModeEnabledFromCfg, 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.test.ts b/packages/ui/src/product/home/use-subscription-link.test.ts index c79bb9c..e91fb21 100644 --- a/packages/ui/src/product/home/use-subscription-link.test.ts +++ b/packages/ui/src/product/home/use-subscription-link.test.ts @@ -103,7 +103,8 @@ describe("useSubscriptionLink", () => { vi.clearAllMocks(); resetHookState(); mocks.bag.storeState = { - filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], + proxyGroupAdvanced: { auto: { includeRegex: "Fast" } }, + proxyGroupAdvancedModeEnabled: true, proxyGroupOrder: ["select", "auto"], }; originalWindow = globalThis.window; @@ -255,7 +256,8 @@ describe("useSubscriptionLink", () => { subscriptionUserInfo: { upload: 2_048, download: 1_024, total: 4_096 }, }), ], - filteredProxyGroups: [{ id: "fg-1", name: "Fast", enabled: true, groupType: "select" }], + proxyGroupAdvanced: { auto: { includeRegex: "Fast" } }, + proxyGroupAdvancedModeEnabled: true, listenerPorts: { "Node A": 41000 }, proxyGroupOrder: ["select", "auto"], }), diff --git a/packages/ui/src/product/home/use-subscription-link.tsx b/packages/ui/src/product/home/use-subscription-link.tsx index 4333f52..8d0822e 100644 --- a/packages/ui/src/product/home/use-subscription-link.tsx +++ b/packages/ui/src/product/home/use-subscription-link.tsx @@ -7,14 +7,11 @@ 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"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; import { hasSubscriptionUserInfo, mergeSubscriptionUserInfo, @@ -79,10 +76,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 +110,9 @@ export function useSubscriptionLink({ hiddenProxyGroups, customRules, customProxyGroups, + customRuleSets, + builtinRuleEdits, ruleOrder, - allRulesOrderEditingEnabled, - moduleRuleOverrides, - moduleRuleExclusions, moduleRuleEditWarningAccepted, dialerProxyGroups, proxyGroupNameOverrides, @@ -383,11 +378,11 @@ export function useSubscriptionLink({ hiddenProxyGroups, customRules, ruleOrder, - allRulesOrderEditingEnabled, customProxyGroups, - filteredProxyGroups: useConfigStore.getState().filteredProxyGroups as FilteredProxyGroup[], - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, + proxyGroupAdvanced: useConfigStore.getState().proxyGroupAdvanced, + proxyGroupAdvancedModeEnabled: Boolean(useConfigStore.getState().proxyGroupAdvancedModeEnabled), 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..da37c11 100644 --- a/packages/ui/src/product/preview/visual-graph.test.ts +++ b/packages/ui/src/product/preview/visual-graph.test.ts @@ -71,14 +71,29 @@ 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", + rules: [{ id: "sel", name: "Select Rule", behavior: "classical" }], + }, + { + 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 +162,9 @@ 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" }] }], - filteredProxyGroups: [{ id: "filtered-1", name: "🧩 Filtered", emoji: "", enabled: true }], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customProxyGroups: [{ id: "custom-1", name: "🧩 Custom", emoji: "🧩", groupType: "load-balance", strategy: "round-robin" }], + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, proxyGroupOrder: ["dialer:d1", "module:auto", "missing", "module:select", "dialer:d1"], testUrl: "https://example.com", @@ -185,7 +199,7 @@ describe("VisualGraph", () => { "module:auto", "module:select", "custom:custom-1", - "filtered:filtered-1", + "name:🧩 Filtered", "name:External", ]); expect(preview.displayGroups.find((group: any) => group.id === "module:auto").rules).toEqual([ @@ -205,6 +219,52 @@ describe("VisualGraph", () => { expect(mocks.store.setProxyGroupOrder).toHaveBeenCalledWith(["module:auto"]); }); + it("uses selector defaults when optional store slices are absent", () => { + mocks.generatedProxyGroups = [{ name: "🚀 节点选择", type: "select", proxies: ["DIRECT"] }]; + mocks.customRuleSets = []; + mocks.store = { + nodes: [{ name: "Only", type: "ss" }], + enabledProxyGroups: ["select"], + testUrl: "https://example.com", + testInterval: 300, + ruleProviderBaseUrl: "https://rules.example", + setProxyGroupOrder: vi.fn(), + }; + + const { html } = renderGraph(); + + expect(html).toContain("1"); + expect(mocks.captures.proxyGroupsPreview.displayGroups).toEqual([ + expect.objectContaining({ id: "module:select", name: "🚀 节点选择" }), + ]); + expect(mocks.captures.customRulesPreview).toEqual({ + customRules: [], + ruleSets: [], + }); + }); + + it("shows builtin rules moved from another module into the target module", () => { + mocks.generatedProxyGroups = [{ name: "🚀 节点选择", type: "select", proxies: ["DIRECT"] }]; + mocks.store = { + ...mocks.store, + builtinRuleEdits: { + "module:auto:r1": { target: "🚀 节点选择" }, + "module:auto:missing": { target: "🚀 节点选择" }, + "module:missing:r1": { target: "🚀 节点选择" }, + "module:select:sel": { target: "🚀 节点选择" }, + "not-module": { target: "🚀 节点选择" }, + }, + proxyGroupOrder: [], + }; + + renderGraph(); + + expect(mocks.captures.proxyGroupsPreview.displayGroups[0].rules).toEqual([ + { id: "sel", name: "Select Rule", behavior: "classical" }, + { id: "r1", name: "Rule One", behavior: "domain" }, + ]); + }); + it("falls back to generated order and truncates long node lists", () => { mocks.store.proxyGroupOrder = []; mocks.store.dialerProxyGroups = [{ id: "d2", name: "Disabled", enabled: false }]; @@ -215,7 +275,7 @@ describe("VisualGraph", () => { "module:select", "module:auto", "custom:custom-1", - "filtered:filtered-1", + "name:🧩 Filtered", "name:External", ]); expect(html).toContain("还有 2 个节点"); @@ -292,20 +352,16 @@ describe("VisualGraph", () => { null, { id: "bad-type", name: 123 }, { id: "bad-name", name: " " }, + { id: "emoji", name: "Emoji Filter", emoji: "⭐", groupType: "select" }, { id: "plain", name: "Plain Custom", emoji: "", groupType: "", strategy: "", rules: [] }, ]; - mocks.store.filteredProxyGroups = [ - null, - { id: "disabled", name: "Disabled", enabled: false }, - { id: "emoji", name: "Emoji Filter", emoji: "⭐", enabled: true }, - ]; - mocks.store.proxyGroupOrder = ["name:", "filtered:emoji", "custom:plain", "dialer:d3"]; + mocks.store.proxyGroupOrder = ["name:", "custom:emoji", "custom:plain", "dialer:d3"]; const { html } = renderGraph({ 3: 800 }); const groups = mocks.captures.proxyGroupsPreview.displayGroups; - expect(groups.map((group: any) => group.id)).toEqual(["name:", "filtered:emoji", "custom:plain", "dialer:d3"]); - expect(groups.find((group: any) => group.id === "filtered:emoji")).toMatchObject({ emoji: "⭐" }); + expect(groups.map((group: any) => group.id)).toEqual(["name:", "custom:emoji", "custom:plain", "dialer:d3"]); + expect(groups.find((group: any) => group.id === "custom:emoji")).toMatchObject({ emoji: "⭐" }); expect(groups.find((group: any) => group.id === "dialer:d3").dialer).toMatchObject({ relayNodes: [], targetNodes: [], @@ -314,4 +370,19 @@ describe("VisualGraph", () => { expect(html).toContain("bg-teal-400"); expect(html).toContain("bg-gray-400"); }); + + it("merges built-in rule edits moved from another module and skips invalid edit records", () => { + mocks.store.builtinRuleEdits = { + "module:auto:r1": { enabled: false }, + "module:select:sel": { target: "⚡ 自动选择" }, + "module:select:missing": { target: "⚡ 自动选择" }, + "module:missing:ghost": { target: "⚡ 自动选择" }, + "not-a-module-key": { target: "⚡ 自动选择" }, + }; + + renderGraph({ 3: 800 }); + const auto = mocks.captures.proxyGroupsPreview.displayGroups.find((group: any) => group.id === "module:auto"); + + expect(auto.rules).toEqual([{ id: "sel", name: "Select Rule", behavior: "classical" }]); + }); }); diff --git a/packages/ui/src/product/preview/visual-graph.tsx b/packages/ui/src/product/preview/visual-graph.tsx index 11582fc..029796d 100644 --- a/packages/ui/src/product/preview/visual-graph.tsx +++ b/packages/ui/src/product/preview/visual-graph.tsx @@ -10,8 +10,9 @@ 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 { resolveProxyGroupTargetName } from "@subboost/core/proxy-group-targets"; import { collectCustomRoutingRuleSets } from "@subboost/core/rules/custom-routing-rule-sets"; import { CustomRulesPreview } from "./visual-graph/custom-rules-preview"; import { getDialerEmojiFromName } from "./visual-graph/emoji"; @@ -31,9 +32,9 @@ export function VisualGraph() { dialerProxyGroups, customRules, customProxyGroups, - filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, proxyGroupNameOverrides, proxyGroupOrder, testUrl, @@ -44,14 +45,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 ?? [], + proxyGroupAdvanced: state.proxyGroupAdvanced ?? {}, + builtinRuleEdits: state.builtinRuleEdits ?? {}, + proxyGroupNameOverrides: state.proxyGroupNameOverrides ?? {}, + proxyGroupOrder: state.proxyGroupOrder ?? [], testUrl: state.testUrl, testInterval: state.testInterval, ruleProviderBaseUrl: state.ruleProviderBaseUrl, @@ -75,6 +76,10 @@ export function VisualGraph() { } | null>(null); const containerRef = React.useRef(null); const [containerContentWidth, setContainerContentWidth] = React.useState(0); + const activeCustomProxyGroups = React.useMemo( + () => customProxyGroups.filter((group) => group && group.enabled !== false), + [customProxyGroups], + ); React.useEffect(() => { const el = containerRef.current; @@ -116,10 +121,10 @@ export function VisualGraph() { ruleProviderBaseUrl, testUrl, testInterval, - customProxyGroups, - filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customProxyGroups: activeCustomProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, proxyGroupNameOverrides, }); }, [ @@ -128,10 +133,10 @@ export function VisualGraph() { ruleProviderBaseUrl, testUrl, testInterval, - customProxyGroups, - filteredProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + activeCustomProxyGroups, + customRuleSets, + proxyGroupAdvanced, + builtinRuleEdits, proxyGroupNameOverrides, ]); @@ -145,64 +150,80 @@ export function VisualGraph() { moduleByName.set(resolveModuleName(m), m); } const customByName = new Map(); - for (const g of customProxyGroups) { + for (const g of activeCustomProxyGroups) { if (!g || typeof g.name !== "string" || !g.name.trim()) continue; customByName.set(g.name.trim(), g); } - const filteredByName = new Map< - string, - (typeof filteredProxyGroups)[number] - >(); - for (const g of filteredProxyGroups) { - if (!g || g.enabled === false) continue; - if (typeof g.name !== "string" || !g.name.trim()) continue; - filteredByName.set(g.name.trim(), g); - } + const moduleNames = Object.fromEntries( + PROXY_GROUP_MODULES.map((module) => [module.id, resolveModuleName(module)]), + ); const base = generatedProxyGroups.map((g) => { const groupName = typeof g.name === "string" ? g.name.trim() : ""; 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)]; + const target = edit?.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + fallbackTarget: moduleTarget, + }) + : moduleTarget; + return edit?.enabled !== false && target === moduleTarget; + }) + .map((r) => ({ id: r.id, name: r.name, behavior: r.behavior, - })); - })(); + })), + ...customRuleSets + .filter( + (ruleSet) => + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }) === moduleTarget, + ) + .map((ruleSet) => ({ + id: ruleSet.id, + name: ruleSet.name, + behavior: ruleSet.behavior, + })), + ...Object.entries(builtinRuleEdits || {}).flatMap(([key, edit]) => { + const target = edit?.target + ? resolveProxyGroupTargetName(edit.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }) + : ""; + if (edit?.enabled === false || target !== moduleTarget) return []; + const match = key.match(/^module:([^:]+):(.+)$/); + if (!match) return []; + const [, sourceModuleId, ruleId] = match; + 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}`, name: resolveModuleName(mod), emoji: mod.emoji, - groupType: mod.groupType, + groupType: proxyGroupAdvanced?.[mod.id]?.groupType ?? mod.groupType, + strategy: proxyGroupAdvanced?.[mod.id]?.strategy, category: mod.category, rules: mergedRules, }; } - const fg = groupName ? filteredByName.get(groupName) : undefined; - if (fg) { - const token = groupName.split(/\s+/)[0] || ""; - const emoji = - typeof fg.emoji === "string" && fg.emoji.trim() - ? fg.emoji.trim() - : token || "🧩"; - return { - id: `filtered:${fg.id}`, - name: groupName, - emoji, - groupType: g.type, - strategy: g.strategy, - category: "custom", - rules: [], - }; - } - const cg = groupName ? customByName.get(groupName) : undefined; return { id: cg ? `custom:${cg.id}` : `name:${groupName}`, @@ -211,10 +232,18 @@ 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) => + resolveProxyGroupTargetName(ruleSet.target, { + moduleNames, + customProxyGroups: activeCustomProxyGroups, + }) === groupName, + ) + .map((r) => ({ + id: r.id, + name: r.name, + behavior: r.behavior, })), }; }); @@ -283,12 +312,12 @@ export function VisualGraph() { .map((id) => byId.get(id)) .filter(Boolean) as VisualDisplayGroup[]; }, [ - customProxyGroups, + activeCustomProxyGroups, enabledDialerProxyGroups, - filteredProxyGroups, generatedProxyGroups, - moduleRuleOverrides, - moduleRuleExclusions, + customRuleSets, + builtinRuleEdits, + proxyGroupAdvanced, proxyGroupOrder, resolveModuleName, ]); @@ -306,11 +335,11 @@ export function VisualGraph() { const customRoutingRuleSets = React.useMemo( () => collectCustomRoutingRuleSets({ - customProxyGroups, - moduleRuleOverrides, + customRuleSets, + customProxyGroups: activeCustomProxyGroups, proxyGroupNameOverrides, }), - [customProxyGroups, moduleRuleOverrides, proxyGroupNameOverrides], + [activeCustomProxyGroups, 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/product/preview/visual-graph/custom-rules-preview.tsx b/packages/ui/src/product/preview/visual-graph/custom-rules-preview.tsx index 3e78cab..e817b92 100644 --- a/packages/ui/src/product/preview/visual-graph/custom-rules-preview.tsx +++ b/packages/ui/src/product/preview/visual-graph/custom-rules-preview.tsx @@ -5,6 +5,11 @@ import { ArrowRight, Shield } from "lucide-react"; import type { CustomRoutingRuleSetItem } from "@subboost/core/rules/custom-routing-rule-sets"; import type { CustomRule } from "@subboost/core/types/config"; +function ruleTargetToText(target: CustomRule["target"]): string { + if (typeof target === "string") return target; + return `${target.kind}:${target.id}`; +} + export function CustomRulesPreview({ customRules, ruleSets = [], @@ -18,7 +23,7 @@ export function CustomRulesPreview({ key: rule.id || `${rule.type}:${rule.value}`, type: rule.type, value: rule.value, - target: rule.target, + target: ruleTargetToText(rule.target), noResolve: Boolean(rule.noResolve), title: rule.value, })), diff --git a/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx b/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx index a0a6462..3e44fff 100644 --- a/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx +++ b/packages/ui/src/product/preview/visual-graph/proxy-groups-preview.tsx @@ -13,6 +13,7 @@ import { Shield, Zap, } from "lucide-react"; +import type { ProxyGroupGroupType } from "@subboost/core/types/config"; import { cn } from "@subboost/ui/lib/utils"; export type VisualDisplayGroup = { @@ -26,7 +27,7 @@ export type VisualDisplayGroup = { dialer?: { relayNodes: string[]; targetNodes: string[]; - type: "select" | "url-test"; + type: ProxyGroupGroupType; }; }; diff --git a/packages/ui/src/store/config-store.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..66b5dd6 100644 --- a/packages/ui/src/store/config-store.ts +++ b/packages/ui/src/store/config-store.ts @@ -31,9 +31,10 @@ 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..0658668 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.test.ts @@ -77,40 +77,103 @@ 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"); + expect(getState().customProxyGroups[0]).toEqual( + expect.objectContaining({ includeInGroupMembers: true }) + ); 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("normalizes advanced custom proxy group fields and keeps blank-name removals narrow", () => { + const { actions, getState } = createHarness({ + customProxyGroups: [ + { id: "blank", name: " ", emoji: "", groupType: "select" }, + ], + customRuleSets: [ + { id: "keep", name: "Keep", behavior: "domain", path: "geosite/keep.mrs", target: "Keep" }, + ], + }); + + actions.addCustomProxyGroup({ + name: "Load Balance", + emoji: "LB", + enabled: false, + description: " Fast nodes ", + memberSource: "filtered-nodes", + includeInGroupMembers: false, + groupType: "load-balance", + strategy: "round-robin", + advanced: { + includeRegex: "HK", + sourceIds: ["s1", "s1", ""], + }, + }); + + const groupId = getState().customProxyGroups[1].id; + expect(getState().customProxyGroups[1]).toEqual( + expect.objectContaining({ + advanced: expect.objectContaining({ includeRegex: "HK", sourceIds: ["s1"] }), + description: "Fast nodes", + enabled: false, + includeInGroupMembers: false, + memberSource: "filtered-nodes", + strategy: "round-robin", + }) + ); + + actions.updateCustomProxyGroup(groupId, { + enabled: true, + description: " Updated ", + advanced: { excludeRegex: "US" }, + }); + expect(getState().customProxyGroups[1]).toEqual( + expect.objectContaining({ + advanced: expect.objectContaining({ excludeRegex: "US" }), + description: "Updated", + enabled: true, + }) + ); + + actions.removeCustomProxyGroup("blank"); + expect(getState().customProxyGroups.map((group: { id: string }) => group.id)).toEqual([groupId]); + expect(getState().customRuleSets).toEqual([ + { id: "keep", name: "Keep", behavior: "domain", path: "geosite/keep.mrs", target: "Keep" }, + ]); }); it("keeps custom rule targets unchanged when group updates do not rename a group", () => { const { actions, getState } = createHarness({ customRules: [rule({ id: "r1", target: "Stable Group" })], 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..4a3cc8b 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.ts @@ -1,9 +1,10 @@ -import type { CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; +import type { BuiltinRuleEdits, CustomProxyGroup, CustomRule } from "@subboost/core/types/config"; import { createCustomRuleId, ensureCustomRuleId, } from "@subboost/core/rules/custom-rule-utils"; import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; import type { ConfigActions } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; @@ -19,6 +20,45 @@ type CustomActions = Pick< | "updateCustomProxyGroup" >; +function normalizeRuleOrderForState(state: { + enabledProxyGroups: string[]; + customProxyGroups: Parameters[0]["customProxyGroups"]; + customRules: Parameters[0]["customRules"]; + customRuleSets: Parameters[0]["customRuleSets"]; + builtinRuleEdits: Parameters[0]["builtinRuleEdits"]; + proxyGroupNameOverrides: Record; + experimentalCnUseCnRuleSet: boolean; + cnIpNoResolve: boolean; + ruleOrder: string[]; +}): string[] { + return normalizePersistedRuleOrder({ + enabledModules: state.enabledProxyGroups, + customProxyGroups: state.customProxyGroups, + customRules: state.customRules, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, + proxyGroupNameOverrides: state.proxyGroupNameOverrides, + experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, + cnIpNoResolve: state.cnIpNoResolve, + ruleOrder: state.ruleOrder, + }); +} + +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 +76,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 +94,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 +108,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 +118,59 @@ 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, + ...(group.enabled === false ? { enabled: false } : {}), + ...(typeof group.description === "string" ? { description: group.description.trim() } : {}), + ...(group.memberSource === "filtered-nodes" ? { memberSource: "filtered-nodes" as const } : {}), + includeInGroupMembers: + typeof group.includeInGroupMembers === "boolean" ? group.includeInGroupMembers : true, + groupType: group.groupType, + ...(group.groupType === "load-balance" && group.strategy ? { strategy: group.strategy } : {}), + ...(group.advanced ? { advanced: normalizeProxyGroupAdvancedConfig(group.advanced) } : {}), + }; 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 +187,34 @@ export function createCustomActions( rule.target === prevName ? { ...rule, target: nextName } : rule, ) : state.customRules; - const nextCustomProxyGroups = state.customProxyGroups.map((g) => - g.id === id ? { ...g, ...group } : g, - ); + 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) => { + if (g.id !== id) return g; + const next = { ...g, ...group }; + if (typeof group.enabled === "boolean") next.enabled = group.enabled; + if (group.advanced) next.advanced = normalizeProxyGroupAdvancedConfig(group.advanced); + if (typeof group.description === "string") next.description = group.description.trim(); + return next; + }); return { customRules: nextCustomRules, + customRuleSets: nextCustomRuleSets, + 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/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/actions/proxy-group-actions.test.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts index 273e6f6..6cfe8e3 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.test.ts @@ -40,11 +40,12 @@ describe("createProxyGroupActions", () => { "", "module:ai", "filtered:fast", + "name:External", // Intentionally force a non-string runtime value to verify invalid input is ignored. 123 as unknown as string, ]); - expect(getState().proxyGroupOrder).toEqual(["module:ai", "filtered:fast"]); + expect(getState().proxyGroupOrder).toEqual(["module:ai", "name:External"]); actions.setProxyGroupOrder("bad" as never); expect(getState().proxyGroupOrder).toEqual([]); @@ -95,226 +96,88 @@ describe("createProxyGroupActions", () => { expect(getState()).toBe(beforeRestore); }); - it("adds, updates, renames, and removes filtered proxy groups", () => { - vi.spyOn(Date, "now").mockReturnValue(1700000000000); - const { actions, getState } = createHarness({ - filteredProxyGroups: [ - { - id: "filtered-1", - name: "Old Filter", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], - }, - ], - customRules: [{ id: "rule-1", type: "DOMAIN", value: "example.com", target: "Old Filter" }], - dialerProxyGroups: [ - { - id: "dialer-1", - name: "Relay", - relayNodes: ["Old Filter", "Node A"], - targetNodes: ["Node A"], - }, - { - id: "dialer-2", - name: "Broken", - relayNodes: "bad", - targetNodes: ["Old Filter"], - }, - ], - }); - - actions.addFilteredProxyGroup({ - name: "Fast Nodes", - enabled: true, - groupType: "load-balance", - strategy: "bad" as never, - emoji: "⚡", - sourceIds: "bad" as never, - regions: ["us"], - excludeRegex: "Test", - excludedNodeNames: [" Node A ", "Node A", "", "Node B"], - }); - - expect(getState().filteredProxyGroups.at(-1)).toMatchObject({ - id: "filtered-group-1700000000000", - name: "Fast Nodes", - enabled: true, - groupType: "load-balance", - strategy: "consistent-hashing", - emoji: "⚡", - sourceIds: [], - regions: ["us"], - excludeRegex: "Test", - excludedNodeNames: ["Node A", "Node B"], - }); - - actions.addFilteredProxyGroup({ - name: "Plain Group", - enabled: false, - groupType: "invalid" as never, - emoji: 123 as never, - sourceIds: ["source-1"], - regions: "bad" as never, - includeRegex: "HK", - excludeRegex: 42 as never, - excludedNodeNames: "bad" as never, - }); - - expect(getState().filteredProxyGroups.at(-1)).toMatchObject({ - id: "filtered-group-1700000000000", - name: "Plain Group", - enabled: false, - groupType: "select", - sourceIds: ["source-1"], - regions: [], - includeRegex: "HK", - excludedNodeNames: [], - }); - expect(getState().filteredProxyGroups.at(-1)).not.toHaveProperty("strategy"); - expect(getState().filteredProxyGroups.at(-1)).toHaveProperty("emoji", undefined); - - actions.updateFilteredProxyGroup("filtered-1", { - name: "New Filter", - groupType: "load-balance", - strategy: "round-robin", - excludedNodeNames: ["Node C", "Node C"], - }); - - expect(getState().filteredProxyGroups[0]).toMatchObject({ - id: "filtered-1", - name: "New Filter", - groupType: "load-balance", - strategy: "round-robin", - excludedNodeNames: ["Node C"], - }); - expect(getState().customRules[0].target).toBe("New Filter"); - expect(getState().dialerProxyGroups[0].relayNodes).toEqual(["New Filter", "Node A"]); - expect(getState().dialerProxyGroups[1].relayNodes).toBe("bad"); - - actions.updateFilteredProxyGroup("filtered-1", { - groupType: "direct-first", - enabled: "bad" as never, - includeRegex: null as never, - excludeRegex: null as never, - sourceIds: "bad" as never, - regions: "bad" as never, + it("normalizes legacy hidden group lists while hiding builtin groups", () => { + const harness = createHarness({ + enabledProxyGroups: ["select", "ai"], + hiddenProxyGroups: "ai" as never, }); - expect(getState().filteredProxyGroups[0]).toMatchObject({ - groupType: "direct-first", - enabled: true, - includeRegex: undefined, - excludeRegex: undefined, - sourceIds: [], - regions: [], - }); - expect(getState().filteredProxyGroups[0]).toHaveProperty("strategy", undefined); + harness.actions.hideProxyGroup("ai"); - actions.updateFilteredProxyGroup("filtered-1", { - enabled: false, - emoji: "N", - groupType: "select", - sourceIds: ["source-2"], - regions: ["jp"], - includeRegex: "JP", - excludeRegex: "Relay", - }); + expect(harness.getState().hiddenProxyGroups).toEqual(["ai"]); + expect(harness.getState().enabledProxyGroups).toEqual(["select"]); - expect(getState().filteredProxyGroups[0]).toMatchObject({ - enabled: false, - emoji: "N", - groupType: "select", - sourceIds: ["source-2"], - regions: ["jp"], - includeRegex: "JP", - excludeRegex: "Relay", - strategy: undefined, + const duplicateHarness = createHarness({ + enabledProxyGroups: ["select", "ai"], + hiddenProxyGroups: ["ai"], }); - actions.updateFilteredProxyGroup("filtered-1", { - groupType: "load-balance", - strategy: undefined, - }); - expect(getState().filteredProxyGroups[0].strategy).toBe("consistent-hashing"); + duplicateHarness.actions.hideProxyGroup("ai"); - const beforeMissingUpdate = getState(); - actions.updateFilteredProxyGroup("", { name: "Ignored" }); - actions.updateFilteredProxyGroup("missing", { name: "Ignored" }); - expect(getState()).toBe(beforeMissingUpdate); - - actions.removeFilteredProxyGroup(" filtered-1 "); - actions.removeFilteredProxyGroup(""); - expect(getState().filteredProxyGroups.map((group: { id: string }) => group.id)).toEqual([ - "filtered-group-1700000000000", - "filtered-group-1700000000000", - ]); + expect(duplicateHarness.getState().hiddenProxyGroups).toEqual(["ai"]); + expect(duplicateHarness.getState().enabledProxyGroups).toEqual(["select"]); }); - it("preserves filtered proxy group values when partial updates are invalid or omitted", () => { + it("updates advanced config for builtin proxy groups", () => { const { actions, getState } = createHarness({ - filteredProxyGroups: [ - { - id: "filtered-1", - name: "Stable", - emoji: "S", - enabled: false, - groupType: "load-balance", - strategy: "round-robin", + proxyGroupAdvanced: { + ai: { sourceIds: ["source-1"], regions: ["hk"], includeRegex: "HK", - excludeRegex: "Test", - excludedNodeNames: "bad", + excludedMembers: [{ kind: "node", name: "Old" }], }, + }, + }); + + actions.updateProxyGroupAdvanced(" ai ", { + sourceIds: [" source-2 ", "", "source-2"], + regions: "bad" as never, + includeRegex: "Node", + excludeRegex: "Test", + excludedMembers: [ + { kind: "node", name: " Node A " }, + { kind: "node", name: "Node A" }, + { kind: "dialer", id: "relay" } as never, ], }); - actions.updateFilteredProxyGroup("filtered-1", { - enabled: "bad" as never, - emoji: 123 as never, - groupType: "load-balance", - strategy: "bad" as never, - includeRegex: undefined, - excludeRegex: undefined, - sourceIds: undefined, - regions: undefined, - }); - - expect(getState().filteredProxyGroups[0]).toMatchObject({ - name: "Stable", - emoji: "S", - enabled: false, - groupType: "load-balance", - strategy: "round-robin", - sourceIds: ["source-1"], - regions: ["hk"], - includeRegex: "HK", + expect(getState().proxyGroupAdvanced.ai).toMatchObject({ + sourceIds: ["source-2"], + includeRegex: "Node", excludeRegex: "Test", - excludedNodeNames: [], + excludedMembers: [{ kind: "node", name: "Node A" }], }); + expect(getState().proxyGroupAdvanced.ai.regions).toBeUndefined(); + + const beforeMissingUpdate = getState(); + actions.updateProxyGroupAdvanced("", { includeRegex: "Ignored" }); + actions.updateProxyGroupAdvanced("missing", { includeRegex: "Ignored" }); + expect(getState()).toBe(beforeMissingUpdate); + }); - actions.updateFilteredProxyGroup("filtered-1", { - groupType: "load-balance", - strategy: undefined, + it("updates advanced config from empty state and empty patches", () => { + const { actions, getState } = createHarness({ + proxyGroupAdvanced: undefined, }); - expect(getState().filteredProxyGroups[0].strategy).toBe("round-robin"); + + actions.updateProxyGroupAdvanced("ai", undefined as never); + + expect(getState().proxyGroupAdvanced.ai).toEqual({}); }); it("adds, updates, removes, and restores module rules", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "ai"], - 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 +185,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 +202,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 +219,68 @@ 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("disables moved builtin rule edits when removed from custom target groups", () => { + const { actions, getState } = createHarness({ + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + builtinRuleEdits: { + "module:ai:openai": { target: "Custom", enabled: true }, + }, + }); + + actions.removeModuleRule("custom-1", "openai"); + + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "Custom", enabled: false }, + }); + }); + + it("keeps missing rule-set targets as no-ops and retargets moved builtin edits", () => { + const { actions, getState } = createHarness({ + customProxyGroups: [ + { id: "blank", name: " ", emoji: "", groupType: "select" }, + ], + customRuleSets: [ + { id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 AI 服务" }, + ], + builtinRuleEdits: { + "module:ai:openai": { target: "🤖 AI 服务", enabled: false }, + }, + }); + + const beforeMissingTargets = getState(); + actions.addModuleRules("blank", [ + { id: "ignored", name: "Ignored", behavior: "domain", path: "geosite/ignored.mrs" }, + ]); + actions.updateModuleRule("blank", "custom-ai", { name: "Ignored" }); + expect(getState()).toBe(beforeMissingTargets); + + actions.removeModuleRule("ai", "openai"); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "🤖 AI 服务", enabled: false }, + }); + + actions.moveModuleRule("ai", "openai", { kind: "module", id: "youtube" }); + expect(getState().enabledProxyGroups).toContain("youtube"); + expect(getState().builtinRuleEdits).toEqual({ + "module:ai:openai": { target: "📹 油管视频" }, + }); }); it("keeps full rule order positions across preset rule remove, restore, hide, and move", () => { @@ -378,8 +289,8 @@ describe("createProxyGroupActions", () => { enabledModules: enabledProxyGroups, customRules: [], customProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, proxyGroupNameOverrides: {}, experimentalCnUseCnRuleSet: true, cnIpNoResolve: true, @@ -390,7 +301,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 +309,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 +339,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 +379,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(); @@ -480,23 +391,95 @@ describe("createProxyGroupActions", () => { expect(getState()).toEqual(before); }); + it("adds, updates, moves, and removes custom rule sets for custom groups", () => { + const { actions, getState } = createHarness({ + enabledProxyGroups: ["select", "auto"], + customProxyGroups: [ + { + id: "custom-1", + name: "Custom", + emoji: "", + groupType: "select", + }, + { + id: "custom-2", + name: "Target", + emoji: "", + groupType: "select", + }, + ], + customRuleSets: [], + }); + + actions.addModuleRules("custom-1", [ + { id: "telegram", name: "Telegram", behavior: "ipcidr", path: "geoip/telegram.mrs" }, + ]); + + expect(getState().customRuleSets).toEqual([ + { + id: "telegram", + name: "Telegram", + behavior: "ipcidr", + path: "geoip/telegram.mrs", + target: "Custom", + noResolve: true, + }, + ]); + + actions.updateModuleRule("custom-1", "telegram", { + name: "Telegram Custom", + path: "geoip/telegram.mrs", + }); + expect(getState().customRuleSets[0]).toMatchObject({ + id: "telegram", + name: "Telegram Custom", + target: "Custom", + }); + + actions.moveModuleRule("custom-1", "telegram", { kind: "custom", id: "custom-2" }); + expect(getState().customRuleSets[0].target).toBe("Target"); + + actions.removeModuleRule("custom-2", "telegram"); + expect(getState().customRuleSets).toEqual([]); + }); + it("restores all default module rules for one module and accepts edit warnings", () => { const { actions, getState } = createHarness({ - 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"] }); + actions.restoreModuleDefaultRules("missing"); + 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); }); + it("keeps reset rule target no-ops stable for invalid or already-default rules", () => { + const { actions, getState } = createHarness({ + builtinRuleEdits: {}, + }); + + const before = getState(); + actions.resetModuleRuleTarget("", "openai"); + actions.resetModuleRuleTarget("ai", ""); + actions.resetModuleRuleTarget("missing", "openai"); + actions.resetModuleRuleTarget("ai", "missing"); + actions.resetModuleRuleTarget("ai", "openai"); + + expect(getState()).toBe(before); + }); + it("moves module rules into another builtin group or a custom group", () => { const { actions, getState } = createHarness({ enabledProxyGroups: ["select", "auto", "ai"], @@ -507,43 +490,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 +536,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 +585,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", () => { @@ -645,6 +604,13 @@ describe("createProxyGroupActions", () => { { id: "rule-1", type: "DOMAIN", value: "example.com", target: "🤖 AI 服务" }, { id: "rule-2", type: "DOMAIN", value: "example.net", target: "🚀 节点选择" }, ], + customRuleSets: [ + { id: "rs-1", name: "RS", behavior: "domain", path: "geosite/rs.mrs", target: "🤖 AI 服务" }, + { id: "rs-2", name: "Other", behavior: "domain", path: "geosite/other.mrs", target: "🚀 节点选择" }, + ], + builtinRuleEdits: { + "module:ai:openai": { target: "🤖 AI 服务" }, + }, }); actions.setProxyGroupNameOverride("select", "Main"); @@ -655,10 +621,15 @@ describe("createProxyGroupActions", () => { expect(getState().proxyGroupNameOverrides).toEqual({ ai: "Labs" }); expect(getState().customRules[0].target).toBe("🤖 Labs"); expect(getState().customRules[1].target).toBe("🚀 节点选择"); + expect(getState().customRuleSets[0].target).toBe("🤖 Labs"); + expect(getState().customRuleSets[1].target).toBe("🚀 节点选择"); + expect(getState().builtinRuleEdits["module:ai:openai"].target).toBe("🤖 Labs"); actions.setProxyGroupNameOverride("ai", ""); expect(getState().proxyGroupNameOverrides).toEqual({ ai: "" }); expect(getState().customRules[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[1].target).toBe("🚀 节点选择"); actions.setProxyGroupNameOverride("ai", "Labs"); actions.clearProxyGroupNameOverride("ai"); @@ -666,6 +637,9 @@ describe("createProxyGroupActions", () => { actions.clearProxyGroupNameOverride("select"); expect(getState().proxyGroupNameOverrides).toEqual({}); expect(getState().customRules[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[0].target).toBe("🤖 AI 服务"); + expect(getState().customRuleSets[1].target).toBe("🚀 节点选择"); + expect(getState().builtinRuleEdits["module:ai:openai"].target).toBe("🤖 AI 服务"); }); it("renames groups when override maps are not initialized", () => { diff --git a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts index c11de1f..ae03821 100644 --- a/packages/ui/src/store/config-store/actions/proxy-group-actions.ts +++ b/packages/ui/src/store/config-store/actions/proxy-group-actions.ts @@ -1,30 +1,35 @@ -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -import { DEFAULT_LOAD_BALANCE_STRATEGY, isLoadBalanceStrategy } 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"; + type CustomRuleSet, +} from "@subboost/core/types/config"; +import { normalizeProxyGroupAdvancedConfig } from "@subboost/core/proxy-group-advanced"; +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; +import { getModuleRuleOrderKey, isPresetModuleRule } from "@subboost/core/generator/module-rules"; import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; -import type { ConfigActions, ModuleRuleOverride } from "../definitions"; +import type { ConfigActions, RuleSetDraft } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; +import { + appendUniqueCustomRuleSets, + findBuiltinRuleEditKeyByTarget, + normalizeRuleOrderForState, + normalizeRuleSetDraft, + resolveMoveTargetName, + resolveRuleSetContainerTargetName, + retargetBuiltinRuleEdits, + updateBuiltinRuleEdit, +} from "./proxy-group-rule-set-helpers"; type ProxyGroupActions = Pick< ConfigActions, | "setProxyGroupOrder" | "hideProxyGroup" | "restoreHiddenProxyGroup" - | "addFilteredProxyGroup" - | "removeFilteredProxyGroup" - | "updateFilteredProxyGroup" + | "updateProxyGroupAdvanced" | "addModuleRules" | "updateModuleRule" | "removeModuleRule" | "moveModuleRule" | "restoreModuleRule" + | "resetModuleRuleTarget" | "restoreModuleDefaultRules" | "acceptModuleRuleEditWarning" | "setProxyGroupNameOverride" @@ -46,85 +51,16 @@ function normalizeStringList(value: unknown): string[] { } function isBuiltinProxyGroup(moduleId: string): boolean { - return PROXY_GROUP_MODULES.some((module) => module.id === moduleId); -} - -function normalizeModuleRuleOverride(rule: ModuleRuleOverride): ModuleRuleOverride | 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/") - ? "ipcidr" - : "domain"; - return { - id, - name: typeof rule.name === "string" && rule.name.trim() ? rule.name.trim() : id, - behavior, - path, - ...(rule.noResolve || behavior === "ipcidr" ? { noResolve: true } : {}), - }; + return PROXY_GROUP_MODULES.some((proxyModule) => proxyModule.id === moduleId); } -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; - proxyGroupNameOverrides: Record; - experimentalCnUseCnRuleSet: boolean; - cnIpNoResolve: boolean; - ruleOrder: string[]; -}): string[] { - return 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: state.ruleOrder, - }); +function isSupportedProxyGroupOrderKey(key: string): boolean { + return ( + key.startsWith("module:") || + key.startsWith("custom:") || + key.startsWith("dialer:") || + key.startsWith("name:") + ); } export function createProxyGroupActions( @@ -139,6 +75,7 @@ export function createProxyGroupActions( .filter((k) => typeof k === "string") .map((k) => k.trim()) .filter(Boolean) + .filter(isSupportedProxyGroupOrderKey) : []; const seen = new Set(); @@ -209,175 +146,62 @@ export function createProxyGroupActions( }); }, - addFilteredProxyGroup: (group: Omit) => { - const id = `filtered-group-${Date.now()}`; - const groupType = - group.groupType === "url-test" || - group.groupType === "fallback" || - group.groupType === "load-balance" || - group.groupType === "direct-first" || - group.groupType === "reject-first" - ? group.groupType - : "select"; - const strategy = - groupType === "load-balance" - ? isLoadBalanceStrategy(group.strategy) - ? group.strategy - : DEFAULT_LOAD_BALANCE_STRATEGY - : undefined; - const next: FilteredProxyGroup = { - id, - emoji: typeof group.emoji === "string" ? group.emoji : undefined, - name: group.name, - enabled: Boolean(group.enabled), - groupType, - ...(strategy ? { strategy } : {}), - sourceIds: Array.isArray(group.sourceIds) ? group.sourceIds : [], - regions: Array.isArray(group.regions) ? group.regions : [], - includeRegex: typeof group.includeRegex === "string" ? group.includeRegex : undefined, - excludeRegex: typeof group.excludeRegex === "string" ? group.excludeRegex : undefined, - excludedNodeNames: normalizeStringList(group.excludedNodeNames), - }; - - setAndGenerateConfig((state) => ({ - filteredProxyGroups: [...state.filteredProxyGroups, next], - })); - }, - - removeFilteredProxyGroup: (id: string) => { - const gid = (id || "").trim(); - if (!gid) return; - setAndGenerateConfig((state) => ({ - filteredProxyGroups: state.filteredProxyGroups.filter((g) => g.id !== gid), - })); - }, - - updateFilteredProxyGroup: (id: string, group: Partial) => { - const gid = (id || "").trim(); - if (!gid) return; + updateProxyGroupAdvanced: (moduleId, patch) => { + const id = (moduleId || "").trim(); + if (!id || !isBuiltinProxyGroup(id)) return; setAndGenerateConfig((state) => { - const prevGroup = state.filteredProxyGroups.find((g) => g.id === gid); - if (!prevGroup) return state; - - const prevName = typeof prevGroup.name === "string" ? prevGroup.name : ""; - const nextName = typeof group.name === "string" ? group.name : prevName; - const didRename = Boolean(prevName && nextName && prevName !== nextName); - - const nextFilteredProxyGroups = state.filteredProxyGroups.map((g) => { - if (g.id !== gid) return g; - - const nextGroupType = - group.groupType === "url-test" || - group.groupType === "fallback" || - group.groupType === "load-balance" || - group.groupType === "direct-first" || - group.groupType === "reject-first" || - group.groupType === "select" - ? group.groupType - : g.groupType; - const nextStrategy = - nextGroupType === "load-balance" - ? isLoadBalanceStrategy(group.strategy) - ? group.strategy - : isLoadBalanceStrategy(g.strategy) - ? g.strategy - : DEFAULT_LOAD_BALANCE_STRATEGY - : undefined; - - return { - ...g, - ...group, - enabled: typeof group.enabled === "boolean" ? group.enabled : g.enabled, - emoji: typeof group.emoji === "string" ? group.emoji : g.emoji, - groupType: nextGroupType, - ...(nextStrategy ? { strategy: nextStrategy } : { strategy: undefined }), - sourceIds: Array.isArray(group.sourceIds) ? group.sourceIds : g.sourceIds, - regions: Array.isArray(group.regions) ? group.regions : g.regions, - includeRegex: - typeof group.includeRegex === "string" - ? group.includeRegex - : group.includeRegex === undefined - ? g.includeRegex - : g.includeRegex, - excludeRegex: - typeof group.excludeRegex === "string" - ? group.excludeRegex - : group.excludeRegex === undefined - ? g.excludeRegex - : g.excludeRegex, - excludedNodeNames: Array.isArray(group.excludedNodeNames) - ? normalizeStringList(group.excludedNodeNames) - : Array.isArray(g.excludedNodeNames) - ? normalizeStringList(g.excludedNodeNames) - : [], - }; - }); - - if (!didRename) { - return { filteredProxyGroups: nextFilteredProxyGroups }; - } - + const prev = normalizeProxyGroupAdvancedConfig(state.proxyGroupAdvanced?.[id]); + const next = normalizeProxyGroupAdvancedConfig({ ...prev, ...(patch || {}) }); return { - filteredProxyGroups: nextFilteredProxyGroups, - // 筛选组名称可能被其他功能(自定义规则 / 中转组)引用:改名时同步更新引用,避免产生“指向不存在的组”。 - customRules: state.customRules.map((r) => - r.target === prevName ? { ...r, target: nextName } : r - ), - dialerProxyGroups: state.dialerProxyGroups.map((dg) => ({ - ...dg, - relayNodes: Array.isArray(dg.relayNodes) - ? dg.relayNodes.map((n) => (n === prevName ? nextName : n)) - : dg.relayNodes, - })), + proxyGroupAdvanced: { + ...(state.proxyGroupAdvanced || {}), + [id]: next, + }, }; }); }, - 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 target = resolveRuleSetContainerTargetName( + id, + state.customProxyGroups, + state.proxyGroupNameOverrides, ); - 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; + 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 +210,38 @@ 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 = resolveRuleSetContainerTargetName( + id, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); + if (!target) return state; + const index = state.customRuleSets.findIndex((item) => item.id === rid && item.target === target); if (index < 0) return state; - 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 +254,44 @@ 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 = resolveRuleSetContainerTargetName( + id, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); + 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 +306,102 @@ 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 = resolveRuleSetContainerTargetName( + sourceId, + state.customProxyGroups, + state.proxyGroupNameOverrides, + ); + if (!sourceTarget) return state; + if (sourceTarget === targetName) 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 +416,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 +453,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 +498,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 +533,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/proxy-group-rule-set-helpers.ts b/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts new file mode 100644 index 0000000..7ea0b34 --- /dev/null +++ b/packages/ui/src/store/config-store/actions/proxy-group-rule-set-helpers.ts @@ -0,0 +1,160 @@ +import { getModuleRuleOrderKey, isPresetModuleRule } from "@subboost/core/generator/module-rules"; +import { PROXY_GROUP_MODULES } from "@subboost/core/generator/proxy-groups"; +import { normalizePersistedRuleOrder } from "@subboost/core/generator/rules"; +import { resolveProxyGroupModuleName } from "@subboost/core/proxy-group-name"; +import { isValidRuleSetPathOrUrl, normalizeRuleSetPathInput } from "@subboost/core/rules/rule-model"; +import type { + BuiltinRuleEdits, + CustomProxyGroup, + CustomRuleSet, + RuleSetBehavior, +} from "@subboost/core/types/config"; +import type { RuleSetDraft } from "../definitions"; + +export function normalizeRuleSetDraft(rule: RuleSetDraft): RuleSetDraft | null { + if (!rule || typeof rule.id !== "string" || typeof rule.path !== "string") return null; + const id = rule.id.trim(); + const path = normalizeRuleSetPathInput(rule.path); + if (!id || !path || !isValidRuleSetPathOrUrl(path)) return null; + const behavior: RuleSetBehavior = rule.behavior === "ipcidr" || path.toLowerCase().startsWith("geoip/") + ? "ipcidr" + : "domain"; + return { + id, + name: typeof rule.name === "string" && rule.name.trim() ? rule.name.trim() : id, + behavior, + path, + ...(rule.noResolve || behavior === "ipcidr" ? { noResolve: true } : {}), + }; +} + +export function normalizeRuleOrderForState(state: { + enabledProxyGroups: string[]; + customProxyGroups: CustomProxyGroup[]; + customRules: Parameters[0]["customRules"]; + customRuleSets: Parameters[0]["customRuleSets"]; + builtinRuleEdits: Parameters[0]["builtinRuleEdits"]; + proxyGroupNameOverrides: Record; + experimentalCnUseCnRuleSet: boolean; + cnIpNoResolve: boolean; + ruleOrder: string[]; +}): string[] { + return normalizePersistedRuleOrder({ + enabledModules: state.enabledProxyGroups, + customProxyGroups: state.customProxyGroups, + customRules: state.customRules, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, + proxyGroupNameOverrides: state.proxyGroupNameOverrides, + experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, + cnIpNoResolve: state.cnIpNoResolve, + ruleOrder: state.ruleOrder, + }); +} + +export function resolveModuleTargetName(moduleId: string, overrides?: Record): string | null { + const proxyModule = PROXY_GROUP_MODULES.find((item) => item.id === moduleId); + if (!proxyModule) return null; + return resolveProxyGroupModuleName(proxyModule, overrides?.[moduleId]); +} + +export function resolveMoveTargetName( + target: { kind: "module" | "custom"; id: string }, + customProxyGroups: CustomProxyGroup[], + proxyGroupNameOverrides?: Record +): string | null { + if (target.kind === "module") return resolveModuleTargetName(target.id, proxyGroupNameOverrides); + const group = customProxyGroups.find((item) => item.id === target.id); + return group?.name?.trim() || null; +} + +export function resolveRuleSetContainerTargetName( + id: string, + customProxyGroups: CustomProxyGroup[], + proxyGroupNameOverrides?: Record +): string | null { + return ( + resolveModuleTargetName(id, proxyGroupNameOverrides) || + customProxyGroups.find((group) => group.id === id)?.name?.trim() || + null + ); +} + +export function compactBuiltinRuleEdits(edits: BuiltinRuleEdits): BuiltinRuleEdits { + const next: BuiltinRuleEdits = {}; + for (const [key, edit] of Object.entries(edits || {})) { + const target = typeof edit?.target === "string" ? edit.target.trim() : ""; + const enabled = edit?.enabled === false ? false : undefined; + if (!target && enabled !== false) continue; + next[key] = { + ...(target ? { target } : {}), + ...(enabled === false ? { enabled: false } : {}), + }; + } + return next; +} + +export function updateBuiltinRuleEdit( + edits: BuiltinRuleEdits, + key: string, + patch: { target?: string | null; enabled?: false | true | null } +): BuiltinRuleEdits { + const prev = edits?.[key] || {}; + const next = { ...prev }; + if ("target" in patch) { + const target = typeof patch.target === "string" ? patch.target.trim() : ""; + if (target) next.target = target; + else delete next.target; + } + if ("enabled" in patch) { + if (patch.enabled === false) next.enabled = false; + else delete next.enabled; + } + return compactBuiltinRuleEdits({ ...(edits || {}), [key]: next }); +} + +export function retargetBuiltinRuleEdits(edits: BuiltinRuleEdits, from: string, to: string): BuiltinRuleEdits { + if (!from || from === to) return edits; + let changed = false; + const next: BuiltinRuleEdits = {}; + for (const [key, edit] of Object.entries(edits || {})) { + if (edit?.target === from) { + next[key] = { ...edit, target: to }; + changed = true; + } else { + next[key] = edit; + } + } + return changed ? compactBuiltinRuleEdits(next) : edits; +} + +export function findBuiltinRuleEditKeyByTarget( + edits: BuiltinRuleEdits, + target: string, + ruleId: string +): string | null { + if (!target || !ruleId) return null; + for (const [key, edit] of Object.entries(edits || {})) { + if (edit?.target !== target) continue; + const parts = key.split(":"); + if (parts.length !== 3 || parts[0] !== "module") continue; + if (parts[2] === ruleId) return key; + } + return null; +} + +export function appendUniqueCustomRuleSets( + existing: CustomRuleSet[], + drafts: RuleSetDraft[], + target: string +): CustomRuleSet[] { + const seen = new Set(existing.map((item) => item.id)); + const next = [...existing]; + for (const draft of drafts) { + const ruleSet = normalizeRuleSetDraft(draft); + if (!ruleSet || seen.has(ruleSet.id)) continue; + seen.add(ruleSet.id); + next.push({ ...ruleSet, target }); + } + return next; +} diff --git a/packages/ui/src/store/config-store/actions/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..c1508f2 100644 --- a/packages/ui/src/store/config-store/actions/settings-actions.ts +++ b/packages/ui/src/store/config-store/actions/settings-actions.ts @@ -9,13 +9,13 @@ type SettingsActions = Pick< | "setTestUrl" | "setTestInterval" | "setRuleProviderBaseUrl" + | "setProxyGroupAdvancedModeEnabled" | "setCnIpNoResolve" | "setExperimentalCnUseCnRuleSet" - | "setAllRulesOrderEditingEnabled" >; export function createSettingsActions( - set: SetState, + _set: SetState, _get: GetState, setAndGenerateConfig: SetAndGenerateConfig ): SettingsActions { @@ -44,6 +44,10 @@ export function createSettingsActions( setAndGenerateConfig(() => ({ ruleProviderBaseUrl: url })); }, + setProxyGroupAdvancedModeEnabled: (value: boolean) => { + setAndGenerateConfig(() => ({ proxyGroupAdvancedModeEnabled: Boolean(value) })); + }, + setCnIpNoResolve: (value: boolean) => { setAndGenerateConfig(() => ({ cnIpNoResolve: Boolean(value) })); }, @@ -51,9 +55,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..62fae65 100644 --- a/packages/ui/src/store/config-store/actions/template-actions.test.ts +++ b/packages/ui/src/store/config-store/actions/template-actions.test.ts @@ -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,27 +93,20 @@ describe("createTemplateActions", () => { name: "Custom Group", emoji: "", groupType: "select", - rules: [{ id: "custom-provider", name: "Custom Provider", behavior: "domain", url: "https://example.com/rule.mrs" }], }, ], - filteredProxyGroups: [ + customRuleSets: [ { - id: "filtered-1", - name: "Filtered", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], + id: "custom-provider", + name: "Custom Provider", + behavior: "domain", + path: "https://example.com/rule.mrs", + target: "Custom Group", }, ], - 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: [ @@ -128,7 +119,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 +127,27 @@ describe("createTemplateActions", () => { template: "full", enabledProxyGroups: ["select", "google"], hiddenProxyGroups: ["ai"], - customProxyGroups: config.customProxyGroups, - filteredProxyGroups: config.filteredProxyGroups, - moduleRuleOverrides: config.moduleRuleOverrides, - moduleRuleExclusions: { ai: ["openai"] }, + customProxyGroups: [ + { + id: "custom-group-1", + name: "Custom Group", + emoji: "", + advanced: {}, + groupType: "select", + }, + ], + proxyGroupAdvancedModeEnabled: true, + 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,23 +179,11 @@ describe("createTemplateActions", () => { name: "Existing", emoji: "", groupType: "select", - rules: [], - }, - ], - filteredProxyGroups: [ - { - id: "existing-filter", - name: "Existing Filter", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], }, ], customRules: [{ id: "existing-rule", type: "DOMAIN", value: "example.org", target: "Proxy" }], - 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, @@ -212,11 +206,7 @@ describe("createTemplateActions", () => { enabledProxyGroups: "bad", hiddenProxyGroups: [" ai ", "adult", "adult", 123], customProxyGroups: "bad", - filteredProxyGroups: "bad", - moduleRuleOverrides: null, - moduleRuleExclusions: "bad", customRules: "bad", - allRulesOrderEditingEnabled: "bad", cnIpNoResolve: "bad", experimentalCnUseCnRuleSet: "bad", dialerProxyGroups: "bad", @@ -239,25 +229,12 @@ describe("createTemplateActions", () => { name: "Existing", emoji: "", groupType: "select", - rules: [], - }, - ], - filteredProxyGroups: [ - { - id: "existing-filter", - name: "Existing Filter", - enabled: true, - groupType: "select", - sourceIds: [], - regions: [], - excludedNodeNames: [], }, ], customRules: [{ id: "existing-rule", type: "DOMAIN", value: "example.org", target: "Proxy" }], - 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..206bdcd 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,11 @@ 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 { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; +import type { ConfigActions, SubBoostTemplateConfig } from "../definitions"; import type { GetState, SetAndGenerateConfig, SetState } from "../store-types"; type TemplateActions = Pick< @@ -50,10 +46,9 @@ export function createTemplateActions( hiddenProxyGroups: [], appliedTemplateId: getBuiltinTemplateId(template), customRules: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + customRuleSets: [], + builtinRuleEdits: {}, ruleOrder: [], - allRulesOrderEditingEnabled: false, moduleRuleEditWarningAccepted: false, })); }, @@ -86,39 +81,48 @@ 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 hasBuiltinRuleEdits = Boolean(config.builtinRuleEdits && typeof config.builtinRuleEdits === "object"); + const nextCustomProxyGroups = + hasCustomProxyGroups || ruleModel.customProxyGroups.length > 0 + ? ruleModel.customProxyGroups + : state.customProxyGroups; + const nextCustomRuleSets = + hasCustomRuleSets + ? ruleModel.customRuleSets + : state.customRuleSets; + const nextBuiltinRuleEdits = + hasBuiltinRuleEdits ? ruleModel.builtinRuleEdits : state.builtinRuleEdits; const nextCustomRules = Array.isArray(config.customRules) ? ensureCustomRulesHaveIds(config.customRules) : state.customRules; + const nextProxyGroupAdvanced = + config.proxyGroupAdvanced && typeof config.proxyGroupAdvanced === "object" + ? config.proxyGroupAdvanced + : state.proxyGroupAdvanced; const nextHiddenProxyGroups = normalizeHiddenProxyGroups(config.hiddenProxyGroups); const nextHiddenProxyGroupSet = new Set(nextHiddenProxyGroups); const shouldRefreshRuleOrder = Array.isArray(config.ruleOrder) || Array.isArray(config.customRules) || - Array.isArray(config.customProxyGroups) || - Boolean(config.moduleRuleExclusions && typeof config.moduleRuleExclusions === "object"); + hasCustomProxyGroups || + hasCustomRuleSets || + hasBuiltinRuleEdits; 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, + customRuleSets: nextCustomRuleSets, customProxyGroups: nextCustomProxyGroups, - moduleRuleOverrides: - config.moduleRuleOverrides && typeof config.moduleRuleOverrides === "object" - ? (config.moduleRuleOverrides as Record) - : state.moduleRuleOverrides, - moduleRuleExclusions: nextModuleRuleExclusions, + builtinRuleEdits: nextBuiltinRuleEdits, proxyGroupNameOverrides: config.proxyGroupNameOverrides && typeof config.proxyGroupNameOverrides === "object" ? (config.proxyGroupNameOverrides as Record) @@ -132,28 +136,23 @@ export function createTemplateActions( ruleOrder: config.ruleOrder, }) : state.ruleOrder; - return { // 不触碰 nodes/sources:模板只描述“生成策略”,节点仍由用户导入 template: config.template ?? state.template, enabledProxyGroups: nextEnabledModules, hiddenProxyGroups: nextHiddenProxyGroups, customProxyGroups: nextCustomProxyGroups, - 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, + proxyGroupAdvanced: nextProxyGroupAdvanced, + proxyGroupAdvancedModeEnabled: resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: config.proxyGroupAdvancedModeEnabled, + customProxyGroups: nextCustomProxyGroups, + proxyGroupAdvanced: nextProxyGroupAdvanced, + }), + 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..9e805fb 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,15 @@ 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: [] }], - filteredProxyGroups: [{ id: "filtered-1", name: "Filtered", enabled: true }], - moduleRuleOverrides: { ai: [{ id: "custom-ai", path: "geosite/custom-ai.mrs" }] }, - moduleRuleExclusions: { ai: ["openai"] }, + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + proxyGroupAdvanced: { ai: { includeRegex: "AI" } }, + proxyGroupAdvancedModeEnabled: true, + customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, dialerProxyGroups: [{ id: "dialer-1", name: "Relay", relayNodes: ["Node A"], targetNodes: [] }], proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", template: "full", @@ -148,14 +148,14 @@ 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: [] }], - filteredProxyGroups: [{ id: "filtered-1", name: "Filtered", enabled: true }], - moduleRuleOverrides: { ai: [{ id: "custom-ai", path: "geosite/custom-ai.mrs" }] }, - moduleRuleExclusions: { ai: ["openai"] }, + customProxyGroups: [{ id: "custom-1", name: "Custom", emoji: "", groupType: "select" }], + proxyGroupAdvanced: { ai: { includeRegex: "AI" } }, + proxyGroupAdvancedModeEnabled: true, + customRuleSets: [{ id: "custom-ai", name: "Custom AI", behavior: "domain", path: "geosite/custom-ai.mrs", target: "🤖 Labs" }], + builtinRuleEdits: { "module:ai:openai": { enabled: false } }, proxyGroupNameOverrides: { ai: "Labs" }, proxyGroupOrder: ["module:ai"], ruleOrder: ["module:ai:openai"], - allRulesOrderEditingEnabled: true, moduleRuleEditWarningAccepted: true, appliedTemplateId: "template-1", dnsYaml: "dns: {}", @@ -249,15 +249,22 @@ describe("auth config handoff", () => { enabledProxyGroups: ["select", 1], hiddenProxyGroups: "bad", customProxyGroups: [{ id: "custom" }], - filteredProxyGroups: [{ id: "filtered" }], - moduleRuleOverrides: { ai: [] }, - moduleRuleExclusions: { ai: ["openai"] }, + proxyGroupAdvancedModeEnabled: true, + 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: {}", @@ -280,15 +287,22 @@ describe("auth config handoff", () => { deletedNodeNames: ["Gone"], deletedNodes: [{ originName: "Gone" }], enabledProxyGroups: ["select"], - customProxyGroups: [{ id: "custom" }], - filteredProxyGroups: [{ id: "filtered" }], - moduleRuleOverrides: { ai: [] }, - moduleRuleExclusions: { ai: ["openai"] }, + customProxyGroups: [], + proxyGroupAdvancedModeEnabled: true, + 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 80c132f..79cee51 100644 --- a/packages/ui/src/store/config-store/auth-handoff.ts +++ b/packages/ui/src/store/config-store/auth-handoff.ts @@ -1,6 +1,8 @@ import type { ConfigState, SourceType, SubscriptionSource } from "./definitions"; import { initialState } from "./definitions"; import { safeParseJsonObject } from "@subboost/core/json"; +import { resolveProxyGroupAdvancedModeEnabled } from "@subboost/core/proxy-group-advanced-mode"; +import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; export const AUTH_CONFIG_HANDOFF_STORAGE_NAME = "subboost-auth-config-handoff"; @@ -110,15 +112,15 @@ 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.proxyGroupAdvanced 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.proxyGroupAdvancedModeEnabled !== initialState.proxyGroupAdvancedModeEnabled || state.moduleRuleEditWarningAccepted !== initialState.moduleRuleEditWarningAccepted || state.appliedTemplateId !== initialState.appliedTemplateId || state.template !== initialState.template || @@ -146,15 +148,15 @@ function buildHandoffState(state: ConfigState): Partial { enabledProxyGroups: state.enabledProxyGroups, hiddenProxyGroups: state.hiddenProxyGroups, customProxyGroups: state.customProxyGroups, - filteredProxyGroups: state.filteredProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, + proxyGroupAdvanced: state.proxyGroupAdvanced, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, customRules: state.customRules, dialerProxyGroups: state.dialerProxyGroups, proxyGroupNameOverrides: state.proxyGroupNameOverrides, proxyGroupOrder: state.proxyGroupOrder, ruleOrder: state.ruleOrder, - allRulesOrderEditingEnabled: state.allRulesOrderEditingEnabled, + proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled, moduleRuleEditWarningAccepted: state.moduleRuleEditWarningAccepted, appliedTemplateId: state.appliedTemplateId, dnsYaml: state.dnsYaml, @@ -186,12 +188,26 @@ 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; - 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 (customProxyGroups || ruleModel.customProxyGroups.length > 0) out.customProxyGroups = ruleModel.customProxyGroups; + if (isRecord(raw.proxyGroupAdvanced)) { + out.proxyGroupAdvanced = raw.proxyGroupAdvanced as ConfigState["proxyGroupAdvanced"]; + } + const proxyGroupAdvancedModeEnabled = resolveProxyGroupAdvancedModeEnabled({ + proxyGroupAdvancedModeEnabled: raw.proxyGroupAdvancedModeEnabled, + customProxyGroups: out.customProxyGroups, + proxyGroupAdvanced: out.proxyGroupAdvanced, + }); + if (typeof raw.proxyGroupAdvancedModeEnabled === "boolean" || proxyGroupAdvancedModeEnabled) { + out.proxyGroupAdvancedModeEnabled = proxyGroupAdvancedModeEnabled; + } + if (Array.isArray(raw.customRuleSets)) { + out.customRuleSets = ruleModel.customRuleSets; + } + if (isRecord(raw.builtinRuleEdits)) { + out.builtinRuleEdits = ruleModel.builtinRuleEdits; + } const customRules = objectArray(raw.customRules); if (customRules) out.customRules = customRules; const dialerProxyGroups = objectArray(raw.dialerProxyGroups); @@ -201,7 +217,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..91e2dc0 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, + ProxyGroupAdvancedConfig, + TemplateType, +} from "@subboost/core/types/config"; import { DEFAULT_BASE_CONFIG_YAML, DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { getBuiltinTemplateId } from "@subboost/core/templates/builtin"; import { TEMPLATES } from "@subboost/core/templates"; -import type { FilteredProxyGroup } from "@subboost/core/types/filtered-proxy-group"; -import type { 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,9 @@ 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 { BuiltinRuleEdits, CustomRuleSet, ProxyGroupAdvancedConfig }; +export type { DialerProxyGroup, SubBoostTemplateConfig } from "@subboost/core/types/template-config"; // 预设的中转组名称 export const PRESET_RELAY_NAMES = [ @@ -171,9 +173,10 @@ export interface ConfigState { enabledProxyGroups: string[]; hiddenProxyGroups: string[]; // 隐藏的内置代理组(仅影响 UI,不参与生成) customProxyGroups: CustomProxyGroup[]; // 自定义分流组 - filteredProxyGroups: FilteredProxyGroup[]; // 筛选代理组(从节点池派生) - moduleRuleOverrides: Record; // 内置代理组附加规则集 - moduleRuleExclusions: ModuleRuleExclusions; // 内置代理组排除的预设规则 + proxyGroupAdvanced: Record; // 内置分流组高级筛选/排序配置 + proxyGroupAdvancedModeEnabled: boolean; // 分流组高级模式 UI 开关 + customRuleSets: CustomRuleSet[]; // 用户新增规则集,统一进入自定义规则块 + builtinRuleEdits: BuiltinRuleEdits; // 内置规则的目标覆盖或禁用状态 customRules: CustomRule[]; dialerProxyGroups: DialerProxyGroup[]; @@ -181,16 +184,13 @@ export interface ConfigState { proxyGroupNameOverrides: Record; // 代理组顺序(影响 Clash 客户端中的展示顺序;由可视化预览拖拽维护) - // Key 格式:module: / custom: / filtered: / dialer: + // Key 格式:module: / custom: / dialer: 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; @@ -259,17 +258,15 @@ export interface ConfigActions { // 代理组顺序 setProxyGroupOrder: (order: string[]) => void; - // 筛选代理组 - addFilteredProxyGroup: (group: Omit) => void; - removeFilteredProxyGroup: (id: string) => void; - updateFilteredProxyGroup: (id: string, group: Partial) => void; + // 分流组高级配置 + updateProxyGroupAdvanced: (moduleId: string, patch: Partial) => void; - // 内置分流组附加规则集 - addModuleRules: (moduleId: string, rules: 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 +275,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; @@ -297,6 +295,7 @@ export interface ConfigActions { setTestUrl: (url: string) => void; setTestInterval: (interval: number) => void; setRuleProviderBaseUrl: (url: string) => void; + setProxyGroupAdvancedModeEnabled: (value: boolean) => void; setCnIpNoResolve: (value: boolean) => void; setExperimentalCnUseCnRuleSet: (value: boolean) => void; setListenerPort: (nodeName: string, port: number | null) => void; @@ -341,15 +340,15 @@ export const initialState: ConfigState = { enabledProxyGroups: TEMPLATES.minimal.groups, hiddenProxyGroups: [], customProxyGroups: [], // 自定义分流组 - filteredProxyGroups: [], - moduleRuleOverrides: {}, - moduleRuleExclusions: {}, + proxyGroupAdvanced: {}, + proxyGroupAdvancedModeEnabled: false, + 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..b202822 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, - filteredProxyGroups: state.filteredProxyGroups, - moduleRuleOverrides: state.moduleRuleOverrides, - moduleRuleExclusions: state.moduleRuleExclusions, + customRuleSets: state.customRuleSets, + builtinRuleEdits: state.builtinRuleEdits, + proxyGroupAdvanced: state.proxyGroupAdvanced, 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..8f359f3 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; @@ -55,6 +55,9 @@ export function normalizePersistedConfigState( ...(typeof state.testUrl === "string" ? { testUrl: state.testUrl } : {}), ...(typeof state.testInterval === "number" ? { testInterval: state.testInterval } : {}), ...(typeof state.ruleProviderBaseUrl === "string" ? { ruleProviderBaseUrl: state.ruleProviderBaseUrl } : {}), + ...(typeof state.proxyGroupAdvancedModeEnabled === "boolean" + ? { proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled } + : {}), cnIpNoResolve: typeof state.cnIpNoResolve === "boolean" ? state.cnIpNoResolve : true, experimentalCnUseCnRuleSet: typeof state.experimentalCnUseCnRuleSet === "boolean" ? state.experimentalCnUseCnRuleSet : true, @@ -72,6 +75,7 @@ export function partializeConfigState(state: ConfigState): Partial testUrl: state.testUrl, testInterval: state.testInterval, ruleProviderBaseUrl: state.ruleProviderBaseUrl, + proxyGroupAdvancedModeEnabled: state.proxyGroupAdvancedModeEnabled, cnIpNoResolve: state.cnIpNoResolve, experimentalCnUseCnRuleSet: state.experimentalCnUseCnRuleSet, }; diff --git a/packages/ui/src/store/config-store/source-actions-multiple.test.ts b/packages/ui/src/store/config-store/source-actions-multiple.test.ts index da57f7e..53c4c0c 100644 --- a/packages/ui/src/store/config-store/source-actions-multiple.test.ts +++ b/packages/ui/src/store/config-store/source-actions-multiple.test.ts @@ -207,6 +207,29 @@ describe("createSourceActions parseMultipleSources", () => { }); }); + it("ignores non-numeric URL userinfo and keeps raw URL content when normalization fails", async () => { + mocks.fetchUrlContentInBrowser.mockResolvedValueOnce({ + content: "ss://remote", + headers: { + "subscription-userinfo": "upload=bad; expire=never", + }, + }); + mocks.parseSubscription.mockReturnValueOnce(parseResult([node("Remote")])); + const sources = [source({ id: "url-ok", type: "url", content: "not a url" })]; + const { actions, getState } = createHarness({ sources }); + + await actions.parseMultipleSources(sources); + + expect(getState().nodes).toEqual([ + expect.objectContaining({ name: "Remote", _originName: "Remote", _sourceIds: ["url-ok"] }), + ]); + expect(getState().sources[0]).toMatchObject({ + parsed: true, + subscriptionUserInfo: undefined, + lastParsedContent: "not a url", + }); + }); + it("merges duplicate parsed nodes and prunes stale listener ports and dialer nodes", async () => { const duplicate = node("Duplicate", { server: "same.example.com", @@ -285,4 +308,54 @@ describe("createSourceActions parseMultipleSources", () => { }); expect(getState().listenerPorts).toEqual({ Duplicate: 41000 }); }); + + it("keeps repeated origins when multiple distinct deleted-origin nodes are reimported", async () => { + mocks.parseSubscription.mockReturnValueOnce( + parseResult([ + node("Shared Origin", { + _originName: "Shared Origin", + server: "one.example.com", + }), + node("Shared Origin", { + _originName: "Shared Origin", + server: "two.example.com", + }), + ]) + ); + const sources = [source({ id: "s1", type: "yaml", content: "one" })]; + const { actions, getState } = createHarness({ + sources, + deletedNodeNames: ["Shared Origin"], + }); + + await actions.parseMultipleSources(sources); + + expect(getState().nodes).toEqual([ + expect.objectContaining({ name: "Shared Origin", _originName: "Shared Origin", server: "one.example.com" }), + expect.objectContaining({ name: "Shared Origin (2)", _originName: "Shared Origin", server: "two.example.com" }), + ]); + }); + + it("filters deleted node identity records while keeping blank-origin fallbacks", async () => { + const deleted = node("Gone"); + const blankOrigin = node("Blank Origin", { _originName: " " }); + mocks.parseSubscription.mockReturnValueOnce(parseResult([deleted, blankOrigin, node("Fresh")])); + const sources = [source({ id: "s1", type: "yaml", content: "one" })]; + const { actions, getState } = createHarness({ + sources, + deletedNodes: [ + { originName: " Gone ", node: deleted }, + { node: null }, + { originName: " ", node: blankOrigin }, + ], + }); + + await actions.parseMultipleSources(sources); + + expect(getState().nodes.map((item: ParsedNode) => item.name)).toEqual(["Blank Origin", "Fresh"]); + expect(getState().sources[0]).toMatchObject({ + parsed: true, + nodeCount: 3, + }); + }); }); diff --git a/packages/ui/src/store/config-store/source-actions.test.ts b/packages/ui/src/store/config-store/source-actions.test.ts index 1244a11..c3baf95 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", @@ -293,6 +337,28 @@ describe("createSourceActions", () => { }); }); + it("keeps original URL text when a fetched single source cannot be normalized", async () => { + mocks.fetchUrlContentInBrowser.mockResolvedValueOnce({ + content: "ss://remote", + headers: {}, + }); + mocks.parseSubscription.mockReturnValueOnce(parseResult([node("Remote Node")])); + const { actions, getState } = createHarness({ + sources: [source({ id: "s1", type: "url", content: "not a url" })], + }); + + await actions.parseSingleSource("s1"); + + expect(getState().nodes).toEqual([ + expect.objectContaining({ name: "Remote Node", _originName: "Remote Node", _sourceIds: ["s1"] }), + ]); + expect(getState().sources[0]).toMatchObject({ + parsed: true, + lastParsedContent: "not a url", + subscriptionUserInfo: undefined, + }); + }); + it("reimports changed URL sources while preserving unrelated source state", async () => { mocks.fetchUrlContentInBrowser.mockResolvedValueOnce({ content: "ss://fresh", 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); }); 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, 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, };