58 Commits

Author SHA1 Message Date
coolcoala
b3c3407c1f Update autobuild.yml 2025-11-30 02:29:48 +03:00
coolcoala
db7fd8a3d3 Update prebuild.mjs 2025-11-28 02:27:37 +03:00
coolcoala
2b74606764 Update prebuild.mjs 2025-11-28 02:24:54 +03:00
coolcoala
da2000c1e5 Update prebuild.mjs 2025-11-28 02:21:22 +03:00
coolcoala
ab276045ea test with xhttp 2025-11-24 23:15:50 +03:00
coolcoala
75aee23241 test with xhttp 2025-11-24 23:06:49 +03:00
coolcoala
3a59d95732 v0.2.8 2025-11-13 01:35:38 +03:00
coolcoala
6916009cc7 updated UPDATELOG.md 2025-11-13 01:08:04 +03:00
coolcoala
9b82d02c67 fixed russian locale 2025-11-13 01:02:16 +03:00
coolcoala
099cf8065f allowed to set an empty password on an external controller 2025-11-13 00:53:59 +03:00
coolcoala
ad8b5a5171 fixed minimal window size 2025-11-13 00:46:05 +03:00
coolcoala
ac09de615e fixed locales 2025-11-13 00:41:12 +03:00
coolcoala
743cc42879 menu removed by right-clicking 2025-11-13 00:13:27 +03:00
coolcoala
3fd969b9b0 fixed renaming .sig files 2025-11-13 00:08:50 +03:00
coolcoala
92ba69078d fixed uploading updater for macos 2025-11-13 00:00:31 +03:00
coolcoala
20ca8619f7 minor layout fix 2025-11-12 23:40:26 +03:00
coolcoala
892738e198 fixed hwid definition 2025-11-12 23:29:14 +03:00
coolcoala
1aa0c7bc34 fixed an issue with error 0xc00000142 2025-11-12 23:28:49 +03:00
coolcoala
aba9715453 fixed an issue with opening a window via a shortcut when the application is already running 2025-11-12 23:28:15 +03:00
coolcoala
c8f61d6359 fixed problem with dark mode 2025-11-12 23:26:54 +03:00
coolcoala
1fd018f3f8 update workflow 2025-11-12 23:25:59 +03:00
coolcoala
d7cfd7d3ac updated preview in README.md 2025-10-18 00:58:28 +03:00
coolcoala
e310381735 Merge pull request #14 from prettyleaf/dev
feat: add packaging status badge for koala-clash in release notes
2025-10-12 18:53:49 +03:00
Ivan Kolesnikov
8b5385b701 feat: add packaging status badge for koala-clash in release notes 2025-10-12 22:38:35 +07:00
coolcoala
a1e1fedc3f v0.2.7 2025-09-29 02:52:35 +03:00
coolcoala
84dc631d80 updated UPDATELOG.md 2025-09-29 02:52:05 +03:00
coolcoala
6a3072fe04 hide the icon from the dock on macOS when clicking the close button 2025-09-29 02:33:53 +03:00
coolcoala
98d943f39d update dependencies 2025-09-29 02:30:28 +03:00
coolcoala
bcf724273d added message about global mode enabled 2025-09-29 02:29:13 +03:00
coolcoala
8703918a8c fixed some bugs with UI 2025-09-29 02:21:50 +03:00
coolcoala
7e88f3ba29 fixed localization 2025-09-29 02:02:34 +03:00
coolcoala
d9a2f221db v0.2.6 2025-08-25 01:08:34 +03:00
coolcoala
a4b3a257ed updated UPDATELOG.md 2025-08-25 01:05:48 +03:00
coolcoala
10397d0847 logs translated from Chinese into English 2025-08-25 01:04:56 +03:00
coolcoala
db442b2746 Fixed renaming installer 2025-08-23 03:33:19 +03:00
coolcoala
8cb3c69b78 Fixed issue with deep links 2025-08-23 03:15:29 +03:00
coolcoala
967f21cc23 Improved proxy selector view 2025-08-23 03:10:46 +03:00
coolcoala
3ecd73f430 Added some animations 2025-08-23 03:10:18 +03:00
coolcoala
ca7f6b86d7 Fixed an issue with the selector width 2025-08-23 03:06:20 +03:00
coolcoala
00cee81812 Fixed an issue with adding a profile when making changes in advanced settings. 2025-08-23 03:03:07 +03:00
coolcoala
25f5db82dc fixed dns leak 2025-08-19 16:43:50 +03:00
coolcoala
9e5c5d5e69 Merge remote-tracking branch 'origin/dev' into dev 2025-08-17 16:09:48 +03:00
coolcoala
2cfd1784d8 Merge pull request #8 from vffuunnyy/dev
Features + logs
2025-08-17 13:39:56 +03:00
vffuunnyy
bec1b95ad3 refactor: fix lifetime annotations in draft.rs
ci: add cargo retry settings
2025-08-16 15:31:30 +07:00
vffuunnyy
e26f500ad0 refactor: format code with prettier and fix quotation marks 2025-08-16 15:23:43 +07:00
vffuunnyy
9c33f007a1 refactor: fix formating in rust 2025-08-16 15:22:38 +07:00
vffuunnyy
902256d461 refactor: translate Chinese log messages to English in core modules 2025-08-16 04:00:00 +07:00
vffuunnyy
6051bd6d06 Merge branch 'dev' of https://github.com/vffuunnyy/clash-verge-rev-lite into dev
* 'dev' of https://github.com/vffuunnyy/clash-verge-rev-lite:
  the Add Profile button has been moved, and the layout has been slightly changed.
  the connections page has been slightly revised.
  fixed an issue with the dialog box when the profile name is long.
  added glass effect to components
  fixed icon background
2025-08-16 01:57:32 +07:00
vffuunnyy
c82f4e50d2 feat: add compression support and fix tun config overwrite issue 2025-08-16 01:56:56 +07:00
coolcoala
94e785c75c fixed deeplinks on windows 2025-08-12 21:19:26 +03:00
coolcoala
8b8daa7b4c the Add Profile button has been moved, and the layout has been slightly changed. 2025-08-09 02:54:05 +03:00
coolcoala
c95e63014f the connections page has been slightly revised. 2025-08-09 02:54:05 +03:00
coolcoala
32bf42cbb9 fixed an issue with the dialog box when the profile name is long. 2025-08-09 02:54:05 +03:00
coolcoala
175ec98947 added glass effect to components 2025-08-09 02:53:56 +03:00
coolcoala
0abd9343a9 Merge remote-tracking branch 'origin/dev' into dev 2025-08-09 02:35:53 +03:00
coolcoala
c9976382a9 Merge pull request #6 from vffuunnyy/dev
feat: replace AliDNS with Google and Cloudflare DNS servers
2025-08-08 01:45:17 +03:00
vffuunnyy
d38e93ac7e feat: replace AliDNS with Google and Cloudflare DNS servers 2025-08-08 05:24:25 +07:00
coolcoala
e51f1d20c0 fixed icon background 2025-08-06 18:49:36 +03:00
98 changed files with 6072 additions and 3906 deletions

6
.github/FUNDING.yml vendored
View File

@@ -1 +1,5 @@
custom: ['https://t.me/tribute/app?startapp=dtfk','https://t.me/tribute/app?startapp=dtLE']
custom:
[
"https://t.me/tribute/app?startapp=dtfk",
"https://t.me/tribute/app?startapp=dtLE",
]

View File

@@ -190,9 +190,12 @@ jobs:
target: aarch64-apple-darwin
- os: macos-latest
target: x86_64-apple-darwin
- os: ubuntu-22.04
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
env:
CARGO_NET_RETRY: "5"
CARGO_HTTP_CHECK_REVOKE: "false"
steps:
- name: Checkout Repository
uses: actions/checkout@v4
@@ -211,7 +214,7 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/dev' }}
- name: Install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-22.04'
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
@@ -258,10 +261,10 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
arch: arm64
- os: ubuntu-22.04
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
arch: armhf
runs-on: ${{ matrix.os }}
@@ -306,15 +309,15 @@ jobs:
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=amd64,i386] http://archive.ubuntu.com/ubuntu noble main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-security main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-updates main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu noble-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
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main multiverse universe restricted
EOF
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default

View File

@@ -17,6 +17,9 @@ jobs:
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
env:
CARGO_NET_RETRY: "5"
CARGO_HTTP_CHECK_REVOKE: "false"
steps:
- name: Checkout Repository
uses: actions/checkout@v4

View File

@@ -28,6 +28,9 @@ jobs:
bundle: dmg
runs-on: ${{ matrix.os }}
env:
CARGO_NET_RETRY: "5"
CARGO_HTTP_CHECK_REVOKE: "false"
steps:
- name: Checkout Repository
uses: actions/checkout@v4

View File

@@ -10,6 +10,9 @@ on:
jobs:
rustfmt:
runs-on: ubuntu-latest
env:
CARGO_NET_RETRY: "5"
CARGO_HTTP_CHECK_REVOKE: "false"
steps:
- uses: actions/checkout@v4

View File

@@ -6,8 +6,8 @@ on:
# workflow_dispatch:
push:
# 应当限制在 main 分支上触发发布。
branches:
- main
# branches:
# - main
# 应当限制 v*.*.* 的 tag 触发发布。
tags:
- "v*.*.*"
@@ -86,29 +86,33 @@ jobs:
## Which version should I download?
### macOS
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_aarch64.dmg"><img src="https://img.shields.io/badge/DMG-default?style=flat&logo=apple&label=Apple%20Silicon"></a><br>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_aarch64.dmg"><img src="https://img.shields.io/badge/DMG-default?style=flat&logo=apple&label=Apple%20Silicon"></a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_x64.dmg"><img src="https://img.shields.io/badge/DMG-default?style=flat&logo=apple&label=Intel"></a><br>
> :warning: **Warning**
If you get a notification that the application is corrupted when you run it on macOS, run this command:<br>
<code>sudo xattr -r -c /Applications/Koala\ Clash.app</code>
### Linux
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_amd64.deb"><img src="https://img.shields.io/badge/x64-default?style=flat&logo=debian&label=DEB"> </a><br>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_amd64.deb"><img src="https://img.shields.io/badge/x64-default?style=flat&logo=debian&label=DEB"> </a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash.x86_64.rpm"><img src="https://img.shields.io/badge/x64-default?style=flat&logo=fedora&label=RPM"> </a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_arm64.deb"><img src="https://img.shields.io/badge/arm64-default?style=flat&logo=debian&label=DEB"> </a><br>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_arm64.deb"><img src="https://img.shields.io/badge/arm64-default?style=flat&logo=debian&label=DEB"> </a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash.aarch64.rpm"><img src="https://img.shields.io/badge/aarch64-default?style=flat&logo=fedora&label=RPM"> </a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_armhf.deb"><img src="https://img.shields.io/badge/armhf-default?style=flat&logo=debian&label=DEB"> </a><br>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_armhf.deb"><img src="https://img.shields.io/badge/armhf-default?style=flat&logo=debian&label=DEB"> </a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash.armhfp.rpm"><img src="https://img.shields.io/badge/armhfp-default?style=flat&logo=fedora&label=RPM"> </a>
#### Package availability for many distributions
<a href="https://aur.archlinux.org/packages/koala-clash-bin"><img src="https://img.shields.io/aur/version/koala-clash-bin"></a>
### Windows (Win7 is no longer supported)
#### Normal version (recommended)
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_x64-setup.exe"><img src="https://badgen.net/badge/icon/x64?icon=windows&label=exe"></a><br>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_x64-setup.exe"><img src="https://badgen.net/badge/icon/x64?icon=windows&label=exe"></a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_arm64-setup.exe"><img src="https://badgen.net/badge/icon/arm64?icon=windows&label=exe"></a>
#### Portable version is no longer available with many problems
#### Built-in Webview version 2 (large size, only used in enterprise version of the system or can not install webview2)
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_x64_fixed_webview2-setup.exe"><img src="https://badgen.net/badge/icon/x64?icon=windows&label=exe"></a><br>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_x64_fixed_webview2-setup.exe"><img src="https://badgen.net/badge/icon/x64?icon=windows&label=exe"></a>
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Koala.Clash_arm64_fixed_webview2-setup.exe"><img src="https://badgen.net/badge/icon/arm64?icon=windows&label=exe"></a>
Created at ${{ env.BUILDTIME }}.
@@ -205,10 +209,17 @@ jobs:
if: runner.os == 'Windows'
shell: pwsh
run: |
$version = ${{steps.build.outputs.appVersion}}
# Rename .exe files
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
foreach ($file in $files) {
$newName = $file.Name -replace "_${version}_", "_"
$newName = $file.Name -replace "_${{steps.build.outputs.appVersion}}_", "_"
Rename-Item $file.FullName $newName
}
# Rename .exe.sig files
$sigFiles = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
foreach ($file in $sigFiles) {
$newName = $file.Name -replace "_${{steps.build.outputs.appVersion}}_", "_"
Rename-Item $file.FullName $newName
}
@@ -217,11 +228,11 @@ jobs:
shell: bash
run: |
TARGET_DIR="src-tauri/target/${{ matrix.target }}/release/bundle"
if [ ! -d "$TARGET_DIR" ]; then
exit 1
fi
find "$TARGET_DIR" -type f \( -name "*.dmg" -o -name "*.deb" -o -name "*.rpm" \) -print0 | while IFS= read -r -d '' old_path; do
dir_path=$(dirname "$old_path")
old_filename=$(basename "$old_path")
@@ -236,6 +247,51 @@ jobs:
fi
done
- name: Rename macOS Updater Files with Architecture Prefix
if: runner.os == 'macOS'
shell: bash
run: |
MACOS_DIR="src-tauri/target/${{ matrix.target }}/release/bundle/macos"
if [ ! -d "$MACOS_DIR" ]; then
echo "macOS bundle directory not found, skipping"
exit 0
fi
# Determine architecture suffix
if [ "${{ matrix.target }}" == "aarch64-apple-darwin" ]; then
ARCH_SUFFIX="_aarch64"
elif [ "${{ matrix.target }}" == "x86_64-apple-darwin" ]; then
ARCH_SUFFIX="_x64"
else
echo "Unknown target: ${{ matrix.target }}"
exit 1
fi
# Rename .app.tar.gz files
find "$MACOS_DIR" -type f -name "*.app.tar.gz" ! -name "*.app.tar.gz.sig" -print0 | while IFS= read -r -d '' old_path; do
dir_path=$(dirname "$old_path")
old_filename=$(basename "$old_path")
new_filename=$(echo "$old_filename" | sed -E "s/\.app\.tar\.gz$/${ARCH_SUFFIX}.app.tar.gz/")
new_path="${dir_path}/${new_filename}"
if [ "$old_path" != "$new_path" ]; then
echo "Renaming updater: '$old_filename' -> '$new_filename'"
mv "$old_path" "$new_path"
fi
done
# Rename .app.tar.gz.sig files
find "$MACOS_DIR" -type f -name "*.app.tar.gz.sig" -print0 | while IFS= read -r -d '' old_path; do
dir_path=$(dirname "$old_path")
old_filename=$(basename "$old_path")
new_filename=$(echo "$old_filename" | sed -E "s/\.app\.tar\.gz\.sig$/${ARCH_SUFFIX}.app.tar.gz.sig/")
new_path="${dir_path}/${new_filename}"
if [ "$old_path" != "$new_path" ]; then
echo "Renaming signature: '$old_filename' -> '$new_filename'"
mv "$old_path" "$new_path"
fi
done
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
@@ -365,11 +421,11 @@ jobs:
shell: bash
run: |
TARGET_DIR="src-tauri/target/${{ matrix.target }}/release/bundle"
if [ ! -d "$TARGET_DIR" ]; then
exit 1
fi
find "$TARGET_DIR" -type f \( -name "*.dmg" -o -name "*.deb" -o -name "*.rpm" \) -print0 | while IFS= read -r -d '' old_path; do
dir_path=$(dirname "$old_path")
old_filename=$(basename "$old_path")
@@ -578,14 +634,14 @@ jobs:
echo "Using found update logs"
UPDATE_LOGS=$(echo "$UPDATE_LOGS" | sed 's/^## \(v.*\)/\*\1\*/')
fi
cat > release.txt << EOF
Вышло обновление!
$UPDATE_LOGS
[Ссылка на релиз](https://github.com/coolcoala/clash-verge-rev-lite/releases/latest)
EOF
- name: notify to channel
@@ -603,4 +659,3 @@ jobs:
token: ${{ secrets.TELEGRAM_TOKEN }}
message_file: release.txt
format: markdown

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ scripts/_env.sh
.tool-versions
.idea
.old
bun.lock

View File

@@ -9,11 +9,7 @@
A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
</h3>
## Preview
| Dark | Light |
| ----------------------------------- | ------------------------------------ |
| ![Preview](./docs/preview_dark.png) | ![Preview](./docs/preview_light.png) |
![Preview](./docs/preview.png)
## Install

View File

@@ -1,7 +1,38 @@
## v0.2.8
- fixed issue with error 0xc00000142 when shutting down the computer
- dark mode issue fixed
- improved HWID definition
- fixed an issue with opening a window via a shortcut when the application is already running
- fixed uploading updater for macos
- menu removed by right-clicking
- allowed to set an empty password on an external controller
## v0.2.7
- fixed bug in proxy groups menu
- added message about global mode enabled on main screen
- fixed minor bugs
- updated Mihomo core to v1.19.14
## v0.2.6
- fixed deep links
- removed AliDNS, replaced with Cloudflare and Google DNS servers
- improved proxy selector view
- added some animations
- fixed an issue with saving the profile when changing advanced settings
- fixed DNS leak, strict routing now default
- logs translated into English
- table on the connections page corrected
- fixed issue with deleting profiles with long names
- glass effect added to components
- icon background fixed
- fixed tun settings override
- added support for brotli, gzip, zstd
## v0.2.5
- new main page
- fixed issue with opening via shortcut
- fixed issue with opening via shortcut
- fixed logo in sidebar
- fixed issue with changing tray settings
- name changed to koala clash
@@ -27,7 +58,6 @@
- corrected side menu in compressed window
- added check at the main toggle switch, now it cannot be enabled if there are no profiles.
## v0.2.1
- added headers "announce-url", "update-always"

BIN
docs/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

View File

@@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}

View File

@@ -1,6 +1,6 @@
{
"name": "koala-clash",
"version": "0.2.5",
"version": "0.2.8",
"license": "GPL-3.0-only",
"scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
@@ -29,53 +29,55 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^5.1.1",
"@emotion/styled": "^11.14.1",
"@hookform/resolvers": "^5.2.2",
"@juggle/resize-observer": "^3.4.0",
"@mui/icons-material": "^7.1.1",
"@mui/icons-material": "^7.3.2",
"@mui/lab": "7.0.0-beta.13",
"@mui/material": "^7.1.1",
"@mui/x-data-grid": "^8.5.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@mui/material": "^7.3.2",
"@mui/x-data-grid": "^8.11.3",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-global-shortcut": "^2.2.1",
"@tauri-apps/plugin-notification": "^2.2.2",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-deep-link": "~2.4.3",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-global-shortcut": "^2.3.0",
"@tauri-apps/plugin-notification": "^2.3.1",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "2.2.1",
"@tauri-apps/plugin-updater": "2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@tauri-apps/plugin-window-state": "^2.4.0",
"@types/d3-shape": "^3.1.7",
"@types/json-schema": "^7.0.15",
"ahooks": "^3.8.5",
"axios": "^1.9.0",
"chart.js": "^4.4.9",
"ahooks": "^3.9.5",
"axios": "^1.12.2",
"chart.js": "^4.5.0",
"class-variance-authority": "^0.7.1",
"cli-color": "^2.0.4",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-shape": "^3.2.0",
"dayjs": "1.11.13",
"foxact": "^0.2.45",
"glob": "^11.0.2",
"i18next": "^25.2.1",
"js-base64": "^3.7.7",
"foxact": "^0.2.49",
"framer-motion": "^12.23.16",
"glob": "^11.0.3",
"i18next": "^25.5.2",
"js-base64": "^3.7.8",
"js-yaml": "^4.1.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.514.0",
@@ -83,26 +85,26 @@
"monaco-yaml": "^5.4.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"peggy": "^5.0.3",
"peggy": "^5.0.6",
"react": "19.1.0",
"react-chartjs-2": "^5.3.0",
"react-colorful": "^5.6.1",
"react-dom": "19.1.0",
"react-error-boundary": "6.0.0",
"react-hook-form": "^7.57.0",
"react-hook-form": "^7.63.0",
"react-i18next": "15.5.2",
"react-markdown": "10.1.0",
"react-monaco-editor": "0.58.0",
"react-router-dom": "7.6.2",
"react-virtuoso": "^4.12.8",
"react-virtuoso": "^4.14.0",
"sockette": "^2.0.6",
"sonner": "^2.0.5",
"swr": "^2.3.3",
"sonner": "^2.0.7",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"tar": "^7.4.3",
"types-pac": "^1.0.3",
"zod": "^3.25.67",
"zustand": "^5.0.5"
"zod": "^3.25.76",
"zustand": "^5.0.8"
},
"devDependencies": {
"@actions/github": "^6.0.1",
@@ -110,30 +112,30 @@
"@types/js-cookie": "^3.0.6",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.0.0",
"@types/node": "^24.5.2",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.6",
"@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-react": "4.5.1",
"adm-zip": "^0.5.16",
"autoprefixer": "^10.4.21",
"commander": "^14.0.0",
"commander": "^14.0.1",
"cross-env": "^7.0.3",
"https-proxy-agent": "^7.0.6",
"husky": "^9.1.7",
"meta-json-schema": "^1.19.10",
"meta-json-schema": "^1.19.13",
"node-fetch": "^3.3.2",
"postcss": "^8.5.4",
"prettier": "^3.5.3",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"pretty-quick": "^4.2.2",
"sass": "^1.89.1",
"tailwindcss": "^4.1.11",
"terser": "^5.41.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"sass": "^1.93.0",
"tailwindcss": "^4.1.13",
"terser": "^5.44.0",
"tw-animate-css": "^1.3.8",
"typescript": "^5.9.2",
"vite": "^6.3.6",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-svgr": "^4.3.0"
"vite-plugin-svgr": "^4.5.0"
},
"prettier": {
"tabWidth": 2,
@@ -143,4 +145,4 @@
},
"type": "module",
"packageManager": "pnpm@9.13.2"
}
}

2684
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -103,8 +103,8 @@ async function getLatestAlphaVersion() {
/* ======= clash meta stable ======= */
const META_VERSION_URL =
"https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt";
const META_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download`;
"https://github.com/vffuunnyy/mihetero/releases/download/Prerelease-xhttp/version.txt";
const META_URL_PREFIX = `https://github.com/vffuunnyy/mihetero/releases/download`;
let META_VERSION;
const META_MAP = {
@@ -187,7 +187,7 @@ function clashMeta() {
const name = META_MAP[`${platform}-${arch}`];
const isWin = platform === "win32";
const urlExt = isWin ? "zip" : "gz";
const downloadURL = `${META_URL_PREFIX}/${META_VERSION}/${name}-${META_VERSION}.${urlExt}`;
const downloadURL = `${META_URL_PREFIX}/Prerelease-xhttp/${name}-${META_VERSION}.${urlExt}`;
const exeFile = `${name}${isWin ? ".exe" : ""}`;
const zipFile = `${name}-${META_VERSION}.${urlExt}`;

1957
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "koala-clash"
version = "0.2.5"
version = "0.2.8"
description = "koala clash"
authors = ["zzzgydi", "wonfen", "MystiPanda", "coolcoala"]
license = "GPL-3.0-only"
@@ -45,7 +45,7 @@ tokio = { version = "1.45.1", features = [
"sync",
] }
serde = { version = "1.0.219", features = ["derive"] }
reqwest = { version = "0.12.20", features = ["json", "rustls-tls", "cookies"] }
reqwest = { version = "0.12.20", features = ["json", "rustls-tls", "cookies", "brotli", "gzip", "zstd"] }
regex = "1.11.1"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs" }
image = "0.25.6"
@@ -63,7 +63,6 @@ tauri-plugin-dialog = "2.3.0"
tauri-plugin-fs = "2.4.0"
tauri-plugin-process = "2.3.0"
tauri-plugin-clipboard-manager = "2.3.0"
tauri-plugin-deep-link = "2.4.0"
tauri-plugin-devtools = "2.0.0"
tauri-plugin-window-state = "2.3.0"
zip = "4.2.0"
@@ -85,6 +84,7 @@ sha2 = "0.10.9"
hex = "0.4.3"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.0"
tauri-plugin-deep-link = "2"
[target.'cfg(windows)'.dependencies]
runas = "=1.2.0"
@@ -110,7 +110,7 @@ users = "0.11.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.0"
tauri-plugin-global-shortcut = "2.3.0"
tauri-plugin-single-instance = "2"
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
tauri-plugin-updater = "2.9.0"
[features]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -147,7 +147,7 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
Ok(icon_path.to_string_lossy().to_string())
} else {
let _ = std::fs::remove_file(&temp_path);
Err(format!("下载的内容不是有效图片: {url}"))
Err(format!("Downloaded content is not a valid image: {url}"))
}
}
@@ -209,15 +209,17 @@ pub fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult<String> {
/// 通知UI已准备就绪
#[tauri::command]
pub fn notify_ui_ready() -> CmdResult<()> {
log::info!(target: "app", "前端UI已准备就绪");
log::info!(target: "app", "Frontend UI is ready");
crate::utils::resolve::mark_ui_ready();
// Flush any pending messages queued while UI was not ready (e.g. minimized to tray)
crate::core::handle::Handle::global().flush_ui_pending_messages();
Ok(())
}
/// UI加载阶段
#[tauri::command]
pub fn update_ui_stage(stage: String) -> CmdResult<()> {
log::info!(target: "app", "UI加载阶段更新: {stage}");
log::info!(target: "app", "UI loading stage updated: {stage}");
use crate::utils::resolve::UiReadyStage;
@@ -228,8 +230,8 @@ pub fn update_ui_stage(stage: String) -> CmdResult<()> {
"ResourcesLoaded" => UiReadyStage::ResourcesLoaded,
"Ready" => UiReadyStage::Ready,
_ => {
log::warn!(target: "app", "未知的UI加载阶段: {stage}");
return Err(format!("未知的UI加载阶段: {stage}"));
log::warn!(target: "app", "Unknown UI loading stage: {stage}");
return Err(format!("Unknown UI loading stage: {stage}"));
}
};
@@ -240,7 +242,7 @@ pub fn update_ui_stage(stage: String) -> CmdResult<()> {
/// 重置UI就绪状态
#[tauri::command]
pub fn reset_ui_ready_state() -> CmdResult<()> {
log::info!(target: "app", "重置UI就绪状态");
log::info!(target: "app", "Reset UI ready state");
crate::utils::resolve::reset_ui_ready();
Ok(())
}

View File

@@ -7,7 +7,7 @@ use serde_yaml::Mapping;
/// get the system proxy
#[tauri::command]
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
log::debug!(target: "app", "异步获取系统代理配置");
log::debug!(target: "app", "Asynchronously getting system proxy configuration");
let current = AsyncProxyQuery::get_system_proxy().await;
@@ -19,14 +19,14 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
);
map.insert("bypass".into(), current.bypass.into());
log::debug!(target: "app", "返回系统代理配置: enable={}, {}:{}", current.enable, current.host, current.port);
log::debug!(target: "app", "Return system proxy configuration: enable={}, {}:{}", current.enable, current.host, current.port);
Ok(map)
}
/// 获取自动代理配置
#[tauri::command]
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
log::debug!(target: "app", "开始获取自动代理配置(事件驱动)");
log::debug!(target: "app", "Start retrieving auto proxy configuration (event-driven)");
let proxy_manager = EventDrivenProxyManager::global();
@@ -40,7 +40,7 @@ pub async fn get_auto_proxy() -> CmdResult<Mapping> {
map.insert("enable".into(), current.enable.into());
map.insert("url".into(), current.url.clone().into());
log::debug!(target: "app", "返回自动代理配置(缓存): enable={}, url={}", current.enable, current.url);
log::debug!(target: "app", "Return auto proxy configuration (cached): enable={}, url={}", current.enable, current.url);
Ok(map)
}

View File

@@ -6,14 +6,14 @@ use crate::{
utils::{dirs, help, logging::Type},
wrap_err,
};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use percent_encoding::percent_decode_str;
use serde_yaml::Value;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::{Mutex, RwLock};
use std::collections::BTreeMap;
use url::Url;
use serde_yaml::Value;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use percent_encoding::percent_decode_str;
// 全局互斥锁防止并发配置更新
static PROFILE_UPDATE_MUTEX: Mutex<()> = Mutex::const_new(());
@@ -30,7 +30,7 @@ async fn cleanup_processing_state(sequence: u64, reason: &str) {
info,
Type::Cmd,
true,
"{}清理状态,序列号: {}",
"{}Cleanup status, serial number: {}",
reason,
sequence
);
@@ -55,14 +55,14 @@ pub async fn get_profiles() -> CmdResult<IProfiles> {
match latest_result {
Ok(Ok(profiles)) => {
logging!(info, Type::Cmd, false, "快速获取配置列表成功");
logging!(info, Type::Cmd, false, "Quickly fetched profiles list successfully");
return Ok(profiles);
}
Ok(Err(join_err)) => {
logging!(warn, Type::Cmd, true, "快速获取配置任务失败: {}", join_err);
logging!(warn, Type::Cmd, true, "Quick profile list fetch task failed: {}", join_err);
}
Err(_) => {
logging!(warn, Type::Cmd, true, "快速获取配置超时(500ms)");
logging!(warn, Type::Cmd, true, "Quick profile list fetch timeout (500ms)");
}
}
@@ -82,7 +82,7 @@ pub async fn get_profiles() -> CmdResult<IProfiles> {
match data_result {
Ok(Ok(profiles)) => {
logging!(info, Type::Cmd, false, "获取draft配置列表成功");
logging!(info, Type::Cmd, false, "Fetched draft profile list successfully");
return Ok(profiles);
}
Ok(Err(join_err)) => {
@@ -90,12 +90,12 @@ pub async fn get_profiles() -> CmdResult<IProfiles> {
error,
Type::Cmd,
true,
"获取draft配置任务失败: {}",
"Failed to obtain draft configuration task: {}",
join_err
);
}
Err(_) => {
logging!(error, Type::Cmd, true, "获取draft配置超时(2)");
logging!(error, Type::Cmd, true, "Draft profile list fetch timeout (2s)");
}
}
@@ -104,16 +104,16 @@ pub async fn get_profiles() -> CmdResult<IProfiles> {
warn,
Type::Cmd,
true,
"所有获取配置策略都失败,尝试fallback"
"All attempts to obtain configuration policies failed. Trying fallback"
);
match tokio::task::spawn_blocking(IProfiles::new).await {
Ok(profiles) => {
logging!(info, Type::Cmd, true, "使用fallback配置成功");
logging!(info, Type::Cmd, true, "Fallback profiles created successfully");
Ok(profiles)
}
Err(err) => {
logging!(error, Type::Cmd, true, "fallback配置也失败: {}", err);
logging!(error, Type::Cmd, true, "Fallback profiles creation failed: {}", err);
// 返回空配置避免崩溃
Ok(IProfiles {
current: None,
@@ -138,20 +138,43 @@ pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult
let profiles = Config::profiles();
let profiles = profiles.latest();
profiles.items.as_ref()
profiles
.items
.as_ref()
.and_then(|items| items.iter().find(|item| item.url.as_deref() == Some(&url)))
.and_then(|item| item.uid.clone())
};
if let Some(uid) = existing_uid {
logging!(info, Type::Cmd, true, "The profile with URL {} already exists (UID: {}). Running the update...", url, uid);
logging!(
info,
Type::Cmd,
true,
"The profile with URL {} already exists (UID: {}). Running the update...",
url,
uid
);
update_profile(uid, option).await
} else {
logging!(info, Type::Cmd, true, "Profile with URL {} not found. Create a new one...", url);
logging!(
info,
Type::Cmd,
true,
"Profile with URL {} not found. Create a new one...",
url
);
let item = wrap_err!(PrfItem::from_url(&url, None, None, option).await)?;
wrap_err!(Config::profiles().data().append_item(item))
let new_uid = item.uid.clone().unwrap_or_default();
wrap_err!(Config::profiles().data().append_item(item))?;
if !new_uid.is_empty() {
let _ = patch_profiles_config(IProfiles {
current: Some(new_uid),
items: None,
})
.await?;
}
Ok(())
}
}
/// 重新排序配置文件
@@ -164,7 +187,17 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
#[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
let item = wrap_err!(PrfItem::from(item, file_data).await)?;
wrap_err!(Config::profiles().data().append_item(item))
let new_uid = item.uid.clone().unwrap_or_default();
wrap_err!(Config::profiles().data().append_item(item))?;
if !new_uid.is_empty() {
let _ = patch_profiles_config(IProfiles {
current: Some(new_uid),
items: None,
})
.await?;
}
Ok(())
}
/// 更新配置文件
@@ -181,20 +214,29 @@ pub async fn delete_profile(index: String) -> CmdResult {
{
let profiles_config = Config::profiles();
let mut profiles_data = profiles_config.data();
should_update = profiles_data.delete_item(index.clone()).map_err(|e| e.to_string())?;
should_update = profiles_data
.delete_item(index.clone())
.map_err(|e| e.to_string())?;
let was_last_profile = profiles_data.items.as_ref().map_or(true, |items| {
!items.iter().any(|item|
item.itype == Some("remote".to_string()) || item.itype == Some("local".to_string())
)
let was_last_profile = profiles_data.items.as_ref().is_none_or(|items| {
!items
.iter()
.any(|item| matches!(item.itype.as_deref(), Some("remote") | Some("local")))
});
if was_last_profile {
logging!(info, Type::Cmd, true, "The last profile has been deleted. Disabling proxy modes...");
logging!(
info,
Type::Cmd,
true,
"The last profile has been deleted. Disabling proxy modes..."
);
let verge_config = Config::verge();
let mut verge_data = verge_config.data();
if verge_data.enable_tun_mode == Some(true) || verge_data.enable_system_proxy == Some(true) {
if verge_data.enable_tun_mode == Some(true)
|| verge_data.enable_system_proxy == Some(true)
{
verge_data.enable_tun_mode = Some(false);
verge_data.enable_system_proxy = Some(false);
verge_data.save_file().map_err(|e| e.to_string())?;
@@ -226,7 +268,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"开始修改配置文件,请求序列号: {}, 目标profile: {:?}",
"Starting to modify profiles, sequence: {}, target profile: {:?}",
current_sequence,
target_profile
);
@@ -243,7 +285,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"检测到更新的请求 (序列号: {} < {}),放弃当前请求",
"Newer request detected (seq: {} < {}), abandoning current",
current_sequence,
latest_sequence
);
@@ -253,7 +295,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"强制获取锁以处理最新请求: {}",
"Force acquiring lock to process latest request: {}",
current_sequence
);
PROFILE_UPDATE_MUTEX.lock().await
@@ -266,7 +308,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"获取锁后发现更新的请求 (序列号: {} < {}),放弃当前请求",
"After acquiring lock, found newer request (seq: {} < {}), abandoning current",
current_sequence,
latest_sequence
);
@@ -275,12 +317,12 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 保存当前配置,以便在验证失败时恢复
let current_profile = Config::profiles().latest().current.clone();
logging!(info, Type::Cmd, true, "当前配置: {:?}", current_profile);
logging!(info, Type::Cmd, true, "Current profile: {:?}", current_profile);
// 如果要切换配置,先检查目标配置文件是否有语法错误
if let Some(new_profile) = profiles.current.as_ref() {
if current_profile.as_ref() != Some(new_profile) {
logging!(info, Type::Cmd, true, "正在切换到新配置: {}", new_profile);
logging!(info, Type::Cmd, true, "Switching to new profile: {}", new_profile);
// 获取目标配置文件路径
let config_file_result = {
@@ -296,7 +338,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
}
}
Err(e) => {
logging!(error, Type::Cmd, true, "获取目标配置信息失败: {}", e);
logging!(error, Type::Cmd, true, "Failed to get target profile info: {}", e);
None
}
}
@@ -309,7 +351,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
error,
Type::Cmd,
true,
"目标配置文件不存在: {}",
"Target profile does not exist: {}",
file_path.display()
);
handle::Handle::notice_message(
@@ -335,7 +377,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
match yaml_parse_result {
Ok(Ok(_)) => {
logging!(info, Type::Cmd, true, "目标配置文件语法正确");
logging!(info, Type::Cmd, true, "Target profile file syntax is correct");
}
Ok(Err(err)) => {
let error_msg = format!(" {err}");
@@ -343,7 +385,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
error,
Type::Cmd,
true,
"目标配置文件存在YAML语法错误:{}",
"YAML syntax error in target profile file: {}",
error_msg
);
handle::Handle::notice_message(
@@ -353,7 +395,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
return Ok(false);
}
Err(join_err) => {
let error_msg = format!("YAML解析任务失败: {join_err}");
let error_msg = format!("YAML parse task failed: {join_err}");
logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message(
"config_validate::yaml_parse_error",
@@ -364,7 +406,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
}
}
Ok(Err(err)) => {
let error_msg = format!("无法读取目标配置文件: {err}");
let error_msg = format!("Failed to read target profile file: {err}");
logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message(
"config_validate::file_read_error",
@@ -373,7 +415,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
return Ok(false);
}
Err(_) => {
let error_msg = "读取配置文件超时(5)".to_string();
let error_msg = "Reading config file timed out (5s)".to_string();
logging!(error, Type::Cmd, true, "{}", error_msg);
handle::Handle::notice_message(
"config_validate::file_read_timeout",
@@ -393,7 +435,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"在核心操作前发现更新的请求 (序列号: {} < {}),放弃当前请求",
"Found newer request before core operation (seq: {} < {}), abandoning current",
current_sequence,
latest_sequence
);
@@ -406,7 +448,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"设置当前处理profile: {}, 序列号: {}",
"Set current processing profile: {}, serial number: {}",
profile,
current_sequence
);
@@ -417,7 +459,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"正在更新配置草稿,序列号: {}",
"Updating draft profiles, sequence: {}",
current_sequence
);
@@ -432,7 +474,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"在内核交互前发现更新的请求 (序列号: {} < {}),放弃当前请求",
"Detect updated requests before kernel interaction (sequence number: {} < {}) and abandon the current request.",
current_sequence,
latest_sequence
);
@@ -445,7 +487,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"开始内核配置更新,序列号: {}",
"Starting kernel config update, sequence: {}",
current_sequence
);
let update_result = tokio::time::timeout(
@@ -464,7 +506,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"内核操作后发现更新的请求 (序列号: {} < {}),忽略当前结果",
"After kernel operation, an updated request was found (sequence number: {} < {}), ignore the current result.",
current_sequence,
latest_sequence
);
@@ -476,7 +518,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"配置更新成功,序列号: {}",
"Configuration update successful, serial number: {}",
current_sequence
);
Config::profiles().apply();
@@ -485,22 +527,22 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
// 强制刷新代理缓存确保profile切换后立即获取最新节点数据
crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = super::proxy::force_refresh_proxies().await {
log::warn!(target: "app", "强制刷新代理缓存失败: {e}");
log::warn!(target: "app", "Force refresh proxy cache failed: {e}");
}
});
crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = Tray::global().update_tooltip() {
log::warn!(target: "app", "异步更新托盘提示失败: {e}");
log::warn!(target: "app", "Async tray tooltip update failed: {e}");
}
if let Err(e) = Tray::global().update_menu() {
log::warn!(target: "app", "异步更新托盘菜单失败: {e}");
log::warn!(target: "app", "Async tray menu update failed: {e}");
}
// 保存配置文件
if let Err(e) = Config::profiles().data().save_file() {
log::warn!(target: "app", "异步保存配置文件失败: {e}");
log::warn!(target: "app", "Async save profiles file failed: {e}");
}
});
@@ -510,19 +552,19 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"向前端发送配置变更事件: {}, 序列号: {}",
"Sending profile change event to frontend: {}, sequence: {}",
current,
current_sequence
);
handle::Handle::notify_profile_changed(current.clone());
}
cleanup_processing_state(current_sequence, "配置切换完成").await;
cleanup_processing_state(current_sequence, "Profile switch completed").await;
Ok(true)
}
Ok(Ok((false, error_msg))) => {
logging!(warn, Type::Cmd, true, "配置验证失败: {}", error_msg);
logging!(warn, Type::Cmd, true, "Profile validation failed: {}", error_msg);
Config::profiles().discard();
// 如果验证失败,恢复到之前的配置
if let Some(prev_profile) = current_profile {
@@ -530,7 +572,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"尝试恢复到之前的配置: {}",
"Attempting to restore previous profile: {}",
prev_profile
);
let restore_profiles = IProfiles {
@@ -543,17 +585,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
crate::process::AsyncHandler::spawn(|| async move {
if let Err(e) = Config::profiles().data().save_file() {
log::warn!(target: "app", "异步保存恢复配置文件失败: {e}");
log::warn!(target: "app", "Failed to save and restore configuration file asynchronously: {e}");
}
});
logging!(info, Type::Cmd, true, "成功恢复到之前的配置");
logging!(info, Type::Cmd, true, "Successfully restored previous profile");
}
// 发送验证错误通知
handle::Handle::notice_message("config_validate::error", &error_msg);
cleanup_processing_state(current_sequence, "配置验证失败").await;
cleanup_processing_state(current_sequence, "Profile validation failed").await;
Ok(false)
}
@@ -562,25 +604,25 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
warn,
Type::Cmd,
true,
"更新过程发生错误: {}, 序列号: {}",
"Error occurred during update: {}, sequence: {}",
e,
current_sequence
);
Config::profiles().discard();
handle::Handle::notice_message("config_validate::boot_error", e.to_string());
cleanup_processing_state(current_sequence, "更新过程错误").await;
cleanup_processing_state(current_sequence, "Update process error").await;
Ok(false)
}
Err(_) => {
// 超时处理
let timeout_msg = "配置更新超时(30秒),可能是配置验证或核心通信阻塞";
let timeout_msg = "Profile update timed out (30s), possibly due to validation or kernel communication";
logging!(
error,
Type::Cmd,
true,
"{}, 序列号: {}",
"{}, sequence: {}",
timeout_msg,
current_sequence
);
@@ -591,7 +633,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
info,
Type::Cmd,
true,
"超时后尝试恢复到之前的配置: {}, 序列号: {}",
"After timeout, attempting to restore previous profile: {}, sequence: {}",
prev_profile,
current_sequence
);
@@ -605,7 +647,7 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<bool> {
handle::Handle::notice_message("config_validate::timeout", timeout_msg);
cleanup_processing_state(current_sequence, "配置更新超时").await;
cleanup_processing_state(current_sequence, "Profile update timeout").await;
Ok(false)
}
@@ -618,7 +660,7 @@ pub async fn patch_profiles_config_by_profile_index(
_app_handle: tauri::AppHandle,
profile_index: String,
) -> CmdResult<bool> {
logging!(info, Type::Cmd, true, "切换配置到: {}", profile_index);
logging!(info, Type::Cmd, true, "Switching profile to: {}", profile_index);
let profiles = IProfiles {
current: Some(profile_index),
@@ -647,9 +689,9 @@ pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
if update_interval_changed {
let index_clone = index.clone();
crate::process::AsyncHandler::spawn(move || async move {
logging!(info, Type::Timer, "定时器更新间隔已变更,正在刷新定时器...");
logging!(info, Type::Timer, "Timer update interval changed; refreshing timers...");
if let Err(e) = crate::core::Timer::global().refresh() {
logging!(error, Type::Timer, "刷新定时器失败: {}", e);
logging!(error, Type::Timer, "Failed to refresh timers: {}", e);
} else {
// 刷新成功后发送自定义事件,不触发配置重载
crate::core::handle::Handle::notify_timer_updated(index_clone);
@@ -696,23 +738,30 @@ pub fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
Ok(next_time)
}
#[tauri::command]
pub async fn update_profiles_on_startup() -> CmdResult {
logging!(info, Type::Cmd, true, "Checking profiles for updates at startup...");
logging!(
info,
Type::Cmd,
true,
"Checking profiles for updates at startup..."
);
let profiles_to_update = {
let profiles = Config::profiles();
let profiles = profiles.latest();
profiles.items.as_ref()
.map_or_else(
Vec::new,
|items| items.iter()
.filter(|item| item.option.as_ref().is_some_and(|opt| opt.update_always == Some(true)))
.filter_map(|item| item.uid.clone())
.collect()
)
profiles.items.as_ref().map_or_else(Vec::new, |items| {
items
.iter()
.filter(|item| {
item.option
.as_ref()
.is_some_and(|opt| opt.update_always == Some(true))
})
.filter_map(|item| item.uid.clone())
.collect()
})
};
if profiles_to_update.is_empty() {
@@ -720,7 +769,13 @@ pub async fn update_profiles_on_startup() -> CmdResult {
return Ok(());
}
logging!(info, Type::Cmd, true, "Found profiles to update: {:?}", profiles_to_update);
logging!(
info,
Type::Cmd,
true,
"Found profiles to update: {:?}",
profiles_to_update
);
let mut update_futures = Vec::new();
for uid in profiles_to_update {
@@ -729,13 +784,25 @@ pub async fn update_profiles_on_startup() -> CmdResult {
let results = futures::future::join_all(update_futures).await;
if results.iter().any(|res| res.is_ok()) {
logging!(info, Type::Cmd, true, "The startup update is complete, restart the kernel...");
CoreManager::global().update_config().await.map_err(|e| e.to_string())?;
logging!(
info,
Type::Cmd,
true,
"The startup update is complete, restart the kernel..."
);
CoreManager::global()
.update_config()
.await
.map_err(|e| e.to_string())?;
handle::Handle::refresh_clash();
} else {
logging!(warn, Type::Cmd, true, "All updates completed with errors on startup.");
logging!(
warn,
Type::Cmd,
true,
"All updates completed with errors on startup."
);
}
Ok(())
@@ -743,7 +810,6 @@ pub async fn update_profiles_on_startup() -> CmdResult {
#[tauri::command]
pub async fn create_profile_from_share_link(link: String, template_name: String) -> CmdResult {
const DEFAULT_TEMPLATE: &str = r#"
mixed-port: 2080
allow-lan: true
@@ -1107,14 +1173,18 @@ pub async fn create_profile_from_share_link(link: String, template_name: String)
let parsed_url = Url::parse(&link).map_err(|e| e.to_string())?;
let scheme = parsed_url.scheme();
let proxy_name = parsed_url.fragment()
let proxy_name = parsed_url
.fragment()
.map(|f| percent_decode_str(f).decode_utf8_lossy().to_string())
.unwrap_or_else(|| "Proxy from Link".to_string());
let mut proxy_map: BTreeMap<String, Value> = BTreeMap::new();
proxy_map.insert("name".into(), proxy_name.clone().into());
proxy_map.insert("type".into(), scheme.into());
proxy_map.insert("server".into(), parsed_url.host_str().unwrap_or_default().into());
proxy_map.insert(
"server".into(),
parsed_url.host_str().unwrap_or_default().into(),
);
proxy_map.insert("port".into(), parsed_url.port().unwrap_or(443).into());
proxy_map.insert("udp".into(), true.into());
@@ -1130,16 +1200,29 @@ pub async fn create_profile_from_share_link(link: String, template_name: String)
"security" if value == "tls" => {
proxy_map.insert("tls".into(), true.into());
}
"flow" => { proxy_map.insert("flow".into(), value.to_string().into()); }
"sni" => { proxy_map.insert("servername".into(), value.to_string().into()); }
"fp" => { proxy_map.insert("client-fingerprint".into(), value.to_string().into()); }
"pbk" => { reality_opts.insert("public-key".into(), value.to_string().into()); }
"sid" => { reality_opts.insert("short-id".into(), value.to_string().into()); }
"flow" => {
proxy_map.insert("flow".into(), value.to_string().into());
}
"sni" => {
proxy_map.insert("servername".into(), value.to_string().into());
}
"fp" => {
proxy_map.insert("client-fingerprint".into(), value.to_string().into());
}
"pbk" => {
reality_opts.insert("public-key".into(), value.to_string().into());
}
"sid" => {
reality_opts.insert("short-id".into(), value.to_string().into());
}
_ => {}
}
}
if !reality_opts.is_empty() {
proxy_map.insert("reality-opts".into(), serde_yaml::to_value(reality_opts).map_err(|e| e.to_string())?);
proxy_map.insert(
"reality-opts".into(),
serde_yaml::to_value(reality_opts).map_err(|e| e.to_string())?,
);
}
}
"ss" => {
@@ -1155,19 +1238,32 @@ pub async fn create_profile_from_share_link(link: String, template_name: String)
"vmess" => {
if let Ok(decoded_bytes) = STANDARD.decode(parsed_url.host_str().unwrap_or_default()) {
if let Ok(json_str) = String::from_utf8(decoded_bytes) {
if let Ok(vmess_params) = serde_json::from_str::<BTreeMap<String, Value>>(&json_str) {
if let Some(add) = vmess_params.get("add") { proxy_map.insert("server".into(), add.clone()); }
if let Some(port) = vmess_params.get("port") { proxy_map.insert("port".into(), port.clone()); }
if let Some(id) = vmess_params.get("id") { proxy_map.insert("uuid".into(), id.clone()); }
if let Some(aid) = vmess_params.get("aid") { proxy_map.insert("alterId".into(), aid.clone()); }
if let Some(net) = vmess_params.get("net") { proxy_map.insert("network".into(), net.clone()); }
if let Some(ps) = vmess_params.get("ps") { proxy_map.insert("name".into(), ps.clone()); }
if let Ok(vmess_params) =
serde_json::from_str::<BTreeMap<String, Value>>(&json_str)
{
if let Some(add) = vmess_params.get("add") {
proxy_map.insert("server".into(), add.clone());
}
if let Some(port) = vmess_params.get("port") {
proxy_map.insert("port".into(), port.clone());
}
if let Some(id) = vmess_params.get("id") {
proxy_map.insert("uuid".into(), id.clone());
}
if let Some(aid) = vmess_params.get("aid") {
proxy_map.insert("alterId".into(), aid.clone());
}
if let Some(net) = vmess_params.get("net") {
proxy_map.insert("network".into(), net.clone());
}
if let Some(ps) = vmess_params.get("ps") {
proxy_map.insert("name".into(), ps.clone());
}
}
}
}
}
_ => {
}
_ => {}
}
let mut config: Value = serde_yaml::from_str(template_yaml).map_err(|e| e.to_string())?;
@@ -1177,10 +1273,15 @@ pub async fn create_profile_from_share_link(link: String, template_name: String)
proxies.push(serde_yaml::to_value(proxy_map).map_err(|e| e.to_string())?);
}
if let Some(groups) = config.get_mut("proxy-groups").and_then(|v| v.as_sequence_mut()) {
if let Some(groups) = config
.get_mut("proxy-groups")
.and_then(|v| v.as_sequence_mut())
{
for group in groups.iter_mut() {
if let Some(mapping) = group.as_mapping_mut() {
if let Some(proxies_list) = mapping.get_mut("proxies").and_then(|p| p.as_sequence_mut()) {
if let Some(proxies_list) =
mapping.get_mut("proxies").and_then(|p| p.as_sequence_mut())
{
let new_proxies_list: Vec<Value> = proxies_list
.iter()
.map(|p| {
@@ -1199,8 +1300,13 @@ pub async fn create_profile_from_share_link(link: String, template_name: String)
let new_yaml_content = serde_yaml::to_string(&config).map_err(|e| e.to_string())?;
let item = PrfItem::from_local(proxy_name, "Created from share link".into(), Some(new_yaml_content), None)
.map_err(|e| e.to_string())?;
let item = PrfItem::from_local(
proxy_name,
"Created from share link".into(),
Some(new_yaml_content),
None,
)
.map_err(|e| e.to_string())?;
wrap_err!(Config::profiles().data().append_item(item))
}
}

View File

@@ -33,7 +33,7 @@ pub async fn get_proxies() -> CmdResult<serde_json::Value> {
state.proxies = Box::new(proxies);
state.need_refresh = false;
}
log::debug!(target: "app", "proxies刷新成功");
log::debug!(target: "app", "Proxies refreshed successfully");
}
let proxies = {
@@ -50,7 +50,7 @@ pub async fn force_refresh_proxies() -> CmdResult<serde_json::Value> {
let app_handle = handle::Handle::global().app_handle().unwrap();
let cmd_proxy_state = app_handle.state::<Mutex<CmdProxyState>>();
log::debug!(target: "app", "强制刷新代理缓存");
log::debug!(target: "app", "Force refresh proxy cache");
let proxies = manager.get_refresh_proxies().await?;
@@ -61,7 +61,7 @@ pub async fn force_refresh_proxies() -> CmdResult<serde_json::Value> {
state.last_refresh_time = Instant::now();
}
log::debug!(target: "app", "强制刷新代理缓存完成");
log::debug!(target: "app", "Force refresh proxy cache completed");
Ok(proxies)
}
@@ -88,7 +88,7 @@ pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
state.providers_proxies = Box::new(providers);
state.need_refresh = false;
}
log::debug!(target: "app", "providers_proxies刷新成功");
log::debug!(target: "app", "providers_proxies refreshed successfully");
}
let providers_proxies = {

View File

@@ -84,7 +84,7 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
wrap_err!(fs::write(&file_path, original_content))?;
// 发送合并文件专用错误通知
let result = (false, error_msg.clone());
crate::cmd::validate::handle_yaml_validation_notice(&result, "合并配置文件");
crate::cmd::validate::handle_yaml_validation_notice(&result, "Merge config file");
return Ok(());
}
Err(e) => {
@@ -133,17 +133,17 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
|| (!file_path_str.ends_with(".js") && !is_script_error)
{
// 普通YAML错误使用YAML通知处理
log::info!(target: "app", "[cmd配置save] YAML配置文件验证失败,发送通知");
log::info!(target: "app", "[cmd config save] YAML config file validation failed, sending notification");
let result = (false, error_msg.clone());
crate::cmd::validate::handle_yaml_validation_notice(&result, "YAML配置文件");
crate::cmd::validate::handle_yaml_validation_notice(&result, "YAML config file");
} else if is_script_error {
// 脚本错误使用专门的通知处理
log::info!(target: "app", "[cmd配置save] 脚本文件验证失败,发送通知");
log::info!(target: "app", "[cmd config save] Script file validation failed, sending notification");
let result = (false, error_msg.clone());
crate::cmd::validate::handle_script_validation_notice(&result, "脚本文件");
} else {
// 普通配置错误使用一般通知
log::info!(target: "app", "[cmd配置save] 其他类型验证失败,发送一般通知");
log::info!(target: "app", "[cmd config save] Other validation failure type, sending general notification");
handle::Handle::notice_message("config_validate::error", &error_msg);
}
@@ -154,7 +154,7 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
error,
Type::Config,
true,
"[cmd配置save] 验证过程发生错误: {}",
"[cmd config save] Error occurred during validation: {}",
e
);
// 恢复原始配置文件

View File

@@ -32,7 +32,7 @@ pub fn handle_script_validation_notice(result: &(bool, String), file_type: &str)
warn,
Type::Config,
true,
"{} 验证失败: {}",
"{} validation failed: {}",
file_type,
error_msg
);
@@ -43,14 +43,14 @@ pub fn handle_script_validation_notice(result: &(bool, String), file_type: &str)
/// 验证指定脚本文件
#[tauri::command]
pub async fn validate_script_file(file_path: String) -> CmdResult<bool> {
logging!(info, Type::Config, true, "验证脚本文件: {}", file_path);
logging!(info, Type::Config, true, "Validating script file: {}", file_path);
match CoreManager::global()
.validate_config_file(&file_path, None)
.await
{
Ok(result) => {
handle_script_validation_notice(&result, "脚本文件");
handle_script_validation_notice(&result, "Script file");
Ok(result.0) // 返回验证结果布尔值
}
Err(e) => {
@@ -129,7 +129,7 @@ pub fn handle_yaml_validation_notice(result: &(bool, String), file_type: &str) {
info,
Type::Config,
true,
"[通知] 发送通知: status={}, msg={}",
"[Notice] Sending notice: status={}, msg={}",
status,
error_msg
);

View File

@@ -20,12 +20,7 @@ impl IClashTemp {
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();
}
}
// Allow empty secret - user may want to disable authentication
Self(Self::guard(map))
}
Err(err) => {
@@ -42,7 +37,7 @@ impl IClashTemp {
tun.insert("enable".into(), false.into());
tun.insert("stack".into(), "gvisor".into());
tun.insert("auto-route".into(), true.into());
tun.insert("strict-route".into(), false.into());
tun.insert("strict-route".into(), true.into());
tun.insert("auto-detect-interface".into(), true.into());
tun.insert("dns-hijack".into(), vec!["any:53"].into());
#[cfg(not(target_os = "windows"))]
@@ -87,7 +82,13 @@ impl IClashTemp {
let mixed_port = Self::guard_mixed_port(&config);
let socks_port = Self::guard_socks_port(&config);
let port = Self::guard_port(&config);
let ctrl = Self::guard_server_ctrl(&config);
// Only set external-controller if it doesn't exist or is invalid
// Don't overwrite valid user-configured values
if !config.contains_key("external-controller") {
config.insert("external-controller".into(), "127.0.0.1:9097".into());
}
#[cfg(not(target_os = "windows"))]
config.insert("redir-port".into(), redir_port.into());
#[cfg(target_os = "linux")]
@@ -95,7 +96,6 @@ impl IClashTemp {
config.insert("mixed-port".into(), mixed_port.into());
config.insert("socks-port".into(), socks_port.into());
config.insert("port".into(), port.into());
config.insert("external-controller".into(), ctrl.into());
// 强制覆盖 external-controller-cors 字段,允许本地和 tauri 前端
let mut cors_map = Mapping::new();

View File

@@ -69,9 +69,9 @@ impl Config {
}
// 生成运行时配置
if let Err(err) = Self::generate().await {
logging!(error, Type::Config, true, "生成运行时配置失败: {}", err);
logging!(error, Type::Config, true, "Failed to generate runtime config: {}", err);
} else {
logging!(info, Type::Config, true, "生成运行时配置成功");
logging!(info, Type::Config, true, "Runtime config generated successfully");
}
// 生成运行时配置文件并验证
@@ -79,7 +79,7 @@ impl Config {
let validation_result = if config_result.is_ok() {
// 验证配置文件
logging!(info, Type::Config, true, "开始验证配置");
logging!(info, Type::Config, true, "Starting config validation");
match CoreManager::global().validate_config().await {
Ok((is_valid, error_msg)) => {
@@ -88,7 +88,7 @@ impl Config {
warn,
Type::Config,
true,
"[首次启动] 配置验证失败,使用默认最小配置启动: {}",
"[First launch] Config validation failed, starting with minimal default config: {}",
error_msg
);
CoreManager::global()
@@ -96,12 +96,12 @@ impl Config {
.await?;
Some(("config_validate::boot_error", error_msg))
} else {
logging!(info, Type::Config, true, "配置验证成功");
logging!(info, Type::Config, true, "Config validation succeeded");
Some(("config_validate::success", String::new()))
}
}
Err(err) => {
logging!(warn, Type::Config, true, "验证进程执行失败: {}", err);
logging!(warn, Type::Config, true, "Validation process execution failed: {}", err);
CoreManager::global()
.use_default_config("config_validate::process_terminated", "")
.await?;
@@ -109,7 +109,7 @@ impl Config {
}
}
} else {
logging!(warn, Type::Config, true, "生成配置文件失败,使用默认配置");
logging!(warn, Type::Config, true, "Failed to generate config file; using default config");
CoreManager::global()
.use_default_config("config_validate::error", "")
.await?;

View File

@@ -19,11 +19,11 @@ macro_rules! draft_define {
impl Draft<Box<$id>> {
#[allow(unused)]
pub fn data(&self) -> MappedMutexGuard<Box<$id>> {
pub fn data(&self) -> MappedMutexGuard<'_, Box<$id>> {
MutexGuard::map(self.inner.lock(), |guard| &mut guard.0)
}
pub fn latest(&self) -> MappedMutexGuard<Box<$id>> {
pub fn latest(&self) -> MappedMutexGuard<'_, Box<$id>> {
MutexGuard::map(self.inner.lock(), |inner| {
if inner.1.is_none() {
&mut inner.0
@@ -33,7 +33,7 @@ macro_rules! draft_define {
})
}
pub fn draft(&self) -> MappedMutexGuard<Box<$id>> {
pub fn draft(&self) -> MappedMutexGuard<'_, Box<$id>> {
MutexGuard::map(self.inner.lock(), |inner| {
if inner.1.is_none() {
inner.1 = Some(inner.0.clone());

View File

@@ -326,7 +326,7 @@ impl PrfItem {
if let Ok(mut parsed_url) = Url::parse(url) {
if parsed_url.set_host(Some(new_domain)).is_ok() {
final_url = parsed_url.to_string();
log::info!(target: "app", "URL host updated to -> {}", final_url);
log::info!(target: "app", "URL host updated to -> {final_url}");
}
}
}
@@ -504,13 +504,23 @@ impl PrfItem {
selected: None,
extra,
option: Some(PrfOption {
user_agent: user_agent.clone(),
with_proxy: if with_proxy { Some(true) } else { None },
self_proxy: if self_proxy { Some(true) } else { None },
update_interval,
update_always,
timeout_seconds: Some(timeout),
danger_accept_invalid_certs: if accept_invalid_certs {
Some(true)
} else {
None
},
merge,
script,
rules,
proxies,
groups,
use_hwid: Some(use_hwid),
..PrfOption::default()
}),
home,

View File

@@ -136,10 +136,9 @@ impl IProfiles {
.with_context(|| format!("failed to write to file \"{file}\""))?;
}
if self.current.is_none()
&& (item.itype == Some("remote".to_string()) || item.itype == Some("local".to_string()))
{
self.current = uid;
if item.itype == Some("remote".to_string()) || item.itype == Some("local".to_string()) {
// Always switch current to the newly created remote/local profile
self.current = uid.clone();
}
if self.items.is_none() {
@@ -536,7 +535,7 @@ impl IProfiles {
if Self::is_profile_file(file_name) {
// 检查是否为全局扩展文件
if protected_files.contains(file_name) {
log::debug!(target: "app", "保护全局扩展配置文件: {file_name}");
log::debug!(target: "app", "Protect global extension config file: {file_name}");
continue;
}
@@ -545,11 +544,11 @@ impl IProfiles {
match std::fs::remove_file(&path) {
Ok(_) => {
deleted_files.push(file_name.to_string());
log::info!(target: "app", "已清理冗余文件: {file_name}");
log::info!(target: "app", "Cleaned up redundant file: {file_name}");
}
Err(e) => {
failed_deletions.push(format!("{file_name}: {e}"));
log::warn!(target: "app", "清理文件失败: {file_name} - {e}");
log::warn!(target: "app", "Failed to clean file: {file_name} - {e}");
}
}
}
@@ -679,14 +678,14 @@ impl IProfiles {
if !result.deleted_files.is_empty() {
log::info!(
target: "app",
"自动清理完成,删除了 {} 个冗余文件",
"Auto cleanup completed, deleted {} redundant files",
result.deleted_files.len()
);
}
Ok(())
}
Err(e) => {
log::warn!(target: "app", "自动清理失败: {e}");
log::warn!(target: "app", "Auto cleanup failed: {e}");
Ok(())
}
}

View File

@@ -257,7 +257,7 @@ impl IVerge {
warn,
Type::Config,
true,
"启动时发现无效的clash_core配置: '{}', 将自动修正为 'koala-mihomo'",
"Invalid clash_core config detected at startup: '{}', auto-fixing to 'koala-mihomo'",
core
);
config.clash_core = Some("koala-mihomo".to_string());
@@ -268,7 +268,7 @@ impl IVerge {
info,
Type::Config,
true,
"启动时发现未配置clash_core, 将设置为默认值 'koala-mihomo'"
"clash_core not configured at startup; setting default to 'koala-mihomo'"
);
config.clash_core = Some("koala-mihomo".to_string());
needs_fix = true;
@@ -276,13 +276,13 @@ impl IVerge {
// 修正后保存配置
if needs_fix {
logging!(info, Type::Config, true, "正在保存修正后的配置文件...");
logging!(info, Type::Config, true, "Saving fixed configuration file...");
help::save_yaml(&config_path, &config, Some("# Koala Clash Config"))?;
logging!(
info,
Type::Config,
true,
"配置文件修正完成,需要重新加载配置"
"Configuration file fixed; reloading config required"
);
Self::reload_config_after_fix(config)?;
@@ -291,7 +291,7 @@ impl IVerge {
info,
Type::Config,
true,
"clash_core配置验证通过: {:?}",
"clash_core config validation passed: {:?}",
config.clash_core
);
}
@@ -340,10 +340,12 @@ impl IVerge {
}
pub fn new() -> Self {
dirs::verge_path().and_then(|path| help::read_yaml::<IVerge>(&path)).unwrap_or_else(|err| {
log::error!(target: "app", "{err}");
Self::template()
})
dirs::verge_path()
.and_then(|path| help::read_yaml::<IVerge>(&path))
.unwrap_or_else(|err| {
log::error!(target: "app", "{err}");
Self::template()
})
}
pub fn template() -> Self {

View File

@@ -39,15 +39,15 @@ impl AsyncProxyQuery {
pub async fn get_auto_proxy() -> AsyncAutoproxy {
match timeout(Duration::from_secs(3), Self::get_auto_proxy_impl()).await {
Ok(Ok(proxy)) => {
log::debug!(target: "app", "异步获取自动代理成功: enable={}, url={}", proxy.enable, proxy.url);
log::debug!(target: "app", "Async auto proxy fetch succeeded: enable={}, url={}", proxy.enable, proxy.url);
proxy
}
Ok(Err(e)) => {
log::warn!(target: "app", "异步获取自动代理失败: {e}");
log::warn!(target: "app", "Async auto proxy fetch failed: {e}");
AsyncAutoproxy::default()
}
Err(_) => {
log::warn!(target: "app", "异步获取自动代理超时");
log::warn!(target: "app", "Async auto proxy fetch timed out");
AsyncAutoproxy::default()
}
}
@@ -57,15 +57,15 @@ impl AsyncProxyQuery {
pub async fn get_system_proxy() -> AsyncSysproxy {
match timeout(Duration::from_secs(3), Self::get_system_proxy_impl()).await {
Ok(Ok(proxy)) => {
log::debug!(target: "app", "异步获取系统代理成功: enable={}, {}:{}", proxy.enable, proxy.host, proxy.port);
log::debug!(target: "app", "Async system proxy fetch succeeded: enable={}, {}:{}", proxy.enable, proxy.host, proxy.port);
proxy
}
Ok(Err(e)) => {
log::warn!(target: "app", "异步获取系统代理失败: {e}");
log::warn!(target: "app", "Async system proxy fetch failed: {e}");
AsyncSysproxy::default()
}
Err(_) => {
log::warn!(target: "app", "异步获取系统代理超时");
log::warn!(target: "app", "Async system proxy fetch timed out");
AsyncSysproxy::default()
}
}
@@ -97,7 +97,7 @@ impl AsyncProxyQuery {
RegOpenKeyExW(HKEY_CURRENT_USER, key_path.as_ptr(), 0, KEY_READ, &mut hkey);
if result != 0 {
log::debug!(target: "app", "无法打开注册表项");
log::debug!(target: "app", "Unable to open registry key");
return Ok(AsyncAutoproxy::default());
}
@@ -123,7 +123,7 @@ impl AsyncProxyQuery {
.position(|&x| x == 0)
.unwrap_or(url_buffer.len());
pac_url = String::from_utf16_lossy(&url_buffer[..end_pos]);
log::debug!(target: "app", "从注册表读取到PAC URL: {}", pac_url);
log::debug!(target: "app", "Read PAC URL from registry: {}", pac_url);
}
// 2. 检查自动检测设置是否启用
@@ -148,7 +148,7 @@ impl AsyncProxyQuery {
|| (detect_query_result == 0 && detect_value_type == REG_DWORD && auto_detect != 0);
if pac_enabled {
log::debug!(target: "app", "PAC配置启用: URL={}, AutoDetect={}", pac_url, auto_detect);
log::debug!(target: "app", "PAC configuration enabled: URL={}, AutoDetect={}", pac_url, auto_detect);
if pac_url.is_empty() && auto_detect != 0 {
pac_url = "auto-detect".to_string();
@@ -159,7 +159,7 @@ impl AsyncProxyQuery {
url: pac_url,
})
} else {
log::debug!(target: "app", "PAC配置未启用");
log::debug!(target: "app", "PAC configuration not enabled");
Ok(AsyncAutoproxy::default())
}
}
@@ -194,7 +194,7 @@ impl AsyncProxyQuery {
}
}
log::debug!(target: "app", "解析结果: pac_enabled={pac_enabled}, pac_url={pac_url}");
log::debug!(target: "app", "Parse result: pac_enabled={pac_enabled}, pac_url={pac_url}");
Ok(AsyncAutoproxy {
enable: pac_enabled && !pac_url.is_empty(),
@@ -361,7 +361,7 @@ impl AsyncProxyQuery {
(proxy_server, 8080)
};
log::debug!(target: "app", "从注册表读取到代理设置: {}:{}, bypass: {}", host, port, bypass_list);
log::debug!(target: "app", "Read proxy settings from registry: {}:{}, bypass: {}", host, port, bypass_list);
Ok(AsyncSysproxy {
enable: true,
@@ -518,7 +518,7 @@ impl AsyncProxyQuery {
};
if host.is_empty() {
return Err(anyhow!("无效的代理URL"));
return Err(anyhow!("Invalid proxy URL"));
}
Ok(AsyncSysproxy {

View File

@@ -112,7 +112,7 @@ impl WebDavClient {
.redirect(reqwest::redirect::Policy::custom(|attempt| {
// 允许所有请求类型的重定向包括PUT
if attempt.previous().len() >= 5 {
attempt.error("重定向次数过多")
attempt.error("Too many redirects")
} else {
attempt.follow()
}

View File

@@ -72,7 +72,7 @@ impl CoreManager {
warn,
Type::Config,
true,
"无法读取文件以检测类型: {}, 错误: {}",
"Failed to read file to detect type: {}, error: {}",
path,
err
);
@@ -130,7 +130,7 @@ impl CoreManager {
debug,
Type::Config,
true,
"无法确定文件类型默认当作YAML处理: {}",
"Unable to determine file type, defaulting to YAML handling: {}",
path
);
Ok(false)
@@ -153,7 +153,12 @@ impl CoreManager {
}
/// 验证运行时配置
pub async fn validate_config(&self) -> Result<(bool, String)> {
logging!(info, Type::Config, true, "生成临时配置文件用于验证");
logging!(
info,
Type::Config,
true,
"Generate temporary config file for validation"
);
let config_path = Config::generate_file(ConfigType::Check)?;
let config_path = dirs::path_to_str(&config_path)?;
self.validate_config_internal(config_path).await
@@ -166,7 +171,12 @@ impl CoreManager {
) -> Result<(bool, String)> {
// 检查程序是否正在退出,如果是则跳过验证
if handle::Handle::global().is_exiting() {
logging!(info, Type::Core, true, "应用正在退出,跳过验证");
logging!(
info,
Type::Core,
true,
"App is exiting, skipping validation"
);
return Ok((true, String::new()));
}
@@ -183,7 +193,7 @@ impl CoreManager {
info,
Type::Config,
true,
"检测到Merge文件仅进行语法检查: {}",
"Detected merge file, performing syntax check only: {}",
config_path
);
return self.validate_file_syntax(config_path).await;
@@ -201,7 +211,7 @@ impl CoreManager {
warn,
Type::Config,
true,
"无法确定文件类型: {}, 错误: {}",
"Unable to determine file type: {}, error: {}",
config_path,
err
);
@@ -215,7 +225,7 @@ impl CoreManager {
info,
Type::Config,
true,
"检测到脚本文件,使用JavaScript验证: {}",
"Detected script file, validating with JavaScript: {}",
config_path
);
return self.validate_script_file(config_path).await;
@@ -226,7 +236,7 @@ impl CoreManager {
info,
Type::Config,
true,
"使用Clash内核验证配置文件: {}",
"Validating config file with Clash core: {}",
config_path
);
self.validate_config_internal(config_path).await
@@ -235,7 +245,12 @@ impl CoreManager {
async fn validate_config_internal(&self, config_path: &str) -> Result<(bool, String)> {
// 检查程序是否正在退出,如果是则跳过验证
if handle::Handle::global().is_exiting() {
logging!(info, Type::Core, true, "应用正在退出,跳过验证");
logging!(
info,
Type::Core,
true,
"App is exiting, skipping validation"
);
return Ok((true, String::new()));
}
@@ -243,17 +258,23 @@ impl CoreManager {
info,
Type::Config,
true,
"开始验证配置文件: {}",
"Starting validation for config file: {}",
config_path
);
let clash_core = Config::verge().latest().get_valid_clash_core();
logging!(info, Type::Config, true, "使用内核: {}", clash_core);
logging!(info, Type::Config, true, "Using core: {}", clash_core);
let app_handle = handle::Handle::global().app_handle().unwrap();
let app_dir = dirs::app_home_dir()?;
let app_dir_str = dirs::path_to_str(&app_dir)?;
logging!(info, Type::Config, true, "验证目录: {}", app_dir_str);
logging!(
info,
Type::Config,
true,
"Validation directory: {}",
app_dir_str
);
// 使用子进程运行clash验证配置
let output = app_handle
@@ -271,56 +292,84 @@ impl CoreManager {
let has_error =
!output.status.success() || error_keywords.iter().any(|&kw| stderr.contains(kw));
logging!(info, Type::Config, true, "-------- 验证结果 --------");
logging!(
info,
Type::Config,
true,
"-------- Validation Result --------"
);
if !stderr.is_empty() {
logging!(info, Type::Config, true, "stderr输出:\n{}", stderr);
logging!(info, Type::Config, true, "stderr output:\n{}", stderr);
}
if has_error {
logging!(info, Type::Config, true, "发现错误,开始处理错误信息");
logging!(
info,
Type::Config,
true,
"Errors found, processing error details"
);
let error_msg = if !stdout.is_empty() {
stdout.to_string()
} else if !stderr.is_empty() {
stderr.to_string()
} else if let Some(code) = output.status.code() {
format!("验证进程异常退出,退出码: {code}")
format!("Validation process exited abnormally, exit code: {code}")
} else {
"验证进程被终止".to_string()
"Validation process was terminated".to_string()
};
logging!(info, Type::Config, true, "-------- 验证结束 --------");
logging!(info, Type::Config, true, "-------- Validation End --------");
Ok((false, error_msg)) // 返回错误消息给调用者处理
} else {
logging!(info, Type::Config, true, "验证成功");
logging!(info, Type::Config, true, "-------- 验证结束 --------");
logging!(info, Type::Config, true, "Validation succeeded");
logging!(info, Type::Config, true, "-------- Validation End --------");
Ok((true, String::new()))
}
}
/// 只进行文件语法检查,不进行完整验证
async fn validate_file_syntax(&self, config_path: &str) -> Result<(bool, String)> {
logging!(info, Type::Config, true, "开始检查文件: {}", config_path);
logging!(
info,
Type::Config,
true,
"Starting file check: {}",
config_path
);
// 读取文件内容
let content = match std::fs::read_to_string(config_path) {
Ok(content) => content,
Err(err) => {
let error_msg = format!("Failed to read file: {err}");
logging!(error, Type::Config, true, "无法读取文件: {}", error_msg);
logging!(
error,
Type::Config,
true,
"Failed to read file: {}",
error_msg
);
return Ok((false, error_msg));
}
};
// 对YAML文件尝试解析只检查语法正确性
logging!(info, Type::Config, true, "进行YAML语法检查");
logging!(info, Type::Config, true, "Performing YAML syntax check");
match serde_yaml::from_str::<serde_yaml::Value>(&content) {
Ok(_) => {
logging!(info, Type::Config, true, "YAML语法检查通过");
logging!(info, Type::Config, true, "YAML syntax check passed");
Ok((true, String::new()))
}
Err(err) => {
// 使用标准化的前缀,以便错误处理函数能正确识别
let error_msg = format!("YAML syntax error: {err}");
logging!(error, Type::Config, true, "YAML语法错误: {}", error_msg);
logging!(
error,
Type::Config,
true,
"YAML syntax error: {}",
error_msg
);
Ok((false, error_msg))
}
}
@@ -332,13 +381,19 @@ impl CoreManager {
Ok(content) => content,
Err(err) => {
let error_msg = format!("Failed to read script file: {err}");
logging!(warn, Type::Config, true, "脚本语法错误: {}", err);
logging!(warn, Type::Config, true, "Script syntax error: {}", err);
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
return Ok((false, error_msg));
}
};
logging!(debug, Type::Config, true, "验证脚本文件: {}", path);
logging!(
debug,
Type::Config,
true,
"Validating script file: {}",
path
);
// 使用boa引擎进行基本语法检查
use boa_engine::{Context, Source};
@@ -348,7 +403,13 @@ impl CoreManager {
match result {
Ok(_) => {
logging!(debug, Type::Config, true, "脚本语法验证通过: {}", path);
logging!(
debug,
Type::Config,
true,
"Script syntax validation passed: {}",
path
);
// 检查脚本是否包含main函数
if !content.contains("function main")
@@ -356,7 +417,13 @@ impl CoreManager {
&& !content.contains("let main")
{
let error_msg = "Script must contain a main function";
logging!(warn, Type::Config, true, "脚本缺少main函数: {}", path);
logging!(
warn,
Type::Config,
true,
"Script missing main function: {}",
path
);
//handle::Handle::notice_message("config_validate::script_missing_main", error_msg);
return Ok((false, error_msg.to_string()));
}
@@ -365,7 +432,7 @@ impl CoreManager {
}
Err(err) => {
let error_msg = format!("Script syntax error: {err}");
logging!(warn, Type::Config, true, "脚本语法错误: {}", err);
logging!(warn, Type::Config, true, "Script syntax error: {}", err);
//handle::Handle::notice_message("config_validate::script_syntax_error", &error_msg);
Ok((false, error_msg))
}
@@ -375,33 +442,55 @@ impl CoreManager {
pub async fn update_config(&self) -> Result<(bool, String)> {
// 检查程序是否正在退出,如果是则跳过完整验证流程
if handle::Handle::global().is_exiting() {
logging!(info, Type::Config, true, "应用正在退出,跳过验证");
logging!(
info,
Type::Config,
true,
"App is exiting, skipping validation"
);
return Ok((true, String::new()));
}
logging!(info, Type::Config, true, "开始更新配置");
logging!(info, Type::Config, true, "Starting config update");
// 1. 先生成新的配置内容
logging!(info, Type::Config, true, "生成新的配置内容");
logging!(
info,
Type::Config,
true,
"Generating new configuration content"
);
Config::generate().await?;
// 2. 验证配置
match self.validate_config().await {
Ok((true, _)) => {
logging!(info, Type::Config, true, "配置验证通过");
logging!(info, Type::Config, true, "Configuration validation passed");
// 4. 验证通过后,生成正式的运行时配置
logging!(info, Type::Config, true, "生成运行时配置");
logging!(info, Type::Config, true, "Generating runtime configuration");
let run_path = Config::generate_file(ConfigType::Run)?;
logging_error!(Type::Config, true, self.put_configs_force(run_path).await);
Ok((true, "something".into()))
}
Ok((false, error_msg)) => {
logging!(warn, Type::Config, true, "配置验证失败: {}", error_msg);
logging!(
warn,
Type::Config,
true,
"Configuration validation failed: {}",
error_msg
);
Config::runtime().discard();
Ok((false, error_msg))
}
Err(e) => {
logging!(warn, Type::Config, true, "验证过程发生错误: {}", e);
logging!(
warn,
Type::Config,
true,
"Error occurred during validation: {}",
e
);
Config::runtime().discard();
Err(e)
}
@@ -435,7 +524,12 @@ impl CoreManager {
impl CoreManager {
/// 清理多余的 mihomo 进程
async fn cleanup_orphaned_mihomo_processes(&self) -> Result<()> {
logging!(info, Type::Core, true, "开始清理多余的 mihomo 进程");
logging!(
info,
Type::Core,
true,
"Starting cleanup of orphaned mihomo processes"
);
// 获取当前管理的进程 PID
let current_pid = {
@@ -471,7 +565,7 @@ impl CoreManager {
debug,
Type::Core,
true,
"跳过当前管理的进程: {} (PID: {})",
"Skipping currently managed process: {} (PID: {})",
process_name,
pid
);
@@ -482,13 +576,24 @@ impl CoreManager {
}
}
Err(e) => {
logging!(debug, Type::Core, true, "查找进程时发生错误: {}", e);
logging!(
debug,
Type::Core,
true,
"Error occurred while finding processes: {}",
e
);
}
}
}
if pids_to_kill.is_empty() {
logging!(debug, Type::Core, true, "未发现多余的 mihomo 进程");
logging!(
debug,
Type::Core,
true,
"No orphaned mihomo processes found"
);
return Ok(());
}
@@ -506,7 +611,7 @@ impl CoreManager {
info,
Type::Core,
true,
"清理完成,共终止了 {} 个多余的 mihomo 进程",
"Cleanup complete, a total of {} redundant mihomo processes terminated",
killed_count
);
}
@@ -615,7 +720,7 @@ impl CoreManager {
info,
Type::Core,
true,
"尝试终止进程: {} (PID: {})",
"Attempt to terminate process: {} (PID: {})",
process_name,
pid
);
@@ -662,7 +767,7 @@ impl CoreManager {
warn,
Type::Core,
true,
"进程 {} (PID: {}) 终止命令成功但进程仍在运行",
"Process {} (PID: {}) Termination command successful, but process still running",
process_name,
pid
);
@@ -672,7 +777,7 @@ impl CoreManager {
info,
Type::Core,
true,
"成功终止进程: {} (PID: {})",
"Successfully terminated process: {} (PID: {})",
process_name,
pid
);
@@ -683,7 +788,7 @@ impl CoreManager {
warn,
Type::Core,
true,
"无法终止进程: {} (PID: {})",
"Unable to terminate process: {} (PID: {})",
process_name,
pid
);
@@ -837,19 +942,29 @@ impl CoreManager {
// 当服务安装失败时的回退逻辑
async fn attempt_service_init(&self) -> Result<()> {
if service::check_service_needs_reinstall().await {
logging!(info, Type::Core, true, "服务版本不匹配或状态异常,执行重装");
logging!(
info,
Type::Core,
true,
"Service version mismatch or abnormal status, performing reinstallation"
);
if let Err(e) = service::reinstall_service().await {
logging!(
warn,
Type::Core,
true,
"服务重装失败 during attempt_service_init: {}",
"Service reinstallation failed during attempt_service_init: {}",
e
);
return Err(e);
}
// 如果重装成功,还需要尝试启动服务
logging!(info, Type::Core, true, "服务重装成功,尝试启动服务");
logging!(
info,
Type::Core,
true,
"Service reinstalled successfully, attempting to start"
);
}
if let Err(e) = self.start_core_by_service().await {
@@ -857,20 +972,20 @@ impl CoreManager {
warn,
Type::Core,
true,
"通过服务启动核心失败 during attempt_service_init: {}",
"Failed to start core via service 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}"));
state.last_error = Some(format!("Failed to start core via service: {e}"));
if let Err(save_err) = state.save() {
logging!(
error,
Type::Core,
true,
"保存ServiceState失败 (in attempt_service_init/start_core_by_service): {}",
"Failed to save ServiceState (in attempt_service_init/start_core_by_service): {}",
save_err
);
}
@@ -889,7 +1004,7 @@ impl CoreManager {
warn,
Type::Core,
true,
"应用初始化时清理多余 mihomo 进程失败: {}",
"Failed to clean up unnecessary mihomo processes during application initialization: {}",
e
);
}
@@ -901,11 +1016,16 @@ impl CoreManager {
info,
Type::Core,
true,
"服务当前可用或看似可用,尝试通过服务模式启动/重装"
"Service currently available or appears available; attempting to start/reinstall via service mode"
);
match self.attempt_service_init().await {
Ok(_) => {
logging!(info, Type::Core, true, "服务模式成功启动核心");
logging!(
info,
Type::Core,
true,
"Service mode successfully started core"
);
core_started_successfully = true;
}
Err(_err) => {
@@ -913,7 +1033,7 @@ impl CoreManager {
warn,
Type::Core,
true,
"服务模式启动或重装失败。将尝试Sidecar模式回退。"
"Service mode start or reinstall failed. Will attempt Sidecar fallback."
);
}
}
@@ -922,7 +1042,7 @@ impl CoreManager {
info,
Type::Core,
true,
"服务初始不可用 (is_service_available 调用失败)"
"Service initially unavailable (is_service_available call failed)"
);
}
@@ -931,7 +1051,7 @@ impl CoreManager {
info,
Type::Core,
true,
"核心未通过服务模式启动执行Sidecar回退或首次安装逻辑"
"Core not started via service mode; performing Sidecar fallback or first-time install logic"
);
let service_state = service::ServiceState::get();
@@ -941,7 +1061,7 @@ impl CoreManager {
info,
Type::Core,
true,
"用户偏好Sidecar模式或先前服务启动失败使用Sidecar模式启动"
"User prefers Sidecar mode or previous service start failed; starting with Sidecar mode"
);
self.start_core_by_sidecar().await?;
// 如果 sidecar 启动成功,我们可以认为核心初始化流程到此结束
@@ -953,26 +1073,41 @@ impl CoreManager {
info,
Type::Core,
true,
"无服务安装记录 (首次运行或状态重置),尝试安装服务"
"No service installation record (first run or state reset); attempting to install service"
);
match service::install_service().await {
Ok(_) => {
logging!(info, Type::Core, true, "服务安装成功(首次尝试)");
logging!(
info,
Type::Core,
true,
"Service installed successfully (first attempt)"
);
let mut new_state = service::ServiceState::default();
new_state.record_install();
new_state.prefer_sidecar = false;
new_state.save()?;
if service::is_service_available().await.is_ok() {
logging!(info, Type::Core, true, "新安装的服务可用,尝试启动");
logging!(
info,
Type::Core,
true,
"Newly installed service available; attempting to start"
);
if self.start_core_by_service().await.is_ok() {
logging!(info, Type::Core, true, "新安装的服务启动成功");
logging!(
info,
Type::Core,
true,
"Newly installed service started successfully"
);
} else {
logging!(
warn,
Type::Core,
true,
"新安装的服务启动失败,回退到Sidecar模式"
"Newly installed service failed to start; falling back to Sidecar mode"
);
let mut final_state = service::ServiceState::get();
final_state.prefer_sidecar = true;
@@ -986,7 +1121,7 @@ impl CoreManager {
warn,
Type::Core,
true,
"服务安装成功但未能连接/立即可用,回退到Sidecar模式"
"Service installed successfully but not connectable/immediately available; falling back to Sidecar mode"
);
let mut final_state = service::ServiceState::get();
final_state.prefer_sidecar = true;
@@ -999,7 +1134,13 @@ impl CoreManager {
}
}
Err(err) => {
logging!(warn, Type::Core, true, "服务首次安装失败: {}", err);
logging!(
warn,
Type::Core,
true,
"Service first-time installation failed: {}",
err
);
let new_state = service::ServiceState {
last_error: Some(err.to_string()),
prefer_sidecar: true,
@@ -1017,7 +1158,7 @@ impl CoreManager {
info,
Type::Core,
true,
"有服务安装记录但服务不可用/未启动,强制切换到Sidecar模式"
"There is a service installation record, but the service is unavailable/not started. Force switch to Sidecar mode"
);
let mut final_state = service::ServiceState::get();
if !final_state.prefer_sidecar {
@@ -1025,7 +1166,7 @@ impl CoreManager {
warn,
Type::Core,
true,
"prefer_sidecar false,因服务启动失败或不可用而强制设置为 true"
"prefer_sidecar is false, but is forced to true due to service startup failure or unavailability"
);
final_state.prefer_sidecar = true;
final_state.last_error =
@@ -1062,7 +1203,12 @@ impl CoreManager {
if service::check_service_needs_reinstall().await {
service::reinstall_service().await?;
}
logging!(info, Type::Core, true, "服务可用,使用服务模式启动");
logging!(
info,
Type::Core,
true,
"Service available; starting in service mode"
);
self.start_core_by_service().await?;
} else {
// 服务不可用,检查用户偏好
@@ -1072,11 +1218,16 @@ impl CoreManager {
info,
Type::Core,
true,
"服务不可用根据用户偏好使用Sidecar模式"
"Service unavailable; starting in Sidecar mode per user preference"
);
self.start_core_by_sidecar().await?;
} else {
logging!(info, Type::Core, true, "服务不可用使用Sidecar模式");
logging!(
info,
Type::Core,
true,
"Service unavailable; starting in Sidecar mode"
);
self.start_core_by_sidecar().await?;
}
}

View File

@@ -4,7 +4,7 @@ use tokio::sync::{mpsc, oneshot};
use tokio::time::{sleep, timeout, Duration};
use crate::config::{Config, IVerge};
use crate::core::async_proxy_query::AsyncProxyQuery;
use crate::core::{async_proxy_query::AsyncProxyQuery, handle};
use crate::logging_error;
use crate::utils::logging::Type;
use once_cell::sync::Lazy;
@@ -78,7 +78,7 @@ struct QueryRequest {
response_tx: oneshot::Sender<Autoproxy>,
}
// 配置结构体移到外部
// Configuration structure moved to external
struct ProxyConfig {
sys_enabled: bool,
pac_enabled: bool,
@@ -106,59 +106,59 @@ impl EventDrivenProxyManager {
}
}
/// 获取自动代理配置(缓存)
/// Get automatic proxy configuration (cached)
pub fn get_auto_proxy_cached(&self) -> Autoproxy {
self.state.read().auto_proxy.clone()
}
/// 异步获取最新的自动代理配置
/// Asynchronously get the latest automatic proxy configuration
pub async fn get_auto_proxy_async(&self) -> Autoproxy {
let (tx, rx) = oneshot::channel();
let query = QueryRequest { response_tx: tx };
if self.query_sender.send(query).is_err() {
log::error!(target: "app", "发送查询请求失败,返回缓存数据");
log::error!(target: "app", "Failed to send query request, returning cached data");
return self.get_auto_proxy_cached();
}
match timeout(Duration::from_secs(5), rx).await {
Ok(Ok(result)) => result,
_ => {
log::warn!(target: "app", "查询超时,返回缓存数据");
log::warn!(target: "app", "Query timed out, returning cached data");
self.get_auto_proxy_cached()
}
}
}
/// 通知配置变更
/// Notify configuration changed
pub fn notify_config_changed(&self) {
self.send_event(ProxyEvent::ConfigChanged);
}
/// 通知应用启动
/// Notify application started
pub fn notify_app_started(&self) {
self.send_event(ProxyEvent::AppStarted);
}
/// 通知应用即将关闭
/// Notify application stopping
#[allow(dead_code)]
pub fn notify_app_stopping(&self) {
self.send_event(ProxyEvent::AppStopping);
}
/// 启用系统代理
/// Enable system proxy
#[allow(dead_code)]
pub fn enable_proxy(&self) {
self.send_event(ProxyEvent::EnableProxy);
}
/// 禁用系统代理
/// Disable system proxy
#[allow(dead_code)]
pub fn disable_proxy(&self) {
self.send_event(ProxyEvent::DisableProxy);
}
/// 强制检查代理状态
/// Force check proxy status
#[allow(dead_code)]
pub fn force_check(&self) {
self.send_event(ProxyEvent::ForceCheck);
@@ -166,7 +166,7 @@ impl EventDrivenProxyManager {
fn send_event(&self, event: ProxyEvent) {
if let Err(e) = self.event_sender.send(event) {
log::error!(target: "app", "发送代理事件失败: {e}");
log::error!(target: "app", "Failed to send proxy event: {e}");
}
}
@@ -176,18 +176,18 @@ impl EventDrivenProxyManager {
mut query_rx: mpsc::UnboundedReceiver<QueryRequest>,
) {
tokio::spawn(async move {
log::info!(target: "app", "事件驱动代理管理器启动");
log::info!(target: "app", "Event-driven proxy manager started");
loop {
tokio::select! {
event = event_rx.recv() => {
match event {
Some(event) => {
log::debug!(target: "app", "处理代理事件: {event:?}");
log::debug!(target: "app", "Handling proxy event: {event:?}");
Self::handle_event(&state, event).await;
}
None => {
log::info!(target: "app", "事件通道关闭,代理管理器停止");
log::info!(target: "app", "Event channel closed, proxy manager stopped");
break;
}
}
@@ -199,7 +199,7 @@ impl EventDrivenProxyManager {
let _ = query.response_tx.send(result);
}
None => {
log::info!(target: "app", "查询通道关闭");
log::info!(target: "app", "Query channel closed");
break;
}
}
@@ -230,7 +230,12 @@ impl EventDrivenProxyManager {
Self::initialize_proxy_state(state).await;
}
ProxyEvent::AppStopping => {
log::info!(target: "app", "清理代理状态");
log::info!(target: "app", "Cleaning up proxy state");
Self::update_state_timestamp(state, |s| {
s.sys_enabled = false;
s.pac_enabled = false;
s.is_healthy = false;
});
}
}
}
@@ -246,7 +251,7 @@ impl EventDrivenProxyManager {
}
async fn initialize_proxy_state(state: &Arc<RwLock<ProxyState>>) {
log::info!(target: "app", "初始化代理状态");
log::info!(target: "app", "Initializing proxy state");
let config = Self::get_proxy_config();
let auto_proxy = Self::get_auto_proxy_with_timeout().await;
@@ -260,11 +265,11 @@ impl EventDrivenProxyManager {
s.is_healthy = true;
});
log::info!(target: "app", "代理状态初始化完成: sys={}, pac={}", config.sys_enabled, config.pac_enabled);
log::info!(target: "app", "Proxy state initialized: sys={}, pac={}", config.sys_enabled, config.pac_enabled);
}
async fn update_proxy_config(state: &Arc<RwLock<ProxyState>>) {
log::debug!(target: "app", "更新代理配置");
log::debug!(target: "app", "Updating proxy configuration");
let config = Self::get_proxy_config();
@@ -279,6 +284,10 @@ impl EventDrivenProxyManager {
}
async fn check_and_restore_proxy(state: &Arc<RwLock<ProxyState>>) {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip system proxy guard check");
return;
}
let (sys_enabled, pac_enabled) = {
let s = state.read();
(s.sys_enabled, s.pac_enabled)
@@ -288,7 +297,7 @@ impl EventDrivenProxyManager {
return;
}
log::debug!(target: "app", "检查代理状态");
log::debug!(target: "app", "Checking proxy status");
if pac_enabled {
Self::check_and_restore_pac_proxy(state).await;
@@ -298,6 +307,11 @@ impl EventDrivenProxyManager {
}
async fn check_and_restore_pac_proxy(state: &Arc<RwLock<ProxyState>>) {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip PAC proxy restore check");
return;
}
let current = Self::get_auto_proxy_with_timeout().await;
let expected = Self::get_expected_pac_config();
@@ -306,7 +320,7 @@ impl EventDrivenProxyManager {
});
if !current.enable || current.url != expected.url {
log::info!(target: "app", "PAC代理设置异常,正在恢复...");
log::info!(target: "app", "PAC proxy setting abnormal, recovering...");
Self::restore_pac_proxy(&expected.url).await;
sleep(Duration::from_millis(500)).await;
@@ -320,6 +334,11 @@ impl EventDrivenProxyManager {
}
async fn check_and_restore_sys_proxy(state: &Arc<RwLock<ProxyState>>) {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip system proxy restore check");
return;
}
let current = Self::get_sys_proxy_with_timeout().await;
let expected = Self::get_expected_sys_proxy();
@@ -328,7 +347,7 @@ impl EventDrivenProxyManager {
});
if !current.enable || current.host != expected.host || current.port != expected.port {
log::info!(target: "app", "系统代理设置异常,正在恢复...");
log::info!(target: "app", "System proxy setting abnormal, recovering...");
Self::restore_sys_proxy(&expected).await;
sleep(Duration::from_millis(500)).await;
@@ -344,7 +363,12 @@ impl EventDrivenProxyManager {
}
async fn enable_system_proxy(state: &Arc<RwLock<ProxyState>>) {
log::info!(target: "app", "启用系统代理");
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip enabling system proxy");
return;
}
log::info!(target: "app", "Enabling system proxy");
let pac_enabled = state.read().pac_enabled;
@@ -360,7 +384,7 @@ impl EventDrivenProxyManager {
}
async fn disable_system_proxy(_state: &Arc<RwLock<ProxyState>>) {
log::info!(target: "app", "禁用系统代理");
log::info!(target: "app", "Disabling system proxy");
#[cfg(not(target_os = "windows"))]
{
@@ -373,7 +397,12 @@ impl EventDrivenProxyManager {
}
async fn switch_proxy_mode(state: &Arc<RwLock<ProxyState>>, to_pac: bool) {
log::info!(target: "app", "切换到{}模式", if to_pac { "PAC" } else { "HTTP代理" });
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip proxy mode switch");
return;
}
log::info!(target: "app", "Switching to {} mode", if to_pac { "PAC" } else { "HTTP Proxy" });
if to_pac {
let disabled_sys = Sysproxy::default();
@@ -396,7 +425,7 @@ impl EventDrivenProxyManager {
async fn get_auto_proxy_with_timeout() -> Autoproxy {
let async_proxy = AsyncProxyQuery::get_auto_proxy().await;
// 转换为兼容的结构
// Convert to compatible structure
Autoproxy {
enable: async_proxy.enable,
url: async_proxy.url,
@@ -406,7 +435,7 @@ impl EventDrivenProxyManager {
async fn get_sys_proxy_with_timeout() -> Sysproxy {
let async_proxy = AsyncProxyQuery::get_system_proxy().await;
// 转换为兼容的结构
// Convert to compatible structure
Sysproxy {
enable: async_proxy.enable,
host: async_proxy.host,
@@ -415,7 +444,7 @@ impl EventDrivenProxyManager {
}
}
// 统一的状态更新方法
// Unified state update method
fn update_state_timestamp<F>(state: &Arc<RwLock<ProxyState>>, update_fn: F)
where
F: FnOnce(&mut ProxyState),
@@ -507,6 +536,10 @@ impl EventDrivenProxyManager {
#[cfg(target_os = "windows")]
{
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip PAC proxy restore");
return;
}
Self::execute_sysproxy_command(&["pac", expected_url]).await;
}
}
@@ -519,6 +552,10 @@ impl EventDrivenProxyManager {
#[cfg(target_os = "windows")]
{
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip system proxy restore");
return;
}
let address = format!("{}:{}", expected.host, expected.port);
Self::execute_sysproxy_command(&["global", &address, &expected.bypass]).await;
}
@@ -526,6 +563,15 @@ impl EventDrivenProxyManager {
#[cfg(target_os = "windows")]
async fn execute_sysproxy_command(args: &[&str]) {
if handle::Handle::global().is_exiting() {
log::debug!(
target: "app",
"Application is exiting, cancel calling sysproxy.exe, args: {:?}",
args
);
return;
}
use crate::utils::dirs;
#[allow(unused_imports)] // creation_flags必须
use std::os::windows::process::CommandExt;
@@ -534,14 +580,14 @@ impl EventDrivenProxyManager {
let binary_path = match dirs::service_path() {
Ok(path) => path,
Err(e) => {
log::error!(target: "app", "获取服务路径失败: {}", e);
log::error!(target: "app", "Failed to get service path: {}", e);
return;
}
};
let sysproxy_exe = binary_path.with_file_name("sysproxy.exe");
if !sysproxy_exe.exists() {
log::error!(target: "app", "sysproxy.exe 不存在");
log::error!(target: "app", "sysproxy.exe does not exist");
return;
}
@@ -554,17 +600,17 @@ impl EventDrivenProxyManager {
match output {
Ok(output) => {
if !output.status.success() {
log::error!(target: "app", "执行sysproxy命令失败: {:?}", args);
log::error!(target: "app", "Failed to execute sysproxy command: {:?}", args);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
log::error!(target: "app", "sysproxy错误输出: {}", stderr);
log::error!(target: "app", "sysproxy stderr: {}", stderr);
}
} else {
log::debug!(target: "app", "成功执行sysproxy命令: {:?}", args);
log::debug!(target: "app", "Successfully executed sysproxy command: {:?}", args);
}
}
Err(e) => {
log::error!(target: "app", "执行sysproxy命令出错: {}", e);
log::error!(target: "app", "Error executing sysproxy command: {}", e);
}
}
}

View File

@@ -258,6 +258,8 @@ pub struct Handle {
startup_errors: Arc<RwLock<Vec<ErrorMessage>>>,
startup_completed: Arc<RwLock<bool>>,
notification_system: Arc<RwLock<Option<NotificationSystem>>>,
/// Messages that should be emitted only after UI is really ready
ui_pending_messages: Arc<RwLock<Vec<ErrorMessage>>>,
}
impl Default for Handle {
@@ -268,6 +270,7 @@ impl Default for Handle {
startup_errors: Arc::new(RwLock::new(Vec::new())),
startup_completed: Arc::new(RwLock::new(false)),
notification_system: Arc::new(RwLock::new(Some(NotificationSystem::new()))),
ui_pending_messages: Arc::new(RwLock::new(Vec::new())),
}
}
}
@@ -295,6 +298,10 @@ impl Handle {
}
pub fn get_window(&self) -> Option<WebviewWindow> {
// If we are in lightweight mode, treat as no window (webview may be destroyed)
if crate::module::lightweight::is_in_lightweight_mode() {
return None;
}
let app_handle = self.app_handle()?;
let window: Option<WebviewWindow> = app_handle.get_webview_window("main");
if window.is_none() {
@@ -411,12 +418,13 @@ impl Handle {
let status_str = status.into();
let msg_str = msg.into();
// If startup not completed, buffer messages (existing behavior)
if !*handle.startup_completed.read() {
logging!(
info,
Type::Frontend,
true,
"启动过程中发现错误,加入消息队列: {} - {}",
"Error found during startup; queued: {} - {}",
status_str,
msg_str
);
@@ -429,6 +437,23 @@ impl Handle {
return;
}
// If UI is not yet ready (e.g., window re-created from tray or lightweight mode),
// buffer messages to emit after UI signals readiness.
if !crate::utils::resolve::is_ui_ready() {
log::debug!(
target: "app",
"UI not ready, queue notice message: {} - {}",
status_str,
msg_str
);
let mut pendings = handle.ui_pending_messages.write();
pendings.push(ErrorMessage {
status: status_str,
message: msg_str,
});
return;
}
if handle.is_exiting() {
return;
}
@@ -442,6 +467,34 @@ impl Handle {
}
}
/// Flush messages buffered while UI was not ready
pub fn flush_ui_pending_messages(&self) {
let pending = {
let mut msgs = self.ui_pending_messages.write();
std::mem::take(&mut *msgs)
};
if pending.is_empty() {
return;
}
if self.is_exiting() {
return;
}
let system_opt = self.notification_system.read();
if let Some(system) = system_opt.as_ref() {
for msg in pending {
system.send_event(FrontendEvent::NoticeMessage {
status: msg.status,
message: msg.message,
});
// small pacing to avoid flooding immediately on resume
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
pub fn mark_startup_completed(&self) {
{
let mut completed = self.startup_completed.write();
@@ -466,7 +519,7 @@ impl Handle {
info,
Type::Frontend,
true,
"发送{}条启动时累积的错误消息",
"Sending {} accumulated startup error messages",
errors.len()
);

View File

@@ -346,7 +346,7 @@ pub async fn reinstall_service() -> Result<()> {
Ok(())
}
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.prefer_sidecar = true;
service_state.save()?;
@@ -477,7 +477,12 @@ pub async fn reinstall_service() -> Result<()> {
/// 检查服务状态 - 使用IPC通信
pub async fn check_ipc_service_status() -> Result<JsonResponse> {
logging!(info, Type::Service, true, "开始检查服务状态 (IPC)");
logging!(
info,
Type::Service,
true,
"Starting service status check (IPC)"
);
// 使用IPC通信
let payload = serde_json::json!({});
@@ -495,8 +500,16 @@ pub async fn check_ipc_service_status() -> Result<JsonResponse> {
); */
if !response.success {
let err_msg = response.error.unwrap_or_else(|| "未知服务错误".to_string());
logging!(error, Type::Service, true, "服务响应错误: {}", err_msg);
let err_msg = response
.error
.unwrap_or_else(|| "Unknown service error".to_string());
logging!(
error,
Type::Service,
true,
"Service response error: {}",
err_msg
);
bail!(err_msg);
}
@@ -516,7 +529,7 @@ pub async fn check_ipc_service_status() -> Result<JsonResponse> {
warn,
Type::Service,
true,
"解析嵌套的ResponseBody失败: {}; 尝试其他方式",
"Failed to parse nested ResponseBody: {}; trying alternative",
e
);
None
@@ -536,7 +549,7 @@ pub async fn check_ipc_service_status() -> Result<JsonResponse> {
info,
Type::Service,
true,
"服务检测成功: code={}, msg={}, data存在={}",
"Service check succeeded: code={}, msg={}, data_present={}",
json_response.code,
json_response.msg,
json_response.data.is_some()
@@ -550,7 +563,7 @@ pub async fn check_ipc_service_status() -> Result<JsonResponse> {
info,
Type::Service,
true,
"服务检测成功: code={}, msg={}",
"Service check succeeded: code={}, msg={}",
json_response.code,
json_response.msg
);
@@ -561,31 +574,42 @@ pub async fn check_ipc_service_status() -> Result<JsonResponse> {
error,
Type::Service,
true,
"解析服务响应失败: {}; 原始数据: {:?}",
"Failed to parse service response: {}; raw data: {:?}",
e,
data
);
bail!("无法解析服务响应数据: {}", e)
bail!("Unable to parse service response data: {}", e)
}
}
}
}
None => {
logging!(error, Type::Service, true, "服务响应中没有数据");
bail!("服务响应中没有数据")
logging!(error, Type::Service, true, "No data in service response");
bail!("No data in service response")
}
}
}
Err(e) => {
logging!(error, Type::Service, true, "IPC通信失败: {}", e);
bail!("无法连接到Koala Clash Service: {}", e)
logging!(
error,
Type::Service,
true,
"IPC communication failed: {}",
e
);
bail!("Unable to connect to Koala Clash Service: {}", e)
}
}
}
/// 检查服务版本 - 使用IPC通信
pub async fn check_service_version() -> Result<String> {
logging!(info, Type::Service, true, "开始检查服务版本 (IPC)");
logging!(
info,
Type::Service,
true,
"Starting service version check (IPC)"
);
let payload = serde_json::json!({});
// logging!(debug, Type::Service, true, "发送GetVersion请求");
@@ -604,8 +628,14 @@ pub async fn check_service_version() -> Result<String> {
if !response.success {
let err_msg = response
.error
.unwrap_or_else(|| "获取服务版本失败".to_string());
logging!(error, Type::Service, true, "获取版本错误: {}", err_msg);
.unwrap_or_else(|| "Failed to get service version".to_string());
logging!(
error,
Type::Service,
true,
"Failed to get service version: {}",
err_msg
);
bail!(err_msg);
}
@@ -618,7 +648,7 @@ pub async fn check_service_version() -> Result<String> {
info,
Type::Service,
true,
"获取到服务版本: {}",
"Service version: {}",
version_str
);
return Ok(version_str.to_string());
@@ -628,7 +658,7 @@ pub async fn check_service_version() -> Result<String> {
error,
Type::Service,
true,
"嵌套数据中没有version字段: {:?}",
"Nested data does not contain version field: {:?}",
nested_data
);
} else {
@@ -639,7 +669,7 @@ pub async fn check_service_version() -> Result<String> {
info,
Type::Service,
true,
"获取到服务版本: {}",
"Received service version: {}",
version_response.version
);
return Ok(version_response.version);
@@ -649,44 +679,55 @@ pub async fn check_service_version() -> Result<String> {
error,
Type::Service,
true,
"解析版本响应失败: {}; 原始数据: {:?}",
"Failed to parse version response: {}; raw data: {:?}",
e,
data
);
bail!("无法解析服务版本数据: {}", e)
bail!("Unable to parse service version data: {}", e)
}
}
}
bail!("响应中未找到有效的版本信息")
bail!("No valid version information found in response")
}
None => {
logging!(error, Type::Service, true, "版本响应中没有数据");
bail!("服务版本响应中没有数据")
logging!(error, Type::Service, true, "No data in version response");
bail!("No data in service version response")
}
}
}
Err(e) => {
logging!(error, Type::Service, true, "IPC通信失败: {}", e);
bail!("无法连接到Koala Clash Service: {}", e)
logging!(
error,
Type::Service,
true,
"IPC communication failed: {}",
e
);
bail!("Unable to connect to Koala Clash Service: {}", e)
}
}
}
/// 检查服务是否需要重装
pub async fn check_service_needs_reinstall() -> bool {
logging!(info, Type::Service, true, "开始检查服务是否需要重装");
logging!(
info,
Type::Service,
true,
"Checking whether service needs reinstallation"
);
let service_state = ServiceState::get();
if !service_state.can_reinstall() {
log::info!(target: "app", "服务重装检查: 处于冷却期或已达最大尝试次数");
log::info!(target: "app", "Service reinstall check: in cooldown period or max attempts reached");
return false;
}
// 检查版本和可用性
match check_service_version().await {
Ok(version) => {
log::info!(target: "app", "服务版本检测:当前={version}, 要求={REQUIRED_SERVICE_VERSION}");
log::info!(target: "app", "Service version check: current={version}, required={REQUIRED_SERVICE_VERSION}");
/* logging!(
info,
Type::Service,
@@ -698,25 +739,36 @@ pub async fn check_service_needs_reinstall() -> bool {
let needs_reinstall = version != REQUIRED_SERVICE_VERSION;
if needs_reinstall {
log::warn!(target: "app", "发现服务版本不匹配,需要重装! 当前={version}, 要求={REQUIRED_SERVICE_VERSION}");
logging!(warn, Type::Service, true, "服务版本不匹配,需要重装");
log::warn!(target: "app", "Service version mismatch detected, reinstallation required! current={version}, required={REQUIRED_SERVICE_VERSION}");
logging!(
warn,
Type::Service,
true,
"Service version mismatch, reinstallation required"
);
// log::debug!(target: "app", "当前版本字节: {:?}", version.as_bytes());
// log::debug!(target: "app", "要求版本字节: {:?}", REQUIRED_SERVICE_VERSION.as_bytes());
} else {
log::info!(target: "app", "服务版本匹配,无需重装");
log::info!(target: "app", "Service version matches, no reinstallation needed");
// logging!(info, Type::Service, true, "服务版本匹配,无需重装");
}
needs_reinstall
}
Err(err) => {
logging!(error, Type::Service, true, "检查服务版本失败: {}", err);
logging!(
error,
Type::Service,
true,
"Failed to check service version: {}",
err
);
// 检查服务是否可用
match is_service_available().await {
Ok(()) => {
log::info!(target: "app", "服务正在运行但版本检查失败: {err}");
log::info!(target: "app", "Service is running but version check failed: {err}");
/* logging!(
info,
Type::Service,
@@ -727,7 +779,7 @@ pub async fn check_service_needs_reinstall() -> bool {
false
}
_ => {
log::info!(target: "app", "服务不可用或未运行,需要重装");
log::info!(target: "app", "Service unavailable or not running, reinstallation needed");
// logging!(info, Type::Service, true, "服务不可用或未运行,需要重装");
true
}
@@ -738,7 +790,7 @@ pub async fn check_service_needs_reinstall() -> bool {
/// 尝试使用服务启动core
pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result<()> {
log::info!(target:"app", "尝试使用现有服务启动核心 (IPC)");
log::info!(target:"app", "Attempting to start core with existing service (IPC)");
// logging!(info, Type::Service, true, "尝试使用现有服务启动核心");
let clash_core = Config::verge().latest().get_valid_clash_core();
@@ -781,8 +833,16 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
); */
if !response.success {
let err_msg = response.error.unwrap_or_else(|| "启动核心失败".to_string());
logging!(error, Type::Service, true, "启动核心失败: {}", err_msg);
let err_msg = response
.error
.unwrap_or_else(|| "Failed to start core".to_string());
logging!(
error,
Type::Service,
true,
"Failed to start core: {}",
err_msg
);
bail!(err_msg);
}
@@ -793,127 +853,140 @@ pub(super) async fn start_with_existing_service(config_file: &PathBuf) -> Result
let msg = data
.get("msg")
.and_then(|m| m.as_str())
.unwrap_or("未知错误");
.unwrap_or("Unknown error");
if code_value != 0 {
logging!(
error,
Type::Service,
true,
"启动核心返回错误: code={}, msg={}",
"Start core returned error: code={}, msg={}",
code_value,
msg
);
bail!("启动核心失败: {}", msg);
bail!("Failed to start core: {}", msg);
}
}
}
logging!(info, Type::Service, true, "服务成功启动核心");
logging!(
info,
Type::Service,
true,
"Service successfully started core"
);
Ok(())
}
Err(e) => {
logging!(error, Type::Service, true, "启动核心IPC通信失败: {}", e);
bail!("无法连接到Koala Clash Service: {}", e)
logging!(
error,
Type::Service,
true,
"Failed to start core via IPC: {}",
e
);
bail!("Unable to connect to Koala Clash Service: {}", e)
}
}
}
// 以服务启动core
pub(super) async fn run_core_by_service(config_file: &PathBuf) -> Result<()> {
log::info!(target: "app", "正在尝试通过服务启动核心");
log::info!(target: "app", "Attempting to start core via service");
// 先检查服务版本,不受冷却期限制
let version_check = match check_service_version().await {
Ok(version) => {
log::info!(target: "app", "检测到服务版本: {version}, 要求版本: {REQUIRED_SERVICE_VERSION}");
log::info!(target: "app", "Detected service version: {version}, required: {REQUIRED_SERVICE_VERSION}");
if version.as_bytes() != REQUIRED_SERVICE_VERSION.as_bytes() {
log::warn!(target: "app", "服务版本不匹配,需要重装");
log::warn!(target: "app", "Service version mismatch, reinstallation required");
false
} else {
log::info!(target: "app", "服务版本匹配");
log::info!(target: "app", "Service version matches");
true
}
}
Err(err) => {
log::warn!(target: "app", "无法获取服务版本: {err}");
log::warn!(target: "app", "Failed to get service version: {err}");
false
}
};
if version_check && is_service_available().await.is_ok() {
log::info!(target: "app", "服务已在运行且版本匹配,尝试使用");
log::info!(target: "app", "Service is running and version matches, attempting to use it");
return start_with_existing_service(config_file).await;
}
if !version_check {
log::info!(target: "app", "服务版本不匹配,尝试重装");
log::info!(target: "app", "Service version mismatch, attempting reinstallation");
let service_state = ServiceState::get();
if !service_state.can_reinstall() {
log::warn!(target: "app", "由于限制无法重装服务");
log::warn!(target: "app", "Cannot reinstall service due to limitations");
if let Ok(()) = start_with_existing_service(config_file).await {
log::info!(target: "app", "尽管版本不匹配,但成功启动了服务");
log::info!(target: "app", "Service started successfully despite version mismatch");
return Ok(());
} else {
bail!("服务版本不匹配且无法重装,启动失败");
bail!("Service version mismatch and cannot reinstall; startup failed");
}
}
log::info!(target: "app", "开始重装服务");
log::info!(target: "app", "Starting service reinstallation");
if let Err(err) = reinstall_service().await {
log::warn!(target: "app", "服务重装失败: {err}");
log::warn!(target: "app", "Service reinstallation failed: {err}");
log::info!(target: "app", "尝试使用现有服务");
log::info!(target: "app", "Attempting to use existing service");
return start_with_existing_service(config_file).await;
}
log::info!(target: "app", "服务重装成功,尝试启动");
log::info!(target: "app", "Service reinstalled successfully, attempting to start");
return start_with_existing_service(config_file).await;
}
// 检查服务状态
// Check service status
match check_ipc_service_status().await {
Ok(_) => {
log::info!(target: "app", "服务可用但未运行核心,尝试启动");
log::info!(target: "app", "Service available but core not running, attempting to start");
if let Ok(()) = start_with_existing_service(config_file).await {
return Ok(());
}
}
Err(err) => {
log::warn!(target: "app", "服务检查失败: {err}");
log::warn!(target: "app", "Service check failed: {err}");
}
}
// 服务不可用或启动失败,检查是否需要重装
// Service unavailable or startup failed, check if reinstallation is needed
if check_service_needs_reinstall().await {
log::info!(target: "app", "服务需要重装");
log::info!(target: "app", "Service needs reinstallation");
if let Err(err) = reinstall_service().await {
log::warn!(target: "app", "服务重装失败: {err}");
log::warn!(target: "app", "Service reinstallation failed: {err}");
bail!("Failed to reinstall service: {}", err);
}
log::info!(target: "app", "服务重装完成,尝试启动核心");
log::info!(target: "app", "Service reinstallation completed, attempting to start core");
start_with_existing_service(config_file).await
} else {
log::warn!(target: "app", "服务不可用且无法重装");
log::warn!(target: "app", "Service unavailable and cannot be reinstalled");
bail!("Service is not available and cannot be reinstalled at this time")
}
}
/// 通过服务停止core
pub(super) async fn stop_core_by_service() -> Result<()> {
logging!(info, Type::Service, true, "通过服务停止核心 (IPC)");
logging!(info, Type::Service, true, "Stopping core via service (IPC)");
let payload = serde_json::json!({});
let response = send_ipc_request(IpcCommand::StopClash, payload)
.await
.context("无法连接到Koala Clash Service")?;
.context("Unable to connect to Koala Clash Service")?;
if !response.success {
bail!(response.error.unwrap_or_else(|| "停止核心失败".to_string()));
bail!(response
.error
.unwrap_or_else(|| "Failed to stop core".to_string()));
}
if let Some(data) = &response.data {
@@ -922,18 +995,18 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
let msg = data
.get("msg")
.and_then(|m| m.as_str())
.unwrap_or("未知错误");
.unwrap_or("Unknown error");
if code_value != 0 {
logging!(
error,
Type::Service,
true,
"停止核心返回错误: code={}, msg={}",
"Stop core returned error: code={}, msg={}",
code_value,
msg
);
bail!("停止核心失败: {}", msg);
bail!("Failed to stop core: {}", msg);
}
}
}
@@ -943,19 +1016,24 @@ pub(super) async fn stop_core_by_service() -> Result<()> {
/// 检查服务是否正在运行
pub async fn is_service_available() -> Result<()> {
logging!(info, Type::Service, true, "开始检查服务是否正在运行");
logging!(
info,
Type::Service,
true,
"Checking whether service is running"
);
match check_ipc_service_status().await {
Ok(resp) => {
if resp.code == 0 && resp.msg == "ok" && resp.data.is_some() {
logging!(info, Type::Service, true, "服务正在运行");
logging!(info, Type::Service, true, "Service is running");
Ok(())
} else {
logging!(
warn,
Type::Service,
true,
"服务未正常运行: code={}, msg={}",
"Service not running normally: code={}, msg={}",
resp.code,
resp.msg
);
@@ -963,7 +1041,13 @@ pub async fn is_service_available() -> Result<()> {
}
}
Err(err) => {
logging!(error, Type::Service, true, "检查服务运行状态失败: {}", err);
logging!(
error,
Type::Service,
true,
"Failed to check service running status: {}",
err
);
Err(err)
}
}
@@ -971,21 +1055,21 @@ pub async fn is_service_available() -> Result<()> {
/// 强制重装服务UI修复按钮
pub async fn force_reinstall_service() -> Result<()> {
log::info!(target: "app", "用户请求强制重装服务");
log::info!(target: "app", "User requested forced service reinstallation");
let service_state = ServiceState::default();
service_state.save()?;
log::info!(target: "app", "已重置服务状态,开始执行重装");
log::info!(target: "app", "Service state reset, starting reinstallation");
match reinstall_service().await {
Ok(()) => {
log::info!(target: "app", "服务重装成功");
log::info!(target: "app", "Service reinstalled successfully");
Ok(())
}
Err(err) => {
log::error!(target: "app", "强制重装服务失败: {err}");
bail!("强制重装服务失败: {}", err)
log::error!(target: "app", "Forced service reinstallation failed: {err}");
bail!("Forced service reinstallation failed: {}", err)
}
}
}

View File

@@ -85,7 +85,7 @@ 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初始化失败")?;
let mut mac = HmacSha256::new_from_slice(&secret_key).context("Failed to initialize HMAC")?;
mac.update(message.as_bytes());
let result = mac.finalize();
@@ -129,14 +129,25 @@ pub async fn send_ipc_request(
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
};
logging!(info, Type::Service, true, "正在连接服务 (Windows)...");
logging!(
info,
Type::Service,
true,
"Connecting to service (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);
logging!(
error,
Type::Service,
true,
"Failed to create signed request: {}",
e
);
return Err(e);
}
};
@@ -147,8 +158,14 @@ pub async fn send_ipc_request(
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));
logging!(
error,
Type::Service,
true,
"Failed to create CString: {}",
e
);
return Err(anyhow::anyhow!("Failed to create CString: {}", e));
}
};
@@ -170,64 +187,110 @@ pub async fn send_ipc_request(
error,
Type::Service,
true,
"连接到服务命名管道失败: {}",
"Failed to connect to service named pipe: {}",
error
);
return Err(anyhow::anyhow!("无法连接到服务命名管道: {}", error));
return Err(anyhow::anyhow!("Unable to connect to service named pipe: {}", error));
}
let mut pipe = unsafe { File::from_raw_handle(handle as RawHandle) };
logging!(info, Type::Service, true, "服务连接成功 (Windows)");
logging!(
info,
Type::Service,
true,
"Service connection successful (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));
logging!(
error,
Type::Service,
true,
"Failed to write request length: {}",
e
);
return Err(anyhow::anyhow!("Failed to write request length: {}", e));
}
if let Err(e) = pipe.write_all(request_bytes) {
logging!(error, Type::Service, true, "写入请求内容失败: {}", e);
return Err(anyhow::anyhow!("写入请求内容失败: {}", e));
logging!(
error,
Type::Service,
true,
"Failed to write request body: {}",
e
);
return Err(anyhow::anyhow!("Failed to write request body: {}", e));
}
if let Err(e) = pipe.flush() {
logging!(error, Type::Service, true, "刷新管道失败: {}", e);
return Err(anyhow::anyhow!("刷新管道失败: {}", e));
logging!(error, Type::Service, true, "Failed to flush pipe: {}", e);
return Err(anyhow::anyhow!("Failed to flush pipe: {}", 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));
logging!(
error,
Type::Service,
true,
"Failed to read response length: {}",
e
);
return Err(anyhow::anyhow!("Failed to read response length: {}", 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));
logging!(
error,
Type::Service,
true,
"Failed to read response body: {}",
e
);
return Err(anyhow::anyhow!("Failed to read response body: {}", 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));
logging!(
error,
Type::Service,
true,
"Failed to parse service response: {}",
e
);
return Err(anyhow::anyhow!("Failed to parse response: {}", e));
}
};
match verify_response_signature(&response) {
Ok(valid) => {
if !valid {
logging!(error, Type::Service, true, "服务响应签名验证失败");
bail!("服务响应签名验证失败");
logging!(
error,
Type::Service,
true,
"Service response signature verification failed"
);
bail!("Service response signature verification failed");
}
}
Err(e) => {
logging!(error, Type::Service, true, "验证响应签名时出错: {}", e);
logging!(
error,
Type::Service,
true,
"Error verifying response signature: {}",
e
);
return Err(e);
}
}
@@ -236,7 +299,7 @@ pub async fn send_ipc_request(
info,
Type::Service,
true,
"IPC请求完成: 命令={}, 成功={}",
"IPC request completed: command={}, success={}",
command_type,
response.success
);
@@ -255,14 +318,14 @@ pub async fn send_ipc_request(
) -> Result<IpcResponse> {
use std::os::unix::net::UnixStream;
logging!(info, Type::Service, true, "正在连接服务 (Unix)...");
logging!(info, Type::Service, true, "Connecting to service (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);
logging!(error, Type::Service, true, "Failed to create signed request: {}", e);
return Err(e);
}
};
@@ -271,12 +334,23 @@ pub async fn send_ipc_request(
let mut stream = match UnixStream::connect(IPC_SOCKET_NAME) {
Ok(s) => {
logging!(info, Type::Service, true, "服务连接成功 (Unix)");
logging!(
info,
Type::Service,
true,
"Service connection successful (Unix)"
);
s
}
Err(e) => {
logging!(error, Type::Service, true, "连接到Unix套接字失败: {}", e);
return Err(anyhow::anyhow!("无法连接到服务Unix套接字: {}", e));
logging!(
error,
Type::Service,
true,
"Failed to connect to Unix socket: {}",
e
);
return Err(anyhow::anyhow!("Unable to connect to service Unix socket: {}", e));
}
};
@@ -284,46 +358,58 @@ pub async fn send_ipc_request(
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));
logging!(error, Type::Service, true, "Failed to write request length: {}", e);
return Err(anyhow::anyhow!("Failed to write request length: {}", 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));
logging!(error, Type::Service, true, "Failed to write request body: {}", e);
return Err(anyhow::anyhow!("Failed to write request body: {}", 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));
logging!(error, Type::Service, true, "Failed to read response length: {}", e);
return Err(anyhow::anyhow!("Failed to read response length: {}", 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));
logging!(error, Type::Service, true, "Failed to read response body: {}", e);
return Err(anyhow::anyhow!("Failed to read response body: {}", 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));
logging!(
error,
Type::Service,
true,
"Failed to parse service response: {}",
e,
);
return Err(anyhow::anyhow!("Failed to parse response: {}", e));
}
};
match verify_response_signature(&response) {
Ok(valid) => {
if !valid {
logging!(error, Type::Service, true, "服务响应签名验证失败");
bail!("服务响应签名验证失败");
logging!(error, Type::Service, true, "Service response signature verification failed");
bail!("Service response signature verification failed");
}
}
Err(e) => {
logging!(error, Type::Service, true, "验证响应签名时出错: {}", e);
logging!(
error,
Type::Service,
true,
"Error verifying response signature: {}",
e
);
return Err(e);
}
}
@@ -332,7 +418,7 @@ pub async fn send_ipc_request(
info,
Type::Service,
true,
"IPC请求完成: 命令={}, 成功={}",
"IPC request completed: command={}, success={}",
command_type,
response.success
);

View File

@@ -63,12 +63,16 @@ impl Sysopt {
let proxy_manager = EventDrivenProxyManager::global();
proxy_manager.notify_app_started();
log::info!(target: "app", "已启用事件驱动代理守卫");
log::info!(target: "app", "Event-driven proxy guard enabled");
Ok(())
}
/// init the sysproxy
pub async fn update_sysproxy(&self) -> Result<()> {
if Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip updating sysproxy");
return Ok(());
}
let _lock = self.update_sysproxy.lock().await;
let port = Config::verge()
@@ -185,6 +189,10 @@ impl Sysopt {
/// reset the sysproxy
pub async fn reset_sysproxy(&self) -> Result<()> {
if Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip resetting sysproxy");
return Ok(());
}
let _lock = self.reset_sysproxy.lock().await;
//直接关闭所有代理
#[cfg(not(target_os = "windows"))]
@@ -193,7 +201,7 @@ impl Sysopt {
let mut autoproxy = match Autoproxy::get_auto_proxy() {
Ok(ap) => ap,
Err(e) => {
log::warn!(target: "app", "重置代理时获取自动代理配置失败: {e}, 使用默认配置");
log::warn!(target: "app", "Failed to get auto proxy config while resetting: {e}, using default config");
Autoproxy {
enable: false,
url: "".to_string(),
@@ -248,14 +256,14 @@ impl Sysopt {
{
if is_enable {
if let Err(e) = startup_shortcut::create_shortcut() {
log::error!(target: "app", "创建启动快捷方式失败: {}", e);
log::error!(target: "app", "Failed to create startup shortcut: {}", 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);
log::error!(target: "app", "Failed to remove startup shortcut: {}", e);
self.try_original_autostart_method(is_enable);
} else {
return Ok(());
@@ -290,11 +298,11 @@ impl Sysopt {
{
match startup_shortcut::is_shortcut_enabled() {
Ok(enabled) => {
log::info!(target: "app", "快捷方式自启动状态: {}", enabled);
log::info!(target: "app", "Shortcut auto-launch state: {}", enabled);
return Ok(enabled);
}
Err(e) => {
log::error!(target: "app", "检查快捷方式失败,尝试原来的方法: {}", e);
log::error!(target: "app", "Failed to check shortcut, falling back to original method: {}", e);
}
}
}

View File

@@ -73,7 +73,7 @@ impl Timer {
logging!(
info,
Type::Timer,
"已注册的定时任务数量: {}",
"Registered timer task count: {}",
timer_map.len()
);
@@ -81,7 +81,7 @@ impl Timer {
logging!(
info,
Type::Timer,
"注册了定时任务 - uid={}, interval={}min, task_id={}",
"Registered timer task - uid={}, interval={}min, task_id={}",
uid,
task.interval_minutes,
task.task_id
@@ -100,7 +100,12 @@ impl Timer {
let uid = item.uid.as_ref()?;
if interval > 0 && cur_timestamp - updated >= interval * 60 {
logging!(info, Type::Timer, "需要立即更新的配置: uid={}", uid);
logging!(
info,
Type::Timer,
"Profile requires immediate update: uid={}",
uid
);
Some(uid.clone())
} else {
None
@@ -116,7 +121,7 @@ impl Timer {
logging!(
info,
Type::Timer,
"需要立即更新的配置数量: {}",
"Number of profiles requiring immediate update: {}",
profiles_to_update.len()
);
let timer_map = self.timer_map.read();
@@ -124,7 +129,7 @@ impl Timer {
for uid in profiles_to_update {
if let Some(task) = timer_map.get(&uid) {
logging!(info, Type::Timer, "立即执行任务: uid={}", uid);
logging!(info, Type::Timer, "Executing task immediately: uid={}", uid);
if let Err(e) = delay_timer.advance_task(task.task_id) {
logging!(warn, Type::Timer, "Failed to advance task {}: {}", uid, e);
}
@@ -237,7 +242,7 @@ impl Timer {
logging!(
debug,
Type::Timer,
"找到定时更新配置: uid={}, interval={}min",
"Found scheduled update config: uid={}, interval={}min",
uid,
interval
);
@@ -251,7 +256,7 @@ impl Timer {
logging!(
debug,
Type::Timer,
"生成的定时更新配置数量: {}",
"Generated scheduled update config count: {}",
new_map.len()
);
new_map
@@ -267,7 +272,7 @@ impl Timer {
logging!(
debug,
Type::Timer,
"当前 timer_map 大小: {}",
"Current timer_map size: {}",
timer_map.len()
);
@@ -279,7 +284,7 @@ impl Timer {
logging!(
debug,
Type::Timer,
"定时任务间隔变更: uid={}, ={}, ={}",
"Timer task interval changed: uid={}, old={}, new={}",
uid,
task.interval_minutes,
interval
@@ -288,12 +293,12 @@ impl Timer {
}
None => {
// Task no longer needed
logging!(debug, Type::Timer, "定时任务已删除: uid={}", uid);
logging!(debug, Type::Timer, "Timer task removed: uid={}", uid);
diff_map.insert(uid.clone(), DiffFlag::Del(task.task_id));
}
_ => {
// Task exists with same interval, no change needed
logging!(debug, Type::Timer, "定时任务保持不变: uid={}", uid);
logging!(debug, Type::Timer, "Timer task unchanged: uid={}", uid);
}
}
}
@@ -306,7 +311,7 @@ impl Timer {
logging!(
debug,
Type::Timer,
"新增定时任务: uid={}, interval={}min",
"Added timer task: uid={}, interval={}min",
uid,
interval
);
@@ -320,7 +325,13 @@ impl Timer {
*self.timer_count.lock() = next_id;
}
logging!(debug, Type::Timer, "定时任务变更数量: {}", diff_map.len());
logging!(debug, Type::Timer, "Number of scheduled task changes: {}", diff_map.len());
logging!(
debug,
Type::Timer,
"Number of timer task changes: {}",
diff_map.len()
);
diff_map
}
@@ -363,13 +374,18 @@ impl Timer {
/// Get next update time for a profile
pub fn get_next_update_time(&self, uid: &str) -> Option<i64> {
logging!(info, Type::Timer, "获取下次更新时间,uid={}", uid);
logging!(info, Type::Timer, "Getting next update time, 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);
logging!(
warn,
Type::Timer,
"Corresponding timer task not found, uid={}",
uid
);
return None;
}
};
@@ -380,7 +396,7 @@ impl Timer {
let items = match profiles.get_items() {
Some(i) => i,
None => {
logging!(warn, Type::Timer, "获取配置列表失败");
logging!(warn, Type::Timer, "Failed to get profile list");
return None;
}
};
@@ -388,7 +404,12 @@ impl Timer {
let profile = match items.iter().find(|item| item.uid.as_deref() == Some(uid)) {
Some(p) => p,
None => {
logging!(warn, Type::Timer, "找不到对应的配置uid={}", uid);
logging!(
warn,
Type::Timer,
"Corresponding profile not found, uid={}",
uid
);
return None;
}
};
@@ -401,7 +422,7 @@ impl Timer {
logging!(
info,
Type::Timer,
"计算得到下次更新时间: {}, uid={}",
"Calculated next update time: {}, uid={}",
next_time,
uid
);
@@ -410,7 +431,7 @@ impl Timer {
logging!(
warn,
Type::Timer,
"更新时间或间隔无效,updated={}, interval={}",
"Invalid update time or interval, updated={}, interval={}",
updated,
task.interval_minutes
);
@@ -442,7 +463,7 @@ impl Timer {
logging!(
info,
Type::Timer,
"配置 {} 是否为当前激活配置: {}",
"Is profile {} currently active: {}",
uid,
is_current
);

View File

@@ -3,7 +3,6 @@ use tauri::tray::TrayIconBuilder;
#[cfg(target_os = "macos")]
pub mod speed_rate;
use crate::{
cmd,
config::Config,
feat, logging,
module::{lightweight::is_in_lightweight_mode, mihomo::Rate},
@@ -46,7 +45,7 @@ fn should_handle_tray_click() -> bool {
*last_click = now;
true
} else {
log::debug!(target: "app", "托盘点击被防抖机制忽略,距离上次点击 {:?}ms",
log::debug!(target: "app", "Tray click ignored by debounce; time since last click: {:?}ms",
now.duration_since(*last_click).as_millis());
false
}
@@ -185,11 +184,19 @@ impl Tray {
}
pub fn init(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray initialization");
return Ok(());
}
Ok(())
}
/// 更新托盘点击行为
pub fn update_click_behavior(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray click behavior update");
return Ok(());
}
let app_handle = handle::Handle::global().app_handle().unwrap();
let tray_event = { Config::verge().latest().tray_event.clone() };
let tray_event: String = tray_event.unwrap_or("main_window".into());
@@ -203,6 +210,10 @@ impl Tray {
/// 更新托盘菜单
pub fn update_menu(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray menu update");
return Ok(());
}
// 调整最小更新间隔,确保状态及时刷新
const MIN_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
@@ -231,7 +242,7 @@ impl Tray {
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘菜单失败: app_handle不存在");
log::warn!(target: "app", "Failed to update tray menu: app_handle not found");
return Ok(());
}
};
@@ -279,11 +290,11 @@ impl Tray {
profile_uid_and_name,
is_lightweight_mode,
)?));
log::debug!(target: "app", "托盘菜单更新成功");
log::debug!(target: "app", "Tray menu updated successfully");
Ok(())
}
None => {
log::warn!(target: "app", "更新托盘菜单失败: 托盘不存在");
log::warn!(target: "app", "Failed to update tray menu: tray not found");
Ok(())
}
}
@@ -292,10 +303,14 @@ impl Tray {
/// 更新托盘图标
#[cfg(target_os = "macos")]
pub fn update_icon(&self, _rate: Option<Rate>) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray icon update");
return Ok(());
}
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘图标失败: app_handle不存在");
log::warn!(target: "app", "Failed to update tray icon: app_handle not found");
return Ok(());
}
};
@@ -303,7 +318,7 @@ impl Tray {
let tray = match app_handle.tray_by_id("main") {
Some(tray) => tray,
None => {
log::warn!(target: "app", "更新托盘图标失败: 托盘不存在");
log::warn!(target: "app", "Failed to update tray icon: tray not found");
return Ok(());
}
};
@@ -329,10 +344,14 @@ impl Tray {
#[cfg(not(target_os = "macos"))]
pub fn update_icon(&self, _rate: Option<Rate>) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray icon update");
return Ok(());
}
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘图标失败: app_handle不存在");
log::warn!(target: "app", "Failed to update tray icon: app_handle not found");
return Ok(());
}
};
@@ -340,7 +359,7 @@ impl Tray {
let tray = match app_handle.tray_by_id("main") {
Some(tray) => tray,
None => {
log::warn!(target: "app", "更新托盘图标失败: 托盘不存在");
log::warn!(target: "app", "Failed to update tray icon: tray not found");
return Ok(());
}
};
@@ -362,6 +381,10 @@ impl Tray {
/// 更新托盘显示状态的函数
pub fn update_tray_display(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray display update");
return Ok(());
}
let app_handle = handle::Handle::global().app_handle().unwrap();
let _tray = app_handle.tray_by_id("main").unwrap();
@@ -373,10 +396,14 @@ impl Tray {
/// 更新托盘提示
pub fn update_tooltip(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray tooltip update");
return Ok(());
}
let app_handle = match handle::Handle::global().app_handle() {
Some(handle) => handle,
None => {
log::warn!(target: "app", "更新托盘提示失败: app_handle不存在");
log::warn!(target: "app", "Failed to update tray tooltip: app_handle not found");
return Ok(());
}
};
@@ -384,7 +411,7 @@ impl Tray {
let version = match VERSION.get() {
Some(v) => v,
None => {
log::warn!(target: "app", "更新托盘提示失败: 版本信息不存在");
log::warn!(target: "app", "Failed to update tray tooltip: version info not found");
return Ok(());
}
};
@@ -423,13 +450,17 @@ impl Tray {
current_profile_name
)));
} else {
log::warn!(target: "app", "更新托盘提示失败: 托盘不存在");
log::warn!(target: "app", "Failed to update tray tooltip: tray not found");
}
Ok(())
}
pub fn update_part(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray partial update");
return Ok(());
}
self.update_menu()?;
self.update_icon(None)?;
self.update_tooltip()?;
@@ -443,7 +474,11 @@ impl Tray {
pub fn unsubscribe_traffic(&self) {}
pub fn create_tray_from_handle(&self, app_handle: &AppHandle) -> Result<()> {
log::info!(target: "app", "正在从AppHandle创建系统托盘");
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray creation");
return Ok(());
}
log::info!(target: "app", "Creating system tray from AppHandle");
// 获取图标
let icon_bytes = TrayState::get_common_tray_icon().1;
@@ -491,25 +526,29 @@ impl Tray {
"tun_mode" => feat::toggle_tun_mode(None),
"main_window" => {
use crate::utils::window_manager::WindowManager;
log::info!(target: "app", "Tray点击事件: 显示主窗口");
log::info!(target: "app", "Tray click: show main window");
if crate::module::lightweight::is_in_lightweight_mode() {
log::info!(target: "app", "当前在轻量模式,正在退出轻量模式");
log::info!(target: "app", "Currently in lightweight mode, exiting lightweight mode");
crate::module::lightweight::exit_lightweight_mode();
}
let result = WindowManager::show_main_window();
log::info!(target: "app", "窗口显示结果: {result:?}");
log::info!(target: "app", "Window show result: {result:?}");
}
_ => {}
}
}
});
tray.on_menu_event(on_menu_event);
log::info!(target: "app", "系统托盘创建成功");
log::info!(target: "app", "System tray created successfully");
Ok(())
}
// 托盘统一的状态更新函数
pub fn update_all_states(&self) -> Result<()> {
if handle::Handle::global().is_exiting() {
log::debug!(target: "app", "Application is exiting, skip tray state update");
return Ok(());
}
// 确保所有状态更新完成
self.update_menu()?;
self.update_icon(None)?;
@@ -718,18 +757,18 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}
"open_window" => {
use crate::utils::window_manager::WindowManager;
log::info!(target: "app", "托盘菜单点击: 打开窗口");
log::info!(target: "app", "Tray menu click: open window");
if !should_handle_tray_click() {
return;
}
if crate::module::lightweight::is_in_lightweight_mode() {
log::info!(target: "app", "当前在轻量模式,正在退出");
log::info!(target: "app", "Currently in lightweight mode, exiting");
crate::module::lightweight::exit_lightweight_mode();
}
let result = WindowManager::show_main_window();
log::info!(target: "app", "窗口显示结果: {result:?}");
log::info!(target: "app", "Window show result: {result:?}");
}
"system_proxy" => {
feat::toggle_system_proxy();
@@ -754,7 +793,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
if was_lightweight {
use crate::utils::window_manager::WindowManager;
let result = WindowManager::show_main_window();
log::info!(target: "app", "退出轻量模式后显示主窗口: {result:?}");
log::info!(target: "app", "Show main window after exiting lightweight mode: {result:?}");
}
}
"quit" => {
@@ -768,6 +807,6 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}
if let Err(e) = Tray::global().update_all_states() {
log::warn!(target: "app", "更新托盘状态失败: {e}");
log::warn!(target: "app", "Failed to update tray state: {e}");
}
}

View File

@@ -202,7 +202,9 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
});
let patch_tun = value.as_mapping().cloned().unwrap_or(Mapping::new());
for (key, value) in patch_tun.into_iter() {
tun.insert(key, value);
if !tun.contains_key(&key) {
tun.insert(key, value);
}
}
config.insert("tun".into(), tun.into());
} else {
@@ -239,7 +241,7 @@ pub async fn enhance() -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
.filter(|(s, _)| s.is_support(clash_core.as_ref()))
.map(|(_, c)| c)
.for_each(|item| {
log::debug!(target: "app", "run builtin script {}", item.uid);
log::debug!(target: "app", "run builtin script {0}", item.uid);
if let ChainType::Script(script) = item.data {
match use_script(script, config.to_owned(), "".to_string()) {
Ok((res_config, _)) => {

View File

@@ -141,8 +141,8 @@ fn test_script() {
fn test_escape_unescape() {
let test_string = r#"Hello "World"!\nThis is a test with \u00A9 copyright symbol."#;
let escaped = escape_js_string_for_single_quote(test_string);
println!("Original: {}", test_string);
println!("Escaped: {}", escaped);
println!("Original: {test_string}");
println!("Escaped: {escaped}");
let json_str = r#"{"key":"value","nested":{"key":"value"}}"#;
let parsed = parse_json_safely(json_str).unwrap();

View File

@@ -60,7 +60,7 @@ pub async fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
#[cfg(target_os = "macos")]
{
crate::utils::resolve::restore_public_dns().await;
crate::utils::resolve::set_public_dns("223.6.6.6".to_string()).await;
crate::utils::resolve::set_public_dns("8.8.8.8".to_string()).await;
}
}

View File

@@ -31,7 +31,13 @@ pub async fn update_profile(
option: Option<PrfOption>,
auto_refresh: Option<bool>,
) -> Result<()> {
logging!(info, Type::Config, true, "[订阅更新] 开始更新订阅 {}", uid);
logging!(
info,
Type::Config,
true,
"[Subscription Update] Start updating subscription {}",
uid
);
let auto_refresh = auto_refresh.unwrap_or(true); // 默认为true保持兼容性
let url_opt = {
@@ -41,14 +47,14 @@ pub async fn update_profile(
let is_remote = item.itype.as_ref().is_some_and(|s| s == "remote");
if !is_remote {
log::info!(target: "app", "[订阅更新] {uid} 不是远程订阅,跳过更新");
log::info!(target: "app", "[Subscription Update] {uid} is not a remote subscription, skipping update");
None // 非远程订阅直接更新
} else if item.url.is_none() {
log::warn!(target: "app", "[订阅更新] {uid} 缺少URL无法更新");
log::warn!(target: "app", "[Subscription Update] {uid} is missing URL, cannot update");
bail!("failed to get the profile item url");
} else {
log::info!(target: "app",
"[订阅更新] {} 是远程订阅,URL: {}",
"[Subscription Update] {} is a remote subscription, URL: {}",
uid,
item.url.clone().unwrap()
);
@@ -58,24 +64,24 @@ pub async fn update_profile(
let should_update = match url_opt {
Some((url, opt)) => {
log::info!(target: "app", "[订阅更新] 开始下载新的订阅内容");
log::info!(target: "app", "[Subscription Update] Start downloading new subscription content");
let merged_opt = PrfOption::merge(opt.clone(), option.clone());
// 尝试使用正常设置更新
match PrfItem::from_url(&url, None, None, merged_opt.clone()).await {
Ok(item) => {
log::info!(target: "app", "[订阅更新] 更新订阅配置成功");
log::info!(target: "app", "[Subscription Update] Subscription config updated successfully");
let profiles = Config::profiles();
let mut profiles = profiles.latest();
profiles.update_item(uid.clone(), item)?;
let is_current = Some(uid.clone()) == profiles.get_current();
log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {is_current}");
log::info!(target: "app", "[Subscription Update] Is current active subscription: {is_current}");
is_current && auto_refresh
}
Err(err) => {
// 首次更新失败尝试使用Clash代理
log::warn!(target: "app", "[订阅更新] 正常更新失败: {err}尝试使用Clash代理更新");
log::warn!(target: "app", "[Subscription Update] Normal update failed: {err}, trying to update via Clash proxy");
// 发送通知
handle::Handle::notice_message("update_retry_with_clash", uid.clone());
@@ -92,7 +98,7 @@ pub async fn update_profile(
// 使用Clash代理重试
match PrfItem::from_url(&url, None, None, Some(fallback_opt)).await {
Ok(mut item) => {
log::info!(target: "app", "[订阅更新] 使用Clash代理更新成功");
log::info!(target: "app", "[Subscription Update] Update via Clash proxy succeeded");
// 恢复原始代理设置到item
if let Some(option) = item.option.as_mut() {
@@ -112,11 +118,11 @@ pub async fn update_profile(
handle::Handle::notice_message("update_with_clash_proxy", profile_name);
let is_current = Some(uid.clone()) == profiles.get_current();
log::info!(target: "app", "[订阅更新] 是否为当前使用的订阅: {is_current}");
log::info!(target: "app", "[Subscription Update] Is current active subscription: {is_current}");
is_current && auto_refresh
}
Err(retry_err) => {
log::error!(target: "app", "[订阅更新] 使用Clash代理更新仍然失败: {retry_err}");
log::error!(target: "app", "[Subscription Update] Update via Clash proxy still failed: {retry_err}");
handle::Handle::notice_message(
"update_failed_even_with_clash",
format!("{retry_err}"),
@@ -131,14 +137,30 @@ pub async fn update_profile(
};
if should_update {
logging!(info, Type::Config, true, "[订阅更新] 更新内核配置");
logging!(
info,
Type::Config,
true,
"[Subscription Update] Update core configuration"
);
match CoreManager::global().update_config().await {
Ok(_) => {
logging!(info, Type::Config, true, "[订阅更新] 更新成功");
logging!(
info,
Type::Config,
true,
"[Subscription Update] Update succeeded"
);
handle::Handle::refresh_clash();
}
Err(err) => {
logging!(error, Type::Config, true, "[订阅更新] 更新失败: {}", err);
logging!(
error,
Type::Config,
true,
"[Subscription Update] Update failed: {}",
err
);
handle::Handle::notice_message("update_failed", format!("{err}"));
log::error!(target: "app", "{err}");
}

View File

@@ -2,7 +2,7 @@
use crate::AppHandleManager;
use crate::{
config::Config,
core::{handle, sysopt, CoreManager},
core::{event_driven_proxy::EventDrivenProxyManager, handle, sysopt, CoreManager},
logging,
module::mihomo::MihomoManager,
utils::logging::Type,
@@ -25,14 +25,14 @@ fn open_or_close_dashboard_internal(bypass_debounce: bool) {
use crate::process::AsyncHandler;
use crate::utils::window_manager::WindowManager;
log::info!(target: "app", "Attempting to open/close dashboard (绕过防抖: {bypass_debounce})");
log::info!(target: "app", "Attempting to open/close dashboard (bypass debounce: {bypass_debounce})");
// 热键调用调度到主线程执行,避免 WebView 创建死锁
if bypass_debounce {
log::info!(target: "app", "热键调用,调度到主线程执行窗口操作");
log::info!(target: "app", "Hotkey invoked, dispatching window operation to main thread");
AsyncHandler::spawn(move || async move {
log::info!(target: "app", "主线程中执行热键窗口操作");
log::info!(target: "app", "Executing hotkey window operation on main thread");
if crate::module::lightweight::is_in_lightweight_mode() {
log::info!(target: "app", "Currently in lightweight mode, exiting lightweight mode");
@@ -64,28 +64,34 @@ fn open_or_close_dashboard_internal(bypass_debounce: bool) {
/// 异步优化的应用退出函数
pub fn quit() {
use crate::process::AsyncHandler;
logging!(debug, Type::System, true, "启动退出流程");
logging!(debug, Type::System, true, "Start exit process");
// 获取应用句柄并设置退出标志
let app_handle = handle::Handle::global().app_handle().unwrap();
handle::Handle::global().set_is_exiting();
EventDrivenProxyManager::global().notify_app_stopping();
// 优先关闭窗口,提供立即反馈
if let Some(window) = handle::Handle::global().get_window() {
let _ = window.hide();
log::info!(target: "app", "窗口已隐藏");
log::info!(target: "app", "Window hidden");
}
// 使用异步任务处理资源清理,避免阻塞
AsyncHandler::spawn(move || async move {
logging!(info, Type::System, true, "开始异步清理资源");
logging!(
info,
Type::System,
true,
"Start asynchronous resource cleanup"
);
let cleanup_result = clean_async().await;
logging!(
info,
Type::System,
true,
"资源清理完成,退出代码: {}",
"Resource cleanup completed, exit code: {}",
if cleanup_result { 0 } else { 1 }
);
app_handle.exit(if cleanup_result { 0 } else { 1 });
@@ -95,7 +101,12 @@ pub fn quit() {
async fn clean_async() -> bool {
use tokio::time::{timeout, Duration};
logging!(info, Type::System, true, "开始执行异步清理操作...");
logging!(
info,
Type::System,
true,
"Start executing asynchronous cleanup..."
);
// 1. 处理TUN模式
let tun_task = async {
@@ -112,11 +123,11 @@ async fn clean_async() -> bool {
.await
{
Ok(_) => {
log::info!(target: "app", "TUN模式已禁用");
log::info!(target: "app", "TUN mode disabled");
true
}
Err(_) => {
log::warn!(target: "app", "禁用TUN模式超时");
log::warn!(target: "app", "Timeout disabling TUN mode");
false
}
}
@@ -134,11 +145,11 @@ async fn clean_async() -> bool {
.await
{
Ok(_) => {
log::info!(target: "app", "系统代理已重置");
log::info!(target: "app", "System proxy reset");
true
}
Err(_) => {
log::warn!(target: "app", "重置系统代理超时");
log::warn!(target: "app", "Timeout resetting system proxy");
false
}
}
@@ -148,11 +159,11 @@ async fn clean_async() -> bool {
let core_task = async {
match timeout(Duration::from_secs(3), CoreManager::global().stop_core()).await {
Ok(_) => {
log::info!(target: "app", "核心服务已停止");
log::info!(target: "app", "Core service stopped");
true
}
Err(_) => {
log::warn!(target: "app", "停止核心服务超时");
log::warn!(target: "app", "Timeout stopping core service");
false
}
}
@@ -168,11 +179,11 @@ async fn clean_async() -> bool {
.await
{
Ok(_) => {
log::info!(target: "app", "DNS设置已恢复");
log::info!(target: "app", "DNS settings restored");
true
}
Err(_) => {
log::warn!(target: "app", "恢复DNS设置超时");
log::warn!(target: "app", "Timeout restoring DNS settings");
false
}
}
@@ -192,7 +203,7 @@ async fn clean_async() -> bool {
info,
Type::System,
true,
"异步清理操作完成 - TUN: {}, 代理: {}, 核心: {}, DNS: {}, 总体: {}",
"Asynchronous cleanup completed - TUN: {}, Proxy: {}, Core: {}, DNS: {}, Overall: {}",
tun_success,
proxy_success,
core_success,
@@ -209,7 +220,7 @@ pub fn clean() -> bool {
let (tx, rx) = std::sync::mpsc::channel();
AsyncHandler::spawn(move || async move {
logging!(info, Type::System, true, "开始执行清理操作...");
logging!(info, Type::System, true, "Start executing cleanup...");
// 使用已有的异步清理函数
let cleanup_result = clean_async().await;
@@ -220,7 +231,13 @@ pub fn clean() -> bool {
match rx.recv_timeout(std::time::Duration::from_secs(8)) {
Ok(result) => {
logging!(info, Type::System, true, "清理操作完成,结果: {}", result);
logging!(
info,
Type::System,
true,
"Cleanup completed, result: {}",
result
);
result
}
Err(_) => {
@@ -228,7 +245,7 @@ pub fn clean() -> bool {
warn,
Type::System,
true,
"清理操作超时,返回成功状态避免阻塞"
"Cleanup timed out, returning success to avoid blocking"
);
true
}

View File

@@ -8,9 +8,9 @@ mod process;
mod state;
mod utils;
use crate::{
core::hotkey,
core::{event_driven_proxy::EventDrivenProxyManager, hotkey},
process::AsyncHandler,
utils::{resolve, resolve::resolve_scheme},
utils::resolve,
};
use config::Config;
use std::sync::{Mutex, Once};
@@ -86,7 +86,10 @@ impl AppHandleManager {
#[allow(clippy::panic)]
pub fn run() {
utils::network::NetworkManager::global().init();
// Capture early deep link before any async setup (cold start on macOS)
utils::resolve::capture_early_deep_link_from_args();
utils::network::NetworkManager::global().init();
let _ = utils::dirs::init_portable_flag();
@@ -96,55 +99,82 @@ pub fn run() {
#[cfg(debug_assertions)]
let devtools = tauri_plugin_devtools::init();
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.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());
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| {
// When a second instance is invoked, always show the window
AsyncHandler::spawn(move || async move {
// Exit lightweight mode if active
if crate::module::lightweight::is_in_lightweight_mode() {
logging!(info, Type::System, true, "Second instance detected: exiting lightweight mode");
crate::module::lightweight::exit_lightweight_mode();
// Wait for lightweight mode to fully exit
for _ in 0..50 {
if !crate::module::lightweight::is_in_lightweight_mode() {
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
}
}
// Show the main window
logging!(info, Type::System, true, "Second instance detected: showing main window");
let _ = crate::utils::window_manager::WindowManager::show_main_window();
// Handle deep link if present
if let Some(url) = argv
.iter()
.find(|a| a.starts_with("clash://") || a.starts_with("koala-clash://"))
.cloned()
{
logging!(info, Type::System, true, "Second instance with deep link: {}", url);
resolve::schedule_handle_deep_link(url);
}
});
}))
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
logging!(info, Type::Setup, true, "Starting app initialization...");
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
use tauri_plugin_deep_link::DeepLinkExt;
logging!(info, Type::Setup, true, "注册深层链接...");
logging_error!(Type::System, true, app.deep_link().register_all());
}
// Register deep link handler as early as possible to not miss cold-start events (macOS)
app.deep_link().on_open_url(|event| {
let urls: Vec<String> = event.urls().iter().map(|u| u.to_string()).collect();
logging!(info, Type::Setup, true, "on_open_url received: {:?}", urls);
if let Some(url) = urls.first().cloned() {
resolve::schedule_handle_deep_link(url);
}
});
app.deep_link().on_open_url(|event| {
AsyncHandler::spawn(move || {
let url = event.urls().first().map(|u| u.to_string());
async move {
if let Some(url) = url {
logging_error!(Type::Setup, true, resolve_scheme(url).await);
}
}
});
});
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());
// Ensure URL schemes are registered with the OS (all platforms)
logging!(info, Type::Setup, true, "Registering deep links with OS...");
logging_error!(Type::System, true, app.deep_link().register_all());
// Deep link handler will be registered AFTER core handle init to ensure window creation works
// 窗口管理
logging!(info, Type::Setup, true, "初始化窗口状态管理...");
logging!(
info,
Type::Setup,
true,
"Initializing window state management..."
);
let window_state_plugin = tauri_plugin_window_state::Builder::new()
.with_filename("window_state.json")
.with_state_flags(tauri_plugin_window_state::StateFlags::default())
@@ -154,7 +184,12 @@ pub fn run() {
// 异步处理
let app_handle = app.handle().clone();
AsyncHandler::spawn(move || async move {
logging!(info, Type::Setup, true, "异步执行应用设置...");
logging!(
info,
Type::Setup,
true,
"Executing app setup asynchronously..."
);
match timeout(
Duration::from_secs(30),
resolve::resolve_setup_async(&app_handle),
@@ -162,49 +197,81 @@ pub fn run() {
.await
{
Ok(_) => {
logging!(info, Type::Setup, true, "应用设置成功完成");
logging!(info, Type::Setup, true, "App setup completed successfully");
}
Err(_) => {
logging!(
error,
Type::Setup,
true,
"应用设置超时(30秒),继续执行后续流程"
"App setup timed out (30s), continuing with subsequent steps"
);
}
}
});
logging!(info, Type::Setup, true, "执行主要设置操作...");
logging!(
info,
Type::Setup,
true,
"Executing main setup operations..."
);
logging!(info, Type::Setup, true, "初始化AppHandleManager...");
logging!(info, Type::Setup, true, "Initializing AppHandleManager...");
AppHandleManager::global().init(app.handle().clone());
logging!(info, Type::Setup, true, "初始化核心句柄...");
logging!(info, Type::Setup, true, "Initializing core handle...");
core::handle::Handle::global().init(app.handle());
logging!(info, Type::Setup, true, "初始化配置...");
logging!(info, Type::Setup, true, "Initializing config...");
if let Err(e) = utils::init::init_config() {
logging!(error, Type::Setup, true, "初始化配置失败: {}", e);
logging!(
error,
Type::Setup,
true,
"Failed to initialize config: {}",
e
);
}
logging!(info, Type::Setup, true, "初始化资源...");
logging!(info, Type::Setup, true, "Initializing resources...");
if let Err(e) = utils::init::init_resources() {
logging!(error, Type::Setup, true, "初始化资源失败: {}", e);
logging!(
error,
Type::Setup,
true,
"Failed to initialize resources: {}",
e
);
}
app.manage(Mutex::new(state::proxy::CmdProxyState::default()));
app.manage(Mutex::new(state::lightweight::LightWeightState::default()));
tauri::async_runtime::spawn(async {
tokio::time::sleep(Duration::from_secs(5)).await;
logging!(info, Type::Cmd, true, "Running profile updates at startup...");
if let Err(e) = crate::cmd::update_profiles_on_startup().await {
log::error!("Failed to update profiles on startup: {}", e);
}
});
// If an early deep link was captured from argv, schedule it now (after core and window can be created)
utils::resolve::replay_early_deep_link();
logging!(info, Type::Setup, true, "初始化完成,继续执行");
// (deep link handler already registered above)
tauri::async_runtime::spawn(async {
tokio::time::sleep(Duration::from_secs(5)).await;
logging!(
info,
Type::Cmd,
true,
"Running profile updates at startup..."
);
if let Err(e) = crate::cmd::update_profiles_on_startup().await {
log::error!("Failed to update profiles on startup: {e}");
}
});
logging!(
info,
Type::Setup,
true,
"Initialization completed, continuing"
);
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -324,7 +391,7 @@ pub fn run() {
app.run(|app_handle, e| match e {
tauri::RunEvent::Ready | tauri::RunEvent::Resumed => {
logging!(info, Type::System, true, "应用就绪或恢复");
logging!(info, Type::System, true, "App ready or resumed");
AppHandleManager::global().init(app_handle.clone());
#[cfg(target_os = "macos")]
{
@@ -332,7 +399,7 @@ pub fn run() {
.get_handle()
.get_webview_window("main")
{
logging!(info, Type::Window, true, "设置macOS窗口标题");
logging!(info, Type::Window, true, "Setting macOS window title");
let _ = window.set_title("Koala Clash");
}
}
@@ -357,17 +424,27 @@ pub fn run() {
}
}
tauri::RunEvent::Exit => {
// avoid duplicate cleanup
if core::handle::Handle::global().is_exiting() {
return;
let handle = core::handle::Handle::global();
if handle.is_exiting() {
logging!(
debug,
Type::System,
"Exit event triggered, but exit flow already executed, skip duplicate cleanup"
);
} else {
logging!(debug, Type::System, "Exit event triggered, executing cleanup flow");
handle.set_is_exiting();
EventDrivenProxyManager::global().notify_app_stopping();
feat::clean();
}
feat::clean();
}
tauri::RunEvent::WindowEvent { label, event, .. } => {
if label == "main" {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
#[cfg(target_os = "macos")]
AppHandleManager::global().set_activation_policy_accessory();
if core::handle::Handle::global().is_exiting() {
return;
}
@@ -376,7 +453,12 @@ pub fn run() {
if let Some(window) = core::handle::Handle::global().get_window() {
let _ = window.hide();
} else {
logging!(warn, Type::Window, true, "尝试隐藏窗口但窗口不存在");
logging!(
warn,
Type::Window,
true,
"Tried to hide window but it does not exist"
);
}
}
tauri::WindowEvent::Focused(true) => {

View File

@@ -46,7 +46,7 @@ pub fn run_once_auto_lightweight() {
info,
Type::Lightweight,
true,
"在静默启动的情况下,创建窗口再添加自动进入轻量模式窗口监听器"
"Silent start detected: create window, then attach auto lightweight-mode listener"
);
set_lightweight_mode(false);
enable_auto_light_weight_mode();
@@ -70,7 +70,7 @@ pub fn auto_lightweight_mode_init() {
info,
Type::Lightweight,
true,
"非静默启动直接挂载自动进入轻量模式监听器!"
"Non-silent start: directly attach auto lightweight-mode listener"
);
set_lightweight_mode(true);
enable_auto_light_weight_mode();
@@ -102,26 +102,26 @@ pub fn set_lightweight_mode(value: bool) {
pub fn enable_auto_light_weight_mode() {
Timer::global().init().unwrap();
logging!(info, Type::Lightweight, true, "开启自动轻量模式");
logging!(info, Type::Lightweight, true, "Enable auto lightweight mode");
setup_window_close_listener();
setup_webview_focus_listener();
}
pub fn disable_auto_light_weight_mode() {
logging!(info, Type::Lightweight, true, "关闭自动轻量模式");
logging!(info, Type::Lightweight, true, "Disable auto lightweight mode");
let _ = cancel_light_weight_timer();
cancel_window_close_listener();
}
pub fn entry_lightweight_mode() {
use crate::utils::window_manager::WindowManager;
crate::utils::resolve::reset_ui_ready();
let result = WindowManager::hide_main_window();
logging!(
info,
Type::Lightweight,
true,
"轻量模式隐藏窗口结果: {:?}",
"Lightweight mode window hide result: {:?}",
result
);
@@ -150,7 +150,7 @@ pub fn exit_lightweight_mode() {
info,
Type::Lightweight,
true,
"轻量模式退出操作已在进行中,跳过重复调用"
"Lightweight mode exit already in progress; skipping duplicate call"
);
return;
}
@@ -162,7 +162,7 @@ pub fn exit_lightweight_mode() {
// 确保当前确实处于轻量模式才执行退出操作
if !is_in_lightweight_mode() {
logging!(info, Type::Lightweight, true, "当前不在轻量模式,无需退出");
logging!(info, Type::Lightweight, true, "Not in lightweight mode; skip exit");
return;
}
@@ -192,7 +192,7 @@ fn setup_window_close_listener() -> u32 {
info,
Type::Lightweight,
true,
"监听到关闭请求,开始轻量模式计时"
"Close requested; starting lightweight-mode timer"
);
});
return handler;
@@ -207,7 +207,7 @@ fn setup_webview_focus_listener() -> u32 {
logging!(
info,
Type::Lightweight,
"监听到窗口获得焦点,取消轻量模式计时"
"Window focused; cancel lightweight-mode timer"
);
});
return handler;
@@ -218,7 +218,7 @@ fn setup_webview_focus_listener() -> u32 {
fn cancel_window_close_listener() {
if let Some(window) = handle::Handle::global().get_window() {
window.unlisten(setup_window_close_listener());
logging!(info, Type::Lightweight, true, "取消了窗口关闭监听");
logging!(info, Type::Lightweight, true, "Removed window close listener");
}
}
@@ -243,7 +243,7 @@ fn setup_light_weight_timer() -> Result<()> {
.set_maximum_parallel_runnable_num(1)
.set_frequency_once_by_minutes(once_by_minutes)
.spawn_async_routine(move || async move {
logging!(info, Type::Timer, true, "计时器到期,开始进入轻量模式");
logging!(info, Type::Timer, true, "Timer expired; entering lightweight mode");
entry_lightweight_mode();
})
.context("failed to create timer task")?;
@@ -271,7 +271,7 @@ fn setup_light_weight_timer() -> Result<()> {
info,
Type::Timer,
true,
"计时器已设置,{} 分钟后将自动进入轻量模式",
"Timer set; will auto-enter lightweight mode after {} minute(s)",
once_by_minutes
);
@@ -286,7 +286,7 @@ fn cancel_light_weight_timer() -> Result<()> {
delay_timer
.remove_task(task.task_id)
.context("failed to remove timer task")?;
logging!(info, Type::Timer, true, "计时器已取消");
logging!(info, Type::Timer, true, "Timer canceled");
}
Ok(())

View File

@@ -28,9 +28,9 @@ impl LightWeightState {
pub fn set_lightweight_mode(&mut self, value: bool) -> &Self {
self.is_lightweight = value;
if value {
logging!(info, Type::Lightweight, true, "轻量模式已开启");
logging!(info, Type::Lightweight, true, "Lightweight mode enabled");
} else {
logging!(info, Type::Lightweight, true, "轻量模式已关闭");
logging!(info, Type::Lightweight, true, "Lightweight mode disabled");
}
self
}

View File

@@ -9,7 +9,7 @@ use std::{fs, os::windows::process::CommandExt, 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 appdata = std::env::var("APPDATA").map_err(|_| anyhow!("Unable to obtain APPDATA environment variable"))?;
let startup_dir = Path::new(&appdata)
.join("Microsoft")
@@ -19,7 +19,7 @@ pub fn get_startup_dir() -> Result<PathBuf> {
.join("Startup");
if !startup_dir.exists() {
return Err(anyhow!("Startup 目录不存在: {:?}", startup_dir));
return Err(anyhow!("Startup directory does not exist: {:?}", startup_dir));
}
Ok(startup_dir)
@@ -29,7 +29,7 @@ pub fn get_startup_dir() -> Result<PathBuf> {
#[cfg(target_os = "windows")]
pub fn get_exe_path() -> Result<PathBuf> {
let exe_path =
std::env::current_exe().map_err(|e| anyhow!("无法获取当前可执行文件路径: {}", e))?;
std::env::current_exe().map_err(|e| anyhow!("Unable to obtain the path of the current executable file: {}", e))?;
Ok(exe_path)
}
@@ -41,9 +41,9 @@ pub fn create_shortcut() -> Result<()> {
let startup_dir = get_startup_dir()?;
let shortcut_path = startup_dir.join("Koala-Clash.lnk");
// 如果快捷方式已存在,直接返回成功
// If the shortcut already exists, return success directly
if shortcut_path.exists() {
info!(target: "app", "启动快捷方式已存在");
info!(target: "app", "Startup shortcut already exists");
return Ok(());
}
@@ -59,36 +59,36 @@ pub fn create_shortcut() -> Result<()> {
let output = std::process::Command::new("powershell")
.args(["-Command", &powershell_command])
// 隐藏 PowerShell 窗口
// Hide the PowerShell window
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output()
.map_err(|e| anyhow!("执行 PowerShell 命令失败: {}", e))?;
.map_err(|e| anyhow!("Failed to execute PowerShell command: {}", e))?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("创建快捷方式失败: {}", error_msg));
return Err(anyhow!("Failed to create shortcut: {}", error_msg));
}
info!(target: "app", "成功创建启动快捷方式");
info!(target: "app", "Successfully created startup shortcut");
Ok(())
}
/// 删除快捷方式
/// Remove the shortcut
#[cfg(target_os = "windows")]
pub fn remove_shortcut() -> Result<()> {
let startup_dir = get_startup_dir()?;
let shortcut_path = startup_dir.join("Koala-Clash.lnk");
// 如果快捷方式不存在,直接返回成功
// If the shortcut does not exist, return success directly
if !shortcut_path.exists() {
info!(target: "app", "启动快捷方式不存在,无需删除");
info!(target: "app", "Startup shortcut does not exist, nothing to remove");
return Ok(());
}
// 删除快捷方式
fs::remove_file(&shortcut_path).map_err(|e| anyhow!("删除快捷方式失败: {}", e))?;
// Delete the shortcut
fs::remove_file(&shortcut_path).map_err(|e| anyhow!("Failed to delete shortcut: {}", e))?;
info!(target: "app", "成功删除启动快捷方式");
info!(target: "app", "Successfully removed startup shortcut");
Ok(())
}

View File

@@ -178,9 +178,8 @@ fn init_dns_config() -> Result<()> {
"default-nameserver".into(),
Value::Sequence(vec![
Value::String("system".into()),
Value::String("223.6.6.6".into()),
Value::String("8.8.8.8".into()),
Value::String("2400:3200::1".into()),
Value::String("1.1.1.1".into()),
Value::String("2001:4860:4860::8888".into()),
]),
),
@@ -189,7 +188,8 @@ fn init_dns_config() -> Result<()> {
Value::Sequence(vec![
Value::String("8.8.8.8".into()),
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
Value::String("https://dns.google/dns-query".into()),
Value::String("https://cloudflare-dns.com/dns-query".into()),
]),
),
("fallback".into(), Value::Sequence(vec![])),
@@ -201,8 +201,9 @@ fn init_dns_config() -> Result<()> {
"proxy-server-nameserver".into(),
Value::Sequence(vec![
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
Value::String("tls://223.5.5.5".into()),
Value::String("https://dns.google/dns-query".into()),
Value::String("https://cloudflare-dns.com/dns-query".into()),
Value::String("tls://1.1.1.1".into()),
]),
),
("direct-nameserver".into(), Value::Sequence(vec![])),
@@ -397,6 +398,33 @@ pub fn init_scheme() -> Result<()> {
}
#[cfg(target_os = "macos")]
pub fn init_scheme() -> Result<()> {
use std::process::Command;
use tauri::utils::platform::current_exe;
// Try to re-register the app bundle with LaunchServices to ensure URL schemes are active
if let Ok(exe) = current_exe() {
if let (Some(_parent1), Some(_parent2), Some(app_bundle)) =
(exe.parent(), exe.parent().and_then(|p| p.parent()), exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()))
{
let app_bundle_path = app_bundle.to_string_lossy().into_owned();
let lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
let output = Command::new(lsregister)
.args(["-f", "-R", &app_bundle_path])
.output();
match output {
Ok(out) => {
if !out.status.success() {
log::warn!(target: "app", "lsregister returned non-zero: {:?}", out.status);
} else {
log::info!(target: "app", "Re-registered URL schemes with LaunchServices");
}
}
Err(e) => {
log::warn!(target: "app", "Failed to run lsregister: {e}");
}
}
}
}
Ok(())
}

View File

@@ -65,7 +65,7 @@ impl NetworkManager {
pub fn init(&self) {
self.init.call_once(|| {
self.runtime.spawn(async {
logging!(info, Type::Network, true, "初始化网络管理器");
logging!(info, Type::Network, true, "Initializing network manager");
// 创建无代理客户端
let no_proxy_client = ClientBuilder::new()
@@ -81,7 +81,7 @@ impl NetworkManager {
let mut no_proxy_guard = NETWORK_MANAGER.no_proxy_client.lock().unwrap();
*no_proxy_guard = Some(no_proxy_client);
logging!(info, Type::Network, true, "网络管理器初始化完成");
logging!(info, Type::Network, true, "Network manager initialization completed");
});
});
}
@@ -112,7 +112,7 @@ impl NetworkManager {
}
pub fn reset_clients(&self) {
logging!(info, Type::Network, true, "正在重置所有HTTP客户端");
logging!(info, Type::Network, true, "Resetting all HTTP clients");
{
let mut client = self.self_proxy_client.lock().unwrap();
*client = None;
@@ -340,7 +340,8 @@ impl NetworkManager {
request_builder = request_builder
.header("x-hwid", &sys_info.hwid)
.header("x-device-os", &sys_info.os_type)
.header("x-ver-os", &sys_info.os_ver);
.header("x-ver-os", &sys_info.os_ver)
.header("x-device-model", &sys_info.device_model);
}
request_builder
@@ -409,7 +410,7 @@ impl NetworkManager {
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);
logging!(warn, Type::Network, true, "Request canceled due to timeout: {}", url_clone);
});
let result = tokio::select! {

View File

@@ -3,10 +3,11 @@ use crate::AppHandleManager;
use crate::{
config::{Config, IVerge, PrfItem},
core::*,
core::handle::Handle,
logging, logging_error,
module::lightweight::{self, auto_lightweight_mode_init},
process::AsyncHandler,
utils::{init, logging::Type, server},
utils::{init, logging::Type, server, window_manager::WindowManager},
wrap_err,
};
use anyhow::{bail, Result};
@@ -23,7 +24,6 @@ use tauri::{AppHandle, Manager};
use tokio::net::TcpListener;
use tauri::Url;
use crate::config::PrfOption;
//#[cfg(not(target_os = "linux"))]
// use window_shadows::set_shadow;
@@ -66,6 +66,35 @@ impl Default for UiReadyState {
// 获取UI就绪状态细节
static UI_READY_STATE: OnceCell<Arc<UiReadyState>> = OnceCell::new();
// Early deep link capture on cold start
static EARLY_DEEP_LINK: OnceCell<Mutex<Option<String>>> = OnceCell::new();
// Deduplication for deep links to avoid processing same URL twice in short time
static LAST_DEEP_LINK: OnceCell<Mutex<Option<(String, Instant)>>> = OnceCell::new();
fn get_early_deep_link() -> &'static Mutex<Option<String>> {
EARLY_DEEP_LINK.get_or_init(|| Mutex::new(None))
}
/// Capture deep link from process arguments as early as possible (cold start on macOS)
pub fn capture_early_deep_link_from_args() {
let args: Vec<String> = std::env::args().collect();
if let Some(url) = args.iter().find(|a| a.starts_with("clash://") || a.starts_with("koala-clash://")).cloned() {
println!("[DeepLink][argv] {}", url);
logging!(info, Type::Setup, true, "argv captured deep link: {}", url);
*get_early_deep_link().lock() = Some(url);
} else {
println!("[DeepLink][argv] none: {:?}", args);
logging!(info, Type::Setup, true, "no deep link found in argv at startup: {:?}", args);
}
}
/// If an early deep link was captured before setup, schedule it now
pub fn replay_early_deep_link() {
if let Some(url) = get_early_deep_link().lock().take() {
schedule_handle_deep_link(url);
}
}
fn get_window_creating_lock() -> &'static Mutex<(bool, Instant)> {
WINDOW_CREATING.get_or_init(|| Mutex::new((false, Instant::now())))
}
@@ -74,6 +103,11 @@ fn get_ui_ready() -> &'static Arc<RwLock<bool>> {
UI_READY.get_or_init(|| Arc::new(RwLock::new(false)))
}
/// Check whether the UI has finished initialization on the frontend side
pub fn is_ui_ready() -> bool {
*get_ui_ready().read()
}
fn get_ui_ready_state() -> &'static Arc<UiReadyState> {
UI_READY_STATE.get_or_init(|| Arc::new(UiReadyState::default()))
}
@@ -94,7 +128,10 @@ pub fn update_ui_ready_stage(stage: UiReadyStage) {
pub fn mark_ui_ready() {
let mut ready = get_ui_ready().write();
*ready = true;
logging!(info, Type::Window, true, "UI已标记为完全就绪");
logging!(info, Type::Window, true, "UI marked as fully ready");
// If any deep links were queued while UI was not ready, handle them now
// No queued deep links list anymore; early and runtime deep links are deduped
}
// 重置UI就绪状态
@@ -108,7 +145,83 @@ pub fn reset_ui_ready() {
let mut stage = state.stage.write();
*stage = UiReadyStage::NotStarted;
}
logging!(info, Type::Window, true, "UI就绪状态已重置");
logging!(info, Type::Window, true, "UI readiness state has been reset");
}
/// Schedule robust deep-link handling to avoid races with lightweight mode and window creation
pub fn schedule_handle_deep_link(url: String) {
AsyncHandler::spawn(move || async move {
// Normalize dedup key to the actual subscription URL inside the deep link
let dedup_key = (|| {
if let Ok(parsed) = Url::parse(&url) {
for (k, v) in parsed.query_pairs() {
if k == "url" {
return percent_decode_str(&v).decode_utf8_lossy().to_string();
}
}
}
url.clone()
})();
// Deduplicate: if the same deep/subscription link was handled very recently, skip
{
let now = Instant::now();
let mut last = LAST_DEEP_LINK.get_or_init(|| Mutex::new(None)).lock();
if let Some((prev_url, prev_time)) = last.as_ref() {
if *prev_url == dedup_key && now.duration_since(*prev_time) < Duration::from_secs(5) {
log::warn!(target: "app", "Skip duplicate deep link within 5s: {}", dedup_key);
return;
}
}
*last = Some((dedup_key.clone(), now));
}
// Wait until app handle exists
for i in 0..100u8 {
if Handle::global().app_handle().is_some() {
break;
}
if i % 10 == 0 { logging!(info, Type::Setup, true, "waiting for app handle... ({}ms)", i as u64 * 20); }
tokio::time::sleep(Duration::from_millis(20)).await;
}
// Ensure we are not in lightweight mode (webview destroyed)
lightweight::exit_lightweight_mode();
for _ in 0..150u16 {
if !lightweight::is_in_lightweight_mode() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
// Ensure a window exists ASAP so UI can mount
#[cfg(target_os = "macos")]
{
AppHandleManager::global().set_activation_policy_regular();
}
// If lightweight mode was active, give it a bit of time to unwind before recreating window
if lightweight::is_in_lightweight_mode() {
tokio::time::sleep(Duration::from_millis(200)).await;
}
let _ = WindowManager::show_main_window();
// Ensure profiles directory exists on cold start
if let Ok(dir) = crate::utils::dirs::app_profiles_dir() {
if !dir.exists() {
let _ = std::fs::create_dir_all(&dir);
}
}
// Process deep link (add profile regardless of UI state)
logging!(info, Type::Setup, true, "processing deep link: {}", dedup_key);
if let Err(e) = resolve_scheme(url.clone()).await {
log::error!(target: "app", "Deep link handling failed: {e}");
}
// If UI is ready, small delay to let listeners settle before finishing
if is_ui_ready() {
tokio::time::sleep(Duration::from_millis(120)).await;
}
});
}
pub async fn find_unused_port() -> Result<u16> {
@@ -131,12 +244,12 @@ pub async fn find_unused_port() -> Result<u16> {
/// 异步方式处理启动后的额外任务
pub async fn resolve_setup_async(app_handle: &AppHandle) {
let start_time = std::time::Instant::now();
logging!(info, Type::Setup, true, "开始执行异步设置任务...");
logging!(info, Type::Setup, true, "Starting asynchronous setup tasks...");
if VERSION.get().is_none() {
let version = app_handle.package_info().version.to_string();
VERSION.get_or_init(|| {
logging!(info, Type::Setup, true, "初始化版本信息: {}", version);
logging!(info, Type::Setup, true, "Initializing version information: {}", version);
version.clone()
});
}
@@ -155,40 +268,40 @@ pub async fn resolve_setup_async(app_handle: &AppHandle) {
);
}
logging!(trace, Type::Config, true, "初始化配置...");
logging!(trace, Type::Config, true, "Initializing configuration...");
logging_error!(Type::Config, true, Config::init_config().await);
// 启动时清理冗余的 Profile 文件
logging!(info, Type::Setup, true, "清理冗余的Profile文件...");
logging!(info, Type::Setup, true, "Cleaning redundant profile files...");
let profiles = Config::profiles();
if let Err(e) = profiles.latest().auto_cleanup() {
logging!(warn, Type::Setup, true, "启动时清理Profile文件失败: {}", e);
logging!(warn, Type::Setup, true, "Failed to clean profile files at startup: {}", e);
} else {
logging!(info, Type::Setup, true, "启动时Profile文件清理完成");
logging!(info, Type::Setup, true, "Startup profile files cleanup completed");
}
logging!(trace, Type::Core, true, "启动核心管理器...");
logging!(trace, Type::Core, true, "Starting core manager...");
logging_error!(Type::Core, true, CoreManager::global().init().await);
log::trace!(target: "app", "启动内嵌服务器...");
log::trace!(target: "app", "Starting embedded server...");
server::embed_server();
logging_error!(Type::Tray, true, tray::Tray::global().init());
if let Some(app_handle) = handle::Handle::global().app_handle() {
logging!(info, Type::Tray, true, "创建系统托盘...");
logging!(info, Type::Tray, true, "Creating system tray...");
let result = tray::Tray::global().create_tray_from_handle(&app_handle);
if result.is_ok() {
logging!(info, Type::Tray, true, "系统托盘创建成功");
logging!(info, Type::Tray, true, "System tray created successfully");
} else if let Err(e) = result {
logging!(error, Type::Tray, true, "系统托盘创建失败: {}", e);
logging!(error, Type::Tray, true, "Failed to create system tray: {}", e);
}
} else {
logging!(
error,
Type::Tray,
true,
"无法创建系统托盘: app_handle不存在"
"Unable to create system tray: app_handle missing"
);
}
@@ -224,7 +337,7 @@ pub async fn resolve_setup_async(app_handle: &AppHandle) {
logging_error!(Type::Tray, true, tray::Tray::global().update_part());
logging!(trace, Type::System, true, "初始化热键...");
logging!(trace, Type::System, true, "Initializing hotkeys...");
logging_error!(Type::System, true, hotkey::Hotkey::global().init());
let elapsed = start_time.elapsed();
@@ -232,7 +345,7 @@ pub async fn resolve_setup_async(app_handle: &AppHandle) {
info,
Type::Setup,
true,
"异步设置任务完成,耗时: {:?}",
"Asynchronous task completed, time taken: {:?}",
elapsed
);
@@ -242,7 +355,7 @@ pub async fn resolve_setup_async(app_handle: &AppHandle) {
warn,
Type::Setup,
true,
"异步设置任务耗时较长({:?})",
"Asynchronous task setup takes a long time ({:?})",
elapsed
);
}
@@ -274,12 +387,12 @@ pub fn create_window(is_show: bool) -> bool {
info,
Type::Window,
true,
"开始创建/显示主窗口, is_show={}",
"Creating/showing main window, is_show={}",
is_show
);
if !is_show {
logging!(info, Type::Window, true, "静默模式启动时不创建窗口");
logging!(info, Type::Window, true, "Silent start: do not create window");
lightweight::set_lightweight_mode(true);
handle::Handle::notify_startup_completed();
return false;
@@ -287,21 +400,34 @@ pub fn create_window(is_show: bool) -> bool {
if let Some(app_handle) = handle::Handle::global().app_handle() {
if let Some(window) = app_handle.get_webview_window("main") {
logging!(info, Type::Window, true, "主窗口已存在,将显示现有窗口");
logging!(info, Type::Window, true, "Main window already exists; will try to show it");
if is_show {
if window.is_minimized().unwrap_or(false) {
logging!(info, Type::Window, true, "窗口已最小化,正在取消最小化");
logging!(info, Type::Window, true, "Window is minimized; unminimizing");
let _ = window.unminimize();
}
let _ = window.show();
let _ = window.set_focus();
let show_result = window.show();
let focus_result = window.set_focus();
#[cfg(target_os = "macos")]
{
AppHandleManager::global().set_activation_policy_regular();
// If showing or focusing fails (possibly destroyed webview after lightweight), fallback to recreate
if show_result.is_err() || focus_result.is_err() {
logging!(
warn,
Type::Window,
true,
"Failed to show existing window; will destroy and recreate"
);
let _ = window.destroy();
} else {
#[cfg(target_os = "macos")]
{
AppHandleManager::global().set_activation_policy_regular();
}
return true;
}
} else {
return true;
}
return true;
}
}
@@ -316,7 +442,7 @@ pub fn create_window(is_show: bool) -> bool {
info,
Type::Window,
true,
"窗口创建请求被忽略,因为最近创建过 ({:?}ms)",
"Window creation request ignored because recently created ({:?}ms)",
elapsed.as_millis()
);
return false;
@@ -327,7 +453,7 @@ pub fn create_window(is_show: bool) -> bool {
// ScopeGuard 确保创建状态重置,防止 webview 卡死
let _guard = scopeguard::guard(creating, |mut creating_guard| {
*creating_guard = (false, Instant::now());
logging!(debug, Type::Window, true, "[ScopeGuard] 窗口创建状态已重置");
logging!(debug, Type::Window, true, "[ScopeGuard] Window creation state reset");
});
match tauri::WebviewWindowBuilder::new(
@@ -340,20 +466,20 @@ pub fn create_window(is_show: bool) -> bool {
.decorations(true)
.fullscreen(false)
.inner_size(DEFAULT_WIDTH as f64, DEFAULT_HEIGHT as f64)
.min_inner_size(1000.0, 800.0)
.min_inner_size(1000.0, 700.0)
.visible(true) // 立即显示窗口,避免用户等待
.initialization_script(
r#"
console.log('[Tauri] 窗口初始化脚本开始执行');
console.log('[Tauri] Window init script started');
function createLoadingOverlay() {
if (document.getElementById('initial-loading-overlay')) {
console.log('[Tauri] 加载指示器已存在');
console.log('[Tauri] Loading indicator already exists');
return;
}
console.log('[Tauri] 创建加载指示器');
console.log('[Tauri] Creating loading indicator');
const loadingDiv = document.createElement('div');
loadingDiv.id = 'initial-loading-overlay';
loadingDiv.innerHTML = `
@@ -372,7 +498,7 @@ pub fn create_window(is_show: bool) -> bool {
animation: spin 1s linear infinite;
"></div>
</div>
<div style="font-size: 14px; opacity: 0.7;">Loading Clash Verge...</div>
<div style="font-size: 14px; opacity: 0.7;">Loading Koala Clash...</div>
</div>
<style>
@keyframes spin {
@@ -404,13 +530,13 @@ pub fn create_window(is_show: bool) -> bool {
createLoadingOverlay();
}
console.log('[Tauri] 窗口初始化脚本执行完成');
console.log('[Tauri] Window init script finished');
"#,
)
.build()
{
Ok(newly_created_window) => {
logging!(debug, Type::Window, true, "主窗口实例创建成功");
logging!(debug, Type::Window, true, "Main window instance created successfully");
update_ui_ready_stage(UiReadyStage::NotStarted);
@@ -420,7 +546,7 @@ pub fn create_window(is_show: bool) -> bool {
debug,
Type::Window,
true,
"异步窗口任务开始 (启动已标记完成)"
"Async window task started (startup marked completed)"
);
// 先运行轻量模式检测
@@ -431,7 +557,7 @@ pub fn create_window(is_show: bool) -> bool {
debug,
Type::Window,
true,
"发送 verge://startup-completed 事件"
"Sending verge://startup-completed event"
);
handle::Handle::notify_startup_completed();
@@ -441,7 +567,7 @@ pub fn create_window(is_show: bool) -> bool {
// 立即显示窗口
let _ = window_clone.show();
let _ = window_clone.set_focus();
logging!(info, Type::Window, true, "窗口已立即显示");
logging!(info, Type::Window, true, "Window shown immediately");
#[cfg(target_os = "macos")]
{
AppHandleManager::global().set_activation_policy_regular();
@@ -457,7 +583,7 @@ pub fn create_window(is_show: bool) -> bool {
info,
Type::Window,
true,
"开始监控UI加载状态 (最多{}秒)...",
"Start monitoring UI load status (up to {} seconds)...",
timeout_seconds
);
@@ -476,7 +602,7 @@ pub fn create_window(is_show: bool) -> bool {
debug,
Type::Window,
true,
"UI加载状态检查... ({})",
"UI loading status check... ({}s)",
check_count / 10
);
}
@@ -486,7 +612,7 @@ pub fn create_window(is_show: bool) -> bool {
match wait_result {
Ok(_) => {
logging!(info, Type::Window, true, "UI已完全加载就绪");
logging!(info, Type::Window, true, "UI fully loaded and ready");
// 移除初始加载指示器
if let Some(window) = handle::Handle::global().get_window() {
let _ = window.eval(r#"
@@ -503,7 +629,7 @@ pub fn create_window(is_show: bool) -> bool {
warn,
Type::Window,
true,
"UI加载监控超时({}秒),但窗口已正常显示",
"UI load monitoring timed out ({}s), but window is already visible",
timeout_seconds
);
*get_ui_ready().write() = true;
@@ -511,20 +637,20 @@ pub fn create_window(is_show: bool) -> bool {
}
});
logging!(info, Type::Window, true, "窗口显示流程完成");
logging!(info, Type::Window, true, "Window display flow completed");
} else {
logging!(
debug,
Type::Window,
true,
"is_showfalse,窗口保持隐藏状态"
"is_show is false; keeping window hidden"
);
}
});
true
}
Err(e) => {
logging!(error, Type::Window, true, "主窗口构建失败: {}", e);
logging!(error, Type::Window, true, "Failed to build main window: {}", e);
false
}
}
@@ -556,7 +682,9 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
for (key, value) in link_parsed.query_pairs() {
match key.as_ref() {
"name" => name = Some(value.into_owned()),
"url" => url_param = Some(percent_decode_str(&value).decode_utf8_lossy().to_string()),
"url" => {
url_param = Some(percent_decode_str(&value).decode_utf8_lossy().to_string())
}
_ => {}
}
}
@@ -565,11 +693,12 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
Some(url) => {
log::info!(target:"app", "decoded subscription url: {url}");
create_window(true);
// Deep link inside resolver is now executed via schedule_handle_deep_link
match PrfItem::from_url(url.as_ref(), name, None, None).await {
Ok(item) => {
let uid = item.uid.clone().unwrap();
let _ = wrap_err!(Config::profiles().data().append_item(item));
// If UI not ready yet, message will be queued and flushed on ready
handle::Handle::notice_message("import_sub_url::ok", uid);
}
Err(e) => {

View File

@@ -6,17 +6,172 @@ pub struct SystemInfo {
pub hwid: String,
pub os_type: String,
pub os_ver: String,
pub device_model: String,
}
pub static SYSTEM_INFO: Lazy<SystemInfo> = Lazy::new(|| {
let os_info = os_info::get();
SystemInfo {
hwid: machine_uid::get().unwrap_or_else(|_| "unknown_hwid".to_string()),
os_type: os_info.os_type().to_string(),
os_ver: os_info.version().to_string(),
let hwid = machine_uid::get().unwrap_or_else(|_| "unknown_hwid".to_string());
#[cfg(target_os = "windows")]
{
SystemInfo {
hwid,
os_type: "Windows".to_string(),
os_ver: get_windows_build_name(),
device_model: get_windows_edition(),
}
}
#[cfg(target_os = "macos")]
{
SystemInfo {
hwid,
os_type: "macOS".to_string(),
os_ver: get_macos_version(),
device_model: get_mac_model(),
}
}
#[cfg(target_os = "linux")]
{
SystemInfo {
hwid,
os_type: "Linux".to_string(),
os_ver: get_linux_distro_version(),
device_model: get_linux_distro_name(),
}
}
});
pub fn get_system_info() -> &'static SystemInfo {
&SYSTEM_INFO
}
#[cfg(target_os = "windows")]
fn get_windows_build_name() -> String {
use winreg::enums::*;
use winreg::RegKey;
let hklm = match RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion") {
Ok(key) => key,
Err(_) => return "Unknown".to_string(),
};
// Пытаемся получить DisplayVersion (например, "24H2", "23H2", "22H2")
if let Ok(display_version) = hklm.get_value::<String, _>("DisplayVersion") {
return display_version;
}
// Если DisplayVersion нет, получаем ReleaseId
if let Ok(release_id) = hklm.get_value::<String, _>("ReleaseId") {
return release_id;
}
// В крайнем случае возвращаем номер сборки
if let Ok(build) = hklm.get_value::<String, _>("CurrentBuild") {
return format!("Build {}", build);
}
"Unknown".to_string()
}
#[cfg(target_os = "windows")]
fn get_windows_edition() -> String {
use winreg::enums::*;
use winreg::RegKey;
let hklm = match RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion") {
Ok(key) => key,
Err(_) => return "Windows".to_string(),
};
let product_name = hklm.get_value::<String, _>("ProductName").unwrap_or_else(|_| "Windows".to_string());
product_name
}
#[cfg(target_os = "macos")]
fn get_macos_version() -> String {
use std::process::Command;
let output = Command::new("sw_vers")
.arg("-productVersion")
.output();
match output {
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
_ => {
// Fallback to os_info
let os_info = os_info::get();
os_info.version().to_string()
}
}
}
#[cfg(target_os = "macos")]
fn get_mac_model() -> String {
use std::process::Command;
// Получаем идентификатор модели (например, "MacBookPro18,3")
let output = Command::new("sysctl")
.arg("-n")
.arg("hw.model")
.output();
match output {
Ok(output) if output.status.success() => {
let model = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !model.is_empty() {
return model;
}
}
_ => {}
}
// Если не получилось, пробуем получить marketing name
let output = Command::new("system_profiler")
.arg("SPHardwareDataType")
.output();
if let Ok(output) = output {
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("Model Name:") {
if let Some(name) = line.split(':').nth(1) {
return name.trim().to_string();
}
}
}
}
}
"Mac".to_string()
}
#[cfg(target_os = "linux")]
fn get_linux_distro_version() -> String {
let os_info = os_info::get();
// os_info::Version может содержать версию дистрибутива
let version = os_info.version();
let version_str = version.to_string();
if version_str != "Unknown" && !version_str.is_empty() {
version_str
} else {
"Unknown".to_string()
}
}
#[cfg(target_os = "linux")]
fn get_linux_distro_name() -> String {
let os_info = os_info::get();
// Получаем тип дистрибутива (Ubuntu, Fedora, etc.)
let os_type = os_info.os_type();
format!("{}", os_type)
}

View File

@@ -53,7 +53,7 @@ fn get_window_operation_debounce() -> &'static Mutex<Instant> {
fn should_handle_window_operation() -> bool {
if WINDOW_OPERATION_IN_PROGRESS.load(Ordering::Acquire) {
log::warn!(target: "app", "[防抖] 窗口操作已在进行中,跳过重复调用");
log::warn!(target: "app", "[debounce] Window operation already in progress, skipping duplicate call");
return false;
}
@@ -62,16 +62,16 @@ fn should_handle_window_operation() -> bool {
let now = Instant::now();
let elapsed = now.duration_since(*last_operation);
log::debug!(target: "app", "[防抖] 检查窗口操作间隔: {}ms (需要>={}ms)",
log::debug!(target: "app", "[debounce] Checking window operation interval: {}ms (need >={}ms)",
elapsed.as_millis(), WINDOW_OPERATION_DEBOUNCE_MS);
if elapsed >= Duration::from_millis(WINDOW_OPERATION_DEBOUNCE_MS) {
*last_operation = now;
WINDOW_OPERATION_IN_PROGRESS.store(true, Ordering::Release);
log::info!(target: "app", "[防抖] 窗口操作被允许执行");
log::info!(target: "app", "[debounce] Window operation allowed to execute");
true
} else {
log::warn!(target: "app", "[防抖] 窗口操作被防抖机制忽略,距离上次操作 {}ms < {}ms",
log::warn!(target: "app", "[debounce] Window operation ignored by debounce: {}ms since last < {}ms",
elapsed.as_millis(), WINDOW_OPERATION_DEBOUNCE_MS);
false
}
@@ -127,7 +127,7 @@ impl WindowManager {
finish_window_operation();
});
logging!(info, Type::Window, true, "开始智能显示主窗口");
logging!(info, Type::Window, true, "Starting smart show for main window");
logging!(
debug,
Type::Window,
@@ -140,18 +140,18 @@ impl WindowManager {
match current_state {
WindowState::NotExist => {
logging!(info, Type::Window, true, "窗口不存在,创建新窗口");
logging!(info, Type::Window, true, "Main window not found; creating new window");
if Self::create_new_window() {
logging!(info, Type::Window, true, "窗口创建成功");
logging!(info, Type::Window, true, "Window created successfully");
std::thread::sleep(std::time::Duration::from_millis(100));
WindowOperationResult::Created
} else {
logging!(warn, Type::Window, true, "窗口创建失败");
logging!(warn, Type::Window, true, "Window creation failed");
WindowOperationResult::Failed
}
}
WindowState::VisibleFocused => {
logging!(info, Type::Window, true, "窗口已经可见且有焦点,无需操作");
logging!(info, Type::Window, true, "Window already visible and focused; no action needed");
WindowOperationResult::NoAction
}
WindowState::VisibleUnfocused | WindowState::Minimized | WindowState::Hidden => {
@@ -184,14 +184,14 @@ impl WindowManager {
finish_window_operation();
});
logging!(info, Type::Window, true, "开始切换主窗口显示状态");
logging!(info, Type::Window, true, "Toggling main window visibility");
let current_state = Self::get_main_window_state();
logging!(
info,
Type::Window,
true,
"当前窗口状态: {:?} | 详细状态: {}",
"Current window state: {:?} | Details: {}",
current_state,
Self::get_window_status_info()
);
@@ -199,7 +199,7 @@ impl WindowManager {
match current_state {
WindowState::NotExist => {
// 窗口不存在,创建新窗口
logging!(info, Type::Window, true, "窗口不存在,将创建新窗口");
logging!(info, Type::Window, true, "Main window not found; will create new window");
// 由于已经有防抖保护,直接调用内部方法
if Self::create_new_window() {
WindowOperationResult::Created
@@ -212,26 +212,26 @@ impl WindowManager {
info,
Type::Window,
true,
"窗口可见(焦点状态: {}),将隐藏窗口",
"Window visible (focused: {}), hiding window",
if current_state == WindowState::VisibleFocused {
"有焦点"
"focused"
} else {
"无焦点"
"unfocused"
}
);
if let Some(window) = Self::get_main_window() {
match window.hide() {
Ok(_) => {
logging!(info, Type::Window, true, "窗口已成功隐藏");
logging!(info, Type::Window, true, "Window hidden successfully");
WindowOperationResult::Hidden
}
Err(e) => {
logging!(warn, Type::Window, true, "隐藏窗口失败: {}", e);
logging!(warn, Type::Window, true, "Failed to hide window: {}", e);
WindowOperationResult::Failed
}
}
} else {
logging!(warn, Type::Window, true, "无法获取窗口实例");
logging!(warn, Type::Window, true, "Unable to get window instance");
WindowOperationResult::Failed
}
}
@@ -240,12 +240,12 @@ impl WindowManager {
info,
Type::Window,
true,
"窗口存在但被隐藏或最小化,将激活窗口"
"Window exists but is hidden or minimized; activating"
);
if let Some(window) = Self::get_main_window() {
Self::activate_window(&window)
} else {
logging!(warn, Type::Window, true, "无法获取窗口实例");
logging!(warn, Type::Window, true, "Unable to get window instance");
WindowOperationResult::Failed
}
}
@@ -254,35 +254,35 @@ impl WindowManager {
/// 激活窗口(取消最小化、显示、设置焦点)
fn activate_window(window: &WebviewWindow<Wry>) -> WindowOperationResult {
logging!(info, Type::Window, true, "开始激活窗口");
logging!(info, Type::Window, true, "Starting to activate window");
let mut operations_successful = true;
// 1. 如果窗口最小化,先取消最小化
if window.is_minimized().unwrap_or(false) {
logging!(info, Type::Window, true, "窗口已最小化,正在取消最小化");
logging!(info, Type::Window, true, "Window minimized; unminimizing");
if let Err(e) = window.unminimize() {
logging!(warn, Type::Window, true, "取消最小化失败: {}", e);
logging!(warn, Type::Window, true, "Failed to unminimize window: {}", e);
operations_successful = false;
}
}
// 2. 显示窗口
if let Err(e) = window.show() {
logging!(warn, Type::Window, true, "显示窗口失败: {}", e);
logging!(warn, Type::Window, true, "Failed to show window: {}", e);
operations_successful = false;
}
// 3. 设置焦点
if let Err(e) = window.set_focus() {
logging!(warn, Type::Window, true, "设置窗口焦点失败: {}", e);
logging!(warn, Type::Window, true, "Failed to set window focus: {}", e);
operations_successful = false;
}
// 4. 平台特定的激活策略
#[cfg(target_os = "macos")]
{
logging!(info, Type::Window, true, "应用 macOS 特定的激活策略");
logging!(info, Type::Window, true, "Applying macOS-specific activation policy");
AppHandleManager::global().set_activation_policy_regular();
}
@@ -294,7 +294,7 @@ impl WindowManager {
debug,
Type::Window,
true,
"设置置顶失败(非关键错误): {}",
"Failed to set always-on-top (non-critical): {}",
e
);
}
@@ -304,38 +304,38 @@ impl WindowManager {
debug,
Type::Window,
true,
"取消置顶失败(非关键错误): {}",
"Failed to unset always-on-top (non-critical): {}",
e
);
}
}
if operations_successful {
logging!(info, Type::Window, true, "窗口激活成功");
logging!(info, Type::Window, true, "Window activation successful");
WindowOperationResult::Shown
} else {
logging!(warn, Type::Window, true, "窗口激活部分失败");
logging!(warn, Type::Window, true, "Window activation partially failed");
WindowOperationResult::Failed
}
}
/// 隐藏主窗口
pub fn hide_main_window() -> WindowOperationResult {
logging!(info, Type::Window, true, "开始隐藏主窗口");
logging!(info, Type::Window, true, "Starting to hide main window");
match Self::get_main_window() {
Some(window) => match window.hide() {
Ok(_) => {
logging!(info, Type::Window, true, "窗口已隐藏");
logging!(info, Type::Window, true, "Window hidden");
WindowOperationResult::Hidden
}
Err(e) => {
logging!(warn, Type::Window, true, "隐藏窗口失败: {}", e);
logging!(warn, Type::Window, true, "Failed to hide window: {}", e);
WindowOperationResult::Failed
}
},
None => {
logging!(info, Type::Window, true, "窗口不存在,无需隐藏");
logging!(info, Type::Window, true, "Window does not exist; nothing to hide");
WindowOperationResult::NoAction
}
}
@@ -376,7 +376,7 @@ impl WindowManager {
let is_minimized = Self::is_main_window_minimized();
format!(
"窗口状态: {state:?} | 可见: {is_visible} | 有焦点: {is_focused} | 最小化: {is_minimized}"
"WindowState: {state:?} | visible: {is_visible} | focused: {is_focused} | minimized: {is_minimized}"
)
}
}

View File

@@ -1,5 +1,5 @@
{
"version": "0.2.5",
"version": "0.2.8",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
@@ -11,9 +11,15 @@
"icons/icon.icns",
"icons/icon.ico"
],
"resources": ["resources", "resources/locales/*"],
"resources": [
"resources",
"resources/locales/*"
],
"publisher": "Koala Clash",
"externalBin": ["sidecar/koala-mihomo", "sidecar/koala-mihomo-alpha"],
"externalBin": [
"sidecar/koala-mihomo",
"sidecar/koala-mihomo-alpha"
],
"copyright": "GNU General Public License v3.0",
"category": "DeveloperTool",
"shortDescription": "Koala Clash",
@@ -40,18 +46,28 @@
},
"deep-link": {
"desktop": {
"schemes": ["clash", "koala-clash"]
"schemes": [
"clash",
"koala-clash"
]
}
}
},
"app": {
"security": {
"capabilities": ["desktop-capability", "migrated"],
"capabilities": [
"desktop-capability",
"migrated"
],
"assetProtocol": {
"scope": ["$APPDATA/**", "$RESOURCE/../**", "**"],
"scope": [
"$APPDATA/**",
"$RESOURCE/../**",
"**"
],
"enable": true
},
"csp": null
}
}
}
}

View File

@@ -31,4 +31,3 @@
}
}
}

View File

@@ -3,9 +3,9 @@ import Layout from "./pages/_layout";
function App() {
return (
<AppDataProvider>
<Layout />
</AppDataProvider>
<AppDataProvider>
<Layout />
</AppDataProvider>
);
}
export default App;

View File

@@ -1,15 +1,14 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import React, { useMemo, useState, useEffect, RefObject } from "react";
import React, { useMemo, useState, useEffect, useRef } from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
Row,
Header,
ColumnSizingState,
} from "@tanstack/react-table";
import { TableVirtuoso, TableComponents } from "react-virtuoso";
import {
Table,
@@ -27,7 +26,30 @@ import { cn } from "@root/lib/utils";
dayjs.extend(relativeTime);
// Интерфейс для строки данных, которую использует react-table
interface IConnectionsItem {
id: string;
metadata: {
host: string;
destinationIP: string;
destinationPort: string;
remoteDestination: string;
process?: string;
processPath?: string;
sourceIP: string;
sourcePort: string;
type: string;
network: string;
};
rule: string;
rulePayload?: string;
chains: string[];
download: number;
upload: number;
curDownload?: number;
curUpload?: number;
start: string;
}
interface ConnectionRow {
id: string;
host: string;
@@ -45,22 +67,78 @@ interface ConnectionRow {
connectionData: IConnectionsItem;
}
// Интерфейс для пропсов, которые компонент получает от родителя
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
scrollerRef: (element: HTMLElement | Window | null) => void;
}
const ColumnResizer = ({
header,
}: {
header: Header<ConnectionRow, unknown>;
}) => {
return (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none",
"bg-transparent hover:bg-primary/50 active:bg-primary",
"transition-colors duration-150",
header.column.getIsResizing() && "bg-primary",
)}
style={{
transform: header.column.getIsResizing() ? `translateX(0px)` : "",
}}
/>
);
};
export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail, scrollerRef } = props;
const tableContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (tableContainerRef.current && scrollerRef) {
scrollerRef(tableContainerRef.current);
}
}, [scrollerRef]);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
try {
const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {};
return saved
? JSON.parse(saved)
: {
host: 220,
download: 88,
upload: 88,
dlSpeed: 88,
ulSpeed: 88,
chains: 340,
rule: 280,
process: 220,
time: 120,
source: 200,
remoteDestination: 200,
type: 160,
};
} catch {
return {};
return {
host: 220,
download: 88,
upload: 88,
dlSpeed: 88,
ulSpeed: 88,
chains: 340,
rule: 280,
process: 220,
time: 120,
source: 200,
remoteDestination: 200,
type: 160,
};
}
});
@@ -107,13 +185,16 @@ export const ConnectionTable = (props: Props) => {
header: () => t("Host"),
size: columnSizing?.host || 220,
minSize: 180,
maxSize: 400,
},
{
accessorKey: "download",
header: () => t("Downloaded"),
size: columnSizing?.download || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
@@ -122,8 +203,10 @@ export const ConnectionTable = (props: Props) => {
accessorKey: "upload",
header: () => t("Uploaded"),
size: columnSizing?.upload || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}
</div>
),
@@ -132,8 +215,10 @@ export const ConnectionTable = (props: Props) => {
accessorKey: "dlSpeed",
header: () => t("DL Speed"),
size: columnSizing?.dlSpeed || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
@@ -142,8 +227,10 @@ export const ConnectionTable = (props: Props) => {
accessorKey: "ulSpeed",
header: () => t("UL Speed"),
size: columnSizing?.ulSpeed || 88,
minSize: 80,
maxSize: 150,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{parseTraffic(getValue<number>()).join(" ")}/s
</div>
),
@@ -153,26 +240,30 @@ export const ConnectionTable = (props: Props) => {
header: () => t("Chains"),
size: columnSizing?.chains || 340,
minSize: 180,
maxSize: 500,
},
{
accessorKey: "rule",
header: () => t("Rule"),
size: columnSizing?.rule || 280,
minSize: 180,
maxSize: 400,
},
{
accessorKey: "process",
header: () => t("Process"),
size: columnSizing?.process || 220,
minSize: 180,
maxSize: 350,
},
{
accessorKey: "time",
header: () => t("Time"),
size: columnSizing?.time || 120,
minSize: 100,
maxSize: 180,
cell: ({ getValue }) => (
<div className="text-right">
<div className="text-right font-mono text-sm">
{dayjs(getValue<string>()).fromNow()}
</div>
),
@@ -182,18 +273,21 @@ export const ConnectionTable = (props: Props) => {
header: () => t("Source"),
size: columnSizing?.source || 200,
minSize: 130,
maxSize: 300,
},
{
accessorKey: "remoteDestination",
header: () => t("Destination"),
size: columnSizing?.remoteDestination || 200,
minSize: 130,
maxSize: 300,
},
{
accessorKey: "type",
header: () => t("Type"),
size: columnSizing?.type || 160,
minSize: 100,
maxSize: 220,
},
],
[columnSizing],
@@ -206,92 +300,91 @@ export const ConnectionTable = (props: Props) => {
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
enableColumnResizing: true,
});
const VirtuosoTableComponents = useMemo<TableComponents<Row<ConnectionRow>>>(
() => ({
// Явно типизируем `ref` для каждого компонента
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
<div className="h-full" {...props} ref={ref} />
)),
Table: (props) => <Table {...props} className="w-full border-collapse" />,
TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableHeader {...props} ref={ref} />
)),
// Явно типизируем пропсы и `ref` для TableRow
TableRow: React.forwardRef<
HTMLTableRowElement,
{ item: Row<ConnectionRow> } & React.HTMLAttributes<HTMLTableRowElement>
>(({ item: row, ...props }, ref) => {
// `Virtuoso` передает нам готовую строку `row` в пропсе `item`.
// Больше не нужно искать ее по индексу!
return (
<TableRow
{...props}
ref={ref}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onShowDetail(row.original.connectionData)}
/>
);
}),
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableBody {...props} ref={ref} />
)),
}),
[],
);
const totalTableWidth = useMemo(() => {
return table.getCenterTotalSize();
}, [table.getState().columnSizing]);
if (connRows.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">{t("No connections")}</p>
</div>
);
}
return (
<div className="h-full rounded-md border overflow-hidden">
{connRows.length > 0 ? (
<TableVirtuoso
scrollerRef={scrollerRef}
data={table.getRowModel().rows}
components={VirtuosoTableComponents}
fixedHeaderContent={() =>
table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="hover:bg-transparent bg-background/95 backdrop-blur"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className="p-2"
>
<div className="rounded-md border relative bg-background">
<Table
className="w-full border-collapse table-fixed"
style={{
width: totalTableWidth,
minWidth: "100%",
}}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="hover:bg-transparent border-b-0 h-10"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={cn(
"sticky top-0 z-10",
"p-2 text-xs font-semibold select-none border-r last:border-r-0 bg-background h-10",
)}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize,
}}
>
<div className="flex items-center justify-between h-full">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))
}
itemContent={(index, row) => (
<>
</div>
{header.column.getCanResize() && (
<ColumnResizer header={header} />
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => onShowDetail(row.original.connectionData)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: cell.column.getSize() }}
className="p-2 whitespace-nowrap"
onClick={() => onShowDetail(row.original.connectionData)}
className="p-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm border-r last:border-r-0"
style={{
width: cell.column.getSize(),
minWidth: cell.column.columnDef.minSize,
maxWidth: cell.column.columnDef.maxSize,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</>
)}
/>
) : (
<div className="flex h-full items-center justify-center">
<p>No results.</p>
</div>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -1,64 +1,123 @@
import React from 'react';
import { cn } from '@root/lib/utils';
import { Power } from 'lucide-react';
import React from "react";
import { motion, HTMLMotionProps, Transition, AnimatePresence } from "framer-motion";
import { cn } from "@root/lib/utils";
import { Power } from "lucide-react";
export interface PowerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface PowerButtonProps extends HTMLMotionProps<"button"> {
checked?: boolean;
loading?: boolean;
}
export const PowerButton = React.forwardRef<HTMLButtonElement, PowerButtonProps>(
({ className, checked = false, loading = false, ...props }, ref) => {
const state = checked ? 'on' : 'off';
const state = checked ? "on" : "off";
// Единые, мягкие настройки для всех пружинных анимаций
const sharedSpring: Transition = {
type: "spring",
stiffness: 100,
damping: 30,
mass: 1,
};
const glowColors = {
on: "rgba(74, 222, 128, 0.6)",
off: "rgba(239, 68, 68, 0.4)",
};
const shadows = {
on: "0px 0px 50px rgba(34, 197, 94, 1)",
off: "0px 0px 30px rgba(239, 68, 68, 0.6)",
disabled: "none",
};
const textColors = {
on: "rgb(255, 255, 255)",
off: "rgb(239, 68, 68)",
disabled: "rgb(100, 116, 139)",
};
const isDisabled = props.disabled && !loading;
const currentShadow = isDisabled ? shadows.disabled : checked ? shadows.on : shadows.off;
const currentColor = isDisabled ? textColors.disabled : checked ? textColors.on : textColors.off;
return (
<div className="relative flex items-center justify-center h-44 w-44">
<div
className={cn(
'absolute h-28 w-28 rounded-full blur-3xl transition-all duration-500',
state === 'on' ? 'bg-green-400/60' : 'bg-red-500/40',
props.disabled && 'opacity-0'
)}
<motion.div
className="absolute h-28 w-28 rounded-full blur-3xl"
animate={{
backgroundColor: state === "on" ? glowColors.on : glowColors.off,
opacity: isDisabled ? 0 : checked ? 1 : 0.3,
scale: checked ? 1.2 : 0.8,
}}
transition={sharedSpring}
/>
<button
<motion.div
className="absolute h-40 w-40 rounded-full blur-[60px]"
animate={{
backgroundColor: checked ? "rgba(34, 197, 94, 0.2)" : "rgba(239, 68, 68, 0.1)",
opacity: isDisabled ? 0 : checked ? 0.8 : 0,
scale: checked ? 1.4 : 0.6,
}}
transition={sharedSpring}
/>
<motion.button
ref={ref}
type="button"
disabled={loading || props.disabled}
data-state={state}
animate={{
scale: checked ? 1.1 : 0.9,
boxShadow: currentShadow,
color: currentColor,
}}
whileHover={{ scale: checked ? 1.15 : 0.95 }}
whileTap={{ scale: checked ? 1.05 : 0.85 }}
transition={sharedSpring}
className={cn(
'relative z-10 flex items-center justify-center h-36 w-36 rounded-full border-2',
'backdrop-blur-sm bg-white/10 border-white/20',
'text-red-500 shadow-[0_0_30px_rgba(239,68,68,0.6)]',
'data-[state=on]:text-green-500 dark:data-[state=on]:text-white',
'data-[state=on]:shadow-[0_0_50px_rgba(34,197,94,1)]',
'transition-all duration-300 hover:scale-105 active:scale-95 focus:outline-none',
'disabled:cursor-not-allowed disabled:scale-100',
// Стили ТОЛЬКО для отключенного состояния (но не для загрузки)
(props.disabled && !loading) && 'grayscale opacity-50 shadow-none bg-slate-100/70 border-slate-300/80',
"group",
"relative z-10 flex items-center justify-center h-36 w-36 rounded-full border-2",
"backdrop-blur-sm bg-white/10 border-white/20",
"focus:outline-none",
"disabled:cursor-not-allowed",
isDisabled && "grayscale opacity-50 bg-slate-100/70 border-slate-300/80",
className
)}
{...props}
>
<Power className={cn(
"h-20 w-20",
!props.disabled && "active:scale-90 transition-transform duration-300"
)} />
</button>
<motion.span
className="flex items-center justify-center"
animate={{ scale: checked ? 1 / 1.1 : 1 }}
whileTap={{ scale: 0.95 }}
transition={sharedSpring}
>
<Power className="h-20 w-20" />
</motion.span>
</motion.button>
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className={cn(
'h-full w-full animate-spin rounded-full border-4',
'border-transparent',
checked ? 'border-t-green-500' : 'border-t-red-500',
'blur-xs'
)} />
</div>
)}
<AnimatePresence>
{loading && (
<motion.div
key="pb-loader"
className="absolute inset-0 z-20 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<div
className={cn(
"h-full w-full animate-spin rounded-full border-4",
"border-transparent",
checked ? "border-t-green-500" : "border-t-red-500",
"blur-xs"
)}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
);
);

View File

@@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@root/lib/utils";
// Компоненты и иконки
import {
Select,
SelectContent,
@@ -18,15 +17,13 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChevronsUpDown, Timer, WholeWord } from "lucide-react";
import {AlertTriangle, ChevronsUpDown, Timer, WholeWord} from "lucide-react";
// Логика
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
import delayManager from "@/services/delay";
import { updateProxy, deleteConnection } from "@/services/api";
// --- Типы и константы ---
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
@@ -40,16 +37,16 @@ interface IProxyGroup {
icon?: string;
}
// --- Вспомогательная функция для цвета задержки ---
function getDelayBadgeVariant(
delayValue: number,
): "default" | "secondary" | "destructive" | "outline" {
if (delayValue < 0) return "secondary";
if (delayValue >= 150) return "destructive";
return "default";
function getDelayColorClasses(delayValue: number): string {
if (delayValue < 0) {
return "text-muted-foreground border-border";
}
if (delayValue >= 150) {
return "text-destructive border-destructive/40";
}
return "text-green-600 border-green-500/40 dark:text-green-400 dark:border-green-400/30";
}
// --- Дочерний компонент для элемента списка с "живым" обновлением пинга ---
const ProxySelectItem = ({
proxyName,
groupName,
@@ -80,20 +77,21 @@ const ProxySelectItem = ({
}, [proxyName, groupName]);
return (
<SelectItem key={proxyName} value={proxyName}>
<div className="flex items-center justify-between w-full">
<span className="truncate">{proxyName}</span>
<Badge
variant={getDelayBadgeVariant(delay)}
className={cn(
"ml-4 flex-shrink-0 px-2 h-5 justify-center transition-colors duration-300",
isJustUpdated && "bg-primary/20 border-primary/50",
)}
>
{delay < 0 || delay > 10000 ? "---" : delay}
</Badge>
</div>
</SelectItem>
<SelectItem key={proxyName} value={proxyName}>
<div className="flex items-center justify-between w-full">
<Badge
variant="outline"
className={cn(
"mr-2 flex-shrink-0 px-2 h-5 w-8 justify-center transition-colors duration-300",
getDelayColorClasses(delay),
isJustUpdated && "bg-primary/10 border-primary/50",
)}
>
{delay < 0 || delay > 10000 ? "---" : delay}
</Badge>
<span className="truncate">{proxyName}</span>
</div>
</SelectItem>
);
};
@@ -259,11 +257,20 @@ export const ProxySelectors: React.FC = () => {
?.all;
if (sourceList) {
options = sourceList
.map((proxy: any) => ({
name: typeof proxy === "string" ? proxy : proxy.name,
}))
.filter((p: { name: string }) => p.name);
const rawOptions = sourceList
.map((proxy: any) => ({
name: typeof proxy === "string" ? proxy : proxy.name,
}))
.filter((p: { name: string }) => p.name);
const uniqueNames = new Set<string>();
options = rawOptions.filter((proxy: any) => {
if (!uniqueNames.has(proxy.name)) {
uniqueNames.add(proxy.name);
return true;
}
return false;
});
}
if (sortType === "name")
@@ -293,11 +300,20 @@ export const ProxySelectors: React.FC = () => {
disabled={isGlobalMode || isDirectMode}
>
<SelectTrigger className="w-100">
{isGlobalMode ? (
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4 text-destructive" />
<span className="font-medium text-sm">
{t("Global Mode Active")}
</span>
</div>
) : (
<div className="flex items-center gap-2 truncate">
<span className="truncate">
<SelectValue placeholder={t("Select a group...")} />
</span>
</div>
)}
</SelectTrigger>
<SelectContent>
{selectorGroups.map((group: IProxyGroup) => (
@@ -345,44 +361,37 @@ export const ProxySelectors: React.FC = () => {
{sortType === "name" && <WholeWord className="h-4 w-4" />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{sortType === "default" && <p>{t("Sort by default")}</p>}
{sortType === "delay" && <p>{t("Sort by delay")}</p>}
{sortType === "name" && <p>{t("Sort by name")}</p>}
</TooltipContent>
</Tooltip>
</div>
<Select
value={selectedProxy}
onValueChange={handleProxyChange}
disabled={isDirectMode}
onOpenChange={handleProxyListOpen}
>
<SelectTrigger className="w-100">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
<SelectValue placeholder={t("Select a proxy...")} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{selectedProxy}</p>
{sortType === "default" && <p>{t("Sort by default")}</p>}
{sortType === "delay" && <p>{t("Sort by delay")}</p>}
{sortType === "name" && <p>{t("Sort by name")}</p>}
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent>
{proxyOptions.map((proxy) => (
<ProxySelectItem
key={proxy.name}
proxyName={proxy.name}
groupName={selectedGroup}
/>
))}
</SelectContent>
</Select>
</div>
<Select
value={selectedProxy}
onValueChange={handleProxyChange}
disabled={isDirectMode}
onOpenChange={handleProxyListOpen}
>
<SelectTrigger className="w-100">
<span className="truncate">
<SelectValue placeholder={t("Select a proxy...")} />
</span>
</SelectTrigger>
<SelectContent>
{proxyOptions.map((proxy) => (
<ProxySelectItem
key={proxy.name}
proxyName={proxy.name}
groupName={selectedGroup}
/>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</TooltipProvider>
</TooltipProvider>
);
};

View File

@@ -1,16 +1,18 @@
import { Link } from 'react-router-dom';
import { Link } from "react-router-dom";
import {
Sidebar,
SidebarContent, SidebarFooter,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem, useSidebar,
} from "@/components/ui/sidebar"
import { t } from 'i18next';
import { cn } from '@root/lib/utils';
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { t } from "i18next";
import { cn } from "@root/lib/utils";
import {
Home,
@@ -19,21 +21,22 @@ import {
Cable,
ListChecks,
FileText,
Settings, EarthLock,
} from 'lucide-react';
Settings,
EarthLock,
} from "lucide-react";
import { UpdateButton } from "@/components/layout/update-button";
import React from "react";
import { SheetClose } from '@/components/ui/sheet';
import logo from "@/assets/image/logo.png"
import { SheetClose } from "@/components/ui/sheet";
import logo from "@/assets/image/logo.png";
const menuItems = [
{ title: 'Home', url: '/home', icon: Home },
{ title: 'Profiles', url: '/profile', icon: Users },
{ title: 'Proxies', url: '/proxies', icon: Server },
{ title: 'Connections', url: '/connections', icon: Cable },
{ title: 'Rules', url: '/rules', icon: ListChecks },
{ title: 'Logs', url: '/logs', icon: FileText },
{ title: 'Settings', url: '/settings', icon: Settings },
{ title: "Home", url: "/home", icon: Home },
{ title: "Profiles", url: "/profile", icon: Users },
{ title: "Proxies", url: "/proxies", icon: Server },
{ title: "Connections", url: "/connections", icon: Cable },
{ title: "Rules", url: "/rules", icon: ListChecks },
{ title: "Logs", url: "/logs", icon: FileText },
{ title: "Settings", url: "/settings", icon: Settings },
];
export function AppSidebar() {
@@ -41,18 +44,15 @@ export function AppSidebar() {
return (
<Sidebar variant="floating" collapsible="icon">
<SidebarHeader>
<SidebarMenuButton size="lg"
<SidebarMenuButton
size="lg"
className={cn(
"flex h-12 items-center transition-all duration-200",
"group-data-[state=expanded]:w-full group-data-[state=expanded]:gap-2 group-data-[state=expanded]:px-3",
"group-data-[state=collapsed]:w-full group-data-[state=collapsed]:justify-center"
"group-data-[state=collapsed]:w-full group-data-[state=collapsed]:justify-center",
)}
>
<img
src={logo}
alt="logo"
className="h-6 w-6 flex-shrink-0"
/>
<img src={logo} alt="logo" className="h-6 w-6 flex-shrink-0" />
<span className="font-semibold whitespace-nowrap group-data-[state=collapsed]:hidden">
Koala Clash
</span>
@@ -69,30 +69,29 @@ export function AppSidebar() {
key={item.title}
to={item.url}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary',
'data-[active=true]:font-semibold data-[active=true]:border'
"flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary",
"data-[active=true]:font-semibold data-[active=true]:border",
)}
>
<item.icon className="h-4 w-4 drop-shadow-md" />
{t(item.title)}
</Link>
)
);
return (
<SidebarMenuItem key={item.title} className="my-1">
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={t(item.title)}>
{isMobile ? (
<SheetClose asChild>
{linkElement}
</SheetClose>
<SidebarMenuItem key={item.title} className="my-1">
<SidebarMenuButton
asChild
isActive={isActive}
tooltip={t(item.title)}
>
{isMobile ? (
<SheetClose asChild>{linkElement}</SheetClose>
) : (
linkElement
)}
</SidebarMenuButton>
</SidebarMenuItem>
)
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
@@ -104,5 +103,5 @@ export function AppSidebar() {
</div>
</SidebarFooter>
</Sidebar>
)
);
}

View File

@@ -6,7 +6,7 @@ import { DialogRef } from "../base";
import { useVerge } from "@/hooks/use-verge";
import { Button } from "@/components/ui/button";
import { t } from "i18next";
import {Download, RefreshCw} from "lucide-react";
import { Download, RefreshCw } from "lucide-react";
import { useSidebar } from "../ui/sidebar";
interface Props {
@@ -17,7 +17,7 @@ export const UpdateButton = (props: Props) => {
const { className } = props;
const { verge } = useVerge();
const { auto_check_update } = verge || {};
const { state: sidebarState } = useSidebar();
const { state: sidebarState } = useSidebar();
const viewerRef = useRef<DialogRef>(null);
@@ -36,7 +36,7 @@ export const UpdateButton = (props: Props) => {
return (
<>
<UpdateViewer ref={viewerRef} />
{sidebarState === 'collapsed' ? (
{sidebarState === "collapsed" ? (
<Button
variant="outline"
size="icon"

View File

@@ -1,7 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useSetThemeMode, useThemeMode } from "@/services/states";
import { useVerge } from "@/hooks/use-verge";
import { getCurrentWebviewWindow, WebviewWindow } from "@tauri-apps/api/webviewWindow";
import {
getCurrentWebviewWindow,
WebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { Theme } from "@tauri-apps/api/window";
export const useCustomTheme = () => {
@@ -12,26 +15,28 @@ export const useCustomTheme = () => {
const mode = useThemeMode();
const setMode = useSetThemeMode();
const [systemTheme, setSystemTheme] = useState(
() => window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
const [systemTheme, setSystemTheme] = useState(() =>
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light",
);
useEffect(() => {
setMode(
theme_mode === "light" || theme_mode === "dark" ? theme_mode : "system",
theme_mode === "light" || theme_mode === "dark" ? theme_mode : "system",
);
}, [theme_mode, setMode]);
useEffect(() => {
if (mode !== 'system') return;
if (mode !== "system") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
setSystemTheme(e.matches ? "dark" : "light");
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [mode]);
useEffect(() => {
@@ -45,8 +50,7 @@ export const useCustomTheme = () => {
} else {
appWindow.setTheme(activeTheme as Theme).catch(console.error);
}
}, [mode, systemTheme, appWindow, theme_mode]);
return {};
};
};

View File

@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next";
// Новые импорты из shadcn/ui
import {
AlertDialog,
AlertDialogAction,
@@ -18,7 +17,7 @@ interface Props {
open: boolean;
title: string;
description: string;
onOpenChange: (open: boolean) => void; // shadcn использует этот коллбэк
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
@@ -30,7 +29,7 @@ export const ConfirmViewer = (props: Props) => {
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogTitle className="truncate">{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@@ -32,7 +32,6 @@ import { BaseSearchBox } from "../base/base-search-box";
import { showNotice } from "@/services/noticeService";
import { cn } from "@root/lib/utils";
// --- Компоненты shadcn/ui и иконки ---
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -85,7 +84,6 @@ import {
ArrowUpToLine,
} from "lucide-react";
// --- Вспомогательные функции, константы и валидаторы ---
const portValidator = (value: string): boolean =>
/^(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$/.test(
value,
@@ -109,7 +107,6 @@ interface Props {
onSave?: (prev?: string, curr?: string) => void;
}
// --- Новый компонент Combobox (одиночный выбор) ---
const Combobox = ({
options,
value,
@@ -123,7 +120,7 @@ const Combobox = ({
}) => {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -168,7 +165,6 @@ const Combobox = ({
);
};
// --- Новый компонент MultiSelectCombobox (множественный выбор) ---
const MultiSelectCombobox = ({
options,
value,
@@ -194,7 +190,7 @@ const MultiSelectCombobox = ({
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -246,7 +242,6 @@ const MultiSelectCombobox = ({
);
};
// --- Новый компонент для элемента списка групп ---
const EditorGroupItem = ({
type,
group,
@@ -407,22 +402,6 @@ export const GroupsEditorViewer = (props: Props) => {
}
};
useEffect(() => {
if (currData === "" || !visualization) return;
try {
let obj = yaml.load(currData) as {
prepend: [];
append: [];
delete: [];
} | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
} catch (e) {
/* Ignore parsing errors while typing */
}
}, [visualization, currData]);
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq && visualization) {
const serialize = () => {
@@ -580,9 +559,8 @@ export const GroupsEditorViewer = (props: Props) => {
{visualization ? (
<Form {...form}>
<form className="h-full flex gap-4">
{/* Левая панель: Конструктор групп */}
<div className="w-1/2 flex flex-col border rounded-md p-4">
<h3 className="text-lg font-medium mb-4">Constructor</h3>
<h3 className="text-lg font-medium mb-4">{t("Constructor")}</h3>
<Separator className="mb-4" />
<div className="space-y-3 overflow-y-auto p-1 -mr-3 ">
<FormField

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
@@ -21,10 +21,10 @@ export const HwidErrorDialog = () => {
setErrorMessage(customEvent.detail);
};
window.addEventListener('show-hwid-error', handleShowHwidError);
window.addEventListener("show-hwid-error", handleShowHwidError);
return () => {
window.removeEventListener('show-hwid-error', handleShowHwidError);
window.removeEventListener("show-hwid-error", handleShowHwidError);
};
}, []);

View File

@@ -23,7 +23,6 @@ import { open } from "@tauri-apps/plugin-shell";
import { ProxiesEditorViewer } from "./proxies-editor-viewer";
import { cn } from "@root/lib/utils";
// --- Компоненты shadcn/ui ---
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
@@ -46,7 +45,6 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
// --- Иконки ---
import {
GripVertical,
File as FileIcon,
@@ -67,14 +65,13 @@ import {
ListTree,
CheckCircle,
Infinity,
RefreshCw, Network,
RefreshCw,
Network,
} from "lucide-react";
import { t } from "i18next";
// Активируем плагин для dayjs
dayjs.extend(relativeTime);
// --- Вспомогательные функции ---
const parseUrl = (url?: string): string | undefined => {
if (!url) return undefined;
try {
@@ -302,6 +299,12 @@ export const ProfileItem = (props: Props) => {
isDestructive: true,
};
const MAX_NAME_LENGTH = 25;
const truncatedName =
name.length > MAX_NAME_LENGTH
? `${name.slice(0, MAX_NAME_LENGTH)}...`
: name;
return (
<div ref={setNodeRef} style={style} {...attributes}>
<ContextMenu>
@@ -343,10 +346,7 @@ export const ProfileItem = (props: Props) => {
) : null}
</div>
<div className="flex items-center flex-shrink-0">
<Badge
variant="outline"
className="text-xs shadow-sm"
>
<Badge variant="outline" className="text-xs shadow-sm">
{type}
</Badge>
</div>
@@ -388,14 +388,13 @@ export const ProfileItem = (props: Props) => {
<div className="flex items-center justify-between">
<div className="flex items-center">
<Download className="h-3 w-3 inline mr-1.5" />
<span className="pr-5">
{parseTraffic(download)}
</span>
<span className="pr-5">{parseTraffic(download)}</span>
<Network className="h-3 w-3 inline mr-1.5" />
{total > 0 ? (
<span>{parseTraffic(total)}</span>
) : <Infinity className="h-3 w-3 inline mr-1.5" />}
<span>{parseTraffic(total)}</span>
) : (
<Infinity className="h-3 w-3 inline mr-1.5" />
)}
</div>
</div>
</div>
@@ -459,7 +458,6 @@ export const ProfileItem = (props: Props) => {
</ContextMenuContent>
</ContextMenu>
{/* Модальные окна для редактирования */}
{fileOpen && (
<EditorViewer
open={true}
@@ -479,10 +477,10 @@ export const ProfileItem = (props: Props) => {
<RulesEditorViewer
open={true}
onClose={() => setRulesOpen(false)}
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
profileUid={uid}
property={option?.rules ?? ""}
groupsUid={option?.groups ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
groupsUid={option?.groups ?? ""}
mergeUid={option?.merge ?? ""}
onSave={onSave}
/>
)}
@@ -491,7 +489,7 @@ export const ProfileItem = (props: Props) => {
<ProxiesEditorViewer
open={true}
onClose={() => setProxiesOpen(false)}
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
profileUid={uid}
property={option?.proxies ?? ""}
onSave={onSave}
/>
@@ -501,10 +499,10 @@ export const ProfileItem = (props: Props) => {
<GroupsEditorViewer
open={true}
onClose={() => setGroupsOpen(false)}
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
profileUid={uid}
property={option?.groups ?? ""}
proxiesUid={option?.proxies ?? ""} // <-- Добавлен недостающий пропс
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
proxiesUid={option?.proxies ?? ""}
mergeUid={option?.merge ?? ""}
onSave={onSave}
/>
)}
@@ -513,7 +511,7 @@ export const ProfileItem = (props: Props) => {
open={confirmOpen}
onOpenChange={setConfirmOpen}
onConfirm={onDelete}
title={t("Delete Profile", { name })}
title={t("Delete Profile", { name: truncatedName })}
description={t("This action cannot be undone.")}
/>
</div>

View File

@@ -12,7 +12,9 @@ import {
createProfile,
patchProfile,
importProfile,
enhanceProfiles, createProfileFromShareLink,
enhanceProfiles,
createProfileFromShareLink,
getProfiles,
} from "@/services/cmds";
import { useProfiles } from "@/hooks/use-profiles";
import { showNotice } from "@/services/noticeService";
@@ -64,7 +66,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const { profiles } = useProfiles();
const { profiles, patchProfiles } = useProfiles();
const fileDataRef = useRef<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -138,7 +140,9 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
setIsCheckingUrl(true);
const handler = setTimeout(() => {
const isValid = /^(https?|vmess|vless|ss|socks|trojan):\/\//.test(importUrl);
const isValid = /^(https?|vmess|vless|ss|socks|trojan):\/\//.test(
importUrl,
);
setIsUrlValid(isValid);
setIsCheckingUrl(false);
}, 500);
@@ -165,23 +169,35 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
await enhanceProfiles();
setOpen(false);
} catch (err: any) {
const errorMessage = typeof err === 'string' ? err : (err.message || String(err));
const errorMessage =
typeof err === "string" ? err : err.message || String(err);
const lowerErrorMessage = errorMessage.toLowerCase();
if (lowerErrorMessage.includes('device') || lowerErrorMessage.includes('устройств')) {
window.dispatchEvent(new CustomEvent('show-hwid-error', { detail: errorMessage }));
if (
lowerErrorMessage.includes("device") ||
lowerErrorMessage.includes("устройств")
) {
window.dispatchEvent(
new CustomEvent("show-hwid-error", { detail: errorMessage }),
);
} else if (!isShareLink && errorMessage.includes("failed to fetch")) {
showNotice("info", t("Import failed, retrying with Clash proxy..."));
try {
await importProfile(importUrl, { with_proxy: false, self_proxy: true });
showNotice("success", t("Profile Imported with Clash proxy"));
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (retryErr: any) {
showNotice("error", `${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`);
}
showNotice("info", t("Import failed, retrying with Clash proxy..."));
try {
await importProfile(importUrl, {
with_proxy: false,
self_proxy: true,
});
showNotice("success", t("Profile Imported with Clash proxy"));
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (retryErr: any) {
showNotice(
"error",
`${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`,
);
}
} else {
showNotice("error", errorMessage);
showNotice("error", errorMessage);
}
} finally {
setIsImporting(false);
@@ -195,33 +211,81 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
const handleSaveAdvanced = useLockFn(
handleSubmit(async (formData) => {
const form = { ...formData, url: formData.url || importUrl };
const form = { ...formData, url: formData.url || importUrl } as Partial<IProfileItem>;
setLoading(true);
try {
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url)
throw new Error("The URL should not be null");
if (form.option?.update_interval)
form.option.update_interval = +form.option.update_interval;
else delete form.option?.update_interval;
if (form.option?.user_agent === "") delete form.option.user_agent;
const name = form.name || `${form.type} file`;
const item = { ...form, name };
// Clean option fields: only send what user actually set
let option = form.option ? { ...form.option } : undefined;
if (option) {
if ((option as any).update_interval != null && (option as any).update_interval !== "") {
// ensure number
(option as any).update_interval = +((option as any).update_interval as any);
} else {
delete (option as any).update_interval;
}
if (typeof option.user_agent === "string" && option.user_agent.trim() === "") {
delete (option as any).user_agent;
}
}
const providedName = (form as any).name && String((form as any).name).trim();
const providedDesc = (form as any).desc && String((form as any).desc).trim();
const item: Partial<IProfileItem> = {
...form,
// Only include name/desc when user explicitly entered them
name: providedName ? (providedName as string) : undefined,
desc: providedDesc ? (providedDesc as string) : undefined,
option,
};
const isUpdate = openType === "edit";
const isActivating =
isUpdate && form.uid === (profiles?.current ?? "");
const wasCurrent = isUpdate && form.uid === (profiles?.current ?? "");
if (openType === "new") {
// Detect newly created profile and activate it explicitly
const before = await getProfiles().catch(() => null);
const beforeUids = new Set(
(before?.items || []).map((i: any) => i?.uid).filter(Boolean),
);
await createProfile(item, fileDataRef.current);
const after = await getProfiles().catch(() => null);
const newRemoteLocal = (after?.items || []).find(
(i: any) =>
i &&
(i.type === "remote" || i.type === "local") &&
i.uid &&
!beforeUids.has(i.uid),
);
const newUid = (newRemoteLocal && newRemoteLocal.uid) as
| string
| undefined;
if (newUid) {
try {
await patchProfiles({ current: newUid });
} catch {}
}
showNotice("success", t("Profile Created Successfully"));
setOpen(false);
props.onChange(true);
return;
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
await patchProfile(form.uid as string, item);
showNotice("success", t("Profile Updated Successfully"));
}
setOpen(false);
props.onChange(isActivating);
props.onChange(wasCurrent);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
@@ -302,19 +366,26 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
</div>
{/^(vmess|vless|ss|socks|trojan):\/\//.test(importUrl) && (
<div className="space-y-2">
<div className="space-y-2">
<Label>{t("Template")}</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger>
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("Default Template")}</SelectItem>
<SelectItem value="without_ru">{t("Template without RU Rules")}</SelectItem>
</SelectContent>
<Select
value={selectedTemplate}
onValueChange={setSelectedTemplate}
>
<SelectTrigger>
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("Default Template")}
</SelectItem>
<SelectItem value="without_ru">
{t("Template without RU Rules")}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
)}
<Button
variant="outline"
@@ -473,15 +544,15 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
control={control}
name="option.update_always"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<FormLabel>{t("Update on Startup")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
<FormItem className="flex flex-row items-center justify-between">
<FormLabel>{t("Update on Startup")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField

View File

@@ -24,7 +24,6 @@ import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button";
function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number,
@@ -53,7 +52,6 @@ function throttle<T extends (...args: any[]) => any>(
};
}
interface Props {
mode: string;
}
@@ -72,7 +70,6 @@ export const ProxyGroups = memo((props: Props) => {
const scrollerRef = useRef<Element | null>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
// Обработчик скролла для показа/скрытия кнопки "Наверх"
const handleScroll = useCallback(
throttle((e: any) => {

View File

@@ -58,22 +58,23 @@ const DEFAULT_DNS_CONFIG = {
],
"default-nameserver": [
"system",
"223.6.6.6",
"8.8.8.8",
"2400:3200::1",
"1.1.1.1",
"2001:4860:4860::8888",
],
nameserver: [
"8.8.8.8",
"https://doh.pub/dns-query",
"https://dns.alidns.com/dns-query",
"https://dns.google/dns-query",
"https://cloudflare-dns.com/dns-query",
],
fallback: [],
"nameserver-policy": {},
"proxy-server-nameserver": [
"https://doh.pub/dns-query",
"https://dns.alidns.com/dns-query",
"tls://223.5.5.5",
"https://dns.google/dns-query",
"https://cloudflare-dns.com/dns-query",
"tls://1.1.1.1",
],
"direct-nameserver": [],
"direct-nameserver-follow-policy": false,

View File

@@ -112,7 +112,7 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
}));
const handleConfigChange = (patch: Partial<IVergeConfig>) => {
setLocalConfig(prev => ({ ...prev, ...patch }));
setLocalConfig((prev) => ({ ...prev, ...patch }));
};
const handleIconChange = useLockFn(
@@ -133,7 +133,8 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
handleConfigChange({ [key]: true });
}
}
});
},
);
const handleSave = useLockFn(async () => {
setLoading(true);
@@ -149,75 +150,112 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Layout Setting")}</DialogTitle>
</DialogHeader>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Layout Setting")}</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-1">
{OS === "macos" && (
<>
<SettingRow label={t("Tray Icon")}>
<Select
onValueChange={(value) => handleConfigChange({ tray_icon: value as any })}
value={localConfig.tray_icon ?? "monochrome"}
>
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<div className="py-4 space-y-1">
{OS === "macos" && (
<>
<SettingRow label={t("Tray Icon")}>
<Select
onValueChange={(value) =>
handleConfigChange({ tray_icon: value as any })
}
value={localConfig.tray_icon ?? "monochrome"}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">
{t("Monochrome")}
</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow label={t("Enable Tray Icon")}>
<Switch
checked={localConfig.enable_tray_icon ?? true}
onCheckedChange={(checked) => handleConfigChange({ enable_tray_icon: checked })}
/>
</SettingRow>
</>
)}
<SettingRow label={t("Enable Tray Icon")}>
<Switch
checked={localConfig.enable_tray_icon ?? true}
onCheckedChange={(checked) =>
handleConfigChange({ enable_tray_icon: checked })
}
/>
</SettingRow>
</>
)}
<SettingRow label={t("Common Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange("common")}>
{localConfig.common_tray_icon && commonIcon && (
<img src={convertFileSrc(commonIcon)} className="h-5 mr-2" alt="common tray icon" />
)}
{localConfig.common_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("System Proxy Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange("sysproxy")}>
{localConfig.sysproxy_tray_icon && sysproxyIcon && (
<img src={convertFileSrc(sysproxyIcon)} className="h-5 mr-2" alt="system proxy tray icon" />
)}
{localConfig.sysproxy_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("Tun Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange("tun")}>
{localConfig.tun_tray_icon && tunIcon && (
<img src={convertFileSrc(tunIcon)} className="h-5 mr-2" alt="tun mode tray icon" />
)}
{localConfig.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Cancel")}</Button>
</DialogClose>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
<SettingRow label={t("Common Tray Icon")}>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => handleIconChange("common")}
>
{localConfig.common_tray_icon && commonIcon && (
<img
src={convertFileSrc(commonIcon)}
className="h-5 mr-2"
alt="common tray icon"
/>
)}
{localConfig.common_tray_icon ? t("Clear") : t("Browse")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingRow>
<SettingRow label={t("System Proxy Tray Icon")}>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => handleIconChange("sysproxy")}
>
{localConfig.sysproxy_tray_icon && sysproxyIcon && (
<img
src={convertFileSrc(sysproxyIcon)}
className="h-5 mr-2"
alt="system proxy tray icon"
/>
)}
{localConfig.sysproxy_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
<SettingRow label={t("Tun Tray Icon")}>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => handleIconChange("tun")}
>
{localConfig.tun_tray_icon && tunIcon && (
<img
src={convertFileSrc(tunIcon)}
className="h-5 mr-2"
alt="tun mode tray icon"
/>
)}
{localConfig.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
});

View File

@@ -429,11 +429,11 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
}
}
} catch (err) {
console.warn("代理状态更新失败:", err);
console.warn("Proxy status update failed:", err);
}
}, 50);
} catch (err: any) {
console.error("配置保存失败:", err);
console.error("Configuration save failed:", err);
mutateVerge();
showNotice("error", err.toString());
// setOpen(true);

View File

@@ -1,4 +1,4 @@
import {useMemo, useRef, useState} from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { mutate } from "swr";
@@ -43,7 +43,7 @@ import {
Power,
BellOff,
Repeat,
Fingerprint
Fingerprint,
} from "lucide-react";
// Модальные окна
@@ -56,7 +56,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {useProfiles} from "@/hooks/use-profiles";
import { useProfiles } from "@/hooks/use-profiles";
const isWIN = getSystem() === "windows";
interface Props {
@@ -110,7 +110,7 @@ const SettingSystem = ({ onError }: Props) => {
const { profiles } = useProfiles();
const hasProfiles = useMemo(() => {
const items = profiles?.items ?? [];
return items.some(p => p.type === 'local' || p.type === 'remote');
return items.some((p) => p.type === "local" || p.type === "remote");
}, [profiles]);
const {
@@ -261,7 +261,10 @@ const SettingSystem = ({ onError }: Props) => {
);
}
if (e) {
return patchVerge({ enable_tun_mode: true, enable_system_proxy: false });
return patchVerge({
enable_tun_mode: true,
enable_system_proxy: false,
});
} else {
return patchVerge({ enable_tun_mode: false });
}

View File

@@ -188,20 +188,20 @@ const SettingVergeBasic = ({ onError }: Props) => {
{OS !== "linux" && (
<SettingRow
label={
<LabelWithIcon
icon={MousePointerClick}
text={t("Tray Click Event")}
/>
}
label={
<LabelWithIcon
icon={MousePointerClick}
text={t("Tray Click Event")}
/>
}
>
<GuardState
value={tray_event ?? "main_window"}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ tray_event: e })}
onGuard={(e) => patchVerge({ tray_event: e })}
onChangeProps="onValueChange"
value={tray_event ?? "main_window"}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ tray_event: e })}
onGuard={(e) => patchVerge({ tray_event: e })}
onChangeProps="onValueChange"
>
<Select>
<SelectTrigger className="w-40 h-8">

View File

@@ -40,7 +40,11 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md p-1",
"backdrop-blur-sm bg-white/70 border border-white/40",
"dark:bg-white/10 dark:border-white/20",
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
@@ -72,7 +76,13 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:hover:bg-red-200/80 data-[variant=destructive]:focus:bg-red-200/80",
"dark:data-[variant=destructive]:hover:bg-destructive/20 dark:data-[variant=destructive]:focus:bg-destructive/20",
"data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -90,7 +100,10 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -126,7 +139,10 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -154,6 +170,7 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
"text-foreground/80",
className,
)}
{...props}
@@ -168,7 +185,11 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
className={cn(
"-mx-1 my-1 h-px",
"bg-gray-400/50 dark:bg-white/20",
className,
)}
{...props}
/>
);
@@ -182,7 +203,8 @@ function DropdownMenuShortcut({
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
"ml-auto text-xs tracking-widest",
"text-foreground/60",
className,
)}
{...props}
@@ -209,7 +231,10 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"data-[state=open]:backdrop-blur-sm data-[state=open]:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20 dark:data-[state=open]:bg-white/20",
className,
)}
{...props}
@@ -228,7 +253,11 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md p-1",
"backdrop-blur-sm bg-white/70 border border-white/40",
"dark:bg-white/10 dark:border-white/20",
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}

View File

@@ -35,7 +35,11 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
className,
)}
{...props}
@@ -59,9 +63,14 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md",
"backdrop-blur-sm bg-white/70 border border-white/40",
"dark:bg-white/10 dark:border-white/20",
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
position === "popper" && "w-[var(--radix-select-trigger-width)]",
className,
)}
position={position}
@@ -69,11 +78,7 @@ function SelectContent({
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
className={cn("p-1", position === "popper" && "w-full")}
>
{children}
</SelectPrimitive.Viewport>
@@ -105,7 +110,11 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
"dark:hover:bg-white/20 dark:focus:bg-white/20",
"transition-all duration-200",
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}

View File

@@ -1,54 +1,54 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@root/hooks/use-mobile"
import { cn } from "@root/lib/utils"
import { Button } from "@root/src/components/ui/button"
import { Input } from "@root/src/components/ui/input"
import { Separator } from "@root/src/components/ui/separator"
import { useIsMobile } from "@root/hooks/use-mobile";
import { cn } from "@root/lib/utils";
import { Button } from "@root/src/components/ui/button";
import { Input } from "@root/src/components/ui/input";
import { Separator } from "@root/src/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@root/src/components/ui/sheet"
import { Skeleton } from "@root/src/components/ui/skeleton"
} from "@root/src/components/ui/sheet";
import { Skeleton } from "@root/src/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@root/src/components/ui/tooltip"
} from "@root/src/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context
return context;
}
function SidebarProvider({
@@ -60,36 +60,36 @@ function SidebarProvider({
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
)
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -98,18 +98,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -121,8 +121,8 @@ function SidebarProvider({
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
@@ -138,7 +138,7 @@ function SidebarProvider({
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
className,
)}
{...props}
>
@@ -146,7 +146,7 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
);
}
function Sidebar({
@@ -157,11 +157,11 @@ function Sidebar({
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
@@ -169,13 +169,13 @@ function Sidebar({
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
className,
)}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
@@ -200,7 +200,7 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
@@ -221,7 +221,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
@@ -235,7 +235,7 @@ function Sidebar({
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
className,
)}
{...props}
>
@@ -248,7 +248,7 @@ function Sidebar({
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
@@ -256,7 +256,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<Button
@@ -266,19 +266,19 @@ function SidebarTrigger({
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<button
@@ -295,11 +295,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
className,
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@@ -309,11 +309,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
className,
)}
{...props}
/>
)
);
}
function SidebarInput({
@@ -327,7 +327,7 @@ function SidebarInput({
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -338,7 +338,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -349,7 +349,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarSeparator({
@@ -363,7 +363,7 @@ function SidebarSeparator({
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -373,11 +373,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -388,7 +388,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
);
}
function SidebarGroupLabel({
@@ -396,7 +396,7 @@ function SidebarGroupLabel({
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
const Comp = asChild ? Slot : "div";
return (
<Comp
@@ -405,11 +405,11 @@ function SidebarGroupLabel({
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroupAction({
@@ -417,7 +417,7 @@ function SidebarGroupAction({
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -428,11 +428,11 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroupContent({
@@ -446,7 +446,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@@ -457,7 +457,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -468,7 +468,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)}
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
@@ -490,8 +490,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function SidebarMenuButton({
asChild = false,
@@ -502,12 +502,12 @@ function SidebarMenuButton({
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
@@ -518,16 +518,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
@@ -540,7 +540,7 @@ function SidebarMenuButton({
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -549,10 +549,10 @@ function SidebarMenuAction({
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -568,11 +568,11 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuBadge({
@@ -590,11 +590,11 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
@@ -602,12 +602,12 @@ function SidebarMenuSkeleton({
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
@@ -632,7 +632,7 @@ function SidebarMenuSkeleton({
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@@ -643,11 +643,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
@@ -661,7 +661,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
);
}
function SidebarMenuSubButton({
@@ -671,11 +671,11 @@ function SidebarMenuSubButton({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : "a";
return (
<Comp
@@ -689,11 +689,11 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -721,4 +721,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};

View File

@@ -1,4 +1,4 @@
import { cn } from "@root/lib/utils"
import { cn } from "@root/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };

View File

@@ -19,7 +19,8 @@ export const useListen = () => {
};
const setupCloseListener = async function () {
await event.once("tauri://close-requested", async () => {
// Do not clear listeners on close-requested (we hide to tray). Clean up only when window is destroyed.
await event.once("tauri://destroyed", async () => {
removeAllListeners();
});
};

View File

@@ -56,7 +56,7 @@ export const useProfiles = () => {
// 根据selected的节点选择
const activateSelected = async () => {
try {
console.log("[ActivateSelected] 开始处理代理选择");
console.log("[ActivateSelected] Start processing proxy selection");
const [proxiesData, profileData] = await Promise.all([
getProxies(),
@@ -64,7 +64,9 @@ export const useProfiles = () => {
]);
if (!profileData || !proxiesData) {
console.log("[ActivateSelected] 代理或配置数据不可用,跳过处理");
console.log(
"[ActivateSelected] Proxy or configuration data unavailable, skipping processing",
);
return;
}
@@ -73,19 +75,23 @@ export const useProfiles = () => {
);
if (!current) {
console.log("[ActivateSelected] 未找到当前profile配置");
console.log(
"[ActivateSelected] Current profile configuration not found",
);
return;
}
// 检查是否有saved的代理选择
const { selected = [] } = current;
if (selected.length === 0) {
console.log("[ActivateSelected] 当前profile无保存的代理选择跳过");
console.log(
"[ActivateSelected] The current profile has no saved proxy selection, so it will be skipped",
);
return;
}
console.log(
`[ActivateSelected] 当前profile ${selected.length} 个代理选择配置`,
`[ActivateSelected] The current profile has ${selected.length} proxy selection configurations`,
);
const selectedMap = Object.fromEntries(
@@ -108,7 +114,7 @@ export const useProfiles = () => {
const targetProxy = selectedMap[name];
if (targetProxy != null && targetProxy !== now) {
console.log(
`[ActivateSelected] 需要切换代理组 ${name}: ${now} -> ${targetProxy}`,
`[ActivateSelected] Need to switch proxy groups ${name}: ${now} -> ${targetProxy}`,
);
hasChange = true;
updateProxy(name, targetProxy);
@@ -118,27 +124,36 @@ export const useProfiles = () => {
});
if (!hasChange) {
console.log("[ActivateSelected] 所有代理选择已经是目标状态,无需更新");
console.log(
"[ActivateSelected] All agent selections are already in the target state and do not need to be updated",
);
return;
}
console.log(`[ActivateSelected] 完成代理切换,保存新的选择配置`);
console.log(
`[ActivateSelected] Complete the proxy switch and save the new selection configuration`,
);
try {
await patchProfile(profileData.current!, { selected: newSelected });
console.log("[ActivateSelected] 代理选择配置保存成功");
console.log(
"[ActivateSelected] Proxy selection configuration saved successfully",
);
setTimeout(() => {
mutate("getProxies", getProxies());
}, 100);
} catch (error: any) {
console.error(
"[ActivateSelected] 保存代理选择配置失败:",
"[ActivateSelected] Failed to save proxy selection configuration:",
error.message,
);
}
} catch (error: any) {
console.error("[ActivateSelected] 处理代理选择失败:", error.message);
console.error(
"[ActivateSelected] Handling proxy selection failure:",
error.message,
);
}
};

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { useEffect, useState, useCallback } from "react";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
// Константы для управления масштабом
const ZOOM_STEP = 0.1;
@@ -22,7 +22,9 @@ export const useZoomControls = () => {
let initialZoom = 1.0;
console.log(`Physical width: ${size.width}, Scale Factor: ${scaleFactor}, Logical width: ${logicalWidth}`);
console.log(
`Physical width: ${size.width}, Scale Factor: ${scaleFactor}, Logical width: ${logicalWidth}`,
);
// 3. Используем логическую ширину для принятия решения
if (logicalWidth < 1300) {
@@ -38,18 +40,24 @@ export const useZoomControls = () => {
setInitialZoom();
}, []);
const handleZoom = useCallback((delta: number, isReset = false) => {
setZoomLevel(currentZoom => {
const newZoom = isReset ? 1.0 : currentZoom + delta;
const clampedZoom = Math.max(MIN_ZOOM, Math.min(newZoom, MAX_ZOOM));
const roundedZoom = Math.round(clampedZoom * 100) / 100;
const handleZoom = useCallback(
(delta: number, isReset = false) => {
setZoomLevel((currentZoom) => {
const newZoom = isReset ? 1.0 : currentZoom + delta;
const clampedZoom = Math.max(MIN_ZOOM, Math.min(newZoom, MAX_ZOOM));
const roundedZoom = Math.round(clampedZoom * 100) / 100;
appWindow.setZoom(roundedZoom);
const newStrokeWidth = 2 / roundedZoom;
document.documentElement.style.setProperty('--icon-stroke-width', newStrokeWidth.toString());
return roundedZoom;
});
}, [appWindow]);
appWindow.setZoom(roundedZoom);
const newStrokeWidth = 2 / roundedZoom;
document.documentElement.style.setProperty(
"--icon-stroke-width",
newStrokeWidth.toString(),
);
return roundedZoom;
});
},
[appWindow],
);
useEffect(() => {
const handleWheel = (event: WheelEvent) => {
@@ -63,18 +71,18 @@ export const useZoomControls = () => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey) {
switch (event.code) {
case 'Equal':
case 'NumpadAdd':
case "Equal":
case "NumpadAdd":
event.preventDefault();
handleZoom(ZOOM_STEP);
break;
case 'Minus':
case 'NumpadSubtract':
case "Minus":
case "NumpadSubtract":
event.preventDefault();
handleZoom(-ZOOM_STEP);
break;
case 'Digit0':
case 'Numpad0':
case "Digit0":
case "Numpad0":
event.preventDefault();
handleZoom(0, true);
break;
@@ -82,12 +90,12 @@ export const useZoomControls = () => {
}
};
window.addEventListener('wheel', handleWheel, { passive: false });
window.addEventListener('keydown', handleKeyDown);
window.addEventListener("wheel", handleWheel, { passive: false });
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener('wheel', handleWheel);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener("wheel", handleWheel);
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleZoom]);
};

View File

@@ -1,9 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@theme {
--tailwind-darkMode: 'class';
}
@variant dark (&:where(.dark, .dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
@@ -132,7 +130,6 @@ svg {
stroke-width: var(--icon-stroke-width, 2);
}
@keyframes gradient-wave {
0% {
background-position: -200% center;

View File

@@ -392,6 +392,8 @@
"Profile Imported Successfully": "Profile Imported Successfully",
"Profile Switched": "Profile Switched",
"Profile Reactivated": "Profile Reactivated",
"Profile Created Successfully": "Profile created successfully",
"Profile Updated Successfully": "Profile updated successfully",
"Profile switch interrupted by new selection": "Profile switch interrupted by new selection",
"Only YAML Files Supported": "Only YAML Files Supported",
"Settings Applied": "Settings Applied",
@@ -678,5 +680,13 @@
"Template without RU Rules": "Without-ru template",
"Stopping Core...": "Stopping Core...",
"Uninstalling Service...": "Uninstalling Service...",
"Try running core as Sidecar...": "Try running core as Sidecar..."
"Try running core as Sidecar...": "Try running core as Sidecar...",
"Global Mode Active": "Global Mode Active",
"Update Interval (mins)": "Update Interval (mins)",
"Profile Name": "Profile Name",
"Profile Description": "Profile Description",
"Constructor": "Group constructor",
"Leave blank to use the URL above": "Leave blank to use the URL above",
"No profiles available": "No profiles available",
"Configuration saved successfully": "Configuration saved successfully"
}

View File

@@ -392,6 +392,8 @@
"Profile Imported Successfully": "Профиль успешно импортирован",
"Profile Switched": "Профиль изменен",
"Profile Reactivated": "Профиль перезапущен",
"Profile Created Successfully": "Профиль успешно создан",
"Profile Updated Successfully": "Профиль успешно обновлён",
"Profile switch interrupted by new selection": "Переключение профилей прервано новым выбором",
"Only YAML Files Supported": "Поддерживаются только файлы YAML",
"Settings Applied": "Настройки применены",
@@ -480,14 +482,14 @@
"Direct Mode": "Прямой режим",
"Enable Tray Speed": "Показывать скорость в трее",
"Enable Tray Icon": "Показывать значок в трее",
"LightWeight Mode": "LightWeight Mode",
"LightWeight Mode": "Легковесный режим",
"LightWeight Mode Info": "Режим, в котором работает только ядро Clash, а графический интрефейс закрыт",
"LightWeight Mode Settings": "Настройки LightWeight Mode",
"Enter LightWeight Mode Now": "Войти в LightWeight Mode",
"Auto Enter LightWeight Mode": "Автоматический вход в LightWeight Mode",
"Auto Enter LightWeight Mode Info": "Автоматически включать LightWeight Mode, если окно закрыто определенное время",
"Auto Enter LightWeight Mode Delay": "Задержка включения LightWeight Mode",
"When closing the window, LightWeight Mode will be automatically activated after _n minutes": "При закрытии окна LightWeight Mode будет автоматически активирован через {{n}} минут",
"LightWeight Mode Settings": "Настройки легковесного режима",
"Enter LightWeight Mode Now": "Войти в легковесный режим",
"Auto Enter LightWeight Mode": "Автоматический вход в легковесный режим",
"Auto Enter LightWeight Mode Info": "Автоматически включать легковесный режим, если окно закрыто определенное время",
"Auto Enter LightWeight Mode Delay": "Задержка включения легковесного режима",
"When closing the window, LightWeight Mode will be automatically activated after _n minutes": "При закрытии окна легковесный режим будет автоматически активирован через {{n}} минут",
"Config Validation Failed": "Ошибка проверки конфигурации подписки, проверьте файл конфигурации, изменения отменены, ошибка:",
"Boot Config Validation Failed": "Ошибка проверки конфигурации при запуске, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
"Core Change Config Validation Failed": "Ошибка проверки конфигурации при смене ядра, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
@@ -678,5 +680,13 @@
"Template without RU Rules": "Шаблон without-ru",
"Stopping Core...": "Остановка ядра...",
"Uninstalling Service...": "Удаление сервиса...",
"Try running core as Sidecar...": "Попытка запустить ядро как Sidecar..."
"Try running core as Sidecar...": "Попытка запустить ядро как Sidecar...",
"Global Mode Active": "Глобальный режим активен",
"Update Interval (mins)": "Интервал обновления (в минутах)",
"Profile Name": "Имя профиля",
"Profile Description": "Описание профиля",
"Constructor": "Конструктор групп",
"Leave blank to use the URL above": "Оставьте поле пустым, чтобы использовать URL-адрес выше",
"No profiles available": "Нет доступных профилей",
"Configuration saved successfully": "Конфигурация успешно сохранена"
}

View File

@@ -43,6 +43,22 @@ document.addEventListener("keydown", (event) => {
disabledShortcuts && event.preventDefault();
});
// Disable context menu everywhere except in input fields and textareas
document.addEventListener("contextmenu", (event) => {
const target = event.target as HTMLElement;
// Allow context menu for input fields, textareas, and editable content
const isEditable =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
target.closest('[contenteditable="true"]') !== null;
if (!isEditable) {
event.preventDefault();
}
});
const contexts = [
<ThemeModeProvider />,
<LoadingCacheProvider />,
@@ -66,9 +82,9 @@ root.render(
// 错误处理
window.addEventListener("error", (event) => {
console.error("[main.tsx] 全局错误:", event.error);
console.error("[main.tsx] Global error:", event.error);
});
window.addEventListener("unhandledrejection", (event) => {
console.error("[main.tsx] 未处理的Promise拒绝:", event.reason);
console.error("[main.tsx] Unhandled promise rejection:", event.reason);
});

View File

@@ -27,7 +27,6 @@ import { AppSidebar } from "@/components/layout/sidebar";
import { useZoomControls } from "@/hooks/useZoomControls";
import { HwidErrorDialog } from "@/components/profile/hwid-error-dialog";
const appWindow = getCurrentWebviewWindow();
export let portableFlag = false;
@@ -49,16 +48,21 @@ const handleNoticeMessage = (
mutate("getProfiles");
navigate("/");
showNotice("success", t("Import Subscription Successful"));
sessionStorage.setItem('activateProfile', msg);
sessionStorage.setItem("activateProfile", msg);
break;
case "import_sub_url::error":
console.log(msg);
if (msg.toLowerCase().includes('device') || msg.toLowerCase().includes('устройств')) {
window.dispatchEvent(new CustomEvent('show-hwid-error', { detail: msg }));
} else {
showNotice("error", msg);
}
break;
console.log(msg);
if (
msg.toLowerCase().includes("device") ||
msg.toLowerCase().includes("устройств")
) {
window.dispatchEvent(
new CustomEvent("show-hwid-error", { detail: msg }),
);
} else {
showNotice("error", msg);
}
break;
case "set_config::error":
showNotice("error", msg);
break;
@@ -172,7 +176,10 @@ const Layout = () => {
try {
handleNoticeMessage(status, msg, t, navigate);
} catch (error) {
console.error("[Layout] Failure to process a notification message:", error);
console.error(
"[Layout] Failure to process a notification message:",
error,
);
}
}, 0);
},
@@ -233,11 +240,17 @@ const Layout = () => {
try {
unlisten();
} catch (error) {
console.error("[Layout] Failed to clear event listener:", error);
console.error(
"[Layout] Failed to clear event listener:",
error,
);
}
})
.catch((error) => {
console.error("[Layout] Failed to get unlisten function:", error);
console.error(
"[Layout] Failed to get unlisten function:",
error,
);
});
}
});
@@ -259,7 +272,9 @@ const Layout = () => {
useEffect(() => {
if (initRef.current) {
console.log("[Layout] Initialization code has already been executed, skip");
console.log(
"[Layout] Initialization code has already been executed, skip",
);
return;
}
console.log("[Layout] Begin executing initialization code");
@@ -305,7 +320,9 @@ const Layout = () => {
}
initializationAttempts++;
console.log(`[Layout] Start ${initializationAttempts} for the first time`);
console.log(
`[Layout] Start ${initializationAttempts} for the first time`,
);
try {
removeLoadingOverlay();
@@ -326,7 +343,9 @@ const Layout = () => {
checkReactMount();
setTimeout(() => {
console.log("[Layout] React components mount check timeout, continue execution");
console.log(
"[Layout] React components mount check timeout, continue execution",
);
resolve();
}, 2000);
});
@@ -342,7 +361,9 @@ const Layout = () => {
await notifyBackend("UI ready");
isInitialized = true;
console.log(`[Layout] The ${initializationAttempts} initialization is complete`);
console.log(
`[Layout] The ${initializationAttempts} initialization is complete`,
);
} catch (error) {
console.error(
`[Layout] Initialization failure at ${initializationAttempts}:`,
@@ -355,7 +376,9 @@ const Layout = () => {
);
setTimeout(performInitialization, 500);
} else {
console.error("[Layout] All initialization attempts fail, perform emergency initialization");
console.error(
"[Layout] All initialization attempts fail, perform emergency initialization",
);
removeLoadingOverlay();
try {
@@ -375,14 +398,19 @@ const Layout = () => {
console.log("[Layout] Start listening for startup completion events");
const unlisten = await listen("verge://startup-completed", () => {
if (!hasEventTriggered) {
console.log("[Layout] Receive startup completion event, start initialization");
console.log(
"[Layout] Receive startup completion event, start initialization",
);
hasEventTriggered = true;
performInitialization();
}
});
return unlisten;
} catch (err) {
console.error("[Layout] Failed to listen for startup completion event:", err);
console.error(
"[Layout] Failed to listen for startup completion event:",
err,
);
return () => {};
}
};
@@ -393,18 +421,24 @@ const Layout = () => {
await invoke("update_ui_stage", { stage: "Loading" });
if (!hasEventTriggered && !isInitialized) {
console.log("[Layout] Backend is ready, start initialization immediately");
console.log(
"[Layout] Backend is ready, start initialization immediately",
);
hasEventTriggered = true;
performInitialization();
}
} catch (err) {
console.log("[Layout] Backend not yet ready, waiting for startup completion event");
console.log(
"[Layout] Backend not yet ready, waiting for startup completion event",
);
}
};
const backupInitialization = setTimeout(() => {
if (!hasEventTriggered && !isInitialized) {
console.warn("[Layout] Standby initialization trigger: initialization not started within 1.5 seconds");
console.warn(
"[Layout] Standby initialization trigger: initialization not started within 1.5 seconds",
);
hasEventTriggered = true;
performInitialization();
}
@@ -412,7 +446,9 @@ const Layout = () => {
const emergencyInitialization = setTimeout(() => {
if (!isInitialized) {
console.error("[Layout] Emergency initialization trigger: initialization not completed within 5 seconds");
console.error(
"[Layout] Emergency initialization trigger: initialization not completed within 5 seconds",
);
removeLoadingOverlay();
notifyBackend("UI ready").catch(() => {});
isInitialized = true;
@@ -445,7 +481,7 @@ const Layout = () => {
}, [start_page]);
if (!routersEles) {
return <div className="h-screen w-screen bg-background" />;
return <div className="h-screen w-screen bg-background" />;
}
const AppLayout = () => {
@@ -454,21 +490,18 @@ const Layout = () => {
const routersEles = useRoutes(routers);
return (
<>
<AppSidebar />
<main
className="h-screen w-full overflow-y-auto transition-[margin] duration-200 ease-linear"
>
<div className="h-full w-full relative">
{routersEles && React.cloneElement(routersEles, { key: location.pathname })}
</div>
</main>
<HwidErrorDialog />
</>
<>
<AppSidebar />
<main className="h-screen w-full overflow-y-auto transition-[margin] duration-200 ease-linear">
<div className="h-full w-full relative">
{routersEles &&
React.cloneElement(routersEles, { key: location.pathname })}
</div>
</main>
<HwidErrorDialog />
</>
);
};
};
return (
<SWRConfig value={{ errorRetryCount: 3 }}>

View File

@@ -22,9 +22,7 @@ import {
ConnectionDetail,
ConnectionDetailRef,
} from "@/components/connection/connection-detail";
import {
BaseSearchBox,
} from "@/components/base/base-search-box";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -49,18 +47,37 @@ import {
PauseCircle,
ArrowDown,
ArrowUp,
Menu,
} from "lucide-react";
import {SidebarTrigger} from "@/components/ui/sidebar";
import { SidebarTrigger } from "@/components/ui/sidebar";
interface IConnectionsItem {
id: string;
metadata: {
host: string;
destinationIP: string;
process?: string;
};
start?: string;
curUpload?: number;
curDownload?: number;
}
interface IConnections {
uploadTotal: number;
downloadTotal: number;
connections: IConnectionsItem[];
data: IConnectionsItem[];
}
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const initConn: IConnections = {
uploadTotal: 0,
downloadTotal: 0,
connections: [],
data: [],
};
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const ConnectionsPage = () => {
const { t } = useTranslation();
const pageVisible = useVisibility();
@@ -77,9 +94,10 @@ const ConnectionsPage = () => {
new Date(b.start || "0").getTime()! -
new Date(a.start || "0").getTime()!,
),
"Upload Speed": (list) => list.sort((a, b) => b.curUpload! - a.curUpload!),
"Upload Speed": (list) =>
list.sort((a, b) => (b.curUpload ?? 0) - (a.curUpload ?? 0)),
"Download Speed": (list) =>
list.sort((a, b) => b.curDownload! - a.curDownload!),
list.sort((a, b) => (b.curDownload ?? 0) - (a.curDownload ?? 0)),
};
const [isPaused, setIsPaused] = useState(false);
@@ -91,6 +109,7 @@ const ConnectionsPage = () => {
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data,
data: connections.data,
};
if (isPaused) return frozenData ?? currentData;
return currentData;
@@ -147,6 +166,7 @@ const ConnectionsPage = () => {
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data,
data: connections.data,
});
} else {
setFrozenData(null);
@@ -155,13 +175,13 @@ const ConnectionsPage = () => {
});
}, [connections]);
const headerHeight = "7rem";
return (
<div className="h-full w-full relative">
<div className="relative h-full w-full">
<div
className={cn(
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled },
)}
className="absolute top-0 left-0 right-0 z-20 p-4 bg-background/80 backdrop-blur-sm"
style={{ height: headerHeight }}
>
<div className="flex justify-between items-center">
<div className="w-10">
@@ -255,11 +275,15 @@ const ConnectionsPage = () => {
</div>
</div>
<div className="absolute top-0 left-0 right-0 bottom-0 pt-28">
<div
ref={scrollerRefCallback}
className="absolute left-0 right-0 bottom-0 overflow-y-auto"
style={{ top: headerHeight }}
>
{filterConn.length === 0 ? (
<BaseEmpty />
) : isTableLayout ? (
<div className="p-4 pt-0 h-full w-full">
<div className="p-4 pt-0">
<ConnectionTable
connections={filterConn}
onShowDetail={(detail) => detailRef.current?.open(detail)}

View File

@@ -1,4 +1,10 @@
import React, {useRef, useMemo, useCallback, useState, useEffect} from "react";
import React, {
useRef,
useMemo,
useCallback,
useState,
useEffect,
} from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -25,7 +31,11 @@ import {
AlertTriangle,
Loader2,
Globe,
Send, ExternalLink, RefreshCw, ArrowDown, ArrowUp,
Send,
ExternalLink,
RefreshCw,
ArrowDown,
ArrowUp,
} from "lucide-react";
import { useVerge } from "@/hooks/use-verge";
import { useSystemState } from "@/hooks/use-system-state";
@@ -34,7 +44,12 @@ import { Switch } from "@/components/ui/switch";
import { ProxySelectors } from "@/components/home/proxy-selectors";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { closeAllConnections } from "@/services/api";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { updateProfile } from "@/services/cmds";
import { SidebarTrigger } from "@/components/ui/sidebar";
import parseTraffic from "@/utils/parse-traffic";
@@ -42,6 +57,36 @@ import { useAppData } from "@/providers/app-data-provider";
import { PowerButton } from "@/components/home/power-button";
import { cn } from "@root/lib/utils";
import map from "../assets/image/map.svg";
import { AnimatePresence, motion } from "framer-motion";
function useSmoothBoolean(
source: boolean,
delayOffMs: number = 600,
delayOnMs: number = 0
): boolean {
const [value, setValue] = useState<boolean>(source);
useEffect(() => {
let timer: number | undefined;
if (source) {
if (delayOnMs > 0) {
timer = window.setTimeout(() => setValue(true), delayOnMs);
} else {
setValue(true);
}
} else {
timer = window.setTimeout(() => setValue(false), delayOffMs);
}
return () => {
if (timer) window.clearTimeout(timer);
};
}, [source, delayOffMs, delayOnMs]);
return value;
}
const MinimalHomePage: React.FC = () => {
const { t } = useTranslation();
@@ -61,7 +106,7 @@ const MinimalHomePage: React.FC = () => {
}, [profiles]);
const currentProfile = useMemo(() => {
return profileItems.find(p => p.uid === profiles?.current);
return profileItems.find((p) => p.uid === profiles?.current);
}, [profileItems, profiles?.current]);
const currentProfileName = currentProfile?.name || profiles?.current;
@@ -76,21 +121,20 @@ const MinimalHomePage: React.FC = () => {
}
} catch (err: any) {
toast.error(err.message || err.toString());
mutateProfiles();
await mutateProfiles();
}
},
[patchProfiles, activateSelected, mutateProfiles, t],
);
useEffect(() => {
const uidToActivate = sessionStorage.getItem('activateProfile');
if (uidToActivate && profileItems.some(p => p.uid === uidToActivate)) {
const uidToActivate = sessionStorage.getItem("activateProfile");
if (uidToActivate && profileItems.some((p) => p.uid === uidToActivate)) {
activateProfile(uidToActivate, false);
sessionStorage.removeItem('activateProfile');
sessionStorage.removeItem("activateProfile");
}
}, [profileItems, activateProfile]);
const handleProfileChange = useLockFn(async (uid: string) => {
if (profiles?.current === uid) return;
await activateProfile(uid, true);
@@ -100,7 +144,11 @@ const MinimalHomePage: React.FC = () => {
const { isAdminMode, isServiceMode } = useSystemState();
const { installServiceAndRestartCore } = useServiceInstaller();
const isTunAvailable = isServiceMode || isAdminMode;
const isProxyEnabled = verge?.enable_system_proxy || verge?.enable_tun_mode;
const isProxyEnabled =
(!!verge?.enable_system_proxy) || (!!verge?.enable_tun_mode);
const uiProxyEnabled = useSmoothBoolean(isProxyEnabled, 600, 0);
const showTunAlert =
(verge?.primary_action ?? "tun-mode") === "tun-mode" && !isTunAvailable;
@@ -144,7 +192,7 @@ const MinimalHomePage: React.FC = () => {
});
const handleUpdateProfile = useLockFn(async () => {
if (!currentProfile?.uid || currentProfile.type !== 'remote') return;
if (!currentProfile?.uid || currentProfile.type !== "remote") return;
setIsUpdating(true);
try {
await updateProfile(currentProfile.uid);
@@ -160,56 +208,141 @@ const MinimalHomePage: React.FC = () => {
const statusInfo = useMemo(() => {
if (isToggling) {
return {
text: isProxyEnabled ? t('Disconnecting...') : t('Connecting...'),
color: isProxyEnabled ? '#f59e0b' : '#84cc16',
text: isProxyEnabled ? t("Disconnecting...") : t("Connecting..."),
color: isProxyEnabled ? "#f59e0b" : "#84cc16",
isAnimating: true,
};
}
if (isProxyEnabled) {
return {
text: t('Connected'),
color: '#22c55e',
text: t("Connected"),
color: "#22c55e",
isAnimating: false,
};
}
return {
text: t('Disconnected'),
color: '#ef4444',
text: t("Disconnected"),
color: "#ef4444",
isAnimating: false,
};
}, [isToggling, isProxyEnabled, t]);
const statsContainerVariants = {
initial: { opacity: 0, y: 25, filter: "blur(8px)", scale: 0.98 },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
scale: 1,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
when: "beforeChildren",
staggerChildren: 0.08,
},
},
exit: {
opacity: 0,
y: 10,
filter: "blur(10px)",
scale: 0.98,
transition: {
duration: 0.45,
ease: [0.22, 0.08, 0.05, 1],
when: "afterChildren",
staggerChildren: 0.06,
staggerDirection: -1,
},
},
} as const;
const statItemVariants = {
initial: { opacity: 0, y: 10, filter: "blur(6px)" },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.35, ease: "easeOut" },
},
exit: {
opacity: 0,
y: -8,
filter: "blur(6px)",
transition: { duration: 0.3, ease: "easeIn" },
},
} as const;
return (
<div className="h-full w-full flex flex-col">
<div className="absolute inset-0 opacity-20 pointer-events-none z-0 [transform:translateZ(0)]">
<img
src={map}
alt="World map"
className="w-full h-full object-cover"
/>
<img src={map} alt="World map" className="w-full h-full object-cover" />
</div>
{isProxyEnabled && (
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[500px] w-[500px] rounded-full pointer-events-none z-0 transition-opacity duration-500"
style={{
background: 'radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)',
filter: 'blur(100px)',
}}
/>
)}
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[500px] w-[500px] rounded-full pointer-events-none z-0"
style={{
background:
"radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)",
filter: "blur(100px)",
}}
animate={{
opacity: uiProxyEnabled ? 1 : 0,
scale: uiProxyEnabled ? 1 : 0.92,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 25,
mass: 1,
}}
/>
<header className="flex-shrink-0 p-5 grid grid-cols-3 items-center z-10">
<div className="flex justify-start">
<SidebarTrigger />
</div>
<div className="justify-self-center flex flex-col items-center gap-2">
<div className="relative">
{profileItems.length > 0 && (
<div className="flex-shrink-0">
<div className="justify-self-center flex flex-col items-center gap-2">
<div className="relative flex items-center justify-center">
{profileItems.length > 0 ? (
<>
<div className="absolute right-full mr-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => viewerRef.current?.create()}
className={cn(
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
"transition-all duration-200",
)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Add Profile")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full max-w-[250px] sm:max-w-xs">
<Button
variant="outline"
className={cn(
"w-full max-w-[250px] sm:max-w-xs",
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
"transition-all duration-200",
)}
>
<span className="truncate">{currentProfileName}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -228,170 +361,240 @@ const MinimalHomePage: React.FC = () => {
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => viewerRef.current?.create()}>
<PlusCircle className="mr-2 h-4 w-4" />
<span>{t("Add Profile")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{currentProfile?.type === "remote" && (
<div className="absolute left-full ml-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleUpdateProfile}
disabled={isUpdating}
className={cn(
"flex-shrink-0",
"backdrop-blur-sm bg-white/70 border border-gray-300/50",
"dark:bg-white/5 dark:border-white/10",
"hover:bg-white/85 hover:border-gray-400/60",
"dark:hover:bg-white/10 dark:hover:border-white/15",
"transition-all duration-200",
)}
>
{isUpdating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<RefreshCw className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Update Profile")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</>
) : (
<>
<div className="absolute right-full mr-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => viewerRef.current?.create()}
className={cn(
"backdrop-blur-sm bg-white/80 border-gray-300/60",
"dark:bg-white/5 dark:border-white/15",
"hover:bg-white/90 hover:border-gray-400/70",
"dark:hover:bg-white/10 dark:hover:border-white/20",
"transition-all duration-200",
)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Add Profile")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Button
variant="outline"
disabled
className={cn(
"max-w-[250px] sm:max-w-xs opacity-50 cursor-not-allowed",
"backdrop-blur-sm bg-white/50 border-gray-300/40",
"dark:bg-white/3 dark:border-white/10",
)}
>
<span className="truncate">{t("No profiles available")}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-30" />
</Button>
</>
)}
{currentProfile?.type === 'remote' && (
<div className="absolute top-1/2 -translate-y-1/2 left-full ml-2">
</div>
</div>
<div className="flex justify-end"></div>
</header>
<main className="flex-1 overflow-y-auto flex items-center justify-center">
<div className="relative flex flex-col items-center gap-8 py-10 w-full max-w-4xl px-4">
{currentProfile?.announce && (
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
{currentProfile.announce_url ? (
<a
href={currentProfile.announce_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-base font-semibold text-foreground hover:underline hover:opacity-80 transition-all whitespace-pre-wrap"
title={currentProfile.announce_url.replace(/\\n/g, "\n")}
>
<span>{currentProfile.announce.replace(/\\n/g, "\n")}</span>
<ExternalLink className="h-4 w-4 flex-shrink-0" />
</a>
) : (
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
{currentProfile.announce}
</p>
)}
</div>
)}
<div className="relative text-center">
<motion.h1
className={cn(
"text-4xl mb-2 font-semibold",
statusInfo.isAnimating && "animate-pulse"
)}
animate={{ color: statusInfo.color }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
{statusInfo.text}
</motion.h1>
<AnimatePresence mode="wait">
{uiProxyEnabled && (
<motion.div
key="traffic-stats"
className="absolute top-full left-1/2 -translate-x-1/2 mt-52 flex justify-center items-center text-sm text-muted-foreground gap-6"
variants={statsContainerVariants}
initial="initial"
animate="animate"
exit="exit"
style={{ willChange: "opacity, transform, filter" }}
>
<motion.div
className="flex items-center gap-1"
variants={statItemVariants}
style={{ willChange: "opacity, transform, filter" }}
>
<ArrowDown className="h-4 w-4 text-green-500" />
<motion.span layout>
{parseTraffic(connections.downloadTotal)}
</motion.span>
</motion.div>
<motion.div
className="flex items-center gap-1"
variants={statItemVariants}
style={{ willChange: "opacity, transform, filter" }}
>
<ArrowUp className="h-4 w-4 text-sky-500" />
<motion.span layout>
{parseTraffic(connections.uploadTotal)}
</motion.span>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="relative -translate-y-6">
<PowerButton
loading={isToggling}
checked={uiProxyEnabled}
onClick={handleToggleProxy}
disabled={showTunAlert || isToggling || profileItems.length === 0}
aria-label={t("Toggle Proxy")}
/>
</div>
{showTunAlert && (
<div className="w-full max-w-sm">
<Alert className="flex flex-col items-center gap-2 text-center" variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("Attention Required")}</AlertTitle>
<AlertDescription className="text-xs">
{t("TUN requires Service Mode or Admin Mode")}
</AlertDescription>
{!isServiceMode && !isAdminMode && (
<Button size="sm" className="mt-2" onClick={installServiceAndRestartCore}>
<Wrench className="mr-2 h-4 w-4" />
{t("Install Service")}
</Button>
)}
</Alert>
</div>
)}
<div className="w-full max-w-sm mt-4 flex justify-center">
{profileItems.length > 0 ? (
<ProxySelectors />
) : (
<Alert className="flex flex-col items-center gap-2 text-center">
<PlusCircle className="h-4 w-4" />
<AlertTitle>{t("Get Started")}</AlertTitle>
<AlertDescription className="whitespace-pre-wrap">
{t("You don't have any profiles yet. Add your first one to begin.")}
</AlertDescription>
<Button className="mt-2" onClick={() => viewerRef.current?.create()}>
{t("Add Profile")}
</Button>
</Alert>
)}
</div>
</div>
</main>
<footer className="flex justify-center p-4 flex-shrink-0">
{currentProfile?.support_url && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{t("Support")}:</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleUpdateProfile}
disabled={isUpdating}
className="flex-shrink-0"
<a
href={currentProfile.support_url}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-primary"
>
{isUpdating ? <Loader2 className="h-5 w-5 animate-spin" /> : <RefreshCw className="h-5 w-5" />}
</Button>
{currentProfile.support_url.includes("t.me") ||
currentProfile.support_url.includes("telegram") ||
currentProfile.support_url.startsWith("tg://") ? (
<Send className="h-5 w-5" />
) : (
<Globe className="h-5 w-5" />
)}
</a>
</TooltipTrigger>
<TooltipContent><p>{t("Update Profile")}</p></TooltipContent>
<TooltipContent>
<p>{currentProfile.support_url}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
</div>
<div className="flex justify-end">
</div>
</header>
<main className="flex-1 overflow-y-auto flex items-center justify-center">
<div className="relative flex flex-col items-center gap-8 py-10 w-full max-w-4xl px-4">
{currentProfile?.announce && (
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
{currentProfile.announce_url ? (
<a
href={currentProfile.announce_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-base font-semibold text-foreground hover:underline hover:opacity-80 transition-all whitespace-pre-wrap"
title={currentProfile.announce_url.replace(/\\n/g, '\n')}
>
<span>{currentProfile.announce.replace(/\\n/g, '\n')}</span>
<ExternalLink className="h-4 w-4 flex-shrink-0" />
</a>
) : (
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
{currentProfile.announce}
</p>
)}
</div>
)}
<div className="relative text-center">
<h1
className={cn(
"text-4xl mb-2 font-semibold transition-colors duration-300",
statusInfo.isAnimating && "animate-pulse"
)}
style={{ color: statusInfo.color }}
>
{statusInfo.text}
</h1>
{isProxyEnabled && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-52 flex justify-center items-center text-sm text-muted-foreground gap-6">
<div className="flex items-center gap-1">
<ArrowDown className="h-4 w-4 text-green-500" />
{parseTraffic(connections.downloadTotal)}
</div>
<div className="flex items-center gap-1">
<ArrowUp className="h-4 w-4 text-sky-500" />
{parseTraffic(connections.uploadTotal)}
</div>
</div>
)}
</div>
</footer>
<div className="relative -translate-y-6">
<PowerButton
loading={isToggling}
checked={!!isProxyEnabled}
onClick={handleToggleProxy}
disabled={showTunAlert || isToggling || profileItems.length === 0}
aria-label={t("Toggle Proxy")}
/>
</div>
{showTunAlert && (
<div className="w-full max-w-sm">
<Alert
variant="destructive"
className="flex flex-col items-center gap-2 text-center"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("Attention Required")}</AlertTitle>
<AlertDescription className="text-xs">
{t("TUN requires Service Mode or Admin Mode")}
</AlertDescription>
{!isServiceMode && !isAdminMode && (
<Button
size="sm"
className="mt-2"
onClick={installServiceAndRestartCore}
>
<Wrench className="mr-2 h-4 w-4" />
{t("Install Service")}
</Button>
)}
</Alert>
</div>
)}
<div className="w-full max-w-sm mt-4 flex justify-center">
{profileItems.length > 0 ? (
<ProxySelectors />
) : (
<Alert className="flex flex-col items-center gap-2 text-center">
<PlusCircle className="h-4 w-4" />
<AlertTitle>{t("Get Started")}</AlertTitle>
<AlertDescription className="whitespace-pre-wrap">
{t(
"You don't have any profiles yet. Add your first one to begin.",
)}
</AlertDescription>
<Button
className="mt-2"
onClick={() => viewerRef.current?.create()}
>
{t("Add Profile")}
</Button>
</Alert>
)}
</div>
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
</div>
</main>
<footer className="flex justify-center p-4 flex-shrink-0">
{currentProfile?.support_url && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{t("Support")}:</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a href={currentProfile.support_url} target="_blank" rel="noopener noreferrer" className="transition-colors hover:text-primary">
{(currentProfile.support_url.includes('t.me') || currentProfile.support_url.includes('telegram') || currentProfile.support_url.startsWith('tg://')) ? (
<Send className="h-5 w-5" />
) : (
<Globe className="h-5 w-5" />
)}
</a>
</TooltipTrigger>
<TooltipContent>
<p>{currentProfile.support_url}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</footer>
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
</div>
);
};

View File

@@ -31,7 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {SidebarTrigger} from "@/components/ui/sidebar";
import { SidebarTrigger } from "@/components/ui/sidebar";
const LogPage = () => {
const { t } = useTranslation();

View File

@@ -55,13 +55,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
PlusCircle,
RefreshCw,
Zap,
FileText,
Loader2,
} from "lucide-react";
import { PlusCircle, RefreshCw, Zap, FileText, Loader2 } from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
const ProfilePage = () => {

View File

@@ -1,4 +1,10 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from "react";
import React, {
useState,
useMemo,
useRef,
useEffect,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { useAppData } from "@/providers/app-data-provider";

View File

@@ -88,13 +88,13 @@ export const AppDataProvider = ({
const newProfileId = event.payload;
const now = Date.now();
console.log(`[AppDataProvider] Profile切换事件: ${newProfileId}`);
console.log(`[AppDataProvider] Profile switched: ${newProfileId}`);
if (
lastProfileId === newProfileId &&
now - lastUpdateTime < refreshThrottle
) {
console.log("[AppDataProvider] 重复事件被防抖,跳过");
console.log("[AppDataProvider] Duplicate event debounced, skip");
return;
}
@@ -103,7 +103,7 @@ export const AppDataProvider = ({
setTimeout(async () => {
try {
console.log("[AppDataProvider] 强制刷新代理缓存");
console.log("[AppDataProvider] Force refresh proxy cache");
const refreshPromise = Promise.race([
forceRefreshProxies(),
@@ -117,15 +117,15 @@ export const AppDataProvider = ({
await refreshPromise;
console.log("[AppDataProvider] 刷新前端代理数据");
console.log("[AppDataProvider] Refresh frontend proxy data");
await refreshProxy();
console.log("[AppDataProvider] Profile切换的代理数据刷新完成");
console.log("[AppDataProvider] Proxy data refreshed for profile switch");
} catch (error) {
console.error("[AppDataProvider] 强制刷新代理缓存失败:", error);
console.error("[AppDataProvider] Force refresh proxy cache failed:", error);
refreshProxy().catch((e) =>
console.warn("[AppDataProvider] 普通刷新也失败:", e),
console.warn("[AppDataProvider] Normal refresh also failed:", e),
);
}
}, 0);
@@ -134,14 +134,14 @@ export const AppDataProvider = ({
// 监听Clash配置刷新事件(enhance操作等)
const handleRefreshClash = () => {
const now = Date.now();
console.log("[AppDataProvider] Clash配置刷新事件");
console.log("[AppDataProvider] Clash config refresh event");
if (now - lastUpdateTime > refreshThrottle) {
lastUpdateTime = now;
setTimeout(async () => {
try {
console.log("[AppDataProvider] Clash刷新 - 强制刷新代理缓存");
console.log("[AppDataProvider] Clash refresh - force refresh proxy cache");
// 添加超时保护
const refreshPromise = Promise.race([
@@ -158,11 +158,11 @@ export const AppDataProvider = ({
await refreshProxy();
} catch (error) {
console.error(
"[AppDataProvider] Clash刷新时强制刷新代理缓存失败:",
"[AppDataProvider] Clash refresh forcing proxy cache refresh failed:",
error,
);
refreshProxy().catch((e) =>
console.warn("[AppDataProvider] Clash刷新普通刷新也失败:", e),
console.warn("[AppDataProvider] Clash refresh normal refresh also failed:", e),
);
}
}, 0);
@@ -181,7 +181,7 @@ export const AppDataProvider = ({
);
};
} catch (error) {
console.error("[AppDataProvider] 事件监听器设置失败:", error);
console.error("[AppDataProvider] Failed to set up event listeners:", error);
return () => {};
}
};
@@ -279,7 +279,7 @@ export const AppDataProvider = ({
if (!server) return () => {};
console.log(
`[Connections][${AppDataProvider.name}] 正在连接: ${server}/connections`,
`[Connections][${AppDataProvider.name}] Connecting: ${server}/connections`,
);
const socket = createAuthSockette(`${server}/connections`, secret, {
timeout: 5000,
@@ -322,7 +322,7 @@ export const AppDataProvider = ({
);
} catch (err) {
console.error(
`[Connections][${AppDataProvider.name}] 解析数据错误:`,
`[Connections][${AppDataProvider.name}] Failed to parse data:`,
err,
event.data,
);
@@ -330,26 +330,26 @@ export const AppDataProvider = ({
},
onopen: (event) => {
console.log(
`[Connections][${AppDataProvider.name}] WebSocket 连接已建立`,
`[Connections][${AppDataProvider.name}] WebSocket connected`,
event,
);
},
onerror(event) {
console.error(
`[Connections][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`,
`[Connections][${AppDataProvider.name}] WebSocket error or max retries reached`,
event,
);
next(null, { connections: [], uploadTotal: 0, downloadTotal: 0 });
},
onclose: (event) => {
console.log(
`[Connections][${AppDataProvider.name}] WebSocket 连接关闭`,
`[Connections][${AppDataProvider.name}] WebSocket closed`,
event.code,
event.reason,
);
if (event.code !== 1000 && event.code !== 1001) {
console.warn(
`[Connections][${AppDataProvider.name}] 连接非正常关闭,重置数据`,
`[Connections][${AppDataProvider.name}] Abnormal close, resetting data`,
);
next(null, { connections: [], uploadTotal: 0, downloadTotal: 0 });
}
@@ -357,7 +357,7 @@ export const AppDataProvider = ({
});
return () => {
console.log(`[Connections][${AppDataProvider.name}] 清理WebSocket连接`);
console.log(`[Connections][${AppDataProvider.name}] Cleaning up WebSocket connection`);
socket.close();
};
},
@@ -373,7 +373,7 @@ export const AppDataProvider = ({
if (!server) return () => {};
console.log(
`[Traffic][${AppDataProvider.name}] 正在连接: ${server}/traffic`,
`[Traffic][${AppDataProvider.name}] Connecting: ${server}/traffic`,
);
const socket = createAuthSockette(`${server}/traffic`, secret, {
onmessage(event) {
@@ -387,13 +387,13 @@ export const AppDataProvider = ({
next(null, data);
} else {
console.warn(
`[Traffic][${AppDataProvider.name}] 收到无效数据:`,
`[Traffic][${AppDataProvider.name}] Received invalid data:`,
data,
);
}
} catch (err) {
console.error(
`[Traffic][${AppDataProvider.name}] 解析数据错误:`,
`[Traffic][${AppDataProvider.name}] Failed to parse data:`,
err,
event.data,
);
@@ -401,26 +401,26 @@ export const AppDataProvider = ({
},
onopen: (event) => {
console.log(
`[Traffic][${AppDataProvider.name}] WebSocket 连接已建立`,
`[Traffic][${AppDataProvider.name}] WebSocket connected`,
event,
);
},
onerror(event) {
console.error(
`[Traffic][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`,
`[Traffic][${AppDataProvider.name}] WebSocket error or max retries reached`,
event,
);
next(null, { up: 0, down: 0 });
},
onclose: (event) => {
console.log(
`[Traffic][${AppDataProvider.name}] WebSocket 连接关闭`,
`[Traffic][${AppDataProvider.name}] WebSocket closed`,
event.code,
event.reason,
);
if (event.code !== 1000 && event.code !== 1001) {
console.warn(
`[Traffic][${AppDataProvider.name}] 连接非正常关闭,重置数据`,
`[Traffic][${AppDataProvider.name}] Abnormal close, resetting data`,
);
next(null, { up: 0, down: 0 });
}
@@ -428,7 +428,7 @@ export const AppDataProvider = ({
});
return () => {
console.log(`[Traffic][${AppDataProvider.name}] 清理WebSocket连接`);
console.log(`[Traffic][${AppDataProvider.name}] Cleaning up WebSocket connection`);
socket.close();
};
},
@@ -443,7 +443,7 @@ export const AppDataProvider = ({
if (!server) return () => {};
console.log(
`[Memory][${AppDataProvider.name}] 正在连接: ${server}/memory`,
`[Memory][${AppDataProvider.name}] Connecting: ${server}/memory`,
);
const socket = createAuthSockette(`${server}/memory`, secret, {
onmessage(event) {
@@ -453,13 +453,13 @@ export const AppDataProvider = ({
next(null, data);
} else {
console.warn(
`[Memory][${AppDataProvider.name}] 收到无效数据:`,
`[Memory][${AppDataProvider.name}] Received invalid data:`,
data,
);
}
} catch (err) {
console.error(
`[Memory][${AppDataProvider.name}] 解析数据错误:`,
`[Memory][${AppDataProvider.name}] Failed to parse data:`,
err,
event.data,
);
@@ -467,26 +467,26 @@ export const AppDataProvider = ({
},
onopen: (event) => {
console.log(
`[Memory][${AppDataProvider.name}] WebSocket 连接已建立`,
`[Memory][${AppDataProvider.name}] WebSocket connected`,
event,
);
},
onerror(event) {
console.error(
`[Memory][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`,
`[Memory][${AppDataProvider.name}] WebSocket error or max retries reached`,
event,
);
next(null, { inuse: 0 });
},
onclose: (event) => {
console.log(
`[Memory][${AppDataProvider.name}] WebSocket 连接关闭`,
`[Memory][${AppDataProvider.name}] WebSocket closed`,
event.code,
event.reason,
);
if (event.code !== 1000 && event.code !== 1001) {
console.warn(
`[Memory][${AppDataProvider.name}] 连接非正常关闭,重置数据`,
`[Memory][${AppDataProvider.name}] Abnormal close, resetting data`,
);
next(null, { inuse: 0 });
}
@@ -494,7 +494,7 @@ export const AppDataProvider = ({
});
return () => {
console.log(`[Memory][${AppDataProvider.name}] 清理WebSocket连接`);
console.log(`[Memory][${AppDataProvider.name}] Cleaning up WebSocket connection`);
socket.close();
};
},
@@ -521,13 +521,13 @@ export const AppDataProvider = ({
const isPacMode = verge.proxy_auto_config ?? false;
if (isPacMode) {
// PAC模式:显示我们期望设置的代理地址
// PAC mode: show expected proxy address
const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort =
verge.verge_mixed_port || clashConfig["mixed-port"] || 7897;
return `${proxyHost}:${proxyPort}`;
} else {
// HTTP代理模式:优先使用系统地址,但如果格式不正确则使用期望地址
// HTTP proxy mode: prefer system address, else fallback to expected address
const systemServer = sysproxy?.server;
if (
systemServer &&
@@ -536,7 +536,7 @@ export const AppDataProvider = ({
) {
return systemServer;
} else {
// 系统地址无效,返回期望的代理地址
// Invalid system address; return expected proxy address
const proxyHost = verge.proxy_host || "127.0.0.1";
const proxyPort =
verge.verge_mixed_port || clashConfig["mixed-port"] || 7897;
@@ -612,7 +612,7 @@ export const useAppData = () => {
const context = useContext(AppDataContext);
if (!context) {
throw new Error("useAppData必须在AppDataProvider内使用");
throw new Error("useAppData must be used within AppDataProvider");
}
return context;

View File

@@ -279,13 +279,13 @@ export const getGroupProxyDelays = async (
};
console.log(
`[API] 获取代理组延迟,组: ${groupName}, URL: ${params.url}, 超时: ${params.timeout}ms`,
`[API] Get proxy group delay, group: ${groupName}, URL: ${params.url}, timeout: ${params.timeout}ms`,
);
try {
const instance = await getAxios();
console.log(
`[API] 发送HTTP请求: GET /group/${encodeURIComponent(groupName)}/delay`,
`[API] Send HTTP request: GET /group/${encodeURIComponent(groupName)}/delay`,
);
const result = await instance.get(
@@ -294,12 +294,12 @@ export const getGroupProxyDelays = async (
);
console.log(
`[API] 获取代理组延迟成功,组: ${groupName}, 结果数量:`,
`[API] Get proxy group delay success, group: ${groupName}, result count:`,
Object.keys(result || {}).length,
);
return result as any as Record<string, number>;
} catch (error) {
console.error(`[API] 获取代理组延迟失败,组: ${groupName}`, error);
console.error(`[API] Get proxy group delay failed, group: ${groupName}`, error);
throw error;
}
};
@@ -476,7 +476,7 @@ export const getIpInfo = async (): Promise<IpInfo> => {
// 配置参数
const maxRetries = 3;
const serviceTimeout = 5000;
const overallTimeout = 20000; // 增加总超时时间以容纳延迟
const overallTimeout = 20000; // increase total timeout to accommodate delays
const overallTimeoutController = new AbortController();
const overallTimeoutId = setTimeout(() => {
@@ -488,7 +488,7 @@ export const getIpInfo = async (): Promise<IpInfo> => {
let lastError: Error | null = null;
for (const service of shuffledServices) {
console.log(`尝试IP检测服务: ${service.url}`);
console.log(`Trying IP detection service: ${service.url}`);
for (let attempt = 0; attempt < maxRetries; attempt++) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
@@ -508,17 +508,17 @@ export const getIpInfo = async (): Promise<IpInfo> => {
if (timeoutId) clearTimeout(timeoutId);
if (response.data && response.data.ip) {
console.log(`IP检测成功,使用服务: ${service.url}`);
console.log(`IP detection succeeded, using service: ${service.url}`);
return service.mapping(response.data);
} else {
throw new Error(`无效的响应格式 from ${service.url}`);
throw new Error(`Invalid response format from ${service.url}`);
}
} catch (error: any) {
if (timeoutId) clearTimeout(timeoutId);
lastError = error;
console.log(
`尝试 ${attempt + 1}/${maxRetries} 失败 (${service.url}):`,
`Attempt ${attempt + 1}/${maxRetries} failed (${service.url}):`,
error.message,
);
@@ -534,9 +534,9 @@ export const getIpInfo = async (): Promise<IpInfo> => {
}
if (lastError) {
throw new Error(`所有IP检测服务都失败: ${lastError.message}`);
throw new Error(`All IP detection services failed: ${lastError.message}`);
} else {
throw new Error("没有可用的IP检测服务");
throw new Error("No available IP detection services");
}
} finally {
clearTimeout(overallTimeoutId);

View File

@@ -112,15 +112,15 @@ export async function getSystemProxy() {
export async function getAutotemProxy() {
try {
console.log("[API] 开始调用 get_auto_proxy");
console.log("[API] Start calling get_auto_proxy");
const result = await invoke<{
enable: boolean;
url: string;
}>("get_auto_proxy");
console.log("[API] get_auto_proxy 调用成功:", result);
console.log("[API] get_auto_proxy success:", result);
return result;
} catch (error) {
console.error("[API] get_auto_proxy 调用失败:", error);
console.error("[API] get_auto_proxy failed:", error);
return {
enable: false,
url: "",
@@ -132,7 +132,7 @@ export async function getAutoLaunchStatus() {
try {
return await invoke<boolean>("get_auto_launch_status");
} catch (error) {
console.error("获取自启动状态失败:", error);
console.error("Failed to get auto-launch state:", error);
return false;
}
}
@@ -195,7 +195,7 @@ export async function cmdGetProxyDelay(
// 确保URL不为空
const testUrl = url || "https://cp.cloudflare.com/generate_204";
console.log(
`[API] 调用延迟测试API代理: ${name}, 超时: ${timeout}ms, URL: ${testUrl}`,
`[API] Calling delay test API, proxy: ${name}, timeout: ${timeout}ms, URL: ${testUrl}`,
);
try {
@@ -212,19 +212,19 @@ export async function cmdGetProxyDelay(
// 验证返回结果中是否有delay字段并且值是一个有效的数字
if (result && typeof result.delay === "number") {
console.log(
`[API] 延迟测试API调用成功代理: ${name}, 延迟: ${result.delay}ms`,
`[API] Delay test API success, proxy: ${name}, delay: ${result.delay}ms`,
);
return result;
} else {
console.error(
`[API] 延迟测试API返回无效结果代理: ${name}, 结果:`,
`[API] Delay test API returned invalid result, proxy: ${name}, result:`,
result,
);
// 返回一个有效的结果对象,但标记为超时
return { delay: 1e6 };
}
} catch (error) {
console.error(`[API] 延迟测试API调用失败代理: ${name}`, error);
console.error(`[API] Delay test API failed, proxy: ${name}`, error);
// 返回一个有效的结果对象,但标记为错误
return { delay: 1e6 };
}
@@ -232,7 +232,7 @@ export async function cmdGetProxyDelay(
/// 用于profile切换等场景
export async function forceRefreshProxies() {
console.log("[API] 强制刷新代理缓存");
console.log("[API] Force refresh proxy cache");
return invoke<any>("force_refresh_proxies");
}
@@ -392,7 +392,7 @@ export const isAdmin = async () => {
try {
return await invoke<boolean>("is_admin");
} catch (error) {
console.error("检查管理员权限失败:", error);
console.error("Failed to check admin privileges:", error);
return false;
}
};
@@ -401,6 +401,9 @@ export async function getNextUpdateTime(uid: string) {
return invoke<number | null>("get_next_update_time", { uid });
}
export async function createProfileFromShareLink(link: string, templateName: string) {
export async function createProfileFromShareLink(
link: string,
templateName: string,
) {
return invoke<void>("create_profile_from_share_link", { link, templateName });
}

View File

@@ -83,10 +83,10 @@ export const initGlobalLogService = (
// 创建新的WebSocket连接使用新的认证方法
const wsUrl = buildWSUrl(server, logLevel);
console.log(`[GlobalLog] 正在连接日志服务: ${wsUrl}`);
console.log(`[GlobalLog] Connecting to log service: ${wsUrl}`);
if (!server) {
console.warn("[GlobalLog] 服务器地址为空,无法建立连接");
console.warn("[GlobalLog] Server URL is empty, cannot establish connection");
return;
}
@@ -98,11 +98,11 @@ export const initGlobalLogService = (
const time = dayjs().format("MM-DD HH:mm:ss");
appendLog({ ...data, time });
} catch (error) {
console.error("[GlobalLog] 解析日志数据失败:", error);
console.error("[GlobalLog] Failed to parse log data:", error);
}
},
onerror(event) {
console.error("[GlobalLog] WebSocket连接错误", event);
console.error("[GlobalLog] WebSocket connection error", event);
// 记录错误状态但不关闭连接,让重连机制起作用
useGlobalLogStore.setState({ isConnected: false });
@@ -114,16 +114,16 @@ export const initGlobalLogService = (
"type" in event &&
event.type === "error"
) {
console.error("[GlobalLog] 连接已彻底失败,关闭连接");
console.error("[GlobalLog] Connection exhausted retries, closing");
closeGlobalLogConnection();
}
},
onclose(event) {
console.log("[GlobalLog] WebSocket连接关闭", event);
console.log("[GlobalLog] WebSocket connection closed", event);
useGlobalLogStore.setState({ isConnected: false });
},
onopen(event) {
console.log("[GlobalLog] WebSocket连接已建立", event);
console.log("[GlobalLog] WebSocket connection established", event);
useGlobalLogStore.setState({ isConnected: true });
},
});

View File

@@ -1,25 +1,29 @@
import { toast } from "sonner";
type NoticeType = 'success' | 'error' | 'info' | 'warning';
type NoticeType = "success" | "error" | "info" | "warning";
export const showNotice = (type: NoticeType, message: string, duration?: number) => {
export const showNotice = (
type: NoticeType,
message: string,
duration?: number,
) => {
const options = duration ? { duration } : {};
switch (type) {
case 'success':
case "success":
toast.success(message, options);
break;
case 'error':
case "error":
toast.error(message, options);
break;
case 'info':
case "info":
toast.info(message, options);
break;
case 'warning':
case "warning":
toast.warning(message, options);
break;
default:
toast(message, options);
break;
}
};
};

View File

@@ -154,7 +154,7 @@ interface IConnectionsItem {
start: string;
chains: string[];
rule: string;
rulePayload: string;
rulePayload?: string;
curUpload?: number; // upload speed, calculate at runtime
curDownload?: number; // download speed, calculate at runtime
}