Compare commits

...

17 Commits
v1.7.4 ... main

Author SHA1 Message Date
ezequielnick
364578f210 1.7.7 Released 2025-07-31 22:31:52 +08:00
Sabertaz
674cefcc29
feat: add webdav max backups configuration and cleanup logic (#862)
issue #648.
2025-07-29 16:55:05 +08:00
ezequielnick
827e744601 feat: add user data directory permission repair functionality 2025-07-29 16:47:49 +08:00
ezequielnick
151726fcce fix: 5: Input/output error when install macos helper 2025-07-28 20:03:38 +08:00
ezequielnick
6f3845151d chore: add missing trans 2025-06-22 10:21:10 +08:00
ezequielnick
a9b5887e15 1.7.6 Released 2025-06-17 22:19:59 +08:00
ezequielnick
727fd48684 chore: add missing trans 2025-06-13 22:04:28 +08:00
ezequielnick
e2ab88f4e2 i18n: add missing trans 2025-06-12 21:13:11 +08:00
Xia Wanxu
1a5c001dbd fix: synchronization issue with gist after mihomo core 1.19.8 update (#780)
Co-authored-by: 2045gemini <2045gemini@gmail.com>

感谢!

(cherry picked from commit f168adb68f917e0214064498efcac347c448672e)
2025-06-11 21:49:46 +08:00
ezequielnick
6744e14c66 fix: EACCES: permission denied on MacOS 2025-06-11 21:45:41 +08:00
ezequielnick
62a04cc5ad 1.7.5 Released 2025-06-08 10:14:43 +08:00
ezequielnick
555130001b feat: integrate with socket reconstruction mechanism 2025-06-07 22:45:38 +08:00
ezequielnick
78f9211ebe fix: builder scripts 2025-06-07 21:50:41 +08:00
ezequielnick
c6d0e05851 chore: change log 2025-06-07 21:50:41 +08:00
ezequielnick
caf962f921 1.7.4 Released
(cherry picked from commit 1e83bac48231a433206c966756d71d139f553f6f)
2025-06-07 21:50:41 +08:00
ezequielnick
0b8c77d200 feat: add animation during latency testing for better user feedback 2025-06-07 21:50:41 +08:00
ezequielnick
d6e456302e feat: enable right-click context menu on subscription cards 2025-06-06 22:42:41 +08:00
23 changed files with 483 additions and 132 deletions

View File

@ -206,7 +206,7 @@ jobs:
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: | run: |
chmod +x build/pkg-scripts/postinstall chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
pnpm build:mac --${{ matrix.arch }} pnpm build:mac --${{ matrix.arch }}
- name: Setup temporary installer signing keychain - name: Setup temporary installer signing keychain
uses: apple-actions/import-codesign-certs@v3 uses: apple-actions/import-codesign-certs@v3
@ -281,7 +281,7 @@ jobs:
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: | run: |
sed -i "" -e "s/macos/catalina/" electron-builder.yml sed -i "" -e "s/macos/catalina/" electron-builder.yml
chmod +x build/pkg-scripts/postinstall chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
pnpm build:mac --${{ matrix.arch }} pnpm build:mac --${{ matrix.arch }}
- name: Setup temporary installer signing keychain - name: Setup temporary installer signing keychain
uses: apple-actions/import-codesign-certs@v3 uses: apple-actions/import-codesign-certs@v3

View File

@ -74,7 +74,9 @@ cat << EOF > "$LAUNCH_DAEMON"
<key>Label</key> <key>Label</key>
<string>party.mihomo.helper</string> <string>party.mihomo.helper</string>
<key>AssociatedBundleIdentifiers</key> <key>AssociatedBundleIdentifiers</key>
<array>
<string>party.mihomo.app</string> <string>party.mihomo.app</string>
</array>
<key>KeepAlive</key> <key>KeepAlive</key>
<true/> <true/>
<key>Program</key> <key>Program</key>
@ -91,18 +93,81 @@ chown root:wheel "$LAUNCH_DAEMON"
chmod 644 "$LAUNCH_DAEMON" chmod 644 "$LAUNCH_DAEMON"
log "LaunchDaemon configured" log "LaunchDaemon configured"
# 加载并启动服务 # 验证关键文件
log "Loading and starting service..." log "Verifying installation..."
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true if [ ! -x "$HELPER_PATH" ]; then
if ! launchctl load "$LAUNCH_DAEMON"; then log "Error: Helper tool is not executable: $HELPER_PATH"
log "Error: Failed to load helper service"
exit 1 exit 1
fi fi
if ! launchctl start party.mihomo.helper; then # 检查二进制文件有效性
log "Error: Failed to start helper service" if ! file "$HELPER_PATH" | grep -q "Mach-O"; then
log "Error: Helper tool is not a valid Mach-O binary"
exit 1 exit 1
fi fi
# 验证 plist 格式
if ! plutil -lint "$LAUNCH_DAEMON" >/dev/null 2>&1; then
log "Error: Invalid plist format"
exit 1
fi
# 获取 macOS 版本
macos_version=$(sw_vers -productVersion)
macos_major=$(echo "$macos_version" | cut -d. -f1)
log "macOS version: $macos_version"
# 清理现有服务
log "Cleaning up existing services..."
launchctl bootout system "$LAUNCH_DAEMON" 2>/dev/null || true
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
# 加载服务
log "Loading service..."
if [ "$macos_major" -ge 11 ]; then
# macOS Big Sur 及更新版本使用 bootstrap
if ! launchctl bootstrap system "$LAUNCH_DAEMON"; then
log "Bootstrap failed, trying legacy load..."
if ! launchctl load "$LAUNCH_DAEMON"; then
log "Error: Failed to load service with both methods"
exit 1
fi
fi
else
# 旧版本使用 load
if ! launchctl load "$LAUNCH_DAEMON"; then
log "Error: Failed to load service"
exit 1
fi
fi
# 验证服务状态
log "Verifying service status..."
sleep 2
if launchctl list | grep -q "party.mihomo.helper"; then
log "Service loaded successfully"
else
log "Warning: Service may not be running properly"
fi
log "Installation completed successfully" log "Installation completed successfully"
# Fix user data directory permissions
log "Fixing user data directory permissions..."
for user_home in /Users/*; do
if [ -d "$user_home" ] && [ "$(basename "$user_home")" != "Shared" ] && [ "$(basename "$user_home")" != ".localized" ]; then
username=$(basename "$user_home")
user_data_dir="$user_home/Library/Application Support/mihomo-party"
if [ -d "$user_data_dir" ]; then
current_owner=$(stat -f "%Su" "$user_data_dir" 2>/dev/null || echo "unknown")
if [ "$current_owner" = "root" ]; then
log "Fixing ownership for user: $username"
chown -R "$username:staff" "$user_data_dir" 2>/dev/null || true
chmod -R u+rwX "$user_data_dir" 2>/dev/null || true
fi
fi
fi
done
exit 0 exit 0

View File

@ -1,3 +1,56 @@
## 1.7.7
### 新功能 (Feat)
- Mihomo 内核升级 v1.19.12
- 新增 Webdav 最大备数设置和清理逻辑
### 修复 (Fix)
- 修复 MacOS 下无法启动的问题(重置工作目录权限)
- 尝试修复不同版本 MacOS 下安装软件时候的报错Input/output error
- 部分遗漏的多国语言翻译
## 1.7.6
**此版本修复了 1.7.5 中的几个严重 bug推荐所有人更新**
### 修复 (Fix)
- 修复了内核1.19.8更新后gist同步失效的问题(#780)
- 部分遗漏的多国语言翻译
- MacOS 下启动Error: EACCES: permission denied
- MacOS 系统代理 bypass 不生效
- MacOS 系统代理开启时 500 报错
## 1.7.5
### 新功能 (Feat)
- 增加组延迟测试时的动画
- 订阅卡片可右键点击
-
### 修复 (Fix)
- 1.7.4引入的内核启动错误
- 无法手动设置内核权限
- 完善 系统代理socket 重建和检测机制
## 1.7.4
### 新功能 (Feat)
- Mihomo 内核升级 v1.19.10
- 改进 socket创建机制防止 MacOS 下系统代理开启无法找到 socket 文件的问题
- mihomo-party-helper增加更多日志以方便调试
- 改进 MacOS 下签名和公正流程
- 增加 MacOS 下 plist 权限设置
- 改进安装流程
-
### 修复 (Fix)
- 修复mihomo-party-helper本地提权漏洞
- 修复 MacOS 下安装失败的问题
- 移除节点页面的滚动位置记忆,解决页面溢出的问题
- DNS hosts 设置在 useHosts 不为 true 时也会被错误应用的问题(#742)
- 当用户在 Profile 设置中修改了更新间隔并保存后,新的间隔时间不会立即生效(#671)
- 禁止选择器组件选择空值
- 修复proxy-provider
## 1.7.3 ## 1.7.3
**注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件** **注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件**
@ -17,73 +70,3 @@
- 修复多语言翻译 - 修复多语言翻译
- 修复 defaultBypass 几乎总是 Windows 默认绕过设置 (#602) - 修复 defaultBypass 几乎总是 Windows 默认绕过设置 (#602)
- 修复重置防火墙时发生的错误,因为没有指定防火墙规则 (#650) - 修复重置防火墙时发生的错误,因为没有指定防火墙规则 (#650)
## 1.7.2
**注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件**
### 新功能 (Feat)
- 添加伊朗语支持 (#507)
- 添加俄语支持 (#503)
- 使用特权助手设置系统代理解决MacOS下的少有的系统代理设置问题
### 修复 (Fix)
- 修复 Linux 上 sub-store 的更新问题 (#545)
- 修复按钮嵌套的水合错误 (button nesting hydration errors)
### 其他优化 (Perf)
- 优化系统代理开关逻辑
- 优化代理页面性能和滚动体验
- 简化启动守护进程的配置以支持辅助服务
- 改进安装后脚本,增强错误处理和路径灵活性
## 1.7.1
**注意主题失效请重新下载一次因为更新了UI组件老主题不兼容了**
### 新功能 (Feat)
- 自动检测操作系统语言并设置app
### 修复 (Fix)
- 修复详细模式下节点旗帜不显示的问题
- 修复引导页显示问题
- 修复缺失的hero-ui参数
- 补充丢失的翻译
### 其他改进 (Chore)
- 美化延迟测试结果按钮的显示样式
- 默认开1-RTT延迟测试
- 替换默认延迟测试链接
## 1.7.0
### 新功能 (Feat)
- 增加更多核心设置
- 添加内联支持和缺失的翻译
- 增加连接的时间排序功能
- 添加 i18n 支持,包含英文翻译
- 支持应用内更新和重启 Sub-Store
### 修复 (Fix)
- 延迟按钮宽度的自动调整
- 渲染 Card 为 div防止按钮嵌套导致的 Hydration 错误
- 解决自动运行受控组件的警告
- 解决标题栏覆盖和受控组件的警告
- 解决无法点击配置文件的问题
- 解决组件层级中的无效按钮嵌套
- 在 NextUI 按钮组件中用 `onPress` 替换 `onClick`
- 添加 `aria-label` 以解决可访问性警告
- UI 中的延迟测试结果支持自动更新,无需点击
- 移除 NextUI Card 组件中的嵌套按钮元素
- 修复 `useWindowFrame` 切换时的重复重启问题 (#457)
### 重构 (Refactor)
- 按文件类型筛选提供者,而不是按订阅信息筛选
- 添加缺失的 `aria-label`,提升可访问性合规性
### 其他改进 (Chore)
- 格式化语言文件并删除未使用的文件
- 添加缺失的 i18n 字符串和 UI 调整
- 更新依赖项
- 记住滚动位置和展开状态
- 增加节点详细信息
### 依赖更新 (Deps)
- 更新依赖项,并将 UI 框架从 NextUI 迁移到 HeroUI

View File

@ -52,7 +52,6 @@ pkg:
background: background:
alignment: bottomleft alignment: bottomleft
file: build/background.png file: build/background.png
scripts: build/pkg-scripts
linux: linux:
desktop: desktop:
Name: Mihomo Party Name: Mihomo Party

View File

@ -1,6 +1,6 @@
{ {
"name": "mihomo-party", "name": "mihomo-party",
"version": "1.7.3", "version": "1.7.7",
"description": "Mihomo Party", "description": "Mihomo Party",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "mihomo-party-org", "author": "mihomo-party-org",

View File

@ -158,7 +158,7 @@ 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().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')

View File

@ -10,8 +10,10 @@ import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
import { init } from './utils/init' import { init } from './utils/init'
import { join } from 'path' import { join } from 'path'
import { initShortcut } from './resolve/shortcut' import { initShortcut } from './resolve/shortcut'
import { execSync, spawn } from 'child_process' import { execSync, spawn, exec } from 'child_process'
import { createElevateTask } from './sys/misc' import { createElevateTask } from './sys/misc'
import { promisify } from 'util'
import { stat } from 'fs/promises'
import { initProfileUpdater } from './core/profileUpdater' import { initProfileUpdater } from './core/profileUpdater'
import { existsSync, writeFileSync } from 'fs' import { existsSync, writeFileSync } from 'fs'
import { exePath, taskDir } from './utils/dirs' import { exePath, taskDir } from './utils/dirs'
@ -22,6 +24,29 @@ import iconv from 'iconv-lite'
import { initI18n } from '../shared/i18n' import { initI18n } from '../shared/i18n'
import i18next from 'i18next' import i18next from 'i18next'
async function fixUserDataPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
const userDataPath = app.getPath('userData')
if (!existsSync(userDataPath)) return
try {
const stats = await stat(userDataPath)
const currentUid = process.getuid?.() || 0
if (stats.uid === 0 && currentUid !== 0) {
const execPromise = promisify(exec)
const username = process.env.USER || process.env.LOGNAME
if (username) {
await execPromise(`chown -R "${username}:staff" "${userDataPath}"`)
await execPromise(`chmod -R u+rwX "${userDataPath}"`)
}
}
} catch {
// ignore
}
}
let quitTimeout: NodeJS.Timeout | null = null let quitTimeout: NodeJS.Timeout | null = null
export let mainWindow: BrowserWindow | null = null export let mainWindow: BrowserWindow | null = null
@ -59,12 +84,27 @@ if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')
} }
} }
const gotTheLock = app.requestSingleInstanceLock() async function initApp(): Promise<void> {
await fixUserDataPermissions()
if (!gotTheLock) {
app.quit()
} }
initApp()
.then(() => {
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
})
.catch(() => {
// ignore permission fix errors
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
}
})
export function customRelaunch(): void { export function customRelaunch(): void {
const script = `while kill -0 ${process.pid} 2>/dev/null; do const script = `while kill -0 ${process.pid} 2>/dev/null; do
sleep 0.1 sleep 0.1

View File

@ -19,7 +19,8 @@ export async function webdavBackup(): Promise<boolean> {
webdavUrl = '', webdavUrl = '',
webdavUsername = '', webdavUsername = '',
webdavPassword = '', webdavPassword = '',
webdavDir = 'mihomo-party' webdavDir = 'mihomo-party',
webdavMaxBackups = 0
} = await getAppConfig() } = await getAppConfig()
const zip = new AdmZip() const zip = new AdmZip()
@ -44,7 +45,41 @@ export async function webdavBackup(): Promise<boolean> {
// ignore // ignore
} }
return await client.putFileContents(`${webdavDir}/${zipFileName}`, zip.toBuffer()) const result = await client.putFileContents(`${webdavDir}/${zipFileName}`, zip.toBuffer())
if (webdavMaxBackups > 0) {
try {
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
const fileList = Array.isArray(files) ? files : files.data
const currentPlatformFiles = fileList.filter((file) => {
return file.basename.startsWith(`${process.platform}_`)
})
currentPlatformFiles.sort((a, b) => {
const timeA = a.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
const timeB = b.basename.match(/_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip$/)?.[1] || ''
return timeB.localeCompare(timeA)
})
if (currentPlatformFiles.length > webdavMaxBackups) {
const filesToDelete = currentPlatformFiles.slice(webdavMaxBackups)
for (let i = 0; i < filesToDelete.length; i++) {
const file = filesToDelete[i]
await client.deleteFile(`${webdavDir}/${file.basename}`)
if (i < filesToDelete.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
}
} catch (error) {
console.error('Failed to clean up old backup files:', error)
}
}
return result
} }
export async function webdavRestore(filename: string): Promise<void> { export async function webdavRestore(filename: string): Promise<void> {

View File

@ -7,7 +7,8 @@ import path from 'path'
const appName = 'mihomo-party' const appName = 'mihomo-party'
const taskXml = `<?xml version="1.0" encoding="UTF-16"?> function getTaskXml(): string {
return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers> <Triggers>
<LogonTrigger> <LogonTrigger>
@ -48,6 +49,7 @@ const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
</Actions> </Actions>
</Task> </Task>
` `
}
export async function checkAutoRun(): Promise<boolean> { export async function checkAutoRun(): Promise<boolean> {
if (process.platform === 'win32') { if (process.platform === 'win32') {
@ -80,7 +82,7 @@ export async function enableAutoRun(): Promise<void> {
if (process.platform === 'win32') { if (process.platform === 'win32') {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const taskFilePath = path.join(taskDir(), `${appName}.xml`) const taskFilePath = path.join(taskDir(), `${appName}.xml`)
await writeFile(taskFilePath, Buffer.from(`\ufeff${taskXml}`, 'utf-16le')) await writeFile(taskFilePath, Buffer.from(`\ufeff${getTaskXml()}`, 'utf-16le'))
await execPromise( await execPromise(
`%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f` `%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f`
) )

View File

@ -68,7 +68,8 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
nativeTheme.themeSource = theme nativeTheme.themeSource = theme
} }
const elevateTaskXml = `<?xml version="1.0" encoding="UTF-16"?> function getElevateTaskXml(): string {
return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<Triggers /> <Triggers />
<Principals> <Principals>
@ -104,10 +105,11 @@ const elevateTaskXml = `<?xml version="1.0" encoding="UTF-16"?>
</Actions> </Actions>
</Task> </Task>
` `
}
export function createElevateTask(): void { export function createElevateTask(): void {
const taskFilePath = path.join(taskDir(), `mihomo-party-run.xml`) const taskFilePath = path.join(taskDir(), `mihomo-party-run.xml`)
writeFileSync(taskFilePath, Buffer.from(`\ufeff${elevateTaskXml}`, 'utf-16le')) writeFileSync(taskFilePath, Buffer.from(`\ufeff${getElevateTaskXml()}`, 'utf-16le'))
copyFileSync( copyFileSync(
path.join(resourcesFilesDir(), 'mihomo-party-run.exe'), path.join(resourcesFilesDir(), 'mihomo-party-run.exe'),
path.join(taskDir(), 'mihomo-party-run.exe') path.join(taskDir(), 'mihomo-party-run.exe')

View File

@ -7,6 +7,7 @@ import path from 'path'
import { resourcesFilesDir } from '../utils/dirs' import { resourcesFilesDir } from '../utils/dirs'
import { net } from 'electron' import { net } from 'electron'
import axios from 'axios' import axios from 'axios'
import fs from 'fs'
let defaultBypass: string[] let defaultBypass: string[]
let triggerSysProxyTimer: NodeJS.Timeout | null = null let triggerSysProxyTimer: NodeJS.Timeout | null = null
@ -82,13 +83,15 @@ async function enableSysProxy(): Promise<void> {
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`) triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
} }
} else if (process.platform === 'darwin') { } else if (process.platform === 'darwin') {
await axios.post( await helperRequest(() =>
axios.post(
'http://localhost/pac', 'http://localhost/pac',
{ url: `http://${host || '127.0.0.1'}:${pacPort}/pac` }, { url: `http://${host || '127.0.0.1'}:${pacPort}/pac` },
{ {
socketPath: helperSocketPath socketPath: helperSocketPath
} }
) )
)
} else { } else {
triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`) triggerAutoProxy(true, `http://${host || '127.0.0.1'}:${pacPort}/pac`)
} }
@ -108,13 +111,15 @@ async function enableSysProxy(): Promise<void> {
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(',')) triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
} }
} else if (process.platform === 'darwin') { } else if (process.platform === 'darwin') {
await axios.post( await helperRequest(() =>
axios.post(
'http://localhost/global', 'http://localhost/global',
{ host: host || '127.0.0.1', port: port.toString(), bypass: bypass.join(',') }, { host: host || '127.0.0.1', port: port.toString(), bypass: bypass.join(',') },
{ {
socketPath: helperSocketPath socketPath: helperSocketPath
} }
) )
)
} else { } else {
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(',')) triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
} }
@ -134,11 +139,84 @@ async function disableSysProxy(): Promise<void> {
triggerManualProxy(false, '', 0, '') triggerManualProxy(false, '', 0, '')
} }
} else if (process.platform === 'darwin') { } else if (process.platform === 'darwin') {
await axios.get('http://localhost/off', { await helperRequest(() =>
axios.get('http://localhost/off', {
socketPath: helperSocketPath socketPath: helperSocketPath
}) })
)
} else { } else {
triggerAutoProxy(false, '') triggerAutoProxy(false, '')
triggerManualProxy(false, '', 0, '') triggerManualProxy(false, '', 0, '')
} }
} }
// Helper function to check if socket file exists
function isSocketFileExists(): boolean {
try {
return fs.existsSync(helperSocketPath)
} catch {
return false
}
}
// Helper function to send signal to recreate socket
async function requestSocketRecreation(): Promise<void> {
try {
// Send SIGUSR1 signal to helper process to recreate socket
const { exec } = require('child_process')
const { promisify } = require('util')
const execPromise = promisify(exec)
// Use osascript with administrator privileges (same pattern as manualGrantCorePermition)
const shell = `pkill -USR1 -f party.mihomo.helper`
const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`)
// Wait a bit for socket recreation
await new Promise(resolve => setTimeout(resolve, 1000))
} catch (error) {
console.log('Failed to send signal to helper:', error)
throw error
}
}
// Wrapper function for helper requests with auto-retry on socket issues
async function helperRequest(requestFn: () => Promise<unknown>, maxRetries = 1): Promise<unknown> {
let lastError: Error | null = null
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await requestFn()
} catch (error) {
lastError = error as Error
// Check if it's a connection error and socket file doesn't exist
if (attempt < maxRetries &&
((error as NodeJS.ErrnoException).code === 'ECONNREFUSED' ||
(error as NodeJS.ErrnoException).code === 'ENOENT' ||
(error as Error).message?.includes('connect ECONNREFUSED') ||
(error as Error).message?.includes('ENOENT'))) {
console.log(`Helper request failed (attempt ${attempt + 1}), checking socket file...`)
if (!isSocketFileExists()) {
console.log('Socket file missing, requesting recreation...')
try {
await requestSocketRecreation()
console.log('Socket recreation requested, retrying...')
continue
} catch (signalError) {
console.log('Failed to request socket recreation:', signalError)
}
}
}
// If not a connection error or we've exhausted retries, throw the error
if (attempt === maxRetries) {
throw lastError
}
}
}
throw lastError
}

View File

@ -18,7 +18,13 @@ export function dataDir(): string {
} }
export function taskDir(): string { export function taskDir(): string {
const dir = path.join(app.getPath('userData'), 'tasks') const userDataDir = app.getPath('userData')
// 确保 userData 目录存在
if (!existsSync(userDataDir)) {
mkdirSync(userDataDir, { recursive: true })
}
const dir = path.join(userDataDir, 'tasks')
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }) mkdirSync(dir, { recursive: true })
} }

View File

@ -22,8 +22,10 @@ import {
defaultProfileConfig defaultProfileConfig
} from './template' } from './template'
import yaml from 'yaml' import yaml from 'yaml'
import { mkdir, writeFile,rm, readdir, cp } from 'fs/promises' import { mkdir, writeFile, rm, readdir, cp, stat } from 'fs/promises'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { exec } from 'child_process'
import { promisify } from 'util'
import path from 'path' import path from 'path'
import { import {
startPacServer, startPacServer,
@ -40,7 +42,32 @@ import {
import { app } from 'electron' import { app } from 'electron'
import { startSSIDCheck } from '../sys/ssid' import { startSSIDCheck } from '../sys/ssid'
async function fixDataDirPermissions(): Promise<void> {
if (process.platform !== 'darwin') return
const dataDirPath = dataDir()
if (!existsSync(dataDirPath)) return
try {
const stats = await stat(dataDirPath)
const currentUid = process.getuid?.() || 0
if (stats.uid === 0 && currentUid !== 0) {
const execPromise = promisify(exec)
const username = process.env.USER || process.env.LOGNAME
if (username) {
await execPromise(`chown -R "${username}:staff" "${dataDirPath}"`)
await execPromise(`chmod -R u+rwX "${dataDirPath}"`)
}
}
} catch {
// ignore
}
}
async function initDirs(): Promise<void> { async function initDirs(): Promise<void> {
await fixDataDirPermissions()
if (!existsSync(dataDir())) { if (!existsSync(dataDir())) {
await mkdir(dataDir()) await mkdir(dataDir())
} }

View File

@ -60,6 +60,7 @@ const ProfileItem: React.FC<Props> = (props) => {
const [selecting, setSelecting] = useState(false) const [selecting, setSelecting] = useState(false)
const [openInfoEditor, setOpenInfoEditor] = useState(false) const [openInfoEditor, setOpenInfoEditor] = useState(false)
const [openFileEditor, setOpenFileEditor] = useState(false) const [openFileEditor, setOpenFileEditor] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { const {
attributes, attributes,
listeners, listeners,
@ -143,6 +144,12 @@ const ProfileItem: React.FC<Props> = (props) => {
} }
} }
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setDropdownOpen(true)
}
useEffect(() => { useEffect(() => {
if (isDragging) { if (isDragging) {
setTimeout(() => { setTimeout(() => {
@ -155,6 +162,8 @@ const ProfileItem: React.FC<Props> = (props) => {
} }
}, [isDragging]) }, [isDragging])
return ( return (
<div <div
className="grid col-span-1" className="grid col-span-1"
@ -173,6 +182,7 @@ const ProfileItem: React.FC<Props> = (props) => {
updateProfileItem={updateProfileItem} updateProfileItem={updateProfileItem}
/> />
)} )}
<Card <Card
as="div" as="div"
fullWidth fullWidth
@ -184,6 +194,7 @@ const ProfileItem: React.FC<Props> = (props) => {
setSelecting(false) setSelecting(false)
}) })
}} }}
onContextMenu={handleContextMenu}
className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''}`} className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''}`}
> >
<div ref={setNodeRef} {...attributes} {...listeners} className="w-full h-full"> <div ref={setNodeRef} {...attributes} {...listeners} className="w-full h-full">
@ -218,7 +229,10 @@ const ProfileItem: React.FC<Props> = (props) => {
</Tooltip> </Tooltip>
)} )}
<Dropdown> <Dropdown
isOpen={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<DropdownTrigger> <DropdownTrigger>
<Button isIconOnly size="sm" variant="light" color="default"> <Button isIconOnly size="sm" variant="light" color="default">
<IoMdMore <IoMdMore

View File

@ -1,6 +1,7 @@
import { Button, Card, CardBody } from '@heroui/react' import { Button, Card, CardBody } from '@heroui/react'
import { mihomoUnfixedProxy } from '@renderer/utils/ipc' import { mihomoUnfixedProxy } from '@renderer/utils/ipc'
import React, { useMemo, useState } from 'react' import React from 'react'
import { useMemo, useState } from 'react'
import { FaMapPin } from 'react-icons/fa6' import { FaMapPin } from 'react-icons/fa6'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -12,11 +13,12 @@ interface Props {
group: IMihomoMixedGroup group: IMihomoMixedGroup
onSelect: (group: string, proxy: string) => void onSelect: (group: string, proxy: string) => void
selected: boolean selected: boolean
isGroupTesting?: boolean
} }
const ProxyItem: React.FC<Props> = (props) => { const ProxyItem: React.FC<Props> = (props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { mutateProxies, proxyDisplayMode, group, proxy, selected, onSelect, onProxyDelay } = 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) {
@ -26,6 +28,9 @@ const ProxyItem: React.FC<Props> = (props) => {
}, [proxy]) }, [proxy])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const isLoading = loading || isGroupTesting
function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' { function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' {
if (delay === -1) return 'primary' if (delay === -1) return 'primary'
if (delay === 0) return 'danger' if (delay === 0) return 'danger'
@ -106,7 +111,7 @@ const ProxyItem: React.FC<Props> = (props) => {
<Button <Button
isIconOnly isIconOnly
title={proxy.type} title={proxy.type}
isLoading={loading} isLoading={isLoading}
color={delayColor(delay)} color={delayColor(delay)}
onPress={onDelay} onPress={onDelay}
variant="light" variant="light"
@ -144,11 +149,11 @@ const ProxyItem: React.FC<Props> = (props) => {
<Button <Button
isIconOnly isIconOnly
title={proxy.type} title={proxy.type}
isLoading={loading} isLoading={isLoading}
color={delayColor(delay)} color={delayColor(delay)}
onPress={onDelay} onPress={onDelay}
variant="light" variant="light"
className="h-[24px] 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(delay)} {delayText(delay)}

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import SettingCard from '../base/base-setting-card' import SettingCard from '../base/base-setting-card'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { Button, Input } from '@heroui/react' import { Button, Input, Select, SelectItem } from '@heroui/react'
import { listWebdavBackups, webdavBackup } from '@renderer/utils/ipc' import { listWebdavBackups, webdavBackup } from '@renderer/utils/ipc'
import WebdavRestoreModal from './webdav-restore-modal' import WebdavRestoreModal from './webdav-restore-modal'
import debounce from '@renderer/utils/debounce' import debounce from '@renderer/utils/debounce'
@ -11,16 +11,31 @@ import { useTranslation } from 'react-i18next'
const WebdavConfig: React.FC = () => { const WebdavConfig: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { webdavUrl, webdavUsername, webdavPassword, webdavDir = 'mihomo-party' } = appConfig || {} const {
webdavUrl,
webdavUsername,
webdavPassword,
webdavDir = 'mihomo-party',
webdavMaxBackups = 0
} = appConfig || {}
const [backuping, setBackuping] = useState(false) const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false) const [restoring, setRestoring] = useState(false)
const [filenames, setFilenames] = useState<string[]>([]) const [filenames, setFilenames] = useState<string[]>([])
const [restoreOpen, setRestoreOpen] = useState(false) const [restoreOpen, setRestoreOpen] = useState(false)
const [webdav, setWebdav] = useState({ webdavUrl, webdavUsername, webdavPassword, webdavDir }) const [webdav, setWebdav] = useState({
const setWebdavDebounce = debounce(({ webdavUrl, webdavUsername, webdavPassword, webdavDir }) => { webdavUrl,
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword, webdavDir }) webdavUsername,
}, 500) webdavPassword,
webdavDir,
webdavMaxBackups
})
const setWebdavDebounce = debounce(
({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups }) => {
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups })
},
500
)
const handleBackup = async (): Promise<void> => { const handleBackup = async (): Promise<void> => {
setBackuping(true) setBackuping(true)
try { try {
@ -98,6 +113,28 @@ const WebdavConfig: React.FC = () => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('webdav.maxBackups')} divider>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[150px]"
size="sm"
selectedKeys={new Set([webdav.webdavMaxBackups.toString()])}
aria-label={t('webdav.maxBackups')}
onSelectionChange={(v) => {
const value = Number.parseInt(Array.from(v)[0] as string, 10)
setWebdav({ ...webdav, webdavMaxBackups: value })
setWebdavDebounce({ ...webdav, webdavMaxBackups: value })
}}
>
<SelectItem key="0">{t('webdav.noLimit')}</SelectItem>
<SelectItem key="1">1</SelectItem>
<SelectItem key="3">3</SelectItem>
<SelectItem key="5">5</SelectItem>
<SelectItem key="10">10</SelectItem>
<SelectItem key="15">15</SelectItem>
<SelectItem key="20">20</SelectItem>
</Select>
</SettingItem>
<div className="flex justify0between"> <div className="flex justify0between">
<Button isLoading={backuping} fullWidth size="sm" className="mr-1" onPress={handleBackup}> <Button isLoading={backuping} fullWidth size="sm" className="mr-1" onPress={handleBackup}>
{t('webdav.backup')} {t('webdav.backup')}

View File

@ -19,6 +19,13 @@
"common.prev": "Previous", "common.prev": "Previous",
"common.done": "Done", "common.done": "Done",
"common.notification.restartRequired": "Restart required for changes to take effect", "common.notification.restartRequired": "Restart required for changes to take effect",
"common.notification.systemProxyEnabled": "System proxy enabled",
"common.notification.systemProxyDisabled": "System proxy disabled",
"common.notification.tunEnabled": "TUN enabled",
"common.notification.tunDisabled": "TUN disabled",
"common.notification.ruleMode": "Rule Mode",
"common.notification.globalMode": "Global Mode",
"common.notification.directMode": "Direct Mode",
"common.error.appCrash": "Application crashed :( Please submit the following information to the developer to troubleshoot", "common.error.appCrash": "Application crashed :( Please submit the following information to the developer to troubleshoot",
"common.error.copyErrorMessage": "Copy Error Message", "common.error.copyErrorMessage": "Copy Error Message",
"common.error.invalidCron": "Invalid Cron expression", "common.error.invalidCron": "Invalid Cron expression",
@ -172,6 +179,8 @@
"webdav.dir": "WebDAV Backup Directory", "webdav.dir": "WebDAV Backup Directory",
"webdav.username": "WebDAV Username", "webdav.username": "WebDAV Username",
"webdav.password": "WebDAV Password", "webdav.password": "WebDAV Password",
"webdav.maxBackups": "Max Backups",
"webdav.noLimit": "No Limit",
"webdav.backup": "Backup", "webdav.backup": "Backup",
"webdav.restore.title": "Restore Backup", "webdav.restore.title": "Restore Backup",
"webdav.restore.noBackups": "No backups available", "webdav.restore.noBackups": "No backups available",

View File

@ -19,6 +19,13 @@
"common.prev": "قبلی", "common.prev": "قبلی",
"common.done": "انجام شد", "common.done": "انجام شد",
"common.notification.restartRequired": "برای اعمال تغییرات نیاز به راه‌اندازی مجدد است", "common.notification.restartRequired": "برای اعمال تغییرات نیاز به راه‌اندازی مجدد است",
"common.notification.systemProxyEnabled": "پراکسی سیستم فعال شد",
"common.notification.systemProxyDisabled": "پراکسی سیستم غیرفعال شد",
"common.notification.tunEnabled": "TUN فعال شد",
"common.notification.tunDisabled": "TUN غیرفعال شد",
"common.notification.ruleMode": "حالت قانون",
"common.notification.globalMode": "حالت جهانی",
"common.notification.directMode": "حالت مستقیم",
"common.error.appCrash": "برنامه دچار خطا شد :( لطفا اطلاعات زیر را برای رفع مشکل به توسعه‌دهنده ارسال کنید", "common.error.appCrash": "برنامه دچار خطا شد :( لطفا اطلاعات زیر را برای رفع مشکل به توسعه‌دهنده ارسال کنید",
"common.error.copyErrorMessage": "کپی پیام خطا", "common.error.copyErrorMessage": "کپی پیام خطا",
"common.error.invalidCron": "عبارت Cron نامعتبر است", "common.error.invalidCron": "عبارت Cron نامعتبر است",
@ -172,6 +179,8 @@
"webdav.dir": "پوشه پشتیبان‌گیری WebDAV", "webdav.dir": "پوشه پشتیبان‌گیری WebDAV",
"webdav.username": "نام کاربری WebDAV", "webdav.username": "نام کاربری WebDAV",
"webdav.password": "رمز عبور WebDAV", "webdav.password": "رمز عبور WebDAV",
"webdav.maxBackups": "حداکثر نسخه پشتیبان",
"webdav.noLimit": "بدون محدودیت",
"webdav.backup": "پشتیبان‌گیری", "webdav.backup": "پشتیبان‌گیری",
"webdav.restore.title": "بازیابی پشتیبان", "webdav.restore.title": "بازیابی پشتیبان",
"webdav.restore.noBackups": "هیچ پشتیبانی موجود نیست", "webdav.restore.noBackups": "هیچ پشتیبانی موجود نیست",

View File

@ -19,6 +19,13 @@
"common.prev": "Назад", "common.prev": "Назад",
"common.done": "Готово", "common.done": "Готово",
"common.notification.restartRequired": "Требуется перезапуск для применения изменений", "common.notification.restartRequired": "Требуется перезапуск для применения изменений",
"common.notification.systemProxyEnabled": "Системный прокси включен",
"common.notification.systemProxyDisabled": "Системный прокси отключен",
"common.notification.tunEnabled": "TUN включен",
"common.notification.tunDisabled": "TUN отключен",
"common.notification.ruleMode": "Режим правил",
"common.notification.globalMode": "Глобальный режим",
"common.notification.directMode": "Прямой режим",
"common.error.appCrash": "Приложение завершилось аварийно :( Пожалуйста, отправьте следующую информацию разработчику для устранения проблемы", "common.error.appCrash": "Приложение завершилось аварийно :( Пожалуйста, отправьте следующую информацию разработчику для устранения проблемы",
"common.error.copyErrorMessage": "Копировать сообщение об ошибке", "common.error.copyErrorMessage": "Копировать сообщение об ошибке",
"common.error.invalidCron": "Неверное выражение Cron", "common.error.invalidCron": "Неверное выражение Cron",
@ -172,6 +179,8 @@
"webdav.dir": "Каталог резервных копий WebDAV", "webdav.dir": "Каталог резервных копий WebDAV",
"webdav.username": "Имя пользователя WebDAV", "webdav.username": "Имя пользователя WebDAV",
"webdav.password": "Пароль WebDAV", "webdav.password": "Пароль WebDAV",
"webdav.maxBackups": "Максимум резервных копий",
"webdav.noLimit": "Без ограничений",
"webdav.backup": "Резервное копирование", "webdav.backup": "Резервное копирование",
"webdav.restore.title": "Восстановление резервной копии", "webdav.restore.title": "Восстановление резервной копии",
"webdav.restore.noBackups": "Нет доступных резервных копий", "webdav.restore.noBackups": "Нет доступных резервных копий",

View File

@ -19,6 +19,13 @@
"common.prev": "上一步", "common.prev": "上一步",
"common.done": "完成", "common.done": "完成",
"common.notification.restartRequired": "需要重启应用以使更改生效", "common.notification.restartRequired": "需要重启应用以使更改生效",
"common.notification.systemProxyEnabled": "系统代理已启用",
"common.notification.systemProxyDisabled": "系统代理已关闭",
"common.notification.tunEnabled": "TUN 已启用",
"common.notification.tunDisabled": "TUN 已关闭",
"common.notification.ruleMode": "规则模式",
"common.notification.globalMode": "全局模式",
"common.notification.directMode": "直连模式",
"common.error.appCrash": "应用崩溃了 :( 请将以下信息提交给开发者以排查错误", "common.error.appCrash": "应用崩溃了 :( 请将以下信息提交给开发者以排查错误",
"common.error.copyErrorMessage": "复制报错信息", "common.error.copyErrorMessage": "复制报错信息",
"common.error.invalidCron": "无效的 Cron 表达式", "common.error.invalidCron": "无效的 Cron 表达式",
@ -172,6 +179,8 @@
"webdav.dir": "WebDAV 备份目录", "webdav.dir": "WebDAV 备份目录",
"webdav.username": "WebDAV 用户名", "webdav.username": "WebDAV 用户名",
"webdav.password": "WebDAV 密码", "webdav.password": "WebDAV 密码",
"webdav.maxBackups": "最大备份数",
"webdav.noLimit": "不限制",
"webdav.backup": "备份", "webdav.backup": "备份",
"webdav.restore.title": "恢复备份", "webdav.restore.title": "恢复备份",
"webdav.restore.noBackups": "还没有备份", "webdav.restore.noBackups": "还没有备份",

View File

@ -401,7 +401,7 @@ const Mihomo: React.FC = () => {
<Input <Input
size="sm" size="sm"
fullWidth fullWidth
placeholder="IP 段" placeholder={t('mihomo.ipSegment.placeholder')}
value={ipcidr || ''} value={ipcidr || ''}
onValueChange={(v) => { onValueChange={(v) => {
if (index === lanAllowedIpsInput.length) { if (index === lanAllowedIpsInput.length) {
@ -451,7 +451,7 @@ const Mihomo: React.FC = () => {
<Input <Input
size="sm" size="sm"
fullWidth fullWidth
placeholder="IP 段" placeholder={t('mihomo.username.placeholder')}
value={ipcidr || ''} value={ipcidr || ''}
onValueChange={(v) => { onValueChange={(v) => {
if (index === lanDisallowedIpsInput.length) { if (index === lanDisallowedIpsInput.length) {

View File

@ -29,7 +29,6 @@ const useProxyState = (groups: IMihomoMixedGroup[]): {
virtuosoRef: React.RefObject<GroupedVirtuosoHandle>; virtuosoRef: React.RefObject<GroupedVirtuosoHandle>;
isOpen: boolean[]; isOpen: boolean[];
setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>;
onScroll: (e: React.UIEvent<HTMLElement>) => void;
} => { } => {
const virtuosoRef = useRef<GroupedVirtuosoHandle>(null) const virtuosoRef = useRef<GroupedVirtuosoHandle>(null)
@ -56,10 +55,7 @@ const useProxyState = (groups: IMihomoMixedGroup[]): {
return { return {
virtuosoRef, virtuosoRef,
isOpen, isOpen,
setIsOpen, setIsOpen
onScroll: useCallback((_e: React.UIEvent<HTMLElement>) => {
// 空实现,不再保存滚动位置
}, [])
} }
} }
@ -78,8 +74,9 @@ const Proxies: React.FC = () => {
} = appConfig || {} } = appConfig || {}
const [cols, setCols] = useState(1) const [cols, setCols] = useState(1)
const { virtuosoRef, isOpen, setIsOpen, onScroll } = useProxyState(groups) const { virtuosoRef, isOpen, setIsOpen } = useProxyState(groups)
const [delaying, setDelaying] = useState(Array(groups.length).fill(false)) const [delaying, setDelaying] = useState(Array(groups.length).fill(false))
const [proxyDelaying, setProxyDelaying] = useState<Record<string, boolean>>({})
const [searchValue, setSearchValue] = useState(Array(groups.length).fill('')) const [searchValue, setSearchValue] = useState(Array(groups.length).fill(''))
const { groupCounts, allProxies } = useMemo(() => { const { groupCounts, allProxies } = useMemo(() => {
const groupCounts: number[] = [] const groupCounts: number[] = []
@ -139,6 +136,16 @@ const Proxies: React.FC = () => {
return newDelaying return newDelaying
}) })
// 本组测试状态
const groupProxies = allProxies[index]
setProxyDelaying((prev) => {
const newProxyDelaying = { ...prev }
groupProxies.forEach(proxy => {
newProxyDelaying[proxy.name] = true
})
return newProxyDelaying
})
try { try {
// 限制并发数量 // 限制并发数量
const result: Promise<void>[] = [] const result: Promise<void>[] = []
@ -150,6 +157,12 @@ const Proxies: React.FC = () => {
} catch { } catch {
// ignore // ignore
} finally { } finally {
// 立即更新状态
setProxyDelaying((prev) => {
const newProxyDelaying = { ...prev }
delete newProxyDelaying[proxy.name]
return newProxyDelaying
})
mutate() mutate()
} }
}) })
@ -169,6 +182,14 @@ const Proxies: React.FC = () => {
newDelaying[index] = false newDelaying[index] = false
return newDelaying return newDelaying
}) })
// 状态清理
setProxyDelaying((prev) => {
const newProxyDelaying = { ...prev }
groupProxies.forEach(proxy => {
delete newProxyDelaying[proxy.name]
})
return newProxyDelaying
})
} }
}, [allProxies, groups, delayTestConcurrency, mutate]) }, [allProxies, groups, delayTestConcurrency, mutate])
@ -256,7 +277,6 @@ const Proxies: React.FC = () => {
<GroupedVirtuoso <GroupedVirtuoso
ref={virtuosoRef} ref={virtuosoRef}
groupCounts={groupCounts} groupCounts={groupCounts}
onScroll={onScroll}
defaultItemHeight={80} defaultItemHeight={80}
increaseViewportBy={{ top: 300, bottom: 300 }} increaseViewportBy={{ top: 300, bottom: 300 }}
overscan={500} overscan={500}
@ -434,6 +454,7 @@ const Proxies: React.FC = () => {
allProxies[groupIndex][innerIndex * cols + i]?.name === allProxies[groupIndex][innerIndex * cols + i]?.name ===
groups[groupIndex].now groups[groupIndex].now
} }
isGroupTesting={!!proxyDelaying[allProxies[groupIndex][innerIndex * cols + i].name]}
/> />
) )
})} })}

View File

@ -284,6 +284,7 @@ interface IAppConfig {
webdavDir?: string webdavDir?: string
webdavUsername?: string webdavUsername?: string
webdavPassword?: string webdavPassword?: string
webdavMaxBackups?: number
useNameserverPolicy: boolean useNameserverPolicy: boolean
nameserverPolicy: { [key: string]: string | string[] } nameserverPolicy: { [key: string]: string | string[] }
showWindowShortcut?: string showWindowShortcut?: string