Compare commits

...

39 Commits
v1.7.2 ... 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
ezequielnick
daa8e7ba7e fix: electron-builder script path format 2025-05-30 12:39:43 +08:00
Xia Wanxu
27ab6d1b5c
fix: apply DNS hosts only if useHosts is true (#742)
Co-authored-by: 2045gemini <2045gemini@gmail.com>
2025-05-30 10:17:18 +08:00
Xia Wanxu
da5336ee36
fix: adds the functionality to update the update interval after saving a profile. (#671) 2025-05-30 10:17:08 +08:00
ezequielnick
bdb7fb6489 feat: improve installation mechanism 2025-05-30 10:11:13 +08:00
ezequielnick
1a1992c617 refactor: remove scroll position saving in proxy state management, may fix Maximum call stack size exceeded 2025-05-02 23:42:09 +08:00
miwu04
511eb0c7fa fix: disallow empty selection in Select component
(cherry picked from commit 48eef556770030d8a032fb6dd0e6888d1f0750a0)
2025-05-02 22:34:03 +08:00
miwu04
f54ffcf42b
Remove unused code 2025-04-27 23:11:11 +08:00
xishang0128
5a84d6485e
fix: proxy-provider 2025-04-27 20:55:25 +08:00
miwu04
afe93774b0
Revert "refactor: filter providers by file type instead of subscription info"
This reverts commit d28d33849c5758bb3e48a8337192096243423fce.
2025-04-27 14:30:25 +08:00
miwu04
00be605e6e
1.7.3 Released 2025-04-26 17:09:21 +08:00
miwu04
944475d791
refactor: move sub-store path to WorkDir 2025-04-26 12:05:01 +08:00
ezequielnick
08f15c7e01 feat: add warning prompt before resetting application 2025-04-25 22:33:16 +08:00
ezequielnick
0218f72c5f fix: #587 2025-04-22 23:19:41 +08:00
ezequielnick
c54ce3577d feat: change default UA and add version 2025-04-20 21:12:04 +08:00
miwu04
6cb432dea9 fix: sub-store temp dir 2025-04-18 22:04:44 +08:00
miwu04
a6a3afd3bb fix: update password prompt from 'root' to 'administrator' in multiple languages 2025-04-18 01:00:56 +08:00
YsielX
a2faf0fc8f
feat: add a fixed-interval button for profiles updating (#670) 2025-04-14 10:00:30 +08:00
miwu04
b15fc6ce3a refactor: replace password-based sudo with pkexec for improved security 2025-04-13 00:19:00 +08:00
ForestL
fcb323a17a
Errors occurred when reset firewall since no specified firewall rules existed (#650) 2025-04-10 19:53:03 +08:00
Amamiya Miu
69e65a3959
fix: Fix defaultBypass almost always be the default bypass of Windows (#602)
感谢PR!
2025-03-22 23:36:21 +08:00
Amamiya Miu
6ffcf544b8
fix: Fix missing window at startup due to mihomo core error (#601)
感谢PR!
2025-03-22 23:36:01 +08:00
Junjia
36746074da
feat: Add dynamic Dock icon visibility for macOS (#594)
Implemented `showDockIcon` and `hideDockIcon` to toggle the Dock icon visibility based on user preferences. Adjusted event handlers to respect the `useDockIcon` configuration setting, enhancing macOS-specific behavior and user experience.
2025-03-22 23:35:35 +08:00
41 changed files with 883 additions and 550 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

@ -1,9 +1,17 @@
#!/bin/sh #!/bin/sh
set -e 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 权限 # 检查 root 权限
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo "Please run as root" log "Error: Please run as root"
exit 1 exit 1
fi fi
@ -13,28 +21,51 @@ if [[ $2 == *".app" ]]; then
else else
APP_PATH="$2/Mihomo Party.app" APP_PATH="$2/Mihomo Party.app"
fi fi
HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper" HELPER_PATH="/Library/PrivilegedHelperTools/party.mihomo.helper"
LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist" LAUNCH_DAEMON="/Library/LaunchDaemons/party.mihomo.helper.plist"
# 设置核心文件权限 log "Starting installation..."
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"
# 创建目录并复制 helper # 创建目录并设置权限
mkdir -p /Library/PrivilegedHelperTools log "Creating directories and setting permissions..."
if [ ! -f "$APP_PATH/Contents/Resources/files/party.mihomo.helper" ]; then mkdir -p "/Library/PrivilegedHelperTools"
echo "Helper file not found" 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 exit 1
fi fi
cp "$APP_PATH/Contents/Resources/files/party.mihomo.helper" "$HELPER_PATH"
chown root:wheel "$HELPER_PATH"
chmod 544 "$HELPER_PATH"
# 创建并配置 LaunchDaemon # 创建并配置 LaunchDaemon
mkdir -p /Library/LaunchDaemons log "Configuring LaunchDaemon..."
mkdir -p "/Library/LaunchDaemons"
cat << EOF > "$LAUNCH_DAEMON" cat << EOF > "$LAUNCH_DAEMON"
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!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> <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>
@ -58,18 +91,83 @@ EOF
chown root:wheel "$LAUNCH_DAEMON" chown root:wheel "$LAUNCH_DAEMON"
chmod 644 "$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 if ! launchctl load "$LAUNCH_DAEMON"; then
echo "Failed to load helper service" log "Error: Failed to load service with both methods"
exit 1 exit 1
fi fi
fi
if ! launchctl start party.mihomo.helper; then else
echo "Failed to start helper service" # 旧版本使用 load
if ! launchctl load "$LAUNCH_DAEMON"; then
log "Error: Failed to load service"
exit 1 exit 1
fi 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 exit 0

View 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

View File

@ -1,69 +1,72 @@
## 1.7.2 ## 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
**注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件** **注意:如安装后为英文,请在设置中反复选择几次不同语言以写入配置文件**
### 新功能 (Feat) ### 新功能 (Feat)
- 添加伊朗语支持 (#507) - Mihomo 内核升级 v1.19.5
- 添加俄语支持 (#503) - MacOS 下添加 Dock 图标动态展现方式 (#594)
- 使用特权助手设置系统代理解决MacOS下的少有的系统代理设置问题 - 更改默认 UA 并添加版本
- 添加固定间隔的配置文件更新按钮 (#670)
- 重构Linux上的手动授权内核方式
- 将sub-store迁移到工作目录下(#552)
- 重置软件增加警告提示
### 修复 (Fix) ### 修复 (Fix)
- 修复 Linux 上 sub-store 的更新问题 (#545) - 修复代理节点页面因为重复刷新导致的溢出问题
- 修复按钮嵌套的水合错误 (button nesting hydration errors) - 修复由于 Mihomo 核心错误导致启动时窗口丢失 (#601)
- 修复macOS下的sub-store更新问题 (#552)
### 其他优化 (Perf) - 修复多语言翻译
- 优化系统代理开关逻辑 - 修复 defaultBypass 几乎总是 Windows 默认绕过设置 (#602)
- 优化代理页面性能和滚动体验 - 修复重置防火墙时发生的错误,因为没有指定防火墙规则 (#650)
- 简化启动守护进程的配置以支持辅助服务
- 改进安装后脚本,增强错误处理和路径灵活性
## 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

@ -1,6 +1,6 @@
{ {
"name": "mihomo-party", "name": "mihomo-party",
"version": "1.7.2", "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",

27
scripts/cleanup-mac.sh Executable file
View 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."

View File

@ -10,6 +10,7 @@ import yaml from 'yaml'
import { defaultProfile } from '../utils/template' import { defaultProfile } from '../utils/template'
import { subStorePort } from '../resolve/server' import { subStorePort } from '../resolve/server'
import { join } from 'path' import { join } from 'path'
import { app } from 'electron'
let profileConfig: IProfileConfig // profile.yaml let profileConfig: IProfileConfig // profile.yaml
@ -114,6 +115,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
interval: item.interval || 0, interval: item.interval || 0,
override: item.override || [], override: item.override || [],
useProxy: item.useProxy || false, useProxy: item.useProxy || false,
allowFixedInterval: item.allowFixedInterval || false,
updated: new Date().getTime() updated: new Date().getTime()
} as IProfileItem } as IProfileItem
switch (newItem.type) { switch (newItem.type) {
@ -133,7 +135,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
} }
res = await axios.get(urlObj.toString(), { res = await axios.get(urlObj.toString(), {
headers: { headers: {
'User-Agent': userAgent || 'clash.meta' 'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
}, },
responseType: 'text' responseType: 'text'
}) })
@ -147,7 +149,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
} }
: false, : false,
headers: { headers: {
'User-Agent': userAgent || 'clash.meta' 'User-Agent': userAgent || `mihomo.party/v${app.getVersion()} (clash.meta)`
}, },
responseType: 'text' responseType: 'text'
}) })
@ -162,8 +164,10 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
newItem.home = headers['profile-web-page-url'] newItem.home = headers['profile-web-page-url']
} }
if (headers['profile-update-interval']) { if (headers['profile-update-interval']) {
if (!item.allowFixedInterval) {
newItem.interval = parseInt(headers['profile-update-interval']) * 60 newItem.interval = parseInt(headers['profile-update-interval']) * 60
} }
}
if (headers['subscription-userinfo']) { if (headers['subscription-userinfo']) {
newItem.extra = parseSubinfo(headers['subscription-userinfo']) newItem.extra = parseSubinfo(headers['subscription-userinfo'])
} }

View File

@ -145,6 +145,12 @@ export async function startCore(detached = false): Promise<Promise<void>[]> {
reject(i18next.t('tun.error.tunPermissionDenied')) reject(i18next.t('tun.error.tunPermissionDenied'))
} }
if ((process.platform !== 'win32' && str.includes('External controller unix listen error')) ||
(process.platform === 'win32' && str.includes('External controller pipe listen error'))
) {
reject(i18next.t('mihomo.error.externalControllerListenError'))
}
if ( if (
(process.platform !== 'win32' && str.includes('RESTful API unix listening at')) || (process.platform !== 'win32' && str.includes('RESTful API unix listening at')) ||
(process.platform === 'win32' && str.includes('RESTful API pipe listening at')) (process.platform === 'win32' && str.includes('RESTful API pipe listening at'))
@ -152,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')
@ -256,18 +262,22 @@ async function checkProfile(): Promise<void> {
} }
} }
export async function manualGrantCorePermition(password?: string): Promise<void> { export async function manualGrantCorePermition(): Promise<void> {
const { core = 'mihomo' } = await getAppConfig() const { core = 'mihomo' } = await getAppConfig()
const corePath = mihomoCorePath(core) const corePath = mihomoCorePath(core)
const execPromise = promisify(exec) const execPromise = promisify(exec)
const execFilePromise = promisify(execFile)
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}` const shell = `chown root:admin ${corePath.replace(' ', '\\\\ ')}\nchmod +sx ${corePath.replace(' ', '\\\\ ')}`
const command = `do shell script "${shell}" with administrator privileges` const command = `do shell script "${shell}" with administrator privileges`
await execPromise(`osascript -e '${command}'`) await execPromise(`osascript -e '${command}'`)
} }
if (process.platform === 'linux') { if (process.platform === 'linux') {
await execPromise(`echo "${password}" | sudo -S chown root:root "${corePath}"`) await execFilePromise('pkexec', [
await execPromise(`echo "${password}" | sudo -S chmod +sx "${corePath}"`) 'bash',
'-c',
`chown root:root "${corePath}" && chmod +sx "${corePath}"`
])
} }
} }

View File

@ -6,12 +6,14 @@ import { addProfileItem, getAppConfig, patchAppConfig } from './config'
import { quitWithoutCore, startCore, stopCore } from './core/manager' import { quitWithoutCore, startCore, stopCore } from './core/manager'
import { triggerSysProxy } from './sys/sysproxy' import { triggerSysProxy } from './sys/sysproxy'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { createTray } from './resolve/tray' 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,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() const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) { if (!gotTheLock) {
app.quit() 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
@ -269,10 +309,21 @@ export async function createWindow(): Promise<void> {
mainWindow?.webContents.reload() mainWindow?.webContents.reload()
}) })
mainWindow.on('show', () => {
showDockIcon()
})
mainWindow.on('close', async (event) => { mainWindow.on('close', async (event) => {
event.preventDefault() event.preventDefault()
mainWindow?.hide() mainWindow?.hide()
const { autoQuitWithoutCore = false, autoQuitWithoutCoreDelay = 60 } = await getAppConfig() const {
autoQuitWithoutCore = false,
autoQuitWithoutCoreDelay = 60,
useDockIcon = true
} = await getAppConfig()
if (!useDockIcon) {
hideDockIcon()
}
if (autoQuitWithoutCore) { if (autoQuitWithoutCore) {
if (quitTimeout) { if (quitTimeout) {
clearTimeout(quitTimeout) clearTimeout(quitTimeout)

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

@ -1,12 +1,6 @@
import { getAppConfig, getControledMihomoConfig } from '../config' import { getAppConfig, getControledMihomoConfig } from '../config'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { import { dataDir, mihomoWorkDir, subStoreDir, substoreLogPath } from '../utils/dirs'
dataDir,
mihomoWorkDir,
resourcesFilesDir,
subStoreDir,
substoreLogPath
} from '../utils/dirs'
import subStoreIcon from '../../../resources/subStoreIcon.png?asset' import subStoreIcon from '../../../resources/subStoreIcon.png?asset'
import { createWriteStream, existsSync, mkdirSync } from 'fs' import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { writeFile, rm, cp } from 'fs/promises' import { writeFile, rm, cp } from 'fs/promises'
@ -17,9 +11,6 @@ import { nativeImage } from 'electron'
import express from 'express' import express from 'express'
import axios from 'axios' import axios from 'axios'
import AdmZip from 'adm-zip' import AdmZip from 'adm-zip'
import { promisify } from 'util'
import { exec } from 'child_process'
import { platform } from 'os'
export let pacPort: number export let pacPort: number
export let subStorePort: number export let subStorePort: number
@ -86,7 +77,7 @@ export async function startSubStoreFrontendServer(): Promise<void> {
await stopSubStoreFrontendServer() await stopSubStoreFrontendServer()
subStoreFrontendPort = await findAvailablePort(14122) subStoreFrontendPort = await findAvailablePort(14122)
const app = express() const app = express()
app.use(express.static(path.join(resourcesFilesDir(), 'sub-store-frontend'))) app.use(express.static(path.join(mihomoWorkDir(), 'sub-store-frontend')))
subStoreFrontendServer = app.listen(subStoreFrontendPort, subStoreHost) subStoreFrontendServer = app.listen(subStoreFrontendPort, subStoreHost)
} }
@ -127,7 +118,7 @@ export async function startSubStoreBackendServer(): Promise<void> {
SUB_STORE_MMDB_COUNTRY_PATH: path.join(mihomoWorkDir(), 'country.mmdb'), SUB_STORE_MMDB_COUNTRY_PATH: path.join(mihomoWorkDir(), 'country.mmdb'),
SUB_STORE_MMDB_ASN_PATH: path.join(mihomoWorkDir(), 'ASN.mmdb') SUB_STORE_MMDB_ASN_PATH: path.join(mihomoWorkDir(), 'ASN.mmdb')
} }
subStoreBackendWorker = new Worker(path.join(resourcesFilesDir(), 'sub-store.bundle.js'), { subStoreBackendWorker = new Worker(path.join(mihomoWorkDir(), 'sub-store.bundle.js'), {
env: useProxyInSubStore env: useProxyInSubStore
? { ? {
...env, ...env,
@ -148,12 +139,11 @@ export async function stopSubStoreBackendServer(): Promise<void> {
} }
} }
export async function downloadSubStore(password?: string): Promise<void> { export async function downloadSubStore(): Promise<void> {
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const frontendDir = path.join(resourcesFilesDir(), 'sub-store-frontend') const frontendDir = path.join(mihomoWorkDir(), 'sub-store-frontend')
const backendPath = path.join(resourcesFilesDir(), 'sub-store.bundle.js') const backendPath = path.join(mihomoWorkDir(), 'sub-store.bundle.js')
const tempDir = path.join(dataDir(), 'temp') const tempDir = path.join(dataDir(), 'temp')
const execPromise = promisify(exec)
try { try {
// 创建临时目录 // 创建临时目录
@ -194,35 +184,12 @@ export async function downloadSubStore(password?: string): Promise<void> {
// 先解压到临时目录 // 先解压到临时目录
const zip = new AdmZip(Buffer.from(frontendRes.data)) const zip = new AdmZip(Buffer.from(frontendRes.data))
zip.extractAllTo(tempDir, true) zip.extractAllTo(tempDir, true)
// 如果是 Linux 平台,使用 sudo cp 移动文件
if (platform() === 'linux') {
try {
await execPromise(`echo "${password}" | sudo -S cp "${tempBackendPath}" "${backendPath}"`)
// 确保目标目录存在并清空
if (existsSync(frontendDir)) {
await execPromise(`echo "${password}" | sudo -S rm -r "${frontendDir}"`)
}
await execPromise(`echo "${password}" | sudo -S mkdir "${frontendDir}"`)
// 将 dist 目录中的内容移动到目标目录
await execPromise(
`echo "${password}" | sudo -S cp -r "${tempFrontendDir}"/* "${frontendDir}/"`
)
} catch (error) {
console.error('substore.downloadFailed:', error)
throw error
}
} else {
// 非 Linux 平台
await cp(tempBackendPath, backendPath) await cp(tempBackendPath, backendPath)
if (existsSync(frontendDir)) { if (existsSync(frontendDir)) {
await rm(frontendDir, { recursive: true }) await rm(frontendDir, { recursive: true })
} }
mkdirSync(frontendDir, { recursive: true }) mkdirSync(frontendDir, { recursive: true })
await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true }) await cp(path.join(tempDir, 'dist'), frontendDir, { recursive: true })
}
// 清理临时目录
await rm(tempDir, { recursive: true }) await rm(tempDir, { recursive: true })
} catch (error) { } catch (error) {
console.error('substore.downloadFailed:', error) console.error('substore.downloadFailed:', error)

View File

@ -309,7 +309,7 @@ export async function createTray(): Promise<void> {
tray?.setIgnoreDoubleClickEvents(true) tray?.setIgnoreDoubleClickEvents(true)
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
if (!useDockIcon) { if (!useDockIcon) {
app.dock.hide() hideDockIcon()
} }
ipcMain.on('trayIconUpdate', async (_, png: string) => { ipcMain.on('trayIconUpdate', async (_, png: string) => {
const image = nativeImage.createFromDataURL(png).resize({ height: 16 }) const image = nativeImage.createFromDataURL(png).resize({ height: 16 })
@ -387,3 +387,15 @@ export async function closeTrayIcon(): Promise<void> {
} }
tray = null tray = null
} }
export async function showDockIcon(): Promise<void> {
if (process.platform === 'darwin' && !app.dock.isVisible()) {
await app.dock.show()
}
}
export async function hideDockIcon(): Promise<void> {
if (process.platform === 'darwin' && app.dock.isVisible()) {
app.dock.hide()
}
}

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

@ -45,9 +45,12 @@ export async function openUWPTool(): Promise<void> {
export async function setupFirewall(): Promise<void> { export async function setupFirewall(): Promise<void> {
const execPromise = promisify(exec) const execPromise = promisify(exec)
const removeCommand = ` const removeCommand = `
Remove-NetFirewallRule -DisplayName "mihomo" -ErrorAction SilentlyContinue $rules = @("mihomo", "mihomo-alpha", "Mihomo Party")
Remove-NetFirewallRule -DisplayName "mihomo-alpha" -ErrorAction SilentlyContinue foreach ($rule in $rules) {
Remove-NetFirewallRule -DisplayName "Mihomo Party" -ErrorAction SilentlyContinue if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) {
Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue
}
}
` `
const createCommand = ` const createCommand = `
New-NetFirewallRule -DisplayName "mihomo" -Direction Inbound -Action Allow -Program "${mihomoCorePath('mihomo')}" -Enabled True -Profile Any -ErrorAction SilentlyContinue New-NetFirewallRule -DisplayName "mihomo" -Direction Inbound -Action Allow -Program "${mihomoCorePath('mihomo')}" -Enabled True -Profile Any -ErrorAction SilentlyContinue
@ -65,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>
@ -101,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, copyFile, rm, readdir } 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())
} }
@ -88,13 +115,13 @@ async function initConfig(): Promise<void> {
async function initFiles(): Promise<void> { async function initFiles(): Promise<void> {
const copy = async (file: string): Promise<void> => { const copy = async (file: string): Promise<void> => {
const targetPath = path.join(mihomoWorkDir(), file) const targetPath = path.join(mihomoWorkDir(), file)
const testTargrtPath = path.join(mihomoTestDir(), file) const testTargetPath = path.join(mihomoTestDir(), file)
const sourcePath = path.join(resourcesFilesDir(), file) const sourcePath = path.join(resourcesFilesDir(), file)
if (!existsSync(targetPath) && existsSync(sourcePath)) { if (!existsSync(targetPath) && existsSync(sourcePath)) {
await copyFile(sourcePath, targetPath) await cp(sourcePath, targetPath, { recursive: true })
} }
if (!existsSync(testTargrtPath) && existsSync(sourcePath)) { if (!existsSync(testTargetPath) && existsSync(sourcePath)) {
await copyFile(sourcePath, testTargrtPath) await cp(sourcePath, testTargetPath, { recursive: true })
} }
} }
await Promise.all([ await Promise.all([
@ -102,7 +129,9 @@ async function initFiles(): Promise<void> {
copy('geoip.metadb'), copy('geoip.metadb'),
copy('geoip.dat'), copy('geoip.dat'),
copy('geosite.dat'), copy('geosite.dat'),
copy('ASN.mmdb') copy('ASN.mmdb'),
copy('sub-store.bundle.js'),
copy('sub-store-frontend')
]) ])
} }

View File

@ -89,6 +89,7 @@ import { getImageDataURL } from './image'
import { startMonitor } from '../resolve/trafficMonitor' import { startMonitor } from '../resolve/trafficMonitor'
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow' import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
import i18next from 'i18next' import i18next from 'i18next'
import { addProfileUpdater } from '../core/profileUpdater'
function ipcErrorWrapper<T>( // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 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('changeCurrentProfile', (_e, id) => ipcErrorWrapper(changeCurrentProfile)(id))
ipcMain.handle('addProfileItem', (_e, item) => ipcErrorWrapper(addProfileItem)(item)) ipcMain.handle('addProfileItem', (_e, item) => ipcErrorWrapper(addProfileItem)(item))
ipcMain.handle('removeProfileItem', (_e, id) => ipcErrorWrapper(removeProfileItem)(id)) 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('getOverrideConfig', (_e, force) => ipcErrorWrapper(getOverrideConfig)(force))
ipcMain.handle('setOverrideConfig', (_e, config) => ipcErrorWrapper(setOverrideConfig)(config)) ipcMain.handle('setOverrideConfig', (_e, config) => ipcErrorWrapper(setOverrideConfig)(config))
ipcMain.handle('getOverrideItem', (_e, id) => ipcErrorWrapper(getOverrideItem)(id)) ipcMain.handle('getOverrideItem', (_e, id) => ipcErrorWrapper(getOverrideItem)(id))
@ -174,9 +176,7 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('restartCore', ipcErrorWrapper(restartCore)) ipcMain.handle('restartCore', ipcErrorWrapper(restartCore))
ipcMain.handle('startMonitor', (_e, detached) => ipcErrorWrapper(startMonitor)(detached)) ipcMain.handle('startMonitor', (_e, detached) => ipcErrorWrapper(startMonitor)(detached))
ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable)) ipcMain.handle('triggerSysProxy', (_e, enable) => ipcErrorWrapper(triggerSysProxy)(enable))
ipcMain.handle('manualGrantCorePermition', (_e, password) => ipcMain.handle('manualGrantCorePermition', () => ipcErrorWrapper(manualGrantCorePermition)())
ipcErrorWrapper(manualGrantCorePermition)(password)
)
ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext)) ipcMain.handle('getFilePath', (_e, ext) => getFilePath(ext))
ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath)) ipcMain.handle('readTextFile', (_e, filePath) => ipcErrorWrapper(readTextFile)(filePath))
ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr)) ipcMain.handle('getRuntimeConfigStr', ipcErrorWrapper(getRuntimeConfigStr))
@ -203,7 +203,7 @@ export function registerIpcMainHandlers(): void {
ipcMain.handle('stopSubStoreFrontendServer', () => ipcErrorWrapper(stopSubStoreFrontendServer)()) ipcMain.handle('stopSubStoreFrontendServer', () => ipcErrorWrapper(stopSubStoreFrontendServer)())
ipcMain.handle('startSubStoreBackendServer', () => ipcErrorWrapper(startSubStoreBackendServer)()) ipcMain.handle('startSubStoreBackendServer', () => ipcErrorWrapper(startSubStoreBackendServer)())
ipcMain.handle('stopSubStoreBackendServer', () => ipcErrorWrapper(stopSubStoreBackendServer)()) ipcMain.handle('stopSubStoreBackendServer', () => ipcErrorWrapper(stopSubStoreBackendServer)())
ipcMain.handle('downloadSubStore', (_e, password) => ipcErrorWrapper(downloadSubStore)(password)) ipcMain.handle('downloadSubStore', () => ipcErrorWrapper(downloadSubStore)())
ipcMain.handle('subStorePort', () => subStorePort) ipcMain.handle('subStorePort', () => subStorePort)
ipcMain.handle('subStoreFrontendPort', () => subStoreFrontendPort) ipcMain.handle('subStoreFrontendPort', () => subStoreFrontendPort)
ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)()) ipcMain.handle('subStoreSubs', () => ipcErrorWrapper(subStoreSubs)())

View File

@ -0,0 +1,37 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from '@heroui/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
title: string
content: string
onCancel: () => void
onConfirm: () => void
isOpen: boolean
}
const BaseConfirmModal: React.FC<Props> = (props) => {
const { t } = useTranslation()
const { title, content, onCancel, onConfirm, isOpen } = props
return (
<Modal backdrop="blur" classNames={{ backdrop: 'top-[48px]' }} hideCloseButton isOpen={isOpen}>
<ModalContent>
<ModalHeader className="flex app-drag">{title}</ModalHeader>
<ModalBody>
<p>{content}</p>
</ModalBody>
<ModalFooter>
<Button size="sm" variant="light" onPress={onCancel}>
{t('common.cancel')}
</Button>
<Button size="sm" color="danger" onPress={onConfirm}>
{t('common.confirm')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default BaseConfirmModal

View File

@ -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.enterRootPassword')}</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

View File

@ -16,7 +16,7 @@ import {
import React, { useState } from 'react' import React, { useState } from 'react'
import SettingItem from '../base/base-setting-item' import SettingItem from '../base/base-setting-item'
import { useOverrideConfig } from '@renderer/hooks/use-override-config' 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 { MdDeleteForever } from 'react-icons/md'
import { FaPlus } from 'react-icons/fa6' import { FaPlus } from 'react-icons/fa6'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -36,13 +36,15 @@ const EditInfoModal: React.FC<Props> = (props) => {
const onSave = async (): Promise<void> => { const onSave = async (): Promise<void> => {
try { try {
await updateProfileItem({ const updatedItem = {
...values, ...values,
override: values.override?.filter( override: values.override?.filter(
(i) => (i) =>
overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global
) )
}) };
await updateProfileItem(updatedItem)
await addProfileUpdater(updatedItem)
await restartCore() await restartCore()
onClose() onClose()
} catch (e) { } catch (e) {
@ -108,6 +110,15 @@ const EditInfoModal: React.FC<Props> = (props) => {
}} }}
/> />
</SettingItem> </SettingItem>
<SettingItem title={t('profiles.editInfo.fixedInterval')}>
<Switch
size="sm"
isSelected={values.allowFixedInterval ?? false}
onValueChange={(v) => {
setValues({ ...values, allowFixedInterval: v })
}}
/>
</SettingItem>
</> </>
)} )}
<SettingItem title={t('profiles.editInfo.override.title')}> <SettingItem title={t('profiles.editInfo.override.title')}>

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

@ -50,25 +50,10 @@ const ProxyProvider: React.FC = () => {
const providers = useMemo(() => { const providers = useMemo(() => {
if (!data) return [] if (!data) return []
return Object.values(data.providers) return Object.values(data.providers)
.map(provider => { .filter((provider) => provider.vehicleType !== 'Compatible')
if (provider.vehicleType === 'Inline' || provider.vehicleType === 'File') {
return {
...provider,
subscriptionInfo: null
}
}
return provider
})
.filter(provider => 'subscriptionInfo' in provider)
.sort((a, b) => { .sort((a, b) => {
if (a.vehicleType === 'File' && b.vehicleType !== 'File') { const order = { File: 1, Inline: 2, HTTP: 3 }
return -1 return (order[a.vehicleType] || 4) - (order[b.vehicleType] || 4)
}
if (a.vehicleType !== 'File' && b.vehicleType === 'File') {
return 1
}
return 0
}) })
}, [data]) }, [data])
const [updating, setUpdating] = useState(Array(providers.length).fill(false)) const [updating, setUpdating] = useState(Array(providers.length).fill(false))

View File

@ -14,6 +14,7 @@ import { version } from '@renderer/utils/init'
import { IoIosHelpCircle } from 'react-icons/io' import { IoIosHelpCircle } from 'react-icons/io'
import { getDriver } from '@renderer/App' import { getDriver } from '@renderer/App'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import BaseConfirmModal from '../base/base-confirm-modal'
const Actions: React.FC = () => { const Actions: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -21,6 +22,7 @@ const Actions: React.FC = () => {
const [changelog, setChangelog] = useState('') const [changelog, setChangelog] = useState('')
const [openUpdate, setOpenUpdate] = useState(false) const [openUpdate, setOpenUpdate] = useState(false)
const [checkingUpdate, setCheckingUpdate] = useState(false) const [checkingUpdate, setCheckingUpdate] = useState(false)
const [showResetConfirm, setShowResetConfirm] = useState(false)
return ( return (
<> <>
@ -31,6 +33,18 @@ const Actions: React.FC = () => {
changelog={changelog} changelog={changelog}
/> />
)} )}
{showResetConfirm && (
<BaseConfirmModal
isOpen={showResetConfirm}
title={t('actions.reset.confirm.title')}
content={t('actions.reset.confirm.content')}
onCancel={() => setShowResetConfirm(false)}
onConfirm={() => {
resetAppConfig()
setShowResetConfirm(false)
}}
/>
)}
<SettingCard> <SettingCard>
<SettingItem title={t('actions.guide.title')} divider> <SettingItem title={t('actions.guide.title')} divider>
<Button size="sm" onPress={() => getDriver()?.drive()}> <Button size="sm" onPress={() => getDriver()?.drive()}>
@ -75,7 +89,7 @@ const Actions: React.FC = () => {
} }
divider divider
> >
<Button size="sm" onPress={resetAppConfig}> <Button size="sm" onPress={() => setShowResetConfirm(true)}>
{t('actions.reset.button')} {t('actions.reset.button')}
</Button> </Button>
</SettingItem> </SettingItem>

View File

@ -193,6 +193,7 @@ const GeneralConfig: React.FC = () => {
selectionMode="multiple" selectionMode="multiple"
selectedKeys={new Set(envType)} selectedKeys={new Set(envType)}
aria-label={t('settings.envType')} aria-label={t('settings.envType')}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
try { try {
await patchAppConfig({ await patchAppConfig({
@ -389,6 +390,7 @@ const GeneralConfig: React.FC = () => {
size="sm" size="sm"
selectedKeys={new Set([customTheme])} selectedKeys={new Set([customTheme])}
aria-label={t('settings.selectTheme')} aria-label={t('settings.selectTheme')}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
try { try {
await patchAppConfig({ customTheme: v.currentKey as string }) await patchAppConfig({ customTheme: v.currentKey as string })

View File

@ -8,7 +8,7 @@ import { getGistUrl, patchControledMihomoConfig, restartCore } from '@renderer/u
import { MdDeleteForever } from 'react-icons/md' import { MdDeleteForever } from 'react-icons/md'
import { BiCopy } from 'react-icons/bi' import { BiCopy } from 'react-icons/bi'
import { IoIosHelpCircle } from 'react-icons/io' import { IoIosHelpCircle } from 'react-icons/io'
import { platform } from '@renderer/utils/init' import { platform, version } from '@renderer/utils/init'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const MihomoConfig: React.FC = () => { const MihomoConfig: React.FC = () => {
@ -44,7 +44,7 @@ const MihomoConfig: React.FC = () => {
size="sm" size="sm"
className="w-[60%]" className="w-[60%]"
value={ua} value={ua}
placeholder={t('mihomo.userAgentPlaceholder')} placeholder={t('mihomo.userAgentPlaceholder', { version })}
onValueChange={(v) => { onValueChange={(v) => {
setUa(v) setUa(v)
setUaDebounce(v) setUaDebounce(v)
@ -129,6 +129,7 @@ const MihomoConfig: React.FC = () => {
size="sm" size="sm"
selectedKeys={new Set([proxyCols])} selectedKeys={new Set([proxyCols])}
aria-label={t('mihomo.proxyColumns.title')} aria-label={t('mihomo.proxyColumns.title')}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
await patchAppConfig({ proxyCols: v.currentKey as 'auto' | '1' | '2' | '3' | '4' }) await patchAppConfig({ proxyCols: v.currentKey as 'auto' | '1' | '2' | '3' | '4' })
}} }}
@ -147,6 +148,7 @@ const MihomoConfig: React.FC = () => {
className="w-[150px]" className="w-[150px]"
size="sm" size="sm"
selectedKeys={new Set([mihomoCpuPriority])} selectedKeys={new Set([mihomoCpuPriority])}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
try { try {
await patchAppConfig({ await patchAppConfig({

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

@ -15,11 +15,17 @@
"common.default": "Default", "common.default": "Default",
"common.close": "Close", "common.close": "Close",
"common.pinWindow": "Pin Window", "common.pinWindow": "Pin Window",
"common.enterRootPassword": "Please enter root password",
"common.next": "Next", "common.next": "Next",
"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",
@ -67,7 +73,7 @@
"settings.links.telegram": "Telegram Group", "settings.links.telegram": "Telegram Group",
"settings.title": "Application Settings", "settings.title": "Application Settings",
"mihomo.userAgent": "Subscription User Agent", "mihomo.userAgent": "Subscription User Agent",
"mihomo.userAgentPlaceholder": "Default: clash.meta", "mihomo.userAgentPlaceholder": "Default: mihomo.party/v{{version}} (clash.meta)",
"mihomo.delayTest.url": "Delay Test URL", "mihomo.delayTest.url": "Delay Test URL",
"mihomo.delayTest.urlPlaceholder": "Default: http://www.gstatic.com/generate_204", "mihomo.delayTest.urlPlaceholder": "Default: http://www.gstatic.com/generate_204",
"mihomo.delayTest.concurrency": "Delay Test Concurrency", "mihomo.delayTest.concurrency": "Delay Test Concurrency",
@ -139,6 +145,7 @@
"mihomo.debug": "Debug", "mihomo.debug": "Debug",
"mihomo.error.coreStartFailed": "Core start failed", "mihomo.error.coreStartFailed": "Core start failed",
"mihomo.error.profileCheckFailed": "Profile Check Failed", "mihomo.error.profileCheckFailed": "Profile Check Failed",
"mihomo.error.externalControllerListenError": "External controller listen error",
"mihomo.findProcess": "Find Process", "mihomo.findProcess": "Find Process",
"mihomo.selectFindProcessMode": "Select Process Find Mode", "mihomo.selectFindProcessMode": "Select Process Find Mode",
"mihomo.strict": "Auto", "mihomo.strict": "Auto",
@ -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",
@ -225,6 +234,8 @@
"actions.reset.title": "Reset App", "actions.reset.title": "Reset App",
"actions.reset.button": "Reset App", "actions.reset.button": "Reset App",
"actions.reset.tooltip": "Delete all configurations and restore the app to its initial state", "actions.reset.tooltip": "Delete all configurations and restore the app to its initial state",
"actions.reset.confirm.title": "Confirm Reset",
"actions.reset.confirm.content": "Are you sure you want to reset the app and delete all configurations? Only reset the app when it is not working properly. Your all subscriptions, overrides, and scripts will be lost!",
"actions.heapSnapshot.title": "Create Heap Snapshot", "actions.heapSnapshot.title": "Create Heap Snapshot",
"actions.heapSnapshot.button": "Create Heap Snapshot", "actions.heapSnapshot.button": "Create Heap Snapshot",
"actions.heapSnapshot.tooltip": "Create a heap snapshot of the main process for memory issue debugging", "actions.heapSnapshot.tooltip": "Create a heap snapshot of the main process for memory issue debugging",
@ -344,6 +355,7 @@
"profiles.editInfo.url": "Subscription URL", "profiles.editInfo.url": "Subscription URL",
"profiles.editInfo.useProxy": "Use Proxy to Update", "profiles.editInfo.useProxy": "Use Proxy to Update",
"profiles.editInfo.interval": "Upd. Interval (min)", "profiles.editInfo.interval": "Upd. Interval (min)",
"profiles.editInfo.fixedInterval": "Fixed Update Interval",
"profiles.editInfo.override.title": "Override", "profiles.editInfo.override.title": "Override",
"profiles.editInfo.override.global": "Global", "profiles.editInfo.override.global": "Global",
"profiles.editInfo.override.noAvailable": "No available overrides", "profiles.editInfo.override.noAvailable": "No available overrides",

View File

@ -15,11 +15,17 @@
"common.default": "پیش‌فرض", "common.default": "پیش‌فرض",
"common.close": "بستن", "common.close": "بستن",
"common.pinWindow": "پین کردن پنجره", "common.pinWindow": "پین کردن پنجره",
"common.enterRootPassword": "لطفا رمز عبور روت را وارد کنید",
"common.next": "بعدی", "common.next": "بعدی",
"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 نامعتبر است",
@ -67,7 +73,7 @@
"settings.links.telegram": "گروه تلگرام", "settings.links.telegram": "گروه تلگرام",
"settings.title": "تنظیمات برنامه", "settings.title": "تنظیمات برنامه",
"mihomo.userAgent": "User Agent اشتراک", "mihomo.userAgent": "User Agent اشتراک",
"mihomo.userAgentPlaceholder": "پیش‌فرض: clash.meta", "mihomo.userAgentPlaceholder": "پیش‌فرض: mihomo.party/v{{version}} (clash.meta)",
"mihomo.title": "تنظیمات هسته", "mihomo.title": "تنظیمات هسته",
"mihomo.restart": "راه‌اندازی مجدد هسته", "mihomo.restart": "راه‌اندازی مجدد هسته",
"mihomo.memory": "مصرف حافظه", "mihomo.memory": "مصرف حافظه",
@ -139,6 +145,7 @@
"mihomo.debug": "اشکال‌زدایی", "mihomo.debug": "اشکال‌زدایی",
"mihomo.error.coreStartFailed": "راه‌اندازی هسته با خطا مواجه شد", "mihomo.error.coreStartFailed": "راه‌اندازی هسته با خطا مواجه شد",
"mihomo.error.profileCheckFailed": "بررسی پروفایل با خطا مواجه شد", "mihomo.error.profileCheckFailed": "بررسی پروفایل با خطا مواجه شد",
"mihomo.error.externalControllerListenError": "خطا در گوش‌دادن به کنترل‌کننده خارجی",
"mihomo.findProcess": "یافتن فرآیند", "mihomo.findProcess": "یافتن فرآیند",
"mihomo.selectFindProcessMode": "انتخاب حالت یافتن فرآیند", "mihomo.selectFindProcessMode": "انتخاب حالت یافتن فرآیند",
"mihomo.strict": "خودکار", "mihomo.strict": "خودکار",
@ -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": "هیچ پشتیبانی موجود نیست",
@ -225,6 +234,8 @@
"actions.reset.title": "بازنشانی برنامه", "actions.reset.title": "بازنشانی برنامه",
"actions.reset.button": "بازنشانی برنامه", "actions.reset.button": "بازنشانی برنامه",
"actions.reset.tooltip": "حذف تمام پیکربندی‌ها و بازگرداندن برنامه به حالت اولیه", "actions.reset.tooltip": "حذف تمام پیکربندی‌ها و بازگرداندن برنامه به حالت اولیه",
"actions.reset.confirm.title": "تایید بازنشانی",
"actions.reset.confirm.content": "آیا از بازنشانی برنامه و حذف تمام پیکربندی‌ها مطمئن هستید؟ فقط بازنشانی برنامه زمانی که برنامه نادرست کار می‌کند، مورد استفاده قرار می‌گیرد. تمام اشتراک ها و جایگزینی‌ها و اسکریپت‌ها حذف خواهند شد!",
"actions.heapSnapshot.title": "ایجاد نمای لحظه‌ای حافظه", "actions.heapSnapshot.title": "ایجاد نمای لحظه‌ای حافظه",
"actions.heapSnapshot.button": "ایجاد نمای لحظه‌ای حافظه", "actions.heapSnapshot.button": "ایجاد نمای لحظه‌ای حافظه",
"actions.heapSnapshot.tooltip": "ایجاد نمای لحظه‌ای از فرآیند اصلی برای اشکال‌زدایی مشکلات حافظه", "actions.heapSnapshot.tooltip": "ایجاد نمای لحظه‌ای از فرآیند اصلی برای اشکال‌زدایی مشکلات حافظه",
@ -349,6 +360,7 @@
"profiles.editInfo.url": "آدرس اشتراک", "profiles.editInfo.url": "آدرس اشتراک",
"profiles.editInfo.useProxy": "استفاده از پراکسی برای به‌روزرسانی", "profiles.editInfo.useProxy": "استفاده از پراکسی برای به‌روزرسانی",
"profiles.editInfo.interval": "فاصله به‌روزرسانی (دقیقه)", "profiles.editInfo.interval": "فاصله به‌روزرسانی (دقیقه)",
"profiles.editInfo.fixedInterval": "فاصله به‌روزرسانی ثابت",
"profiles.editInfo.override.title": "جایگزینی", "profiles.editInfo.override.title": "جایگزینی",
"profiles.editInfo.override.global": "جهانی", "profiles.editInfo.override.global": "جهانی",
"profiles.editInfo.override.noAvailable": "جایگزینی در دسترس نیست", "profiles.editInfo.override.noAvailable": "جایگزینی در دسترس نیست",

View File

@ -15,11 +15,17 @@
"common.default": "По умолчанию", "common.default": "По умолчанию",
"common.close": "Закрыть", "common.close": "Закрыть",
"common.pinWindow": "Закрепить окно", "common.pinWindow": "Закрепить окно",
"common.enterRootPassword": "Введите пароль root",
"common.next": "Далее", "common.next": "Далее",
"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",
@ -70,7 +76,7 @@
"mihomo.restart": "Перезапустить ядро", "mihomo.restart": "Перезапустить ядро",
"mihomo.memory": "Использование памяти", "mihomo.memory": "Использование памяти",
"mihomo.userAgent": "User Agent подписки", "mihomo.userAgent": "User Agent подписки",
"mihomo.userAgentPlaceholder": "По умолчанию: clash.meta", "mihomo.userAgentPlaceholder": "По умолчанию: mihomo.party/v{{version}} (clash.meta)",
"mihomo.delayTest.url": "URL теста задержки", "mihomo.delayTest.url": "URL теста задержки",
"mihomo.delayTest.urlPlaceholder": "По умолчанию: http://www.gstatic.com/generate_204", "mihomo.delayTest.urlPlaceholder": "По умолчанию: http://www.gstatic.com/generate_204",
"mihomo.delayTest.concurrency": "Параллельность теста задержки", "mihomo.delayTest.concurrency": "Параллельность теста задержки",
@ -139,6 +145,7 @@
"mihomo.debug": "Отладка", "mihomo.debug": "Отладка",
"mihomo.error.coreStartFailed": "Ошибка запуска ядра", "mihomo.error.coreStartFailed": "Ошибка запуска ядра",
"mihomo.error.profileCheckFailed": "Проверка профиля не удалась", "mihomo.error.profileCheckFailed": "Проверка профиля не удалась",
"mihomo.error.externalControllerListenError": "Ошибка прослушивания внешнего контроллера",
"mihomo.findProcess": "Поиск процесса", "mihomo.findProcess": "Поиск процесса",
"mihomo.selectFindProcessMode": "Выберите режим поиска процесса", "mihomo.selectFindProcessMode": "Выберите режим поиска процесса",
"mihomo.strict": "Авто", "mihomo.strict": "Авто",
@ -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": "Нет доступных резервных копий",
@ -225,6 +234,8 @@
"actions.reset.title": "Сбросить приложение", "actions.reset.title": "Сбросить приложение",
"actions.reset.button": "Сбросить приложение", "actions.reset.button": "Сбросить приложение",
"actions.reset.tooltip": "Удалить все настройки и вернуть приложение к исходному состоянию", "actions.reset.tooltip": "Удалить все настройки и вернуть приложение к исходному состоянию",
"actions.reset.confirm.title": "Подтвердить сброс",
"actions.reset.confirm.content": "Вы уверены, что хотите сбросить приложение и удалить все настройки? Сброс приложения следует выполнять только в том случае, если оно не работает должным образом. Ваши все подписки, переопределения и скрипты будут потеряны!",
"actions.heapSnapshot.title": "Создать снимок кучи", "actions.heapSnapshot.title": "Создать снимок кучи",
"actions.heapSnapshot.button": "Создать снимок кучи", "actions.heapSnapshot.button": "Создать снимок кучи",
"actions.heapSnapshot.tooltip": "Создать снимок кучи основного процесса для отладки проблем с памятью", "actions.heapSnapshot.tooltip": "Создать снимок кучи основного процесса для отладки проблем с памятью",
@ -349,6 +360,7 @@
"profiles.editInfo.url": "URL подписки", "profiles.editInfo.url": "URL подписки",
"profiles.editInfo.useProxy": "Использовать прокси для обновления", "profiles.editInfo.useProxy": "Использовать прокси для обновления",
"profiles.editInfo.interval": "Интервал обн. (мин)", "profiles.editInfo.interval": "Интервал обн. (мин)",
"profiles.editInfo.fixedInterval": "Фиксированный интервал обновления",
"profiles.editInfo.override.title": "Переопределение", "profiles.editInfo.override.title": "Переопределение",
"profiles.editInfo.override.global": "Глобальный", "profiles.editInfo.override.global": "Глобальный",
"profiles.editInfo.override.noAvailable": "Нет доступных переопределений", "profiles.editInfo.override.noAvailable": "Нет доступных переопределений",

View File

@ -15,11 +15,17 @@
"common.default": "默认", "common.default": "默认",
"common.close": "关闭", "common.close": "关闭",
"common.pinWindow": "窗口置顶", "common.pinWindow": "窗口置顶",
"common.enterRootPassword": "请输入root密码",
"common.next": "下一步", "common.next": "下一步",
"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 表达式",
@ -70,7 +76,7 @@
"mihomo.restart": "重启内核", "mihomo.restart": "重启内核",
"mihomo.memory": "内存使用", "mihomo.memory": "内存使用",
"mihomo.userAgent": "订阅 User Agent", "mihomo.userAgent": "订阅 User Agent",
"mihomo.userAgentPlaceholder": "默认:clash.meta", "mihomo.userAgentPlaceholder": "默认:mihomo.party/v{{version}} (clash.meta)",
"mihomo.delayTest.url": "延迟测试 URL", "mihomo.delayTest.url": "延迟测试 URL",
"mihomo.delayTest.urlPlaceholder": "默认http://www.gstatic.com/generate_204", "mihomo.delayTest.urlPlaceholder": "默认http://www.gstatic.com/generate_204",
"mihomo.delayTest.concurrency": "延迟测试并发数", "mihomo.delayTest.concurrency": "延迟测试并发数",
@ -139,6 +145,7 @@
"mihomo.debug": "调试", "mihomo.debug": "调试",
"mihomo.error.coreStartFailed": "内核启动出错", "mihomo.error.coreStartFailed": "内核启动出错",
"mihomo.error.profileCheckFailed": "配置检查失败", "mihomo.error.profileCheckFailed": "配置检查失败",
"mihomo.error.externalControllerListenError": "外部控制监听错误",
"mihomo.findProcess": "查找进程", "mihomo.findProcess": "查找进程",
"mihomo.selectFindProcessMode": "选择进程查找模式", "mihomo.selectFindProcessMode": "选择进程查找模式",
"mihomo.strict": "自动", "mihomo.strict": "自动",
@ -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": "还没有备份",
@ -225,6 +234,8 @@
"actions.reset.title": "重置软件", "actions.reset.title": "重置软件",
"actions.reset.button": "重置软件", "actions.reset.button": "重置软件",
"actions.reset.tooltip": "删除所有配置,将软件恢复初始状态", "actions.reset.tooltip": "删除所有配置,将软件恢复初始状态",
"actions.reset.confirm.title": "确认重置",
"actions.reset.confirm.content": "您确定要重置删除所有配置,将软件恢复初始状态吗?只有软件运行不正常的时候才需重置,您的所有订阅、覆写、脚本将全部丢失!",
"actions.heapSnapshot.title": "创建堆快照", "actions.heapSnapshot.title": "创建堆快照",
"actions.heapSnapshot.button": "创建堆快照", "actions.heapSnapshot.button": "创建堆快照",
"actions.heapSnapshot.tooltip": "创建主进程堆快照,用于排查内存问题", "actions.heapSnapshot.tooltip": "创建主进程堆快照,用于排查内存问题",
@ -349,6 +360,7 @@
"profiles.editInfo.url": "订阅地址", "profiles.editInfo.url": "订阅地址",
"profiles.editInfo.useProxy": "使用代理更新", "profiles.editInfo.useProxy": "使用代理更新",
"profiles.editInfo.interval": "更新间隔(分钟)", "profiles.editInfo.interval": "更新间隔(分钟)",
"profiles.editInfo.fixedInterval": "固定更新间隔",
"profiles.editInfo.override.title": "覆写", "profiles.editInfo.override.title": "覆写",
"profiles.editInfo.override.global": "全局", "profiles.editInfo.override.global": "全局",
"profiles.editInfo.override.noAvailable": "没有可用的覆写", "profiles.editInfo.override.noAvailable": "没有可用的覆写",

View File

@ -236,6 +236,7 @@ const Connections: React.FC = () => {
className="w-[180px] min-w-[131px]" className="w-[180px] min-w-[131px]"
aria-label={t('connections.orderBy')} aria-label={t('connections.orderBy')}
selectedKeys={new Set([connectionOrderBy])} selectedKeys={new Set([connectionOrderBy])}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
await patchAppConfig({ await patchAppConfig({
connectionOrderBy: v.currentKey as connectionOrderBy: v.currentKey as

View File

@ -142,9 +142,6 @@ const DNS: React.FC = () => {
className="app-nodrag" className="app-nodrag"
color="primary" color="primary"
onPress={() => { onPress={() => {
const hostsObject = Object.fromEntries(
values.hosts.map(({ domain, value }) => [domain, value])
)
const dnsConfig = { const dnsConfig = {
ipv6: values.ipv6, ipv6: values.ipv6,
'fake-ip-range': values.fakeIPRange, 'fake-ip-range': values.fakeIPRange,
@ -165,10 +162,13 @@ const DNS: React.FC = () => {
values.nameserverPolicy.map(({ domain, value }) => [domain, value]) values.nameserverPolicy.map(({ domain, value }) => [domain, value])
) )
} }
onSave({ const result = { dns: dnsConfig }
dns: dnsConfig, if (values.useHosts) {
hosts: hostsObject result['hosts'] = Object.fromEntries(
}) values.hosts.map(({ domain, value }) => [domain, value])
)
}
onSave(result)
}} }}
> >
{t('common.save')} {t('common.save')}

View File

@ -136,6 +136,7 @@ const Mihomo: React.FC = () => {
size="sm" size="sm"
aria-label={t('mihomo.selectCoreVersion')} aria-label={t('mihomo.selectCoreVersion')}
selectedKeys={new Set([core])} selectedKeys={new Set([core])}
disallowEmptySelection={true}
onSelectionChange={async (v) => { onSelectionChange={async (v) => {
handleConfigChangeWithRestart('core', v.currentKey as 'mihomo' | 'mihomo-alpha') handleConfigChangeWithRestart('core', v.currentKey as 'mihomo' | 'mihomo-alpha')
}} }}
@ -400,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) {
@ -450,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) {
@ -702,6 +703,7 @@ const Mihomo: React.FC = () => {
size="sm" size="sm"
aria-label={t('mihomo.selectLogLevel')} aria-label={t('mihomo.selectLogLevel')}
selectedKeys={new Set([logLevel])} selectedKeys={new Set([logLevel])}
disallowEmptySelection={true}
onSelectionChange={(v) => { onSelectionChange={(v) => {
onChangeNeedRestart({ 'log-level': v.currentKey as LogLevel }) onChangeNeedRestart({ 'log-level': v.currentKey as LogLevel })
}} }}
@ -720,6 +722,7 @@ const Mihomo: React.FC = () => {
size="sm" size="sm"
aria-label={t('mihomo.selectFindProcessMode')} aria-label={t('mihomo.selectFindProcessMode')}
selectedKeys={new Set([findProcessMode])} selectedKeys={new Set([findProcessMode])}
disallowEmptySelection={true}
onSelectionChange={(v) => { onSelectionChange={(v) => {
onChangeNeedRestart({ 'find-process-mode': v.currentKey as FindProcessMode }) onChangeNeedRestart({ 'find-process-mode': v.currentKey as FindProcessMode })
}} }}

View File

@ -22,24 +22,15 @@ import { includesIgnoreCase } from '@renderer/utils/includes'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config' import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SCROLL_POSITION_KEY = 'proxy_scroll_position'
const GROUP_EXPAND_STATE_KEY = 'proxy_group_expand_state' const GROUP_EXPAND_STATE_KEY = 'proxy_group_expand_state'
const SCROLL_DEBOUNCE_TIME = 200
const RENDER_DELAY = 100
// 自定义 hook 用于管理滚动位置和展开状态 // 自定义 hook 用于管理展开状态
const useProxyState = (groups: IMihomoMixedGroup[]): { 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[]>>;
scrollPosition: number;
onScroll: (e: React.UIEvent<HTMLElement>) => void;
} => { } => {
const virtuosoRef = useRef<GroupedVirtuosoHandle>(null) 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[]>(() => { const [isOpen, setIsOpen] = useState<boolean[]>(() => {
@ -61,84 +52,10 @@ const useProxyState = (groups: IMihomoMixedGroup[]): {
} }
}, [isOpen]) }, [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) {
// 只在首次加载或groups长度变化时恢复滚动位置
if (lastGroupsLength.current === 0 || lastGroupsLength.current !== groups.length) {
lastGroupsLength.current = groups.length
const timer = setTimeout(() => {
virtuosoRef.current?.scrollTo({
top: position,
behavior: 'auto' // 使用auto以避免平滑滚动引起的额外视觉效果
})
}, RENDER_DELAY)
return () => clearTimeout(timer)
}
}
}
} catch (error) {
console.error('Failed to restore scroll position:', error)
}
}
// 记录当前组长度以便跟踪变化
lastGroupsLength.current = groups.length
}, [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 { return {
virtuosoRef, virtuosoRef,
isOpen, isOpen,
setIsOpen, 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])
} }
} }
@ -157,8 +74,9 @@ const Proxies: React.FC = () => {
} = appConfig || {} } = appConfig || {}
const [cols, setCols] = useState(1) const [cols, setCols] = useState(1)
const { virtuosoRef, isOpen, setIsOpen, onScroll, scrollPosition } = 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[] = []
@ -193,23 +111,12 @@ const Proxies: React.FC = () => {
}, [groups, isOpen, proxyDisplayOrder, cols, searchValue]) }, [groups, isOpen, proxyDisplayOrder, cols, searchValue])
const onChangeProxy = useCallback(async (group: string, proxy: string): Promise<void> => { const onChangeProxy = useCallback(async (group: string, proxy: string): Promise<void> => {
// 保存当前滚动位置以便切换后恢复
const currentPosition = scrollPosition;
await mihomoChangeProxy(group, proxy) await mihomoChangeProxy(group, proxy)
if (autoCloseConnection) { if (autoCloseConnection) {
await mihomoCloseAllConnections() await mihomoCloseAllConnections()
} }
mutate() mutate()
}, [autoCloseConnection, mutate])
// 使用单层requestAnimationFrame和更长的延迟来确保DOM更新完成
setTimeout(() => {
virtuosoRef.current?.scrollTo({
top: currentPosition,
behavior: 'auto' // 使用auto避免出现平滑滚动导致的额外视觉抖动
})
}, 150) // 增加延迟让DOM有足够的时间更新
}, [autoCloseConnection, mutate, virtuosoRef, scrollPosition])
const onProxyDelay = useCallback(async (proxy: string, url?: string): Promise<IMihomoDelay> => { const onProxyDelay = useCallback(async (proxy: string, url?: string): Promise<IMihomoDelay> => {
return await mihomoProxyDelay(proxy, url) return await mihomoProxyDelay(proxy, url)
@ -229,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>[] = []
@ -240,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()
} }
}) })
@ -259,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])
@ -280,7 +211,7 @@ const Proxies: React.FC = () => {
handleResize() // 初始化 handleResize() // 初始化
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
return () => { return (): void => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
} }
}, [calcCols]) }, [calcCols])
@ -346,12 +277,9 @@ const Proxies: React.FC = () => {
<GroupedVirtuoso <GroupedVirtuoso
ref={virtuosoRef} ref={virtuosoRef}
groupCounts={groupCounts} groupCounts={groupCounts}
onScroll={onScroll} defaultItemHeight={80}
initialTopMostItemIndex={scrollPosition > 0 ? undefined : 0} increaseViewportBy={{ top: 300, bottom: 300 }}
defaultItemHeight={80} // 设置默认高度减少跳动 overscan={500}
increaseViewportBy={{ top: 300, bottom: 300 }} // 扩大可视区域减少闪烁
overscan={500} // 增加预渲染区域
// 使用稳定的key减少不必要的重新渲染
computeItemKey={(index, groupIndex) => { computeItemKey={(index, groupIndex) => {
let innerIndex = index let innerIndex = index
groupCounts.slice(0, groupIndex).forEach((count) => { groupCounts.slice(0, groupIndex).forEach((count) => {
@ -526,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

@ -14,8 +14,6 @@ import React, { useEffect, useState } from 'react'
import { HiExternalLink } from 'react-icons/hi' import { HiExternalLink } from 'react-icons/hi'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { IoMdCloudDownload } from 'react-icons/io' import { IoMdCloudDownload } from 'react-icons/io'
import BasePasswordModal from '@renderer/components/base/base-password-modal'
import { platform } from '@renderer/utils/init'
const SubStore: React.FC = () => { const SubStore: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -24,7 +22,6 @@ const SubStore: React.FC = () => {
const [backendPort, setBackendPort] = useState<number | undefined>() const [backendPort, setBackendPort] = useState<number | undefined>()
const [frontendPort, setFrontendPort] = useState<number | undefined>() const [frontendPort, setFrontendPort] = useState<number | undefined>()
const [isUpdating, setIsUpdating] = useState(false) const [isUpdating, setIsUpdating] = useState(false)
const [openPasswordModal, setOpenPasswordModal] = useState(false)
const getPort = async (): Promise<void> => { const getPort = async (): Promise<void> => {
setBackendPort(await subStorePort()) setBackendPort(await subStorePort())
setFrontendPort(await subStoreFrontendPort()) setFrontendPort(await subStoreFrontendPort())
@ -37,33 +34,10 @@ const SubStore: React.FC = () => {
if (!frontendPort) return null if (!frontendPort) return null
return ( return (
<> <>
{openPasswordModal && (
<BasePasswordModal
onCancel={() => setOpenPasswordModal(false)}
onConfirm={async (password: string) => {
try {
setOpenPasswordModal(false)
new Notification(t('substore.updating'))
await downloadSubStore(password)
await stopSubStoreBackendServer()
await startSubStoreBackendServer()
await new Promise((resolve) => setTimeout(resolve, 1000))
setFrontendPort(0)
await stopSubStoreFrontendServer()
await startSubStoreFrontendServer()
await getPort()
new Notification(t('substore.updateCompleted'))
} catch (e) {
alert(e)
}
}}
/>
)}
<BasePage <BasePage
title={t('substore.title')} title={t('substore.title')}
header={ header={
<div className="flex gap-2"> <div className="flex gap-2">
{platform != 'linux' && (
<Button <Button
title={t('substore.checkUpdate')} title={t('substore.checkUpdate')}
isIconOnly isIconOnly
@ -93,29 +67,7 @@ const SubStore: React.FC = () => {
> >
<IoMdCloudDownload className="text-lg" /> <IoMdCloudDownload className="text-lg" />
</Button> </Button>
)}
{platform === 'linux' && (
<Button
title={t('substore.checkUpdate')}
isIconOnly
size="sm"
className="app-nodrag"
variant="light"
isLoading={isUpdating}
onPress={async () => {
try {
setIsUpdating(true)
setOpenPasswordModal(true)
} catch (e) {
new Notification(`${t('substore.updateFailed')}: ${e}`)
} finally {
setIsUpdating(false)
}
}}
>
<IoMdCloudDownload className="text-lg" />
</Button>
)}
<Button <Button
title={t('substore.openInBrowser')} title={t('substore.openInBrowser')}
isIconOnly isIconOnly

View File

@ -11,6 +11,13 @@ import React from 'react'
import { MdDeleteForever } from 'react-icons/md' import { MdDeleteForever } from 'react-icons/md'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const defaultPacScript = `
function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
}
`
const Sysproxy: React.FC = () => {
const defaultBypass: string[] = const defaultBypass: string[] =
platform === 'linux' platform === 'linux'
? ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1'] ? ['localhost', '127.0.0.1', '192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12', '::1']
@ -49,13 +56,6 @@ const defaultBypass: string[] =
'<local>' '<local>'
] ]
const defaultPacScript = `
function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
}
`
const Sysproxy: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const { sysProxy } = appConfig || ({ sysProxy: { enable: false } } as IAppConfig) const { sysProxy } = appConfig || ({ sysProxy: { enable: false } } as IAppConfig)

View File

@ -6,7 +6,6 @@ import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-c
import { manualGrantCorePermition, restartCore, setupFirewall } from '@renderer/utils/ipc' import { manualGrantCorePermition, restartCore, setupFirewall } from '@renderer/utils/ipc'
import { platform } from '@renderer/utils/init' import { platform } from '@renderer/utils/init'
import React, { Key, useState } from 'react' import React, { Key, useState } from 'react'
import BasePasswordModal from '@renderer/components/base/base-password-modal'
import { useAppConfig } from '@renderer/hooks/use-app-config' import { useAppConfig } from '@renderer/hooks/use-app-config'
import { MdDeleteForever } from 'react-icons/md' import { MdDeleteForever } from 'react-icons/md'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -18,7 +17,6 @@ const Tun: React.FC = () => {
const { autoSetDNS = true } = appConfig || {} const { autoSetDNS = true } = appConfig || {}
const { tun } = controledMihomoConfig || {} const { tun } = controledMihomoConfig || {}
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [openPasswordModal, setOpenPasswordModal] = useState(false)
const { const {
device = 'Mihomo', device = 'Mihomo',
stack = 'mixed', stack = 'mixed',
@ -71,21 +69,6 @@ const Tun: React.FC = () => {
return ( return (
<> <>
{openPasswordModal && (
<BasePasswordModal
onCancel={() => setOpenPasswordModal(false)}
onConfirm={async (password: string) => {
try {
await manualGrantCorePermition(password)
new Notification(t('tun.notifications.coreAuthSuccess'))
await restartCore()
setOpenPasswordModal(false)
} catch (e) {
alert(e)
}
}}
/>
)}
<BasePage <BasePage
title={t('tun.title')} title={t('tun.title')}
header={ header={
@ -145,7 +128,6 @@ const Tun: React.FC = () => {
size="sm" size="sm"
color="primary" color="primary"
onPress={async () => { onPress={async () => {
if (platform === 'darwin') {
try { try {
await manualGrantCorePermition() await manualGrantCorePermition()
new Notification(t('tun.notifications.coreAuthSuccess')) new Notification(t('tun.notifications.coreAuthSuccess'))
@ -153,9 +135,6 @@ const Tun: React.FC = () => {
} catch (e) { } catch (e) {
alert(e) alert(e)
} }
} else {
setOpenPasswordModal(true)
}
}} }}
> >
{t('tun.core.auth')} {t('tun.core.auth')}

View File

@ -147,6 +147,10 @@ export async function updateProfileItem(item: IProfileItem): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('updateProfileItem', item)) 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> { export async function getProfileStr(id: string): Promise<string> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getProfileStr', id)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getProfileStr', id))
} }
@ -207,10 +211,8 @@ export async function triggerSysProxy(enable: boolean): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('triggerSysProxy', enable)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('triggerSysProxy', enable))
} }
export async function manualGrantCorePermition(password?: string): Promise<void> { export async function manualGrantCorePermition(): Promise<void> {
return ipcErrorWrapper( return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('manualGrantCorePermition'))
await window.electron.ipcRenderer.invoke('manualGrantCorePermition', password)
)
} }
export async function getFilePath(ext: string[]): Promise<string[] | undefined> { export async function getFilePath(ext: string[]): Promise<string[] | undefined> {
@ -326,8 +328,8 @@ export async function startSubStoreBackendServer(): Promise<void> {
export async function stopSubStoreBackendServer(): Promise<void> { export async function stopSubStoreBackendServer(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('stopSubStoreBackendServer')) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('stopSubStoreBackendServer'))
} }
export async function downloadSubStore(password?: string): Promise<void> { export async function downloadSubStore(): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('downloadSubStore', password)) return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('downloadSubStore'))
} }
export async function subStorePort(): Promise<number> { export async function subStorePort(): Promise<number> {

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
@ -461,6 +462,7 @@ interface IProfileItem {
useProxy?: boolean useProxy?: boolean
extra?: ISubscriptionUserInfo extra?: ISubscriptionUserInfo
substore?: boolean substore?: boolean
allowFixedInterval?: boolean
} }
interface ISubStoreSub { interface ISubStoreSub {