Compare commits

...
91 Commits
Author SHA1 Message Date
Tunglies f6b9ccc774 ```
nothing changes
```
2025-03-11 01:15:37 +08:00
Tunglies d001bbc2eb ```
No changes made
```
2025-03-11 01:12:20 +08:00
Tunglies 2d1807cd4d feat: enhance tag retrieval and detection for stable and pre-release versions 2025-03-11 01:09:25 +08:00
Tunglies c6dce2d6cb feat: increase tag retrieval limit and refine alpha tag identification 2025-03-11 00:56:35 +08:00
Tunglies 3c7768b379 feat: add support for alpha updates and enhance updater functionality 2025-03-11 00:24:31 +08:00
Tunglies d470bf2dd9 rm: label issues workflow 2025-03-10 01:01:12 +08:00
Tunglies b5e8a1ddb5 chore: rename "DNS Settings" to "DNS Overwrite" in UI for consistency
This change updates the label for the DNS toggle setting from "DNS Settings" to "DNS Overwrite"
in the ClashVerge settings interface. The change provides better consistency with the translation
keys and more clearly communicates the function of the setting, which is to override system DNS.
The corresponding translation keys have been updated in both English and Chinese localization files.
2025-03-10 00:57:55 +08:00
Tunglies 08f3357d9a feat: add scheduled workflow and commit change check to alpha build 2025-03-09 17:42:13 +08:00
Tunglies 107c54921d refactor: update MihomoManager to handle traffic WebSocket URL and authorization 2025-03-09 14:44:15 +08:00
wonfen ca1f7a2b31 feat: enhance latency test logging and error handling 2025-03-09 04:22:34 +08:00
wonfen 95e8fe38f2 refactor: improve webSocket connection handling and error recovery 2025-03-09 04:22:01 +08:00
wonfen cd6eb26a41 feat: add icon file content check to prevent saving failed downloads 2025-03-09 02:34:57 +08:00
Tunglies 9f2c4b4f35 refactor: migrate clash client info retrieval to MihomoManager 2025-03-09 00:40:16 +08:00
Tunglies adbf98cb15 rm: clash-api dead code 2025-03-09 00:29:14 +08:00
Tunglies c9b3fec09c refactor: improve request handling and response processing in MihomoManager 2025-03-09 00:27:12 +08:00
Tunglies 364fb16b7b refactor: streamline clash delay test and improve API interactions 2025-03-09 00:04:48 +08:00
Tunglies 634ed9cb46 refactor: streamline clash mode handling and improve API interactions 2025-03-08 22:41:14 +08:00
wonfen cddb117402 feat: optimize icon cache download and DNS view styling 2025-03-08 13:31:20 +08:00
wonfen bd6c4e00c4 refactor: restructure DNS setting logic 2025-03-08 11:25:00 +08:00
wonfen bbcfb4901f refactor: refine DNS handling to follow config and merge settings 2025-03-08 03:34:25 +08:00
wonfen 230a0eeb64 fix: regex for first character matching 2025-03-07 14:07:10 +08:00
wonfen c4c49f61df perf: faster app exit 2025-03-07 13:44:07 +08:00
TianHua Liu 6d5c4f86d3 fix: Notice @ts-ignore (#2896)
thx
2025-03-07 12:46:30 +08:00
0XE 59f22b9cd2 feat: Add x-data-grid component localization (#2925)
thx
2025-03-07 12:45:56 +08:00
wonfen 6caf2cd057 test: remove entitlements.plist items 2025-03-07 06:49:24 +08:00
Tunglies 684128ca68 Update dependencies and refactor encryption logic
Updates multiple dependencies to their latest versions in Cargo.lock and Cargo.toml.
Refactors encryption logic to use updated getrandom API.
Improves tray speed rate display by using ab_glyph for font rendering.
2025-03-06 18:56:31 +08:00
wonfen 6925adc66f feat: enable dns settings by default 2025-03-06 14:40:35 +08:00
wonfen e1e9d91d86 feat: add dns settings 2025-03-06 14:30:43 +08:00
Christine. 881c136b1e add: missing i18n text. (#2917) 2025-03-05 22:28:28 +08:00
0XE 5bc699b19c fix: ISSUES #2727 (#2913) 2025-03-05 14:20:07 +08:00
Christine. 52ba1c5a30 workflow: remove renaming behavior. (#2909) 2025-03-05 11:41:15 +08:00
Tunglies 817d68546e feat(mihomo): refactor MihomoManager for global access and improve proxy retrieval (#2906) 2025-03-05 10:58:54 +08:00
wonfen c0f5c231ad feat: add admin permission prompt for system service 2025-03-05 10:22:57 +08:00
Tunglies 9677d8670b test: crate_mihomo_api additional headers 2025-03-05 08:14:37 +08:00
Tunglies b01630d31c fix: extern controler api secert with headers 2025-03-05 08:09:42 +08:00
Tunglies f771f4720f refacture: Mihomo API integration (#2900)
* feat: add mihomo_api crate as a workspace member

Added a new mihomo_api crate to handle interactions with the Mihomo API. This modular approach provides a dedicated interface for fetching and managing proxy data from Mihomo servers. The implementation includes functionality to refresh and retrieve both proxies and provider proxies with proper error handling and timeouts. Added this crate as a workspace member and included it as a dependency in the main project.

* Refactors Mihomo API integration

Simplifies proxy fetching by removing the MihomoManager structure.

Updates the get_proxies and get_providers_proxies functions to directly use the mihomo_api module.

Removes unused Mihomo API related files and modules for cleaner codebase.

Enhances overall maintainability and performance.
2025-03-05 00:45:08 +08:00
0XE f0becb5003 fix: issues #2838 (#2886) 2025-03-04 20:46:17 +08:00
Tunglies 405e8df825 feat(sysinfo): Add diagnostic information enhancements (#2880)
Enhanced the PlatformSpecification struct with additional diagnostic information including:
- Added Verge version information to diagnostic output
- Added running mode information (Service/Sidecar/Not Running)
- Improved Debug implementation to display all diagnostic fields
- Implemented asynchronous detection of core running mode

This change helps users provide more complete system information when reporting issues.
2025-03-04 11:52:22 +08:00
wonfen 49fc7ff7ca fix: sync system proxy status indicator with hotkey 2025-03-04 06:57:42 +08:00
wonfen fde9c8aaee fix: correct type declarations for getProxiesInner and getProxyProviders 2025-03-04 02:26:26 +08:00
Tunglies 8f95c28050 feat(proxy): add proxy commands and integrate with API
Add new proxy.rs module with get_proxies and get_providers_proxies commands.
Update mod.rs and lib.rs to re-export and register proxy commands.
Update API.ts to use invoke for proxy commands.
Minor formatting improvements in module/mihomo.rs.
2025-03-04 01:01:24 +08:00
Tunglies 2cd97c7785 feat: add Mihomo API modules and manager (#2869)
• Introduce new API caller implementations for Mihomo in model and module layers.
• Add configuration and API integration files under /src-tauri/src/config/api and /src-tauri/src/model/api.
• Implement a singleton MihomoAPICaller with async API call support and integration tests.
• Create a new MihomoManager module to refresh and fetch proxies from the API.
• Update Cargo.lock and Cargo.toml with additional dependencies (async-trait, env_logger, mockito, tempfile, etc.) related to the Mihomo API support.
2025-03-03 19:31:44 +08:00
wonfen 6e48781687 Revert "refactor: improve proxy group UI and spacing (#2835)"
This reverts commit b31cbda615.
2025-03-03 14:47:05 +08:00
wonfen dd510b2ee9 feat: show service mode installation prompts in user mode 2025-03-03 14:42:31 +08:00
Tunglies fadae3f7dc fix: speed format runns by docs 2025-03-03 11:36:21 +08:00
Tunglies fdf8a6b2ba Revert "style: update box styling in settings page for improved layout (#2857)"
This reverts commit 4382df7951.
2025-03-03 08:00:10 +08:00
Tunglies 4382df7951 style: update box styling in settings page for improved layout (#2857) 2025-03-03 06:38:32 +08:00
Tunglies 6cb7d48530 feat: add export diagnostic info functionality (#2856) 2025-03-03 05:58:12 +08:00
wonfen 9d74b93ee0 feat: Add sidecar mode as an alternative to service mode
- Auto-fallback to sidecar mode if service mode fails
2025-03-03 03:34:34 +08:00
Tunglies d370868222 fix: windows/linux runtime crash 2025-03-03 02:27:45 +08:00
Christine. 9278f9e193 workflow: remove 32-bit platform (#2855)
* chore: build portable by self

* chore: remove 32bit platform

* Update CONTRIBUTING.md

* update alpha version
2025-03-03 01:16:33 +08:00
Tunglies a708db6f2b Revert "chore: alpha ci should remove old builds"
This reverts commit d2c4a44297.
2025-03-03 00:16:17 +08:00
Tunglies d2c4a44297 chore: alpha ci should remove old builds 2025-03-02 23:46:20 +08:00
Tunglies 3a24623e4b feat: fish env export support 2025-03-02 23:20:10 +08:00
Tunglies d2084d8d21 version: 2.1.3 a 2025-03-02 20:46:53 +08:00
Tunglies d9b4ca91e7 version: 2.1.3 alpha 2025-03-02 20:43:54 +08:00
Tunglies 13da571ad2 version: 2.1.3 alpha (#2851)
* version: 2.1.3 alpha
2025-03-02 19:08:27 +08:00
Tunglies 662d60c0f5 chore: automatically label issues (#2844) 2025-03-02 18:50:31 +08:00
Tunglies 368095d2d4 docs: add fast build and clean commands to contributing guide (#2842)
docs: add fast build and clean commands to contributing guide

- Added documentation for the `pnpm build:fast` command which uses Rust's fast-release profile to reduce compilation time
- Added explanation that fast builds disable optimization and LTO, resulting in larger but quicker builds
- Added documentation for the `pnpm clean` command to clean Rust build files
2025-03-02 15:13:17 +08:00
Tunglies 496aeeb06d chore: fast-dev and fast-build profile (#2841)
* refactor: improve proxy group UI and spacing

- Increased spacing in proxy-groups.tsx by adjusting the right position
  of the alphabet selector to provide better visual separation
- Enhanced spacing in proxy-render.tsx with larger margins and padding
  - Increased group item margins from 8px to 10px with 16px horizontal spacing
  - Expanded border radius from 8px to 10px for smoother appearance
  - Improved ProxyHead component spacing with pl: 3, pr: 3.5
  - Enhanced grid spacing in proxy collection items from 1 to 1.5
  - Adjusted padding for better visual hierarchy

These changes create a more polished, spacious layout with improved
readability and touch targets.

* - Update package.json with improved dev and build scripts:
  - Add fast-dev profile to development scripts
  - Configure build:fast with fast-release profile
  - Add clean command for cargo cleaning
2025-03-02 14:58:59 +08:00
Tunglies b31cbda615 refactor: improve proxy group UI and spacing (#2835)
- Increased spacing in proxy-groups.tsx by adjusting the right position
  of the alphabet selector to provide better visual separation
- Enhanced spacing in proxy-render.tsx with larger margins and padding
  - Increased group item margins from 8px to 10px with 16px horizontal spacing
  - Expanded border radius from 8px to 10px for smoother appearance
  - Improved ProxyHead component spacing with pl: 3, pr: 3.5
  - Enhanced grid spacing in proxy collection items from 1 to 1.5
  - Adjusted padding for better visual hierarchy

These changes create a more polished, spacious layout with improved
readability and touch targets.
2025-03-02 05:36:18 +08:00
Tunglies 7898f3a119 fix: macos frameless title 2025-03-02 04:30:58 +08:00
wonfen ba0a291d97 feat: refactor logging system into a global service 2025-03-02 04:20:38 +08:00
wonfen b3e4defc0f fix: remove macos window title 2025-03-02 04:09:50 +08:00
wonfen 1ae0ad8a0e fix: add bottom padding to prevent jitter 2025-03-02 04:08:13 +08:00
Tunglies 79b20694c7 feat: add fast compilation options for development and release (#2831)
- Added fast compilation profiles in Cargo.toml
  - fast-dev profile with max codegen units and disabled optimizations
  - fast-release profile with debugging support and faster build time
- Added new npm scripts for quick development iterations
  - dev:fast command for standard development without extra features
  - build:fast command for quick release builds
- Updated default dev command to use verge-dev feature flag
- Both profiles retain debug symbols and disable stripping for better debugging
2025-03-02 00:40:07 +08:00
Tunglies 1ad4941ed8 refactor: rename cmds module to cmd for better consistency (#2830)
- Renamed `cmds` module to `cmd` for better naming consistency
- Reorganized command modules into separate files under src/cmd/
- Updated all imports and references to use the new module name
- Fixed missing dependency in webdav.rs to reference core::backup
- Updated tray module to use new cmd namespace
- Improved uwp.rs module structure using platform-specific implementations
- Removed unnecessary imports from various command files
2025-03-01 22:52:43 +08:00
Tunglies b3fd44d165 refactor: reorganize feat.rs into modular structure (#2827)
Split the monolithic feat.rs file into specialized modules:
- backup.rs: WebDAV backup and restore functions
- clash.rs: Core management and testing functions
- config.rs: Configuration handling
- profile.rs: Profile management
- proxy.rs: Proxy and TUN mode controls
- window.rs: Dashboard window management

This improves code organization, readability, and maintainability
by grouping related functionality into logical modules.
2025-03-01 20:44:35 +08:00
Christine. 391a494af6 fix: build failed with Windows (#2825) 2025-03-01 19:52:42 +08:00
Tunglies 623d075ab8 add: issues type template 2025-03-01 19:12:12 +08:00
Tunglies 5d70b77316 Issues label options template (#2820)
* fix: macos dock display icon and text

* add: issues label option template
2025-03-01 02:46:16 -08:00
Tunglies f5f54c0f0e fix: macos dock display icon and text (#2818) 2025-03-01 02:29:40 -08:00
wonfen 91b0b1f279 perf: improve scrolling performance and interaction in proxy group list 2025-03-01 08:31:31 +08:00
wonfen e0fa1f3efe fix: v2 action file rename 2025-03-01 08:04:31 +08:00
wonfen b00abf3337 chore: updatelog 2025-03-01 03:45:03 +08:00
wonfen a5d846ce4e feat: add exit status check in core config validation 2025-03-01 03:39:13 +08:00
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
96 changed files with 6587 additions and 2480 deletions
+12
View File
@@ -2,6 +2,7 @@ name: 问题反馈 / Bug report
title: "[BUG] "
description: 反馈你遇到的问题 / Report the issue you are experiencing
labels: ["bug"]
type: "Bug"
body:
- type: markdown
@@ -54,6 +55,17 @@ body:
description: 请提供你的操作系统版本,Linux请额外提供桌面环境及窗口系统 / Please provide your OS version, for Linux, please also provide the desktop environment and window system
validations:
required: true
- type: checkboxes
id: os-labels
attributes:
label: 系统标签 / OS Labels
description: 请选择受影响的操作系统(至少选择一个) / Please select the affected operating system(s) (select at least one)
options:
- label: windows
- label: macos
- label: linux
validations:
required: true
- type: textarea
attributes:
label: 日志 / Log
@@ -2,6 +2,7 @@ name: 功能请求 / Feature request
title: "[Feature] "
description: 提出你的功能请求 / Propose your feature request
labels: ["enhancement"]
type: "Feature"
body:
- type: markdown
@@ -33,3 +34,14 @@ body:
description: 请描述你的功能请求的使用场景 / Please describe the use case of your feature request
validations:
required: true
- type: checkboxes
id: os-labels
attributes:
label: 适用系统 / Target OS
description: 请选择该功能适用的操作系统(至少选择一个) / Please select the operating system(s) for this feature request (select at least one)
options:
- label: windows
- label: macos
- label: linux
validations:
required: true
+46 -11
View File
@@ -2,6 +2,9 @@ name: Alpha Build
on:
workflow_dispatch:
schedule:
# UTC+8 00:00 (UTC 16:00 previous day) and UTC+8 12:00 (UTC 04:00)
- cron: "0 16,4 * * *"
permissions: write-all
env:
CARGO_INCREMENTAL: 0
@@ -12,7 +15,40 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
check_commit:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if commit changed
id: check
run: |
# For manual workflow_dispatch, always run
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
exit 0
fi
# Check if current commit is different from the previous one
CURRENT_COMMIT=$(git rev-parse HEAD)
PREVIOUS_COMMIT=$(git rev-parse HEAD~1)
if [ "$CURRENT_COMMIT" != "$PREVIOUS_COMMIT" ]; then
echo "New commit detected: $CURRENT_COMMIT"
echo "should_run=true" >> $GITHUB_OUTPUT
else
echo "No new commits since last run"
echo "should_run=false" >> $GITHUB_OUTPUT
fi
alpha:
needs: check_commit
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
strategy:
fail-fast: false
matrix:
@@ -92,13 +128,9 @@ jobs:
tauriScript: pnpm
args: --target ${{ matrix.target }}
- name: Portable Bundle
if: matrix.os == 'windows-latest'
run: pnpm portable ${{ matrix.target }} --alpha
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
alpha-for-linux-arm:
needs: check_commit
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
strategy:
fail-fast: false
matrix:
@@ -203,7 +235,7 @@ jobs:
- name: Get Version
run: |
sudo apt-get update
sudo apt-get install jq
sudo apt-get install jq
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
@@ -220,6 +252,8 @@ jobs:
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
alpha-for-fixed-webview2:
needs: check_commit
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
strategy:
fail-fast: false
matrix:
@@ -293,9 +327,9 @@ jobs:
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip.sig"
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip\.sig$", "_fixed_webview2-setup.nsis.zip.sig"
$newName = $file.Name -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
Rename-Item $file.FullName $newName
}
@@ -317,7 +351,8 @@ jobs:
update_tag:
name: Update tag
runs-on: ubuntu-latest
needs: [alpha, alpha-for-linux-arm, alpha-for-fixed-webview2]
needs: [check_commit, alpha, alpha-for-linux-arm, alpha-for-fixed-webview2]
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -347,7 +382,7 @@ jobs:
- Linux arm64 architecture: arm64.deb/aarch64.rpm
- Linux armv7架构: armhf.deb/armhfp.rpm
### Windows (Win7 用户请查看下面FAQ中的解决方案)
### Windows (不再支持Win7)
#### 正常版本(推荐)
- 64位: x64-setup.exe
- arm64架构: arm64-setup.exe
+9 -11
View File
@@ -6,6 +6,10 @@ permissions: write-all
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
concurrency:
# only allow per workflow per commit (and not pr) to run at a time
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
release:
@@ -40,6 +44,7 @@ jobs:
with:
workspaces: src-tauri
cache-all-crates: true
cache-on-failure: true
- name: Install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-22.04'
@@ -82,12 +87,6 @@ jobs:
tauriScript: pnpm
args: --target ${{ matrix.target }}
- name: Portable Bundle
if: matrix.os == 'windows-latest'
run: pnpm portable ${{ matrix.target }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-for-linux-arm:
strategy:
fail-fast: false
@@ -216,9 +215,6 @@ jobs:
- os: windows-latest
target: x86_64-pc-windows-msvc
arch: x64
- os: windows-latest
target: i686-pc-windows-msvc
arch: x86
- os: windows-latest
target: aarch64-pc-windows-msvc
arch: arm64
@@ -234,6 +230,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
cache-all-crates: true
cache-on-failure: true
- name: Install Node
uses: actions/setup-node@v4
@@ -283,9 +281,9 @@ jobs:
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip.sig"
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip\.sig$", "_fixed_webview2-setup.nsis.zip.sig"
$newName = $file.Name -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
Rename-Item $file.FullName $newName
}
+27 -3
View File
@@ -38,7 +38,7 @@ npm install pnpm -g
pnpm install
```
### Download the Clash Mihomo Core Binary
### Download the Mihomo Core Binary
You have two options for downloading the clash binary:
@@ -48,7 +48,7 @@ You have two options for downloading the clash binary:
# Use '--force' to force update to the latest version
# pnpm run check --force
```
- Manually download it from the [Clash Meta release](https://github.com/MetaCubeX/Clash.Meta/releases). After downloading, rename the binary according to the [Tauri configuration](https://tauri.app/v1/api/config#bundleconfig.externalbin).
- Manually download it from the [Mihomo release](https://github.com/MetaCubeX/mihomo/releases). After downloading, rename the binary according to the [Tauri configuration](https://tauri.app/v1/api/config#bundleconfig.externalbin).
### Run the Development Server
@@ -65,11 +65,35 @@ pnpm dev:diff
To build this project:
```shell
pnpm run build
pnpm build
```
For a faster build, use the following command
```shell
pnpm build:fast
```
This uses Rust's fast-release profile which significantly reduces compilation time by disabling optimization and LTO. The resulting binary will be larger and less performant than the standard build, but it's useful for testing changes quickly.
The `Artifacts` will display in the `log` in the Terminal.
### Build clean
To clean rust build:
```shell
pnpm clean
```
### Portable Version (Windows Only)
To package portable version after the build:
```shell
pnpm portable
```
## Contributing Your Changes
Once you have made your changes:
+16 -2
View File
@@ -1,4 +1,4 @@
## v2.1.1
## v2.1.2
**发行代号:臻**
@@ -6,8 +6,16 @@
感谢 Tychristine 对社区群组管理做出的重大贡献!
2.1.1相对2.1.0(已下架不在提供)更新了:
##### 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因底层兼容性问题,可能圆角和边框显示不太完美)
@@ -15,6 +23,8 @@
- 修复Linux下编译问题
- 修复热键无法关闭面板的问题
##### 2.1.0 - 发行代号:臻
### 功能新增
- 新增窗口状态实时监控与自动保存功能
@@ -91,6 +101,8 @@
- 改进脚本验证与异常处理流程
- 修复编译警告(移除无用导入)
---
## v2.0.3
### Notice
@@ -138,6 +150,8 @@
- 改进更新托盘图标性能
- 窗口隐藏后WebSocket断开连接
---
## v2.0.2
### Notice
+6 -4
View File
@@ -1,11 +1,12 @@
{
"name": "clash-verge",
"version": "2.1.1",
"version": "2.1.3-alpha",
"license": "GPL-3.0-only",
"scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev",
"dev:diff": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev -- --profile fast-dev",
"dev:diff": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev -- --profile fast-dev",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsc --noEmit && vite build",
@@ -16,7 +17,8 @@
"portable": "node scripts/portable.mjs",
"portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs",
"fix-alpha-version": "node scripts/alpha_version.mjs",
"prepare": "husky"
"prepare": "husky",
"clean": "cd ./src-tauri && cargo clean && cd -"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
+8 -6
View File
@@ -33,22 +33,24 @@ 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;
// 获取键值替换
let result = packageJson.version.replace("alpha", newVersion);
console.log("[INFO]: Current version is: ", result);
packageJson.version = result;
// 写入版本号
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
"utf8",
);
console.log(`Alpha version update to: ${fixversion}`);
console.log(`[INFO]: Alpha version update to: ${newVersion}`);
} catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error);
}
}
const newVersion = await getLatestCommitHash();
updatePackageVersion(newVersion);
updatePackageVersion(newVersion).catch(console.error);
+2 -44
View File
@@ -2,20 +2,16 @@ import fs from "fs";
import path from "path";
import AdmZip from "adm-zip";
import { createRequire } from "module";
import { getOctokit, context } from "@actions/github";
import fsp from "fs/promises";
const target = process.argv.slice(2)[0];
const alpha = process.argv.slice(2)[1];
const ARCH_MAP = {
"x86_64-pc-windows-msvc": "x64",
"i686-pc-windows-msvc": "x86",
"aarch64-pc-windows-msvc": "arm64",
};
const PROCESS_MAP = {
x64: "x64",
ia32: "x86",
arm64: "arm64",
};
const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch];
@@ -37,10 +33,9 @@ async function resolvePortable() {
if (!fs.existsSync(path.join(configDir, "PORTABLE"))) {
await fsp.writeFile(path.join(configDir, "PORTABLE"), "");
}
const zip = new AdmZip();
zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe"));
zip.addLocalFile(path.join(releaseDir, "clash-verge.exe"));
zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe"));
zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe"));
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources");
@@ -49,46 +44,9 @@ async function resolvePortable() {
const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
const { version } = packageJson;
const zipFile = `Clash.Verge_${version}_${arch}_portable.zip`;
zip.writeZip(zipFile);
console.log("[INFO]: create portable zip successfully");
// push release assets
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("GITHUB_TOKEN is required");
}
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
const tag = alpha ? "alpha" : process.env.TAG_NAME || `v${version}`;
console.log("[INFO]: upload to ", tag);
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag,
});
let assets = release.assets.filter((x) => {
return x.name === zipFile;
});
if (assets.length > 0) {
let id = assets[0].id;
await github.rest.repos.deleteReleaseAsset({
...options,
asset_id: id,
});
}
console.log(release.name);
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: release.id,
name: zipFile,
data: zip.toBuffer(),
});
}
resolvePortable().catch(console.error);
+124 -51
View File
@@ -2,9 +2,14 @@ import fetch from "node-fetch";
import { getOctokit, context } from "@actions/github";
import { resolveUpdateLog } from "./updatelog.mjs";
// Add stable update JSON filenames
const UPDATE_TAG_NAME = "updater";
const UPDATE_JSON_FILE = "update.json";
const UPDATE_JSON_PROXY = "update-proxy.json";
// Add alpha update JSON filenames
const ALPHA_TAG_NAME = "updater-alpha";
const ALPHA_UPDATE_JSON_FILE = "update-alpha.json";
const ALPHA_UPDATE_JSON_PROXY = "update-alpha-proxy.json";
/// generate update.json
/// upload to update tag's release asset
@@ -16,26 +21,73 @@ async function resolveUpdater() {
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
const { data: tags } = await github.rest.repos.listTags({
...options,
per_page: 10,
page: 1,
});
// Fetch all tags using pagination
let allTags = [];
let page = 1;
const perPage = 100;
// get the latest publish tag
const tag = tags.find((t) => t.name.startsWith("v"));
while (true) {
const { data: pageTags } = await github.rest.repos.listTags({
...options,
per_page: perPage,
page: page,
});
console.log(tag);
allTags = allTags.concat(pageTags);
// Break if we received fewer tags than requested (last page)
if (pageTags.length < perPage) {
break;
}
page++;
}
const tags = allTags;
console.log(`Retrieved ${tags.length} tags in total`);
// More flexible tag detection with regex patterns
const stableTagRegex = /^v\d+\.\d+\.\d+$/; // Matches vX.Y.Z format
// const preReleaseRegex = /^v\d+\.\d+\.\d+-(alpha|beta|rc|pre)/i; // Matches vX.Y.Z-alpha/beta/rc format
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i; // Matches exact alpha/beta/rc/pre tags
// Get the latest stable tag and pre-release tag
const stableTag = tags.find((t) => stableTagRegex.test(t.name));
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name));
console.log("All tags:", tags.map((t) => t.name).join(", "));
console.log("Stable tag:", stableTag ? stableTag.name : "None found");
console.log(
"Pre-release tag:",
preReleaseTag ? preReleaseTag.name : "None found",
);
console.log();
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
// Process stable release
if (stableTag) {
await processRelease(github, options, stableTag, false);
}
// Process pre-release if found
if (preReleaseTag) {
await processRelease(github, options, preReleaseTag, true);
}
}
// Process a release (stable or alpha) and generate update files
async function processRelease(github, options, tag, isAlpha) {
if (!tag) return;
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
});
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name), // use updatelog.md
notes: await resolveUpdateLog(tag.name).catch(
() => "No changelog available",
),
pub_date: new Date().toISOString(),
platforms: {
win64: { signature: "", url: "" }, // compatible with older formats
@@ -56,39 +108,40 @@ async function resolveUpdater() {
},
};
const promises = latestRelease.assets.map(async (asset) => {
const promises = release.assets.map(async (asset) => {
const { name, browser_download_url } = asset;
// Process all the platform URL and signature data
// win64 url
if (name.endsWith("x64-setup.nsis.zip")) {
if (name.endsWith("x64-setup.exe")) {
updateData.platforms.win64.url = browser_download_url;
updateData.platforms["windows-x86_64"].url = browser_download_url;
}
// win64 signature
if (name.endsWith("x64-setup.nsis.zip.sig")) {
if (name.endsWith("x64-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms.win64.signature = sig;
updateData.platforms["windows-x86_64"].signature = sig;
}
// 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-i686"].url = browser_download_url;
}
// win32 signature
if (name.endsWith("x86-setup.nsis.zip.sig")) {
if (name.endsWith("x86-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-x86"].signature = sig;
updateData.platforms["windows-i686"].signature = sig;
}
// win arm url
if (name.endsWith("arm64-setup.nsis.zip")) {
if (name.endsWith("arm64-setup.exe")) {
updateData.platforms["windows-aarch64"].url = browser_download_url;
}
// 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);
updateData.platforms["windows-aarch64"].signature = sig;
}
@@ -143,8 +196,7 @@ async function resolveUpdater() {
}
});
// 生成一个代理github的更新文件
// 使用 https://hub.fastgit.xyz/ 做github资源的加速
// Generate a proxy update file for accelerated GitHub resources
const updateDataNew = JSON.parse(JSON.stringify(updateData));
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
@@ -156,42 +208,63 @@ async function resolveUpdater() {
}
});
// update the update.json
const { data: updateRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: UPDATE_TAG_NAME,
});
// Get the appropriate updater release based on isAlpha flag
const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME;
console.log(
`Processing ${isAlpha ? "alpha" : "stable"} release:`,
releaseTag,
);
// delete the old assets
for (let asset of updateRelease.assets) {
if (asset.name === UPDATE_JSON_FILE) {
await github.rest.repos.deleteReleaseAsset({
...options,
asset_id: asset.id,
});
try {
const { data: updateRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: releaseTag,
});
// File names based on release type
const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE;
const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY;
// Delete existing assets with these names
for (let asset of updateRelease.assets) {
if (asset.name === jsonFile) {
await github.rest.repos.deleteReleaseAsset({
...options,
asset_id: asset.id,
});
}
if (asset.name === proxyFile) {
await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id })
.catch(console.error); // do not break the pipeline
}
}
if (asset.name === UPDATE_JSON_PROXY) {
await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id })
.catch(console.error); // do not break the pipeline
}
// Upload new assets
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: jsonFile,
data: JSON.stringify(updateData, null, 2),
});
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: proxyFile,
data: JSON.stringify(updateDataNew, null, 2),
});
console.log(
`Successfully uploaded ${isAlpha ? "alpha" : "stable"} update files to ${releaseTag}`,
);
} catch (error) {
console.error(
`Failed to process ${isAlpha ? "alpha" : "stable"} release:`,
error.message,
);
}
// upload new assets
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: UPDATE_JSON_FILE,
data: JSON.stringify(updateData, null, 2),
});
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: UPDATE_JSON_PROXY,
data: JSON.stringify(updateDataNew, null, 2),
});
}
// get the signature file content
+908 -343
View File
File diff suppressed because it is too large Load Diff
+43 -13
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.1.1"
version = "2.1.2"
description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -13,23 +13,23 @@ build = "build.rs"
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.0.5", features = [] }
tauri-build = { version = "2.0.6", features = [] }
[dependencies]
warp = "0.3"
anyhow = "1.0"
anyhow = "1.0.97"
dirs = "6.0"
open = "5.1"
log = "0.4"
dunce = "1.0"
log4rs = "1"
nanoid = "0.4"
chrono = "0.4"
chrono = "0.4.40"
sysinfo = "0.33.1"
boa_engine = "0.20.0"
serde_json = "1.0"
serde_yaml = "0.9"
once_cell = "1.19"
once_cell = "1.20.3"
port_scanner = "0.1.5"
delay_timer = "0.11.6"
parking_lot = "0.12"
@@ -39,10 +39,10 @@ tokio = { version = "1.43", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", rev = "3d748b5" }
image = "0.24"
imageproc = "0.23"
image = "0.25.5"
imageproc = "0.25.0"
rusttype = "0.9"
tauri = { version = "2.2.5", features = [
tauri = { version = "2.3.1", features = [
"protocol-asset",
"devtools",
"tray-icon",
@@ -59,14 +59,18 @@ tauri-plugin-clipboard-manager = "2.2.1"
tauri-plugin-deep-link = "2.2.0"
tauri-plugin-devtools = "2.0.0-rc"
url = "2.5.4"
zip = "2.2.2"
zip = "2.2.3"
reqwest_dav = "0.1.14"
aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1"
getrandom = "0.2"
tokio-tungstenite = "0.26.1"
getrandom = "0.3.1"
tokio-tungstenite = "0.26.2"
futures = "0.3"
sys-locale = "0.3.1"
async-trait = "0.1.87"
mihomo_api = { path = "./src/crate_mihomo_api" }
ab_glyph = "0.2.29"
tungstenite = "0.26.2"
[target.'cfg(windows)'.dependencies]
runas = "=1.2.0"
@@ -74,14 +78,13 @@ deelevate = "0.2.0"
winreg = "0.55.0"
url = "2.5.4"
[target.'cfg(target_os = "linux")'.dependencies]
users = "0.11.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.2.0"
tauri-plugin-global-shortcut = "2.2.0"
tauri-plugin-updater = "2.3.0"
tauri-plugin-updater = "2.5.1"
tauri-plugin-window-state = "2.2.1"
#openssl
@@ -100,6 +103,33 @@ strip = true
[profile.dev]
incremental = true
[profile.fast-release]
inherits = "release" # 继承 release 的配置
panic = "abort" # 与 release 相同
codegen-units = 256 # 增加编译单元,提升编译速度
lto = false # 禁用 LTO,提升编译速度
opt-level = 0 # 禁用优化,大幅提升编译速度
debug = true # 保留调试信息
strip = false # 不剥离符号,保留调试信息
[profile.fast-dev]
inherits = "dev" # 继承 dev 的配置
codegen-units = 256 # 增加编译单元,提升编译速度
opt-level = 0 # 禁用优化,进一步提升编译速度
incremental = true # 启用增量编译
debug = true # 保留调试信息
strip = false # 不剥离符号,保留调试信息
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[dev-dependencies]
env_logger = "0.11.0"
mockito = "1.7.0"
tempfile = "3.17.1"
[workspace]
members = [
"src/crate_mihomo_api"
]
+7
View File
@@ -6,6 +6,13 @@
"permissions": [
"global-shortcut: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",
"window-state:default",
"window-state:default",
+198
View File
@@ -0,0 +1,198 @@
use super::CmdResult;
use crate::{feat, utils::dirs, wrap_err};
use tauri::Manager;
/// 打开应用程序所在目录
#[tauri::command]
pub fn open_app_dir() -> CmdResult<()> {
let app_dir = wrap_err!(dirs::app_home_dir())?;
wrap_err!(open::that(app_dir))
}
/// 打开核心所在目录
#[tauri::command]
pub fn open_core_dir() -> CmdResult<()> {
let core_dir = wrap_err!(tauri::utils::platform::current_exe())?;
let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
wrap_err!(open::that(core_dir))
}
/// 打开日志目录
#[tauri::command]
pub fn open_logs_dir() -> CmdResult<()> {
let log_dir = wrap_err!(dirs::app_logs_dir())?;
wrap_err!(open::that(log_dir))
}
/// 打开网页链接
#[tauri::command]
pub fn open_web_url(url: String) -> CmdResult<()> {
wrap_err!(open::that(url))
}
/// 打开/关闭开发者工具
#[tauri::command]
pub fn open_devtools(app_handle: tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if !window.is_devtools_open() {
window.open_devtools();
} else {
window.close_devtools();
}
}
}
/// 退出应用
#[tauri::command]
pub fn exit_app() {
feat::quit(Some(0));
}
/// 重启应用
#[tauri::command]
pub async fn restart_app() -> CmdResult<()> {
feat::restart_app();
Ok(())
}
/// 获取便携版标识
#[tauri::command]
pub fn get_portable_flag() -> CmdResult<bool> {
Ok(*dirs::PORTABLE_FLAG.get().unwrap_or(&false))
}
/// 获取应用目录
#[tauri::command]
pub fn get_app_dir() -> CmdResult<String> {
let app_home_dir = wrap_err!(dirs::app_home_dir())?
.to_string_lossy()
.to_string();
Ok(app_home_dir)
}
/// 下载图标缓存
#[tauri::command]
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> {
let icon_cache_dir = wrap_err!(dirs::app_home_dir())?.join("icons").join("cache");
let icon_path = icon_cache_dir.join(&name);
// 如果文件已存在,直接返回路径
if icon_path.exists() {
return Ok(icon_path.to_string_lossy().to_string());
}
// 确保缓存目录存在
if !icon_cache_dir.exists() {
let _ = std::fs::create_dir_all(&icon_cache_dir);
}
// 使用临时文件名来下载
let temp_path = icon_cache_dir.join(format!("{}.downloading", &name));
// 下载文件到临时位置
let response = wrap_err!(reqwest::get(&url).await)?;
// 检查内容类型是否为图片
let content_type = response.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let is_image = content_type.starts_with("image/");
// 获取响应内容
let content = wrap_err!(response.bytes().await)?;
// 检查内容是否为HTML (针对CDN错误页面)
let is_html = content.len() > 15 &&
(content.starts_with(b"<!DOCTYPE html") ||
content.starts_with(b"<html") ||
content.starts_with(b"<?xml"));
// 只有当内容确实是图片时才保存
if is_image && !is_html {
{
let mut file = match std::fs::File::create(&temp_path) {
Ok(file) => file,
Err(_) => {
if icon_path.exists() {
return Ok(icon_path.to_string_lossy().to_string());
} else {
return Err("Failed to create temporary file".into());
}
}
};
wrap_err!(std::io::copy(&mut content.as_ref(), &mut file))?;
}
// 再次检查目标文件是否已存在,避免重命名覆盖其他线程已完成的文件
if !icon_path.exists() {
match std::fs::rename(&temp_path, &icon_path) {
Ok(_) => {},
Err(_) => {
let _ = std::fs::remove_file(&temp_path);
if icon_path.exists() {
return Ok(icon_path.to_string_lossy().to_string());
}
}
}
} else {
let _ = std::fs::remove_file(&temp_path);
}
Ok(icon_path.to_string_lossy().to_string())
} else {
let _ = std::fs::remove_file(&temp_path);
Err(format!("下载的内容不是有效图片: {}", url).into())
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct IconInfo {
name: String,
previous_t: String,
current_t: String,
}
/// 复制图标文件
#[tauri::command]
pub fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult<String> {
use std::fs;
use std::path::Path;
let file_path = Path::new(&path);
let icon_dir = wrap_err!(dirs::app_home_dir())?.join("icons");
if !icon_dir.exists() {
let _ = fs::create_dir_all(&icon_dir);
}
let ext = match file_path.extension() {
Some(e) => e.to_string_lossy().to_string(),
None => "ico".to_string(),
};
let dest_path = icon_dir.join(format!(
"{0}-{1}.{ext}",
icon_info.name, icon_info.current_t
));
if file_path.exists() {
if icon_info.previous_t.trim() != "" {
fs::remove_file(
icon_dir.join(format!("{0}-{1}.png", icon_info.name, icon_info.previous_t)),
)
.unwrap_or_default();
fs::remove_file(
icon_dir.join(format!("{0}-{1}.ico", icon_info.name, icon_info.previous_t)),
)
.unwrap_or_default();
}
match fs::copy(file_path, &dest_path) {
Ok(_) => Ok(dest_path.to_string_lossy().to_string()),
Err(err) => Err(err.to_string()),
}
} else {
Err("file not found".to_string())
}
}
+220
View File
@@ -0,0 +1,220 @@
use super::CmdResult;
use crate::{config::*, core::*, feat, module::mihomo::MihomoManager, wrap_err};
use serde_yaml::Mapping;
/// 复制Clash环境变量
#[tauri::command]
pub fn copy_clash_env() -> CmdResult {
feat::copy_clash_env();
Ok(())
}
/// 获取Clash信息
#[tauri::command]
pub fn get_clash_info() -> CmdResult<ClashInfo> {
Ok(Config::clash().latest().get_client_info())
}
/// 修改Clash配置
#[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
wrap_err!(feat::patch_clash(payload).await)
}
/// 修改Clash模式
#[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult {
Ok(feat::change_clash_mode(payload))
}
/// 切换Clash核心
#[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>> {
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))
}
}
}
/// 重启核心
#[tauri::command]
pub async fn restart_core() -> CmdResult {
wrap_err!(CoreManager::global().restart_core().await)
}
/// 获取代理延迟
#[tauri::command]
pub async fn clash_api_get_proxy_delay(
name: String,
url: Option<String>,
timeout: i32,
) -> CmdResult<serde_json::Value> {
MihomoManager::global()
.test_proxy_delay(&name, url, timeout)
.await
}
/// 测试URL延迟
#[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> {
Ok(feat::test_delay(url).await.unwrap_or(10000u32))
}
/// 保存DNS配置到单独文件
#[tauri::command]
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
use crate::utils::dirs;
use serde_yaml;
use std::fs;
// 获取DNS配置文件路径
let dns_path = dirs::app_home_dir()
.map_err(|e| e.to_string())?
.join("dns_config.yaml");
// 保存DNS配置到文件
let yaml_str = serde_yaml::to_string(&dns_config).map_err(|e| e.to_string())?;
fs::write(&dns_path, yaml_str).map_err(|e| e.to_string())?;
log::info!(target: "app", "DNS config saved to {:?}", dns_path);
Ok(())
}
/// 应用或撤销DNS配置
#[tauri::command]
pub fn apply_dns_config(apply: bool) -> CmdResult {
use crate::config::Config;
use crate::core::{handle, CoreManager};
use crate::utils::dirs;
use tauri::async_runtime;
// 使用spawn来处理异步操作
async_runtime::spawn(async move {
if apply {
// 读取DNS配置文件
let dns_path = match dirs::app_home_dir() {
Ok(path) => path.join("dns_config.yaml"),
Err(e) => {
log::error!(target: "app", "Failed to get home dir: {}", e);
return;
}
};
if !dns_path.exists() {
log::warn!(target: "app", "DNS config file not found");
return;
}
let dns_yaml = match std::fs::read_to_string(&dns_path) {
Ok(content) => content,
Err(e) => {
log::error!(target: "app", "Failed to read DNS config: {}", e);
return;
}
};
// 解析DNS配置并创建patch
let patch_config = match serde_yaml::from_str::<serde_yaml::Mapping>(&dns_yaml) {
Ok(config) => {
let mut patch = serde_yaml::Mapping::new();
patch.insert("dns".into(), config.into());
patch
}
Err(e) => {
log::error!(target: "app", "Failed to parse DNS config: {}", e);
return;
}
};
log::info!(target: "app", "Applying DNS config from file");
// 重新生成配置,确保DNS配置被正确应用
// 这里不调用patch_clash以避免将DNS配置写入config.yaml
Config::runtime()
.latest()
.patch_config(patch_config.clone());
// 首先重新生成配置
if let Err(err) = Config::generate().await {
log::error!(target: "app", "Failed to regenerate config with DNS: {}", err);
return;
}
// 然后应用新配置
if let Err(err) = CoreManager::global().update_config().await {
log::error!(target: "app", "Failed to apply config with DNS: {}", err);
} else {
log::info!(target: "app", "DNS config successfully applied");
handle::Handle::refresh_clash();
}
} else {
// 当关闭DNS设置时,不需要对配置进行任何修改
// 直接重新生成配置,让enhance函数自动跳过DNS配置的加载
log::info!(target: "app", "DNS settings disabled, regenerating config");
// 重新生成配置
if let Err(err) = Config::generate().await {
log::error!(target: "app", "Failed to regenerate config: {}", err);
return;
}
// 应用新配置
match CoreManager::global().update_config().await {
Ok(_) => {
log::info!(target: "app", "Config regenerated successfully");
handle::Handle::refresh_clash();
}
Err(err) => {
log::error!(target: "app", "Failed to apply regenerated config: {}", err);
}
}
}
});
Ok(())
}
/// 检查DNS配置文件是否存在
#[tauri::command]
pub fn check_dns_config_exists() -> CmdResult<bool> {
use crate::utils::dirs;
let dns_path = dirs::app_home_dir()
.map_err(|e| e.to_string())?
.join("dns_config.yaml");
Ok(dns_path.exists())
}
/// 获取DNS配置文件内容
#[tauri::command]
pub async fn get_dns_config_content() -> CmdResult<String> {
use crate::utils::dirs;
use std::fs;
let dns_path = dirs::app_home_dir()
.map_err(|e| e.to_string())?
.join("dns_config.yaml");
if !dns_path.exists() {
return Err("DNS config file not found".into());
}
let content = fs::read_to_string(&dns_path).map_err(|e| e.to_string())?;
Ok(content)
}
+32
View File
@@ -0,0 +1,32 @@
use anyhow::Result;
// Common result type used by command functions
pub type CmdResult<T = ()> = Result<T, String>;
// Command modules
pub mod profile;
pub mod validate;
pub mod uwp;
pub mod webdav;
pub mod app;
pub mod network;
pub mod clash;
pub mod verge;
pub mod runtime;
pub mod save_profile;
pub mod system;
pub mod proxy;
// Re-export all command functions for backwards compatibility
pub use profile::*;
pub use validate::*;
pub use uwp::*;
pub use webdav::*;
pub use app::*;
pub use network::*;
pub use clash::*;
pub use verge::*;
pub use runtime::*;
pub use save_profile::*;
pub use system::*;
pub use proxy::*;
+64
View File
@@ -0,0 +1,64 @@
use crate::wrap_err;
use super::CmdResult;
use sysproxy::{Autoproxy, Sysproxy};
use serde_yaml::Mapping;
use network_interface::NetworkInterface;
/// get the system proxy
#[tauri::command]
pub fn get_sys_proxy() -> CmdResult<Mapping> {
let current = wrap_err!(Sysproxy::get_system_proxy())?;
let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into());
map.insert(
"server".into(),
format!("{}:{}", current.host, current.port).into(),
);
map.insert("bypass".into(), current.bypass.into());
Ok(map)
}
/// get the system proxy
#[tauri::command]
pub fn get_auto_proxy() -> CmdResult<Mapping> {
let current = wrap_err!(Autoproxy::get_auto_proxy())?;
let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into());
map.insert("url".into(), current.url.into());
Ok(map)
}
/// 获取网络接口列表
#[tauri::command]
pub fn get_network_interfaces() -> Vec<String> {
use sysinfo::Networks;
let mut result = Vec::new();
let networks = Networks::new_with_refreshed_list();
for (interface_name, _) in &networks {
result.push(interface_name.clone());
}
result
}
/// 获取网络接口详细信息
#[tauri::command]
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
use network_interface::NetworkInterface;
use network_interface::NetworkInterfaceConfig;
let names = get_network_interfaces();
let interfaces = wrap_err!(NetworkInterface::show())?;
let mut result = Vec::new();
for interface in interfaces {
if names.contains(&interface.name) {
result.push(interface);
}
}
Ok(result)
}
+176
View File
@@ -0,0 +1,176 @@
use crate::{
config::*,
core::*,
feat,
utils::{dirs, help},
log_err, ret_err, wrap_err,
};
use super::CmdResult;
/// 获取配置文件列表
#[tauri::command]
pub fn get_profiles() -> CmdResult<IProfiles> {
let _ = tray::Tray::global().update_menu();
Ok(Config::profiles().data().clone())
}
/// 增强配置文件
#[tauri::command]
pub async fn enhance_profiles() -> CmdResult {
match CoreManager::global().update_config().await {
Ok((true, _)) => {
println!("[enhance_profiles] 配置更新成功");
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]
pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult {
let item = wrap_err!(PrfItem::from_url(&url, None, None, option).await)?;
wrap_err!(Config::profiles().data().append_item(item))
}
/// 重新排序配置文件
#[tauri::command]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
wrap_err!(Config::profiles().data().reorder(active_id, over_id))
}
/// 创建配置文件
#[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
let item = wrap_err!(PrfItem::from(item, file_data).await)?;
wrap_err!(Config::profiles().data().append_item(item))
}
/// 更新配置文件
#[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
wrap_err!(feat::update_profile(index, option).await)
}
/// 删除配置文件
#[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult {
let should_update = wrap_err!({ Config::profiles().data().delete_item(index) })?;
if should_update {
wrap_err!(CoreManager::global().update_config().await)?;
handle::Handle::refresh_clash();
}
Ok(())
}
/// 修改profiles的配置
#[tauri::command]
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) })?;
// 更新配置并进行验证
match CoreManager::global().update_config().await {
Ok((true, _)) => {
println!("[cmd配置patch] 配置更新成功");
handle::Handle::refresh_clash();
let _ = tray::Tray::global().update_tooltip();
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
Ok(true)
}
Ok((false, error_msg)) => {
println!("[cmd配置patch] 配置验证失败: {}", error_msg);
Config::profiles().discard();
// 如果验证失败,恢复到之前的配置
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的
#[tauri::command]
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
wrap_err!(Config::profiles().data().patch_item(index, profile))?;
Ok(())
}
/// 查看配置文件
#[tauri::command]
pub fn view_profile(app_handle: tauri::AppHandle, index: String) -> CmdResult {
let file = {
wrap_err!(Config::profiles().latest().get_item(&index))?
.file
.clone()
.ok_or("the file field is null")
}?;
let path = wrap_err!(dirs::app_profiles_dir())?.join(file);
if !path.exists() {
ret_err!("the file not found");
}
wrap_err!(help::open_file(app_handle, path))
}
/// 读取配置文件内容
#[tauri::command]
pub fn read_profile_file(index: String) -> CmdResult<String> {
let profiles = Config::profiles();
let profiles = profiles.latest();
let item = wrap_err!(profiles.get_item(&index))?;
let data = wrap_err!(item.read_file())?;
Ok(data)
}
+16
View File
@@ -0,0 +1,16 @@
use super::CmdResult;
use crate::module::mihomo::MihomoManager;
#[tauri::command]
pub async fn get_proxies() -> CmdResult<serde_json::Value> {
let mannager = MihomoManager::global();
mannager.refresh_proxies().await.unwrap();
Ok(mannager.get_proxies())
}
#[tauri::command]
pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
let mannager = MihomoManager::global();
mannager.refresh_providers_proxies().await.unwrap();
Ok(mannager.get_providers_proxies())
}
+39
View File
@@ -0,0 +1,39 @@
use crate::{
config::*,
wrap_err,
};
use super::CmdResult;
use anyhow::Context;
use std::collections::HashMap;
use serde_yaml::Mapping;
/// 获取运行时配置
#[tauri::command]
pub fn get_runtime_config() -> CmdResult<Option<Mapping>> {
Ok(Config::runtime().latest().config.clone())
}
/// 获取运行时YAML配置
#[tauri::command]
pub fn get_runtime_yaml() -> CmdResult<String> {
let runtime = Config::runtime();
let runtime = runtime.latest();
let config = runtime.config.as_ref();
wrap_err!(config
.ok_or(anyhow::anyhow!("failed to parse config to yaml file"))
.and_then(
|config| serde_yaml::to_string(config).context("failed to convert config to yaml")
))
}
/// 获取运行时存在的键
#[tauri::command]
pub fn get_runtime_exists() -> CmdResult<Vec<String>> {
Ok(Config::runtime().latest().exists_keys.clone())
}
/// 获取运行时日志
#[tauri::command]
pub fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
Ok(Config::runtime().latest().chain_logs.clone())
}
+111
View File
@@ -0,0 +1,111 @@
use crate::{
config::*,
core::*,
utils::dirs,
wrap_err,
};
use super::CmdResult;
use std::fs;
/// 保存profiles的配置
#[tauri::command]
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
if file_data.is_none() {
return Ok(());
}
// 在异步操作前完成所有文件操作
let (file_path, original_content, is_merge_file) = {
let profiles = Config::profiles();
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());
crate::cmd::validate::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());
crate::cmd::validate::handle_yaml_validation_notice(&result, "YAML配置文件");
} else if is_script_error {
// 脚本错误使用专门的通知处理
println!("[cmd配置save] 脚本文件验证失败,发送通知");
let result = (false, error_msg.clone());
crate::cmd::validate::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())
}
}
}
+35
View File
@@ -0,0 +1,35 @@
use super::CmdResult;
use crate::core::handle;
use crate::module::sysinfo::PlatformSpecification;
use tauri_plugin_clipboard_manager::ClipboardExt;
use crate::{core::{self, CoreManager, service}, wrap_err};
#[tauri::command]
pub async fn export_diagnostic_info() -> CmdResult<()> {
let sysinfo = PlatformSpecification::new();
let info = format!("{:?}", sysinfo);
let app_handle = handle::Handle::global().app_handle().unwrap();
let cliboard = app_handle.clipboard();
if let Err(_) = cliboard.write_text(info) {
log::error!(target: "app", "Failed to write to clipboard");
}
Ok(())
}
/// 获取当前内核运行模式
#[tauri::command]
pub async fn get_running_mode() -> Result<String, String> {
match CoreManager::global().get_running_mode().await {
core::RunningMode::Service => Ok("service".to_string()),
core::RunningMode::Sidecar => Ok("sidecar".to_string()),
core::RunningMode::NotRunning => Ok("not_running".to_string()),
}
}
/// 安装/重装系统服务
#[tauri::command]
pub async fn install_service() -> CmdResult {
wrap_err!(service::reinstall_service().await)
}
+29
View File
@@ -0,0 +1,29 @@
use super::CmdResult;
/// Platform-specific implementation for UWP functionality
#[cfg(windows)]
mod platform {
use super::CmdResult;
use crate::core::win_uwp;
use crate::wrap_err;
pub async fn invoke_uwp_tool() -> CmdResult {
wrap_err!(win_uwp::invoke_uwptools().await)
}
}
/// Stub implementation for non-Windows platforms
#[cfg(not(windows))]
mod platform {
use super::CmdResult;
pub async fn invoke_uwp_tool() -> CmdResult {
Ok(())
}
}
/// Command exposed to Tauri
#[tauri::command]
pub async fn invoke_uwp_tool() -> CmdResult {
platform::invoke_uwp_tool().await
}
+101
View File
@@ -0,0 +1,101 @@
use crate::core::*;
use super::CmdResult;
/// 发送脚本验证通知消息
#[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);
}
}
+20
View File
@@ -0,0 +1,20 @@
use crate::{
config::*,
feat,
wrap_err,
};
use super::CmdResult;
/// 获取Verge配置
#[tauri::command]
pub fn get_verge_config() -> CmdResult<IVergeResponse> {
let verge = Config::verge();
let verge_data = verge.data().clone();
Ok(IVergeResponse::from(verge_data))
}
/// 修改Verge配置
#[tauri::command]
pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
wrap_err!(feat::patch_verge(payload, false).await)
}
+51
View File
@@ -0,0 +1,51 @@
use crate::{
core,
config::*,
feat,
wrap_err,
};
use super::CmdResult;
use reqwest_dav::list_cmd::ListFile;
/// 保存 WebDAV 配置
#[tauri::command]
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> {
let patch = IVerge {
webdav_url: Some(url),
webdav_username: Some(username),
webdav_password: Some(password),
..IVerge::default()
};
Config::verge().draft().patch_config(patch.clone());
Config::verge().apply();
Config::verge()
.data()
.save_file()
.map_err(|err| err.to_string())?;
core::backup::WebDavClient::global().reset();
Ok(())
}
/// 创建 WebDAV 备份并上传
#[tauri::command]
pub async fn create_webdav_backup() -> CmdResult<()> {
wrap_err!(feat::create_backup_and_upload_webdav().await)
}
/// 列出 WebDAV 上的备份文件
#[tauri::command]
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> {
wrap_err!(feat::list_wevdav_backup().await)
}
/// 删除 WebDAV 上的备份文件
#[tauri::command]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::delete_webdav_backup(filename).await)
}
/// 从 WebDAV 恢复备份文件
#[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::restore_webdav_backup(filename).await)
}
-608
View File
@@ -1,608 +0,0 @@
use crate::{
config::*,
core::*,
feat,
utils::{dirs, help},
};
use crate::{log_err, ret_err, wrap_err};
use anyhow::{Context, Result};
use network_interface::NetworkInterface;
use serde_yaml::Mapping;
use std::collections::HashMap;
use sysproxy::{Autoproxy, Sysproxy};
type CmdResult<T = ()> = Result<T, String>;
use reqwest_dav::list_cmd::ListFile;
use tauri::Manager;
use std::fs;
#[tauri::command]
pub fn copy_clash_env() -> CmdResult {
feat::copy_clash_env();
Ok(())
}
#[tauri::command]
pub fn get_profiles() -> CmdResult<IProfiles> {
let _ = tray::Tray::global().update_menu();
Ok(Config::profiles().data().clone())
}
#[tauri::command]
pub async fn enhance_profiles() -> CmdResult {
match CoreManager::global().update_config().await {
Ok((true, _)) => {
println!("[enhance_profiles] 配置更新成功");
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]
pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult {
let item = wrap_err!(PrfItem::from_url(&url, None, None, option).await)?;
wrap_err!(Config::profiles().data().append_item(item))
}
#[tauri::command]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
wrap_err!(Config::profiles().data().reorder(active_id, over_id))
}
#[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
let item = wrap_err!(PrfItem::from(item, file_data).await)?;
wrap_err!(Config::profiles().data().append_item(item))
}
#[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
wrap_err!(feat::update_profile(index, option).await)
}
#[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult {
let should_update = wrap_err!({ Config::profiles().data().delete_item(index) })?;
if should_update {
wrap_err!(CoreManager::global().update_config().await)?;
handle::Handle::refresh_clash();
}
Ok(())
}
/// 修改profiles的配置
#[tauri::command]
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) })?;
// 更新配置并进行验证
match CoreManager::global().update_config().await {
Ok((true, _)) => {
println!("[cmd配置patch] 配置更新成功");
handle::Handle::refresh_clash();
let _ = tray::Tray::global().update_tooltip();
Config::profiles().apply();
wrap_err!(Config::profiles().data().save_file())?;
Ok(true)
}
Ok((false, error_msg)) => {
println!("[cmd配置patch] 配置验证失败: {}", error_msg);
Config::profiles().discard();
// 如果验证失败,恢复到之前的配置
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的
#[tauri::command]
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
wrap_err!(Config::profiles().data().patch_item(index, profile))?;
Ok(())
}
#[tauri::command]
pub fn view_profile(app_handle: tauri::AppHandle, index: String) -> CmdResult {
let file = {
wrap_err!(Config::profiles().latest().get_item(&index))?
.file
.clone()
.ok_or("the file field is null")
}?;
let path = wrap_err!(dirs::app_profiles_dir())?.join(file);
if !path.exists() {
ret_err!("the file not found");
}
wrap_err!(help::open_file(app_handle, path))
}
#[tauri::command]
pub fn read_profile_file(index: String) -> CmdResult<String> {
let profiles = Config::profiles();
let profiles = profiles.latest();
let item = wrap_err!(profiles.get_item(&index))?;
let data = wrap_err!(item.read_file())?;
Ok(data)
}
/// 保存profiles的配置
#[tauri::command]
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
if file_data.is_none() {
return Ok(());
}
// 在异步操作前完成所有文件操作
let (file_path, original_content) = {
let profiles = Config::profiles();
let profiles_guard = profiles.latest();
let item = wrap_err!(profiles_guard.get_item(&index))?;
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)
};
// 保存新的配置文件
wrap_err!(fs::write(&file_path, file_data.clone().unwrap()))?;
let file_path_str = file_path.to_string_lossy();
println!("[cmd配置save] 开始验证配置文件: {}", file_path_str);
// 验证配置文件
match CoreManager::global().validate_config_file(&file_path_str).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 is_script_error {
// 脚本错误使用专门的通知处理
let result = (false, error_msg.clone());
handle_script_validation_notice(&result, "脚本文件");
} else {
// 普通配置错误使用一般通知
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]
pub fn get_clash_info() -> CmdResult<ClashInfo> {
Ok(Config::clash().latest().get_client_info())
}
#[tauri::command]
pub fn get_runtime_config() -> CmdResult<Option<Mapping>> {
Ok(Config::runtime().latest().config.clone())
}
#[tauri::command]
pub fn get_runtime_yaml() -> CmdResult<String> {
let runtime = Config::runtime();
let runtime = runtime.latest();
let config = runtime.config.as_ref();
wrap_err!(config
.ok_or(anyhow::anyhow!("failed to parse config to yaml file"))
.and_then(
|config| serde_yaml::to_string(config).context("failed to convert config to yaml")
))
}
#[tauri::command]
pub fn get_runtime_exists() -> CmdResult<Vec<String>> {
Ok(Config::runtime().latest().exists_keys.clone())
}
#[tauri::command]
pub fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
Ok(Config::runtime().latest().chain_logs.clone())
}
#[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
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]
pub fn get_verge_config() -> CmdResult<IVergeResponse> {
let verge = Config::verge();
let verge_data = verge.data().clone();
Ok(IVergeResponse::from(verge_data))
}
#[tauri::command]
pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
wrap_err!(feat::patch_verge(payload).await)
}
#[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>> {
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
#[tauri::command]
pub async fn restart_core() -> CmdResult {
wrap_err!(CoreManager::global().restart_core().await)
}
/// get the system proxy
#[tauri::command]
pub fn get_sys_proxy() -> CmdResult<Mapping> {
let current = wrap_err!(Sysproxy::get_system_proxy())?;
let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into());
map.insert(
"server".into(),
format!("{}:{}", current.host, current.port).into(),
);
map.insert("bypass".into(), current.bypass.into());
Ok(map)
}
/// get the system proxy
#[tauri::command]
pub fn get_auto_proxy() -> CmdResult<Mapping> {
let current = wrap_err!(Autoproxy::get_auto_proxy())?;
let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into());
map.insert("url".into(), current.url.into());
Ok(map)
}
#[tauri::command]
pub fn open_app_dir() -> CmdResult<()> {
let app_dir = wrap_err!(dirs::app_home_dir())?;
wrap_err!(open::that(app_dir))
}
#[tauri::command]
pub fn open_core_dir() -> CmdResult<()> {
let core_dir = wrap_err!(tauri::utils::platform::current_exe())?;
let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
wrap_err!(open::that(core_dir))
}
#[tauri::command]
pub fn open_logs_dir() -> CmdResult<()> {
let log_dir = wrap_err!(dirs::app_logs_dir())?;
wrap_err!(open::that(log_dir))
}
#[tauri::command]
pub fn open_web_url(url: String) -> CmdResult<()> {
wrap_err!(open::that(url))
}
#[cfg(windows)]
pub mod uwp {
use super::*;
use crate::core::win_uwp;
#[tauri::command]
pub async fn invoke_uwp_tool() -> CmdResult {
wrap_err!(win_uwp::invoke_uwptools().await)
}
}
#[tauri::command]
pub async fn clash_api_get_proxy_delay(
name: String,
url: Option<String>,
timeout: i32,
) -> CmdResult<clash_api::DelayRes> {
match clash_api::get_proxy_delay(name, url, timeout).await {
Ok(res) => Ok(res),
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
pub fn get_portable_flag() -> CmdResult<bool> {
Ok(*dirs::PORTABLE_FLAG.get().unwrap_or(&false))
}
#[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> {
Ok(feat::test_delay(url).await.unwrap_or(10000u32))
}
#[tauri::command]
pub fn get_app_dir() -> CmdResult<String> {
let app_home_dir = wrap_err!(dirs::app_home_dir())?
.to_string_lossy()
.to_string();
Ok(app_home_dir)
}
#[tauri::command]
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> {
let icon_cache_dir = wrap_err!(dirs::app_home_dir())?.join("icons").join("cache");
let icon_path = icon_cache_dir.join(name);
if !icon_cache_dir.exists() {
let _ = std::fs::create_dir_all(&icon_cache_dir);
}
if !icon_path.exists() {
let response = wrap_err!(reqwest::get(url).await)?;
let mut file = wrap_err!(std::fs::File::create(&icon_path))?;
let content = wrap_err!(response.bytes().await)?;
wrap_err!(std::io::copy(&mut content.as_ref(), &mut file))?;
}
Ok(icon_path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn copy_icon_file(path: String, name: String) -> CmdResult<String> {
let file_path = std::path::Path::new(&path);
let icon_dir = wrap_err!(dirs::app_home_dir())?.join("icons");
if !icon_dir.exists() {
let _ = std::fs::create_dir_all(&icon_dir);
}
let ext = match file_path.extension() {
Some(e) => e.to_string_lossy().to_string(),
None => "ico".to_string(),
};
let png_dest_path = icon_dir.join(format!("{name}.png"));
let ico_dest_path = icon_dir.join(format!("{name}.ico"));
let dest_path = icon_dir.join(format!("{name}.{ext}"));
if file_path.exists() {
std::fs::remove_file(png_dest_path).unwrap_or_default();
std::fs::remove_file(ico_dest_path).unwrap_or_default();
match std::fs::copy(file_path, &dest_path) {
Ok(_) => Ok(dest_path.to_string_lossy().to_string()),
Err(err) => Err(err.to_string()),
}
} else {
Err("file not found".to_string())
}
}
#[tauri::command]
pub fn get_network_interfaces() -> Vec<String> {
use sysinfo::Networks;
let mut result = Vec::new();
let networks = Networks::new_with_refreshed_list();
for (interface_name, _) in &networks {
result.push(interface_name.clone());
}
result
}
#[tauri::command]
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
use network_interface::NetworkInterface;
use network_interface::NetworkInterfaceConfig;
let names = get_network_interfaces();
let interfaces = wrap_err!(NetworkInterface::show())?;
let mut result = Vec::new();
for interface in interfaces {
if names.contains(&interface.name) {
result.push(interface);
}
}
Ok(result)
}
#[tauri::command]
pub fn open_devtools(app_handle: tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if !window.is_devtools_open() {
window.open_devtools();
} else {
window.close_devtools();
}
}
}
#[tauri::command]
pub fn exit_app() {
feat::quit(Some(0));
}
#[tauri::command]
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> {
let patch = IVerge {
webdav_url: Some(url),
webdav_username: Some(username),
webdav_password: Some(password),
..IVerge::default()
};
Config::verge().draft().patch_config(patch.clone());
Config::verge().apply();
Config::verge()
.data()
.save_file()
.map_err(|err| err.to_string())?;
backup::WebDavClient::global().reset();
Ok(())
}
#[tauri::command]
pub async fn create_webdav_backup() -> CmdResult<()> {
wrap_err!(feat::create_backup_and_upload_webdav().await)
}
#[tauri::command]
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> {
wrap_err!(feat::list_wevdav_backup().await)
}
#[tauri::command]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::delete_webdav_backup(filename).await)
}
#[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
wrap_err!(feat::restore_webdav_backup(filename).await)
}
#[tauri::command]
pub async fn restart_app() -> CmdResult<()> {
feat::restart_app();
Ok(())
}
#[cfg(not(windows))]
pub mod uwp {
use super::*;
#[tauri::command]
pub async fn invoke_uwp_tool() -> CmdResult {
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).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)
}
}
}
+1
View File
@@ -38,6 +38,7 @@ impl IClashTemp {
tun.insert("strict-route".into(), false.into());
tun.insert("auto-detect-interface".into(), true.into());
tun.insert("dns-hijack".into(), vec!["any:53"].into());
#[cfg(not(target_os = "windows"))]
map.insert("redir-port".into(), 7895.into());
#[cfg(target_os = "linux")]
+1 -1
View File
@@ -16,7 +16,7 @@ pub fn encrypt_data(data: &str) -> Result<String, Box<dyn std::error::Error>> {
// Generate random nonce
let mut nonce = vec![0u8; NONCE_LENGTH];
getrandom::getrandom(&mut nonce)?;
getrandom::fill(&mut nonce)?;
// Encrypt data
let ciphertext = cipher
+7
View File
@@ -70,6 +70,9 @@ pub struct IVerge {
/// enable proxy guard
pub enable_proxy_guard: Option<bool>,
/// enable dns settings - this controls whether dns_config.yaml is applied
pub enable_dns_settings: Option<bool>,
/// always use default bypass
pub use_default_bypass: Option<bool>,
@@ -287,6 +290,7 @@ impl IVerge {
enable_tray_speed: Some(true),
enable_global_hotkey: Some(true),
enable_lite_mode: Some(false),
enable_dns_settings: Some(true),
..Self::default()
}
}
@@ -369,6 +373,7 @@ impl IVerge {
patch!(webdav_password);
patch!(enable_tray_speed);
patch!(enable_lite_mode);
patch!(enable_dns_settings);
}
/// 在初始化前尝试拿到单例端口的值
@@ -458,6 +463,7 @@ pub struct IVergeResponse {
pub webdav_password: Option<String>,
pub enable_tray_speed: Option<bool>,
pub enable_lite_mode: Option<bool>,
pub enable_dns_settings: Option<bool>,
}
impl From<IVerge> for IVergeResponse {
@@ -521,6 +527,7 @@ impl From<IVerge> for IVergeResponse {
webdav_password: verge.webdav_password,
enable_tray_speed: verge.enable_tray_speed,
enable_lite_mode: verge.enable_lite_mode,
enable_dns_settings: verge.enable_dns_settings,
}
}
}
-156
View File
@@ -1,156 +0,0 @@
use crate::config::Config;
use anyhow::{bail, Result};
use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Rate {
pub up: u64,
pub down: u64,
}
/// PUT /configs
/// path 是绝对路径
pub async fn put_configs(path: &str) -> Result<()> {
let (url, headers) = clash_client_info()?;
let url = format!("{url}/configs?force=true");
let mut data = HashMap::new();
data.insert("path", path);
let client = reqwest::ClientBuilder::new().no_proxy().build()?;
let builder = client.put(&url).headers(headers).json(&data);
let response = builder.send().await?;
match response.status().as_u16() {
204 => Ok(()),
status => {
let body = response.text().await?;
// print!("failed to put configs with status \"{}\"\n{}\n{}", status, url, body);
bail!("failed to put configs with status \"{status}\"\n{url}\n{body}");
}
}
}
/// PATCH /configs
pub async fn patch_configs(config: &Mapping) -> Result<()> {
let (url, headers) = clash_client_info()?;
let url = format!("{url}/configs");
let client = reqwest::ClientBuilder::new().no_proxy().build()?;
let builder = client.patch(&url).headers(headers.clone()).json(config);
builder.send().await?;
Ok(())
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct DelayRes {
delay: u64,
}
/// GET /proxies/{name}/delay
/// 获取代理延迟
pub async fn get_proxy_delay(
name: String,
test_url: Option<String>,
timeout: i32,
) -> Result<DelayRes> {
let (url, headers) = clash_client_info()?;
let url = format!("{url}/proxies/{name}/delay");
let default_url = "http://cp.cloudflare.com/generate_204";
let test_url = test_url
.map(|s| if s.is_empty() { default_url.into() } else { s })
.unwrap_or(default_url.into());
let client = reqwest::ClientBuilder::new().no_proxy().build()?;
let builder = client
.get(&url)
.headers(headers)
.query(&[("timeout", &format!("{timeout}")), ("url", &test_url)]);
let response = builder.send().await?;
Ok(response.json::<DelayRes>().await?)
}
/// 根据clash info获取clash服务地址和请求头
fn clash_client_info() -> Result<(String, HeaderMap)> {
let client = { Config::clash().data().get_client_info() };
let server = format!("http://{}", client.server);
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "application/json".parse()?);
if let Some(secret) = client.secret {
let secret = format!("Bearer {}", secret).parse()?;
headers.insert("Authorization", secret);
}
Ok((server, headers))
}
/// 缩短clash的日志
#[allow(dead_code)]
pub fn parse_log(log: String) -> String {
if log.starts_with("time=") && log.len() > 33 {
return (log[33..]).to_owned();
}
if log.len() > 9 {
return (log[9..]).to_owned();
}
log
}
#[allow(dead_code)]
pub fn parse_check_output(log: String) -> String {
let t = log.find("time=");
let m = log.find("msg=");
let mr = log.rfind('"');
if let (Some(_), Some(m), Some(mr)) = (t, m, mr) {
let e = match log.find("level=error msg=") {
Some(e) => e + 17,
None => m + 5,
};
if mr > m {
return (log[e..mr]).to_owned();
}
}
let l = log.find("error=");
let r = log.find("path=").or(Some(log.len()));
if let (Some(l), Some(r)) = (l, r) {
return (log[(l + 6)..(r - 1)]).to_owned();
}
log
}
#[cfg(target_os = "macos")]
pub fn get_traffic_ws_url() -> Result<String> {
let (url, _) = clash_client_info()?;
let ws_url = url.replace("http://", "ws://") + "/traffic";
Ok(ws_url)
}
#[test]
fn test_parse_check_output() {
let str1 = r#"xxxx\n time="2022-11-18T20:42:58+08:00" level=error msg="proxy 0: 'alpn' expected type 'string', got unconvertible type '[]interface {}'""#;
//let str2 = r#"20:43:49 ERR [Config] configuration file test failed error=proxy 0: unsupport proxy type: hysteria path=xxx"#;
let str3 = r#"
"time="2022-11-18T21:38:01+08:00" level=info msg="Start initial configuration in progress"
time="2022-11-18T21:38:01+08:00" level=error msg="proxy 0: 'alpn' expected type 'string', got unconvertible type '[]interface {}'"
configuration file xxx\n
"#;
let res1 = parse_check_output(str1.into());
// let res2 = parse_check_output(str2.into());
let res3 = parse_check_output(str3.into());
assert_eq!(res1, res3);
}
+266 -72
View File
@@ -1,13 +1,13 @@
use crate::config::*;
use crate::core::{clash_api, handle, service};
#[cfg(target_os = "macos")]
use crate::core::tray::Tray;
use crate::core::{handle, service};
use crate::log_err;
use crate::module::mihomo::MihomoManager;
use crate::utils::{dirs, help};
use anyhow::{bail, Result};
use once_cell::sync::OnceCell;
use serde_yaml::Mapping;
use std::{sync::Arc, time::Duration};
use std::{path::PathBuf, sync::Arc, time::Duration};
use tauri_plugin_shell::ShellExt;
use tokio::sync::Mutex;
use tokio::time::sleep;
@@ -17,6 +17,17 @@ pub struct CoreManager {
running: Arc<Mutex<bool>>,
}
/// 内核运行模式
#[derive(Debug, Clone, serde::Serialize)]
pub enum RunningMode {
/// 服务模式运行
Service,
/// Sidecar模式运行
Sidecar,
/// 未运行
NotRunning,
}
impl CoreManager {
pub fn global() -> &'static CoreManager {
static CORE_MANAGER: OnceCell<CoreManager> = OnceCell::new();
@@ -43,22 +54,49 @@ impl CoreManager {
}
// 关闭tun模式
let mut disable = Mapping::new();
let mut tun = Mapping::new();
tun.insert("enable".into(), false.into());
disable.insert("tun".into(), tun.into());
// Create a JSON object to disable TUN mode
let disable = serde_json::json!({
"tun": {
"enable": false
}
});
log::debug!(target: "app", "disable tun mode");
log_err!(clash_api::patch_configs(&disable).await);
log_err!(MihomoManager::global().patch_configs(disable).await);
// 服务模式
if service::check_service().await.is_ok() {
log::info!(target: "app", "stop the core by service");
service::stop_core_by_service().await?;
match service::stop_core_by_service().await {
Ok(_) => {
log::info!(target: "app", "core stopped successfully by service");
}
Err(err) => {
log::warn!(target: "app", "failed to stop core by service: {}", err);
// 服务停止失败,尝试停止可能的sidecar进程
self.stop_sidecar_process();
}
}
} else {
// 如果没有使用服务,尝试停止sidecar进程
self.stop_sidecar_process();
}
*running = false;
Ok(())
}
/// 停止通过sidecar启动的进程
fn stop_sidecar_process(&self) {
if let Some(process) = handle::Handle::global().take_core_process() {
log::info!(target: "app", "stopping core process in sidecar mode");
if let Err(e) = process.kill() {
log::warn!(target: "app", "failed to kill core process: {}", e);
} else {
log::info!(target: "app", "core process stopped successfully");
}
}
}
/// 启动核心
pub async fn start_core(&self) -> Result<()> {
let mut running = self.running.lock().await;
@@ -69,11 +107,26 @@ impl CoreManager {
let config_path = Config::generate_file(ConfigType::Run)?;
// 服务模式
// 先尝试服务模式
if service::check_service().await.is_ok() {
log::info!(target: "app", "try to run core in service mode");
service::run_core_by_service(&config_path).await?;
match service::run_core_by_service(&config_path).await {
Ok(_) => {
log::info!(target: "app", "core started successfully in service mode");
}
Err(err) => {
// 服务启动失败,尝试sidecar模式
log::warn!(target: "app", "failed to start core in service mode: {}", err);
log::info!(target: "app", "trying to run core in sidecar mode");
self.run_core_by_sidecar(&config_path).await?;
}
}
} else {
// 服务不可用,直接使用sidecar模式
log::info!(target: "app", "service not available, running core in sidecar mode");
self.run_core_by_sidecar(&config_path).await?;
}
// 流量订阅
#[cfg(target_os = "macos")]
log_err!(Tray::global().subscribe_traffic().await);
@@ -83,11 +136,45 @@ impl CoreManager {
Ok(())
}
/// 通过sidecar启动内核
async fn run_core_by_sidecar(&self, config_path: &PathBuf) -> Result<()> {
let clash_core = { Config::verge().latest().clash_core.clone() };
let clash_core = clash_core.unwrap_or("verge-mihomo".into());
log::info!(target: "app", "starting core {} in sidecar mode", clash_core);
let app_handle = handle::Handle::global()
.app_handle()
.ok_or(anyhow::anyhow!("failed to get app handle"))?;
// 获取配置目录
let config_dir = dirs::app_home_dir()?;
let config_path_str = dirs::path_to_str(config_path)?;
// 启动核心进程并转入后台运行
let (_, child) = app_handle
.shell()
.sidecar(clash_core)?
.args(["-d", dirs::path_to_str(&config_dir)?, "-f", config_path_str])
.spawn()?;
// 保存进程ID以便后续管理
handle::Handle::global().set_core_process(child);
// 等待短暂时间确保启动成功
sleep(Duration::from_millis(300)).await;
log::info!(target: "app", "core started in sidecar mode");
Ok(())
}
/// 重启内核
pub async fn restart_core(&self) -> Result<()> {
// 重新启动app
log::info!(target: "app", "restarting core");
self.stop_core().await?;
self.start_core().await?;
log::info!(target: "app", "core restarted successfully");
Ok(())
}
@@ -118,10 +205,10 @@ impl CoreManager {
}
log::info!(target: "app", "change core to `{clash_core}`");
// 1. 先更新内核配置(但不应用)
Config::verge().draft().clash_core = Some(clash_core);
// 2. 使用新内核验证配置
println!("[切换内核] 使用新内核验证配置");
match self.validate_config().await {
@@ -130,7 +217,7 @@ impl CoreManager {
// 3. 验证通过后,应用内核配置并重启
Config::verge().apply();
log_err!(Config::verge().latest().save_file());
match self.restart_core().await {
Ok(_) => {
println!("[切换内核] 内核切换成功");
@@ -139,19 +226,22 @@ impl CoreManager {
}
Err(err) => {
println!("[切换内核] 内核切换失败: {}", err);
Config::verge().discard();
Config::runtime().discard();
Err(err)
// 即使使用服务失败,我们也尝试使用sidecar模式启动
log::info!(target: "app", "trying sidecar mode after service failure");
self.start_core().await?;
Config::runtime().apply();
Ok(())
}
}
}
Ok((false, error_msg)) => {
println!("[切换内核] 配置验证失败: {}", error_msg);
// 使用默认配置并继续切换内核
self.use_default_config("config_validate::core_change", &error_msg).await?;
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!("[切换内核] 内核切换成功(使用默认配置)");
@@ -159,8 +249,10 @@ impl CoreManager {
}
Err(err) => {
println!("[切换内核] 内核切换失败: {}", err);
Config::verge().discard();
Err(err)
// 即使使用服务失败,我们也尝试使用sidecar模式启动
log::info!(target: "app", "trying sidecar mode after service failure with default config");
self.start_core().await?;
Ok(())
}
}
}
@@ -174,12 +266,18 @@ impl CoreManager {
/// 内部验证配置文件的实现
async fn validate_config_internal(&self, config_path: &str) -> Result<(bool, String)> {
// 检查程序是否正在退出,如果是则跳过验证
if handle::Handle::global().is_exiting() {
println!("[core配置验证] 应用正在退出,跳过验证");
return Ok((true, String::new()));
}
println!("[core配置验证] 开始验证配置文件: {}", config_path);
let clash_core = { Config::verge().latest().clash_core.clone() };
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)?;
@@ -196,14 +294,15 @@ impl CoreManager {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// 检查进程退出状态和错误输出
let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"];
let has_error = !output.status.success() || error_keywords.iter().any(|&kw| stderr.contains(kw));
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);
}
@@ -224,7 +323,7 @@ impl CoreManager {
};
println!("[core配置验证] -------- 验证结束 --------\n");
Ok((false, error_msg)) // 返回错误消息给调用者处理
Ok((false, error_msg)) // 返回错误消息给调用者处理
} else {
println!("[core配置验证] 验证成功");
println!("[core配置验证] -------- 验证结束 --------\n");
@@ -240,14 +339,33 @@ impl CoreManager {
}
/// 验证指定的配置文件
pub async fn validate_config_file(&self, config_path: &str) -> Result<(bool, String)> {
pub async fn validate_config_file(
&self,
config_path: &str,
is_merge_file: Option<bool>,
) -> Result<(bool, String)> {
// 检查程序是否正在退出,如果是则跳过验证
if handle::Handle::global().is_exiting() {
println!("[core配置验证] 应用正在退出,跳过验证");
return Ok((true, String::new()));
}
// 检查文件是否存在
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
@@ -261,12 +379,12 @@ impl CoreManager {
}
}
};
if is_script {
log::info!(target: "app", "检测到脚本文件,使用JavaScript验证: {}", config_path);
return self.validate_script_file(config_path).await;
}
// 对YAML配置文件使用Clash内核验证
log::info!(target: "app", "使用Clash内核验证配置文件: {}", config_path);
self.validate_config_internal(config_path).await
@@ -280,33 +398,36 @@ impl CoreManager {
} 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));
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("- "));
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 ");
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特征
@@ -317,22 +438,21 @@ impl CoreManager {
} else if has_yaml_features && has_js_features {
// 两种特征都有,需要更精细判断
// 优先检查是否有明确的JS结构特征
if content.contains("function main") ||
content.contains("module.exports") ||
content.contains("export default") {
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();
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)
@@ -345,33 +465,37 @@ impl CoreManager {
Ok(content) => content,
Err(err) => {
let error_msg = format!("Failed to read script file: {}", err);
//handle::Handle::notice_message("config_validate::script_error", &error_msg);
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") {
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);
@@ -383,12 +507,18 @@ impl CoreManager {
/// 更新proxies等配置
pub async fn update_config(&self) -> Result<(bool, String)> {
// 检查程序是否正在退出,如果是则跳过完整验证流程
if handle::Handle::global().is_exiting() {
println!("[core配置更新] 应用正在退出,跳过验证");
return Ok((true, String::new()));
}
println!("[core配置更新] 开始更新配置");
// 1. 先生成新的配置内容
println!("[core配置更新] 生成新的配置内容");
Config::generate().await?;
// 2. 生成临时文件并进行验证
println!("[core配置更新] 生成临时配置文件用于验证");
let temp_config = Config::generate_file(ConfigType::Check)?;
@@ -407,7 +537,7 @@ impl CoreManager {
// 5. 应用新配置
println!("[core配置更新] 应用新配置");
for i in 0..3 {
match clash_api::put_configs(run_path).await {
match MihomoManager::global().put_configs_force(run_path).await {
Ok(_) => {
println!("[core配置更新] 配置应用成功");
Config::runtime().apply();
@@ -440,4 +570,68 @@ impl CoreManager {
}
}
}
}
/// 只进行文件语法检查,不进行完整验证
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))
}
}
}
/// 获取当前内核运行模式
pub async fn get_running_mode(&self) -> RunningMode {
let running = self.running.lock().await;
if !*running {
return RunningMode::NotRunning;
}
// 检查服务状态
match service::check_service().await {
Ok(_) => {
// 检查服务是否实际运行核心
match service::is_service_running().await {
Ok(true) => RunningMode::Service,
_ => {
// 服务存在但可能没有运行,检查是否有sidecar进程
if handle::Handle::global().has_core_process() {
RunningMode::Sidecar
} else {
RunningMode::NotRunning
}
}
}
}
Err(_) => {
// 服务不可用,检查是否有sidecar进程
if handle::Handle::global().has_core_process() {
RunningMode::Sidecar
} else {
RunningMode::NotRunning
}
}
}
}
}
+18
View File
@@ -3,11 +3,13 @@ use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
use tauri_plugin_shell::process::CommandChild;
#[derive(Debug, Default, Clone)]
pub struct Handle {
pub app_handle: Arc<RwLock<Option<AppHandle>>>,
pub is_exiting: Arc<RwLock<bool>>,
pub core_process: Arc<RwLock<Option<CommandChild>>>,
}
impl Handle {
@@ -17,6 +19,7 @@ impl Handle {
HANDLE.get_or_init(|| Handle {
app_handle: Arc::new(RwLock::new(None)),
is_exiting: Arc::new(RwLock::new(false)),
core_process: Arc::new(RwLock::new(None)),
})
}
@@ -68,6 +71,21 @@ impl Handle {
*is_exiting = true;
}
pub fn set_core_process(&self, process: CommandChild) {
let mut core_process = self.core_process.write();
*core_process = Some(process);
}
pub fn take_core_process(&self) -> Option<CommandChild> {
let mut core_process = self.core_process.write();
core_process.take()
}
/// 检查是否有运行中的核心进程
pub fn has_core_process(&self) -> bool {
self.core_process.read().is_some()
}
pub fn is_exiting(&self) -> bool {
*self.is_exiting.read()
}
+1 -1
View File
@@ -140,7 +140,7 @@ impl Hotkey {
"clash_mode_global" => || feat::change_clash_mode("global".into()),
"clash_mode_direct" => || feat::change_clash_mode("direct".into()),
"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)),
_ => {
-1
View File
@@ -1,5 +1,4 @@
pub mod backup;
pub mod clash_api;
#[allow(clippy::module_inception)]
mod core;
pub mod handle;
+75 -1
View File
@@ -10,6 +10,7 @@ use tokio::time::Duration;
// Windows only
const SERVICE_URL: &str = "http://127.0.0.1:33211";
const REQUIRED_SERVICE_VERSION: &str = "1.0.1"; // 定义所需的服务版本号
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ResponseBody {
@@ -19,6 +20,12 @@ pub struct ResponseBody {
pub log_file: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct VersionResponse {
pub service: String,
pub version: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct JsonResponse {
pub code: u64,
@@ -26,6 +33,13 @@ pub struct JsonResponse {
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")]
pub async fn reinstall_service() -> Result<()> {
log::info!(target:"app", "reinstall service");
@@ -140,8 +154,21 @@ pub async fn reinstall_service() -> Result<()> {
let install_shell: String = install_path.to_string_lossy().into_owned();
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
// 获取提示文本,如果 i18n 失败则使用硬编码默认值
let prompt = crate::utils::i18n::t("Service Administrator Prompt");
let prompt = if prompt == "Service Administrator Prompt" {
if Config::verge().latest().language.as_deref() == Some("zh") || Config::verge().latest().language.is_none() {
"Clash Verge 需要使用管理员权限来重新安装系统服务"
} else {
"Clash Verge needs administrator privileges to reinstall the system service"
}
} else {
&prompt
};
let command = format!(
r#"do shell script "sudo '{uninstall_shell}' && sudo '{install_shell}'" with administrator privileges"#
r#"do shell script "sudo '{uninstall_shell}' && sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#
);
log::debug!(target: "app", "command: {}", command);
@@ -177,8 +204,43 @@ pub async fn check_service() -> Result<JsonResponse> {
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
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 = clash_core.unwrap_or("verge-mihomo".into());
@@ -230,3 +292,15 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
Ok(())
}
/// 检查服务是否正在运行
pub async fn is_service_running() -> Result<bool> {
let resp = check_service().await?;
// 检查服务状态码和消息
if resp.code == 200 && resp.msg == "success" && resp.data.is_some() {
Ok(true)
} else {
Ok(false)
}
}
+8 -8
View File
@@ -1,9 +1,9 @@
use once_cell::sync::OnceCell;
#[cfg(target_os = "macos")]
pub mod speed_rate;
use crate::core::clash_api::Rate;
use crate::module::mihomo::Rate;
use crate::{
cmds,
cmd,
config::Config,
feat, resolve,
utils::resolve::VERSION,
@@ -87,7 +87,7 @@ impl Tray {
{
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"tun_mode" => feat::toggle_tun_mode(None),
"main_window" => resolve::create_window(),
_ => {}
}
@@ -102,7 +102,7 @@ impl Tray {
{
match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"tun_mode" => feat::toggle_tun_mode(None),
"main_window" => resolve::create_window(),
_ => {}
}
@@ -594,11 +594,11 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}
"open_window" => resolve::create_window(),
"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(),
"open_app_dir" => crate::log_err!(cmds::open_app_dir()),
"open_core_dir" => crate::log_err!(cmds::open_core_dir()),
"open_logs_dir" => crate::log_err!(cmds::open_logs_dir()),
"open_app_dir" => crate::log_err!(cmd::open_app_dir()),
"open_core_dir" => crate::log_err!(cmd::open_core_dir()),
"open_logs_dir" => crate::log_err!(cmd::open_logs_dir()),
"restart_clash" => feat::restart_clash_core(),
"restart_app" => feat::restart_app(),
"quit" => {
+44 -31
View File
@@ -1,15 +1,17 @@
use crate::core::clash_api::{get_traffic_ws_url, Rate};
use crate::module::mihomo::Rate;
use crate::module::mihomo::MihomoManager;
use crate::utils::help::format_bytes_speed;
use ab_glyph::FontArc;
use anyhow::Result;
use futures::Stream;
use image::{Rgba, GenericImageView, RgbaImage};
use image::{GenericImageView, Rgba, RgbaImage};
use imageproc::drawing::draw_text_mut;
use parking_lot::Mutex;
use rusttype::{Font, Scale};
use std::io::Cursor;
use std::sync::Arc;
use tokio_tungstenite::tungstenite::http;
use tokio_tungstenite::tungstenite::Message;
use tungstenite::client::IntoClientRequest;
#[derive(Debug, Clone)]
pub struct SpeedRate {
rate: Arc<Mutex<(Rate, Rate)>>,
@@ -29,7 +31,7 @@ impl SpeedRate {
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;
@@ -40,7 +42,8 @@ impl SpeedRate {
// 如果速率变化不大(小于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);
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
};
@@ -69,21 +72,22 @@ impl SpeedRate {
// 分离图标加载和速率渲染
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 icon_image = image::load_from_memory(&icon_bytes)?;
let (icon_width, icon_height) = (icon_image.width(), icon_image.height());
// 判断是否为彩色图标
let is_colorful = !crate::utils::help::is_monochrome_image_from_bytes(&icon_bytes).unwrap_or(false);
let is_colorful =
!crate::utils::help::is_monochrome_image_from_bytes(&icon_bytes).unwrap_or(false);
// 增加文本宽度和间距
let text_width = 580; // 文本区域宽度
let text_width = 580; // 文本区域宽度
let total_width = icon_width + text_width;
// 创建新的透明画布
let mut combined_image = RgbaImage::new(total_width, icon_height);
// 将原始图标绘制到新画布的左侧
for y in 0..icon_height {
for x in 0..icon_width {
@@ -91,43 +95,49 @@ impl SpeedRate {
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]))
(
Rgba([255u8, 255u8, 255u8, 255u8]),
Rgba([0u8, 0u8, 0u8, 160u8]),
)
} else {
// 单色图标使用白色文本和轻微黑色阴影
(Rgba([255u8, 255u8, 255u8, 255u8]), Rgba([0u8, 0u8, 0u8, 120u8]))
(
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 font_data = include_bytes!("../../../assets/fonts/SF-Pro.ttf");
let font = FontArc::try_from_vec(font_data.to_vec()).unwrap();
let font_size = icon_height as f32 * 0.6; // 稍微减小字体
let scale = ab_glyph::PxScale::from(font_size);
// 使用更简洁的速率格式
let up_text = format_bytes_speed(rate.up);
let down_text = format_bytes_speed(rate.down);
// 计算文本位置,确保垂直间距合适
// 修改文本位置为居右显示
let up_text_width = imageproc::drawing::text_size(scale, &font, &up_text).0 as u32;
let down_text_width = imageproc::drawing::text_size(scale, &font, &down_text).0 as u32;
// 计算右对齐的文本位置
let up_text_x = total_width - up_text_width;
let down_text_x = total_width - down_text_width;
// 优化垂直位置,使速率显示的高度和上下间距正好等于图标大小
let text_height = font_size as i32;
let total_text_height = text_height * 2;
let up_y = (icon_height as i32 - total_text_height) / 2;
let down_y = up_y + text_height;
// 绘制速率文本(先阴影后文字)
let shadow_offset = 1;
// 绘制上行速率
draw_text_mut(
&mut combined_image,
@@ -147,7 +157,7 @@ impl SpeedRate {
&font,
&up_text,
);
// 绘制下行速率
draw_text_mut(
&mut combined_image,
@@ -167,13 +177,12 @@ impl SpeedRate {
&font,
&down_text,
);
// 将结果转换为 PNG 数据
let mut bytes = Vec::new();
combined_image.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)?;
Ok(bytes)
}
}
#[derive(Debug, Clone)]
@@ -191,9 +200,13 @@ impl Traffic {
let stream = Box::pin(
stream::unfold((), |_| async {
loop {
let ws_url = get_traffic_ws_url().unwrap();
let (url, token) = MihomoManager::get_traffic_ws_url();
let mut request = url.into_client_request().unwrap();
request
.headers_mut()
.insert(http::header::AUTHORIZATION, token);
match tokio_tungstenite::connect_async(&ws_url).await {
match tokio_tungstenite::connect_async(request).await {
Ok((ws_stream, _)) => {
log::info!(target: "app", "traffic ws connection established");
return Some((
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "mihomo_api"
edition = "2024"
[features]
debug = []
[dependencies]
reqwest = { version = "0.12.12", features = ["json"] }
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.140"
[dev-dependencies]
tokio = { version = "1.43.0", features = ["rt", "macros"] }
+134
View File
@@ -0,0 +1,134 @@
use reqwest::header::HeaderMap;
use serde_json::json;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
pub mod model;
pub use model::{MihomoData, MihomoManager};
impl MihomoManager {
pub fn new(mihomo_server: String, headers: HeaderMap) -> Self {
Self {
mihomo_server,
data: Arc::new(Mutex::new(MihomoData {
proxies: serde_json::Value::Null,
providers_proxies: serde_json::Value::Null,
})),
headers: headers,
}
}
fn update_proxies(&self, proxies: serde_json::Value) {
let mut data = self.data.lock().unwrap();
data.proxies = proxies;
}
fn update_providers_proxies(&self, providers_proxies: serde_json::Value) {
let mut data = self.data.lock().unwrap();
data.providers_proxies = providers_proxies;
}
pub fn get_mihomo_server(&self) -> String {
self.mihomo_server.clone()
}
pub fn get_proxies(&self) -> serde_json::Value {
let data = self.data.lock().unwrap();
data.proxies.clone()
}
pub fn get_providers_proxies(&self) -> serde_json::Value {
let data = self.data.lock().unwrap();
data.providers_proxies.clone()
}
async fn send_request(
&self,
method: &str,
url: String,
data: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> {
let client_response = reqwest::ClientBuilder::new()
.default_headers(self.headers.clone())
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
.map_err(|e| e.to_string())?
.request(
match method {
"GET" => reqwest::Method::GET,
"PUT" => reqwest::Method::PUT,
"POST" => reqwest::Method::POST,
"PATCH" => reqwest::Method::PATCH,
_ => reqwest::Method::GET,
},
&url,
)
.json(&data.unwrap_or(json!({})))
.send()
.await
.map_err(|e| e.to_string())?;
let response = if method != "PUT" {
client_response.json::<serde_json::Value>().await
} else {
client_response.text().await.map(|text| json!(text))
}
.map_err(|e| e.to_string())?;
return Ok(response);
}
pub async fn refresh_proxies(&self) -> Result<&Self, String> {
let url = format!("{}/proxies", self.mihomo_server);
let proxies = self.send_request("GET", url, None).await?;
self.update_proxies(proxies);
Ok(self)
}
pub async fn refresh_providers_proxies(&self) -> Result<&Self, String> {
let url = format!("{}/providers/proxies", self.mihomo_server);
let providers_proxies = self.send_request("GET", url, None).await?;
self.update_providers_proxies(providers_proxies);
Ok(self)
}
}
impl MihomoManager {
pub async fn put_configs_force(&self, clash_config_path: &str) -> Result<(), String> {
let url = format!("{}/configs?force=true", self.mihomo_server);
let payload = serde_json::json!({
"path": clash_config_path,
});
let _response = self.send_request("PUT", url, Some(payload)).await?;
Ok(())
}
pub async fn patch_configs(&self, config: serde_json::Value) -> Result<(), String> {
let url = format!("{}/configs", self.mihomo_server);
let response = self.send_request("PATCH", url, Some(config)).await?;
if response["code"] == 204 {
Ok(())
} else {
Err(response["message"]
.as_str()
.unwrap_or("unknown error")
.to_string())
}
}
pub async fn test_proxy_delay(
&self,
name: &str,
test_url: Option<String>,
timeout: i32,
) -> Result<serde_json::Value, String> {
let test_url = test_url.unwrap_or("http://cp.cloudflare.com/generate_204".to_string());
let url = format!(
"{}/proxies/{}/delay?url={}&timeout={}",
self.mihomo_server, name, test_url, timeout
);
let response = self.send_request("GET", url, None).await?;
return Ok(response);
}
}
@@ -0,0 +1,29 @@
use std::sync::{Arc, Mutex};
use reqwest::header::HeaderMap;
pub struct MihomoData {
pub(crate) proxies: serde_json::Value,
pub(crate) providers_proxies: serde_json::Value,
}
#[derive(Clone)]
pub struct MihomoManager {
pub(crate) mihomo_server: String,
pub(crate) data: Arc<Mutex<MihomoData>>,
pub(crate) headers: HeaderMap,
}
#[cfg(feature = "debug")]
impl Drop for MihomoData {
fn drop(&mut self) {
println!("Dropping MihomoData");
}
}
#[cfg(feature = "debug")]
impl Drop for MihomoManager {
fn drop(&mut self) {
println!("Dropping MihomoManager");
}
}
@@ -0,0 +1,29 @@
use mihomo_api;
use reqwest::header::HeaderMap;
#[test]
fn test_mihomo_manager_init() {
let manager = mihomo_api::MihomoManager::new("url".into(), HeaderMap::new());
assert_eq!(manager.get_proxies(), serde_json::Value::Null);
assert_eq!(manager.get_providers_proxies(), serde_json::Value::Null);
}
#[tokio::test]
async fn test_refresh_proxies() {
let manager = mihomo_api::MihomoManager::new("http://127.0.0.1:9097".into(), HeaderMap::new());
let manager = manager.refresh_proxies().await.unwrap();
let proxies = manager.get_proxies();
let providers = manager.get_providers_proxies();
assert_ne!(proxies, serde_json::Value::Null);
assert_eq!(providers, serde_json::Value::Null);
}
#[tokio::test]
async fn test_refresh_providers_proxies() {
let manager = mihomo_api::MihomoManager::new("http://127.0.0.1:9097".into(), HeaderMap::new());
let manager = manager.refresh_providers_proxies().await.unwrap();
let proxies = manager.get_proxies();
let providers = manager.get_providers_proxies();
assert_eq!(proxies, serde_json::Value::Null);
assert_ne!(providers, serde_json::Value::Null);
}
+23 -1
View File
@@ -25,7 +25,7 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
// config.yaml 的订阅
let clash_config = { Config::clash().latest().0.clone() };
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled) = {
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = {
let verge = Config::verge();
let verge = verge.latest();
(
@@ -34,6 +34,7 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
verge.enable_builtin_enhanced.unwrap_or(true),
verge.verge_socks_enabled.unwrap_or(false),
verge.verge_http_enabled.unwrap_or(false),
verge.enable_dns_settings.unwrap_or(false),
)
};
#[cfg(not(target_os = "windows"))]
@@ -262,6 +263,27 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
config = use_tun(config, enable_tun).await;
config = use_sort(config);
// 应用独立的DNS配置(如果启用)
if enable_dns_settings {
use crate::utils::dirs;
use std::fs;
// 尝试读取dns_config.yaml
if let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join("dns_config.yaml");
if dns_path.exists() {
if let Ok(dns_yaml) = fs::read_to_string(&dns_path) {
if let Ok(dns_config) = serde_yaml::from_str::<serde_yaml::Mapping>(&dns_yaml) {
// 将DNS配置合并到最终配置中
config.insert("dns".into(), dns_config.into());
log::info!(target: "app", "apply dns_config.yaml");
}
}
}
}
}
let mut exists_set = HashSet::new();
exists_set.extend(exists_keys);
exists_keys = exists_set.into_iter().collect();
+1
View File
@@ -85,6 +85,7 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
#[cfg(test)]
mod tests {
use super::*;
#[allow(unused_imports)]
use serde_yaml::Value;
#[test]
+19 -41
View File
@@ -24,18 +24,20 @@ pub async fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
let mut tun_val = tun_val.map_or(Mapping::new(), |val| {
val.as_mapping().cloned().unwrap_or(Mapping::new())
});
let dns_key = Value::from("dns");
let dns_val = config.get(&dns_key);
let mut dns_val = dns_val.map_or(Mapping::new(), |val| {
val.as_mapping().cloned().unwrap_or(Mapping::new())
});
let ipv6_key = Value::from("ipv6");
let ipv6_val = config
.get(&ipv6_key)
.and_then(|v| v.as_bool())
.unwrap_or(false);
if enable {
// 读取DNS配置
let dns_key = Value::from("dns");
let dns_val = config.get(&dns_key);
let mut dns_val = dns_val.map_or(Mapping::new(), |val| {
val.as_mapping().cloned().unwrap_or(Mapping::new())
});
let ipv6_key = Value::from("ipv6");
let ipv6_val = config
.get(&ipv6_key)
.and_then(|v| v.as_bool())
.unwrap_or(false);
// 检查现有的 enhanced-mode 设置
let current_mode = dns_val
.get(&Value::from("enhanced-mode"))
@@ -61,42 +63,18 @@ pub async fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
crate::utils::resolve::set_public_dns("223.6.6.6".to_string()).await;
}
}
// 当TUN启用时,将修改后的DNS配置写回
revise!(config, "dns", dns_val);
} else {
revise!(
dns_val,
"enable",
dns_val
.get("enable")
.and_then(|v| v.as_bool())
.unwrap_or(true)
);
revise!(dns_val, "ipv6", ipv6_val);
revise!(
dns_val,
"enhanced-mode",
dns_val
.get("enhanced-mode")
.and_then(|v| v.as_str())
.unwrap_or("redir-host")
);
revise!(
dns_val,
"fake-ip-range",
dns_val
.get("fake-ip-range")
.and_then(|v| v.as_str())
.unwrap_or("198.18.0.1/16")
);
// TUN未启用时,仅恢复系统DNS,不修改配置文件中的DNS设置
#[cfg(target_os = "macos")]
crate::utils::resolve::restore_public_dns().await;
}
// 更新TUN配置
revise!(tun_val, "enable", enable);
revise!(config, "tun", tun_val);
revise!(config, "dns", dns_val);
config
}
-626
View File
@@ -1,626 +0,0 @@
//!
//! feat mod 里的函数主要用于
//! - hotkey 快捷键
//! - timer 定时器
//! - cmds 页面调用
//!
use crate::cmds;
use crate::config::*;
use crate::core::*;
use crate::log_err;
use crate::utils::dirs::app_home_dir;
use crate::utils::resolve;
use anyhow::{bail, Result};
use reqwest_dav::list_cmd::ListFile;
use serde_yaml::{Mapping, Value};
use std::fs;
use tauri::Manager;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use std::env;
// 打开面板
#[allow(dead_code)]
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() {
println!("Found existing window");
log::info!(target: "app", "Found existing window");
// 如果窗口存在,则切换其显示状态
match window.is_visible() {
Ok(visible) => {
println!("Window visibility status: {}", visible);
log::info!(target: "app", "Window visibility status: {}", visible);
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 {
println!("No existing window found, creating new window");
log::info!(target: "app", "No existing window found, creating new window");
resolve::create_window();
}
}
// 重启clash
pub fn restart_clash_core() {
tauri::async_runtime::spawn(async {
match CoreManager::global().restart_core().await {
Ok(_) => {
handle::Handle::refresh_clash();
handle::Handle::notice_message("set_config::ok", "ok");
}
Err(err) => {
handle::Handle::notice_message("set_config::error", format!("{err}"));
log::error!(target:"app", "{err}");
}
}
});
}
pub fn restart_app() {
tauri::async_runtime::spawn_blocking(|| {
tauri::async_runtime::block_on(async {
log_err!(CoreManager::global().stop_core().await);
});
resolve::resolve_reset();
let app_handle = handle::Handle::global().app_handle().unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
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
pub fn change_clash_mode(mode: String) {
let mut mapping = Mapping::new();
mapping.insert(Value::from("mode"), mode.clone().into());
tauri::async_runtime::spawn(async move {
log::debug!(target: "app", "change clash mode to {mode}");
match clash_api::patch_configs(&mapping).await {
Ok(_) => {
// 更新订阅
Config::clash().data().patch_config(mapping);
if Config::clash().data().save_config().is_ok() {
handle::Handle::refresh_clash();
log_err!(tray::Tray::global().update_menu());
log_err!(tray::Tray::global().update_icon(None));
}
}
Err(err) => log::error!(target: "app", "{err}"),
}
});
}
// 切换系统代理
pub fn toggle_system_proxy() {
let enable = Config::verge().draft().enable_system_proxy;
let enable = enable.unwrap_or(false);
tauri::async_runtime::spawn(async move {
match patch_verge(IVerge {
enable_system_proxy: Some(!enable),
..IVerge::default()
})
.await
{
Ok(_) => handle::Handle::refresh_verge(),
Err(err) => log::error!(target: "app", "{err}"),
}
});
}
// 切换代理文件
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模式
pub fn toggle_tun_mode() {
let enable = Config::verge().data().enable_tun_mode;
let enable = enable.unwrap_or(false);
tauri::async_runtime::spawn(async move {
match patch_verge(IVerge {
enable_tun_mode: Some(!enable),
..IVerge::default()
})
.await
{
Ok(_) => handle::Handle::refresh_verge(),
Err(err) => log::error!(target: "app", "{err}"),
}
});
}
pub fn quit(code: Option<i32>) {
let app_handle = handle::Handle::global().app_handle().unwrap();
handle::Handle::global().set_is_exiting();
resolve::resolve_reset();
log_err!(handle::Handle::global().get_window().unwrap().close());
app_handle.exit(code.unwrap_or(0));
}
/// 修改clash的订阅
pub async fn patch_clash(patch: Mapping) -> Result<()> {
Config::clash().draft().patch_config(patch.clone());
let res = {
// 激活订阅
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
Config::generate().await?;
CoreManager::global().restart_core().await?;
} else {
if patch.get("mode").is_some() {
log_err!(tray::Tray::global().update_menu());
log_err!(tray::Tray::global().update_icon(None));
}
Config::runtime().latest().patch_config(patch);
CoreManager::global().update_config().await?;
}
handle::Handle::refresh_clash();
<Result<()>>::Ok(())
};
match res {
Ok(()) => {
Config::clash().apply();
Config::clash().data().save_config()?;
Ok(())
}
Err(err) => {
Config::clash().discard();
Err(err)
}
}
}
/// 修改verge的订阅
/// 一般都是一个个的修改
pub async fn patch_verge(patch: IVerge) -> Result<()> {
Config::verge().draft().patch_config(patch.clone());
let tun_mode = patch.enable_tun_mode;
let auto_launch = patch.enable_auto_launch;
let system_proxy = patch.enable_system_proxy;
let pac = patch.proxy_auto_config;
let pac_content = patch.pac_file_content;
let proxy_bypass = patch.system_proxy_bypass;
let language = patch.language;
let mixed_port = patch.verge_mixed_port;
let lite_mode = patch.enable_lite_mode;
#[cfg(target_os = "macos")]
let tray_icon = patch.tray_icon;
#[cfg(not(target_os = "macos"))]
let tray_icon: Option<String> = None;
let common_tray_icon = patch.common_tray_icon;
let sysproxy_tray_icon = patch.sysproxy_tray_icon;
let tun_tray_icon = patch.tun_tray_icon;
#[cfg(not(target_os = "windows"))]
let redir_enabled = patch.verge_redir_enabled;
#[cfg(not(target_os = "windows"))]
let redir_port = patch.verge_redir_port;
#[cfg(target_os = "linux")]
let tproxy_enabled = patch.verge_tproxy_enabled;
#[cfg(target_os = "linux")]
let tproxy_port = patch.verge_tproxy_port;
let socks_enabled = patch.verge_socks_enabled;
let socks_port = patch.verge_socks_port;
let http_enabled = patch.verge_http_enabled;
let http_port = patch.verge_port;
let enable_tray_speed = patch.enable_tray_speed;
let enable_global_hotkey = patch.enable_global_hotkey;
let res: std::result::Result<(), anyhow::Error> = {
let mut should_restart_core = 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_sysproxy = false;
let mut should_update_systray_icon = false;
let mut should_update_hotkey = false;
let mut should_update_systray_menu = false;
let mut should_update_systray_tooltip = false;
if tun_mode.is_some() {
should_update_clash_config = true;
should_update_systray_menu = true;
should_update_systray_tooltip = true;
should_update_systray_icon = true;
}
if enable_global_hotkey.is_some() {
should_update_verge_config = true;
}
#[cfg(not(target_os = "windows"))]
if redir_enabled.is_some() || redir_port.is_some() {
should_restart_core = true;
}
#[cfg(target_os = "linux")]
if tproxy_enabled.is_some() || tproxy_port.is_some() {
should_restart_core = true;
}
if socks_enabled.is_some()
|| http_enabled.is_some()
|| socks_port.is_some()
|| http_port.is_some()
|| mixed_port.is_some()
{
should_restart_core = true;
}
if auto_launch.is_some() {
should_update_launch = true;
}
if system_proxy.is_some() {
should_update_sysproxy = true;
should_update_systray_menu = true;
should_update_systray_tooltip = true;
should_update_systray_icon = true;
}
if proxy_bypass.is_some() || pac_content.is_some() || pac.is_some() {
should_update_sysproxy = true;
}
if language.is_some() {
should_update_systray_menu = true;
}
if common_tray_icon.is_some()
|| sysproxy_tray_icon.is_some()
|| tun_tray_icon.is_some()
|| tray_icon.is_some()
{
should_update_systray_icon = true;
}
if patch.hotkeys.is_some() {
should_update_hotkey = true;
should_update_systray_menu = true;
}
if enable_tray_speed.is_some() {
should_update_systray_icon = true;
}
if should_restart_core {
CoreManager::global().restart_core().await?;
}
if should_update_clash_config {
CoreManager::global().update_config().await?;
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 {
sysopt::Sysopt::global().update_launch()?;
}
if should_update_sysproxy {
sysopt::Sysopt::global().update_sysproxy().await?;
}
if should_update_hotkey {
hotkey::Hotkey::global().update(patch.hotkeys.unwrap())?;
}
if should_update_systray_menu {
tray::Tray::global().update_menu()?;
}
if should_update_systray_icon {
tray::Tray::global().update_icon(None)?;
}
if should_update_systray_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(())
};
match res {
Ok(()) => {
Config::verge().apply();
Config::verge().data().save_file()?;
Ok(())
}
Err(err) => {
Config::verge().discard();
Err(err)
}
}
}
/// 更新某个profile
/// 如果更新当前订阅就激活订阅
pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()> {
println!("[订阅更新] 开始更新订阅 {}", uid);
let url_opt = {
let profiles = Config::profiles();
let profiles = profiles.latest();
let item = profiles.get_item(&uid)?;
let is_remote = item.itype.as_ref().map_or(false, |s| s == "remote");
if !is_remote {
println!("[订阅更新] {} 不是远程订阅,跳过更新", uid);
None // 非远程订阅直接更新
} else if item.url.is_none() {
println!("[订阅更新] {} 缺少URL,无法更新", uid);
bail!("failed to get the profile item url");
} else {
println!("[订阅更新] {} 是远程订阅,URL: {}", uid, item.url.clone().unwrap());
Some((item.url.clone().unwrap(), item.option.clone()))
}
};
let should_update = match url_opt {
Some((url, opt)) => {
println!("[订阅更新] 开始下载新的订阅内容");
let merged_opt = PrfOption::merge(opt, option);
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
println!("[订阅更新] 更新订阅配置");
let profiles = Config::profiles();
let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item)?;
let is_current = Some(uid.clone()) == profiles.get_current();
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
is_current
}
None => true,
};
if should_update {
println!("[订阅更新] 更新内核配置");
match CoreManager::global().update_config().await {
Ok(_) => {
println!("[订阅更新] 更新成功");
handle::Handle::refresh_clash();
}
Err(err) => {
println!("[订阅更新] 更新失败: {}", err);
handle::Handle::notice_message("set_config::error", format!("{err}"));
log::error!(target: "app", "{err}");
}
}
}
Ok(())
}
/// copy env variable
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 port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
let http_proxy = format!("http://{clash_verge_rev_ip}:{}", port);
let socks5_proxy = format!("socks5://{clash_verge_rev_ip}:{}", port);
let sh =
format!("export https_proxy={http_proxy} http_proxy={http_proxy} all_proxy={socks5_proxy}");
let cmd: String = format!("set http_proxy={http_proxy}\r\nset https_proxy={http_proxy}");
let ps: String = format!("$env:HTTP_PROXY=\"{http_proxy}\"; $env:HTTPS_PROXY=\"{http_proxy}\"");
let nu: String =
format!("load-env {{ http_proxy: \"{http_proxy}\", https_proxy: \"{http_proxy}\" }}");
let cliboard = app_handle.clipboard();
let env_type = { Config::verge().latest().env_type.clone() };
let env_type = match env_type {
Some(env_type) => env_type,
None => {
#[cfg(not(target_os = "windows"))]
let default = "bash";
#[cfg(target_os = "windows")]
let default = "powershell";
default.to_string()
}
};
match env_type.as_str() {
"bash" => cliboard.write_text(sh).unwrap_or_default(),
"cmd" => cliboard.write_text(cmd).unwrap_or_default(),
"powershell" => cliboard.write_text(ps).unwrap_or_default(),
"nushell" => cliboard.write_text(nu).unwrap_or_default(),
_ => log::error!(target: "app", "copy_clash_env: Invalid env type! {env_type}"),
};
}
pub async fn test_delay(url: String) -> Result<u32> {
use tokio::time::{Duration, Instant};
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let tun_mode = Config::verge().latest().enable_tun_mode.unwrap_or(false);
let proxy_scheme = format!("http://127.0.0.1:{port}");
if !tun_mode {
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
let request = builder
.timeout(Duration::from_millis(10000))
.build()?
.get(url).header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
let start = Instant::now();
let response = request.send().await;
match response {
Ok(response) => {
log::trace!(target: "app", "test_delay response: {:#?}", response);
if response.status().is_success() {
Ok(start.elapsed().as_millis() as u32)
} else {
Ok(10000u32)
}
}
Err(err) => {
log::trace!(target: "app", "test_delay error: {:#?}", err);
Err(err.into())
}
}
}
pub async fn create_backup_and_upload_webdav() -> Result<()> {
let (file_name, temp_file_path) = backup::create_backup().map_err(|err| {
log::error!(target: "app", "Failed to create backup: {:#?}", err);
err
})?;
if let Err(err) = backup::WebDavClient::global()
.upload(temp_file_path.clone(), file_name)
.await
{
log::error!(target: "app", "Failed to upload to WebDAV: {:#?}", err);
return Err(err);
}
if let Err(err) = std::fs::remove_file(&temp_file_path) {
log::warn!(target: "app", "Failed to remove temp file: {:#?}", err);
}
Ok(())
}
pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> {
backup::WebDavClient::global().list().await.map_err(|err| {
log::error!(target: "app", "Failed to list WebDAV backup files: {:#?}", err);
err
})
}
pub async fn delete_webdav_backup(filename: String) -> Result<()> {
backup::WebDavClient::global()
.delete(filename)
.await
.map_err(|err| {
log::error!(target: "app", "Failed to delete WebDAV backup file: {:#?}", err);
err
})
}
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let verge = Config::verge();
let verge_data = verge.data().clone();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let backup_storage_path = app_home_dir().unwrap().join(&filename);
backup::WebDavClient::global()
.download(filename, backup_storage_path.clone())
.await
.map_err(|err| {
log::error!(target: "app", "Failed to download WebDAV backup file: {:#?}", err);
err
})?;
// extract zip file
let mut zip = zip::ZipArchive::new(fs::File::open(backup_storage_path.clone())?)?;
zip.extract(app_home_dir()?)?;
log_err!(
patch_verge(IVerge {
webdav_url,
webdav_username,
webdav_password,
..IVerge::default()
})
.await
);
// 最后删除临时文件
fs::remove_file(backup_storage_path)?;
Ok(())
}
+86
View File
@@ -0,0 +1,86 @@
use crate::config::{Config, IVerge};
use crate::core::backup;
use crate::log_err;
use crate::utils::dirs::app_home_dir;
use anyhow::Result;
use reqwest_dav::list_cmd::ListFile;
use std::fs;
/// Create a backup and upload to WebDAV
pub async fn create_backup_and_upload_webdav() -> Result<()> {
let (file_name, temp_file_path) = backup::create_backup().map_err(|err| {
log::error!(target: "app", "Failed to create backup: {:#?}", err);
err
})?;
if let Err(err) = backup::WebDavClient::global()
.upload(temp_file_path.clone(), file_name)
.await
{
log::error!(target: "app", "Failed to upload to WebDAV: {:#?}", err);
return Err(err);
}
if let Err(err) = std::fs::remove_file(&temp_file_path) {
log::warn!(target: "app", "Failed to remove temp file: {:#?}", err);
}
Ok(())
}
/// List WebDAV backups
pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> {
backup::WebDavClient::global().list().await.map_err(|err| {
log::error!(target: "app", "Failed to list WebDAV backup files: {:#?}", err);
err
})
}
/// Delete WebDAV backup
pub async fn delete_webdav_backup(filename: String) -> Result<()> {
backup::WebDavClient::global()
.delete(filename)
.await
.map_err(|err| {
log::error!(target: "app", "Failed to delete WebDAV backup file: {:#?}", err);
err
})
}
/// Restore WebDAV backup
pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let verge = Config::verge();
let verge_data = verge.data().clone();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let backup_storage_path = app_home_dir().unwrap().join(&filename);
backup::WebDavClient::global()
.download(filename, backup_storage_path.clone())
.await
.map_err(|err| {
log::error!(target: "app", "Failed to download WebDAV backup file: {:#?}", err);
err
})?;
// extract zip file
let mut zip = zip::ZipArchive::new(fs::File::open(backup_storage_path.clone())?)?;
zip.extract(app_home_dir()?)?;
log_err!(
super::patch_verge(
IVerge {
webdav_url,
webdav_username,
webdav_password,
..IVerge::default()
},
false
)
.await
);
// 最后删除临时文件
fs::remove_file(backup_storage_path)?;
Ok(())
}
+111
View File
@@ -0,0 +1,111 @@
use crate::config::Config;
use crate::core::{handle, tray, CoreManager};
use crate::log_err;
use crate::module::mihomo::MihomoManager;
use crate::utils::resolve;
use serde_yaml::{Mapping, Value};
use tauri::Manager;
/// Restart the Clash core
pub fn restart_clash_core() {
tauri::async_runtime::spawn(async {
match CoreManager::global().restart_core().await {
Ok(_) => {
handle::Handle::refresh_clash();
handle::Handle::notice_message("set_config::ok", "ok");
}
Err(err) => {
handle::Handle::notice_message("set_config::error", format!("{err}"));
log::error!(target:"app", "{err}");
}
}
});
}
/// Restart the application
pub fn restart_app() {
tauri::async_runtime::spawn_blocking(|| {
tauri::async_runtime::block_on(async {
log_err!(CoreManager::global().stop_core().await);
});
resolve::resolve_reset();
let app_handle = handle::Handle::global().app_handle().unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
tauri::process::restart(&app_handle.env());
});
}
/// Change Clash mode (rule/global/direct/script)
pub fn change_clash_mode(mode: String) {
let mut mapping = Mapping::new();
mapping.insert(Value::from("mode"), mode.clone().into());
// Convert YAML mapping to JSON Value
let json_value = serde_json::json!({
"mode": mode
});
tauri::async_runtime::spawn(async move {
log::debug!(target: "app", "change clash mode to {mode}");
match MihomoManager::global().patch_configs(json_value).await {
Ok(_) => {
// 更新订阅
Config::clash().data().patch_config(mapping);
if Config::clash().data().save_config().is_ok() {
handle::Handle::refresh_clash();
log_err!(tray::Tray::global().update_menu());
log_err!(tray::Tray::global().update_icon(None));
}
}
Err(err) => log::error!(target: "app", "{err}"),
}
});
}
/// Test connection delay to a URL
pub async fn test_delay(url: String) -> anyhow::Result<u32> {
use tokio::time::{Duration, Instant};
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let tun_mode = Config::verge().latest().enable_tun_mode.unwrap_or(false);
let proxy_scheme = format!("http://127.0.0.1:{port}");
if !tun_mode {
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
builder = builder.proxy(proxy);
}
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
builder = builder.proxy(proxy);
}
}
let request = builder
.timeout(Duration::from_millis(10000))
.build()?
.get(url).header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
let start = Instant::now();
let response = request.send().await;
match response {
Ok(response) => {
log::trace!(target: "app", "test_delay response: {:#?}", response);
if response.status().is_success() {
Ok(start.elapsed().as_millis() as u32)
} else {
Ok(10000u32)
}
}
Err(err) => {
log::trace!(target: "app", "test_delay error: {:#?}", err);
Err(err.into())
}
}
}
+215
View File
@@ -0,0 +1,215 @@
use crate::config::{Config, IVerge};
use crate::core::{handle, hotkey, sysopt, tray, CoreManager};
use crate::log_err;
use crate::utils::resolve;
use anyhow::Result;
use serde_yaml::Mapping;
use tauri::Manager;
/// Patch Clash configuration
pub async fn patch_clash(patch: Mapping) -> Result<()> {
Config::clash().draft().patch_config(patch.clone());
let res = {
// 激活订阅
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
Config::generate().await?;
CoreManager::global().restart_core().await?;
} else {
if patch.get("mode").is_some() {
log_err!(tray::Tray::global().update_menu());
log_err!(tray::Tray::global().update_icon(None));
}
Config::runtime().latest().patch_config(patch);
CoreManager::global().update_config().await?;
}
handle::Handle::refresh_clash();
<Result<()>>::Ok(())
};
match res {
Ok(()) => {
Config::clash().apply();
Config::clash().data().save_config()?;
Ok(())
}
Err(err) => {
Config::clash().discard();
Err(err)
}
}
}
/// Patch Verge configuration
pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> {
Config::verge().draft().patch_config(patch.clone());
let tun_mode = patch.enable_tun_mode;
let auto_launch = patch.enable_auto_launch;
let system_proxy = patch.enable_system_proxy;
let pac = patch.proxy_auto_config;
let pac_content = patch.pac_file_content;
let proxy_bypass = patch.system_proxy_bypass;
let language = patch.language;
let mixed_port = patch.verge_mixed_port;
let lite_mode = patch.enable_lite_mode;
#[cfg(target_os = "macos")]
let tray_icon = patch.tray_icon;
#[cfg(not(target_os = "macos"))]
let tray_icon: Option<String> = None;
let common_tray_icon = patch.common_tray_icon;
let sysproxy_tray_icon = patch.sysproxy_tray_icon;
let tun_tray_icon = patch.tun_tray_icon;
#[cfg(not(target_os = "windows"))]
let redir_enabled = patch.verge_redir_enabled;
#[cfg(not(target_os = "windows"))]
let redir_port = patch.verge_redir_port;
#[cfg(target_os = "linux")]
let tproxy_enabled = patch.verge_tproxy_enabled;
#[cfg(target_os = "linux")]
let tproxy_port = patch.verge_tproxy_port;
let socks_enabled = patch.verge_socks_enabled;
let socks_port = patch.verge_socks_port;
let http_enabled = patch.verge_http_enabled;
let http_port = patch.verge_port;
let enable_tray_speed = patch.enable_tray_speed;
let enable_global_hotkey = patch.enable_global_hotkey;
let res: std::result::Result<(), anyhow::Error> = {
let mut should_restart_core = 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_sysproxy = false;
let mut should_update_systray_icon = false;
let mut should_update_hotkey = false;
let mut should_update_systray_menu = false;
let mut should_update_systray_tooltip = false;
if tun_mode.is_some() {
should_update_clash_config = true;
should_update_systray_menu = true;
should_update_systray_tooltip = true;
should_update_systray_icon = true;
}
if enable_global_hotkey.is_some() {
should_update_verge_config = true;
}
#[cfg(not(target_os = "windows"))]
if redir_enabled.is_some() || redir_port.is_some() {
should_restart_core = true;
}
#[cfg(target_os = "linux")]
if tproxy_enabled.is_some() || tproxy_port.is_some() {
should_restart_core = true;
}
if socks_enabled.is_some()
|| http_enabled.is_some()
|| socks_port.is_some()
|| http_port.is_some()
|| mixed_port.is_some()
{
should_restart_core = true;
}
if auto_launch.is_some() {
should_update_launch = true;
}
if system_proxy.is_some() {
should_update_sysproxy = true;
should_update_systray_menu = true;
should_update_systray_tooltip = true;
should_update_systray_icon = true;
}
if proxy_bypass.is_some() || pac_content.is_some() || pac.is_some() {
should_update_sysproxy = true;
}
if language.is_some() {
should_update_systray_menu = true;
}
if common_tray_icon.is_some()
|| sysproxy_tray_icon.is_some()
|| tun_tray_icon.is_some()
|| tray_icon.is_some()
{
should_update_systray_icon = true;
}
if patch.hotkeys.is_some() {
should_update_hotkey = true;
should_update_systray_menu = true;
}
if enable_tray_speed.is_some() {
should_update_systray_icon = true;
}
if should_restart_core {
CoreManager::global().restart_core().await?;
}
if should_update_clash_config {
CoreManager::global().update_config().await?;
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 {
sysopt::Sysopt::global().update_launch()?;
}
if should_update_sysproxy {
sysopt::Sysopt::global().update_sysproxy().await?;
}
if should_update_hotkey {
hotkey::Hotkey::global().update(patch.hotkeys.unwrap())?;
}
if should_update_systray_menu {
tray::Tray::global().update_menu()?;
}
if should_update_systray_icon {
tray::Tray::global().update_icon(None)?;
}
if should_update_systray_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(())
};
match res {
Ok(()) => {
Config::verge().apply();
if !not_save_file {
Config::verge().data().save_file()?;
}
Ok(())
}
Err(err) => {
Config::verge().discard();
Err(err)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
mod backup;
mod clash;
mod config;
mod profile;
mod proxy;
mod window;
// Re-export all functions from modules
pub use backup::*;
pub use clash::*;
pub use config::*;
pub use profile::*;
pub use proxy::*;
pub use window::*;
+84
View File
@@ -0,0 +1,84 @@
use crate::cmd;
use crate::config::{Config, PrfItem, PrfOption};
use crate::core::handle;
use crate::core::CoreManager;
use crate::core::*;
use anyhow::{bail, Result};
/// Toggle proxy profile
pub fn toggle_proxy_profile(profile_index: String) {
tauri::async_runtime::spawn(async move {
let app_handle = handle::Handle::global().app_handle().unwrap();
match cmd::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}");
}
}
});
}
/// Update a profile
/// If updating current profile, activate it
pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()> {
println!("[订阅更新] 开始更新订阅 {}", uid);
let url_opt = {
let profiles = Config::profiles();
let profiles = profiles.latest();
let item = profiles.get_item(&uid)?;
let is_remote = item.itype.as_ref().map_or(false, |s| s == "remote");
if !is_remote {
println!("[订阅更新] {} 不是远程订阅,跳过更新", uid);
None // 非远程订阅直接更新
} else if item.url.is_none() {
println!("[订阅更新] {} 缺少URL,无法更新", uid);
bail!("failed to get the profile item url");
} else {
println!(
"[订阅更新] {} 是远程订阅,URL: {}",
uid,
item.url.clone().unwrap()
);
Some((item.url.clone().unwrap(), item.option.clone()))
}
};
let should_update = match url_opt {
Some((url, opt)) => {
println!("[订阅更新] 开始下载新的订阅内容");
let merged_opt = PrfOption::merge(opt, option);
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
println!("[订阅更新] 更新订阅配置");
let profiles = Config::profiles();
let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item)?;
let is_current = Some(uid.clone()) == profiles.get_current();
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
is_current
}
None => true,
};
if should_update {
println!("[订阅更新] 更新内核配置");
match CoreManager::global().update_config().await {
Ok(_) => {
println!("[订阅更新] 更新成功");
handle::Handle::refresh_clash();
}
Err(err) => {
println!("[订阅更新] 更新失败: {}", err);
handle::Handle::notice_message("set_config::error", format!("{err}"));
log::error!(target: "app", "{err}");
}
}
}
Ok(())
}
+89
View File
@@ -0,0 +1,89 @@
use crate::config::Config;
use crate::config::IVerge;
use crate::core::handle;
use std::env;
use tauri_plugin_clipboard_manager::ClipboardExt;
/// Toggle system proxy on/off
pub fn toggle_system_proxy() {
let enable = Config::verge().draft().enable_system_proxy;
let enable = enable.unwrap_or(false);
tauri::async_runtime::spawn(async move {
match super::patch_verge(
IVerge {
enable_system_proxy: Some(!enable),
..IVerge::default()
},
false,
)
.await
{
Ok(_) => handle::Handle::refresh_verge(),
Err(err) => log::error!(target: "app", "{err}"),
}
});
}
/// Toggle TUN mode on/off
pub fn toggle_tun_mode(not_save_file: Option<bool>) {
let enable = Config::verge().data().enable_tun_mode;
let enable = enable.unwrap_or(false);
tauri::async_runtime::spawn(async move {
match super::patch_verge(
IVerge {
enable_tun_mode: Some(!enable),
..IVerge::default()
},
not_save_file.unwrap_or(false),
)
.await
{
Ok(_) => handle::Handle::refresh_verge(),
Err(err) => log::error!(target: "app", "{err}"),
}
});
}
/// Copy proxy environment variables to clipboard
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 port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
let http_proxy = format!("http://{clash_verge_rev_ip}:{}", port);
let socks5_proxy = format!("socks5://{clash_verge_rev_ip}:{}", port);
let cliboard = app_handle.clipboard();
let env_type = { Config::verge().latest().env_type.clone() };
let env_type = match env_type {
Some(env_type) => env_type,
None => {
#[cfg(not(target_os = "windows"))]
let default = "bash";
#[cfg(target_os = "windows")]
let default = "powershell";
default.to_string()
}
};
let export_text = match env_type.as_str() {
"bash" => format!("export https_proxy={http_proxy} http_proxy={http_proxy} all_proxy={socks5_proxy}"),
"cmd" => format!("set http_proxy={http_proxy}\r\nset https_proxy={http_proxy}"),
"powershell" => format!("$env:HTTP_PROXY=\"{http_proxy}\"; $env:HTTPS_PROXY=\"{http_proxy}\""),
"nushell" => format!("load-env {{ http_proxy: \"{http_proxy}\", https_proxy: \"{http_proxy}\" }}"),
"fish" => format!("set -x http_proxy {http_proxy}; set -x https_proxy {http_proxy}"),
_ => {
log::error!(target: "app", "copy_clash_env: Invalid env type! {env_type}");
return;
}
};
if let Err(_) = cliboard.write_text(export_text) {
log::error!(target: "app", "Failed to write to clipboard");
}
}
+139
View File
@@ -0,0 +1,139 @@
use crate::config::Config;
use crate::core::handle;
use crate::core::{sysopt, CoreManager};
use crate::module::mihomo::MihomoManager;
use crate::utils::resolve;
use futures;
use tauri::Manager;
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
/// Open or close the dashboard window
#[allow(dead_code)]
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() {
println!("Found existing window");
log::info!(target: "app", "Found existing window");
// 如果窗口存在,则切换其显示状态
match window.is_visible() {
Ok(visible) => {
println!("Window visibility status: {}", visible);
log::info!(target: "app", "Window visibility status: {}", visible);
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 {
println!("No existing window found, creating new window");
log::info!(target: "app", "No existing window found, creating new window");
resolve::create_window();
}
}
/// Setup window state monitor to save window position and size in real-time
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());
}
_ => {}
}
});
}
/// 优化的应用退出函数
pub fn quit(code: Option<i32>) {
log::debug!(target: "app", "启动退出流程");
// 获取应用句柄并设置退出标志
let app_handle = handle::Handle::global().app_handle().unwrap();
handle::Handle::global().set_is_exiting();
// 优先关闭窗口,提供立即反馈
if let Some(window) = handle::Handle::global().get_window() {
let _ = window.hide();
}
// 在单独线程中处理资源清理,避免阻塞主线程
std::thread::spawn(move || {
// 使用tokio运行时执行异步清理任务
tauri::async_runtime::block_on(async {
// 使用超时机制处理清理操作
use tokio::time::{timeout, Duration};
// 1. 直接关闭TUN模式 (优先处理,通常最容易卡住)
if Config::verge().data().enable_tun_mode.unwrap_or(false) {
let disable = serde_json::json!({
"tun": {
"enable": false
}
});
// 设置1秒超时
let _ = timeout(
Duration::from_secs(1),
MihomoManager::global().patch_configs(disable),
)
.await;
}
// 2. 并行处理系统代理和核心进程清理
let proxy_future = timeout(
Duration::from_secs(1),
sysopt::Sysopt::global().reset_sysproxy(),
);
let core_future = timeout(Duration::from_secs(1), CoreManager::global().stop_core());
// 同时等待两个任务完成
let _ = futures::join!(proxy_future, core_future);
// 3. 处理macOS特定清理
#[cfg(target_os = "macos")]
{
let _ = timeout(Duration::from_millis(500), resolve::restore_public_dns()).await;
}
});
// 无论清理结果如何,确保应用退出
app_handle.exit(code.unwrap_or(0));
});
}
+146 -51
View File
@@ -1,14 +1,81 @@
mod cmds;
mod cmd;
mod config;
mod core;
mod enhance;
mod feat;
mod utils;
mod module;
use crate::core::hotkey;
use crate::utils::{resolve, resolve::resolve_scheme, server};
use config::Config;
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt;
use std::sync::{Mutex, Once};
use tauri::AppHandle;
#[cfg(target_os = "macos")]
use tauri::Manager;
/// A global singleton handle to the application.
pub struct AppHandleManager {
inner: Mutex<Option<AppHandle>>,
init: Once,
}
impl AppHandleManager {
/// Get the global instance of the app handle manager.
pub fn global() -> &'static Self {
static INSTANCE: AppHandleManager = AppHandleManager {
inner: Mutex::new(None),
init: Once::new(),
};
&INSTANCE
}
/// Initialize the app handle manager with an app handle.
pub fn init(&self, handle: AppHandle) {
self.init.call_once(|| {
let mut app_handle = self.inner.lock().unwrap();
*app_handle = Some(handle);
});
}
/// Get the app handle if it has been initialized.
pub fn get(&self) -> Option<AppHandle> {
self.inner.lock().unwrap().clone()
}
/// Get the app handle, panics if it hasn't been initialized.
pub fn get_handle(&self) -> AppHandle {
self.get().expect("AppHandle not initialized")
}
pub fn set_activation_policy_regular(&self) {
#[cfg(target_os = "macos")]
{
let app_handle = self.inner.lock().unwrap();
let app_handle = app_handle.as_ref().unwrap();
let _ = app_handle.set_activation_policy(tauri::ActivationPolicy::Regular);
}
}
pub fn set_activation_policy_accessory(&self) {
#[cfg(target_os = "macos")]
{
let app_handle = self.inner.lock().unwrap();
let app_handle = app_handle.as_ref().unwrap();
let _ = app_handle.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
}
pub fn set_activation_policy_prohibited(&self) {
#[cfg(target_os = "macos")]
{
let app_handle = self.inner.lock().unwrap();
let app_handle = app_handle.as_ref().unwrap();
let _ = app_handle.set_activation_policy(tauri::ActivationPolicy::Prohibited);
}
}
}
pub fn run() {
// 单例检测
@@ -70,61 +137,72 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
// common
cmds::get_sys_proxy,
cmds::get_auto_proxy,
cmds::open_app_dir,
cmds::open_logs_dir,
cmds::open_web_url,
cmds::open_core_dir,
cmds::get_portable_flag,
cmds::get_network_interfaces,
cmds::restart_core,
cmds::restart_app,
cmd::get_sys_proxy,
cmd::get_auto_proxy,
cmd::open_app_dir,
cmd::open_logs_dir,
cmd::open_web_url,
cmd::open_core_dir,
cmd::get_portable_flag,
cmd::get_network_interfaces,
cmd::restart_core,
cmd::restart_app,
// 添加新的命令
cmd::get_running_mode,
cmd::install_service,
// clash
cmds::get_clash_info,
cmds::patch_clash_config,
cmds::patch_clash_mode,
cmds::change_clash_core,
cmds::get_runtime_config,
cmds::get_runtime_yaml,
cmds::get_runtime_exists,
cmds::get_runtime_logs,
cmds::uwp::invoke_uwp_tool,
cmds::copy_clash_env,
cmd::get_clash_info,
cmd::patch_clash_config,
cmd::patch_clash_mode,
cmd::change_clash_core,
cmd::get_runtime_config,
cmd::get_runtime_yaml,
cmd::get_runtime_exists,
cmd::get_runtime_logs,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::get_proxies,
cmd::get_providers_proxies,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::check_dns_config_exists,
cmd::get_dns_config_content,
// verge
cmds::get_verge_config,
cmds::patch_verge_config,
cmds::test_delay,
cmds::get_app_dir,
cmds::copy_icon_file,
cmds::download_icon_cache,
cmds::open_devtools,
cmds::exit_app,
cmds::get_network_interfaces_info,
cmd::get_verge_config,
cmd::patch_verge_config,
cmd::test_delay,
cmd::get_app_dir,
cmd::copy_icon_file,
cmd::download_icon_cache,
cmd::open_devtools,
cmd::exit_app,
cmd::get_network_interfaces_info,
// profile
cmds::get_profiles,
cmds::enhance_profiles,
cmds::patch_profiles_config,
cmds::view_profile,
cmds::patch_profile,
cmds::create_profile,
cmds::import_profile,
cmds::reorder_profile,
cmds::update_profile,
cmds::delete_profile,
cmds::read_profile_file,
cmds::save_profile_file,
cmd::get_profiles,
cmd::enhance_profiles,
cmd::patch_profiles_config,
cmd::view_profile,
cmd::patch_profile,
cmd::create_profile,
cmd::import_profile,
cmd::reorder_profile,
cmd::update_profile,
cmd::delete_profile,
cmd::read_profile_file,
cmd::save_profile_file,
// script validation
cmds::script_validate_notice,
cmds::validate_script_file,
cmd::script_validate_notice,
cmd::validate_script_file,
// clash api
cmds::clash_api_get_proxy_delay,
cmd::clash_api_get_proxy_delay,
// backup
cmds::create_webdav_backup,
cmds::save_webdav_config,
cmds::list_webdav_backup,
cmds::delete_webdav_backup,
cmds::restore_webdav_backup,
cmd::create_webdav_backup,
cmd::save_webdav_config,
cmd::list_webdav_backup,
cmd::delete_webdav_backup,
cmd::restore_webdav_backup,
// export diagnostic info for issue reporting
cmd::export_diagnostic_info,
]);
#[cfg(debug_assertions)]
@@ -136,7 +214,22 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while running tauri application");
app.run(|_, e| match e {
app.run(|app_handle, e| match e {
tauri::RunEvent::Ready | tauri::RunEvent::Resumed => {
AppHandleManager::global().init(app_handle.clone());
#[cfg(target_os = "macos")]
{
let main_window = AppHandleManager::global().get_handle().get_webview_window("main").unwrap();
let _ = main_window.set_title("Clash Verge");
}
}
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen { has_visible_windows, .. } => {
if !has_visible_windows {
AppHandleManager::global().set_activation_policy_regular();
}
AppHandleManager::global().init(app_handle.clone());
}
tauri::RunEvent::ExitRequested { api, code, .. } => {
if code.is_none() {
api.prevent_exit();
@@ -146,6 +239,8 @@ pub fn run() {
if label == "main" {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
#[cfg(target_os = "macos")]
AppHandleManager::global().set_activation_policy_accessory();
if core::handle::Handle::global().is_exiting() {
return;
}
+70
View File
@@ -0,0 +1,70 @@
use crate::config::Config;
use mihomo_api;
use once_cell::sync::{Lazy, OnceCell};
use std::sync::Mutex;
use tauri::http::{HeaderMap, HeaderValue};
#[cfg(target_os = "macos")]
use tokio_tungstenite::tungstenite::http;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Rate {
pub up: u64,
pub down: u64,
}
pub struct MihomoManager {
mihomo: Mutex<OnceCell<mihomo_api::MihomoManager>>,
}
impl MihomoManager {
fn __global() -> &'static MihomoManager {
static INSTANCE: Lazy<MihomoManager> = Lazy::new(|| MihomoManager {
mihomo: Mutex::new(OnceCell::new()),
});
&INSTANCE
}
pub fn global() -> mihomo_api::MihomoManager {
let instance = MihomoManager::__global();
let (current_server, headers) = MihomoManager::get_clash_client_info().unwrap();
let lock = instance.mihomo.lock().unwrap();
if let Some(mihomo) = lock.get() {
if mihomo.get_mihomo_server() == current_server {
return mihomo.clone();
}
}
lock.set(mihomo_api::MihomoManager::new(current_server, headers))
.ok();
lock.get().unwrap().clone()
}
}
impl MihomoManager {
pub fn get_clash_client_info() -> Option<(String, HeaderMap)> {
let client = { Config::clash().data().get_client_info() };
let server = format!("http://{}", client.server);
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "application/json".parse().unwrap());
if let Some(secret) = client.secret {
let secret = format!("Bearer {}", secret).parse().unwrap();
headers.insert("Authorization", secret);
}
Some((server, headers))
}
#[cfg(target_os = "macos")]
pub fn get_traffic_ws_url() -> (String, HeaderValue) {
let (url, headers) = MihomoManager::get_clash_client_info().unwrap();
let ws_url = url.replace("http://", "ws://") + "/traffic";
let auth = headers
.get("Authorization")
.unwrap()
.to_str()
.unwrap()
.to_string();
let token = http::header::HeaderValue::from_str(&auth).unwrap();
return (ws_url, token);
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod sysinfo;
pub mod mihomo;
+56
View File
@@ -0,0 +1,56 @@
use crate::core::{handle, CoreManager};
use std::fmt::{self, Debug, Formatter};
use sysinfo::System;
pub struct PlatformSpecification {
system_name: String,
system_version: String,
system_kernel_version: String,
system_arch: String,
verge_version: String,
running_mode: String,
}
impl Debug for PlatformSpecification {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"System Name: {}\nSystem Version: {}\nSystem kernel Version: {}\nSystem Arch: {}\nVerge Version: {}\nRunning Mode: {}",
self.system_name, self.system_version, self.system_kernel_version, self.system_arch, self.verge_version, self.running_mode
)
}
}
impl PlatformSpecification {
pub fn new() -> Self {
let system_name = System::name().unwrap_or("Null".into());
let system_version = System::long_os_version().unwrap_or("Null".into());
let system_kernel_version = System::kernel_version().unwrap_or("Null".into());
let system_arch = std::env::consts::ARCH.to_string();
let handler = handle::Handle::global().app_handle().unwrap();
let config = handler.config();
let verge_version = config.version.clone().unwrap_or("Null".into());
// Get running mode asynchronously
let running_mode = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
match CoreManager::global().get_running_mode().await {
crate::core::RunningMode::Service => "Service".to_string(),
crate::core::RunningMode::Sidecar => "Sidecar".to_string(),
crate::core::RunningMode::NotRunning => "Not Running".to_string(),
}
})
});
Self {
system_name,
system_version,
system_kernel_version,
system_arch,
verge_version,
running_mode,
}
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ pub fn get_encryption_key() -> Result<Vec<u8>> {
} else {
// Generate and save new key
let mut key = vec![0u8; 32];
getrandom::getrandom(&mut key)?;
getrandom::fill(&mut key)?;
// Ensure directory exists
if let Some(parent) = key_path.parent() {
+4 -3
View File
@@ -205,10 +205,11 @@ macro_rules! t {
/// 支持 B/s、KB/s、MB/s、GB/s 的自动转换
///
/// # Examples
/// ```not_run
/// format_bytes_speed(1000) // returns "1000B/s"
/// format_bytes_speed(1024) // returns "1.0KB/s"
/// format_bytes_speed(1024 * 1024) // returns "1.0MB/s"
/// ```
/// assert_eq!(format_bytes_speed(1000), "1000B/s");
/// assert_eq!(format_bytes_speed(1024), "1.0KB/s");
/// assert_eq!(format_bytes_speed(1024 * 1024), "1.0MB/s");
/// ```
#[cfg(target_os = "macos")]
pub fn format_bytes_speed(speed: u64) -> String {
+78
View File
@@ -133,6 +133,81 @@ pub fn delete_log() -> Result<()> {
Ok(())
}
/// 初始化DNS配置文件
fn init_dns_config() -> Result<()> {
use serde_yaml::Value;
// 获取默认DNS配置
let default_dns_config = serde_yaml::Mapping::from_iter([
("enable".into(), Value::Bool(true)),
("listen".into(), Value::String(":53".into())),
("enhanced-mode".into(), Value::String("fake-ip".into())),
("fake-ip-range".into(), Value::String("198.18.0.1/16".into())),
("fake-ip-filter-mode".into(), Value::String("blacklist".into())),
("prefer-h3".into(), Value::Bool(false)),
("respect-rules".into(), Value::Bool(false)),
("use-hosts".into(), Value::Bool(false)),
("use-system-hosts".into(), Value::Bool(false)),
("fake-ip-filter".into(), Value::Sequence(vec![
Value::String("*.lan".into()),
Value::String("*.local".into()),
Value::String("*.arpa".into()),
Value::String("time.*.com".into()),
Value::String("ntp.*.com".into()),
Value::String("time.*.com".into()),
Value::String("+.market.xiaomi.com".into()),
Value::String("localhost.ptlogin2.qq.com".into()),
Value::String("*.msftncsi.com".into()),
Value::String("www.msftconnecttest.com".into()),
])),
("default-nameserver".into(), Value::Sequence(vec![
Value::String("223.6.6.6".into()),
Value::String("8.8.8.8".into()),
])),
("nameserver".into(), Value::Sequence(vec![
Value::String("8.8.8.8".into()),
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
])),
("fallback".into(), Value::Sequence(vec![
Value::String("https://dns.alidns.com/dns-query".into()),
Value::String("https://dns.google/dns-query".into()),
Value::String("https://cloudflare-dns.com/dns-query".into()),
])),
("nameserver-policy".into(), Value::Mapping(serde_yaml::Mapping::new())),
("proxy-server-nameserver".into(), Value::Sequence(vec![
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
])),
("direct-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
("fallback-filter".into(), Value::Mapping(serde_yaml::Mapping::from_iter([
("geoip".into(), Value::Bool(true)),
("geoip-code".into(), Value::String("CN".into())),
("ipcidr".into(), Value::Sequence(vec![
Value::String("240.0.0.0/4".into()),
Value::String("0.0.0.0/32".into()),
])),
("domain".into(), Value::Sequence(vec![
Value::String("+.google.com".into()),
Value::String("+.facebook.com".into()),
Value::String("+.youtube.com".into()),
])),
]))),
]);
// 检查DNS配置文件是否存在
let app_dir = dirs::app_home_dir()?;
let dns_path = app_dir.join("dns_config.yaml");
if !dns_path.exists() {
log::info!(target: "app", "Creating default DNS config file");
help::save_yaml(&dns_path, &default_dns_config, Some("# Clash Verge DNS Config"))?;
}
Ok(())
}
/// Initialize all the config files
/// before tauri setup
pub fn init_config() -> Result<()> {
@@ -173,6 +248,9 @@ pub fn init_config() -> Result<()> {
<Result<()>>::Ok(())
}));
// 初始化DNS配置文件
let _ = init_dns_config();
Ok(())
}
+10 -5
View File
@@ -2,6 +2,8 @@ use crate::config::IVerge;
use crate::utils::error;
use crate::{config::Config, config::PrfItem, core::*, utils::init, utils::server};
use crate::{log_err, wrap_err};
#[cfg(target_os = "macos")]
use crate::AppHandleManager;
use anyhow::{bail, Result};
use once_cell::sync::OnceCell;
use percent_encoding::percent_decode_str;
@@ -37,7 +39,10 @@ pub fn find_unused_port() -> Result<u16> {
pub async fn resolve_setup(app: &mut App) {
error::redirect_panic_to_log();
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
{
AppHandleManager::global().init(app.app_handle().clone());
AppHandleManager::global().set_activation_policy_accessory();
}
let version = app.package_info().version.to_string();
handle::Handle::global().init(app.app_handle());
@@ -72,14 +77,12 @@ pub async fn resolve_setup(app: &mut App) {
}
}
if !service_runing {
log::error!(target: "app", "service not runing. exit");
app.app_handle().exit(-2);
log::warn!(target: "app", "service not running, will fallback to user mode");
}
}
}
Err(e) => {
log::error!(target: "app", "{e:?}");
app.app_handle().exit(-1);
log::warn!(target: "app", "failed to install service: {e:?}, will fallback to user mode");
}
}
}
@@ -130,6 +133,8 @@ pub fn create_window() {
log::info!(target: "app", "Starting to create window");
let app_handle = handle::Handle::global().app_handle().unwrap();
#[cfg(target_os = "macos")]
AppHandleManager::global().set_activation_policy_regular();
if let Some(window) = handle::Handle::global().get_window() {
println!("Found existing window, trying to show it");
+12 -13
View File
@@ -1,4 +1,5 @@
{
"version": "2.1.3-alpha",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
@@ -16,7 +17,7 @@
"copyright": "GNU General Public License v3.0",
"category": "DeveloperTool",
"shortDescription": "Clash Verge Rev",
"createUpdaterArtifacts": "v1Compatible"
"createUpdaterArtifacts": true
},
"build": {
"beforeBuildCommand": "pnpm run web:build",
@@ -25,24 +26,22 @@
"devUrl": "http://localhost:3000/"
},
"productName": "Clash Verge",
"version": "2.1.1",
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK",
"endpoints": [
"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"
],
"windows": {
"installMode": "basicUi"
}
},
"deep-link": {
"desktop": {
"schemes": ["clash", "clash-verge"]
}
},
"updater": {
"active": true,
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK",
"endpoints": [
"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://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/alpha/update-alpha-proxy.json",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/alpha/update-alpha.json"
]
}
},
"app": {
+1
View File
@@ -1,6 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
"productName": "Clash Verge",
"bundle": {
"targets": ["app", "dmg"],
"macOS": {
+1 -1
View File
@@ -111,7 +111,7 @@
position: absolute;
top: 0;
left: 0;
right: 0px;
right: 1px;
bottom: 0px;
}
}
+6 -4
View File
@@ -103,7 +103,6 @@ interface NoticeInstance {
let parent: HTMLDivElement = null!;
// @ts-ignore
export const Notice: NoticeInstance = (props) => {
const { type, message, duration } = props;
@@ -142,8 +141,8 @@ export const Notice: NoticeInstance = (props) => {
);
};
(["info", "error", "success"] as const).forEach((type) => {
Notice[type] = (message: ReactNode, duration?: number) => {
const createNoticeTypeFactory =
(type: keyof NoticeInstance) => (message: ReactNode, duration?: number) => {
// 确保消息不为空
if (!message) {
return;
@@ -156,4 +155,7 @@ export const Notice: NoticeInstance = (props) => {
duration: type === "error" ? 8000 : duration || 1500,
});
};
});
Notice.info = createNoticeTypeFactory("info");
Notice.error = createNoticeTypeFactory("error");
Notice.success = createNoticeTypeFactory("success");
@@ -30,7 +30,13 @@ export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
open={open}
onClose={onClose}
sx={{ maxWidth: "520px" }}
sx={{
".MuiSnackbarContent-root": {
maxWidth: "520px",
maxHeight: "480px",
overflowY: "auto",
},
}}
message={
detail ? (
<InnerConnectionDetail data={detail} onClose={onClose} />
@@ -51,8 +57,11 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const chains = [...data.chains].reverse().join(" / ");
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const host = metadata.host
? `${metadata.host}:${metadata.remoteDestination}`
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
const Destination = metadata.destinationIP
? metadata.destinationIP
: metadata.remoteDestination;
const information = [
{ label: t("Host"), value: host },
@@ -66,7 +75,10 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
label: t("UL Speed"),
value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s",
},
{ label: t("Chains"), value: chains },
{
label: t("Chains"),
value: chains,
},
{ label: t("Rule"), value: rule },
{
label: t("Process"),
@@ -79,7 +91,7 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
label: t("Source"),
value: `${metadata.sourceIP}:${metadata.sourcePort}`,
},
{ label: t("Destination"), value: metadata.remoteDestination },
{ label: t("Destination"), value: Destination },
{ label: t("DestinationPort"), value: `${metadata.destinationPort}` },
{ label: t("Type"), value: `${metadata.type}(${metadata.network})` },
];
@@ -90,7 +102,8 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
<Box sx={{ userSelect: "text" }}>
{information.map((each) => (
<div key={each.label}>
<b>{each.label}</b>: <span>{each.value}</span>
<b>{each.label}</b>
<span style={{ wordBreak: "break-all" }}>: {each.value}</span>
</div>
))}
@@ -138,6 +138,9 @@ export const ConnectionTable = (props: Props) => {
const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
const Destination = metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
return {
id: each.id,
host: metadata.host
@@ -152,7 +155,7 @@ export const ConnectionTable = (props: Props) => {
process: truncateStr(metadata.process || metadata.processPath),
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: `${metadata.remoteDestination}:${metadata.destinationPort}`,
remoteDestination: Destination,
type: `${metadata.type}(${metadata.network})`,
connectionData: each,
};
+64 -28
View File
@@ -11,7 +11,7 @@ import { TrafficGraph, type TrafficRef } from "./traffic-graph";
import { useVisibility } from "@/hooks/use-visibility";
import parseTraffic from "@/utils/parse-traffic";
import useSWRSubscription from "swr/subscription";
import { createSockette } from "@/utils/websocket";
import { createSockette, createAuthSockette } from "@/utils/websocket";
import { useTranslation } from "react-i18next";
import { isDebugEnabled, gc } from "@/services/api";
@@ -47,23 +47,41 @@ export const LayoutTraffic = () => {
(_key, { next }) => {
const { server = "", secret = "" } = clashInfo!;
const s = createSockette(
`ws://${server}${secret ? `/traffic?token=${encodeURIComponent(secret)}` : "/traffic"}`,
{
onmessage(event) {
const data = JSON.parse(event.data) as ITrafficItem;
trafficRef.current?.appendData(data);
next(null, data);
},
onerror(event) {
this.close();
next(event, { up: 0, down: 0 });
},
if (!server) {
console.warn("[Traffic] 服务器地址为空,无法建立连接");
next(null, { up: 0, down: 0 });
return () => {};
}
console.log(`[Traffic] 正在连接: ${server}/traffic`);
const s = createAuthSockette(`${server}/traffic`, secret, {
timeout: 8000, // 8秒超时
onmessage(event) {
const data = JSON.parse(event.data) as ITrafficItem;
trafficRef.current?.appendData(data);
next(null, data);
},
);
onerror(event) {
console.error("[Traffic] WebSocket 连接错误", event);
this.close();
next(null, { up: 0, down: 0 });
},
onclose(event) {
console.log("[Traffic] WebSocket 连接关闭", event);
},
onopen(event) {
console.log("[Traffic] WebSocket 连接已建立");
},
});
return () => {
s.close();
console.log("[Traffic] 清理WebSocket连接");
try {
s.close();
} catch (e) {
console.error("[Traffic] 关闭连接时出错", e);
}
};
},
{
@@ -85,22 +103,40 @@ export const LayoutTraffic = () => {
(_key, { next }) => {
const { server = "", secret = "" } = clashInfo!;
const s = createSockette(
`ws://${server}${secret ? `/memory?token=${encodeURIComponent(secret)}` : "/memory"}`,
{
onmessage(event) {
const data = JSON.parse(event.data) as MemoryUsage;
next(null, data);
},
onerror(event) {
this.close();
next(event, { inuse: 0 });
},
if (!server) {
console.warn("[Memory] 服务器地址为空,无法建立连接");
next(null, { inuse: 0 });
return () => {};
}
console.log(`[Memory] 正在连接: ${server}/memory`);
const s = createAuthSockette(`${server}/memory`, secret, {
timeout: 8000, // 8秒超时
onmessage(event) {
const data = JSON.parse(event.data) as MemoryUsage;
next(null, data);
},
);
onerror(event) {
console.error("[Memory] WebSocket 连接错误", event);
this.close();
next(null, { inuse: 0 });
},
onclose(event) {
console.log("[Memory] WebSocket 连接关闭", event);
},
onopen(event) {
console.log("[Memory] WebSocket 连接已建立");
},
});
return () => {
s.close();
console.log("[Memory] 清理WebSocket连接");
try {
s.close();
} catch (e) {
console.error("[Memory] 关闭连接时出错", e);
}
};
},
{
+50 -28
View File
@@ -4,14 +4,33 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useSetThemeMode, useThemeMode } from "@/services/states";
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
import { useVerge } from "@/hooks/use-verge";
import { useTheme } from "@mui/material/styles";
import {
zhCN as zhXDataGrid,
enUS as enXDataGrid,
ruRU as ruXDataGrid,
faIR as faXDataGrid,
arSD as arXDataGrid,
} from "@mui/x-data-grid/locales";
import { useTranslation } from "react-i18next";
const appWindow = getCurrentWebviewWindow();
const languagePackMap: Record<string, any> = {
zh: { ...zhXDataGrid },
fa: { ...faXDataGrid },
ru: { ...ruXDataGrid },
ar: { ...arXDataGrid },
en: { ...enXDataGrid },
};
const getLanguagePackMap = (key: string) =>
languagePackMap[key] || languagePackMap.en;
/**
* custom theme
*/
export const useCustomTheme = () => {
const { verge } = useVerge();
const { i18n } = useTranslation();
const { theme_mode, theme_setting } = verge ?? {};
const mode = useThemeMode();
const setMode = useSetThemeMode();
@@ -41,34 +60,37 @@ export const useCustomTheme = () => {
let theme: Theme;
try {
theme = createTheme({
breakpoints: {
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
},
palette: {
mode,
primary: { main: setting.primary_color || dt.primary_color },
secondary: { main: setting.secondary_color || dt.secondary_color },
info: { main: setting.info_color || dt.info_color },
error: { main: setting.error_color || dt.error_color },
warning: { main: setting.warning_color || dt.warning_color },
success: { main: setting.success_color || dt.success_color },
text: {
primary: setting.primary_text || dt.primary_text,
secondary: setting.secondary_text || dt.secondary_text,
theme = createTheme(
{
breakpoints: {
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
},
background: {
paper: dt.background_color,
palette: {
mode,
primary: { main: setting.primary_color || dt.primary_color },
secondary: { main: setting.secondary_color || dt.secondary_color },
info: { main: setting.info_color || dt.info_color },
error: { main: setting.error_color || dt.error_color },
warning: { main: setting.warning_color || dt.warning_color },
success: { main: setting.success_color || dt.success_color },
text: {
primary: setting.primary_text || dt.primary_text,
secondary: setting.secondary_text || dt.secondary_text,
},
background: {
paper: dt.background_color,
},
},
shadows: Array(25).fill("none") as Shadows,
typography: {
// todo
fontFamily: setting.font_family
? `${setting.font_family}, ${dt.font_family}`
: dt.font_family,
},
},
shadows: Array(25).fill("none") as Shadows,
typography: {
// todo
fontFamily: setting.font_family
? `${setting.font_family}, ${dt.font_family}`
: dt.font_family,
},
});
getLanguagePackMap(i18n.language),
);
} catch {
// fix #294
theme = createTheme({
@@ -92,7 +114,7 @@ export const useCustomTheme = () => {
// css
const backgroundColor = mode === "light" ? "#ECECEC" : "#2e303d";
const selectColor = mode === "light" ? "#f5f5f5" : "#d5d5d5";
const scrollColor = mode === "light" ? "#90939980" : "#54545480";
const scrollColor = mode === "light" ? "#90939980" : "#3E3E3Eee";
const dividerColor =
mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)";
@@ -133,7 +155,7 @@ export const useCustomTheme = () => {
}, 0);
return theme;
}, [mode, theme_setting]);
}, [mode, theme_setting, i18n.language]);
return { theme };
};
+77 -14
View File
@@ -41,12 +41,14 @@ const AlphabetSelector = styled(Box)(({ theme }) => ({
"& .scroll-container": {
overflow: "hidden",
maxHeight: "inherit",
willChange: "transform",
},
"& .letter-container": {
display: "flex",
flexDirection: "column",
gap: "2px",
transition: "transform 0.2s ease",
willChange: "transform",
},
"& .letter": {
padding: "1px 4px",
@@ -229,7 +231,8 @@ export const ProxyGroups = (props: Props) => {
// 缓存getFirstChar函数
const getFirstChar = useCallback((str: string) => {
const regex = /\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
const regex =
/\p{Regional_Indicator}{2}|\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
const match = str.match(regex);
return match ? match[0] : str.charAt(0);
}, []);
@@ -259,7 +262,7 @@ export const ProxyGroups = (props: Props) => {
}
}, [mode, renderList]);
// 使用防抖保存滚动位置
// 改为使用节流函数保存滚动位置
const saveScrollPosition = useCallback(
(scrollTop: number) => {
try {
@@ -275,13 +278,14 @@ export const ProxyGroups = (props: Props) => {
[mode],
);
// 优化滚动处理函数,使用防抖
// 使用改进的滚动处理
const handleScroll = useCallback(
debounce((e: any) => {
throttle((e: any) => {
const scrollTop = e.target.scrollTop;
setShowScrollTop(scrollTop > 100);
// 使用稳定的节流来保存位置,而不是setTimeout
saveScrollPosition(scrollTop);
}, 16),
}, 500), // 增加到500ms以确保平滑滚动
[saveScrollPosition],
);
@@ -361,6 +365,8 @@ export const ProxyGroups = (props: Props) => {
// 测全部延迟
const handleCheckAll = useLockFn(async (groupName: string) => {
console.log(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`);
const proxies = renderList
.filter(
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
@@ -368,20 +374,40 @@ export const ProxyGroups = (props: Props) => {
.flatMap((e) => e.proxyCol || e.proxy!)
.filter(Boolean);
console.log(`[ProxyGroups] 找到代理数量: ${proxies.length}`);
const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean));
if (providers.size) {
console.log(`[ProxyGroups] 发现提供者,数量: ${providers.size}`);
Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p)),
).then(() => onProxies());
).then(() => {
console.log(`[ProxyGroups] 提供者健康检查完成`);
onProxies();
});
}
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name);
console.log(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`);
await Promise.race([
delayManager.checkListDelay(names, groupName, timeout),
getGroupProxyDelays(groupName, delayManager.getUrl(groupName), timeout), // 查询group delays 将清除fixed(不关注调用结果)
]);
const url = delayManager.getUrl(groupName);
console.log(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`);
try {
await Promise.race([
delayManager.checkListDelay(names, groupName, timeout),
getGroupProxyDelays(groupName, url, timeout).then((result) => {
console.log(
`[ProxyGroups] getGroupProxyDelays返回结果数量:`,
Object.keys(result || {}).length,
);
}), // 查询group delays 将清除fixed(不关注调用结果)
]);
console.log(`[ProxyGroups] 延迟测试完成,组: ${groupName}`);
} catch (error) {
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error);
}
onProxies();
});
@@ -407,8 +433,11 @@ export const ProxyGroups = (props: Props) => {
}
};
// 添加滚轮事件处理函数
// 添加滚轮事件处理函数 - 改进为只在悬停时触发
const handleWheel = useCallback((e: WheelEvent) => {
// 只有当鼠标在字母选择器上时才处理滚轮事件
if (!alphabetSelectorRef.current?.contains(e.target as Node)) return;
e.preventDefault();
if (!letterContainerRef.current) return;
@@ -470,12 +499,14 @@ export const ProxyGroups = (props: Props) => {
}
return (
<div style={{ position: "relative", height: "100%" }}>
<div
style={{ position: "relative", height: "100%", willChange: "transform" }}
>
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 14px)" }}
totalCount={renderList.length}
increaseViewportBy={{ top: 256, bottom: 256 }}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
@@ -484,6 +515,9 @@ export const ProxyGroups = (props: Props) => {
components={{
Footer: () => <div style={{ height: "8px" }} />,
}}
// 添加平滑滚动设置
initialScrollTop={scrollPositionRef.current[mode]}
computeItemKey={(index) => renderList[index].key}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
@@ -516,7 +550,36 @@ export const ProxyGroups = (props: Props) => {
);
};
// 简单的防抖函数
// 替换简单防抖函数为更优的节流函数
function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let previous = 0;
return function (...args: Parameters<T>) {
const now = Date.now();
const remaining = wait - (now - previous);
if (remaining <= 0 || remaining > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
}
previous = now;
func(...args);
} else if (!timer) {
timer = setTimeout(() => {
previous = Date.now();
timer = null;
func(...args);
}, remaining);
}
};
}
// 保留防抖函数以兼容其他地方可能的使用
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
+3 -1
View File
@@ -48,7 +48,7 @@ export const ProxyHead = (props: Props) => {
useEffect(() => {
delayManager.setUrl(
groupName,
testUrl || url || verge?.default_latency_test!
testUrl || url || verge?.default_latency_test!,
);
}, [groupName, testUrl, verge?.default_latency_test]);
@@ -68,8 +68,10 @@ export const ProxyHead = (props: Props) => {
color="inherit"
title={t("Delay check")}
onClick={() => {
console.log(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`);
// Remind the user that it is custom test url
if (testUrl?.trim() && textState !== "filter") {
console.log(`[ProxyHead] 使用自定义测试URL: ${testUrl}`);
onHeadState({ textState: "url" });
}
props.onCheckDelay();
+961
View File
@@ -0,0 +1,961 @@
import { forwardRef, useImperativeHandle, useState, useEffect } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
Box,
Button,
FormControl,
List,
ListItem,
ListItemText,
MenuItem,
Select,
styled,
Switch,
TextField,
Typography,
} from "@mui/material";
import { RestartAltRounded } from "@mui/icons-material";
import { useClash } from "@/hooks/use-clash";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import yaml from "js-yaml";
import MonacoEditor from "react-monaco-editor";
import { useThemeMode } from "@/services/states";
import getSystem from "@/utils/get-system";
import { invoke } from "@tauri-apps/api/core";
const Item = styled(ListItem)(({ theme }) => ({
padding: "8px 0",
borderBottom: `1px solid ${theme.palette.divider}`,
"& textarea": {
lineHeight: 1.5,
fontSize: 14,
resize: "vertical",
},
}));
// 默认DNS配置
const DEFAULT_DNS_CONFIG = {
enable: true,
listen: ":53",
"enhanced-mode": "fake-ip" as "fake-ip" | "redir-host",
"fake-ip-range": "198.18.0.1/16",
"fake-ip-filter-mode": "blacklist" as "blacklist" | "whitelist",
"prefer-h3": false,
"respect-rules": false,
"use-hosts": false,
"use-system-hosts": false,
"fake-ip-filter": [
"*.lan",
"*.local",
"*.arpa",
"time.*.com",
"ntp.*.com",
"time.*.com",
"+.market.xiaomi.com",
"localhost.ptlogin2.qq.com",
"*.msftncsi.com",
"www.msftconnecttest.com",
],
"default-nameserver": ["223.6.6.6", "8.8.8.8"],
nameserver: [
"8.8.8.8",
"https://doh.pub/dns-query",
"https://dns.alidns.com/dns-query",
],
fallback: [
"https://dns.alidns.com/dns-query",
"https://dns.google/dns-query",
"https://cloudflare-dns.com/dns-query",
],
"nameserver-policy": {},
"proxy-server-nameserver": [
"https://doh.pub/dns-query",
"https://dns.alidns.com/dns-query",
],
"direct-nameserver": [],
"direct-nameserver-follow-policy": false,
"fallback-filter": {
geoip: true,
"geoip-code": "CN",
ipcidr: ["240.0.0.0/4", "0.0.0.0/32"],
domain: ["+.google.com", "+.facebook.com", "+.youtube.com"],
},
};
export const DnsViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { clash, mutateClash, patchClash } = useClash();
const themeMode = useThemeMode();
const [open, setOpen] = useState(false);
const [visualization, setVisualization] = useState(true);
const [values, setValues] = useState<{
enable: boolean;
listen: string;
enhancedMode: "fake-ip" | "redir-host";
fakeIpRange: string;
fakeIpFilterMode: "blacklist" | "whitelist";
preferH3: boolean;
respectRules: boolean;
fakeIpFilter: string;
nameserver: string;
fallback: string;
defaultNameserver: string;
useHosts: boolean;
useSystemHosts: boolean;
proxyServerNameserver: string;
directNameserver: string;
directNameserverFollowPolicy: boolean;
fallbackGeoip: boolean;
fallbackGeoipCode: string;
fallbackIpcidr: string;
fallbackDomain: string;
nameserverPolicy: string;
}>({
enable: DEFAULT_DNS_CONFIG.enable,
listen: DEFAULT_DNS_CONFIG.listen,
enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"],
fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"],
preferH3: DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules: DEFAULT_DNS_CONFIG["respect-rules"],
fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback: DEFAULT_DNS_CONFIG.fallback.join(", "),
useHosts: DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"],
proxyServerNameserver:
DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "",
directNameserver: DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "",
directNameserverFollowPolicy:
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false,
fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "",
fallbackDomain:
DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "",
nameserverPolicy: "",
});
// 用于YAML编辑模式
const [yamlContent, setYamlContent] = useState("");
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
// 获取DNS配置文件并初始化表单
initDnsConfig();
},
close: () => setOpen(false),
}));
// 初始化DNS配置
const initDnsConfig = async () => {
try {
// 尝试从dns_config.yaml文件读取配置
const dnsConfigExists = await invoke<boolean>(
"check_dns_config_exists",
{},
);
if (dnsConfigExists) {
// 如果存在配置文件,加载其内容
const dnsConfig = await invoke<string>("get_dns_config_content", {});
const config = yaml.load(dnsConfig) as any;
// 更新表单数据
updateValuesFromConfig(config);
// 更新YAML编辑器内容
setYamlContent(dnsConfig);
} else {
// 如果不存在配置文件,使用默认值
resetToDefaults();
}
} catch (err) {
console.error("Failed to initialize DNS config", err);
resetToDefaults();
}
};
// 从配置对象更新表单值
const updateValuesFromConfig = (config: any) => {
if (!config) return;
const enhancedMode =
config["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"];
const validEnhancedMode =
enhancedMode === "fake-ip" || enhancedMode === "redir-host"
? enhancedMode
: DEFAULT_DNS_CONFIG["enhanced-mode"];
const fakeIpFilterMode =
config["fake-ip-filter-mode"] ||
DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
const validFakeIpFilterMode =
fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist"
? fakeIpFilterMode
: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
setValues({
enable: config.enable ?? DEFAULT_DNS_CONFIG.enable,
listen: config.listen ?? DEFAULT_DNS_CONFIG.listen,
enhancedMode: validEnhancedMode,
fakeIpRange:
config["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: validFakeIpFilterMode,
preferH3: config["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules:
config["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"],
fakeIpFilter:
config["fake-ip-filter"]?.join(", ") ??
DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
nameserver:
config.nameserver?.join(", ") ??
DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback:
config.fallback?.join(", ") ?? DEFAULT_DNS_CONFIG.fallback.join(", "),
defaultNameserver:
config["default-nameserver"]?.join(", ") ??
DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
useHosts: config["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts:
config["use-system-hosts"] ?? DEFAULT_DNS_CONFIG["use-system-hosts"],
proxyServerNameserver:
config["proxy-server-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""),
directNameserver:
config["direct-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""),
directNameserverFollowPolicy:
config["direct-nameserver-follow-policy"] ??
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"],
fallbackGeoip:
config["fallback-filter"]?.geoip ??
DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode:
config["fallback-filter"]?.["geoip-code"] ??
DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
config["fallback-filter"]?.ipcidr?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "),
fallbackDomain:
config["fallback-filter"]?.domain?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "),
nameserverPolicy:
formatNameserverPolicy(config["nameserver-policy"]) || "",
});
};
// 重置为默认值
const resetToDefaults = () => {
setValues({
enable: DEFAULT_DNS_CONFIG.enable,
listen: DEFAULT_DNS_CONFIG.listen,
enhancedMode: DEFAULT_DNS_CONFIG["enhanced-mode"],
fakeIpRange: DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"],
preferH3: DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules: DEFAULT_DNS_CONFIG["respect-rules"],
fakeIpFilter: DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
defaultNameserver: DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback: DEFAULT_DNS_CONFIG.fallback.join(", "),
useHosts: DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts: DEFAULT_DNS_CONFIG["use-system-hosts"],
proxyServerNameserver:
DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || "",
directNameserver:
DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || "",
directNameserverFollowPolicy:
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"] || false,
fallbackGeoip: DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode: DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr?.join(", ") || "",
fallbackDomain:
DEFAULT_DNS_CONFIG["fallback-filter"].domain?.join(", ") || "",
nameserverPolicy: "",
});
// 更新YAML编辑器内容
updateYamlFromValues(DEFAULT_DNS_CONFIG);
};
// 从表单值更新YAML内容
const updateYamlFromValues = (dnsConfig: any = null) => {
// 如果提供了dnsConfig,直接使用它
if (dnsConfig) {
setYamlContent(yaml.dump(dnsConfig, { forceQuotes: true }));
return;
}
// 否则从当前表单值生成
const config = generateDnsConfig();
setYamlContent(yaml.dump(config, { forceQuotes: true }));
};
// 从YAML更新表单值
const updateValuesFromYaml = () => {
try {
const dnsConfig = yaml.load(yamlContent) as any;
if (!dnsConfig) return;
const enhancedMode =
dnsConfig["enhanced-mode"] || DEFAULT_DNS_CONFIG["enhanced-mode"];
// 确保enhancedMode只能是"fake-ip"或"redir-host"
const validEnhancedMode =
enhancedMode === "fake-ip" || enhancedMode === "redir-host"
? enhancedMode
: DEFAULT_DNS_CONFIG["enhanced-mode"];
const fakeIpFilterMode =
dnsConfig["fake-ip-filter-mode"] ||
DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
// 确保fakeIpFilterMode只能是"blacklist"或"whitelist"
const validFakeIpFilterMode =
fakeIpFilterMode === "blacklist" || fakeIpFilterMode === "whitelist"
? fakeIpFilterMode
: DEFAULT_DNS_CONFIG["fake-ip-filter-mode"];
setValues({
enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable,
listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen,
enhancedMode: validEnhancedMode,
fakeIpRange:
dnsConfig["fake-ip-range"] ?? DEFAULT_DNS_CONFIG["fake-ip-range"],
fakeIpFilterMode: validFakeIpFilterMode,
preferH3: dnsConfig["prefer-h3"] ?? DEFAULT_DNS_CONFIG["prefer-h3"],
respectRules:
dnsConfig["respect-rules"] ?? DEFAULT_DNS_CONFIG["respect-rules"],
fakeIpFilter:
dnsConfig["fake-ip-filter"]?.join(", ") ??
DEFAULT_DNS_CONFIG["fake-ip-filter"].join(", "),
defaultNameserver:
dnsConfig["default-nameserver"]?.join(", ") ??
DEFAULT_DNS_CONFIG["default-nameserver"].join(", "),
nameserver:
dnsConfig.nameserver?.join(", ") ??
DEFAULT_DNS_CONFIG.nameserver.join(", "),
fallback:
dnsConfig.fallback?.join(", ") ??
DEFAULT_DNS_CONFIG.fallback.join(", "),
useHosts: dnsConfig["use-hosts"] ?? DEFAULT_DNS_CONFIG["use-hosts"],
useSystemHosts:
dnsConfig["use-system-hosts"] ??
DEFAULT_DNS_CONFIG["use-system-hosts"],
proxyServerNameserver:
dnsConfig["proxy-server-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["proxy-server-nameserver"]?.join(", ") || ""),
directNameserver:
dnsConfig["direct-nameserver"]?.join(", ") ??
(DEFAULT_DNS_CONFIG["direct-nameserver"]?.join(", ") || ""),
directNameserverFollowPolicy:
dnsConfig["direct-nameserver-follow-policy"] ??
DEFAULT_DNS_CONFIG["direct-nameserver-follow-policy"],
fallbackGeoip:
dnsConfig["fallback-filter"]?.geoip ??
DEFAULT_DNS_CONFIG["fallback-filter"].geoip,
fallbackGeoipCode:
dnsConfig["fallback-filter"]?.["geoip-code"] ??
DEFAULT_DNS_CONFIG["fallback-filter"]["geoip-code"],
fallbackIpcidr:
dnsConfig["fallback-filter"]?.ipcidr?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].ipcidr.join(", "),
fallbackDomain:
dnsConfig["fallback-filter"]?.domain?.join(", ") ??
DEFAULT_DNS_CONFIG["fallback-filter"].domain.join(", "),
nameserverPolicy:
formatNameserverPolicy(dnsConfig["nameserver-policy"]) || "",
});
} catch (err: any) {
Notice.error(t("Invalid YAML format"));
}
};
// 格式化nameserver-policy为字符串
const formatNameserverPolicy = (policy: any): string => {
if (!policy) return "";
let result: string[] = [];
Object.entries(policy).forEach(([domain, servers]) => {
if (Array.isArray(servers)) {
// 处理数组格式的服务器
const serversStr = servers.join(";");
result.push(`${domain}=${serversStr}`);
} else {
// 处理单个服务器
result.push(`${domain}=${servers}`);
}
});
return result.join(", ");
};
// 解析nameserver-policy为对象
const parseNameserverPolicy = (str: string): Record<string, any> => {
const result: Record<string, any> = {};
if (!str) return result;
str.split(",").forEach((item) => {
const parts = item.trim().split("=");
if (parts.length < 2) return;
const domain = parts[0].trim();
const serversStr = parts.slice(1).join("=").trim();
// 检查是否包含多个分号分隔的服务器
if (serversStr.includes(";")) {
// 多个服务器,作为数组处理
result[domain] = serversStr
.split(";")
.map((s) => s.trim())
.filter(Boolean);
} else {
// 单个服务器
result[domain] = serversStr;
}
});
return result;
};
// 初始化时设置默认YAML
useEffect(() => {
updateYamlFromValues(DEFAULT_DNS_CONFIG);
}, []);
// 切换编辑模式时的处理
useEffect(() => {
if (visualization) {
// 从YAML更新表单值
updateValuesFromYaml();
} else {
// 从表单值更新YAML
updateYamlFromValues();
}
}, [visualization]);
// 解析列表字符串为数组
const parseList = (str: string): string[] => {
if (!str) return [];
return str
.split(",")
.map((item) => item.trim())
.filter(Boolean);
};
// 生成DNS配置对象
const generateDnsConfig = () => {
const dnsConfig: any = {
enable: values.enable,
listen: values.listen,
"enhanced-mode": values.enhancedMode,
"fake-ip-range": values.fakeIpRange,
"fake-ip-filter-mode": values.fakeIpFilterMode,
"prefer-h3": values.preferH3,
"respect-rules": values.respectRules,
"fake-ip-filter": parseList(values.fakeIpFilter),
"default-nameserver": parseList(values.defaultNameserver),
nameserver: parseList(values.nameserver),
fallback: parseList(values.fallback),
"use-hosts": values.useHosts,
"use-system-hosts": values.useSystemHosts,
"fallback-filter": {
geoip: values.fallbackGeoip,
"geoip-code": values.fallbackGeoipCode,
ipcidr: parseList(values.fallbackIpcidr),
domain: parseList(values.fallbackDomain),
},
};
// 只在有nameserverPolicy时添加
const policy = parseNameserverPolicy(values.nameserverPolicy);
if (Object.keys(policy).length > 0) {
dnsConfig["nameserver-policy"] = policy;
}
// 只在有值时添加其他可选字段
if (values.proxyServerNameserver) {
dnsConfig["proxy-server-nameserver"] = parseList(
values.proxyServerNameserver,
);
}
if (values.directNameserver) {
dnsConfig["direct-nameserver"] = parseList(values.directNameserver);
}
dnsConfig["direct-nameserver-follow-policy"] =
values.directNameserverFollowPolicy;
return dnsConfig;
};
// 处理保存操作
const onSave = useLockFn(async () => {
try {
let dnsConfig;
if (visualization) {
// 使用表单值
dnsConfig = generateDnsConfig();
} else {
// 使用YAML编辑器的值
const parsedConfig = yaml.load(yamlContent);
if (typeof parsedConfig !== "object" || parsedConfig === null) {
throw new Error(t("Invalid DNS configuration"));
}
dnsConfig = parsedConfig;
}
// 不直接应用到clash配置,而是保存到单独文件
await invoke("save_dns_config", { dnsConfig });
// 如果DNS开关当前是打开的,则需要应用新的DNS配置
if (clash?.dns?.enable) {
await invoke("apply_dns_config", { apply: true });
mutateClash(); // 刷新UI
}
setOpen(false);
Notice.success(t("DNS settings saved"));
} catch (err: any) {
Notice.error(err.message || err.toString());
}
});
// YAML编辑器内容变更处理
const handleYamlChange = (value: string) => {
setYamlContent(value || "");
// 允许YAML编辑后立即分析和更新表单值
try {
const dnsConfig = yaml.load(value) as any;
if (dnsConfig && typeof dnsConfig === "object") {
// 稍微延迟更新,以避免性能问题
setTimeout(() => {
updateValuesFromYaml();
}, 300);
}
} catch (err) {
// 忽略解析错误,只有当YAML有效时才更新表单
console.log("YAML解析错误,忽略自动更新", err);
}
};
// 处理表单值变化
const handleChange = (field: string) => (event: any) => {
const value =
event.target.type === "checkbox"
? event.target.checked
: event.target.value;
setValues((prev) => {
const newValues = {
...prev,
[field]: value,
};
// 当可视化编辑模式下的值变化时,自动更新YAML
if (visualization) {
setTimeout(() => {
updateYamlFromValues(null);
}, 0);
}
return newValues;
});
};
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between" alignItems="center">
{t("DNS Settings")}
<Box display="flex" alignItems="center" gap={1}>
<Button
variant="outlined"
size="small"
color="warning"
startIcon={<RestartAltRounded />}
onClick={resetToDefaults}
>
{t("Reset to Default")}
</Button>
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev);
}}
>
{visualization ? t("Advanced") : t("Visualization")}
</Button>
</Box>
</Box>
}
contentSx={{
width: 550,
overflow: "auto",
...(visualization
? {}
: { padding: "0 24px", display: "flex", flexDirection: "column" }),
}}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
{/* Warning message */}
<Typography
variant="body2"
color="warning.main"
sx={{ mb: 2, mt: 0, fontStyle: "italic" }}
>
{t("DNS Settings Warning")}
</Typography>
{visualization ? (
<List>
<Item>
<ListItemText primary={t("Enable DNS")} />
<Switch
edge="end"
checked={values.enable}
onChange={handleChange("enable")}
/>
</Item>
<Item>
<ListItemText primary={t("DNS Listen")} />
<TextField
size="small"
autoComplete="off"
value={values.listen}
onChange={handleChange("listen")}
placeholder=":53"
sx={{ width: 150 }}
/>
</Item>
<Item>
<ListItemText primary={t("Enhanced Mode")} />
<FormControl size="small" sx={{ width: 150 }}>
<Select
value={values.enhancedMode}
onChange={handleChange("enhancedMode")}
>
<MenuItem value="fake-ip">fake-ip</MenuItem>
<MenuItem value="redir-host">redir-host</MenuItem>
</Select>
</FormControl>
</Item>
<Item>
<ListItemText primary={t("Fake IP Range")} />
<TextField
size="small"
autoComplete="off"
value={values.fakeIpRange}
onChange={handleChange("fakeIpRange")}
placeholder="198.18.0.1/16"
sx={{ width: 150 }}
/>
</Item>
<Item>
<ListItemText primary={t("Fake IP Filter Mode")} />
<FormControl size="small" sx={{ width: 150 }}>
<Select
value={values.fakeIpFilterMode}
onChange={handleChange("fakeIpFilterMode")}
>
<MenuItem value="blacklist">blacklist</MenuItem>
<MenuItem value="whitelist">whitelist</MenuItem>
</Select>
</FormControl>
</Item>
<Item>
<ListItemText
primary={t("Prefer H3")}
secondary={t("DNS DOH使用HTTP/3")}
/>
<Switch
edge="end"
checked={values.preferH3}
onChange={handleChange("preferH3")}
/>
</Item>
<Item>
<ListItemText
primary={t("Respect Rules")}
secondary={t("DNS连接遵守路由规则")}
/>
<Switch
edge="end"
checked={values.respectRules}
onChange={handleChange("respectRules")}
/>
</Item>
<Item>
<ListItemText
primary={t("Use Hosts")}
secondary={t("Enable to resolve hosts through hosts file")}
/>
<Switch
edge="end"
checked={values.useHosts}
onChange={handleChange("useHosts")}
/>
</Item>
<Item>
<ListItemText
primary={t("Use System Hosts")}
secondary={t("Enable to resolve hosts through system hosts file")}
/>
<Switch
edge="end"
checked={values.useSystemHosts}
onChange={handleChange("useSystemHosts")}
/>
</Item>
<Item>
<ListItemText
primary={t("Direct Nameserver Follow Policy")}
secondary={t("是否遵循nameserver-policy")}
/>
<Switch
edge="end"
checked={values.directNameserverFollowPolicy}
onChange={handleChange("directNameserverFollowPolicy")}
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Default Nameserver")}
secondary={t("Default DNS servers used to resolve DNS servers")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={3}
size="small"
value={values.defaultNameserver}
onChange={handleChange("defaultNameserver")}
placeholder="223.6.6.6, 8.8.8.8"
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Nameserver")}
secondary={t("List of DNS servers")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={4}
size="small"
value={values.nameserver}
onChange={handleChange("nameserver")}
placeholder="8.8.8.8, https://doh.pub/dns-query, https://dns.alidns.com/dns-query"
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Fallback")}
secondary={t("List of fallback DNS servers")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={4}
size="small"
value={values.fallback}
onChange={handleChange("fallback")}
placeholder="https://dns.alidns.com/dns-query, https://dns.google/dns-query, https://cloudflare-dns.com/dns-query"
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Proxy Server Nameserver")}
secondary={t("Proxy Node Nameserver")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={3}
size="small"
value={values.proxyServerNameserver}
onChange={handleChange("proxyServerNameserver")}
placeholder="https://doh.pub/dns-query, https://dns.alidns.com/dns-query"
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Direct Nameserver")}
secondary={t("Direct outbound Nameserver")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={3}
size="small"
value={values.directNameserver}
onChange={handleChange("directNameserver")}
placeholder="system, 223.6.6.6"
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Fake IP Filter")}
secondary={t("Domains that skip fake IP resolution")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={4}
size="small"
value={values.fakeIpFilter}
onChange={handleChange("fakeIpFilter")}
placeholder="*.lan, *.local, localhost.ptlogin2.qq.com"
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Nameserver Policy")}
secondary={t("Domain-specific DNS server")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={4}
size="small"
value={values.nameserverPolicy}
onChange={handleChange("nameserverPolicy")}
placeholder="+.arpa=10.0.0.1, rule-set:cn=https://doh.pub/dns-query;https://dns.alidns.com/dns-query"
/>
</Item>
<Typography
variant="subtitle2"
sx={{ mt: 2, mb: 1, fontWeight: "bold" }}
>
{t("Fallback Filter Settings")}
</Typography>
<Item>
<ListItemText
primary={t("GeoIP Filtering")}
secondary={t("Enable GeoIP filtering for fallback")}
/>
<Switch
edge="end"
checked={values.fallbackGeoip}
onChange={handleChange("fallbackGeoip")}
/>
</Item>
<Item>
<ListItemText primary={t("GeoIP Code")} />
<TextField
size="small"
autoComplete="off"
value={values.fallbackGeoipCode}
onChange={handleChange("fallbackGeoipCode")}
placeholder="CN"
sx={{ width: 100 }}
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Fallback IP CIDR")}
secondary={t("IP CIDRs not using fallback servers")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={3}
size="small"
value={values.fallbackIpcidr}
onChange={handleChange("fallbackIpcidr")}
placeholder="240.0.0.0/4, 127.0.0.1/8"
/>
</Item>
<Item sx={{ flexDirection: "column", alignItems: "flex-start" }}>
<ListItemText
primary={t("Fallback Domain")}
secondary={t("Domains using fallback servers")}
/>
<TextField
fullWidth
multiline
minRows={2}
maxRows={3}
size="small"
value={values.fallbackDomain}
onChange={handleChange("fallbackDomain")}
placeholder="+.google.com, +.facebook.com, +.youtube.com"
/>
</Item>
</List>
) : (
<MonacoEditor
height="100vh"
language="yaml"
value={yamlContent}
theme={themeMode === "light" ? "vs" : "vs-dark"}
className="flex-grow"
options={{
tabSize: 2,
minimap: {
enabled: document.documentElement.clientWidth >= 1500,
},
mouseWheelZoom: true,
quickSuggestions: {
strings: true,
comments: true,
other: true,
},
padding: {
top: 33,
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: true,
smoothScrolling: true,
}}
onChange={handleYamlChange}
/>
)}
</BaseDialog>
);
});
+27 -6
View File
@@ -22,6 +22,18 @@ import getSystem from "@/utils/get-system";
const OS = getSystem();
const getIcons = async (icon_dir: string, name: string) => {
const updateTime = localStorage.getItem(`icon_${name}_update_time`) || "";
const icon_png = await join(icon_dir, `${name}-${updateTime}.png`);
const icon_ico = await join(icon_dir, `${name}-${updateTime}.ico`);
return {
icon_png,
icon_ico,
};
};
export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
@@ -37,13 +49,20 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
async function initIconPath() {
const appDir = await getAppDir();
const icon_dir = await join(appDir, "icons");
const common_icon_png = await join(icon_dir, "common.png");
const common_icon_ico = await join(icon_dir, "common.ico");
const sysproxy_icon_png = await join(icon_dir, "sysproxy.png");
const sysproxy_icon_ico = await join(icon_dir, "sysproxy.ico");
const tun_icon_png = await join(icon_dir, "tun.png");
const tun_icon_ico = await join(icon_dir, "tun.ico");
const { icon_png: common_icon_png, icon_ico: common_icon_ico } =
await getIcons(icon_dir, "common");
const { icon_png: sysproxy_icon_png, icon_ico: sysproxy_icon_ico } =
await getIcons(icon_dir, "sysproxy");
const { icon_png: tun_icon_png, icon_ico: tun_icon_ico } = await getIcons(
icon_dir,
"tun",
);
if (await exists(common_icon_ico)) {
setCommonIcon(common_icon_ico);
} else {
@@ -212,11 +231,13 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
},
],
});
if (selected) {
await copyIconFile(`${selected}`, "common");
await initIconPath();
onChangeData({ common_tray_icon: true });
patchVerge({ common_tray_icon: true });
console.log();
}
}
}}
@@ -47,7 +47,7 @@ export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
</Box>
</Box>
}
contentSx={{ width: 450, maxHeight: 330 }}
contentSx={{ width: 450 }}
disableOk
cancelBtn={t("Close")}
onCancel={() => setOpen(false)}
@@ -66,7 +66,7 @@ export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
label={t("Ip Address")}
content={address.V4.ip}
/>
)
),
)}
<AddressDisplay
label={t("Mac Address")}
@@ -84,7 +84,7 @@ export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
label={t("Ip Address")}
content={address.V6.ip}
/>
)
),
)}
<AddressDisplay
label={t("Mac Address")}
+55 -1
View File
@@ -1,10 +1,11 @@
import { useRef } from "react";
import { useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TextField, Select, MenuItem, Typography } from "@mui/material";
import {
SettingsRounded,
ShuffleRounded,
LanRounded,
DnsRounded,
} from "@mui/icons-material";
import { DialogRef, Notice, Switch } from "@/components/base";
import { useClash } from "@/hooks/use-clash";
@@ -20,6 +21,10 @@ import { useVerge } from "@/hooks/use-verge";
import { updateGeoData } from "@/services/api";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { NetworkInterfaceViewer } from "./mods/network-interface-viewer";
import { DnsViewer } from "./mods/dns-viewer";
import { invoke } from "@tauri-apps/api/core";
import { useLockFn } from "ahooks";
import { useListen } from "@/hooks/use-listen";
const isWIN = getSystem() === "windows";
@@ -38,15 +43,27 @@ const SettingClash = ({ onError }: Props) => {
"allow-lan": allowLan,
"log-level": logLevel,
"unified-delay": unifiedDelay,
dns,
} = clash ?? {};
const { enable_random_port = false, verge_mixed_port } = verge ?? {};
// 独立跟踪DNS设置开关状态
const [dnsSettingsEnabled, setDnsSettingsEnabled] = useState(false);
const { addListener } = useListen();
const webRef = useRef<DialogRef>(null);
const portRef = useRef<DialogRef>(null);
const ctrlRef = useRef<DialogRef>(null);
const coreRef = useRef<DialogRef>(null);
const networkRef = useRef<DialogRef>(null);
const dnsRef = useRef<DialogRef>(null);
// 初始化时从verge配置中加载DNS设置开关状态
useEffect(() => {
const dnsSettingsState = verge?.enable_dns_settings ?? false;
setDnsSettingsEnabled(dnsSettingsState);
}, [verge]);
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IConfigData>) => {
@@ -64,6 +81,25 @@ const SettingClash = ({ onError }: Props) => {
}
};
// 实现DNS设置开关处理函数
const handleDnsToggle = useLockFn(async (enable: boolean) => {
try {
setDnsSettingsEnabled(enable);
await patchVerge({ enable_dns_settings: enable });
await invoke("apply_dns_config", { apply: enable });
setTimeout(() => {
mutateClash();
}, 500); // 延迟500ms确保后端完成处理
} catch (err: any) {
Notice.error(err.message || err.toString());
setDnsSettingsEnabled(!enable);
await patchVerge({ enable_dns_settings: !enable }).catch(() => {
// 忽略恢复状态时的错误
});
throw err;
}
});
return (
<SettingList title={t("Clash Setting")}>
<WebUIViewer ref={webRef} />
@@ -71,6 +107,7 @@ const SettingClash = ({ onError }: Props) => {
<ControllerViewer ref={ctrlRef} />
<ClashCoreViewer ref={coreRef} />
<NetworkInterfaceViewer ref={networkRef} />
<DnsViewer ref={dnsRef} />
<SettingItem
label={t("Allow Lan")}
@@ -97,6 +134,23 @@ const SettingClash = ({ onError }: Props) => {
</GuardState>
</SettingItem>
<SettingItem
label={t("DNS Overwrite")}
extra={
<TooltipIcon
icon={SettingsRounded}
onClick={() => dnsRef.current?.open()}
/>
}
>
{/* 使用独立状态,不再依赖dns?.enable */}
<Switch
edge="end"
checked={dnsSettingsEnabled}
onChange={(_, checked) => handleDnsToggle(checked)}
/>
</SettingItem>
<SettingItem label={t("IPv6")}>
<GuardState
value={ipv6 ?? false}
+70 -12
View File
@@ -5,6 +5,8 @@ import {
SettingsRounded,
PlayArrowRounded,
PauseRounded,
WarningRounded,
BuildRounded,
} from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge";
import { DialogRef, Notice, Switch } from "@/components/base";
@@ -13,7 +15,14 @@ import { GuardState } from "./mods/guard-state";
import { SysproxyViewer } from "./mods/sysproxy-viewer";
import { TunViewer } from "./mods/tun-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { getSystemProxy, getAutotemProxy } from "@/services/cmds";
import {
getSystemProxy,
getAutotemProxy,
getRunningMode,
installService,
} from "@/services/cmds";
import { useLockFn } from "ahooks";
import { Box, Button, Tooltip } from "@mui/material";
interface Props {
onError?: (err: Error) => void;
@@ -26,6 +35,13 @@ const SettingSystem = ({ onError }: Props) => {
const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy);
const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy);
const { data: runningMode, mutate: mutateRunningMode } = useSWR(
"getRunningMode",
getRunningMode,
);
// 是否以sidecar模式运行
const isSidecarMode = runningMode === "sidecar";
const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null);
@@ -38,10 +54,6 @@ const SettingSystem = ({ onError }: Props) => {
proxy_auto_config,
} = verge ?? {};
const isProxyEnabled = proxy_auto_config
? autoproxy?.enable
: sysproxy?.enable;
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
@@ -54,6 +66,19 @@ const SettingSystem = ({ onError }: Props) => {
await mutate("getAutotemProxy");
};
// 安装系统服务
const onInstallService = useLockFn(async () => {
try {
Notice.info(t("Installing Service..."), 1000);
await installService();
Notice.success(t("Service Installed Successfully"), 2000);
// 重新获取运行模式
await mutateRunningMode();
} catch (err: any) {
Notice.error(err.message || err.toString(), 3000);
}
});
return (
<SettingList title={t("System Setting")}>
<SysproxyViewer ref={sysproxyRef} />
@@ -62,11 +87,31 @@ const SettingSystem = ({ onError }: Props) => {
<SettingItem
label={t("Tun Mode")}
extra={
<TooltipIcon
title={t("Tun Mode Info")}
icon={SettingsRounded}
onClick={() => tunRef.current?.open()}
/>
<>
<TooltipIcon
title={t("Tun Mode Info")}
icon={SettingsRounded}
onClick={() => tunRef.current?.open()}
/>
{isSidecarMode && (
<Tooltip title={t("TUN requires Service Mode")}>
<WarningRounded sx={{ color: "warning.main", mr: 1 }} />
</Tooltip>
)}
{isSidecarMode && (
<Tooltip title={t("Install Service")}>
<Button
variant="outlined"
color="primary"
size="small"
onClick={onInstallService}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<BuildRounded fontSize="small" />
</Button>
</Tooltip>
)}
</>
}
>
<GuardState
@@ -75,13 +120,20 @@ const SettingSystem = ({ onError }: Props) => {
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
// 当在sidecar模式下禁用切换
if (isSidecarMode) return;
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
// 当在sidecar模式下禁用切换
if (isSidecarMode) {
Notice.error(t("TUN requires Service Mode"), 2000);
return Promise.reject(new Error(t("TUN requires Service Mode")));
}
return patchVerge({ enable_tun_mode: e });
}}
>
<Switch edge="end" />
<Switch edge="end" disabled={isSidecarMode} />
</GuardState>
</SettingItem>
<SettingItem
@@ -93,7 +145,13 @@ const SettingSystem = ({ onError }: Props) => {
icon={SettingsRounded}
onClick={() => sysproxyRef.current?.open()}
/>
{isProxyEnabled ? (
{proxy_auto_config ? (
autoproxy?.enable ? (
<PlayArrowRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseRounded sx={{ color: "error.main", mr: 1 }} />
)
) : sysproxy?.enable ? (
<PlayArrowRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseRounded sx={{ color: "error.main", mr: 1 }} />
@@ -0,0 +1,138 @@
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Typography } from "@mui/material";
import {
exitApp,
openAppDir,
openCoreDir,
openLogsDir,
openDevTools,
exportDiagnosticInfo,
} 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";
import { ContentCopyRounded } from "@mui/icons-material";
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());
}
};
const onExportDiagnosticInfo = useCallback(async () => {
await exportDiagnosticInfo();
Notice.success(t("Copy Success"), 1000);
}, []);
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("Export Diagnostic Info")}
extra={
<TooltipIcon
icon={ContentCopyRounded}
onClick={onExportDiagnosticInfo}
/>
}
></SettingItem>
<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 { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/plugin-dialog";
import { Button, MenuItem, Select, Input, Typography } from "@mui/material";
import {
exitApp,
openAppDir,
openCoreDir,
openLogsDir,
openDevTools,
copyClashEnv,
} from "@/services/cmds";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { Button, MenuItem, Select, Input } from "@mui/material";
import { copyClashEnv } from "@/services/cmds";
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 { ThemeModeSwitch } from "./mods/theme-mode-switch";
@@ -49,7 +40,7 @@ const languageOptions = Object.entries(languages).map(([code, _]) => {
return { code, label: labels[code] };
});
const SettingVerge = ({ onError }: Props) => {
const SettingVergeBasic = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
@@ -60,7 +51,6 @@ const SettingVerge = ({ onError }: Props) => {
env_type,
startup_script,
start_page,
enable_lite_mode,
} = verge ?? {};
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
@@ -74,26 +64,13 @@ const SettingVerge = ({ onError }: Props) => {
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 () => {
await copyClashEnv();
Notice.success(t("Copy Success"), 1000);
}, []);
return (
<SettingList title={t("Verge Setting")}>
<SettingList title={t("Verge Basic Setting")}>
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
@@ -165,8 +142,9 @@ const SettingVerge = ({ onError }: Props) => {
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="bash">Bash</MenuItem>
<MenuItem value="cmd">CMD</MenuItem>
<MenuItem value="fish">Fish</MenuItem>
<MenuItem value="nushell">Nushell</MenuItem>
<MenuItem value="cmd">CMD</MenuItem>
<MenuItem value="powershell">PowerShell</MenuItem>
</Select>
</GuardState>
@@ -261,62 +239,8 @@ const SettingVerge = ({ onError }: Props) => {
onClick={() => hotkeyRef.current?.open()}
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
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 SettingVerge;
export default SettingVergeBasic;
+21 -63
View File
@@ -1,36 +1,33 @@
import { useEffect } from "react";
import { useEnableLog } from "../services/states";
import { createSockette } from "../utils/websocket";
import { createSockette, createAuthSockette } from "../utils/websocket";
import { useClashInfo } from "./use-clash";
import dayjs from "dayjs";
import { create } from "zustand";
import { useVisibility } from "./use-visibility";
import {
useGlobalLogData,
clearGlobalLogs,
LogLevel,
ILogItem,
} from "@/services/global-log-service";
// 为了向后兼容,导出相同的类型
export type { LogLevel };
export type { ILogItem };
const MAX_LOG_NUM = 1000;
export type LogLevel = "warning" | "info" | "debug" | "error" | "all";
const buildWSUrl = (server: string, logLevel: LogLevel) => {
let baseUrl = `${server}/logs`;
interface ILogItem {
time?: string;
type: string;
payload: string;
[key: string]: any;
}
const buildWSUrl = (server: string, secret: string, logLevel: LogLevel) => {
const baseUrl = `ws://${server}/logs`;
const params = new URLSearchParams();
if (secret) {
params.append("token", secret);
// 只处理日志级别参数
if (logLevel && logLevel !== "info") {
const level = logLevel === "all" ? "debug" : logLevel;
baseUrl += `?level=${level}`;
}
if (logLevel === "all") {
params.append("level", "debug");
} else {
params.append("level", logLevel);
}
const queryString = params.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
return baseUrl;
};
interface LogStore {
@@ -57,45 +54,6 @@ const useLogStore = create<LogStore>(
}),
);
export const useLogData = (logLevel: LogLevel) => {
const { clashInfo } = useClashInfo();
const [enableLog] = useEnableLog();
const { logs, appendLog } = useLogStore();
const pageVisible = useVisibility();
export const useLogData = useGlobalLogData;
useEffect(() => {
if (!enableLog || !clashInfo || !pageVisible) return;
const { server = "", secret = "" } = clashInfo;
const wsUrl = buildWSUrl(server, secret, logLevel);
let isActive = true;
const socket = createSockette(wsUrl, {
onmessage(event) {
if (!isActive) return;
const data = JSON.parse(event.data) as ILogItem;
const time = dayjs().format("MM-DD HH:mm:ss");
appendLog({ ...data, time });
},
onerror() {
if (!isActive) return;
socket.close();
},
});
return () => {
isActive = false;
socket.close();
};
}, [clashInfo, enableLog, logLevel]);
// 根据当前选择的日志等级过滤日志
return logLevel === "all"
? logs
: logs.filter((log) => log.type.toLowerCase() === logLevel);
};
// 导出清空日志的方法
export const clearLogs = () => {
useLogStore.getState().clearLogs();
};
export const clearLogs = clearGlobalLogs;
+7 -1
View File
@@ -447,5 +447,11 @@
"File Not Found": "الملف غير موجود، تم التراجع عن التغييرات",
"Script File Error": "خطأ في ملف السكريبت، تم التراجع عن التغييرات",
"Core Changed Successfully": "تم تغيير النواة بنجاح",
"Failed to Change Core": "فشل تغيير النواة"
"Failed to Change Core": "فشل تغيير النواة",
"Verge Basic Setting": "الإعدادات الأساسية Verge",
"Verge Advanced Setting": "الإعدادات الأساسية Verge",
"TUN requires Service Mode": "يتطلب وضع TUN خدمة",
"Install Service": "تثبيت الخدمة ",
"Installing Service...": "جاري تثبيت الخدمة...",
"Service Administrator Prompt": "يتطلب Clash Verge امتيازات المسؤول لإعادة تثبيت خدمة النظام"
}
+64 -2
View File
@@ -195,6 +195,8 @@
"Settings": "Settings",
"System Setting": "System Setting",
"Tun Mode": "Tun (Virtual NIC) Mode",
"TUN requires Service Mode": "TUN mode requires service",
"Install Service": "Install Service",
"Reset to Default": "Reset to Default",
"Tun Mode Info": "Tun (Virtual NIC) mode: Captures all system traffic, when enabled, there is no need to enable system proxy.",
"Stack": "Tun Stack",
@@ -277,7 +279,8 @@
"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",
"Update GeoData": "Update GeoData",
"Verge Setting": "Verge Setting",
"Verge Basic Setting": "Verge Basic Setting",
"Verge Advanced Setting": "Verge Advanced Setting",
"Language": "Language",
"Theme Mode": "Theme Mode",
"theme.light": "Light",
@@ -348,6 +351,8 @@
"Portable Updater Error": "The portable version does not support in-app updates. Please manually download and replace it",
"Break Change Update Error": "This version is a major update and does not support in-app updates. Please uninstall it and manually download and install the new version",
"Open Dev Tools": "Dev Tools",
"Export Diagnostic Info": "Export Diagnostic Info",
"Export Diagnostic Info For Issue Reporting": "Export Diagnostic Info For Issue Reporting",
"Exit": "Exit",
"Verge Version": "Verge Version",
"ReadOnly": "ReadOnly",
@@ -362,6 +367,7 @@
"Profile Reactivated": "Profile Reactivated",
"Only YAML Files Supported": "Only YAML Files Supported",
"Settings Applied": "Settings Applied",
"Installing Service...": "Installing Service...",
"Service Installed Successfully": "Service Installed Successfully",
"Service Uninstalled Successfully": "Service Uninstalled Successfully",
"Proxy Daemon Duration Cannot be Less than 1 Second": "Proxy Daemon Duration Cannot be Less than 1 Second",
@@ -451,5 +457,61 @@
"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"
"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",
"Service Administrator Prompt": "Clash Verge requires administrator privileges to reinstall the system service",
"DNS Settings": "DNS Settings",
"DNS Overwrite": "DNS Overwrite",
"DNS Settings Warning": "If you are not familiar with these settings, please do not modify them and keep DNS Settings enabled",
"Enable DNS": "Enable DNS",
"DNS Listen": "DNS Listen",
"Enhanced Mode": "Enhanced Mode",
"Fake IP Range": "Fake IP Range",
"Fake IP Filter Mode": "Fake IP Filter Mode",
"Prefer H3": "Prefer H3",
"DNS DOH使用HTTP/3": "DNS DOH uses HTTP/3",
"Respect Rules": "Respect Rules",
"DNS连接遵守路由规则": "DNS connections follow routing rules",
"Use Hosts": "Use Hosts",
"Enable to resolve hosts through hosts file": "Enable to resolve hosts through hosts file",
"Use System Hosts": "Use System Hosts",
"Enable to resolve hosts through system hosts file": "Enable to resolve hosts through system hosts file",
"Direct Nameserver Follow Policy": "Direct Nameserver Follow Policy",
"是否遵循nameserver-policy": "Whether to follow nameserver policy",
"Default Nameserver": "Default Nameserver",
"Default DNS servers used to resolve DNS servers": "Default DNS servers used to resolve DNS servers",
"Nameserver": "Nameserver",
"List of DNS servers": "List of DNS servers, comma separated",
"Fallback": "Fallback",
"List of fallback DNS servers": "List of fallback DNS servers, comma separated",
"Proxy Server Nameserver": "Proxy Server Nameserver",
"Proxy Node Nameserver": "DNS servers for proxy node domain resolution",
"Direct Nameserver": "Direct Nameserver",
"Direct outbound Nameserver": "DNS servers for direct exit domain resolution, supports 'system' keyword, comma separated",
"Fake IP Filter": "Fake IP Filter",
"Domains that skip fake IP resolution": "Domains that skip fake IP resolution, comma separated",
"Nameserver Policy": "Nameserver Policy",
"Domain-specific DNS server": "Domain-specific DNS server, multiple servers separated by semicolons, format: domain=server1;server2",
"Fallback Filter Settings": "Fallback Filter Settings",
"GeoIP Filtering": "GeoIP Filtering",
"Enable GeoIP filtering for fallback": "Enable GeoIP filtering for fallback",
"GeoIP Code": "GeoIP Code",
"Fallback IP CIDR": "Fallback IP CIDR",
"IP CIDRs not using fallback servers": "IP CIDRs not using fallback servers, comma separated",
"Fallback Domain": "Fallback Domain",
"Domains using fallback servers": "Domains using fallback servers, comma separated",
"Enable Alpha Channel": "Enable Alpha Channel",
"Alpha versions may contain experimental features and bugs": "Alpha versions may contain experimental features and bugs"
}
+7 -1
View File
@@ -444,5 +444,11 @@
"File Not Found": "فایل یافت نشد، تغییرات برگشت داده شد",
"Script File Error": "خطای فایل اسکریپت، تغییرات برگشت داده شد",
"Core Changed Successfully": "هسته با موفقیت تغییر کرد",
"Failed to Change Core": "تغییر هسته ناموفق بود"
"Failed to Change Core": "تغییر هسته ناموفق بود",
"Verge Basic Setting": "تنظیمات پایه Verge",
"Verge Advanced Setting": "تنظیمات پیشرفته Verge",
"TUN requires Service Mode": "حالت تونل‌زنی نیاز به سرویس دارد",
"Install Service": "نصب سرویس",
"Installing Service...": "در حال نصب سرویس...",
"Service Administrator Prompt": "Clash Verge برای نصب مجدد سرویس سیستم به امتیازات مدیر نیاز دارد"
}
+7 -1
View File
@@ -443,5 +443,11 @@
"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"
"Failed to Change Core": "Gagal mengubah inti",
"Verge Basic Setting": "Pengaturan Dasar Verge",
"Verge Advanced Setting": "Pengaturan Lanjutan Verge",
"TUN requires Service Mode": "Mode TUN memerlukan layanan",
"Install Service": "Instal Layanan",
"Installing Service...": "Memasang Layanan...",
"Service Administrator Prompt": "Clash Verge memerlukan hak administrator untuk menginstal ulang layanan sistem"
}
+7 -1
View File
@@ -444,5 +444,11 @@
"File Not Found": "Файл не найден, изменения отменены",
"Script File Error": "Ошибка файла скрипта, изменения отменены",
"Core Changed Successfully": "Ядро успешно сменено",
"Failed to Change Core": "Не удалось сменить ядро"
"Failed to Change Core": "Не удалось сменить ядро",
"Verge Basic Setting": "Основные настройки Verge",
"Verge Advanced Setting": "Расширенные настройки Verge",
"TUN requires Service Mode": "Режим TUN требует обслуживания",
"Install Service": "Установить службу",
"Installing Service...": "Установка службы...",
"Service Administrator Prompt": "Clash Verge требует прав администратора для переустановки системной службы"
}
+7 -1
View File
@@ -443,5 +443,11 @@
"File Not Found": "Файл табылмады, үзгәрешләр кире кайтарылды",
"Script File Error": "Скрипт файлы хатасы, үзгәрешләр кире кайтарылды",
"Core Changed Successfully": "Ядро уңышлы алыштырылды",
"Failed to Change Core": "Ядро алыштыру уңышсыз булды"
"Failed to Change Core": "Ядро алыштыру уңышсыз булды",
"Verge Basic Setting": "Verge Төп көйләүләр",
"Verge Advanced Setting": "Verge Киңәйтелгән көйләүләр",
"TUN requires Service Mode": "TUN режимы хезмәт күрсәтүне таләп итә",
"Install Service": "Хезмәтне урнаштыру",
"Installing Service...": "Хезмәт урнаштырыла...",
"Service Administrator Prompt": "Clash Verge система хезмәтен яңадан урнаштыру өчен администратор хокукларын таләп итә"
}
+65 -4
View File
@@ -135,9 +135,9 @@
"Hidden": "隐藏代理组",
"Group Name Required": "代理组名称不能为空",
"Group Name Already Exists": "代理组名称已存在",
"Extend Config": "扩展配置",
"Extend Config": "扩展覆写配置",
"Extend Script": "扩展脚本",
"Global Merge": "全局扩展配置",
"Global Merge": "全局扩展覆写配置",
"Global Script": "全局扩展脚本",
"Type": "类型",
"Name": "名称",
@@ -194,7 +194,9 @@
"Test URL": "测试地址",
"Settings": "设置",
"System Setting": "系统设置",
"Tun Mode": "TUN(虚拟网卡)模式",
"Tun Mode": "TUN(虚拟网卡)模式",
"TUN requires Service Mode": "TUN 模式需要服务",
"Install Service": "安装服务",
"Reset to Default": "重置为默认值",
"Tun Mode Info": "TUN(虚拟网卡)模式接管系统所有流量,启用时无须打开系统代理",
"Stack": "TUN 模式堆栈",
@@ -362,6 +364,7 @@
"Profile Reactivated": "订阅已激活",
"Only YAML Files Supported": "仅支持 YAML 文件",
"Settings Applied": "设置已应用",
"Installing Service...": "安装服务中...",
"Service Installed Successfully": "已成功安装服务",
"Service Uninstalled Successfully": "已成功卸载服务",
"Proxy Daemon Duration Cannot be Less than 1 Second": "代理守护间隔时间不得低于 1 秒",
@@ -444,5 +447,63 @@
"File Not Found": "文件丢失,变更已撤销",
"Script File Error": "脚本文件错误,变更已撤销",
"Core Changed Successfully": "内核切换成功",
"Failed to Change Core": "无法切换内核"
"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 高级设置",
"Service Administrator Prompt": "Clash Verge 需要使用管理员权限来重新安装系统服务",
"DNS Settings": "DNS 设置",
"DNS Overwrite": "DNS 覆写",
"DNS Settings Warning": "如果你不清楚这里的设置请不要修改,并保持 DNS 设置开启",
"Enable DNS": "启用 DNS",
"DNS Listen": "DNS 监听地址",
"Enhanced Mode": "增强模式",
"Fake IP Range": "Fake IP 范围",
"Fake IP Filter Mode": "Fake IP 过滤模式",
"Prefer H3": "优先使用 HTTP/3",
"DNS DOH使用HTTP/3": "DNS DOH 使用 HTTP/3 协议",
"Respect Rules": "遵循路由规则",
"DNS连接遵守路由规则": "DNS 连接遵循路由规则",
"Use Hosts": "使用 Hosts",
"Enable to resolve hosts through hosts file": "启用通过 hosts 文件解析域名",
"Use System Hosts": "使用系统 Hosts",
"Enable to resolve hosts through system hosts file": "启用通过系统 hosts 文件解析域名",
"Direct Nameserver Follow Policy": "直连域名服务器遵循策略",
"是否遵循nameserver-policy": "是否遵循 nameserver-policy 设置",
"Default Nameserver": "默认域名服务器",
"Default DNS servers used to resolve DNS servers": "用于解析 DNS 服务器的默认 DNS 服务器",
"Nameserver": "域名服务器",
"List of DNS servers": "DNS 服务器列表,用逗号分隔",
"Fallback": "回退服务器",
"List of fallback DNS servers": "回退 DNS 服务器列表,用逗号分隔",
"Proxy Server Nameserver": "代理节点DNS",
"Proxy Node Nameserver": "代理节点域名解析服务器,仅用于解析代理节点的域名,用逗号分隔",
"Direct Nameserver": "直连域名服务器",
"Direct outbound Nameserver": "直连出口域名解析服务器,支持 system 关键字,用逗号分隔",
"Fake IP Filter": "Fake IP 过滤",
"Domains that skip fake IP resolution": "跳过 Fake IP 解析的域名,用逗号分隔",
"Nameserver Policy": "域名服务器策略",
"Domain-specific DNS server": "特定域名的 DNS 服务器,多个服务器使用分号分隔,格式: domain=server1;server2",
"Fallback Filter Settings": "回退过滤设置",
"GeoIP Filtering": "GeoIP 过滤",
"Enable GeoIP filtering for fallback": "启用 GeoIP 回退过滤",
"GeoIP Code": "GeoIP 国家代码",
"Fallback IP CIDR": "回退 IP CIDR",
"IP CIDRs not using fallback servers": "不使用回退服务器的 IP CIDR,用逗号分隔",
"Fallback Domain": "回退域名",
"Domains using fallback servers": "使用回退服务器的域名,用逗号分隔",
"Enable Alpha Channel": "启用 Alpha 通道",
"Alpha versions may contain experimental features and bugs": "Alpha 版本可能包含实验性功能和已知问题"
}
+55 -10
View File
@@ -13,7 +13,7 @@ import { useVerge } from "@/hooks/use-verge";
import LogoSvg from "@/assets/image/logo.svg?react";
import iconLight from "@/assets/image/icon_light.svg?react";
import iconDark from "@/assets/image/icon_dark.svg?react";
import { useThemeMode } from "@/services/states";
import { useThemeMode, useEnableLog } from "@/services/states";
import { Notice } from "@/components/base";
import { LayoutItem } from "@/components/layout/layout-item";
import { LayoutControl } from "@/components/layout/layout-control";
@@ -28,6 +28,8 @@ import React from "react";
import { TransitionGroup, CSSTransition } from "react-transition-group";
import { useListen } from "@/hooks/use-listen";
import { listen } from "@tauri-apps/api/event";
import { useClashInfo } from "@/hooks/use-clash";
import { initGlobalLogService } from "@/services/global-log-service";
const appWindow = getCurrentWebviewWindow();
export let portableFlag = false;
@@ -84,6 +86,33 @@ const handleNoticeMessage = (
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;
@@ -99,6 +128,8 @@ const Layout = () => {
const { t } = useTranslation();
const { theme } = useCustomTheme();
const { verge } = useVerge();
const { clashInfo } = useClashInfo();
const [enableLog] = useEnableLog();
const { language, start_page } = verge ?? {};
const navigate = useNavigate();
const location = useLocation();
@@ -113,6 +144,15 @@ const Layout = () => {
[t, navigate],
);
// 初始化全局日志服务
useEffect(() => {
if (clashInfo) {
const { server = "", secret = "" } = clashInfo;
// 使用本地存储中的enableLog值初始化全局日志服务
initGlobalLogService(server, secret, enableLog, "info");
}
}, [clashInfo, enableLog]);
// 设置监听器
useEffect(() => {
const listeners = [
@@ -126,9 +166,12 @@ const Layout = () => {
}),
// verge 配置更新监听
addListener("verge://refresh-verge-config", () =>
mutate("getVergeConfig"),
),
addListener("verge://refresh-verge-config", () => {
mutate("getVergeConfig");
// 添加对系统代理状态的刷新
mutate("getSystemProxy");
mutate("getAutotemProxy");
}),
// 通知消息监听
addListener("verge://notice-message", ({ payload }) =>
@@ -202,12 +245,14 @@ const Layout = () => {
}}
sx={[
({ palette }) => ({ bgcolor: palette.background.paper }),
{
borderRadius: "8px",
border: "0px solid var(--divider-color)",
width: "calc(100vw - 1px)",
height: "calc(100vh - 1px)",
},
OS === "linux"
? {
borderRadius: "8px",
border: "1px solid var(--divider-color)",
width: "calc(100vw - 0px)",
height: "calc(100vh - 0px)",
}
: {},
]}
>
<div className="layout__left">
+56 -36
View File
@@ -26,7 +26,7 @@ import {
} from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select";
import useSWRSubscription from "swr/subscription";
import { createSockette } from "@/utils/websocket";
import { createSockette, createAuthSockette } from "@/utils/websocket";
import { useTheme } from "@mui/material/styles";
import { useVisibility } from "@/hooks/use-visibility";
@@ -74,51 +74,71 @@ const ConnectionsPage = () => {
clashInfo && pageVisible ? "getClashConnections" : null,
(_key, { next }) => {
const { server = "", secret = "" } = clashInfo!;
const s = createSockette(
`ws://${server}/connections?token=${encodeURIComponent(secret)}`,
{
onmessage(event) {
const data = JSON.parse(event.data) as IConnections;
next(null, (old = initConn) => {
const oldConn = old.connections;
const maxLen = data.connections?.length;
const connections: IConnectionsItem[] = [];
if (!server) {
console.warn("[Connections] 服务器地址为空,无法建立连接");
next(null, initConn);
return () => {};
}
const rest = (data.connections || []).filter((each) => {
const index = oldConn.findIndex((o) => o.id === each.id);
console.log(`[Connections] 正在连接: ${server}/connections`);
if (index >= 0 && index < maxLen) {
const old = oldConn[index];
each.curUpload = each.upload - old.upload;
each.curDownload = each.download - old.download;
// 设置较长的超时时间,确保连接可以建立
const s = createAuthSockette(`${server}/connections`, secret, {
timeout: 8000, // 8秒超时
onmessage(event) {
const data = JSON.parse(event.data) as IConnections;
next(null, (old = initConn) => {
const oldConn = old.connections;
const maxLen = data.connections?.length;
connections[index] = each;
return false;
}
return true;
});
const connections: IConnectionsItem[] = [];
for (let i = 0; i < maxLen; ++i) {
if (!connections[i] && rest.length > 0) {
connections[i] = rest.shift()!;
connections[i].curUpload = 0;
connections[i].curDownload = 0;
}
const rest = (data.connections || []).filter((each) => {
const index = oldConn.findIndex((o) => o.id === each.id);
if (index >= 0 && index < maxLen) {
const old = oldConn[index];
each.curUpload = each.upload - old.upload;
each.curDownload = each.download - old.download;
connections[index] = each;
return false;
}
return { ...data, connections };
return true;
});
},
onerror(event) {
next(event);
},
for (let i = 0; i < maxLen; ++i) {
if (!connections[i] && rest.length > 0) {
connections[i] = rest.shift()!;
connections[i].curUpload = 0;
connections[i].curDownload = 0;
}
}
return { ...data, connections };
});
},
3,
);
onerror(event) {
console.error("[Connections] WebSocket 连接错误", event);
// 报告错误但提供空数据,避免UI崩溃
next(null, initConn);
},
onclose(event) {
console.log("[Connections] WebSocket 连接关闭", event);
},
onopen(event) {
console.log("[Connections] WebSocket 连接已建立");
},
});
return () => {
s.close();
console.log("[Connections] 清理WebSocket连接");
try {
s.close();
} catch (e) {
console.error("[Connections] 关闭连接时出错", e);
}
};
},
);
+29 -5
View File
@@ -8,7 +8,8 @@ import {
PlayCircleOutlineRounded,
PauseCircleOutlineRounded,
} from "@mui/icons-material";
import { useLogData, LogLevel, clearLogs } from "@/hooks/use-log-data";
import { LogLevel, clearLogs } from "@/hooks/use-log-data";
import { useClashInfo } from "@/hooks/use-clash";
import { useEnableLog } from "@/services/states";
import { BaseEmpty, BasePage } from "@/components/base";
import LogItem from "@/components/log/log-item";
@@ -16,10 +17,17 @@ import { useTheme } from "@mui/material/styles";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select";
import { SearchState } from "@/components/base/base-search-box";
import {
useGlobalLogData,
clearGlobalLogs,
changeLogLevel,
toggleLogEnabled,
} from "@/services/global-log-service";
const LogPage = () => {
const { t } = useTranslation();
const [enableLog, setEnableLog] = useEnableLog();
const { clashInfo } = useClashInfo();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [logLevel, setLogLevel] = useLocalStorage<LogLevel>(
@@ -27,7 +35,7 @@ const LogPage = () => {
"info",
);
const [match, setMatch] = useState(() => (_: string) => true);
const logData = useLogData(logLevel);
const logData = useGlobalLogData(logLevel);
const [searchState, setSearchState] = useState<SearchState>();
const filterLogs = useMemo(() => {
@@ -44,6 +52,22 @@ const LogPage = () => {
: [];
}, [logData, logLevel, match]);
const handleLogLevelChange = (newLevel: LogLevel) => {
setLogLevel(newLevel);
if (clashInfo) {
const { server = "", secret = "" } = clashInfo;
changeLogLevel(newLevel, server, secret);
}
};
const handleToggleLog = () => {
if (clashInfo) {
const { server = "", secret = "" } = clashInfo;
toggleLogEnabled(server, secret);
setEnableLog(!enableLog);
}
};
return (
<BasePage
full
@@ -60,7 +84,7 @@ const LogPage = () => {
title={t("Pause")}
size="small"
color="inherit"
onClick={() => setEnableLog((e) => !e)}
onClick={handleToggleLog}
>
{enableLog ? (
<PauseCircleOutlineRounded />
@@ -74,7 +98,7 @@ const LogPage = () => {
size="small"
variant="contained"
onClick={() => {
clearLogs();
clearGlobalLogs();
}}
>
{t("Clear")}
@@ -95,7 +119,7 @@ const LogPage = () => {
>
<BaseStyledSelect
value={logLevel}
onChange={(e) => setLogLevel(e.target.value as LogLevel)}
onChange={(e) => handleLogLevelChange(e.target.value as LogLevel)}
>
<MenuItem value="all">ALL</MenuItem>
<MenuItem value="info">INFO</MenuItem>
+12 -2
View File
@@ -11,7 +11,8 @@ import { useTranslation } from "react-i18next";
import { BasePage, Notice } from "@/components/base";
import { GitHub, HelpOutlineRounded, Telegram } from "@mui/icons-material";
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 SettingSystem from "@/components/setting/setting-system";
import { useThemeMode } from "@/services/states";
@@ -95,10 +96,19 @@ const SettingPage = () => {
<Box
sx={{
borderRadius: 2,
marginBottom: 1.5,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
>
<SettingVerge onError={onError} />
<SettingVergeBasic onError={onError} />
</Box>
<Box
sx={{
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
>
<SettingVergeAdvanced onError={onError} />
</Box>
</Grid>
</Grid>
+41 -16
View File
@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from "axios";
import { getClashInfo } from "./cmds";
import { invoke } from "@tauri-apps/api/core";
import { useLockFn } from "ahooks";
let axiosIns: AxiosInstance = null!;
@@ -94,13 +96,20 @@ export const updateProxy = async (group: string, proxy: string) => {
// get proxy
export const getProxiesInner = async () => {
const instance = await getAxios();
const response = await instance.get<any, any>("/proxies");
return (response?.proxies || {}) as Record<string, IProxyItem>;
const response = await invoke<{ proxies: Record<string, IProxyItem> }>(
"get_proxies",
);
return response.proxies as Record<string, IProxyItem>;
};
/// Get the Proxy information
export const getProxies = async () => {
export const getProxies = async (): Promise<{
global: IProxyGroupItem;
direct: IProxyItem;
groups: IProxyGroupItem[];
records: Record<string, IProxyItem>;
proxies: IProxyItem[];
}> => {
const [proxyRecord, providerRecord] = await Promise.all([
getProxiesInner(),
getProxyProviders(),
@@ -181,13 +190,10 @@ export const getProxies = async () => {
// get proxy providers
export const getProxyProviders = async () => {
const instance = await getAxios();
const response = await instance.get<any, any>("/providers/proxies");
const providers = (response.providers || {}) as Record<
string,
IProxyProviderItem
>;
const response = await invoke<{
providers: Record<string, IProxyProviderItem>;
}>("get_providers_proxies");
const providers = response.providers as Record<string, IProxyProviderItem>;
return Object.fromEntries(
Object.entries(providers).filter(([key, item]) => {
@@ -260,12 +266,31 @@ export const getGroupProxyDelays = async (
timeout: timeout || 10000,
url: url || "http://cp.cloudflare.com/generate_204",
};
const instance = await getAxios();
const result = await instance.get(
`/group/${encodeURIComponent(groupName)}/delay`,
{ params },
console.log(
`[API] 获取代理组延迟,组: ${groupName}, URL: ${params.url}, 超时: ${params.timeout}ms`,
);
return result as any as Record<string, number>;
try {
const instance = await getAxios();
console.log(
`[API] 发送HTTP请求: GET /group/${encodeURIComponent(groupName)}/delay`,
);
const result = await instance.get(
`/group/${encodeURIComponent(groupName)}/delay`,
{ params },
);
console.log(
`[API] 获取代理组延迟成功,组: ${groupName}, 结果数量:`,
Object.keys(result || {}).length,
);
return result as any as Record<string, number>;
} catch (error) {
console.error(`[API] 获取代理组延迟失败,组: ${groupName}`, error);
throw error;
}
};
// Is debug enabled
+63 -7
View File
@@ -161,12 +161,42 @@ export async function cmdGetProxyDelay(
timeout: number,
url?: string,
) {
name = encodeURIComponent(name);
return invoke<{ delay: number }>("clash_api_get_proxy_delay", {
name,
url,
timeout,
});
// 确保URL不为空
const testUrl = url || "http://cp.cloudflare.com/generate_204";
console.log(
`[API] 调用延迟测试API,代理: ${name}, 超时: ${timeout}ms, URL: ${testUrl}`,
);
try {
name = encodeURIComponent(name);
const result = await invoke<{ delay: number }>(
"clash_api_get_proxy_delay",
{
name,
url: testUrl, // 传递经过验证的URL
timeout,
},
);
// 验证返回结果中是否有delay字段,并且值是一个有效的数字
if (result && typeof result.delay === "number") {
console.log(
`[API] 延迟测试API调用成功,代理: ${name}, 延迟: ${result.delay}ms`,
);
return result;
} else {
console.error(
`[API] 延迟测试API返回无效结果,代理: ${name}, 结果:`,
result,
);
// 返回一个有效的结果对象,但标记为超时
return { delay: 1e6 };
}
} catch (error) {
console.error(`[API] 延迟测试API调用失败,代理: ${name}`, error);
// 返回一个有效的结果对象,但标记为错误
return { delay: 1e6 };
}
}
export async function cmdTestDelay(url: string) {
@@ -191,11 +221,27 @@ export async function exitApp() {
return invoke("exit_app");
}
export async function exportDiagnosticInfo() {
return invoke("export_diagnostic_info");
}
export async function copyIconFile(
path: string,
name: "common" | "sysproxy" | "tun",
) {
return invoke<void>("copy_icon_file", { path, name });
const key = `icon_${name}_update_time`;
const previousTime = localStorage.getItem(key) || "";
const currentTime = String(Date.now());
localStorage.setItem(key, currentTime);
const iconInfo = {
name,
previous_t: previousTime,
current_t: currentTime,
};
return invoke<void>("copy_icon_file", { path, iconInfo });
}
export async function downloadIconCache(url: string, name: string) {
@@ -249,3 +295,13 @@ export async function scriptValidateNotice(status: string, msg: string) {
export async function validateScriptFile(filePath: string) {
return invoke<boolean>("validate_script_file", { filePath });
}
// 获取当前运行模式
export const getRunningMode = async () => {
return invoke<string>("get_running_mode");
};
// 安装/重装系统服务
export const installService = async () => {
return invoke<void>("install_service");
};
+129 -34
View File
@@ -13,11 +13,17 @@ class DelayManager {
private groupListenerMap = new Map<string, () => void>();
setUrl(group: string, url: string) {
console.log(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`);
this.urlMap.set(group, url);
}
getUrl(group: string) {
return this.urlMap.get(group);
const url = this.urlMap.get(group);
console.log(
`[DelayManager] 获取测试URL,组: ${group}, URL: ${url || "未设置"}`,
);
// 如果未设置URL,返回默认URL
return url || "http://cp.cloudflare.com/generate_204";
}
setListener(name: string, group: string, listener: (time: number) => void) {
@@ -40,19 +46,25 @@ class DelayManager {
setDelay(name: string, group: string, delay: number) {
const key = hashKey(name, group);
this.cache.set(key, [Date.now(), delay]);
this.listenerMap.get(key)?.(delay);
this.groupListenerMap.get(group)?.();
console.log(
`[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`,
);
this.cache.set(key, [delay, Date.now()]);
const listener = this.listenerMap.get(key);
if (listener) listener(delay);
}
getDelay(name: string, group: string) {
if (!name) return -1;
const key = hashKey(name, group);
const val = this.cache.get(key);
if (!val) return -1;
const result = this.cache.get(hashKey(name, group));
if (result && Date.now() - result[0] <= 18e5) {
return result[1];
// 缓存30分钟
if (Date.now() - val[1] > 30 * 60 * 1000) {
return -1;
}
return -1;
return val[0];
}
/// 暂时修复provider的节点延迟排序的问题
@@ -70,13 +82,60 @@ class DelayManager {
}
async checkDelay(name: string, group: string, timeout: number) {
console.log(
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
);
// 先将状态设置为测试中
this.setDelay(name, group, -2);
let delay = -1;
try {
const url = this.getUrl(group);
const result = await cmdGetProxyDelay(name, timeout, url);
delay = result.delay;
} catch {
console.log(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`);
// 记录开始时间,用于计算实际延迟
const startTime = Date.now();
// 设置超时处理
const timeoutPromise = new Promise<{ delay: number }>((_, reject) => {
setTimeout(() => reject(new Error("Timeout")), timeout);
});
// 使用Promise.race来实现超时控制
const result = await Promise.race([
cmdGetProxyDelay(name, timeout, url),
timeoutPromise,
]);
// 确保至少显示500ms的加载动画
const elapsedTime = Date.now() - startTime;
if (elapsedTime < 500) {
await new Promise((resolve) => setTimeout(resolve, 500 - elapsedTime));
}
// 检查延迟结果是否为undefined
if (result && typeof result.delay === "number") {
delay = result.delay;
console.log(
`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`,
);
} else {
console.error(
`[DelayManager] 延迟测试返回无效结果,代理: ${name}, 结果:`,
result,
);
delay = 1e6; // 错误情况
}
} catch (error) {
// 确保至少显示500ms的加载动画
await new Promise((resolve) => setTimeout(resolve, 500));
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error);
if (error instanceof Error && error.message === "Timeout") {
console.log(`[DelayManager] 延迟测试超时,代理: ${name}`);
}
delay = 1e6; // error
}
@@ -88,42 +147,78 @@ class DelayManager {
nameList: string[],
group: string,
timeout: number,
concurrency = 36
concurrency = 36,
) {
console.log(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${nameList.length}, 并发数: ${concurrency}`,
);
const names = nameList.filter(Boolean);
// 设置正在延迟测试中
names.forEach((name) => this.setDelay(name, group, -2));
let total = names.length;
let current = 0;
let index = 0;
const startTime = Date.now();
const listener = this.groupListenerMap.get(group);
return new Promise((resolve) => {
const help = async (): Promise<void> => {
if (current >= concurrency) return;
const task = names.shift();
if (!task) return;
current += 1;
await this.checkDelay(task, group, timeout);
current -= 1;
total -= 1;
if (total <= 0) resolve(null);
else return help();
};
for (let i = 0; i < concurrency; ++i) help();
});
const help = async (): Promise<void> => {
const currName = names[index++];
if (!currName) return;
try {
// 确保API调用前状态为测试中
this.setDelay(currName, group, -2);
// 添加一些随机延迟,避免所有请求同时发出和返回
if (index > 1) {
// 第一个不延迟,保持响应性
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 200),
);
}
await this.checkDelay(currName, group, timeout);
if (listener) listener();
} catch (error) {
console.error(
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
);
// 设置为错误状态
this.setDelay(currName, group, 1e6);
}
return help();
};
// 限制并发数,避免发送太多请求
const actualConcurrency = Math.min(concurrency, names.length, 10);
console.log(`[DelayManager] 实际并发数: ${actualConcurrency}`);
const promiseList: Promise<void>[] = [];
for (let i = 0; i < actualConcurrency; i++) {
promiseList.push(help());
}
await Promise.all(promiseList);
const totalTime = Date.now() - startTime;
console.log(
`[DelayManager] 批量测试延迟完成,组: ${group}, 总耗时: ${totalTime}ms`,
);
}
formatDelay(delay: number, timeout = 10000) {
if (delay <= 0) return "Error";
if (delay > 1e5) return "Error";
if (delay >= timeout) return "Timeout"; // 10s
if (delay === -1) return "-";
if (delay === -2) return "testing";
if (delay >= timeout) return "timeout";
return `${delay}`;
}
formatDelayColor(delay: number, timeout = 10000) {
if (delay < 0) return "";
if (delay >= timeout) return "error.main";
if (delay <= 0) return "error.main";
if (delay > 500) return "warning.main";
if (delay >= 10000) return "error.main";
if (delay >= 400) return "warning.main";
if (delay >= 250) return "primary.main";
return "success.main";
}
}
+185
View File
@@ -0,0 +1,185 @@
// 全局日志服务,使应用在任何页面都能收集日志
import { create } from "zustand";
import { createSockette, createAuthSockette } from "@/utils/websocket";
import dayjs from "dayjs";
import { useState, useEffect } from "react";
// 最大日志数量
const MAX_LOG_NUM = 1000;
export type LogLevel = "warning" | "info" | "debug" | "error" | "all";
export interface ILogItem {
time?: string;
type: string;
payload: string;
[key: string]: any;
}
interface GlobalLogStore {
logs: ILogItem[];
enabled: boolean;
isConnected: boolean;
currentLevel: LogLevel;
setEnabled: (enabled: boolean) => void;
setCurrentLevel: (level: LogLevel) => void;
clearLogs: () => void;
appendLog: (log: ILogItem) => void;
}
// 创建全局状态存储
export const useGlobalLogStore = create<GlobalLogStore>((set) => ({
logs: [],
enabled: false,
isConnected: false,
currentLevel: "info",
setEnabled: (enabled) => set({ enabled }),
setCurrentLevel: (currentLevel) => set({ currentLevel }),
clearLogs: () => set({ logs: [] }),
appendLog: (log: ILogItem) =>
set((state) => {
const newLogs =
state.logs.length >= MAX_LOG_NUM
? [...state.logs.slice(1), log]
: [...state.logs, log];
return { logs: newLogs };
}),
}));
// 构建WebSocket URL
const buildWSUrl = (server: string, logLevel: LogLevel) => {
let baseUrl = `${server}/logs`;
// 只处理日志级别参数
if (logLevel && logLevel !== "info") {
const level = logLevel === "all" ? "debug" : logLevel;
baseUrl += `?level=${level}`;
}
return baseUrl;
};
// 初始化全局日志服务
let globalLogSocket: any = null;
export const initGlobalLogService = (
server: string,
secret: string,
enabled: boolean = false,
logLevel: LogLevel = "info",
) => {
const { appendLog, setEnabled } = useGlobalLogStore.getState();
// 更新启用状态
setEnabled(enabled);
// 如果不启用或没有服务器信息,则不初始化
if (!enabled || !server) {
closeGlobalLogConnection();
return;
}
// 关闭现有连接
closeGlobalLogConnection();
// 创建新的WebSocket连接,使用新的认证方法
const wsUrl = buildWSUrl(server, logLevel);
console.log(`[GlobalLog] 正在连接日志服务: ${wsUrl}`);
if (!server) {
console.warn("[GlobalLog] 服务器地址为空,无法建立连接");
return;
}
globalLogSocket = createAuthSockette(wsUrl, secret, {
timeout: 8000, // 8秒超时
onmessage(event) {
try {
const data = JSON.parse(event.data) as ILogItem;
const time = dayjs().format("MM-DD HH:mm:ss");
appendLog({ ...data, time });
} catch (error) {
console.error("[GlobalLog] 解析日志数据失败:", error);
}
},
onerror(event) {
console.error("[GlobalLog] WebSocket连接错误", event);
// 记录错误状态但不关闭连接,让重连机制起作用
useGlobalLogStore.setState({ isConnected: false });
// 只有在重试彻底失败后才关闭连接
if (
event &&
typeof event === "object" &&
"type" in event &&
event.type === "error"
) {
console.error("[GlobalLog] 连接已彻底失败,关闭连接");
closeGlobalLogConnection();
}
},
onclose(event) {
console.log("[GlobalLog] WebSocket连接关闭", event);
useGlobalLogStore.setState({ isConnected: false });
},
onopen(event) {
console.log("[GlobalLog] WebSocket连接已建立", event);
useGlobalLogStore.setState({ isConnected: true });
},
});
};
// 关闭全局日志连接
export const closeGlobalLogConnection = () => {
if (globalLogSocket) {
globalLogSocket.close();
globalLogSocket = null;
useGlobalLogStore.setState({ isConnected: false });
}
};
// 切换日志级别
export const changeLogLevel = (
level: LogLevel,
server: string,
secret: string,
) => {
const { enabled } = useGlobalLogStore.getState();
useGlobalLogStore.setState({ currentLevel: level });
if (enabled && server) {
initGlobalLogService(server, secret, enabled, level);
}
};
// 切换启用状态
export const toggleLogEnabled = (server: string, secret: string) => {
const { enabled, currentLevel } = useGlobalLogStore.getState();
const newEnabled = !enabled;
useGlobalLogStore.setState({ enabled: newEnabled });
if (newEnabled && server) {
initGlobalLogService(server, secret, newEnabled, currentLevel);
} else {
closeGlobalLogConnection();
}
};
// 获取日志清理函数
export const clearGlobalLogs = () => {
useGlobalLogStore.getState().clearLogs();
};
// 自定义钩子,用于获取过滤后的日志数据
export const useGlobalLogData = (logLevel: LogLevel = "all") => {
const logs = useGlobalLogStore((state) => state.logs);
// 根据当前选择的日志等级过滤日志
if (logLevel === "all") {
return logs;
} else {
return logs.filter((log) => log.type.toLowerCase() === logLevel);
}
};
+27 -1
View File
@@ -42,6 +42,31 @@ interface IConfigData {
"strict-route": boolean;
mtu: number;
};
dns?: {
enable?: boolean;
listen?: string;
"enhanced-mode"?: "fake-ip" | "redir-host";
"fake-ip-range"?: string;
"fake-ip-filter"?: string[];
"fake-ip-filter-mode"?: "blacklist" | "whitelist";
"prefer-h3"?: boolean;
"respect-rules"?: boolean;
nameserver?: string[];
fallback?: string[];
"default-nameserver"?: string[];
"proxy-server-nameserver"?: string[];
"direct-nameserver"?: string[];
"direct-nameserver-follow-policy"?: boolean;
"nameserver-policy"?: Record<string, any>;
"use-hosts"?: boolean;
"use-system-hosts"?: boolean;
"fallback-filter"?: {
geoip?: boolean;
"geoip-code"?: string;
ipcidr?: string[];
domain?: string[];
};
};
}
interface IRuleItem {
@@ -694,7 +719,7 @@ interface IVergeConfig {
app_log_level?: "trace" | "debug" | "info" | "warn" | "error" | string;
language?: string;
tray_event?: "main_window" | "system_proxy" | "tun_mode" | string;
env_type?: "bash" | "cmd" | "powershell" | string;
env_type?: "bash" | "cmd" | "powershell" | "fish" | string;
startup_script?: string;
start_page?: string;
clash_core?: string;
@@ -714,6 +739,7 @@ interface IVergeConfig {
enable_silent_start?: boolean;
enable_system_proxy?: boolean;
enable_global_hotkey?: boolean;
enable_dns_settings?: boolean;
proxy_auto_config?: boolean;
pac_file_content?: string;
enable_random_port?: boolean;
+221
View File
@@ -39,3 +39,224 @@ export const createSockette = (
},
});
};
/**
* 创建一个支持认证的WebSocket连接
* 使用标准的URL参数方式添加token
*
* 注意:mihomo服务器对WebSocket的认证支持不佳,使用URL参数方式传递token
*/
export const createAuthSockette = (
baseUrl: string,
secret: string,
opt: SocketteOptions,
maxError = 10,
) => {
// 确保baseUrl格式正确
let url = baseUrl;
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
url = `ws://${url}`;
}
// 重试控制
let reconnectAttempts = 0;
const MAX_RECONNECT = maxError;
let reconnectTimeout: any = null;
let ws: WebSocket | null = null;
// 使用URL API解析和构建URL
try {
const urlObj = new URL(url);
// 添加token参数(如果有secret)
if (secret) {
urlObj.searchParams.delete("token");
urlObj.searchParams.append("token", secret);
}
url = urlObj.toString();
console.log(`[WebSocket] 创建连接: ${url.replace(secret || "", "***")}`);
} catch (e) {
console.error(`[WebSocket] URL格式错误: ${url}`, e);
if (opt.onerror) {
// 使用任意类型避免类型错误
const anyOpt = opt as any;
anyOpt.onerror(
new ErrorEvent("error", { message: `URL格式错误: ${e}` } as any),
);
}
return createDummySocket();
}
function connect() {
try {
ws = new WebSocket(url);
ws.onopen = function (event) {
console.log(
`[WebSocket] 连接成功: ${url.replace(secret || "", "***")}`,
);
reconnectAttempts = 0; // 重置重连计数
if (opt.onopen) {
// 使用任意类型避免类型错误
const anyOpt = opt as any;
anyOpt.onopen(event);
}
};
ws.onmessage = function (event) {
if (opt.onmessage) {
// 使用任意类型避免类型错误
const anyOpt = opt as any;
anyOpt.onmessage(event);
}
};
ws.onerror = function (event) {
console.error(
`[WebSocket] 连接错误: ${url.replace(secret || "", "***")}`,
);
// 错误处理
if (reconnectAttempts < MAX_RECONNECT) {
scheduleReconnect();
} else if (opt.onerror) {
// 使用任意类型避免类型错误
const anyOpt = opt as any;
anyOpt.onerror(event);
}
};
ws.onclose = function (event) {
console.log(
`[WebSocket] 连接关闭: ${url.replace(secret || "", "***")}, 代码: ${event.code}`,
);
// 如果不是正常关闭(1000, 1001),尝试重连
if (
event.code !== 1000 &&
event.code !== 1001 &&
reconnectAttempts < MAX_RECONNECT
) {
scheduleReconnect();
} else {
if (opt.onclose) {
// 使用任意类型避免类型错误
const anyOpt = opt as any;
anyOpt.onclose(event);
}
// 如果已达到最大重试次数
if (reconnectAttempts >= MAX_RECONNECT && opt.onmaximum) {
console.error(
`[WebSocket] 达到最大重试次数: ${url.replace(secret || "", "***")}`,
);
const anyOpt = opt as any;
anyOpt.onmaximum(event);
}
}
};
} catch (error) {
console.error(`[WebSocket] 创建连接失败:`, error);
if (opt.onerror) {
// 使用任意类型避免类型错误
const anyOpt = opt as any;
anyOpt.onerror(
new ErrorEvent("error", { message: `创建连接失败: ${error}` } as any),
);
}
}
}
function scheduleReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(1.5, reconnectAttempts), 10000); // 指数退避,最大10秒
console.log(
`[WebSocket] 计划重连 (${reconnectAttempts}/${MAX_RECONNECT}) 延迟: ${delay}ms`,
);
reconnectTimeout = setTimeout(() => {
console.log(
`[WebSocket] 尝试重连 (${reconnectAttempts}/${MAX_RECONNECT})`,
);
cleanup();
connect();
}, delay);
}
function cleanup() {
if (ws) {
// 移除所有事件监听器
ws.onopen = null;
ws.onmessage = null;
ws.onerror = null;
ws.onclose = null;
// 如果连接仍然打开,关闭它
if (
ws.readyState === WebSocket.OPEN ||
ws.readyState === WebSocket.CONNECTING
) {
try {
ws.close();
} catch (e) {
console.error("[WebSocket] 关闭连接时出错:", e);
}
}
ws = null;
}
// 清除重连计时器
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
}
// 创建一个类似Sockette的接口对象
const socketLike = {
ws,
close: () => {
console.log(
`[WebSocket] 手动关闭连接: ${url.replace(secret || "", "***")}`,
);
cleanup();
},
reconnect: () => {
cleanup();
connect();
},
json: (data: any) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
},
send: (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
},
open: connect,
};
// 立即连接
connect();
return socketLike;
};
// 创建一个空的WebSocket对象
function createDummySocket() {
return {
close: () => {},
reconnect: () => {},
json: () => {},
send: () => {},
open: () => {},
};
}