/** * 把一路入站固定到某一条订阅线路。 * * 内核不允许直接这么写:入站的 `proxy:` 只认全局名字表里的名字,而订阅线路不在表上 * (见决策记录 0005)。绕法是给那条线路单独建一个只装它的策略组,入站指向这个组。 * * 这些组是 Patchbay 自己造的,用户不该看到也不该管。名字带前缀是唯一可靠的标记—— * 注释靠不住,被碰到的段落重写时会丢。 */ import type { ConfigObject } from '../vendor/config-editor/index.ts' /** 自动生成的筛选组的名字前缀。改它会让已有配置里的组失去标记,别改。 */ export const GENERATED_PREFIX = 'patchbay:' export function isGeneratedGroup(name: string): boolean { return name.startsWith(GENERATED_PREFIX) } /** 固定到某条线路时,生成的组叫什么。名字里带上线路名,翻文件时一眼看得懂。 */ export function generatedGroupName(line: string): string { return `${GENERATED_PREFIX}${line}` } /** 这个组固定的是哪条线路。不是生成的组返回 null。 */ export function pinnedLineOf(groupName: string): string | null { return isGeneratedGroup(groupName) ? groupName.slice(GENERATED_PREFIX.length) : null } /** * 出站在界面上怎么念。 * * 生成的组显示成它固定的那条线路——用户从没建过这个组,让他在界面上读到 * `patchbay:[vless]Gomami` 只会莫名其妙。 */ export function displayOutboundName(name: string): string { return pinnedLineOf(name) ?? name } /** * 把线路名转成只匹配它自己的筛选条件。 * * 两件事必须做对: * 转义正则元字符——线路名形如 `[vmess]DMIT.LAX.Pro.MTYQ`,`[`、`]`、`.` 不转义会匹配错 * 前后加锚点——内核的筛选是部分匹配,不加锚点时 `香港01` 会把 `香港011` 也匹配进来 * * 反引号在内核里是多个筛选条件的分隔符(adapter/outboundgroup/groupbase.go:72-76), * 线路名里含它就没法用一条正则表达,这种情况返回 null,调用方据此告诉用户做不到。 */ export function lineFilter(line: string): string | null { if (line.includes('`')) return null const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return `^${escaped}$` } /** * 造一个只装指定线路的策略组。 * * 用 `select` 而不是 `url-test`:组里只有一条线路,没什么可测的,而 `select` 的语义 * 就是「用这一个」。 */ export function makeFilterGroup(line: string, provider: string): ConfigObject | null { const filter = lineFilter(line) if (filter === null) return null return { name: generatedGroupName(line), type: 'select', use: [provider], filter, } } /** 配置里所有指向某个名字的位置。用来判断一个生成的组还有没有人在用。 */ function referencesTo(data: ConfigObject, name: string): number { let count = 0 for (const entry of Array.isArray(data.listeners) ? data.listeners : []) { if (typeof entry === 'object' && entry !== null) { if ((entry as Record).proxy === name) count += 1 } } for (const group of Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []) { if (typeof group !== 'object' || group === null) continue const members = (group as Record).proxies if (Array.isArray(members) && members.includes(name)) count += 1 } for (const rule of Array.isArray(data.rules) ? data.rules : []) { // 规则的目标是逗号分隔的最后一段之前那个位置,形如 MATCH,目标 或 DOMAIN,x,目标 if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1 } return count } /** * 删掉没人再引用的生成组。 * * 每次改完配置都跑一遍。不清理的话,改几次出站文件里就攒下一堆没用的组,而用户 * 并不知道它们是干嘛的——他从来没主动建过它们。 * * 只动带前缀的组,用户自己建的一律不碰,哪怕它确实没人引用。 */ export function pruneGeneratedGroups(data: ConfigObject): ConfigObject { const groups = Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : [] const kept = groups.filter((group) => { if (typeof group !== 'object' || group === null) return true const name = (group as Record).name if (typeof name !== 'string' || !isGeneratedGroup(name)) return true return referencesTo(data, name) > 0 }) if (kept.length === groups.length) return data const next = { ...data } if (kept.length === 0) delete next['proxy-groups'] else next['proxy-groups'] = kept return next }