From 73830ab590ee63c52d917eb9428f598f4189c473 Mon Sep 17 00:00:00 2001 From: local-dev Date: Tue, 14 Jul 2026 01:54:18 +0800 Subject: [PATCH 1/2] feat: proxy-providers import mode for URL sources - Per-source provider settings: custom key, attach mode (grouped/inline/bare), emoji group name, node filter regex; persisted end-to-end (store, handoff, subscription save/load, server-side saved-sources normalization) - Grouped mode generates an airport group (select + use) that joins all manual-select groups as a regular member: excludable, sortable, and manually attachable to any group via new provider member refs - Fixed provider defaults (interval, health-check), provider path follows key - UI: inline status bar on URL source editors, provider settings in advanced dialog, read-only airport group card, provider members in group editor, airport groups in visual preview --- packages/core/src/generator/index.test.ts | 208 +++++++++++- packages/core/src/generator/index.ts | 55 +++- packages/core/src/generator/proxy-groups.ts | 119 ++++++- .../core/src/proxy-group-advanced.test.ts | 62 ++++ packages/core/src/proxy-group-advanced.ts | 34 +- packages/core/src/proxy-group-targets.ts | 4 + .../core/src/subscription/config-utils.ts | 10 +- .../src/subscription/proxy-providers.test.ts | 134 +++++++- .../core/src/subscription/proxy-providers.ts | 107 +++++- .../subscription/subscription-utils.test.ts | 6 +- packages/core/src/types/config.ts | 5 + .../refresh-node-snapshot.test.ts | 35 ++ .../src/subscription/saved-sources.test.ts | 112 +++++++ .../src/subscription/saved-sources.ts | 16 + .../sections/input-section.test.ts | 33 +- .../advanced-mode/sections/input-section.tsx | 33 +- .../input-source-editor-dialog.test.ts | 31 +- .../sections/input-source-editor-dialog.tsx | 224 ++++++++----- .../sections/provider-group-plan.ts | 65 ++++ .../sections/provider-group-readonly-card.tsx | 34 ++ ...-group-advanced-panel-interactions.test.ts | 311 +++++++++++++++++- .../sections/proxy-group-advanced-panel.tsx | 227 +++++++++++-- .../sections/proxy-group-member-bulk.test.ts | 35 +- .../sections/proxy-group-member-bulk.ts | 9 +- .../sections/proxy-groups-categories.test.ts | 48 ++- .../sections/proxy-groups-categories.tsx | 32 +- .../provider-mode-status-bar.test.ts | 95 ++++++ .../converter/provider-mode-status-bar.tsx | 103 ++++++ .../provider-settings-fields.test.ts | 140 ++++++++ .../converter/provider-settings-fields.tsx | 189 +++++++++++ .../quick-mode/sources-section.test.ts | 42 ++- .../converter/quick-mode/sources-section.tsx | 261 +++++++++------ .../use-subscription-sources-controller.ts | 4 + .../home/use-editing-subscription-loader.ts | 46 +++ .../product/home/use-subscription-link.tsx | 13 + .../src/product/preview/visual-graph.test.ts | 80 +++++ .../ui/src/product/preview/visual-graph.tsx | 47 ++- .../visual-graph/proxy-groups-preview.tsx | 2 + .../ui/src/store/config-store/auth-handoff.ts | 7 +- .../ui/src/store/config-store/definitions.ts | 9 + .../store/config-store/generated-yaml.test.ts | 8 +- .../src/store/config-store/generated-yaml.ts | 50 +-- 42 files changed, 2757 insertions(+), 328 deletions(-) create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/provider-group-plan.ts create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/provider-group-readonly-card.tsx create mode 100644 packages/ui/src/product/converter/provider-mode-status-bar.test.ts create mode 100644 packages/ui/src/product/converter/provider-mode-status-bar.tsx create mode 100644 packages/ui/src/product/converter/provider-settings-fields.test.ts create mode 100644 packages/ui/src/product/converter/provider-settings-fields.tsx diff --git a/packages/core/src/generator/index.test.ts b/packages/core/src/generator/index.test.ts index 19ddd86..48e7b9a 100644 --- a/packages/core/src/generator/index.test.ts +++ b/packages/core/src/generator/index.test.ts @@ -366,9 +366,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 +390,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 +620,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..c445857 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, @@ -32,6 +33,7 @@ import type { 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 +41,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[]; @@ -179,6 +185,7 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { const { nodes, proxyProviders, + proxyProviderAttachments = [], template = "standard", userConfig = {}, dialerProxyGroups = [], @@ -204,6 +211,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 +358,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 +418,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 +433,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) 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.ts b/packages/core/src/subscription/config-utils.ts index 09d4517..131b739 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -13,7 +13,7 @@ import { 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"; @@ -237,8 +237,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"); @@ -310,6 +313,7 @@ export function buildGenerateOptionsFromConfig( return { nodes: sanitizedNodes, ...(proxyProviders ? { proxyProviders } : {}), + ...(proxyProviderAttachments && proxyProviderAttachments.length > 0 ? { proxyProviderAttachments } : {}), template, userConfig, ...(dialerProxyGroups.length > 0 ? { dialerProxyGroups } : {}), 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..34c53c7 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -220,6 +220,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/input-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/input-section.test.ts index 8ed3683..65fe071 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/input-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/input-section.test.ts @@ -163,6 +163,12 @@ vi.mock("@subboost/ui/product/converter/subscription-import-error", () => ({ return null; }, })); +vi.mock("@subboost/ui/product/converter/provider-mode-status-bar", () => ({ + ProviderModeStatusBar: (props: any) => { + mocks.captures.statusBars.push(props); + return null; + }, +})); vi.mock("./input-source-editor-dialog", () => ({ InputSourceEditorDialog: (props: any) => { mocks.captures.editor = props; @@ -204,6 +210,7 @@ function renderSection(overrides: Record = {}, props = { isExpa mocks.captures.inputs = []; mocks.captures.rawButtons = []; mocks.captures.textareas = []; + mocks.captures.statusBars = []; try { const html = renderToStaticMarkup(React.createElement(InputSection, props)); return { html, setters: stateMock.setters }; @@ -222,7 +229,7 @@ describe("advanced mode InputSection", () => { beforeEach(() => { vi.clearAllMocks(); stateMock.runEffects = false; - mocks.captures = { buttons: [], inputs: [], textareas: [] }; + mocks.captures = { buttons: [], inputs: [], textareas: [], statusBars: [] }; mocks.userInfoDisplay = { traffic: "1 GB", expire: "2026-01-01" }; mocks.markSourceAsPendingImport.mockImplementation((source) => ({ ...source, pendingImport: true })); mocks.moveSubscriptionSource.mockImplementation((sources) => sources.slice().reverse()); @@ -250,7 +257,13 @@ describe("advanced mode InputSection", () => { previewName: "[{tag}] {name}:HK:节点名称", }) ); - expect(mocks.captures.inputs.some((props: any) => props.value === "https://example.com/sub")).toBe(true); + expect(mocks.captures.textareas.some((props: any) => props.value === "https://example.com/sub")).toBe(true); + expect(mocks.captures.statusBars).toEqual([ + expect.objectContaining({ + source: expect.objectContaining({ id: "s1", useProxyProviders: false }), + defaultProviderKey: "url_s1", + }), + ]); expect(mocks.captures.textareas.some((props: any) => props.value === "ss://node")).toBe(true); expect(mocks.captures.buttons.at(-1)).toEqual(expect.objectContaining({ variant: "outline", size: "sm" })); }); @@ -258,11 +271,23 @@ describe("advanced mode InputSection", () => { it("updates source content and metadata through captured fields", () => { renderSection({ 1: "s1" }); - const mainUrlInput = mocks.captures.inputs.find((props: any) => props.placeholder === "https://example.com/sub"); - mainUrlInput.onChange({ target: { value: "https://new.example/sub" } }); + const mainUrlEditor = mocks.captures.textareas.find((props: any) => props.placeholder === "https://example.com/sub"); + mainUrlEditor.onChange({ target: { value: "https://new.example/sub\n" } }); expect(mocks.store.setSources).toHaveBeenCalledWith(expect.any(Array)); expect(mocks.markSourceAsPendingImport).toHaveBeenCalledWith(expect.objectContaining({ content: "https://new.example/sub" })); + const enterEvent = { key: "Enter", preventDefault: vi.fn() }; + mainUrlEditor.onKeyDown(enterEvent); + expect(enterEvent.preventDefault).toHaveBeenCalled(); + + mocks.captures.statusBars[0].onCheckedChange(true); + expect(mocks.markSourceAsPendingImport).toHaveBeenCalledWith( + expect.objectContaining({ useProxyProviders: true, providerMode: "grouped" }) + ); + + mocks.captures.statusBars[0].onUpdateMeta("s1", { providerKey: "my_airport" }); + expect(mocks.store.setSources).toHaveBeenCalledWith(expect.any(Array)); + const textArea = mocks.captures.textareas.find((props: any) => props.value === "ss://node"); textArea.onChange({ target: { value: "trojan://node" } }); expect(mocks.markSourceAsPendingImport).toHaveBeenCalledWith(expect.objectContaining({ content: "trojan://node" })); 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 e769332..b4df4f8 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 @@ -4,7 +4,6 @@ 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 { cn } from "@subboost/ui/lib/utils"; import type { SourceType } from "@subboost/ui/store/config-store"; @@ -14,6 +13,7 @@ import { useSubscriptionSourcesController } from "@subboost/ui/product/converter import { sourceTypeInfo } from "../constants"; import { SectionHeader } from "../section-header"; import { SubscriptionImportErrorBadge } from "@subboost/ui/product/converter/subscription-import-error"; +import { ProviderModeStatusBar } from "@subboost/ui/product/converter/provider-mode-status-bar"; import { InputSourceEditorDialog } from "./input-source-editor-dialog"; export function InputSection({ @@ -206,12 +206,31 @@ export function InputSection({ {source.type === "url" ? ( - updateSource(source.id, e.target.value)} - placeholder={typeInfo.placeholder} - className="text-xs h-8" - /> +
+