34 Commits

Author SHA1 Message Date
coolcoala
53de1bc8b0 v0.2.4 2025-07-30 09:12:05 +03:00
coolcoala
77eacd3ab3 fixed icons 2025-07-30 09:12:05 +03:00
coolcoala
a2010e6d1d fixed renaming files 2025-07-30 09:11:57 +03:00
coolcoala
4ce6e9bfd7 updated UPDATELOG.md 2025-07-30 06:59:48 +03:00
coolcoala
9a3794073b fixed flag display when adding a link via vless:// 2025-07-30 06:53:02 +03:00
coolcoala
d6197d6d21 added traffic information display to the main page 2025-07-30 06:53:02 +03:00
coolcoala
1f321cf6bc fixed translations 2025-07-30 06:52:55 +03:00
coolcoala
5c6d3f4078 unused settings removed 2025-07-30 06:31:49 +03:00
coolcoala
6b8b95e4ca traffic information has been reworked 2025-07-30 06:31:49 +03:00
coolcoala
ae08d48641 added application icon to sidebar 2025-07-30 06:31:45 +03:00
coolcoala
d1ce5566cf added new background for dmg installer 2025-07-28 08:52:39 +03:00
coolcoala
5f027ebc79 started the process of renaming to Koala Clash 2025-07-28 08:43:36 +03:00
coolcoala
8cf83f8338 minor fix 2025-07-28 08:43:36 +03:00
coolcoala
b96e2c1fe0 notification of exceeding the number of devices in the subscription, support for vless:// links with templates by @legiz-ru 2025-07-28 08:43:30 +03:00
coolcoala
4ad1379773 new icons 2025-07-28 06:53:48 +03:00
coolcoala
ef0883f732 notifications in Telegram, and changes have been made so that the link to the release does not change over time 2025-07-26 09:28:24 +03:00
coolcoala
a2076b4e2d minor fix 2025-07-26 06:54:34 +03:00
coolcoala
0a3998530e the alphabetical index has been removed, and additional information about proxies is now hidden by default 2025-07-26 06:54:25 +03:00
coolcoala
ed2ec56a44 the size of modal windows has been adjusted due to an increase in the minimum window size 2025-07-26 06:53:35 +03:00
coolcoala
87473bdf92 fixed log color when dark theme is enabled 2025-07-26 06:53:00 +03:00
coolcoala
8186a6841a added icons for proxy groups 2025-07-26 06:52:36 +03:00
coolcoala
0a0b5b6612 direct was removed, and the translation for rules and global was replaced 2025-07-26 06:52:07 +03:00
coolcoala
72704f9dc9 the minimum window size has been changed 2025-07-26 06:50:42 +03:00
coolcoala
06ad23d904 added auto-scaling and scaling via key combination 2025-07-26 06:50:18 +03:00
coolcoala
fbd1c55f44 v0.2.3 2025-07-22 02:11:13 +03:00
coolcoala
9668a04a1a updated UPDATELOG.md 2025-07-21 03:41:07 +03:00
coolcoala
24af375a8e started work on translating console logs from Chinese to English 2025-07-21 03:40:47 +03:00
coolcoala
a32c973ab8 fixed problem with profile inactivation after adding via deeplink on windows 2025-07-21 03:06:37 +03:00
coolcoala
50beb913de fixed command mapping for macos installation 2025-07-21 03:06:22 +03:00
coolcoala
05f1ec7b34 added that it is not possible to enable proxy if no profile is available 2025-07-21 01:57:37 +03:00
coolcoala
9271b107b6 fixed a layout issue in the proxy menu, now all cards are the same size 2025-07-21 01:56:24 +03:00
coolcoala
e7208dd7d2 fixed problem with menu reopening when opening a page in a compressed window 2025-07-21 01:55:33 +03:00
coolcoala
e5dfb34082 v0.2.2 2025-07-19 03:57:29 +03:00
coolcoala
2ba5c4e706 new menu added, layout corrected in some places 2025-07-19 03:57:07 +03:00
59 changed files with 2112 additions and 964 deletions

View File

@@ -40,9 +40,91 @@ jobs:
fi
echo "Tag and package.json version are consistent."
create_release_notes:
name: Create Release Notes
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch UPDATE logs
id: fetch_update_logs
run: |
if [ -f "UPDATELOG.md" ]; then
UPDATE_LOGS=$(awk '/^## v/{if(flag) exit; flag=1} flag' UPDATELOG.md)
if [ -n "$UPDATE_LOGS" ]; then
echo "Found update logs"
echo "UPDATE_LOGS<<EOF" >> $GITHUB_ENV
echo "$UPDATE_LOGS" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
else
echo "No update sections found in UPDATELOG.md"
fi
else
echo "UPDATELOG.md file not found"
fi
shell: bash
- name: Get Version
run: |
sudo apt-get update
sudo apt-get install jq
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
echo "BUILDTIME=$(TZ=Europe/Moscow date)" >> $GITHUB_ENV
- run: |
if [ -z "$UPDATE_LOGS" ]; then
echo "No update logs found, using default message"
UPDATE_LOGS="More new features are now supported. Check for detailed changelog soon."
else
echo "Using found update logs"
fi
cat > release.txt << EOF
$UPDATE_LOGS
## 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_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/Clash\ Verge\ Rev\ Lite.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.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.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.armhfp.rpm"><img src="https://img.shields.io/badge/armhfp-default?style=flat&logo=fedora&label=RPM"> </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_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_arm64_fixed_webview2-setup.exe"><img src="https://badgen.net/badge/icon/arm64?icon=windows&label=exe"></a>
Created at ${{ env.BUILDTIME }}.
EOF
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{env.VERSION}}
name: "Koala Clash v${{env.VERSION}}"
body_path: release.txt
token: ${{ secrets.GITHUB_TOKEN }}
release:
name: Release Build
needs: check_tag_version
needs: [check_tag_version, create_release_notes]
strategy:
fail-fast: false
matrix:
@@ -97,6 +179,7 @@ jobs:
pnpm run prebuild ${{ matrix.target }}
- name: Tauri build
id: build
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
@@ -104,11 +187,56 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: "Clash Verge Rev Lite v__VERSION__"
tauriScript: pnpm
args: --target ${{ matrix.target }}
- name: Rename Artifact (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$version = ${{steps.build.outputs.appVersion}}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
foreach ($file in $files) {
$newName = $file.Name -replace "_${version}_", "_"
Rename-Item $file.FullName $newName
}
- name: Rename Artifact (Linux/macOS)
if: runner.os == 'Linux' || runner.os == 'macOS'
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")
new_filename=$(echo "$old_filename" \
| sed -E 's/_[0-9]+\.[0-9]+\.[0-9]+_/_/' \
| sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+//' \
)
new_path="${dir_path}/${new_filename}"
if [ "$old_path" != "$new_path" ]; then
echo " - '$old_filename' -> '$new_filename'"
mv "$old_path" "$new_path"
fi
done
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{steps.build.outputs.appVersion}}
name: "Koala Clash v${{steps.build.outputs.appVersion}}"
token: ${{ secrets.GITHUB_TOKEN }}
files: |
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
release-for-linux-arm:
name: Release Build for Linux ARM
strategy:
@@ -220,11 +348,34 @@ jobs:
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
echo "BUILDTIME=$(TZ=Europe/Moscow date)" >> $GITHUB_ENV
- name: Rename
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")
new_filename=$(echo "$old_filename" \
| sed -E 's/_[0-9]+\.[0-9]+\.[0-9]+_/_/' \
| sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+//' \
)
new_path="${dir_path}/${new_filename}"
if [ "$old_path" != "$new_path" ]; then
echo " - '$old_filename' -> '$new_filename'"
mv "$old_path" "$new_path"
fi
done
- name: Upload Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{env.VERSION}}
name: "Clash Verge Rev Lite v${{env.VERSION}}"
name: "Koala Clash v${{env.VERSION}}"
token: ${{ secrets.GITHUB_TOKEN }}
files: |
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
@@ -294,19 +445,19 @@ jobs:
run: |
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
$newName = $file.Name -replace "_${{steps.build.outputs.appVersion}}_", "_" -replace "-setup\.exe$", "_fixed_webview2-setup.exe"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*.nsis.zip"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
$newName = $file.Name -replace "_${{steps.build.outputs.appVersion}}_", "_" -replace "-setup\.nsis\.zip$", "_fixed_webview2-setup.nsis.zip"
Rename-Item $file.FullName $newName
}
$files = Get-ChildItem ".\src-tauri\target\${{ matrix.target }}\release\bundle\nsis\*-setup.exe.sig"
foreach ($file in $files) {
$newName = $file.Name -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
$newName = $file.Name -replace "_${{steps.build.outputs.appVersion}}_", "_" -replace "-setup\.exe\.sig$", "_fixed_webview2-setup.exe.sig"
Rename-Item $file.FullName $newName
}
@@ -314,7 +465,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: v${{steps.build.outputs.appVersion}}
name: "Clash Verge Rev Lite v${{steps.build.outputs.appVersion}}"
name: "Koala Clash v${{steps.build.outputs.appVersion}}"
token: ${{ secrets.GITHUB_TOKEN }}
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
@@ -374,9 +525,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
create_release_notes:
name: Create Release Notes
push-notify-to-telegram:
runs-on: ubuntu-latest
needs: [release-update, release-update-for-fixed-webview2]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -413,45 +564,28 @@ jobs:
else
echo "Using found update logs"
fi
cat > release.txt << EOF
Вышло обновление!
$UPDATE_LOGS
## Which version should I download?
### macOS
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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 }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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>
`sudo xattr -r -c /Applications/Clash\ Verge\ Rev\ Lite.app`
### Linux
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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 }}/Clash.Verge.Rev.Lite-${{ env.VERSION }}-1.x86_64.rpm"><img src="https://img.shields.io/badge/x64-default?style=flat&logo=fedora&label=RPM"> </a>
[Ссылка на релиз](https://github.com/coolcoala/clash-verge-rev-lite/releases/latest)
<a href="https://github.com/coolcoala/clash-verge-rev-lite/releases/download/v${{ env.VERSION }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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 }}/Clash.Verge.Rev.Lite-${{ env.VERSION }}-1.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 }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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 }}/Clash.Verge.Rev.Lite-${{ env.VERSION }}-1.armhfp.rpm"><img src="https://img.shields.io/badge/armhfp-default?style=flat&logo=fedora&label=RPM"> </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 }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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 }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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 }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_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 }}/Clash.Verge.Rev.Lite_${{ env.VERSION }}_arm64_fixed_webview2-setup.exe"><img src="https://badgen.net/badge/icon/arm64?icon=windows&label=exe"></a>
Created at ${{ env.BUILDTIME }}.
EOF
- name: Upload Release
uses: softprops/action-gh-release@v2
- name: notify to channel
uses: appleboy/telegram-action@master
with:
tag_name: v${{env.VERSION}}
name: "Clash Verge Rev Lite v${{env.VERSION}}"
body_path: release.txt
token: ${{ secrets.GITHUB_TOKEN }}
to: ${{ secrets.TELEGRAM_TO_CHANNEL }}
token: ${{ secrets.TELEGRAM_TOKEN }}
message_file: release.txt
- name: notify to group
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO_GROUP }}
token: ${{ secrets.TELEGRAM_TOKEN }}
message_file: release.txt
format: markdown

View File

@@ -1,3 +1,24 @@
## v0.2.4
- added auto-scaling and scaling via key combination
- direct was removed, and the translation for rules and global was replaced
- added icons for proxy groups on main page
- fixed log color when dark theme is enabled
- the alphabetical index has been removed, and additional information about proxies is now hidden by default
- notification of exceeding the number of devices in the subscription
- support for vless:// links with templates by @legiz-ru
- started the process of renaming to Koala Clash, replaced icons
- traffic information has been reworked on profile page
## v0.2.3
- fixed problem with profile inactivation after adding via deeplink on windows
- corrected layout on the proxy page, now all cards are the same size
- corrected announe transposition by \n
- 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"

19
hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
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)
}, [])
return !!isMobile
}

View File

@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "0.2.1",
"version": "0.2.4",
"license": "GPL-3.0-only",
"scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",

2
src-tauri/Cargo.lock generated
View File

@@ -1061,7 +1061,7 @@ dependencies = [
[[package]]
name = "clash-verge"
version = "0.2.1"
version = "0.2.4"
dependencies = [
"ab_glyph",
"aes-gcm",

View File

@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "0.2.1"
version = "0.2.4"
description = "clash verge"
authors = ["zzzgydi", "wonfen", "MystiPanda", "coolcoala"]
license = "GPL-3.0-only"

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -9,6 +9,11 @@ use crate::{
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(());
@@ -708,3 +713,467 @@ pub async fn update_profiles_on_startup() -> CmdResult {
Ok(())
}
#[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
tcp-concurrent: true
enable-process: true
find-process-mode: always
global-client-fingerprint: chrome
mode: rule
log-level: debug
ipv6: false
keep-alive-interval: 30
unified-delay: false
profile:
store-selected: true
store-fake-ip: true
sniffer:
enable: true
sniff:
HTTP:
ports: [80, 8080-8880]
override-destination: true
TLS:
ports: [443, 8443]
QUIC:
ports: [443, 8443]
tun:
enable: true
stack: mixed
dns-hijack: ['any:53']
auto-route: true
auto-detect-interface: true
strict-route: true
dns:
enable: true
listen: :1053
prefer-h3: false
ipv6: false
enhanced-mode: fake-ip
fake-ip-filter: ['+.lan', '+.local']
nameserver: ['https://doh.dns.sb/dns-query']
proxies:
- name: myproxy
type: vless
server: YOURDOMAIN
port: 443
uuid: YOURUUID
network: tcp
flow: xtls-rprx-vision
udp: true
tls: true
reality-opts:
public-key: YOURPUBLIC
short-id: YOURSHORTID
servername: YOURREALITYDEST
client-fingerprint: chrome
proxy-groups:
- name: PROXY
type: select
proxies:
- myproxy
rule-providers:
ru-bundle:
type: http
behavior: domain
format: mrs
url: https://github.com/legiz-ru/mihomo-rule-sets/raw/main/ru-bundle/rule.mrs
path: ./ru-bundle/rule.mrs
interval: 86400
refilter_domains:
type: http
behavior: domain
format: mrs
url: https://github.com/legiz-ru/mihomo-rule-sets/raw/main/re-filter/domain-rule.mrs
path: ./re-filter/domain-rule.mrs
interval: 86400
refilter_ipsum:
type: http
behavior: ipcidr
format: mrs
url: https://github.com/legiz-ru/mihomo-rule-sets/raw/main/re-filter/ip-rule.mrs
path: ./re-filter/ip-rule.mrs
interval: 86400
oisd_big:
type: http
behavior: domain
format: mrs
url: https://github.com/legiz-ru/mihomo-rule-sets/raw/main/oisd/big.mrs
path: ./oisd/big.mrs
interval: 86400
rules:
- OR,((DOMAIN,ipwhois.app),(DOMAIN,ipwho.is),(DOMAIN,api.ip.sb),(DOMAIN,ipapi.co),(DOMAIN,ipinfo.io)),PROXY
- RULE-SET,oisd_big,REJECT
- PROCESS-NAME,Discord.exe,PROXY
- RULE-SET,ru-bundle,PROXY
- RULE-SET,refilter_domains,PROXY
- RULE-SET,refilter_ipsum,PROXY
- MATCH,DIRECT
"#;
const WITHOUT_RU_TEMPLATE: &str = r#"
mixed-port: 7890
allow-lan: true
tcp-concurrent: true
enable-process: true
find-process-mode: always
mode: rule
log-level: debug
ipv6: false
keep-alive-interval: 30
unified-delay: false
profile:
store-selected: true
store-fake-ip: true
sniffer:
enable: true
force-dns-mapping: true
parse-pure-ip: true
sniff:
HTTP:
ports:
- 80
- 8080-8880
override-destination: true
TLS:
ports:
- 443
- 8443
tun:
enable: true
stack: gvisor
auto-route: true
auto-detect-interface: false
dns-hijack:
- any:53
strict-route: true
mtu: 1500
dns:
enable: true
prefer-h3: true
use-hosts: true
use-system-hosts: true
listen: 127.0.0.1:6868
ipv6: false
enhanced-mode: redir-host
default-nameserver:
- tls://1.1.1.1
- tls://1.0.0.1
proxy-server-nameserver:
- tls://1.1.1.1
- tls://1.0.0.1
direct-nameserver:
- tls://77.88.8.8
nameserver:
- https://cloudflare-dns.com/dns-query
proxies:
- name: myproxy
type: vless
server: YOURDOMAIN
port: 443
uuid: YOURUUID
network: tcp
flow: xtls-rprx-vision
udp: true
tls: true
reality-opts:
public-key: YOURPUBLIC
short-id: YOURSHORTID
servername: YOURREALITYDEST
client-fingerprint: chrome
proxy-groups:
- name: PROXY
icon: https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color/Hijacking.png
type: select
proxies:
- ⚡️ Fastest
- 📶 First Available
- myproxy
- name: ⚡️ Fastest
icon: https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color/Auto.png
type: url-test
tolerance: 150
url: https://cp.cloudflare.com/generate_204
interval: 300
proxies:
- myproxy
- name: 📶 First Available
icon: https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color/Download.png
type: fallback
url: https://cp.cloudflare.com/generate_204
interval: 300
proxies:
- myproxy
rule-providers:
torrent-trackers:
type: http
behavior: domain
format: mrs
url: https://github.com/legiz-ru/mihomo-rule-sets/raw/main/other/torrent-trackers.mrs
path: ./rule-sets/torrent-trackers.mrs
interval: 86400
torrent-clients:
type: http
behavior: classical
format: yaml
url: https://github.com/legiz-ru/mihomo-rule-sets/raw/main/other/torrent-clients.yaml
path: ./rule-sets/torrent-clients.yaml
interval: 86400
geosite-ru:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/category-ru.mrs
path: ./geosite-ru.mrs
interval: 86400
xiaomi:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/xiaomi.mrs
path: ./rule-sets/xiaomi.mrs
interval: 86400
blender:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/blender.mrs
path: ./rule-sets/blender.mrs
interval: 86400
drweb:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/drweb.mrs
path: ./rule-sets/drweb.mrs
interval: 86400
debian:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/debian.mrs
path: ./rule-sets/debian.mrs
interval: 86400
canonical:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/canonical.mrs
path: ./rule-sets/canonical.mrs
interval: 86400
python:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/python.mrs
path: ./rule-sets/python.mrs
interval: 86400
geoip-ru:
type: http
behavior: ipcidr
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geoip/ru.mrs
path: ./geoip-ru.mrs
interval: 86400
geosite-private:
type: http
behavior: domain
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geosite/private.mrs
path: ./geosite-private.mrs
interval: 86400
geoip-private:
type: http
behavior: ipcidr
format: mrs
url: https://github.com/MetaCubeX/meta-rules-dat/raw/meta/geo/geoip/private.mrs
path: ./geoip-private.mrs
interval: 86400
rules:
- DOMAIN-SUFFIX,habr.com,PROXY
- DOMAIN-SUFFIX,kemono.su,PROXY
- DOMAIN-SUFFIX,jut.su,PROXY
- DOMAIN-SUFFIX,kara.su,PROXY
- DOMAIN-SUFFIX,theins.ru,PROXY
- DOMAIN-SUFFIX,tvrain.ru,PROXY
- DOMAIN-SUFFIX,echo.msk.ru,PROXY
- DOMAIN-SUFFIX,the-village.ru,PROXY
- DOMAIN-SUFFIX,snob.ru,PROXY
- DOMAIN-SUFFIX,novayagazeta.ru,PROXY
- DOMAIN-SUFFIX,moscowtimes.ru,PROXY
- DOMAIN-KEYWORD,animego,PROXY
- DOMAIN-KEYWORD,yummyanime,PROXY
- DOMAIN-KEYWORD,yummy-anime,PROXY
- DOMAIN-KEYWORD,animeportal,PROXY
- DOMAIN-KEYWORD,anime-portal,PROXY
- DOMAIN-KEYWORD,animedub,PROXY
- DOMAIN-KEYWORD,anidub,PROXY
- DOMAIN-KEYWORD,animelib,PROXY
- DOMAIN-KEYWORD,ikianime,PROXY
- DOMAIN-KEYWORD,anilibria,PROXY
- PROCESS-NAME,Discord.exe,PROXY
- PROCESS-NAME,discord,PROXY
- RULE-SET,geosite-private,DIRECT,no-resolve
- RULE-SET,geoip-private,DIRECT
- RULE-SET,torrent-clients,DIRECT
- RULE-SET,torrent-trackers,DIRECT
- DOMAIN-SUFFIX,.ru,DIRECT
- DOMAIN-SUFFIX,.su,DIRECT
- DOMAIN-SUFFIX,.ru.com,DIRECT
- DOMAIN-SUFFIX,.ru.net,DIRECT
- DOMAIN-SUFFIX,wikipedia.org,DIRECT
- DOMAIN-SUFFIX,kudago.com,DIRECT
- DOMAIN-SUFFIX,kinescope.io,DIRECT
- DOMAIN-SUFFIX,redheadsound.studio,DIRECT
- DOMAIN-SUFFIX,plplayer.online,DIRECT
- DOMAIN-SUFFIX,lomont.site,DIRECT
- DOMAIN-SUFFIX,remanga.org,DIRECT
- DOMAIN-SUFFIX,shopstory.live,DIRECT
- DOMAIN-KEYWORD,miradres,DIRECT
- DOMAIN-KEYWORD,premier,DIRECT
- DOMAIN-KEYWORD,shutterstock,DIRECT
- DOMAIN-KEYWORD,2gis,DIRECT
- DOMAIN-KEYWORD,diginetica,DIRECT
- DOMAIN-KEYWORD,kinescopecdn,DIRECT
- DOMAIN-KEYWORD,researchgate,DIRECT
- DOMAIN-KEYWORD,springer,DIRECT
- DOMAIN-KEYWORD,nextcloud,DIRECT
- DOMAIN-KEYWORD,wiki,DIRECT
- DOMAIN-KEYWORD,kaspersky,DIRECT
- DOMAIN-KEYWORD,stepik,DIRECT
- DOMAIN-KEYWORD,likee,DIRECT
- DOMAIN-KEYWORD,snapchat,DIRECT
- DOMAIN-KEYWORD,yappy,DIRECT
- DOMAIN-KEYWORD,pikabu,DIRECT
- DOMAIN-KEYWORD,okko,DIRECT
- DOMAIN-KEYWORD,wink,DIRECT
- DOMAIN-KEYWORD,kion,DIRECT
- DOMAIN-KEYWORD,roblox,DIRECT
- DOMAIN-KEYWORD,ozon,DIRECT
- DOMAIN-KEYWORD,wildberries,DIRECT
- DOMAIN-KEYWORD,aliexpress,DIRECT
- RULE-SET,geosite-ru,DIRECT
- RULE-SET,xiaomi,DIRECT
- RULE-SET,blender,DIRECT
- RULE-SET,drweb,DIRECT
- RULE-SET,debian,DIRECT
- RULE-SET,canonical,DIRECT
- RULE-SET,python,DIRECT
- RULE-SET,geoip-ru,DIRECT
- MATCH,PROXY
"#;
let template_yaml = match template_name.as_str() {
"without_ru" => WITHOUT_RU_TEMPLATE,
_ => DEFAULT_TEMPLATE,
};
let parsed_url = Url::parse(&link).map_err(|e| e.to_string())?;
let scheme = parsed_url.scheme();
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("port".into(), parsed_url.port().unwrap_or(443).into());
proxy_map.insert("udp".into(), true.into());
match scheme {
"vless" | "trojan" => {
proxy_map.insert("uuid".into(), parsed_url.username().into());
let mut reality_opts: BTreeMap<String, Value> = BTreeMap::new();
for (key, value) in parsed_url.query_pairs() {
match key.as_ref() {
"security" if value == "reality" => {
proxy_map.insert("tls".into(), true.into());
}
"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()); }
_ => {}
}
}
if !reality_opts.is_empty() {
proxy_map.insert("reality-opts".into(), serde_yaml::to_value(reality_opts).map_err(|e| e.to_string())?);
}
}
"ss" => {
if let Ok(decoded_user) = STANDARD.decode(parsed_url.username()) {
if let Ok(user_str) = String::from_utf8(decoded_user) {
if let Some((cipher, password)) = user_str.split_once(':') {
proxy_map.insert("cipher".into(), cipher.into());
proxy_map.insert("password".into(), password.into());
}
}
}
}
"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()); }
}
}
}
}
_ => {
}
}
let mut config: Value = serde_yaml::from_str(template_yaml).map_err(|e| e.to_string())?;
if let Some(proxies) = config.get_mut("proxies").and_then(|v| v.as_sequence_mut()) {
proxies.clear();
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()) {
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()) {
let new_proxies_list: Vec<Value> = proxies_list
.iter()
.map(|p| {
if p.as_str() == Some("myproxy") {
proxy_name.clone().into()
} else {
p.clone()
}
})
.collect();
*proxies_list = new_proxies_list;
}
}
}
}
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())?;
wrap_err!(Config::profiles().data().append_item(item))
}

View File

@@ -417,6 +417,13 @@ impl PrfItem {
None => None,
};
if let Some(announce_msg) = &announce {
let lower_msg = announce_msg.to_lowercase();
if lower_msg.contains("device") || lower_msg.contains("устройств") {
bail!(announce_msg.clone());
}
}
let announce_url = match header.get("announce-url") {
Some(value) => {
let str_value = value.to_str().unwrap_or("");

View File

@@ -304,6 +304,7 @@ pub fn run() {
cmd::save_profile_file,
cmd::get_next_update_time,
cmd::update_profiles_on_startup,
cmd::create_profile_from_share_link,
// script validation
cmd::script_validate_notice,
cmd::validate_script_file,
@@ -352,7 +353,7 @@ pub fn run() {
.get_webview_window("main")
{
logging!(info, Type::Window, true, "设置macOS窗口标题");
let _ = window.set_title("Clash Verge Rev Lite");
let _ = window.set_title("Koala Clash");
}
}
}

View File

@@ -335,12 +335,12 @@ pub fn create_window(is_show: bool) -> bool {
"main", /* the unique window label */
tauri::WebviewUrl::App("index.html".into()),
)
.title("Clash Verge Rev Lite")
.title("Koala Clash")
.center()
.decorations(true)
.fullscreen(false)
.inner_size(DEFAULT_WIDTH as f64, DEFAULT_HEIGHT as f64)
.min_inner_size(520.0, 520.0)
.min_inner_size(1000.0, 800.0)
.visible(true) // 立即显示窗口,避免用户等待
.initialization_script(
r#"
@@ -565,7 +565,7 @@ pub async fn resolve_scheme(param: String) -> Result<()> {
Some(url) => {
log::info!(target:"app", "decoded subscription url: {url}");
create_window(false);
create_window(true);
match PrfItem::from_url(url.as_ref(), name, None, None).await {
Ok(item) => {
let uid = item.uid.clone().unwrap();

View File

@@ -1,9 +1,9 @@
{
"version": "0.2.1",
"version": "0.2.4",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
"longDescription": "Clash Verge Rev Lite",
"longDescription": "Koala Clash",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -12,11 +12,11 @@
"icons/icon.ico"
],
"resources": ["resources", "resources/locales/*"],
"publisher": "Clash Verge Rev Lite",
"publisher": "Koala Clash",
"externalBin": ["sidecar/verge-mihomo", "sidecar/verge-mihomo-alpha"],
"copyright": "GNU General Public License v3.0",
"category": "DeveloperTool",
"shortDescription": "Clash Verge Rev Lite",
"shortDescription": "Koala Clash",
"createUpdaterArtifacts": true
},
"build": {
@@ -25,7 +25,7 @@
"beforeDevCommand": "pnpm run web:dev",
"devUrl": "http://localhost:3000/"
},
"productName": "Clash Verge Rev Lite",
"productName": "Koala Clash",
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
"plugins": {
"updater": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"identifier": "io.github.clash-verge-rev.clash-verge-rev",
"productName": "Clash Verge Rev Lite",
"productName": "Koala Clash",
"bundle": {
"targets": ["app", "dmg"],
"macOS": {
@@ -14,11 +14,11 @@
"background": "images/background.png",
"appPosition": {
"x": 180,
"y": 170
"y": 200
},
"applicationFolderPosition": {
"x": 480,
"y": 170
"y": 200
},
"windowSize": {
"height": 400,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
src/assets/image/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

View File

@@ -37,6 +37,7 @@ interface IProxyGroup {
now: string;
hidden: boolean;
all: (string | { name: string })[];
icon?: string;
}
// --- Вспомогательная функция для цвета задержки ---
@@ -112,6 +113,7 @@ export const ProxySelectors: React.FC = () => {
(localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) ||
"default",
);
const enable_group_icon = verge?.enable_group_icon ?? true;
useEffect(() => {
if (!proxies?.groups) return;
@@ -291,21 +293,31 @@ export const ProxySelectors: React.FC = () => {
disabled={isGlobalMode || isDirectMode}
>
<SelectTrigger className="w-100">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
<SelectValue placeholder={t("Select a group...")} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{selectedGroup}</p>
</TooltipContent>
</Tooltip>
<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) => (
<SelectItem key={group.name} value={group.name}>
{group.name}
<div className="flex items-center gap-2">
{enable_group_icon && group.icon && (
<img
src={
group.icon.startsWith("data")
? group.icon
: group.icon.startsWith("<svg")
? `data:image/svg+xml;base64,${btoa(group.icon)}`
: group.icon
}
className="w-4 h-4 rounded-sm"
alt={group.name}
/>
)}
<span>{group.name}</span>
</div>
</SelectItem>
))}
</SelectContent>

View File

@@ -0,0 +1,107 @@
import { Link } from 'react-router-dom';
import {
Sidebar,
SidebarContent, SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem, useSidebar,
} from "@/components/ui/sidebar"
import { t } from 'i18next';
import { cn } from '@root/lib/utils';
import {
Home,
Users,
Server,
Cable,
ListChecks,
FileText,
Settings, EarthLock,
} from 'lucide-react';
import { UpdateButton } from "@/components/layout/update-button";
import React from "react";
import { SheetClose } from '@/components/ui/sheet';
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 },
];
export function AppSidebar() {
const { isMobile } = useSidebar();
return (
<Sidebar variant="floating" collapsible="icon">
<SidebarHeader>
<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"
)}
>
<img
src="./assets/image/logo.png"
alt="logo"
className="h-6 w-6 flex-shrink-0"
/>
<span className="font-semibold whitespace-nowrap group-data-[state=collapsed]:hidden">
Koala Clash
</span>
</SidebarMenuButton>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-3">
{menuItems.map((item) => {
const isActive = location.pathname === item.url;
const linkElement = (
<Link
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'
)}
>
<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>
) : (
linkElement
)}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="w-full flex justify-center">
<UpdateButton className="bg-green-700 hover:bg-green-500 hover:text-white text-white text-shadow-md" />
</div>
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -5,6 +5,9 @@ import { UpdateViewer } from "../setting/mods/update-viewer";
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 { useSidebar } from "../ui/sidebar";
interface Props {
className?: string;
@@ -14,6 +17,7 @@ export const UpdateButton = (props: Props) => {
const { className } = props;
const { verge } = useVerge();
const { auto_check_update } = verge || {};
const { state: sidebarState } = useSidebar();
const viewerRef = useRef<DialogRef>(null);
@@ -32,15 +36,26 @@ export const UpdateButton = (props: Props) => {
return (
<>
<UpdateViewer ref={viewerRef} />
<Button
variant="destructive"
size="sm"
className={className}
onClick={() => viewerRef.current?.open()}
>
New
</Button>
{sidebarState === 'collapsed' ? (
<Button
variant="outline"
size="icon"
className={className}
onClick={() => viewerRef.current?.open()}
>
<Download />
</Button>
) : (
<Button
variant="outline"
size="lg"
className={className}
onClick={() => viewerRef.current?.open()}
>
<Download />
{t("New update")}
</Button>
)}
</>
);
};

View File

@@ -69,7 +69,7 @@ const LogItem = ({ value, searchState }: Props) => {
{renderHighlightText(value.type)}
</span>
</div>
<div className="text-gray-800 dark:text-gray-200 break-all whitespace-pre-wrap">
<div className="text-foreground break-all whitespace-pre-wrap">
{renderHighlightText(value.payload)}
</div>
</div>

View File

@@ -562,7 +562,7 @@ export const GroupsEditorViewer = (props: Props) => {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="lg:min-w-5xl h-[90vh] flex flex-col">
<DialogContent className="min-w-5xl h-[90vh] flex flex-col">
<DialogHeader>
<div className="flex justify-between items-center pr-8">
<DialogTitle>{t("Edit Groups")}</DialogTitle>

View File

@@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
export const HwidErrorDialog = () => {
const { t } = useTranslation();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
const handleShowHwidError = (event: Event) => {
const customEvent = event as CustomEvent<string>;
setErrorMessage(customEvent.detail);
};
window.addEventListener('show-hwid-error', handleShowHwidError);
return () => {
window.removeEventListener('show-hwid-error', handleShowHwidError);
};
}, []);
if (!errorMessage) {
return null;
}
return (
<Dialog open={!!errorMessage} onOpenChange={() => setErrorMessage(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
{t("Device Limit Reached")}
</DialogTitle>
<DialogDescription className="pt-4 text-left">
{errorMessage}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setErrorMessage(null)}>{t("OK")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -56,6 +56,7 @@ import {
Loader2,
Info,
DownloadCloud,
Download,
Trash2,
Edit3,
FileText as FileTextIcon,
@@ -66,7 +67,7 @@ import {
ListTree,
CheckCircle,
Infinity,
RefreshCw,
RefreshCw, Network,
} from "lucide-react";
import { t } from "i18next";
@@ -343,8 +344,8 @@ export const ProfileItem = (props: Props) => {
</div>
<div className="flex items-center flex-shrink-0">
<Badge
variant={type === "local" ? "secondary" : "outline"}
className="text-xs"
variant="outline"
className="text-xs shadow-sm"
>
{type}
</Badge>
@@ -384,20 +385,21 @@ export const ProfileItem = (props: Props) => {
)}
</div>
</div>
</div>
</div>
<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>
<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" />}
{hasExtra && total > 0 && (
<div className="relative h-5">
<Progress value={progress} className="h-full rounded-none" />
<div className="absolute inset-0 flex items-center justify-between px-2 text-xs text-white/90 font-medium">
<span>
{parseTraffic(download)} / {parseTraffic(upload)}
</span>
<span>{parseTraffic(total)}</span>
</div>
</div>
</div>
)}
</div>
</Card>
</ContextMenuTrigger>
@@ -405,7 +407,6 @@ export const ProfileItem = (props: Props) => {
className="w-56"
onClick={(e) => e.stopPropagation()}
>
{/* Объединяем все части меню */}
{[...homeMenuItem, ...mainMenuItems].map((item) => (
<ContextMenuItem
key={item.label}
@@ -420,7 +421,7 @@ export const ProfileItem = (props: Props) => {
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!hasUrl || isLoading}>
<DownloadCloud className="mr-2 h-4 w-4" />
<span>{t("Update")}</span>
<span className="px-2">{t("Update")}</span>
</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent>

View File

@@ -12,13 +12,14 @@ import {
createProfile,
patchProfile,
importProfile,
enhanceProfiles,
enhanceProfiles, createProfileFromShareLink,
} from "@/services/cmds";
import { useProfiles } from "@/hooks/use-profiles";
import { showNotice } from "@/services/noticeService";
import { version } from "@root/package.json";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
@@ -72,6 +73,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
const [isCheckingUrl, setIsCheckingUrl] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [loading, setLoading] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState("default");
const form = useForm<IProfileItem>({
defaultValues: {
@@ -136,14 +138,9 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
setIsCheckingUrl(true);
const handler = setTimeout(() => {
try {
new URL(importUrl);
setIsUrlValid(true);
} catch (error) {
setIsUrlValid(false);
} finally {
setIsCheckingUrl(false);
}
const isValid = /^(https?|vmess|vless|ss|socks|trojan):\/\//.test(importUrl);
setIsUrlValid(isValid);
setIsCheckingUrl(false);
}, 500);
return () => {
clearTimeout(handler);
@@ -151,30 +148,40 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
}, [importUrl]);
const handleImport = useLockFn(async () => {
if (!importUrl) return;
if (!importUrl || !isUrlValid) return;
setIsImporting(true);
const isShareLink = /^(vmess|vless|ss|socks|trojan):\/\//.test(importUrl);
try {
await importProfile(importUrl);
showNotice("success", t("Profile Imported Successfully"));
if (isShareLink) {
await createProfileFromShareLink(importUrl, selectedTemplate);
showNotice("success", t("Profile created from link successfully"));
} else {
await importProfile(importUrl);
showNotice("success", t("Profile Imported Successfully"));
}
props.onChange();
await enhanceProfiles();
setOpen(false);
} catch (err) {
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()}`,
);
} catch (err: any) {
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 }));
} 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()}`);
}
} else {
showNotice("error", errorMessage);
}
} finally {
setIsImporting(false);
@@ -294,6 +301,21 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
)}
</div>
{/^(vmess|vless|ss|socks|trojan):\/\//.test(importUrl) && (
<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>
</div>
)}
<Button
variant="outline"
onClick={() => setShowAdvanced(!showAdvanced)}
@@ -440,7 +462,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
<FormLabel>User Agent</FormLabel>
<FormControl>
<Input
placeholder={`clash-verge/v${version}`}
placeholder={`koala-clash/v${version}`}
{...field}
/>
</FormControl>

View File

@@ -302,7 +302,7 @@ export const ProxiesEditorViewer = (props: Props) => {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="lg:max-w-4xl h-[80vh] flex flex-col">
<DialogContent className="min-w-4xl h-[90vh] flex flex-col">
<DialogHeader>
<div className="flex justify-between items-center pr-8">
<DialogTitle>{t("Edit Proxies")}</DialogTitle>

View File

@@ -513,7 +513,7 @@ export const RulesEditorViewer = (props: Props) => {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="w-[95vw] lg:min-w-5xl h-[80vh] flex flex-col">
<DialogContent className="min-w-4xl h-[90vh] flex flex-col">
<DialogHeader>
<div className="flex justify-between items-center pr-8">
<DialogTitle>{t("Edit Rules")}</DialogTitle>
@@ -529,8 +529,8 @@ export const RulesEditorViewer = (props: Props) => {
<div className="flex-1 min-h-0 mt-4">
{visualization ? (
<div className="h-full flex flex-col lg:flex-row gap-4">
<div className="w-full lg:w-1/3 flex flex-col gap-4 p-1">
<div className="h-full flex flex-row gap-4">
<div className="w-1/3 flex flex-col gap-4 p-1">
<div className="space-y-2">
<Label>{t("Rule Type")}</Label>
<Combobox
@@ -617,8 +617,8 @@ export const RulesEditorViewer = (props: Props) => {
</Button>
</div>
</div>
<Separator orientation="vertical" className="hidden lg:flex" />
<div className="w-full lg:w-2/3 flex flex-col min-w-0 flex-1">
<Separator orientation="vertical" className="flex" />
<div className="w-2/3 flex flex-col min-w-0 flex-1">
<BaseSearchBox
onSearch={(matcher) => setMatch(() => matcher)}
/>

View File

@@ -23,14 +23,8 @@ import { ProxyRender } from "./proxy-render";
import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Вспомогательная функция для плавного скролла (взята из вашего оригинального файла)
function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number,
@@ -59,36 +53,6 @@ function throttle<T extends (...args: any[]) => any>(
};
}
// Компонент для одной буквы в навигаторе, переписанный на Tailwind и shadcn/ui
const LetterItem = memo(
({
name,
onClick,
getFirstChar,
}: {
name: string;
onClick: (name: string) => void;
getFirstChar: (str: string) => string;
}) => {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex items-center justify-center w-6 h-6 text-xs rounded-md border shadow-sm cursor-pointer text-muted-foreground transition-transform hover:bg-accent hover:text-accent-foreground hover:scale-125"
onClick={() => onClick(name)}
>
{getFirstChar(name)}
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p>{name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
interface Props {
mode: string;
@@ -108,33 +72,6 @@ export const ProxyGroups = memo((props: Props) => {
const scrollerRef = useRef<Element | null>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
// Мемоизация вычисления букв и индексов для навигатора
const { groupFirstLetters, letterIndexMap } = useMemo(() => {
const letters = new Set<string>();
const indexMap: Record<string, number> = {};
renderList.forEach((item, index) => {
if (item.type === 0) {
// type 0 - это заголовок группы
const fullName = item.group.name;
letters.add(fullName);
if (!(fullName in indexMap)) {
indexMap[fullName] = index;
}
}
});
return {
groupFirstLetters: Array.from(letters),
letterIndexMap: indexMap,
};
}, [renderList]);
// Мемоизация функции для получения первой буквы (поддерживает эмодзи)
const getFirstChar = useCallback((str: string) => {
const match = str.match(
/\p{Regional_Indicator}{2}|\p{Extended_Pictographic}|\p{L}|\p{N}|./u,
);
return match ? match[0] : str.charAt(0);
}, []);
// Обработчик скролла для показа/скрытия кнопки "Наверх"
const handleScroll = useCallback(
@@ -161,20 +98,6 @@ export const ProxyGroups = memo((props: Props) => {
virtuosoRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const handleLetterClick = useCallback(
(name: string) => {
const index = letterIndexMap[name];
if (index !== undefined) {
virtuosoRef.current?.scrollToIndex({
index,
align: "start",
behavior: "smooth",
});
}
},
[letterIndexMap],
);
// Вся бизнес-логика из оригинального файла
const handleChangeProxy = useLockFn(
async (group: IProxyGroupItem, proxy: IProxyItem) => {
@@ -288,18 +211,6 @@ export const ProxyGroups = memo((props: Props) => {
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
{/* Алфавитный указатель */}
<div className="fixed top-1/2 right-4 z-50 flex -translate-y-1/2 flex-col gap-1 rounded-md bg-background/50 p-1 backdrop-blur-sm">
{groupFirstLetters.map((name) => (
<LetterItem
key={name}
name={name}
onClick={handleLetterClick}
getFirstChar={getFirstChar}
/>
))}
</div>
</div>
);
});

View File

@@ -66,7 +66,7 @@ export const ProxyItemMini = (props: Props) => {
title={`${proxy.name}\n${proxy.now ?? ""}`}
className="group relative flex h-16 cursor-pointer items-center justify-between rounded-lg border bg-card p-3 shadow-sm transition-colors duration-200 hover:bg-accent data-[selected=true]:ring-2 data-[selected=true]:ring-primary"
>
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 w-0">
<p className="truncate text-sm font-medium">{proxy.name}</p>
{showType && (

View File

@@ -16,7 +16,7 @@ type HeadStateStorage = Record<string, Record<string, HeadState>>;
const HEAD_STATE_KEY = "proxy-head-state";
export const DEFAULT_STATE: HeadState = {
open: false,
showType: true,
showType: false,
sortType: 0,
filterText: "",
textState: null,

View File

@@ -143,19 +143,6 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
</DialogHeader>
<div className="py-4 space-y-1">
<SettingRow label={t("Traffic Graph")}>
<GuardState
value={verge?.traffic_graph ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ traffic_graph: e })}
onGuard={(e) => patchVerge({ traffic_graph: e })}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Memory Usage")}>
<GuardState
value={verge?.enable_memory_usage ?? true}
@@ -197,134 +184,6 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Nav Icon")}>
<GuardState
value={verge?.menu_icon ?? "monochrome"}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ menu_icon: e })}
onGuard={(e) => patchVerge({ menu_icon: e })}
>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 1 --- */}
<Select
onValueChange={(value) =>
onChangeData({ menu_icon: value as any })
}
value={verge?.menu_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 1 --- */}
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
<SelectItem value="disable">{t("Disable")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
{OS === "macos" && (
<>
<SettingRow label={t("Tray Icon")}>
<GuardState
value={verge?.tray_icon ?? "monochrome"}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ tray_icon: e })}
onGuard={(e) => patchVerge({ tray_icon: e })}
>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 2 --- */}
<Select
onValueChange={(value) =>
onChangeData({ tray_icon: value as any })
}
value={verge?.tray_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">
{t("Monochrome")}
</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow label={t("Enable Tray Icon")}>
<GuardState
value={verge?.enable_tray_icon ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_tray_icon: e })}
onGuard={(e) => patchVerge({ enable_tray_icon: e })}
>
<Switch />
</GuardState>
</SettingRow>
</>
)}
<SettingRow label={t("Common Tray Icon")}>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => handleIconChange("common")}
>
{verge?.common_tray_icon && commonIcon && (
<img
src={convertFileSrc(commonIcon)}
className="h-5 mr-2"
alt="common tray icon"
/>
)}
{verge?.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")}
>
{verge?.sysproxy_tray_icon && sysproxyIcon && (
<img
src={convertFileSrc(sysproxyIcon)}
className="h-5 mr-2"
alt="system proxy tray icon"
/>
)}
{verge?.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")}
>
{verge?.tun_tray_icon && tunIcon && (
<img
src={convertFileSrc(tunIcon)}
className="h-5 mr-2"
alt="tun mode tray icon"
/>
)}
{verge?.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</SettingRow>
</div>
<DialogFooter>

View File

@@ -118,7 +118,7 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex justify-between items-center">
<div className="flex justify-between items-center pr-5">
<DialogTitle>
{t("New Version")} v{updateInfo?.version}
</DialogTitle>
@@ -127,7 +127,7 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
size="sm"
onClick={() =>
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
`https://github.com/coolcoala/clash-verge-rev-lite/releases/tag/v${updateInfo?.version}`,
)
}
>

View File

@@ -396,7 +396,7 @@ const SettingSystem = ({ onError }: Props) => {
label={<LabelWithIcon icon={Fingerprint} text={t("Send HWID")} />}
>
<GuardState
value={verge?.enable_send_hwid ?? true} // По умолчанию включено
value={verge?.enable_send_hwid ?? true}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
@@ -404,7 +404,7 @@ const SettingSystem = ({ onError }: Props) => {
onGuard={(e) => patchVerge({ enable_send_hwid: e })}
onCatch={onError}
>
<Switch />
<Switch disabled={true} />
</GuardState>
</SettingRow>
</div>

View File

@@ -180,32 +180,12 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
extra={<TooltipIcon tooltip={t("LightWeight Mode Info")} />}
onClick={() => liteModeRef.current?.open()}
/>
<SettingRow
onClick={exitApp}
label={<LabelWithIcon icon={LogOut} text={t("Exit")} />}
/>
<SettingRow
label={
<LabelWithIcon
icon={ClipboardList}
text={t("Export Diagnostic Info")}
/>
}
>
<TooltipIcon
tooltip={t("Copy")}
icon={<Copy className="h-4 w-4" />}
onClick={onExportDiagnosticInfo}
/>
</SettingRow>
<SettingRow
label={<LabelWithIcon icon={Info} text={t("Verge Version")} />}
>
<p className="text-sm font-medium pr-2 font-mono">v{version}</p>
</SettingRow>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
</div>
</div>
);

View File

@@ -227,41 +227,6 @@ const SettingVergeBasic = ({ onError }: Props) => {
</SettingRow>
)}
<SettingRow
label={<LabelWithIcon icon={Copy} text={t("Copy Env Type")} />}
extra={
<TooltipIcon
tooltip={t("Copy")}
icon={<Copy className="h-4 w-4" />}
onClick={onCopyClashEnv}
/>
}
>
<GuardState
value={env_type ?? (OS === "windows" ? "powershell" : "bash")}
onCatch={onError}
onFormat={(v) => v}
onChange={(e) => onChangeData({ env_type: e })}
onGuard={(e) => patchVerge({ env_type: e })}
>
<Select
onValueChange={(value) => onChangeData({ env_type: value })}
value={env_type}
>
<SelectTrigger className="w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="fish">Fish</SelectItem>
<SelectItem value="nushell">Nushell</SelectItem>
<SelectItem value="cmd">CMD</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow
label={<LabelWithIcon icon={Home} text={t("Start Page")} />}
>
@@ -290,59 +255,10 @@ const SettingVergeBasic = ({ onError }: Props) => {
</GuardState>
</SettingRow>
<SettingRow
label={
<LabelWithIcon icon={FileTerminal} text={t("Startup Script")} />
}
>
<div className="flex items-center gap-2">
<Input
readOnly
value={startup_script ?? ""}
placeholder={t("Not Set")}
className="h-8 flex-1"
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={async () => {
const selected = await open({
directory: false,
multiple: false,
filters: [
{ name: "Shell Script", extensions: ["sh", "bat", "ps1"] },
],
});
if (selected) {
const path = Array.isArray(selected) ? selected[0] : selected;
onChangeData({ startup_script: path });
patchVerge({ startup_script: path });
}
}}
>
{t("Browse")}
</Button>
{startup_script && (
<Button
variant="destructive"
size="sm"
className="h-8"
onClick={async () => {
onChangeData({ startup_script: "" });
patchVerge({ startup_script: "" });
}}
>
{t("Clear")}
</Button>
)}
</div>
</SettingRow>
<SettingRow
onClick={() => themeRef.current?.open()}
label={<LabelWithIcon icon={SwatchBook} text={t("Theme Setting")} />}
/>
{/*<SettingRow*/}
{/* onClick={() => themeRef.current?.open()}*/}
{/* label={<LabelWithIcon icon={SwatchBook} text={t("Theme Setting")} />}*/}
{/*/>*/}
<SettingRow
onClick={() => layoutRef.current?.open()}
label={

View File

@@ -0,0 +1,724 @@
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 {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} 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"
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
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
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 setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
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 contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"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)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
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
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"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
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
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
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
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
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"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
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
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
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

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

View File

@@ -0,0 +1,93 @@
import { useEffect, useState, useCallback } from 'react';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
// Константы для управления масштабом
const ZOOM_STEP = 0.1;
const ZOOM_WHEEL_STEP = 0.05;
const MIN_ZOOM = 0.5; // 50%
const MAX_ZOOM = 2.0; // 200%
export const useZoomControls = () => {
const [zoomLevel, setZoomLevel] = useState(1.0);
const appWindow = WebviewWindow.getCurrent();
useEffect(() => {
const setInitialZoom = async () => {
// 1. Получаем и физический размер, и коэффициент масштабирования
const size = await appWindow.innerSize();
const scaleFactor = await appWindow.scaleFactor();
// 2. Вычисляем логическую ширину
const logicalWidth = size.width / scaleFactor;
let initialZoom = 1.0;
console.log(`Physical width: ${size.width}, Scale Factor: ${scaleFactor}, Logical width: ${logicalWidth}`);
// 3. Используем логическую ширину для принятия решения
if (logicalWidth < 1300) {
initialZoom = 1.0;
} else if (logicalWidth > 2000) {
initialZoom = 2.0;
}
await appWindow.setZoom(initialZoom);
setZoomLevel(initialZoom);
};
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;
appWindow.setZoom(roundedZoom);
const newStrokeWidth = 2 / roundedZoom;
document.documentElement.style.setProperty('--icon-stroke-width', newStrokeWidth.toString());
return roundedZoom;
});
}, [appWindow]);
useEffect(() => {
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const delta = event.deltaY > 0 ? -ZOOM_WHEEL_STEP : ZOOM_WHEEL_STEP;
handleZoom(delta);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey) {
switch (event.code) {
case 'Equal':
case 'NumpadAdd':
event.preventDefault();
handleZoom(ZOOM_STEP);
break;
case 'Minus':
case 'NumpadSubtract':
event.preventDefault();
handleZoom(-ZOOM_STEP);
break;
case 'Digit0':
case 'Numpad0':
event.preventDefault();
handleZoom(0, true);
break;
}
}
};
window.addEventListener('wheel', handleWheel, { passive: false });
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('wheel', handleWheel);
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleZoom]);
};

View File

@@ -125,3 +125,7 @@
/* h-full уже применен выше к body */
}
}
svg {
stroke-width: var(--icon-stroke-width, 2);
}

View File

@@ -8,7 +8,7 @@
type="image/x-icon"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clash Verge Rev Lite</title>
<title>Koala Clash</title>
</head>
<body>
<div id="root"></div>

View File

@@ -511,7 +511,7 @@
"Validate Merge File": "Validate Merge File",
"Validation Success": "Validation Success",
"Validation Failed": "Validation Failed",
"Service Administrator Prompt": "Clash Verge requires administrator privileges to reinstall the system service",
"Service Administrator Prompt": "Koala Clash requires administrator privileges to reinstall the system service",
"DNS Settings": "DNS Settings",
"DNS settings saved": "DNS settings saved",
"DNS Overwrite": "DNS Overwrite",
@@ -645,6 +645,10 @@
"Attention Required": "Attention Required",
"Menu": "Menu",
"Add Profile": "Add Profile",
"Proxy enabled": "Proxy enabled",
"Proxy disabled": "Proxy disabled",
"Connecting...": "Connecting...",
"Disconnecting...": "Disconnecting...",
"Delete Profile": "Delete Profile {{name}}?",
"This action cannot be undone.": "This action cannot be undone.",
"Check Group Latency": "Check Group Latency",
@@ -662,5 +666,14 @@
"Main Toggle Action": "Main Toggle Action",
"Support": "Support",
"Update on Startup": "Update on Startup",
"Send HWID": "Send HWID"
"Send HWID": "Send HWID",
"New Version is available": "New Version is available",
"New Version": "New Version",
"New update": "New update",
"Device Limit Reached": "Device Limit Reached",
"Update Profile": "Update Profile",
"Template": "Template",
"Select a template...": "Select a template...",
"Default Template": "Ru-bundle template",
"Template without RU Rules": "Without-ru template"
}

View File

@@ -27,10 +27,11 @@
"Proxies": "Прокси",
"Proxy Groups": "Группы прокси",
"Proxy Provider": "Провайдер прокси",
"Proxy Count": "Число прокси",
"Update All": "Обновить все",
"Update At": "Обновлено в",
"rule": "правила",
"global": "глобальный",
"rule": "По правилам",
"global": "Глобально",
"direct": "прямой",
"script": "скриптовый",
"locate": "Местоположение",
@@ -156,6 +157,7 @@
"Edit File": "Изменить файл",
"Open File": "Открыть файл",
"Update": "Обновить",
"Update via proxy": "Обновить через прокси",
"Update(Proxy)": "Обновить (прокси)",
"Confirm deletion": "Подтвердите удаление",
"This operation is not reversible": "Эта операция необратима",
@@ -200,15 +202,19 @@
"Settings": "Настройки",
"System Setting": "Настройки системы",
"Tun Mode": "Режим TUN",
"TUN requires Service Mode": "Режим TUN требует установленную службу Clash Verge",
"TUN requires Service Mode": "Режим TUN требует установленную службу Koala Clash",
"Install Service": "Установить службу",
"Install Service failed": "Установка сервиса не удалась",
"Uninstall Service": "Удалить сервис",
"Restart Core failed": "Перезапуск ядра не удалась",
"Reset to Default": "Сбросить настройки",
"Tun Mode Info": "Режим Tun: захватывает весь системный трафик, при включении нет необходимости включать системный прокси-сервер.",
"TUN requires Service Mode or Admin Mode": "TUN режим требует Режима Службы или прав Администратора",
"System Proxy Enabled": "Системный прокси включен, ваши приложения будут получать доступ к сети через него",
"System Proxy Disabled": "Системный прокси отключен, большинству пользователей рекомендуется включить эту опцию",
"TUN Mode Enabled": "Режим TUN включен, приложения будут получать доступ к сети через виртуальную сетевую карту",
"TUN Mode Disabled": "Режим TUN отключен",
"TUN Mode Service Required": "Режим TUN требует установленную службу Clash Verge",
"TUN Mode Service Required": "Режим TUN требует установленную службу Koala Clash",
"TUN Mode Intercept Info": "Режим TUN может перехватить трафик всех приложений, подходит для приложений, которые не работают в режиме системного прокси.",
"Rule Mode Description": "Направляет трафик в соответствии с предустановленными правилами",
"Global Mode Description": "Направляет весь трафик через прокси-серверы",
@@ -255,8 +261,11 @@
"PAC Script Content": "Содержание сценария PAC",
"PAC URL": "Адрес PAC: ",
"Auto Launch": "Автозапуск",
"Administrator mode may not support auto launch": "Режим администратора может не поддерживать автоматический запуск",
"Silent Start": "Тихий запуск",
"Silent Start Info": "Запускать программу в фоновом режиме без отображения панели",
"Hover Jump Navigator": "Hover Jump Navigator",
"Hover Jump Navigator Info": "Автоматически переходить к соответствующей группе прокси при наведении курсора на буквы алфавита",
"TG Channel": "Telegram-канал",
"Manual": "Документация",
"Github Repo": "GitHub репозиторий",
@@ -372,7 +381,7 @@
"Export Diagnostic Info": "Экспорт диагностической информации",
"Export Diagnostic Info For Issue Reporting": "Экспорт диагностической информации для отчета об ошибке",
"Exit": "Выход",
"Verge Version": "Версия Clash Verge Rev",
"Verge Version": "Версия Koala Clash",
"ReadOnly": "Только для чтения",
"ReadOnlyMessage": "Невозможно редактировать в режиме только для чтения",
"Filter": "Фильтр",
@@ -383,6 +392,7 @@
"Profile Imported Successfully": "Профиль успешно импортирован",
"Profile Switched": "Профиль изменен",
"Profile Reactivated": "Профиль перезапущен",
"Profile switch interrupted by new selection": "Переключение профилей прервано новым выбором",
"Only YAML Files Supported": "Поддерживаются только файлы YAML",
"Settings Applied": "Настройки применены",
"Installing Service...": "Установка службы...",
@@ -390,6 +400,17 @@
"Service Uninstalled Successfully": "Служба успешно удалена",
"Proxy Daemon Duration Cannot be Less than 1 Second": "Продолжительность работы прокси-демона не может быть меньше 1 секунды",
"Invalid Bypass Format": "Неверный формат обхода",
"Waiting for service to be ready...": "Ожидание готовности сервиса...",
"Service not ready, retrying attempt {count}/{total}...": "Служба не готова, повторная попытка {count}/{total}...",
"Failed to check service status, retrying attempt {count}/{total}...": "Не удалось проверить состояние службы, повторная попытка {count}/{total}...",
"Service did not become ready after attempts. Proceeding with core restart.": "Служба не была готова после нескольких попыток. Продолжаем перезапуск ядра.",
"Restarting Core...": "Перезапуск ядра...",
"Service was ready, but core restart might have issues or service became unavailable. Please check.": "Служба была готова, но при перезапуске ядра могли возникнуть проблемы или служба стала недоступна. Пожалуйста, проверьте.",
"Service installation or core restart encountered issues. Service might not be available. Please check system logs.": "При установке службы или перезапуске ядра возникли проблемы. Служба может быть недоступна. Проверьте системные журналы.",
"Attempting to restart core as a fallback...": "Попытка перезапустить ядро в резервном режиме...",
"Fallback core restart also failed: {message}": "Перезапуск резервного ядра также не удался: {{message}}",
"Service is ready and core restarted": "Служба готова, ядро перезапущено",
"Core restarted. Service is now available.": "Ядро перезапущено. Сервис теперь доступен.",
"Clash Port Modified": "Порт Clash изменен",
"Port Conflict": "Конфликт портов",
"Restart Application to Apply Modifications": "Чтобы изменения вступили в силу, необходимо перезапустить приложение",
@@ -399,6 +420,7 @@
"Clash Core Restarted": "Ядро перезапущено",
"GeoData Updated": "Файлы GeoData обновлены",
"Currently on the Latest Version": "Обновление не требуется",
"Already Using Latest Core": "Уже используется последняя версия ядра",
"Import Subscription Successful": "Подписка успешно импортирована",
"WebDAV Server URL": "URL-адрес сервера WebDAV http(s)://",
"Username": "Имя пользователя",
@@ -489,8 +511,9 @@
"Validate Merge File": "Проверить Merge File",
"Validation Success": "Файл успешно проверен",
"Validation Failed": "Проверка не удалась",
"Service Administrator Prompt": "Clash Verge требует прав администратора для переустановки системной службы",
"Service Administrator Prompt": "Koala Clash требует прав администратора для переустановки системной службы",
"DNS Settings": "Настройки DNS",
"DNS settings saved": "Настройки DNS сохранены",
"DNS Overwrite": "Переопределение настроек DNS",
"DNS Settings Warning": "Если вы не знакомы с этими настройками, пожалуйста, не изменяйте и не отключайте их",
"Enable DNS": "Включить DNS",
@@ -498,6 +521,7 @@
"Enhanced Mode": "Enhanced Mode",
"Fake IP Range": "Диапазон FakeIP",
"Fake IP Filter Mode": "FakeIP Filter Mode",
"Enable IPv6 DNS resolution": "Включить разрешение DNS по IPv6",
"Prefer H3": "Предпочитать H3",
"DNS DOH使用HTTP/3": "DNS DOH использует http/3",
"Respect Rules": "Приоритизировать правила",
@@ -530,6 +554,9 @@
"IP CIDRs not using fallback servers": "Диапазоны IP-адресов, не использующие резервные серверы, разделенные запятой",
"Fallback Domain": "Fallback домены",
"Domains using fallback servers": "Домены, использующие резервные серверы, разделенные запятой",
"Hosts Settings": "Настройки хостов",
"Hosts": "Хосты",
"Custom domain to IP or domain mapping": "Настраиваемое сопоставление домена с IP-адресом или доменом",
"Enable Alpha Channel": "Включить альфа-канал",
"Alpha versions may contain experimental features and bugs": "Альфа-версии могут содержать экспериментальные функции и ошибки",
"Home Settings": "Настройки главной страницы",
@@ -553,9 +580,24 @@
"OS Info": "Версия ОС",
"Running Mode": "Режим работы",
"Sidecar Mode": "Пользовательский режим",
"Administrator Mode": "Режим администратора",
"Administrator + Service Mode": "Административный + сервисный режим",
"Last Check Update": "Последняя проверка обновлений",
"Click to import subscription": "Нажмите, чтобы импортировать подписку",
"Last Update failed": "Последнее обновление не удалось",
"Next Up": "Далее",
"No schedule": "Нет расписания",
"Unknown": "Неизвестно",
"Auto update disabled": "Автоматическое обновление отключено",
"Update subscription successfully": "Подписка успешно обновлена",
"Update failed, retrying with Clash proxy...": "Обновление не удалось, пробую повторно с помощью прокси Clash...",
"Update with Clash proxy successfully": "Обновление с помощью прокси Clash прошло успешно",
"Update failed even with Clash proxy": "Обновление не удалось даже с помощью прокси Clash",
"Profile creation failed, retrying with Clash proxy...": "Создание профиля не удалось, повторная попытка с прокси Clash...",
"Profile creation succeeded with Clash proxy": "Создание профиля с помощью прокси Clash прошло успешно",
"Import failed, retrying with Clash proxy...": "Импорт не удался, повторная попытка с прокси Clash...",
"Profile Imported with Clash proxy": "Профиль импортирован с помощью прокси Clash",
"Import failed even with Clash proxy": "Импорт не удался даже с прокси Clash",
"Current Node": "Текущий сервер",
"No active proxy node": "Нет активного прокси-узла",
"Network Settings": "Настройки сети",
@@ -582,27 +624,37 @@
"No (IP Banned By Disney+)": "Нет (IP забанен Disney+)",
"Unsupported Country/Region": "Страна/регион не поддерживается",
"Failed (Network Connection)": "Ошибка подключения",
"DashboardToggledTitle": "Панель управления переключена",
"DashboardToggledBody": "Видимость панели инструментов переключена с помощью горячей клавиши",
"ClashModeChangedTitle": "Режим Clash изменен",
"ClashModeChangedBody": "Переключено в режим {mode}",
"SystemProxyToggledTitle": "Системный прокси переключен",
"SystemProxyToggledBody": "Состояние системного прокси-сервера переключена с помощью горячей клавиши",
"TunModeToggledTitle": "Режим TUN переключен",
"TunModeToggledBody": "Режим TUN переключен с помощью горячей клавиши",
"LightweightModeEnteredTitle": "Легкий режим",
"LightweightModeEnteredBody": "Вход в легкий режим с помощью горячей клавиши",
"AppQuitTitle": "Выход из приложения",
"AppQuitBody": "Приложение закрыто с помощью горячей клавиши",
"AppHiddenTitle": "Приложение скрыто",
"AppHiddenBody": "Окно приложения скрыто с помощью горячей клавиши",
"Invalid Profile URL": "Неверный URL-адрес профиля. Введите URL-адрес, начинающийся с http:// или https://.",
"Saved Successfully": "Успешно сохранено",
"Connected": "Подключено",
"Disconnected": "Отключено",
"Attention Required": "Требуется внимание",
"TUN requires Service Mode or Admin Mode": "TUN режим требует Режима Службы или прав Администратора",
"Menu": "Меню",
"Add Profile": "Добавить профиль",
"Proxy enabled": "Прокси включено",
"Proxy disabled": "Прокси выключено",
"Connecting...": "Подключение...",
"Disconnecting...": "Отключение...",
"Add Profile": "Добавить профиль",
"Delete Profile": "Удалить профиль {{name}}?",
"This action cannot be undone.": "Это действие не может быть отменено",
"Update via proxy": "Обновить через прокси",
"Check Group Latency": "Проверка задержки в группе",
"Locate Current Proxy": "Найти текущий прокси",
"Show Basic Info": "Показать основную информацию",
"Show Detailed Info": "Показать подробную информацию",
"Update failed, retrying with Clash proxy...": "Обновление не удалось, пробую повторно с помощью прокси Clash...",
"Update failed even with Clash proxy": "Обновление не удалось даже с помощью прокси Clash",
"Update with Clash proxy successfully": "Обновление с помощью прокси Clash прошло успешно",
"Proxy Count": "Число прокси",
"Set Latency Test URL": "Установить URL-адрес тестирования задержки",
"Filter by Name": "Фильтр по имени",
"Expires in": "Истекает через {{duration}}",
@@ -614,5 +666,14 @@
"Main Toggle Action": "Действие главного переключателя",
"Support": "Поддержка",
"Update on Startup": "Обновлять при запуске",
"Send HWID": "Отправлять HWID"
"Send HWID": "Отправлять HWID",
"New Version is available": "Доступна новая версия",
"New Version": "Новая версия",
"New update": "Доступно обновление",
"Device Limit Reached": "Достигнут лимит устройств",
"Update Profile": "Обновить профиль",
"Template": "Шаблон",
"Select a template...": "Выберите шаблон...",
"Default Template": "Шаблон ru-bundle",
"Template without RU Rules": "Шаблон without-ru"
}

View File

@@ -5,23 +5,15 @@ import { SWRConfig, mutate } from "swr";
import { useEffect, useCallback, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoutes, useNavigate } from "react-router-dom";
import { List, Paper, ThemeProvider, SvgIcon } from "@mui/material";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { routers } from "./_routers";
import { getAxios } from "@/services/api";
import { useVerge } from "@/hooks/use-verge";
import LogoSvg from "@/assets/image/logo.svg?react";
import iconLight from "@/assets/image/icon_light.svg?react";
import iconDark from "@/assets/image/icon_dark.svg?react";
import { useThemeMode, useEnableLog } from "@/services/states";
import { LayoutItem } from "@/components/layout/layout-item";
import { LayoutTraffic } from "@/components/layout/layout-traffic";
import { UpdateButton } from "@/components/layout/update-button";
import { useCustomTheme } from "@/components/layout/use-custom-theme";
import getSystem from "@/utils/get-system";
import "dayjs/locale/ru";
import "dayjs/locale/zh-cn";
import { getPortableFlag } from "@/services/cmds";
import React from "react";
import { useListen } from "@/hooks/use-listen";
import { listen } from "@tauri-apps/api/event";
@@ -30,6 +22,11 @@ import { initGlobalLogService } from "@/services/global-log-service";
import { invoke } from "@tauri-apps/api/core";
import { showNotice } from "@/services/noticeService";
import { NoticeManager } from "@/components/base/NoticeManager";
import { SidebarProvider, useSidebar } from "@/components/ui/sidebar";
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;
@@ -38,25 +35,30 @@ dayjs.extend(relativeTime);
const OS = getSystem();
// 通知处理函数
// Notification Handler
const handleNoticeMessage = (
status: string,
msg: string,
t: (key: string) => string,
navigate: (path: string, options?: any) => void,
) => {
console.log("[通知监听 V2] 收到消息:", status, msg);
console.log("[Notification Listener V2] Receiving a message:", status, msg);
switch (status) {
case "import_sub_url::ok":
mutate("getProfiles");
navigate("/", { state: { activateProfile: msg } });
navigate("/");
showNotice("success", t("Import Subscription Successful"));
window.dispatchEvent(new CustomEvent('activate-profile', { detail: msg }));
sessionStorage.setItem('activateProfile', msg);
break;
case "import_sub_url::error":
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;
@@ -142,13 +144,14 @@ const handleNoticeMessage = (
showNotice("error", `${t("Failed to Change Core")}: ${msg}`);
break;
default: // Optional: Log unhandled statuses
console.warn(`[通知监听 V2] 未处理的状态: ${status}`);
console.warn(`[Notification Listener V2] Unprocessed state: ${status}`);
break;
}
};
const Layout = () => {
const mode = useThemeMode();
useZoomControls();
const isDark = mode === "light" ? false : true;
const { t } = useTranslation();
useCustomTheme();
@@ -169,14 +172,14 @@ const Layout = () => {
try {
handleNoticeMessage(status, msg, t, navigate);
} catch (error) {
console.error("[Layout] 处理通知消息失败:", error);
console.error("[Layout] Failure to process a notification message:", error);
}
}, 0);
},
[t, navigate],
);
// 初始化全局日志服务
// Initialize the global logging service
useEffect(() => {
if (clashInfo) {
const { server = "", secret = "" } = clashInfo;
@@ -184,7 +187,7 @@ const Layout = () => {
}
}, [clashInfo, enableLog]);
// 设置监听器
// Setting up a listener
useEffect(() => {
const listeners = [
addListener("verge://refresh-clash-config", async () => {
@@ -230,11 +233,11 @@ const Layout = () => {
try {
unlisten();
} catch (error) {
console.error("[Layout] 清理事件监听器失败:", error);
console.error("[Layout] Failed to clear event listener:", error);
}
})
.catch((error) => {
console.error("[Layout] 获取unlisten函数失败:", error);
console.error("[Layout] Failed to get unlisten function:", error);
});
}
});
@@ -244,11 +247,11 @@ const Layout = () => {
try {
cleanup();
} catch (error) {
console.error("[Layout] 清理窗口监听器失败:", error);
console.error("[Layout] Failed to clear window listener:", error);
}
})
.catch((error) => {
console.error("[Layout] 获取cleanup函数失败:", error);
console.error("[Layout] Failed to get cleanup function:", error);
});
}, 0);
};
@@ -256,10 +259,10 @@ const Layout = () => {
useEffect(() => {
if (initRef.current) {
console.log("[Layout] 初始化代码已执行过,跳过");
console.log("[Layout] Initialization code has already been executed, skip");
return;
}
console.log("[Layout] 开始执行初始化代码");
console.log("[Layout] Begin executing initialization code");
initRef.current = true;
let isInitialized = false;
@@ -269,27 +272,27 @@ const Layout = () => {
const notifyBackend = async (action: string, stage?: string) => {
try {
if (stage) {
console.log(`[Layout] 通知后端 ${action}: ${stage}`);
console.log(`[Layout] Notification Backend ${action}: ${stage}`);
await invoke("update_ui_stage", { stage });
} else {
console.log(`[Layout] 通知后端 ${action}`);
console.log(`[Layout] Notification Backend ${action}`);
await invoke("notify_ui_ready");
}
} catch (err) {
console.error(`[Layout] 通知失败 ${action}:`, err);
console.error(`[Layout] Notification failure ${action}:`, err);
}
};
const removeLoadingOverlay = () => {
const initialOverlay = document.getElementById("initial-loading-overlay");
if (initialOverlay) {
console.log("[Layout] 移除加载指示器");
console.log("[Layout] Remove loading indicator");
initialOverlay.style.opacity = "0";
setTimeout(() => {
try {
initialOverlay.remove();
} catch (e) {
console.log("[Layout] 加载指示器已被移除");
console.log("[Layout] Load indicator has been removed");
}
}, 300);
}
@@ -297,23 +300,23 @@ const Layout = () => {
const performInitialization = async () => {
if (isInitialized) {
console.log("[Layout] 已经初始化过,跳过");
console.log("[Layout] Already initialized, skip");
return;
}
initializationAttempts++;
console.log(`[Layout] 开始第 ${initializationAttempts} 次初始化尝试`);
console.log(`[Layout] Start ${initializationAttempts} for the first time`);
try {
removeLoadingOverlay();
await notifyBackend("加载阶段", "Loading");
await notifyBackend("Loading phase", "Loading");
await new Promise<void>((resolve) => {
const checkReactMount = () => {
const rootElement = document.getElementById("root");
if (rootElement && rootElement.children.length > 0) {
console.log("[Layout] React组件已挂载");
console.log("[Layout] React components are mounted");
resolve();
} else {
setTimeout(checkReactMount, 50);
@@ -323,43 +326,43 @@ const Layout = () => {
checkReactMount();
setTimeout(() => {
console.log("[Layout] React组件挂载检查超时,继续执行");
console.log("[Layout] React components mount check timeout, continue execution");
resolve();
}, 2000);
});
await notifyBackend("DOM就绪", "DomReady");
await notifyBackend("DOM ready", "DomReady");
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await notifyBackend("资源加载完成", "ResourcesLoaded");
await notifyBackend("Resource loading completed", "ResourcesLoaded");
await notifyBackend("UI就绪");
await notifyBackend("UI ready");
isInitialized = true;
console.log(`[Layout] ${initializationAttempts} 次初始化完成`);
console.log(`[Layout] The ${initializationAttempts} initialization is complete`);
} catch (error) {
console.error(
`[Layout] ${initializationAttempts} 次初始化失败:`,
`[Layout] Initialization failure at ${initializationAttempts}:`,
error,
);
if (initializationAttempts < maxAttempts) {
console.log(
`[Layout] 将在500ms后进行第 ${initializationAttempts + 1} 次重试`,
`[Layout] The first ${initializationAttempts + 1} retry will be made after 500ms`,
);
setTimeout(performInitialization, 500);
} else {
console.error("[Layout] 所有初始化尝试都失败,执行紧急初始化");
console.error("[Layout] All initialization attempts fail, perform emergency initialization");
removeLoadingOverlay();
try {
await notifyBackend("UI就绪");
await notifyBackend("UI ready");
isInitialized = true;
} catch (e) {
console.error("[Layout] 紧急初始化也失败:", e);
console.error("[Layout] Emergency initialization also failed:", e);
}
}
}
@@ -369,39 +372,39 @@ const Layout = () => {
const setupEventListener = async () => {
try {
console.log("[Layout] 开始监听启动完成事件");
console.log("[Layout] Start listening for startup completion events");
const unlisten = await listen("verge://startup-completed", () => {
if (!hasEventTriggered) {
console.log("[Layout] 收到启动完成事件,开始初始化");
console.log("[Layout] Receive startup completion event, start initialization");
hasEventTriggered = true;
performInitialization();
}
});
return unlisten;
} catch (err) {
console.error("[Layout] 监听启动完成事件失败:", err);
console.error("[Layout] Failed to listen for startup completion event:", err);
return () => {};
}
};
const checkImmediateInitialization = async () => {
try {
console.log("[Layout] 检查后端是否已就绪");
console.log("[Layout] Check if the backend is ready");
await invoke("update_ui_stage", { stage: "Loading" });
if (!hasEventTriggered && !isInitialized) {
console.log("[Layout] 后端已就绪,立即开始初始化");
console.log("[Layout] Backend is ready, start initialization immediately");
hasEventTriggered = true;
performInitialization();
}
} catch (err) {
console.log("[Layout] 后端尚未就绪,等待启动完成事件");
console.log("[Layout] Backend not yet ready, waiting for startup completion event");
}
};
const backupInitialization = setTimeout(() => {
if (!hasEventTriggered && !isInitialized) {
console.warn("[Layout] 备用初始化触发1.5秒内未开始初始化");
console.warn("[Layout] Standby initialization trigger: initialization not started within 1.5 seconds");
hasEventTriggered = true;
performInitialization();
}
@@ -409,9 +412,9 @@ const Layout = () => {
const emergencyInitialization = setTimeout(() => {
if (!isInitialized) {
console.error("[Layout] 紧急初始化触发5秒内未完成初始化");
console.error("[Layout] Emergency initialization trigger: initialization not completed within 5 seconds");
removeLoadingOverlay();
notifyBackend("UI就绪").catch(() => {});
notifyBackend("UI ready").catch(() => {});
isInitialized = true;
}
}, 5000);
@@ -427,10 +430,10 @@ const Layout = () => {
};
}, []);
// 语言和起始页设置
// Language and start page settings
useEffect(() => {
if (language) {
dayjs.locale(language === "zh" ? "zh-cn" : language);
dayjs.locale(language === "ru" ? "ru-ru" : language);
i18next.changeLanguage(language);
}
}, [language]);
@@ -442,17 +445,37 @@ 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 = () => {
const { state, isMobile } = useSidebar();
const location = useLocation();
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 />
</>
);
};
return (
<SWRConfig value={{ errorRetryCount: 3 }}>
<NoticeManager />
<div className="h-screen w-screen bg-background text-foreground overflow-hidden">
<div className="h-full w-full relative">
{React.cloneElement(routersEles, { key: location.pathname })}
</div>
</div>
<SidebarProvider defaultOpen={false}>
<AppLayout />
</SidebarProvider>
</SWRConfig>
);
};

View File

@@ -8,7 +8,6 @@ import React, {
import { useLockFn } from "ahooks";
import { Virtuoso } from "react-virtuoso";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useConnectionSetting } from "@/services/states";
import { useVisibility } from "@/hooks/use-visibility";
import { useAppData } from "@/providers/app-data-provider";
@@ -16,7 +15,6 @@ import { closeAllConnections } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic";
import { cn } from "@root/lib/utils";
// Компоненты
import { BaseEmpty } from "@/components/base";
import { ConnectionItem } from "@/components/connection/connection-item";
import { ConnectionTable } from "@/components/connection/connection-table";
@@ -26,7 +24,6 @@ import {
} from "@/components/connection/connection-detail";
import {
BaseSearchBox,
type SearchState,
} from "@/components/base/base-search-box";
import { Button } from "@/components/ui/button";
import {
@@ -36,14 +33,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
@@ -52,7 +42,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
// Иконки
import {
List,
Table2,
@@ -62,6 +51,7 @@ import {
ArrowUp,
Menu,
} from "lucide-react";
import {SidebarTrigger} from "@/components/ui/sidebar";
const initConn: IConnections = {
uploadTotal: 0,
@@ -73,7 +63,6 @@ type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const ConnectionsPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const pageVisible = useVisibility();
const [match, setMatch] = useState(() => (_: string) => true);
const [curOrderOpt, setOrderOpt] = useState("Default");
@@ -166,15 +155,6 @@ const ConnectionsPage = () => {
});
}, [connections]);
const menuItems = [
{ label: t("Home"), path: "/home" },
{ label: t("Profiles"), path: "/profile" },
{ label: t("Settings"), path: "/settings" },
{ label: t("Logs"), path: "/logs" },
{ label: t("Proxies"), path: "/proxies" },
{ label: t("Rules"), path: "/rules" },
];
return (
<div className="h-full w-full relative">
<div
@@ -184,6 +164,9 @@ const ConnectionsPage = () => {
)}
>
<div className="flex justify-between items-center">
<div className="w-10">
<SidebarTrigger />
</div>
<h2 className="text-2xl font-semibold tracking-tight">
{t("Connections")}
</h2>
@@ -245,26 +228,6 @@ const ConnectionsPage = () => {
<Button size="sm" variant="destructive" onClick={onCloseAll}>
{t("Close All")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={t("Menu")}>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipProvider>
</div>

View File

@@ -1,5 +1,4 @@
import React, {useRef, useMemo, useCallback, useState, useEffect} from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -22,12 +21,11 @@ import {
ChevronsUpDown,
Check,
PlusCircle,
Menu,
Wrench,
AlertTriangle,
Loader2,
Globe,
Send, ExternalLink, RefreshCw,
Send, ExternalLink, RefreshCw, ArrowDown, ArrowUp,
} from "lucide-react";
import { useVerge } from "@/hooks/use-verge";
import { useSystemState } from "@/hooks/use-system-state";
@@ -38,16 +36,19 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { closeAllConnections } from "@/services/api";
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";
import { useAppData } from "@/providers/app-data-provider";
const MinimalHomePage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isToggling, setIsToggling] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const { profiles, patchProfiles, activateSelected, mutateProfiles } =
useProfiles();
const viewerRef = useRef<ProfileViewerRef>(null);
const [uidToActivate, setUidToActivate] = useState<string | null>(null);
const { connections } = useAppData();
const profileItems = useMemo(() => {
const items =
@@ -59,7 +60,6 @@ const MinimalHomePage: React.FC = () => {
const currentProfile = useMemo(() => {
return profileItems.find(p => p.uid === profiles?.current);
}, [profileItems, profiles?.current]);
console.log("Current profile", currentProfile);
const currentProfileName = currentProfile?.name || profiles?.current;
const activateProfile = useCallback(
@@ -80,26 +80,13 @@ const MinimalHomePage: React.FC = () => {
);
useEffect(() => {
const handleActivationEvent = (event: Event) => {
const customEvent = event as CustomEvent<string>;
const profileId = customEvent.detail;
if (profileId) {
setUidToActivate(profileId);
}
};
window.addEventListener('activate-profile', handleActivationEvent);
return () => {
window.removeEventListener('activate-profile', handleActivationEvent);
};
}, []);
useEffect(() => {
const uidToActivate = sessionStorage.getItem('activateProfile');
if (uidToActivate && profileItems.some(p => p.uid === uidToActivate)) {
activateProfile(uidToActivate, false);
setUidToActivate(null);
sessionStorage.removeItem('activateProfile');
}
}, [uidToActivate, profileItems, activateProfile]);
}, [profileItems, activateProfile]);
const handleProfileChange = useLockFn(async (uid: string) => {
if (profiles?.current === uid) return;
@@ -167,29 +154,19 @@ const MinimalHomePage: React.FC = () => {
}
});
const navMenuItems = [
{ label: "Profiles", path: "/profile" },
{ label: "Settings", path: "/settings" },
{ label: "Logs", path: "/logs" },
{ label: "Proxies", path: "/proxies" },
{ label: "Connections", path: "/connections" },
{ label: "Rules", path: "/rules" },
];
return (
<div className="flex flex-col h-screen p-5">
<header className="absolute top-0 left-0 right-0 p-5 flex items-center justify-between z-20">
<div className="w-10"></div>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<div className="h-full w-full flex flex-col">
<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">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-full max-w-[250px] sm:max-w-xs"
>
<Button variant="outline" className="w-full max-w-[250px] sm:max-w-xs">
<span className="truncate">{currentProfileName}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -218,84 +195,73 @@ const MinimalHomePage: React.FC = () => {
</div>
)}
{currentProfile?.type === 'remote' && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleUpdateProfile}
disabled={isUpdating}
className="flex-shrink-0"
>
{isUpdating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<RefreshCw className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Update")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="absolute top-1/2 -translate-y-1/2 left-full ml-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleUpdateProfile}
disabled={isUpdating}
className="flex-shrink-0"
>
{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>
</div>
<div className="w-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{navMenuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
>
{t(item.label)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex justify-end">
</div>
</header>
<div className="flex items-center justify-center flex-grow w-full">
<div className="flex flex-col items-center gap-8 pt-10">
<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="flex-shrink-0 flex justify-center text-center px-5">
<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"
title={currentProfile.announce_url}
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}</span>
<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 max-w-lg">
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
{currentProfile.announce}
</p>
)}
</div>
)}
<div className="text-center">
<div className="relative text-center">
<h1
className="text-4xl mb-2 font-semibold"
style={{ color: isProxyEnabled ? "#22c55e" : "#ef4444" }}
>
{isProxyEnabled ? t("Connected") : t("Disconnected")}
</h1>
{isProxyEnabled && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-48 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>
)}
<p className="h-6 text-sm text-muted-foreground transition-opacity duration-300">
{isToggling &&
(isProxyEnabled ? t("Disconnecting...") : t("Connecting..."))}
@@ -304,13 +270,14 @@ const MinimalHomePage: React.FC = () => {
<div className="scale-[7] my-16">
<Switch
disabled={showTunAlert || isToggling}
disabled={showTunAlert || isToggling || profileItems.length === 0}
checked={!!isProxyEnabled}
onCheckedChange={handleToggleProxy}
aria-label={t("Toggle Proxy")}
/>
</div>
{showTunAlert && (
<div className="w-full max-w-sm">
<Alert
@@ -357,8 +324,8 @@ const MinimalHomePage: React.FC = () => {
</Alert>
)}
</div>
</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">

View File

@@ -1,5 +1,3 @@
// LogPage.tsx
import React, {
useMemo,
useState,
@@ -10,8 +8,7 @@ import React, {
import { Virtuoso } from "react-virtuoso";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "foxact/use-local-storage";
import { useNavigate } from "react-router-dom";
import { Play, Pause, Trash2, Menu } from "lucide-react";
import { Play, Pause, Trash2 } from "lucide-react";
import { LogLevel } from "@/hooks/use-log-data";
import { useClashInfo } from "@/hooks/use-clash";
import { useEnableLog } from "@/services/states";
@@ -34,18 +31,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {SidebarTrigger} from "@/components/ui/sidebar";
const LogPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [enableLog, setEnableLog] = useEnableLog();
const { clashInfo } = useClashInfo();
const [logLevel, setLogLevel] = useLocalStorage<LogLevel>(
@@ -104,28 +93,16 @@ const LogPage = () => {
[],
);
const menuItems = [
{ label: t("Home"), path: "/home" },
{ label: t("Profiles"), path: "/profile" },
{ label: t("Settings"), path: "/settings" },
{ label: t("Proxies"), path: "/proxies" },
{ label: t("Connections"), path: "/connections" },
{ label: t("Rules"), path: "/rules" },
];
return (
<div className="h-full w-full relative">
{/* "Липкая" шапка */}
<div
className={cn(
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
// --- НАЧАЛО ИЗМЕНЕНИЙ ---
// Вместо блюра делаем солидный фон с тенью при прокрутке
{ "bg-background shadow-md": isScrolled },
// --- КОНЕЦ ИЗМЕНЕНИЙ ---
)}
>
<div className="flex justify-between items-center mb-4">
<SidebarTrigger />
<h2 className="text-2xl font-semibold tracking-tight">{t("Logs")}</h2>
<div className="flex items-center gap-2">
<Button
@@ -146,26 +123,6 @@ const LogPage = () => {
{t("Clear")}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={t("Menu")}>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex items-center space-x-2">
@@ -187,7 +144,6 @@ const LogPage = () => {
</div>
</div>
{/* Возвращаем Virtuoso на место */}
<div
ref={scrollContainerRef}
className="absolute top-0 left-0 right-0 bottom-0 pt-32 overflow-y-auto"

View File

@@ -41,14 +41,12 @@ import { ConfigViewer } from "@/components/setting/mods/config-viewer";
import { throttle } from "lodash-es";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { readText } from "@tauri-apps/plugin-clipboard-manager";
import { useLocation, useNavigate } from "react-router-dom";
import { useLocation } from "react-router-dom";
import { useListen } from "@/hooks/use-listen";
import { listen, TauriEvent } from "@tauri-apps/api/event";
import { showNotice } from "@/services/noticeService";
import { cn } from "@root/lib/utils";
// Компоненты shadcn/ui
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -56,31 +54,19 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// Иконки
import {
ClipboardPaste,
X,
PlusCircle,
RefreshCw,
Zap,
FileText,
Loader2,
Menu,
} from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
const ProfilePage = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { addListener } = useListen();
const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false);
@@ -89,7 +75,6 @@ const ProfilePage = () => {
const [updateAllLoading, setUpdateAllLoading] = useState(false);
const [enhanceLoading, setEnhanceLoading] = useState(false);
// Логика для "липкой" шапки
const scrollerRef = useRef<HTMLDivElement>(null);
const [isScrolled, setIsScrolled] = useState(false);
@@ -335,15 +320,6 @@ const ProfilePage = () => {
};
}, [mutateProfiles]);
const menuItems = [
{ label: t("Home"), path: "/home" },
{ label: t("Settings"), path: "/settings" },
{ label: t("Logs"), path: "/logs" },
{ label: t("Proxies"), path: "/proxies" },
{ label: t("Connections"), path: "/connections" },
{ label: t("Rules"), path: "/rules" },
];
return (
<div className="h-full w-full relative">
<div
@@ -353,6 +329,9 @@ const ProfilePage = () => {
)}
>
<div className="flex justify-between items-center">
<div className="w-10">
<SidebarTrigger />
</div>
<h2 className="text-2xl font-semibold tracking-tight">
{t("Profiles")}
</h2>
@@ -424,74 +403,14 @@ const ProfilePage = () => {
<p>{t("View Runtime Config")}</p>
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={t("Menu")}>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipProvider>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 flex-grow sm:flex-grow-0">
<Input
type="text"
placeholder={t("Profile URL")}
value={url}
onChange={(e) => setUrl(e.target.value)}
className="h-9 min-w-[200px] flex-grow sm:w-80"
/>
{url ? (
<Button
variant="ghost"
size="icon"
title={t("Clear")}
onClick={() => setUrl("")}
className="h-9 w-9 flex-shrink-0"
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
title={t("Paste")}
onClick={onCopyLink}
className="h-9 w-9 flex-shrink-0"
>
<ClipboardPaste className="h-4 w-4" />
</Button>
)}
</div>
<Button
onClick={onImport}
disabled={!url || disabled || importLoading}
className="h-9"
>
{importLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Import")}
</Button>
</div>
</div>
<div
ref={scrollerRef}
className="absolute top-0 left-0 right-0 bottom-0 pt-40 overflow-y-auto"
className="absolute top-0 left-0 right-0 bottom-0 pt-25 overflow-y-auto"
>
<DndContext
sensors={sensors}

View File

@@ -1,27 +1,17 @@
import useSWR from "swr";
import { useEffect } from "react";
import React, { useEffect } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { closeAllConnections, getClashConfig } from "@/services/api";
import { patchClashMode } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { ProxyGroups } from "@/components/proxy/proxy-groups";
import { ProviderButton } from "@/components/proxy/provider-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Menu } from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
const ProxyPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: clashConfig, mutate: mutateClash } = useSWR(
"getClashConfig",
@@ -33,7 +23,7 @@ const ProxyPage = () => {
);
const { verge } = useVerge();
const modeList = ["rule", "global", "direct"];
const modeList = ["rule", "global"];
const curMode = clashConfig?.mode?.toLowerCase();
const onChangeMode = useLockFn(async (mode: string) => {
@@ -50,18 +40,12 @@ const ProxyPage = () => {
}
}, [curMode]);
const menuItems = [
{ label: t("Home"), path: "/home" },
{ label: t("Profiles"), path: "/profile" },
{ label: t("Settings"), path: "/settings" },
{ label: t("Logs"), path: "/logs" },
{ label: t("Connections"), path: "/connections" },
{ label: t("Rules"), path: "/rules" },
];
return (
<div className="h-full flex flex-col">
<div className="p-4 pb-2 flex justify-between items-center">
<div className="w-10">
<SidebarTrigger />
</div>
<h2 className="text-2xl font-semibold tracking-tight">
{t("Proxies")}
</h2>
@@ -74,32 +58,12 @@ const ProxyPage = () => {
variant={mode === curMode ? "default" : "ghost"}
size="sm"
onClick={() => onChangeMode(mode)}
className="capitalize px-3 py-1 h-auto"
className="px-3 py-1 h-auto"
>
{t(mode)}
</Button>
))}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={t("Menu")}>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

View File

@@ -1,33 +1,20 @@
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import React, { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { useAppData } from "@/providers/app-data-provider";
import { useVisibility } from "@/hooks/use-visibility";
import { cn } from "@root/lib/utils";
// Компоненты
import { BaseEmpty } from "@/components/base";
import RuleItem from "@/components/rule/rule-item";
import { ProviderButton } from "@/components/rule/provider-button";
import { BaseSearchBox, SearchState } from "@/components/base/base-search-box";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { ScrollTopButton } from "@/components/layout/scroll-top-button";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// Иконки
import { Menu } from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
const RulesPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { rules = [], refreshRules, refreshRuleProviders } = useAppData();
const [match, setMatch] = useState(() => (_: string) => true);
const virtuosoRef = useRef<VirtuosoHandle>(null);
@@ -36,24 +23,17 @@ const RulesPage = () => {
const [isScrolled, setIsScrolled] = useState(false);
const pageVisible = useVisibility();
// --- НАЧАЛО ИЗМЕНЕНИЙ 1 ---
// Разделяем логику на два безопасных useEffect
useEffect(() => {
// Этот эффект сработает только один раз при монтировании компонента
refreshRules();
refreshRuleProviders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Пустой массив зависимостей = запуск только один раз
}, []);
useEffect(() => {
// Этот эффект будет срабатывать только при изменении видимости страницы
if (pageVisible) {
refreshRules();
refreshRuleProviders();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageVisible]);
// --- КОНЕЦ ИЗМЕНЕНИЙ 1 ---
const filteredRules = useMemo(() => {
return rules.filter((item) => match(item.payload));
@@ -75,21 +55,9 @@ const RulesPage = () => {
virtuosoRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []);
// --- НАЧАЛО ИЗМЕНЕНИЙ 2 ---
// Оборачиваем обработчик поиска в useCallback для стабильности
const handleSearch = useCallback((matcher: (content: string) => boolean) => {
setMatch(() => matcher);
}, []);
// --- КОНЕЦ ИЗМЕНЕНИЙ 2 ---
const menuItems = [
{ label: t("Home"), path: "/home" },
{ label: t("Profiles"), path: "/profile" },
{ label: t("Settings"), path: "/settings" },
{ label: t("Logs"), path: "/logs" },
{ label: t("Proxies"), path: "/proxies" },
{ label: t("Connections"), path: "/connections" },
];
return (
<div className="h-full w-full relative">
@@ -100,35 +68,17 @@ const RulesPage = () => {
)}
>
<div className="flex justify-between items-center">
<div className="w-10">
<SidebarTrigger />
</div>
<h2 className="text-2xl font-semibold tracking-tight">
{t("Rules")}
</h2>
<div className="flex items-center gap-2">
<div className="w-70">
{/* Передаем стабильную функцию handleSearch в пропс */}
<BaseSearchBox onSearch={handleSearch} />
</div>
<ProviderButton />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={t("Menu")}>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
@@ -150,7 +100,6 @@ const RulesPage = () => {
<BaseEmpty />
)}
</div>
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
</div>
);

View File

@@ -7,23 +7,14 @@ import SettingVergeAdvanced from "@/components/setting/setting-verge-advanced";
import SettingClash from "@/components/setting/setting-clash";
import SettingSystem from "@/components/setting/setting-system";
import { showNotice } from "@/services/noticeService";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Menu, Github, HelpCircle, Send } from "lucide-react";
import { cn } from "@root/lib/utils";
import { SidebarTrigger } from "@/components/ui/sidebar";
const SettingPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isScrolled, setIsScrolled] = useState(false);
@@ -46,14 +37,6 @@ const SettingPage = () => {
const toGithubRepo = useLockFn(() =>
openWebUrl("https://github.com/coolcoala/clash-verge-rev-lite"),
);
const menuItems = [
{ label: t("Home"), path: "/home" },
{ label: t("Profiles"), path: "/profile" },
{ label: t("Logs"), path: "/logs" },
{ label: t("Proxies"), path: "/proxies" },
{ label: t("Connections"), path: "/connections" },
{ label: t("Rules"), path: "/rules" },
];
return (
<div className="h-full w-full relative">
@@ -63,9 +46,13 @@ const SettingPage = () => {
{ "bg-background/80 backdrop-blur-sm": isScrolled },
)}
>
<div className="w-10">
<SidebarTrigger />
</div>
<h2 className="text-2xl font-semibold tracking-tight">
{t("Settings")}
</h2>
<div className="flex items-center gap-2">
<Button
variant="ghost"
@@ -75,26 +62,6 @@ const SettingPage = () => {
>
<Github className="h-5 w-5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title={t("Menu")}>
<Menu className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{menuItems.map((item) => (
<DropdownMenuItem
key={item.path}
onSelect={() => navigate(item.path)}
disabled={location.pathname === item.path}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>

View File

@@ -400,3 +400,7 @@ export const isAdmin = async () => {
export async function getNextUpdateTime(uid: string) {
return invoke<number | null>("get_next_update_time", { uid });
}
export async function createProfileFromShareLink(link: string, templateName: string) {
return invoke<void>("create_profile_from_share_link", { link, templateName });
}