Files
mihomo-patchbay/shared/providers.ts
T
im0o 275f536429 追加 Hosts、路径撞名拦截、连接页表头随行滚动、GEO 借内核通路
设置页内核参数加 hosts 行,铅笔进弹窗编辑(顶层 hosts: 键,
config.go:454,值单个或数组 parseHosts config.go:1131)。弹窗里随便改、
保存才一次性提交——写配置有几秒的校验重载,逐行失焦提交会把等待塞进
每次输入。没动过的行原样写回,数组不被重整形。

订阅和规则组新增时查缓存路径撞名:路径由名字净化而来,「订阅 A」和
「订阅/A」是两个名字、同一个文件,名字查重挡不住,会互相覆盖缓存。
纯函数在 shared 带测试,页面提交前拦。

连接页表头挪进滚动容器加 sticky:.scroller 声明 overflow-y 后按规范
连带拿到横向滚动上下文,表头留在外面时窄屏横向滚动两者脱节。表头
40px 的窗口偏移由 overscan 8 行吸收,不配 scrollMargin。

GEO 单文件下载先借内核的混合端口出去再退直连——内核自己更新 GEO 走
的是它的分流,Patchbay 直连 GitHub 不一定通。ECharts 拆独立 chunk,
业务代码改动不再让这 500KB 跟着换 hash。
2026-08-12 02:47:17 +08:00

205 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 订阅的读写。
*
* 配置文件里的 `proxy-providers` 段:一个名字对一份声明,内核定时从地址拉回一批线路。
* 这段里除了用户自己加的订阅,还有 Patchbay 为链式代理生成的副本(带 `patchbay:` 前缀)。
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
*
* 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的
* 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { isGenerated, providerCachePath } from './inbound-groups.ts'
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
export const DEFAULT_INTERVAL = 86400
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
export interface Subscription {
name: string
/** http 定时从地址拉取;file 读本机文件,内核靠文件监视跟进变化 */
type: 'http' | 'file'
url: string
/** 线路文件的路径(http 类型是缓存,file 类型是数据本体) */
path: string | null
/**
* 更新间隔,秒。null 表示配置里没写——内核只在 interval > 0 时才起定时
* 拉取循环(内核源码 component/resource/fetcher.go:226-228),所以
* 「没写」就是「不自动更新」,界面上它是个开关而不是必填数字。
*/
interval: number | null
/** 健康检查。没写或 enable 不是 true 都算未开启 */
healthCheck: { url: string | null; interval: number | null } | null
}
function isPlainObject(value: unknown): value is ConfigObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function providersOf(data: ConfigObject): ConfigObject {
return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {}
}
function groupsOf(data: ConfigObject): ConfigObject[] {
return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject)
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
/** 所有用户订阅,按配置里的顺序。不含生成的副本。 */
export function listSubscriptions(data: ConfigObject): Subscription[] {
const result: Subscription[] = []
for (const [name, entry] of Object.entries(providersOf(data))) {
if (isGenerated(name) || !isPlainObject(entry)) continue
const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null
result.push({
name,
type: entry.type === 'file' ? 'file' : 'http',
path: typeof entry.path === 'string' ? entry.path : null,
url: typeof entry.url === 'string' ? entry.url : '',
interval: typeof entry.interval === 'number' ? entry.interval : null,
healthCheck:
hc !== null && hc.enable === true
? {
url: typeof hc.url === 'string' ? hc.url : null,
interval: typeof hc.interval === 'number' ? hc.interval : null,
}
: null,
})
}
return result
}
/**
* `proxy-providers` 里的所有名字,含生成的副本。
* 新增订阅挡重名要用全部键:它是映射,和用户看不见的副本撞名也会被静默丢掉。
*/
export function providerNames(data: ConfigObject): string[] {
return Object.keys(providersOf(data))
}
/**
* 新增一个订阅。
*
* `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个,
* 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源
* (见 inbound-groups.ts 的 applySetup)。
*
* `interval` 为 null 时不写这个键,内核就不自动更新(见 Subscription.interval 的注)。
*/
export function addSubscription(
data: ConfigObject,
name: string,
url: string,
interval: number | null = DEFAULT_INTERVAL,
): ConfigObject {
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: {
type: 'http',
url,
path: providerCachePath(name),
...(interval !== null ? { interval } : {}),
},
}
return next
}
/**
* 新订阅的缓存路径是否和现有订阅的撞车。
*
* 名字查重挡不住这种:路径由名字净化而来,「订阅 A」和「订阅/A」是两个名字、
* 同一个 `providers/订阅-A.yaml`,两个订阅会互相覆盖对方的缓存文件。
* 返回撞上的那个订阅名,没撞返回 null。
*/
export function subscriptionPathConflict(
data: ConfigObject,
selfName: string,
path: string,
): string | null {
for (const [name, decl] of Object.entries(providersOf(data))) {
if (name === selfName) continue
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
if ((decl as ConfigObject).path === path) return name
}
return null
}
/**
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
*/
export function addFileSubscription(data: ConfigObject, name: string): ConfigObject {
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: { type: 'file', path: providerCachePath(name) },
}
return next
}
/** 改订阅的地址和更新间隔。其余字段(缓存路径、健康检查、筛选)原样保留。 */
export function updateSubscription(
data: ConfigObject,
name: string,
url: string,
interval: number | null,
): ConfigObject {
const entry = providersOf(data)[name]
if (!isPlainObject(entry)) return data
const updated: ConfigObject = { ...entry, url }
if (interval === null) delete updated.interval
else updated.interval = interval
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: updated,
}
return next
}
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
const providers = { ...providersOf(data) }
if (!(name in providers)) return data
delete providers[name]
const next = { ...data }
if (Object.keys(providers).length === 0) delete next['proxy-providers']
else next['proxy-providers'] = providers
return next
}
/**
* 哪些策略组在引用这个订阅。删除前必须先查:被引用的订阅删了,那些组的 `use:` 就空了,
* 而空组会让指向它的入站无路可走。
*
* 引用有两种,第二种容易漏:
* 直接 组的 `use:` 里有这个订阅。链式代理的入口组就是这么引原订阅的
* 间接 开了链式代理的入站,它的专属组 `use:` 的是订阅副本;副本靠和原订阅
* 共用缓存文件(`path` 相同)认来源,名字不可靠解析(见 inbound-groups.ts)
*/
export function subscriptionReferences(data: ConfigObject, name: string): string[] {
const target = providersOf(data)[name]
if (!isPlainObject(target)) return []
const copies = new Set<string>()
if (typeof target.path === 'string') {
for (const [copyName, entry] of Object.entries(providersOf(data))) {
if (isGenerated(copyName) && isPlainObject(entry) && entry.path === target.path) {
copies.add(copyName)
}
}
}
return groupsOf(data)
.filter((group) => {
const use = stringList(group.use)
return use.includes(name) || use.some((u) => copies.has(u))
})
.map((group) => String(group.name))
}