85 Commits

Author SHA1 Message Date
coolcoala
5e855e4755 fixed locales and number of version in files 2025-07-15 03:24:35 +03:00
coolcoala
a8b75aeabd updated UPDATELOG.md 2025-07-15 03:09:38 +03:00
coolcoala
854d42180a corrected information in the README.md 2025-07-15 03:09:10 +03:00
coolcoala
e94724595c fixes for possible problems with updates 2025-07-15 03:08:23 +03:00
coolcoala
6f1d9ba1b4 added support for special headers and displaying their information on the main page 2025-07-15 03:07:42 +03:00
coolcoala
c090ae3b11 removed icons that are not used anywhere else 2025-07-15 03:06:11 +03:00
coolcoala
3303e95713 fixed actions for release 2025-07-15 03:05:37 +03:00
coolcoala
5cdc5075f8 code formatting with prettier 2025-07-14 05:23:32 +03:00
coolcoala
eb1e4fe0c3 added a setting to change the proxy mode controlled by a switch on the homepage 2025-07-14 05:23:02 +03:00
coolcoala
b1e3283a24 fixed automatic activation of profile after importing 2025-07-14 05:07:57 +03:00
coolcoala
ce3b0bb479 fixed selection from list and sticking of modal windows to window edges 2025-07-14 05:07:15 +03:00
coolcoala
25b295f2a8 latency is now measured only when opening the proxy list 2025-07-14 01:13:54 +03:00
coolcoala
18b7366258 simplified the proxy import menu 2025-07-14 01:13:20 +03:00
coolcoala
565771a3ea updated UPDATELOG.md 2025-07-13 23:04:24 +03:00
coolcoala
f9376f6903 added notification that profiles are missing 2025-07-13 22:55:20 +03:00
coolcoala
8e0f5b6abd fixed layout of profile cards 2025-07-13 22:54:44 +03:00
coolcoala
41f32231f0 fixed localization and made minor bug fixes 2025-07-11 21:15:52 +03:00
coolcoala
e1968891ac corrected display of flags 2025-07-11 20:18:33 +03:00
coolcoala
f04e707b10 corrected localization of the installer, and also corrected project name in configs 2025-07-11 20:18:09 +03:00
coolcoala
0bb795b0e1 fixed proxy guard in sysproxy settings 2025-07-11 20:16:54 +03:00
coolcoala
1c5e43690e added tooltips for the sorting icon in the proxy list 2025-07-11 20:16:24 +03:00
coolcoala
f604416532 another attempt to fix emoji display on windows 2025-07-11 04:58:53 +03:00
coolcoala
87ee07d481 fixed display of proxy groups and emoji on windows 2025-07-11 04:26:01 +03:00
coolcoala
7dec9cbe9b fix actions 2025-07-10 23:18:38 +03:00
coolcoala
1274ba2324 HWID implementation 2025-07-10 20:50:12 +03:00
coolcoala
d6014865d6 fixed display of profiles 2025-07-10 20:49:18 +03:00
coolcoala
48a5ff6948 fixed localization 2025-07-10 20:48:51 +03:00
coolcoala
dd3950e46d corrected triggers for launching a build 2025-07-10 02:55:41 +03:00
coolcoala
1708246866 fixed bug with displaying alert about the need to install the service 2025-07-09 11:01:39 +03:00
coolcoala
b0734f5935 fixed github link 2025-07-09 10:27:53 +03:00
coolcoala
11768862d3 increased the size of the proxy enable button 2025-07-09 10:25:58 +03:00
coolcoala
ef409216d8 attempting to configure the autobuild 2025-07-09 05:00:24 +03:00
coolcoala
f739afea3d fixed problem with language switching 2025-07-09 04:49:47 +03:00
coolcoala
d5266fa003 fixed theme viewer 2025-07-09 04:49:05 +03:00
coolcoala
149bdd5175 corrected layout for better visibility 2025-07-09 04:47:59 +03:00
coolcoala
ec99e24ca1 fixed dark mode 2025-07-09 04:43:16 +03:00
coolcoala
7cc893383e README has been corrected 2025-07-09 04:30:20 +03:00
coolcoala
3902480d39 corrected layout on profiles and rules pages 2025-07-08 15:42:18 +03:00
coolcoala
686490ded1 New Interface (initial commit) 2025-07-04 02:28:27 +03:00
❤是纱雾酱哟~
4435a5aee4 Chore (ISSUE_TEMPLATE): 修复错误的 YAML 缩进 (bug_report.yml) (#3933) 2025-06-29 19:27:13 +08:00
Tunglies
d9e3a47894 fix: adjust formatting of log content section in bug report template 2025-06-29 10:41:18 +08:00
❤是纱雾酱哟~
c96be18187 chore (ISSUE_TEMPLATE): 修改问题反馈模板 (#3927)
* chore (ISSUE_TEMPLATE): 修改问题反馈模板

- 修改 bug_report.yml
- 增加一段预置的格式模板,指引用户将日志粘贴在规范的 Code Block 中以保持日志原有的格式排版
- 使用 <details /> 块以压缩日志片段,对于非 Contributors / Collaborators 用户友好,规避日志霸屏效果,简化爬楼。

* 接受来自审查的建议

- 增加英语支持,以方便非中文母语用户

Co-authored-by: Tunglies <tunglies.dev@outlook.com>

---------

Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2025-06-28 21:23:46 +08:00
Tunglies
47416dd3f8 chore: update UPDATELOG 2025-06-28 20:03:30 +08:00
Tunglies
4486f734bb fix: correct traffic percentage calculation to handle zero total gracefully #3920 2025-06-28 20:00:49 +08:00
Tunglies
bea6a2c8f7 chore: update Prettier configuration and dependencies; refactor code formatting for consistency (#3926) 2025-06-28 18:37:53 +08:00
renovate[bot]
82af9ed78e chore(deps): update cargo dependencies (#3908)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 23:48:42 +08:00
renovate[bot]
2b9e38d259 chore(deps): update npm dependencies (#3910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 23:48:18 +08:00
Tunglies
7c4222aed2 chore: update Node.js setup steps in formatting workflow to use corepack for package management (#3914) 2025-06-27 23:46:51 +08:00
Tunglies
a574ced428 Refactor logging statements to use the new formatting syntax for improved readability and consistency across the codebase. This includes updating error, warning, and info logs in various modules such as system commands, configuration, core functionalities, and utilities. Additionally, minor adjustments were made to string formatting in backup and proxy features to enhance clarity. 2025-06-27 23:30:59 +08:00
wonfen
cf437e6d94 chore: reduce default auto clean log to 7 days 2025-06-27 09:29:55 +08:00
Tunglies
e1bb8aa125 chore(pre-push): update comments for clarity and refine format check condition 2025-06-27 00:36:00 +08:00
Tunglies
c11bdd81e9 chore(i18n): add "Saved Successfully" message to English and Chinese locales 2025-06-27 00:32:37 +08:00
Tunglies
f1192c95a8 feat: add notification system with hotkey events and permission handling (#3867)
* feat: add notification system with hotkey events and permission handling

* Add macOS-specific handling for AppHidden notification

Introduces conditional support for the AppHidden notification event,
enabling macOS-specific behavior. Updates the enum and notification
logic to include this platform-specific feature.

Improves macOS user experience by accommodating system-level
application hiding events.

* Implement feature X to enhance user experience and fix bug Y in module Z

* refactor(notification): update notification keys for consistency and clarity

* chore(deps): update dependencies to latest versions
2025-06-26 23:09:07 +08:00
renovate[bot]
ae187cc21a chore(deps): update npm dependencies (#3871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2025-06-26 22:33:58 +08:00
renovate[bot]
a7875718f7 chore(deps): update rust crate tauri to 2.6.1 (#3906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 22:31:28 +08:00
renovate[bot]
5db1f7cda7 chore(deps): update cargo dependencies (#3896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 22:29:13 +08:00
renovate[bot]
cb98b17052 chore(deps): update npm dependencies to v7 (#3886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 01:09:50 +08:00
renovate[bot]
7aee1c6d6e chore(deps): update cargo dependencies (#3872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 01:09:36 +08:00
TRH
f22199b7d9 chore(logging): 去除无用日志 (#3882)
* chore(logging): 去除无用日志

* chore: 删除相关计时代码
2025-06-24 16:49:06 +08:00
Tunglies
0a8d6e5147 fix: add validation for profile URL format during import 2025-06-23 23:30:33 +08:00
希亚的西红柿
6d519dac1e fix: auto light-weight mode doesn't take effect in silent-start mode (#3875)
* fix: auto light-weight mode does not take effect when silent-start mode is enabled

* refactor: streamline window state retrieval and hiding logic

* fix: add checks for remote name and existence before format check in pre-push hook

* fix: simplify remote checks in pre-push hook to enhance clarity and maintainability

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2025-06-23 23:13:31 +08:00
wonfen
d5a174c71b perf: improve proxy status indicator and toggle responsiveness 2025-06-23 12:59:24 +08:00
wonfen
628de70e89 chore: remove unused imports 2025-06-23 00:09:17 +08:00
wonfen
fee08f3826 fix: correct address display error caused by async system proxy retrieval 2025-06-22 23:19:11 +08:00
wonfen
bdfc383a18 refactor: enhance Windows proxy retrieval by using WinAPI for registry access 2025-06-22 21:05:50 +08:00
wonfen
f6b5524e0e refactor: replace shell command check with WinAPI call 2025-06-22 18:45:38 +08:00
Tunglies
e7461fccab refactor: remove unused macOS tray speed display and improve tray icon handling (#3862)
* refactor: remove unused macOS tray speed display and improve tray icon handling

* refactor: disable macOS specific logging during core initialization

* feat: add linux elevator function to determine privilege escalation command
2025-06-22 16:28:06 +08:00
wonfen
a92872c831 fix: resolve race condition freeze on rapid tray icon clicks in lightweight mode 2025-06-22 15:44:09 +08:00
Tunglies
094feb74ec feat: add new monochrome tray icons for system and tunnel 2025-06-22 15:32:33 +08:00
Tunglies
9b1c660306 feat: add new monochrome tray icons for system and tunnel 2025-06-22 15:31:59 +08:00
renovate[bot]
4b860ba897 chore(deps): update npm dependencies (#3831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-22 13:24:25 +08:00
Tunglies
3d8b2cf35f fix: improve error handling for application handle retrieval (#3860)
* fix: improve error handling for application handle retrieval

* fix: correct argument passing for command execution and improve URL stripping logic
2025-06-22 01:16:57 +08:00
wonfen
41fc13cfe2 fix: format & update 2025-06-21 22:39:12 +08:00
wonfen
5fde5dcc7c feat: implement async proxy lookup and optimize system/auto proxy retrieval logic 2025-06-21 21:56:15 +08:00
wonfen
034885d810 feat: introduce event-driven proxy manager and optimize proxy config updates 2025-06-21 21:56:15 +08:00
Tunglies
3f7a7b8cd2 chore: add cargo deny config 2025-06-21 21:22:20 +08:00
Tunglies
98dc50a9ed refactor: remove redundant steps for cleaning old release assets 2025-06-21 20:42:27 +08:00
Tunglies
5dd820d12e feat: add job to clean old release assets before uploading new ones 2025-06-21 20:36:09 +08:00
renovate[bot]
bc30db2875 chore(deps): update rust crate libc to 0.2.174 (#3794)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-21 18:41:06 +08:00
Dyna
abe914d446 fix the issue that CSS cannot customize background (#3854)
* fixed the issue that css background cannot be used

* update logs

* fix logs
2025-06-21 16:53:10 +08:00
wonfen
cc65ce6812 chore: update to 2.3.2 2025-06-21 10:09:22 +08:00
wonfen
1a6454ee79 perf: optimize profile switching logic with interrupt support to prevent freeze 2025-06-21 10:04:12 +08:00
Just want to protect you
b72f397369 fix the issue of system proxy port being out of sync (#3841)
* repair system proxy port real-time response

* update logs

* fix-logs
2025-06-21 10:03:06 +08:00
wonfen
e698fe8d18 feat: add cleanup for redundant mihomo processes 2025-06-21 08:13:12 +08:00
wonfen
c8cad1c295 chore: add funding button 2025-06-20 20:52:40 +08:00
221 changed files with 20246 additions and 16705 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: clash-verge-rev

View File

@@ -61,5 +61,12 @@ body:
attributes: attributes:
label: 日志(勿上传日志文件,请粘贴日志内容) / Log (Do not upload the log file, paste the log content directly) label: 日志(勿上传日志文件,请粘贴日志内容) / Log (Do not upload the log file, paste the log content directly)
description: 请提供完整或相关部分的Debug日志请在“软件左侧菜单”->“设置”->“日志等级”调整到debugVerge错误请把“杂项设置”->“app日志等级”调整到debug并重启Verge生效。日志文件在“软件左侧菜单”->“设置”->“日志目录”下) / Please provide a complete or relevant part of the Debug log (please adjust the "Log level" to debug in "Software left menu" -> "Settings" -> "Log level". If there is a Verge error, please adjust "Miscellaneous settings" -> "app log level" to debug, and restart Verge to take effect. The log file is under "Software left menu" -> "Settings" -> "Log directory") description: 请提供完整或相关部分的Debug日志请在“软件左侧菜单”->“设置”->“日志等级”调整到debugVerge错误请把“杂项设置”->“app日志等级”调整到debug并重启Verge生效。日志文件在“软件左侧菜单”->“设置”->“日志目录”下) / Please provide a complete or relevant part of the Debug log (please adjust the "Log level" to debug in "Software left menu" -> "Settings" -> "Log level". If there is a Verge error, please adjust "Miscellaneous settings" -> "app log level" to debug, and restart Verge to take effect. The log file is under "Software left menu" -> "Settings" -> "Log directory")
value: |
<details><summary>日志内容 / Log Content</summary>
```log
<!-- 在此处粘贴完整日志 / Paste the full log here -->
```
</details>
validations: validations:
required: true required: true

View File

@@ -2,9 +2,9 @@ name: Auto Build
on: on:
workflow_dispatch: workflow_dispatch:
schedule: # schedule:
# UTC+8 0,6,12,18 # # UTC+8 0,6,12,18
- cron: "0 16,22,4,10 * * *" # - cron: "0 16,22,4,10 * * *"
permissions: write-all permissions: write-all
env: env:
TAG_NAME: autobuild TAG_NAME: autobuild
@@ -77,6 +77,15 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Update git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -f ${{ env.TAG_NAME }}
git push --force origin ${{ env.TAG_NAME }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch UPDATE logs - name: Fetch UPDATE logs
id: fetch_update_logs id: fetch_update_logs
run: | run: |
@@ -111,31 +120,25 @@ jobs:
cat > release.txt << EOF cat > release.txt << EOF
$UPDATE_LOGS $UPDATE_LOGS
## 我应该下载哪个版本? ## Which version should I download?
### MacOS ### MacOS
- MacOS intel芯片: x64.dmg - MacOS Intel Chip: x64.dmg
- MacOS apple M芯片: aarch64.dmg - MacOS Apple M Chip: aarch64.dmg
### Linux ### Linux
- Linux 64: amd64.deb/amd64.rpm - Linux 64-bit: amd64.deb/amd64.rpm
- Linux arm64 architecture: arm64.deb/aarch64.rpm - Linux arm64: arm64.deb/aarch64.rpm
- Linux armv7架构: armhf.deb/armhfp.rpm - Linux armv7: armhf.deb/armhfp.rpm
### Windows (不再支持Win7) ### Windows (Win7 is no longer supported)
#### 正常版本(推荐) #### Normal version (recommended)
- 64: x64-setup.exe - 64-bit: x64-setup.exe
- arm64架构: arm64-setup.exe - arm64: arm64-setup.exe
#### 便携版问题很多不再提供 #### Portable version is no longer available with many problems
#### 内置Webview2版(体积较大,仅在企业版系统或无法安装webview2时使用) #### Built-in Webview version 2 (large size, only used in enterprise version of the system or can not install webview2)
- 64: x64_fixed_webview2-setup.exe - 64-bit: x64_fixed_webview2-setup.exe
- arm64架构: arm64_fixed_webview2-setup.exe - arm64: arm64_fixed_webview2-setup.exe
### FAQ
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}. Created at ${{ env.BUILDTIME }}.
EOF EOF
@@ -150,6 +153,28 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
generate_release_notes: true generate_release_notes: true
clean_old_assets:
name: Clean Old Release Assets
runs-on: ubuntu-latest
needs: update_tag
if: ${{ needs.update_tag.result == 'success' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Remove old assets from release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ env.TAG_NAME }}
run: |
VERSION=$(cat package.json | jq -r '.version')
assets=$(gh release view "$TAG_NAME" --json assets -q '.assets[].name' || true)
for asset in $assets; do
if [[ "$asset" != *"$VERSION"* ]]; then
echo "Deleting old asset: $asset"
gh release delete-asset "$TAG_NAME" "$asset" -y
fi
done
autobuild-x86-windows-macos-linux: autobuild-x86-windows-macos-linux:
name: Autobuild x86 Windows, MacOS and Linux name: Autobuild x86 Windows, MacOS and Linux
needs: update_tag needs: update_tag
@@ -217,12 +242,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
tagName: ${{ env.TAG_NAME }} tagName: ${{ env.TAG_NAME }}
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"

View File

@@ -26,11 +26,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: npm i -g --force corepack
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "lts/*" node-version: "lts/*"
- run: pnpm i --frozen-lockfile - run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm format:check - run: pnpm format:check
# taplo: # taplo:

View File

@@ -103,15 +103,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
tagName: v__VERSION__ tagName: v__VERSION__
releaseName: "Clash Verge Rev v__VERSION__" releaseName: "Clash Verge Rev Lite v__VERSION__"
releaseBody: "More new features are now supported." releaseBody: "More new features are now supported."
tauriScript: pnpm tauriScript: pnpm
args: --target ${{ matrix.target }} args: --target ${{ matrix.target }}
@@ -231,7 +225,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{env.VERSION}} tag_name: v${{env.VERSION}}
name: "Clash Verge Rev v${{env.VERSION}}" name: "Clash Verge Rev Lite v${{env.VERSION}}"
body: "More new features are now supported." body: "More new features are now supported."
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: | files: |
@@ -322,7 +316,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{steps.build.outputs.appVersion}} tag_name: v${{steps.build.outputs.appVersion}}
name: "Clash Verge Rev v${{steps.build.outputs.appVersion}}" name: "Clash Verge Rev Lite v${{steps.build.outputs.appVersion}}"
body: "More new features are now supported." body: "More new features are now supported."
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup* files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
@@ -382,26 +376,3 @@ jobs:
run: pnpm updater-fixed-webview2 run: pnpm updater-fixed-webview2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
submit-to-winget:
name: Submit to Winget
runs-on: ubuntu-latest
needs: [release-update]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get Version
run: |
sudo apt-get update
sudo apt-get install jq
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
- name: Submit to Winget
uses: vedantmgoyal9/winget-releaser@main
with:
identifier: ClashVergeRev.ClashVergeRev
version: ${{env.VERSION}}
release-tag: v${{env.VERSION}}
installers-regex: '_(arm64|x64|x86)-setup\.exe$'
token: ${{ secrets.WINGET_TOKEN }}

View File

@@ -1,5 +1,8 @@
#!/bin/bash #!/bin/bash
# $1: remote name (e.g., origin)
# $2: remote url (e.g., git@github.com:clash-verge-rev/clash-verge-rev.git)
if git diff --cached --name-only | grep -q '^src-tauri/'; then if git diff --cached --name-only | grep -q '^src-tauri/'; then
cargo clippy --manifest-path ./src-tauri/Cargo.toml cargo clippy --manifest-path ./src-tauri/Cargo.toml
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
@@ -8,11 +11,9 @@ if git diff --cached --name-only | grep -q '^src-tauri/'; then
fi fi
fi fi
remote_name="$1" # 只在 push 到 origin 并且 origin 指向目标仓库时执行格式检查
remote_url=$(git remote get-url "$remote_name") if [ "$1" = "origin" ] && echo "$2" | grep -Eq 'github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$'; then
echo "[pre-push] Detected push to origin (clash-verge-rev/clash-verge-rev)"
if [[ "$remote_url" =~ github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$ ]]; then
echo "[pre-push] Detected push to clash-verge-rev/clash-verge-rev ($remote_url)"
echo "[pre-push] Running pnpm format:check..." echo "[pre-push] Running pnpm format:check..."
pnpm format:check pnpm format:check

View File

@@ -1,6 +1,16 @@
{ {
"singleQuote": true, "printWidth": 80,
"semi": false, "tabWidth": 2,
"trailingComma": "none", "useTabs": false,
"experimentalOperatorPosition": "start" "semi": true,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto"
} }

View File

@@ -1,7 +1,7 @@
<h1 align="center"> <h1 align="center">
<img src="./src-tauri/icons/icon.png" alt="Clash" width="128" /> <img src="./src-tauri/icons/icon.png" alt="Clash" width="128" />
<br> <br>
Continuation of <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a> Fork of <a href="https://github.com/clash-verge-rev/clash-verge-rev">Clash Verge Rev</a>
<br> <br>
</h1> </h1>
@@ -11,74 +11,26 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
## Preview ## Preview
| Dark | Light | | Dark | Light |
| -------------------------------- | --------------------------------- | | ----------------------------------- | ------------------------------------ |
| ![预览](./docs/preview_dark.png) | ![预览](./docs/preview_light.png) | | ![Preview](./docs/preview_dark.png) | ![Preview](./docs/preview_light.png) |
## Install ## Install
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br> Go to the [Release page](https://github.com/coolcoala/clash-verge-rev-lite/releases) to download the corresponding installation package<br>
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple). Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
#### 我应当怎样选择发行版 ### Telegram channel: ---
| 版本 | 特征 | 链接 |
| :-------- | :--------------------------------------- | :------------------------------------------------------------------------------------- |
| Stable | 正式版,高可靠性,适合日常使用。 | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
| Alpha | 早期测试版,功能未完善,可能存在缺陷。 | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) |
| AutoBuild | 滚动更新版,持续集成更新,适合开发测试。 | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) |
#### 安装说明和常见问题,请到 [文档页](https://clash-verge-rev.github.io/) 查看
---
### TG 频道: [@clash_verge_rev](https://t.me/clash_verge_re)
## Promotion
#### [狗狗加速 —— 技术流机场 Doggygo VPN](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 高性能海外机场,免费试用,优惠套餐,解锁流媒体,全球首家支持 Hysteria 协议。
- 使用 Clash Verge 专属邀请链接注册送 3 天,每天 1G 流量免费试用:[点此注册](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 专属 8 折优惠码: verge20 (仅有 500 份)
- 优惠套餐每月仅需 15.8 元160G 流量,年付 8 折
- 海外团队,无跑路风险,高达 50% 返佣
- 集群负载均衡设计,高速专线(兼容老客户端)极低延迟无视晚高峰4K 秒开
- 全球首家 Hysteria 协议机场,现已上线更快的 `Hysteria2` 协议(Clash Verge 客户端最佳搭配)
- 解锁流媒体及 ChatGPT
- 官网:[https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### 本项目的构建与发布环境由 [YXVM](https://yxvm.com/aff.php?aff=827) 独立服务器全力支持,
感谢提供 独享资源、高性能、高速网络 的强大后端环境。如果你觉得下载够快、使用够爽,那是因为我们用了好服务器!
🧩 YXVM 独立服务器优势:
- 🌎 优质网络,回程优化,下载快到飞起
- 🔧 物理机独享资源非VPS可比性能拉满
- 🧠 适合跑代理、搭建 WEB 站 CDN 站 、搞 CI/CD 或任何高负载应用
- 💡 支持即开即用多机房选择CN2 / IEPL 可选
- 📦 本项目使用配置已在售,欢迎同款入手!
- 🎯 想要同款构建体验?[立即下单 YXVM 独立服务器!](https://yxvm.com/aff.php?aff=827)
## Features ## Features
- 基于性能强劲的 Rust Tauri 2 框架 - Based on the powerful Rust and Tauri 2 frameworks.
- 内置[Clash.Meta(mihomo)](https://github.com/MetaCubeX/mihomo)内核,并支持切换 `Alpha` 版本内核。 - Built-in [Clash.Meta(mihomo)](https://github.com/MetaCubeX/mihomo) kernel with support for switching between `Alpha` versions of the kernel.
- 简洁美观的用户界面,支持自定义主题颜色、代理组/托盘图标以及 `CSS Injection` - Simple and beautiful user interface with support for custom theme colors, agent group/tray icons, and `CSS Injection`.
- 配置文件管理和增强Merge 和 Script配置文件语法提示。 - Configuration file management, configuration file syntax hints.
- 系统代理和守卫、`TUN(虚拟网卡)` 模式。 - System Agent and Guard, `TUN (Virtual NIC)` mode.
- 可视化节点和规则编辑 - Visual node and rule editing
- WebDav 配置备份和同步 - WebDav configuration backup and synchronization
### FAQ
Refer to [Doc FAQ Page](https://clash-verge-rev.github.io/faq/windows.html)
### Donation
[捐助Clash Verge Rev的开发](https://github.com/sponsors/clash-verge-rev)
## Development ## Development

File diff suppressed because it is too large Load Diff

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@root/src/components",
"utils": "@root/lib/utils",
"ui": "@root/src/components/ui",
"lib": "@root/lib",
"hooks": "@root/hooks"
},
"iconLibrary": "lucide"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 671 KiB

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "clash-verge", "name": "clash-verge",
"version": "2.3.1", "version": "0.2.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev", "dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
@@ -30,53 +30,78 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^5.1.1",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@mui/icons-material": "^7.1.1", "@mui/icons-material": "^7.1.1",
"@mui/lab": "7.0.0-beta.13", "@mui/lab": "7.0.0-beta.13",
"@mui/material": "^7.1.1", "@mui/material": "^7.1.1",
"@mui/x-data-grid": "^8.5.2", "@mui/x-data-grid": "^8.5.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "2.5.0", "@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.3", "@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-dialog": "^2.2.2", "@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0", "@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-global-shortcut": "^2.2.1", "@tauri-apps/plugin-global-shortcut": "^2.2.1",
"@tauri-apps/plugin-notification": "^2.2.3", "@tauri-apps/plugin-notification": "^2.2.2",
"@tauri-apps/plugin-process": "^2.2.2", "@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "2.2.2", "@tauri-apps/plugin-shell": "2.2.1",
"@tauri-apps/plugin-updater": "2.8.1", "@tauri-apps/plugin-updater": "2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.3", "@tauri-apps/plugin-window-state": "^2.2.2",
"@types/d3-shape": "^3.1.7", "@types/d3-shape": "^3.1.7",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"ahooks": "^3.8.5", "ahooks": "^3.8.5",
"axios": "^1.10.0", "axios": "^1.9.0",
"chart.js": "^4.5.0", "chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1",
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-shape": "^3.2.0", "d3-shape": "^3.2.0",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"foxact": "^0.2.49", "foxact": "^0.2.45",
"glob": "^11.0.3", "glob": "^11.0.2",
"i18next": "^25.2.1", "i18next": "^25.2.1",
"js-base64": "^3.7.7", "js-base64": "^3.7.7",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.514.0",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"monaco-yaml": "^5.4.0", "monaco-yaml": "^5.4.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"peggy": "^5.0.3", "peggy": "^5.0.3",
"react": "19.1.0", "react": "19.1.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-colorful": "^5.6.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-error-boundary": "6.0.0", "react-error-boundary": "6.0.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.57.0",
"react-i18next": "15.5.3", "react-i18next": "15.5.2",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-monaco-editor": "0.58.0", "react-monaco-editor": "0.58.0",
"react-router-dom": "7.6.2", "react-router-dom": "7.6.2",
"react-virtuoso": "^4.13.0", "react-virtuoso": "^4.12.8",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"sonner": "^2.0.5",
"swr": "^2.3.3", "swr": "^2.3.3",
"tailwind-merge": "^3.3.1",
"tar": "^7.4.3", "tar": "^7.4.3",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
"zod": "^3.25.67",
"zustand": "^5.0.5" "zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
@@ -85,21 +110,26 @@
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/react": "19.1.8", "@types/node": "^24.0.0",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@vitejs/plugin-legacy": "^6.1.1", "@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-react": "4.5.2", "@vitejs/plugin-react": "4.5.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"autoprefixer": "^10.4.21",
"commander": "^14.0.0", "commander": "^14.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"meta-json-schema": "^1.19.10", "meta-json-schema": "^1.19.10",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"postcss": "^8.5.4",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"pretty-quick": "^4.2.2", "pretty-quick": "^4.2.2",
"sass": "^1.89.2", "sass": "^1.89.1",
"terser": "^5.43.0", "tailwindcss": "^4.1.11",
"terser": "^5.41.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",

2067
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1990
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
[package] [package]
name = "clash-verge" name = "clash-verge"
version = "2.3.1" version = "0.2.0"
description = "clash verge" description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda"] authors = ["zzzgydi", "wonfen", "MystiPanda", "coolcoala"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://github.com/clash-verge-rev/clash-verge-rev.git" repository = "https://github.com/coolcoala/clash-verge-rev-lite.git"
default-run = "clash-verge" default-run = "clash-verge"
edition = "2021" edition = "2021"
build = "build.rs" build = "build.rs"
@@ -13,9 +13,12 @@ build = "build.rs"
identifier = "io.github.clash-verge-rev.clash-verge-rev" identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.2.0", features = [] } tauri-build = { version = "2.3.0", features = [] }
[dependencies] [dependencies]
url = "2.5.4"
os_info = "3.0"
machine-uid = "0.2"
warp = "0.3.7" warp = "0.3.7"
anyhow = "1.0.98" anyhow = "1.0.98"
dirs = "6.0" dirs = "6.0"
@@ -47,7 +50,7 @@ regex = "1.11.1"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs" } sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs" }
image = "0.25.6" image = "0.25.6"
imageproc = "0.25.0" imageproc = "0.25.0"
tauri = { version = "2.5.1", features = [ tauri = { version = "2.6.2", features = [
"protocol-asset", "protocol-asset",
"devtools", "devtools",
"tray-icon", "tray-icon",
@@ -55,15 +58,15 @@ tauri = { version = "2.5.1", features = [
"image-png", "image-png",
] } ] }
network-interface = { version = "2.0.1", features = ["serde"] } network-interface = { version = "2.0.1", features = ["serde"] }
tauri-plugin-shell = "2.2.2" tauri-plugin-shell = "2.3.0"
tauri-plugin-dialog = "2.2.2" tauri-plugin-dialog = "2.3.0"
tauri-plugin-fs = "2.3.0" tauri-plugin-fs = "2.4.0"
tauri-plugin-process = "2.2.2" tauri-plugin-process = "2.3.0"
tauri-plugin-clipboard-manager = "2.2.3" tauri-plugin-clipboard-manager = "2.3.0"
tauri-plugin-deep-link = "2.3.0" tauri-plugin-deep-link = "2.4.0"
tauri-plugin-devtools = "2.0.0" tauri-plugin-devtools = "2.0.0"
tauri-plugin-window-state = "2.2.3" tauri-plugin-window-state = "2.3.0"
zip = "4.1.0" zip = "4.2.0"
reqwest_dav = "0.2.1" reqwest_dav = "0.2.1"
aes-gcm = { version = "0.10.3", features = ["std"] } aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1" base64 = "0.22.1"
@@ -75,11 +78,13 @@ async-trait = "0.1.88"
mihomo_api = { path = "src_crates/crate_mihomo_api" } mihomo_api = { path = "src_crates/crate_mihomo_api" }
ab_glyph = "0.2.29" ab_glyph = "0.2.29"
tungstenite = "0.27.0" tungstenite = "0.27.0"
libc = "0.2.173" libc = "0.2.174"
gethostname = "1.0.2" gethostname = "1.0.2"
hmac = "0.12.1" hmac = "0.12.1"
sha2 = "0.10.9" sha2 = "0.10.9"
hex = "0.4.3" hex = "0.4.3"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
runas = "=1.2.0" runas = "=1.2.0"
@@ -93,15 +98,19 @@ winapi = { version = "0.3.9", features = [
"errhandlingapi", "errhandlingapi",
"minwindef", "minwindef",
"winerror", "winerror",
"tlhelp32",
"processthreadsapi",
"winhttp",
"winreg",
] } ] }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
users = "0.11.0" users = "0.11.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.4.0" tauri-plugin-autostart = "2.5.0"
tauri-plugin-global-shortcut = "2.2.1" tauri-plugin-global-shortcut = "2.3.0"
tauri-plugin-updater = "2.8.1" tauri-plugin-updater = "2.9.0"
[features] [features]
default = ["custom-protocol"] default = ["custom-protocol"]

View File

@@ -17,6 +17,7 @@
"autostart:allow-enable", "autostart:allow-enable",
"autostart:allow-disable", "autostart:allow-disable",
"autostart:allow-is-enabled", "autostart:allow-is-enabled",
"core:window:allow-set-theme" "core:window:allow-set-theme",
"notification:default"
] ]
} }

236
src-tauri/deny.toml Normal file
View File

@@ -0,0 +1,236 @@
# This template contains all of the possible sections and their default values
# Note that all fields that take a lint level have these possible values:
# * deny - An error will be produced and the check will fail
# * warn - A warning will be produced, but the check will not fail
# * allow - No warning or error will be produced, though in some cases a note
# will be
# The values provided in this template are the default values that will be used
# when any section or field is not specified in your own configuration
# Root options
# The graph table configures how the dependency graph is constructed and thus
# which crates the checks are performed against
[graph]
# If 1 or more target triples (and optionally, target_features) are specified,
# only the specified targets will be checked when running `cargo deny check`.
# This means, if a particular package is only ever used as a target specific
# dependency, such as, for example, the `nix` crate only being used via the
# `target_family = "unix"` configuration, that only having windows targets in
# this list would mean the nix crate, as well as any of its exclusive
# dependencies not shared by any other crates, would be ignored, as the target
# list here is effectively saying which targets you are building for.
targets = [
# The triple can be any string, but only the target triples built in to
# rustc (as of 1.40) can be checked against actual config expressions
#"x86_64-unknown-linux-musl",
# You can also specify which target_features you promise are enabled for a
# particular target. target_features are currently not validated against
# the actual valid features supported by the target architecture.
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
]
# When creating the dependency graph used as the source of truth when checks are
# executed, this field can be used to prune crates from the graph, removing them
# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
# is pruned from the graph, all of its dependencies will also be pruned unless
# they are connected to another crate in the graph that hasn't been pruned,
# so it should be used with care. The identifiers are [Package ID Specifications]
# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
#exclude = []
# If true, metadata will be collected with `--all-features`. Note that this can't
# be toggled off if true, if you want to conditionally enable `--all-features` it
# is recommended to pass `--all-features` on the cmd line instead
all-features = false
# If true, metadata will be collected with `--no-default-features`. The same
# caveat with `all-features` applies
no-default-features = false
# If set, these feature will be enabled when collecting metadata. If `--features`
# is specified on the cmd line they will take precedence over this option.
#features = []
# The output table provides options for how/if diagnostics are outputted
[output]
# When outputting inclusion graphs in diagnostics that include features, this
# option can be used to specify the depth at which feature edges will be added.
# This option is included since the graphs can be quite large and the addition
# of features from the crate(s) to all of the graph roots can be far too verbose.
# This option can be overridden via `--feature-depth` on the cmd line
feature-depth = 1
# This section is considered when running `cargo deny check advisories`
# More documentation for the advisories section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
[advisories]
# The path where the advisory databases are cloned/fetched into
#db-path = "$CARGO_HOME/advisory-dbs"
# The url(s) of the advisory databases to use
#db-urls = ["https://github.com/rustsec/advisory-db"]
# A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered.
ignore = [
#"RUSTSEC-0000-0000",
#{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
#"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
#{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
"RUSTSEC-2024-0415",
]
# If this is true, then cargo deny will use the git executable to fetch advisory database.
# If this is false, then it uses a built-in git library.
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
# See Git Authentication for more information about setting up git authentication.
#git-fetch-with-cli = true
# This section is considered when running `cargo deny check licenses`
# More documentation for the licenses section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
[licenses]
# List of explicitly allowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
allow = [
#"MIT",
#"Apache-2.0",
#"Apache-2.0 WITH LLVM-exception",
]
# The confidence threshold for detecting a license from license text.
# The higher the value, the more closely the license text must be to the
# canonical license text of a valid SPDX license file.
# [possible values: any between 0.0 and 1.0].
confidence-threshold = 0.85
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
# aren't accepted for every possible crate as with the normal allow list
exceptions = [
# Each entry is the crate and version constraint, and its specific allow
# list
#{ allow = ["Zlib"], crate = "adler32" },
]
# Some crates don't have (easily) machine readable licensing information,
# adding a clarification entry for it allows you to manually specify the
# licensing information
#[[licenses.clarify]]
# The package spec the clarification applies to
#crate = "ring"
# The SPDX expression for the license requirements of the crate
#expression = "MIT AND ISC AND OpenSSL"
# One or more files in the crate's source used as the "source of truth" for
# the license expression. If the contents match, the clarification will be used
# when running the license check, otherwise the clarification will be ignored
# and the crate will be checked normally, which may produce warnings or errors
# depending on the rest of your configuration
#license-files = [
# Each entry is a crate relative path, and the (opaque) hash of its contents
#{ path = "LICENSE", hash = 0xbd0eed23 }
#]
[licenses.private]
# If true, ignores workspace crates that aren't published, or are only
# published to private registries.
# To see how to mark a crate as unpublished (to the official registry),
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
ignore = false
# One or more private registries that you might publish crates to, if a crate
# is only published to private registries, and ignore is true, the crate will
# not have its license(s) checked
registries = [
#"https://sekretz.com/registry
]
# This section is considered when running `cargo deny check bans`.
# More documentation about the 'bans' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
# Lint level for when a crate version requirement is `*`
wildcards = "allow"
# The graph highlighting used when creating dotgraphs for crates
# with multiple versions
# * lowest-version - The path to the lowest versioned duplicate is highlighted
# * simplest-path - The path to the version with the fewest edges is highlighted
# * all - Both lowest-version and simplest-path are used
highlight = "all"
# The default lint level for `default` features for crates that are members of
# the workspace that is being checked. This can be overridden by allowing/denying
# `default` on a crate-by-crate basis if desired.
workspace-default-features = "allow"
# The default lint level for `default` features for external crates that are not
# members of the workspace. This can be overridden by allowing/denying `default`
# on a crate-by-crate basis if desired.
external-default-features = "allow"
# List of crates that are allowed. Use with care!
allow = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
]
# List of crates to deny
deny = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
# Wrapper crates can optionally be specified to allow the crate when it
# is a direct dependency of the otherwise banned crate
#{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
]
# List of features to allow/deny
# Each entry the name of a crate and a version range. If version is
# not specified, all versions will be matched.
#[[bans.features]]
#crate = "reqwest"
# Features to not allow
#deny = ["json"]
# Features to allow
#allow = [
# "rustls",
# "__rustls",
# "__tls",
# "hyper-rustls",
# "rustls",
# "rustls-pemfile",
# "rustls-tls-webpki-roots",
# "tokio-rustls",
# "webpki-roots",
#]
# If true, the allowed features must exactly match the enabled feature set. If
# this is set there is no point setting `deny`
#exact = true
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
#"ansi_term@0.11.0",
#{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
]
# Similarly to `skip` allows you to skip certain crates during duplicate
# detection. Unlike skip, it also includes the entire tree of transitive
# dependencies starting at the specified crate, up to a certain depth, which is
# by default infinite.
skip-tree = [
#"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
#{ crate = "ansi_term@0.11.0", depth = 20 },
]
# This section is considered when running `cargo deny check sources`.
# More documentation about the 'sources' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
[sources]
# Lint level for what to happen when a crate from a crate registry that is not
# in the allow list is encountered
unknown-registry = "warn"
# Lint level for what to happen when a crate from a git repository that is not
# in the allow list is encountered
unknown-git = "warn"
# List of URLs for allowed crate registries. Defaults to the crates.io index
# if not specified. If it is specified but empty, no registries are allowed.
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# List of URLs for allowed Git repositories
allow-git = []
[sources.allow-org]
# github.com organizations to allow git sources for
github = []
# gitlab.com organizations to allow git sources for
gitlab = []
# bitbucket.org organizations to allow git sources for
bitbucket = []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -147,7 +147,7 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
Ok(icon_path.to_string_lossy().to_string()) Ok(icon_path.to_string_lossy().to_string())
} else { } else {
let _ = std::fs::remove_file(&temp_path); let _ = std::fs::remove_file(&temp_path);
Err(format!("下载的内容不是有效图片: {}", url)) Err(format!("下载的内容不是有效图片: {url}"))
} }
} }
@@ -217,7 +217,7 @@ pub fn notify_ui_ready() -> CmdResult<()> {
/// UI加载阶段 /// UI加载阶段
#[tauri::command] #[tauri::command]
pub fn update_ui_stage(stage: String) -> CmdResult<()> { pub fn update_ui_stage(stage: String) -> CmdResult<()> {
log::info!(target: "app", "UI加载阶段更新: {}", stage); log::info!(target: "app", "UI加载阶段更新: {stage}");
use crate::utils::resolve::UiReadyStage; use crate::utils::resolve::UiReadyStage;
@@ -228,8 +228,8 @@ pub fn update_ui_stage(stage: String) -> CmdResult<()> {
"ResourcesLoaded" => UiReadyStage::ResourcesLoaded, "ResourcesLoaded" => UiReadyStage::ResourcesLoaded,
"Ready" => UiReadyStage::Ready, "Ready" => UiReadyStage::Ready,
_ => { _ => {
log::warn!(target: "app", "未知的UI加载阶段: {}", stage); log::warn!(target: "app", "未知的UI加载阶段: {stage}");
return Err(format!("未知的UI加载阶段: {}", stage)); return Err(format!("未知的UI加载阶段: {stage}"));
} }
}; };

View File

@@ -49,8 +49,8 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
Ok(None) Ok(None)
} }
Err(err) => { Err(err) => {
let error_msg = format!("Core changed but failed to restart: {}", err); let error_msg = format!("Core changed but failed to restart: {err}");
log::error!(target: "app", "{}", error_msg); log::error!(target: "app", "{error_msg}");
handle::Handle::notice_message("config_core::change_error", &error_msg); handle::Handle::notice_message("config_core::change_error", &error_msg);
Ok(Some(error_msg)) Ok(Some(error_msg))
} }
@@ -116,7 +116,7 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
// 保存DNS配置到文件 // 保存DNS配置到文件
let yaml_str = serde_yaml::to_string(&dns_config).map_err(|e| e.to_string())?; 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())?; fs::write(&dns_path, yaml_str).map_err(|e| e.to_string())?;
log::info!(target: "app", "DNS config saved to {:?}", dns_path); log::info!(target: "app", "DNS config saved to {dns_path:?}");
Ok(()) Ok(())
} }
@@ -137,7 +137,7 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
let dns_path = match dirs::app_home_dir() { let dns_path = match dirs::app_home_dir() {
Ok(path) => path.join("dns_config.yaml"), Ok(path) => path.join("dns_config.yaml"),
Err(e) => { Err(e) => {
log::error!(target: "app", "Failed to get home dir: {}", e); log::error!(target: "app", "Failed to get home dir: {e}");
return; return;
} }
}; };
@@ -150,7 +150,7 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
let dns_yaml = match std::fs::read_to_string(&dns_path) { let dns_yaml = match std::fs::read_to_string(&dns_path) {
Ok(content) => content, Ok(content) => content,
Err(e) => { Err(e) => {
log::error!(target: "app", "Failed to read DNS config: {}", e); log::error!(target: "app", "Failed to read DNS config: {e}");
return; return;
} }
}; };
@@ -163,7 +163,7 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
patch patch
} }
Err(e) => { Err(e) => {
log::error!(target: "app", "Failed to parse DNS config: {}", e); log::error!(target: "app", "Failed to parse DNS config: {e}");
return; return;
} }
}; };
@@ -178,13 +178,13 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
// 首先重新生成配置 // 首先重新生成配置
if let Err(err) = Config::generate().await { if let Err(err) = Config::generate().await {
log::error!(target: "app", "Failed to regenerate config with DNS: {}", err); log::error!(target: "app", "Failed to regenerate config with DNS: {err}");
return; return;
} }
// 然后应用新配置 // 然后应用新配置
if let Err(err) = CoreManager::global().update_config().await { if let Err(err) = CoreManager::global().update_config().await {
log::error!(target: "app", "Failed to apply config with DNS: {}", err); log::error!(target: "app", "Failed to apply config with DNS: {err}");
} else { } else {
log::info!(target: "app", "DNS config successfully applied"); log::info!(target: "app", "DNS config successfully applied");
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
@@ -196,7 +196,7 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
// 重新生成配置 // 重新生成配置
if let Err(err) = Config::generate().await { if let Err(err) = Config::generate().await {
log::error!(target: "app", "Failed to regenerate config: {}", err); log::error!(target: "app", "Failed to regenerate config: {err}");
return; return;
} }
@@ -207,7 +207,7 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
} }
Err(err) => { Err(err) => {
log::error!(target: "app", "Failed to apply regenerated config: {}", err); log::error!(target: "app", "Failed to apply regenerated config: {err}");
} }
} }
} }

View File

@@ -37,7 +37,7 @@ fn country_code_to_emoji(country_code: &str) -> String {
let c2 = 0x1F1E6 + (bytes[1] as u32) - ('A' as u32); let c2 = 0x1F1E6 + (bytes[1] as u32) - ('A' as u32);
char::from_u32(c1) char::from_u32(c1)
.and_then(|c1| char::from_u32(c2).map(|c2| format!("{}{}", c1, c2))) .and_then(|c1| char::from_u32(c2).map(|c2| format!("{c1}{c2}")))
.unwrap_or_default() .unwrap_or_default()
} }
@@ -163,7 +163,7 @@ async fn check_chatgpt_combined(client: &Client) -> Vec<UnlockItem> {
map.get("loc").map(|loc| { map.get("loc").map(|loc| {
let emoji = country_code_to_emoji(loc); let emoji = country_code_to_emoji(loc);
format!("{}{}", emoji, loc) format!("{emoji}{loc}")
}) })
} else { } else {
None None
@@ -255,7 +255,7 @@ async fn check_gemini(client: &Client) -> UnlockItem {
caps.get(1).map(|m| { caps.get(1).map(|m| {
let country_code = m.as_str(); let country_code = m.as_str();
let emoji = country_code_to_emoji(country_code); let emoji = country_code_to_emoji(country_code);
format!("{}{}", emoji, country_code) format!("{emoji}{country_code}")
}) })
}); });
@@ -308,7 +308,7 @@ async fn check_youtube_premium(client: &Client) -> UnlockItem {
caps.get(1).map(|m| { caps.get(1).map(|m| {
let country_code = m.as_str().trim(); let country_code = m.as_str().trim();
let emoji = country_code_to_emoji(country_code); let emoji = country_code_to_emoji(country_code);
format!("{}{}", emoji, country_code) format!("{emoji}{country_code}")
}) })
}); });
@@ -384,10 +384,8 @@ async fn check_bahamut_anime(client: &Client) -> UnlockItem {
} }
// 第二步使用设备ID检查访问权限 (使用相同的Cookie) // 第二步使用设备ID检查访问权限 (使用相同的Cookie)
let url = format!( let url =
"https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={}", format!("https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={device_id}");
device_id
);
let token_result = match client_with_cookies.get(&url).send().await { let token_result = match client_with_cookies.get(&url).send().await {
Ok(response) => { Ok(response) => {
@@ -431,7 +429,7 @@ async fn check_bahamut_anime(client: &Client) -> UnlockItem {
.map(|m| { .map(|m| {
let country_code = m.as_str(); let country_code = m.as_str();
let emoji = country_code_to_emoji(country_code); let emoji = country_code_to_emoji(country_code);
format!("{}{}", emoji, country_code) format!("{emoji}{country_code}")
}) })
} }
Err(_) => None, Err(_) => None,
@@ -470,7 +468,7 @@ async fn check_netflix(client: &Client) -> UnlockItem {
// 检查连接失败情况 // 检查连接失败情况
if let Err(e) = &result1 { if let Err(e) = &result1 {
eprintln!("Netflix请求错误: {}", e); eprintln!("Netflix请求错误: {e}");
return UnlockItem { return UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: "Failed".to_string(), status: "Failed".to_string(),
@@ -487,7 +485,7 @@ async fn check_netflix(client: &Client) -> UnlockItem {
.await; .await;
if let Err(e) = &result2 { if let Err(e) = &result2 {
eprintln!("Netflix请求错误: {}", e); eprintln!("Netflix请求错误: {e}");
return UnlockItem { return UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: "Failed".to_string(), status: "Failed".to_string(),
@@ -541,7 +539,7 @@ async fn check_netflix(client: &Client) -> UnlockItem {
return UnlockItem { return UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: "Yes".to_string(), status: "Yes".to_string(),
region: Some(format!("{}{}", emoji, region_code)), region: Some(format!("{emoji}{region_code}")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
}; };
} }
@@ -557,7 +555,7 @@ async fn check_netflix(client: &Client) -> UnlockItem {
} }
} }
Err(e) => { Err(e) => {
eprintln!("获取Netflix区域信息失败: {}", e); eprintln!("获取Netflix区域信息失败: {e}");
UnlockItem { UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: "Yes (但无法获取区域)".to_string(), status: "Yes (但无法获取区域)".to_string(),
@@ -570,7 +568,7 @@ async fn check_netflix(client: &Client) -> UnlockItem {
// 其他未知错误状态 // 其他未知错误状态
UnlockItem { UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: format!("Failed (状态码: {}_{}", status1, status2), status: format!("Failed (状态码: {status1}_{status2}"),
region: None, region: None,
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
} }
@@ -614,7 +612,7 @@ async fn check_netflix_cdn(client: &Client) -> UnlockItem {
return UnlockItem { return UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: "Yes".to_string(), status: "Yes".to_string(),
region: Some(format!("{}{}", emoji, country)), region: Some(format!("{emoji}{country}")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
}; };
} }
@@ -631,7 +629,7 @@ async fn check_netflix_cdn(client: &Client) -> UnlockItem {
} }
} }
Err(e) => { Err(e) => {
eprintln!("解析Fast.com API响应失败: {}", e); eprintln!("解析Fast.com API响应失败: {e}");
UnlockItem { UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: "Failed (解析错误)".to_string(), status: "Failed (解析错误)".to_string(),
@@ -642,7 +640,7 @@ async fn check_netflix_cdn(client: &Client) -> UnlockItem {
} }
} }
Err(e) => { Err(e) => {
eprintln!("Fast.com API请求失败: {}", e); eprintln!("Fast.com API请求失败: {e}");
UnlockItem { UnlockItem {
name: "Netflix".to_string(), name: "Netflix".to_string(),
status: "Failed (CDN API)".to_string(), status: "Failed (CDN API)".to_string(),
@@ -884,7 +882,7 @@ async fn check_disney_plus(client: &Client) -> UnlockItem {
return UnlockItem { return UnlockItem {
name: "Disney+".to_string(), name: "Disney+".to_string(),
status: "Yes".to_string(), status: "Yes".to_string(),
region: Some(format!("{}{} (from main page)", emoji, region)), region: Some(format!("{emoji}{region} (from main page)")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
}; };
} }
@@ -947,7 +945,7 @@ async fn check_disney_plus(client: &Client) -> UnlockItem {
return UnlockItem { return UnlockItem {
name: "Disney+".to_string(), name: "Disney+".to_string(),
status: "Yes".to_string(), status: "Yes".to_string(),
region: Some(format!("{}{} (from main page)", emoji, region)), region: Some(format!("{emoji}{region} (from main page)")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
}; };
} }
@@ -968,7 +966,7 @@ async fn check_disney_plus(client: &Client) -> UnlockItem {
return UnlockItem { return UnlockItem {
name: "Disney+".to_string(), name: "Disney+".to_string(),
status: "Yes".to_string(), status: "Yes".to_string(),
region: Some(format!("{}{}", emoji, region)), region: Some(format!("{emoji}{region}")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
}; };
} }
@@ -990,7 +988,7 @@ async fn check_disney_plus(client: &Client) -> UnlockItem {
UnlockItem { UnlockItem {
name: "Disney+".to_string(), name: "Disney+".to_string(),
status: "Soon".to_string(), status: "Soon".to_string(),
region: Some(format!("{}{}(即将上线)", emoji, region)), region: Some(format!("{emoji}{region}(即将上线)")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
} }
} }
@@ -999,13 +997,13 @@ async fn check_disney_plus(client: &Client) -> UnlockItem {
UnlockItem { UnlockItem {
name: "Disney+".to_string(), name: "Disney+".to_string(),
status: "Yes".to_string(), status: "Yes".to_string(),
region: Some(format!("{}{}", emoji, region)), region: Some(format!("{emoji}{region}")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
} }
} }
None => UnlockItem { None => UnlockItem {
name: "Disney+".to_string(), name: "Disney+".to_string(),
status: format!("Failed (Error: Unknown region status for {})", region), status: format!("Failed (Error: Unknown region status for {region})"),
region: None, region: None,
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
}, },
@@ -1056,7 +1054,7 @@ async fn check_prime_video(client: &Client) -> UnlockItem {
return UnlockItem { return UnlockItem {
name: "Prime Video".to_string(), name: "Prime Video".to_string(),
status: "Yes".to_string(), status: "Yes".to_string(),
region: Some(format!("{}{}", emoji, region)), region: Some(format!("{emoji}{region}")),
check_time: Some(get_local_date_string()), check_time: Some(get_local_date_string()),
}; };
} }
@@ -1170,7 +1168,7 @@ pub async fn check_media_unlock() -> Result<Vec<UnlockItem>, String> {
.connection_verbose(true) // 详细连接信息 .connection_verbose(true) // 详细连接信息
.build() { .build() {
Ok(client) => client, Ok(client) => client,
Err(e) => return Err(format!("创建HTTP客户端失败: {}", e)), Err(e) => return Err(format!("创建HTTP客户端失败: {e}")),
}; };
// 创建共享的结果集 // 创建共享的结果集
@@ -1284,7 +1282,7 @@ pub async fn check_media_unlock() -> Result<Vec<UnlockItem>, String> {
// 等待所有任务完成 // 等待所有任务完成
while let Some(res) = tasks.join_next().await { while let Some(res) = tasks.join_next().await {
if let Err(e) = res { if let Err(e) = res {
eprintln!("任务执行失败: {}", e); eprintln!("任务执行失败: {e}");
} }
} }

View File

@@ -1,17 +1,15 @@
use super::CmdResult; use super::CmdResult;
use crate::core::{async_proxy_query::AsyncProxyQuery, EventDrivenProxyManager};
use crate::wrap_err; use crate::wrap_err;
use network_interface::NetworkInterface; use network_interface::NetworkInterface;
use serde_yaml::Mapping; use serde_yaml::Mapping;
use sysproxy::{Autoproxy, Sysproxy};
use tokio::task::spawn_blocking;
/// get the system proxy /// get the system proxy
#[tauri::command] #[tauri::command]
pub async fn get_sys_proxy() -> CmdResult<Mapping> { pub async fn get_sys_proxy() -> CmdResult<Mapping> {
let current = spawn_blocking(Sysproxy::get_system_proxy) log::debug!(target: "app", "异步获取系统代理配置");
.await
.map_err(|e| format!("Failed to spawn blocking task for sysproxy: {}", e))? let current = AsyncProxyQuery::get_system_proxy().await;
.map_err(|e| format!("Failed to get system proxy: {}", e))?;
let mut map = Mapping::new(); let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into()); map.insert("enable".into(), current.enable.into());
@@ -21,21 +19,28 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
); );
map.insert("bypass".into(), current.bypass.into()); map.insert("bypass".into(), current.bypass.into());
log::debug!(target: "app", "返回系统代理配置: enable={}, {}:{}", current.enable, current.host, current.port);
Ok(map) Ok(map)
} }
/// get the system proxy /// 获取自动代理配置
#[tauri::command] #[tauri::command]
pub async fn get_auto_proxy() -> CmdResult<Mapping> { pub async fn get_auto_proxy() -> CmdResult<Mapping> {
let current = spawn_blocking(Autoproxy::get_auto_proxy) log::debug!(target: "app", "开始获取自动代理配置(事件驱动)");
.await
.map_err(|e| format!("Failed to spawn blocking task for autoproxy: {}", e))? let proxy_manager = EventDrivenProxyManager::global();
.map_err(|e| format!("Failed to get auto proxy: {}", e))?;
let current = proxy_manager.get_auto_proxy_cached();
// 异步请求更新,立即返回缓存数据
tokio::spawn(async move {
let _ = proxy_manager.get_auto_proxy_async().await;
});
let mut map = Mapping::new(); let mut map = Mapping::new();
map.insert("enable".into(), current.enable.into()); map.insert("enable".into(), current.enable.into());
map.insert("url".into(), current.url.into()); map.insert("url".into(), current.url.clone().into());
log::debug!(target: "app", "返回自动代理配置(缓存): enable={}, url={}", current.enable, current.url);
Ok(map) Ok(map)
} }
@@ -49,7 +54,7 @@ pub fn get_system_hostname() -> CmdResult<String> {
Ok(name) => name, Ok(name) => name,
Err(os_string) => { Err(os_string) => {
// 对于包含非UTF-8的主机名使用调试格式化 // 对于包含非UTF-8的主机名使用调试格式化
let fallback = format!("{:?}", os_string); let fallback = format!("{os_string:?}");
// 去掉可能存在的引号 // 去掉可能存在的引号
fallback.trim_matches('"').to_string() fallback.trim_matches('"').to_string()
} }

View File

@@ -6,12 +6,31 @@ use crate::{
utils::{dirs, help, logging::Type}, utils::{dirs, help, logging::Type},
wrap_err, wrap_err,
}; };
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration; use std::time::Duration;
use tokio::sync::Mutex; use tokio::sync::{Mutex, RwLock};
// 添加全局互斥锁防止并发配置更新 // 全局互斥锁防止并发配置更新
static PROFILE_UPDATE_MUTEX: Mutex<()> = Mutex::const_new(()); static PROFILE_UPDATE_MUTEX: Mutex<()> = Mutex::const_new(());
// 全局请求序列号跟踪,用于避免队列化执行
static CURRENT_REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
static CURRENT_PROCESSING_PROFILE: RwLock<Option<String>> = RwLock::const_new(None);
/// 清理配置处理状态
async fn cleanup_processing_state(sequence: u64, reason: &str) {
*CURRENT_PROCESSING_PROFILE.write().await = None;
logging!(
info,
Type::Cmd,
true,
"{},清理状态,序列号: {}",
reason,
sequence
);
}
/// 获取配置文件避免锁竞争 /// 获取配置文件避免锁竞争
#[tauri::command] #[tauri::command]
pub async fn get_profiles() -> CmdResult<IProfiles> { pub async fn get_profiles() -> CmdResult<IProfiles> {
@@ -151,10 +170,60 @@ pub async fn delete_profile(index: String) -> CmdResult {
/// 修改profiles的配置 /// 修改profiles的配置
#[tauri::command] #[tauri::command]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> { pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 获取互斥锁,防止并发执行 // 为当前请求分配序列号
let _guard = PROFILE_UPDATE_MUTEX.lock().await; let current_sequence = CURRENT_REQUEST_SEQUENCE.fetch_add(1, Ordering::SeqCst) + 1;
let target_profile = profiles.current.clone();
logging!(info, Type::Cmd, true, "开始修改配置文件"); logging!(
info,
Type::Cmd,
true,
"开始修改配置文件,请求序列号: {}, 目标profile: {:?}",
current_sequence,
target_profile
);
let mutex_result =
tokio::time::timeout(Duration::from_millis(100), PROFILE_UPDATE_MUTEX.lock()).await;
let _guard = match mutex_result {
Ok(guard) => guard,
Err(_) => {
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
true,
"检测到更新的请求 (序列号: {} < {}),放弃当前请求",
current_sequence,
latest_sequence
);
return Ok(false);
}
logging!(
info,
Type::Cmd,
true,
"强制获取锁以处理最新请求: {}",
current_sequence
);
PROFILE_UPDATE_MUTEX.lock().await
}
};
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
true,
"获取锁后发现更新的请求 (序列号: {} < {}),放弃当前请求",
current_sequence,
latest_sequence
);
return Ok(false);
}
// 保存当前配置,以便在验证失败时恢复 // 保存当前配置,以便在验证失败时恢复
let current_profile = Config::profiles().latest().current.clone(); let current_profile = Config::profiles().latest().current.clone();
@@ -221,7 +290,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
logging!(info, Type::Cmd, true, "目标配置文件语法正确"); logging!(info, Type::Cmd, true, "目标配置文件语法正确");
} }
Ok(Err(err)) => { Ok(Err(err)) => {
let error_msg = format!(" {}", err); let error_msg = format!(" {err}");
logging!( logging!(
error, error,
Type::Cmd, Type::Cmd,
@@ -236,7 +305,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
return Ok(false); return Ok(false);
} }
Err(join_err) => { Err(join_err) => {
let error_msg = format!("YAML解析任务失败: {}", join_err); let error_msg = format!("YAML解析任务失败: {join_err}");
logging!(error, Type::Cmd, true, "{}", error_msg); logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message( handle::Handle::notice_message(
"config_validate::yaml_parse_error", "config_validate::yaml_parse_error",
@@ -247,7 +316,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
} }
} }
Ok(Err(err)) => { Ok(Err(err)) => {
let error_msg = format!("无法读取目标配置文件: {}", err); let error_msg = format!("无法读取目标配置文件: {err}");
logging!(error, Type::Cmd, true, "{}", error_msg); logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message( handle::Handle::notice_message(
"config_validate::file_read_error", "config_validate::file_read_error",
@@ -269,14 +338,68 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
} }
} }
// 检查请求有效性
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
true,
"在核心操作前发现更新的请求 (序列号: {} < {}),放弃当前请求",
current_sequence,
latest_sequence
);
return Ok(false);
}
if let Some(ref profile) = target_profile {
*CURRENT_PROCESSING_PROFILE.write().await = Some(profile.clone());
logging!(
info,
Type::Cmd,
true,
"设置当前处理profile: {}, 序列号: {}",
profile,
current_sequence
);
}
// 更新profiles配置 // 更新profiles配置
logging!(info, Type::Cmd, true, "正在更新配置草稿"); logging!(
info,
Type::Cmd,
true,
"正在更新配置草稿,序列号: {}",
current_sequence
);
let current_value = profiles.current.clone(); let current_value = profiles.current.clone();
let _ = Config::profiles().draft().patch_config(profiles); let _ = Config::profiles().draft().patch_config(profiles);
// 在调用内核前再次验证请求有效性
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
true,
"在内核交互前发现更新的请求 (序列号: {} < {}),放弃当前请求",
current_sequence,
latest_sequence
);
Config::profiles().discard();
return Ok(false);
}
// 为配置更新添加超时保护 // 为配置更新添加超时保护
logging!(
info,
Type::Cmd,
true,
"开始内核配置更新,序列号: {}",
current_sequence
);
let update_result = tokio::time::timeout( let update_result = tokio::time::timeout(
Duration::from_secs(30), // 30秒超时 Duration::from_secs(30), // 30秒超时
CoreManager::global().update_config(), CoreManager::global().update_config(),
@@ -286,38 +409,68 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 更新配置并进行验证 // 更新配置并进行验证
match update_result { match update_result {
Ok(Ok((true, _))) => { Ok(Ok((true, _))) => {
logging!(info, Type::Cmd, true, "配置更新成功"); // 内核操作完成后再次检查请求有效性
let latest_sequence = CURRENT_REQUEST_SEQUENCE.load(Ordering::SeqCst);
if current_sequence < latest_sequence {
logging!(
info,
Type::Cmd,
true,
"内核操作后发现更新的请求 (序列号: {} < {}),忽略当前结果",
current_sequence,
latest_sequence
);
Config::profiles().discard();
return Ok(false);
}
logging!(
info,
Type::Cmd,
true,
"配置更新成功,序列号: {}",
current_sequence
);
Config::profiles().apply(); Config::profiles().apply();
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
// 强制刷新代理缓存确保profile切换后立即获取最新节点数据 // 强制刷新代理缓存确保profile切换后立即获取最新节点数据
crate::process::AsyncHandler::spawn(|| async move { crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = super::proxy::force_refresh_proxies().await { if let Err(e) = super::proxy::force_refresh_proxies().await {
log::warn!(target: "app", "强制刷新代理缓存失败: {}", e); log::warn!(target: "app", "强制刷新代理缓存失败: {e}");
} }
}); });
crate::process::AsyncHandler::spawn(|| async move { crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = Tray::global().update_tooltip() { if let Err(e) = Tray::global().update_tooltip() {
log::warn!(target: "app", "异步更新托盘提示失败: {}", e); log::warn!(target: "app", "异步更新托盘提示失败: {e}");
} }
if let Err(e) = Tray::global().update_menu() { if let Err(e) = Tray::global().update_menu() {
log::warn!(target: "app", "异步更新托盘菜单失败: {}", e); log::warn!(target: "app", "异步更新托盘菜单失败: {e}");
} }
// 保存配置文件 // 保存配置文件
if let Err(e) = Config::profiles().data().save_file() { if let Err(e) = Config::profiles().data().save_file() {
log::warn!(target: "app", "异步保存配置文件失败: {}", e); log::warn!(target: "app", "异步保存配置文件失败: {e}");
} }
}); });
// 立即通知前端配置变更 // 立即通知前端配置变更
if let Some(current) = &current_value { if let Some(current) = &current_value {
logging!(info, Type::Cmd, true, "向前端发送配置变更事件: {}", current); logging!(
info,
Type::Cmd,
true,
"向前端发送配置变更事件: {}, 序列号: {}",
current,
current_sequence
);
handle::Handle::notify_profile_changed(current.clone()); handle::Handle::notify_profile_changed(current.clone());
} }
cleanup_processing_state(current_sequence, "配置切换完成").await;
Ok(true) Ok(true)
} }
Ok(Ok((false, error_msg))) => { Ok(Ok((false, error_msg))) => {
@@ -342,7 +495,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
crate::process::AsyncHandler::spawn(|| async move { crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = Config::profiles().data().save_file() { if let Err(e) = Config::profiles().data().save_file() {
log::warn!(target: "app", "异步保存恢复配置文件失败: {}", e); log::warn!(target: "app", "异步保存恢复配置文件失败: {e}");
} }
}); });
@@ -351,18 +504,38 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 发送验证错误通知 // 发送验证错误通知
handle::Handle::notice_message("config_validate::error", &error_msg); handle::Handle::notice_message("config_validate::error", &error_msg);
cleanup_processing_state(current_sequence, "配置验证失败").await;
Ok(false) Ok(false)
} }
Ok(Err(e)) => { Ok(Err(e)) => {
logging!(warn, Type::Cmd, true, "更新过程发生错误: {}", e); logging!(
warn,
Type::Cmd,
true,
"更新过程发生错误: {}, 序列号: {}",
e,
current_sequence
);
Config::profiles().discard(); Config::profiles().discard();
handle::Handle::notice_message("config_validate::boot_error", e.to_string()); handle::Handle::notice_message("config_validate::boot_error", e.to_string());
cleanup_processing_state(current_sequence, "更新过程错误").await;
Ok(false) Ok(false)
} }
Err(_) => { Err(_) => {
// 超时处理 // 超时处理
let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞"; let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞";
logging!(error, Type::Cmd, true, "{}", timeout_msg); logging!(
error,
Type::Cmd,
true,
"{}, 序列号: {}",
timeout_msg,
current_sequence
);
Config::profiles().discard(); Config::profiles().discard();
if let Some(prev_profile) = current_profile { if let Some(prev_profile) = current_profile {
@@ -370,8 +543,9 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info, info,
Type::Cmd, Type::Cmd,
true, true,
"超时后尝试恢复到之前的配置: {}", "超时后尝试恢复到之前的配置: {}, 序列号: {}",
prev_profile prev_profile,
current_sequence
); );
let restore_profiles = IProfiles { let restore_profiles = IProfiles {
current: Some(prev_profile), current: Some(prev_profile),
@@ -382,6 +556,9 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
} }
handle::Handle::notice_message("config_validate::timeout", timeout_msg); handle::Handle::notice_message("config_validate::timeout", timeout_msg);
cleanup_processing_state(current_sequence, "配置更新超时").await;
Ok(false) Ok(false)
} }
} }

View File

@@ -24,7 +24,7 @@ static APP_START_TIME: Lazy<AtomicI64> = Lazy::new(|| {
#[tauri::command] #[tauri::command]
pub async fn export_diagnostic_info() -> CmdResult<()> { pub async fn export_diagnostic_info() -> CmdResult<()> {
let sysinfo = PlatformSpecification::new_async().await; let sysinfo = PlatformSpecification::new_async().await;
let info = format!("{:?}", sysinfo); let info = format!("{sysinfo:?}");
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
let cliboard = app_handle.clipboard(); let cliboard = app_handle.clipboard();
@@ -37,7 +37,7 @@ pub async fn export_diagnostic_info() -> CmdResult<()> {
#[tauri::command] #[tauri::command]
pub async fn get_system_info() -> CmdResult<String> { pub async fn get_system_info() -> CmdResult<String> {
let sysinfo = PlatformSpecification::new_async().await; let sysinfo = PlatformSpecification::new_async().await;
let info = format!("{:?}", sysinfo); let info = format!("{sysinfo:?}");
Ok(info) Ok(info)
} }

View File

@@ -21,7 +21,7 @@ pub fn encrypt_data(data: &str) -> Result<String, Box<dyn std::error::Error>> {
// Encrypt data // Encrypt data
let ciphertext = cipher let ciphertext = cipher
.encrypt(nonce.as_slice().into(), data.as_bytes()) .encrypt(nonce.as_slice().into(), data.as_bytes())
.map_err(|e| format!("Encryption failed: {}", e))?; .map_err(|e| format!("Encryption failed: {e}"))?;
// Concatenate nonce and ciphertext and encode them in base64 // Concatenate nonce and ciphertext and encode them in base64
let mut combined = nonce; let mut combined = nonce;
@@ -46,7 +46,7 @@ pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error
// Decrypt data // Decrypt data
let plaintext = cipher let plaintext = cipher
.decrypt(nonce.into(), ciphertext) .decrypt(nonce.into(), ciphertext)
.map_err(|e| format!("Decryption failed: {}", e))?; .map_err(|e| format!("Decryption failed: {e}"))?;
String::from_utf8(plaintext).map_err(|e| e.into()) String::from_utf8(plaintext).map_err(|e| e.into())
} }

View File

@@ -8,6 +8,8 @@ use reqwest::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml::Mapping; use serde_yaml::Mapping;
use std::{fs, time::Duration}; use std::{fs, time::Duration};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use url::Url;
use super::Config; use super::Config;
@@ -53,6 +55,14 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub home: Option<String>, pub home: Option<String>,
/// profile support url
#[serde(skip_serializing_if = "Option::is_none")]
pub support_url: Option<String>,
/// profile announce
#[serde(skip_serializing_if = "Option::is_none")]
pub announce: Option<String>,
/// the file data /// the file data
#[serde(skip)] #[serde(skip)]
pub file_data: Option<String>, pub file_data: Option<String>,
@@ -113,6 +123,9 @@ pub struct PrfOption {
pub proxies: Option<String>, pub proxies: Option<String>,
pub groups: Option<String>, pub groups: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_hwid: Option<bool>,
} }
impl PrfOption { impl PrfOption {
@@ -132,6 +145,7 @@ impl PrfOption {
a.proxies = b.proxies.or(a.proxies); a.proxies = b.proxies.or(a.proxies);
a.groups = b.groups.or(a.groups); a.groups = b.groups.or(a.groups);
a.timeout_seconds = b.timeout_seconds.or(a.timeout_seconds); a.timeout_seconds = b.timeout_seconds.or(a.timeout_seconds);
a.use_hwid = b.use_hwid.or(a.use_hwid);
Some(a) Some(a)
} }
t => t.0.or(t.1), t => t.0.or(t.1),
@@ -230,6 +244,8 @@ impl PrfItem {
..PrfOption::default() ..PrfOption::default()
}), }),
home: None, home: None,
support_url: None,
announce: None,
updated: Some(chrono::Local::now().timestamp() as usize), updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(file_data.unwrap_or(tmpl::ITEM_LOCAL.into())), file_data: Some(file_data.unwrap_or(tmpl::ITEM_LOCAL.into())),
}) })
@@ -251,6 +267,7 @@ impl PrfItem {
let user_agent = opt_ref.and_then(|o| o.user_agent.clone()); let user_agent = opt_ref.and_then(|o| o.user_agent.clone());
let update_interval = opt_ref.and_then(|o| o.update_interval); let update_interval = opt_ref.and_then(|o| o.update_interval);
let timeout = opt_ref.and_then(|o| o.timeout_seconds).unwrap_or(20); let timeout = opt_ref.and_then(|o| o.timeout_seconds).unwrap_or(20);
let use_hwid = opt_ref.is_some_and(|o| o.use_hwid.unwrap_or(true));
let mut merge = opt_ref.and_then(|o| o.merge.clone()); let mut merge = opt_ref.and_then(|o| o.merge.clone());
let mut script = opt_ref.and_then(|o| o.script.clone()); let mut script = opt_ref.and_then(|o| o.script.clone());
let mut rules = opt_ref.and_then(|o| o.rules.clone()); let mut rules = opt_ref.and_then(|o| o.rules.clone());
@@ -274,6 +291,7 @@ impl PrfItem {
Some(timeout), Some(timeout),
user_agent.clone(), user_agent.clone(),
accept_invalid_certs, accept_invalid_certs,
use_hwid,
) )
.await .await
{ {
@@ -291,6 +309,21 @@ impl PrfItem {
let header = resp.headers(); let header = resp.headers();
let mut final_url = url.to_string();
if let Some(new_domain_value) = header.get("new-sub-domain") {
if let Ok(new_domain) = new_domain_value.to_str() {
if !new_domain.is_empty() {
if let Ok(mut parsed_url) = Url::parse(url) {
if parsed_url.set_host(Some(new_domain)).is_ok() {
final_url = parsed_url.to_string();
log::info!(target: "app", "URL host updated to -> {}", final_url);
}
}
}
}
}
// parse the Subscription UserInfo // parse the Subscription UserInfo
let extra = match header.get("Subscription-Userinfo") { let extra = match header.get("Subscription-Userinfo") {
Some(value) => { Some(value) => {
@@ -348,9 +381,45 @@ impl PrfItem {
None => None, None => None,
}; };
let support_url = match header.get("support-url") {
Some(value) => {
let str_value = value.to_str().unwrap_or("");
Some(str_value.to_string())
}
None => None,
};
let announce = match header.get("announce") {
Some(value) => {
let str_value = value.to_str().unwrap_or("");
if let Some(b64_data) = str_value.strip_prefix("base64:") {
STANDARD.decode(b64_data)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
} else {
Some(str_value.to_string())
}
}
None => None,
};
let profile_title = match header.get("profile-title") {
Some(value) => {
let str_value = value.to_str().unwrap_or("");
if let Some(b64_data) = str_value.strip_prefix("base64:") {
STANDARD.decode(b64_data)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
} else {
Some(str_value.to_string())
}
}
None => None,
};
let uid = help::get_uid("R"); let uid = help::get_uid("R");
let file = format!("{uid}.yaml"); let file = format!("{uid}.yaml");
let name = name.unwrap_or(filename.unwrap_or("Remote File".into())); let name = name.or(profile_title).unwrap_or(filename.unwrap_or("Remote File".into()));
let data = resp.text_with_charset("utf-8").await?; let data = resp.text_with_charset("utf-8").await?;
// process the charset "UTF-8 with BOM" // process the charset "UTF-8 with BOM"
@@ -398,7 +467,7 @@ impl PrfItem {
name: Some(name), name: Some(name),
desc, desc,
file: Some(file), file: Some(file),
url: Some(url.into()), url: Some(final_url),
selected: None, selected: None,
extra, extra,
option: Some(PrfOption { option: Some(PrfOption {
@@ -411,6 +480,8 @@ impl PrfItem {
..PrfOption::default() ..PrfOption::default()
}), }),
home, home,
support_url,
announce,
updated: Some(chrono::Local::now().timestamp() as usize), updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(data.into()), file_data: Some(data.into()),
}) })
@@ -438,6 +509,8 @@ impl PrfItem {
extra: None, extra: None,
option: None, option: None,
home: None, home: None,
support_url: None,
announce: None,
updated: Some(chrono::Local::now().timestamp() as usize), updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(template), file_data: Some(template),
}) })
@@ -460,6 +533,8 @@ impl PrfItem {
file: Some(file), file: Some(file),
url: None, url: None,
home: None, home: None,
support_url: None,
announce: None,
selected: None, selected: None,
extra: None, extra: None,
option: None, option: None,
@@ -481,6 +556,8 @@ impl PrfItem {
file: Some(file), file: Some(file),
url: None, url: None,
home: None, home: None,
support_url: None,
announce: None,
selected: None, selected: None,
extra: None, extra: None,
option: None, option: None,
@@ -502,6 +579,8 @@ impl PrfItem {
file: Some(file), file: Some(file),
url: None, url: None,
home: None, home: None,
support_url: None,
announce: None,
selected: None, selected: None,
extra: None, extra: None,
option: None, option: None,
@@ -523,6 +602,8 @@ impl PrfItem {
file: Some(file), file: Some(file),
url: None, url: None,
home: None, home: None,
support_url: None,
announce: None,
selected: None, selected: None,
extra: None, extra: None,
option: None, option: None,

View File

@@ -131,9 +131,9 @@ impl IProfiles {
let path = dirs::app_profiles_dir()?.join(&file); let path = dirs::app_profiles_dir()?.join(&file);
fs::File::create(path) fs::File::create(path)
.with_context(|| format!("failed to create file \"{}\"", file))? .with_context(|| format!("failed to create file \"{file}\""))?
.write(file_data.as_bytes()) .write(file_data.as_bytes())
.with_context(|| format!("failed to write to file \"{}\"", file))?; .with_context(|| format!("failed to write to file \"{file}\""))?;
} }
if self.current.is_none() if self.current.is_none()
@@ -220,6 +220,10 @@ impl IProfiles {
each.extra = item.extra; each.extra = item.extra;
each.updated = item.updated; each.updated = item.updated;
each.home = item.home; each.home = item.home;
each.announce = item.announce;
each.support_url = item.support_url;
each.name = item.name;
each.url = item.url;
each.option = PrfOption::merge(each.option.clone(), item.option); each.option = PrfOption::merge(each.option.clone(), item.option);
// save the file data // save the file data
// move the field value after save // move the field value after save
@@ -234,9 +238,9 @@ impl IProfiles {
let path = dirs::app_profiles_dir()?.join(&file); let path = dirs::app_profiles_dir()?.join(&file);
fs::File::create(path) fs::File::create(path)
.with_context(|| format!("failed to create file \"{}\"", file))? .with_context(|| format!("failed to create file \"{file}\""))?
.write(file_data.as_bytes()) .write(file_data.as_bytes())
.with_context(|| format!("failed to write to file \"{}\"", file))?; .with_context(|| format!("failed to write to file \"{file}\""))?;
} }
break; break;
@@ -531,7 +535,7 @@ impl IProfiles {
if Self::is_profile_file(file_name) { if Self::is_profile_file(file_name) {
// 检查是否为全局扩展文件 // 检查是否为全局扩展文件
if protected_files.contains(file_name) { if protected_files.contains(file_name) {
log::debug!(target: "app", "保护全局扩展配置文件: {}", file_name); log::debug!(target: "app", "保护全局扩展配置文件: {file_name}");
continue; continue;
} }
@@ -540,11 +544,11 @@ impl IProfiles {
match std::fs::remove_file(&path) { match std::fs::remove_file(&path) {
Ok(_) => { Ok(_) => {
deleted_files.push(file_name.to_string()); deleted_files.push(file_name.to_string());
log::info!(target: "app", "已清理冗余文件: {}", file_name); log::info!(target: "app", "已清理冗余文件: {file_name}");
} }
Err(e) => { Err(e) => {
failed_deletions.push(format!("{}: {}", file_name, e)); failed_deletions.push(format!("{file_name}: {e}"));
log::warn!(target: "app", "清理文件失败: {} - {}", file_name, e); log::warn!(target: "app", "清理文件失败: {file_name} - {e}");
} }
} }
} }
@@ -681,7 +685,7 @@ impl IProfiles {
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
log::warn!(target: "app", "自动清理失败: {}", e); log::warn!(target: "app", "自动清理失败: {e}");
Ok(()) Ok(())
} }
} }

View File

@@ -74,6 +74,8 @@ pub struct IVerge {
/// enable dns settings - this controls whether dns_config.yaml is applied /// enable dns settings - this controls whether dns_config.yaml is applied
pub enable_dns_settings: Option<bool>, pub enable_dns_settings: Option<bool>,
pub primary_action: Option<String>,
/// always use default bypass /// always use default bypass
pub use_default_bypass: Option<bool>, pub use_default_bypass: Option<bool>,
@@ -391,7 +393,7 @@ impl IVerge {
auto_close_connection: Some(true), auto_close_connection: Some(true),
auto_check_update: Some(true), auto_check_update: Some(true),
enable_builtin_enhanced: Some(true), enable_builtin_enhanced: Some(true),
auto_log_clean: Some(3), auto_log_clean: Some(2),
webdav_url: None, webdav_url: None,
webdav_username: None, webdav_username: None,
webdav_password: None, webdav_password: None,
@@ -401,6 +403,7 @@ impl IVerge {
enable_auto_light_weight_mode: Some(false), enable_auto_light_weight_mode: Some(false),
auto_light_weight_minutes: Some(10), auto_light_weight_minutes: Some(10),
enable_dns_settings: Some(false), enable_dns_settings: Some(false),
primary_action: Some("tun-mode".into()),
home_cards: None, home_cards: None,
service_state: None, service_state: None,
..Self::default() ..Self::default()
@@ -489,6 +492,7 @@ impl IVerge {
patch!(enable_auto_light_weight_mode); patch!(enable_auto_light_weight_mode);
patch!(auto_light_weight_minutes); patch!(auto_light_weight_minutes);
patch!(enable_dns_settings); patch!(enable_dns_settings);
patch!(primary_action);
patch!(home_cards); patch!(home_cards);
patch!(service_state); patch!(service_state);
} }
@@ -584,6 +588,7 @@ pub struct IVergeResponse {
pub enable_auto_light_weight_mode: Option<bool>, pub enable_auto_light_weight_mode: Option<bool>,
pub auto_light_weight_minutes: Option<u64>, pub auto_light_weight_minutes: Option<u64>,
pub enable_dns_settings: Option<bool>, pub enable_dns_settings: Option<bool>,
pub primary_action: Option<String>,
pub home_cards: Option<serde_json::Value>, pub home_cards: Option<serde_json::Value>,
pub enable_hover_jump_navigator: Option<bool>, pub enable_hover_jump_navigator: Option<bool>,
pub service_state: Option<crate::core::service::ServiceState>, pub service_state: Option<crate::core::service::ServiceState>,
@@ -656,6 +661,7 @@ impl From<IVerge> for IVergeResponse {
enable_auto_light_weight_mode: verge.enable_auto_light_weight_mode, enable_auto_light_weight_mode: verge.enable_auto_light_weight_mode,
auto_light_weight_minutes: verge.auto_light_weight_minutes, auto_light_weight_minutes: verge.auto_light_weight_minutes,
enable_dns_settings: verge.enable_dns_settings, enable_dns_settings: verge.enable_dns_settings,
primary_action: verge.primary_action,
home_cards: verge.home_cards, home_cards: verge.home_cards,
enable_hover_jump_navigator: verge.enable_hover_jump_navigator, enable_hover_jump_navigator: verge.enable_hover_jump_navigator,
service_state: verge.service_state, service_state: verge.service_state,

View File

@@ -0,0 +1,531 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::time::{timeout, Duration};
#[cfg(target_os = "linux")]
use anyhow::anyhow;
#[cfg(not(target_os = "windows"))]
use tokio::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AsyncAutoproxy {
pub enable: bool,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AsyncSysproxy {
pub enable: bool,
pub host: String,
pub port: u16,
pub bypass: String,
}
impl Default for AsyncSysproxy {
fn default() -> Self {
Self {
enable: false,
host: "127.0.0.1".to_string(),
port: 7890,
bypass: String::new(),
}
}
}
pub struct AsyncProxyQuery;
impl AsyncProxyQuery {
/// 异步获取自动代理配置PAC
pub async fn get_auto_proxy() -> AsyncAutoproxy {
match timeout(Duration::from_secs(3), Self::get_auto_proxy_impl()).await {
Ok(Ok(proxy)) => {
log::debug!(target: "app", "异步获取自动代理成功: enable={}, url={}", proxy.enable, proxy.url);
proxy
}
Ok(Err(e)) => {
log::warn!(target: "app", "异步获取自动代理失败: {e}");
AsyncAutoproxy::default()
}
Err(_) => {
log::warn!(target: "app", "异步获取自动代理超时");
AsyncAutoproxy::default()
}
}
}
/// 异步获取系统代理配置
pub async fn get_system_proxy() -> AsyncSysproxy {
match timeout(Duration::from_secs(3), Self::get_system_proxy_impl()).await {
Ok(Ok(proxy)) => {
log::debug!(target: "app", "异步获取系统代理成功: enable={}, {}:{}", proxy.enable, proxy.host, proxy.port);
proxy
}
Ok(Err(e)) => {
log::warn!(target: "app", "异步获取系统代理失败: {e}");
AsyncSysproxy::default()
}
Err(_) => {
log::warn!(target: "app", "异步获取系统代理超时");
AsyncSysproxy::default()
}
}
}
#[cfg(target_os = "windows")]
async fn get_auto_proxy_impl() -> Result<AsyncAutoproxy> {
// Windows: 从注册表读取PAC配置
tokio::task::spawn_blocking(move || -> Result<AsyncAutoproxy> {
Self::get_pac_config_from_registry()
})
.await?
}
#[cfg(target_os = "windows")]
fn get_pac_config_from_registry() -> Result<AsyncAutoproxy> {
use std::ptr;
use winapi::shared::minwindef::{DWORD, HKEY};
use winapi::um::winnt::{KEY_READ, REG_DWORD, REG_SZ};
use winapi::um::winreg::{RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER};
unsafe {
let key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\0"
.encode_utf16()
.collect::<Vec<u16>>();
let mut hkey: HKEY = ptr::null_mut();
let result =
RegOpenKeyExW(HKEY_CURRENT_USER, key_path.as_ptr(), 0, KEY_READ, &mut hkey);
if result != 0 {
log::debug!(target: "app", "无法打开注册表项");
return Ok(AsyncAutoproxy::default());
}
// 1. 检查自动配置是否启用 (AutoConfigURL 存在且不为空即表示启用)
let auto_config_url_name = "AutoConfigURL\0".encode_utf16().collect::<Vec<u16>>();
let mut url_buffer = vec![0u16; 1024];
let mut url_buffer_size: DWORD = (url_buffer.len() * 2) as DWORD;
let mut url_value_type: DWORD = 0;
let url_query_result = RegQueryValueExW(
hkey,
auto_config_url_name.as_ptr(),
ptr::null_mut(),
&mut url_value_type,
url_buffer.as_mut_ptr() as *mut u8,
&mut url_buffer_size,
);
let mut pac_url = String::new();
if url_query_result == 0 && url_value_type == REG_SZ && url_buffer_size > 0 {
let end_pos = url_buffer
.iter()
.position(|&x| x == 0)
.unwrap_or(url_buffer.len());
pac_url = String::from_utf16_lossy(&url_buffer[..end_pos]);
log::debug!(target: "app", "从注册表读取到PAC URL: {}", pac_url);
}
// 2. 检查自动检测设置是否启用
let auto_detect_name = "AutoDetect\0".encode_utf16().collect::<Vec<u16>>();
let mut auto_detect: DWORD = 0;
let mut detect_buffer_size: DWORD = 4;
let mut detect_value_type: DWORD = 0;
let detect_query_result = RegQueryValueExW(
hkey,
auto_detect_name.as_ptr(),
ptr::null_mut(),
&mut detect_value_type,
&mut auto_detect as *mut DWORD as *mut u8,
&mut detect_buffer_size,
);
RegCloseKey(hkey);
// PAC 启用的条件AutoConfigURL 不为空,或 AutoDetect 被启用
let pac_enabled = !pac_url.is_empty()
|| (detect_query_result == 0 && detect_value_type == REG_DWORD && auto_detect != 0);
if pac_enabled {
log::debug!(target: "app", "PAC配置启用: URL={}, AutoDetect={}", pac_url, auto_detect);
if pac_url.is_empty() && auto_detect != 0 {
pac_url = "auto-detect".to_string();
}
Ok(AsyncAutoproxy {
enable: true,
url: pac_url,
})
} else {
log::debug!(target: "app", "PAC配置未启用");
Ok(AsyncAutoproxy::default())
}
}
}
#[cfg(target_os = "macos")]
async fn get_auto_proxy_impl() -> Result<AsyncAutoproxy> {
// macOS: 使用 scutil --proxy 命令
let output = Command::new("scutil").args(["--proxy"]).output().await?;
if !output.status.success() {
return Ok(AsyncAutoproxy::default());
}
let stdout = String::from_utf8_lossy(&output.stdout);
log::debug!(target: "app", "scutil output: {stdout}");
let mut pac_enabled = false;
let mut pac_url = String::new();
// 解析 scutil 输出
for line in stdout.lines() {
let line = line.trim();
if line.contains("ProxyAutoConfigEnable") && line.contains("1") {
pac_enabled = true;
} else if line.contains("ProxyAutoConfigURLString") {
// 正确解析包含冒号的URL
// 格式: "ProxyAutoConfigURLString : http://127.0.0.1:11233/commands/pac"
if let Some(colon_pos) = line.find(" : ") {
pac_url = line[colon_pos + 3..].trim().to_string();
}
}
}
log::debug!(target: "app", "解析结果: pac_enabled={pac_enabled}, pac_url={pac_url}");
Ok(AsyncAutoproxy {
enable: pac_enabled && !pac_url.is_empty(),
url: pac_url,
})
}
#[cfg(target_os = "linux")]
async fn get_auto_proxy_impl() -> Result<AsyncAutoproxy> {
// Linux: 检查环境变量和GNOME设置
// 首先检查环境变量
if let Ok(auto_proxy) = std::env::var("auto_proxy") {
if !auto_proxy.is_empty() {
return Ok(AsyncAutoproxy {
enable: true,
url: auto_proxy,
});
}
}
// 尝试使用 gsettings 获取 GNOME 代理设置
let output = Command::new("gsettings")
.args(["get", "org.gnome.system.proxy", "mode"])
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
let mode = String::from_utf8_lossy(&output.stdout).trim().to_string();
if mode.contains("auto") {
// 获取 PAC URL
let pac_output = Command::new("gsettings")
.args(["get", "org.gnome.system.proxy", "autoconfig-url"])
.output()
.await;
if let Ok(pac_output) = pac_output {
if pac_output.status.success() {
let pac_url = String::from_utf8_lossy(&pac_output.stdout)
.trim()
.trim_matches('\'')
.trim_matches('"')
.to_string();
if !pac_url.is_empty() {
return Ok(AsyncAutoproxy {
enable: true,
url: pac_url,
});
}
}
}
}
}
}
Ok(AsyncAutoproxy::default())
}
#[cfg(target_os = "windows")]
async fn get_system_proxy_impl() -> Result<AsyncSysproxy> {
// Windows: 使用注册表直接读取代理设置
tokio::task::spawn_blocking(move || -> Result<AsyncSysproxy> {
Self::get_system_proxy_from_registry()
})
.await?
}
#[cfg(target_os = "windows")]
fn get_system_proxy_from_registry() -> Result<AsyncSysproxy> {
use std::ptr;
use winapi::shared::minwindef::{DWORD, HKEY};
use winapi::um::winnt::{KEY_READ, REG_DWORD, REG_SZ};
use winapi::um::winreg::{RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER};
unsafe {
let key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\0"
.encode_utf16()
.collect::<Vec<u16>>();
let mut hkey: HKEY = ptr::null_mut();
let result =
RegOpenKeyExW(HKEY_CURRENT_USER, key_path.as_ptr(), 0, KEY_READ, &mut hkey);
if result != 0 {
return Ok(AsyncSysproxy::default());
}
// 检查代理是否启用
let proxy_enable_name = "ProxyEnable\0".encode_utf16().collect::<Vec<u16>>();
let mut proxy_enable: DWORD = 0;
let mut buffer_size: DWORD = 4;
let mut value_type: DWORD = 0;
let enable_result = RegQueryValueExW(
hkey,
proxy_enable_name.as_ptr(),
ptr::null_mut(),
&mut value_type,
&mut proxy_enable as *mut DWORD as *mut u8,
&mut buffer_size,
);
if enable_result != 0 || value_type != REG_DWORD || proxy_enable == 0 {
RegCloseKey(hkey);
return Ok(AsyncSysproxy::default());
}
// 读取代理服务器设置
let proxy_server_name = "ProxyServer\0".encode_utf16().collect::<Vec<u16>>();
let mut buffer = vec![0u16; 1024];
let mut buffer_size: DWORD = (buffer.len() * 2) as DWORD;
let mut value_type: DWORD = 0;
let server_result = RegQueryValueExW(
hkey,
proxy_server_name.as_ptr(),
ptr::null_mut(),
&mut value_type,
buffer.as_mut_ptr() as *mut u8,
&mut buffer_size,
);
let mut proxy_server = String::new();
if server_result == 0 && value_type == REG_SZ && buffer_size > 0 {
let end_pos = buffer.iter().position(|&x| x == 0).unwrap_or(buffer.len());
proxy_server = String::from_utf16_lossy(&buffer[..end_pos]);
}
// 读取代理绕过列表
let proxy_override_name = "ProxyOverride\0".encode_utf16().collect::<Vec<u16>>();
let mut bypass_buffer = vec![0u16; 1024];
let mut bypass_buffer_size: DWORD = (bypass_buffer.len() * 2) as DWORD;
let mut bypass_value_type: DWORD = 0;
let override_result = RegQueryValueExW(
hkey,
proxy_override_name.as_ptr(),
ptr::null_mut(),
&mut bypass_value_type,
bypass_buffer.as_mut_ptr() as *mut u8,
&mut bypass_buffer_size,
);
let mut bypass_list = String::new();
if override_result == 0 && bypass_value_type == REG_SZ && bypass_buffer_size > 0 {
let end_pos = bypass_buffer
.iter()
.position(|&x| x == 0)
.unwrap_or(bypass_buffer.len());
bypass_list = String::from_utf16_lossy(&bypass_buffer[..end_pos]);
}
RegCloseKey(hkey);
if !proxy_server.is_empty() {
// 解析服务器地址和端口
let (host, port) = if let Some(colon_pos) = proxy_server.rfind(':') {
let host = proxy_server[..colon_pos].to_string();
let port = proxy_server[colon_pos + 1..].parse::<u16>().unwrap_or(8080);
(host, port)
} else {
(proxy_server, 8080)
};
log::debug!(target: "app", "从注册表读取到代理设置: {}:{}, bypass: {}", host, port, bypass_list);
Ok(AsyncSysproxy {
enable: true,
host,
port,
bypass: bypass_list,
})
} else {
Ok(AsyncSysproxy::default())
}
}
}
#[cfg(target_os = "macos")]
async fn get_system_proxy_impl() -> Result<AsyncSysproxy> {
let output = Command::new("scutil").args(["--proxy"]).output().await?;
if !output.status.success() {
return Ok(AsyncSysproxy::default());
}
let stdout = String::from_utf8_lossy(&output.stdout);
log::debug!(target: "app", "scutil proxy output: {stdout}");
let mut http_enabled = false;
let mut http_host = String::new();
let mut http_port = 8080u16;
let mut exceptions = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.contains("HTTPEnable") && line.contains("1") {
http_enabled = true;
} else if line.contains("HTTPProxy") && !line.contains("Port") {
if let Some(host_part) = line.split(':').nth(1) {
http_host = host_part.trim().to_string();
}
} else if line.contains("HTTPPort") {
if let Some(port_part) = line.split(':').nth(1) {
if let Ok(port) = port_part.trim().parse::<u16>() {
http_port = port;
}
}
} else if line.contains("ExceptionsList") {
// 解析异常列表
if let Some(list_part) = line.split(':').nth(1) {
let list = list_part.trim();
if !list.is_empty() {
exceptions.push(list.to_string());
}
}
}
}
Ok(AsyncSysproxy {
enable: http_enabled && !http_host.is_empty(),
host: http_host,
port: http_port,
bypass: exceptions.join(","),
})
}
#[cfg(target_os = "linux")]
async fn get_system_proxy_impl() -> Result<AsyncSysproxy> {
// Linux: 检查环境变量和桌面环境设置
// 首先检查环境变量
if let Ok(http_proxy) = std::env::var("http_proxy") {
if let Ok(proxy_info) = Self::parse_proxy_url(&http_proxy) {
return Ok(proxy_info);
}
}
if let Ok(https_proxy) = std::env::var("https_proxy") {
if let Ok(proxy_info) = Self::parse_proxy_url(&https_proxy) {
return Ok(proxy_info);
}
}
// 尝试使用 gsettings 获取 GNOME 代理设置
let mode_output = Command::new("gsettings")
.args(["get", "org.gnome.system.proxy", "mode"])
.output()
.await;
if let Ok(mode_output) = mode_output {
if mode_output.status.success() {
let mode = String::from_utf8_lossy(&mode_output.stdout)
.trim()
.to_string();
if mode.contains("manual") {
// 获取HTTP代理设置
let host_result = Command::new("gsettings")
.args(["get", "org.gnome.system.proxy.http", "host"])
.output()
.await;
let port_result = Command::new("gsettings")
.args(["get", "org.gnome.system.proxy.http", "port"])
.output()
.await;
if let (Ok(host_output), Ok(port_output)) = (host_result, port_result) {
if host_output.status.success() && port_output.status.success() {
let host = String::from_utf8_lossy(&host_output.stdout)
.trim()
.trim_matches('\'')
.trim_matches('"')
.to_string();
let port = String::from_utf8_lossy(&port_output.stdout)
.trim()
.parse::<u16>()
.unwrap_or(8080);
if !host.is_empty() {
return Ok(AsyncSysproxy {
enable: true,
host,
port,
bypass: String::new(),
});
}
}
}
}
}
}
Ok(AsyncSysproxy::default())
}
#[cfg(target_os = "linux")]
fn parse_proxy_url(proxy_url: &str) -> Result<AsyncSysproxy> {
// 解析形如 "http://proxy.example.com:8080" 的URL
let url = proxy_url.trim();
// 移除协议前缀
let url = if let Some(stripped) = url.strip_prefix("http://") {
stripped
} else if let Some(stripped) = url.strip_prefix("https://") {
stripped
} else {
url
};
// 解析主机和端口
let (host, port) = if let Some(colon_pos) = url.rfind(':') {
let host = url[..colon_pos].to_string();
let port = url[colon_pos + 1..].parse::<u16>().unwrap_or(8080);
(host, port)
} else {
(url.to_string(), 8080)
};
if host.is_empty() {
return Err(anyhow!("无效的代理URL"));
}
Ok(AsyncSysproxy {
enable: true,
host,
port,
bypass: std::env::var("no_proxy").unwrap_or_default(),
})
}
}

View File

@@ -108,10 +108,7 @@ impl WebDavClient {
reqwest::Client::builder() reqwest::Client::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(op.timeout())) .timeout(Duration::from_secs(op.timeout()))
.user_agent(format!( .user_agent(format!("clash-verge/{APP_VERSION} ({OS} WebDAV-Client)"))
"clash-verge/{} ({} WebDAV-Client)",
APP_VERSION, OS
))
.redirect(reqwest::redirect::Policy::custom(|attempt| { .redirect(reqwest::redirect::Policy::custom(|attempt| {
// 允许所有请求类型的重定向包括PUT // 允许所有请求类型的重定向包括PUT
if attempt.previous().len() >= 5 { if attempt.previous().len() >= 5 {
@@ -177,7 +174,7 @@ impl WebDavClient {
} }
Ok(Err(e)) => { Ok(Err(e)) => {
log::warn!("Upload failed, retrying once: {}", e); log::warn!("Upload failed, retrying once: {e}");
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
timeout( timeout(
Duration::from_secs(TIMEOUT_UPLOAD), Duration::from_secs(TIMEOUT_UPLOAD),
@@ -237,7 +234,7 @@ impl WebDavClient {
pub fn create_backup() -> Result<(String, PathBuf), Error> { pub fn create_backup() -> Result<(String, PathBuf), Error> {
let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string(); let now = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S").to_string();
let zip_file_name = format!("{}-backup-{}.zip", OS, now); let zip_file_name = format!("{OS}-backup-{now}.zip");
let zip_path = temp_dir().join(&zip_file_name); let zip_path = temp_dir().join(&zip_file_name);
let file = fs::File::create(&zip_path)?; let file = fs::File::create(&zip_path)?;

View File

@@ -1,5 +1,3 @@
#[cfg(target_os = "macos")]
use crate::core::tray::Tray;
use crate::{ use crate::{
config::*, config::*,
core::{ core::{
@@ -174,7 +172,7 @@ impl CoreManager {
// 检查文件是否存在 // 检查文件是否存在
if !std::path::Path::new(config_path).exists() { if !std::path::Path::new(config_path).exists() {
let error_msg = format!("File not found: {}", config_path); let error_msg = format!("File not found: {config_path}");
//handle::Handle::notice_message("config_validate::file_not_found", &error_msg); //handle::Handle::notice_message("config_validate::file_not_found", &error_msg);
return Ok((false, error_msg)); return Ok((false, error_msg));
} }
@@ -286,7 +284,7 @@ impl CoreManager {
} else if !stderr.is_empty() { } else if !stderr.is_empty() {
stderr.to_string() stderr.to_string()
} else if let Some(code) = output.status.code() { } else if let Some(code) = output.status.code() {
format!("验证进程异常退出,退出码: {}", code) format!("验证进程异常退出,退出码: {code}")
} else { } else {
"验证进程被终止".to_string() "验证进程被终止".to_string()
}; };
@@ -307,7 +305,7 @@ impl CoreManager {
let content = match std::fs::read_to_string(config_path) { let content = match std::fs::read_to_string(config_path) {
Ok(content) => content, Ok(content) => content,
Err(err) => { Err(err) => {
let error_msg = format!("Failed to read file: {}", err); let error_msg = format!("Failed to read file: {err}");
logging!(error, Type::Config, true, "无法读取文件: {}", error_msg); logging!(error, Type::Config, true, "无法读取文件: {}", error_msg);
return Ok((false, error_msg)); return Ok((false, error_msg));
} }
@@ -321,7 +319,7 @@ impl CoreManager {
} }
Err(err) => { Err(err) => {
// 使用标准化的前缀,以便错误处理函数能正确识别 // 使用标准化的前缀,以便错误处理函数能正确识别
let error_msg = format!("YAML syntax error: {}", err); let error_msg = format!("YAML syntax error: {err}");
logging!(error, Type::Config, true, "YAML语法错误: {}", error_msg); logging!(error, Type::Config, true, "YAML语法错误: {}", error_msg);
Ok((false, error_msg)) Ok((false, error_msg))
} }
@@ -333,7 +331,7 @@ impl CoreManager {
let content = match std::fs::read_to_string(path) { let content = match std::fs::read_to_string(path) {
Ok(content) => content, Ok(content) => content,
Err(err) => { Err(err) => {
let error_msg = format!("Failed to read script file: {}", err); let error_msg = format!("Failed to read script file: {err}");
logging!(warn, Type::Config, true, "脚本语法错误: {}", err); logging!(warn, Type::Config, true, "脚本语法错误: {}", err);
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg); //handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
return Ok((false, error_msg)); return Ok((false, error_msg));
@@ -366,7 +364,7 @@ impl CoreManager {
Ok((true, String::new())) Ok((true, String::new()))
} }
Err(err) => { Err(err) => {
let error_msg = format!("Script syntax error: {}", err); let error_msg = format!("Script syntax error: {err}");
logging!(warn, Type::Config, true, "脚本语法错误: {}", err); logging!(warn, Type::Config, true, "脚本语法错误: {}", err);
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg); //handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
Ok((false, error_msg)) Ok((false, error_msg))
@@ -435,6 +433,306 @@ impl CoreManager {
} }
impl CoreManager { impl CoreManager {
/// 清理多余的 mihomo 进程
async fn cleanup_orphaned_mihomo_processes(&self) -> Result<()> {
logging!(info, Type::Core, true, "开始清理多余的 mihomo 进程");
// 获取当前管理的进程 PID
let current_pid = {
let child_guard = self.child_sidecar.lock().await;
child_guard.as_ref().map(|child| child.pid())
};
let target_processes = ["verge-mihomo", "verge-mihomo-alpha"];
// 并行查找所有目标进程
let mut process_futures = Vec::new();
for &target in &target_processes {
let process_name = if cfg!(windows) {
format!("{target}.exe")
} else {
target.to_string()
};
process_futures.push(self.find_processes_by_name(process_name, target));
}
let process_results = futures::future::join_all(process_futures).await;
// 收集所有需要终止的进程PID
let mut pids_to_kill = Vec::new();
for result in process_results {
match result {
Ok((pids, process_name)) => {
for pid in pids {
// 跳过当前管理的进程
if let Some(current) = current_pid {
if pid == current {
logging!(
debug,
Type::Core,
true,
"跳过当前管理的进程: {} (PID: {})",
process_name,
pid
);
continue;
}
}
pids_to_kill.push((pid, process_name.clone()));
}
}
Err(e) => {
logging!(debug, Type::Core, true, "查找进程时发生错误: {}", e);
}
}
}
if pids_to_kill.is_empty() {
logging!(debug, Type::Core, true, "未发现多余的 mihomo 进程");
return Ok(());
}
let mut kill_futures = Vec::new();
for (pid, process_name) in &pids_to_kill {
kill_futures.push(self.kill_process_with_verification(*pid, process_name.clone()));
}
let kill_results = futures::future::join_all(kill_futures).await;
let killed_count = kill_results.into_iter().filter(|&success| success).count();
if killed_count > 0 {
logging!(
info,
Type::Core,
true,
"清理完成,共终止了 {} 个多余的 mihomo 进程",
killed_count
);
}
Ok(())
}
/// 根据进程名查找进程PID列
async fn find_processes_by_name(
&self,
process_name: String,
_target: &str,
) -> Result<(Vec<u32>, String)> {
#[cfg(windows)]
{
use std::mem;
use winapi::um::handleapi::CloseHandle;
use winapi::um::tlhelp32::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
};
use winapi::um::winnt::HANDLE;
let process_name_clone = process_name.clone();
let pids = tokio::task::spawn_blocking(move || -> Result<Vec<u32>> {
let mut pids = Vec::new();
unsafe {
// 创建进程快照
let snapshot: HANDLE = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot == winapi::um::handleapi::INVALID_HANDLE_VALUE {
return Err(anyhow::anyhow!("Failed to create process snapshot"));
}
let mut pe32: PROCESSENTRY32W = mem::zeroed();
pe32.dwSize = mem::size_of::<PROCESSENTRY32W>() as u32;
// 获取第一个进程
if Process32FirstW(snapshot, &mut pe32) != 0 {
loop {
// 将宽字符转换为String
let end_pos = pe32
.szExeFile
.iter()
.position(|&x| x == 0)
.unwrap_or(pe32.szExeFile.len());
let exe_file = String::from_utf16_lossy(&pe32.szExeFile[..end_pos]);
// 检查进程名是否匹配
if exe_file.eq_ignore_ascii_case(&process_name_clone) {
pids.push(pe32.th32ProcessID);
}
if Process32NextW(snapshot, &mut pe32) == 0 {
break;
}
}
}
// 关闭句柄
CloseHandle(snapshot);
}
Ok(pids)
})
.await??;
Ok((pids, process_name))
}
#[cfg(not(windows))]
{
let output = if cfg!(target_os = "macos") {
tokio::process::Command::new("pgrep")
.arg(&process_name)
.output()
.await?
} else {
// Linux
tokio::process::Command::new("pidof")
.arg(&process_name)
.output()
.await?
};
if !output.status.success() {
return Ok((Vec::new(), process_name));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut pids = Vec::new();
// Unix系统直接解析PID列表
for pid_str in stdout.split_whitespace() {
if let Ok(pid) = pid_str.parse::<u32>() {
pids.push(pid);
}
}
Ok((pids, process_name))
}
}
/// 终止进程并验证结果 - 使用Windows API直接终止更优雅高效
async fn kill_process_with_verification(&self, pid: u32, process_name: String) -> bool {
logging!(
info,
Type::Core,
true,
"尝试终止进程: {} (PID: {})",
process_name,
pid
);
#[cfg(windows)]
let success = {
use winapi::um::handleapi::CloseHandle;
use winapi::um::processthreadsapi::{OpenProcess, TerminateProcess};
use winapi::um::winnt::{HANDLE, PROCESS_TERMINATE};
tokio::task::spawn_blocking(move || -> bool {
unsafe {
let process_handle: HANDLE = OpenProcess(PROCESS_TERMINATE, 0, pid);
if process_handle.is_null() {
return false;
}
let result = TerminateProcess(process_handle, 1);
CloseHandle(process_handle);
result != 0
}
})
.await
.unwrap_or(false)
};
#[cfg(not(windows))]
let success = {
tokio::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output()
.await
.map(|output| output.status.success())
.unwrap_or(false)
};
if success {
// 短暂等待并验证进程是否真正终止
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let still_running = self.is_process_running(pid).await.unwrap_or(false);
if still_running {
logging!(
warn,
Type::Core,
true,
"进程 {} (PID: {}) 终止命令成功但进程仍在运行",
process_name,
pid
);
false
} else {
logging!(
info,
Type::Core,
true,
"成功终止进程: {} (PID: {})",
process_name,
pid
);
true
}
} else {
logging!(
warn,
Type::Core,
true,
"无法终止进程: {} (PID: {})",
process_name,
pid
);
false
}
}
/// Windows API检查进程
async fn is_process_running(&self, pid: u32) -> Result<bool> {
#[cfg(windows)]
{
use winapi::shared::minwindef::DWORD;
use winapi::um::handleapi::CloseHandle;
use winapi::um::processthreadsapi::GetExitCodeProcess;
use winapi::um::processthreadsapi::OpenProcess;
use winapi::um::winnt::{HANDLE, PROCESS_QUERY_INFORMATION};
let result = tokio::task::spawn_blocking(move || -> Result<bool> {
unsafe {
let process_handle: HANDLE = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid);
if process_handle.is_null() {
return Ok(false);
}
let mut exit_code: DWORD = 0;
let result = GetExitCodeProcess(process_handle, &mut exit_code);
CloseHandle(process_handle);
if result == 0 {
return Ok(false);
}
Ok(exit_code == 259)
}
})
.await?;
result
}
#[cfg(not(windows))]
{
let output = tokio::process::Command::new("ps")
.args(["-p", &pid.to_string()])
.output()
.await?;
Ok(output.status.success() && !output.stdout.is_empty())
}
}
async fn start_core_by_sidecar(&self) -> Result<()> { async fn start_core_by_sidecar(&self) -> Result<()> {
logging!(trace, Type::Core, true, "Running core by sidecar"); logging!(trace, Type::Core, true, "Running core by sidecar");
let config_file = &Config::generate_file(ConfigType::Run)?; let config_file = &Config::generate_file(ConfigType::Run)?;
@@ -450,7 +748,7 @@ impl CoreManager {
let now = Local::now(); let now = Local::now();
let timestamp = now.format("%Y%m%d_%H%M%S").to_string(); let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
let log_path = service_log_dir.join(format!("sidecar_{}.log", timestamp)); let log_path = service_log_dir.join(format!("sidecar_{timestamp}.log"));
let mut log_file = File::create(log_path)?; let mut log_file = File::create(log_path)?;
@@ -566,7 +864,7 @@ impl CoreManager {
let mut state = service::ServiceState::get(); let mut state = service::ServiceState::get();
if !state.prefer_sidecar { if !state.prefer_sidecar {
state.prefer_sidecar = true; state.prefer_sidecar = true;
state.last_error = Some(format!("通过服务启动核心失败: {}", e)); state.last_error = Some(format!("通过服务启动核心失败: {e}"));
if let Err(save_err) = state.save() { if let Err(save_err) = state.save() {
logging!( logging!(
error, error,
@@ -585,6 +883,17 @@ impl CoreManager {
pub async fn init(&self) -> Result<()> { pub async fn init(&self) -> Result<()> {
logging!(trace, Type::Core, "Initializing core"); logging!(trace, Type::Core, "Initializing core");
// 应用启动时先清理任何遗留的 mihomo 进程
if let Err(e) = self.cleanup_orphaned_mihomo_processes().await {
logging!(
warn,
Type::Core,
true,
"应用初始化时清理多余 mihomo 进程失败: {}",
e
);
}
let mut core_started_successfully = false; let mut core_started_successfully = false;
if service::is_service_available().await.is_ok() { if service::is_service_available().await.is_ok() {
@@ -732,9 +1041,8 @@ impl CoreManager {
} }
logging!(trace, Type::Core, "Initied core logic completed"); logging!(trace, Type::Core, "Initied core logic completed");
#[cfg(target_os = "macos")] // #[cfg(target_os = "macos")]
logging_error!(Type::Core, true, Tray::global().subscribe_traffic().await); // logging_error!(Type::Core, true, Tray::global().subscribe_traffic().await);
Ok(()) Ok(())
} }
@@ -787,6 +1095,7 @@ impl CoreManager {
/// 重启内核 /// 重启内核
pub async fn restart_core(&self) -> Result<()> { pub async fn restart_core(&self) -> Result<()> {
self.stop_core().await?; self.stop_core().await?;
self.start_core().await?; self.start_core().await?;
Ok(()) Ok(())
} }
@@ -800,7 +1109,7 @@ impl CoreManager {
} }
let core: &str = &clash_core.clone().unwrap(); let core: &str = &clash_core.clone().unwrap();
if !IVerge::VALID_CLASH_CORES.contains(&core) { if !IVerge::VALID_CLASH_CORES.contains(&core) {
let error_message = format!("Clash core invalid name: {}", core); let error_message = format!("Clash core invalid name: {core}");
logging!(error, Type::Core, true, "{}", error_message); logging!(error, Type::Core, true, "{}", error_message);
return Err(error_message); return Err(error_message);
} }

View File

@@ -0,0 +1,571 @@
use parking_lot::RwLock;
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{sleep, timeout, Duration};
use crate::config::{Config, IVerge};
use crate::core::async_proxy_query::AsyncProxyQuery;
use crate::logging_error;
use crate::utils::logging::Type;
use once_cell::sync::Lazy;
use sysproxy::{Autoproxy, Sysproxy};
#[derive(Debug, Clone)]
pub enum ProxyEvent {
/// 配置变更事件
ConfigChanged,
/// 强制检查代理状态
#[allow(dead_code)]
ForceCheck,
/// 启用系统代理
#[allow(dead_code)]
EnableProxy,
/// 禁用系统代理
#[allow(dead_code)]
DisableProxy,
/// 切换到PAC模式
#[allow(dead_code)]
SwitchToPac,
/// 切换到HTTP代理模式
#[allow(dead_code)]
SwitchToHttp,
/// 应用启动事件
AppStarted,
/// 应用关闭事件
#[allow(dead_code)]
AppStopping,
}
#[derive(Debug, Clone)]
pub struct ProxyState {
pub sys_enabled: bool,
pub pac_enabled: bool,
pub auto_proxy: Autoproxy,
pub sys_proxy: Sysproxy,
pub last_updated: std::time::Instant,
pub is_healthy: bool,
}
impl Default for ProxyState {
fn default() -> Self {
Self {
sys_enabled: false,
pac_enabled: false,
auto_proxy: Autoproxy {
enable: false,
url: "".to_string(),
},
sys_proxy: Sysproxy {
enable: false,
host: "127.0.0.1".to_string(),
port: 7890,
bypass: "".to_string(),
},
last_updated: std::time::Instant::now(),
is_healthy: true,
}
}
}
pub struct EventDrivenProxyManager {
state: Arc<RwLock<ProxyState>>,
event_sender: mpsc::UnboundedSender<ProxyEvent>,
query_sender: mpsc::UnboundedSender<QueryRequest>,
}
#[derive(Debug)]
struct QueryRequest {
response_tx: oneshot::Sender<Autoproxy>,
}
// 配置结构体移到外部
struct ProxyConfig {
sys_enabled: bool,
pac_enabled: bool,
guard_enabled: bool,
}
static PROXY_MANAGER: Lazy<EventDrivenProxyManager> = Lazy::new(EventDrivenProxyManager::new);
impl EventDrivenProxyManager {
pub fn global() -> &'static EventDrivenProxyManager {
&PROXY_MANAGER
}
fn new() -> Self {
let state = Arc::new(RwLock::new(ProxyState::default()));
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (query_tx, query_rx) = mpsc::unbounded_channel();
Self::start_event_loop(state.clone(), event_rx, query_rx);
Self {
state,
event_sender: event_tx,
query_sender: query_tx,
}
}
/// 获取自动代理配置(缓存)
pub fn get_auto_proxy_cached(&self) -> Autoproxy {
self.state.read().auto_proxy.clone()
}
/// 异步获取最新的自动代理配置
pub async fn get_auto_proxy_async(&self) -> Autoproxy {
let (tx, rx) = oneshot::channel();
let query = QueryRequest { response_tx: tx };
if self.query_sender.send(query).is_err() {
log::error!(target: "app", "发送查询请求失败,返回缓存数据");
return self.get_auto_proxy_cached();
}
match timeout(Duration::from_secs(5), rx).await {
Ok(Ok(result)) => result,
_ => {
log::warn!(target: "app", "查询超时,返回缓存数据");
self.get_auto_proxy_cached()
}
}
}
/// 通知配置变更
pub fn notify_config_changed(&self) {
self.send_event(ProxyEvent::ConfigChanged);
}
/// 通知应用启动
pub fn notify_app_started(&self) {
self.send_event(ProxyEvent::AppStarted);
}
/// 通知应用即将关闭
#[allow(dead_code)]
pub fn notify_app_stopping(&self) {
self.send_event(ProxyEvent::AppStopping);
}
/// 启用系统代理
#[allow(dead_code)]
pub fn enable_proxy(&self) {
self.send_event(ProxyEvent::EnableProxy);
}
/// 禁用系统代理
#[allow(dead_code)]
pub fn disable_proxy(&self) {
self.send_event(ProxyEvent::DisableProxy);
}
/// 强制检查代理状态
#[allow(dead_code)]
pub fn force_check(&self) {
self.send_event(ProxyEvent::ForceCheck);
}
fn send_event(&self, event: ProxyEvent) {
if let Err(e) = self.event_sender.send(event) {
log::error!(target: "app", "发送代理事件失败: {e}");
}
}
fn start_event_loop(
state: Arc<RwLock<ProxyState>>,
mut event_rx: mpsc::UnboundedReceiver<ProxyEvent>,
mut query_rx: mpsc::UnboundedReceiver<QueryRequest>,
) {
tokio::spawn(async move {
log::info!(target: "app", "事件驱动代理管理器启动");
loop {
tokio::select! {
event = event_rx.recv() => {
match event {
Some(event) => {
log::debug!(target: "app", "处理代理事件: {event:?}");
Self::handle_event(&state, event).await;
}
None => {
log::info!(target: "app", "事件通道关闭,代理管理器停止");
break;
}
}
}
query = query_rx.recv() => {
match query {
Some(query) => {
let result = Self::handle_query(&state).await;
let _ = query.response_tx.send(result);
}
None => {
log::info!(target: "app", "查询通道关闭");
break;
}
}
}
}
}
});
}
async fn handle_event(state: &Arc<RwLock<ProxyState>>, event: ProxyEvent) {
match event {
ProxyEvent::ConfigChanged | ProxyEvent::ForceCheck => {
Self::update_proxy_config(state).await;
}
ProxyEvent::EnableProxy => {
Self::enable_system_proxy(state).await;
}
ProxyEvent::DisableProxy => {
Self::disable_system_proxy(state).await;
}
ProxyEvent::SwitchToPac => {
Self::switch_proxy_mode(state, true).await;
}
ProxyEvent::SwitchToHttp => {
Self::switch_proxy_mode(state, false).await;
}
ProxyEvent::AppStarted => {
Self::initialize_proxy_state(state).await;
}
ProxyEvent::AppStopping => {
log::info!(target: "app", "清理代理状态");
}
}
}
async fn handle_query(state: &Arc<RwLock<ProxyState>>) -> Autoproxy {
let auto_proxy = Self::get_auto_proxy_with_timeout().await;
Self::update_state_timestamp(state, |s| {
s.auto_proxy = auto_proxy.clone();
});
auto_proxy
}
async fn initialize_proxy_state(state: &Arc<RwLock<ProxyState>>) {
log::info!(target: "app", "初始化代理状态");
let config = Self::get_proxy_config();
let auto_proxy = Self::get_auto_proxy_with_timeout().await;
let sys_proxy = Self::get_sys_proxy_with_timeout().await;
Self::update_state_timestamp(state, |s| {
s.sys_enabled = config.sys_enabled;
s.pac_enabled = config.pac_enabled;
s.auto_proxy = auto_proxy;
s.sys_proxy = sys_proxy;
s.is_healthy = true;
});
log::info!(target: "app", "代理状态初始化完成: sys={}, pac={}", config.sys_enabled, config.pac_enabled);
}
async fn update_proxy_config(state: &Arc<RwLock<ProxyState>>) {
log::debug!(target: "app", "更新代理配置");
let config = Self::get_proxy_config();
Self::update_state_timestamp(state, |s| {
s.sys_enabled = config.sys_enabled;
s.pac_enabled = config.pac_enabled;
});
if config.guard_enabled && config.sys_enabled {
Self::check_and_restore_proxy(state).await;
}
}
async fn check_and_restore_proxy(state: &Arc<RwLock<ProxyState>>) {
let (sys_enabled, pac_enabled) = {
let s = state.read();
(s.sys_enabled, s.pac_enabled)
};
if !sys_enabled {
return;
}
log::debug!(target: "app", "检查代理状态");
if pac_enabled {
Self::check_and_restore_pac_proxy(state).await;
} else {
Self::check_and_restore_sys_proxy(state).await;
}
}
async fn check_and_restore_pac_proxy(state: &Arc<RwLock<ProxyState>>) {
let current = Self::get_auto_proxy_with_timeout().await;
let expected = Self::get_expected_pac_config();
Self::update_state_timestamp(state, |s| {
s.auto_proxy = current.clone();
});
if !current.enable || current.url != expected.url {
log::info!(target: "app", "PAC代理设置异常正在恢复...");
Self::restore_pac_proxy(&expected.url).await;
sleep(Duration::from_millis(500)).await;
let restored = Self::get_auto_proxy_with_timeout().await;
Self::update_state_timestamp(state, |s| {
s.is_healthy = restored.enable && restored.url == expected.url;
s.auto_proxy = restored;
});
}
}
async fn check_and_restore_sys_proxy(state: &Arc<RwLock<ProxyState>>) {
let current = Self::get_sys_proxy_with_timeout().await;
let expected = Self::get_expected_sys_proxy();
Self::update_state_timestamp(state, |s| {
s.sys_proxy = current.clone();
});
if !current.enable || current.host != expected.host || current.port != expected.port {
log::info!(target: "app", "系统代理设置异常,正在恢复...");
Self::restore_sys_proxy(&expected).await;
sleep(Duration::from_millis(500)).await;
let restored = Self::get_sys_proxy_with_timeout().await;
Self::update_state_timestamp(state, |s| {
s.is_healthy = restored.enable
&& restored.host == expected.host
&& restored.port == expected.port;
s.sys_proxy = restored;
});
}
}
async fn enable_system_proxy(state: &Arc<RwLock<ProxyState>>) {
log::info!(target: "app", "启用系统代理");
let pac_enabled = state.read().pac_enabled;
if pac_enabled {
let expected = Self::get_expected_pac_config();
Self::restore_pac_proxy(&expected.url).await;
} else {
let expected = Self::get_expected_sys_proxy();
Self::restore_sys_proxy(&expected).await;
}
Self::check_and_restore_proxy(state).await;
}
async fn disable_system_proxy(_state: &Arc<RwLock<ProxyState>>) {
log::info!(target: "app", "禁用系统代理");
#[cfg(not(target_os = "windows"))]
{
let disabled_sys = Sysproxy::default();
let disabled_auto = Autoproxy::default();
logging_error!(Type::System, true, disabled_auto.set_auto_proxy());
logging_error!(Type::System, true, disabled_sys.set_system_proxy());
}
}
async fn switch_proxy_mode(state: &Arc<RwLock<ProxyState>>, to_pac: bool) {
log::info!(target: "app", "切换到{}模式", if to_pac { "PAC" } else { "HTTP代理" });
if to_pac {
let disabled_sys = Sysproxy::default();
logging_error!(Type::System, true, disabled_sys.set_system_proxy());
let expected = Self::get_expected_pac_config();
Self::restore_pac_proxy(&expected.url).await;
} else {
let disabled_auto = Autoproxy::default();
logging_error!(Type::System, true, disabled_auto.set_auto_proxy());
let expected = Self::get_expected_sys_proxy();
Self::restore_sys_proxy(&expected).await;
}
Self::update_state_timestamp(state, |s| s.pac_enabled = to_pac);
Self::check_and_restore_proxy(state).await;
}
async fn get_auto_proxy_with_timeout() -> Autoproxy {
let async_proxy = AsyncProxyQuery::get_auto_proxy().await;
// 转换为兼容的结构
Autoproxy {
enable: async_proxy.enable,
url: async_proxy.url,
}
}
async fn get_sys_proxy_with_timeout() -> Sysproxy {
let async_proxy = AsyncProxyQuery::get_system_proxy().await;
// 转换为兼容的结构
Sysproxy {
enable: async_proxy.enable,
host: async_proxy.host,
port: async_proxy.port,
bypass: async_proxy.bypass,
}
}
// 统一的状态更新方法
fn update_state_timestamp<F>(state: &Arc<RwLock<ProxyState>>, update_fn: F)
where
F: FnOnce(&mut ProxyState),
{
let mut state_guard = state.write();
update_fn(&mut state_guard);
state_guard.last_updated = std::time::Instant::now();
}
fn get_proxy_config() -> ProxyConfig {
let verge_config = Config::verge();
let verge = verge_config.latest();
ProxyConfig {
sys_enabled: verge.enable_system_proxy.unwrap_or(false),
pac_enabled: verge.proxy_auto_config.unwrap_or(false),
guard_enabled: verge.enable_proxy_guard.unwrap_or(false),
}
}
fn get_expected_pac_config() -> Autoproxy {
let verge_config = Config::verge();
let verge = verge_config.latest();
let (proxy_host, pac_port) = (
verge
.proxy_host
.clone()
.unwrap_or_else(|| "127.0.0.1".to_string()),
IVerge::get_singleton_port(),
);
Autoproxy {
enable: true,
url: format!("http://{proxy_host}:{pac_port}/commands/pac"),
}
}
fn get_expected_sys_proxy() -> Sysproxy {
let verge_config = Config::verge();
let verge = verge_config.latest();
let port = verge
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let proxy_host = verge
.proxy_host
.clone()
.unwrap_or_else(|| "127.0.0.1".to_string());
Sysproxy {
enable: true,
host: proxy_host,
port,
bypass: Self::get_bypass_config(),
}
}
fn get_bypass_config() -> String {
let verge_config = Config::verge();
let verge = verge_config.latest();
let use_default = verge.use_default_bypass.unwrap_or(true);
let custom_bypass = verge.system_proxy_bypass.clone().unwrap_or_default();
#[cfg(target_os = "windows")]
let default_bypass = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>";
#[cfg(target_os = "linux")]
let default_bypass =
"localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,::1";
#[cfg(target_os = "macos")]
let default_bypass = "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,<local>";
if custom_bypass.is_empty() {
default_bypass.to_string()
} else if use_default {
format!("{default_bypass},{custom_bypass}")
} else {
custom_bypass
}
}
async fn restore_pac_proxy(expected_url: &str) {
#[cfg(not(target_os = "windows"))]
{
let new_autoproxy = Autoproxy {
enable: true,
url: expected_url.to_string(),
};
logging_error!(Type::System, true, new_autoproxy.set_auto_proxy());
}
#[cfg(target_os = "windows")]
{
Self::execute_sysproxy_command(&["pac", expected_url]).await;
}
}
async fn restore_sys_proxy(expected: &Sysproxy) {
#[cfg(not(target_os = "windows"))]
{
logging_error!(Type::System, true, expected.set_system_proxy());
}
#[cfg(target_os = "windows")]
{
let address = format!("{}:{}", expected.host, expected.port);
Self::execute_sysproxy_command(&["global", &address, &expected.bypass]).await;
}
}
#[cfg(target_os = "windows")]
async fn execute_sysproxy_command(args: &[&str]) {
use crate::utils::dirs;
#[allow(unused_imports)] // creation_flags必须
use std::os::windows::process::CommandExt;
use tokio::process::Command;
let binary_path = match dirs::service_path() {
Ok(path) => path,
Err(e) => {
log::error!(target: "app", "获取服务路径失败: {}", e);
return;
}
};
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
if !sysproxy_exe.exists() {
log::error!(target: "app", "sysproxy.exe 不存在");
return;
}
let output = Command::new(sysproxy_exe)
.args(args)
.creation_flags(0x08000000) // CREATE_NO_WINDOW - 隐藏窗口
.output()
.await;
match output {
Ok(output) => {
if !output.status.success() {
log::error!(target: "app", "执行sysproxy命令失败: {:?}", args);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
log::error!(target: "app", "sysproxy错误输出: {}", stderr);
}
} else {
log::debug!(target: "app", "成功执行sysproxy命令: {:?}", args);
}
}
Err(e) => {
log::error!(target: "app", "执行sysproxy命令出错: {}", e);
}
}
}
}

View File

@@ -125,7 +125,7 @@ impl NotificationSystem {
match serde_json::to_value((status, message)) { match serde_json::to_value((status, message)) {
Ok(p) => ("verge://notice-message", Ok(p)), Ok(p) => ("verge://notice-message", Ok(p)),
Err(e) => { Err(e) => {
log::error!("Failed to serialize NoticeMessage payload: {}", e); log::error!("Failed to serialize NoticeMessage payload: {e}");
("verge://notice-message", Err(e)) ("verge://notice-message", Err(e))
} }
} }
@@ -153,11 +153,11 @@ impl NotificationSystem {
system.stats.total_sent.fetch_add(1, Ordering::SeqCst); system.stats.total_sent.fetch_add(1, Ordering::SeqCst);
// 记录成功发送的事件 // 记录成功发送的事件
if log::log_enabled!(log::Level::Debug) { if log::log_enabled!(log::Level::Debug) {
log::debug!("Successfully emitted event: {}", event_name_str); log::debug!("Successfully emitted event: {event_name_str}");
} }
} }
Err(e) => { Err(e) => {
log::warn!("Failed to emit event {}: {}", event_name_str, e); log::warn!("Failed to emit event {event_name_str}: {e}");
system.stats.total_errors.fetch_add(1, Ordering::SeqCst); system.stats.total_errors.fetch_add(1, Ordering::SeqCst);
*system.stats.last_error_time.write() = Some(Instant::now()); *system.stats.last_error_time.write() = Some(Instant::now());
@@ -165,8 +165,7 @@ impl NotificationSystem {
const EMIT_ERROR_THRESHOLD: u64 = 10; const EMIT_ERROR_THRESHOLD: u64 = 10;
if errors > EMIT_ERROR_THRESHOLD && !*system.emergency_mode.read() { if errors > EMIT_ERROR_THRESHOLD && !*system.emergency_mode.read() {
log::warn!( log::warn!(
"Reached {} emit errors, entering emergency mode", "Reached {EMIT_ERROR_THRESHOLD} emit errors, entering emergency mode"
EMIT_ERROR_THRESHOLD
); );
*system.emergency_mode.write() = true; *system.emergency_mode.write() = true;
} }
@@ -175,7 +174,7 @@ impl NotificationSystem {
} else { } else {
system.stats.total_errors.fetch_add(1, Ordering::SeqCst); system.stats.total_errors.fetch_add(1, Ordering::SeqCst);
*system.stats.last_error_time.write() = Some(Instant::now()); *system.stats.last_error_time.write() = Some(Instant::now());
log::warn!("Skipped emitting event due to payload serialization error for {}", event_name_str); log::warn!("Skipped emitting event due to payload serialization error for {event_name_str}");
} }
} else { } else {
log::warn!("No window found, skipping event emit."); log::warn!("No window found, skipping event emit.");
@@ -215,7 +214,7 @@ impl NotificationSystem {
match sender.send(event) { match sender.send(event) {
Ok(_) => true, Ok(_) => true,
Err(e) => { Err(e) => {
log::warn!("Failed to send event to notification queue: {:?}", e); log::warn!("Failed to send event to notification queue: {e:?}");
self.stats.total_errors.fetch_add(1, Ordering::SeqCst); self.stats.total_errors.fetch_add(1, Ordering::SeqCst);
*self.stats.last_error_time.write() = Some(Instant::now()); *self.stats.last_error_time.write() = Some(Instant::now());
false false
@@ -243,7 +242,7 @@ impl NotificationSystem {
log::info!("NotificationSystem worker thread joined successfully"); log::info!("NotificationSystem worker thread joined successfully");
} }
Err(e) => { Err(e) => {
log::error!("NotificationSystem worker thread join failed: {:?}", e); log::error!("NotificationSystem worker thread join failed: {e:?}");
} }
} }
} }
@@ -500,7 +499,7 @@ impl Handle {
}); });
if let Err(e) = thread_result { if let Err(e) = thread_result {
log::error!("Failed to spawn startup errors thread: {}", e); log::error!("Failed to spawn startup errors thread: {e}");
} }
} }

View File

@@ -1,10 +1,7 @@
use crate::utils::notification::{notify_event, NotificationEvent};
use crate::{ use crate::{
config::Config, config::Config, core::handle, feat, logging, logging_error,
core::handle, module::lightweight::entry_lightweight_mode, utils::logging::Type,
feat, logging, logging_error,
module::lightweight::entry_lightweight_mode,
process::AsyncHandler,
utils::{logging::Type, resolve},
}; };
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
@@ -14,7 +11,7 @@ use tauri::Manager;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState}; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState};
pub struct Hotkey { pub struct Hotkey {
current: Arc<Mutex<Vec<String>>>, // 保存当前的热键设置 current: Arc<Mutex<Vec<String>>>,
} }
impl Hotkey { impl Hotkey {
@@ -38,7 +35,6 @@ impl Hotkey {
enable_global_hotkey enable_global_hotkey
); );
// 如果全局热键被禁用,则不注册热键
if !enable_global_hotkey { if !enable_global_hotkey {
return Ok(()); return Ok(());
} }
@@ -138,14 +134,11 @@ impl Hotkey {
manager.unregister(hotkey)?; manager.unregister(hotkey)?;
} }
let f = match func.trim() { let app_handle_clone = app_handle.clone();
let f: Box<dyn Fn() + Send + Sync> = match func.trim() {
"open_or_close_dashboard" => { "open_or_close_dashboard" => {
logging!( let app_handle = app_handle_clone.clone();
debug, Box::new(move || {
Type::Hotkey,
"Registering open_or_close_dashboard function"
);
|| {
logging!( logging!(
debug, debug,
Type::Hotkey, Type::Hotkey,
@@ -153,94 +146,89 @@ impl Hotkey {
"=== Hotkey Dashboard Window Operation Start ===" "=== Hotkey Dashboard Window Operation Start ==="
); );
// 检查是否在轻量模式下,如果是,需要同步处理 logging!(
if crate::module::lightweight::is_in_lightweight_mode() { info,
logging!( Type::Hotkey,
info, true,
Type::Hotkey, "Using unified WindowManager for hotkey operation (bypass debounce)"
true, );
"In lightweight mode, calling open_or_close_dashboard directly"
);
crate::feat::open_or_close_dashboard();
} else {
AsyncHandler::spawn(move || async move {
logging!(
debug,
Type::Hotkey,
true,
"Toggle dashboard window visibility (async)"
);
// 检查窗口是否存在 crate::feat::open_or_close_dashboard_hotkey();
if let Some(window) = handle::Handle::global().get_window() {
// 如果窗口可见,则隐藏
match window.is_visible() {
Ok(visible) => {
if visible {
logging!(
info,
Type::Window,
true,
"Window is visible, hiding it"
);
let _ = window.hide();
} else {
// 如果窗口不可见,则显示
logging!(
info,
Type::Window,
true,
"Window is hidden, showing it"
);
if window.is_minimized().unwrap_or(false) {
let _ = window.unminimize();
}
let _ = window.show();
let _ = window.set_focus();
}
}
Err(e) => {
logging!(
warn,
Type::Window,
true,
"Failed to check window visibility: {}",
e
);
let _ = window.show();
let _ = window.set_focus();
}
}
} else {
// 如果窗口不存在,创建一个新窗口
logging!(
info,
Type::Window,
true,
"Window does not exist, creating a new one"
);
resolve::create_window(true);
}
});
}
logging!( logging!(
debug, debug,
Type::Hotkey, Type::Hotkey,
"=== Hotkey Dashboard Window Operation End ===" "=== Hotkey Dashboard Window Operation End ==="
); );
} notify_event(&app_handle, NotificationEvent::DashboardToggled);
})
}
"clash_mode_rule" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::change_clash_mode("rule".into());
notify_event(
&app_handle,
NotificationEvent::ClashModeChanged { mode: "Rule" },
);
})
}
"clash_mode_global" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::change_clash_mode("global".into());
notify_event(
&app_handle,
NotificationEvent::ClashModeChanged { mode: "Global" },
);
})
}
"clash_mode_direct" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::change_clash_mode("direct".into());
notify_event(
&app_handle,
NotificationEvent::ClashModeChanged { mode: "Direct" },
);
})
}
"toggle_system_proxy" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::toggle_system_proxy();
notify_event(&app_handle, NotificationEvent::SystemProxyToggled);
})
}
"toggle_tun_mode" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::toggle_tun_mode(None);
notify_event(&app_handle, NotificationEvent::TunModeToggled);
})
}
"entry_lightweight_mode" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
entry_lightweight_mode();
notify_event(&app_handle, NotificationEvent::LightweightModeEntered);
})
}
"quit" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::quit();
notify_event(&app_handle, NotificationEvent::AppQuit);
})
} }
"clash_mode_rule" => || feat::change_clash_mode("rule".into()),
"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(None),
"entry_lightweight_mode" => || entry_lightweight_mode(),
"quit" => || feat::quit(),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
"hide" => || feat::hide(), "hide" => {
let app_handle = app_handle_clone.clone();
Box::new(move || {
feat::hide();
notify_event(&app_handle, NotificationEvent::AppHidden);
})
}
_ => { _ => {
logging!(error, Type::Hotkey, "Invalid function: {}", func); logging!(error, Type::Hotkey, "Invalid function: {}", func);
bail!("invalid function \"{func}\""); bail!("invalid function \"{func}\"");
@@ -261,10 +249,8 @@ impl Hotkey {
} }
} }
} else { } else {
// 直接执行函数,不做任何状态检查
logging!(debug, Type::Hotkey, "Executing function directly"); logging!(debug, Type::Hotkey, "Executing function directly");
// 获取全局热键状态
let is_enable_global_hotkey = Config::verge() let is_enable_global_hotkey = Config::verge()
.latest() .latest()
.enable_global_hotkey .enable_global_hotkey
@@ -274,7 +260,6 @@ impl Hotkey {
f(); f();
} else { } else {
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
// 非轻量模式且未启用全局热键时,只在窗口可见且有焦点的情况下响应热键
let is_visible = WindowManager::is_main_window_visible(); let is_visible = WindowManager::is_main_window_visible();
let is_focused = WindowManager::is_main_window_focused(); let is_focused = WindowManager::is_main_window_focused();

View File

@@ -1,6 +1,8 @@
pub mod async_proxy_query;
pub mod backup; pub mod backup;
#[allow(clippy::module_inception)] #[allow(clippy::module_inception)]
mod core; mod core;
pub mod event_driven_proxy;
pub mod handle; pub mod handle;
pub mod hotkey; pub mod hotkey;
pub mod service; pub mod service;
@@ -10,4 +12,4 @@ pub mod timer;
pub mod tray; pub mod tray;
pub mod win_uwp; pub mod win_uwp;
pub use self::{core::*, timer::Timer}; pub use self::{core::*, event_driven_proxy::EventDrivenProxyManager, timer::Timer};

View File

@@ -466,7 +466,7 @@ pub async fn reinstall_service() -> Result<()> {
Ok(()) Ok(())
} }
Err(err) => { Err(err) => {
let error = format!("failed to install service: {}", err); let error = format!("failed to install service: {err}");
service_state.last_error = Some(error.clone()); service_state.last_error = Some(error.clone());
service_state.prefer_sidecar = true; service_state.prefer_sidecar = true;
service_state.save()?; service_state.save()?;
@@ -686,7 +686,7 @@ pub async fn check_service_needs_reinstall() -> bool {
// 检查版本和可用性 // 检查版本和可用性
match check_service_version().await { match check_service_version().await {
Ok(version) => { Ok(version) => {
log::info!(target: "app", "服务版本检测:当前={}, 要求={}", version, REQUIRED_SERVICE_VERSION); log::info!(target: "app", "服务版本检测:当前={version}, 要求={REQUIRED_SERVICE_VERSION}");
/* logging!( /* logging!(
info, info,
Type::Service, Type::Service,
@@ -698,8 +698,7 @@ pub async fn check_service_needs_reinstall() -> bool {
let needs_reinstall = version != REQUIRED_SERVICE_VERSION; let needs_reinstall = version != REQUIRED_SERVICE_VERSION;
if needs_reinstall { if needs_reinstall {
log::warn!(target: "app", "发现服务版本不匹配,需要重装! 当前={}, 要求={}", log::warn!(target: "app", "发现服务版本不匹配,需要重装! 当前={version}, 要求={REQUIRED_SERVICE_VERSION}");
version, REQUIRED_SERVICE_VERSION);
logging!(warn, Type::Service, true, "服务版本不匹配,需要重装"); logging!(warn, Type::Service, true, "服务版本不匹配,需要重装");
// log::debug!(target: "app", "当前版本字节: {:?}", version.as_bytes()); // log::debug!(target: "app", "当前版本字节: {:?}", version.as_bytes());
@@ -717,7 +716,7 @@ pub async fn check_service_needs_reinstall() -> bool {
// 检查服务是否可用 // 检查服务是否可用
match is_service_available().await { match is_service_available().await {
Ok(()) => { Ok(()) => {
log::info!(target: "app", "服务正在运行但版本检查失败: {}", err); log::info!(target: "app", "服务正在运行但版本检查失败: {err}");
/* logging!( /* logging!(
info, info,
Type::Service, Type::Service,
@@ -827,8 +826,7 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
// 先检查服务版本,不受冷却期限制 // 先检查服务版本,不受冷却期限制
let version_check = match check_service_version().await { let version_check = match check_service_version().await {
Ok(version) => { Ok(version) => {
log::info!(target: "app", "检测到服务版本: {}, 要求版本: {}", log::info!(target: "app", "检测到服务版本: {version}, 要求版本: {REQUIRED_SERVICE_VERSION}");
version, REQUIRED_SERVICE_VERSION);
if version.as_bytes() != REQUIRED_SERVICE_VERSION.as_bytes() { if version.as_bytes() != REQUIRED_SERVICE_VERSION.as_bytes() {
log::warn!(target: "app", "服务版本不匹配,需要重装"); log::warn!(target: "app", "服务版本不匹配,需要重装");
@@ -839,7 +837,7 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
} }
} }
Err(err) => { Err(err) => {
log::warn!(target: "app", "无法获取服务版本: {}", err); log::warn!(target: "app", "无法获取服务版本: {err}");
false false
} }
}; };
@@ -865,7 +863,7 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
log::info!(target: "app", "开始重装服务"); log::info!(target: "app", "开始重装服务");
if let Err(err) = reinstall_service().await { if let Err(err) = reinstall_service().await {
log::warn!(target: "app", "服务重装失败: {}", err); log::warn!(target: "app", "服务重装失败: {err}");
log::info!(target: "app", "尝试使用现有服务"); log::info!(target: "app", "尝试使用现有服务");
return start_with_existing_service(config_file).await; return start_with_existing_service(config_file).await;
@@ -884,7 +882,7 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
} }
} }
Err(err) => { Err(err) => {
log::warn!(target: "app", "服务检查失败: {}", err); log::warn!(target: "app", "服务检查失败: {err}");
} }
} }
@@ -893,7 +891,7 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
log::info!(target: "app", "服务需要重装"); log::info!(target: "app", "服务需要重装");
if let Err(err) = reinstall_service().await { if let Err(err) = reinstall_service().await {
log::warn!(target: "app", "服务重装失败: {}", err); log::warn!(target: "app", "服务重装失败: {err}");
bail!("Failed to reinstall service: {}", err); bail!("Failed to reinstall service: {}", err);
} }
@@ -986,7 +984,7 @@ pub async fn force_reinstall_service() -> Result<()> {
Ok(()) Ok(())
} }
Err(err) => { Err(err) => {
log::error!(target: "app", "强制重装服务失败: {}", err); log::error!(target: "app", "强制重装服务失败: {err}");
bail!("强制重装服务失败: {}", err) bail!("强制重装服务失败: {}", err)
} }
} }

View File

@@ -257,7 +257,7 @@ pub async fn send_ipc_request(
logging!(info, Type::Service, true, "正在连接服务 (Unix)..."); logging!(info, Type::Service, true, "正在连接服务 (Unix)...");
let command_type = format!("{:?}", command); let command_type = format!("{command:?}");
let request = match create_signed_request(command, payload) { let request = match create_signed_request(command, payload) {
Ok(req) => req, Ok(req) => req,

View File

@@ -2,25 +2,21 @@
use crate::utils::autostart as startup_shortcut; use crate::utils::autostart as startup_shortcut;
use crate::{ use crate::{
config::{Config, IVerge}, config::{Config, IVerge},
core::handle::Handle, core::{handle::Handle, EventDrivenProxyManager},
logging, logging_error, logging, logging_error,
process::AsyncHandler,
utils::logging::Type, utils::logging::Type,
}; };
use anyhow::Result; use anyhow::Result;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use std::sync::Arc; use std::sync::Arc;
#[cfg(not(target_os = "windows"))]
use sysproxy::{Autoproxy, Sysproxy}; use sysproxy::{Autoproxy, Sysproxy};
use tauri::async_runtime::Mutex as TokioMutex; use tauri::async_runtime::Mutex as TokioMutex;
use tauri_plugin_autostart::ManagerExt; use tauri_plugin_autostart::ManagerExt;
use tokio::time::{sleep, Duration};
pub struct Sysopt { pub struct Sysopt {
update_sysproxy: Arc<TokioMutex<bool>>, update_sysproxy: Arc<TokioMutex<bool>>,
reset_sysproxy: Arc<TokioMutex<bool>>, reset_sysproxy: Arc<TokioMutex<bool>>,
/// record whether the guard async is running or not
guard_state: Arc<Mutex<bool>>,
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -47,7 +43,7 @@ fn get_bypass() -> String {
if custom_bypass.is_empty() { if custom_bypass.is_empty() {
DEFAULT_BYPASS.to_string() DEFAULT_BYPASS.to_string()
} else if use_default { } else if use_default {
format!("{},{}", DEFAULT_BYPASS, custom_bypass) format!("{DEFAULT_BYPASS},{custom_bypass}")
} else { } else {
custom_bypass custom_bypass
} }
@@ -59,12 +55,15 @@ impl Sysopt {
SYSOPT.get_or_init(|| Sysopt { SYSOPT.get_or_init(|| Sysopt {
update_sysproxy: Arc::new(TokioMutex::new(false)), update_sysproxy: Arc::new(TokioMutex::new(false)),
reset_sysproxy: Arc::new(TokioMutex::new(false)), reset_sysproxy: Arc::new(TokioMutex::new(false)),
guard_state: Arc::new(false.into()),
}) })
} }
pub fn init_guard_sysproxy(&self) -> Result<()> { pub fn init_guard_sysproxy(&self) -> Result<()> {
self.guard_proxy(); // 使用事件驱动代理管理器
let proxy_manager = EventDrivenProxyManager::global();
proxy_manager.notify_app_started();
log::info!(target: "app", "已启用事件驱动代理守卫");
Ok(()) Ok(())
} }
@@ -101,12 +100,14 @@ impl Sysopt {
}; };
let mut auto = Autoproxy { let mut auto = Autoproxy {
enable: false, enable: false,
url: format!("http://{}:{}/commands/pac", proxy_host, pac_port), url: format!("http://{proxy_host}:{pac_port}/commands/pac"),
}; };
if !sys_enable { if !sys_enable {
sys.set_system_proxy()?; sys.set_system_proxy()?;
auto.set_auto_proxy()?; auto.set_auto_proxy()?;
let proxy_manager = EventDrivenProxyManager::global();
proxy_manager.notify_config_changed();
return Ok(()); return Ok(());
} }
@@ -115,6 +116,8 @@ impl Sysopt {
auto.enable = true; auto.enable = true;
sys.set_system_proxy()?; sys.set_system_proxy()?;
auto.set_auto_proxy()?; auto.set_auto_proxy()?;
let proxy_manager = EventDrivenProxyManager::global();
proxy_manager.notify_config_changed();
return Ok(()); return Ok(());
} }
@@ -123,13 +126,18 @@ impl Sysopt {
sys.enable = true; sys.enable = true;
auto.set_auto_proxy()?; auto.set_auto_proxy()?;
sys.set_system_proxy()?; sys.set_system_proxy()?;
let proxy_manager = EventDrivenProxyManager::global();
proxy_manager.notify_config_changed();
return Ok(()); return Ok(());
} }
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
if !sys_enable { if !sys_enable {
return self.reset_sysproxy().await; let result = self.reset_sysproxy().await;
let proxy_manager = EventDrivenProxyManager::global();
proxy_manager.notify_config_changed();
return result;
} }
use crate::{core::handle::Handle, utils::dirs}; use crate::{core::handle::Handle, utils::dirs};
use anyhow::bail; use anyhow::bail;
@@ -169,6 +177,8 @@ impl Sysopt {
bail!("sysproxy exe run failed"); bail!("sysproxy exe run failed");
} }
} }
let proxy_manager = EventDrivenProxyManager::global();
proxy_manager.notify_config_changed();
Ok(()) Ok(())
} }
@@ -180,7 +190,16 @@ impl Sysopt {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
let mut sysproxy: Sysproxy = Sysproxy::get_system_proxy()?; let mut sysproxy: Sysproxy = Sysproxy::get_system_proxy()?;
let mut autoproxy = Autoproxy::get_auto_proxy()?; let mut autoproxy = match Autoproxy::get_auto_proxy() {
Ok(ap) => ap,
Err(e) => {
log::warn!(target: "app", "重置代理时获取自动代理配置失败: {e}, 使用默认配置");
Autoproxy {
enable: false,
url: "".to_string(),
}
}
};
sysproxy.enable = false; sysproxy.enable = false;
autoproxy.enable = false; autoproxy.enable = false;
autoproxy.set_auto_proxy()?; autoproxy.set_auto_proxy()?;
@@ -286,176 +305,13 @@ impl Sysopt {
match autostart_manager.is_enabled() { match autostart_manager.is_enabled() {
Ok(status) => { Ok(status) => {
log::info!(target: "app", "Auto launch status: {}", status); log::info!(target: "app", "Auto launch status: {status}");
Ok(status) Ok(status)
} }
Err(e) => { Err(e) => {
log::error!(target: "app", "Failed to get auto launch status: {}", e); log::error!(target: "app", "Failed to get auto launch status: {e}");
Err(anyhow::anyhow!("Failed to get auto launch status: {}", e)) Err(anyhow::anyhow!("Failed to get auto launch status: {}", e))
} }
} }
} }
fn guard_proxy(&self) {
let _lock = self.guard_state.lock();
AsyncHandler::spawn(move || async move {
// default duration is 10s
let mut wait_secs = 10u64;
loop {
sleep(Duration::from_secs(wait_secs)).await;
let (enable, guard, guard_duration, pac, proxy_host) = {
let verge = Config::verge();
let verge = verge.latest();
(
verge.enable_system_proxy.unwrap_or(false),
verge.enable_proxy_guard.unwrap_or(false),
verge.proxy_guard_duration.unwrap_or(10),
verge.proxy_auto_config.unwrap_or(false),
verge
.proxy_host
.clone()
.unwrap_or_else(|| String::from("127.0.0.1")),
)
};
// stop loop
if !enable || !guard {
continue;
}
// update duration
wait_secs = guard_duration;
log::debug!(target: "app", "try to guard the system proxy");
// 获取期望的代理端口
let port = Config::verge()
.latest()
.verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port());
let pac_port = IVerge::get_singleton_port();
let bypass = get_bypass();
// 检查系统代理配置
if pac {
// 检查 PAC 代理设置
let expected_url = format!("http://{}:{}/commands/pac", proxy_host, pac_port);
let autoproxy = match Autoproxy::get_auto_proxy() {
Ok(ap) => ap,
Err(e) => {
log::error!(target: "app", "failed to get the auto proxy: {}", e);
continue;
}
};
// 检查自动代理是否启用且URL是否正确
if !autoproxy.enable || autoproxy.url != expected_url {
log::info!(target: "app", "auto proxy settings changed, restoring...");
#[cfg(not(target_os = "windows"))]
{
let new_autoproxy = Autoproxy {
enable: true,
url: expected_url,
};
logging_error!(Type::System, true, new_autoproxy.set_auto_proxy());
}
#[cfg(target_os = "windows")]
{
use crate::{core::handle::Handle, utils::dirs};
use tauri_plugin_shell::ShellExt;
let app_handle = Handle::global().app_handle().unwrap();
let binary_path = match dirs::service_path() {
Ok(path) => path,
Err(e) => {
log::error!(target: "app", "failed to get service path: {}", e);
continue;
}
};
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
if !sysproxy_exe.exists() {
log::error!(target: "app", "sysproxy.exe not found");
continue;
}
let shell = app_handle.shell();
let output = shell
.command(sysproxy_exe.as_path().to_str().unwrap())
.args(["pac", expected_url.as_str()])
.output()
.await
.unwrap();
if !output.status.success() {
log::error!(target: "app", "failed to set auto proxy");
}
}
}
} else {
// 检查常规系统代理设置
let sysproxy = match Sysproxy::get_system_proxy() {
Ok(sp) => sp,
Err(e) => {
log::error!(target: "app", "failed to get the system proxy: {}", e);
continue;
}
};
// 检查系统代理是否启用且配置是否匹配
if !sysproxy.enable || sysproxy.host != proxy_host || sysproxy.port != port {
log::info!(target: "app", "system proxy settings changed, restoring...");
#[cfg(not(target_os = "windows"))]
{
let new_sysproxy = Sysproxy {
enable: true,
host: proxy_host.clone(),
port,
bypass: bypass.clone(),
};
logging_error!(Type::System, true, new_sysproxy.set_system_proxy());
}
#[cfg(target_os = "windows")]
{
use crate::{core::handle::Handle, utils::dirs};
use tauri_plugin_shell::ShellExt;
let app_handle = Handle::global().app_handle().unwrap();
let binary_path = match dirs::service_path() {
Ok(path) => path,
Err(e) => {
log::error!(target: "app", "failed to get service path: {}", e);
continue;
}
};
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
if !sysproxy_exe.exists() {
log::error!(target: "app", "sysproxy.exe not found");
continue;
}
let address = format!("{}:{}", proxy_host, port);
let shell = app_handle.shell();
let output = shell
.command(sysproxy_exe.as_path().to_str().unwrap())
.args(["global", address.as_str(), bypass.as_ref()])
.output()
.await
.unwrap();
if !output.status.success() {
log::error!(target: "app", "failed to set system proxy");
}
}
}
}
}
});
}
} }

View File

@@ -12,15 +12,7 @@ use crate::{
}; };
use anyhow::Result; use anyhow::Result;
#[cfg(target_os = "macos")]
use futures::StreamExt;
use parking_lot::Mutex; use parking_lot::Mutex;
#[cfg(target_os = "macos")]
use parking_lot::RwLock;
#[cfg(target_os = "macos")]
pub use speed_rate::{SpeedRate, Traffic};
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{ use std::{
fs, fs,
sync::atomic::{AtomicBool, Ordering}, sync::atomic::{AtomicBool, Ordering},
@@ -31,20 +23,37 @@ use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconEvent},
AppHandle, Wry, AppHandle, Wry,
}; };
#[cfg(target_os = "macos")]
use tokio::sync::broadcast;
use super::handle; use super::handle;
#[derive(Clone)] #[derive(Clone)]
struct TrayState {} struct TrayState {}
// 托盘点击防抖机制
static TRAY_CLICK_DEBOUNCE: OnceCell<Mutex<Instant>> = OnceCell::new();
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
fn get_tray_click_debounce() -> &'static Mutex<Instant> {
TRAY_CLICK_DEBOUNCE.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(1)))
}
fn should_handle_tray_click() -> bool {
let debounce_lock = get_tray_click_debounce();
let mut last_click = debounce_lock.lock();
let now = Instant::now();
if now.duration_since(*last_click) >= Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS) {
*last_click = now;
true
} else {
log::debug!(target: "app", "托盘点击被防抖机制忽略,距离上次点击 {:?}ms",
now.duration_since(*last_click).as_millis());
false
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub struct Tray { pub struct Tray {
pub speed_rate: Arc<Mutex<Option<SpeedRate>>>,
shutdown_tx: Arc<RwLock<Option<broadcast::Sender<()>>>>,
is_subscribed: Arc<RwLock<bool>>,
pub rate_cache: Arc<Mutex<Option<Rate>>>,
last_menu_update: Mutex<Option<Instant>>, last_menu_update: Mutex<Option<Instant>>,
menu_updating: AtomicBool, menu_updating: AtomicBool,
} }
@@ -105,7 +114,7 @@ impl TrayState {
if tray_icon_colorful == "monochrome" { if tray_icon_colorful == "monochrome" {
( (
false, false,
include_bytes!("../../../icons/tray-icon-sys-mono.ico").to_vec(), include_bytes!("../../../icons/tray-icon-sys-mono-new.ico").to_vec(),
) )
} else { } else {
( (
@@ -139,7 +148,7 @@ impl TrayState {
if tray_icon_colorful == "monochrome" { if tray_icon_colorful == "monochrome" {
( (
false, false,
include_bytes!("../../../icons/tray-icon-tun-mono.ico").to_vec(), include_bytes!("../../../icons/tray-icon-tun-mono-new.ico").to_vec(),
) )
} else { } else {
( (
@@ -164,10 +173,6 @@ impl Tray {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
return TRAY.get_or_init(|| Tray { return TRAY.get_or_init(|| Tray {
speed_rate: Arc::new(Mutex::new(None)),
shutdown_tx: Arc::new(RwLock::new(None)),
is_subscribed: Arc::new(RwLock::new(false)),
rate_cache: Arc::new(Mutex::new(None)),
last_menu_update: Mutex::new(None), last_menu_update: Mutex::new(None),
menu_updating: AtomicBool::new(false), menu_updating: AtomicBool::new(false),
}); });
@@ -180,11 +185,6 @@ impl Tray {
} }
pub fn init(&self) -> Result<()> { pub fn init(&self) -> Result<()> {
#[cfg(target_os = "macos")]
{
let mut speed_rate = self.speed_rate.lock();
*speed_rate = Some(SpeedRate::new());
}
Ok(()) Ok(())
} }
@@ -291,7 +291,7 @@ impl Tray {
/// 更新托盘图标 /// 更新托盘图标
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn update_icon(&self, rate: Option<Rate>) -> Result<()> { pub fn update_icon(&self, _rate: Option<Rate>) -> Result<()> {
let app_handle = match handle::Handle::global().app_handle() { let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle, Some(handle) => handle,
None => { None => {
@@ -312,55 +312,18 @@ impl Tray {
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false); let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let (is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) { let (_is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
(true, true) => TrayState::get_tun_tray_icon(), (true, true) => TrayState::get_tun_tray_icon(),
(true, false) => TrayState::get_sysproxy_tray_icon(), (true, false) => TrayState::get_sysproxy_tray_icon(),
(false, true) => TrayState::get_tun_tray_icon(), (false, true) => TrayState::get_tun_tray_icon(),
(false, false) => TrayState::get_common_tray_icon(), (false, false) => TrayState::get_common_tray_icon(),
}; };
let enable_tray_speed = verge.enable_tray_speed.unwrap_or(false);
let enable_tray_icon = verge.enable_tray_icon.unwrap_or(true);
let colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string()); let colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string());
let is_colorful = colorful == "colorful"; let is_colorful = colorful == "colorful";
if !enable_tray_speed { let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?));
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&icon_bytes)?)); let _ = tray.set_icon_as_template(!is_colorful);
let _ = tray.set_icon_as_template(!is_colorful);
return Ok(());
}
let rate = if let Some(rate) = rate {
Some(rate)
} else {
let guard = self.speed_rate.lock();
if let Some(guard) = guard.as_ref() {
if let Some(rate) = guard.get_curent_rate() {
Some(rate)
} else {
Some(Rate::default())
}
} else {
Some(Rate::default())
}
};
let mut rate_guard = self.rate_cache.lock();
if *rate_guard != rate {
*rate_guard = rate;
let bytes = if enable_tray_icon {
Some(icon_bytes)
} else {
None
};
let rate = rate_guard.as_ref();
if let Ok(rate_bytes) = SpeedRate::add_speed_text(is_custom_icon, bytes, rate) {
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&rate_bytes)?));
let _ = tray.set_icon_as_template(!is_custom_icon && !is_colorful);
}
}
Ok(()) Ok(())
} }
@@ -475,155 +438,9 @@ impl Tray {
Ok(()) Ok(())
} }
/// 订阅流量数据
#[cfg(target_os = "macos")]
pub async fn subscribe_traffic(&self) -> Result<()> {
log::info!(target: "app", "subscribe traffic");
// 如果已经订阅,先取消订阅
if *self.is_subscribed.read() {
self.unsubscribe_traffic();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let (shutdown_tx, shutdown_rx) = broadcast::channel(3);
*self.shutdown_tx.write() = Some(shutdown_tx);
*self.is_subscribed.write() = true;
let speed_rate = Arc::clone(&self.speed_rate);
let is_subscribed = Arc::clone(&self.is_subscribed);
// 使用单线程防止阻塞主线程
std::thread::Builder::new()
.name("traffic-monitor".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to build tokio runtime for traffic monitor");
// 在单独的运行时中执行异步任务
rt.block_on(async move {
let mut shutdown = shutdown_rx;
let speed_rate = speed_rate.clone();
let is_subscribed = is_subscribed.clone();
let mut consecutive_errors = 0;
let max_consecutive_errors = 5;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
'outer: loop {
if !*is_subscribed.read() {
log::info!(target: "app", "Traffic subscription has been cancelled");
break;
}
match tokio::time::timeout(
std::time::Duration::from_secs(5),
Traffic::get_traffic_stream()
).await {
Ok(stream_result) => {
match stream_result {
Ok(mut stream) => {
consecutive_errors = 0;
loop {
tokio::select! {
traffic_result = stream.next() => {
match traffic_result {
Some(Ok(traffic)) => {
if let Ok(Some(rate)) = tokio::time::timeout(
std::time::Duration::from_millis(50),
async {
let guard = speed_rate.try_lock();
if let Some(guard) = guard {
if let Some(sr) = guard.as_ref() {
sr.update_and_check_changed(traffic.up, traffic.down)
} else {
None
}
} else {
None
}
}
).await {
let _ = tokio::time::timeout(
std::time::Duration::from_millis(100),
async { let _ = Tray::global().update_icon(Some(rate)); }
).await;
}
},
Some(Err(e)) => {
log::error!(target: "app", "Traffic stream error: {}", e);
consecutive_errors += 1;
if consecutive_errors >= max_consecutive_errors {
log::error!(target: "app", "Too many errors, reconnecting traffic stream");
break;
}
},
None => {
log::info!(target: "app", "Traffic stream ended, reconnecting");
break;
}
}
},
_ = shutdown.recv() => {
log::info!(target: "app", "Received shutdown signal for traffic stream");
break 'outer;
},
_ = interval.tick() => {
if !*is_subscribed.read() {
log::info!(target: "app", "Traffic monitor detected subscription cancelled");
break 'outer;
}
log::debug!(target: "app", "Traffic subscription periodic health check");
},
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
log::info!(target: "app", "Traffic stream max active time reached, reconnecting");
break;
}
}
}
},
Err(e) => {
log::error!(target: "app", "Failed to get traffic stream: {}", e);
consecutive_errors += 1;
if consecutive_errors >= max_consecutive_errors {
log::error!(target: "app", "Too many consecutive errors, pausing traffic monitoring");
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
consecutive_errors = 0;
} else {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
},
Err(_) => {
log::error!(target: "app", "Traffic stream initialization timed out");
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
if !*is_subscribed.read() {
break;
}
}
log::info!(target: "app", "Traffic subscription thread terminated");
});
})
.expect("Failed to spawn traffic monitor thread");
Ok(())
}
/// 取消订阅 traffic 数据 /// 取消订阅 traffic 数据
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn unsubscribe_traffic(&self) { pub fn unsubscribe_traffic(&self) {}
log::info!(target: "app", "unsubscribe traffic");
*self.is_subscribed.write() = false;
if let Some(tx) = self.shutdown_tx.write().take() {
drop(tx);
}
}
pub fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> { pub fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> {
log::info!(target: "app", "正在从AppHandle创建系统托盘"); log::info!(target: "app", "正在从AppHandle创建系统托盘");
@@ -656,7 +473,7 @@ impl Tray {
tray.on_tray_icon_event(|_, event| { tray.on_tray_icon_event(|_, event| {
let tray_event = { Config::verge().latest().tray_event.clone() }; let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into()); let tray_event: String = tray_event.unwrap_or("main_window".into());
log::debug!(target: "app","tray event: {:?}", tray_event); log::debug!(target: "app","tray event: {tray_event:?}");
if let TrayIconEvent::Click { if let TrayIconEvent::Click {
button: MouseButton::Left, button: MouseButton::Left,
@@ -664,6 +481,11 @@ impl Tray {
.. ..
} = event } = event
{ {
// 添加防抖检查,防止快速连击
if !should_handle_tray_click() {
return;
}
match tray_event.as_str() { match tray_event.as_str() {
"system_proxy" => feat::toggle_system_proxy(), "system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(None), "tun_mode" => feat::toggle_tun_mode(None),
@@ -675,7 +497,7 @@ impl Tray {
crate::module::lightweight::exit_lightweight_mode(); crate::module::lightweight::exit_lightweight_mode();
} }
let result = WindowManager::show_main_window(); let result = WindowManager::show_main_window();
log::info!(target: "app", "窗口显示结果: {:?}", result); log::info!(target: "app", "窗口显示结果: {result:?}");
} }
_ => {} _ => {}
} }
@@ -736,7 +558,7 @@ fn create_tray_menu(
.is_current_profile_index(profile_uid.to_string()); .is_current_profile_index(profile_uid.to_string());
CheckMenuItem::with_id( CheckMenuItem::with_id(
app_handle, app_handle,
format!("profiles_{}", profile_uid), format!("profiles_{profile_uid}"),
t(profile_name), t(profile_name),
true, true,
is_current_profile, is_current_profile,
@@ -949,14 +771,17 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
"open_window" => { "open_window" => {
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
log::info!(target: "app", "托盘菜单点击: 打开窗口"); log::info!(target: "app", "托盘菜单点击: 打开窗口");
// 如果在轻量模式中,先退出轻量模式
if !should_handle_tray_click() {
return;
}
if crate::module::lightweight::is_in_lightweight_mode() { if crate::module::lightweight::is_in_lightweight_mode() {
log::info!(target: "app", "当前在轻量模式,正在退出"); log::info!(target: "app", "当前在轻量模式,正在退出");
crate::module::lightweight::exit_lightweight_mode(); crate::module::lightweight::exit_lightweight_mode();
} }
// 使用统一的窗口管理器显示窗口
let result = WindowManager::show_main_window(); let result = WindowManager::show_main_window();
log::info!(target: "app", "窗口显示结果: {:?}", result); log::info!(target: "app", "窗口显示结果: {result:?}");
} }
"system_proxy" => { "system_proxy" => {
feat::toggle_system_proxy(); feat::toggle_system_proxy();
@@ -977,7 +802,10 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
"restart_clash" => feat::restart_clash_core(), "restart_clash" => feat::restart_clash_core(),
"restart_app" => feat::restart_app(), "restart_app" => feat::restart_app(),
"entry_lightweight_mode" => { "entry_lightweight_mode" => {
// 处理轻量模式的切换 if !should_handle_tray_click() {
return;
}
let was_lightweight = crate::module::lightweight::is_in_lightweight_mode(); let was_lightweight = crate::module::lightweight::is_in_lightweight_mode();
if was_lightweight { if was_lightweight {
crate::module::lightweight::exit_lightweight_mode(); crate::module::lightweight::exit_lightweight_mode();
@@ -985,11 +813,10 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
crate::module::lightweight::entry_lightweight_mode(); crate::module::lightweight::entry_lightweight_mode();
} }
// 退出轻量模式后显示主窗口
if was_lightweight { if was_lightweight {
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
let result = WindowManager::show_main_window(); let result = WindowManager::show_main_window();
log::info!(target: "app", "退出轻量模式后显示主窗口: {:?}", result); log::info!(target: "app", "退出轻量模式后显示主窗口: {result:?}");
} }
} }
"quit" => { "quit" => {
@@ -1002,8 +829,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
_ => {} _ => {}
} }
// 统一调用状态更新
if let Err(e) = Tray::global().update_all_states() { if let Err(e) = Tray::global().update_all_states() {
log::warn!(target: "app", "更新托盘状态失败: {}", e); log::warn!(target: "app", "更新托盘状态失败: {e}");
} }
} }

View File

@@ -1,336 +1 @@
use crate::{
module::mihomo::{MihomoManager, Rate},
utils::help::format_bytes_speed,
};
use ab_glyph::FontArc;
use anyhow::Result;
use futures::Stream;
use image::{GenericImageView, Rgba, RgbaImage};
use imageproc::drawing::draw_text_mut;
use parking_lot::Mutex;
use std::{io::Cursor, sync::Arc};
use tokio_tungstenite::tungstenite::http;
use tungstenite::client::IntoClientRequest;
#[derive(Debug, Clone)]
pub struct SpeedRate {
rate: Arc<Mutex<(Rate, Rate)>>,
last_update: Arc<Mutex<std::time::Instant>>,
}
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;
// Avoid unnecessary float conversions for small value checks
let should_update = if current.up < 1000 && down < 1000 {
// For small values, always update to ensure accuracy
current.up != up || current.down != down
} else {
// For larger values, use integer math to check for >5% change
// Multiply by 20 instead of dividing by 0.05 to avoid floating point
let up_threshold = current.up / 20;
let down_threshold = current.down / 20;
(up > current.up && up - current.up > up_threshold)
|| (up < current.up && current.up - up > up_threshold)
|| (down > current.down && down - current.down > down_threshold)
|| (down < current.down && current.down - down > down_threshold)
};
if !should_update {
return None;
}
*previous = current.clone();
current.up = up;
current.down = down;
*last_update = now;
if previous != current {
Some(current.clone())
} else {
None
}
}
pub fn get_curent_rate(&self) -> Option<Rate> {
let rates = self.rate.lock();
let (current, _) = &*rates;
Some(current.clone())
}
// 分离图标加载和速率渲染
pub fn add_speed_text(
is_custom_icon: bool,
icon_bytes: Option<Vec<u8>>,
rate: Option<&Rate>,
) -> Result<Vec<u8>> {
let rate = rate.unwrap_or(&Rate { up: 0, down: 0 });
let (mut icon_width, mut icon_height) = (0, 256);
let icon_image = if let Some(bytes) = icon_bytes.clone() {
let icon_image = image::load_from_memory(&bytes)?;
icon_width = icon_image.width();
icon_height = icon_image.height();
icon_image
} else {
// 返回一个空的 RGBA 图像
image::DynamicImage::new_rgba8(0, 0)
};
let total_width = match (is_custom_icon, icon_bytes.is_some()) {
(true, true) => 510,
(true, false) => 740,
(false, false) => 740,
(false, true) => icon_width + 740,
};
// println!(
// "icon_height: {}, icon_wight: {}, total_width: {}",
// icon_height, icon_width, total_width
// );
// 创建新的透明画布
let mut combined_image = RgbaImage::new(total_width, icon_height);
// 将原始图标绘制到新画布的左侧
if icon_bytes.is_some() {
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 is_colorful = if let Some(bytes) = icon_bytes.clone() {
!crate::utils::help::is_monochrome_image_from_bytes(&bytes).unwrap_or(false)
} else {
false
};
// 选择文本颜色
let (text_color, shadow_color) = if is_colorful {
(
Rgba([144u8, 144u8, 144u8, 255u8]),
// Rgba([255u8, 255u8, 255u8, 128u8]),
Rgba([0u8, 0u8, 0u8, 128u8]),
)
// (
// Rgba([160u8, 160u8, 160u8, 255u8]),
// // Rgba([255u8, 255u8, 255u8, 128u8]),
// Rgba([0u8, 0u8, 0u8, 255u8]),
// )
} else {
(
Rgba([255u8, 255u8, 255u8, 255u8]),
Rgba([0u8, 0u8, 0u8, 128u8]),
)
};
// 减小字体大小以适应文本区域
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!("{}", format_bytes_speed(rate.up));
let down_text = format!("{}", format_bytes_speed(rate.down));
// For test rate display
// let down_text = format!("↓ {}", format_bytes_speed(102 * 1020 * 1024));
// 计算文本位置,确保垂直间距合适
// 修改文本位置为居右显示
// 计算右对齐的文本位置
// let up_text_width = imageproc::drawing::text_size(scale, &font, &up_text).0 as u32;
// let down_text_width = imageproc::drawing::text_size(scale, &font, &down_text).0 as u32;
// let up_text_x = total_width - up_text_width;
// let down_text_x = total_width - down_text_width;
// 计算左对齐的文本位置
let (up_text_x, down_text_x) = {
if is_custom_icon || icon_bytes.is_some() {
let text_left_offset = 30;
let left_begin = icon_width + text_left_offset;
(left_begin, left_begin)
} else {
(icon_width, icon_width)
}
};
// 优化垂直位置,使速率显示的高度和上下间距正好等于图标大小
let text_height = font_size as i32;
let total_text_height = text_height * 2;
let up_y = (icon_height as i32 - total_text_height) / 2;
let down_y = up_y + text_height;
// 绘制速率文本(先阴影后文字)
let shadow_offset = 1;
// 绘制上行速率
draw_text_mut(
&mut combined_image,
shadow_color,
up_text_x as i32 + shadow_offset,
up_y + shadow_offset,
scale,
&font,
&up_text,
);
draw_text_mut(
&mut combined_image,
text_color,
up_text_x as i32,
up_y,
scale,
&font,
&up_text,
);
// 绘制下行速率
draw_text_mut(
&mut combined_image,
shadow_color,
down_text_x as i32 + shadow_offset,
down_y + shadow_offset,
scale,
&font,
&down_text,
);
draw_text_mut(
&mut combined_image,
text_color,
down_text_x as i32,
down_y,
scale,
&font,
&down_text,
);
// 将结果转换为 PNG 数据
let mut bytes = Vec::new();
combined_image.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)?;
Ok(bytes)
}
}
#[derive(Debug, Clone)]
pub struct Traffic {
pub up: u64,
pub down: u64,
}
impl Traffic {
pub async fn get_traffic_stream() -> Result<impl Stream<Item = Result<Traffic, anyhow::Error>>>
{
use futures::{
future::FutureExt,
stream::{self, StreamExt},
};
use std::time::Duration;
// 先处理错误和超时情况
let stream = Box::pin(
stream::unfold((), move |_| async move {
'retry: loop {
log::info!(target: "app", "establishing traffic websocket connection");
let (url, token) = MihomoManager::get_traffic_ws_url();
let mut request = match url.into_client_request() {
Ok(req) => req,
Err(e) => {
log::error!(target: "app", "failed to create websocket request: {}", e);
tokio::time::sleep(Duration::from_secs(2)).await;
continue 'retry;
}
};
request.headers_mut().insert(http::header::AUTHORIZATION, token);
match tokio::time::timeout(Duration::from_secs(3),
tokio_tungstenite::connect_async(request)
).await {
Ok(Ok((ws_stream, _))) => {
log::info!(target: "app", "traffic websocket connection established");
// 设置流超时控制
let traffic_stream = ws_stream
.take_while(|msg| {
let continue_stream = msg.is_ok();
async move { continue_stream }.boxed()
})
.filter_map(|msg| async move {
match msg {
Ok(msg) => {
if !msg.is_text() {
return None;
}
match tokio::time::timeout(
Duration::from_millis(200),
async { msg.into_text() }
).await {
Ok(Ok(text)) => {
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(json) => {
let up = json["up"].as_u64().unwrap_or(0);
let down = json["down"].as_u64().unwrap_or(0);
Some(Ok(Traffic { up, down }))
},
Err(e) => {
log::warn!(target: "app", "traffic json parse error: {} for {}", e, text);
None
}
}
},
Ok(Err(e)) => {
log::warn!(target: "app", "traffic text conversion error: {}", e);
None
},
Err(_) => {
log::warn!(target: "app", "traffic text processing timeout");
None
}
}
},
Err(e) => {
log::error!(target: "app", "traffic websocket error: {}", e);
None
}
}
});
return Some((traffic_stream, ()));
},
Ok(Err(e)) => {
log::error!(target: "app", "traffic websocket connection failed: {}", e);
},
Err(_) => {
log::error!(target: "app", "traffic websocket connection timed out");
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
})
.flatten(),
);
Ok(stream)
}
}

View File

@@ -11,7 +11,7 @@ use std::fs;
/// Create a backup and upload to WebDAV /// Create a backup and upload to WebDAV
pub async fn create_backup_and_upload_webdav() -> Result<()> { pub async fn create_backup_and_upload_webdav() -> Result<()> {
let (file_name, temp_file_path) = backup::create_backup().map_err(|err| { let (file_name, temp_file_path) = backup::create_backup().map_err(|err| {
log::error!(target: "app", "Failed to create backup: {:#?}", err); log::error!(target: "app", "Failed to create backup: {err:#?}");
err err
})?; })?;
@@ -19,12 +19,12 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> {
.upload(temp_file_path.clone(), file_name) .upload(temp_file_path.clone(), file_name)
.await .await
{ {
log::error!(target: "app", "Failed to upload to WebDAV: {:#?}", err); log::error!(target: "app", "Failed to upload to WebDAV: {err:#?}");
return Err(err); return Err(err);
} }
if let Err(err) = std::fs::remove_file(&temp_file_path) { if let Err(err) = std::fs::remove_file(&temp_file_path) {
log::warn!(target: "app", "Failed to remove temp file: {:#?}", err); log::warn!(target: "app", "Failed to remove temp file: {err:#?}");
} }
Ok(()) Ok(())
@@ -33,7 +33,7 @@ pub async fn create_backup_and_upload_webdav() -> Result<()> {
/// List WebDAV backups /// List WebDAV backups
pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> { pub async fn list_wevdav_backup() -> Result<Vec<ListFile>> {
backup::WebDavClient::global().list().await.map_err(|err| { backup::WebDavClient::global().list().await.map_err(|err| {
log::error!(target: "app", "Failed to list WebDAV backup files: {:#?}", err); log::error!(target: "app", "Failed to list WebDAV backup files: {err:#?}");
err err
}) })
} }
@@ -44,7 +44,7 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> {
.delete(filename) .delete(filename)
.await .await
.map_err(|err| { .map_err(|err| {
log::error!(target: "app", "Failed to delete WebDAV backup file: {:#?}", err); log::error!(target: "app", "Failed to delete WebDAV backup file: {err:#?}");
err err
}) })
} }
@@ -62,7 +62,7 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
.download(filename, backup_storage_path.clone()) .download(filename, backup_storage_path.clone())
.await .await
.map_err(|err| { .map_err(|err| {
log::error!(target: "app", "Failed to download WebDAV backup file: {:#?}", err); log::error!(target: "app", "Failed to download WebDAV backup file: {err:#?}");
err err
})?; })?;

View File

@@ -49,7 +49,7 @@ fn after_change_clash_mode() {
} }
} }
Err(err) => { Err(err) => {
log::error!(target: "app", "Failed to get connections: {}", err); log::error!(target: "app", "Failed to get connections: {err}");
} }
} }
}); });
@@ -108,12 +108,12 @@ pub async fn test_delay(url: String) -> anyhow::Result<u32> {
let start = Instant::now(); let start = Instant::now();
let response = NetworkManager::global() let response = NetworkManager::global()
.get_with_interrupt(&url, proxy_type, Some(10), user_agent, false) .get_with_interrupt(&url, proxy_type, Some(10), user_agent, false, false)
.await; .await;
match response { match response {
Ok(response) => { Ok(response) => {
log::trace!(target: "app", "test_delay response: {:#?}", response); log::trace!(target: "app", "test_delay response: {response:#?}");
if response.status().is_success() { if response.status().is_success() {
Ok(start.elapsed().as_millis() as u32) Ok(start.elapsed().as_millis() as u32)
} else { } else {
@@ -121,7 +121,7 @@ pub async fn test_delay(url: String) -> anyhow::Result<u32> {
} }
} }
Err(err) => { Err(err) => {
log::trace!(target: "app", "test_delay error: {:#?}", err); log::trace!(target: "app", "test_delay error: {err:#?}");
Err(err) Err(err)
} }
} }

View File

@@ -41,10 +41,10 @@ pub async fn update_profile(
let is_remote = item.itype.as_ref().is_some_and(|s| s == "remote"); let is_remote = item.itype.as_ref().is_some_and(|s| s == "remote");
if !is_remote { if !is_remote {
log::info!(target: "app", "[订阅更新] {} 不是远程订阅,跳过更新", uid); log::info!(target: "app", "[订阅更新] {uid} 不是远程订阅,跳过更新");
None // 非远程订阅直接更新 None // 非远程订阅直接更新
} else if item.url.is_none() { } else if item.url.is_none() {
log::warn!(target: "app", "[订阅更新] {} 缺少URL无法更新", uid); log::warn!(target: "app", "[订阅更新] {uid} 缺少URL无法更新");
bail!("failed to get the profile item url"); bail!("failed to get the profile item url");
} else { } else {
log::info!(target: "app", log::info!(target: "app",
@@ -70,12 +70,12 @@ pub async fn update_profile(
profiles.update_item(uid.clone(), item)?; profiles.update_item(uid.clone(), item)?;
let is_current = Some(uid.clone()) == profiles.get_current(); let is_current = Some(uid.clone()) == profiles.get_current();
log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {}", is_current); log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {is_current}");
is_current && auto_refresh is_current && auto_refresh
} }
Err(err) => { Err(err) => {
// 首次更新失败尝试使用Clash代理 // 首次更新失败尝试使用Clash代理
log::warn!(target: "app", "[订阅更新] 正常更新失败: {}尝试使用Clash代理更新", err); log::warn!(target: "app", "[订阅更新] 正常更新失败: {err}尝试使用Clash代理更新");
// 发送通知 // 发送通知
handle::Handle::notice_message("update_retry_with_clash", uid.clone()); handle::Handle::notice_message("update_retry_with_clash", uid.clone());
@@ -112,14 +112,14 @@ pub async fn update_profile(
handle::Handle::notice_message("update_with_clash_proxy", profile_name); handle::Handle::notice_message("update_with_clash_proxy", profile_name);
let is_current = Some(uid.clone()) == profiles.get_current(); let is_current = Some(uid.clone()) == profiles.get_current();
log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {}", is_current); log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {is_current}");
is_current && auto_refresh is_current && auto_refresh
} }
Err(retry_err) => { Err(retry_err) => {
log::error!(target: "app", "[订阅更新] 使用Clash代理更新仍然失败: {}", retry_err); log::error!(target: "app", "[订阅更新] 使用Clash代理更新仍然失败: {retry_err}");
handle::Handle::notice_message( handle::Handle::notice_message(
"update_failed_even_with_clash", "update_failed_even_with_clash",
format!("{}", retry_err), format!("{retry_err}"),
); );
return Err(retry_err); return Err(retry_err);
} }

View File

@@ -22,7 +22,7 @@ pub fn toggle_system_proxy() {
.close_all_connections() .close_all_connections()
.await .await
{ {
log::error!(target: "app", "Failed to close all connections: {}", err); log::error!(target: "app", "Failed to close all connections: {err}");
} }
} }
@@ -75,8 +75,8 @@ pub fn copy_clash_env() {
let app_handle = handle::Handle::global().app_handle().unwrap(); let app_handle = handle::Handle::global().app_handle().unwrap();
let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) }; let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
let http_proxy = format!("http://{clash_verge_rev_ip}:{}", port); let http_proxy = format!("http://{clash_verge_rev_ip}:{port}");
let socks5_proxy = format!("socks5://{clash_verge_rev_ip}:{}", port); let socks5_proxy = format!("socks5://{clash_verge_rev_ip}:{port}");
let cliboard = app_handle.clipboard(); let cliboard = app_handle.clipboard();
let env_type = { Config::verge().latest().env_type.clone() }; let env_type = { Config::verge().latest().env_type.clone() };

View File

@@ -11,23 +11,54 @@ use crate::{
/// Open or close the dashboard window /// Open or close the dashboard window
#[allow(dead_code)] #[allow(dead_code)]
pub fn open_or_close_dashboard() { pub fn open_or_close_dashboard() {
open_or_close_dashboard_internal(false)
}
/// Open or close the dashboard window (hotkey call, dispatched to main thread)
#[allow(dead_code)]
pub fn open_or_close_dashboard_hotkey() {
open_or_close_dashboard_internal(true)
}
/// Internal implementation for opening/closing dashboard
fn open_or_close_dashboard_internal(bypass_debounce: bool) {
use crate::process::AsyncHandler;
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
log::info!(target: "app", "Attempting to open/close dashboard"); log::info!(target: "app", "Attempting to open/close dashboard (绕过防抖: {bypass_debounce})");
// 检查是否在轻量模式下 // 热键调用调度到主线程执行,避免 WebView 创建死锁
if bypass_debounce {
log::info!(target: "app", "热键调用,调度到主线程执行窗口操作");
AsyncHandler::spawn(move || async move {
log::info!(target: "app", "主线程中执行热键窗口操作");
if crate::module::lightweight::is_in_lightweight_mode() {
log::info!(target: "app", "Currently in lightweight mode, exiting lightweight mode");
crate::module::lightweight::exit_lightweight_mode();
log::info!(target: "app", "Creating new window after exiting lightweight mode");
let result = WindowManager::show_main_window();
log::info!(target: "app", "Window operation result: {result:?}");
return;
}
let result = WindowManager::toggle_main_window();
log::info!(target: "app", "Window toggle result: {result:?}");
});
return;
}
if crate::module::lightweight::is_in_lightweight_mode() { if crate::module::lightweight::is_in_lightweight_mode() {
log::info!(target: "app", "Currently in lightweight mode, exiting lightweight mode"); log::info!(target: "app", "Currently in lightweight mode, exiting lightweight mode");
crate::module::lightweight::exit_lightweight_mode(); crate::module::lightweight::exit_lightweight_mode();
log::info!(target: "app", "Creating new window after exiting lightweight mode"); log::info!(target: "app", "Creating new window after exiting lightweight mode");
let result = WindowManager::show_main_window(); let result = WindowManager::show_main_window();
log::info!(target: "app", "Window operation result: {:?}", result); log::info!(target: "app", "Window operation result: {result:?}");
return; return;
} }
// 使用统一的窗口管理器切换窗口状态
let result = WindowManager::toggle_main_window(); let result = WindowManager::toggle_main_window();
log::info!(target: "app", "Window toggle result: {:?}", result); log::info!(target: "app", "Window toggle result: {result:?}");
} }
/// 异步优化的应用退出函数 /// 异步优化的应用退出函数

View File

@@ -125,6 +125,7 @@ pub fn run() {
#[allow(unused_mut)] #[allow(unused_mut)]
let mut builder = tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
@@ -342,7 +343,7 @@ pub fn run() {
.get_webview_window("main") .get_webview_window("main")
{ {
logging!(info, Type::Window, true, "设置macOS窗口标题"); logging!(info, Type::Window, true, "设置macOS窗口标题");
let _ = window.set_title("Clash Verge"); let _ = window.set_title("Clash Verge Rev Lite");
} }
} }
} }

View File

@@ -13,11 +13,17 @@ use crate::AppHandleManager;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use delay_timer::prelude::TaskBuilder; use delay_timer::prelude::TaskBuilder;
use std::sync::Mutex; use std::sync::{
atomic::{AtomicBool, Ordering},
Mutex,
};
use tauri::{Listener, Manager}; use tauri::{Listener, Manager};
const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task"; const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task";
// 添加退出轻量模式的锁,防止并发调用
static EXITING_LIGHTWEIGHT: AtomicBool = AtomicBool::new(false);
fn with_lightweight_status<F, R>(f: F) -> R fn with_lightweight_status<F, R>(f: F) -> R
where where
F: FnOnce(&mut LightWeightState) -> R, F: FnOnce(&mut LightWeightState) -> R,
@@ -30,24 +36,24 @@ where
pub fn run_once_auto_lightweight() { pub fn run_once_auto_lightweight() {
LightWeightState::default().run_once_time(|| { LightWeightState::default().run_once_time(|| {
let is_silent_start = Config::verge().data().enable_silent_start.unwrap_or(true); let is_silent_start = Config::verge().data().enable_silent_start.unwrap_or(false);
let enable_auto = Config::verge() let enable_auto = Config::verge()
.data() .data()
.enable_auto_light_weight_mode .enable_auto_light_weight_mode
.unwrap_or(true); .unwrap_or(false);
if enable_auto && is_silent_start { if enable_auto && is_silent_start {
logging!( logging!(
info, info,
Type::Lightweight, Type::Lightweight,
true, true,
"正常创建窗口添加定时器监听器" "在静默启动的情况下,创建窗口添加自动进入轻量模式窗口监听器"
); );
set_lightweight_mode(false); set_lightweight_mode(false);
disable_auto_light_weight_mode(); enable_auto_light_weight_mode();
// 触发托盘更新 // 触发托盘更新
if let Err(e) = Tray::global().update_part() { if let Err(e) = Tray::global().update_part() {
log::warn!("Failed to update tray: {}", e); log::warn!("Failed to update tray: {e}");
} }
} }
}); });
@@ -59,14 +65,19 @@ pub fn auto_lightweight_mode_init() {
let is_silent_start = { Config::verge().data().enable_silent_start }.unwrap_or(false); let is_silent_start = { Config::verge().data().enable_silent_start }.unwrap_or(false);
let enable_auto = { Config::verge().data().enable_auto_light_weight_mode }.unwrap_or(false); let enable_auto = { Config::verge().data().enable_auto_light_weight_mode }.unwrap_or(false);
if enable_auto && is_silent_start { if enable_auto && !is_silent_start {
logging!(info, Type::Lightweight, true, "自动轻量模式静默启动"); logging!(
info,
Type::Lightweight,
true,
"非静默启动直接挂载自动进入轻量模式监听器!"
);
set_lightweight_mode(true); set_lightweight_mode(true);
enable_auto_light_weight_mode(); enable_auto_light_weight_mode();
// 确保托盘状态更新 // 确保托盘状态更新
if let Err(e) = Tray::global().update_part() { if let Err(e) = Tray::global().update_part() {
log::warn!("Failed to update tray: {}", e); log::warn!("Failed to update tray: {e}");
} }
} }
} }
@@ -78,14 +89,14 @@ pub fn is_in_lightweight_mode() -> bool {
} }
// 设置轻量模式状态 // 设置轻量模式状态
fn set_lightweight_mode(value: bool) { pub fn set_lightweight_mode(value: bool) {
with_lightweight_status(|state| { with_lightweight_status(|state| {
state.set_lightweight_mode(value); state.set_lightweight_mode(value);
}); });
// 触发托盘更新 // 触发托盘更新
if let Err(e) = Tray::global().update_part() { if let Err(e) = Tray::global().update_part() {
log::warn!("Failed to update tray: {}", e); log::warn!("Failed to update tray: {e}");
} }
} }
@@ -120,7 +131,6 @@ pub fn entry_lightweight_mode() {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
AppHandleManager::global().set_activation_policy_accessory(); AppHandleManager::global().set_activation_policy_accessory();
logging!(info, Type::Lightweight, true, "轻量模式已开启");
} }
set_lightweight_mode(true); set_lightweight_mode(true);
let _ = cancel_light_weight_timer(); let _ = cancel_light_weight_timer();
@@ -131,6 +141,25 @@ pub fn entry_lightweight_mode() {
// 添加从轻量模式恢复的函数 // 添加从轻量模式恢复的函数
pub fn exit_lightweight_mode() { pub fn exit_lightweight_mode() {
// 使用原子操作检查是否已经在退出过程中,防止并发调用
if EXITING_LIGHTWEIGHT
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
logging!(
info,
Type::Lightweight,
true,
"轻量模式退出操作已在进行中,跳过重复调用"
);
return;
}
// 使用defer确保无论如何都会重置标志
let _guard = scopeguard::guard((), |_| {
EXITING_LIGHTWEIGHT.store(false, Ordering::SeqCst);
});
// 确保当前确实处于轻量模式才执行退出操作 // 确保当前确实处于轻量模式才执行退出操作
if !is_in_lightweight_mode() { if !is_in_lightweight_mode() {
logging!(info, Type::Lightweight, true, "当前不在轻量模式,无需退出"); logging!(info, Type::Lightweight, true, "当前不在轻量模式,无需退出");
@@ -138,7 +167,6 @@ pub fn exit_lightweight_mode() {
} }
set_lightweight_mode(false); set_lightweight_mode(false);
logging!(info, Type::Lightweight, true, "正在退出轻量模式");
// macOS激活策略 // macOS激活策略
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -4,8 +4,6 @@ use once_cell::sync::Lazy;
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::http::HeaderMap; use tauri::http::HeaderMap;
#[cfg(target_os = "macos")]
use tauri::http::HeaderValue;
// 缓存的最大有效期5秒 // 缓存的最大有效期5秒
const CACHE_TTL: Duration = Duration::from_secs(5); const CACHE_TTL: Duration = Duration::from_secs(5);
@@ -99,38 +97,12 @@ impl MihomoManager {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("Content-Type", "application/json".parse().unwrap()); headers.insert("Content-Type", "application/json".parse().unwrap());
if let Some(secret) = client.secret { if let Some(secret) = client.secret {
let secret = format!("Bearer {}", secret).parse().unwrap(); let secret = format!("Bearer {secret}").parse().unwrap();
headers.insert("Authorization", secret); headers.insert("Authorization", secret);
} }
Some((server, headers)) Some((server, headers))
} }
// 提供默认值的版本避免在connection_info为None时panic // 已移除未使用的 get_clash_client_info_or_default 和 get_traffic_ws_url 方法
#[cfg(target_os = "macos")]
fn get_clash_client_info_or_default() -> (String, HeaderMap) {
Self::get_clash_client_info().unwrap_or_else(|| {
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "application/json".parse().unwrap());
("http://127.0.0.1:9090".to_string(), headers)
})
}
#[cfg(target_os = "macos")]
pub fn get_traffic_ws_url() -> (String, HeaderValue) {
let (url, headers) = MihomoManager::get_clash_client_info_or_default();
let ws_url = url.replace("http://", "ws://") + "/traffic";
let auth = headers
.get("Authorization")
.map(|val| val.to_str().unwrap_or("").to_string())
.unwrap_or_default();
// 创建默认的空HeaderValue而不是使用unwrap_or_default
let token = match HeaderValue::from_str(&auth) {
Ok(v) => v,
Err(_) => HeaderValue::from_static(""),
};
(ws_url, token)
}
} }

View File

@@ -4,7 +4,7 @@ use anyhow::{anyhow, Result};
use log::info; use log::info;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use std::{fs, path::Path, path::PathBuf}; use std::{fs, os::windows::process::CommandExt, path::Path, path::PathBuf};
/// Windows 下的开机启动文件夹路径 /// Windows 下的开机启动文件夹路径
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -59,6 +59,8 @@ pub fn create_shortcut() -> Result<()> {
let output = std::process::Command::new("powershell") let output = std::process::Command::new("powershell")
.args(["-Command", &powershell_command]) .args(["-Command", &powershell_command])
// 隐藏 PowerShell 窗口
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output() .output()
.map_err(|e| anyhow!("执行 PowerShell 命令失败: {}", e))?; .map_err(|e| anyhow!("执行 PowerShell 命令失败: {}", e))?;

View File

@@ -94,7 +94,7 @@ pub fn app_home_dir() -> Result<PathBuf> {
// 如果无法获取系统目录,则回退到可执行文件目录 // 如果无法获取系统目录,则回退到可执行文件目录
let fallback_dir = PathBuf::from(exe_dir).join(".config").join(APP_ID); let fallback_dir = PathBuf::from(exe_dir).join(".config").join(APP_ID);
log::warn!(target: "app", "Using fallback data directory: {:?}", fallback_dir); log::warn!(target: "app", "Using fallback data directory: {fallback_dir:?}");
return Ok(fallback_dir); return Ok(fallback_dir);
} }
}; };
@@ -102,7 +102,7 @@ pub fn app_home_dir() -> Result<PathBuf> {
match app_handle.path().data_dir() { match app_handle.path().data_dir() {
Ok(dir) => Ok(dir.join(APP_ID)), Ok(dir) => Ok(dir.join(APP_ID)),
Err(e) => { Err(e) => {
log::error!(target: "app", "Failed to get the app home directory: {}", e); log::error!(target: "app", "Failed to get the app home directory: {e}");
Err(anyhow::anyhow!("Failed to get the app homedirectory")) Err(anyhow::anyhow!("Failed to get the app homedirectory"))
} }
} }
@@ -127,7 +127,7 @@ pub fn app_resources_dir() -> Result<PathBuf> {
match app_handle.path().resource_dir() { match app_handle.path().resource_dir() {
Ok(dir) => Ok(dir.join("resources")), Ok(dir) => Ok(dir.join("resources")),
Err(e) => { Err(e) => {
log::error!(target: "app", "Failed to get the resource directory: {}", e); log::error!(target: "app", "Failed to get the resource directory: {e}");
Err(anyhow::anyhow!("Failed to get the resource directory")) Err(anyhow::anyhow!("Failed to get the resource directory"))
} }
} }
@@ -203,7 +203,7 @@ pub fn service_log_file() -> Result<PathBuf> {
let log_dir = app_logs_dir()?.join("service"); let log_dir = app_logs_dir()?.join("service");
let local_time = Local::now().format("%Y-%m-%d-%H%M").to_string(); let local_time = Local::now().format("%Y-%m-%d-%H%M").to_string();
let log_file = format!("{}.log", local_time); let log_file = format!("{local_time}.log");
let log_file = log_dir.join(log_file); let log_file = log_dir.join(log_file);
let _ = std::fs::create_dir_all(&log_dir); let _ = std::fs::create_dir_all(&log_dir);

View File

@@ -125,19 +125,6 @@ pub fn open_file(_: tauri::AppHandle, path: PathBuf) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(target_os = "macos")]
pub fn is_monochrome_image_from_bytes(data: &[u8]) -> anyhow::Result<bool> {
let img = image::load_from_memory(data)?;
let rgb_img = img.to_rgb8();
for pixel in rgb_img.pixels() {
if pixel[0] != pixel[1] || pixel[1] != pixel[2] {
return Ok(false);
}
}
Ok(true)
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn linux_elevator() -> String { pub fn linux_elevator() -> String {
use std::process::Command; use std::process::Command;
@@ -176,39 +163,3 @@ 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"
/// ```
/// ```
#[cfg(target_os = "macos")]
pub fn format_bytes_speed(speed: u64) -> String {
const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
let mut size = speed as f64;
let mut unit_index = 0;
while size >= 1000.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.1}{}/s", size, UNITS[unit_index])
}
#[cfg(target_os = "macos")]
#[test]
fn test_format_bytes_speed() {
assert_eq!(format_bytes_speed(0), "0.0B/s");
assert_eq!(format_bytes_speed(1023), "1.0KB/s");
assert_eq!(format_bytes_speed(1024), "1.0KB/s");
assert_eq!(format_bytes_speed(1024 * 1024), "1.0MB/s");
assert_eq!(format_bytes_speed(1024 * 1024 * 1024), "1.0GB/s");
assert_eq!(format_bytes_speed(1024 * 500), "500.0KB/s");
assert_eq!(format_bytes_speed(1024 * 1024 * 2), "2.0MB/s");
}

View File

@@ -38,7 +38,7 @@ static TRANSLATIONS: Lazy<HashMap<String, Value>> = Lazy::new(|| {
if let Some(locales_dir) = get_locales_dir() { if let Some(locales_dir) = get_locales_dir() {
for lang in get_supported_languages() { for lang in get_supported_languages() {
let file_path = locales_dir.join(format!("{}.json", lang)); let file_path = locales_dir.join(format!("{lang}.json"));
if let Ok(content) = fs::read_to_string(file_path) { if let Ok(content) = fs::read_to_string(file_path) {
if let Ok(json) = serde_json::from_str(&content) { if let Ok(json) = serde_json::from_str(&content) {
translations.insert(lang.to_string(), json); translations.insert(lang.to_string(), json);

View File

@@ -31,7 +31,7 @@ fn init_log() -> Result<()> {
} }
let local_time = Local::now().format("%Y-%m-%d-%H%M").to_string(); let local_time = Local::now().format("%Y-%m-%d-%H%M").to_string();
let log_file = format!("{}.log", local_time); let log_file = format!("{local_time}.log");
let log_file = log_dir.join(log_file); let log_file = log_dir.join(log_file);
let log_pattern = match log_level { let log_pattern = match log_level {

View File

@@ -5,7 +5,9 @@ pub mod i18n;
pub mod init; pub mod init;
pub mod logging; pub mod logging;
pub mod network; pub mod network;
pub mod notification;
pub mod resolve; pub mod resolve;
pub mod server; pub mod server;
pub mod tmpl; pub mod tmpl;
pub mod window_manager; pub mod window_manager;
pub mod sys_info;

View File

@@ -7,7 +7,7 @@ use std::{
}; };
use tokio::runtime::{Builder, Runtime}; use tokio::runtime::{Builder, Runtime};
use crate::{config::Config, logging, utils::logging::Type}; use crate::{config::Config, logging, utils::logging::Type, utils::sys_info};
// HTTP2 相关 // HTTP2 相关
const H2_CONNECTION_WINDOW_SIZE: u32 = 1024 * 1024; const H2_CONNECTION_WINDOW_SIZE: u32 = 1024 * 1024;
@@ -248,6 +248,7 @@ impl NetworkManager {
timeout_secs: Option<u64>, timeout_secs: Option<u64>,
user_agent: Option<String>, user_agent: Option<String>,
accept_invalid_certs: bool, accept_invalid_certs: bool,
use_hwid: bool,
) -> RequestBuilder { ) -> RequestBuilder {
if self.should_reset_clients() { if self.should_reset_clients() {
self.reset_clients(); self.reset_clients();
@@ -322,7 +323,7 @@ impl NetworkManager {
use crate::utils::resolve::VERSION; use crate::utils::resolve::VERSION;
let version = match VERSION.get() { let version = match VERSION.get() {
Some(v) => format!("clash-verge/v{}", v), Some(v) => format!("clash-verge/v{v}"),
None => "clash-verge/unknown".to_string(), None => "clash-verge/unknown".to_string(),
}; };
@@ -331,7 +332,18 @@ impl NetworkManager {
let client = builder.build().expect("Failed to build custom HTTP client"); let client = builder.build().expect("Failed to build custom HTTP client");
client.get(url) let mut request_builder = client.get(url);
if use_hwid {
let sys_info = sys_info::get_system_info();
logging!(info, Type::Network, true, "Adding HWID headers to request");
request_builder = request_builder
.header("x-hwid", &sys_info.hwid)
.header("x-device-os", &sys_info.os_type)
.header("x-ver-os", &sys_info.os_ver);
}
request_builder
} }
/* /// 执行GET请求添加错误跟踪 /* /// 执行GET请求添加错误跟踪
@@ -378,6 +390,7 @@ impl NetworkManager {
timeout_secs: Option<u64>, timeout_secs: Option<u64>,
user_agent: Option<String>, user_agent: Option<String>,
accept_invalid_certs: bool, accept_invalid_certs: bool,
use_hwid: bool,
) -> Result<Response> { ) -> Result<Response> {
let request = self.create_request( let request = self.create_request(
url, url,
@@ -385,6 +398,7 @@ impl NetworkManager {
timeout_secs, timeout_secs,
user_agent, user_agent,
accept_invalid_certs, accept_invalid_certs,
use_hwid,
); );
let timeout_duration = timeout_secs.unwrap_or(20); let timeout_duration = timeout_secs.unwrap_or(20);
@@ -401,7 +415,7 @@ impl NetworkManager {
let result = tokio::select! { let result = tokio::select! {
result = request.send() => result, result = request.send() => result,
_ = cancel_rx => { _ = cancel_rx => {
self.record_connection_error(&format!("Request interrupted for: {}", url)); self.record_connection_error(&format!("Request interrupted for: {url}"));
return Err(anyhow::anyhow!("Request interrupted after {} seconds", timeout_duration)); return Err(anyhow::anyhow!("Request interrupted after {} seconds", timeout_duration));
} }
}; };

View File

@@ -0,0 +1,70 @@
use tauri::AppHandle;
use tauri_plugin_notification::NotificationExt;
pub enum NotificationEvent<'a> {
DashboardToggled,
ClashModeChanged {
mode: &'a str,
},
SystemProxyToggled,
TunModeToggled,
LightweightModeEntered,
AppQuit,
#[cfg(target_os = "macos")]
AppHidden,
}
fn notify(app: &AppHandle, title: &str, body: &str) {
app.notification()
.builder()
.title(title)
.body(body)
.show()
.ok();
}
pub fn notify_event(app: &AppHandle, event: NotificationEvent) {
use crate::utils::i18n::t;
match event {
NotificationEvent::DashboardToggled => {
notify(app, &t("DashboardToggledTitle"), &t("DashboardToggledBody"));
}
NotificationEvent::ClashModeChanged { mode } => {
notify(
app,
&t("ClashModeChangedTitle"),
&t_with_args("ClashModeChangedBody", mode),
);
}
NotificationEvent::SystemProxyToggled => {
notify(
app,
&t("SystemProxyToggledTitle"),
&t("SystemProxyToggledBody"),
);
}
NotificationEvent::TunModeToggled => {
notify(app, &t("TunModeToggledTitle"), &t("TunModeToggledBody"));
}
NotificationEvent::LightweightModeEntered => {
notify(
app,
&t("LightweightModeEnteredTitle"),
&t("LightweightModeEnteredBody"),
);
}
NotificationEvent::AppQuit => {
notify(app, &t("AppQuitTitle"), &t("AppQuitBody"));
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
notify(app, &t("AppHiddenTitle"), &t("AppHiddenBody"));
}
}
}
// 辅助函数带参数的i18n
fn t_with_args(key: &str, mode: &str) -> String {
use crate::utils::i18n::t;
t(key).replace("{mode}", mode)
}

View File

@@ -13,6 +13,7 @@ use anyhow::{bail, Result};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
use percent_encoding::percent_decode_str; use percent_encoding::percent_decode_str;
use scopeguard;
use serde_yaml::Mapping; use serde_yaml::Mapping;
use std::{ use std::{
sync::Arc, sync::Arc,
@@ -22,6 +23,7 @@ use tauri::{AppHandle, Manager};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tauri::Url; use tauri::Url;
use crate::config::PrfOption;
//#[cfg(not(target_os = "linux"))] //#[cfg(not(target_os = "linux"))]
// use window_shadows::set_shadow; // use window_shadows::set_shadow;
@@ -51,14 +53,12 @@ pub enum UiReadyStage {
#[derive(Debug)] #[derive(Debug)]
struct UiReadyState { struct UiReadyState {
stage: RwLock<UiReadyStage>, stage: RwLock<UiReadyStage>,
last_update: RwLock<Instant>,
} }
impl Default for UiReadyState { impl Default for UiReadyState {
fn default() -> Self { fn default() -> Self {
Self { Self {
stage: RwLock::new(UiReadyStage::NotStarted), stage: RwLock::new(UiReadyStage::NotStarted),
last_update: RwLock::new(Instant::now()),
} }
} }
} }
@@ -82,20 +82,8 @@ fn get_ui_ready_state() -> &'static Arc<UiReadyState> {
pub fn update_ui_ready_stage(stage: UiReadyStage) { pub fn update_ui_ready_stage(stage: UiReadyStage) {
let state = get_ui_ready_state(); let state = get_ui_ready_state();
let mut stage_lock = state.stage.write(); let mut stage_lock = state.stage.write();
let mut time_lock = state.last_update.write();
*stage_lock = stage; *stage_lock = stage;
*time_lock = Instant::now();
logging!(
info,
Type::Window,
true,
"UI准备阶段更新: {:?}, 耗时: {:?}ms",
stage,
time_lock.elapsed().as_millis()
);
// 如果是最终阶段标记UI完全就绪 // 如果是最终阶段标记UI完全就绪
if stage == UiReadyStage::Ready { if stage == UiReadyStage::Ready {
mark_ui_ready(); mark_ui_ready();
@@ -118,9 +106,7 @@ pub fn reset_ui_ready() {
{ {
let state = get_ui_ready_state(); let state = get_ui_ready_state();
let mut stage = state.stage.write(); let mut stage = state.stage.write();
let mut time = state.last_update.write();
*stage = UiReadyStage::NotStarted; *stage = UiReadyStage::NotStarted;
*time = Instant::now();
} }
logging!(info, Type::Window, true, "UI就绪状态已重置"); logging!(info, Type::Window, true, "UI就绪状态已重置");
} }
@@ -136,7 +122,7 @@ pub async fn find_unused_port() -> Result<u16> {
.latest() .latest()
.verge_mixed_port .verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port()); .unwrap_or(Config::clash().data().get_mixed_port());
log::warn!(target: "app", "use default port: {}", port); log::warn!(target: "app", "use default port: {port}");
Ok(port) Ok(port)
} }
} }
@@ -294,6 +280,7 @@ pub fn create_window(is_show: bool) -> bool {
if !is_show { if !is_show {
logging!(info, Type::Window, true, "静默模式启动时不创建窗口"); logging!(info, Type::Window, true, "静默模式启动时不创建窗口");
lightweight::set_lightweight_mode(true);
handle::Handle::notify_startup_completed(); handle::Handle::notify_startup_completed();
return false; return false;
} }
@@ -337,12 +324,18 @@ pub fn create_window(is_show: bool) -> bool {
*creating = (true, Instant::now()); *creating = (true, Instant::now());
// ScopeGuard 确保创建状态重置,防止 webview 卡死
let _guard = scopeguard::guard(creating, |mut creating_guard| {
*creating_guard = (false, Instant::now());
logging!(debug, Type::Window, true, "[ScopeGuard] 窗口创建状态已重置");
});
match tauri::WebviewWindowBuilder::new( match tauri::WebviewWindowBuilder::new(
&handle::Handle::global().app_handle().unwrap(), &handle::Handle::global().app_handle().unwrap(),
"main", /* the unique window label */ "main", /* the unique window label */
tauri::WebviewUrl::App("index.html".into()), tauri::WebviewUrl::App("index.html".into()),
) )
.title("Clash Verge") .title("Clash Verge Rev Lite")
.center() .center()
.decorations(true) .decorations(true)
.fullscreen(false) .fullscreen(false)
@@ -419,8 +412,6 @@ pub fn create_window(is_show: bool) -> bool {
Ok(newly_created_window) => { Ok(newly_created_window) => {
logging!(debug, Type::Window, true, "主窗口实例创建成功"); logging!(debug, Type::Window, true, "主窗口实例创建成功");
*creating = (false, Instant::now());
update_ui_ready_stage(UiReadyStage::NotStarted); update_ui_ready_stage(UiReadyStage::NotStarted);
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
@@ -534,14 +525,13 @@ pub fn create_window(is_show: bool) -> bool {
} }
Err(e) => { Err(e) => {
logging!(error, Type::Window, true, "主窗口构建失败: {}", e); logging!(error, Type::Window, true, "主窗口构建失败: {}", e);
*creating = (false, Instant::now()); // Reset the creating state if window creation failed
false false
} }
} }
} }
pub async fn resolve_scheme(param: String) -> Result<()> { pub async fn resolve_scheme(param: String) -> Result<()> {
log::info!(target:"app", "received deep link: {}", param); log::info!(target:"app", "received deep link: {param}");
let param_str = if param.starts_with("[") && param.len() > 4 { let param_str = if param.starts_with("[") && param.len() > 4 {
param param
@@ -560,29 +550,35 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
}; };
if link_parsed.scheme() == "clash" || link_parsed.scheme() == "clash-verge" { if link_parsed.scheme() == "clash" || link_parsed.scheme() == "clash-verge" {
let name = link_parsed let mut name: Option<String> = None;
.query_pairs() let mut url_param: Option<String> = None;
.find(|(key, _)| key == "name") let mut use_hwid = true;
.map(|(_, value)| value.into_owned());
let url_param = if let Some(query) = link_parsed.query() { for (key, value) in link_parsed.query_pairs() {
let prefix = "url="; match key.as_ref() {
if let Some(pos) = query.find(prefix) { "name" => name = Some(value.into_owned()),
let raw_url = &query[pos + prefix.len()..]; "url" => url_param = Some(percent_decode_str(&value).decode_utf8_lossy().to_string()),
Some(percent_decode_str(raw_url).decode_utf8_lossy().to_string()) "hwid" => use_hwid = value == "1" || value == "true",
} else { _ => {}
None
} }
}
let option = if use_hwid {
log::info!(target:"app", "HWID usage requested via deep link");
Some(PrfOption {
use_hwid: Some(true),
..Default::default()
})
} else { } else {
None None
}; };
match url_param { match url_param {
Some(url) => { Some(url) => {
log::info!(target:"app", "decoded subscription url: {}", url); log::info!(target:"app", "decoded subscription url: {url}");
create_window(false); create_window(false);
match PrfItem::from_url(url.as_ref(), name, None, None).await { match PrfItem::from_url(url.as_ref(), name, None, option).await {
Ok(item) => { Ok(item) => {
let uid = item.uid.clone().unwrap(); let uid = item.uid.clone().unwrap();
let _ = wrap_err!(Config::profiles().data().append_item(item)); let _ = wrap_err!(Config::profiles().data().append_item(item));

View File

@@ -64,7 +64,7 @@ pub fn embed_server() {
.latest() .latest()
.verge_mixed_port .verge_mixed_port
.unwrap_or(Config::clash().data().get_mixed_port()); .unwrap_or(Config::clash().data().get_mixed_port());
let content = content.replace("%mixed-port%", &format!("{}", port)); let content = content.replace("%mixed-port%", &format!("{port}"));
warp::http::Response::builder() warp::http::Response::builder()
.header("Content-Type", "application/x-ns-proxy-autoconfig") .header("Content-Type", "application/x-ns-proxy-autoconfig")
.body(content) .body(content)

View File

@@ -0,0 +1,22 @@
use once_cell::sync::Lazy;
use serde::Serialize;
#[derive(Serialize, Debug, Clone)]
pub struct SystemInfo {
pub hwid: String,
pub os_type: String,
pub os_ver: String,
}
pub static SYSTEM_INFO: Lazy<SystemInfo> = Lazy::new(|| {
let os_info = os_info::get();
SystemInfo {
hwid: machine_uid::get().unwrap_or_else(|_| "unknown_hwid".to_string()),
os_type: os_info.os_type().to_string(),
os_ver: os_info.version().to_string(),
}
});
pub fn get_system_info() -> &'static SystemInfo {
&SYSTEM_INFO
}

View File

@@ -4,6 +4,14 @@ use tauri::{Manager, WebviewWindow, Wry};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::AppHandleManager; use crate::AppHandleManager;
use once_cell::sync::OnceCell;
use parking_lot::Mutex;
use scopeguard;
use std::{
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
/// 窗口操作结果 /// 窗口操作结果
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindowOperationResult { pub enum WindowOperationResult {
@@ -34,25 +42,71 @@ pub enum WindowState {
NotExist, NotExist,
} }
// 窗口操作防抖机制
static WINDOW_OPERATION_DEBOUNCE: OnceCell<Mutex<Instant>> = OnceCell::new();
static WINDOW_OPERATION_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
const WINDOW_OPERATION_DEBOUNCE_MS: u64 = 500;
fn get_window_operation_debounce() -> &'static Mutex<Instant> {
WINDOW_OPERATION_DEBOUNCE.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(1)))
}
fn should_handle_window_operation() -> bool {
if WINDOW_OPERATION_IN_PROGRESS.load(Ordering::Acquire) {
log::warn!(target: "app", "[防抖] 窗口操作已在进行中,跳过重复调用");
return false;
}
let debounce_lock = get_window_operation_debounce();
let mut last_operation = debounce_lock.lock();
let now = Instant::now();
let elapsed = now.duration_since(*last_operation);
log::debug!(target: "app", "[防抖] 检查窗口操作间隔: {}ms (需要>={}ms)",
elapsed.as_millis(), WINDOW_OPERATION_DEBOUNCE_MS);
if elapsed >= Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS) {
*last_operation = now;
WINDOW_OPERATION_IN_PROGRESS.store(true, Ordering::Release);
log::info!(target: "app", "[防抖] 窗口操作被允许执行");
true
} else {
log::warn!(target: "app", "[防抖] 窗口操作被防抖机制忽略,距离上次操作 {}ms < {}ms",
elapsed.as_millis(), WINDOW_OPERATION_DEBOUNCE_MS);
false
}
}
fn finish_window_operation() {
WINDOW_OPERATION_IN_PROGRESS.store(false, Ordering::Release);
}
/// 统一的窗口管理器 /// 统一的窗口管理器
pub struct WindowManager; pub struct WindowManager;
impl WindowManager { impl WindowManager {
pub fn get_main_window_state() -> WindowState { pub fn get_main_window_state() -> WindowState {
if let Some(window) = Self::get_main_window() { match Self::get_main_window() {
if window.is_minimized().unwrap_or(false) { Some(window) => {
WindowState::Minimized let is_minimized = window.is_minimized().unwrap_or(false);
} else if window.is_visible().unwrap_or(false) { let is_visible = window.is_visible().unwrap_or(false);
if window.is_focused().unwrap_or(false) { let is_focused = window.is_focused().unwrap_or(false);
if is_minimized {
return WindowState::Minimized;
}
if !is_visible {
return WindowState::Hidden;
}
if is_focused {
WindowState::VisibleFocused WindowState::VisibleFocused
} else { } else {
WindowState::VisibleUnfocused WindowState::VisibleUnfocused
} }
} else {
WindowState::Hidden
} }
} else { None => WindowState::NotExist,
WindowState::NotExist
} }
} }
@@ -65,6 +119,14 @@ impl WindowManager {
/// 智能显示主窗口 /// 智能显示主窗口
pub fn show_main_window() -> WindowOperationResult { pub fn show_main_window() -> WindowOperationResult {
// 防抖检查
if !should_handle_window_operation() {
return WindowOperationResult::NoAction;
}
let _guard = scopeguard::guard((), |_| {
finish_window_operation();
});
logging!(info, Type::Window, true, "开始智能显示主窗口"); logging!(info, Type::Window, true, "开始智能显示主窗口");
logging!( logging!(
debug, debug,
@@ -80,8 +142,11 @@ impl WindowManager {
WindowState::NotExist => { WindowState::NotExist => {
logging!(info, Type::Window, true, "窗口不存在,创建新窗口"); logging!(info, Type::Window, true, "窗口不存在,创建新窗口");
if Self::create_new_window() { if Self::create_new_window() {
logging!(info, Type::Window, true, "窗口创建成功");
std::thread::sleep(std::time::Duration::from_millis(100));
WindowOperationResult::Created WindowOperationResult::Created
} else { } else {
logging!(warn, Type::Window, true, "窗口创建失败");
WindowOperationResult::Failed WindowOperationResult::Failed
} }
} }
@@ -91,6 +156,16 @@ impl WindowManager {
} }
WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => { WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => {
if let Some(window) = Self::get_main_window() { if let Some(window) = Self::get_main_window() {
let state_after_check = Self::get_main_window_state();
if state_after_check == WindowState::VisibleFocused {
logging!(
info,
Type::Window,
true,
"窗口在检查期间已变为可见和有焦点状态"
);
return WindowOperationResult::NoAction;
}
Self::activate_window(&window) Self::activate_window(&window)
} else { } else {
WindowOperationResult::Failed WindowOperationResult::Failed
@@ -101,6 +176,14 @@ impl WindowManager {
/// 切换主窗口显示状态(显示/隐藏) /// 切换主窗口显示状态(显示/隐藏)
pub fn toggle_main_window() -> WindowOperationResult { pub fn toggle_main_window() -> WindowOperationResult {
// 防抖检查
if !should_handle_window_operation() {
return WindowOperationResult::NoAction;
}
let _guard = scopeguard::guard((), |_| {
finish_window_operation();
});
logging!(info, Type::Window, true, "开始切换主窗口显示状态"); logging!(info, Type::Window, true, "开始切换主窗口显示状态");
let current_state = Self::get_main_window_state(); let current_state = Self::get_main_window_state();
@@ -108,37 +191,61 @@ impl WindowManager {
info, info,
Type::Window, Type::Window,
true, true,
"当前窗口状态: {:?}", "当前窗口状态: {:?} | 详细状态: {}",
current_state current_state,
Self::get_window_status_info()
); );
match current_state { match current_state {
WindowState::NotExist => { WindowState::NotExist => {
// 窗口不存在,创建新窗口 // 窗口不存在,创建新窗口
logging!(info, Type::Window, true, "窗口不存在,将创建新窗口");
// 由于已经有防抖保护,直接调用内部方法
if Self::create_new_window() { if Self::create_new_window() {
WindowOperationResult::Created WindowOperationResult::Created
} else { } else {
WindowOperationResult::Failed WindowOperationResult::Failed
} }
} }
WindowState::VisibleFocused => { WindowState::VisibleFocused | WindowState::VisibleUnfocused => {
// 窗口可见且有焦点,隐藏它 logging!(
if let Some(window) = Self::get_main_window() { info,
if window.hide().is_ok() { Type::Window,
logging!(info, Type::Window, true, "窗口已隐藏"); true,
WindowOperationResult::Hidden "窗口可见(焦点状态: {}),将隐藏窗口",
if current_state == WindowState::VisibleFocused {
"有焦点"
} else { } else {
WindowOperationResult::Failed "无焦点"
}
);
if let Some(window) = Self::get_main_window() {
match window.hide() {
Ok(_) => {
logging!(info, Type::Window, true, "窗口已成功隐藏");
WindowOperationResult::Hidden
}
Err(e) => {
logging!(warn, Type::Window, true, "隐藏窗口失败: {}", e);
WindowOperationResult::Failed
}
} }
} else { } else {
logging!(warn, Type::Window, true, "无法获取窗口实例");
WindowOperationResult::Failed WindowOperationResult::Failed
} }
} }
WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => { WindowState::Minimized | WindowState::Hidden => {
// 窗口存在但不可见或无焦点,激活它 logging!(
info,
Type::Window,
true,
"窗口存在但被隐藏或最小化,将激活窗口"
);
if let Some(window) = Self::get_main_window() { if let Some(window) = Self::get_main_window() {
Self::activate_window(&window) Self::activate_window(&window)
} else { } else {
logging!(warn, Type::Window, true, "无法获取窗口实例");
WindowOperationResult::Failed WindowOperationResult::Failed
} }
} }
@@ -216,17 +323,21 @@ impl WindowManager {
pub fn hide_main_window() -> WindowOperationResult { pub fn hide_main_window() -> WindowOperationResult {
logging!(info, Type::Window, true, "开始隐藏主窗口"); logging!(info, Type::Window, true, "开始隐藏主窗口");
if let Some(window) = Self::get_main_window() { match Self::get_main_window() {
if window.hide().is_ok() { Some(window) => match window.hide() {
logging!(info, Type::Window, true, "窗口已隐藏"); Ok(_) => {
WindowOperationResult::Hidden logging!(info, Type::Window, true, "窗口已隐藏");
} else { WindowOperationResult::Hidden
logging!(warn, Type::Window, true, "隐藏窗口失败"); }
WindowOperationResult::Failed Err(e) => {
logging!(warn, Type::Window, true, "隐藏窗口失败: {}", e);
WindowOperationResult::Failed
}
},
None => {
logging!(info, Type::Window, true, "窗口不存在,无需隐藏");
WindowOperationResult::NoAction
} }
} else {
logging!(info, Type::Window, true, "窗口不存在,无需隐藏");
WindowOperationResult::NoAction
} }
} }
@@ -251,7 +362,7 @@ impl WindowManager {
.unwrap_or(false) .unwrap_or(false)
} }
/// 创建新窗口现有的实现 /// 创建新窗口,防抖避免重复调用
fn create_new_window() -> bool { fn create_new_window() -> bool {
use crate::utils::resolve; use crate::utils::resolve;
resolve::create_window(true) resolve::create_window(true)
@@ -265,8 +376,7 @@ impl WindowManager {
let is_minimized = Self::is_main_window_minimized(); let is_minimized = Self::is_main_window_minimized();
format!( format!(
"窗口状态: {:?} | 可见: {} | 有焦点: {} | 最小化: {}", "窗口状态: {state:?} | 可见: {is_visible} | 有焦点: {is_focused} | 最小化: {is_minimized}"
state, is_visible, is_focused, is_minimized
) )
} }
} }

View File

@@ -1,9 +1,9 @@
{ {
"version": "2.3.1", "version": "0.2.0",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": { "bundle": {
"active": true, "active": true,
"longDescription": "Clash Verge Rev", "longDescription": "Clash Verge Rev Lite",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@@ -12,11 +12,11 @@
"icons/icon.ico" "icons/icon.ico"
], ],
"resources": ["resources", "resources/locales/*"], "resources": ["resources", "resources/locales/*"],
"publisher": "Clash Verge Rev", "publisher": "Clash Verge Rev Lite",
"externalBin": ["sidecar/verge-mihomo", "sidecar/verge-mihomo-alpha"], "externalBin": ["sidecar/verge-mihomo", "sidecar/verge-mihomo-alpha"],
"copyright": "GNU General Public License v3.0", "copyright": "GNU General Public License v3.0",
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Clash Verge Rev", "shortDescription": "Clash Verge Rev Lite",
"createUpdaterArtifacts": true "createUpdaterArtifacts": true
}, },
"build": { "build": {
@@ -25,18 +25,14 @@
"beforeDevCommand": "pnpm run web:dev", "beforeDevCommand": "pnpm run web:dev",
"devUrl": "http://localhost:3000/" "devUrl": "http://localhost:3000/"
}, },
"productName": "Clash Verge", "productName": "Clash Verge Rev Lite",
"identifier": "io.github.clash-verge-rev.clash-verge-rev", "identifier": "io.github.clash-verge-rev.clash-verge-rev",
"plugins": { "plugins": {
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERCQjQ1QjQ0QUJDQTU1RTkKUldUcFZjcXJSRnUwMjdXSERoZVQ1R0hHRDMrT3VkSmpvbDJmb01sN3ZpYWhVYnEwaWpYUWU4YU0K",
"endpoints": [ "endpoints": [
"https://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json", "https://github.com/coolcoala/clash-verge-rev-lite/releases/download/updater/update.json",
"https://gh-proxy.com/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json", "https://github.com/coolcoala/clash-verge-rev-lite/releases/download/updater-alpha/update-alpha.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/updater-alpha/update-alpha-proxy.json",
"https://gh-proxy.com/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater-alpha/update-alpha-proxy.json",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater-alpha/update-alpha.json"
], ],
"windows": { "windows": {
"installMode": "basicUi" "installMode": "basicUi"

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"identifier": "io.github.clash-verge-rev.clash-verge-rev", "identifier": "io.github.clash-verge-rev.clash-verge-rev",
"productName": "Clash Verge", "productName": "Clash Verge Rev Lite",
"bundle": { "bundle": {
"targets": ["app", "dmg"], "targets": ["app", "dmg"],
"macOS": { "macOS": {

View File

@@ -14,7 +14,7 @@
"nsis": { "nsis": {
"displayLanguageSelector": true, "displayLanguageSelector": true,
"installerIcon": "icons/icon.ico", "installerIcon": "icons/icon.ico",
"languages": ["SimpChinese", "English"], "languages": ["Russian", "English"],
"installMode": "perMachine", "installMode": "perMachine",
"template": "./packages/windows/installer.nsi" "template": "./packages/windows/installer.nsi"
} }

View File

@@ -14,7 +14,7 @@
"nsis": { "nsis": {
"displayLanguageSelector": true, "displayLanguageSelector": true,
"installerIcon": "icons/icon.ico", "installerIcon": "icons/icon.ico",
"languages": ["SimpChinese", "English"], "languages": ["Russian", "English"],
"installMode": "perMachine", "installMode": "perMachine",
"template": "./packages/windows/installer.nsi" "template": "./packages/windows/installer.nsi"
} }
@@ -25,10 +25,10 @@
"active": true, "active": true,
"dialog": false, "dialog": false,
"endpoints": [ "endpoints": [
"https://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", "https://github.com/coolcoala/clash-verge-rev-lite/releases/download/updater/update-fixed-webview2.json"
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json"
], ],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERCQjQ1QjQ0QUJDQTU1RTkKUldUcFZjcXJSRnUwMjdXSERoZVQ1R0hHRDMrT3VkSmpvbDJmb01sN3ZpYWhVYnEwaWpYUWU4YU0K"
} }
} }
} }

View File

@@ -14,7 +14,7 @@
"nsis": { "nsis": {
"displayLanguageSelector": true, "displayLanguageSelector": true,
"installerIcon": "icons/icon.ico", "installerIcon": "icons/icon.ico",
"languages": ["SimpChinese", "English"], "languages": ["Russian", "English"],
"installMode": "perMachine", "installMode": "perMachine",
"template": "./packages/windows/installer.nsi" "template": "./packages/windows/installer.nsi"
} }
@@ -25,10 +25,9 @@
"active": true, "active": true,
"dialog": false, "dialog": false,
"endpoints": [ "endpoints": [
"https://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", "https://github.com/coolcoala/clash-verge-rev-lite/releases/download/updater/update-fixed-webview2.json"
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json"
], ],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERCQjQ1QjQ0QUJDQTU1RTkKUldUcFZjcXJSRnUwMjdXSERoZVQ1R0hHRDMrT3VkSmpvbDJmb01sN3ZpYWhVYnEwaWpYUWU4YU0K"
} }
} }
} }

View File

@@ -14,7 +14,7 @@
"nsis": { "nsis": {
"displayLanguageSelector": true, "displayLanguageSelector": true,
"installerIcon": "icons/icon.ico", "installerIcon": "icons/icon.ico",
"languages": ["SimpChinese", "English"], "languages": ["Russian", "English"],
"installMode": "perMachine", "installMode": "perMachine",
"template": "./packages/windows/installer.nsi" "template": "./packages/windows/installer.nsi"
} }
@@ -25,10 +25,9 @@
"active": true, "active": true,
"dialog": false, "dialog": false,
"endpoints": [ "endpoints": [
"https://download.clashverge.dev/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", "https://github.com/coolcoala/clash-verge-rev-lite/releases/download/updater/update-fixed-webview2.json"
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json"
], ],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERCQjQ1QjQ0QUJDQTU1RTkKUldUcFZjcXJSRnUwMjdXSERoZVQ1R0hHRDMrT3VkSmpvbDJmb01sN3ZpYWhVYnEwaWpYUWU4YU0K"
} }
} }
} }

View File

@@ -1,12 +1,14 @@
import { AppDataProvider } from "./providers/app-data-provider"; import { AppDataProvider } from "./providers/app-data-provider";
import { ThemeProvider } from "@/components/layout/theme-provider";
import Layout from "./pages/_layout"; import Layout from "./pages/_layout";
function App() { function App() {
return ( return (
<AppDataProvider> <ThemeProvider>
<Layout /> <AppDataProvider>
</AppDataProvider> <Layout />
</AppDataProvider>
</ThemeProvider>
); );
} }
export default App; export default App;

View File

@@ -6,7 +6,8 @@ body {
margin: 0; margin: 0;
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif,
"twemoji mozilla";
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
user-select: none; user-select: none;

View File

@@ -1,68 +1,30 @@
import React, { useSyncExternalStore } from "react"; "use client";
import { Snackbar, Alert, IconButton, Box } from "@mui/material";
import { CloseRounded } from "@mui/icons-material"; import { Toaster, toast } from "sonner";
import { useEffect, useSyncExternalStore } from "react";
import { import {
subscribeNotices,
hideNotice,
getSnapshotNotices, getSnapshotNotices,
hideNotice,
subscribeNotices,
} from "@/services/noticeService"; } from "@/services/noticeService";
export const NoticeManager: React.FC = () => { export const NoticeManager = () => {
const currentNotices = useSyncExternalStore( const currentNotices = useSyncExternalStore(
subscribeNotices, subscribeNotices,
getSnapshotNotices, getSnapshotNotices,
); );
const handleClose = (id: number) => { useEffect(() => {
hideNotice(id); for (const notice of currentNotices) {
}; const toastId = toast(notice.message, {
id: notice.id,
duration: notice.duration,
onDismiss: (t) => {
hideNotice(t.id as number);
},
});
}
}, [currentNotices]);
return ( return <Toaster />;
<Box
sx={{
position: "fixed",
top: "20px",
right: "20px",
zIndex: 1500,
display: "flex",
flexDirection: "column",
gap: "10px",
maxWidth: "360px",
}}
>
{currentNotices.map((notice) => (
<Snackbar
key={notice.id}
open={true}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
sx={{
position: "relative",
transform: "none",
top: "auto",
right: "auto",
bottom: "auto",
left: "auto",
width: "100%",
}}
>
<Alert
severity={notice.type}
variant="filled"
sx={{ width: "100%" }}
action={
<IconButton
size="small"
color="inherit"
onClick={() => handleClose(notice.id)}
>
<CloseRounded fontSize="inherit" />
</IconButton>
}
>
{notice.message}
</Alert>
</Snackbar>
))}
</Box>
);
}; };

View File

@@ -1,15 +1,18 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import { useTranslation } from "react-i18next";
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
type SxProps,
type Theme,
} from "@mui/material";
import { LoadingButton } from "@mui/lab";
// --- Новые импорты ---
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; // Иконка для спиннера
// --- Интерфейсы ---
interface Props { interface Props {
title: ReactNode; title: ReactNode;
open: boolean; open: boolean;
@@ -18,12 +21,12 @@ interface Props {
disableOk?: boolean; disableOk?: boolean;
disableCancel?: boolean; disableCancel?: boolean;
disableFooter?: boolean; disableFooter?: boolean;
contentSx?: SxProps<Theme>; className?: string; // Замена для contentSx, чтобы передавать классы Tailwind
children?: ReactNode; children?: ReactNode;
loading?: boolean; loading?: boolean;
onOk?: () => void; onOk?: () => void;
onCancel?: () => void; onCancel?: () => void;
onClose?: () => void; onClose?: () => void; // onOpenChange в shadcn/ui делает то же самое
} }
export interface DialogRef { export interface DialogRef {
@@ -38,37 +41,44 @@ export const BaseDialog: React.FC<Props> = (props) => {
children, children,
okBtn, okBtn,
cancelBtn, cancelBtn,
contentSx, className,
disableCancel, disableCancel,
disableOk, disableOk,
disableFooter, disableFooter,
loading, loading,
onClose,
onCancel,
onOk,
} = props; } = props;
const { t } = useTranslation();
return ( return (
<Dialog open={open} onClose={props.onClose}> // Управляем состоянием через onOpenChange, которое вызывает onClose
<DialogTitle>{title}</DialogTitle> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose?.()}>
<DialogContent className={className}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DialogContent sx={contentSx}>{children}</DialogContent> {children}
{!disableFooter && ( {!disableFooter && (
<DialogActions> <DialogFooter>
{!disableCancel && ( {!disableCancel && (
<Button variant="outlined" onClick={props.onCancel}> <Button variant="outline" onClick={onCancel} disabled={loading}>
{cancelBtn} {cancelBtn || t("Cancel")}
</Button> </Button>
)} )}
{!disableOk && ( {!disableOk && (
<LoadingButton <Button disabled={loading || disableOk} onClick={onOk}>
loading={loading} {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
variant="contained" {okBtn || t("Confirm")}
onClick={props.onOk} </Button>
> )}
{okBtn} </DialogFooter>
</LoadingButton> )}
)} </DialogContent>
</DialogActions>
)}
</Dialog> </Dialog>
); );
}; };

View File

@@ -1,10 +1,10 @@
import { alpha, Box, Typography } from "@mui/material"; import { ReactNode } from "react";
import { InboxRounded } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react"; // 1. Импортируем иконку из lucide-react
interface Props { interface Props {
text?: React.ReactNode; text?: ReactNode;
extra?: React.ReactNode; extra?: ReactNode;
} }
export const BaseEmpty = (props: Props) => { export const BaseEmpty = (props: Props) => {
@@ -12,20 +12,15 @@ export const BaseEmpty = (props: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box // 2. Заменяем Box на div и переводим sx в классы Tailwind
sx={({ palette }) => ({ <div className="flex h-full w-full flex-col items-center justify-center space-y-4 text-muted-foreground/75">
width: "100%", {/* 3. Заменяем иконку MUI на lucide-react и задаем размер классами */}
height: "100%", <Inbox className="h-20 w-20" />
display: "flex",
flexDirection: "column", {/* 4. Заменяем Typography на p */}
alignItems: "center", <p className="text-xl">{t(`${text}`)}</p>
justifyContent: "center",
color: alpha(palette.text.secondary, 0.75),
})}
>
<InboxRounded sx={{ fontSize: "4em" }} />
<Typography sx={{ fontSize: "1.25em" }}>{t(`${text}`)}</Typography>
{extra} {extra}
</Box> </div>
); );
}; };

View File

@@ -1,16 +1,33 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary"; import { ErrorBoundary, FallbackProps } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react"; // Импортируем иконку
// Новый, стилизованный компонент для отображения ошибки
function ErrorFallback({ error }: FallbackProps) { function ErrorFallback({ error }: FallbackProps) {
const { t } = useTranslation();
return ( return (
<div role="alert" style={{ padding: 16 }}> <div
<h4>Something went wrong:(</h4> role="alert"
className="m-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive"
>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
<h3 className="font-semibold">{t("Something went wrong")}</h3>
</div>
<pre>{error.message}</pre> <pre className="mt-2 whitespace-pre-wrap rounded-md bg-destructive/10 p-2 text-xs font-mono">
{error.message}
</pre>
<details title="Error Stack"> <details className="mt-4">
<summary>Error Stack</summary> <summary className="cursor-pointer text-xs font-medium hover:underline">
<pre>{error.stack}</pre> {t("Error Stack")}
</summary>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-2 text-xs font-mono text-muted-foreground">
{error.stack}
</pre>
</details> </details>
</div> </div>
); );

View File

@@ -1,38 +1,30 @@
import React from "react"; import React from "react";
import { Box, styled } from "@mui/material"; import { cn } from "@root/lib/utils"; // Импортируем утилиту для объединения классов
type Props = { type Props = {
label: string; label: string;
fontSize?: string;
width?: string;
padding?: string;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; // Пропс для дополнительной стилизации
}; };
export const BaseFieldset: React.FC<Props> = (props: Props) => { export const BaseFieldset: React.FC<Props> = (props) => {
const Fieldset = styled(Box)<{ component?: string }>(() => ({ const { label, children, className } = props;
position: "relative",
border: "1px solid #bbb",
borderRadius: "5px",
width: props.width ?? "auto",
padding: props.padding ?? "15px",
}));
const Label = styled("legend")(({ theme }) => ({
position: "absolute",
top: "-10px",
left: props.padding ?? "15px",
backgroundColor: theme.palette.background.paper,
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
color: theme.palette.text.primary,
fontSize: props.fontSize ?? "1em",
}));
return ( return (
<Fieldset component="fieldset"> // 1. Используем тег fieldset для семантики. Он позиционирован как relative.
<Label>{props.label}</Label> <fieldset
{props.children} className={cn(
</Fieldset> "relative rounded-md border border-border p-4", // Базовые стили
className, // Дополнительные классы от пользователя
)}
>
{/* 2. Используем legend. Он абсолютно спозиционирован относительно fieldset. */}
<legend className="absolute -top-2.5 left-3 bg-background px-1 text-sm text-muted-foreground">
{label}
</legend>
{/* 3. Здесь будет содержимое филдсета */}
{children}
</fieldset>
); );
}; };

View File

@@ -1,32 +1,29 @@
import React from "react"; import React from "react";
import { Box, CircularProgress } from "@mui/material"; import { BaseLoading } from "./base-loading"; // 1. Импортируем наш собственный компонент загрузки
import { cn } from "@root/lib/utils";
export interface BaseLoadingOverlayProps { export interface BaseLoadingOverlayProps {
isLoading: boolean; isLoading: boolean;
className?: string;
} }
export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({ export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({
isLoading, isLoading,
className,
}) => { }) => {
if (!isLoading) return null; if (!isLoading) return null;
return ( return (
<Box // 2. Заменяем Box на div и переводим sx в классы Tailwind
sx={{ <div
position: "absolute", className={cn(
top: 0, "absolute inset-0 z-50 flex items-center justify-center bg-background/70 backdrop-blur-sm",
left: 0, className,
right: 0, )}
bottom: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(255, 255, 255, 0.7)",
zIndex: 1000,
}}
> >
<CircularProgress /> {/* 3. Используем наш BaseLoading и делаем его немного больше */}
</Box> <BaseLoading className="h-8 w-8 text-primary" />
</div>
); );
}; };

View File

@@ -1,48 +1,14 @@
import { styled } from "@mui/material"; import { Loader2 } from "lucide-react"; // 1. Импортируем стандартную иконку загрузки
import { cn } from "@root/lib/utils"; // Утилита для объединения классов
const Loading = styled("div")` interface Props {
position: relative; className?: string;
display: flex; }
height: 100%;
min-height: 18px;
box-sizing: border-box;
align-items: center;
& > div { export const BaseLoading: React.FC<Props> = ({ className }) => {
box-sizing: border-box;
width: 6px;
height: 6px;
margin: 2px;
border-radius: 100%;
animation: loading 0.7s -0.15s infinite linear;
}
& > div:nth-child(2n-1) {
animation-delay: -0.5s;
}
@keyframes loading {
50% {
opacity: 0.2;
transform: scale(0.75);
}
100% {
opacity: 1;
transform: scale(1);
}
}
`;
const LoadingItem = styled("div")(({ theme }) => ({
background: theme.palette.text.secondary,
}));
export const BaseLoading = () => {
return ( return (
<Loading> // 2. Используем иконку с анимацией вращения от Tailwind
<LoadingItem /> // Мы можем легко менять ее размер и цвет через className
<LoadingItem /> <Loader2 className={cn("h-5 w-5 animate-spin", className)} />
<LoadingItem />
</Loading>
); );
}; };

View File

@@ -1,50 +1,36 @@
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Typography } from "@mui/material";
import { BaseErrorBoundary } from "./base-error-boundary"; import { BaseErrorBoundary } from "./base-error-boundary";
import { useTheme } from "@mui/material/styles"; import { cn } from "@root/lib/utils";
interface Props { interface Props {
title?: React.ReactNode; // the page title title?: ReactNode; // Заголовок страницы
header?: React.ReactNode; // something behind title header?: ReactNode; // Элементы в правой части шапки (кнопки и т.д.)
contentStyle?: React.CSSProperties; children?: ReactNode; // Основное содержимое страницы
children?: ReactNode; className?: string; // Дополнительные классы для основной области контента
full?: boolean;
} }
export const BasePage: React.FC<Props> = (props) => { export const BasePage: React.FC<Props> = (props) => {
const { title, header, contentStyle, full, children } = props; const { title, header, children, className } = props;
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
return ( return (
<BaseErrorBoundary> <BaseErrorBoundary>
<div className="base-page"> {/* 1. Корневой контейнер: flex-колонка на всю высоту */}
<header data-tauri-drag-region="true" style={{ userSelect: "none" }}> <div className="h-full flex flex-col bg-background text-foreground">
<Typography {/* 2. Шапка: не растягивается, имеет фиксированную высоту и нижнюю границу */}
sx={{ fontSize: "20px", fontWeight: "700 " }} <header
data-tauri-drag-region="true" data-tauri-drag-region="true"
> className="flex-shrink-0 flex items-center justify-between h-16 px-4 border-b border-border"
>
<h2 className="text-xl font-bold" data-tauri-drag-region="true">
{title} {title}
</Typography> </h2>
<div data-tauri-drag-region="true">{header}</div>
{header}
</header> </header>
<div {/* 3. Основная область: занимает все оставшееся место и прокручивается */}
className={full ? "base-container no-padding" : "base-container"} <main className={cn("flex-1 overflow-y-auto min-h-0", className)}>
style={{ backgroundColor: isDark ? "#1e1f27" : "#ffffff" }} {children}
> </main>
<section
style={{
backgroundColor: isDark ? "#1e1f27" : "var(--background-color)",
}}
>
<div className="base-content" style={contentStyle}>
{children}
</div>
</section>
</div>
</div> </div>
</BaseErrorBoundary> </BaseErrorBoundary>
); );

View File

@@ -1,11 +1,17 @@
import { Box, SvgIcon, TextField, styled } from "@mui/material"; import { ChangeEvent, useEffect, useMemo, useState } from "react";
import Tooltip from "@mui/material/Tooltip";
import { ChangeEvent, useEffect, useRef, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; import { cn } from "@root/lib/utils";
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react";
import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react"; // Новые импорты
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CaseSensitive, WholeWord, Regex } from "lucide-react"; // Иконки из lucide-react
export type SearchState = { export type SearchState = {
text: string; text: string;
@@ -16,87 +22,55 @@ export type SearchState = {
type SearchProps = { type SearchProps = {
placeholder?: string; placeholder?: string;
matchCase?: boolean;
matchWholeWord?: boolean;
useRegularExpression?: boolean;
onSearch: (match: (content: string) => boolean, state: SearchState) => void; onSearch: (match: (content: string) => boolean, state: SearchState) => void;
}; };
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) => { export const BaseSearchBox = (props: SearchProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null); const [text, setText] = useState("");
const [matchCase, setMatchCase] = useState(props.matchCase ?? false); const [matchCase, setMatchCase] = useState(false);
const [matchWholeWord, setMatchWholeWord] = useState( const [matchWholeWord, setMatchWholeWord] = useState(false);
props.matchWholeWord ?? false, const [useRegularExpression, setUseRegularExpression] = useState(false);
);
const [useRegularExpression, setUseRegularExpression] = useState(
props.useRegularExpression ?? false,
);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const iconStyle = {
style: {
height: "24px",
width: "24px",
cursor: "pointer",
} as React.CSSProperties,
inheritViewBox: true,
};
const createMatcher = useMemo(() => { const createMatcher = useMemo(() => {
return (searchText: string) => { return (searchText: string) => {
try { try {
setErrorMessage(""); // Сбрасываем ошибку при новой попытке
return (content: string) => { return (content: string) => {
if (!searchText) return true; if (!searchText) return true;
const flags = matchCase ? "" : "i";
let item = !matchCase ? content.toLowerCase() : content;
let searchItem = !matchCase ? searchText.toLowerCase() : searchText;
if (useRegularExpression) { if (useRegularExpression) {
return new RegExp(searchItem).test(item); return new RegExp(searchText, flags).test(content);
} }
let pattern = searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); // Экранируем спецсимволы
if (matchWholeWord) { if (matchWholeWord) {
return new RegExp(`\\b${searchItem}\\b`).test(item); pattern = `\\b${pattern}\\b`;
} }
return item.includes(searchItem); return new RegExp(pattern, flags).test(content);
}; };
} catch (err) { } catch (err: any) {
setErrorMessage(`${err}`); setErrorMessage(err.message);
return () => true; return () => true; // Возвращаем "безопасный" матчер в случае ошибки
} }
}; };
}, [matchCase, matchWholeWord, useRegularExpression]); }, [matchCase, matchWholeWord, useRegularExpression]);
useEffect(() => { useEffect(() => {
if (!inputRef.current) return; props.onSearch(createMatcher(text), {
const value = inputRef.current.value; text,
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase, matchCase,
matchWholeWord, matchWholeWord,
useRegularExpression, useRegularExpression,
}); });
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]); }, [matchCase, matchWholeWord, useRegularExpression, createMatcher]); // Убрали text из зависимостей
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target?.value ?? ""; const value = e.target.value;
setErrorMessage(""); setText(value);
props.onSearch(createMatcher(value), { props.onSearch(createMatcher(value), {
text: value, text: value,
matchCase, matchCase,
@@ -105,61 +79,74 @@ export const BaseSearchBox = (props: SearchProps) => {
}); });
}; };
const getToggleVariant = (isActive: boolean) =>
isActive ? "secondary" : "ghost";
return ( return (
<Tooltip title={errorMessage} placement="bottom-start"> <div className="w-full">
<StyledTextField <div className="relative">
autoComplete="new-password" {/* Добавляем правый отступ, чтобы текст не заезжал под иконки */}
inputRef={inputRef} <Input
hiddenLabel placeholder={props.placeholder ?? t("Filter conditions")}
fullWidth value={text}
size="small" onChange={handleChange}
variant="outlined" className="pr-28" // pr-[112px]
spellCheck="false" />
placeholder={props.placeholder ?? t("Filter conditions")} {/* Контейнер для иконок, абсолютно спозиционированный справа */}
sx={{ input: { py: 0.65, px: 1.25 } }} <div className="absolute inset-y-0 right-0 flex items-center pr-2">
onChange={onChange} <TooltipProvider delayDuration={100}>
slotProps={{ <Tooltip>
input: { <TooltipTrigger asChild>
sx: { pr: 1 }, <Button
endAdornment: ( variant={getToggleVariant(matchCase)}
<Box display="flex"> size="icon"
<Tooltip title={t("Match Case")}> className="h-7 w-7"
<div> onClick={() => setMatchCase(!matchCase)}
<SvgIcon >
component={matchCaseIcon} <CaseSensitive className="h-4 w-4" />
{...iconStyle} </Button>
aria-label={matchCase ? "active" : "inactive"} </TooltipTrigger>
onClick={() => setMatchCase(!matchCase)} <TooltipContent>
/> <p>{t("Match Case")}</p>
</div> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip title={t("Match Whole Word")}> <Tooltip>
<div> <TooltipTrigger asChild>
<SvgIcon <Button
component={matchWholeWordIcon} variant={getToggleVariant(matchWholeWord)}
{...iconStyle} size="icon"
aria-label={matchWholeWord ? "active" : "inactive"} className="h-7 w-7"
onClick={() => setMatchWholeWord(!matchWholeWord)} onClick={() => setMatchWholeWord(!matchWholeWord)}
/> >
</div> <WholeWord className="h-4 w-4" />
</Tooltip> </Button>
<Tooltip title={t("Use Regular Expression")}> </TooltipTrigger>
<div> <TooltipContent>
<SvgIcon <p>{t("Match Whole Word")}</p>
component={useRegularExpressionIcon} </TooltipContent>
aria-label={useRegularExpression ? "active" : "inactive"} </Tooltip>
{...iconStyle} <Tooltip>
onClick={() => <TooltipTrigger asChild>
setUseRegularExpression(!useRegularExpression) <Button
} variant={getToggleVariant(useRegularExpression)}
/>{" "} size="icon"
</div> className="h-7 w-7"
</Tooltip> onClick={() => setUseRegularExpression(!useRegularExpression)}
</Box> >
), <Regex className="h-4 w-4" />
}, </Button>
}} </TooltipTrigger>
/> <TooltipContent>
</Tooltip> <p>{t("Use Regular Expression")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Отображение ошибки под полем ввода */}
{errorMessage && (
<p className="mt-1 text-xs text-destructive">{errorMessage}</p>
)}
</div>
); );
}; };

View File

@@ -1,19 +1,37 @@
import { Select, SelectProps, styled } from "@mui/material"; import * as React from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@root/lib/utils";
// Определяем новые пропсы для нашего компонента
export interface BaseStyledSelectProps {
children: React.ReactNode; // Сюда будут передаваться <SelectItem>
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
className?: string; // для дополнительной стилизации
}
export const BaseStyledSelect: React.FC<BaseStyledSelectProps> = (props) => {
const { value, onValueChange, placeholder, children, className } = props;
export const BaseStyledSelect = styled((props: SelectProps<string>) => {
return ( return (
<Select // Используем композицию компонентов Select из shadcn/ui
size="small" <Select value={value} onValueChange={onValueChange}>
autoComplete="new-password" <SelectTrigger
sx={{ className={cn(
width: 120, "h-9 w-[180px]", // Задаем стандартные размеры, как у других селектов
height: 33.375, className,
mr: 1, )}
'[role="button"]': { py: 0.65 }, >
}} <SelectValue placeholder={placeholder} />
{...props} </SelectTrigger>
/> <SelectContent>{children}</SelectContent>
</Select>
); );
})(({ theme }) => ({ };
background: theme.palette.mode === "light" ? "#fff" : undefined,
}));

View File

@@ -1,24 +1,32 @@
import { TextField, type TextFieldProps, styled } from "@mui/material"; import * as React from "react"; // 1. Убедимся, что React импортирован
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@root/lib/utils";
import { Input } from "@/components/ui/input"; // 2. Убираем импорт несуществующего типа InputProps
export const BaseStyledTextField = styled((props: TextFieldProps) => { // 3. Определяем наши пропсы, расширяя стандартный тип для input-элементов из React
export interface BaseStyledTextFieldProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export const BaseStyledTextField = React.forwardRef<
HTMLInputElement,
BaseStyledTextFieldProps // Используем наш правильный тип
>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { className, ...restProps } = props;
return ( return (
<TextField <Input
autoComplete="new-password" ref={ref}
hiddenLabel className={cn(
fullWidth "h-9", // Задаем стандартную компактную высоту
size="small" className,
variant="outlined" )}
placeholder={props.placeholder ?? t("Filter conditions")}
autoComplete="off"
spellCheck="false" spellCheck="false"
placeholder={t("Filter conditions")} {...restProps}
sx={{ input: { py: 0.65, px: 1.25 } }}
{...props}
/> />
); );
})(({ theme }) => ({ });
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined, BaseStyledTextField.displayName = "BaseStyledTextField";
},
}));

View File

@@ -1,58 +1,16 @@
import { styled } from "@mui/material/styles"; import * as React from "react";
import { default as MuiSwitch, SwitchProps } from "@mui/material/Switch"; import { Switch as ShadcnSwitch } from "@/components/ui/switch";
import { cn } from "@root/lib/utils";
export const Switch = styled((props: SwitchProps) => ( // Тип пропсов остается без изменений
<MuiSwitch export type SwitchProps = React.ComponentPropsWithoutRef<typeof ShadcnSwitch>;
focusVisibleClassName=".Mui-focusVisible"
disableRipple const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
{...props} ({ className, ...props }, ref) => {
/> return <ShadcnSwitch className={cn(className)} ref={ref} {...props} />;
))(({ theme }) => ({
width: 42,
height: 26,
padding: 0,
marginRight: 1,
"& .MuiSwitch-switchBase": {
padding: 0,
margin: 2,
transitionDuration: "300ms",
"&.Mui-checked": {
transform: "translateX(16px)",
color: "#fff",
"& + .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
border: 0,
},
"&.Mui-disabled + .MuiSwitch-track": {
opacity: 0.5,
},
},
"&.Mui-focusVisible .MuiSwitch-thumb": {
color: "#33cf4d",
border: "6px solid #fff",
},
"&.Mui-disabled .MuiSwitch-thumb": {
color:
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[600],
},
"&.Mui-disabled + .MuiSwitch-track": {
opacity: theme.palette.mode === "light" ? 0.7 : 0.3,
},
}, },
"& .MuiSwitch-thumb": { );
boxSizing: "border-box",
width: 22, Switch.displayName = "Switch";
height: 22,
}, export { Switch };
"& .MuiSwitch-track": {
borderRadius: 26 / 2,
backgroundColor: theme.palette.mode === "light" ? "#BBBBBB" : "#39393D",
opacity: 1,
transition: theme.transitions.create(["background-color"], {
duration: 500,
}),
},
}));

View File

@@ -1,24 +1,52 @@
import * as React from "react";
import { cn } from "@root/lib/utils";
// 1. Убираем импорт несуществующего типа ButtonProps
import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
IconButton, TooltipContent,
IconButtonProps, TooltipProvider,
SvgIconProps, TooltipTrigger,
} from "@mui/material"; } from "@/components/ui/tooltip";
import { InfoRounded } from "@mui/icons-material"; import { Info } from "lucide-react";
interface Props extends IconButtonProps { // 2. Определяем наши пропсы, расширяя стандартный тип для кнопок из React
title?: string; export interface TooltipIconProps
icon?: React.ElementType<SvgIconProps>; extends React.ButtonHTMLAttributes<HTMLButtonElement> {
tooltip: React.ReactNode;
icon?: React.ReactNode;
} }
export const TooltipIcon: React.FC<Props> = (props: Props) => { export const TooltipIcon = React.forwardRef<
const { title = "", icon: Icon = InfoRounded, ...restProps } = props; HTMLButtonElement,
TooltipIconProps
>(({ tooltip, icon, className, ...props }, ref) => {
const displayIcon = icon || <Info className="h-4 w-4" />;
return ( return (
<Tooltip title={title} placement="top"> <TooltipProvider>
<IconButton color="inherit" size="small" {...restProps}> <Tooltip>
<Icon fontSize="inherit" style={{ cursor: "pointer", opacity: 0.75 }} /> <TooltipTrigger asChild>
</IconButton> <Button
</Tooltip> ref={ref}
variant="ghost"
size="icon"
className={cn("h-7 w-7 text-muted-foreground", className)}
{...props}
>
{displayIcon}
<span className="sr-only">
{typeof tooltip === "string" ? tooltip : "Icon button"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
); );
}; });
TooltipIcon.displayName = "TooltipIcon";

View File

@@ -1,10 +1,16 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { Box, Button, Snackbar, useTheme } from "@mui/material"; import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { deleteConnection } from "@/services/api"; import { deleteConnection } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next"; import { t } from "i18next";
import { Button } from "@/components/ui/button";
export interface ConnectionDetailRef { export interface ConnectionDetailRef {
open: (detail: IConnectionsItem) => void; open: (detail: IConnectionsItem) => void;
@@ -14,38 +20,37 @@ export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
(props, ref) => { (props, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [detail, setDetail] = useState<IConnectionsItem>(null!); const [detail, setDetail] = useState<IConnectionsItem>(null!);
const theme = useTheme();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: (detail: IConnectionsItem) => { open: (detail: IConnectionsItem) => {
if (open) return;
setOpen(true);
setDetail(detail); setDetail(detail);
setOpen(true);
}, },
})); }));
const onClose = () => setOpen(false); const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
};
if (!detail) return null;
return ( return (
<Snackbar <Sheet open={open} onOpenChange={handleOpenChange}>
anchorOrigin={{ vertical: "bottom", horizontal: "right" }} <SheetContent
open={open} side="right"
onClose={onClose} className="w-full max-w-[520px] max-h-[100vh] sm:max-h-[calc(100vh-2rem)] overflow-y-auto p-0 flex flex-col"
sx={{ >
".MuiSnackbarContent-root": { <SheetHeader className="p-6 pb-4">
maxWidth: "520px", <SheetTitle>{t("Connection Details")}</SheetTitle>
maxHeight: "480px", </SheetHeader>
overflowY: "auto", <div className="flex-grow overflow-y-auto p-6 pt-0">
backgroundColor: theme.palette.background.paper, <InnerConnectionDetail
color: theme.palette.text.primary, data={detail}
}, onClose={() => setOpen(false)}
}} />
message={ </div>
detail ? ( </SheetContent>
<InnerConnectionDetail data={detail} onClose={onClose} /> </Sheet>
) : null
}
/>
); );
}, },
); );
@@ -57,7 +62,6 @@ interface InnerProps {
const InnerConnectionDetail = ({ data, onClose }: InnerProps) => { const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const { metadata, rulePayload } = data; const { metadata, rulePayload } = data;
const theme = useTheme();
const chains = [...data.chains].reverse().join(" / "); const chains = [...data.chains].reverse().join(" / ");
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const host = metadata.host const host = metadata.host
@@ -103,24 +107,16 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const onDelete = useLockFn(async () => deleteConnection(data.id)); const onDelete = useLockFn(async () => deleteConnection(data.id));
return ( return (
<Box sx={{ userSelect: "text", color: theme.palette.text.secondary }}> <div className="select-text text-muted-foreground">
{information.map((each) => ( {information.map((each) => (
<div key={each.label}> <div key={each.label} className="mb-1">
<b>{each.label}</b> <b className="text-foreground">{each.label}</b>
<span <span className="break-all text-foreground">: {each.value}</span>
style={{
wordBreak: "break-all",
color: theme.palette.text.primary,
}}
>
: {each.value}
</span>
</div> </div>
))} ))}
<Box sx={{ textAlign: "right" }}> <div className="text-right mt-4">
<Button <Button
variant="contained"
title={t("Close Connection")} title={t("Close Connection")}
onClick={() => { onClick={() => {
onDelete(); onDelete();
@@ -129,7 +125,7 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
> >
{t("Close Connection")} {t("Close Connection")}
</Button> </Button>
</Box> </div>
</Box> </div>
); );
}; };

View File

@@ -1,27 +1,22 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { import { X } from "lucide-react";
styled, import { Button } from "@/components/ui/button";
ListItem,
IconButton,
ListItemText,
Box,
alpha,
} from "@mui/material";
import { CloseRounded } from "@mui/icons-material";
import { deleteConnection } from "@/services/api"; import { deleteConnection } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
const Tag = styled("span")(({ theme }) => ({ interface TagProps {
fontSize: "10px", children: React.ReactNode;
padding: "0 4px", className?: string;
lineHeight: 1.375, }
border: "1px solid",
borderRadius: 4, const Tag: React.FC<TagProps> = ({ children, className }) => {
borderColor: alpha(theme.palette.text.secondary, 0.35), const baseClasses =
marginTop: "4px", "text-[10px] px-1 leading-[1.375] border rounded-[4px] border-muted-foreground/35";
marginRight: "4px", return (
})); <span className={`${baseClasses} ${className || ""}`}>{children}</span>
);
};
interface Props { interface Props {
value: IConnectionsItem; value: IConnectionsItem;
@@ -37,43 +32,42 @@ export const ConnectionItem = (props: Props) => {
const showTraffic = curUpload! >= 100 || curDownload! >= 100; const showTraffic = curUpload! >= 100 || curDownload! >= 100;
return ( return (
<ListItem <div className="flex items-center justify-between p-3 border-b border-border dark:border-border">
dense <div
sx={{ borderBottom: "1px solid var(--divider-color)" }} className="flex-grow select-text cursor-pointer mr-2"
secondaryAction={
<IconButton edge="end" color="inherit" onClick={onDelete}>
<CloseRounded />
</IconButton>
}
>
<ListItemText
sx={{ userSelect: "text", cursor: "pointer" }}
primary={metadata.host || metadata.destinationIP}
onClick={onShowDetail} onClick={onShowDetail}
secondary={ >
<Box sx={{ display: "flex", flexWrap: "wrap" }}> <div className="text-sm font-medium text-foreground">
<Tag sx={{ textTransform: "uppercase", color: "success" }}> {metadata.host || metadata.destinationIP}
{metadata.network} </div>
<div className="flex flex-wrap gap-1 mt-1">
<Tag className="uppercase text-green-600 dark:text-green-500">
{metadata.network}
</Tag>
<Tag>{metadata.type}</Tag>
{!!metadata.process && <Tag>{metadata.process}</Tag>}
{chains?.length > 0 && <Tag>{[...chains].reverse().join(" / ")}</Tag>}
<Tag>{dayjs(start).fromNow()}</Tag>
{showTraffic && (
<Tag>
{parseTraffic(curUpload!)} / {parseTraffic(curDownload!)}
</Tag> </Tag>
)}
<Tag>{metadata.type}</Tag> </div>
</div>
{!!metadata.process && <Tag>{metadata.process}</Tag>} <Button
variant="ghost"
{chains?.length > 0 && ( size="icon"
<Tag>{[...chains].reverse().join(" / ")}</Tag> onClick={onDelete}
)} className="ml-2 flex-shrink-0"
>
<Tag>{dayjs(start).fromNow()}</Tag> <X className="h-4 w-4" />
</Button>
{showTraffic && ( </div>
<Tag>
{parseTraffic(curUpload!)} / {parseTraffic(curDownload!)}
</Tag>
)}
</Box>
}
/>
</ListItem>
); );
}; };

View File

@@ -1,139 +1,77 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMemo, useState, useEffect } from "react"; import relativeTime from "dayjs/plugin/relativeTime";
import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid"; import React, { useMemo, useState, useEffect, RefObject } from "react";
import { useThemeMode } from "@/services/states"; import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
Row,
ColumnSizingState,
} from "@tanstack/react-table";
import { TableVirtuoso, TableComponents } from "react-virtuoso";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { truncateStr } from "@/utils/truncate-str"; import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next"; import { t } from "i18next";
import { cn } from "@root/lib/utils";
dayjs.extend(relativeTime);
// Интерфейс для строки данных, которую использует react-table
interface ConnectionRow {
id: string;
host: string;
download: number;
upload: number;
dlSpeed: number;
ulSpeed: number;
chains: string;
rule: string;
process: string;
time: string;
source: string;
remoteDestination: string;
type: string;
connectionData: IConnectionsItem;
}
// Интерфейс для пропсов, которые компонент получает от родителя
interface Props { interface Props {
connections: IConnectionsItem[]; connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void; onShowDetail: (data: IConnectionsItem) => void;
scrollerRef: (element: HTMLElement | Window | null) => void;
} }
export const ConnectionTable = (props: Props) => { export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail } = props; const { connections, onShowDetail, scrollerRef } = props;
const mode = useThemeMode();
const isDark = mode === "light" ? false : true;
const backgroundColor = isDark ? "#282A36" : "#ffffff";
const [columnVisible, setColumnVisible] = useState< const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
Partial<Record<keyof IConnectionsItem, boolean>> try {
>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
() => {
const saved = localStorage.getItem("connection-table-widths"); const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {}; return saved ? JSON.parse(saved) : {};
}, } catch {
); return {};
}
const [columns] = useState<GridColDef[]>([ });
{
field: "host",
headerName: t("Host"),
width: columnWidths["host"] || 220,
minWidth: 180,
},
{
field: "download",
headerName: t("Downloaded"),
width: columnWidths["download"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "),
},
{
field: "upload",
headerName: t("Uploaded"),
width: columnWidths["upload"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "),
},
{
field: "dlSpeed",
headerName: t("DL Speed"),
width: columnWidths["dlSpeed"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
},
{
field: "ulSpeed",
headerName: t("UL Speed"),
width: columnWidths["ulSpeed"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
},
{
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"),
width: columnWidths["time"] || 120,
minWidth: 100,
align: "right",
headerAlign: "right",
sortComparator: (v1: string, v2: string) =>
new Date(v2).getTime() - new Date(v1).getTime(),
valueFormatter: (value: number) => dayjs(value).fromNow(),
},
{
field: "source",
headerName: t("Source"),
width: columnWidths["source"] || 200,
minWidth: 130,
},
{
field: "remoteDestination",
headerName: t("Destination"),
width: columnWidths["remoteDestination"] || 200,
minWidth: 130,
},
{
field: "type",
headerName: t("Type"),
width: columnWidths["type"] || 160,
minWidth: 100,
},
]);
useEffect(() => { useEffect(() => {
console.log("Saving column widths:", columnWidths);
localStorage.setItem( localStorage.setItem(
"connection-table-widths", "connection-table-widths",
JSON.stringify(columnWidths), JSON.stringify(columnSizing),
); );
}, [columnWidths]); }, [columnSizing]);
const handleColumnResize = (params: GridColumnResizeParams) => { const connRows = useMemo((): ConnectionRow[] => {
const { colDef, width } = params;
console.log("Column resize:", colDef.field, width);
setColumnWidths((prev) => ({
...prev,
[colDef.field]: width,
}));
};
const connRows = useMemo(() => {
return connections.map((each) => { return connections.map((each) => {
const { metadata, rulePayload } = each; const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / "); const chains = [...each.chains].reverse().join(" / ");
@@ -148,11 +86,11 @@ export const ConnectionTable = (props: Props) => {
: `${metadata.remoteDestination}:${metadata.destinationPort}`, : `${metadata.remoteDestination}:${metadata.destinationPort}`,
download: each.download, download: each.download,
upload: each.upload, upload: each.upload,
dlSpeed: each.curDownload, dlSpeed: each.curDownload ?? 0,
ulSpeed: each.curUpload, ulSpeed: each.curUpload ?? 0,
chains, chains,
rule, rule,
process: truncateStr(metadata.process || metadata.processPath), process: truncateStr(metadata.process || metadata.processPath) ?? "",
time: each.start, time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`, source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: Destination, remoteDestination: Destination,
@@ -162,24 +100,198 @@ export const ConnectionTable = (props: Props) => {
}); });
}, [connections]); }, [connections]);
const columns = useMemo<ColumnDef<ConnectionRow>[]>(
() => [
{
accessorKey: "host",
header: () => t("Host"),
size: columnSizing?.host || 220,
minSize: 180,
},
{
accessorKey: "download",
header: () => t("Downloaded"),
size: columnSizing?.download || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
},
{
accessorKey: "upload",
header: () => t("Uploaded"),
size: columnSizing?.upload || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
},
{
accessorKey: "dlSpeed",
header: () => t("DL Speed"),
size: columnSizing?.dlSpeed || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
},
{
accessorKey: "ulSpeed",
header: () => t("UL Speed"),
size: columnSizing?.ulSpeed || 88,
cell: ({ getValue }) => (
<div className="text-right">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
},
{
accessorKey: "chains",
header: () => t("Chains"),
size: columnSizing?.chains || 340,
minSize: 180,
},
{
accessorKey: "rule",
header: () => t("Rule"),
size: columnSizing?.rule || 280,
minSize: 180,
},
{
accessorKey: "process",
header: () => t("Process"),
size: columnSizing?.process || 220,
minSize: 180,
},
{
accessorKey: "time",
header: () => t("Time"),
size: columnSizing?.time || 120,
minSize: 100,
cell: ({ getValue }) => (
<div className="text-right">
{dayjs(getValue<string>()).fromNow()}
</div>
),
},
{
accessorKey: "source",
header: () => t("Source"),
size: columnSizing?.source || 200,
minSize: 130,
},
{
accessorKey: "remoteDestination",
header: () => t("Destination"),
size: columnSizing?.remoteDestination || 200,
minSize: 130,
},
{
accessorKey: "type",
header: () => t("Type"),
size: columnSizing?.type || 160,
minSize: 100,
},
],
[columnSizing],
);
const table = useReactTable({
data: connRows,
columns,
state: { columnSizing },
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
});
const VirtuosoTableComponents = useMemo<TableComponents<Row<ConnectionRow>>>(
() => ({
// Явно типизируем `ref` для каждого компонента
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
<div className="h-full" {...props} ref={ref} />
)),
Table: (props) => <Table {...props} className="w-full border-collapse" />,
TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableHeader {...props} ref={ref} />
)),
// Явно типизируем пропсы и `ref` для TableRow
TableRow: React.forwardRef<
HTMLTableRowElement,
{ item: Row<ConnectionRow> } & React.HTMLAttributes<HTMLTableRowElement>
>(({ item: row, ...props }, ref) => {
// `Virtuoso` передает нам готовую строку `row` в пропсе `item`.
// Больше не нужно искать ее по индексу!
return (
<TableRow
{...props}
ref={ref}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onShowDetail(row.original.connectionData)}
/>
);
}),
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableBody {...props} ref={ref} />
)),
}),
[],
);
return ( return (
<DataGrid <div className="h-full rounded-md border overflow-hidden">
hideFooter {connRows.length > 0 ? (
rows={connRows} <TableVirtuoso
columns={columns} scrollerRef={scrollerRef}
onRowClick={(e) => onShowDetail(e.row.connectionData)} data={table.getRowModel().rows}
density="compact" components={VirtuosoTableComponents}
sx={{ fixedHeaderContent={() =>
border: "none", table.getHeaderGroups().map((headerGroup) => (
"div:focus": { outline: "none !important" }, <TableRow
"& .MuiDataGrid-columnHeader": { key={headerGroup.id}
userSelect: "none", className="hover:bg-transparent bg-background/95 backdrop-blur"
}, >
}} {headerGroup.headers.map((header) => (
columnVisibilityModel={columnVisible} <TableHead
onColumnVisibilityModelChange={(e) => setColumnVisible(e)} key={header.id}
onColumnResize={handleColumnResize} style={{ width: header.getSize() }}
disableColumnMenu={false} className="p-2"
/> >
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))
}
itemContent={(index, row) => (
<>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: cell.column.getSize() }}
className="p-2 whitespace-nowrap"
onClick={() => onShowDetail(row.original.connectionData)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</>
)}
/>
) : (
<div className="flex h-full items-center justify-center">
<p>No results.</p>
</div>
)}
</div>
); );
}; };

View File

@@ -17,7 +17,7 @@ const formatUptime = (uptimeMs: number) => {
export const ClashInfoCard = () => { export const ClashInfoCard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { version: clashVersion } = useClash(); const { version: clashVersion } = useClash();
const { clashConfig, sysproxy, rules, uptime } = useAppData(); const { clashConfig, rules, uptime, systemProxyAddress } = useAppData();
// 使用useMemo缓存格式化后的uptime避免频繁计算 // 使用useMemo缓存格式化后的uptime避免频繁计算
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]); const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);
@@ -42,7 +42,7 @@ export const ClashInfoCard = () => {
{t("System Proxy Address")} {t("System Proxy Address")}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{sysproxy?.server || "-"} {systemProxyAddress}
</Typography> </Typography>
</Stack> </Stack>
<Divider /> <Divider />
@@ -74,7 +74,14 @@ export const ClashInfoCard = () => {
</Stack> </Stack>
</Stack> </Stack>
); );
}, [clashConfig, clashVersion, t, formattedUptime, rules.length, sysproxy]); }, [
clashConfig,
clashVersion,
t,
formattedUptime,
rules.length,
systemProxyAddress,
]);
return ( return (
<EnhancedCard <EnhancedCard

View File

@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Box, Typography, Paper, Stack, Fade } from "@mui/material"; import { Box, Typography, Paper, Stack } from "@mui/material";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { closeAllConnections } from "@/services/api"; import { closeAllConnections } from "@/services/api";
import { patchClashMode } from "@/services/cmds"; import { patchClashMode } from "@/services/cmds";

View File

@@ -26,7 +26,7 @@ import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { createAuthSockette } from "@/utils/websocket"; import { createAuthSockette } from "@/utils/websocket";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { getConnections, isDebugEnabled, gc } from "@/services/api"; import { isDebugEnabled, gc } from "@/services/api";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useAppData } from "@/providers/app-data-provider"; import { useAppData } from "@/providers/app-data-provider";

Some files were not shown because too many files have changed in this diff Show More