Compare commits

...
91 Commits
Author SHA1 Message Date
wonfen 7743c42dd1 Release - 2.1.2 真·臻 2025-03-01 01:49:01 +08:00
Christine. 3f3dab9495 add: i18n text for settings page. (#2815) 2025-03-01 01:36:36 +08:00
Tunglies 2dc9672b20 feat: better setting UI layout (#2814) 2025-03-01 01:29:23 +08:00
wonfen 2c90d1ca69 chore: v2 updater 2025-03-01 01:29:23 +08:00
Tunglies 1555e2910d feat: better setting layout ui (#2809) 2025-03-01 01:29:23 +08:00
Tunglies 19b6bd35f5 feat: quiting when enable tun mode no more blocking system network (#2805) 2025-03-01 01:29:23 +08:00
wonfen 3d4d60edc8 test updater 2025-03-01 01:29:23 +08:00
wonfen db0a000ecb chore: updater v2 Capabilities 2025-02-28 10:55:53 +08:00
wonfen 24ff48b41c fix: updater 2025-02-28 09:33:50 +08:00
wonfen 18a67d0b2f chore: updatelog 2025-02-28 07:34:56 +08:00
wonfen e23af1ad58 feat: add ability to check service version and auto-reinstall 2025-02-28 06:45:30 +08:00
wonfen 70af392059 feat: enhance updater script with comprehensive platform support and logging 2025-02-27 17:19:04 +08:00
Christine. d255df23ee fix: connection details (#2778) 2025-02-27 14:53:17 +08:00
wonfen 1317a8b2db feat: enhance merge config validation and error handling 2025-02-27 14:49:55 +08:00
wonfen 4cac118442 style: UI tweak 2025-02-27 13:04:46 +08:00
wonfen 2ce43ccd23 Release - 2.1.1 臻fix 2025-02-27 03:18:23 +08:00
Tunglies d23b2949d8 feat: macos display colorful icon with speed rate (#2771) 2025-02-27 01:51:52 +08:00
wonfen 3471476ba2 feat: improve file type detection for better script recognition 2025-02-26 15:59:19 +08:00
wonfen cee61e5619 chore: downgrade Tauri updater plugin and add i18n for core switching 2025-02-26 15:04:47 +08:00
wonfen 5026e2bade feat: optimize hotkey behavior and window management logic 2025-02-26 11:03:50 +08:00
wonfen 83f005c256 fix: CI 2025-02-26 09:52:15 +08:00
wonfen 80843c5ee3 Release 2.1.0 - 臻 2025-02-26 08:36:02 +08:00
wonfen ee00defe43 feat: enhance script validation and error handling 2025-02-26 05:21:14 +08:00
wonfen 7696504d97 feat: Add window state monitoring and auto-save in real-time 2025-02-26 00:36:02 +08:00
wonfen 7a0e38a1b4 perf: Improve config validation error messages and handling 2025-02-25 13:47:29 +08:00
wonfen bc5d577553 chore: revise translation 2025-02-24 23:26:04 +08:00
wonfen cfed2b7236 chore: Change default TUN stack from 'mixed' to 'gvisor' 2025-02-24 23:10:09 +08:00
Christine. e34e6654bc add: i18n text for config check (#2750) 2025-02-24 22:23:42 +08:00
wonfen d6b85f1a01 fix: remove node related group info when deleting a node 2025-02-24 11:40:28 +08:00
wonfen 16af7b53cb fix: menu switching issue 2025-02-24 11:37:23 +08:00
wonfen befc856207 feat: Improve core change configuration validation and error handling 2025-02-24 07:34:03 +08:00
wonfen 5ab8e7a7c7 feat: Enhance configuration validation and error handling during app startup 2025-02-24 06:21:32 +08:00
zhaoyuanandzymouse 443bfa5928 feat: 通过添加CLASH_VERGE_REV_IP环境变量的方式,修改复制环境变量按钮的IP (#2734)
Co-authored-by: zymouse <zymouse@pixmoving.net>
2025-02-24 03:42:40 +08:00
wonfen 52627575ff feat: Enhance configuration validation and error handling
- Improve config validation process with detailed logging and error tracking
- Add more robust error handling in profile updates and config patches
- Implement comprehensive config validation using clash core subprocess
2025-02-23 10:53:09 +08:00
Christine. 39973f2d24 feat: auto rename alpha version (#2740) 2025-02-23 02:30:15 +08:00
wonfen 2b01857d15 chore: Disable automatic alpha tag update in workflow 2025-02-20 14:25:58 +08:00
wonfen 2650aa845f refactor: Optimize proxy rendering and layout calculation 2025-02-20 14:21:55 +08:00
wonfen 8400a61bf0 refactor: Simplify tray icon event handling across platforms 2025-02-20 07:03:28 +08:00
wonfen 763b6b9003 chore: Update Node.js version and remove updater file generation 2025-02-20 02:55:47 +08:00
wonfen 053f414f3c feat: Add system proxy status indicators 2025-02-19 13:56:22 +08:00
wonfen 2defa2cc56 refactor: Simplify log data management and improve search functionality 2025-02-19 13:06:15 +08:00
wonfen bb1dbfcfc3 fix: connections page traffic calculation 2025-02-19 02:24:21 +08:00
Tunglies 1378068a30 rm dead code (#2718)
* rm: verge service takes full control of mihomo process. no more required.

* rm: dead code
2025-02-19 01:13:52 +08:00
wonfen 5d9dce7d10 style: tweak 2025-02-18 11:40:41 +08:00
wonfen acb66a3012 feat: Add persistent column width settings for connection table & process filtering 2025-02-18 11:04:22 +08:00
wonfen 054f902cc6 refactor: Remove unused routing select component from settings page 2025-02-18 09:04:23 +08:00
wonfen 4333153a59 feat: Improve letter item hover interaction with debounce mechanism 2025-02-18 09:00:03 +08:00
wonfen 19e623d7c2 feat: Add pause/resume functionality for connection tracking 2025-02-18 08:14:09 +08:00
wonfen 2dd646537e fix: resolve deprecated warnings in console 2025-02-18 07:10:28 +08:00
wonfen 1ece079978 perf: Improve proxy list interaction and UI responsiveness 2025-02-18 05:54:16 +08:00
wonfen 0d8f779634 feat: Enhance alphabet selector with dynamic tooltip and scrolling 2025-02-18 01:55:44 +08:00
wonfen a7ffc9fc38 refactor: Improve proxy data update mechanism with optimistic UI and error handling 2025-02-18 00:37:55 +08:00
wonfen 932b29e66f fix: Refine TypeScript types for proxy groups component 2025-02-17 16:27:06 +08:00
wonfen 6be7a3b94c feat: Enhance proxy groups with Initials navigation and performance optimizations 2025-02-17 16:07:46 +08:00
wonfen 8e8dd1ec03 feat: Optimize tray speed rate rendering and update logic 2025-02-17 15:08:19 +08:00
wonfen 772f3268ce feat: Improve Virtuoso list rendering for proxy groups 2025-02-17 14:30:21 +08:00
wonfen a179591ac2 feat: Add persistent scroll position for proxy groups 2025-02-17 14:24:33 +08:00
wonfen d071e5971f fix: alpha build win webview pack rename issue 2025-02-17 12:06:57 +08:00
wonfen a9032f5f20 chore: Update updater artifacts configuration 2025-02-17 11:22:14 +08:00
Akioe Yu 0190702616 fix: check script error (#2453)
(cherry picked from commit 97bde64fd6516019a190d52f05af54ade623d57c)
2025-02-17 03:10:19 +08:00
wonfen c35bf38420 chore: Add macOS-specific test for format_bytes_speed function 2025-02-17 03:10:03 +08:00
wonfen 89d20e564a Revert "perf: Improve kernel management logic & add more dev mode logs"
This reverts commit 18c7ed1ccc.
2025-02-15 05:51:46 +08:00
Christine. 3c24ff3afc add: i18n text for lightweight mode (#2692) 2025-02-13 14:27:12 +08:00
Tunglies daf0398750 fix: build front cannot find IvergeConfig.enable_lite_mode and macos port switching causes crash (#2691)
* fix: macos switch protocol port or enable protocol port causes crash

* fix: build time front cannot find IVergeConfig attribute enable_lite_mode
2025-02-12 18:14:13 -08:00
wonfen 6b14c2b763 feat: add lightweight mode 2025-02-13 02:18:17 +08:00
wonfen 5bf2f9b8ed fix(macos): add missing required dependencies 2025-02-12 15:06:42 +08:00
wonfen 10a2655288 fix: remove unused imports to resolve compile warnings 2025-02-12 14:35:49 +08:00
wonfen ac07397818 refactor: improve hotkey management, logging, and error handling; fix tray freeze and hotkey failure on silent startup 2025-02-12 14:23:42 +08:00
wonfen 30d061d00f fix(tray): resolve blank icon issue on Windows and optimize creation logic 2025-02-12 13:56:33 +08:00
Christine. 360d8a5143 fix: use remoteDestination replace DestinationIP in connection page, #2668 (#2679)
* fix: use remoteDestination replace DestinationIP  in connection page, #2668

* add: missing i18n text

* fix: display the target address details in connection page
2025-02-11 14:52:27 +08:00
wonfen 70e0a5adc8 refactor: Improve tray icon and event handling across platforms & unify click behavior 2025-02-11 14:48:31 +08:00
wonfen ef52f81494 fix: app crash without a default global_hotkey vaule 2025-02-11 01:50:48 +08:00
wonfen e5c1de3ad3 fix: deprecated CLI warnings 2025-02-11 01:05:51 +08:00
wonfen 576fd700ae feat: Add independent alpha update channel 2025-02-11 00:53:44 +08:00
wonfen 476df65a1b fix: try to fix linux CI updater problem 2025-02-11 00:53:44 +08:00
Christine. bd0a4863a8 add: i18n text (#2675) 2025-02-10 00:29:43 -08:00
wonfen d25fbc05e2 perf: Optimize kernel shutdown speed & logic 2025-02-10 13:18:00 +08:00
wonfen b8a0b6f1f4 fix: crash caused by global_hotkey 2025-02-10 12:39:07 +08:00
wonfen 18c7ed1ccc perf: Improve kernel management logic & add more dev mode logs 2025-02-09 14:19:15 +08:00
Tunglies b14db06955 perf: imporve clash mode switching performance on the main window (#2667) 2025-02-09 07:45:46 +08:00
Tunglies 215dcee3f1 feat: option to enable global hotkey (#2665) 2025-02-09 07:45:22 +08:00
Tunglies 37e5c22a5a fix: duplicate checked tray menu when profile name are same (#2660) 2025-02-08 09:36:04 +08:00
wonfen bb0b6c3f77 feat: Optimize UI layout and page hierarchy 2025-02-07 09:13:30 +08:00
wonfen a37df46ce7 perf: optimize node latency refresh rate for faster updates 2025-02-06 07:58:42 +08:00
wonfen 73fbec2514 chore: update deps 2025-02-05 14:19:43 +08:00
Tunglies f66fa08b2c Feature: Switch Proxy Profile from Tray Menu (#2644) 2025-02-05 08:52:47 +08:00
wonfen bae606bc9d fix: restore hotkey functionality after silent startup 2025-02-02 11:37:10 +08:00
wonfen 1a2d5e988a feat: allow users to customize enhanced-mode and fakeip-range in tun mode by wonfen 2025-02-02 05:12:16 +08:00
wonfen d20bd62b90 style: refine tray speed display 2025-02-02 03:43:15 +08:00
wonfen 204bf43b9a fix: windows rounded corners 2025-01-19 09:47:43 +08:00
wonfen 3ea0d20e2c add donation 2025-01-18 16:08:24 +08:00
64 changed files with 5027 additions and 2836 deletions
+29 -15
View File
@@ -2,9 +2,6 @@ name: Alpha Build
on: on:
workflow_dispatch: workflow_dispatch:
push:
branches: [main]
tags-ignore: [updater, alpha]
permissions: write-all permissions: write-all
env: env:
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
@@ -58,7 +55,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "22"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -70,6 +67,9 @@ jobs:
pnpm i pnpm i
pnpm check ${{ matrix.target }} pnpm check ${{ matrix.target }}
- name: Alpha Version update
run: pnpm run fix-alpha-version
- name: Tauri build - name: Tauri build
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
@@ -129,7 +129,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "22"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -248,7 +248,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "22"
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -281,9 +281,23 @@ jobs:
- name: Rename - name: Rename
run: | run: |
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.exe' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.exe' $files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip' foreach ($file in $files) {
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip.sig' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip.sig' $newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip.sig"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip\.sig$", "_fixed_webview2-setup.nsis.zip.sig"
Rename-Item $file.FullName $newName
}
- name: Upload Release - name: Upload Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -313,12 +327,12 @@ jobs:
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
shell: bash shell: bash
- name: Update Tag # - name: Update Tag
uses: richardsimko/update-tag@v1 # uses: richardsimko/update-tag@v1
with: # with:
tag_name: alpha # tag_name: alpha
env: # env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: | - run: |
cat > release.txt << 'EOF' cat > release.txt << 'EOF'
+17 -3
View File
@@ -271,9 +271,23 @@ jobs:
- name: Rename - name: Rename
run: | run: |
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.exe' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.exe' $files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip' foreach ($file in $files) {
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip.sig' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip.sig' $newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip.sig"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip\.sig$", "_fixed_webview2-setup.nsis.zip.sig"
Rename-Item $file.FullName $newName
}
- name: Upload Release - name: Upload Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
+4
View File
@@ -55,6 +55,10 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
Refer to [Doc FAQ Page](https://clash-verge-rev.github.io/faq/windows.html) Refer to [Doc FAQ Page](https://clash-verge-rev.github.io/faq/windows.html)
### Donation
[捐助Clash Verge Rev的开发](https://github.com/sponsors/clash-verge-rev)
## Development ## Development
See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
+103
View File
@@ -1,3 +1,106 @@
## v2.1.2
**发行代号:臻**
代号释义: 千锤百炼臻至善,集性能跃升、功能拓展、交互焕新于一体,彰显持续打磨、全方位优化的迭代精神。
感谢 Tychristine 对社区群组管理做出的重大贡献!
2.1.2相对2.1.1(已下架不在提供)更新了:
- 无法更新和签名验证失败的问题(该死的CDN缓存)
- 设置菜单区分Verge基本设置和高级设置
- 增加v2 Updater的更多功能和权限
- 退出Verge后Tun代理状态仍保留的问题
2.1.1相对2.1.0(已下架不在提供)更新了:
- 检测所需的Clash Verge Service版本(杀毒软件误报可能与此有关,因为检测和安装新版本Service需管理员权限)
- MacOS下支持彩色托盘图标和更好速率显示(感谢Tunglies)
- 文件类型判断不准导致脚本检测报错的问题
- 打开Win下的阴影(Win10因底层兼容性问题,可能圆角和边框显示不太完美)
- 边框去白边
- 修复Linux下编译问题
- 修复热键无法关闭面板的问题
## v2.1.0 - 发行代号:臻
### 功能新增
- 新增窗口状态实时监控与自动保存功能
- 增强核心配置变更时的验证与错误处理机制
- 支持通过环境变量`CLASH_VERGE_REV_IP`自定义复制IP地址
- 添加连接表列宽持久化设置与进程过滤功能
- 新增代理组首字母导航与动态滚动定位功能
- 实现连接追踪暂停/恢复功能
- 支持从托盘菜单快速切换代理配置
- 添加轻量级模式开关选项
- 允许用户自定义TUN模式增强类型和FakeIP范围
- 新增系统代理状态指示器
- 增加Alpha版本自动重命名逻辑
- 优化字母导航工具提示与防抖交互机制
### 性能优化
- 重构代理列表渲染逻辑,提升布局计算效率
- 优化代理数据更新机制,采用乐观UI策略
- 改进虚拟列表渲染性能(Virtuoso)
- 提升主窗口Clash模式切换速度(感谢Tunglies)
- 加速内核关闭流程并优化管理逻辑
- 优化节点延迟刷新速率
- 改进托盘网速显示更新逻辑
- 提升配置验证错误信息的可读性
- 重构服务架构,优化代码组织结构(感谢Tunglies)
- 优化内核启动时的配置验证流程
### 问题修复
- 修复删除节点时关联组信息残留问题
- 解决菜单切换异常与重复勾选问题
- 修正连接页流量计算错误
- 修复Windows圆角显示异常问题
- 解决控制台废弃API警告
- 修复全局热键空值导致的崩溃
- 修复Alpha版本Windows打包重命名问题
- 修复MacOS端口切换崩溃问题
- 解决Linux持续集成更新器问题
- 修复静默启动后热键失效问题
- 修正TypeScript代理组类型定义
- 修复Windows托盘图标空白问题
- 优化远程目标地址显示(替换旧版IP展示)
### 交互体验
- 统一多平台托盘图标点击行为
- 优化代理列表滚动流畅度
- 改进日志搜索功能与数据管理
- 重构热键管理逻辑,修复托盘冻结问题
- 优化托盘网速显示样式
- 增强字母导航工具提示的动态响应
### 国际化
- 新增配置检查多语言支持
- 添加轻量级模式多语言文本
- 完善多语言翻译内容
### 维护更新
- 将默认TUN协议栈改为gVisor
- 更新Node.js运行版本
- 移除自动生成更新器文件
- 清理废弃代码与未使用组件
- 禁用工作流自动Alpha标签更新
- 更新依赖库版本
- 添加MacOS格式转换函数专项测试
- 优化开发模式日志输出
### 安全增强
- 强化应用启动时的配置验证机制
- 改进脚本验证与异常处理流程
- 修复编译警告(移除无用导入)
## v2.0.3 ## v2.0.3
### Notice ### Notice
+24 -23
View File
@@ -1,6 +1,6 @@
{ {
"name": "clash-verge", "name": "clash-verge",
"version": "2.0.3", "version": "2.1.2",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev", "dev": "cross-env RUST_BACKTRACE=1 tauri dev",
@@ -15,25 +15,26 @@
"updater-fixed-webview2": "node scripts/updater-fixed-webview2.mjs", "updater-fixed-webview2": "node scripts/updater-fixed-webview2.mjs",
"portable": "node scripts/portable.mjs", "portable": "node scripts/portable.mjs",
"portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs", "portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs",
"fix-alpha-version": "node scripts/alpha_version.mjs",
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@mui/icons-material": "^6.3.0", "@mui/icons-material": "^6.4.2",
"@mui/lab": "5.0.0-alpha.149", "@mui/lab": "6.0.0-beta.25",
"@mui/material": "^6.3.0", "@mui/material": "^6.4.2",
"@mui/x-data-grid": "^7.23.5", "@mui/x-data-grid": "^7.25.0",
"@tauri-apps/api": "2.1.1", "@tauri-apps/api": "2.2.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.0", "@tauri-apps/plugin-clipboard-manager": "^2.2.1",
"@tauri-apps/plugin-dialog": "^2.2.0", "@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0", "@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-global-shortcut": "^2.2.0", "@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-notification": "^2.2.0", "@tauri-apps/plugin-notification": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-shell": "2.2.0", "@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-updater": "2.3.0", "@tauri-apps/plugin-updater": "2.3.0",
@@ -43,8 +44,8 @@
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"foxact": "^0.2.43", "foxact": "^0.2.43",
"glob": "^11.0.0", "glob": "^11.0.1",
"i18next": "^23.16.8", "i18next": "^24.2.2",
"js-base64": "^3.7.7", "js-base64": "^3.7.7",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@@ -54,31 +55,31 @@
"peggy": "^4.2.0", "peggy": "^4.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^4.0.12",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-i18next": "^15.4.0", "react-i18next": "^13.5.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-monaco-editor": "^0.56.2", "react-monaco-editor": "^0.56.0",
"react-router-dom": "^6.28.1", "react-router-dom": "^6.22.3",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-virtuoso": "^4.12.3", "react-virtuoso": "^4.6.3",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"swr": "^2.3.0", "swr": "^2.3.0",
"tar": "^7.4.3", "tar": "^7.4.3",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
"zustand": "^5.0.2" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "^6.0.0", "@actions/github": "^6.0.0",
"@tauri-apps/cli": "2.1.0", "@tauri-apps/cli": "2.2.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"@vitejs/plugin-legacy": "^5.4.3", "@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.2.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
@@ -87,10 +88,10 @@
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"pretty-quick": "^4.0.0", "pretty-quick": "^4.0.0",
"sass": "^1.83.0", "sass": "^1.83.4",
"terser": "^5.37.0", "terser": "^5.37.0",
"typescript": "^5.7.2", "typescript": "^5.7.3",
"vite": "^5.4.11", "vite": "^6.0.11",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-svgr": "^4.3.0" "vite-plugin-svgr": "^4.3.0"
}, },
+1106 -1202
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import { exec } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
/**
* 为Alpha版本重命名版本号
*/
const execPromise = promisify(exec);
/**
* 标准输出HEAD hash
*/
async function getLatestCommitHash() {
try {
const { stdout } = await execPromise("git rev-parse HEAD");
const commitHash = stdout.trim();
// 格式化,只截取前7位字符
const formathash = commitHash.substring(0, 7);
console.log(`Found the latest commit hash code: ${commitHash}`);
return formathash;
} catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error);
}
}
/**
* @param string 传入格式化后的hash
* 将新的版本号写入文件 package.json
*/
async function updatePackageVersion(newVersion) {
// 获取内容根目录
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
const initversion = packageJson.version;
// 将匹配到的第一个 "alpha" => 具体的hash
const fixversion = initversion.replace("alpha", newVersion);
packageJson.version = fixversion;
// 写入版本号
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
"utf8",
);
console.log(`Alpha version update to: ${fixversion}`);
} catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error);
}
}
const newVersion = await getLatestCommitHash();
updatePackageVersion(newVersion);
+4 -4
View File
@@ -5,7 +5,7 @@ import { extract } from "tar";
import path from "path"; import path from "path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import fetch from "node-fetch"; import fetch from "node-fetch";
import proxyAgent from "https-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { log_info, log_debug, log_error, log_success } from "./utils.mjs"; import { log_info, log_debug, log_error, log_success } from "./utils.mjs";
import { glob } from "glob"; import { glob } from "glob";
@@ -85,7 +85,7 @@ async function getLatestAlphaVersion() {
process.env.https_proxy; process.env.https_proxy;
if (httpProxy) { if (httpProxy) {
options.agent = proxyAgent(httpProxy); options.agent = new HttpsProxyAgent(httpProxy);
} }
try { try {
const response = await fetch(META_ALPHA_VERSION_URL, { const response = await fetch(META_ALPHA_VERSION_URL, {
@@ -132,7 +132,7 @@ async function getLatestReleaseVersion() {
process.env.https_proxy; process.env.https_proxy;
if (httpProxy) { if (httpProxy) {
options.agent = proxyAgent(httpProxy); options.agent = new HttpsProxyAgent(httpProxy);
} }
try { try {
const response = await fetch(META_VERSION_URL, { const response = await fetch(META_VERSION_URL, {
@@ -332,7 +332,7 @@ async function resolveResource(binInfo) {
process.env.https_proxy; process.env.https_proxy;
if (httpProxy) { if (httpProxy) {
options.agent = proxyAgent(httpProxy); options.agent = new HttpsProxyAgent(httpProxy);
} }
const response = await fetch(url, { const response = await fetch(url, {
+6 -6
View File
@@ -60,35 +60,35 @@ async function resolveUpdater() {
const { name, browser_download_url } = asset; const { name, browser_download_url } = asset;
// win64 url // win64 url
if (name.endsWith("x64-setup.nsis.zip")) { if (name.endsWith("x64-setup.exe")) {
updateData.platforms.win64.url = browser_download_url; updateData.platforms.win64.url = browser_download_url;
updateData.platforms["windows-x86_64"].url = browser_download_url; updateData.platforms["windows-x86_64"].url = browser_download_url;
} }
// win64 signature // win64 signature
if (name.endsWith("x64-setup.nsis.zip.sig")) { if (name.endsWith("x64-setup.exe.sig")) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url);
updateData.platforms.win64.signature = sig; updateData.platforms.win64.signature = sig;
updateData.platforms["windows-x86_64"].signature = sig; updateData.platforms["windows-x86_64"].signature = sig;
} }
// win32 url // win32 url
if (name.endsWith("x86-setup.nsis.zip")) { if (name.endsWith("x86-setup.exe")) {
updateData.platforms["windows-x86"].url = browser_download_url; updateData.platforms["windows-x86"].url = browser_download_url;
updateData.platforms["windows-i686"].url = browser_download_url; updateData.platforms["windows-i686"].url = browser_download_url;
} }
// win32 signature // win32 signature
if (name.endsWith("x86-setup.nsis.zip.sig")) { if (name.endsWith("x86-setup.exe.sig")) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url);
updateData.platforms["windows-x86"].signature = sig; updateData.platforms["windows-x86"].signature = sig;
updateData.platforms["windows-i686"].signature = sig; updateData.platforms["windows-i686"].signature = sig;
} }
// win arm url // win arm url
if (name.endsWith("arm64-setup.nsis.zip")) { if (name.endsWith("arm64-setup.exe")) {
updateData.platforms["windows-aarch64"].url = browser_download_url; updateData.platforms["windows-aarch64"].url = browser_download_url;
} }
// win arm signature // win arm signature
if (name.endsWith("arm64-setup.nsis.zip.sig")) { if (name.endsWith("arm64-setup.exe.sig")) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url);
updateData.platforms["windows-aarch64"].signature = sig; updateData.platforms["windows-aarch64"].signature = sig;
} }
+807 -752
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clash-verge" name = "clash-verge"
version = "2.0.3" version = "2.1.2"
description = "clash verge" description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda"] authors = ["zzzgydi", "wonfen", "MystiPanda"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
@@ -13,36 +13,36 @@ build = "build.rs"
identifier = "io.github.clash-verge-rev.clash-verge-rev" identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.0.3", features = [] } tauri-build = { version = "2.0.5", features = [] }
[dependencies] [dependencies]
warp = "0.3" warp = "0.3"
anyhow = "1.0" anyhow = "1.0"
dirs = "5.0" dirs = "6.0"
open = "5.1" open = "5.1"
log = "0.4" log = "0.4"
dunce = "1.0" dunce = "1.0"
log4rs = "1" log4rs = "1"
nanoid = "0.4" nanoid = "0.4"
chrono = "0.4" chrono = "0.4"
sysinfo = "0.33.0" sysinfo = "0.33.1"
boa_engine = "0.20.0" boa_engine = "0.20.0"
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9" serde_yaml = "0.9"
once_cell = "1.19" once_cell = "1.19"
port_scanner = "0.1.5" port_scanner = "0.1.5"
delay_timer = "0.11" delay_timer = "0.11.6"
parking_lot = "0.12" parking_lot = "0.12"
percent-encoding = "2.3.1" percent-encoding = "2.3.1"
window-shadows = { version = "0.2.2" } window-shadows = { version = "0.2.2" }
tokio = { version = "1", features = ["full"] } tokio = { version = "1.43", features = ["full"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", rev = "3d748b5" } sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", rev = "3d748b5" }
image = "0.24" image = "0.24"
imageproc = "0.23" imageproc = "0.23"
rusttype = "0.9" rusttype = "0.9"
tauri = { version = "2.1.1", features = [ tauri = { version = "2.2.5", features = [
"protocol-asset", "protocol-asset",
"devtools", "devtools",
"tray-icon", "tray-icon",
@@ -53,13 +53,13 @@ network-interface = { version = "2.0.0", features = ["serde"] }
tauri-plugin-shell = "2.2.0" tauri-plugin-shell = "2.2.0"
tauri-plugin-dialog = "2.2.0" tauri-plugin-dialog = "2.2.0"
tauri-plugin-fs = "2.2.0" tauri-plugin-fs = "2.2.0"
tauri-plugin-notification = "2.2.0" tauri-plugin-notification = "2.2.1"
tauri-plugin-process = "2.2.0" tauri-plugin-process = "2.2.0"
tauri-plugin-clipboard-manager = "2.2.0" tauri-plugin-clipboard-manager = "2.2.1"
tauri-plugin-deep-link = "2.2.0" tauri-plugin-deep-link = "2.2.0"
tauri-plugin-devtools = "2.0.0-rc" tauri-plugin-devtools = "2.0.0-rc"
url = "2.5.2" url = "2.5.4"
zip = "2.2.0" zip = "2.2.2"
reqwest_dav = "0.1.14" reqwest_dav = "0.1.14"
aes-gcm = { version = "0.10.3", features = ["std"] } aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1" base64 = "0.22.1"
@@ -71,8 +71,8 @@ sys-locale = "0.3.1"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
runas = "=1.2.0" runas = "=1.2.0"
deelevate = "0.2.0" deelevate = "0.2.0"
winreg = "0.52.0" winreg = "0.55.0"
url = "2.5.2" url = "2.5.4"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
@@ -82,7 +82,7 @@ users = "0.11.0"
tauri-plugin-autostart = "2.2.0" tauri-plugin-autostart = "2.2.0"
tauri-plugin-global-shortcut = "2.2.0" tauri-plugin-global-shortcut = "2.2.0"
tauri-plugin-updater = "2.3.0" tauri-plugin-updater = "2.3.0"
tauri-plugin-window-state = "2.2.0" tauri-plugin-window-state = "2.2.1"
#openssl #openssl
[features] [features]
BIN
View File
Binary file not shown.
Binary file not shown.
+7
View File
@@ -6,6 +6,13 @@
"permissions": [ "permissions": [
"global-shortcut:default", "global-shortcut:default",
"updater:default", "updater:default",
"dialog:default",
"dialog:allow-ask",
"dialog:allow-message",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:allow-restart",
"deep-link:default", "deep-link:default",
"window-state:default", "window-state:default",
"window-state:default", "window-state:default",
+292 -23
View File
@@ -13,6 +13,7 @@ use sysproxy::{Autoproxy, Sysproxy};
type CmdResult<T = ()> = Result<T, String>; type CmdResult<T = ()> = Result<T, String>;
use reqwest_dav::list_cmd::ListFile; use reqwest_dav::list_cmd::ListFile;
use tauri::Manager; use tauri::Manager;
use std::fs;
#[tauri::command] #[tauri::command]
pub fn copy_clash_env() -> CmdResult { pub fn copy_clash_env() -> CmdResult {
@@ -22,15 +23,30 @@ pub fn copy_clash_env() -> CmdResult {
#[tauri::command] #[tauri::command]
pub fn get_profiles() -> CmdResult<IProfiles> { pub fn get_profiles() -> CmdResult<IProfiles> {
let _ = tray::Tray::global().update_menu();
Ok(Config::profiles().data().clone()) Ok(Config::profiles().data().clone())
} }
#[tauri::command] #[tauri::command]
pub async fn enhance_profiles() -> CmdResult { pub async fn enhance_profiles() -> CmdResult {
wrap_err!(CoreManager::global().update_config().await)?; match CoreManager::global().update_config().await {
log_err!(tray::Tray::global().update_tooltip()); Ok((true, _)) => {
handle::Handle::refresh_clash(); println!("[enhance_profiles] 配置更新成功");
Ok(()) log_err!(tray::Tray::global().update_tooltip());
handle::Handle::refresh_clash();
Ok(())
}
Ok((false, error_msg)) => {
println!("[enhance_profiles] 配置验证失败: {}", error_msg);
handle::Handle::notice_message("config_validate::error", &error_msg);
Ok(())
}
Err(e) => {
println!("[enhance_profiles] 更新过程发生错误: {}", e);
handle::Handle::notice_message("config_validate::process_terminated", &e.to_string());
Ok(())
}
}
} }
#[tauri::command] #[tauri::command]
@@ -62,36 +78,80 @@ pub async fn delete_profile(index: String) -> CmdResult {
wrap_err!(CoreManager::global().update_config().await)?; wrap_err!(CoreManager::global().update_config().await)?;
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
} }
Ok(()) Ok(())
} }
/// 修改profiles的 /// 修改profiles的配置
#[tauri::command] #[tauri::command]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { pub async fn patch_profiles_config(
profiles: IProfiles
) -> CmdResult<bool> {
println!("[cmd配置patch] 开始修改配置文件");
// 保存当前配置,以便在验证失败时恢复
let current_profile = Config::profiles().latest().current.clone();
println!("[cmd配置patch] 当前配置: {:?}", current_profile);
// 更新profiles配置
println!("[cmd配置patch] 正在更新配置草稿");
wrap_err!({ Config::profiles().draft().patch_config(profiles) })?; wrap_err!({ Config::profiles().draft().patch_config(profiles) })?;
// 更新配置并进行验证
match CoreManager::global().update_config().await { match CoreManager::global().update_config().await {
Ok(_) => { Ok((true, _)) => {
println!("[cmd配置patch] 配置更新成功");
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
let _ = tray::Tray::global().update_tooltip(); let _ = tray::Tray::global().update_tooltip();
Config::profiles().apply(); Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?; wrap_err!(Config::profiles().data().save_file())?;
Ok(()) Ok(true)
} }
Err(err) => { Ok((false, error_msg)) => {
println!("[cmd配置patch] 配置验证失败: {}", error_msg);
Config::profiles().discard(); Config::profiles().discard();
log::error!(target: "app", "{err}");
Err(format!("{err}")) // 如果验证失败,恢复到之前的配置
if let Some(prev_profile) = current_profile {
println!("[cmd配置patch] 尝试恢复到之前的配置: {}", prev_profile);
let restore_profiles = IProfiles {
current: Some(prev_profile),
items: None,
};
// 静默恢复,不触发验证
wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?;
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
println!("[cmd配置patch] 成功恢复到之前的配置");
}
// 发送验证错误通知
handle::Handle::notice_message("config_validate::error", &error_msg);
Ok(false)
}
Err(e) => {
println!("[cmd配置patch] 更新过程发生错误: {}", e);
Config::profiles().discard();
handle::Handle::notice_message("config_validate::boot_error", &e.to_string());
Ok(false)
} }
} }
} }
/// 根据profile name修改profiles
#[tauri::command]
pub async fn patch_profiles_config_by_profile_index(
_app_handle: tauri::AppHandle,
profile_index: String
) -> CmdResult<bool> {
let profiles = IProfiles{current: Some(profile_index), items: None};
patch_profiles_config(profiles).await
}
/// 修改某个profile item的 /// 修改某个profile item的
#[tauri::command] #[tauri::command]
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult { pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
wrap_err!(Config::profiles().data().patch_item(index, profile))?; wrap_err!(Config::profiles().data().patch_item(index, profile))?;
wrap_err!(timer::Timer::global().refresh()) Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -119,17 +179,107 @@ pub fn read_profile_file(index: String) -> CmdResult<String> {
let data = wrap_err!(item.read_file())?; let data = wrap_err!(item.read_file())?;
Ok(data) Ok(data)
} }
/// 保存profiles的配置
#[tauri::command] #[tauri::command]
pub fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult { pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
if file_data.is_none() { if file_data.is_none() {
return Ok(()); return Ok(());
} }
let profiles = Config::profiles(); // 在异步操作前完成所有文件操作
let profiles = profiles.latest(); let (file_path, original_content, is_merge_file) = {
let item = wrap_err!(profiles.get_item(&index))?; let profiles = Config::profiles();
wrap_err!(item.save_file(file_data.unwrap())) let profiles_guard = profiles.latest();
let item = wrap_err!(profiles_guard.get_item(&index))?;
// 确定是否为merge类型文件
let is_merge = item.itype.as_ref().map_or(false, |t| t == "merge");
let content = wrap_err!(item.read_file())?;
let path = item.file.clone().ok_or("file field is null")?;
let profiles_dir = wrap_err!(dirs::app_profiles_dir())?;
(profiles_dir.join(path), content, is_merge)
};
// 保存新的配置文件
wrap_err!(fs::write(&file_path, file_data.clone().unwrap()))?;
let file_path_str = file_path.to_string_lossy().to_string();
println!("[cmd配置save] 开始验证配置文件: {}, 是否为merge文件: {}", file_path_str, is_merge_file);
// 对于 merge 文件,只进行语法验证,不进行后续内核验证
if is_merge_file {
println!("[cmd配置save] 检测到merge文件,只进行语法验证");
match CoreManager::global().validate_config_file(&file_path_str, Some(true)).await {
Ok((true, _)) => {
println!("[cmd配置save] merge文件语法验证通过");
// 成功后尝试更新整体配置
if let Err(e) = CoreManager::global().update_config().await {
println!("[cmd配置save] 更新整体配置时发生错误: {}", e);
log::warn!(target: "app", "更新整体配置时发生错误: {}", e);
}
return Ok(());
}
Ok((false, error_msg)) => {
println!("[cmd配置save] merge文件语法验证失败: {}", error_msg);
// 恢复原始配置文件
wrap_err!(fs::write(&file_path, original_content))?;
// 发送合并文件专用错误通知
let result = (false, error_msg.clone());
handle_yaml_validation_notice(&result, "合并配置文件");
return Ok(());
}
Err(e) => {
println!("[cmd配置save] 验证过程发生错误: {}", e);
// 恢复原始配置文件
wrap_err!(fs::write(&file_path, original_content))?;
return Err(e.to_string());
}
}
}
// 非merge文件使用完整验证流程
match CoreManager::global().validate_config_file(&file_path_str, None).await {
Ok((true, _)) => {
println!("[cmd配置save] 验证成功");
Ok(())
}
Ok((false, error_msg)) => {
println!("[cmd配置save] 验证失败: {}", error_msg);
// 恢复原始配置文件
wrap_err!(fs::write(&file_path, original_content))?;
// 智能判断错误类型
let is_script_error = file_path_str.ends_with(".js") ||
error_msg.contains("Script syntax error") ||
error_msg.contains("Script must contain a main function") ||
error_msg.contains("Failed to read script file");
if error_msg.contains("YAML syntax error") ||
error_msg.contains("Failed to read file:") ||
(!file_path_str.ends_with(".js") && !is_script_error) {
// 普通YAML错误使用YAML通知处理
println!("[cmd配置save] YAML配置文件验证失败,发送通知");
let result = (false, error_msg.clone());
handle_yaml_validation_notice(&result, "YAML配置文件");
} else if is_script_error {
// 脚本错误使用专门的通知处理
println!("[cmd配置save] 脚本文件验证失败,发送通知");
let result = (false, error_msg.clone());
handle_script_validation_notice(&result, "脚本文件");
} else {
// 普通配置错误使用一般通知
println!("[cmd配置save] 其他类型验证失败,发送一般通知");
handle::Handle::notice_message("config_validate::error", &error_msg);
}
Ok(())
}
Err(e) => {
println!("[cmd配置save] 验证过程发生错误: {}", e);
// 恢复原始配置文件
wrap_err!(fs::write(&file_path, original_content))?;
Err(e.to_string())
}
}
} }
#[tauri::command] #[tauri::command]
@@ -169,6 +319,12 @@ pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
wrap_err!(feat::patch_clash(payload).await) wrap_err!(feat::patch_clash(payload).await)
} }
#[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult {
Ok(feat::change_clash_mode(payload))
}
#[tauri::command] #[tauri::command]
pub fn get_verge_config() -> CmdResult<IVergeResponse> { pub fn get_verge_config() -> CmdResult<IVergeResponse> {
let verge = Config::verge(); let verge = Config::verge();
@@ -178,12 +334,27 @@ pub fn get_verge_config() -> CmdResult<IVergeResponse> {
#[tauri::command] #[tauri::command]
pub async fn patch_verge_config(payload: IVerge) -> CmdResult { pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
wrap_err!(feat::patch_verge(payload).await) wrap_err!(feat::patch_verge(payload, false).await)
} }
#[tauri::command] #[tauri::command]
pub async fn change_clash_core(clash_core: Option<String>) -> CmdResult { pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>> {
wrap_err!(CoreManager::global().change_core(clash_core).await) log::info!(target: "app", "changing core to {clash_core}");
match CoreManager::global().change_core(Some(clash_core.clone())).await {
Ok(_) => {
log::info!(target: "app", "core changed to {clash_core}");
handle::Handle::notice_message("config_core::change_success", &clash_core);
handle::Handle::refresh_clash();
Ok(None)
}
Err(err) => {
let error_msg = err.to_string();
log::error!(target: "app", "failed to change core: {error_msg}");
handle::Handle::notice_message("config_core::change_error", &error_msg);
Ok(Some(error_msg))
}
}
} }
/// restart the sidecar /// restart the sidecar
@@ -427,3 +598,101 @@ pub mod uwp {
Ok(()) Ok(())
} }
} }
#[tauri::command]
pub async fn script_validate_notice(status: String, msg: String) -> CmdResult {
handle::Handle::notice_message(&status, &msg);
Ok(())
}
/// 处理脚本验证相关的所有消息通知
/// 统一通知接口,保持消息类型一致性
pub fn handle_script_validation_notice(result: &(bool, String), file_type: &str) {
if !result.0 {
let error_msg = &result.1;
// 根据错误消息内容判断错误类型
let status = if error_msg.starts_with("File not found:") {
"config_validate::file_not_found"
} else if error_msg.starts_with("Failed to read script file:") {
"config_validate::script_error"
} else if error_msg.starts_with("Script syntax error:") {
"config_validate::script_syntax_error"
} else if error_msg == "Script must contain a main function" {
"config_validate::script_missing_main"
} else {
// 如果是其他类型错误,作为一般脚本错误处理
"config_validate::script_error"
};
log::warn!(target: "app", "{} 验证失败: {}", file_type, error_msg);
handle::Handle::notice_message(status, error_msg);
}
}
/// 验证指定脚本文件
#[tauri::command]
pub async fn validate_script_file(file_path: String) -> CmdResult<bool> {
log::info!(target: "app", "验证脚本文件: {}", file_path);
match CoreManager::global().validate_config_file(&file_path, None).await {
Ok(result) => {
handle_script_validation_notice(&result, "脚本文件");
Ok(result.0) // 返回验证结果布尔值
},
Err(e) => {
let error_msg = e.to_string();
log::error!(target: "app", "验证脚本文件过程发生错误: {}", error_msg);
handle::Handle::notice_message("config_validate::process_terminated", &error_msg);
Ok(false)
}
}
}
/// 处理YAML验证相关的所有消息通知
/// 统一通知接口,保持消息类型一致性
pub fn handle_yaml_validation_notice(result: &(bool, String), file_type: &str) {
if !result.0 {
let error_msg = &result.1;
println!("[通知] 处理{}验证错误: {}", file_type, error_msg);
// 检查是否为merge文件
let is_merge_file = file_type.contains("合并");
// 根据错误消息内容判断错误类型
let status = if error_msg.starts_with("File not found:") {
"config_validate::file_not_found"
} else if error_msg.starts_with("Failed to read file:") {
"config_validate::yaml_read_error"
} else if error_msg.starts_with("YAML syntax error:") {
if is_merge_file {
"config_validate::merge_syntax_error"
} else {
"config_validate::yaml_syntax_error"
}
} else if error_msg.contains("mapping values are not allowed") {
if is_merge_file {
"config_validate::merge_mapping_error"
} else {
"config_validate::yaml_mapping_error"
}
} else if error_msg.contains("did not find expected key") {
if is_merge_file {
"config_validate::merge_key_error"
} else {
"config_validate::yaml_key_error"
}
} else {
// 如果是其他类型错误,根据文件类型作为一般错误处理
if is_merge_file {
"config_validate::merge_error"
} else {
"config_validate::yaml_error"
}
};
log::warn!(target: "app", "{} 验证失败: {}", file_type, error_msg);
println!("[通知] 发送通知: status={}, msg={}", status, error_msg);
handle::Handle::notice_message(status, error_msg);
}
}
+50 -11
View File
@@ -3,10 +3,12 @@ use crate::{
config::PrfItem, config::PrfItem,
enhance, enhance,
utils::{dirs, help}, utils::{dirs, help},
core::{handle, CoreManager},
}; };
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::time::{sleep, Duration};
pub const RUNTIME_CONFIG: &str = "clash-verge.yaml"; pub const RUNTIME_CONFIG: &str = "clash-verge.yaml";
pub const CHECK_CONFIG: &str = "clash-verge-check.yaml"; pub const CHECK_CONFIG: &str = "clash-verge-check.yaml";
@@ -64,20 +66,57 @@ impl Config {
let script_item = PrfItem::from_script(Some("Script".to_string()))?; let script_item = PrfItem::from_script(Some("Script".to_string()))?;
Self::profiles().data().append_item(script_item.clone())?; Self::profiles().data().append_item(script_item.clone())?;
} }
crate::log_err!(Self::generate().await);
if let Err(err) = Self::generate_file(ConfigType::Run) {
log::error!(target: "app", "{err}");
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG); // 生成运行时配置
// 如果不存在就将默认的clash文件拿过来 crate::log_err!(Self::generate().await);
if !runtime_path.exists() {
help::save_yaml( // 生成运行时配置文件并验证
&runtime_path, let config_result = Self::generate_file(ConfigType::Run);
&Config::clash().latest().0,
Some("# Clash Verge Runtime"), let validation_result = if let Ok(_) = config_result {
)?; // 验证配置文件
println!("[首次启动] 开始验证配置");
match CoreManager::global().validate_config().await {
Ok((is_valid, error_msg)) => {
if !is_valid {
println!("[首次启动] 配置验证失败,使用默认最小配置启动: {}", error_msg);
CoreManager::global()
.use_default_config("config_validate::boot_error", &error_msg)
.await?;
Some(("config_validate::boot_error", error_msg))
} else {
println!("[首次启动] 配置验证成功");
Some(("config_validate::success", String::new()))
}
}
Err(err) => {
println!("[首次启动] 验证进程执行失败: {}", err);
CoreManager::global()
.use_default_config("config_validate::process_terminated", "")
.await?;
Some(("config_validate::process_terminated", String::new()))
}
} }
} else {
println!("[首次启动] 生成配置文件失败,使用默认配置");
CoreManager::global()
.use_default_config(
"config_validate::error",
"",
)
.await?;
Some(("config_validate::error", String::new()))
};
// 在单独的任务中发送通知
if let Some((msg_type, msg_content)) = validation_result {
tauri::async_runtime::spawn(async move {
sleep(Duration::from_secs(2)).await;
handle::Handle::notice_message(msg_type, &msg_content);
});
} }
Ok(()) Ok(())
} }
+19 -1
View File
@@ -71,7 +71,6 @@ impl IProfiles {
if let Some(current) = patch.current { if let Some(current) = patch.current {
let items = self.items.as_ref().unwrap(); let items = self.items.as_ref().unwrap();
let some_uid = Some(current); let some_uid = Some(current);
if items.iter().any(|e| e.uid == some_uid) { if items.iter().any(|e| e.uid == some_uid) {
self.current = some_uid; self.current = some_uid;
} }
@@ -465,4 +464,23 @@ impl IProfiles {
_ => None, _ => None,
} }
} }
/// 判断profile是否是current指向的
pub fn is_current_profile_index(&self, index: String) -> bool {
self.current == Some(index)
}
/// 获取所有的profiles(uid,名称)
pub fn all_profile_uid_and_name(&self) -> Option<Vec<(String, String)>> {
match self.items.as_ref() {
Some(items) => Some(items.iter().filter_map(|e| {
if let (Some(uid), Some(name)) = (e.uid.clone(), e.name.clone()) {
Some((uid, name))
} else {
None
}
}).collect()),
None => None,
}
}
} }
+14
View File
@@ -98,6 +98,9 @@ pub struct IVerge {
/// hotkey map /// hotkey map
/// format: {func},{key} /// format: {func},{key}
pub hotkeys: Option<Vec<String>>, pub hotkeys: Option<Vec<String>>,
/// enable global hotkey
pub enable_global_hotkey: Option<bool>,
/// 切换代理时自动关闭连接 /// 切换代理时自动关闭连接
pub auto_close_connection: Option<bool>, pub auto_close_connection: Option<bool>,
@@ -178,6 +181,9 @@ pub struct IVerge {
pub webdav_password: Option<String>, pub webdav_password: Option<String>,
pub enable_tray_speed: Option<bool>, pub enable_tray_speed: Option<bool>,
/// 轻量模式 - 只保留内核运行
pub enable_lite_mode: Option<bool>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize)]
@@ -279,6 +285,8 @@ impl IVerge {
webdav_username: None, webdav_username: None,
webdav_password: None, webdav_password: None,
enable_tray_speed: Some(true), enable_tray_speed: Some(true),
enable_global_hotkey: Some(true),
enable_lite_mode: Some(false),
..Self::default() ..Self::default()
} }
} }
@@ -345,6 +353,7 @@ impl IVerge {
patch!(web_ui_list); patch!(web_ui_list);
patch!(clash_core); patch!(clash_core);
patch!(hotkeys); patch!(hotkeys);
patch!(enable_global_hotkey);
patch!(auto_close_connection); patch!(auto_close_connection);
patch!(auto_check_update); patch!(auto_check_update);
@@ -359,6 +368,7 @@ impl IVerge {
patch!(webdav_username); patch!(webdav_username);
patch!(webdav_password); patch!(webdav_password);
patch!(enable_tray_speed); patch!(enable_tray_speed);
patch!(enable_lite_mode);
} }
/// 在初始化前尝试拿到单例端口的值 /// 在初始化前尝试拿到单例端口的值
@@ -411,6 +421,7 @@ pub struct IVergeResponse {
pub enable_silent_start: Option<bool>, pub enable_silent_start: Option<bool>,
pub enable_system_proxy: Option<bool>, pub enable_system_proxy: Option<bool>,
pub enable_proxy_guard: Option<bool>, pub enable_proxy_guard: Option<bool>,
pub enable_global_hotkey: Option<bool>,
pub use_default_bypass: Option<bool>, pub use_default_bypass: Option<bool>,
pub system_proxy_bypass: Option<String>, pub system_proxy_bypass: Option<String>,
pub proxy_guard_duration: Option<u64>, pub proxy_guard_duration: Option<u64>,
@@ -446,6 +457,7 @@ pub struct IVergeResponse {
pub webdav_username: Option<String>, pub webdav_username: Option<String>,
pub webdav_password: Option<String>, pub webdav_password: Option<String>,
pub enable_tray_speed: Option<bool>, pub enable_tray_speed: Option<bool>,
pub enable_lite_mode: Option<bool>,
} }
impl From<IVerge> for IVergeResponse { impl From<IVerge> for IVergeResponse {
@@ -472,6 +484,7 @@ impl From<IVerge> for IVergeResponse {
enable_silent_start: verge.enable_silent_start, enable_silent_start: verge.enable_silent_start,
enable_system_proxy: verge.enable_system_proxy, enable_system_proxy: verge.enable_system_proxy,
enable_proxy_guard: verge.enable_proxy_guard, enable_proxy_guard: verge.enable_proxy_guard,
enable_global_hotkey: verge.enable_global_hotkey,
use_default_bypass: verge.use_default_bypass, use_default_bypass: verge.use_default_bypass,
system_proxy_bypass: verge.system_proxy_bypass, system_proxy_bypass: verge.system_proxy_bypass,
proxy_guard_duration: verge.proxy_guard_duration, proxy_guard_duration: verge.proxy_guard_duration,
@@ -507,6 +520,7 @@ impl From<IVerge> for IVergeResponse {
webdav_username: verge.webdav_username, webdav_username: verge.webdav_username,
webdav_password: verge.webdav_password, webdav_password: verge.webdav_password,
enable_tray_speed: verge.enable_tray_speed, enable_tray_speed: verge.enable_tray_speed,
enable_lite_mode: verge.enable_lite_mode,
} }
} }
} }
+361 -59
View File
@@ -1,8 +1,9 @@
use super::tray::Tray;
use crate::config::*; use crate::config::*;
use crate::core::{clash_api, handle, service}; use crate::core::{clash_api, handle, service};
#[cfg(target_os = "macos")]
use crate::core::tray::Tray;
use crate::log_err; use crate::log_err;
use crate::utils::dirs; use crate::utils::{dirs, help};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use serde_yaml::Mapping; use serde_yaml::Mapping;
@@ -32,28 +33,6 @@ impl CoreManager {
Ok(()) Ok(())
} }
/// 检查订阅是否正确
pub async fn check_config(&self) -> Result<()> {
let config_path = Config::generate_file(ConfigType::Check)?;
let config_path = dirs::path_to_str(&config_path)?;
let clash_core = { Config::verge().latest().clash_core.clone() };
let clash_core = clash_core.unwrap_or("verge-mihomo".into());
let test_dir = dirs::app_home_dir()?.join("test");
let test_dir = dirs::path_to_str(&test_dir)?;
let app_handle = handle::Handle::global().app_handle().unwrap();
let _ = app_handle
.shell()
.sidecar(clash_core)?
.args(["-t", "-d", test_dir, "-f", config_path])
.output()
.await?;
Ok(())
}
/// 停止核心运行 /// 停止核心运行
pub async fn stop_core(&self) -> Result<()> { pub async fn stop_core(&self) -> Result<()> {
let mut running = self.running.lock().await; let mut running = self.running.lock().await;
@@ -112,6 +91,23 @@ impl CoreManager {
Ok(()) Ok(())
} }
/// 使用默认配置
pub async fn use_default_config(&self, msg_type: &str, msg_content: &str) -> Result<()> {
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
*Config::runtime().draft() = IRuntime {
config: Some(Config::clash().latest().0.clone()),
exists_keys: vec![],
chain_logs: Default::default(),
};
help::save_yaml(
&runtime_path,
&Config::clash().latest().0,
Some("# Clash Verge Runtime"),
)?;
handle::Handle::notice_message(msg_type, msg_content);
Ok(())
}
/// 切换核心 /// 切换核心
pub async fn change_core(&self, clash_core: Option<String>) -> Result<()> { pub async fn change_core(&self, clash_core: Option<String>) -> Result<()> {
let clash_core = clash_core.ok_or(anyhow::anyhow!("clash core is null"))?; let clash_core = clash_core.ok_or(anyhow::anyhow!("clash core is null"))?;
@@ -122,57 +118,363 @@ impl CoreManager {
} }
log::info!(target: "app", "change core to `{clash_core}`"); log::info!(target: "app", "change core to `{clash_core}`");
// 1. 先更新内核配置(但不应用)
Config::verge().draft().clash_core = Some(clash_core); Config::verge().draft().clash_core = Some(clash_core);
// 更新订阅 // 2. 使用新内核验证配置
Config::generate().await?; println!("[切换内核] 使用新内核验证配置");
match self.validate_config().await {
self.check_config().await?; Ok((true, _)) => {
println!("[切换内核] 配置验证通过,开始切换内核");
match self.restart_core().await { // 3. 验证通过后,应用内核配置并重启
Ok(_) => {
Config::verge().apply(); Config::verge().apply();
Config::runtime().apply();
log_err!(Config::verge().latest().save_file()); log_err!(Config::verge().latest().save_file());
Ok(())
match self.restart_core().await {
Ok(_) => {
println!("[切换内核] 内核切换成功");
Config::runtime().apply();
Ok(())
}
Err(err) => {
println!("[切换内核] 内核切换失败: {}", err);
Config::verge().discard();
Config::runtime().discard();
Err(err)
}
}
}
Ok((false, error_msg)) => {
println!("[切换内核] 配置验证失败: {}", error_msg);
// 使用默认配置并继续切换内核
self.use_default_config("config_validate::core_change", &error_msg).await?;
Config::verge().apply();
log_err!(Config::verge().latest().save_file());
match self.restart_core().await {
Ok(_) => {
println!("[切换内核] 内核切换成功(使用默认配置)");
Ok(())
}
Err(err) => {
println!("[切换内核] 内核切换失败: {}", err);
Config::verge().discard();
Err(err)
}
}
} }
Err(err) => { Err(err) => {
println!("[切换内核] 验证过程发生错误: {}", err);
Config::verge().discard(); Config::verge().discard();
Config::runtime().discard();
Err(err) Err(err)
} }
} }
} }
/// 更新proxies那些 /// 内部验证配置文件的实现
/// 如果涉及端口和外部控制则需要重启 async fn validate_config_internal(&self, config_path: &str) -> Result<(bool, String)> {
pub async fn update_config(&self) -> Result<()> { println!("[core配置验证] 开始验证配置文件: {}", config_path);
log::debug!(target: "app", "try to update clash config");
// 更新订阅 let clash_core = { Config::verge().latest().clash_core.clone() };
Config::generate().await?; let clash_core = clash_core.unwrap_or("verge-mihomo".into());
println!("[core配置验证] 使用内核: {}", clash_core);
let app_handle = handle::Handle::global().app_handle().unwrap();
let test_dir = dirs::app_home_dir()?.join("test");
let test_dir = dirs::path_to_str(&test_dir)?;
println!("[core配置验证] 测试目录: {}", test_dir);
// 检查订阅是否正常 // 使用子进程运行clash验证配置
self.check_config().await?; println!("[core配置验证] 运行子进程验证配置");
let output = app_handle
.shell()
.sidecar(clash_core)?
.args(["-t", "-d", test_dir, "-f", config_path])
.output()
.await?;
// 更新运行时订阅 let stderr = String::from_utf8_lossy(&output.stderr);
let path = Config::generate_file(ConfigType::Run)?; let stdout = String::from_utf8_lossy(&output.stdout);
let path = dirs::path_to_str(&path)?;
// 检查进程退出状态和错误输出
let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"];
let has_error = !output.status.success() || error_keywords.iter().any(|&kw| stderr.contains(kw));
println!("\n[core配置验证] -------- 验证结果 --------");
println!("[core配置验证] 进程退出状态: {:?}", output.status);
if !stderr.is_empty() {
println!("[core配置验证] stderr输出:\n{}", stderr);
}
if !stdout.is_empty() {
println!("[core配置验证] stdout输出:\n{}", stdout);
}
// 发送请求 发送5次 if has_error {
for i in 0..10 { println!("[core配置验证] 发现错误,开始处理错误信息");
match clash_api::put_configs(path).await { let error_msg = if !stdout.is_empty() {
Ok(_) => break, stdout.to_string()
} else if !stderr.is_empty() {
stderr.to_string()
} else if let Some(code) = output.status.code() {
format!("验证进程异常退出,退出码: {}", code)
} else {
"验证进程被终止".to_string()
};
println!("[core配置验证] -------- 验证结束 --------\n");
Ok((false, error_msg)) // 返回错误消息给调用者处理
} else {
println!("[core配置验证] 验证成功");
println!("[core配置验证] -------- 验证结束 --------\n");
Ok((true, String::new()))
}
}
/// 验证运行时配置
pub async fn validate_config(&self) -> Result<(bool, String)> {
let config_path = Config::generate_file(ConfigType::Check)?;
let config_path = dirs::path_to_str(&config_path)?;
self.validate_config_internal(config_path).await
}
/// 验证指定的配置文件
pub async fn validate_config_file(&self, config_path: &str, is_merge_file: Option<bool>) -> Result<(bool, String)> {
// 检查文件是否存在
if !std::path::Path::new(config_path).exists() {
let error_msg = format!("File not found: {}", config_path);
//handle::Handle::notice_message("config_validate::file_not_found", &error_msg);
return Ok((false, error_msg));
}
// 如果是合并文件且不是强制验证,执行语法检查但不进行完整验证
if is_merge_file.unwrap_or(false) {
println!("[core配置验证] 检测到Merge文件,仅进行语法检查: {}", config_path);
return self.validate_file_syntax(config_path).await;
}
// 检查是否为脚本文件
let is_script = if config_path.ends_with(".js") {
true
} else {
match self.is_script_file(config_path) {
Ok(result) => result,
Err(err) => { Err(err) => {
if i < 9 { // 如果无法确定文件类型,尝试使用Clash内核验证
log::info!(target: "app", "{err}"); log::warn!(target: "app", "无法确定文件类型: {}, 错误: {}", config_path, err);
} else { return self.validate_config_internal(config_path).await;
bail!(err);
}
} }
} }
sleep(Duration::from_millis(100)).await; };
if is_script {
log::info!(target: "app", "检测到脚本文件,使用JavaScript验证: {}", config_path);
return self.validate_script_file(config_path).await;
} }
Ok(())
// 对YAML配置文件使用Clash内核验证
log::info!(target: "app", "使用Clash内核验证配置文件: {}", config_path);
self.validate_config_internal(config_path).await
} }
}
/// 检查文件是否为脚本文件
fn is_script_file(&self, path: &str) -> Result<bool> {
// 1. 先通过扩展名快速判断
if path.ends_with(".yaml") || path.ends_with(".yml") {
return Ok(false); // YAML文件不是脚本文件
} else if path.ends_with(".js") {
return Ok(true); // JS文件是脚本文件
}
// 2. 读取文件内容
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => {
log::warn!(target: "app", "无法读取文件以检测类型: {}, 错误: {}", path, err);
return Err(anyhow::anyhow!("Failed to read file to detect type: {}", err));
}
};
// 3. 检查是否存在明显的YAML特征
let has_yaml_features = content.contains(": ") ||
content.contains("#") ||
content.contains("---") ||
content.lines().any(|line| line.trim().starts_with("- "));
// 4. 检查是否存在明显的JS特征
let has_js_features = content.contains("function ") ||
content.contains("const ") ||
content.contains("let ") ||
content.contains("var ") ||
content.contains("//") ||
content.contains("/*") ||
content.contains("*/") ||
content.contains("export ") ||
content.contains("import ");
// 5. 决策逻辑
if has_yaml_features && !has_js_features {
// 只有YAML特征,没有JS特征
return Ok(false);
} else if has_js_features && !has_yaml_features {
// 只有JS特征,没有YAML特征
return Ok(true);
} else if has_yaml_features && has_js_features {
// 两种特征都有,需要更精细判断
// 优先检查是否有明确的JS结构特征
if content.contains("function main") ||
content.contains("module.exports") ||
content.contains("export default") {
return Ok(true);
}
// 检查冒号后是否有空格(YAML的典型特征)
let yaml_pattern_count = content.lines()
.filter(|line| line.contains(": "))
.count();
if yaml_pattern_count > 2 {
return Ok(false); // 多个键值对格式,更可能是YAML
}
}
// 默认情况:无法确定时,假设为非脚本文件(更安全)
log::debug!(target: "app", "无法确定文件类型,默认当作YAML处理: {}", path);
Ok(false)
}
/// 验证脚本文件语法
async fn validate_script_file(&self, path: &str) -> Result<(bool, String)> {
// 读取脚本内容
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => {
let error_msg = format!("Failed to read script file: {}", err);
log::warn!(target: "app", "脚本语法错误: {}", err);
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
return Ok((false, error_msg));
}
};
log::debug!(target: "app", "验证脚本文件: {}", path);
// 使用boa引擎进行基本语法检查
use boa_engine::{Context, Source};
let mut context = Context::default();
let result = context.eval(Source::from_bytes(&content));
match result {
Ok(_) => {
log::debug!(target: "app", "脚本语法验证通过: {}", path);
// 检查脚本是否包含main函数
if !content.contains("function main") && !content.contains("const main") && !content.contains("let main") {
let error_msg = "Script must contain a main function";
log::warn!(target: "app", "脚本缺少main函数: {}", path);
//handle::Handle::notice_message("config_validate::script_missing_main", error_msg);
return Ok((false, error_msg.to_string()));
}
Ok((true, String::new()))
},
Err(err) => {
let error_msg = format!("Script syntax error: {}", err);
log::warn!(target: "app", "脚本语法错误: {}", err);
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
Ok((false, error_msg))
}
}
}
/// 更新proxies等配置
pub async fn update_config(&self) -> Result<(bool, String)> {
println!("[core配置更新] 开始更新配置");
// 1. 先生成新的配置内容
println!("[core配置更新] 生成新的配置内容");
Config::generate().await?;
// 2. 生成临时文件并进行验证
println!("[core配置更新] 生成临时配置文件用于验证");
let temp_config = Config::generate_file(ConfigType::Check)?;
let temp_config = dirs::path_to_str(&temp_config)?;
println!("[core配置更新] 临时配置文件路径: {}", temp_config);
// 3. 验证配置
match self.validate_config().await {
Ok((true, _)) => {
println!("[core配置更新] 配置验证通过");
// 4. 验证通过后,生成正式的运行时配置
println!("[core配置更新] 生成运行时配置");
let run_path = Config::generate_file(ConfigType::Run)?;
let run_path = dirs::path_to_str(&run_path)?;
// 5. 应用新配置
println!("[core配置更新] 应用新配置");
for i in 0..3 {
match clash_api::put_configs(run_path).await {
Ok(_) => {
println!("[core配置更新] 配置应用成功");
Config::runtime().apply();
return Ok((true, String::new()));
}
Err(err) => {
if i < 2 {
println!("[core配置更新] 第{}次重试应用配置", i + 1);
log::info!(target: "app", "{err}");
sleep(Duration::from_millis(100)).await;
} else {
println!("[core配置更新] 配置应用失败: {}", err);
Config::runtime().discard();
return Ok((false, err.to_string()));
}
}
}
}
Ok((true, String::new()))
}
Ok((false, error_msg)) => {
println!("[core配置更新] 配置验证失败: {}", error_msg);
Config::runtime().discard();
Ok((false, error_msg))
}
Err(e) => {
println!("[core配置更新] 验证过程发生错误: {}", e);
Config::runtime().discard();
Err(e)
}
}
}
/// 只进行文件语法检查,不进行完整验证
async fn validate_file_syntax(&self, config_path: &str) -> Result<(bool, String)> {
println!("[core配置语法检查] 开始检查文件: {}", config_path);
// 读取文件内容
let content = match std::fs::read_to_string(config_path) {
Ok(content) => content,
Err(err) => {
let error_msg = format!("Failed to read file: {}", err);
println!("[core配置语法检查] 无法读取文件: {}", error_msg);
return Ok((false, error_msg));
}
};
// 对YAML文件尝试解析,只检查语法正确性
println!("[core配置语法检查] 进行YAML语法检查");
match serde_yaml::from_str::<serde_yaml::Value>(&content) {
Ok(_) => {
println!("[core配置语法检查] YAML语法检查通过");
Ok((true, String::new()))
},
Err(err) => {
// 使用标准化的前缀,以便错误处理函数能正确识别
let error_msg = format!("YAML syntax error: {}", err);
println!("[core配置语法检查] YAML语法错误: {}", error_msg);
Ok((false, error_msg))
}
}
}
}
+121 -10
View File
@@ -1,11 +1,14 @@
use crate::core::handle; use crate::core::handle;
use crate::{config::Config, feat, log_err}; use crate::{config::Config, feat, log_err};
use crate::utils::resolve;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use tauri::Manager; use tauri::Manager;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState}; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState};
use tauri::async_runtime;
pub struct Hotkey { pub struct Hotkey {
current: Arc<Mutex<Vec<String>>>, // 保存当前的热键设置 current: Arc<Mutex<Vec<String>>>, // 保存当前的热键设置
} }
@@ -21,8 +24,22 @@ impl Hotkey {
pub fn init(&self) -> Result<()> { pub fn init(&self) -> Result<()> {
let verge = Config::verge(); let verge = Config::verge();
let enable_global_hotkey = verge.latest().enable_global_hotkey.unwrap_or(true);
println!("Initializing hotkeys, global hotkey enabled: {}", enable_global_hotkey);
log::info!(target: "app", "Initializing hotkeys, global hotkey enabled: {}", enable_global_hotkey);
// 如果全局热键被禁用,则不注册热键
if !enable_global_hotkey {
println!("Global hotkey is disabled, skipping registration");
log::info!(target: "app", "Global hotkey is disabled, skipping registration");
return Ok(());
}
if let Some(hotkeys) = verge.latest().hotkeys.as_ref() { if let Some(hotkeys) = verge.latest().hotkeys.as_ref() {
println!("Found {} hotkeys to register", hotkeys.len());
log::info!(target: "app", "Found {} hotkeys to register", hotkeys.len());
for hotkey in hotkeys.iter() { for hotkey in hotkeys.iter() {
let mut iter = hotkey.split(','); let mut iter = hotkey.split(',');
let func = iter.next(); let func = iter.next();
@@ -30,56 +47,151 @@ impl Hotkey {
match (key, func) { match (key, func) {
(Some(key), Some(func)) => { (Some(key), Some(func)) => {
log_err!(self.register(key, func)); println!("Registering hotkey: {} -> {}", key, func);
log::info!(target: "app", "Registering hotkey: {} -> {}", key, func);
if let Err(e) = self.register(key, func) {
println!("Failed to register hotkey {} -> {}: {:?}", key, func, e);
log::error!(target: "app", "Failed to register hotkey {} -> {}: {:?}", key, func, e);
} else {
println!("Successfully registered hotkey {} -> {}", key, func);
log::info!(target: "app", "Successfully registered hotkey {} -> {}", key, func);
}
} }
_ => { _ => {
let key = key.unwrap_or("None"); let key = key.unwrap_or("None");
let func = func.unwrap_or("None"); let func = func.unwrap_or("None");
log::error!(target: "app", "invalid hotkey `{key}`:`{func}`"); println!("Invalid hotkey configuration: `{key}`:`{func}`");
log::error!(target: "app", "Invalid hotkey configuration: `{key}`:`{func}`");
} }
} }
} }
self.current.lock().clone_from(hotkeys); self.current.lock().clone_from(hotkeys);
} else {
println!("No hotkeys configured");
log::info!(target: "app", "No hotkeys configured");
} }
Ok(()) Ok(())
} }
pub fn reset(&self) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let manager = app_handle.global_shortcut();
manager.unregister_all()?;
Ok(())
}
pub fn register(&self, hotkey: &str, func: &str) -> Result<()> { pub fn register(&self, hotkey: &str, func: &str) -> Result<()> {
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
let manager = app_handle.global_shortcut(); let manager = app_handle.global_shortcut();
println!("Attempting to register hotkey: {} for function: {}", hotkey, func);
log::info!(target: "app", "Attempting to register hotkey: {} for function: {}", hotkey, func);
if manager.is_registered(hotkey) { if manager.is_registered(hotkey) {
println!("Hotkey {} was already registered, unregistering first", hotkey);
log::info!(target: "app", "Hotkey {} was already registered, unregistering first", hotkey);
manager.unregister(hotkey)?; manager.unregister(hotkey)?;
} }
let f = match func.trim() { let f = match func.trim() {
"open_or_close_dashboard" => feat::open_or_close_dashboard, "open_or_close_dashboard" => {
println!("Registering open_or_close_dashboard function");
log::info!(target: "app", "Registering open_or_close_dashboard function");
|| {
println!("=== Hotkey Dashboard Window Operation Start ===");
log::info!(target: "app", "=== Hotkey Dashboard Window Operation Start ===");
// 使用 spawn_blocking 来确保在正确的线程上执行
async_runtime::spawn_blocking(|| {
println!("Toggle dashboard window visibility");
log::info!(target: "app", "Toggle dashboard window visibility");
// 检查窗口是否存在
if let Some(window) = handle::Handle::global().get_window() {
// 如果窗口可见,则隐藏它
if window.is_visible().unwrap_or(false) {
println!("Window is visible, hiding it");
log::info!(target: "app", "Window is visible, hiding it");
let _ = window.hide();
} else {
// 如果窗口不可见,则显示它
println!("Window is hidden, showing it");
log::info!(target: "app", "Window is hidden, showing it");
if window.is_minimized().unwrap_or(false) {
let _ = window.unminimize();
}
let _ = window.show();
let _ = window.set_focus();
}
} else {
// 如果窗口不存在,创建一个新窗口
println!("Window does not exist, creating a new one");
log::info!(target: "app", "Window does not exist, creating a new one");
resolve::create_window();
}
});
println!("=== Hotkey Dashboard Window Operation End ===");
log::info!(target: "app", "=== Hotkey Dashboard Window Operation End ===");
}
},
"clash_mode_rule" => || feat::change_clash_mode("rule".into()), "clash_mode_rule" => || feat::change_clash_mode("rule".into()),
"clash_mode_global" => || feat::change_clash_mode("global".into()), "clash_mode_global" => || feat::change_clash_mode("global".into()),
"clash_mode_direct" => || feat::change_clash_mode("direct".into()), "clash_mode_direct" => || feat::change_clash_mode("direct".into()),
"toggle_system_proxy" => feat::toggle_system_proxy, "toggle_system_proxy" => || feat::toggle_system_proxy(),
"toggle_tun_mode" => feat::toggle_tun_mode, "toggle_tun_mode" => || feat::toggle_tun_mode(None),
"quit" => || feat::quit(Some(0)), "quit" => || feat::quit(Some(0)),
_ => bail!("invalid function \"{func}\""), _ => {
println!("Invalid function: {}", func);
log::error!(target: "app", "Invalid function: {}", func);
bail!("invalid function \"{func}\"");
}
}; };
let is_quit = func.trim() == "quit";
let _ = manager.on_shortcut(hotkey, move |app_handle, hotkey, event| { let _ = manager.on_shortcut(hotkey, move |app_handle, hotkey, event| {
if event.state == ShortcutState::Pressed { if event.state == ShortcutState::Pressed {
if hotkey.key == Code::KeyQ { println!("Hotkey pressed: {:?}", hotkey);
log::info!(target: "app", "Hotkey pressed: {:?}", hotkey);
if hotkey.key == Code::KeyQ && is_quit {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = app_handle.get_webview_window("main") {
if window.is_focused().unwrap_or(false) { if window.is_focused().unwrap_or(false) {
println!("Executing quit function");
log::info!(target: "app", "Executing quit function");
f(); f();
} }
} }
} else { } else {
f(); // 直接执行函数,不做任何状态检查
println!("Executing function directly");
log::info!(target: "app", "Executing function directly");
// 获取轻量模式状态和全局热键状态
let is_lite_mode = Config::verge().latest().enable_lite_mode.unwrap_or(false);
let is_enable_global_hotkey = Config::verge().latest().enable_global_hotkey.unwrap_or(true);
// 在轻量模式下或配置了全局热键时,始终执行热键功能
if is_lite_mode || is_enable_global_hotkey {
f();
} else if let Some(window) = app_handle.get_webview_window("main") {
// 非轻量模式且未启用全局热键时,只在窗口可见且有焦点的情况下响应热键
let is_visible = window.is_visible().unwrap_or(false);
let is_focused = window.is_focused().unwrap_or(false);
if is_focused && is_visible {
f();
}
}
} }
} }
}); });
log::debug!(target: "app", "register hotkey {hotkey} {func}"); println!("Successfully registered hotkey {} for {}", hotkey, func);
log::info!(target: "app", "Successfully registered hotkey {} for {}", hotkey, func);
Ok(()) Ok(())
} }
@@ -87,7 +199,6 @@ impl Hotkey {
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
let manager = app_handle.global_shortcut(); let manager = app_handle.global_shortcut();
manager.unregister(hotkey)?; manager.unregister(hotkey)?;
log::debug!(target: "app", "unregister hotkey {hotkey}"); log::debug!(target: "app", "unregister hotkey {hotkey}");
Ok(()) Ok(())
} }
+49
View File
@@ -10,6 +10,7 @@ use tokio::time::Duration;
// Windows only // Windows only
const SERVICE_URL: &str = "http://127.0.0.1:33211"; const SERVICE_URL: &str = "http://127.0.0.1:33211";
const REQUIRED_SERVICE_VERSION: &str = "1.0.1"; // 定义所需的服务版本号
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ResponseBody { pub struct ResponseBody {
@@ -19,6 +20,12 @@ pub struct ResponseBody {
pub log_file: String, pub log_file: String,
} }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct VersionResponse {
pub service: String,
pub version: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JsonResponse { pub struct JsonResponse {
pub code: u64, pub code: u64,
@@ -26,6 +33,13 @@ pub struct JsonResponse {
pub data: Option<ResponseBody>, pub data: Option<ResponseBody>,
} }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct VersionJsonResponse {
pub code: u64,
pub msg: String,
pub data: Option<VersionResponse>,
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub async fn reinstall_service() -> Result<()> { pub async fn reinstall_service() -> Result<()> {
log::info!(target:"app", "reinstall service"); log::info!(target:"app", "reinstall service");
@@ -177,8 +191,43 @@ pub async fn check_service() -> Result<JsonResponse> {
Ok(response) Ok(response)
} }
/// check the service version
pub async fn check_service_version() -> Result<String> {
let url = format!("{SERVICE_URL}/version");
let response = reqwest::ClientBuilder::new()
.no_proxy()
.timeout(Duration::from_secs(3))
.build()?
.get(url)
.send()
.await
.context("failed to connect to the Clash Verge Service")?
.json::<VersionJsonResponse>()
.await
.context("failed to parse the Clash Verge Service version response")?;
match response.data {
Some(data) => Ok(data.version),
None => bail!("service version not found in response"),
}
}
/// check if service needs to be reinstalled
pub async fn check_service_needs_reinstall() -> bool {
match check_service_version().await {
Ok(version) => version != REQUIRED_SERVICE_VERSION,
Err(_) => true, // 如果无法获取版本或服务未运行,也需要重新安装
}
}
/// start the clash by service /// start the clash by service
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> { pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
// 检查服务版本,如果不匹配则重新安装
if check_service_needs_reinstall().await {
log::info!(target: "app", "service version mismatch, reinstalling");
reinstall_service().await?;
}
let clash_core = { Config::verge().latest().clash_core.clone() }; let clash_core = { Config::verge().latest().clash_core.clone() };
let clash_core = clash_core.unwrap_or("verge-mihomo".into()); let clash_core = clash_core.unwrap_or("verge-mihomo".into());
+12 -1
View File
@@ -1,5 +1,6 @@
use crate::config::Config; use crate::config::Config;
use crate::feat; use crate::feat;
use crate::core::CoreManager;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder}; use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
@@ -172,7 +173,17 @@ impl Timer {
/// the task runner /// the task runner
async fn async_task(uid: String) { async fn async_task(uid: String) {
log::info!(target: "app", "running timer task `{uid}`"); log::info!(target: "app", "running timer task `{uid}`");
crate::log_err!(feat::update_profile(uid, None).await);
// 使用更轻量级的更新方式
if let Err(e) = feat::update_profile(uid.clone(), None).await {
log::error!(target: "app", "timer task update error: {}", e);
return;
}
// 只有更新成功后才刷新配置
if let Err(e) = CoreManager::global().update_config().await {
log::error!(target: "app", "timer task refresh error: {}", e);
}
} }
} }
+57 -11
View File
@@ -21,7 +21,7 @@ use parking_lot::RwLock;
pub use speed_rate::{SpeedRate, Traffic}; pub use speed_rate::{SpeedRate, Traffic};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use std::sync::Arc; use std::sync::Arc;
use tauri::menu::CheckMenuItem; use tauri::menu::{CheckMenuItem, IsMenuItem};
use tauri::AppHandle; use tauri::AppHandle;
use tauri::{ use tauri::{
menu::{MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, menu::{MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
@@ -71,20 +71,23 @@ impl Tray {
let tray_incon_id = TrayIconId::new("main"); let tray_incon_id = TrayIconId::new("main");
let tray = app_handle.tray_by_id(&tray_incon_id).unwrap(); let tray = app_handle.tray_by_id(&tray_incon_id).unwrap();
#[cfg(target_os = "macos")]
tray.set_show_menu_on_left_click(false)?;
tray.on_tray_icon_event(|_, event| { tray.on_tray_icon_event(|_, event| {
let tray_event = { Config::verge().latest().tray_event.clone() }; let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into()); let tray_event: String = tray_event.unwrap_or("main_window".into());
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let TrayIconEvent::Click { if let TrayIconEvent::Click {
button: MouseButton::Right, button: MouseButton::Left,
button_state: MouseButtonState::Down, button_state: MouseButtonState::Down,
.. ..
} = event } = event
{ {
match tray_event.as_str() { match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(), "system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(), "tun_mode" => feat::toggle_tun_mode(None),
"main_window" => resolve::create_window(), "main_window" => resolve::create_window(),
_ => {} _ => {}
} }
@@ -99,7 +102,7 @@ impl Tray {
{ {
match tray_event.as_str() { match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(), "system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(), "tun_mode" => feat::toggle_tun_mode(None),
"main_window" => resolve::create_window(), "main_window" => resolve::create_window(),
_ => {} _ => {}
} }
@@ -124,6 +127,10 @@ impl Tray {
.unwrap_or("rule") .unwrap_or("rule")
.to_owned() .to_owned()
}; };
let profile_uid_and_name = Config::profiles()
.data()
.all_profile_uid_and_name()
.unwrap_or(Vec::new());
let tray = app_handle.tray_by_id("main").unwrap(); let tray = app_handle.tray_by_id("main").unwrap();
let _ = tray.set_menu(Some(create_tray_menu( let _ = tray.set_menu(Some(create_tray_menu(
@@ -131,6 +138,7 @@ impl Tray {
Some(mode.as_str()), Some(mode.as_str()),
*system_proxy, *system_proxy,
*tun_mode, *tun_mode,
profile_uid_and_name,
)?)); )?));
Ok(()) Ok(())
} }
@@ -217,23 +225,27 @@ impl Tray {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let enable_tray_speed = Config::verge().latest().enable_tray_speed.unwrap_or(true); let enable_tray_speed = Config::verge().latest().enable_tray_speed.unwrap_or(true);
let is_template = let is_colorful = tray_icon == "colorful";
crate::utils::help::is_monochrome_image_from_bytes(&icon_bytes).unwrap_or(false);
// 处理图标和速率
let icon_bytes = if enable_tray_speed { let final_icon_bytes = if enable_tray_speed {
let rate = rate.or_else(|| { let rate = rate.or_else(|| {
self.speed_rate self.speed_rate
.lock() .lock()
.as_ref() .as_ref()
.and_then(|speed_rate| speed_rate.get_curent_rate()) .and_then(|speed_rate| speed_rate.get_curent_rate())
}); });
// 使用新的方法渲染图标和速率
SpeedRate::add_speed_text(icon_bytes, rate)? SpeedRate::add_speed_text(icon_bytes, rate)?
} else { } else {
icon_bytes icon_bytes
}; };
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?)); // 设置系统托盘图标
let _ = tray.set_icon_as_template(is_template); let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&final_icon_bytes)?));
// 只对单色图标使用 template 模式
let _ = tray.set_icon_as_template(!is_colorful);
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@@ -363,6 +375,7 @@ fn create_tray_menu(
mode: Option<&str>, mode: Option<&str>,
system_proxy_enabled: bool, system_proxy_enabled: bool,
tun_mode_enabled: bool, tun_mode_enabled: bool,
profile_uid_and_name: Vec<(String, String)>,
) -> Result<tauri::menu::Menu<Wry>> { ) -> Result<tauri::menu::Menu<Wry>> {
let mode = mode.unwrap_or(""); let mode = mode.unwrap_or("");
let version = VERSION.get().unwrap(); let version = VERSION.get().unwrap();
@@ -382,6 +395,25 @@ fn create_tray_menu(
.collect::<std::collections::HashMap<String, String>>() .collect::<std::collections::HashMap<String, String>>()
}) })
.unwrap_or_default(); .unwrap_or_default();
let profile_menu_items: Vec<CheckMenuItem<Wry>> = profile_uid_and_name
.iter()
.map(|(profile_uid, profile_name)| {
let is_current_profile = Config::profiles().data().is_current_profile_index(profile_uid.to_string());
CheckMenuItem::with_id(
app_handle,
&format!("profiles_{}", profile_uid),
t(&profile_name),
true,
is_current_profile,
None::<&str>,
)
.unwrap()
}).collect();
let profile_menu_items: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
.iter()
.map(|item| item as &dyn IsMenuItem<Wry>)
.collect();
let open_window = &MenuItem::with_id( let open_window = &MenuItem::with_id(
app_handle, app_handle,
@@ -422,6 +454,14 @@ fn create_tray_menu(
) )
.unwrap(); .unwrap();
let profiles = &Submenu::with_id_and_items(
app_handle,
"profiles",
t("Profiles"),
true,
&profile_menu_items,
).unwrap();
let system_proxy = &CheckMenuItem::with_id( let system_proxy = &CheckMenuItem::with_id(
app_handle, app_handle,
"system_proxy", "system_proxy",
@@ -530,6 +570,8 @@ fn create_tray_menu(
global_mode, global_mode,
direct_mode, direct_mode,
separator, separator,
profiles,
separator,
system_proxy, system_proxy,
tun_mode, tun_mode,
copy_env, copy_env,
@@ -552,7 +594,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
} }
"open_window" => resolve::create_window(), "open_window" => resolve::create_window(),
"system_proxy" => feat::toggle_system_proxy(), "system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(), "tun_mode" => feat::toggle_tun_mode(None),
"copy_env" => feat::copy_clash_env(), "copy_env" => feat::copy_clash_env(),
"open_app_dir" => crate::log_err!(cmds::open_app_dir()), "open_app_dir" => crate::log_err!(cmds::open_app_dir()),
"open_core_dir" => crate::log_err!(cmds::open_core_dir()), "open_core_dir" => crate::log_err!(cmds::open_core_dir()),
@@ -562,6 +604,10 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
"quit" => { "quit" => {
println!("quit"); println!("quit");
feat::quit(Some(0)); feat::quit(Some(0));
},
id if id.starts_with("profiles_") => {
let profile_index = &id["profiles_".len()..];
feat::toggle_proxy_profile(profile_index.into());
} }
_ => {} _ => {}
} }
+103 -58
View File
@@ -2,7 +2,7 @@ use crate::core::clash_api::{get_traffic_ws_url, Rate};
use crate::utils::help::format_bytes_speed; use crate::utils::help::format_bytes_speed;
use anyhow::Result; use anyhow::Result;
use futures::Stream; use futures::Stream;
use image::{ImageBuffer, Rgba}; use image::{Rgba, GenericImageView, RgbaImage};
use imageproc::drawing::draw_text_mut; use imageproc::drawing::draw_text_mut;
use parking_lot::Mutex; use parking_lot::Mutex;
use rusttype::{Font, Scale}; use rusttype::{Font, Scale};
@@ -13,22 +13,45 @@ use tokio_tungstenite::tungstenite::Message;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SpeedRate { pub struct SpeedRate {
rate: Arc<Mutex<(Rate, Rate)>>, rate: Arc<Mutex<(Rate, Rate)>>,
last_update: Arc<Mutex<std::time::Instant>>,
// 移除 base_image,不再缓存原始图像
} }
impl SpeedRate { impl SpeedRate {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
rate: Arc::new(Mutex::new((Rate::default(), Rate::default()))), rate: Arc::new(Mutex::new((Rate::default(), Rate::default()))),
last_update: Arc::new(Mutex::new(std::time::Instant::now())),
} }
} }
pub fn update_and_check_changed(&self, up: u64, down: u64) -> Option<Rate> { pub fn update_and_check_changed(&self, up: u64, down: u64) -> Option<Rate> {
let mut rates = self.rate.lock(); let mut rates = self.rate.lock();
let mut last_update = self.last_update.lock();
let now = std::time::Instant::now();
// 限制更新频率为每秒最多2次(500ms)
if now.duration_since(*last_update).as_millis() < 500 {
return None;
}
let (current, previous) = &mut *rates; let (current, previous) = &mut *rates;
// 如果速率变化不大(小于10%),则不更新
let should_update = {
let up_change = (current.up as f64 - up as f64).abs() / (current.up as f64 + 1.0);
let down_change = (current.down as f64 - down as f64).abs() / (current.down as f64 + 1.0);
up_change > 0.1 || down_change > 0.1
};
if !should_update {
return None;
}
*previous = current.clone(); *previous = current.clone();
current.up = up; current.up = up;
current.down = down; current.down = down;
*last_update = now;
if previous != current { if previous != current {
Some(current.clone()) Some(current.clone())
@@ -43,92 +66,114 @@ impl SpeedRate {
Some(current.clone()) Some(current.clone())
} }
pub fn add_speed_text(icon: Vec<u8>, rate: Option<Rate>) -> Result<Vec<u8>> { // 分离图标加载和速率渲染
pub fn add_speed_text(icon_bytes: Vec<u8>, rate: Option<Rate>) -> Result<Vec<u8>> {
let rate = rate.unwrap_or(Rate { up: 0, down: 0 }); let rate = rate.unwrap_or(Rate { up: 0, down: 0 });
let img = image::load_from_memory(&icon)?;
let (width, height) = (img.width(), img.height()); // 加载原始图标
let icon_image = image::load_from_memory(&icon_bytes)?;
let mut image = ImageBuffer::new((width as f32 * 4.0) as u32, height); let (icon_width, icon_height) = (icon_image.width(), icon_image.height());
image::imageops::replace(&mut image, &img, 0, 0);
// 判断是否为彩色图标
let font = let is_colorful = !crate::utils::help::is_monochrome_image_from_bytes(&icon_bytes).unwrap_or(false);
Font::try_from_bytes(include_bytes!("../../../assets/fonts/SFCompact.ttf")).unwrap();
// 增加文本宽度和间距
// 修改颜色和阴影参数 let text_width = 580; // 文本区域宽度
let text_color = Rgba([255u8, 255u8, 255u8, 255u8]); // 纯白色 let total_width = icon_width + text_width;
let shadow_color = Rgba([0u8, 0u8, 0u8, 180u8]); // 半透明黑色阴影
let base_size = height as f32 * 0.5; // 创建新的透明画布
let scale = Scale::uniform(base_size); let mut combined_image = RgbaImage::new(total_width, icon_height);
// 将原始图标绘制到新画布的左侧
for y in 0..icon_height {
for x in 0..icon_width {
let pixel = icon_image.get_pixel(x, y);
combined_image.put_pixel(x, y, pixel);
}
}
// 选择文本颜色
let (text_color, shadow_color) = if is_colorful {
// 彩色图标使用黑色文本和轻微白色阴影
(Rgba([255u8, 255u8, 255u8, 255u8]), Rgba([0u8, 0u8, 0u8, 160u8]))
} else {
// 单色图标使用白色文本和轻微黑色阴影
(Rgba([255u8, 255u8, 255u8, 255u8]), Rgba([0u8, 0u8, 0u8, 120u8]))
};
// 减小字体大小以适应文本区域
let font = Font::try_from_bytes(include_bytes!("../../../assets/fonts/SF-Pro.ttf")).unwrap();
let font_size = icon_height as f32 * 0.6; // 稍微减小字体
let scale = Scale::uniform(font_size);
// 使用更简洁的速率格式
let up_text = format_bytes_speed(rate.up); let up_text = format_bytes_speed(rate.up);
let down_text = format_bytes_speed(rate.down); let down_text = format_bytes_speed(rate.down);
// 计算文本位置(保持不变) // 计算文本位置,确保垂直间距合适
let up_width = font // 修改文本位置为居右显示
.layout(&up_text, scale, rusttype::Point { x: 0.0, y: 0.0 }) let up_text_width = imageproc::drawing::text_size(scale, &font, &up_text).0 as u32;
.map(|g| g.position().x + g.unpositioned().h_metrics().advance_width) let down_text_width = imageproc::drawing::text_size(scale, &font, &down_text).0 as u32;
.last()
.unwrap_or(0.0); // 计算右对齐的文本位置
let up_text_x = total_width - up_text_width;
let down_width = font let down_text_x = total_width - down_text_width;
.layout(&down_text, scale, rusttype::Point { x: 0.0, y: 0.0 })
.map(|g| g.position().x + g.unpositioned().h_metrics().advance_width) // 优化垂直位置,使速率显示的高度和上下间距正好等于图标大小
.last() let text_height = font_size as i32;
.unwrap_or(0.0); let total_text_height = text_height * 2;
let up_y = (icon_height as i32 - total_text_height) / 2;
let right_margin = 8; let down_y = up_y + text_height;
let canvas_width = width * 4;
let up_x = canvas_width as f32 - up_width - right_margin as f32; // 绘制速率文本(先阴影后文字)
let down_x = canvas_width as f32 - down_width - right_margin as f32; let shadow_offset = 1;
// 添加阴影效果 // 绘制上行速率
let shadow_offset = 1; // 阴影偏移量
// 绘制上行速率(先画阴影,再画文字)
draw_text_mut( draw_text_mut(
&mut image, &mut combined_image,
shadow_color, shadow_color,
up_x as i32 + shadow_offset, up_text_x as i32 + shadow_offset,
1 + shadow_offset, up_y + shadow_offset,
scale, scale,
&font, &font,
&up_text, &up_text,
); );
draw_text_mut( draw_text_mut(
&mut image, &mut combined_image,
text_color, text_color,
up_x as i32, up_text_x as i32,
1, up_y,
scale, scale,
&font, &font,
&up_text, &up_text,
); );
// 绘制下行速率(先画阴影,再画文字) // 绘制下行速率
draw_text_mut( draw_text_mut(
&mut image, &mut combined_image,
shadow_color, shadow_color,
down_x as i32 + shadow_offset, down_text_x as i32 + shadow_offset,
height as i32 - (base_size as i32) - 1 + shadow_offset, down_y + shadow_offset,
scale, scale,
&font, &font,
&down_text, &down_text,
); );
draw_text_mut( draw_text_mut(
&mut image, &mut combined_image,
text_color, text_color,
down_x as i32, down_text_x as i32,
height as i32 - (base_size as i32) - 1, down_y,
scale, scale,
&font, &font,
&down_text, &down_text,
); );
let mut bytes: Vec<u8> = Vec::new(); // 将结果转换为 PNG 数据
let mut cursor = Cursor::new(&mut bytes); let mut bytes = Vec::new();
image.write_to(&mut cursor, image::ImageFormat::Png)?; combined_image.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)?;
Ok(bytes) Ok(bytes)
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
+125 -42
View File
@@ -1,55 +1,138 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Sequence, Value}; use serde_yaml::{Mapping, Sequence, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeqMap { pub struct SeqMap {
prepend: Sequence, pub prepend: Sequence,
append: Sequence, pub append: Sequence,
delete: Sequence, pub delete: Vec<String>,
} }
pub fn use_seq(seq_map: SeqMap, config: Mapping, name: &str) -> Mapping { pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
let mut prepend = seq_map.prepend; let SeqMap {
let append = seq_map.append; prepend,
let delete = seq_map.delete; append,
delete,
} = seq;
let origin_seq = config.get(name).map_or(Sequence::default(), |val| { let mut new_seq = Sequence::new();
val.as_sequence().unwrap_or(&Sequence::default()).clone() new_seq.extend(prepend);
});
let mut seq = origin_seq.clone();
let mut delete_names = Vec::new(); if let Some(Value::Sequence(origin)) = config.get(field) {
for item in delete { // Filter out deleted items
let item = item.clone(); let filtered: Sequence = origin
if let Some(name) = if item.is_string() { .iter()
Some(item) .filter(|item| {
} else { if let Value::String(s) = item {
item.get("name").cloned() !delete.contains(s)
} { } else if let Value::Mapping(m) = item {
delete_names.push(name.clone()); if let Some(Value::String(name)) = m.get("name") {
!delete.contains(name)
} else {
true
}
} else {
true
}
})
.cloned()
.collect();
new_seq.extend(filtered);
}
new_seq.extend(append);
config.insert(Value::String(field.into()), Value::Sequence(new_seq));
// If this is proxies field, we also need to filter proxy-groups
if field == "proxies" {
if let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups") {
let mut new_groups = Sequence::new();
for group in groups {
if let Value::Mapping(group_map) = group {
let mut new_group = group_map.clone();
if let Some(Value::Sequence(proxies)) = group_map.get("proxies") {
let filtered_proxies: Sequence = proxies
.iter()
.filter(|p| {
if let Value::String(name) = p {
!delete.contains(name)
} else {
true
}
})
.cloned()
.collect();
new_group.insert(
Value::String("proxies".into()),
Value::Sequence(filtered_proxies),
);
}
new_groups.push(Value::Mapping(new_group));
} else {
new_groups.push(group.clone());
}
}
config.insert(
Value::String("proxy-groups".into()),
Value::Sequence(new_groups),
);
} }
} }
seq.retain(|x| {
if let Some(x_name) = if x.is_string() {
Some(x)
} else {
x.get("name")
} {
!delete_names.contains(x_name)
} else {
true
}
});
prepend.reverse();
for item in prepend {
seq.insert(0, item);
}
for item in append {
seq.push(item);
}
let mut config = config.clone();
config.insert(Value::from(name), Value::from(seq));
config config
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml::Value;
#[test]
fn test_delete_proxy_and_references() {
let config_str = r#"
proxies:
- name: "proxy1"
type: "ss"
- name: "proxy2"
type: "vmess"
proxy-groups:
- name: "group1"
type: "select"
proxies:
- "proxy1"
- "proxy2"
- name: "group2"
type: "select"
proxies:
- "proxy1"
"#;
let mut config: Mapping = serde_yaml::from_str(config_str).unwrap();
let seq = SeqMap {
prepend: Sequence::new(),
append: Sequence::new(),
delete: vec!["proxy1".to_string()],
};
config = use_seq(seq, config, "proxies");
// Check if proxy1 is removed from proxies
let proxies = config.get("proxies").unwrap().as_sequence().unwrap();
assert_eq!(proxies.len(), 1);
assert_eq!(
proxies[0].as_mapping().unwrap().get("name").unwrap().as_str().unwrap(),
"proxy2"
);
// Check if proxy1 is removed from all groups
let groups = config.get("proxy-groups").unwrap().as_sequence().unwrap();
let group1_proxies = groups[0].as_mapping().unwrap()
.get("proxies").unwrap().as_sequence().unwrap();
let group2_proxies = groups[1].as_mapping().unwrap()
.get("proxies").unwrap().as_sequence().unwrap();
assert_eq!(group1_proxies.len(), 1);
assert_eq!(group1_proxies[0].as_str().unwrap(), "proxy2");
assert_eq!(group2_proxies.len(), 0);
}
}
+25 -9
View File
@@ -36,14 +36,30 @@ pub async fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
.unwrap_or(false); .unwrap_or(false);
if enable { if enable {
revise!(dns_val, "enable", true); // 检查现有的 enhanced-mode 设置
revise!(dns_val, "ipv6", ipv6_val); let current_mode = dns_val
revise!(dns_val, "enhanced-mode", "fake-ip"); .get(&Value::from("enhanced-mode"))
revise!(dns_val, "fake-ip-range", "172.29.0.1/16"); .and_then(|v| v.as_str())
#[cfg(target_os = "macos")] .unwrap_or("fake-ip");
{
crate::utils::resolve::restore_public_dns().await; // 只有当 enhanced-mode 是 fake-ip 或未设置时才修改 DNS 配置
crate::utils::resolve::set_public_dns("223.6.6.6".to_string()).await; if current_mode == "fake-ip" || !dns_val.contains_key(&Value::from("enhanced-mode")) {
revise!(dns_val, "enable", true);
revise!(dns_val, "ipv6", ipv6_val);
if !dns_val.contains_key(&Value::from("enhanced-mode")) {
revise!(dns_val, "enhanced-mode", "fake-ip");
}
if !dns_val.contains_key(&Value::from("fake-ip-range")) {
revise!(dns_val, "fake-ip-range", "198.18.0.1/16");
}
#[cfg(target_os = "macos")]
{
crate::utils::resolve::restore_public_dns().await;
crate::utils::resolve::set_public_dns("223.6.6.6".to_string()).await;
}
} }
} else { } else {
revise!( revise!(
@@ -72,7 +88,7 @@ pub async fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
dns_val dns_val
.get("fake-ip-range") .get("fake-ip-range")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("172.29.0.1/16") .unwrap_or("198.18.0.1/16")
); );
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
+136 -19
View File
@@ -4,6 +4,7 @@
//! - timer 定时器 //! - timer 定时器
//! - cmds 页面调用 //! - cmds 页面调用
//! //!
use crate::cmds;
use crate::config::*; use crate::config::*;
use crate::core::*; use crate::core::*;
use crate::log_err; use crate::log_err;
@@ -16,18 +17,46 @@ use std::fs;
use tauri::Manager; use tauri::Manager;
use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_window_state::{AppHandleExt, StateFlags}; use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use std::env;
// 打开面板 // 打开面板
#[allow(dead_code)]
pub fn open_or_close_dashboard() { pub fn open_or_close_dashboard() {
println!("Attempting to open/close dashboard");
log::info!(target: "app", "Attempting to open/close dashboard");
if let Some(window) = handle::Handle::global().get_window() { if let Some(window) = handle::Handle::global().get_window() {
println!("Found existing window");
log::info!(target: "app", "Found existing window");
// 如果窗口存在,则切换其显示状态 // 如果窗口存在,则切换其显示状态
if window.is_visible().unwrap_or(false) { match window.is_visible() {
let _ = window.hide(); Ok(visible) => {
} else { println!("Window visibility status: {}", visible);
let _ = window.show(); log::info!(target: "app", "Window visibility status: {}", visible);
let _ = window.set_focus();
if visible {
println!("Attempting to hide window");
log::info!(target: "app", "Attempting to hide window");
let _ = window.hide();
} else {
println!("Attempting to show and focus window");
log::info!(target: "app", "Attempting to show and focus window");
if window.is_minimized().unwrap_or(false) {
let _ = window.unminimize();
}
let _ = window.show();
let _ = window.set_focus();
}
}
Err(e) => {
println!("Failed to get window visibility: {:?}", e);
log::error!(target: "app", "Failed to get window visibility: {:?}", e);
}
} }
} else { } else {
println!("No existing window found, creating new window");
log::info!(target: "app", "No existing window found, creating new window");
resolve::create_window(); resolve::create_window();
} }
} }
@@ -56,16 +85,44 @@ pub fn restart_app() {
resolve::resolve_reset(); resolve::resolve_reset();
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
std::thread::sleep(std::time::Duration::from_secs(1)); std::thread::sleep(std::time::Duration::from_secs(1));
let _ = app_handle.save_window_state(StateFlags::default());
tauri::process::restart(&app_handle.env()); tauri::process::restart(&app_handle.env());
}); });
} }
/// 设置窗口状态监控,实时保存窗口位置和大小
pub fn setup_window_state_monitor(app_handle: &tauri::AppHandle) {
let window = app_handle.get_webview_window("main").unwrap();
let app_handle_clone = app_handle.clone();
// 监听窗口移动事件
let app_handle_move = app_handle_clone.clone();
window.on_window_event(move |event| {
match event {
// 窗口移动时保存状态
tauri::WindowEvent::Moved(_) => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
// 窗口调整大小时保存状态
tauri::WindowEvent::Resized(_) => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
// 其他可能改变窗口状态的事件
tauri::WindowEvent::ScaleFactorChanged { .. } => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
// 窗口关闭时保存
tauri::WindowEvent::CloseRequested { .. } => {
let _ = app_handle_move.save_window_state(StateFlags::all());
},
_ => {}
}
});
}
// 切换模式 rule/global/direct/script mode // 切换模式 rule/global/direct/script mode
pub fn change_clash_mode(mode: String) { pub fn change_clash_mode(mode: String) {
let mut mapping = Mapping::new(); let mut mapping = Mapping::new();
mapping.insert(Value::from("mode"), mode.clone().into()); mapping.insert(Value::from("mode"), mode.clone().into());
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
log::debug!(target: "app", "change clash mode to {mode}"); log::debug!(target: "app", "change clash mode to {mode}");
@@ -94,7 +151,7 @@ pub fn toggle_system_proxy() {
match patch_verge(IVerge { match patch_verge(IVerge {
enable_system_proxy: Some(!enable), enable_system_proxy: Some(!enable),
..IVerge::default() ..IVerge::default()
}) }, false)
.await .await
{ {
Ok(_) => handle::Handle::refresh_verge(), Ok(_) => handle::Handle::refresh_verge(),
@@ -103,8 +160,23 @@ pub fn toggle_system_proxy() {
}); });
} }
// 切换代理文件
pub fn toggle_proxy_profile(profile_index: String) {
tauri::async_runtime::spawn(async move {
let app_handle = handle::Handle::global().app_handle().unwrap();
match cmds::patch_profiles_config_by_profile_index(app_handle, profile_index).await {
Ok(_) => {
let _ = tray::Tray::global().update_menu();
}
Err(err) => {
log::error!(target: "app", "{err}");
}
}
});
}
// 切换tun模式 // 切换tun模式
pub fn toggle_tun_mode() { pub fn toggle_tun_mode(not_save_file: Option<bool>) {
let enable = Config::verge().data().enable_tun_mode; let enable = Config::verge().data().enable_tun_mode;
let enable = enable.unwrap_or(false); let enable = enable.unwrap_or(false);
@@ -112,7 +184,7 @@ pub fn toggle_tun_mode() {
match patch_verge(IVerge { match patch_verge(IVerge {
enable_tun_mode: Some(!enable), enable_tun_mode: Some(!enable),
..IVerge::default() ..IVerge::default()
}) }, not_save_file.unwrap_or(false))
.await .await
{ {
Ok(_) => handle::Handle::refresh_verge(), Ok(_) => handle::Handle::refresh_verge(),
@@ -124,6 +196,7 @@ pub fn toggle_tun_mode() {
pub fn quit(code: Option<i32>) { pub fn quit(code: Option<i32>) {
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
handle::Handle::global().set_is_exiting(); handle::Handle::global().set_is_exiting();
toggle_tun_mode(Some(true));
resolve::resolve_reset(); resolve::resolve_reset();
log_err!(handle::Handle::global().get_window().unwrap().close()); log_err!(handle::Handle::global().get_window().unwrap().close());
app_handle.exit(code.unwrap_or(0)); app_handle.exit(code.unwrap_or(0));
@@ -164,7 +237,7 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
/// 修改verge的订阅 /// 修改verge的订阅
/// 一般都是一个个的修改 /// 一般都是一个个的修改
pub async fn patch_verge(patch: IVerge) -> Result<()> { pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> {
Config::verge().draft().patch_config(patch.clone()); Config::verge().draft().patch_config(patch.clone());
let tun_mode = patch.enable_tun_mode; let tun_mode = patch.enable_tun_mode;
@@ -175,6 +248,7 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
let proxy_bypass = patch.system_proxy_bypass; let proxy_bypass = patch.system_proxy_bypass;
let language = patch.language; let language = patch.language;
let mixed_port = patch.verge_mixed_port; let mixed_port = patch.verge_mixed_port;
let lite_mode = patch.enable_lite_mode;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let tray_icon = patch.tray_icon; let tray_icon = patch.tray_icon;
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@@ -195,10 +269,12 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
let http_enabled = patch.verge_http_enabled; let http_enabled = patch.verge_http_enabled;
let http_port = patch.verge_port; let http_port = patch.verge_port;
let enable_tray_speed = patch.enable_tray_speed; let enable_tray_speed = patch.enable_tray_speed;
let enable_global_hotkey = patch.enable_global_hotkey;
let res: std::result::Result<(), anyhow::Error> = { let res: std::result::Result<(), anyhow::Error> = {
let mut should_restart_core = false; let mut should_restart_core = false;
let mut should_update_clash_config = false; let mut should_update_clash_config = false;
let mut should_update_verge_config = false;
let mut should_update_launch = false; let mut should_update_launch = false;
let mut should_update_sysproxy = false; let mut should_update_sysproxy = false;
let mut should_update_systray_icon = false; let mut should_update_systray_icon = false;
@@ -212,12 +288,13 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
should_update_systray_tooltip = true; should_update_systray_tooltip = true;
should_update_systray_icon = true; should_update_systray_icon = true;
} }
if enable_global_hotkey.is_some() {
should_update_verge_config = true;
}
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
if redir_enabled.is_some() || redir_port.is_some() { if redir_enabled.is_some() || redir_port.is_some() {
should_restart_core = true; should_restart_core = true;
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if tproxy_enabled.is_some() || tproxy_port.is_some() { if tproxy_enabled.is_some() || tproxy_port.is_some() {
should_restart_core = true; should_restart_core = true;
@@ -272,6 +349,10 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
CoreManager::global().update_config().await?; CoreManager::global().update_config().await?;
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
} }
if should_update_verge_config {
Config::verge().draft().enable_global_hotkey = enable_global_hotkey;
handle::Handle::refresh_verge();
}
if should_update_launch { if should_update_launch {
sysopt::Sysopt::global().update_launch()?; sysopt::Sysopt::global().update_launch()?;
} }
@@ -295,12 +376,31 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
if should_update_systray_tooltip { if should_update_systray_tooltip {
tray::Tray::global().update_tooltip()?; tray::Tray::global().update_tooltip()?;
} }
// 处理轻量模式切换
if lite_mode.is_some() {
if let Some(window) = handle::Handle::global().get_window() {
if lite_mode.unwrap() {
// 完全退出 webview 进程
window.close()?; // 先关闭窗口
let app_handle = handle::Handle::global().app_handle().unwrap();
if let Some(webview) = app_handle.get_webview_window("main") {
webview.destroy()?; // 销毁 webview 进程
}
} else {
resolve::create_window(); // 重新创建窗口
}
}
}
<Result<()>>::Ok(()) <Result<()>>::Ok(())
}; };
match res { match res {
Ok(()) => { Ok(()) => {
Config::verge().apply(); Config::verge().apply();
Config::verge().data().save_file()?; if !not_save_file {
Config::verge().data().save_file()?;
}
Ok(()) Ok(())
} }
@@ -314,6 +414,8 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
/// 更新某个profile /// 更新某个profile
/// 如果更新当前订阅就激活订阅 /// 如果更新当前订阅就激活订阅
pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()> { pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()> {
println!("[订阅更新] 开始更新订阅 {}", uid);
let url_opt = { let url_opt = {
let profiles = Config::profiles(); let profiles = Config::profiles();
let profiles = profiles.latest(); let profiles = profiles.latest();
@@ -321,33 +423,44 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
let is_remote = item.itype.as_ref().map_or(false, |s| s == "remote"); let is_remote = item.itype.as_ref().map_or(false, |s| s == "remote");
if !is_remote { if !is_remote {
None // 直接更新 println!("[订阅更新] {} 不是远程订阅,跳过更新", uid);
None // 非远程订阅直接更新
} else if item.url.is_none() { } else if item.url.is_none() {
println!("[订阅更新] {} 缺少URL,无法更新", uid);
bail!("failed to get the profile item url"); bail!("failed to get the profile item url");
} else { } else {
println!("[订阅更新] {} 是远程订阅,URL: {}", uid, item.url.clone().unwrap());
Some((item.url.clone().unwrap(), item.option.clone())) Some((item.url.clone().unwrap(), item.option.clone()))
} }
}; };
let should_update = match url_opt { let should_update = match url_opt {
Some((url, opt)) => { Some((url, opt)) => {
println!("[订阅更新] 开始下载新的订阅内容");
let merged_opt = PrfOption::merge(opt, option); let merged_opt = PrfOption::merge(opt, option);
let item = PrfItem::from_url(&url, None, None, merged_opt).await?; let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
println!("[订阅更新] 更新订阅配置");
let profiles = Config::profiles(); let profiles = Config::profiles();
let mut profiles = profiles.latest(); let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item)?; profiles.update_item(uid.clone(), item)?;
Some(uid) == profiles.get_current() let is_current = Some(uid.clone()) == profiles.get_current();
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
is_current
} }
None => true, None => true,
}; };
if should_update { if should_update {
println!("[订阅更新] 更新内核配置");
match CoreManager::global().update_config().await { match CoreManager::global().update_config().await {
Ok(_) => { Ok(_) => {
println!("[订阅更新] 更新成功");
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
} }
Err(err) => { Err(err) => {
println!("[订阅更新] 更新失败: {}", err);
handle::Handle::notice_message("set_config::error", format!("{err}")); handle::Handle::notice_message("set_config::error", format!("{err}"));
log::error!(target: "app", "{err}"); log::error!(target: "app", "{err}");
} }
@@ -359,10 +472,13 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
/// copy env variable /// copy env variable
pub fn copy_clash_env() { pub fn copy_clash_env() {
// 从环境变量获取IP地址,默认127.0.0.1
let clash_verge_rev_ip = env::var("CLASH_VERGE_REV_IP").unwrap_or_else(|_| "127.0.0.1".to_string());
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) }; let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
let http_proxy = format!("http://127.0.0.1:{}", port); let http_proxy = format!("http://{clash_verge_rev_ip}:{}", port);
let socks5_proxy = format!("socks5://127.0.0.1:{}", port); let socks5_proxy = format!("socks5://{clash_verge_rev_ip}:{}", port);
let sh = let sh =
format!("export https_proxy={http_proxy} http_proxy={http_proxy} all_proxy={socks5_proxy}"); format!("export https_proxy={http_proxy} http_proxy={http_proxy} all_proxy={socks5_proxy}");
@@ -504,7 +620,8 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
webdav_username, webdav_username,
webdav_password, webdav_password,
..IVerge::default() ..IVerge::default()
}) },
false)
.await .await
); );
// 最后删除临时文件 // 最后删除临时文件
+17
View File
@@ -6,6 +6,7 @@ mod feat;
mod utils; mod utils;
use crate::core::hotkey; use crate::core::hotkey;
use crate::utils::{resolve, resolve::resolve_scheme, server}; use crate::utils::{resolve, resolve::resolve_scheme, server};
use config::Config;
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
@@ -82,6 +83,7 @@ pub fn run() {
// clash // clash
cmds::get_clash_info, cmds::get_clash_info,
cmds::patch_clash_config, cmds::patch_clash_config,
cmds::patch_clash_mode,
cmds::change_clash_core, cmds::change_clash_core,
cmds::get_runtime_config, cmds::get_runtime_config,
cmds::get_runtime_yaml, cmds::get_runtime_yaml,
@@ -112,6 +114,9 @@ pub fn run() {
cmds::delete_profile, cmds::delete_profile,
cmds::read_profile_file, cmds::read_profile_file,
cmds::save_profile_file, cmds::save_profile_file,
// script validation
cmds::script_validate_notice,
cmds::validate_script_file,
// clash api // clash api
cmds::clash_api_get_proxy_delay, cmds::clash_api_get_proxy_delay,
// backup // backup
@@ -159,6 +164,12 @@ pub fn run() {
{ {
log_err!(hotkey::Hotkey::global().register("Control+Q", "quit")); log_err!(hotkey::Hotkey::global().register("Control+Q", "quit"));
}; };
{
let is_enable_global_hotkey = Config::verge().latest().enable_global_hotkey.unwrap_or(true);
if !is_enable_global_hotkey {
log_err!(hotkey::Hotkey::global().init())
}
}
} }
tauri::WindowEvent::Focused(false) => { tauri::WindowEvent::Focused(false) => {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -169,6 +180,12 @@ pub fn run() {
{ {
log_err!(hotkey::Hotkey::global().unregister("Control+Q")); log_err!(hotkey::Hotkey::global().unregister("Control+Q"));
}; };
{
let is_enable_global_hotkey = Config::verge().latest().enable_global_hotkey.unwrap_or(true);
if !is_enable_global_hotkey {
log_err!(hotkey::Hotkey::global().reset())
}
}
} }
tauri::WindowEvent::Destroyed => { tauri::WindowEvent::Destroyed => {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
+1
View File
@@ -223,6 +223,7 @@ pub fn format_bytes_speed(speed: u64) -> String {
} }
} }
#[cfg(target_os = "macos")]
#[test] #[test]
fn test_format_bytes_speed() { fn test_format_bytes_speed() {
assert_eq!(format_bytes_speed(0), "0B/s"); assert_eq!(format_bytes_speed(0), "0B/s");
+40 -13
View File
@@ -95,16 +95,19 @@ pub async fn resolve_setup(app: &mut App) {
log_err!(tray::Tray::global().init()); log_err!(tray::Tray::global().init());
log_err!(tray::Tray::global().create_systray()); log_err!(tray::Tray::global().create_systray());
log_err!(sysopt::Sysopt::global().update_sysproxy().await);
log_err!(sysopt::Sysopt::global().init_guard_sysproxy());
// 初始化热键
log::trace!(target: "app", "init hotkeys");
log_err!(hotkey::Hotkey::global().init());
let silent_start = { Config::verge().data().enable_silent_start }; let silent_start = { Config::verge().data().enable_silent_start };
if !silent_start.unwrap_or(false) { if !silent_start.unwrap_or(false) {
create_window(); create_window();
} }
log_err!(sysopt::Sysopt::global().update_sysproxy().await);
log_err!(sysopt::Sysopt::global().init_guard_sysproxy());
log_err!(tray::Tray::global().update_part()); log_err!(tray::Tray::global().update_part());
log_err!(hotkey::Hotkey::global().init());
log_err!(timer::Timer::global().init()); log_err!(timer::Timer::global().init());
} }
@@ -123,10 +126,18 @@ pub fn resolve_reset() {
/// create main window /// create main window
pub fn create_window() { pub fn create_window() {
println!("Starting to create window");
log::info!(target: "app", "Starting to create window");
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
if let Some(window) = handle::Handle::global().get_window() { if let Some(window) = handle::Handle::global().get_window() {
println!("Found existing window, trying to show it");
log::info!(target: "app", "Found existing window, trying to show it");
if window.is_minimized().unwrap_or(false) { if window.is_minimized().unwrap_or(false) {
println!("Window is minimized, unminimizing");
log::info!(target: "app", "Window is minimized, unminimizing");
let _ = window.unminimize(); let _ = window.unminimize();
} }
let _ = window.show(); let _ = window.show();
@@ -134,25 +145,27 @@ pub fn create_window() {
return; return;
} }
println!("Creating new window");
log::info!(target: "app", "Creating new window");
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let _ = tauri::WebviewWindowBuilder::new( let window = tauri::WebviewWindowBuilder::new(
&app_handle, &app_handle,
"main".to_string(), "main".to_string(),
tauri::WebviewUrl::App("index.html".into()), tauri::WebviewUrl::App("index.html".into()),
) )
.title("Clash Verge") .title("Clash Verge")
.visible(false)
.inner_size(890.0, 700.0) .inner_size(890.0, 700.0)
.min_inner_size(620.0, 550.0) .min_inner_size(620.0, 550.0)
.decorations(false) .decorations(false)
.maximizable(true) .maximizable(true)
.additional_browser_args("--enable-features=msWebView2EnableDraggableRegions --disable-features=OverscrollHistoryNavigation,msExperimentalScrolling") .additional_browser_args("--enable-features=msWebView2EnableDraggableRegions --disable-features=OverscrollHistoryNavigation,msExperimentalScrolling")
.transparent(true) .transparent(true)
.shadow(false) .shadow(true)
.build(); .build();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let _ = tauri::WebviewWindowBuilder::new( let window = tauri::WebviewWindowBuilder::new(
&app_handle, &app_handle,
"main".to_string(), "main".to_string(),
tauri::WebviewUrl::App("index.html".into()), tauri::WebviewUrl::App("index.html".into()),
@@ -162,11 +175,10 @@ pub fn create_window() {
.title_bar_style(tauri::TitleBarStyle::Overlay) .title_bar_style(tauri::TitleBarStyle::Overlay)
.inner_size(890.0, 700.0) .inner_size(890.0, 700.0)
.min_inner_size(620.0, 550.0) .min_inner_size(620.0, 550.0)
.build() .build();
.unwrap();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let _ = tauri::WebviewWindowBuilder::new( let window = tauri::WebviewWindowBuilder::new(
&app_handle, &app_handle,
"main".to_string(), "main".to_string(),
tauri::WebviewUrl::App("index.html".into()), tauri::WebviewUrl::App("index.html".into()),
@@ -176,8 +188,23 @@ pub fn create_window() {
.inner_size(890.0, 700.0) .inner_size(890.0, 700.0)
.min_inner_size(620.0, 550.0) .min_inner_size(620.0, 550.0)
.transparent(true) .transparent(true)
.build() .build();
.unwrap();
match window {
Ok(window) => {
println!("Window created successfully, attempting to show");
log::info!(target: "app", "Window created successfully, attempting to show");
let _ = window.show();
let _ = window.set_focus();
// 设置窗口状态监控,实时保存窗口位置和大小
crate::feat::setup_window_state_monitor(&app_handle);
}
Err(e) => {
println!("Failed to create window: {:?}", e);
log::error!(target: "app", "Failed to create window: {:?}", e);
}
}
} }
pub async fn resolve_scheme(param: String) -> Result<()> { pub async fn resolve_scheme(param: String) -> Result<()> {
+6 -3
View File
@@ -16,7 +16,7 @@
"copyright": "GNU General Public License v3.0", "copyright": "GNU General Public License v3.0",
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Clash Verge Rev", "shortDescription": "Clash Verge Rev",
"createUpdaterArtifacts": "v1Compatible" "createUpdaterArtifacts": true
}, },
"build": { "build": {
"beforeBuildCommand": "pnpm run web:build", "beforeBuildCommand": "pnpm run web:build",
@@ -25,7 +25,7 @@
"devUrl": "http://localhost:3000/" "devUrl": "http://localhost:3000/"
}, },
"productName": "Clash Verge", "productName": "Clash Verge",
"version": "2.0.3", "version": "2.1.2",
"identifier": "io.github.clash-verge-rev.clash-verge-rev", "identifier": "io.github.clash-verge-rev.clash-verge-rev",
"plugins": { "plugins": {
"updater": { "updater": {
@@ -33,7 +33,10 @@
"endpoints": [ "endpoints": [
"https://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json", "https://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update.json" "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update.json"
] ],
"windows": {
"installMode": "basicUi"
}
}, },
"deep-link": { "deep-link": {
"desktop": { "desktop": {
+1 -1
View File
@@ -24,7 +24,7 @@
"trayIcon": { "trayIcon": {
"iconPath": "icons/tray-icon.ico", "iconPath": "icons/tray-icon.ico",
"iconAsTemplate": true, "iconAsTemplate": true,
"menuOnLeftClick": false "showMenuOnLeftClick": false
}, },
"windows": [] "windows": []
} }
+1 -1
View File
@@ -111,7 +111,7 @@
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 2px; right: 1px;
bottom: 0px; bottom: 0px;
} }
} }
+37 -7
View File
@@ -18,7 +18,7 @@ interface InnerProps {
} }
const NoticeInner = (props: InnerProps) => { const NoticeInner = (props: InnerProps) => {
const { type, message, duration = 1500, onClose } = props; const { type, message, duration, onClose } = props;
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
const { verge } = useVerge(); const { verge } = useVerge();
@@ -43,7 +43,7 @@ const NoticeInner = (props: InnerProps) => {
appWindow.theme().then((m) => m && setIsDark(m === "dark")); appWindow.theme().then((m) => m && setIsDark(m === "dark"));
const unlisten = appWindow.onThemeChanged((e) => const unlisten = appWindow.onThemeChanged((e) =>
setIsDark(e.payload === "dark") setIsDark(e.payload === "dark"),
); );
return () => { return () => {
@@ -72,7 +72,7 @@ const NoticeInner = (props: InnerProps) => {
<Snackbar <Snackbar
open={visible} open={visible}
anchorOrigin={{ vertical: "top", horizontal: "right" }} anchorOrigin={{ vertical: "top", horizontal: "right" }}
autoHideDuration={duration} autoHideDuration={duration === -1 ? null : duration || 1500}
onClose={onAutoClose} onClose={onAutoClose}
message={msgElement} message={msgElement}
sx={{ sx={{
@@ -105,25 +105,55 @@ let parent: HTMLDivElement = null!;
// @ts-ignore // @ts-ignore
export const Notice: NoticeInstance = (props) => { export const Notice: NoticeInstance = (props) => {
const { type, message, duration } = props;
// 验证必要的参数
if (!message) {
return;
}
if (!parent) { if (!parent) {
parent = document.createElement("div"); parent = document.createElement("div");
parent.setAttribute("id", "notice-container"); // 添加 id 便于调试
document.body.appendChild(parent); document.body.appendChild(parent);
} }
const container = document.createElement("div"); const container = document.createElement("div");
parent.appendChild(container); parent.appendChild(container);
const root = createRoot(container); const root = createRoot(container);
const onUnmount = () => { const onUnmount = () => {
root.unmount(); root.unmount();
if (parent) setTimeout(() => parent.removeChild(container), 500); if (parent && container.parentNode === parent) {
setTimeout(() => {
parent.removeChild(container);
}, 500);
}
}; };
root.render(<NoticeInner {...props} onClose={onUnmount} />); root.render(
<NoticeInner
type={type}
message={message}
duration={duration || 1500}
onClose={onUnmount}
/>,
);
}; };
(["info", "error", "success"] as const).forEach((type) => { (["info", "error", "success"] as const).forEach((type) => {
Notice[type] = (message, duration) => { Notice[type] = (message: ReactNode, duration?: number) => {
setTimeout(() => Notice({ type, message, duration }), 0); // 确保消息不为空
if (!message) {
return;
}
Notice({
type,
message,
// 错误类型通知显示 8 秒,其他类型默认 1.5 秒
duration: type === "error" ? 8000 : duration || 1500,
});
}; };
}); });
+62 -65
View File
@@ -1,6 +1,6 @@
import { Box, SvgIcon, TextField, styled } from "@mui/material"; import { Box, SvgIcon, TextField, styled } from "@mui/material";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import { ChangeEvent, useEffect, useRef, useState } from "react"; import { ChangeEvent, useEffect, useRef, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
@@ -22,7 +22,20 @@ type SearchProps = {
onSearch: (match: (content: string) => boolean, state: SearchState) => void; onSearch: (match: (content: string) => boolean, state: SearchState) => void;
}; };
export const BaseSearchBox = styled((props: SearchProps) => { const StyledTextField = styled(TextField)(({ theme }) => ({
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined,
paddingRight: "4px",
},
"& .MuiInputBase-root svg[aria-label='active'] path": {
fill: theme.palette.primary.light,
},
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
fill: "#A7A7A7",
},
}));
export const BaseSearchBox = (props: SearchProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [matchCase, setMatchCase] = useState(props.matchCase ?? false); const [matchCase, setMatchCase] = useState(props.matchCase ?? false);
@@ -43,58 +56,58 @@ export const BaseSearchBox = styled((props: SearchProps) => {
inheritViewBox: true, inheritViewBox: true,
}; };
useEffect(() => { const createMatcher = useMemo(() => {
if (!inputRef.current) return; return (searchText: string) => {
onChange({
target: inputRef.current,
} as ChangeEvent<HTMLInputElement>);
}, [matchCase, matchWholeWord, useRegularExpression]);
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
props.onSearch(
(content) => doSearch([content], e.target?.value ?? "").length > 0,
{
text: e.target?.value ?? "",
matchCase,
matchWholeWord,
useRegularExpression,
},
);
};
const doSearch = (searchList: string[], searchItem: string) => {
setErrorMessage("");
return searchList.filter((item) => {
try { try {
let searchItemCopy = searchItem; return (content: string) => {
if (!matchCase) { if (!searchText) return true;
item = item.toLowerCase();
searchItemCopy = searchItemCopy.toLowerCase(); let item = !matchCase ? content.toLowerCase() : content;
} let searchItem = !matchCase ? searchText.toLowerCase() : searchText;
if (matchWholeWord) {
const regex = new RegExp(`\\b${searchItemCopy}\\b`);
if (useRegularExpression) { if (useRegularExpression) {
const regexWithOptions = new RegExp(searchItemCopy); return new RegExp(searchItem).test(item);
return regexWithOptions.test(item) && regex.test(item);
} else {
return regex.test(item);
} }
} else if (useRegularExpression) {
const regex = new RegExp(searchItemCopy); if (matchWholeWord) {
return regex.test(item); return new RegExp(`\\b${searchItem}\\b`).test(item);
} else { }
return item.includes(searchItemCopy);
} return item.includes(searchItem);
};
} catch (err) { } catch (err) {
setErrorMessage(`${err}`); setErrorMessage(`${err}`);
return () => true;
} }
};
}, [matchCase, matchWholeWord, useRegularExpression]);
useEffect(() => {
if (!inputRef.current) return;
const value = inputRef.current.value;
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]);
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target?.value ?? "";
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
}); });
}; };
return ( return (
<Tooltip title={errorMessage} placement="bottom-start"> <Tooltip title={errorMessage} placement="bottom-start">
<TextField <StyledTextField
autoComplete="new-password" autoComplete="new-password"
inputRef={inputRef} inputRef={inputRef}
hiddenLabel hiddenLabel
@@ -115,9 +128,7 @@ export const BaseSearchBox = styled((props: SearchProps) => {
component={matchCaseIcon} component={matchCaseIcon}
{...iconStyle} {...iconStyle}
aria-label={matchCase ? "active" : "inactive"} aria-label={matchCase ? "active" : "inactive"}
onClick={() => { onClick={() => setMatchCase(!matchCase)}
setMatchCase(!matchCase);
}}
/> />
</div> </div>
</Tooltip> </Tooltip>
@@ -127,9 +138,7 @@ export const BaseSearchBox = styled((props: SearchProps) => {
component={matchWholeWordIcon} component={matchWholeWordIcon}
{...iconStyle} {...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"} aria-label={matchWholeWord ? "active" : "inactive"}
onClick={() => { onClick={() => setMatchWholeWord(!matchWholeWord)}
setMatchWholeWord(!matchWholeWord);
}}
/> />
</div> </div>
</Tooltip> </Tooltip>
@@ -139,28 +148,16 @@ export const BaseSearchBox = styled((props: SearchProps) => {
component={useRegularExpressionIcon} component={useRegularExpressionIcon}
aria-label={useRegularExpression ? "active" : "inactive"} aria-label={useRegularExpression ? "active" : "inactive"}
{...iconStyle} {...iconStyle}
onClick={() => { onClick={() =>
setUseRegularExpression(!useRegularExpression); setUseRegularExpression(!useRegularExpression)
}} }
/>{" "} />{" "}
</div> </div>
</Tooltip> </Tooltip>
</Box> </Box>
), ),
}} }}
{...props}
/> />
</Tooltip> </Tooltip>
); );
})(({ theme }) => ({ };
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined,
"padding-right": "4px",
},
"& .MuiInputBase-root svg[aria-label='active'] path": {
fill: theme.palette.primary.light,
},
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
fill: "#A7A7A7",
},
}));
@@ -52,7 +52,10 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const host = metadata.host const host = metadata.host
? `${metadata.host}:${metadata.destinationPort}` ? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`; : `${metadata.remoteDestination}:${metadata.destinationPort}`;
const Destination = metadata.destinationIP
? metadata.destinationIP
: metadata.remoteDestination;
const information = [ const information = [
{ label: t("Host"), value: host }, { label: t("Host"), value: host },
@@ -79,7 +82,8 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
label: t("Source"), label: t("Source"),
value: `${metadata.sourceIP}:${metadata.sourcePort}`, value: `${metadata.sourceIP}:${metadata.sourcePort}`,
}, },
{ label: t("Destination IP"), value: metadata.destinationIP }, { label: t("Destination"), value: Destination },
{ label: t("DestinationPort"), value: `${metadata.destinationPort}` },
{ label: t("Type"), value: `${metadata.type}(${metadata.network})` }, { label: t("Type"), value: `${metadata.type}(${metadata.network})` },
]; ];
+79 -20
View File
@@ -1,6 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMemo, useState } from "react"; import { useMemo, useState, useEffect } from "react";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
import { truncateStr } from "@/utils/truncate-str"; import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
@@ -21,12 +21,24 @@ export const ConnectionTable = (props: Props) => {
Partial<Record<keyof IConnectionsItem, boolean>> Partial<Record<keyof IConnectionsItem, boolean>>
>({}); >({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
() => {
const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {};
},
);
const [columns] = useState<GridColDef[]>([ const [columns] = useState<GridColDef[]>([
{ field: "host", headerName: t("Host"), flex: 220, minWidth: 220 }, {
field: "host",
headerName: t("Host"),
width: columnWidths["host"] || 220,
minWidth: 180,
},
{ {
field: "download", field: "download",
headerName: t("Downloaded"), headerName: t("Downloaded"),
width: 88, width: columnWidths["download"] || 88,
align: "right", align: "right",
headerAlign: "right", headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "), valueFormatter: (value: number) => parseTraffic(value).join(" "),
@@ -34,7 +46,7 @@ export const ConnectionTable = (props: Props) => {
{ {
field: "upload", field: "upload",
headerName: t("Uploaded"), headerName: t("Uploaded"),
width: 88, width: columnWidths["upload"] || 88,
align: "right", align: "right",
headerAlign: "right", headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "), valueFormatter: (value: number) => parseTraffic(value).join(" "),
@@ -42,7 +54,7 @@ export const ConnectionTable = (props: Props) => {
{ {
field: "dlSpeed", field: "dlSpeed",
headerName: t("DL Speed"), headerName: t("DL Speed"),
width: 88, width: columnWidths["dlSpeed"] || 88,
align: "right", align: "right",
headerAlign: "right", headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
@@ -50,18 +62,33 @@ export const ConnectionTable = (props: Props) => {
{ {
field: "ulSpeed", field: "ulSpeed",
headerName: t("UL Speed"), headerName: t("UL Speed"),
width: 88, width: columnWidths["ulSpeed"] || 88,
align: "right", align: "right",
headerAlign: "right", headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s", valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
}, },
{ field: "chains", headerName: t("Chains"), flex: 360, minWidth: 360 }, {
{ field: "rule", headerName: t("Rule"), flex: 300, minWidth: 250 }, field: "chains",
{ field: "process", headerName: t("Process"), flex: 240, minWidth: 120 }, headerName: t("Chains"),
width: columnWidths["chains"] || 340,
minWidth: 180,
},
{
field: "rule",
headerName: t("Rule"),
width: columnWidths["rule"] || 280,
minWidth: 180,
},
{
field: "process",
headerName: t("Process"),
width: columnWidths["process"] || 220,
minWidth: 180,
},
{ {
field: "time", field: "time",
headerName: t("Time"), headerName: t("Time"),
flex: 120, width: columnWidths["time"] || 120,
minWidth: 100, minWidth: 100,
align: "right", align: "right",
headerAlign: "right", headerAlign: "right",
@@ -69,26 +96,56 @@ export const ConnectionTable = (props: Props) => {
new Date(v2).getTime() - new Date(v1).getTime(), new Date(v2).getTime() - new Date(v1).getTime(),
valueFormatter: (value: number) => dayjs(value).fromNow(), valueFormatter: (value: number) => dayjs(value).fromNow(),
}, },
{ field: "source", headerName: t("Source"), flex: 200, minWidth: 130 },
{ {
field: "destinationIP", field: "source",
headerName: t("Destination IP"), headerName: t("Source"),
flex: 200, width: columnWidths["source"] || 200,
minWidth: 130, minWidth: 130,
}, },
{ field: "type", headerName: t("Type"), flex: 160, minWidth: 100 }, {
field: "remoteDestination",
headerName: t("Destination"),
width: columnWidths["remoteDestination"] || 200,
minWidth: 130,
},
{
field: "type",
headerName: t("Type"),
width: columnWidths["type"] || 160,
minWidth: 100,
},
]); ]);
useEffect(() => {
console.log("Saving column widths:", columnWidths);
localStorage.setItem(
"connection-table-widths",
JSON.stringify(columnWidths),
);
}, [columnWidths]);
const handleColumnResize = (params: GridColumnResizeParams) => {
const { colDef, width } = params;
console.log("Column resize:", colDef.field, width);
setColumnWidths((prev) => ({
...prev,
[colDef.field]: width,
}));
};
const connRows = useMemo(() => { const connRows = useMemo(() => {
return connections.map((each) => { return connections.map((each) => {
const { metadata, rulePayload } = each; const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / "); const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule; const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
const Destination = metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
return { return {
id: each.id, id: each.id,
host: metadata.host host: metadata.host
? `${metadata.host}:${metadata.destinationPort}` ? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`, : `${metadata.remoteDestination}:${metadata.destinationPort}`,
download: each.download, download: each.download,
upload: each.upload, upload: each.upload,
dlSpeed: each.curDownload, dlSpeed: each.curDownload,
@@ -98,7 +155,7 @@ export const ConnectionTable = (props: Props) => {
process: truncateStr(metadata.process || metadata.processPath), process: truncateStr(metadata.process || metadata.processPath),
time: each.start, time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`, source: `${metadata.sourceIP}:${metadata.sourcePort}`,
destinationIP: metadata.destinationIP, remoteDestination: Destination,
type: `${metadata.type}(${metadata.network})`, type: `${metadata.type}(${metadata.network})`,
connectionData: each, connectionData: each,
}; };
@@ -115,12 +172,14 @@ export const ConnectionTable = (props: Props) => {
sx={{ sx={{
border: "none", border: "none",
"div:focus": { outline: "none !important" }, "div:focus": { outline: "none !important" },
"& div[aria-rowindex]": { "& .MuiDataGrid-columnHeader": {
backgroundColor: `${backgroundColor} !important`, userSelect: "none",
}, },
}} }}
columnVisibilityModel={columnVisible} columnVisibilityModel={columnVisible}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)} onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
onColumnResize={handleColumnResize}
disableColumnMenu={false}
/> />
); );
}; };
+2 -2
View File
@@ -85,9 +85,9 @@ const LogItem = ({ value, searchState }: Props) => {
return ( return (
<Item> <Item>
<div> <div>
<span className="time">{value.time}</span> <span className="time">{renderHighlightText(value.time || "")}</span>
<span className="type" data-type={value.type.toLowerCase()}> <span className="type" data-type={value.type.toLowerCase()}>
{value.type} {renderHighlightText(value.type)}
</span> </span>
</div> </div>
<div> <div>
+384 -26
View File
@@ -1,4 +1,4 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { import {
@@ -16,6 +16,173 @@ import { ProxyRender } from "./proxy-render";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button"; import { ScrollTopButton } from "../layout/scroll-top-button";
import { Box, styled } from "@mui/material";
import { memo } from "react";
import { createPortal } from "react-dom";
// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式
const AlphabetSelector = styled(Box)(({ theme }) => ({
position: "fixed",
right: 4,
top: "50%",
transform: "translateY(-50%)",
display: "flex",
flexDirection: "column",
background: "transparent",
zIndex: 1000,
gap: "2px",
// padding: "4px 2px",
willChange: "transform",
"&:hover": {
background: theme.palette.background.paper,
boxShadow: theme.shadows[2],
borderRadius: "8px",
},
"& .scroll-container": {
overflow: "hidden",
maxHeight: "inherit",
},
"& .letter-container": {
display: "flex",
flexDirection: "column",
gap: "2px",
transition: "transform 0.2s ease",
},
"& .letter": {
padding: "1px 4px",
fontSize: "12px",
cursor: "pointer",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
color: theme.palette.text.secondary,
position: "relative",
width: "1.5em",
height: "1.5em",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)",
transform: "scale(1) translateZ(0)",
backfaceVisibility: "hidden",
borderRadius: "6px",
"&:hover": {
color: theme.palette.primary.main,
transform: "scale(1.4) translateZ(0)",
backgroundColor: theme.palette.action.hover,
},
},
}));
// 创建一个单独的 Tooltip 组件
const Tooltip = styled("div")(({ theme }) => ({
position: "fixed",
background: theme.palette.background.paper,
padding: "4px 8px",
borderRadius: "6px",
boxShadow: theme.shadows[3],
whiteSpace: "nowrap",
fontSize: "16px",
color: theme.palette.text.primary,
pointerEvents: "none",
"&::after": {
content: '""',
position: "absolute",
right: "-4px",
top: "50%",
transform: "translateY(-50%)",
width: 0,
height: 0,
borderTop: "4px solid transparent",
borderBottom: "4px solid transparent",
borderLeft: `4px solid ${theme.palette.background.paper}`,
},
}));
// 抽离字母选择器子组件
const LetterItem = memo(
({
name,
onClick,
getFirstChar,
}: {
name: string;
onClick: (name: string) => void;
getFirstChar: (str: string) => string;
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const letterRef = useRef<HTMLDivElement>(null);
const [tooltipPosition, setTooltipPosition] = useState({
top: 0,
right: 0,
});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const updateTooltipPosition = useCallback(() => {
if (!letterRef.current) return;
const rect = letterRef.current.getBoundingClientRect();
setTooltipPosition({
top: rect.top + rect.height / 2,
right: window.innerWidth - rect.left + 8,
});
}, []);
useEffect(() => {
if (showTooltip) {
updateTooltipPosition();
}
}, [showTooltip, updateTooltipPosition]);
const handleMouseEnter = useCallback(() => {
setShowTooltip(true);
// 添加 200ms 的延迟,避免鼠标快速划过时触发滚动
hoverTimeoutRef.current = setTimeout(() => {
onClick(name);
}, 100);
}, [name, onClick]);
const handleMouseLeave = useCallback(() => {
setShowTooltip(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
return (
<>
<div
ref={letterRef}
className="letter"
onClick={() => onClick(name)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span>{getFirstChar(name)}</span>
</div>
{showTooltip &&
createPortal(
<Tooltip
style={{
top: tooltipPosition.top,
right: tooltipPosition.right,
transform: "translateY(-50%)",
}}
>
{name}
</Tooltip>,
document.body,
)}
</>
);
},
);
interface Props { interface Props {
mode: string; mode: string;
@@ -32,22 +199,128 @@ export const ProxyGroups = (props: Props) => {
const timeout = verge?.default_latency_timeout || 10000; const timeout = verge?.default_latency_timeout || 10000;
const virtuosoRef = useRef<VirtuosoHandle>(null); const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollPositionRef = useRef<Record<string, number>>({});
const [showScrollTop, setShowScrollTop] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false);
const scrollerRef = useRef<Element | null>(null);
const letterContainerRef = useRef<HTMLDivElement>(null);
const alphabetSelectorRef = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState("auto");
// 添加滚动处理函数 // 使用useMemo缓存字母索引数据
const handleScroll = (e: any) => { const { groupFirstLetters, letterIndexMap } = useMemo(() => {
const scrollTop = e.target.scrollTop; const letters = new Set<string>();
setShowScrollTop(scrollTop > 100); const indexMap: Record<string, number> = {};
};
renderList.forEach((item, index) => {
if (item.type === 0) {
const fullName = item.group.name;
letters.add(fullName);
if (!(fullName in indexMap)) {
indexMap[fullName] = index;
}
}
});
return {
groupFirstLetters: Array.from(letters),
letterIndexMap: indexMap,
};
}, [renderList]);
// 缓存getFirstChar函数
const getFirstChar = useCallback((str: string) => {
const regex = /\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
const match = str.match(regex);
return match ? match[0] : str.charAt(0);
}, []);
// 从 localStorage 恢复滚动位置
useEffect(() => {
if (renderList.length === 0) return;
try {
const savedPositions = localStorage.getItem("proxy-scroll-positions");
if (savedPositions) {
const positions = JSON.parse(savedPositions);
scrollPositionRef.current = positions;
const savedPosition = positions[mode];
if (savedPosition !== undefined) {
setTimeout(() => {
virtuosoRef.current?.scrollTo({
top: savedPosition,
behavior: "auto",
});
}, 100);
}
}
} catch (e) {
console.error("Error restoring scroll position:", e);
}
}, [mode, renderList]);
// 使用防抖保存滚动位置
const saveScrollPosition = useCallback(
(scrollTop: number) => {
try {
scrollPositionRef.current[mode] = scrollTop;
localStorage.setItem(
"proxy-scroll-positions",
JSON.stringify(scrollPositionRef.current),
);
} catch (e) {
console.error("Error saving scroll position:", e);
}
},
[mode],
);
// 优化滚动处理函数,使用防抖
const handleScroll = useCallback(
debounce((e: any) => {
const scrollTop = e.target.scrollTop;
setShowScrollTop(scrollTop > 100);
saveScrollPosition(scrollTop);
}, 16),
[saveScrollPosition],
);
// 添加和清理滚动事件监听器
useEffect(() => {
const currentScroller = scrollerRef.current;
if (currentScroller) {
currentScroller.addEventListener("scroll", handleScroll, {
passive: true,
});
return () => {
currentScroller.removeEventListener("scroll", handleScroll);
};
}
}, [handleScroll]);
// 滚动到顶部 // 滚动到顶部
const scrollToTop = () => { const scrollToTop = useCallback(() => {
virtuosoRef.current?.scrollTo?.({ virtuosoRef.current?.scrollTo?.({
top: 0, top: 0,
behavior: "smooth", behavior: "smooth",
}); });
}; saveScrollPosition(0);
}, [saveScrollPosition]);
// 处理字母点击,使用useCallback
const handleLetterClick = useCallback(
(name: string) => {
const index = letterIndexMap[name];
if (index !== undefined) {
virtuosoRef.current?.scrollToIndex({
index,
align: "start",
behavior: "smooth",
});
}
},
[letterIndexMap],
);
// 切换分组的节点代理 // 切换分组的节点代理
const handleChangeProxy = useLockFn( const handleChangeProxy = useLockFn(
@@ -134,6 +407,64 @@ export const ProxyGroups = (props: Props) => {
} }
}; };
// 添加滚轮事件处理函数
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault();
if (!letterContainerRef.current) return;
const container = letterContainerRef.current;
const scrollAmount = e.deltaY;
const currentTransform = new WebKitCSSMatrix(container.style.transform);
const currentY = currentTransform.m42 || 0;
const containerHeight = container.getBoundingClientRect().height;
const parentHeight =
container.parentElement?.getBoundingClientRect().height || 0;
const maxScroll = Math.max(0, containerHeight - parentHeight);
let newY = currentY - scrollAmount;
newY = Math.min(0, Math.max(-maxScroll, newY));
container.style.transform = `translateY(${newY}px)`;
}, []);
// 添加和移除滚轮事件监听
useEffect(() => {
const container = letterContainerRef.current?.parentElement;
if (container) {
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}
}, [handleWheel]);
// 添加窗口大小变化监听和最大高度计算
const updateMaxHeight = useCallback(() => {
if (!alphabetSelectorRef.current) return;
const windowHeight = window.innerHeight;
const bottomMargin = 60; // 底部边距
const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍
const availableHeight = windowHeight - (topMargin + bottomMargin);
// 调整选择器的位置,使其偏下
const offsetPercentage =
(((topMargin - bottomMargin) / windowHeight) * 100) / 2;
alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`;
setMaxHeight(`${availableHeight}px`);
}, []);
// 监听窗口大小变化
useEffect(() => {
updateMaxHeight();
window.addEventListener("resize", updateMaxHeight);
return () => {
window.removeEventListener("resize", updateMaxHeight);
};
}, [updateMaxHeight]);
if (mode === "direct") { if (mode === "direct") {
return <BaseEmpty text={t("clash_mode_direct")} />; return <BaseEmpty text={t("clash_mode_direct")} />;
} }
@@ -142,30 +473,57 @@ export const ProxyGroups = (props: Props) => {
<div style={{ position: "relative", height: "100%" }}> <div style={{ position: "relative", height: "100%" }}>
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
style={{ height: "calc(100% - 16px)" }} style={{ height: "calc(100% - 14px)" }}
totalCount={renderList.length} totalCount={renderList.length}
increaseViewportBy={256} increaseViewportBy={{ top: 256, bottom: 256 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => { scrollerRef={(ref) => {
if (ref) { scrollerRef.current = ref as Element;
ref.addEventListener("scroll", handleScroll); }}
} components={{
Footer: () => <div style={{ height: "8px" }} />,
}} }}
itemContent={(index) => ( itemContent={(index) => (
<> <ProxyRender
<ProxyRender key={renderList[index].key}
key={renderList[index].key} item={renderList[index]}
item={renderList[index]} indent={mode === "rule" || mode === "script"}
indent={mode === "rule" || mode === "script"} onLocation={handleLocation}
onLocation={handleLocation} onCheckAll={handleCheckAll}
onCheckAll={handleCheckAll} onHeadState={onHeadState}
onHeadState={onHeadState} onChangeProxy={handleChangeProxy}
onChangeProxy={handleChangeProxy} />
/>
</>
)} )}
/> />
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} /> <ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
<AlphabetSelector ref={alphabetSelectorRef} style={{ maxHeight }}>
<div className="scroll-container">
<div ref={letterContainerRef} className="letter-container">
{groupFirstLetters.map((name) => (
<LetterItem
key={name}
name={name}
onClick={handleLetterClick}
getFirstChar={getFirstChar}
/>
))}
</div>
</div>
</AlphabetSelector>
</div> </div>
); );
}; };
// 简单的防抖函数
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
+10 -11
View File
@@ -100,7 +100,7 @@ export const ProxyRender = (props: RenderProps) => {
<ListItemText <ListItemText
primary={<StyledPrimary>{group.name}</StyledPrimary>} primary={<StyledPrimary>{group.name}</StyledPrimary>}
secondary={ secondary={
<ListItemTextChild <Box
sx={{ sx={{
overflow: "hidden", overflow: "hidden",
display: "flex", display: "flex",
@@ -108,16 +108,19 @@ export const ProxyRender = (props: RenderProps) => {
pt: "2px", pt: "2px",
}} }}
> >
<Box sx={{ marginTop: "2px" }}> <Box component="span" sx={{ marginTop: "2px" }}>
<StyledTypeBox>{group.type}</StyledTypeBox> <StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: "text.secondary" }}> <StyledSubtitle sx={{ color: "text.secondary" }}>
{group.now} {group.now}
</StyledSubtitle> </StyledSubtitle>
</Box> </Box>
</ListItemTextChild> </Box>
} }
secondaryTypographyProps={{ slotProps={{
sx: { display: "flex", alignItems: "center", color: "#ccc" }, secondary: {
component: "div",
sx: { display: "flex", alignItems: "center", color: "#ccc" },
},
}} }}
/> />
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />} {headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
@@ -204,7 +207,7 @@ export const ProxyRender = (props: RenderProps) => {
}; };
const StyledPrimary = styled("span")` const StyledPrimary = styled("span")`
font-size: 15px; font-size: 16px;
font-weight: 700; font-weight: 700;
line-height: 1.5; line-height: 1.5;
overflow: hidden; overflow: hidden;
@@ -219,11 +222,7 @@ const StyledSubtitle = styled("span")`
white-space: nowrap; white-space: nowrap;
`; `;
const ListItemTextChild = styled("span")` const StyledTypeBox = styled(Box)(({ theme }) => ({
display: block;
`;
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block", display: "inline-block",
border: "1px solid #ccc", border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5), borderColor: alpha(theme.palette.primary.main, 0.5),
+83 -54
View File
@@ -1,5 +1,5 @@
import useSWR from "swr"; import useSWR from "swr";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useCallback } from "react";
import { getProxies } from "@/services/api"; import { getProxies } from "@/services/api";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { filterSort } from "./use-filter-sort"; import { filterSort } from "./use-filter-sort";
@@ -11,7 +11,7 @@ import {
} from "./use-head-state"; } from "./use-head-state";
export interface IRenderItem { export interface IRenderItem {
// 组 | head | item | empty | item col // 组 | head | item | empty | item col
type: 0 | 1 | 2 | 3 | 4; type: 0 | 1 | 2 | 3 | 4;
key: string; key: string;
group: IProxyGroupItem; group: IProxyGroupItem;
@@ -19,32 +19,59 @@ export interface IRenderItem {
col?: number; col?: number;
proxyCol?: IProxyItem[]; proxyCol?: IProxyItem[];
headState?: HeadState; headState?: HeadState;
// 新增支持图标和其他元数据
icon?: string;
provider?: string;
testUrl?: string;
} }
// 优化列布局计算
const calculateColumns = (width: number, configCol: number): number => {
if (configCol > 0 && configCol < 6) return configCol;
if (width > 1920) return 5;
if (width > 1450) return 4;
if (width > 1024) return 3;
if (width > 900) return 2;
if (width >= 600) return 2;
return 1;
};
// 优化分组逻辑
const groupProxies = <T = any>(list: T[], size: number): T[][] => {
return list.reduce((acc, item) => {
const lastGroup = acc[acc.length - 1];
if (!lastGroup || lastGroup.length >= size) {
acc.push([item]);
} else {
lastGroup.push(item);
}
return acc;
}, [] as T[][]);
};
export const useRenderList = (mode: string) => { export const useRenderList = (mode: string) => {
const { data: proxiesData, mutate: mutateProxies } = useSWR( const { data: proxiesData, mutate: mutateProxies } = useSWR(
"getProxies", "getProxies",
getProxies, getProxies,
{ refreshInterval: 45000 }, {
refreshInterval: 2000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
},
); );
const { verge } = useVerge(); const { verge } = useVerge();
const { width } = useWindowWidth(); const { width } = useWindowWidth();
let col = Math.floor(verge?.proxy_layout_column || 6);
// 自适应
if (col >= 6 || col <= 0) {
if (width > 1450) col = 4;
else if (width > 1024) col = 3;
else if (width > 900) col = 2;
else if (width >= 600) col = 2;
else col = 1;
}
const [headStates, setHeadState] = useHeadStateNew(); const [headStates, setHeadState] = useHeadStateNew();
// make sure that fetch the proxies successfully // 计算列数
const col = useMemo(
() => calculateColumns(width, verge?.proxy_layout_column || 6),
[width, verge?.proxy_layout_column],
);
// 确保代理数据加载
useEffect(() => { useEffect(() => {
if (!proxiesData) return; if (!proxiesData) return;
const { groups, proxies } = proxiesData; const { groups, proxies } = proxiesData;
@@ -55,22 +82,29 @@ export const useRenderList = (mode: string) => {
) { ) {
setTimeout(() => mutateProxies(), 500); setTimeout(() => mutateProxies(), 500);
} }
}, [proxiesData, mode]); }, [proxiesData, mode, mutateProxies]);
// 处理渲染列表
const renderList: IRenderItem[] = useMemo(() => { const renderList: IRenderItem[] = useMemo(() => {
if (!proxiesData) return []; if (!proxiesData) return [];
// global 和 direct 使用展开的样式
const useRule = mode === "rule" || mode === "script"; const useRule = mode === "rule" || mode === "script";
const renderGroups = const renderGroups =
(useRule && proxiesData.groups.length useRule && proxiesData.groups.length
? proxiesData.groups ? proxiesData.groups
: [proxiesData.global!]) || []; : [proxiesData.global!];
const retList = renderGroups.flatMap((group) => { const retList = renderGroups.flatMap((group) => {
const headState = headStates[group.name] || DEFAULT_STATE; const headState = headStates[group.name] || DEFAULT_STATE;
const ret: IRenderItem[] = [ const ret: IRenderItem[] = [
{ type: 0, key: group.name, group, headState }, {
type: 0,
key: group.name,
group,
headState,
icon: group.icon,
testUrl: group.testUrl,
},
]; ];
if (headState?.open || !useRule) { if (headState?.open || !useRule) {
@@ -81,61 +115,56 @@ export const useRenderList = (mode: string) => {
headState.sortType, headState.sortType,
); );
ret.push({ type: 1, key: `head-${group.name}`, group, headState }); ret.push({
type: 1,
key: `head-${group.name}`,
group,
headState,
});
if (!proxies.length) { if (!proxies.length) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState }); ret.push({
} type: 3,
key: `empty-${group.name}`,
// 支持多列布局 group,
if (col > 1) { headState,
});
} else if (col > 1) {
return ret.concat( return ret.concat(
groupList(proxies, col).map((proxyCol) => ({ groupProxies(proxies, col).map((proxyCol) => ({
type: 4, type: 4,
key: `col-${group.name}-${proxyCol[0].name}`, key: `col-${group.name}-${proxyCol[0].name}`,
group, group,
headState, headState,
col, col,
proxyCol, proxyCol,
provider: proxyCol[0].provider,
})),
);
} else {
return ret.concat(
proxies.map((proxy) => ({
type: 2,
key: `${group.name}-${proxy!.name}`,
group,
proxy,
headState,
provider: proxy.provider,
})), })),
); );
} }
return ret.concat(
proxies.map((proxy) => ({
type: 2,
key: `${group.name}-${proxy!.name}`,
group,
proxy,
headState,
})),
);
} }
return ret; return ret;
}); });
if (!useRule) return retList.slice(1); if (!useRule) return retList.slice(1);
return retList.filter((item) => item.group.hidden === false); return retList.filter((item) => !item.group.hidden);
}, [headStates, proxiesData, mode, col]); }, [headStates, proxiesData, mode, col]);
return { return {
renderList, renderList,
onProxies: mutateProxies, onProxies: mutateProxies,
onHeadState: setHeadState, onHeadState: setHeadState,
currentColumns: col,
}; };
}; };
function groupList<T = any>(list: T[], size: number): T[][] {
return list.reduce((p, n) => {
if (!p.length) return [[n]];
const i = p.length - 1;
if (p[i].length < size) {
p[i].push(n);
return p;
}
p.push([n]);
return p;
}, [] as T[][]);
}
@@ -51,7 +51,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
mutate("getClashConfig"); mutate("getClashConfig");
mutate("getVersion"); mutate("getVersion");
}, 100); }, 100);
Notice.success(t("Switched to _clash Core", { core: `${core}` }), 1000); // Notice.success(t("Switched to _clash Core", { core: `${core}` }), 1000);
} catch (err: any) { } catch (err: any) {
Notice.error(err?.message || err.toString()); Notice.error(err?.message || err.toString());
} }
+18 -3
View File
@@ -1,7 +1,7 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { styled, Typography } from "@mui/material"; import { styled, Typography, Switch } from "@mui/material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Notice } from "@/components/base"; import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { HotkeyInput } from "./hotkey-input"; import { HotkeyInput } from "./hotkey-input";
@@ -29,6 +29,9 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge();
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({}); const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
const [enableGlobalHotkey, setEnableHotkey] = useState(
verge?.enable_global_hotkey ?? true,
);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
@@ -69,7 +72,10 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
.filter(Boolean); .filter(Boolean);
try { try {
await patchVerge({ hotkeys }); await patchVerge({
hotkeys,
enable_global_hotkey: enableGlobalHotkey,
});
setOpen(false); setOpen(false);
} catch (err: any) { } catch (err: any) {
Notice.error(err.message || err.toString()); Notice.error(err.message || err.toString());
@@ -80,13 +86,22 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
<BaseDialog <BaseDialog
open={open} open={open}
title={t("Hotkey Setting")} title={t("Hotkey Setting")}
contentSx={{ width: 450, maxHeight: 330 }} contentSx={{ width: 450, maxHeight: 380 }}
okBtn={t("Save")} okBtn={t("Save")}
cancelBtn={t("Cancel")} cancelBtn={t("Cancel")}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<ItemWrapper style={{ marginBottom: 16 }}>
<Typography>{t("Enable Global Hotkey")}</Typography>
<Switch
edge="end"
checked={enableGlobalHotkey}
onChange={(e) => setEnableHotkey(e.target.checked)}
/>
</ItemWrapper>
{HOTKEY_FUNC.map((func) => ( {HOTKEY_FUNC.map((func) => (
<ItemWrapper key={func}> <ItemWrapper key={func}>
<Typography>{t(func)}</Typography> <Typography>{t(func)}</Typography>
+3 -3
View File
@@ -35,7 +35,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
open: () => { open: () => {
setOpen(true); setOpen(true);
setValues({ setValues({
stack: clash?.tun.stack ?? "mixed", stack: clash?.tun.stack ?? "gvisor",
device: clash?.tun.device ?? "Mihomo", device: clash?.tun.device ?? "Mihomo",
autoRoute: clash?.tun["auto-route"] ?? true, autoRoute: clash?.tun["auto-route"] ?? true,
autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true, autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true,
@@ -89,7 +89,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
size="small" size="small"
onClick={async () => { onClick={async () => {
let tun = { let tun = {
stack: "mixed", stack: "gvisor",
device: "Mihomo", device: "Mihomo",
"auto-route": true, "auto-route": true,
"auto-detect-interface": true, "auto-detect-interface": true,
@@ -98,7 +98,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
mtu: 1500, mtu: 1500,
}; };
setValues({ setValues({
stack: "mixed", stack: "gvisor",
device: "Mihomo", device: "Mihomo",
autoRoute: true, autoRoute: true,
autoDetectInterface: true, autoDetectInterface: true,
+31 -3
View File
@@ -1,7 +1,11 @@
import useSWR from "swr"; import useSWR, { mutate } from "swr";
import { useRef } from "react"; import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SettingsRounded } from "@mui/icons-material"; import {
SettingsRounded,
PlayArrowRounded,
PauseRounded,
} from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { DialogRef, Notice, Switch } from "@/components/base"; import { DialogRef, Notice, Switch } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp"; import { SettingList, SettingItem } from "./mods/setting-comp";
@@ -9,6 +13,7 @@ import { GuardState } from "./mods/guard-state";
import { SysproxyViewer } from "./mods/sysproxy-viewer"; import { SysproxyViewer } from "./mods/sysproxy-viewer";
import { TunViewer } from "./mods/tun-viewer"; import { TunViewer } from "./mods/tun-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { getSystemProxy, getAutotemProxy } from "@/services/cmds";
interface Props { interface Props {
onError?: (err: Error) => void; onError?: (err: Error) => void;
@@ -19,6 +24,9 @@ const SettingSystem = ({ onError }: Props) => {
const { verge, mutateVerge, patchVerge } = useVerge(); const { verge, mutateVerge, patchVerge } = useVerge();
const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy);
const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy);
const sysproxyRef = useRef<DialogRef>(null); const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null); const tunRef = useRef<DialogRef>(null);
@@ -27,13 +35,25 @@ const SettingSystem = ({ onError }: Props) => {
enable_auto_launch, enable_auto_launch,
enable_silent_start, enable_silent_start,
enable_system_proxy, enable_system_proxy,
proxy_auto_config,
} = verge ?? {}; } = verge ?? {};
const isProxyEnabled = proxy_auto_config
? autoproxy?.enable
: sysproxy?.enable;
const onSwitchFormat = (_e: any, value: boolean) => value; const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IVergeConfig>) => { const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false); mutateVerge({ ...verge, ...patch }, false);
}; };
const updateProxyStatus = async () => {
// 等待一小段时间让系统代理状态变化
await new Promise((resolve) => setTimeout(resolve, 100));
await mutate("getSystemProxy");
await mutate("getAutotemProxy");
};
return ( return (
<SettingList title={t("System Setting")}> <SettingList title={t("System Setting")}>
<SysproxyViewer ref={sysproxyRef} /> <SysproxyViewer ref={sysproxyRef} />
@@ -73,6 +93,11 @@ const SettingSystem = ({ onError }: Props) => {
icon={SettingsRounded} icon={SettingsRounded}
onClick={() => sysproxyRef.current?.open()} onClick={() => sysproxyRef.current?.open()}
/> />
{isProxyEnabled ? (
<PlayArrowRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseRounded sx={{ color: "error.main", mr: 1 }} />
)}
</> </>
} }
> >
@@ -82,7 +107,10 @@ const SettingSystem = ({ onError }: Props) => {
onCatch={onError} onCatch={onError}
onFormat={onSwitchFormat} onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_system_proxy: e })} onChange={(e) => onChangeData({ enable_system_proxy: e })}
onGuard={(e) => patchVerge({ enable_system_proxy: e })} onGuard={async (e) => {
await patchVerge({ enable_system_proxy: e });
await updateProxyStatus();
}}
> >
<Switch edge="end" /> <Switch edge="end" />
</GuardState> </GuardState>
@@ -0,0 +1,121 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Typography } from "@mui/material";
import {
exitApp,
openAppDir,
openCoreDir,
openLogsDir,
openDevTools,
} from "@/services/cmds";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { useVerge } from "@/hooks/use-verge";
import { version } from "@root/package.json";
import { DialogRef, Notice } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { ConfigViewer } from "./mods/config-viewer";
import { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer";
import { ThemeViewer } from "./mods/theme-viewer";
import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
interface Props {
onError?: (err: Error) => void;
}
const SettingVergeAdvanced = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null);
const themeRef = useRef<DialogRef>(null);
const layoutRef = useRef<DialogRef>(null);
const updateRef = useRef<DialogRef>(null);
const backupRef = useRef<DialogRef>(null);
const onCheckUpdate = async () => {
try {
const info = await checkUpdate();
if (!info?.available) {
Notice.success(t("Currently on the Latest Version"));
} else {
updateRef.current?.open();
}
} catch (err: any) {
Notice.error(err.message || err.toString());
}
};
return (
<SettingList title={t("Verge Advanced Setting")}>
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<SettingItem
onClick={() => backupRef.current?.open()}
label={t("Backup Setting")}
extra={
<TooltipIcon
title={t("Backup Setting Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
<SettingItem
onClick={() => configRef.current?.open()}
label={t("Runtime Config")}
/>
<SettingItem
onClick={openAppDir}
label={t("Open Conf Dir")}
extra={
<TooltipIcon
title={t("Open Conf Dir Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
<SettingItem onClick={openCoreDir} label={t("Open Core Dir")} />
<SettingItem onClick={openLogsDir} label={t("Open Logs Dir")} />
<SettingItem onClick={onCheckUpdate} label={t("Check for Updates")} />
<SettingItem onClick={openDevTools} label={t("Open Dev Tools")} />
<SettingItem
label={t("Lite Mode")}
extra={
<TooltipIcon title={t("Lite Mode Info")} sx={{ opacity: "0.7" }} />
}
onClick={() => patchVerge({ enable_lite_mode: true })}
/>
<SettingItem
onClick={() => {
exitApp();
}}
label={t("Exit")}
/>
<SettingItem label={t("Verge Version")}>
<Typography sx={{ py: "7px", pr: 1 }}>v{version}</Typography>
</SettingItem>
</SettingList>
);
};
export default SettingVergeAdvanced;
@@ -1,18 +1,9 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { Button, MenuItem, Select, Input, Typography } from "@mui/material"; import { Button, MenuItem, Select, Input } from "@mui/material";
import { import { copyClashEnv } from "@/services/cmds";
exitApp,
openAppDir,
openCoreDir,
openLogsDir,
openDevTools,
copyClashEnv,
} from "@/services/cmds";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { version } from "@root/package.json";
import { DialogRef, Notice } from "@/components/base"; import { DialogRef, Notice } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp"; import { SettingList, SettingItem } from "./mods/setting-comp";
import { ThemeModeSwitch } from "./mods/theme-mode-switch"; import { ThemeModeSwitch } from "./mods/theme-mode-switch";
@@ -49,7 +40,7 @@ const languageOptions = Object.entries(languages).map(([code, _]) => {
return { code, label: labels[code] }; return { code, label: labels[code] };
}); });
const SettingVerge = ({ onError }: Props) => { const SettingVergeBasic = ({ onError }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge(); const { verge, patchVerge, mutateVerge } = useVerge();
@@ -69,30 +60,17 @@ const SettingVerge = ({ onError }: Props) => {
const updateRef = useRef<DialogRef>(null); const updateRef = useRef<DialogRef>(null);
const backupRef = useRef<DialogRef>(null); const backupRef = useRef<DialogRef>(null);
const onChangeData = (patch: Partial<IVergeConfig>) => { const onChangeData = (patch: any) => {
mutateVerge({ ...verge, ...patch }, false); mutateVerge({ ...verge, ...patch }, false);
}; };
const onCheckUpdate = async () => {
try {
const info = await checkUpdate();
if (!info?.available) {
Notice.success(t("Currently on the Latest Version"));
} else {
updateRef.current?.open();
}
} catch (err: any) {
Notice.error(err.message || err.toString());
}
};
const onCopyClashEnv = useCallback(async () => { const onCopyClashEnv = useCallback(async () => {
await copyClashEnv(); await copyClashEnv();
Notice.success(t("Copy Success"), 1000); Notice.success(t("Copy Success"), 1000);
}, []); }, []);
return ( return (
<SettingList title={t("Verge Setting")}> <SettingList title={t("Verge Basic Setting")}>
<ThemeViewer ref={themeRef} /> <ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} /> <ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} /> <HotkeyViewer ref={hotkeyRef} />
@@ -181,7 +159,11 @@ const SettingVerge = ({ onError }: Props) => {
> >
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}> <Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
{routers.map((page: { label: string; path: string }) => { {routers.map((page: { label: string; path: string }) => {
return <MenuItem value={page.path}>{t(page.label)}</MenuItem>; return (
<MenuItem key={page.path} value={page.path}>
{t(page.label)}
</MenuItem>
);
})} })}
</Select> </Select>
</GuardState> </GuardState>
@@ -256,54 +238,8 @@ const SettingVerge = ({ onError }: Props) => {
onClick={() => hotkeyRef.current?.open()} onClick={() => hotkeyRef.current?.open()}
label={t("Hotkey Setting")} label={t("Hotkey Setting")}
/> />
<SettingItem
onClick={() => backupRef.current?.open()}
label={t("Backup Setting")}
extra={
<TooltipIcon
title={t("Backup Setting Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
<SettingItem
onClick={() => configRef.current?.open()}
label={t("Runtime Config")}
/>
<SettingItem
onClick={openAppDir}
label={t("Open Conf Dir")}
extra={
<TooltipIcon
title={t("Open Conf Dir Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
<SettingItem onClick={openCoreDir} label={t("Open Core Dir")} />
<SettingItem onClick={openLogsDir} label={t("Open Logs Dir")} />
<SettingItem onClick={onCheckUpdate} label={t("Check for Updates")} />
<SettingItem onClick={openDevTools} label={t("Open Dev Tools")} />
<SettingItem
onClick={() => {
exitApp();
}}
label={t("Exit")}
/>
<SettingItem label={t("Verge Version")}>
<Typography sx={{ py: "7px", pr: 1 }}>v{version}</Typography>
</SettingItem>
</SettingList> </SettingList>
); );
}; };
export default SettingVerge; export default SettingVergeBasic;
+19 -25
View File
@@ -34,34 +34,25 @@ const buildWSUrl = (server: string, secret: string, logLevel: LogLevel) => {
}; };
interface LogStore { interface LogStore {
logs: Record<LogLevel, ILogItem[]>; logs: ILogItem[];
clearLogs: (level?: LogLevel) => void; clearLogs: () => void;
appendLog: (level: LogLevel, log: ILogItem) => void; appendLog: (log: ILogItem) => void;
} }
const useLogStore = create<LogStore>( const useLogStore = create<LogStore>(
(set: (fn: (state: LogStore) => Partial<LogStore>) => void) => ({ (set: (fn: (state: LogStore) => Partial<LogStore>) => void) => ({
logs: { logs: [],
warning: [], clearLogs: () =>
info: [], set(() => ({
debug: [], logs: [],
error: [],
all: [],
},
clearLogs: (level?: LogLevel) =>
set((state: LogStore) => ({
logs: level
? { ...state.logs, [level]: [] }
: { warning: [], info: [], debug: [], error: [], all: [] },
})), })),
appendLog: (level: LogLevel, log: ILogItem) => appendLog: (log: ILogItem) =>
set((state: LogStore) => { set((state: LogStore) => {
const currentLogs = state.logs[level];
const newLogs = const newLogs =
currentLogs.length >= MAX_LOG_NUM state.logs.length >= MAX_LOG_NUM
? [...currentLogs.slice(1), log] ? [...state.logs.slice(1), log]
: [...currentLogs, log]; : [...state.logs, log];
return { logs: { ...state.logs, [level]: newLogs } }; return { logs: newLogs };
}), }),
}), }),
); );
@@ -84,7 +75,7 @@ export const useLogData = (logLevel: LogLevel) => {
if (!isActive) return; if (!isActive) return;
const data = JSON.parse(event.data) as ILogItem; const data = JSON.parse(event.data) as ILogItem;
const time = dayjs().format("MM-DD HH:mm:ss"); const time = dayjs().format("MM-DD HH:mm:ss");
appendLog(logLevel, { ...data, time }); appendLog({ ...data, time });
}, },
onerror() { onerror() {
if (!isActive) return; if (!isActive) return;
@@ -98,10 +89,13 @@ export const useLogData = (logLevel: LogLevel) => {
}; };
}, [clashInfo, enableLog, logLevel]); }, [clashInfo, enableLog, logLevel]);
return logs[logLevel]; // 根据当前选择的日志等级过滤日志
return logLevel === "all"
? logs
: logs.filter((log) => log.type.toLowerCase() === logLevel);
}; };
// 导出清空日志的方法 // 导出清空日志的方法
export const clearLogs = (level?: LogLevel) => { export const clearLogs = () => {
useLogStore.getState().clearLogs(level); useLogStore.getState().clearLogs();
}; };
+18 -3
View File
@@ -178,7 +178,8 @@
"Process": "عملية", "Process": "عملية",
"Time": "الوقت", "Time": "الوقت",
"Source": "المصدر", "Source": "المصدر",
"Destination IP": "عنوان IP الوجهة", "Destination": "عنوان IP الوجهة",
"DestinationPort": "ميناء الوجهة",
"Close Connection": "إغلاق الاتصال", "Close Connection": "إغلاق الاتصال",
"Rules": "القواعد", "Rules": "القواعد",
"Rule Provider": "مزود القواعد", "Rule Provider": "مزود القواعد",
@@ -326,6 +327,7 @@
"Default Latency Test Info": "يُستخدم فقط لاختبار طلب HTTP العميل. لن يؤثر على ملف التكوين", "Default Latency Test Info": "يُستخدم فقط لاختبار طلب HTTP العميل. لن يؤثر على ملف التكوين",
"Default Latency Timeout": "مهلة التأخير الافتراضية", "Default Latency Timeout": "مهلة التأخير الافتراضية",
"Hotkey Setting": "إعدادات الاختصارات", "Hotkey Setting": "إعدادات الاختصارات",
"Enable Global Hotkey": "تمكين مفتاح التشغيل السريع العالمي",
"open_or_close_dashboard": "فتح/إغلاق لوحة التحكم", "open_or_close_dashboard": "فتح/إغلاق لوحة التحكم",
"clash_mode_rule": "وضع القواعد", "clash_mode_rule": "وضع القواعد",
"clash_mode_global": "الوضع العالمي", "clash_mode_global": "الوضع العالمي",
@@ -368,7 +370,6 @@
"Permissions Granted Successfully for _clash Core": "تم منح الأذونات بنجاح لـ {{core}} Core", "Permissions Granted Successfully for _clash Core": "تم منح الأذونات بنجاح لـ {{core}} Core",
"Core Version Updated": "تم تحديث إصدار النواة", "Core Version Updated": "تم تحديث إصدار النواة",
"Clash Core Restarted": "تم إعادة تشغيل نواة Clash", "Clash Core Restarted": "تم إعادة تشغيل نواة Clash",
"Switched to _clash Core": "تم التبديل إلى {{core}} Core",
"GeoData Updated": "تم تحديث البيانات الجغرافية", "GeoData Updated": "تم تحديث البيانات الجغرافية",
"Currently on the Latest Version": "أنت على أحدث إصدار حاليًا", "Currently on the Latest Version": "أنت على أحدث إصدار حاليًا",
"Import Subscription Successful": "تم استيراد الاشتراك بنجاح", "Import Subscription Successful": "تم استيراد الاشتراك بنجاح",
@@ -434,5 +435,19 @@
"Rule Mode": "وضع القواعد", "Rule Mode": "وضع القواعد",
"Global Mode": "الوضع العالمي", "Global Mode": "الوضع العالمي",
"Direct Mode": "الوضع المباشر", "Direct Mode": "الوضع المباشر",
"Enable Tray Speed": "تفعيل سرعة التراي" "Enable Tray Speed": "تفعيل سرعة التراي",
"Lite Mode": "وضع الأداء الخفيف",
"Lite Mode Info": "إيقاف الواجهة الرسومية والإبقاء على تشغيل النواة",
"Config Validation Failed": "فشل التحقق من تكوين الاشتراك، يرجى فحص ملف التكوين، تم التراجع عن التغييرات، تفاصيل الخطأ:",
"Boot Config Validation Failed": "فشل التحقق من التكوين عند الإقلاع، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
"Core Change Config Validation Failed": "فشل التحقق من التكوين عند تغيير النواة، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
"Config Validation Process Terminated": "تم إنهاء عملية التحقق",
"Script Syntax Error": "خطأ في بناء جملة السكريبت، تم التراجع عن التغييرات",
"Script Missing Main": "خطأ في السكريبت، تم التراجع عن التغييرات",
"File Not Found": "الملف غير موجود، تم التراجع عن التغييرات",
"Script File Error": "خطأ في ملف السكريبت، تم التراجع عن التغييرات",
"Core Changed Successfully": "تم تغيير النواة بنجاح",
"Failed to Change Core": "فشل تغيير النواة",
"Verge Basic Setting": "الإعدادات الأساسية Verge",
"Verge Advanced Setting": "الإعدادات الأساسية Verge"
} }
+31 -3
View File
@@ -178,7 +178,8 @@
"Process": "Process", "Process": "Process",
"Time": "Time", "Time": "Time",
"Source": "Source", "Source": "Source",
"Destination IP": "Destination IP", "Destination": "Destination",
"DestinationPort": "Destination Port",
"Close Connection": "Close Connection", "Close Connection": "Close Connection",
"Rules": "Rules", "Rules": "Rules",
"Rule Provider": "Rule Provider", "Rule Provider": "Rule Provider",
@@ -276,7 +277,8 @@
"Open UWP tool": "Open UWP tool", "Open UWP tool": "Open UWP tool",
"Open UWP tool Info": "Since Windows 8, UWP apps (such as Microsoft Store) are restricted from directly accessing local host network services, and this tool can be used to bypass this restriction", "Open UWP tool Info": "Since Windows 8, UWP apps (such as Microsoft Store) are restricted from directly accessing local host network services, and this tool can be used to bypass this restriction",
"Update GeoData": "Update GeoData", "Update GeoData": "Update GeoData",
"Verge Setting": "Verge Setting", "Verge Basic Setting": "Verge Basic Setting",
"Verge Advanced Setting": "Verge Advanced Setting",
"Language": "Language", "Language": "Language",
"Theme Mode": "Theme Mode", "Theme Mode": "Theme Mode",
"theme.light": "Light", "theme.light": "Light",
@@ -328,6 +330,7 @@
"Default Latency Test Info": "Used for HTTP client request testing only and won't make a difference to the configuration file", "Default Latency Test Info": "Used for HTTP client request testing only and won't make a difference to the configuration file",
"Default Latency Timeout": "Default Latency Timeout", "Default Latency Timeout": "Default Latency Timeout",
"Hotkey Setting": "Hotkey Setting", "Hotkey Setting": "Hotkey Setting",
"Enable Global Hotkey": "Enable Global Hotkey",
"open_or_close_dashboard": "Open/Close Dashboard", "open_or_close_dashboard": "Open/Close Dashboard",
"clash_mode_rule": "Rule Mode", "clash_mode_rule": "Rule Mode",
"clash_mode_global": "Global Mode", "clash_mode_global": "Global Mode",
@@ -437,5 +440,30 @@
"Rule Mode": "Rule Mode", "Rule Mode": "Rule Mode",
"Global Mode": "Global Mode", "Global Mode": "Global Mode",
"Direct Mode": "Direct Mode", "Direct Mode": "Direct Mode",
"Enable Tray Speed": "Enable Tray Speed" "Enable Tray Speed": "Enable Tray Speed",
"Lite Mode": "Lightweight Mode",
"Lite Mode Info": "Close the GUI and keep only the kernel running",
"Config Validation Failed": "Subscription configuration validation failed. Please check the subscription configuration file; modifications have been rolled back.",
"Boot Config Validation Failed": "Boot subscription configuration validation failed. Started with the default configuration; please check the subscription configuration file.",
"Core Change Config Validation Failed": "Configuration validation failed when switching the kernel. Started with the default configuration; please check the subscription configuration file.",
"Config Validation Process Terminated": "The validation process has been terminated.",
"Script Syntax Error": "Script syntax error, changes reverted",
"Script Missing Main": "Script error, changes reverted",
"File Not Found": "File missing, changes reverted",
"Script File Error": "Script file error, changes reverted",
"Core Changed Successfully": "Core changed successfully",
"Failed to Change Core": "Failed to change core",
"YAML Syntax Error": "YAML syntax error, changes reverted",
"YAML Read Error": "YAML read error, changes reverted",
"YAML Mapping Error": "YAML mapping error, changes reverted",
"YAML Key Error": "YAML key error, changes reverted",
"YAML Error": "YAML error, changes reverted",
"Merge File Syntax Error": "Merge file syntax error, changes reverted",
"Merge File Mapping Error": "Merge file mapping error, changes reverted",
"Merge File Key Error": "Merge file key error, changes reverted",
"Merge File Error": "Merge file error, changes reverted",
"Validate YAML File": "Validate YAML File",
"Validate Merge File": "Validate Merge File",
"Validation Success": "Validation Success",
"Validation Failed": "Validation Failed"
} }
+18 -3
View File
@@ -178,7 +178,8 @@
"Process": "فرآیند", "Process": "فرآیند",
"Time": "زمان", "Time": "زمان",
"Source": "منبع", "Source": "منبع",
"Destination IP": "آدرس IP مقصد", "Destination": "آدرس IP مقصد",
"DestinationPort": "بندر هدف",
"Close Connection": "بستن اتصال", "Close Connection": "بستن اتصال",
"Rules": "قوانین", "Rules": "قوانین",
"Rule Provider": "تأمین‌کننده قانون", "Rule Provider": "تأمین‌کننده قانون",
@@ -328,6 +329,7 @@
"Default Latency Test Info": "فقط برای تست درخواست‌های کلاینت HTTP استفاده می‌شود و بر فایل پیکربندی تأثیری نخواهد داشت", "Default Latency Test Info": "فقط برای تست درخواست‌های کلاینت HTTP استفاده می‌شود و بر فایل پیکربندی تأثیری نخواهد داشت",
"Default Latency Timeout": "زمان انتظار تأخیر پیش‌فرض", "Default Latency Timeout": "زمان انتظار تأخیر پیش‌فرض",
"Hotkey Setting": "تنظیمات کلیدهای میانبر", "Hotkey Setting": "تنظیمات کلیدهای میانبر",
"Enable Global Hotkey": "فعال کردن کلید میانبر سراسری",
"open_or_close_dashboard": "باز/بستن داشبورد", "open_or_close_dashboard": "باز/بستن داشبورد",
"clash_mode_rule": "حالت قانون", "clash_mode_rule": "حالت قانون",
"clash_mode_global": "حالت جهانی", "clash_mode_global": "حالت جهانی",
@@ -371,7 +373,6 @@
"Permissions Granted Successfully for _clash Core": "مجوزها با موفقیت برای هسته {{core}} اعطا شد", "Permissions Granted Successfully for _clash Core": "مجوزها با موفقیت برای هسته {{core}} اعطا شد",
"Core Version Updated": "نسخه هسته به‌روزرسانی شد", "Core Version Updated": "نسخه هسته به‌روزرسانی شد",
"Clash Core Restarted": "هسته Clash مجدداً راه‌اندازی شد", "Clash Core Restarted": "هسته Clash مجدداً راه‌اندازی شد",
"Switched to _clash Core": "تغییر به هسته {{core}}",
"GeoData Updated": "GeoData به‌روزرسانی شد", "GeoData Updated": "GeoData به‌روزرسانی شد",
"Currently on the Latest Version": "در حال حاضر در آخرین نسخه", "Currently on the Latest Version": "در حال حاضر در آخرین نسخه",
"Import Subscription Successful": "وارد کردن اشتراک با موفقیت انجام شد", "Import Subscription Successful": "وارد کردن اشتراک با موفقیت انجام شد",
@@ -431,5 +432,19 @@
"Rule Mode": "حالت قوانین", "Rule Mode": "حالت قوانین",
"Global Mode": "حالت جهانی", "Global Mode": "حالت جهانی",
"Direct Mode": "حالت مستقیم", "Direct Mode": "حالت مستقیم",
"Enable Tray Speed": "فعال کردن سرعت ترای" "Enable Tray Speed": "فعال کردن سرعت ترای",
"Lite Mode": "در فارسی",
"Lite Mode Info": "رابط کاربری گرافیکی را ببندید و فقط هسته را در حال اجرا نگه دارید",
"Config Validation Failed": "اعتبارسنجی پیکربندی اشتراک ناموفق بود، فایل پیکربندی را بررسی کنید، تغییرات برگشت داده شد، جزئیات خطا:",
"Boot Config Validation Failed": "اعتبارسنجی پیکربندی هنگام راه‌اندازی ناموفق بود، پیکربندی پیش‌فرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
"Core Change Config Validation Failed": "اعتبارسنجی پیکربندی هنگام تغییر هسته ناموفق بود، پیکربندی پیش‌فرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
"Config Validation Process Terminated": "فرآیند اعتبارسنجی متوقف شد",
"Script Syntax Error": "خطای نحوی اسکریپت، تغییرات برگشت داده شد",
"Script Missing Main": "خطای اسکریپت، تغییرات برگشت داده شد",
"File Not Found": "فایل یافت نشد، تغییرات برگشت داده شد",
"Script File Error": "خطای فایل اسکریپت، تغییرات برگشت داده شد",
"Core Changed Successfully": "هسته با موفقیت تغییر کرد",
"Failed to Change Core": "تغییر هسته ناموفق بود",
"Verge Basic Setting": "تنظیمات پایه Verge",
"Verge Advanced Setting": "تنظیمات پیشرفته Verge"
} }
+18 -3
View File
@@ -210,7 +210,8 @@
"Process": "Proses", "Process": "Proses",
"Time": "Waktu", "Time": "Waktu",
"Source": "Sumber", "Source": "Sumber",
"Destination IP": "IP Tujuan", "Destination": "IP Tujuan",
"DestinationPort": "Port Tujuan",
"Close Connection": "Tutup Koneksi", "Close Connection": "Tutup Koneksi",
"Rules": "Aturan", "Rules": "Aturan",
"Rule Provider": "Penyedia Aturan", "Rule Provider": "Penyedia Aturan",
@@ -359,6 +360,7 @@
"Default Latency Test Info": "Digunakan hanya untuk pengujian permintaan klien HTTP dan tidak akan mempengaruhi file konfigurasi", "Default Latency Test Info": "Digunakan hanya untuk pengujian permintaan klien HTTP dan tidak akan mempengaruhi file konfigurasi",
"Default Latency Timeout": "Waktu Habis Latensi Default", "Default Latency Timeout": "Waktu Habis Latensi Default",
"Hotkey Setting": "Pengaturan Pintasan", "Hotkey Setting": "Pengaturan Pintasan",
"Enable Global Hotkey": "Aktifkan Hotkey Global",
"open_or_close_dashboard": "Buka/Tutup Dasbor", "open_or_close_dashboard": "Buka/Tutup Dasbor",
"clash_mode_rule": "Mode Aturan", "clash_mode_rule": "Mode Aturan",
"clash_mode_global": "Mode Global", "clash_mode_global": "Mode Global",
@@ -402,7 +404,6 @@
"Permissions Granted Successfully for _clash Core": "Izin Berhasil Diberikan untuk Core {{core}}", "Permissions Granted Successfully for _clash Core": "Izin Berhasil Diberikan untuk Core {{core}}",
"Core Version Updated": "Versi Core Diperbarui", "Core Version Updated": "Versi Core Diperbarui",
"Clash Core Restarted": "Core Clash Dimulai Ulang", "Clash Core Restarted": "Core Clash Dimulai Ulang",
"Switched to _clash Core": "Beralih ke Core {{core}}",
"GeoData Updated": "GeoData Diperbarui", "GeoData Updated": "GeoData Diperbarui",
"Currently on the Latest Version": "Saat ini pada Versi Terbaru", "Currently on the Latest Version": "Saat ini pada Versi Terbaru",
"Import Subscription Successful": "Berlangganan Berhasil Diimpor", "Import Subscription Successful": "Berlangganan Berhasil Diimpor",
@@ -430,5 +431,19 @@
"Confirm to restore this backup file?": "Konfirmasi untuk memulihkan file cadangan ini?", "Confirm to restore this backup file?": "Konfirmasi untuk memulihkan file cadangan ini?",
"Restore Success, App will restart in 1s": "Pemulihan Berhasil, Aplikasi akan dimulai ulang dalam 1 detik", "Restore Success, App will restart in 1s": "Pemulihan Berhasil, Aplikasi akan dimulai ulang dalam 1 detik",
"Failed to fetch backup files": "Gagal mengambil file cadangan", "Failed to fetch backup files": "Gagal mengambil file cadangan",
"Enable Tray Speed": "Aktifkan Tray Speed" "Enable Tray Speed": "Aktifkan Tray Speed",
"Lite Mode": "Mode Ringan",
"Lite Mode Info": "Tutup GUI dan biarkan hanya kernel yang berjalan",
"Config Validation Failed": "Validasi konfigurasi langganan gagal, periksa file konfigurasi, perubahan dibatalkan, detail kesalahan:",
"Boot Config Validation Failed": "Validasi konfigurasi saat boot gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
"Core Change Config Validation Failed": "Validasi konfigurasi saat ganti inti gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
"Config Validation Process Terminated": "Proses validasi dihentikan",
"Script Syntax Error": "Kesalahan sintaks skrip, perubahan dibatalkan",
"Script Missing Main": "Kesalahan skrip, perubahan dibatalkan",
"File Not Found": "File tidak ditemukan, perubahan dibatalkan",
"Script File Error": "Kesalahan file skrip, perubahan dibatalkan",
"Core Changed Successfully": "Inti berhasil diubah",
"Failed to Change Core": "Gagal mengubah inti",
"Verge Basic Setting": "Pengaturan Dasar Verge",
"Verge Advanced Setting": "Pengaturan Lanjutan Verge"
} }
+18 -3
View File
@@ -178,7 +178,8 @@
"Process": "Процесс", "Process": "Процесс",
"Time": "Время подключения", "Time": "Время подключения",
"Source": "Исходный адрес", "Source": "Исходный адрес",
"Destination IP": "IP-адрес назначения", "Destination": "IP-адрес назначения",
"DestinationPort": "Целевой порт",
"Close Connection": "Закрыть соединение", "Close Connection": "Закрыть соединение",
"Rules": "Правила", "Rules": "Правила",
"Rule Provider": "Провайдер правило", "Rule Provider": "Провайдер правило",
@@ -328,6 +329,7 @@
"Default Latency Test Info": "Используется только для тестирования HTTP-запросов клиента и не влияет на файл конфигурации", "Default Latency Test Info": "Используется только для тестирования HTTP-запросов клиента и не влияет на файл конфигурации",
"Default Latency Timeout": "Таймаут задержки по умолчанию", "Default Latency Timeout": "Таймаут задержки по умолчанию",
"Hotkey Setting": "Настройки клавиатурных сокращений", "Hotkey Setting": "Настройки клавиатурных сокращений",
"Enable Global Hotkey": "Включить глобальную горячую клавишу",
"open_or_close_dashboard": "Открыть/Закрыть панель управления", "open_or_close_dashboard": "Открыть/Закрыть панель управления",
"clash_mode_rule": "Режим правил", "clash_mode_rule": "Режим правил",
"clash_mode_global": "Глобальный режим", "clash_mode_global": "Глобальный режим",
@@ -371,7 +373,6 @@
"Permissions Granted Successfully for _clash Core": "Разрешения успешно предоставлены для ядра {{core}}", "Permissions Granted Successfully for _clash Core": "Разрешения успешно предоставлены для ядра {{core}}",
"Core Version Updated": "Обновлена версия ядра", "Core Version Updated": "Обновлена версия ядра",
"Clash Core Restarted": "Clash ядра перезапущено", "Clash Core Restarted": "Clash ядра перезапущено",
"Switched to _clash Core": "Переключено на ядра {{core}}",
"GeoData Updated": "GeoData Обновлена", "GeoData Updated": "GeoData Обновлена",
"Currently on the Latest Version": "В настоящее время используется последняя версия", "Currently on the Latest Version": "В настоящее время используется последняя версия",
"Import subscription successful": "Импорт подписки успешно", "Import subscription successful": "Импорт подписки успешно",
@@ -431,5 +432,19 @@
"Rule Mode": "Режим правил", "Rule Mode": "Режим правил",
"Global Mode": "Глобальный режим", "Global Mode": "Глобальный режим",
"Direct Mode": "Прямой режим", "Direct Mode": "Прямой режим",
"Enable Tray Speed": "Включить скорость в лотке" "Enable Tray Speed": "Включить скорость в лотке",
"Lite Mode": "Облегченный режим",
"Lite Mode Info": "Закройте графический интерфейс и оставьте работать только ядро",
"Config Validation Failed": "Ошибка проверки конфигурации подписки, проверьте файл конфигурации, изменения отменены, ошибка:",
"Boot Config Validation Failed": "Ошибка проверки конфигурации при запуске, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
"Core Change Config Validation Failed": "Ошибка проверки конфигурации при смене ядра, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
"Config Validation Process Terminated": "Процесс проверки прерван",
"Script Syntax Error": "Ошибка синтаксиса скрипта, изменения отменены",
"Script Missing Main": "Ошибка скрипта, изменения отменены",
"File Not Found": "Файл не найден, изменения отменены",
"Script File Error": "Ошибка файла скрипта, изменения отменены",
"Core Changed Successfully": "Ядро успешно сменено",
"Failed to Change Core": "Не удалось сменить ядро",
"Verge Basic Setting": "Основные настройки Verge",
"Verge Advanced Setting": "Расширенные настройки Verge"
} }
+18 -3
View File
@@ -178,7 +178,8 @@
"Process": "Процесс", "Process": "Процесс",
"Time": "Тоташу вакыты", "Time": "Тоташу вакыты",
"Source": "Чыганак адресы", "Source": "Чыганак адресы",
"Destination IP": "Максат IP-адресы", "Destination": "Максат IP-адресы",
"DestinationPort": "Барасы порты",
"Close Connection": "Тоташуны ябу", "Close Connection": "Тоташуны ябу",
"Rules": "Кагыйдәләр", "Rules": "Кагыйдәләр",
"Rule Provider": "Кагыйдә провайдеры", "Rule Provider": "Кагыйдә провайдеры",
@@ -327,6 +328,7 @@
"Default Latency Test Info": "Бу фәкать клиентның HTTP сораулары тесты өчен кулланыла, конфигурация файлына йогынты ясамый", "Default Latency Test Info": "Бу фәкать клиентның HTTP сораулары тесты өчен кулланыла, конфигурация файлына йогынты ясамый",
"Default Latency Timeout": "Тоткарлануның стандарт таймауты", "Default Latency Timeout": "Тоткарлануның стандарт таймауты",
"Hotkey Setting": "Клавиатура төймәләре (hotkey) көйләүләре", "Hotkey Setting": "Клавиатура төймәләре (hotkey) көйләүләре",
"Enable Global Hotkey": "Глобаль Хоткейны кушу",
"open_or_close_dashboard": "Панельне ачу/ябу", "open_or_close_dashboard": "Панельне ачу/ябу",
"clash_mode_rule": "Кагыйдәләр режимы", "clash_mode_rule": "Кагыйдәләр режимы",
"clash_mode_global": "Глобаль режим", "clash_mode_global": "Глобаль режим",
@@ -370,7 +372,6 @@
"Permissions Granted Successfully for _clash Core": "{{core}} ядросы өчен рөхсәтләр бирелде", "Permissions Granted Successfully for _clash Core": "{{core}} ядросы өчен рөхсәтләр бирелде",
"Core Version Updated": "Ядро версиясе яңартылды", "Core Version Updated": "Ядро версиясе яңартылды",
"Clash Core Restarted": "Clash ядросы яңадан башланды", "Clash Core Restarted": "Clash ядросы яңадан башланды",
"Switched to _clash Core": "{{core}} ядросына күчү башкарылды",
"GeoData Updated": "GeoData яңартылды", "GeoData Updated": "GeoData яңартылды",
"Currently on the Latest Version": "Сездә иң соңгы версия урнаштырылган", "Currently on the Latest Version": "Сездә иң соңгы версия урнаштырылган",
"Import subscription successful": "Подписка уңышлы импортланды", "Import subscription successful": "Подписка уңышлы импортланды",
@@ -430,5 +431,19 @@
"Rule Mode": "Кагыйдә режимы", "Rule Mode": "Кагыйдә режимы",
"Global Mode": "Глобаль режим", "Global Mode": "Глобаль режим",
"Direct Mode": "Туры режим", "Direct Mode": "Туры режим",
"Enable Tray Speed": "Трей скоростьне үстерү" "Enable Tray Speed": "Трей скоростьне үстерү",
"Lite Mode": "Җиңел Режим",
"Lite Mode Info": "GUI-ны ябыгыз һәм бары тик төшне генә эшләтеп калдырыгыз",
"Config Validation Failed": "Язылу көйләү тикшерүе уңышсыз, көйләү файлын тикшерегез, үзгәрешләр кире кайтарылды, хата:",
"Boot Config Validation Failed": "Йөкләү вакытында көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
"Core Change Config Validation Failed": "Ядро алыштырганда көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
"Config Validation Process Terminated": "Тикшерү процессы туктатылды",
"Script Syntax Error": "Скрипт синтаксик хатасы, үзгәрешләр кире кайтарылды",
"Script Missing Main": "Скрипт хатасы, үзгәрешләр кире кайтарылды",
"File Not Found": "Файл табылмады, үзгәрешләр кире кайтарылды",
"Script File Error": "Скрипт файлы хатасы, үзгәрешләр кире кайтарылды",
"Core Changed Successfully": "Ядро уңышлы алыштырылды",
"Failed to Change Core": "Ядро алыштыру уңышсыз булды",
"Verge Basic Setting": "Verge Төп көйләүләр",
"Verge Advanced Setting": "Verge Киңәйтелгән көйләүләр"
} }
+33 -5
View File
@@ -135,9 +135,9 @@
"Hidden": "隐藏代理组", "Hidden": "隐藏代理组",
"Group Name Required": "代理组名称不能为空", "Group Name Required": "代理组名称不能为空",
"Group Name Already Exists": "代理组名称已存在", "Group Name Already Exists": "代理组名称已存在",
"Extend Config": "扩展配置", "Extend Config": "扩展覆写配置",
"Extend Script": "扩展脚本", "Extend Script": "扩展脚本",
"Global Merge": "全局扩展配置", "Global Merge": "全局扩展覆写配置",
"Global Script": "全局扩展脚本", "Global Script": "全局扩展脚本",
"Type": "类型", "Type": "类型",
"Name": "名称", "Name": "名称",
@@ -178,7 +178,8 @@
"Process": "进程", "Process": "进程",
"Time": "连接时间", "Time": "连接时间",
"Source": "源地址", "Source": "源地址",
"Destination IP": "目标地址", "Destination": "目标地址",
"DestinationPort": "目标端口",
"Close Connection": "关闭连接", "Close Connection": "关闭连接",
"Rules": "规则", "Rules": "规则",
"Rule Provider": "规则集合", "Rule Provider": "规则集合",
@@ -328,6 +329,7 @@
"Default Latency Test Info": "仅用于 HTTP 客户端请求测试,不会对配置文件产生影响", "Default Latency Test Info": "仅用于 HTTP 客户端请求测试,不会对配置文件产生影响",
"Default Latency Timeout": "测试超时时间", "Default Latency Timeout": "测试超时时间",
"Hotkey Setting": "热键设置", "Hotkey Setting": "热键设置",
"Enable Global Hotkey": "启用全局热键",
"open_or_close_dashboard": "打开/关闭面板", "open_or_close_dashboard": "打开/关闭面板",
"clash_mode_rule": "规则模式", "clash_mode_rule": "规则模式",
"clash_mode_global": "全局模式", "clash_mode_global": "全局模式",
@@ -371,7 +373,6 @@
"Permissions Granted Successfully for _clash Core": "{{core}} 内核授权成功", "Permissions Granted Successfully for _clash Core": "{{core}} 内核授权成功",
"Core Version Updated": "内核版本已更新", "Core Version Updated": "内核版本已更新",
"Clash Core Restarted": "已重启 Clash 内核", "Clash Core Restarted": "已重启 Clash 内核",
"Switched to _clash Core": "已切换至 {{core}} 内核",
"GeoData Updated": "已更新 GeoData", "GeoData Updated": "已更新 GeoData",
"Currently on the Latest Version": "当前已是最新版本", "Currently on the Latest Version": "当前已是最新版本",
"Import Subscription Successful": "导入订阅成功", "Import Subscription Successful": "导入订阅成功",
@@ -431,5 +432,32 @@
"Rule Mode": "规则模式", "Rule Mode": "规则模式",
"Global Mode": "全局模式", "Global Mode": "全局模式",
"Direct Mode": "直连模式", "Direct Mode": "直连模式",
"Enable Tray Speed": "启用托盘速率" "Enable Tray Speed": "启用托盘速率",
"Lite Mode": "轻量模式",
"Lite Mode Info": "关闭GUI界面,仅保留内核运行",
"Config Validation Failed": "订阅配置校验失败,请检查订阅配置文件,变更已撤销,错误详情:",
"Boot Config Validation Failed": "启动订阅配置校验失败,已使用默认配置启动;请检查订阅配置文件,错误详情:",
"Core Change Config Validation Failed": "切换内核时配置校验失败,已使用默认配置启动;请检查订阅配置文件,错误详情:",
"Config Validation Process Terminated": "验证进程被终止",
"Script Syntax Error": "脚本语法错误,变更已撤销",
"Script Missing Main": "脚本错误,变更已撤销",
"File Not Found": "文件丢失,变更已撤销",
"Script File Error": "脚本文件错误,变更已撤销",
"Core Changed Successfully": "内核切换成功",
"Failed to Change Core": "无法切换内核",
"YAML Syntax Error": "YAML语法错误,变更已撤销",
"YAML Read Error": "YAML读取错误,变更已撤销",
"YAML Mapping Error": "YAML映射错误,变更已撤销",
"YAML Key Error": "YAML键错误,变更已撤销",
"YAML Error": "YAML错误,变更已撤销",
"Merge File Syntax Error": "覆写文件语法错误,变更已撤销",
"Merge File Mapping Error": "覆写文件映射错误,变更已撤销",
"Merge File Key Error": "覆写文件键错误,变更已撤销",
"Merge File Error": "覆写文件错误,变更已撤销",
"Validate YAML File": "验证YAML文件",
"Validate Merge File": "验证覆写文件",
"Validation Success": "验证成功",
"Validation Failed": "验证失败",
"Verge Basic Setting": "Verge 基础设置",
"Verge Advanced Setting": "Verge 高级设置"
} }
+166 -67
View File
@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import i18next from "i18next"; import i18next from "i18next";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { SWRConfig, mutate } from "swr"; import { SWRConfig, mutate } from "swr";
import { useEffect } from "react"; import { useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useRoutes, useNavigate } from "react-router-dom"; import { useLocation, useRoutes, useNavigate } from "react-router-dom";
import { List, Paper, ThemeProvider, SvgIcon } from "@mui/material"; import { List, Paper, ThemeProvider, SvgIcon } from "@mui/material";
@@ -27,6 +27,7 @@ import { getPortableFlag } from "@/services/cmds";
import React from "react"; import React from "react";
import { TransitionGroup, CSSTransition } from "react-transition-group"; import { TransitionGroup, CSSTransition } from "react-transition-group";
import { useListen } from "@/hooks/use-listen"; import { useListen } from "@/hooks/use-listen";
import { listen } from "@tauri-apps/api/event";
const appWindow = getCurrentWebviewWindow(); const appWindow = getCurrentWebviewWindow();
export let portableFlag = false; export let portableFlag = false;
@@ -35,73 +36,178 @@ dayjs.extend(relativeTime);
const OS = getSystem(); const OS = getSystem();
// 通知处理函数
const handleNoticeMessage = (
status: string,
msg: string,
t: (key: string) => string,
navigate: (path: string, options?: any) => void,
) => {
console.log("[通知监听] 收到消息:", status, msg);
switch (status) {
case "import_sub_url::ok":
navigate("/profile", { state: { current: msg } });
Notice.success(t("Import Subscription Successful"));
break;
case "import_sub_url::error":
navigate("/profile");
Notice.error(msg);
break;
case "set_config::error":
Notice.error(msg);
break;
case "config_validate::boot_error":
Notice.error(`${t("Boot Config Validation Failed")} ${msg}`);
break;
case "config_validate::core_change":
Notice.error(`${t("Core Change Config Validation Failed")} ${msg}`);
break;
case "config_validate::error":
Notice.error(`${t("Config Validation Failed")} ${msg}`);
break;
case "config_validate::process_terminated":
Notice.error(t("Config Validation Process Terminated"));
break;
case "config_validate::stdout_error":
Notice.error(`${t("Config Validation Failed")} ${msg}`);
break;
case "config_validate::script_error":
Notice.error(`${t("Script File Error")} ${msg}`);
break;
case "config_validate::script_syntax_error":
Notice.error(`${t("Script Syntax Error")} ${msg}`);
break;
case "config_validate::script_missing_main":
Notice.error(`${t("Script Missing Main")} ${msg}`);
break;
case "config_validate::file_not_found":
Notice.error(`${t("File Not Found")} ${msg}`);
break;
case "config_validate::yaml_syntax_error":
Notice.error(`${t("YAML Syntax Error")} ${msg}`);
break;
case "config_validate::yaml_read_error":
Notice.error(`${t("YAML Read Error")} ${msg}`);
break;
case "config_validate::yaml_mapping_error":
Notice.error(`${t("YAML Mapping Error")} ${msg}`);
break;
case "config_validate::yaml_key_error":
Notice.error(`${t("YAML Key Error")} ${msg}`);
break;
case "config_validate::yaml_error":
Notice.error(`${t("YAML Error")} ${msg}`);
break;
case "config_validate::merge_syntax_error":
Notice.error(`${t("Merge File Syntax Error")} ${msg}`);
break;
case "config_validate::merge_mapping_error":
Notice.error(`${t("Merge File Mapping Error")} ${msg}`);
break;
case "config_validate::merge_key_error":
Notice.error(`${t("Merge File Key Error")} ${msg}`);
break;
case "config_validate::merge_error":
Notice.error(`${t("Merge File Error")} ${msg}`);
break;
case "config_core::change_success":
Notice.success(`${t("Core Changed Successfully")}: ${msg}`);
break;
case "config_core::change_error":
Notice.error(`${t("Failed to Change Core")}: ${msg}`);
break;
}
};
const Layout = () => { const Layout = () => {
const mode = useThemeMode(); const mode = useThemeMode();
const isDark = mode === "light" ? false : true; const isDark = mode === "light" ? false : true;
const { t } = useTranslation(); const { t } = useTranslation();
const { theme } = useCustomTheme(); const { theme } = useCustomTheme();
const { verge } = useVerge(); const { verge } = useVerge();
const { language, start_page } = verge || {}; const { language, start_page } = verge ?? {};
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const routersEles = useRoutes(routers); const routersEles = useRoutes(routers);
const { addListener, setupCloseListener } = useListen(); const { addListener, setupCloseListener } = useListen();
if (!routersEles) return null;
setupCloseListener(); const handleNotice = useCallback(
(payload: [string, string]) => {
const [status, msg] = payload;
handleNoticeMessage(status, msg, t, navigate);
},
[t, navigate],
);
// 设置监听器
useEffect(() => { useEffect(() => {
addListener("verge://refresh-clash-config", async () => { const listeners = [
// the clash info may be updated // 配置更新监听
await getAxios(true); addListener("verge://refresh-clash-config", async () => {
mutate("getProxies"); await getAxios(true);
mutate("getVersion"); mutate("getProxies");
mutate("getClashConfig"); mutate("getVersion");
mutate("getProxyProviders"); mutate("getClashConfig");
}); mutate("getProxyProviders");
}),
// update the verge config // verge 配置更新监听
addListener("verge://refresh-verge-config", () => mutate("getVergeConfig")); addListener("verge://refresh-verge-config", () =>
mutate("getVergeConfig"),
),
// 设置提示监听 // 通知消息监听
addListener("verge://notice-message", ({ payload }) => { addListener("verge://notice-message", ({ payload }) =>
const [status, msg] = payload as [string, string]; handleNotice(payload as [string, string]),
switch (status) { ),
case "import_sub_url::ok": ];
navigate("/profile", { state: { current: msg } });
Notice.success(t("Import Subscription Successful")); // 设置窗口显示/隐藏监听
break; const setupWindowListeners = async () => {
case "import_sub_url::error": const [hideUnlisten, showUnlisten] = await Promise.all([
navigate("/profile"); listen("verge://hide-window", () => appWindow.hide()),
Notice.error(msg); listen("verge://show-window", () => appWindow.show()),
break; ]);
case "set_config::error":
Notice.error(msg);
break;
default:
break;
}
});
setTimeout(async () => { return () => {
portableFlag = await getPortableFlag(); hideUnlisten();
await appWindow.unminimize(); showUnlisten();
await appWindow.show(); };
await appWindow.setFocus(); };
}, 50);
}, []);
// 初始化
setupCloseListener();
const cleanupWindow = setupWindowListeners();
// 清理函数
return () => {
// 清理主要监听器
listeners.forEach((listener) => {
if (typeof listener.then === "function") {
listener.then((unlisten) => unlisten());
}
});
// 清理窗口监听器
cleanupWindow.then((cleanup) => cleanup());
};
}, [handleNotice]);
// 语言和起始页设置
useEffect(() => { useEffect(() => {
if (language) { if (language) {
dayjs.locale(language === "zh" ? "zh-cn" : language); dayjs.locale(language === "zh" ? "zh-cn" : language);
i18next.changeLanguage(language); i18next.changeLanguage(language);
} }
}, [language]);
useEffect(() => {
if (start_page) { if (start_page) {
navigate(start_page); navigate(start_page, { replace: true });
} }
}, [language, start_page]); }, [start_page]);
if (!routersEles) return null;
return ( return (
<SWRConfig value={{ errorRetryCount: 3 }}> <SWRConfig value={{ errorRetryCount: 3 }}>
@@ -111,29 +217,24 @@ const Layout = () => {
elevation={0} elevation={0}
className={`${OS} layout`} className={`${OS} layout`}
onContextMenu={(e) => { onContextMenu={(e) => {
// only prevent it on Windows
const validList = ["input", "textarea"];
const target = e.currentTarget;
if ( if (
OS === "windows" && OS === "windows" &&
!( !["input", "textarea"].includes(
validList.includes(target.tagName.toLowerCase()) || e.currentTarget.tagName.toLowerCase(),
target.isContentEditable ) &&
) !e.currentTarget.isContentEditable
) { ) {
e.preventDefault(); e.preventDefault();
} }
}} }}
sx={[ sx={[
({ palette }) => ({ ({ palette }) => ({ bgcolor: palette.background.paper }),
bgcolor: palette.background.paper,
}),
OS === "linux" OS === "linux"
? { ? {
borderRadius: "8px", borderRadius: "8px",
border: "2px solid var(--divider-color)", border: "1px solid var(--divider-color)",
width: "calc(100vw - 4px)", width: "calc(100vw - 0px)",
height: "calc(100vh - 4px)", height: "calc(100vh - 0px)",
} }
: {}, : {},
]} ]}
@@ -160,7 +261,7 @@ const Layout = () => {
/> />
<LogoSvg fill={isDark ? "white" : "black"} /> <LogoSvg fill={isDark ? "white" : "black"} />
</div> </div>
{<UpdateButton className="the-newbtn" />} <UpdateButton className="the-newbtn" />
</div> </div>
<List className="the-menu"> <List className="the-menu">
@@ -181,16 +282,14 @@ const Layout = () => {
</div> </div>
<div className="layout__right"> <div className="layout__right">
{ <div className="the-bar">
<div className="the-bar"> <div
<div className="the-dragbar"
className="the-dragbar" data-tauri-drag-region="true"
data-tauri-drag-region="true" style={{ width: "100%" }}
style={{ width: "100%" }} />
></div> {OS !== "macos" && <LayoutControl />}
{OS !== "macos" && <LayoutControl />} </div>
</div>
}
<TransitionGroup className="the-content"> <TransitionGroup className="the-content">
<CSSTransition <CSSTransition
+91 -56
View File
@@ -1,9 +1,14 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState, useCallback } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { Box, Button, IconButton, MenuItem } from "@mui/material"; import { Box, Button, IconButton, MenuItem } from "@mui/material";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TableChartRounded, TableRowsRounded } from "@mui/icons-material"; import {
TableChartRounded,
TableRowsRounded,
PlayCircleOutlineRounded,
PauseCircleOutlineRounded,
} from "@mui/icons-material";
import { closeAllConnections } from "@/services/api"; import { closeAllConnections } from "@/services/api";
import { useConnectionSetting } from "@/services/states"; import { useConnectionSetting } from "@/services/states";
import { useClashInfo } from "@/hooks/use-clash"; import { useClashInfo } from "@/hooks/use-clash";
@@ -15,7 +20,10 @@ import {
ConnectionDetailRef, ConnectionDetailRef,
} from "@/components/connection/connection-detail"; } from "@/components/connection/connection-detail";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { BaseSearchBox } from "@/components/base/base-search-box"; import {
BaseSearchBox,
type SearchState,
} from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select"; import { BaseStyledSelect } from "@/components/base/base-styled-select";
import useSWRSubscription from "swr/subscription"; import useSWRSubscription from "swr/subscription";
import { createSockette } from "@/utils/websocket"; import { createSockette } from "@/utils/websocket";
@@ -55,6 +63,9 @@ const ConnectionsPage = () => {
list.sort((a, b) => b.curDownload! - a.curDownload!), list.sort((a, b) => b.curDownload! - a.curDownload!),
}; };
const [isPaused, setIsPaused] = useState(false);
const [frozenData, setFrozenData] = useState<IConnections | null>(null);
const { data: connData = initConn } = useSWRSubscription< const { data: connData = initConn } = useSWRSubscription<
IConnections, IConnections,
any, any,
@@ -67,9 +78,7 @@ const ConnectionsPage = () => {
`ws://${server}/connections?token=${encodeURIComponent(secret)}`, `ws://${server}/connections?token=${encodeURIComponent(secret)}`,
{ {
onmessage(event) { onmessage(event) {
// meta v1.15.0 出现 data.connections 为 null 的情况
const data = JSON.parse(event.data) as IConnections; const data = JSON.parse(event.data) as IConnections;
// 尽量与前一次 connections 的展示顺序保持一致
next(null, (old = initConn) => { next(null, (old = initConn) => {
const oldConn = old.connections; const oldConn = old.connections;
const maxLen = data.connections?.length; const maxLen = data.connections?.length;
@@ -114,39 +123,61 @@ const ConnectionsPage = () => {
}, },
); );
const [filterConn, download, upload] = useMemo(() => { const displayData = useMemo(() => {
return isPaused ? (frozenData ?? connData) : connData;
}, [isPaused, frozenData, connData]);
const [filterConn] = useMemo(() => {
const orderFunc = orderOpts[curOrderOpt]; const orderFunc = orderOpts[curOrderOpt];
let connections = connData.connections.filter((conn) => let connections = displayData.connections.filter((conn) => {
match(conn.metadata.host || conn.metadata.destinationIP || ""), const { host, destinationIP, process } = conn.metadata;
); return (
match(host || "") || match(destinationIP || "") || match(process || "")
);
});
if (orderFunc) connections = orderFunc(connections); if (orderFunc) connections = orderFunc(connections);
let download = 0; return [connections];
let upload = 0; }, [displayData, match, curOrderOpt]);
connections.forEach((x) => {
download += x.download;
upload += x.upload;
});
return [connections, download, upload];
}, [connData, match, curOrderOpt]);
const onCloseAll = useLockFn(closeAllConnections); const onCloseAll = useLockFn(closeAllConnections);
const detailRef = useRef<ConnectionDetailRef>(null!); const detailRef = useRef<ConnectionDetailRef>(null!);
const handleSearch = useCallback((match: (content: string) => boolean) => {
setMatch(() => match);
}, []);
const handlePauseToggle = useCallback(() => {
setIsPaused((prev) => {
if (!prev) {
setFrozenData(connData);
} else {
setFrozenData(null);
}
return !prev;
});
}, [connData]);
return ( return (
<BasePage <BasePage
full full
title={<span style={{ whiteSpace: "nowrap" }}>{t("Connections")}</span>} title={<span style={{ whiteSpace: "nowrap" }}>{t("Connections")}</span>}
contentStyle={{ height: "100%" }} contentStyle={{
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
borderRadius: "8px",
}}
header={ header={
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ mx: 1 }}> <Box sx={{ mx: 1 }}>
{t("Downloaded")}: {parseTraffic(download)} {t("Downloaded")}: {parseTraffic(displayData.downloadTotal)}
</Box> </Box>
<Box sx={{ mx: 1 }}> <Box sx={{ mx: 1 }}>
{t("Uploaded")}: {parseTraffic(upload)} {t("Uploaded")}: {parseTraffic(displayData.uploadTotal)}
</Box> </Box>
<IconButton <IconButton
color="inherit" color="inherit"
@@ -160,16 +191,23 @@ const ConnectionsPage = () => {
} }
> >
{isTableLayout ? ( {isTableLayout ? (
<span title={t("List View")}> <TableRowsRounded titleAccess={t("List View")} />
<TableRowsRounded fontSize="inherit" />
</span>
) : ( ) : (
<span title={t("Table View")}> <TableChartRounded titleAccess={t("Table View")} />
<TableChartRounded fontSize="inherit" /> )}
</span> </IconButton>
<IconButton
color="inherit"
size="small"
onClick={handlePauseToggle}
title={isPaused ? t("Resume") : t("Pause")}
>
{isPaused ? (
<PlayCircleOutlineRounded />
) : (
<PauseCircleOutlineRounded />
)} )}
</IconButton> </IconButton>
<Button size="small" variant="contained" onClick={onCloseAll}> <Button size="small" variant="contained" onClick={onCloseAll}>
<span style={{ whiteSpace: "nowrap" }}>{t("Close All")}</span> <span style={{ whiteSpace: "nowrap" }}>{t("Close All")}</span>
</Button> </Button>
@@ -185,6 +223,9 @@ const ConnectionsPage = () => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
userSelect: "text", userSelect: "text",
position: "sticky",
top: 0,
zIndex: 2,
}} }}
> >
{!isTableLayout && ( {!isTableLayout && (
@@ -199,37 +240,31 @@ const ConnectionsPage = () => {
))} ))}
</BaseStyledSelect> </BaseStyledSelect>
)} )}
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={handleSearch} />
</Box> </Box>
<Box {filterConn.length === 0 ? (
height="calc(100% - 65px)" <BaseEmpty />
sx={{ ) : isTableLayout ? (
userSelect: "text", <ConnectionTable
margin: "10px", connections={filterConn}
borderRadius: "8px", onShowDetail={(detail) => detailRef.current?.open(detail)}
bgcolor: isDark ? "#282a36" : "#ffffff", />
}} ) : (
> <Virtuoso
{filterConn.length === 0 ? ( style={{
<BaseEmpty /> flex: 1,
) : isTableLayout ? ( borderRadius: "8px",
<ConnectionTable }}
connections={filterConn} data={filterConn}
onShowDetail={(detail) => detailRef.current?.open(detail)} itemContent={(_, item) => (
/> <ConnectionItem
) : ( value={item}
<Virtuoso onShowDetail={() => detailRef.current?.open(item)}
data={filterConn} />
itemContent={(_, item) => ( )}
<ConnectionItem />
value={item} )}
onShowDetail={() => detailRef.current?.open(item)}
/>
)}
/>
)}
</Box>
<ConnectionDetail ref={detailRef} /> <ConnectionDetail ref={detailRef} />
</BasePage> </BasePage>
); );
+31 -28
View File
@@ -32,11 +32,15 @@ const LogPage = () => {
const filterLogs = useMemo(() => { const filterLogs = useMemo(() => {
return logData return logData
? logData.filter((data) => ? logData.filter((data) => {
logLevel === "all" // 构建完整的搜索文本,包含时间、类型和内容
? match(data.payload) const searchText =
: data.type.includes(logLevel) && match(data.payload), `${data.time || ""} ${data.type} ${data.payload}`.toLowerCase();
)
return logLevel === "all"
? match(searchText)
: data.type.toLowerCase() === logLevel && match(searchText);
})
: []; : [];
}, [logData, logLevel, match]); }, [logData, logLevel, match]);
@@ -44,7 +48,12 @@ const LogPage = () => {
<BasePage <BasePage
full full
title={t("Logs")} title={t("Logs")}
contentStyle={{ height: "100%" }} contentStyle={{
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
}}
header={ header={
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<IconButton <IconButton
@@ -65,7 +74,7 @@ const LogPage = () => {
size="small" size="small"
variant="contained" variant="contained"
onClick={() => { onClick={() => {
clearLogs(logLevel); clearLogs();
}} }}
> >
{t("Clear")} {t("Clear")}
@@ -102,27 +111,21 @@ const LogPage = () => {
/> />
</Box> </Box>
<Box {filterLogs.length > 0 ? (
height="calc(100% - 65px)" <Virtuoso
sx={{ initialTopMostItemIndex={999}
margin: "10px", data={filterLogs}
borderRadius: "8px", style={{
bgcolor: isDark ? "#282a36" : "#ffffff", flex: 1,
}} }}
> itemContent={(index, item) => (
{filterLogs.length > 0 ? ( <LogItem value={item} searchState={searchState} />
<Virtuoso )}
initialTopMostItemIndex={999} followOutput={"smooth"}
data={filterLogs} />
itemContent={(index, item) => ( ) : (
<LogItem value={item} searchState={searchState} /> <BaseEmpty />
)} )}
followOutput={"smooth"}
/>
) : (
<BaseEmpty />
)}
</Box>
</BasePage> </BasePage>
); );
}; };
+47 -48
View File
@@ -27,7 +27,7 @@ import { useTranslation } from "react-i18next";
import { import {
importProfile, importProfile,
enhanceProfiles, enhanceProfiles,
restartCore, //restartCore,
getRuntimeLogs, getRuntimeLogs,
deleteProfile, deleteProfile,
updateProfile, updateProfile,
@@ -174,11 +174,11 @@ const ProfilePage = () => {
}, 100); }, 100);
try { try {
await patchProfiles({ current: profile }); const success = await patchProfiles({ current: profile });
await mutateLogs(); await mutateLogs();
closeAllConnections(); closeAllConnections();
await activateSelected(); await activateSelected();
if (notifySuccess) { if (notifySuccess && success) {
Notice.success(t("Profile Switched"), 1000); Notice.success(t("Profile Switched"), 1000);
} }
} catch (err: any) { } catch (err: any) {
@@ -367,20 +367,19 @@ const ProfilePage = () => {
{t("New")} {t("New")}
</Button> </Button>
</Stack> </Stack>
<Box
sx={{ <DndContext
pt: 1, sensors={sensors}
mb: 0.5, collisionDetection={closestCenter}
pl: "10px", onDragEnd={onDragEnd}
mr: "10px",
height: "calc(100% - 68px)",
overflowY: "auto",
}}
> >
<DndContext <Box
sensors={sensors} sx={{
collisionDetection={closestCenter} pl: "10px",
onDragEnd={onDragEnd} pr: "10px",
height: "calc(100% - 48px)",
overflowY: "auto",
}}
> >
<Box sx={{ mb: 1.5 }}> <Box sx={{ mb: 1.5 }}>
<Grid2 container spacing={{ xs: 1, lg: 1 }}> <Grid2 container spacing={{ xs: 1, lg: 1 }}>
@@ -401,8 +400,8 @@ const ProfilePage = () => {
onSave={async (prev, curr) => { onSave={async (prev, curr) => {
if (prev !== curr && profiles.current === item.uid) { if (prev !== curr && profiles.current === item.uid) {
await onEnhance(false); await onEnhance(false);
await restartCore(); // await restartCore();
Notice.success(t("Clash Core Restarted"), 1000); // Notice.success(t("Clash Core Restarted"), 1000);
} }
}} }}
onDelete={() => onDelete(item.uid)} onDelete={() => onDelete(item.uid)}
@@ -412,38 +411,38 @@ const ProfilePage = () => {
</SortableContext> </SortableContext>
</Grid2> </Grid2>
</Box> </Box>
</DndContext> <Divider
<Divider variant="middle"
variant="middle" flexItem
flexItem sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }}
sx={{ width: `calc(100% - 32px)`, borderColor: dividercolor }} ></Divider>
></Divider> <Box sx={{ mt: 1.5, mb: "10px" }}>
<Box sx={{ mt: 1.5 }}> <Grid2 container spacing={{ xs: 1, lg: 1 }}>
<Grid2 container spacing={{ xs: 1, lg: 1 }}> <Grid2 size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<Grid2 size={{ xs: 12, sm: 6, md: 6, lg: 6 }}> <ProfileMore
<ProfileMore id="Merge"
id="Merge" onSave={async (prev, curr) => {
onSave={async (prev, curr) => { if (prev !== curr) {
if (prev !== curr) { await onEnhance(false);
await onEnhance(false); }
} }}
}} />
/> </Grid2>
<Grid2 size={{ xs: 12, sm: 6, md: 6, lg: 6 }}>
<ProfileMore
id="Script"
logInfo={chainLogs["Script"]}
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false);
}
}}
/>
</Grid2>
</Grid2> </Grid2>
<Grid2 size={{ xs: 12, sm: 6, md: 6, lg: 6 }}> </Box>
<ProfileMore
id="Script"
logInfo={chainLogs["Script"]}
onSave={async (prev, curr) => {
if (prev !== curr) {
await onEnhance(false);
}
}}
/>
</Grid2>
</Grid2>
</Box> </Box>
</Box> </DndContext>
<ProfileViewer <ProfileViewer
ref={viewerRef} ref={viewerRef}
+3 -3
View File
@@ -4,7 +4,7 @@ import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Box, Button, ButtonGroup } from "@mui/material"; import { Box, Button, ButtonGroup } from "@mui/material";
import { closeAllConnections, getClashConfig } from "@/services/api"; import { closeAllConnections, getClashConfig } from "@/services/api";
import { patchClashConfig } from "@/services/cmds"; import { patchClashConfig, patchClashMode } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { BasePage } from "@/components/base"; import { BasePage } from "@/components/base";
import { ProxyGroups } from "@/components/proxy/proxy-groups"; import { ProxyGroups } from "@/components/proxy/proxy-groups";
@@ -29,7 +29,7 @@ const ProxyPage = () => {
if (mode !== curMode && verge?.auto_close_connection) { if (mode !== curMode && verge?.auto_close_connection) {
closeAllConnections(); closeAllConnections();
} }
await patchClashConfig({ mode }); await patchClashMode(mode);
mutateClash(); mutateClash();
}); });
@@ -42,7 +42,7 @@ const ProxyPage = () => {
return ( return (
<BasePage <BasePage
full full
contentStyle={{ height: "100%" }} contentStyle={{ height: "101.5%" }}
title={t("Proxy Groups")} title={t("Proxy Groups")}
header={ header={
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
+27 -29
View File
@@ -39,7 +39,12 @@ const RulesPage = () => {
<BasePage <BasePage
full full
title={t("Rules")} title={t("Rules")}
contentStyle={{ height: "100%" }} contentStyle={{
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
}}
header={ header={
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<ProviderButton /> <ProviderButton />
@@ -59,34 +64,27 @@ const RulesPage = () => {
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
</Box> </Box>
<Box {rules.length > 0 ? (
height="calc(100% - 65px)" <>
sx={{ <Virtuoso
margin: "10px", ref={virtuosoRef}
borderRadius: "8px", data={rules}
bgcolor: isDark ? "#282a36" : "#ffffff", style={{
position: "relative", flex: 1,
}} }}
> itemContent={(index, item) => (
{rules.length > 0 ? ( <RuleItem index={index + 1} value={item} />
<> )}
<Virtuoso followOutput={"smooth"}
ref={virtuosoRef} scrollerRef={(ref) => {
data={rules} if (ref) ref.addEventListener("scroll", handleScroll);
itemContent={(index, item) => ( }}
<RuleItem index={index + 1} value={item} /> />
)} <ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
followOutput={"smooth"} </>
scrollerRef={(ref) => { ) : (
if (ref) ref.addEventListener("scroll", handleScroll); <BaseEmpty />
}} )}
/>
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
</>
) : (
<BaseEmpty />
)}
</Box>
</BasePage> </BasePage>
); );
}; };
+20 -3
View File
@@ -1,10 +1,18 @@
import { Box, ButtonGroup, Grid, IconButton } from "@mui/material"; import {
Box,
ButtonGroup,
Grid,
IconButton,
Select,
MenuItem,
} from "@mui/material";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BasePage, Notice } from "@/components/base"; import { BasePage, Notice } from "@/components/base";
import { GitHub, HelpOutlineRounded, Telegram } from "@mui/icons-material"; import { GitHub, HelpOutlineRounded, Telegram } from "@mui/icons-material";
import { openWebUrl } from "@/services/cmds"; import { openWebUrl } from "@/services/cmds";
import SettingVerge from "@/components/setting/setting-verge"; import SettingVergeBasic from "@/components/setting/setting-verge-basic";
import SettingVergeAdvanced from "@/components/setting/setting-verge-advanced";
import SettingClash from "@/components/setting/setting-clash"; import SettingClash from "@/components/setting/setting-clash";
import SettingSystem from "@/components/setting/setting-system"; import SettingSystem from "@/components/setting/setting-system";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
@@ -88,10 +96,19 @@ const SettingPage = () => {
<Box <Box
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
marginBottom: 1.5,
backgroundColor: isDark ? "#282a36" : "#ffffff", backgroundColor: isDark ? "#282a36" : "#ffffff",
}} }}
> >
<SettingVerge onError={onError} /> <SettingVergeBasic onError={onError} />
</Box>
<Box
sx={{
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
>
<SettingVergeAdvanced onError={onError} />
</Box> </Box>
</Grid> </Grid>
</Grid> </Grid>
+13 -1
View File
@@ -91,6 +91,10 @@ export async function patchClashConfig(payload: Partial<IConfigData>) {
return invoke<void>("patch_clash_config", { payload }); return invoke<void>("patch_clash_config", { payload });
} }
export async function patchClashMode(payload: String) {
return invoke<void>("patch_clash_mode", { payload });
}
export async function getVergeConfig() { export async function getVergeConfig() {
return invoke<IVergeConfig>("get_verge_config"); return invoke<IVergeConfig>("get_verge_config");
} }
@@ -115,7 +119,7 @@ export async function getAutotemProxy() {
} }
export async function changeClashCore(clashCore: string) { export async function changeClashCore(clashCore: string) {
return invoke<any>("change_clash_core", { clashCore }); return invoke<string | null>("change_clash_core", { clashCore });
} }
export async function restartCore() { export async function restartCore() {
@@ -237,3 +241,11 @@ export async function listWebDavBackup() {
}); });
return list; return list;
} }
export async function scriptValidateNotice(status: string, msg: string) {
return invoke<void>("script_validate_notice", { status, msg });
}
export async function validateScriptFile(filePath: string) {
return invoke<boolean>("validate_script_file", { filePath });
}
+3
View File
@@ -120,6 +120,7 @@ interface IConnectionsItem {
sourcePort: string; sourcePort: string;
destinationPort: string; destinationPort: string;
destinationIP?: string; destinationIP?: string;
remoteDestination?: string;
process?: string; process?: string;
processPath?: string; processPath?: string;
}; };
@@ -708,9 +709,11 @@ interface IVergeConfig {
tun_tray_icon?: boolean; tun_tray_icon?: boolean;
enable_tray_speed?: boolean; enable_tray_speed?: boolean;
enable_tun_mode?: boolean; enable_tun_mode?: boolean;
enable_lite_mode?: boolean;
enable_auto_launch?: boolean; enable_auto_launch?: boolean;
enable_silent_start?: boolean; enable_silent_start?: boolean;
enable_system_proxy?: boolean; enable_system_proxy?: boolean;
enable_global_hotkey?: boolean;
proxy_auto_config?: boolean; proxy_auto_config?: boolean;
pac_file_content?: string; pac_file_content?: string;
enable_random_port?: boolean; enable_random_port?: boolean;