/** * 内核的运行时状态:有哪些出站、每个是什么协议、策略组当前选中谁、最近一次延迟。 * * 这些数据配置文件里没有,只有运行中的内核知道;而且界面上有三层组件要用它。 * 原本它住在根组件里靠 props 一层层往下传,中间有两层完全不碰它、纯粹为了转发而 * 声明了参数。做成模块级单例之后那两层就不用再知道它的存在了。 * * 用的是 web/prefs.ts 已有的模式(模块级 ref),不是新抽象。 */ import { ref } from 'vue' import { api } from './api' import type { ProxyInfo } from './api' import { isGenerated } from '@shared/inbound-groups.ts' /** * 出站名字 → 运行时信息。内核没连接时是空的。 * * 合并了内核全局名字表和用户订阅里的线路。Patchbay 自己生成的订阅副本**不并进来**: * 副本里的线路和原版同名(决策记录 0007 里那套复制订阅声明的做法),并进来只会互相 * 覆盖。要看副本里那一份,用下面的 `providers`。 */ export const runtime = ref>({}) /** 订阅名 → 它当前提供的线路。含 Patchbay 生成的副本,按订阅隔开所以不会撞。 */ export const providers = ref>({}) /** 用户自己的订阅带来的线路,按订阅返回的顺序。不含生成的副本。 */ export const providerLines = ref([]) /** 订阅线路 → 它所属的订阅名。测它的延迟必须从所属订阅进去。 */ export const lineProvider = ref>({}) export const kernel = ref<{ online: boolean; version?: string }>({ online: false }) /** 只探测内核在不在线,不拉运行时数据。给外壳的定时心跳用,各页等内核回来的 watch 靠它可达。 */ export async function refreshKernel(): Promise { kernel.value = await api.kernel().catch(() => ({ online: false })) } /** * 重新读一遍运行时状态。 * * 要读两处:`/proxies` 只包含内核全局名字表里的东西(内置出站、自有线路、策略组), * 订阅线路不在那张表上,它们的协议和延迟只能从 `/providers/proxies` 拿。 * * 任何一处取不到都不算错误——界面照常显示,只是少了延迟数字。内核没起来的时候正是 * 最需要这个界面的时候。 */ export async function reloadRuntime(): Promise { await refreshKernel() if (!kernel.value.online) { runtime.value = {} providers.value = {} providerLines.value = [] lineProvider.value = {} return } const merged: Record = {} try { Object.assign(merged, (await api.proxies()).proxies) } catch { // 拿不到就当没有 } const byProvider: Record = {} const lines: string[] = [] const owner: Record = {} try { const result = await api.providers() for (const [name, provider] of Object.entries(result.providers)) { // 内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅 if (provider.vehicleType === 'Compatible') continue byProvider[name] = provider.proxies if (isGenerated(name)) continue for (const line of provider.proxies) { merged[line.name] = line lines.push(line.name) owner[line.name] = name } } } catch { // 同上 } runtime.value = merged providers.value = byProvider providerLines.value = lines lineProvider.value = owner } /** * 界面渲染一个策略组需要知道的最少信息。 * * `uses` 来自配置文件,`now` 来自内核,两边缺一不可:光有名字查不到它从哪取成员, * 而开了链式代理之后同名线路有两份,不知道来源就分不清该显示哪一份。 */ export interface GroupView { name: string uses: string[] } /** 一条线路在界面上要显示的样子,外加测它的延迟该从哪个订阅进去。 */ export interface LineEntry { name: string /** 它来自哪个订阅。测延迟必须带上 */ provider: string info: ProxyInfo | undefined } /** * 这条线路是不是来自 Patchbay 生成的订阅副本。 * * 副本整份挂着 `dialer-proxy`,所以来自副本就一定经过入口。点名加进组里的成员 * (`DIRECT`、AUTO 和 FALLBACK 那两个组)不属于任何订阅,不在副本里。 */ export function isChainedLine(name: string): boolean { return Object.entries(providers.value).some( ([provider, lines]) => isGenerated(provider) && lines.some((line) => line.name === name), ) } /** * 这几个订阅里一共有哪些线路。 * * 只给配置表单里那个「匹配到几条」的预览用:那时用户选的来源还没保存,内核也还不知道 * 有这么个组,只能自己按来源算一遍。已经存在的组要用下面的 `membersOf`。 */ export function linesFrom(uses: string[]): string[] { const seen = new Set() for (const provider of uses) { for (const line of providers.value[provider] ?? []) seen.add(line.name) } return [...seen] } /** * 一个策略组的成员,按内核给的顺序。 * * 成员名单直接用内核算好的(`/proxies` 里那个 `all`),不自己从来源订阅里推——组上的 * `filter` 已经在内核那边生效过了,重推一遍就是把同一条规则实现两次,两边一旦不一致 * 界面显示的就是假的。 * * 每个成员还要标出它来自哪个订阅,因为测延迟得从所属订阅进去。顺着组自己的 `use:` 找而 * 不是全局按名字查:开了链式代理之后同一个线路名在原订阅和副本里各有一份,只有顺着来源 * 才知道该用哪一份。副本那份挂着 `dialer-proxy`,测出来是走完两跳的延迟,这正是这一路 * 真实的延迟。点名加进来的成员(DIRECT、别的策略组)不属于任何订阅,`provider` 是空串。 */ export function membersOf(view: GroupView): LineEntry[] { return (runtime.value[view.name]?.all ?? []).map((name) => { for (const provider of view.uses) { const line = (providers.value[provider] ?? []).find((item) => item.name === name) if (line) return { name, provider, info: line } } return { name, provider: '', info: runtime.value[name] } }) } /** * 切换策略组选中谁时先在本地改掉,不等内核回话。 * * 这一步不写配置文件、失败率极低,而等一个往返再更新会让点击感觉发木。 * 真失败了随后的刷新会把状态纠正回来。 */ export function optimisticSelect(group: string, member: string): void { const node = runtime.value[group] if (node) node.now = member }