Refactor rule (#1326)

* refactor: enhance prepend/append deletion in rule editor

* fix: Reject empty payload for non-MATCH rules

* More validator

* feat(backup): add rules directory support to local and WebDAV backups
This commit is contained in:
Memory 2025-11-01 18:19:07 +08:00 committed by GitHub
parent 0e58f6f314
commit 4af5cae356
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 596 additions and 156 deletions

View File

@ -12,10 +12,11 @@ import {
mihomoProfileWorkDir, mihomoProfileWorkDir,
mihomoWorkConfigPath, mihomoWorkConfigPath,
mihomoWorkDir, mihomoWorkDir,
overridePath overridePath,
rulePath
} from '../utils/dirs' } from '../utils/dirs'
import { parse, stringify } from '../utils/yaml' import { parse, stringify } from '../utils/yaml'
import { copyFile, mkdir, writeFile } from 'fs/promises' import { copyFile, mkdir, writeFile, readFile } from 'fs/promises'
import { deepMerge } from '../utils/merge' import { deepMerge } from '../utils/merge'
import vm from 'vm' import vm from 'vm'
import { existsSync, writeFileSync } from 'fs' import { existsSync, writeFileSync } from 'fs'
@ -24,11 +25,41 @@ import path from 'path'
let runtimeConfigStr: string let runtimeConfigStr: string
let runtimeConfig: IMihomoConfig let runtimeConfig: IMihomoConfig
// 辅助函数:处理带偏移量的规则
function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) {
const normalRules: string[] = []
let rules = [...currentRules]
ruleStrings.forEach(ruleStr => {
const parts = ruleStr.split(',')
const firstPartIsNumber = !isNaN(Number(parts[0])) && parts[0].trim() !== '' && parts.length >= 3
if (firstPartIsNumber) {
const offset = parseInt(parts[0])
const rule = parts.slice(1).join(',')
if (isAppend) {
// 后置规则的插入位置计算
const insertPosition = Math.max(0, rules.length - Math.min(offset, rules.length))
rules.splice(insertPosition, 0, rule)
} else {
// 前置规则的插入位置计算
const insertPosition = Math.min(offset, rules.length)
rules.splice(insertPosition, 0, rule)
}
} else {
normalRules.push(ruleStr)
}
})
return { normalRules, insertRules: rules }
}
export async function generateProfile(): Promise<void> { export async function generateProfile(): Promise<void> {
// 读取最新的配置 // 读取最新的配置
const { current } = await getProfileConfig(true) const { current } = await getProfileConfig(true)
const { diffWorkDir = false, controlDns = true, controlSniff = true, useNameserverPolicy } = await getAppConfig() const { diffWorkDir = false, controlDns = true, controlSniff = true, useNameserverPolicy } = await getAppConfig()
const currentProfile = await overrideProfile(current, await getProfile(current)) let currentProfile = await overrideProfile(current, await getProfile(current))
let controledMihomoConfig = await getControledMihomoConfig() let controledMihomoConfig = await getControledMihomoConfig()
// 根据开关状态过滤控制配置 // 根据开关状态过滤控制配置
@ -44,6 +75,49 @@ export async function generateProfile(): Promise<void> {
delete controledMihomoConfig?.dns?.['nameserver-policy'] delete controledMihomoConfig?.dns?.['nameserver-policy']
} }
// 应用规则文件
try {
const ruleFilePath = rulePath(current || 'default')
if (existsSync(ruleFilePath)) {
const ruleFileContent = await readFile(ruleFilePath, 'utf-8')
const ruleData = parse(ruleFileContent) as { prepend?: string[], append?: string[], delete?: string[] } | null
if (ruleData && typeof ruleData === 'object') {
// 确保 rules 数组存在
if (!currentProfile.rules) {
currentProfile.rules = [] as unknown as []
}
let rules = [...currentProfile.rules] as unknown as string[]
// 处理前置规则
if (ruleData.prepend?.length) {
const { normalRules: prependRules, insertRules } = processRulesWithOffset(ruleData.prepend, rules)
rules = [...prependRules, ...insertRules]
}
// 处理后置规则
if (ruleData.append?.length) {
const { normalRules: appendRules, insertRules } = processRulesWithOffset(ruleData.append, rules, true)
rules = [...insertRules, ...appendRules]
}
// 处理删除规则
if (ruleData.delete?.length) {
const deleteSet = new Set(ruleData.delete)
rules = rules.filter(rule => {
const ruleStr = Array.isArray(rule) ? rule.join(',') : rule
return !deleteSet.has(ruleStr)
})
}
currentProfile.rules = rules as unknown as []
}
}
} catch (error) {
console.error('读取或应用规则文件时出错:', error)
}
const profile = deepMerge(currentProfile, controledMihomoConfig) const profile = deepMerge(currentProfile, controledMihomoConfig)
// 确保可以拿到基础日志信息 // 确保可以拿到基础日志信息
// 使用 debug 可以调试内核相关问题 `debug/pprof` // 使用 debug 可以调试内核相关问题 `debug/pprof`

View File

@ -9,6 +9,7 @@ import {
overrideDir, overrideDir,
profileConfigPath, profileConfigPath,
profilesDir, profilesDir,
rulesDir,
subStoreDir, subStoreDir,
themesDir themesDir
} from '../utils/dirs' } from '../utils/dirs'
@ -38,6 +39,7 @@ export async function webdavBackup(): Promise<boolean> {
zip.addLocalFolder(themesDir(), 'themes') zip.addLocalFolder(themesDir(), 'themes')
zip.addLocalFolder(profilesDir(), 'profiles') zip.addLocalFolder(profilesDir(), 'profiles')
zip.addLocalFolder(overrideDir(), 'override') zip.addLocalFolder(overrideDir(), 'override')
zip.addLocalFolder(rulesDir(), 'rules')
zip.addLocalFolder(subStoreDir(), 'substore') zip.addLocalFolder(subStoreDir(), 'substore')
const date = new Date() const date = new Date()
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip` const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
@ -229,6 +231,9 @@ export async function exportLocalBackup(): Promise<boolean> {
if (existsSync(subStoreDir())) { if (existsSync(subStoreDir())) {
zip.addLocalFolder(subStoreDir(), 'substore') zip.addLocalFolder(subStoreDir(), 'substore')
} }
if (existsSync(rulesDir())) {
zip.addLocalFolder(rulesDir(), 'rules')
}
const date = new Date() const date = new Date()
const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip` const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`

View File

@ -158,3 +158,11 @@ export function coreLogPath(): string {
const name = `core-${year}-${month}-${day}` const name = `core-${year}-${month}-${day}`
return path.join(logDir(), `${name}.log`) return path.join(logDir(), `${name}.log`)
} }
export function rulesDir(): string {
return path.join(dataDir(), 'rules')
}
export function rulePath(id: string): string {
return path.join(rulesDir(), `${id}.yaml`)
}

View File

@ -11,6 +11,7 @@ import {
profilePath, profilePath,
profilesDir, profilesDir,
resourcesFilesDir, resourcesFilesDir,
rulesDir,
subStoreDir, subStoreDir,
themesDir themesDir
} from './dirs' } from './dirs'
@ -106,6 +107,7 @@ async function initDirs(): Promise<void> {
themesDir(), themesDir(),
profilesDir(), profilesDir(),
overrideDir(), overrideDir(),
rulesDir(),
mihomoWorkDir(), mihomoWorkDir(),
logDir(), logDir(),
mihomoTestDir(), mihomoTestDir(),

View File

@ -99,7 +99,7 @@ import {
writeTheme writeTheme
} from '../resolve/theme' } from '../resolve/theme'
import { subStoreCollections, subStoreSubs } from '../core/subStoreApi' import { subStoreCollections, subStoreSubs } from '../core/subStoreApi'
import { logDir } from './dirs' import { logDir, rulePath } from './dirs'
import path from 'path' import path from 'path'
import v8 from 'v8' import v8 from 'v8'
import { getGistUrl } from '../resolve/gistApi' import { getGistUrl } from '../resolve/gistApi'
@ -148,6 +148,18 @@ export async function clearMihomoVersionCache(): Promise<void> {
clearVersionCache('MetaCubeX', 'mihomo') clearVersionCache('MetaCubeX', 'mihomo')
} }
export async function getRuleStr(id: string): Promise<string> {
const { readFile } = await import('fs/promises')
const filePath = rulePath(id)
return await readFile(filePath, 'utf-8')
}
export async function setRuleStr(id: string, str: string): Promise<void> {
const { writeFile } = await import('fs/promises')
const filePath = rulePath(id)
await writeFile(filePath, str, 'utf-8')
}
export function registerIpcMainHandlers(): void { export function registerIpcMainHandlers(): void {
ipcMain.handle('mihomoVersion', ipcErrorWrapper(mihomoVersion)) ipcMain.handle('mihomoVersion', ipcErrorWrapper(mihomoVersion))
ipcMain.handle('mihomoCloseConnection', (_e, id) => ipcErrorWrapper(mihomoCloseConnection)(id)) ipcMain.handle('mihomoCloseConnection', (_e, id) => ipcErrorWrapper(mihomoCloseConnection)(id))
@ -348,4 +360,8 @@ export function registerIpcMainHandlers(): void {
// 注册清除版本缓存的IPC处理程序 // 注册清除版本缓存的IPC处理程序
ipcMain.handle('clearMihomoVersionCache', () => ipcErrorWrapper(clearMihomoVersionCache)()) ipcMain.handle('clearMihomoVersionCache', () => ipcErrorWrapper(clearMihomoVersionCache)())
// 规则相关IPC处理程序
ipcMain.handle('getRuleStr', (_e, id) => ipcErrorWrapper(getRuleStr)(id))
ipcMain.handle('setRuleStr', (_e, id, str) => ipcErrorWrapper(setRuleStr)(id, str))
} }

View File

@ -15,7 +15,7 @@ import {
Divider Divider
} from '@heroui/react' } from '@heroui/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getProfileStr, setProfileStr } from '@renderer/utils/ipc' import { getProfileStr, setRuleStr, getRuleStr } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io' import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io'
@ -32,8 +32,37 @@ interface RuleItem {
payload: string payload: string
proxy: string proxy: string
additionalParams?: string[] additionalParams?: string[]
offset?: number
} }
const domainValidator = (value: string): boolean => {
if (value.length > 253 || value.length < 2) return false;
return new RegExp(
"^(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\\.)+[a-zA-Z]{2,}$"
).test(value) ||
["localhost", "local", "localdomain"].includes(value.toLowerCase());
};
const domainSuffixValidator = (value: string): boolean => {
return new RegExp(
"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.[a-zA-Z]{2,}$"
).test(value);
};
const domainKeywordValidator = (value: string): boolean => {
return value.length > 0 && !value.includes(",") && !value.includes(" ");
};
const domainRegexValidator = (value: string): boolean => {
try {
new RegExp(value);
return true;
} catch {
return false;
}
};
const portValidator = (value: string): boolean => { const portValidator = (value: string): boolean => {
return new RegExp( return new RegExp(
"^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$", "^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$",
@ -64,18 +93,22 @@ const ruleDefinitionsMap = new Map<string, {
["DOMAIN", { ["DOMAIN", {
name: "DOMAIN", name: "DOMAIN",
example: "example.com", example: "example.com",
validator: (value) => domainValidator(value)
}], }],
["DOMAIN-SUFFIX", { ["DOMAIN-SUFFIX", {
name: "DOMAIN-SUFFIX", name: "DOMAIN-SUFFIX",
example: "example.com", example: "example.com",
validator: (value) => domainSuffixValidator(value)
}], }],
["DOMAIN-KEYWORD", { ["DOMAIN-KEYWORD", {
name: "DOMAIN-KEYWORD", name: "DOMAIN-KEYWORD",
example: "example", example: "example",
validator: (value) => domainKeywordValidator(value)
}], }],
["DOMAIN-REGEX", { ["DOMAIN-REGEX", {
name: "DOMAIN-REGEX", name: "DOMAIN-REGEX",
example: "example.*", example: "example.*",
validator: (value) => domainRegexValidator(value)
}], }],
["GEOSITE", { ["GEOSITE", {
name: "GEOSITE", name: "GEOSITE",
@ -241,7 +274,7 @@ const getRuleExample = (ruleType: string): string => {
}; };
const isAddRuleDisabled = (newRule: RuleItem, validateRulePayload: (ruleType: string, payload: string) => boolean): boolean => { const isAddRuleDisabled = (newRule: RuleItem, validateRulePayload: (ruleType: string, payload: string) => boolean): boolean => {
return (!(newRule.payload.trim() || newRule.type === 'MATCH')) || !newRule.type || return (!(newRule.payload.trim() || newRule.type === 'MATCH')) || !newRule.type || !newRule.proxy ||
(newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload)); (newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload));
}; };
@ -249,11 +282,13 @@ const EditRulesModal: React.FC<Props> = (props) => {
const { id, onClose } = props const { id, onClose } = props
const [rules, setRules] = useState<RuleItem[]>([]) const [rules, setRules] = useState<RuleItem[]>([])
const [filteredRules, setFilteredRules] = useState<RuleItem[]>([]) const [filteredRules, setFilteredRules] = useState<RuleItem[]>([])
const [profileContent, setProfileContent] = useState('') const [, setProfileContent] = useState('')
const [newRule, setNewRule] = useState<RuleItem>({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] }) const [newRule, setNewRule] = useState<RuleItem>({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] })
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [proxyGroups, setProxyGroups] = useState<string[]>([]) const [proxyGroups, setProxyGroups] = useState<string[]>([])
const [deletedRules, setDeletedRules] = useState<Set<number>>(new Set()) // Store indices of deleted rules const [deletedRules, setDeletedRules] = useState<Set<number>>(new Set())
const [prependRules, setPrependRules] = useState<Set<number>>(new Set())
const [appendRules, setAppendRules] = useState<Set<number>>(new Set())
const { t } = useTranslation() const { t } = useTranslation()
const getContent = async (): Promise<void> => { const getContent = async (): Promise<void> => {
@ -262,12 +297,15 @@ const EditRulesModal: React.FC<Props> = (props) => {
try { try {
const parsed = yaml.load(content) as any const parsed = yaml.load(content) as any
let initialRules: RuleItem[] = [];
if (parsed && parsed.rules && Array.isArray(parsed.rules)) { if (parsed && parsed.rules && Array.isArray(parsed.rules)) {
const parsedRules = parsed.rules.map((rule: string) => { initialRules = parsed.rules.map((rule: string) => {
const parts = rule.split(',') const parts = rule.split(',')
if (parts[0] === 'MATCH') { if (parts[0] === 'MATCH') {
return { return {
type: 'MATCH', type: 'MATCH',
payload: '',
proxy: parts[1] proxy: parts[1]
} }
} else { } else {
@ -279,21 +317,18 @@ const EditRulesModal: React.FC<Props> = (props) => {
additionalParams additionalParams
} }
} }
}) });
setRules(parsedRules)
setFilteredRules(parsedRules)
} }
// 从配置文件中提取代理组 // 提取代理组
if (parsed) { if (parsed) {
const groups: string[] = [] const groups: string[] = []
// 添加代理组名称 // 添加代理组和代理名称
if (Array.isArray(parsed['proxy-groups'])) { if (Array.isArray(parsed['proxy-groups'])) {
groups.push(...parsed['proxy-groups'].map((group: any) => group?.name).filter(Boolean)) groups.push(...parsed['proxy-groups'].map((group: any) => group?.name).filter(Boolean))
} }
// 添加代理名称
if (Array.isArray(parsed['proxies'])) { if (Array.isArray(parsed['proxies'])) {
groups.push(...parsed['proxies'].map((proxy: any) => proxy?.name).filter(Boolean)) groups.push(...parsed['proxies'].map((proxy: any) => proxy?.name).filter(Boolean))
} }
@ -304,8 +339,116 @@ const EditRulesModal: React.FC<Props> = (props) => {
// 去重 // 去重
setProxyGroups([...new Set(groups)]) setProxyGroups([...new Set(groups)])
} }
// 读取规则文件
try {
const ruleContent = await getRuleStr(id);
const ruleData = yaml.load(ruleContent) as { prepend?: string[], append?: string[], delete?: string[] };
if (ruleData) {
let allRules = [...initialRules];
const newPrependRules = new Set<number>();
const newAppendRules = new Set<number>();
const newDeletedRules = new Set<number>();
// 处理前置规则
if (ruleData.prepend && Array.isArray(ruleData.prepend)) {
const prependRules: RuleItem[] = [];
ruleData.prepend.forEach((ruleStr: string) => {
prependRules.push(parseRuleString(ruleStr));
});
// 插入前置规则
const { updatedRules, ruleIndices } = processRulesWithPositions(
prependRules,
allRules,
(rule, currentRules) => {
if (rule.offset !== undefined && rule.offset < currentRules.length) {
return rule.offset;
}
return 0;
}
);
allRules = updatedRules;
ruleIndices.forEach(index => newPrependRules.add(index));
}
// 处理后置规则
if (ruleData.append && Array.isArray(ruleData.append)) {
const appendRules: RuleItem[] = [];
ruleData.append.forEach((ruleStr: string) => {
appendRules.push(parseRuleString(ruleStr));
});
// 插入后置规则
const { updatedRules, ruleIndices } = processAppendRulesWithPositions(
appendRules,
allRules,
(rule, currentRules) => {
if (rule.offset !== undefined) {
return Math.max(0, currentRules.length - rule.offset);
}
return currentRules.length;
}
);
allRules = updatedRules;
// 标记后置规则
ruleIndices.forEach(index => newAppendRules.add(index));
}
// 处理删除规则
if (ruleData.delete && Array.isArray(ruleData.delete)) {
const deleteRules = ruleData.delete.map((ruleStr: string) => {
return parseRuleString(ruleStr);
});
// 匹配并标记删除规则
deleteRules.forEach(deleteRule => {
const matchedIndex = allRules.findIndex(rule =>
rule.type === deleteRule.type &&
rule.payload === deleteRule.payload &&
rule.proxy === deleteRule.proxy &&
JSON.stringify(rule.additionalParams || []) === JSON.stringify(deleteRule.additionalParams || [])
);
if (matchedIndex !== -1) {
newDeletedRules.add(matchedIndex);
}
});
}
// 更新状态
setPrependRules(newPrependRules);
setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules);
// 设置规则列表
setRules(allRules);
setFilteredRules(allRules);
} else {
// 使用初始规则
setRules(initialRules);
setFilteredRules(initialRules);
// 清空规则标记
setPrependRules(new Set());
setAppendRules(new Set());
setDeletedRules(new Set());
}
} catch (ruleError) {
// 规则文件读取失败
console.debug('规则文件读取失败:', ruleError);
setRules(initialRules);
setFilteredRules(initialRules);
// 清空规则标记
setPrependRules(new Set());
setAppendRules(new Set());
setDeletedRules(new Set());
}
} catch (e) { } catch (e) {
console.error('解析配置文件内容失败', e) console.error('Failed to parse profile content', e)
} }
} }
@ -329,91 +472,42 @@ const EditRulesModal: React.FC<Props> = (props) => {
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
try { try {
// 过滤掉已标记为删除的规则 // 保存规则到文件
const rulesToSave = rules.filter((_, index) => !deletedRules.has(index)); const prependRuleStrings = Array.from(prependRules)
.filter(index => !deletedRules.has(index) && index < rules.length)
.map(index => convertRuleToString(rules[index]));
// 将规则转换回字符串格式 const appendRuleStrings = Array.from(appendRules)
const ruleStrings = rulesToSave.map(rule => { .filter(index => !deletedRules.has(index) && index < rules.length)
const parts = [rule.type] .map(index => convertRuleToString(rules[index]));
if (rule.payload) parts.push(rule.payload)
if (rule.proxy) parts.push(rule.proxy)
if (rule.additionalParams && rule.additionalParams.length > 0) {
parts.push(...rule.additionalParams)
}
return parts.join(',')
})
// 直接在原始内容中替换规则部分,保持原有格式 // 保存删除的规则
let updatedContent = profileContent const deletedRuleStrings = Array.from(deletedRules)
.filter(index => index < rules.length && !prependRules.has(index) && !appendRules.has(index))
// 将内容按行分割,便于处理 .map(index => {
const lines = profileContent.split('\n') const rule = rules[index];
const newLines: string[] = [] const parts = [rule.type];
let inRulesSection = false if (rule.payload) parts.push(rule.payload);
let rulesSectionFound = false if (rule.proxy) parts.push(rule.proxy);
let rulesIndent = '' if (rule.additionalParams && rule.additionalParams.length > 0) {
parts.push(...rule.additionalParams);
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmedLine = line.trim()
// 检查是否是 rules: 开始行
if (trimmedLine === 'rules:') {
rulesSectionFound = true
inRulesSection = true
rulesIndent = line.match(/^\s*/)?.[0] || ''
newLines.push(line) // 保留 rules: 行
// 添加新的规则行
for (const rule of ruleStrings) {
newLines.push(`${rulesIndent} - ${rule}`)
} }
return parts.join(',');
// 跳过原有的 rules 内容 });
while (i + 1 < lines.length) {
const nextLine = lines[i + 1]
const nextTrimmed = nextLine.trim()
const nextIndent = nextLine.match(/^\s*/)?.[0] || ''
// 如果下一行不是空行且缩进大于 rules 缩进,说明还是 rules 的内容
if (nextTrimmed !== '' && nextIndent.length > rulesIndent.length) {
i++ // 跳过这一行
} else {
break
}
}
continue
}
// 如果在 rules 部分中,跳过处理
if (inRulesSection && trimmedLine.startsWith('-')) {
// 检查是否还有 rules 行
const currentIndent = line.match(/^\s*/)?.[0] || ''
if (currentIndent.length > rulesIndent.length) {
continue // 跳过原有规则行
} else {
inRulesSection = false // rules 部分结束
}
}
newLines.push(line)
}
// 如果没有找到 rules 部分,添加到文件末尾 // 创建规则数据对象
if (!rulesSectionFound) { const ruleData = {
newLines.push('') // 空行 prepend: prependRuleStrings,
newLines.push('rules:') append: appendRuleStrings,
for (const rule of ruleStrings) { delete: deletedRuleStrings
newLines.push(` - ${rule}`) };
}
}
updatedContent = newLines.join('\n') // 保存到 YAML 文件
const ruleYaml = yaml.dump(ruleData);
await setProfileStr(id, updatedContent) await setRuleStr(id, ruleYaml);
onClose() onClose();
} catch (e) { } catch (e) {
alert(t('profiles.editRules.saveError') + ': ' + (e instanceof Error ? e.message : String(e))) alert(t('profiles.editRules.saveError') + ': ' + (e instanceof Error ? e.message : String(e)));
} }
} }
@ -464,73 +558,129 @@ const EditRulesModal: React.FC<Props> = (props) => {
let updatedRules: RuleItem[]; let updatedRules: RuleItem[];
if (position === 'prepend') { if (position === 'prepend') {
updatedRules = [newRuleItem, ...rules]; // 前置规则插入
const insertPosition = newRuleItem.offset !== undefined ?
Math.min(newRuleItem.offset, rules.length) : 0;
updatedRules = [...rules];
updatedRules.splice(insertPosition, 0, newRuleItem);
// 更新规则索引
const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, true);
// 更新状态
setPrependRules(newPrependRules);
setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules);
} else { } else {
updatedRules = [...rules, newRuleItem]; // 后置规则插入
const insertPosition = newRuleItem.offset !== undefined ?
Math.max(0, rules.length - newRuleItem.offset) :
rules.length;
updatedRules = [...rules];
updatedRules.splice(insertPosition, 0, newRuleItem);
// 更新规则索引
const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, false, true);
// 更新状态
setPrependRules(newPrependRules);
setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules);
} }
setRules(updatedRules) // 更新规则列表
setFilteredRules(updatedRules) setRules(updatedRules);
setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] }) setFilteredRules(updatedRules);
setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] });
} }
} }
const handleRemoveRule = (index: number): void => { const handleRemoveRule = (index: number): void => {
setDeletedRules(prev => { setDeletedRules(prev => {
const newSet = new Set(prev) const newSet = new Set(prev);
if (newSet.has(index)) { if (newSet.has(index)) {
newSet.delete(index) // 如果已经标记为删除,则取消标记 newSet.delete(index); // 如果已经标记为删除,则取消标记
} else { } else {
newSet.add(index) // 标记为删除 newSet.add(index); // 标记为删除
} }
return newSet return newSet;
}) });
} }
const handleMoveRuleUp = (index: number): void => { const handleMoveRuleUp = (index: number): void => {
if (index <= 0) return if (index <= 0) return;
const updatedRules = [...rules] const updatedRules = [...rules];
const temp = updatedRules[index] const temp = updatedRules[index];
updatedRules[index] = updatedRules[index - 1] updatedRules[index] = updatedRules[index - 1];
updatedRules[index - 1] = temp updatedRules[index - 1] = temp;
setRules(updatedRules)
setFilteredRules(updatedRules) // 更新前置规则偏移量
setDeletedRules(prev => { if (prependRules.has(index)) {
const newSet = new Set<number>() updatedRules[index - 1] = {
prev.forEach(idx => { ...updatedRules[index - 1],
if (idx === index) { offset: Math.max(0, (updatedRules[index - 1].offset || 0) - 1)
newSet.add(index - 1) };
} else if (idx === index - 1) { }
newSet.add(index)
} else { // 更新后置规则偏移量
newSet.add(idx) if (appendRules.has(index)) {
} updatedRules[index - 1] = {
}) ...updatedRules[index - 1],
return newSet offset: (updatedRules[index - 1].offset || 0) + 1
}) };
}
// 首先更新规则数组
setRules(updatedRules);
setFilteredRules(updatedRules);
// 更新删除规则索引
setDeletedRules(prev => updateRuleIndices(prev, index, index - 1));
// 更新前置规则索引
setPrependRules(prev => updateRuleIndices(prev, index, index - 1));
// 更新后置规则索引
setAppendRules(prev => updateRuleIndices(prev, index, index - 1));
} }
const handleMoveRuleDown = (index: number): void => { const handleMoveRuleDown = (index: number): void => {
if (index >= rules.length - 1) return if (index >= rules.length - 1) return;
const updatedRules = [...rules] const updatedRules = [...rules];
const temp = updatedRules[index] const temp = updatedRules[index];
updatedRules[index] = updatedRules[index + 1] updatedRules[index] = updatedRules[index + 1];
updatedRules[index + 1] = temp updatedRules[index + 1] = temp;
setRules(updatedRules)
setFilteredRules(updatedRules) // 更新前置规则偏移量
setDeletedRules(prev => { if (prependRules.has(index)) {
const newSet = new Set<number>() updatedRules[index + 1] = {
prev.forEach(idx => { ...updatedRules[index + 1],
if (idx === index) { offset: (updatedRules[index + 1].offset || 0) + 1
newSet.add(index + 1) };
} else if (idx === index + 1) { }
newSet.add(index)
} else { // 更新后置规则偏移量
newSet.add(idx) if (appendRules.has(index)) {
} updatedRules[index + 1] = {
}) ...updatedRules[index + 1],
return newSet offset: Math.max(0, (updatedRules[index + 1].offset || 0) - 1)
}) };
}
// 首先更新规则数组
setRules(updatedRules);
setFilteredRules(updatedRules);
// 更新删除规则索引
setDeletedRules(prev => updateRuleIndices(prev, index, index + 1));
// 更新前置规则索引
setPrependRules(prev => updateRuleIndices(prev, index, index + 1));
// 更新后置规则索引
setAppendRules(prev => updateRuleIndices(prev, index, index + 1));
} }
const validateRulePayload = (ruleType: string, payload: string): boolean => { const validateRulePayload = (ruleType: string, payload: string): boolean => {
@ -550,6 +700,174 @@ const EditRulesModal: React.FC<Props> = (props) => {
const rule = ruleDefinitionsMap.get(ruleType); const rule = ruleDefinitionsMap.get(ruleType);
return rule?.validator; return rule?.validator;
}; };
// 解析规则字符串
const parseRuleString = (ruleStr: string): RuleItem => {
const parts = ruleStr.split(',');
const firstPartIsNumber = !isNaN(Number(parts[0])) && parts[0].trim() !== '' && parts.length >= 3;
let offset = 0;
let ruleParts = parts;
if (firstPartIsNumber) {
offset = parseInt(parts[0]);
ruleParts = parts.slice(1);
}
if (ruleParts[0] === 'MATCH') {
return {
type: 'MATCH',
payload: '',
proxy: ruleParts[1],
offset: offset > 0 ? offset : undefined
};
} else {
const additionalParams = ruleParts.slice(3).filter(param => param.trim() !== '') || [];
return {
type: ruleParts[0],
payload: ruleParts[1],
proxy: ruleParts[2],
additionalParams,
offset: offset > 0 ? offset : undefined
};
}
};
// 规则转字符串
const convertRuleToString = (rule: RuleItem): string => {
const parts = [rule.type];
if (rule.payload) parts.push(rule.payload);
if (rule.proxy) parts.push(rule.proxy);
if (rule.additionalParams && rule.additionalParams.length > 0) {
parts.push(...rule.additionalParams);
}
// 添加偏移量
if (rule.offset !== undefined && rule.offset > 0) {
parts.unshift(rule.offset.toString());
}
return parts.join(',');
};
// 处理前置规则位置
const processRulesWithPositions = (rules: RuleItem[], allRules: RuleItem[], positionCalculator: (rule: RuleItem, currentRules: RuleItem[]) => number): { updatedRules: RuleItem[], ruleIndices: Set<number> } => {
const updatedRules = [...allRules];
const ruleIndices = new Set<number>();
// 按顺序处理规则
rules.forEach((rule) => {
const targetPosition = positionCalculator(rule, updatedRules);
const actualPosition = Math.min(targetPosition, updatedRules.length);
updatedRules.splice(actualPosition, 0, rule);
// 更新索引
const newRuleIndices = new Set<number>();
ruleIndices.forEach(idx => {
if (idx >= actualPosition) {
newRuleIndices.add(idx + 1);
} else {
newRuleIndices.add(idx);
}
});
// 添加当前规则索引
newRuleIndices.add(actualPosition);
// 更新索引集合
ruleIndices.clear();
newRuleIndices.forEach(idx => ruleIndices.add(idx));
});
return { updatedRules, ruleIndices };
};
// 处理后置规则位置
const processAppendRulesWithPositions = (rules: RuleItem[], allRules: RuleItem[], positionCalculator: (rule: RuleItem, currentRules: RuleItem[]) => number): { updatedRules: RuleItem[], ruleIndices: Set<number> } => {
const updatedRules = [...allRules];
const ruleIndices = new Set<number>();
// 按顺序处理规则
rules.forEach((rule) => {
const targetPosition = positionCalculator(rule, updatedRules);
const actualPosition = Math.min(targetPosition, updatedRules.length);
updatedRules.splice(actualPosition, 0, rule);
// 更新索引
const newRuleIndices = new Set<number>();
ruleIndices.forEach(idx => {
if (idx >= actualPosition) {
newRuleIndices.add(idx + 1);
} else {
newRuleIndices.add(idx);
}
});
// 添加当前规则索引
newRuleIndices.add(actualPosition);
// 更新索引集合
ruleIndices.clear();
newRuleIndices.forEach(idx => ruleIndices.add(idx));
});
return { updatedRules, ruleIndices };
};
// 更新规则索引
const updateRuleIndices = (prev: Set<number>, index1: number, index2: number): Set<number> => {
const newSet = new Set<number>();
prev.forEach(idx => {
if (idx === index1) {
newSet.add(index2);
} else if (idx === index2) {
newSet.add(index1);
} else {
newSet.add(idx);
}
});
return newSet;
};
// 计算插入位置的索引
const getUpdatedIndexForInsertion = (index: number, insertPosition: number): number => {
if (index >= insertPosition) {
return index + 1;
} else {
return index;
}
};
// 插入规则后更新所有索引
const updateAllRuleIndicesAfterInsertion = (prependRules: Set<number>, appendRules: Set<number>, deletedRules: Set<number>, insertPosition: number, isNewPrependRule: boolean = false, isNewAppendRule: boolean = false): { newPrependRules: Set<number>, newAppendRules: Set<number>, newDeletedRules: Set<number> } => {
const newPrependRules = new Set<number>();
const newAppendRules = new Set<number>();
const newDeletedRules = new Set<number>();
// 更新前置规则索引
prependRules.forEach(idx => {
newPrependRules.add(getUpdatedIndexForInsertion(idx, insertPosition));
});
// 更新后置规则索引
appendRules.forEach(idx => {
newAppendRules.add(getUpdatedIndexForInsertion(idx, insertPosition));
});
// 更新删除规则索引
deletedRules.forEach(idx => {
newDeletedRules.add(getUpdatedIndexForInsertion(idx, insertPosition));
});
// 标记新规则
if (isNewPrependRule) {
newPrependRules.add(insertPosition);
}
if (isNewAppendRule) {
newAppendRules.add(insertPosition);
}
return { newPrependRules, newAppendRules, newDeletedRules };
};
return ( return (
<Modal <Modal
@ -689,10 +1007,19 @@ const EditRulesModal: React.FC<Props> = (props) => {
: t('profiles.editRules.noRules')} : t('profiles.editRules.noRules')}
</div> </div>
) : ( ) : (
filteredRules.map((rule) => { filteredRules.map((rule, index) => {
const originalIndex = rules.indexOf(rule); const originalIndex = rules.indexOf(rule);
let bgColorClass = 'bg-content2';
let textStyleClass = '';
if (deletedRules.has(originalIndex)) {
bgColorClass = 'bg-danger-50 opacity-70';
textStyleClass = 'line-through text-foreground-500';
} else if (prependRules.has(originalIndex) || appendRules.has(originalIndex)) {
bgColorClass = 'bg-success-50';
}
return ( return (
<div key={originalIndex} className={`flex items-center gap-2 p-2 rounded-lg ${deletedRules.has(originalIndex) ? 'bg-danger-50 opacity-70' : 'bg-content2'}`}> <div key={`${originalIndex}-${index}`} className={`flex items-center gap-2 p-2 rounded-lg ${bgColorClass}`}>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Chip size="sm" variant="flat"> <Chip size="sm" variant="flat">
@ -709,40 +1036,40 @@ const EditRulesModal: React.FC<Props> = (props) => {
</div> </div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate"> <div className={`font-medium truncate ${textStyleClass}`}>
{rule.type === 'MATCH' ? rule.proxy : rule.payload} {rule.type === 'MATCH' ? rule.proxy : rule.payload}
</div> </div>
{rule.proxy && rule.type !== 'MATCH' && ( {rule.proxy && rule.type !== 'MATCH' && (
<div className="text-sm text-foreground-500 truncate">{rule.proxy}</div> <div className={`text-sm text-foreground-500 truncate ${textStyleClass}`}>{rule.proxy}</div>
)} )}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
size="sm" size="sm"
variant="light" variant="light"
onPress={() => handleMoveRuleUp(originalIndex)} onPress={() => originalIndex !== -1 && handleMoveRuleUp(originalIndex)}
isIconOnly isIconOnly
isDisabled={originalIndex === 0 || deletedRules.has(originalIndex)} isDisabled={originalIndex === -1 || originalIndex === 0 || deletedRules.has(originalIndex)}
> >
<IoMdArrowUp className="text-lg" /> <IoMdArrowUp className="text-lg" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="light" variant="light"
onPress={() => handleMoveRuleDown(originalIndex)} onPress={() => originalIndex !== -1 && handleMoveRuleDown(originalIndex)}
isIconOnly isIconOnly
isDisabled={originalIndex === rules.length - 1 || deletedRules.has(originalIndex)} isDisabled={originalIndex === -1 || originalIndex === rules.length - 1 || deletedRules.has(originalIndex)}
> >
<IoMdArrowDown className="text-lg" /> <IoMdArrowDown className="text-lg" />
</Button> </Button>
<Button <Button
size="sm" size="sm"
color={deletedRules.has(originalIndex) ? "success" : "danger"} color={originalIndex !== -1 && deletedRules.has(originalIndex) ? "success" : "danger"}
variant="light" variant="light"
onPress={() => handleRemoveRule(originalIndex)} onPress={() => originalIndex !== -1 && handleRemoveRule(originalIndex)}
isIconOnly isIconOnly
> >
{deletedRules.has(originalIndex) ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />} {originalIndex !== -1 && deletedRules.has(originalIndex) ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -549,6 +549,14 @@ export async function copyEnv(type: 'bash' | 'cmd' | 'powershell'): Promise<void
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('copyEnv', type)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('copyEnv', type))
} }
export async function getRuleStr(id: string): Promise<string> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getRuleStr', id))
}
export async function setRuleStr(id: string, str: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('setRuleStr', id, str))
}
async function alert<T>(msg: T): Promise<void> { async function alert<T>(msg: T): Promise<void> {
const msgStr = typeof msg === 'string' ? msg : JSON.stringify(msg) const msgStr = typeof msg === 'string' ? msg : JSON.stringify(msg)
return await window.electron.ipcRenderer.invoke('alert', msgStr) return await window.electron.ipcRenderer.invoke('alert', msgStr)