mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 19:22:44 +08:00
Compare commits
207
Commits
v2.2.3
...
v2.3.0-alpha.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a918afb653 | ||
|
|
80f550d67e | ||
|
|
44f085604a | ||
|
|
1144f8017a | ||
|
|
d8fa17a3e7 | ||
|
|
bf868e0ae2 | ||
|
|
e1dac63f69 | ||
|
|
2657234761 | ||
|
|
4a345fdd41 | ||
|
|
fee6a586d7 | ||
|
|
7c738483b7 | ||
|
|
c3d8ed28a2 | ||
|
|
f47b4b961b | ||
|
|
7763abf6c2 | ||
|
|
9d9d078346 | ||
|
|
76cec7aa54 | ||
|
|
2223a99ed1 | ||
|
|
4926d33849 | ||
|
|
9978182cae | ||
|
|
1d77bc57df | ||
|
|
1bcb6646c4 | ||
|
|
06130b95d2 | ||
|
|
0536a45959 | ||
|
|
b150eb7e64 | ||
|
|
d5c0b09a2f | ||
|
|
a36d2633c9 | ||
|
|
72783e3ff5 | ||
|
|
929abb3c04 | ||
|
|
6810b1f221 | ||
|
|
a96678caea | ||
|
|
2d59177256 | ||
|
|
62b743fa20 | ||
|
|
b85aaa816e | ||
|
|
74fc3dea33 | ||
|
|
55167e75af | ||
|
|
63d39c24dc | ||
|
|
1d4d8aeb2b | ||
|
|
c9589fc75d | ||
|
|
d0c3a306bc | ||
|
|
1e3566ed7d | ||
|
|
756d303f6a | ||
|
|
a5b948a41c | ||
|
|
6440f856a2 | ||
|
|
25acb8121f | ||
|
|
c05bd31f6b | ||
|
|
9416de6442 | ||
|
|
c507c483fb | ||
|
|
32ee1b36d2 | ||
|
|
2b89f07fe5 | ||
|
|
0621a17d17 | ||
|
|
4840e07da8 | ||
|
|
af1689ee07 | ||
|
|
d3dbc11b1b | ||
|
|
10479b0936 | ||
|
|
fa1f9875d9 | ||
|
|
7466139320 | ||
|
|
7a5bcf67c5 | ||
|
|
10e3010324 | ||
|
|
464f8095ab | ||
|
|
8b800f679b | ||
|
|
5b2f946828 | ||
|
|
b1c31f7a6f | ||
|
|
d49618786e | ||
|
|
866ec2720d | ||
|
|
b4595d7886 | ||
|
|
9af0803e9b | ||
|
|
ebecabc0cc | ||
|
|
c9a9e934f4 | ||
|
|
544f63a2ff | ||
|
|
1dbfc96ebf | ||
|
|
250dcf569c | ||
|
|
0ef552d384 | ||
|
|
998ded5e0b | ||
|
|
d60c3b4d64 | ||
|
|
b6db209670 | ||
|
|
29fd97e402 | ||
|
|
32ebc8d174 | ||
|
|
420b0a254a | ||
|
|
400efa00ec | ||
|
|
4024b72954 | ||
|
|
7cd1816866 | ||
|
|
5983ac5449 | ||
|
|
b70e202201 | ||
|
|
cc5ebec0cb | ||
|
|
1d2fd06507 | ||
|
|
8d99af4c15 | ||
|
|
861428d3bd | ||
|
|
00e3f13bc9 | ||
|
|
2a897ecf8a | ||
|
|
8448217bd4 | ||
|
|
305a64c3e3 | ||
|
|
e1d6c74e4f | ||
|
|
7556758284 | ||
|
|
3926288b7f | ||
|
|
4d56a5cd8c | ||
|
|
d554679ee2 | ||
|
|
6fe7aabe14 | ||
|
|
f4e57a2831 | ||
|
|
32008b9364 | ||
|
|
9d89eeb974 | ||
|
|
95c23730ef | ||
|
|
7acbb5da4f | ||
|
|
ddd85d4d87 | ||
|
|
53a46d0dc6 | ||
|
|
92ae277e3a | ||
|
|
dd190659ef | ||
|
|
6c5488be70 | ||
|
|
316e1d4268 | ||
|
|
c3c13ba6e6 | ||
|
|
b0a82bc7ad | ||
|
|
7f0ebbd83d | ||
|
|
4f86f3c0ec | ||
|
|
c100b9c54d | ||
|
|
ebc9fc5eba | ||
|
|
29ae70bbf6 | ||
|
|
becc51bcd2 | ||
|
|
1993e5dd51 | ||
|
|
d1a2bd78d7 | ||
|
|
a5521404b6 | ||
|
|
8aa7b34197 | ||
|
|
1ddbe7c2cc | ||
|
|
6578cd8c51 | ||
|
|
c2c46d1cae | ||
|
|
95231e7cd3 | ||
|
|
5b6c9be99f | ||
|
|
d587ed09a5 | ||
|
|
10576780ed | ||
|
|
b37b121afb | ||
|
|
2b69ed4915 | ||
|
|
9a666d807c | ||
|
|
050b363066 | ||
|
|
8cae9d4e0a | ||
|
|
b5b65ac8c5 | ||
|
|
7370f00857 | ||
|
|
dc798fe2dd | ||
|
|
eda8fc125f | ||
|
|
8296675574 | ||
|
|
e2ad2d23f8 | ||
|
|
d8df283e93 | ||
|
|
ce42ca77a9 | ||
|
|
cfe8328f9e | ||
|
|
ff5a2c6ca4 | ||
|
|
23b0493d0b | ||
|
|
779291151e | ||
|
|
ba0f4cdde0 | ||
|
|
3983762202 | ||
|
|
c72413cbe6 | ||
|
|
73b9a71c84 | ||
|
|
e7bf997f8c | ||
|
|
dbfcc80afe | ||
|
|
32b6821b8a | ||
|
|
3ce5d3bf2d | ||
|
|
f6023dc618 | ||
|
|
9d9dd73790 | ||
|
|
7383459b9a | ||
|
|
55cde38562 | ||
|
|
d6a79316a6 | ||
|
|
bd3231bfa8 | ||
|
|
8d62c0d521 | ||
|
|
4d37e6870c | ||
|
|
0bb042e085 | ||
|
|
65ed9cb5b2 | ||
|
|
ffc0693afc | ||
|
|
2622cc06eb | ||
|
|
15b117dc15 | ||
|
|
16846aefd3 | ||
|
|
babc2c8f9b | ||
|
|
a0b70f9c34 | ||
|
|
dc46eb9d21 | ||
|
|
824ad9fa29 | ||
|
|
0646fa96a6 | ||
|
|
d05952e945 | ||
|
|
abe5cf1b84 | ||
|
|
07b424cb09 | ||
|
|
c7494de0a7 | ||
|
|
1a9a2ff9e0 | ||
|
|
4f15bdf74d | ||
|
|
541b78ba6f | ||
|
|
05b910dc17 | ||
|
|
41629df189 | ||
|
|
c718ef3058 | ||
|
|
2b1d02f0cc | ||
|
|
272a0c916d | ||
|
|
16187858a3 | ||
|
|
f6ea01238a | ||
|
|
ae4067aee6 | ||
|
|
d401a83c75 | ||
|
|
41a662f784 | ||
|
|
1df9fff0a7 | ||
|
|
40de99e65d | ||
|
|
76227770a4 | ||
|
|
b70cad537c | ||
|
|
1282cc56bf | ||
|
|
85d08fadd9 | ||
|
|
fad73a281a | ||
|
|
18d24d5952 | ||
|
|
6e6462742c | ||
|
|
16381d1895 | ||
|
|
1970155c51 | ||
|
|
51ef1329be | ||
|
|
b6a6f5f434 | ||
|
|
644fdc071f | ||
|
|
25d66a4eee | ||
|
|
9070ef1dbe | ||
|
|
f700dc124e | ||
|
|
5fcea4c684 | ||
|
|
843f40d7d5 |
+150
-137
@@ -1,123 +1,136 @@
|
|||||||
name: Alpha Build
|
name: Alpha Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
# 因为 alpha 不再负责频繁构建,且需要相对于 autobuild 更稳定使用环境
|
||||||
schedule:
|
# 所以不再使用 workflow_dispatch 触发
|
||||||
# UTC+8 0,6,12,18
|
# 应当通过 git tag 来触发构建
|
||||||
- cron: "0 16,22,4,10 * * *"
|
# workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*-alpha*"
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
env:
|
env:
|
||||||
|
TAG_NAME: alpha
|
||||||
|
TAG_CHANNEL: Alpha
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
RUST_BACKTRACE: short
|
RUST_BACKTRACE: short
|
||||||
concurrency:
|
concurrency:
|
||||||
# only allow per workflow per commit (and not pr) to run at a time
|
|
||||||
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
|
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check_commit:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
should_run: ${{ steps.check.outputs.should_run }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check if version changed or src changed
|
|
||||||
id: check
|
|
||||||
run: |
|
|
||||||
# For manual workflow_dispatch, always run
|
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
|
||||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Store current version from package.json
|
|
||||||
CURRENT_VERSION=$(cat package.json | jq -r '.version')
|
|
||||||
echo "Current version: $CURRENT_VERSION"
|
|
||||||
|
|
||||||
# Get the previous commit's package.json version
|
|
||||||
git checkout HEAD~1 package.json
|
|
||||||
PREVIOUS_VERSION=$(cat package.json | jq -r '.version')
|
|
||||||
echo "Previous version: $PREVIOUS_VERSION"
|
|
||||||
|
|
||||||
# Reset back to current commit
|
|
||||||
git checkout HEAD package.json
|
|
||||||
|
|
||||||
# Check if version changed
|
|
||||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
|
||||||
echo "Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION"
|
|
||||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if src or src-tauri directories changed
|
|
||||||
CURRENT_SRC_HASH=$(git rev-parse HEAD:src)
|
|
||||||
PREVIOUS_SRC_HASH=$(git rev-parse HEAD~1:src 2>/dev/null || echo "")
|
|
||||||
CURRENT_TAURI_HASH=$(git rev-parse HEAD:src-tauri 2>/dev/null || echo "")
|
|
||||||
PREVIOUS_TAURI_HASH=$(git rev-parse HEAD~1:src-tauri 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
echo "Current src hash: $CURRENT_SRC_HASH"
|
|
||||||
echo "Previous src hash: $PREVIOUS_SRC_HASH"
|
|
||||||
echo "Current tauri hash: $CURRENT_TAURI_HASH"
|
|
||||||
echo "Previous tauri hash: $PREVIOUS_TAURI_HASH"
|
|
||||||
|
|
||||||
if [ "$CURRENT_SRC_HASH" != "$PREVIOUS_SRC_HASH" ] || [ "$CURRENT_TAURI_HASH" != "$PREVIOUS_TAURI_HASH" ]; then
|
|
||||||
echo "Source directories changed"
|
|
||||||
echo "should_run=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "Version and source directories unchanged"
|
|
||||||
echo "should_run=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
delete_old_assets:
|
delete_old_assets:
|
||||||
needs: check_commit
|
name: Delete Old Alpha Release Assets and Tags
|
||||||
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Delete Old Alpha Release Assets
|
- name: Delete Old Alpha Tags Except Latest
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
const releaseTag = 'alpha';
|
const tagPattern = /-alpha.*/; // 匹配带有 -alpha 的 tag
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the release by tag name
|
// 获取所有 tag
|
||||||
const { data: release } = await github.rest.repos.getReleaseByTag({
|
const { data: tags } = await github.rest.repos.listTags({
|
||||||
owner: context.repo.owner,
|
owner,
|
||||||
repo: context.repo.repo,
|
repo,
|
||||||
tag: releaseTag
|
per_page: 100 // 调整 per_page 以获取更多 tag
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Found release with ID: ${release.id}`);
|
// 过滤出包含 -alpha 的 tag
|
||||||
|
const alphaTags = (await Promise.all(
|
||||||
|
tags
|
||||||
|
.filter(tag => tagPattern.test(tag.name))
|
||||||
|
.map(async tag => {
|
||||||
|
// 获取每个 tag 的 commit 信息以获得日期
|
||||||
|
const { data: commit } = await github.rest.repos.getCommit({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: tag.commit.sha
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...tag,
|
||||||
|
commitDate: commit.committer && commit.committer.date ? commit.committer.date : commit.commit.author.date
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)).sort((a, b) => {
|
||||||
|
// 按 commit 日期降序排序(最新的在前面)
|
||||||
|
return new Date(b.commitDate) - new Date(a.commitDate);
|
||||||
|
});
|
||||||
|
|
||||||
// Delete each asset
|
console.log(`Found ${alphaTags.length} alpha tags`);
|
||||||
if (release.assets && release.assets.length > 0) {
|
|
||||||
console.log(`Deleting ${release.assets.length} assets`);
|
|
||||||
|
|
||||||
for (const asset of release.assets) {
|
if (alphaTags.length === 0) {
|
||||||
console.log(`Deleting asset: ${asset.name} (${asset.id})`);
|
console.log('No alpha tags found');
|
||||||
await github.rest.repos.deleteReleaseAsset({
|
return;
|
||||||
owner: context.repo.owner,
|
}
|
||||||
repo: context.repo.repo,
|
|
||||||
asset_id: asset.id
|
// 保留最新的 tag
|
||||||
|
const latestTag = alphaTags[0];
|
||||||
|
console.log(`Keeping latest alpha tag: ${latestTag.name}`);
|
||||||
|
|
||||||
|
// 处理其他旧的 alpha tag
|
||||||
|
for (const tag of alphaTags.slice(1)) {
|
||||||
|
console.log(`Processing tag: ${tag.name}`);
|
||||||
|
|
||||||
|
// 获取与 tag 关联的 release
|
||||||
|
try {
|
||||||
|
const { data: release } = await github.rest.repos.getReleaseByTag({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tag: tag.name
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
console.log('All assets deleted successfully');
|
// 删除 release 下的所有资产
|
||||||
} else {
|
if (release.assets && release.assets.length > 0) {
|
||||||
console.log('No assets found to delete');
|
console.log(`Deleting ${release.assets.length} assets for release ${tag.name}`);
|
||||||
|
for (const asset of release.assets) {
|
||||||
|
console.log(`Deleting asset: ${asset.name} (${asset.id})`);
|
||||||
|
await github.rest.repos.deleteReleaseAsset({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
asset_id: asset.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 release
|
||||||
|
console.log(`Deleting release for tag: ${tag.name}`);
|
||||||
|
await github.rest.repos.deleteRelease({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
release_id: release.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除 tag
|
||||||
|
console.log(`Deleting tag: ${tag.name}`);
|
||||||
|
await github.rest.git.deleteRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: `tags/${tag.name}`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
console.log(`No release found for tag ${tag.name}, deleting tag directly`);
|
||||||
|
await github.rest.git.deleteRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: `tags/${tag.name}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(`Error processing tag ${tag.name}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Old alpha tags and releases deleted successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status === 404) {
|
console.error('Error:', error);
|
||||||
console.log('Release not found, nothing to delete');
|
throw error;
|
||||||
} else {
|
|
||||||
console.error('Error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update_tag:
|
update_tag:
|
||||||
@@ -128,22 +141,18 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Fetch Alpha update logs
|
- name: Fetch UPDATE logs
|
||||||
id: fetch_alpha_logs
|
id: fetch_update_logs
|
||||||
run: |
|
run: |
|
||||||
# Check if UPDATELOG.md exists
|
|
||||||
if [ -f "UPDATELOG.md" ]; then
|
if [ -f "UPDATELOG.md" ]; then
|
||||||
# Extract the section starting with ## and containing -alpha until the next ## or end of file
|
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
||||||
# ALPHA_LOGS=$(awk '/^## .*-alpha/{flag=1; print; next} /^## /{flag=0} flag' UPDATELOG.md)
|
if [ -n "$UPDATE_LOGS" ]; then
|
||||||
ALPHA_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
echo "Found update logs"
|
||||||
|
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
|
||||||
if [ -n "$ALPHA_LOGS" ]; then
|
echo "$UPDATE_LOGS" >> $GITHUB_ENV
|
||||||
echo "Found alpha update logs"
|
|
||||||
echo "ALPHA_LOGS<<EOF" >> $GITHUB_ENV
|
|
||||||
echo "$ALPHA_LOGS" >> $GITHUB_ENV
|
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "No alpha sections found in UPDATELOG.md"
|
echo "No update sections found in UPDATELOG.md"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "UPDATELOG.md file not found"
|
echo "UPDATELOG.md file not found"
|
||||||
@@ -156,17 +165,15 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
# 检查 ALPHA_LOGS 是否存在,如果不存在则使用默认消息
|
if [ -z "$UPDATE_LOGS" ]; then
|
||||||
if [ -z "$ALPHA_LOGS" ]; then
|
echo "No update logs found, using default message"
|
||||||
echo "No alpha logs found, using default message"
|
UPDATE_LOGS="More new features are now supported. Check for detailed changelog soon."
|
||||||
ALPHA_LOGS="More new features are now supported. Check for detailed changelog soon."
|
|
||||||
else
|
else
|
||||||
echo "Using found alpha logs"
|
echo "Using found update logs"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 生成 release.txt 文件
|
|
||||||
cat > release.txt << EOF
|
cat > release.txt << EOF
|
||||||
$ALPHA_LOGS
|
$UPDATE_LOGS
|
||||||
|
|
||||||
## 我应该下载哪个版本?
|
## 我应该下载哪个版本?
|
||||||
|
|
||||||
@@ -189,7 +196,6 @@ jobs:
|
|||||||
- arm64架构: arm64_fixed_webview2-setup.exe
|
- arm64架构: arm64_fixed_webview2-setup.exe
|
||||||
|
|
||||||
### FAQ
|
### FAQ
|
||||||
|
|
||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
@@ -201,14 +207,15 @@ jobs:
|
|||||||
- name: Upload Release
|
- name: Upload Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: alpha
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
name: "Clash Verge Rev Alpha"
|
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
body_path: release.txt
|
body_path: release.txt
|
||||||
prerelease: true
|
prerelease: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|
||||||
alpha:
|
alpha-x86-windows-macos-linux:
|
||||||
|
name: Alpha x86 Windows, MacOS and Linux
|
||||||
needs: update_tag
|
needs: update_tag
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -224,7 +231,6 @@ jobs:
|
|||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
@@ -264,8 +270,8 @@ jobs:
|
|||||||
pnpm i
|
pnpm i
|
||||||
pnpm check ${{ matrix.target }}
|
pnpm check ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Release Alpha Version
|
# - name: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
run: pnpm release-alpha-version
|
# run: pnpm release-version ${{ env.TAG_NAME }}
|
||||||
|
|
||||||
- name: Tauri build
|
- name: Tauri build
|
||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
@@ -281,15 +287,16 @@ jobs:
|
|||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
with:
|
with:
|
||||||
tagName: alpha
|
tagName: ${{ env.TAG_NAME }}
|
||||||
releaseName: "Clash Verge Rev Alpha"
|
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
releaseBody: "More new features are now supported."
|
releaseBody: "More new features are now supported."
|
||||||
releaseDraft: false
|
releaseDraft: false
|
||||||
prerelease: true
|
prerelease: true
|
||||||
tauriScript: pnpm
|
tauriScript: pnpm
|
||||||
args: --target ${{ matrix.target }}
|
args: --target ${{ matrix.target }}
|
||||||
|
|
||||||
alpha-for-linux-arm:
|
alpha-arm-linux:
|
||||||
|
name: Alpha ARM Linux
|
||||||
needs: update_tag
|
needs: update_tag
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -333,11 +340,11 @@ jobs:
|
|||||||
pnpm i
|
pnpm i
|
||||||
pnpm check ${{ matrix.target }}
|
pnpm check ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Release Alpha Version
|
# - name: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
run: pnpm release-alpha-version
|
# run: pnpm release-version ${{ env.TAG_NAME }}
|
||||||
|
|
||||||
- name: "Setup for linux"
|
- name: Setup for linux
|
||||||
run: |-
|
run: |
|
||||||
sudo ls -lR /etc/apt/
|
sudo ls -lR /etc/apt/
|
||||||
|
|
||||||
cat > /tmp/sources.list << EOF
|
cat > /tmp/sources.list << EOF
|
||||||
@@ -356,9 +363,14 @@ jobs:
|
|||||||
sudo mv /tmp/sources.list /etc/apt/sources.list
|
sudo mv /tmp/sources.list /etc/apt/sources.list
|
||||||
|
|
||||||
sudo dpkg --add-architecture ${{ matrix.arch }}
|
sudo dpkg --add-architecture ${{ matrix.arch }}
|
||||||
sudo apt update
|
sudo apt-get update -y
|
||||||
|
sudo apt-get -f install -y
|
||||||
|
|
||||||
sudo apt install -y \
|
sudo apt-get install -y \
|
||||||
|
linux-libc-dev:${{ matrix.arch }} \
|
||||||
|
libc6-dev:${{ matrix.arch }}
|
||||||
|
|
||||||
|
sudo apt-get install -y \
|
||||||
libxslt1.1:${{ matrix.arch }} \
|
libxslt1.1:${{ matrix.arch }} \
|
||||||
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
|
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
|
||||||
libayatana-appindicator3-dev:${{ matrix.arch }} \
|
libayatana-appindicator3-dev:${{ matrix.arch }} \
|
||||||
@@ -366,14 +378,14 @@ jobs:
|
|||||||
patchelf:${{ matrix.arch }} \
|
patchelf:${{ matrix.arch }} \
|
||||||
librsvg2-dev:${{ matrix.arch }}
|
librsvg2-dev:${{ matrix.arch }}
|
||||||
|
|
||||||
- name: "Install aarch64 tools"
|
- name: Install aarch64 tools
|
||||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
run: |
|
run: |
|
||||||
sudo apt install -y \
|
sudo apt install -y \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
g++-aarch64-linux-gnu
|
g++-aarch64-linux-gnu
|
||||||
|
|
||||||
- name: "Install armv7 tools"
|
- name: Install armv7 tools
|
||||||
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
|
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||||
run: |
|
run: |
|
||||||
sudo apt install -y \
|
sudo apt install -y \
|
||||||
@@ -406,15 +418,16 @@ jobs:
|
|||||||
- name: Upload Release
|
- name: Upload Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: alpha
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
name: "Clash Verge Rev Alpha"
|
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
prerelease: true
|
prerelease: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
files: |
|
files: |
|
||||||
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||||
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||||
|
|
||||||
alpha-for-fixed-webview2:
|
alpha-x86-arm-windows_webview2:
|
||||||
|
name: Alpha x86 and ARM Windows with WebView2
|
||||||
needs: update_tag
|
needs: update_tag
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -456,8 +469,8 @@ jobs:
|
|||||||
pnpm i
|
pnpm i
|
||||||
pnpm check ${{ matrix.target }}
|
pnpm check ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Release Alpha Version
|
# - name: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
run: pnpm release-alpha-version
|
# run: pnpm release-version ${{ env.TAG_NAME }}
|
||||||
|
|
||||||
- name: Download WebView2 Runtime
|
- name: Download WebView2 Runtime
|
||||||
run: |
|
run: |
|
||||||
@@ -501,13 +514,13 @@ jobs:
|
|||||||
- name: Upload Release
|
- name: Upload Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: alpha
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
name: "Clash Verge Rev Alpha"
|
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
prerelease: true
|
prerelease: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
|
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
|
||||||
|
|
||||||
- name: Portable Bundle
|
- name: Portable Bundle
|
||||||
run: pnpm portable-fixed-webview2 ${{ matrix.target }} --alpha
|
run: pnpm portable-fixed-webview2 ${{ matrix.target }} --${{ env.TAG_NAME }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
name: Auto Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
# UTC+8 0,6,12,18
|
||||||
|
- cron: "0 16,22,4,10 * * *"
|
||||||
|
permissions: write-all
|
||||||
|
env:
|
||||||
|
TAG_NAME: autobuild
|
||||||
|
TAG_CHANNEL: AutoBuild
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
RUST_BACKTRACE: short
|
||||||
|
concurrency:
|
||||||
|
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
|
||||||
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check_commit:
|
||||||
|
name: Check Commit Needs Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_run: ${{ steps.check.outputs.should_run }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Check if version changed or src changed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_VERSION=$(cat package.json | jq -r '.version')
|
||||||
|
echo "Current version: $CURRENT_VERSION"
|
||||||
|
|
||||||
|
git checkout HEAD~1 package.json
|
||||||
|
PREVIOUS_VERSION=$(cat package.json | jq -r '.version')
|
||||||
|
echo "Previous version: $PREVIOUS_VERSION"
|
||||||
|
|
||||||
|
git checkout HEAD package.json
|
||||||
|
|
||||||
|
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
||||||
|
echo "Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION"
|
||||||
|
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_SRC_HASH=$(git rev-parse HEAD:src)
|
||||||
|
PREVIOUS_SRC_HASH=$(git rev-parse HEAD~1:src 2>/dev/null || echo "")
|
||||||
|
CURRENT_TAURI_HASH=$(git rev-parse HEAD:src-tauri 2>/dev/null || echo "")
|
||||||
|
PREVIOUS_TAURI_HASH=$(git rev-parse HEAD~1:src-tauri 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
echo "Current src hash: $CURRENT_SRC_HASH"
|
||||||
|
echo "Previous src hash: $PREVIOUS_SRC_HASH"
|
||||||
|
echo "Current tauri hash: $CURRENT_TAURI_HASH"
|
||||||
|
echo "Previous tauri hash: $PREVIOUS_TAURI_HASH"
|
||||||
|
|
||||||
|
if [ "$CURRENT_SRC_HASH" != "$PREVIOUS_SRC_HASH" ] || [ "$CURRENT_TAURI_HASH" != "$PREVIOUS_TAURI_HASH" ]; then
|
||||||
|
echo "Source directories changed"
|
||||||
|
echo "should_run=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Version and source directories unchanged"
|
||||||
|
echo "should_run=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
update_tag:
|
||||||
|
name: Update tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check_commit
|
||||||
|
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Fetch UPDATE logs
|
||||||
|
id: fetch_update_logs
|
||||||
|
run: |
|
||||||
|
if [ -f "UPDATELOG.md" ]; then
|
||||||
|
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
|
||||||
|
if [ -n "$UPDATE_LOGS" ]; then
|
||||||
|
echo "Found update logs"
|
||||||
|
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
|
||||||
|
echo "$UPDATE_LOGS" >> $GITHUB_ENV
|
||||||
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "No update sections found in UPDATELOG.md"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "UPDATELOG.md file not found"
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Set Env
|
||||||
|
run: |
|
||||||
|
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
if [ -z "$UPDATE_LOGS" ]; then
|
||||||
|
echo "No update logs found, using default message"
|
||||||
|
UPDATE_LOGS="More new features are now supported. Check for detailed changelog soon."
|
||||||
|
else
|
||||||
|
echo "Using found update logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > release.txt << EOF
|
||||||
|
$UPDATE_LOGS
|
||||||
|
|
||||||
|
## 我应该下载哪个版本?
|
||||||
|
|
||||||
|
### MacOS
|
||||||
|
- MacOS intel芯片: x64.dmg
|
||||||
|
- MacOS apple M芯片: aarch64.dmg
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
- Linux 64位: amd64.deb/amd64.rpm
|
||||||
|
- Linux arm64 architecture: arm64.deb/aarch64.rpm
|
||||||
|
- Linux armv7架构: armhf.deb/armhfp.rpm
|
||||||
|
|
||||||
|
### Windows (不再支持Win7)
|
||||||
|
#### 正常版本(推荐)
|
||||||
|
- 64位: x64-setup.exe
|
||||||
|
- arm64架构: arm64-setup.exe
|
||||||
|
#### 便携版问题很多不再提供
|
||||||
|
#### 内置Webview2版(体积较大,仅在企业版系统或无法安装webview2时使用)
|
||||||
|
- 64位: x64_fixed_webview2-setup.exe
|
||||||
|
- arm64架构: arm64_fixed_webview2-setup.exe
|
||||||
|
|
||||||
|
### FAQ
|
||||||
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
|
### 稳定机场VPN推荐
|
||||||
|
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
|
Created at ${{ env.BUILDTIME }}.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Upload Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
|
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
|
body_path: release.txt
|
||||||
|
prerelease: true
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
generate_release_notes: true
|
||||||
|
|
||||||
|
autobuild-x86-windows-macos-linux:
|
||||||
|
name: Autobuild x86 Windows, MacOS and Linux
|
||||||
|
needs: update_tag
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
- os: windows-latest
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
- os: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
- os: macos-latest
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust Stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- 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 dependencies (ubuntu only)
|
||||||
|
if: matrix.os == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- 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: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
|
run: pnpm release-version ${{ env.TAG_NAME }}
|
||||||
|
|
||||||
|
- name: Tauri build
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
with:
|
||||||
|
tagName: ${{ env.TAG_NAME }}
|
||||||
|
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
|
releaseBody: "More new features are now supported."
|
||||||
|
releaseDraft: false
|
||||||
|
prerelease: true
|
||||||
|
tauriScript: pnpm
|
||||||
|
args: --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
autobuild-arm-linux:
|
||||||
|
name: Autobuild ARM Linux
|
||||||
|
needs: update_tag
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
arch: arm64
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
target: armv7-unknown-linux-gnueabihf
|
||||||
|
arch: armhf
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust Stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Pnpm install and check
|
||||||
|
run: |
|
||||||
|
pnpm i
|
||||||
|
pnpm check ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
|
run: pnpm release-version ${{ env.TAG_NAME }}
|
||||||
|
|
||||||
|
- name: Setup for linux
|
||||||
|
run: |
|
||||||
|
sudo ls -lR /etc/apt/
|
||||||
|
|
||||||
|
cat > /tmp/sources.list << EOF
|
||||||
|
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy main multiverse universe restricted
|
||||||
|
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
|
||||||
|
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
|
||||||
|
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-backports main multiverse universe restricted
|
||||||
|
|
||||||
|
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
|
||||||
|
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
|
||||||
|
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
|
||||||
|
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main multiverse universe restricted
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
|
||||||
|
sudo mv /tmp/sources.list /etc/apt/sources.list
|
||||||
|
|
||||||
|
sudo dpkg --add-architecture ${{ matrix.arch }}
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get -f install -y
|
||||||
|
|
||||||
|
sudo apt-get install -y \
|
||||||
|
linux-libc-dev:${{ matrix.arch }} \
|
||||||
|
libc6-dev:${{ matrix.arch }}
|
||||||
|
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libxslt1.1:${{ matrix.arch }} \
|
||||||
|
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
|
||||||
|
libayatana-appindicator3-dev:${{ matrix.arch }} \
|
||||||
|
libssl-dev:${{ matrix.arch }} \
|
||||||
|
patchelf:${{ matrix.arch }} \
|
||||||
|
librsvg2-dev:${{ matrix.arch }}
|
||||||
|
|
||||||
|
- name: Install aarch64 tools
|
||||||
|
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||||
|
run: |
|
||||||
|
sudo apt install -y \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
g++-aarch64-linux-gnu
|
||||||
|
|
||||||
|
- name: Install armv7 tools
|
||||||
|
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||||
|
run: |
|
||||||
|
sudo apt install -y \
|
||||||
|
gcc-arm-linux-gnueabihf \
|
||||||
|
g++-arm-linux-gnueabihf
|
||||||
|
|
||||||
|
- name: Build for Linux
|
||||||
|
run: |
|
||||||
|
export PKG_CONFIG_ALLOW_CROSS=1
|
||||||
|
if [ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]; then
|
||||||
|
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/:$PKG_CONFIG_PATH
|
||||||
|
export PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/
|
||||||
|
elif [ "${{ matrix.target }}" == "armv7-unknown-linux-gnueabihf" ]; then
|
||||||
|
export PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig/:$PKG_CONFIG_PATH
|
||||||
|
export PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf/
|
||||||
|
fi
|
||||||
|
pnpm build --target ${{ matrix.target }}
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Get Version
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install jq
|
||||||
|
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
|
||||||
|
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Upload Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
|
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
|
prerelease: true
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
files: |
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||||
|
|
||||||
|
autobuild-x86-arm-windows_webview2:
|
||||||
|
name: Autobuild x86 and ARM Windows with WebView2
|
||||||
|
needs: update_tag
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
arch: x64
|
||||||
|
- os: windows-latest
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
arch: arm64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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: "22"
|
||||||
|
|
||||||
|
- 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: Release ${{ env.TAG_CHANNEL }} Version
|
||||||
|
run: pnpm release-version ${{ env.TAG_NAME }}
|
||||||
|
|
||||||
|
- name: Download WebView2 Runtime
|
||||||
|
run: |
|
||||||
|
invoke-webrequest -uri https://github.com/westinyang/WebView2RuntimeArchive/releases/download/109.0.1518.78/Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.${{ matrix.arch }}.cab -outfile Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.${{ matrix.arch }}.cab
|
||||||
|
Expand .\Microsoft.WebView2.FixedVersionRuntime.109.0.1518.78.${{ matrix.arch }}.cab -F:* ./src-tauri
|
||||||
|
Remove-Item .\src-tauri\tauri.windows.conf.json
|
||||||
|
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
|
||||||
|
|
||||||
|
- name: Tauri build
|
||||||
|
id: build
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
with:
|
||||||
|
tauriScript: pnpm
|
||||||
|
args: --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Rename
|
||||||
|
run: |
|
||||||
|
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
|
||||||
|
Rename-Item $file.FullName $newName
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
|
||||||
|
Rename-Item $file.FullName $newName
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$newName = $file.Name -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
|
||||||
|
Rename-Item $file.FullName $newName
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Upload Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAG_NAME }}
|
||||||
|
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
|
||||||
|
prerelease: true
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
|
||||||
|
|
||||||
|
- name: Portable Bundle
|
||||||
|
run: pnpm portable-fixed-webview2 ${{ matrix.target }} --${{ env.TAG_NAME }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
name: Release Build
|
name: Release Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
# ! 为了避免重复发布版本,应当通过独特 git tag 触发。
|
||||||
|
# ! 不再使用 workflow_dispatch 触发。
|
||||||
|
# workflow_dispatch:
|
||||||
|
push:
|
||||||
|
# ? 应当限制在 main 分支上触发发布。
|
||||||
|
# branches:
|
||||||
|
# - main
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
@@ -13,6 +21,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
name: Release Build
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -88,6 +97,7 @@ jobs:
|
|||||||
args: --target ${{ matrix.target }}
|
args: --target ${{ matrix.target }}
|
||||||
|
|
||||||
release-for-linux-arm:
|
release-for-linux-arm:
|
||||||
|
name: Release Build for Linux ARM
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -209,6 +219,7 @@ jobs:
|
|||||||
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
|
||||||
|
|
||||||
release-for-fixed-webview2:
|
release-for-fixed-webview2:
|
||||||
|
name: Release Build for Fixed WebView2
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -303,6 +314,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
release-update:
|
release-update:
|
||||||
|
name: Release Update
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [release, release-for-linux-arm]
|
needs: [release, release-for-linux-arm]
|
||||||
steps:
|
steps:
|
||||||
@@ -353,6 +365,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
submit-to-winget:
|
submit-to-winget:
|
||||||
|
name: Submit to Winget
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [release-update]
|
needs: [release-update]
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ scripts/_env.sh
|
|||||||
.vscode
|
.vscode
|
||||||
.tool-versions
|
.tool-versions
|
||||||
.idea
|
.idea
|
||||||
|
.old
|
||||||
|
|||||||
+4
-2
@@ -3,14 +3,16 @@
|
|||||||
#pnpm pretty-quick --staged
|
#pnpm pretty-quick --staged
|
||||||
|
|
||||||
# 运行 clippy fmt
|
# 运行 clippy fmt
|
||||||
cargo fmt --manifest-path ./src-tauri/Cargo.toml
|
cd src-tauri
|
||||||
|
cargo fmt
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo "rustfmt failed to format the code. Please fix the issues and try again."
|
echo "rustfmt failed to format the code. Please fix the issues and try again."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
cd ..
|
||||||
|
|
||||||
#git add .
|
git add .
|
||||||
|
|
||||||
# 允许提交
|
# 允许提交
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+24
-1
@@ -33,11 +33,16 @@ npm install pnpm -g
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Install Dependencies
|
### Install Dependencies
|
||||||
|
Install node packages
|
||||||
```shell
|
```shell
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install apt packages ONLY for Ubuntu
|
||||||
|
```shell
|
||||||
|
apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
```
|
||||||
|
|
||||||
### Download the Mihomo Core Binary
|
### Download the Mihomo Core Binary
|
||||||
|
|
||||||
You have two options for downloading the clash binary:
|
You have two options for downloading the clash binary:
|
||||||
@@ -96,6 +101,24 @@ pnpm portable
|
|||||||
|
|
||||||
## Contributing Your Changes
|
## Contributing Your Changes
|
||||||
|
|
||||||
|
#### Before commit your changes
|
||||||
|
|
||||||
|
If you changed the rust code, it's recommanded to execute code style formatting and quailty checks.
|
||||||
|
|
||||||
|
1. Code style formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ clash-verge-rev: cd src-tauri
|
||||||
|
$ clash-verge-rev/src-tauri: cargo fmt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Code quailty checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ clash-verge-rev: pnpm clippy
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
Once you have made your changes:
|
Once you have made your changes:
|
||||||
|
|
||||||
1. Fork the repository.
|
1. Fork the repository.
|
||||||
|
|||||||
@@ -18,10 +18,18 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
|
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
|
||||||
Go to the [release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
|
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
|
||||||
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
|
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
|
||||||
|
|
||||||
### 安装说明和常见问题,请到[文档页](https://clash-verge-rev.github.io/)查看:[Doc](https://clash-verge-rev.github.io/)
|
#### 我应当怎样选择发行版
|
||||||
|
|
||||||
|
| 版本 | 特征 | 链接 |
|
||||||
|
|:-----|:-----|:-----|
|
||||||
|
|Stable|正式版,高可靠性,适合日常使用。|[Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
|
||||||
|
|Alpha|早期测试版,功能未完善,可能存在缺陷。|[Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha)|
|
||||||
|
|AutoBuild|滚动更新版,持续集成更新,适合开发测试。|[AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild)|
|
||||||
|
|
||||||
|
#### 安装说明和常见问题,请到 [文档页](https://clash-verge-rev.github.io/) 查看
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,7 +37,7 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
|
|||||||
|
|
||||||
## Promotion
|
## Promotion
|
||||||
|
|
||||||
[狗狗加速 —— 技术流机场 Doggygo VPN](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
#### [狗狗加速 —— 技术流机场 Doggygo VPN](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- 高性能海外机场,免费试用,优惠套餐,解锁流媒体,全球首家支持 Hysteria 协议。
|
- 高性能海外机场,免费试用,优惠套餐,解锁流媒体,全球首家支持 Hysteria 协议。
|
||||||
- 使用 Clash Verge 专属邀请链接注册送 3 天,每天 1G 流量免费试用:[点此注册](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- 使用 Clash Verge 专属邀请链接注册送 3 天,每天 1G 流量免费试用:[点此注册](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
@@ -41,6 +49,18 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 10.15+ (intel/apple).
|
|||||||
- 解锁流媒体及 ChatGPT
|
- 解锁流媒体及 ChatGPT
|
||||||
- 官网:[https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- 官网:[https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
|
|
||||||
|
#### 本项目的构建与发布环境由 [YXVM](https://yxvm.com/aff.php?aff=827) 独立服务器全力支持,
|
||||||
|
感谢提供 独享资源、高性能、高速网络 的强大后端环境。如果你觉得下载够快、使用够爽,那是因为我们用了好服务器!
|
||||||
|
|
||||||
|
🧩 YXVM 独立服务器优势:
|
||||||
|
- 🌎 优质网络,回程优化,下载快到飞起
|
||||||
|
- 🔧 物理机独享资源,非VPS可比,性能拉满
|
||||||
|
- 🧠 适合跑代理、搭建 WEB 站 CDN 站 、搞 CI/CD 或任何高负载应用
|
||||||
|
- 💡 支持即开即用,多机房选择,CN2 / IEPL 可选
|
||||||
|
- 📦 本项目使用配置已在售,欢迎同款入手!
|
||||||
|
- 🎯 想要同款构建体验?[立即下单 YXVM 独立服务器!](https://yxvm.com/aff.php?aff=827)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 基于性能强劲的 Rust 和 Tauri 2 框架
|
- 基于性能强劲的 Rust 和 Tauri 2 框架
|
||||||
|
|||||||
@@ -1,3 +1,99 @@
|
|||||||
|
## v2.3.0
|
||||||
|
|
||||||
|
尽管外部控制密钥已自动补全默认值且不允许为空。仍然推荐自行修改外部控制密钥。
|
||||||
|
|
||||||
|
#### ⚠️ 已知问题
|
||||||
|
- 仅在Ubuntu 22.04/24.04,Fedora 41 **Gnome桌面环境** 做过简单测试,不保证其他其他Linux发行版可用,将在未来做进一步适配和调优
|
||||||
|
- MacOS 下 墙贴主要为浅色,Tray 图标深色时图标闪烁;彩色 Tray 速率颜色淡
|
||||||
|
- 窗口状态管理器已确定上游存在缺陷,暂时移除。当前不再内置窗口大小和位置记忆。
|
||||||
|
- MacOS 下卸载服务后需手动重启软件才能与内核通信。
|
||||||
|
|
||||||
|
### 2.3.0 相对于 2.2.3
|
||||||
|
#### 🐞 修复问题
|
||||||
|
- 首页"代理模式"快速切换导致的卡死问题
|
||||||
|
- 解锁测试报错信息
|
||||||
|
- Macos 快捷键关闭窗口无法启用自动轻量模式
|
||||||
|
- 静默启动异常窗口创建和关闭流程
|
||||||
|
- Windows 错误的全局快捷键 `Ctrl+Q` 注册
|
||||||
|
- Vless URL 解码时网络类型错误
|
||||||
|
- 切换自定义代理地址导致系统代理状态异常
|
||||||
|
- Macos TUN 默认无效网卡名称
|
||||||
|
- 托盘更改订阅后 UI 不同步的问题
|
||||||
|
- 修复提权漏洞,改用带认证的 IPC 通信
|
||||||
|
- 编辑器中连字符问题
|
||||||
|
- 安装服务模式后无法立即开启 TUN 模式
|
||||||
|
- 同步更新多语言翻译
|
||||||
|
- 修复 .window-state.json 无法删除的问题
|
||||||
|
- 无法修改配置更新 HTTP 请求超时
|
||||||
|
- 修复 getDelayFix 钩子问题
|
||||||
|
- 使用外部扩展脚本覆写代理组时首页无法显示代理组
|
||||||
|
- 导出诊断 Verge 版本与设置页面不同步
|
||||||
|
- 切换语言时可能造成设置页面无法加载
|
||||||
|
|
||||||
|
#### ✨ 新增功能
|
||||||
|
- Mihomo(Meta)内核升级至 1.19.10
|
||||||
|
- 允许代理主机地址设置为非 127.0.0.1 对 WSL 代理友好
|
||||||
|
- 关闭系统代理时关闭已建立的网络连接
|
||||||
|
- 托盘显示当前轻量模式状态
|
||||||
|
- Webdav 请求加入 UA
|
||||||
|
- Webdav 支持目录重定向
|
||||||
|
- Webdav 备份目录检查和文件上传重试机制
|
||||||
|
- 系统代理守卫可检查意外设置变更并恢复
|
||||||
|
- 定时自动订阅更新也能自动回退使用代理
|
||||||
|
- 订阅请求超时机制,防止订阅更新的时候卡死
|
||||||
|
- 订阅卡片点击时间可切换下次自动更新时间,自动更新触发后页面有明确的成功与否提示
|
||||||
|
- 添加网络管理器以优化网络请求处理,防止资源竞争导致的启动时 UI 卡死
|
||||||
|
- 更新依赖,替换弃用元素
|
||||||
|
- 首页当前节点增加排序功能
|
||||||
|
- DNS 覆写下增加 Hosts 设置功能
|
||||||
|
- 修复服务模式安装后无法立即开启 TUN 模式的问题
|
||||||
|
- 支持手动卸载服务模式,回退到 Sidecar 模式
|
||||||
|
- 添加了土耳其语,日本语,德语,西班牙语,繁体中文的支持
|
||||||
|
- 卸载服务的按钮
|
||||||
|
- 添加了Zashboard的一键跳转URL
|
||||||
|
- 使用操作系统默认的窗口管理器
|
||||||
|
- 切换、升级、重启内核的状态管理
|
||||||
|
- 更精细化控制自动日志清理,新增1天选项
|
||||||
|
- Winodws 快捷键名称改为 `Clash Verge`
|
||||||
|
- 配置加载阶段自动补全 external-controller secret 字段。
|
||||||
|
|
||||||
|
#### 🚀 优化改进
|
||||||
|
- 系统代理 Bypass 设置
|
||||||
|
- Windows 下使用 Startup 文件夹的方式实现开机自启,解决管理员模式下开机自启的各种问题
|
||||||
|
- 切换到规则页面时自动刷新规则数据
|
||||||
|
- 重构更新失败回退机制,使用后端完成更新失败后回退到使用 Clash 代理再次尝试更新
|
||||||
|
- 编辑非激活订阅的时候不在触发当前订阅配置重载
|
||||||
|
- 改进核心功能防止主进程阻塞、改进MihomoManager实现,以及优化窗口创建流程
|
||||||
|
- 优化系统代理设置更新逻辑
|
||||||
|
- 重构前端通知系统分离通知线程防止前端卡死
|
||||||
|
- 优化网络请求和错误处理
|
||||||
|
- 重构通知系统
|
||||||
|
- 使用异步方法重构 UI 启动逻辑,解决启动软件过程中的各种卡死问题
|
||||||
|
- MacOS 下默认关闭托盘速率显示
|
||||||
|
- 优化服务操作流程,提升系统服务相关操作的稳定性和用户体验
|
||||||
|
- 优化了其他语言的翻译问题
|
||||||
|
- Mihomo 内核默认日志等级为 warn
|
||||||
|
- Clash Verge Rev 应用默认日志等级为 warn
|
||||||
|
- 重构了原来的 IP 信息请求重试机制,采用轮询检测,解决了 Network Error 和超时问题
|
||||||
|
- 对轮询检测机制进行了优化,引入洗牌算法来增强随机性
|
||||||
|
- 对获取系统信息的流程进行了优化,并添加了去重检测机制,确保剔除重复的信息
|
||||||
|
- 优化窗口状态初始化逻辑和添加缺失的权限设置
|
||||||
|
- 异步化配置:优化端口查找和配置保存逻辑
|
||||||
|
- 重构事件通知机制到独立线程,避免前端卡死
|
||||||
|
- 优化端口设置,每个端口可随机设置端口号
|
||||||
|
- 优化了保存机制,使用平滑函数,防止客户端卡死
|
||||||
|
- 优化端口设置退出和保存机制
|
||||||
|
- 强制为 Mihomo 配置补全并覆盖 external-controller-cors 字段,默认不允许跨域和仅本地请求,提升 cors 安全性,升级配置时自动覆盖
|
||||||
|
- 修改 端口检测范围 (1111-65536)
|
||||||
|
- 配置文件缺失 secret 字段时自动填充默认值 set-your-secret
|
||||||
|
- 优化异步处理,防止部分组件 UI 阻塞
|
||||||
|
- 关闭 DNS 启用
|
||||||
|
- 延迟测试链接更换为 Https 协议 https://cp.cloudflare.com/generate_204
|
||||||
|
|
||||||
|
#### 🗑️ 移除内容
|
||||||
|
- 窗口状态管理器
|
||||||
|
- Webdav 跨平台备份恢复限制
|
||||||
|
|
||||||
## v2.2.3
|
## v2.2.3
|
||||||
|
|
||||||
#### 已知问题
|
#### 已知问题
|
||||||
|
|||||||
+50
-48
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "clash-verge",
|
"name": "clash-verge",
|
||||||
"version": "2.2.3",
|
"version": "2.3.0-alpha.5",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev -- --profile fast-dev",
|
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
|
||||||
"dev:diff": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev -- --profile fast-dev",
|
"dev:diff": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
|
||||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
|
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
|
||||||
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
@@ -17,9 +17,10 @@
|
|||||||
"portable": "node scripts/portable.mjs",
|
"portable": "node scripts/portable.mjs",
|
||||||
"portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs",
|
"portable-fixed-webview2": "node scripts/portable-fixed-webview2.mjs",
|
||||||
"fix-alpha-version": "node scripts/fix-alpha_version.mjs",
|
"fix-alpha-version": "node scripts/fix-alpha_version.mjs",
|
||||||
"release-version": "node scripts/release_version.mjs",
|
"release-version": "node scripts/release-version.mjs",
|
||||||
"release-alpha-version": "node scripts/release-alpha_version.mjs",
|
"publish-version": "node scripts/publish-version.mjs",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
"fmt": "cargo fmt --manifest-path ./src-tauri/Cargo.toml",
|
||||||
"clippy": "cargo clippy --manifest-path ./src-tauri/Cargo.toml"
|
"clippy": "cargo clippy --manifest-path ./src-tauri/Cargo.toml"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -29,76 +30,77 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@mui/icons-material": "^6.4.8",
|
"@mui/icons-material": "^7.1.1",
|
||||||
"@mui/lab": "6.0.0-beta.25",
|
"@mui/lab": "7.0.0-beta.13",
|
||||||
"@mui/material": "^6.4.8",
|
"@mui/material": "^7.1.1",
|
||||||
"@mui/x-data-grid": "^7.28.0",
|
"@mui/x-data-grid": "^8.5.0",
|
||||||
"@tauri-apps/api": "2.2.0",
|
"@tauri-apps/api": "2.5.0",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
"@tauri-apps/plugin-dialog": "^2.2.2",
|
||||||
"@tauri-apps/plugin-fs": "^2.2.0",
|
"@tauri-apps/plugin-fs": "^2.3.0",
|
||||||
"@tauri-apps/plugin-global-shortcut": "^2.2.0",
|
"@tauri-apps/plugin-global-shortcut": "^2.2.1",
|
||||||
"@tauri-apps/plugin-notification": "^2.2.2",
|
"@tauri-apps/plugin-notification": "^2.2.2",
|
||||||
"@tauri-apps/plugin-process": "^2.2.0",
|
"@tauri-apps/plugin-process": "^2.2.1",
|
||||||
"@tauri-apps/plugin-shell": "2.2.0",
|
"@tauri-apps/plugin-shell": "2.2.1",
|
||||||
"@tauri-apps/plugin-updater": "2.3.0",
|
"@tauri-apps/plugin-updater": "2.7.1",
|
||||||
|
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||||
"@types/d3-shape": "^3.1.7",
|
"@types/d3-shape": "^3.1.7",
|
||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
"ahooks": "^3.8.4",
|
"ahooks": "^3.8.5",
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.9.0",
|
||||||
|
"chart.js": "^4.4.9",
|
||||||
"cli-color": "^2.0.4",
|
"cli-color": "^2.0.4",
|
||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"dayjs": "1.11.13",
|
"dayjs": "1.11.13",
|
||||||
"foxact": "^0.2.44",
|
"foxact": "^0.2.45",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.2",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^25.2.1",
|
||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"monaco-yaml": "^5.3.1",
|
"monaco-yaml": "^5.4.0",
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"peggy": "^4.2.0",
|
"peggy": "^5.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "19.1.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-error-boundary": "6.0.0",
|
||||||
"react-i18next": "^13.5.0",
|
"react-hook-form": "^7.57.0",
|
||||||
"react-markdown": "^9.1.0",
|
"react-i18next": "15.5.2",
|
||||||
"react-monaco-editor": "^0.56.2",
|
"react-markdown": "10.1.0",
|
||||||
"react-router-dom": "^6.30.0",
|
"react-monaco-editor": "0.58.0",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-router-dom": "7.6.2",
|
||||||
"react-virtuoso": "^4.12.5",
|
"react-virtuoso": "^4.12.8",
|
||||||
"recharts": "^2.15.1",
|
|
||||||
"sockette": "^2.0.6",
|
"sockette": "^2.0.6",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"types-pac": "^1.0.3",
|
"types-pac": "^1.0.3",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/github": "^6.0.0",
|
"@actions/github": "^6.0.1",
|
||||||
"@tauri-apps/cli": "2.2.7",
|
"@tauri-apps/cli": "2.5.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "19.1.6",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "19.1.6",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@vitejs/plugin-legacy": "^6.1.1",
|
||||||
"@vitejs/plugin-legacy": "^6.0.2",
|
"@vitejs/plugin-react": "4.5.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
"commander": "^14.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"meta-json-schema": "^1.19.3",
|
"meta-json-schema": "^1.19.10",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"pretty-quick": "^4.1.1",
|
"pretty-quick": "^4.2.2",
|
||||||
"sass": "^1.86.0",
|
"sass": "^1.89.1",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.40.0",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.2.2",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"vite-plugin-svgr": "^4.3.0"
|
"vite-plugin-svgr": "^4.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1492
-1775
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"baseBranches": ["dev"],
|
||||||
|
"enabledManagers": ["cargo", "npm"],
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"ignorePaths": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/bower_components/**",
|
||||||
|
"**/vendor/**",
|
||||||
|
"**/__tests__/**",
|
||||||
|
"**/test/**",
|
||||||
|
"**/tests/**",
|
||||||
|
"**/__fixtures__/**",
|
||||||
|
"**/crate/**",
|
||||||
|
"shared/**"
|
||||||
|
],
|
||||||
|
"rangeStrategy": "bump",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"semanticCommitType": "chore",
|
||||||
|
"matchPackageNames": ["*"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Disable node/pnpm version updates",
|
||||||
|
"matchPackageNames": ["node", "pnpm"],
|
||||||
|
"matchDepTypes": ["engines", "packageManager"],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group all cargo dependencies into a single PR",
|
||||||
|
"matchManagers": ["cargo"],
|
||||||
|
"groupName": "cargo dependencies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group all npm dependencies into a single PR",
|
||||||
|
"matchManagers": ["npm"],
|
||||||
|
"groupName": "npm dependencies"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"postUpdateOptions": ["pnpmDedupe"],
|
||||||
|
"ignoreDeps": ["serde_yaml"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const LOCALES_DIR = path.resolve(__dirname, '../src/locales');
|
||||||
|
const SRC_DIRS = [
|
||||||
|
path.resolve(__dirname, '../src'),
|
||||||
|
path.resolve(__dirname, '../src-tauri')
|
||||||
|
];
|
||||||
|
const exts = ['.js', '.ts', '.tsx', '.jsx', '.vue', '.rs'];
|
||||||
|
|
||||||
|
// 递归获取所有文件
|
||||||
|
function getAllFiles(dir, exts) {
|
||||||
|
let files = [];
|
||||||
|
fs.readdirSync(dir).forEach(file => {
|
||||||
|
const full = path.join(dir, file);
|
||||||
|
if (fs.statSync(full).isDirectory()) {
|
||||||
|
files = files.concat(getAllFiles(full, exts));
|
||||||
|
} else if (exts.includes(path.extname(full))) {
|
||||||
|
files.push(full);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取所有源码内容为一个大字符串
|
||||||
|
function getAllSourceContent() {
|
||||||
|
const files = SRC_DIRS.flatMap(dir => getAllFiles(dir, exts));
|
||||||
|
return files.map(f => fs.readFileSync(f, 'utf8')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单 key,不检查这些 key 是否被使用
|
||||||
|
const WHITELIST_KEYS = [
|
||||||
|
'theme.light',
|
||||||
|
'theme.dark',
|
||||||
|
'theme.system',
|
||||||
|
"Already Using Latest Core Version"
|
||||||
|
];
|
||||||
|
|
||||||
|
// 主流程
|
||||||
|
function processI18nFile(i18nPath, lang, allSource) {
|
||||||
|
const i18n = JSON.parse(fs.readFileSync(i18nPath, 'utf8'));
|
||||||
|
const keys = Object.keys(i18n);
|
||||||
|
|
||||||
|
const used = {};
|
||||||
|
const unused = [];
|
||||||
|
|
||||||
|
let checked = 0;
|
||||||
|
const total = keys.length;
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (WHITELIST_KEYS.includes(key)) {
|
||||||
|
used[key] = i18n[key];
|
||||||
|
} else {
|
||||||
|
// 只查找一次
|
||||||
|
const regex = new RegExp(`["'\`]${key}["'\`]`);
|
||||||
|
if (regex.test(allSource)) {
|
||||||
|
used[key] = i18n[key];
|
||||||
|
} else {
|
||||||
|
unused.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checked++;
|
||||||
|
if (checked % 20 === 0 || checked === total) {
|
||||||
|
const percent = ((checked / total) * 100).toFixed(1);
|
||||||
|
process.stdout.write(`\r[${lang}] Progress: ${checked}/${total} (${percent}%)`);
|
||||||
|
if (checked === total) process.stdout.write('\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 输出未使用的 key
|
||||||
|
console.log(`\n[${lang}] Unused keys:`, unused);
|
||||||
|
|
||||||
|
// 备份原文件
|
||||||
|
const oldPath = i18nPath + '.old';
|
||||||
|
fs.renameSync(i18nPath, oldPath);
|
||||||
|
|
||||||
|
// 写入精简后的 i18n 文件(保留原文件名)
|
||||||
|
fs.writeFileSync(i18nPath, JSON.stringify(used, null, 2), 'utf8');
|
||||||
|
console.log(`[${lang}] Cleaned i18n file written to src/locales/${path.basename(i18nPath)}`);
|
||||||
|
console.log(`[${lang}] Original file backed up as ${path.basename(oldPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
// 支持 zhtw.json、zh-tw.json、zh_CN.json 等
|
||||||
|
const files = fs.readdirSync(LOCALES_DIR).filter(f =>
|
||||||
|
/^[a-z0-9\-_]+\.json$/i.test(f) && !f.endsWith('.old')
|
||||||
|
);
|
||||||
|
const allSource = getAllSourceContent();
|
||||||
|
files.forEach(file => {
|
||||||
|
const lang = path.basename(file, '.json');
|
||||||
|
processI18nFile(path.join(LOCALES_DIR, file), lang, allSource);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -38,6 +38,17 @@ async function updatePackageVersion(newVersion) {
|
|||||||
const packageJson = JSON.parse(data);
|
const packageJson = JSON.parse(data);
|
||||||
// 获取键值替换
|
// 获取键值替换
|
||||||
let result = packageJson.version.replace("alpha", newVersion);
|
let result = packageJson.version.replace("alpha", newVersion);
|
||||||
|
// 检查当前版本号是否已经包含了 alpha- 后缀
|
||||||
|
if (!packageJson.version.includes(`alpha-`)) {
|
||||||
|
// 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion
|
||||||
|
result = packageJson.version.replace("alpha", `alpha-${newVersion}`);
|
||||||
|
} else {
|
||||||
|
// 如果已经是 alpha-xxx 格式,则更新 xxx 部分
|
||||||
|
result = packageJson.version.replace(
|
||||||
|
/alpha-[^-]*/,
|
||||||
|
`alpha-${newVersion}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
console.log("[INFO]: Current version is: ", result);
|
console.log("[INFO]: Current version is: ", result);
|
||||||
packageJson.version = result;
|
packageJson.version = result;
|
||||||
// 写入版本号
|
// 写入版本号
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// scripts/publish-version.mjs
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const rootDir = process.cwd();
|
||||||
|
const scriptPath = path.join(rootDir, "scripts", "release-version.mjs");
|
||||||
|
|
||||||
|
if (!existsSync(scriptPath)) {
|
||||||
|
console.error("release-version.mjs not found!");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionArg = process.argv[2];
|
||||||
|
if (!versionArg) {
|
||||||
|
console.error("Usage: pnpm publish-version <version>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 调用 release-version.mjs
|
||||||
|
const runRelease = () =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const child = spawn("node", [scriptPath, versionArg], { stdio: "inherit" });
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error("release-version failed"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 判断是否需要打 tag
|
||||||
|
function isSemver(version) {
|
||||||
|
return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await runRelease();
|
||||||
|
|
||||||
|
let tag = null;
|
||||||
|
if (versionArg === "alpha") {
|
||||||
|
// 读取 package.json 里的主版本
|
||||||
|
const pkg = await import(path.join(rootDir, "package.json"), { assert: { type: "json" } });
|
||||||
|
tag = `v${pkg.default.version}-alpha`;
|
||||||
|
} else if (isSemver(versionArg)) {
|
||||||
|
// 1.2.3 或 v1.2.3
|
||||||
|
tag = versionArg.startsWith("v") ? versionArg : `v${versionArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
// 打 tag 并推送
|
||||||
|
const { execSync } = await import("child_process");
|
||||||
|
try {
|
||||||
|
execSync(`git tag ${tag}`, { stdio: "inherit" });
|
||||||
|
execSync(`git push origin ${tag}`, { stdio: "inherit" });
|
||||||
|
console.log(`[INFO]: Git tag ${tag} created and pushed.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[ERROR]: Failed to create or push git tag: ${tag}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[INFO]: No git tag created for this version.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 package.json 文件中的版本号
|
|
||||||
*/
|
|
||||||
async function updatePackageVersion() {
|
|
||||||
const _dirname = process.cwd();
|
|
||||||
const packageJsonPath = path.join(_dirname, "package.json");
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(data);
|
|
||||||
|
|
||||||
let result = packageJson.version;
|
|
||||||
if (!result.includes("alpha")) {
|
|
||||||
result = `${result}-alpha`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[INFO]: Current package.json version is: ", result);
|
|
||||||
packageJson.version = result;
|
|
||||||
await fs.writeFile(
|
|
||||||
packageJsonPath,
|
|
||||||
JSON.stringify(packageJson, null, 2),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
console.log(`[INFO]: package.json version updated to: ${result}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating package.json version:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 Cargo.toml 文件中的版本号
|
|
||||||
*/
|
|
||||||
async function updateCargoVersion() {
|
|
||||||
const _dirname = process.cwd();
|
|
||||||
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml");
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(cargoTomlPath, "utf8");
|
|
||||||
const lines = data.split("\n");
|
|
||||||
|
|
||||||
const updatedLines = lines.map((line) => {
|
|
||||||
if (line.startsWith("version =")) {
|
|
||||||
const versionMatch = line.match(/version\s*=\s*"([^"]+)"/);
|
|
||||||
if (versionMatch && !versionMatch[1].includes("alpha")) {
|
|
||||||
const newVersion = `${versionMatch[1]}-alpha`;
|
|
||||||
return line.replace(versionMatch[1], newVersion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating Cargo.toml version:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 tauri.conf.json 文件中的版本号
|
|
||||||
*/
|
|
||||||
async function updateTauriConfigVersion() {
|
|
||||||
const _dirname = process.cwd();
|
|
||||||
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json");
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(tauriConfigPath, "utf8");
|
|
||||||
const tauriConfig = JSON.parse(data);
|
|
||||||
|
|
||||||
let version = tauriConfig.version;
|
|
||||||
if (!version.includes("alpha")) {
|
|
||||||
version = `${version}-alpha`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[INFO]: Current tauri.conf.json version is: ", version);
|
|
||||||
tauriConfig.version = version;
|
|
||||||
await fs.writeFile(
|
|
||||||
tauriConfigPath,
|
|
||||||
JSON.stringify(tauriConfig, null, 2),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
console.log(`[INFO]: tauri.conf.json version updated to: ${version}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating tauri.conf.json version:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主函数,依次更新所有文件的版本号
|
|
||||||
*/
|
|
||||||
async function main() {
|
|
||||||
await updatePackageVersion();
|
|
||||||
await updateCargoVersion();
|
|
||||||
await updateTauriConfigVersion();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm release-version <version>
|
||||||
|
*
|
||||||
|
* <version> can be:
|
||||||
|
* - A full semver version (e.g., 1.2.3, v1.2.3, 1.2.3-beta, v1.2.3+build)
|
||||||
|
* - A tag: "alpha", "beta", "rc", or "autobuild"
|
||||||
|
* - "alpha", "beta", "rc": Appends the tag to the current base version (e.g., 1.2.3-beta)
|
||||||
|
* - "autobuild": Appends a timestamped autobuild tag (e.g., 1.2.3+autobuild.2406101530)
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* pnpm release-version 1.2.3
|
||||||
|
* pnpm release-version v1.2.3-beta
|
||||||
|
* pnpm release-version beta
|
||||||
|
* pnpm release-version autobuild
|
||||||
|
*
|
||||||
|
* The script will:
|
||||||
|
* - Validate and normalize the version argument
|
||||||
|
* - Update the version field in package.json
|
||||||
|
* - Update the version field in src-tauri/Cargo.toml
|
||||||
|
* - Update the version field in src-tauri/tauri.conf.json
|
||||||
|
*
|
||||||
|
* Errors are logged and the process exits with code 1 on failure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { program } from "commander";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成短时间戳(格式:YYMMDDHHMM)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function generateShortTimestamp() {
|
||||||
|
const now = new Date();
|
||||||
|
const year = String(now.getFullYear()).slice(-2);
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(now.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||||
|
return `${year}${month}${day}${hours}${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证版本号格式
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isValidVersion(version) {
|
||||||
|
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化版本号
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function normalizeVersion(version) {
|
||||||
|
return version.startsWith("v") ? version : `v${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取基础版本号(去掉所有 -tag 和 +build 部分)
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getBaseVersion(version) {
|
||||||
|
let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '');
|
||||||
|
base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '');
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 package.json 版本号
|
||||||
|
* @param {string} newVersion
|
||||||
|
*/
|
||||||
|
async function updatePackageVersion(newVersion) {
|
||||||
|
const _dirname = process.cwd();
|
||||||
|
const packageJsonPath = path.join(_dirname, "package.json");
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(packageJsonPath, "utf8");
|
||||||
|
const packageJson = JSON.parse(data);
|
||||||
|
|
||||||
|
console.log("[INFO]: Current package.json version is: ", packageJson.version);
|
||||||
|
packageJson.version = newVersion.startsWith("v") ? newVersion.slice(1) : newVersion;
|
||||||
|
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), "utf8");
|
||||||
|
console.log(`[INFO]: package.json version updated to: ${packageJson.version}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating package.json version:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 Cargo.toml 版本号
|
||||||
|
* @param {string} newVersion
|
||||||
|
*/
|
||||||
|
async function updateCargoVersion(newVersion) {
|
||||||
|
const _dirname = process.cwd();
|
||||||
|
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml");
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(cargoTomlPath, "utf8");
|
||||||
|
const lines = data.split("\n");
|
||||||
|
const versionWithoutV = newVersion.startsWith("v") ? newVersion.slice(1) : newVersion;
|
||||||
|
const baseVersion = getBaseVersion(versionWithoutV);
|
||||||
|
|
||||||
|
const updatedLines = lines.map((line) => {
|
||||||
|
if (line.trim().startsWith("version =")) {
|
||||||
|
return line.replace(/version\s*=\s*"[^"]+"/, `version = "${baseVersion}"`);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8");
|
||||||
|
console.log(`[INFO]: Cargo.toml version updated to: ${baseVersion}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating Cargo.toml version:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 tauri.conf.json 版本号
|
||||||
|
* @param {string} newVersion
|
||||||
|
*/
|
||||||
|
async function updateTauriConfigVersion(newVersion) {
|
||||||
|
const _dirname = process.cwd();
|
||||||
|
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json");
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(tauriConfigPath, "utf8");
|
||||||
|
const tauriConfig = JSON.parse(data);
|
||||||
|
const versionWithoutV = newVersion.startsWith("v") ? newVersion.slice(1) : newVersion;
|
||||||
|
const baseVersion = getBaseVersion(versionWithoutV);
|
||||||
|
|
||||||
|
console.log("[INFO]: Current tauri.conf.json version is: ", tauriConfig.version);
|
||||||
|
tauriConfig.version = baseVersion;
|
||||||
|
await fs.writeFile(tauriConfigPath, JSON.stringify(tauriConfig, null, 2), "utf8");
|
||||||
|
console.log(`[INFO]: tauri.conf.json version updated to: ${baseVersion}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating tauri.conf.json version:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前版本号
|
||||||
|
*/
|
||||||
|
async function getCurrentVersion() {
|
||||||
|
const _dirname = process.cwd();
|
||||||
|
const packageJsonPath = path.join(_dirname, "package.json");
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(packageJsonPath, "utf8");
|
||||||
|
const packageJson = JSON.parse(data);
|
||||||
|
return packageJson.version;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting current version:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主函数
|
||||||
|
*/
|
||||||
|
async function main(versionArg) {
|
||||||
|
if (!versionArg) {
|
||||||
|
console.error("Error: Version argument is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let newVersion;
|
||||||
|
const validTags = ["alpha", "beta", "rc", "autobuild"];
|
||||||
|
|
||||||
|
if (validTags.includes(versionArg.toLowerCase())) {
|
||||||
|
const currentVersion = await getCurrentVersion();
|
||||||
|
const baseVersion = getBaseVersion(currentVersion);
|
||||||
|
|
||||||
|
if (versionArg.toLowerCase() === "autobuild") {
|
||||||
|
const timestamp = generateShortTimestamp();
|
||||||
|
newVersion = `${baseVersion}+autobuild.${timestamp}`;
|
||||||
|
} else {
|
||||||
|
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isValidVersion(versionArg)) {
|
||||||
|
console.error("Error: Invalid version format");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
newVersion = normalizeVersion(versionArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[INFO]: Updating versions to: ${newVersion}`);
|
||||||
|
await updatePackageVersion(newVersion);
|
||||||
|
await updateCargoVersion(newVersion);
|
||||||
|
await updateTauriConfigVersion(newVersion);
|
||||||
|
console.log("[SUCCESS]: All version updates completed successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ERROR]: Failed to update versions:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("pnpm release-version")
|
||||||
|
.description("Update project version numbers")
|
||||||
|
.argument("<version>", "version tag or full version")
|
||||||
|
.action(main)
|
||||||
|
.parse(process.argv);
|
||||||
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import { program } from "commander";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证版本号格式
|
|
||||||
* @param {string} version
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function isValidVersion(version) {
|
|
||||||
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?$/i.test(version);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标准化版本号(确保v前缀可选)
|
|
||||||
* @param {string} version
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function normalizeVersion(version) {
|
|
||||||
return version.startsWith("v") ? version : `v${version}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 package.json 文件中的版本号
|
|
||||||
* @param {string} newVersion 新版本号
|
|
||||||
*/
|
|
||||||
async function updatePackageVersion(newVersion) {
|
|
||||||
const _dirname = process.cwd();
|
|
||||||
const packageJsonPath = path.join(_dirname, "package.json");
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(data);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[INFO]: Current package.json version is: ",
|
|
||||||
packageJson.version,
|
|
||||||
);
|
|
||||||
packageJson.version = newVersion.startsWith("v")
|
|
||||||
? newVersion.slice(1)
|
|
||||||
: newVersion;
|
|
||||||
await fs.writeFile(
|
|
||||||
packageJsonPath,
|
|
||||||
JSON.stringify(packageJson, null, 2),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[INFO]: package.json version updated to: ${packageJson.version}`,
|
|
||||||
);
|
|
||||||
return packageJson.version;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating package.json version:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 Cargo.toml 文件中的版本号
|
|
||||||
* @param {string} newVersion 新版本号
|
|
||||||
*/
|
|
||||||
async function updateCargoVersion(newVersion) {
|
|
||||||
const _dirname = process.cwd();
|
|
||||||
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml");
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(cargoTomlPath, "utf8");
|
|
||||||
const lines = data.split("\n");
|
|
||||||
|
|
||||||
const versionWithoutV = newVersion.startsWith("v")
|
|
||||||
? newVersion.slice(1)
|
|
||||||
: newVersion;
|
|
||||||
|
|
||||||
const updatedLines = lines.map((line) => {
|
|
||||||
if (line.trim().startsWith("version =")) {
|
|
||||||
return line.replace(
|
|
||||||
/version\s*=\s*"[^"]+"/,
|
|
||||||
`version = "${versionWithoutV}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8");
|
|
||||||
console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating Cargo.toml version:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新 tauri.conf.json 文件中的版本号
|
|
||||||
* @param {string} newVersion 新版本号
|
|
||||||
*/
|
|
||||||
async function updateTauriConfigVersion(newVersion) {
|
|
||||||
const _dirname = process.cwd();
|
|
||||||
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json");
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(tauriConfigPath, "utf8");
|
|
||||||
const tauriConfig = JSON.parse(data);
|
|
||||||
|
|
||||||
const versionWithoutV = newVersion.startsWith("v")
|
|
||||||
? newVersion.slice(1)
|
|
||||||
: newVersion;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[INFO]: Current tauri.conf.json version is: ",
|
|
||||||
tauriConfig.version,
|
|
||||||
);
|
|
||||||
tauriConfig.version = versionWithoutV;
|
|
||||||
await fs.writeFile(
|
|
||||||
tauriConfigPath,
|
|
||||||
JSON.stringify(tauriConfig, null, 2),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating tauri.conf.json version:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前版本号(从package.json)
|
|
||||||
*/
|
|
||||||
async function getCurrentVersion() {
|
|
||||||
const _dirname = process.cwd();
|
|
||||||
const packageJsonPath = path.join(_dirname, "package.json");
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(packageJsonPath, "utf8");
|
|
||||||
const packageJson = JSON.parse(data);
|
|
||||||
return packageJson.version;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting current version:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 主函数,更新所有文件的版本号
|
|
||||||
* @param {string} versionArg 版本参数(可以是标签或完整版本号)
|
|
||||||
*/
|
|
||||||
async function main(versionArg) {
|
|
||||||
if (!versionArg) {
|
|
||||||
console.error("Error: Version argument is required");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let newVersion;
|
|
||||||
const validTags = ["alpha", "beta", "rc"];
|
|
||||||
|
|
||||||
// 判断参数是标签还是完整版本号
|
|
||||||
if (validTags.includes(versionArg.toLowerCase())) {
|
|
||||||
// 标签模式:在当前版本基础上添加标签
|
|
||||||
const currentVersion = await getCurrentVersion();
|
|
||||||
const baseVersion = currentVersion.replace(
|
|
||||||
/-(alpha|beta|rc)(\.\d+)?$/i,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`;
|
|
||||||
} else {
|
|
||||||
// 完整版本号模式
|
|
||||||
if (!isValidVersion(versionArg)) {
|
|
||||||
console.error(
|
|
||||||
"Error: Invalid version format. Expected format: vX.X.X or vX.X.X-tag (e.g. v2.2.3 or v2.2.3-alpha)",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
newVersion = normalizeVersion(versionArg);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[INFO]: Updating versions to: ${newVersion}`);
|
|
||||||
await updatePackageVersion(newVersion);
|
|
||||||
await updateCargoVersion(newVersion);
|
|
||||||
await updateTauriConfigVersion(newVersion);
|
|
||||||
console.log("[SUCCESS]: All version updates completed successfully!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[ERROR]: Failed to update versions:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example:
|
|
||||||
// pnpm release-version 2.2.3-alpha
|
|
||||||
// 设置命令行界面
|
|
||||||
program
|
|
||||||
.name("pnpm release-version")
|
|
||||||
.description(
|
|
||||||
"Update project version numbers. Can add tag (alpha/beta/rc) or set full version (e.g. v2.2.3 or v2.2.3-alpha)",
|
|
||||||
)
|
|
||||||
.argument(
|
|
||||||
"<version>",
|
|
||||||
"version tag (alpha/beta/rc) or full version (e.g. v2.2.3 or v2.2.3-alpha)",
|
|
||||||
)
|
|
||||||
.action(main)
|
|
||||||
.parse(process.argv);
|
|
||||||
Generated
+315
-370
File diff suppressed because it is too large
Load Diff
+51
-41
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.2.3"
|
version = "2.3.0"
|
||||||
description = "clash verge"
|
description = "clash verge"
|
||||||
authors = ["zzzgydi", "wonfen", "MystiPanda"]
|
authors = ["zzzgydi", "wonfen", "MystiPanda"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -13,40 +13,41 @@ build = "build.rs"
|
|||||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.1.0", features = [] }
|
tauri-build = { version = "2.2.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
warp = "0.3"
|
warp = "0.3.7"
|
||||||
anyhow = "1.0.97"
|
anyhow = "1.0.98"
|
||||||
dirs = "6.0"
|
dirs = "6.0"
|
||||||
open = "5.3"
|
open = "5.3.2"
|
||||||
log = "0.4"
|
log = "0.4.27"
|
||||||
dunce = "1.0"
|
dunce = "1.0.5"
|
||||||
log4rs = "1"
|
log4rs = "1.3.0"
|
||||||
nanoid = "0.4"
|
nanoid = "0.4"
|
||||||
chrono = "0.4.40"
|
chrono = "0.4.41"
|
||||||
sysinfo = "0.34"
|
sysinfo = "0.35.2"
|
||||||
boa_engine = "0.20.0"
|
boa_engine = "0.20.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0.140"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9.34-deprecated"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
|
lazy_static = "1.5.0"
|
||||||
port_scanner = "0.1.5"
|
port_scanner = "0.1.5"
|
||||||
delay_timer = "0.11.6"
|
delay_timer = "0.11.6"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12.4"
|
||||||
percent-encoding = "2.3.1"
|
percent-encoding = "2.3.1"
|
||||||
tokio = { version = "1.44", features = [
|
tokio = { version = "1.45.1", features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"macros",
|
"macros",
|
||||||
"time",
|
"time",
|
||||||
"sync",
|
"sync",
|
||||||
] }
|
] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "cookies"] }
|
reqwest = { version = "0.12.19", features = ["json", "rustls-tls", "cookies"] }
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", rev = "3d748b5" }
|
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs" }
|
||||||
image = "0.25.6"
|
image = "0.25.6"
|
||||||
imageproc = "0.25.0"
|
imageproc = "0.25.0"
|
||||||
tauri = { version = "2.4.0", features = [
|
tauri = { version = "2.5.1", features = [
|
||||||
"protocol-asset",
|
"protocol-asset",
|
||||||
"devtools",
|
"devtools",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
@@ -54,40 +55,53 @@ tauri = { version = "2.4.0", features = [
|
|||||||
"image-png",
|
"image-png",
|
||||||
] }
|
] }
|
||||||
network-interface = { version = "2.0.1", features = ["serde"] }
|
network-interface = { version = "2.0.1", features = ["serde"] }
|
||||||
tauri-plugin-shell = "2.2.0"
|
tauri-plugin-shell = "2.2.1"
|
||||||
tauri-plugin-dialog = "2.2.0"
|
tauri-plugin-dialog = "2.2.2"
|
||||||
tauri-plugin-fs = "2.2.0"
|
tauri-plugin-fs = "2.3.0"
|
||||||
tauri-plugin-process = "2.2.0"
|
tauri-plugin-process = "2.2.1"
|
||||||
tauri-plugin-clipboard-manager = "2.2.2"
|
tauri-plugin-clipboard-manager = "2.2.2"
|
||||||
tauri-plugin-deep-link = "2.2.0"
|
tauri-plugin-deep-link = "2.3.0"
|
||||||
tauri-plugin-devtools = "2.0.0"
|
tauri-plugin-devtools = "2.0.0"
|
||||||
zip = "2.5.0"
|
tauri-plugin-window-state = "2.2.2"
|
||||||
reqwest_dav = "0.1.15"
|
zip = "4.0.0"
|
||||||
|
reqwest_dav = "0.2.1"
|
||||||
aes-gcm = { version = "0.10.3", features = ["std"] }
|
aes-gcm = { version = "0.10.3", features = ["std"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
getrandom = "0.3.2"
|
getrandom = "0.3.3"
|
||||||
tokio-tungstenite = "0.26.2"
|
tokio-tungstenite = "0.26.2"
|
||||||
futures = "0.3"
|
futures = "0.3.31"
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
async-trait = "0.1.88"
|
async-trait = "0.1.88"
|
||||||
mihomo_api = { path = "src_crates/crate_mihomo_api" }
|
mihomo_api = { path = "src_crates/crate_mihomo_api" }
|
||||||
ab_glyph = "0.2.29"
|
ab_glyph = "0.2.29"
|
||||||
tungstenite = "0.26.2"
|
tungstenite = "0.26.2"
|
||||||
libc = "0.2"
|
libc = "0.2.172"
|
||||||
|
gethostname = "1.0.2"
|
||||||
|
hmac = "0.12.1"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
hex = "0.4.3"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
runas = "=1.2.0"
|
runas = "=1.2.0"
|
||||||
deelevate = "0.2.0"
|
deelevate = "0.2.0"
|
||||||
winreg = "0.55.0"
|
winreg = "0.55.0"
|
||||||
|
winapi = { version = "0.3.9", features = [
|
||||||
|
"winbase",
|
||||||
|
"fileapi",
|
||||||
|
"winnt",
|
||||||
|
"handleapi",
|
||||||
|
"errhandlingapi",
|
||||||
|
"minwindef",
|
||||||
|
"winerror",
|
||||||
|
] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
users = "0.11.0"
|
users = "0.11.0"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2.2.0"
|
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-global-shortcut = "2.2.0"
|
tauri-plugin-global-shortcut = "2.2.1"
|
||||||
tauri-plugin-updater = "2.6.1"
|
tauri-plugin-updater = "2.7.1"
|
||||||
tauri-plugin-window-state = "2.2.1"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["custom-protocol"]
|
default = ["custom-protocol"]
|
||||||
@@ -103,6 +117,10 @@ strip = true
|
|||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
incremental = true
|
incremental = true
|
||||||
|
codegen-units = 256 # 增加编译单元,提升编译速度
|
||||||
|
opt-level = 0 # 禁用优化,进一步提升编译速度
|
||||||
|
debug = true # 保留调试信息
|
||||||
|
strip = false # 不剥离符号,保留调试信息
|
||||||
|
|
||||||
[profile.fast-release]
|
[profile.fast-release]
|
||||||
inherits = "release" # 继承 release 的配置
|
inherits = "release" # 继承 release 的配置
|
||||||
@@ -113,20 +131,12 @@ opt-level = 0 # 禁用优化,大幅提升编译速度
|
|||||||
debug = true # 保留调试信息
|
debug = true # 保留调试信息
|
||||||
strip = false # 不剥离符号,保留调试信息
|
strip = false # 不剥离符号,保留调试信息
|
||||||
|
|
||||||
[profile.fast-dev]
|
|
||||||
inherits = "dev" # 继承 dev 的配置
|
|
||||||
codegen-units = 256 # 增加编译单元,提升编译速度
|
|
||||||
opt-level = 0 # 禁用优化,进一步提升编译速度
|
|
||||||
incremental = true # 启用增量编译
|
|
||||||
debug = true # 保留调试信息
|
|
||||||
strip = false # 不剥离符号,保留调试信息
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.19.1"
|
tempfile = "3.20.0"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["src_crates/crate_mihomo_api"]
|
members = ["src_crates/crate_mihomo_api"]
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"identifier": "desktop-windows-capability",
|
||||||
|
"description": "permissions for desktop windows applications",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:webview:allow-create-webview",
|
||||||
|
"core:webview:allow-create-webview-window"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
"updater:allow-download-and-install",
|
"updater:allow-download-and-install",
|
||||||
"process:allow-restart",
|
"process:allow-restart",
|
||||||
"deep-link:default",
|
"deep-link:default",
|
||||||
"window-state:default",
|
"autostart:allow-enable",
|
||||||
"window-state:default",
|
"autostart:allow-disable",
|
||||||
"autostart:default"
|
"autostart:allow-is-enabled",
|
||||||
|
"core:window:allow-set-theme"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -916,6 +916,10 @@ FunctionEnd
|
|||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
Section Uninstall
|
Section Uninstall
|
||||||
|
;删除 .window-state.json 文件
|
||||||
|
SetShellVarContext current
|
||||||
|
Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\.window-state.json"
|
||||||
|
|
||||||
!insertmacro CheckIfAppIsRunning
|
!insertmacro CheckIfAppIsRunning
|
||||||
!insertmacro CheckAllVergeProcesses
|
!insertmacro CheckAllVergeProcesses
|
||||||
!insertmacro RemoveVergeService
|
!insertmacro RemoveVergeService
|
||||||
@@ -975,16 +979,23 @@ Section Uninstall
|
|||||||
RMDir "$INSTDIR"
|
RMDir "$INSTDIR"
|
||||||
|
|
||||||
!insertmacro DeleteAppUserModelId
|
!insertmacro DeleteAppUserModelId
|
||||||
!insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk"
|
!insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
|
||||||
!insertmacro UnpinShortcut "$DESKTOP\${MAINBINARYNAME}.lnk"
|
!insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk"
|
||||||
|
; 兼容旧名称快捷方式
|
||||||
|
!insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\clash-verge.lnk"
|
||||||
|
!insertmacro UnpinShortcut "$DESKTOP\clash-verge.lnk"
|
||||||
|
|
||||||
; Remove start menu shortcut
|
; Remove start menu shortcut
|
||||||
!insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder
|
!insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder
|
||||||
Delete "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk"
|
Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
|
||||||
|
; 兼容旧名称快捷方式
|
||||||
|
Delete "$SMPROGRAMS\$AppStartMenuFolder\clash-verge.lnk"
|
||||||
RMDir "$SMPROGRAMS\$AppStartMenuFolder"
|
RMDir "$SMPROGRAMS\$AppStartMenuFolder"
|
||||||
|
|
||||||
; Remove desktop shortcuts
|
; Remove desktop shortcuts
|
||||||
Delete "$DESKTOP\${MAINBINARYNAME}.lnk"
|
Delete "$DESKTOP\${PRODUCTNAME}.lnk"
|
||||||
|
; 兼容旧名称快捷方式
|
||||||
|
Delete "$DESKTOP\clash-verge.lnk"
|
||||||
|
|
||||||
; Remove registry information for add/remove programs
|
; Remove registry information for add/remove programs
|
||||||
!if "${INSTALLMODE}" == "both"
|
!if "${INSTALLMODE}" == "both"
|
||||||
@@ -1004,6 +1015,10 @@ Section Uninstall
|
|||||||
RmDir /r "$LOCALAPPDATA\${BUNDLEID}"
|
RmDir /r "$LOCALAPPDATA\${BUNDLEID}"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
|
;删除 .window-state.json 文件
|
||||||
|
SetShellVarContext current
|
||||||
|
Delete "$APPDATA\io.github.clash-verge-rev.clash-verge-rev\.window-state.json"
|
||||||
|
|
||||||
${GetOptions} $CMDLINE "/P" $R0
|
${GetOptions} $CMDLINE "/P" $R0
|
||||||
IfErrors +2 0
|
IfErrors +2 0
|
||||||
SetAutoClose true
|
SetAutoClose true
|
||||||
@@ -1046,12 +1061,12 @@ FunctionEnd
|
|||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
Function CreateDesktopShortcut
|
Function CreateDesktopShortcut
|
||||||
CreateShortcut "$DESKTOP\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
||||||
!insertmacro SetLnkAppUserModelId "$DESKTOP\${MAINBINARYNAME}.lnk"
|
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
||||||
Function CreateStartMenuShortcut
|
Function CreateStartMenuShortcut
|
||||||
CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder"
|
CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder"
|
||||||
CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
||||||
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${MAINBINARYNAME}.lnk"
|
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk"
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|||||||
+40
-10
@@ -49,7 +49,7 @@ pub fn open_devtools(app_handle: tauri::AppHandle) {
|
|||||||
/// 退出应用
|
/// 退出应用
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn exit_app() {
|
pub fn exit_app() {
|
||||||
feat::quit(Some(0));
|
feat::quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重启应用
|
/// 重启应用
|
||||||
@@ -87,23 +87,18 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
|
|||||||
let icon_cache_dir = wrap_err!(dirs::app_home_dir())?.join("icons").join("cache");
|
let icon_cache_dir = wrap_err!(dirs::app_home_dir())?.join("icons").join("cache");
|
||||||
let icon_path = icon_cache_dir.join(&name);
|
let icon_path = icon_cache_dir.join(&name);
|
||||||
|
|
||||||
// 如果文件已存在,直接返回路径
|
|
||||||
if icon_path.exists() {
|
if icon_path.exists() {
|
||||||
return Ok(icon_path.to_string_lossy().to_string());
|
return Ok(icon_path.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保缓存目录存在
|
|
||||||
if !icon_cache_dir.exists() {
|
if !icon_cache_dir.exists() {
|
||||||
let _ = std::fs::create_dir_all(&icon_cache_dir);
|
let _ = std::fs::create_dir_all(&icon_cache_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用临时文件名来下载
|
|
||||||
let temp_path = icon_cache_dir.join(format!("{}.downloading", &name));
|
let temp_path = icon_cache_dir.join(format!("{}.downloading", &name));
|
||||||
|
|
||||||
// 下载文件到临时位置
|
|
||||||
let response = wrap_err!(reqwest::get(&url).await)?;
|
let response = wrap_err!(reqwest::get(&url).await)?;
|
||||||
|
|
||||||
// 检查内容类型是否为图片
|
|
||||||
let content_type = response
|
let content_type = response
|
||||||
.headers()
|
.headers()
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
@@ -112,16 +107,13 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
|
|||||||
|
|
||||||
let is_image = content_type.starts_with("image/");
|
let is_image = content_type.starts_with("image/");
|
||||||
|
|
||||||
// 获取响应内容
|
|
||||||
let content = wrap_err!(response.bytes().await)?;
|
let content = wrap_err!(response.bytes().await)?;
|
||||||
|
|
||||||
// 检查内容是否为HTML (针对CDN错误页面)
|
|
||||||
let is_html = content.len() > 15
|
let is_html = content.len() > 15
|
||||||
&& (content.starts_with(b"<!DOCTYPE html")
|
&& (content.starts_with(b"<!DOCTYPE html")
|
||||||
|| content.starts_with(b"<html")
|
|| content.starts_with(b"<html")
|
||||||
|| content.starts_with(b"<?xml"));
|
|| content.starts_with(b"<?xml"));
|
||||||
|
|
||||||
// 只有当内容确实是图片时才保存
|
|
||||||
if is_image && !is_html {
|
if is_image && !is_html {
|
||||||
{
|
{
|
||||||
let mut file = match std::fs::File::create(&temp_path) {
|
let mut file = match std::fs::File::create(&temp_path) {
|
||||||
@@ -138,7 +130,6 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
|
|||||||
wrap_err!(std::io::copy(&mut content.as_ref(), &mut file))?;
|
wrap_err!(std::io::copy(&mut content.as_ref(), &mut file))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再次检查目标文件是否已存在,避免重命名覆盖其他线程已完成的文件
|
|
||||||
if !icon_path.exists() {
|
if !icon_path.exists() {
|
||||||
match std::fs::rename(&temp_path, &icon_path) {
|
match std::fs::rename(&temp_path, &icon_path) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
@@ -214,3 +205,42 @@ pub fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult<String> {
|
|||||||
Err("file not found".to_string())
|
Err("file not found".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 通知UI已准备就绪
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn notify_ui_ready() -> CmdResult<()> {
|
||||||
|
log::info!(target: "app", "前端UI已准备就绪");
|
||||||
|
crate::utils::resolve::mark_ui_ready();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI加载阶段
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_ui_stage(stage: String) -> CmdResult<()> {
|
||||||
|
log::info!(target: "app", "UI加载阶段更新: {}", stage);
|
||||||
|
|
||||||
|
use crate::utils::resolve::UiReadyStage;
|
||||||
|
|
||||||
|
let stage_enum = match stage.as_str() {
|
||||||
|
"NotStarted" => UiReadyStage::NotStarted,
|
||||||
|
"Loading" => UiReadyStage::Loading,
|
||||||
|
"DomReady" => UiReadyStage::DomReady,
|
||||||
|
"ResourcesLoaded" => UiReadyStage::ResourcesLoaded,
|
||||||
|
"Ready" => UiReadyStage::Ready,
|
||||||
|
_ => {
|
||||||
|
log::warn!(target: "app", "未知的UI加载阶段: {}", stage);
|
||||||
|
return Err(format!("未知的UI加载阶段: {}", stage));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::utils::resolve::update_ui_ready_stage(stage_enum);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置UI就绪状态
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_ui_ready_state() -> CmdResult<()> {
|
||||||
|
log::info!(target: "app", "重置UI就绪状态");
|
||||||
|
crate::utils::resolve::reset_ui_ready();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::{config::*, core::*, feat, module::mihomo::MihomoManager, wrap_err};
|
use crate::{
|
||||||
|
config::*, core::*, feat, module::mihomo::MihomoManager, process::AsyncHandler, wrap_err,
|
||||||
|
};
|
||||||
use serde_yaml::Mapping;
|
use serde_yaml::Mapping;
|
||||||
|
|
||||||
/// 复制Clash环境变量
|
/// 复制Clash环境变量
|
||||||
@@ -38,10 +40,21 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!(target: "app", "core changed to {clash_core}");
|
// 切换内核后重启内核
|
||||||
handle::Handle::notice_message("config_core::change_success", &clash_core);
|
match CoreManager::global().restart_core().await {
|
||||||
handle::Handle::refresh_clash();
|
Ok(_) => {
|
||||||
Ok(None)
|
log::info!(target: "app", "core changed and restarted to {clash_core}");
|
||||||
|
handle::Handle::notice_message("config_core::change_success", &clash_core);
|
||||||
|
handle::Handle::refresh_clash();
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let error_msg = format!("Core changed but failed to restart: {}", err);
|
||||||
|
log::error!(target: "app", "{}", error_msg);
|
||||||
|
handle::Handle::notice_message("config_core::change_error", &error_msg);
|
||||||
|
Ok(Some(error_msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg = err.to_string();
|
let error_msg = err.to_string();
|
||||||
@@ -52,6 +65,18 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 启动核心
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn start_core() -> CmdResult {
|
||||||
|
wrap_err!(CoreManager::global().start_core().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭核心
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn stop_core() -> CmdResult {
|
||||||
|
wrap_err!(CoreManager::global().stop_core().await)
|
||||||
|
}
|
||||||
|
|
||||||
/// 重启核心
|
/// 重启核心
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn restart_core() -> CmdResult {
|
pub async fn restart_core() -> CmdResult {
|
||||||
@@ -104,10 +129,9 @@ pub fn apply_dns_config(apply: bool) -> CmdResult {
|
|||||||
core::{handle, CoreManager},
|
core::{handle, CoreManager},
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
use tauri::async_runtime;
|
|
||||||
|
|
||||||
// 使用spawn来处理异步操作
|
// 使用spawn来处理异步操作
|
||||||
async_runtime::spawn(async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
if apply {
|
if apply {
|
||||||
// 读取DNS配置文件
|
// 读取DNS配置文件
|
||||||
let dns_path = match dirs::app_home_dir() {
|
let dns_path = match dirs::app_home_dir() {
|
||||||
@@ -221,3 +245,25 @@ pub async fn get_dns_config_content() -> CmdResult<String> {
|
|||||||
let content = fs::read_to_string(&dns_path).map_err(|e| e.to_string())?;
|
let content = fs::read_to_string(&dns_path).map_err(|e| e.to_string())?;
|
||||||
Ok(content)
|
Ok(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 验证DNS配置文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn validate_dns_config() -> CmdResult<(bool, String)> {
|
||||||
|
use crate::{core::CoreManager, utils::dirs};
|
||||||
|
|
||||||
|
let app_dir = dirs::app_home_dir().map_err(|e| e.to_string())?;
|
||||||
|
let dns_path = app_dir.join("dns_config.yaml");
|
||||||
|
let dns_path_str = dns_path.to_str().unwrap_or_default();
|
||||||
|
|
||||||
|
if !dns_path.exists() {
|
||||||
|
return Ok((false, "DNS config file not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match CoreManager::global()
|
||||||
|
.validate_config_file(dns_path_str, None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,3 +7,9 @@ pub async fn entry_lightweight_mode() -> CmdResult {
|
|||||||
lightweight::entry_lightweight_mode();
|
lightweight::entry_lightweight_mode();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn exit_lightweight_mode() -> CmdResult {
|
||||||
|
lightweight::exit_lightweight_mode();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ async fn check_chatgpt_combined(client: &Client) -> Vec<UnlockItem> {
|
|||||||
if let Ok(body) = response.text().await {
|
if let Ok(body) = response.text().await {
|
||||||
let body_lower = body.to_lowercase();
|
let body_lower = body.to_lowercase();
|
||||||
if body_lower.contains("unsupported_country") {
|
if body_lower.contains("unsupported_country") {
|
||||||
"Unsupported Country"
|
"Unsupported Country/Region"
|
||||||
} else {
|
} else {
|
||||||
"Yes"
|
"Yes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ use crate::wrap_err;
|
|||||||
use network_interface::NetworkInterface;
|
use network_interface::NetworkInterface;
|
||||||
use serde_yaml::Mapping;
|
use serde_yaml::Mapping;
|
||||||
use sysproxy::{Autoproxy, Sysproxy};
|
use sysproxy::{Autoproxy, Sysproxy};
|
||||||
|
use tokio::task::spawn_blocking;
|
||||||
|
|
||||||
/// get the system proxy
|
/// get the system proxy
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_sys_proxy() -> CmdResult<Mapping> {
|
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
|
||||||
let current = wrap_err!(Sysproxy::get_system_proxy())?;
|
let current = spawn_blocking(Sysproxy::get_system_proxy)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to spawn blocking task for sysproxy: {}", e))?
|
||||||
|
.map_err(|e| format!("Failed to get system proxy: {}", e))?;
|
||||||
|
|
||||||
let mut map = Mapping::new();
|
let mut map = Mapping::new();
|
||||||
map.insert("enable".into(), current.enable.into());
|
map.insert("enable".into(), current.enable.into());
|
||||||
map.insert(
|
map.insert(
|
||||||
@@ -21,8 +26,11 @@ pub fn get_sys_proxy() -> CmdResult<Mapping> {
|
|||||||
|
|
||||||
/// get the system proxy
|
/// get the system proxy
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_auto_proxy() -> CmdResult<Mapping> {
|
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
|
||||||
let current = wrap_err!(Autoproxy::get_auto_proxy())?;
|
let current = spawn_blocking(Autoproxy::get_auto_proxy)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to spawn blocking task for autoproxy: {}", e))?
|
||||||
|
.map_err(|e| format!("Failed to get auto proxy: {}", e))?;
|
||||||
|
|
||||||
let mut map = Mapping::new();
|
let mut map = Mapping::new();
|
||||||
map.insert("enable".into(), current.enable.into());
|
map.insert("enable".into(), current.enable.into());
|
||||||
@@ -31,6 +39,25 @@ pub fn get_auto_proxy() -> CmdResult<Mapping> {
|
|||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取系统主机名
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_system_hostname() -> CmdResult<String> {
|
||||||
|
use gethostname::gethostname;
|
||||||
|
|
||||||
|
// 获取系统主机名,处理可能的非UTF-8字符
|
||||||
|
let hostname = match gethostname().into_string() {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(os_string) => {
|
||||||
|
// 对于包含非UTF-8的主机名,使用调试格式化
|
||||||
|
let fallback = format!("{:?}", os_string);
|
||||||
|
// 去掉可能存在的引号
|
||||||
|
fallback.trim_matches('"').to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取网络接口列表
|
/// 获取网络接口列表
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_network_interfaces() -> Vec<String> {
|
pub fn get_network_interfaces() -> Vec<String> {
|
||||||
|
|||||||
+220
-47
@@ -1,17 +1,58 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IProfiles, PrfItem, PrfOption},
|
config::{Config, IProfiles, PrfItem, PrfOption},
|
||||||
core::{handle, tray::Tray, CoreManager},
|
core::{handle, timer::Timer, tray::Tray, CoreManager},
|
||||||
feat, logging, ret_err,
|
feat, logging, ret_err,
|
||||||
utils::{dirs, help, logging::Type},
|
utils::{dirs, help, logging::Type},
|
||||||
wrap_err,
|
wrap_err,
|
||||||
};
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
// 添加全局互斥锁防止并发配置更新
|
||||||
|
static PROFILE_UPDATE_MUTEX: Mutex<()> = Mutex::const_new(());
|
||||||
|
|
||||||
/// 获取配置文件列表
|
/// 获取配置文件列表
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_profiles() -> CmdResult<IProfiles> {
|
pub async fn get_profiles() -> CmdResult<IProfiles> {
|
||||||
let _ = Tray::global().update_menu();
|
let profiles_result = tokio::time::timeout(
|
||||||
Ok(Config::profiles().data().clone())
|
Duration::from_secs(3), // 3秒超时
|
||||||
|
tokio::task::spawn_blocking(move || Config::profiles().data().clone()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match profiles_result {
|
||||||
|
Ok(Ok(profiles)) => Ok(profiles),
|
||||||
|
Ok(Err(join_err)) => {
|
||||||
|
logging!(error, Type::Cmd, true, "获取配置列表任务失败: {}", join_err);
|
||||||
|
Ok(IProfiles {
|
||||||
|
current: None,
|
||||||
|
items: Some(vec![]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// 超时情况
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Cmd,
|
||||||
|
true,
|
||||||
|
"获取配置列表超时(3秒),可能存在锁竞争"
|
||||||
|
);
|
||||||
|
match tokio::task::spawn_blocking(move || Config::profiles().latest().clone()).await {
|
||||||
|
Ok(profiles) => {
|
||||||
|
logging!(info, Type::Cmd, true, "使用latest()成功获取配置");
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
logging!(error, Type::Cmd, true, "fallback获取配置也失败,返回空配置");
|
||||||
|
Ok(IProfiles {
|
||||||
|
current: None,
|
||||||
|
items: Some(vec![]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 增强配置文件
|
/// 增强配置文件
|
||||||
@@ -45,7 +86,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
|||||||
/// 更新配置文件
|
/// 更新配置文件
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
|
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
|
||||||
wrap_err!(feat::update_profile(index, option).await)
|
wrap_err!(feat::update_profile(index, option, Some(true)).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除配置文件
|
/// 删除配置文件
|
||||||
@@ -62,6 +103,9 @@ pub async fn delete_profile(index: String) -> CmdResult {
|
|||||||
/// 修改profiles的配置
|
/// 修改profiles的配置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
||||||
|
// 获取互斥锁,防止并发执行
|
||||||
|
let _guard = PROFILE_UPDATE_MUTEX.lock().await;
|
||||||
|
|
||||||
logging!(info, Type::Cmd, true, "开始修改配置文件");
|
logging!(info, Type::Cmd, true, "开始修改配置文件");
|
||||||
|
|
||||||
// 保存当前配置,以便在验证失败时恢复
|
// 保存当前配置,以便在验证失败时恢复
|
||||||
@@ -74,21 +118,23 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
|||||||
logging!(info, Type::Cmd, true, "正在切换到新配置: {}", new_profile);
|
logging!(info, Type::Cmd, true, "正在切换到新配置: {}", new_profile);
|
||||||
|
|
||||||
// 获取目标配置文件路径
|
// 获取目标配置文件路径
|
||||||
let profiles_config = Config::profiles();
|
let config_file_result = {
|
||||||
let profiles_data = profiles_config.latest();
|
let profiles_config = Config::profiles();
|
||||||
let config_file_result = match profiles_data.get_item(new_profile) {
|
let profiles_data = profiles_config.latest();
|
||||||
Ok(item) => {
|
match profiles_data.get_item(new_profile) {
|
||||||
if let Some(file) = &item.file {
|
Ok(item) => {
|
||||||
let path = dirs::app_profiles_dir().map(|dir| dir.join(file));
|
if let Some(file) = &item.file {
|
||||||
path.ok()
|
let path = dirs::app_profiles_dir().map(|dir| dir.join(file));
|
||||||
} else {
|
path.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Cmd, true, "获取目标配置信息失败: {}", e);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
logging!(error, Type::Cmd, true, "获取目标配置信息失败: {}", e);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果获取到文件路径,检查YAML语法
|
// 如果获取到文件路径,检查YAML语法
|
||||||
@@ -108,28 +154,51 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
match std::fs::read_to_string(&file_path) {
|
// 超时保护
|
||||||
Ok(content) => match serde_yaml::from_str::<serde_yaml::Value>(&content) {
|
let file_read_result = tokio::time::timeout(
|
||||||
Ok(_) => {
|
Duration::from_secs(5),
|
||||||
logging!(info, Type::Cmd, true, "目标配置文件语法正确");
|
tokio::fs::read_to_string(&file_path),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match file_read_result {
|
||||||
|
Ok(Ok(content)) => {
|
||||||
|
let yaml_parse_result = tokio::task::spawn_blocking(move || {
|
||||||
|
serde_yaml::from_str::<serde_yaml::Value>(&content)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match yaml_parse_result {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
logging!(info, Type::Cmd, true, "目标配置文件语法正确");
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
let error_msg = format!(" {}", err);
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Cmd,
|
||||||
|
true,
|
||||||
|
"目标配置文件存在YAML语法错误:{}",
|
||||||
|
error_msg
|
||||||
|
);
|
||||||
|
handle::Handle::notice_message(
|
||||||
|
"config_validate::yaml_syntax_error",
|
||||||
|
&error_msg,
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
Err(join_err) => {
|
||||||
|
let error_msg = format!("YAML解析任务失败: {}", join_err);
|
||||||
|
logging!(error, Type::Cmd, true, "{}", error_msg);
|
||||||
|
handle::Handle::notice_message(
|
||||||
|
"config_validate::yaml_parse_error",
|
||||||
|
&error_msg,
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
}
|
||||||
let error_msg = format!(" {}", err);
|
Ok(Err(err)) => {
|
||||||
logging!(
|
|
||||||
error,
|
|
||||||
Type::Cmd,
|
|
||||||
true,
|
|
||||||
"目标配置文件存在YAML语法错误:{}",
|
|
||||||
error_msg
|
|
||||||
);
|
|
||||||
handle::Handle::notice_message(
|
|
||||||
"config_validate::yaml_syntax_error",
|
|
||||||
&error_msg,
|
|
||||||
);
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
|
||||||
let error_msg = format!("无法读取目标配置文件: {}", err);
|
let error_msg = format!("无法读取目标配置文件: {}", err);
|
||||||
logging!(error, Type::Cmd, true, "{}", error_msg);
|
logging!(error, Type::Cmd, true, "{}", error_msg);
|
||||||
handle::Handle::notice_message(
|
handle::Handle::notice_message(
|
||||||
@@ -138,6 +207,15 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
|||||||
);
|
);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let error_msg = "读取配置文件超时(5秒)".to_string();
|
||||||
|
logging!(error, Type::Cmd, true, "{}", error_msg);
|
||||||
|
handle::Handle::notice_message(
|
||||||
|
"config_validate::file_read_timeout",
|
||||||
|
&error_msg,
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,19 +223,49 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
|||||||
|
|
||||||
// 更新profiles配置
|
// 更新profiles配置
|
||||||
logging!(info, Type::Cmd, true, "正在更新配置草稿");
|
logging!(info, Type::Cmd, true, "正在更新配置草稿");
|
||||||
|
|
||||||
|
let current_value = profiles.current.clone();
|
||||||
|
|
||||||
let _ = Config::profiles().draft().patch_config(profiles);
|
let _ = Config::profiles().draft().patch_config(profiles);
|
||||||
|
|
||||||
|
// 为配置更新添加超时保护
|
||||||
|
let update_result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(30), // 30秒超时
|
||||||
|
CoreManager::global().update_config(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// 更新配置并进行验证
|
// 更新配置并进行验证
|
||||||
match CoreManager::global().update_config().await {
|
match update_result {
|
||||||
Ok((true, _)) => {
|
Ok(Ok((true, _))) => {
|
||||||
logging!(info, Type::Cmd, true, "配置更新成功");
|
logging!(info, Type::Cmd, true, "配置更新成功");
|
||||||
handle::Handle::refresh_clash();
|
|
||||||
let _ = Tray::global().update_tooltip();
|
|
||||||
Config::profiles().apply();
|
Config::profiles().apply();
|
||||||
wrap_err!(Config::profiles().data().save_file())?;
|
handle::Handle::refresh_clash();
|
||||||
|
|
||||||
|
crate::process::AsyncHandler::spawn(|| async move {
|
||||||
|
if let Err(e) = Tray::global().update_tooltip() {
|
||||||
|
log::warn!(target: "app", "异步更新托盘提示失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = Tray::global().update_menu() {
|
||||||
|
log::warn!(target: "app", "异步更新托盘菜单失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置文件
|
||||||
|
if let Err(e) = Config::profiles().data().save_file() {
|
||||||
|
log::warn!(target: "app", "异步保存配置文件失败: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 立即通知前端配置变更
|
||||||
|
if let Some(current) = ¤t_value {
|
||||||
|
logging!(info, Type::Cmd, true, "向前端发送配置变更事件: {}", current);
|
||||||
|
handle::Handle::notify_profile_changed(current.clone());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
Ok((false, error_msg)) => {
|
Ok(Ok((false, error_msg))) => {
|
||||||
logging!(warn, Type::Cmd, true, "配置验证失败: {}", error_msg);
|
logging!(warn, Type::Cmd, true, "配置验证失败: {}", error_msg);
|
||||||
Config::profiles().discard();
|
Config::profiles().discard();
|
||||||
// 如果验证失败,恢复到之前的配置
|
// 如果验证失败,恢复到之前的配置
|
||||||
@@ -176,7 +284,13 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
|||||||
// 静默恢复,不触发验证
|
// 静默恢复,不触发验证
|
||||||
wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?;
|
wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?;
|
||||||
Config::profiles().apply();
|
Config::profiles().apply();
|
||||||
wrap_err!(Config::profiles().data().save_file())?;
|
|
||||||
|
crate::process::AsyncHandler::spawn(|| async move {
|
||||||
|
if let Err(e) = Config::profiles().data().save_file() {
|
||||||
|
log::warn!(target: "app", "异步保存恢复配置文件失败: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logging!(info, Type::Cmd, true, "成功恢复到之前的配置");
|
logging!(info, Type::Cmd, true, "成功恢复到之前的配置");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,12 +298,37 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
|
|||||||
handle::Handle::notice_message("config_validate::error", &error_msg);
|
handle::Handle::notice_message("config_validate::error", &error_msg);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
logging!(warn, Type::Cmd, true, "更新过程发生错误: {}", e);
|
logging!(warn, Type::Cmd, true, "更新过程发生错误: {}", e);
|
||||||
Config::profiles().discard();
|
Config::profiles().discard();
|
||||||
handle::Handle::notice_message("config_validate::boot_error", e.to_string());
|
handle::Handle::notice_message("config_validate::boot_error", e.to_string());
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// 超时处理
|
||||||
|
let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞";
|
||||||
|
logging!(error, Type::Cmd, true, "{}", timeout_msg);
|
||||||
|
Config::profiles().discard();
|
||||||
|
|
||||||
|
if let Some(prev_profile) = current_profile {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Cmd,
|
||||||
|
true,
|
||||||
|
"超时后尝试恢复到之前的配置: {}",
|
||||||
|
prev_profile
|
||||||
|
);
|
||||||
|
let restore_profiles = IProfiles {
|
||||||
|
current: Some(prev_profile),
|
||||||
|
items: None,
|
||||||
|
};
|
||||||
|
wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?;
|
||||||
|
Config::profiles().apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
handle::Handle::notice_message("config_validate::timeout", timeout_msg);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +350,33 @@ pub async fn patch_profiles_config_by_profile_index(
|
|||||||
/// 修改某个profile item的
|
/// 修改某个profile item的
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||||
wrap_err!(Config::profiles().data().patch_item(index, profile))?;
|
// 保存修改前检查是否有更新 update_interval
|
||||||
|
let update_interval_changed =
|
||||||
|
if let Ok(old_profile) = Config::profiles().latest().get_item(&index) {
|
||||||
|
let old_interval = old_profile.option.as_ref().and_then(|o| o.update_interval);
|
||||||
|
let new_interval = profile.option.as_ref().and_then(|o| o.update_interval);
|
||||||
|
old_interval != new_interval
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存修改
|
||||||
|
wrap_err!(Config::profiles().data().patch_item(index.clone(), profile))?;
|
||||||
|
|
||||||
|
// 如果更新间隔变更,异步刷新定时器
|
||||||
|
if update_interval_changed {
|
||||||
|
let index_clone = index.clone();
|
||||||
|
crate::process::AsyncHandler::spawn(move || async move {
|
||||||
|
logging!(info, Type::Timer, "定时器更新间隔已变更,正在刷新定时器...");
|
||||||
|
if let Err(e) = crate::core::Timer::global().refresh() {
|
||||||
|
logging!(error, Type::Timer, "刷新定时器失败: {}", e);
|
||||||
|
} else {
|
||||||
|
// 刷新成功后发送自定义事件,不触发配置重载
|
||||||
|
crate::core::handle::Handle::notify_timer_updated(index_clone);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,3 +407,11 @@ pub fn read_profile_file(index: String) -> CmdResult<String> {
|
|||||||
let data = wrap_err!(item.read_file())?;
|
let data = wrap_err!(item.read_file())?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取下一次更新时间
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
|
||||||
|
let timer = Timer::global();
|
||||||
|
let next_time = timer.get_next_update_time(&uid);
|
||||||
|
Ok(next_time)
|
||||||
|
}
|
||||||
|
|||||||
+66
-13
@@ -1,24 +1,77 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::module::mihomo::MihomoManager;
|
use crate::{core::handle, module::mihomo::MihomoManager, state::proxy::CmdProxyState};
|
||||||
|
use std::{
|
||||||
|
sync::Mutex,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
const PROVIDERS_REFRESH_INTERVAL: Duration = Duration::from_secs(3);
|
||||||
|
const PROXIES_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_proxies() -> CmdResult<serde_json::Value> {
|
pub async fn get_proxies() -> CmdResult<serde_json::Value> {
|
||||||
let mannager = MihomoManager::global();
|
let manager = MihomoManager::global();
|
||||||
|
|
||||||
mannager
|
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||||
.refresh_proxies()
|
let cmd_proxy_state = app_handle.state::<Mutex<CmdProxyState>>();
|
||||||
.await
|
|
||||||
.map(|_| mannager.get_proxies())
|
let should_refresh = {
|
||||||
.or_else(|_| Ok(mannager.get_proxies()))
|
let mut state = cmd_proxy_state.lock().unwrap();
|
||||||
|
let now = Instant::now();
|
||||||
|
if now.duration_since(state.last_refresh_time) > PROXIES_REFRESH_INTERVAL {
|
||||||
|
state.need_refresh = true;
|
||||||
|
state.last_refresh_time = now;
|
||||||
|
}
|
||||||
|
state.need_refresh
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_refresh {
|
||||||
|
let proxies = manager.get_refresh_proxies().await?;
|
||||||
|
{
|
||||||
|
let mut state = cmd_proxy_state.lock().unwrap();
|
||||||
|
state.proxies = proxies;
|
||||||
|
state.need_refresh = false;
|
||||||
|
}
|
||||||
|
log::debug!(target: "app", "proxies刷新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxies = {
|
||||||
|
let state = cmd_proxy_state.lock().unwrap();
|
||||||
|
state.proxies.clone()
|
||||||
|
};
|
||||||
|
Ok(proxies)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
|
pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
|
||||||
let mannager = MihomoManager::global();
|
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||||
|
let cmd_proxy_state = app_handle.state::<Mutex<CmdProxyState>>();
|
||||||
|
|
||||||
mannager
|
let should_refresh = {
|
||||||
.refresh_providers_proxies()
|
let mut state = cmd_proxy_state.lock().unwrap();
|
||||||
.await
|
let now = Instant::now();
|
||||||
.map(|_| mannager.get_providers_proxies())
|
if now.duration_since(state.last_refresh_time) > PROVIDERS_REFRESH_INTERVAL {
|
||||||
.or_else(|_| Ok(mannager.get_providers_proxies()))
|
state.need_refresh = true;
|
||||||
|
state.last_refresh_time = now;
|
||||||
|
}
|
||||||
|
state.need_refresh
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_refresh {
|
||||||
|
let manager = MihomoManager::global();
|
||||||
|
let providers = manager.get_providers_proxies().await?;
|
||||||
|
{
|
||||||
|
let mut state = cmd_proxy_state.lock().unwrap();
|
||||||
|
state.providers_proxies = providers;
|
||||||
|
state.need_refresh = false;
|
||||||
|
}
|
||||||
|
log::debug!(target: "app", "providers_proxies刷新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers_proxies = {
|
||||||
|
let state = cmd_proxy_state.lock().unwrap();
|
||||||
|
state.providers_proxies.clone()
|
||||||
|
};
|
||||||
|
Ok(providers_proxies)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ pub async fn reinstall_service() -> CmdResult {
|
|||||||
pub async fn repair_service() -> CmdResult {
|
pub async fn repair_service() -> CmdResult {
|
||||||
execute_service_operation(service::force_reinstall_service(), "Repair").await
|
execute_service_operation(service::force_reinstall_service(), "Repair").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn is_service_available() -> CmdResult<bool> {
|
||||||
|
service::is_service_available()
|
||||||
|
.await
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ impl IClashTemp {
|
|||||||
map.insert(key.clone(), template.0.get(key).unwrap().clone());
|
map.insert(key.clone(), template.0.get(key).unwrap().clone());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 确保 secret 字段存在且不为空
|
||||||
|
if let Some(Value::String(s)) = map.get_mut("secret") {
|
||||||
|
if s.is_empty() {
|
||||||
|
*s = "set-your-secret".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
Self(Self::guard(map))
|
Self(Self::guard(map))
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -32,13 +38,13 @@ impl IClashTemp {
|
|||||||
pub fn template() -> Self {
|
pub fn template() -> Self {
|
||||||
let mut map = Mapping::new();
|
let mut map = Mapping::new();
|
||||||
let mut tun = Mapping::new();
|
let mut tun = Mapping::new();
|
||||||
|
let mut cors_map = Mapping::new();
|
||||||
tun.insert("enable".into(), false.into());
|
tun.insert("enable".into(), false.into());
|
||||||
tun.insert("stack".into(), "gvisor".into());
|
tun.insert("stack".into(), "gvisor".into());
|
||||||
tun.insert("auto-route".into(), true.into());
|
tun.insert("auto-route".into(), true.into());
|
||||||
tun.insert("strict-route".into(), false.into());
|
tun.insert("strict-route".into(), false.into());
|
||||||
tun.insert("auto-detect-interface".into(), true.into());
|
tun.insert("auto-detect-interface".into(), true.into());
|
||||||
tun.insert("dns-hijack".into(), vec!["any:53"].into());
|
tun.insert("dns-hijack".into(), vec!["any:53"].into());
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
map.insert("redir-port".into(), 7895.into());
|
map.insert("redir-port".into(), 7895.into());
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -46,14 +52,25 @@ impl IClashTemp {
|
|||||||
map.insert("mixed-port".into(), 7897.into());
|
map.insert("mixed-port".into(), 7897.into());
|
||||||
map.insert("socks-port".into(), 7898.into());
|
map.insert("socks-port".into(), 7898.into());
|
||||||
map.insert("port".into(), 7899.into());
|
map.insert("port".into(), 7899.into());
|
||||||
map.insert("log-level".into(), "info".into());
|
map.insert("log-level".into(), "warning".into());
|
||||||
map.insert("allow-lan".into(), false.into());
|
map.insert("allow-lan".into(), false.into());
|
||||||
|
map.insert("ipv6".into(), true.into());
|
||||||
map.insert("mode".into(), "rule".into());
|
map.insert("mode".into(), "rule".into());
|
||||||
map.insert("external-controller".into(), "127.0.0.1:9097".into());
|
map.insert("external-controller".into(), "127.0.0.1:9097".into());
|
||||||
let mut cors_map = Mapping::new();
|
|
||||||
cors_map.insert("allow-private-network".into(), true.into());
|
cors_map.insert("allow-private-network".into(), true.into());
|
||||||
cors_map.insert("allow-origins".into(), vec!["*"].into());
|
cors_map.insert(
|
||||||
map.insert("secret".into(), "".into());
|
"allow-origins".into(),
|
||||||
|
vec![
|
||||||
|
"tauri://localhost",
|
||||||
|
"http://tauri.localhost",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"https://yacd.metacubex.one",
|
||||||
|
"https://metacubex.github.io",
|
||||||
|
"https://board.zash.run.place",
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
map.insert("secret".into(), "set-your-secret".into());
|
||||||
map.insert("tun".into(), tun.into());
|
map.insert("tun".into(), tun.into());
|
||||||
map.insert("external-controller-cors".into(), cors_map.into());
|
map.insert("external-controller-cors".into(), cors_map.into());
|
||||||
map.insert("unified-delay".into(), true.into());
|
map.insert("unified-delay".into(), true.into());
|
||||||
@@ -77,6 +94,24 @@ impl IClashTemp {
|
|||||||
config.insert("socks-port".into(), socks_port.into());
|
config.insert("socks-port".into(), socks_port.into());
|
||||||
config.insert("port".into(), port.into());
|
config.insert("port".into(), port.into());
|
||||||
config.insert("external-controller".into(), ctrl.into());
|
config.insert("external-controller".into(), ctrl.into());
|
||||||
|
|
||||||
|
// 强制覆盖 external-controller-cors 字段,允许本地和 tauri 前端
|
||||||
|
let mut cors_map = Mapping::new();
|
||||||
|
cors_map.insert("allow-private-network".into(), true.into());
|
||||||
|
cors_map.insert(
|
||||||
|
"allow-origins".into(),
|
||||||
|
vec![
|
||||||
|
"tauri://localhost",
|
||||||
|
"http://tauri.localhost",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"https://yacd.metacubex.one",
|
||||||
|
"https://metacubex.github.io",
|
||||||
|
"https://board.zash.run.place",
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
config.insert("external-controller-cors".into(), cors_map.into());
|
||||||
|
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +351,13 @@ fn test_clash_info() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub struct IClashExternalControllerCors {
|
||||||
|
pub allow_origins: Option<Vec<String>>,
|
||||||
|
pub allow_private_network: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct IClash {
|
pub struct IClash {
|
||||||
@@ -329,6 +371,7 @@ pub struct IClash {
|
|||||||
pub dns: Option<IClashDNS>,
|
pub dns: Option<IClashDNS>,
|
||||||
pub tun: Option<IClashTUN>,
|
pub tun: Option<IClashTUN>,
|
||||||
pub interface_name: Option<String>,
|
pub interface_name: Option<String>,
|
||||||
|
pub external_controller_cors: Option<IClashExternalControllerCors>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::{
|
|||||||
config::PrfItem,
|
config::PrfItem,
|
||||||
core::{handle, CoreManager},
|
core::{handle, CoreManager},
|
||||||
enhance, logging,
|
enhance, logging,
|
||||||
|
process::AsyncHandler,
|
||||||
utils::{dirs, help, logging::Type},
|
utils::{dirs, help, logging::Type},
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
@@ -117,7 +118,7 @@ impl Config {
|
|||||||
|
|
||||||
// 在单独的任务中发送通知
|
// 在单独的任务中发送通知
|
||||||
if let Some((msg_type, msg_content)) = validation_result {
|
if let Some((msg_type, msg_content)) = validation_result {
|
||||||
tauri::async_runtime::spawn(async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
sleep(Duration::from_secs(2)).await;
|
sleep(Duration::from_secs(2)).await;
|
||||||
handle::Handle::notice_message(msg_type, &msg_content);
|
handle::Handle::notice_message(msg_type, &msg_content);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
use crate::utils::{dirs, help, resolve::VERSION, tmpl};
|
use crate::utils::{
|
||||||
|
dirs, help,
|
||||||
|
network::{NetworkManager, ProxyType},
|
||||||
|
tmpl,
|
||||||
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml::Mapping;
|
use serde_yaml::Mapping;
|
||||||
use std::fs;
|
use std::{fs, time::Duration};
|
||||||
use sysproxy::Sysproxy;
|
|
||||||
|
|
||||||
use super::Config;
|
use super::Config;
|
||||||
|
|
||||||
@@ -89,6 +92,12 @@ pub struct PrfOption {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub update_interval: Option<u64>,
|
pub update_interval: Option<u64>,
|
||||||
|
|
||||||
|
/// for `remote` profile
|
||||||
|
/// HTTP request timeout in seconds
|
||||||
|
/// default is 60 seconds
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_seconds: Option<u64>,
|
||||||
|
|
||||||
/// for `remote` profile
|
/// for `remote` profile
|
||||||
/// disable certificate validation
|
/// disable certificate validation
|
||||||
/// default is `false`
|
/// default is `false`
|
||||||
@@ -122,6 +131,7 @@ impl PrfOption {
|
|||||||
a.rules = b.rules.or(a.rules);
|
a.rules = b.rules.or(a.rules);
|
||||||
a.proxies = b.proxies.or(a.proxies);
|
a.proxies = b.proxies.or(a.proxies);
|
||||||
a.groups = b.groups.or(a.groups);
|
a.groups = b.groups.or(a.groups);
|
||||||
|
a.timeout_seconds = b.timeout_seconds.or(a.timeout_seconds);
|
||||||
Some(a)
|
Some(a)
|
||||||
}
|
}
|
||||||
t => t.0.or(t.1),
|
t => t.0.or(t.1),
|
||||||
@@ -240,58 +250,39 @@ impl PrfItem {
|
|||||||
opt_ref.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false));
|
opt_ref.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false));
|
||||||
let user_agent = opt_ref.and_then(|o| o.user_agent.clone());
|
let user_agent = opt_ref.and_then(|o| o.user_agent.clone());
|
||||||
let update_interval = opt_ref.and_then(|o| o.update_interval);
|
let update_interval = opt_ref.and_then(|o| o.update_interval);
|
||||||
|
let timeout = opt_ref.and_then(|o| o.timeout_seconds).unwrap_or(20);
|
||||||
let mut merge = opt_ref.and_then(|o| o.merge.clone());
|
let mut merge = opt_ref.and_then(|o| o.merge.clone());
|
||||||
let mut script = opt_ref.and_then(|o| o.script.clone());
|
let mut script = opt_ref.and_then(|o| o.script.clone());
|
||||||
let mut rules = opt_ref.and_then(|o| o.rules.clone());
|
let mut rules = opt_ref.and_then(|o| o.rules.clone());
|
||||||
let mut proxies = opt_ref.and_then(|o| o.proxies.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 groups = opt_ref.and_then(|o| o.groups.clone());
|
||||||
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
|
|
||||||
|
|
||||||
// 使用软件自己的代理
|
// 选择代理类型
|
||||||
if self_proxy {
|
let proxy_type = if self_proxy {
|
||||||
let port = Config::verge()
|
ProxyType::Localhost
|
||||||
.latest()
|
} else if with_proxy {
|
||||||
.verge_mixed_port
|
ProxyType::System
|
||||||
.unwrap_or(Config::clash().data().get_mixed_port());
|
} else {
|
||||||
|
ProxyType::None
|
||||||
let proxy_scheme = format!("http://127.0.0.1:{port}");
|
|
||||||
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 使用系统代理
|
|
||||||
else if with_proxy {
|
|
||||||
if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() {
|
|
||||||
let proxy_scheme = format!("http://{}:{}", p.host, p.port);
|
|
||||||
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = match VERSION.get() {
|
|
||||||
Some(v) => format!("clash-verge/v{}", v),
|
|
||||||
None => "clash-verge/unknown".to_string(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
builder = builder.danger_accept_invalid_certs(accept_invalid_certs);
|
// 使用网络管理器发送请求
|
||||||
builder = builder.user_agent(user_agent.unwrap_or(version));
|
let resp = match NetworkManager::global()
|
||||||
|
.get_with_interrupt(
|
||||||
let resp = builder.build()?.get(url).send().await?;
|
url,
|
||||||
|
proxy_type,
|
||||||
|
Some(timeout),
|
||||||
|
user_agent.clone(),
|
||||||
|
accept_invalid_certs,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
bail!("failed to fetch remote profile: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let status_code = resp.status();
|
let status_code = resp.status();
|
||||||
if !StatusCode::is_success(&status_code) {
|
if !StatusCode::is_success(&status_code) {
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ pub struct IVerge {
|
|||||||
/// pac script content
|
/// pac script content
|
||||||
pub pac_file_content: Option<String>,
|
pub pac_file_content: Option<String>,
|
||||||
|
|
||||||
|
/// proxy host address
|
||||||
|
pub proxy_host: Option<String>,
|
||||||
|
|
||||||
/// theme setting
|
/// theme setting
|
||||||
pub theme_setting: Option<IVergeTheme>,
|
pub theme_setting: Option<IVergeTheme>,
|
||||||
|
|
||||||
@@ -131,7 +134,7 @@ pub struct IVerge {
|
|||||||
pub test_list: Option<Vec<IVergeTestItem>>,
|
pub test_list: Option<Vec<IVergeTestItem>>,
|
||||||
|
|
||||||
/// 日志清理
|
/// 日志清理
|
||||||
/// 0: 不清理; 1: 7天; 2: 30天; 3: 90天
|
/// 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天
|
||||||
pub auto_log_clean: Option<i32>,
|
pub auto_log_clean: Option<i32>,
|
||||||
|
|
||||||
/// 是否启用随机端口
|
/// 是否启用随机端口
|
||||||
@@ -275,6 +278,7 @@ impl IVerge {
|
|||||||
enable_system_proxy: Some(false),
|
enable_system_proxy: Some(false),
|
||||||
proxy_auto_config: Some(false),
|
proxy_auto_config: Some(false),
|
||||||
pac_file_content: Some(DEFAULT_PAC.into()),
|
pac_file_content: Some(DEFAULT_PAC.into()),
|
||||||
|
proxy_host: Some("127.0.0.1".into()),
|
||||||
enable_random_port: Some(false),
|
enable_random_port: Some(false),
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
verge_redir_port: Some(7895),
|
verge_redir_port: Some(7895),
|
||||||
@@ -299,12 +303,12 @@ impl IVerge {
|
|||||||
webdav_url: None,
|
webdav_url: None,
|
||||||
webdav_username: None,
|
webdav_username: None,
|
||||||
webdav_password: None,
|
webdav_password: None,
|
||||||
enable_tray_speed: Some(true),
|
enable_tray_speed: Some(false),
|
||||||
enable_tray_icon: Some(true),
|
enable_tray_icon: Some(true),
|
||||||
enable_global_hotkey: Some(true),
|
enable_global_hotkey: Some(true),
|
||||||
enable_auto_light_weight_mode: Some(false),
|
enable_auto_light_weight_mode: Some(false),
|
||||||
auto_light_weight_minutes: Some(10),
|
auto_light_weight_minutes: Some(10),
|
||||||
enable_dns_settings: Some(true),
|
enable_dns_settings: Some(false),
|
||||||
home_cards: None,
|
home_cards: None,
|
||||||
service_state: None,
|
service_state: None,
|
||||||
..Self::default()
|
..Self::default()
|
||||||
@@ -368,7 +372,7 @@ impl IVerge {
|
|||||||
patch!(proxy_guard_duration);
|
patch!(proxy_guard_duration);
|
||||||
patch!(proxy_auto_config);
|
patch!(proxy_auto_config);
|
||||||
patch!(pac_file_content);
|
patch!(pac_file_content);
|
||||||
|
patch!(proxy_host);
|
||||||
patch!(theme_setting);
|
patch!(theme_setting);
|
||||||
patch!(web_ui_list);
|
patch!(web_ui_list);
|
||||||
patch!(clash_core);
|
patch!(clash_core);
|
||||||
@@ -452,6 +456,7 @@ pub struct IVergeResponse {
|
|||||||
pub proxy_guard_duration: Option<u64>,
|
pub proxy_guard_duration: Option<u64>,
|
||||||
pub proxy_auto_config: Option<bool>,
|
pub proxy_auto_config: Option<bool>,
|
||||||
pub pac_file_content: Option<String>,
|
pub pac_file_content: Option<String>,
|
||||||
|
pub proxy_host: Option<String>,
|
||||||
pub theme_setting: Option<IVergeTheme>,
|
pub theme_setting: Option<IVergeTheme>,
|
||||||
pub web_ui_list: Option<Vec<String>>,
|
pub web_ui_list: Option<Vec<String>>,
|
||||||
pub clash_core: Option<String>,
|
pub clash_core: Option<String>,
|
||||||
@@ -520,6 +525,7 @@ impl From<IVerge> for IVergeResponse {
|
|||||||
proxy_guard_duration: verge.proxy_guard_duration,
|
proxy_guard_duration: verge.proxy_guard_duration,
|
||||||
proxy_auto_config: verge.proxy_auto_config,
|
proxy_auto_config: verge.proxy_auto_config,
|
||||||
pac_file_content: verge.pac_file_content,
|
pac_file_content: verge.pac_file_content,
|
||||||
|
proxy_host: verge.proxy_host,
|
||||||
theme_setting: verge.theme_setting,
|
theme_setting: verge.theme_setting,
|
||||||
web_ui_list: verge.web_ui_list,
|
web_ui_list: verge.web_ui_list,
|
||||||
clash_core: verge.clash_core,
|
clash_core: verge.clash_core,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ use std::{
|
|||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use zip::write::SimpleFileOptions;
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
|
// 应用版本常量,来自 tauri.conf.json
|
||||||
|
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
const TIMEOUT_UPLOAD: u64 = 300; // 上传超时 5 分钟
|
const TIMEOUT_UPLOAD: u64 = 300; // 上传超时 5 分钟
|
||||||
const TIMEOUT_DOWNLOAD: u64 = 300; // 下载超时 5 分钟
|
const TIMEOUT_DOWNLOAD: u64 = 300; // 下载超时 5 分钟
|
||||||
const TIMEOUT_LIST: u64 = 3; // 列表超时 30 秒
|
const TIMEOUT_LIST: u64 = 3; // 列表超时 30 秒
|
||||||
@@ -105,6 +108,18 @@ impl WebDavClient {
|
|||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.danger_accept_invalid_certs(true)
|
.danger_accept_invalid_certs(true)
|
||||||
.timeout(Duration::from_secs(op.timeout()))
|
.timeout(Duration::from_secs(op.timeout()))
|
||||||
|
.user_agent(format!(
|
||||||
|
"clash-verge/{} ({} WebDAV-Client)",
|
||||||
|
APP_VERSION, OS
|
||||||
|
))
|
||||||
|
.redirect(reqwest::redirect::Policy::custom(|attempt| {
|
||||||
|
// 允许所有请求类型的重定向,包括PUT
|
||||||
|
if attempt.previous().len() >= 5 {
|
||||||
|
attempt.error("重定向次数过多")
|
||||||
|
} else {
|
||||||
|
attempt.follow()
|
||||||
|
}
|
||||||
|
}))
|
||||||
.build()
|
.build()
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
@@ -112,12 +127,13 @@ impl WebDavClient {
|
|||||||
.set_auth(reqwest_dav::Auth::Basic(config.username, config.password))
|
.set_auth(reqwest_dav::Auth::Basic(config.username, config.password))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
// 确保备份目录存在
|
// 尝试检查目录是否存在,如果不存在尝试创建,但创建失败不报错
|
||||||
let list_result = client
|
if client
|
||||||
.list(dirs::BACKUP_DIR, reqwest_dav::Depth::Number(0))
|
.list(dirs::BACKUP_DIR, reqwest_dav::Depth::Number(0))
|
||||||
.await;
|
.await
|
||||||
if list_result.is_err() {
|
.is_err()
|
||||||
client.mkcol(dirs::BACKUP_DIR).await?;
|
{
|
||||||
|
let _ = client.mkcol(dirs::BACKUP_DIR).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存客户端
|
// 缓存客户端
|
||||||
@@ -137,9 +153,41 @@ impl WebDavClient {
|
|||||||
pub async fn upload(&self, file_path: PathBuf, file_name: String) -> Result<(), Error> {
|
pub async fn upload(&self, file_path: PathBuf, file_name: String) -> Result<(), Error> {
|
||||||
let client = self.get_client(Operation::Upload).await?;
|
let client = self.get_client(Operation::Upload).await?;
|
||||||
let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name);
|
let webdav_path: String = format!("{}/{}", dirs::BACKUP_DIR, file_name);
|
||||||
let fut = client.put(webdav_path.as_ref(), fs::read(file_path)?);
|
|
||||||
timeout(Duration::from_secs(TIMEOUT_UPLOAD), fut).await??;
|
// 读取文件并上传,如果失败尝试一次重试
|
||||||
Ok(())
|
let file_content = fs::read(&file_path)?;
|
||||||
|
|
||||||
|
// 添加超时保护
|
||||||
|
let upload_result = timeout(
|
||||||
|
Duration::from_secs(TIMEOUT_UPLOAD),
|
||||||
|
client.put(&webdav_path, file_content.clone()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match upload_result {
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("Upload timed out, retrying once");
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(TIMEOUT_UPLOAD),
|
||||||
|
client.put(&webdav_path, file_content),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::warn!("Upload failed, retrying once: {}", e);
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(TIMEOUT_UPLOAD),
|
||||||
|
client.put(&webdav_path, file_content),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download(&self, filename: String, storage_path: PathBuf) -> Result<(), Error> {
|
pub async fn download(&self, filename: String, storage_path: PathBuf) -> Result<(), Error> {
|
||||||
|
|||||||
+209
-58
@@ -15,8 +15,15 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use chrono::Local;
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use std::{fmt, path::PathBuf, sync::Arc};
|
use std::{
|
||||||
|
fmt,
|
||||||
|
fs::{create_dir_all, File},
|
||||||
|
io::Write,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
use tauri_plugin_shell::{process::CommandChild, ShellExt};
|
use tauri_plugin_shell::{process::CommandChild, ShellExt};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -441,7 +448,18 @@ impl CoreManager {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or("verge-mihomo".to_string());
|
.unwrap_or("verge-mihomo".to_string());
|
||||||
let config_dir = dirs::app_home_dir()?;
|
let config_dir = dirs::app_home_dir()?;
|
||||||
let (_, child) = app_handle
|
|
||||||
|
let service_log_dir = dirs::app_home_dir()?.join("logs").join("service");
|
||||||
|
create_dir_all(&service_log_dir)?;
|
||||||
|
|
||||||
|
let now = Local::now();
|
||||||
|
let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
|
||||||
|
|
||||||
|
let log_path = service_log_dir.join(format!("sidecar_{}.log", timestamp));
|
||||||
|
|
||||||
|
let mut log_file = File::create(log_path)?;
|
||||||
|
|
||||||
|
let (mut rx, child) = app_handle
|
||||||
.shell()
|
.shell()
|
||||||
.sidecar(&clash_core)?
|
.sidecar(&clash_core)?
|
||||||
.args([
|
.args([
|
||||||
@@ -451,6 +469,23 @@ impl CoreManager {
|
|||||||
dirs::path_to_str(config_file)?,
|
dirs::path_to_str(config_file)?,
|
||||||
])
|
])
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
if let tauri_plugin_shell::process::CommandEvent::Stdout(line) = event {
|
||||||
|
if let Err(e) = writeln!(log_file, "{}", String::from_utf8_lossy(&line)) {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"[Sidecar] Failed to write stdout to file: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let pid = child.pid();
|
let pid = child.pid();
|
||||||
logging!(
|
logging!(
|
||||||
trace,
|
trace,
|
||||||
@@ -506,89 +541,205 @@ impl CoreManager {
|
|||||||
child_sidecar: Arc::new(Mutex::new(None)),
|
child_sidecar: Arc::new(Mutex::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 当服务安装失败时的回退逻辑
|
||||||
|
async fn attempt_service_init(&self) -> Result<()> {
|
||||||
|
if service::check_service_needs_reinstall().await {
|
||||||
|
logging!(info, Type::Core, true, "服务版本不匹配或状态异常,执行重装");
|
||||||
|
if let Err(e) = service::reinstall_service().await {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"服务重装失败 during attempt_service_init: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
// 如果重装成功,还需要尝试启动服务
|
||||||
|
logging!(info, Type::Core, true, "服务重装成功,尝试启动服务");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.start_core_by_service().await {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"通过服务启动核心失败 during attempt_service_init: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
// 确保 prefer_sidecar 在 start_core_by_service 失败时也被设置
|
||||||
|
let mut state = service::ServiceState::get();
|
||||||
|
if !state.prefer_sidecar {
|
||||||
|
state.prefer_sidecar = true;
|
||||||
|
state.last_error = Some(format!("通过服务启动核心失败: {}", e));
|
||||||
|
if let Err(save_err) = state.save() {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"保存ServiceState失败 (in attempt_service_init/start_core_by_service): {}",
|
||||||
|
save_err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn init(&self) -> Result<()> {
|
pub async fn init(&self) -> Result<()> {
|
||||||
logging!(trace, Type::Core, "Initializing core");
|
logging!(trace, Type::Core, "Initializing core");
|
||||||
|
|
||||||
|
let mut core_started_successfully = false;
|
||||||
|
|
||||||
if service::is_service_available().await.is_ok() {
|
if service::is_service_available().await.is_ok() {
|
||||||
logging!(info, Type::Core, true, "服务可用,直接使用服务模式");
|
logging!(
|
||||||
|
info,
|
||||||
// 检查版本是否需要重装
|
Type::Core,
|
||||||
if service::check_service_needs_reinstall().await {
|
true,
|
||||||
logging!(info, Type::Core, true, "服务版本不匹配,执行重装");
|
"服务当前可用或看似可用,尝试通过服务模式启动/重装"
|
||||||
service::reinstall_service().await?;
|
);
|
||||||
|
match self.attempt_service_init().await {
|
||||||
|
Ok(_) => {
|
||||||
|
logging!(info, Type::Core, true, "服务模式成功启动核心");
|
||||||
|
core_started_successfully = true;
|
||||||
|
}
|
||||||
|
Err(_err) => {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"服务模式启动或重装失败。将尝试Sidecar模式回退。"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.start_core_by_service().await?;
|
|
||||||
} else {
|
} else {
|
||||||
// 服务不可用,获取服务状态
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"服务初始不可用 (is_service_available 调用失败)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !core_started_successfully {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"核心未通过服务模式启动,执行Sidecar回退或首次安装逻辑"
|
||||||
|
);
|
||||||
|
|
||||||
let service_state = service::ServiceState::get();
|
let service_state = service::ServiceState::get();
|
||||||
let has_service_install_record = service_state.last_install_time > 0;
|
|
||||||
|
|
||||||
if service_state.prefer_sidecar {
|
if service_state.prefer_sidecar {
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::Core,
|
Type::Core,
|
||||||
true,
|
true,
|
||||||
"用户偏好Sidecar模式,使用Sidecar模式启动"
|
"用户偏好Sidecar模式或先前服务启动失败,使用Sidecar模式启动"
|
||||||
);
|
);
|
||||||
self.start_core_by_sidecar().await?;
|
self.start_core_by_sidecar().await?;
|
||||||
}
|
// 如果 sidecar 启动成功,我们可以认为核心初始化流程到此结束
|
||||||
// 检查是否已经有服务安装记录,如果没有,则尝试安装
|
// 后续的 Tray::global().subscribe_traffic().await 仍然会执行
|
||||||
else if !has_service_install_record {
|
} else {
|
||||||
logging!(info, Type::Core, true, "首次运行,服务不可用,尝试安装");
|
let has_service_install_record = service_state.last_install_time > 0;
|
||||||
|
if !has_service_install_record {
|
||||||
match service::install_service().await {
|
logging!(
|
||||||
Ok(_) => {
|
info,
|
||||||
logging!(info, Type::Core, true, "服务安装成功");
|
Type::Core,
|
||||||
|
true,
|
||||||
let mut new_state = service::ServiceState::default();
|
"无服务安装记录 (首次运行或状态重置),尝试安装服务"
|
||||||
new_state.record_install();
|
);
|
||||||
new_state.prefer_sidecar = false;
|
match service::install_service().await {
|
||||||
new_state.save()?;
|
Ok(_) => {
|
||||||
|
logging!(info, Type::Core, true, "服务安装成功(首次尝试)");
|
||||||
if service::is_service_available().await.is_ok() {
|
let mut new_state = service::ServiceState::default();
|
||||||
self.start_core_by_service().await?;
|
new_state.record_install();
|
||||||
logging!(info, Type::Core, true, "服务启动成功");
|
new_state.prefer_sidecar = false;
|
||||||
} else {
|
new_state.save()?;
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Core,
|
|
||||||
true,
|
|
||||||
"服务安装成功但未能连接,回退到Sidecar模式"
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if service::is_service_available().await.is_ok() {
|
||||||
|
logging!(info, Type::Core, true, "新安装的服务可用,尝试启动");
|
||||||
|
if self.start_core_by_service().await.is_ok() {
|
||||||
|
logging!(info, Type::Core, true, "新安装的服务启动成功");
|
||||||
|
} else {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"新安装的服务启动失败,回退到Sidecar模式"
|
||||||
|
);
|
||||||
|
let mut final_state = service::ServiceState::get();
|
||||||
|
final_state.prefer_sidecar = true;
|
||||||
|
final_state.last_error =
|
||||||
|
Some("Newly installed service failed to start".to_string());
|
||||||
|
final_state.save()?;
|
||||||
|
self.start_core_by_sidecar().await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"服务安装成功但未能连接/立即可用,回退到Sidecar模式"
|
||||||
|
);
|
||||||
|
let mut final_state = service::ServiceState::get();
|
||||||
|
final_state.prefer_sidecar = true;
|
||||||
|
final_state.last_error = Some(
|
||||||
|
"Newly installed service not immediately available/connectable"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
final_state.save()?;
|
||||||
|
self.start_core_by_sidecar().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
logging!(warn, Type::Core, true, "服务首次安装失败: {}", err);
|
||||||
|
let new_state = service::ServiceState {
|
||||||
|
last_error: Some(err.to_string()),
|
||||||
|
prefer_sidecar: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
new_state.save()?;
|
||||||
self.start_core_by_sidecar().await?;
|
self.start_core_by_sidecar().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
} else {
|
||||||
// 安装失败,记录错误并使用sidecar模式
|
// 有安装记录,服务未成功启动,且初始不偏好sidecar
|
||||||
logging!(warn, Type::Core, true, "服务安装失败: {}", err);
|
// 这意味着服务之前可能可用,但 attempt_service_init 失败了(并应已设置 prefer_sidecar),
|
||||||
|
// 或者服务初始不可用,无偏好,有记录。应强制使用 sidecar。
|
||||||
let new_state = service::ServiceState {
|
logging!(
|
||||||
last_error: Some(err.to_string()),
|
info,
|
||||||
prefer_sidecar: true,
|
Type::Core,
|
||||||
..Default::default()
|
true,
|
||||||
};
|
"有服务安装记录但服务不可用/未启动,强制切换到Sidecar模式"
|
||||||
new_state.save()?;
|
);
|
||||||
|
let mut final_state = service::ServiceState::get();
|
||||||
self.start_core_by_sidecar().await?;
|
if !final_state.prefer_sidecar {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
true,
|
||||||
|
"prefer_sidecar 为 false,因服务启动失败或不可用而强制设置为 true"
|
||||||
|
);
|
||||||
|
final_state.prefer_sidecar = true;
|
||||||
|
final_state.last_error =
|
||||||
|
Some(final_state.last_error.unwrap_or_else(|| {
|
||||||
|
"Service startup failed or unavailable before sidecar fallback"
|
||||||
|
.to_string()
|
||||||
|
}));
|
||||||
|
final_state.save()?;
|
||||||
}
|
}
|
||||||
|
self.start_core_by_sidecar().await?;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logging!(
|
|
||||||
info,
|
|
||||||
Type::Core,
|
|
||||||
true,
|
|
||||||
"有服务安装记录但服务不可用,使用Sidecar模式"
|
|
||||||
);
|
|
||||||
self.start_core_by_sidecar().await?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logging!(trace, Type::Core, "Initied core");
|
logging!(trace, Type::Core, "Initied core logic completed");
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
logging_error!(Type::Core, true, Tray::global().subscribe_traffic().await);
|
logging_error!(Type::Core, true, Tray::global().subscribe_traffic().await);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+379
-54
@@ -1,9 +1,37 @@
|
|||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
mpsc, Arc,
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
|
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};
|
||||||
|
|
||||||
use crate::{logging, logging_error, utils::logging::Type};
|
use crate::{logging, utils::logging::Type};
|
||||||
|
|
||||||
|
/// 不同类型的前端通知
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum FrontendEvent {
|
||||||
|
RefreshClash,
|
||||||
|
RefreshVerge,
|
||||||
|
NoticeMessage { status: String, message: String },
|
||||||
|
ProfileChanged { current_profile_id: String },
|
||||||
|
TimerUpdated { profile_index: String },
|
||||||
|
StartupCompleted,
|
||||||
|
ProfileUpdateStarted { uid: String },
|
||||||
|
ProfileUpdateCompleted { uid: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 事件发送统计和监控
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct EventStats {
|
||||||
|
total_sent: AtomicU64,
|
||||||
|
total_errors: AtomicU64,
|
||||||
|
last_error_time: RwLock<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 存储启动期间的错误消息
|
/// 存储启动期间的错误消息
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -12,30 +40,236 @@ struct ErrorMessage {
|
|||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
/// 全局前端通知系统
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct NotificationSystem {
|
||||||
|
sender: Option<mpsc::Sender<FrontendEvent>>,
|
||||||
|
worker_handle: Option<thread::JoinHandle<()>>,
|
||||||
|
is_running: bool,
|
||||||
|
stats: EventStats,
|
||||||
|
last_emit_time: RwLock<Instant>,
|
||||||
|
/// 当通知系统失败超过阈值时,进入紧急模式
|
||||||
|
emergency_mode: RwLock<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NotificationSystem {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationSystem {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sender: None,
|
||||||
|
worker_handle: None,
|
||||||
|
is_running: false,
|
||||||
|
stats: EventStats::default(),
|
||||||
|
last_emit_time: RwLock::new(Instant::now()),
|
||||||
|
emergency_mode: RwLock::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动通知处理线程
|
||||||
|
fn start(&mut self) {
|
||||||
|
if self.is_running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.sender = Some(tx);
|
||||||
|
self.is_running = true;
|
||||||
|
|
||||||
|
*self.last_emit_time.write() = Instant::now();
|
||||||
|
|
||||||
|
self.worker_handle = Some(
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("frontend-notifier".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let handle = Handle::global();
|
||||||
|
|
||||||
|
while !handle.is_exiting() {
|
||||||
|
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
|
Ok(event) => {
|
||||||
|
let system_guard = handle.notification_system.read();
|
||||||
|
if system_guard.as_ref().is_none() {
|
||||||
|
log::warn!("NotificationSystem not found in handle while processing event.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let system = system_guard.as_ref().unwrap();
|
||||||
|
|
||||||
|
let is_emergency = *system.emergency_mode.read();
|
||||||
|
|
||||||
|
if is_emergency {
|
||||||
|
if let FrontendEvent::NoticeMessage { ref status, .. } = event {
|
||||||
|
if status == "info" {
|
||||||
|
log::warn!(
|
||||||
|
"Emergency mode active, skipping info message"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(window) = handle.get_window() {
|
||||||
|
*system.last_emit_time.write() = Instant::now();
|
||||||
|
|
||||||
|
let (event_name_str, payload_result) = match event {
|
||||||
|
FrontendEvent::RefreshClash => {
|
||||||
|
("verge://refresh-clash-config", Ok(serde_json::json!("yes")))
|
||||||
|
}
|
||||||
|
FrontendEvent::RefreshVerge => {
|
||||||
|
("verge://refresh-verge-config", Ok(serde_json::json!("yes")))
|
||||||
|
}
|
||||||
|
FrontendEvent::NoticeMessage { status, message } => {
|
||||||
|
match serde_json::to_value((status, message)) {
|
||||||
|
Ok(p) => ("verge://notice-message", Ok(p)),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to serialize NoticeMessage payload: {}", e);
|
||||||
|
("verge://notice-message", Err(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FrontendEvent::ProfileChanged { current_profile_id } => {
|
||||||
|
("profile-changed", Ok(serde_json::json!(current_profile_id)))
|
||||||
|
}
|
||||||
|
FrontendEvent::TimerUpdated { profile_index } => {
|
||||||
|
("verge://timer-updated", Ok(serde_json::json!(profile_index)))
|
||||||
|
}
|
||||||
|
FrontendEvent::StartupCompleted => {
|
||||||
|
("verge://startup-completed", Ok(serde_json::json!(null)))
|
||||||
|
}
|
||||||
|
FrontendEvent::ProfileUpdateStarted { uid } => {
|
||||||
|
("profile-update-started", Ok(serde_json::json!({ "uid": uid })))
|
||||||
|
}
|
||||||
|
FrontendEvent::ProfileUpdateCompleted { uid } => {
|
||||||
|
("profile-update-completed", Ok(serde_json::json!({ "uid": uid })))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(payload) = payload_result {
|
||||||
|
match window.emit(event_name_str, payload) {
|
||||||
|
Ok(_) => {
|
||||||
|
system.stats.total_sent.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to emit event {}: {}", event_name_str, e);
|
||||||
|
system.stats.total_errors.fetch_add(1, Ordering::SeqCst);
|
||||||
|
*system.stats.last_error_time.write() = Some(Instant::now());
|
||||||
|
|
||||||
|
let errors = system.stats.total_errors.load(Ordering::SeqCst);
|
||||||
|
const EMIT_ERROR_THRESHOLD: u64 = 10;
|
||||||
|
if errors > EMIT_ERROR_THRESHOLD && !*system.emergency_mode.read() {
|
||||||
|
log::warn!(
|
||||||
|
"Reached {} emit errors, entering emergency mode",
|
||||||
|
EMIT_ERROR_THRESHOLD
|
||||||
|
);
|
||||||
|
*system.emergency_mode.write() = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
system.stats.total_errors.fetch_add(1, Ordering::SeqCst);
|
||||||
|
*system.stats.last_error_time.write() = Some(Instant::now());
|
||||||
|
log::warn!("Skipped emitting event due to payload serialization error for {}", event_name_str);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::warn!("No window found, skipping event emit.");
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
log::info!(
|
||||||
|
"Notification channel disconnected, exiting worker thread"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Notification worker thread exiting");
|
||||||
|
})
|
||||||
|
.expect("Failed to start notification worker thread"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送事件到队列
|
||||||
|
fn send_event(&self, event: FrontendEvent) -> bool {
|
||||||
|
if *self.emergency_mode.read() {
|
||||||
|
if let FrontendEvent::NoticeMessage { ref status, .. } = event {
|
||||||
|
if status == "info" {
|
||||||
|
log::info!("Skipping info message in emergency mode");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sender) = &self.sender {
|
||||||
|
match sender.send(event) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to send event to notification queue: {:?}", e);
|
||||||
|
self.stats.total_errors.fetch_add(1, Ordering::SeqCst);
|
||||||
|
*self.stats.last_error_time.write() = Some(Instant::now());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::warn!("Notification system not started, can't send event");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&mut self) {
|
||||||
|
self.is_running = false;
|
||||||
|
self.sender = None;
|
||||||
|
|
||||||
|
if let Some(handle) = self.worker_handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct Handle {
|
pub struct Handle {
|
||||||
pub app_handle: Arc<RwLock<Option<AppHandle>>>,
|
pub app_handle: Arc<RwLock<Option<AppHandle>>>,
|
||||||
pub is_exiting: Arc<RwLock<bool>>,
|
pub is_exiting: Arc<RwLock<bool>>,
|
||||||
/// 存储启动过程中产生的错误消息队列
|
|
||||||
startup_errors: Arc<RwLock<Vec<ErrorMessage>>>,
|
startup_errors: Arc<RwLock<Vec<ErrorMessage>>>,
|
||||||
startup_completed: Arc<RwLock<bool>>,
|
startup_completed: Arc<RwLock<bool>>,
|
||||||
|
notification_system: Arc<RwLock<Option<NotificationSystem>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Handle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
app_handle: Arc::new(RwLock::new(None)),
|
||||||
|
is_exiting: Arc::new(RwLock::new(false)),
|
||||||
|
startup_errors: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
startup_completed: Arc::new(RwLock::new(false)),
|
||||||
|
notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Handle {
|
impl Handle {
|
||||||
pub fn global() -> &'static Handle {
|
pub fn global() -> &'static Handle {
|
||||||
static HANDLE: OnceCell<Handle> = OnceCell::new();
|
static HANDLE: OnceCell<Handle> = OnceCell::new();
|
||||||
|
HANDLE.get_or_init(Handle::default)
|
||||||
HANDLE.get_or_init(|| Handle {
|
|
||||||
app_handle: Arc::new(RwLock::new(None)),
|
|
||||||
is_exiting: Arc::new(RwLock::new(false)),
|
|
||||||
startup_errors: Arc::new(RwLock::new(Vec::new())),
|
|
||||||
startup_completed: Arc::new(RwLock::new(false)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(&self, app_handle: &AppHandle) {
|
pub fn init(&self, app_handle: &AppHandle) {
|
||||||
let mut handle = self.app_handle.write();
|
{
|
||||||
*handle = Some(app_handle.clone());
|
let mut handle = self.app_handle.write();
|
||||||
|
*handle = Some(app_handle.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut system_opt = self.notification_system.write();
|
||||||
|
if let Some(system) = system_opt.as_mut() {
|
||||||
|
system.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn app_handle(&self) -> Option<AppHandle> {
|
pub fn app_handle(&self) -> Option<AppHandle> {
|
||||||
@@ -43,7 +277,7 @@ impl Handle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_window(&self) -> Option<WebviewWindow> {
|
pub fn get_window(&self) -> Option<WebviewWindow> {
|
||||||
let app_handle = self.app_handle().unwrap();
|
let app_handle = self.app_handle()?;
|
||||||
let window: Option<WebviewWindow> = app_handle.get_webview_window("main");
|
let window: Option<WebviewWindow> = app_handle.get_webview_window("main");
|
||||||
if window.is_none() {
|
if window.is_none() {
|
||||||
log::debug!(target:"app", "main window not found");
|
log::debug!(target:"app", "main window not found");
|
||||||
@@ -52,43 +286,113 @@ impl Handle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_clash() {
|
pub fn refresh_clash() {
|
||||||
if let Some(window) = Self::global().get_window() {
|
let handle = Self::global();
|
||||||
logging_error!(
|
if handle.is_exiting() {
|
||||||
Type::Frontend,
|
return;
|
||||||
true,
|
}
|
||||||
window.emit("verge://refresh-clash-config", "yes")
|
|
||||||
);
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::RefreshClash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_verge() {
|
pub fn refresh_verge() {
|
||||||
if let Some(window) = Self::global().get_window() {
|
let handle = Self::global();
|
||||||
logging_error!(
|
if handle.is_exiting() {
|
||||||
Type::Frontend,
|
return;
|
||||||
true,
|
}
|
||||||
window.emit("verge://refresh-verge-config", "yes")
|
|
||||||
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::RefreshVerge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notify_profile_changed(profile_id: String) {
|
||||||
|
let handle = Self::global();
|
||||||
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::ProfileChanged {
|
||||||
|
current_profile_id: profile_id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Notification system not initialized when trying to send ProfileChanged event."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
pub fn notify_timer_updated(profile_index: String) {
|
||||||
pub fn refresh_profiles() {
|
let handle = Self::global();
|
||||||
if let Some(window) = Self::global().get_window() {
|
if handle.is_exiting() {
|
||||||
logging_error!(
|
return;
|
||||||
Type::Frontend,
|
}
|
||||||
true,
|
|
||||||
window.emit("verge://refresh-profiles-config", "yes")
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::TimerUpdated { profile_index });
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Notification system not initialized when trying to send TimerUpdated event."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通知前端显示消息,如果在启动过程中,则将消息存入启动错误队列
|
pub fn notify_startup_completed() {
|
||||||
|
let handle = Self::global();
|
||||||
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::StartupCompleted);
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Notification system not initialized when trying to send StartupCompleted event."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notify_profile_update_started(uid: String) {
|
||||||
|
let handle = Self::global();
|
||||||
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::ProfileUpdateStarted { uid });
|
||||||
|
} else {
|
||||||
|
log::warn!("Notification system not initialized when trying to send ProfileUpdateStarted event.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notify_profile_update_completed(uid: String) {
|
||||||
|
let handle = Self::global();
|
||||||
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let system_opt = handle.notification_system.read();
|
||||||
|
if let Some(system) = system_opt.as_ref() {
|
||||||
|
system.send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
||||||
|
} else {
|
||||||
|
log::warn!("Notification system not initialized when trying to send ProfileUpdateCompleted event.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通知前端显示消息队列
|
||||||
pub fn notice_message<S: Into<String>, M: Into<String>>(status: S, msg: M) {
|
pub fn notice_message<S: Into<String>, M: Into<String>>(status: S, msg: M) {
|
||||||
let handle = Self::global();
|
let handle = Self::global();
|
||||||
let status_str = status.into();
|
let status_str = status.into();
|
||||||
let msg_str = msg.into();
|
let msg_str = msg.into();
|
||||||
|
|
||||||
// 检查是否正在启动过程中
|
|
||||||
if !*handle.startup_completed.read() {
|
if !*handle.startup_completed.read() {
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
@@ -99,7 +403,6 @@ impl Handle {
|
|||||||
msg_str
|
msg_str
|
||||||
);
|
);
|
||||||
|
|
||||||
// 将消息添加到启动错误队列
|
|
||||||
let mut errors = handle.startup_errors.write();
|
let mut errors = handle.startup_errors.write();
|
||||||
errors.push(ErrorMessage {
|
errors.push(ErrorMessage {
|
||||||
status: status_str,
|
status: status_str,
|
||||||
@@ -108,17 +411,19 @@ impl Handle {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已经完成启动,直接发送消息
|
if handle.is_exiting() {
|
||||||
if let Some(window) = handle.get_window() {
|
return;
|
||||||
logging_error!(
|
}
|
||||||
Type::Frontend,
|
|
||||||
true,
|
let system_opt = handle.notification_system.read();
|
||||||
window.emit("verge://notice-message", (status_str, msg_str))
|
if let Some(system) = system_opt.as_ref() {
|
||||||
);
|
system.send_event(FrontendEvent::NoticeMessage {
|
||||||
|
status: status_str,
|
||||||
|
message: msg_str,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 标记启动已完成,并发送所有启动阶段累积的错误消息
|
|
||||||
pub fn mark_startup_completed(&self) {
|
pub fn mark_startup_completed(&self) {
|
||||||
{
|
{
|
||||||
let mut completed = self.startup_completed.write();
|
let mut completed = self.startup_completed.write();
|
||||||
@@ -147,27 +452,47 @@ impl Handle {
|
|||||||
errors.len()
|
errors.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 等待2秒以确保前端已完全加载,延迟发送错误通知
|
// 启动单独线程处理启动错误,避免阻塞主线程
|
||||||
if let Some(window) = self.get_window() {
|
let thread_result = thread::Builder::new()
|
||||||
let window_clone = window.clone();
|
.name("startup-errors-sender".into())
|
||||||
let errors_clone = errors.clone();
|
.spawn(move || {
|
||||||
|
thread::sleep(Duration::from_secs(2));
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
let handle = Handle::global();
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
if handle.is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for error in errors_clone {
|
let system_opt = handle.notification_system.read();
|
||||||
let _ =
|
if let Some(system) = system_opt.as_ref() {
|
||||||
window_clone.emit("verge://notice-message", (error.status, error.message));
|
for error in errors {
|
||||||
// 每条消息之间间隔500ms,避免消息堆积
|
if handle.is_exiting() {
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
system.send_event(FrontendEvent::NoticeMessage {
|
||||||
|
status: error.status,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Err(e) = thread_result {
|
||||||
|
log::error!("Failed to spawn startup errors thread: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_is_exiting(&self) {
|
pub fn set_is_exiting(&self) {
|
||||||
let mut is_exiting = self.is_exiting.write();
|
let mut is_exiting = self.is_exiting.write();
|
||||||
*is_exiting = true;
|
*is_exiting = true;
|
||||||
|
|
||||||
|
let mut system_opt = self.notification_system.write();
|
||||||
|
if let Some(system) = system_opt.as_mut() {
|
||||||
|
system.shutdown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_exiting(&self) -> bool {
|
pub fn is_exiting(&self) -> bool {
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ use crate::{
|
|||||||
core::handle,
|
core::handle,
|
||||||
feat, logging, logging_error,
|
feat, logging, logging_error,
|
||||||
module::lightweight::entry_lightweight_mode,
|
module::lightweight::entry_lightweight_mode,
|
||||||
|
process::AsyncHandler,
|
||||||
utils::{logging::Type, resolve},
|
utils::{logging::Type, resolve},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
use tauri::{async_runtime, Manager};
|
use tauri::Manager;
|
||||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState};
|
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, ShortcutState};
|
||||||
|
|
||||||
pub struct Hotkey {
|
pub struct Hotkey {
|
||||||
@@ -33,7 +34,7 @@ impl Hotkey {
|
|||||||
debug,
|
debug,
|
||||||
Type::Hotkey,
|
Type::Hotkey,
|
||||||
true,
|
true,
|
||||||
"Initializing hotkeys with enable: {}",
|
"Initializing global hotkeys: {}",
|
||||||
enable_global_hotkey
|
enable_global_hotkey
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,7 +81,6 @@ impl Hotkey {
|
|||||||
logging!(
|
logging!(
|
||||||
debug,
|
debug,
|
||||||
Type::Hotkey,
|
Type::Hotkey,
|
||||||
true,
|
|
||||||
"Successfully registered hotkey {} -> {}",
|
"Successfully registered hotkey {} -> {}",
|
||||||
key,
|
key,
|
||||||
func
|
func
|
||||||
@@ -103,7 +103,7 @@ impl Hotkey {
|
|||||||
}
|
}
|
||||||
self.current.lock().clone_from(hotkeys);
|
self.current.lock().clone_from(hotkeys);
|
||||||
} else {
|
} else {
|
||||||
logging!(debug, Type::Hotkey, true, "No hotkeys configured");
|
logging!(debug, Type::Hotkey, "No hotkeys configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -123,7 +123,6 @@ impl Hotkey {
|
|||||||
logging!(
|
logging!(
|
||||||
debug,
|
debug,
|
||||||
Type::Hotkey,
|
Type::Hotkey,
|
||||||
true,
|
|
||||||
"Attempting to register hotkey: {} for function: {}",
|
"Attempting to register hotkey: {} for function: {}",
|
||||||
hotkey,
|
hotkey,
|
||||||
func
|
func
|
||||||
@@ -133,7 +132,6 @@ impl Hotkey {
|
|||||||
logging!(
|
logging!(
|
||||||
debug,
|
debug,
|
||||||
Type::Hotkey,
|
Type::Hotkey,
|
||||||
true,
|
|
||||||
"Hotkey {} was already registered, unregistering first",
|
"Hotkey {} was already registered, unregistering first",
|
||||||
hotkey
|
hotkey
|
||||||
);
|
);
|
||||||
@@ -145,7 +143,6 @@ impl Hotkey {
|
|||||||
logging!(
|
logging!(
|
||||||
debug,
|
debug,
|
||||||
Type::Hotkey,
|
Type::Hotkey,
|
||||||
true,
|
|
||||||
"Registering open_or_close_dashboard function"
|
"Registering open_or_close_dashboard function"
|
||||||
);
|
);
|
||||||
|| {
|
|| {
|
||||||
@@ -157,23 +154,18 @@ impl Hotkey {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 使用 spawn_blocking 来确保在正确的线程上执行
|
// 使用 spawn_blocking 来确保在正确的线程上执行
|
||||||
async_runtime::spawn_blocking(|| {
|
AsyncHandler::spawn_blocking(|| {
|
||||||
logging!(
|
logging!(debug, Type::Hotkey, "Toggle dashboard window visibility");
|
||||||
debug,
|
|
||||||
Type::Hotkey,
|
|
||||||
true,
|
|
||||||
"Toggle dashboard window visibility"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 检查窗口是否存在
|
// 检查窗口是否存在
|
||||||
if let Some(window) = handle::Handle::global().get_window() {
|
if let Some(window) = handle::Handle::global().get_window() {
|
||||||
// 如果窗口可见,则隐藏它
|
// 如果窗口可见,则隐藏它
|
||||||
if window.is_visible().unwrap_or(false) {
|
if window.is_visible().unwrap_or(false) {
|
||||||
logging!(info, Type::Window, true, "Window is visible, hiding it");
|
logging!(info, Type::Window, "Window is visible, hiding it");
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
} else {
|
} else {
|
||||||
// 如果窗口不可见,则显示它
|
// 如果窗口不可见,则显示它
|
||||||
logging!(info, Type::Window, true, "Window is hidden, showing it");
|
logging!(info, Type::Window, "Window is hidden, showing it");
|
||||||
if window.is_minimized().unwrap_or(false) {
|
if window.is_minimized().unwrap_or(false) {
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
}
|
}
|
||||||
@@ -185,7 +177,6 @@ impl Hotkey {
|
|||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::Window,
|
Type::Window,
|
||||||
true,
|
|
||||||
"Window does not exist, creating a new one"
|
"Window does not exist, creating a new one"
|
||||||
);
|
);
|
||||||
resolve::create_window(true);
|
resolve::create_window(true);
|
||||||
@@ -195,7 +186,6 @@ impl Hotkey {
|
|||||||
logging!(
|
logging!(
|
||||||
debug,
|
debug,
|
||||||
Type::Hotkey,
|
Type::Hotkey,
|
||||||
true,
|
|
||||||
"=== Hotkey Dashboard Window Operation End ==="
|
"=== Hotkey Dashboard Window Operation End ==="
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,12 +196,12 @@ impl Hotkey {
|
|||||||
"toggle_system_proxy" => || feat::toggle_system_proxy(),
|
"toggle_system_proxy" => || feat::toggle_system_proxy(),
|
||||||
"toggle_tun_mode" => || feat::toggle_tun_mode(None),
|
"toggle_tun_mode" => || feat::toggle_tun_mode(None),
|
||||||
"entry_lightweight_mode" => || entry_lightweight_mode(),
|
"entry_lightweight_mode" => || entry_lightweight_mode(),
|
||||||
"quit" => || feat::quit(Some(0)),
|
"quit" => || feat::quit(),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
"hide" => || feat::hide(),
|
"hide" => || feat::hide(),
|
||||||
|
|
||||||
_ => {
|
_ => {
|
||||||
logging!(error, Type::Hotkey, true, "Invalid function: {}", func);
|
logging!(error, Type::Hotkey, "Invalid function: {}", func);
|
||||||
bail!("invalid function \"{func}\"");
|
bail!("invalid function \"{func}\"");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -220,18 +210,18 @@ impl Hotkey {
|
|||||||
|
|
||||||
let _ = manager.on_shortcut(hotkey, move |app_handle, hotkey, event| {
|
let _ = manager.on_shortcut(hotkey, move |app_handle, hotkey, event| {
|
||||||
if event.state == ShortcutState::Pressed {
|
if event.state == ShortcutState::Pressed {
|
||||||
logging!(debug, Type::Hotkey, true, "Hotkey pressed: {:?}", hotkey);
|
logging!(debug, Type::Hotkey, "Hotkey pressed: {:?}", hotkey);
|
||||||
|
|
||||||
if hotkey.key == Code::KeyQ && is_quit {
|
if hotkey.key == Code::KeyQ && is_quit {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
if window.is_focused().unwrap_or(false) {
|
if window.is_focused().unwrap_or(false) {
|
||||||
logging!(debug, Type::Hotkey, true, "Executing quit function");
|
logging!(debug, Type::Hotkey, "Executing quit function");
|
||||||
f();
|
f();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 直接执行函数,不做任何状态检查
|
// 直接执行函数,不做任何状态检查
|
||||||
logging!(debug, Type::Hotkey, true, "Executing function directly");
|
logging!(debug, Type::Hotkey, "Executing function directly");
|
||||||
|
|
||||||
// 获取全局热键状态
|
// 获取全局热键状态
|
||||||
let is_enable_global_hotkey = Config::verge()
|
let is_enable_global_hotkey = Config::verge()
|
||||||
@@ -257,7 +247,6 @@ impl Hotkey {
|
|||||||
logging!(
|
logging!(
|
||||||
debug,
|
debug,
|
||||||
Type::Hotkey,
|
Type::Hotkey,
|
||||||
true,
|
|
||||||
"Successfully registered hotkey {} for {}",
|
"Successfully registered hotkey {} for {}",
|
||||||
hotkey,
|
hotkey,
|
||||||
func
|
func
|
||||||
@@ -269,7 +258,7 @@ impl Hotkey {
|
|||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||||
let manager = app_handle.global_shortcut();
|
let manager = app_handle.global_shortcut();
|
||||||
manager.unregister(hotkey)?;
|
manager.unregister(hotkey)?;
|
||||||
logging!(debug, Type::Hotkey, true, "Unregister hotkey {}", hotkey);
|
logging!(debug, Type::Hotkey, "Unregister hotkey {}", hotkey);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +274,7 @@ impl Hotkey {
|
|||||||
});
|
});
|
||||||
|
|
||||||
add.iter().for_each(|(key, func)| {
|
add.iter().for_each(|(key, func)| {
|
||||||
logging_error!(Type::Hotkey, true, self.register(key, func));
|
logging_error!(Type::Hotkey, self.register(key, func));
|
||||||
});
|
});
|
||||||
|
|
||||||
*current = new_hotkeys;
|
*current = new_hotkeys;
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ mod core;
|
|||||||
pub mod handle;
|
pub mod handle;
|
||||||
pub mod hotkey;
|
pub mod hotkey;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod service_ipc;
|
||||||
pub mod sysopt;
|
pub mod sysopt;
|
||||||
pub mod timer;
|
pub mod timer;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
pub mod win_uwp;
|
pub mod win_uwp;
|
||||||
|
|
||||||
pub use self::core::*;
|
pub use self::{core::*, timer::Timer};
|
||||||
|
|||||||
+534
-131
@@ -1,23 +1,19 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
|
core::service_ipc::{send_ipc_request, IpcCommand},
|
||||||
logging,
|
logging,
|
||||||
utils::{dirs, logging::Type},
|
utils::{dirs, logging::Type},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
|
||||||
env::current_exe,
|
env::current_exe,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
process::Command as StdCommand,
|
process::Command as StdCommand,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
use tokio::time::Duration;
|
|
||||||
|
|
||||||
// Windows only
|
const REQUIRED_SERVICE_VERSION: &str = "1.1.0"; // 定义所需的服务版本号
|
||||||
|
|
||||||
const SERVICE_URL: &str = "http://127.0.0.1:33211";
|
|
||||||
const REQUIRED_SERVICE_VERSION: &str = "1.0.5"; // 定义所需的服务版本号
|
|
||||||
|
|
||||||
// 限制重装时间和次数的常量
|
// 限制重装时间和次数的常量
|
||||||
const REINSTALL_COOLDOWN_SECS: u64 = 300; // 5分钟冷却期
|
const REINSTALL_COOLDOWN_SECS: u64 = 300; // 5分钟冷却期
|
||||||
@@ -49,7 +45,8 @@ impl ServiceState {
|
|||||||
latest.service_state = Some(self.clone());
|
latest.service_state = Some(self.clone());
|
||||||
*config.draft() = latest;
|
*config.draft() = latest;
|
||||||
config.apply();
|
config.apply();
|
||||||
Config::verge().latest().save_file()
|
let result = config.latest().save_file();
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新安装信息
|
// 更新安装信息
|
||||||
@@ -91,6 +88,7 @@ impl ServiceState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保留核心数据结构,但将HTTP特定的结构体合并为通用结构体
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct ResponseBody {
|
pub struct ResponseBody {
|
||||||
pub core_type: Option<String>,
|
pub core_type: Option<String>,
|
||||||
@@ -105,6 +103,7 @@ pub struct VersionResponse {
|
|||||||
pub version: String,
|
pub version: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保留通用的响应结构体,用于IPC通信后的数据解析
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct JsonResponse {
|
pub struct JsonResponse {
|
||||||
pub code: u64,
|
pub code: u64,
|
||||||
@@ -112,13 +111,6 @@ pub struct JsonResponse {
|
|||||||
pub data: Option<ResponseBody>,
|
pub data: Option<ResponseBody>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
||||||
pub struct VersionJsonResponse {
|
|
||||||
pub code: u64,
|
|
||||||
pub msg: String,
|
|
||||||
pub data: Option<VersionResponse>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub async fn uninstall_service() -> Result<()> {
|
pub async fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, true, "uninstall service");
|
logging!(info, Type::Service, true, "uninstall service");
|
||||||
@@ -228,6 +220,7 @@ pub async fn reinstall_service() -> Result<()> {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error = format!("failed to install service: {}", err);
|
let error = format!("failed to install service: {}", err);
|
||||||
service_state.last_error = Some(error.clone());
|
service_state.last_error = Some(error.clone());
|
||||||
|
service_state.prefer_sidecar = true;
|
||||||
service_state.save()?;
|
service_state.save()?;
|
||||||
bail!(error)
|
bail!(error)
|
||||||
}
|
}
|
||||||
@@ -355,6 +348,7 @@ pub async fn reinstall_service() -> Result<()> {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error = format!("failed to install service: {}", err);
|
let error = format!("failed to install service: {}", err);
|
||||||
service_state.last_error = Some(error.clone());
|
service_state.last_error = Some(error.clone());
|
||||||
|
service_state.prefer_sidecar = true;
|
||||||
service_state.save()?;
|
service_state.save()?;
|
||||||
bail!(error)
|
bail!(error)
|
||||||
}
|
}
|
||||||
@@ -381,7 +375,7 @@ pub async fn uninstall_service() -> Result<()> {
|
|||||||
r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#
|
r#"do shell script "sudo '{uninstall_shell}'" with administrator privileges with prompt "{prompt}""#
|
||||||
);
|
);
|
||||||
|
|
||||||
logging!(debug, Type::Service, true, "uninstall command: {}", command);
|
// logging!(debug, Type::Service, true, "uninstall command: {}", command);
|
||||||
|
|
||||||
let status = StdCommand::new("osascript")
|
let status = StdCommand::new("osascript")
|
||||||
.args(vec!["-e", &command])
|
.args(vec!["-e", &command])
|
||||||
@@ -417,7 +411,7 @@ pub async fn install_service() -> Result<()> {
|
|||||||
r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#
|
r#"do shell script "sudo '{install_shell}'" with administrator privileges with prompt "{prompt}""#
|
||||||
);
|
);
|
||||||
|
|
||||||
logging!(debug, Type::Service, true, "install command: {}", command);
|
// logging!(debug, Type::Service, true, "install command: {}", command);
|
||||||
|
|
||||||
let status = StdCommand::new("osascript")
|
let status = StdCommand::new("osascript")
|
||||||
.args(vec!["-e", &command])
|
.args(vec!["-e", &command])
|
||||||
@@ -474,101 +468,279 @@ pub async fn reinstall_service() -> Result<()> {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error = format!("failed to install service: {}", err);
|
let error = format!("failed to install service: {}", err);
|
||||||
service_state.last_error = Some(error.clone());
|
service_state.last_error = Some(error.clone());
|
||||||
|
service_state.prefer_sidecar = true;
|
||||||
service_state.save()?;
|
service_state.save()?;
|
||||||
bail!(error)
|
bail!(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// check the windows service status
|
/// 检查服务状态 - 使用IPC通信
|
||||||
pub async fn check_service() -> Result<JsonResponse> {
|
pub async fn check_ipc_service_status() -> Result<JsonResponse> {
|
||||||
let url = format!("{SERVICE_URL}/get_clash");
|
logging!(info, Type::Service, true, "开始检查服务状态 (IPC)");
|
||||||
let response = reqwest::ClientBuilder::new()
|
|
||||||
.no_proxy()
|
|
||||||
.timeout(Duration::from_secs(3))
|
|
||||||
.build()?
|
|
||||||
.get(url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("failed to connect to the Clash Verge Service")?
|
|
||||||
.json::<JsonResponse>()
|
|
||||||
.await
|
|
||||||
.context("failed to parse the Clash Verge Service response")?;
|
|
||||||
|
|
||||||
Ok(response)
|
// 使用IPC通信
|
||||||
}
|
let payload = serde_json::json!({});
|
||||||
|
// logging!(debug, Type::Service, true, "发送GetClash请求");
|
||||||
|
|
||||||
/// check the service version
|
match send_ipc_request(IpcCommand::GetClash, payload).await {
|
||||||
pub async fn check_service_version() -> Result<String> {
|
Ok(response) => {
|
||||||
let url = format!("{SERVICE_URL}/version");
|
/* logging!(
|
||||||
let response = reqwest::ClientBuilder::new()
|
debug,
|
||||||
.no_proxy()
|
Type::Service,
|
||||||
.timeout(Duration::from_secs(3))
|
true,
|
||||||
.build()?
|
"收到GetClash响应: success={}, error={:?}",
|
||||||
.get(url)
|
response.success,
|
||||||
.send()
|
response.error
|
||||||
.await
|
); */
|
||||||
.context("failed to connect to the Clash Verge Service")?
|
|
||||||
.json::<VersionJsonResponse>()
|
|
||||||
.await
|
|
||||||
.context("failed to parse the Clash Verge Service version response")?;
|
|
||||||
|
|
||||||
match response.data {
|
if !response.success {
|
||||||
Some(data) => Ok(data.version),
|
let err_msg = response.error.unwrap_or_else(|| "未知服务错误".to_string());
|
||||||
None => bail!("service version not found in response"),
|
logging!(error, Type::Service, true, "服务响应错误: {}", err_msg);
|
||||||
|
bail!(err_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
match response.data {
|
||||||
|
Some(data) => {
|
||||||
|
// 检查嵌套结构
|
||||||
|
if let (Some(code), Some(msg)) = (data.get("code"), data.get("msg")) {
|
||||||
|
let code_value = code.as_u64().unwrap_or(0);
|
||||||
|
let msg_value = msg.as_str().unwrap_or("ok").to_string();
|
||||||
|
|
||||||
|
// 提取嵌套的data字段并解析为ResponseBody
|
||||||
|
let response_body = if let Some(nested_data) = data.get("data") {
|
||||||
|
match serde_json::from_value::<ResponseBody>(nested_data.clone()) {
|
||||||
|
Ok(body) => Some(body),
|
||||||
|
Err(e) => {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"解析嵌套的ResponseBody失败: {}; 尝试其他方式",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let json_response = JsonResponse {
|
||||||
|
code: code_value,
|
||||||
|
msg: msg_value,
|
||||||
|
data: response_body,
|
||||||
|
};
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"服务检测成功: code={}, msg={}, data存在={}",
|
||||||
|
json_response.code,
|
||||||
|
json_response.msg,
|
||||||
|
json_response.data.is_some()
|
||||||
|
);
|
||||||
|
Ok(json_response)
|
||||||
|
} else {
|
||||||
|
// 尝试直接解析
|
||||||
|
match serde_json::from_value::<JsonResponse>(data.clone()) {
|
||||||
|
Ok(json_response) => {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"服务检测成功: code={}, msg={}",
|
||||||
|
json_response.code,
|
||||||
|
json_response.msg
|
||||||
|
);
|
||||||
|
Ok(json_response)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"解析服务响应失败: {}; 原始数据: {:?}",
|
||||||
|
e,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
bail!("无法解析服务响应数据: {}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
logging!(error, Type::Service, true, "服务响应中没有数据");
|
||||||
|
bail!("服务响应中没有数据")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "IPC通信失败: {}", e);
|
||||||
|
bail!("无法连接到Clash Verge Service: {}", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// check if service needs to be reinstalled
|
/// 检查服务版本 - 使用IPC通信
|
||||||
|
pub async fn check_service_version() -> Result<String> {
|
||||||
|
logging!(info, Type::Service, true, "开始检查服务版本 (IPC)");
|
||||||
|
|
||||||
|
let payload = serde_json::json!({});
|
||||||
|
// logging!(debug, Type::Service, true, "发送GetVersion请求");
|
||||||
|
|
||||||
|
match send_ipc_request(IpcCommand::GetVersion, payload).await {
|
||||||
|
Ok(response) => {
|
||||||
|
/* logging!(
|
||||||
|
debug,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"收到GetVersion响应: success={}, error={:?}",
|
||||||
|
response.success,
|
||||||
|
response.error
|
||||||
|
); */
|
||||||
|
|
||||||
|
if !response.success {
|
||||||
|
let err_msg = response
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "获取服务版本失败".to_string());
|
||||||
|
logging!(error, Type::Service, true, "获取版本错误: {}", err_msg);
|
||||||
|
bail!(err_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
match response.data {
|
||||||
|
Some(data) => {
|
||||||
|
if let Some(nested_data) = data.get("data") {
|
||||||
|
if let Some(version) = nested_data.get("version") {
|
||||||
|
if let Some(version_str) = version.as_str() {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"获取到服务版本: {}",
|
||||||
|
version_str
|
||||||
|
);
|
||||||
|
return Ok(version_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"嵌套数据中没有version字段: {:?}",
|
||||||
|
nested_data
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 兼容旧格式
|
||||||
|
match serde_json::from_value::<VersionResponse>(data.clone()) {
|
||||||
|
Ok(version_response) => {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"获取到服务版本: {}",
|
||||||
|
version_response.version
|
||||||
|
);
|
||||||
|
return Ok(version_response.version);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"解析版本响应失败: {}; 原始数据: {:?}",
|
||||||
|
e,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
bail!("无法解析服务版本数据: {}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("响应中未找到有效的版本信息")
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
logging!(error, Type::Service, true, "版本响应中没有数据");
|
||||||
|
bail!("服务版本响应中没有数据")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "IPC通信失败: {}", e);
|
||||||
|
bail!("无法连接到Clash Verge Service: {}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查服务是否需要重装
|
||||||
pub async fn check_service_needs_reinstall() -> bool {
|
pub async fn check_service_needs_reinstall() -> bool {
|
||||||
// 获取当前服务状态
|
logging!(info, Type::Service, true, "开始检查服务是否需要重装");
|
||||||
|
|
||||||
let service_state = ServiceState::get();
|
let service_state = ServiceState::get();
|
||||||
|
|
||||||
// 首先检查是否在冷却期或超过重装次数限制
|
|
||||||
if !service_state.can_reinstall() {
|
if !service_state.can_reinstall() {
|
||||||
log::info!(target: "app", "service reinstall check: in cooldown period or max attempts reached");
|
log::info!(target: "app", "服务重装检查: 处于冷却期或已达最大尝试次数");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 然后才检查版本和可用性
|
// 检查版本和可用性
|
||||||
match check_service_version().await {
|
match check_service_version().await {
|
||||||
Ok(version) => {
|
Ok(version) => {
|
||||||
// 打印更详细的日志,方便排查问题
|
|
||||||
log::info!(target: "app", "服务版本检测:当前={}, 要求={}", version, REQUIRED_SERVICE_VERSION);
|
log::info!(target: "app", "服务版本检测:当前={}, 要求={}", version, REQUIRED_SERVICE_VERSION);
|
||||||
|
/* logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"服务版本检测:当前={}, 要求={}",
|
||||||
|
version,
|
||||||
|
REQUIRED_SERVICE_VERSION
|
||||||
|
); */
|
||||||
|
|
||||||
let needs_reinstall = version != REQUIRED_SERVICE_VERSION;
|
let needs_reinstall = version != REQUIRED_SERVICE_VERSION;
|
||||||
if needs_reinstall {
|
if needs_reinstall {
|
||||||
log::warn!(target: "app", "发现服务版本不匹配,需要重装! 当前={}, 要求={}",
|
log::warn!(target: "app", "发现服务版本不匹配,需要重装! 当前={}, 要求={}",
|
||||||
version, REQUIRED_SERVICE_VERSION);
|
version, REQUIRED_SERVICE_VERSION);
|
||||||
|
logging!(warn, Type::Service, true, "服务版本不匹配,需要重装");
|
||||||
|
|
||||||
// 打印版本字符串的原始字节,确认没有隐藏字符
|
// log::debug!(target: "app", "当前版本字节: {:?}", version.as_bytes());
|
||||||
log::debug!(target: "app", "当前版本字节: {:?}", version.as_bytes());
|
// log::debug!(target: "app", "要求版本字节: {:?}", REQUIRED_SERVICE_VERSION.as_bytes());
|
||||||
log::debug!(target: "app", "要求版本字节: {:?}", REQUIRED_SERVICE_VERSION.as_bytes());
|
|
||||||
} else {
|
} else {
|
||||||
log::info!(target: "app", "服务版本匹配,无需重装");
|
log::info!(target: "app", "服务版本匹配,无需重装");
|
||||||
|
// logging!(info, Type::Service, true, "服务版本匹配,无需重装");
|
||||||
}
|
}
|
||||||
|
|
||||||
needs_reinstall
|
needs_reinstall
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// 检查服务是否可用,如果可用但版本检查失败,可能只是版本API有问题
|
logging!(error, Type::Service, true, "检查服务版本失败: {}", err);
|
||||||
match is_service_running().await {
|
|
||||||
Ok(true) => {
|
// 检查服务是否可用
|
||||||
log::info!(target: "app", "service is running but version check failed: {}", err);
|
match is_service_available().await {
|
||||||
false // 服务在运行,不需要重装
|
Ok(()) => {
|
||||||
|
log::info!(target: "app", "服务正在运行但版本检查失败: {}", err);
|
||||||
|
/* logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"服务正在运行但版本检查失败: {}",
|
||||||
|
err
|
||||||
|
); */
|
||||||
|
false
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
log::info!(target: "app", "service is not running or unavailable");
|
log::info!(target: "app", "服务不可用或未运行,需要重装");
|
||||||
true // 服务不可用,需要重装
|
// logging!(info, Type::Service, true, "服务不可用或未运行,需要重装");
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 尝试使用现有服务启动核心,不进行重装
|
/// 尝试使用服务启动core
|
||||||
pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result<()> {
|
pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result<()> {
|
||||||
log::info!(target:"app", "attempting to start core with existing service");
|
log::info!(target:"app", "尝试使用现有服务启动核心 (IPC)");
|
||||||
|
// logging!(info, Type::Service, true, "尝试使用现有服务启动核心");
|
||||||
|
|
||||||
let clash_core = { Config::verge().latest().clash_core.clone() };
|
let clash_core = { Config::verge().latest().clash_core.clone() };
|
||||||
let clash_core = clash_core.unwrap_or("verge-mihomo".into());
|
let clash_core = clash_core.unwrap_or("verge-mihomo".into());
|
||||||
@@ -586,29 +758,70 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
|
|||||||
|
|
||||||
let config_file = dirs::path_to_str(config_file)?;
|
let config_file = dirs::path_to_str(config_file)?;
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
// 构建启动参数
|
||||||
map.insert("core_type", clash_core.as_str());
|
let payload = serde_json::json!({
|
||||||
map.insert("bin_path", bin_path);
|
"core_type": clash_core,
|
||||||
map.insert("config_dir", config_dir);
|
"bin_path": bin_path,
|
||||||
map.insert("config_file", config_file);
|
"config_dir": config_dir,
|
||||||
map.insert("log_file", log_path);
|
"config_file": config_file,
|
||||||
|
"log_file": log_path,
|
||||||
|
});
|
||||||
|
|
||||||
log::info!(target:"app", "start service: {:?}", map.clone());
|
// log::info!(target:"app", "启动服务参数: {:?}", payload);
|
||||||
|
// logging!(info, Type::Service, true, "发送StartClash请求");
|
||||||
|
|
||||||
let url = format!("{SERVICE_URL}/start_clash");
|
// 使用IPC通信
|
||||||
let _ = reqwest::ClientBuilder::new()
|
match send_ipc_request(IpcCommand::StartClash, payload).await {
|
||||||
.no_proxy()
|
Ok(response) => {
|
||||||
.build()?
|
/* logging!(
|
||||||
.post(url)
|
info,
|
||||||
.json(&map)
|
Type::Service,
|
||||||
.send()
|
true,
|
||||||
.await
|
"收到StartClash响应: success={}, error={:?}",
|
||||||
.context("failed to connect to the Clash Verge Service")?;
|
response.success,
|
||||||
|
response.error
|
||||||
|
); */
|
||||||
|
|
||||||
Ok(())
|
if !response.success {
|
||||||
|
let err_msg = response.error.unwrap_or_else(|| "启动核心失败".to_string());
|
||||||
|
logging!(error, Type::Service, true, "启动核心失败: {}", err_msg);
|
||||||
|
bail!(err_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加对嵌套JSON结构的处理
|
||||||
|
if let Some(data) = &response.data {
|
||||||
|
if let Some(code) = data.get("code") {
|
||||||
|
let code_value = code.as_u64().unwrap_or(1);
|
||||||
|
let msg = data
|
||||||
|
.get("msg")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("未知错误");
|
||||||
|
|
||||||
|
if code_value != 0 {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"启动核心返回错误: code={}, msg={}",
|
||||||
|
code_value,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
bail!("启动核心失败: {}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logging!(info, Type::Service, true, "服务成功启动核心");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "启动核心IPC通信失败: {}", e);
|
||||||
|
bail!("无法连接到Clash Verge Service: {}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// start the clash by service
|
// 以服务启动core
|
||||||
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
||||||
log::info!(target: "app", "正在尝试通过服务启动核心");
|
log::info!(target: "app", "正在尝试通过服务启动核心");
|
||||||
|
|
||||||
@@ -618,39 +831,33 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
|||||||
log::info!(target: "app", "检测到服务版本: {}, 要求版本: {}",
|
log::info!(target: "app", "检测到服务版本: {}, 要求版本: {}",
|
||||||
version, REQUIRED_SERVICE_VERSION);
|
version, REQUIRED_SERVICE_VERSION);
|
||||||
|
|
||||||
// 通过字节比较确保完全匹配
|
|
||||||
if version.as_bytes() != REQUIRED_SERVICE_VERSION.as_bytes() {
|
if version.as_bytes() != REQUIRED_SERVICE_VERSION.as_bytes() {
|
||||||
log::warn!(target: "app", "服务版本不匹配,需要重装");
|
log::warn!(target: "app", "服务版本不匹配,需要重装");
|
||||||
false // 版本不匹配
|
false
|
||||||
} else {
|
} else {
|
||||||
log::info!(target: "app", "服务版本匹配");
|
log::info!(target: "app", "服务版本匹配");
|
||||||
true // 版本匹配
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::warn!(target: "app", "无法获取服务版本: {}", err);
|
log::warn!(target: "app", "无法获取服务版本: {}", err);
|
||||||
false // 无法获取版本
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 先尝试直接启动服务,如果服务可用且版本匹配
|
|
||||||
if version_check {
|
if version_check {
|
||||||
if let Ok(true) = is_service_running().await {
|
if is_service_available().await.is_ok() {
|
||||||
// 服务正在运行且版本匹配,直接使用
|
|
||||||
log::info!(target: "app", "服务已在运行且版本匹配,尝试使用");
|
log::info!(target: "app", "服务已在运行且版本匹配,尝试使用");
|
||||||
return start_with_existing_service(config_file).await;
|
return start_with_existing_service(config_file).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制执行版本检查,如果版本不匹配则重装
|
|
||||||
if !version_check {
|
if !version_check {
|
||||||
log::info!(target: "app", "服务版本不匹配,尝试重装");
|
log::info!(target: "app", "服务版本不匹配,尝试重装");
|
||||||
|
|
||||||
// 获取服务状态,检查是否可以重装
|
|
||||||
let service_state = ServiceState::get();
|
let service_state = ServiceState::get();
|
||||||
if !service_state.can_reinstall() {
|
if !service_state.can_reinstall() {
|
||||||
log::warn!(target: "app", "由于限制无法重装服务");
|
log::warn!(target: "app", "由于限制无法重装服务");
|
||||||
// 尝试直接启动,即使版本不匹配
|
|
||||||
if let Ok(()) = start_with_existing_service(config_file).await {
|
if let Ok(()) = start_with_existing_service(config_file).await {
|
||||||
log::info!(target: "app", "尽管版本不匹配,但成功启动了服务");
|
log::info!(target: "app", "尽管版本不匹配,但成功启动了服务");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -659,25 +866,21 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试重装
|
|
||||||
log::info!(target: "app", "开始重装服务");
|
log::info!(target: "app", "开始重装服务");
|
||||||
if let Err(err) = reinstall_service().await {
|
if let Err(err) = reinstall_service().await {
|
||||||
log::warn!(target: "app", "服务重装失败: {}", err);
|
log::warn!(target: "app", "服务重装失败: {}", err);
|
||||||
|
|
||||||
// 尝试使用现有服务
|
|
||||||
log::info!(target: "app", "尝试使用现有服务");
|
log::info!(target: "app", "尝试使用现有服务");
|
||||||
return start_with_existing_service(config_file).await;
|
return start_with_existing_service(config_file).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重装成功,尝试启动
|
|
||||||
log::info!(target: "app", "服务重装成功,尝试启动");
|
log::info!(target: "app", "服务重装成功,尝试启动");
|
||||||
return start_with_existing_service(config_file).await;
|
return start_with_existing_service(config_file).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查服务状态
|
// 检查服务状态
|
||||||
match check_service().await {
|
match check_ipc_service_status().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// 服务可访问但可能没有运行核心,尝试直接启动
|
|
||||||
log::info!(target: "app", "服务可用但未运行核心,尝试启动");
|
log::info!(target: "app", "服务可用但未运行核心,尝试启动");
|
||||||
if let Ok(()) = start_with_existing_service(config_file).await {
|
if let Ok(()) = start_with_existing_service(config_file).await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -692,69 +895,94 @@ pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
|
|||||||
if check_service_needs_reinstall().await {
|
if check_service_needs_reinstall().await {
|
||||||
log::info!(target: "app", "服务需要重装");
|
log::info!(target: "app", "服务需要重装");
|
||||||
|
|
||||||
// 尝试重装
|
|
||||||
if let Err(err) = reinstall_service().await {
|
if let Err(err) = reinstall_service().await {
|
||||||
log::warn!(target: "app", "服务重装失败: {}", err);
|
log::warn!(target: "app", "服务重装失败: {}", err);
|
||||||
bail!("Failed to reinstall service: {}", err);
|
bail!("Failed to reinstall service: {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重装后再次尝试启动
|
|
||||||
log::info!(target: "app", "服务重装完成,尝试启动核心");
|
log::info!(target: "app", "服务重装完成,尝试启动核心");
|
||||||
start_with_existing_service(config_file).await
|
start_with_existing_service(config_file).await
|
||||||
} else {
|
} else {
|
||||||
// 不需要或不能重装,返回错误
|
|
||||||
log::warn!(target: "app", "服务不可用且无法重装");
|
log::warn!(target: "app", "服务不可用且无法重装");
|
||||||
bail!("Service is not available and cannot be reinstalled at this time")
|
bail!("Service is not available and cannot be reinstalled at this time")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// stop the clash by service
|
/// 通过服务停止core
|
||||||
pub(super) async fn stop_core_by_service() -> Result<()> {
|
pub(super) async fn stop_core_by_service() -> Result<()> {
|
||||||
let url = format!("{SERVICE_URL}/stop_clash");
|
logging!(info, Type::Service, true, "通过服务停止核心 (IPC)");
|
||||||
let _ = reqwest::ClientBuilder::new()
|
|
||||||
.no_proxy()
|
let payload = serde_json::json!({});
|
||||||
.build()?
|
let response = send_ipc_request(IpcCommand::StopClash, payload)
|
||||||
.post(url)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.context("failed to connect to the Clash Verge Service")?;
|
.context("无法连接到Clash Verge Service")?;
|
||||||
|
|
||||||
|
if !response.success {
|
||||||
|
bail!(response.error.unwrap_or_else(|| "停止核心失败".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = &response.data {
|
||||||
|
if let Some(code) = data.get("code") {
|
||||||
|
let code_value = code.as_u64().unwrap_or(1);
|
||||||
|
let msg = data
|
||||||
|
.get("msg")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("未知错误");
|
||||||
|
|
||||||
|
if code_value != 0 {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"停止核心返回错误: code={}, msg={}",
|
||||||
|
code_value,
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
bail!("停止核心失败: {}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查服务是否正在运行
|
/// 检查服务是否正在运行
|
||||||
pub async fn is_service_running() -> Result<bool> {
|
|
||||||
let resp = check_service().await?;
|
|
||||||
|
|
||||||
// 检查服务状态码和消息
|
|
||||||
if resp.code == 0 && resp.msg == "ok" && resp.data.is_some() {
|
|
||||||
logging!(debug, Type::Service, "Service is running");
|
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
logging!(debug, Type::Service, "Service is not running");
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_service_available() -> Result<()> {
|
pub async fn is_service_available() -> Result<()> {
|
||||||
let resp = check_service().await?;
|
logging!(info, Type::Service, true, "开始检查服务是否正在运行");
|
||||||
if resp.code == 0 && resp.msg == "ok" && resp.data.is_some() {
|
|
||||||
logging!(debug, Type::Service, "Service is available");
|
match check_ipc_service_status().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.code == 0 && resp.msg == "ok" && resp.data.is_some() {
|
||||||
|
logging!(info, Type::Service, true, "服务正在运行");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"服务未正常运行: code={}, msg={}",
|
||||||
|
resp.code,
|
||||||
|
resp.msg
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
logging!(error, Type::Service, true, "检查服务运行状态失败: {}", err);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 强制重装服务(用于UI中的修复服务按钮)
|
/// 强制重装服务(UI修复按钮)
|
||||||
pub async fn force_reinstall_service() -> Result<()> {
|
pub async fn force_reinstall_service() -> Result<()> {
|
||||||
log::info!(target: "app", "用户请求强制重装服务");
|
log::info!(target: "app", "用户请求强制重装服务");
|
||||||
|
|
||||||
// 创建默认服务状态(重置所有限制)
|
|
||||||
let service_state = ServiceState::default();
|
let service_state = ServiceState::default();
|
||||||
service_state.save()?;
|
service_state.save()?;
|
||||||
|
|
||||||
log::info!(target: "app", "已重置服务状态,开始执行重装");
|
log::info!(target: "app", "已重置服务状态,开始执行重装");
|
||||||
|
|
||||||
// 执行重装
|
|
||||||
match reinstall_service().await {
|
match reinstall_service().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
log::info!(target: "app", "服务重装成功");
|
log::info!(target: "app", "服务重装成功");
|
||||||
@@ -766,3 +994,178 @@ pub async fn force_reinstall_service() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
/// 彻底诊断服务状态,检查安装状态、IPC通信和服务版本
|
||||||
|
pub async fn diagnose_service() -> Result<()> {
|
||||||
|
logging!(info, Type::Service, true, "============= 开始服务诊断 =============");
|
||||||
|
|
||||||
|
// 1. 检查服务文件是否存在
|
||||||
|
let service_path = dirs::service_path();
|
||||||
|
match service_path {
|
||||||
|
Ok(path) => {
|
||||||
|
let service_exists = path.exists();
|
||||||
|
logging!(info, Type::Service, true, "服务可执行文件路径: {:?}, 存在: {}", path, service_exists);
|
||||||
|
|
||||||
|
if !service_exists {
|
||||||
|
logging!(error, Type::Service, true, "服务可执行文件不存在,需要重新安装");
|
||||||
|
bail!("服务可执行文件不存在,需要重新安装");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查服务版本文件
|
||||||
|
let version_file = path.with_file_name("version.txt");
|
||||||
|
if version_file.exists() {
|
||||||
|
match std::fs::read_to_string(&version_file) {
|
||||||
|
Ok(content) => {
|
||||||
|
logging!(info, Type::Service, true, "服务版本文件内容: {}", content.trim());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(warn, Type::Service, true, "读取服务版本文件失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logging!(warn, Type::Service, true, "服务版本文件不存在: {:?}", version_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "获取服务路径失败: {}", e);
|
||||||
|
bail!("获取服务路径失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查IPC通信 - 命名管道/Unix套接字
|
||||||
|
let socket_path = if cfg!(windows) {
|
||||||
|
r"\\.\pipe\clash-verge-service"
|
||||||
|
} else {
|
||||||
|
"/tmp/clash-verge-service.sock"
|
||||||
|
};
|
||||||
|
|
||||||
|
logging!(info, Type::Service, true, "IPC通信路径: {}", socket_path);
|
||||||
|
|
||||||
|
if !cfg!(windows) {
|
||||||
|
// Unix系统检查套接字文件是否存在
|
||||||
|
let socket_exists = std::path::Path::new(socket_path).exists();
|
||||||
|
logging!(info, Type::Service, true, "Unix套接字文件是否存在: {}", socket_exists);
|
||||||
|
|
||||||
|
if !socket_exists {
|
||||||
|
logging!(warn, Type::Service, true, "Unix套接字文件不存在,服务可能未运行");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 尝试通过IPC检查服务状态
|
||||||
|
logging!(info, Type::Service, true, "尝试通过IPC通信检查服务状态...");
|
||||||
|
match check_service().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
logging!(info, Type::Service, true, "服务状态检查成功: code={}, msg={}", resp.code, resp.msg);
|
||||||
|
|
||||||
|
// 4. 检查服务版本
|
||||||
|
match check_service_version().await {
|
||||||
|
Ok(version) => {
|
||||||
|
logging!(info, Type::Service, true, "服务版本: {}, 要求版本: {}",
|
||||||
|
version, REQUIRED_SERVICE_VERSION);
|
||||||
|
|
||||||
|
if version != REQUIRED_SERVICE_VERSION {
|
||||||
|
logging!(warn, Type::Service, true, "服务版本不匹配,建议重装服务");
|
||||||
|
} else {
|
||||||
|
logging!(info, Type::Service, true, "服务版本匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
logging!(error, Type::Service, true, "检查服务版本失败: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
logging!(error, Type::Service, true, "服务状态检查失败: {}", err);
|
||||||
|
|
||||||
|
// 5. 检查系统服务状态 - Windows专用
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::process::Command;
|
||||||
|
logging!(info, Type::Service, true, "尝试检查Windows服务状态...");
|
||||||
|
|
||||||
|
let output = Command::new("sc")
|
||||||
|
.args(["query", "clash_verge_service"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let contains_running = stdout.contains("RUNNING");
|
||||||
|
|
||||||
|
logging!(info, Type::Service, true, "Windows服务查询结果: {}",
|
||||||
|
if contains_running { "正在运行" } else { "未运行" });
|
||||||
|
|
||||||
|
if !contains_running {
|
||||||
|
logging!(info, Type::Service, true, "服务输出: {}", stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "检查Windows服务状态失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS专用
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use std::process::Command;
|
||||||
|
logging!(info, Type::Service, true, "尝试检查macOS服务状态...");
|
||||||
|
|
||||||
|
let output = Command::new("launchctl")
|
||||||
|
.args(["list", "io.github.clash-verge-rev.clash-verge-rev.service"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
|
||||||
|
if out.status.success() {
|
||||||
|
logging!(info, Type::Service, true, "macOS服务正在运行");
|
||||||
|
logging!(debug, Type::Service, true, "服务详情: {}", stdout);
|
||||||
|
} else {
|
||||||
|
logging!(warn, Type::Service, true, "macOS服务未运行");
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
logging!(info, Type::Service, true, "错误信息: {}", stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "检查macOS服务状态失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux专用
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use std::process::Command;
|
||||||
|
logging!(info, Type::Service, true, "尝试检查Linux服务状态...");
|
||||||
|
|
||||||
|
let output = Command::new("systemctl")
|
||||||
|
.args(["status", "clash_verge_service"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let is_active = stdout.contains("Active: active (running)");
|
||||||
|
|
||||||
|
logging!(info, Type::Service, true, "Linux服务状态: {}",
|
||||||
|
if is_active { "活跃运行中" } else { "未运行" });
|
||||||
|
|
||||||
|
if !is_active {
|
||||||
|
logging!(info, Type::Service, true, "服务状态详情: {}", stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "检查Linux服务状态失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logging!(info, Type::Service, true, "============= 服务诊断完成 =============");
|
||||||
|
Ok(())
|
||||||
|
} */
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
use crate::{logging, utils::logging::Type};
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const IPC_SOCKET_NAME: &str = if cfg!(windows) {
|
||||||
|
r"\\.\pipe\clash-verge-service"
|
||||||
|
} else {
|
||||||
|
"/tmp/clash-verge-service.sock"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义命令类型
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum IpcCommand {
|
||||||
|
GetClash,
|
||||||
|
GetVersion,
|
||||||
|
StartClash,
|
||||||
|
StopClash,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC消息格式
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IpcRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub command: IpcCommand,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IpcResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub success: bool,
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密钥派生函数
|
||||||
|
fn derive_secret_key() -> Vec<u8> {
|
||||||
|
// to do
|
||||||
|
// 从系统安全存储中获取或从程序安装时生成的密钥文件中读取
|
||||||
|
let unique_app_id = "clash-verge-app-secret-fuck-me-until-daylight";
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(unique_app_id.as_bytes());
|
||||||
|
hasher.finalize().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建带签名的请求
|
||||||
|
pub fn create_signed_request(
|
||||||
|
command: IpcCommand,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<IpcRequest> {
|
||||||
|
let id = nanoid::nanoid!(32);
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let unsigned_request = IpcRequest {
|
||||||
|
id: id.clone(),
|
||||||
|
timestamp,
|
||||||
|
command: command.clone(),
|
||||||
|
payload: payload.clone(),
|
||||||
|
signature: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let unsigned_json = serde_json::to_string(&unsigned_request)?;
|
||||||
|
let signature = sign_message(&unsigned_json)?;
|
||||||
|
|
||||||
|
Ok(IpcRequest {
|
||||||
|
id,
|
||||||
|
timestamp,
|
||||||
|
command,
|
||||||
|
payload,
|
||||||
|
signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名消息
|
||||||
|
fn sign_message(message: &str) -> Result<String> {
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
let secret_key = derive_secret_key();
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&secret_key).context("HMAC初始化失败")?;
|
||||||
|
|
||||||
|
mac.update(message.as_bytes());
|
||||||
|
let result = mac.finalize();
|
||||||
|
let signature = hex::encode(result.into_bytes());
|
||||||
|
|
||||||
|
Ok(signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证响应签名
|
||||||
|
pub fn verify_response_signature(response: &IpcResponse) -> Result<bool> {
|
||||||
|
let verification_response = IpcResponse {
|
||||||
|
id: response.id.clone(),
|
||||||
|
success: response.success,
|
||||||
|
data: response.data.clone(),
|
||||||
|
error: response.error.clone(),
|
||||||
|
signature: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = serde_json::to_string(&verification_response)?;
|
||||||
|
let expected_signature = sign_message(&message)?;
|
||||||
|
|
||||||
|
Ok(expected_signature == response.signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC连接管理-win
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub async fn send_ipc_request(
|
||||||
|
command: IpcCommand,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<IpcResponse> {
|
||||||
|
use std::{
|
||||||
|
ffi::CString,
|
||||||
|
fs::File,
|
||||||
|
io::{Read, Write},
|
||||||
|
os::windows::io::{FromRawHandle, RawHandle},
|
||||||
|
ptr,
|
||||||
|
};
|
||||||
|
use winapi::um::{
|
||||||
|
fileapi::{CreateFileA, OPEN_EXISTING},
|
||||||
|
handleapi::INVALID_HANDLE_VALUE,
|
||||||
|
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
|
||||||
|
};
|
||||||
|
|
||||||
|
logging!(info, Type::Service, true, "正在连接服务 (Windows)...");
|
||||||
|
|
||||||
|
let command_type = format!("{:?}", command);
|
||||||
|
|
||||||
|
let request = match create_signed_request(command, payload) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "创建签名请求失败: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_json = serde_json::to_string(&request)?;
|
||||||
|
|
||||||
|
let result = tokio::task::spawn_blocking(move || -> Result<IpcResponse> {
|
||||||
|
let c_pipe_name = match CString::new(IPC_SOCKET_NAME) {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "创建CString失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("创建CString失败: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle = unsafe {
|
||||||
|
CreateFileA(
|
||||||
|
c_pipe_name.as_ptr(),
|
||||||
|
GENERIC_READ | GENERIC_WRITE,
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||||
|
ptr::null_mut(),
|
||||||
|
OPEN_EXISTING,
|
||||||
|
0,
|
||||||
|
ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if handle == INVALID_HANDLE_VALUE {
|
||||||
|
let error = std::io::Error::last_os_error();
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"连接到服务命名管道失败: {}",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return Err(anyhow::anyhow!("无法连接到服务命名管道: {}", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pipe = unsafe { File::from_raw_handle(handle as RawHandle) };
|
||||||
|
logging!(info, Type::Service, true, "服务连接成功 (Windows)");
|
||||||
|
|
||||||
|
let request_bytes = request_json.as_bytes();
|
||||||
|
let len_bytes = (request_bytes.len() as u32).to_be_bytes();
|
||||||
|
|
||||||
|
if let Err(e) = pipe.write_all(&len_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "写入请求长度失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("写入请求长度失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = pipe.write_all(request_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "写入请求内容失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("写入请求内容失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = pipe.flush() {
|
||||||
|
logging!(error, Type::Service, true, "刷新管道失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("刷新管道失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response_len_bytes = [0u8; 4];
|
||||||
|
if let Err(e) = pipe.read_exact(&mut response_len_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "读取响应长度失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("读取响应长度失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_len = u32::from_be_bytes(response_len_bytes) as usize;
|
||||||
|
|
||||||
|
let mut response_bytes = vec![0u8; response_len];
|
||||||
|
if let Err(e) = pipe.read_exact(&mut response_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "读取响应内容失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("读取响应内容失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: IpcResponse = match serde_json::from_slice::<IpcResponse>(&response_bytes) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "服务响应解析失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("解析响应失败: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match verify_response_signature(&response) {
|
||||||
|
Ok(valid) => {
|
||||||
|
if !valid {
|
||||||
|
logging!(error, Type::Service, true, "服务响应签名验证失败");
|
||||||
|
bail!("服务响应签名验证失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "验证响应签名时出错: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"IPC请求完成: 命令={}, 成功={}",
|
||||||
|
command_type,
|
||||||
|
response.success
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC连接管理-unix
|
||||||
|
#[cfg(target_family = "unix")]
|
||||||
|
pub async fn send_ipc_request(
|
||||||
|
command: IpcCommand,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<IpcResponse> {
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
|
logging!(info, Type::Service, true, "正在连接服务 (Unix)...");
|
||||||
|
|
||||||
|
let command_type = format!("{:?}", command);
|
||||||
|
|
||||||
|
let request = match create_signed_request(command, payload) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "创建签名请求失败: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_json = serde_json::to_string(&request)?;
|
||||||
|
|
||||||
|
let mut stream = match UnixStream::connect(IPC_SOCKET_NAME) {
|
||||||
|
Ok(s) => {
|
||||||
|
logging!(info, Type::Service, true, "服务连接成功 (Unix)");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "连接到Unix套接字失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("无法连接到服务Unix套接字: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_bytes = request_json.as_bytes();
|
||||||
|
let len_bytes = (request_bytes.len() as u32).to_be_bytes();
|
||||||
|
|
||||||
|
if let Err(e) = std::io::Write::write_all(&mut stream, &len_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "写入请求长度失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("写入请求长度失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::io::Write::write_all(&mut stream, request_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "写入请求内容失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("写入请求内容失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response_len_bytes = [0u8; 4];
|
||||||
|
if let Err(e) = std::io::Read::read_exact(&mut stream, &mut response_len_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "读取响应长度失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("读取响应长度失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_len = u32::from_be_bytes(response_len_bytes) as usize;
|
||||||
|
|
||||||
|
let mut response_bytes = vec![0u8; response_len];
|
||||||
|
if let Err(e) = std::io::Read::read_exact(&mut stream, &mut response_bytes) {
|
||||||
|
logging!(error, Type::Service, true, "读取响应内容失败: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("读取响应内容失败: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: IpcResponse = match serde_json::from_slice::<IpcResponse>(&response_bytes) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "服务响应解析失败: {}", e,);
|
||||||
|
return Err(anyhow::anyhow!("解析响应失败: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match verify_response_signature(&response) {
|
||||||
|
Ok(valid) => {
|
||||||
|
if !valid {
|
||||||
|
logging!(error, Type::Service, true, "服务响应签名验证失败");
|
||||||
|
bail!("服务响应签名验证失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
logging!(error, Type::Service, true, "验证响应签名时出错: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Service,
|
||||||
|
true,
|
||||||
|
"IPC请求完成: 命令={}, 成功={}",
|
||||||
|
command_type,
|
||||||
|
response.success
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
+194
-112
@@ -1,7 +1,10 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use crate::utils::autostart as startup_shortcut;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IVerge},
|
config::{Config, IVerge},
|
||||||
core::handle::Handle,
|
core::handle::Handle,
|
||||||
logging_error,
|
logging, logging_error,
|
||||||
|
process::AsyncHandler,
|
||||||
utils::logging::Type,
|
utils::logging::Type,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -16,8 +19,6 @@ use tokio::time::{sleep, Duration};
|
|||||||
pub struct Sysopt {
|
pub struct Sysopt {
|
||||||
update_sysproxy: Arc<TokioMutex<bool>>,
|
update_sysproxy: Arc<TokioMutex<bool>>,
|
||||||
reset_sysproxy: Arc<TokioMutex<bool>>,
|
reset_sysproxy: Arc<TokioMutex<bool>>,
|
||||||
/// helps to auto launch the app
|
|
||||||
auto_launch: Arc<Mutex<bool>>,
|
|
||||||
/// record whether the guard async is running or not
|
/// record whether the guard async is running or not
|
||||||
guard_state: Arc<Mutex<bool>>,
|
guard_state: Arc<Mutex<bool>>,
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,6 @@ impl Sysopt {
|
|||||||
SYSOPT.get_or_init(|| Sysopt {
|
SYSOPT.get_or_init(|| Sysopt {
|
||||||
update_sysproxy: Arc::new(TokioMutex::new(false)),
|
update_sysproxy: Arc::new(TokioMutex::new(false)),
|
||||||
reset_sysproxy: Arc::new(TokioMutex::new(false)),
|
reset_sysproxy: Arc::new(TokioMutex::new(false)),
|
||||||
auto_launch: Arc::new(Mutex::new(false)),
|
|
||||||
guard_state: Arc::new(false.into()),
|
guard_state: Arc::new(false.into()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -78,12 +78,16 @@ impl Sysopt {
|
|||||||
.unwrap_or(Config::clash().data().get_mixed_port());
|
.unwrap_or(Config::clash().data().get_mixed_port());
|
||||||
let pac_port = IVerge::get_singleton_port();
|
let pac_port = IVerge::get_singleton_port();
|
||||||
|
|
||||||
let (sys_enable, pac_enable) = {
|
let (sys_enable, pac_enable, proxy_host) = {
|
||||||
let verge = Config::verge();
|
let verge = Config::verge();
|
||||||
let verge = verge.latest();
|
let verge = verge.latest();
|
||||||
(
|
(
|
||||||
verge.enable_system_proxy.unwrap_or(false),
|
verge.enable_system_proxy.unwrap_or(false),
|
||||||
verge.proxy_auto_config.unwrap_or(false),
|
verge.proxy_auto_config.unwrap_or(false),
|
||||||
|
verge
|
||||||
|
.proxy_host
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| String::from("127.0.0.1")),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,13 +95,13 @@ impl Sysopt {
|
|||||||
{
|
{
|
||||||
let mut sys = Sysproxy {
|
let mut sys = Sysproxy {
|
||||||
enable: false,
|
enable: false,
|
||||||
host: String::from("127.0.0.1"),
|
host: proxy_host.clone(),
|
||||||
port,
|
port,
|
||||||
bypass: get_bypass(),
|
bypass: get_bypass(),
|
||||||
};
|
};
|
||||||
let mut auto = Autoproxy {
|
let mut auto = Autoproxy {
|
||||||
enable: false,
|
enable: false,
|
||||||
url: format!("http://127.0.0.1:{pac_port}/commands/pac"),
|
url: format!("http://{}:{}/commands/pac", proxy_host, pac_port),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !sys_enable {
|
if !sys_enable {
|
||||||
@@ -141,7 +145,7 @@ impl Sysopt {
|
|||||||
|
|
||||||
let shell = app_handle.shell();
|
let shell = app_handle.shell();
|
||||||
let output = if pac_enable {
|
let output = if pac_enable {
|
||||||
let address = format!("http://{}:{}/commands/pac", "127.0.0.1", pac_port);
|
let address = format!("http://{}:{}/commands/pac", proxy_host, pac_port);
|
||||||
let output = shell
|
let output = shell
|
||||||
.command(sysproxy_exe.as_path().to_str().unwrap())
|
.command(sysproxy_exe.as_path().to_str().unwrap())
|
||||||
.args(["pac", address.as_str()])
|
.args(["pac", address.as_str()])
|
||||||
@@ -150,7 +154,7 @@ impl Sysopt {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
output
|
output
|
||||||
} else {
|
} else {
|
||||||
let address = format!("{}:{}", "127.0.0.1", port);
|
let address = format!("{}:{}", proxy_host, port);
|
||||||
let bypass = get_bypass();
|
let bypass = get_bypass();
|
||||||
let output = shell
|
let output = shell
|
||||||
.command(sysproxy_exe.as_path().to_str().unwrap())
|
.command(sysproxy_exe.as_path().to_str().unwrap())
|
||||||
@@ -216,40 +220,69 @@ impl Sysopt {
|
|||||||
|
|
||||||
/// update the startup
|
/// update the startup
|
||||||
pub fn update_launch(&self) -> Result<()> {
|
pub fn update_launch(&self) -> Result<()> {
|
||||||
let _lock = self.auto_launch.lock();
|
let enable_auto_launch = { Config::verge().latest().enable_auto_launch };
|
||||||
let enable = { Config::verge().latest().enable_auto_launch };
|
let is_enable = enable_auto_launch.unwrap_or(false);
|
||||||
let enable = enable.unwrap_or(false);
|
logging!(info, true, "Setting auto-launch state to: {:?}", is_enable);
|
||||||
let app_handle = Handle::global().app_handle().unwrap();
|
|
||||||
let autostart_manager = app_handle.autolaunch();
|
|
||||||
|
|
||||||
log::info!(target: "app", "Setting auto launch to: {}", enable);
|
// 首先尝试使用快捷方式方法
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if is_enable {
|
||||||
|
if let Err(e) = startup_shortcut::create_shortcut() {
|
||||||
|
log::error!(target: "app", "创建启动快捷方式失败: {}", e);
|
||||||
|
// 如果快捷方式创建失败,回退到原来的方法
|
||||||
|
self.try_original_autostart_method(is_enable);
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Err(e) = startup_shortcut::remove_shortcut() {
|
||||||
|
log::error!(target: "app", "删除启动快捷方式失败: {}", e);
|
||||||
|
self.try_original_autostart_method(is_enable);
|
||||||
|
} else {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match enable {
|
#[cfg(not(target_os = "windows"))]
|
||||||
true => {
|
{
|
||||||
let result = autostart_manager.enable();
|
// 非Windows平台使用原来的方法
|
||||||
if let Err(ref e) = result {
|
self.try_original_autostart_method(is_enable);
|
||||||
log::error!(target: "app", "Failed to enable auto launch: {}", e);
|
}
|
||||||
} else {
|
|
||||||
log::info!(target: "app", "Auto launch enabled successfully");
|
|
||||||
}
|
|
||||||
logging_error!(Type::System, true, result);
|
|
||||||
}
|
|
||||||
false => {
|
|
||||||
let result = autostart_manager.disable();
|
|
||||||
if let Err(ref e) = result {
|
|
||||||
log::error!(target: "app", "Failed to disable auto launch: {}", e);
|
|
||||||
} else {
|
|
||||||
log::info!(target: "app", "Auto launch disabled successfully");
|
|
||||||
}
|
|
||||||
logging_error!(Type::System, true, result);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 尝试使用原来的自启动方法
|
||||||
|
fn try_original_autostart_method(&self, is_enable: bool) {
|
||||||
|
let app_handle = Handle::global().app_handle().unwrap();
|
||||||
|
let autostart_manager = app_handle.autolaunch();
|
||||||
|
|
||||||
|
if is_enable {
|
||||||
|
logging_error!(Type::System, true, "{:?}", autostart_manager.enable());
|
||||||
|
} else {
|
||||||
|
logging_error!(Type::System, true, "{:?}", autostart_manager.disable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取当前自启动的实际状态
|
/// 获取当前自启动的实际状态
|
||||||
pub fn get_launch_status(&self) -> Result<bool> {
|
pub fn get_launch_status(&self) -> Result<bool> {
|
||||||
|
// 首先尝试检查快捷方式是否存在
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
match startup_shortcut::is_shortcut_enabled() {
|
||||||
|
Ok(enabled) => {
|
||||||
|
log::info!(target: "app", "快捷方式自启动状态: {}", enabled);
|
||||||
|
return Ok(enabled);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(target: "app", "检查快捷方式失败,尝试原来的方法: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到原来的方法
|
||||||
let app_handle = Handle::global().app_handle().unwrap();
|
let app_handle = Handle::global().app_handle().unwrap();
|
||||||
let autostart_manager = app_handle.autolaunch();
|
let autostart_manager = app_handle.autolaunch();
|
||||||
|
|
||||||
@@ -268,14 +301,14 @@ impl Sysopt {
|
|||||||
fn guard_proxy(&self) {
|
fn guard_proxy(&self) {
|
||||||
let _lock = self.guard_state.lock();
|
let _lock = self.guard_state.lock();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
// default duration is 10s
|
// default duration is 10s
|
||||||
let mut wait_secs = 10u64;
|
let mut wait_secs = 10u64;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
sleep(Duration::from_secs(wait_secs)).await;
|
sleep(Duration::from_secs(wait_secs)).await;
|
||||||
|
|
||||||
let (enable, guard, guard_duration, pac) = {
|
let (enable, guard, guard_duration, pac, proxy_host) = {
|
||||||
let verge = Config::verge();
|
let verge = Config::verge();
|
||||||
let verge = verge.latest();
|
let verge = verge.latest();
|
||||||
(
|
(
|
||||||
@@ -283,6 +316,10 @@ impl Sysopt {
|
|||||||
verge.enable_proxy_guard.unwrap_or(false),
|
verge.enable_proxy_guard.unwrap_or(false),
|
||||||
verge.proxy_guard_duration.unwrap_or(10),
|
verge.proxy_guard_duration.unwrap_or(10),
|
||||||
verge.proxy_auto_config.unwrap_or(false),
|
verge.proxy_auto_config.unwrap_or(false),
|
||||||
|
verge
|
||||||
|
.proxy_host
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| String::from("127.0.0.1")),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -296,85 +333,130 @@ impl Sysopt {
|
|||||||
|
|
||||||
log::debug!(target: "app", "try to guard the system proxy");
|
log::debug!(target: "app", "try to guard the system proxy");
|
||||||
|
|
||||||
let sysproxy = Sysproxy::get_system_proxy();
|
// 获取期望的代理端口
|
||||||
let autoproxy = Autoproxy::get_auto_proxy();
|
let port = Config::verge()
|
||||||
if sysproxy.is_err() || autoproxy.is_err() {
|
.latest()
|
||||||
log::error!(target: "app", "failed to get the system proxy");
|
.verge_mixed_port
|
||||||
continue;
|
.unwrap_or(Config::clash().data().get_mixed_port());
|
||||||
}
|
|
||||||
|
|
||||||
let sysproxy_enable = sysproxy.ok().map(|s| s.enable).unwrap_or(false);
|
|
||||||
let autoproxy_enable = autoproxy.ok().map(|s| s.enable).unwrap_or(false);
|
|
||||||
|
|
||||||
if sysproxy_enable || autoproxy_enable {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let port = {
|
|
||||||
Config::verge()
|
|
||||||
.latest()
|
|
||||||
.verge_mixed_port
|
|
||||||
.unwrap_or(Config::clash().data().get_mixed_port())
|
|
||||||
};
|
|
||||||
let pac_port = IVerge::get_singleton_port();
|
let pac_port = IVerge::get_singleton_port();
|
||||||
#[cfg(not(target_os = "windows"))]
|
let bypass = get_bypass();
|
||||||
{
|
|
||||||
if pac {
|
|
||||||
let autoproxy = Autoproxy {
|
|
||||||
enable: true,
|
|
||||||
url: format!("http://127.0.0.1:{pac_port}/commands/pac"),
|
|
||||||
};
|
|
||||||
logging_error!(Type::System, true, autoproxy.set_auto_proxy());
|
|
||||||
} else {
|
|
||||||
let sysproxy = Sysproxy {
|
|
||||||
enable: true,
|
|
||||||
host: "127.0.0.1".into(),
|
|
||||||
port,
|
|
||||||
bypass: get_bypass(),
|
|
||||||
};
|
|
||||||
|
|
||||||
logging_error!(Type::System, true, sysproxy.set_system_proxy());
|
// 检查系统代理配置
|
||||||
|
if pac {
|
||||||
|
// 检查 PAC 代理设置
|
||||||
|
let expected_url = format!("http://{}:{}/commands/pac", proxy_host, pac_port);
|
||||||
|
let autoproxy = match Autoproxy::get_auto_proxy() {
|
||||||
|
Ok(ap) => ap,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(target: "app", "failed to get the auto proxy: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查自动代理是否启用且URL是否正确
|
||||||
|
if !autoproxy.enable || autoproxy.url != expected_url {
|
||||||
|
log::info!(target: "app", "auto proxy settings changed, restoring...");
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let new_autoproxy = Autoproxy {
|
||||||
|
enable: true,
|
||||||
|
url: expected_url,
|
||||||
|
};
|
||||||
|
logging_error!(Type::System, true, new_autoproxy.set_auto_proxy());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use crate::{core::handle::Handle, utils::dirs};
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
|
||||||
|
let app_handle = Handle::global().app_handle().unwrap();
|
||||||
|
let binary_path = match dirs::service_path() {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(target: "app", "failed to get service path: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
|
||||||
|
if !sysproxy_exe.exists() {
|
||||||
|
log::error!(target: "app", "sysproxy.exe not found");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shell = app_handle.shell();
|
||||||
|
let output = shell
|
||||||
|
.command(sysproxy_exe.as_path().to_str().unwrap())
|
||||||
|
.args(["pac", expected_url.as_str()])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
log::error!(target: "app", "failed to set auto proxy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 检查常规系统代理设置
|
||||||
|
let sysproxy = match Sysproxy::get_system_proxy() {
|
||||||
|
Ok(sp) => sp,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(target: "app", "failed to get the system proxy: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查系统代理是否启用且配置是否匹配
|
||||||
|
if !sysproxy.enable || sysproxy.host != proxy_host || sysproxy.port != port {
|
||||||
|
log::info!(target: "app", "system proxy settings changed, restoring...");
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let new_sysproxy = Sysproxy {
|
||||||
|
enable: true,
|
||||||
|
host: proxy_host.clone(),
|
||||||
|
port,
|
||||||
|
bypass: bypass.clone(),
|
||||||
|
};
|
||||||
|
logging_error!(Type::System, true, new_sysproxy.set_system_proxy());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use crate::{core::handle::Handle, utils::dirs};
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
|
||||||
|
let app_handle = Handle::global().app_handle().unwrap();
|
||||||
|
let binary_path = match dirs::service_path() {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(target: "app", "failed to get service path: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
|
||||||
|
if !sysproxy_exe.exists() {
|
||||||
|
log::error!(target: "app", "sysproxy.exe not found");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let address = format!("{}:{}", proxy_host, port);
|
||||||
|
let shell = app_handle.shell();
|
||||||
|
let output = shell
|
||||||
|
.command(sysproxy_exe.as_path().to_str().unwrap())
|
||||||
|
.args(["global", address.as_str(), bypass.as_ref()])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
log::error!(target: "app", "failed to set system proxy");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use crate::{core::handle::Handle, utils::dirs};
|
|
||||||
use tauri_plugin_shell::ShellExt;
|
|
||||||
|
|
||||||
let app_handle = Handle::global().app_handle().unwrap();
|
|
||||||
|
|
||||||
let binary_path = dirs::service_path().unwrap();
|
|
||||||
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
|
|
||||||
if !sysproxy_exe.exists() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shell = app_handle.shell();
|
|
||||||
let output = if pac {
|
|
||||||
let address = format!("http://{}:{}/commands/pac", "127.0.0.1", pac_port);
|
|
||||||
|
|
||||||
shell
|
|
||||||
.command(sysproxy_exe.as_path().to_str().unwrap())
|
|
||||||
.args(["pac", address.as_str()])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
} else {
|
|
||||||
let address = format!("{}:{}", "127.0.0.1", port);
|
|
||||||
let bypass = get_bypass();
|
|
||||||
|
|
||||||
shell
|
|
||||||
.command(sysproxy_exe.as_path().to_str().unwrap())
|
|
||||||
.args(["global", address.as_str(), bypass.as_ref()])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
};
|
|
||||||
if !output.status.success() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-31
@@ -1,6 +1,4 @@
|
|||||||
use crate::{
|
use crate::{config::Config, feat, logging, logging_error, utils::logging::Type};
|
||||||
config::Config, core::CoreManager, feat, logging, logging_error, utils::logging::Type,
|
|
||||||
};
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
|
use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder};
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
@@ -71,6 +69,25 @@ impl Timer {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timer_map = self.timer_map.read();
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Timer,
|
||||||
|
"已注册的定时任务数量: {}",
|
||||||
|
timer_map.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (uid, task) in timer_map.iter() {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Timer,
|
||||||
|
"注册了定时任务 - uid={}, interval={}min, task_id={}",
|
||||||
|
uid,
|
||||||
|
task.interval_minutes,
|
||||||
|
task.task_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let cur_timestamp = chrono::Local::now().timestamp();
|
let cur_timestamp = chrono::Local::now().timestamp();
|
||||||
|
|
||||||
// Collect profiles that need immediate update
|
// Collect profiles that need immediate update
|
||||||
@@ -83,6 +100,7 @@ impl Timer {
|
|||||||
let uid = item.uid.as_ref()?;
|
let uid = item.uid.as_ref()?;
|
||||||
|
|
||||||
if interval > 0 && cur_timestamp - updated >= interval * 60 {
|
if interval > 0 && cur_timestamp - updated >= interval * 60 {
|
||||||
|
logging!(info, Type::Timer, "需要立即更新的配置: uid={}", uid);
|
||||||
Some(uid.clone())
|
Some(uid.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -95,12 +113,18 @@ impl Timer {
|
|||||||
|
|
||||||
// Advance tasks outside of locks to minimize lock contention
|
// Advance tasks outside of locks to minimize lock contention
|
||||||
if !profiles_to_update.is_empty() {
|
if !profiles_to_update.is_empty() {
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Timer,
|
||||||
|
"需要立即更新的配置数量: {}",
|
||||||
|
profiles_to_update.len()
|
||||||
|
);
|
||||||
let timer_map = self.timer_map.read();
|
let timer_map = self.timer_map.read();
|
||||||
let delay_timer = self.delay_timer.write();
|
let delay_timer = self.delay_timer.write();
|
||||||
|
|
||||||
for uid in profiles_to_update {
|
for uid in profiles_to_update {
|
||||||
if let Some(task) = timer_map.get(&uid) {
|
if let Some(task) = timer_map.get(&uid) {
|
||||||
logging!(info, Type::Timer, "Advancing task for uid: {}", uid);
|
logging!(info, Type::Timer, "立即执行任务: uid={}", uid);
|
||||||
if let Err(e) = delay_timer.advance_task(task.task_id) {
|
if let Err(e) = delay_timer.advance_task(task.task_id) {
|
||||||
logging!(warn, Type::Timer, "Failed to advance task {}: {}", uid, e);
|
logging!(warn, Type::Timer, "Failed to advance task {}: {}", uid, e);
|
||||||
}
|
}
|
||||||
@@ -210,6 +234,13 @@ impl Timer {
|
|||||||
if let Some(option) = item.option.as_ref() {
|
if let Some(option) = item.option.as_ref() {
|
||||||
if let (Some(interval), Some(uid)) = (option.update_interval, &item.uid) {
|
if let (Some(interval), Some(uid)) = (option.update_interval, &item.uid) {
|
||||||
if interval > 0 {
|
if interval > 0 {
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Timer,
|
||||||
|
"找到定时更新配置: uid={}, interval={}min",
|
||||||
|
uid,
|
||||||
|
interval
|
||||||
|
);
|
||||||
new_map.insert(uid.clone(), interval);
|
new_map.insert(uid.clone(), interval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +248,12 @@ impl Timer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Timer,
|
||||||
|
"生成的定时更新配置数量: {}",
|
||||||
|
new_map.len()
|
||||||
|
);
|
||||||
new_map
|
new_map
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,20 +264,36 @@ impl Timer {
|
|||||||
|
|
||||||
// Read lock for comparing current state
|
// Read lock for comparing current state
|
||||||
let timer_map = self.timer_map.read();
|
let timer_map = self.timer_map.read();
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Timer,
|
||||||
|
"当前 timer_map 大小: {}",
|
||||||
|
timer_map.len()
|
||||||
|
);
|
||||||
|
|
||||||
// Find tasks to modify or delete
|
// Find tasks to modify or delete
|
||||||
for (uid, task) in timer_map.iter() {
|
for (uid, task) in timer_map.iter() {
|
||||||
match new_map.get(uid) {
|
match new_map.get(uid) {
|
||||||
Some(&interval) if interval != task.interval_minutes => {
|
Some(&interval) if interval != task.interval_minutes => {
|
||||||
// Task exists but interval changed
|
// Task exists but interval changed
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Timer,
|
||||||
|
"定时任务间隔变更: uid={}, 旧={}, 新={}",
|
||||||
|
uid,
|
||||||
|
task.interval_minutes,
|
||||||
|
interval
|
||||||
|
);
|
||||||
diff_map.insert(uid.clone(), DiffFlag::Mod(task.task_id, interval));
|
diff_map.insert(uid.clone(), DiffFlag::Mod(task.task_id, interval));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Task no longer needed
|
// Task no longer needed
|
||||||
|
logging!(debug, Type::Timer, "定时任务已删除: uid={}", uid);
|
||||||
diff_map.insert(uid.clone(), DiffFlag::Del(task.task_id));
|
diff_map.insert(uid.clone(), DiffFlag::Del(task.task_id));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Task exists with same interval, no change needed
|
// Task exists with same interval, no change needed
|
||||||
|
logging!(debug, Type::Timer, "定时任务保持不变: uid={}", uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +303,13 @@ impl Timer {
|
|||||||
|
|
||||||
for (uid, &interval) in new_map.iter() {
|
for (uid, &interval) in new_map.iter() {
|
||||||
if !timer_map.contains_key(uid) {
|
if !timer_map.contains_key(uid) {
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Timer,
|
||||||
|
"新增定时任务: uid={}, interval={}min",
|
||||||
|
uid,
|
||||||
|
interval
|
||||||
|
);
|
||||||
diff_map.insert(uid.clone(), DiffFlag::Add(next_id, interval));
|
diff_map.insert(uid.clone(), DiffFlag::Add(next_id, interval));
|
||||||
next_id += 1;
|
next_id += 1;
|
||||||
}
|
}
|
||||||
@@ -260,6 +320,7 @@ impl Timer {
|
|||||||
*self.timer_count.lock() = next_id;
|
*self.timer_count.lock() = next_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logging!(debug, Type::Timer, "定时任务变更数量: {}", diff_map.len());
|
||||||
diff_map
|
diff_map
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,42 +361,118 @@ impl Timer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get next update time for a profile
|
||||||
|
pub fn get_next_update_time(&self, uid: &str) -> Option<i64> {
|
||||||
|
logging!(info, Type::Timer, "获取下次更新时间,uid={}", uid);
|
||||||
|
|
||||||
|
let timer_map = self.timer_map.read();
|
||||||
|
let task = match timer_map.get(uid) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
logging!(warn, Type::Timer, "找不到对应的定时任务,uid={}", uid);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the profile updated timestamp
|
||||||
|
let profiles_config = Config::profiles();
|
||||||
|
let profiles = profiles_config.latest();
|
||||||
|
let items = match profiles.get_items() {
|
||||||
|
Some(i) => i,
|
||||||
|
None => {
|
||||||
|
logging!(warn, Type::Timer, "获取配置列表失败");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let profile = match items.iter().find(|item| item.uid.as_deref() == Some(uid)) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
logging!(warn, Type::Timer, "找不到对应的配置,uid={}", uid);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = profile.updated.unwrap_or(0) as i64;
|
||||||
|
|
||||||
|
// Calculate next update time
|
||||||
|
if updated > 0 && task.interval_minutes > 0 {
|
||||||
|
let next_time = updated + (task.interval_minutes as i64 * 60);
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Timer,
|
||||||
|
"计算得到下次更新时间: {}, uid={}",
|
||||||
|
next_time,
|
||||||
|
uid
|
||||||
|
);
|
||||||
|
Some(next_time)
|
||||||
|
} else {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Timer,
|
||||||
|
"更新时间或间隔无效,updated={}, interval={}",
|
||||||
|
updated,
|
||||||
|
task.interval_minutes
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit update events for frontend notification
|
||||||
|
fn emit_update_event(_uid: &str, _is_start: bool) {
|
||||||
|
#[cfg(any(feature = "verge-dev", feature = "default"))]
|
||||||
|
{
|
||||||
|
if _is_start {
|
||||||
|
super::handle::Handle::notify_profile_update_started(_uid.to_string());
|
||||||
|
} else {
|
||||||
|
super::handle::Handle::notify_profile_update_completed(_uid.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Async task with better error handling and logging
|
/// Async task with better error handling and logging
|
||||||
async fn async_task(uid: String) {
|
async fn async_task(uid: String) {
|
||||||
let task_start = std::time::Instant::now();
|
let task_start = std::time::Instant::now();
|
||||||
logging!(info, Type::Timer, "Running timer task for profile: {}", uid);
|
logging!(info, Type::Timer, "Running timer task for profile: {}", uid);
|
||||||
|
|
||||||
// Update profile
|
match tokio::time::timeout(std::time::Duration::from_secs(40), async {
|
||||||
let profile_result = feat::update_profile(uid.clone(), None).await;
|
Self::emit_update_event(&uid, true);
|
||||||
|
|
||||||
match profile_result {
|
let is_current = Config::profiles().latest().current.as_ref() == Some(&uid);
|
||||||
Ok(_) => {
|
logging!(
|
||||||
// Update configuration
|
info,
|
||||||
match CoreManager::global().update_config().await {
|
Type::Timer,
|
||||||
Ok(_) => {
|
"配置 {} 是否为当前激活配置: {}",
|
||||||
let duration = task_start.elapsed().as_millis();
|
uid,
|
||||||
logging!(
|
is_current
|
||||||
info,
|
);
|
||||||
Type::Timer,
|
|
||||||
"Timer task completed successfully for uid: {} (took {}ms)",
|
feat::update_profile(uid.clone(), None, Some(is_current)).await
|
||||||
uid,
|
})
|
||||||
duration
|
.await
|
||||||
);
|
{
|
||||||
}
|
Ok(result) => match result {
|
||||||
Err(e) => {
|
Ok(_) => {
|
||||||
logging_error!(
|
let duration = task_start.elapsed().as_millis();
|
||||||
Type::Timer,
|
logging!(
|
||||||
"Failed to refresh config after profile update for uid {}: {}",
|
info,
|
||||||
uid,
|
Type::Timer,
|
||||||
e
|
"Timer task completed successfully for uid: {} (took {}ms)",
|
||||||
);
|
uid,
|
||||||
}
|
duration
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(e) => {
|
logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e);
|
||||||
logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e);
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
logging_error!(Type::Timer, false, "Timer task timed out for uid: {}", uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit completed event
|
||||||
|
Self::emit_update_event(&uid, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+341
-119
@@ -5,28 +5,35 @@ pub mod speed_rate;
|
|||||||
use crate::{
|
use crate::{
|
||||||
cmd,
|
cmd,
|
||||||
config::Config,
|
config::Config,
|
||||||
feat,
|
feat, logging,
|
||||||
module::{lightweight::entry_lightweight_mode, mihomo::Rate},
|
module::{
|
||||||
|
lightweight::{entry_lightweight_mode, is_in_lightweight_mode},
|
||||||
|
mihomo::Rate,
|
||||||
|
},
|
||||||
resolve,
|
resolve,
|
||||||
utils::{dirs::find_target_icons, i18n::t, logging::Type, resolve::VERSION},
|
utils::{dirs::find_target_icons, i18n::t, resolve::VERSION},
|
||||||
|
Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub use speed_rate::{SpeedRate, Traffic};
|
pub use speed_rate::{SpeedRate, Traffic};
|
||||||
use std::fs;
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconEvent},
|
||||||
App, AppHandle, Wry,
|
AppHandle, Wry,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -42,10 +49,15 @@ pub struct Tray {
|
|||||||
shutdown_tx: Arc<RwLock<Option<broadcast::Sender<()>>>>,
|
shutdown_tx: Arc<RwLock<Option<broadcast::Sender<()>>>>,
|
||||||
is_subscribed: Arc<RwLock<bool>>,
|
is_subscribed: Arc<RwLock<bool>>,
|
||||||
pub rate_cache: Arc<Mutex<Option<Rate>>>,
|
pub rate_cache: Arc<Mutex<Option<Rate>>>,
|
||||||
|
last_menu_update: Mutex<Option<Instant>>,
|
||||||
|
menu_updating: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
pub struct Tray {}
|
pub struct Tray {
|
||||||
|
last_menu_update: Mutex<Option<Instant>>,
|
||||||
|
menu_updating: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
impl TrayState {
|
impl TrayState {
|
||||||
pub fn get_common_tray_icon() -> (bool, Vec<u8>) {
|
pub fn get_common_tray_icon() -> (bool, Vec<u8>) {
|
||||||
@@ -160,10 +172,15 @@ impl Tray {
|
|||||||
shutdown_tx: Arc::new(RwLock::new(None)),
|
shutdown_tx: Arc::new(RwLock::new(None)),
|
||||||
is_subscribed: Arc::new(RwLock::new(false)),
|
is_subscribed: Arc::new(RwLock::new(false)),
|
||||||
rate_cache: Arc::new(Mutex::new(None)),
|
rate_cache: Arc::new(Mutex::new(None)),
|
||||||
|
last_menu_update: Mutex::new(None),
|
||||||
|
menu_updating: AtomicBool::new(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
return TRAY.get_or_init(|| Tray {});
|
return TRAY.get_or_init(|| Tray {
|
||||||
|
last_menu_update: Mutex::new(None),
|
||||||
|
menu_updating: AtomicBool::new(false),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(&self) -> Result<()> {
|
pub fn init(&self) -> Result<()> {
|
||||||
@@ -175,45 +192,6 @@ impl Tray {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_systray(&self, app: &App) -> Result<()> {
|
|
||||||
let mut builder = TrayIconBuilder::with_id("main")
|
|
||||||
.icon(app.default_window_icon().unwrap().clone())
|
|
||||||
.icon_as_template(false);
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
||||||
{
|
|
||||||
let tray_event = { Config::verge().latest().tray_event.clone() };
|
|
||||||
let tray_event: String = tray_event.unwrap_or("main_window".into());
|
|
||||||
if tray_event.as_str() != "tray_menu" {
|
|
||||||
builder = builder.show_menu_on_left_click(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tray = builder.build(app)?;
|
|
||||||
|
|
||||||
tray.on_tray_icon_event(|_, event| {
|
|
||||||
let tray_event = { Config::verge().latest().tray_event.clone() };
|
|
||||||
let tray_event: String = tray_event.unwrap_or("main_window".into());
|
|
||||||
log::debug!(target: "app","tray event: {:?}", tray_event);
|
|
||||||
|
|
||||||
if let TrayIconEvent::Click {
|
|
||||||
button: MouseButton::Left,
|
|
||||||
button_state: MouseButtonState::Down,
|
|
||||||
..
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
match tray_event.as_str() {
|
|
||||||
"system_proxy" => feat::toggle_system_proxy(),
|
|
||||||
"tun_mode" => feat::toggle_tun_mode(None),
|
|
||||||
"main_window" => resolve::create_window(true),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tray.on_menu_event(on_menu_event);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新托盘点击行为
|
/// 更新托盘点击行为
|
||||||
pub fn update_click_behavior(&self) -> Result<()> {
|
pub fn update_click_behavior(&self) -> Result<()> {
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||||
@@ -227,9 +205,50 @@ impl Tray {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新托盘菜单
|
/// 更新托盘菜单(带频率限制)
|
||||||
pub fn update_menu(&self) -> Result<()> {
|
pub fn update_menu(&self) -> Result<()> {
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
// 检查是否正在更新或距离上次更新太近
|
||||||
|
const MIN_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
// 检查是否已有更新任务在执行
|
||||||
|
if self.menu_updating.load(Ordering::Acquire) {
|
||||||
|
log::debug!(target: "app", "托盘菜单正在更新中,跳过本次更新");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查更新频率
|
||||||
|
{
|
||||||
|
let last_update = self.last_menu_update.lock();
|
||||||
|
if let Some(last_time) = *last_update {
|
||||||
|
if last_time.elapsed() < MIN_UPDATE_INTERVAL {
|
||||||
|
log::debug!(target: "app", "托盘菜单更新频率过高,跳过本次更新");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_handle = match handle::Handle::global().app_handle() {
|
||||||
|
Some(handle) => handle,
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "更新托盘菜单失败: app_handle不存在");
|
||||||
|
return Ok(()); // 早期返回,避免panic
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置更新状态
|
||||||
|
self.menu_updating.store(true, Ordering::Release);
|
||||||
|
|
||||||
|
let result = self.update_menu_internal(&app_handle);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut last_update = self.last_menu_update.lock();
|
||||||
|
*last_update = Some(Instant::now());
|
||||||
|
}
|
||||||
|
self.menu_updating.store(false, Ordering::Release);
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn update_menu_internal(&self, app_handle: &AppHandle) -> Result<()> {
|
||||||
let verge = Config::verge().latest().clone();
|
let verge = Config::verge().latest().clone();
|
||||||
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||||
@@ -246,27 +265,50 @@ impl Tray {
|
|||||||
.data()
|
.data()
|
||||||
.all_profile_uid_and_name()
|
.all_profile_uid_and_name()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let is_lightweight_mode = is_in_lightweight_mode();
|
||||||
|
|
||||||
let tray = app_handle.tray_by_id("main").unwrap();
|
match app_handle.tray_by_id("main") {
|
||||||
let _ = tray.set_menu(Some(create_tray_menu(
|
Some(tray) => {
|
||||||
&app_handle,
|
let _ = tray.set_menu(Some(create_tray_menu(
|
||||||
Some(mode.as_str()),
|
&app_handle,
|
||||||
*system_proxy,
|
Some(mode.as_str()),
|
||||||
*tun_mode,
|
*system_proxy,
|
||||||
profile_uid_and_name,
|
*tun_mode,
|
||||||
)?));
|
profile_uid_and_name,
|
||||||
Ok(())
|
is_lightweight_mode,
|
||||||
|
)?));
|
||||||
|
log::debug!(target: "app", "托盘菜单更新成功");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "更新托盘菜单失败: 托盘不存在");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新托盘图标
|
/// 更新托盘图标
|
||||||
pub fn update_icon(&self, rate: Option<Rate>) -> Result<()> {
|
pub fn update_icon(&self, rate: Option<Rate>) -> Result<()> {
|
||||||
|
let app_handle = match handle::Handle::global().app_handle() {
|
||||||
|
Some(handle) => handle,
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "更新托盘图标失败: app_handle不存在");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tray = match app_handle.tray_by_id("main") {
|
||||||
|
Some(tray) => tray,
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "更新托盘图标失败: 托盘不存在");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let verge = Config::verge().latest().clone();
|
let verge = Config::verge().latest().clone();
|
||||||
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
let system_mode = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||||
|
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
|
||||||
let tray = app_handle.tray_by_id("main").unwrap();
|
|
||||||
|
|
||||||
let (is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
|
let (is_custom_icon, icon_bytes) = match (*system_mode, *tun_mode) {
|
||||||
(true, true) => TrayState::get_tun_tray_icon(),
|
(true, true) => TrayState::get_tun_tray_icon(),
|
||||||
(true, false) => TrayState::get_sysproxy_tray_icon(),
|
(true, false) => TrayState::get_sysproxy_tray_icon(),
|
||||||
@@ -275,7 +317,7 @@ impl Tray {
|
|||||||
};
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let enable_tray_speed = verge.enable_tray_speed.unwrap_or(true);
|
let enable_tray_speed = verge.enable_tray_speed.unwrap_or(false);
|
||||||
let enable_tray_icon = verge.enable_tray_icon.unwrap_or(true);
|
let enable_tray_icon = verge.enable_tray_icon.unwrap_or(true);
|
||||||
let colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string());
|
let colorful = verge.tray_icon.clone().unwrap_or("monochrome".to_string());
|
||||||
let is_colorful = colorful == "colorful";
|
let is_colorful = colorful == "colorful";
|
||||||
@@ -290,8 +332,12 @@ impl Tray {
|
|||||||
Some(rate)
|
Some(rate)
|
||||||
} else {
|
} else {
|
||||||
let guard = self.speed_rate.lock();
|
let guard = self.speed_rate.lock();
|
||||||
if let Some(rate) = guard.as_ref().unwrap().get_curent_rate() {
|
if let Some(guard) = guard.as_ref() {
|
||||||
Some(rate)
|
if let Some(rate) = guard.get_curent_rate() {
|
||||||
|
Some(rate)
|
||||||
|
} else {
|
||||||
|
Some(Rate::default())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Some(Rate::default())
|
Some(Rate::default())
|
||||||
}
|
}
|
||||||
@@ -308,9 +354,10 @@ impl Tray {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let rate = rate_guard.as_ref();
|
let rate = rate_guard.as_ref();
|
||||||
let rate_bytes = SpeedRate::add_speed_text(is_custom_icon, bytes, rate).unwrap();
|
if let Ok(rate_bytes) = SpeedRate::add_speed_text(is_custom_icon, bytes, rate) {
|
||||||
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&rate_bytes)?));
|
let _ = tray.set_icon(Some(tauri::image::Image::from_bytes(&rate_bytes)?));
|
||||||
let _ = tray.set_icon_as_template(!is_custom_icon && !is_colorful);
|
let _ = tray.set_icon_as_template(!is_custom_icon && !is_colorful);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -324,8 +371,21 @@ impl Tray {
|
|||||||
|
|
||||||
/// 更新托盘提示
|
/// 更新托盘提示
|
||||||
pub fn update_tooltip(&self) -> Result<()> {
|
pub fn update_tooltip(&self) -> Result<()> {
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
let app_handle = match handle::Handle::global().app_handle() {
|
||||||
let version = VERSION.get().unwrap();
|
Some(handle) => handle,
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "更新托盘提示失败: app_handle不存在");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let version = match VERSION.get() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "更新托盘提示失败: 版本信息不存在");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let verge = Config::verge().latest().clone();
|
let verge = Config::verge().latest().clone();
|
||||||
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||||
@@ -342,23 +402,28 @@ impl Tray {
|
|||||||
let profiles = Config::profiles();
|
let profiles = Config::profiles();
|
||||||
let profiles = profiles.latest();
|
let profiles = profiles.latest();
|
||||||
if let Some(current_profile_uid) = profiles.get_current() {
|
if let Some(current_profile_uid) = profiles.get_current() {
|
||||||
let current_profile = profiles.get_item(¤t_profile_uid);
|
if let Ok(profile) = profiles.get_item(¤t_profile_uid) {
|
||||||
current_profile_name = match ¤t_profile.unwrap().name {
|
current_profile_name = match &profile.name {
|
||||||
Some(profile_name) => profile_name.to_string(),
|
Some(profile_name) => profile_name.to_string(),
|
||||||
None => current_profile_name,
|
None => current_profile_name,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let tray = app_handle.tray_by_id("main").unwrap();
|
if let Some(tray) = app_handle.tray_by_id("main") {
|
||||||
let _ = tray.set_tooltip(Some(&format!(
|
let _ = tray.set_tooltip(Some(&format!(
|
||||||
"Clash Verge {version}\n{}: {}\n{}: {}\n{}: {}",
|
"Clash Verge {version}\n{}: {}\n{}: {}\n{}: {}",
|
||||||
t("SysProxy"),
|
t("SysProxy"),
|
||||||
switch_map[system_proxy],
|
switch_map[system_proxy],
|
||||||
t("TUN"),
|
t("TUN"),
|
||||||
switch_map[tun_mode],
|
switch_map[tun_mode],
|
||||||
t("Profile"),
|
t("Profile"),
|
||||||
current_profile_name
|
current_profile_name
|
||||||
)));
|
)));
|
||||||
|
} else {
|
||||||
|
log::warn!(target: "app", "更新托盘提示失败: 托盘不存在");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,52 +442,134 @@ impl Tray {
|
|||||||
// 如果已经订阅,先取消订阅
|
// 如果已经订阅,先取消订阅
|
||||||
if *self.is_subscribed.read() {
|
if *self.is_subscribed.read() {
|
||||||
self.unsubscribe_traffic();
|
self.unsubscribe_traffic();
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
|
let (shutdown_tx, shutdown_rx) = broadcast::channel(3);
|
||||||
*self.shutdown_tx.write() = Some(shutdown_tx);
|
*self.shutdown_tx.write() = Some(shutdown_tx);
|
||||||
*self.is_subscribed.write() = true;
|
*self.is_subscribed.write() = true;
|
||||||
|
|
||||||
let speed_rate = Arc::clone(&self.speed_rate);
|
let speed_rate = Arc::clone(&self.speed_rate);
|
||||||
let is_subscribed = Arc::clone(&self.is_subscribed);
|
let is_subscribed = Arc::clone(&self.is_subscribed);
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
// 使用单线程防止阻塞主线程
|
||||||
let mut shutdown = shutdown_rx;
|
std::thread::Builder::new()
|
||||||
|
.name("traffic-monitor".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build tokio runtime for traffic monitor");
|
||||||
|
// 在单独的运行时中执行异步任务
|
||||||
|
rt.block_on(async move {
|
||||||
|
let mut shutdown = shutdown_rx;
|
||||||
|
let speed_rate = speed_rate.clone();
|
||||||
|
let is_subscribed = is_subscribed.clone();
|
||||||
|
let mut consecutive_errors = 0;
|
||||||
|
let max_consecutive_errors = 5;
|
||||||
|
|
||||||
'outer: loop {
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
|
||||||
match Traffic::get_traffic_stream().await {
|
|
||||||
Ok(mut stream) => loop {
|
'outer: loop {
|
||||||
tokio::select! {
|
if !*is_subscribed.read() {
|
||||||
Some(traffic) = stream.next() => {
|
log::info!(target: "app", "Traffic subscription has been cancelled");
|
||||||
if let Ok(traffic) = traffic {
|
break;
|
||||||
let guard = speed_rate.lock();
|
}
|
||||||
let enable_tray_speed: bool = Config::verge().latest().enable_tray_speed.unwrap_or(true);
|
|
||||||
if !enable_tray_speed {
|
match tokio::time::timeout(
|
||||||
continue;
|
std::time::Duration::from_secs(5),
|
||||||
}
|
Traffic::get_traffic_stream()
|
||||||
if let Some(sr) = guard.as_ref() {
|
).await {
|
||||||
if let Some(rate) = sr.update_and_check_changed(traffic.up, traffic.down) {
|
Ok(stream_result) => {
|
||||||
let _ = Tray::global().update_icon(Some(rate));
|
match stream_result {
|
||||||
|
Ok(mut stream) => {
|
||||||
|
consecutive_errors = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
traffic_result = stream.next() => {
|
||||||
|
match traffic_result {
|
||||||
|
Some(Ok(traffic)) => {
|
||||||
|
if let Ok(Some(rate)) = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(50),
|
||||||
|
async {
|
||||||
|
let guard = speed_rate.try_lock();
|
||||||
|
if let Some(guard) = guard {
|
||||||
|
if let Some(sr) = guard.as_ref() {
|
||||||
|
sr.update_and_check_changed(traffic.up, traffic.down)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await {
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
async { let _ = Tray::global().update_icon(Some(rate)); }
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(Err(e)) => {
|
||||||
|
log::error!(target: "app", "Traffic stream error: {}", e);
|
||||||
|
consecutive_errors += 1;
|
||||||
|
if consecutive_errors >= max_consecutive_errors {
|
||||||
|
log::error!(target: "app", "Too many errors, reconnecting traffic stream");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
log::info!(target: "app", "Traffic stream ended, reconnecting");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ = shutdown.recv() => {
|
||||||
|
log::info!(target: "app", "Received shutdown signal for traffic stream");
|
||||||
|
break 'outer;
|
||||||
|
},
|
||||||
|
_ = interval.tick() => {
|
||||||
|
if !*is_subscribed.read() {
|
||||||
|
log::info!(target: "app", "Traffic monitor detected subscription cancelled");
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
log::debug!(target: "app", "Traffic subscription periodic health check");
|
||||||
|
},
|
||||||
|
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
|
||||||
|
log::info!(target: "app", "Traffic stream max active time reached, reconnecting");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(target: "app", "Failed to get traffic stream: {}", e);
|
||||||
|
consecutive_errors += 1;
|
||||||
|
if consecutive_errors >= max_consecutive_errors {
|
||||||
|
log::error!(target: "app", "Too many consecutive errors, pausing traffic monitoring");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||||
|
consecutive_errors = 0;
|
||||||
|
} else {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
log::error!(target: "app", "Traffic stream initialization timed out");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
}
|
}
|
||||||
_ = shutdown.recv() => break 'outer,
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
log::error!(target: "app", "Failed to get traffic stream: {}", e);
|
|
||||||
// 如果获取流失败,等待一段时间后重试
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
|
|
||||||
// 检查是否应该继续重试
|
|
||||||
if !*is_subscribed.read() {
|
if !*is_subscribed.read() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
log::info!(target: "app", "Traffic subscription thread terminated");
|
||||||
}
|
});
|
||||||
});
|
})
|
||||||
|
.expect("Failed to spawn traffic monitor thread");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -436,6 +583,57 @@ impl Tray {
|
|||||||
drop(tx);
|
drop(tx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> {
|
||||||
|
log::info!(target: "app", "正在从AppHandle创建系统托盘");
|
||||||
|
|
||||||
|
// 获取图标
|
||||||
|
let icon_bytes = TrayState::get_common_tray_icon().1;
|
||||||
|
let icon = tauri::image::Image::from_bytes(&icon_bytes)?;
|
||||||
|
|
||||||
|
let mut builder = TrayIconBuilder::with_id("main")
|
||||||
|
.icon(icon)
|
||||||
|
.icon_as_template(false);
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let tray_event = { Config::verge().latest().tray_event.clone() };
|
||||||
|
let tray_event: String = tray_event.unwrap_or("main_window".into());
|
||||||
|
if tray_event.as_str() != "tray_menu" {
|
||||||
|
builder = builder.show_menu_on_left_click(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tray = builder.build(app_handle)?;
|
||||||
|
|
||||||
|
tray.on_tray_icon_event(|_, event| {
|
||||||
|
let tray_event = { Config::verge().latest().tray_event.clone() };
|
||||||
|
let tray_event: String = tray_event.unwrap_or("main_window".into());
|
||||||
|
log::debug!(target: "app","tray event: {:?}", tray_event);
|
||||||
|
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Down,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
match tray_event.as_str() {
|
||||||
|
"system_proxy" => feat::toggle_system_proxy(),
|
||||||
|
"tun_mode" => feat::toggle_tun_mode(None),
|
||||||
|
"main_window" => {
|
||||||
|
if crate::module::lightweight::is_in_lightweight_mode() {
|
||||||
|
crate::module::lightweight::exit_lightweight_mode();
|
||||||
|
}
|
||||||
|
let _ = resolve::create_window(true);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tray.on_menu_event(on_menu_event);
|
||||||
|
log::info!(target: "app", "系统托盘创建成功");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_tray_menu(
|
fn create_tray_menu(
|
||||||
@@ -444,9 +642,13 @@ fn create_tray_menu(
|
|||||||
system_proxy_enabled: bool,
|
system_proxy_enabled: bool,
|
||||||
tun_mode_enabled: bool,
|
tun_mode_enabled: bool,
|
||||||
profile_uid_and_name: Vec<(String, String)>,
|
profile_uid_and_name: Vec<(String, String)>,
|
||||||
|
is_lightweight_mode: bool,
|
||||||
) -> Result<tauri::menu::Menu<Wry>> {
|
) -> Result<tauri::menu::Menu<Wry>> {
|
||||||
let mode = mode.unwrap_or("");
|
let mode = mode.unwrap_or("");
|
||||||
let version = VERSION.get().unwrap();
|
|
||||||
|
let unknown_version = String::from("unknown");
|
||||||
|
let version = VERSION.get().unwrap_or(&unknown_version);
|
||||||
|
|
||||||
let hotkeys = Config::verge()
|
let hotkeys = Config::verge()
|
||||||
.latest()
|
.latest()
|
||||||
.hotkeys
|
.hotkeys
|
||||||
@@ -554,11 +756,12 @@ fn create_tray_menu(
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let lighteweight_mode = &MenuItem::with_id(
|
let lighteweight_mode = &CheckMenuItem::with_id(
|
||||||
app_handle,
|
app_handle,
|
||||||
"entry_lightweight_mode",
|
"entry_lightweight_mode",
|
||||||
t("LightWeight Mode"),
|
t("LightWeight Mode"),
|
||||||
true,
|
true,
|
||||||
|
is_lightweight_mode,
|
||||||
hotkeys.get("entry_lightweight_mode").map(|s| s.as_str()),
|
hotkeys.get("entry_lightweight_mode").map(|s| s.as_str()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -672,21 +875,40 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
|||||||
match event.id.as_ref() {
|
match event.id.as_ref() {
|
||||||
mode @ ("rule_mode" | "global_mode" | "direct_mode") => {
|
mode @ ("rule_mode" | "global_mode" | "direct_mode") => {
|
||||||
let mode = &mode[0..mode.len() - 5];
|
let mode = &mode[0..mode.len() - 5];
|
||||||
println!("change mode to: {}", mode);
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::ProxyMode,
|
||||||
|
true,
|
||||||
|
"Switch Proxy Mode To: {}",
|
||||||
|
mode
|
||||||
|
);
|
||||||
feat::change_clash_mode(mode.into());
|
feat::change_clash_mode(mode.into());
|
||||||
}
|
}
|
||||||
"open_window" => resolve::create_window(true),
|
"open_window" => {
|
||||||
|
// 如果在轻量模式中,先退出轻量模式
|
||||||
|
if crate::module::lightweight::is_in_lightweight_mode() {
|
||||||
|
crate::module::lightweight::exit_lightweight_mode();
|
||||||
|
}
|
||||||
|
// 然后创建窗口
|
||||||
|
let _ = resolve::create_window(true);
|
||||||
|
}
|
||||||
"system_proxy" => feat::toggle_system_proxy(),
|
"system_proxy" => feat::toggle_system_proxy(),
|
||||||
"tun_mode" => feat::toggle_tun_mode(None),
|
"tun_mode" => feat::toggle_tun_mode(None),
|
||||||
"copy_env" => feat::copy_clash_env(),
|
"copy_env" => feat::copy_clash_env(),
|
||||||
"open_app_dir" => crate::logging_error!(Type::Cmd, true, cmd::open_app_dir()),
|
"open_app_dir" => {
|
||||||
"open_core_dir" => crate::logging_error!(Type::Cmd, true, cmd::open_core_dir()),
|
let _ = cmd::open_app_dir();
|
||||||
"open_logs_dir" => crate::logging_error!(Type::Cmd, true, cmd::open_logs_dir()),
|
}
|
||||||
|
"open_core_dir" => {
|
||||||
|
let _ = cmd::open_core_dir();
|
||||||
|
}
|
||||||
|
"open_logs_dir" => {
|
||||||
|
let _ = cmd::open_logs_dir();
|
||||||
|
}
|
||||||
"restart_clash" => feat::restart_clash_core(),
|
"restart_clash" => feat::restart_clash_core(),
|
||||||
"restart_app" => feat::restart_app(),
|
"restart_app" => feat::restart_app(),
|
||||||
"entry_lightweight_mode" => entry_lightweight_mode(),
|
"entry_lightweight_mode" => entry_lightweight_mode(),
|
||||||
"quit" => {
|
"quit" => {
|
||||||
feat::quit(Some(0));
|
feat::quit();
|
||||||
}
|
}
|
||||||
id if id.starts_with("profiles_") => {
|
id if id.starts_with("profiles_") => {
|
||||||
let profile_index = &id["profiles_".len()..];
|
let profile_index = &id["profiles_".len()..];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use image::{GenericImageView, Rgba, RgbaImage};
|
|||||||
use imageproc::drawing::draw_text_mut;
|
use imageproc::drawing::draw_text_mut;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::{io::Cursor, sync::Arc};
|
use std::{io::Cursor, sync::Arc};
|
||||||
use tokio_tungstenite::tungstenite::{http, Message};
|
use tokio_tungstenite::tungstenite::http;
|
||||||
use tungstenite::client::IntoClientRequest;
|
use tungstenite::client::IntoClientRequest;
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SpeedRate {
|
pub struct SpeedRate {
|
||||||
@@ -240,41 +240,92 @@ pub struct Traffic {
|
|||||||
impl Traffic {
|
impl Traffic {
|
||||||
pub async fn get_traffic_stream() -> Result<impl Stream<Item = Result<Traffic, anyhow::Error>>>
|
pub async fn get_traffic_stream() -> Result<impl Stream<Item = Result<Traffic, anyhow::Error>>>
|
||||||
{
|
{
|
||||||
use futures::stream::{self, StreamExt};
|
use futures::{
|
||||||
|
future::FutureExt,
|
||||||
|
stream::{self, StreamExt},
|
||||||
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// 先处理错误和超时情况
|
||||||
let stream = Box::pin(
|
let stream = Box::pin(
|
||||||
stream::unfold((), |_| async {
|
stream::unfold((), move |_| async move {
|
||||||
loop {
|
'retry: loop {
|
||||||
|
log::info!(target: "app", "establishing traffic websocket connection");
|
||||||
let (url, token) = MihomoManager::get_traffic_ws_url();
|
let (url, token) = MihomoManager::get_traffic_ws_url();
|
||||||
let mut request = url.into_client_request().unwrap();
|
let mut request = match url.into_client_request() {
|
||||||
request
|
Ok(req) => req,
|
||||||
.headers_mut()
|
|
||||||
.insert(http::header::AUTHORIZATION, token);
|
|
||||||
|
|
||||||
match tokio_tungstenite::connect_async(request).await {
|
|
||||||
Ok((ws_stream, _)) => {
|
|
||||||
log::info!(target: "app", "traffic ws connection established");
|
|
||||||
return Some((
|
|
||||||
ws_stream.map(|msg| {
|
|
||||||
msg.map_err(anyhow::Error::from).and_then(|msg: Message| {
|
|
||||||
let data = msg.into_text()?;
|
|
||||||
let json: serde_json::Value = serde_json::from_str(&data)?;
|
|
||||||
Ok(Traffic {
|
|
||||||
up: json["up"].as_u64().unwrap_or(0),
|
|
||||||
down: json["down"].as_u64().unwrap_or(0),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!(target: "app", "traffic ws connection failed: {e}");
|
log::error!(target: "app", "failed to create websocket request: {}", e);
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
continue;
|
continue 'retry;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.headers_mut().insert(http::header::AUTHORIZATION, token);
|
||||||
|
|
||||||
|
match tokio::time::timeout(Duration::from_secs(3),
|
||||||
|
tokio_tungstenite::connect_async(request)
|
||||||
|
).await {
|
||||||
|
Ok(Ok((ws_stream, _))) => {
|
||||||
|
log::info!(target: "app", "traffic websocket connection established");
|
||||||
|
// 设置流超时控制
|
||||||
|
let traffic_stream = ws_stream
|
||||||
|
.take_while(|msg| {
|
||||||
|
let continue_stream = msg.is_ok();
|
||||||
|
async move { continue_stream }.boxed()
|
||||||
|
})
|
||||||
|
.filter_map(|msg| async move {
|
||||||
|
match msg {
|
||||||
|
Ok(msg) => {
|
||||||
|
if !msg.is_text() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(
|
||||||
|
Duration::from_millis(200),
|
||||||
|
async { msg.into_text() }
|
||||||
|
).await {
|
||||||
|
Ok(Ok(text)) => {
|
||||||
|
match serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
|
Ok(json) => {
|
||||||
|
let up = json["up"].as_u64().unwrap_or(0);
|
||||||
|
let down = json["down"].as_u64().unwrap_or(0);
|
||||||
|
Some(Ok(Traffic { up, down }))
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(target: "app", "traffic json parse error: {} for {}", e, text);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::warn!(target: "app", "traffic text conversion error: {}", e);
|
||||||
|
None
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!(target: "app", "traffic text processing timeout");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(target: "app", "traffic websocket error: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Some((traffic_stream, ()));
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::error!(target: "app", "traffic websocket connection failed: {}", e);
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
log::error!(target: "app", "traffic websocket connection timed out");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.flatten(),
|
.flatten(),
|
||||||
|
|||||||
@@ -261,16 +261,29 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
|||||||
use crate::utils::dirs;
|
use crate::utils::dirs;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
// 尝试读取dns_config.yaml
|
|
||||||
if let Ok(app_dir) = dirs::app_home_dir() {
|
if let Ok(app_dir) = dirs::app_home_dir() {
|
||||||
let dns_path = app_dir.join("dns_config.yaml");
|
let dns_path = app_dir.join("dns_config.yaml");
|
||||||
|
|
||||||
if dns_path.exists() {
|
if dns_path.exists() {
|
||||||
if let Ok(dns_yaml) = fs::read_to_string(&dns_path) {
|
if let Ok(dns_yaml) = fs::read_to_string(&dns_path) {
|
||||||
if let Ok(dns_config) = serde_yaml::from_str::<serde_yaml::Mapping>(&dns_yaml) {
|
if let Ok(dns_config) = serde_yaml::from_str::<serde_yaml::Mapping>(&dns_yaml) {
|
||||||
// 将DNS配置合并到最终配置中
|
// 处理hosts配置
|
||||||
config.insert("dns".into(), dns_config.into());
|
if let Some(hosts_value) = dns_config.get("hosts") {
|
||||||
log::info!(target: "app", "apply dns_config.yaml");
|
if hosts_value.is_mapping() {
|
||||||
|
config.insert("hosts".into(), hosts_value.clone());
|
||||||
|
log::info!(target: "app", "apply hosts configuration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(dns_value) = dns_config.get("dns") {
|
||||||
|
if let Some(dns_mapping) = dns_value.as_mapping() {
|
||||||
|
config.insert("dns".into(), dns_mapping.clone().into());
|
||||||
|
log::info!(target: "app", "apply dns_config.yaml (dns section)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.insert("dns".into(), dns_config.into());
|
||||||
|
log::info!(target: "app", "apply dns_config.yaml");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
pub mod service;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
+24
-35
@@ -3,6 +3,7 @@ use crate::{
|
|||||||
core::{handle, tray, CoreManager},
|
core::{handle, tray, CoreManager},
|
||||||
logging_error,
|
logging_error,
|
||||||
module::mihomo::MihomoManager,
|
module::mihomo::MihomoManager,
|
||||||
|
process::AsyncHandler,
|
||||||
utils::{logging::Type, resolve},
|
utils::{logging::Type, resolve},
|
||||||
};
|
};
|
||||||
use serde_yaml::{Mapping, Value};
|
use serde_yaml::{Mapping, Value};
|
||||||
@@ -10,7 +11,7 @@ use tauri::Manager;
|
|||||||
|
|
||||||
/// Restart the Clash core
|
/// Restart the Clash core
|
||||||
pub fn restart_clash_core() {
|
pub fn restart_clash_core() {
|
||||||
tauri::async_runtime::spawn(async {
|
AsyncHandler::spawn(move || async move {
|
||||||
match CoreManager::global().restart_core().await {
|
match CoreManager::global().restart_core().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
@@ -26,19 +27,17 @@ pub fn restart_clash_core() {
|
|||||||
|
|
||||||
/// Restart the application
|
/// Restart the application
|
||||||
pub fn restart_app() {
|
pub fn restart_app() {
|
||||||
tauri::async_runtime::spawn_blocking(|| {
|
AsyncHandler::spawn(move || async move {
|
||||||
tauri::async_runtime::block_on(async {
|
logging_error!(Type::Core, true, CoreManager::global().stop_core().await);
|
||||||
logging_error!(Type::Core, true, CoreManager::global().stop_core().await);
|
resolve::resolve_reset_async().await;
|
||||||
resolve::resolve_reset_async().await;
|
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
tauri::process::restart(&app_handle.env());
|
||||||
tauri::process::restart(&app_handle.env());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn after_change_clash_mode() {
|
fn after_change_clash_mode() {
|
||||||
tauri::async_runtime::spawn(async {
|
AsyncHandler::spawn(move || async {
|
||||||
match MihomoManager::global().get_connections().await {
|
match MihomoManager::global().get_connections().await {
|
||||||
Ok(connections) => {
|
Ok(connections) => {
|
||||||
if let Some(connections_array) = connections["connections"].as_array() {
|
if let Some(connections_array) = connections["connections"].as_array() {
|
||||||
@@ -64,7 +63,7 @@ pub fn change_clash_mode(mode: String) {
|
|||||||
let json_value = serde_json::json!({
|
let json_value = serde_json::json!({
|
||||||
"mode": mode
|
"mode": mode
|
||||||
});
|
});
|
||||||
tauri::async_runtime::spawn(async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
log::debug!(target: "app", "change clash mode to {mode}");
|
log::debug!(target: "app", "change clash mode to {mode}");
|
||||||
match MihomoManager::global().patch_configs(json_value).await {
|
match MihomoManager::global().patch_configs(json_value).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -92,36 +91,26 @@ pub fn change_clash_mode(mode: String) {
|
|||||||
|
|
||||||
/// Test connection delay to a URL
|
/// Test connection delay to a URL
|
||||||
pub async fn test_delay(url: String) -> anyhow::Result<u32> {
|
pub async fn test_delay(url: String) -> anyhow::Result<u32> {
|
||||||
use tokio::time::{Duration, Instant};
|
use crate::utils::network::{NetworkManager, ProxyType};
|
||||||
let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy();
|
use tokio::time::Instant;
|
||||||
|
|
||||||
let port = Config::verge()
|
|
||||||
.latest()
|
|
||||||
.verge_mixed_port
|
|
||||||
.unwrap_or(Config::clash().data().get_mixed_port());
|
|
||||||
let tun_mode = Config::verge().latest().enable_tun_mode.unwrap_or(false);
|
let tun_mode = Config::verge().latest().enable_tun_mode.unwrap_or(false);
|
||||||
|
|
||||||
let proxy_scheme = format!("http://127.0.0.1:{port}");
|
// 如果是TUN模式,不使用代理,否则使用自身代理
|
||||||
|
let proxy_type = if !tun_mode {
|
||||||
|
ProxyType::Localhost
|
||||||
|
} else {
|
||||||
|
ProxyType::None
|
||||||
|
};
|
||||||
|
|
||||||
if !tun_mode {
|
let user_agent = Some("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".to_string());
|
||||||
if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) {
|
|
||||||
builder = builder.proxy(proxy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let request = builder
|
|
||||||
.timeout(Duration::from_millis(10000))
|
|
||||||
.build()?
|
|
||||||
.get(url).header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0");
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
let response = request.send().await;
|
let response = NetworkManager::global()
|
||||||
|
.get_with_interrupt(&url, proxy_type, Some(10), user_agent, false)
|
||||||
|
.await;
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
log::trace!(target: "app", "test_delay response: {:#?}", response);
|
log::trace!(target: "app", "test_delay response: {:#?}", response);
|
||||||
@@ -133,7 +122,7 @@ pub async fn test_delay(url: String) -> anyhow::Result<u32> {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::trace!(target: "app", "test_delay error: {:#?}", err);
|
log::trace!(target: "app", "test_delay error: {:#?}", err);
|
||||||
Err(err.into())
|
Err(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ use crate::{
|
|||||||
cmd,
|
cmd,
|
||||||
config::{Config, PrfItem, PrfOption},
|
config::{Config, PrfItem, PrfOption},
|
||||||
core::{handle, CoreManager, *},
|
core::{handle, CoreManager, *},
|
||||||
|
process::AsyncHandler,
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
/// Toggle proxy profile
|
/// Toggle proxy profile
|
||||||
pub fn toggle_proxy_profile(profile_index: String) {
|
pub fn toggle_proxy_profile(profile_index: String) {
|
||||||
tauri::async_runtime::spawn(async move {
|
AsyncHandler::spawn(|| async move {
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||||
match cmd::patch_profiles_config_by_profile_index(app_handle, profile_index).await {
|
match cmd::patch_profiles_config_by_profile_index(app_handle, profile_index).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -22,8 +23,14 @@ pub fn toggle_proxy_profile(profile_index: String) {
|
|||||||
|
|
||||||
/// Update a profile
|
/// Update a profile
|
||||||
/// If updating current profile, activate it
|
/// If updating current profile, activate it
|
||||||
pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()> {
|
/// auto_refresh: 是否自动更新配置和刷新前端
|
||||||
|
pub async fn update_profile(
|
||||||
|
uid: String,
|
||||||
|
option: Option<PrfOption>,
|
||||||
|
auto_refresh: Option<bool>,
|
||||||
|
) -> Result<()> {
|
||||||
println!("[订阅更新] 开始更新订阅 {}", uid);
|
println!("[订阅更新] 开始更新订阅 {}", uid);
|
||||||
|
let auto_refresh = auto_refresh.unwrap_or(true); // 默认为true,保持兼容性
|
||||||
|
|
||||||
let url_opt = {
|
let url_opt = {
|
||||||
let profiles = Config::profiles();
|
let profiles = Config::profiles();
|
||||||
@@ -50,19 +57,75 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
|
|||||||
let should_update = match url_opt {
|
let should_update = match url_opt {
|
||||||
Some((url, opt)) => {
|
Some((url, opt)) => {
|
||||||
println!("[订阅更新] 开始下载新的订阅内容");
|
println!("[订阅更新] 开始下载新的订阅内容");
|
||||||
let merged_opt = PrfOption::merge(opt, option);
|
let merged_opt = PrfOption::merge(opt.clone(), option.clone());
|
||||||
let item = PrfItem::from_url(&url, None, None, merged_opt).await?;
|
|
||||||
|
|
||||||
println!("[订阅更新] 更新订阅配置");
|
// 尝试使用正常设置更新
|
||||||
let profiles = Config::profiles();
|
match PrfItem::from_url(&url, None, None, merged_opt.clone()).await {
|
||||||
let mut profiles = profiles.latest();
|
Ok(item) => {
|
||||||
profiles.update_item(uid.clone(), item)?;
|
println!("[订阅更新] 更新订阅配置成功");
|
||||||
|
let profiles = Config::profiles();
|
||||||
|
let mut profiles = profiles.latest();
|
||||||
|
profiles.update_item(uid.clone(), item)?;
|
||||||
|
|
||||||
let is_current = Some(uid.clone()) == profiles.get_current();
|
let is_current = Some(uid.clone()) == profiles.get_current();
|
||||||
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
|
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
|
||||||
is_current
|
is_current && auto_refresh
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// 首次更新失败,尝试使用Clash代理
|
||||||
|
println!("[订阅更新] 正常更新失败: {},尝试使用Clash代理更新", err);
|
||||||
|
|
||||||
|
// 发送通知
|
||||||
|
handle::Handle::notice_message("update_retry_with_clash", uid.clone());
|
||||||
|
|
||||||
|
// 保存原始代理设置
|
||||||
|
let original_with_proxy = merged_opt.as_ref().and_then(|o| o.with_proxy);
|
||||||
|
let original_self_proxy = merged_opt.as_ref().and_then(|o| o.self_proxy);
|
||||||
|
|
||||||
|
// 创建使用Clash代理的选项
|
||||||
|
let mut fallback_opt = merged_opt.unwrap_or_default();
|
||||||
|
fallback_opt.with_proxy = Some(false);
|
||||||
|
fallback_opt.self_proxy = Some(true);
|
||||||
|
|
||||||
|
// 使用Clash代理重试
|
||||||
|
match PrfItem::from_url(&url, None, None, Some(fallback_opt)).await {
|
||||||
|
Ok(mut item) => {
|
||||||
|
println!("[订阅更新] 使用Clash代理更新成功");
|
||||||
|
|
||||||
|
// 恢复原始代理设置到item
|
||||||
|
if let Some(option) = item.option.as_mut() {
|
||||||
|
option.with_proxy = original_with_proxy;
|
||||||
|
option.self_proxy = original_self_proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新到配置
|
||||||
|
let profiles = Config::profiles();
|
||||||
|
let mut profiles = profiles.latest();
|
||||||
|
profiles.update_item(uid.clone(), item.clone())?;
|
||||||
|
|
||||||
|
// 获取配置名称用于通知
|
||||||
|
let profile_name = item.name.clone().unwrap_or_else(|| uid.clone());
|
||||||
|
|
||||||
|
// 发送通知告知用户自动更新使用了回退机制
|
||||||
|
handle::Handle::notice_message("update_with_clash_proxy", profile_name);
|
||||||
|
|
||||||
|
let is_current = Some(uid.clone()) == profiles.get_current();
|
||||||
|
println!("[订阅更新] 是否为当前使用的订阅: {}", is_current);
|
||||||
|
is_current && auto_refresh
|
||||||
|
}
|
||||||
|
Err(retry_err) => {
|
||||||
|
println!("[订阅更新] 使用Clash代理更新仍然失败: {}", retry_err);
|
||||||
|
handle::Handle::notice_message(
|
||||||
|
"update_failed_even_with_clash",
|
||||||
|
format!("{}", retry_err),
|
||||||
|
);
|
||||||
|
return Err(retry_err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => true,
|
None => auto_refresh,
|
||||||
};
|
};
|
||||||
|
|
||||||
if should_update {
|
if should_update {
|
||||||
@@ -74,7 +137,7 @@ pub async fn update_profile(uid: String, option: Option<PrfOption>) -> Result<()
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("[订阅更新] 更新失败: {}", err);
|
println!("[订阅更新] 更新失败: {}", err);
|
||||||
handle::Handle::notice_message("set_config::error", format!("{err}"));
|
handle::Handle::notice_message("update_failed", format!("{err}"));
|
||||||
log::error!(target: "app", "{err}");
|
log::error!(target: "app", "{err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-18
@@ -1,6 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IVerge},
|
config::{Config, IVerge},
|
||||||
core::handle,
|
core::handle,
|
||||||
|
process::AsyncHandler,
|
||||||
};
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||||
@@ -9,8 +10,22 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
|
|||||||
pub fn toggle_system_proxy() {
|
pub fn toggle_system_proxy() {
|
||||||
let enable = Config::verge().draft().enable_system_proxy;
|
let enable = Config::verge().draft().enable_system_proxy;
|
||||||
let enable = enable.unwrap_or(false);
|
let enable = enable.unwrap_or(false);
|
||||||
|
let auto_close_connection = Config::verge()
|
||||||
|
.data()
|
||||||
|
.auto_close_connection
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
AsyncHandler::spawn(move || async move {
|
||||||
|
// 如果当前系统代理即将关闭,且自动关闭连接设置为true,则关闭所有连接
|
||||||
|
if enable && auto_close_connection {
|
||||||
|
if let Err(err) = crate::module::mihomo::MihomoManager::global()
|
||||||
|
.close_all_connections()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!(target: "app", "Failed to close all connections: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
match super::patch_verge(
|
match super::patch_verge(
|
||||||
IVerge {
|
IVerge {
|
||||||
enable_system_proxy: Some(!enable),
|
enable_system_proxy: Some(!enable),
|
||||||
@@ -28,23 +43,10 @@ pub fn toggle_system_proxy() {
|
|||||||
|
|
||||||
/// Toggle TUN mode on/off
|
/// Toggle TUN mode on/off
|
||||||
pub fn toggle_tun_mode(not_save_file: Option<bool>) {
|
pub fn toggle_tun_mode(not_save_file: Option<bool>) {
|
||||||
// tauri::async_runtime::spawn(async move {
|
|
||||||
// logging!(
|
|
||||||
// info,
|
|
||||||
// Type::Service,
|
|
||||||
// true,
|
|
||||||
// "Toggle TUN mode need install service"
|
|
||||||
// );
|
|
||||||
// if is_service_available().await.is_err() {
|
|
||||||
// logging_error!(Type::Service, true, install_service().await);
|
|
||||||
// }
|
|
||||||
// logging_error!(Type::Core, true, CoreManager::global().restart_core().await);
|
|
||||||
// });
|
|
||||||
|
|
||||||
let enable = Config::verge().data().enable_tun_mode;
|
let enable = Config::verge().data().enable_tun_mode;
|
||||||
let enable = enable.unwrap_or(false);
|
let enable = enable.unwrap_or(false);
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
AsyncHandler::spawn(async move || {
|
||||||
match super::patch_verge(
|
match super::patch_verge(
|
||||||
IVerge {
|
IVerge {
|
||||||
enable_tun_mode: Some(!enable),
|
enable_tun_mode: Some(!enable),
|
||||||
@@ -62,9 +64,14 @@ pub fn toggle_tun_mode(not_save_file: Option<bool>) {
|
|||||||
|
|
||||||
/// Copy proxy environment variables to clipboard
|
/// Copy proxy environment variables to clipboard
|
||||||
pub fn copy_clash_env() {
|
pub fn copy_clash_env() {
|
||||||
// 从环境变量获取IP地址,默认127.0.0.1
|
// 从环境变量获取IP地址,如果没有则从配置中获取 proxy_host,默认为 127.0.0.1
|
||||||
let clash_verge_rev_ip =
|
let clash_verge_rev_ip = env::var("CLASH_VERGE_REV_IP").unwrap_or_else(|_| {
|
||||||
env::var("CLASH_VERGE_REV_IP").unwrap_or_else(|_| "127.0.0.1".to_string());
|
Config::verge()
|
||||||
|
.latest()
|
||||||
|
.proxy_host
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "127.0.0.1".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
let app_handle = handle::Handle::global().app_handle().unwrap();
|
||||||
let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
|
let port = { Config::verge().latest().verge_mixed_port.unwrap_or(7897) };
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ pub fn open_or_close_dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 优化的应用退出函数
|
/// 优化的应用退出函数
|
||||||
pub fn quit(code: Option<i32>) {
|
pub fn quit() {
|
||||||
log::debug!(target: "app", "启动退出流程");
|
log::debug!(target: "app", "启动退出流程");
|
||||||
|
|
||||||
// 获取应用句柄并设置退出标志
|
// 获取应用句柄并设置退出标志
|
||||||
@@ -64,56 +64,70 @@ pub fn quit(code: Option<i32>) {
|
|||||||
|
|
||||||
// 在单独线程中处理资源清理,避免阻塞主线程
|
// 在单独线程中处理资源清理,避免阻塞主线程
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// 使用tokio运行时执行异步清理任务
|
let cleanup_result = clean();
|
||||||
tauri::async_runtime::block_on(async {
|
app_handle.exit(match cleanup_result {
|
||||||
// 使用超时机制处理清理操作
|
true => 0,
|
||||||
use tokio::time::{timeout, Duration};
|
false => 1,
|
||||||
|
|
||||||
// 1. 直接关闭TUN模式 (优先处理,通常最容易卡住)
|
|
||||||
if Config::verge().data().enable_tun_mode.unwrap_or(false) {
|
|
||||||
let disable = serde_json::json!({
|
|
||||||
"tun": {
|
|
||||||
"enable": false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 设置1秒超时
|
|
||||||
let _ = timeout(
|
|
||||||
Duration::from_secs(1),
|
|
||||||
MihomoManager::global().patch_configs(disable),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 并行处理系统代理和核心进程清理
|
|
||||||
let proxy_future = timeout(
|
|
||||||
Duration::from_secs(1),
|
|
||||||
sysopt::Sysopt::global().reset_sysproxy(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let core_future = timeout(Duration::from_secs(1), CoreManager::global().stop_core());
|
|
||||||
|
|
||||||
// 同时等待两个任务完成
|
|
||||||
let _ = futures::join!(proxy_future, core_future);
|
|
||||||
|
|
||||||
// 3. 处理macOS特定清理
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let _ = timeout(Duration::from_millis(500), resolve::restore_public_dns()).await;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 无论清理结果如何,确保应用退出
|
|
||||||
app_handle.exit(code.unwrap_or(0));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clean() -> bool {
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let cleanup_result = rt.block_on(async {
|
||||||
|
// 1. 处理TUN模式
|
||||||
|
let tun_success = if Config::verge().data().enable_tun_mode.unwrap_or(false) {
|
||||||
|
let disable_tun = serde_json::json!({
|
||||||
|
"tun": {
|
||||||
|
"enable": false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
MihomoManager::global().patch_configs(disable_tun),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 顺序执行关键清理
|
||||||
|
let proxy_res = timeout(
|
||||||
|
Duration::from_secs(1),
|
||||||
|
sysopt::Sysopt::global().reset_sysproxy(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let core_res = timeout(Duration::from_secs(1), CoreManager::global().stop_core()).await;
|
||||||
|
|
||||||
|
// 3. 平台特定清理
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let _dns_res = timeout(Duration::from_millis(500), resolve::restore_public_dns()).await;
|
||||||
|
|
||||||
|
tun_success && proxy_res.is_ok() && core_res.is_ok()
|
||||||
|
});
|
||||||
|
cleanup_result
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn hide() {
|
pub fn hide() {
|
||||||
|
use crate::module::lightweight::add_light_weight_timer;
|
||||||
|
|
||||||
|
let enable_auto_light_weight_mode = Config::verge()
|
||||||
|
.data()
|
||||||
|
.enable_auto_light_weight_mode
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if enable_auto_light_weight_mode {
|
||||||
|
add_light_weight_timer();
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(window) = handle::Handle::global().get_window() {
|
if let Some(window) = handle::Handle::global().get_window() {
|
||||||
if window.is_visible().unwrap_or(false) {
|
if window.is_visible().unwrap_or(false) {
|
||||||
AppHandleManager::global().set_activation_policy_accessory();
|
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppHandleManager::global().set_activation_policy_accessory();
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-49
@@ -2,21 +2,23 @@ mod cmd;
|
|||||||
mod config;
|
mod config;
|
||||||
mod core;
|
mod core;
|
||||||
mod enhance;
|
mod enhance;
|
||||||
mod error;
|
|
||||||
mod feat;
|
mod feat;
|
||||||
mod module;
|
mod module;
|
||||||
|
mod process;
|
||||||
|
mod state;
|
||||||
mod utils;
|
mod utils;
|
||||||
use crate::{
|
use crate::{
|
||||||
core::hotkey,
|
core::hotkey,
|
||||||
|
process::AsyncHandler,
|
||||||
utils::{resolve, resolve::resolve_scheme, server},
|
utils::{resolve, resolve::resolve_scheme, server},
|
||||||
};
|
};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use std::sync::{Mutex, Once};
|
use std::sync::{Mutex, Once};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
use tauri_plugin_deep_link::DeepLinkExt;
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
use utils::logging::Type;
|
use utils::logging::Type;
|
||||||
|
|
||||||
/// A global singleton handle to the application.
|
/// A global singleton handle to the application.
|
||||||
@@ -83,13 +85,32 @@ impl AppHandleManager {
|
|||||||
|
|
||||||
#[allow(clippy::panic)]
|
#[allow(clippy::panic)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
utils::network::NetworkManager::global().init();
|
||||||
|
|
||||||
|
let _ = utils::dirs::init_portable_flag();
|
||||||
|
|
||||||
// 单例检测
|
// 单例检测
|
||||||
let app_exists: bool = tauri::async_runtime::block_on(async move {
|
let app_exists: bool = AsyncHandler::block_on(move || async move {
|
||||||
if server::check_singleton().await.is_err() {
|
logging!(info, Type::Setup, true, "开始检查单例实例...");
|
||||||
println!("app exists");
|
match timeout(Duration::from_secs(3), server::check_singleton()).await {
|
||||||
true
|
Ok(result) => {
|
||||||
} else {
|
if result.is_err() {
|
||||||
false
|
logging!(info, Type::Setup, true, "检测到已有应用实例运行");
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
logging!(info, Type::Setup, true, "未检测到其他应用实例");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Setup,
|
||||||
|
true,
|
||||||
|
"单例检查超时,假定没有其他实例运行"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if app_exists {
|
if app_exists {
|
||||||
@@ -101,13 +122,9 @@ pub fn run() {
|
|||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
let devtools = tauri_plugin_devtools::init();
|
let devtools = tauri_plugin_devtools::init();
|
||||||
|
|
||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_autostart::init(
|
|
||||||
MacosLauncher::LaunchAgent,
|
|
||||||
None,
|
|
||||||
))
|
|
||||||
.plugin(tauri_plugin_window_state::Builder::new().build())
|
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
@@ -116,26 +133,80 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
logging!(info, Type::Setup, true, "开始应用初始化...");
|
||||||
|
let mut auto_start_plugin_builder = tauri_plugin_autostart::Builder::new();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
auto_start_plugin_builder = auto_start_plugin_builder
|
||||||
|
.macos_launcher(MacosLauncher::LaunchAgent)
|
||||||
|
.app_name(app.config().identifier.clone());
|
||||||
|
}
|
||||||
|
let _ = app.handle().plugin(auto_start_plugin_builder.build());
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||||||
{
|
{
|
||||||
use tauri_plugin_deep_link::DeepLinkExt;
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
|
logging!(info, Type::Setup, true, "注册深层链接...");
|
||||||
logging_error!(Type::System, true, app.deep_link().register_all());
|
logging_error!(Type::System, true, app.deep_link().register_all());
|
||||||
}
|
}
|
||||||
|
|
||||||
app.deep_link().on_open_url(|event| {
|
app.deep_link().on_open_url(|event| {
|
||||||
tauri::async_runtime::spawn(async move {
|
AsyncHandler::spawn(move || {
|
||||||
if let Some(url) = event.urls().first() {
|
let url = event.urls().first().map(|u| u.to_string());
|
||||||
logging_error!(Type::Setup, true, resolve_scheme(url.to_string()).await);
|
async move {
|
||||||
|
if let Some(url) = url {
|
||||||
|
logging_error!(Type::Setup, true, resolve_scheme(url).await);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
tauri::async_runtime::block_on(async move {
|
// 异步处理
|
||||||
resolve::resolve_setup(app).await;
|
let app_handle = app.handle().clone();
|
||||||
|
AsyncHandler::spawn(move || async move {
|
||||||
|
logging!(info, Type::Setup, true, "异步执行应用设置...");
|
||||||
|
match timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
resolve::resolve_setup_async(&app_handle),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
logging!(info, Type::Setup, true, "应用设置成功完成");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Setup,
|
||||||
|
true,
|
||||||
|
"应用设置超时(30秒),继续执行后续流程"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logging!(info, Type::Setup, true, "执行主要设置操作...");
|
||||||
|
|
||||||
|
logging!(info, Type::Setup, true, "初始化AppHandleManager...");
|
||||||
|
AppHandleManager::global().init(app.handle().clone());
|
||||||
|
|
||||||
|
logging!(info, Type::Setup, true, "初始化核心句柄...");
|
||||||
|
core::handle::Handle::global().init(app.handle());
|
||||||
|
|
||||||
|
logging!(info, Type::Setup, true, "初始化配置...");
|
||||||
|
if let Err(e) = utils::init::init_config() {
|
||||||
|
logging!(error, Type::Setup, true, "初始化配置失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
logging!(info, Type::Setup, true, "初始化资源...");
|
||||||
|
if let Err(e) = utils::init::init_resources() {
|
||||||
|
logging!(error, Type::Setup, true, "初始化资源失败: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.manage(Mutex::new(state::proxy::CmdProxyState::default()));
|
||||||
|
|
||||||
|
logging!(info, Type::Setup, true, "初始化完成,继续执行");
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -148,18 +219,29 @@ pub fn run() {
|
|||||||
cmd::open_core_dir,
|
cmd::open_core_dir,
|
||||||
cmd::get_portable_flag,
|
cmd::get_portable_flag,
|
||||||
cmd::get_network_interfaces,
|
cmd::get_network_interfaces,
|
||||||
cmd::restart_core,
|
cmd::get_system_hostname,
|
||||||
cmd::restart_app,
|
cmd::restart_app,
|
||||||
// 添加新的命令
|
// 内核管理
|
||||||
|
cmd::start_core,
|
||||||
|
cmd::stop_core,
|
||||||
|
cmd::restart_core,
|
||||||
|
// 启动命令
|
||||||
|
cmd::notify_ui_ready,
|
||||||
|
cmd::update_ui_stage,
|
||||||
|
cmd::reset_ui_ready_state,
|
||||||
cmd::get_running_mode,
|
cmd::get_running_mode,
|
||||||
cmd::get_app_uptime,
|
cmd::get_app_uptime,
|
||||||
cmd::get_auto_launch_status,
|
cmd::get_auto_launch_status,
|
||||||
cmd::is_admin,
|
cmd::is_admin,
|
||||||
|
// 添加轻量模式相关命令
|
||||||
|
cmd::entry_lightweight_mode,
|
||||||
|
cmd::exit_lightweight_mode,
|
||||||
// service 管理
|
// service 管理
|
||||||
cmd::install_service,
|
cmd::install_service,
|
||||||
cmd::uninstall_service,
|
cmd::uninstall_service,
|
||||||
cmd::reinstall_service,
|
cmd::reinstall_service,
|
||||||
cmd::repair_service,
|
cmd::repair_service,
|
||||||
|
cmd::is_service_available,
|
||||||
// clash
|
// clash
|
||||||
cmd::get_clash_info,
|
cmd::get_clash_info,
|
||||||
cmd::patch_clash_config,
|
cmd::patch_clash_config,
|
||||||
@@ -177,6 +259,7 @@ pub fn run() {
|
|||||||
cmd::apply_dns_config,
|
cmd::apply_dns_config,
|
||||||
cmd::check_dns_config_exists,
|
cmd::check_dns_config_exists,
|
||||||
cmd::get_dns_config_content,
|
cmd::get_dns_config_content,
|
||||||
|
cmd::validate_dns_config,
|
||||||
// verge
|
// verge
|
||||||
cmd::get_verge_config,
|
cmd::get_verge_config,
|
||||||
cmd::patch_verge_config,
|
cmd::patch_verge_config,
|
||||||
@@ -200,6 +283,7 @@ pub fn run() {
|
|||||||
cmd::delete_profile,
|
cmd::delete_profile,
|
||||||
cmd::read_profile_file,
|
cmd::read_profile_file,
|
||||||
cmd::save_profile_file,
|
cmd::save_profile_file,
|
||||||
|
cmd::get_next_update_time,
|
||||||
// script validation
|
// script validation
|
||||||
cmd::script_validate_notice,
|
cmd::script_validate_notice,
|
||||||
cmd::validate_script_file,
|
cmd::validate_script_file,
|
||||||
@@ -239,6 +323,7 @@ pub fn run() {
|
|||||||
|
|
||||||
app.run(|app_handle, e| match e {
|
app.run(|app_handle, e| match e {
|
||||||
tauri::RunEvent::Ready | tauri::RunEvent::Resumed => {
|
tauri::RunEvent::Ready | tauri::RunEvent::Resumed => {
|
||||||
|
logging!(info, Type::System, true, "应用就绪或恢复");
|
||||||
AppHandleManager::global().init(app_handle.clone());
|
AppHandleManager::global().init(app_handle.clone());
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
@@ -246,6 +331,7 @@ pub fn run() {
|
|||||||
.get_handle()
|
.get_handle()
|
||||||
.get_webview_window("main")
|
.get_webview_window("main")
|
||||||
{
|
{
|
||||||
|
logging!(info, Type::Window, true, "设置macOS窗口标题");
|
||||||
let _ = window.set_title("Clash Verge");
|
let _ = window.set_title("Clash Verge");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,6 +351,13 @@ pub fn run() {
|
|||||||
api.prevent_exit();
|
api.prevent_exit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tauri::RunEvent::Exit => {
|
||||||
|
// avoid duplicate cleanup
|
||||||
|
if core::handle::Handle::global().is_exiting() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
feat::clean();
|
||||||
|
}
|
||||||
tauri::RunEvent::WindowEvent { label, event, .. } => {
|
tauri::RunEvent::WindowEvent { label, event, .. } => {
|
||||||
if label == "main" {
|
if label == "main" {
|
||||||
match event {
|
match event {
|
||||||
@@ -276,8 +369,11 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
println!("closing window...");
|
println!("closing window...");
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
let window = core::handle::Handle::global().get_window().unwrap();
|
if let Some(window) = core::handle::Handle::global().get_window() {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
|
} else {
|
||||||
|
logging!(warn, Type::Window, true, "尝试隐藏窗口但窗口不存在");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tauri::WindowEvent::Focused(true) => {
|
tauri::WindowEvent::Focused(true) => {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -293,15 +389,6 @@ pub fn run() {
|
|||||||
hotkey::Hotkey::global().register("CMD+W", "hide")
|
hotkey::Hotkey::global().register("CMD+W", "hide")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
{
|
|
||||||
logging_error!(
|
|
||||||
Type::Hotkey,
|
|
||||||
true,
|
|
||||||
hotkey::Hotkey::global().register("Control+Q", "quit")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
{
|
{
|
||||||
let is_enable_global_hotkey = Config::verge()
|
let is_enable_global_hotkey = Config::verge()
|
||||||
.latest()
|
.latest()
|
||||||
@@ -326,14 +413,6 @@ pub fn run() {
|
|||||||
hotkey::Hotkey::global().unregister("CMD+W")
|
hotkey::Hotkey::global().unregister("CMD+W")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
{
|
|
||||||
logging_error!(
|
|
||||||
Type::Hotkey,
|
|
||||||
true,
|
|
||||||
hotkey::Hotkey::global().unregister("Control+Q")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
{
|
{
|
||||||
let is_enable_global_hotkey = Config::verge()
|
let is_enable_global_hotkey = Config::verge()
|
||||||
.latest()
|
.latest()
|
||||||
@@ -358,15 +437,6 @@ pub fn run() {
|
|||||||
hotkey::Hotkey::global().unregister("CMD+W")
|
hotkey::Hotkey::global().unregister("CMD+W")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
{
|
|
||||||
logging_error!(
|
|
||||||
Type::Hotkey,
|
|
||||||
true,
|
|
||||||
hotkey::Hotkey::global().unregister("Control+Q")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use delay_timer::prelude::TaskBuilder;
|
|
||||||
use tauri::{Listener, Manager};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
core::{handle, timer::Timer},
|
core::{handle, timer::Timer},
|
||||||
log_err, logging,
|
log_err, logging, logging_error,
|
||||||
utils::logging::Type,
|
utils::logging::Type,
|
||||||
AppHandleManager,
|
AppHandleManager,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use delay_timer::prelude::TaskBuilder;
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
use std::{
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tauri::{Listener, Manager};
|
||||||
|
|
||||||
const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task";
|
const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task";
|
||||||
|
|
||||||
|
// 轻量模式状态标志
|
||||||
|
static IS_LIGHTWEIGHT_MODE: OnceCell<Arc<RwLock<bool>>> = OnceCell::new();
|
||||||
|
|
||||||
|
// 添加一个锁来防止并发退出轻量模式
|
||||||
|
static EXIT_LOCK: OnceCell<Mutex<(bool, Instant)>> = OnceCell::new();
|
||||||
|
|
||||||
|
fn get_lightweight_mode() -> &'static Arc<RwLock<bool>> {
|
||||||
|
IS_LIGHTWEIGHT_MODE.get_or_init(|| Arc::new(RwLock::new(false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_exit_lock() -> &'static Mutex<(bool, Instant)> {
|
||||||
|
EXIT_LOCK.get_or_init(|| Mutex::new((false, Instant::now())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否处于轻量模式
|
||||||
|
pub fn is_in_lightweight_mode() -> bool {
|
||||||
|
*get_lightweight_mode().read()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置轻量模式状态
|
||||||
|
fn set_lightweight_mode(value: bool) {
|
||||||
|
let mut mode = get_lightweight_mode().write();
|
||||||
|
*mode = value;
|
||||||
|
logging!(info, Type::Lightweight, true, "轻量模式状态: {}", value);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn enable_auto_light_weight_mode() {
|
pub fn enable_auto_light_weight_mode() {
|
||||||
Timer::global().init().unwrap();
|
Timer::global().init().unwrap();
|
||||||
logging!(info, Type::Lightweight, true, "开启自动轻量模式");
|
logging!(info, Type::Lightweight, true, "开启自动轻量模式");
|
||||||
@@ -37,9 +69,53 @@ pub fn entry_lightweight_mode() {
|
|||||||
AppHandleManager::global().set_activation_policy_accessory();
|
AppHandleManager::global().set_activation_policy_accessory();
|
||||||
logging!(info, Type::Lightweight, true, "轻量模式已开启");
|
logging!(info, Type::Lightweight, true, "轻量模式已开启");
|
||||||
}
|
}
|
||||||
|
// 标记已进入轻量模式
|
||||||
|
set_lightweight_mode(true);
|
||||||
let _ = cancel_light_weight_timer();
|
let _ = cancel_light_weight_timer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加从轻量模式恢复的函数
|
||||||
|
pub fn exit_lightweight_mode() {
|
||||||
|
// 获取锁,检查是否已经有退出操作在进行中
|
||||||
|
let mut exit_lock = get_exit_lock().lock();
|
||||||
|
let (is_exiting, last_exit_time) = *exit_lock;
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
// 如果已经有一个退出操作在进行,并且距离上次退出时间不超过2秒,跳过本次退出
|
||||||
|
if is_exiting && now.duration_since(last_exit_time) < Duration::from_secs(2) {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Lightweight,
|
||||||
|
true,
|
||||||
|
"已有退出轻量模式操作正在进行中,跳过本次请求"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*exit_lock = (true, now);
|
||||||
|
|
||||||
|
// 确保当前确实处于轻量模式才执行退出操作
|
||||||
|
if !is_in_lightweight_mode() {
|
||||||
|
logging!(info, Type::Lightweight, true, "当前不在轻量模式,无需退出");
|
||||||
|
exit_lock.0 = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记退出轻量模式
|
||||||
|
set_lightweight_mode(false);
|
||||||
|
logging!(info, Type::Lightweight, true, "正在退出轻量模式");
|
||||||
|
|
||||||
|
// 重置UI就绪状态
|
||||||
|
crate::utils::resolve::reset_ui_ready();
|
||||||
|
|
||||||
|
// 释放锁
|
||||||
|
exit_lock.0 = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_light_weight_timer() {
|
||||||
|
logging_error!(Type::Lightweight, setup_light_weight_timer());
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_window_close_listener() -> u32 {
|
fn setup_window_close_listener() -> u32 {
|
||||||
if let Some(window) = handle::Handle::global().get_window() {
|
if let Some(window) = handle::Handle::global().get_window() {
|
||||||
let handler = window.listen("tauri://close-requested", move |_event| {
|
let handler = window.listen("tauri://close-requested", move |_event| {
|
||||||
@@ -63,7 +139,6 @@ fn setup_webview_focus_listener() -> u32 {
|
|||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::Lightweight,
|
Type::Lightweight,
|
||||||
true,
|
|
||||||
"监听到窗口获得焦点,取消轻量模式计时"
|
"监听到窗口获得焦点,取消轻量模式计时"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +1,92 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use mihomo_api;
|
use mihomo_api;
|
||||||
use once_cell::sync::{Lazy, OnceCell};
|
use once_cell::sync::Lazy;
|
||||||
use std::sync::Mutex;
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tauri::http::{HeaderMap, HeaderValue};
|
use tauri::http::{HeaderMap, HeaderValue};
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use tokio_tungstenite::tungstenite::http;
|
// 缓存的最大有效期(5秒)
|
||||||
|
const CACHE_TTL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct Rate {
|
pub struct Rate {
|
||||||
pub up: u64,
|
pub up: u64,
|
||||||
pub down: u64,
|
pub down: u64,
|
||||||
}
|
}
|
||||||
|
// 缓存MihomoManager实例
|
||||||
|
struct MihomoCache {
|
||||||
|
manager: mihomo_api::MihomoManager,
|
||||||
|
created_at: Instant,
|
||||||
|
server: String,
|
||||||
|
}
|
||||||
|
// 使用RwLock替代Mutex,允许多个读取操作并发进行
|
||||||
pub struct MihomoManager {
|
pub struct MihomoManager {
|
||||||
mihomo: Mutex<OnceCell<mihomo_api::MihomoManager>>,
|
mihomo_cache: RwLock<Option<MihomoCache>>,
|
||||||
|
create_lock: Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MihomoManager {
|
impl MihomoManager {
|
||||||
fn __global() -> &'static MihomoManager {
|
fn __global() -> &'static MihomoManager {
|
||||||
static INSTANCE: Lazy<MihomoManager> = Lazy::new(|| MihomoManager {
|
static INSTANCE: Lazy<MihomoManager> = Lazy::new(|| MihomoManager {
|
||||||
mihomo: Mutex::new(OnceCell::new()),
|
mihomo_cache: RwLock::new(None),
|
||||||
|
create_lock: Mutex::new(()),
|
||||||
});
|
});
|
||||||
&INSTANCE
|
&INSTANCE
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn global() -> mihomo_api::MihomoManager {
|
pub fn global() -> mihomo_api::MihomoManager {
|
||||||
let instance = MihomoManager::__global();
|
let instance = MihomoManager::__global();
|
||||||
let (current_server, headers) = MihomoManager::get_clash_client_info().unwrap();
|
|
||||||
|
|
||||||
let lock = instance.mihomo.lock().unwrap();
|
// 尝试从缓存读取(只需读锁)
|
||||||
if let Some(mihomo) = lock.get() {
|
{
|
||||||
if mihomo.get_mihomo_server() == current_server {
|
let cache = instance.mihomo_cache.read();
|
||||||
return mihomo.clone();
|
if let Some(cache_entry) = &*cache {
|
||||||
|
let (current_server, _) = MihomoManager::get_clash_client_info()
|
||||||
|
.unwrap_or_else(|| (String::new(), HeaderMap::new()));
|
||||||
|
|
||||||
|
// 检查缓存是否有效
|
||||||
|
if cache_entry.server == current_server
|
||||||
|
&& cache_entry.created_at.elapsed() < CACHE_TTL
|
||||||
|
{
|
||||||
|
return cache_entry.manager.clone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lock.set(mihomo_api::MihomoManager::new(current_server, headers))
|
// 缓存无效,获取创建锁
|
||||||
.ok();
|
let _create_guard = instance.create_lock.lock();
|
||||||
lock.get().unwrap().clone()
|
|
||||||
|
// 再次检查缓存(双重检查锁定模式)
|
||||||
|
{
|
||||||
|
let cache = instance.mihomo_cache.read();
|
||||||
|
if let Some(cache_entry) = &*cache {
|
||||||
|
let (current_server, _) = MihomoManager::get_clash_client_info()
|
||||||
|
.unwrap_or_else(|| (String::new(), HeaderMap::new()));
|
||||||
|
|
||||||
|
if cache_entry.server == current_server
|
||||||
|
&& cache_entry.created_at.elapsed() < CACHE_TTL
|
||||||
|
{
|
||||||
|
return cache_entry.manager.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新实例
|
||||||
|
let (current_server, headers) = MihomoManager::get_clash_client_info()
|
||||||
|
.unwrap_or_else(|| (String::new(), HeaderMap::new()));
|
||||||
|
let manager = mihomo_api::MihomoManager::new(current_server.clone(), headers);
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
{
|
||||||
|
let mut cache = instance.mihomo_cache.write();
|
||||||
|
*cache = Some(MihomoCache {
|
||||||
|
manager: manager.clone(),
|
||||||
|
created_at: Instant::now(),
|
||||||
|
server: current_server,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,17 +103,31 @@ impl MihomoManager {
|
|||||||
|
|
||||||
Some((server, headers))
|
Some((server, headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提供默认值的版本,避免在connection_info为None时panic
|
||||||
|
fn get_clash_client_info_or_default() -> (String, HeaderMap) {
|
||||||
|
Self::get_clash_client_info().unwrap_or_else(|| {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("Content-Type", "application/json".parse().unwrap());
|
||||||
|
("http://127.0.0.1:9090".to_string(), headers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn get_traffic_ws_url() -> (String, HeaderValue) {
|
pub fn get_traffic_ws_url() -> (String, HeaderValue) {
|
||||||
let (url, headers) = MihomoManager::get_clash_client_info().unwrap();
|
let (url, headers) = MihomoManager::get_clash_client_info_or_default();
|
||||||
let ws_url = url.replace("http://", "ws://") + "/traffic";
|
let ws_url = url.replace("http://", "ws://") + "/traffic";
|
||||||
let auth = headers
|
let auth = headers
|
||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
.unwrap()
|
.map(|val| val.to_str().unwrap_or("").to_string())
|
||||||
.to_str()
|
.unwrap_or_default();
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
// 创建默认的空HeaderValue而不是使用unwrap_or_default
|
||||||
let token = http::header::HeaderValue::from_str(&auth).unwrap();
|
let token = match HeaderValue::from_str(&auth) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => HeaderValue::from_static(""),
|
||||||
|
};
|
||||||
|
|
||||||
(ws_url, token)
|
(ws_url, token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ impl PlatformSpecification {
|
|||||||
let system_arch = System::cpu_arch();
|
let system_arch = System::cpu_arch();
|
||||||
|
|
||||||
let handler = handle::Handle::global().app_handle().unwrap();
|
let handler = handle::Handle::global().app_handle().unwrap();
|
||||||
let config = handler.config();
|
let verge_version = handler.package_info().version.to_string();
|
||||||
let verge_version = config.version.clone().unwrap_or("Null".into());
|
|
||||||
|
|
||||||
// 使用默认值避免在同步上下文中执行异步操作
|
// 使用默认值避免在同步上下文中执行异步操作
|
||||||
let running_mode = "NotRunning".to_string();
|
let running_mode = "NotRunning".to_string();
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use std::future::Future;
|
||||||
|
use tauri::{async_runtime, async_runtime::JoinHandle};
|
||||||
|
|
||||||
|
pub struct AsyncHandler;
|
||||||
|
|
||||||
|
impl AsyncHandler {
|
||||||
|
pub fn spawn<F, Fut>(f: F) -> JoinHandle<()>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut + Send + 'static,
|
||||||
|
Fut: Future<Output = ()> + Send + 'static,
|
||||||
|
{
|
||||||
|
async_runtime::spawn(f())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_blocking<F, R>(f: F) -> JoinHandle<R>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> R + Send + 'static,
|
||||||
|
R: Send + 'static,
|
||||||
|
{
|
||||||
|
async_runtime::spawn_blocking(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn block_on<F, Fut, R>(f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = R>,
|
||||||
|
{
|
||||||
|
async_runtime::block_on(f())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod async_handler;
|
||||||
|
pub use async_handler::AsyncHandler;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Tauri Manager 会进行 Arc 管理,无需额外 Arc
|
||||||
|
// https://tauri.app/develop/state-management/#do-you-need-arc
|
||||||
|
|
||||||
|
pub mod proxy;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pub struct CmdProxyState {
|
||||||
|
pub last_refresh_time: std::time::Instant,
|
||||||
|
pub need_refresh: bool,
|
||||||
|
pub proxies: serde_json::Value,
|
||||||
|
pub providers_proxies: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CmdProxyState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
last_refresh_time: std::time::Instant::now(),
|
||||||
|
need_refresh: true,
|
||||||
|
proxies: serde_json::Value::Null,
|
||||||
|
providers_proxies: serde_json::Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use log::info;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::{fs, path::Path, path::PathBuf};
|
||||||
|
|
||||||
|
/// Windows 下的开机启动文件夹路径
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn get_startup_dir() -> Result<PathBuf> {
|
||||||
|
let appdata = std::env::var("APPDATA").map_err(|_| anyhow!("无法获取 APPDATA 环境变量"))?;
|
||||||
|
|
||||||
|
let startup_dir = Path::new(&appdata)
|
||||||
|
.join("Microsoft")
|
||||||
|
.join("Windows")
|
||||||
|
.join("Start Menu")
|
||||||
|
.join("Programs")
|
||||||
|
.join("Startup");
|
||||||
|
|
||||||
|
if !startup_dir.exists() {
|
||||||
|
return Err(anyhow!("Startup 目录不存在: {:?}", startup_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(startup_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前可执行文件路径
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn get_exe_path() -> Result<PathBuf> {
|
||||||
|
let exe_path =
|
||||||
|
std::env::current_exe().map_err(|e| anyhow!("无法获取当前可执行文件路径: {}", e))?;
|
||||||
|
|
||||||
|
Ok(exe_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建快捷方式
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn create_shortcut() -> Result<()> {
|
||||||
|
let exe_path = get_exe_path()?;
|
||||||
|
let startup_dir = get_startup_dir()?;
|
||||||
|
let shortcut_path = startup_dir.join("Clash-Verge.lnk");
|
||||||
|
|
||||||
|
// 如果快捷方式已存在,直接返回成功
|
||||||
|
if shortcut_path.exists() {
|
||||||
|
info!(target: "app", "启动快捷方式已存在");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 PowerShell 创建快捷方式
|
||||||
|
let powershell_command = format!(
|
||||||
|
"$WshShell = New-Object -ComObject WScript.Shell; \
|
||||||
|
$Shortcut = $WshShell.CreateShortcut('{}'); \
|
||||||
|
$Shortcut.TargetPath = '{}'; \
|
||||||
|
$Shortcut.Save()",
|
||||||
|
shortcut_path.to_string_lossy().replace("\\", "\\\\"),
|
||||||
|
exe_path.to_string_lossy().replace("\\", "\\\\")
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = std::process::Command::new("powershell")
|
||||||
|
.args(["-Command", &powershell_command])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| anyhow!("执行 PowerShell 命令失败: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(anyhow!("创建快捷方式失败: {}", error_msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(target: "app", "成功创建启动快捷方式");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除快捷方式
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn remove_shortcut() -> Result<()> {
|
||||||
|
let startup_dir = get_startup_dir()?;
|
||||||
|
let shortcut_path = startup_dir.join("Clash-Verge.lnk");
|
||||||
|
|
||||||
|
// 如果快捷方式不存在,直接返回成功
|
||||||
|
if !shortcut_path.exists() {
|
||||||
|
info!(target: "app", "启动快捷方式不存在,无需删除");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除快捷方式
|
||||||
|
fs::remove_file(&shortcut_path).map_err(|e| anyhow!("删除快捷方式失败: {}", e))?;
|
||||||
|
|
||||||
|
info!(target: "app", "成功删除启动快捷方式");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查快捷方式是否存在
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn is_shortcut_enabled() -> Result<bool> {
|
||||||
|
let startup_dir = get_startup_dir()?;
|
||||||
|
let shortcut_path = startup_dir.join("Clash-Verge.lnk");
|
||||||
|
|
||||||
|
Ok(shortcut_path.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 Windows 平台使用的空方法
|
||||||
|
// #[cfg(not(target_os = "windows"))]
|
||||||
|
// pub fn create_shortcut() -> Result<()> {
|
||||||
|
// Ok(())
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[cfg(not(target_os = "windows"))]
|
||||||
|
// pub fn remove_shortcut() -> Result<()> {
|
||||||
|
// Ok(())
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[cfg(not(target_os = "windows"))]
|
||||||
|
// pub fn is_shortcut_enabled() -> Result<bool> {
|
||||||
|
// Ok(false)
|
||||||
|
// }
|
||||||
@@ -49,12 +49,60 @@ pub fn app_home_dir() -> Result<PathBuf> {
|
|||||||
.ok_or(anyhow::anyhow!("failed to get the portable app dir"))?;
|
.ok_or(anyhow::anyhow!("failed to get the portable app dir"))?;
|
||||||
return Ok(PathBuf::from(app_dir).join(".config").join(APP_ID));
|
return Ok(PathBuf::from(app_dir).join(".config").join(APP_ID));
|
||||||
}
|
}
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
|
||||||
|
// 避免在Handle未初始化时崩溃
|
||||||
|
let app_handle = match handle::Handle::global().app_handle() {
|
||||||
|
Some(handle) => handle,
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "app_handle not initialized, using default path");
|
||||||
|
// 使用可执行文件目录作为备用
|
||||||
|
let exe_path = tauri::utils::platform::current_exe()?;
|
||||||
|
let exe_dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or(anyhow::anyhow!("failed to get executable directory"))?;
|
||||||
|
|
||||||
|
// 使用系统临时目录 + 应用ID
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
|
||||||
|
let path = PathBuf::from(local_app_data).join(APP_ID);
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
let path = PathBuf::from(home)
|
||||||
|
.join("Library")
|
||||||
|
.join("Application Support")
|
||||||
|
.join(APP_ID);
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
let path = PathBuf::from(home)
|
||||||
|
.join(".local")
|
||||||
|
.join("share")
|
||||||
|
.join(APP_ID);
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果无法获取系统目录,则回退到可执行文件目录
|
||||||
|
let fallback_dir = PathBuf::from(exe_dir).join(".config").join(APP_ID);
|
||||||
|
log::warn!(target: "app", "Using fallback data directory: {:?}", fallback_dir);
|
||||||
|
return Ok(fallback_dir);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match app_handle.path().data_dir() {
|
match app_handle.path().data_dir() {
|
||||||
Ok(dir) => Ok(dir.join(APP_ID)),
|
Ok(dir) => Ok(dir.join(APP_ID)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!(target:"app", "Failed to get the app home directory: {}", e);
|
log::error!(target: "app", "Failed to get the app home directory: {}", e);
|
||||||
Err(anyhow::anyhow!("Failed to get the app homedirectory"))
|
Err(anyhow::anyhow!("Failed to get the app homedirectory"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,11 +110,24 @@ pub fn app_home_dir() -> Result<PathBuf> {
|
|||||||
|
|
||||||
/// get the resources dir
|
/// get the resources dir
|
||||||
pub fn app_resources_dir() -> Result<PathBuf> {
|
pub fn app_resources_dir() -> Result<PathBuf> {
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
// 避免在Handle未初始化时崩溃
|
||||||
|
let app_handle = match handle::Handle::global().app_handle() {
|
||||||
|
Some(handle) => handle,
|
||||||
|
None => {
|
||||||
|
log::warn!(target: "app", "app_handle not initialized in app_resources_dir, using fallback");
|
||||||
|
// 使用可执行文件目录作为备用
|
||||||
|
let exe_dir = tauri::utils::platform::current_exe()?
|
||||||
|
.parent()
|
||||||
|
.ok_or(anyhow::anyhow!("failed to get executable directory"))?
|
||||||
|
.to_path_buf();
|
||||||
|
return Ok(exe_dir.join("resources"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match app_handle.path().resource_dir() {
|
match app_handle.path().resource_dir() {
|
||||||
Ok(dir) => Ok(dir.join("resources")),
|
Ok(dir) => Ok(dir.join("resources")),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!(target:"app", "Failed to get the resource directory: {}", e);
|
log::error!(target: "app", "Failed to get the resource directory: {}", e);
|
||||||
Err(anyhow::anyhow!("Failed to get the resource directory"))
|
Err(anyhow::anyhow!("Failed to get the resource directory"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,12 +187,14 @@ pub fn profiles_path() -> Result<PathBuf> {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn service_path() -> Result<PathBuf> {
|
pub fn service_path() -> Result<PathBuf> {
|
||||||
Ok(app_resources_dir()?.join("clash-verge-service"))
|
let res_dir = app_resources_dir()?;
|
||||||
|
Ok(res_dir.join("clash-verge-service"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn service_path() -> Result<PathBuf> {
|
pub fn service_path() -> Result<PathBuf> {
|
||||||
Ok(app_resources_dir()?.join("clash-verge-service.exe"))
|
let res_dir = app_resources_dir()?;
|
||||||
|
Ok(res_dir.join("clash-verge-service.exe"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn service_log_file() -> Result<PathBuf> {
|
pub fn service_log_file() -> Result<PathBuf> {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
use crate::log_err;
|
|
||||||
use anyhow;
|
|
||||||
use std::{
|
|
||||||
backtrace::{Backtrace, BacktraceStatus},
|
|
||||||
thread,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn redirect_panic_to_log() {
|
|
||||||
std::panic::set_hook(Box::new(move |panic_info| {
|
|
||||||
let thread = thread::current();
|
|
||||||
let thread_name = thread.name().unwrap_or("<unnamed>");
|
|
||||||
let payload = panic_info.payload();
|
|
||||||
|
|
||||||
let payload = if let Some(s) = payload.downcast_ref::<&str>() {
|
|
||||||
&**s
|
|
||||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
|
||||||
s
|
|
||||||
} else {
|
|
||||||
&format!("{:?}", payload)
|
|
||||||
};
|
|
||||||
|
|
||||||
let location = panic_info
|
|
||||||
.location()
|
|
||||||
.map(|l| l.to_string())
|
|
||||||
.unwrap_or("unknown location".to_string());
|
|
||||||
|
|
||||||
let backtrace = Backtrace::capture();
|
|
||||||
let backtrace = if backtrace.status() == BacktraceStatus::Captured {
|
|
||||||
&format!("stack backtrace:\n{}", backtrace)
|
|
||||||
} else {
|
|
||||||
"note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace"
|
|
||||||
};
|
|
||||||
|
|
||||||
let err: Result<(), anyhow::Error> = Err(anyhow::anyhow!(format!(
|
|
||||||
"thread '{}' panicked at {}:\n{}\n{}",
|
|
||||||
thread_name, location, payload, backtrace
|
|
||||||
)));
|
|
||||||
log_err!(err);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -142,8 +142,8 @@ pub fn delete_log() -> Result<()> {
|
|||||||
fn init_dns_config() -> Result<()> {
|
fn init_dns_config() -> Result<()> {
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
|
|
||||||
// 获取默认DNS配置
|
// 创建DNS子配置
|
||||||
let default_dns_config = serde_yaml::Mapping::from_iter([
|
let dns_config = serde_yaml::Mapping::from_iter([
|
||||||
("enable".into(), Value::Bool(true)),
|
("enable".into(), Value::Bool(true)),
|
||||||
("listen".into(), Value::String(":53".into())),
|
("listen".into(), Value::String(":53".into())),
|
||||||
("enhanced-mode".into(), Value::String("fake-ip".into())),
|
("enhanced-mode".into(), Value::String("fake-ip".into())),
|
||||||
@@ -180,6 +180,8 @@ fn init_dns_config() -> Result<()> {
|
|||||||
Value::String("system".into()),
|
Value::String("system".into()),
|
||||||
Value::String("223.6.6.6".into()),
|
Value::String("223.6.6.6".into()),
|
||||||
Value::String("8.8.8.8".into()),
|
Value::String("8.8.8.8".into()),
|
||||||
|
Value::String("2400:3200::1".into()),
|
||||||
|
Value::String("2001:4860:4860::8888".into()),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -229,6 +231,12 @@ fn init_dns_config() -> Result<()> {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 获取默认DNS和host配置
|
||||||
|
let default_dns_config = serde_yaml::Mapping::from_iter([
|
||||||
|
("dns".into(), Value::Mapping(dns_config)),
|
||||||
|
("hosts".into(), Value::Mapping(serde_yaml::Mapping::new())),
|
||||||
|
]);
|
||||||
|
|
||||||
// 检查DNS配置文件是否存在
|
// 检查DNS配置文件是否存在
|
||||||
let app_dir = dirs::app_home_dir()?;
|
let app_dir = dirs::app_home_dir()?;
|
||||||
let dns_path = app_dir.join("dns_config.yaml");
|
let dns_path = app_dir.join("dns_config.yaml");
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub enum Type {
|
|||||||
Frontend,
|
Frontend,
|
||||||
Backup,
|
Backup,
|
||||||
Lightweight,
|
Lightweight,
|
||||||
|
Network,
|
||||||
|
ProxyMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for Type {
|
impl fmt::Display for Type {
|
||||||
@@ -33,6 +35,8 @@ impl fmt::Display for Type {
|
|||||||
Type::Frontend => write!(f, "[Frontend]"),
|
Type::Frontend => write!(f, "[Frontend]"),
|
||||||
Type::Backup => write!(f, "[Backup]"),
|
Type::Backup => write!(f, "[Backup]"),
|
||||||
Type::Lightweight => write!(f, "[Lightweight]"),
|
Type::Lightweight => write!(f, "[Lightweight]"),
|
||||||
|
Type::Network => write!(f, "[Network]"),
|
||||||
|
Type::ProxyMode => write!(f, "[ProxMode]"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
pub mod autostart;
|
||||||
pub mod dirs;
|
pub mod dirs;
|
||||||
pub mod error;
|
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod network;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod tmpl;
|
pub mod tmpl;
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use reqwest::{Client, ClientBuilder, Proxy, RequestBuilder, Response};
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, Mutex, Once},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tokio::runtime::{Builder, Runtime};
|
||||||
|
|
||||||
|
use crate::{config::Config, logging, utils::logging::Type};
|
||||||
|
|
||||||
|
// HTTP2 相关
|
||||||
|
const H2_CONNECTION_WINDOW_SIZE: u32 = 1024 * 1024;
|
||||||
|
const H2_STREAM_WINDOW_SIZE: u32 = 1024 * 1024;
|
||||||
|
const H2_MAX_FRAME_SIZE: u32 = 16 * 1024;
|
||||||
|
const H2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
const H2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const POOL_MAX_IDLE_PER_HOST: usize = 5;
|
||||||
|
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// 网络管理器
|
||||||
|
pub struct NetworkManager {
|
||||||
|
runtime: Arc<Runtime>,
|
||||||
|
self_proxy_client: Arc<Mutex<Option<Client>>>,
|
||||||
|
system_proxy_client: Arc<Mutex<Option<Client>>>,
|
||||||
|
no_proxy_client: Arc<Mutex<Option<Client>>>,
|
||||||
|
init: Once,
|
||||||
|
last_connection_error: Arc<Mutex<Option<(Instant, String)>>>,
|
||||||
|
connection_error_count: Arc<Mutex<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref NETWORK_MANAGER: NetworkManager = NetworkManager::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkManager {
|
||||||
|
fn new() -> Self {
|
||||||
|
// 创建专用的异步运行时,线程数限制为4个
|
||||||
|
let runtime = Builder::new_multi_thread()
|
||||||
|
.worker_threads(4)
|
||||||
|
.thread_name("clash-verge-network")
|
||||||
|
.enable_io()
|
||||||
|
.enable_time()
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create network runtime");
|
||||||
|
|
||||||
|
NetworkManager {
|
||||||
|
runtime: Arc::new(runtime),
|
||||||
|
self_proxy_client: Arc::new(Mutex::new(None)),
|
||||||
|
system_proxy_client: Arc::new(Mutex::new(None)),
|
||||||
|
no_proxy_client: Arc::new(Mutex::new(None)),
|
||||||
|
init: Once::new(),
|
||||||
|
last_connection_error: Arc::new(Mutex::new(None)),
|
||||||
|
connection_error_count: Arc::new(Mutex::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn global() -> &'static Self {
|
||||||
|
&NETWORK_MANAGER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化网络客户端
|
||||||
|
pub fn init(&self) {
|
||||||
|
self.init.call_once(|| {
|
||||||
|
self.runtime.spawn(async {
|
||||||
|
logging!(info, Type::Network, true, "初始化网络管理器");
|
||||||
|
|
||||||
|
// 创建无代理客户端
|
||||||
|
let no_proxy_client = ClientBuilder::new()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.no_proxy()
|
||||||
|
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
|
||||||
|
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build no_proxy client");
|
||||||
|
|
||||||
|
let mut no_proxy_guard = NETWORK_MANAGER.no_proxy_client.lock().unwrap();
|
||||||
|
*no_proxy_guard = Some(no_proxy_client);
|
||||||
|
|
||||||
|
logging!(info, Type::Network, true, "网络管理器初始化完成");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_connection_error(&self, error: &str) {
|
||||||
|
let mut last_error = self.last_connection_error.lock().unwrap();
|
||||||
|
*last_error = Some((Instant::now(), error.to_string()));
|
||||||
|
|
||||||
|
let mut error_count = self.connection_error_count.lock().unwrap();
|
||||||
|
*error_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_reset_clients(&self) -> bool {
|
||||||
|
let error_count = *self.connection_error_count.lock().unwrap();
|
||||||
|
let last_error = self.last_connection_error.lock().unwrap();
|
||||||
|
|
||||||
|
if error_count > 5 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((time, _)) = *last_error {
|
||||||
|
if time.elapsed() < Duration::from_secs(30) && error_count > 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_clients(&self) {
|
||||||
|
logging!(info, Type::Network, true, "正在重置所有HTTP客户端");
|
||||||
|
{
|
||||||
|
let mut client = self.self_proxy_client.lock().unwrap();
|
||||||
|
*client = None;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut client = self.system_proxy_client.lock().unwrap();
|
||||||
|
*client = None;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut client = self.no_proxy_client.lock().unwrap();
|
||||||
|
*client = None;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut error_count = self.connection_error_count.lock().unwrap();
|
||||||
|
*error_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
/// 获取或创建自代理客户端
|
||||||
|
fn get_or_create_self_proxy_client(&self) -> Client {
|
||||||
|
if self.should_reset_clients() {
|
||||||
|
self.reset_clients();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut client_guard = self.self_proxy_client.lock().unwrap();
|
||||||
|
|
||||||
|
if client_guard.is_none() {
|
||||||
|
let port = Config::verge()
|
||||||
|
.latest()
|
||||||
|
.verge_mixed_port
|
||||||
|
.unwrap_or(Config::clash().data().get_mixed_port());
|
||||||
|
|
||||||
|
let proxy_scheme = format!("http://127.0.0.1:{port}");
|
||||||
|
|
||||||
|
let mut builder = ClientBuilder::new()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
|
||||||
|
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
|
||||||
|
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
|
||||||
|
.timeout(DEFAULT_REQUEST_TIMEOUT)
|
||||||
|
.http2_initial_stream_window_size(H2_STREAM_WINDOW_SIZE)
|
||||||
|
.http2_initial_connection_window_size(H2_CONNECTION_WINDOW_SIZE)
|
||||||
|
.http2_adaptive_window(true)
|
||||||
|
.http2_keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
|
||||||
|
.http2_keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
|
||||||
|
.http2_max_frame_size(H2_MAX_FRAME_SIZE)
|
||||||
|
.tcp_keepalive(Some(Duration::from_secs(10)))
|
||||||
|
.http2_max_header_list_size(16 * 1024);
|
||||||
|
|
||||||
|
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = builder.build().expect("Failed to build self_proxy client");
|
||||||
|
*client_guard = Some(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
client_guard.as_ref().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取或创建系统代理客户端
|
||||||
|
fn get_or_create_system_proxy_client(&self) -> Client {
|
||||||
|
if self.should_reset_clients() {
|
||||||
|
self.reset_clients();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut client_guard = self.system_proxy_client.lock().unwrap();
|
||||||
|
|
||||||
|
if client_guard.is_none() {
|
||||||
|
use sysproxy::Sysproxy;
|
||||||
|
|
||||||
|
let mut builder = ClientBuilder::new()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
|
||||||
|
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
|
||||||
|
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
|
||||||
|
.timeout(DEFAULT_REQUEST_TIMEOUT)
|
||||||
|
.http2_initial_stream_window_size(H2_STREAM_WINDOW_SIZE)
|
||||||
|
.http2_initial_connection_window_size(H2_CONNECTION_WINDOW_SIZE)
|
||||||
|
.http2_adaptive_window(true)
|
||||||
|
.http2_keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
|
||||||
|
.http2_keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
|
||||||
|
.http2_max_frame_size(H2_MAX_FRAME_SIZE)
|
||||||
|
.tcp_keepalive(Some(Duration::from_secs(10)))
|
||||||
|
.http2_max_header_list_size(16 * 1024);
|
||||||
|
|
||||||
|
if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() {
|
||||||
|
let proxy_scheme = format!("http://{}:{}", p.host, p.port);
|
||||||
|
|
||||||
|
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = builder
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build system_proxy client");
|
||||||
|
*client_guard = Some(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
client_guard.as_ref().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据代理设置选择合适的客户端
|
||||||
|
pub fn get_client(&self, proxy_type: ProxyType) -> Client {
|
||||||
|
match proxy_type {
|
||||||
|
ProxyType::NoProxy => {
|
||||||
|
let client_guard = self.no_proxy_client.lock().unwrap();
|
||||||
|
client_guard.as_ref().unwrap().clone()
|
||||||
|
}
|
||||||
|
ProxyType::SelfProxy => self.get_or_create_self_proxy_client(),
|
||||||
|
ProxyType::SystemProxy => self.get_or_create_system_proxy_client(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
/// 创建带有自定义选项的HTTP请求
|
||||||
|
pub fn create_request(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
proxy_type: ProxyType,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
accept_invalid_certs: bool,
|
||||||
|
) -> RequestBuilder {
|
||||||
|
if self.should_reset_clients() {
|
||||||
|
self.reset_clients();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = ClientBuilder::new()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
|
||||||
|
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
|
||||||
|
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
|
||||||
|
.http2_initial_stream_window_size(H2_STREAM_WINDOW_SIZE)
|
||||||
|
.http2_initial_connection_window_size(H2_CONNECTION_WINDOW_SIZE)
|
||||||
|
.http2_adaptive_window(true)
|
||||||
|
.http2_keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
|
||||||
|
.http2_keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
|
||||||
|
.http2_max_frame_size(H2_MAX_FRAME_SIZE)
|
||||||
|
.tcp_keepalive(Some(Duration::from_secs(10)))
|
||||||
|
.http2_max_header_list_size(16 * 1024);
|
||||||
|
|
||||||
|
if let Some(timeout) = timeout_secs {
|
||||||
|
builder = builder.timeout(Duration::from_secs(timeout));
|
||||||
|
} else {
|
||||||
|
builder = builder.timeout(DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
match proxy_type {
|
||||||
|
ProxyType::None => {
|
||||||
|
builder = builder.no_proxy();
|
||||||
|
}
|
||||||
|
ProxyType::Localhost => {
|
||||||
|
let port = Config::verge()
|
||||||
|
.latest()
|
||||||
|
.verge_mixed_port
|
||||||
|
.unwrap_or(Config::clash().data().get_mixed_port());
|
||||||
|
|
||||||
|
let proxy_scheme = format!("http://127.0.0.1:{port}");
|
||||||
|
|
||||||
|
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProxyType::System => {
|
||||||
|
use sysproxy::Sysproxy;
|
||||||
|
|
||||||
|
if let Ok(p @ Sysproxy { enable: true, .. }) = Sysproxy::get_system_proxy() {
|
||||||
|
let proxy_scheme = format!("http://{}:{}", p.host, p.port);
|
||||||
|
|
||||||
|
if let Ok(proxy) = Proxy::http(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::https(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
if let Ok(proxy) = Proxy::all(&proxy_scheme) {
|
||||||
|
builder = builder.proxy(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = builder.danger_accept_invalid_certs(accept_invalid_certs);
|
||||||
|
|
||||||
|
if let Some(ua) = user_agent {
|
||||||
|
builder = builder.user_agent(ua);
|
||||||
|
} else {
|
||||||
|
use crate::utils::resolve::VERSION;
|
||||||
|
|
||||||
|
let version = match VERSION.get() {
|
||||||
|
Some(v) => format!("clash-verge/v{}", v),
|
||||||
|
None => "clash-verge/unknown".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
builder = builder.user_agent(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = builder.build().expect("Failed to build custom HTTP client");
|
||||||
|
|
||||||
|
client.get(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /// 执行GET请求,添加错误跟踪
|
||||||
|
pub async fn get(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
proxy_type: ProxyType,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
accept_invalid_certs: bool,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let request = self.create_request(
|
||||||
|
url,
|
||||||
|
proxy_type,
|
||||||
|
timeout_secs,
|
||||||
|
user_agent,
|
||||||
|
accept_invalid_certs,
|
||||||
|
);
|
||||||
|
|
||||||
|
let timeout_duration = timeout_secs.unwrap_or(30);
|
||||||
|
|
||||||
|
match tokio::time::timeout(Duration::from_secs(timeout_duration), request.send()).await {
|
||||||
|
Ok(result) => match result {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(e) => {
|
||||||
|
self.record_connection_error(&e.to_string());
|
||||||
|
Err(anyhow::anyhow!("Failed to send HTTP request: {}", e))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
self.record_connection_error("Request timeout");
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"HTTP request timed out after {} seconds",
|
||||||
|
timeout_duration
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
pub async fn get_with_interrupt(
|
||||||
|
&self,
|
||||||
|
url: &str,
|
||||||
|
proxy_type: ProxyType,
|
||||||
|
timeout_secs: Option<u64>,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
accept_invalid_certs: bool,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let request = self.create_request(
|
||||||
|
url,
|
||||||
|
proxy_type,
|
||||||
|
timeout_secs,
|
||||||
|
user_agent,
|
||||||
|
accept_invalid_certs,
|
||||||
|
);
|
||||||
|
|
||||||
|
let timeout_duration = timeout_secs.unwrap_or(20);
|
||||||
|
|
||||||
|
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
|
||||||
|
let url_clone = url.to_string();
|
||||||
|
let watchdog = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(timeout_duration)).await;
|
||||||
|
let _ = cancel_tx.send(());
|
||||||
|
logging!(warn, Type::Network, true, "请求超时取消: {}", url_clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = tokio::select! {
|
||||||
|
result = request.send() => result,
|
||||||
|
_ = cancel_rx => {
|
||||||
|
self.record_connection_error(&format!("Request interrupted for: {}", url));
|
||||||
|
return Err(anyhow::anyhow!("Request interrupted after {} seconds", timeout_duration));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
watchdog.abort();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(e) => {
|
||||||
|
self.record_connection_error(&e.to_string());
|
||||||
|
Err(anyhow::anyhow!("Failed to send HTTP request: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代理类型
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ProxyType {
|
||||||
|
None,
|
||||||
|
Localhost,
|
||||||
|
System,
|
||||||
|
}
|
||||||
+303
-137
@@ -1,19 +1,23 @@
|
|||||||
#[cfg(target_os = "macos")]
|
|
||||||
use crate::AppHandleManager;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IVerge, PrfItem},
|
config::{Config, IVerge, PrfItem},
|
||||||
core::*,
|
core::*,
|
||||||
logging, logging_error,
|
logging, logging_error,
|
||||||
module::lightweight,
|
module::lightweight,
|
||||||
utils::{error, init, logging::Type, server},
|
process::AsyncHandler,
|
||||||
|
utils::{init, logging::Type, server},
|
||||||
wrap_err,
|
wrap_err,
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
|
use parking_lot::{Mutex, RwLock};
|
||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use serde_yaml::Mapping;
|
use serde_yaml::Mapping;
|
||||||
use std::net::TcpListener;
|
use std::{
|
||||||
use tauri::{App, Manager};
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use tauri::Url;
|
use tauri::Url;
|
||||||
//#[cfg(not(target_os = "linux"))]
|
//#[cfg(not(target_os = "linux"))]
|
||||||
@@ -21,8 +25,106 @@ use tauri::Url;
|
|||||||
|
|
||||||
pub static VERSION: OnceCell<String> = OnceCell::new();
|
pub static VERSION: OnceCell<String> = OnceCell::new();
|
||||||
|
|
||||||
pub fn find_unused_port() -> Result<u16> {
|
// 定义默认窗口尺寸常量
|
||||||
match TcpListener::bind("127.0.0.1:0") {
|
const DEFAULT_WIDTH: u32 = 940;
|
||||||
|
const DEFAULT_HEIGHT: u32 = 700;
|
||||||
|
|
||||||
|
// 添加全局UI准备就绪标志
|
||||||
|
static UI_READY: OnceCell<Arc<RwLock<bool>>> = OnceCell::new();
|
||||||
|
|
||||||
|
// 窗口创建锁,防止并发创建窗口
|
||||||
|
static WINDOW_CREATING: OnceCell<Mutex<(bool, Instant)>> = OnceCell::new();
|
||||||
|
|
||||||
|
// UI就绪阶段状态枚举
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum UiReadyStage {
|
||||||
|
NotStarted,
|
||||||
|
Loading,
|
||||||
|
DomReady,
|
||||||
|
ResourcesLoaded,
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI就绪详细状态
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UiReadyState {
|
||||||
|
stage: RwLock<UiReadyStage>,
|
||||||
|
last_update: RwLock<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiReadyState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
stage: RwLock::new(UiReadyStage::NotStarted),
|
||||||
|
last_update: RwLock::new(Instant::now()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取UI就绪状态细节
|
||||||
|
static UI_READY_STATE: OnceCell<Arc<UiReadyState>> = OnceCell::new();
|
||||||
|
|
||||||
|
fn get_window_creating_lock() -> &'static Mutex<(bool, Instant)> {
|
||||||
|
WINDOW_CREATING.get_or_init(|| Mutex::new((false, Instant::now())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_ui_ready() -> &'static Arc<RwLock<bool>> {
|
||||||
|
UI_READY.get_or_init(|| Arc::new(RwLock::new(false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_ui_ready_state() -> &'static Arc<UiReadyState> {
|
||||||
|
UI_READY_STATE.get_or_init(|| Arc::new(UiReadyState::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新UI准备阶段
|
||||||
|
pub fn update_ui_ready_stage(stage: UiReadyStage) {
|
||||||
|
let state = get_ui_ready_state();
|
||||||
|
let mut stage_lock = state.stage.write();
|
||||||
|
let mut time_lock = state.last_update.write();
|
||||||
|
|
||||||
|
*stage_lock = stage;
|
||||||
|
*time_lock = Instant::now();
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Window,
|
||||||
|
true,
|
||||||
|
"UI准备阶段更新: {:?}, 耗时: {:?}ms",
|
||||||
|
stage,
|
||||||
|
time_lock.elapsed().as_millis()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果是最终阶段,标记UI完全就绪
|
||||||
|
if stage == UiReadyStage::Ready {
|
||||||
|
mark_ui_ready();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记UI已准备就绪
|
||||||
|
pub fn mark_ui_ready() {
|
||||||
|
let mut ready = get_ui_ready().write();
|
||||||
|
*ready = true;
|
||||||
|
logging!(info, Type::Window, true, "UI已标记为完全就绪");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置UI就绪状态
|
||||||
|
pub fn reset_ui_ready() {
|
||||||
|
{
|
||||||
|
let mut ready = get_ui_ready().write();
|
||||||
|
*ready = false;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let state = get_ui_ready_state();
|
||||||
|
let mut stage = state.stage.write();
|
||||||
|
let mut time = state.last_update.write();
|
||||||
|
*stage = UiReadyStage::NotStarted;
|
||||||
|
*time = Instant::now();
|
||||||
|
}
|
||||||
|
logging!(info, Type::Window, true, "UI就绪状态已重置");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_unused_port() -> Result<u16> {
|
||||||
|
match TcpListener::bind("127.0.0.1:0").await {
|
||||||
Ok(listener) => {
|
Ok(listener) => {
|
||||||
let port = listener.local_addr()?.port();
|
let port = listener.local_addr()?.port();
|
||||||
Ok(port)
|
Ok(port)
|
||||||
@@ -38,40 +140,61 @@ pub fn find_unused_port() -> Result<u16> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// handle something when start app
|
/// 异步方式处理启动后的额外任务
|
||||||
pub async fn resolve_setup(app: &mut App) {
|
pub async fn resolve_setup_async(app_handle: &AppHandle) {
|
||||||
error::redirect_panic_to_log();
|
logging!(info, Type::Setup, true, "执行异步设置任务...");
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
if VERSION.get().is_none() {
|
||||||
AppHandleManager::global().init(app.app_handle().clone());
|
let version = app_handle.package_info().version.to_string();
|
||||||
AppHandleManager::global().set_activation_policy_accessory();
|
VERSION.get_or_init(|| {
|
||||||
|
logging!(info, Type::Setup, true, "初始化版本信息: {}", version);
|
||||||
|
version.clone()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let version = app.package_info().version.to_string();
|
|
||||||
|
|
||||||
handle::Handle::global().init(app.app_handle());
|
|
||||||
VERSION.get_or_init(|| version.clone());
|
|
||||||
|
|
||||||
logging_error!(Type::Config, true, init::init_config());
|
|
||||||
logging_error!(Type::Setup, true, init::init_resources());
|
|
||||||
logging_error!(Type::Setup, true, init::init_scheme());
|
logging_error!(Type::Setup, true, init::init_scheme());
|
||||||
|
|
||||||
logging_error!(Type::Setup, true, init::startup_script().await);
|
logging_error!(Type::Setup, true, init::startup_script().await);
|
||||||
// 处理随机端口
|
|
||||||
logging_error!(Type::System, true, resolve_random_port_config());
|
if let Err(err) = resolve_random_port_config().await {
|
||||||
// 启动核心
|
logging!(
|
||||||
logging!(trace, Type::Config, true, "Initial config");
|
error,
|
||||||
|
Type::System,
|
||||||
|
true,
|
||||||
|
"Failed to resolve random port config: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logging!(trace, Type::Config, true, "初始化配置...");
|
||||||
logging_error!(Type::Config, true, Config::init_config().await);
|
logging_error!(Type::Config, true, Config::init_config().await);
|
||||||
|
|
||||||
logging!(trace, Type::Core, "Starting CoreManager");
|
logging!(trace, Type::Core, true, "启动核心管理器...");
|
||||||
logging_error!(Type::Core, true, CoreManager::global().init().await);
|
logging_error!(Type::Core, true, CoreManager::global().init().await);
|
||||||
|
|
||||||
// setup a simple http server for singleton
|
log::trace!(target: "app", "启动内嵌服务器...");
|
||||||
log::trace!(target: "app", "launch embed server");
|
|
||||||
server::embed_server();
|
server::embed_server();
|
||||||
|
|
||||||
log::trace!(target: "app", "Initial system tray");
|
|
||||||
logging_error!(Type::Tray, true, tray::Tray::global().init());
|
logging_error!(Type::Tray, true, tray::Tray::global().init());
|
||||||
logging_error!(Type::Tray, true, tray::Tray::global().create_systray(app));
|
|
||||||
|
|
||||||
|
if let Some(app_handle) = handle::Handle::global().app_handle() {
|
||||||
|
logging!(info, Type::Tray, true, "创建系统托盘...");
|
||||||
|
let result = tray::Tray::global().create_tray_from_handle(&app_handle);
|
||||||
|
if result.is_ok() {
|
||||||
|
logging!(info, Type::Tray, true, "系统托盘创建成功");
|
||||||
|
} else if let Err(e) = result {
|
||||||
|
logging!(error, Type::Tray, true, "系统托盘创建失败: {}", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Tray,
|
||||||
|
true,
|
||||||
|
"无法创建系统托盘: app_handle不存在"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新系统代理
|
||||||
logging_error!(
|
logging_error!(
|
||||||
Type::System,
|
Type::System,
|
||||||
true,
|
true,
|
||||||
@@ -83,24 +206,29 @@ pub async fn resolve_setup(app: &mut App) {
|
|||||||
sysopt::Sysopt::global().init_guard_sysproxy()
|
sysopt::Sysopt::global().init_guard_sysproxy()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 创建窗口
|
||||||
let is_silent_start = { Config::verge().data().enable_silent_start }.unwrap_or(false);
|
let is_silent_start = { Config::verge().data().enable_silent_start }.unwrap_or(false);
|
||||||
create_window(!is_silent_start);
|
create_window(!is_silent_start);
|
||||||
|
|
||||||
|
// 初始化定时器
|
||||||
logging_error!(Type::System, true, timer::Timer::global().init());
|
logging_error!(Type::System, true, timer::Timer::global().init());
|
||||||
|
|
||||||
let enable_auto_light_weight_mode = { Config::verge().data().enable_auto_light_weight_mode };
|
// 自动进入轻量模式
|
||||||
if enable_auto_light_weight_mode.unwrap_or(false) {
|
let enable_auto_light_weight_mode =
|
||||||
|
{ Config::verge().data().enable_auto_light_weight_mode }.unwrap_or(false);
|
||||||
|
if enable_auto_light_weight_mode && !is_silent_start {
|
||||||
lightweight::enable_auto_light_weight_mode();
|
lightweight::enable_auto_light_weight_mode();
|
||||||
}
|
}
|
||||||
|
|
||||||
logging_error!(Type::Tray, true, tray::Tray::global().update_part());
|
logging_error!(Type::Tray, true, tray::Tray::global().update_part());
|
||||||
|
|
||||||
// 初始化热键
|
logging!(trace, Type::System, true, "初始化热键...");
|
||||||
logging!(trace, Type::System, true, "Initial hotkeys");
|
|
||||||
logging_error!(Type::System, true, hotkey::Hotkey::global().init());
|
logging_error!(Type::System, true, hotkey::Hotkey::global().init());
|
||||||
|
|
||||||
|
logging!(info, Type::Setup, true, "异步设置任务完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// reset system proxy (异步版)
|
/// reset system proxy (异步)
|
||||||
pub async fn resolve_reset_async() {
|
pub async fn resolve_reset_async() {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
logging!(info, Type::Tray, true, "Unsubscribing from traffic updates");
|
logging!(info, Type::Tray, true, "Unsubscribing from traffic updates");
|
||||||
@@ -120,122 +248,148 @@ pub async fn resolve_reset_async() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// create main window
|
/// Create the main window
|
||||||
pub fn create_window(is_showup: bool) {
|
pub fn create_window(is_show: bool) -> bool {
|
||||||
logging!(info, Type::Window, true, "Creating window");
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Window,
|
||||||
|
true,
|
||||||
|
"开始创建/显示主窗口, is_show={}",
|
||||||
|
is_show
|
||||||
|
);
|
||||||
|
|
||||||
let app_handle = handle::Handle::global().app_handle().unwrap();
|
if let Some(app_handle) = handle::Handle::global().app_handle() {
|
||||||
#[cfg(target_os = "macos")]
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
AppHandleManager::global().set_activation_policy_regular();
|
logging!(info, Type::Window, true, "主窗口已存在,将显示现有窗口");
|
||||||
|
if is_show {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(window) = handle::Handle::global().get_window() {
|
let creating_lock = get_window_creating_lock();
|
||||||
|
let mut creating = creating_lock.lock();
|
||||||
|
|
||||||
|
let (is_creating, last_time) = *creating;
|
||||||
|
let elapsed = last_time.elapsed();
|
||||||
|
|
||||||
|
if is_creating && elapsed < Duration::from_secs(2) {
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::Window,
|
Type::Window,
|
||||||
true,
|
true,
|
||||||
"Found existing window, attempting to restore visibility"
|
"窗口创建请求被忽略,因为最近创建过 ({:?}ms)",
|
||||||
|
elapsed.as_millis()
|
||||||
);
|
);
|
||||||
|
return false;
|
||||||
if window.is_minimized().unwrap_or(false) {
|
|
||||||
logging!(
|
|
||||||
info,
|
|
||||||
Type::Window,
|
|
||||||
true,
|
|
||||||
"Window is minimized, restoring window state"
|
|
||||||
);
|
|
||||||
let _ = window.unminimize();
|
|
||||||
}
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logging!(info, Type::Window, true, "Creating new application window");
|
*creating = (true, Instant::now());
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
match tauri::WebviewWindowBuilder::new(
|
||||||
let window = tauri::WebviewWindowBuilder::new(
|
&handle::Handle::global().app_handle().unwrap(),
|
||||||
&app_handle,
|
"main", /* the unique window label */
|
||||||
"main".to_string(),
|
|
||||||
tauri::WebviewUrl::App("index.html".into()),
|
|
||||||
)
|
|
||||||
.title("Clash Verge")
|
|
||||||
.inner_size(890.0, 700.0)
|
|
||||||
.min_inner_size(620.0, 550.0)
|
|
||||||
.decorations(false)
|
|
||||||
.maximizable(true)
|
|
||||||
.additional_browser_args("--enable-features=msWebView2EnableDraggableRegions --disable-features=OverscrollHistoryNavigation,msExperimentalScrolling")
|
|
||||||
.transparent(true)
|
|
||||||
.shadow(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let window = tauri::WebviewWindowBuilder::new(
|
|
||||||
&app_handle,
|
|
||||||
"main".to_string(),
|
|
||||||
tauri::WebviewUrl::App("index.html".into()),
|
|
||||||
)
|
|
||||||
.decorations(true)
|
|
||||||
.hidden_title(true)
|
|
||||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
|
||||||
.inner_size(890.0, 700.0)
|
|
||||||
.min_inner_size(620.0, 550.0)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let window = tauri::WebviewWindowBuilder::new(
|
|
||||||
&app_handle,
|
|
||||||
"main".to_string(),
|
|
||||||
tauri::WebviewUrl::App("index.html".into()),
|
tauri::WebviewUrl::App("index.html".into()),
|
||||||
)
|
)
|
||||||
.title("Clash Verge")
|
.title("Clash Verge")
|
||||||
.decorations(false)
|
.center()
|
||||||
.inner_size(890.0, 700.0)
|
.decorations(true)
|
||||||
.min_inner_size(620.0, 550.0)
|
.fullscreen(false)
|
||||||
.transparent(true)
|
.inner_size(DEFAULT_WIDTH as f64, DEFAULT_HEIGHT as f64)
|
||||||
.build();
|
.min_inner_size(520.0, 520.0)
|
||||||
|
.visible(false)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(newly_created_window) => {
|
||||||
|
logging!(debug, Type::Window, true, "主窗口实例创建成功");
|
||||||
|
|
||||||
match window {
|
*creating = (false, Instant::now());
|
||||||
Ok(window) => {
|
|
||||||
logging!(info, Type::Window, true, "Window created successfully");
|
|
||||||
if is_showup {
|
|
||||||
println!("is showup");
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
} else {
|
|
||||||
let _ = window.hide();
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
AppHandleManager::global().set_activation_policy_accessory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置窗口状态监控,实时保存窗口位置和大小
|
update_ui_ready_stage(UiReadyStage::NotStarted);
|
||||||
// crate::feat::setup_window_state_monitor(&app_handle);
|
|
||||||
|
|
||||||
// 标记前端UI已准备就绪,向前端发送启动完成事件
|
|
||||||
let app_handle_clone = app_handle.clone();
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
use tauri::Emitter;
|
|
||||||
|
|
||||||
|
AsyncHandler::spawn(move || async move {
|
||||||
|
handle::Handle::global().mark_startup_completed();
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
debug,
|
||||||
Type::Window,
|
Type::Window,
|
||||||
true,
|
true,
|
||||||
"标记前端UI已准备就绪,开始处理启动错误队列"
|
"异步窗口任务开始 (启动已标记完成)"
|
||||||
);
|
);
|
||||||
handle::Handle::global().mark_startup_completed();
|
|
||||||
|
|
||||||
if let Some(window) = app_handle_clone.get_webview_window("main") {
|
if is_show {
|
||||||
let _ = window.emit("verge://startup-completed", ());
|
let window_clone = newly_created_window.clone();
|
||||||
|
|
||||||
|
// Attempt to show and focus the window first.
|
||||||
|
let _ = window_clone.show();
|
||||||
|
let _ = window_clone.set_focus();
|
||||||
|
logging!(debug, Type::Window, true, "窗口已尝试显示和聚焦");
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await; // Crucial delay
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Window,
|
||||||
|
true,
|
||||||
|
"延时后,尝试发送 verge://startup-completed 事件"
|
||||||
|
);
|
||||||
|
handle::Handle::notify_startup_completed();
|
||||||
|
|
||||||
|
let timeout_seconds = if crate::module::lightweight::is_in_lightweight_mode() {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
5
|
||||||
|
};
|
||||||
|
|
||||||
|
logging!(
|
||||||
|
info,
|
||||||
|
Type::Window,
|
||||||
|
true,
|
||||||
|
"等待UI就绪 (最多{}秒)...",
|
||||||
|
timeout_seconds
|
||||||
|
);
|
||||||
|
|
||||||
|
let wait_result =
|
||||||
|
tokio::time::timeout(Duration::from_secs(timeout_seconds), async {
|
||||||
|
while !*get_ui_ready().read() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match wait_result {
|
||||||
|
Ok(_) => {
|
||||||
|
logging!(info, Type::Window, true, "UI就绪");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Window,
|
||||||
|
true,
|
||||||
|
"等待UI就绪超时({}秒),强制标记就绪",
|
||||||
|
timeout_seconds
|
||||||
|
);
|
||||||
|
*get_ui_ready().write() = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logging!(info, Type::Window, true, "窗口显示流程完成");
|
||||||
|
} else {
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Window,
|
||||||
|
true,
|
||||||
|
"is_show为false,窗口保持隐藏。尝试发送启动事件。"
|
||||||
|
);
|
||||||
|
handle::Handle::notify_startup_completed();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
logging!(
|
logging!(error, Type::Window, true, "主窗口构建失败: {}", e);
|
||||||
error,
|
*creating = (false, Instant::now()); // Reset the creating state if window creation failed
|
||||||
Type::Window,
|
false
|
||||||
true,
|
|
||||||
"Failed to create window: {:?}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +419,6 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
|
|||||||
.find(|(key, _)| key == "name")
|
.find(|(key, _)| key == "name")
|
||||||
.map(|(_, value)| value.into_owned());
|
.map(|(_, value)| value.into_owned());
|
||||||
|
|
||||||
// 通过直接获取查询部分并解析特定参数来避免 URL 转义问题
|
|
||||||
let url_param = if let Some(query) = link_parsed.query() {
|
let url_param = if let Some(query) = link_parsed.query() {
|
||||||
let prefix = "url=";
|
let prefix = "url=";
|
||||||
if let Some(pos) = query.find(prefix) {
|
if let Some(pos) = query.find(prefix) {
|
||||||
@@ -301,7 +454,7 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_random_port_config() -> Result<()> {
|
async fn resolve_random_port_config() -> Result<()> {
|
||||||
let verge_config = Config::verge();
|
let verge_config = Config::verge();
|
||||||
let clash_config = Config::clash();
|
let clash_config = Config::clash();
|
||||||
let enable_random_port = verge_config.latest().enable_random_port.unwrap_or(false);
|
let enable_random_port = verge_config.latest().enable_random_port.unwrap_or(false);
|
||||||
@@ -312,21 +465,34 @@ fn resolve_random_port_config() -> Result<()> {
|
|||||||
.unwrap_or(clash_config.data().get_mixed_port());
|
.unwrap_or(clash_config.data().get_mixed_port());
|
||||||
|
|
||||||
let port = if enable_random_port {
|
let port = if enable_random_port {
|
||||||
find_unused_port().unwrap_or(default_port)
|
find_unused_port().await.unwrap_or(default_port)
|
||||||
} else {
|
} else {
|
||||||
default_port
|
default_port
|
||||||
};
|
};
|
||||||
|
|
||||||
verge_config.data().patch_config(IVerge {
|
let port_to_save = port;
|
||||||
verge_mixed_port: Some(port),
|
|
||||||
..IVerge::default()
|
tokio::task::spawn_blocking(move || {
|
||||||
});
|
let verge_config_accessor = Config::verge();
|
||||||
verge_config.data().save_file()?;
|
let mut verge_data = verge_config_accessor.data();
|
||||||
|
verge_data.patch_config(IVerge {
|
||||||
|
verge_mixed_port: Some(port_to_save),
|
||||||
|
..IVerge::default()
|
||||||
|
});
|
||||||
|
verge_data.save_file()
|
||||||
|
})
|
||||||
|
.await??; // First ? for spawn_blocking error, second for save_file Result
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let clash_config_accessor = Config::clash(); // Extend lifetime of the accessor
|
||||||
|
let mut clash_data = clash_config_accessor.data(); // Access within blocking task, made mutable
|
||||||
|
let mut mapping = Mapping::new();
|
||||||
|
mapping.insert("mixed-port".into(), port_to_save.into());
|
||||||
|
clash_data.patch_config(mapping);
|
||||||
|
clash_data.save_config()
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
let mut mapping = Mapping::new();
|
|
||||||
mapping.insert("mixed-port".into(), port.into());
|
|
||||||
clash_config.data().patch_config(mapping);
|
|
||||||
clash_config.data().save_config()?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use super::resolve;
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IVerge, DEFAULT_PAC},
|
config::{Config, IVerge, DEFAULT_PAC},
|
||||||
logging_error,
|
logging_error,
|
||||||
|
process::AsyncHandler,
|
||||||
utils::logging::Type,
|
utils::logging::Type,
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
@@ -47,7 +48,7 @@ pub async fn check_singleton() -> Result<()> {
|
|||||||
pub fn embed_server() {
|
pub fn embed_server() {
|
||||||
let port = IVerge::get_singleton_port();
|
let port = IVerge::get_singleton_port();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
let visible = warp::path!("commands" / "visible").map(move || {
|
let visible = warp::path!("commands" / "visible").map(move || {
|
||||||
resolve::create_window(false);
|
resolve::create_window(false);
|
||||||
"ok"
|
"ok"
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ pub const ITEM_MERGE: &str = "# Profile Enhancement Merge Template for Clash Ver
|
|||||||
|
|
||||||
profile:
|
profile:
|
||||||
store-selected: true
|
store-selected: true
|
||||||
|
|
||||||
dns:
|
|
||||||
use-system-hosts: false
|
|
||||||
";
|
";
|
||||||
|
|
||||||
pub const ITEM_MERGE_EMPTY: &str = "# Profile Enhancement Merge Template for Clash Verge
|
pub const ITEM_MERGE_EMPTY: &str = "# Profile Enhancement Merge Template for Clash Verge
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/mihomo.sock"
|
||||||
|
LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/mihomo.sock"
|
||||||
@@ -6,9 +6,9 @@ edition = "2024"
|
|||||||
debug = []
|
debug = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.12.15", features = ["json"] }
|
reqwest = { version = "0.12.19", features = ["json"] }
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
|
tokio = { version = "1.45.1", features = ["rt", "macros", "time"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.44.1", features = ["rt", "macros"] }
|
|
||||||
|
|||||||
@@ -1,60 +1,34 @@
|
|||||||
use reqwest::{Method, header::HeaderMap};
|
use reqwest::{Method, header::HeaderMap};
|
||||||
use serde_json::json;
|
use serde_json::{Value, json};
|
||||||
use std::{
|
use std::time::Duration;
|
||||||
sync::{Arc, Mutex},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub use model::{MihomoData, MihomoManager};
|
pub use model::MihomoManager;
|
||||||
|
|
||||||
impl MihomoManager {
|
impl MihomoManager {
|
||||||
pub fn new(mihomo_server: String, headers: HeaderMap) -> Self {
|
pub fn new(mihomo_server: String, headers: HeaderMap) -> Self {
|
||||||
|
let client = reqwest::ClientBuilder::new()
|
||||||
|
.default_headers(headers)
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
|
.pool_max_idle_per_host(5)
|
||||||
|
.pool_idle_timeout(Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build reqwest client");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
mihomo_server,
|
mihomo_server,
|
||||||
data: Arc::new(Mutex::new(MihomoData {
|
client,
|
||||||
proxies: serde_json::Value::Null,
|
|
||||||
providers_proxies: serde_json::Value::Null,
|
|
||||||
})),
|
|
||||||
headers,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_proxies(&self, proxies: serde_json::Value) {
|
|
||||||
let mut data = self.data.lock().unwrap();
|
|
||||||
data.proxies = proxies;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_providers_proxies(&self, providers_proxies: serde_json::Value) {
|
|
||||||
let mut data = self.data.lock().unwrap();
|
|
||||||
data.providers_proxies = providers_proxies;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_mihomo_server(&self) -> String {
|
|
||||||
self.mihomo_server.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_proxies(&self) -> serde_json::Value {
|
|
||||||
let data = self.data.lock().unwrap();
|
|
||||||
data.proxies.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_providers_proxies(&self) -> serde_json::Value {
|
|
||||||
let data = self.data.lock().unwrap();
|
|
||||||
data.providers_proxies.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_request(
|
async fn send_request(
|
||||||
&self,
|
&self,
|
||||||
method: Method,
|
method: Method,
|
||||||
url: String,
|
url: String,
|
||||||
data: Option<serde_json::Value>,
|
data: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let client_response = reqwest::ClientBuilder::new()
|
let client_response = self
|
||||||
.default_headers(self.headers.clone())
|
.client
|
||||||
.no_proxy()
|
|
||||||
.timeout(Duration::from_secs(60))
|
|
||||||
.build()
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.request(method.clone(), &url)
|
.request(method.clone(), &url)
|
||||||
.json(&data.unwrap_or(json!({})))
|
.json(&data.unwrap_or(json!({})))
|
||||||
.send()
|
.send()
|
||||||
@@ -82,18 +56,29 @@ impl MihomoManager {
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_proxies(&self) -> Result<&Self, String> {
|
pub async fn get_refresh_proxies(&self) -> Result<Value, String> {
|
||||||
let url = format!("{}/proxies", self.mihomo_server);
|
let url = format!("{}/proxies", self.mihomo_server);
|
||||||
let proxies = self.send_request(Method::GET, url, None).await?;
|
let proxies = self.send_request(Method::GET, url, None).await?;
|
||||||
self.update_proxies(proxies);
|
Ok(proxies)
|
||||||
Ok(self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_providers_proxies(&self) -> Result<&Self, String> {
|
pub async fn get_providers_proxies(&self) -> Result<Value, String> {
|
||||||
let url = format!("{}/providers/proxies", self.mihomo_server);
|
let url = format!("{}/providers/proxies", self.mihomo_server);
|
||||||
let providers_proxies = self.send_request(Method::GET, url, None).await?;
|
let providers_proxies = self.send_request(Method::GET, url, None).await?;
|
||||||
self.update_providers_proxies(providers_proxies);
|
Ok(providers_proxies)
|
||||||
Ok(self)
|
}
|
||||||
|
|
||||||
|
pub async fn close_all_connections(&self) -> Result<(), String> {
|
||||||
|
let url = format!("{}/connections", self.mihomo_server);
|
||||||
|
let response = self.send_request(Method::DELETE, url, None).await?;
|
||||||
|
if response["code"] == 204 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(response["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("unknown error")
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +117,7 @@ impl MihomoManager {
|
|||||||
test_url: Option<String>,
|
test_url: Option<String>,
|
||||||
timeout: i32,
|
timeout: i32,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let test_url = test_url.unwrap_or("http://cp.cloudflare.com/generate_204".to_string());
|
let test_url = test_url.unwrap_or("https://cp.cloudflare.com/generate_204".to_string());
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/proxies/{}/delay?url={}&timeout={}",
|
"{}/proxies/{}/delay?url={}&timeout={}",
|
||||||
self.mihomo_server, name, test_url, timeout
|
self.mihomo_server, name, test_url, timeout
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use reqwest::header::HeaderMap;
|
|
||||||
|
|
||||||
pub struct MihomoData {
|
|
||||||
pub(crate) proxies: serde_json::Value,
|
|
||||||
pub(crate) providers_proxies: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct MihomoManager {
|
pub struct MihomoManager {
|
||||||
pub(crate) mihomo_server: String,
|
pub(crate) mihomo_server: String,
|
||||||
pub(crate) data: Arc<Mutex<MihomoData>>,
|
pub(crate) client: reqwest::Client,
|
||||||
pub(crate) headers: HeaderMap,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "debug")]
|
#[cfg(feature = "debug")]
|
||||||
@@ -25,5 +16,4 @@ impl Drop for MihomoManager {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
println!("Dropping MihomoManager");
|
println!("Dropping MihomoManager");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,7 @@
|
|||||||
use mihomo_api;
|
|
||||||
use reqwest::header::HeaderMap;
|
use reqwest::header::HeaderMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mihomo_manager_init() {
|
fn test_mihomo_manager_init() {
|
||||||
let manager = mihomo_api::MihomoManager::new("url".into(), HeaderMap::new());
|
let _ = mihomo_api::MihomoManager::new("url".into(), HeaderMap::new());
|
||||||
assert_eq!(manager.get_proxies(), serde_json::Value::Null);
|
assert_eq!(true, true);
|
||||||
assert_eq!(manager.get_providers_proxies(), serde_json::Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_refresh_proxies() {
|
|
||||||
let manager = mihomo_api::MihomoManager::new("http://127.0.0.1:9097".into(), HeaderMap::new());
|
|
||||||
let manager = manager.refresh_proxies().await.unwrap();
|
|
||||||
let proxies = manager.get_proxies();
|
|
||||||
let providers = manager.get_providers_proxies();
|
|
||||||
assert_ne!(proxies, serde_json::Value::Null);
|
|
||||||
assert_eq!(providers, serde_json::Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_refresh_providers_proxies() {
|
|
||||||
let manager = mihomo_api::MihomoManager::new("http://127.0.0.1:9097".into(), HeaderMap::new());
|
|
||||||
let manager = manager.refresh_providers_proxies().await.unwrap();
|
|
||||||
let proxies = manager.get_proxies();
|
|
||||||
let providers = manager.get_providers_proxies();
|
|
||||||
assert_eq!(proxies, serde_json::Value::Null);
|
|
||||||
assert_ne!(providers, serde_json::Value::Null);
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.2.3",
|
"version": "2.3.0",
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
|||||||
@@ -21,6 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": []
|
"windows": [],
|
||||||
|
"security": {
|
||||||
|
"capabilities": [
|
||||||
|
"desktop-capability",
|
||||||
|
"desktop-windows-capability",
|
||||||
|
"migrated"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { AppDataProvider } from "./providers/app-data-provider";
|
import { AppDataProvider } from "./providers/app-data-provider";
|
||||||
import React from "react";
|
|
||||||
import Layout from "./pages/_layout";
|
import Layout from "./pages/_layout";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|||||||
@@ -129,14 +129,14 @@
|
|||||||
flex: 1 0 58px;
|
flex: 1 0 58px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
padding-top: 30px;
|
padding-top: 5px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout__right .the-content {
|
.layout__right .the-content {
|
||||||
top: 24px;
|
top: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,11 +144,11 @@
|
|||||||
.macos {
|
.macos {
|
||||||
&.layout {
|
&.layout {
|
||||||
.layout__left {
|
.layout__left {
|
||||||
padding-top: 24px;
|
padding-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout__right .the-content {
|
.layout__right .the-content {
|
||||||
top: 24px;
|
top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout__left .the-newbtn {
|
.layout__left .the-newbtn {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Snackbar, Alert, IconButton, Box } from '@mui/material';
|
||||||
|
import { CloseRounded } from '@mui/icons-material';
|
||||||
|
import { subscribeNotices, hideNotice, NoticeItem } from '@/services/noticeService';
|
||||||
|
|
||||||
|
export const NoticeManager: React.FC = () => {
|
||||||
|
const [currentNotices, setCurrentNotices] = useState<NoticeItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribeNotices((notices) => {
|
||||||
|
setCurrentNotices(notices);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = (id: number) => {
|
||||||
|
hideNotice(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
right: '20px',
|
||||||
|
zIndex: 1500,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '10px',
|
||||||
|
maxWidth: '360px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentNotices.map((notice) => (
|
||||||
|
<Snackbar
|
||||||
|
key={notice.id}
|
||||||
|
open={true}
|
||||||
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
transform: 'none',
|
||||||
|
top: 'auto',
|
||||||
|
right: 'auto',
|
||||||
|
bottom: 'auto',
|
||||||
|
left: 'auto',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity={notice.type}
|
||||||
|
variant="filled"
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
action={
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => handleClose(notice.id)}
|
||||||
|
>
|
||||||
|
<CloseRounded fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{notice.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { ReactNode, useState, useEffect } from "react";
|
|
||||||
import { Box, IconButton, Slide, Snackbar, Typography } from "@mui/material";
|
|
||||||
import {
|
|
||||||
CloseRounded,
|
|
||||||
CheckCircleRounded,
|
|
||||||
ErrorRounded,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
const appWindow = getCurrentWebviewWindow();
|
|
||||||
interface InnerProps {
|
|
||||||
type: string;
|
|
||||||
duration?: number;
|
|
||||||
message: ReactNode;
|
|
||||||
isDark?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NoticeInner = (props: InnerProps) => {
|
|
||||||
const { type, message, duration, onClose } = props;
|
|
||||||
const [visible, setVisible] = useState(true);
|
|
||||||
const [isDark, setIsDark] = useState(false);
|
|
||||||
const { verge } = useVerge();
|
|
||||||
const { theme_mode } = verge ?? {};
|
|
||||||
const onBtnClose = () => {
|
|
||||||
setVisible(false);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
const onAutoClose = (_e: any, reason: string) => {
|
|
||||||
if (reason !== "clickaway") onBtnClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const themeMode = ["light", "dark", "system"].includes(theme_mode!)
|
|
||||||
? theme_mode!
|
|
||||||
: "light";
|
|
||||||
|
|
||||||
if (themeMode !== "system") {
|
|
||||||
setIsDark(themeMode === "dark");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
appWindow.theme().then((m) => m && setIsDark(m === "dark"));
|
|
||||||
const unlisten = appWindow.onThemeChanged((e) =>
|
|
||||||
setIsDark(e.payload === "dark"),
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlisten.then((fn) => fn());
|
|
||||||
};
|
|
||||||
}, [theme_mode]);
|
|
||||||
|
|
||||||
const msgElement =
|
|
||||||
type === "info" ? (
|
|
||||||
message
|
|
||||||
) : (
|
|
||||||
<Box sx={{ width: 328, display: "flex", alignItems: "center" }}>
|
|
||||||
{type === "error" && <ErrorRounded color="error" />}
|
|
||||||
{type === "success" && <CheckCircleRounded color="success" />}
|
|
||||||
|
|
||||||
<Typography
|
|
||||||
component="span"
|
|
||||||
sx={{ ml: 1, wordWrap: "break-word", width: "calc(100% - 35px)" }}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Snackbar
|
|
||||||
open={visible}
|
|
||||||
anchorOrigin={{ vertical: "top", horizontal: "right" }}
|
|
||||||
autoHideDuration={duration === -1 ? null : duration || 1500}
|
|
||||||
onClose={onAutoClose}
|
|
||||||
message={msgElement}
|
|
||||||
sx={{
|
|
||||||
maxWidth: 360,
|
|
||||||
".MuiSnackbarContent-root": {
|
|
||||||
bgcolor: isDark ? "#50515C" : "#ffffff",
|
|
||||||
color: isDark ? "#ffffff" : "#000000",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
TransitionComponent={(p) => <Slide {...p} direction="left" />}
|
|
||||||
transitionDuration={200}
|
|
||||||
action={
|
|
||||||
<IconButton size="small" color="inherit" onClick={onBtnClose}>
|
|
||||||
<CloseRounded fontSize="inherit" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface NoticeInstance {
|
|
||||||
(props: Omit<InnerProps, "onClose">): void;
|
|
||||||
|
|
||||||
info(message: ReactNode, duration?: number, isDark?: boolean): void;
|
|
||||||
error(message: ReactNode, duration?: number, isDark?: boolean): void;
|
|
||||||
success(message: ReactNode, duration?: number, isDark?: boolean): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent: HTMLDivElement = null!;
|
|
||||||
|
|
||||||
export const Notice: NoticeInstance = (props) => {
|
|
||||||
const { type, message, duration } = props;
|
|
||||||
|
|
||||||
// 验证必要的参数
|
|
||||||
if (!message) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parent) {
|
|
||||||
parent = document.createElement("div");
|
|
||||||
parent.setAttribute("id", "notice-container"); // 添加 id 便于调试
|
|
||||||
document.body.appendChild(parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
parent.appendChild(container);
|
|
||||||
|
|
||||||
const root = createRoot(container);
|
|
||||||
|
|
||||||
const onUnmount = () => {
|
|
||||||
root.unmount();
|
|
||||||
if (parent && container.parentNode === parent) {
|
|
||||||
setTimeout(() => {
|
|
||||||
parent.removeChild(container);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
root.render(
|
|
||||||
<NoticeInner
|
|
||||||
type={type}
|
|
||||||
message={message}
|
|
||||||
duration={duration || 1500}
|
|
||||||
onClose={onUnmount}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNoticeTypeFactory =
|
|
||||||
(type: keyof NoticeInstance) => (message: ReactNode, duration?: number) => {
|
|
||||||
// 确保消息不为空
|
|
||||||
if (!message) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notice({
|
|
||||||
type,
|
|
||||||
message,
|
|
||||||
// 错误类型通知显示 8 秒,其他类型默认 1.5 秒
|
|
||||||
duration: type === "error" ? 8000 : duration || 1500,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Notice.info = createNoticeTypeFactory("info");
|
|
||||||
Notice.error = createNoticeTypeFactory("error");
|
|
||||||
Notice.success = createNoticeTypeFactory("success");
|
|
||||||
@@ -118,44 +118,46 @@ export const BaseSearchBox = (props: SearchProps) => {
|
|||||||
placeholder={props.placeholder ?? t("Filter conditions")}
|
placeholder={props.placeholder ?? t("Filter conditions")}
|
||||||
sx={{ input: { py: 0.65, px: 1.25 } }}
|
sx={{ input: { py: 0.65, px: 1.25 } }}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
InputProps={{
|
slotProps={{
|
||||||
sx: { pr: 1 },
|
input: {
|
||||||
endAdornment: (
|
sx: { pr: 1 },
|
||||||
<Box display="flex">
|
endAdornment: (
|
||||||
<Tooltip title={t("Match Case")}>
|
<Box display="flex">
|
||||||
<div>
|
<Tooltip title={t("Match Case")}>
|
||||||
<SvgIcon
|
<div>
|
||||||
component={matchCaseIcon}
|
<SvgIcon
|
||||||
{...iconStyle}
|
component={matchCaseIcon}
|
||||||
aria-label={matchCase ? "active" : "inactive"}
|
{...iconStyle}
|
||||||
onClick={() => setMatchCase(!matchCase)}
|
aria-label={matchCase ? "active" : "inactive"}
|
||||||
/>
|
onClick={() => setMatchCase(!matchCase)}
|
||||||
</div>
|
/>
|
||||||
</Tooltip>
|
</div>
|
||||||
<Tooltip title={t("Match Whole Word")}>
|
</Tooltip>
|
||||||
<div>
|
<Tooltip title={t("Match Whole Word")}>
|
||||||
<SvgIcon
|
<div>
|
||||||
component={matchWholeWordIcon}
|
<SvgIcon
|
||||||
{...iconStyle}
|
component={matchWholeWordIcon}
|
||||||
aria-label={matchWholeWord ? "active" : "inactive"}
|
{...iconStyle}
|
||||||
onClick={() => setMatchWholeWord(!matchWholeWord)}
|
aria-label={matchWholeWord ? "active" : "inactive"}
|
||||||
/>
|
onClick={() => setMatchWholeWord(!matchWholeWord)}
|
||||||
</div>
|
/>
|
||||||
</Tooltip>
|
</div>
|
||||||
<Tooltip title={t("Use Regular Expression")}>
|
</Tooltip>
|
||||||
<div>
|
<Tooltip title={t("Use Regular Expression")}>
|
||||||
<SvgIcon
|
<div>
|
||||||
component={useRegularExpressionIcon}
|
<SvgIcon
|
||||||
aria-label={useRegularExpression ? "active" : "inactive"}
|
component={useRegularExpressionIcon}
|
||||||
{...iconStyle}
|
aria-label={useRegularExpression ? "active" : "inactive"}
|
||||||
onClick={() =>
|
{...iconStyle}
|
||||||
setUseRegularExpression(!useRegularExpression)
|
onClick={() =>
|
||||||
}
|
setUseRegularExpression(!useRegularExpression)
|
||||||
/>{" "}
|
}
|
||||||
</div>
|
/>{" "}
|
||||||
</Tooltip>
|
</div>
|
||||||
</Box>
|
</Tooltip>
|
||||||
),
|
</Box>
|
||||||
|
),
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ export { BasePage } from "./base-page";
|
|||||||
export { BaseEmpty } from "./base-empty";
|
export { BaseEmpty } from "./base-empty";
|
||||||
export { BaseLoading } from "./base-loading";
|
export { BaseLoading } from "./base-loading";
|
||||||
export { BaseErrorBoundary } from "./base-error-boundary";
|
export { BaseErrorBoundary } from "./base-error-boundary";
|
||||||
export { Notice } from "./base-notice";
|
|
||||||
export { Switch } from "./base-switch";
|
export { Switch } from "./base-switch";
|
||||||
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
||||||
|
export { NoticeManager } from "./NoticeManager";
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useAppData } from "@/providers/app-data-provider";
|
|||||||
export const ClashModeCard = () => {
|
export const ClashModeCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const { clashConfig, refreshProxy } = useAppData();
|
const { clashConfig, refreshClashConfig } = useAppData();
|
||||||
|
|
||||||
// 支持的模式列表
|
// 支持的模式列表
|
||||||
const modeList = useMemo(() => ["rule", "global", "direct"] as const, []);
|
const modeList = useMemo(() => ["rule", "global", "direct"] as const, []);
|
||||||
@@ -40,7 +40,7 @@ export const ClashModeCard = () => {
|
|||||||
try {
|
try {
|
||||||
await patchClashMode(mode);
|
await patchClashMode(mode);
|
||||||
// 使用共享的刷新方法
|
// 使用共享的刷新方法
|
||||||
refreshProxy();
|
refreshClashConfig();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to change mode:", error);
|
console.error("Failed to change mode:", error);
|
||||||
}
|
}
|
||||||
@@ -70,15 +70,15 @@ export const ClashModeCard = () => {
|
|||||||
},
|
},
|
||||||
"&::after": mode === currentMode
|
"&::after": mode === currentMode
|
||||||
? {
|
? {
|
||||||
content: '""',
|
content: '""',
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: -16,
|
bottom: -16,
|
||||||
left: "50%",
|
left: "50%",
|
||||||
width: 2,
|
width: 2,
|
||||||
height: 16,
|
height: 16,
|
||||||
bgcolor: "primary.main",
|
bgcolor: "primary.main",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ export const ClashModeCard = () => {
|
|||||||
component="div"
|
component="div"
|
||||||
sx={descriptionStyles}
|
sx={descriptionStyles}
|
||||||
>
|
>
|
||||||
{t(`${currentMode} Mode Description`)}
|
{t(`${currentMode?.charAt(0).toUpperCase()}${currentMode?.slice(1)} Mode Description`)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
IconButton,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
SignalWifi0Bar as SignalNone,
|
SignalWifi0Bar as SignalNone,
|
||||||
WifiOff as SignalError,
|
WifiOff as SignalError,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
SortRounded,
|
||||||
|
AccessTimeRounded,
|
||||||
|
SortByAlphaRounded,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { EnhancedCard } from "@/components/home/enhanced-card";
|
import { EnhancedCard } from "@/components/home/enhanced-card";
|
||||||
@@ -33,18 +37,20 @@ import { useAppData } from "@/providers/app-data-provider";
|
|||||||
// 本地存储的键名
|
// 本地存储的键名
|
||||||
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
|
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
|
||||||
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
|
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
|
||||||
|
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
|
||||||
|
|
||||||
// 代理节点信息接口
|
// 代理节点信息接口
|
||||||
interface ProxyOption {
|
interface ProxyOption {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将delayManager返回的颜色格式转换为MUI Chip组件需要的格式
|
// 排序类型: 默认 | 按延迟 | 按字母
|
||||||
|
export type ProxySortType = 0 | 1 | 2;
|
||||||
|
|
||||||
function convertDelayColor(delayValue: number) {
|
function convertDelayColor(delayValue: number) {
|
||||||
const colorStr = delayManager.formatDelayColor(delayValue);
|
const colorStr = delayManager.formatDelayColor(delayValue);
|
||||||
if (!colorStr) return "default";
|
if (!colorStr) return "default";
|
||||||
|
|
||||||
// 从"error.main"这样的格式转为"error"
|
|
||||||
const mainColor = colorStr.split(".")[0];
|
const mainColor = colorStr.split(".")[0];
|
||||||
|
|
||||||
switch (mainColor) {
|
switch (mainColor) {
|
||||||
@@ -61,7 +67,6 @@ function convertDelayColor(delayValue: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据延迟值获取合适的WiFi信号图标
|
|
||||||
function getSignalIcon(delay: number) {
|
function getSignalIcon(delay: number) {
|
||||||
if (delay < 0)
|
if (delay < 0)
|
||||||
return { icon: <SignalNone />, text: "未测试", color: "text.secondary" };
|
return { icon: <SignalNone />, text: "未测试", color: "text.secondary" };
|
||||||
@@ -97,6 +102,12 @@ export const CurrentProxyCard = () => {
|
|||||||
const isGlobalMode = mode === "global";
|
const isGlobalMode = mode === "global";
|
||||||
const isDirectMode = mode === "direct";
|
const isDirectMode = mode === "direct";
|
||||||
|
|
||||||
|
// 添加排序类型状态
|
||||||
|
const [sortType, setSortType] = useState<ProxySortType>(() => {
|
||||||
|
const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE);
|
||||||
|
return savedSortType ? Number(savedSortType) as ProxySortType : 0;
|
||||||
|
});
|
||||||
|
|
||||||
// 定义状态类型
|
// 定义状态类型
|
||||||
type ProxyState = {
|
type ProxyState = {
|
||||||
proxyData: {
|
proxyData: {
|
||||||
@@ -112,13 +123,12 @@ export const CurrentProxyCard = () => {
|
|||||||
displayProxy: any;
|
displayProxy: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 合并状态,减少状态更新次数
|
|
||||||
const [state, setState] = useState<ProxyState>({
|
const [state, setState] = useState<ProxyState>({
|
||||||
proxyData: {
|
proxyData: {
|
||||||
groups: [],
|
groups: [],
|
||||||
records: {},
|
records: {},
|
||||||
globalProxy: "",
|
globalProxy: "",
|
||||||
directProxy: null,
|
directProxy: { name: "DIRECT" }, // 默认值避免 undefined
|
||||||
},
|
},
|
||||||
selection: {
|
selection: {
|
||||||
group: "",
|
group: "",
|
||||||
@@ -131,11 +141,9 @@ export const CurrentProxyCard = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!proxies) return;
|
if (!proxies) return;
|
||||||
|
|
||||||
// 提取primaryGroupName
|
|
||||||
const getPrimaryGroupName = () => {
|
const getPrimaryGroupName = () => {
|
||||||
if (!proxies?.groups?.length) return "";
|
if (!proxies?.groups?.length) return "";
|
||||||
|
|
||||||
// 查找主要的代理组(优先级:包含关键词 > 第一个非GLOBAL组)
|
|
||||||
const primaryKeywords = [
|
const primaryKeywords = [
|
||||||
"auto",
|
"auto",
|
||||||
"select",
|
"select",
|
||||||
@@ -188,11 +196,10 @@ export const CurrentProxyCard = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!proxies) return;
|
if (!proxies) return;
|
||||||
|
|
||||||
// 使用函数式更新确保状态更新的原子性
|
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
// 过滤和格式化组
|
// 只保留 Selector 类型的组用于选择
|
||||||
const filteredGroups = proxies.groups
|
const filteredGroups = proxies.groups
|
||||||
.filter((g: { name: string }) => g.name !== "DIRECT" && g.name !== "REJECT")
|
.filter((g: { name: string; type?: string }) => g.type === "Selector")
|
||||||
.map((g: { name: string; now: string; all: Array<{ name: string }> }) => ({
|
.map((g: { name: string; now: string; all: Array<{ name: string }> }) => ({
|
||||||
name: g.name,
|
name: g.name,
|
||||||
now: g.now || "",
|
now: g.now || "",
|
||||||
@@ -207,34 +214,33 @@ export const CurrentProxyCard = () => {
|
|||||||
if (isDirectMode) {
|
if (isDirectMode) {
|
||||||
newGroup = "DIRECT";
|
newGroup = "DIRECT";
|
||||||
newProxy = "DIRECT";
|
newProxy = "DIRECT";
|
||||||
newDisplayProxy = proxies.records?.DIRECT || null;
|
newDisplayProxy = proxies.records?.DIRECT || { name: "DIRECT" }; // 确保非空
|
||||||
} else if (isGlobalMode && proxies.global) {
|
} else if (isGlobalMode && proxies.global) {
|
||||||
newGroup = "GLOBAL";
|
newGroup = "GLOBAL";
|
||||||
newProxy = proxies.global.now || "";
|
newProxy = proxies.global.now || "";
|
||||||
newDisplayProxy = proxies.records?.[newProxy] || null;
|
newDisplayProxy = proxies.records?.[newProxy] || null;
|
||||||
} else {
|
} else {
|
||||||
// 普通模式 - 检查当前选择的组是否存在
|
|
||||||
const currentGroup = filteredGroups.find(
|
const currentGroup = filteredGroups.find(
|
||||||
(g: { name: string }) => g.name === prev.selection.group,
|
(g: { name: string }) => g.name === prev.selection.group,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 如果当前组不存在或为空,自动选择第一个组
|
// 如果当前组不存在或为空,自动选择第一个 selector 类型的组
|
||||||
if (!currentGroup && filteredGroups.length > 0) {
|
if (!currentGroup && filteredGroups.length > 0) {
|
||||||
newGroup = filteredGroups[0].name;
|
const selectorGroup = filteredGroups[0];
|
||||||
const firstGroup = filteredGroups[0];
|
if (selectorGroup) {
|
||||||
newProxy = firstGroup.now;
|
newGroup = selectorGroup.name;
|
||||||
|
newProxy = selectorGroup.now || selectorGroup.all[0] || "";
|
||||||
newDisplayProxy = proxies.records?.[newProxy] || null;
|
newDisplayProxy = proxies.records?.[newProxy] || null;
|
||||||
|
|
||||||
// 保存到本地存储
|
|
||||||
if (!isGlobalMode && !isDirectMode) {
|
if (!isGlobalMode && !isDirectMode) {
|
||||||
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
|
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
|
||||||
if (newProxy) {
|
if (newProxy) {
|
||||||
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
|
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (currentGroup) {
|
} else if (currentGroup) {
|
||||||
// 使用当前组的代理
|
newProxy = currentGroup.now || currentGroup.all[0] || "";
|
||||||
newProxy = currentGroup.now;
|
|
||||||
newDisplayProxy = proxies.records?.[newProxy] || null;
|
newDisplayProxy = proxies.records?.[newProxy] || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +251,7 @@ export const CurrentProxyCard = () => {
|
|||||||
groups: filteredGroups,
|
groups: filteredGroups,
|
||||||
records: proxies.records || {},
|
records: proxies.records || {},
|
||||||
globalProxy: proxies.global?.now || "",
|
globalProxy: proxies.global?.now || "",
|
||||||
directProxy: proxies.records?.DIRECT || null,
|
directProxy: proxies.records?.DIRECT || { name: "DIRECT" },
|
||||||
},
|
},
|
||||||
selection: {
|
selection: {
|
||||||
group: newGroup,
|
group: newGroup,
|
||||||
@@ -256,7 +262,7 @@ export const CurrentProxyCard = () => {
|
|||||||
});
|
});
|
||||||
}, [proxies, isGlobalMode, isDirectMode]);
|
}, [proxies, isGlobalMode, isDirectMode]);
|
||||||
|
|
||||||
// 使用防抖包装状态更新,避免快速连续更新,增加防抖时间
|
// 使用防抖包装状态更新
|
||||||
const debouncedSetState = useCallback(
|
const debouncedSetState = useCallback(
|
||||||
debounce((updateFn: (prev: ProxyState) => ProxyState) => {
|
debounce((updateFn: (prev: ProxyState) => ProxyState) => {
|
||||||
setState(updateFn);
|
setState(updateFn);
|
||||||
@@ -271,10 +277,8 @@ export const CurrentProxyCard = () => {
|
|||||||
|
|
||||||
const newGroup = event.target.value;
|
const newGroup = event.target.value;
|
||||||
|
|
||||||
// 保存到本地存储
|
|
||||||
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
|
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
|
||||||
|
|
||||||
// 获取该组当前选中的代理
|
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
const group = prev.proxyData.groups.find((g: { name: string }) => g.name === newGroup);
|
const group = prev.proxyData.groups.find((g: { name: string }) => g.name === newGroup);
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -308,7 +312,6 @@ export const CurrentProxyCard = () => {
|
|||||||
const currentGroup = state.selection.group;
|
const currentGroup = state.selection.group;
|
||||||
const previousProxy = state.selection.proxy;
|
const previousProxy = state.selection.proxy;
|
||||||
|
|
||||||
// 立即更新UI,优化体验
|
|
||||||
debouncedSetState((prev: ProxyState) => ({
|
debouncedSetState((prev: ProxyState) => ({
|
||||||
...prev,
|
...prev,
|
||||||
selection: {
|
selection: {
|
||||||
@@ -318,13 +321,11 @@ export const CurrentProxyCard = () => {
|
|||||||
displayProxy: prev.proxyData.records[newProxy] || null,
|
displayProxy: prev.proxyData.records[newProxy] || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 非特殊模式下保存到本地存储
|
|
||||||
if (!isGlobalMode && !isDirectMode) {
|
if (!isGlobalMode && !isDirectMode) {
|
||||||
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
|
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 更新代理设置
|
|
||||||
await updateProxy(currentGroup, newProxy);
|
await updateProxy(currentGroup, newProxy);
|
||||||
|
|
||||||
// 自动关闭连接设置
|
// 自动关闭连接设置
|
||||||
@@ -363,17 +364,18 @@ export const CurrentProxyCard = () => {
|
|||||||
|
|
||||||
// 获取要显示的代理节点
|
// 获取要显示的代理节点
|
||||||
const currentProxy = useMemo(() => {
|
const currentProxy = useMemo(() => {
|
||||||
// 从state中获取当前代理信息
|
|
||||||
return state.displayProxy;
|
return state.displayProxy;
|
||||||
}, [state.displayProxy]);
|
}, [state.displayProxy]);
|
||||||
|
|
||||||
// 获取当前节点的延迟
|
// 获取当前节点的延迟(增加非空校验)
|
||||||
const currentDelay = currentProxy
|
const currentDelay = currentProxy && state.selection.group
|
||||||
? delayManager.getDelayFix(currentProxy, state.selection.group)
|
? delayManager.getDelayFix(currentProxy, state.selection.group)
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
// 获取信号图标
|
// 信号图标(增加非空校验)
|
||||||
const signalInfo = getSignalIcon(currentDelay);
|
const signalInfo = currentProxy && state.selection.group
|
||||||
|
? getSignalIcon(currentDelay)
|
||||||
|
: { icon: <SignalNone />, text: "未初始化", color: "text.secondary" };
|
||||||
|
|
||||||
// 自定义渲染选择框中的值
|
// 自定义渲染选择框中的值
|
||||||
const renderProxyValue = useCallback(
|
const renderProxyValue = useCallback(
|
||||||
@@ -382,7 +384,7 @@ export const CurrentProxyCard = () => {
|
|||||||
|
|
||||||
const delayValue = delayManager.getDelayFix(
|
const delayValue = delayManager.getDelayFix(
|
||||||
state.proxyData.records[selected],
|
state.proxyData.records[selected],
|
||||||
state.selection.group,
|
state.selection.group
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -399,27 +401,105 @@ export const CurrentProxyCard = () => {
|
|||||||
[state.proxyData.records, state.selection.group],
|
[state.proxyData.records, state.selection.group],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 计算要显示的代理选项 - 使用 useMemo 优化
|
// 排序类型变更
|
||||||
|
const handleSortTypeChange = useCallback(() => {
|
||||||
|
const newSortType = ((sortType + 1) % 3) as ProxySortType;
|
||||||
|
setSortType(newSortType);
|
||||||
|
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString());
|
||||||
|
}, [sortType]);
|
||||||
|
|
||||||
|
// 排序代理函数(增加非空校验)
|
||||||
|
const sortProxies = useCallback(
|
||||||
|
(proxies: ProxyOption[]) => {
|
||||||
|
if (!proxies || sortType === 0) return proxies;
|
||||||
|
|
||||||
|
// 确保数据存在
|
||||||
|
if (!state.proxyData.records || !state.selection.group) return proxies;
|
||||||
|
|
||||||
|
const list = [...proxies];
|
||||||
|
|
||||||
|
if (sortType === 1) {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const recordA = state.proxyData.records[a.name];
|
||||||
|
const recordB = state.proxyData.records[b.name];
|
||||||
|
|
||||||
|
// 处理 record 不存在的情况
|
||||||
|
if (!recordA) return 1;
|
||||||
|
if (!recordB) return -1;
|
||||||
|
|
||||||
|
const ad = delayManager.getDelayFix(recordA, state.selection.group);
|
||||||
|
const bd = delayManager.getDelayFix(recordB, state.selection.group);
|
||||||
|
|
||||||
|
if (ad === -1 || ad === -2) return 1;
|
||||||
|
if (bd === -1 || bd === -2) return -1;
|
||||||
|
|
||||||
|
return ad - bd;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
[sortType, state.proxyData.records, state.selection.group]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算要显示的代理选项(增加非空校验)
|
||||||
const proxyOptions = useMemo(() => {
|
const proxyOptions = useMemo(() => {
|
||||||
if (isDirectMode) {
|
if (isDirectMode) {
|
||||||
return [{ name: "DIRECT" }];
|
return [{ name: "DIRECT" }];
|
||||||
}
|
}
|
||||||
if (isGlobalMode && state.proxyData.records) {
|
if (isGlobalMode && proxies?.global) {
|
||||||
// 全局模式下的选项
|
const options = proxies.global.all
|
||||||
return Object.keys(state.proxyData.records)
|
.filter((p: any) => {
|
||||||
.filter((name) => name !== "DIRECT" && name !== "REJECT")
|
const name = typeof p === 'string' ? p : p.name;
|
||||||
.map((name) => ({ name }));
|
return name !== "DIRECT" && name !== "REJECT";
|
||||||
|
})
|
||||||
|
.map((p: any) => ({
|
||||||
|
name: typeof p === 'string' ? p : p.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
return sortProxies(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 普通模式
|
// 规则模式
|
||||||
const group = state.proxyData.groups.find(
|
const group = state.selection.group
|
||||||
(g: { name: string }) => g.name === state.selection.group,
|
? state.proxyData.groups.find(g => g.name === state.selection.group)
|
||||||
);
|
: null;
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
return group.all.map((name) => ({ name }));
|
const options = group.all.map((name) => ({ name }));
|
||||||
|
return sortProxies(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}, [isDirectMode, isGlobalMode, state.proxyData, state.selection.group]);
|
}, [isDirectMode, isGlobalMode, proxies, state.proxyData, state.selection.group, sortProxies]);
|
||||||
|
|
||||||
|
// 获取排序图标
|
||||||
|
const getSortIcon = () => {
|
||||||
|
switch (sortType) {
|
||||||
|
case 1:
|
||||||
|
return <AccessTimeRounded fontSize="small" />;
|
||||||
|
case 2:
|
||||||
|
return <SortByAlphaRounded fontSize="small" />;
|
||||||
|
default:
|
||||||
|
return <SortRounded fontSize="small" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取排序提示文本
|
||||||
|
const getSortTooltip = () => {
|
||||||
|
switch (sortType) {
|
||||||
|
case 0:
|
||||||
|
return t("Sort by default");
|
||||||
|
case 1:
|
||||||
|
return t("Sort by delay");
|
||||||
|
case 2:
|
||||||
|
return t("Sort by name");
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnhancedCard
|
<EnhancedCard
|
||||||
@@ -439,15 +519,27 @@ export const CurrentProxyCard = () => {
|
|||||||
}
|
}
|
||||||
iconColor={currentProxy ? "primary" : undefined}
|
iconColor={currentProxy ? "primary" : undefined}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
variant="outlined"
|
<Tooltip title={getSortTooltip()}>
|
||||||
size="small"
|
<IconButton
|
||||||
onClick={goToProxies}
|
size="small"
|
||||||
sx={{ borderRadius: 1.5 }}
|
color="inherit"
|
||||||
endIcon={<ChevronRight fontSize="small" />}
|
onClick={handleSortTypeChange}
|
||||||
>
|
sx={{ mr: 1 }}
|
||||||
{t("Label-Proxies")}
|
>
|
||||||
</Button>
|
{getSortIcon()}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={goToProxies}
|
||||||
|
sx={{ borderRadius: 1.5 }}
|
||||||
|
endIcon={<ChevronRight fontSize="small" />}
|
||||||
|
>
|
||||||
|
{t("Label-Proxies")}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{currentProxy ? (
|
{currentProxy ? (
|
||||||
@@ -568,10 +660,12 @@ export const CurrentProxyCard = () => {
|
|||||||
{isDirectMode
|
{isDirectMode
|
||||||
? null
|
? null
|
||||||
: proxyOptions.map((proxy, index) => {
|
: proxyOptions.map((proxy, index) => {
|
||||||
const delayValue = delayManager.getDelayFix(
|
const delayValue = state.proxyData.records[proxy.name] && state.selection.group
|
||||||
state.proxyData.records[proxy.name],
|
? delayManager.getDelayFix(
|
||||||
state.selection.group,
|
state.proxyData.records[proxy.name],
|
||||||
);
|
state.selection.group,
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={`${proxy.name}-${index}`}
|
key={`${proxy.name}-${index}`}
|
||||||
|
|||||||
@@ -11,17 +11,27 @@ import {
|
|||||||
import { Box, useTheme } from "@mui/material";
|
import { Box, useTheme } from "@mui/material";
|
||||||
import parseTraffic from "@/utils/parse-traffic";
|
import parseTraffic from "@/utils/parse-traffic";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Line as ChartJsLine } from "react-chartjs-2";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
Chart as ChartJS,
|
||||||
Line,
|
CategoryScale,
|
||||||
XAxis,
|
LinearScale,
|
||||||
YAxis,
|
PointElement,
|
||||||
CartesianGrid,
|
LineElement,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
Filler,
|
||||||
AreaChart,
|
Scale,
|
||||||
Area,
|
Tick,
|
||||||
} from "recharts";
|
} from "chart.js";
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Tooltip,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
// 流量数据项接口
|
// 流量数据项接口
|
||||||
export interface ITrafficItem {
|
export interface ITrafficItem {
|
||||||
@@ -30,13 +40,12 @@ export interface ITrafficItem {
|
|||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件对外暴露的方法
|
// 对外暴露的接口
|
||||||
export interface EnhancedTrafficGraphRef {
|
export interface EnhancedTrafficGraphRef {
|
||||||
appendData: (data: ITrafficItem) => void;
|
appendData: (data: ITrafficItem) => void;
|
||||||
toggleStyle: () => void;
|
toggleStyle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时间范围类型
|
|
||||||
type TimeRange = 1 | 5 | 10; // 分钟
|
type TimeRange = 1 | 5 | 10; // 分钟
|
||||||
|
|
||||||
// 数据点类型
|
// 数据点类型
|
||||||
@@ -76,23 +85,30 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
|
|||||||
up: theme.palette.secondary.main,
|
up: theme.palette.secondary.main,
|
||||||
down: theme.palette.primary.main,
|
down: theme.palette.primary.main,
|
||||||
grid: theme.palette.divider,
|
grid: theme.palette.divider,
|
||||||
tooltip: theme.palette.background.paper,
|
tooltipBg: theme.palette.background.paper,
|
||||||
text: theme.palette.text.primary,
|
text: theme.palette.text.primary,
|
||||||
|
tooltipBorder: theme.palette.divider,
|
||||||
}),
|
}),
|
||||||
[theme]
|
[theme]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 切换时间范围
|
// 切换时间范围
|
||||||
const handleTimeRangeClick = useCallback(() => {
|
const handleTimeRangeClick = useCallback((event: React.MouseEvent<SVGTextElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
setTimeRange((prevRange) => {
|
setTimeRange((prevRange) => {
|
||||||
// 在1、5、10分钟之间循环切换
|
|
||||||
return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1;
|
return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 点击图表主体或图例时切换样式
|
||||||
|
const handleToggleStyleClick = useCallback((event: React.MouseEvent<SVGTextElement | HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setChartStyle((prev) => (prev === "line" ? "area" : "line"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// 初始化数据缓冲区
|
// 初始化数据缓冲区
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 创建初始空数据
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const tenMinutesAgo = now - 10 * 60 * 1000;
|
const tenMinutesAgo = now - 10 * 60 * 1000;
|
||||||
|
|
||||||
@@ -102,17 +118,29 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
|
|||||||
const pointTime =
|
const pointTime =
|
||||||
tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE);
|
tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE);
|
||||||
const date = new Date(pointTime);
|
const date = new Date(pointTime);
|
||||||
|
let nameValue: string;
|
||||||
|
try {
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
console.warn(`Initial data generation: Invalid date for timestamp ${pointTime}`);
|
||||||
|
nameValue = "??:??:??";
|
||||||
|
} else {
|
||||||
|
nameValue = date.toLocaleTimeString("en-US", {
|
||||||
|
hour12: false,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error in toLocaleTimeString during initial data gen:", e, "Date:", date, "Timestamp:", pointTime);
|
||||||
|
nameValue = "Err:Time";
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
up: 0,
|
up: 0,
|
||||||
down: 0,
|
down: 0,
|
||||||
timestamp: pointTime,
|
timestamp: pointTime,
|
||||||
name: date.toLocaleTimeString("en-US", {
|
name: nameValue,
|
||||||
hour12: false,
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -123,44 +151,53 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
|
|||||||
const pointsToShow = getMaxPointsByTimeRange(timeRange);
|
const pointsToShow = getMaxPointsByTimeRange(timeRange);
|
||||||
setDisplayData(initialBuffer.slice(-pointsToShow));
|
setDisplayData(initialBuffer.slice(-pointsToShow));
|
||||||
}, [MAX_BUFFER_SIZE, getMaxPointsByTimeRange]);
|
}, [MAX_BUFFER_SIZE, getMaxPointsByTimeRange]);
|
||||||
|
|
||||||
// 添加数据点方法
|
// 添加数据点方法
|
||||||
const appendData = useCallback((data: ITrafficItem) => {
|
const appendData = useCallback((data: ITrafficItem) => {
|
||||||
// 安全处理数据
|
|
||||||
const safeData = {
|
const safeData = {
|
||||||
up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0,
|
up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0,
|
||||||
down: typeof data.down === "number" && !isNaN(data.down) ? data.down : 0,
|
down: typeof data.down === "number" && !isNaN(data.down) ? data.down : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用提供的时间戳或当前时间
|
|
||||||
const timestamp = data.timestamp || Date.now();
|
const timestamp = data.timestamp || Date.now();
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
|
let nameValue: string;
|
||||||
|
try {
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
console.warn(`appendData: Invalid date for timestamp ${timestamp}`);
|
||||||
|
nameValue = "??:??:??";
|
||||||
|
} else {
|
||||||
|
nameValue = date.toLocaleTimeString("en-US", {
|
||||||
|
hour12: false,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error in toLocaleTimeString in appendData:", e, "Date:", date, "Timestamp:", timestamp);
|
||||||
|
nameValue = "Err:Time";
|
||||||
|
}
|
||||||
// 带时间标签的新数据点
|
// 带时间标签的新数据点
|
||||||
const newPoint: DataPoint = {
|
const newPoint: DataPoint = {
|
||||||
...safeData,
|
...safeData,
|
||||||
name: date.toLocaleTimeString("en-US", {
|
name: nameValue,
|
||||||
hour12: false,
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
}),
|
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新缓冲区,保持原数组大小
|
|
||||||
const newBuffer = [...dataBufferRef.current.slice(1), newPoint];
|
const newBuffer = [...dataBufferRef.current.slice(1), newPoint];
|
||||||
dataBufferRef.current = newBuffer;
|
dataBufferRef.current = newBuffer;
|
||||||
|
|
||||||
// 更新显示数据
|
|
||||||
const pointsToShow = getMaxPointsByTimeRange(timeRange);
|
const pointsToShow = getMaxPointsByTimeRange(timeRange);
|
||||||
setDisplayData(newBuffer.slice(-pointsToShow));
|
setDisplayData(newBuffer.slice(-pointsToShow));
|
||||||
}, [timeRange, getMaxPointsByTimeRange]);
|
}, [timeRange, getMaxPointsByTimeRange]);
|
||||||
|
|
||||||
// 监听时间范围变化,更新显示数据
|
// 监听时间范围变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pointsToShow = getMaxPointsByTimeRange(timeRange);
|
const pointsToShow = getMaxPointsByTimeRange(timeRange);
|
||||||
setDisplayData(dataBufferRef.current.slice(-pointsToShow));
|
if (dataBufferRef.current.length > 0) {
|
||||||
|
setDisplayData(dataBufferRef.current.slice(-pointsToShow));
|
||||||
|
}
|
||||||
}, [timeRange, getMaxPointsByTimeRange]);
|
}, [timeRange, getMaxPointsByTimeRange]);
|
||||||
|
|
||||||
// 切换图表样式
|
// 切换图表样式
|
||||||
@@ -178,47 +215,166 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
|
|||||||
[appendData, toggleStyle]
|
[appendData, toggleStyle]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 格式化工具提示内容
|
|
||||||
const formatTooltip = useCallback((value: number, name: string, props: any) => {
|
|
||||||
const [num, unit] = parseTraffic(value);
|
|
||||||
return [`${num} ${unit}/s`, props?.dataKey === "up" ? t("Upload") : t("Download")];
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
// Y轴刻度格式化
|
const formatYAxis = useCallback((value: number | string): string => {
|
||||||
const formatYAxis = useCallback((value: number) => {
|
if (typeof value !== 'number') return String(value);
|
||||||
const [num, unit] = parseTraffic(value);
|
const [num, unit] = parseTraffic(value);
|
||||||
return `${num}${unit}`;
|
return `${num}${unit}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 格式化X轴标签
|
const formatXLabel = useCallback((tickValue: string | number, index: number, ticks: any[]) => {
|
||||||
const formatXLabel = useCallback((value: string) => {
|
const dataPoint = displayData[index as number];
|
||||||
if (!value) return "";
|
if (dataPoint && dataPoint.name) {
|
||||||
const parts = value.split(":");
|
const parts = dataPoint.name.split(":");
|
||||||
return `${parts[0]}:${parts[1]}`;
|
return `${parts[0]}:${parts[1]}`;
|
||||||
}, []);
|
}
|
||||||
|
if(typeof tickValue === 'string') {
|
||||||
|
const parts = tickValue.split(":");
|
||||||
|
if (parts.length >= 2) return `${parts[0]}:${parts[1]}`;
|
||||||
|
return tickValue;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [displayData]);
|
||||||
|
|
||||||
|
|
||||||
// 获取当前时间范围文本
|
// 获取当前时间范围文本
|
||||||
const getTimeRangeText = useCallback(() => {
|
const getTimeRangeText = useCallback(() => {
|
||||||
return t("{{time}} Minutes", { time: timeRange });
|
return t("{{time}} Minutes", { time: timeRange });
|
||||||
}, [timeRange, t]);
|
}, [timeRange, t]);
|
||||||
|
|
||||||
// 共享图表配置
|
const chartData = useMemo(() => {
|
||||||
const chartConfig = useMemo(() => ({
|
const labels = displayData.map(d => d.name);
|
||||||
data: displayData,
|
return {
|
||||||
margin: { top: 20, right: 10, left: 0, bottom: -10 },
|
labels,
|
||||||
}), [displayData]);
|
datasets: [
|
||||||
|
{
|
||||||
|
label: t("Upload"),
|
||||||
|
data: displayData.map(d => d.up),
|
||||||
|
borderColor: colors.up,
|
||||||
|
backgroundColor: chartStyle === "area" ? colors.up : colors.up,
|
||||||
|
fill: chartStyle === "area",
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("Download"),
|
||||||
|
data: displayData.map(d => d.down),
|
||||||
|
borderColor: colors.down,
|
||||||
|
backgroundColor: chartStyle === "area" ? colors.down : colors.down,
|
||||||
|
fill: chartStyle === "area",
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}, [displayData, colors.up, colors.down, t, chartStyle]);
|
||||||
|
|
||||||
// 共享的线条/区域配置
|
const chartOptions = useMemo(() => ({
|
||||||
const commonLineProps = useMemo(() => ({
|
responsive: true,
|
||||||
dot: false,
|
maintainAspectRatio: false,
|
||||||
strokeWidth: 2,
|
animation: false as false,
|
||||||
connectNulls: false,
|
scales: {
|
||||||
activeDot: { r: 4, strokeWidth: 1 },
|
x: {
|
||||||
isAnimationActive: false, // 禁用动画以减少CPU使用
|
display: true,
|
||||||
}), []);
|
type: 'category' as const,
|
||||||
|
labels: displayData.map(d => d.name),
|
||||||
|
ticks: {
|
||||||
|
display: true,
|
||||||
|
color: colors.text,
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: function(this: Scale, tickValue: string | number, index: number, ticks: Tick[]): string | undefined {
|
||||||
|
let labelToFormat: string | undefined = undefined;
|
||||||
|
|
||||||
|
const currentDisplayTick = ticks[index];
|
||||||
|
if (currentDisplayTick && typeof currentDisplayTick.label === 'string') {
|
||||||
|
labelToFormat = currentDisplayTick.label;
|
||||||
|
} else {
|
||||||
|
const sourceLabels = displayData.map(d => d.name);
|
||||||
|
if (typeof tickValue === 'number' && tickValue >= 0 && tickValue < sourceLabels.length) {
|
||||||
|
labelToFormat = sourceLabels[tickValue];
|
||||||
|
} else if (typeof tickValue === 'string') {
|
||||||
|
labelToFormat = tickValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof labelToFormat !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = labelToFormat.split(':');
|
||||||
|
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : labelToFormat;
|
||||||
|
},
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: Math.max(5, Math.floor(displayData.length / (timeRange * 2))),
|
||||||
|
minRotation: 0,
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawOnChartArea: false,
|
||||||
|
drawTicks: true,
|
||||||
|
tickLength: 2,
|
||||||
|
color: colors.text,
|
||||||
|
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
color: colors.text,
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: formatYAxis,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: true,
|
||||||
|
drawTicks: true,
|
||||||
|
tickLength: 3,
|
||||||
|
color: colors.grid,
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'index' as const,
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
titleColor: colors.text,
|
||||||
|
bodyColor: colors.text,
|
||||||
|
borderColor: colors.tooltipBorder,
|
||||||
|
borderWidth: 1,
|
||||||
|
cornerRadius: 4,
|
||||||
|
padding: 8,
|
||||||
|
callbacks: {
|
||||||
|
title: (tooltipItems: any[]) => {
|
||||||
|
return `${t("Time")}: ${tooltipItems[0].label}`;
|
||||||
|
},
|
||||||
|
label: (context: any): string => {
|
||||||
|
const label = context.dataset.label || '';
|
||||||
|
const value = context.parsed.y;
|
||||||
|
const [num, unit] = parseTraffic(value);
|
||||||
|
return `${label}: ${num} ${unit}/s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
padding: {
|
||||||
|
top: 16,
|
||||||
|
right: 7,
|
||||||
|
left: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [colors, t, formatYAxis, timeRange, displayData]);
|
||||||
|
|
||||||
// 曲线类型
|
|
||||||
const curveType = "monotone";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -230,141 +386,57 @@ export const EnhancedTrafficGraph = memo(forwardRef<EnhancedTrafficGraphRef>(
|
|||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={toggleStyle}
|
onClick={handleToggleStyleClick}
|
||||||
>
|
>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div style={{ width: "100%", height: "100%", position: "relative" }}>
|
||||||
{/* 根据chartStyle动态选择图表类型 */}
|
{displayData.length > 0 && (
|
||||||
{(() => {
|
<ChartJsLine data={chartData} options={chartOptions} />
|
||||||
// 创建共享的图表组件
|
)}
|
||||||
const commonChartComponents = (
|
|
||||||
<>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={colors.grid} opacity={0.3} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="name"
|
|
||||||
tick={{ fontSize: 10, fill: colors.text }}
|
|
||||||
tickLine={{ stroke: colors.grid }}
|
|
||||||
axisLine={{ stroke: colors.grid }}
|
|
||||||
interval="preserveStart"
|
|
||||||
tickFormatter={formatXLabel}
|
|
||||||
minTickGap={30}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tickFormatter={formatYAxis}
|
|
||||||
tick={{ fontSize: 10, fill: colors.text }}
|
|
||||||
tickLine={{ stroke: colors.grid }}
|
|
||||||
axisLine={{ stroke: colors.grid }}
|
|
||||||
width={44}
|
|
||||||
domain={[0, "auto"]}
|
|
||||||
padding={{ top: 8, bottom: 0 }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={formatTooltip}
|
|
||||||
labelFormatter={(label) => `${t("Time")}: ${label}`}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: colors.tooltip,
|
|
||||||
borderColor: colors.grid,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
itemStyle={{ color: colors.text }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 可点击的时间范围标签 */}
|
<svg width="100%" height="100%" style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
|
||||||
<text
|
<text
|
||||||
x="1%"
|
x="3.5%"
|
||||||
y="11%"
|
y="10%"
|
||||||
textAnchor="start"
|
textAnchor="start"
|
||||||
fill={theme.palette.text.secondary}
|
fill={theme.palette.text.secondary}
|
||||||
fontSize={11}
|
fontSize={11}
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
onClick={handleTimeRangeClick}
|
onClick={handleTimeRangeClick}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer", pointerEvents: 'all' }}
|
||||||
>
|
>
|
||||||
{getTimeRangeText()}
|
{getTimeRangeText()}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* 上传标签 - 右上角 */}
|
<text
|
||||||
<text
|
x="99%"
|
||||||
x="99%"
|
y="10%"
|
||||||
y="11%"
|
textAnchor="end"
|
||||||
textAnchor="end"
|
fill={colors.up}
|
||||||
fill={colors.up}
|
fontSize={12}
|
||||||
fontSize={12}
|
fontWeight="bold"
|
||||||
fontWeight="bold"
|
onClick={handleToggleStyleClick}
|
||||||
onClick={toggleStyle}
|
style={{ cursor: "pointer", pointerEvents: 'all' }}
|
||||||
style={{ cursor: "pointer" }}
|
>
|
||||||
>
|
{t("Upload")}
|
||||||
{t("Upload")}
|
</text>
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* 下载标签 - 右上角下方 */}
|
<text
|
||||||
<text
|
x="99%"
|
||||||
x="99%"
|
y="19%"
|
||||||
y="19%"
|
textAnchor="end"
|
||||||
textAnchor="end"
|
fill={colors.down}
|
||||||
fill={colors.down}
|
fontSize={12}
|
||||||
fontSize={12}
|
fontWeight="bold"
|
||||||
fontWeight="bold"
|
onClick={handleToggleStyleClick}
|
||||||
onClick={toggleStyle}
|
style={{ cursor: "pointer", pointerEvents: 'all' }}
|
||||||
style={{ cursor: "pointer" }}
|
>
|
||||||
>
|
{t("Download")}
|
||||||
{t("Download")}
|
</text>
|
||||||
</text>
|
</svg>
|
||||||
</>
|
</div>
|
||||||
);
|
|
||||||
|
|
||||||
// 根据chartStyle返回相应的图表类型
|
|
||||||
if (chartStyle === "line") {
|
|
||||||
return (
|
|
||||||
<LineChart {...chartConfig}>
|
|
||||||
{commonChartComponents}
|
|
||||||
<Line
|
|
||||||
type={curveType}
|
|
||||||
{...commonLineProps}
|
|
||||||
dataKey="up"
|
|
||||||
name={t("Upload")}
|
|
||||||
stroke={colors.up}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type={curveType}
|
|
||||||
{...commonLineProps}
|
|
||||||
dataKey="down"
|
|
||||||
name={t("Download")}
|
|
||||||
stroke={colors.down}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AreaChart {...chartConfig}>
|
|
||||||
{commonChartComponents}
|
|
||||||
<Area
|
|
||||||
type={curveType}
|
|
||||||
{...commonLineProps}
|
|
||||||
dataKey="up"
|
|
||||||
name={t("Upload")}
|
|
||||||
stroke={colors.up}
|
|
||||||
fill={colors.up}
|
|
||||||
fillOpacity={0.2}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type={curveType}
|
|
||||||
{...commonLineProps}
|
|
||||||
dataKey="down"
|
|
||||||
name={t("Download")}
|
|
||||||
stroke={colors.down}
|
|
||||||
fill={colors.down}
|
|
||||||
fillOpacity={0.3}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
// 添加显示名称以便调试
|
|
||||||
EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";
|
EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
PaletteColor,
|
PaletteColor,
|
||||||
|
Grid,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Grid from "@mui/material/Grid2";
|
|
||||||
import {
|
import {
|
||||||
ArrowUpwardRounded,
|
ArrowUpwardRounded,
|
||||||
ArrowDownwardRounded,
|
ArrowDownwardRounded,
|
||||||
@@ -174,9 +174,12 @@ export const EnhancedTrafficStats = () => {
|
|||||||
const trafficGraph = verge?.traffic_graph ?? true;
|
const trafficGraph = verge?.traffic_graph ?? true;
|
||||||
|
|
||||||
// WebSocket引用
|
// WebSocket引用
|
||||||
const socketRefs = useRef({
|
const socketRefs = useRef<{
|
||||||
traffic: null as ReturnType<typeof createAuthSockette> | null,
|
traffic: ReturnType<typeof createAuthSockette> | null;
|
||||||
memory: null as ReturnType<typeof createAuthSockette> | null,
|
memory: ReturnType<typeof createAuthSockette> | null;
|
||||||
|
}>({
|
||||||
|
traffic: null,
|
||||||
|
memory: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 检查是否支持调试
|
// 检查是否支持调试
|
||||||
@@ -196,41 +199,34 @@ export const EnhancedTrafficStats = () => {
|
|||||||
// 使用节流控制更新频率
|
// 使用节流控制更新频率
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastUpdateRef.current.traffic < THROTTLE_TRAFFIC_UPDATE) {
|
if (now - lastUpdateRef.current.traffic < THROTTLE_TRAFFIC_UPDATE) {
|
||||||
// 如果距离上次更新时间小于阈值,只更新图表不更新状态
|
try {
|
||||||
if (trafficRef.current) {
|
trafficRef.current?.appendData({
|
||||||
trafficRef.current.appendData({
|
|
||||||
up: data.up,
|
up: data.up,
|
||||||
down: data.down,
|
down: data.down,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
});
|
});
|
||||||
}
|
} catch { }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新最后更新时间
|
|
||||||
lastUpdateRef.current.traffic = now;
|
lastUpdateRef.current.traffic = now;
|
||||||
|
|
||||||
// 验证数据有效性,防止NaN
|
|
||||||
const safeUp = isNaN(data.up) ? 0 : data.up;
|
const safeUp = isNaN(data.up) ? 0 : data.up;
|
||||||
const safeDown = isNaN(data.down) ? 0 : data.down;
|
const safeDown = isNaN(data.down) ? 0 : data.down;
|
||||||
|
try {
|
||||||
// 批量更新状态
|
setStats(prev => ({
|
||||||
setStats(prev => ({
|
...prev,
|
||||||
...prev,
|
traffic: { up: safeUp, down: safeDown }
|
||||||
traffic: { up: safeUp, down: safeDown }
|
}));
|
||||||
}));
|
} catch { }
|
||||||
|
try {
|
||||||
// 更新图表数据
|
trafficRef.current?.appendData({
|
||||||
if (trafficRef.current) {
|
|
||||||
trafficRef.current.appendData({
|
|
||||||
up: safeUp,
|
up: safeUp,
|
||||||
down: safeDown,
|
down: safeDown,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
});
|
});
|
||||||
}
|
} catch { }
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Traffic] 解析数据错误:", err);
|
console.error("[Traffic] 解析数据错误:", err, event.data);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -248,7 +244,7 @@ export const EnhancedTrafficStats = () => {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Memory] 解析数据错误:", err);
|
console.error("[Memory] 解析数据错误:", err, event.data);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -273,17 +269,59 @@ export const EnhancedTrafficStats = () => {
|
|||||||
cleanupSockets();
|
cleanupSockets();
|
||||||
|
|
||||||
// 创建新连接
|
// 创建新连接
|
||||||
|
console.log(`[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`);
|
||||||
socketRefs.current.traffic = createAuthSockette(`${server}/traffic`, secret, {
|
socketRefs.current.traffic = createAuthSockette(`${server}/traffic`, secret, {
|
||||||
onmessage: handleTrafficUpdate,
|
onmessage: handleTrafficUpdate,
|
||||||
|
onopen: (event) => {
|
||||||
|
console.log(`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, event);
|
||||||
|
},
|
||||||
|
onerror: (event) => {
|
||||||
|
console.error(`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, event);
|
||||||
|
setStats(prev => ({ ...prev, traffic: { up: 0, down: 0 } }));
|
||||||
|
},
|
||||||
|
onclose: (event) => {
|
||||||
|
console.log(`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, event.code, event.reason);
|
||||||
|
if (event.code !== 1000 && event.code !== 1001) {
|
||||||
|
console.warn(`[Traffic][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`);
|
||||||
|
setStats(prev => ({ ...prev, traffic: { up: 0, down: 0 } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`);
|
||||||
socketRefs.current.memory = createAuthSockette(`${server}/memory`, secret, {
|
socketRefs.current.memory = createAuthSockette(`${server}/memory`, secret, {
|
||||||
onmessage: handleMemoryUpdate,
|
onmessage: handleMemoryUpdate,
|
||||||
|
onopen: (event) => {
|
||||||
|
console.log(`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, event);
|
||||||
|
},
|
||||||
|
onerror: (event) => {
|
||||||
|
console.error(`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, event);
|
||||||
|
setStats(prev => ({ ...prev, memory: { inuse: 0, oslimit: undefined } }));
|
||||||
|
},
|
||||||
|
onclose: (event) => {
|
||||||
|
console.log(`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, event.code, event.reason);
|
||||||
|
if (event.code !== 1000 && event.code !== 1001) {
|
||||||
|
console.warn(`[Memory][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`);
|
||||||
|
setStats(prev => ({ ...prev, memory: { inuse: 0, oslimit: undefined } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return cleanupSockets;
|
return cleanupSockets;
|
||||||
}, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]);
|
}, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]);
|
||||||
|
|
||||||
|
// 组件卸载时清理所有定时器/引用
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
Object.values(socketRefs.current).forEach(socket => {
|
||||||
|
if (socket) socket.close();
|
||||||
|
});
|
||||||
|
socketRefs.current = { traffic: null, memory: null };
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 执行垃圾回收
|
// 执行垃圾回收
|
||||||
const handleGarbageCollection = useCallback(async () => {
|
const handleGarbageCollection = useCallback(async () => {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
@@ -306,9 +344,10 @@ export const EnhancedTrafficStats = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
up, upUnit, down, downUnit, inuse, inuseUnit,
|
up, upUnit, down, downUnit, inuse, inuseUnit,
|
||||||
uploadTotal, uploadTotalUnit, downloadTotal, downloadTotalUnit
|
uploadTotal, uploadTotalUnit, downloadTotal, downloadTotalUnit,
|
||||||
|
connectionsCount: connections.count
|
||||||
};
|
};
|
||||||
}, [stats, connections.uploadTotal, connections.downloadTotal]);
|
}, [stats, connections]);
|
||||||
|
|
||||||
// 渲染流量图表 - 使用useMemo缓存渲染结果
|
// 渲染流量图表 - 使用useMemo缓存渲染结果
|
||||||
const trafficGraphComponent = useMemo(() => {
|
const trafficGraphComponent = useMemo(() => {
|
||||||
@@ -371,7 +410,7 @@ export const EnhancedTrafficStats = () => {
|
|||||||
{
|
{
|
||||||
icon: <LinkRounded fontSize="small" />,
|
icon: <LinkRounded fontSize="small" />,
|
||||||
title: t("Active Connections"),
|
title: t("Active Connections"),
|
||||||
value: connections.count,
|
value: parsedData.connectionsCount,
|
||||||
unit: "",
|
unit: "",
|
||||||
color: "success" as const,
|
color: "success" as const,
|
||||||
},
|
},
|
||||||
@@ -397,7 +436,7 @@ export const EnhancedTrafficStats = () => {
|
|||||||
color: "error" as const,
|
color: "error" as const,
|
||||||
onClick: isDebug ? handleGarbageCollection : undefined,
|
onClick: isDebug ? handleGarbageCollection : undefined,
|
||||||
},
|
},
|
||||||
], [t, parsedData, connections.count, isDebug, handleGarbageCollection]);
|
], [t, parsedData, isDebug, handleGarbageCollection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import parseTraffic from "@/utils/parse-traffic";
|
|||||||
import { useMemo, useCallback, useState } from "react";
|
import { useMemo, useCallback, useState } from "react";
|
||||||
import { openWebUrl, updateProfile } from "@/services/cmds";
|
import { openWebUrl, updateProfile } from "@/services/cmds";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import { Notice } from "@/components/base";
|
import { showNotice } from "@/services/noticeService";
|
||||||
import { EnhancedCard } from "./enhanced-card";
|
import { EnhancedCard } from "./enhanced-card";
|
||||||
import { useAppData } from "@/providers/app-data-provider";
|
import { useAppData } from "@/providers/app-data-provider";
|
||||||
|
|
||||||
@@ -281,14 +281,14 @@ export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardPr
|
|||||||
|
|
||||||
setUpdating(true);
|
setUpdating(true);
|
||||||
try {
|
try {
|
||||||
await updateProfile(current.uid);
|
await updateProfile(current.uid, current.option);
|
||||||
Notice.success(t("Update subscription successfully"));
|
showNotice('success', t("Update subscription successfully"), 1000);
|
||||||
onProfileUpdated?.();
|
onProfileUpdated?.();
|
||||||
|
|
||||||
// 刷新首页数据
|
// 刷新首页数据
|
||||||
refreshAll();
|
refreshAll();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err?.message || err.toString());
|
showNotice('error', err.message || err.toString(), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
setUpdating(false);
|
setUpdating(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
Fade,
|
Fade,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useState, useMemo, memo, FC } from "react";
|
import { useState, useMemo, memo, FC, useEffect } from "react";
|
||||||
import ProxyControlSwitches from "@/components/shared/ProxyControlSwitches";
|
import ProxyControlSwitches from "@/components/shared/ProxyControlSwitches";
|
||||||
import { Notice } from "@/components/base";
|
|
||||||
import {
|
import {
|
||||||
ComputerRounded,
|
ComputerRounded,
|
||||||
TroubleshootRounded,
|
TroubleshootRounded,
|
||||||
@@ -20,6 +19,9 @@ import {
|
|||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { useSystemState } from "@/hooks/use-system-state";
|
import { useSystemState } from "@/hooks/use-system-state";
|
||||||
|
import { showNotice } from "@/services/noticeService";
|
||||||
|
import { getRunningMode } from "@/services/cmds";
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
const LOCAL_STORAGE_TAB_KEY = "clash-verge-proxy-active-tab";
|
const LOCAL_STORAGE_TAB_KEY = "clash-verge-proxy-active-tab";
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ interface TabButtonProps {
|
|||||||
hasIndicator?: boolean;
|
hasIndicator?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抽取Tab组件以减少重复代码
|
// Tab组件
|
||||||
const TabButton: FC<TabButtonProps> = memo(
|
const TabButton: FC<TabButtonProps> = memo(
|
||||||
({ isActive, onClick, icon: Icon, label, hasIndicator = false }) => (
|
({ isActive, onClick, icon: Icon, label, hasIndicator = false }) => (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -96,7 +98,7 @@ interface TabDescriptionProps {
|
|||||||
tooltipTitle: string;
|
tooltipTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抽取描述文本组件
|
// 描述文本组件
|
||||||
const TabDescription: FC<TabDescriptionProps> = memo(
|
const TabDescription: FC<TabDescriptionProps> = memo(
|
||||||
({ description, tooltipTitle }) => (
|
({ description, tooltipTitle }) => (
|
||||||
<Fade in={true} timeout={200}>
|
<Fade in={true} timeout={200}>
|
||||||
@@ -139,28 +141,42 @@ export const ProxyTunCard: FC = () => {
|
|||||||
() => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || "system",
|
() => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || "system",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取代理状态信息
|
const [localServiceOk, setLocalServiceOk] = useState(false);
|
||||||
|
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const { isSidecarMode, isAdminMode } = useSystemState();
|
const { isAdminMode } = useSystemState();
|
||||||
|
|
||||||
// 从verge配置中获取开关状态
|
|
||||||
const { enable_system_proxy, enable_tun_mode } = verge ?? {};
|
const { enable_system_proxy, enable_tun_mode } = verge ?? {};
|
||||||
|
|
||||||
// 判断Tun模式是否可用 - 当处于服务模式或管理员模式时可用
|
const updateLocalStatus = async () => {
|
||||||
const isTunAvailable = !isSidecarMode || isAdminMode;
|
try {
|
||||||
|
const runningMode = await getRunningMode();
|
||||||
|
const serviceStatus = runningMode === "Service";
|
||||||
|
setLocalServiceOk(serviceStatus);
|
||||||
|
mutate("isServiceAvailable", serviceStatus, false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新TUN状态失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理错误
|
useEffect(() => {
|
||||||
|
updateLocalStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isTunAvailable = localServiceOk || isAdminMode;
|
||||||
|
|
||||||
const handleError = (err: Error) => {
|
const handleError = (err: Error) => {
|
||||||
Notice.error(err.message || err.toString(), 3000);
|
showNotice("error", err.message || err.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理标签切换并保存到localStorage
|
|
||||||
const handleTabChange = (tab: string) => {
|
const handleTabChange = (tab: string) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab);
|
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab);
|
||||||
|
if (tab === "tun") {
|
||||||
|
updateLocalStatus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 用户提示文本 - 使用useMemo避免重复计算
|
|
||||||
const tabDescription = useMemo(() => {
|
const tabDescription = useMemo(() => {
|
||||||
if (activeTab === "system") {
|
if (activeTab === "system") {
|
||||||
return {
|
return {
|
||||||
@@ -183,7 +199,6 @@ export const ProxyTunCard: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||||
{/* 选项卡 */}
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
spacing={1}
|
spacing={1}
|
||||||
@@ -210,7 +225,6 @@ export const ProxyTunCard: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* 说明文本区域 */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -227,7 +241,6 @@ export const ProxyTunCard: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 控制开关部分 */}
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
mt: 0,
|
mt: 0,
|
||||||
|
|||||||
@@ -11,43 +11,51 @@ import {
|
|||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { EnhancedCard } from "./enhanced-card";
|
import { EnhancedCard } from "./enhanced-card";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { getSystemInfo, installService } from "@/services/cmds";
|
import {
|
||||||
|
getSystemInfo,
|
||||||
|
} from "@/services/cmds";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { version as appVersion } from "@root/package.json";
|
import { version as appVersion } from "@root/package.json";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import { Notice } from "@/components/base";
|
import { showNotice } from "@/services/noticeService";
|
||||||
import { useSystemState } from "@/hooks/use-system-state";
|
import { useSystemState } from "@/hooks/use-system-state";
|
||||||
|
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
|
||||||
|
|
||||||
export const SystemInfoCard = () => {
|
export const SystemInfoCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { verge, patchVerge } = useVerge();
|
const { verge, patchVerge } = useVerge();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAdminMode, isSidecarMode, mutateRunningMode } = useSystemState();
|
const { isAdminMode, isSidecarMode, mutateRunningMode } = useSystemState();
|
||||||
|
const { installServiceAndRestartCore } = useServiceInstaller();
|
||||||
|
|
||||||
// 系统信息状态
|
// 系统信息状态
|
||||||
const [systemState, setSystemState] = useState({
|
const [systemState, setSystemState] = useState({
|
||||||
osInfo: "",
|
osInfo: "",
|
||||||
lastCheckUpdate: "-",
|
lastCheckUpdate: "-",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化系统信息
|
// 初始化系统信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 获取系统信息
|
getSystemInfo()
|
||||||
getSystemInfo()
|
.then((info) => {
|
||||||
.then((info) => {
|
const lines = info.split("\n");
|
||||||
const lines = info.split("\n");
|
if (lines.length > 0) {
|
||||||
if (lines.length > 0) {
|
const sysName = lines[0].split(": ")[1] || "";
|
||||||
const sysName = lines[0].split(": ")[1] || "";
|
let sysVersion = lines[1].split(": ")[1] || "";
|
||||||
const sysVersion = lines[1].split(": ")[1] || "";
|
|
||||||
setSystemState((prev) => ({
|
if (sysName && sysVersion.toLowerCase().startsWith(sysName.toLowerCase())) {
|
||||||
...prev,
|
sysVersion = sysVersion.substring(sysName.length).trim();
|
||||||
osInfo: `${sysName} ${sysVersion}`,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(console.error);
|
setSystemState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
osInfo: `${sysName} ${sysVersion}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
// 获取最后检查更新时间
|
// 获取最后检查更新时间
|
||||||
const lastCheck = localStorage.getItem("last_check_update");
|
const lastCheck = localStorage.getItem("last_check_update");
|
||||||
@@ -106,47 +114,34 @@ export const SystemInfoCard = () => {
|
|||||||
|
|
||||||
// 切换自启动状态
|
// 切换自启动状态
|
||||||
const toggleAutoLaunch = useCallback(async () => {
|
const toggleAutoLaunch = useCallback(async () => {
|
||||||
if (!verge || isAdminMode) return;
|
if (!verge) return;
|
||||||
try {
|
try {
|
||||||
await patchVerge({ enable_auto_launch: !verge.enable_auto_launch });
|
await patchVerge({ enable_auto_launch: !verge.enable_auto_launch });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("切换开机自启动状态失败:", err);
|
console.error("切换开机自启动状态失败:", err);
|
||||||
}
|
}
|
||||||
}, [verge, patchVerge, isAdminMode]);
|
}, [verge, patchVerge]);
|
||||||
|
|
||||||
// 安装系统服务
|
|
||||||
const onInstallService = useLockFn(async () => {
|
|
||||||
try {
|
|
||||||
Notice.info(t("Installing Service..."), 1000);
|
|
||||||
await installService();
|
|
||||||
Notice.success(t("Service Installed Successfully"), 2000);
|
|
||||||
|
|
||||||
await mutateRunningMode();
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
Notice.error(err.message || err.toString(), 3000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 点击运行模式处理,Sidecar或纯管理员模式允许安装服务
|
// 点击运行模式处理,Sidecar或纯管理员模式允许安装服务
|
||||||
const handleRunningModeClick = useCallback(() => {
|
const handleRunningModeClick = useCallback(() => {
|
||||||
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
|
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
|
||||||
onInstallService();
|
installServiceAndRestartCore();
|
||||||
}
|
}
|
||||||
}, [isSidecarMode, isAdminMode, onInstallService]);
|
}, [isSidecarMode, isAdminMode, installServiceAndRestartCore]);
|
||||||
|
|
||||||
// 检查更新
|
// 检查更新
|
||||||
const onCheckUpdate = useLockFn(async () => {
|
const onCheckUpdate = useLockFn(async () => {
|
||||||
try {
|
try {
|
||||||
const info = await checkUpdate();
|
const info = await checkUpdate();
|
||||||
if (!info?.available) {
|
if (!info?.available) {
|
||||||
Notice.success(t("Currently on the Latest Version"));
|
showNotice('success', t("Currently on the Latest Version"));
|
||||||
} else {
|
} else {
|
||||||
Notice.info(t("Update Available"), 2000);
|
showNotice('info', t("Update Available"), 2000);
|
||||||
goToSettings();
|
goToSettings();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.message || err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,7 +253,7 @@ export const SystemInfoCard = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
{isAdminMode && (
|
{isAdminMode && (
|
||||||
<Tooltip title={t("Administrator mode does not support auto launch")}>
|
<Tooltip title={t("Administrator mode may not support auto launch")}>
|
||||||
<WarningOutlined sx={{ color: "warning.main", fontSize: 20 }} />
|
<WarningOutlined sx={{ color: "warning.main", fontSize: 20 }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -268,8 +263,7 @@ export const SystemInfoCard = () => {
|
|||||||
color={autoLaunchEnabled ? "success" : "default"}
|
color={autoLaunchEnabled ? "success" : "default"}
|
||||||
variant={autoLaunchEnabled ? "filled" : "outlined"}
|
variant={autoLaunchEnabled ? "filled" : "outlined"}
|
||||||
onClick={toggleAutoLaunch}
|
onClick={toggleAutoLaunch}
|
||||||
disabled={isAdminMode}
|
sx={{ cursor: "pointer" }}
|
||||||
sx={{ cursor: isAdminMode ? "not-allowed" : "pointer" }}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useRef, useMemo, useCallback } from "react";
|
import { useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { Box, IconButton, Tooltip, alpha, styled } from "@mui/material";
|
import { Box, IconButton, Tooltip, alpha, styled, Grid } from "@mui/material";
|
||||||
import Grid from "@mui/material/Grid2";
|
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -118,8 +117,18 @@ export const TestCard = () => {
|
|||||||
const [removed] = newList.splice(old_index, 1);
|
const [removed] = newList.splice(old_index, 1);
|
||||||
newList.splice(new_index, 0, removed);
|
newList.splice(new_index, 0, removed);
|
||||||
|
|
||||||
await mutateVerge({ ...verge, test_list: newList }, false);
|
// 优化:先本地更新,再异步 patch,避免UI卡死
|
||||||
await patchVerge({ test_list: newList });
|
mutateVerge({ ...verge, test_list: newList }, false);
|
||||||
|
const patchFn = () => {
|
||||||
|
try {
|
||||||
|
patchVerge({ test_list: newList });
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
if (window.requestIdleCallback) {
|
||||||
|
window.requestIdleCallback(patchFn);
|
||||||
|
} else {
|
||||||
|
setTimeout(patchFn, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[testList, verge, mutateVerge, patchVerge]
|
[testList, verge, mutateVerge, patchVerge]
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Button, ButtonGroup } from "@mui/material";
|
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
import {
|
|
||||||
CloseRounded,
|
|
||||||
CropSquareRounded,
|
|
||||||
FilterNoneRounded,
|
|
||||||
HorizontalRuleRounded,
|
|
||||||
PushPinOutlined,
|
|
||||||
PushPinRounded,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
const appWindow = getCurrentWebviewWindow();
|
|
||||||
|
|
||||||
export const LayoutControl = () => {
|
|
||||||
const minWidth = 40;
|
|
||||||
|
|
||||||
const [isMaximized, setIsMaximized] = useState(false);
|
|
||||||
const [isPined, setIsPined] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unlistenResized = appWindow.onResized(() => {
|
|
||||||
appWindow.isMaximized().then((maximized) => {
|
|
||||||
setIsMaximized(() => maximized);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
appWindow.isMaximized().then((maximized) => {
|
|
||||||
setIsMaximized(() => maximized);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unlistenResized.then((fn) => fn());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonGroup
|
|
||||||
variant="text"
|
|
||||||
sx={{
|
|
||||||
zIndex: 1000,
|
|
||||||
height: "100%",
|
|
||||||
".MuiButtonGroup-grouped": {
|
|
||||||
borderRadius: "0px",
|
|
||||||
borderRight: "0px",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
|
|
||||||
onClick={() => {
|
|
||||||
appWindow.setAlwaysOnTop(!isPined);
|
|
||||||
setIsPined((isPined) => !isPined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPined ? (
|
|
||||||
<PushPinRounded fontSize="small" />
|
|
||||||
) : (
|
|
||||||
<PushPinOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
|
|
||||||
onClick={() => appWindow.minimize()}
|
|
||||||
>
|
|
||||||
<HorizontalRuleRounded fontSize="small" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
|
|
||||||
onClick={() => {
|
|
||||||
setIsMaximized((isMaximized) => !isMaximized);
|
|
||||||
appWindow.toggleMaximize();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isMaximized ? (
|
|
||||||
<FilterNoneRounded
|
|
||||||
fontSize="small"
|
|
||||||
style={{
|
|
||||||
transform: "rotate(180deg) scale(0.7)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CropSquareRounded fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
minWidth,
|
|
||||||
svg: { transform: "scale(1.05)" },
|
|
||||||
":hover": { bgcolor: "#ff000090" },
|
|
||||||
}}
|
|
||||||
onClick={() => appWindow.close()}
|
|
||||||
>
|
|
||||||
<CloseRounded fontSize="small" />
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { alpha, createTheme, Shadows, Theme } from "@mui/material";
|
import { alpha, createTheme, Shadows, Theme as MuiTheme } from "@mui/material";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow, WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { useSetThemeMode, useThemeMode } from "@/services/states";
|
import { useSetThemeMode, useThemeMode } from "@/services/states";
|
||||||
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
|
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
arSD as arXDataGrid,
|
arSD as arXDataGrid,
|
||||||
} from "@mui/x-data-grid/locales";
|
} from "@mui/x-data-grid/locales";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
const appWindow = getCurrentWebviewWindow();
|
import { Theme as TauriOsTheme } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
const languagePackMap: Record<string, any> = {
|
const languagePackMap: Record<string, any> = {
|
||||||
zh: { ...zhXDataGrid },
|
zh: { ...zhXDataGrid },
|
||||||
@@ -29,6 +29,7 @@ const getLanguagePackMap = (key: string) =>
|
|||||||
* custom theme
|
* custom theme
|
||||||
*/
|
*/
|
||||||
export const useCustomTheme = () => {
|
export const useCustomTheme = () => {
|
||||||
|
const appWindow: WebviewWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const { theme_mode, theme_setting } = verge ?? {};
|
const { theme_mode, theme_setting } = verge ?? {};
|
||||||
@@ -36,31 +37,71 @@ export const useCustomTheme = () => {
|
|||||||
const setMode = useSetThemeMode();
|
const setMode = useSetThemeMode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const themeMode = ["light", "dark", "system"].includes(theme_mode!)
|
if (theme_mode === "light" || theme_mode === "dark") {
|
||||||
? theme_mode!
|
setMode(theme_mode);
|
||||||
: "light";
|
}
|
||||||
|
}, [theme_mode, setMode]);
|
||||||
|
|
||||||
if (themeMode !== "system") {
|
useEffect(() => {
|
||||||
setMode(themeMode);
|
if (theme_mode !== "system") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appWindow.theme().then((m) => m && setMode(m));
|
let isMounted = true;
|
||||||
const unlisten = appWindow.onThemeChanged((e) => setMode(e.payload));
|
|
||||||
|
const timerId = setTimeout(() => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
appWindow.theme().then((systemTheme) => {
|
||||||
|
if (isMounted && systemTheme) {
|
||||||
|
setMode(systemTheme);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Failed to get initial system theme:", err);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const unlistenPromise = appWindow.onThemeChanged(({ payload }) => {
|
||||||
|
if (isMounted) {
|
||||||
|
setMode(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.then((fn) => fn());
|
isMounted = false;
|
||||||
|
clearTimeout(timerId);
|
||||||
|
unlistenPromise.then((unlistenFn) => {
|
||||||
|
if (typeof unlistenFn === 'function') {
|
||||||
|
unlistenFn();
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error("Failed to unlisten from theme changes:", err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [theme_mode]);
|
}, [theme_mode, appWindow, setMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme_mode === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme_mode === "system") {
|
||||||
|
appWindow.setTheme(null).catch((err) => {
|
||||||
|
console.error("Failed to set window theme to follow system (setTheme(null)):", err);
|
||||||
|
});
|
||||||
|
} else if (mode) {
|
||||||
|
appWindow.setTheme(mode as TauriOsTheme).catch((err) => {
|
||||||
|
console.error(`Failed to set window theme to ${mode}:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mode, appWindow, theme_mode]);
|
||||||
|
|
||||||
const theme = useMemo(() => {
|
const theme = useMemo(() => {
|
||||||
const setting = theme_setting || {};
|
const setting = theme_setting || {};
|
||||||
const dt = mode === "light" ? defaultTheme : defaultDarkTheme;
|
const dt = mode === "light" ? defaultTheme : defaultDarkTheme;
|
||||||
|
let muiTheme: MuiTheme;
|
||||||
let theme: Theme;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
theme = createTheme(
|
muiTheme = createTheme(
|
||||||
{
|
{
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
|
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
|
||||||
@@ -83,7 +124,6 @@ export const useCustomTheme = () => {
|
|||||||
},
|
},
|
||||||
shadows: Array(25).fill("none") as Shadows,
|
shadows: Array(25).fill("none") as Shadows,
|
||||||
typography: {
|
typography: {
|
||||||
// todo
|
|
||||||
fontFamily: setting.font_family
|
fontFamily: setting.font_family
|
||||||
? `${setting.font_family}, ${dt.font_family}`
|
? `${setting.font_family}, ${dt.font_family}`
|
||||||
: dt.font_family,
|
: dt.font_family,
|
||||||
@@ -91,9 +131,9 @@ export const useCustomTheme = () => {
|
|||||||
},
|
},
|
||||||
getLanguagePackMap(i18n.language),
|
getLanguagePackMap(i18n.language),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (e) {
|
||||||
// fix #294
|
console.error("Error creating MUI theme, falling back to defaults:", e);
|
||||||
theme = createTheme({
|
muiTheme = createTheme({
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
|
values: { xs: 0, sm: 650, md: 900, lg: 1200, xl: 1536 },
|
||||||
},
|
},
|
||||||
@@ -111,38 +151,36 @@ export const useCustomTheme = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// css
|
|
||||||
const backgroundColor = mode === "light" ? "#ECECEC" : "#2e303d";
|
|
||||||
const selectColor = mode === "light" ? "#f5f5f5" : "#d5d5d5";
|
|
||||||
const scrollColor = mode === "light" ? "#90939980" : "#3E3E3Eee";
|
|
||||||
const dividerColor =
|
|
||||||
mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)";
|
|
||||||
|
|
||||||
const rootEle = document.documentElement;
|
const rootEle = document.documentElement;
|
||||||
rootEle.style.setProperty("--divider-color", dividerColor);
|
if (rootEle) {
|
||||||
rootEle.style.setProperty("--background-color", backgroundColor);
|
const backgroundColor = mode === "light" ? "#ECECEC" : "#2e303d";
|
||||||
rootEle.style.setProperty("--selection-color", selectColor);
|
const selectColor = mode === "light" ? "#f5f5f5" : "#d5d5d5";
|
||||||
rootEle.style.setProperty("--scroller-color", scrollColor);
|
const scrollColor = mode === "light" ? "#90939980" : "#3E3E3Eee";
|
||||||
rootEle.style.setProperty("--primary-main", theme.palette.primary.main);
|
const dividerColor =
|
||||||
rootEle.style.setProperty(
|
mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)";
|
||||||
"--background-color-alpha",
|
|
||||||
alpha(theme.palette.primary.main, 0.1),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
rootEle.style.setProperty("--divider-color", dividerColor);
|
||||||
|
rootEle.style.setProperty("--background-color", backgroundColor);
|
||||||
|
rootEle.style.setProperty("--selection-color", selectColor);
|
||||||
|
rootEle.style.setProperty("--scroller-color", scrollColor);
|
||||||
|
rootEle.style.setProperty("--primary-main", muiTheme.palette.primary.main);
|
||||||
|
rootEle.style.setProperty(
|
||||||
|
"--background-color-alpha",
|
||||||
|
alpha(muiTheme.palette.primary.main, 0.1),
|
||||||
|
);
|
||||||
|
}
|
||||||
// inject css
|
// inject css
|
||||||
let style = document.querySelector("style#verge-theme");
|
let styleElement = document.querySelector("style#verge-theme");
|
||||||
if (!style) {
|
if (!styleElement) {
|
||||||
style = document.createElement("style");
|
styleElement = document.createElement("style");
|
||||||
style.id = "verge-theme";
|
styleElement.id = "verge-theme";
|
||||||
document.head.appendChild(style!);
|
document.head.appendChild(styleElement!);
|
||||||
}
|
}
|
||||||
if (style) {
|
if (styleElement) {
|
||||||
style.innerHTML = setting.css_injection || "";
|
styleElement.innerHTML = setting.css_injection || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// update svg icon
|
const { palette } = muiTheme;
|
||||||
const { palette } = theme;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const dom = document.querySelector("#Gradient2");
|
const dom = document.querySelector("#Gradient2");
|
||||||
if (dom) {
|
if (dom) {
|
||||||
@@ -154,7 +192,7 @@ export const useCustomTheme = () => {
|
|||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return theme;
|
return muiTheme;
|
||||||
}, [mode, theme_setting, i18n.language]);
|
}, [mode, theme_setting, i18n.language]);
|
||||||
|
|
||||||
return { theme };
|
return { theme };
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import {
|
|||||||
CloseFullscreenRounded,
|
CloseFullscreenRounded,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useThemeMode } from "@/services/states";
|
import { useThemeMode } from "@/services/states";
|
||||||
import { Notice } from "@/components/base";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { showNotice } from "@/services/noticeService";
|
||||||
import getSystem from "@/utils/get-system";
|
import getSystem from "@/utils/get-system";
|
||||||
import debounce from "@/utils/debounce";
|
import debounce from "@/utils/debounce";
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
|
|||||||
onClose,
|
onClose,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
|
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(undefined);
|
||||||
const prevData = useRef<string | undefined>("");
|
const prevData = useRef<string | undefined>("");
|
||||||
const currData = useRef<string | undefined>("");
|
const currData = useRef<string | undefined>("");
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
|
|||||||
currData.current = value;
|
currData.current = value;
|
||||||
onChange?.(prevData.current, currData.current);
|
onChange?.(prevData.current, currData.current);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.message || err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
|
|||||||
!readOnly && onSave?.(prevData.current, currData.current);
|
!readOnly && onSave?.(prevData.current, currData.current);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.message || err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
|
|||||||
try {
|
try {
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.message || err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const FileInput = (props: Props) => {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// file input
|
// file input
|
||||||
const inputRef = useRef<any>();
|
const inputRef = useRef<any>(undefined);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [fileName, setFileName] = useState("");
|
const [fileName, setFileName] = useState("");
|
||||||
|
|
||||||
|
|||||||
@@ -40,13 +40,14 @@ import {
|
|||||||
readProfileFile,
|
readProfileFile,
|
||||||
saveProfileFile,
|
saveProfileFile,
|
||||||
} from "@/services/cmds";
|
} from "@/services/cmds";
|
||||||
import { Notice, Switch } from "@/components/base";
|
import { Switch } from "@/components/base";
|
||||||
import getSystem from "@/utils/get-system";
|
import getSystem from "@/utils/get-system";
|
||||||
import { BaseSearchBox } from "../base/base-search-box";
|
import { BaseSearchBox } from "../base/base-search-box";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
import MonacoEditor from "react-monaco-editor";
|
import MonacoEditor from "react-monaco-editor";
|
||||||
import { useThemeMode } from "@/services/states";
|
import { useThemeMode } from "@/services/states";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { showNotice } from "@/services/noticeService";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
proxiesUid: string;
|
proxiesUid: string;
|
||||||
@@ -179,16 +180,27 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
setDeleteSeq(obj?.delete || []);
|
setDeleteSeq(obj?.delete || []);
|
||||||
}, [visualization]);
|
}, [visualization]);
|
||||||
|
|
||||||
|
// 优化:异步处理大数据yaml.dump,避免UI卡死
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prependSeq && appendSeq && deleteSeq)
|
if (prependSeq && appendSeq && deleteSeq) {
|
||||||
setCurrData(
|
const serialize = () => {
|
||||||
yaml.dump(
|
try {
|
||||||
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
setCurrData(
|
||||||
{
|
yaml.dump(
|
||||||
forceQuotes: true,
|
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
||||||
}
|
{ forceQuotes: true }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 防止异常导致UI卡死
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (window.requestIdleCallback) {
|
||||||
|
window.requestIdleCallback(serialize);
|
||||||
|
} else {
|
||||||
|
setTimeout(serialize, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [prependSeq, appendSeq, deleteSeq]);
|
}, [prependSeq, appendSeq, deleteSeq]);
|
||||||
|
|
||||||
const fetchProxyPolicy = async () => {
|
const fetchProxyPolicy = async () => {
|
||||||
@@ -285,10 +297,11 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
const handleSave = useLockFn(async () => {
|
const handleSave = useLockFn(async () => {
|
||||||
try {
|
try {
|
||||||
await saveProfileFile(property, currData);
|
await saveProfileFile(property, currData);
|
||||||
|
showNotice('success', t("Saved Successfully"));
|
||||||
onSave?.(prevData, currData);
|
onSave?.(prevData, currData);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,7 +454,7 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
<ListItemText primary={t("Health Check Url")} />
|
<ListItemText primary={t("Health Check Url")} />
|
||||||
<TextField
|
<TextField
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="https://www.gstatic.com/generate_204"
|
placeholder="https://cp.cloudflare.com/generate_204"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ width: "calc(100% - 150px)" }}
|
sx={{ width: "calc(100% - 150px)" }}
|
||||||
{...field}
|
{...field}
|
||||||
@@ -482,12 +495,14 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(parseInt(e.target.value));
|
field.onChange(parseInt(e.target.value));
|
||||||
}}
|
}}
|
||||||
InputProps={{
|
slotProps={{
|
||||||
endAdornment: (
|
input: {
|
||||||
<InputAdornment position="end">
|
endAdornment: (
|
||||||
{t("seconds")}
|
<InputAdornment position="end">
|
||||||
</InputAdornment>
|
{t("seconds")}
|
||||||
),
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -508,12 +523,14 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(parseInt(e.target.value));
|
field.onChange(parseInt(e.target.value));
|
||||||
}}
|
}}
|
||||||
InputProps={{
|
slotProps={{
|
||||||
endAdornment: (
|
input: {
|
||||||
<InputAdornment position="end">
|
endAdornment: (
|
||||||
{t("millis")}
|
<InputAdornment position="end">
|
||||||
</InputAdornment>
|
{t("millis")}
|
||||||
),
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -725,7 +742,7 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
}
|
}
|
||||||
setPrependSeq([formIns.getValues(), ...prependSeq]);
|
setPrependSeq([formIns.getValues(), ...prependSeq]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.message || err.toString());
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -747,7 +764,7 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
}
|
}
|
||||||
setAppendSeq([...appendSeq, formIns.getValues()]);
|
setAppendSeq([...appendSeq, formIns.getValues()]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.message || err.toString());
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -889,10 +906,9 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
padding: {
|
padding: {
|
||||||
top: 33, // 顶部padding防止遮挡snippets
|
top: 33, // 顶部padding防止遮挡snippets
|
||||||
},
|
},
|
||||||
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
|
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""
|
||||||
getSystem() === "windows" ? ", twemoji mozilla" : ""
|
}`,
|
||||||
}`,
|
fontLigatures: false, // 连字符
|
||||||
fontLigatures: true, // 连字符
|
|
||||||
smoothScrolling: true, // 平滑滚动
|
smoothScrolling: true, // 平滑滚动
|
||||||
}}
|
}}
|
||||||
onChange={(value) => setCurrData(value)}
|
onChange={(value) => setCurrData(value)}
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import {
|
|||||||
readProfileFile,
|
readProfileFile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
saveProfileFile,
|
saveProfileFile,
|
||||||
|
getNextUpdateTime,
|
||||||
} from "@/services/cmds";
|
} from "@/services/cmds";
|
||||||
import { Notice } from "@/components/base";
|
import { showNotice } from "@/services/noticeService";
|
||||||
import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer";
|
import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer";
|
||||||
import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
|
import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
|
||||||
import { EditorViewer } from "@/components/profile/editor-viewer";
|
import { EditorViewer } from "@/components/profile/editor-viewer";
|
||||||
@@ -66,8 +67,106 @@ export const ProfileItem = (props: Props) => {
|
|||||||
const loadingCache = useLoadingCache();
|
const loadingCache = useLoadingCache();
|
||||||
const setLoadingCache = useSetLoadingCache();
|
const setLoadingCache = useSetLoadingCache();
|
||||||
|
|
||||||
|
// 新增状态:是否显示下次更新时间
|
||||||
|
const [showNextUpdate, setShowNextUpdate] = useState(false);
|
||||||
|
const [nextUpdateTime, setNextUpdateTime] = useState("");
|
||||||
|
|
||||||
const { uid, name = "Profile", extra, updated = 0, option } = itemData;
|
const { uid, name = "Profile", extra, updated = 0, option } = itemData;
|
||||||
|
|
||||||
|
// 获取下次更新时间的函数
|
||||||
|
const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => {
|
||||||
|
if (itemData.option?.update_interval && itemData.option.update_interval > 0) {
|
||||||
|
try {
|
||||||
|
console.log(`尝试获取配置 ${itemData.uid} 的下次更新时间`);
|
||||||
|
|
||||||
|
// 如果需要强制刷新,先触发Timer.refresh()
|
||||||
|
if (forceRefresh) {
|
||||||
|
// 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新
|
||||||
|
console.log(`强制刷新定时器任务`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUpdate = await getNextUpdateTime(itemData.uid);
|
||||||
|
console.log(`获取到下次更新时间结果:`, nextUpdate);
|
||||||
|
|
||||||
|
if (nextUpdate) {
|
||||||
|
const nextUpdateDate = dayjs(nextUpdate * 1000);
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
// 如果已经过期,显示"更新失败"
|
||||||
|
if (nextUpdateDate.isBefore(now)) {
|
||||||
|
setNextUpdateTime(t("Last Update failed"));
|
||||||
|
} else {
|
||||||
|
// 否则显示剩余时间
|
||||||
|
const diffMinutes = nextUpdateDate.diff(now, 'minute');
|
||||||
|
|
||||||
|
if (diffMinutes < 60) {
|
||||||
|
if (diffMinutes <= 0) {
|
||||||
|
setNextUpdateTime(`${t("Next Up")} <1m`);
|
||||||
|
} else {
|
||||||
|
setNextUpdateTime(`${t("Next Up")} ${diffMinutes}m`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hours = Math.floor(diffMinutes / 60);
|
||||||
|
const mins = diffMinutes % 60;
|
||||||
|
setNextUpdateTime(`${t("Next Up")} ${hours}h ${mins}m`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`返回的下次更新时间为空`);
|
||||||
|
setNextUpdateTime(t("No schedule"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`获取下次更新时间出错:`, err);
|
||||||
|
setNextUpdateTime(t("Unknown"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`该配置未设置更新间隔或间隔为0`);
|
||||||
|
setNextUpdateTime(t("Auto update disabled"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换显示模式的函数
|
||||||
|
const toggleUpdateTimeDisplay = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!showNextUpdate) {
|
||||||
|
fetchNextUpdateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowNextUpdate(!showNextUpdate);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当组件加载或更新间隔变化时更新下次更新时间
|
||||||
|
useEffect(() => {
|
||||||
|
if (showNextUpdate) {
|
||||||
|
fetchNextUpdateTime();
|
||||||
|
}
|
||||||
|
}, [showNextUpdate, itemData.option?.update_interval, updated]);
|
||||||
|
|
||||||
|
// 订阅定时器更新事件
|
||||||
|
useEffect(() => {
|
||||||
|
// 处理定时器更新事件 - 这个事件专门用于通知定时器变更
|
||||||
|
const handleTimerUpdate = (event: any) => {
|
||||||
|
const updatedUid = event.payload as string;
|
||||||
|
|
||||||
|
// 只有当更新的是当前配置时才刷新显示
|
||||||
|
if (updatedUid === itemData.uid && showNextUpdate) {
|
||||||
|
console.log(`收到定时器更新事件: uid=${updatedUid}`);
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchNextUpdateTime(true);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只注册定时器更新事件监听
|
||||||
|
window.addEventListener('verge://timer-updated', handleTimerUpdate as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 清理事件监听
|
||||||
|
window.removeEventListener('verge://timer-updated', handleTimerUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}, [showNextUpdate, itemData.uid]);
|
||||||
|
|
||||||
// local file mode
|
// local file mode
|
||||||
// remote file mode
|
// remote file mode
|
||||||
// remote file mode
|
// remote file mode
|
||||||
@@ -172,7 +271,7 @@ export const ProfileItem = (props: Props) => {
|
|||||||
try {
|
try {
|
||||||
await viewProfile(itemData.uid);
|
await viewProfile(itemData.uid);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err?.message || err.toString());
|
showNotice('error', err?.message || err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,12 +282,6 @@ export const ProfileItem = (props: Props) => {
|
|||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
|
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
|
||||||
|
|
||||||
// 存储原始设置以便回退后恢复
|
|
||||||
const originalOptions = {
|
|
||||||
with_proxy: itemData.option?.with_proxy,
|
|
||||||
self_proxy: itemData.option?.self_proxy
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据类型设置初始更新选项
|
// 根据类型设置初始更新选项
|
||||||
const option: Partial<IProfileOption> = {};
|
const option: Partial<IProfileOption> = {};
|
||||||
if (type === 0) {
|
if (type === 0) {
|
||||||
@@ -205,31 +298,15 @@ export const ProfileItem = (props: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 尝试正常更新
|
// 调用后端更新(后端会自动处理回退逻辑)
|
||||||
await updateProfile(itemData.uid, option);
|
await updateProfile(itemData.uid, option);
|
||||||
Notice.success(t("Update subscription successfully"));
|
|
||||||
|
// 更新成功,刷新列表
|
||||||
|
showNotice('success', t("Update subscription successfully"));
|
||||||
mutate("getProfiles");
|
mutate("getProfiles");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 更新失败,尝试使用自身代理
|
// 更新完全失败(包括后端的回退尝试)
|
||||||
const errmsg = err?.message || err.toString();
|
// 不需要做处理,后端会通过事件通知系统发送错误
|
||||||
Notice.info(t("Update failed, retrying with Clash proxy..."));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateProfile(itemData.uid, {
|
|
||||||
with_proxy: false,
|
|
||||||
self_proxy: true
|
|
||||||
});
|
|
||||||
|
|
||||||
Notice.success(t("Update with Clash proxy successfully"));
|
|
||||||
|
|
||||||
await updateProfile(itemData.uid, originalOptions);
|
|
||||||
mutate("getProfiles");
|
|
||||||
} catch (retryErr: any) {
|
|
||||||
const retryErrmsg = retryErr?.message || retryErr.toString();
|
|
||||||
Notice.error(
|
|
||||||
`${t("Update failed even with Clash proxy")}: ${retryErrmsg.replace(/error sending request for url (\S+?): /, "")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
|
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
|
||||||
}
|
}
|
||||||
@@ -268,7 +345,7 @@ export const ProfileItem = (props: Props) => {
|
|||||||
},
|
},
|
||||||
{ label: "Open File", handler: onOpenFile, disabled: false },
|
{ label: "Open File", handler: onOpenFile, disabled: false },
|
||||||
{ label: "Update", handler: () => onUpdate(0), disabled: false },
|
{ label: "Update", handler: () => onUpdate(0), disabled: false },
|
||||||
{ label: "Update(Proxy)", handler: () => onUpdate(2), disabled: false },
|
{ label: "Update via proxy", handler: () => onUpdate(2), disabled: false },
|
||||||
{
|
{
|
||||||
label: "Delete",
|
label: "Delete",
|
||||||
handler: () => {
|
handler: () => {
|
||||||
@@ -325,6 +402,35 @@ export const ProfileItem = (props: Props) => {
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 监听自动更新事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdateStarted = (event: CustomEvent) => {
|
||||||
|
if (event.detail.uid === itemData.uid) {
|
||||||
|
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCompleted = (event: CustomEvent) => {
|
||||||
|
if (event.detail.uid === itemData.uid) {
|
||||||
|
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
|
||||||
|
// 更新完成后刷新显示
|
||||||
|
if (showNextUpdate) {
|
||||||
|
fetchNextUpdateTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注册事件监听
|
||||||
|
window.addEventListener('profile-update-started', handleUpdateStarted as EventListener);
|
||||||
|
window.addEventListener('profile-update-completed', handleUpdateCompleted as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 清理事件监听
|
||||||
|
window.removeEventListener('profile-update-started', handleUpdateStarted as EventListener);
|
||||||
|
window.removeEventListener('profile-update-completed', handleUpdateCompleted as EventListener);
|
||||||
|
};
|
||||||
|
}, [itemData.uid, showNextUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -435,15 +541,30 @@ export const ProfileItem = (props: Props) => {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{hasUrl && (
|
{hasUrl && (
|
||||||
<Typography
|
<Box sx={{ display: "flex", justifyContent: "flex-end", ml: "auto" }}>
|
||||||
noWrap
|
<Typography
|
||||||
flex="1 0 auto"
|
noWrap
|
||||||
fontSize={14}
|
component="span"
|
||||||
textAlign="right"
|
fontSize={14}
|
||||||
title={`${t("Update Time")}: ${parseExpire(updated)}`}
|
textAlign="right"
|
||||||
>
|
title={showNextUpdate ? t("Click to show last update time") : `${t("Update Time")}: ${parseExpire(updated)}\n${t("Click to show next update")}`}
|
||||||
{updated > 0 ? dayjs(updated * 1000).fromNow() : ""}
|
sx={{
|
||||||
</Typography>
|
cursor: "pointer",
|
||||||
|
display: "inline-block",
|
||||||
|
borderBottom: "1px dashed transparent",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
"&:hover": {
|
||||||
|
borderBottomColor: "primary.main",
|
||||||
|
color: "primary.main",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={toggleUpdateTimeDisplay}
|
||||||
|
>
|
||||||
|
{showNextUpdate
|
||||||
|
? nextUpdateTime
|
||||||
|
: (updated > 0 ? dayjs(updated * 1000).fromNow() : "")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { FeaturedPlayListRounded } from "@mui/icons-material";
|
import { FeaturedPlayListRounded } from "@mui/icons-material";
|
||||||
import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
|
import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
|
||||||
import { Notice } from "@/components/base";
|
|
||||||
import { EditorViewer } from "@/components/profile/editor-viewer";
|
import { EditorViewer } from "@/components/profile/editor-viewer";
|
||||||
import { ProfileBox } from "./profile-box";
|
import { ProfileBox } from "./profile-box";
|
||||||
import { LogViewer } from "./log-viewer";
|
import { LogViewer } from "./log-viewer";
|
||||||
|
import { showNotice } from "@/services/noticeService";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logInfo?: [string, string][];
|
logInfo?: [string, string][];
|
||||||
@@ -43,7 +43,7 @@ export const ProfileMore = (props: Props) => {
|
|||||||
try {
|
try {
|
||||||
await viewProfile(id);
|
await viewProfile(id);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err?.message || err.toString());
|
showNotice('error', err?.message || err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { createProfile, patchProfile } from "@/services/cmds";
|
import { createProfile, patchProfile } from "@/services/cmds";
|
||||||
import { BaseDialog, Notice, Switch } from "@/components/base";
|
import { BaseDialog, Switch } from "@/components/base";
|
||||||
import { version } from "@root/package.json";
|
import { version } from "@root/package.json";
|
||||||
import { FileInput } from "./file-input";
|
import { FileInput } from "./file-input";
|
||||||
|
import { useProfiles } from "@/hooks/use-profiles";
|
||||||
|
import { showNotice } from "@/services/noticeService";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onChange: () => void;
|
onChange: (isActivating?: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileViewerRef {
|
export interface ProfileViewerRef {
|
||||||
@@ -40,6 +42,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [openType, setOpenType] = useState<"new" | "edit">("new");
|
const [openType, setOpenType] = useState<"new" | "edit">("new");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { profiles } = useProfiles();
|
||||||
|
|
||||||
// file input
|
// file input
|
||||||
const fileDataRef = useRef<string | null>(null);
|
const fileDataRef = useRef<string | null>(null);
|
||||||
@@ -86,6 +89,10 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
|
|
||||||
const handleOk = useLockFn(
|
const handleOk = useLockFn(
|
||||||
formIns.handleSubmit(async (form) => {
|
formIns.handleSubmit(async (form) => {
|
||||||
|
if (form.option?.timeout_seconds) {
|
||||||
|
form.option.timeout_seconds = +form.option.timeout_seconds;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 基本验证
|
// 基本验证
|
||||||
@@ -107,11 +114,16 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
const name = form.name || `${form.type} file`;
|
const name = form.name || `${form.type} file`;
|
||||||
const item = { ...form, name };
|
const item = { ...form, name };
|
||||||
const isRemote = form.type === "remote";
|
const isRemote = form.type === "remote";
|
||||||
|
const isUpdate = openType === "edit";
|
||||||
|
|
||||||
|
// 判断是否是当前激活的配置
|
||||||
|
const isActivating =
|
||||||
|
isUpdate && form.uid === (profiles?.current ?? "");
|
||||||
|
|
||||||
// 保存原始代理设置以便回退成功后恢复
|
// 保存原始代理设置以便回退成功后恢复
|
||||||
const originalOptions = {
|
const originalOptions = {
|
||||||
with_proxy: form.option?.with_proxy,
|
with_proxy: form.option?.with_proxy,
|
||||||
self_proxy: form.option?.self_proxy
|
self_proxy: form.option?.self_proxy,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 执行创建或更新操作,本地配置不需要回退机制
|
// 执行创建或更新操作,本地配置不需要回退机制
|
||||||
@@ -134,7 +146,10 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 首次创建/更新失败,尝试使用自身代理
|
// 首次创建/更新失败,尝试使用自身代理
|
||||||
Notice.info(t("Profile creation failed, retrying with Clash proxy..."));
|
showNotice(
|
||||||
|
"info",
|
||||||
|
t("Profile creation failed, retrying with Clash proxy..."),
|
||||||
|
);
|
||||||
|
|
||||||
// 使用自身代理的配置
|
// 使用自身代理的配置
|
||||||
const retryItem = {
|
const retryItem = {
|
||||||
@@ -142,8 +157,8 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
option: {
|
option: {
|
||||||
...item.option,
|
...item.option,
|
||||||
with_proxy: false,
|
with_proxy: false,
|
||||||
self_proxy: true
|
self_proxy: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用自身代理再次尝试
|
// 使用自身代理再次尝试
|
||||||
@@ -157,7 +172,10 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
await patchProfile(form.uid, { option: originalOptions });
|
await patchProfile(form.uid, { option: originalOptions });
|
||||||
}
|
}
|
||||||
|
|
||||||
Notice.success(t("Profile creation succeeded with Clash proxy"));
|
showNotice(
|
||||||
|
"success",
|
||||||
|
t("Profile creation succeeded with Clash proxy"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,19 +183,25 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setTimeout(() => formIns.reset(), 500);
|
setTimeout(() => formIns.reset(), 500);
|
||||||
fileDataRef.current = null;
|
fileDataRef.current = null;
|
||||||
props.onChange();
|
|
||||||
|
// 优化:UI先关闭,异步通知父组件
|
||||||
|
setTimeout(() => {
|
||||||
|
props.onChange(isActivating);
|
||||||
|
}, 0);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice("error", err.message || err.toString());
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setOpen(false);
|
try {
|
||||||
fileDataRef.current = null;
|
setOpen(false);
|
||||||
setTimeout(() => formIns.reset(), 500);
|
fileDataRef.current = null;
|
||||||
|
setTimeout(() => formIns.reset(), 500);
|
||||||
|
} catch { }
|
||||||
};
|
};
|
||||||
|
|
||||||
const text = {
|
const text = {
|
||||||
@@ -262,6 +286,29 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="option.timeout_seconds"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...text}
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
placeholder="60"
|
||||||
|
label={t("HTTP Request Timeout")}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
{t("seconds")}
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -275,10 +322,14 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
label={t("Update Interval")}
|
label={t("Update Interval")}
|
||||||
InputProps={{
|
slotProps={{
|
||||||
endAdornment: (
|
input: {
|
||||||
<InputAdornment position="end">{t("mins")}</InputAdornment>
|
endAdornment: (
|
||||||
),
|
<InputAdornment position="end">
|
||||||
|
{t("mins")}
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -332,7 +383,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
|
|||||||
)}
|
)}
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const StyledBox = styled(Box)(() => ({
|
const StyledBox = styled(Box)(() => ({
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ import {
|
|||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { ProxyItem } from "@/components/profile/proxy-item";
|
import { ProxyItem } from "@/components/profile/proxy-item";
|
||||||
import { readProfileFile, saveProfileFile } from "@/services/cmds";
|
import { readProfileFile, saveProfileFile } from "@/services/cmds";
|
||||||
import { Notice } from "@/components/base";
|
|
||||||
import getSystem from "@/utils/get-system";
|
import getSystem from "@/utils/get-system";
|
||||||
import { BaseSearchBox } from "../base/base-search-box";
|
import { BaseSearchBox } from "../base/base-search-box";
|
||||||
import { Virtuoso } from "react-virtuoso";
|
import { Virtuoso } from "react-virtuoso";
|
||||||
import MonacoEditor from "react-monaco-editor";
|
import MonacoEditor from "react-monaco-editor";
|
||||||
import { useThemeMode } from "@/services/states";
|
import { useThemeMode } from "@/services/states";
|
||||||
import parseUri from "@/utils/uri-parser";
|
import parseUri from "@/utils/uri-parser";
|
||||||
|
import { showNotice } from "@/services/noticeService";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
profileUid: string;
|
profileUid: string;
|
||||||
@@ -130,8 +130,9 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleParse = () => {
|
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
|
||||||
let proxies = [] as IProxyConfig[];
|
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
|
||||||
|
let proxies: IProxyConfig[] = [];
|
||||||
let names: string[] = [];
|
let names: string[] = [];
|
||||||
let uris = "";
|
let uris = "";
|
||||||
try {
|
try {
|
||||||
@@ -139,10 +140,13 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
} catch {
|
} catch {
|
||||||
uris = proxyUri;
|
uris = proxyUri;
|
||||||
}
|
}
|
||||||
uris
|
const lines = uris.trim().split("\n");
|
||||||
.trim()
|
let idx = 0;
|
||||||
.split("\n")
|
const batchSize = 50;
|
||||||
.forEach((uri) => {
|
function parseBatch() {
|
||||||
|
const end = Math.min(idx + batchSize, lines.length);
|
||||||
|
for (; idx < end; idx++) {
|
||||||
|
const uri = lines[idx];
|
||||||
try {
|
try {
|
||||||
let proxy = parseUri(uri.trim());
|
let proxy = parseUri(uri.trim());
|
||||||
if (!names.includes(proxy.name)) {
|
if (!names.includes(proxy.name)) {
|
||||||
@@ -150,10 +154,16 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
names.push(proxy.name);
|
names.push(proxy.name);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
// 不阻塞主流程
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return proxies;
|
if (idx < lines.length) {
|
||||||
|
setTimeout(parseBatch, 0);
|
||||||
|
} else {
|
||||||
|
cb(proxies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseBatch();
|
||||||
};
|
};
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
let data = await readProfileFile(profileUid);
|
let data = await readProfileFile(profileUid);
|
||||||
@@ -192,15 +202,25 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
}, [visualization]);
|
}, [visualization]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prependSeq && appendSeq && deleteSeq)
|
if (prependSeq && appendSeq && deleteSeq) {
|
||||||
setCurrData(
|
const serialize = () => {
|
||||||
yaml.dump(
|
try {
|
||||||
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
setCurrData(
|
||||||
{
|
yaml.dump(
|
||||||
forceQuotes: true,
|
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
||||||
}
|
{ forceQuotes: true }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// 防止异常导致UI卡死
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (window.requestIdleCallback) {
|
||||||
|
window.requestIdleCallback(serialize);
|
||||||
|
} else {
|
||||||
|
setTimeout(serialize, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [prependSeq, appendSeq, deleteSeq]);
|
}, [prependSeq, appendSeq, deleteSeq]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -212,10 +232,11 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
const handleSave = useLockFn(async () => {
|
const handleSave = useLockFn(async () => {
|
||||||
try {
|
try {
|
||||||
await saveProfileFile(property, currData);
|
await saveProfileFile(property, currData);
|
||||||
|
showNotice('success', t("Saved Successfully"));
|
||||||
onSave?.(prevData, currData);
|
onSave?.(prevData, currData);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
Notice.error(err.message || err.toString());
|
showNotice('error', err.toString());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -275,8 +296,9 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<VerticalAlignTopRounded />}
|
startIcon={<VerticalAlignTopRounded />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
let proxies = handleParse();
|
handleParseAsync((proxies) => {
|
||||||
setPrependSeq([...proxies, ...prependSeq]);
|
setPrependSeq((prev) => [...proxies, ...prev]);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Prepend Proxy")}
|
{t("Prepend Proxy")}
|
||||||
@@ -288,8 +310,9 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<VerticalAlignBottomRounded />}
|
startIcon={<VerticalAlignBottomRounded />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
let proxies = handleParse();
|
handleParseAsync((proxies) => {
|
||||||
setAppendSeq([...appendSeq, ...proxies]);
|
setAppendSeq((prev) => [...prev, ...proxies]);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("Append Proxy")}
|
{t("Append Proxy")}
|
||||||
@@ -430,10 +453,9 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
padding: {
|
padding: {
|
||||||
top: 33, // 顶部padding防止遮挡snippets
|
top: 33, // 顶部padding防止遮挡snippets
|
||||||
},
|
},
|
||||||
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
|
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""
|
||||||
getSystem() === "windows" ? ", twemoji mozilla" : ""
|
}`,
|
||||||
}`,
|
fontLigatures: false, // 连字符
|
||||||
fontLigatures: true, // 连字符
|
|
||||||
smoothScrolling: true, // 平滑滚动
|
smoothScrolling: true, // 平滑滚动
|
||||||
}}
|
}}
|
||||||
onChange={(value) => setCurrData(value)}
|
onChange={(value) => setCurrData(value)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user