chore: ensure ESLint passes and format code & update changelog.md

* chore: ensure ESLint passes and format code

* chore: update changelog.md
This commit is contained in:
Memory 2025-12-13 15:22:32 +08:00 committed by GitHub
parent 8f5486064b
commit 6542be8490
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 2429 additions and 1849 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,11 @@
import yaml from 'yaml' import yaml from 'yaml'
import { readFileSync, writeFileSync } from 'fs' 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') const pkg = readFileSync('package.json', 'utf-8')
let changelog = readFileSync('changelog.md', 'utf-8') let changelog = readFileSync('changelog.md', 'utf-8')
@ -19,7 +24,8 @@ const latest = {
// 使用统一的下载链接生成函数 // 使用统一的下载链接生成函数
changelog += generateDownloadLinksMarkdown(downloadUrl, version) 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('latest.yml', yaml.stringify(latest))
writeFileSync('changelog.md', changelog) writeFileSync('changelog.md', changelog)

View File

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

View File

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

View File

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

View File

@ -29,15 +29,16 @@ let runtimeConfig: IMihomoConfig
function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) { function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) {
const normalRules: string[] = [] const normalRules: string[] = []
let rules = [...currentRules] let rules = [...currentRules]
ruleStrings.forEach(ruleStr => { ruleStrings.forEach((ruleStr) => {
const parts = ruleStr.split(',') 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) { if (firstPartIsNumber) {
const offset = parseInt(parts[0]) const offset = parseInt(parts[0])
const rule = parts.slice(1).join(',') const rule = parts.slice(1).join(',')
if (isAppend) { if (isAppend) {
// 后置规则的插入位置计算 // 后置规则的插入位置计算
const insertPosition = Math.max(0, rules.length - Math.min(offset, rules.length)) const insertPosition = Math.max(0, rules.length - Math.min(offset, rules.length))
@ -51,14 +52,19 @@ function processRulesWithOffset(ruleStrings: string[], currentRules: string[], i
normalRules.push(ruleStr) normalRules.push(ruleStr)
} }
}) })
return { normalRules, insertRules: rules } return { normalRules, insertRules: rules }
} }
export async function generateProfile(): Promise<void> { export async function generateProfile(): Promise<void> {
// 读取最新的配置 // 读取最新的配置
const { current } = await getProfileConfig(true) const { current } = await getProfileConfig(true)
const { diffWorkDir = false, controlDns = true, controlSniff = true, useNameserverPolicy } = await getAppConfig() const {
diffWorkDir = false,
controlDns = true,
controlSniff = true,
useNameserverPolicy
} = await getAppConfig()
let currentProfile = await overrideProfile(current, await getProfile(current)) let currentProfile = await overrideProfile(current, await getProfile(current))
let controledMihomoConfig = await getControledMihomoConfig() let controledMihomoConfig = await getControledMihomoConfig()
@ -80,37 +86,48 @@ export async function generateProfile(): Promise<void> {
const ruleFilePath = rulePath(current || 'default') const ruleFilePath = rulePath(current || 'default')
if (existsSync(ruleFilePath)) { if (existsSync(ruleFilePath)) {
const ruleFileContent = await readFile(ruleFilePath, 'utf-8') 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') { if (ruleData && typeof ruleData === 'object') {
// 确保 rules 数组存在 // 确保 rules 数组存在
if (!currentProfile.rules) { if (!currentProfile.rules) {
currentProfile.rules = [] as unknown as [] currentProfile.rules = [] as unknown as []
} }
let rules = [...currentProfile.rules] as unknown as string[] let rules = [...currentProfile.rules] as unknown as string[]
// 处理前置规则 // 处理前置规则
if (ruleData.prepend?.length) { if (ruleData.prepend?.length) {
const { normalRules: prependRules, insertRules } = processRulesWithOffset(ruleData.prepend, rules) const { normalRules: prependRules, insertRules } = processRulesWithOffset(
ruleData.prepend,
rules
)
rules = [...prependRules, ...insertRules] rules = [...prependRules, ...insertRules]
} }
// 处理后置规则 // 处理后置规则
if (ruleData.append?.length) { 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] rules = [...insertRules, ...appendRules]
} }
// 处理删除规则 // 处理删除规则
if (ruleData.delete?.length) { if (ruleData.delete?.length) {
const deleteSet = new Set(ruleData.delete) const deleteSet = new Set(ruleData.delete)
rules = rules.filter(rule => { rules = rules.filter((rule) => {
const ruleStr = Array.isArray(rule) ? rule.join(',') : rule const ruleStr = Array.isArray(rule) ? rule.join(',') : rule
return !deleteSet.has(ruleStr) return !deleteSet.has(ruleStr)
}) })
} }
currentProfile.rules = rules as unknown as [] currentProfile.rules = rules as unknown as []
} }
} }

View File

@ -46,15 +46,14 @@ import { managerLogger } from '../utils/logger'
// 内核名称白名单 // 内核名称白名单
const ALLOWED_CORES = ['mihomo', 'mihomo-alpha', 'mihomo-smart'] as const 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 { function isValidCoreName(core: string): core is AllowedCore {
return ALLOWED_CORES.includes(core as AllowedCore) return ALLOWED_CORES.includes(core as AllowedCore)
} }
// 路径检查 // 路径检查
function validateCorePath(corePath: string): void { function validateCorePath(corePath: string): void {
if (corePath.includes('..')) { if (corePath.includes('..')) {
throw new Error('Invalid core path: directory traversal detected') 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]) os.setPriority(child.pid, os.constants.priority[mihomoCpuPriority])
} }
if (detached) { 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() child.unref()
return new Promise((resolve) => { return new Promise((resolve) => {
resolve([new Promise(() => {})]) resolve([new Promise(() => {})])
@ -210,7 +211,8 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
reject(i18next.t('tun.error.tunPermissionDenied')) 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')) (process.platform === 'win32' && str.includes('External controller pipe listen error'))
) { ) {
await managerLogger.error('External controller listen error detected:', str) 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...') await managerLogger.info('Attempting Windows pipe cleanup and retry...')
try { try {
await cleanupWindowsNamedPipes() await cleanupWindowsNamedPipes()
await new Promise(resolve => setTimeout(resolve, 2000)) await new Promise((resolve) => setTimeout(resolve, 2000))
} catch (cleanupError) { } catch (cleanupError) {
await managerLogger.error('Pipe cleanup failed:', cleanupError) await managerLogger.error('Pipe cleanup failed:', cleanupError)
} }
@ -235,7 +237,9 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
resolve([ resolve([
new Promise((resolve) => { new Promise((resolve) => {
child.stdout?.on('data', async (data) => { 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 { try {
mainWindow?.webContents.send('groupsUpdated') mainWindow?.webContents.send('groupsUpdated')
mainWindow?.webContents.send('rulesUpdated') mainWindow?.webContents.send('rulesUpdated')
@ -337,7 +341,7 @@ async function cleanupWindowsNamedPipes(): Promise<void> {
await managerLogger.warn('Failed to parse process list JSON:', parseError) 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) { for (const line of lines) {
const match = line.match(/(\d+)/) const match = line.match(/(\d+)/)
if (match) { if (match) {
@ -361,8 +365,7 @@ async function cleanupWindowsNamedPipes(): Promise<void> {
await managerLogger.warn('Failed to check mihomo processes:', error) 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) { } catch (error) {
await managerLogger.error('Windows named pipe cleanup failed:', 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> { async function checkProfile(): Promise<void> {
const { const { core = 'mihomo', diffWorkDir = false } = await getAppConfig()
core = 'mihomo',
diffWorkDir = false
} = await getAppConfig()
const { current } = await getProfileConfig() const { current } = await getProfileConfig()
const corePath = mihomoCorePath(core) const corePath = mihomoCorePath(core)
const execFilePromise = promisify(execFile) const execFilePromise = promisify(execFile)
@ -484,13 +484,15 @@ async function checkProfile(): Promise<void> {
} }
return line.trim() return line.trim()
}) })
.filter(line => line.length > 0) .filter((line) => line.length > 0)
if (errorLines.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')}`) throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}:\n${allLines.join('\n')}`)
} else { } 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 { } else {
throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}: ${error}`) throw new Error(`${i18next.t('mihomo.error.profileCheckFailed')}: ${error}`)
@ -570,7 +572,7 @@ async function waitForCoreReady(): Promise<void> {
return 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 return true
} catch (netSessionError: any) { } catch (netSessionError: any) {
const netErrorCode = netSessionError?.code || 0 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 return false
} }
} }
@ -613,11 +617,14 @@ export async function showTunPermissionDialog(): Promise<boolean> {
await managerLogger.info(`i18next available: ${typeof i18next.t === 'function'}`) await managerLogger.info(`i18next available: ${typeof i18next.t === 'function'}`)
const title = i18next.t('tun.permissions.title') || '需要管理员权限' 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 confirmText = i18next.t('common.confirm') || '确认'
const cancelText = i18next.t('common.cancel') || '取消' 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({ const choice = dialog.showMessageBoxSync({
type: 'warning', type: 'warning',
@ -759,8 +766,11 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
for (const executable of mihomoExecutables) { for (const executable of mihomoExecutables) {
try { try {
const { stdout } = await execPromise(`chcp 65001 >nul 2>&1 && tasklist /FI "IMAGENAME eq ${executable}" /FO CSV`, { encoding: 'utf8' }) const { stdout } = await execPromise(
const lines = stdout.split('\n').filter(line => line.includes(executable)) `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) { if (lines.length > 0) {
await managerLogger.info(`Found ${lines.length} ${executable} processes running`) await managerLogger.info(`Found ${lines.length} ${executable} processes running`)
@ -781,7 +791,9 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
return true return true
} }
} catch (error) { } 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) { for (const executable of mihomoExecutables) {
try { try {
const { stdout } = await execPromise(`ps aux | grep ${executable} | grep -v grep`) 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) { if (lines.length > 0) {
foundProcesses = true foundProcesses = true
@ -820,8 +834,7 @@ async function checkHighPrivilegeMihomoProcess(): Promise<boolean> {
} }
} }
} }
} catch (error) { } catch (error) {}
}
} }
if (!foundProcesses) { if (!foundProcesses) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { app, dialog, ipcMain } from 'electron' import { app, ipcMain } from 'electron'
import { import {
mihomoChangeProxy, mihomoChangeProxy,
mihomoCloseAllConnections, mihomoCloseAllConnections,
@ -86,9 +86,22 @@ import {
setupFirewall setupFirewall
} from '../sys/misc' } from '../sys/misc'
import { getRuntimeConfig, getRuntimeConfigStr } from '../core/factory' 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 { 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 { registerShortcut } from '../resolve/shortcut'
import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..' import { closeMainWindow, mainWindow, showMainWindow, triggerMainWindow } from '..'
import { import {
@ -135,7 +148,9 @@ function ipcErrorWrapper<T>( // eslint-disable-next-line @typescript-eslint/no-e
} }
// GitHub版本管理相关IPC处理程序 // 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) return await getGitHubTags('MetaCubeX', 'mihomo', forceRefresh)
} }
@ -216,7 +231,9 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id)) ipcMain.handle('getProfileStr', (_e, id) => ipcErrorWrapper(getProfileStr)(id))
ipcMain.handle('getFileStr', (_e, path) => ipcErrorWrapper(getFileStr)(path)) ipcMain.handle('getFileStr', (_e, path) => ipcErrorWrapper(getFileStr)(path))
ipcMain.handle('setFileStr', (_e, path, str) => ipcErrorWrapper(setFileStr)(path, str)) 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('setProfileStr', (_e, id, str) => ipcErrorWrapper(setProfileStr)(id, str))
ipcMain.handle('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item)) ipcMain.handle('updateProfileItem', (_e, item) => ipcErrorWrapper(updateProfileItem)(item))
ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id)) ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))
@ -242,7 +259,9 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('requestTunPermissions', () => ipcErrorWrapper(requestTunPermissions)()) ipcMain.handle('requestTunPermissions', () => ipcErrorWrapper(requestTunPermissions)())
ipcMain.handle('checkHighPrivilegeCore', () => ipcErrorWrapper(checkHighPrivilegeCore)()) ipcMain.handle('checkHighPrivilegeCore', () => ipcErrorWrapper(checkHighPrivilegeCore)())
ipcMain.handle('showTunPermissionDialog', () => ipcErrorWrapper(showTunPermissionDialog)()) 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('checkTunPermissions', () => ipcErrorWrapper(checkTunPermissions)())
ipcMain.handle('grantTunPermissions', () => ipcErrorWrapper(grantTunPermissions)()) ipcMain.handle('grantTunPermissions', () => ipcErrorWrapper(grantTunPermissions)())
@ -347,17 +366,21 @@ export function registerIpcMainHandlers(): void {
// 触发托盘菜单更新 // 触发托盘菜单更新
ipcMain.emit('updateTrayMenu') ipcMain.emit('updateTrayMenu')
}) })
// 注册获取Mihomo标签的IPC处理程序 // 注册获取Mihomo标签的IPC处理程序
ipcMain.handle('fetchMihomoTags', (_e, forceRefresh) => ipcErrorWrapper(fetchMihomoTags)(forceRefresh)) ipcMain.handle('fetchMihomoTags', (_e, forceRefresh) =>
ipcErrorWrapper(fetchMihomoTags)(forceRefresh)
)
// 注册安装特定版本Mihomo核心的IPC处理程序 // 注册安装特定版本Mihomo核心的IPC处理程序
ipcMain.handle('installSpecificMihomoCore', (_e, version) => ipcErrorWrapper(installSpecificMihomoCore)(version)) ipcMain.handle('installSpecificMihomoCore', (_e, version) =>
ipcErrorWrapper(installSpecificMihomoCore)(version)
)
// 注册清除版本缓存的IPC处理程序 // 注册清除版本缓存的IPC处理程序
ipcMain.handle('clearMihomoVersionCache', () => ipcErrorWrapper(clearMihomoVersionCache)()) ipcMain.handle('clearMihomoVersionCache', () => ipcErrorWrapper(clearMihomoVersionCache)())
// 规则相关IPC处理程序 // 规则相关IPC处理程序
ipcMain.handle('getRuleStr', (_e, id) => ipcErrorWrapper(getRuleStr)(id)) ipcMain.handle('getRuleStr', (_e, id) => ipcErrorWrapper(getRuleStr)(id))
ipcMain.handle('setRuleStr', (_e, id, str) => ipcErrorWrapper(setRuleStr)(id, str)) ipcMain.handle('setRuleStr', (_e, id, str) => ipcErrorWrapper(setRuleStr)(id, str))
} }

View File

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

View File

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

View File

@ -5,7 +5,9 @@
@source '../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'; @source '../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@theme { @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 { @font-face {
@ -192,4 +194,4 @@
to { to {
width: 0%; width: 0%;
} }
} }

View File

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

View File

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

View File

@ -50,7 +50,8 @@ export const toast = {
error: (message: string, title?: string): void => addToast('error', message, title, 1800), error: (message: string, title?: string): void => addToast('error', message, title, 1800),
warning: (message: string, title?: string): void => addToast('warning', message, title), warning: (message: string, title?: string): void => addToast('warning', message, title),
info: (message: string, title?: string): void => addToast('info', 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<{ const ToastItem: React.FC<{
@ -76,7 +77,7 @@ const ToastItem: React.FC<{
}, },
error: { error: {
icon: <IoClose className="text-white text-sm" />, icon: <IoClose className="text-white text-sm" />,
bg: 'bg-content1', bg: 'bg-content1',
iconBg: 'bg-danger' iconBg: 'bg-danger'
}, },
warning: { warning: {
@ -119,7 +120,9 @@ const ToastItem: React.FC<{
> >
<div className="flex items-center justify-between overflow-visible"> <div className="flex items-center justify-between overflow-visible">
<div className="flex items-center gap-3"> <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} {icon}
</div> </div>
<p className="text-base font-semibold text-foreground">{data.title || '错误'}</p> <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" className="p-2 rounded-lg hover:bg-default-200 transition-colors"
> >
<div className="relative w-4 h-4"> <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'}`} /> <IoCopy
<IoCheckmark className={`absolute inset-0 text-base text-success transition-all duration-200 ${copied ? 'opacity-100 scale-100' : 'opacity-0 scale-50'}`} /> 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> </div>
</button> </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>
</div> </div>
@ -166,16 +176,14 @@ const ToastItem: React.FC<{
`} `}
style={{ width: 340 }} 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} {icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{data.title && ( {data.title && <p className="text-sm font-medium text-foreground">{data.title}</p>}
<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>
)}
<p className="text-sm text-foreground-500 break-words select-text">
{data.message}
</p>
</div> </div>
<button <button
onClick={handleClose} onClick={handleClose}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,8 +35,22 @@ const WebdavConfig: React.FC = () => {
webdavBackupCron webdavBackupCron
}) })
const setWebdavDebounce = debounce( 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 500
) )
@ -142,7 +156,7 @@ const WebdavConfig: React.FC = () => {
<SettingItem title={t('webdav.backup.cron.title')} divider> <SettingItem title={t('webdav.backup.cron.title')} divider>
<div className="flex w-[60%] gap-2"> <div className="flex w-[60%] gap-2">
{webdavBackupCron !== webdav.webdavBackupCron && ( {webdavBackupCron !== webdav.webdavBackupCron && (
<Button <Button
size="sm" size="sm"
color="primary" color="primary"
onPress={async () => { onPress={async () => {
@ -172,7 +186,6 @@ const WebdavConfig: React.FC = () => {
setWebdav({ ...webdav, webdavBackupCron: v }) setWebdav({ ...webdav, webdavBackupCron: v })
}} }}
/> />
</div> </div>
</SettingItem> </SettingItem>
<div className="flex justify0between"> <div className="flex justify0between">

View File

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

View File

@ -36,7 +36,12 @@ interface Props {
const ConnCard: React.FC<Props> = (props) => { const ConnCard: React.FC<Props> = (props) => {
const { iconOnly } = props const { iconOnly } = props
const { appConfig } = useAppConfig() 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 location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const match = location.pathname.includes('/connections') const match = location.pathname.includes('/connections')

View File

@ -18,7 +18,11 @@ const DNSCard: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { iconOnly } = props 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 location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const match = location.pathname.includes('/dns') const match = location.pathname.includes('/dns')

View File

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

View File

@ -26,7 +26,11 @@ const ProfileCard: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { iconOnly } = props 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 location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const match = location.pathname.includes('/profiles') const match = location.pathname.includes('/profiles')
@ -158,7 +162,9 @@ const ProfileCard: React.FC<Props> = (props) => {
await patchAppConfig({ profileDisplayDate: 'update' }) 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>
) : ( ) : (
<Button <Button

View File

@ -18,7 +18,11 @@ const SniffCard: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { iconOnly } = props 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 location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const match = location.pathname.includes('/sniffer') const match = location.pathname.includes('/sniffer')

View File

@ -15,7 +15,11 @@ const SubStoreCard: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { appConfig } = useAppConfig() const { appConfig } = useAppConfig()
const { iconOnly } = props 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 location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const match = location.pathname.includes('/substore') const match = location.pathname.includes('/substore')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,12 +26,14 @@ const GROUP_EXPAND_STATE_KEY = 'proxy_group_expand_state'
const SCROLL_POSITION_KEY = 'proxy_scroll_position' const SCROLL_POSITION_KEY = 'proxy_scroll_position'
// 自定义 hook 用于管理展开状态 // 自定义 hook 用于管理展开状态
const useProxyState = (groups: IMihomoMixedGroup[]): { const useProxyState = (
virtuosoRef: React.RefObject<GroupedVirtuosoHandle | null>; groups: IMihomoMixedGroup[]
isOpen: boolean[]; ): {
setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>; virtuosoRef: React.RefObject<GroupedVirtuosoHandle | null>
initialTopMostItemIndex: number; isOpen: boolean[]
handleRangeChanged: (range: { startIndex: number }) => void; setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>
initialTopMostItemIndex: number
handleRangeChanged: (range: { startIndex: number }) => void
} => { } => {
const virtuosoRef = useRef<GroupedVirtuosoHandle | null>(null) const virtuosoRef = useRef<GroupedVirtuosoHandle | null>(null)
@ -56,7 +58,7 @@ const useProxyState = (groups: IMihomoMixedGroup[]): {
console.error('Failed to save scroll position:', error) console.error('Failed to save scroll position:', error)
} }
}, []) }, [])
// 初始化展开状态 // 初始化展开状态
const [isOpen, setIsOpen] = useState<boolean[]>(() => { const [isOpen, setIsOpen] = useState<boolean[]>(() => {
try { try {
@ -99,9 +101,10 @@ const Proxies: React.FC = () => {
proxyCols = 'auto', proxyCols = 'auto',
delayTestConcurrency = 50 delayTestConcurrency = 50
} = appConfig || {} } = appConfig || {}
const [cols, setCols] = useState(1) 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 [delaying, setDelaying] = useState(Array(groups.length).fill(false))
const [proxyDelaying, setProxyDelaying] = useState<Set<string>>(new Set()) const [proxyDelaying, setProxyDelaying] = useState<Set<string>>(new Set())
const [searchValue, setSearchValue] = useState(Array(groups.length).fill('')) const [searchValue, setSearchValue] = useState(Array(groups.length).fill(''))
@ -153,84 +156,90 @@ const Proxies: React.FC = () => {
return { groupCounts, allProxies } return { groupCounts, allProxies }
}, [groups, isOpen, proxyDisplayOrder, cols, searchValue, sortProxies]) }, [groups, isOpen, proxyDisplayOrder, cols, searchValue, sortProxies])
const onChangeProxy = useCallback(async (group: string, proxy: string): Promise<void> => { const onChangeProxy = useCallback(
await mihomoChangeProxy(group, proxy) async (group: string, proxy: string): Promise<void> => {
if (autoCloseConnection) { await mihomoChangeProxy(group, proxy)
await mihomoCloseAllConnections() if (autoCloseConnection) {
} await mihomoCloseAllConnections()
mutate() }
}, [autoCloseConnection, mutate]) mutate()
},
[autoCloseConnection, mutate]
)
const onProxyDelay = useCallback(async (proxy: string, url?: string): Promise<IMihomoDelay> => { const onProxyDelay = useCallback(async (proxy: string, url?: string): Promise<IMihomoDelay> => {
return await mihomoProxyDelay(proxy, url) return await mihomoProxyDelay(proxy, url)
}, []) }, [])
const onGroupDelay = useCallback(async (index: number): Promise<void> => { const onGroupDelay = useCallback(
if (allProxies[index].length === 0) { async (index: number): Promise<void> => {
setIsOpen((prev) => { if (allProxies[index].length === 0) {
const newOpen = [...prev] setIsOpen((prev) => {
newOpen[index] = true const newOpen = [...prev]
return newOpen 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()
}
}) })
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) => { setDelaying((prev) => {
const newDelaying = [...prev] const newDelaying = [...prev]
newDelaying[index] = false newDelaying[index] = true
return newDelaying return newDelaying
}) })
// 状态清理
// 管理测试状态
const groupProxies = allProxies[index]
setProxyDelaying((prev) => { setProxyDelaying((prev) => {
const newSet = new Set(prev) const newSet = new Set(prev)
groupProxies.forEach(proxy => newSet.delete(proxy.name)) groupProxies.forEach((proxy) => newSet.add(proxy.name))
return newSet 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 => { const calcCols = useCallback((): number => {
if (proxyCols !== 'auto') { if (proxyCols !== 'auto') {
@ -281,170 +290,200 @@ const Proxies: React.FC = () => {
loadImages() loadImages()
}, [groups, mutate]) }, [groups, mutate])
const renderGroupContent = useCallback((index: number) => { const renderGroupContent = useCallback(
return groups[index] ? ( (index: number) => {
<div return groups[index] ? (
className={`w-full pt-2 ${index === groupCounts.length - 1 && !isOpen[index] ? 'pb-2' : ''} px-2`} <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
})
}}
> >
<CardBody className="w-full"> <Card
<div className="flex justify-between"> as="div"
<div className="flex text-ellipsis overflow-hidden whitespace-nowrap"> isPressable
{groups[index].icon ? ( fullWidth
<Avatar onPress={() => {
className="bg-transparent mr-2" setIsOpen((prev) => {
size="sm" const newOpen = [...prev]
radius="sm" newOpen[index] = !prev[index]
src={ return newOpen
groups[index].icon.startsWith('<svg') })
? `data:image/svg+xml;utf8,${groups[index].icon}` }}
: localStorage.getItem(groups[index].icon) || groups[index].icon >
} <CardBody className="w-full">
/> <div className="flex justify-between">
) : null} <div className="flex text-ellipsis overflow-hidden whitespace-nowrap">
<div className="text-ellipsis overflow-hidden whitespace-nowrap"> {groups[index].icon ? (
<div <Avatar
title={groups[index].name} className="bg-transparent mr-2"
className="inline flag-emoji h-[32px] text-md leading-[32px]" size="sm"
> radius="sm"
{groups[index].name} src={
</div> groups[index].icon.startsWith('<svg')
{proxyDisplayMode === 'full' && ( ? `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 <div
title={groups[index].type} title={groups[index].name}
className="inline ml-2 text-sm text-foreground-500" className="inline flag-emoji h-[32px] text-md leading-[32px]"
> >
{groups[index].type} {groups[index].name}
</div> </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' && ( {proxyDisplayMode === 'full' && (
<div className="inline flag-emoji ml-2 text-sm text-foreground-500"> <Chip size="sm" className="my-1 mr-2">
{groups[index].now} {groups[index].all.length}
</div> </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> </div>
<div className="flex"> </CardBody>
{proxyDisplayMode === 'full' && ( </Card>
<Chip size="sm" className="my-1 mr-2"> </div>
{groups[index].all.length} ) : (
</Chip> <div>Never See This</div>
)} )
<CollapseInput },
title={t('proxies.search.placeholder')} [
value={searchValue[index]} groups,
onValueChange={(v) => { groupCounts,
setSearchValue((prev) => { isOpen,
const newSearchValue = [...prev] proxyDisplayMode,
newSearchValue[index] = v searchValue,
return newSearchValue delaying,
}) cols,
}} allProxies,
/> virtuosoRef,
<Button t,
title={t('proxies.locate')} setIsOpen,
variant="light" onGroupDelay
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])
const renderItemContent = useCallback((index: number, groupIndex: number) => { const renderItemContent = useCallback(
let innerIndex = index (index: number, groupIndex: number) => {
groupCounts.slice(0, groupIndex).forEach((count) => { let innerIndex = index
innerIndex -= count groupCounts.slice(0, groupIndex).forEach((count) => {
}) innerIndex -= count
return allProxies[groupIndex] ? ( })
<div return allProxies[groupIndex] ? (
style={ <div
proxyCols !== 'auto' style={
? { gridTemplateColumns: `repeat(${proxyCols}, minmax(0, 1fr))` } 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`} }
> 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 {Array.from({ length: cols }).map((_, i) => {
return ( if (!allProxies[groupIndex][innerIndex * cols + i]) return null
<ProxyItem return (
key={allProxies[groupIndex][innerIndex * cols + i].name} <ProxyItem
mutateProxies={mutate} key={allProxies[groupIndex][innerIndex * cols + i].name}
onProxyDelay={onProxyDelay} mutateProxies={mutate}
onSelect={onChangeProxy} onProxyDelay={onProxyDelay}
proxy={allProxies[groupIndex][innerIndex * cols + i]} onSelect={onChangeProxy}
group={groups[groupIndex]} proxy={allProxies[groupIndex][innerIndex * cols + i]}
proxyDisplayMode={proxyDisplayMode} group={groups[groupIndex]}
selected={ proxyDisplayMode={proxyDisplayMode}
allProxies[groupIndex][innerIndex * cols + i]?.name === selected={
groups[groupIndex].now allProxies[groupIndex][innerIndex * cols + i]?.name === groups[groupIndex].now
} }
isGroupTesting={proxyDelaying.has(allProxies[groupIndex][innerIndex * cols + i].name)} isGroupTesting={proxyDelaying.has(
/> allProxies[groupIndex][innerIndex * cols + i].name
) )}
})} />
</div> )
) : ( })}
<div>Never See This</div> </div>
) ) : (
}, [groupCounts, allProxies, proxyCols, cols, groups, proxyDisplayMode, proxyDelaying, mutate, onProxyDelay, onChangeProxy]) <div>Never See This</div>
)
},
[
groupCounts,
allProxies,
proxyCols,
cols,
groups,
proxyDisplayMode,
proxyDelaying,
mutate,
onProxyDelay,
onChangeProxy
]
)
return ( return (
<BasePage <BasePage
@ -522,4 +561,4 @@ const Proxies: React.FC = () => {
) )
} }
export default Proxies export default Proxies

View File

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

View File

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

View File

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

View File

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