mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-28 05:30:29 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364578f210 | ||
|
|
674cefcc29 | ||
|
|
827e744601 | ||
|
|
151726fcce | ||
|
|
6f3845151d | ||
|
|
a9b5887e15 | ||
|
|
727fd48684 | ||
|
|
e2ab88f4e2 | ||
|
|
1a5c001dbd | ||
|
|
6744e14c66 | ||
|
|
62a04cc5ad | ||
|
|
555130001b | ||
|
|
78f9211ebe | ||
|
|
c6d0e05851 | ||
|
|
caf962f921 | ||
|
|
0b8c77d200 | ||
|
|
d6e456302e | ||
|
|
daa8e7ba7e | ||
|
|
27ab6d1b5c | ||
|
|
da5336ee36 | ||
|
|
bdb7fb6489 | ||
|
|
1a1992c617 | ||
|
|
511eb0c7fa | ||
|
|
f54ffcf42b | ||
|
|
5a84d6485e | ||
|
|
afe93774b0 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@ -206,7 +206,7 @@ jobs:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x build/pkg-scripts/postinstall
|
||||
chmod +x build/pkg-scripts/postinstall build/pkg-scripts/preinstall
|
||||
pnpm build:mac --${{ matrix.arch }}
|
||||
- name: Setup temporary installer signing keychain
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
@ -281,7 +281,7 @@ jobs:
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
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 }}
|
||||
- name: Setup temporary installer signing keychain
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 设置日志文件
|
||||
LOG_FILE="/tmp/mihomo-party-install.log"
|
||||
exec > "$LOG_FILE" 2>&1
|
||||
|
||||
log() {
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# 检查 root 权限
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root"
|
||||
log "Error: Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -13,28 +21,51 @@ if [[ $2 == *".app" ]]; then
|
||||
else
|
||||
APP_PATH="$2/Mihomo Party.app"
|
||||
fi
|
||||
|
||||
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
|
||||
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
|
||||
|
||||
# 设置核心文件权限
|
||||
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
log "Starting installation..."
|
||||
|
||||
# 创建目录并复制 helper
|
||||
mkdir -p /Library/PrivilegedHelperTools
|
||||
if [ ! -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" ]; then
|
||||
echo "Helper file not found"
|
||||
# 创建目录并设置权限
|
||||
log "Creating directories and setting permissions..."
|
||||
mkdir -p "/Library/PrivilegedHelperTools"
|
||||
chmod 755 "/Library/PrivilegedHelperTools"
|
||||
chown root:wheel "/Library/PrivilegedHelperTools"
|
||||
|
||||
# 设置核心文件权限
|
||||
log "Setting core file permissions..."
|
||||
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo" ]; then
|
||||
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
log "Set permissions for mihomo"
|
||||
else
|
||||
log "Warning: mihomo binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo"
|
||||
fi
|
||||
|
||||
if [ -f "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha" ]; then
|
||||
chown root:admin "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
chmod +s "$APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
log "Set permissions for mihomo-alpha"
|
||||
else
|
||||
log "Warning: mihomo-alpha binary not found at $APP_PATH/Contents/Resources/sidecar/mihomo-alpha"
|
||||
fi
|
||||
|
||||
# 复制 helper 工具
|
||||
log "Installing helper tool..."
|
||||
if [ -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" ]; then
|
||||
cp -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" "$HELPER_PATH"
|
||||
chown root:wheel "$HELPER_PATH"
|
||||
chmod 544 "$HELPER_PATH"
|
||||
log "Helper tool installed successfully"
|
||||
else
|
||||
log "Error: Helper file not found at $APP_PATH/Contents/Resources/files/party.mihomo.helper"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$APP_PATH/Contents/Resources/files/party.mihomo.helper" "$HELPER_PATH"
|
||||
chown root:wheel "$HELPER_PATH"
|
||||
chmod 544 "$HELPER_PATH"
|
||||
|
||||
# 创建并配置 LaunchDaemon
|
||||
mkdir -p /Library/LaunchDaemons
|
||||
log "Configuring LaunchDaemon..."
|
||||
mkdir -p "/Library/LaunchDaemons"
|
||||
cat << EOF > "$LAUNCH_DAEMON"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@ -43,7 +74,9 @@ cat << EOF > "$LAUNCH_DAEMON"
|
||||
<key>Label</key>
|
||||
<string>party.mihomo.helper</string>
|
||||
<key>AssociatedBundleIdentifiers</key>
|
||||
<array>
|
||||
<string>party.mihomo.app</string>
|
||||
</array>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>Program</key>
|
||||
@ -58,18 +91,83 @@ EOF
|
||||
|
||||
chown root:wheel "$LAUNCH_DAEMON"
|
||||
chmod 644 "$LAUNCH_DAEMON"
|
||||
log "LaunchDaemon configured"
|
||||
|
||||
# 加载并启动服务
|
||||
launchctl unload "$LAUNCH_DAEMON" || true
|
||||
# 验证关键文件
|
||||
log "Verifying installation..."
|
||||
if [ ! -x "$HELPER_PATH" ]; then
|
||||
log "Error: Helper tool is not executable: $HELPER_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查二进制文件有效性
|
||||
if ! file "$HELPER_PATH" | grep -q "Mach-O"; then
|
||||
log "Error: Helper tool is not a valid Mach-O binary"
|
||||
exit 1
|
||||
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
|
||||
echo "Failed to load helper service"
|
||||
log "Error: Failed to load service with both methods"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! launchctl start party.mihomo.helper; then
|
||||
echo "Failed to start helper service"
|
||||
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"
|
||||
|
||||
# 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
|
||||
|
||||
echo "Installation completed successfully"
|
||||
exit 0
|
||||
|
||||
26
build/pkg-scripts/preinstall
Normal file
26
build/pkg-scripts/preinstall
Normal file
@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 检查 root 权限
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
|
||||
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
|
||||
|
||||
# 停止并卸载现有的 LaunchDaemon
|
||||
if [ -f "$LAUNCH_DAEMON" ]; then
|
||||
launchctl unload "$LAUNCH_DAEMON" 2>/dev/null || true
|
||||
rm -f "$LAUNCH_DAEMON"
|
||||
fi
|
||||
|
||||
# 移除 helper 工具
|
||||
rm -f "$HELPER_PATH"
|
||||
|
||||
# 清理可能存在的旧版本文件
|
||||
rm -rf "/Applications/Mihomo Party.app"
|
||||
rm -rf "/Applications/Mihomo\\ Party.app"
|
||||
|
||||
exit 0
|
||||
123
changelog.md
123
changelog.md
@ -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
|
||||
**注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件**
|
||||
|
||||
@ -17,73 +70,3 @@
|
||||
- 修复多语言翻译
|
||||
- 修复 defaultBypass 几乎总是 Windows 默认绕过设置 (#602)
|
||||
- 修复重置防火墙时发生的错误,因为没有指定防火墙规则 (#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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mihomo-party",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.7",
|
||||
"description": "Mihomo Party",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "mihomo-party-org",
|
||||
|
||||
27
scripts/cleanup-mac.sh
Executable file
27
scripts/cleanup-mac.sh
Executable file
@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "=== Mihomo Party Cleanup Tool ==="
|
||||
echo "This script will remove all Mihomo Party related files and services."
|
||||
read -p "Are you sure you want to continue? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop and unload services
|
||||
echo "Stopping services..."
|
||||
sudo launchctl unload /Library/LaunchDaemons/party.mihomo.helper.plist 2>/dev/null || true
|
||||
|
||||
# Remove files
|
||||
echo "Removing files..."
|
||||
sudo rm -f /Library/LaunchDaemons/party.mihomo.helper.plist
|
||||
sudo rm -f /Library/PrivilegedHelperTools/party.mihomo.helper
|
||||
sudo rm -rf "/Applications/Mihomo Party.app"
|
||||
sudo rm -rf "/Applications/Mihomo\\ Party.app"
|
||||
sudo rm -rf ~/Library/Application\ Support/mihomo-party
|
||||
sudo rm -rf ~/Library/Caches/mihomo-party
|
||||
sudo rm -f ~/Library/Preferences/party.mihomo.app.helper.plist
|
||||
sudo rm -f ~/Library/Preferences/party.mihomo.app.plist
|
||||
|
||||
echo "Cleanup complete. Please restart your computer to complete the process."
|
||||
@ -158,7 +158,7 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
|
||||
resolve([
|
||||
new Promise((resolve) => {
|
||||
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 {
|
||||
mainWindow?.webContents.send('groupsUpdated')
|
||||
mainWindow?.webContents.send('rulesUpdated')
|
||||
|
||||
@ -10,8 +10,10 @@ import { createTray, hideDockIcon, showDockIcon } from './resolve/tray'
|
||||
import { init } from './utils/init'
|
||||
import { join } from 'path'
|
||||
import { initShortcut } from './resolve/shortcut'
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import { execSync, spawn, exec } from 'child_process'
|
||||
import { createElevateTask } from './sys/misc'
|
||||
import { promisify } from 'util'
|
||||
import { stat } from 'fs/promises'
|
||||
import { initProfileUpdater } from './core/profileUpdater'
|
||||
import { existsSync, writeFileSync } from 'fs'
|
||||
import { exePath, taskDir } from './utils/dirs'
|
||||
@ -22,6 +24,29 @@ import iconv from 'iconv-lite'
|
||||
import { initI18n } from '../shared/i18n'
|
||||
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
|
||||
export let mainWindow: BrowserWindow | null = null
|
||||
|
||||
@ -59,11 +84,26 @@ if (process.platform === 'win32' && !is.dev && !process.argv.includes('noadmin')
|
||||
}
|
||||
}
|
||||
|
||||
async function initApp(): Promise<void> {
|
||||
await fixUserDataPermissions()
|
||||
}
|
||||
|
||||
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 {
|
||||
const script = `while kill -0 ${process.pid} 2>/dev/null; do
|
||||
|
||||
@ -19,7 +19,8 @@ export async function webdavBackup(): Promise<boolean> {
|
||||
webdavUrl = '',
|
||||
webdavUsername = '',
|
||||
webdavPassword = '',
|
||||
webdavDir = 'mihomo-party'
|
||||
webdavDir = 'mihomo-party',
|
||||
webdavMaxBackups = 0
|
||||
} = await getAppConfig()
|
||||
const zip = new AdmZip()
|
||||
|
||||
@ -44,7 +45,41 @@ export async function webdavBackup(): Promise<boolean> {
|
||||
// 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> {
|
||||
|
||||
@ -7,7 +7,8 @@ import path from 'path'
|
||||
|
||||
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">
|
||||
<Triggers>
|
||||
<LogonTrigger>
|
||||
@ -48,6 +49,7 @@ const taskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
</Actions>
|
||||
</Task>
|
||||
`
|
||||
}
|
||||
|
||||
export async function checkAutoRun(): Promise<boolean> {
|
||||
if (process.platform === 'win32') {
|
||||
@ -80,7 +82,7 @@ export async function enableAutoRun(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
const execPromise = promisify(exec)
|
||||
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(
|
||||
`%SystemRoot%\\System32\\schtasks.exe /create /tn "${appName}" /xml "${taskFilePath}" /f`
|
||||
)
|
||||
|
||||
@ -68,7 +68,8 @@ export function setNativeTheme(theme: 'system' | 'light' | 'dark'): void {
|
||||
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">
|
||||
<Triggers />
|
||||
<Principals>
|
||||
@ -104,10 +105,11 @@ const elevateTaskXml = `<?xml version="1.0" encoding="UTF-16"?>
|
||||
</Actions>
|
||||
</Task>
|
||||
`
|
||||
}
|
||||
|
||||
export function createElevateTask(): void {
|
||||
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(
|
||||
path.join(resourcesFilesDir(), 'mihomo-party-run.exe'),
|
||||
path.join(taskDir(), 'mihomo-party-run.exe')
|
||||
|
||||
@ -7,6 +7,7 @@ import path from 'path'
|
||||
import { resourcesFilesDir } from '../utils/dirs'
|
||||
import { net } from 'electron'
|
||||
import axios from 'axios'
|
||||
import fs from 'fs'
|
||||
|
||||
let defaultBypass: string[]
|
||||
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`)
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
await axios.post(
|
||||
await helperRequest(() =>
|
||||
axios.post(
|
||||
'http://localhost/pac',
|
||||
{ url: `http://${host || '127.0.0.1'}:${pacPort}/pac` },
|
||||
{
|
||||
socketPath: helperSocketPath
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
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(','))
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
await axios.post(
|
||||
await helperRequest(() =>
|
||||
axios.post(
|
||||
'http://localhost/global',
|
||||
{ host: host || '127.0.0.1', port: port.toString(), bypass: bypass.join(',') },
|
||||
{
|
||||
socketPath: helperSocketPath
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
triggerManualProxy(true, host || '127.0.0.1', port, bypass.join(','))
|
||||
}
|
||||
@ -134,11 +139,84 @@ async function disableSysProxy(): Promise<void> {
|
||||
triggerManualProxy(false, '', 0, '')
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
await axios.get('http://localhost/off', {
|
||||
await helperRequest(() =>
|
||||
axios.get('http://localhost/off', {
|
||||
socketPath: helperSocketPath
|
||||
})
|
||||
)
|
||||
} else {
|
||||
triggerAutoProxy(false, '')
|
||||
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
|
||||
}
|
||||
|
||||
@ -18,7 +18,13 @@ export function dataDir(): 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)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
@ -22,8 +22,10 @@ import {
|
||||
defaultProfileConfig
|
||||
} from './template'
|
||||
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 { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import {
|
||||
startPacServer,
|
||||
@ -40,7 +42,32 @@ import {
|
||||
import { app } from 'electron'
|
||||
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> {
|
||||
await fixDataDirPermissions()
|
||||
|
||||
if (!existsSync(dataDir())) {
|
||||
await mkdir(dataDir())
|
||||
}
|
||||
|
||||
@ -89,6 +89,7 @@ import { getImageDataURL } from './image'
|
||||
import { startMonitor } from '../resolve/trafficMonitor'
|
||||
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
|
||||
import i18next from 'i18next'
|
||||
import { addProfileUpdater } from '../core/profileUpdater'
|
||||
|
||||
function ipcErrorWrapper<T>( // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fn: (...args: any[]) => Promise<T> // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -163,6 +164,7 @@ export function registerIpcMainHandlers(): void {
|
||||
ipcMain.handle('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))
|
||||
ipcMain.handle('addProfileItem', (_e, item) => ipcErrorWrapper(addProfileItem)(item))
|
||||
ipcMain.handle('removeProfileItem', (_e, id) => ipcErrorWrapper(removeProfileItem)(id))
|
||||
ipcMain.handle('addProfileUpdater', (_e, item) => ipcErrorWrapper(addProfileUpdater)(item))
|
||||
ipcMain.handle('getOverrideConfig', (_e, force) => ipcErrorWrapper(getOverrideConfig)(force))
|
||||
ipcMain.handle('setOverrideConfig', (_e, config) => ipcErrorWrapper(setOverrideConfig)(config))
|
||||
ipcMain.handle('getOverrideItem', (_e, id) => ipcErrorWrapper(getOverrideItem)(id))
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
Input
|
||||
} from '@heroui/react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void
|
||||
onConfirm: (script: string) => void
|
||||
}
|
||||
|
||||
const BasePasswordModal: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const { onCancel, onConfirm } = props
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
return (
|
||||
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={true}>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex app-drag">{t('common.enterAdminPassword')}</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input fullWidth type="password" value={password} onValueChange={setPassword} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size="sm" variant="light" onPress={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onPress={() => onConfirm(password)}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasePasswordModal
|
||||
@ -16,7 +16,7 @@ import {
|
||||
import React, { useState } from 'react'
|
||||
import SettingItem from '../base/base-setting-item'
|
||||
import { useOverrideConfig } from '@renderer/hooks/use-override-config'
|
||||
import { restartCore } from '@renderer/utils/ipc'
|
||||
import { restartCore, addProfileUpdater } from '@renderer/utils/ipc'
|
||||
import { MdDeleteForever } from 'react-icons/md'
|
||||
import { FaPlus } from 'react-icons/fa6'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -36,13 +36,15 @@ const EditInfoModal: React.FC<Props> = (props) => {
|
||||
|
||||
const onSave = async (): Promise<void> => {
|
||||
try {
|
||||
await updateProfileItem({
|
||||
const updatedItem = {
|
||||
...values,
|
||||
override: values.override?.filter(
|
||||
(i) =>
|
||||
overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global
|
||||
)
|
||||
})
|
||||
};
|
||||
await updateProfileItem(updatedItem)
|
||||
await addProfileUpdater(updatedItem)
|
||||
await restartCore()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
|
||||
@ -60,6 +60,7 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
const [openInfoEditor, setOpenInfoEditor] = useState(false)
|
||||
const [openFileEditor, setOpenFileEditor] = useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@ -143,6 +144,12 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDropdownOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setTimeout(() => {
|
||||
@ -155,6 +162,8 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid col-span-1"
|
||||
@ -173,6 +182,7 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
updateProfileItem={updateProfileItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
as="div"
|
||||
fullWidth
|
||||
@ -184,6 +194,7 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
setSelecting(false)
|
||||
})
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`${isCurrent ? 'bg-primary' : ''} ${selecting ? 'blur-sm' : ''}`}
|
||||
>
|
||||
<div ref={setNodeRef} {...attributes} {...listeners} className="w-full h-full">
|
||||
@ -218,7 +229,10 @@ const ProfileItem: React.FC<Props> = (props) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Dropdown>
|
||||
<Dropdown
|
||||
isOpen={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly size="sm" variant="light" color="default">
|
||||
<IoMdMore
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Button, Card, CardBody } from '@heroui/react'
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
|
||||
@ -12,11 +13,12 @@ interface Props {
|
||||
group: IMihomoMixedGroup
|
||||
onSelect: (group: string, proxy: string) => void
|
||||
selected: boolean
|
||||
isGroupTesting?: boolean
|
||||
}
|
||||
|
||||
const ProxyItem: React.FC<Props> = (props) => {
|
||||
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(() => {
|
||||
if (proxy.history.length > 0) {
|
||||
@ -26,6 +28,9 @@ const ProxyItem: React.FC<Props> = (props) => {
|
||||
}, [proxy])
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const isLoading = loading || isGroupTesting
|
||||
|
||||
function delayColor(delay: number): 'primary' | 'success' | 'warning' | 'danger' {
|
||||
if (delay === -1) return 'primary'
|
||||
if (delay === 0) return 'danger'
|
||||
@ -106,7 +111,7 @@ const ProxyItem: React.FC<Props> = (props) => {
|
||||
<Button
|
||||
isIconOnly
|
||||
title={proxy.type}
|
||||
isLoading={loading}
|
||||
isLoading={isLoading}
|
||||
color={delayColor(delay)}
|
||||
onPress={onDelay}
|
||||
variant="light"
|
||||
@ -144,11 +149,11 @@ const ProxyItem: React.FC<Props> = (props) => {
|
||||
<Button
|
||||
isIconOnly
|
||||
title={proxy.type}
|
||||
isLoading={loading}
|
||||
isLoading={isLoading}
|
||||
color={delayColor(delay)}
|
||||
onPress={onDelay}
|
||||
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">
|
||||
{delayText(delay)}
|
||||
|
||||
@ -50,25 +50,10 @@ const ProxyProvider: React.FC = () => {
|
||||
const providers = useMemo(() => {
|
||||
if (!data) return []
|
||||
return Object.values(data.providers)
|
||||
.map(provider => {
|
||||
if (provider.vehicleType === 'Inline' || provider.vehicleType === 'File') {
|
||||
return {
|
||||
...provider,
|
||||
subscriptionInfo: null
|
||||
}
|
||||
}
|
||||
return provider
|
||||
})
|
||||
|
||||
.filter(provider => 'subscriptionInfo' in provider)
|
||||
.filter((provider) => provider.vehicleType !== 'Compatible')
|
||||
.sort((a, b) => {
|
||||
if (a.vehicleType === 'File' && b.vehicleType !== 'File') {
|
||||
return -1
|
||||
}
|
||||
if (a.vehicleType !== 'File' && b.vehicleType === 'File') {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
const order = { File: 1, Inline: 2, HTTP: 3 }
|
||||
return (order[a.vehicleType] || 4) - (order[b.vehicleType] || 4)
|
||||
})
|
||||
}, [data])
|
||||
const [updating, setUpdating] = useState(Array(providers.length).fill(false))
|
||||
|
||||
@ -193,6 +193,7 @@ const GeneralConfig: React.FC = () => {
|
||||
selectionMode="multiple"
|
||||
selectedKeys={new Set(envType)}
|
||||
aria-label={t('settings.envType')}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({
|
||||
@ -389,6 +390,7 @@ const GeneralConfig: React.FC = () => {
|
||||
size="sm"
|
||||
selectedKeys={new Set([customTheme])}
|
||||
aria-label={t('settings.selectTheme')}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({ customTheme: v.currentKey as string })
|
||||
|
||||
@ -129,6 +129,7 @@ const MihomoConfig: React.FC = () => {
|
||||
size="sm"
|
||||
selectedKeys={new Set([proxyCols])}
|
||||
aria-label={t('mihomo.proxyColumns.title')}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
await patchAppConfig({ proxyCols: v.currentKey as 'auto' | '1' | '2' | '3' | '4' })
|
||||
}}
|
||||
@ -147,6 +148,7 @@ const MihomoConfig: React.FC = () => {
|
||||
className="w-[150px]"
|
||||
size="sm"
|
||||
selectedKeys={new Set([mihomoCpuPriority])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
try {
|
||||
await patchAppConfig({
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import SettingCard from '../base/base-setting-card'
|
||||
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 WebdavRestoreModal from './webdav-restore-modal'
|
||||
import debounce from '@renderer/utils/debounce'
|
||||
@ -11,16 +11,31 @@ import { useTranslation } from 'react-i18next'
|
||||
const WebdavConfig: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
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 [restoring, setRestoring] = useState(false)
|
||||
const [filenames, setFilenames] = useState<string[]>([])
|
||||
const [restoreOpen, setRestoreOpen] = useState(false)
|
||||
|
||||
const [webdav, setWebdav] = useState({ webdavUrl, webdavUsername, webdavPassword, webdavDir })
|
||||
const setWebdavDebounce = debounce(({ webdavUrl, webdavUsername, webdavPassword, webdavDir }) => {
|
||||
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword, webdavDir })
|
||||
}, 500)
|
||||
const [webdav, setWebdav] = useState({
|
||||
webdavUrl,
|
||||
webdavUsername,
|
||||
webdavPassword,
|
||||
webdavDir,
|
||||
webdavMaxBackups
|
||||
})
|
||||
const setWebdavDebounce = debounce(
|
||||
({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups }) => {
|
||||
patchAppConfig({ webdavUrl, webdavUsername, webdavPassword, webdavDir, webdavMaxBackups })
|
||||
},
|
||||
500
|
||||
)
|
||||
const handleBackup = async (): Promise<void> => {
|
||||
setBackuping(true)
|
||||
try {
|
||||
@ -98,6 +113,28 @@ const WebdavConfig: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<Button isLoading={backuping} fullWidth size="sm" className="mr-1" onPress={handleBackup}>
|
||||
{t('webdav.backup')}
|
||||
|
||||
@ -15,11 +15,17 @@
|
||||
"common.default": "Default",
|
||||
"common.close": "Close",
|
||||
"common.pinWindow": "Pin Window",
|
||||
"common.enterAdminPassword": "Please enter the administrator password",
|
||||
"common.next": "Next",
|
||||
"common.prev": "Previous",
|
||||
"common.done": "Done",
|
||||
"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.copyErrorMessage": "Copy Error Message",
|
||||
"common.error.invalidCron": "Invalid Cron expression",
|
||||
@ -173,6 +179,8 @@
|
||||
"webdav.dir": "WebDAV Backup Directory",
|
||||
"webdav.username": "WebDAV Username",
|
||||
"webdav.password": "WebDAV Password",
|
||||
"webdav.maxBackups": "Max Backups",
|
||||
"webdav.noLimit": "No Limit",
|
||||
"webdav.backup": "Backup",
|
||||
"webdav.restore.title": "Restore Backup",
|
||||
"webdav.restore.noBackups": "No backups available",
|
||||
|
||||
@ -15,11 +15,17 @@
|
||||
"common.default": "پیشفرض",
|
||||
"common.close": "بستن",
|
||||
"common.pinWindow": "پین کردن پنجره",
|
||||
"common.enterAdminPassword": "الرجاء إدخال كلمة مرور المسؤول",
|
||||
"common.next": "بعدی",
|
||||
"common.prev": "قبلی",
|
||||
"common.done": "انجام شد",
|
||||
"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.copyErrorMessage": "کپی پیام خطا",
|
||||
"common.error.invalidCron": "عبارت Cron نامعتبر است",
|
||||
@ -173,6 +179,8 @@
|
||||
"webdav.dir": "پوشه پشتیبانگیری WebDAV",
|
||||
"webdav.username": "نام کاربری WebDAV",
|
||||
"webdav.password": "رمز عبور WebDAV",
|
||||
"webdav.maxBackups": "حداکثر نسخه پشتیبان",
|
||||
"webdav.noLimit": "بدون محدودیت",
|
||||
"webdav.backup": "پشتیبانگیری",
|
||||
"webdav.restore.title": "بازیابی پشتیبان",
|
||||
"webdav.restore.noBackups": "هیچ پشتیبانی موجود نیست",
|
||||
|
||||
@ -15,11 +15,17 @@
|
||||
"common.default": "По умолчанию",
|
||||
"common.close": "Закрыть",
|
||||
"common.pinWindow": "Закрепить окно",
|
||||
"common.enterAdminPassword": "Введите пароль администратора",
|
||||
"common.next": "Далее",
|
||||
"common.prev": "Назад",
|
||||
"common.done": "Готово",
|
||||
"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.copyErrorMessage": "Копировать сообщение об ошибке",
|
||||
"common.error.invalidCron": "Неверное выражение Cron",
|
||||
@ -173,6 +179,8 @@
|
||||
"webdav.dir": "Каталог резервных копий WebDAV",
|
||||
"webdav.username": "Имя пользователя WebDAV",
|
||||
"webdav.password": "Пароль WebDAV",
|
||||
"webdav.maxBackups": "Максимум резервных копий",
|
||||
"webdav.noLimit": "Без ограничений",
|
||||
"webdav.backup": "Резервное копирование",
|
||||
"webdav.restore.title": "Восстановление резервной копии",
|
||||
"webdav.restore.noBackups": "Нет доступных резервных копий",
|
||||
|
||||
@ -15,11 +15,17 @@
|
||||
"common.default": "默认",
|
||||
"common.close": "关闭",
|
||||
"common.pinWindow": "窗口置顶",
|
||||
"common.enterAdminPassword": "请输入管理员密码",
|
||||
"common.next": "下一步",
|
||||
"common.prev": "上一步",
|
||||
"common.done": "完成",
|
||||
"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.copyErrorMessage": "复制报错信息",
|
||||
"common.error.invalidCron": "无效的 Cron 表达式",
|
||||
@ -173,6 +179,8 @@
|
||||
"webdav.dir": "WebDAV 备份目录",
|
||||
"webdav.username": "WebDAV 用户名",
|
||||
"webdav.password": "WebDAV 密码",
|
||||
"webdav.maxBackups": "最大备份数",
|
||||
"webdav.noLimit": "不限制",
|
||||
"webdav.backup": "备份",
|
||||
"webdav.restore.title": "恢复备份",
|
||||
"webdav.restore.noBackups": "还没有备份",
|
||||
|
||||
@ -236,6 +236,7 @@ const Connections: React.FC = () => {
|
||||
className="w-[180px] min-w-[131px]"
|
||||
aria-label={t('connections.orderBy')}
|
||||
selectedKeys={new Set([connectionOrderBy])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
await patchAppConfig({
|
||||
connectionOrderBy: v.currentKey as
|
||||
|
||||
@ -142,9 +142,6 @@ const DNS: React.FC = () => {
|
||||
className="app-nodrag"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
const hostsObject = Object.fromEntries(
|
||||
values.hosts.map(({ domain, value }) => [domain, value])
|
||||
)
|
||||
const dnsConfig = {
|
||||
ipv6: values.ipv6,
|
||||
'fake-ip-range': values.fakeIPRange,
|
||||
@ -165,10 +162,13 @@ const DNS: React.FC = () => {
|
||||
values.nameserverPolicy.map(({ domain, value }) => [domain, value])
|
||||
)
|
||||
}
|
||||
onSave({
|
||||
dns: dnsConfig,
|
||||
hosts: hostsObject
|
||||
})
|
||||
const result = { dns: dnsConfig }
|
||||
if (values.useHosts) {
|
||||
result['hosts'] = Object.fromEntries(
|
||||
values.hosts.map(({ domain, value }) => [domain, value])
|
||||
)
|
||||
}
|
||||
onSave(result)
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
|
||||
@ -136,6 +136,7 @@ const Mihomo: React.FC = () => {
|
||||
size="sm"
|
||||
aria-label={t('mihomo.selectCoreVersion')}
|
||||
selectedKeys={new Set([core])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={async (v) => {
|
||||
handleConfigChangeWithRestart('core', v.currentKey as 'mihomo' | 'mihomo-alpha')
|
||||
}}
|
||||
@ -400,7 +401,7 @@ const Mihomo: React.FC = () => {
|
||||
<Input
|
||||
size="sm"
|
||||
fullWidth
|
||||
placeholder="IP 段"
|
||||
placeholder={t('mihomo.ipSegment.placeholder')}
|
||||
value={ipcidr || ''}
|
||||
onValueChange={(v) => {
|
||||
if (index === lanAllowedIpsInput.length) {
|
||||
@ -450,7 +451,7 @@ const Mihomo: React.FC = () => {
|
||||
<Input
|
||||
size="sm"
|
||||
fullWidth
|
||||
placeholder="IP 段"
|
||||
placeholder={t('mihomo.username.placeholder')}
|
||||
value={ipcidr || ''}
|
||||
onValueChange={(v) => {
|
||||
if (index === lanDisallowedIpsInput.length) {
|
||||
@ -702,6 +703,7 @@ const Mihomo: React.FC = () => {
|
||||
size="sm"
|
||||
aria-label={t('mihomo.selectLogLevel')}
|
||||
selectedKeys={new Set([logLevel])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={(v) => {
|
||||
onChangeNeedRestart({ 'log-level': v.currentKey as LogLevel })
|
||||
}}
|
||||
@ -720,6 +722,7 @@ const Mihomo: React.FC = () => {
|
||||
size="sm"
|
||||
aria-label={t('mihomo.selectFindProcessMode')}
|
||||
selectedKeys={new Set([findProcessMode])}
|
||||
disallowEmptySelection={true}
|
||||
onSelectionChange={(v) => {
|
||||
onChangeNeedRestart({ 'find-process-mode': v.currentKey as FindProcessMode })
|
||||
}}
|
||||
|
||||
@ -22,25 +22,15 @@ import { includesIgnoreCase } from '@renderer/utils/includes'
|
||||
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SCROLL_POSITION_KEY = 'proxy_scroll_position'
|
||||
const GROUP_EXPAND_STATE_KEY = 'proxy_group_expand_state'
|
||||
const SCROLL_DEBOUNCE_TIME = 200
|
||||
const RENDER_DELAY = 100
|
||||
|
||||
// 自定义 hook 用于管理滚动位置和展开状态
|
||||
// 自定义 hook 用于管理展开状态
|
||||
const useProxyState = (groups: IMihomoMixedGroup[]): {
|
||||
virtuosoRef: React.RefObject<GroupedVirtuosoHandle>;
|
||||
isOpen: boolean[];
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean[]>>;
|
||||
scrollPosition: number;
|
||||
onScroll: (e: React.UIEvent<HTMLElement>) => void;
|
||||
isManualScroll: React.RefObject<boolean>;
|
||||
} => {
|
||||
const virtuosoRef = useRef<GroupedVirtuosoHandle>(null)
|
||||
const [scrollPosition, setScrollPosition] = useState<number>(0)
|
||||
const scrollTimerRef = useRef<NodeJS.Timeout>()
|
||||
const isManualScroll = useRef<boolean>(false)
|
||||
const lastGroupsLength = useRef<number>(0)
|
||||
|
||||
// 初始化展开状态
|
||||
const [isOpen, setIsOpen] = useState<boolean[]>(() => {
|
||||
@ -62,91 +52,10 @@ const useProxyState = (groups: IMihomoMixedGroup[]): {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 恢复滚动位置
|
||||
useEffect(() => {
|
||||
if (groups.length > 0) {
|
||||
try {
|
||||
const savedPosition = localStorage.getItem(SCROLL_POSITION_KEY)
|
||||
if (savedPosition) {
|
||||
const position = parseInt(savedPosition)
|
||||
if (!isNaN(position) && position >= 0) {
|
||||
// 记录当前组长度以便跟踪变化
|
||||
lastGroupsLength.current = groups.length
|
||||
|
||||
// 设置标志位避免循环触发滚动
|
||||
isManualScroll.current = true;
|
||||
|
||||
// 延迟一点时间确保DOM已更新
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: position,
|
||||
behavior: 'auto' // 使用auto以避免平滑滚动引起的额外视觉效果
|
||||
})
|
||||
|
||||
// 延迟恢复标志位
|
||||
setTimeout(() => {
|
||||
isManualScroll.current = false;
|
||||
}, 200);
|
||||
}, RENDER_DELAY)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore scroll position:', error)
|
||||
}
|
||||
}
|
||||
}, [groups])
|
||||
|
||||
// 数据刷新时保持滚动位置
|
||||
useEffect(() => {
|
||||
if (groups.length > 0 && scrollPosition > 0 && !isManualScroll.current) {
|
||||
// 只在数据刷新时恢复位置,不是手动滚动触发的
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollTo({ top: scrollPosition, behavior: 'auto' })
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [groups, scrollPosition])
|
||||
|
||||
const saveScrollPosition = useCallback((position: number) => {
|
||||
try {
|
||||
localStorage.setItem(SCROLL_POSITION_KEY, position.toString())
|
||||
} catch (error) {
|
||||
console.error('Failed to save scroll position:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
virtuosoRef,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
scrollPosition,
|
||||
onScroll: useCallback((e: React.UIEvent<HTMLElement>) => {
|
||||
const position = (e.target as HTMLElement).scrollTop
|
||||
isManualScroll.current = true // 标记这是手动滚动
|
||||
setScrollPosition(position)
|
||||
|
||||
// 清理之前的定时器
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current)
|
||||
}
|
||||
|
||||
// 使用防抖来减少存储频率
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
saveScrollPosition(position)
|
||||
isManualScroll.current = false // 重置标记
|
||||
}, SCROLL_DEBOUNCE_TIME)
|
||||
}, [saveScrollPosition]),
|
||||
isManualScroll
|
||||
setIsOpen
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,8 +74,9 @@ const Proxies: React.FC = () => {
|
||||
} = appConfig || {}
|
||||
|
||||
const [cols, setCols] = useState(1)
|
||||
const { virtuosoRef, isOpen, setIsOpen, onScroll, scrollPosition, isManualScroll } = useProxyState(groups)
|
||||
const { virtuosoRef, isOpen, setIsOpen } = useProxyState(groups)
|
||||
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 { groupCounts, allProxies } = useMemo(() => {
|
||||
const groupCounts: number[] = []
|
||||
@ -200,63 +110,13 @@ const Proxies: React.FC = () => {
|
||||
return { groupCounts, allProxies }
|
||||
}, [groups, isOpen, proxyDisplayOrder, cols, searchValue])
|
||||
|
||||
// 界面选项(如显示模式、排序方式)变化时恢复滚动位置
|
||||
useEffect(() => {
|
||||
if (groups.length > 0) {
|
||||
try {
|
||||
const savedPosition = localStorage.getItem(SCROLL_POSITION_KEY)
|
||||
if (savedPosition) {
|
||||
const position = parseInt(savedPosition)
|
||||
if (!isNaN(position) && position > 0) {
|
||||
// 设置标志位避免循环触发滚动
|
||||
isManualScroll.current = true;
|
||||
|
||||
// 延迟一点时间确保DOM已更新
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: position,
|
||||
behavior: 'auto'
|
||||
})
|
||||
|
||||
// 延迟恢复标志位
|
||||
setTimeout(() => {
|
||||
isManualScroll.current = false;
|
||||
}, 200);
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore scroll position after UI change:', error)
|
||||
}
|
||||
}
|
||||
}, [proxyDisplayMode, proxyDisplayOrder])
|
||||
|
||||
const onChangeProxy = useCallback(async (group: string, proxy: string): Promise<void> => {
|
||||
// 保存当前滚动位置以便切换后恢复
|
||||
const currentPosition = scrollPosition;
|
||||
|
||||
await mihomoChangeProxy(group, proxy)
|
||||
if (autoCloseConnection) {
|
||||
await mihomoCloseAllConnections()
|
||||
}
|
||||
mutate()
|
||||
|
||||
// 设置标志位,表明这是程序控制的滚动,防止触发useEffect中的滚动
|
||||
isManualScroll.current = true;
|
||||
|
||||
setTimeout(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: currentPosition,
|
||||
behavior: 'auto' // 使用auto避免出现平滑滚动导致的额外视觉抖动
|
||||
})
|
||||
|
||||
// 延迟恢复标志位,确保滚动事件处理完成
|
||||
setTimeout(() => {
|
||||
isManualScroll.current = false;
|
||||
}, 200);
|
||||
}, 150) // 增加延迟让DOM有足够的时间更新
|
||||
}, [autoCloseConnection, mutate, virtuosoRef, scrollPosition])
|
||||
}, [autoCloseConnection, mutate])
|
||||
|
||||
const onProxyDelay = useCallback(async (proxy: string, url?: string): Promise<IMihomoDelay> => {
|
||||
return await mihomoProxyDelay(proxy, url)
|
||||
@ -276,6 +136,16 @@ const Proxies: React.FC = () => {
|
||||
return newDelaying
|
||||
})
|
||||
|
||||
// 本组测试状态
|
||||
const groupProxies = allProxies[index]
|
||||
setProxyDelaying((prev) => {
|
||||
const newProxyDelaying = { ...prev }
|
||||
groupProxies.forEach(proxy => {
|
||||
newProxyDelaying[proxy.name] = true
|
||||
})
|
||||
return newProxyDelaying
|
||||
})
|
||||
|
||||
try {
|
||||
// 限制并发数量
|
||||
const result: Promise<void>[] = []
|
||||
@ -287,6 +157,12 @@ const Proxies: React.FC = () => {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
// 立即更新状态
|
||||
setProxyDelaying((prev) => {
|
||||
const newProxyDelaying = { ...prev }
|
||||
delete newProxyDelaying[proxy.name]
|
||||
return newProxyDelaying
|
||||
})
|
||||
mutate()
|
||||
}
|
||||
})
|
||||
@ -306,6 +182,14 @@ const Proxies: React.FC = () => {
|
||||
newDelaying[index] = false
|
||||
return newDelaying
|
||||
})
|
||||
// 状态清理
|
||||
setProxyDelaying((prev) => {
|
||||
const newProxyDelaying = { ...prev }
|
||||
groupProxies.forEach(proxy => {
|
||||
delete newProxyDelaying[proxy.name]
|
||||
})
|
||||
return newProxyDelaying
|
||||
})
|
||||
}
|
||||
}, [allProxies, groups, delayTestConcurrency, mutate])
|
||||
|
||||
@ -327,7 +211,7 @@ const Proxies: React.FC = () => {
|
||||
handleResize() // 初始化
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
return (): void => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [calcCols])
|
||||
@ -393,12 +277,9 @@ const Proxies: React.FC = () => {
|
||||
<GroupedVirtuoso
|
||||
ref={virtuosoRef}
|
||||
groupCounts={groupCounts}
|
||||
onScroll={onScroll}
|
||||
initialTopMostItemIndex={scrollPosition > 0 ? undefined : 0}
|
||||
defaultItemHeight={80} // 设置默认高度减少跳动
|
||||
increaseViewportBy={{ top: 300, bottom: 300 }} // 扩大可视区域减少闪烁
|
||||
overscan={500} // 增加预渲染区域
|
||||
// 使用稳定的key减少不必要的重新渲染
|
||||
defaultItemHeight={80}
|
||||
increaseViewportBy={{ top: 300, bottom: 300 }}
|
||||
overscan={500}
|
||||
computeItemKey={(index, groupIndex) => {
|
||||
let innerIndex = index
|
||||
groupCounts.slice(0, groupIndex).forEach((count) => {
|
||||
@ -573,6 +454,7 @@ const Proxies: React.FC = () => {
|
||||
allProxies[groupIndex][innerIndex * cols + i]?.name ===
|
||||
groups[groupIndex].now
|
||||
}
|
||||
isGroupTesting={!!proxyDelaying[allProxies[groupIndex][innerIndex * cols + i].name]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -147,6 +147,10 @@ export async function updateProfileItem(item: IProfileItem): Promise<void> {
|
||||
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('updateProfileItem', item))
|
||||
}
|
||||
|
||||
export async function addProfileUpdater(item: IProfileItem): Promise<void> {
|
||||
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('addProfileUpdater', item))
|
||||
}
|
||||
|
||||
export async function getProfileStr(id: string): Promise<string> {
|
||||
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getProfileStr', id))
|
||||
}
|
||||
|
||||
1
src/shared/types.d.ts
vendored
1
src/shared/types.d.ts
vendored
@ -284,6 +284,7 @@ interface IAppConfig {
|
||||
webdavDir?: string
|
||||
webdavUsername?: string
|
||||
webdavPassword?: string
|
||||
webdavMaxBackups?: number
|
||||
useNameserverPolicy: boolean
|
||||
nameserverPolicy: { [key: string]: string | string[] }
|
||||
showWindowShortcut?: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user