mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 13:50:47 +08:00
Compare commits
135
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b9ccc774 | ||
|
|
d001bbc2eb | ||
|
|
2d1807cd4d | ||
|
|
c6dce2d6cb | ||
|
|
3c7768b379 | ||
|
|
d470bf2dd9 | ||
|
|
b5e8a1ddb5 | ||
|
|
08f3357d9a | ||
|
|
107c54921d | ||
|
|
ca1f7a2b31 | ||
|
|
95e8fe38f2 | ||
|
|
cd6eb26a41 | ||
|
|
9f2c4b4f35 | ||
|
|
adbf98cb15 | ||
|
|
c9b3fec09c | ||
|
|
364fb16b7b | ||
|
|
634ed9cb46 | ||
|
|
cddb117402 | ||
|
|
bd6c4e00c4 | ||
|
|
bbcfb4901f | ||
|
|
230a0eeb64 | ||
|
|
c4c49f61df | ||
|
|
6d5c4f86d3 | ||
|
|
59f22b9cd2 | ||
|
|
6caf2cd057 | ||
|
|
684128ca68 | ||
|
|
6925adc66f | ||
|
|
e1e9d91d86 | ||
|
|
881c136b1e | ||
|
|
5bc699b19c | ||
|
|
52ba1c5a30 | ||
|
|
817d68546e | ||
|
|
c0f5c231ad | ||
|
|
9677d8670b | ||
|
|
b01630d31c | ||
|
|
f771f4720f | ||
|
|
f0becb5003 | ||
|
|
405e8df825 | ||
|
|
49fc7ff7ca | ||
|
|
fde9c8aaee | ||
|
|
8f95c28050 | ||
|
|
2cd97c7785 | ||
|
|
6e48781687 | ||
|
|
dd510b2ee9 | ||
|
|
fadae3f7dc | ||
|
|
fdf8a6b2ba | ||
|
|
4382df7951 | ||
|
|
6cb7d48530 | ||
|
|
9d74b93ee0 | ||
|
|
d370868222 | ||
|
|
9278f9e193 | ||
|
|
a708db6f2b | ||
|
|
d2c4a44297 | ||
|
|
3a24623e4b | ||
|
|
d2084d8d21 | ||
|
|
d9b4ca91e7 | ||
|
|
13da571ad2 | ||
|
|
662d60c0f5 | ||
|
|
368095d2d4 | ||
|
|
496aeeb06d | ||
|
|
b31cbda615 | ||
|
|
7898f3a119 | ||
|
|
ba0a291d97 | ||
|
|
b3e4defc0f | ||
|
|
1ae0ad8a0e | ||
|
|
79b20694c7 | ||
|
|
1ad4941ed8 | ||
|
|
b3fd44d165 | ||
|
|
391a494af6 | ||
|
|
623d075ab8 | ||
|
|
5d70b77316 | ||
|
|
f5f54c0f0e | ||
|
|
91b0b1f279 | ||
|
|
e0fa1f3efe | ||
|
|
b00abf3337 | ||
|
|
a5d846ce4e | ||
|
|
7743c42dd1 | ||
|
|
3f3dab9495 | ||
|
|
2dc9672b20 | ||
|
|
2c90d1ca69 | ||
|
|
1555e2910d | ||
|
|
19b6bd35f5 | ||
|
|
3d4d60edc8 | ||
|
|
db0a000ecb | ||
|
|
24ff48b41c | ||
|
|
18a67d0b2f | ||
|
|
e23af1ad58 | ||
|
|
70af392059 | ||
|
|
d255df23ee | ||
|
|
1317a8b2db | ||
|
|
4cac118442 | ||
|
|
2ce43ccd23 | ||
|
|
d23b2949d8 | ||
|
|
3471476ba2 | ||
|
|
cee61e5619 | ||
|
|
5026e2bade | ||
|
|
83f005c256 | ||
|
|
80843c5ee3 | ||
|
|
ee00defe43 | ||
|
|
7696504d97 | ||
|
|
7a0e38a1b4 | ||
|
|
bc5d577553 | ||
|
|
cfed2b7236 | ||
|
|
e34e6654bc | ||
|
|
d6b85f1a01 | ||
|
|
16af7b53cb | ||
|
|
befc856207 | ||
|
|
5ab8e7a7c7 | ||
|
|
443bfa5928 | ||
|
|
52627575ff | ||
|
|
39973f2d24 | ||
|
|
2b01857d15 | ||
|
|
2650aa845f | ||
|
|
8400a61bf0 | ||
|
|
763b6b9003 | ||
|
|
053f414f3c | ||
|
|
2defa2cc56 | ||
|
|
bb1dbfcfc3 | ||
|
|
1378068a30 | ||
|
|
5d9dce7d10 | ||
|
|
acb66a3012 | ||
|
|
054f902cc6 | ||
|
|
4333153a59 | ||
|
|
19e623d7c2 | ||
|
|
2dd646537e | ||
|
|
1ece079978 | ||
|
|
0d8f779634 | ||
|
|
a7ffc9fc38 | ||
|
|
932b29e66f | ||
|
|
6be7a3b94c | ||
|
|
8e8dd1ec03 | ||
|
|
772f3268ce | ||
|
|
a179591ac2 | ||
|
|
d071e5971f | ||
|
|
a9032f5f20 |
@@ -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
|
||||
|
||||
+72
-54
@@ -2,9 +2,9 @@ name: Alpha Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
tags-ignore: [updater, alpha]
|
||||
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
|
||||
@@ -15,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:
|
||||
@@ -58,7 +91,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
@@ -70,34 +103,8 @@ jobs:
|
||||
pnpm i
|
||||
pnpm check ${{ matrix.target }}
|
||||
|
||||
- name: Prepare Updater Files
|
||||
run: |
|
||||
mkdir -p updater
|
||||
# 创建 alpha 更新通道的更新文件
|
||||
echo '{
|
||||
"version": "'$(cat package.json | jq -r .version)'",
|
||||
"notes": "Alpha channel update - More new features are now supported.",
|
||||
"pub_date": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'",
|
||||
"platforms": {
|
||||
"darwin-x86_64": {
|
||||
"signature": "",
|
||||
"url": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/alpha/Clash.Verge_${VERSION}_x64.dmg"
|
||||
},
|
||||
"darwin-aarch64": {
|
||||
"signature": "",
|
||||
"url": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/alpha/Clash.Verge_${VERSION}_aarch64.dmg"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "",
|
||||
"url": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/alpha/clash-verge_${VERSION}_amd64.deb"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "",
|
||||
"url": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/alpha/Clash.Verge_${VERSION}_x64-setup.exe"
|
||||
}
|
||||
}
|
||||
}' > updater/update-alpha.json
|
||||
cp updater/update-alpha.json updater/update-alpha-proxy.json
|
||||
- name: Alpha Version update
|
||||
run: pnpm run fix-alpha-version
|
||||
|
||||
- name: Tauri build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
@@ -121,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:
|
||||
@@ -158,7 +161,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -232,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
|
||||
|
||||
@@ -247,10 +250,10 @@ jobs:
|
||||
files: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||
updater/update-alpha.json
|
||||
updater/update-alpha-proxy.json
|
||||
|
||||
alpha-for-fixed-webview2:
|
||||
needs: check_commit
|
||||
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -279,7 +282,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
@@ -312,9 +315,23 @@ jobs:
|
||||
|
||||
- name: Rename
|
||||
run: |
|
||||
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.exe' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.exe'
|
||||
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip'
|
||||
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip.sig' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip.sig'
|
||||
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
|
||||
foreach ($file in $files) {
|
||||
$newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
|
||||
Rename-Item $file.FullName $newName
|
||||
}
|
||||
|
||||
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
|
||||
foreach ($file in $files) {
|
||||
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
|
||||
Rename-Item $file.FullName $newName
|
||||
}
|
||||
|
||||
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
|
||||
foreach ($file in $files) {
|
||||
$newName = $file.Name -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
|
||||
Rename-Item $file.FullName $newName
|
||||
}
|
||||
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
@@ -334,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
|
||||
@@ -344,12 +362,12 @@ jobs:
|
||||
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Update Tag
|
||||
uses: richardsimko/update-tag@v1
|
||||
with:
|
||||
tag_name: alpha
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# - name: Update Tag
|
||||
# uses: richardsimko/update-tag@v1
|
||||
# with:
|
||||
# tag_name: alpha
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- run: |
|
||||
cat > release.txt << 'EOF'
|
||||
@@ -364,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
|
||||
|
||||
@@ -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
|
||||
@@ -271,9 +269,23 @@ jobs:
|
||||
|
||||
- name: Rename
|
||||
run: |
|
||||
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.exe' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.exe'
|
||||
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip'
|
||||
Rename-Item '.\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}-setup.nsis.zip.sig' 'Clash Verge_${{steps.build.outputs.appVersion}}_${{ matrix.arch }}_fixed_webview2-setup.nsis.zip.sig'
|
||||
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
|
||||
foreach ($file in $files) {
|
||||
$newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
|
||||
Rename-Item $file.FullName $newName
|
||||
}
|
||||
|
||||
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
|
||||
foreach ($file in $files) {
|
||||
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
|
||||
Rename-Item $file.FullName $newName
|
||||
}
|
||||
|
||||
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
|
||||
foreach ($file in $files) {
|
||||
$newName = $file.Name -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
|
||||
Rename-Item $file.FullName $newName
|
||||
}
|
||||
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
+27
-3
@@ -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:
|
||||
|
||||
+107
@@ -1,3 +1,108 @@
|
||||
## v2.1.2
|
||||
|
||||
**发行代号:臻**
|
||||
|
||||
代号释义: 千锤百炼臻至善,集性能跃升、功能拓展、交互焕新于一体,彰显持续打磨、全方位优化的迭代精神。
|
||||
|
||||
感谢 Tychristine 对社区群组管理做出的重大贡献!
|
||||
|
||||
##### 2.1.2相对2.1.1(已下架不在提供)更新了:
|
||||
|
||||
- 无法更新和签名验证失败的问题(该死的CDN缓存)
|
||||
- 设置菜单区分Verge基本设置和高级设置
|
||||
- 增加v2 Updater的更多功能和权限
|
||||
- 退出Verge后Tun代理状态仍保留的问题
|
||||
|
||||
##### 2.1.1相对2.1.0(已下架不在提供)更新了:
|
||||
|
||||
- 检测所需的Clash Verge Service版本(杀毒软件误报可能与此有关,因为检测和安装新版本Service需管理员权限)
|
||||
- MacOS下支持彩色托盘图标和更好速率显示(感谢Tunglies)
|
||||
- 文件类型判断不准导致脚本检测报错的问题
|
||||
- 打开Win下的阴影(Win10因底层兼容性问题,可能圆角和边框显示不太完美)
|
||||
- 边框去白边
|
||||
- 修复Linux下编译问题
|
||||
- 修复热键无法关闭面板的问题
|
||||
|
||||
##### 2.1.0 - 发行代号:臻
|
||||
|
||||
### 功能新增
|
||||
|
||||
- 新增窗口状态实时监控与自动保存功能
|
||||
- 增强核心配置变更时的验证与错误处理机制
|
||||
- 支持通过环境变量`CLASH_VERGE_REV_IP`自定义复制IP地址
|
||||
- 添加连接表列宽持久化设置与进程过滤功能
|
||||
- 新增代理组首字母导航与动态滚动定位功能
|
||||
- 实现连接追踪暂停/恢复功能
|
||||
- 支持从托盘菜单快速切换代理配置
|
||||
- 添加轻量级模式开关选项
|
||||
- 允许用户自定义TUN模式增强类型和FakeIP范围
|
||||
- 新增系统代理状态指示器
|
||||
- 增加Alpha版本自动重命名逻辑
|
||||
- 优化字母导航工具提示与防抖交互机制
|
||||
|
||||
### 性能优化
|
||||
|
||||
- 重构代理列表渲染逻辑,提升布局计算效率
|
||||
- 优化代理数据更新机制,采用乐观UI策略
|
||||
- 改进虚拟列表渲染性能(Virtuoso)
|
||||
- 提升主窗口Clash模式切换速度(感谢Tunglies)
|
||||
- 加速内核关闭流程并优化管理逻辑
|
||||
- 优化节点延迟刷新速率
|
||||
- 改进托盘网速显示更新逻辑
|
||||
- 提升配置验证错误信息的可读性
|
||||
- 重构服务架构,优化代码组织结构(感谢Tunglies)
|
||||
- 优化内核启动时的配置验证流程
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复删除节点时关联组信息残留问题
|
||||
- 解决菜单切换异常与重复勾选问题
|
||||
- 修正连接页流量计算错误
|
||||
- 修复Windows圆角显示异常问题
|
||||
- 解决控制台废弃API警告
|
||||
- 修复全局热键空值导致的崩溃
|
||||
- 修复Alpha版本Windows打包重命名问题
|
||||
- 修复MacOS端口切换崩溃问题
|
||||
- 解决Linux持续集成更新器问题
|
||||
- 修复静默启动后热键失效问题
|
||||
- 修正TypeScript代理组类型定义
|
||||
- 修复Windows托盘图标空白问题
|
||||
- 优化远程目标地址显示(替换旧版IP展示)
|
||||
|
||||
### 交互体验
|
||||
|
||||
- 统一多平台托盘图标点击行为
|
||||
- 优化代理列表滚动流畅度
|
||||
- 改进日志搜索功能与数据管理
|
||||
- 重构热键管理逻辑,修复托盘冻结问题
|
||||
- 优化托盘网速显示样式
|
||||
- 增强字母导航工具提示的动态响应
|
||||
|
||||
### 国际化
|
||||
|
||||
- 新增配置检查多语言支持
|
||||
- 添加轻量级模式多语言文本
|
||||
- 完善多语言翻译内容
|
||||
|
||||
### 维护更新
|
||||
|
||||
- 将默认TUN协议栈改为gVisor
|
||||
- 更新Node.js运行版本
|
||||
- 移除自动生成更新器文件
|
||||
- 清理废弃代码与未使用组件
|
||||
- 禁用工作流自动Alpha标签更新
|
||||
- 更新依赖库版本
|
||||
- 添加MacOS格式转换函数专项测试
|
||||
- 优化开发模式日志输出
|
||||
|
||||
### 安全增强
|
||||
|
||||
- 强化应用启动时的配置验证机制
|
||||
- 改进脚本验证与异常处理流程
|
||||
- 修复编译警告(移除无用导入)
|
||||
|
||||
---
|
||||
|
||||
## v2.0.3
|
||||
|
||||
### Notice
|
||||
@@ -45,6 +150,8 @@
|
||||
- 改进更新托盘图标性能
|
||||
- 窗口隐藏后WebSocket断开连接
|
||||
|
||||
---
|
||||
|
||||
## v2.0.2
|
||||
|
||||
### Notice
|
||||
|
||||
+8
-5
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.0.4-alpha",
|
||||
"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",
|
||||
@@ -15,7 +16,9 @@
|
||||
"updater-fixed-webview2": "node scripts/updater-fixed-webview2.mjs",
|
||||
"portable": "node scripts/portable.mjs",
|
||||
"portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs",
|
||||
"prepare": "husky"
|
||||
"fix-alpha-version": "node scripts/alpha_version.mjs",
|
||||
"prepare": "husky",
|
||||
"clean": "cd ./src-tauri && cargo clean && cd -"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -36,7 +39,7 @@
|
||||
"@tauri-apps/plugin-notification": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "2.2.0",
|
||||
"@tauri-apps/plugin-updater": "2.4.0",
|
||||
"@tauri-apps/plugin-updater": "2.3.0",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.7.9",
|
||||
|
||||
Generated
+5
-5
@@ -62,8 +62,8 @@ importers:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0
|
||||
"@tauri-apps/plugin-updater":
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
"@types/json-schema":
|
||||
specifier: ^7.0.15
|
||||
version: 7.0.15
|
||||
@@ -2291,10 +2291,10 @@ packages:
|
||||
integrity: sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==,
|
||||
}
|
||||
|
||||
"@tauri-apps/plugin-updater@2.4.0":
|
||||
"@tauri-apps/plugin-updater@2.3.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-BkeKN2WObAjobf2G77HyW/DxAfI0In+VSqWGnw/0cVPlM+VmA7fw9dKUnSunryZOG7ys9y07tj7FQa1ABMXGZQ==,
|
||||
integrity: sha512-qdzyZEUN69FZQ/nRx51fBub10tT6wffJl3DLVo9q922Gvw8Wk++rZhoD9eethPlZYbog/7RGgT8JkrfLh5BKAg==,
|
||||
}
|
||||
|
||||
"@types/babel__core@7.20.5":
|
||||
@@ -6279,7 +6279,7 @@ snapshots:
|
||||
dependencies:
|
||||
"@tauri-apps/api": 2.2.0
|
||||
|
||||
"@tauri-apps/plugin-updater@2.4.0":
|
||||
"@tauri-apps/plugin-updater@2.3.0":
|
||||
dependencies:
|
||||
"@tauri-apps/api": 2.2.0
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* 为Alpha版本重命名版本号
|
||||
*/
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
/**
|
||||
* 标准输出HEAD hash
|
||||
*/
|
||||
async function getLatestCommitHash() {
|
||||
try {
|
||||
const { stdout } = await execPromise("git rev-parse HEAD");
|
||||
const commitHash = stdout.trim();
|
||||
// 格式化,只截取前7位字符
|
||||
const formathash = commitHash.substring(0, 7);
|
||||
console.log(`Found the latest commit hash code: ${commitHash}`);
|
||||
return formathash;
|
||||
} catch (error) {
|
||||
console.error("pnpm run fix-alpha-version ERROR", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string 传入格式化后的hash
|
||||
* 将新的版本号写入文件 package.json
|
||||
*/
|
||||
async function updatePackageVersion(newVersion) {
|
||||
// 获取内容根目录
|
||||
const _dirname = process.cwd();
|
||||
const packageJsonPath = path.join(_dirname, "package.json");
|
||||
try {
|
||||
// 读取文件
|
||||
const data = await fs.readFile(packageJsonPath, "utf8");
|
||||
const packageJson = JSON.parse(data);
|
||||
// 获取键值替换
|
||||
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(`[INFO]: Alpha version update to: ${newVersion}`);
|
||||
} catch (error) {
|
||||
console.error("pnpm run fix-alpha-version ERROR", error);
|
||||
}
|
||||
}
|
||||
|
||||
const newVersion = await getLatestCommitHash();
|
||||
updatePackageVersion(newVersion).catch(console.error);
|
||||
+2
-44
@@ -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
@@ -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
|
||||
|
||||
Generated
+908
-343
File diff suppressed because it is too large
Load Diff
+44
-14
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.0.4-alpha"
|
||||
version = "2.1.2"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -13,25 +13,25 @@ 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"
|
||||
delay_timer = "0.11.6"
|
||||
parking_lot = "0.12"
|
||||
percent-encoding = "2.3.1"
|
||||
window-shadows = { version = "0.2.2" }
|
||||
@@ -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.4.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"
|
||||
]
|
||||
|
||||
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,441 +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;
|
||||
|
||||
#[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 {
|
||||
wrap_err!(CoreManager::global().update_config().await)?;
|
||||
log_err!(tray::Tray::global().update_tooltip());
|
||||
handle::Handle::refresh_clash();
|
||||
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 {
|
||||
wrap_err!({ Config::profiles().draft().patch_config(profiles) })?;
|
||||
match CoreManager::global().update_config().await {
|
||||
Ok(_) => {
|
||||
handle::Handle::refresh_clash();
|
||||
let _ = tray::Tray::global().update_tooltip();
|
||||
Config::profiles().apply();
|
||||
wrap_err!(Config::profiles().data().save_file())?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
Config::profiles().discard();
|
||||
log::error!(target: "app", "{err}");
|
||||
Err(format!("{err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据profile name修改profiles
|
||||
#[tauri::command]
|
||||
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult {
|
||||
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))?;
|
||||
wrap_err!(timer::Timer::global().refresh())
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
|
||||
if file_data.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let profiles = Config::profiles();
|
||||
let profiles = profiles.latest();
|
||||
let item = wrap_err!(profiles.get_item(&index))?;
|
||||
wrap_err!(item.save_file(file_data.unwrap()))
|
||||
}
|
||||
|
||||
#[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: Option<String>) -> CmdResult {
|
||||
wrap_err!(CoreManager::global().change_core(clash_core).await)
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -3,10 +3,12 @@ use crate::{
|
||||
config::PrfItem,
|
||||
enhance,
|
||||
utils::{dirs, help},
|
||||
core::{handle, CoreManager},
|
||||
};
|
||||
use anyhow::{anyhow, Result};
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::path::PathBuf;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
pub const RUNTIME_CONFIG: &str = "clash-verge.yaml";
|
||||
pub const CHECK_CONFIG: &str = "clash-verge-check.yaml";
|
||||
@@ -64,20 +66,57 @@ impl Config {
|
||||
let script_item = PrfItem::from_script(Some("Script".to_string()))?;
|
||||
Self::profiles().data().append_item(script_item.clone())?;
|
||||
}
|
||||
crate::log_err!(Self::generate().await);
|
||||
if let Err(err) = Self::generate_file(ConfigType::Run) {
|
||||
log::error!(target: "app", "{err}");
|
||||
|
||||
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
|
||||
// 如果不存在就将默认的clash文件拿过来
|
||||
if !runtime_path.exists() {
|
||||
help::save_yaml(
|
||||
&runtime_path,
|
||||
&Config::clash().latest().0,
|
||||
Some("# Clash Verge Runtime"),
|
||||
)?;
|
||||
// 生成运行时配置
|
||||
crate::log_err!(Self::generate().await);
|
||||
|
||||
// 生成运行时配置文件并验证
|
||||
let config_result = Self::generate_file(ConfigType::Run);
|
||||
|
||||
let validation_result = if let Ok(_) = config_result {
|
||||
// 验证配置文件
|
||||
println!("[首次启动] 开始验证配置");
|
||||
|
||||
match CoreManager::global().validate_config().await {
|
||||
Ok((is_valid, error_msg)) => {
|
||||
if !is_valid {
|
||||
println!("[首次启动] 配置验证失败,使用默认最小配置启动: {}", error_msg);
|
||||
CoreManager::global()
|
||||
.use_default_config("config_validate::boot_error", &error_msg)
|
||||
.await?;
|
||||
Some(("config_validate::boot_error", error_msg))
|
||||
} else {
|
||||
println!("[首次启动] 配置验证成功");
|
||||
Some(("config_validate::success", String::new()))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!("[首次启动] 验证进程执行失败: {}", err);
|
||||
CoreManager::global()
|
||||
.use_default_config("config_validate::process_terminated", "")
|
||||
.await?;
|
||||
Some(("config_validate::process_terminated", String::new()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("[首次启动] 生成配置文件失败,使用默认配置");
|
||||
CoreManager::global()
|
||||
.use_default_config(
|
||||
"config_validate::error",
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
Some(("config_validate::error", String::new()))
|
||||
};
|
||||
|
||||
// 在单独的任务中发送通知
|
||||
if let Some((msg_type, msg_content)) = validation_result {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
handle::Handle::notice_message(msg_type, &msg_content);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+524
-65
@@ -1,12 +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::utils::dirs;
|
||||
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;
|
||||
@@ -16,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();
|
||||
@@ -32,28 +44,6 @@ impl CoreManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查订阅是否正确
|
||||
pub async fn check_config(&self) -> Result<()> {
|
||||
let config_path = Config::generate_file(ConfigType::Check)?;
|
||||
let config_path = dirs::path_to_str(&config_path)?;
|
||||
|
||||
let clash_core = { Config::verge().latest().clash_core.clone() };
|
||||
let clash_core = clash_core.unwrap_or("verge-mihomo".into());
|
||||
|
||||
let test_dir = dirs::app_home_dir()?.join("test");
|
||||
let test_dir = dirs::path_to_str(&test_dir)?;
|
||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||
|
||||
let _ = app_handle
|
||||
.shell()
|
||||
.sidecar(clash_core)?
|
||||
.args(["-t", "-d", test_dir, "-f", config_path])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止核心运行
|
||||
pub async fn stop_core(&self) -> Result<()> {
|
||||
let mut running = self.running.lock().await;
|
||||
@@ -64,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;
|
||||
@@ -90,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);
|
||||
@@ -104,11 +136,62 @@ 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(())
|
||||
}
|
||||
|
||||
/// 使用默认配置
|
||||
pub async fn use_default_config(&self, msg_type: &str, msg_content: &str) -> Result<()> {
|
||||
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
|
||||
*Config::runtime().draft() = IRuntime {
|
||||
config: Some(Config::clash().latest().0.clone()),
|
||||
exists_keys: vec![],
|
||||
chain_logs: Default::default(),
|
||||
};
|
||||
help::save_yaml(
|
||||
&runtime_path,
|
||||
&Config::clash().latest().0,
|
||||
Some("# Clash Verge Runtime"),
|
||||
)?;
|
||||
handle::Handle::notice_message(msg_type, msg_content);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -123,56 +206,432 @@ impl CoreManager {
|
||||
|
||||
log::info!(target: "app", "change core to `{clash_core}`");
|
||||
|
||||
// 1. 先更新内核配置(但不应用)
|
||||
Config::verge().draft().clash_core = Some(clash_core);
|
||||
|
||||
// 更新订阅
|
||||
Config::generate().await?;
|
||||
|
||||
self.check_config().await?;
|
||||
|
||||
match self.restart_core().await {
|
||||
Ok(_) => {
|
||||
// 2. 使用新内核验证配置
|
||||
println!("[切换内核] 使用新内核验证配置");
|
||||
match self.validate_config().await {
|
||||
Ok((true, _)) => {
|
||||
println!("[切换内核] 配置验证通过,开始切换内核");
|
||||
// 3. 验证通过后,应用内核配置并重启
|
||||
Config::verge().apply();
|
||||
Config::runtime().apply();
|
||||
log_err!(Config::verge().latest().save_file());
|
||||
Ok(())
|
||||
|
||||
match self.restart_core().await {
|
||||
Ok(_) => {
|
||||
println!("[切换内核] 内核切换成功");
|
||||
Config::runtime().apply();
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
println!("[切换内核] 内核切换失败: {}", 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?;
|
||||
Config::verge().apply();
|
||||
log_err!(Config::verge().latest().save_file());
|
||||
|
||||
match self.restart_core().await {
|
||||
Ok(_) => {
|
||||
println!("[切换内核] 内核切换成功(使用默认配置)");
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
println!("[切换内核] 内核切换失败: {}", err);
|
||||
// 即使使用服务失败,我们也尝试使用sidecar模式启动
|
||||
log::info!(target: "app", "trying sidecar mode after service failure with default config");
|
||||
self.start_core().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!("[切换内核] 验证过程发生错误: {}", err);
|
||||
Config::verge().discard();
|
||||
Config::runtime().discard();
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新proxies那些
|
||||
/// 如果涉及端口和外部控制则需要重启
|
||||
pub async fn update_config(&self) -> Result<()> {
|
||||
log::debug!(target: "app", "try to update clash config");
|
||||
// 更新订阅
|
||||
/// 内部验证配置文件的实现
|
||||
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)?;
|
||||
println!("[core配置验证] 测试目录: {}", test_dir);
|
||||
|
||||
// 使用子进程运行clash验证配置
|
||||
println!("[core配置验证] 运行子进程验证配置");
|
||||
let output = app_handle
|
||||
.shell()
|
||||
.sidecar(clash_core)?
|
||||
.args(["-t", "-d", test_dir, "-f", config_path])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let 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));
|
||||
|
||||
println!("\n[core配置验证] -------- 验证结果 --------");
|
||||
println!("[core配置验证] 进程退出状态: {:?}", output.status);
|
||||
|
||||
if !stderr.is_empty() {
|
||||
println!("[core配置验证] stderr输出:\n{}", stderr);
|
||||
}
|
||||
if !stdout.is_empty() {
|
||||
println!("[core配置验证] stdout输出:\n{}", stdout);
|
||||
}
|
||||
|
||||
if has_error {
|
||||
println!("[core配置验证] 发现错误,开始处理错误信息");
|
||||
let error_msg = if !stdout.is_empty() {
|
||||
stdout.to_string()
|
||||
} else if !stderr.is_empty() {
|
||||
stderr.to_string()
|
||||
} else if let Some(code) = output.status.code() {
|
||||
format!("验证进程异常退出,退出码: {}", code)
|
||||
} else {
|
||||
"验证进程被终止".to_string()
|
||||
};
|
||||
|
||||
println!("[core配置验证] -------- 验证结束 --------\n");
|
||||
Ok((false, error_msg)) // 返回错误消息给调用者处理
|
||||
} else {
|
||||
println!("[core配置验证] 验证成功");
|
||||
println!("[core配置验证] -------- 验证结束 --------\n");
|
||||
Ok((true, String::new()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证运行时配置
|
||||
pub async fn validate_config(&self) -> Result<(bool, String)> {
|
||||
let config_path = Config::generate_file(ConfigType::Check)?;
|
||||
let config_path = dirs::path_to_str(&config_path)?;
|
||||
self.validate_config_internal(config_path).await
|
||||
}
|
||||
|
||||
/// 验证指定的配置文件
|
||||
pub async fn validate_config_file(
|
||||
&self,
|
||||
config_path: &str,
|
||||
is_merge_file: Option<bool>,
|
||||
) -> Result<(bool, String)> {
|
||||
// 检查程序是否正在退出,如果是则跳过验证
|
||||
if 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
|
||||
} else {
|
||||
match self.is_script_file(config_path) {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
// 如果无法确定文件类型,尝试使用Clash内核验证
|
||||
log::warn!(target: "app", "无法确定文件类型: {}, 错误: {}", config_path, err);
|
||||
return self.validate_config_internal(config_path).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 检查文件是否为脚本文件
|
||||
fn is_script_file(&self, path: &str) -> Result<bool> {
|
||||
// 1. 先通过扩展名快速判断
|
||||
if path.ends_with(".yaml") || path.ends_with(".yml") {
|
||||
return Ok(false); // YAML文件不是脚本文件
|
||||
} else if path.ends_with(".js") {
|
||||
return Ok(true); // JS文件是脚本文件
|
||||
}
|
||||
|
||||
// 2. 读取文件内容
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
log::warn!(target: "app", "无法读取文件以检测类型: {}, 错误: {}", path, err);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to read file to detect type: {}",
|
||||
err
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 检查是否存在明显的YAML特征
|
||||
let has_yaml_features = content.contains(": ")
|
||||
|| content.contains("#")
|
||||
|| content.contains("---")
|
||||
|| content.lines().any(|line| line.trim().starts_with("- "));
|
||||
|
||||
// 4. 检查是否存在明显的JS特征
|
||||
let has_js_features = content.contains("function ")
|
||||
|| content.contains("const ")
|
||||
|| content.contains("let ")
|
||||
|| content.contains("var ")
|
||||
|| content.contains("//")
|
||||
|| content.contains("/*")
|
||||
|| content.contains("*/")
|
||||
|| content.contains("export ")
|
||||
|| content.contains("import ");
|
||||
|
||||
// 5. 决策逻辑
|
||||
if has_yaml_features && !has_js_features {
|
||||
// 只有YAML特征,没有JS特征
|
||||
return Ok(false);
|
||||
} else if has_js_features && !has_yaml_features {
|
||||
// 只有JS特征,没有YAML特征
|
||||
return Ok(true);
|
||||
} else if has_yaml_features && has_js_features {
|
||||
// 两种特征都有,需要更精细判断
|
||||
// 优先检查是否有明确的JS结构特征
|
||||
if content.contains("function main")
|
||||
|| content.contains("module.exports")
|
||||
|| content.contains("export default")
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// 检查冒号后是否有空格(YAML的典型特征)
|
||||
let yaml_pattern_count = content.lines().filter(|line| line.contains(": ")).count();
|
||||
|
||||
if yaml_pattern_count > 2 {
|
||||
return Ok(false); // 多个键值对格式,更可能是YAML
|
||||
}
|
||||
}
|
||||
|
||||
// 默认情况:无法确定时,假设为非脚本文件(更安全)
|
||||
log::debug!(target: "app", "无法确定文件类型,默认当作YAML处理: {}", path);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// 验证脚本文件语法
|
||||
async fn validate_script_file(&self, path: &str) -> Result<(bool, String)> {
|
||||
// 读取脚本内容
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
let error_msg = format!("Failed to read script file: {}", err);
|
||||
log::warn!(target: "app", "脚本语法错误: {}", err);
|
||||
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
|
||||
return Ok((false, error_msg));
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!(target: "app", "验证脚本文件: {}", path);
|
||||
|
||||
// 使用boa引擎进行基本语法检查
|
||||
use boa_engine::{Context, Source};
|
||||
|
||||
let mut context = Context::default();
|
||||
let result = context.eval(Source::from_bytes(&content));
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
log::debug!(target: "app", "脚本语法验证通过: {}", path);
|
||||
|
||||
// 检查脚本是否包含main函数
|
||||
if !content.contains("function main")
|
||||
&& !content.contains("const main")
|
||||
&& !content.contains("let main")
|
||||
{
|
||||
let error_msg = "Script must contain a main function";
|
||||
log::warn!(target: "app", "脚本缺少main函数: {}", path);
|
||||
//handle::Handle::notice_message("config_validate::script_missing_main", error_msg);
|
||||
return Ok((false, error_msg.to_string()));
|
||||
}
|
||||
|
||||
Ok((true, String::new()))
|
||||
}
|
||||
Err(err) => {
|
||||
let error_msg = format!("Script syntax error: {}", err);
|
||||
log::warn!(target: "app", "脚本语法错误: {}", err);
|
||||
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
|
||||
Ok((false, error_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新proxies等配置
|
||||
pub async fn update_config(&self) -> Result<(bool, String)> {
|
||||
// 检查程序是否正在退出,如果是则跳过完整验证流程
|
||||
if handle::Handle::global().is_exiting() {
|
||||
println!("[core配置更新] 应用正在退出,跳过验证");
|
||||
return Ok((true, String::new()));
|
||||
}
|
||||
|
||||
println!("[core配置更新] 开始更新配置");
|
||||
|
||||
// 1. 先生成新的配置内容
|
||||
println!("[core配置更新] 生成新的配置内容");
|
||||
Config::generate().await?;
|
||||
|
||||
// 检查订阅是否正常
|
||||
self.check_config().await?;
|
||||
// 2. 生成临时文件并进行验证
|
||||
println!("[core配置更新] 生成临时配置文件用于验证");
|
||||
let temp_config = Config::generate_file(ConfigType::Check)?;
|
||||
let temp_config = dirs::path_to_str(&temp_config)?;
|
||||
println!("[core配置更新] 临时配置文件路径: {}", temp_config);
|
||||
|
||||
// 更新运行时订阅
|
||||
let path = Config::generate_file(ConfigType::Run)?;
|
||||
let path = dirs::path_to_str(&path)?;
|
||||
// 3. 验证配置
|
||||
match self.validate_config().await {
|
||||
Ok((true, _)) => {
|
||||
println!("[core配置更新] 配置验证通过");
|
||||
// 4. 验证通过后,生成正式的运行时配置
|
||||
println!("[core配置更新] 生成运行时配置");
|
||||
let run_path = Config::generate_file(ConfigType::Run)?;
|
||||
let run_path = dirs::path_to_str(&run_path)?;
|
||||
|
||||
// 发送请求 发送5次
|
||||
for i in 0..10 {
|
||||
match clash_api::put_configs(path).await {
|
||||
Ok(_) => break,
|
||||
Err(err) => {
|
||||
if i < 9 {
|
||||
log::info!(target: "app", "{err}");
|
||||
} else {
|
||||
bail!(err);
|
||||
// 5. 应用新配置
|
||||
println!("[core配置更新] 应用新配置");
|
||||
for i in 0..3 {
|
||||
match MihomoManager::global().put_configs_force(run_path).await {
|
||||
Ok(_) => {
|
||||
println!("[core配置更新] 配置应用成功");
|
||||
Config::runtime().apply();
|
||||
return Ok((true, String::new()));
|
||||
}
|
||||
Err(err) => {
|
||||
if i < 2 {
|
||||
println!("[core配置更新] 第{}次重试应用配置", i + 1);
|
||||
log::info!(target: "app", "{err}");
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
} else {
|
||||
println!("[core配置更新] 配置应用失败: {}", err);
|
||||
Config::runtime().discard();
|
||||
return Ok((false, err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((true, String::new()))
|
||||
}
|
||||
Ok((false, error_msg)) => {
|
||||
println!("[core配置更新] 配置验证失败: {}", error_msg);
|
||||
Config::runtime().discard();
|
||||
Ok((false, error_msg))
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[core配置更新] 验证过程发生错误: {}", e);
|
||||
Config::runtime().discard();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 只进行文件语法检查,不进行完整验证
|
||||
async fn validate_file_syntax(&self, config_path: &str) -> Result<(bool, String)> {
|
||||
println!("[core配置语法检查] 开始检查文件: {}", config_path);
|
||||
|
||||
// 读取文件内容
|
||||
let content = match std::fs::read_to_string(config_path) {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
let error_msg = format!("Failed to read file: {}", err);
|
||||
println!("[core配置语法检查] 无法读取文件: {}", error_msg);
|
||||
return Ok((false, error_msg));
|
||||
}
|
||||
};
|
||||
|
||||
// 对YAML文件尝试解析,只检查语法正确性
|
||||
println!("[core配置语法检查] 进行YAML语法检查");
|
||||
match serde_yaml::from_str::<serde_yaml::Value>(&content) {
|
||||
Ok(_) => {
|
||||
println!("[core配置语法检查] YAML语法检查通过");
|
||||
Ok((true, String::new()))
|
||||
}
|
||||
Err(err) => {
|
||||
// 使用标准化的前缀,以便错误处理函数能正确识别
|
||||
let error_msg = format!("YAML syntax error: {}", err);
|
||||
println!("[core配置语法检查] YAML语法错误: {}", error_msg);
|
||||
Ok((false, error_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前内核运行模式
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
Err(_) => {
|
||||
// 服务不可用,检查是否有sidecar进程
|
||||
if handle::Handle::global().has_core_process() {
|
||||
RunningMode::Sidecar
|
||||
} else {
|
||||
RunningMode::NotRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,59 +0,0 @@
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use anyhow::{bail, Result};
|
||||
#[cfg(target_os = "macos")]
|
||||
use anyhow::Result;
|
||||
use sysinfo::{Pid, System};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use crate::config::Config;
|
||||
use crate::core::service;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use port_scanner::local_port_available;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HealthChecker;
|
||||
|
||||
impl HealthChecker {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub async fn check_ports(&self) -> Result<()> {
|
||||
let verge = Config::verge();
|
||||
let verge_config = verge.latest();
|
||||
let mixed_port = verge_config.verge_mixed_port.unwrap_or(7897);
|
||||
let socks_port = verge_config.verge_socks_port.unwrap_or(7890);
|
||||
let http_port = verge_config.verge_port.unwrap_or(7891);
|
||||
|
||||
if !local_port_available(mixed_port) {
|
||||
bail!("Mixed port {} is already in use", mixed_port);
|
||||
}
|
||||
|
||||
if verge_config.verge_socks_enabled.unwrap_or(true) && !local_port_available(socks_port) {
|
||||
bail!("Socks port {} is already in use", socks_port);
|
||||
}
|
||||
|
||||
if verge_config.verge_http_enabled.unwrap_or(true) && !local_port_available(http_port) {
|
||||
bail!("Http port {} is already in use", http_port);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_service_health(&self) -> Result<()> {
|
||||
if let Ok(response) = service::check_service().await {
|
||||
if let Some(body) = response.data {
|
||||
let sys = System::new_all();
|
||||
if let Ok(pid) = body.bin_path.parse::<u32>() {
|
||||
if let Some(process) = sys.process(Pid::from(pid as usize)) {
|
||||
if !process.name().to_string_lossy().contains("mihomo") {
|
||||
log::warn!(target: "app", "Found non-mihomo process using service port");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,32 @@ impl Hotkey {
|
||||
|
||||
// 使用 spawn_blocking 来确保在正确的线程上执行
|
||||
async_runtime::spawn_blocking(|| {
|
||||
println!("Creating window in spawn_blocking");
|
||||
log::info!(target: "app", "Creating window in spawn_blocking");
|
||||
resolve::create_window();
|
||||
println!("Toggle dashboard window visibility");
|
||||
log::info!(target: "app", "Toggle dashboard window visibility");
|
||||
|
||||
// 检查窗口是否存在
|
||||
if let Some(window) = handle::Handle::global().get_window() {
|
||||
// 如果窗口可见,则隐藏它
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
println!("Window is visible, hiding it");
|
||||
log::info!(target: "app", "Window is visible, hiding it");
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
// 如果窗口不可见,则显示它
|
||||
println!("Window is hidden, showing it");
|
||||
log::info!(target: "app", "Window is hidden, showing it");
|
||||
if window.is_minimized().unwrap_or(false) {
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
} else {
|
||||
// 如果窗口不存在,创建一个新窗口
|
||||
println!("Window does not exist, creating a new one");
|
||||
log::info!(target: "app", "Window does not exist, creating a new one");
|
||||
resolve::create_window();
|
||||
}
|
||||
});
|
||||
|
||||
println!("=== Hotkey Dashboard Window Operation End ===");
|
||||
@@ -117,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)),
|
||||
|
||||
_ => {
|
||||
@@ -146,7 +169,23 @@ impl Hotkey {
|
||||
// 直接执行函数,不做任何状态检查
|
||||
println!("Executing function directly");
|
||||
log::info!(target: "app", "Executing function directly");
|
||||
f();
|
||||
|
||||
// 获取轻量模式状态和全局热键状态
|
||||
let is_lite_mode = Config::verge().latest().enable_lite_mode.unwrap_or(false);
|
||||
let is_enable_global_hotkey = Config::verge().latest().enable_global_hotkey.unwrap_or(true);
|
||||
|
||||
// 在轻量模式下或配置了全局热键时,始终执行热键功能
|
||||
if is_lite_mode || is_enable_global_hotkey {
|
||||
f();
|
||||
} else if let Some(window) = app_handle.get_webview_window("main") {
|
||||
// 非轻量模式且未启用全局热键时,只在窗口可见且有焦点的情况下响应热键
|
||||
let is_visible = window.is_visible().unwrap_or(false);
|
||||
let is_focused = window.is_focused().unwrap_or(false);
|
||||
|
||||
if is_focused && is_visible {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod backup;
|
||||
pub mod clash_api;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod core;
|
||||
pub mod handle;
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
use anyhow::{bail, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use sysinfo::{Pid, System, Signal};
|
||||
use crate::utils::dirs;
|
||||
use std::time::Duration;
|
||||
|
||||
const KILL_WAIT: Duration = Duration::from_millis(500);
|
||||
const FINAL_WAIT: Duration = Duration::from_millis(1000);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProcessLock {
|
||||
pid_file: PathBuf,
|
||||
}
|
||||
|
||||
impl ProcessLock {
|
||||
pub fn new() -> Result<Self> {
|
||||
let pid_file = dirs::app_home_dir()?.join("mihomo.pid");
|
||||
println!("Creating ProcessLock with PID file: {:?}", pid_file);
|
||||
log::info!(target: "app", "Creating ProcessLock with PID file: {:?}", pid_file);
|
||||
Ok(Self { pid_file })
|
||||
}
|
||||
|
||||
fn is_target_process(process_name: &str) -> bool {
|
||||
let name = process_name.to_lowercase();
|
||||
(name.contains("mihomo") || name.contains("clash"))
|
||||
&& !name.contains("clash-verge")
|
||||
&& !name.contains("clash.meta")
|
||||
}
|
||||
|
||||
fn kill_process(pid: Pid, process: &sysinfo::Process) -> bool {
|
||||
let process_name = process.name().to_string_lossy().to_lowercase();
|
||||
let process_pid = pid.as_u32();
|
||||
|
||||
println!("Force killing process (PID: {}, Name: {})", process_pid, process_name);
|
||||
log::info!(target: "app", "Force killing process (PID: {}, Name: {})", process_pid, process_name);
|
||||
|
||||
// 直接使用 SIGKILL 强制终止
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
println!("Sending SIGKILL to process {}", process_pid);
|
||||
log::info!(target: "app", "Sending SIGKILL to process {}", process_pid);
|
||||
let _ = process.kill_with(Signal::Kill);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
println!("Force killing process {}", process_pid);
|
||||
log::info!(target: "app", "Force killing process {}", process_pid);
|
||||
process.kill();
|
||||
}
|
||||
|
||||
std::thread::sleep(KILL_WAIT);
|
||||
|
||||
// 检查进程是否还在运行
|
||||
let mut sys = System::new();
|
||||
sys.refresh_all();
|
||||
|
||||
let is_terminated = sys.process(pid).is_none();
|
||||
if !is_terminated {
|
||||
println!("Failed to terminate process {}", process_pid);
|
||||
log::error!(target: "app", "Failed to terminate process {}", process_pid);
|
||||
} else {
|
||||
println!("Process {} has been terminated", process_pid);
|
||||
log::info!(target: "app", "Process {} has been terminated", process_pid);
|
||||
}
|
||||
|
||||
is_terminated
|
||||
}
|
||||
|
||||
fn kill_other_processes(include_current: bool) -> Result<()> {
|
||||
println!("Starting process cleanup (include_current: {})", include_current);
|
||||
log::info!(target: "app", "Starting process cleanup (include_current: {})", include_current);
|
||||
|
||||
let mut sys = System::new();
|
||||
sys.refresh_all();
|
||||
|
||||
let current_pid = std::process::id();
|
||||
let mut failed_kills = Vec::new();
|
||||
|
||||
// 收集需要终止的进程
|
||||
let target_processes: Vec<_> = sys.processes()
|
||||
.iter()
|
||||
.filter(|(pid, process)| {
|
||||
let process_pid = pid.as_u32();
|
||||
let process_name = process.name().to_string_lossy();
|
||||
|
||||
Self::is_target_process(&process_name) &&
|
||||
(include_current || process_pid != current_pid)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 如果没有目标进程,直接返回
|
||||
if target_processes.is_empty() {
|
||||
println!("No target processes found");
|
||||
log::info!(target: "app", "No target processes found");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 终止进程
|
||||
for (pid, process) in target_processes {
|
||||
if !Self::kill_process(*pid, process) {
|
||||
failed_kills.push((pid.as_u32(), process.name().to_string_lossy().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// 最终检查
|
||||
std::thread::sleep(FINAL_WAIT);
|
||||
sys.refresh_all();
|
||||
|
||||
let remaining: Vec<_> = sys.processes()
|
||||
.iter()
|
||||
.filter(|(_, process)| Self::is_target_process(&process.name().to_string_lossy()))
|
||||
.map(|(pid, process)| (pid.as_u32(), process.name().to_string_lossy().to_string()))
|
||||
.collect();
|
||||
|
||||
if !remaining.is_empty() {
|
||||
println!("Failed to terminate processes: {:?}", remaining);
|
||||
log::error!(target: "app", "Failed to terminate processes: {:?}", remaining);
|
||||
bail!("Failed to terminate processes: {:?}", remaining);
|
||||
}
|
||||
|
||||
println!("Process cleanup completed");
|
||||
log::info!(target: "app", "Process cleanup completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn acquire(&self) -> Result<()> {
|
||||
println!("Attempting to acquire process lock");
|
||||
log::info!(target: "app", "Attempting to acquire process lock");
|
||||
|
||||
// 首先尝试终止其他进程
|
||||
Self::kill_other_processes(false)?;
|
||||
|
||||
if self.pid_file.exists() {
|
||||
fs::remove_file(&self.pid_file)?;
|
||||
}
|
||||
|
||||
fs::write(&self.pid_file, std::process::id().to_string())?;
|
||||
println!("Process lock acquired successfully");
|
||||
log::info!(target: "app", "Process lock acquired successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn release(&self) -> Result<()> {
|
||||
println!("Starting release process");
|
||||
log::info!(target: "app", "Starting release process");
|
||||
|
||||
Self::kill_other_processes(true)?;
|
||||
|
||||
if self.pid_file.exists() {
|
||||
println!("Removing PID file");
|
||||
log::info!(target: "app", "Removing PID file");
|
||||
fs::remove_file(&self.pid_file)?;
|
||||
}
|
||||
|
||||
println!("Release process completed");
|
||||
log::info!(target: "app", "Release process completed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessLock {
|
||||
fn drop(&mut self) {
|
||||
if self.pid_file.exists() {
|
||||
println!("ProcessLock being dropped");
|
||||
log::info!(target: "app", "ProcessLock being dropped");
|
||||
let _ = self.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::config::Config;
|
||||
use crate::feat;
|
||||
use crate::core::CoreManager;
|
||||
use anyhow::{Context, Result};
|
||||
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
|
||||
use once_cell::sync::OnceCell;
|
||||
@@ -172,7 +173,17 @@ impl Timer {
|
||||
/// the task runner
|
||||
async fn async_task(uid: String) {
|
||||
log::info!(target: "app", "running timer task `{uid}`");
|
||||
crate::log_err!(feat::update_profile(uid, None).await);
|
||||
|
||||
// 使用更轻量级的更新方式
|
||||
if let Err(e) = feat::update_profile(uid.clone(), None).await {
|
||||
log::error!(target: "app", "timer task update error: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有更新成功后才刷新配置
|
||||
if let Err(e) = CoreManager::global().update_config().await {
|
||||
log::error!(target: "app", "timer task refresh error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -68,64 +68,47 @@ impl Tray {
|
||||
|
||||
pub fn create_systray(&self) -> Result<()> {
|
||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||
let tray_incon_id = TrayIconId::new("main");
|
||||
let tray = app_handle.tray_by_id(&tray_incon_id).unwrap();
|
||||
|
||||
// 创建初始菜单
|
||||
let initial_menu = create_tray_menu(
|
||||
&app_handle,
|
||||
Some("rule"),
|
||||
false,
|
||||
false,
|
||||
Vec::new(),
|
||||
)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
tray.set_show_menu_on_left_click(false)?;
|
||||
|
||||
// 使用 TrayIconBuilder 构建托盘
|
||||
let _tray = tauri::tray::TrayIconBuilder::<Wry>::with_id(TrayIconId::new("main"))
|
||||
.menu(&initial_menu) // 先设置菜单
|
||||
.show_menu_on_left_click(false) // 再禁用左键菜单
|
||||
.on_tray_icon_event(move |_tray, event| {
|
||||
// 处理左键点击事件
|
||||
if let TrayIconEvent::Click {
|
||||
button,
|
||||
button_state: MouseButtonState::Down,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
match button {
|
||||
// 左键点击
|
||||
MouseButton::Left => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 确保菜单不会显示
|
||||
let _ = _tray.set_show_menu_on_left_click(false);
|
||||
}
|
||||
tray.on_tray_icon_event(|_, event| {
|
||||
let tray_event = { Config::verge().latest().tray_event.clone() };
|
||||
let tray_event: String = tray_event.unwrap_or("main_window".into());
|
||||
|
||||
// 获取并执行自定义事件
|
||||
let tray_event = Config::verge()
|
||||
.latest()
|
||||
.tray_event
|
||||
.clone()
|
||||
.unwrap_or("main_window".into());
|
||||
|
||||
match tray_event.as_str() {
|
||||
"system_proxy" => feat::toggle_system_proxy(),
|
||||
"tun_mode" => feat::toggle_tun_mode(),
|
||||
"main_window" => resolve::create_window(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// 其他按钮点击不处理
|
||||
_ => {}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Down,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
match tray_event.as_str() {
|
||||
"system_proxy" => feat::toggle_system_proxy(),
|
||||
"tun_mode" => feat::toggle_tun_mode(None),
|
||||
"main_window" => resolve::create_window(),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_menu_event(on_menu_event)
|
||||
.build(&app_handle)?;
|
||||
|
||||
// 初始化托盘状态
|
||||
self.update_menu()?;
|
||||
self.update_icon(None)?;
|
||||
self.update_tooltip()?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Down,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
match tray_event.as_str() {
|
||||
"system_proxy" => feat::toggle_system_proxy(),
|
||||
"tun_mode" => feat::toggle_tun_mode(None),
|
||||
"main_window" => resolve::create_window(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
tray.on_menu_event(on_menu_event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -242,23 +225,27 @@ impl Tray {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let enable_tray_speed = Config::verge().latest().enable_tray_speed.unwrap_or(true);
|
||||
let is_template =
|
||||
crate::utils::help::is_monochrome_image_from_bytes(&icon_bytes).unwrap_or(false);
|
||||
|
||||
let icon_bytes = if enable_tray_speed {
|
||||
let is_colorful = tray_icon == "colorful";
|
||||
|
||||
// 处理图标和速率
|
||||
let final_icon_bytes = if enable_tray_speed {
|
||||
let rate = rate.or_else(|| {
|
||||
self.speed_rate
|
||||
.lock()
|
||||
.as_ref()
|
||||
.and_then(|speed_rate| speed_rate.get_curent_rate())
|
||||
});
|
||||
|
||||
// 使用新的方法渲染图标和速率
|
||||
SpeedRate::add_speed_text(icon_bytes, rate)?
|
||||
} else {
|
||||
icon_bytes
|
||||
};
|
||||
|
||||
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?));
|
||||
let _ = tray.set_icon_as_template(is_template);
|
||||
// 设置系统托盘图标
|
||||
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&final_icon_bytes)?));
|
||||
// 只对单色图标使用 template 模式
|
||||
let _ = tray.set_icon_as_template(!is_colorful);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -607,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" => {
|
||||
|
||||
@@ -1,34 +1,60 @@
|
||||
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::{ImageBuffer, Rgba};
|
||||
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)>>,
|
||||
last_update: Arc<Mutex<std::time::Instant>>,
|
||||
// 移除 base_image,不再缓存原始图像
|
||||
}
|
||||
|
||||
impl SpeedRate {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rate: Arc::new(Mutex::new((Rate::default(), Rate::default()))),
|
||||
last_update: Arc::new(Mutex::new(std::time::Instant::now())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_and_check_changed(&self, up: u64, down: u64) -> Option<Rate> {
|
||||
let mut rates = self.rate.lock();
|
||||
let mut last_update = self.last_update.lock();
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
// 限制更新频率为每秒最多2次(500ms)
|
||||
if now.duration_since(*last_update).as_millis() < 500 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (current, previous) = &mut *rates;
|
||||
|
||||
// 如果速率变化不大(小于10%),则不更新
|
||||
let should_update = {
|
||||
let up_change = (current.up as f64 - up as f64).abs() / (current.up as f64 + 1.0);
|
||||
let down_change =
|
||||
(current.down as f64 - down as f64).abs() / (current.down as f64 + 1.0);
|
||||
up_change > 0.1 || down_change > 0.1
|
||||
};
|
||||
|
||||
if !should_update {
|
||||
return None;
|
||||
}
|
||||
|
||||
*previous = current.clone();
|
||||
current.up = up;
|
||||
current.down = down;
|
||||
*last_update = now;
|
||||
|
||||
if previous != current {
|
||||
Some(current.clone())
|
||||
@@ -43,99 +69,118 @@ impl SpeedRate {
|
||||
Some(current.clone())
|
||||
}
|
||||
|
||||
pub fn add_speed_text(icon: Vec<u8>, rate: Option<Rate>) -> Result<Vec<u8>> {
|
||||
// 分离图标加载和速率渲染
|
||||
pub fn add_speed_text(icon_bytes: Vec<u8>, rate: Option<Rate>) -> Result<Vec<u8>> {
|
||||
let rate = rate.unwrap_or(Rate { up: 0, down: 0 });
|
||||
let img = image::load_from_memory(&icon)?;
|
||||
let (width, height) = (img.width(), img.height());
|
||||
|
||||
let font =
|
||||
Font::try_from_bytes(include_bytes!("../../../assets/fonts/FiraCode-Medium.ttf")).unwrap();
|
||||
// 加载原始图标
|
||||
let icon_image = image::load_from_memory(&icon_bytes)?;
|
||||
let (icon_width, icon_height) = (icon_image.width(), icon_image.height());
|
||||
|
||||
// 修改颜色和阴影参数
|
||||
let text_color = Rgba([255u8, 255u8, 255u8, 255u8]); // 纯白色
|
||||
let shadow_color = Rgba([0u8, 0u8, 0u8, 120u8]); // 降低阴影不透明度
|
||||
let base_size = height as f32 * 0.6; // 保持字体大小
|
||||
let scale = Scale::uniform(base_size);
|
||||
// 判断是否为彩色图标
|
||||
let is_colorful =
|
||||
!crate::utils::help::is_monochrome_image_from_bytes(&icon_bytes).unwrap_or(false);
|
||||
|
||||
// 增加文本宽度和间距
|
||||
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 {
|
||||
let pixel = icon_image.get_pixel(x, y);
|
||||
combined_image.put_pixel(x, y, pixel);
|
||||
}
|
||||
}
|
||||
|
||||
// 选择文本颜色
|
||||
let (text_color, shadow_color) = if is_colorful {
|
||||
// 彩色图标使用黑色文本和轻微白色阴影
|
||||
(
|
||||
Rgba([255u8, 255u8, 255u8, 255u8]),
|
||||
Rgba([0u8, 0u8, 0u8, 160u8]),
|
||||
)
|
||||
} else {
|
||||
// 单色图标使用白色文本和轻微黑色阴影
|
||||
(
|
||||
Rgba([255u8, 255u8, 255u8, 255u8]),
|
||||
Rgba([0u8, 0u8, 0u8, 120u8]),
|
||||
)
|
||||
};
|
||||
// 减小字体大小以适应文本区域
|
||||
let font_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_width = font
|
||||
.layout(&up_text, scale, rusttype::Point { x: 0.0, y: 0.0 })
|
||||
.map(|g| g.position().x + g.unpositioned().h_metrics().advance_width)
|
||||
.last()
|
||||
.unwrap_or(0.0);
|
||||
// 计算文本位置,确保垂直间距合适
|
||||
// 修改文本位置为居右显示
|
||||
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 down_width = font
|
||||
.layout(&down_text, scale, rusttype::Point { x: 0.0, y: 0.0 })
|
||||
.map(|g| g.position().x + g.unpositioned().h_metrics().advance_width)
|
||||
.last()
|
||||
.unwrap_or(0.0);
|
||||
// 计算右对齐的文本位置
|
||||
let up_text_x = total_width - up_text_width;
|
||||
let down_text_x = total_width - down_text_width;
|
||||
|
||||
let icon_text_gap = 40; // 图标和文字之间的间隔
|
||||
|
||||
// 计算所需的总宽度:图标宽度 + 间隔 + 最大文本宽度
|
||||
let max_text_width = up_width.max(down_width);
|
||||
let total_width = width as f32 + icon_text_gap as f32 + max_text_width;
|
||||
|
||||
let mut image = ImageBuffer::new(total_width.ceil() as u32, height);
|
||||
// 将图标绘制在最左边
|
||||
image::imageops::replace(&mut image, &img, 0_i64, 0_i64);
|
||||
// 优化垂直位置,使速率显示的高度和上下间距正好等于图标大小
|
||||
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;
|
||||
|
||||
// 计算文字的起始x坐标(图标宽度 + 间隔)
|
||||
let text_start_x = width as i32 + icon_text_gap as i32;
|
||||
|
||||
// 添加阴影效果
|
||||
// 绘制速率文本(先阴影后文字)
|
||||
let shadow_offset = 1;
|
||||
|
||||
// 计算垂直位置
|
||||
let up_y = 0; // 上行速率紧贴顶部
|
||||
let down_y = height as i32 - base_size as i32; // 下行速率紧贴底部
|
||||
|
||||
// 绘制上行速率(先画阴影,再画文字)
|
||||
// 绘制上行速率
|
||||
draw_text_mut(
|
||||
&mut image,
|
||||
&mut combined_image,
|
||||
shadow_color,
|
||||
text_start_x + shadow_offset,
|
||||
up_text_x as i32 + shadow_offset,
|
||||
up_y + shadow_offset,
|
||||
scale,
|
||||
&font,
|
||||
&up_text,
|
||||
);
|
||||
draw_text_mut(
|
||||
&mut image,
|
||||
&mut combined_image,
|
||||
text_color,
|
||||
text_start_x,
|
||||
up_text_x as i32,
|
||||
up_y,
|
||||
scale,
|
||||
&font,
|
||||
&up_text,
|
||||
);
|
||||
|
||||
// 绘制下行速率(先画阴影,再画文字)
|
||||
// 绘制下行速率
|
||||
draw_text_mut(
|
||||
&mut image,
|
||||
&mut combined_image,
|
||||
shadow_color,
|
||||
text_start_x + shadow_offset,
|
||||
down_text_x as i32 + shadow_offset,
|
||||
down_y + shadow_offset,
|
||||
scale,
|
||||
&font,
|
||||
&down_text,
|
||||
);
|
||||
draw_text_mut(
|
||||
&mut image,
|
||||
&mut combined_image,
|
||||
text_color,
|
||||
text_start_x,
|
||||
down_text_x as i32,
|
||||
down_y,
|
||||
scale,
|
||||
&font,
|
||||
&down_text,
|
||||
);
|
||||
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
let mut cursor = Cursor::new(&mut bytes);
|
||||
image.write_to(&mut cursor, image::ImageFormat::Png)?;
|
||||
// 将结果转换为 PNG 数据
|
||||
let mut bytes = Vec::new();
|
||||
combined_image.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
@@ -155,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((
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+126
-42
@@ -1,55 +1,139 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::{Mapping, Sequence, Value};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SeqMap {
|
||||
prepend: Sequence,
|
||||
append: Sequence,
|
||||
delete: Sequence,
|
||||
pub prepend: Sequence,
|
||||
pub append: Sequence,
|
||||
pub delete: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn use_seq(seq_map: SeqMap, config: Mapping, name: &str) -> Mapping {
|
||||
let mut prepend = seq_map.prepend;
|
||||
let append = seq_map.append;
|
||||
let delete = seq_map.delete;
|
||||
pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
|
||||
let SeqMap {
|
||||
prepend,
|
||||
append,
|
||||
delete,
|
||||
} = seq;
|
||||
|
||||
let origin_seq = config.get(name).map_or(Sequence::default(), |val| {
|
||||
val.as_sequence().unwrap_or(&Sequence::default()).clone()
|
||||
});
|
||||
let mut seq = origin_seq.clone();
|
||||
let mut new_seq = Sequence::new();
|
||||
new_seq.extend(prepend);
|
||||
|
||||
let mut delete_names = Vec::new();
|
||||
for item in delete {
|
||||
let item = item.clone();
|
||||
if let Some(name) = if item.is_string() {
|
||||
Some(item)
|
||||
} else {
|
||||
item.get("name").cloned()
|
||||
} {
|
||||
delete_names.push(name.clone());
|
||||
if let Some(Value::Sequence(origin)) = config.get(field) {
|
||||
// Filter out deleted items
|
||||
let filtered: Sequence = origin
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
if let Value::String(s) = item {
|
||||
!delete.contains(s)
|
||||
} else if let Value::Mapping(m) = item {
|
||||
if let Some(Value::String(name)) = m.get("name") {
|
||||
!delete.contains(name)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
new_seq.extend(filtered);
|
||||
}
|
||||
|
||||
new_seq.extend(append);
|
||||
config.insert(Value::String(field.into()), Value::Sequence(new_seq));
|
||||
|
||||
// If this is proxies field, we also need to filter proxy-groups
|
||||
if field == "proxies" {
|
||||
if let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups") {
|
||||
let mut new_groups = Sequence::new();
|
||||
for group in groups {
|
||||
if let Value::Mapping(group_map) = group {
|
||||
let mut new_group = group_map.clone();
|
||||
if let Some(Value::Sequence(proxies)) = group_map.get("proxies") {
|
||||
let filtered_proxies: Sequence = proxies
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
if let Value::String(name) = p {
|
||||
!delete.contains(name)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
new_group.insert(
|
||||
Value::String("proxies".into()),
|
||||
Value::Sequence(filtered_proxies),
|
||||
);
|
||||
}
|
||||
new_groups.push(Value::Mapping(new_group));
|
||||
} else {
|
||||
new_groups.push(group.clone());
|
||||
}
|
||||
}
|
||||
config.insert(
|
||||
Value::String("proxy-groups".into()),
|
||||
Value::Sequence(new_groups),
|
||||
);
|
||||
}
|
||||
}
|
||||
seq.retain(|x| {
|
||||
if let Some(x_name) = if x.is_string() {
|
||||
Some(x)
|
||||
} else {
|
||||
x.get("name")
|
||||
} {
|
||||
!delete_names.contains(x_name)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
prepend.reverse();
|
||||
for item in prepend {
|
||||
seq.insert(0, item);
|
||||
}
|
||||
|
||||
for item in append {
|
||||
seq.push(item);
|
||||
}
|
||||
|
||||
let mut config = config.clone();
|
||||
config.insert(Value::from(name), Value::from(seq));
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use serde_yaml::Value;
|
||||
|
||||
#[test]
|
||||
fn test_delete_proxy_and_references() {
|
||||
let config_str = r#"
|
||||
proxies:
|
||||
- name: "proxy1"
|
||||
type: "ss"
|
||||
- name: "proxy2"
|
||||
type: "vmess"
|
||||
proxy-groups:
|
||||
- name: "group1"
|
||||
type: "select"
|
||||
proxies:
|
||||
- "proxy1"
|
||||
- "proxy2"
|
||||
- name: "group2"
|
||||
type: "select"
|
||||
proxies:
|
||||
- "proxy1"
|
||||
"#;
|
||||
let mut config: Mapping = serde_yaml::from_str(config_str).unwrap();
|
||||
|
||||
let seq = SeqMap {
|
||||
prepend: Sequence::new(),
|
||||
append: Sequence::new(),
|
||||
delete: vec!["proxy1".to_string()],
|
||||
};
|
||||
|
||||
config = use_seq(seq, config, "proxies");
|
||||
|
||||
// Check if proxy1 is removed from proxies
|
||||
let proxies = config.get("proxies").unwrap().as_sequence().unwrap();
|
||||
assert_eq!(proxies.len(), 1);
|
||||
assert_eq!(
|
||||
proxies[0].as_mapping().unwrap().get("name").unwrap().as_str().unwrap(),
|
||||
"proxy2"
|
||||
);
|
||||
|
||||
// Check if proxy1 is removed from all groups
|
||||
let groups = config.get("proxy-groups").unwrap().as_sequence().unwrap();
|
||||
let group1_proxies = groups[0].as_mapping().unwrap()
|
||||
.get("proxies").unwrap().as_sequence().unwrap();
|
||||
let group2_proxies = groups[1].as_mapping().unwrap()
|
||||
.get("proxies").unwrap().as_sequence().unwrap();
|
||||
|
||||
assert_eq!(group1_proxies.len(), 1);
|
||||
assert_eq!(group1_proxies[0].as_str().unwrap(), "proxy2");
|
||||
assert_eq!(group2_proxies.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,578 +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};
|
||||
|
||||
// 打开面板
|
||||
#[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));
|
||||
let _ = app_handle.save_window_state(StateFlags::default());
|
||||
tauri::process::restart(&app_handle.env());
|
||||
});
|
||||
}
|
||||
|
||||
// 切换模式 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 {
|
||||
match cmds::patch_profiles_config_by_profile_index(profile_index).await {
|
||||
Ok(_) => {
|
||||
let _ = tray::Tray::global().update_menu();
|
||||
handle::Handle::refresh_verge();
|
||||
},
|
||||
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<()> {
|
||||
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 {
|
||||
None // 直接更新
|
||||
} else if item.url.is_none() {
|
||||
bail!("failed to get the profile item url");
|
||||
} else {
|
||||
Some((item.url.clone().unwrap(), item.option.clone()))
|
||||
}
|
||||
};
|
||||
|
||||
let should_update = match url_opt {
|
||||
Some((url, opt)) => {
|
||||
let merged_opt = PrfOption::merge(opt, option);
|
||||
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
|
||||
let profiles = Config::profiles();
|
||||
let mut profiles = profiles.latest();
|
||||
profiles.update_item(uid.clone(), item)?;
|
||||
|
||||
Some(uid) == profiles.get_current()
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
if should_update {
|
||||
match CoreManager::global().update_config().await {
|
||||
Ok(_) => {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
Err(err) => {
|
||||
handle::Handle::notice_message("set_config::error", format!("{err}"));
|
||||
log::error!(target: "app", "{err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// copy env variable
|
||||
pub fn copy_clash_env() {
|
||||
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://127.0.0.1:{}", port);
|
||||
let socks5_proxy = format!("socks5://127.0.0.1:{}", 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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
+147
-49
@@ -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,58 +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
|
||||
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)]
|
||||
@@ -133,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();
|
||||
@@ -143,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod sysinfo;
|
||||
pub mod mihomo;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -161,7 +166,7 @@ pub fn create_window() {
|
||||
.maximizable(true)
|
||||
.additional_browser_args("--enable-features=msWebView2EnableDraggableRegions --disable-features=OverscrollHistoryNavigation,msExperimentalScrolling")
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.shadow(true)
|
||||
.build();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -196,6 +201,9 @@ pub fn create_window() {
|
||||
log::info!(target: "app", "Window created successfully, attempting to show");
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
|
||||
// 设置窗口状态监控,实时保存窗口位置和大小
|
||||
crate::feat::setup_window_state_monitor(&app_handle);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to create window: {:?}", e);
|
||||
|
||||
+12
-13
@@ -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.0.4-alpha",
|
||||
"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": {
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
},
|
||||
"app": {
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/tray-icon.ico",
|
||||
"iconAsTemplate": true
|
||||
"iconPath": "icons/tray-icon.ico"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -19,5 +19,13 @@
|
||||
"template": "./packages/windows/installer.nsi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/tray-icon.ico",
|
||||
"iconAsTemplate": true,
|
||||
"showMenuOnLeftClick": false
|
||||
},
|
||||
"windows": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 2px;
|
||||
right: 1px;
|
||||
bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface InnerProps {
|
||||
}
|
||||
|
||||
const NoticeInner = (props: InnerProps) => {
|
||||
const { type, message, duration = 1500, onClose } = props;
|
||||
const { type, message, duration, onClose } = props;
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const { verge } = useVerge();
|
||||
@@ -43,7 +43,7 @@ const NoticeInner = (props: InnerProps) => {
|
||||
|
||||
appWindow.theme().then((m) => m && setIsDark(m === "dark"));
|
||||
const unlisten = appWindow.onThemeChanged((e) =>
|
||||
setIsDark(e.payload === "dark")
|
||||
setIsDark(e.payload === "dark"),
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -72,7 +72,7 @@ const NoticeInner = (props: InnerProps) => {
|
||||
<Snackbar
|
||||
open={visible}
|
||||
anchorOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
autoHideDuration={duration}
|
||||
autoHideDuration={duration === -1 ? null : duration || 1500}
|
||||
onClose={onAutoClose}
|
||||
message={msgElement}
|
||||
sx={{
|
||||
@@ -103,27 +103,59 @@ interface NoticeInstance {
|
||||
|
||||
let parent: HTMLDivElement = null!;
|
||||
|
||||
// @ts-ignore
|
||||
export const Notice: NoticeInstance = (props) => {
|
||||
const { type, message, duration } = props;
|
||||
|
||||
// 验证必要的参数
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parent) {
|
||||
parent = document.createElement("div");
|
||||
parent.setAttribute("id", "notice-container"); // 添加 id 便于调试
|
||||
document.body.appendChild(parent);
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
parent.appendChild(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
|
||||
const onUnmount = () => {
|
||||
root.unmount();
|
||||
if (parent) setTimeout(() => parent.removeChild(container), 500);
|
||||
if (parent && container.parentNode === parent) {
|
||||
setTimeout(() => {
|
||||
parent.removeChild(container);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
root.render(<NoticeInner {...props} onClose={onUnmount} />);
|
||||
root.render(
|
||||
<NoticeInner
|
||||
type={type}
|
||||
message={message}
|
||||
duration={duration || 1500}
|
||||
onClose={onUnmount}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
(["info", "error", "success"] as const).forEach((type) => {
|
||||
Notice[type] = (message, duration) => {
|
||||
setTimeout(() => Notice({ type, message, duration }), 0);
|
||||
const createNoticeTypeFactory =
|
||||
(type: keyof NoticeInstance) => (message: ReactNode, duration?: number) => {
|
||||
// 确保消息不为空
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notice({
|
||||
type,
|
||||
message,
|
||||
// 错误类型通知显示 8 秒,其他类型默认 1.5 秒
|
||||
duration: type === "error" ? 8000 : duration || 1500,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Notice.info = createNoticeTypeFactory("info");
|
||||
Notice.error = createNoticeTypeFactory("error");
|
||||
Notice.success = createNoticeTypeFactory("success");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, SvgIcon, TextField, styled } from "@mui/material";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import { ChangeEvent, useEffect, useRef, useState, useMemo } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
|
||||
@@ -22,7 +22,20 @@ type SearchProps = {
|
||||
onSearch: (match: (content: string) => boolean, state: SearchState) => void;
|
||||
};
|
||||
|
||||
export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
const StyledTextField = styled(TextField)(({ theme }) => ({
|
||||
"& .MuiInputBase-root": {
|
||||
background: theme.palette.mode === "light" ? "#fff" : undefined,
|
||||
paddingRight: "4px",
|
||||
},
|
||||
"& .MuiInputBase-root svg[aria-label='active'] path": {
|
||||
fill: theme.palette.primary.light,
|
||||
},
|
||||
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
|
||||
fill: "#A7A7A7",
|
||||
},
|
||||
}));
|
||||
|
||||
export const BaseSearchBox = (props: SearchProps) => {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [matchCase, setMatchCase] = useState(props.matchCase ?? false);
|
||||
@@ -43,58 +56,58 @@ export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
inheritViewBox: true,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
onChange({
|
||||
target: inputRef.current,
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
}, [matchCase, matchWholeWord, useRegularExpression]);
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
props.onSearch(
|
||||
(content) => doSearch([content], e.target?.value ?? "").length > 0,
|
||||
{
|
||||
text: e.target?.value ?? "",
|
||||
matchCase,
|
||||
matchWholeWord,
|
||||
useRegularExpression,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const doSearch = (searchList: string[], searchItem: string) => {
|
||||
setErrorMessage("");
|
||||
return searchList.filter((item) => {
|
||||
const createMatcher = useMemo(() => {
|
||||
return (searchText: string) => {
|
||||
try {
|
||||
let searchItemCopy = searchItem;
|
||||
if (!matchCase) {
|
||||
item = item.toLowerCase();
|
||||
searchItemCopy = searchItemCopy.toLowerCase();
|
||||
}
|
||||
if (matchWholeWord) {
|
||||
const regex = new RegExp(`\\b${searchItemCopy}\\b`);
|
||||
return (content: string) => {
|
||||
if (!searchText) return true;
|
||||
|
||||
let item = !matchCase ? content.toLowerCase() : content;
|
||||
let searchItem = !matchCase ? searchText.toLowerCase() : searchText;
|
||||
|
||||
if (useRegularExpression) {
|
||||
const regexWithOptions = new RegExp(searchItemCopy);
|
||||
return regexWithOptions.test(item) && regex.test(item);
|
||||
} else {
|
||||
return regex.test(item);
|
||||
return new RegExp(searchItem).test(item);
|
||||
}
|
||||
} else if (useRegularExpression) {
|
||||
const regex = new RegExp(searchItemCopy);
|
||||
return regex.test(item);
|
||||
} else {
|
||||
return item.includes(searchItemCopy);
|
||||
}
|
||||
|
||||
if (matchWholeWord) {
|
||||
return new RegExp(`\\b${searchItem}\\b`).test(item);
|
||||
}
|
||||
|
||||
return item.includes(searchItem);
|
||||
};
|
||||
} catch (err) {
|
||||
setErrorMessage(`${err}`);
|
||||
return () => true;
|
||||
}
|
||||
};
|
||||
}, [matchCase, matchWholeWord, useRegularExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
const value = inputRef.current.value;
|
||||
setErrorMessage("");
|
||||
props.onSearch(createMatcher(value), {
|
||||
text: value,
|
||||
matchCase,
|
||||
matchWholeWord,
|
||||
useRegularExpression,
|
||||
});
|
||||
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]);
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const value = e.target?.value ?? "";
|
||||
setErrorMessage("");
|
||||
props.onSearch(createMatcher(value), {
|
||||
text: value,
|
||||
matchCase,
|
||||
matchWholeWord,
|
||||
useRegularExpression,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={errorMessage} placement="bottom-start">
|
||||
<TextField
|
||||
<StyledTextField
|
||||
autoComplete="new-password"
|
||||
inputRef={inputRef}
|
||||
hiddenLabel
|
||||
@@ -115,9 +128,7 @@ export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
component={matchCaseIcon}
|
||||
{...iconStyle}
|
||||
aria-label={matchCase ? "active" : "inactive"}
|
||||
onClick={() => {
|
||||
setMatchCase(!matchCase);
|
||||
}}
|
||||
onClick={() => setMatchCase(!matchCase)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -127,9 +138,7 @@ export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
component={matchWholeWordIcon}
|
||||
{...iconStyle}
|
||||
aria-label={matchWholeWord ? "active" : "inactive"}
|
||||
onClick={() => {
|
||||
setMatchWholeWord(!matchWholeWord);
|
||||
}}
|
||||
onClick={() => setMatchWholeWord(!matchWholeWord)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -139,28 +148,16 @@ export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
component={useRegularExpressionIcon}
|
||||
aria-label={useRegularExpression ? "active" : "inactive"}
|
||||
{...iconStyle}
|
||||
onClick={() => {
|
||||
setUseRegularExpression(!useRegularExpression);
|
||||
}}
|
||||
onClick={() =>
|
||||
setUseRegularExpression(!useRegularExpression)
|
||||
}
|
||||
/>{" "}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})(({ theme }) => ({
|
||||
"& .MuiInputBase-root": {
|
||||
background: theme.palette.mode === "light" ? "#fff" : undefined,
|
||||
"padding-right": "4px",
|
||||
},
|
||||
"& .MuiInputBase-root svg[aria-label='active'] path": {
|
||||
fill: theme.palette.primary.light,
|
||||
},
|
||||
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
|
||||
fill: "#A7A7A7",
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo, useState } from "react";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import { truncateStr } from "@/utils/truncate-str";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
@@ -21,12 +21,24 @@ export const ConnectionTable = (props: Props) => {
|
||||
Partial<Record<keyof IConnectionsItem, boolean>>
|
||||
>({});
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
|
||||
() => {
|
||||
const saved = localStorage.getItem("connection-table-widths");
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
},
|
||||
);
|
||||
|
||||
const [columns] = useState<GridColDef[]>([
|
||||
{ field: "host", headerName: t("Host"), flex: 220, minWidth: 220 },
|
||||
{
|
||||
field: "host",
|
||||
headerName: t("Host"),
|
||||
width: columnWidths["host"] || 220,
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: "download",
|
||||
headerName: t("Downloaded"),
|
||||
width: 88,
|
||||
width: columnWidths["download"] || 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" "),
|
||||
@@ -34,7 +46,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
{
|
||||
field: "upload",
|
||||
headerName: t("Uploaded"),
|
||||
width: 88,
|
||||
width: columnWidths["upload"] || 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" "),
|
||||
@@ -42,7 +54,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
{
|
||||
field: "dlSpeed",
|
||||
headerName: t("DL Speed"),
|
||||
width: 88,
|
||||
width: columnWidths["dlSpeed"] || 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
|
||||
@@ -50,18 +62,33 @@ export const ConnectionTable = (props: Props) => {
|
||||
{
|
||||
field: "ulSpeed",
|
||||
headerName: t("UL Speed"),
|
||||
width: 88,
|
||||
width: columnWidths["ulSpeed"] || 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
|
||||
},
|
||||
{ field: "chains", headerName: t("Chains"), flex: 360, minWidth: 360 },
|
||||
{ field: "rule", headerName: t("Rule"), flex: 300, minWidth: 250 },
|
||||
{ field: "process", headerName: t("Process"), flex: 240, minWidth: 120 },
|
||||
{
|
||||
field: "chains",
|
||||
headerName: t("Chains"),
|
||||
width: columnWidths["chains"] || 340,
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: "rule",
|
||||
headerName: t("Rule"),
|
||||
width: columnWidths["rule"] || 280,
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: "process",
|
||||
headerName: t("Process"),
|
||||
width: columnWidths["process"] || 220,
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
field: "time",
|
||||
headerName: t("Time"),
|
||||
flex: 120,
|
||||
width: columnWidths["time"] || 120,
|
||||
minWidth: 100,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
@@ -69,21 +96,51 @@ export const ConnectionTable = (props: Props) => {
|
||||
new Date(v2).getTime() - new Date(v1).getTime(),
|
||||
valueFormatter: (value: number) => dayjs(value).fromNow(),
|
||||
},
|
||||
{ field: "source", headerName: t("Source"), flex: 200, minWidth: 130 },
|
||||
{
|
||||
field: "source",
|
||||
headerName: t("Source"),
|
||||
width: columnWidths["source"] || 200,
|
||||
minWidth: 130,
|
||||
},
|
||||
{
|
||||
field: "remoteDestination",
|
||||
headerName: t("Destination"),
|
||||
flex: 200,
|
||||
width: columnWidths["remoteDestination"] || 200,
|
||||
minWidth: 130,
|
||||
},
|
||||
{ field: "type", headerName: t("Type"), flex: 160, minWidth: 100 },
|
||||
{
|
||||
field: "type",
|
||||
headerName: t("Type"),
|
||||
width: columnWidths["type"] || 160,
|
||||
minWidth: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Saving column widths:", columnWidths);
|
||||
localStorage.setItem(
|
||||
"connection-table-widths",
|
||||
JSON.stringify(columnWidths),
|
||||
);
|
||||
}, [columnWidths]);
|
||||
|
||||
const handleColumnResize = (params: GridColumnResizeParams) => {
|
||||
const { colDef, width } = params;
|
||||
console.log("Column resize:", colDef.field, width);
|
||||
setColumnWidths((prev) => ({
|
||||
...prev,
|
||||
[colDef.field]: width,
|
||||
}));
|
||||
};
|
||||
|
||||
const connRows = useMemo(() => {
|
||||
return connections.map((each) => {
|
||||
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
|
||||
@@ -98,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,
|
||||
};
|
||||
@@ -115,9 +172,14 @@ export const ConnectionTable = (props: Props) => {
|
||||
sx={{
|
||||
border: "none",
|
||||
"div:focus": { outline: "none !important" },
|
||||
"& .MuiDataGrid-columnHeader": {
|
||||
userSelect: "none",
|
||||
},
|
||||
}}
|
||||
columnVisibilityModel={columnVisible}
|
||||
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
|
||||
onColumnResize={handleColumnResize}
|
||||
disableColumnMenu={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -85,9 +85,9 @@ const LogItem = ({ value, searchState }: Props) => {
|
||||
return (
|
||||
<Item>
|
||||
<div>
|
||||
<span className="time">{value.time}</span>
|
||||
<span className="time">{renderHighlightText(value.time || "")}</span>
|
||||
<span className="type" data-type={value.type.toLowerCase()}>
|
||||
{value.type}
|
||||
{renderHighlightText(value.type)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import {
|
||||
@@ -16,6 +16,175 @@ import { ProxyRender } from "./proxy-render";
|
||||
import delayManager from "@/services/delay";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollTopButton } from "../layout/scroll-top-button";
|
||||
import { Box, styled } from "@mui/material";
|
||||
import { memo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式
|
||||
const AlphabetSelector = styled(Box)(({ theme }) => ({
|
||||
position: "fixed",
|
||||
right: 4,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "transparent",
|
||||
zIndex: 1000,
|
||||
gap: "2px",
|
||||
// padding: "4px 2px",
|
||||
willChange: "transform",
|
||||
"&:hover": {
|
||||
background: theme.palette.background.paper,
|
||||
boxShadow: theme.shadows[2],
|
||||
borderRadius: "8px",
|
||||
},
|
||||
"& .scroll-container": {
|
||||
overflow: "hidden",
|
||||
maxHeight: "inherit",
|
||||
willChange: "transform",
|
||||
},
|
||||
"& .letter-container": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
transition: "transform 0.2s ease",
|
||||
willChange: "transform",
|
||||
},
|
||||
"& .letter": {
|
||||
padding: "1px 4px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
color: theme.palette.text.secondary,
|
||||
position: "relative",
|
||||
width: "1.5em",
|
||||
height: "1.5em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
transform: "scale(1) translateZ(0)",
|
||||
backfaceVisibility: "hidden",
|
||||
borderRadius: "6px",
|
||||
"&:hover": {
|
||||
color: theme.palette.primary.main,
|
||||
transform: "scale(1.4) translateZ(0)",
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 创建一个单独的 Tooltip 组件
|
||||
const Tooltip = styled("div")(({ theme }) => ({
|
||||
position: "fixed",
|
||||
background: theme.palette.background.paper,
|
||||
padding: "4px 8px",
|
||||
borderRadius: "6px",
|
||||
boxShadow: theme.shadows[3],
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "16px",
|
||||
color: theme.palette.text.primary,
|
||||
pointerEvents: "none",
|
||||
"&::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
right: "-4px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderTop: "4px solid transparent",
|
||||
borderBottom: "4px solid transparent",
|
||||
borderLeft: `4px solid ${theme.palette.background.paper}`,
|
||||
},
|
||||
}));
|
||||
|
||||
// 抽离字母选择器子组件
|
||||
const LetterItem = memo(
|
||||
({
|
||||
name,
|
||||
onClick,
|
||||
getFirstChar,
|
||||
}: {
|
||||
name: string;
|
||||
onClick: (name: string) => void;
|
||||
getFirstChar: (str: string) => string;
|
||||
}) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const letterRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
});
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const updateTooltipPosition = useCallback(() => {
|
||||
if (!letterRef.current) return;
|
||||
const rect = letterRef.current.getBoundingClientRect();
|
||||
setTooltipPosition({
|
||||
top: rect.top + rect.height / 2,
|
||||
right: window.innerWidth - rect.left + 8,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTooltip) {
|
||||
updateTooltipPosition();
|
||||
}
|
||||
}, [showTooltip, updateTooltipPosition]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setShowTooltip(true);
|
||||
// 添加 200ms 的延迟,避免鼠标快速划过时触发滚动
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
onClick(name);
|
||||
}, 100);
|
||||
}, [name, onClick]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setShowTooltip(false);
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={letterRef}
|
||||
className="letter"
|
||||
onClick={() => onClick(name)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<span>{getFirstChar(name)}</span>
|
||||
</div>
|
||||
{showTooltip &&
|
||||
createPortal(
|
||||
<Tooltip
|
||||
style={{
|
||||
top: tooltipPosition.top,
|
||||
right: tooltipPosition.right,
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Tooltip>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface Props {
|
||||
mode: string;
|
||||
@@ -32,22 +201,130 @@ export const ProxyGroups = (props: Props) => {
|
||||
const timeout = verge?.default_latency_timeout || 10000;
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const scrollPositionRef = useRef<Record<string, number>>({});
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
const scrollerRef = useRef<Element | null>(null);
|
||||
const letterContainerRef = useRef<HTMLDivElement>(null);
|
||||
const alphabetSelectorRef = useRef<HTMLDivElement>(null);
|
||||
const [maxHeight, setMaxHeight] = useState("auto");
|
||||
|
||||
// 添加滚动处理函数
|
||||
const handleScroll = (e: any) => {
|
||||
const scrollTop = e.target.scrollTop;
|
||||
setShowScrollTop(scrollTop > 100);
|
||||
};
|
||||
// 使用useMemo缓存字母索引数据
|
||||
const { groupFirstLetters, letterIndexMap } = useMemo(() => {
|
||||
const letters = new Set<string>();
|
||||
const indexMap: Record<string, number> = {};
|
||||
|
||||
renderList.forEach((item, index) => {
|
||||
if (item.type === 0) {
|
||||
const fullName = item.group.name;
|
||||
letters.add(fullName);
|
||||
if (!(fullName in indexMap)) {
|
||||
indexMap[fullName] = index;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
groupFirstLetters: Array.from(letters),
|
||||
letterIndexMap: indexMap,
|
||||
};
|
||||
}, [renderList]);
|
||||
|
||||
// 缓存getFirstChar函数
|
||||
const getFirstChar = useCallback((str: string) => {
|
||||
const regex =
|
||||
/\p{Regional_Indicator}{2}|\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
|
||||
const match = str.match(regex);
|
||||
return match ? match[0] : str.charAt(0);
|
||||
}, []);
|
||||
|
||||
// 从 localStorage 恢复滚动位置
|
||||
useEffect(() => {
|
||||
if (renderList.length === 0) return;
|
||||
|
||||
try {
|
||||
const savedPositions = localStorage.getItem("proxy-scroll-positions");
|
||||
if (savedPositions) {
|
||||
const positions = JSON.parse(savedPositions);
|
||||
scrollPositionRef.current = positions;
|
||||
const savedPosition = positions[mode];
|
||||
|
||||
if (savedPosition !== undefined) {
|
||||
setTimeout(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: savedPosition,
|
||||
behavior: "auto",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error restoring scroll position:", e);
|
||||
}
|
||||
}, [mode, renderList]);
|
||||
|
||||
// 改为使用节流函数保存滚动位置
|
||||
const saveScrollPosition = useCallback(
|
||||
(scrollTop: number) => {
|
||||
try {
|
||||
scrollPositionRef.current[mode] = scrollTop;
|
||||
localStorage.setItem(
|
||||
"proxy-scroll-positions",
|
||||
JSON.stringify(scrollPositionRef.current),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error saving scroll position:", e);
|
||||
}
|
||||
},
|
||||
[mode],
|
||||
);
|
||||
|
||||
// 使用改进的滚动处理
|
||||
const handleScroll = useCallback(
|
||||
throttle((e: any) => {
|
||||
const scrollTop = e.target.scrollTop;
|
||||
setShowScrollTop(scrollTop > 100);
|
||||
// 使用稳定的节流来保存位置,而不是setTimeout
|
||||
saveScrollPosition(scrollTop);
|
||||
}, 500), // 增加到500ms以确保平滑滚动
|
||||
[saveScrollPosition],
|
||||
);
|
||||
|
||||
// 添加和清理滚动事件监听器
|
||||
useEffect(() => {
|
||||
const currentScroller = scrollerRef.current;
|
||||
if (currentScroller) {
|
||||
currentScroller.addEventListener("scroll", handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
currentScroller.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}
|
||||
}, [handleScroll]);
|
||||
|
||||
// 滚动到顶部
|
||||
const scrollToTop = () => {
|
||||
const scrollToTop = useCallback(() => {
|
||||
virtuosoRef.current?.scrollTo?.({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
saveScrollPosition(0);
|
||||
}, [saveScrollPosition]);
|
||||
|
||||
// 处理字母点击,使用useCallback
|
||||
const handleLetterClick = useCallback(
|
||||
(name: string) => {
|
||||
const index = letterIndexMap[name];
|
||||
if (index !== undefined) {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index,
|
||||
align: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
[letterIndexMap],
|
||||
);
|
||||
|
||||
// 切换分组的节点代理
|
||||
const handleChangeProxy = useLockFn(
|
||||
@@ -88,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),
|
||||
@@ -95,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();
|
||||
});
|
||||
@@ -134,38 +433,160 @@ 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;
|
||||
|
||||
const container = letterContainerRef.current;
|
||||
const scrollAmount = e.deltaY;
|
||||
const currentTransform = new WebKitCSSMatrix(container.style.transform);
|
||||
const currentY = currentTransform.m42 || 0;
|
||||
|
||||
const containerHeight = container.getBoundingClientRect().height;
|
||||
const parentHeight =
|
||||
container.parentElement?.getBoundingClientRect().height || 0;
|
||||
const maxScroll = Math.max(0, containerHeight - parentHeight);
|
||||
|
||||
let newY = currentY - scrollAmount;
|
||||
newY = Math.min(0, Math.max(-maxScroll, newY));
|
||||
|
||||
container.style.transform = `translateY(${newY}px)`;
|
||||
}, []);
|
||||
|
||||
// 添加和移除滚轮事件监听
|
||||
useEffect(() => {
|
||||
const container = letterContainerRef.current?.parentElement;
|
||||
if (container) {
|
||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => {
|
||||
container.removeEventListener("wheel", handleWheel);
|
||||
};
|
||||
}
|
||||
}, [handleWheel]);
|
||||
|
||||
// 添加窗口大小变化监听和最大高度计算
|
||||
const updateMaxHeight = useCallback(() => {
|
||||
if (!alphabetSelectorRef.current) return;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
const bottomMargin = 60; // 底部边距
|
||||
const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍
|
||||
const availableHeight = windowHeight - (topMargin + bottomMargin);
|
||||
|
||||
// 调整选择器的位置,使其偏下
|
||||
const offsetPercentage =
|
||||
(((topMargin - bottomMargin) / windowHeight) * 100) / 2;
|
||||
alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`;
|
||||
|
||||
setMaxHeight(`${availableHeight}px`);
|
||||
}, []);
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
updateMaxHeight();
|
||||
window.addEventListener("resize", updateMaxHeight);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateMaxHeight);
|
||||
};
|
||||
}, [updateMaxHeight]);
|
||||
|
||||
if (mode === "direct") {
|
||||
return <BaseEmpty text={t("clash_mode_direct")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div
|
||||
style={{ position: "relative", height: "100%", willChange: "transform" }}
|
||||
>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
style={{ height: "calc(100% - 16px)" }}
|
||||
style={{ height: "calc(100% - 14px)" }}
|
||||
totalCount={renderList.length}
|
||||
increaseViewportBy={256}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
overscan={150}
|
||||
defaultItemHeight={56}
|
||||
scrollerRef={(ref) => {
|
||||
if (ref) {
|
||||
ref.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
scrollerRef.current = ref as Element;
|
||||
}}
|
||||
components={{
|
||||
Footer: () => <div style={{ height: "8px" }} />,
|
||||
}}
|
||||
// 添加平滑滚动设置
|
||||
initialScrollTop={scrollPositionRef.current[mode]}
|
||||
computeItemKey={(index) => renderList[index].key}
|
||||
itemContent={(index) => (
|
||||
<>
|
||||
<ProxyRender
|
||||
key={renderList[index].key}
|
||||
item={renderList[index]}
|
||||
indent={mode === "rule" || mode === "script"}
|
||||
onLocation={handleLocation}
|
||||
onCheckAll={handleCheckAll}
|
||||
onHeadState={onHeadState}
|
||||
onChangeProxy={handleChangeProxy}
|
||||
/>
|
||||
</>
|
||||
<ProxyRender
|
||||
key={renderList[index].key}
|
||||
item={renderList[index]}
|
||||
indent={mode === "rule" || mode === "script"}
|
||||
onLocation={handleLocation}
|
||||
onCheckAll={handleCheckAll}
|
||||
onHeadState={onHeadState}
|
||||
onChangeProxy={handleChangeProxy}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
||||
|
||||
<AlphabetSelector ref={alphabetSelectorRef} style={{ maxHeight }}>
|
||||
<div className="scroll-container">
|
||||
<div ref={letterContainerRef} className="letter-container">
|
||||
{groupFirstLetters.map((name) => (
|
||||
<LetterItem
|
||||
key={name}
|
||||
name={name}
|
||||
onClick={handleLetterClick}
|
||||
getFirstChar={getFirstChar}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AlphabetSelector>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 替换简单防抖函数为更优的节流函数
|
||||
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,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -100,7 +100,7 @@ export const ProxyRender = (props: RenderProps) => {
|
||||
<ListItemText
|
||||
primary={<StyledPrimary>{group.name}</StyledPrimary>}
|
||||
secondary={
|
||||
<ListItemTextChild
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
@@ -108,16 +108,19 @@ export const ProxyRender = (props: RenderProps) => {
|
||||
pt: "2px",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ marginTop: "2px" }}>
|
||||
<Box component="span" sx={{ marginTop: "2px" }}>
|
||||
<StyledTypeBox>{group.type}</StyledTypeBox>
|
||||
<StyledSubtitle sx={{ color: "text.secondary" }}>
|
||||
{group.now}
|
||||
</StyledSubtitle>
|
||||
</Box>
|
||||
</ListItemTextChild>
|
||||
</Box>
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
sx: { display: "flex", alignItems: "center", color: "#ccc" },
|
||||
slotProps={{
|
||||
secondary: {
|
||||
component: "div",
|
||||
sx: { display: "flex", alignItems: "center", color: "#ccc" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
|
||||
@@ -204,7 +207,7 @@ export const ProxyRender = (props: RenderProps) => {
|
||||
};
|
||||
|
||||
const StyledPrimary = styled("span")`
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
@@ -219,11 +222,7 @@ const StyledSubtitle = styled("span")`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ListItemTextChild = styled("span")`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
|
||||
const StyledTypeBox = styled(Box)(({ theme }) => ({
|
||||
display: "inline-block",
|
||||
border: "1px solid #ccc",
|
||||
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { getProxies } from "@/services/api";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { filterSort } from "./use-filter-sort";
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "./use-head-state";
|
||||
|
||||
export interface IRenderItem {
|
||||
// 组 | head | item | empty | item col
|
||||
// 组 | head | item | empty | item col
|
||||
type: 0 | 1 | 2 | 3 | 4;
|
||||
key: string;
|
||||
group: IProxyGroupItem;
|
||||
@@ -19,36 +19,59 @@ export interface IRenderItem {
|
||||
col?: number;
|
||||
proxyCol?: IProxyItem[];
|
||||
headState?: HeadState;
|
||||
// 新增支持图标和其他元数据
|
||||
icon?: string;
|
||||
provider?: string;
|
||||
testUrl?: string;
|
||||
}
|
||||
|
||||
// 优化列布局计算
|
||||
const calculateColumns = (width: number, configCol: number): number => {
|
||||
if (configCol > 0 && configCol < 6) return configCol;
|
||||
|
||||
if (width > 1920) return 5;
|
||||
if (width > 1450) return 4;
|
||||
if (width > 1024) return 3;
|
||||
if (width > 900) return 2;
|
||||
if (width >= 600) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
// 优化分组逻辑
|
||||
const groupProxies = <T = any>(list: T[], size: number): T[][] => {
|
||||
return list.reduce((acc, item) => {
|
||||
const lastGroup = acc[acc.length - 1];
|
||||
if (!lastGroup || lastGroup.length >= size) {
|
||||
acc.push([item]);
|
||||
} else {
|
||||
lastGroup.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, [] as T[][]);
|
||||
};
|
||||
|
||||
export const useRenderList = (mode: string) => {
|
||||
const { data: proxiesData, mutate: mutateProxies } = useSWR(
|
||||
"getProxies",
|
||||
getProxies,
|
||||
{
|
||||
refreshInterval: 2000,
|
||||
dedupingInterval: 1000,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { verge } = useVerge();
|
||||
const { width } = useWindowWidth();
|
||||
|
||||
let col = Math.floor(verge?.proxy_layout_column || 6);
|
||||
|
||||
// 自适应
|
||||
if (col >= 6 || col <= 0) {
|
||||
if (width > 1450) col = 4;
|
||||
else if (width > 1024) col = 3;
|
||||
else if (width > 900) col = 2;
|
||||
else if (width >= 600) col = 2;
|
||||
else col = 1;
|
||||
}
|
||||
|
||||
const [headStates, setHeadState] = useHeadStateNew();
|
||||
|
||||
// make sure that fetch the proxies successfully
|
||||
// 计算列数
|
||||
const col = useMemo(
|
||||
() => calculateColumns(width, verge?.proxy_layout_column || 6),
|
||||
[width, verge?.proxy_layout_column],
|
||||
);
|
||||
|
||||
// 确保代理数据加载
|
||||
useEffect(() => {
|
||||
if (!proxiesData) return;
|
||||
const { groups, proxies } = proxiesData;
|
||||
@@ -59,22 +82,29 @@ export const useRenderList = (mode: string) => {
|
||||
) {
|
||||
setTimeout(() => mutateProxies(), 500);
|
||||
}
|
||||
}, [proxiesData, mode]);
|
||||
}, [proxiesData, mode, mutateProxies]);
|
||||
|
||||
// 处理渲染列表
|
||||
const renderList: IRenderItem[] = useMemo(() => {
|
||||
if (!proxiesData) return [];
|
||||
|
||||
// global 和 direct 使用展开的样式
|
||||
const useRule = mode === "rule" || mode === "script";
|
||||
const renderGroups =
|
||||
(useRule && proxiesData.groups.length
|
||||
useRule && proxiesData.groups.length
|
||||
? proxiesData.groups
|
||||
: [proxiesData.global!]) || [];
|
||||
: [proxiesData.global!];
|
||||
|
||||
const retList = renderGroups.flatMap((group) => {
|
||||
const headState = headStates[group.name] || DEFAULT_STATE;
|
||||
const ret: IRenderItem[] = [
|
||||
{ type: 0, key: group.name, group, headState },
|
||||
{
|
||||
type: 0,
|
||||
key: group.name,
|
||||
group,
|
||||
headState,
|
||||
icon: group.icon,
|
||||
testUrl: group.testUrl,
|
||||
},
|
||||
];
|
||||
|
||||
if (headState?.open || !useRule) {
|
||||
@@ -85,61 +115,56 @@ export const useRenderList = (mode: string) => {
|
||||
headState.sortType,
|
||||
);
|
||||
|
||||
ret.push({ type: 1, key: `head-${group.name}`, group, headState });
|
||||
ret.push({
|
||||
type: 1,
|
||||
key: `head-${group.name}`,
|
||||
group,
|
||||
headState,
|
||||
});
|
||||
|
||||
if (!proxies.length) {
|
||||
ret.push({ type: 3, key: `empty-${group.name}`, group, headState });
|
||||
}
|
||||
|
||||
// 支持多列布局
|
||||
if (col > 1) {
|
||||
ret.push({
|
||||
type: 3,
|
||||
key: `empty-${group.name}`,
|
||||
group,
|
||||
headState,
|
||||
});
|
||||
} else if (col > 1) {
|
||||
return ret.concat(
|
||||
groupList(proxies, col).map((proxyCol) => ({
|
||||
groupProxies(proxies, col).map((proxyCol) => ({
|
||||
type: 4,
|
||||
key: `col-${group.name}-${proxyCol[0].name}`,
|
||||
group,
|
||||
headState,
|
||||
col,
|
||||
proxyCol,
|
||||
provider: proxyCol[0].provider,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
return ret.concat(
|
||||
proxies.map((proxy) => ({
|
||||
type: 2,
|
||||
key: `${group.name}-${proxy!.name}`,
|
||||
group,
|
||||
proxy,
|
||||
headState,
|
||||
provider: proxy.provider,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return ret.concat(
|
||||
proxies.map((proxy) => ({
|
||||
type: 2,
|
||||
key: `${group.name}-${proxy!.name}`,
|
||||
group,
|
||||
proxy,
|
||||
headState,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
|
||||
if (!useRule) return retList.slice(1);
|
||||
return retList.filter((item) => item.group.hidden === false);
|
||||
return retList.filter((item) => !item.group.hidden);
|
||||
}, [headStates, proxiesData, mode, col]);
|
||||
|
||||
return {
|
||||
renderList,
|
||||
onProxies: mutateProxies,
|
||||
onHeadState: setHeadState,
|
||||
currentColumns: col,
|
||||
};
|
||||
};
|
||||
|
||||
function groupList<T = any>(list: T[], size: number): T[][] {
|
||||
return list.reduce((p, n) => {
|
||||
if (!p.length) return [[n]];
|
||||
|
||||
const i = p.length - 1;
|
||||
if (p[i].length < size) {
|
||||
p[i].push(n);
|
||||
return p;
|
||||
}
|
||||
|
||||
p.push([n]);
|
||||
return p;
|
||||
}, [] as T[][]);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
mutate("getClashConfig");
|
||||
mutate("getVersion");
|
||||
}, 100);
|
||||
Notice.success(t("Switched to _clash Core", { core: `${core}` }), 1000);
|
||||
// Notice.success(t("Switched to _clash Core", { core: `${core}` }), 1000);
|
||||
} catch (err: any) {
|
||||
Notice.error(err?.message || err.toString());
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
open: () => {
|
||||
setOpen(true);
|
||||
setValues({
|
||||
stack: clash?.tun.stack ?? "mixed",
|
||||
stack: clash?.tun.stack ?? "gvisor",
|
||||
device: clash?.tun.device ?? "Mihomo",
|
||||
autoRoute: clash?.tun["auto-route"] ?? true,
|
||||
autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true,
|
||||
@@ -89,7 +89,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
let tun = {
|
||||
stack: "mixed",
|
||||
stack: "gvisor",
|
||||
device: "Mihomo",
|
||||
"auto-route": true,
|
||||
"auto-detect-interface": true,
|
||||
@@ -98,7 +98,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
mtu: 1500,
|
||||
};
|
||||
setValues({
|
||||
stack: "mixed",
|
||||
stack: "gvisor",
|
||||
device: "Mihomo",
|
||||
autoRoute: true,
|
||||
autoDetectInterface: true,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import useSWR from "swr";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsRounded } from "@mui/icons-material";
|
||||
import {
|
||||
SettingsRounded,
|
||||
PlayArrowRounded,
|
||||
PauseRounded,
|
||||
WarningRounded,
|
||||
BuildRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { DialogRef, Notice, Switch } from "@/components/base";
|
||||
import { SettingList, SettingItem } from "./mods/setting-comp";
|
||||
@@ -9,6 +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,
|
||||
getRunningMode,
|
||||
installService,
|
||||
} from "@/services/cmds";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { Box, Button, Tooltip } from "@mui/material";
|
||||
|
||||
interface Props {
|
||||
onError?: (err: Error) => void;
|
||||
@@ -19,6 +33,16 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
|
||||
const { verge, mutateVerge, patchVerge } = useVerge();
|
||||
|
||||
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);
|
||||
|
||||
@@ -27,6 +51,7 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
enable_auto_launch,
|
||||
enable_silent_start,
|
||||
enable_system_proxy,
|
||||
proxy_auto_config,
|
||||
} = verge ?? {};
|
||||
|
||||
const onSwitchFormat = (_e: any, value: boolean) => value;
|
||||
@@ -34,6 +59,26 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
mutateVerge({ ...verge, ...patch }, false);
|
||||
};
|
||||
|
||||
const updateProxyStatus = async () => {
|
||||
// 等待一小段时间让系统代理状态变化
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await mutate("getSystemProxy");
|
||||
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} />
|
||||
@@ -42,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
|
||||
@@ -55,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
|
||||
@@ -73,6 +145,17 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
icon={SettingsRounded}
|
||||
onClick={() => sysproxyRef.current?.open()}
|
||||
/>
|
||||
{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 }} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -82,7 +165,10 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
onCatch={onError}
|
||||
onFormat={onSwitchFormat}
|
||||
onChange={(e) => onChangeData({ enable_system_proxy: e })}
|
||||
onGuard={(e) => patchVerge({ enable_system_proxy: e })}
|
||||
onGuard={async (e) => {
|
||||
await patchVerge({ enable_system_proxy: e });
|
||||
await updateProxyStatus();
|
||||
}}
|
||||
>
|
||||
<Switch edge="end" />
|
||||
</GuardState>
|
||||
|
||||
@@ -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;
|
||||
+12
-84
@@ -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>
|
||||
@@ -182,7 +160,11 @@ const SettingVerge = ({ onError }: Props) => {
|
||||
>
|
||||
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
|
||||
{routers.map((page: { label: string; path: string }) => {
|
||||
return <MenuItem value={page.path}>{t(page.label)}</MenuItem>;
|
||||
return (
|
||||
<MenuItem key={page.path} value={page.path}>
|
||||
{t(page.label)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</GuardState>
|
||||
@@ -257,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;
|
||||
+33
-81
@@ -1,107 +1,59 @@
|
||||
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 {
|
||||
logs: Record<LogLevel, ILogItem[]>;
|
||||
clearLogs: (level?: LogLevel) => void;
|
||||
appendLog: (level: LogLevel, log: ILogItem) => void;
|
||||
logs: ILogItem[];
|
||||
clearLogs: () => void;
|
||||
appendLog: (log: ILogItem) => void;
|
||||
}
|
||||
|
||||
const useLogStore = create<LogStore>(
|
||||
(set: (fn: (state: LogStore) => Partial<LogStore>) => void) => ({
|
||||
logs: {
|
||||
warning: [],
|
||||
info: [],
|
||||
debug: [],
|
||||
error: [],
|
||||
all: [],
|
||||
},
|
||||
clearLogs: (level?: LogLevel) =>
|
||||
set((state: LogStore) => ({
|
||||
logs: level
|
||||
? { ...state.logs, [level]: [] }
|
||||
: { warning: [], info: [], debug: [], error: [], all: [] },
|
||||
logs: [],
|
||||
clearLogs: () =>
|
||||
set(() => ({
|
||||
logs: [],
|
||||
})),
|
||||
appendLog: (level: LogLevel, log: ILogItem) =>
|
||||
appendLog: (log: ILogItem) =>
|
||||
set((state: LogStore) => {
|
||||
const currentLogs = state.logs[level];
|
||||
const newLogs =
|
||||
currentLogs.length >= MAX_LOG_NUM
|
||||
? [...currentLogs.slice(1), log]
|
||||
: [...currentLogs, log];
|
||||
return { logs: { ...state.logs, [level]: newLogs } };
|
||||
state.logs.length >= MAX_LOG_NUM
|
||||
? [...state.logs.slice(1), log]
|
||||
: [...state.logs, log];
|
||||
return { logs: newLogs };
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
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(logLevel, { ...data, time });
|
||||
},
|
||||
onerror() {
|
||||
if (!isActive) return;
|
||||
socket.close();
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
socket.close();
|
||||
};
|
||||
}, [clashInfo, enableLog, logLevel]);
|
||||
|
||||
return logs[logLevel];
|
||||
};
|
||||
|
||||
// 导出清空日志的方法
|
||||
export const clearLogs = (level?: LogLevel) => {
|
||||
useLogStore.getState().clearLogs(level);
|
||||
};
|
||||
export const clearLogs = clearGlobalLogs;
|
||||
|
||||
+17
-2
@@ -370,7 +370,6 @@
|
||||
"Permissions Granted Successfully for _clash Core": "تم منح الأذونات بنجاح لـ {{core}} Core",
|
||||
"Core Version Updated": "تم تحديث إصدار النواة",
|
||||
"Clash Core Restarted": "تم إعادة تشغيل نواة Clash",
|
||||
"Switched to _clash Core": "تم التبديل إلى {{core}} Core",
|
||||
"GeoData Updated": "تم تحديث البيانات الجغرافية",
|
||||
"Currently on the Latest Version": "أنت على أحدث إصدار حاليًا",
|
||||
"Import Subscription Successful": "تم استيراد الاشتراك بنجاح",
|
||||
@@ -438,5 +437,21 @@
|
||||
"Direct Mode": "الوضع المباشر",
|
||||
"Enable Tray Speed": "تفعيل سرعة التراي",
|
||||
"Lite Mode": "وضع الأداء الخفيف",
|
||||
"Lite Mode Info": "إيقاف الواجهة الرسومية والإبقاء على تشغيل النواة"
|
||||
"Lite Mode Info": "إيقاف الواجهة الرسومية والإبقاء على تشغيل النواة",
|
||||
"Config Validation Failed": "فشل التحقق من تكوين الاشتراك، يرجى فحص ملف التكوين، تم التراجع عن التغييرات، تفاصيل الخطأ:",
|
||||
"Boot Config Validation Failed": "فشل التحقق من التكوين عند الإقلاع، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
|
||||
"Core Change Config Validation Failed": "فشل التحقق من التكوين عند تغيير النواة، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
|
||||
"Config Validation Process Terminated": "تم إنهاء عملية التحقق",
|
||||
"Script Syntax Error": "خطأ في بناء جملة السكريبت، تم التراجع عن التغييرات",
|
||||
"Script Missing Main": "خطأ في السكريبت، تم التراجع عن التغييرات",
|
||||
"File Not Found": "الملف غير موجود، تم التراجع عن التغييرات",
|
||||
"Script File Error": "خطأ في ملف السكريبت، تم التراجع عن التغييرات",
|
||||
"Core Changed Successfully": "تم تغيير النواة بنجاح",
|
||||
"Failed to Change Core": "فشل تغيير النواة",
|
||||
"Verge Basic Setting": "الإعدادات الأساسية Verge",
|
||||
"Verge Advanced Setting": "الإعدادات الأساسية Verge",
|
||||
"TUN requires Service Mode": "يتطلب وضع TUN خدمة",
|
||||
"Install Service": "تثبيت الخدمة ",
|
||||
"Installing Service...": "جاري تثبيت الخدمة...",
|
||||
"Service Administrator Prompt": "يتطلب Clash Verge امتيازات المسؤول لإعادة تثبيت خدمة النظام"
|
||||
}
|
||||
|
||||
+75
-3
@@ -178,7 +178,7 @@
|
||||
"Process": "Process",
|
||||
"Time": "Time",
|
||||
"Source": "Source",
|
||||
"Destination": "Destination IP",
|
||||
"Destination": "Destination",
|
||||
"DestinationPort": "Destination Port",
|
||||
"Close Connection": "Close Connection",
|
||||
"Rules": "Rules",
|
||||
@@ -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",
|
||||
@@ -441,5 +447,71 @@
|
||||
"Direct Mode": "Direct Mode",
|
||||
"Enable Tray Speed": "Enable Tray Speed",
|
||||
"Lite Mode": "Lightweight Mode",
|
||||
"Lite Mode Info": "Close the GUI and keep only the kernel running"
|
||||
"Lite Mode Info": "Close the GUI and keep only the kernel running",
|
||||
"Config Validation Failed": "Subscription configuration validation failed. Please check the subscription configuration file; modifications have been rolled back.",
|
||||
"Boot Config Validation Failed": "Boot subscription configuration validation failed. Started with the default configuration; please check the subscription configuration file.",
|
||||
"Core Change Config Validation Failed": "Configuration validation failed when switching the kernel. Started with the default configuration; please check the subscription configuration file.",
|
||||
"Config Validation Process Terminated": "The validation process has been terminated.",
|
||||
"Script Syntax Error": "Script syntax error, changes reverted",
|
||||
"Script Missing Main": "Script error, changes reverted",
|
||||
"File Not Found": "File missing, changes reverted",
|
||||
"Script File Error": "Script file error, changes reverted",
|
||||
"Core Changed Successfully": "Core changed successfully",
|
||||
"Failed to Change Core": "Failed to change core",
|
||||
"YAML Syntax Error": "YAML syntax error, changes reverted",
|
||||
"YAML Read Error": "YAML read error, changes reverted",
|
||||
"YAML Mapping Error": "YAML mapping error, changes reverted",
|
||||
"YAML Key Error": "YAML key error, changes reverted",
|
||||
"YAML Error": "YAML error, changes reverted",
|
||||
"Merge File Syntax Error": "Merge file syntax error, changes reverted",
|
||||
"Merge File Mapping Error": "Merge file mapping error, changes reverted",
|
||||
"Merge File Key Error": "Merge file key error, changes reverted",
|
||||
"Merge File Error": "Merge file error, changes reverted",
|
||||
"Validate YAML File": "Validate YAML File",
|
||||
"Validate Merge File": "Validate Merge File",
|
||||
"Validation Success": "Validation Success",
|
||||
"Validation Failed": "Validation Failed",
|
||||
"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"
|
||||
}
|
||||
|
||||
+17
-2
@@ -373,7 +373,6 @@
|
||||
"Permissions Granted Successfully for _clash Core": "مجوزها با موفقیت برای هسته {{core}} اعطا شد",
|
||||
"Core Version Updated": "نسخه هسته بهروزرسانی شد",
|
||||
"Clash Core Restarted": "هسته Clash مجدداً راهاندازی شد",
|
||||
"Switched to _clash Core": "تغییر به هسته {{core}}",
|
||||
"GeoData Updated": "GeoData بهروزرسانی شد",
|
||||
"Currently on the Latest Version": "در حال حاضر در آخرین نسخه",
|
||||
"Import Subscription Successful": "وارد کردن اشتراک با موفقیت انجام شد",
|
||||
@@ -435,5 +434,21 @@
|
||||
"Direct Mode": "حالت مستقیم",
|
||||
"Enable Tray Speed": "فعال کردن سرعت ترای",
|
||||
"Lite Mode": "در فارسی",
|
||||
"Lite Mode Info": "رابط کاربری گرافیکی را ببندید و فقط هسته را در حال اجرا نگه دارید"
|
||||
"Lite Mode Info": "رابط کاربری گرافیکی را ببندید و فقط هسته را در حال اجرا نگه دارید",
|
||||
"Config Validation Failed": "اعتبارسنجی پیکربندی اشتراک ناموفق بود، فایل پیکربندی را بررسی کنید، تغییرات برگشت داده شد، جزئیات خطا:",
|
||||
"Boot Config Validation Failed": "اعتبارسنجی پیکربندی هنگام راهاندازی ناموفق بود، پیکربندی پیشفرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
|
||||
"Core Change Config Validation Failed": "اعتبارسنجی پیکربندی هنگام تغییر هسته ناموفق بود، پیکربندی پیشفرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
|
||||
"Config Validation Process Terminated": "فرآیند اعتبارسنجی متوقف شد",
|
||||
"Script Syntax Error": "خطای نحوی اسکریپت، تغییرات برگشت داده شد",
|
||||
"Script Missing Main": "خطای اسکریپت، تغییرات برگشت داده شد",
|
||||
"File Not Found": "فایل یافت نشد، تغییرات برگشت داده شد",
|
||||
"Script File Error": "خطای فایل اسکریپت، تغییرات برگشت داده شد",
|
||||
"Core Changed Successfully": "هسته با موفقیت تغییر کرد",
|
||||
"Failed to Change Core": "تغییر هسته ناموفق بود",
|
||||
"Verge Basic Setting": "تنظیمات پایه Verge",
|
||||
"Verge Advanced Setting": "تنظیمات پیشرفته Verge",
|
||||
"TUN requires Service Mode": "حالت تونلزنی نیاز به سرویس دارد",
|
||||
"Install Service": "نصب سرویس",
|
||||
"Installing Service...": "در حال نصب سرویس...",
|
||||
"Service Administrator Prompt": "Clash Verge برای نصب مجدد سرویس سیستم به امتیازات مدیر نیاز دارد"
|
||||
}
|
||||
|
||||
+17
-2
@@ -404,7 +404,6 @@
|
||||
"Permissions Granted Successfully for _clash Core": "Izin Berhasil Diberikan untuk Core {{core}}",
|
||||
"Core Version Updated": "Versi Core Diperbarui",
|
||||
"Clash Core Restarted": "Core Clash Dimulai Ulang",
|
||||
"Switched to _clash Core": "Beralih ke Core {{core}}",
|
||||
"GeoData Updated": "GeoData Diperbarui",
|
||||
"Currently on the Latest Version": "Saat ini pada Versi Terbaru",
|
||||
"Import Subscription Successful": "Berlangganan Berhasil Diimpor",
|
||||
@@ -434,5 +433,21 @@
|
||||
"Failed to fetch backup files": "Gagal mengambil file cadangan",
|
||||
"Enable Tray Speed": "Aktifkan Tray Speed",
|
||||
"Lite Mode": "Mode Ringan",
|
||||
"Lite Mode Info": "Tutup GUI dan biarkan hanya kernel yang berjalan"
|
||||
"Lite Mode Info": "Tutup GUI dan biarkan hanya kernel yang berjalan",
|
||||
"Config Validation Failed": "Validasi konfigurasi langganan gagal, periksa file konfigurasi, perubahan dibatalkan, detail kesalahan:",
|
||||
"Boot Config Validation Failed": "Validasi konfigurasi saat boot gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
|
||||
"Core Change Config Validation Failed": "Validasi konfigurasi saat ganti inti gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
|
||||
"Config Validation Process Terminated": "Proses validasi dihentikan",
|
||||
"Script Syntax Error": "Kesalahan sintaks skrip, perubahan dibatalkan",
|
||||
"Script Missing Main": "Kesalahan skrip, perubahan dibatalkan",
|
||||
"File Not Found": "File tidak ditemukan, perubahan dibatalkan",
|
||||
"Script File Error": "Kesalahan file skrip, perubahan dibatalkan",
|
||||
"Core Changed Successfully": "Inti berhasil diubah",
|
||||
"Failed to Change Core": "Gagal mengubah inti",
|
||||
"Verge Basic Setting": "Pengaturan Dasar Verge",
|
||||
"Verge Advanced Setting": "Pengaturan Lanjutan Verge",
|
||||
"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"
|
||||
}
|
||||
|
||||
+17
-2
@@ -373,7 +373,6 @@
|
||||
"Permissions Granted Successfully for _clash Core": "Разрешения успешно предоставлены для ядра {{core}}",
|
||||
"Core Version Updated": "Обновлена версия ядра",
|
||||
"Clash Core Restarted": "Clash ядра перезапущено",
|
||||
"Switched to _clash Core": "Переключено на ядра {{core}}",
|
||||
"GeoData Updated": "GeoData Обновлена",
|
||||
"Currently on the Latest Version": "В настоящее время используется последняя версия",
|
||||
"Import subscription successful": "Импорт подписки успешно",
|
||||
@@ -435,5 +434,21 @@
|
||||
"Direct Mode": "Прямой режим",
|
||||
"Enable Tray Speed": "Включить скорость в лотке",
|
||||
"Lite Mode": "Облегченный режим",
|
||||
"Lite Mode Info": "Закройте графический интерфейс и оставьте работать только ядро"
|
||||
"Lite Mode Info": "Закройте графический интерфейс и оставьте работать только ядро",
|
||||
"Config Validation Failed": "Ошибка проверки конфигурации подписки, проверьте файл конфигурации, изменения отменены, ошибка:",
|
||||
"Boot Config Validation Failed": "Ошибка проверки конфигурации при запуске, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
|
||||
"Core Change Config Validation Failed": "Ошибка проверки конфигурации при смене ядра, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
|
||||
"Config Validation Process Terminated": "Процесс проверки прерван",
|
||||
"Script Syntax Error": "Ошибка синтаксиса скрипта, изменения отменены",
|
||||
"Script Missing Main": "Ошибка скрипта, изменения отменены",
|
||||
"File Not Found": "Файл не найден, изменения отменены",
|
||||
"Script File Error": "Ошибка файла скрипта, изменения отменены",
|
||||
"Core Changed Successfully": "Ядро успешно сменено",
|
||||
"Failed to Change Core": "Не удалось сменить ядро",
|
||||
"Verge Basic Setting": "Основные настройки Verge",
|
||||
"Verge Advanced Setting": "Расширенные настройки Verge",
|
||||
"TUN requires Service Mode": "Режим TUN требует обслуживания",
|
||||
"Install Service": "Установить службу",
|
||||
"Installing Service...": "Установка службы...",
|
||||
"Service Administrator Prompt": "Clash Verge требует прав администратора для переустановки системной службы"
|
||||
}
|
||||
|
||||
+17
-2
@@ -372,7 +372,6 @@
|
||||
"Permissions Granted Successfully for _clash Core": "{{core}} ядросы өчен рөхсәтләр бирелде",
|
||||
"Core Version Updated": "Ядро версиясе яңартылды",
|
||||
"Clash Core Restarted": "Clash ядросы яңадан башланды",
|
||||
"Switched to _clash Core": "{{core}} ядросына күчү башкарылды",
|
||||
"GeoData Updated": "GeoData яңартылды",
|
||||
"Currently on the Latest Version": "Сездә иң соңгы версия урнаштырылган",
|
||||
"Import subscription successful": "Подписка уңышлы импортланды",
|
||||
@@ -434,5 +433,21 @@
|
||||
"Direct Mode": "Туры режим",
|
||||
"Enable Tray Speed": "Трей скоростьне үстерү",
|
||||
"Lite Mode": "Җиңел Режим",
|
||||
"Lite Mode Info": "GUI-ны ябыгыз һәм бары тик төшне генә эшләтеп калдырыгыз"
|
||||
"Lite Mode Info": "GUI-ны ябыгыз һәм бары тик төшне генә эшләтеп калдырыгыз",
|
||||
"Config Validation Failed": "Язылу көйләү тикшерүе уңышсыз, көйләү файлын тикшерегез, үзгәрешләр кире кайтарылды, хата:",
|
||||
"Boot Config Validation Failed": "Йөкләү вакытында көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
|
||||
"Core Change Config Validation Failed": "Ядро алыштырганда көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
|
||||
"Config Validation Process Terminated": "Тикшерү процессы туктатылды",
|
||||
"Script Syntax Error": "Скрипт синтаксик хатасы, үзгәрешләр кире кайтарылды",
|
||||
"Script Missing Main": "Скрипт хатасы, үзгәрешләр кире кайтарылды",
|
||||
"File Not Found": "Файл табылмады, үзгәрешләр кире кайтарылды",
|
||||
"Script File Error": "Скрипт файлы хатасы, үзгәрешләр кире кайтарылды",
|
||||
"Core Changed Successfully": "Ядро уңышлы алыштырылды",
|
||||
"Failed to Change Core": "Ядро алыштыру уңышсыз булды",
|
||||
"Verge Basic Setting": "Verge Төп көйләүләр",
|
||||
"Verge Advanced Setting": "Verge Киңәйтелгән көйләүләр",
|
||||
"TUN requires Service Mode": "TUN режимы хезмәт күрсәтүне таләп итә",
|
||||
"Install Service": "Хезмәтне урнаштыру",
|
||||
"Installing Service...": "Хезмәт урнаштырыла...",
|
||||
"Service Administrator Prompt": "Clash Verge система хезмәтен яңадан урнаштыру өчен администратор хокукларын таләп итә"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user