Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f77a07b3d | ||
|
|
4cd7ccda57 | ||
|
|
ef337ffb69 | ||
|
|
9ff5d8d4e0 | ||
|
|
199a5eed60 | ||
|
|
e14ddbe23c | ||
|
|
3878e66ea4 | ||
|
|
c14a33444b | ||
|
|
48f7c15035 | ||
|
|
32212a46e2 | ||
|
|
b4025c45da | ||
|
|
bd589c4422 | ||
|
|
f85f7758e6 | ||
|
|
49e36b6e00 | ||
|
|
df6e245e5c | ||
|
|
4e4f9af5b7 | ||
|
|
aa9e63b51a | ||
|
|
82340582b2 | ||
|
|
9d045e24db | ||
|
|
1dcc95d16a | ||
|
|
78c32d51db | ||
|
|
545a2c6688 | ||
|
|
359d9285fe | ||
|
|
1bd51be99c | ||
|
|
ee56080af0 | ||
|
|
18196c4a77 | ||
|
|
14cd3b99cc | ||
|
|
ff75fe47ee | ||
|
|
7e2cc180c3 | ||
|
|
bef9eea87b | ||
|
|
9cd1aef1db | ||
|
|
ab25cf0637 | ||
|
|
32f37720dd | ||
|
|
766cf3aeae | ||
|
|
905353d540 | ||
|
|
38eb3123be | ||
|
|
0183edd450 | ||
|
|
2f640a946e | ||
|
|
ec6c2adf9b | ||
|
|
5a1edc5ffb | ||
|
|
149d482c7d | ||
|
|
c9f784e4fa | ||
|
|
9edafa75e7 | ||
|
|
08baba545d | ||
|
|
60d0b29236 | ||
|
|
f5ee4fb5b5 | ||
|
|
5b7b3be6f9 | ||
|
|
c933560102 | ||
|
|
12a80f35fe | ||
|
|
43f901eeb9 | ||
|
|
e8440e06a1 | ||
|
|
19e9e9d032 | ||
|
|
8ff2d687e4 | ||
|
|
e616e8f9aa | ||
|
|
1fbc67fe98 | ||
|
|
a49e4d8d0c | ||
|
|
fa4ac00504 | ||
|
|
dc066edec4 | ||
|
|
eaed7b2899 | ||
|
|
123ecc3548 | ||
|
|
c857fcf035 | ||
|
|
923f710e53 | ||
|
|
8d8c2ed262 | ||
|
|
8558673a5a | ||
|
|
7372f330a4 | ||
|
|
07c145c661 | ||
|
|
f69e1d2a0c | ||
|
|
0021fc24bb | ||
|
|
82bc1d5da5 | ||
|
|
ffc343b471 | ||
|
|
86e43123b0 | ||
|
|
b558202441 | ||
|
|
83947b6725 | ||
|
|
7e8b65e61f | ||
|
|
cff0ea425c | ||
|
|
f6ba6d0310 | ||
|
|
7b9bf9e552 | ||
|
|
c91ad1e016 | ||
|
|
b1444b8635 | ||
|
|
51a49b94d8 | ||
|
|
720b46d790 | ||
|
|
9089c30d57 | ||
|
|
e2d522803c | ||
|
|
babcb00621 | ||
|
|
901a983150 | ||
|
|
955182b05b | ||
|
|
f4dfe8eeb4 | ||
|
|
93904b8278 | ||
|
|
495580ae2b | ||
|
|
3051004217 | ||
|
|
b854b5e1ac | ||
|
|
cf61a96ef6 | ||
|
|
1293d25e1b | ||
|
|
c1a201f358 | ||
|
|
9ee5390ec7 | ||
|
|
3f1caa702b | ||
|
|
24f9573c05 | ||
|
|
83b25920ea | ||
|
|
834edcd03e | ||
|
|
8f3801e3c2 | ||
|
|
b5f7c58276 | ||
|
|
66ae293ddd | ||
|
|
8e78b9e405 | ||
|
|
9da1759247 | ||
|
|
704c41c0f2 | ||
|
|
1f422afe3d | ||
|
|
709a23cf09 | ||
|
|
bfa3fa293f | ||
|
|
d8b878b1bb | ||
|
|
b2589dbc04 | ||
|
|
2a4b605794 | ||
|
|
a3080a3373 | ||
|
|
b4b9ae5d7f | ||
|
|
bb9a93e462 | ||
|
|
e55fbf675e | ||
|
|
0342477733 | ||
|
|
e6e2b1f142 | ||
|
|
95c23a93cd | ||
|
|
ae784cb985 | ||
|
|
17765bdba9 | ||
|
|
45f791b3c8 | ||
|
|
b0ad39ed0b | ||
|
|
1988aeb945 | ||
|
|
f48a5710aa | ||
|
|
c8d2410c27 | ||
|
|
a9ef32c32f | ||
|
|
19b6f78c8a | ||
|
|
4ab507fd43 | ||
|
|
2662df2547 | ||
|
|
0332415ac9 | ||
|
|
a0f9fb90ee | ||
|
|
1b8c4cb832 | ||
|
|
cfd50d281b | ||
|
|
1667856894 | ||
|
|
c5c76ab539 | ||
|
|
eb060d2e43 | ||
|
|
b5556613cf | ||
|
|
ad1a057edb | ||
|
|
8342f985e5 | ||
|
|
9d32e58915 | ||
|
|
84027cbae1 | ||
|
|
5343040ef3 | ||
|
|
75cd7dd4b9 | ||
|
|
fb6ffd8e09 | ||
|
|
aca9e61f39 | ||
|
|
56078098a4 | ||
|
|
1f197965e0 | ||
|
|
183f0f6c42 | ||
|
|
76624c7d83 | ||
|
|
ab47c56ec5 | ||
|
|
ad35ed96c8 | ||
|
|
97bd51cf5f | ||
|
|
f4a110cfd7 | ||
|
|
e9ed06adfd | ||
|
|
bc5eaf34fe | ||
|
|
3e8b891dd0 | ||
|
|
3f2ea4bc64 | ||
|
|
ae81b37c1d | ||
|
|
b4349afb1b | ||
|
|
0cffe8e3f7 | ||
|
|
7a1f4f9abe | ||
|
|
5768b01786 | ||
|
|
7e9079467f | ||
|
|
615e96922e | ||
|
|
8b94c452fb | ||
|
|
66dd510acc | ||
|
|
96e044566c | ||
|
|
726ad84d7f | ||
|
|
4fc422f880 | ||
|
|
1938024ab3 | ||
|
|
05b1de6fd5 | ||
|
|
0bf8807b50 | ||
|
|
23668ee74e | ||
|
|
6bc5c5a808 | ||
|
|
330004dc72 | ||
|
|
a900d6d742 | ||
|
|
bfea70eec9 | ||
|
|
13babbf330 | ||
|
|
0a6fe382ac | ||
|
|
dd6dea9dfc | ||
|
|
11434fba68 | ||
|
|
10bf90c92a | ||
|
|
5193fe4bab | ||
|
|
4df4995623 | ||
|
|
f5f4ecf46a | ||
|
|
69b65d06a4 | ||
|
|
e6bf4e836d | ||
|
|
01d67eb239 | ||
|
|
3514cfbd44 | ||
|
|
cade35fe10 | ||
|
|
2c6df77f5a | ||
|
|
0276ae91b5 | ||
|
|
60fee2accb | ||
|
|
03e2632294 | ||
|
|
194fe59458 | ||
|
|
e400111f8a | ||
|
|
8bc7607783 |
@@ -1,8 +1,5 @@
|
||||
pnpm install
|
||||
pnpm check $INPUT_TARGET
|
||||
sed -i "s/#openssl/openssl={version=\"0.10\",features=[\"vendored\"]}/g" src-tauri/Cargo.toml
|
||||
if [ "$INPUT_TARGET" = "x86_64-unknown-linux-gnu" ]; then
|
||||
pnpm build --target $INPUT_TARGET
|
||||
else
|
||||
pnpm build --target $INPUT_TARGET -b deb
|
||||
fi
|
||||
|
||||
pnpm build --target $INPUT_TARGET
|
||||
|
||||
@@ -9,6 +9,10 @@ permissions: write-all
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
concurrency:
|
||||
# only allow per workflow per commit (and not pr) to run at a time
|
||||
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
alpha:
|
||||
@@ -42,16 +46,17 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
cache-all-crates: true
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -62,9 +67,16 @@ jobs:
|
||||
- name: Tauri build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: alpha
|
||||
releaseName: "Clash Verge Rev Alpha"
|
||||
@@ -101,6 +113,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
uses: ./.github/build-for-linux
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
with:
|
||||
@@ -114,7 +127,6 @@ jobs:
|
||||
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload Release
|
||||
if: startsWith(matrix.target, 'x86_64')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: alpha
|
||||
@@ -122,17 +134,9 @@ jobs:
|
||||
body: "More new features are now supported."
|
||||
prerelease: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage*
|
||||
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: alpha
|
||||
name: "Clash Verge Rev Alpha"
|
||||
body: "More new features are now supported."
|
||||
prerelease: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
files: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||
|
||||
alpha-for-fixed-webview2:
|
||||
strategy:
|
||||
@@ -160,16 +164,17 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
cache-all-crates: true
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -188,6 +193,7 @@ jobs:
|
||||
id: build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
@@ -245,12 +251,12 @@ jobs:
|
||||
- MacOS apple M芯片: aarch64.dmg
|
||||
|
||||
### Linux
|
||||
- Linux 64位: amd64.AppImage/amd64.deb
|
||||
- Linux 32位: i386.deb
|
||||
- Linux arm64架构: arm64.deb
|
||||
- Linux armv7架构: armhf.deb
|
||||
- Linux 64位: amd64.deb/amd64.rpm
|
||||
- Linux 32位: i386.deb/i386.rpm
|
||||
- Linux arm64架构: arm64.deb/aarch64.rpm
|
||||
- Linux armv7架构: armhf.deb/armhfp.rpm
|
||||
|
||||
### Windows (Win7 用户请查看下面FAQ手动替换内核使用)
|
||||
### Windows (Win7 用户请查看下面FAQ中的解决方案)
|
||||
#### 正常版本(推荐)
|
||||
- 64位: x64-setup.exe
|
||||
- 32位: x86-setup.exe
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Development Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions: write-all
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
concurrency:
|
||||
# only allow per workflow per commit (and not pr) to run at a time
|
||||
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
dev:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
bundle: nsis
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
bundle: dmg
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
bundle: dmg
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@1.77.0
|
||||
|
||||
- name: Add Rust Target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
cache-all-crates: true
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install and check
|
||||
run: |
|
||||
pnpm i
|
||||
pnpm check ${{ matrix.target }}
|
||||
|
||||
- name: Tauri build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tauriScript: pnpm
|
||||
args: --target ${{ matrix.target }} -b ${{ matrix.bundle }}
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
@@ -39,16 +39,16 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
cache-all-crates: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -59,9 +59,16 @@ jobs:
|
||||
- name: Tauri build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: "Clash Verge Rev v__VERSION__"
|
||||
@@ -96,6 +103,7 @@ jobs:
|
||||
- name: Build for Linux
|
||||
uses: ./.github/build-for-linux
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
with:
|
||||
@@ -108,23 +116,15 @@ jobs:
|
||||
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload Release
|
||||
if: startsWith(matrix.target, 'x86_64')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{env.VERSION}}
|
||||
name: "Clash Verge Rev v${{env.VERSION}}"
|
||||
body: "More new features are now supported."
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage*
|
||||
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{env.VERSION}}
|
||||
name: "Clash Verge Rev v${{env.VERSION}}"
|
||||
body: "More new features are now supported."
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
files: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||
|
||||
release-for-fixed-webview2:
|
||||
strategy:
|
||||
@@ -158,10 +158,9 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -180,6 +179,7 @@ jobs:
|
||||
id: build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
@@ -219,10 +219,9 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install
|
||||
@@ -245,10 +244,9 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install
|
||||
@@ -258,3 +256,14 @@ jobs:
|
||||
run: pnpm updater-fixed-webview2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
submit-to-winget:
|
||||
runs-on: windows-latest
|
||||
needs: [release-update]
|
||||
steps:
|
||||
- name: Submit to Winget
|
||||
uses: vedantmgoyal9/winget-releaser@main
|
||||
with:
|
||||
identifier: ClashVergeRev.ClashVergeRev
|
||||
installers-regex: '_(arm64|x64|x86)-setup\.exe$'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -7,17 +7,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install
|
||||
@@ -39,10 +38,9 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Pnpm install
|
||||
|
||||
@@ -7,4 +7,5 @@ dist-ssr
|
||||
update.json
|
||||
scripts/_env.sh
|
||||
.vscode
|
||||
.tool-versions
|
||||
.tool-versions
|
||||
.idea
|
||||
|
||||
@@ -1,4 +1,190 @@
|
||||
## v1.6.3
|
||||
## v1.7.4
|
||||
|
||||
### Features
|
||||
|
||||
- 展示局域网 IP 地址信息
|
||||
- 在设置页面直接复制环境变量
|
||||
- 优化服务模式安装逻辑
|
||||
|
||||
### Performance
|
||||
|
||||
- 优化切换订阅速度
|
||||
- 优化更改端口速度
|
||||
|
||||
### Bugs Fixes
|
||||
|
||||
- 调整 MacOS 托盘图标大小
|
||||
- Trojan URI 解析错误
|
||||
- 卡片拖动显示层级错误
|
||||
- 代理绕过格式检查错误
|
||||
- MacOS 下编辑器最大化失败
|
||||
- MacOS 服务安装失败
|
||||
- 更改窗口大小导致闪退的问题
|
||||
|
||||
---
|
||||
|
||||
## v1.7.3
|
||||
|
||||
### Features
|
||||
|
||||
- 支持可视化编辑订阅代理组
|
||||
- 支持可视化编辑订阅节点
|
||||
- 支持可视化编辑订阅规则
|
||||
- 扩展脚本支持订阅名称参数 `function main(config, profileName)`
|
||||
|
||||
### Bugs Fixes
|
||||
|
||||
- 代理绕过格式检查错误
|
||||
|
||||
---
|
||||
|
||||
## v1.7.2
|
||||
|
||||
### Break Changes
|
||||
|
||||
- 更新后请务必重新导入所有订阅,包括 Remote 和 Local
|
||||
- 此版本重构了 Merge/Script,更新前请先备份好自定义 Merge 和 Script(更新并不会删除配置文件,但是旧版 Merge 和 Script 在更新后无法从前端访问,备份以防万一)
|
||||
- Merge 改名为 `扩展配置`,分为 `全局扩展配置` 和 `订阅扩展配置`,全局扩展配置对所有订阅生效,订阅扩展配置只对关联的订阅生效
|
||||
- Script 改名为 `扩展脚本`,同样分为 `全局扩展脚本` 和 `订阅扩展脚本`
|
||||
- 订阅扩展配置在订阅右键菜单里进入
|
||||
- 执行优先级为: 全局扩展配置 -> 全局扩展脚本 -> 订阅扩展配置 ->订阅扩展脚本
|
||||
- 扩展配置删除了 `prepend/append` 能力,请使用 右键订阅 -> `编辑规则`/`编辑节点`/`编辑代理组` 来代替
|
||||
- MacOS 用户更新后请重新安装服务模式
|
||||
|
||||
### Features
|
||||
|
||||
- 升级内核到 1.18.6
|
||||
- 移除内核授权,改为服务模式实现
|
||||
- 自动填充本地订阅名称
|
||||
- 添加重大更新处理逻辑
|
||||
- 订阅单独指定扩展配置/脚本(需要重新导入订阅)
|
||||
- 添加可视化规则编辑器(需要重新导入订阅)
|
||||
- 编辑器新增工具栏按钮(格式化、最大化/最小化)
|
||||
- WEBUI 使用最新版 metacubex,并解决无法自动登陆问问题
|
||||
- 禁用部分 Webview2 快捷键
|
||||
- 热键配置新增连接符 + 号
|
||||
- 新增部分悬浮提示按钮,用于解释说明
|
||||
- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字)
|
||||
- 设置页面右上角新增 TG 频道链接
|
||||
- 各种细节优化和界面性能优化
|
||||
|
||||
### Bugs Fixes
|
||||
|
||||
- 修复代理绕过格式检查
|
||||
- 通过进程名称关闭进程
|
||||
- 退出软件时恢复 DNS 设置
|
||||
- 修复创建本地订阅时更新间隔无法保存
|
||||
- 连接页面列宽无法调整
|
||||
|
||||
---
|
||||
|
||||
## v1.7.1
|
||||
|
||||
### Break Changes
|
||||
|
||||
- 更新后请务必重新导入所有订阅,包括 Remote 和 Local
|
||||
- 此版本重构了 Merge/Script,更新前请先备份好自定义 Merge 和 Script(更新并不会删除配置文件,但是旧版 Merge 和 Script 在更新后无法从前端访问,备份以防万一)
|
||||
- Merge 改名为 `扩展配置`,分为 `全局扩展配置` 和 `订阅扩展配置`,全局扩展配置对所有订阅生效,订阅扩展配置只对关联的订阅生效
|
||||
- Script 改名为 `扩展脚本`,同样分为 `全局扩展脚本` 和 `订阅扩展脚本`
|
||||
- 订阅扩展配置在订阅右键菜单里进入
|
||||
- 执行优先级为: 全局扩展配置 -> 全局扩展脚本 -> 订阅扩展配置 ->订阅扩展脚本
|
||||
- 扩展配置删除了 `prepend/append` 能力,请使用 右键订阅 -> `编辑规则`/`编辑节点`/`编辑代理组` 来代替
|
||||
- MacOS 用户更新后请重新安装服务模式
|
||||
|
||||
### Features
|
||||
|
||||
- 升级内核到 1.18.6
|
||||
- 移除内核授权,改为服务模式实现
|
||||
- 自动填充本地订阅名称
|
||||
- 添加重大更新处理逻辑
|
||||
- 订阅单独指定扩展配置/脚本(需要重新导入订阅)
|
||||
- 添加可视化规则编辑器(需要重新导入订阅)
|
||||
- 编辑器新增工具栏按钮(格式化、最大化/最小化)
|
||||
- WEBUI 使用最新版 metacubex,并解决无法自动登陆问问题
|
||||
- 禁用部分 Webview2 快捷键
|
||||
- 热键配置新增连接符 + 号
|
||||
- 新增部分悬浮提示按钮,用于解释说明
|
||||
- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字)
|
||||
- 设置页面右上角新增 TG 频道链接
|
||||
- 各种细节优化和界面性能优化
|
||||
|
||||
### Bugs Fixes
|
||||
|
||||
- 修复代理绕过格式检查
|
||||
- 通过进程名称关闭进程
|
||||
- 退出软件时恢复 DNS 设置
|
||||
- 修复创建本地订阅时更新间隔无法保存
|
||||
- 连接页面列宽无法调整
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0
|
||||
|
||||
### Break Changes
|
||||
|
||||
- 此版本重构了 Merge/Script,更新前请先备份好自定义 Merge 和 Script(更新并不会删除配置文件,但是旧版 Merge 和 Script 在更新后无法从前端访问,备份以防万一)
|
||||
- Merge 改名为 `扩展配置`,分为 `全局扩展配置` 和 `订阅扩展配置`,全局扩展配置对所有订阅生效,订阅扩展配置只对关联的订阅生效
|
||||
- Script 改名为 `扩展脚本`,同样分为 `全局扩展脚本` 和 `订阅扩展脚本`
|
||||
- 执行优先级为: 全局扩展配置 -> 全局扩展脚本 -> 订阅扩展配置 ->订阅扩展脚本
|
||||
- MacOS 用户更新后请重新安装服务模式
|
||||
|
||||
### Features
|
||||
|
||||
- 移除内核授权,改为服务模式实现
|
||||
- 自动填充本地订阅名称
|
||||
- 添加重大更新处理逻辑
|
||||
- 订阅单独指定扩展配置/脚本(需要重新导入订阅)
|
||||
- 添加可视化规则编辑器(需要重新导入订阅)
|
||||
- 编辑器新增工具栏按钮(格式化、最大化/最小化)
|
||||
- WEBUI 使用最新版 metacubex,并解决无法自动登陆问问题
|
||||
- 禁用部分 Webview2 快捷键
|
||||
- 热键配置新增连接符 + 号
|
||||
- 新增部分悬浮提示按钮,用于解释说明
|
||||
- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字)
|
||||
- 设置页面右上角新增 TG 频道链接
|
||||
|
||||
### Bugs Fixes
|
||||
|
||||
- 修复代理绕过格式检查
|
||||
- 通过进程名称关闭进程
|
||||
- 退出软件时恢复 DNS 设置
|
||||
- 修复创建本地订阅时更新间隔无法保存
|
||||
- 连接页面列宽无法调整
|
||||
|
||||
---
|
||||
|
||||
## v1.6.6
|
||||
|
||||
### Features
|
||||
|
||||
- MacOS 应用签名
|
||||
- 删除 AppImage
|
||||
- 应用更新对话框添加下载按钮
|
||||
- 设置系统代理绕过时保留默认值
|
||||
- 系统代理绕过设置输入格式检查
|
||||
|
||||
### Bugs Fixes
|
||||
|
||||
- MacOS 代理组图标无法显示
|
||||
- RPM 包依赖缺失
|
||||
|
||||
---
|
||||
|
||||
## v1.6.5
|
||||
|
||||
### Features
|
||||
|
||||
- 添加 RPM 包支持
|
||||
- 优化细节
|
||||
|
||||
### Bugs Fixes
|
||||
|
||||
- MacOS 10.15 编辑器空白的问题
|
||||
- MacOS 低版本启动白屏的问题
|
||||
|
||||
---
|
||||
|
||||
## v1.6.4
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "1.6.3",
|
||||
"version": "1.7.4",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
@@ -24,47 +24,53 @@
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@mui/icons-material": "^5.15.16",
|
||||
"@mui/icons-material": "^5.16.0",
|
||||
"@mui/lab": "5.0.0-alpha.149",
|
||||
"@mui/material": "^5.15.16",
|
||||
"@mui/x-data-grid": "^6.19.11",
|
||||
"@tauri-apps/api": "^1.5.4",
|
||||
"@mui/material": "^5.16.0",
|
||||
"@mui/x-data-grid": "^7.9.0",
|
||||
"@tauri-apps/api": "^1.6.0",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ahooks": "^3.7.11",
|
||||
"axios": "^1.6.8",
|
||||
"axios-tauri-api-adapter": "^0.2.1",
|
||||
"ahooks": "^3.8.0",
|
||||
"axios": "^1.7.2",
|
||||
"dayjs": "1.11.5",
|
||||
"i18next": "^23.11.3",
|
||||
"foxact": "^0.2.35",
|
||||
"i18next": "^23.11.5",
|
||||
"js-base64": "^3.7.7",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"meta-json-schema": "1.18.5-alpha",
|
||||
"monaco-editor": "^0.48.0",
|
||||
"monaco-yaml": "^5.1.1",
|
||||
"types-pac": "^1.0.2",
|
||||
"meta-json-schema": "1.18.6",
|
||||
"monaco-editor": "^0.49.0",
|
||||
"monaco-yaml": "^5.2.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"peggy": "^4.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-i18next": "^13.5.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"react-monaco-editor": "^0.55.0",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-virtuoso": "^4.7.10",
|
||||
"recoil": "^0.7.7",
|
||||
"swr": "^1.3.0",
|
||||
"tar": "^6.2.1"
|
||||
"react-virtuoso": "^4.7.11",
|
||||
"sockette": "^2.0.6",
|
||||
"swr": "^2.2.5",
|
||||
"tar": "^6.2.1",
|
||||
"types-pac": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/github": "^5.1.1",
|
||||
"@tauri-apps/cli": "^1.5.13",
|
||||
"@tauri-apps/cli": "^1.6.0",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-transition-group": "^4.4.10",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"adm-zip": "^0.5.12",
|
||||
"@vitejs/plugin-legacy": "^5.4.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"adm-zip": "^0.5.14",
|
||||
"cross-env": "^7.0.3",
|
||||
"fs-extra": "^11.2.0",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
@@ -72,9 +78,10 @@
|
||||
"node-fetch": "^3.3.2",
|
||||
"prettier": "^2.8.8",
|
||||
"pretty-quick": "^3.3.1",
|
||||
"sass": "^1.77.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"sass": "^1.77.6",
|
||||
"terser": "^5.31.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
},
|
||||
@@ -83,5 +90,6 @@
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@9.1.4"
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ function clashMetaAlpha() {
|
||||
const zipFile = `${name}-${META_ALPHA_VERSION}.${urlExt}`;
|
||||
|
||||
return {
|
||||
name: "clash-meta-alpha",
|
||||
targetFile: `clash-meta-alpha-${SIDECAR_HOST}${isWin ? ".exe" : ""}`,
|
||||
name: "verge-mihomo-alpha",
|
||||
targetFile: `verge-mihomo-alpha-${SIDECAR_HOST}${isWin ? ".exe" : ""}`,
|
||||
exeFile,
|
||||
zipFile,
|
||||
downloadURL,
|
||||
@@ -189,8 +189,8 @@ function clashMeta() {
|
||||
const zipFile = `${name}-${META_VERSION}.${urlExt}`;
|
||||
|
||||
return {
|
||||
name: "clash-meta",
|
||||
targetFile: `clash-meta-${SIDECAR_HOST}${isWin ? ".exe" : ""}`,
|
||||
name: "verge-mihomo",
|
||||
targetFile: `verge-mihomo-${SIDECAR_HOST}${isWin ? ".exe" : ""}`,
|
||||
exeFile,
|
||||
zipFile,
|
||||
downloadURL,
|
||||
@@ -400,16 +400,6 @@ const resolveUninstall = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveSetDnsScript = () =>
|
||||
resolveResource({
|
||||
file: "set_dns.sh",
|
||||
downloadURL: `https://github.com/clash-verge-rev/set-dns-script/releases/download/script/set_dns.sh`,
|
||||
});
|
||||
const resolveUnSetDnsScript = () =>
|
||||
resolveResource({
|
||||
file: "unset_dns.sh",
|
||||
downloadURL: `https://github.com/clash-verge-rev/set-dns-script/releases/download/script/unset_dns.sh`,
|
||||
});
|
||||
const resolveMmdb = () =>
|
||||
resolveResource({
|
||||
file: "Country.mmdb",
|
||||
@@ -434,13 +424,13 @@ const resolveEnableLoopback = () =>
|
||||
const tasks = [
|
||||
// { name: "clash", func: resolveClash, retry: 5 },
|
||||
{
|
||||
name: "clash-meta-alpha",
|
||||
name: "verge-mihomo-alpha",
|
||||
func: () =>
|
||||
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
|
||||
retry: 5,
|
||||
},
|
||||
{
|
||||
name: "clash-meta",
|
||||
name: "verge-mihomo",
|
||||
func: () =>
|
||||
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
|
||||
retry: 5,
|
||||
@@ -449,8 +439,6 @@ const tasks = [
|
||||
{ name: "service", func: resolveService, retry: 5 },
|
||||
{ name: "install", func: resolveInstall, retry: 5 },
|
||||
{ name: "uninstall", func: resolveUninstall, retry: 5 },
|
||||
{ name: "set_dns_script", func: resolveSetDnsScript, retry: 5 },
|
||||
{ name: "unset_dns_script", func: resolveUnSetDnsScript, retry: 5 },
|
||||
{ name: "mmdb", func: resolveMmdb, retry: 5 },
|
||||
{ name: "geosite", func: resolveGeosite, retry: 5 },
|
||||
{ name: "geoip", func: resolveGeoIP, retry: 5 },
|
||||
|
||||
@@ -40,8 +40,8 @@ async function resolvePortable() {
|
||||
const zip = new AdmZip();
|
||||
|
||||
zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "clash-meta.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "clash-meta-alpha.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe"));
|
||||
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources");
|
||||
zip.addLocalFolder(
|
||||
path.join(
|
||||
|
||||
@@ -39,8 +39,8 @@ async function resolvePortable() {
|
||||
const zip = new AdmZip();
|
||||
|
||||
zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "clash-meta.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "clash-meta-alpha.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe"));
|
||||
zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe"));
|
||||
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources");
|
||||
zip.addLocalFolder(configDir, ".config");
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ async function resolveUpdater() {
|
||||
"windows-x86_64": { signature: "", url: "" },
|
||||
"windows-aarch64": { signature: "", url: "" },
|
||||
"windows-x86": { signature: "", url: "" },
|
||||
"windows-i686": { signature: "", url: "" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,11 +61,13 @@ async function resolveUpdater() {
|
||||
// win32 url
|
||||
if (name.endsWith("x86_fixed_webview2-setup.nsis.zip")) {
|
||||
updateData.platforms["windows-x86"].url = browser_download_url;
|
||||
updateData.platforms["windows-i686"].url = browser_download_url;
|
||||
}
|
||||
// win32 signature
|
||||
if (name.endsWith("x86_fixed_webview2-setup.nsis.zip.sig")) {
|
||||
const sig = await getSignature(browser_download_url);
|
||||
updateData.platforms["windows-x86"].signature = sig;
|
||||
updateData.platforms["windows-i686"].signature = sig;
|
||||
}
|
||||
|
||||
// win arm url
|
||||
|
||||
@@ -46,11 +46,13 @@ async function resolveUpdater() {
|
||||
"darwin-x86_64": { signature: "", url: "" },
|
||||
"linux-x86_64": { signature: "", url: "" },
|
||||
"linux-x86": { signature: "", url: "" },
|
||||
"linux-i686": { signature: "", url: "" },
|
||||
"linux-aarch64": { signature: "", url: "" },
|
||||
"linux-armv7": { signature: "", url: "" },
|
||||
"windows-x86_64": { signature: "", url: "" },
|
||||
"windows-aarch64": { signature: "", url: "" },
|
||||
"windows-x86": { signature: "", url: "" },
|
||||
"windows-i686": { signature: "", url: "" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,11 +74,13 @@ async function resolveUpdater() {
|
||||
// win32 url
|
||||
if (name.endsWith("x64-setup.nsis.zip")) {
|
||||
updateData.platforms["windows-x86"].url = browser_download_url;
|
||||
updateData.platforms["windows-i686"].url = browser_download_url;
|
||||
}
|
||||
// win32 signature
|
||||
if (name.endsWith("x64-setup.nsis.zip.sig")) {
|
||||
const sig = await getSignature(browser_download_url);
|
||||
updateData.platforms["windows-x86"].signature = sig;
|
||||
updateData.platforms["windows-i686"].signature = sig;
|
||||
}
|
||||
|
||||
// win arm url
|
||||
@@ -106,29 +110,22 @@ async function resolveUpdater() {
|
||||
// darwin url (aarch)
|
||||
if (name.endsWith("aarch64.app.tar.gz")) {
|
||||
updateData.platforms["darwin-aarch64"].url = browser_download_url;
|
||||
// 使linux可以检查更新
|
||||
updateData.platforms.linux.url = browser_download_url;
|
||||
updateData.platforms["linux-x86_64"].url = browser_download_url;
|
||||
updateData.platforms["linux-x86"].url = browser_download_url;
|
||||
updateData.platforms["linux-i686"].url = browser_download_url;
|
||||
updateData.platforms["linux-aarch64"].url = browser_download_url;
|
||||
updateData.platforms["linux-armv7"].url = browser_download_url;
|
||||
}
|
||||
// darwin signature (aarch)
|
||||
if (name.endsWith("aarch64.app.tar.gz.sig")) {
|
||||
const sig = await getSignature(browser_download_url);
|
||||
updateData.platforms["darwin-aarch64"].signature = sig;
|
||||
}
|
||||
|
||||
// linux x64 url
|
||||
if (name.endsWith("amd64.AppImage.tar.gz")) {
|
||||
updateData.platforms.linux.url = browser_download_url;
|
||||
updateData.platforms["linux-x86_64"].url = browser_download_url;
|
||||
updateData.platforms["linux-x86"].url = browser_download_url;
|
||||
// 暂时使用x64版本的url和sig,使得可以检查更新,但aarch64版本还不支持构建appimage
|
||||
updateData.platforms["linux-aarch64"].url = browser_download_url;
|
||||
updateData.platforms["linux-armv7"].url = browser_download_url;
|
||||
}
|
||||
// linux x64 signature
|
||||
if (name.endsWith("amd64.AppImage.tar.gz.sig")) {
|
||||
const sig = await getSignature(browser_download_url);
|
||||
updateData.platforms.linux.signature = sig;
|
||||
updateData.platforms["linux-x86_64"].signature = sig;
|
||||
updateData.platforms["linux-x86"].url = browser_download_url;
|
||||
// 暂时使用x64版本的url和sig,使得可以检查更新,但aarch64版本还不支持构建appimage
|
||||
updateData.platforms["linux-i686"].url = browser_download_url;
|
||||
updateData.platforms["linux-aarch64"].signature = sig;
|
||||
updateData.platforms["linux-armv7"].signature = sig;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "1.6.3"
|
||||
version = "1.7.4"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -10,13 +10,13 @@ edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1", features = [] }
|
||||
tauri-build = { version="1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
warp = "0.3"
|
||||
anyhow = "1.0"
|
||||
dirs = "5.0"
|
||||
open = "5.0"
|
||||
open = "5.1"
|
||||
log = "0.4"
|
||||
dunce = "1.0"
|
||||
log4rs = "1"
|
||||
@@ -26,19 +26,19 @@ sysinfo = "0.30"
|
||||
boa_engine = "0.18"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
once_cell = "1.18"
|
||||
once_cell = "1.19"
|
||||
port_scanner = "0.1.5"
|
||||
delay_timer = "0.11.5"
|
||||
delay_timer = "0.11"
|
||||
parking_lot = "0.12"
|
||||
auto-launch = "0.5.0"
|
||||
percent-encoding = "2.3.1"
|
||||
window-shadows = { version = "0.2" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||
sysproxy = { git="https://github.com/zzzgydi/sysproxy-rs", branch = "main" }
|
||||
auto-launch = { git="https://github.com/zzzgydi/auto-launch", branch = "main" }
|
||||
tauri = { version = "1.6", features = [ "http-all", "fs-read-file", "fs-exists", "path-all", "protocol-asset", "dialog-open", "notification-all", "icon-png", "icon-ico", "clipboard-all", "global-shortcut-all", "process-all", "shell-all", "system-tray", "updater", "window-all", "devtools"] }
|
||||
|
||||
tauri = { version="1", features = [ "fs-read-file", "fs-exists", "path-all", "protocol-asset", "dialog-open", "notification-all", "icon-png", "icon-ico", "clipboard-all", "global-shortcut-all", "process-all", "shell-all", "system-tray", "updater", "window-all", "devtools"] }
|
||||
network-interface = { version = "2.0.0", features = ["serde"] }
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
runas = "=1.2.0"
|
||||
deelevate = "0.2.0"
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -6,12 +6,19 @@ use crate::{
|
||||
};
|
||||
use crate::{ret_err, wrap_err};
|
||||
use anyhow::{Context, Result};
|
||||
use network_interface::NetworkInterface;
|
||||
use serde_yaml::Mapping;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use sysproxy::{Autoproxy, Sysproxy};
|
||||
use tauri::{api, Manager};
|
||||
type CmdResult<T = ()> = Result<T, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_clash_env(app_handle: tauri::AppHandle) -> CmdResult {
|
||||
feat::copy_clash_env(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_profiles() -> CmdResult<IProfiles> {
|
||||
Ok(Config::profiles().data().clone())
|
||||
@@ -181,15 +188,6 @@ pub async fn restart_sidecar() -> CmdResult {
|
||||
wrap_err!(CoreManager::global().run_core().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn grant_permission(_core: String) -> CmdResult {
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
return wrap_err!(manager::grant_permission(_core));
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
return Err("Unsupported target".into());
|
||||
}
|
||||
|
||||
/// get the system proxy
|
||||
#[tauri::command]
|
||||
pub fn get_sys_proxy() -> CmdResult<Mapping> {
|
||||
@@ -327,10 +325,40 @@ pub fn copy_icon_file(path: String, name: String) -> CmdResult<String> {
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
} else {
|
||||
return Err("file not found".to_string());
|
||||
Err("file not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_network_interfaces() -> Vec<String> {
|
||||
use sysinfo::Networks;
|
||||
let mut result = Vec::new();
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
for (interface_name, _) in &networks {
|
||||
result.push(interface_name.clone());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
|
||||
use network_interface::NetworkInterface;
|
||||
use network_interface::NetworkInterfaceConfig;
|
||||
|
||||
let names = get_network_interfaces();
|
||||
let interfaces = wrap_err!(NetworkInterface::show())?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for interface in interfaces {
|
||||
if names.contains(&interface.name) {
|
||||
result.push(interface);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_devtools(app_handle: tauri::AppHandle) {
|
||||
if let Some(window) = app_handle.get_window("main") {
|
||||
|
||||
@@ -13,7 +13,7 @@ pub struct IClashTemp(pub Mapping);
|
||||
impl IClashTemp {
|
||||
pub fn new() -> Self {
|
||||
let template = Self::template();
|
||||
match dirs::clash_path().and_then(|path| help::read_merge_mapping(&path)) {
|
||||
match dirs::clash_path().and_then(|path| help::read_mapping(&path)) {
|
||||
Ok(mut map) => {
|
||||
template.0.keys().for_each(|key| {
|
||||
if !map.contains_key(key) {
|
||||
@@ -33,7 +33,7 @@ impl IClashTemp {
|
||||
let mut map = Mapping::new();
|
||||
let mut tun = Mapping::new();
|
||||
tun.insert("stack".into(), "gvisor".into());
|
||||
tun.insert("device".into(), "Meta".into());
|
||||
tun.insert("device".into(), "Mihomo".into());
|
||||
tun.insert("auto-route".into(), true.into());
|
||||
tun.insert("strict-route".into(), false.into());
|
||||
tun.insert("auto-detect-interface".into(), true.into());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::{Draft, IClashTemp, IProfiles, IRuntime, IVerge};
|
||||
use crate::{
|
||||
config::PrfItem,
|
||||
enhance,
|
||||
utils::{dirs, help},
|
||||
};
|
||||
@@ -46,8 +47,24 @@ impl Config {
|
||||
}
|
||||
|
||||
/// 初始化订阅
|
||||
pub fn init_config() -> Result<()> {
|
||||
crate::log_err!(Self::generate());
|
||||
pub async fn init_config() -> Result<()> {
|
||||
if Self::profiles()
|
||||
.data()
|
||||
.get_item(&"Merge".to_string())
|
||||
.is_err()
|
||||
{
|
||||
let merge_item = PrfItem::from_merge(Some("Merge".to_string()))?;
|
||||
Self::profiles().data().append_item(merge_item.clone())?;
|
||||
}
|
||||
if Self::profiles()
|
||||
.data()
|
||||
.get_item(&"Script".to_string())
|
||||
.is_err()
|
||||
{
|
||||
let script_item = PrfItem::from_script(Some("Script".to_string()))?;
|
||||
Self::profiles().data().append_item(script_item.clone())?;
|
||||
}
|
||||
crate::log_err!(Self::generate().await);
|
||||
if let Err(err) = Self::generate_file(ConfigType::Run) {
|
||||
log::error!(target: "app", "{err}");
|
||||
|
||||
@@ -83,8 +100,8 @@ impl Config {
|
||||
}
|
||||
|
||||
/// 生成订阅存好
|
||||
pub fn generate() -> Result<()> {
|
||||
let (config, exists_keys, logs) = enhance::enhance();
|
||||
pub async fn generate() -> Result<()> {
|
||||
let (config, exists_keys, logs) = enhance::enhance().await;
|
||||
|
||||
*Config::runtime().draft() = IRuntime {
|
||||
config: Some(config),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod clash;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod config;
|
||||
mod draft;
|
||||
mod prfitem;
|
||||
|
||||
@@ -94,6 +94,16 @@ pub struct PrfOption {
|
||||
/// default is `false`
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub danger_accept_invalid_certs: Option<bool>,
|
||||
|
||||
pub merge: Option<String>,
|
||||
|
||||
pub script: Option<String>,
|
||||
|
||||
pub rules: Option<String>,
|
||||
|
||||
pub proxies: Option<String>,
|
||||
|
||||
pub groups: Option<String>,
|
||||
}
|
||||
|
||||
impl PrfOption {
|
||||
@@ -103,8 +113,15 @@ impl PrfOption {
|
||||
a.user_agent = b.user_agent.or(a.user_agent);
|
||||
a.with_proxy = b.with_proxy.or(a.with_proxy);
|
||||
a.self_proxy = b.self_proxy.or(a.self_proxy);
|
||||
a.danger_accept_invalid_certs = b.danger_accept_invalid_certs.or(a.danger_accept_invalid_certs);
|
||||
a.danger_accept_invalid_certs = b
|
||||
.danger_accept_invalid_certs
|
||||
.or(a.danger_accept_invalid_certs);
|
||||
a.update_interval = b.update_interval.or(a.update_interval);
|
||||
a.merge = b.merge.or(a.merge);
|
||||
a.script = b.script.or(a.script);
|
||||
a.rules = b.rules.or(a.rules);
|
||||
a.proxies = b.proxies.or(a.proxies);
|
||||
a.groups = b.groups.or(a.groups);
|
||||
Some(a)
|
||||
}
|
||||
t => t.0.or(t.1),
|
||||
@@ -133,17 +150,7 @@ impl PrfItem {
|
||||
"local" => {
|
||||
let name = item.name.unwrap_or("Local File".into());
|
||||
let desc = item.desc.unwrap_or("".into());
|
||||
PrfItem::from_local(name, desc, file_data)
|
||||
}
|
||||
"merge" => {
|
||||
let name = item.name.unwrap_or("Merge".into());
|
||||
let desc = item.desc.unwrap_or("".into());
|
||||
PrfItem::from_merge(name, desc)
|
||||
}
|
||||
"script" => {
|
||||
let name = item.name.unwrap_or("Script".into());
|
||||
let desc = item.desc.unwrap_or("".into());
|
||||
PrfItem::from_script(name, desc)
|
||||
PrfItem::from_local(name, desc, file_data, item.option)
|
||||
}
|
||||
typ => bail!("invalid profile item type \"{typ}\""),
|
||||
}
|
||||
@@ -151,10 +158,49 @@ impl PrfItem {
|
||||
|
||||
/// ## Local type
|
||||
/// create a new item from name/desc
|
||||
pub fn from_local(name: String, desc: String, file_data: Option<String>) -> Result<PrfItem> {
|
||||
let uid = help::get_uid("l");
|
||||
pub fn from_local(
|
||||
name: String,
|
||||
desc: String,
|
||||
file_data: Option<String>,
|
||||
option: Option<PrfOption>,
|
||||
) -> Result<PrfItem> {
|
||||
let uid = help::get_uid("L");
|
||||
let file = format!("{uid}.yaml");
|
||||
let opt_ref = option.as_ref();
|
||||
let update_interval = opt_ref.and_then(|o| o.update_interval);
|
||||
let mut merge = opt_ref.and_then(|o| o.merge.clone());
|
||||
let mut script = opt_ref.and_then(|o| o.script.clone());
|
||||
let mut rules = opt_ref.and_then(|o| o.rules.clone());
|
||||
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
|
||||
let mut groups = opt_ref.and_then(|o| o.groups.clone());
|
||||
|
||||
if merge.is_none() {
|
||||
let merge_item = PrfItem::from_merge(None)?;
|
||||
Config::profiles().data().append_item(merge_item.clone())?;
|
||||
merge = merge_item.uid;
|
||||
}
|
||||
if script.is_none() {
|
||||
let script_item = PrfItem::from_script(None)?;
|
||||
Config::profiles().data().append_item(script_item.clone())?;
|
||||
script = script_item.uid;
|
||||
}
|
||||
if rules.is_none() {
|
||||
let rules_item = PrfItem::from_rules()?;
|
||||
Config::profiles().data().append_item(rules_item.clone())?;
|
||||
rules = rules_item.uid;
|
||||
}
|
||||
if proxies.is_none() {
|
||||
let proxies_item = PrfItem::from_proxies()?;
|
||||
Config::profiles()
|
||||
.data()
|
||||
.append_item(proxies_item.clone())?;
|
||||
proxies = proxies_item.uid;
|
||||
}
|
||||
if groups.is_none() {
|
||||
let groups_item = PrfItem::from_groups()?;
|
||||
Config::profiles().data().append_item(groups_item.clone())?;
|
||||
groups = groups_item.uid;
|
||||
}
|
||||
Ok(PrfItem {
|
||||
uid: Some(uid),
|
||||
itype: Some("local".into()),
|
||||
@@ -164,7 +210,15 @@ impl PrfItem {
|
||||
url: None,
|
||||
selected: None,
|
||||
extra: None,
|
||||
option: None,
|
||||
option: Some(PrfOption {
|
||||
update_interval,
|
||||
merge,
|
||||
script,
|
||||
rules,
|
||||
proxies,
|
||||
groups,
|
||||
..PrfOption::default()
|
||||
}),
|
||||
home: None,
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(file_data.unwrap_or(tmpl::ITEM_LOCAL.into())),
|
||||
@@ -182,10 +236,15 @@ impl PrfItem {
|
||||
let opt_ref = option.as_ref();
|
||||
let with_proxy = opt_ref.map_or(false, |o| o.with_proxy.unwrap_or(false));
|
||||
let self_proxy = opt_ref.map_or(false, |o| o.self_proxy.unwrap_or(false));
|
||||
let accept_invalid_certs = opt_ref.map_or(false, |o| o.danger_accept_invalid_certs.unwrap_or(false));
|
||||
let accept_invalid_certs =
|
||||
opt_ref.map_or(false, |o| o.danger_accept_invalid_certs.unwrap_or(false));
|
||||
let user_agent = opt_ref.and_then(|o| o.user_agent.clone());
|
||||
let update_interval = opt_ref.and_then(|o| o.update_interval);
|
||||
|
||||
let mut merge = opt_ref.and_then(|o| o.merge.clone());
|
||||
let mut script = opt_ref.and_then(|o| o.script.clone());
|
||||
let mut rules = opt_ref.and_then(|o| o.rules.clone());
|
||||
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
|
||||
let mut groups = opt_ref.and_then(|o| o.groups.clone());
|
||||
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
|
||||
|
||||
// 使用软件自己的代理
|
||||
@@ -279,17 +338,11 @@ impl PrfItem {
|
||||
crate::utils::help::get_last_part_and_decode(url).unwrap_or("Remote File".into()),
|
||||
),
|
||||
};
|
||||
let option = match update_interval {
|
||||
Some(val) => Some(PrfOption {
|
||||
update_interval: Some(val),
|
||||
..PrfOption::default()
|
||||
}),
|
||||
let update_interval = match update_interval {
|
||||
Some(val) => Some(val),
|
||||
None => match header.get("profile-update-interval") {
|
||||
Some(value) => match value.to_str().unwrap_or("").parse::<u64>() {
|
||||
Ok(val) => Some(PrfOption {
|
||||
update_interval: Some(val * 60), // hour -> min
|
||||
..PrfOption::default()
|
||||
}),
|
||||
Ok(val) => Some(val * 60), // hour -> min
|
||||
Err(_) => None,
|
||||
},
|
||||
None => None,
|
||||
@@ -300,11 +353,11 @@ impl PrfItem {
|
||||
Some(value) => {
|
||||
let str_value = value.to_str().unwrap_or("");
|
||||
Some(str_value.to_string())
|
||||
},
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let uid = help::get_uid("r");
|
||||
let uid = help::get_uid("R");
|
||||
let file = format!("{uid}.yaml");
|
||||
let name = name.unwrap_or(filename.unwrap_or("Remote File".into()));
|
||||
let data = resp.text_with_charset("utf-8").await?;
|
||||
@@ -320,6 +373,34 @@ impl PrfItem {
|
||||
bail!("profile does not contain `proxies` or `proxy-providers`");
|
||||
}
|
||||
|
||||
if merge.is_none() {
|
||||
let merge_item = PrfItem::from_merge(None)?;
|
||||
Config::profiles().data().append_item(merge_item.clone())?;
|
||||
merge = merge_item.uid;
|
||||
}
|
||||
if script.is_none() {
|
||||
let script_item = PrfItem::from_script(None)?;
|
||||
Config::profiles().data().append_item(script_item.clone())?;
|
||||
script = script_item.uid;
|
||||
}
|
||||
if rules.is_none() {
|
||||
let rules_item = PrfItem::from_rules()?;
|
||||
Config::profiles().data().append_item(rules_item.clone())?;
|
||||
rules = rules_item.uid;
|
||||
}
|
||||
if proxies.is_none() {
|
||||
let proxies_item = PrfItem::from_proxies()?;
|
||||
Config::profiles()
|
||||
.data()
|
||||
.append_item(proxies_item.clone())?;
|
||||
proxies = proxies_item.uid;
|
||||
}
|
||||
if groups.is_none() {
|
||||
let groups_item = PrfItem::from_groups()?;
|
||||
Config::profiles().data().append_item(groups_item.clone())?;
|
||||
groups = groups_item.uid;
|
||||
}
|
||||
|
||||
Ok(PrfItem {
|
||||
uid: Some(uid),
|
||||
itype: Some("remote".into()),
|
||||
@@ -329,7 +410,15 @@ impl PrfItem {
|
||||
url: Some(url.into()),
|
||||
selected: None,
|
||||
extra,
|
||||
option,
|
||||
option: Some(PrfOption {
|
||||
update_interval,
|
||||
merge,
|
||||
script,
|
||||
rules,
|
||||
proxies,
|
||||
groups,
|
||||
..PrfOption::default()
|
||||
}),
|
||||
home,
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(data.into()),
|
||||
@@ -338,15 +427,20 @@ impl PrfItem {
|
||||
|
||||
/// ## Merge type (enhance)
|
||||
/// create the enhanced item by using `merge` rule
|
||||
pub fn from_merge(name: String, desc: String) -> Result<PrfItem> {
|
||||
let uid = help::get_uid("m");
|
||||
let file = format!("{uid}.yaml");
|
||||
pub fn from_merge(uid: Option<String>) -> Result<PrfItem> {
|
||||
let mut id = help::get_uid("m");
|
||||
let mut template = tmpl::ITEM_MERGE_EMPTY.into();
|
||||
if let Some(uid) = uid {
|
||||
id = uid;
|
||||
template = tmpl::ITEM_MERGE.into();
|
||||
}
|
||||
let file = format!("{id}.yaml");
|
||||
|
||||
Ok(PrfItem {
|
||||
uid: Some(uid),
|
||||
uid: Some(id),
|
||||
itype: Some("merge".into()),
|
||||
name: Some(name),
|
||||
desc: Some(desc),
|
||||
name: None,
|
||||
desc: None,
|
||||
file: Some(file),
|
||||
url: None,
|
||||
selected: None,
|
||||
@@ -354,21 +448,24 @@ impl PrfItem {
|
||||
option: None,
|
||||
home: None,
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(tmpl::ITEM_MERGE.into()),
|
||||
file_data: Some(template),
|
||||
})
|
||||
}
|
||||
|
||||
/// ## Script type (enhance)
|
||||
/// create the enhanced item by using javascript quick.js
|
||||
pub fn from_script(name: String, desc: String) -> Result<PrfItem> {
|
||||
let uid = help::get_uid("s");
|
||||
let file = format!("{uid}.js"); // js ext
|
||||
pub fn from_script(uid: Option<String>) -> Result<PrfItem> {
|
||||
let mut id = help::get_uid("s");
|
||||
if let Some(uid) = uid {
|
||||
id = uid;
|
||||
}
|
||||
let file = format!("{id}.js"); // js ext
|
||||
|
||||
Ok(PrfItem {
|
||||
uid: Some(uid),
|
||||
uid: Some(id),
|
||||
itype: Some("script".into()),
|
||||
name: Some(name),
|
||||
desc: Some(desc),
|
||||
name: None,
|
||||
desc: None,
|
||||
file: Some(file),
|
||||
url: None,
|
||||
home: None,
|
||||
@@ -380,6 +477,69 @@ impl PrfItem {
|
||||
})
|
||||
}
|
||||
|
||||
/// ## Rules type (enhance)
|
||||
pub fn from_rules() -> Result<PrfItem> {
|
||||
let uid = help::get_uid("r");
|
||||
let file = format!("{uid}.yaml"); // yaml ext
|
||||
|
||||
Ok(PrfItem {
|
||||
uid: Some(uid),
|
||||
itype: Some("rules".into()),
|
||||
name: None,
|
||||
desc: None,
|
||||
file: Some(file),
|
||||
url: None,
|
||||
home: None,
|
||||
selected: None,
|
||||
extra: None,
|
||||
option: None,
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(tmpl::ITEM_RULES.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// ## Proxies type (enhance)
|
||||
pub fn from_proxies() -> Result<PrfItem> {
|
||||
let uid = help::get_uid("p");
|
||||
let file = format!("{uid}.yaml"); // yaml ext
|
||||
|
||||
Ok(PrfItem {
|
||||
uid: Some(uid),
|
||||
itype: Some("proxies".into()),
|
||||
name: None,
|
||||
desc: None,
|
||||
file: Some(file),
|
||||
url: None,
|
||||
home: None,
|
||||
selected: None,
|
||||
extra: None,
|
||||
option: None,
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(tmpl::ITEM_PROXIES.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// ## Groups type (enhance)
|
||||
pub fn from_groups() -> Result<PrfItem> {
|
||||
let uid = help::get_uid("g");
|
||||
let file = format!("{uid}.yaml"); // yaml ext
|
||||
|
||||
Ok(PrfItem {
|
||||
uid: Some(uid),
|
||||
itype: Some("groups".into()),
|
||||
name: None,
|
||||
desc: None,
|
||||
file: Some(file),
|
||||
url: None,
|
||||
home: None,
|
||||
selected: None,
|
||||
extra: None,
|
||||
option: None,
|
||||
updated: Some(chrono::Local::now().timestamp() as usize),
|
||||
file_data: Some(tmpl::ITEM_GROUPS.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// get the file data
|
||||
pub fn read_file(&self) -> Result<String> {
|
||||
if self.file.is_none() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::prfitem::PrfItem;
|
||||
use super::{prfitem::PrfItem, PrfOption};
|
||||
use crate::utils::{dirs, help};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -11,9 +11,6 @@ pub struct IProfiles {
|
||||
/// same as PrfConfig.current
|
||||
pub current: Option<String>,
|
||||
|
||||
/// same as PrfConfig.chain
|
||||
pub chain: Option<Vec<String>>,
|
||||
|
||||
/// profile list
|
||||
pub items: Option<Vec<PrfItem>>,
|
||||
}
|
||||
@@ -80,10 +77,6 @@ impl IProfiles {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(chain) = patch.chain {
|
||||
self.chain = Some(chain);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -212,6 +205,7 @@ impl IProfiles {
|
||||
each.extra = item.extra;
|
||||
each.updated = item.updated;
|
||||
each.home = item.home;
|
||||
each.option = PrfOption::merge(each.option.clone(), item.option);
|
||||
// save the file data
|
||||
// move the field value after save
|
||||
if let Some(file_data) = item.file_data.take() {
|
||||
@@ -243,9 +237,19 @@ impl IProfiles {
|
||||
pub fn delete_item(&mut self, uid: String) -> Result<bool> {
|
||||
let current = self.current.as_ref().unwrap_or(&uid);
|
||||
let current = current.clone();
|
||||
|
||||
let item = self.get_item(&uid)?;
|
||||
let merge_uid = item.option.as_ref().and_then(|e| e.merge.clone());
|
||||
let script_uid = item.option.as_ref().and_then(|e| e.script.clone());
|
||||
let rules_uid = item.option.as_ref().and_then(|e| e.rules.clone());
|
||||
let proxies_uid = item.option.as_ref().and_then(|e| e.proxies.clone());
|
||||
let groups_uid = item.option.as_ref().and_then(|e| e.groups.clone());
|
||||
let mut items = self.items.take().unwrap_or_default();
|
||||
let mut index = None;
|
||||
let mut merge_index = None;
|
||||
let mut script_index = None;
|
||||
let mut rules_index = None;
|
||||
let mut proxies_index = None;
|
||||
let mut groups_index = None;
|
||||
|
||||
// get the index
|
||||
for (i, _) in items.iter().enumerate() {
|
||||
@@ -254,7 +258,6 @@ impl IProfiles {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(index) = index {
|
||||
if let Some(file) = items.remove(index).file {
|
||||
let _ = dirs::app_profiles_dir().map(|path| {
|
||||
@@ -265,7 +268,91 @@ impl IProfiles {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// get the merge index
|
||||
for (i, _) in items.iter().enumerate() {
|
||||
if items[i].uid == merge_uid {
|
||||
merge_index = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(index) = merge_index {
|
||||
if let Some(file) = items.remove(index).file {
|
||||
let _ = dirs::app_profiles_dir().map(|path| {
|
||||
let path = path.join(file);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// get the script index
|
||||
for (i, _) in items.iter().enumerate() {
|
||||
if items[i].uid == script_uid {
|
||||
script_index = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(index) = script_index {
|
||||
if let Some(file) = items.remove(index).file {
|
||||
let _ = dirs::app_profiles_dir().map(|path| {
|
||||
let path = path.join(file);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// get the rules index
|
||||
for (i, _) in items.iter().enumerate() {
|
||||
if items[i].uid == rules_uid {
|
||||
rules_index = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(index) = rules_index {
|
||||
if let Some(file) = items.remove(index).file {
|
||||
let _ = dirs::app_profiles_dir().map(|path| {
|
||||
let path = path.join(file);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// get the proxies index
|
||||
for (i, _) in items.iter().enumerate() {
|
||||
if items[i].uid == proxies_uid {
|
||||
proxies_index = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(index) = proxies_index {
|
||||
if let Some(file) = items.remove(index).file {
|
||||
let _ = dirs::app_profiles_dir().map(|path| {
|
||||
let path = path.join(file);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// get the groups index
|
||||
for (i, _) in items.iter().enumerate() {
|
||||
if items[i].uid == groups_uid {
|
||||
groups_index = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(index) = groups_index {
|
||||
if let Some(file) = items.remove(index).file {
|
||||
let _ = dirs::app_profiles_dir().map(|path| {
|
||||
let path = path.join(file);
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// delete the original uid
|
||||
if current == uid {
|
||||
self.current = match !items.is_empty() {
|
||||
@@ -288,11 +375,81 @@ impl IProfiles {
|
||||
Some(file) => dirs::app_profiles_dir()?.join(file),
|
||||
None => bail!("failed to get the file field"),
|
||||
};
|
||||
return help::read_merge_mapping(&file_path);
|
||||
return help::read_mapping(&file_path);
|
||||
}
|
||||
bail!("failed to find the current profile \"uid:{current}\"");
|
||||
}
|
||||
_ => Ok(Mapping::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取current指向的订阅的merge
|
||||
pub fn current_merge(&self) -> Option<String> {
|
||||
match (self.current.as_ref(), self.items.as_ref()) {
|
||||
(Some(current), Some(items)) => {
|
||||
if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) {
|
||||
let merge = item.option.as_ref().and_then(|e| e.merge.clone());
|
||||
return merge;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取current指向的订阅的script
|
||||
pub fn current_script(&self) -> Option<String> {
|
||||
match (self.current.as_ref(), self.items.as_ref()) {
|
||||
(Some(current), Some(items)) => {
|
||||
if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) {
|
||||
let script = item.option.as_ref().and_then(|e| e.script.clone());
|
||||
return script;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取current指向的订阅的rules
|
||||
pub fn current_rules(&self) -> Option<String> {
|
||||
match (self.current.as_ref(), self.items.as_ref()) {
|
||||
(Some(current), Some(items)) => {
|
||||
if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) {
|
||||
let rules = item.option.as_ref().and_then(|e| e.rules.clone());
|
||||
return rules;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取current指向的订阅的proxies
|
||||
pub fn current_proxies(&self) -> Option<String> {
|
||||
match (self.current.as_ref(), self.items.as_ref()) {
|
||||
(Some(current), Some(items)) => {
|
||||
if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) {
|
||||
let proxies = item.option.as_ref().and_then(|e| e.proxies.clone());
|
||||
return proxies;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取current指向的订阅的groups
|
||||
pub fn current_groups(&self) -> Option<String> {
|
||||
match (self.current.as_ref(), self.items.as_ref()) {
|
||||
(Some(current), Some(items)) => {
|
||||
if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) {
|
||||
let groups = item.option.as_ref().and_then(|e| e.groups.clone());
|
||||
return groups;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ pub struct IVerge {
|
||||
/// enable proxy guard
|
||||
pub enable_proxy_guard: Option<bool>,
|
||||
|
||||
/// always use default bypass
|
||||
pub use_default_bypass: Option<bool>,
|
||||
|
||||
/// set system proxy bypass
|
||||
pub system_proxy_bypass: Option<String>,
|
||||
|
||||
@@ -198,7 +201,7 @@ impl IVerge {
|
||||
|
||||
pub fn template() -> Self {
|
||||
Self {
|
||||
clash_core: Some("clash-meta".into()),
|
||||
clash_core: Some("verge-mihomo".into()),
|
||||
language: Some("zh".into()),
|
||||
theme_mode: Some("system".into()),
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -224,17 +227,18 @@ impl IVerge {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
verge_redir_port: Some(7895),
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
verge_redir_enabled: Some(true),
|
||||
verge_redir_enabled: Some(false),
|
||||
#[cfg(target_os = "linux")]
|
||||
verge_tproxy_port: Some(7896),
|
||||
#[cfg(target_os = "linux")]
|
||||
verge_tproxy_enabled: Some(true),
|
||||
verge_tproxy_enabled: Some(false),
|
||||
verge_mixed_port: Some(7897),
|
||||
verge_socks_port: Some(7898),
|
||||
verge_socks_enabled: Some(true),
|
||||
verge_socks_enabled: Some(false),
|
||||
verge_port: Some(7899),
|
||||
verge_http_enabled: Some(true),
|
||||
verge_http_enabled: Some(false),
|
||||
enable_proxy_guard: Some(false),
|
||||
use_default_bypass: Some(true),
|
||||
proxy_guard_duration: Some(30),
|
||||
auto_close_connection: Some(true),
|
||||
auto_check_update: Some(true),
|
||||
@@ -297,6 +301,7 @@ impl IVerge {
|
||||
patch!(verge_http_enabled);
|
||||
patch!(enable_system_proxy);
|
||||
patch!(enable_proxy_guard);
|
||||
patch!(use_default_bypass);
|
||||
patch!(system_proxy_bypass);
|
||||
patch!(proxy_guard_duration);
|
||||
patch!(proxy_auto_config);
|
||||
|
||||
@@ -85,6 +85,7 @@ fn clash_client_info() -> Result<(String, HeaderMap)> {
|
||||
}
|
||||
|
||||
/// 缩短clash的日志
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_log(log: String) -> String {
|
||||
if log.starts_with("time=") && log.len() > 33 {
|
||||
return (log[33..]).to_owned();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::service;
|
||||
use super::{clash_api, logger::Logger};
|
||||
use crate::config::*;
|
||||
use crate::core::{clash_api, handle, logger::Logger, service};
|
||||
use crate::log_err;
|
||||
use crate::{config::*, utils::dirs};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use crate::utils::dirs;
|
||||
use anyhow::{bail, Result};
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::Mutex;
|
||||
use serde_yaml::Mapping;
|
||||
use std::{fs, io::Write, sync::Arc, time::Duration};
|
||||
use sysinfo::{Pid, System};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
use tauri::api::process::{Command, CommandChild, CommandEvent};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -30,21 +30,6 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
pub fn init(&self) -> Result<()> {
|
||||
// kill old clash process
|
||||
let _ = dirs::clash_pid_path()
|
||||
.and_then(|path| fs::read(path).map(|p| p.to_vec()).context(""))
|
||||
.and_then(|pid| String::from_utf8_lossy(&pid).parse().context(""))
|
||||
.map(|pid| {
|
||||
let mut system = System::new();
|
||||
system.refresh_all();
|
||||
if let Some(proc) = system.process(Pid::from_u32(pid)) {
|
||||
if proc.name().contains("clash") {
|
||||
log::debug!(target: "app", "kill old clash process");
|
||||
proc.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async {
|
||||
// 启动clash
|
||||
log_err!(Self::global().run_core().await);
|
||||
@@ -59,13 +44,27 @@ impl CoreManager {
|
||||
let config_path = dirs::path_to_str(&config_path)?;
|
||||
|
||||
let clash_core = { Config::verge().latest().clash_core.clone() };
|
||||
let clash_core = clash_core.unwrap_or("clash".into());
|
||||
let mut clash_core = clash_core.unwrap_or("verge-mihomo".into());
|
||||
|
||||
let app_dir = dirs::app_home_dir()?;
|
||||
let app_dir = dirs::path_to_str(&app_dir)?;
|
||||
// compatibility
|
||||
if clash_core.contains("clash") {
|
||||
clash_core = "verge-mihomo".to_string();
|
||||
Config::verge().draft().patch_config(IVerge {
|
||||
clash_core: Some("verge-mihomo".to_string()),
|
||||
..IVerge::default()
|
||||
});
|
||||
Config::verge().apply();
|
||||
match Config::verge().data().save_file() {
|
||||
Ok(_) => handle::Handle::refresh_verge(),
|
||||
Err(err) => log::error!(target: "app", "{err}"),
|
||||
}
|
||||
}
|
||||
|
||||
let test_dir = dirs::app_home_dir()?.join("test");
|
||||
let test_dir = dirs::path_to_str(&test_dir)?;
|
||||
|
||||
let output = Command::new_sidecar(clash_core)?
|
||||
.args(["-t", "-d", app_dir, "-f", config_path])
|
||||
.args(["-t", "-d", test_dir, "-f", config_path])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
@@ -85,25 +84,27 @@ impl CoreManager {
|
||||
pub async fn run_core(&self) -> Result<()> {
|
||||
let config_path = Config::generate_file(ConfigType::Run)?;
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut should_kill = match self.sidecar.lock().take() {
|
||||
Some(child) => {
|
||||
log::debug!(target: "app", "stop the core by sidecar");
|
||||
let _ = child.kill();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
// 关闭tun模式
|
||||
let mut disable = Mapping::new();
|
||||
let mut tun = Mapping::new();
|
||||
tun.insert("enable".into(), false.into());
|
||||
disable.insert("tun".into(), tun.into());
|
||||
log::debug!(target: "app", "disable tun mode");
|
||||
let _ = clash_api::patch_configs(&disable).await;
|
||||
|
||||
if *self.use_service_mode.lock() {
|
||||
log::debug!(target: "app", "stop the core by service");
|
||||
log_err!(service::stop_core_by_service().await);
|
||||
should_kill = true;
|
||||
}
|
||||
} else {
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let procs = system.processes_by_name("verge-mihomo");
|
||||
|
||||
// 这里得等一会儿
|
||||
if should_kill {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
for proc in procs {
|
||||
log::debug!(target: "app", "kill all clash process");
|
||||
proc.kill();
|
||||
}
|
||||
}
|
||||
|
||||
// 服务模式
|
||||
@@ -116,12 +117,12 @@ impl CoreManager {
|
||||
// 服务模式启动失败就直接运行sidecar
|
||||
log::debug!(target: "app", "try to run core in service mode");
|
||||
|
||||
match (|| async {
|
||||
let res = async {
|
||||
service::check_service().await?;
|
||||
service::run_core_by_service(&config_path).await
|
||||
})()
|
||||
.await
|
||||
{
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(err) => {
|
||||
// 修改这个值,免得stop出错
|
||||
@@ -135,31 +136,29 @@ impl CoreManager {
|
||||
let app_dir = dirs::path_to_str(&app_dir)?;
|
||||
|
||||
let clash_core = { Config::verge().latest().clash_core.clone() };
|
||||
let clash_core = clash_core.unwrap_or("clash".into());
|
||||
let is_clash = clash_core == "clash";
|
||||
let mut clash_core = clash_core.unwrap_or("verge-mihomo".into());
|
||||
|
||||
// compatibility
|
||||
if clash_core.contains("clash") {
|
||||
clash_core = "verge-mihomo".to_string();
|
||||
Config::verge().draft().patch_config(IVerge {
|
||||
clash_core: Some("verge-mihomo".to_string()),
|
||||
..IVerge::default()
|
||||
});
|
||||
Config::verge().apply();
|
||||
match Config::verge().data().save_file() {
|
||||
Ok(_) => handle::Handle::refresh_verge(),
|
||||
Err(err) => log::error!(target: "app", "{err}"),
|
||||
}
|
||||
}
|
||||
|
||||
let config_path = dirs::path_to_str(&config_path)?;
|
||||
|
||||
let args = match clash_core.as_str() {
|
||||
"clash-meta" => vec!["-d", app_dir, "-f", config_path],
|
||||
"clash-meta-alpha" => vec!["-d", app_dir, "-f", config_path],
|
||||
_ => vec!["-d", app_dir, "-f", config_path],
|
||||
};
|
||||
let args = vec!["-d", app_dir, "-f", config_path];
|
||||
|
||||
let cmd = Command::new_sidecar(clash_core)?;
|
||||
let (mut rx, cmd_child) = cmd.args(args).spawn()?;
|
||||
|
||||
// 将pid写入文件中
|
||||
crate::log_err!((|| {
|
||||
let pid = cmd_child.pid();
|
||||
let path = dirs::clash_pid_path()?;
|
||||
fs::File::create(path)
|
||||
.context("failed to create the pid file")?
|
||||
.write(format!("{pid}").as_bytes())
|
||||
.context("failed to write pid to the file")?;
|
||||
<Result<()>>::Ok(())
|
||||
})());
|
||||
|
||||
let mut sidecar = self.sidecar.lock();
|
||||
*sidecar = Some(cmd_child);
|
||||
drop(sidecar);
|
||||
@@ -168,25 +167,19 @@ impl CoreManager {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
if is_clash {
|
||||
let stdout = clash_api::parse_log(line.clone());
|
||||
log::info!(target: "app", "[clash]: {stdout}");
|
||||
} else {
|
||||
log::info!(target: "app", "[clash]: {line}");
|
||||
};
|
||||
log::info!(target: "app", "[mihomo]: {line}");
|
||||
Logger::global().set_log(line);
|
||||
}
|
||||
CommandEvent::Stderr(err) => {
|
||||
// let stdout = clash_api::parse_log(err.clone());
|
||||
log::error!(target: "app", "[clash]: {err}");
|
||||
log::error!(target: "app", "[mihomo]: {err}");
|
||||
Logger::global().set_log(err);
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
log::error!(target: "app", "[clash]: {err}");
|
||||
log::error!(target: "app", "[mihomo]: {err}");
|
||||
Logger::global().set_log(err);
|
||||
}
|
||||
CommandEvent::Terminated(_) => {
|
||||
log::info!(target: "app", "clash core terminated");
|
||||
log::info!(target: "app", "mihomo core terminated");
|
||||
let _ = CoreManager::global().recover_core();
|
||||
break;
|
||||
}
|
||||
@@ -206,9 +199,7 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
// 清空原来的sidecar值
|
||||
if let Some(sidecar) = self.sidecar.lock().take() {
|
||||
let _ = sidecar.kill();
|
||||
}
|
||||
let _ = self.sidecar.lock().take();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 6秒之后再查看服务是否正常 (时间随便搞的)
|
||||
@@ -232,29 +223,31 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
/// 停止核心运行
|
||||
pub fn stop_core(&self) -> Result<()> {
|
||||
pub async fn stop_core(&self) -> Result<()> {
|
||||
// 关闭tun模式
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let mut disable = Mapping::new();
|
||||
let mut tun = Mapping::new();
|
||||
tun.insert("enable".into(), false.into());
|
||||
disable.insert("tun".into(), tun.into());
|
||||
log::debug!(target: "app", "disable tun mode");
|
||||
let _ = clash_api::patch_configs(&disable).await;
|
||||
});
|
||||
let mut disable = Mapping::new();
|
||||
let mut tun = Mapping::new();
|
||||
tun.insert("enable".into(), false.into());
|
||||
disable.insert("tun".into(), tun.into());
|
||||
log::debug!(target: "app", "disable tun mode");
|
||||
let _ = clash_api::patch_configs(&disable).await;
|
||||
|
||||
if *self.use_service_mode.lock() {
|
||||
log::debug!(target: "app", "stop the core by service");
|
||||
tauri::async_runtime::block_on(async move {
|
||||
log_err!(service::stop_core_by_service().await);
|
||||
});
|
||||
log_err!(service::stop_core_by_service().await);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sidecar = self.sidecar.lock();
|
||||
if let Some(child) = sidecar.take() {
|
||||
log::debug!(target: "app", "stop the core by sidecar");
|
||||
let _ = child.kill();
|
||||
let _ = sidecar.take();
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let procs = system.processes_by_name("verge-mihomo");
|
||||
for proc in procs {
|
||||
log::debug!(target: "app", "kill all clash process");
|
||||
proc.kill();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -262,7 +255,7 @@ impl CoreManager {
|
||||
/// 切换核心
|
||||
pub async fn change_core(&self, clash_core: Option<String>) -> Result<()> {
|
||||
let clash_core = clash_core.ok_or(anyhow::anyhow!("clash core is null"))?;
|
||||
const CLASH_CORES: [&str; 2] = ["clash-meta", "clash-meta-alpha"];
|
||||
const CLASH_CORES: [&str; 2] = ["verge-mihomo", "verge-mihomo-alpha"];
|
||||
|
||||
if !CLASH_CORES.contains(&clash_core.as_str()) {
|
||||
bail!("invalid clash core name \"{clash_core}\"");
|
||||
@@ -273,7 +266,7 @@ impl CoreManager {
|
||||
Config::verge().draft().clash_core = Some(clash_core);
|
||||
|
||||
// 更新订阅
|
||||
Config::generate()?;
|
||||
Config::generate().await?;
|
||||
|
||||
self.check_config()?;
|
||||
|
||||
@@ -299,9 +292,8 @@ impl CoreManager {
|
||||
/// 如果涉及端口和外部控制则需要重启
|
||||
pub async fn update_config(&self) -> Result<()> {
|
||||
log::debug!(target: "app", "try to update clash config");
|
||||
|
||||
// 更新订阅
|
||||
Config::generate()?;
|
||||
Config::generate().await?;
|
||||
|
||||
// 检查订阅是否正常
|
||||
self.check_config()?;
|
||||
@@ -311,20 +303,19 @@ impl CoreManager {
|
||||
let path = dirs::path_to_str(&path)?;
|
||||
|
||||
// 发送请求 发送5次
|
||||
for i in 0..5 {
|
||||
for i in 0..10 {
|
||||
match clash_api::put_configs(path).await {
|
||||
Ok(_) => break,
|
||||
Err(err) => {
|
||||
if i < 4 {
|
||||
if i < 9 {
|
||||
log::info!(target: "app", "{err}");
|
||||
} else {
|
||||
bail!(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ impl Hotkey {
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.current.lock() = hotkeys.clone();
|
||||
self.current.lock().clone_from(hotkeys);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -142,7 +142,7 @@ impl Hotkey {
|
||||
});
|
||||
|
||||
new_map.iter().for_each(|(&key, &func)| {
|
||||
if old_map.get(key).is_none() {
|
||||
if !old_map.contains_key(key) {
|
||||
add_list.push((key, func));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/// 给clash内核的tun模式授权
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
pub fn grant_permission(core: String) -> anyhow::Result<()> {
|
||||
use std::process::Command;
|
||||
use tauri::utils::platform::current_exe;
|
||||
|
||||
let path = current_exe()?.with_file_name(core).canonicalize()?;
|
||||
let path = path.display().to_string();
|
||||
|
||||
log::debug!("grant_permission path: {path}");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let output = {
|
||||
let path = path.replace(' ', "\\\\ ");
|
||||
let shell = format!("chown root:admin {path}\nchmod +sx {path}");
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges"#);
|
||||
Command::new("osascript")
|
||||
.args(vec!["-e", &command])
|
||||
.output()?
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let output = {
|
||||
let path = path.replace(' ', "\\ "); // 避免路径中有空格
|
||||
let shell = format!("setcap cap_net_bind_service,cap_net_admin,cap_dac_override=+ep {path}");
|
||||
|
||||
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() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = std::str::from_utf8(&output.stderr).unwrap_or("");
|
||||
anyhow::bail!("{stderr}");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
pub mod clash_api;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod core;
|
||||
pub mod handle;
|
||||
pub mod hotkey;
|
||||
pub mod logger;
|
||||
pub mod manager;
|
||||
pub mod service;
|
||||
pub mod sysopt;
|
||||
pub mod timer;
|
||||
pub mod tray;
|
||||
pub mod service;
|
||||
pub mod win_uwp;
|
||||
|
||||
pub use self::core::*;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, IVerge};
|
||||
use crate::core::handle;
|
||||
use crate::utils::dirs;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -102,6 +103,12 @@ pub async fn install_service() -> Result<()> {
|
||||
if !installer_path.exists() {
|
||||
bail!("installer not found");
|
||||
}
|
||||
|
||||
let _ = StdCommand::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(installer_path.to_string_lossy().replace(" ", "\\ "))
|
||||
.output();
|
||||
|
||||
let shell = installer_path.to_string_lossy().replace(" ", "\\\\ ");
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges"#);
|
||||
|
||||
@@ -237,7 +244,21 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
||||
}
|
||||
|
||||
let clash_core = { Config::verge().latest().clash_core.clone() };
|
||||
let clash_core = clash_core.unwrap_or("clash".into());
|
||||
let mut clash_core = clash_core.unwrap_or("verge-mihomo".into());
|
||||
|
||||
// compatibility
|
||||
if clash_core.contains("clash") {
|
||||
clash_core = "verge-mihomo".to_string();
|
||||
Config::verge().draft().patch_config(IVerge {
|
||||
clash_core: Some("verge-mihomo".to_string()),
|
||||
..IVerge::default()
|
||||
});
|
||||
Config::verge().apply();
|
||||
match Config::verge().data().save_file() {
|
||||
Ok(_) => handle::Handle::refresh_verge(),
|
||||
Err(err) => log::error!(target: "app", "{err}"),
|
||||
}
|
||||
}
|
||||
|
||||
let bin_ext = if cfg!(windows) { ".exe" } else { "" };
|
||||
let clash_bin = format!("{clash_core}{bin_ext}");
|
||||
@@ -297,3 +318,43 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// set dns by service
|
||||
pub async fn set_dns_by_service() -> Result<()> {
|
||||
let url = format!("{SERVICE_URL}/set_dns");
|
||||
let res = reqwest::ClientBuilder::new()
|
||||
.no_proxy()
|
||||
.build()?
|
||||
.post(url)
|
||||
.send()
|
||||
.await?
|
||||
.json::<JsonResponse>()
|
||||
.await
|
||||
.context("failed to connect to the Clash Verge Service")?;
|
||||
|
||||
if res.code != 0 {
|
||||
bail!(res.msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// unset dns by service
|
||||
pub async fn unset_dns_by_service() -> Result<()> {
|
||||
let url = format!("{SERVICE_URL}/unset_dns");
|
||||
let res = reqwest::ClientBuilder::new()
|
||||
.no_proxy()
|
||||
.build()?
|
||||
.post(url)
|
||||
.send()
|
||||
.await?
|
||||
.json::<JsonResponse>()
|
||||
.await
|
||||
.context("failed to connect to the Clash Verge Service")?;
|
||||
|
||||
if res.code != 0 {
|
||||
bail!(res.msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -41,6 +41,42 @@ static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172
|
||||
static DEFAULT_BYPASS: &str =
|
||||
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
||||
|
||||
fn get_bypass() -> String {
|
||||
// let bypass = DEFAULT_BYPASS.to_string();
|
||||
let use_default = Config::verge().latest().use_default_bypass.unwrap_or(true);
|
||||
let res = {
|
||||
let verge = Config::verge();
|
||||
let verge = verge.latest();
|
||||
verge.system_proxy_bypass.clone()
|
||||
};
|
||||
let custom_bypass = match res {
|
||||
Some(bypass) => bypass,
|
||||
None => "".to_string(),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let bypass = if custom_bypass.is_empty() {
|
||||
DEFAULT_BYPASS.to_string()
|
||||
} else {
|
||||
if use_default {
|
||||
format!("{};{}", DEFAULT_BYPASS, custom_bypass)
|
||||
} else {
|
||||
custom_bypass
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let bypass = if custom_bypass.is_empty() {
|
||||
DEFAULT_BYPASS.to_string()
|
||||
} else {
|
||||
if use_default {
|
||||
format!("{},{}", DEFAULT_BYPASS, custom_bypass)
|
||||
} else {
|
||||
custom_bypass
|
||||
}
|
||||
};
|
||||
|
||||
bypass
|
||||
}
|
||||
|
||||
impl Sysopt {
|
||||
pub fn global() -> &'static Sysopt {
|
||||
static SYSOPT: OnceCell<Sysopt> = OnceCell::new();
|
||||
@@ -63,12 +99,11 @@ impl Sysopt {
|
||||
.unwrap_or(Config::clash().data().get_mixed_port());
|
||||
let pac_port = IVerge::get_singleton_port();
|
||||
|
||||
let (enable, bypass, pac) = {
|
||||
let (enable, pac) = {
|
||||
let verge = Config::verge();
|
||||
let verge = verge.latest();
|
||||
(
|
||||
verge.enable_system_proxy.unwrap_or(false),
|
||||
verge.system_proxy_bypass.clone(),
|
||||
verge.proxy_auto_config.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
@@ -76,16 +111,7 @@ impl Sysopt {
|
||||
enable,
|
||||
host: String::from("127.0.0.1"),
|
||||
port,
|
||||
bypass: match bypass {
|
||||
Some(bypass) => {
|
||||
if bypass.is_empty() {
|
||||
DEFAULT_BYPASS.into()
|
||||
} else {
|
||||
bypass
|
||||
}
|
||||
}
|
||||
None => DEFAULT_BYPASS.into(),
|
||||
},
|
||||
bypass: get_bypass(),
|
||||
};
|
||||
let mut auto = Autoproxy {
|
||||
enable,
|
||||
@@ -127,28 +153,26 @@ impl Sysopt {
|
||||
let mut cur_autoproxy = self.cur_autoproxy.lock();
|
||||
let old_autoproxy = self.old_autoproxy.lock();
|
||||
|
||||
let (enable, bypass, pac) = {
|
||||
let (enable, pac) = {
|
||||
let verge = Config::verge();
|
||||
let verge = verge.latest();
|
||||
(
|
||||
verge.enable_system_proxy.unwrap_or(false),
|
||||
verge.system_proxy_bypass.clone(),
|
||||
verge.proxy_auto_config.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
if pac {
|
||||
if cur_autoproxy.is_none() || old_autoproxy.is_none() {
|
||||
drop(cur_autoproxy);
|
||||
drop(old_autoproxy);
|
||||
return self.init_sysproxy();
|
||||
}
|
||||
} else {
|
||||
if cur_sysproxy.is_none() || old_sysproxy.is_none() {
|
||||
drop(cur_sysproxy);
|
||||
drop(old_sysproxy);
|
||||
return self.init_sysproxy();
|
||||
}
|
||||
if pac && (cur_autoproxy.is_none() || old_autoproxy.is_none()) {
|
||||
drop(cur_autoproxy);
|
||||
drop(old_autoproxy);
|
||||
return self.init_sysproxy();
|
||||
}
|
||||
|
||||
if !pac && (cur_sysproxy.is_none() || old_sysproxy.is_none()) {
|
||||
drop(cur_sysproxy);
|
||||
drop(old_sysproxy);
|
||||
return self.init_sysproxy();
|
||||
}
|
||||
|
||||
let port = Config::verge()
|
||||
.latest()
|
||||
.verge_mixed_port
|
||||
@@ -156,16 +180,7 @@ impl Sysopt {
|
||||
let pac_port = IVerge::get_singleton_port();
|
||||
|
||||
let mut sysproxy = cur_sysproxy.take().unwrap();
|
||||
sysproxy.bypass = match bypass {
|
||||
Some(bypass) => {
|
||||
if bypass.is_empty() {
|
||||
DEFAULT_BYPASS.into()
|
||||
} else {
|
||||
bypass
|
||||
}
|
||||
}
|
||||
None => DEFAULT_BYPASS.into(),
|
||||
};
|
||||
sysproxy.bypass = get_bypass();
|
||||
sysproxy.port = port;
|
||||
|
||||
let mut autoproxy = cur_autoproxy.take().unwrap();
|
||||
@@ -349,14 +364,13 @@ impl Sysopt {
|
||||
loop {
|
||||
sleep(Duration::from_secs(wait_secs)).await;
|
||||
|
||||
let (enable, guard, guard_duration, bypass, pac) = {
|
||||
let (enable, guard, guard_duration, pac) = {
|
||||
let verge = Config::verge();
|
||||
let verge = verge.latest();
|
||||
(
|
||||
verge.enable_system_proxy.unwrap_or(false),
|
||||
verge.enable_proxy_guard.unwrap_or(false),
|
||||
verge.proxy_guard_duration.unwrap_or(10),
|
||||
verge.system_proxy_bypass.clone(),
|
||||
verge.proxy_auto_config.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
@@ -389,16 +403,7 @@ impl Sysopt {
|
||||
enable: true,
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
bypass: match bypass {
|
||||
Some(bypass) => {
|
||||
if bypass.is_empty() {
|
||||
DEFAULT_BYPASS.into()
|
||||
} else {
|
||||
bypass
|
||||
}
|
||||
}
|
||||
None => DEFAULT_BYPASS.into(),
|
||||
},
|
||||
bypass: get_bypass(),
|
||||
};
|
||||
|
||||
log_err!(sysproxy.set_system_proxy());
|
||||
|
||||
@@ -88,7 +88,7 @@ impl Tray {
|
||||
),
|
||||
))
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("quit", t!("Quit", "退出")).accelerator("CmdOrControl+Q"))
|
||||
.add_item(CustomMenuItem::new("quit", t!("Quit", "退出")))
|
||||
}
|
||||
|
||||
pub fn update_systray(app_handle: &AppHandle) -> Result<()> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function main(config) {
|
||||
function main(config, _name) {
|
||||
if (config.mode === "script") {
|
||||
config.mode = "rule";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function main(config) {
|
||||
function main(config, _name) {
|
||||
if (Array.isArray(config.proxies)) {
|
||||
config.proxies.forEach((p, i) => {
|
||||
if (p.type === "hysteria" && typeof p.alpn === "string") {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::SeqMap;
|
||||
use crate::{
|
||||
config::PrfItem,
|
||||
utils::{dirs, help},
|
||||
@@ -15,6 +16,9 @@ pub struct ChainItem {
|
||||
pub enum ChainType {
|
||||
Merge(Mapping),
|
||||
Script(String),
|
||||
Rules(SeqMap),
|
||||
Proxies(SeqMap),
|
||||
Groups(SeqMap),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -43,7 +47,19 @@ impl From<&PrfItem> for Option<ChainItem> {
|
||||
}),
|
||||
"merge" => Some(ChainItem {
|
||||
uid,
|
||||
data: ChainType::Merge(help::read_merge_mapping(&path).ok()?),
|
||||
data: ChainType::Merge(help::read_mapping(&path).ok()?),
|
||||
}),
|
||||
"rules" => Some(ChainItem {
|
||||
uid,
|
||||
data: ChainType::Rules(help::read_seq_map(&path).ok()?),
|
||||
}),
|
||||
"proxies" => Some(ChainItem {
|
||||
uid,
|
||||
data: ChainType::Proxies(help::read_seq_map(&path).ok()?),
|
||||
}),
|
||||
"groups" => Some(ChainItem {
|
||||
uid,
|
||||
data: ChainType::Groups(help::read_seq_map(&path).ok()?),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
@@ -88,13 +104,13 @@ impl ChainItem {
|
||||
impl ChainSupport {
|
||||
pub fn is_support(&self, core: Option<&String>) -> bool {
|
||||
match core {
|
||||
Some(core) => match (self, core.as_str()) {
|
||||
(ChainSupport::All, _) => true,
|
||||
(ChainSupport::Clash, "clash") => true,
|
||||
(ChainSupport::ClashMeta, "clash-meta") => true,
|
||||
(ChainSupport::ClashMetaAlpha, "clash-meta-alpha") => true,
|
||||
_ => false,
|
||||
},
|
||||
Some(core) => matches!(
|
||||
(self, core.as_str()),
|
||||
(ChainSupport::All, _)
|
||||
| (ChainSupport::Clash, "clash")
|
||||
| (ChainSupport::ClashMeta, "verge-mihomo")
|
||||
| (ChainSupport::ClashMetaAlpha, "verge-mihomo-alpha")
|
||||
),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ pub const HANDLE_FIELDS: [&str; 11] = [
|
||||
"allow-lan",
|
||||
"log-level",
|
||||
"ipv6",
|
||||
"secret",
|
||||
"external-controller",
|
||||
"secret",
|
||||
];
|
||||
|
||||
pub const DEFAULT_FIELDS: [&str; 5] = [
|
||||
@@ -23,19 +23,6 @@ pub const DEFAULT_FIELDS: [&str; 5] = [
|
||||
"rules",
|
||||
];
|
||||
|
||||
pub fn use_filter(config: Mapping, filter: &Vec<String>) -> Mapping {
|
||||
let mut ret = Mapping::new();
|
||||
|
||||
for (key, value) in config.into_iter() {
|
||||
if let Some(key) = key.as_str() {
|
||||
if filter.contains(&key.to_string()) {
|
||||
ret.insert(Value::from(key), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
pub fn use_lowercase(config: Mapping) -> Mapping {
|
||||
let mut ret = Mapping::new();
|
||||
|
||||
@@ -60,11 +47,7 @@ pub fn use_sort(config: Mapping) -> Mapping {
|
||||
|
||||
let supported_keys: HashSet<&str> = HANDLE_FIELDS.into_iter().chain(DEFAULT_FIELDS).collect();
|
||||
|
||||
let config_keys: HashSet<&str> = config
|
||||
.keys()
|
||||
.filter_map(|e| e.as_str())
|
||||
.into_iter()
|
||||
.collect();
|
||||
let config_keys: HashSet<&str> = config.keys().filter_map(|e| e.as_str()).collect();
|
||||
|
||||
config_keys.difference(&supported_keys).for_each(|&key| {
|
||||
let key = Value::from(key);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
use super::{use_filter, use_lowercase};
|
||||
use serde_yaml::{self, Mapping, Sequence, Value};
|
||||
|
||||
const MERGE_FIELDS: [&str; 6] = [
|
||||
"prepend-rules",
|
||||
"append-rules",
|
||||
"prepend-proxies",
|
||||
"append-proxies",
|
||||
"prepend-proxy-groups",
|
||||
"append-proxy-groups",
|
||||
];
|
||||
use super::use_lowercase;
|
||||
use serde_yaml::{self, Mapping, Value};
|
||||
|
||||
fn deep_merge(a: &mut Value, b: &Value) {
|
||||
match (a, b) {
|
||||
(&mut Value::Mapping(ref mut a), &Value::Mapping(ref b)) => {
|
||||
(&mut Value::Mapping(ref mut a), Value::Mapping(b)) => {
|
||||
for (k, v) in b {
|
||||
deep_merge(a.entry(k.clone()).or_insert(Value::Null), v);
|
||||
}
|
||||
@@ -23,47 +14,12 @@ fn deep_merge(a: &mut Value, b: &Value) {
|
||||
|
||||
pub fn use_merge(merge: Mapping, config: Mapping) -> Mapping {
|
||||
let mut config = Value::from(config);
|
||||
let mut merge_without_append = use_lowercase(merge.clone());
|
||||
for key in MERGE_FIELDS {
|
||||
merge_without_append.remove(key).unwrap_or_default();
|
||||
}
|
||||
deep_merge(&mut config, &Value::from(merge_without_append));
|
||||
let merge = use_lowercase(merge.clone());
|
||||
|
||||
let mut config = config.as_mapping().unwrap().clone();
|
||||
let merge_list = MERGE_FIELDS.iter().map(|s| s.to_string());
|
||||
let merge = use_filter(merge, &merge_list.collect());
|
||||
deep_merge(&mut config, &Value::from(merge));
|
||||
|
||||
["rules", "proxies", "proxy-groups"]
|
||||
.iter()
|
||||
.for_each(|key_str| {
|
||||
let key_val = Value::from(key_str.to_string());
|
||||
let config = config.as_mapping().unwrap().clone();
|
||||
|
||||
let mut list = Sequence::default();
|
||||
list = config.get(&key_val).map_or(list.clone(), |val| {
|
||||
val.as_sequence().map_or(list, |v| v.clone())
|
||||
});
|
||||
|
||||
let pre_key = Value::from(format!("prepend-{key_str}"));
|
||||
let post_key = Value::from(format!("append-{key_str}"));
|
||||
|
||||
if let Some(pre_val) = merge.get(&pre_key) {
|
||||
if pre_val.is_sequence() {
|
||||
let mut pre_val = pre_val.as_sequence().unwrap().clone();
|
||||
pre_val.extend(list);
|
||||
list = pre_val;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(post_val) = merge.get(&post_key) {
|
||||
if post_val.is_sequence() {
|
||||
list.extend(post_val.as_sequence().unwrap().clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !list.is_empty() {
|
||||
config.insert(key_val, Value::from(list));
|
||||
}
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@ mod chain;
|
||||
pub mod field;
|
||||
mod merge;
|
||||
mod script;
|
||||
pub mod seq;
|
||||
mod tun;
|
||||
|
||||
use self::chain::*;
|
||||
use self::field::*;
|
||||
use self::merge::*;
|
||||
use self::script::*;
|
||||
use self::seq::*;
|
||||
use self::tun::*;
|
||||
use crate::config::Config;
|
||||
use crate::utils::tmpl;
|
||||
use serde_yaml::Mapping;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -18,7 +21,7 @@ type ResultLog = Vec<(String, String)>;
|
||||
|
||||
/// Enhance mode
|
||||
/// 返回最终订阅、该订阅包含的键、和script执行的结果
|
||||
pub fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
// config.yaml 的订阅
|
||||
let clash_config = { Config::clash().latest().0.clone() };
|
||||
|
||||
@@ -29,66 +32,173 @@ pub fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
verge.clash_core.clone(),
|
||||
verge.enable_tun_mode.unwrap_or(false),
|
||||
verge.enable_builtin_enhanced.unwrap_or(true),
|
||||
verge.verge_socks_enabled.unwrap_or(true),
|
||||
verge.verge_http_enabled.unwrap_or(true),
|
||||
verge.verge_socks_enabled.unwrap_or(false),
|
||||
verge.verge_http_enabled.unwrap_or(false),
|
||||
)
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let redir_enabled = {
|
||||
let verge = Config::verge();
|
||||
let verge = verge.latest();
|
||||
verge.verge_redir_enabled.unwrap_or(true)
|
||||
verge.verge_redir_enabled.unwrap_or(false)
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
let tproxy_enabled = {
|
||||
let verge = Config::verge();
|
||||
let verge = verge.latest();
|
||||
verge.verge_tproxy_enabled.unwrap_or(true)
|
||||
verge.verge_tproxy_enabled.unwrap_or(false)
|
||||
};
|
||||
|
||||
// 从profiles里拿东西
|
||||
let (mut config, chain) = {
|
||||
let (
|
||||
mut config,
|
||||
merge_item,
|
||||
script_item,
|
||||
rules_item,
|
||||
proxies_item,
|
||||
groups_item,
|
||||
global_merge,
|
||||
global_script,
|
||||
profile_name,
|
||||
) = {
|
||||
let profiles = Config::profiles();
|
||||
let profiles = profiles.latest();
|
||||
|
||||
let current = profiles.current_mapping().unwrap_or_default();
|
||||
let merge = profiles
|
||||
.get_item(&profiles.current_merge().unwrap_or_default())
|
||||
.ok()
|
||||
.and_then(<Option<ChainItem>>::from)
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Merge(Mapping::new()),
|
||||
});
|
||||
let script = profiles
|
||||
.get_item(&profiles.current_script().unwrap_or_default())
|
||||
.ok()
|
||||
.and_then(<Option<ChainItem>>::from)
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||
});
|
||||
let rules = profiles
|
||||
.get_item(&profiles.current_rules().unwrap_or_default())
|
||||
.ok()
|
||||
.and_then(<Option<ChainItem>>::from)
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Rules(SeqMap::default()),
|
||||
});
|
||||
let proxies = profiles
|
||||
.get_item(&profiles.current_proxies().unwrap_or_default())
|
||||
.ok()
|
||||
.and_then(<Option<ChainItem>>::from)
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Proxies(SeqMap::default()),
|
||||
});
|
||||
let groups = profiles
|
||||
.get_item(&profiles.current_groups().unwrap_or_default())
|
||||
.ok()
|
||||
.and_then(<Option<ChainItem>>::from)
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "".into(),
|
||||
data: ChainType::Groups(SeqMap::default()),
|
||||
});
|
||||
|
||||
let chain = match profiles.chain.as_ref() {
|
||||
Some(chain) => chain
|
||||
.iter()
|
||||
.filter_map(|uid| profiles.get_item(uid).ok())
|
||||
.filter_map(<Option<ChainItem>>::from)
|
||||
.collect::<Vec<ChainItem>>(),
|
||||
None => vec![],
|
||||
};
|
||||
let global_merge = profiles
|
||||
.get_item(&"Merge".to_string())
|
||||
.ok()
|
||||
.and_then(<Option<ChainItem>>::from)
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "Merge".into(),
|
||||
data: ChainType::Merge(Mapping::new()),
|
||||
});
|
||||
|
||||
(current, chain)
|
||||
let global_script = profiles
|
||||
.get_item(&"Script".to_string())
|
||||
.ok()
|
||||
.and_then(<Option<ChainItem>>::from)
|
||||
.unwrap_or_else(|| ChainItem {
|
||||
uid: "Script".into(),
|
||||
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
|
||||
});
|
||||
|
||||
let name = profiles
|
||||
.get_item(&profiles.get_current().unwrap_or_default())
|
||||
.ok()
|
||||
.and_then(|item| item.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
(
|
||||
current,
|
||||
merge,
|
||||
script,
|
||||
rules,
|
||||
proxies,
|
||||
groups,
|
||||
global_merge,
|
||||
global_script,
|
||||
name,
|
||||
)
|
||||
};
|
||||
|
||||
let mut result_map = HashMap::new(); // 保存脚本日志
|
||||
let mut exists_keys = use_keys(&config); // 保存出现过的keys
|
||||
|
||||
// 处理用户的profile
|
||||
chain.into_iter().for_each(|item| match item.data {
|
||||
ChainType::Merge(merge) => {
|
||||
exists_keys.extend(use_keys(&merge));
|
||||
config = use_merge(merge, config.to_owned());
|
||||
}
|
||||
ChainType::Script(script) => {
|
||||
let mut logs = vec![];
|
||||
// 全局Merge和Script
|
||||
if let ChainType::Merge(merge) = global_merge.data {
|
||||
exists_keys.extend(use_keys(&merge));
|
||||
config = use_merge(merge, config.to_owned());
|
||||
}
|
||||
|
||||
match use_script(script, config.to_owned()) {
|
||||
Ok((res_config, res_logs)) => {
|
||||
exists_keys.extend(use_keys(&res_config));
|
||||
config = res_config;
|
||||
logs.extend(res_logs);
|
||||
}
|
||||
Err(err) => logs.push(("exception".into(), err.to_string())),
|
||||
if let ChainType::Script(script) = global_script.data {
|
||||
let mut logs = vec![];
|
||||
|
||||
match use_script(script, config.to_owned(), profile_name.to_owned()) {
|
||||
Ok((res_config, res_logs)) => {
|
||||
exists_keys.extend(use_keys(&res_config));
|
||||
config = res_config;
|
||||
logs.extend(res_logs);
|
||||
}
|
||||
|
||||
result_map.insert(item.uid, logs);
|
||||
Err(err) => logs.push(("exception".into(), err.to_string())),
|
||||
}
|
||||
});
|
||||
|
||||
result_map.insert(global_script.uid, logs);
|
||||
}
|
||||
|
||||
// 订阅关联的Merge、Script、Rules、Proxies、Groups
|
||||
if let ChainType::Rules(rules) = rules_item.data {
|
||||
config = use_seq(rules, config.to_owned(), "rules");
|
||||
}
|
||||
|
||||
if let ChainType::Proxies(proxies) = proxies_item.data {
|
||||
config = use_seq(proxies, config.to_owned(), "proxies");
|
||||
}
|
||||
|
||||
if let ChainType::Groups(groups) = groups_item.data {
|
||||
config = use_seq(groups, config.to_owned(), "proxy-groups");
|
||||
}
|
||||
|
||||
if let ChainType::Merge(merge) = merge_item.data {
|
||||
exists_keys.extend(use_keys(&merge));
|
||||
config = use_merge(merge, config.to_owned());
|
||||
}
|
||||
|
||||
if let ChainType::Script(script) = script_item.data {
|
||||
let mut logs = vec![];
|
||||
|
||||
match use_script(script, config.to_owned(), profile_name.to_owned()) {
|
||||
Ok((res_config, res_logs)) => {
|
||||
exists_keys.extend(use_keys(&res_config));
|
||||
config = res_config;
|
||||
logs.extend(res_logs);
|
||||
}
|
||||
Err(err) => logs.push(("exception".into(), err.to_string())),
|
||||
}
|
||||
|
||||
result_map.insert(script_item.uid, logs);
|
||||
}
|
||||
|
||||
// 合并默认的config
|
||||
for (key, value) in clash_config.into_iter() {
|
||||
@@ -136,26 +246,24 @@ pub fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
.map(|(_, c)| c)
|
||||
.for_each(|item| {
|
||||
log::debug!(target: "app", "run builtin script {}", item.uid);
|
||||
|
||||
match item.data {
|
||||
ChainType::Script(script) => match use_script(script, config.to_owned()) {
|
||||
if let ChainType::Script(script) = item.data {
|
||||
match use_script(script, config.to_owned(), "".to_string()) {
|
||||
Ok((res_config, _)) => {
|
||||
config = res_config;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(target: "app", "builtin script error `{err}`");
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
config = use_tun(config, enable_tun);
|
||||
config = use_tun(config, enable_tun).await;
|
||||
config = use_sort(config);
|
||||
|
||||
let mut exists_set = HashSet::new();
|
||||
exists_set.extend(exists_keys.into_iter());
|
||||
exists_set.extend(exists_keys);
|
||||
exists_keys = exists_set.into_iter().collect();
|
||||
|
||||
(config, exists_keys, result_map)
|
||||
|
||||
@@ -2,7 +2,11 @@ use super::use_lowercase;
|
||||
use anyhow::{Error, Result};
|
||||
use serde_yaml::Mapping;
|
||||
|
||||
pub fn use_script(script: String, config: Mapping) -> Result<(Mapping, Vec<(String, String)>)> {
|
||||
pub fn use_script(
|
||||
script: String,
|
||||
config: Mapping,
|
||||
name: String,
|
||||
) -> Result<(Mapping, Vec<(String, String)>)> {
|
||||
use boa_engine::{native_function::NativeFunction, Context, JsValue, Source};
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut context = Context::default();
|
||||
@@ -16,7 +20,7 @@ pub fn use_script(script: String, config: Mapping) -> Result<(Mapping, Vec<(Stri
|
||||
2,
|
||||
NativeFunction::from_closure(
|
||||
move |_: &JsValue, args: &[JsValue], context: &mut Context| {
|
||||
let level = args.get(0).unwrap().to_string(context)?;
|
||||
let level = args.first().unwrap().to_string(context)?;
|
||||
let level = level.to_std_string().unwrap();
|
||||
let data = args.get(1).unwrap().to_string(context)?;
|
||||
let data = data.to_std_string().unwrap();
|
||||
@@ -42,7 +46,7 @@ pub fn use_script(script: String, config: Mapping) -> Result<(Mapping, Vec<(Stri
|
||||
let code = format!(
|
||||
r#"try{{
|
||||
{script};
|
||||
JSON.stringify(main({config_str})||'')
|
||||
JSON.stringify(main({config_str},'{name}')||'')
|
||||
}} catch(err) {{
|
||||
`__error_flag__ ${{err.toString()}}`
|
||||
}}"#
|
||||
@@ -97,7 +101,7 @@ fn test_script() {
|
||||
"#;
|
||||
|
||||
let config = serde_yaml::from_str(config).unwrap();
|
||||
let (config, results) = use_script(script.into(), config).unwrap();
|
||||
let (config, results) = use_script(script.into(), config, "".to_string()).unwrap();
|
||||
|
||||
let config_str = serde_yaml::to_string(&config).unwrap();
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::{Mapping, Sequence, Value};
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SeqMap {
|
||||
prepend: Sequence,
|
||||
append: Sequence,
|
||||
delete: Sequence,
|
||||
}
|
||||
|
||||
pub fn use_seq(seq_map: SeqMap, config: Mapping, name: &str) -> Mapping {
|
||||
let mut prepend = seq_map.prepend;
|
||||
let append = seq_map.append;
|
||||
let delete = seq_map.delete;
|
||||
|
||||
let origin_seq = config.get(&name).map_or(Sequence::default(), |val| {
|
||||
val.as_sequence().unwrap_or(&Sequence::default()).clone()
|
||||
});
|
||||
let mut seq = origin_seq.clone();
|
||||
|
||||
let mut delete_names = Vec::new();
|
||||
for item in delete {
|
||||
let item = item.clone();
|
||||
if let Some(name) = if item.is_string() {
|
||||
Some(item)
|
||||
} else {
|
||||
item.get("name").map(|y| y.clone())
|
||||
} {
|
||||
delete_names.push(name.clone());
|
||||
}
|
||||
}
|
||||
seq.retain(|x| {
|
||||
if let Some(x_name) = if x.is_string() {
|
||||
Some(x)
|
||||
} else {
|
||||
x.get("name")
|
||||
} {
|
||||
!delete_names.contains(&x_name)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
prepend.reverse();
|
||||
for item in prepend {
|
||||
seq.insert(0, item);
|
||||
}
|
||||
|
||||
for item in append {
|
||||
seq.push(item);
|
||||
}
|
||||
|
||||
let mut config = config.clone();
|
||||
config.insert(Value::from(name), Value::from(seq));
|
||||
return config;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::{core::service, log_err};
|
||||
use serde_yaml::{Mapping, Value};
|
||||
|
||||
macro_rules! revise {
|
||||
@@ -17,7 +18,7 @@ macro_rules! append {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
||||
pub async fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
||||
let tun_key = Value::from("tun");
|
||||
let tun_val = config.get(&tun_key);
|
||||
|
||||
@@ -34,60 +35,10 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
||||
revise!(config, "tun", tun_val);
|
||||
|
||||
if enable {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use crate::utils::dirs;
|
||||
use tauri::api::process::Command;
|
||||
log::info!(target: "app", "try to set system dns");
|
||||
let resource_dir = dirs::app_resources_dir().unwrap();
|
||||
let script = resource_dir.join("set_dns.sh");
|
||||
let script = script.to_string_lossy();
|
||||
match Command::new("bash")
|
||||
.args([script])
|
||||
.current_dir(resource_dir)
|
||||
.status()
|
||||
{
|
||||
Ok(status) => {
|
||||
if status.success() {
|
||||
log::info!(target: "app", "set system dns successfully");
|
||||
} else {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
log::error!(target: "app", "set system dns failed: {code}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(target: "app", "set system dns failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
log_err!(service::set_dns_by_service().await);
|
||||
use_dns_for_tun(config)
|
||||
} else {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use crate::utils::dirs;
|
||||
use tauri::api::process::Command;
|
||||
log::info!(target: "app", "try to unset system dns");
|
||||
let resource_dir = dirs::app_resources_dir().unwrap();
|
||||
let script = resource_dir.join("unset_dns.sh");
|
||||
let script = script.to_string_lossy();
|
||||
match Command::new("bash")
|
||||
.args([script])
|
||||
.current_dir(resource_dir)
|
||||
.status()
|
||||
{
|
||||
Ok(status) => {
|
||||
if status.success() {
|
||||
log::info!(target: "app", "unset system dns successfully");
|
||||
} else {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
log::error!(target: "app", "unset system dns failed: {code}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!(target: "app", "unset system dns failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
log_err!(service::unset_dns_by_service().await);
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,49 +106,14 @@ pub fn toggle_tun_mode() {
|
||||
pub async fn patch_clash(patch: Mapping) -> Result<()> {
|
||||
Config::clash().draft().patch_config(patch.clone());
|
||||
|
||||
match {
|
||||
let redir_port = patch.get("redir-port");
|
||||
let tproxy_port = patch.get("tproxy-port");
|
||||
let mixed_port = patch.get("mixed-port");
|
||||
let socks_port = patch.get("socks-port");
|
||||
let port = patch.get("port");
|
||||
let enable_random_port = Config::verge().latest().enable_random_port.unwrap_or(false);
|
||||
if mixed_port.is_some() && !enable_random_port {
|
||||
let changed = mixed_port.unwrap()
|
||||
!= Config::verge()
|
||||
.latest()
|
||||
.verge_mixed_port
|
||||
.unwrap_or(Config::clash().data().get_mixed_port());
|
||||
// 检查端口占用
|
||||
if changed {
|
||||
if let Some(port) = mixed_port.unwrap().as_u64() {
|
||||
if !port_scanner::local_port_available(port as u16) {
|
||||
Config::clash().discard();
|
||||
bail!("port already in use");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let res = {
|
||||
// 激活订阅
|
||||
if redir_port.is_some()
|
||||
|| tproxy_port.is_some()
|
||||
|| mixed_port.is_some()
|
||||
|| socks_port.is_some()
|
||||
|| port.is_some()
|
||||
|| patch.get("secret").is_some()
|
||||
|| patch.get("external-controller").is_some()
|
||||
{
|
||||
Config::generate()?;
|
||||
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().run_core().await?;
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
|
||||
// 更新系统代理
|
||||
if mixed_port.is_some() {
|
||||
log_err!(sysopt::Sysopt::global().init_sysproxy());
|
||||
}
|
||||
|
||||
if patch.get("mode").is_some() {
|
||||
log_err!(handle::Handle::update_systray_part());
|
||||
}
|
||||
@@ -156,7 +121,8 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
|
||||
Config::runtime().latest().patch_config(patch);
|
||||
|
||||
<Result<()>>::Ok(())
|
||||
} {
|
||||
};
|
||||
match res {
|
||||
Ok(()) => {
|
||||
Config::clash().apply();
|
||||
Config::clash().data().save_config()?;
|
||||
@@ -173,7 +139,6 @@ pub async fn patch_clash(patch: Mapping) -> Result<()> {
|
||||
/// 一般都是一个个的修改
|
||||
pub async fn patch_verge(patch: IVerge) -> Result<()> {
|
||||
Config::verge().draft().patch_config(patch.clone());
|
||||
|
||||
let tun_mode = patch.enable_tun_mode;
|
||||
let auto_launch = patch.enable_auto_launch;
|
||||
let system_proxy = patch.enable_system_proxy;
|
||||
@@ -181,7 +146,7 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
|
||||
let pac_content = patch.pac_file_content;
|
||||
let proxy_bypass = patch.system_proxy_bypass;
|
||||
let language = patch.language;
|
||||
let port = patch.verge_mixed_port;
|
||||
let mixed_port = patch.verge_mixed_port;
|
||||
#[cfg(target_os = "macos")]
|
||||
let tray_icon = patch.tray_icon;
|
||||
let common_tray_icon = patch.common_tray_icon;
|
||||
@@ -189,41 +154,62 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
|
||||
let tun_tray_icon = patch.tun_tray_icon;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let redir_enabled = patch.verge_redir_enabled;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let redir_port = patch.verge_redir_port;
|
||||
#[cfg(target_os = "linux")]
|
||||
let tproxy_enabled = patch.verge_tproxy_enabled;
|
||||
#[cfg(target_os = "linux")]
|
||||
let tproxy_port = patch.verge_tproxy_port;
|
||||
let socks_enabled = patch.verge_socks_enabled;
|
||||
let socks_port = patch.verge_socks_port;
|
||||
let http_enabled = patch.verge_http_enabled;
|
||||
match {
|
||||
let http_port = patch.verge_port;
|
||||
let res = {
|
||||
let service_mode = patch.enable_service_mode;
|
||||
|
||||
let mut generated = false;
|
||||
if service_mode.is_some() {
|
||||
log::debug!(target: "app", "change service mode to {}", service_mode.unwrap());
|
||||
|
||||
Config::generate()?;
|
||||
CoreManager::global().run_core().await?;
|
||||
if !generated {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().run_core().await?;
|
||||
generated = true;
|
||||
}
|
||||
} else if tun_mode.is_some() {
|
||||
update_core_config().await?;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if redir_enabled.is_some() {
|
||||
Config::generate()?;
|
||||
CoreManager::global().run_core().await?;
|
||||
if redir_enabled.is_some() || redir_port.is_some() {
|
||||
if !generated {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().run_core().await?;
|
||||
generated = true;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if tproxy_enabled.is_some() {
|
||||
Config::generate()?;
|
||||
CoreManager::global().run_core().await?;
|
||||
if tproxy_enabled.is_some() || tproxy_port.is_some() {
|
||||
if !generated {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().run_core().await?;
|
||||
generated = true;
|
||||
}
|
||||
}
|
||||
if socks_enabled.is_some() || http_enabled.is_some() {
|
||||
Config::generate()?;
|
||||
CoreManager::global().run_core().await?;
|
||||
if socks_enabled.is_some()
|
||||
|| http_enabled.is_some()
|
||||
|| socks_port.is_some()
|
||||
|| http_port.is_some()
|
||||
|| mixed_port.is_some()
|
||||
{
|
||||
if !generated {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().run_core().await?;
|
||||
}
|
||||
}
|
||||
if auto_launch.is_some() {
|
||||
sysopt::Sysopt::global().update_launch()?;
|
||||
}
|
||||
if system_proxy.is_some()
|
||||
|| proxy_bypass.is_some()
|
||||
|| port.is_some()
|
||||
|| mixed_port.is_some()
|
||||
|| pac.is_some()
|
||||
|| pac_content.is_some()
|
||||
{
|
||||
@@ -255,7 +241,8 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
|
||||
}
|
||||
|
||||
<Result<()>>::Ok(())
|
||||
} {
|
||||
};
|
||||
match res {
|
||||
Ok(()) => {
|
||||
Config::verge().apply();
|
||||
Config::verge().data().save_file()?;
|
||||
@@ -290,7 +277,6 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
|
||||
Some((url, opt)) => {
|
||||
let merged_opt = PrfOption::merge(opt, option);
|
||||
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
|
||||
|
||||
let profiles = Config::profiles();
|
||||
let mut profiles = profiles.latest();
|
||||
profiles.update_item(uid.clone(), item)?;
|
||||
@@ -385,11 +371,19 @@ pub async fn test_delay(url: String) -> Result<u32> {
|
||||
.get(url).header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
|
||||
let start = Instant::now();
|
||||
|
||||
let response = request.send().await?;
|
||||
if response.status().is_success() {
|
||||
let delay = start.elapsed().as_millis() as u32;
|
||||
Ok(delay)
|
||||
} else {
|
||||
Ok(10000u32)
|
||||
let response = request.send().await;
|
||||
match response {
|
||||
Ok(response) => {
|
||||
log::trace!(target: "app", "test_delay response: {:#?}", response);
|
||||
if response.status().is_success() {
|
||||
Ok(start.elapsed().as_millis() as u32)
|
||||
} else {
|
||||
Ok(10000u32)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::trace!(target: "app", "test_delay error: {:#?}", err);
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,15 @@ use tauri::{api, SystemTray};
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
// 单例检测
|
||||
if server::check_singleton().is_err() {
|
||||
println!("app exists");
|
||||
let app_exists: bool = tauri::async_runtime::block_on(async move {
|
||||
if server::check_singleton().await.is_err() {
|
||||
println!("app exists");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if app_exists {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -29,7 +36,9 @@ fn main() -> std::io::Result<()> {
|
||||
let mut builder = tauri::Builder::default()
|
||||
.system_tray(SystemTray::new())
|
||||
.setup(|app| {
|
||||
resolve::resolve_setup(app);
|
||||
tauri::async_runtime::block_on(async move {
|
||||
resolve::resolve_setup(app).await;
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_system_tray_event(core::tray::Tray::on_system_tray_event)
|
||||
@@ -42,9 +51,9 @@ fn main() -> std::io::Result<()> {
|
||||
cmds::open_web_url,
|
||||
cmds::open_core_dir,
|
||||
cmds::get_portable_flag,
|
||||
cmds::get_network_interfaces,
|
||||
// cmds::kill_sidecar,
|
||||
cmds::restart_sidecar,
|
||||
cmds::grant_permission,
|
||||
// clash
|
||||
cmds::get_clash_info,
|
||||
cmds::get_clash_logs,
|
||||
@@ -55,6 +64,7 @@ fn main() -> std::io::Result<()> {
|
||||
cmds::get_runtime_exists,
|
||||
cmds::get_runtime_logs,
|
||||
cmds::uwp::invoke_uwp_tool,
|
||||
cmds::copy_clash_env,
|
||||
// verge
|
||||
cmds::get_verge_config,
|
||||
cmds::patch_verge_config,
|
||||
@@ -64,6 +74,7 @@ fn main() -> std::io::Result<()> {
|
||||
cmds::download_icon_cache,
|
||||
cmds::open_devtools,
|
||||
cmds::exit_app,
|
||||
cmds::get_network_interfaces_info,
|
||||
// cmds::update_hotkeys,
|
||||
// profile
|
||||
cmds::get_profiles,
|
||||
|
||||
@@ -88,10 +88,6 @@ pub fn profiles_path() -> Result<PathBuf> {
|
||||
Ok(app_home_dir()?.join(PROFILE_YAML))
|
||||
}
|
||||
|
||||
pub fn clash_pid_path() -> Result<PathBuf> {
|
||||
Ok(app_home_dir()?.join("clash.pid"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn service_path() -> Result<PathBuf> {
|
||||
Ok(app_resources_dir()?.join("clash-verge-service"))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::enhance::seq::SeqMap;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use nanoid::nanoid;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
@@ -26,7 +27,7 @@ pub fn read_yaml<T: DeserializeOwned>(path: &PathBuf) -> Result<T> {
|
||||
}
|
||||
|
||||
/// read mapping from yaml fix #165
|
||||
pub fn read_merge_mapping(path: &PathBuf) -> Result<Mapping> {
|
||||
pub fn read_mapping(path: &PathBuf) -> Result<Mapping> {
|
||||
let mut val: Value = read_yaml(path)?;
|
||||
val.apply_merge()
|
||||
.with_context(|| format!("failed to apply merge \"{}\"", path.display()))?;
|
||||
@@ -40,6 +41,13 @@ pub fn read_merge_mapping(path: &PathBuf) -> Result<Mapping> {
|
||||
.to_owned())
|
||||
}
|
||||
|
||||
/// read mapping from yaml fix #165
|
||||
pub fn read_seq_map(path: &PathBuf) -> Result<SeqMap> {
|
||||
let val: SeqMap = read_yaml(path)?;
|
||||
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// save the data to the file
|
||||
/// can set `prefix` string to add some comments
|
||||
pub fn save_yaml<T: Serialize>(path: &PathBuf, data: &T, prefix: Option<&str>) -> Result<()> {
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn delete_log() -> Result<()> {
|
||||
}
|
||||
|
||||
let service_log_dir = log_dir.join("service");
|
||||
for file in fs::read_dir(&service_log_dir)?.flatten() {
|
||||
for file in fs::read_dir(service_log_dir)?.flatten() {
|
||||
let _ = process_file(file);
|
||||
}
|
||||
|
||||
@@ -318,11 +318,11 @@ pub fn startup_script() -> Result<()> {
|
||||
Some(dir) => {
|
||||
let _ = Command::new(shell)
|
||||
.current_dir(dir.to_path_buf())
|
||||
.args(&[path])
|
||||
.args([path])
|
||||
.output()?;
|
||||
}
|
||||
None => {
|
||||
let _ = Command::new(shell).args(&[path]).output()?;
|
||||
let _ = Command::new(shell).args([path]).output()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use crate::config::{IVerge, PrfOption};
|
||||
use crate::{
|
||||
config::{Config, PrfItem},
|
||||
core::*,
|
||||
utils::init,
|
||||
utils::server,
|
||||
};
|
||||
use crate::cmds::import_profile;
|
||||
use crate::config::IVerge;
|
||||
use crate::{config::Config, core::*, utils::init, utils::server};
|
||||
use crate::{log_err, trace_err};
|
||||
use anyhow::Result;
|
||||
use once_cell::sync::OnceCell;
|
||||
@@ -34,7 +30,7 @@ pub fn find_unused_port() -> Result<u16> {
|
||||
}
|
||||
|
||||
/// handle something when start app
|
||||
pub fn resolve_setup(app: &mut App) {
|
||||
pub async fn resolve_setup(app: &mut App) {
|
||||
#[cfg(target_os = "macos")]
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||
let version = app.package_info().version.to_string();
|
||||
@@ -73,7 +69,8 @@ pub fn resolve_setup(app: &mut App) {
|
||||
|
||||
// 启动核心
|
||||
log::trace!("init config");
|
||||
log_err!(Config::init_config());
|
||||
|
||||
log_err!(Config::init_config().await);
|
||||
|
||||
log::trace!("launch core");
|
||||
log_err!(CoreManager::global().init());
|
||||
@@ -99,16 +96,20 @@ pub fn resolve_setup(app: &mut App) {
|
||||
|
||||
let argvs: Vec<String> = std::env::args().collect();
|
||||
if argvs.len() > 1 {
|
||||
tauri::async_runtime::block_on(async {
|
||||
resolve_scheme(argvs[1].to_owned()).await;
|
||||
});
|
||||
let param = argvs[1].as_str();
|
||||
if param.starts_with("clash:") {
|
||||
log_err!(resolve_scheme(argvs[1].to_owned()).await);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// reset system proxy
|
||||
pub fn resolve_reset() {
|
||||
log_err!(sysopt::Sysopt::global().reset_sysproxy());
|
||||
log_err!(CoreManager::global().stop_core());
|
||||
tauri::async_runtime::block_on(async move {
|
||||
log_err!(CoreManager::global().stop_core().await);
|
||||
log_err!(service::unset_dns_by_service().await);
|
||||
});
|
||||
}
|
||||
|
||||
/// create main window
|
||||
@@ -205,7 +206,6 @@ pub fn create_window(app_handle: &AppHandle) {
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("failed to create window");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,31 +236,26 @@ pub fn save_window_size_position(app_handle: &AppHandle, save_to_file: bool) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_scheme(param: String) {
|
||||
pub async fn resolve_scheme(param: String) -> Result<()> {
|
||||
let url = param
|
||||
.trim_start_matches("clash://install-config/?url=")
|
||||
.trim_start_matches("clash://install-config?url=");
|
||||
let option = PrfOption {
|
||||
user_agent: None,
|
||||
with_proxy: Some(true),
|
||||
self_proxy: None,
|
||||
danger_accept_invalid_certs: None,
|
||||
update_interval: None,
|
||||
};
|
||||
if let Ok(item) = PrfItem::from_url(url, None, None, Some(option)).await {
|
||||
if Config::profiles().data().append_item(item).is_ok() {
|
||||
match import_profile(url.to_string(), None).await {
|
||||
Ok(_) => {
|
||||
notification::Notification::new(crate::utils::dirs::APP_ID)
|
||||
.title("Clash Verge")
|
||||
.body("Import profile success")
|
||||
.show()
|
||||
.unwrap();
|
||||
};
|
||||
} else {
|
||||
notification::Notification::new(crate::utils::dirs::APP_ID)
|
||||
.title("Clash Verge")
|
||||
.body("Import profile failed")
|
||||
.show()
|
||||
.unwrap();
|
||||
log::error!("failed to parse url: {}", url);
|
||||
}
|
||||
Err(e) => {
|
||||
notification::Notification::new(crate::utils::dirs::APP_ID)
|
||||
.title("Clash Verge")
|
||||
.body(format!("Import profile failed: {e}"))
|
||||
.show()
|
||||
.unwrap();
|
||||
log::error!("Import profile failed: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
extern crate warp;
|
||||
|
||||
use super::resolve;
|
||||
use crate::config::{Config, IVerge, DEFAULT_PAC};
|
||||
use crate::{
|
||||
config::{Config, IVerge, DEFAULT_PAC},
|
||||
log_err,
|
||||
};
|
||||
use anyhow::{bail, Result};
|
||||
use port_scanner::local_port_available;
|
||||
use std::convert::Infallible;
|
||||
@@ -14,38 +17,38 @@ struct QueryParam {
|
||||
}
|
||||
|
||||
/// check whether there is already exists
|
||||
pub fn check_singleton() -> Result<()> {
|
||||
pub async fn check_singleton() -> Result<()> {
|
||||
let port = IVerge::get_singleton_port();
|
||||
|
||||
if !local_port_available(port) {
|
||||
tauri::async_runtime::block_on(async {
|
||||
let resp = reqwest::get(format!("http://127.0.0.1:{port}/commands/ping"))
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
let resp = reqwest::get(format!("http://127.0.0.1:{port}/commands/ping"))
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
if &resp == "ok" {
|
||||
let argvs: Vec<String> = std::env::args().collect();
|
||||
if argvs.len() > 1 {
|
||||
let param = argvs[1].as_str();
|
||||
if &resp == "ok" {
|
||||
let argvs: Vec<String> = std::env::args().collect();
|
||||
if argvs.len() > 1 {
|
||||
let param = argvs[1].as_str();
|
||||
if param.starts_with("clash:") {
|
||||
reqwest::get(format!(
|
||||
"http://127.0.0.1:{port}/commands/scheme?param={param}"
|
||||
))
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
} else {
|
||||
reqwest::get(format!("http://127.0.0.1:{port}/commands/visible"))
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
}
|
||||
bail!("app exists");
|
||||
} else {
|
||||
reqwest::get(format!("http://127.0.0.1:{port}/commands/visible"))
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
}
|
||||
bail!("app exists");
|
||||
}
|
||||
|
||||
log::error!("failed to setup singleton listen server");
|
||||
Ok(())
|
||||
})
|
||||
log::error!("failed to setup singleton listen server");
|
||||
Ok(())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,7 +88,7 @@ pub fn embed_server(app_handle: AppHandle) {
|
||||
.and_then(scheme_handler);
|
||||
|
||||
async fn scheme_handler(query: QueryParam) -> Result<impl warp::Reply, Infallible> {
|
||||
resolve::resolve_scheme(query.param).await;
|
||||
log_err!(resolve::resolve_scheme(query.param).await);
|
||||
Ok("ok")
|
||||
}
|
||||
let commands = ping.or(visible).or(pac).or(scheme);
|
||||
|
||||
@@ -13,23 +13,51 @@ rules: []
|
||||
/// enhanced profile
|
||||
pub const ITEM_MERGE: &str = "# Profile Enhancement Merge Template for Clash Verge
|
||||
|
||||
prepend-rules: []
|
||||
profile:
|
||||
store-selected: true
|
||||
|
||||
prepend-proxies: []
|
||||
dns:
|
||||
use-system-hosts: false
|
||||
";
|
||||
|
||||
prepend-proxy-groups: []
|
||||
pub const ITEM_MERGE_EMPTY: &str = "# Profile Enhancement Merge Template for Clash Verge
|
||||
|
||||
append-rules: []
|
||||
|
||||
append-proxies: []
|
||||
|
||||
append-proxy-groups: []
|
||||
";
|
||||
|
||||
/// enhanced profile
|
||||
pub const ITEM_SCRIPT: &str = "// Define main function (script entry)
|
||||
|
||||
function main(config) {
|
||||
function main(config, profileName) {
|
||||
return config;
|
||||
}
|
||||
";
|
||||
|
||||
/// enhanced profile
|
||||
pub const ITEM_RULES: &str = "# Profile Enhancement Rules Template for Clash Verge
|
||||
|
||||
prepend: []
|
||||
|
||||
append: []
|
||||
|
||||
delete: []
|
||||
";
|
||||
|
||||
/// enhanced profile
|
||||
pub const ITEM_PROXIES: &str = "# Profile Enhancement Proxies Template for Clash Verge
|
||||
|
||||
prepend: []
|
||||
|
||||
append: []
|
||||
|
||||
delete: []
|
||||
";
|
||||
|
||||
/// enhanced profile
|
||||
pub const ITEM_GROUPS: &str = "# Profile Enhancement Groups Template for Clash Verge
|
||||
|
||||
prepend: []
|
||||
|
||||
append: []
|
||||
|
||||
delete: []
|
||||
";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"package": {
|
||||
"productName": "Clash Verge",
|
||||
"version": "1.6.3"
|
||||
"version": "1.7.4"
|
||||
},
|
||||
"build": {
|
||||
"distDir": "../dist",
|
||||
@@ -22,8 +22,9 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": ["resources"],
|
||||
"externalBin": ["sidecar/clash-meta", "sidecar/clash-meta-alpha"],
|
||||
"copyright": "© 2022 zzzgydi All Rights Reserved",
|
||||
"publisher": "Clash Verge Rev",
|
||||
"externalBin": ["sidecar/verge-mihomo", "sidecar/verge-mihomo-alpha"],
|
||||
"copyright": "GNU General Public License v3.0",
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "A Clash Meta GUI based on tauri.",
|
||||
"longDescription": "A Clash Meta GUI based on tauri."
|
||||
@@ -38,10 +39,6 @@
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
|
||||
},
|
||||
"allowlist": {
|
||||
"http": {
|
||||
"all": true,
|
||||
"scope": ["http://**", "http://**"]
|
||||
},
|
||||
"shell": {
|
||||
"all": true
|
||||
},
|
||||
@@ -79,7 +76,7 @@
|
||||
},
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "script-src 'unsafe-eval' 'self'; default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self'; img-src asset: http: https: data: 'self';"
|
||||
"csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: asset: 'unsafe-eval' 'unsafe-inline' 'self';"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,20 @@
|
||||
},
|
||||
"bundle": {
|
||||
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
|
||||
"targets": ["deb", "appimage", "updater"],
|
||||
"targets": ["deb", "rpm"],
|
||||
"deb": {
|
||||
"depends": ["openssl"],
|
||||
"desktopTemplate": "./template/clash-verge.desktop"
|
||||
"desktopTemplate": "./template/clash-verge.desktop",
|
||||
"provides": ["clash-verge"],
|
||||
"conflicts": ["clash-verge"],
|
||||
"replaces": ["clash-verge"]
|
||||
},
|
||||
"rpm": {
|
||||
"depends": ["openssl"],
|
||||
"desktopTemplate": "./template/clash-verge.desktop",
|
||||
"provides": ["clash-verge"],
|
||||
"conflicts": ["clash-verge"],
|
||||
"obsoletes": ["clash-verge"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,25 @@
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null
|
||||
},
|
||||
"dmg": {
|
||||
"background": "images/background.png",
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 170
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 480,
|
||||
"y": 170
|
||||
},
|
||||
"windowSize": {
|
||||
"height": 400,
|
||||
"width": 660
|
||||
},
|
||||
"windowPosition": {
|
||||
"x": 200,
|
||||
"y": 180
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,10 +323,15 @@ Var AppStartMenuFolder
|
||||
!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)"
|
||||
!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut
|
||||
; Show run app after installation.
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\${MAINBINARYNAME}.exe"
|
||||
!define MUI_FINISHPAGE_RUN
|
||||
!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
Function RunMainBinary
|
||||
nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" ""
|
||||
FunctionEnd
|
||||
|
||||
; Uninstaller Pages
|
||||
; 1. Confirm uninstall page
|
||||
Var DeleteAppDataCheckbox
|
||||
@@ -436,6 +441,38 @@ FunctionEnd
|
||||
!endif
|
||||
${EndIf}
|
||||
|
||||
; Check if verge-mihomo-alpha.exe is running
|
||||
!if "${INSTALLMODE}" == "currentUser"
|
||||
nsis_tauri_utils::FindProcessCurrentUser "verge-mihomo-alpha.exe"
|
||||
!else
|
||||
nsis_tauri_utils::FindProcess "verge-mihomo-alpha.exe"
|
||||
!endif
|
||||
Pop $R0
|
||||
${If} $R0 = 0
|
||||
DetailPrint "Kill verge-mihomo-alpha.exe..."
|
||||
!if "${INSTALLMODE}" == "currentUser"
|
||||
nsis_tauri_utils::KillProcessCurrentUser "verge-mihomo-alpha.exe"
|
||||
!else
|
||||
nsis_tauri_utils::KillProcess "verge-mihomo-alpha.exe"
|
||||
!endif
|
||||
${EndIf}
|
||||
|
||||
; Check if verge-mihomo.exe is running
|
||||
!if "${INSTALLMODE}" == "currentUser"
|
||||
nsis_tauri_utils::FindProcessCurrentUser "verge-mihomo.exe"
|
||||
!else
|
||||
nsis_tauri_utils::FindProcess "verge-mihomo.exe"
|
||||
!endif
|
||||
Pop $R0
|
||||
${If} $R0 = 0
|
||||
DetailPrint "Kill verge-mihomo.exe..."
|
||||
!if "${INSTALLMODE}" == "currentUser"
|
||||
nsis_tauri_utils::KillProcessCurrentUser "verge-mihomo.exe"
|
||||
!else
|
||||
nsis_tauri_utils::KillProcess "verge-mihomo.exe"
|
||||
!endif
|
||||
${EndIf}
|
||||
|
||||
; Check if clash-meta-alpha.exe is running
|
||||
!if "${INSTALLMODE}" == "currentUser"
|
||||
nsis_tauri_utils::FindProcessCurrentUser "clash-meta-alpha.exe"
|
||||
@@ -729,7 +766,7 @@ Function .onInstSuccess
|
||||
${GetOptions} $CMDLINE "/R" $R0
|
||||
IfErrors run_done 0
|
||||
${GetOptions} $CMDLINE "/ARGS" $R0
|
||||
Exec '"$INSTDIR\${MAINBINARYNAME}.exe" $R0'
|
||||
nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0"
|
||||
run_done:
|
||||
FunctionEnd
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="48" height="48"><path d="M16.125 1c-1.153.067-2.477.71-3.264 1.527-.71.744-1.272 1.85-1.043 2.918 1.253.033 2.511-.626 3.264-1.459.703-.779 1.236-1.866 1.043-2.986zm.068 4.443c-1.809 0-2.565 1.112-3.818 1.112-1.289 0-2.467-1.041-4.027-1.041C6.226 5.514 3 7.48 3 12.11 3 16.324 6.818 21 8.973 21c1.309.013 1.626-.823 3.402-.832 1.778-.013 2.162.843 3.473.832 1.476-.011 2.628-1.633 3.47-2.918.604-.92.853-1.39 1.32-2.43-3.472-.88-4.163-6.48 0-7.638-.785-1.341-3.08-2.57-4.445-2.57z"/></svg>
|
||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="48" height="48"><path d="M15 3C8.373 3 3 8.373 3 15c0 5.623 3.872 10.328 9.092 11.63a1.751 1.751 0 0 1-.092-.583v-2.051h-1.508c-.821 0-1.551-.353-1.905-1.009-.393-.729-.461-1.844-1.435-2.526-.289-.227-.069-.486.264-.451.615.174 1.125.596 1.605 1.222.478.627.703.769 1.596.769.433 0 1.081-.025 1.691-.121.328-.833.895-1.6 1.588-1.962-3.996-.411-5.903-2.399-5.903-5.098 0-1.162.495-2.286 1.336-3.233-.276-.94-.623-2.857.106-3.587 1.798 0 2.885 1.166 3.146 1.481A8.993 8.993 0 0 1 15.495 9c1.036 0 2.024.174 2.922.483C18.675 9.17 19.763 8 21.565 8c.732.731.381 2.656.102 3.594.836.945 1.328 2.066 1.328 3.226 0 2.697-1.904 4.684-5.894 5.097C18.199 20.49 19 22.1 19 23.313v2.734c0 .104-.023.179-.035.268C23.641 24.676 27 20.236 27 15c0-6.627-5.373-12-12-12z"/></svg>
|
||||
|
After Width: | Height: | Size: 829 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"/><path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/><path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/><path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"/></svg>
|
||||
|
After Width: | Height: | Size: 901 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><path fill="#FF3D00" d="M43.2 33.9c-.4 2.1-2.1 3.7-4.2 4-3.3.5-8.8 1.1-15 1.1-6.1 0-11.6-.6-15-1.1-2.1-.3-3.8-1.9-4.2-4-.4-2.3-.8-5.7-.8-9.9s.4-7.6.8-9.9c.4-2.1 2.1-3.7 4.2-4C12.3 9.6 17.8 9 24 9c6.2 0 11.6.6 15 1.1 2.1.3 3.8 1.9 4.2 4 .4 2.3.9 5.7.9 9.9-.1 4.2-.5 7.6-.9 9.9z"/><path fill="#FFF" d="M20 31V17l12 7z"/></svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -1,5 +1,6 @@
|
||||
import { alpha, Box, Typography } from "@mui/material";
|
||||
import { InboxRounded } from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
text?: React.ReactNode;
|
||||
@@ -8,6 +9,7 @@ interface Props {
|
||||
|
||||
export const BaseEmpty = (props: Props) => {
|
||||
const { text = "Empty", extra } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -22,7 +24,7 @@ export const BaseEmpty = (props: Props) => {
|
||||
})}
|
||||
>
|
||||
<InboxRounded sx={{ fontSize: "4em" }} />
|
||||
<Typography sx={{ fontSize: "1.25em" }}>{text}</Typography>
|
||||
<Typography sx={{ fontSize: "1.25em" }}>{t(`${text}`)}</Typography>
|
||||
{extra}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { Box, styled } from "@mui/material";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
fontSize?: string;
|
||||
width?: string;
|
||||
padding?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BaseFieldset: React.FC<Props> = (props: Props) => {
|
||||
const Fieldset = styled(Box)(() => ({
|
||||
position: "relative",
|
||||
border: "1px solid #bbb",
|
||||
borderRadius: "5px",
|
||||
width: props.width ?? "auto",
|
||||
padding: props.padding ?? "15px",
|
||||
}));
|
||||
|
||||
const Label = styled("legend")(({ theme }) => ({
|
||||
position: "absolute",
|
||||
top: "-10px",
|
||||
left: props.padding ?? "15px",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: props.fontSize ?? "1em",
|
||||
}));
|
||||
|
||||
return (
|
||||
<Fieldset component="fieldset">
|
||||
<Label>{props.label}</Label>
|
||||
{props.children}
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ReactNode, useState, useEffect } from "react";
|
||||
import { Box, IconButton, Slide, Snackbar, Typography } from "@mui/material";
|
||||
import { Close, CheckCircleRounded, ErrorRounded } from "@mui/icons-material";
|
||||
import {
|
||||
CloseRounded,
|
||||
CheckCircleRounded,
|
||||
ErrorRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { appWindow } from "@tauri-apps/api/window";
|
||||
interface InnerProps {
|
||||
@@ -81,7 +85,7 @@ const NoticeInner = (props: InnerProps) => {
|
||||
transitionDuration={200}
|
||||
action={
|
||||
<IconButton size="small" color="inherit" onClick={onBtnClose}>
|
||||
<Close fontSize="inherit" />
|
||||
<CloseRounded fontSize="inherit" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Typography, alpha } from "@mui/material";
|
||||
import { Typography } from "@mui/material";
|
||||
import { BaseErrorBoundary } from "./base-error-boundary";
|
||||
import { useCustomTheme } from "@/components/layout/use-custom-theme";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, SvgIcon, TextField, Theme, styled } from "@mui/material";
|
||||
import { Box, SvgIcon, TextField, styled } from "@mui/material";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
|
||||
@@ -9,6 +9,9 @@ import useRegularExpressionIcon from "@/assets/image/component/use_regular_expre
|
||||
|
||||
type SearchProps = {
|
||||
placeholder?: string;
|
||||
matchCase?: boolean;
|
||||
matchWholeWord?: boolean;
|
||||
useRegularExpression?: boolean;
|
||||
onSearch: (
|
||||
match: (content: string) => boolean,
|
||||
state: {
|
||||
@@ -22,9 +25,14 @@ type SearchProps = {
|
||||
|
||||
export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [matchCase, setMatchCase] = useState(true);
|
||||
const [matchWholeWord, setMatchWholeWord] = useState(false);
|
||||
const [useRegularExpression, setUseRegularExpression] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [matchCase, setMatchCase] = useState(props.matchCase ?? false);
|
||||
const [matchWholeWord, setMatchWholeWord] = useState(
|
||||
props.matchWholeWord ?? false
|
||||
);
|
||||
const [useRegularExpression, setUseRegularExpression] = useState(
|
||||
props.useRegularExpression ?? false
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const iconStyle = {
|
||||
@@ -36,6 +44,14 @@ export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
inheritViewBox: true,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
onChange({
|
||||
target: inputRef.current,
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
}, [matchCase, matchWholeWord, useRegularExpression]);
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
props.onSearch(
|
||||
(content) => doSearch([content], e.target?.value ?? "").length > 0,
|
||||
@@ -80,10 +96,11 @@ export const BaseSearchBox = styled((props: SearchProps) => {
|
||||
return (
|
||||
<Tooltip title={errorMessage} placement="bottom-start">
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
inputRef={inputRef}
|
||||
hiddenLabel
|
||||
fullWidth
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
variant="outlined"
|
||||
spellCheck="false"
|
||||
placeholder={props.placeholder ?? t("Filter conditions")}
|
||||
|
||||
@@ -4,7 +4,7 @@ export const BaseStyledSelect = styled((props: SelectProps<string>) => {
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
autoComplete="new-password"
|
||||
sx={{
|
||||
width: 120,
|
||||
height: 33.375,
|
||||
|
||||
@@ -6,10 +6,10 @@ export const BaseStyledTextField = styled((props: TextFieldProps) => {
|
||||
|
||||
return (
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
hiddenLabel
|
||||
fullWidth
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
variant="outlined"
|
||||
spellCheck="false"
|
||||
placeholder={t("Filter conditions")}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const Switch = styled((props: SwitchProps) => (
|
||||
width: 42,
|
||||
height: 26,
|
||||
padding: 0,
|
||||
marginRight: 1,
|
||||
"& .MuiSwitch-switchBase": {
|
||||
padding: 0,
|
||||
margin: 2,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Tooltip,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
SvgIconProps,
|
||||
} from "@mui/material";
|
||||
import { InfoRounded } from "@mui/icons-material";
|
||||
|
||||
interface Props extends IconButtonProps {
|
||||
title?: string;
|
||||
icon?: React.ElementType<SvgIconProps>;
|
||||
}
|
||||
|
||||
export const TooltipIcon: React.FC<Props> = (props: Props) => {
|
||||
const { title = "", icon: Icon = InfoRounded, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<Tooltip title={title} placement="top">
|
||||
<IconButton color="inherit" size="small" {...restProps}>
|
||||
<Icon fontSize="inherit" style={{ cursor: "pointer", opacity: 0.75 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ const Tag = styled("span")(({ theme }) => ({
|
||||
border: "1px solid",
|
||||
borderRadius: 4,
|
||||
borderColor: alpha(theme.palette.text.secondary, 0.35),
|
||||
marginTop: "4px",
|
||||
marginRight: "4px",
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridValueFormatterParams,
|
||||
} from "@mui/x-data-grid";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import { truncateStr } from "@/utils/truncate-str";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import { t } from "i18next";
|
||||
@@ -16,12 +13,15 @@ interface Props {
|
||||
|
||||
export const ConnectionTable = (props: Props) => {
|
||||
const { connections, onShowDetail } = props;
|
||||
const mode = useThemeMode();
|
||||
const isDark = mode === "light" ? false : true;
|
||||
const backgroundColor = isDark ? "#282A36" : "#ffffff";
|
||||
|
||||
const [columnVisible, setColumnVisible] = useState<
|
||||
Partial<Record<keyof IConnectionsItem, boolean>>
|
||||
>({});
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
const [columns] = useState<GridColDef[]>([
|
||||
{ field: "host", headerName: t("Host"), flex: 220, minWidth: 220 },
|
||||
{
|
||||
field: "download",
|
||||
@@ -29,8 +29,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
width: 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (params: GridValueFormatterParams<number>) =>
|
||||
parseTraffic(params.value).join(" "),
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" "),
|
||||
},
|
||||
{
|
||||
field: "upload",
|
||||
@@ -38,8 +37,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
width: 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (params: GridValueFormatterParams<number>) =>
|
||||
parseTraffic(params.value).join(" "),
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" "),
|
||||
},
|
||||
{
|
||||
field: "dlSpeed",
|
||||
@@ -47,8 +45,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
width: 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (params: GridValueFormatterParams<number>) =>
|
||||
parseTraffic(params.value).join(" ") + "/s",
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
|
||||
},
|
||||
{
|
||||
field: "ulSpeed",
|
||||
@@ -56,8 +53,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
width: 88,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
valueFormatter: (params: GridValueFormatterParams<number>) =>
|
||||
parseTraffic(params.value).join(" ") + "/s",
|
||||
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
|
||||
},
|
||||
{ field: "chains", headerName: t("Chains"), flex: 360, minWidth: 360 },
|
||||
{ field: "rule", headerName: t("Rule"), flex: 300, minWidth: 250 },
|
||||
@@ -69,11 +65,9 @@ export const ConnectionTable = (props: Props) => {
|
||||
minWidth: 100,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
sortComparator: (v1, v2) => {
|
||||
return new Date(v2).getTime() - new Date(v1).getTime();
|
||||
},
|
||||
valueFormatter: (params: GridValueFormatterParams<string>) =>
|
||||
dayjs(params.value).fromNow(),
|
||||
sortComparator: (v1: string, v2: string) =>
|
||||
new Date(v2).getTime() - new Date(v1).getTime(),
|
||||
valueFormatter: (value: number) => dayjs(value).fromNow(),
|
||||
},
|
||||
{ field: "source", headerName: t("Source"), flex: 200, minWidth: 130 },
|
||||
{
|
||||
@@ -83,7 +77,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
minWidth: 130,
|
||||
},
|
||||
{ field: "type", headerName: t("Type"), flex: 160, minWidth: 100 },
|
||||
];
|
||||
]);
|
||||
|
||||
const connRows = useMemo(() => {
|
||||
return connections.map((each) => {
|
||||
@@ -106,7 +100,6 @@ export const ConnectionTable = (props: Props) => {
|
||||
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
|
||||
destinationIP: metadata.destinationIP,
|
||||
type: `${metadata.type}(${metadata.network})`,
|
||||
|
||||
connectionData: each,
|
||||
};
|
||||
});
|
||||
@@ -119,7 +112,13 @@ export const ConnectionTable = (props: Props) => {
|
||||
columns={columns}
|
||||
onRowClick={(e) => onShowDetail(e.row.connectionData)}
|
||||
density="compact"
|
||||
sx={{ border: "none", "div:focus": { outline: "none !important" } }}
|
||||
sx={{
|
||||
border: "none",
|
||||
"div:focus": { outline: "none !important" },
|
||||
"& div[aria-rowindex]": {
|
||||
backgroundColor: `${backgroundColor} !important`,
|
||||
},
|
||||
}}
|
||||
columnVisibilityModel={columnVisible}
|
||||
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
|
||||
/>
|
||||
|
||||
@@ -36,6 +36,7 @@ export const LayoutControl = () => {
|
||||
<ButtonGroup
|
||||
variant="text"
|
||||
sx={{
|
||||
zIndex: 1000,
|
||||
height: "100%",
|
||||
".MuiButtonGroup-grouped": {
|
||||
borderRadius: "0px",
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import {
|
||||
ArrowDownward,
|
||||
ArrowUpward,
|
||||
MemoryOutlined,
|
||||
ArrowDownwardRounded,
|
||||
ArrowUpwardRounded,
|
||||
MemoryRounded,
|
||||
} 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 { useLogData } from "@/hooks/use-log-data";
|
||||
import { useVisibility } from "@/hooks/use-visibility";
|
||||
import { useWebsocket } from "@/hooks/use-websocket";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import useSWRSubscription from "swr/subscription";
|
||||
import { createSockette } from "@/utils/websocket";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isDebugEnabled, gc } from "@/services/api";
|
||||
|
||||
interface MemoryUsage {
|
||||
inuse: number;
|
||||
oslimit?: number;
|
||||
}
|
||||
|
||||
// setup the traffic
|
||||
export const LayoutTraffic = () => {
|
||||
const { t } = useTranslation();
|
||||
const { clashInfo } = useClashInfo();
|
||||
const { verge } = useVerge();
|
||||
|
||||
@@ -22,62 +31,104 @@ export const LayoutTraffic = () => {
|
||||
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();
|
||||
|
||||
const trafficWs = useWebsocket(
|
||||
(event) => {
|
||||
const data = JSON.parse(event.data) as ITrafficItem;
|
||||
trafficRef.current?.appendData(data);
|
||||
setTraffic(data);
|
||||
},
|
||||
{ onError: () => setTraffic({ up: 0, down: 0 }), errorCount: 10 }
|
||||
);
|
||||
const [isDebug, setIsDebug] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!clashInfo || !pageVisible) return;
|
||||
isDebugEnabled().then((flag) => setIsDebug(flag));
|
||||
return () => {};
|
||||
}, [isDebug]);
|
||||
|
||||
const { server = "", secret = "" } = clashInfo;
|
||||
trafficWs.connect(
|
||||
`ws://${server}/traffic?token=${encodeURIComponent(secret)}`
|
||||
);
|
||||
return () => trafficWs.disconnect();
|
||||
}, [clashInfo, pageVisible]);
|
||||
// https://swr.vercel.app/docs/subscription#deduplication
|
||||
// useSWRSubscription auto deduplicates to one subscription per key per entire app
|
||||
// So we can simply invoke it here acting as preconnect
|
||||
useLogData();
|
||||
|
||||
const { data: traffic = { up: 0, down: 0 } } = useSWRSubscription<
|
||||
ITrafficItem,
|
||||
any,
|
||||
"getRealtimeTraffic" | null
|
||||
>(
|
||||
clashInfo && pageVisible ? "getRealtimeTraffic" : null,
|
||||
(_key, { next }) => {
|
||||
const { server = "", secret = "" } = clashInfo!;
|
||||
|
||||
const s = createSockette(
|
||||
`ws://${server}/traffic?token=${encodeURIComponent(secret)}`,
|
||||
{
|
||||
onmessage(event) {
|
||||
const data = JSON.parse(event.data) as ITrafficItem;
|
||||
trafficRef.current?.appendData(data);
|
||||
next(null, data);
|
||||
},
|
||||
onerror(event) {
|
||||
this.close();
|
||||
next(event, { up: 0, down: 0 });
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
s.close();
|
||||
};
|
||||
},
|
||||
{
|
||||
fallbackData: { up: 0, down: 0 },
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
/* --------- meta memory information --------- */
|
||||
const isMetaCore = verge?.clash_core?.includes("clash-meta");
|
||||
const displayMemory = isMetaCore && (verge?.enable_memory_usage ?? true);
|
||||
|
||||
const memoryWs = useWebsocket(
|
||||
(event) => {
|
||||
setMemory(JSON.parse(event.data));
|
||||
const displayMemory = verge?.enable_memory_usage ?? true;
|
||||
|
||||
const { data: memory = { inuse: 0 } } = useSWRSubscription<
|
||||
MemoryUsage,
|
||||
any,
|
||||
"getRealtimeMemory" | null
|
||||
>(
|
||||
clashInfo && pageVisible && displayMemory ? "getRealtimeMemory" : null,
|
||||
(_key, { next }) => {
|
||||
const { server = "", secret = "" } = clashInfo!;
|
||||
|
||||
const s = createSockette(
|
||||
`ws://${server}/memory?token=${encodeURIComponent(secret)}`,
|
||||
{
|
||||
onmessage(event) {
|
||||
const data = JSON.parse(event.data) as MemoryUsage;
|
||||
next(null, data);
|
||||
},
|
||||
onerror(event) {
|
||||
this.close();
|
||||
next(event, { inuse: 0 });
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
s.close();
|
||||
};
|
||||
},
|
||||
{ onError: () => setMemory({ inuse: 0 }), errorCount: 10 }
|
||||
{
|
||||
fallbackData: { inuse: 0 },
|
||||
keepPreviousData: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
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 boxStyle: any = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
const iconStyle: any = {
|
||||
sx: { mr: "8px", fontSize: 16 },
|
||||
};
|
||||
const valStyle: any = {
|
||||
component: "span",
|
||||
// color: "primary",
|
||||
textAlign: "center",
|
||||
sx: { flex: "1 1 56px", userSelect: "none" },
|
||||
};
|
||||
@@ -90,16 +141,19 @@ export const LayoutTraffic = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="relative" onClick={trafficRef.current?.toggleStyle}>
|
||||
<Box position="relative">
|
||||
{trafficGraph && pageVisible && (
|
||||
<div style={{ width: "100%", height: 60, marginBottom: 6 }}>
|
||||
<div
|
||||
style={{ width: "100%", height: 60, marginBottom: 6 }}
|
||||
onClick={trafficRef.current?.toggleStyle}
|
||||
>
|
||||
<TrafficGraph ref={trafficRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={0.75}>
|
||||
<Box display="flex" alignItems="center" whiteSpace="nowrap">
|
||||
<ArrowUpward
|
||||
<Box title={t("Upload Speed")} {...boxStyle}>
|
||||
<ArrowUpwardRounded
|
||||
{...iconStyle}
|
||||
color={+up > 0 ? "secondary" : "disabled"}
|
||||
/>
|
||||
@@ -109,8 +163,8 @@ export const LayoutTraffic = () => {
|
||||
<Typography {...unitStyle}>{upUnit}/s</Typography>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" whiteSpace="nowrap">
|
||||
<ArrowDownward
|
||||
<Box title={t("Download Speed")} {...boxStyle}>
|
||||
<ArrowDownwardRounded
|
||||
{...iconStyle}
|
||||
color={+down > 0 ? "primary" : "disabled"}
|
||||
/>
|
||||
@@ -122,12 +176,15 @@ export const LayoutTraffic = () => {
|
||||
|
||||
{displayMemory && (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
whiteSpace="nowrap"
|
||||
title="Memory Usage"
|
||||
title={t(isDebug ? "Memory Cleanup" : "Memory Usage")}
|
||||
{...boxStyle}
|
||||
sx={{ cursor: isDebug ? "pointer" : "auto" }}
|
||||
color={isDebug ? "success.main" : "disabled"}
|
||||
onClick={async () => {
|
||||
isDebug && (await gc());
|
||||
}}
|
||||
>
|
||||
<MemoryOutlined {...iconStyle} color="disabled" />
|
||||
<MemoryRounded {...iconStyle} />
|
||||
<Typography {...valStyle}>{inuse}</Typography>
|
||||
<Typography {...unitStyle}>{inuseUnit}</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { alpha, createTheme, Shadows, Theme } from "@mui/material";
|
||||
import { appWindow } from "@tauri-apps/api/window";
|
||||
import { atomThemeMode } from "@/services/states";
|
||||
import { useSetThemeMode, useThemeMode } from "@/services/states";
|
||||
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
|
||||
@@ -12,7 +11,8 @@ import { useVerge } from "@/hooks/use-verge";
|
||||
export const useCustomTheme = () => {
|
||||
const { verge } = useVerge();
|
||||
const { theme_mode, theme_setting } = verge ?? {};
|
||||
const [mode, setMode] = useRecoilState(atomThemeMode);
|
||||
const mode = useThemeMode();
|
||||
const setMode = useSetThemeMode();
|
||||
|
||||
useEffect(() => {
|
||||
const themeMode = ["light", "dark", "system"].includes(theme_mode!)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect } from "react";
|
||||
import { useRecoilValue, useSetRecoilState } from "recoil";
|
||||
import { getClashLogs } from "@/services/cmds";
|
||||
import { useClashInfo } from "@/hooks/use-clash";
|
||||
import { atomEnableLog, atomLogData } from "@/services/states";
|
||||
import { useWebsocket } from "@/hooks/use-websocket";
|
||||
|
||||
const MAX_LOG_NUM = 1000;
|
||||
|
||||
// setup the log websocket
|
||||
export const useLogSetup = () => {
|
||||
const { clashInfo } = useClashInfo();
|
||||
|
||||
const enableLog = useRecoilValue(atomEnableLog);
|
||||
const setLogData = useSetRecoilState(atomLogData);
|
||||
|
||||
const { connect, disconnect } = useWebsocket((event) => {
|
||||
const data = JSON.parse(event.data) as ILogItem;
|
||||
const time = dayjs().format("MM-DD HH:mm:ss");
|
||||
setLogData((l) => {
|
||||
if (l.length >= MAX_LOG_NUM) l.shift();
|
||||
return [...l, { ...data, time }];
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableLog || !clashInfo) return;
|
||||
|
||||
getClashLogs().then(setLogData);
|
||||
|
||||
const { server = "", secret = "" } = clashInfo;
|
||||
connect(`ws://${server}/logs?token=${encodeURIComponent(secret)}`);
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [clashInfo, enableLog]);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
@@ -27,10 +27,10 @@ export const ConfirmViewer = (props: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t(title)}</DialogTitle>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ pb: 1, userSelect: "text" }}>
|
||||
{t(message)}
|
||||
{message}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -1,183 +1,252 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useRecoilValue } from "recoil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { atomThemeMode } from "@/services/states";
|
||||
import { readProfileFile, saveProfileFile } from "@/services/cmds";
|
||||
import {
|
||||
FormatPaintRounded,
|
||||
OpenInFullRounded,
|
||||
CloseFullscreenRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import { Notice } from "@/components/base";
|
||||
import { nanoid } from "nanoid";
|
||||
import { appWindow } from "@tauri-apps/api/window";
|
||||
import getSystem from "@/utils/get-system";
|
||||
import debounce from "@/utils/debounce";
|
||||
|
||||
import * as monaco from "monaco-editor";
|
||||
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
|
||||
import MonacoEditor from "react-monaco-editor";
|
||||
import { configureMonacoYaml } from "monaco-yaml";
|
||||
|
||||
import { type JSONSchema7 } from "json-schema";
|
||||
import metaSchema from "meta-json-schema/schemas/meta-json-schema.json";
|
||||
import mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json";
|
||||
import pac from "types-pac/pac.d.ts?raw";
|
||||
|
||||
interface Props {
|
||||
title?: string | ReactNode;
|
||||
mode: "profile" | "text";
|
||||
property: string;
|
||||
open: boolean;
|
||||
readOnly?: boolean;
|
||||
language: "yaml" | "javascript" | "css";
|
||||
schema?: "clash" | "merge";
|
||||
onClose: () => void;
|
||||
onChange?: (content?: string) => void;
|
||||
type Language = "yaml" | "javascript" | "css";
|
||||
type Schema<T extends Language> = LanguageSchemaMap[T];
|
||||
interface LanguageSchemaMap {
|
||||
yaml: "clash" | "merge";
|
||||
javascript: never;
|
||||
css: never;
|
||||
}
|
||||
|
||||
// yaml worker
|
||||
configureMonacoYaml(monaco, {
|
||||
validate: true,
|
||||
enableSchemaRequest: true,
|
||||
schemas: [
|
||||
{
|
||||
uri: "http://example.com/meta-json-schema.json",
|
||||
fileMatch: ["**/*.clash.yaml"],
|
||||
//@ts-ignore
|
||||
schema: metaSchema as JSONSchema7,
|
||||
},
|
||||
{
|
||||
uri: "http://example.com/clash-verge-merge-json-schema.json",
|
||||
fileMatch: ["**/*.merge.yaml"],
|
||||
//@ts-ignore
|
||||
schema: mergeSchema as JSONSchema7,
|
||||
},
|
||||
],
|
||||
});
|
||||
// PAC definition
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
|
||||
monaco.languages.registerCompletionItemProvider("javascript", {
|
||||
provideCompletionItems: (model, position) => ({
|
||||
suggestions: [
|
||||
interface Props<T extends Language> {
|
||||
open: boolean;
|
||||
title?: string | ReactNode;
|
||||
initialData: Promise<string>;
|
||||
readOnly?: boolean;
|
||||
language: T;
|
||||
schema?: Schema<T>;
|
||||
onChange?: (prev?: string, curr?: string) => void;
|
||||
onSave?: (prev?: string, curr?: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
const monacoInitialization = () => {
|
||||
if (initialized) return;
|
||||
|
||||
// configure yaml worker
|
||||
configureMonacoYaml(monaco, {
|
||||
validate: true,
|
||||
enableSchemaRequest: true,
|
||||
schemas: [
|
||||
{
|
||||
label: "%mixed-port%",
|
||||
kind: monaco.languages.CompletionItemKind.Text,
|
||||
insertText: "%mixed-port%",
|
||||
range: {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: model.getWordUntilPosition(position).startColumn - 1,
|
||||
endColumn: model.getWordUntilPosition(position).endColumn - 1,
|
||||
},
|
||||
uri: "http://example.com/meta-json-schema.json",
|
||||
fileMatch: ["**/*.clash.yaml"],
|
||||
// @ts-ignore
|
||||
schema: metaSchema as JSONSchema7,
|
||||
},
|
||||
{
|
||||
uri: "http://example.com/clash-verge-merge-json-schema.json",
|
||||
fileMatch: ["**/*.merge.yaml"],
|
||||
// @ts-ignore
|
||||
schema: mergeSchema as JSONSchema7,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
// configure PAC definition
|
||||
monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
|
||||
|
||||
export const EditorViewer = (props: Props) => {
|
||||
const {
|
||||
title,
|
||||
mode,
|
||||
property,
|
||||
open,
|
||||
readOnly,
|
||||
language,
|
||||
schema,
|
||||
onClose,
|
||||
onChange,
|
||||
} = props;
|
||||
initialized = true;
|
||||
};
|
||||
|
||||
export const EditorViewer = <T extends Language>(props: Props<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const editorRef = useRef<any>();
|
||||
const instanceRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const themeMode = useRecoilValue(atomThemeMode);
|
||||
const themeMode = useThemeMode();
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const {
|
||||
open = false,
|
||||
title = t("Edit File"),
|
||||
initialData = Promise.resolve(""),
|
||||
readOnly = false,
|
||||
language = "yaml",
|
||||
schema,
|
||||
onChange,
|
||||
onSave,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
let fetchContent;
|
||||
switch (mode) {
|
||||
case "profile": // profile文件
|
||||
fetchContent = readProfileFile(property);
|
||||
break;
|
||||
case "text": // 文本内容
|
||||
fetchContent = Promise.resolve(property);
|
||||
break;
|
||||
}
|
||||
fetchContent.then((data) => {
|
||||
const dom = editorRef.current;
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
|
||||
const prevData = useRef<string | undefined>("");
|
||||
const currData = useRef<string | undefined>("");
|
||||
|
||||
if (!dom) return;
|
||||
const editorWillMount = () => {
|
||||
monacoInitialization(); // initialize monaco
|
||||
};
|
||||
|
||||
if (instanceRef.current) instanceRef.current.dispose();
|
||||
const editorDidMount = async (
|
||||
editor: monaco.editor.IStandaloneCodeEditor
|
||||
) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// retrieve initial data
|
||||
await initialData.then((data) => {
|
||||
prevData.current = data;
|
||||
currData.current = data;
|
||||
|
||||
// create and set model
|
||||
const uri = monaco.Uri.parse(`${nanoid()}.${schema}.${language}`);
|
||||
const model = monaco.editor.createModel(data, language, uri);
|
||||
instanceRef.current = editor.create(editorRef.current, {
|
||||
model: model,
|
||||
language: language,
|
||||
tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
|
||||
theme: themeMode === "light" ? "vs" : "vs-dark",
|
||||
minimap: { enabled: dom.clientWidth >= 1000 }, // 超过一定宽度显示minimap滚动条
|
||||
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
|
||||
readOnly: readOnly, // 只读模式
|
||||
readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息
|
||||
renderValidationDecorations: "on", // 只读模式下显示校验信息
|
||||
quickSuggestions: {
|
||||
strings: true, // 字符串类型的建议
|
||||
comments: true, // 注释类型的建议
|
||||
other: true, // 其他类型的建议
|
||||
},
|
||||
padding: {
|
||||
top: 33, // 顶部padding防止遮挡snippets
|
||||
},
|
||||
fontFamily: `Fira Code, Roboto Mono, Roboto, Source Code Pro, Menlo, Monaco, Consolas, Courier New, monospace, "Apple Color Emoji"${
|
||||
getSystem() === "windows" ? ", twemoji mozilla" : ""
|
||||
}`,
|
||||
fontLigatures: true, // 连字符
|
||||
smoothScrolling: true, // 平滑滚动
|
||||
});
|
||||
editorRef.current?.setModel(model);
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (instanceRef.current) {
|
||||
instanceRef.current.dispose();
|
||||
instanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const onSave = useLockFn(async () => {
|
||||
const value = instanceRef.current?.getValue();
|
||||
|
||||
if (value == null) return;
|
||||
|
||||
const handleChange = useLockFn(async (value: string | undefined) => {
|
||||
try {
|
||||
if (mode === "profile") {
|
||||
await saveProfileFile(property, value);
|
||||
}
|
||||
onChange?.(value);
|
||||
currData.current = value;
|
||||
onChange?.(prevData.current, currData.current);
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
try {
|
||||
!readOnly && onSave?.(prevData.current, currData.current);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const handleClose = useLockFn(async () => {
|
||||
try {
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const editorResize = debounce(() => {
|
||||
editorRef.current?.layout();
|
||||
setTimeout(() => editorRef.current?.layout(), 500);
|
||||
}, 100);
|
||||
|
||||
useEffect(() => {
|
||||
const onResized = debounce(() => {
|
||||
editorResize();
|
||||
appWindow.isMaximized().then((maximized) => {
|
||||
setIsMaximized(() => maximized);
|
||||
});
|
||||
}, 100);
|
||||
const unlistenResized = appWindow.onResized(onResized);
|
||||
|
||||
return () => {
|
||||
unlistenResized.then((fn) => fn());
|
||||
editorRef.current?.dispose();
|
||||
editorRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
|
||||
<DialogTitle>{title ?? t("Edit File")}</DialogTitle>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ width: "auto", height: "100vh" }}>
|
||||
<div style={{ width: "100%", height: "100%" }} ref={editorRef} />
|
||||
<DialogContent
|
||||
sx={{
|
||||
width: "auto",
|
||||
height: "calc(100vh - 185px)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<MonacoEditor
|
||||
language={language}
|
||||
theme={themeMode === "light" ? "vs" : "vs-dark"}
|
||||
options={{
|
||||
tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
|
||||
minimap: {
|
||||
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
|
||||
},
|
||||
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
|
||||
readOnly: readOnly, // 只读模式
|
||||
readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息
|
||||
renderValidationDecorations: "on", // 只读模式下显示校验信息
|
||||
quickSuggestions: {
|
||||
strings: true, // 字符串类型的建议
|
||||
comments: true, // 注释类型的建议
|
||||
other: true, // 其他类型的建议
|
||||
},
|
||||
padding: {
|
||||
top: 33, // 顶部padding防止遮挡snippets
|
||||
},
|
||||
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
|
||||
getSystem() === "windows" ? ", twemoji mozilla" : ""
|
||||
}`,
|
||||
fontLigatures: true, // 连字符
|
||||
smoothScrolling: true, // 平滑滚动
|
||||
}}
|
||||
editorWillMount={editorWillMount}
|
||||
editorDidMount={editorDidMount}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<ButtonGroup
|
||||
variant="contained"
|
||||
sx={{ position: "absolute", left: "14px", bottom: "8px" }}
|
||||
>
|
||||
<IconButton
|
||||
size="medium"
|
||||
color="inherit"
|
||||
sx={{ display: readOnly ? "none" : "" }}
|
||||
title={t("Format document")}
|
||||
onClick={() =>
|
||||
editorRef.current
|
||||
?.getAction("editor.action.formatDocument")
|
||||
?.run()
|
||||
}
|
||||
>
|
||||
<FormatPaintRounded fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="medium"
|
||||
color="inherit"
|
||||
title={t(isMaximized ? "Minimize" : "Maximize")}
|
||||
onClick={() => appWindow.toggleMaximize().then(editorResize)}
|
||||
>
|
||||
{isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />}
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={onSave} variant="contained">
|
||||
{t("Save")}
|
||||
<Button onClick={handleClose} variant="outlined">
|
||||
{t(readOnly ? "Close" : "Cancel")}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
|
||||
interface Props {
|
||||
onChange: (value: string) => void;
|
||||
onChange: (file: File, value: string) => void;
|
||||
}
|
||||
|
||||
export const FileInput = (props: Props) => {
|
||||
@@ -28,7 +28,7 @@ export const FileInput = (props: Props) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
resolve(null);
|
||||
onChange(event.target?.result as string);
|
||||
onChange(file, event.target?.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
alpha,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { downloadIconCache } from "@/services/cmds";
|
||||
import { convertFileSrc } from "@tauri-apps/api/tauri";
|
||||
import { useEffect, useState } from "react";
|
||||
interface Props {
|
||||
type: "prepend" | "original" | "delete" | "append";
|
||||
group: IProxyGroupConfig;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const GroupItem = (props: Props) => {
|
||||
let { type, group, onDelete } = props;
|
||||
const sortable = type === "prepend" || type === "append";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = sortable
|
||||
? useSortable({ id: group.name })
|
||||
: {
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: null,
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
const [iconCachePath, setIconCachePath] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
initIconCachePath();
|
||||
}, [group]);
|
||||
|
||||
async function initIconCachePath() {
|
||||
if (group.icon && group.icon.trim().startsWith("http")) {
|
||||
const fileName =
|
||||
group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
|
||||
const iconPath = await downloadIconCache(group.icon, fileName);
|
||||
setIconCachePath(convertFileSrc(iconPath));
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(url: string) {
|
||||
return url.substring(url.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
dense
|
||||
sx={({ palette }) => ({
|
||||
position: "relative",
|
||||
background:
|
||||
type === "original"
|
||||
? palette.mode === "dark"
|
||||
? alpha(palette.background.paper, 0.3)
|
||||
: alpha(palette.grey[400], 0.3)
|
||||
: type === "delete"
|
||||
? alpha(palette.error.main, 0.3)
|
||||
: alpha(palette.success.main, 0.3),
|
||||
height: "100%",
|
||||
margin: "8px 0",
|
||||
borderRadius: "8px",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? "calc(infinity)" : undefined,
|
||||
})}
|
||||
>
|
||||
{group.icon && group.icon?.trim().startsWith("http") && (
|
||||
<img
|
||||
src={iconCachePath === "" ? group.icon : iconCachePath}
|
||||
width="32px"
|
||||
style={{
|
||||
marginRight: "12px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{group.icon && group.icon?.trim().startsWith("data") && (
|
||||
<img
|
||||
src={group.icon}
|
||||
width="32px"
|
||||
style={{
|
||||
marginRight: "12px",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{group.icon && group.icon?.trim().startsWith("<svg") && (
|
||||
<img
|
||||
src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`}
|
||||
width="32px"
|
||||
/>
|
||||
)}
|
||||
<ListItemText
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={setNodeRef}
|
||||
sx={{ cursor: sortable ? "move" : "" }}
|
||||
primary={
|
||||
<StyledPrimary
|
||||
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
|
||||
>
|
||||
{group.name}
|
||||
</StyledPrimary>
|
||||
}
|
||||
secondary={
|
||||
<ListItemTextChild
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
pt: "2px",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ marginTop: "2px" }}>
|
||||
<StyledTypeBox>{group.type}</StyledTypeBox>
|
||||
</Box>
|
||||
</ListItemTextChild>
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
sx: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "#ccc",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton onClick={onDelete}>
|
||||
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledPrimary = styled("div")`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ListItemTextChild = styled("span")`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
|
||||
display: "inline-block",
|
||||
border: "1px solid #ccc",
|
||||
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||
color: alpha(theme.palette.primary.main, 0.8),
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
padding: "0 4px",
|
||||
lineHeight: 1.5,
|
||||
marginRight: "8px",
|
||||
}));
|
||||
@@ -0,0 +1,918 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import yaml from "js-yaml";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
TextField,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
VerticalAlignTopRounded,
|
||||
VerticalAlignBottomRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { GroupItem } from "@/components/profile/group-item";
|
||||
import {
|
||||
getNetworkInterfaces,
|
||||
readProfileFile,
|
||||
saveProfileFile,
|
||||
} from "@/services/cmds";
|
||||
import { Notice, Switch } from "@/components/base";
|
||||
import getSystem from "@/utils/get-system";
|
||||
import { BaseSearchBox } from "../base/base-search-box";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import MonacoEditor from "react-monaco-editor";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
interface Props {
|
||||
proxiesUid: string;
|
||||
mergeUid: string;
|
||||
profileUid: string;
|
||||
property: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave?: (prev?: string, curr?: string) => void;
|
||||
}
|
||||
|
||||
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
|
||||
|
||||
export const GroupsEditorViewer = (props: Props) => {
|
||||
const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } =
|
||||
props;
|
||||
const { t } = useTranslation();
|
||||
const themeMode = useThemeMode();
|
||||
const [prevData, setPrevData] = useState("");
|
||||
const [currData, setCurrData] = useState("");
|
||||
const [visualization, setVisualization] = useState(true);
|
||||
const [match, setMatch] = useState(() => (_: string) => true);
|
||||
const [interfaceNameList, setInterfaceNameList] = useState<string[]>([]);
|
||||
const { control, watch, register, ...formIns } = useForm<IProxyGroupConfig>({
|
||||
defaultValues: {
|
||||
type: "select",
|
||||
name: "",
|
||||
interval: 300,
|
||||
timeout: 5000,
|
||||
"max-failed-times": 5,
|
||||
lazy: true,
|
||||
},
|
||||
});
|
||||
const [groupList, setGroupList] = useState<IProxyGroupConfig[]>([]);
|
||||
const [proxyPolicyList, setProxyPolicyList] = useState<string[]>([]);
|
||||
const [proxyProviderList, setProxyProviderList] = useState<string[]>([]);
|
||||
const [prependSeq, setPrependSeq] = useState<IProxyGroupConfig[]>([]);
|
||||
const [appendSeq, setAppendSeq] = useState<IProxyGroupConfig[]>([]);
|
||||
const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
|
||||
|
||||
const filteredPrependSeq = useMemo(
|
||||
() => prependSeq.filter((group) => match(group.name)),
|
||||
[prependSeq, match]
|
||||
);
|
||||
const filteredGroupList = useMemo(
|
||||
() => groupList.filter((group) => match(group.name)),
|
||||
[groupList, match]
|
||||
);
|
||||
const filteredAppendSeq = useMemo(
|
||||
() => appendSeq.filter((group) => match(group.name)),
|
||||
[appendSeq, match]
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const reorder = (
|
||||
list: IProxyGroupConfig[],
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
) => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over) {
|
||||
if (active.id !== over.id) {
|
||||
let activeIndex = 0;
|
||||
let overIndex = 0;
|
||||
prependSeq.forEach((item, index) => {
|
||||
if (item.name === active.id) {
|
||||
activeIndex = index;
|
||||
}
|
||||
if (item.name === over.id) {
|
||||
overIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
};
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over) {
|
||||
if (active.id !== over.id) {
|
||||
let activeIndex = 0;
|
||||
let overIndex = 0;
|
||||
appendSeq.forEach((item, index) => {
|
||||
if (item.name === active.id) {
|
||||
activeIndex = index;
|
||||
}
|
||||
if (item.name === over.id) {
|
||||
overIndex = index;
|
||||
}
|
||||
});
|
||||
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
};
|
||||
const fetchContent = async () => {
|
||||
let data = await readProfileFile(property);
|
||||
let obj = yaml.load(data) as ISeqProfileConfig | null;
|
||||
|
||||
setPrependSeq(obj?.prepend || []);
|
||||
setAppendSeq(obj?.append || []);
|
||||
setDeleteSeq(obj?.delete || []);
|
||||
|
||||
setPrevData(data);
|
||||
setCurrData(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currData === "") return;
|
||||
if (visualization !== true) return;
|
||||
|
||||
let obj = yaml.load(currData) as {
|
||||
prepend: [];
|
||||
append: [];
|
||||
delete: [];
|
||||
} | null;
|
||||
setPrependSeq(obj?.prepend || []);
|
||||
setAppendSeq(obj?.append || []);
|
||||
setDeleteSeq(obj?.delete || []);
|
||||
}, [visualization]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prependSeq && appendSeq && deleteSeq)
|
||||
setCurrData(
|
||||
yaml.dump(
|
||||
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
||||
{
|
||||
forceQuotes: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}, [prependSeq, appendSeq, deleteSeq]);
|
||||
|
||||
const fetchProxyPolicy = async () => {
|
||||
let data = await readProfileFile(profileUid);
|
||||
let proxiesData = await readProfileFile(proxiesUid);
|
||||
let originGroupsObj = yaml.load(data) as {
|
||||
"proxy-groups": IProxyGroupConfig[];
|
||||
} | null;
|
||||
|
||||
let originProxiesObj = yaml.load(data) as { proxies: [] } | null;
|
||||
let originProxies = originProxiesObj?.proxies || [];
|
||||
let moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null;
|
||||
let morePrependProxies = moreProxiesObj?.prepend || [];
|
||||
let moreAppendProxies = moreProxiesObj?.append || [];
|
||||
let moreDeleteProxies =
|
||||
moreProxiesObj?.delete || ([] as string[] | { name: string }[]);
|
||||
|
||||
let proxies = morePrependProxies.concat(
|
||||
originProxies.filter((proxy: any) => {
|
||||
if (proxy.name) {
|
||||
return !moreDeleteProxies.includes(proxy.name);
|
||||
} else {
|
||||
return !moreDeleteProxies.includes(proxy);
|
||||
}
|
||||
}),
|
||||
moreAppendProxies
|
||||
);
|
||||
|
||||
setProxyPolicyList(
|
||||
builtinProxyPolicies.concat(
|
||||
prependSeq.map((group: IProxyGroupConfig) => group.name),
|
||||
originGroupsObj?.["proxy-groups"]
|
||||
.map((group: IProxyGroupConfig) => group.name)
|
||||
.filter((name) => !deleteSeq.includes(name)) || [],
|
||||
appendSeq.map((group: IProxyGroupConfig) => group.name),
|
||||
proxies.map((proxy: any) => proxy.name)
|
||||
)
|
||||
);
|
||||
};
|
||||
const fetchProfile = async () => {
|
||||
let data = await readProfileFile(profileUid);
|
||||
let mergeData = await readProfileFile(mergeUid);
|
||||
let globalMergeData = await readProfileFile("Merge");
|
||||
|
||||
let originGroupsObj = yaml.load(data) as {
|
||||
"proxy-groups": IProxyGroupConfig[];
|
||||
} | null;
|
||||
|
||||
let originProviderObj = yaml.load(data) as { "proxy-providers": {} } | null;
|
||||
let originProvider = originProviderObj?.["proxy-providers"] || {};
|
||||
|
||||
let moreProviderObj = yaml.load(mergeData) as {
|
||||
"proxy-providers": {};
|
||||
} | null;
|
||||
let moreProvider = moreProviderObj?.["proxy-providers"] || {};
|
||||
|
||||
let globalProviderObj = yaml.load(globalMergeData) as {
|
||||
"proxy-providers": {};
|
||||
} | null;
|
||||
let globalProvider = globalProviderObj?.["proxy-providers"] || {};
|
||||
|
||||
let provider = Object.assign(
|
||||
{},
|
||||
originProvider,
|
||||
moreProvider,
|
||||
globalProvider
|
||||
);
|
||||
|
||||
setProxyProviderList(Object.keys(provider));
|
||||
setGroupList(originGroupsObj?.["proxy-groups"] || []);
|
||||
};
|
||||
const getInterfaceNameList = async () => {
|
||||
let list = await getNetworkInterfaces();
|
||||
setInterfaceNameList(list);
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchProxyPolicy();
|
||||
}, [prependSeq, appendSeq, deleteSeq]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
fetchContent();
|
||||
fetchProxyPolicy();
|
||||
fetchProfile();
|
||||
getInterfaceNameList();
|
||||
}, [open]);
|
||||
|
||||
const validateGroup = () => {
|
||||
let group = formIns.getValues();
|
||||
if (group.name === "") {
|
||||
throw new Error(t("Group Name Required"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
try {
|
||||
await saveProfileFile(property, currData);
|
||||
onSave?.(prevData, currData);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
|
||||
<DialogTitle>
|
||||
{
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
{t("Edit Groups")}
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setVisualization((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{visualization ? t("Advanced") : t("Visualization")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent
|
||||
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
|
||||
>
|
||||
{visualization ? (
|
||||
<>
|
||||
<List
|
||||
sx={{
|
||||
width: "50%",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: "calc(100% - 80px)",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Group Type")} />
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
options={[
|
||||
"select",
|
||||
"url-test",
|
||||
"fallback",
|
||||
"load-balance",
|
||||
"relay",
|
||||
]}
|
||||
value={field.value}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} title={t(option)}>
|
||||
{option}
|
||||
</li>
|
||||
)}
|
||||
onChange={(_, value) => value && field.onChange(value)}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Group Name")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
{...field}
|
||||
error={field.value === ""}
|
||||
required={true}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="icon"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Proxy Group Icon")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
{...field}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="proxies"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Use Proxies")} />
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{
|
||||
width: "calc(100% - 150px)",
|
||||
}}
|
||||
multiple
|
||||
options={proxyPolicyList}
|
||||
disableCloseOnSelect
|
||||
onChange={(_, value) => value && field.onChange(value)}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} title={t(option)}>
|
||||
{option}
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="use"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Use Provider")} />
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
multiple
|
||||
options={proxyProviderList}
|
||||
disableCloseOnSelect
|
||||
onChange={(_, value) => value && field.onChange(value)}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Health Check Url")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
placeholder="https://www.gstatic.com/generate_204"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
{...field}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="expected-status"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Expected Status")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
placeholder="*"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
onChange={(e) => {
|
||||
field.onChange(parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="interval"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Interval")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
placeholder="300"
|
||||
type="number"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
onChange={(e) => {
|
||||
field.onChange(parseInt(e.target.value));
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{t("seconds")}
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="timeout"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Timeout")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
placeholder="5000"
|
||||
type="number"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
onChange={(e) => {
|
||||
field.onChange(parseInt(e.target.value));
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{t("millis")}
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="max-failed-times"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Max Failed Times")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
placeholder="5"
|
||||
type="number"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
onChange={(e) => {
|
||||
field.onChange(parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="interface-name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Interface Name")} />
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
options={interfaceNameList}
|
||||
value={field.value}
|
||||
onChange={(_, value) => value && field.onChange(value)}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="routing-mark"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Routing Mark")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
onChange={(e) => {
|
||||
field.onChange(parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="filter"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Filter")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
{...field}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="exclude-filter"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Exclude Filter")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
{...field}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="exclude-type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Exclude Type")} />
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={[
|
||||
"Direct",
|
||||
"Reject",
|
||||
"RejectDrop",
|
||||
"Compatible",
|
||||
"Pass",
|
||||
"Dns",
|
||||
"Shadowsocks",
|
||||
"ShadowsocksR",
|
||||
"Snell",
|
||||
"Socks5",
|
||||
"Http",
|
||||
"Vmess",
|
||||
"Vless",
|
||||
"Trojan",
|
||||
"Hysteria",
|
||||
"Hysteria2",
|
||||
"WireGuard",
|
||||
"Tuic",
|
||||
"Relay",
|
||||
"Selector",
|
||||
"Fallback",
|
||||
"URLTest",
|
||||
"LoadBalance",
|
||||
"Ssh",
|
||||
]}
|
||||
size="small"
|
||||
disableCloseOnSelect
|
||||
sx={{ width: "calc(100% - 150px)" }}
|
||||
value={field.value?.split("|")}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value.join("|"));
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="include-all"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Include All")} />
|
||||
<Switch checked={field.value} {...field} />
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="include-all-proxies"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Include All Proxies")} />
|
||||
<Switch checked={field.value} {...field} />
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="include-all-providers"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Include All Providers")} />
|
||||
<Switch checked={field.value} {...field} />
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="lazy"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Lazy")} />
|
||||
<Switch checked={field.value} {...field} />
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="disable-udp"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Disable UDP")} />
|
||||
<Switch checked={field.value} {...field} />
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="hidden"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Item>
|
||||
<ListItemText primary={t("Hidden")} />
|
||||
<Switch checked={field.value} {...field} />
|
||||
</Item>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Item>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<VerticalAlignTopRounded />}
|
||||
onClick={() => {
|
||||
try {
|
||||
validateGroup();
|
||||
for (const item of [...prependSeq, ...groupList]) {
|
||||
if (item.name === formIns.getValues().name) {
|
||||
throw new Error(t("Group Name Already Exists"));
|
||||
}
|
||||
}
|
||||
setPrependSeq([...prependSeq, formIns.getValues()]);
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Prepend Group")}
|
||||
</Button>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<VerticalAlignBottomRounded />}
|
||||
onClick={() => {
|
||||
try {
|
||||
validateGroup();
|
||||
for (const item of [...appendSeq, ...groupList]) {
|
||||
if (item.name === formIns.getValues().name) {
|
||||
throw new Error(t("Group Name Already Exists"));
|
||||
}
|
||||
}
|
||||
setAppendSeq([...appendSeq, formIns.getValues()]);
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Append Group")}
|
||||
</Button>
|
||||
</Item>
|
||||
</List>
|
||||
|
||||
<List
|
||||
sx={{
|
||||
width: "50%",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<Virtuoso
|
||||
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
|
||||
totalCount={
|
||||
filteredGroupList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
increaseViewportBy={256}
|
||||
itemContent={(index) => {
|
||||
let shift = filteredPrependSeq.length > 0 ? 1 : 0;
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onPrependDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredPrependSeq.map((x) => {
|
||||
return x.name;
|
||||
})}
|
||||
>
|
||||
{filteredPrependSeq.map((item, index) => {
|
||||
return (
|
||||
<GroupItem
|
||||
key={`${item.name}-${index}`}
|
||||
type="prepend"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(
|
||||
prependSeq.filter(
|
||||
(v) => v.name !== item.name
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
} else if (index < filteredGroupList.length + shift) {
|
||||
let newIndex = index - shift;
|
||||
return (
|
||||
<GroupItem
|
||||
key={`${filteredGroupList[newIndex].name}-${index}`}
|
||||
type={
|
||||
deleteSeq.includes(filteredGroupList[newIndex].name)
|
||||
? "delete"
|
||||
: "original"
|
||||
}
|
||||
group={filteredGroupList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (
|
||||
deleteSeq.includes(filteredGroupList[newIndex].name)
|
||||
) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter(
|
||||
(v) => v !== filteredGroupList[newIndex].name
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setDeleteSeq((prev) => [
|
||||
...prev,
|
||||
filteredGroupList[newIndex].name,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onAppendDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredAppendSeq.map((x) => {
|
||||
return x.name;
|
||||
})}
|
||||
>
|
||||
{filteredAppendSeq.map((item, index) => {
|
||||
return (
|
||||
<GroupItem
|
||||
key={`${item.name}-${index}`}
|
||||
type="append"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(
|
||||
appendSeq.filter(
|
||||
(v) => v.name !== item.name
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language="yaml"
|
||||
value={currData}
|
||||
theme={themeMode === "light" ? "vs" : "vs-dark"}
|
||||
options={{
|
||||
tabSize: 2, // 根据语言类型设置缩进大小
|
||||
minimap: {
|
||||
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
|
||||
},
|
||||
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
|
||||
quickSuggestions: {
|
||||
strings: true, // 字符串类型的建议
|
||||
comments: true, // 注释类型的建议
|
||||
other: true, // 其他类型的建议
|
||||
},
|
||||
padding: {
|
||||
top: 33, // 顶部padding防止遮挡snippets
|
||||
},
|
||||
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
|
||||
getSystem() === "windows" ? ", twemoji mozilla" : ""
|
||||
}`,
|
||||
fontLigatures: true, // 连字符
|
||||
smoothScrolling: true, // 平滑滚动
|
||||
}}
|
||||
onChange={(value) => setCurrData(value)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Item = styled(ListItem)(() => ({
|
||||
padding: "5px 2px",
|
||||
}));
|
||||
@@ -61,7 +61,7 @@ export const LogViewer = (props: Props) => {
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t("Back")}
|
||||
{t("Close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,7 +2,6 @@ import dayjs from "dayjs";
|
||||
import { mutate } from "swr";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -16,15 +15,23 @@ import {
|
||||
Menu,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import { RefreshRounded, DragIndicator } from "@mui/icons-material";
|
||||
import { atomLoadingCache } from "@/services/states";
|
||||
import { updateProfile, deleteProfile, viewProfile } from "@/services/cmds";
|
||||
import { RefreshRounded, DragIndicatorRounded } from "@mui/icons-material";
|
||||
import { useLoadingCache, useSetLoadingCache } from "@/services/states";
|
||||
import {
|
||||
viewProfile,
|
||||
readProfileFile,
|
||||
updateProfile,
|
||||
saveProfileFile,
|
||||
} from "@/services/cmds";
|
||||
import { Notice } from "@/components/base";
|
||||
import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer";
|
||||
import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
|
||||
import { EditorViewer } from "@/components/profile/editor-viewer";
|
||||
import { ProfileBox } from "./profile-box";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import { ConfirmViewer } from "./confirm-viewer";
|
||||
import { ConfirmViewer } from "@/components/profile/confirm-viewer";
|
||||
import { open } from "@tauri-apps/api/shell";
|
||||
import { ProxiesEditorViewer } from "./proxies-editor-viewer";
|
||||
const round = keyframes`
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -37,22 +44,33 @@ interface Props {
|
||||
itemData: IProfileItem;
|
||||
onSelect: (force: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onSave?: (prev?: string, curr?: string) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const ProfileItem = (props: Props) => {
|
||||
const { selected, activating, itemData, onSelect, onEdit } = props;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: props.id });
|
||||
const { selected, activating, itemData, onSelect, onEdit, onSave, onDelete } =
|
||||
props;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: props.id });
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<any>(null);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||
const [loadingCache, setLoadingCache] = useRecoilState(atomLoadingCache);
|
||||
const loadingCache = useLoadingCache();
|
||||
const setLoadingCache = useSetLoadingCache();
|
||||
|
||||
const { uid, name = "Profile", extra, updated = 0 } = itemData;
|
||||
const { uid, name = "Profile", extra, updated = 0, option } = itemData;
|
||||
|
||||
// local file mode
|
||||
// remote file mode
|
||||
// remote file mode
|
||||
const hasUrl = !!itemData.url;
|
||||
const hasExtra = !!extra; // only subscription url has extra info
|
||||
const hasHome = !!itemData.home; // only subscription url has home page
|
||||
@@ -61,7 +79,10 @@ export const ProfileItem = (props: Props) => {
|
||||
const from = parseUrl(itemData.url);
|
||||
const description = itemData.desc;
|
||||
const expire = parseExpire(extra?.expire);
|
||||
const progress = Math.round(((download + upload) * 100) / (total + 0.1));
|
||||
const progress = Math.min(
|
||||
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
|
||||
100
|
||||
);
|
||||
|
||||
const loading = loadingCache[itemData.uid] ?? false;
|
||||
|
||||
@@ -94,6 +115,11 @@ export const ProfileItem = (props: Props) => {
|
||||
}, [hasUrl, updated]);
|
||||
|
||||
const [fileOpen, setFileOpen] = useState(false);
|
||||
const [rulesOpen, setRulesOpen] = useState(false);
|
||||
const [proxiesOpen, setProxiesOpen] = useState(false);
|
||||
const [groupsOpen, setGroupsOpen] = useState(false);
|
||||
const [mergeOpen, setMergeOpen] = useState(false);
|
||||
const [scriptOpen, setScriptOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const onOpenHome = () => {
|
||||
@@ -111,6 +137,31 @@ export const ProfileItem = (props: Props) => {
|
||||
setFileOpen(true);
|
||||
};
|
||||
|
||||
const onEditRules = () => {
|
||||
setAnchorEl(null);
|
||||
setRulesOpen(true);
|
||||
};
|
||||
|
||||
const onEditProxies = () => {
|
||||
setAnchorEl(null);
|
||||
setProxiesOpen(true);
|
||||
};
|
||||
|
||||
const onEditGroups = () => {
|
||||
setAnchorEl(null);
|
||||
setGroupsOpen(true);
|
||||
};
|
||||
|
||||
const onEditMerge = () => {
|
||||
setAnchorEl(null);
|
||||
setMergeOpen(true);
|
||||
};
|
||||
|
||||
const onEditScript = () => {
|
||||
setAnchorEl(null);
|
||||
setScriptOpen(true);
|
||||
};
|
||||
|
||||
const onForceSelect = () => {
|
||||
setAnchorEl(null);
|
||||
onSelect(true);
|
||||
@@ -162,44 +213,86 @@ export const ProfileItem = (props: Props) => {
|
||||
}
|
||||
});
|
||||
|
||||
const onDelete = useLockFn(async () => {
|
||||
setAnchorEl(null);
|
||||
try {
|
||||
await deleteProfile(itemData.uid);
|
||||
mutate("getProfiles");
|
||||
} catch (err: any) {
|
||||
Notice.error(err?.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const urlModeMenu = (
|
||||
hasHome ? [{ label: "Home", handler: onOpenHome }] : []
|
||||
hasHome ? [{ label: "Home", handler: onOpenHome, disabled: false }] : []
|
||||
).concat([
|
||||
{ label: "Select", handler: onForceSelect },
|
||||
{ label: "Edit Info", handler: onEditInfo },
|
||||
{ label: "Edit File", handler: onEditFile },
|
||||
{ label: "Open File", handler: onOpenFile },
|
||||
{ label: "Update", handler: () => onUpdate(0) },
|
||||
{ label: "Update(Proxy)", handler: () => onUpdate(2) },
|
||||
{ label: "Select", handler: onForceSelect, disabled: false },
|
||||
{ label: "Edit Info", handler: onEditInfo, disabled: false },
|
||||
{ label: "Edit File", handler: onEditFile, disabled: false },
|
||||
{
|
||||
label: "Edit Rules",
|
||||
handler: onEditRules,
|
||||
disabled: !option?.rules,
|
||||
},
|
||||
{
|
||||
label: "Edit Proxies",
|
||||
handler: onEditProxies,
|
||||
disabled: !option?.proxies,
|
||||
},
|
||||
{
|
||||
label: "Edit Groups",
|
||||
handler: onEditGroups,
|
||||
disabled: !option?.groups,
|
||||
},
|
||||
{
|
||||
label: "Extend Config",
|
||||
handler: onEditMerge,
|
||||
disabled: !option?.merge,
|
||||
},
|
||||
{
|
||||
label: "Extend Script",
|
||||
handler: onEditScript,
|
||||
disabled: !option?.script,
|
||||
},
|
||||
{ label: "Open File", handler: onOpenFile, disabled: false },
|
||||
{ label: "Update", handler: () => onUpdate(0), disabled: false },
|
||||
{ label: "Update(Proxy)", handler: () => onUpdate(2), disabled: false },
|
||||
{
|
||||
label: "Delete",
|
||||
handler: () => {
|
||||
setAnchorEl(null);
|
||||
setConfirmOpen(true);
|
||||
},
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
const fileModeMenu = [
|
||||
{ label: "Select", handler: onForceSelect },
|
||||
{ label: "Edit Info", handler: onEditInfo },
|
||||
{ label: "Edit File", handler: onEditFile },
|
||||
{ label: "Open File", handler: onOpenFile },
|
||||
{ label: "Select", handler: onForceSelect, disabled: false },
|
||||
{ label: "Edit Info", handler: onEditInfo, disabled: false },
|
||||
{ label: "Edit File", handler: onEditFile, disabled: false },
|
||||
{
|
||||
label: "Edit Rules",
|
||||
handler: onEditRules,
|
||||
disabled: !option?.rules,
|
||||
},
|
||||
{
|
||||
label: "Edit Proxies",
|
||||
handler: onEditProxies,
|
||||
disabled: !option?.proxies,
|
||||
},
|
||||
{
|
||||
label: "Edit Groups",
|
||||
handler: onEditGroups,
|
||||
disabled: !option?.groups,
|
||||
},
|
||||
{
|
||||
label: "Extend Config",
|
||||
handler: onEditMerge,
|
||||
disabled: !option?.merge,
|
||||
},
|
||||
{
|
||||
label: "Extend Script",
|
||||
handler: onEditScript,
|
||||
disabled: !option?.script,
|
||||
},
|
||||
{ label: "Open File", handler: onOpenFile, disabled: false },
|
||||
{
|
||||
label: "Delete",
|
||||
handler: () => {
|
||||
setAnchorEl(null);
|
||||
setConfirmOpen(true);
|
||||
},
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -213,8 +306,10 @@ export const ProfileItem = (props: Props) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? "calc(infinity)" : undefined,
|
||||
}}
|
||||
>
|
||||
<ProfileBox
|
||||
@@ -242,7 +337,7 @@ export const ProfileItem = (props: Props) => {
|
||||
backdropFilter: "blur(2px)",
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={20} />
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
</Box>
|
||||
)}
|
||||
<Box position="relative">
|
||||
@@ -253,7 +348,7 @@ export const ProfileItem = (props: Props) => {
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<DragIndicator
|
||||
<DragIndicatorRounded
|
||||
sx={[
|
||||
{ cursor: "move", marginLeft: "-6px" },
|
||||
({ palette: { text } }) => {
|
||||
@@ -278,6 +373,7 @@ export const ProfileItem = (props: Props) => {
|
||||
{/* only if has url can it be updated */}
|
||||
{hasUrl && (
|
||||
<IconButton
|
||||
title={t("Refresh")}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
p: "3px",
|
||||
@@ -311,7 +407,7 @@ export const ProfileItem = (props: Props) => {
|
||||
</Typography>
|
||||
) : (
|
||||
hasUrl && (
|
||||
<Typography noWrap title={`From ${from}`}>
|
||||
<Typography noWrap title={`${t("From")} ${from}`}>
|
||||
{from}
|
||||
</Typography>
|
||||
)
|
||||
@@ -322,7 +418,7 @@ export const ProfileItem = (props: Props) => {
|
||||
flex="1 0 auto"
|
||||
fontSize={14}
|
||||
textAlign="right"
|
||||
title={`Updated Time: ${parseExpire(updated)}`}
|
||||
title={`${t("Update Time")}: ${parseExpire(updated)}`}
|
||||
>
|
||||
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
|
||||
</Typography>
|
||||
@@ -333,17 +429,21 @@ export const ProfileItem = (props: Props) => {
|
||||
{/* the third line show extra info or last updated time */}
|
||||
{hasExtra ? (
|
||||
<Box sx={{ ...boxStyle, fontSize: 14 }}>
|
||||
<span title="Used / Total">
|
||||
<span title={t("Used / Total")}>
|
||||
{parseTraffic(upload + download)} / {parseTraffic(total)}
|
||||
</span>
|
||||
<span title="Expire Time">{expire}</span>
|
||||
<span title={t("Expire Time")}>{expire}</span>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ ...boxStyle, fontSize: 12, justifyContent: "flex-end" }}>
|
||||
<span title="Updated Time">{parseExpire(updated)}</span>
|
||||
<span title={t("Update Time")}>{parseExpire(updated)}</span>
|
||||
</Box>
|
||||
)}
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
style={{ opacity: total > 0 ? 1 : 0 }}
|
||||
/>
|
||||
</ProfileBox>
|
||||
|
||||
<Menu
|
||||
@@ -363,6 +463,7 @@ export const ProfileItem = (props: Props) => {
|
||||
<MenuItem
|
||||
key={item.label}
|
||||
onClick={item.handler}
|
||||
disabled={item.disabled}
|
||||
sx={[
|
||||
{
|
||||
minWidth: 120,
|
||||
@@ -382,18 +483,81 @@ export const ProfileItem = (props: Props) => {
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
{fileOpen && (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
initialData={readProfileFile(uid)}
|
||||
language="yaml"
|
||||
schema="clash"
|
||||
onSave={async (prev, curr) => {
|
||||
await saveProfileFile(uid, curr ?? "");
|
||||
onSave && onSave(prev, curr);
|
||||
}}
|
||||
onClose={() => setFileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{rulesOpen && (
|
||||
<RulesEditorViewer
|
||||
groupsUid={option?.groups ?? ""}
|
||||
mergeUid={option?.merge ?? ""}
|
||||
profileUid={uid}
|
||||
property={option?.rules ?? ""}
|
||||
open={true}
|
||||
onSave={onSave}
|
||||
onClose={() => setRulesOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{proxiesOpen && (
|
||||
<ProxiesEditorViewer
|
||||
profileUid={uid}
|
||||
property={option?.proxies ?? ""}
|
||||
open={true}
|
||||
onSave={onSave}
|
||||
onClose={() => setProxiesOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{groupsOpen && (
|
||||
<GroupsEditorViewer
|
||||
mergeUid={option?.merge ?? ""}
|
||||
proxiesUid={option?.proxies ?? ""}
|
||||
profileUid={uid}
|
||||
property={option?.groups ?? ""}
|
||||
open={true}
|
||||
onSave={onSave}
|
||||
onClose={() => {
|
||||
setGroupsOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mergeOpen && (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
initialData={readProfileFile(option?.merge ?? "")}
|
||||
language="yaml"
|
||||
schema="clash"
|
||||
onSave={async (prev, curr) => {
|
||||
await saveProfileFile(option?.merge ?? "", curr ?? "");
|
||||
onSave && onSave(prev, curr);
|
||||
}}
|
||||
onClose={() => setMergeOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{scriptOpen && (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
initialData={readProfileFile(option?.script ?? "")}
|
||||
language="javascript"
|
||||
onSave={async (prev, curr) => {
|
||||
await saveProfileFile(option?.script ?? "", curr ?? "");
|
||||
onSave && onSave(prev, curr);
|
||||
}}
|
||||
onClose={() => setScriptOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditorViewer
|
||||
mode="profile"
|
||||
property={uid}
|
||||
open={fileOpen}
|
||||
language="yaml"
|
||||
schema="clash"
|
||||
onClose={() => setFileOpen(false)}
|
||||
/>
|
||||
<ConfirmViewer
|
||||
title="Confirm deletion"
|
||||
message="This operation is not reversible"
|
||||
title={t("Confirm deletion")}
|
||||
message={t("This operation is not reversible")}
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLockFn } from "ahooks";
|
||||
@@ -12,54 +11,28 @@ import {
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { FeaturedPlayListRounded } from "@mui/icons-material";
|
||||
import { viewProfile } from "@/services/cmds";
|
||||
import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
|
||||
import { Notice } from "@/components/base";
|
||||
import { EditorViewer } from "@/components/profile/editor-viewer";
|
||||
import { ProfileBox } from "./profile-box";
|
||||
import { LogViewer } from "./log-viewer";
|
||||
import { ConfirmViewer } from "./confirm-viewer";
|
||||
|
||||
interface Props {
|
||||
selected: boolean;
|
||||
itemData: IProfileItem;
|
||||
enableNum: number;
|
||||
logInfo?: [string, string][];
|
||||
onEnable: () => void;
|
||||
onDisable: () => void;
|
||||
onMoveTop: () => void;
|
||||
onMoveEnd: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
id: "Merge" | "Script";
|
||||
onSave?: (prev?: string, curr?: string) => void;
|
||||
}
|
||||
|
||||
// profile enhanced item
|
||||
export const ProfileMore = (props: Props) => {
|
||||
const {
|
||||
selected,
|
||||
itemData,
|
||||
enableNum,
|
||||
logInfo = [],
|
||||
onEnable,
|
||||
onDisable,
|
||||
onMoveTop,
|
||||
onMoveEnd,
|
||||
onDelete,
|
||||
onEdit,
|
||||
} = props;
|
||||
const { id, logInfo = [], onSave } = props;
|
||||
|
||||
const { uid, type } = itemData;
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<any>(null);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0 });
|
||||
const [fileOpen, setFileOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
|
||||
const onEditInfo = () => {
|
||||
setAnchorEl(null);
|
||||
onEdit();
|
||||
};
|
||||
|
||||
const onEditFile = () => {
|
||||
setAnchorEl(null);
|
||||
setFileOpen(true);
|
||||
@@ -68,7 +41,7 @@ export const ProfileMore = (props: Props) => {
|
||||
const onOpenFile = useLockFn(async () => {
|
||||
setAnchorEl(null);
|
||||
try {
|
||||
await viewProfile(itemData.uid);
|
||||
await viewProfile(id);
|
||||
} catch (err: any) {
|
||||
Notice.error(err?.message || err.toString());
|
||||
}
|
||||
@@ -80,36 +53,10 @@ export const ProfileMore = (props: Props) => {
|
||||
};
|
||||
|
||||
const hasError = !!logInfo.find((e) => e[0] === "exception");
|
||||
const showMove = enableNum > 1 && !hasError;
|
||||
|
||||
const enableMenu = [
|
||||
{ label: "Disable", handler: fnWrapper(onDisable) },
|
||||
{ label: "Edit Info", handler: onEditInfo },
|
||||
const itemMenu = [
|
||||
{ label: "Edit File", handler: onEditFile },
|
||||
{ label: "Open File", handler: onOpenFile },
|
||||
{ label: "To Top", show: showMove, handler: fnWrapper(onMoveTop) },
|
||||
{ label: "To End", show: showMove, handler: fnWrapper(onMoveEnd) },
|
||||
{
|
||||
label: "Delete",
|
||||
handler: () => {
|
||||
setAnchorEl(null);
|
||||
setConfirmOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const disableMenu = [
|
||||
{ label: "Enable", handler: fnWrapper(onEnable) },
|
||||
{ label: "Edit Info", handler: onEditInfo },
|
||||
{ label: "Edit File", handler: onEditFile },
|
||||
{ label: "Open File", handler: onOpenFile },
|
||||
{
|
||||
label: "Delete",
|
||||
handler: () => {
|
||||
setAnchorEl(null);
|
||||
setConfirmOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const boxStyle = {
|
||||
@@ -123,9 +70,7 @@ export const ProfileMore = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<ProfileBox
|
||||
aria-selected={selected}
|
||||
onDoubleClick={onEditFile}
|
||||
// onClick={() => onSelect(false)}
|
||||
onContextMenu={(event) => {
|
||||
const { clientX, clientY } = event;
|
||||
setPosition({ top: clientY, left: clientX });
|
||||
@@ -144,13 +89,13 @@ export const ProfileMore = (props: Props) => {
|
||||
variant="h6"
|
||||
component="h2"
|
||||
noWrap
|
||||
title={itemData.name}
|
||||
title={t(`Global ${id}`)}
|
||||
>
|
||||
{itemData.name}
|
||||
{t(`Global ${id}`)}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
label={type}
|
||||
label={id}
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@@ -159,8 +104,8 @@ export const ProfileMore = (props: Props) => {
|
||||
</Box>
|
||||
|
||||
<Box sx={boxStyle}>
|
||||
{selected && type === "script" ? (
|
||||
hasError ? (
|
||||
{id === "Script" &&
|
||||
(hasError ? (
|
||||
<Badge color="error" variant="dot" overlap="circular">
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -182,16 +127,7 @@ export const ProfileMore = (props: Props) => {
|
||||
>
|
||||
<FeaturedPlayListRounded fontSize="inherit" />
|
||||
</IconButton>
|
||||
)
|
||||
) : (
|
||||
<Typography
|
||||
noWrap
|
||||
title={itemData.desc}
|
||||
sx={i18n.language === "zh" ? { width: "calc(100% - 75px)" } : {}}
|
||||
>
|
||||
{itemData.desc}
|
||||
</Typography>
|
||||
)}
|
||||
))}
|
||||
</Box>
|
||||
</ProfileBox>
|
||||
|
||||
@@ -208,7 +144,7 @@ export const ProfileMore = (props: Props) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{(selected ? enableMenu : disableMenu)
|
||||
{itemMenu
|
||||
.filter((item: any) => item.show !== false)
|
||||
.map((item) => (
|
||||
<MenuItem
|
||||
@@ -231,26 +167,21 @@ export const ProfileMore = (props: Props) => {
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<EditorViewer
|
||||
mode="profile"
|
||||
property={uid}
|
||||
open={fileOpen}
|
||||
language={type === "merge" ? "yaml" : "javascript"}
|
||||
schema={type === "merge" ? "merge" : undefined}
|
||||
onClose={() => setFileOpen(false)}
|
||||
/>
|
||||
<ConfirmViewer
|
||||
title="Confirm deletion"
|
||||
message="This operation is not reversible"
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
onConfirm={() => {
|
||||
onDelete();
|
||||
setConfirmOpen(false);
|
||||
}}
|
||||
/>
|
||||
{selected && (
|
||||
{fileOpen && (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
title={`${t("Global " + id)}`}
|
||||
initialData={readProfileFile(id)}
|
||||
language={id === "Merge" ? "yaml" : "javascript"}
|
||||
schema={id === "Merge" ? "clash" : undefined}
|
||||
onSave={async (prev, curr) => {
|
||||
await saveProfileFile(id, curr ?? "");
|
||||
onSave && onSave(prev, curr);
|
||||
}}
|
||||
onClose={() => setFileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{logOpen && (
|
||||
<LogViewer
|
||||
open={logOpen}
|
||||
logInfo={logInfo}
|
||||
@@ -260,8 +191,3 @@ export const ProfileMore = (props: Props) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function parseExpire(expire?: number) {
|
||||
if (!expire) return "-";
|
||||
return dayjs(expire * 1000).format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ProfileViewerRef {
|
||||
}
|
||||
|
||||
// create or edit the profile
|
||||
// remote / local / merge / script
|
||||
// remote / local
|
||||
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
||||
(props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -92,9 +92,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
||||
if (form.type === "remote" && !form.url) {
|
||||
throw new Error("The URL should not be null");
|
||||
}
|
||||
if (form.type !== "remote" && form.type !== "local") {
|
||||
delete form.option;
|
||||
}
|
||||
|
||||
if (form.option?.update_interval) {
|
||||
form.option.update_interval = +form.option.update_interval;
|
||||
} else {
|
||||
@@ -167,8 +165,6 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
||||
<Select {...field} autoFocus label={t("Type")}>
|
||||
<MenuItem value="remote">Remote</MenuItem>
|
||||
<MenuItem value="local">Local</MenuItem>
|
||||
<MenuItem value="script">Script</MenuItem>
|
||||
<MenuItem value="merge">Merge</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -228,16 +224,11 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
||||
<TextField
|
||||
{...text}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value
|
||||
?.replace(/\D/, "")
|
||||
.slice(0, 10);
|
||||
field.onChange(e);
|
||||
}}
|
||||
type="number"
|
||||
label={t("Update Interval")}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">mins</InputAdornment>
|
||||
<InputAdornment position="end">{t("mins")}</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -246,7 +237,12 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
||||
)}
|
||||
|
||||
{isLocal && openType === "new" && (
|
||||
<FileInput onChange={(val) => (fileDataRef.current = val)} />
|
||||
<FileInput
|
||||
onChange={(file, val) => {
|
||||
formIns.setValue("name", formIns.getValues("name") || file.name);
|
||||
fileDataRef.current = val;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRemote && (
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import yaml from "js-yaml";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
TextField,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
VerticalAlignTopRounded,
|
||||
VerticalAlignBottomRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { ProxyItem } from "@/components/profile/proxy-item";
|
||||
import { readProfileFile, saveProfileFile } from "@/services/cmds";
|
||||
import { Notice } from "@/components/base";
|
||||
import getSystem from "@/utils/get-system";
|
||||
import { BaseSearchBox } from "../base/base-search-box";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import MonacoEditor from "react-monaco-editor";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import parseUri from "@/utils/uri-parser";
|
||||
|
||||
interface Props {
|
||||
profileUid: string;
|
||||
property: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave?: (prev?: string, curr?: string) => void;
|
||||
}
|
||||
|
||||
export const ProxiesEditorViewer = (props: Props) => {
|
||||
const { profileUid, property, open, onClose, onSave } = props;
|
||||
const { t } = useTranslation();
|
||||
const themeMode = useThemeMode();
|
||||
const [prevData, setPrevData] = useState("");
|
||||
const [currData, setCurrData] = useState("");
|
||||
const [visualization, setVisualization] = useState(true);
|
||||
const [match, setMatch] = useState(() => (_: string) => true);
|
||||
const [proxyUri, setProxyUri] = useState<string>("");
|
||||
|
||||
const [proxyList, setProxyList] = useState<IProxyConfig[]>([]);
|
||||
const [prependSeq, setPrependSeq] = useState<IProxyConfig[]>([]);
|
||||
const [appendSeq, setAppendSeq] = useState<IProxyConfig[]>([]);
|
||||
const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
|
||||
|
||||
const filteredPrependSeq = useMemo(
|
||||
() => prependSeq.filter((proxy) => match(proxy.name)),
|
||||
[prependSeq, match]
|
||||
);
|
||||
const filteredProxyList = useMemo(
|
||||
() => proxyList.filter((proxy) => match(proxy.name)),
|
||||
[proxyList, match]
|
||||
);
|
||||
const filteredAppendSeq = useMemo(
|
||||
() => appendSeq.filter((proxy) => match(proxy.name)),
|
||||
[appendSeq, match]
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const reorder = (
|
||||
list: IProxyConfig[],
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
) => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over) {
|
||||
if (active.id !== over.id) {
|
||||
let activeIndex = 0;
|
||||
let overIndex = 0;
|
||||
prependSeq.forEach((item, index) => {
|
||||
if (item.name === active.id) {
|
||||
activeIndex = index;
|
||||
}
|
||||
if (item.name === over.id) {
|
||||
overIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
};
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over) {
|
||||
if (active.id !== over.id) {
|
||||
let activeIndex = 0;
|
||||
let overIndex = 0;
|
||||
appendSeq.forEach((item, index) => {
|
||||
if (item.name === active.id) {
|
||||
activeIndex = index;
|
||||
}
|
||||
if (item.name === over.id) {
|
||||
overIndex = index;
|
||||
}
|
||||
});
|
||||
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleParse = () => {
|
||||
let proxies = [] as IProxyConfig[];
|
||||
let names: string[] = [];
|
||||
let uris = "";
|
||||
try {
|
||||
uris = atob(proxyUri);
|
||||
} catch {
|
||||
uris = proxyUri;
|
||||
}
|
||||
uris
|
||||
.trim()
|
||||
.split("\n")
|
||||
.forEach((uri) => {
|
||||
try {
|
||||
let proxy = parseUri(uri.trim());
|
||||
if (!names.includes(proxy.name)) {
|
||||
proxies.push(proxy);
|
||||
names.push(proxy.name);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
});
|
||||
return proxies;
|
||||
};
|
||||
const fetchProfile = async () => {
|
||||
let data = await readProfileFile(profileUid);
|
||||
|
||||
let originProxiesObj = yaml.load(data) as {
|
||||
proxies: IProxyConfig[];
|
||||
} | null;
|
||||
|
||||
setProxyList(originProxiesObj?.proxies || []);
|
||||
};
|
||||
|
||||
const fetchContent = async () => {
|
||||
let data = await readProfileFile(property);
|
||||
let obj = yaml.load(data) as ISeqProfileConfig | null;
|
||||
|
||||
setPrependSeq(obj?.prepend || []);
|
||||
setAppendSeq(obj?.append || []);
|
||||
setDeleteSeq(obj?.delete || []);
|
||||
|
||||
setPrevData(data);
|
||||
setCurrData(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currData === "") return;
|
||||
if (visualization !== true) return;
|
||||
|
||||
let obj = yaml.load(currData) as {
|
||||
prepend: [];
|
||||
append: [];
|
||||
delete: [];
|
||||
} | null;
|
||||
setPrependSeq(obj?.prepend || []);
|
||||
setAppendSeq(obj?.append || []);
|
||||
setDeleteSeq(obj?.delete || []);
|
||||
}, [visualization]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prependSeq && appendSeq && deleteSeq)
|
||||
setCurrData(
|
||||
yaml.dump(
|
||||
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
||||
{
|
||||
forceQuotes: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}, [prependSeq, appendSeq, deleteSeq]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
fetchContent();
|
||||
fetchProfile();
|
||||
}, [open]);
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
try {
|
||||
await saveProfileFile(property, currData);
|
||||
onSave?.(prevData, currData);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
|
||||
<DialogTitle>
|
||||
{
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
{t("Edit Proxies")}
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setVisualization((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{visualization ? t("Advanced") : t("Visualization")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent
|
||||
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
|
||||
>
|
||||
{visualization ? (
|
||||
<>
|
||||
<List
|
||||
sx={{
|
||||
width: "50%",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: "calc(100% - 80px)",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<Item>
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
placeholder={t("Use newlines for multiple uri")}
|
||||
fullWidth
|
||||
rows={9}
|
||||
multiline
|
||||
size="small"
|
||||
onChange={(e) => setProxyUri(e.target.value)}
|
||||
/>
|
||||
</Item>
|
||||
</Box>
|
||||
<Item>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<VerticalAlignTopRounded />}
|
||||
onClick={() => {
|
||||
let proxies = handleParse();
|
||||
setPrependSeq([...prependSeq, ...proxies]);
|
||||
}}
|
||||
>
|
||||
{t("Prepend Proxy")}
|
||||
</Button>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<VerticalAlignBottomRounded />}
|
||||
onClick={() => {
|
||||
let proxies = handleParse();
|
||||
setAppendSeq([...appendSeq, ...proxies]);
|
||||
}}
|
||||
>
|
||||
{t("Append Proxy")}
|
||||
</Button>
|
||||
</Item>
|
||||
</List>
|
||||
|
||||
<List
|
||||
sx={{
|
||||
width: "50%",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<Virtuoso
|
||||
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
|
||||
totalCount={
|
||||
filteredProxyList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
increaseViewportBy={256}
|
||||
itemContent={(index) => {
|
||||
let shift = filteredPrependSeq.length > 0 ? 1 : 0;
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onPrependDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredPrependSeq.map((x) => {
|
||||
return x.name;
|
||||
})}
|
||||
>
|
||||
{filteredPrependSeq.map((item, index) => {
|
||||
return (
|
||||
<ProxyItem
|
||||
key={`${item.name}-${index}`}
|
||||
type="prepend"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(
|
||||
prependSeq.filter(
|
||||
(v) => v.name !== item.name
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
} else if (index < filteredProxyList.length + shift) {
|
||||
let newIndex = index - shift;
|
||||
return (
|
||||
<ProxyItem
|
||||
key={`${filteredProxyList[newIndex].name}-${index}`}
|
||||
type={
|
||||
deleteSeq.includes(filteredProxyList[newIndex].name)
|
||||
? "delete"
|
||||
: "original"
|
||||
}
|
||||
proxy={filteredProxyList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (
|
||||
deleteSeq.includes(filteredProxyList[newIndex].name)
|
||||
) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter(
|
||||
(v) => v !== filteredProxyList[newIndex].name
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setDeleteSeq((prev) => [
|
||||
...prev,
|
||||
filteredProxyList[newIndex].name,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onAppendDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredAppendSeq.map((x) => {
|
||||
return x.name;
|
||||
})}
|
||||
>
|
||||
{filteredAppendSeq.map((item, index) => {
|
||||
return (
|
||||
<ProxyItem
|
||||
key={`${item.name}-${index}`}
|
||||
type="append"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(
|
||||
appendSeq.filter(
|
||||
(v) => v.name !== item.name
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language="yaml"
|
||||
value={currData}
|
||||
theme={themeMode === "light" ? "vs" : "vs-dark"}
|
||||
options={{
|
||||
tabSize: 2, // 根据语言类型设置缩进大小
|
||||
minimap: {
|
||||
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
|
||||
},
|
||||
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
|
||||
quickSuggestions: {
|
||||
strings: true, // 字符串类型的建议
|
||||
comments: true, // 注释类型的建议
|
||||
other: true, // 其他类型的建议
|
||||
},
|
||||
padding: {
|
||||
top: 33, // 顶部padding防止遮挡snippets
|
||||
},
|
||||
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
|
||||
getSystem() === "windows" ? ", twemoji mozilla" : ""
|
||||
}`,
|
||||
fontLigatures: true, // 连字符
|
||||
smoothScrolling: true, // 平滑滚动
|
||||
}}
|
||||
onChange={(value) => setCurrData(value)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Item = styled(ListItem)(() => ({
|
||||
padding: "5px 2px",
|
||||
}));
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
alpha,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface Props {
|
||||
type: "prepend" | "original" | "delete" | "append";
|
||||
proxy: IProxyConfig;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const ProxyItem = (props: Props) => {
|
||||
let { type, proxy, onDelete } = props;
|
||||
const sortable = type === "prepend" || type === "append";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = sortable
|
||||
? useSortable({ id: proxy.name })
|
||||
: {
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: null,
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
dense
|
||||
sx={({ palette }) => ({
|
||||
position: "relative",
|
||||
background:
|
||||
type === "original"
|
||||
? palette.mode === "dark"
|
||||
? alpha(palette.background.paper, 0.3)
|
||||
: alpha(palette.grey[400], 0.3)
|
||||
: type === "delete"
|
||||
? alpha(palette.error.main, 0.3)
|
||||
: alpha(palette.success.main, 0.3),
|
||||
height: "100%",
|
||||
margin: "8px 0",
|
||||
borderRadius: "8px",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? "calc(infinity)" : undefined,
|
||||
})}
|
||||
>
|
||||
<ListItemText
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={setNodeRef}
|
||||
sx={{ cursor: sortable ? "move" : "" }}
|
||||
primary={
|
||||
<StyledPrimary
|
||||
title={proxy.name}
|
||||
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
|
||||
>
|
||||
{proxy.name}
|
||||
</StyledPrimary>
|
||||
}
|
||||
secondary={
|
||||
<ListItemTextChild
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
pt: "2px",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ marginTop: "2px" }}>
|
||||
<StyledTypeBox>{proxy.type}</StyledTypeBox>
|
||||
</Box>
|
||||
</ListItemTextChild>
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
sx: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "#ccc",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton onClick={onDelete}>
|
||||
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledPrimary = styled("div")`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ListItemTextChild = styled("span")`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
|
||||
display: "inline-block",
|
||||
border: "1px solid #ccc",
|
||||
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||
color: alpha(theme.palette.primary.main, 0.8),
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
padding: "0 4px",
|
||||
lineHeight: 1.5,
|
||||
marginRight: "8px",
|
||||
}));
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
alpha,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
interface Props {
|
||||
type: "prepend" | "original" | "delete" | "append";
|
||||
ruleRaw: string;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const RuleItem = (props: Props) => {
|
||||
let { type, ruleRaw, onDelete } = props;
|
||||
const sortable = type === "prepend" || type === "append";
|
||||
const rule = ruleRaw.replace(",no-resolve", "");
|
||||
|
||||
const ruleType = rule.match(/^[^,]+/)?.[0] ?? "";
|
||||
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? "";
|
||||
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = sortable
|
||||
? useSortable({ id: ruleRaw })
|
||||
: {
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: null,
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
};
|
||||
return (
|
||||
<ListItem
|
||||
dense
|
||||
sx={({ palette }) => ({
|
||||
position: "relative",
|
||||
background:
|
||||
type === "original"
|
||||
? palette.mode === "dark"
|
||||
? alpha(palette.background.paper, 0.3)
|
||||
: alpha(palette.grey[400], 0.3)
|
||||
: type === "delete"
|
||||
? alpha(palette.error.main, 0.3)
|
||||
: alpha(palette.success.main, 0.3),
|
||||
height: "100%",
|
||||
margin: "8px 0",
|
||||
borderRadius: "8px",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? "calc(infinity)" : undefined,
|
||||
})}
|
||||
>
|
||||
<ListItemText
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
ref={setNodeRef}
|
||||
sx={{ cursor: sortable ? "move" : "" }}
|
||||
primary={
|
||||
<StyledPrimary
|
||||
title={ruleContent || "-"}
|
||||
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
|
||||
>
|
||||
{ruleContent || "-"}
|
||||
</StyledPrimary>
|
||||
}
|
||||
secondary={
|
||||
<ListItemTextChild
|
||||
sx={{
|
||||
width: "62%",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
pt: "2px",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ marginTop: "2px" }}>
|
||||
<StyledTypeBox>{ruleType}</StyledTypeBox>
|
||||
</Box>
|
||||
<StyledSubtitle sx={{ color: "text.secondary" }}>
|
||||
{proxyPolicy}
|
||||
</StyledSubtitle>
|
||||
</ListItemTextChild>
|
||||
}
|
||||
secondaryTypographyProps={{
|
||||
sx: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "#ccc",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton onClick={onDelete}>
|
||||
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledPrimary = styled("div")`
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledSubtitle = styled("span")`
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
color: text.secondary;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ListItemTextChild = styled("span")`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
|
||||
display: "inline-block",
|
||||
border: "1px solid #ccc",
|
||||
borderColor: alpha(theme.palette.primary.main, 0.5),
|
||||
color: alpha(theme.palette.primary.main, 0.8),
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
padding: "0 4px",
|
||||
lineHeight: 1.5,
|
||||
marginRight: "8px",
|
||||
}));
|
||||
@@ -0,0 +1,728 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import yaml from "js-yaml";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
TextField,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
VerticalAlignTopRounded,
|
||||
VerticalAlignBottomRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { readProfileFile, saveProfileFile } from "@/services/cmds";
|
||||
import { Notice, Switch } from "@/components/base";
|
||||
import getSystem from "@/utils/get-system";
|
||||
import { RuleItem } from "@/components/profile/rule-item";
|
||||
import { BaseSearchBox } from "../base/base-search-box";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import MonacoEditor from "react-monaco-editor";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
|
||||
interface Props {
|
||||
groupsUid: string;
|
||||
mergeUid: string;
|
||||
profileUid: string;
|
||||
property: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave?: (prev?: string, curr?: string) => void;
|
||||
}
|
||||
|
||||
const portValidator = (value: string): boolean => {
|
||||
return new RegExp(
|
||||
"^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$"
|
||||
).test(value);
|
||||
};
|
||||
const ipv4CIDRValidator = (value: string): boolean => {
|
||||
return new RegExp(
|
||||
"^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\\/(?:[12]?[0-9]|3[0-2]))$"
|
||||
).test(value);
|
||||
};
|
||||
const ipv6CIDRValidator = (value: string): boolean => {
|
||||
return new RegExp(
|
||||
"^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}|::|:(?::[0-9a-fA-F]{1,4}){1,6}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,5}|(?:[0-9a-fA-F]{1,4}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$"
|
||||
).test(value);
|
||||
};
|
||||
|
||||
const rules: {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
example?: string;
|
||||
noResolve?: boolean;
|
||||
validator?: (value: string) => boolean;
|
||||
}[] = [
|
||||
{
|
||||
name: "DOMAIN",
|
||||
example: "example.com",
|
||||
},
|
||||
{
|
||||
name: "DOMAIN-SUFFIX",
|
||||
example: "example.com",
|
||||
},
|
||||
{
|
||||
name: "DOMAIN-KEYWORD",
|
||||
example: "example",
|
||||
},
|
||||
{
|
||||
name: "DOMAIN-REGEX",
|
||||
example: "example.*",
|
||||
},
|
||||
{
|
||||
name: "GEOSITE",
|
||||
example: "youtube",
|
||||
},
|
||||
{
|
||||
name: "GEOIP",
|
||||
example: "CN",
|
||||
noResolve: true,
|
||||
},
|
||||
{
|
||||
name: "SRC-GEOIP",
|
||||
example: "CN",
|
||||
},
|
||||
{
|
||||
name: "IP-ASN",
|
||||
example: "13335",
|
||||
noResolve: true,
|
||||
validator: (value) => (+value ? true : false),
|
||||
},
|
||||
{
|
||||
name: "SRC-IP-ASN",
|
||||
example: "9808",
|
||||
validator: (value) => (+value ? true : false),
|
||||
},
|
||||
{
|
||||
name: "IP-CIDR",
|
||||
example: "127.0.0.0/8",
|
||||
noResolve: true,
|
||||
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
|
||||
},
|
||||
{
|
||||
name: "IP-CIDR6",
|
||||
example: "2620:0:2d0:200::7/32",
|
||||
noResolve: true,
|
||||
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
|
||||
},
|
||||
{
|
||||
name: "SRC-IP-CIDR",
|
||||
example: "192.168.1.201/32",
|
||||
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
|
||||
},
|
||||
{
|
||||
name: "IP-SUFFIX",
|
||||
example: "8.8.8.8/24",
|
||||
noResolve: true,
|
||||
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
|
||||
},
|
||||
{
|
||||
name: "SRC-IP-SUFFIX",
|
||||
example: "192.168.1.201/8",
|
||||
validator: (value) => ipv4CIDRValidator(value) || ipv6CIDRValidator(value),
|
||||
},
|
||||
{
|
||||
name: "SRC-PORT",
|
||||
example: "7777",
|
||||
validator: (value) => portValidator(value),
|
||||
},
|
||||
{
|
||||
name: "DST-PORT",
|
||||
example: "80",
|
||||
validator: (value) => portValidator(value),
|
||||
},
|
||||
{
|
||||
name: "IN-PORT",
|
||||
example: "7890",
|
||||
validator: (value) => portValidator(value),
|
||||
},
|
||||
{
|
||||
name: "DSCP",
|
||||
example: "4",
|
||||
},
|
||||
{
|
||||
name: "PROCESS-NAME",
|
||||
example: getSystem() === "windows" ? "chrome.exe" : "curl",
|
||||
},
|
||||
{
|
||||
name: "PROCESS-PATH",
|
||||
example:
|
||||
getSystem() === "windows"
|
||||
? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
|
||||
: "/usr/bin/wget",
|
||||
},
|
||||
{
|
||||
name: "PROCESS-NAME-REGEX",
|
||||
example: ".*telegram.*",
|
||||
},
|
||||
{
|
||||
name: "PROCESS-PATH-REGEX",
|
||||
example:
|
||||
getSystem() === "windows" ? "(?i).*Application\\chrome.*" : ".*bin/wget",
|
||||
},
|
||||
{
|
||||
name: "NETWORK",
|
||||
example: "udp",
|
||||
validator: (value) => ["tcp", "udp"].includes(value),
|
||||
},
|
||||
{
|
||||
name: "UID",
|
||||
example: "1001",
|
||||
validator: (value) => (+value ? true : false),
|
||||
},
|
||||
{
|
||||
name: "IN-TYPE",
|
||||
example: "SOCKS/HTTP",
|
||||
},
|
||||
{
|
||||
name: "IN-USER",
|
||||
example: "mihomo",
|
||||
},
|
||||
{
|
||||
name: "IN-NAME",
|
||||
example: "ss",
|
||||
},
|
||||
{
|
||||
name: "SUB-RULE",
|
||||
example: "(NETWORK,tcp)",
|
||||
},
|
||||
{
|
||||
name: "RULE-SET",
|
||||
example: "providername",
|
||||
noResolve: true,
|
||||
},
|
||||
{
|
||||
name: "AND",
|
||||
example: "((DOMAIN,baidu.com),(NETWORK,UDP))",
|
||||
},
|
||||
{
|
||||
name: "OR",
|
||||
example: "((NETWORK,UDP),(DOMAIN,baidu.com))",
|
||||
},
|
||||
{
|
||||
name: "NOT",
|
||||
example: "((DOMAIN,baidu.com))",
|
||||
},
|
||||
{
|
||||
name: "MATCH",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
|
||||
|
||||
export const RulesEditorViewer = (props: Props) => {
|
||||
const { groupsUid, mergeUid, profileUid, property, open, onClose, onSave } =
|
||||
props;
|
||||
const { t } = useTranslation();
|
||||
const themeMode = useThemeMode();
|
||||
|
||||
const [prevData, setPrevData] = useState("");
|
||||
const [currData, setCurrData] = useState("");
|
||||
const [visualization, setVisualization] = useState(true);
|
||||
const [match, setMatch] = useState(() => (_: string) => true);
|
||||
|
||||
const [ruleType, setRuleType] = useState<(typeof rules)[number]>(rules[0]);
|
||||
const [ruleContent, setRuleContent] = useState("");
|
||||
const [noResolve, setNoResolve] = useState(false);
|
||||
const [proxyPolicy, setProxyPolicy] = useState(builtinProxyPolicies[0]);
|
||||
const [proxyPolicyList, setProxyPolicyList] = useState<string[]>([]);
|
||||
const [ruleList, setRuleList] = useState<string[]>([]);
|
||||
const [ruleSetList, setRuleSetList] = useState<string[]>([]);
|
||||
const [subRuleList, setSubRuleList] = useState<string[]>([]);
|
||||
|
||||
const [prependSeq, setPrependSeq] = useState<string[]>([]);
|
||||
const [appendSeq, setAppendSeq] = useState<string[]>([]);
|
||||
const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
|
||||
|
||||
const filteredPrependSeq = useMemo(
|
||||
() => prependSeq.filter((rule) => match(rule)),
|
||||
[prependSeq, match]
|
||||
);
|
||||
const filteredRuleList = useMemo(
|
||||
() => ruleList.filter((rule) => match(rule)),
|
||||
[ruleList, match]
|
||||
);
|
||||
const filteredAppendSeq = useMemo(
|
||||
() => appendSeq.filter((rule) => match(rule)),
|
||||
[appendSeq, match]
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const reorder = (list: string[], startIndex: number, endIndex: number) => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over) {
|
||||
if (active.id !== over.id) {
|
||||
let activeIndex = prependSeq.indexOf(active.id.toString());
|
||||
let overIndex = prependSeq.indexOf(over.id.toString());
|
||||
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
};
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over) {
|
||||
if (active.id !== over.id) {
|
||||
let activeIndex = appendSeq.indexOf(active.id.toString());
|
||||
let overIndex = appendSeq.indexOf(over.id.toString());
|
||||
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
|
||||
}
|
||||
}
|
||||
};
|
||||
const fetchContent = async () => {
|
||||
let data = await readProfileFile(property);
|
||||
let obj = yaml.load(data) as ISeqProfileConfig | null;
|
||||
|
||||
setPrependSeq(obj?.prepend || []);
|
||||
setAppendSeq(obj?.append || []);
|
||||
setDeleteSeq(obj?.delete || []);
|
||||
|
||||
setPrevData(data);
|
||||
setCurrData(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currData === "") return;
|
||||
if (visualization !== true) return;
|
||||
|
||||
let obj = yaml.load(currData) as ISeqProfileConfig | null;
|
||||
setPrependSeq(obj?.prepend || []);
|
||||
setAppendSeq(obj?.append || []);
|
||||
setDeleteSeq(obj?.delete || []);
|
||||
}, [visualization]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prependSeq && appendSeq && deleteSeq)
|
||||
setCurrData(
|
||||
yaml.dump(
|
||||
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
||||
{
|
||||
forceQuotes: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
}, [prependSeq, appendSeq, deleteSeq]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
let data = await readProfileFile(profileUid); // 原配置文件
|
||||
let groupsData = await readProfileFile(groupsUid); // groups配置文件
|
||||
let mergeData = await readProfileFile(mergeUid); // merge配置文件
|
||||
let globalMergeData = await readProfileFile("Merge"); // global merge配置文件
|
||||
|
||||
let rulesObj = yaml.load(data) as { rules: [] } | null;
|
||||
|
||||
let originGroupsObj = yaml.load(data) as { "proxy-groups": [] } | null;
|
||||
let originGroups = originGroupsObj?.["proxy-groups"] || [];
|
||||
let moreGroupsObj = yaml.load(groupsData) as ISeqProfileConfig | null;
|
||||
let morePrependGroups = moreGroupsObj?.["prepend"] || [];
|
||||
let moreAppendGroups = moreGroupsObj?.["append"] || [];
|
||||
let moreDeleteGroups =
|
||||
moreGroupsObj?.["delete"] || ([] as string[] | { name: string }[]);
|
||||
let groups = morePrependGroups.concat(
|
||||
originGroups.filter((group: any) => {
|
||||
if (group.name) {
|
||||
return !moreDeleteGroups.includes(group.name);
|
||||
} else {
|
||||
return !moreDeleteGroups.includes(group);
|
||||
}
|
||||
}),
|
||||
moreAppendGroups
|
||||
);
|
||||
|
||||
let originRuleSetObj = yaml.load(data) as { "rule-providers": {} } | null;
|
||||
let originRuleSet = originRuleSetObj?.["rule-providers"] || {};
|
||||
let moreRuleSetObj = yaml.load(mergeData) as {
|
||||
"rule-providers": {};
|
||||
} | null;
|
||||
let moreRuleSet = moreRuleSetObj?.["rule-providers"] || {};
|
||||
let globalRuleSetObj = yaml.load(globalMergeData) as {
|
||||
"rule-providers": {};
|
||||
} | null;
|
||||
let globalRuleSet = globalRuleSetObj?.["rule-providers"] || {};
|
||||
let ruleSet = Object.assign({}, originRuleSet, moreRuleSet, globalRuleSet);
|
||||
|
||||
let originSubRuleObj = yaml.load(data) as { "sub-rules": {} } | null;
|
||||
let originSubRule = originSubRuleObj?.["sub-rules"] || {};
|
||||
let moreSubRuleObj = yaml.load(mergeData) as { "sub-rules": {} } | null;
|
||||
let moreSubRule = moreSubRuleObj?.["sub-rules"] || {};
|
||||
let globalSubRuleObj = yaml.load(globalMergeData) as {
|
||||
"sub-rules": {};
|
||||
} | null;
|
||||
let globalSubRule = globalSubRuleObj?.["sub-rules"] || {};
|
||||
let subRule = Object.assign({}, originSubRule, moreSubRule, globalSubRule);
|
||||
setProxyPolicyList(
|
||||
builtinProxyPolicies.concat(groups.map((group: any) => group.name))
|
||||
);
|
||||
setRuleSetList(Object.keys(ruleSet));
|
||||
setSubRuleList(Object.keys(subRule));
|
||||
setRuleList(rulesObj?.rules || []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
fetchContent();
|
||||
fetchProfile();
|
||||
}, [open]);
|
||||
|
||||
const validateRule = () => {
|
||||
if ((ruleType.required ?? true) && !ruleContent) {
|
||||
throw new Error(t("Rule Condition Required"));
|
||||
}
|
||||
if (ruleType.validator && !ruleType.validator(ruleContent)) {
|
||||
throw new Error(t("Invalid Rule"));
|
||||
}
|
||||
|
||||
const condition = ruleType.required ?? true ? ruleContent : "";
|
||||
return `${ruleType.name}${condition ? "," + condition : ""},${proxyPolicy}${
|
||||
ruleType.noResolve && noResolve ? ",no-resolve" : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
try {
|
||||
await saveProfileFile(property, currData);
|
||||
onSave?.(prevData, currData);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
|
||||
<DialogTitle>
|
||||
{
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
{t("Edit Rules")}
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setVisualization((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{visualization ? t("Advanced") : t("Visualization")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent
|
||||
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
|
||||
>
|
||||
{visualization ? (
|
||||
<>
|
||||
<List
|
||||
sx={{
|
||||
width: "50%",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
<Item>
|
||||
<ListItemText primary={t("Rule Type")} />
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ minWidth: "240px" }}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
options={rules}
|
||||
value={ruleType}
|
||||
getOptionLabel={(option) => option.name}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} title={t(option.name)}>
|
||||
{option.name}
|
||||
</li>
|
||||
)}
|
||||
onChange={(_, value) => value && setRuleType(value)}
|
||||
/>
|
||||
</Item>
|
||||
<Item
|
||||
sx={{ display: !(ruleType.required ?? true) ? "none" : "" }}
|
||||
>
|
||||
<ListItemText primary={t("Rule Content")} />
|
||||
|
||||
{ruleType.name === "RULE-SET" && (
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ minWidth: "240px" }}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
options={ruleSetList}
|
||||
value={ruleContent}
|
||||
onChange={(_, value) => value && setRuleContent(value)}
|
||||
/>
|
||||
)}
|
||||
{ruleType.name === "SUB-RULE" && (
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ minWidth: "240px" }}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
options={subRuleList}
|
||||
value={ruleContent}
|
||||
onChange={(_, value) => value && setRuleContent(value)}
|
||||
/>
|
||||
)}
|
||||
{ruleType.name !== "RULE-SET" &&
|
||||
ruleType.name !== "SUB-RULE" && (
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
sx={{ minWidth: "240px" }}
|
||||
value={ruleContent}
|
||||
required={ruleType.required ?? true}
|
||||
error={(ruleType.required ?? true) && !ruleContent}
|
||||
placeholder={ruleType.example}
|
||||
onChange={(e) => setRuleContent(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Item>
|
||||
<Item>
|
||||
<ListItemText primary={t("Proxy Policy")} />
|
||||
<Autocomplete
|
||||
size="small"
|
||||
sx={{ minWidth: "240px" }}
|
||||
renderInput={(params) => <TextField {...params} />}
|
||||
options={proxyPolicyList}
|
||||
value={proxyPolicy}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} title={t(option)}>
|
||||
{option}
|
||||
</li>
|
||||
)}
|
||||
onChange={(_, value) => value && setProxyPolicy(value)}
|
||||
/>
|
||||
</Item>
|
||||
{ruleType.noResolve && (
|
||||
<Item>
|
||||
<ListItemText primary={t("No Resolve")} />
|
||||
<Switch
|
||||
checked={noResolve}
|
||||
onChange={() => setNoResolve(!noResolve)}
|
||||
/>
|
||||
</Item>
|
||||
)}
|
||||
<Item>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<VerticalAlignTopRounded />}
|
||||
onClick={() => {
|
||||
try {
|
||||
let raw = validateRule();
|
||||
if (prependSeq.includes(raw)) return;
|
||||
setPrependSeq([...prependSeq, raw]);
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Prepend Rule")}
|
||||
</Button>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<VerticalAlignBottomRounded />}
|
||||
onClick={() => {
|
||||
try {
|
||||
let raw = validateRule();
|
||||
if (appendSeq.includes(raw)) return;
|
||||
setAppendSeq([...appendSeq, raw]);
|
||||
} catch (err: any) {
|
||||
Notice.error(err.message || err.toString());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Append Rule")}
|
||||
</Button>
|
||||
</Item>
|
||||
</List>
|
||||
|
||||
<List
|
||||
sx={{
|
||||
width: "50%",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<Virtuoso
|
||||
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
|
||||
totalCount={
|
||||
filteredRuleList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
increaseViewportBy={256}
|
||||
itemContent={(index) => {
|
||||
let shift = filteredPrependSeq.length > 0 ? 1 : 0;
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onPrependDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredPrependSeq.map((x) => {
|
||||
return x;
|
||||
})}
|
||||
>
|
||||
{filteredPrependSeq.map((item, index) => {
|
||||
return (
|
||||
<RuleItem
|
||||
key={`${item}-${index}`}
|
||||
type="prepend"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(
|
||||
prependSeq.filter((v) => v !== item)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
} else if (index < filteredRuleList.length + shift) {
|
||||
let newIndex = index - shift;
|
||||
return (
|
||||
<RuleItem
|
||||
key={`${filteredRuleList[newIndex]}-${index}`}
|
||||
type={
|
||||
deleteSeq.includes(filteredRuleList[newIndex])
|
||||
? "delete"
|
||||
: "original"
|
||||
}
|
||||
ruleRaw={filteredRuleList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (deleteSeq.includes(filteredRuleList[newIndex])) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter(
|
||||
(v) => v !== filteredRuleList[newIndex]
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setDeleteSeq((prev) => [
|
||||
...prev,
|
||||
filteredRuleList[newIndex],
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onAppendDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={filteredAppendSeq.map((x) => {
|
||||
return x;
|
||||
})}
|
||||
>
|
||||
{filteredAppendSeq.map((item, index) => {
|
||||
return (
|
||||
<RuleItem
|
||||
key={`${item}-${index}`}
|
||||
type="append"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(
|
||||
appendSeq.filter((v) => v !== item)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language="yaml"
|
||||
value={currData}
|
||||
theme={themeMode === "light" ? "vs" : "vs-dark"}
|
||||
options={{
|
||||
tabSize: 2, // 根据语言类型设置缩进大小
|
||||
minimap: {
|
||||
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
|
||||
},
|
||||
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
|
||||
quickSuggestions: {
|
||||
strings: true, // 字符串类型的建议
|
||||
comments: true, // 注释类型的建议
|
||||
other: true, // 其他类型的建议
|
||||
},
|
||||
padding: {
|
||||
top: 33, // 顶部padding防止遮挡snippets
|
||||
},
|
||||
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
|
||||
getSystem() === "windows" ? ", twemoji mozilla" : ""
|
||||
}`,
|
||||
fontLigatures: true, // 连字符
|
||||
smoothScrolling: true, // 平滑滚动
|
||||
}}
|
||||
onChange={(value) => setCurrData(value)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="outlined">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Item = styled(ListItem)(() => ({
|
||||
padding: "5px 2px",
|
||||
}));
|
||||
@@ -69,7 +69,7 @@ export const ProviderButton = () => {
|
||||
sx={{ textTransform: "capitalize" }}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{t("Provider")}
|
||||
{t("Proxy Provider")}
|
||||
</Button>
|
||||
|
||||
<BaseDialog
|
||||
@@ -81,11 +81,9 @@ export const ProviderButton = () => {
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
Object.entries(data || {}).forEach(
|
||||
async ([key, item], index) => {
|
||||
await handleUpdate(key, index);
|
||||
}
|
||||
);
|
||||
Object.entries(data || {}).forEach(async ([key], index) => {
|
||||
await handleUpdate(key, index);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("Update All")}
|
||||
@@ -94,7 +92,7 @@ export const ProviderButton = () => {
|
||||
}
|
||||
contentSx={{ width: 400 }}
|
||||
disableOk
|
||||
cancelBtn={t("Cancel")}
|
||||
cancelBtn={t("Close")}
|
||||
onClose={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
@@ -107,8 +105,9 @@ export const ProviderButton = () => {
|
||||
const download = sub?.Download || 0;
|
||||
const total = sub?.Total || 0;
|
||||
const expire = sub?.Expire || 0;
|
||||
const progress = Math.round(
|
||||
((download + upload) * 100) / (total + 0.1)
|
||||
const progress = Math.min(
|
||||
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
|
||||
100
|
||||
);
|
||||
return (
|
||||
<>
|
||||
@@ -161,6 +160,7 @@ export const ProviderButton = () => {
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
style={{ opacity: total > 0 ? 1 : 0 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -171,7 +171,7 @@ export const ProviderButton = () => {
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title="Update Provider"
|
||||
title={`${t("Update")}${t("Proxy Provider")}`}
|
||||
onClick={() => handleUpdate(key, index)}
|
||||
sx={{
|
||||
animation: updating[index]
|
||||
|
||||
@@ -14,12 +14,14 @@ import { BaseEmpty } from "../base";
|
||||
import { useRenderList } from "./use-render-list";
|
||||
import { ProxyRender } from "./proxy-render";
|
||||
import delayManager from "@/services/delay";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export const ProxyGroups = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { mode } = props;
|
||||
|
||||
const { renderList, onProxies, onHeadState } = useRenderList(mode);
|
||||
@@ -116,7 +118,7 @@ export const ProxyGroups = (props: Props) => {
|
||||
};
|
||||
|
||||
if (mode === "direct") {
|
||||
return <BaseEmpty text="Direct Mode" />;
|
||||
return <BaseEmpty text={t("clash_mode_direct")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -132,6 +132,7 @@ export const ProxyHead = (props: Props) => {
|
||||
|
||||
{textState === "filter" && (
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
autoFocus={autoFocus}
|
||||
hiddenLabel
|
||||
value={filterText}
|
||||
@@ -145,10 +146,10 @@ export const ProxyHead = (props: Props) => {
|
||||
|
||||
{textState === "url" && (
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
autoFocus={autoFocus}
|
||||
hiddenLabel
|
||||
autoSave="off"
|
||||
autoComplete="off"
|
||||
value={testUrl}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
|
||||
@@ -21,6 +21,8 @@ export const ProxyItemMini = (props: Props) => {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
|
||||
const isPreset = presetList.includes(proxy.name);
|
||||
// -1/<=0 为 不显示
|
||||
// -2 为 loading
|
||||
const [delay, setDelay] = useState(-1);
|
||||
@@ -28,6 +30,7 @@ export const ProxyItemMini = (props: Props) => {
|
||||
const timeout = verge?.default_latency_timeout || 10000;
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreset) return;
|
||||
delayManager.setListener(proxy.name, group.name, setDelay);
|
||||
|
||||
return () => {
|
||||
@@ -160,7 +163,9 @@ export const ProxyItemMini = (props: Props) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ ml: 0.5, color: "primary.main" }}>
|
||||
<Box
|
||||
sx={{ ml: 0.5, color: "primary.main", display: isPreset ? "none" : "" }}
|
||||
>
|
||||
{delay === -2 && (
|
||||
<Widget>
|
||||
<BaseLoading />
|
||||
|
||||
@@ -46,12 +46,15 @@ const TypeBox = styled(Box)(({ theme }) => ({
|
||||
export const ProxyItem = (props: Props) => {
|
||||
const { group, proxy, selected, showType = true, sx, onClick } = props;
|
||||
|
||||
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
|
||||
const isPreset = presetList.includes(proxy.name);
|
||||
// -1/<=0 为 不显示
|
||||
// -2 为 loading
|
||||
const [delay, setDelay] = useState(-1);
|
||||
const { verge } = useVerge();
|
||||
const timeout = verge?.default_latency_timeout || 10000;
|
||||
useEffect(() => {
|
||||
if (isPreset) return;
|
||||
delayManager.setListener(proxy.name, group.name, setDelay);
|
||||
|
||||
return () => {
|
||||
@@ -131,7 +134,11 @@ export const ProxyItem = (props: Props) => {
|
||||
/>
|
||||
|
||||
<ListItemIcon
|
||||
sx={{ justifyContent: "flex-end", color: "primary.main" }}
|
||||
sx={{
|
||||
justifyContent: "flex-end",
|
||||
color: "primary.main",
|
||||
display: isPreset ? "none" : "",
|
||||
}}
|
||||
>
|
||||
{delay === -2 && (
|
||||
<Widget>
|
||||
|
||||
@@ -17,8 +17,7 @@ import { ProxyItem } from "./proxy-item";
|
||||
import { ProxyItemMini } from "./proxy-item-mini";
|
||||
import type { IRenderItem } from "./use-render-list";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { atomThemeMode } from "@/services/states";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { convertFileSrc } from "@tauri-apps/api/tauri";
|
||||
import { downloadIconCache } from "@/services/cmds";
|
||||
@@ -38,7 +37,7 @@ export const ProxyRender = (props: RenderProps) => {
|
||||
const { type, group, headState, proxy, proxyCol } = item;
|
||||
const { verge } = useVerge();
|
||||
const enable_group_icon = verge?.enable_group_icon ?? true;
|
||||
const [mode] = useRecoilState(atomThemeMode);
|
||||
const mode = useThemeMode();
|
||||
const isDark = mode === "light" ? false : true;
|
||||
const itembackgroundcolor = isDark ? "#282A36" : "#ffffff";
|
||||
const [iconCachePath, setIconCachePath] = useState("");
|
||||
|
||||
@@ -67,7 +67,7 @@ export const ProviderButton = () => {
|
||||
sx={{ textTransform: "capitalize" }}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{t("Provider")}
|
||||
{t("Rule Provider")}
|
||||
</Button>
|
||||
|
||||
<BaseDialog
|
||||
@@ -79,11 +79,9 @@ export const ProviderButton = () => {
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
Object.entries(data || {}).forEach(
|
||||
async ([key, item], index) => {
|
||||
await handleUpdate(key, index);
|
||||
}
|
||||
);
|
||||
Object.entries(data || {}).forEach(async ([key], index) => {
|
||||
await handleUpdate(key, index);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("Update All")}
|
||||
@@ -92,7 +90,7 @@ export const ProviderButton = () => {
|
||||
}
|
||||
contentSx={{ width: 400 }}
|
||||
disableOk
|
||||
cancelBtn={t("Cancel")}
|
||||
cancelBtn={t("Close")}
|
||||
onClose={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
@@ -145,7 +143,7 @@ export const ProviderButton = () => {
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title="Update Provider"
|
||||
title={`${t("Update")}${t("Rule Provider")}`}
|
||||
onClick={() => handleUpdate(key, index)}
|
||||
sx={{
|
||||
animation: updating[index]
|
||||
|
||||
@@ -5,27 +5,26 @@ import { useTranslation } from "react-i18next";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { SwitchAccessShortcut, RestartAlt } from "@mui/icons-material";
|
||||
import {
|
||||
SwitchAccessShortcutRounded,
|
||||
RestartAltRounded,
|
||||
} from "@mui/icons-material";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Tooltip,
|
||||
Chip,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
} from "@mui/material";
|
||||
import { changeClashCore, restartSidecar } from "@/services/cmds";
|
||||
import { closeAllConnections, upgradeCore } from "@/services/api";
|
||||
import { grantPermission } from "@/services/cmds";
|
||||
import getSystem from "@/utils/get-system";
|
||||
|
||||
const VALID_CORE = [
|
||||
{ name: "Clash Meta", core: "clash-meta" },
|
||||
{ name: "Clash Meta Alpha", core: "clash-meta-alpha" },
|
||||
{ name: "Mihomo", core: "verge-mihomo", chip: "Release Version" },
|
||||
{ name: "Mihomo Alpha", core: "verge-mihomo-alpha", chip: "Alpha Version" },
|
||||
];
|
||||
|
||||
const OS = getSystem();
|
||||
|
||||
export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -39,7 +38,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
close: () => setOpen(false),
|
||||
}));
|
||||
|
||||
const { clash_core = "clash-meta" } = verge ?? {};
|
||||
const { clash_core = "verge-mihomo" } = verge ?? {};
|
||||
|
||||
const onCoreChange = useLockFn(async (core: string) => {
|
||||
if (core === clash_core) return;
|
||||
@@ -58,17 +57,6 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
}
|
||||
});
|
||||
|
||||
const onGrant = useLockFn(async (core: string) => {
|
||||
try {
|
||||
await grantPermission(core);
|
||||
// 自动重启
|
||||
if (core === clash_core) await restartSidecar();
|
||||
Notice.success(t("Permissions Granted Successfully for _clash Core", { core: `${core}` }), 1000);
|
||||
} catch (err: any) {
|
||||
Notice.error(err?.message || err.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const onRestart = useLockFn(async () => {
|
||||
try {
|
||||
await restartSidecar();
|
||||
@@ -100,7 +88,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
<LoadingButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<SwitchAccessShortcut />}
|
||||
startIcon={<SwitchAccessShortcutRounded />}
|
||||
loadingPosition="start"
|
||||
loading={upgrading}
|
||||
sx={{ marginRight: "8px" }}
|
||||
@@ -112,7 +100,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={onRestart}
|
||||
startIcon={<RestartAlt />}
|
||||
startIcon={<RestartAltRounded />}
|
||||
>
|
||||
{t("Restart")}
|
||||
</Button>
|
||||
@@ -128,7 +116,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
marginTop: "-8px",
|
||||
}}
|
||||
disableOk
|
||||
cancelBtn={t("Back")}
|
||||
cancelBtn={t("Close")}
|
||||
onClose={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
@@ -140,22 +128,7 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
onClick={() => onCoreChange(each.core)}
|
||||
>
|
||||
<ListItemText primary={each.name} secondary={`/${each.core}`} />
|
||||
|
||||
{(OS === "macos" || OS === "linux") && (
|
||||
<Tooltip title={t("Tun mode requires")}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onGrant(each.core);
|
||||
}}
|
||||
>
|
||||
{t("Grant")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Chip label={t(`${each.chip}`)} size="small" />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -13,19 +13,18 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
|
||||
const { clashInfo, patchInfo } = useClashInfo();
|
||||
const { verge, patchVerge } = useVerge();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [redirPort, setRedirPort] = useState(
|
||||
verge?.verge_redir_port ?? clashInfo?.redir_port ?? 7895
|
||||
);
|
||||
const [redirEnabled, setRedirEnabled] = useState(
|
||||
verge?.verge_redir_enabled ?? true
|
||||
verge?.verge_redir_enabled ?? false
|
||||
);
|
||||
const [tproxyPort, setTproxyPort] = useState(
|
||||
verge?.verge_tproxy_port ?? clashInfo?.tproxy_port ?? 7896
|
||||
);
|
||||
const [tproxyEnabled, setTproxyEnabled] = useState(
|
||||
verge?.verge_tproxy_enabled ?? true
|
||||
verge?.verge_tproxy_enabled ?? false
|
||||
);
|
||||
const [mixedPort, setMixedPort] = useState(
|
||||
verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897
|
||||
@@ -34,26 +33,26 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
verge?.verge_socks_port ?? clashInfo?.socks_port ?? 7898
|
||||
);
|
||||
const [socksEnabled, setSocksEnabled] = useState(
|
||||
verge?.verge_socks_enabled ?? true
|
||||
verge?.verge_socks_enabled ?? false
|
||||
);
|
||||
const [port, setPort] = useState(
|
||||
verge?.verge_port ?? clashInfo?.port ?? 7899
|
||||
);
|
||||
const [httpEnabled, setHttpEnabled] = useState(
|
||||
verge?.verge_http_enabled ?? true
|
||||
verge?.verge_http_enabled ?? false
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
if (verge?.verge_redir_port) setRedirPort(verge?.verge_redir_port);
|
||||
setRedirEnabled(verge?.verge_redir_enabled ?? true);
|
||||
setRedirEnabled(verge?.verge_redir_enabled ?? false);
|
||||
if (verge?.verge_tproxy_port) setTproxyPort(verge?.verge_tproxy_port);
|
||||
setTproxyEnabled(verge?.verge_tproxy_enabled ?? true);
|
||||
setTproxyEnabled(verge?.verge_tproxy_enabled ?? false);
|
||||
if (verge?.verge_mixed_port) setMixedPort(verge?.verge_mixed_port);
|
||||
if (verge?.verge_socks_port) setSocksPort(verge?.verge_socks_port);
|
||||
setSocksEnabled(verge?.verge_socks_enabled ?? true);
|
||||
setSocksEnabled(verge?.verge_socks_enabled ?? false);
|
||||
if (verge?.verge_port) setPort(verge?.verge_port);
|
||||
setHttpEnabled(verge?.verge_http_enabled ?? true);
|
||||
setHttpEnabled(verge?.verge_http_enabled ?? false);
|
||||
setOpen(true);
|
||||
},
|
||||
close: () => setOpen(false),
|
||||
@@ -94,24 +93,57 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (OS !== "windows") {
|
||||
await patchInfo({ "redir-port": redirPort });
|
||||
await patchVerge({ verge_redir_port: redirPort });
|
||||
await patchVerge({ verge_redir_enabled: redirEnabled });
|
||||
if (OS === "windows") {
|
||||
await patchInfo({
|
||||
"mixed-port": mixedPort,
|
||||
"socks-port": socksPort,
|
||||
port,
|
||||
});
|
||||
await patchVerge({
|
||||
verge_mixed_port: mixedPort,
|
||||
verge_socks_port: socksPort,
|
||||
verge_socks_enabled: socksEnabled,
|
||||
verge_port: port,
|
||||
verge_http_enabled: httpEnabled,
|
||||
});
|
||||
}
|
||||
if (OS === "macos") {
|
||||
await patchInfo({
|
||||
"redir-port": redirPort,
|
||||
"mixed-port": mixedPort,
|
||||
"socks-port": socksPort,
|
||||
port,
|
||||
});
|
||||
await patchVerge({
|
||||
verge_redir_port: redirPort,
|
||||
verge_redir_enabled: redirEnabled,
|
||||
verge_mixed_port: mixedPort,
|
||||
verge_socks_port: socksPort,
|
||||
verge_socks_enabled: socksEnabled,
|
||||
verge_port: port,
|
||||
verge_http_enabled: httpEnabled,
|
||||
});
|
||||
}
|
||||
if (OS === "linux") {
|
||||
await patchInfo({ "tproxy-port": tproxyPort });
|
||||
await patchVerge({ verge_tproxy_port: tproxyPort });
|
||||
await patchVerge({ verge_tproxy_enabled: tproxyEnabled });
|
||||
await patchInfo({
|
||||
"redir-port": redirPort,
|
||||
"tproxy-port": tproxyPort,
|
||||
"mixed-port": mixedPort,
|
||||
"socks-port": socksPort,
|
||||
port,
|
||||
});
|
||||
await patchVerge({
|
||||
verge_redir_port: redirPort,
|
||||
verge_redir_enabled: redirEnabled,
|
||||
verge_tproxy_port: tproxyPort,
|
||||
verge_tproxy_enabled: tproxyEnabled,
|
||||
verge_mixed_port: mixedPort,
|
||||
verge_socks_port: socksPort,
|
||||
verge_socks_enabled: socksEnabled,
|
||||
verge_port: port,
|
||||
verge_http_enabled: httpEnabled,
|
||||
});
|
||||
}
|
||||
await patchInfo({ "mixed-port": mixedPort });
|
||||
await patchInfo({ "socks-port": socksPort });
|
||||
await patchInfo({ port });
|
||||
await patchVerge({ verge_mixed_port: mixedPort });
|
||||
await patchVerge({ verge_socks_port: socksPort });
|
||||
await patchVerge({ verge_port: port });
|
||||
await patchVerge({ verge_socks_enabled: socksEnabled });
|
||||
await patchVerge({ verge_http_enabled: httpEnabled });
|
||||
setOpen(false);
|
||||
Notice.success(t("Clash Port Modified"), 1000);
|
||||
} catch (err: any) {
|
||||
@@ -122,7 +154,7 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
return (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("Clash Port")}
|
||||
title={t("Port Config")}
|
||||
contentSx={{ width: 300 }}
|
||||
okBtn={t("Save")}
|
||||
cancelBtn={t("Cancel")}
|
||||
@@ -132,10 +164,10 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
>
|
||||
<List>
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText primary="Mixed Port" />
|
||||
<ListItemText primary={t("Mixed Port")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
sx={{ width: 135 }}
|
||||
value={mixedPort}
|
||||
onChange={(e) =>
|
||||
@@ -144,10 +176,10 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText primary="Socks Port" />
|
||||
<ListItemText primary={t("Socks Port")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
sx={{ width: 135 }}
|
||||
value={socksPort}
|
||||
onChange={(e) =>
|
||||
@@ -167,10 +199,10 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText primary="Http Port" />
|
||||
<ListItemText primary={t("Http Port")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
sx={{ width: 135 }}
|
||||
value={port}
|
||||
onChange={(e) =>
|
||||
@@ -191,10 +223,10 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
</ListItem>
|
||||
{OS !== "windows" && (
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText primary="Redir Port" />
|
||||
<ListItemText primary={t("Redir Port")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
sx={{ width: 135 }}
|
||||
value={redirPort}
|
||||
onChange={(e) =>
|
||||
@@ -216,10 +248,10 @@ export const ClashPortViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
)}
|
||||
{OS === "linux" && (
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText primary="Tproxy Port" />
|
||||
<ListItemText primary={t("Tproxy Port")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
sx={{ width: 135 }}
|
||||
value={tproxyPort}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -20,17 +20,17 @@ export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
close: () => setOpen(false),
|
||||
}));
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
title={
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{t("Runtime Config")}
|
||||
<Chip label={t("ReadOnly")} size="small" />
|
||||
</Box>
|
||||
}
|
||||
mode="text"
|
||||
property={runtimeConfig}
|
||||
open={open}
|
||||
initialData={Promise.resolve(runtimeConfig)}
|
||||
readOnly
|
||||
language="yaml"
|
||||
schema="clash"
|
||||
|
||||
@@ -48,8 +48,8 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText primary={t("External Controller")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
sx={{ width: 175 }}
|
||||
value={controller}
|
||||
placeholder="Required"
|
||||
@@ -60,8 +60,8 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
<ListItem sx={{ padding: "5px 2px" }}>
|
||||
<ListItemText primary={t("Core Secret")} />
|
||||
<TextField
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
autoComplete="off"
|
||||
sx={{ width: 175 }}
|
||||
value={secret}
|
||||
placeholder={t("Recommended")}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
|
||||
import { alpha, Box, IconButton, styled } from "@mui/material";
|
||||
import { DeleteRounded } from "@mui/icons-material";
|
||||
import { parseHotkey } from "@/utils/parse-hotkey";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const KeyWrapper = styled("div")(({ theme }) => ({
|
||||
position: "relative",
|
||||
@@ -41,9 +42,12 @@ const KeyWrapper = styled("div")(({ theme }) => ({
|
||||
border: "1px solid",
|
||||
borderColor: alpha(theme.palette.text.secondary, 0.2),
|
||||
borderRadius: "2px",
|
||||
padding: "1px 1px",
|
||||
padding: "1px 5px",
|
||||
margin: "2px 0",
|
||||
marginRight: 8,
|
||||
},
|
||||
".delimiter": {
|
||||
lineHeight: "25px",
|
||||
padding: "0 2px",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -54,6 +58,7 @@ interface Props {
|
||||
|
||||
export const HotkeyInput = (props: Props) => {
|
||||
const { value, onChange } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const changeRef = useRef<string[]>([]);
|
||||
const [keys, setKeys] = useState(value);
|
||||
@@ -83,17 +88,22 @@ export const HotkeyInput = (props: Props) => {
|
||||
/>
|
||||
|
||||
<div className="list">
|
||||
{keys.map((key) => (
|
||||
<div key={key} className="item">
|
||||
{key}
|
||||
</div>
|
||||
{keys.map((key, index) => (
|
||||
<Box display="flex">
|
||||
<span className="delimiter" hidden={index === 0}>
|
||||
+
|
||||
</span>
|
||||
<div key={key} className="item">
|
||||
{key}
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</KeyWrapper>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
title="Delete"
|
||||
title={t("Delete")}
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
onChange([]);
|
||||
|
||||