diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index 19ddd86..5936655 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -77,6 +77,55 @@ describe("generateClashConfig", () => { ]); }); + it("generates group listeners bound to existing proxy groups with validation and dedupe", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + dialerProxyGroups: [ + { id: "d1", name: "美国中转", relayNodes: ["Node"], targetNodes: [], type: "select" }, + { id: "d2", name: "香港中转", relayNodes: ["Node"], targetNodes: [], type: "select" }, + ], + userConfig: { + dnsYaml: "", + mixedPort: 7890, + listenerPorts: { Node: 12000 }, + }, + groupListeners: [ + { id: "a", target: "美国中转", port: 7891 }, + { id: "b", target: "不存在的组", port: 7892 }, + { id: "c", target: "美国中转", port: 7893 }, + { id: "d", target: "香港中转", port: 12000 }, + { id: "e", target: "香港中转", port: 7890 }, + { id: "f", target: "香港中转", port: 7894 }, + ], + }); + + expect(config.listeners).toEqual([ + { name: "mixed0", type: "mixed", port: 12000, proxy: "Node" }, + { name: "group-in-0", type: "mixed", listen: "0.0.0.0", port: 7891, proxy: "美国中转", udp: true }, + { name: "group-in-1", type: "mixed", listen: "0.0.0.0", port: 7894, proxy: "香港中转", udp: true }, + ]); + }); + + it("appends group listeners after base config listeners and node listeners", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + dialerProxyGroups: [ + { id: "d1", name: "美国中转", relayNodes: ["Node"], targetNodes: [], type: "select" }, + ], + userConfig: { + dnsYaml: ["listeners:", " - name: base-in", " type: socks", " port: 18000"].join("\n"), + listenerPorts: { Node: 12000 }, + }, + groupListeners: [{ id: "a", target: "美国中转", port: 7891 }], + }); + + expect(config.listeners).toEqual([ + { name: "base-in", type: "socks", port: 18000 }, + { name: "mixed0", type: "mixed", port: 12000, proxy: "Node" }, + { name: "group-in-0", type: "mixed", listen: "0.0.0.0", port: 7891, proxy: "美国中转", udp: true }, + ]); + }); + it("moves root nameserver-policy under dns and merges proxy providers", () => { const config = generateClashConfig({ nodes: [ssNode()], @@ -366,9 +415,13 @@ describe("generateClashConfig", () => { }); }); - it("applies persisted proxy group order across dialer, custom, and module groups", () => { + it("applies persisted proxy group order across dialer, custom, module, and provider groups", () => { const config = generateClashConfig({ nodes: [ssNode({ name: "Relay" }), ssNode({ name: "Target", server: "target.example.com" })], + proxyProviders: { + air: { type: "http", url: "https://air.example.com/sub", path: "./air.yaml" }, + }, + proxyProviderAttachments: [{ key: "air", mode: "grouped", groupName: "✈️ Air" }], dialerProxyGroups: [ { id: "chain", @@ -386,14 +439,15 @@ describe("generateClashConfig", () => { groupType: "select", }, ], - proxyGroupOrder: ["custom:custom", "dialer:chain", "module:auto", "missing", "module:auto"], + proxyGroupOrder: ["name:✈️ Air", "custom:custom", "dialer:chain", "module:auto", "missing", "module:auto"], userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"], }, }); - expect(config["proxy-groups"]?.slice(0, 4).map((group) => group.name)).toEqual([ + expect(config["proxy-groups"]?.slice(0, 5).map((group) => group.name)).toEqual([ + "✈️ Air", "Custom", "Chain", "⚡ 自动选择", @@ -615,4 +669,201 @@ describe("generateClashConfig", () => { expect(yaml).toContain("proxy-groups:"); expect(yaml).toContain("rules:"); }); + + it("keeps legacy inline behavior when providers carry no attachments", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + proxyProviders: { + remote: { type: "http", url: "https://example.com/provider.yaml", path: "./remote.yaml" }, + }, + userConfig: { dnsYaml: "" }, + }); + + const groups = config["proxy-groups"] ?? []; + const select = groups.find((group) => group.name === "🚀 节点选择"); + expect(select).toMatchObject({ use: ["remote"] }); + // 未声明 attachments 时不生成机场组 + expect(groups.filter((group) => Array.isArray(group.use) && group.use.length === 1 && !group.proxies)).toHaveLength(0); + }); + + it("wires grouped, inline, and bare providers according to their attachments", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + proxyProviders: { + grouped_a: { type: "http", url: "https://a.example.com/sub", path: "./a.yaml" }, + inline_b: { type: "http", url: "https://b.example.com/sub", path: "./b.yaml" }, + bare_c: { type: "http", url: "https://c.example.com/sub", path: "./c.yaml" }, + }, + proxyProviderAttachments: [ + { key: "grouped_a", mode: "grouped", groupName: "✈️ 测试机场" }, + { key: "inline_b", mode: "inline" }, + { key: "bare_c", mode: "bare" }, + ], + userConfig: { dnsYaml: "" }, + }); + + const groups = config["proxy-groups"] ?? []; + + // 机场组:select + use,插在节点选择/自动选择之后 + const airport = groups.find((group) => group.name === "✈️ 测试机场"); + expect(airport).toMatchObject({ + type: "select", + use: ["grouped_a"], + }); + expect(airport).not.toHaveProperty("icon"); + const airportIndex = groups.findIndex((group) => group.name === "✈️ 测试机场"); + const selectIndex = groups.findIndex((group) => group.name === "🚀 节点选择"); + const autoIndex = groups.findIndex((group) => group.name === "⚡ 自动选择"); + expect(airportIndex).toBeGreaterThan(Math.max(selectIndex, autoIndex)); + + // 机场组进入「节点选择」候选;provider 注入只保留 inline + const select = groups.find((group) => group.name === "🚀 节点选择"); + expect(select?.proxies).toContain("✈️ 测试机场"); + expect(select).toMatchObject({ use: ["inline_b"] }); + // 机场组默认进入所有内置手选类分流组候选 + for (const group of groups) { + if (group.name === "✈️ 测试机场") continue; + if (group.type !== "select") continue; + expect(group.proxies).toContain("✈️ 测试机场"); + } + for (const group of groups) { + if (group.name === "✈️ 测试机场") continue; + if (!Array.isArray(group.use)) continue; + expect(group.use).toEqual(["inline_b"]); + } + // bare 不出现在任何 use 里 + expect(groups.some((group) => Array.isArray(group.use) && group.use.includes("bare_c"))).toBe(false); + }); + + it("excludes a default airport group from a group via advanced.excludedMembers", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + proxyProviders: { + grouped_a: { type: "http", url: "https://a.example.com/sub", path: "./a.yaml" }, + }, + proxyProviderAttachments: [{ key: "grouped_a", mode: "grouped", groupName: "✈️ 测试机场" }], + proxyGroupAdvanced: { + select: { excludedMembers: [{ kind: "provider-group", key: "grouped_a" }] }, + }, + userConfig: { dnsYaml: "" }, + }); + const groups = config["proxy-groups"] ?? []; + // 「节点选择」排除了机场组 → 其 proxies 不含机场组名;机场组本体仍生成,其它组仍含 + const select = groups.find((group) => group.name === "🚀 节点选择"); + expect(select?.proxies).not.toContain("✈️ 测试机场"); + expect(groups.some((group) => group.name === "✈️ 测试机场")).toBe(true); + const auto = groups.find((group) => group.name === "⚡ 自动选择"); + // ⚡ 自动选择是 url-test,不注入机场组;换个手选组验证仍默认含 + const anotherSelect = groups.find( + (group) => group.type === "select" && group.name !== "🚀 节点选择" && group.name !== "✈️ 测试机场", + ); + expect(anotherSelect?.proxies).toContain("✈️ 测试机场"); + void auto; + }); + + it("reorders a default airport group within a group via advanced.memberOrder", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + proxyProviders: { + grouped_a: { type: "http", url: "https://a.example.com/sub", path: "./a.yaml" }, + }, + proxyProviderAttachments: [{ key: "grouped_a", mode: "grouped", groupName: "✈️ 测试机场" }], + proxyGroupAdvanced: { + select: { + memberOrder: [ + { kind: "provider-group", key: "grouped_a" }, + { kind: "module", id: "auto" }, + ], + }, + }, + userConfig: { dnsYaml: "" }, + }); + const groups = config["proxy-groups"] ?? []; + const select = groups.find((group) => group.name === "🚀 节点选择"); + const proxies = select?.proxies ?? []; + // memberOrder 把机场组排到自动选择之前 + expect(proxies.indexOf("✈️ 测试机场")).toBeLessThan(proxies.indexOf("⚡ 自动选择")); + }); + + it("suffixes airport group names that collide with existing groups or nodes", () => { + const config = generateClashConfig({ + nodes: [ssNode({ name: "撞名机场" })], + proxyProviders: { + p1: { type: "http", url: "https://a.example.com/sub", path: "./a.yaml" }, + p2: { type: "http", url: "https://b.example.com/sub", path: "./b.yaml" }, + }, + proxyProviderAttachments: [ + { key: "p1", mode: "grouped", groupName: "撞名机场" }, + { key: "p2", mode: "grouped", groupName: "撞名机场" }, + ], + userConfig: { dnsYaml: "" }, + }); + + const groups = config["proxy-groups"] ?? []; + const airportNames = groups.filter((group) => Array.isArray(group.use)).map((group) => group.name); + expect(airportNames).toEqual(["撞名机场 2", "撞名机场 3"]); + }); + + it("places the airport group right after 自动选择 (core area) and adds it to custom groups", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + proxyProviders: { + grouped_a: { type: "http", url: "https://a.example.com/sub", path: "./a.yaml" }, + }, + proxyProviderAttachments: [{ key: "grouped_a", mode: "grouped", groupName: "✈️ 测试机场" }], + customProxyGroups: [{ id: "cg1", name: "🧩 我的分组", emoji: "🧩", groupType: "select", enabled: true }], + userConfig: { dnsYaml: "" }, + }); + + const groups = config["proxy-groups"] ?? []; + const names = groups.map((group) => group.name); + // 机场组紧跟“⚡ 自动选择”之后(核心区内) + const autoIndex = names.indexOf("⚡ 自动选择"); + expect(names[autoIndex + 1]).toBe("✈️ 测试机场"); + // 自定义分组默认也带上机场组候选 + const custom = groups.find((group) => group.name === "🧩 我的分组"); + expect(custom?.proxies).toContain("✈️ 测试机场"); + }); + + it("appends manually added provider members (Provider 组 panel) to use and proxies", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + proxyProviders: { + grouped_a: { type: "http", url: "https://a.example.com/sub", path: "./a.yaml" }, + bare_b: { type: "http", url: "https://b.example.com/sub", path: "./b.yaml" }, + }, + proxyProviderAttachments: [ + { key: "grouped_a", mode: "grouped", groupName: "✈️ 测试机场" }, + { key: "bare_b", mode: "bare" }, + ], + customProxyGroups: [ + { + id: "cg1", + name: "🧩 我的分组", + emoji: "🧩", + groupType: "select", + enabled: true, + advanced: { + // 手动把 bare provider 以内联方式并入本组,并追加分组模式机场组 + extraMembers: [ + { kind: "provider-inline", key: "bare_b" }, + { kind: "provider-group", key: "grouped_a" }, + ], + }, + }, + ], + userConfig: { dnsYaml: "" }, + }); + + const groups = config["proxy-groups"] ?? []; + const custom = groups.find((group) => group.name === "🧩 我的分组"); + // 内联的 bare provider 落到该组 use(bare 默认不注入,靠手动追加) + expect(custom?.use).toContain("bare_b"); + // 机场组名落到 proxies(去重,不因候选已含而重复) + const airportCount = (custom?.proxies ?? []).filter((name) => name === "✈️ 测试机场").length; + expect(airportCount).toBe(1); + // 其他组不受影响:不会平白多出 bare_b 的 use + const select = groups.find((group) => group.name === "🚀 节点选择"); + expect(select?.use ?? []).not.toContain("bare_b"); + }); }); diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 5157b1c..d5b4acd 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -8,6 +8,7 @@ import { buildDefaultUserConfig, } from "@subboost/core/config/defaults"; import { + buildProviderProxyGroups, generateProxyGroups, generateRules, generateRuleProviders, @@ -26,12 +27,14 @@ import type { ClashConfig, CustomProxyGroup, CustomRuleSet, + GroupListenerBinding, ProxyGroup, ProxyGroupAdvancedConfig, TemplateType, UserConfig, } from "@subboost/core/types/config"; import type { DialerProxyGroup } from "@subboost/core/types/template-config"; +import type { ProxyProviderAttachment } from "@subboost/core/subscription/proxy-providers"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "../mihomo/proxy-sanitizer"; import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-targets"; @@ -39,6 +42,10 @@ import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-t export interface GenerateOptions { nodes: ParsedNode[]; proxyProviders?: Record; + // 各 provider 的接入方式(key 对应 proxyProviders 的键): + // grouped=生成机场组挂入节点选择;inline=直接 use 注入所有策略组;bare=仅生成不挂接。 + // 未提供条目的 key 按 inline 处理(与历史行为一致)。 + proxyProviderAttachments?: ProxyProviderAttachment[]; template?: TemplateType; userConfig?: Partial; dialerProxyGroups?: DialerProxyGroup[]; @@ -48,6 +55,7 @@ export interface GenerateOptions { builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; proxyGroupOrder?: string[]; + groupListeners?: GroupListenerBinding[]; } type BaseConfig = Record; @@ -179,6 +187,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { const { nodes, proxyProviders, + proxyProviderAttachments = [], template = "standard", userConfig = {}, dialerProxyGroups = [], @@ -204,6 +213,19 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { ? Object.keys(resolvedProxyProviders).map((k) => k.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)) : []; + // provider 接入模式:grouped/inline/bare;未声明的 key 按 inline(历史行为) + const attachmentByKey = new Map(); + for (const attachment of proxyProviderAttachments) { + if (!attachment || typeof attachment.key !== "string" || !attachment.key.trim()) continue; + attachmentByKey.set(attachment.key.trim(), attachment); + } + const providerModeOf = (key: string) => { + const mode = attachmentByKey.get(key)?.mode; + return mode === "grouped" || mode === "bare" ? mode : "inline"; + }; + const inlineProviderNames = proxyProviderNames.filter((key) => providerModeOf(key) === "inline"); + const groupedProviderKeys = proxyProviderNames.filter((key) => providerModeOf(key) === "grouped"); + // 解析用户自定义的基础配置(提前解析:用于规范化补齐) const baseConfigYaml = (hasExplicitBaseConfigYaml ? userConfig.dnsYaml : config.dnsYaml) ?? ""; const baseConfig = baseConfigYaml.trim() ? parseBaseConfigYaml(baseConfigYaml) : {}; @@ -338,18 +360,40 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { return out.length > 0 ? out : undefined; })(); + // 生成机场组(proxy-providers 分组模式):与可视化预览共用同一构建函数,保证组名一致 + const groupedAttachments = groupedProviderKeys + .map((key) => attachmentByKey.get(key)) + .filter((attachment): attachment is ProxyProviderAttachment => Boolean(attachment)); + const { groups: providerGroups, names: providerGroupNames } = buildProviderProxyGroups(groupedAttachments, [ + "DIRECT", + "REJECT", + ...nodeNameSet, + ...moduleGroupNameSet, + ...customGroupNameSet, + ...sanitizedDialerProxyGroups.map((g) => g.name.trim()).filter(Boolean), + ]); + // key→机场组名映射(供 Provider 组面板手动追加 provider-group 成员时解析组名) + const providerGroupNameByKey: Record = {}; + for (const providerGroup of providerGroups) { + const key = Array.isArray(providerGroup.use) ? providerGroup.use[0] : undefined; + if (typeof key === "string" && key) providerGroupNameByKey[key] = providerGroup.name; + } + // 生成中转代理组 const dialerGroups = generateDialerProxyGroups( sanitizedDialerProxyGroups, config.testUrl, config.testInterval, - proxyProviderNames + inlineProviderNames ) as unknown as ProxyGroup[]; // 生成选项 const generateOpts = { nodes: outputNodes, - proxyProviderNames, + proxyProviderNames: inlineProviderNames, + providerGroupNames, + allProviderKeys: proxyProviderNames, + providerGroupNameByKey, enabledModules: config.enabledGroups, ruleProviderBaseUrl: config.ruleProviderBaseUrl, testUrl: config.testUrl, @@ -376,8 +420,9 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { const allGroups = generateProxyGroups(generateOpts); const mergedGroups = (() => { - // 如果没有中转组,直接返回 - if (dialerGroups.length === 0) { + // 机场组与中转组一起插入到“节点选择/自动选择”之后 + const extraGroups = [...providerGroups, ...dialerGroups]; + if (extraGroups.length === 0) { return allGroups; } @@ -390,11 +435,11 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { // 在节点选择和自动选择之后插入中转组 const before = allGroups.slice(0, insertAfter + 1); const after = allGroups.slice(insertAfter + 1); - return [...before, ...dialerGroups, ...after]; + return [...before, ...extraGroups, ...after]; } // 如果找不到,就放在最前面 - return [...dialerGroups, ...allGroups]; + return [...extraGroups, ...allGroups]; })(); const orderKeys = Array.isArray(options.proxyGroupOrder) @@ -476,9 +521,53 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { availablePolicyTargets ); const resolvedListeners = (() => { - if (!listeners) return baseTopLevelPatch.listeners; - if (baseTopLevelPatch.listeners === undefined) return listeners; - if (Array.isArray(baseTopLevelPatch.listeners)) return [...baseTopLevelPatch.listeners, ...listeners]; + // 分组监听:给"已存在的策略组"绑定 inbound 端口;端口在节点监听与 mixed-port 之间去重。 + const groupListenerEntries = (() => { + const list = Array.isArray(options.groupListeners) ? options.groupListeners : []; + if (list.length === 0) return [] as Array>; + + const validTargets = new Set(availablePolicyTargets); + const usedPorts = new Set(Array.isArray(listeners) ? listeners.map((l) => l.port) : []); + if (typeof config.mixedPort === "number" && Number.isInteger(config.mixedPort)) { + usedPorts.add(config.mixedPort); + } + const usedNames = new Set(Array.isArray(listeners) ? listeners.map((l) => l.name) : []); + const usedTargets = new Set(); + + const out: Array> = []; + let autoIndex = 0; + for (const binding of list) { + if (!binding || typeof binding !== "object") continue; + + const target = typeof binding.target === "string" ? binding.target.trim() : ""; + if (!target || !validTargets.has(target)) continue; + // 一组一端口:同 target 重复绑定只取首条 + if (usedTargets.has(target)) continue; + + const port = binding.port; + if (typeof port !== "number" || !Number.isInteger(port)) continue; + if (port < 1 || port > 65535) continue; + if (usedPorts.has(port)) continue; + usedPorts.add(port); + usedTargets.add(target); + + let name = `group-in-${autoIndex++}`; + while (usedNames.has(name)) name = `group-in-${autoIndex++}`; + usedNames.add(name); + + out.push({ name, type: "mixed", listen: "0.0.0.0", port, proxy: target, udp: true }); + } + return out; + })(); + + const generatedListeners: Array> = [ + ...(Array.isArray(listeners) ? (listeners as Array>) : []), + ...groupListenerEntries, + ]; + + if (generatedListeners.length === 0) return baseTopLevelPatch.listeners; + if (baseTopLevelPatch.listeners === undefined) return generatedListeners; + if (Array.isArray(baseTopLevelPatch.listeners)) return [...baseTopLevelPatch.listeners, ...generatedListeners]; throw new BaseConfigYamlError("基础和 DNS 配置中的 listeners 必须是数组,才能与节点监听端口合并。"); })(); diff --git a/packages/core/src/generator/proxy-groups.ts b/packages/core/src/generator/proxy-groups.ts index 63d56d5..e19e8bc 100644 --- a/packages/core/src/generator/proxy-groups.ts +++ b/packages/core/src/generator/proxy-groups.ts @@ -27,7 +27,7 @@ import { } from "./rules"; import { getModuleRuleOrderKey } from "./module-rules"; import { buildRuleSetUrlFromPath } from "@subboost/core/rules/rule-model"; -import { buildTypedProxyGroup } from "./proxy-group-type"; +import { buildTypedProxyGroup, uniqueProxyNames } from "./proxy-group-type"; export { PROXY_GROUP_MODULES }; export type { ProxyGroupModule, ProxyGroupRule }; @@ -51,6 +51,12 @@ export const CATEGORY_INFO: Record = { export interface GenerateOptions { nodes: ParsedNode[]; proxyProviderNames?: string[]; + // 机场组(proxy-providers 分组模式)的组名:仅用于把机场组注入“节点选择”候选与可选成员池, + // 组本体在 generator/index.ts 中生成并插入(携带 use/icon 完整信息)。 + providerGroupNames?: string[]; + // provider 手动追加(高级模式 Provider 组面板)用:全部 provider key、以及 key→机场组名 映射。 + allProviderKeys?: string[]; + providerGroupNameByKey?: Record; enabledModules: string[]; ruleProviderBaseUrl: string; testUrl: string; @@ -69,6 +75,36 @@ export interface GenerateOptions { export { isSubscriptionInfoNodeName }; +/** + * 构建机场组(proxy-providers 分组模式):select + use。 + * 组名与保留名(既有组/节点等)冲突时自动加序号后缀。 + * generator/index.ts 与可视化预览共用,保证两边组名一致。 + */ +export function buildProviderProxyGroups( + attachments: Array<{ key: string; mode: string; groupName?: string }>, + reservedNames: Iterable +): { groups: ProxyGroup[]; names: string[] } { + const reserved = new Set(reservedNames); + const groups: ProxyGroup[] = []; + const names: string[] = []; + for (const attachment of attachments) { + if (!attachment || attachment.mode !== "grouped") continue; + const key = typeof attachment.key === "string" ? attachment.key.trim() : ""; + if (!key) continue; + const rawGroupName = typeof attachment.groupName === "string" ? attachment.groupName.trim() : ""; + const baseName = rawGroupName || key; + let groupName = baseName; + let suffix = 2; + while (reserved.has(groupName)) { + groupName = `${baseName} ${suffix++}`; + } + reserved.add(groupName); + names.push(groupName); + groups.push({ name: groupName, type: "select", use: [key] }); + } + return { groups, names }; +} + function getEnabledCustomProxyGroups(customProxyGroups: CustomProxyGroup[]): CustomProxyGroup[] { return customProxyGroups.filter((group) => group && group.enabled !== false); } @@ -134,6 +170,9 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { const { nodes, proxyProviderNames = [], + providerGroupNames = [], + allProviderKeys = [], + providerGroupNameByKey = {}, enabledModules, testUrl, testInterval, @@ -206,8 +245,32 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { ...filteredNodeNames, ...PROXY_GROUP_MODULES.filter((module) => enabledSet.has(module.id)).map((module) => moduleNames[module.id]), ...customGroupNames, + ...providerGroupNames, ); + // 机场组(proxy-providers 分组模式)作为普通默认成员参与解析:{key,name} 列表供 resolve + // 把机场组名解析成 provider-group 成员(可排除/排序/经 extraMembers 追加)。 + const providerKeyByName = new Map(); + for (const [key, name] of Object.entries(providerGroupNameByKey)) { + if (typeof name === "string" && name) providerKeyByName.set(name, key); + } + const providerGroups = providerGroupNames + .map((name) => ({ key: providerKeyByName.get(name) ?? "", name })) + .filter((item) => item.key); + + // 默认注入机场组名:在 resolve 之前对 defaultProxyNames 做锚点插入(自动选择/节点选择之后), + // 使 excludedMembers/memberOrder/extraMembers 对机场组天然生效。算法与原 withProviderGroups 一致。 + const withProviderGroupDefaults = (proxies: string[]) => { + if (providerGroupNames.length === 0) return proxies; + const missing = providerGroupNames.filter((name) => !proxies.includes(name)); + if (missing.length === 0) return proxies; + const autoIndex = autoTarget ? proxies.indexOf(autoTarget) : -1; + const selectIndex = selectTarget ? proxies.indexOf(selectTarget) : -1; + const anchorIndex = autoIndex >= 0 ? autoIndex : selectIndex; + const insertAt = anchorIndex >= 0 ? anchorIndex + 1 : 0; + return [...proxies.slice(0, insertAt), ...missing, ...proxies.slice(insertAt)]; + }; + const resolveGroupProxyNames = ( defaultProxyNames: string[], advanced: ProxyGroupAdvancedConfig | undefined, @@ -219,6 +282,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { nodes, moduleNames, customProxyGroups: activeCustomProxyGroups, + providerGroups, advanced, self, }).proxyNames; @@ -258,7 +322,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: moduleName, type: "select", // “节点选择”组本身不应包含自己;默认先走“⚡ 自动选择”更符合开箱即用 - proxies: resolveGroupProxyNames(defaultProxies, advanced, { + proxies: resolveGroupProxyNames(withProviderGroupDefaults(defaultProxies), advanced, { kind: "module", id: module.id, name: moduleName, @@ -270,7 +334,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { groups.push({ name: moduleName, type: "select", - proxies: resolveGroupProxyNames(defaultProxies, advanced, { + proxies: resolveGroupProxyNames(withProviderGroupDefaults(defaultProxies), advanced, { kind: "module", id: module.id, name: moduleName, @@ -323,7 +387,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { groups.push({ name: moduleName, type: "select", - proxies: resolveGroupProxyNames(defaultProxies, advanced, { + proxies: resolveGroupProxyNames(withProviderGroupDefaults(defaultProxies), advanced, { kind: "module", id: module.id, name: moduleName, @@ -347,7 +411,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: moduleName, type: "select", // 私有网络/国内服务:默认 DIRECT,但也提供自动选择 - proxies: resolveGroupProxyNames(defaultProxies, advanced, { + proxies: resolveGroupProxyNames(withProviderGroupDefaults(defaultProxies), advanced, { kind: "module", id: module.id, name: moduleName, @@ -407,8 +471,10 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: customGroup.name, type: "select", proxies: resolveCustom( - fallbackTargets("DIRECT", "REJECT", ...filteredNodeNames).filter( - (target) => target !== customGroup.name + withProviderGroupDefaults( + fallbackTargets("DIRECT", "REJECT", ...filteredNodeNames).filter( + (target) => target !== customGroup.name + ) ) ), ...providerUse, @@ -419,8 +485,10 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { name: customGroup.name, type: "select", proxies: resolveCustom( - fallbackTargets("REJECT", "DIRECT", ...filteredNodeNames).filter( - (target) => target !== customGroup.name + withProviderGroupDefaults( + fallbackTargets("REJECT", "DIRECT", ...filteredNodeNames).filter( + (target) => target !== customGroup.name + ) ) ), ...providerUse, @@ -429,7 +497,7 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { return { name: customGroup.name, type: "select", - proxies: resolveCustom(customBaseProxies.filter((target) => target !== customGroup.name)), + proxies: resolveCustom(withProviderGroupDefaults(customBaseProxies.filter((target) => target !== customGroup.name))), ...providerUse, }; }; @@ -473,6 +541,37 @@ export function generateProxyGroups(options: GenerateOptions): ProxyGroup[] { } } + // Provider 组面板:把各组 advanced.extraMembers 里手动追加的 provider-inline 成员落到 use。 + // provider-group(机场组)已作为普通成员由 resolveGroupProxyNames 处理(进 proxies 并参与排序),此处不再重复追加。 + if (allProviderKeys.length > 0) { + const providerKeySet = new Set(allProviderKeys); + const advancedByGroupName = new Map(); + for (const proxyModule of PROXY_GROUP_MODULES) { + const adv = proxyGroupAdvanced[proxyModule.id]; + if (adv?.extraMembers?.length) advancedByGroupName.set(moduleNames[proxyModule.id], adv); + } + for (const customGroup of activeCustomProxyGroups) { + const name = typeof customGroup.name === "string" ? customGroup.name.trim() : ""; + if (name && customGroup.advanced?.extraMembers?.length) { + advancedByGroupName.set(name, customGroup.advanced); + } + } + for (const group of groups) { + const advanced = advancedByGroupName.get(group.name); + if (!advanced?.extraMembers?.length) continue; + const inlineKeys: string[] = []; + for (const ref of advanced.extraMembers) { + if (ref.kind === "provider-inline" && providerKeySet.has(ref.key)) { + inlineKeys.push(ref.key); + } + } + if (inlineKeys.length > 0) { + const existing = Array.isArray(group.use) ? group.use : []; + group.use = uniqueProxyNames([...existing, ...inlineKeys]); + } + } + } + return groups; } diff --git a/packages/core/src/proxy-group-advanced.test.ts b/packages/core/src/proxy-group-advanced.test.ts index 764a8ac..e648292 100644 --- a/packages/core/src/proxy-group-advanced.test.ts +++ b/packages/core/src/proxy-group-advanced.test.ts @@ -23,6 +23,10 @@ describe("resolveProxyGroupMembers", () => { expect(normalizeProxyGroupMemberRef({ kind: "node", name: " Node A " })).toEqual({ kind: "node", name: "Node A" }); expect(normalizeProxyGroupMemberRef({ kind: "module", id: " auto " })).toEqual({ kind: "module", id: "auto" }); expect(normalizeProxyGroupMemberRef({ kind: "custom", id: " media " })).toEqual({ kind: "custom", id: "media" }); + expect(normalizeProxyGroupMemberRef({ kind: "provider-inline", key: " lxy " })).toEqual({ kind: "provider-inline", key: "lxy" }); + expect(normalizeProxyGroupMemberRef({ kind: "provider-group", key: " lxy " })).toEqual({ kind: "provider-group", key: "lxy" }); + expect(normalizeProxyGroupMemberRef({ kind: "provider-inline", key: " " })).toBeNull(); + expect(normalizeProxyGroupMemberRef({ kind: "provider-group" })).toBeNull(); expect(normalizeProxyGroupMemberRef({ kind: "direct" })).toEqual({ kind: "direct" }); expect(normalizeProxyGroupMemberRef({ kind: "reject" })).toEqual({ kind: "reject" }); expect(normalizeProxyGroupMemberRef(null)).toBeNull(); @@ -425,4 +429,62 @@ describe("resolveProxyGroupMembers", () => { ]); expect(result.excluded.map((member) => member.key)).toEqual(["direct:DIRECT"]); }); + + it("resolves airport group names as provider-group members when providerGroups is passed", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["DIRECT", "✈️ 香港机场"], + nodes: [], + providerGroups: [{ key: "lxy", name: "✈️ 香港机场" }], + }); + const airport = result.included.find((member) => member.kind === "provider-group"); + expect(airport).toMatchObject({ kind: "provider-group", key: "provider-group:lxy", name: "✈️ 香港机场" }); + expect(airport?.ref).toEqual({ kind: "provider-group", key: "lxy" }); + }); + + it("ignores airport group names when providerGroups is not passed (old behavior)", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["DIRECT", "✈️ 香港机场"], + nodes: [], + }); + expect(result.proxyNames).toEqual(["DIRECT"]); + expect(result.included.some((member) => member.kind === "provider-group")).toBe(false); + }); + + it("excludes a default airport group via excludedMembers", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["DIRECT", "✈️ 香港机场"], + availableProxyNames: ["DIRECT", "✈️ 香港机场"], + nodes: [], + providerGroups: [{ key: "lxy", name: "✈️ 香港机场" }], + advanced: { excludedMembers: [{ kind: "provider-group", key: "lxy" }] }, + }); + expect(result.proxyNames).toEqual(["DIRECT"]); + expect(result.excluded.some((member) => member.key === "provider-group:lxy")).toBe(true); + }); + + it("reorders a provider-group member via memberOrder", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["DIRECT", "✈️ 香港机场", "REJECT"], + nodes: [], + providerGroups: [{ key: "lxy", name: "✈️ 香港机场" }], + advanced: { + memberOrder: [ + { kind: "provider-group", key: "lxy" }, + { kind: "direct" }, + { kind: "reject" }, + ], + }, + }); + expect(result.proxyNames).toEqual(["✈️ 香港机场", "DIRECT", "REJECT"]); + }); + + it("drops an orphan provider-group ref in extraMembers (key not in providerGroups)", () => { + const result = resolveProxyGroupMembers({ + defaultProxyNames: ["DIRECT"], + nodes: [], + providerGroups: [{ key: "lxy", name: "✈️ 香港机场" }], + advanced: { extraMembers: [{ kind: "provider-group", key: "ghost" }] }, + }); + expect(result.included.some((member) => member.key === "provider-group:ghost")).toBe(false); + }); }); diff --git a/packages/core/src/proxy-group-advanced.ts b/packages/core/src/proxy-group-advanced.ts index 1b2187e..d2ee5d1 100644 --- a/packages/core/src/proxy-group-advanced.ts +++ b/packages/core/src/proxy-group-advanced.ts @@ -48,6 +48,9 @@ export interface ResolveProxyGroupMembersOptions { nodes: ParsedNode[]; moduleNames?: Record; customProxyGroups?: CustomProxyGroup[]; + // 机场组(proxy-providers 分组模式):{key, name} 列表,让机场组名可解析成 provider-group 成员, + // 与普通成员一样参与排除/排序/插入。不传则 provider-group 名字不被识别(与旧行为一致)。 + providerGroups?: Array<{ key: string; name: string }>; advanced?: ProxyGroupAdvancedConfig; self?: { kind: "module" | "custom"; id: string; name: string }; } @@ -148,6 +151,12 @@ export function normalizeProxyGroupMemberRef(value: unknown): ProxyGroupMemberRe if (item.kind === "custom" && typeof item.id === "string" && item.id.trim()) { return { kind: "custom", id: item.id.trim() }; } + if (item.kind === "provider-inline" && typeof item.key === "string" && item.key.trim()) { + return { kind: "provider-inline", key: item.key.trim() }; + } + if (item.kind === "provider-group" && typeof item.key === "string" && item.key.trim()) { + return { kind: "provider-group", key: item.key.trim() }; + } return null; } @@ -201,6 +210,7 @@ function buildMemberFromName( nodeNameSet: Set; moduleNameToId: Map; customNameToId: Map; + providerNameToKey?: Map; } ): ProxyGroupResolvedMember | null { const trimmed = name.trim(); @@ -215,6 +225,9 @@ function buildMemberFromName( if (moduleId) ref = { kind: "module", id: moduleId }; const customId = options.customNameToId.get(trimmed); if (!ref && customId) ref = { kind: "custom", id: customId }; + // 机场组名(proxy-providers 分组模式)→ provider-group 成员;组名经 reserved 去重不会与上述冲突 + const providerKey = options.providerNameToKey?.get(trimmed); + if (!ref && providerKey) ref = { kind: "provider-group", key: providerKey }; } if (!ref) return null; @@ -232,6 +245,7 @@ function buildMemberFromRef( nodeNameSet: Set; moduleNames: Record; customGroupsById: Map; + providerKeyToName?: Map; } ): ProxyGroupResolvedMember | null { const key = getProxyGroupMemberKey(ref); @@ -245,6 +259,13 @@ function buildMemberFromRef( const name = options.moduleNames[ref.id]?.trim(); return name ? { ref, key, name, kind: ref.kind } : null; } + // provider-inline 注入 use,不进 proxies、无顺序语义,不参与常规 member 解析 + if (ref.kind === "provider-inline") return null; + // provider-group(机场组):查 key→组名,命中即作为普通成员;孤儿(源已改/删)返回 null + if (ref.kind === "provider-group") { + const name = options.providerKeyToName?.get(ref.key); + return name ? { ref, key, name, kind: ref.kind } : null; + } const group = options.customGroupsById.get(ref.id); const name = group?.name?.trim(); return name ? { ref, key, name, kind: ref.kind } : null; @@ -295,13 +316,23 @@ export function resolveProxyGroupMembers(options: ResolveProxyGroupMembersOption customGroupsById.set(group.id, group); } } + const providerNameToKey = new Map(); + const providerKeyToName = new Map(); + for (const item of options.providerGroups || []) { + const key = typeof item.key === "string" ? item.key.trim() : ""; + const name = typeof item.name === "string" ? item.name.trim() : ""; + if (key && name) { + providerNameToKey.set(name, key); + providerKeyToName.set(key, name); + } + } const buildMembersFromNames = (names: string[]) => { const out: ProxyGroupResolvedMember[] = []; const seen = new Set(); for (const rawName of names || []) { if (typeof rawName !== "string") continue; - const member = buildMemberFromName(rawName, { nodeNameSet, moduleNameToId, customNameToId }); + const member = buildMemberFromName(rawName, { nodeNameSet, moduleNameToId, customNameToId, providerNameToKey }); if (!member || seen.has(member.key)) continue; if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; seen.add(member.key); @@ -320,6 +351,7 @@ export function resolveProxyGroupMembers(options: ResolveProxyGroupMembersOption nodeNameSet, moduleNames: options.moduleNames || {}, customGroupsById, + providerKeyToName, }); if (!member || candidateMap.has(member.key)) continue; if (options.self && member.key === `${options.self.kind}:${options.self.id}`) continue; diff --git a/packages/core/src/proxy-group-targets.ts b/packages/core/src/proxy-group-targets.ts index 3ca3f6d..0608e70 100644 --- a/packages/core/src/proxy-group-targets.ts +++ b/packages/core/src/proxy-group-targets.ts @@ -32,6 +32,10 @@ export function getProxyGroupMemberKey(member: ProxyGroupMemberRef): string { return `module:${member.id}`; case "custom": return `custom:${member.id}`; + case "provider-inline": + return `provider-inline:${member.key}`; + case "provider-group": + return `provider-group:${member.key}`; case "direct": return "direct:DIRECT"; case "reject": diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 27bbf7a..81d1896 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -448,6 +448,40 @@ describe("subscription config utils", () => { expect(options.dialerProxyGroups).toBeUndefined(); }); + it("normalizes group listeners: trims fields, drops invalid entries and dedupes targets", () => { + const options = buildGenerateOptionsFromConfig( + { + groupListeners: [ + { id: " g1 ", target: " AI 组 ", port: 7891 }, + { id: "g2", target: "Media", port: "7892" }, + { id: "g3", target: " ", port: 7893 }, + { id: "g4", target: "AI 组", port: 7894 }, + "bad", + { id: "g5", target: "Games", port: 0 }, + { id: "g6", target: "Games", port: 65536 }, + { target: "Games", port: 7895 }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.groupListeners).toEqual([ + { id: "g1", target: "AI 组", port: 7891 }, + { id: "group_listener_8", target: "Games", port: 7895 }, + ]); + }); + + it("omits group listeners when none survive normalization", () => { + const options = buildGenerateOptionsFromConfig( + { + groupListeners: [{ id: "g1", target: "", port: 7891 }, 42], + }, + { nodes: [node()] }, + ); + + expect(options).not.toHaveProperty("groupListeners"); + }); + it("uses legacy custom group fallback when rule model normalization returns no groups", async () => { vi.resetModules(); vi.doMock("@subboost/core/rules/rule-model", () => ({ diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index 09d4517..8eb8622 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -8,12 +8,13 @@ import { isProxyGroupGroupType, type CustomProxyGroup, type CustomRule, + type GroupListenerBinding, type ProxyGroupRuleTarget, type TemplateType, type UserConfig, } from "@subboost/core/types/config"; import { stripImportedNodeControlFieldsFromList } from "@subboost/core/subscription/imported-node-controls"; -import { buildProxyProvidersFromConfig } from "@subboost/core/subscription/proxy-providers"; +import { buildProxyProviderPlanFromConfig } from "@subboost/core/subscription/proxy-providers"; import { ensureCustomRuleId } from "@subboost/core/rules/custom-rule-utils"; import { DEFAULT_SUBBOOST_CONFIG } from "@subboost/core/config/defaults"; import { normalizeRuleModelFromConfig } from "@subboost/core/rules/rule-model"; @@ -127,6 +128,31 @@ function normalizeListenerPorts(value: unknown): Record | undefi return Object.keys(out).length > 0 ? out : undefined; } +function normalizeGroupListeners(value: unknown): GroupListenerBinding[] { + if (!Array.isArray(value)) return []; + const out: GroupListenerBinding[] = []; + const usedIds = new Set(); + const usedTargets = new Set(); + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (!isRecord(item)) continue; + + const target = toTrimmedString(item.target); + const port = normalizePort(item.port); + if (!target || port === undefined) continue; + // 一组一端口:同 target 重复绑定只保留首个 + if (usedTargets.has(target)) continue; + usedTargets.add(target); + + let id = toTrimmedString(item.id) || `group_listener_${index + 1}`; + while (usedIds.has(id)) id = `${id}_${index + 1}`; + usedIds.add(id); + + out.push({ id, target, port }); + } + return out; +} + function normalizeEnabledList(value: unknown): string[] | undefined { const list = normalizeStringArray(value); return list.length > 0 ? list : undefined; @@ -237,8 +263,11 @@ export function buildGenerateOptionsFromConfig( ): GenerateOptions { const config = rawConfig; const { testUrl, testInterval } = getEffectiveTestOptions(config); - const proxyProviders = - opts.proxyProviders ?? buildProxyProvidersFromConfig(config, { testUrl, testInterval }); + // attachments 始终从 config.sources 派生(描述各 provider 的接入方式); + // opts.proxyProviders 显式传入时(server 路径)与之同源,key 保持一致 + const providerPlan = buildProxyProviderPlanFromConfig(config, { testUrl, testInterval }); + const proxyProviders = opts.proxyProviders ?? providerPlan?.providers; + const proxyProviderAttachments = providerPlan?.attachments; const template = normalizeTemplate(config.template, "standard"); @@ -298,6 +327,7 @@ export function buildGenerateOptionsFromConfig( const dialerProxyGroups = normalizeDialerProxyGroups(config.dialerProxyGroups); const proxyGroupOrder = normalizeProxyGroupOrder(config.proxyGroupOrder); + const groupListeners = normalizeGroupListeners(config.groupListeners); const sanitizedNodes = stripImportedNodeControlFieldsFromList(opts.nodes); const proxyGroupAdvanced = isRecord(config.proxyGroupAdvanced) ? Object.fromEntries( @@ -310,6 +340,7 @@ export function buildGenerateOptionsFromConfig( return { nodes: sanitizedNodes, ...(proxyProviders ? { proxyProviders } : {}), + ...(proxyProviderAttachments && proxyProviderAttachments.length > 0 ? { proxyProviderAttachments } : {}), template, userConfig, ...(dialerProxyGroups.length > 0 ? { dialerProxyGroups } : {}), @@ -319,5 +350,6 @@ export function buildGenerateOptionsFromConfig( ...(Object.keys(builtinRuleEdits).length > 0 ? { builtinRuleEdits } : {}), ...(proxyGroupNameOverrides ? { proxyGroupNameOverrides } : {}), ...(proxyGroupOrder ? { proxyGroupOrder } : {}), + ...(groupListeners.length > 0 ? { groupListeners } : {}), }; } diff --git a/packages/core/src/subscription/proxy-providers.test.ts b/packages/core/src/subscription/proxy-providers.test.ts index 2a10926..dd2e620 100644 --- a/packages/core/src/subscription/proxy-providers.test.ts +++ b/packages/core/src/subscription/proxy-providers.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildProxyProvidersFromConfig } from "./proxy-providers"; +import { + buildProxyProviderPlanFromConfig, + buildProxyProvidersFromConfig, + DEFAULT_PROVIDER_GROUP_EMOJI, + DEFAULT_PROXY_PROVIDER_FILTER, +} from "./proxy-providers"; describe("proxy provider config builder", () => { it("returns undefined when no URL source can safely become a provider", () => { @@ -38,12 +43,14 @@ describe("proxy provider config builder", () => { url_airport_one: { type: "http", url: "https://a.example.com/sub", - interval: 3600, + interval: 43200, path: "./proxy_providers/url_airport_one.yaml", "health-check": { enable: true, url: "https://test.example.com", - interval: 120, + interval: 300, + timeout: 5000, + lazy: true, }, }, url_1: { @@ -57,4 +64,125 @@ describe("proxy provider config builder", () => { }); expect(Object.keys(providers || {})).toEqual(["url_airport_one", "url_1", "url_bad_url"]); }); + + it("treats legacy sources without providerMode as inline attachments", () => { + const plan = buildProxyProviderPlanFromConfig( + { + sources: [{ id: "s1", type: "url", useProxyProviders: true, content: "https://a.example.com/sub" }], + }, + { testUrl: "https://test.example.com", testInterval: 60 } + ); + + expect(plan?.attachments).toEqual([{ key: "url_s1", mode: "inline" }]); + }); + + it("honors custom provider keys while keeping the path in sync with the key", () => { + const plan = buildProxyProviderPlanFromConfig( + { + sources: [ + { + id: "s1", + type: "url", + useProxyProviders: true, + content: "https://a.example.com/sub", + providerKey: " lxy ", + providerMode: "bare", + }, + // 自定义 key 重复时后者被跳过(与默认 key 重复行为一致) + { + id: "s2", + type: "url", + useProxyProviders: true, + content: "https://b.example.com/sub", + providerKey: "lxy", + }, + // 全中文 key:文件名无安全字符,path 回落源 id 默认名 + { + id: "s3", + type: "url", + useProxyProviders: true, + content: "https://c.example.com/sub", + providerKey: "我的机场", + }, + ], + }, + { testUrl: "https://test.example.com", testInterval: 60 } + ); + + expect(Object.keys(plan?.providers ?? {})).toEqual(["lxy", "我的机场"]); + // path 文件名跟随 key(安全化) + expect(plan?.providers["lxy"]).toMatchObject({ + url: "https://a.example.com/sub", + path: "./proxy_providers/lxy.yaml", + }); + // 全非法字符 key 回落源 id 默认名 + expect(plan?.providers["我的机场"]).toMatchObject({ + path: "./proxy_providers/url_s3.yaml", + }); + expect(plan?.attachments).toEqual([ + { key: "lxy", mode: "bare" }, + { key: "我的机场", mode: "inline" }, + ]); + }); + + it("fills grouped attachments with fallback emoji group name", () => { + const plan = buildProxyProviderPlanFromConfig( + { + sources: [ + { + id: "s1", + type: "url", + useProxyProviders: true, + content: "https://a.example.com/sub", + providerMode: "grouped", + }, + { + id: "s2", + type: "url", + useProxyProviders: true, + content: "https://b.example.com/sub", + providerMode: "grouped", + providerGroupName: " ✈️ 测试机场 ", + }, + ], + }, + { testUrl: "https://test.example.com", testInterval: 60 } + ); + + expect(plan?.attachments).toEqual([ + { key: "url_s1", mode: "grouped", groupName: `${DEFAULT_PROVIDER_GROUP_EMOJI} url_s1` }, + { key: "url_s2", mode: "grouped", groupName: "✈️ 测试机场" }, + ]); + // 未设置 providerFilter → 写入默认过滤正则 + expect(plan?.providers["url_s1"]).toMatchObject({ filter: DEFAULT_PROXY_PROVIDER_FILTER }); + }); + + it("treats providerFilter as tri-state: default when unset, omitted when explicitly cleared", () => { + const plan = buildProxyProviderPlanFromConfig( + { + sources: [ + { id: "s1", type: "url", useProxyProviders: true, content: "https://a.example.com/sub" }, + { + id: "s2", + type: "url", + useProxyProviders: true, + content: "https://b.example.com/sub", + providerFilter: "", + }, + { + id: "s3", + type: "url", + useProxyProviders: true, + content: "https://c.example.com/sub", + providerFilter: "(?i)hk", + }, + ], + }, + { testUrl: "https://test.example.com", testInterval: 60 } + ); + + expect(plan?.providers["url_s1"]).toMatchObject({ filter: DEFAULT_PROXY_PROVIDER_FILTER }); + expect(plan?.providers["url_s2"]).not.toHaveProperty("filter"); + expect(plan?.providers["url_s3"]).toMatchObject({ filter: "(?i)hk" }); + }); }); diff --git a/packages/core/src/subscription/proxy-providers.ts b/packages/core/src/subscription/proxy-providers.ts index c412567..af2476e 100644 --- a/packages/core/src/subscription/proxy-providers.ts +++ b/packages/core/src/subscription/proxy-providers.ts @@ -6,17 +6,50 @@ import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/ur * 注意: * - 仅对 sources 里标记 useProxyProviders=true 的 URL 源生效 * - 不触发任何出站请求 - * - provider key 需是安全字符串(避免 YAML key 解析问题) + * - provider key 支持自定义(providerKey),默认 url_<源id>;缓存 path 文件名跟随 key + * (文件名安全化,全非法字符回落 url_<源id>,撞名自动 _2 后缀) + * - filter:providerFilter 未设置时写入默认正则(过滤到期/套餐类信息节点);显式清空则不写 filter + * - 接入模式(providerMode):grouped=生成机场组挂入节点选择;inline=直接 use 注入所有策略组(旧行为, + * 旧数据无此字段时按 inline 处理保持兼容);bare=仅生成 proxy-providers 不挂接 */ -export function buildProxyProvidersFromConfig( - config: Record, +export type ProxyProviderAttachmentMode = "grouped" | "inline" | "bare"; + +export interface ProxyProviderAttachment { + key: string; + mode: ProxyProviderAttachmentMode; + // 仅 grouped 模式生效:机场组名(留空回落为 "✈️ ",emoji 按上游惯例拼在组名前缀) + groupName?: string; +} + +export interface ProxyProviderPlan { + providers: Record; + attachments: ProxyProviderAttachment[]; +} + +// 机场组默认 emoji(拼进组名前缀,参考上游自定义分组) +export const DEFAULT_PROVIDER_GROUP_EMOJI = "✈️"; + +// provider 默认节点过滤正则:剔除到期/套餐/官网等信息节点 +export const DEFAULT_PROXY_PROVIDER_FILTER = "(?i)^(?!.*(到期时间|套餐到期|官网|更新|到期)).*"; + +function toFileSafeName(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function normalizeAttachmentMode(value: unknown): ProxyProviderAttachmentMode { + return value === "grouped" || value === "bare" || value === "inline" ? value : "inline"; +} + +export function buildProxyProviderPlanFromSources( + rawSources: unknown[], opts: { testUrl: string; testInterval: number } -): Record | undefined { - const rawSources = Array.isArray(config.sources) ? config.sources : []; +): ProxyProviderPlan | undefined { if (rawSources.length === 0) return undefined; - const out: Record = {}; + const providers: Record = {}; + const attachments: ProxyProviderAttachment[] = []; + const usedPathNames = new Set(); let fallbackIndex = 0; for (const raw of rawSources) { @@ -42,22 +75,70 @@ export function buildProxyProvidersFromConfig( const rawId = typeof item.id === "string" ? item.id.trim() : ""; const id = rawId || String(++fallbackIndex); const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "_"); - const name = `url_${safeId}`; - if (Object.prototype.hasOwnProperty.call(out, name)) continue; + const defaultKey = `url_${safeId}`; + + const customKey = typeof item.providerKey === "string" ? item.providerKey.trim() : ""; + const key = customKey || defaultKey; + // key 重复时跳过后者(与旧行为一致);UI 层负责重复提示 + if (Object.prototype.hasOwnProperty.call(providers, key)) continue; - out[name] = { + // path 文件名与 key 呼应:安全化后跟随 key;全非法字符则回落源 id 默认名;撞名加后缀 + const fileSafeKey = toFileSafeName(key); + const pathBase = /[a-zA-Z0-9]/.test(fileSafeKey) ? fileSafeKey : defaultKey; + let pathName = pathBase; + let pathSuffix = 2; + while (usedPathNames.has(pathName)) { + pathName = `${pathBase}_${pathSuffix++}`; + } + usedPathNames.add(pathName); + + // filter 三态:未设置→默认正则;显式空串→不写;有值→写 + const rawFilter = typeof item.providerFilter === "string" ? item.providerFilter.trim() : undefined; + const filter = rawFilter === undefined ? DEFAULT_PROXY_PROVIDER_FILTER : rawFilter; + + providers[key] = { type: "http", url, - interval: 3600, - path: `./proxy_providers/${name}.yaml`, + interval: 43200, + path: `./proxy_providers/${pathName}.yaml`, + ...(filter ? { filter } : {}), "health-check": { enable: true, url: opts.testUrl, - interval: opts.testInterval, + interval: 300, + timeout: 5000, + lazy: true, }, }; + + const mode = normalizeAttachmentMode(item.providerMode); + if (mode === "grouped") { + const rawGroupName = typeof item.providerGroupName === "string" ? item.providerGroupName.trim() : ""; + attachments.push({ + key, + mode, + groupName: rawGroupName || `${DEFAULT_PROVIDER_GROUP_EMOJI} ${key}`, + }); + } else { + attachments.push({ key, mode }); + } } - return Object.keys(out).length > 0 ? out : undefined; + if (Object.keys(providers).length === 0) return undefined; + return { providers, attachments }; } +export function buildProxyProviderPlanFromConfig( + config: Record, + opts: { testUrl: string; testInterval: number } +): ProxyProviderPlan | undefined { + const rawSources = Array.isArray(config.sources) ? config.sources : []; + return buildProxyProviderPlanFromSources(rawSources, opts); +} + +export function buildProxyProvidersFromConfig( + config: Record, + opts: { testUrl: string; testInterval: number } +): Record | undefined { + return buildProxyProviderPlanFromConfig(config, opts)?.providers; +} diff --git a/packages/core/src/subscription/subscription-utils.test.ts b/packages/core/src/subscription/subscription-utils.test.ts index 56a42c0..9c9c1cd 100644 --- a/packages/core/src/subscription/subscription-utils.test.ts +++ b/packages/core/src/subscription/subscription-utils.test.ts @@ -213,12 +213,14 @@ describe("subscription URL and config helpers", () => { url_main_source: { type: "http", url: "https://sub.example.com/list?token=abc", - interval: 3600, + interval: 43200, path: "./proxy_providers/url_main_source.yaml", "health-check": { enable: true, url: "https://probe.example.com/204", - interval: 120, + interval: 300, + timeout: 5000, + lazy: true, }, }, }, diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 49babde..3ff0533 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -95,6 +95,17 @@ export interface ListenerConfig { [key: string]: unknown; } +/** + * 分组监听绑定:给一个"已存在的策略组"开本地 inbound 监听端口。 + * 生成时映射为 listeners 条目:{ name: 自动, type: mixed, listen: 0.0.0.0, port, proxy: target, udp: true }。 + */ +export interface GroupListenerBinding { + id: string; + // 绑定的策略组名(写入 listener 的 proxy 字段) + target: string; + port: number; +} + export interface SnifferConfig { enable?: boolean; "parse-pure-ip"?: boolean; @@ -220,6 +231,11 @@ export type ProxyGroupMemberRef = | { kind: "node"; name: string } | { kind: "module"; id: string } | { kind: "custom"; id: string } + // provider 成员(存 provider key,稳定标识): + // provider-inline → 本组 use: [key](把该订阅节点内联进本组) + // provider-group → 本组 proxies 追加该 provider 的机场组名(引用已生成的机场组) + | { kind: "provider-inline"; key: string } + | { kind: "provider-group"; key: string } | { kind: "direct" } | { kind: "reject" }; diff --git a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts index dd71235..3a39205 100644 --- a/packages/server-core/src/subscription/refresh-node-snapshot.test.ts +++ b/packages/server-core/src/subscription/refresh-node-snapshot.test.ts @@ -572,4 +572,39 @@ describe("refreshNodeSnapshot", () => { }), ]); }); + + it("keeps provider settings on saved sources after refresh", async () => { + const result = await refreshNodeSnapshot({ + config: { + sources: [ + { + id: "provider", + type: "url", + content: "https://provider.example.com/sub", + useProxyProviders: true, + providerKey: "my_key", + providerMode: "grouped", + providerGroupName: "✈️ 机场A", + providerFilter: "", + }, + ], + }, + urls: [], + storedNodes: [], + fetchUrlNodes: vi.fn(), + }); + + expect(result.savedSources).toEqual([ + { + id: "provider", + type: "url", + content: "https://provider.example.com/sub", + useProxyProviders: true, + providerKey: "my_key", + providerMode: "grouped", + providerGroupName: "✈️ 机场A", + providerFilter: "", + }, + ]); + }); }); diff --git a/packages/server-core/src/subscription/saved-sources.test.ts b/packages/server-core/src/subscription/saved-sources.test.ts index d03da96..ec718af 100644 --- a/packages/server-core/src/subscription/saved-sources.test.ts +++ b/packages/server-core/src/subscription/saved-sources.test.ts @@ -138,4 +138,116 @@ describe("normalizeSavedSourcesForPersistence", () => { }, ]); }); + + it("preserves provider settings on url sources", () => { + expect( + normalizeSavedSourcesForPersistence([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + useProxyProviders: true, + providerKey: " my_key ", + providerMode: "grouped", + providerGroupName: " ✈️ 机场A ", + providerFilter: "(?i)^(?!.*到期).*", + }, + ]) + ).toEqual([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + useProxyProviders: true, + providerKey: "my_key", + providerMode: "grouped", + providerGroupName: "✈️ 机场A", + providerFilter: "(?i)^(?!.*到期).*", + }, + ]); + }); + + it("keeps empty-string providerFilter (explicit no-filter) but drops empty providerKey/providerGroupName", () => { + expect( + normalizeSavedSourcesForPersistence([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + useProxyProviders: true, + providerKey: " ", + providerMode: "bare", + providerGroupName: "", + providerFilter: "", + }, + ]) + ).toEqual([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + useProxyProviders: true, + providerMode: "bare", + providerFilter: "", + }, + ]); + }); + + it("drops invalid providerMode values and provider settings on non-url sources", () => { + expect( + normalizeSavedSourcesForPersistence([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + providerMode: "unknown", + providerFilter: 42, + }, + { + id: "src-2", + type: "yaml", + content: "proxies: []", + providerKey: "my_key", + providerMode: "grouped", + providerGroupName: "✈️ 机场A", + providerFilter: "", + }, + ]) + ).toEqual([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + }, + { + id: "src-2", + type: "yaml", + content: "proxies: []", + }, + ]); + }); + + it("preserves provider settings even when useProxyProviders is off", () => { + expect( + normalizeSavedSourcesForPersistence([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + providerKey: "my_key", + providerMode: "grouped", + providerGroupName: "✈️ 机场A", + }, + ]) + ).toEqual([ + { + id: "src-1", + type: "url", + content: "https://example.com/sub", + providerKey: "my_key", + providerMode: "grouped", + providerGroupName: "✈️ 机场A", + }, + ]); + }); }); diff --git a/packages/server-core/src/subscription/saved-sources.ts b/packages/server-core/src/subscription/saved-sources.ts index 4c29add..55a9387 100644 --- a/packages/server-core/src/subscription/saved-sources.ts +++ b/packages/server-core/src/subscription/saved-sources.ts @@ -12,6 +12,10 @@ export type SavedSource = { type: SavedSourceType; content: string; useProxyProviders?: boolean; + providerKey?: string; + providerMode?: "grouped" | "inline" | "bare"; + providerGroupName?: string; + providerFilter?: string; userinfoUrl?: string; userinfoUserAgent?: string; subscriptionUserInfo?: SubscriptionUserInfo; @@ -85,12 +89,24 @@ export function normalizeSavedSourcesForPersistence( const lastParsedContent = toTrimmedString(record.lastParsedContent); const lastParsedTag = toTrimmedString(record.lastParsedTag); const lastParsedNameTemplate = toTrimmedString(record.lastParsedNameTemplate); + const providerKey = toTrimmedString(record.providerKey); + const providerMode = + record.providerMode === "grouped" || record.providerMode === "inline" || record.providerMode === "bare" + ? record.providerMode + : undefined; + const providerGroupName = toTrimmedString(record.providerGroupName); + // providerFilter 保留空串(空串=显式不过滤,与"未设置走默认正则"区分),不能 trim + const providerFilter = typeof record.providerFilter === "string" ? record.providerFilter : undefined; return { id: nextId(preferredId), type, content: type === "url" ? normalizeUrlContent(content) : content, ...(type === "url" && record.useProxyProviders === true ? { useProxyProviders: true } : {}), + ...(type === "url" && providerKey ? { providerKey } : {}), + ...(type === "url" && providerMode ? { providerMode } : {}), + ...(type === "url" && providerGroupName ? { providerGroupName } : {}), + ...(type === "url" && providerFilter !== undefined ? { providerFilter } : {}), ...(type === "url" && userinfoUrl ? { userinfoUrl: normalizeUrlContent(userinfoUrl) } : {}), ...(type === "url" && userinfoUserAgent ? { userinfoUserAgent } : {}), ...(subscriptionUserInfo ? { subscriptionUserInfo } : {}), diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts index fadddcf..9dd6d13 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts @@ -108,6 +108,10 @@ vi.mock("@subboost/ui/components/ui/switch", () => ({ }, })); vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); +vi.mock("./proxy-groups-group-listeners", () => ({ + GroupListenerPortBadge: () => null, + ProxyGroupsGroupListeners: () => null, +})); vi.mock("@subboost/core/generator/proxy-groups", () => ({ PROXY_GROUP_MODULES: [ { id: "auto", name: "Auto" }, diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx index 1e74f58..1ca3d4f 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx @@ -21,6 +21,7 @@ import { type ProxyGroupNameDraft, } from "./proxy-group-name-editor"; import { ProxyGroupSummary } from "./proxy-group-summary"; +import { GroupListenerPortBadge } from "./proxy-groups-group-listeners"; import { getLoadBalanceStrategyLabel, getProxyGroupTypeLabel, @@ -263,6 +264,7 @@ export function DialerProxyGroupsSection({ {group.name} +