Compare commits

...

91 commits
v1.3.3 ... main

Author SHA1 Message Date
GyDi
9df1115380 chore: fix check 2023-11-03 16:00:34 +08:00
GyDi
f22e360cbb chore: fix check script 2023-11-03 15:52:36 +08:00
MZhao
67769af6f4
feat: new windows tray icons (#899) 2023-11-03 15:01:46 +08:00
Aromia
b1a9a1d6d9
chore: update download links in README.md (#896) 2023-11-03 11:21:20 +08:00
Goooler
cf0606ecb7
chore: bump github actions (#868)
https://github.com/actions/checkout/releases/tag/v4.0.0
https://github.com/actions/setup-node/releases/tag/v4.0.0
2023-11-02 20:14:05 +08:00
neovali
7287edcd6f
chore: add fedora linux install description (#874) 2023-11-02 20:13:24 +08:00
GyDi
e0d26203dd feat: support auto clean log files 2023-11-02 20:12:46 +08:00
GyDi
7e3a85e9da
fix: latency url empty 2023-11-01 23:22:30 +08:00
GyDi
5a0fed9c93 feat: increase the concurrency of latency test 2023-11-01 20:52:38 +08:00
GyDi
1f1e743912
fix: change default port 2023-10-31 14:56:19 +08:00
GyDi
b4301ed0d5
fix: csp 2023-10-31 12:00:57 +08:00
GyDi
b5391560fc
v1.3.8 2023-10-31 01:29:20 +08:00
GyDi
718989cbcf
chore: update log 2023-10-31 01:28:57 +08:00
GyDi
d0aee76962
fix: add default valid key 2023-10-30 17:10:51 +08:00
GyDi
fb08af96bd
fix: page null exception, close #821 2023-10-30 00:53:24 +08:00
GyDi
510a0c5e70
feat: adjust the delay display interval and color, close #836 2023-10-29 23:01:05 +08:00
GyDi
5fe2be031f
chore: fix rust lint 2023-10-11 14:55:20 +08:00
GyDi
2ba3aaba47
chore: fix alpha ci 2023-10-11 14:31:44 +08:00
GyDi
ad8903991c
fix: try fix csp 2023-10-11 14:21:56 +08:00
GyDi
3e5624c570
chore: update readme 2023-10-11 00:04:56 +08:00
Majokeiko
f5ee6f3537
feat: ClashFieldViewer BaseDialog maxHeight usage percentage (#813)
*The overall interface will be more intuitive when the content is longer.
2023-10-10 14:29:27 +08:00
GyDi
afc77e7adc
chore: update readme 2023-10-10 10:25:57 +08:00
GyDi
024f42fce6
chore: update readme 2023-10-10 10:25:01 +08:00
GyDi
8a5f12b97c
chore: update clash meta 2023-10-08 21:45:12 +08:00
GyDi
954b21cf39
chore: update readme 2023-09-11 10:24:44 +08:00
GyDi
74d095774d
chore: fix updater 2023-09-11 10:16:54 +08:00
GyDi
17a2722e6d
chore: fix updater 2023-09-11 10:12:40 +08:00
GyDi
c843bddbfe
chore: fix clash map, close #736 2023-09-10 19:06:02 +08:00
GyDi
3f22a49755
v1.3.7 2023-09-10 19:02:10 +08:00
GyDi
7af2ffcebf
chore: update log 2023-09-10 19:01:59 +08:00
GyDi
de90c959e0
chore: update auto launch 2023-09-10 18:45:17 +08:00
GyDi
9987dc1eb4
fix: i18n 2023-09-10 15:03:29 +08:00
GyDi
3efd575dd2
feat: add Open Dashboard to the hotkey, close #723 2023-09-10 14:46:03 +08:00
GyDi
f4c7b17a87
feat: add check for updates button, close #766 2023-09-10 14:30:31 +08:00
GyDi
16d80718cb
fix: fix page undefined exception, close #770 2023-09-10 13:45:18 +08:00
GyDi
ad228d53b7
feat: add paste and clear icon 2023-09-09 16:52:00 +08:00
Majokeiko
15ee1e531b
feat: Subscription URL TextField use multiline (#761)
*Subscription link that are too long can make reading difficult, so use multiline TextField.
2023-09-07 16:14:42 +08:00
GyDi
1c8fb3392a
chore: update mmdb 2023-08-28 15:06:13 +08:00
GyDi
8647866a32
fix: set min window size, close #734 2023-08-28 15:00:27 +08:00
GyDi
23351c4f1c
chore: change ubuntu ci version 2023-08-28 14:44:09 +08:00
GyDi
1367c304cf
chore: update clash 2023-08-28 14:22:50 +08:00
GyDi
26d6bcb074
chore: update release link 2023-08-14 11:11:15 +08:00
GyDi
b0d651ece1
chore: update clash meta 2023-08-14 11:09:19 +08:00
GyDi
b6d50ba6a4
fix: rm debug code 2023-08-12 19:16:20 +08:00
GyDi
b3ab6a9166
v1.3.6 2023-08-12 16:12:03 +08:00
GyDi
f39a5ac9c2
chore: update log 2023-08-12 16:11:50 +08:00
GyDi
38a9a9240d
fix: use sudo when pkexec not found 2023-08-12 15:58:37 +08:00
GyDi
241b22a465
chore: update ci 2023-08-12 15:41:26 +08:00
GyDi
741abc0366
feat: show loading when change profile 2023-08-05 22:07:30 +08:00
GyDi
7854775de5
fix: remove div 2023-08-05 21:43:58 +08:00
GyDi
e62eaa6b4b
fix: list key 2023-08-05 21:43:05 +08:00
GyDi
b4cce23ef4
feat: support proxy provider update 2023-08-05 21:38:44 +08:00
GyDi
2bcaf90fc8
feat: add repo link 2023-08-05 19:54:59 +08:00
GyDi
96ffbe2f84
feat: support clash meta memory usage display 2023-08-05 19:40:23 +08:00
GyDi
6f5acee1c3
fix: websocket disconnect when window focus 2023-08-05 17:21:15 +08:00
GyDi
54e491d8bf
feat: supports show connection detail 2023-08-05 16:52:14 +08:00
GyDi
ab6374e278
chore: update geo data to meta, close #707 2023-08-05 13:42:37 +08:00
GyDi
2fda4c9f67
chore: fix faq 2023-08-05 13:41:30 +08:00
GyDi
5138a45b0f
chore: add faq and download link 2023-08-05 13:38:19 +08:00
GyDi
b224d4fa8a
chore: add promotion 2023-08-05 12:57:48 +08:00
whitemirror33
a552e44483
feat: update connection table with wider process column and click to show full detail (#696) 2023-08-04 14:36:28 +08:00
GyDi
0cf3bba118
feat: more trace logs 2023-08-04 14:15:15 +08:00
Andrei Shevchuk
2c48ea3508
feat: Add Russian Language (#697)
* Add Russian Language

* Add Russian support

* Minor update

* Update Russian translation
2023-08-03 11:07:58 +08:00
GyDi
b9b6212b75
fix: try fix undefined error 2023-07-28 09:26:06 +08:00
GyDi
b978aaec21
chore: alpha ci 2023-07-26 17:07:20 +08:00
GyDi
af704681d9
feat: center window when out of monitor 2023-07-24 20:55:26 +08:00
GyDi
1443ddfe6c
chore: fix test ci 2023-07-24 09:59:13 +08:00
GyDi
54457a3e1b
chore: fix test ci 2023-07-24 09:47:05 +08:00
GyDi
bf180e6a2c
chore: test ci 2023-07-24 09:28:54 +08:00
GyDi
864a5820c9
chore: test ci 2023-07-24 09:13:19 +08:00
GyDi
4d3ca49c3f
v1.3.5 2023-07-23 13:31:51 +08:00
GyDi
c49c3cf7f0
chore: update log 2023-07-23 13:31:34 +08:00
GyDi
5d5ab57469
fix: blurry tray icon in Windows 2023-07-23 13:25:54 +08:00
GyDi
31978d8de0
chore: update clash core 2023-07-23 13:11:35 +08:00
GyDi
e8eb68bf24
chore: fix check script 2023-07-23 13:11:17 +08:00
GyDi
9ea08f4fed
chore: update issue templates 2023-07-22 23:04:13 +08:00
GyDi
fe078a5c5b
v1.3.4 2023-07-22 20:38:08 +08:00
GyDi
61933954f3
chore: update log 2023-07-22 20:37:22 +08:00
GyDi
4c243638cb
fix: enable context menu in editable element 2023-07-22 17:21:04 +08:00
GyDi
02ba04b5d8
feat: support copy environment variable 2023-07-22 15:35:32 +08:00
GyDi
4f158a4829
fix: save window size and pos in Windows 2023-07-22 13:13:16 +08:00
GyDi
177a22df59
feat: save window size and position 2023-07-22 10:58:16 +08:00
GyDi
6b0ca2966e
feat: app log level add silent 2023-07-22 09:25:54 +08:00
GyDi
aadfaf7150
feat: overwrite resource file according to file modified 2023-07-22 09:18:54 +08:00
GyDi
b307b9a66b
feat: support app log level settings 2023-07-22 08:53:37 +08:00
Kimiblock Moe
6c1ab6002d
feat: Use polkit to elevate permission instaed of sudo (#678) 2023-07-21 23:05:33 +08:00
GyDi
9638eefc91
chore: update check script 2023-07-11 13:25:55 +08:00
GyDi
9e9c4ad587
fix: optimize traffic graph high CPU usage when hidden 2023-07-10 23:44:09 +08:00
GyDi
ce231431b9
fix: remove fallback group select status, close #659 2023-07-10 23:16:19 +08:00
GyDi
06e1e14e02
chore: update clash 2023-07-10 13:36:17 +08:00
GyDi
416e7884f5
feat: add unified-delay field 2023-06-30 13:58:51 +08:00
71 changed files with 1743 additions and 572 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Information**
- OS: [e.g. macOS]
- Clash Verge Version: [e.g. 1.3.4]
- Clash Core: [e.g. Clash or Clash Meta]
**Additional context**
Add any other context about the problem here.

View file

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -16,19 +16,15 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
os: [windows-latest, ubuntu-20.04, macos-latest]
runs-on: ${{ matrix.os }}
if: startsWith(github.repository, 'zzzgydi')
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@ -36,12 +32,13 @@ jobs:
workspaces: src-tauri
- name: Install Node
uses: actions/setup-node@v1
uses: actions/setup-node@v4
with:
node-version: 16
node-version: "16"
cache: "yarn"
- name: Delete current release assets
if: matrix.os == 'ubuntu-latest'
if: startsWith(matrix.os, 'ubuntu-')
uses: mknejp/delete-release-assets@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
@ -56,6 +53,7 @@ jobs:
*.dmg
*.msi
*.sig
*.exe
- name: Install Dependencies (ubuntu only)
if: startsWith(matrix.os, 'ubuntu-')
@ -63,23 +61,10 @@ jobs:
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf openssl
- name: Get yarn cache dir path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn Cache
uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Yarn install and check
run: |
yarn install --network-timeout 1000000
yarn run check
yarn install --network-timeout 1000000 --frozen-lockfile
yarn run check --force
- name: Tauri build
uses: tauri-apps/tauri-action@v0
@ -96,7 +81,7 @@ jobs:
includeDebug: ${{ github.event.inputs.debug }}
- name: Portable Bundle
if: matrix.os == 'windows-latest'
if: startsWith(matrix.os, 'windows-')
run: |
yarn build
yarn run portable

View file

@ -19,14 +19,10 @@ jobs:
if: startsWith(github.repository, 'zzzgydi')
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@ -34,9 +30,10 @@ jobs:
workspaces: src-tauri
- name: Install Node
uses: actions/setup-node@v1
uses: actions/setup-node@v4
with:
node-version: 16
node-version: "16"
cache: "yarn"
- name: Install Dependencies (ubuntu only)
if: startsWith(matrix.os, 'ubuntu-')
@ -44,22 +41,9 @@ jobs:
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf openssl
- name: Get yarn cache dir path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn Cache
uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Yarn install and check
run: |
yarn install --network-timeout 1000000
yarn install --network-timeout 1000000 --frozen-lockfile
yarn run check
- name: Tauri build
@ -78,7 +62,7 @@ jobs:
prerelease: true
- name: Portable Bundle
if: matrix.os == 'windows-latest'
if: startsWith(matrix.os, 'windows-')
# rebuild with env settings
run: |
yarn build
@ -97,23 +81,16 @@ jobs:
startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Get yarn cache dir path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn Cache
uses: actions/cache@v2
id: yarn-cache
- name: Install Node
uses: actions/setup-node@v4
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
node-version: "16"
cache: "yarn"
- name: Yarn install
run: yarn install
run: yarn install --network-timeout 1000000 --frozen-lockfile
- name: Release updater file
run: yarn run updater

View file

@ -35,14 +35,10 @@ jobs:
echo ${{ github.event.inputs.os }}
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@ -50,32 +46,20 @@ jobs:
workspaces: src-tauri
- name: Install Node
uses: actions/setup-node@v1
uses: actions/setup-node@v4
with:
node-version: 16
node-version: "16"
cache: "yarn"
- name: Install Dependencies (ubuntu only)
if: startsWith(github.event.inputs.os, 'ubuntu-')
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
- name: Get yarn cache dir path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn Cache
uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Yarn install and check
run: |
yarn install --network-timeout 1000000
yarn install --network-timeout 1000000 --frozen-lockfile
yarn run check
- name: Tauri build
@ -84,3 +68,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: alpha
releaseName: "Clash Verge Alpha"
releaseBody: "Alpha Version (include debug)"
releaseDraft: false
includeUpdaterJson: false

View file

@ -8,23 +8,16 @@ jobs:
if: startsWith(github.repository, 'zzzgydi')
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Get yarn cache dir path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Yarn Cache
uses: actions/cache@v2
id: yarn-cache
- name: Install Node
uses: actions/setup-node@v4
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
node-version: "16"
cache: "yarn"
- name: Yarn install
run: yarn install
run: yarn install --network-timeout 1000000 --frozen-lockfile
- name: Release updater file
run: yarn run updater

View file

@ -17,14 +17,66 @@ A <a href="https://github.com/Dreamacro/clash">Clash</a> GUI based on <a href="h
- Built-in support [Clash.Meta](https://github.com/MetaCubeX/Clash.Meta) core.
- System proxy setting and guard.
## Promotion
[狗狗加速 —— 技术流机场 Doggygo VPN](https://dg1.top)
- High-performance overseas VPN, free trial, discounted packages, unlock streaming media, the world's first to support Hysteria protocol.
- 高性能海外机场,免费试用,优惠套餐,解锁流媒体,全球首家支持 Hysteria 协议。
- 使用 Clash Verge 专属邀请链接注册送 15 天,每天 1G 流量免费试用https://panel.dg1.top/#/register?code=sFCDayZf
<details>
<summary>Promotion Detail</summary>
- Clash Verge 专属 8 折优惠码: verge20 (仅有 500 份)
- 优惠套餐每月仅需 15.8 元160G 流量,年付 8 折
- 海外团队,无跑路风险,高达 50% 返佣
- 集群负载均衡设计,高速专线(兼容老客户端)极低延迟无视晚高峰4K 秒开
- 全球首家 Hysteria 协议机场,将在今年 10 月上线更快的 `tuic` 协议(Clash Verge 客户端最佳搭配)
- 解锁流媒体及 ChatGPT
- 官网https://dg1.top
</details>
<br />
[EEVPN —— 海外运营机场 ※ 支持 ChatGPT](https://www.eejsq.net/#/register?code=yRr6qBO3)
- 年付低至 9.99 元,价格低,速度不减
<details>
<summary>Promotion Detail</summary>
- 中国大陆 BGP 网络接入
- IEPL 专线网络
- 最高 2500Mbps 速率可用
- 不限制在线客户端
- 解锁流媒体及 ChatGPT
- 海外运营 数据安全
</details>
## Install
Download from [release](https://github.com/zzzgydi/clash-verge/releases). Supports Windows x64, Linux x86_64 and macOS 11+
- [Windows x64](https://github.com/zzzgydi/clash-verge/releases/download/v1.3.8/Clash.Verge_1.3.8_x64_en-US.msi)
- [macOS intel](https://github.com/zzzgydi/clash-verge/releases/download/v1.3.8/Clash.Verge_1.3.8_x64.dmg)
- [macOS arm](https://github.com/zzzgydi/clash-verge/releases/download/v1.3.8/Clash.Verge_1.3.8_aarch64.dmg)
- [Linux AppImage](https://github.com/zzzgydi/clash-verge/releases/download/v1.3.8/clash-verge_1.3.8_amd64.AppImage)
- [Linux deb](https://github.com/zzzgydi/clash-verge/releases/download/v1.3.8/clash-verge_1.3.8_amd64.deb)
- [Fedora Linux](https://github.com/zzzgydi/clash-verge/issues/352)
Or you can build it yourself. Supports Windows, Linux and macOS 10.15+
Notes: If you could not start the app on Windows, please check that you have [Webview2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/#download-section) installed.
### FAQ
#### 1. **macOS** "Clash Verge" is damaged and can't be opened
open the terminal and run `sudo xattr -r -d com.apple.quarantine /Applications/Clash\ Verge.app`
## Development
You should install Rust and Nodejs, see [here](https://tauri.app/v1/guides/getting-started/prerequisites) for more details. Then install Nodejs packages.
@ -36,6 +88,9 @@ yarn install
Then download the clash binary... Or you can download it from [clash premium release](https://github.com/Dreamacro/clash/releases/tag/premium) and rename it according to [tauri config](https://tauri.studio/docs/api/config/#tauri.bundle.externalBin).
```shell
# force update to latest version
# yarn run check --force
yarn run check
```
@ -43,6 +98,9 @@ Then run
```shell
yarn dev
# run it in another way if app instance exists
yarn dev:diff
```
Or you can build it

View file

@ -1,3 +1,90 @@
## v1.3.8
### Features
- update clash meta core
- add default valid keys
- adjust the delay display interval and color
### Bug Fixes
- fix connections page undefined exception
---
## v1.3.7
### Features
- update clash and clash meta core
- profiles page add paste button
- subscriptions url textfield use multi lines
- set min window size
- add check for updates buttons
- add open dashboard to the hotkey list
### Bug Fixes
- fix profiles page undefined exception
---
## v1.3.6
### Features
- add russian translation
- support to show connection detail
- support clash meta memory usage display
- support proxy provider update ui
- update geo data file from meta repo
- adjust setting page
### Bug Fixes
- center the window when it is out of screen
- use `sudo` when `pkexec` not found (Linux)
- reconnect websocket when window focus
### Notes
- The current version of the Linux installation package is built by Ubuntu 20.04 (Github Action).
---
## v1.3.5
### Features
- update clash core
### Bug Fixes
- fix blurry system tray icon (Windows)
- fix v1.3.4 wintun.dll not found (Windows)
- fix v1.3.4 clash core not found (macOS, Linux)
---
## v1.3.4
### Features
- update clash and clash meta core
- optimize traffic graph high CPU usage when window hidden
- use polkit to elevate permission (Linux)
- support app log level setting
- support copy environment variable
- overwrite resource file according to file modified
- save window size and position
### Bug Fixes
- remove fallback group select status
- enable context menu on editable element (Windows)
---
## v1.3.3
### Features

View file

@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "1.3.3",
"version": "1.3.8",
"license": "GPL-3.0",
"scripts": {
"dev": "tauri dev",

View file

@ -15,28 +15,29 @@ const SIDECAR_HOST = execSync("rustc -vV")
.match(/(?<=host: ).+(?=\s*)/g)[0];
/* ======= clash ======= */
const CLASH_STORAGE_PREFIX = "https://release.dreamacro.workers.dev/";
const CLASH_URL_PREFIX =
"https://github.com/Dreamacro/clash/releases/download/premium/";
const CLASH_LATEST_DATE = "2023.05.29";
const CLASH_LATEST_DATE = "latest";
const CLASH_MAP = {
"win32-x64": "clash-windows-amd64",
"darwin-x64": "clash-darwin-amd64",
"darwin-arm64": "clash-darwin-arm64",
"linux-x64": "clash-linux-amd64",
"linux-arm64": "clash-linux-armv8",
"linux-arm64": "clash-linux-arm64",
};
/* ======= clash meta ======= */
const META_URL_PREFIX = `https://github.com/MetaCubeX/Clash.Meta/releases/download/`;
const META_VERSION = "v1.15.0";
const META_VERSION = "v1.16.0";
const META_MAP = {
"win32-x64": "Clash.Meta-windows-amd64-compatible",
"darwin-x64": "Clash.Meta-darwin-amd64",
"darwin-arm64": "Clash.Meta-darwin-arm64",
"linux-x64": "Clash.Meta-linux-amd64-compatible",
"linux-arm64": "Clash.Meta-linux-arm64",
"win32-x64": "clash.meta-windows-amd64-compatible",
"darwin-x64": "clash.meta-darwin-amd64",
"darwin-arm64": "clash.meta-darwin-arm64",
"linux-x64": "clash.meta-linux-amd64-compatible",
"linux-arm64": "clash.meta-linux-arm64",
};
/**
@ -69,6 +70,24 @@ function clash() {
};
}
function clashS3() {
const name = CLASH_MAP[`${platform}-${arch}`];
const isWin = platform === "win32";
const urlExt = isWin ? "zip" : "gz";
const downloadURL = `${CLASH_STORAGE_PREFIX}${CLASH_LATEST_DATE}/${name}-${CLASH_LATEST_DATE}.${urlExt}`;
const exeFile = `${name}${isWin ? ".exe" : ""}`;
const zipFile = `${name}.${urlExt}`;
return {
name: "clash",
targetFile: `clash-${SIDECAR_HOST}${isWin ? ".exe" : ""}`,
exeFile,
zipFile,
downloadURL,
};
}
function clashMeta() {
const name = META_MAP[`${platform}-${arch}`];
const isWin = platform === "win32";
@ -103,36 +122,61 @@ async function resolveSidecar(binInfo) {
const tempExe = path.join(tempDir, exeFile);
await fs.mkdirp(tempDir);
if (!(await fs.pathExists(tempZip))) await downloadFile(downloadURL, tempZip);
try {
if (!(await fs.pathExists(tempZip))) {
await downloadFile(downloadURL, tempZip);
}
if (zipFile.endsWith(".zip")) {
const zip = new AdmZip(tempZip);
zip.getEntries().forEach((entry) => {
console.log(`[DEBUG]: ${name} entry name`, entry.entryName);
});
zip.extractAllTo(tempDir, true);
await fs.rename(tempExe, sidecarPath);
console.log(`[INFO]: ${name} unzip finished`);
} else {
// gz
const readStream = fs.createReadStream(tempZip);
const writeStream = fs.createWriteStream(sidecarPath);
readStream
.pipe(zlib.createGunzip())
.pipe(writeStream)
.on("finish", () => {
console.log(`[INFO]: ${name} gunzip finished`);
execSync(`chmod 755 ${sidecarPath}`);
console.log(`[INFO]: ${name} chmod binary finished`);
})
.on("error", (error) => {
console.error(`[ERROR]: ${name} gz failed`, error.message);
throw error;
if (zipFile.endsWith(".zip")) {
const zip = new AdmZip(tempZip);
zip.getEntries().forEach((entry) => {
console.log(`[DEBUG]: "${name}" entry name`, entry.entryName);
});
zip.extractAllTo(tempDir, true);
await fs.rename(tempExe, sidecarPath);
console.log(`[INFO]: "${name}" unzip finished`);
} else {
// gz
const readStream = fs.createReadStream(tempZip);
const writeStream = fs.createWriteStream(sidecarPath);
await new Promise((resolve, reject) => {
const onError = (error) => {
console.error(`[ERROR]: "${name}" gz failed:`, error.message);
reject(error);
};
readStream
.pipe(zlib.createGunzip().on("error", onError))
.pipe(writeStream)
.on("finish", () => {
console.log(`[INFO]: "${name}" gunzip finished`);
execSync(`chmod 755 ${sidecarPath}`);
console.log(`[INFO]: "${name}" chmod binary finished`);
resolve();
})
.on("error", onError);
});
}
} catch (err) {
// 需要删除文件
await fs.remove(sidecarPath);
throw err;
} finally {
// delete temp dir
await fs.remove(tempDir);
}
}
// delete temp dir
await fs.remove(tempDir);
/**
* prepare clash core
* if the core version is not updated in time, use S3 storage as a backup.
*/
async function resolveClash() {
try {
return await resolveSidecar(clash());
} catch {
console.log(`[WARN]: clash core needs to be updated`);
return await resolveSidecar(clashS3());
}
}
/**
@ -242,21 +286,21 @@ const resolveUninstall = () =>
const resolveMmdb = () =>
resolveResource({
file: "Country.mmdb",
downloadURL: `https://github.com/Dreamacro/maxmind-geoip/releases/download/20221112/Country.mmdb`,
downloadURL: `https://github.com/Dreamacro/maxmind-geoip/releases/download/20230812/Country.mmdb`,
});
const resolveGeosite = () =>
resolveResource({
file: "geosite.dat",
downloadURL: `https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat`,
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat`,
});
const resolveGeoIP = () =>
resolveResource({
file: "geoip.dat",
downloadURL: `https://github.com/Loyalsoldier/geoip/releases/latest/download/geoip.dat`,
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat`,
});
const tasks = [
{ name: "clash", func: () => resolveSidecar(clash()), retry: 5 },
{ name: "clash", func: () => resolveSidecar(clashS3()), retry: 5 },
{ name: "clash-meta", func: () => resolveSidecar(clashMeta()), retry: 5 },
{ name: "wintun", func: resolveWintun, retry: 5, winOnly: true },
{ name: "service", func: resolveService, retry: 5, winOnly: true },
@ -277,7 +321,8 @@ async function runTask() {
await task.func();
break;
} catch (err) {
console.error(`[ERROR]: task::${task.name} try ${i} == `, err.message);
console.error(`[ERROR]: task::${task.name} try ${i} ==`, err.message);
if (i === task.retry - 1) throw err;
}
}
return runTask();

View file

@ -43,6 +43,7 @@ async function resolveUpdater() {
darwin: { signature: "", url: "" }, // compatible with older formats
"darwin-aarch64": { signature: "", url: "" },
"darwin-intel": { signature: "", url: "" },
"darwin-x86_64": { signature: "", url: "" },
"linux-x86_64": { signature: "", url: "" },
"windows-x86_64": { signature: "", url: "" },
"windows-i686": { signature: "", url: "" }, // no supported
@ -68,12 +69,14 @@ async function resolveUpdater() {
if (name.endsWith(".app.tar.gz") && !name.includes("aarch")) {
updateData.platforms.darwin.url = browser_download_url;
updateData.platforms["darwin-intel"].url = browser_download_url;
updateData.platforms["darwin-x86_64"].url = browser_download_url;
}
// darwin signature (intel)
if (name.endsWith(".app.tar.gz.sig") && !name.includes("aarch")) {
const sig = await getSignature(browser_download_url);
updateData.platforms.darwin.signature = sig;
updateData.platforms["darwin-intel"].signature = sig;
updateData.platforms["darwin-x86_64"].signature = sig;
}
// darwin url (aarch)

4
src-tauri/Cargo.lock generated
View file

@ -243,9 +243,9 @@ dependencies = [
[[package]]
name = "auto-launch"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5904a4d734f0235edf29aab320a14899f3e090446e594ff96508a6215f76f89c"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror",

View file

@ -29,7 +29,7 @@ sysproxy = "0.3"
rquickjs = "0.1.7"
serde_json = "1.0"
serde_yaml = "0.9"
auto-launch = "0.4"
auto-launch = "0.5"
once_cell = "1.14.0"
port_scanner = "0.1.5"
delay_timer = "0.11.1"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -229,6 +229,17 @@ pub fn open_web_url(url: String) -> CmdResult<()> {
wrap_err!(open::that(url))
}
#[tauri::command]
pub async fn clash_api_get_proxy_delay(
name: String,
url: Option<String>,
) -> CmdResult<clash_api::DelayRes> {
match clash_api::get_proxy_delay(name, url).await {
Ok(res) => Ok(res),
Err(err) => Err(format!("{}", err.to_string())),
}
}
#[cfg(windows)]
pub mod service {
use super::*;

View file

@ -24,11 +24,23 @@ impl IClashTemp {
pub fn template() -> Self {
let mut map = Mapping::new();
map.insert("mixed-port".into(), 7890.into());
map.insert(
"mixed-port".into(),
match cfg!(feature = "default-meta") {
false => 7890.into(),
true => 7898.into(),
},
);
map.insert("log-level".into(), "info".into());
map.insert("allow-lan".into(), false.into());
map.insert("mode".into(), "rule".into());
map.insert("external-controller".into(), "127.0.0.1:9090".into());
map.insert(
"external-controller".into(),
match cfg!(feature = "default-meta") {
false => "127.0.0.1:9090".into(),
true => "127.0.0.1:9098".into(),
},
);
map.insert("secret".into(), "".into());
Self(map)

View file

@ -26,7 +26,7 @@ macro_rules! draft_define {
}
pub fn draft(&self) -> MappedMutexGuard<$id> {
MutexGuard::map(self.inner.lock(), |mut inner| {
MutexGuard::map(self.inner.lock(), |inner| {
if inner.1.is_none() {
inner.1 = Some(inner.0.clone());
}

View file

@ -38,7 +38,7 @@ impl IProfiles {
}
// compatible with the old old old version
profiles.items.as_mut().map(|items| {
for mut item in items.iter_mut() {
for item in items.iter_mut() {
if item.uid.is_none() {
item.uid = Some(help::get_uid("d"));
}
@ -55,7 +55,7 @@ impl IProfiles {
pub fn template() -> Self {
Self {
valid: Some(vec!["dns".into()]),
valid: Some(vec!["dns".into(), "sub-rules".into(), "unified-delay".into()]),
items: Some(vec![]),
..Self::default()
}
@ -155,7 +155,7 @@ impl IProfiles {
pub fn patch_item(&mut self, uid: String, item: PrfItem) -> Result<()> {
let mut items = self.items.take().unwrap_or(vec![]);
for mut each in items.iter_mut() {
for each in items.iter_mut() {
if each.uid == Some(uid.clone()) {
patch!(each, item, itype);
patch!(each, item, name);
@ -189,7 +189,7 @@ impl IProfiles {
if let Some(items) = self.items.as_mut() {
let some_uid = Some(uid.clone());
for mut each in items.iter_mut() {
for each in items.iter_mut() {
if each.uid == some_uid {
each.extra = item.extra;
each.updated = item.updated;

View file

@ -1,5 +1,6 @@
use crate::utils::{dirs, help};
use anyhow::Result;
use log::LevelFilter;
use serde::{Deserialize, Serialize};
/// ### `verge.yaml` schema
@ -8,6 +9,10 @@ pub struct IVerge {
/// app listening port for app singleton
pub app_singleton_port: Option<u16>,
/// app log level
/// silent | error | warn | info | debug | trace
pub app_log_level: Option<String>,
// i18n
pub language: Option<String>,
@ -21,6 +26,9 @@ pub struct IVerge {
/// enable traffic graph default is true
pub traffic_graph: Option<bool>,
/// show memory info (only for Clash Meta)
pub enable_memory_usage: Option<bool>,
/// clash tun mode
pub enable_tun_mode: Option<bool>,
@ -74,6 +82,14 @@ pub struct IVerge {
/// proxy 页面布局 列数
pub proxy_layout_column: Option<i32>,
/// 日志清理
/// 0: 不清理; 1: 7天; 2: 30天; 3: 90天
pub auto_log_clean: Option<i32>,
/// window size and position
#[serde(skip_serializing_if = "Option::is_none")]
pub window_size_position: Option<Vec<f64>>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
@ -116,6 +132,7 @@ impl IVerge {
theme_mode: Some("system".into()),
theme_blur: Some(false),
traffic_graph: Some(true),
enable_memory_usage: Some(true),
enable_auto_launch: Some(false),
enable_silent_start: Some(false),
enable_system_proxy: Some(false),
@ -124,6 +141,7 @@ impl IVerge {
auto_close_connection: Some(true),
enable_builtin_enhanced: Some(true),
enable_clash_fields: Some(true),
auto_log_clean: Some(3),
..Self::default()
}
}
@ -144,10 +162,12 @@ impl IVerge {
};
}
patch!(app_log_level);
patch!(language);
patch!(theme_mode);
patch!(theme_blur);
patch!(traffic_graph);
patch!(enable_memory_usage);
patch!(enable_tun_mode);
patch!(enable_service_mode);
@ -168,6 +188,8 @@ impl IVerge {
patch!(enable_builtin_enhanced);
patch!(proxy_layout_column);
patch!(enable_clash_fields);
patch!(auto_log_clean);
patch!(window_size_position);
}
/// 在初始化前尝试拿到单例端口的值
@ -182,4 +204,21 @@ impl IVerge {
Err(_) => SERVER_PORT, // 这里就不log错误了
}
}
/// 获取日志等级
pub fn get_log_level(&self) -> LevelFilter {
if let Some(level) = self.app_log_level.as_ref() {
match level.to_lowercase().as_str() {
"silent" => LevelFilter::Off,
"error" => LevelFilter::Error,
"warn" => LevelFilter::Warn,
"info" => LevelFilter::Info,
"debug" => LevelFilter::Debug,
"trace" => LevelFilter::Trace,
_ => LevelFilter::Info,
}
} else {
LevelFilter::Info
}
}
}

View file

@ -1,6 +1,7 @@
use crate::config::Config;
use anyhow::{bail, Result};
use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize};
use serde_yaml::Mapping;
use std::collections::HashMap;
@ -36,6 +37,32 @@ pub async fn patch_configs(config: &Mapping) -> Result<()> {
Ok(())
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
pub struct DelayRes {
delay: u64,
}
/// GET /proxies/{name}/delay
/// 获取代理延迟
pub async fn get_proxy_delay(name: String, test_url: Option<String>) -> Result<DelayRes> {
let (url, headers) = clash_client_info()?;
let url = format!("{url}/proxies/{name}/delay");
let default_url = "http://www.gstatic.com/generate_204";
let test_url = test_url
.map(|s| if s.is_empty() { default_url.into() } else { s })
.unwrap_or(default_url.into());
let client = reqwest::ClientBuilder::new().no_proxy().build()?;
let builder = client
.get(&url)
.headers(headers)
.query(&[("timeout", "10000"), ("url", &test_url)]);
let response = builder.send().await?;
Ok(response.json::<DelayRes>().await?)
}
/// 根据clash info获取clash服务地址和请求头
fn clash_client_info() -> Result<(String, HeaderMap)> {
let client = { Config::clash().data().get_client_info() };

View file

@ -76,6 +76,7 @@ impl Hotkey {
}
let f = match func.trim() {
"open_dashboard" => || feat::open_dashboard(),
"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()),

View file

@ -28,11 +28,19 @@ pub fn grant_permission(core: String) -> anyhow::Result<()> {
let output = {
let path = path.replace(' ', "\\ "); // 避免路径中有空格
let shell = format!("setcap cap_net_bind_service,cap_net_admin=+ep {path}");
Command::new("sudo")
.arg("sh")
.arg("-c")
.arg(shell)
.output()?
let sudo = match Command::new("which").arg("pkexec").output() {
Ok(output) => {
if output.stdout.is_empty() {
"sudo"
} else {
"pkexec"
}
}
Err(_) => "sudo",
};
Command::new(sudo).arg("sh").arg("-c").arg(shell).output()?
};
if output.status.success() {

View file

@ -13,67 +13,81 @@ impl Tray {
let version = app_handle.package_info().version.to_string();
if zh {
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "打开面板"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("rule_mode", "规则模式"))
.add_item(CustomMenuItem::new("global_mode", "全局模式"))
.add_item(CustomMenuItem::new("direct_mode", "直连模式"))
.add_item(CustomMenuItem::new("script_mode", "脚本模式"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("system_proxy", "系统代理"))
.add_item(CustomMenuItem::new("tun_mode", "TUN 模式"))
.add_submenu(SystemTraySubmenu::new(
"打开目录",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_app_dir", "应用目录"))
.add_item(CustomMenuItem::new("open_core_dir", "内核目录"))
.add_item(CustomMenuItem::new("open_logs_dir", "日志目录")),
))
.add_submenu(SystemTraySubmenu::new(
"更多",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("restart_clash", "重启 Clash"))
.add_item(CustomMenuItem::new("restart_app", "重启应用"))
.add_item(
CustomMenuItem::new("app_version", format!("Version {version}"))
.disabled(),
),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "退出").accelerator("CmdOrControl+Q"))
} else {
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_window", "Dashboard"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("rule_mode", "Rule Mode"))
.add_item(CustomMenuItem::new("global_mode", "Global Mode"))
.add_item(CustomMenuItem::new("direct_mode", "Direct Mode"))
.add_item(CustomMenuItem::new("script_mode", "Script Mode"))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("system_proxy", "System Proxy"))
.add_item(CustomMenuItem::new("tun_mode", "Tun Mode"))
.add_submenu(SystemTraySubmenu::new(
"Open Dir",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("open_app_dir", "App Dir"))
.add_item(CustomMenuItem::new("open_core_dir", "Core Dir"))
.add_item(CustomMenuItem::new("open_logs_dir", "Logs Dir")),
))
.add_submenu(SystemTraySubmenu::new(
"More",
SystemTrayMenu::new()
.add_item(CustomMenuItem::new("restart_clash", "Restart Clash"))
.add_item(CustomMenuItem::new("restart_app", "Restart App"))
.add_item(
CustomMenuItem::new("app_version", format!("Version {version}"))
.disabled(),
),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", "Quit").accelerator("CmdOrControl+Q"))
macro_rules! t {
($en: expr, $zh: expr) => {
if zh {
$zh
} else {
$en
}
};
}
SystemTrayMenu::new()
.add_item(CustomMenuItem::new(
"open_window",
t!("Dashboard", "打开面板"),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new(
"rule_mode",
t!("Rule Mode", "规则模式"),
))
.add_item(CustomMenuItem::new(
"global_mode",
t!("Global Mode", "全局模式"),
))
.add_item(CustomMenuItem::new(
"direct_mode",
t!("Direct Mode", "直连模式"),
))
.add_item(CustomMenuItem::new(
"script_mode",
t!("Script Mode", "脚本模式"),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new(
"system_proxy",
t!("System Proxy", "系统代理"),
))
.add_item(CustomMenuItem::new("tun_mode", t!("TUN Mode", "Tun 模式")))
.add_item(CustomMenuItem::new(
"copy_env",
t!("Copy Env", "复制环境变量"),
))
.add_submenu(SystemTraySubmenu::new(
t!("Open Dir", "打开目录"),
SystemTrayMenu::new()
.add_item(CustomMenuItem::new(
"open_app_dir",
t!("App Dir", "应用目录"),
))
.add_item(CustomMenuItem::new(
"open_core_dir",
t!("Core Dir", "内核目录"),
))
.add_item(CustomMenuItem::new(
"open_logs_dir",
t!("Logs Dir", "日志目录"),
)),
))
.add_submenu(SystemTraySubmenu::new(
t!("More", "更多"),
SystemTrayMenu::new()
.add_item(CustomMenuItem::new(
"restart_clash",
t!("Restart Clash", "重启 Clash"),
))
.add_item(CustomMenuItem::new(
"restart_app",
t!("Restart App", "重启应用"),
))
.add_item(
CustomMenuItem::new("app_version", format!("Version {version}")).disabled(),
),
))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(CustomMenuItem::new("quit", t!("Quit", "退出")).accelerator("CmdOrControl+Q"))
}
pub fn update_systray(app_handle: &AppHandle) -> Result<()> {
@ -110,9 +124,9 @@ impl Tray {
#[cfg(target_os = "windows")]
{
let indication_icon = if *system_proxy {
include_bytes!("../../icons/tray-icon-activated.png").to_vec()
include_bytes!("../../icons/win-tray-icon-activated.png").to_vec()
} else {
include_bytes!("../../icons/tray-icon.png").to_vec()
include_bytes!("../../icons/win-tray-icon.png").to_vec()
};
let _ = tray.set_icon(tauri::Icon::Raw(indication_icon));
@ -135,12 +149,15 @@ impl Tray {
"open_window" => resolve::create_window(app_handle),
"system_proxy" => feat::toggle_system_proxy(),
"tun_mode" => feat::toggle_tun_mode(),
"copy_env" => feat::copy_clash_env(),
"open_app_dir" => crate::log_err!(cmds::open_app_dir()),
"open_core_dir" => crate::log_err!(cmds::open_core_dir()),
"open_logs_dir" => crate::log_err!(cmds::open_logs_dir()),
"restart_clash" => feat::restart_clash_core(),
"restart_app" => api::process::restart(&app_handle.env()),
"quit" => {
let _ = resolve::save_window_size_position(app_handle, true);
resolve::resolve_reset();
api::process::kill_children();
app_handle.exit(0);

View file

@ -21,7 +21,7 @@ pub const DEFAULT_FIELDS: [&str; 5] = [
"rule-providers",
];
pub const OTHERS_FIELDS: [&str; 29] = [
pub const OTHERS_FIELDS: [&str; 30] = [
"dns",
"tun",
"ebpf",
@ -46,6 +46,7 @@ pub const OTHERS_FIELDS: [&str; 29] = [
"listeners", // meta
"sub-rules", // meta
"geodata-mode", // meta
"unified-delay", // meta
"tcp-concurrent", // meta
"enable-process", // meta
"find-process-mode", // meta

View file

@ -7,8 +7,19 @@
use crate::config::*;
use crate::core::*;
use crate::log_err;
use crate::utils::resolve;
use anyhow::{bail, Result};
use serde_yaml::{Mapping, Value};
use wry::application::clipboard::Clipboard;
// 打开面板
pub fn open_dashboard() {
let handle = handle::Handle::global();
let app_handle = handle.app_handle.lock();
if let Some(app_handle) = app_handle.as_ref() {
resolve::create_window(app_handle);
}
}
// 重启clash
pub fn restart_clash_core() {
@ -319,3 +330,12 @@ async fn update_core_config() -> Result<()> {
}
}
}
/// copy env variable
pub fn copy_clash_env() {
let port = { Config::clash().data().get_client_info().port };
let text = format!("export https_proxy=http://127.0.0.1:{port} http_proxy=http://127.0.0.1:{port} all_proxy=socks5://127.0.0.1:{port}");
let mut cliboard = Clipboard::new();
cliboard.write_text(text);
}

View file

@ -66,6 +66,8 @@ fn main() -> std::io::Result<()> {
cmds::service::check_service,
cmds::service::install_service,
cmds::service::uninstall_service,
// clash api
cmds::clash_api_get_proxy_delay
]);
#[cfg(target_os = "macos")]
@ -109,6 +111,8 @@ fn main() -> std::io::Result<()> {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
let _ = resolve::save_window_size_position(&app_handle, true);
app_handle.get_window("main").map(|win| {
let _ = win.hide();
});
@ -117,6 +121,20 @@ fn main() -> std::io::Result<()> {
}
}
}
#[cfg(not(target_os = "macos"))]
tauri::RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
match event {
tauri::WindowEvent::CloseRequested { .. } => {
let _ = resolve::save_window_size_position(&app_handle, true);
}
tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) => {
let _ = resolve::save_window_size_position(&app_handle, false);
}
_ => {}
}
}
}
_ => {}
});

View file

@ -119,6 +119,15 @@ macro_rules! log_err {
};
}
#[macro_export]
macro_rules! trace_err {
($result: expr, $err_str: expr) => {
if let Err(err) = $result {
log::trace!(target: "app", "{}, err {}", $err_str, err);
}
}
}
/// wrap the anyhow error
/// transform the error to String
#[macro_export]

View file

@ -1,13 +1,14 @@
use crate::config::*;
use crate::utils::{dirs, help};
use anyhow::Result;
use chrono::Local;
use chrono::{DateTime, Local};
use log::LevelFilter;
use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Config, Logger, Root};
use log4rs::config::{Appender, Logger, Root};
use log4rs::encode::pattern::PatternEncoder;
use std::fs;
use std::fs::{self, DirEntry};
use std::str::FromStr;
use tauri::PackageInfo;
/// initialize this instance's log file
@ -17,42 +18,126 @@ fn init_log() -> Result<()> {
let _ = fs::create_dir_all(&log_dir);
}
let log_level = Config::verge().data().get_log_level();
if log_level == LevelFilter::Off {
return Ok(());
}
let local_time = Local::now().format("%Y-%m-%d-%H%M").to_string();
let log_file = format!("{}.log", local_time);
let log_file = log_dir.join(log_file);
#[cfg(feature = "verge-dev")]
let time_format = "{d(%Y-%m-%d %H:%M:%S)} {l} - {M} {m}{n}";
#[cfg(not(feature = "verge-dev"))]
let time_format = "{d(%Y-%m-%d %H:%M:%S)} {l} - {m}{n}";
let log_pattern = match log_level {
LevelFilter::Trace => "{d(%Y-%m-%d %H:%M:%S)} {l} [{M}] - {m}{n}",
_ => "{d(%Y-%m-%d %H:%M:%S)} {l} - {m}{n}",
};
let encode = Box::new(PatternEncoder::new(time_format));
let encode = Box::new(PatternEncoder::new(log_pattern));
let stdout = ConsoleAppender::builder().encoder(encode.clone()).build();
let tofile = FileAppender::builder().encoder(encode).build(log_file)?;
#[cfg(feature = "verge-dev")]
let level = LevelFilter::Debug;
#[cfg(not(feature = "verge-dev"))]
let level = LevelFilter::Info;
let mut logger_builder = Logger::builder();
let mut root_builder = Root::builder();
let config = Config::builder()
let log_more = log_level == LevelFilter::Trace || log_level == LevelFilter::Debug;
#[cfg(feature = "verge-dev")]
{
logger_builder = logger_builder.appenders(["file", "stdout"]);
if log_more {
root_builder = root_builder.appenders(["file", "stdout"]);
} else {
root_builder = root_builder.appenders(["stdout"]);
}
}
#[cfg(not(feature = "verge-dev"))]
{
logger_builder = logger_builder.appenders(["file"]);
if log_more {
root_builder = root_builder.appenders(["file"]);
}
}
let (config, _) = log4rs::config::Config::builder()
.appender(Appender::builder().build("stdout", Box::new(stdout)))
.appender(Appender::builder().build("file", Box::new(tofile)))
.logger(
Logger::builder()
.appenders(["file", "stdout"])
.additive(false)
.build("app", level),
)
.build(Root::builder().appender("stdout").build(LevelFilter::Info))?;
.logger(logger_builder.additive(false).build("app", log_level))
.build_lossy(root_builder.build(log_level));
log4rs::init_config(config)?;
Ok(())
}
/// Initialize all the files from resources
/// 删除log文件
pub fn delete_log() -> Result<()> {
let log_dir = dirs::app_logs_dir()?;
if !log_dir.exists() {
return Ok(());
}
let auto_log_clean = {
let verge = Config::verge();
let verge = verge.data();
verge.auto_log_clean.clone().unwrap_or(0)
};
let day = match auto_log_clean {
1 => 7,
2 => 30,
3 => 90,
_ => return Ok(()),
};
log::debug!(target: "app", "try to delete log files, day: {day}");
// %Y-%m-%d to NaiveDateTime
let parse_time_str = |s: &str| {
let sa: Vec<&str> = s.split('-').collect();
if sa.len() != 4 {
return Err(anyhow::anyhow!("invalid time str"));
}
let year = i32::from_str(sa[0])?;
let month = u32::from_str(sa[1])?;
let day = u32::from_str(sa[2])?;
let time = chrono::NaiveDate::from_ymd_opt(year, month, day)
.ok_or(anyhow::anyhow!("invalid time str"))?
.and_hms_opt(0, 0, 0)
.ok_or(anyhow::anyhow!("invalid time str"))?;
Ok(time)
};
let process_file = |file: DirEntry| -> Result<()> {
let file_name = file.file_name();
let file_name = file_name.to_str().unwrap_or_default();
if file_name.ends_with(".log") {
let now = Local::now();
let created_time = parse_time_str(&file_name[0..file_name.len() - 4])?;
let file_time = DateTime::<Local>::from_local(created_time, now.offset().clone());
let duration = now.signed_duration_since(file_time);
if duration.num_days() > day {
let file_path = file.path();
let _ = fs::remove_file(file_path);
log::info!(target: "app", "delete log file: {file_name}");
}
}
Ok(())
};
for file in fs::read_dir(&log_dir)? {
if let Ok(file) = file {
let _ = process_file(file);
}
}
Ok(())
}
/// Initialize all the config files
/// before tauri setup
pub fn init_config() -> Result<()> {
#[cfg(target_os = "windows")]
unsafe {
@ -60,6 +145,7 @@ pub fn init_config() -> Result<()> {
}
let _ = init_log();
let _ = delete_log();
crate::log_err!(dirs::app_home_dir().map(|app_dir| {
if !app_dir.exists() {
@ -97,7 +183,8 @@ pub fn init_config() -> Result<()> {
Ok(())
}
/// initialize app
/// initialize app resources
/// after tauri setup
pub fn init_resources(package_info: &PackageInfo) -> Result<()> {
let app_dir = dirs::app_home_dir()?;
let res_dir = dirs::app_resources_dir(package_info)?;
@ -109,13 +196,47 @@ pub fn init_resources(package_info: &PackageInfo) -> Result<()> {
let _ = fs::create_dir_all(&res_dir);
}
#[cfg(target_os = "windows")]
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat", "wintun.dll"];
#[cfg(not(target_os = "windows"))]
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"];
// copy the resource file
for file in ["Country.mmdb", "geoip.dat", "geosite.dat", "wintun.dll"].iter() {
// if the source file is newer than the destination file, copy it over
for file in file_list.iter() {
let src_path = res_dir.join(file);
let target_path = app_dir.join(file);
if src_path.exists() {
let _ = fs::copy(src_path, target_path);
let dest_path = app_dir.join(file);
let handle_copy = || {
match fs::copy(&src_path, &dest_path) {
Ok(_) => log::debug!(target: "app", "resources copied '{file}'"),
Err(err) => {
log::error!(target: "app", "failed to copy resources '{file}', {err}")
}
};
};
if src_path.exists() && !dest_path.exists() {
handle_copy();
continue;
}
let src_modified = fs::metadata(&src_path).and_then(|m| m.modified());
let dest_modified = fs::metadata(&dest_path).and_then(|m| m.modified());
match (src_modified, dest_modified) {
(Ok(src_modified), Ok(dest_modified)) => {
if src_modified > dest_modified {
handle_copy();
} else {
log::debug!(target: "app", "skipping resource copy '{file}'");
}
}
_ => {
log::debug!(target: "app", "failed to get modified '{file}'");
handle_copy();
}
};
}
Ok(())

View file

@ -1,6 +1,6 @@
use crate::config::Config;
use crate::log_err;
use crate::{core::*, utils::init, utils::server};
use crate::{config::Config, core::*, utils::init, utils::server};
use crate::{log_err, trace_err};
use anyhow::Result;
use tauri::{App, AppHandle, Manager};
/// handle something when start app
@ -13,12 +13,17 @@ pub fn resolve_setup(app: &mut App) {
log_err!(init::init_resources(app.package_info()));
// 启动核心
log::trace!("init config");
log_err!(Config::init_config());
log::trace!("launch core");
log_err!(CoreManager::global().init());
// setup a simple http server for singleton
log::trace!("launch embed server");
server::embed_server(app.app_handle());
log::trace!("init system tray");
log_err!(tray::Tray::update_systray(&app.app_handle()));
let silent_start = { Config::verge().data().enable_silent_start.clone() };
@ -43,22 +48,47 @@ pub fn resolve_reset() {
/// create main window
pub fn create_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
trace_err!(window.unminimize(), "set win unminimize");
trace_err!(window.show(), "set win visible");
trace_err!(window.set_focus(), "set win focus");
return;
}
let builder = tauri::window::WindowBuilder::new(
let mut builder = tauri::window::WindowBuilder::new(
app_handle,
"main".to_string(),
tauri::WindowUrl::App("index.html".into()),
)
.title("Clash Verge")
.center()
.fullscreen(false)
.min_inner_size(600.0, 520.0);
match Config::verge().latest().window_size_position.clone() {
Some(size_pos) if size_pos.len() == 4 => {
let size = (size_pos[0], size_pos[1]);
let pos = (size_pos[2], size_pos[3]);
let w = size.0.clamp(600.0, f64::INFINITY);
let h = size.1.clamp(520.0, f64::INFINITY);
builder = builder.inner_size(w, h).position(pos.0, pos.1);
}
_ => {
#[cfg(target_os = "windows")]
{
builder = builder.inner_size(800.0, 636.0).center();
}
#[cfg(target_os = "macos")]
{
builder = builder.inner_size(800.0, 642.0).center();
}
#[cfg(target_os = "linux")]
{
builder = builder.inner_size(800.0, 642.0).center();
}
}
};
#[cfg(target_os = "windows")]
{
use std::time::Duration;
@ -68,43 +98,82 @@ pub fn create_window(app_handle: &AppHandle) {
match builder
.decorations(false)
.transparent(true)
.inner_size(800.0, 636.0)
.visible(false)
.build()
{
Ok(_) => {
let app_handle = app_handle.clone();
Ok(win) => {
log::trace!("try to calculate the monitor size");
let center = (|| -> Result<bool> {
let mut center = false;
let monitor = win.current_monitor()?.ok_or(anyhow::anyhow!(""))?;
let size = monitor.size();
let pos = win.outer_position()?;
if let Some(window) = app_handle.get_window("main") {
let _ = set_shadow(&window, true);
if pos.x < -400
|| pos.x > (size.width - 200).try_into()?
|| pos.y < -200
|| pos.y > (size.height - 200).try_into()?
{
center = true;
}
Ok(center)
})();
if center.unwrap_or(true) {
trace_err!(win.center(), "set win center");
}
log::trace!("try to create window");
let app_handle = app_handle.clone();
// 加点延迟避免界面闪一下
tauri::async_runtime::spawn(async move {
sleep(Duration::from_secs(1)).await;
sleep(Duration::from_millis(888)).await;
if let Some(window) = app_handle.get_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
trace_err!(set_shadow(&window, true), "set win shadow");
trace_err!(window.show(), "set win visible");
trace_err!(window.unminimize(), "set win unminimize");
trace_err!(window.set_focus(), "set win focus");
} else {
log::error!(target: "app", "failed to create window, get_window is None")
}
});
}
Err(err) => log::error!(target: "app", "{err}"),
Err(err) => log::error!(target: "app", "failed to create window, {err}"),
}
}
#[cfg(target_os = "macos")]
crate::log_err!(builder
.decorations(true)
.inner_size(800.0, 642.0)
.hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay)
.build());
#[cfg(target_os = "linux")]
crate::log_err!(builder
.decorations(true)
.transparent(false)
.inner_size(800.0, 642.0)
.build());
crate::log_err!(builder.decorations(true).transparent(false).build());
}
/// save window size and position
pub fn save_window_size_position(app_handle: &AppHandle, save_to_file: bool) -> Result<()> {
let win = app_handle
.get_window("main")
.ok_or(anyhow::anyhow!("failed to get window"))?;
let scale = win.scale_factor()?;
let size = win.inner_size()?;
let size = size.to_logical::<f64>(scale);
let pos = win.outer_position()?;
let pos = pos.to_logical::<f64>(scale);
let verge = Config::verge();
let mut verge = verge.latest();
verge.window_size_position = Some(vec![size.width, size.height, pos.x, pos.y]);
if save_to_file {
verge.save_file()?;
}
Ok(())
}

View file

@ -1,7 +1,7 @@
{
"package": {
"productName": "Clash Verge",
"version": "1.3.3"
"version": "1.3.8"
},
"build": {
"distDir": "../dist",
@ -46,7 +46,7 @@
"digestAlgorithm": "sha256",
"timestampUrl": "",
"wix": {
"language": ["zh-CN", "en-US"]
"language": ["zh-CN", "en-US", "ru-RU"]
}
}
},

View file

@ -0,0 +1,104 @@
import dayjs from "dayjs";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { Box, Button, Snackbar } from "@mui/material";
import { deleteConnection } from "@/services/api";
import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic";
export interface ConnectionDetailRef {
open: (detail: IConnectionsItem) => void;
}
export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
(props, ref) => {
const [open, setOpen] = useState(false);
const [detail, setDetail] = useState<IConnectionsItem>(null!);
useImperativeHandle(ref, () => ({
open: (detail: IConnectionsItem) => {
if (open) return;
setOpen(true);
setDetail(detail);
},
}));
const onClose = () => setOpen(false);
return (
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
open={open}
onClose={onClose}
message={
detail ? (
<InnerConnectionDetail data={detail} onClose={onClose} />
) : null
}
/>
);
}
);
interface InnerProps {
data: IConnectionsItem;
onClose?: () => void;
}
const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const { metadata, rulePayload } = data;
const chains = [...data.chains].reverse().join(" / ");
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const host = metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`;
const information = [
{ label: "Host", value: host },
{ label: "Download", value: parseTraffic(data.download).join(" ") },
{ label: "Upload", value: parseTraffic(data.upload).join(" ") },
{
label: "DL Speed",
value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s",
},
{
label: "UL Speed",
value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s",
},
{ label: "Chains", value: chains },
{ label: "Rule", value: rule },
{
label: "Process",
value: truncateStr(metadata.process || metadata.processPath),
},
{ label: "Time", value: dayjs(data.start).fromNow() },
{ label: "Source", value: `${metadata.sourceIP}:${metadata.sourcePort}` },
{ label: "Destination IP", value: metadata.destinationIP },
{ label: "Type", value: `${metadata.type}(${metadata.network})` },
];
const onDelete = useLockFn(async () => deleteConnection(data.id));
return (
<Box sx={{ userSelect: "text" }}>
{information.map((each) => (
<div key={each.label}>
<b>{each.label}</b>: <span>{each.value}</span>
</div>
))}
<Box sx={{ textAlign: "right" }}>
<Button
variant="contained"
title="Close Connection"
onClick={() => {
onDelete();
onClose?.();
}}
>
Close
</Button>
</Box>
</Box>
);
};

View file

@ -24,10 +24,11 @@ const Tag = styled("span")(({ theme }) => ({
interface Props {
value: IConnectionsItem;
onShowDetail?: () => void;
}
const ConnectionItem = (props: Props) => {
const { value } = props;
export const ConnectionItem = (props: Props) => {
const { value, onShowDetail } = props;
const { id, metadata, chains, start, curUpload, curDownload } = value;
@ -44,8 +45,9 @@ const ConnectionItem = (props: Props) => {
}
>
<ListItemText
sx={{ userSelect: "text" }}
sx={{ userSelect: "text", cursor: "pointer" }}
primary={metadata.host || metadata.destinationIP}
onClick={onShowDetail}
secondary={
<Box sx={{ display: "flex", flexWrap: "wrap" }}>
<Tag sx={{ textTransform: "uppercase", color: "success" }}>
@ -56,7 +58,7 @@ const ConnectionItem = (props: Props) => {
{!!metadata.process && <Tag>{metadata.process}</Tag>}
{chains.length > 0 && <Tag>{chains[value.chains.length - 1]}</Tag>}
{chains?.length > 0 && <Tag>{chains[value.chains.length - 1]}</Tag>}
<Tag>{dayjs(start).fromNow()}</Tag>
@ -71,5 +73,3 @@ const ConnectionItem = (props: Props) => {
</ListItem>
);
};
export default ConnectionItem;

View file

@ -1,32 +1,29 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic";
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
}
const ConnectionTable = (props: Props) => {
const { connections } = props;
export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail } = props;
const [columnVisible, setColumnVisible] = useState<
Partial<Record<keyof IConnectionsItem, boolean>>
>({});
const columns: GridColDef[] = [
{
field: "host",
headerName: "Host",
flex: 200,
minWidth: 200,
resizable: false,
disableColumnMenu: true,
},
{ field: "host", headerName: "Host", flex: 220, minWidth: 220 },
{
field: "download",
headerName: "Download",
width: 88,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) => parseTraffic(params.value).join(" "),
},
{
field: "upload",
@ -34,18 +31,13 @@ const ConnectionTable = (props: Props) => {
width: 88,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) => parseTraffic(params.value).join(" "),
},
{
field: "dlSpeed",
headerName: "DL Speed",
align: "right",
width: 88,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) =>
parseTraffic(params.value).join(" ") + "/s",
},
{
field: "ulSpeed",
@ -53,55 +45,26 @@ const ConnectionTable = (props: Props) => {
width: 88,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params: any) =>
parseTraffic(params.value).join(" ") + "/s",
},
{
field: "chains",
headerName: "Chains",
width: 360,
disableColumnMenu: true,
},
{
field: "rule",
headerName: "Rule",
width: 225,
disableColumnMenu: true,
},
{
field: "process",
headerName: "Process",
width: 120,
disableColumnMenu: true,
},
{ field: "chains", headerName: "Chains", flex: 360, minWidth: 360 },
{ field: "rule", headerName: "Rule", flex: 300, minWidth: 250 },
{ field: "process", headerName: "Process", flex: 480, minWidth: 480 },
{
field: "time",
headerName: "Time",
width: 120,
flex: 120,
minWidth: 100,
align: "right",
headerAlign: "right",
disableColumnMenu: true,
valueFormatter: (params) => dayjs(params.value).fromNow(),
},
{
field: "source",
headerName: "Source",
width: 150,
disableColumnMenu: true,
},
{ field: "source", headerName: "Source", flex: 200, minWidth: 130 },
{
field: "destinationIP",
headerName: "Destination IP",
width: 125,
disableColumnMenu: true,
},
{
field: "type",
headerName: "Type",
width: 160,
disableColumnMenu: true,
flex: 200,
minWidth: 130,
},
{ field: "type", headerName: "Type", flex: 160, minWidth: 100 },
];
const connRows = useMemo(() => {
@ -115,30 +78,33 @@ const ConnectionTable = (props: Props) => {
host: metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.destinationIP}:${metadata.destinationPort}`,
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
download: parseTraffic(each.download).join(" "),
upload: parseTraffic(each.upload).join(" "),
dlSpeed: parseTraffic(each.curDownload).join(" ") + "/s",
ulSpeed: parseTraffic(each.curUpload).join(" ") + "/s",
chains,
rule,
process: metadata.process || metadata.processPath,
time: each.start,
process: truncateStr(metadata.process || metadata.processPath),
time: dayjs(each.start).fromNow(),
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
destinationIP: metadata.destinationIP,
type: `${metadata.type}(${metadata.network})`,
connectionData: each,
};
});
}, [connections]);
return (
<DataGrid
hideFooter
rows={connRows}
columns={columns}
onRowClick={(e) => onShowDetail(e.row.connectionData)}
density="compact"
sx={{ border: "none", "div:focus": { outline: "none !important" } }}
hideFooter
columnVisibilityModel={columnVisible}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
/>
);
};
export default ConnectionTable;

View file

@ -6,7 +6,7 @@ import {
HorizontalRuleRounded,
} from "@mui/icons-material";
const LayoutControl = () => {
export const LayoutControl = () => {
const minWidth = 40;
return (
@ -37,5 +37,3 @@ const LayoutControl = () => {
</>
);
};
export default LayoutControl;

View file

@ -2,7 +2,7 @@ import { alpha, ListItem, ListItemButton, ListItemText } from "@mui/material";
import { useMatch, useResolvedPath, useNavigate } from "react-router-dom";
import type { LinkProps } from "react-router-dom";
const LayoutItem = (props: LinkProps) => {
export const LayoutItem = (props: LinkProps) => {
const { to, children } = props;
const resolved = useResolvedPath(to);
@ -40,5 +40,3 @@ const LayoutItem = (props: LinkProps) => {
</ListItem>
);
};
export default LayoutItem;

View file

@ -1,23 +1,30 @@
import { useEffect, useRef, useState } from "react";
import { Box, Typography } from "@mui/material";
import { ArrowDownward, ArrowUpward } from "@mui/icons-material";
import {
ArrowDownward,
ArrowUpward,
MemoryOutlined,
} from "@mui/icons-material";
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { TrafficGraph, type TrafficRef } from "./traffic-graph";
import { useLogSetup } from "./use-log-setup";
import { useVisibility } from "@/hooks/use-visibility";
import { useWebsocket } from "@/hooks/use-websocket";
import parseTraffic from "@/utils/parse-traffic";
// setup the traffic
const LayoutTraffic = () => {
export const LayoutTraffic = () => {
const { clashInfo } = useClashInfo();
const { verge } = useVerge();
// whether hide traffic graph
const { verge } = useVerge();
const trafficGraph = verge?.traffic_graph ?? true;
const trafficRef = useRef<TrafficRef>(null);
const [traffic, setTraffic] = useState({ up: 0, down: 0 });
const [memory, setMemory] = useState({ inuse: 0 });
const pageVisible = useVisibility();
// setup log ws during layout
useLogSetup();
@ -29,7 +36,7 @@ const LayoutTraffic = () => {
});
useEffect(() => {
if (!clashInfo) return;
if (!clashInfo || !pageVisible) return;
const { server = "", secret = "" } = clashInfo;
connect(`ws://${server}/traffic?token=${encodeURIComponent(secret)}`);
@ -37,36 +44,40 @@ const LayoutTraffic = () => {
return () => {
disconnect();
};
}, [clashInfo]);
}, [clashInfo, pageVisible]);
/* --------- meta memory information --------- */
const isMetaCore = verge?.clash_core === "clash-meta";
const displayMemory = isMetaCore && (verge?.enable_memory_usage ?? true);
const memoryWs = useWebsocket(
(event) => {
setMemory(JSON.parse(event.data));
},
{ onError: () => setMemory({ inuse: 0 }) }
);
useEffect(() => {
// 页面隐藏时去掉请求
const handleVisibleChange = () => {
if (!clashInfo) return;
if (document.visibilityState === "visible") {
// reconnect websocket
const { server = "", secret = "" } = clashInfo;
connect(`ws://${server}/traffic?token=${encodeURIComponent(secret)}`);
} else {
disconnect();
}
};
document.addEventListener("visibilitychange", handleVisibleChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibleChange);
};
}, []);
if (!clashInfo || !pageVisible || !displayMemory) return;
const { server = "", secret = "" } = clashInfo;
memoryWs.connect(
`ws://${server}/memory?token=${encodeURIComponent(secret)}`
);
return () => memoryWs.disconnect();
}, [clashInfo, pageVisible, displayMemory]);
const [up, upUnit] = parseTraffic(traffic.up);
const [down, downUnit] = parseTraffic(traffic.down);
const [inuse, inuseUnit] = parseTraffic(memory.inuse);
const iconStyle: any = {
sx: { mr: "8px", fontSize: 16 },
};
const valStyle: any = {
component: "span",
color: "primary",
textAlign: "center",
sx: { flex: "1 1 54px", userSelect: "none" },
sx: { flex: "1 1 56px", userSelect: "none" },
};
const unitStyle: any = {
component: "span",
@ -82,31 +93,44 @@ const LayoutTraffic = () => {
position="relative"
onClick={trafficRef.current?.toggleStyle}
>
{trafficGraph && (
{trafficGraph && pageVisible && (
<div style={{ width: "100%", height: 60, marginBottom: 6 }}>
<TrafficGraph ref={trafficRef} />
</div>
)}
<Box mb={1.5} display="flex" alignItems="center" whiteSpace="nowrap">
<ArrowUpward
sx={{ mr: 0.75, fontSize: 18 }}
color={+up > 0 ? "primary" : "disabled"}
/>
<Typography {...valStyle}>{up}</Typography>
<Typography {...unitStyle}>{upUnit}/s</Typography>
</Box>
<Box display="flex" flexDirection="column" gap={0.75}>
<Box display="flex" alignItems="center" whiteSpace="nowrap">
<ArrowUpward
{...iconStyle}
color={+up > 0 ? "primary" : "disabled"}
/>
<Typography {...valStyle}>{up}</Typography>
<Typography {...unitStyle}>{upUnit}/s</Typography>
</Box>
<Box display="flex" alignItems="center" whiteSpace="nowrap">
<ArrowDownward
sx={{ mr: 0.75, fontSize: 18 }}
color={+down > 0 ? "primary" : "disabled"}
/>
<Typography {...valStyle}>{down}</Typography>
<Typography {...unitStyle}>{downUnit}/s</Typography>
<Box display="flex" alignItems="center" whiteSpace="nowrap">
<ArrowDownward
{...iconStyle}
color={+down > 0 ? "primary" : "disabled"}
/>
<Typography {...valStyle}>{down}</Typography>
<Typography {...unitStyle}>{downUnit}/s</Typography>
</Box>
{displayMemory && (
<Box
display="flex"
alignItems="center"
whiteSpace="nowrap"
title="Memory Usage"
>
<MemoryOutlined {...iconStyle} color="disabled" />
<Typography {...valStyle}>{inuse}</Typography>
<Typography {...unitStyle}>{inuseUnit}</Typography>
</Box>
)}
</Box>
</Box>
);
};
export default LayoutTraffic;

View file

@ -1,17 +1,19 @@
import useSWR from "swr";
import { useState } from "react";
import { useRef } from "react";
import { Button } from "@mui/material";
import { checkUpdate } from "@tauri-apps/api/updater";
import UpdateDialog from "./update-dialog";
import { UpdateViewer } from "../setting/mods/update-viewer";
import { DialogRef } from "../base";
interface Props {
className?: string;
}
const UpdateButton = (props: Props) => {
export const UpdateButton = (props: Props) => {
const { className } = props;
const [dialogOpen, setDialogOpen] = useState(false);
const viewerRef = useRef<DialogRef>(null);
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
@ -22,21 +24,17 @@ const UpdateButton = (props: Props) => {
return (
<>
<UpdateViewer ref={viewerRef} />
<Button
color="error"
variant="contained"
size="small"
className={className}
onClick={() => setDialogOpen(true)}
onClick={() => viewerRef.current?.open()}
>
New
</Button>
{dialogOpen && (
<UpdateDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
)}
</>
);
};
export default UpdateButton;

View file

@ -9,7 +9,7 @@ import { useVerge } from "@/hooks/use-verge";
/**
* custom theme
*/
export default function useCustomTheme() {
export const useCustomTheme = () => {
const { verge } = useVerge();
const { theme_mode, theme_setting } = verge ?? {};
const [mode, setMode] = useRecoilState(atomThemeMode);
@ -121,4 +121,4 @@ export default function useCustomTheme() {
}, [mode, theme_setting]);
return { theme };
}
};

View file

@ -27,6 +27,7 @@ export const ProfileBox = styled(Box)(
}[key]!;
return {
position: "relative",
width: "100%",
display: "block",
cursor: "pointer",

View file

@ -12,6 +12,7 @@ import {
keyframes,
MenuItem,
Menu,
CircularProgress,
} from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { atomLoadingCache } from "@/services/states";
@ -28,13 +29,14 @@ const round = keyframes`
interface Props {
selected: boolean;
activating: boolean;
itemData: IProfileItem;
onSelect: (force: boolean) => void;
onEdit: () => void;
}
export const ProfileItem = (props: Props) => {
const { selected, itemData, onSelect, onEdit } = props;
const { selected, activating, itemData, onSelect, onEdit } = props;
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
@ -192,6 +194,25 @@ export const ProfileItem = (props: Props) => {
event.preventDefault();
}}
>
{activating && (
<Box
sx={{
position: "absolute",
display: "flex",
justifyContent: "center",
alignItems: "center",
top: 10,
left: 10,
right: 10,
bottom: 2,
zIndex: 10,
backdropFilter: "blur(2px)",
}}
>
<CircularProgress size={20} />
</Box>
)}
<Box position="relative">
<Typography
width="calc(100% - 36px)"

View file

@ -143,7 +143,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
<BaseDialog
open={open}
title={openType === "new" ? t("Create Profile") : t("Edit Profile")}
contentSx={{ width: 375, pb: 0, maxHeight: 320 }}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={handleClose}
@ -188,7 +188,12 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
name="url"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("Subscription URL")} />
<TextField
{...text}
{...field}
multiline
label={t("Subscription URL")}
/>
)}
/>

View file

@ -0,0 +1,86 @@
import dayjs from "dayjs";
import useSWR, { mutate } from "swr";
import { useState } from "react";
import {
Button,
IconButton,
List,
ListItem,
ListItemText,
} from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { getProviders, providerUpdate } from "@/services/api";
import { BaseDialog } from "../base";
export const ProviderButton = () => {
const { t } = useTranslation();
const { data } = useSWR("getProviders", getProviders);
const [open, setOpen] = useState(false);
const hasProvider = Object.keys(data || {}).length > 0;
const handleUpdate = useLockFn(async (key: string) => {
await providerUpdate(key);
await mutate("getProxies");
await mutate("getProviders");
});
if (!hasProvider) return null;
return (
<>
<Button
size="small"
variant="outlined"
sx={{ textTransform: "capitalize" }}
onClick={() => setOpen(true)}
>
{t("Provider")}
</Button>
<BaseDialog
open={open}
title={t("Proxy Provider")}
contentSx={{ width: 400 }}
disableOk
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(data || {}).map(([key, item]) => {
const time = dayjs(item.updatedAt);
return (
<ListItem sx={{ p: 0 }} key={key}>
<ListItemText
primary={key}
secondary={
<>
<span style={{ marginRight: "4em" }}>
Type: {item.vehicleType}
</span>
<span title={time.format("YYYY-MM-DD HH:mm:ss")}>
Updated: {time.fromNow()}
</span>
</>
}
/>
<IconButton
size="small"
color="inherit"
title="Update Provider"
onClick={() => handleUpdate(key)}
>
<RefreshRounded />
</IconButton>
</ListItem>
);
})}
</List>
</BaseDialog>
</>
);
};

View file

@ -137,20 +137,14 @@ export const ProxyItemMini = (props: Props) => {
e.stopPropagation();
onDelay();
}}
color={
delay > 500
? "error.main"
: delay < 100
? "success.main"
: "text.secondary"
}
color={delayManager.formatDelayColor(delay)}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
>
{delay > 1e5 ? "Error" : delay > 3000 ? "Timeout" : `${delay}`}
{delayManager.formatDelay(delay)}
</Widget>
)}

View file

@ -145,20 +145,14 @@ export const ProxyItem = (props: Props) => {
e.stopPropagation();
onDelay();
}}
color={
delay > 500
? "error.main"
: delay < 100
? "success.main"
: "text.secondary"
}
color={delayManager.formatDelayColor(delay)}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
>
{delay > 1e5 ? "Error" : delay > 3000 ? "Timeout" : `${delay}ms`}
{delayManager.formatDelay(delay)}
</Widget>
)}

View file

@ -14,6 +14,7 @@ const ItemWrapper = styled("div")`
`;
const HOTKEY_FUNC = [
"open_dashboard",
"clash_mode_rule",
"clash_mode_global",
"clash_mode_direct",

View file

@ -0,0 +1,80 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { List, Switch } from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { SettingItem } from "./setting-comp";
import { GuardState } from "./guard-state";
export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const [open, setOpen] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const onSwitchFormat = (_e: any, value: boolean) => value;
const onError = (err: any) => {
Notice.error(err.message || err.toString());
};
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
return (
<BaseDialog
open={open}
title={t("Layout Setting")}
contentSx={{ width: 450 }}
disableOk
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List>
<SettingItem label={t("Theme Blur")}>
<GuardState
value={verge?.theme_blur ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ theme_blur: e })}
onGuard={(e) => patchVerge({ theme_blur: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Traffic Graph")}>
<GuardState
value={verge?.traffic_graph ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ traffic_graph: e })}
onGuard={(e) => patchVerge({ traffic_graph: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem label={t("Memory Usage")}>
<GuardState
value={verge?.enable_memory_usage ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_memory_usage: e })}
onGuard={(e) => patchVerge({ enable_memory_usage: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
</List>
</BaseDialog>
);
});

View file

@ -19,22 +19,26 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const [open, setOpen] = useState(false);
const [values, setValues] = useState({
appLogLevel: "info",
autoCloseConnection: false,
enableClashFields: true,
enableBuiltinEnhanced: true,
proxyLayoutColumn: 6,
defaultLatencyTest: "",
autoLogClean: 0,
});
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setValues({
appLogLevel: verge?.app_log_level ?? "info",
autoCloseConnection: verge?.auto_close_connection ?? false,
enableClashFields: verge?.enable_clash_fields ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
defaultLatencyTest: verge?.default_latency_test || "",
autoLogClean: verge?.auto_log_clean || 0,
});
},
close: () => setOpen(false),
@ -43,11 +47,13 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const onSave = useLockFn(async () => {
try {
await patchVerge({
app_log_level: values.appLogLevel,
auto_close_connection: values.autoCloseConnection,
enable_clash_fields: values.enableClashFields,
enable_builtin_enhanced: values.enableBuiltinEnhanced,
proxy_layout_column: values.proxyLayoutColumn,
default_latency_test: values.defaultLatencyTest,
auto_log_clean: values.autoLogClean as any,
});
setOpen(false);
} catch (err: any) {
@ -68,7 +74,28 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Auto Close Connections" />
<ListItemText primary={t("App Log Level")} />
<Select
size="small"
sx={{ width: 100, "> div": { py: "7.5px" } }}
value={values.appLogLevel}
onChange={(e) => {
setValues((v) => ({
...v,
appLogLevel: e.target.value as string,
}));
}}
>
{["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
<MenuItem value={i} key={i}>
{i[0].toUpperCase() + i.slice(1).toLowerCase()}
</MenuItem>
))}
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Close Connections")} />
<Switch
edge="end"
checked={values.autoCloseConnection}
@ -79,7 +106,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Clash Fields Filter" />
<ListItemText primary={t("Enable Clash Fields Filter")} />
<Switch
edge="end"
checked={values.enableClashFields}
@ -90,7 +117,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Enable Builtin Enhanced" />
<ListItemText primary={t("Enable Builtin Enhanced")} />
<Switch
edge="end"
checked={values.enableBuiltinEnhanced}
@ -101,10 +128,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Proxy Layout Column" />
<ListItemText primary={t("Proxy Layout Column")} />
<Select
size="small"
sx={{ width: 100, "> div": { py: "7.5px" } }}
sx={{ width: 135, "> div": { py: "7.5px" } }}
value={values.proxyLayoutColumn}
onChange={(e) => {
setValues((v) => ({
@ -125,7 +152,33 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary="Default Latency Test" />
<ListItemText primary={t("Auto Log Clean")} />
<Select
size="small"
sx={{ width: 135, "> div": { py: "7.5px" } }}
value={values.autoLogClean}
onChange={(e) => {
setValues((v) => ({
...v,
autoLogClean: e.target.value as number,
}));
}}
>
{[
{ key: "Never Clean", value: 0 },
{ key: "Retain 7 Days", value: 1 },
{ key: "Retain 30 Days", value: 2 },
{ key: "Retain 90 Days", value: 3 },
].map((i) => (
<MenuItem key={i.value} value={i.value}>
{t(i.key)}
</MenuItem>
))}
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Default Latency Test")} />
<TextField
size="small"
autoComplete="off"

View file

@ -11,10 +11,11 @@ interface ItemProps {
label: ReactNode;
extra?: ReactNode;
children?: ReactNode;
secondary?: ReactNode;
}
export const SettingItem: React.FC<ItemProps> = (props) => {
const { label, extra, children } = props;
const { label, extra, children, secondary } = props;
const primary = !extra ? (
label
@ -27,7 +28,7 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
return (
<ListItem sx={{ pt: "5px", pb: "5px" }}>
<ListItemText primary={primary} />
<ListItemText primary={primary} secondary={secondary} />
{children}
</ListItem>
);

View file

@ -1,43 +1,45 @@
import useSWR from "swr";
import snarkdown from "snarkdown";
import { useMemo } from "react";
import { forwardRef, useImperativeHandle, useState, useMemo } from "react";
import { useLockFn } from "ahooks";
import { Box, styled } from "@mui/material";
import { useRecoilState } from "recoil";
import { useTranslation } from "react-i18next";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
styled,
} from "@mui/material";
import { relaunch } from "@tauri-apps/api/process";
import { checkUpdate, installUpdate } from "@tauri-apps/api/updater";
import { BaseDialog, DialogRef, Notice } from "@/components/base";
import { atomUpdateState } from "@/services/states";
import { Notice } from "@/components/base";
interface Props {
open: boolean;
onClose: () => void;
}
const UpdateLog = styled(Box)(() => ({
"h1,h2,h3,ul,ol,p": { margin: "0.5em 0", color: "inherit" },
}));
const UpdateDialog = (props: Props) => {
const { open, onClose } = props;
export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [updateState, setUpdateState] = useRecoilState(atomUpdateState);
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
});
const [updateState, setUpdateState] = useRecoilState(atomUpdateState);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}));
const onUpdate = async () => {
// markdown parser
const parseContent = useMemo(() => {
if (!updateInfo?.manifest?.body) {
return "New Version is available";
}
return snarkdown(updateInfo?.manifest?.body);
}, [updateInfo]);
const onUpdate = useLockFn(async () => {
if (updateState) return;
setUpdateState(true);
@ -49,39 +51,20 @@ const UpdateDialog = (props: Props) => {
} finally {
setUpdateState(false);
}
};
// markdown parser
const parseContent = useMemo(() => {
if (!updateInfo?.manifest?.body) {
return "New Version is available";
}
return snarkdown(updateInfo?.manifest?.body);
}, [updateInfo]);
});
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>New Version v{updateInfo?.manifest?.version}</DialogTitle>
<DialogContent sx={{ minWidth: 360, maxWidth: 400, maxHeight: "50vh" }}>
<UpdateLog dangerouslySetInnerHTML={{ __html: parseContent }} />
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
autoFocus
variant="contained"
disabled={updateState}
onClick={onUpdate}
>
{t("Update")}
</Button>
</DialogActions>
</Dialog>
<BaseDialog
open={open}
title={`New Version v${updateInfo?.manifest?.version}`}
contentSx={{ minWidth: 360, maxWidth: 400, maxHeight: "50vh" }}
okBtn={t("Update")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onUpdate}
>
<UpdateLog dangerouslySetInnerHTML={{ __html: parseContent }} />
</BaseDialog>
);
};
export default UpdateDialog;
});

View file

@ -1,17 +1,13 @@
import { useRef } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
IconButton,
MenuItem,
Select,
Switch,
Typography,
} from "@mui/material";
import { IconButton, MenuItem, Select, Typography } from "@mui/material";
import { openAppDir, openCoreDir, openLogsDir } from "@/services/cmds";
import { ArrowForward } from "@mui/icons-material";
import { checkUpdate } from "@tauri-apps/api/updater";
import { useVerge } from "@/hooks/use-verge";
import { version } from "@root/package.json";
import { DialogRef } from "@/components/base";
import { DialogRef, Notice } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { ThemeModeSwitch } from "./mods/theme-mode-switch";
import { ConfigViewer } from "./mods/config-viewer";
@ -19,34 +15,54 @@ import { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer";
import { ThemeViewer } from "./mods/theme-viewer";
import { GuardState } from "./mods/guard-state";
import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer";
import getSystem from "@/utils/get-system";
interface Props {
onError?: (err: Error) => void;
}
const OS = getSystem();
const SettingVerge = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const { theme_mode, theme_blur, traffic_graph, language } = verge ?? {};
const { theme_mode, language } = verge ?? {};
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null);
const themeRef = useRef<DialogRef>(null);
const layoutRef = useRef<DialogRef>(null);
const updateRef = useRef<DialogRef>(null);
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
const onCheckUpdate = useLockFn(async () => {
try {
const info = await checkUpdate();
if (!info?.shouldUpdate) {
Notice.success("No Updates Available");
} else {
updateRef.current?.open();
}
} catch (err: any) {
Notice.error(err.message || err.toString());
}
});
return (
<SettingList title={t("Verge Setting")}>
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
<MiscViewer ref={miscRef} />
<LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<SettingItem label={t("Language")}>
<GuardState
@ -59,6 +75,7 @@ const SettingVerge = ({ onError }: Props) => {
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}>
<MenuItem value="zh"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
</Select>
</GuardState>
</SettingItem>
@ -74,30 +91,26 @@ const SettingVerge = ({ onError }: Props) => {
</GuardState>
</SettingItem>
<SettingItem label={t("Theme Blur")}>
<GuardState
value={theme_blur ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ theme_blur: e })}
onGuard={(e) => patchVerge({ theme_blur: e })}
<SettingItem label={t("Theme Setting")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => themeRef.current?.open()}
>
<Switch edge="end" />
</GuardState>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Traffic Graph")}>
<GuardState
value={traffic_graph ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ traffic_graph: e })}
onGuard={(e) => patchVerge({ traffic_graph: e })}
<SettingItem label={t("Layout Setting")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => layoutRef.current?.open()}
>
<Switch edge="end" />
</GuardState>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Miscellaneous")}>
@ -111,17 +124,6 @@ const SettingVerge = ({ onError }: Props) => {
</IconButton>
</SettingItem>
<SettingItem label={t("Theme Setting")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={() => themeRef.current?.open()}
>
<ArrowForward />
</IconButton>
</SettingItem>
<SettingItem label={t("Hotkey Setting")}>
<IconButton
color="inherit"
@ -177,6 +179,19 @@ const SettingVerge = ({ onError }: Props) => {
</IconButton>
</SettingItem>
{!(OS === "windows" && WIN_PORTABLE) && (
<SettingItem label={t("Check for Updates")}>
<IconButton
color="inherit"
size="small"
sx={{ my: "2px" }}
onClick={onCheckUpdate}
>
<ArrowForward />
</IconButton>
</SettingItem>
)}
<SettingItem label={t("Verge Version")}>
<Typography sx={{ py: "7px", pr: 1 }}>v{version}</Typography>
</SettingItem>

View file

@ -49,15 +49,10 @@ export const useProfiles = () => {
const { global, groups } = proxiesData;
[global, ...groups].forEach(({ type, name, now }) => {
if (!now || (type !== "Selector" && type !== "Fallback")) return;
if (!now || type !== "Selector") return;
if (selectedMap[name] != null && selectedMap[name] !== now) {
hasChange = true;
updateProxy(name, selectedMap[name]);
console.log({
name,
now,
select: selectedMap[name],
});
}
newSelected.push({ name, now: selectedMap[name] });
});

View file

@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
export const useVisibility = () => {
const [visible, setVisible] = useState(true);
useEffect(() => {
const handleVisibilityChange = () => {
setVisible(document.visibilityState === "visible");
};
const handleFocus = () => setVisible(true);
const handleClick = () => setVisible(true);
handleVisibilityChange();
document.addEventListener("focus", handleFocus);
document.addEventListener("pointerdown", handleClick);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("focus", handleFocus);
document.removeEventListener("pointerdown", handleClick);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return visible;
};

View file

@ -2,12 +2,13 @@ import { useRef } from "react";
export type WsMsgFn = (event: MessageEvent<any>) => void;
interface Options {
export interface WsOptions {
errorCount?: number; // default is 5
retryInterval?: number; // default is 2500
onError?: () => void;
}
export const useWebsocket = (onMessage: WsMsgFn, options?: Options) => {
export const useWebsocket = (onMessage: WsMsgFn, options?: WsOptions) => {
const wsRef = useRef<WebSocket | null>(null);
const timerRef = useRef<any>(null);
@ -38,6 +39,9 @@ export const useWebsocket = (onMessage: WsMsgFn, options?: Options) => {
if (errorCount >= 0) {
timerRef.current = setTimeout(connectHelper, 2500);
} else {
disconnect();
options?.onError?.();
}
});
};

View file

@ -55,6 +55,8 @@
"Descriptions": "Descriptions",
"Subscription URL": "Subscription URL",
"Update Interval": "Update Interval",
"Use System Proxy": "Use System Proxy",
"Use Clash Proxy": "Use Clash Proxy",
"Settings": "Settings",
"Clash Setting": "Clash Setting",
@ -64,6 +66,7 @@
"IPv6": "IPv6",
"Log Level": "Log Level",
"Mixed Port": "Mixed Port",
"External": "External",
"Clash Core": "Clash Core",
"Tun Mode": "Tun Mode",
"Service Mode": "Service Mode",
@ -78,12 +81,16 @@
"Theme Mode": "Theme Mode",
"Theme Blur": "Theme Blur",
"Theme Setting": "Theme Setting",
"Layout Setting": "Layout Setting",
"Miscellaneous": "Miscellaneous",
"Hotkey Setting": "Hotkey Setting",
"Traffic Graph": "Traffic Graph",
"Memory Usage": "Memory Usage",
"Language": "Language",
"Open App Dir": "Open App Dir",
"Open Core Dir": "Open Core Dir",
"Open Logs Dir": "Open Logs Dir",
"Check for Updates": "Check for Updates",
"Verge Version": "Verge Version",
"theme.light": "Light",
"theme.dark": "Dark",
@ -97,6 +104,11 @@
"Save": "Save",
"Cancel": "Cancel",
"Default": "Default",
"Download Speed": "Download Speed",
"Upload Speed": "Upload Speed",
"open_dashboard": "Open Dashboard",
"clash_mode_rule": "Rule Mode",
"clash_mode_global": "Global Mode",
"clash_mode_direct": "Direct Mode",
@ -106,5 +118,18 @@
"disable_system_proxy": "Disable System Proxy",
"toggle_tun_mode": "Toggle Tun Mode",
"enable_tun_mode": "Enable Tun Mode",
"disable_tun_mode": "Disable Tun Mode"
"disable_tun_mode": "Disable Tun Mode",
"App Log Level": "App Log Level",
"Auto Close Connections": "Auto Close Connections",
"Enable Clash Fields Filter": "Enable Clash Fields Filter",
"Enable Builtin Enhanced": "Enable Builtin Enhanced",
"Proxy Layout Column": "Proxy Layout Column",
"Default Latency Test": "Default Latency Test",
"Auto Log Clean": "Auto Log Clean",
"Never Clean": "Never Clean",
"Retain 7 Days": "Retain 7 Days",
"Retain 30 Days": "Retain 30 Days",
"Retain 90 Days": "Retain 90 Days"
}

111
src/locales/ru.json Normal file
View file

@ -0,0 +1,111 @@
{
"Label-Proxies": "Прокси",
"Label-Profiles": "Профили",
"Label-Connections": "Соединения",
"Label-Logs": "Логи",
"Label-Rules": "Правила",
"Label-Settings": "Настройки",
"Connections": "Соединения",
"Logs": "Логи",
"Clear": "Очистить",
"Proxies": "Прокси",
"Proxy Groups": "Группы прокси",
"rule": "правила",
"global": "глобальный",
"direct": "прямой",
"script": "скриптовый",
"Profiles": "Профили",
"Profile URL": "URL профиля",
"Import": "Импорт",
"New": "Новый",
"Create Profile": "Создать профиль",
"Choose File": "Выбрать файл",
"Close All": "Закрыть всё",
"Select": "Выбрать",
"Edit Info": "Изменить информацию",
"Edit File": "Изменить файл",
"Open File": "Открыть файл",
"Update": "Обновить",
"Update(Proxy)": "Обновить (прокси)",
"Delete": "Удалить",
"Enable": "Включить",
"Disable": "Отключить",
"Refresh": "Обновить",
"To Top": "Наверх",
"To End": "Вниз",
"Update All Profiles": "Обновить все профили",
"View Runtime Config": "Просмотреть используемый конфиг",
"Reactivate Profiles": "Реактивировать профили",
"Location": "Местоположение",
"Delay check": "Проверка задержки",
"Sort by default": "Сортировать по умолчанию",
"Sort by delay": "Сортировать по задержке",
"Sort by name": "Сортировать по названию",
"Delay check URL": "URL проверки задержки",
"Proxy detail": "Подробности о прокси",
"Filter": "Фильтр",
"Filter conditions": "Условия фильтрации",
"Refresh profiles": "Обновить профили",
"Type": "Тип",
"Name": "Название",
"Descriptions": "Описания",
"Subscription URL": "URL подписки",
"Update Interval": "Интервал обновления",
"Settings": "Настройки",
"Clash Setting": "Настройки Clash",
"System Setting": "Настройки системы",
"Verge Setting": "Настройки Verge",
"Allow Lan": "Разрешить локальную сеть",
"IPv6": "IPv6",
"Log Level": "Уровень логов",
"Mixed Port": "Смешанный порт",
"Clash Core": "Ядро Clash",
"Tun Mode": "Режим туннеля",
"Service Mode": "Режим сервиса",
"Auto Launch": "Автозапуск",
"Silent Start": "Тихий запуск",
"System Proxy": "Системный прокси",
"System Proxy Setting": "Настройка системного прокси",
"Proxy Guard": "Защита прокси",
"Guard Duration": "Период защиты",
"Proxy Bypass": "Игнорирование прокси",
"Current System Proxy": "Текущий системный прокси",
"Theme Mode": "Режим темы",
"Theme Blur": "Размытие темы",
"Theme Setting": "Настройка темы",
"Hotkey Setting": "Настройка клавиатурных сокращений",
"Traffic Graph": "График трафика",
"Language": "Язык",
"Open App Dir": "Открыть папку приложения",
"Open Core Dir": "Открыть папку ядра",
"Open Logs Dir": "Открыть папку логов",
"Verge Version": "Версия Verge",
"theme.light": "Светлая",
"theme.dark": "Тёмная",
"theme.system": "Системная",
"Clash Field": "Используемые настройки Clash",
"Runtime Config": "Используемый конфиг",
"ReadOnly": "Только для чтения",
"Restart": "Перезапуск",
"Back": "Назад",
"Save": "Сохранить",
"Cancel": "Отмена",
"open_dashboard": "Open Dashboard",
"clash_mode_rule": "Режим правил",
"clash_mode_global": "Глобальный режим",
"clash_mode_direct": "Прямой режим",
"clash_mode_script": "Скриптовый режим",
"toggle_system_proxy": "Переключить режим системного прокси",
"enable_system_proxy": "Включить системный прокси",
"disable_system_proxy": "Отключить системный прокси",
"toggle_tun_mode": "Переключить режим туннеля",
"enable_tun_mode": "Включить режим туннеля",
"disable_tun_mode": "Отключить режим туннеля"
}

View file

@ -80,14 +80,17 @@
"Current System Proxy": "当前系统代理",
"Theme Mode": "主题模式",
"Theme Blur": "背景模糊",
"Miscellaneous": "杂项设置",
"Theme Setting": "主题设置",
"Layout Setting": "界面设置",
"Miscellaneous": "杂项设置",
"Hotkey Setting": "热键设置",
"Traffic Graph": "流量图显",
"Memory Usage": "内存使用",
"Language": "语言设置",
"Open App Dir": "应用目录",
"Open Core Dir": "内核目录",
"Open Logs Dir": "日志目录",
"Check for Updates": "检查更新",
"Verge Version": "应用版本",
"theme.light": "浅色",
"theme.dark": "深色",
@ -105,6 +108,7 @@
"Download Speed": "下载速度",
"Upload Speed": "上传速度",
"open_dashboard": "打开面板",
"clash_mode_rule": "规则模式",
"clash_mode_global": "全局模式",
"clash_mode_direct": "直连模式",
@ -114,5 +118,18 @@
"disable_system_proxy": "关闭系统代理",
"toggle_tun_mode": "切换Tun模式",
"enable_tun_mode": "开启Tun模式",
"disable_tun_mode": "关闭Tun模式"
"disable_tun_mode": "关闭Tun模式",
"App Log Level": "App日志等级",
"Auto Close Connections": "自动关闭连接",
"Enable Clash Fields Filter": "开启Clash字段过滤",
"Enable Builtin Enhanced": "开启内建增强功能",
"Proxy Layout Column": "代理页布局列数",
"Default Latency Test": "默认测试链接",
"Auto Log Clean": "自动清理日志",
"Never Clean": "不清理",
"Retain 7 Days": "保留7天",
"Retain 30 Days": "保留30天",
"Retain 90 Days": "保留90天"
}

View file

@ -13,12 +13,13 @@ import { getAxios } from "@/services/api";
import { useVerge } from "@/hooks/use-verge";
import { ReactComponent as LogoSvg } from "@/assets/image/logo.svg";
import { BaseErrorBoundary, Notice } from "@/components/base";
import LayoutItem from "@/components/layout/layout-item";
import LayoutControl from "@/components/layout/layout-control";
import LayoutTraffic from "@/components/layout/layout-traffic";
import UpdateButton from "@/components/layout/update-button";
import useCustomTheme from "@/components/layout/use-custom-theme";
import { LayoutItem } from "@/components/layout/layout-item";
import { LayoutControl } from "@/components/layout/layout-control";
import { LayoutTraffic } from "@/components/layout/layout-traffic";
import { UpdateButton } from "@/components/layout/update-button";
import { useCustomTheme } from "@/components/layout/use-custom-theme";
import getSystem from "@/utils/get-system";
import "dayjs/locale/ru";
import "dayjs/locale/zh-cn";
dayjs.extend(relativeTime);
@ -47,6 +48,7 @@ const Layout = () => {
mutate("getProxies");
mutate("getVersion");
mutate("getClashConfig");
mutate("getProviders");
});
// update the verge config
@ -87,7 +89,17 @@ const Layout = () => {
}}
onContextMenu={(e) => {
// only prevent it on Windows
if (OS === "windows") e.preventDefault();
const validList = ["input", "textarea"];
const target = e.currentTarget;
if (
OS === "windows" &&
!(
validList.includes(target.tagName.toLowerCase()) ||
target.isContentEditable
)
) {
e.preventDefault();
}
}}
sx={[
({ palette }) => ({

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import {
Box,
@ -18,8 +18,12 @@ import { atomConnectionSetting } from "@/services/states";
import { useClashInfo } from "@/hooks/use-clash";
import { BaseEmpty, BasePage } from "@/components/base";
import { useWebsocket } from "@/hooks/use-websocket";
import ConnectionItem from "@/components/connection/connection-item";
import ConnectionTable from "@/components/connection/connection-table";
import { ConnectionItem } from "@/components/connection/connection-item";
import { ConnectionTable } from "@/components/connection/connection-table";
import {
ConnectionDetail,
ConnectionDetailRef,
} from "@/components/connection/connection-detail";
const initConn = { uploadTotal: 0, downloadTotal: 0, connections: [] };
@ -65,7 +69,7 @@ const ConnectionsPage = () => {
const connections: typeof oldConn = [];
const rest = data.connections?.filter((each) => {
const rest = (data.connections || []).filter((each) => {
const index = oldConn.findIndex((o) => o.id === each.id);
if (index >= 0 && index < maxLen) {
@ -106,6 +110,8 @@ const ConnectionsPage = () => {
const onCloseAll = useLockFn(closeAllConnections);
const detailRef = useRef<ConnectionDetailRef>(null!);
return (
<BasePage
title={t("Connections")}
@ -186,14 +192,24 @@ const ConnectionsPage = () => {
{filterConn.length === 0 ? (
<BaseEmpty text="No Connections" />
) : isTableLayout ? (
<ConnectionTable connections={filterConn} />
<ConnectionTable
connections={filterConn}
onShowDetail={(detail) => detailRef.current?.open(detail)}
/>
) : (
<Virtuoso
data={filterConn}
itemContent={(index, item) => <ConnectionItem value={item} />}
itemContent={(index, item) => (
<ConnectionItem
value={item}
onShowDetail={() => detailRef.current?.open(item)}
/>
)}
/>
)}
</Box>
<ConnectionDetail ref={detailRef} />
</Paper>
</BasePage>
);

View file

@ -4,6 +4,8 @@ import { useLockFn } from "ahooks";
import { useSetRecoilState } from "recoil";
import { Box, Button, Grid, IconButton, Stack, TextField } from "@mui/material";
import {
ClearRounded,
ContentCopyRounded,
LocalFireDepartmentRounded,
RefreshRounded,
TextSnippetOutlined,
@ -35,6 +37,7 @@ const ProfilePage = () => {
const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false);
const [activating, setActivating] = useState("");
const {
profiles = {},
@ -60,11 +63,12 @@ const ProfilePage = () => {
const type1 = ["local", "remote"];
const type2 = ["merge", "script"];
const regularItems = items.filter((i) => type1.includes(i.type!));
const restItems = items.filter((i) => type2.includes(i.type!));
const regularItems = items.filter((i) => i && type1.includes(i.type!));
const restItems = items.filter((i) => i && type2.includes(i.type!));
const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i]));
const enhanceItems = chain
.map((i) => restMap[i]!)
.filter(Boolean)
.concat(restItems.filter((i) => !chain.includes(i.uid)));
return { regularItems, enhanceItems };
@ -99,6 +103,8 @@ const ProfilePage = () => {
const onSelect = useLockFn(async (current: string, force: boolean) => {
if (!force && current === profiles.current) return;
// 避免大多数情况下loading态闪烁
const reset = setTimeout(() => setActivating(current), 100);
try {
await patchProfiles({ current });
mutateLogs();
@ -107,6 +113,9 @@ const ProfilePage = () => {
Notice.success("Refresh clash config", 1000);
} catch (err: any) {
Notice.error(err?.message || err.toString(), 4000);
} finally {
clearTimeout(reset);
setActivating("");
}
});
@ -188,6 +197,11 @@ const ProfilePage = () => {
});
});
const onCopyLink = async () => {
const text = await navigator.clipboard.readText();
if (text) setUrl(text);
};
return (
<BasePage
title={t("Profiles")}
@ -234,6 +248,28 @@ const ProfilePage = () => {
onChange={(e) => setUrl(e.target.value)}
sx={{ input: { py: 0.65, px: 1.25 } }}
placeholder={t("Profile URL")}
InputProps={{
sx: { pr: 1 },
endAdornment: !url ? (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t("Paste")}
onClick={onCopyLink}
>
<ContentCopyRounded fontSize="inherit" />
</IconButton>
) : (
<IconButton
size="small"
sx={{ p: 0.5 }}
title={t("Clear")}
onClick={() => setUrl("")}
>
<ClearRounded fontSize="inherit" />
</IconButton>
),
}}
/>
<Button
disabled={!url || disabled}
@ -258,6 +294,7 @@ const ProfilePage = () => {
<Grid item xs={12} sm={6} md={4} lg={3} key={item.file}>
<ProfileItem
selected={profiles.current === item.uid}
activating={activating === item.uid}
itemData={item}
onSelect={(f) => onSelect(item.uid, f)}
onEdit={() => viewerRef.current?.edit(item)}

View file

@ -2,7 +2,7 @@ import useSWR from "swr";
import { useEffect, useMemo } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { Button, ButtonGroup, Paper } from "@mui/material";
import { Box, Button, ButtonGroup, Paper } from "@mui/material";
import {
closeAllConnections,
getClashConfig,
@ -12,6 +12,7 @@ import { patchClashConfig } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { BasePage } from "@/components/base";
import { ProxyGroups } from "@/components/proxy/proxy-groups";
import { ProviderButton } from "@/components/proxy/provider-button";
const ProxyPage = () => {
const { t } = useTranslation();
@ -30,7 +31,7 @@ const ProxyPage = () => {
return ["rule", "global", "direct", "script"];
}, [verge?.clash_core]);
const curMode = clashConfig?.mode.toLowerCase();
const curMode = clashConfig?.mode?.toLowerCase();
const onChangeMode = useLockFn(async (mode: string) => {
// 断开连接
@ -53,18 +54,22 @@ const ProxyPage = () => {
contentStyle={{ height: "100%" }}
title={t("Proxy Groups")}
header={
<ButtonGroup size="small">
{modeList.map((mode) => (
<Button
key={mode}
variant={mode === curMode ? "contained" : "outlined"}
onClick={() => onChangeMode(mode)}
sx={{ textTransform: "capitalize" }}
>
{t(mode)}
</Button>
))}
</ButtonGroup>
<Box display="flex" alignItems="center" gap={1}>
<ProviderButton />
<ButtonGroup size="small">
{modeList.map((mode) => (
<Button
key={mode}
variant={mode === curMode ? "contained" : "outlined"}
onClick={() => onChangeMode(mode)}
sx={{ textTransform: "capitalize" }}
>
{t(mode)}
</Button>
))}
</ButtonGroup>
</Box>
}
>
<Paper

View file

@ -1,6 +1,9 @@
import { Paper } from "@mui/material";
import { IconButton, Paper } from "@mui/material";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { BasePage, Notice } from "@/components/base";
import { GitHub } from "@mui/icons-material";
import { openWebUrl } from "@/services/cmds";
import SettingVerge from "@/components/setting/setting-verge";
import SettingClash from "@/components/setting/setting-clash";
import SettingSystem from "@/components/setting/setting-system";
@ -12,8 +15,24 @@ const SettingPage = () => {
Notice.error(err?.message || err.toString());
};
const toGithubRepo = useLockFn(() => {
return openWebUrl("https://github.com/zzzgydi/clash-verge");
});
return (
<BasePage title={t("Settings")}>
<BasePage
title={t("Settings")}
header={
<IconButton
size="small"
color="inherit"
title="@zzzgydi/clash-verge"
onClick={toGithubRepo}
>
<GitHub fontSize="inherit" />
</IconButton>
}
>
<Paper sx={{ borderRadius: 1, boxShadow: 2, mb: 3 }}>
<SettingClash onError={onError} />
</Paper>

View file

@ -65,7 +65,7 @@ export const getRules = async () => {
/// Get Proxy delay
export const getProxyDelay = async (name: string, url?: string) => {
const params = {
timeout: 5000,
timeout: 10000,
url: url || "http://www.gstatic.com/generate_204",
};
const instance = await getAxios();
@ -169,6 +169,11 @@ export const providerHealthCheck = async (name: string) => {
);
};
export const providerUpdate = async (name: string) => {
const instance = await getAxios();
return instance.put(`/providers/proxies/${encodeURIComponent(name)}`);
};
export const getConnections = async () => {
const instance = await getAxios();
const result = await instance.get("/connections");

View file

@ -153,6 +153,11 @@ export async function openWebUrl(url: string) {
return invoke<void>("open_web_url", { url });
}
export async function cmdGetProxyDelay(name: string, url?: string) {
name = encodeURIComponent(name);
return invoke<{ delay: number }>("clash_api_get_proxy_delay", { name, url });
}
/// service mode
export async function checkService() {

View file

@ -1,4 +1,4 @@
import { getProxyDelay } from "./api";
import { cmdGetProxyDelay } from "./cmds";
const hashKey = (name: string, group: string) => `${group ?? ""}::${name}`;
@ -74,7 +74,7 @@ class DelayManager {
try {
const url = this.getUrl(group);
const result = await getProxyDelay(name, url);
const result = await cmdGetProxyDelay(name, url);
delay = result.delay;
} catch {
delay = 1e6; // error
@ -84,7 +84,7 @@ class DelayManager {
return delay;
}
async checkListDelay(nameList: string[], group: string, concurrency = 6) {
async checkListDelay(nameList: string[], group: string, concurrency = 36) {
const names = nameList.filter(Boolean);
// 设置正在延迟测试中
names.forEach((name) => this.setDelay(name, group, -2));
@ -107,6 +107,21 @@ class DelayManager {
for (let i = 0; i < concurrency; ++i) help();
});
}
formatDelay(delay: number) {
if (delay < 0) return "-";
if (delay > 1e5) return "Error";
if (delay >= 10000) return "Timeout"; // 10s
return `${delay}`;
}
formatDelayColor(delay: number) {
if (delay <= 0) return "text.secondary";
if (delay >= 10000) return "error.main";
if (delay > 500) return "warning.main";
if (delay > 100) return "text.secondary";
return "success.main";
}
}
export default new DelayManager();

View file

@ -1,10 +1,12 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "@/locales/en.json";
import ru from "@/locales/ru.json";
import zh from "@/locales/zh.json";
const resources = {
en: { translation: en },
ru: { translation: ru },
zh: { translation: zh },
};

View file

@ -153,11 +153,13 @@ interface IProfilesConfig {
}
interface IVergeConfig {
app_log_level?: "trace" | "debug" | "info" | "warn" | "error" | string;
language?: string;
clash_core?: string;
theme_mode?: "light" | "dark" | "system";
theme_blur?: boolean;
traffic_graph?: boolean;
enable_memory_usage?: boolean;
enable_tun_mode?: boolean;
enable_auto_launch?: boolean;
enable_service_mode?: boolean;
@ -184,6 +186,7 @@ interface IVergeConfig {
default_latency_test?: string;
enable_clash_fields?: boolean;
enable_builtin_enhanced?: boolean;
auto_log_clean?: 0 | 1 | 2 | 3;
proxy_layout_column?: number;
}

View file

@ -43,6 +43,7 @@ export const OTHERS_FIELDS = [
"listeners", // meta
"sub-rules", // meta
"geodata-mode", // meta
"unified-delay", // meta
"tcp-concurrent", // meta
"enable-process", // meta
"find-process-mode", // meta

View file

@ -1,6 +1,7 @@
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const parseTraffic = (num: number) => {
const parseTraffic = (num?: number) => {
if (typeof num !== "number") return ["NaN", ""];
if (num < 1000) return [`${Math.round(num)}`, "B"];
const exp = Math.min(Math.floor(Math.log2(num) / 10), UNITS.length - 1);
const dat = num / Math.pow(1024, exp);

View file

@ -0,0 +1,6 @@
export const truncateStr = (str?: string, prefixLen = 16, maxLen = 56) => {
if (!str || str.length <= maxLen) return str;
return (
str.slice(0, prefixLen) + " ... " + str.slice(-(maxLen - prefixLen - 5))
);
};