diff --git a/README-CN.md b/README-CN.md index 421d12d..dfe967a 100644 --- a/README-CN.md +++ b/README-CN.md @@ -4,7 +4,7 @@

SubBoost

平台:Linux + Docker - 版本 2.5.1 + 版本 2.6.0 在线入口 文档 GHCR 镜像 diff --git a/README.md b/README.md index 27a159c..caa58fa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

SubBoost

Platform: Linux + Docker - Version 2.5.1 + Version 2.6.0 Online app Documentation GHCR image diff --git a/docs/release-notes.md b/docs/release-notes.md index 7c045e0..1f1a7b6 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,43 +1,43 @@ -# SubBoost v2.5.1 +# SubBoost v2.6.0 ## 中文 ### 更新重点 -SubBoost v2.5.1 是一个修复版本,主要修复自定义代理组的默认成员逻辑,并提升自部署安装、更新时的版本来源稳定性。建议 v2.5.0 用户升级。 +SubBoost v2.6.0 改进了高级代理组的成员管理,并增强多种订阅格式和 ECH 参数的兼容性。自部署备份流程也更加可靠。建议 v2.5.1 用户升级。 ### 主要变化 -- 修复新建自定义代理组时默认成员过宽的问题,避免默认生成出“节点选择”和自定义组互相引用的循环配置。 -- `🚀 节点选择` 默认不再自动包含自定义代理组,减少代理组套娃导致配置异常或预览卡顿的风险。 -- 新建普通自定义代理组默认只包含 `DIRECT`、`REJECT` 和真实节点,保留高级模式里的手动配置能力。 -- 改进自部署安装和 `subboost update` 的版本来源处理,稳定版安装资产会默认固定到对应 release 版本。 -- 改进自部署管理脚本的更新来源,减少从指定版本更新时意外跟随其它发布通道的风险。 +- 高级代理组现在可以一键添加或移除全部节点、全部代理组,并可恢复默认成员;批量添加代理组时会自动跳过可能形成循环引用的项目。 +- 代理组成员区域会分别显示节点数和代理组数,代理组卡片的节点统计只计算真实节点,不再把 `DIRECT`、`REJECT` 或其它代理组引用计入节点数量。 +- 改进 Clash/Mihomo YAML 导入兼容性,可识别以单行对象列表书写的节点配置,并容忍这类列表中常见的缩进不一致。 +- 修复 VMess、VLESS、Trojan 和 AnyTLS 分享链接中的 ECH 查询服务器名称在转换时丢失的问题。 +- 自部署备份现在会在数据库导出失败时安全终止,不再把不完整文件当作成功备份;备份保留数量可通过 `SUBBOOST_BACKUP_RETENTION_COUNT` 调整,默认仍保留 10 份。 ### 升级说明 - 建议升级前备份 `/opt/subboost/.env` 和数据库,方便需要时回滚。 -- 已安装 v2.5.0 的自部署实例可以继续使用 `subboost update` 更新。 -- 普通订阅转换、模板和规则功能不需要手动改环境变量。 -- 已经手动配置过的高级代理组不会被自动改写;本次主要改变新建和默认生成行为。 +- 现有 v2.5.1 自部署实例可以继续使用 `subboost update` 更新。 +- 本次升级不要求手动迁移数据库,也不要求新增环境变量;只有需要修改默认备份保留数量时,才需要设置 `SUBBOOST_BACKUP_RETENTION_COUNT`。 +- 现有订阅、模板、规则和高级代理组配置保持兼容,不会因为升级自动改写成员选择。 ## English ### Highlights -SubBoost v2.5.1 is a patch release that fixes default custom proxy group membership and improves version-source stability for self-hosted install and update flows. v2.5.0 users are encouraged to upgrade. +SubBoost v2.6.0 improves member management for advanced proxy groups and expands compatibility with several subscription formats and ECH parameters. It also makes self-hosted backups more reliable. Users on v2.5.1 are encouraged to upgrade. ### Main Changes -- Fixed overly broad default members for newly created custom proxy groups, preventing default configurations where node selection and custom groups reference each other in a loop. -- `🚀 节点选择` no longer automatically includes custom proxy groups by default, reducing the risk of nested proxy group loops causing invalid output or preview slowdowns. -- Newly created normal custom proxy groups now default to `DIRECT`, `REJECT`, and real nodes only, while manual advanced configuration remains available. -- Improved self-hosted install and `subboost update` version-source handling so stable release assets stay pinned to the matching release version by default. -- Improved the self-hosted manager update source to reduce the risk of a pinned-version update unexpectedly following another release channel. +- Advanced proxy groups can now add or remove all nodes or all proxy groups in one action, and restore their default members. Bulk proxy-group additions automatically skip entries that could create circular references. +- Proxy-group member sections now show separate node and proxy-group counts. Node totals on proxy-group cards count real nodes only, excluding `DIRECT`, `REJECT`, and references to other proxy groups. +- Improved Clash/Mihomo YAML import compatibility for node configurations written as lists of single-line objects, including common indentation inconsistencies in those lists. +- Fixed ECH query server names being lost when converting VMess, VLESS, Trojan, and AnyTLS share links. +- Self-hosted backups now stop safely when the database export fails instead of treating an incomplete file as a successful backup. Backup retention can be adjusted with `SUBBOOST_BACKUP_RETENTION_COUNT`, while the default remains 10 backups. ### Upgrade Notes - Back up `/opt/subboost/.env` and the database before upgrading so rollback is easier if needed. -- Existing v2.5.0 self-hosted installations can continue to update with `subboost update`. -- Normal subscription conversion, templates, and rules do not require manual environment-variable changes. -- Manually configured advanced proxy groups are not rewritten automatically; this release mainly changes new and default generated behavior. +- Existing v2.5.1 self-hosted installations can continue to update with `subboost update`. +- This upgrade does not require a manual database migration or a new environment variable. Set `SUBBOOST_BACKUP_RETENTION_COUNT` only if you want to change the default backup retention count. +- Existing subscriptions, templates, rules, and advanced proxy-group configurations remain compatible, and upgrading does not automatically rewrite member selections. diff --git a/local/package.json b/local/package.json index 86c48f8..d294337 100644 --- a/local/package.json +++ b/local/package.json @@ -1,6 +1,6 @@ { "name": "@subboost/local", - "version": "2.5.1", + "version": "2.6.0", "license": "AGPL-3.0-only", "private": true, "type": "module", diff --git a/local/scripts/selfhost-shell.test.ts b/local/scripts/selfhost-shell.test.ts index a3bb485..3895239 100644 --- a/local/scripts/selfhost-shell.test.ts +++ b/local/scripts/selfhost-shell.test.ts @@ -282,8 +282,8 @@ ENV expect(result.status).toBe(0); expect(result.stdout).toContain("健康检查: 正常"); expect(result.stdout).not.toContain("健康检查: 异常"); - // wait_for_health stops once ready succeeds, then status_cmd performs one final live+ready check. - expect(result.stdout).toContain("curl_count=7"); + // wait_for_health checks live once per attempt, then status_cmd performs one final live+ready check. + expect(result.stdout).toContain("curl_count=8"); }, 10_000); it("uses refreshed release metadata before pulling during update", () => { diff --git a/local/scripts/subboost.sh b/local/scripts/subboost.sh index 51a13f1..c564fd7 100644 --- a/local/scripts/subboost.sh +++ b/local/scripts/subboost.sh @@ -3,6 +3,7 @@ set -Eeuo pipefail DEFAULT_HOME="/opt/subboost" DEFAULT_STABLE_RELEASE_URL="https://github.com/SubBoost/subboost/releases/latest/download/release.json" +DEFAULT_BACKUP_RETENTION_COUNT="10" SUBBOOST_HOME="${SUBBOOST_HOME:-$DEFAULT_HOME}" ENV_FILE="$SUBBOOST_HOME/.env" COMPOSE_FILE="$SUBBOOST_HOME/docker-compose.yml" @@ -225,17 +226,25 @@ health_status_text() { } health_status_code() { - local port base + local port base live_ok port="$(port_number "${SUBBOOST_PORT:-3000}")" base="http://127.0.0.1:$port" if ! command -v curl >/dev/null 2>&1; then printf 'curl-missing\n' - elif curl -fsS "$base/api/health/live" >/dev/null 2>&1 && curl -fsS "$base/api/health/ready" >/dev/null 2>&1; then - printf 'ok\n' - elif curl -fsS "$base/api/health/live" >/dev/null 2>&1; then - printf 'not-ready\n' else - printf 'unhealthy\n' + if curl -fsS "$base/api/health/live" >/dev/null 2>&1; then + live_ok=1 + else + live_ok=0 + fi + + if [ "$live_ok" = "1" ] && curl -fsS "$base/api/health/ready" >/dev/null 2>&1; then + printf 'ok\n' + elif [ "$live_ok" = "1" ]; then + printf 'not-ready\n' + else + printf 'unhealthy\n' + fi fi } @@ -249,6 +258,7 @@ health_status_label() { } wait_for_health() { + # Default max wait is about 30 seconds: 15 attempts with a 2-second interval. local attempts="${SUBBOOST_DOCTOR_HEALTH_ATTEMPTS:-15}" local interval="${SUBBOOST_DOCTOR_HEALTH_INTERVAL_SECONDS:-2}" local index status @@ -336,12 +346,25 @@ backup_cmd() { sudo_do mkdir -p "$BACKUP_DIR" local stamp db_tmp db_out env_out local -a sql_backups env_backups - local i + local -a backup_status + local i backup_retention_count + backup_retention_count="${SUBBOOST_BACKUP_RETENTION_COUNT:-$DEFAULT_BACKUP_RETENTION_COUNT}" + if ! [[ "$backup_retention_count" =~ ^[0-9]+$ ]] || (( backup_retention_count < 1 )); then + die "SUBBOOST_BACKUP_RETENTION_COUNT must be a positive integer" + fi stamp="$(date -u +%Y%m%dT%H%M%SZ)" db_tmp="$BACKUP_DIR/subboost-$stamp.sql.gz.partial" db_out="$BACKUP_DIR/subboost-$stamp.sql.gz" env_out="$BACKUP_DIR/subboost-$stamp.env" + set +e compose exec -T db pg_dump -U "${POSTGRES_USER:-subboost}" -d "${POSTGRES_DB:-subboost}" | gzip -c | sudo_do tee "$db_tmp" >/dev/null + backup_status=("${PIPESTATUS[@]}") + set -e + if (( backup_status[0] != 0 || backup_status[1] != 0 || backup_status[2] != 0 )); then + sudo_do rm -f -- "$db_tmp" + say "Backup failed: pg_dump=${backup_status[0]} gzip=${backup_status[1]} write=${backup_status[2]}" + return 1 + fi sudo_do mv "$db_tmp" "$db_out" sudo_do install -m 600 "$ENV_FILE" "$env_out" @@ -350,10 +373,10 @@ backup_cmd() { env_backups=("$BACKUP_DIR"/subboost-*.env) shopt -u nullglob - for ((i = 0; i < ${#sql_backups[@]} - 10; i++)); do + for ((i = 0; i < ${#sql_backups[@]} - backup_retention_count; i++)); do sudo_do rm -f -- "${sql_backups[$i]}" done - for ((i = 0; i < ${#env_backups[@]} - 10; i++)); do + for ((i = 0; i < ${#env_backups[@]} - backup_retention_count; i++)); do sudo_do rm -f -- "${env_backups[$i]}" done say "Backup written:" diff --git a/package-lock.json b/package-lock.json index ec0d0d6..b2793aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "subboost", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "subboost", - "version": "2.5.1", + "version": "2.6.0", "license": "AGPL-3.0-only", "workspaces": [ "packages/*", @@ -67,7 +67,7 @@ }, "local": { "name": "@subboost/local", - "version": "2.5.1", + "version": "2.6.0", "license": "AGPL-3.0-only", "dependencies": { "@prisma/adapter-pg": "^7.8.0", diff --git a/package.json b/package.json index 795f5dc..f95b720 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subboost", - "version": "2.5.1", + "version": "2.6.0", "license": "AGPL-3.0-only", "private": true, "engines": { diff --git a/packages/core/src/mihomo/ech.test.ts b/packages/core/src/mihomo/ech.test.ts new file mode 100644 index 0000000..420f13e --- /dev/null +++ b/packages/core/src/mihomo/ech.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + buildMihomoEchOptsFromShareValue, + isMihomoEchQueryServerName, + isStandardBase64String, +} from "./ech"; + +describe("Mihomo ECH helpers", () => { + it("classifies share values without promoting arbitrary text", () => { + expect(buildMihomoEchOptsFromShareValue(null)).toEqual({ enable: true }); + expect(buildMihomoEchOptsFromShareValue(" ")).toEqual({ enable: true }); + expect(buildMihomoEchOptsFromShareValue(" Y29uZmln ")).toEqual({ enable: true, config: "Y29uZmln" }); + expect(buildMihomoEchOptsFromShareValue(" cloudflare-ech.com ")).toEqual({ + enable: true, + "query-server-name": "cloudflare-ech.com", + }); + expect(buildMihomoEchOptsFromShareValue("not base64!")).toEqual({ enable: true }); + }); + + it("keeps Base64 and DNS-name validation bounded and conservative", () => { + expect(isStandardBase64String("+w==")).toBe(true); + expect(isStandardBase64String("dGVzdA")).toBe(false); + expect(isMihomoEchQueryServerName("ech.example.com")).toBe(true); + expect(isMihomoEchQueryServerName("ech.example.com.")).toBe(true); + expect(isMihomoEchQueryServerName("1.1.1.1")).toBe(false); + expect(isMihomoEchQueryServerName("single-label")).toBe(false); + expect(isMihomoEchQueryServerName(`bad_label.${"a".repeat(64)}.example.com`)).toBe(false); + expect(isMihomoEchQueryServerName(`${"a".repeat(100_000)}.example.com`)).toBe(false); + }); +}); diff --git a/packages/core/src/mihomo/ech.ts b/packages/core/src/mihomo/ech.ts new file mode 100644 index 0000000..cf6a13f --- /dev/null +++ b/packages/core/src/mihomo/ech.ts @@ -0,0 +1,31 @@ +const STANDARD_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const DNS_LABEL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/; + +export interface MihomoEchOpts { + enable: true; + config?: string; + "query-server-name"?: string; +} + +export function isStandardBase64String(value: string): boolean { + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.length % 4 === 0 && STANDARD_BASE64_PATTERN.test(trimmed); +} + +export function isMihomoEchQueryServerName(value: string): boolean { + const trimmed = value.trim(); + const hostname = trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed; + if (!hostname || hostname.length > 253 || !hostname.includes(".") || !/[A-Za-z]/.test(hostname)) return false; + + return hostname.split(".").every((label) => label.length <= 63 && DNS_LABEL_PATTERN.test(label)); +} + +export function buildMihomoEchOptsFromShareValue(value: unknown): MihomoEchOpts { + const normalized = typeof value === "string" ? value.trim() : ""; + if (!normalized) return { enable: true }; + if (isStandardBase64String(normalized)) return { enable: true, config: normalized }; + if (isMihomoEchQueryServerName(normalized)) { + return { enable: true, "query-server-name": normalized }; + } + return { enable: true }; +} diff --git a/packages/core/src/mihomo/proxy-sanitizer-ech.test.ts b/packages/core/src/mihomo/proxy-sanitizer-ech.test.ts new file mode 100644 index 0000000..5c6b6d9 --- /dev/null +++ b/packages/core/src/mihomo/proxy-sanitizer-ech.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeMihomoProxyNode } from "./proxy-sanitizer"; + +describe("Mihomo ECH sanitizer compatibility", () => { + it("repairs legacy domain-valued config without overriding explicit query server names", () => { + const legacy = sanitizeMihomoProxyNode({ + name: "Legacy ECH", + type: "vless", + server: "legacy.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + "ech-opts": { enable: true, config: " cloudflare-ech.com " }, + }); + const explicitFirst = sanitizeMihomoProxyNode({ + name: "Explicit First", + type: "vless", + server: "explicit-first.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + "ech-opts": { + "query-server-name": "explicit.example.com", + config: "legacy.example.com", + }, + }); + const explicitLast = sanitizeMihomoProxyNode({ + name: "Explicit Last", + type: "vless", + server: "explicit-last.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + "ech-opts": { + config: "legacy.example.com", + "query-server-name": "explicit.example.com", + }, + }); + const invalid = sanitizeMihomoProxyNode({ + name: "Invalid ECH", + type: "vless", + server: "invalid.example.com", + port: 443, + uuid: "11111111-1111-4111-8111-111111111111", + "ech-opts": { enable: true, config: "not base64!" }, + }); + + expect(legacy["ech-opts"]).toEqual({ enable: true, "query-server-name": "cloudflare-ech.com" }); + expect(explicitFirst["ech-opts"]).toEqual({ "query-server-name": "explicit.example.com" }); + expect(explicitLast["ech-opts"]).toEqual({ "query-server-name": "explicit.example.com" }); + expect(invalid["ech-opts"]).toEqual({ enable: true }); + }); +}); diff --git a/packages/core/src/mihomo/proxy-sanitizer.ts b/packages/core/src/mihomo/proxy-sanitizer.ts index 632703d..3d8f941 100644 --- a/packages/core/src/mihomo/proxy-sanitizer.ts +++ b/packages/core/src/mihomo/proxy-sanitizer.ts @@ -1,8 +1,8 @@ import type { ParsedNode } from "@subboost/core/types/node"; import { splitWsPathEarlyData } from "@subboost/core/parser/ws-early-data"; +import { isMihomoEchQueryServerName, isStandardBase64String } from "./ech"; import { normalizeRealityShortId } from "./reality"; -const STANDARD_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const REALITY_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/; const WIREGUARD_KEY_PATTERN = /^[A-Za-z0-9+/]{43}=$/; const CERTIFICATE_FINGERPRINT_HEX_PATTERN = /^[A-Fa-f0-9]{64}$/; @@ -158,10 +158,7 @@ function normalizeSshServerFingerprint(value: unknown): string | null { return normalized; } -export function isStandardBase64String(value: string): boolean { - const trimmed = value.trim(); - return trimmed.length > 0 && trimmed.length % 4 === 0 && STANDARD_BASE64_PATTERN.test(trimmed); -} +export { isStandardBase64String } from "./ech"; export function normalizeMihomoRealityPublicKey(input: unknown): string | null { const value = normalizeString(input); @@ -240,6 +237,7 @@ function sanitizeEchOpts(value: unknown): unknown { if (!isPlainObject(value)) return undefined; const out: Record = {}; + const explicitQueryServerName = normalizeString(value["query-server-name"]); for (const [key, raw] of Object.entries(value)) { if (key.startsWith("_")) continue; @@ -251,13 +249,16 @@ function sanitizeEchOpts(value: unknown): unknown { if (key === "config") { const config = normalizeString(raw); - if (config && isStandardBase64String(config)) out[key] = config; + if (config && isStandardBase64String(config)) { + out[key] = config; + } else if (config && !explicitQueryServerName && isMihomoEchQueryServerName(config)) { + out["query-server-name"] = config; + } continue; } if (key === "query-server-name") { - const queryServerName = normalizeString(raw); - if (queryServerName) out[key] = queryServerName; + if (explicitQueryServerName) out[key] = explicitQueryServerName; continue; } diff --git a/packages/core/src/parser/clash-yaml.test.ts b/packages/core/src/parser/clash-yaml.test.ts index 4fd10a5..102aef5 100644 --- a/packages/core/src/parser/clash-yaml.test.ts +++ b/packages/core/src/parser/clash-yaml.test.ts @@ -208,6 +208,75 @@ proxies: expect(mixed.errors[0]).toContain('节点 "Bad" 解析失败'); }); + it("parses consistently indented root flow proxy lists", () => { + for (const spaces of [0, 1, 2, 4]) { + const indent = " ".repeat(spaces); + const result = parseClashYaml([ + "proxies:", + `${indent}- {name: A, type: vless, server: 2001:db8::1, port: 443, uuid: 11111111-1111-4111-8111-111111111111}`, + `${indent}- {name: B, type: vless, server: 2001:db8::2, port: 8443, uuid: 22222222-2222-4222-8222-222222222222}`, + ].join("\n")); + + expect(result.errors).toEqual([]); + expect(result.nodes.map((node) => node.name)).toEqual(["A", "B"]); + } + }); + + it("repairs inconsistent root flow proxy list indentation", () => { + for (const secondIndent of [1, 4]) { + const result = parseClashYaml([ + "proxies:", + " - {name: A, type: vless, server: 2001:db8::1, port: 443, uuid: 11111111-1111-4111-8111-111111111111}", + `${" ".repeat(secondIndent)}- {name: B, type: vless, server: 2001:db8::2, port: 8443, uuid: 22222222-2222-4222-8222-222222222222, reality-opts: {public-key: test-key}}`, + ].join("\n")); + + expect(result.errors).toEqual([]); + expect(result.nodes).toMatchObject([ + { name: "A", server: "2001:db8::1", port: 443, type: "vless" }, + { + name: "B", + server: "2001:db8::2", + port: 8443, + type: "vless", + "reality-opts": { "public-key": "test-key" }, + }, + ]); + } + }); + + it("does not repair nested, mixed, or unclosed flow proxy structures", () => { + const nested = parseClashYaml([ + "proxy-groups:", + " - name: Group", + " type: select", + " proxies:", + " - {name: A, type: vless, server: example.com, port: 443}", + " - {name: B, type: vless, server: example.net, port: 443}", + ].join("\n")); + const mixed = parseClashYaml([ + "proxies:", + " - {name: A, type: vless, server: example.com, port: 443}", + " - name: B", + " type: vless", + " server: example.net", + " port: 443", + ].join("\n")); + const unclosed = parseClashYaml("proxies:\n - {name: A, type: vless, server: example.com, port: 443"); + + for (const result of [nested, mixed, unclosed]) { + expect(result.nodes).toEqual([]); + expect(result.errors[0]).toContain("YAML 解析错误"); + } + }); + + it("keeps YAML backslash-underscore escape semantics in quoted names", () => { + const result = parseClashYaml(String.raw`proxies: + - {name: "x1.0\_50M", type: vless, server: example.com, port: 443}`); + + expect(result.errors).toEqual([]); + expect(result.nodes[0]?.name).toBe("x1.0\u00a050M"); + }); + it("handles provider variants, array noise, and malformed proxy rows", () => { expect(parseClashYaml("[]")).toEqual({ nodes: [], diff --git a/packages/core/src/parser/clash-yaml.ts b/packages/core/src/parser/clash-yaml.ts index 03dc359..26381e6 100644 --- a/packages/core/src/parser/clash-yaml.ts +++ b/packages/core/src/parser/clash-yaml.ts @@ -44,6 +44,68 @@ function normalizeClashYamlScalarText(content: string): string { ); } +function countLeadingSpaces(line: string): number { + let count = 0; + while (count < line.length && line.charCodeAt(count) === 32) count += 1; + return count; +} + +function isRootProxiesKey(trimmed: string): boolean { + if (trimmed === "proxies:") return true; + if (!trimmed.startsWith("proxies:")) return false; + return trimmed.slice("proxies:".length).trimStart().startsWith("#"); +} + +function isSingleLineFlowSequenceItem(trimmed: string): boolean { + if (!trimmed.startsWith("- {")) return false; + const closingBrace = trimmed.lastIndexOf("}"); + if (closingBrace < 3) return false; + const trailing = trimmed.slice(closingBrace + 1).trim(); + return !trailing || trailing.startsWith("#"); +} + +function repairRootFlowProxyListIndent(input: string): string { + const lines = input.split(/\r?\n/); + let rootMappingIndent = Number.POSITIVE_INFINITY; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue; + const colonIndex = trimmed.indexOf(":"); + if (colonIndex <= 0) continue; + rootMappingIndent = Math.min(rootMappingIndent, countLeadingSpaces(line)); + } + + if (rootMappingIndent === Number.POSITIVE_INFINITY) return input; + + const proxiesLineIndex = lines.findIndex((line) => { + const trimmed = line.trim(); + return countLeadingSpaces(line) === rootMappingIndent && isRootProxiesKey(trimmed); + }); + if (proxiesLineIndex < 0) return input; + + const itemIndexes: number[] = []; + for (let index = proxiesLineIndex + 1; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const indent = countLeadingSpaces(line); + if (!trimmed.startsWith("-") && indent <= rootMappingIndent) break; + if (indent < rootMappingIndent || !isSingleLineFlowSequenceItem(trimmed)) return input; + itemIndexes.push(index); + } + + if (itemIndexes.length < 2) return input; + const canonicalIndent = countLeadingSpaces(lines[itemIndexes[0]]); + if (itemIndexes.every((index) => countLeadingSpaces(lines[index]) === canonicalIndent)) return input; + + for (const index of itemIndexes) { + lines[index] = `${" ".repeat(canonicalIndent)}${lines[index].trimStart()}`; + } + return lines.join("\n"); +} + /** * 解析 Clash YAML 配置 */ @@ -111,7 +173,7 @@ export function parseClashYaml(content: string): ParseResult { parsed = tryLoad(normalizedContent); } catch (e) { // 尝试修复缩进后再解析一次 - const repaired = repairInlineListIndent(normalizedContent); + const repaired = repairRootFlowProxyListIndent(repairInlineListIndent(normalizedContent)); parsed = tryLoad(repaired); } @@ -452,4 +514,3 @@ function normalizeNode(proxy: Record): ParsedNode | null { return { ...(baseNode as Record), type: type as UnknownNodeType } as unknown as ParsedNode; } } - diff --git a/packages/core/src/parser/content-parsers.test.ts b/packages/core/src/parser/content-parsers.test.ts index 91784d6..81749a6 100644 --- a/packages/core/src/parser/content-parsers.test.ts +++ b/packages/core/src/parser/content-parsers.test.ts @@ -8,6 +8,7 @@ import { splitNodeLinkSegments, } from "./content-parsers"; import { encodeBase64 } from "./base64"; +import { parseSubscription } from "./index"; function ssLink(name = "SS Node"): string { return `ss://${encodeBase64("aes-128-gcm:secret")}@ss.example.com:8388#${encodeURIComponent(name)}`; @@ -31,6 +32,31 @@ describe("content parser registry helpers", () => { expect(isClashYamlContent("ss://not-yaml")).toBe(false); }); + it("detects and parses top-level inline flow proxy arrays", () => { + const input = [ + '- {name: "IPv6 A", type: vless, server: 2001:db8::1, port: 443, uuid: 11111111-1111-4111-8111-111111111111}', + '- {name: "IPv6 B", type: vless, server: 2001:db8::2, port: 8443, uuid: 22222222-2222-4222-8222-222222222222}', + ].join("\n"); + + expect(isClashYamlContent(input)).toBe(true); + expect(parseSubscription(input)).toMatchObject({ + totalParsed: 2, + totalFailed: 0, + nodes: [ + { name: "IPv6 A", server: "2001:db8::1", port: 443, type: "vless" }, + { name: "IPv6 B", server: "2001:db8::2", port: 8443, type: "vless" }, + ], + }); + }); + + it("routes long malformed inline flow arrays to YAML errors without expensive matching", () => { + const input = `- {name: "${"x".repeat(100_000)}", type: vless, server: example.com, port: 443`; + const result = parseSubscription(input); + + expect(result.nodes).toEqual([]); + expect(result.errors[0]).toContain("YAML 解析错误"); + }); + it("parses link lines and keeps per-segment errors", () => { const result = parseLineBasedSubscriptionContent(`${ssLink()}\nftp://not-supported`); diff --git a/packages/core/src/parser/content-parsers.ts b/packages/core/src/parser/content-parsers.ts index 4636be6..c1a0432 100644 --- a/packages/core/src/parser/content-parsers.ts +++ b/packages/core/src/parser/content-parsers.ts @@ -59,6 +59,26 @@ export function splitNodeLinkSegments(content: string): string[] { return segments; } +function looksLikeInlineFlowProxyList(content: string): boolean { + let hasItem = false; + let hasType = false; + let hasServer = false; + let hasPort = false; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (!trimmed.startsWith("- {")) return false; + + hasItem = true; + hasType ||= trimmed.includes("type:"); + hasServer ||= trimmed.includes("server:"); + hasPort ||= trimmed.includes("port:") || trimmed.includes("ports:"); + } + + return hasItem && hasType && hasServer && hasPort; +} + export function isClashYamlContent(content: string): boolean { if ( content.includes("proxies:") || @@ -68,6 +88,8 @@ export function isClashYamlContent(content: string): boolean { return true; } + if (looksLikeInlineFlowProxyList(content)) return true; + const hasType = /(^|\n)\s*(?:-\s*)?type\s*:\s*\S+/i.test(content); const hasServer = /(^|\n)\s*(?:-\s*)?server\s*:\s*\S+/i.test(content); const hasPort = /(^|\n)\s*(?:-\s*)?port\s*:\s*\d+/i.test(content); @@ -180,4 +202,3 @@ export function parseSubscriptionContentByRegistry(content: string): ParseResult } - diff --git a/packages/core/src/parser/parser-contracts.test.ts b/packages/core/src/parser/parser-contracts.test.ts index ace17ec..07d457b 100644 --- a/packages/core/src/parser/parser-contracts.test.ts +++ b/packages/core/src/parser/parser-contracts.test.ts @@ -261,7 +261,7 @@ Direct Policy = direct it("parses AnyTLS TCP-safe options and rejects Reality params", () => { const node = mustParseNode( - "anytls://secret@anytls.example.com:443?alpn=h2,http%2F1.1&allowInsecure=true&fp=chrome&idle-session-check-interval=60&idle-session-timeout=120&min-idle-session=2&padding-scheme=100-200&ech=config#AnyTLS" + "anytls://secret@anytls.example.com:443?alpn=h2,http%2F1.1&allowInsecure=true&fp=chrome&idle-session-check-interval=60&idle-session-timeout=120&min-idle-session=2&padding-scheme=100-200&ech=Y29uZmln#AnyTLS" ); expect(node).toMatchObject({ @@ -281,7 +281,7 @@ Direct Policy = direct "padding-scheme": "100-200", "ech-opts": { enable: true, - config: "config", + config: "Y29uZmln", }, }); expect(() => mustParseNode("anytls://secret@anytls.example.com:443?security=reality#Bad")).toThrow( diff --git a/packages/core/src/parser/protocols/anytls.ts b/packages/core/src/parser/protocols/anytls.ts index 512fbe3..b7aa3c8 100644 --- a/packages/core/src/parser/protocols/anytls.ts +++ b/packages/core/src/parser/protocols/anytls.ts @@ -8,6 +8,7 @@ import { decodeBase64 } from "../base64"; import type { AnyTLSNode } from "@subboost/core/types/node"; +import { buildMihomoEchOptsFromShareValue } from "@subboost/core/mihomo/ech"; import { parseUrlWithNeutralScheme, safeDecodeFormUrlEncoded, safeDecodeURIComponent } from "./url-decode"; function parseBool(value: string): boolean | undefined { @@ -221,10 +222,7 @@ export function parseAnyTLS(uri: string): AnyTLSNode { }; if (echPresent) { - (node as unknown as Record)["ech-opts"] = { - enable: true, - ...(echValue ? { config: echValue } : {}), - }; + (node as unknown as Record)["ech-opts"] = buildMihomoEchOptsFromShareValue(echValue); } if (sni) node.sni = sni; diff --git a/packages/core/src/parser/protocols/ech-protocols.test.ts b/packages/core/src/parser/protocols/ech-protocols.test.ts new file mode 100644 index 0000000..0dd10a8 --- /dev/null +++ b/packages/core/src/parser/protocols/ech-protocols.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { configToYaml } from "../../generator/yaml"; +import type { ClashConfig } from "../../types/config"; +import type { ParsedNode } from "../../types/node"; +import { parseAnyTLS } from "./anytls"; +import { parseTrojan } from "./trojan"; +import { parseVLESS } from "./vless"; +import { parseVMess } from "./vmess"; + +const UUID = "11111111-1111-4111-8111-111111111111"; + +function parseVmessEch(value: string): ParsedNode { + const payload = Buffer.from( + JSON.stringify({ + v: "2", + ps: "VMess ECH", + add: "vmess-ech.example.com", + port: "443", + id: UUID, + aid: "0", + scy: "auto", + net: "ws", + tls: "tls", + ech: value, + }) + ).toString("base64"); + return parseVMess(`vmess://${payload}`); +} + +const protocolCases: Array<[string, (value: string) => ParsedNode]> = [ + [ + "VLESS", + (value) => + parseVLESS( + `vless://${UUID}@vless-ech.example.com:443?security=tls&type=ws&ech=${encodeURIComponent(value)}#VLESS%20ECH` + ), + ], + [ + "Trojan", + (value) => + parseTrojan(`trojan://secret@trojan-ech.example.com:443?type=ws&ech=${encodeURIComponent(value)}#Trojan%20ECH`), + ], + ["AnyTLS", (value) => parseAnyTLS(`anytls://secret@anytls-ech.example.com:443?ech=${encodeURIComponent(value)}#AnyTLS%20ECH`)], + ["VMess", parseVmessEch], +]; + +describe("ECH share-link protocol contracts", () => { + it.each(protocolCases)("classifies %s ECH values and preserves the DNS name in generated YAML", (_protocol, parse) => { + const domainNode = parse("cloudflare-ech.com"); + expect(domainNode["ech-opts"]).toEqual({ + enable: true, + "query-server-name": "cloudflare-ech.com", + }); + + const generated = configToYaml({ + proxies: [domainNode], + "proxy-groups": [], + "rule-providers": {}, + rules: [], + } as unknown as ClashConfig); + expect(generated).toContain("ech-opts: {enable: true, query-server-name: cloudflare-ech.com}"); + + expect(parse("+w==")["ech-opts"]).toEqual({ enable: true, config: "+w==" }); + expect(parse("")["ech-opts"]).toEqual({ enable: true }); + expect(parse("not base64!")["ech-opts"]).toEqual({ enable: true }); + }); +}); diff --git a/packages/core/src/parser/protocols/trojan.ts b/packages/core/src/parser/protocols/trojan.ts index 8ecfbe8..7c90395 100644 --- a/packages/core/src/parser/protocols/trojan.ts +++ b/packages/core/src/parser/protocols/trojan.ts @@ -5,6 +5,7 @@ */ import type { TrojanNode } from "@subboost/core/types/node"; +import { buildMihomoEchOptsFromShareValue } from "@subboost/core/mihomo/ech"; import { splitWsPathEarlyData } from "../ws-early-data"; import { parseUrlWithNeutralScheme, safeDecodeFormUrlEncoded, safeDecodeURIComponent } from "./url-decode"; @@ -76,10 +77,7 @@ export function parseTrojan(uri: string): TrojanNode { if (echRaw !== null) { const echValue = echRaw.trim(); - (node as unknown as Record)["ech-opts"] = { - enable: true, - ...(echValue ? { config: echValue } : {}), - }; + (node as unknown as Record)["ech-opts"] = buildMihomoEchOptsFromShareValue(echValue); } if (allowInsecure) { @@ -131,4 +129,3 @@ export function parseTrojan(uri: string): TrojanNode { return node; } - diff --git a/packages/core/src/parser/protocols/vless.ts b/packages/core/src/parser/protocols/vless.ts index 127c841..2aff7c7 100644 --- a/packages/core/src/parser/protocols/vless.ts +++ b/packages/core/src/parser/protocols/vless.ts @@ -8,6 +8,7 @@ import { decodeBase64 } from "../base64"; import type { VLESSNode, XHttpDownloadSettings, XHttpOpts, XHttpReuseSettings } from "@subboost/core/types/node"; +import { buildMihomoEchOptsFromShareValue } from "@subboost/core/mihomo/ech"; import { normalizeRealityShortId } from "@subboost/core/mihomo/reality"; import { splitWsPathEarlyData } from "../ws-early-data"; import { parseUrlWithNeutralScheme, safeDecodeFormUrlEncoded, safeDecodeURIComponent } from "./url-decode"; @@ -280,10 +281,7 @@ export function parseVLESS(uri: string): VLESSNode { throw new Error("VLESS 启用 ECH 需要 security=tls(不支持 Reality)"); } const echValue = echRaw.trim(); - (node as unknown as Record)["ech-opts"] = { - enable: true, - ...(echValue ? { config: echValue } : {}), - }; + (node as unknown as Record)["ech-opts"] = buildMihomoEchOptsFromShareValue(echValue); } if (allowInsecure) node["skip-cert-verify"] = true; @@ -379,4 +377,3 @@ export function parseVLESS(uri: string): VLESSNode { } - diff --git a/packages/core/src/parser/protocols/vmess.test.ts b/packages/core/src/parser/protocols/vmess.test.ts index 2505fc6..b174833 100644 --- a/packages/core/src/parser/protocols/vmess.test.ts +++ b/packages/core/src/parser/protocols/vmess.test.ts @@ -302,7 +302,7 @@ describe("parseVMess", () => { }); it("parses sparse URI, H2, HTTP, and Kitsunebi defaults", () => { - expect(parseVMess(`vmess://uri-default.example.com?id=${UUID}&security=tls&ech=config&alpn=,,`)) + expect(parseVMess(`vmess://uri-default.example.com?id=${UUID}&security=tls&ech=Y29uZmln&alpn=,,`)) .toMatchObject({ name: "VMess 节点", server: "uri-default.example.com", @@ -310,7 +310,7 @@ describe("parseVMess", () => { uuid: UUID, tls: true, network: "tcp", - "ech-opts": { enable: true, config: "config" }, + "ech-opts": { enable: true, config: "Y29uZmln" }, }); expect(parseVMess(`vmess://${UUID}@h2-empty.example.com:443?type=h2&security=tls`)).toMatchObject({ network: "h2", diff --git a/packages/core/src/parser/protocols/vmess.ts b/packages/core/src/parser/protocols/vmess.ts index 1383ee1..7b18067 100644 --- a/packages/core/src/parser/protocols/vmess.ts +++ b/packages/core/src/parser/protocols/vmess.ts @@ -13,6 +13,7 @@ import { decodeBase64 } from "../base64"; import { tryParseJson } from "../../json"; import type { VMessNode } from "@subboost/core/types/node"; +import { buildMihomoEchOptsFromShareValue } from "@subboost/core/mihomo/ech"; import { splitWsPathEarlyData } from "../ws-early-data"; import { parseUrlWithNeutralScheme, safeDecodeFormUrlEncoded, safeDecodeURIComponent } from "./url-decode"; @@ -493,10 +494,7 @@ function buildNodeFromConfig(config: VMessConfig): VMessNode { throw new Error("VMess 启用 ECH 需要 TLS(security=tls)"); } const echValue = pickString(configRecord.ech); - node["ech-opts"] = { - enable: true, - ...(echValue ? { config: echValue } : {}), - } as Record; + node["ech-opts"] = buildMihomoEchOptsFromShareValue(echValue); } // VMess TCP + http 伪装:vmess json 常见为 net=tcp + type=http。 diff --git a/packages/ui/src/product/converter/advanced-mode/sections/input-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/input-section.tsx index d9c7de4..e769332 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/input-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/input-section.tsx @@ -1,27 +1,16 @@ "use client"; -import * as React from "react"; import * as Popover from "@radix-ui/react-popover"; import { AlertCircle, Check, HelpCircle, Loader2, Maximize2, Plus, Server, X, Menu, ChevronUp, ChevronDown } 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 { Textarea } from "@subboost/ui/components/ui/textarea"; -import { toast } from "@subboost/ui/components/ui/toaster"; -import { normalizeSubscriptionImportErrorInfo } from "@subboost/core/subscription/import-error"; -import type { ParsedNode } from "@subboost/core/types/node"; -import { DEFAULT_NODE_NAME_TEMPLATE, formatNodeNameFromTemplate } from "@subboost/core/node-name-template"; import { cn } from "@subboost/ui/lib/utils"; -import { getNodeSourceIds, useConfigStore, type SubscriptionSource, type SourceType } from "@subboost/ui/store/config-store"; -import { useUserStore } from "@subboost/ui/store/user-store"; +import type { SourceType } from "@subboost/ui/store/config-store"; import { getSubscriptionUserInfoDisplay } from "@subboost/ui/product/subscription/subscription-userinfo-display"; -import { markSourceAsPendingImport } from "@subboost/ui/product/subscription/source-import-state"; -import { moveSubscriptionSource } from "@subboost/ui/product/subscription/source-order"; import { buildSourceDisplayLabel } from "@subboost/ui/product/converter/source-display-label"; -import { - useProductInteractionAdapter, - type ProductInteractionResult, -} from "@subboost/ui/product/interactions"; +import { useSubscriptionSourcesController } from "@subboost/ui/product/converter/use-subscription-sources-controller"; import { sourceTypeInfo } from "../constants"; import { SectionHeader } from "../section-header"; import { SubscriptionImportErrorBadge } from "@subboost/ui/product/converter/subscription-import-error"; @@ -34,243 +23,25 @@ export function InputSection({ isExpanded: boolean; onToggle: () => void; }) { - const { nodes, parseErrors, sources, setSources, parseSingleSource } = useConfigStore(); - const { user } = useUserStore(); - const interactions = useProductInteractionAdapter(); - - const maxSourcesPerType = React.useMemo(() => { - if (user?.isAdmin) return Number.POSITIVE_INFINITY; - if (!user) return 2; - const raw = user.quota?.maxImportSourcesPerType; - return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5; - }, [user]); - + const { + addSource, + closeExpandedSourceEditor, + error: globalError, + expandedSource, + expandedSourcePreviewName, + handleImportSource, + moveSource, + nodesBySourceId, + removeSource, + setExpandedSourceId, + setShowAddMenu, + showAddMenu, + sources, + updateSource, + updateSourceMeta, + updateSourceType, + } = useSubscriptionSourcesController({ mode: "advanced" }); const sourceCount = sources.length; - const globalError = React.useMemo( - () => normalizeSubscriptionImportErrorInfo(parseErrors[0] ?? null)?.message ?? null, - [parseErrors] - ); - const nodesBySourceId = React.useMemo(() => { - const grouped = new Map(); - for (const node of nodes) { - for (const sourceId of getNodeSourceIds(node)) { - const current = grouped.get(sourceId); - if (current) { - current.push(node); - } else { - grouped.set(sourceId, [node]); - } - } - } - return grouped; - }, [nodes]); - - const [showAddMenu, setShowAddMenu] = React.useState(false); - const [expandedSourceId, setExpandedSourceId] = React.useState(null); - const expandedSource = React.useMemo( - () => sources.find((s) => s.id === expandedSourceId) ?? null, - [expandedSourceId, sources] - ); - const [expandedSourceSnapshot, setExpandedSourceSnapshot] = React.useState<{ - id: string; - content: string; - tag: string; - nameTemplate: string; - useProxyProviders: boolean; - userinfoUrl: string; - userinfoUserAgent: string; - } | null>(null); - - React.useEffect(() => { - if (!expandedSource) { - setExpandedSourceSnapshot(null); - return; - } - setExpandedSourceSnapshot((prev) => { - if (prev?.id === expandedSource.id) return prev; - return { - id: expandedSource.id, - content: expandedSource.content, - tag: (expandedSource.tag ?? "").trim(), - nameTemplate: (expandedSource.nameTemplate ?? "").trim(), - useProxyProviders: Boolean(expandedSource.useProxyProviders), - userinfoUrl: (expandedSource.userinfoUrl ?? "").trim(), - userinfoUserAgent: (expandedSource.userinfoUserAgent ?? "").trim(), - }; - }); - }, [expandedSource]); - - const expandedSourcePreviewName = React.useMemo(() => { - if (!expandedSource) return ""; - return formatNodeNameFromTemplate({ - originName: "节点名称", - tag: expandedSource.tag, - template: expandedSource.nameTemplate, - }); - }, [expandedSource]); - - const closeExpandedSourceEditor = React.useCallback(() => { - if (expandedSource && expandedSourceSnapshot?.id === expandedSource.id && !expandedSource.parsing) { - const next = { - content: expandedSource.content, - tag: (expandedSource.tag ?? "").trim(), - nameTemplate: (expandedSource.nameTemplate ?? "").trim(), - useProxyProviders: Boolean(expandedSource.useProxyProviders), - userinfoUrl: (expandedSource.userinfoUrl ?? "").trim(), - userinfoUserAgent: (expandedSource.userinfoUserAgent ?? "").trim(), - }; - const changed = - next.content !== expandedSourceSnapshot.content || - next.tag !== expandedSourceSnapshot.tag || - next.nameTemplate !== expandedSourceSnapshot.nameTemplate || - next.useProxyProviders !== expandedSourceSnapshot.useProxyProviders || - next.userinfoUrl !== expandedSourceSnapshot.userinfoUrl || - next.userinfoUserAgent !== expandedSourceSnapshot.userinfoUserAgent; - if (changed) void parseSingleSource(expandedSource.id); - } - setExpandedSourceId(null); - }, [expandedSource, expandedSourceSnapshot, parseSingleSource]); - - // Initialize with one source if empty - React.useEffect(() => { - if (sources.length === 0) { - setSources([{ id: "1", type: "url", content: "", nameTemplate: DEFAULT_NODE_NAME_TEMPLATE }]); - } - }, [sources.length, setSources]); - - const addSource = (type: SourceType = "url") => { - const used = sources.filter((s) => s.type === type).length; - if (used >= maxSourcesPerType) { - toast({ - title: user - ? `每种导入方式最多 ${maxSourcesPerType} 个` - : "未登录用户每种导入方式最多 2 个(登录后可提升)", - variant: "warning", - }); - setShowAddMenu(false); - return; - } - const newSource: SubscriptionSource = { - id: Date.now().toString(), - type, - content: "", - nameTemplate: DEFAULT_NODE_NAME_TEMPLATE, - }; - setSources([...sources, newSource]); - interactions.sourceAdded?.({ - mode: "advanced", - sourceType: type, - sourceCount: sources.length + 1, - }); - setShowAddMenu(false); - }; - - const removeSource = (id: string) => { - if (sources.length > 1) { - setSources(sources.filter((s) => s.id !== id)); - } - }; - - const updateSource = (id: string, content: string) => { - setSources( - sources.map((s) => { - if (s.id !== id) return s; - if (s.content === content) return s; - return markSourceAsPendingImport({ ...s, content }); - }) - ); - }; - - const updateSourceMeta = (id: string, patch: Partial) => { - setSources( - sources.map((s) => { - if (s.id !== id) return s; - - const next = { ...s, ...patch }; - const changed = (Object.keys(patch) as Array).some((key) => s[key] !== next[key]); - if (!changed) return s; - - const needsReimport = - Object.prototype.hasOwnProperty.call(patch, "tag") || - Object.prototype.hasOwnProperty.call(patch, "nameTemplate") || - Object.prototype.hasOwnProperty.call(patch, "useProxyProviders") || - Object.prototype.hasOwnProperty.call(patch, "userinfoUrl") || - Object.prototype.hasOwnProperty.call(patch, "userinfoUserAgent"); - - return needsReimport ? markSourceAsPendingImport(next) : next; - }) - ); - }; - - const moveSource = React.useCallback( - (sourceId: string, direction: "up" | "down") => { - const nextSources = moveSubscriptionSource(sources, sourceId, direction); - if (nextSources === sources) return; - setSources(nextSources); - }, - [setSources, sources] - ); - - const updateSourceType = (id: string, type: SourceType) => { - const current = sources.find((s) => s.id === id); - if (!current) return; - if (current.type === type) return; - - const used = sources.filter((s) => s.type === type).length; - if (used >= maxSourcesPerType) { - toast({ - title: user - ? `每种导入方式最多 ${maxSourcesPerType} 个` - : "未登录用户每种导入方式最多 2 个(登录后可提升)", - variant: "warning", - }); - return; - } - - setSources( - sources.map((s) => { - if (s.id !== id) return s; - - return markSourceAsPendingImport({ - ...s, - type, - content: "", - useProxyProviders: type === "url" ? Boolean(s.useProxyProviders) : undefined, - userinfoUrl: type === "url" ? s.userinfoUrl : undefined, - userinfoUserAgent: type === "url" ? s.userinfoUserAgent : undefined, - lastParsedContent: undefined, - lastParsedTag: undefined, - lastParsedNameTemplate: undefined, - }); - }) - ); - }; - - const handleImportSource = React.useCallback( - async (sourceId: string) => { - const source = sources.find((item) => item.id === sourceId); - if (!source || !source.content.trim() || source.parsing) return; - - await parseSingleSource(sourceId); - - const latestState = useConfigStore.getState(); - const latestSource = latestState.sources.find((item) => item.id === sourceId) ?? source; - const result: ProductInteractionResult = latestSource.parsed - ? "success" - : latestSource.error || latestSource.errorInfo - ? "runtimeError" - : "validationError"; - interactions.sourceImported?.({ - mode: "advanced", - sourceType: latestSource.type, - result, - sourceCount: latestState.sources.filter((item) => item.content.trim()).length, - nodeCount: latestSource.nodeCount ?? 0, - usesProxyProvider: Boolean(latestSource.useProxyProviders), - }); - }, - [interactions, parseSingleSource, sources], - ); return (

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 index 5559699..98a8a0c 100644 --- 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 @@ -5,8 +5,11 @@ import type { ParsedNode } from "@subboost/core/types/node"; const mocks = vi.hoisted(() => ({ draggingKey: null as string | null, + generatedProxyGroups: [] as Array<{ name: string; proxies: string[] }>, stateSetters: [] as Array>, store: {} as Record, + toast: vi.fn(), + confirmDialog: vi.fn(), })); vi.mock("react", async (importOriginal) => { @@ -26,9 +29,14 @@ vi.mock("react", async (importOriginal) => { vi.mock("lucide-react", () => ({ Plus: () => React.createElement("span", null, "plus-icon"), + RotateCcw: () => React.createElement("span", null, "restore-icon"), X: () => React.createElement("span", null, "x-icon"), })); +vi.mock("@subboost/ui/components/ui/confirm-dialog", () => ({ + confirmDialog: mocks.confirmDialog, +})); + vi.mock("@subboost/ui/components/ui/badge", () => ({ Badge: (props: any) => React.createElement("span", props, props.children), })); @@ -41,6 +49,10 @@ 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/ui/lib/utils", () => ({ cn: (...parts: unknown[]) => parts.filter(Boolean).join(" "), })); @@ -50,12 +62,7 @@ vi.mock("@subboost/core/generator/proxy-groups", () => ({ { id: "select", name: "Select" }, { id: "auto", name: "Auto" }, ], - generateProxyGroups: () => [ - { - name: "Media", - proxies: ["DIRECT", "US Source", "Japan Source"], - }, - ], + generateProxyGroups: () => mocks.generatedProxyGroups, })); vi.mock("@subboost/core/proxy-group-name", () => ({ @@ -103,7 +110,14 @@ function flattenElements(value: React.ReactNode): TestElement[] { describe("ProxyGroupAdvancedPanel interactions", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.confirmDialog.mockResolvedValue(true); mocks.draggingKey = "node:US Source"; + mocks.generatedProxyGroups = [ + { name: "Media", proxies: ["DIRECT", "US Source", "Japan Source"] }, + { name: "Select", proxies: ["US Source"] }, + { name: "Auto", proxies: ["US Source"] }, + { name: "Other", proxies: ["US Source"] }, + ]; mocks.stateSetters = []; mocks.store = { nodes: [ @@ -217,4 +231,166 @@ describe("ProxyGroupAdvancedPanel interactions", () => { expect(onChange).not.toHaveBeenCalled(); expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); }); + + it("adds and removes all nodes without changing proxy group members", () => { + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { excludedMembers: [{ kind: "reject" }] }, + onChange, + rulesCount: 0, + rulesContent: null, + }); + const elements = flattenElements(tree); + const addAll = elements.find( + (element) => element.type === "button" && element.props.title === "添加全部节点", + ); + const removeAll = elements.find( + (element) => element.type === "button" && element.props.title === "移除全部节点", + ); + + addAll?.props.onClick(); + removeAll?.props.onClick(); + + expect(onChange).toHaveBeenCalledWith({ + extraMembers: [{ kind: "node", name: "Extra Node" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [ + { kind: "direct" }, + { kind: "node", name: "Extra Node" }, + { kind: "node", name: "US Source" }, + { kind: "node", name: "Japan Source" }, + ], + }); + expect(onChange).toHaveBeenCalledWith({ + extraMembers: [], + excludedMembers: [ + { kind: "reject" }, + { kind: "node", name: "US Source" }, + { kind: "node", name: "Japan Source" }, + { kind: "node", name: "Extra Node" }, + ], + memberOrder: [], + }); + }); + + it("adds all safe proxy groups and skips groups that would create a cycle", () => { + mocks.generatedProxyGroups = [ + { name: "Media", proxies: ["DIRECT", "US Source"] }, + { name: "Select", proxies: ["US Source"] }, + { name: "Auto", proxies: ["US Source"] }, + { name: "Other", proxies: ["Media"] }, + ]; + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: {}, + onChange, + rulesCount: 0, + rulesContent: null, + }); + const addAll = flattenElements(tree).find( + (element) => element.type === "button" && element.props.title === "添加全部代理组", + ); + + addAll?.props.onClick(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + extraMembers: [ + { kind: "module", id: "select" }, + { kind: "module", id: "auto" }, + ], + }), + ); + expect(mocks.toast).toHaveBeenCalledWith({ + title: "已跳过 1 个会形成循环的代理组", + variant: "warning", + }); + }); + + it("removes all proxy groups while leaving nodes and fixed policies alone", () => { + mocks.generatedProxyGroups = [ + { name: "Media", proxies: ["DIRECT", "Auto", "Other", "US Source"] }, + { name: "Select", proxies: ["US Source"] }, + { name: "Auto", proxies: ["US Source"] }, + { name: "Other", proxies: ["US Source"] }, + ]; + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + extraMembers: [ + { kind: "custom", id: "other" }, + { kind: "node", name: "US Source" }, + ], + memberOrder: [ + { kind: "direct" }, + { kind: "module", id: "auto" }, + { kind: "custom", id: "other" }, + { kind: "node", name: "US Source" }, + ], + }, + onChange, + rulesCount: 0, + rulesContent: null, + }); + const removeAll = flattenElements(tree).find( + (element) => element.type === "button" && element.props.title === "移除全部代理组", + ); + + removeAll?.props.onClick(); + + expect(onChange).toHaveBeenCalledWith({ + extraMembers: [{ kind: "node", name: "US Source" }], + excludedMembers: [ + { kind: "module", id: "select" }, + { kind: "module", id: "auto" }, + { kind: "custom", id: "other" }, + ], + memberOrder: [ + { kind: "direct" }, + { kind: "node", name: "US Source" }, + ], + }); + }); + + it("restores only member overrides after confirmation", async () => { + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + sourceIds: ["source-a"], + includeRegex: "Source", + extraMembers: [{ kind: "node", name: "Extra Node" }], + excludedMembers: [{ kind: "reject" }], + memberOrder: [{ kind: "direct" }, { kind: "node", name: "Extra Node" }], + }, + onChange, + rulesCount: 1, + rulesContent: null, + }); + const restore = flattenElements(tree).find( + (element) => element.type === "button" && element.props.title === "恢复默认成员", + ); + + mocks.confirmDialog.mockResolvedValueOnce(false); + await restore?.props.onClick(); + expect(onChange).not.toHaveBeenCalled(); + + mocks.confirmDialog.mockResolvedValueOnce(true); + await restore?.props.onClick(); + + expect(mocks.confirmDialog).toHaveBeenLastCalledWith({ + title: "恢复默认成员?", + description: "将清除当前代理组的手动添加、排除和排序。导入源、地区、正则筛选及分流规则不会改变。", + confirmText: "恢复", + variant: "warning", + }); + expect(onChange).toHaveBeenCalledWith({ + extraMembers: [], + excludedMembers: [], + memberOrder: [], + }); + }); }); 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 index 8c31f28..d2d9f5d 100644 --- 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 @@ -8,13 +8,19 @@ const mocks = vi.hoisted(() => ({ buttons: [] as any[], inputs: [] as any[], store: {} as Record, + toast: vi.fn(), })); vi.mock("lucide-react", () => ({ Plus: () => React.createElement("span", null, "plus-icon"), + RotateCcw: () => React.createElement("span", null, "restore-icon"), X: () => React.createElement("span", null, "x-icon"), })); +vi.mock("@subboost/ui/components/ui/confirm-dialog", () => ({ + confirmDialog: vi.fn(), +})); + vi.mock("@subboost/ui/components/ui/badge", () => ({ Badge: (props: any) => React.createElement("span", props, props.children), })); @@ -33,6 +39,10 @@ vi.mock("@subboost/ui/components/ui/input", () => ({ }, })); +vi.mock("@subboost/ui/components/ui/toaster", () => ({ + toast: mocks.toast, +})); + vi.mock("@subboost/ui/lib/utils", () => ({ cn: (...parts: unknown[]) => parts.filter(Boolean).join(" "), })); @@ -120,10 +130,31 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).not.toContain("剩余流量"); expect(html).toContain("US Source"); expect(html).toContain("DIRECT"); + expect(html).toContain("已启用成员"); + expect(html).toContain("未启用成员"); + expect(html).not.toContain("规则组"); + expect(html).toContain("proxy-group-member-toolbar"); + expect(html).toContain("whitespace-nowrap"); + expect(html).toContain("proxy-group-member-heading-compact"); + expect(html).toContain("proxy-group-member-action-button"); + expect(html).toContain("proxy-group-member-action-full"); + expect(html).toContain("proxy-group-member-action-medium"); + expect(html).toContain("proxy-group-member-action-compact"); + expect(html).toContain("proxy-group-member-count"); + expect(html).not.toContain("sm:grid-cols-2"); expect(html).toContain("rules-content"); expect(html).toContain("还没有分流规则"); expect(html).toContain("max-h-52 space-y-1.5 overflow-y-auto pr-1 custom-scrollbar"); expect(mocks.inputs.map((input) => input.value)).toEqual(["Source", "Japan"]); + expect(mocks.buttons.map((button) => button.title)).toEqual( + expect.arrayContaining([ + "恢复默认成员", + "添加全部节点", + "移除全部节点", + "添加全部代理组", + "移除全部代理组", + ]), + ); mocks.inputs[0].onChange({ target: { value: "IEPL" } }); const excludeButton = mocks.buttons.find((button) => button.title === "排除"); @@ -159,7 +190,9 @@ describe("ProxyGroupAdvancedPanel", () => { ); expect(html).toContain("暂无可匹配的导入源"); - expect(html).toContain("暂无已启用的节点或代理组"); + expect(html).toContain("暂无已启用成员"); + expect(html.match(/0 节点/g)).toHaveLength(2); + expect(html.match(/0 代理组/g)).toHaveLength(2); expect(html).toContain("DIRECT"); expect(html).toContain("REJECT"); expect(html).toContain("existing-rules"); @@ -196,7 +229,7 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).toContain("#1 订阅链接"); expect(html).toContain("#2 YAML 配置"); expect(html).toContain("#3 节点链接"); - expect(html).toContain("暂无已启用的节点或代理组"); + expect(html).toContain("暂无已启用成员"); }); it("previews enabled members for a disabled built-in proxy group", () => { @@ -220,7 +253,7 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).toContain("US Source"); expect(html).toContain("Japan Source"); expect(html).toContain("max-h-52 overflow-y-auto pr-1 custom-scrollbar flex flex-wrap gap-1.5"); - expect(html).not.toContain("暂无已启用的节点或代理组"); + expect(html).not.toContain("暂无已启用成员"); }); it("previews enabled members for a disabled custom proxy group", () => { @@ -245,7 +278,7 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).toContain("US Source"); expect(html).toContain("Japan Source"); - expect(html).not.toContain("暂无已启用的节点或代理组"); + expect(html).not.toContain("暂无已启用成员"); }); it("normalizes advanced member helpers without rendering the panel", () => { diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx index a635214..4d856f4 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx @@ -4,7 +4,9 @@ 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 { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog"; import { Input } from "@subboost/ui/components/ui/input"; +import { toast } from "@subboost/ui/components/ui/toaster"; 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"; @@ -20,24 +22,32 @@ import type { } from "@subboost/core/types/config"; import type { ParsedNode } from "@subboost/core/types/node"; import { useConfigStore } from "@subboost/ui/store/config-store"; - +import { + buildAddAllMembersPatch, + buildRemoveAllMembersPatch, + findCycleCreatingProxyGroupKeys, + insertMemberAfterProtected, + isNodeMember, + isProxyGroupMember, + normalizeList, + withMember, + withoutMember, + type ResolvedMember, +} from "./proxy-group-member-bulk"; +import { ProxyGroupMemberSectionHeader } from "./proxy-group-member-section-header"; + +export { + insertMemberAfterProtected, + normalizeList, + withMember, + withoutMember, +} from "./proxy-group-member-bulk"; +export type { ResolvedMember } from "./proxy-group-member-bulk"; 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"; @@ -96,48 +106,6 @@ export function toggleValue(list: readonly T[] | undefined, va 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 ( @@ -208,9 +176,8 @@ export function ProxyGroupAdvancedPanel({ [proxyGroupNameOverrides], ); - const generatedProxyNames = React.useMemo(() => { - if (nodes.length === 0) return []; - const generated = generateProxyGroups({ + const generatedProxyGroups = React.useMemo(() => { + return generateProxyGroups({ nodes, enabledModules: previewEnabledProxyGroups, ruleProviderBaseUrl, @@ -222,7 +189,6 @@ export function ProxyGroupAdvancedPanel({ builtinRuleEdits, proxyGroupNameOverrides, }); - return generated.find((group) => group.name === target.name)?.proxies ?? []; }, [ nodes, previewEnabledProxyGroups, @@ -234,8 +200,11 @@ export function ProxyGroupAdvancedPanel({ proxyGroupAdvanced, builtinRuleEdits, proxyGroupNameOverrides, - target.name, ]); + const generatedProxyNames = React.useMemo(() => { + if (nodes.length === 0) return []; + return generatedProxyGroups.find((group) => group.name === target.name)?.proxies ?? []; + }, [generatedProxyGroups, nodes.length, target.name]); const candidateMembers = React.useMemo(() => { const rawNames = [ @@ -274,6 +243,46 @@ export function ProxyGroupAdvancedPanel({ const included = new Set(includedMembers.map((member) => member.key)); return candidateMembers.filter((member) => !included.has(member.key)); }, [candidateMembers, includedMembers]); + const nodeMembers = React.useMemo( + () => candidateMembers.filter(isNodeMember), + [candidateMembers], + ); + const includedNodeMembers = React.useMemo( + () => includedMembers.filter(isNodeMember), + [includedMembers], + ); + const excludedNodeMembers = React.useMemo( + () => excludedMembers.filter(isNodeMember), + [excludedMembers], + ); + const proxyGroupMembers = React.useMemo( + () => candidateMembers.filter(isProxyGroupMember), + [candidateMembers], + ); + const includedProxyGroupMembers = React.useMemo( + () => includedMembers.filter(isProxyGroupMember), + [includedMembers], + ); + const excludedProxyGroupMembers = React.useMemo( + () => excludedMembers.filter(isProxyGroupMember), + [excludedMembers], + ); + const cycleCreatingProxyGroupKeys = React.useMemo( + () => + findCycleCreatingProxyGroupKeys({ + candidates: excludedProxyGroupMembers, + generatedGroups: generatedProxyGroups, + targetName: target.name, + }), + [excludedProxyGroupMembers, generatedProxyGroups, target.name], + ); + const addableProxyGroupMembers = React.useMemo( + () => + excludedProxyGroupMembers.filter( + (member) => !cycleCreatingProxyGroupKeys.has(member.key), + ), + [cycleCreatingProxyGroupKeys, excludedProxyGroupMembers], + ); const sourceOptions = React.useMemo(() => { const sourceIdsInNodes = new Set(); @@ -307,6 +316,9 @@ export function ProxyGroupAdvancedPanel({ const regions = normalizeList(advanced.regions); const extraRefs = normalizeList(advanced.extraMembers); const excludedRefs = normalizeList(advanced.excludedMembers); + const memberOrderRefs = normalizeList(advanced.memberOrder); + const hasMemberOverrides = + extraRefs.length > 0 || excludedRefs.length > 0 || memberOrderRefs.length > 0; const disableMember = React.useCallback( (member: ResolvedMember) => { @@ -330,6 +342,71 @@ export function ProxyGroupAdvancedPanel({ [excludedRefs, extraRefs, includedMembers, onChange], ); + const addAllNodes = React.useCallback(() => { + onChange( + buildAddAllMembersPatch({ + advanced, + currentMembers: includedMembers, + membersToAdd: excludedNodeMembers, + }), + ); + }, [advanced, excludedNodeMembers, includedMembers, onChange]); + + const removeAllNodes = React.useCallback(() => { + onChange( + buildRemoveAllMembersPatch({ + advanced, + membersToRemove: nodeMembers, + }), + ); + }, [advanced, nodeMembers, onChange]); + + const addAllProxyGroups = React.useCallback(() => { + if (addableProxyGroupMembers.length > 0) { + onChange( + buildAddAllMembersPatch({ + advanced, + currentMembers: includedMembers, + membersToAdd: addableProxyGroupMembers, + }), + ); + } + const skippedCount = + excludedProxyGroupMembers.length - addableProxyGroupMembers.length; + if (skippedCount > 0) { + toast({ + title: `已跳过 ${skippedCount} 个会形成循环的代理组`, + variant: "warning", + }); + } + }, [ + addableProxyGroupMembers, + advanced, + excludedProxyGroupMembers.length, + includedMembers, + onChange, + ]); + + const removeAllProxyGroups = React.useCallback(() => { + onChange( + buildRemoveAllMembersPatch({ + advanced, + membersToRemove: proxyGroupMembers, + }), + ); + }, [advanced, onChange, proxyGroupMembers]); + + const restoreDefaultMembers = React.useCallback(async () => { + const confirmed = await confirmDialog({ + title: "恢复默认成员?", + description: "将清除当前代理组的手动添加、排除和排序。导入源、地区、正则筛选及分流规则不会改变。", + confirmText: "恢复", + variant: "warning", + }); + if (!confirmed) return; + onChange({ extraMembers: [], excludedMembers: [], memberOrder: [] }); + }, [onChange]); + return (
@@ -405,13 +482,20 @@ export function ProxyGroupAdvancedPanel({
-
-
已启用节点
- {includedMembers.length} 个 -
+ {includedMembers.length === 0 ? (
- 暂无已启用的节点或代理组 + 暂无已启用成员
) : (
@@ -456,12 +540,17 @@ export function ProxyGroupAdvancedPanel({ )}
-
-
未启用节点
- {excludedMembers.length} 个 -
+ {excludedMembers.length === 0 ? ( -
暂无未启用的节点或代理组
+
暂无未启用成员
) : (
{excludedMembers.map((member) => { diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-bulk.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-bulk.test.ts new file mode 100644 index 0000000..f4d27ce --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-bulk.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { ProxyGroupMemberRef } from "@subboost/core/types/config"; +import { + buildAddAllMembersPatch, + buildRemoveAllMembersPatch, + findCycleCreatingProxyGroupKeys, + isNodeMember, + isProxyGroupMember, + type ResolvedMember, +} from "./proxy-group-member-bulk"; + +function resolved(ref: ProxyGroupMemberRef, name: string): ResolvedMember { + const key = + ref.kind === "node" + ? `node:${ref.name}` + : ref.kind === "module" || ref.kind === "custom" + ? `${ref.kind}:${ref.id}` + : `${ref.kind}:${ref.kind === "direct" ? "DIRECT" : "REJECT"}`; + return { key, ref, name, kind: ref.kind }; +} + +describe("proxy group member bulk helpers", () => { + it("adds every requested member while preserving unrelated member state", () => { + const direct = resolved({ kind: "direct" }, "DIRECT"); + const select = resolved({ kind: "module", id: "select" }, "Select"); + const oldNode = resolved({ kind: "node", name: "Old" }, "Old"); + const newNode = resolved({ kind: "node", name: "New" }, "New"); + const custom = resolved({ kind: "custom", id: "media" }, "Media"); + + expect( + buildAddAllMembersPatch({ + advanced: { + extraMembers: [{ kind: "reject" }], + excludedMembers: [ + { kind: "node", name: "New" }, + { kind: "custom", id: "media" }, + { kind: "module", id: "auto" }, + ], + }, + currentMembers: [direct, select, oldNode], + membersToAdd: [newNode, custom], + }), + ).toEqual({ + extraMembers: [ + { kind: "reject" }, + { kind: "node", name: "New" }, + { kind: "custom", id: "media" }, + ], + excludedMembers: [{ kind: "module", id: "auto" }], + memberOrder: [ + { kind: "direct" }, + { kind: "module", id: "select" }, + { kind: "node", name: "New" }, + { kind: "custom", id: "media" }, + { kind: "node", name: "Old" }, + ], + }); + }); + + it("removes every requested member without changing other member categories", () => { + const nodeA = resolved({ kind: "node", name: "A" }, "A"); + const nodeB = resolved({ kind: "node", name: "B" }, "B"); + + expect( + buildRemoveAllMembersPatch({ + advanced: { + extraMembers: [ + { kind: "custom", id: "media" }, + { kind: "node", name: "A" }, + ], + excludedMembers: [{ kind: "node", name: "B" }], + memberOrder: [ + { kind: "direct" }, + { kind: "node", name: "A" }, + { kind: "custom", id: "media" }, + ], + }, + membersToRemove: [nodeA, nodeB], + }), + ).toEqual({ + extraMembers: [{ kind: "custom", id: "media" }], + excludedMembers: [ + { kind: "node", name: "A" }, + { kind: "node", name: "B" }, + ], + memberOrder: [ + { kind: "direct" }, + { kind: "custom", id: "media" }, + ], + }); + }); + + it("distinguishes nodes and proxy groups from fixed policies", () => { + const node = resolved({ kind: "node", name: "A" }, "A"); + const moduleMember = resolved({ kind: "module", id: "auto" }, "Auto"); + const custom = resolved({ kind: "custom", id: "media" }, "Media"); + const direct = resolved({ kind: "direct" }, "DIRECT"); + + expect(isNodeMember(node)).toBe(true); + expect(isNodeMember(moduleMember)).toBe(false); + expect(isProxyGroupMember(moduleMember)).toBe(true); + expect(isProxyGroupMember(custom)).toBe(true); + expect(isProxyGroupMember(node)).toBe(false); + expect(isProxyGroupMember(direct)).toBe(false); + }); + + it("blocks direct and transitive proxy group cycles", () => { + const directCycle = resolved({ kind: "module", id: "a" }, "A"); + const transitiveCycle = resolved({ kind: "custom", id: "b" }, "B"); + const safe = resolved({ kind: "custom", id: "c" }, "C"); + + expect( + findCycleCreatingProxyGroupKeys({ + candidates: [directCycle, transitiveCycle, safe], + generatedGroups: [ + { name: "Target", proxies: ["DIRECT"] }, + { name: "A", proxies: ["Target"] }, + { name: "B", proxies: ["A"] }, + { name: "C", proxies: ["D"] }, + { name: "D", proxies: [] }, + ], + targetName: "Target", + }), + ).toEqual(new Set(["module:a", "custom:b"])); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-bulk.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-bulk.ts new file mode 100644 index 0000000..bc42638 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-bulk.ts @@ -0,0 +1,168 @@ +import { getProxyGroupMemberKey } from "@subboost/core/proxy-group-targets"; +import type { + ProxyGroupAdvancedConfig, + ProxyGroupMemberRef, +} from "@subboost/core/types/config"; + +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 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", +]); + +function uniqueMemberRefs(members: readonly ProxyGroupMemberRef[]): ProxyGroupMemberRef[] { + const seen = new Set(); + return members.filter((member) => { + const key = getProxyGroupMemberKey(member); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function insertMembersAfterProtected( + currentMembers: readonly ResolvedMember[], + members: readonly ProxyGroupMemberRef[], +): ProxyGroupMemberRef[] { + const additions = uniqueMemberRefs(members); + const additionKeys = new Set(additions.map(getProxyGroupMemberKey)); + const current = currentMembers + .map((item) => item.ref) + .filter((item) => !additionKeys.has(getProxyGroupMemberKey(item))); + let insertAt = 0; + current.forEach((item, index) => { + if (PROTECTED_INSERT_KEYS.has(getProxyGroupMemberKey(item))) { + insertAt = index + 1; + } + }); + const protectedAdditions = additions.filter((item) => + PROTECTED_INSERT_KEYS.has(getProxyGroupMemberKey(item)), + ); + const regularAdditions = additions.filter( + (item) => !PROTECTED_INSERT_KEYS.has(getProxyGroupMemberKey(item)), + ); + return [ + ...current.slice(0, insertAt), + ...protectedAdditions, + ...regularAdditions, + ...current.slice(insertAt), + ]; +} + +export function insertMemberAfterProtected( + currentMembers: readonly ResolvedMember[], + member: ProxyGroupMemberRef, +): ProxyGroupMemberRef[] { + return insertMembersAfterProtected(currentMembers, [member]); +} + +export function isNodeMember(member: ResolvedMember): boolean { + return member.kind === "node"; +} + +export function isProxyGroupMember(member: ResolvedMember): boolean { + return member.kind === "module" || member.kind === "custom"; +} + +export function buildAddAllMembersPatch(options: { + advanced: ProxyGroupAdvancedConfig; + currentMembers: readonly ResolvedMember[]; + membersToAdd: readonly ResolvedMember[]; +}): Partial { + let extraMembers = normalizeList(options.advanced.extraMembers); + let excludedMembers = normalizeList(options.advanced.excludedMembers); + for (const member of options.membersToAdd) { + extraMembers = withMember(extraMembers, member.ref); + excludedMembers = withoutMember(excludedMembers, member.key); + } + return { + extraMembers, + excludedMembers, + memberOrder: insertMembersAfterProtected( + options.currentMembers, + options.membersToAdd.map((member) => member.ref), + ), + }; +} + +export function buildRemoveAllMembersPatch(options: { + advanced: ProxyGroupAdvancedConfig; + membersToRemove: readonly ResolvedMember[]; +}): Partial { + const keys = new Set(options.membersToRemove.map((member) => member.key)); + let excludedMembers = normalizeList(options.advanced.excludedMembers); + for (const member of options.membersToRemove) { + excludedMembers = withMember(excludedMembers, member.ref); + } + return { + extraMembers: normalizeList(options.advanced.extraMembers).filter( + (member) => !keys.has(getProxyGroupMemberKey(member)), + ), + excludedMembers, + memberOrder: normalizeList(options.advanced.memberOrder).filter( + (member) => !keys.has(getProxyGroupMemberKey(member)), + ), + }; +} + +export function findCycleCreatingProxyGroupKeys(options: { + candidates: readonly ResolvedMember[]; + generatedGroups: readonly { name: string; proxies?: string[] }[]; + targetName: string; +}): Set { + const groupNames = new Set(options.generatedGroups.map((group) => group.name)); + const dependencies = new Map( + options.generatedGroups.map((group) => [ + group.name, + (group.proxies ?? []).filter((name) => groupNames.has(name)), + ]), + ); + + const reachesTarget = (start: string): boolean => { + const pending = [start]; + const visited = new Set(); + while (pending.length > 0) { + const current = pending.pop(); + if (!current || visited.has(current)) continue; + if (current === options.targetName) return true; + visited.add(current); + pending.push(...(dependencies.get(current) ?? [])); + } + return false; + }; + + return new Set( + options.candidates + .filter((member) => reachesTarget(member.name)) + .map((member) => member.key), + ); +} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-section-header.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-section-header.tsx new file mode 100644 index 0000000..f2b5483 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-member-section-header.tsx @@ -0,0 +1,117 @@ +import { Plus, RotateCcw, X } from "lucide-react"; +import { Badge } from "@subboost/ui/components/ui/badge"; +import { Button } from "@subboost/ui/components/ui/button"; +import { cn } from "@subboost/ui/lib/utils"; + +export function ProxyGroupMemberSectionHeader({ + mode, + nodeCount, + proxyGroupCount, + onNodeAction, + onProxyGroupAction, + nodeActionDisabled, + proxyGroupActionDisabled, + onRestore, + restoreDisabled = false, +}: { + mode: "included" | "excluded"; + nodeCount: number; + proxyGroupCount: number; + onNodeAction: () => void; + onProxyGroupAction: () => void; + nodeActionDisabled: boolean; + proxyGroupActionDisabled: boolean; + onRestore?: () => void; + restoreDisabled?: boolean; +}) { + const included = mode === "included"; + const verb = included ? "移除" : "添加"; + const ActionIcon = included ? X : Plus; + const actionTone = included + ? "text-white/40 hover:bg-red-500/10 hover:text-red-200" + : "text-white/40 hover:bg-emerald-500/10 hover:text-emerald-200"; + + return ( +
+
+ + {included ? "已启用成员" : "未启用成员"} + + +
+
+ {included && onRestore && ( + + )} + + + + {nodeCount} 节点 + + + {proxyGroupCount} 代理组 + +
+
+ ); +} 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 79ad1fc..726f9a3 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 @@ -72,7 +72,6 @@ export function ProxyGroupsAddedRuleSets({ customProxyGroups = [], proxyGroupNameOverrides = {}, toggleProxyGroup, - addModuleRules, updateModuleRule, removeModuleRule, moveModuleRule, 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 6d23bb1..35b862b 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 @@ -408,7 +408,8 @@ describe("ProxyGroupsCategories", () => { renderCategories({ 0: new Set(["core"]) }); const card = mocks.captures.moduleCards[0]; - expect(card.nodeCount).toBe(2); + // proxies 为 ["US", "DIRECT"],只有 US 是真实节点,DIRECT 是兜底策略不计入节点数。 + expect(card.nodeCount).toBe(1); expect(card.extraRules).toEqual([ { id: "set-1", name: "Set 1", behavior: "domain", path: "geosite/set-1.mrs", noResolve: true }, ]); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx index 96b8c29..e12d52d 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 @@ -171,10 +171,18 @@ export function ProxyGroupsCategories() { builtinRuleEdits, proxyGroupNameOverrides, }); + const nodeNameSet = new Set(nodes.map((node) => node.name)); return new Map( generated.map((group) => [ group.name, - Array.isArray(group.proxies) ? group.proxies.length : 0, + // 生成后的成员里除了真实节点,还会掺入策略组引用与 DIRECT/REJECT 兜底, + // 节点数只统计真实节点,避免把兜底策略和引用组算进来导致数字虚高。 + Array.isArray(group.proxies) + ? group.proxies.filter( + (proxyName): proxyName is string => + typeof proxyName === "string" && nodeNameSet.has(proxyName), + ).length + : 0, ]), ); }, [ 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 8c7b384..872909a 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 @@ -23,10 +23,7 @@ import { isRuleSetMoveTarget, type RuleSetMoveTarget, } from "./proxy-group-rule-row"; -import { - ProxyGroupTypeMenu, - type ProxyGroupTypeMenuValue, -} from "./proxy-group-type-menu"; +import type { ProxyGroupTypeMenuValue } from "./proxy-group-type-menu"; import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; import { buildProxyGroupName, 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 ab83548..5525b2c 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 @@ -168,20 +168,21 @@ export function ProxyGroupsCustomRules() { const handleAddCustomRule = () => { const value = newRuleValue.trim(); if (!value || !newRuleTarget) return; + const addedRuleType = newRuleType; addCustomRule({ id: createCustomRuleId(), - type: newRuleType, + type: addedRuleType, value, target: newRuleTarget, noResolve: newRuleNoResolve, }); interactions.ruleAdded?.({ source: "manual", - kind: getProductRuleKind(newRuleType), + kind: getProductRuleKind(addedRuleType), }); setNewRuleValue(""); - setNewRuleNoResolve(isIpCidrRuleType(newRuleType)); + setNewRuleNoResolve(isIpCidrRuleType(addedRuleType)); }; const handleNewRuleTypeChange = (value: string) => { diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts index ca8b2e6..7ed964c 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-rules-library.test.ts @@ -110,11 +110,11 @@ vi.mock("@subboost/ui/components/ui/select", () => ({ })); vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ - PROXY_GROUP_MODULES: [ - { id: "auto", name: "Auto", rules: [{ id: "netflix" }] }, - { id: "fallback", name: "Fallback", rules: [] }, - { id: "bare", name: "Bare" }, - ], + 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}`, 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 c294d8f..260c219 100644 --- a/packages/ui/src/product/converter/quick-mode/sources-section.tsx +++ b/packages/ui/src/product/converter/quick-mode/sources-section.tsx @@ -1,4 +1,3 @@ -import * as React from "react"; import * as Popover from "@radix-ui/react-popover"; import { Plus, X, AlertCircle, Check, Loader2, Maximize2, HelpCircle, Menu, ChevronUp, ChevronDown } from "lucide-react"; import { Button } from "@subboost/ui/components/ui/button"; @@ -8,249 +7,34 @@ import { Badge } from "@subboost/ui/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@subboost/ui/components/ui/dialog"; import { Switch } from "@subboost/ui/components/ui/switch"; import { cn } from "@subboost/ui/lib/utils"; -import { DEFAULT_NODE_NAME_TEMPLATE, formatNodeNameFromTemplate } from "@subboost/core/node-name-template"; -import type { ParsedNode } from "@subboost/core/types/node"; -import { normalizeSubscriptionImportErrorInfo } from "@subboost/core/subscription/import-error"; -import { getNodeSourceIds, useConfigStore, type SourceType, type SubscriptionSource } from "@subboost/ui/store/config-store"; -import { useUserStore } from "@subboost/ui/store/user-store"; -import { toast } from "@subboost/ui/components/ui/toaster"; +import { DEFAULT_NODE_NAME_TEMPLATE } from "@subboost/core/node-name-template"; +import type { SourceType } from "@subboost/ui/store/config-store"; import { SubscriptionImportErrorBadge } from "@subboost/ui/product/converter/subscription-import-error"; import { getSubscriptionUserInfoDisplay } from "@subboost/ui/product/subscription/subscription-userinfo-display"; -import { markSourceAsPendingImport } from "@subboost/ui/product/subscription/source-import-state"; -import { moveSubscriptionSource } from "@subboost/ui/product/subscription/source-order"; import { buildSourceDisplayLabel } from "@subboost/ui/product/converter/source-display-label"; -import { - useProductInteractionAdapter, - type ProductInteractionResult, -} from "@subboost/ui/product/interactions"; +import { useSubscriptionSourcesController } from "@subboost/ui/product/converter/use-subscription-sources-controller"; import { sourceTypeInfo } from "./constants"; export function SourcesSection() { - const [showAddMenu, setShowAddMenu] = React.useState(false); - - const { nodes, parseErrors, sources, setSources, parseSingleSource } = useConfigStore(); - const { user } = useUserStore(); - const interactions = useProductInteractionAdapter(); - - const [expandedSourceId, setExpandedSourceId] = React.useState(null); - const expandedSource = React.useMemo( - () => sources.find((s) => s.id === expandedSourceId) ?? null, - [expandedSourceId, sources] - ); - const [expandedSourceSnapshot, setExpandedSourceSnapshot] = React.useState<{ - id: string; - content: string; - tag: string; - nameTemplate: string; - useProxyProviders: boolean; - userinfoUrl: string; - userinfoUserAgent: string; - } | null>(null); - - React.useEffect(() => { - if (!expandedSource) { - setExpandedSourceSnapshot(null); - return; - } - setExpandedSourceSnapshot((prev) => { - if (prev?.id === expandedSource.id) return prev; - return { - id: expandedSource.id, - content: expandedSource.content, - tag: (expandedSource.tag ?? "").trim(), - nameTemplate: (expandedSource.nameTemplate ?? "").trim(), - useProxyProviders: Boolean(expandedSource.useProxyProviders), - userinfoUrl: (expandedSource.userinfoUrl ?? "").trim(), - userinfoUserAgent: (expandedSource.userinfoUserAgent ?? "").trim(), - }; - }); - }, [expandedSource]); - - const expandedSourcePreviewName = React.useMemo(() => { - if (!expandedSource) return ""; - return formatNodeNameFromTemplate({ - originName: "节点名称", - tag: expandedSource.tag, - template: expandedSource.nameTemplate, - }); - }, [expandedSource]); - - const closeExpandedSourceEditor = React.useCallback(() => { - if (expandedSource && expandedSourceSnapshot?.id === expandedSource.id && !expandedSource.parsing) { - const next = { - content: expandedSource.content, - tag: (expandedSource.tag ?? "").trim(), - nameTemplate: (expandedSource.nameTemplate ?? "").trim(), - useProxyProviders: Boolean(expandedSource.useProxyProviders), - userinfoUrl: (expandedSource.userinfoUrl ?? "").trim(), - userinfoUserAgent: (expandedSource.userinfoUserAgent ?? "").trim(), - }; - const changed = - next.content !== expandedSourceSnapshot.content || - next.tag !== expandedSourceSnapshot.tag || - next.nameTemplate !== expandedSourceSnapshot.nameTemplate || - next.useProxyProviders !== expandedSourceSnapshot.useProxyProviders || - next.userinfoUrl !== expandedSourceSnapshot.userinfoUrl || - next.userinfoUserAgent !== expandedSourceSnapshot.userinfoUserAgent; - if (changed) void parseSingleSource(expandedSource.id); - } - setExpandedSourceId(null); - }, [expandedSource, expandedSourceSnapshot, parseSingleSource]); - - const maxSourcesPerType = React.useMemo(() => { - if (user?.isAdmin) return Number.POSITIVE_INFINITY; - if (!user) return 2; - const raw = user.quota?.maxImportSourcesPerType; - return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5; - }, [user]); - - const nodeCount = nodes.length; - const error = React.useMemo( - () => normalizeSubscriptionImportErrorInfo(parseErrors[0] ?? null)?.message ?? null, - [parseErrors] - ); - const nodesBySourceId = React.useMemo(() => { - const grouped = new Map(); - for (const node of nodes) { - for (const sourceId of getNodeSourceIds(node)) { - const current = grouped.get(sourceId); - if (current) { - current.push(node); - } else { - grouped.set(sourceId, [node]); - } - } - } - return grouped; - }, [nodes]); - - const addSource = (type: SourceType) => { - const used = sources.filter((s) => s.type === type).length; - if (used >= maxSourcesPerType) { - toast({ - title: user ? `每种导入方式最多 ${maxSourcesPerType} 个` : "未登录用户每种导入方式最多 2 个(登录后可提升)", - variant: "warning", - }); - setShowAddMenu(false); - return; - } - const newSource: SubscriptionSource = { - id: Date.now().toString(), - type, - content: "", - nameTemplate: DEFAULT_NODE_NAME_TEMPLATE, - }; - setSources([...sources, newSource]); - interactions.sourceAdded?.({ - mode: "quick", - sourceType: type, - sourceCount: sources.length + 1, - }); - setShowAddMenu(false); - }; - - const removeSource = (id: string) => { - if (sources.length <= 1) return; - setSources(sources.filter((s) => s.id !== id)); - }; - - const updateSource = (id: string, content: string) => { - setSources( - sources.map((s) => { - if (s.id !== id) return s; - if (s.content === content) return s; - return markSourceAsPendingImport({ ...s, content }); - }) - ); - }; - - const updateSourceMeta = (id: string, patch: Partial) => { - setSources( - sources.map((s) => { - if (s.id !== id) return s; - - const next = { ...s, ...patch }; - const changed = (Object.keys(patch) as Array).some((key) => s[key] !== next[key]); - if (!changed) return s; - - const needsReimport = - Object.prototype.hasOwnProperty.call(patch, "tag") || - Object.prototype.hasOwnProperty.call(patch, "nameTemplate") || - Object.prototype.hasOwnProperty.call(patch, "useProxyProviders") || - Object.prototype.hasOwnProperty.call(patch, "userinfoUrl") || - Object.prototype.hasOwnProperty.call(patch, "userinfoUserAgent"); - - return needsReimport ? markSourceAsPendingImport(next) : next; - }) - ); - }; - - const moveSource = React.useCallback( - (sourceId: string, direction: "up" | "down") => { - const nextSources = moveSubscriptionSource(sources, sourceId, direction); - if (nextSources === sources) return; - setSources(nextSources); - }, - [setSources, sources] - ); - - const updateSourceType = (id: string, type: SourceType) => { - const current = sources.find((s) => s.id === id); - if (!current) return; - if (current.type === type) return; - - const used = sources.filter((s) => s.type === type).length; - if (used >= maxSourcesPerType) { - toast({ - title: user ? `每种导入方式最多 ${maxSourcesPerType} 个` : "未登录用户每种导入方式最多 2 个(登录后可提升)", - variant: "warning", - }); - return; - } - - setSources( - sources.map((s) => { - if (s.id !== id) return s; - - return markSourceAsPendingImport({ - ...s, - type, - content: "", - useProxyProviders: type === "url" ? Boolean(s.useProxyProviders) : undefined, - userinfoUrl: type === "url" ? s.userinfoUrl : undefined, - userinfoUserAgent: type === "url" ? s.userinfoUserAgent : undefined, - lastParsedContent: undefined, - lastParsedTag: undefined, - lastParsedNameTemplate: undefined, - }); - }) - ); - }; - - const handleImportSource = React.useCallback( - async (sourceId: string) => { - const source = sources.find((item) => item.id === sourceId); - if (!source || !source.content.trim() || source.parsing) return; - - await parseSingleSource(sourceId); - - const latestSource = useConfigStore.getState().sources.find((item) => item.id === sourceId) ?? source; - const result: ProductInteractionResult = latestSource.parsed - ? "success" - : latestSource.error || latestSource.errorInfo - ? "runtimeError" - : "validationError"; - interactions.sourceImported?.({ - mode: "quick", - sourceType: latestSource.type, - result, - sourceCount: useConfigStore.getState().sources.filter((item) => item.content.trim()).length, - nodeCount: latestSource.nodeCount ?? 0, - usesProxyProvider: Boolean(latestSource.useProxyProviders), - }); - }, - [interactions, parseSingleSource, sources], - ); + const { + addSource, + closeExpandedSourceEditor, + error, + expandedSource, + expandedSourcePreviewName, + handleImportSource, + moveSource, + nodeCount, + nodesBySourceId, + removeSource, + setExpandedSourceId, + setShowAddMenu, + showAddMenu, + sources, + updateSource, + updateSourceMeta, + updateSourceType, + } = useSubscriptionSourcesController({ mode: "quick" }); return ( <> diff --git a/packages/ui/src/product/converter/use-subscription-sources-controller.ts b/packages/ui/src/product/converter/use-subscription-sources-controller.ts new file mode 100644 index 0000000..fc05fc9 --- /dev/null +++ b/packages/ui/src/product/converter/use-subscription-sources-controller.ts @@ -0,0 +1,297 @@ +"use client"; + +import * as React from "react"; +import { DEFAULT_NODE_NAME_TEMPLATE, formatNodeNameFromTemplate } from "@subboost/core/node-name-template"; +import { normalizeSubscriptionImportErrorInfo } from "@subboost/core/subscription/import-error"; +import type { ParsedNode } from "@subboost/core/types/node"; +import { getNodeSourceIds, useConfigStore, type SourceType, type SubscriptionSource } from "@subboost/ui/store/config-store"; +import { useUserStore } from "@subboost/ui/store/user-store"; +import { toast } from "@subboost/ui/components/ui/toaster"; +import { useProductInteractionAdapter, type ProductInteractionResult } from "@subboost/ui/product/interactions"; +import { markSourceAsPendingImport } from "@subboost/ui/product/subscription/source-import-state"; +import { moveSubscriptionSource } from "@subboost/ui/product/subscription/source-order"; + +export type SubscriptionSourcesMode = "quick" | "advanced"; + +type ExpandedSourceSnapshot = { + id: string; + content: string; + tag: string; + nameTemplate: string; + useProxyProviders: boolean; + userinfoUrl: string; + userinfoUserAgent: string; +}; + +type Options = { + mode: SubscriptionSourcesMode; +}; + +function createExpandedSourceSnapshot(source: SubscriptionSource): ExpandedSourceSnapshot { + return { + id: source.id, + content: source.content, + tag: (source.tag ?? "").trim(), + nameTemplate: (source.nameTemplate ?? "").trim(), + useProxyProviders: Boolean(source.useProxyProviders), + userinfoUrl: (source.userinfoUrl ?? "").trim(), + userinfoUserAgent: (source.userinfoUserAgent ?? "").trim(), + }; +} + +function changedSinceSnapshot(source: SubscriptionSource, snapshot: ExpandedSourceSnapshot): boolean { + const next = createExpandedSourceSnapshot(source); + return ( + next.content !== snapshot.content || + next.tag !== snapshot.tag || + next.nameTemplate !== snapshot.nameTemplate || + next.useProxyProviders !== snapshot.useProxyProviders || + next.userinfoUrl !== snapshot.userinfoUrl || + next.userinfoUserAgent !== snapshot.userinfoUserAgent + ); +} + +function sourceMetaNeedsReimport(patch: Partial): boolean { + return ( + Object.prototype.hasOwnProperty.call(patch, "tag") || + Object.prototype.hasOwnProperty.call(patch, "nameTemplate") || + Object.prototype.hasOwnProperty.call(patch, "useProxyProviders") || + Object.prototype.hasOwnProperty.call(patch, "userinfoUrl") || + Object.prototype.hasOwnProperty.call(patch, "userinfoUserAgent") + ); +} + +export function useSubscriptionSourcesController({ mode }: Options) { + const { nodes, parseErrors, sources, setSources, parseSingleSource } = useConfigStore(); + const { user } = useUserStore(); + const interactions = useProductInteractionAdapter(); + + const [showAddMenu, setShowAddMenu] = React.useState(false); + const [expandedSourceId, setExpandedSourceId] = React.useState(null); + const expandedSource = React.useMemo( + () => sources.find((source) => source.id === expandedSourceId) ?? null, + [expandedSourceId, sources] + ); + const [expandedSourceSnapshot, setExpandedSourceSnapshot] = React.useState(null); + + React.useEffect(() => { + if (!expandedSource) { + setExpandedSourceSnapshot(null); + return; + } + setExpandedSourceSnapshot((prev) => { + if (prev?.id === expandedSource.id) return prev; + return createExpandedSourceSnapshot(expandedSource); + }); + }, [expandedSource]); + + React.useEffect(() => { + if (mode !== "advanced" || sources.length > 0) return; + setSources([{ id: "1", type: "url", content: "", nameTemplate: DEFAULT_NODE_NAME_TEMPLATE }]); + }, [mode, setSources, sources.length]); + + const expandedSourcePreviewName = React.useMemo(() => { + if (!expandedSource) return ""; + return formatNodeNameFromTemplate({ + originName: "节点名称", + tag: expandedSource.tag, + template: expandedSource.nameTemplate, + }); + }, [expandedSource]); + + const maxSourcesPerType = React.useMemo(() => { + if (user?.isAdmin) return Number.POSITIVE_INFINITY; + if (!user) return 2; + const raw = user.quota?.maxImportSourcesPerType; + return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5; + }, [user]); + + const nodeCount = nodes.length; + const error = React.useMemo( + () => normalizeSubscriptionImportErrorInfo(parseErrors[0] ?? null)?.message ?? null, + [parseErrors] + ); + const nodesBySourceId = React.useMemo(() => { + const grouped = new Map(); + for (const node of nodes) { + for (const sourceId of getNodeSourceIds(node)) { + const current = grouped.get(sourceId); + if (current) { + current.push(node); + } else { + grouped.set(sourceId, [node]); + } + } + } + return grouped; + }, [nodes]); + + const closeExpandedSourceEditor = React.useCallback(() => { + if ( + expandedSource && + expandedSourceSnapshot?.id === expandedSource.id && + !expandedSource.parsing && + changedSinceSnapshot(expandedSource, expandedSourceSnapshot) + ) { + void parseSingleSource(expandedSource.id); + } + setExpandedSourceId(null); + }, [expandedSource, expandedSourceSnapshot, parseSingleSource]); + + const addSource = React.useCallback( + (type: SourceType = "url") => { + const used = sources.filter((source) => source.type === type).length; + if (used >= maxSourcesPerType) { + toast({ + title: user ? `每种导入方式最多 ${maxSourcesPerType} 个` : "未登录用户每种导入方式最多 2 个(登录后可提升)", + variant: "warning", + }); + setShowAddMenu(false); + return; + } + const newSource: SubscriptionSource = { + id: Date.now().toString(), + type, + content: "", + nameTemplate: DEFAULT_NODE_NAME_TEMPLATE, + }; + setSources([...sources, newSource]); + interactions.sourceAdded?.({ + mode, + sourceType: type, + sourceCount: sources.length + 1, + }); + setShowAddMenu(false); + }, + [interactions, maxSourcesPerType, mode, setSources, sources, user] + ); + + const removeSource = React.useCallback( + (id: string) => { + if (sources.length <= 1) return; + setSources(sources.filter((source) => source.id !== id)); + }, + [setSources, sources] + ); + + const updateSource = React.useCallback( + (id: string, content: string) => { + setSources( + sources.map((source) => { + if (source.id !== id) return source; + if (source.content === content) return source; + return markSourceAsPendingImport({ ...source, content }); + }) + ); + }, + [setSources, sources] + ); + + const updateSourceMeta = React.useCallback( + (id: string, patch: Partial) => { + setSources( + sources.map((source) => { + if (source.id !== id) return source; + + const next = { ...source, ...patch }; + const changed = (Object.keys(patch) as Array).some((key) => source[key] !== next[key]); + if (!changed) return source; + + return sourceMetaNeedsReimport(patch) ? markSourceAsPendingImport(next) : next; + }) + ); + }, + [setSources, sources] + ); + + const moveSource = React.useCallback( + (sourceId: string, direction: "up" | "down") => { + const nextSources = moveSubscriptionSource(sources, sourceId, direction); + if (nextSources === sources) return; + setSources(nextSources); + }, + [setSources, sources] + ); + + const updateSourceType = React.useCallback( + (id: string, type: SourceType) => { + const current = sources.find((source) => source.id === id); + if (!current || current.type === type) return; + + const used = sources.filter((source) => source.type === type).length; + if (used >= maxSourcesPerType) { + toast({ + title: user ? `每种导入方式最多 ${maxSourcesPerType} 个` : "未登录用户每种导入方式最多 2 个(登录后可提升)", + variant: "warning", + }); + return; + } + + setSources( + sources.map((source) => { + if (source.id !== id) return source; + + return markSourceAsPendingImport({ + ...source, + type, + content: "", + useProxyProviders: type === "url" ? Boolean(source.useProxyProviders) : undefined, + userinfoUrl: type === "url" ? source.userinfoUrl : undefined, + userinfoUserAgent: type === "url" ? source.userinfoUserAgent : undefined, + lastParsedContent: undefined, + lastParsedTag: undefined, + lastParsedNameTemplate: undefined, + }); + }) + ); + }, + [maxSourcesPerType, setSources, sources, user] + ); + + const handleImportSource = React.useCallback( + async (sourceId: string) => { + const source = sources.find((item) => item.id === sourceId); + if (!source || !source.content.trim() || source.parsing) return; + + await parseSingleSource(sourceId); + + const latestState = useConfigStore.getState(); + const latestSource = latestState.sources.find((item) => item.id === sourceId) ?? source; + const result: ProductInteractionResult = latestSource.parsed + ? "success" + : latestSource.error || latestSource.errorInfo + ? "runtimeError" + : "validationError"; + interactions.sourceImported?.({ + mode, + sourceType: latestSource.type, + result, + sourceCount: latestState.sources.filter((item) => item.content.trim()).length, + nodeCount: latestSource.nodeCount ?? 0, + usesProxyProvider: Boolean(latestSource.useProxyProviders), + }); + }, + [interactions, mode, parseSingleSource, sources] + ); + + return { + addSource, + closeExpandedSourceEditor, + error, + expandedSource, + expandedSourcePreviewName, + handleImportSource, + maxSourcesPerType, + moveSource, + nodeCount, + nodesBySourceId, + removeSource, + setExpandedSourceId, + setShowAddMenu, + showAddMenu, + sources, + updateSource, + updateSourceMeta, + updateSourceType, + }; +} 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 a92dd87..fa125a0 100644 --- a/packages/ui/src/store/config-store/actions/custom-actions.ts +++ b/packages/ui/src/store/config-store/actions/custom-actions.ts @@ -20,7 +20,7 @@ type CustomActions = Pick< | "updateCustomProxyGroup" >; -function normalizeRuleOrderForState(state: { +type NormalizeRuleOrderState = { enabledProxyGroups: string[]; customProxyGroups: Parameters[0]["customProxyGroups"]; customRules: Parameters[0]["customRules"]; @@ -30,7 +30,9 @@ function normalizeRuleOrderForState(state: { experimentalCnUseCnRuleSet: boolean; cnIpNoResolve: boolean; ruleOrder: string[]; -}): string[] { +}; + +function normalizeRuleOrderForState(state: NormalizeRuleOrderState): string[] { return normalizePersistedRuleOrder({ enabledModules: state.enabledProxyGroups, customProxyGroups: state.customProxyGroups, @@ -161,9 +163,9 @@ export function createCustomActions( const nextCustomProxyGroups = state.customProxyGroups.filter( (g) => g.id !== id, ); - const removedTarget = removedGroup?.name?.trim() || ""; - const nextCustomRuleSets = removedTarget - ? state.customRuleSets.filter((ruleSet) => ruleSet.target !== removedTarget) + const removedGroupName = removedGroup?.name?.trim() || ""; + const nextCustomRuleSets = removedGroupName + ? state.customRuleSets.filter((ruleSet) => ruleSet.target !== removedGroupName) : state.customRuleSets; return { customProxyGroups: nextCustomProxyGroups, 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 index 7ea0b34..08f9442 100644 --- 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 @@ -1,4 +1,3 @@ -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"; diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index bbf45d7..31ce35f 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -211,6 +211,56 @@ container-type: inline-size; } + .proxy-group-member-toolbar { + container-type: inline-size; + } + + .proxy-group-member-action-full, + .proxy-group-member-action-medium { + display: none; + } + + .proxy-group-member-action-compact { + display: inline; + } + + .proxy-group-member-heading-compact { + display: none; + } + + @container (max-width: 18rem) { + .proxy-group-member-actions { + gap: 0.0625rem !important; + } + + .proxy-group-member-action-button { + gap: 0; + min-width: 1.5rem; + padding-left: 0; + padding-right: 0; + width: 1.5rem; + } + + .proxy-group-member-action-compact { + display: none; + } + + .proxy-group-member-count { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + } + + @container (max-width: 13rem) { + .proxy-group-member-heading-full { + display: none; + } + + .proxy-group-member-heading-compact { + display: inline; + } + } + .proxy-group-custom-rule-editor-trailing { flex: 0 0 auto; grid-template-columns: 120px auto 92px; @@ -256,7 +306,15 @@ text-align: left; } - @container (min-width: 30rem) { + @container (min-width: 23rem) { + .proxy-group-member-action-medium { + display: inline; + } + + .proxy-group-member-action-compact { + display: none; + } + .proxy-group-module-summary { --proxy-group-summary-font-size: 0.75rem; flex-basis: auto; @@ -264,6 +322,16 @@ text-align: right; } } + + @container (min-width: 28rem) { + .proxy-group-member-action-full { + display: inline; + } + + .proxy-group-member-action-medium { + display: none; + } + } } @layer utilities { diff --git a/scripts/selfhost-release-assets.cjs b/scripts/selfhost-release-assets.cjs index 5f32392..5522ce7 100644 --- a/scripts/selfhost-release-assets.cjs +++ b/scripts/selfhost-release-assets.cjs @@ -67,36 +67,36 @@ function parseArgs(argv) { 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]; + for (let argIndex = 0; argIndex < argv.length; argIndex += 1) { + const arg = argv[argIndex]; if (arg === "--base-url") { - args.baseUrl = argv[index + 1] || ""; - index += 1; + args.baseUrl = argv[argIndex + 1] || ""; + argIndex += 1; } else if (arg === "--build-sha") { - args.buildSha = argv[index + 1] || ""; - index += 1; + args.buildSha = argv[argIndex + 1] || ""; + argIndex += 1; } else if (arg === "--dry-run") { args.dryRun = true; } else if (arg === "--image") { - args.image = argv[index + 1] || ""; - index += 1; + args.image = argv[argIndex + 1] || ""; + argIndex += 1; } else if (arg === "--image-repository") { - args.imageRepository = argv[index + 1] || ""; - index += 1; + args.imageRepository = argv[argIndex + 1] || ""; + argIndex += 1; } else if (arg === "--image-tag") { - args.imageTag = argv[index + 1] || ""; - index += 1; + args.imageTag = argv[argIndex + 1] || ""; + argIndex += 1; } else if (arg === "--output") { - args.output = argv[index + 1] || ""; - index += 1; + args.output = argv[argIndex + 1] || ""; + argIndex += 1; } else if (arg === "--tag" || arg === "--release-tag") { - args.releaseTag = argv[index + 1] || ""; - index += 1; + args.releaseTag = argv[argIndex + 1] || ""; + argIndex += 1; } else { const installerDefault = INSTALLER_DEFAULTS.find((item) => item.arg === arg); if (installerDefault) { - args[installerDefault.key] = argv[index + 1] || ""; - index += 1; + args[installerDefault.key] = argv[argIndex + 1] || ""; + argIndex += 1; } else if (arg === "--help" || arg === "-h") { args.help = true; } else { @@ -145,7 +145,8 @@ function buildManifest(publicRoot, args) { const shortSha = buildSha.slice(0, 12); const releaseTag = args.releaseTag || `v${version}`; const imageRepository = args.imageRepository || DEFAULT_IMAGE_REPOSITORY; - const imageTag = args.imageTag || `${imageRepository}:${releaseTag}`; + const defaultImageTag = `${imageRepository}:${releaseTag}`; + const imageTag = args.imageTag || defaultImageTag; const image = args.image || imageTag; const buildVersion = `${version}+sha.${shortSha}`; @@ -172,14 +173,22 @@ function copyFile(publicRoot, from, to, options = {}) { fs.copyFileSync(source, to); } +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function replaceInstallerDefault(content, item, replacement) { - const expected = `${item.name}="${item.value}"`; const replacementLine = `${item.name}="${replacement}"`; - const count = content.split(expected).length - 1; + const assignmentPattern = new RegExp( + `^\\s*${escapeRegExp(item.name)}\\s*=\\s*(["'])${escapeRegExp(item.value)}\\1\\s*$`, + "gm", + ); + const matches = content.match(assignmentPattern); + const count = matches ? matches.length : 0; if (count !== 1) { throw new Error(`Expected exactly one ${item.name} assignment in install.sh, found ${count}.`); } - return content.replace(expected, replacementLine); + return content.replace(assignmentPattern, () => replacementLine); } function withInferredInstallerDefaults(args) {