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
-
+
diff --git a/README.md b/README.md
index 27a159c..caa58fa 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
SubBoost
-
+
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 ? "启用" : "未启用"}
+
+
+
+ {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) {