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

@ -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,7 +125,6 @@ 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')}
@ -135,7 +137,8 @@ const RuleProvider: React.FC = () => {
path: provider.name,
type: provider.vehicleType,
title: provider.name,
format: provider.format
format: provider.format,
behavior: provider.behavior || 'domain'
})
}}
>
@ -145,7 +148,6 @@ const RuleProvider: React.FC = () => {
<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,15 +13,38 @@ 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> => {
setIsLoading(true)
try {
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
}
if (type === 'Inline') {
fileContent = await getFileStr('config.yaml')
language = 'yaml'
@ -42,6 +65,9 @@ const Viewer: React.FC<Props> = (props) => {
} catch (error) {
setCurrData(fileContent)
}
} finally {
setIsLoading(false)
}
}
useEffect(() => {
@ -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">
{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'}
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))
}