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,35 +20,29 @@ 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

View File

@ -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)

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

@ -44,9 +44,11 @@ export function getDevVersion() {
// 检查当前环境是否为dev构建 // 检查当前环境是否为dev构建
export function isDevBuild() { export function isDevBuild() {
return process.env.NODE_ENV === 'development' || return (
process.env.NODE_ENV === 'development' ||
process.argv.includes('--dev') || process.argv.includes('--dev') ||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'
)
} }
// 获取处理后的版本号 // 获取处理后的版本号

View File

@ -17,7 +17,11 @@ 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)
} }

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

@ -30,9 +30,10 @@ function processRulesWithOffset(ruleStrings: string[], currentRules: string[], i
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])
@ -58,7 +59,12 @@ function processRulesWithOffset(ruleStrings: string[], currentRules: string[], i
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,7 +86,11 @@ 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 数组存在
@ -92,20 +102,27 @@ export async function generateProfile(): Promise<void> {
// 处理前置规则 // 处理前置规则
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)
}) })

View File

@ -46,7 +46,7 @@ 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)
@ -54,7 +54,6 @@ function isValidCoreName(core: string): core is 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

@ -195,7 +195,7 @@ export const mihomoUpgradeConfig = async (): Promise<void> => {
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) {

View File

@ -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,7 +117,7 @@ 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)
} }

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,8 +67,7 @@ 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()
}) })
@ -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' })

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

@ -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,7 +80,8 @@ async function createFloatingWindow(): Promise<void> {
}) })
// 加载页面 // 加载页面
const url = is.dev && process.env['ELECTRON_RENDERER_URL'] const url =
is.dev && process.env['ELECTRON_RENDERER_URL']
? `${process.env['ELECTRON_RENDERER_URL']}/floating.html` ? `${process.env['ELECTRON_RENDERER_URL']}/floating.html`
: join(__dirname, '../renderer/floating.html') : join(__dirname, '../renderer/floating.html')

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,7 +74,7 @@ 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,
@ -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()

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

@ -174,7 +174,7 @@ async function requestSocketRecreation(): Promise<void> {
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
@ -192,13 +192,16 @@ async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1):
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 (
attempt < maxRetries &&
((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' || ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' ||
(error as NodeJS.ErrnoException).code === 'ENOENT' || (error as NodeJS.ErrnoException).code === 'ENOENT' ||
(error as Error).message?.includes('connect ECONNREFUSED') || (error as Error).message?.includes('connect ECONNREFUSED') ||
(error as Error).message?.includes('ENOENT'))) { (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...')

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' protocol: 'http' | 'https' | 'socks5'
host: string host: string
port: number port: number
} | false }
| false
timeout?: number timeout?: number
responseType?: 'text' | 'json' | 'arraybuffer' responseType?: 'text' | 'json' | 'arraybuffer'
followRedirect?: boolean followRedirect?: boolean

View File

@ -48,7 +48,11 @@ 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}`
// 检查缓存 // 检查缓存
@ -172,7 +176,7 @@ export async function installMihomoCore(version: string): Promise<void> {
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)
@ -216,6 +220,8 @@ export async function installMihomoCore(version: string): Promise<void> {
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

@ -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 })
} }
@ -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)())
@ -349,10 +368,14 @@ export function registerIpcMainHandlers(): void {
}) })
// 注册获取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)())

View File

@ -28,7 +28,10 @@ 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
)
} }
} }

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 {

View File

@ -8,9 +8,7 @@ const ErrorFallback = ({ error }: FallbackProps): React.ReactElement => {
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<{
@ -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

@ -41,7 +41,8 @@ const CopyableSettingItem: React.FC<{
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
.map((v, i) => {
const p = prefix[i] const p = prefix[i]
if (!p || !v) return null if (!p || !v) return null
@ -59,13 +60,17 @@ const CopyableSettingItem: React.FC<{
} }
} }
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : '' const suffix =
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}`
} }
}).filter(Boolean).flat() })
: prefix.map(p => { .filter(Boolean)
.flat()
: prefix
.map((p) => {
const v = value as string const v = value as string
if (p === 'DOMAIN-SUFFIX') { if (p === 'DOMAIN-SUFFIX') {
return getSubDomains(v).map((subV) => ({ return getSubDomains(v).map((subV) => ({
@ -81,12 +86,14 @@ const CopyableSettingItem: React.FC<{
} }
} }
const suffix = (p === 'IP-CIDR' || p === 'SRC-IP-CIDR') ? (isIPv6(v) ? '/128' : '/32') : '' const suffix =
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()) })
.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,24 +212,28 @@ 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"
radius="sm"
variant="dot"
>
{conn.isActive ? t('connections.active') : t('connections.closed')} {conn.isActive ? t('connections.active') : t('connections.closed')}
</Chip> </Chip>
), [t]) ),
[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(
(key: string): string => {
const translationMap: Record<string, string> = { const translationMap: Record<string, string> = {
status: t('connections.detail.status'), status: t('connections.detail.status'),
establishTime: t('connections.detail.establishTime'), establishTime: t('connections.detail.establishTime'),
@ -252,38 +258,43 @@ const ConnectionTable: React.FC<Props> = ({
dnsMode: t('connections.detail.dnsMode') dnsMode: t('connections.detail.dnsMode')
} }
return translationMap[key] || key return translationMap[key] || key
}, [t]) },
[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 => ({ () =>
DEFAULT_COLUMNS.map((col) => ({
...col, ...col,
label: getLabelForColumn(col.key), label: getLabelForColumn(col.key),
visible: visibleColumns.has(col.key), visible: visibleColumns.has(col.key),
width: columnWidths[col.key] || col.width width: columnWidths[col.key] || col.width
})) })),
, [getLabelForColumn, visibleColumns, columnWidths]) [getLabelForColumn, visibleColumns, columnWidths]
)
// 处理列宽度调整 // 处理列宽度调整
const handleMouseDown = useCallback((e: React.MouseEvent, columnKey: string) => { const handleMouseDown = useCallback(
(e: React.MouseEvent, columnKey: string) => {
e.preventDefault() e.preventDefault()
setResizingColumn(columnKey) 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
}) })
@ -292,7 +303,7 @@ const ConnectionTable: React.FC<Props> = ({
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
})) }))
@ -304,7 +315,7 @@ const ConnectionTable: React.FC<Props> = ({
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
// 保存列宽度 // 保存列宽度
if (onColumnWidthChange) { if (onColumnWidthChange) {
setColumnWidths(currentWidths => { setColumnWidths((currentWidths) => {
onColumnWidthChange(currentWidths) onColumnWidthChange(currentWidths)
return currentWidths return currentWidths
}) })
@ -313,10 +324,13 @@ const ConnectionTable: React.FC<Props> = ({
document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp) document.addEventListener('mouseup', handleMouseUp)
}, [onColumnWidthChange]) },
[onColumnWidthChange]
)
// 处理排序 // 处理排序
const handleSort = useCallback((columnKey: string) => { const handleSort = useCallback(
(columnKey: string) => {
let newDirection: 'asc' | 'desc' = 'asc' let newDirection: 'asc' | 'desc' = 'asc'
let newColumn = columnKey let newColumn = columnKey
@ -332,13 +346,15 @@ const ConnectionTable: React.FC<Props> = ({
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

@ -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()
@ -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"
style={{
color:
typeof values.interval === 'string' &&
!/^\d+$/.test(values.interval) && !/^\d+$/.test(values.interval) &&
!isValidCron(values.interval, { seconds: false }) !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

@ -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

View File

@ -22,9 +22,19 @@ 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(
(props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay, isGroupTesting = false } = props 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) {
@ -51,7 +61,10 @@ const ProxyItem: React.FC<Props> = React.memo((props) => {
}) })
}, [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
@ -99,9 +112,13 @@ const ProxyItem: React.FC<Props> = React.memo((props) => {
<div className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md"> <div className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md">
{proxy.type} {proxy.type}
</div> </div>
{['tfo', 'udp', 'xudp', 'mptcp', 'smux'].map(protocol => {['tfo', 'udp', 'xudp', 'mptcp', 'smux'].map(
(protocol) =>
proxy[protocol as keyof IMihomoProxy] && ( proxy[protocol as keyof IMihomoProxy] && (
<div key={protocol} className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md"> <div
key={protocol}
className="text-foreground-400 text-xs bg-default-100 px-1 rounded-md"
>
{protocol} {protocol}
</div> </div>
) )
@ -116,9 +133,7 @@ const ProxyItem: React.FC<Props> = React.memo((props) => {
variant="light" variant="light"
className="h-full text-sm ml-auto -mt-0.5 px-2 relative w-min whitespace-nowrap" 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"> <div className="w-full h-full flex items-center justify-end">{delayText}</div>
{delayText}
</div>
</Button> </Button>
</div> </div>
</div> </div>
@ -154,9 +169,7 @@ const ProxyItem: React.FC<Props> = React.memo((props) => {
variant="light" variant="light"
className="h-full text-sm px-2 relative w-min whitespace-nowrap" className="h-full text-sm px-2 relative w-min whitespace-nowrap"
> >
<div className="w-full h-full flex items-center justify-end"> <div className="w-full h-full flex items-center justify-end">{delayText}</div>
{delayText}
</div>
</Button> </Button>
</div> </div>
</div> </div>
@ -164,7 +177,8 @@ const ProxyItem: React.FC<Props> = React.memo((props) => {
</CardBody> </CardBody>
</Card> </Card>
) )
}, (prevProps, nextProps) => { },
(prevProps, nextProps) => {
// 必要时重新渲染 // 必要时重新渲染
return ( return (
prevProps.proxy.name === nextProps.proxy.name && prevProps.proxy.name === nextProps.proxy.name &&
@ -174,7 +188,8 @@ const ProxyItem: React.FC<Props> = React.memo((props) => {
prevProps.group.fixed === nextProps.group.fixed && prevProps.group.fixed === nextProps.group.fixed &&
prevProps.isGroupTesting === nextProps.isGroupTesting prevProps.isGroupTesting === nextProps.isGroupTesting
) )
}) }
)
ProxyItem.displayName = 'ProxyItem' ProxyItem.displayName = 'ProxyItem'

View File

@ -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

@ -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

@ -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"

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>

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
) )
@ -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,7 +31,10 @@ 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
.sort()
.reverse()
.map((filename) => (
<div className="flex" key={filename}> <div className="flex" key={filename}>
<Button <Button
size="sm" size="sm"

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

@ -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

@ -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,21 +74,28 @@ const Connections: React.FC = () => {
) )
}, [selected, activeConnections, closedConnections]) }, [selected, activeConnections, closedConnections])
const handleColumnWidthChange = useCallback(async (widths: Record<string, number>) => { const handleColumnWidthChange = useCallback(
async (widths: Record<string, number>) => {
await patchAppConfig({ connectionTableColumnWidths: widths }) await patchAppConfig({ connectionTableColumnWidths: widths })
}, [patchAppConfig]) },
[patchAppConfig]
)
const handleSortChange = useCallback(async (column: string | null, direction: 'asc' | 'desc') => { const handleSortChange = useCallback(
async (column: string | null, direction: 'asc' | 'desc') => {
await patchAppConfig({ await patchAppConfig({
connectionTableSortColumn: column || undefined, connectionTableSortColumn: column || undefined,
connectionTableSortDirection: direction 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 =
filter === ''
? connections ? connections
: connections.filter((connection) => { : connections.filter((connection) => {
const raw = JSON.stringify(connection) const raw = JSON.stringify(connection)
@ -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(
(id: string): void => {
tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id) tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id)
}, [tab]) },
[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,7 +123,7 @@ 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)
@ -197,10 +218,7 @@ const Mihomo: React.FC = () => {
// 打开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')
} }
@ -222,10 +240,8 @@ const Mihomo: React.FC = () => {
// 更新面板 // 更新面板
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)
@ -236,7 +252,7 @@ const Mihomo: React.FC = () => {
// 删除面板 // 删除面板
const deletePanel = (id: string) => { const deletePanel = (id: string) => {
setAllPanels(allPanels.filter(panel => panel.id !== id)) setAllPanels(allPanels.filter((panel) => panel.id !== id))
} }
// 开始编辑面板 // 开始编辑面板
@ -280,7 +296,7 @@ const Mihomo: React.FC = () => {
// 可点击的变量标签组件 // 可点击的变量标签组件
const ClickableVariableTag: React.FC<{ const ClickableVariableTag: React.FC<{
variable: string; variable: string
onClick: (variable: string) => void onClick: (variable: string) => void
}> = ({ variable, onClick }) => { }> = ({ variable, onClick }) => {
return ( return (
@ -385,7 +401,7 @@ const Mihomo: React.FC = () => {
} }
// 过滤标签 // 过滤标签
const filteredTags = tags.filter(tag => const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(searchTerm.toLowerCase()) tag.name.toLowerCase().includes(searchTerm.toLowerCase())
) )
@ -402,15 +418,14 @@ const Mihomo: React.FC = () => {
<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
className={`rounded-md border p-2 transition-all duration-200 ${
enableSmartCore enableSmartCore
? 'border-blue-300 bg-blue-50/30 dark:border-blue-700 dark:bg-blue-950/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' : '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>
</> </>
@ -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">
{/* 添加/编辑面板表单 */} {/* 添加/编辑面板表单 */}
@ -1432,12 +1446,7 @@ const Mihomo: React.FC = () => {
> >
{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>
</> </>
@ -1465,19 +1474,17 @@ const Mihomo: React.FC = () => {
{/* 面板列表 */} {/* 面板列表 */}
<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
@ -1503,10 +1510,7 @@ 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>

View File

@ -375,7 +375,11 @@ const Profiles: React.FC = () => {
}} }}
> >
{subStoreMenuItems.map((item) => ( {subStoreMenuItems.map((item) => (
<DropdownItem startContent={item?.icon} key={item.key} showDivider={item.divider}> <DropdownItem
startContent={item?.icon}
key={item.key}
showDivider={item.divider}
>
{item.children} {item.children}
</DropdownItem> </DropdownItem>
))} ))}

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)
@ -101,7 +103,8 @@ const Proxies: React.FC = () => {
} = 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,19 +156,23 @@ 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(
async (group: string, proxy: string): Promise<void> => {
await mihomoChangeProxy(group, proxy) await mihomoChangeProxy(group, proxy)
if (autoCloseConnection) { if (autoCloseConnection) {
await mihomoCloseAllConnections() await mihomoCloseAllConnections()
} }
mutate() mutate()
}, [autoCloseConnection, 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(
async (index: number): Promise<void> => {
if (allProxies[index].length === 0) { if (allProxies[index].length === 0) {
setIsOpen((prev) => { setIsOpen((prev) => {
const newOpen = [...prev] const newOpen = [...prev]
@ -183,7 +190,7 @@ const Proxies: React.FC = () => {
const groupProxies = allProxies[index] const groupProxies = allProxies[index]
setProxyDelaying((prev) => { setProxyDelaying((prev) => {
const newSet = new Set(prev) const newSet = new Set(prev)
groupProxies.forEach(proxy => newSet.add(proxy.name)) groupProxies.forEach((proxy) => newSet.add(proxy.name))
return newSet return newSet
}) })
@ -226,11 +233,13 @@ const Proxies: React.FC = () => {
// 状态清理 // 状态清理
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.delete(proxy.name))
return newSet return newSet
}) })
} }
}, [allProxies, groups, delayTestConcurrency, mutate, setIsOpen]) },
[allProxies, groups, delayTestConcurrency, mutate, setIsOpen]
)
const calcCols = useCallback((): number => { const calcCols = useCallback((): number => {
if (proxyCols !== 'auto') { if (proxyCols !== 'auto') {
@ -281,7 +290,8 @@ const Proxies: React.FC = () => {
loadImages() loadImages()
}, [groups, mutate]) }, [groups, mutate])
const renderGroupContent = useCallback((index: number) => { const renderGroupContent = useCallback(
(index: number) => {
return groups[index] ? ( return groups[index] ? (
<div <div
className={`w-full pt-2 ${index === groupCounts.length - 1 && !isOpen[index] ? 'pb-2' : ''} px-2`} className={`w-full pt-2 ${index === groupCounts.length - 1 && !isOpen[index] ? 'pb-2' : ''} px-2`}
@ -370,9 +380,8 @@ const Proxies: React.FC = () => {
i += groupCounts[j] i += groupCounts[j]
} }
i += Math.floor( i += Math.floor(
allProxies[index].findIndex( allProxies[index].findIndex((proxy) => proxy.name === groups[index].now) /
(proxy) => proxy.name === groups[index].now cols
) / cols
) )
virtuosoRef.current?.scrollToIndex({ virtuosoRef.current?.scrollToIndex({
index: Math.floor(i), index: Math.floor(i),
@ -405,9 +414,25 @@ const Proxies: React.FC = () => {
) : ( ) : (
<div>Never See This</div> <div>Never See This</div>
) )
}, [groups, groupCounts, isOpen, proxyDisplayMode, searchValue, delaying, cols, allProxies, virtuosoRef, t, setIsOpen, onGroupDelay]) },
[
groups,
groupCounts,
isOpen,
proxyDisplayMode,
searchValue,
delaying,
cols,
allProxies,
virtuosoRef,
t,
setIsOpen,
onGroupDelay
]
)
const renderItemContent = useCallback((index: number, groupIndex: number) => { const renderItemContent = useCallback(
(index: number, groupIndex: number) => {
let innerIndex = index let innerIndex = index
groupCounts.slice(0, groupIndex).forEach((count) => { groupCounts.slice(0, groupIndex).forEach((count) => {
innerIndex -= count innerIndex -= count
@ -433,10 +458,11 @@ const Proxies: React.FC = () => {
group={groups[groupIndex]} group={groups[groupIndex]}
proxyDisplayMode={proxyDisplayMode} proxyDisplayMode={proxyDisplayMode}
selected={ selected={
allProxies[groupIndex][innerIndex * cols + i]?.name === allProxies[groupIndex][innerIndex * cols + i]?.name === groups[groupIndex].now
groups[groupIndex].now
} }
isGroupTesting={proxyDelaying.has(allProxies[groupIndex][innerIndex * cols + i].name)} isGroupTesting={proxyDelaying.has(
allProxies[groupIndex][innerIndex * cols + i].name
)}
/> />
) )
})} })}
@ -444,7 +470,20 @@ const Proxies: React.FC = () => {
) : ( ) : (
<div>Never See This</div> <div>Never See This</div>
) )
}, [groupCounts, allProxies, proxyCols, cols, groups, proxyDisplayMode, proxyDelaying, mutate, onProxyDelay, onChangeProxy]) },
[
groupCounts,
allProxies,
proxyCols,
cols,
groups,
proxyDisplayMode,
proxyDelaying,
mutate,
onProxyDelay,
onChangeProxy
]
)
return ( return (
<BasePage <BasePage

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 调用