设置页内核参数加 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。
108 lines
4.1 KiB
TypeScript
108 lines
4.1 KiB
TypeScript
/**
|
||
* GEO 数据库文件的本地状态:多大、多久没更新了。
|
||
*
|
||
* 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端
|
||
* 在同一台机器上,直接 stat 就是。
|
||
*/
|
||
import { readdir, rename, stat, writeFile } from 'node:fs/promises'
|
||
import { join } from 'node:path'
|
||
import { DEFAULT_GEOX_URLS, GEO_FILES, looksLikeGeodata, looksLikeMmdb } from '../shared/geo.ts'
|
||
import type { GeoFileStatus } from '../shared/geo.ts'
|
||
import type { Env } from './env.ts'
|
||
|
||
/** 配置里写了就用配置的,否则退回内核默认。 */
|
||
function urlFor(geoxUrl: Record<string, unknown>, configKey: string): string {
|
||
const configured = geoxUrl[configKey]
|
||
return typeof configured === 'string' && configured !== ''
|
||
? configured
|
||
: (DEFAULT_GEOX_URLS[configKey] ?? '')
|
||
}
|
||
|
||
export async function geoStatus(
|
||
env: Env,
|
||
geoxUrl: Record<string, unknown>,
|
||
): Promise<GeoFileStatus[]> {
|
||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||
return Promise.all(
|
||
GEO_FILES.map(async ({ key, configKey, file }) => {
|
||
// 内核找 GeoIP.dat/GeoSite.dat 时大小写不敏感(constant/path.go:193-224),这里照做
|
||
const actual = entries.find((name) => name.toLowerCase() === file.toLowerCase())
|
||
let size: number | null = null
|
||
let updatedAt: string | null = null
|
||
if (actual) {
|
||
const info = await stat(join(env.mihomoHome, actual)).catch(() => null)
|
||
if (info) {
|
||
size = info.size
|
||
updatedAt = info.mtime.toISOString()
|
||
}
|
||
}
|
||
return { key, configKey, url: urlFor(geoxUrl, configKey), size, updatedAt }
|
||
}),
|
||
)
|
||
}
|
||
|
||
async function download(url: string, proxy: string | null): Promise<Uint8Array> {
|
||
const res = await fetch(url, {
|
||
signal: AbortSignal.timeout(180_000),
|
||
redirect: 'follow',
|
||
...(proxy ? { proxy } : {}),
|
||
})
|
||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
|
||
return new Uint8Array(await res.arrayBuffer())
|
||
}
|
||
|
||
/**
|
||
* 下载单个 GEO 文件写进内核工作目录。
|
||
*
|
||
* 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型),
|
||
* 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查
|
||
* (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。
|
||
*
|
||
* `proxy` 给的话先经它出去(内核自己更新 GEO 时走的是它自己的分流,直连
|
||
* GitHub 不一定通,Patchbay 借内核的混合端口能拿到同样的通路),失败再直连。
|
||
*/
|
||
export async function syncGeoFile(
|
||
env: Env,
|
||
configKey: string,
|
||
geoxUrl: Record<string, unknown>,
|
||
proxy: string | null,
|
||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const spec = GEO_FILES.find((g) => g.configKey === configKey)
|
||
if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` }
|
||
const url = urlFor(geoxUrl, configKey)
|
||
|
||
let bytes: Uint8Array
|
||
try {
|
||
bytes = await download(url, proxy)
|
||
} catch (viaProxy) {
|
||
if (!proxy) return { ok: false, message: `下载失败:${String(viaProxy)}` }
|
||
try {
|
||
bytes = await download(url, null)
|
||
} catch (direct) {
|
||
return {
|
||
ok: false,
|
||
message: `经内核代理(${String(viaProxy)})和直连(${String(direct)})都没能下载。`,
|
||
}
|
||
}
|
||
}
|
||
|
||
const shapeOk =
|
||
configKey === 'mmdb' || configKey === 'asn' ? looksLikeMmdb(bytes) : looksLikeGeodata(bytes)
|
||
if (!shapeOk) {
|
||
return { ok: false, message: `下载到的内容不像 ${spec.key} 文件,已丢弃,本地文件没有动。` }
|
||
}
|
||
|
||
// 沿用目录里现有的大小写写法,内核就是这样找文件的
|
||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||
const actual = entries.find((name) => name.toLowerCase() === spec.file.toLowerCase()) ?? spec.file
|
||
const finalPath = join(env.mihomoHome, actual)
|
||
const tempPath = `${finalPath}.patchbay-tmp`
|
||
try {
|
||
await writeFile(tempPath, bytes)
|
||
await rename(tempPath, finalPath)
|
||
} catch (error) {
|
||
return { ok: false, message: `写入失败:${String(error)}` }
|
||
}
|
||
return { ok: true }
|
||
}
|