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,
setProfileStr,
changeCurrentProfile,
updateProfileItem
updateProfileItem,
convertMrsRuleset
} from './profile'
export {
getOverrideConfig,

View File

@ -100,7 +100,7 @@ export async function addProfileItem(item: Partial<IProfileItem>): Promise<void>
export async function removeProfileItem(id: string): Promise<void> {
// 先清理自动更新定时器,防止已删除的订阅重新出现
await removeProfileUpdater(id)
const config = await getProfileConfig()
config.items = config.items?.filter((item) => item.id !== id)
let shouldRestart = false
@ -191,7 +191,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
timeout: subscriptionTimeout
})
}
// 检查状态码例如403
if (res.status < 200 || res.status >= 300) {
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 headers = res.headers
// 校验是否为对象结构 (拦截 HTML字符串、普通文本、乱码)
const parsed = parse(data)
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']) {
throw new Error('Subscription failed: Profile missing proxies or providers')
}
if (headers['content-disposition'] && newItem.name === 'Remote File') {
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,
getOverride,
setOverride,
updateOverrideItem
updateOverrideItem,
convertMrsRuleset
} from '../config'
import {
startSubStoreFrontendServer,
@ -215,6 +216,7 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id))
ipcMain.handle('getFileStr', (_e, path) => ipcErrorWrapper(getFileStr)(path))
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('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item))
ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))

View File

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

View File

@ -1,7 +1,7 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
import React, { useEffect, useState } from 'react'
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 { useTranslation } from 'react-i18next'
type Language = 'yaml' | 'javascript' | 'css' | 'json' | 'text'
@ -13,34 +13,60 @@ interface Props {
title: string
privderType: string
format?: string
behavior?: string
}
const Viewer: React.FC<Props> = (props) => {
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 [isLoading, setIsLoading] = useState(true)
let language: Language = !format || format === 'YamlRule' ? 'yaml' : 'text'
const getContent = async (): Promise<void> => {
let fileContent: React.SetStateAction<string>
if (type === 'Inline') {
fileContent = await getFileStr('config.yaml')
language = 'yaml'
} else {
fileContent = await getFileStr(path)
}
setIsLoading(true)
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
}))
let fileContent: React.SetStateAction<string>
if (format === 'MrsRule') {
language = 'text'
let ruleBehavior: string = behavior || 'domain'
if (!behavior) {
try {
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)]">
<ModalHeader className="flex pb-0 app-drag">{title}</ModalHeader>
<ModalBody className="h-full">
<BaseEditor
language={language}
value={currData}
readOnly={type != 'File'}
onChange={(value) => setCurrData(value)}
/>
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-foreground-500">{t('common.loading')}</div>
</div>
) : (
<BaseEditor
language={language}
value={currData}
readOnly={type != 'File' || format === 'MrsRule'}
onChange={(value) => setCurrData(value)}
/>
)}
</ModalBody>
<ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={onClose}>
{t('common.close')}
</Button>
{type == 'File' && (
{type == 'File' && format !== 'MrsRule' && (
<Button
size="sm"
color="primary"

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@
"common.auto": "自动",
"common.default": "默认",
"common.close": "关闭",
"common.loading": "加载中...",
"common.pinWindow": "窗口置顶",
"common.next": "下一步",
"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))
}
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> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getOverrideConfig', force))
}