Merge branch 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core

This commit is contained in:
xmk23333 2025-12-14 21:46:49 +08:00
commit d811f76bb4
78 changed files with 2442 additions and 1859 deletions

View File

@ -18,6 +18,7 @@
</div>
### 本项目认证稳定机场推荐:“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”
##### [狗狗加速 —— 技术流机场 Doggygo VPN](https://party.dginv.click/#/register?code=ARdo0mXx)
- 高性能海外机场,稳定首选,海外团队,无跑路风险

View File

@ -1,6 +1,7 @@
## 1.8.9
# 1.8.9
## 新功能 (Feat)
### 新功能 (Feat)
- 升级内核版本
- 可视化规则编辑
- 连接页面支持暂停
@ -10,18 +11,21 @@
- 在菜单中显示当前代理
- 支持修改 数据收集文件大小
### 修复 (Fix)
## 修复 (Fix)
- 更安全的内核提权检查
- Tun 模式无法在 linux 中正常工作
- 配置导致的程序崩溃问题
# 其他 (chore)
### 其他 (chore)
- 添加缺失的多国语言翻译
- 更新依赖
## 1.8.8
# 1.8.8
## 新功能 (Feat)
### 新功能 (Feat)
- 升级内核版本
- 增加内核版本选择
- 记住日志页面的筛选关键字
@ -30,23 +34,27 @@
- 支持修改点击任务栏的窗口触发行为
- 内核设置下增加 WebUI 快捷打开方式
### 修复 (Fix)
## 修复 (Fix)
- MacOS 首次启动时的 ENOENT: no such file or directory(config.yaml)
- 自动更新获取老的文件名称
- 修复 mihomo.yaml 文件缺失的问题
- Smart 配置文件验证出错的问题
- 开发环境的 electron 问题
### 优化 (Optimize)
## 优化 (Optimize)
- 加快以管理员模式重启速度
- 优化仅用户滚动滚轮时触发自动滚动
- 改进俄语翻译
- 使用重载替换不必要的重启
# 其他 (chore)
- 更新依赖
## 样式调整 (Sytle)
### 样式调整 (Sytle)
- 改进 logo 设计
- 卡片尺寸
- 设置页可展开项增加指示图标
- 改进 logo 设计
- 卡片尺寸
- 设置页可展开项增加指示图标
### 其他 (chore)
- 更新依赖

View File

@ -52,7 +52,7 @@ if (copiedCount > 0) {
console.log('📋 现在 dist 目录包含以下文件:')
const finalFiles = readdirSync(distDir).sort()
finalFiles.forEach(file => {
finalFiles.forEach((file) => {
if (file.includes('clash-party') || file.includes('mihomo-party')) {
const isLegacy = file.includes('mihomo-party')
console.log(` ${isLegacy ? '🔄' : '📦'} ${file}`)

View File

@ -1,6 +1,12 @@
import axios from 'axios'
import { readFileSync } from 'fs'
import { getProcessedVersion, isDevBuild, getDownloadUrl, generateDownloadLinksMarkdown, getGitCommitHash } from './version-utils.mjs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown,
getGitCommitHash
} from './version-utils.mjs'
const chat_id = '@MihomoPartyChannel'
const pkg = readFileSync('package.json', 'utf-8')
@ -14,41 +20,35 @@ const isDevRelease = releaseType === 'dev' || isDevBuild()
function convertMarkdownToTelegramHTML(content) {
return content
.split("\n")
.split('\n')
.map((line) => {
if (line.trim().length === 0) {
return "";
} else if (line.startsWith("## ")) {
return `<b>${line.replace("## ", "")}</b>`;
} else if (line.startsWith("### ")) {
return `<b>${line.replace("### ", "")}</b>`;
} else if (line.startsWith("#### ")) {
return `<b>${line.replace("#### ", "")}</b>`;
return ''
} else if (line.startsWith('## ')) {
return `<b>${line.replace('## ', '')}</b>`
} else if (line.startsWith('### ')) {
return `<b>${line.replace('### ', '')}</b>`
} else if (line.startsWith('#### ')) {
return `<b>${line.replace('#### ', '')}</b>`
} else {
let processedLine = line.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(match, text, url) => {
const encodedUrl = encodeURI(url);
return `<a href="${encodedUrl}">${text}</a>`;
},
);
processedLine = processedLine.replace(
/\*\*([^*]+)\*\*/g,
"<b>$1</b>",
);
return processedLine;
let processedLine = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
const encodedUrl = encodeURI(url)
return `<a href="${encodedUrl}">${text}</a>`
})
processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
return processedLine
}
})
.join("\n");
.join('\n')
}
let content = '';
let content = ''
if (isDevRelease) {
// 版本号中提取commit hash
const shortCommitSha = getGitCommitHash(true)
const commitSha = getGitCommitHash(false)
content = `<b>🚧 <a href="https://github.com/mihomo-party-org/clash-party/releases/tag/dev">Clash Party Dev Build</a> 开发版本发布</b>\n\n`
content += `<b>基于版本:</b> ${version}\n`
content += `<b>提交哈希:</b> <a href="https://github.com/mihomo-party-org/clash-party/commit/${commitSha}">${shortCommitSha}</a>\n\n`
@ -78,4 +78,4 @@ await axios.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/
parse_mode: 'HTML'
})
console.log(`${isDevRelease ? '开发版本' : '正式版本'}Telegram 通知发送成功`)
console.log(`${isDevRelease ? '开发版本' : '正式版本'}Telegram 通知发送成功`)

View File

@ -10,7 +10,7 @@ function updatePackageVersion() {
// 获取处理后的版本号
const newVersion = getProcessedVersion()
console.log(`当前版本: ${packageData.version}`)
console.log(`${isDevBuild() ? 'Dev构建' : '正式构建'} - 新版本: ${newVersion}`)
@ -20,7 +20,6 @@ function updatePackageVersion() {
writeFileSync(packagePath, JSON.stringify(packageData, null, 2) + '\n')
console.log(`✅ package.json版本号已更新为: ${newVersion}`)
} catch (error) {
console.error('❌ 更新package.json版本号失败:', error.message)
process.exit(1)
@ -29,4 +28,4 @@ function updatePackageVersion() {
updatePackageVersion()
export { updatePackageVersion }
export { updatePackageVersion }

View File

@ -1,6 +1,11 @@
import yaml from 'yaml'
import { readFileSync, writeFileSync } from 'fs'
import { getProcessedVersion, isDevBuild, getDownloadUrl, generateDownloadLinksMarkdown } from './version-utils.mjs'
import {
getProcessedVersion,
isDevBuild,
getDownloadUrl,
generateDownloadLinksMarkdown
} from './version-utils.mjs'
const pkg = readFileSync('package.json', 'utf-8')
let changelog = readFileSync('changelog.md', 'utf-8')
@ -19,7 +24,8 @@ const latest = {
// 使用统一的下载链接生成函数
changelog += generateDownloadLinksMarkdown(downloadUrl, version)
changelog += '\n\n### 机场推荐:\n- 高性能海外机场,稳定首选:[https://狗狗加速.com](https://party.dginv.click/#/register?code=ARdo0mXx)'
changelog +=
'\n\n### 机场推荐:\n- 高性能海外机场,稳定首选:[https://狗狗加速.com](https://party.dginv.click/#/register?code=ARdo0mXx)'
writeFileSync('latest.yml', yaml.stringify(latest))
writeFileSync('changelog.md', changelog)

View File

@ -1,7 +1,7 @@
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
// 获取Git commit hash
// 获取Git commit hash
export function getGitCommitHash(short = true) {
try {
const command = short ? 'git rev-parse --short HEAD' : 'git rev-parse HEAD'
@ -12,7 +12,7 @@ export function getGitCommitHash(short = true) {
}
}
// 获取当前月份日期
// 获取当前月份日期
export function getCurrentMonthDate() {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
@ -20,7 +20,7 @@ export function getCurrentMonthDate() {
return `${month}${day}`
}
// 从package.json读取基础版本号
// 从package.json读取基础版本号
export function getBaseVersion() {
try {
const pkg = readFileSync('package.json', 'utf-8')
@ -33,7 +33,7 @@ export function getBaseVersion() {
}
}
// 生成dev版本号
// 生成dev版本号
export function getDevVersion() {
const baseVersion = getBaseVersion()
const monthDate = getCurrentMonthDate()
@ -42,14 +42,16 @@ export function getDevVersion() {
return `${baseVersion}-d${monthDate}.${commitHash}`
}
// 检查当前环境是否为dev构建
// 检查当前环境是否为dev构建
export function isDevBuild() {
return process.env.NODE_ENV === 'development' ||
process.argv.includes('--dev') ||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'
return (
process.env.NODE_ENV === 'development' ||
process.argv.includes('--dev') ||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'
)
}
// 获取处理后的版本号
// 获取处理后的版本号
export function getProcessedVersion() {
if (isDevBuild()) {
return getDevVersion()
@ -58,7 +60,7 @@ export function getProcessedVersion() {
}
}
// 生成下载URL
// 生成下载URL
export function getDownloadUrl(isDev, version) {
if (isDev) {
return 'https://github.com/mihomo-party-org/clash-party/releases/download/dev'
@ -81,6 +83,6 @@ export function generateDownloadLinksMarkdown(downloadUrl, version) {
links += '\n#### Linux\n\n'
links += `- DEB[64位](${downloadUrl}/clash-party-linux-${version}-amd64.deb) | [ARM64](${downloadUrl}/clash-party-linux-${version}-arm64.deb)\n\n`
links += `- RPM[64位](${downloadUrl}/clash-party-linux-${version}-x86_64.rpm) | [ARM64](${downloadUrl}/clash-party-linux-${version}-aarch64.rpm)`
return links
}
}

View File

@ -17,12 +17,16 @@ export async function getControledMihomoConfig(force = false): Promise<Partial<I
} else {
controledMihomoConfig = defaultControledMihomoConfig
try {
await writeFile(controledMihomoConfigPath(), stringify(defaultControledMihomoConfig), 'utf-8')
await writeFile(
controledMihomoConfigPath(),
stringify(defaultControledMihomoConfig),
'utf-8'
)
} catch (error) {
console.error('Failed to create mihomo.yaml file:', error)
}
}
// 确保配置包含所有必要的默认字段,处理升级场景
controledMihomoConfig = deepMerge(defaultControledMihomoConfig, controledMihomoConfig)
}

View File

@ -7,7 +7,12 @@ const SMART_OVERRIDE_ID = 'smart-core-override'
/**
* Smart
*/
function generateSmartOverrideTemplate(useLightGBM: boolean, collectData: boolean, strategy: string, collectorSize: number): string {
function generateSmartOverrideTemplate(
useLightGBM: boolean,
collectData: boolean,
strategy: string,
collectorSize: number
): string {
return `
// 配置会在启用 Smart 内核时自动应用

View File

@ -29,15 +29,16 @@ let runtimeConfig: IMihomoConfig
function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) {
const normalRules: string[] = []
let rules = [...currentRules]
ruleStrings.forEach(ruleStr => {
ruleStrings.forEach((ruleStr) => {
const parts = ruleStr.split(',')
const firstPartIsNumber = !isNaN(Number(parts[0])) && parts[0].trim() !== '' && parts.length >= 3
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))
@ -51,14 +52,19 @@ function processRulesWithOffset(ruleStrings: string[], currentRules: string[], i
normalRules.push(ruleStr)
}
})
return { normalRules, insertRules: rules }
}
export async function generateProfile(): Promise<void> {
// 读取最新的配置
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()
let currentProfile = await overrideProfile(current, await getProfile(current))
let controledMihomoConfig = await getControledMihomoConfig()
@ -80,37 +86,48 @@ export async function generateProfile(): Promise<void> {
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
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)
const { normalRules: prependRules, insertRules } = processRulesWithOffset(
ruleData.prepend,
rules
)
rules = [...prependRules, ...insertRules]
}
// 处理后置规则
if (ruleData.append?.length) {
const { normalRules: appendRules, insertRules } = processRulesWithOffset(ruleData.append, rules, true)
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 => {
rules = rules.filter((rule) => {
const ruleStr = Array.isArray(rule) ? rule.join(',') : rule
return !deleteSet.has(ruleStr)
})
}
currentProfile.rules = rules as unknown as []
}
}

View File

@ -46,15 +46,14 @@ import { managerLogger } from '../utils/logger'
// 内核名称白名单
const ALLOWED_CORES = ['mihomo', 'mihomo-alpha', 'mihomo-smart'] as const
type AllowedCore = typeof ALLOWED_CORES[number]
type AllowedCore = (typeof ALLOWED_CORES)[number]
function isValidCoreName(core: string): core is AllowedCore {
return ALLOWED_CORES.includes(core as AllowedCore)
}
// 路径检查
// 路径检查
function validateCorePath(corePath: string): void {
if (corePath.includes('..')) {
throw new Error('Invalid core path: directory traversal detected')
}
@ -176,7 +175,9 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
os.setPriority(child.pid, os.constants.priority[mihomoCpuPriority])
}
if (detached) {
await managerLogger.info(`Core process detached successfully on ${process.platform}, PID: ${child.pid}`)
await managerLogger.info(
`Core process detached successfully on ${process.platform}, PID: ${child.pid}`
)
child.unref()
return new Promise((resolve) => {
resolve([new Promise(() => {})])
@ -210,7 +211,8 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
reject(i18next.t('tun.error.tunPermissionDenied'))
}
if ((process.platform !== 'win32' && str.includes('External controller unix listen error')) ||
if (
(process.platform !== 'win32' && str.includes('External controller unix listen error')) ||
(process.platform === 'win32' && str.includes('External controller pipe listen error'))
) {
await managerLogger.error('External controller listen error detected:', str)
@ -219,7 +221,7 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
await managerLogger.info('Attempting Windows pipe cleanup and retry...')
try {
await cleanupWindowsNamedPipes()
await new Promise(resolve => setTimeout(resolve, 2000))
await new Promise((resolve) => setTimeout(resolve, 2000))
} catch (cleanupError) {
await managerLogger.error('Pipe cleanup failed:', cleanupError)
}
@ -235,7 +237,9 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
resolve([
new Promise((resolve) => {
child.stdout?.on('data', async (data) => {
if (data.toString().toLowerCase().includes('start initial compatible provider default')) {
if (
data.toString().toLowerCase().includes('start initial compatible provider default')
) {
try {
mainWindow?.webContents.send('groupsUpdated')
mainWindow?.webContents.send('rulesUpdated')
@ -337,7 +341,7 @@ async function cleanupWindowsNamedPipes(): Promise<void> {
await managerLogger.warn('Failed to parse process list JSON:', parseError)
// 回退到文本解析
const lines = stdout.split('\n').filter(line => line.includes('mihomo'))
const lines = stdout.split('\n').filter((line) => line.includes('mihomo'))
for (const line of lines) {
const match = line.match(/(\d+)/)
if (match) {
@ -361,8 +365,7 @@ async function cleanupWindowsNamedPipes(): Promise<void> {
await managerLogger.warn('Failed to check mihomo processes:', error)
}
await new Promise(resolve => setTimeout(resolve, 1000))
await new Promise((resolve) => setTimeout(resolve, 1000))
} catch (error) {
await managerLogger.error('Windows named pipe cleanup failed:', error)
}
@ -451,10 +454,7 @@ export async function quitWithoutCore(): Promise<void> {
}
async function checkProfile(): Promise<void> {
const {
core = 'mihomo',
diffWorkDir = false
} = await getAppConfig()
const { core = 'mihomo', diffWorkDir = false } = await getAppConfig()
const { current } = await getProfileConfig()
const corePath = mihomoCorePath(core)
const execFilePromise = promisify(execFile)
@ -484,13 +484,15 @@ async function checkProfile(): Promise<void> {
}
return line.trim()
})
.filter(line => line.length > 0)
.filter((line) => line.length > 0)
if (errorLines.length === 0) {
const allLines = stdout.split('\n').filter(line => line.trim().length > 0)
const allLines = stdout.split('\n').filter((line) => line.trim().length > 0)
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${allLines.join('\n')}`)
} else {
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\n')}`)
throw new Error(
`${i18next.t('mihomo.error.profileCheckFailed')}:\n${errorLines.join('\n')}`
)
}
} else {
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}: ${error}`)
@ -570,7 +572,7 @@ async function waitForCoreReady(): Promise<void> {
return
}
await new Promise(resolve => setTimeout(resolve, retryInterval))
await new Promise((resolve) => setTimeout(resolve, retryInterval))
}
}
}
@ -598,7 +600,9 @@ export async function checkAdminPrivileges(): Promise<boolean> {
return true
} catch (netSessionError: any) {
const netErrorCode = netSessionError?.code || 0
await managerLogger.debug(`Both fltmc and net session failed, no admin privileges. Error codes: fltmc=${errorCode}, net=${netErrorCode}`)
await managerLogger.debug(
`Both fltmc and net session failed, no admin privileges. Error codes: fltmc=${errorCode}, net=${netErrorCode}`
)
return false
}
}
@ -613,11 +617,14 @@ export async function showTunPermissionDialog(): Promise<boolean> {
await managerLogger.info(`i18next available: ${typeof i18next.t === 'function'}`)
const title = i18next.t('tun.permissions.title') || '需要管理员权限'
const message = i18next.t('tun.permissions.message') || '启用TUN模式需要管理员权限是否现在重启应用获取权限'
const message =
i18next.t('tun.permissions.message') || '启用TUN模式需要管理员权限是否现在重启应用获取权限'
const confirmText = i18next.t('common.confirm') || '确认'
const cancelText = i18next.t('common.cancel') || '取消'
await managerLogger.info(`Dialog texts - Title: "${title}", Message: "${message}", Confirm: "${confirmText}", Cancel: "${cancelText}"`)
await managerLogger.info(
`Dialog texts - Title: "${title}", Message: "${message}", Confirm: "${confirmText}", Cancel: "${cancelText}"`
)
const choice = dialog.showMessageBoxSync({
type: 'warning',
@ -759,8 +766,11 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
for (const executable of mihomoExecutables) {
try {
const { stdout } = await execPromise(`chcp 65001 >nul 2>&1 && tasklist /FI "IMAGENAME eq ${executable}" /FO CSV`, { encoding: 'utf8' })
const lines = stdout.split('\n').filter(line => line.includes(executable))
const { stdout } = await execPromise(
`chcp 65001 >nul 2>&1 && tasklist /FI "IMAGENAME eq ${executable}" /FO CSV`,
{ encoding: 'utf8' }
)
const lines = stdout.split('\n').filter((line) => line.includes(executable))
if (lines.length > 0) {
await managerLogger.info(`Found ${lines.length} ${executable} processes running`)
@ -781,7 +791,9 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
return true
}
} catch (error) {
await managerLogger.info(`Cannot get info for process ${pid}, might be high privilege`)
await managerLogger.info(
`Cannot get info for process ${pid}, might be high privilege`
)
}
}
}
@ -802,7 +814,9 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
for (const executable of mihomoExecutables) {
try {
const { stdout } = await execPromise(`ps aux | grep ${executable} | grep -v grep`)
const lines = stdout.split('\n').filter(line => line.trim() && line.includes(executable))
const lines = stdout
.split('\n')
.filter((line) => line.trim() && line.includes(executable))
if (lines.length > 0) {
foundProcesses = true
@ -820,8 +834,7 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
}
}
}
} catch (error) {
}
} catch (error) {}
}
if (!foundProcesses) {

View File

@ -190,12 +190,12 @@ export const mihomoUpgradeUI = async (): Promise<void> => {
export const mihomoUpgradeConfig = async (): Promise<void> => {
console.log('[mihomoApi] mihomoUpgradeConfig called')
try {
const instance = await getAxios()
console.log('[mihomoApi] axios instance obtained')
const { diffWorkDir = false } = await getAppConfig()
const { current } = await import('../config').then(mod => mod.getProfileConfig(true))
const { current } = await import('../config').then((mod) => mod.getProfileConfig(true))
const { mihomoWorkConfigPath } = await import('../utils/dirs')
const configPath = diffWorkDir ? mihomoWorkConfigPath(current) : mihomoWorkConfigPath('work')
console.log('[mihomoApi] config path:', configPath)
@ -441,7 +441,10 @@ export const TunStatus = async (): Promise<boolean> => {
return config?.tun?.enable === true
}
export function calculateTrayIconStatus(sysProxyEnabled: boolean, tunEnabled: boolean): 'white' | 'blue' | 'green' | 'red' {
export function calculateTrayIconStatus(
sysProxyEnabled: boolean,
tunEnabled: boolean
): 'white' | 'blue' | 'green' | 'red' {
if (sysProxyEnabled && tunEnabled) {
return 'red' // 系统代理 + TUN 同时启用(警告状态)
} else if (sysProxyEnabled) {
@ -456,4 +459,4 @@ export function calculateTrayIconStatus(sysProxyEnabled: boolean, tunEnabled: bo
export async function getTrayIconStatus(): Promise<'white' | 'blue' | 'green' | 'red'> {
const [sysProxyEnabled, tunEnabled] = await Promise.all([SysProxyStatus(), TunStatus()])
return calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
}
}

View File

@ -6,7 +6,7 @@ const intervalPool: Record<string, Cron | NodeJS.Timeout> = {}
export async function initProfileUpdater(): Promise<void> {
const { items, current } = await getProfileConfig()
const currentItem = await getCurrentProfileItem()
for (const item of items.filter((i) => i.id !== current)) {
if (item.type === 'remote' && item.autoUpdate && item.interval) {
if (typeof item.interval === 'number') {
@ -31,7 +31,7 @@ export async function initProfileUpdater(): Promise<void> {
}
})
}
try {
await addProfileItem(item)
} catch (e) {
@ -52,7 +52,7 @@ export async function initProfileUpdater(): Promise<void> {
},
currentItem.interval * 60 * 1000
)
setTimeout(
async () => {
try {
@ -85,7 +85,7 @@ export async function addProfileUpdater(item: IProfileItem): Promise<void> {
if (item.type === 'remote' && item.autoUpdate && item.interval) {
if (intervalPool[item.id]) {
if (intervalPool[item.id] instanceof Cron) {
(intervalPool[item.id] as Cron).stop()
;(intervalPool[item.id] as Cron).stop()
} else {
clearInterval(intervalPool[item.id] as NodeJS.Timeout)
}
@ -117,10 +117,10 @@ export async function addProfileUpdater(item: IProfileItem): Promise<void> {
export async function removeProfileUpdater(id: string): Promise<void> {
if (intervalPool[id]) {
if (intervalPool[id] instanceof Cron) {
(intervalPool[id] as Cron).stop()
;(intervalPool[id] as Cron).stop()
} else {
clearInterval(intervalPool[id] as NodeJS.Timeout)
}
delete intervalPool[id]
}
}
}

View File

@ -3,7 +3,15 @@ import { registerIpcMainHandlers } from './utils/ipc'
import windowStateKeeper from 'electron-window-state'
import { app, shell, BrowserWindow, Menu, dialog, Notification, powerMonitor } from 'electron'
import { addProfileItem, getAppConfig, patchAppConfig } from './config'
import { quitWithoutCore, startCore, stopCore, checkAdminRestartForTun, checkHighPrivilegeCore, restartAsAdmin, initAdminStatus } from './core/manager'
import {
quitWithoutCore,
startCore,
stopCore,
checkAdminRestartForTun,
checkHighPrivilegeCore,
restartAsAdmin,
initAdminStatus
} from './core/manager'
import { triggerSysProxy } from './sys/sysproxy'
import icon from '../../resources/icon.png?asset'
import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
@ -23,7 +31,6 @@ import i18next from 'i18next'
import { logger } from './utils/logger'
import { initWebdavBackupScheduler } from './resolve/backup'
async function fixUserDataPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
@ -60,11 +67,10 @@ async function initApp(): Promise<void> {
await fixUserDataPermissions()
}
initApp()
.catch((e) => {
safeShowErrorBox('common.error.initFailed', `${e}`)
app.quit()
})
initApp().catch((e) => {
safeShowErrorBox('common.error.initFailed', `${e}`)
app.quit()
})
export function customRelaunch(): void {
const script = `while kill -0 ${process.pid} 2>/dev/null; do
@ -111,7 +117,8 @@ async function checkHighPrivilegeCoreEarly(): Promise<void> {
if (hasHighPrivilegeCore) {
try {
const appConfig = await getAppConfig()
const language = appConfig.language || (app.getLocale().startsWith('zh') ? 'zh-CN' : 'en-US')
const language =
appConfig.language || (app.getLocale().startsWith('zh') ? 'zh-CN' : 'en-US')
await initI18n({ lng: language })
} catch {
await initI18n({ lng: 'zh-CN' })
@ -223,8 +230,8 @@ app.whenReady().then(async () => {
try {
const [startPromise] = await startCore()
startPromise.then(async () => {
await initProfileUpdater()
await initWebdavBackupScheduler() // 初始化WebDAV定时备份任务
await initProfileUpdater()
await initWebdavBackupScheduler() // 初始化WebDAV定时备份任务
// 上次是否为了开启 TUN 而重启
await checkAdminRestartForTun()
})
@ -412,18 +419,18 @@ export async function createWindow(): Promise<void> {
export function triggerMainWindow(force?: boolean): void {
if (mainWindow) {
getAppConfig()
.then(({ triggerMainWindowBehavior = 'toggle' }) => {
if (force === true || triggerMainWindowBehavior === 'toggle') {
if (mainWindow?.isVisible()) {
closeMainWindow()
.then(({ triggerMainWindowBehavior = 'toggle' }) => {
if (force === true || triggerMainWindowBehavior === 'toggle') {
if (mainWindow?.isVisible()) {
closeMainWindow()
} else {
showMainWindow()
}
} else {
showMainWindow()
}
} else {
showMainWindow()
}
})
.catch(showMainWindow)
})
.catch(showMainWindow)
}
}

View File

@ -131,9 +131,13 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
await appLogger.info('Opened installer with shell.openPath as fallback')
} catch (fallbackError) {
await appLogger.error('Fallback method also failed', fallbackError)
const installerErrorMessage = installerError instanceof Error ? installerError.message : String(installerError)
const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
throw new Error(`Failed to execute installer: ${installerErrorMessage}. Fallback also failed: ${fallbackErrorMessage}`)
const installerErrorMessage =
installerError instanceof Error ? installerError.message : String(installerError)
const fallbackErrorMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
throw new Error(
`Failed to execute installer: ${installerErrorMessage}. Fallback also failed: ${fallbackErrorMessage}`
)
}
}
}

View File

@ -148,7 +148,7 @@ export async function initWebdavBackupScheduler(): Promise<void> {
}
const { webdavBackupCron } = await getAppConfig()
// 如果配置了Cron表达式则启动定时任务
if (webdavBackupCron) {
backupCronJob = new Cron(webdavBackupCron, async () => {
@ -159,7 +159,7 @@ export async function initWebdavBackupScheduler(): Promise<void> {
await systemLogger.error('Failed to execute WebDAV backup via cron job', error)
}
})
await systemLogger.info(`WebDAV backup scheduler initialized with cron: ${webdavBackupCron}`)
await systemLogger.info(`WebDAV backup scheduler nextRun: ${backupCronJob.nextRun()}`)
} else {
@ -224,7 +224,7 @@ export async function exportLocalBackup(): Promise<boolean> {
if (existsSync(rulesDir())) {
zip.addLocalFolder(rulesDir(), 'rules')
}
const date = new Date()
const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
const result = await dialog.showSaveDialog({
@ -235,7 +235,7 @@ export async function exportLocalBackup(): Promise<boolean> {
{ name: 'All Files', extensions: ['*'] }
]
})
if (!result.canceled && result.filePath) {
zip.writeZip(result.filePath)
await systemLogger.info(`Local backup exported to: ${result.filePath}`)
@ -256,7 +256,7 @@ export async function importLocalBackup(): Promise<boolean> {
],
properties: ['openFile']
})
if (!result.canceled && result.filePaths.length > 0) {
const filePath = result.filePaths[0]
const zip = new AdmZip(filePath)

View File

@ -19,9 +19,8 @@ async function createFloatingWindow(): Promise<void> {
const { customTheme = 'default.css', floatingWindowCompatMode = true } = await getAppConfig()
const safeMode = process.env.FLOATING_SAFE_MODE === 'true'
const useCompatMode = floatingWindowCompatMode ||
process.env.FLOATING_COMPAT_MODE === 'true' ||
safeMode
const useCompatMode =
floatingWindowCompatMode || process.env.FLOATING_COMPAT_MODE === 'true' || safeMode
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 120,
@ -38,7 +37,7 @@ async function createFloatingWindow(): Promise<void> {
maximizable: safeMode,
fullscreenable: false,
closable: safeMode,
backgroundColor: safeMode ? '#ffffff' : (useCompatMode ? '#f0f0f0' : '#00000000'),
backgroundColor: safeMode ? '#ffffff' : useCompatMode ? '#f0f0f0' : '#00000000',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
spellcheck: false,
@ -81,9 +80,10 @@ async function createFloatingWindow(): Promise<void> {
})
// 加载页面
const url = is.dev && process.env['ELECTRON_RENDERER_URL']
? `${process.env['ELECTRON_RENDERER_URL']}/floating.html`
: join(__dirname, '../renderer/floating.html')
const url =
is.dev && process.env['ELECTRON_RENDERER_URL']
? `${process.env['ELECTRON_RENDERER_URL']}/floating.html`
: join(__dirname, '../renderer/floating.html')
is.dev ? await floatingWindow.loadURL(url) : await floatingWindow.loadFile(url)
} catch (error) {

View File

@ -83,9 +83,7 @@ export async function getGistUrl(): Promise<string> {
} else {
await uploadRuntimeConfig()
const gists = await listGists(githubToken)
const gist = gists.find(
(gist) => gist.description === 'Auto Synced Clash Party Runtime Config'
)
const gist = gists.find((gist) => gist.description === 'Auto Synced Clash Party Runtime Config')
if (!gist) throw new Error('Gist not found')
return gist.html_url
}

View File

@ -44,7 +44,11 @@ export async function registerShortcut(
await triggerSysProxy(!enable)
await patchAppConfig({ sysProxy: { enable: !enable } })
new Notification({
title: i18next.t(!enable ? 'common.notification.systemProxyEnabled' : 'common.notification.systemProxyDisabled')
title: i18next.t(
!enable
? 'common.notification.systemProxyEnabled'
: 'common.notification.systemProxyDisabled'
)
}).show()
mainWindow?.webContents.send('appConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')
@ -68,7 +72,9 @@ export async function registerShortcut(
}
await restartCore()
new Notification({
title: i18next.t(!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled')
title: i18next.t(
!enable ? 'common.notification.tunEnabled' : 'common.notification.tunDisabled'
)
}).show()
mainWindow?.webContents.send('controledMihomoConfigUpdated')
floatingWindow?.webContents.send('appConfigUpdated')

View File

@ -15,12 +15,25 @@ import pngIconBlue from '../../../resources/icon_blue.png?asset'
import pngIconRed from '../../../resources/icon_red.png?asset'
import pngIconGreen from '../../../resources/icon_green.png?asset'
import templateIcon from '../../../resources/iconTemplate.png?asset'
import { mihomoChangeProxy, mihomoCloseAllConnections, mihomoGroups, patchMihomoConfig, getTrayIconStatus, calculateTrayIconStatus } from '../core/mihomoApi'
import {
mihomoChangeProxy,
mihomoCloseAllConnections,
mihomoGroups,
patchMihomoConfig,
getTrayIconStatus,
calculateTrayIconStatus
} from '../core/mihomoApi'
import { mainWindow, showMainWindow, triggerMainWindow } from '..'
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { dataDir, logDir, mihomoCoreDir, mihomoWorkDir } from '../utils/dirs'
import { triggerSysProxy } from '../sys/sysproxy'
import { quitWithoutCore, restartCore, checkMihomoCorePermissions, requestTunPermissions, restartAsAdmin } from '../core/manager'
import {
quitWithoutCore,
restartCore,
checkMihomoCorePermissions,
requestTunPermissions,
restartAsAdmin
} from '../core/manager'
import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
import { t } from 'i18next'
import { trayLogger } from '../utils/logger'
@ -30,8 +43,14 @@ export let tray: Tray | null = null
export const buildContextMenu = async (): Promise<Menu> => {
// 添加调试日志
await trayLogger.debug('Current translation for tray.showWindow', t('tray.showWindow'))
await trayLogger.debug('Current translation for tray.hideFloatingWindow', t('tray.hideFloatingWindow'))
await trayLogger.debug('Current translation for tray.showFloatingWindow', t('tray.showFloatingWindow'))
await trayLogger.debug(
'Current translation for tray.hideFloatingWindow',
t('tray.hideFloatingWindow')
)
await trayLogger.debug(
'Current translation for tray.showFloatingWindow',
t('tray.showFloatingWindow')
)
const { mode, tun } = await getControledMihomoConfig()
const {
@ -55,8 +74,8 @@ export const buildContextMenu = async (): Promise<Menu> => {
try {
const groups = await mihomoGroups()
groupsMenu = groups.map((group) => {
const groupLabel = showCurrentProxyInTray ? `${group.name} | ${group.now}` : group.name;
const groupLabel = showCurrentProxyInTray ? `${group.name} | ${group.now}` : group.name
return {
id: group.name,
label: groupLabel,
@ -106,7 +125,9 @@ export const buildContextMenu = async (): Promise<Menu> => {
{
id: 'show-floating',
accelerator: showFloatingWindowShortcut,
label: floatingWindow?.isVisible() ? t('tray.hideFloatingWindow') : t('tray.showFloatingWindow'),
label: floatingWindow?.isVisible()
? t('tray.hideFloatingWindow')
: t('tray.showFloatingWindow'),
type: 'normal',
click: async (): Promise<void> => {
await triggerFloatingWindow()
@ -487,7 +508,7 @@ export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: bo
const status = calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
const iconPaths = getIconPaths()
getAppConfig().then(({ disableTrayIconColor = false }) => {
if (!tray) return
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
@ -526,4 +547,4 @@ export async function updateTrayIcon(): Promise<void> {
} catch (error) {
console.error('更新托盘图标失败:', error)
}
}
}

View File

@ -96,8 +96,7 @@ export async function enableAutoRun(): Promise<void> {
await execPromise(
`powershell -NoProfile -Command "Start-Process schtasks -Verb RunAs -ArgumentList '/create', '/tn', '${appName}', '/xml', '${taskFilePath}', '/f' -WindowStyle Hidden"`
)
}
catch (e) {
} catch (e) {
await managerLogger.info('Maybe the user rejected the UAC dialog?')
}
}

View File

@ -77,8 +77,6 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
nativeTheme.themeSource = theme
}
export function resetAppConfig(): void {
if (process.platform === 'win32') {
spawn(

View File

@ -84,7 +84,7 @@ async function enableSysProxy(): Promise<void> {
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
}
} else if (process.platform === 'darwin') {
await helperRequest(() =>
await helperRequest(() =>
axios.post(
'http://localhost/pac',
{ url: `http://${host || '127.0.0.1'}:${pacPort}/pac` },
@ -167,14 +167,14 @@ async function requestSocketRecreation(): Promise<void> {
const { exec } = require('child_process')
const { promisify } = require('util')
const execPromise = promisify(exec)
// Use osascript with administrator privileges (same pattern as grantTunPermissions)
const shell = `pkill -USR1 -f party.mihomo.helper`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
// Wait a bit for socket recreation
await new Promise(resolve => setTimeout(resolve, 1000))
await new Promise((resolve) => setTimeout(resolve, 1000))
} catch (error) {
await proxyLogger.error('Failed to send signal to helper', error)
throw error
@ -184,21 +184,24 @@ async function requestSocketRecreation(): Promise<void> {
// Wrapper function for helper requests with auto-retry on socket issues
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1): Promise<unknown> {
let lastError: Error | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await requestFn()
} catch (error) {
lastError = error as Error
// Check if it's a connection error and socket file doesn't exist
if (attempt < maxRetries &&
((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' ||
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
(error as Error).message?.includes('connect ECONNREFUSED') ||
(error as Error).message?.includes('ENOENT'))) {
await proxyLogger.info(`Helper request failed (attempt ${attempt + 1}), checking socket file...`)
if (
attempt < maxRetries &&
((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' ||
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
(error as Error).message?.includes('connect ECONNREFUSED') ||
(error as Error).message?.includes('ENOENT'))
) {
await proxyLogger.info(
`Helper request failed (attempt ${attempt + 1}), checking socket file...`
)
if (!isSocketFileExists()) {
await proxyLogger.info('Socket file missing, requesting recreation...')
@ -211,13 +214,13 @@ async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1):
}
}
}
// If not a connection error or we've exhausted retries, throw the error
if (attempt === maxRetries) {
throw lastError
}
}
}
throw lastError
}

View File

@ -4,11 +4,13 @@ export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
headers?: Record<string, string>
body?: string | Buffer
proxy?: {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
} | false
proxy?:
| {
protocol: 'http' | 'https' | 'socks5'
host: string
port: number
}
| false
timeout?: number
responseType?: 'text' | 'json' | 'arraybuffer'
followRedirect?: boolean

View File

@ -23,7 +23,7 @@ export function taskDir(): string {
if (!existsSync(userDataDir)) {
mkdirSync(userDataDir, { recursive: true })
}
const dir = path.join(userDataDir, 'tasks')
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })

View File

@ -48,9 +48,13 @@ const versionCache = new Map<string, VersionCache>()
* @param forceRefresh
* @returns
*/
export async function getGitHubTags(owner: string, repo: string, forceRefresh = false): Promise<GitHubTag[]> {
export async function getGitHubTags(
owner: string,
repo: string,
forceRefresh = false
): Promise<GitHubTag[]> {
const cacheKey = `${owner}/${repo}`
// 检查缓存
if (!forceRefresh && versionCache.has(cacheKey)) {
const cache = versionCache.get(cacheKey)!
@ -60,7 +64,7 @@ export async function getGitHubTags(owner: string, repo: string, forceRefresh =
return cache.data
}
}
try {
console.log(`[GitHub] Fetching tags for ${owner}/${repo}`)
const response = await chromeRequest.get<GitHubTag[]>(
@ -135,45 +139,45 @@ async function downloadGitHubAsset(url: string, outputPath: string): Promise<voi
export async function installMihomoCore(version: string): Promise<void> {
try {
console.log(`[GitHub] Installing mihomo core version ${version}`)
const plat = platform()
let arch = process.arch
// 映射平台和架构到GitHub Release文件名
const key = `${plat}-${arch}`
const name = PLATFORM_MAP[key]
if (!name) {
throw new Error(`Unsupported platform "${plat}-${arch}"`)
}
const isWin = plat === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
const downloadURL = `https://github.com/MetaCubeX/mihomo/releases/download/${version}/${name}-${version}.${urlExt}`
const coreDir = mihomoCoreDir()
const tempZip = join(coreDir, `temp-core.${urlExt}`)
const exeFile = `${name}${isWin ? '.exe' : ''}`
const targetFile = `mihomo-specific${isWin ? '.exe' : ''}`
const targetPath = join(coreDir, targetFile)
// 如果目标文件已存在,先停止核心
if (existsSync(targetPath)) {
console.log('[GitHub] Stopping core before extracting new core file')
// 先停止核心
await stopCore(true)
}
// 下载文件
await downloadGitHubAsset(downloadURL, tempZip)
// 解压文件
if (urlExt === 'zip') {
console.log(`[GitHub] Extracting ZIP file ${tempZip}`)
const zip = new AdmZip(tempZip)
const entries = zip.getEntries()
const entry = entries.find(e => e.entryName.includes(exeFile))
const entry = entries.find((e) => e.entryName.includes(exeFile))
if (entry) {
zip.extractEntryTo(entry, coreDir, false, true, false, targetFile)
console.log(`[GitHub] Successfully extracted ${exeFile} to ${targetPath}`)
@ -185,13 +189,13 @@ export async function installMihomoCore(version: string): Promise<void> {
console.log(`[GitHub] Extracting GZ file ${tempZip}`)
const readStream = createReadStream(tempZip)
const writeStream = createWriteStream(targetPath)
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
console.error('[GitHub] Gzip decompression failed:', error.message)
reject(new Error(`Gzip decompression failed: ${error.message}`))
}
readStream
.pipe(createGunzip().on('error', onError))
.pipe(writeStream)
@ -208,14 +212,16 @@ export async function installMihomoCore(version: string): Promise<void> {
.on('error', onError)
})
}
// 清理临时文件
console.log(`[GitHub] Cleaning up temporary file ${tempZip}`)
rmSync(tempZip)
console.log(`[GitHub] Successfully installed mihomo core version ${version}`)
} catch (error) {
console.error('[GitHub] Failed to install mihomo core:', error)
throw new Error(`Failed to install core: ${error instanceof Error ? error.message : String(error)}`)
throw new Error(
`Failed to install core: ${error instanceof Error ? error.message : String(error)}`
)
}
}
}

View File

@ -84,7 +84,7 @@ async function fixDataDirPermissions(): Promise<void> {
}
}
// 比较修改geodata文件修改时间
// 比较修改geodata文件修改时间
async function isSourceNewer(sourcePath: string, targetPath: string): Promise<boolean> {
try {
const sourceStats = await stat(sourcePath)
@ -130,7 +130,11 @@ async function initConfig(): Promise<void> {
{ path: profileConfigPath(), content: defaultProfileConfig, name: 'profile config' },
{ path: overrideConfigPath(), content: defaultOverrideConfig, name: 'override config' },
{ path: profilePath('default'), content: defaultProfile, name: 'default profile' },
{ path: controledMihomoConfigPath(), content: defaultControledMihomoConfig, name: 'mihomo config' }
{
path: controledMihomoConfigPath(),
content: defaultControledMihomoConfig,
name: 'mihomo config'
}
]
for (const config of configs) {
@ -154,13 +158,15 @@ async function initFiles(): Promise<void> {
try {
// 检查是否需要复制
if (existsSync(sourcePath)) {
const shouldCopyToWork = !existsSync(targetPath) || await isSourceNewer(sourcePath, targetPath)
const shouldCopyToWork =
!existsSync(targetPath) || (await isSourceNewer(sourcePath, targetPath))
if (shouldCopyToWork) {
await cp(sourcePath, targetPath, { recursive: true })
}
}
if (existsSync(sourcePath)) {
const shouldCopyToTest = !existsSync(testTargetPath) || await isSourceNewer(sourcePath, testTargetPath)
const shouldCopyToTest =
!existsSync(testTargetPath) || (await isSourceNewer(sourcePath, testTargetPath))
if (shouldCopyToTest) {
await cp(sourcePath, testTargetPath, { recursive: true })
}
@ -223,7 +229,7 @@ async function cleanup(): Promise<void> {
async function migrateSubStoreFiles(): Promise<void> {
const oldJsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
const newCjsPath = path.join(mihomoWorkDir(), 'sub-store.bundle.cjs')
if (existsSync(oldJsPath) && !existsSync(newCjsPath)) {
try {
await rename(oldJsPath, newCjsPath)
@ -276,7 +282,7 @@ async function migration(): Promise<void> {
if (!skipAuthPrefixes) {
await patchControledMihomoConfig({ 'skip-auth-prefixes': ['127.0.0.1/32', '::1/128'] })
} else if (skipAuthPrefixes.length >= 1 && skipAuthPrefixes[0] === '127.0.0.1/32') {
const filteredPrefixes = skipAuthPrefixes.filter(ip => ip !== '::1/128')
const filteredPrefixes = skipAuthPrefixes.filter((ip) => ip !== '::1/128')
const newPrefixes = [filteredPrefixes[0], '::1/128', ...filteredPrefixes.slice(1)]
if (JSON.stringify(newPrefixes) !== JSON.stringify(skipAuthPrefixes)) {
await patchControledMihomoConfig({ 'skip-auth-prefixes': newPrefixes })

View File

@ -1,4 +1,4 @@
import { app, dialog, ipcMain } from 'electron'
import { app, ipcMain } from 'electron'
import {
mihomoChangeProxy,
mihomoCloseAllConnections,
@ -86,9 +86,22 @@ import {
setupFirewall
} from '../sys/misc'
import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory'
import { listWebdavBackups, webdavBackup, webdavDelete, webdavRestore, exportLocalBackup, importLocalBackup } from '../resolve/backup'
import {
listWebdavBackups,
webdavBackup,
webdavDelete,
webdavRestore,
exportLocalBackup,
importLocalBackup
} from '../resolve/backup'
import { getInterfaces } from '../sys/interface'
import { closeTrayIcon, copyEnv, showTrayIcon, updateTrayIcon, updateTrayIconImmediate } from '../resolve/tray'
import {
closeTrayIcon,
copyEnv,
showTrayIcon,
updateTrayIcon,
updateTrayIconImmediate
} from '../resolve/tray'
import { registerShortcut } from '../resolve/shortcut'
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..'
import {
@ -135,7 +148,9 @@ function ipcErrorWrapper<T>( // eslint-disable-next-line @typescript-eslint/no-e
}
// GitHub版本管理相关IPC处理程序
export async function fetchMihomoTags(forceRefresh = false): Promise<{name: string, zipball_url: string, tarball_url: string}[]> {
export async function fetchMihomoTags(
forceRefresh = false
): Promise<{ name: string; zipball_url: string; tarball_url: string }[]> {
return await getGitHubTags('MetaCubeX', 'mihomo', forceRefresh)
}
@ -216,7 +231,9 @@ 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('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))
@ -242,7 +259,9 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('requestTunPermissions', () => ipcErrorWrapper(requestTunPermissions)())
ipcMain.handle('checkHighPrivilegeCore', () => ipcErrorWrapper(checkHighPrivilegeCore)())
ipcMain.handle('showTunPermissionDialog', () => ipcErrorWrapper(showTunPermissionDialog)())
ipcMain.handle('showErrorDialog', (_, title: string, message: string) => ipcErrorWrapper(showErrorDialog)(title, message))
ipcMain.handle('showErrorDialog', (_, title: string, message: string) =>
ipcErrorWrapper(showErrorDialog)(title, message)
)
ipcMain.handle('checkTunPermissions', () => ipcErrorWrapper(checkTunPermissions)())
ipcMain.handle('grantTunPermissions', () => ipcErrorWrapper(grantTunPermissions)())
@ -324,12 +343,6 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('writeTheme', (_e, theme, css) => ipcErrorWrapper(writeTheme)(theme, css))
ipcMain.handle('applyTheme', (_e, theme) => ipcErrorWrapper(applyTheme)(theme))
ipcMain.handle('copyEnv', (_e, type) => ipcErrorWrapper(copyEnv)(type))
ipcMain.handle('alert', (_e, msg) => {
dialog.showErrorBox('Clash Party', msg)
})
ipcMain.handle('showDetailedError', (_e, title, message) => {
dialog.showErrorBox(title, message)
})
ipcMain.handle('getSmartOverrideContent', async () => {
const { getOverrideItem } = await import('../config')
try {
@ -353,17 +366,21 @@ export function registerIpcMainHandlers(): void {
// 触发托盘菜单更新
ipcMain.emit('updateTrayMenu')
})
// 注册获取Mihomo标签的IPC处理程序
ipcMain.handle('fetchMihomoTags', (_e, forceRefresh) => ipcErrorWrapper(fetchMihomoTags)(forceRefresh))
ipcMain.handle('fetchMihomoTags', (_e, forceRefresh) =>
ipcErrorWrapper(fetchMihomoTags)(forceRefresh)
)
// 注册安装特定版本Mihomo核心的IPC处理程序
ipcMain.handle('installSpecificMihomoCore', (_e, version) => ipcErrorWrapper(installSpecificMihomoCore)(version))
ipcMain.handle('installSpecificMihomoCore', (_e, version) =>
ipcErrorWrapper(installSpecificMihomoCore)(version)
)
// 注册清除版本缓存的IPC处理程序
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

@ -28,13 +28,16 @@ class Logger {
} catch (logError) {
// 如果写入日志文件失败,仍然输出到控制台
console.error(`[Logger] Failed to write to log file:`, logError)
console.error(`[Logger] Original message: [${level.toUpperCase()}] [${this.moduleName}] ${message}`, error)
console.error(
`[Logger] Original message: [${level.toUpperCase()}] [${this.moduleName}] ${message}`,
error
)
}
}
private logToConsole(level: LogLevel, message: string, error?: any): void {
const prefix = `[${this.moduleName}] ${message}`
switch (level) {
case 'debug':
console.debug(prefix, error || '')

View File

@ -10,4 +10,4 @@ export const parse = <T = unknown>(content: string): T => {
export function stringify(content: unknown): string {
return yaml.stringify(content)
}
}

View File

@ -5,7 +5,9 @@
@source '../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@theme {
--default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--default-font-family:
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
}
@font-face {
@ -192,4 +194,4 @@
to {
width: 0%;
}
}
}

View File

@ -141,7 +141,7 @@ export const BaseEditor: React.FC<Props> = (props) => {
}}
editorWillMount={editorWillMount}
editorDidMount={editorDidMount}
editorWillUnmount={(): void => { }}
editorWillUnmount={(): void => {}}
onChange={onChange}
/>
)

View File

@ -5,12 +5,10 @@ import { useTranslation } from 'react-i18next'
const ErrorFallback = ({ error }: FallbackProps): React.ReactElement => {
const { t } = useTranslation()
return (
<div className="p-4">
<h2 className="my-2 text-lg font-bold">
{t('common.error.appCrash')}
</h2>
<h2 className="my-2 text-lg font-bold">{t('common.error.appCrash')}</h2>
<Button
size="sm"

View File

@ -50,7 +50,8 @@ export const toast = {
error: (message: string, title?: string): void => addToast('error', message, title, 1800),
warning: (message: string, title?: string): void => addToast('warning', message, title),
info: (message: string, title?: string): void => addToast('info', message, title),
detailedError: (message: string, title?: string): void => addDetailedToast('error', message, title)
detailedError: (message: string, title?: string): void =>
addDetailedToast('error', message, title)
}
const ToastItem: React.FC<{
@ -76,7 +77,7 @@ const ToastItem: React.FC<{
},
error: {
icon: <IoClose className="text-white text-sm" />,
bg: 'bg-content1',
bg: 'bg-content1',
iconBg: 'bg-danger'
},
warning: {
@ -119,7 +120,9 @@ const ToastItem: React.FC<{
>
<div className="flex items-center justify-between overflow-visible">
<div className="flex items-center gap-3">
<div className={`flex-shrink-0 w-8 h-8 ${iconBg} rounded-full flex items-center justify-center`}>
<div
className={`flex-shrink-0 w-8 h-8 ${iconBg} rounded-full flex items-center justify-center`}
>
{icon}
</div>
<p className="text-base font-semibold text-foreground">{data.title || '错误'}</p>
@ -130,11 +133,18 @@ const ToastItem: React.FC<{
className="p-2 rounded-lg hover:bg-default-200 transition-colors"
>
<div className="relative w-4 h-4">
<IoCopy className={`absolute inset-0 text-base text-foreground-500 transition-all duration-200 ${copied ? 'opacity-0 scale-50' : 'opacity-100 scale-100'}`} />
<IoCheckmark className={`absolute inset-0 text-base text-success transition-all duration-200 ${copied ? 'opacity-100 scale-100' : 'opacity-0 scale-50'}`} />
<IoCopy
className={`absolute inset-0 text-base text-foreground-500 transition-all duration-200 ${copied ? 'opacity-0 scale-50' : 'opacity-100 scale-100'}`}
/>
<IoCheckmark
className={`absolute inset-0 text-base text-success transition-all duration-200 ${copied ? 'opacity-100 scale-100' : 'opacity-0 scale-50'}`}
/>
</div>
</button>
<div className={`absolute top-full mt-1 left-1/2 -translate-x-1/2 px-2 py-1 text-xs text-foreground bg-content2 border border-default-200 rounded shadow-md whitespace-nowrap transition-all duration-200 ${copied ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 pointer-events-none'}`} style={{ zIndex: 99999 }}>
<div
className={`absolute top-full mt-1 left-1/2 -translate-x-1/2 px-2 py-1 text-xs text-foreground bg-content2 border border-default-200 rounded shadow-md whitespace-nowrap transition-all duration-200 ${copied ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 pointer-events-none'}`}
style={{ zIndex: 99999 }}
>
</div>
</div>
@ -166,16 +176,14 @@ const ToastItem: React.FC<{
`}
style={{ width: 340 }}
>
<div className={`flex-shrink-0 w-7 h-7 ${iconBg} rounded-full flex items-center justify-center`}>
<div
className={`flex-shrink-0 w-7 h-7 ${iconBg} rounded-full flex items-center justify-center`}
>
{icon}
</div>
<div className="flex-1 min-w-0">
{data.title && (
<p className="text-sm font-medium text-foreground">{data.title}</p>
)}
<p className="text-sm text-foreground-500 break-words select-text">
{data.message}
</p>
{data.title && <p className="text-sm font-medium text-foreground">{data.title}</p>}
<p className="text-sm text-foreground-500 break-words select-text">{data.message}</p>
</div>
<button
onClick={handleClose}

View File

@ -33,60 +33,67 @@ const CopyableSettingItem: React.FC<{
domain.split('.').length <= 2
? [domain]
: domain
.split('.')
.map((_, i, parts) => parts.slice(i).join('.'))
.slice(0, -1)
.split('.')
.map((_, i, parts) => parts.slice(i).join('.'))
.slice(0, -1)
const isIPv6 = (ip: string) => ip.includes(':')
const menuItems = [
{ key: 'raw', text: displayName || (Array.isArray(value) ? value.join(', ') : value) },
...(Array.isArray(value)
? value.map((v, i) => {
const p = prefix[i]
if (!p || !v) return null
if (p === 'DOMAIN-SUFFIX') {
return getSubDomains(v).map((subV) => ({
key: `${p},${subV}`,
text: `${p},${subV}`
}))
}
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
return {
key: `${p},${v.split(' ')[0]}`,
text: `${p},${v.split(' ')[0]}`
? value
.map((v, i) => {
const p = prefix[i]
if (!p || !v) return null
if (p === 'DOMAIN-SUFFIX') {
return getSubDomains(v).map((subV) => ({
key: `${p},${subV}`,
text: `${p},${subV}`
}))
}
}
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : ''
return {
key: `${p},${v}${suffix}`,
text: `${p},${v}${suffix}`
}
}).filter(Boolean).flat()
: prefix.map(p => {
const v = value as string
if (p === 'DOMAIN-SUFFIX') {
return getSubDomains(v).map((subV) => ({
key: `${p},${subV}`,
text: `${p},${subV}`
}))
}
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
return {
key: `${p},${v.split(' ')[0]}`,
text: `${p},${v.split(' ')[0]}`
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
return {
key: `${p},${v.split(' ')[0]}`,
text: `${p},${v.split(' ')[0]}`
}
}
}
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : ''
return {
key: `${p},${v}${suffix}`,
text: `${p},${v}${suffix}`
}
}).flat())
const suffix =
p === 'IP-CIDR' || p === 'SRC-IP-CIDR' ? (isIPv6(v) ? '/128' : '/32') : ''
return {
key: `${p},${v}${suffix}`,
text: `${p},${v}${suffix}`
}
})
.filter(Boolean)
.flat()
: prefix
.map((p) => {
const v = value as string
if (p === 'DOMAIN-SUFFIX') {
return getSubDomains(v).map((subV) => ({
key: `${p},${subV}`,
text: `${p},${subV}`
}))
}
if (p === 'IP-ASN' || p === 'SRC-IP-ASN') {
return {
key: `${p},${v.split(' ')[0]}`,
text: `${p},${v.split(' ')[0]}`
}
}
const suffix =
p === 'IP-CIDR' || p === 'SRC-IP-CIDR' ? (isIPv6(v) ? '/128' : '/32') : ''
return {
key: `${p},${v}${suffix}`,
text: `${p},${v}${suffix}`
}
})
.flat())
]
return (
@ -137,16 +144,28 @@ const ConnectionDetailModal: React.FC<Props> = (props) => {
<ModalContent className="flag-emoji break-all">
<ModalHeader className="flex app-drag">{t('connections.detail.title')}</ModalHeader>
<ModalBody>
<SettingItem title={t('connections.detail.establishTime')}>{dayjs(connection.start).fromNow()}</SettingItem>
<SettingItem title={t('connections.detail.establishTime')}>
{dayjs(connection.start).fromNow()}
</SettingItem>
<SettingItem title={t('connections.detail.rule')}>
{connection.rule}
{connection.rulePayload ? `(${connection.rulePayload})` : ''}
</SettingItem>
<SettingItem title={t('connections.detail.proxyChain')}>{[...connection.chains].reverse().join('>>')}</SettingItem>
<SettingItem title={t('connections.uploadSpeed')}>{calcTraffic(connection.uploadSpeed || 0)}/s</SettingItem>
<SettingItem title={t('connections.downloadSpeed')}>{calcTraffic(connection.downloadSpeed || 0)}/s</SettingItem>
<SettingItem title={t('connections.uploadAmount')}>{calcTraffic(connection.upload)}</SettingItem>
<SettingItem title={t('connections.downloadAmount')}>{calcTraffic(connection.download)}</SettingItem>
<SettingItem title={t('connections.detail.proxyChain')}>
{[...connection.chains].reverse().join('>>')}
</SettingItem>
<SettingItem title={t('connections.uploadSpeed')}>
{calcTraffic(connection.uploadSpeed || 0)}/s
</SettingItem>
<SettingItem title={t('connections.downloadSpeed')}>
{calcTraffic(connection.downloadSpeed || 0)}/s
</SettingItem>
<SettingItem title={t('connections.uploadAmount')}>
{calcTraffic(connection.upload)}
</SettingItem>
<SettingItem title={t('connections.downloadAmount')}>
{calcTraffic(connection.download)}
</SettingItem>
<CopyableSettingItem
title={t('connections.detail.connectionType')}
value={[connection.metadata.type, connection.metadata.network]}
@ -278,16 +297,24 @@ const ConnectionDetailModal: React.FC<Props> = (props) => {
/>
{connection.metadata.remoteDestination && (
<SettingItem title={t('connections.detail.remoteDestination')}>{connection.metadata.remoteDestination}</SettingItem>
<SettingItem title={t('connections.detail.remoteDestination')}>
{connection.metadata.remoteDestination}
</SettingItem>
)}
{connection.metadata.dnsMode && (
<SettingItem title={t('connections.detail.dnsMode')}>{connection.metadata.dnsMode}</SettingItem>
<SettingItem title={t('connections.detail.dnsMode')}>
{connection.metadata.dnsMode}
</SettingItem>
)}
{connection.metadata.specialProxy && (
<SettingItem title={t('connections.detail.specialProxy')}>{connection.metadata.specialProxy}</SettingItem>
<SettingItem title={t('connections.detail.specialProxy')}>
{connection.metadata.specialProxy}
</SettingItem>
)}
{connection.metadata.specialRules && (
<SettingItem title={t('connections.detail.specialRules')}>{connection.metadata.specialRules}</SettingItem>
<SettingItem title={t('connections.detail.specialRules')}>
{connection.metadata.specialRules}
</SettingItem>
)}
</ModalBody>
<ModalFooter>

View File

@ -68,7 +68,8 @@ const ConnectionItem: React.FC<Props> = (props) => {
</Chip>
{info.uploadSpeed !== 0 || info.downloadSpeed !== 0 ? (
<Chip color="primary" size="sm" radius="sm" variant="bordered">
{calcTraffic(info.uploadSpeed || 0)}/s {calcTraffic(info.downloadSpeed || 0)}
{calcTraffic(info.uploadSpeed || 0)}/s {' '}
{calcTraffic(info.downloadSpeed || 0)}
/s
</Chip>
) : null}

View File

@ -35,8 +35,8 @@ const DEFAULT_COLUMNS: Omit<ColumnConfig, 'label'>[] = [
width: 80,
minWidth: 60,
visible: true,
getValue: (conn) => conn.isActive ? 'active' : 'closed',
sortValue: (conn) => conn.isActive ? 1 : 0
getValue: (conn) => (conn.isActive ? 'active' : 'closed'),
sortValue: (conn) => (conn.isActive ? 1 : 0)
},
{
key: 'establishTime',
@ -53,7 +53,9 @@ const DEFAULT_COLUMNS: Omit<ColumnConfig, 'label'>[] = [
visible: true,
getValue: (conn) => `${conn.metadata.type}(${conn.metadata.network})`,
render: (conn) => (
<span className="text-xs">{conn.metadata.type}({conn.metadata.network.toUpperCase()})</span>
<span className="text-xs">
{conn.metadata.type}({conn.metadata.network.toUpperCase()})
</span>
)
},
{
@ -210,135 +212,149 @@ const ConnectionTable: React.FC<Props> = ({
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(initialSortDirection || 'asc')
// 状态列渲染函数
const renderStatus = useCallback((conn: IMihomoConnectionDetail) => (
<Chip
color={conn.isActive ? 'primary' : 'danger'}
size="sm"
radius="sm"
variant="dot"
>
{conn.isActive ? t('connections.active') : t('connections.closed')}
</Chip>
), [t])
const renderStatus = useCallback(
(conn: IMihomoConnectionDetail) => (
<Chip color={conn.isActive ? 'primary' : 'danger'} size="sm" radius="sm" variant="dot">
{conn.isActive ? t('connections.active') : t('connections.closed')}
</Chip>
),
[t]
)
// 连接类型渲染函数
const renderType = useCallback((conn: IMihomoConnectionDetail) => (
<span className="text-xs">{conn.metadata.type}({conn.metadata.network.toUpperCase()})</span>
), [])
const renderType = useCallback(
(conn: IMihomoConnectionDetail) => (
<span className="text-xs">
{conn.metadata.type}({conn.metadata.network.toUpperCase()})
</span>
),
[]
)
// 翻译标签映射
const getLabelForColumn = useCallback((key: string): string => {
const translationMap: Record<string, string> = {
status: t('connections.detail.status'),
establishTime: t('connections.detail.establishTime'),
type: t('connections.detail.connectionType'),
host: t('connections.detail.host'),
sniffHost: t('connections.detail.sniffHost'),
process: t('connections.detail.processName'),
processPath: t('connections.detail.processPath'),
rule: t('connections.detail.rule'),
proxyChain: t('connections.detail.proxyChain'),
sourceIP: t('connections.detail.sourceIP'),
sourcePort: t('connections.detail.sourcePort'),
destinationPort: t('connections.detail.destinationPort'),
inboundIP: t('connections.detail.inboundIP'),
inboundPort: t('connections.detail.inboundPort'),
uploadSpeed: t('connections.uploadSpeed'),
downloadSpeed: t('connections.downloadSpeed'),
upload: t('connections.uploadAmount'),
download: t('connections.downloadAmount'),
dscp: t('connections.detail.dscp'),
remoteDestination: t('connections.detail.remoteDestination'),
dnsMode: t('connections.detail.dnsMode')
}
return translationMap[key] || key
}, [t])
const getLabelForColumn = useCallback(
(key: string): string => {
const translationMap: Record<string, string> = {
status: t('connections.detail.status'),
establishTime: t('connections.detail.establishTime'),
type: t('connections.detail.connectionType'),
host: t('connections.detail.host'),
sniffHost: t('connections.detail.sniffHost'),
process: t('connections.detail.processName'),
processPath: t('connections.detail.processPath'),
rule: t('connections.detail.rule'),
proxyChain: t('connections.detail.proxyChain'),
sourceIP: t('connections.detail.sourceIP'),
sourcePort: t('connections.detail.sourcePort'),
destinationPort: t('connections.detail.destinationPort'),
inboundIP: t('connections.detail.inboundIP'),
inboundPort: t('connections.detail.inboundPort'),
uploadSpeed: t('connections.uploadSpeed'),
downloadSpeed: t('connections.downloadSpeed'),
upload: t('connections.uploadAmount'),
download: t('connections.downloadAmount'),
dscp: t('connections.detail.dscp'),
remoteDestination: t('connections.detail.remoteDestination'),
dnsMode: t('connections.detail.dnsMode')
}
return translationMap[key] || key
},
[t]
)
// 初始化列配置(保留宽度状态)
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
const widths: Record<string, number> = {}
DEFAULT_COLUMNS.forEach(col => {
DEFAULT_COLUMNS.forEach((col) => {
widths[col.key] = initialColumnWidths?.[col.key] || col.width
})
return widths
})
// 更新列标签和可见性
const columnsWithLabels = useMemo(() =>
DEFAULT_COLUMNS.map(col => ({
...col,
label: getLabelForColumn(col.key),
visible: visibleColumns.has(col.key),
width: columnWidths[col.key] || col.width
}))
, [getLabelForColumn, visibleColumns, columnWidths])
const columnsWithLabels = useMemo(
() =>
DEFAULT_COLUMNS.map((col) => ({
...col,
label: getLabelForColumn(col.key),
visible: visibleColumns.has(col.key),
width: columnWidths[col.key] || col.width
})),
[getLabelForColumn, visibleColumns, columnWidths]
)
// 处理列宽度调整
const handleMouseDown = useCallback((e: React.MouseEvent, columnKey: string) => {
e.preventDefault()
setResizingColumn(columnKey)
const handleMouseDown = useCallback(
(e: React.MouseEvent, columnKey: string) => {
e.preventDefault()
setResizingColumn(columnKey)
const startX = e.clientX
const column = DEFAULT_COLUMNS.find(c => c.key === columnKey)
if (!column) return
const startX = e.clientX
const column = DEFAULT_COLUMNS.find((c) => c.key === columnKey)
if (!column) return
let currentWidth = column.width
setColumnWidths(prev => {
currentWidth = prev[columnKey] || column.width
return prev
})
let currentWidth = column.width
setColumnWidths((prev) => {
currentWidth = prev[columnKey] || column.width
return prev
})
const handleMouseMove = (e: MouseEvent) => {
const diff = e.clientX - startX
const newWidth = Math.max(column.minWidth, currentWidth + diff)
const handleMouseMove = (e: MouseEvent) => {
const diff = e.clientX - startX
const newWidth = Math.max(column.minWidth, currentWidth + diff)
setColumnWidths(prev => ({
...prev,
[columnKey]: newWidth
}))
}
const handleMouseUp = () => {
setResizingColumn(null)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// 保存列宽度
if (onColumnWidthChange) {
setColumnWidths(currentWidths => {
onColumnWidthChange(currentWidths)
return currentWidths
})
setColumnWidths((prev) => ({
...prev,
[columnKey]: newWidth
}))
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [onColumnWidthChange])
const handleMouseUp = () => {
setResizingColumn(null)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// 保存列宽度
if (onColumnWidthChange) {
setColumnWidths((currentWidths) => {
onColumnWidthChange(currentWidths)
return currentWidths
})
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[onColumnWidthChange]
)
// 处理排序
const handleSort = useCallback((columnKey: string) => {
let newDirection: 'asc' | 'desc' = 'asc'
let newColumn = columnKey
const handleSort = useCallback(
(columnKey: string) => {
let newDirection: 'asc' | 'desc' = 'asc'
let newColumn = columnKey
if (sortColumn === columnKey) {
newDirection = sortDirection === 'asc' ? 'desc' : 'asc'
setSortDirection(newDirection)
} else {
setSortColumn(columnKey)
setSortDirection('asc')
}
if (sortColumn === columnKey) {
newDirection = sortDirection === 'asc' ? 'desc' : 'asc'
setSortDirection(newDirection)
} else {
setSortColumn(columnKey)
setSortDirection('asc')
}
// 保存排序状态
if (onSortChange) {
onSortChange(newColumn, newDirection)
}
}, [sortColumn, sortDirection, onSortChange])
// 保存排序状态
if (onSortChange) {
onSortChange(newColumn, newDirection)
}
},
[sortColumn, sortDirection, onSortChange]
)
// 排序连接
const sortedConnections = useMemo(() => {
if (!sortColumn) return connections
const column = columnsWithLabels.find(c => c.key === sortColumn)
const column = columnsWithLabels.find((c) => c.key === sortColumn)
if (!column) return connections
return [...connections].sort((a, b) => {
@ -357,7 +373,7 @@ const ConnectionTable: React.FC<Props> = ({
})
}, [connections, sortColumn, sortDirection, columnsWithLabels])
const visibleColumnsFiltered = columnsWithLabels.filter(col => col.visible)
const visibleColumnsFiltered = columnsWithLabels.filter((col) => col.visible)
return (
<div className="h-full flex flex-col">
@ -366,7 +382,7 @@ const ConnectionTable: React.FC<Props> = ({
<table className="w-full border-collapse">
<thead className="sticky top-0 z-10 bg-content2">
<tr>
{visibleColumnsFiltered.map(col => (
{visibleColumnsFiltered.map((col) => (
<th
key={col.key}
className="relative border-b border-divider text-left text-xs font-semibold text-foreground-600 px-3 h-10"
@ -379,9 +395,7 @@ const ConnectionTable: React.FC<Props> = ({
>
{col.label}
{sortColumn === col.key && (
<span className="ml-1">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
<span className="ml-1">{sortDirection === 'asc' ? '↑' : '↓'}</span>
)}
</button>
<div
@ -411,7 +425,7 @@ const ConnectionTable: React.FC<Props> = ({
setIsDetailModalOpen(true)
}}
>
{visibleColumnsFiltered.map(col => {
{visibleColumnsFiltered.map((col) => {
let content: React.ReactNode
// 根据列类型选择渲染方式
if (col.key === 'status') {
@ -429,7 +443,11 @@ const ConnectionTable: React.FC<Props> = ({
key={col.key}
className="px-3 text-sm text-foreground truncate"
style={{ maxWidth: col.width }}
title={typeof col.getValue(connection) === 'string' ? col.getValue(connection) as string : ''}
title={
typeof col.getValue(connection) === 'string'
? (col.getValue(connection) as string)
: ''
}
>
{content}
</td>
@ -445,7 +463,11 @@ const ConnectionTable: React.FC<Props> = ({
close(connection.id)
}}
>
{connection.isActive ? <CgClose className="text-lg" /> : <CgTrash className="text-lg" />}
{connection.isActive ? (
<CgClose className="text-lg" />
) : (
<CgTrash className="text-lg" />
)}
</Button>
</td>
</tr>

View File

@ -69,4 +69,4 @@ const InterfaceModal: React.FC<Props> = (props) => {
)
}
export default InterfaceModal
export default InterfaceModal

View File

@ -36,7 +36,10 @@ const EditFileModal: React.FC<Props> = (props) => {
<ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">
{t('override.editFile.title', {
type: language === 'javascript' ? t('override.editFile.script') : t('override.editFile.config')
type:
language === 'javascript'
? t('override.editFile.script')
: t('override.editFile.config')
})}
</ModalHeader>
<ModalBody className="h-full">

View File

@ -125,8 +125,6 @@ const OverrideItem: React.FC<Props> = (props) => {
}
}
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
@ -170,12 +168,7 @@ const OverrideItem: React.FC<Props> = (props) => {
setOpenFileEditor(true)
}}
>
<div
ref={setNodeRef}
{...attributes}
{...listeners}
className="h-full w-full"
>
<div ref={setNodeRef} {...attributes} {...listeners} className="h-full w-full">
<CardBody>
<div className="flex justify-between h-[32px]">
<h3
@ -211,10 +204,7 @@ const OverrideItem: React.FC<Props> = (props) => {
</Button>
)}
<Dropdown
isOpen={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<Dropdown isOpen={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownTrigger>
<Button isIconOnly size="sm" variant="light" color="default">
<IoMdMore color="default" className={`text-[24px]`} />

View File

@ -21,7 +21,7 @@ import { restartCore, addProfileUpdater } from '@renderer/utils/ipc'
import { MdDeleteForever } from 'react-icons/md'
import { FaPlus } from 'react-icons/fa6'
import { useTranslation } from 'react-i18next'
import { isValidCron } from 'cron-validator';
import { isValidCron } from 'cron-validator'
interface Props {
item: IProfileItem
@ -44,7 +44,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
(i) =>
overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global
)
};
}
await updateProfileItem(updatedItem)
await addProfileUpdater(updatedItem)
await restartCore()
@ -132,10 +132,10 @@ const EditInfoModal: React.FC<Props> = (props) => {
className={cn(
inputWidth,
// 不合法
typeof values.interval === 'string' &&
!/^\d+$/.test(values.interval) &&
!isValidCron(values.interval, { seconds: false }) &&
'border-red-500'
typeof values.interval === 'string' &&
!/^\d+$/.test(values.interval) &&
!isValidCron(values.interval, { seconds: false }) &&
'border-red-500'
)}
value={values.interval?.toString() ?? ''}
onValueChange={(v) => {
@ -143,12 +143,12 @@ const EditInfoModal: React.FC<Props> = (props) => {
if (/^[\d\s*\-,\/]*$/.test(v)) {
// 纯数字
if (/^\d+$/.test(v)) {
setValues({ ...values, interval: parseInt(v, 10) || 0 });
return;
setValues({ ...values, interval: parseInt(v, 10) || 0 })
return
}
// 非纯数字
try {
setValues({ ...values, interval: v });
setValues({ ...values, interval: v })
} catch (e) {
// ignore
}
@ -158,22 +158,24 @@ const EditInfoModal: React.FC<Props> = (props) => {
/>
{/* 动态提示信息 */}
<div className="text-xs" style={{
color: typeof values.interval === 'string' &&
!/^\d+$/.test(values.interval) &&
!isValidCron(values.interval, { seconds: false })
<div
className="text-xs"
style={{
color:
typeof values.interval === 'string' &&
!/^\d+$/.test(values.interval) &&
!isValidCron(values.interval, { seconds: false })
? '#ef4444'
: '#6b7280'
}}>
{typeof values.interval === 'number' ? (
t('profiles.editInfo.intervalMinutes')
) : /^\d+$/.test(values.interval?.toString() || '') ? (
t('profiles.editInfo.intervalMinutes')
) : isValidCron(values.interval?.toString() || '', { seconds: false }) ? (
t('profiles.editInfo.intervalCron')
) : (
t('profiles.editInfo.intervalHint')
)}
}}
>
{typeof values.interval === 'number'
? t('profiles.editInfo.intervalMinutes')
: /^\d+$/.test(values.interval?.toString() || '')
? t('profiles.editInfo.intervalMinutes')
: isValidCron(values.interval?.toString() || '', { seconds: false })
? t('profiles.editInfo.intervalCron')
: t('profiles.editInfo.intervalHint')}
</div>
</div>
</SettingItem>

File diff suppressed because it is too large Load Diff

View File

@ -222,7 +222,7 @@ const ProfileItem: React.FC<Props> = (props) => {
updateProfileItem={updateProfileItem}
/>
)}
<Card
as="div"
fullWidth
@ -270,17 +270,9 @@ const ProfileItem: React.FC<Props> = (props) => {
</Tooltip>
)}
<Dropdown
isOpen={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<Dropdown isOpen={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownTrigger>
<Button
isIconOnly
size="sm"
variant="light"
color="default"
>
<Button isIconOnly size="sm" variant="light" color="default">
<IoMdMore
color="default"
className={`text-[24px] ${isCurrent ? 'text-primary-foreground' : 'text-foreground'}`}
@ -316,7 +308,9 @@ const ProfileItem: React.FC<Props> = (props) => {
await patchAppConfig({ profileDisplayDate: 'update' })
}}
>
{extra.expire ? dayjs.unix(extra.expire).format('YYYY-MM-DD') : t('profiles.neverExpire')}
{extra.expire
? dayjs.unix(extra.expire).format('YYYY-MM-DD')
: t('profiles.neverExpire')}
</Button>
) : (
<Button
@ -343,7 +337,7 @@ const ProfileItem: React.FC<Props> = (props) => {
variant="bordered"
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`}
>
{t('profiles.remote')}
{t('profiles.remote')}
</Chip>
<small>{dayjs(info.updated).fromNow()}</small>
</div>
@ -357,14 +351,14 @@ const ProfileItem: React.FC<Props> = (props) => {
variant="bordered"
className={`${isCurrent ? 'text-primary-foreground border-primary-foreground' : 'border-primary text-primary'}`}
>
{t('profiles.local')}
{t('profiles.local')}
</Chip>
</div>
)}
{extra && (
<Progress
className="w-full"
aria-label={t('profiles.trafficUsage')}
aria-label={t('profiles.trafficUsage')}
classNames={{
indicator: isCurrent ? 'bg-primary-foreground' : 'bg-foreground'
}}
@ -378,4 +372,4 @@ const ProfileItem: React.FC<Props> = (props) => {
)
}
export default ProfileItem
export default ProfileItem

View File

@ -22,160 +22,175 @@ function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger'
return 'warning'
}
const ProxyItem: React.FC<Props> = React.memo((props) => {
const { t } = useTranslation()
const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay, isGroupTesting = false } = props
const ProxyItem: React.FC<Props> = React.memo(
(props) => {
const { t } = useTranslation()
const {
mutateProxies,
proxyDisplayMode,
group,
proxy,
selected,
onSelect,
onProxyDelay,
isGroupTesting = false
} = props
const delay = useMemo(() => {
if (proxy.history.length > 0) {
return proxy.history[proxy.history.length - 1].delay
}
return -1
}, [proxy.history])
const delay = useMemo(() => {
if (proxy.history.length > 0) {
return proxy.history[proxy.history.length - 1].delay
}
return -1
}, [proxy.history])
const [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(false)
const isLoading = loading || isGroupTesting
const isLoading = loading || isGroupTesting
const delayText = useMemo(() => {
if (delay === -1) return t('proxies.delay.test')
if (delay === 0) return t('proxies.delay.timeout')
return delay.toString()
}, [delay, t])
const delayText = useMemo(() => {
if (delay === -1) return t('proxies.delay.test')
if (delay === 0) return t('proxies.delay.timeout')
return delay.toString()
}, [delay, t])
const onDelay = useCallback((): void => {
setLoading(true)
onProxyDelay(proxy.name, group.testUrl).finally(() => {
mutateProxies()
setLoading(false)
})
}, [proxy.name, group.testUrl, onProxyDelay, mutateProxies])
const onDelay = useCallback((): void => {
setLoading(true)
onProxyDelay(proxy.name, group.testUrl).finally(() => {
mutateProxies()
setLoading(false)
})
}, [proxy.name, group.testUrl, onProxyDelay, mutateProxies])
const fixed = useMemo(() => group.fixed && group.fixed === proxy.name, [group.fixed, proxy.name])
const fixed = useMemo(
() => group.fixed && group.fixed === proxy.name,
[group.fixed, proxy.name]
)
return (
<Card
as="div"
onPress={() => onSelect(group.name, proxy.name)}
isPressable
fullWidth
shadow="sm"
className={`${
fixed
? 'bg-secondary/30 border-r-2 border-r-secondary border-l-2 border-l-secondary'
: selected
? 'bg-primary/30 border-r-2 border-r-primary border-l-2 border-l-primary'
: 'bg-content2'
}`}
radius="sm"
>
<CardBody className="p-1">
{proxyDisplayMode === 'full' ? (
<div className="flex flex-col gap-1">
return (
<Card
as="div"
onPress={() => onSelect(group.name, proxy.name)}
isPressable
fullWidth
shadow="sm"
className={`${
fixed
? 'bg-secondary/30 border-r-2 border-r-secondary border-l-2 border-l-secondary'
: selected
? 'bg-primary/30 border-r-2 border-r-primary border-l-2 border-l-primary'
: 'bg-content2'
}`}
radius="sm"
>
<CardBody className="p-1">
{proxyDisplayMode === 'full' ? (
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center pl-1">
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
<div className="flag-emoji inline" title={proxy.name}>
{proxy.name}
</div>
</div>
{fixed && (
<Button
isIconOnly
title={t('proxies.unpin')}
color="danger"
onPress={async () => {
await mihomoUnfixedProxy(group.name)
mutateProxies()
}}
variant="light"
className="h-[20px] p-0 text-sm"
>
<FaMapPin className="text-md le" />
</Button>
)}
</div>
<div className="flex justify-between items-center pl-1">
<div className="flex gap-1 items-center">
<div className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
{proxy.type}
</div>
{['tfo', 'udp', 'xudp', 'mptcp', 'smux'].map(
(protocol) =>
proxy[protocol as keyof IMihomoProxy] && (
<div
key={protocol}
className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md"
>
{protocol}
</div>
)
)}
</div>
<Button
isIconOnly
title={proxy.type}
isLoading={isLoading}
color={delayColor(delay)}
onPress={onDelay}
variant="light"
className="h-full text-sm ml-auto -mt-0.5 px-2 relative w-min whitespace-nowrap"
>
<div className="w-full h-full flex items-center justify-end">{delayText}</div>
</Button>
</div>
</div>
) : (
<div className="flex justify-between items-center pl-1">
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
<div className="flag-emoji inline" title={proxy.name}>
{proxy.name}
</div>
</div>
{fixed && (
<div className="flex justify-end">
{fixed && (
<Button
isIconOnly
title={t('proxies.unpin')}
color="danger"
onPress={async () => {
await mihomoUnfixedProxy(group.name)
mutateProxies()
}}
variant="light"
className="h-[20px] p-0 text-sm"
>
<FaMapPin className="text-md le" />
</Button>
)}
<Button
isIconOnly
title={t('proxies.unpin')}
color="danger"
onPress={async () => {
await mihomoUnfixedProxy(group.name)
mutateProxies()
}}
title={proxy.type}
isLoading={isLoading}
color={delayColor(delay)}
onPress={onDelay}
variant="light"
className="h-[20px] p-0 text-sm"
className="h-full text-sm px-2 relative w-min whitespace-nowrap"
>
<FaMapPin className="text-md le" />
<div className="w-full h-full flex items-center justify-end">{delayText}</div>
</Button>
)}
</div>
<div className="flex justify-between items-center pl-1">
<div className="flex gap-1 items-center">
<div className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
{proxy.type}
</div>
{['tfo', 'udp', 'xudp', 'mptcp', 'smux'].map(protocol =>
proxy[protocol as keyof IMihomoProxy] && (
<div key={protocol} className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
{protocol}
</div>
)
)}
</div>
<Button
isIconOnly
title={proxy.type}
isLoading={isLoading}
color={delayColor(delay)}
onPress={onDelay}
variant="light"
className="h-full text-sm ml-auto -mt-0.5 px-2 relative w-min whitespace-nowrap"
>
<div className="w-full h-full flex items-center justify-end">
{delayText}
</div>
</Button>
</div>
</div>
) : (
<div className="flex justify-between items-center pl-1">
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
<div className="flag-emoji inline" title={proxy.name}>
{proxy.name}
</div>
</div>
<div className="flex justify-end">
{fixed && (
<Button
isIconOnly
title={t('proxies.unpin')}
color="danger"
onPress={async () => {
await mihomoUnfixedProxy(group.name)
mutateProxies()
}}
variant="light"
className="h-[20px] p-0 text-sm"
>
<FaMapPin className="text-md le" />
</Button>
)}
<Button
isIconOnly
title={proxy.type}
isLoading={isLoading}
color={delayColor(delay)}
onPress={onDelay}
variant="light"
className="h-full text-sm px-2 relative w-min whitespace-nowrap"
>
<div className="w-full h-full flex items-center justify-end">
{delayText}
</div>
</Button>
</div>
</div>
)}
</CardBody>
</Card>
)
}, (prevProps, nextProps) => {
// 必要时重新渲染
return (
prevProps.proxy.name === nextProps.proxy.name &&
prevProps.proxy.history === nextProps.proxy.history &&
prevProps.selected === nextProps.selected &&
prevProps.proxyDisplayMode === nextProps.proxyDisplayMode &&
prevProps.group.fixed === nextProps.group.fixed &&
prevProps.isGroupTesting === nextProps.isGroupTesting
)
})
)}
</CardBody>
</Card>
)
},
(prevProps, nextProps) => {
// 必要时重新渲染
return (
prevProps.proxy.name === nextProps.proxy.name &&
prevProps.proxy.history === nextProps.proxy.history &&
prevProps.selected === nextProps.selected &&
prevProps.proxyDisplayMode === nextProps.proxyDisplayMode &&
prevProps.group.fixed === nextProps.group.fixed &&
prevProps.isGroupTesting === nextProps.isGroupTesting
)
}
)
ProxyItem.displayName = 'ProxyItem'
export default ProxyItem
export default ProxyItem

View File

@ -30,7 +30,7 @@ const ProxyProvider: React.FC = () => {
if (showDetails.title) {
const fetchProviderPath = async (name: string): Promise<void> => {
try {
const providers= await getRuntimeConfig()
const providers = await getRuntimeConfig()
const provider = providers['proxy-providers'][name]
if (provider) {
setShowDetails((prev) => ({
@ -89,7 +89,9 @@ const ProxyProvider: React.FC = () => {
type={showDetails.type}
title={showDetails.title}
privderType={showDetails.privderType}
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', privderType: '' })}
onClose={() =>
setShowDetails({ show: false, path: '', type: '', title: '', privderType: '' })
}
/>
)}
<SettingItem title={t('resources.proxyProviders.title')} divider>
@ -123,7 +125,9 @@ const ProxyProvider: React.FC = () => {
</Button> */}
<Button
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"
size="sm"
onPress={() => {

View File

@ -32,7 +32,7 @@ const RuleProvider: React.FC = () => {
if (showDetails.title) {
const fetchProviderPath = async (name: string): Promise<void> => {
try {
const providers= await getRuntimeConfig()
const providers = await getRuntimeConfig()
const provider = providers['rule-providers'][name]
if (provider) {
setShowDetails((prev) => ({
@ -97,7 +97,17 @@ const RuleProvider: React.FC = () => {
format={showDetails.format}
privderType={showDetails.privderType}
behavior={showDetails.behavior}
onClose={() => setShowDetails({ show: false, path: '', type: '', title: '', format: '', privderType: '', behavior: '' })}
onClose={() =>
setShowDetails({
show: false,
path: '',
type: '',
title: '',
format: '',
privderType: '',
behavior: ''
})
}
/>
)}
<SettingItem title={t('resources.ruleProviders.title')} divider>
@ -127,7 +137,9 @@ const RuleProvider: React.FC = () => {
<div>{dayjs(provider.updatedAt).fromNow()}</div>
<Button
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"
size="sm"
onPress={() => {

View File

@ -54,13 +54,17 @@ const Viewer: React.FC<Props> = (props) => {
try {
const parsedYaml = yaml.load(fileContent)
if (privderType === 'proxy-providers') {
setCurrData(yaml.dump({
'proxies': parsedYaml[privderType][title].payload
}))
setCurrData(
yaml.dump({
proxies: parsedYaml[privderType][title].payload
})
)
} else {
setCurrData(yaml.dump({
'rules': parsedYaml[privderType][title].payload
}))
setCurrData(
yaml.dump({
rules: parsedYaml[privderType][title].payload
})
)
}
} catch (error) {
setCurrData(fileContent)

View File

@ -65,8 +65,8 @@ const Actions: React.FC = () => {
setChangelog(version.changelog)
setOpenUpdate(true)
} else {
new window.Notification(t('actions.update.upToDate.title'), {
body: t('actions.update.upToDate.body')
new window.Notification(t('actions.update.upToDate.title'), {
body: t('actions.update.upToDate.body')
})
}
} catch (e) {

View File

@ -140,7 +140,8 @@ const GeneralConfig: React.FC = () => {
onValueChange={async (v) => {
try {
// 检查管理员权限
const hasAdminPrivileges = await window.electron.ipcRenderer.invoke('checkAdminPrivileges')
const hasAdminPrivileges =
await window.electron.ipcRenderer.invoke('checkAdminPrivileges')
if (!hasAdminPrivileges) {
const notification = new Notification(t('settings.autoStart.permissions'))
@ -286,10 +287,7 @@ const GeneralConfig: React.FC = () => {
}}
/>
</SettingItem>
<SettingItem
title={t('settings.floatingWindowCompatMode')}
divider
>
<SettingItem title={t('settings.floatingWindowCompatMode')} divider>
<div className="flex items-center gap-2">
<Switch
size="sm"
@ -309,47 +307,47 @@ const GeneralConfig: React.FC = () => {
</SettingItem>
</>
)}
<SettingItem title={t('settings.disableTray')} divider>
<Switch
size="sm"
isSelected={disableTray}
onValueChange={async (v) => {
await patchAppConfig({ disableTray: v })
if (v) {
<SettingItem title={t('settings.disableTray')} divider>
<Switch
size="sm"
isSelected={disableTray}
onValueChange={async (v) => {
await patchAppConfig({ disableTray: v })
if (v) {
closeTrayIcon()
} else {
showTrayIcon()
}
}}
/>
</SettingItem>
{!disableTray && (
<>
<SettingItem title={t('settings.swapTrayClick')} divider>
<Switch
size="sm"
isSelected={swapTrayClick}
onValueChange={async (v) => {
await patchAppConfig({ swapTrayClick: v })
closeTrayIcon()
} else {
showTrayIcon()
}
}}
/>
</SettingItem>
{!disableTray && (
<>
<SettingItem title={t('settings.swapTrayClick')} divider>
<Switch
size="sm"
isSelected={swapTrayClick}
onValueChange={async (v) => {
await patchAppConfig({ swapTrayClick: v })
closeTrayIcon()
setTimeout(() => {
showTrayIcon()
}, 100)
}}
/>
</SettingItem>
<SettingItem title={t('settings.disableTrayIconColor')} divider>
<Switch
size="sm"
isSelected={disableTrayIconColor}
onValueChange={async (v) => {
await patchAppConfig({ disableTrayIconColor: v })
await updateTrayIcon()
}}
/>
</SettingItem>
</>
)}
setTimeout(() => {
showTrayIcon()
}, 100)
}}
/>
</SettingItem>
<SettingItem title={t('settings.disableTrayIconColor')} divider>
<Switch
size="sm"
isSelected={disableTrayIconColor}
onValueChange={async (v) => {
await patchAppConfig({ disableTrayIconColor: v })
await updateTrayIcon()
}}
/>
</SettingItem>
</>
)}
{platform !== 'linux' && (
<>
<SettingItem title={t('settings.proxyInTray')} divider>

View File

@ -57,20 +57,12 @@ const LocalBackupConfig: React.FC = () => {
/>
<SettingCard title={t('localBackup.title')}>
<SettingItem title={t('localBackup.export.title')} divider>
<Button
isLoading={exporting}
size="sm"
onPress={handleExport}
>
<Button isLoading={exporting} size="sm" onPress={handleExport}>
{t('localBackup.export.button')}
</Button>
</SettingItem>
<SettingItem title={t('localBackup.import.title')}>
<Button
isLoading={importing}
size="sm"
onPress={onOpen}
>
<Button isLoading={importing} size="sm" onPress={onOpen}>
{t('localBackup.import.button')}
</Button>
</SettingItem>
@ -79,4 +71,4 @@ const LocalBackupConfig: React.FC = () => {
)
}
export default LocalBackupConfig
export default LocalBackupConfig

View File

@ -183,9 +183,13 @@ const MihomoConfig: React.FC = () => {
>
<SelectItem key="PRIORITY_HIGHEST">{t('mihomo.cpuPriority.realtime')}</SelectItem>
<SelectItem key="PRIORITY_HIGH">{t('mihomo.cpuPriority.high')}</SelectItem>
<SelectItem key="PRIORITY_ABOVE_NORMAL">{t('mihomo.cpuPriority.aboveNormal')}</SelectItem>
<SelectItem key="PRIORITY_ABOVE_NORMAL">
{t('mihomo.cpuPriority.aboveNormal')}
</SelectItem>
<SelectItem key="PRIORITY_NORMAL">{t('mihomo.cpuPriority.normal')}</SelectItem>
<SelectItem key="PRIORITY_BELOW_NORMAL">{t('mihomo.cpuPriority.belowNormal')}</SelectItem>
<SelectItem key="PRIORITY_BELOW_NORMAL">
{t('mihomo.cpuPriority.belowNormal')}
</SelectItem>
<SelectItem key="PRIORITY_LOW">{t('mihomo.cpuPriority.low')}</SelectItem>
</Select>
</SettingItem>
@ -215,7 +219,6 @@ const MihomoConfig: React.FC = () => {
/>
</SettingItem>
<SettingItem title={t('mihomo.autoCloseConnection')} divider>
<Switch
size="sm"

View File

@ -37,8 +37,22 @@ const WebdavConfig: React.FC = () => {
webdavIgnoreCert
})
const setWebdavDebounce = debounce(
({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups, webdavBackupCron }) => {
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups, webdavBackupCron })
({
webdavUrl,
webdavUsername,
webdavPassword,
webdavDir,
webdavMaxBackups,
webdavBackupCron
}) => {
patchAppConfig({
webdavUrl,
webdavUsername,
webdavPassword,
webdavDir,
webdavMaxBackups,
webdavBackupCron
})
},
500
)
@ -154,7 +168,7 @@ const WebdavConfig: React.FC = () => {
<SettingItem title={t('webdav.backup.cron.title')} divider>
<div className="flex w-[60%] gap-2">
{webdavBackupCron !== webdav.webdavBackupCron && (
<Button
<Button
size="sm"
color="primary"
onPress={async () => {
@ -184,7 +198,6 @@ const WebdavConfig: React.FC = () => {
setWebdav({ ...webdav, webdavBackupCron: v })
}}
/>
</div>
</SettingItem>
<div className="flex justify0between">

View File

@ -31,45 +31,48 @@ const WebdavRestoreModal: React.FC<Props> = (props) => {
{filenames.length === 0 ? (
<div className="flex justify-center">{t('webdav.restore.noBackups')}</div>
) : (
filenames.sort().reverse().map((filename) => (
<div className="flex" key={filename}>
<Button
size="sm"
fullWidth
isLoading={restoring}
variant="flat"
onPress={async () => {
setRestoring(true)
try {
await webdavRestore(filename)
await relaunchApp()
} catch (e) {
toast.error(t('common.error.restoreFailed', { error: e }))
} finally {
setRestoring(false)
}
}}
>
{filename}
</Button>
<Button
size="sm"
color="warning"
variant="flat"
className="ml-2"
onPress={async () => {
try {
await webdavDelete(filename)
setFilenames(filenames.filter((name) => name !== filename))
} catch (e) {
toast.error(t('common.error.deleteFailed', { error: e }))
}
}}
>
<MdDeleteForever className="text-lg" />
</Button>
</div>
))
filenames
.sort()
.reverse()
.map((filename) => (
<div className="flex" key={filename}>
<Button
size="sm"
fullWidth
isLoading={restoring}
variant="flat"
onPress={async () => {
setRestoring(true)
try {
await webdavRestore(filename)
await relaunchApp()
} catch (e) {
toast.error(t('common.error.restoreFailed', { error: e }))
} finally {
setRestoring(false)
}
}}
>
{filename}
</Button>
<Button
size="sm"
color="warning"
variant="flat"
className="ml-2"
onPress={async () => {
try {
await webdavDelete(filename)
setFilenames(filenames.filter((name) => name !== filename))
} catch (e) {
toast.error(t('common.error.deleteFailed', { error: e }))
}
}}
>
<MdDeleteForever className="text-lg" />
</Button>
</div>
))
)}
</ModalBody>
<ModalFooter>

View File

@ -36,7 +36,12 @@ interface Props {
const ConnCard: React.FC<Props> = (props) => {
const { iconOnly } = props
const { appConfig } = useAppConfig()
const { showTraffic = false, connectionCardStatus = 'col-span-2', disableAnimations = false, hideConnectionCardWave = false } = appConfig || {}
const {
showTraffic = false,
connectionCardStatus = 'col-span-2',
disableAnimations = false,
hideConnectionCardWave = false
} = appConfig || {}
const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/connections')

View File

@ -18,7 +18,11 @@ const DNSCard: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig()
const { iconOnly } = props
const { dnsCardStatus = 'col-span-1', controlDns = true, disableAnimations = false } = appConfig || {}
const {
dnsCardStatus = 'col-span-1',
controlDns = true,
disableAnimations = false
} = appConfig || {}
const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/dns')

View File

@ -34,9 +34,21 @@ const OutboundModeSwitcher: React.FC = () => {
}}
onSelectionChange={(key: Key) => onChangeMode(key as OutboundMode)}
>
<Tab className={`${mode === 'rule' ? 'font-bold' : ''}`} key="rule" title={t('sider.cards.outbound.rule')} />
<Tab className={`${mode === 'global' ? 'font-bold' : ''}`} key="global" title={t('sider.cards.outbound.global')} />
<Tab className={`${mode === 'direct' ? 'font-bold' : ''}`} key="direct" title={t('sider.cards.outbound.direct')} />
<Tab
className={`${mode === 'rule' ? 'font-bold' : ''}`}
key="rule"
title={t('sider.cards.outbound.rule')}
/>
<Tab
className={`${mode === 'global' ? 'font-bold' : ''}`}
key="global"
title={t('sider.cards.outbound.global')}
/>
<Tab
className={`${mode === 'direct' ? 'font-bold' : ''}`}
key="direct"
title={t('sider.cards.outbound.direct')}
/>
</Tabs>
)
}

View File

@ -26,7 +26,11 @@ const ProfileCard: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig()
const { iconOnly } = props
const { profileCardStatus = 'col-span-2', profileDisplayDate = 'expire', disableAnimations = false } = appConfig || {}
const {
profileCardStatus = 'col-span-2',
profileDisplayDate = 'expire',
disableAnimations = false
} = appConfig || {}
const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/profiles')
@ -158,7 +162,9 @@ const ProfileCard: React.FC<Props> = (props) => {
await patchAppConfig({ profileDisplayDate: 'update' })
}}
>
{extra.expire ? dayjs.unix(extra.expire).format('YYYY-MM-DD') : t('sider.cards.neverExpire')}
{extra.expire
? dayjs.unix(extra.expire).format('YYYY-MM-DD')
: t('sider.cards.neverExpire')}
</Button>
) : (
<Button

View File

@ -18,7 +18,11 @@ const SniffCard: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig()
const { iconOnly } = props
const { sniffCardStatus = 'col-span-1', controlSniff = true, disableAnimations = false } = appConfig || {}
const {
sniffCardStatus = 'col-span-1',
controlSniff = true,
disableAnimations = false
} = appConfig || {}
const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/sniffer')

View File

@ -15,7 +15,11 @@ const SubStoreCard: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { appConfig } = useAppConfig()
const { iconOnly } = props
const { substoreCardStatus = 'col-span-1', useSubStore = true, disableAnimations = false } = appConfig || {}
const {
substoreCardStatus = 'col-span-1',
useSubStore = true,
disableAnimations = false
} = appConfig || {}
const location = useLocation()
const navigate = useNavigate()
const match = location.pathname.includes('/substore')

View File

@ -40,14 +40,14 @@ const SysproxySwitcher: React.FC<Props> = (props) => {
const onChange = async (enable: boolean): Promise<void> => {
const previousState = !enable
const tunEnabled = tun?.enable ?? false
// 立即更新图标
updateTrayIconImmediate(enable, tunEnabled)
try {
await patchAppConfig({ sysProxy: { enable } })
await triggerSysProxy(enable)
window.electron.ipcRenderer.send('updateFloatingWindow')
window.electron.ipcRenderer.send('updateTrayMenu')
await updateTrayIcon()

View File

@ -42,7 +42,9 @@ const TunSwitcher: React.FC<Props> = (props) => {
if (enable) {
try {
// 检查内核权限
const hasPermissions = await window.electron.ipcRenderer.invoke('checkMihomoCorePermissions')
const hasPermissions = await window.electron.ipcRenderer.invoke(
'checkMihomoCorePermissions'
)
if (!hasPermissions) {
if (window.electron.process.platform === 'win32') {
@ -55,7 +57,11 @@ const TunSwitcher: React.FC<Props> = (props) => {
return
} catch (error) {
console.error('Failed to restart as admin:', error)
await window.electron.ipcRenderer.invoke('showErrorDialog', t('tun.permissions.failed'), String(error))
await window.electron.ipcRenderer.invoke(
'showErrorDialog',
t('tun.permissions.failed'),
String(error)
)
updateTrayIconImmediate(sysProxyEnabled, false)
return
}
@ -69,7 +75,11 @@ const TunSwitcher: React.FC<Props> = (props) => {
await window.electron.ipcRenderer.invoke('requestTunPermissions')
} catch (error) {
console.warn('Permission grant failed:', error)
await window.electron.ipcRenderer.invoke('showErrorDialog', t('tun.permissions.failed'), String(error))
await window.electron.ipcRenderer.invoke(
'showErrorDialog',
t('tun.permissions.failed'),
String(error)
)
updateTrayIconImmediate(sysProxyEnabled, false)
return
}

View File

@ -1,4 +1,12 @@
import { Button, Code, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/react'
import {
Button,
Code,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from '@heroui/react'
import { toast } from '@renderer/components/base/toast'
import ReactMarkdown from 'react-markdown'
import React, { useState } from 'react'

View File

@ -15,4 +15,4 @@ i18n.on('languageChanged', (lng) => {
window.electron.ipcRenderer.invoke('changeLanguage', lng)
})
export default i18n
export default i18n

View File

@ -499,10 +499,12 @@
"rules.title": "Rules",
"rules.filter": "Filter Rules",
"override.title": "Override",
"override.input.placeholder": "Enter override URL",
"override.import": "Import",
"override.docs": "Documentation",
"override.repository": "Override Repository",
"override.unsupportedFileType": "Unsupported file type",
"override.error.importFailed": "Import failed: {{error}}",
"override.actions.open": "Open",
"override.actions.newYaml": "New YAML",
"override.actions.newJs": "New JavaScript",

View File

@ -468,10 +468,12 @@
"rules.title": "قوانین",
"rules.filter": "فیلتر قوانین",
"override.title": "جایگزینی",
"override.input.placeholder": "وارد کردن URL جایگزین",
"override.import": "وارد کردن",
"override.docs": "مستندات",
"override.repository": "مخزن جایگزینی",
"override.unsupportedFileType": "نوع فایل پشتیبانی نمی‌شود",
"override.error.importFailed": "وارد کردن ناموفق بود: {{error}}",
"override.actions.open": "باز کردن",
"override.actions.newYaml": "YAML جدید",
"override.actions.newJs": "جاوااسکریپت جدید",

View File

@ -468,10 +468,12 @@
"rules.title": "Правила",
"rules.filter": "Фильтр правил",
"override.title": "Переопределение",
"override.input.placeholder": "Введите URL переопределения",
"override.import": "Импорт",
"override.docs": "Документация",
"override.repository": "Репозиторий переопределений",
"override.unsupportedFileType": "Неподдерживаемый тип файла",
"override.error.importFailed": "Не удалось импортировать: {{error}}",
"override.actions.open": "Открыть",
"override.actions.newYaml": "Новый YAML",
"override.actions.newJs": "Новый JavaScript",

View File

@ -499,10 +499,12 @@
"rules.title": "分流规则",
"rules.filter": "筛选过滤",
"override.title": "覆写",
"override.input.placeholder": "输入覆写 URL",
"override.import": "导入",
"override.docs": "使用文档",
"override.repository": "常用覆写仓库",
"override.unsupportedFileType": "不支持的文件类型",
"override.error.importFailed": "导入失败:{{error}}",
"override.actions.open": "打开",
"override.actions.newYaml": "新建 YAML",
"override.actions.newJs": "新建 JavaScript",

View File

@ -499,10 +499,12 @@
"rules.title": "分流規則",
"rules.filter": "篩選過濾",
"override.title": "覆寫",
"override.input.placeholder": "輸入覆寫 URL",
"override.import": "匯入",
"override.docs": "使用文檔",
"override.repository": "常用覆寫倉庫",
"override.unsupportedFileType": "不支持的檔案類型",
"override.error.importFailed": "匯入失敗:{{error}}",
"override.actions.open": "打開",
"override.actions.newYaml": "新建 YAML",
"override.actions.newJs": "新建 JavaScript",

View File

@ -30,8 +30,18 @@ const Connections: React.FC = () => {
connectionOrderBy = 'time',
connectionViewMode = 'list',
connectionTableColumns = [
'status', 'establishTime', 'type', 'host', 'process', 'rule',
'proxyChain', 'remoteDestination', 'uploadSpeed', 'downloadSpeed', 'upload', 'download'
'status',
'establishTime',
'type',
'host',
'process',
'rule',
'proxyChain',
'remoteDestination',
'uploadSpeed',
'downloadSpeed',
'upload',
'download'
],
connectionTableColumnWidths,
connectionTableSortColumn,
@ -64,26 +74,33 @@ const Connections: React.FC = () => {
)
}, [selected, activeConnections, closedConnections])
const handleColumnWidthChange = useCallback(async (widths: Record<string, number>) => {
await patchAppConfig({ connectionTableColumnWidths: widths })
}, [patchAppConfig])
const handleColumnWidthChange = useCallback(
async (widths: Record<string, number>) => {
await patchAppConfig({ connectionTableColumnWidths: widths })
},
[patchAppConfig]
)
const handleSortChange = useCallback(async (column: string | null, direction: 'asc' | 'desc') => {
await patchAppConfig({
connectionTableSortColumn: column || undefined,
connectionTableSortDirection: direction
})
}, [patchAppConfig])
const handleSortChange = useCallback(
async (column: string | null, direction: 'asc' | 'desc') => {
await patchAppConfig({
connectionTableSortColumn: column || undefined,
connectionTableSortDirection: direction
})
},
[patchAppConfig]
)
const filteredConnections = useMemo(() => {
const connections = tab === 'active' ? activeConnections : closedConnections
const filtered = filter === ''
? connections
: connections.filter((connection) => {
const raw = JSON.stringify(connection)
return includesIgnoreCase(raw, filter)
})
const filtered =
filter === ''
? connections
: connections.filter((connection) => {
const raw = JSON.stringify(connection)
return includesIgnoreCase(raw, filter)
})
if (viewMode === 'list' && connectionOrderBy) {
return [...filtered].sort((a, b) => {
@ -110,15 +127,26 @@ const Connections: React.FC = () => {
}
return filtered
}, [activeConnections, closedConnections, tab, filter, connectionDirection, connectionOrderBy, viewMode])
}, [
activeConnections,
closedConnections,
tab,
filter,
connectionDirection,
connectionOrderBy,
viewMode
])
const closeAllConnections = useCallback((): void => {
tab === 'active' ? mihomoCloseAllConnections() : trashAllClosedConnection()
}, [tab])
const closeConnection = useCallback((id: string): void => {
tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id)
}, [tab])
const closeConnection = useCallback(
(id: string): void => {
tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id)
},
[tab]
)
const trashAllClosedConnection = (): void => {
setClosedConnections((closedConns) => {
@ -211,7 +239,11 @@ const Connections: React.FC = () => {
>
<Button
className="app-nodrag ml-1"
title={viewMode === 'list' ? t('connections.table.switchToTable') : t('connections.table.switchToList')}
title={
viewMode === 'list'
? t('connections.table.switchToTable')
: t('connections.table.switchToList')
}
isIconOnly
size="sm"
variant="light"
@ -221,7 +253,11 @@ const Connections: React.FC = () => {
await patchAppConfig({ connectionViewMode: newMode })
}}
>
{viewMode === 'list' ? <MdTableChart className="text-lg" /> : <MdViewList className="text-lg" />}
{viewMode === 'list' ? (
<MdTableChart className="text-lg" />
) : (
<MdViewList className="text-lg" />
)}
</Button>
<Button
className="app-nodrag ml-1"
@ -256,7 +292,10 @@ const Connections: React.FC = () => {
}
>
{isDetailModalOpen && selectedConnection && (
<ConnectionDetailModal onClose={() => setIsDetailModalOpen(false)} connection={selectedConnection} />
<ConnectionDetailModal
onClose={() => setIsDetailModalOpen(false)}
connection={selectedConnection}
/>
)}
<div className="overflow-x-auto sticky top-0 z-40">
<div className="flex p-2 gap-2">
@ -333,7 +372,9 @@ const Connections: React.FC = () => {
}}
>
<DropdownItem key="status">{t('connections.detail.status')}</DropdownItem>
<DropdownItem key="establishTime">{t('connections.detail.establishTime')}</DropdownItem>
<DropdownItem key="establishTime">
{t('connections.detail.establishTime')}
</DropdownItem>
<DropdownItem key="type">{t('connections.detail.connectionType')}</DropdownItem>
<DropdownItem key="host">{t('connections.detail.host')}</DropdownItem>
<DropdownItem key="sniffHost">{t('connections.detail.sniffHost')}</DropdownItem>
@ -343,7 +384,9 @@ const Connections: React.FC = () => {
<DropdownItem key="proxyChain">{t('connections.detail.proxyChain')}</DropdownItem>
<DropdownItem key="sourceIP">{t('connections.detail.sourceIP')}</DropdownItem>
<DropdownItem key="sourcePort">{t('connections.detail.sourcePort')}</DropdownItem>
<DropdownItem key="destinationPort">{t('connections.detail.destinationPort')}</DropdownItem>
<DropdownItem key="destinationPort">
{t('connections.detail.destinationPort')}
</DropdownItem>
<DropdownItem key="inboundIP">{t('connections.detail.inboundIP')}</DropdownItem>
<DropdownItem key="inboundPort">{t('connections.detail.inboundPort')}</DropdownItem>
<DropdownItem key="uploadSpeed">{t('connections.uploadSpeed')}</DropdownItem>
@ -351,7 +394,9 @@ const Connections: React.FC = () => {
<DropdownItem key="upload">{t('connections.uploadAmount')}</DropdownItem>
<DropdownItem key="download">{t('connections.downloadAmount')}</DropdownItem>
<DropdownItem key="dscp">{t('connections.detail.dscp')}</DropdownItem>
<DropdownItem key="remoteDestination">{t('connections.detail.remoteDestination')}</DropdownItem>
<DropdownItem key="remoteDestination">
{t('connections.detail.remoteDestination')}
</DropdownItem>
<DropdownItem key="dnsMode">{t('connections.detail.dnsMode')}</DropdownItem>
</DropdownMenu>
</Dropdown>

View File

@ -122,12 +122,7 @@ const Logs: React.FC = () => {
ref={virtuosoRef}
data={filteredLogs}
itemContent={(i, log) => (
<LogItem
index={i}
time={log.time}
type={log.type}
payload={log.payload}
/>
<LogItem index={i} time={log.time} type={log.type} payload={log.payload} />
)}
/>
</div>

View File

@ -1,4 +1,20 @@
import { Button, Divider, Input, Select, SelectItem, Switch, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure, Spinner, Chip } from '@heroui/react'
import {
Button,
Divider,
Input,
Select,
SelectItem,
Switch,
Tooltip,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
Spinner,
Chip
} from '@heroui/react'
import BasePage from '@renderer/components/base/base-page'
import { toast } from '@renderer/components/base/toast'
import { showError } from '@renderer/utils/error-display'
@ -8,7 +24,12 @@ import { useAppConfig } from '@renderer/hooks/use-app-config'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { platform } from '@renderer/utils/init'
import { FaNetworkWired } from 'react-icons/fa'
import { IoMdCloudDownload, IoMdInformationCircleOutline, IoMdRefresh, IoMdShuffle } from 'react-icons/io'
import {
IoMdCloudDownload,
IoMdInformationCircleOutline,
IoMdRefresh,
IoMdShuffle
} from 'react-icons/io'
import PubSub from 'pubsub-js'
import {
mihomoUpgrade,
@ -102,22 +123,22 @@ const Mihomo: React.FC = () => {
const [upgrading, setUpgrading] = useState(false)
const [lanOpen, setLanOpen] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const [tags, setTags] = useState<{name: string, zipball_url: string, tarball_url: string}[]>([])
const [tags, setTags] = useState<{ name: string; zipball_url: string; tarball_url: string }[]>([])
const [loadingTags, setLoadingTags] = useState(false)
const [selectedTag, setSelectedTag] = useState(specificVersion || '')
const [installing, setInstalling] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [refreshing, setRefreshing] = useState(false)
// WebUI管理状态
const [isWebUIModalOpen, setIsWebUIModalOpen] = useState(false)
const [allPanels, setAllPanels] = useState<WebUIPanel[]>([])
const [editingPanel, setEditingPanel] = useState<WebUIPanel | null>(null)
const [newPanelName, setNewPanelName] = useState('')
const [newPanelUrl, setNewPanelUrl] = useState('')
const urlInputRef = useRef<HTMLInputElement>(null)
// 解析主机和端口
const parseController = () => {
if (externalController) {
@ -126,12 +147,12 @@ const Mihomo: React.FC = () => {
}
return { host: '127.0.0.1', port: '9090' }
}
const { host, port } = parseController()
// 生成随机端口(范围1024-65535)
const generateRandomPort = () => Math.floor(Math.random() * (65535 - 1024 + 1)) + 1024
// 默认WebUI面板选项
const defaultWebUIPanels: WebUIPanel[] = [
{
@ -153,7 +174,7 @@ const Mihomo: React.FC = () => {
isDefault: true
}
]
// 初始化面板列表
useEffect(() => {
const savedPanels = localStorage.getItem('webui-panels')
@ -163,28 +184,28 @@ const Mihomo: React.FC = () => {
setAllPanels(defaultWebUIPanels)
}
}, [])
// 保存面板列表到localStorage
useEffect(() => {
if (allPanels.length > 0) {
localStorage.setItem('webui-panels', JSON.stringify(allPanels))
}
}, [allPanels])
// 在URL输入框光标处插入或替换变量
const insertVariableAtCursor = (variable: string) => {
if (!urlInputRef.current) return
const input = urlInputRef.current
const start = input.selectionStart || 0
const end = input.selectionEnd || 0
const currentValue = newPanelUrl || ''
// 如果有选中文本,则替换选中的文本
const newValue = currentValue.substring(0, start) + variable + currentValue.substring(end)
setNewPanelUrl(newValue)
// 设置光标位置到插入变量之后
setTimeout(() => {
if (urlInputRef.current) {
@ -194,16 +215,13 @@ const Mihomo: React.FC = () => {
}
}, 0)
}
// 打开WebUI面板
const openWebUI = (panel: WebUIPanel) => {
const url = panel.url
.replace('%host', host)
.replace('%port', port)
.replace('%secret', secret)
const url = panel.url.replace('%host', host).replace('%port', port).replace('%secret', secret)
window.open(url, '_blank')
}
// 添加新面板
const addNewPanel = () => {
if (newPanelName && newPanelUrl) {
@ -218,14 +236,12 @@ const Mihomo: React.FC = () => {
setEditingPanel(null)
}
}
// 更新面板
const updatePanel = () => {
if (editingPanel && newPanelName && newPanelUrl) {
const updatedPanels = allPanels.map(panel =>
panel.id === editingPanel.id
? { ...panel, name: newPanelName, url: newPanelUrl }
: panel
const updatedPanels = allPanels.map((panel) =>
panel.id === editingPanel.id ? { ...panel, name: newPanelName, url: newPanelUrl } : panel
)
setAllPanels(updatedPanels)
setEditingPanel(null)
@ -233,35 +249,35 @@ const Mihomo: React.FC = () => {
setNewPanelUrl('')
}
}
// 删除面板
const deletePanel = (id: string) => {
setAllPanels(allPanels.filter(panel => panel.id !== id))
setAllPanels(allPanels.filter((panel) => panel.id !== id))
}
// 开始编辑面板
const startEditing = (panel: WebUIPanel) => {
setEditingPanel(panel)
setNewPanelName(panel.name)
setNewPanelUrl(panel.url)
}
// 取消编辑
const cancelEditing = () => {
setEditingPanel(null)
setNewPanelName('')
setNewPanelUrl('')
}
// 恢复默认面板
const restoreDefaultPanels = () => {
setAllPanels(defaultWebUIPanels)
}
// 用于高亮显示URL中的变量
const HighlightedUrl: React.FC<{ url: string }> = ({ url }) => {
const parts = url.split(/(%host|%port|%secret)/g)
return (
<p className="text-sm text-default-500 break-all">
{parts.map((part, index) => {
@ -277,14 +293,14 @@ const Mihomo: React.FC = () => {
</p>
)
}
// 可点击的变量标签组件
const ClickableVariableTag: React.FC<{
variable: string;
onClick: (variable: string) => void
const ClickableVariableTag: React.FC<{
variable: string
onClick: (variable: string) => void
}> = ({ variable, onClick }) => {
return (
<span
<span
className="bg-warning-200 text-warning-800 px-1 rounded ml-1 cursor-pointer hover:bg-warning-300"
onClick={() => onClick(variable)}
>
@ -292,7 +308,7 @@ const Mihomo: React.FC = () => {
</span>
)
}
const onChangeNeedRestart = async (patch: Partial<IMihomoConfig>): Promise<void> => {
await patchControledMihomoConfig(patch)
await restartCore()
@ -311,7 +327,7 @@ const Mihomo: React.FC = () => {
PubSub.publish('mihomo-core-changed')
}
}
// 获取GitHub标签列表带缓存
const fetchTags = async (forceRefresh = false) => {
setLoadingTags(true)
@ -326,28 +342,28 @@ const Mihomo: React.FC = () => {
setLoadingTags(false)
}
}
// 安装特定版本的核心
const installSpecificCore = async () => {
if (!selectedTag) return
setInstalling(true)
try {
// 下载并安装特定版本的核心
await installSpecificMihomoCore(selectedTag)
// 更新应用配置
await patchAppConfig({
await patchAppConfig({
core: 'mihomo-specific',
specificVersion: selectedTag
})
// 重启核心
await restartCore()
// 关闭模态框
onClose()
// 通知用户
new Notification(t('mihomo.coreUpgradeSuccess'))
} catch (error) {
@ -357,7 +373,7 @@ const Mihomo: React.FC = () => {
setInstalling(false)
}
}
// 刷新标签列表
const refreshTags = async () => {
setRefreshing(true)
@ -369,7 +385,7 @@ const Mihomo: React.FC = () => {
setRefreshing(false)
}
}
// 打开模态框时获取标签
const handleOpenModal = async () => {
onOpen()
@ -377,40 +393,39 @@ const Mihomo: React.FC = () => {
if (tags.length === 0) {
await fetchTags(false) // 使用缓存
}
// 在后台检查更新
setTimeout(() => {
fetchTags(true) // 强制刷新
}, 100)
}
// 过滤标签
const filteredTags = tags.filter(tag =>
const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(searchTerm.toLowerCase())
)
// 当模态框打开时,确保选中当前版本
useEffect(() => {
if (isOpen && specificVersion) {
setSelectedTag(specificVersion)
}
}, [isOpen, specificVersion])
return (
<>
{lanOpen && <InterfaceModal onClose={() => setLanOpen(false)} />}
<BasePage title={t('mihomo.title')}>
{/* Smart 内核设置 */}
<SettingCard>
<div className={`rounded-md border p-2 transition-all duration-200 ${
enableSmartCore
? 'border-blue-300 bg-blue-50/30 dark:border-blue-700 dark:bg-blue-950/20'
: 'border-gray-300 bg-gray-50/30 dark:border-gray-600 dark:bg-gray-800/20'
}`}>
<SettingItem
title={t('mihomo.enableSmartCore')}
divider
>
<div
className={`rounded-md border p-2 transition-all duration-200 ${
enableSmartCore
? 'border-blue-300 bg-blue-50/30 dark:border-blue-700 dark:bg-blue-950/20'
: 'border-gray-300 bg-gray-50/30 dark:border-gray-600 dark:bg-gray-800/20'
}`}
>
<SettingItem title={t('mihomo.enableSmartCore')} divider>
<Switch
size="sm"
isSelected={enableSmartCore}
@ -499,11 +514,7 @@ const Mihomo: React.FC = () => {
>
<IoMdCloudDownload className="text-lg" />
</Button>
<Button
size="sm"
variant="light"
onPress={handleOpenModal}
>
<Button size="sm" variant="light" onPress={handleOpenModal}>
{t('mihomo.selectSpecificVersion')}
</Button>
</div>
@ -519,12 +530,14 @@ const Mihomo: React.FC = () => {
className="w-[150px]"
size="sm"
aria-label={t('mihomo.selectCoreVersion')}
selectedKeys={new Set([
core
])}
selectedKeys={new Set([core])}
disallowEmptySelection={true}
onSelectionChange={async (v) => {
const selectedCore = v.currentKey as 'mihomo' | 'mihomo-alpha' | 'mihomo-smart' | 'mihomo-specific'
const selectedCore = v.currentKey as
| 'mihomo'
| 'mihomo-alpha'
| 'mihomo-smart'
| 'mihomo-specific'
// 如果切换到特定版本但没有设置specificVersion则打开选择模态框
if (selectedCore === 'mihomo-specific' && !specificVersion) {
handleOpenModal()
@ -597,7 +610,6 @@ const Mihomo: React.FC = () => {
/>
</SettingItem>
<SettingItem
title={
<div className="flex items-center gap-2">
@ -637,11 +649,11 @@ const Mihomo: React.FC = () => {
</div>
</SettingItem>
<SettingItem
title={t('mihomo.smartCoreStrategy')}
>
<SettingItem title={t('mihomo.smartCoreStrategy')}>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-blue-100 dark:data-[hover=true]:bg-blue-900/50' }}
classNames={{
trigger: 'data-[hover=true]:bg-blue-100 dark:data-[hover=true]:bg-blue-900/50'
}}
className="w-[150px]"
size="sm"
aria-label={t('mihomo.smartCoreStrategy')}
@ -653,8 +665,12 @@ const Mihomo: React.FC = () => {
await restartCore()
}}
>
<SelectItem key="sticky-sessions">{t('mihomo.smartCoreStrategyStickySession')}</SelectItem>
<SelectItem key="round-robin">{t('mihomo.smartCoreStrategyRoundRobin')}</SelectItem>
<SelectItem key="sticky-sessions">
{t('mihomo.smartCoreStrategyStickySession')}
</SelectItem>
<SelectItem key="round-robin">
{t('mihomo.smartCoreStrategyRoundRobin')}
</SelectItem>
</Select>
</SettingItem>
</>
@ -1032,9 +1048,9 @@ const Mihomo: React.FC = () => {
</SettingItem>
<SettingItem title={t('settings.webui.title')} divider>
<div className="flex gap-2">
<Button
size="sm"
color="primary"
<Button
size="sm"
color="primary"
isDisabled={!externalController || externalController.trim() === ''}
onPress={() => setIsWebUIModalOpen(true)}
>
@ -1379,7 +1395,7 @@ const Mihomo: React.FC = () => {
<SelectItem key="debug">{t('mihomo.debug')}</SelectItem>
</Select>
</SettingItem>
<SettingItem title={t('mihomo.findProcess')} >
<SettingItem title={t('mihomo.findProcess')}>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[100px]"
@ -1398,10 +1414,10 @@ const Mihomo: React.FC = () => {
</SettingItem>
</SettingCard>
</BasePage>
{/* WebUI 管理模态框 */}
<Modal
isOpen={isWebUIModalOpen}
<Modal
isOpen={isWebUIModalOpen}
onOpenChange={setIsWebUIModalOpen}
size="5xl"
scrollBehavior="inside"
@ -1410,9 +1426,7 @@ const Mihomo: React.FC = () => {
hideCloseButton
>
<ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">
{t('settings.webui.manage')}
</ModalHeader>
<ModalHeader className="flex pb-0 app-drag">{t('settings.webui.manage')}</ModalHeader>
<ModalBody className="flex flex-col h-full">
<div className="flex flex-col h-full">
{/* 添加/编辑面板表单 */}
@ -1439,26 +1453,21 @@ const Mihomo: React.FC = () => {
<div className="flex gap-2">
{editingPanel ? (
<>
<Button
size="sm"
<Button
size="sm"
color="primary"
onPress={updatePanel}
isDisabled={!newPanelName || !newPanelUrl}
>
{t('common.save')}
</Button>
<Button
size="sm"
color="default"
variant="bordered"
onPress={cancelEditing}
>
<Button size="sm" color="default" variant="bordered" onPress={cancelEditing}>
{t('common.cancel')}
</Button>
</>
) : (
<Button
size="sm"
<Button
size="sm"
color="primary"
onPress={addNewPanel}
isDisabled={!newPanelName || !newPanelUrl}
@ -1466,8 +1475,8 @@ const Mihomo: React.FC = () => {
{t('settings.webui.addPanel')}
</Button>
)}
<Button
size="sm"
<Button
size="sm"
color="warning"
variant="bordered"
onPress={restoreDefaultPanels}
@ -1476,36 +1485,34 @@ const Mihomo: React.FC = () => {
</Button>
</div>
</div>
{/* 面板列表 */}
<div className="flex flex-col gap-2 mt-2 overflow-y-auto flex-grow">
<h3 className="text-lg font-semibold">{t('settings.webui.panels')}</h3>
{allPanels.map(panel => (
<div key={panel.id} className="flex items-start justify-between p-3 bg-default-50 rounded-lg flex-shrink-0">
{allPanels.map((panel) => (
<div
key={panel.id}
className="flex items-start justify-between p-3 bg-default-50 rounded-lg flex-shrink-0"
>
<div className="flex-1 mr-2">
<p className="font-medium">{panel.name}</p>
<HighlightedUrl url={panel.url} />
</div>
<div className="flex gap-2">
<Button
isIconOnly
size="sm"
color="primary"
onPress={() => openWebUI(panel)}
>
<Button isIconOnly size="sm" color="primary" onPress={() => openWebUI(panel)}>
<MdOpenInNew />
</Button>
<Button
isIconOnly
size="sm"
<Button
isIconOnly
size="sm"
color="warning"
onPress={() => startEditing(panel)}
>
<MdEdit />
</Button>
<Button
isIconOnly
size="sm"
<Button
isIconOnly
size="sm"
color="danger"
onPress={() => deletePanel(panel.id)}
>
@ -1518,20 +1525,17 @@ const Mihomo: React.FC = () => {
</div>
</ModalBody>
<ModalFooter className="pt-0">
<Button
color="primary"
onPress={() => setIsWebUIModalOpen(false)}
>
<Button color="primary" onPress={() => setIsWebUIModalOpen(false)}>
{t('common.close')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 自定义版本选择模态框 */}
<Modal
isOpen={isOpen}
onClose={onClose}
<Modal
isOpen={isOpen}
onClose={onClose}
size="5xl"
backdrop="blur"
classNames={{ backdrop: 'top-[48px]' }}

View File

@ -55,6 +55,8 @@ const Override: React.FC = () => {
url,
ext: urlObj.pathname.endsWith('.js') ? 'js' : 'yaml'
})
} catch (e) {
toast.error(t('override.error.importFailed', { error: String(e) }))
} finally {
setImporting(false)
}
@ -174,6 +176,7 @@ const Override: React.FC = () => {
<div className="flex p-2">
<Input
size="sm"
placeholder={t('override.input.placeholder')}
value={url}
onValueChange={setUrl}
endContent={

View File

@ -325,61 +325,65 @@ const Profiles: React.FC = () => {
<SubStoreIcon className="text-lg" />
</Button>
</DropdownTrigger>
<DropdownMenu
className="max-h-[calc(100vh-200px)] overflow-y-auto"
onAction={async (key) => {
if (key === 'open-substore') {
navigate('/substore')
} else if (key.toString().startsWith('sub-')) {
setSubStoreImporting(true)
try {
const sub = subs.find(
(sub) => sub.name === key.toString().replace('sub-', '')
)
await addProfileItem({
name: sub?.displayName || sub?.name || '',
substore: !useCustomSubStore,
type: 'remote',
url: useCustomSubStore
? `${customSubStoreUrl}/download/${key.toString().replace('sub-', '')}?target=ClashMeta`
: `/download/${key.toString().replace('sub-', '')}`,
useProxy
})
} catch (e) {
toast.error(String(e))
} finally {
setSubStoreImporting(false)
<DropdownMenu
className="max-h-[calc(100vh-200px)] overflow-y-auto"
onAction={async (key) => {
if (key === 'open-substore') {
navigate('/substore')
} else if (key.toString().startsWith('sub-')) {
setSubStoreImporting(true)
try {
const sub = subs.find(
(sub) => sub.name === key.toString().replace('sub-', '')
)
await addProfileItem({
name: sub?.displayName || sub?.name || '',
substore: !useCustomSubStore,
type: 'remote',
url: useCustomSubStore
? `${customSubStoreUrl}/download/${key.toString().replace('sub-', '')}?target=ClashMeta`
: `/download/${key.toString().replace('sub-', '')}`,
useProxy
})
} catch (e) {
toast.error(String(e))
} finally {
setSubStoreImporting(false)
}
} else if (key.toString().startsWith('collection-')) {
setSubStoreImporting(true)
try {
const collection = collections.find(
(collection) =>
collection.name === key.toString().replace('collection-', '')
)
await addProfileItem({
name: collection?.displayName || collection?.name || '',
type: 'remote',
substore: !useCustomSubStore,
url: useCustomSubStore
? `${customSubStoreUrl}/download/collection/${key.toString().replace('collection-', '')}?target=ClashMeta`
: `/download/collection/${key.toString().replace('collection-', '')}`,
useProxy
})
} catch (e) {
toast.error(String(e))
} finally {
setSubStoreImporting(false)
}
}
} else if (key.toString().startsWith('collection-')) {
setSubStoreImporting(true)
try {
const collection = collections.find(
(collection) =>
collection.name === key.toString().replace('collection-', '')
)
await addProfileItem({
name: collection?.displayName || collection?.name || '',
type: 'remote',
substore: !useCustomSubStore,
url: useCustomSubStore
? `${customSubStoreUrl}/download/collection/${key.toString().replace('collection-', '')}?target=ClashMeta`
: `/download/collection/${key.toString().replace('collection-', '')}`,
useProxy
})
} catch (e) {
toast.error(String(e))
} finally {
setSubStoreImporting(false)
}
}
}}
>
{subStoreMenuItems.map((item) => (
<DropdownItem startContent={item?.icon} key={item.key} showDivider={item.divider}>
{item.children}
</DropdownItem>
))}
</DropdownMenu>
}}
>
{subStoreMenuItems.map((item) => (
<DropdownItem
startContent={item?.icon}
key={item.key}
showDivider={item.divider}
>
{item.children}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
)}
<Dropdown>

View File

@ -26,12 +26,14 @@ const GROUP_EXPAND_STATE_KEY = 'proxy_group_expand_state'
const SCROLL_POSITION_KEY = 'proxy_scroll_position'
// 自定义 hook 用于管理展开状态
const useProxyState = (groups: IMihomoMixedGroup[]): {
virtuosoRef: React.RefObject<GroupedVirtuosoHandle | null>;
isOpen: boolean[];
setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>;
initialTopMostItemIndex: number;
handleRangeChanged: (range: { startIndex: number }) => void;
const useProxyState = (
groups: IMihomoMixedGroup[]
): {
virtuosoRef: React.RefObject<GroupedVirtuosoHandle | null>
isOpen: boolean[]
setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>
initialTopMostItemIndex: number
handleRangeChanged: (range: { startIndex: number }) => void
} => {
const virtuosoRef = useRef<GroupedVirtuosoHandle | null>(null)
@ -56,7 +58,7 @@ const useProxyState = (groups: IMihomoMixedGroup[]): {
console.error('Failed to save scroll position:', error)
}
}, [])
// 初始化展开状态
const [isOpen, setIsOpen] = useState<boolean[]>(() => {
try {
@ -118,9 +120,10 @@ const Proxies: React.FC = () => {
proxyCols = 'auto',
delayTestConcurrency = 50
} = appConfig || {}
const [cols, setCols] = useState(1)
const { virtuosoRef, isOpen, setIsOpen, initialTopMostItemIndex, handleRangeChanged } = useProxyState(groups)
const { virtuosoRef, isOpen, setIsOpen, initialTopMostItemIndex, handleRangeChanged } =
useProxyState(groups)
const [delaying, setDelaying] = useState(Array(groups.length).fill(false))
const [proxyDelaying, setProxyDelaying] = useState<Set<string>>(new Set())
const [searchValue, setSearchValue] = useState(Array(groups.length).fill(''))
@ -172,84 +175,90 @@ const Proxies: React.FC = () => {
return { groupCounts, allProxies }
}, [groups, isOpen, proxyDisplayOrder, cols, searchValue, sortProxies])
const onChangeProxy = useCallback(async (group: string, proxy: string): Promise<void> => {
await mihomoChangeProxy(group, proxy)
if (autoCloseConnection) {
await mihomoCloseAllConnections()
}
mutate()
}, [autoCloseConnection, mutate])
const onChangeProxy = useCallback(
async (group: string, proxy: string): Promise<void> => {
await mihomoChangeProxy(group, proxy)
if (autoCloseConnection) {
await mihomoCloseAllConnections()
}
mutate()
},
[autoCloseConnection, mutate]
)
const onProxyDelay = useCallback(async (proxy: string, url?: string): Promise<IMihomoDelay> => {
return await mihomoProxyDelay(proxy, url)
}, [])
const onGroupDelay = useCallback(async (index: number): Promise<void> => {
if (allProxies[index].length === 0) {
setIsOpen((prev) => {
const newOpen = [...prev]
newOpen[index] = true
return newOpen
})
}
setDelaying((prev) => {
const newDelaying = [...prev]
newDelaying[index] = true
return newDelaying
})
// 管理测试状态
const groupProxies = allProxies[index]
setProxyDelaying((prev) => {
const newSet = new Set(prev)
groupProxies.forEach(proxy => newSet.add(proxy.name))
return newSet
})
try {
// 限制并发数量
const result: Promise<void>[] = []
const runningList: Promise<void>[] = []
for (const proxy of allProxies[index]) {
const promise = Promise.resolve().then(async () => {
try {
await mihomoProxyDelay(proxy.name, groups[index].testUrl)
} catch {
// ignore
} finally {
// 更新状态
setProxyDelaying((prev) => {
const newSet = new Set(prev)
newSet.delete(proxy.name)
return newSet
})
mutate()
}
const onGroupDelay = useCallback(
async (index: number): Promise<void> => {
if (allProxies[index].length === 0) {
setIsOpen((prev) => {
const newOpen = [...prev]
newOpen[index] = true
return newOpen
})
result.push(promise)
const running = promise.then(() => {
runningList.splice(runningList.indexOf(running), 1)
})
runningList.push(running)
if (runningList.length >= (delayTestConcurrency || 50)) {
await Promise.race(runningList)
}
}
await Promise.all(result)
} finally {
setDelaying((prev) => {
const newDelaying = [...prev]
newDelaying[index] = false
newDelaying[index] = true
return newDelaying
})
// 状态清理
// 管理测试状态
const groupProxies = allProxies[index]
setProxyDelaying((prev) => {
const newSet = new Set(prev)
groupProxies.forEach(proxy => newSet.delete(proxy.name))
groupProxies.forEach((proxy) => newSet.add(proxy.name))
return newSet
})
}
}, [allProxies, groups, delayTestConcurrency, mutate, setIsOpen])
try {
// 限制并发数量
const result: Promise<void>[] = []
const runningList: Promise<void>[] = []
for (const proxy of allProxies[index]) {
const promise = Promise.resolve().then(async () => {
try {
await mihomoProxyDelay(proxy.name, groups[index].testUrl)
} catch {
// ignore
} finally {
// 更新状态
setProxyDelaying((prev) => {
const newSet = new Set(prev)
newSet.delete(proxy.name)
return newSet
})
mutate()
}
})
result.push(promise)
const running = promise.then(() => {
runningList.splice(runningList.indexOf(running), 1)
})
runningList.push(running)
if (runningList.length >= (delayTestConcurrency || 50)) {
await Promise.race(runningList)
}
}
await Promise.all(result)
} finally {
setDelaying((prev) => {
const newDelaying = [...prev]
newDelaying[index] = false
return newDelaying
})
// 状态清理
setProxyDelaying((prev) => {
const newSet = new Set(prev)
groupProxies.forEach((proxy) => newSet.delete(proxy.name))
return newSet
})
}
},
[allProxies, groups, delayTestConcurrency, mutate, setIsOpen]
)
const calcCols = useCallback((): number => {
if (proxyCols !== 'auto') {
@ -300,170 +309,200 @@ const Proxies: React.FC = () => {
loadImages()
}, [groups, mutate])
const renderGroupContent = useCallback((index: number) => {
return groups[index] ? (
<div
className={`w-full pt-2 ${index === groupCounts.length - 1 && !isOpen[index] ? 'pb-2' : ''} px-2`}
>
<Card
as="div"
isPressable
fullWidth
onPress={() => {
setIsOpen((prev) => {
const newOpen = [...prev]
newOpen[index] = !prev[index]
return newOpen
})
}}
const renderGroupContent = useCallback(
(index: number) => {
return groups[index] ? (
<div
className={`w-full pt-2 ${index === groupCounts.length - 1 && !isOpen[index] ? 'pb-2' : ''} px-2`}
>
<CardBody className="w-full">
<div className="flex justify-between">
<div className="flex text-ellipsis overflow-hidden whitespace-nowrap">
{groups[index].icon ? (
<Avatar
className="bg-transparent mr-2"
size="sm"
radius="sm"
src={
groups[index].icon.startsWith('<svg')
? `data:image/svg+xml;utf8,${groups[index].icon}`
: localStorage.getItem(groups[index].icon) || groups[index].icon
}
/>
) : null}
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
<div
title={groups[index].name}
className="inline flag-emoji h-[32px] text-md leading-[32px]"
>
{groups[index].name}
</div>
{proxyDisplayMode === 'full' && (
<Card
as="div"
isPressable
fullWidth
onPress={() => {
setIsOpen((prev) => {
const newOpen = [...prev]
newOpen[index] = !prev[index]
return newOpen
})
}}
>
<CardBody className="w-full">
<div className="flex justify-between">
<div className="flex text-ellipsis overflow-hidden whitespace-nowrap">
{groups[index].icon ? (
<Avatar
className="bg-transparent mr-2"
size="sm"
radius="sm"
src={
groups[index].icon.startsWith('<svg')
? `data:image/svg+xml;utf8,${groups[index].icon}`
: localStorage.getItem(groups[index].icon) || groups[index].icon
}
/>
) : null}
<div className="text-ellipsis overflow-hidden whitespace-nowrap">
<div
title={groups[index].type}
className="inline ml-2 text-sm text-foreground-500"
title={groups[index].name}
className="inline flag-emoji h-[32px] text-md leading-[32px]"
>
{groups[index].type}
{groups[index].name}
</div>
)}
{proxyDisplayMode === 'full' && (
<div
title={groups[index].type}
className="inline ml-2 text-sm text-foreground-500"
>
{groups[index].type}
</div>
)}
{proxyDisplayMode === 'full' && (
<div className="inline flag-emoji ml-2 text-sm text-foreground-500">
{groups[index].now}
</div>
)}
</div>
</div>
<div className="flex">
{proxyDisplayMode === 'full' && (
<div className="inline flag-emoji ml-2 text-sm text-foreground-500">
{groups[index].now}
</div>
<Chip size="sm" className="my-1 mr-2">
{groups[index].all.length}
</Chip>
)}
<CollapseInput
title={t('proxies.search.placeholder')}
value={searchValue[index]}
onValueChange={(v) => {
setSearchValue((prev) => {
const newSearchValue = [...prev]
newSearchValue[index] = v
return newSearchValue
})
}}
/>
<Button
title={t('proxies.locate')}
variant="light"
size="sm"
isIconOnly
onPress={() => {
if (!isOpen[index]) {
setIsOpen((prev) => {
const newOpen = [...prev]
newOpen[index] = true
return newOpen
})
}
let i = 0
for (let j = 0; j < index; j++) {
i += groupCounts[j]
}
i += Math.floor(
allProxies[index].findIndex((proxy) => proxy.name === groups[index].now) /
cols
)
virtuosoRef.current?.scrollToIndex({
index: Math.floor(i),
align: 'start'
})
}}
>
<FaLocationCrosshairs className="text-lg text-foreground-500" />
</Button>
<Button
title={t('proxies.delay.test')}
variant="light"
isLoading={delaying[index]}
size="sm"
isIconOnly
onPress={() => {
onGroupDelay(index)
}}
>
<MdOutlineSpeed className="text-lg text-foreground-500" />
</Button>
<IoIosArrowBack
className={`transition duration-200 ml-2 h-[32px] text-lg text-foreground-500 ${isOpen[index] ? '-rotate-90' : ''}`}
/>
</div>
</div>
<div className="flex">
{proxyDisplayMode === 'full' && (
<Chip size="sm" className="my-1 mr-2">
{groups[index].all.length}
</Chip>
)}
<CollapseInput
title={t('proxies.search.placeholder')}
value={searchValue[index]}
onValueChange={(v) => {
setSearchValue((prev) => {
const newSearchValue = [...prev]
newSearchValue[index] = v
return newSearchValue
})
}}
/>
<Button
title={t('proxies.locate')}
variant="light"
size="sm"
isIconOnly
onPress={() => {
if (!isOpen[index]) {
setIsOpen((prev) => {
const newOpen = [...prev]
newOpen[index] = true
return newOpen
})
}
let i = 0
for (let j = 0; j < index; j++) {
i += groupCounts[j]
}
i += Math.floor(
allProxies[index].findIndex(
(proxy) => proxy.name === groups[index].now
) / cols
)
virtuosoRef.current?.scrollToIndex({
index: Math.floor(i),
align: 'start'
})
}}
>
<FaLocationCrosshairs className="text-lg text-foreground-500" />
</Button>
<Button
title={t('proxies.delay.test')}
variant="light"
isLoading={delaying[index]}
size="sm"
isIconOnly
onPress={() => {
onGroupDelay(index)
}}
>
<MdOutlineSpeed className="text-lg text-foreground-500" />
</Button>
<IoIosArrowBack
className={`transition duration-200 ml-2 h-[32px] text-lg text-foreground-500 ${isOpen[index] ? '-rotate-90' : ''}`}
/>
</div>
</div>
</CardBody>
</Card>
</div>
) : (
<div>Never See This</div>
)
}, [groups, groupCounts, isOpen, proxyDisplayMode, searchValue, delaying, cols, allProxies, virtuosoRef, t, setIsOpen, onGroupDelay])
</CardBody>
</Card>
</div>
) : (
<div>Never See This</div>
)
},
[
groups,
groupCounts,
isOpen,
proxyDisplayMode,
searchValue,
delaying,
cols,
allProxies,
virtuosoRef,
t,
setIsOpen,
onGroupDelay
]
)
const renderItemContent = useCallback((index: number, groupIndex: number) => {
let innerIndex = index
groupCounts.slice(0, groupIndex).forEach((count) => {
innerIndex -= count
})
return allProxies[groupIndex] ? (
<div
style={
proxyCols !== 'auto'
? { gridTemplateColumns: `repeat(${proxyCols}, minmax(0, 1fr))` }
: {}
}
className={`grid ${proxyCols === 'auto' ? 'sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5' : ''} ${groupIndex === groupCounts.length - 1 && innerIndex === groupCounts[groupIndex] - 1 ? 'pb-2' : ''} gap-2 pt-2 mx-2`}
>
{Array.from({ length: cols }).map((_, i) => {
if (!allProxies[groupIndex][innerIndex * cols + i]) return null
return (
<ProxyItem
key={allProxies[groupIndex][innerIndex * cols + i].name}
mutateProxies={mutate}
onProxyDelay={onProxyDelay}
onSelect={onChangeProxy}
proxy={allProxies[groupIndex][innerIndex * cols + i]}
group={groups[groupIndex]}
proxyDisplayMode={proxyDisplayMode}
selected={
allProxies[groupIndex][innerIndex * cols + i]?.name ===
groups[groupIndex].now
}
isGroupTesting={proxyDelaying.has(allProxies[groupIndex][innerIndex * cols + i].name)}
/>
)
})}
</div>
) : (
<div>Never See This</div>
)
}, [groupCounts, allProxies, proxyCols, cols, groups, proxyDisplayMode, proxyDelaying, mutate, onProxyDelay, onChangeProxy])
const renderItemContent = useCallback(
(index: number, groupIndex: number) => {
let innerIndex = index
groupCounts.slice(0, groupIndex).forEach((count) => {
innerIndex -= count
})
return allProxies[groupIndex] ? (
<div
style={
proxyCols !== 'auto'
? { gridTemplateColumns: `repeat(${proxyCols}, minmax(0, 1fr))` }
: {}
}
className={`grid ${proxyCols === 'auto' ? 'sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5' : ''} ${groupIndex === groupCounts.length - 1 && innerIndex === groupCounts[groupIndex] - 1 ? 'pb-2' : ''} gap-2 pt-2 mx-2`}
>
{Array.from({ length: cols }).map((_, i) => {
if (!allProxies[groupIndex][innerIndex * cols + i]) return null
return (
<ProxyItem
key={allProxies[groupIndex][innerIndex * cols + i].name}
mutateProxies={mutate}
onProxyDelay={onProxyDelay}
onSelect={onChangeProxy}
proxy={allProxies[groupIndex][innerIndex * cols + i]}
group={groups[groupIndex]}
proxyDisplayMode={proxyDisplayMode}
selected={
allProxies[groupIndex][innerIndex * cols + i]?.name === groups[groupIndex].now
}
isGroupTesting={proxyDelaying.has(
allProxies[groupIndex][innerIndex * cols + i].name
)}
/>
)
})}
</div>
) : (
<div>Never See This</div>
)
},
[
groupCounts,
allProxies,
proxyCols,
cols,
groups,
proxyDisplayMode,
proxyDelaying,
mutate,
onProxyDelay,
onChangeProxy
]
)
return (
<BasePage
@ -541,4 +580,4 @@ const Proxies: React.FC = () => {
)
}
export default Proxies
export default Proxies

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
const Resources: React.FC = () => {
const { t } = useTranslation()
return (
<BasePage title={t('sider.cards.resources')}>
<GeoData />

View File

@ -66,7 +66,7 @@ const Settings: React.FC = () => {
<WebdavConfig />
<MihomoConfig />
<ShortcutConfig />
<LocalBackupConfig />
<LocalBackupConfig />
<Actions />
</BasePage>
)

View File

@ -19,4 +19,4 @@ updateDayjsLocale()
// 监听语言变化
i18n.on('languageChanged', updateDayjsLocale)
export default dayjs
export default dayjs

View File

@ -10,12 +10,16 @@ function ipcErrorWrapper(response: any): any {
}
// GitHub版本管理相关IPC调用
export async function fetchMihomoTags(forceRefresh = false): Promise<{name: string, zipball_url: string, tarball_url: string}[]> {
export async function fetchMihomoTags(
forceRefresh = false
): Promise<{ name: string; zipball_url: string; tarball_url: string }[]> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('fetchMihomoTags', forceRefresh))
}
export async function installSpecificMihomoCore(version: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('installSpecificMihomoCore', version))
return ipcErrorWrapper(
await window.electron.ipcRenderer.invoke('installSpecificMihomoCore', version)
)
}
export async function clearMihomoVersionCache(): Promise<void> {
@ -101,15 +105,15 @@ export async function mihomoGroupDelay(group: string, url?: string): Promise<IMi
}
export async function mihomoSmartGroupWeights(groupName: string): Promise<Record<string, number>> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('mihomoSmartGroupWeights', groupName))
return ipcErrorWrapper(
await window.electron.ipcRenderer.invoke('mihomoSmartGroupWeights', groupName)
)
}
export async function mihomoSmartFlushCache(configName?: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('mihomoSmartFlushCache', configName))
}
export async function showDetailedError(title: string, message: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showDetailedError', title, message))
return ipcErrorWrapper(
await window.electron.ipcRenderer.invoke('mihomoSmartFlushCache', configName)
)
}
export async function getSmartOverrideContent(): Promise<string | null> {
@ -209,7 +213,9 @@ export async function setProfileStr(id: string, str: string): Promise<void> {
}
export async function convertMrsRuleset(path: string, behavior: string): Promise<string> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('convertMrsRuleset', path, behavior))
return ipcErrorWrapper(
await window.electron.ipcRenderer.invoke('convertMrsRuleset', path, behavior)
)
}
export async function getOverrideConfig(force = false): Promise<IOverrideConfig> {
@ -285,7 +291,9 @@ export async function showTunPermissionDialog(): Promise<boolean> {
}
export async function showErrorDialog(title: string, message: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('showErrorDialog', title, message))
return ipcErrorWrapper(
await window.electron.ipcRenderer.invoke('showErrorDialog', title, message)
)
}
export async function getFilePath(ext: string[]): Promise<string[] | undefined> {
@ -352,9 +360,7 @@ export async function webdavDelete(filename: string): Promise<void> {
// WebDAV 备份调度器相关 IPC 调用
export async function reinitWebdavBackupScheduler(): Promise<void> {
return ipcErrorWrapper(
await window.electron.ipcRenderer.invoke('reinitWebdavBackupScheduler')
)
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('reinitWebdavBackupScheduler'))
}
// 本地备份相关 IPC 调用