feat: add mrs ruleset preview suppor

This commit is contained in:
Memory 2025-12-09 23:33:38 +08:00 committed by GitHub
parent 19ae63b253
commit 94f52cf636
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 146 additions and 61 deletions

View File

@ -14,7 +14,8 @@ export {
getProfileStr, getProfileStr,
setProfileStr, setProfileStr,
changeCurrentProfile, changeCurrentProfile,
updateProfileItem updateProfileItem,
convertMrsRuleset
} from './profile' } from './profile'
export { export {
getOverrideConfig, getOverrideConfig,

View File

@ -100,7 +100,7 @@ export async function addProfileItem(item: Partial<IProfileItem>): Promise<void>
export async function removeProfileItem(id: string): Promise<void> { export async function removeProfileItem(id: string): Promise<void> {
// 先清理自动更新定时器,防止已删除的订阅重新出现 // 先清理自动更新定时器,防止已删除的订阅重新出现
await removeProfileUpdater(id) await removeProfileUpdater(id)
const config = await getProfileConfig() const config = await getProfileConfig()
config.items = config.items?.filter((item) => item.id !== id) config.items = config.items?.filter((item) => item.id !== id)
let shouldRestart = false let shouldRestart = false
@ -191,7 +191,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
timeout: subscriptionTimeout timeout: subscriptionTimeout
}) })
} }
// 检查状态码例如403 // 检查状态码例如403
if (res.status < 200 || res.status >= 300) { if (res.status < 200 || res.status >= 300) {
throw new Error(`Subscription failed: Request status code ${res.status}`) throw new Error(`Subscription failed: Request status code ${res.status}`)
@ -199,7 +199,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
const data = res.data const data = res.data
const headers = res.headers const headers = res.headers
// 校验是否为对象结构 (拦截 HTML字符串、普通文本、乱码) // 校验是否为对象结构 (拦截 HTML字符串、普通文本、乱码)
const parsed = parse(data) const parsed = parse(data)
if (typeof parsed !== 'object' || parsed === null) { if (typeof parsed !== 'object' || parsed === null) {
@ -210,7 +210,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
if (!profile.proxies && !profile['proxy-providers']) { if (!profile.proxies && !profile['proxy-providers']) {
throw new Error('Subscription failed: Profile missing proxies or providers') throw new Error('Subscription failed: Profile missing proxies or providers')
} }
if (headers['content-disposition'] && newItem.name === 'Remote File') { if (headers['content-disposition'] && newItem.name === 'Remote File') {
newItem.name = parseFilename(headers['content-disposition']) newItem.name = parseFilename(headers['content-disposition'])
} }
@ -329,3 +329,43 @@ export async function setFileStr(path: string, content: string): Promise<void> {
) )
} }
} }
export async function convertMrsRuleset(filePath: string, behavior: string): Promise<string> {
const { exec } = await import('child_process')
const { promisify } = await import('util')
const execAsync = promisify(exec)
const { mihomoCorePath } = await import('../utils/dirs')
const { getAppConfig } = await import('./app')
const { tmpdir } = await import('os')
const { randomBytes } = await import('crypto')
const { unlink } = await import('fs/promises')
const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core)
const { diffWorkDir = false } = await getAppConfig()
const { current } = await getProfileConfig()
let fullPath: string
if (isAbsolutePath(filePath)) {
fullPath = filePath
} else {
fullPath = join(diffWorkDir ? mihomoProfileWorkDir(current) : mihomoWorkDir(), filePath)
}
const tempFileName = `mrs-convert-${randomBytes(8).toString('hex')}.txt`
const tempFilePath = join(tmpdir(), tempFileName)
try {
// 使用 mihomo convert-ruleset 命令转换 MRS 文件为 text 格式
// 命令格式: mihomo convert-ruleset <behavior> <format> <source>
await execAsync(`"${corePath}" convert-ruleset ${behavior} mrs "${fullPath}" "${tempFilePath}"`)
const content = await readFile(tempFilePath, 'utf-8')
await unlink(tempFilePath)
return content
} catch (error) {
try {
await unlink(tempFilePath)
} catch {}
throw error
}
}

View File

@ -48,7 +48,8 @@ import {
removeOverrideItem, removeOverrideItem,
getOverride, getOverride,
setOverride, setOverride,
updateOverrideItem updateOverrideItem,
convertMrsRuleset
} from '../config' } from '../config'
import { import {
startSubStoreFrontendServer, startSubStoreFrontendServer,
@ -215,6 +216,7 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id)) ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id))
ipcMain.handle('getFileStr', (_e, path) => ipcErrorWrapper(getFileStr)(path)) ipcMain.handle('getFileStr', (_e, path) => ipcErrorWrapper(getFileStr)(path))
ipcMain.handle('setFileStr', (_e, path, str) => ipcErrorWrapper(setFileStr)(path, str)) ipcMain.handle('setFileStr', (_e, path, str) => ipcErrorWrapper(setFileStr)(path, str))
ipcMain.handle('convertMrsRuleset', (_e, path, behavior) => ipcErrorWrapper(convertMrsRuleset)(path, behavior))
ipcMain.handle('setProfileStr', (_e, id, str) => ipcErrorWrapper(setProfileStr)(id, str)) ipcMain.handle('setProfileStr', (_e, id, str) => ipcErrorWrapper(setProfileStr)(id, str))
ipcMain.handle('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item)) ipcMain.handle('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item))
ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id)) ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))

View File

@ -25,7 +25,8 @@ const RuleProvider: React.FC = () => {
type: '', type: '',
title: '', title: '',
format: '', format: '',
privderType: '' privderType: '',
behavior: ''
}) })
useEffect(() => { useEffect(() => {
if (showDetails.title) { if (showDetails.title) {
@ -37,11 +38,12 @@ const RuleProvider: React.FC = () => {
setShowDetails((prev) => ({ setShowDetails((prev) => ({
...prev, ...prev,
show: true, show: true,
path: provider?.path || `rules/${getHash(provider?.url)}` path: provider?.path || `rules/${getHash(provider?.url)}`,
behavior: provider?.behavior || 'domain'
})) }))
} }
} catch { } catch {
setShowDetails((prev) => ({ ...prev, path: '' })) setShowDetails((prev) => ({ ...prev, path: '', behavior: '' }))
} }
} }
fetchProviderPath(showDetails.title) fetchProviderPath(showDetails.title)
@ -94,7 +96,8 @@ const RuleProvider: React.FC = () => {
title={showDetails.title} title={showDetails.title}
format={showDetails.format} format={showDetails.format}
privderType={showDetails.privderType} privderType={showDetails.privderType}
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', format: '', privderType: '' })} behavior={showDetails.behavior}
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', format: '', privderType: '', behavior: '' })}
/> />
)} )}
<SettingItem title={t('resources.ruleProviders.title')} divider> <SettingItem title={t('resources.ruleProviders.title')} divider>
@ -122,30 +125,29 @@ const RuleProvider: React.FC = () => {
> >
<div className="flex h-[32px] leading-[32px] text-foreground-500"> <div className="flex h-[32px] leading-[32px] text-foreground-500">
<div>{dayjs(provider.updatedAt).fromNow()}</div> <div>{dayjs(provider.updatedAt).fromNow()}</div>
{provider.format !== 'MrsRule' && ( <Button
<Button isIconOnly
isIconOnly title={provider.vehicleType == 'File' ? t('common.editor.edit') : t('common.viewer.view')}
title={provider.vehicleType == 'File' ? t('common.editor.edit') : t('common.viewer.view')} className="ml-2"
className="ml-2" size="sm"
size="sm" onPress={() => {
onPress={() => { setShowDetails({
setShowDetails({ show: false,
show: false, privderType: 'rule-providers',
privderType: 'rule-providers', path: provider.name,
path: provider.name, type: provider.vehicleType,
type: provider.vehicleType, title: provider.name,
title: provider.name, format: provider.format,
format: provider.format behavior: provider.behavior || 'domain'
}) })
}} }}
> >
{provider.vehicleType == 'File' ? ( {provider.vehicleType == 'File' ? (
<MdEditDocument className={`text-lg`} /> <MdEditDocument className={`text-lg`} />
) : ( ) : (
<CgLoadbarDoc className={`text-lg`} /> <CgLoadbarDoc className={`text-lg`} />
)} )}
</Button> </Button>
)}
<Button <Button
isIconOnly isIconOnly
title={t('common.updater.update')} title={t('common.updater.update')}

View File

@ -1,7 +1,7 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react' import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { BaseEditor } from '../base/base-editor' import { BaseEditor } from '../base/base-editor'
import { getFileStr, setFileStr } from '@renderer/utils/ipc' import { getFileStr, setFileStr, convertMrsRuleset, getRuntimeConfig } from '@renderer/utils/ipc'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text' type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text'
@ -13,34 +13,60 @@ interface Props {
title: string title: string
privderType: string privderType: string
format?: string format?: string
behavior?: string
} }
const Viewer: React.FC<Props> = (props) => { const Viewer: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { type, path, title, format, privderType, onClose } = props const { type, path, title, format, privderType, behavior, onClose } = props
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const [isLoading, setIsLoading] = useState(true)
let language: Language = !format || format === 'YamlRule' ? 'yaml' : 'text' let language: Language = !format || format === 'YamlRule' ? 'yaml' : 'text'
const getContent = async (): Promise<void> => { const getContent = async (): Promise<void> => {
let fileContent: React.SetStateAction<string> setIsLoading(true)
if (type === 'Inline') {
fileContent = await getFileStr('config.yaml')
language = 'yaml'
} else {
fileContent = await getFileStr(path)
}
try { try {
const parsedYaml = yaml.load(fileContent) let fileContent: React.SetStateAction<string>
if (privderType === 'proxy-providers') {
setCurrData(yaml.dump({ if (format === 'MrsRule') {
'proxies': parsedYaml[privderType][title].payload language = 'text'
})) let ruleBehavior: string = behavior || 'domain'
} else { if (!behavior) {
setCurrData(yaml.dump({ try {
'rules': parsedYaml[privderType][title].payload const runtimeConfig = await getRuntimeConfig()
})) const provider = runtimeConfig['rule-providers']?.[title]
ruleBehavior = provider?.behavior || 'domain'
} catch {
ruleBehavior = 'domain'
}
}
fileContent = await convertMrsRuleset(path, ruleBehavior)
setCurrData(fileContent)
return
} }
} catch (error) {
setCurrData(fileContent) if (type === 'Inline') {
fileContent = await getFileStr('config.yaml')
language = 'yaml'
} else {
fileContent = await getFileStr(path)
}
try {
const parsedYaml = yaml.load(fileContent)
if (privderType === 'proxy-providers') {
setCurrData(yaml.dump({
'proxies': parsedYaml[privderType][title].payload
}))
} else {
setCurrData(yaml.dump({
'rules': parsedYaml[privderType][title].payload
}))
}
} catch (error) {
setCurrData(fileContent)
}
} finally {
setIsLoading(false)
} }
} }
@ -61,18 +87,24 @@ const Viewer: React.FC<Props> = (props) => {
<ModalContent className="h-full w-[calc(100%-100px)]"> <ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">{title}</ModalHeader> <ModalHeader className="flex pb-0 app-drag">{title}</ModalHeader>
<ModalBody className="h-full"> <ModalBody className="h-full">
<BaseEditor {isLoading ? (
language={language} <div className="flex items-center justify-center h-full">
value={currData} <div className="text-foreground-500">{t('common.loading')}</div>
readOnly={type != 'File'} </div>
onChange={(value) => setCurrData(value)} ) : (
/> <BaseEditor
language={language}
value={currData}
readOnly={type != 'File' || format === 'MrsRule'}
onChange={(value) => setCurrData(value)}
/>
)}
</ModalBody> </ModalBody>
<ModalFooter className="pt-0"> <ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onClose}> <Button size="sm" variant="light" onPress={onClose}>
{t('common.close')} {t('common.close')}
</Button> </Button>
{type == 'File' && ( {type == 'File' && format !== 'MrsRule' && (
<Button <Button
size="sm" size="sm"
color="primary" color="primary"

View File

@ -16,6 +16,7 @@
"common.auto": "Auto", "common.auto": "Auto",
"common.default": "Default", "common.default": "Default",
"common.close": "Close", "common.close": "Close",
"common.loading": "Loading...",
"common.pinWindow": "Pin Window", "common.pinWindow": "Pin Window",
"common.next": "Next", "common.next": "Next",
"common.prev": "Previous", "common.prev": "Previous",

View File

@ -16,6 +16,7 @@
"common.auto": "خودکار", "common.auto": "خودکار",
"common.default": "پیش‌فرض", "common.default": "پیش‌فرض",
"common.close": "بستن", "common.close": "بستن",
"common.loading": "در حال بارگذاری...",
"common.pinWindow": "پین کردن پنجره", "common.pinWindow": "پین کردن پنجره",
"common.next": "بعدی", "common.next": "بعدی",
"common.prev": "قبلی", "common.prev": "قبلی",

View File

@ -16,6 +16,7 @@
"common.auto": "Авто", "common.auto": "Авто",
"common.default": "По умолчанию", "common.default": "По умолчанию",
"common.close": "Закрыть", "common.close": "Закрыть",
"common.loading": "Загрузка...",
"common.pinWindow": "Закрепить окно", "common.pinWindow": "Закрепить окно",
"common.next": "Далее", "common.next": "Далее",
"common.prev": "Назад", "common.prev": "Назад",

View File

@ -16,6 +16,7 @@
"common.auto": "自动", "common.auto": "自动",
"common.default": "默认", "common.default": "默认",
"common.close": "关闭", "common.close": "关闭",
"common.loading": "加载中...",
"common.pinWindow": "窗口置顶", "common.pinWindow": "窗口置顶",
"common.next": "下一步", "common.next": "下一步",
"common.prev": "上一步", "common.prev": "上一步",

View File

@ -208,6 +208,10 @@ export async function setProfileStr(id: string, str: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('setProfileStr', id, str)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('setProfileStr', id, str))
} }
export async function convertMrsRuleset(path: string, behavior: string): Promise<string> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('convertMrsRuleset', path, behavior))
}
export async function getOverrideConfig(force = false): Promise<IOverrideConfig> { export async function getOverrideConfig(force = false): Promise<IOverrideConfig> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getOverrideConfig', force)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getOverrideConfig', force))
} }