mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-27 05:00:30 +08:00
Merge branch 'smart_core' of https://github.com/mihomo-party-org/clash-party into smart_core
This commit is contained in:
commit
d811f76bb4
@ -18,6 +18,7 @@
|
||||
</div>
|
||||
|
||||
### 本项目认证稳定机场推荐:“[狗狗加速](https://party.dginv.click/#/register?code=ARdo0mXx)”
|
||||
|
||||
##### [狗狗加速 —— 技术流机场 Doggygo VPN](https://party.dginv.click/#/register?code=ARdo0mXx)
|
||||
|
||||
- 高性能海外机场,稳定首选,海外团队,无跑路风险
|
||||
|
||||
36
changelog.md
36
changelog.md
@ -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)
|
||||
|
||||
- 更新依赖
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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,35 +20,29 @@ 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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -17,7 +17,11 @@ 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)
|
||||
}
|
||||
|
||||
@ -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 内核时自动应用
|
||||
|
||||
|
||||
@ -30,9 +30,10 @@ function processRulesWithOffset(ruleStrings: string[], currentRules: string[], i
|
||||
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])
|
||||
@ -58,7 +59,12 @@ function processRulesWithOffset(ruleStrings: string[], currentRules: string[], i
|
||||
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,7 +86,11 @@ 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 数组存在
|
||||
@ -92,20 +102,27 @@ export async function generateProfile(): Promise<void> {
|
||||
|
||||
// 处理前置规则
|
||||
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)
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -195,7 +195,7 @@ export const mihomoUpgradeConfig = async (): Promise<void> => {
|
||||
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) {
|
||||
|
||||
@ -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,7 +117,7 @@ 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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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,7 +74,7 @@ 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,
|
||||
@ -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()
|
||||
|
||||
@ -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?')
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,8 +77,6 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
|
||||
nativeTheme.themeSource = theme
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function resetAppConfig(): void {
|
||||
if (process.platform === 'win32') {
|
||||
spawn(
|
||||
|
||||
@ -174,7 +174,7 @@ async function requestSocketRecreation(): Promise<void> {
|
||||
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
|
||||
@ -192,13 +192,16 @@ async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1):
|
||||
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...')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -48,7 +48,11 @@ 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}`
|
||||
|
||||
// 检查缓存
|
||||
@ -172,7 +176,7 @@ export async function installMihomoCore(version: string): Promise<void> {
|
||||
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)
|
||||
@ -216,6 +220,8 @@ export async function installMihomoCore(version: string): Promise<void> {
|
||||
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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
|
||||
@ -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 {
|
||||
@ -355,10 +368,14 @@ export function registerIpcMainHandlers(): void {
|
||||
})
|
||||
|
||||
// 注册获取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)())
|
||||
|
||||
@ -28,7 +28,10 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -141,7 +141,7 @@ export const BaseEditor: React.FC<Props> = (props) => {
|
||||
}}
|
||||
editorWillMount={editorWillMount}
|
||||
editorDidMount={editorDidMount}
|
||||
editorWillUnmount={(): void => { }}
|
||||
editorWillUnmount={(): void => {}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -8,9 +8,7 @@ const ErrorFallback = ({ error }: FallbackProps): React.ReactElement => {
|
||||
|
||||
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"
|
||||
|
||||
@ -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<{
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
? 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]}`
|
||||
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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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]`} />
|
||||
|
||||
@ -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()
|
||||
@ -133,9 +133,9 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
inputWidth,
|
||||
// 不合法
|
||||
typeof values.interval === 'string' &&
|
||||
!/^\d+$/.test(values.interval) &&
|
||||
!isValidCron(values.interval, { seconds: false }) &&
|
||||
'border-red-500'
|
||||
!/^\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
@ -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'
|
||||
}}
|
||||
|
||||
@ -22,159 +22,174 @@ 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'
|
||||
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "جاوااسکریپت جدید",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,7 +123,7 @@ 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)
|
||||
@ -197,10 +218,7 @@ const Mihomo: React.FC = () => {
|
||||
|
||||
// 打开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')
|
||||
}
|
||||
|
||||
@ -222,10 +240,8 @@ const Mihomo: React.FC = () => {
|
||||
// 更新面板
|
||||
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)
|
||||
@ -236,7 +252,7 @@ const Mihomo: React.FC = () => {
|
||||
|
||||
// 删除面板
|
||||
const deletePanel = (id: string) => {
|
||||
setAllPanels(allPanels.filter(panel => panel.id !== id))
|
||||
setAllPanels(allPanels.filter((panel) => panel.id !== id))
|
||||
}
|
||||
|
||||
// 开始编辑面板
|
||||
@ -280,7 +296,7 @@ const Mihomo: React.FC = () => {
|
||||
|
||||
// 可点击的变量标签组件
|
||||
const ClickableVariableTag: React.FC<{
|
||||
variable: string;
|
||||
variable: string
|
||||
onClick: (variable: string) => void
|
||||
}> = ({ variable, onClick }) => {
|
||||
return (
|
||||
@ -385,7 +401,7 @@ const Mihomo: React.FC = () => {
|
||||
}
|
||||
|
||||
// 过滤标签
|
||||
const filteredTags = tags.filter(tag =>
|
||||
const filteredTags = tags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
@ -402,15 +418,14 @@ const Mihomo: React.FC = () => {
|
||||
<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>
|
||||
</>
|
||||
@ -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]"
|
||||
@ -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">
|
||||
{/* 添加/编辑面板表单 */}
|
||||
@ -1447,12 +1461,7 @@ const Mihomo: React.FC = () => {
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
@ -1480,19 +1489,17 @@ const Mihomo: React.FC = () => {
|
||||
{/* 面板列表 */}
|
||||
<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
|
||||
@ -1518,10 +1525,7 @@ 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>
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -120,7 +122,8 @@ const Proxies: React.FC = () => {
|
||||
} = 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
|
||||
|
||||
@ -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 调用
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user