Compare commits

...

31 Commits

42 changed files with 1644 additions and 2888 deletions

View File

@@ -104,8 +104,8 @@ jobs:
# Generate autobuild version using autobuild-latest format
CURRENT_BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-(alpha|beta|rc)(\.[0-9]+)?//g' | sed -E 's/\+[a-zA-Z0-9.-]+//g')
MONTH=$(date +%m)
DAY=$(date +%d)
MONTH=$(TZ=Asia/Shanghai date +%m)
DAY=$(TZ=Asia/Shanghai date +%d)
AUTOBUILD_VERSION="${CURRENT_BASE_VERSION}+autobuild.${MONTH}${DAY}.${LAST_TAURI_COMMIT}"
echo "🏷️ Autobuild version: $AUTOBUILD_VERSION"

View File

@@ -88,8 +88,8 @@ jobs:
# Generate autobuild version for consistency
CURRENT_BASE_VERSION=$(echo "$CURRENT_VERSION" | sed -E 's/-(alpha|beta|rc)(\.[0-9]+)?//g' | sed -E 's/\+[a-zA-Z0-9.-]+//g')
MONTH=$(date +%m)
DAY=$(date +%d)
MONTH=$(TZ=Asia/Shanghai date +%m)
DAY=$(TZ=Asia/Shanghai date +%d)
AUTOBUILD_VERSION="${CURRENT_BASE_VERSION}+autobuild.${MONTH}${DAY}.${LAST_TAURI_COMMIT}"
echo "🏷️ Current autobuild version: $AUTOBUILD_VERSION"

View File

@@ -122,10 +122,10 @@ jobs:
### Linux
#### DEB包(Debian系) 使用 apt ./路径 安装
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb)
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb)
#### RPM包(Redhat系) 使用 dnf ./路径 安装
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhfp.rpm)
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.x86_64.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.armhfp.rpm)
### FAQ
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
@@ -455,7 +455,7 @@ jobs:
release-update:
name: Release Update
runs-on: ubuntu-latest
needs: [release, release-for-linux-arm]
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -480,7 +480,7 @@ jobs:
release-update-for-fixed-webview2:
runs-on: ubuntu-latest
needs: [release-for-fixed-webview2]
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -605,10 +605,10 @@ jobs:
### Linux
#### DEB包(Debian系) 使用 apt ./路径 安装
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb)
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.deb) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_arm64.deb) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhf.deb)
#### RPM包(Redhat系) 使用 dnf ./路径 安装
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_amd64.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge_${{ env.VERSION }}_armhfp.rpm)
- [64位](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.x86_64.rpm) | [ARM64](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.aarch64.rpm) | [ARMv7](${{ env.DOWNLOAD_URL }}/Clash.Verge-${{ env.VERSION }}-1.armhfp.rpm)
### FAQ
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)

View File

@@ -1,3 +1,32 @@
## v2.4.2
### ✨ 新增功能
- 增加托盘节点选择
### 🚀 性能优化
- 优化前端首页加载速度
- 优化前端未使用 i18n 文件缓存呢
- 优化后端内存占用
- 优化后端启动速度
### 🐞 修复问题
- 修复首页节点切换失效的问题
- 修复和优化服务检查流程
- 修复2.4.1引入的订阅地址重定向报错问题
- 修复 rpm/deb 包名称问题
- 修复托盘轻量模式状态检测异常
- 修复通过 scheme 导入订阅崩溃
- 修复单例检测实效
- 修复启动阶段可能导致的无法连接内核
- 修复导入订阅无法 Auth Basic
### 👙 界面样式
- 简化和改进代理设置样式
## v2.4.1
### 🏆 重大改进

View File

@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.4.1",
"version": "2.4.2",
"license": "GPL-3.0-only",
"scripts": {
"dev": "cross-env RUST_BACKTRACE=1 tauri dev -f verge-dev",
@@ -35,26 +35,26 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@mui/icons-material": "^7.3.1",
"@mui/lab": "7.0.0-beta.16",
"@mui/material": "^7.3.1",
"@mui/x-data-grid": "^8.11.0",
"@mui/icons-material": "^7.3.2",
"@mui/lab": "7.0.0-beta.17",
"@mui/material": "^7.3.2",
"@mui/x-data-grid": "^8.11.1",
"@tauri-apps/api": "2.8.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.3.3",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-notification": "^2.3.1",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "2.3.1",
"@tauri-apps/plugin-updater": "2.9.0",
"@types/json-schema": "^7.0.15",
"ahooks": "^3.9.4",
"ahooks": "^3.9.5",
"axios": "^1.11.0",
"cli-color": "^2.0.4",
"dayjs": "1.11.16",
"dayjs": "1.11.18",
"foxact": "^0.2.49",
"glob": "^11.0.3",
"i18next": "^25.4.2",
"i18next": "^25.5.2",
"js-yaml": "^4.1.0",
"json-schema": "^0.4.0",
"lodash-es": "^4.17.21",
@@ -78,8 +78,8 @@
},
"devDependencies": {
"@actions/github": "^6.0.1",
"@eslint/js": "^9.34.0",
"@tauri-apps/cli": "2.8.3",
"@eslint/js": "^9.35.0",
"@tauri-apps/cli": "2.8.4",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/react": "19.1.12",
@@ -89,19 +89,21 @@
"adm-zip": "^0.5.16",
"commander": "^14.0.0",
"cross-env": "^10.0.0",
"eslint": "^9.34.0",
"eslint": "^9.35.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"https-proxy-agent": "^7.0.6",
"jiti": "^2.5.1",
"meta-json-schema": "^1.19.12",
"meta-json-schema": "^1.19.13",
"node-fetch": "^3.3.2",
"path": "^0.12.7",
"prettier": "^3.6.2",
"sass": "^1.91.0",
"terser": "^5.43.1",
"process": "^0.11.10",
"sass": "^1.92.1",
"terser": "^5.44.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.41.0",
"vite": "^7.1.3",
"typescript-eslint": "^8.42.0",
"vite": "^7.1.4",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-svgr": "^4.5.0"
},

2482
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -68,14 +68,24 @@ function getLatestTauriCommit() {
}
/**
* 生成短时间戳(格式:YYMMDD或带 commit格式YYMMDD.cc39b27
* 生成短时间戳格式MMDD或带 commit格式MMDD.cc39b27
* 使用 Asia/Shanghai 时区
* @param {boolean} withCommit 是否带 commit
* @returns {string}
*/
function generateShortTimestamp(withCommit = false) {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(now);
const month = parts.find((part) => part.type === "month").value;
const day = parts.find((part) => part.type === "day").value;
if (withCommit) {
const gitShort = getGitShortCommit();
return `${month}${day}.${gitShort}`;

View File

@@ -75,7 +75,7 @@ async function sendTelegramNotification() {
const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布";
const encodedVersion = encodeURIComponent(version);
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${encodedVersion}">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`;
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`;
// 发送到 Telegram
try {

154
src-tauri/Cargo.lock generated
View File

@@ -1089,7 +1089,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "clash-verge"
version = "2.4.1"
version = "2.4.2"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1152,7 +1152,7 @@ dependencies = [
"warp",
"winapi",
"winreg 0.55.0",
"zip",
"zip 5.0.0",
]
[[package]]
@@ -1391,6 +1391,21 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -1688,17 +1703,6 @@ dependencies = [
"serde",
]
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -1723,6 +1727,27 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"unicode-xid",
]
[[package]]
name = "destructure_traitobject"
version = "0.2.0"
@@ -3819,26 +3844,6 @@ dependencies = [
"windows-targets 0.53.3",
]
[[package]]
name = "liblzma"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10bf66f4598dc77ff96677c8e763655494f00ff9c1cf79e2eb5bb07bc31f807d"
dependencies = [
"liblzma-sys",
]
[[package]]
name = "liblzma-sys"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "libredox"
version = "0.1.9"
@@ -3931,9 +3936,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.27"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
dependencies = [
"serde",
]
@@ -3946,29 +3951,30 @@ checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7"
[[package]]
name = "log4rs"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0816135ae15bd0391cf284eab37e6e3ee0a6ee63d2ceeb659862bd8d0a984ca6"
checksum = "3e947bb896e702c711fccc2bf02ab2abb6072910693818d1d6b07ee2b9dfd86c"
dependencies = [
"anyhow",
"arc-swap",
"chrono",
"derivative",
"derive_more 2.0.1",
"fnv",
"humantime",
"libc",
"log",
"log-mdc",
"once_cell",
"mock_instant",
"parking_lot 0.12.4",
"rand 0.8.5",
"rand 0.9.2",
"serde",
"serde-value",
"serde_json",
"serde_yaml",
"thiserror 1.0.69",
"thiserror 2.0.16",
"thread-id",
"typemap-ors",
"unicode-segmentation",
"winapi",
]
@@ -3987,6 +3993,16 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a"
dependencies = [
"crc",
"sha2 0.10.9",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -4149,6 +4165,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "mock_instant"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6"
[[package]]
name = "muda"
version = "0.17.1"
@@ -6241,7 +6263,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
dependencies = [
"bitflags 1.3.2",
"cssparser",
"derive_more",
"derive_more 0.99.20",
"fxhash",
"log",
"phf 0.8.0",
@@ -7009,9 +7031,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.8.4"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d545ccf7b60dcd44e07c6fb5aeb09140966f0aabd5d2aa14a6821df7bc99348"
checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c"
dependencies = [
"anyhow",
"bytes",
@@ -7064,9 +7086,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.4.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67945dbaf8920dbe3a1e56721a419a0c3d085254ab24cff5b9ad55e2b0016e0b"
checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f"
dependencies = [
"anyhow",
"cargo_toml",
@@ -7173,9 +7195,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.2"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d430110d4ee102a9b673d3c03ff48098c80fe8ca71ba1ff52d8a5919538a1a6"
checksum = "cd67112fb1131834c2a7398ffcba520dbbf62c17de3b10329acd1a3554b1a9bb"
dependencies = [
"dunce",
"plist",
@@ -7221,9 +7243,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.3.3"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee5a3c416dc59d7d9aa0de5490a82d6e201c67ffe97388979d77b69b08cda40"
checksum = "0beee42a4002bc695550599b011728d9dfabf82f767f134754ed6655e434824e"
dependencies = [
"log",
"raw-window-handle",
@@ -7353,7 +7375,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -7610,12 +7632,12 @@ dependencies = [
[[package]]
name = "thread-id"
version = "4.2.2"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe8f25bbdd100db7e1d34acf7fd2dc59c4bf8f7483f505eaa7d4f12f76cc0ea"
checksum = "99043e46c5a15af379c06add30d9c93a6c0e8849de00d244c4a2c417da128d80"
dependencies = [
"libc",
"winapi",
"windows-sys 0.59.0",
]
[[package]]
@@ -8295,6 +8317,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -9772,9 +9800,21 @@ dependencies = [
[[package]]
name = "zip"
version = "4.5.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835eb39822904d39cb19465de1159e05d371973f0c6df3a365ad50565ddc8b9"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.11.0",
"memchr",
]
[[package]]
name = "zip"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9fdfa5f34b5980f2c21b3a2c68c09ade4debddc7be52c51056695effc73a08c"
dependencies = [
"aes",
"arbitrary",
@@ -9786,7 +9826,7 @@ dependencies = [
"getrandom 0.3.3",
"hmac",
"indexmap 2.11.0",
"liblzma",
"lzma-rust2",
"memchr",
"pbkdf2",
"ppmd-rust",

View File

@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.4.1"
version = "2.4.2"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -13,16 +13,16 @@ build = "build.rs"
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.4.0", features = [] }
tauri-build = { version = "2.4.1", features = [] }
[dependencies]
warp = { version = "0.4.2", features = ["server"] }
anyhow = "1.0.99"
dirs = "6.0"
open = "5.3.2"
log = "0.4.27"
log = "0.4.28"
dunce = "1.0.5"
log4rs = "1.3.0"
log4rs = "1.4.0"
nanoid = "0.4"
chrono = "0.4.41"
sysinfo = { version = "0.37.0", features = ["network", "system"] }
@@ -44,7 +44,7 @@ serde = { version = "1.0.219", features = ["derive"] }
reqwest = { version = "0.12.23", features = ["json", "cookies"] }
regex = "1.11.2"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs" }
tauri = { version = "2.8.4", features = [
tauri = { version = "2.8.5", features = [
"protocol-asset",
"devtools",
"tray-icon",
@@ -53,14 +53,14 @@ tauri = { version = "2.8.4", features = [
] }
network-interface = { version = "2.0.3", features = ["serde"] }
tauri-plugin-shell = "2.3.1"
tauri-plugin-dialog = "2.3.3"
tauri-plugin-dialog = "2.4.0"
tauri-plugin-fs = "2.4.2"
tauri-plugin-process = "2.3.0"
tauri-plugin-clipboard-manager = "2.3.0"
tauri-plugin-deep-link = "2.4.2"
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-devtools = "2.0.1"
tauri-plugin-window-state = "2.4.0"
zip = "4.5.0"
zip = "5.0.0"
reqwest_dav = "0.2.2"
aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1"

View File

@@ -1,5 +1,13 @@
use tauri::Emitter;
use super::CmdResult;
use crate::{ipc::IpcManager, logging, state::proxy::ProxyRequestCache, utils::logging::Type};
use crate::{
core::{handle::Handle, tray::Tray},
ipc::IpcManager,
logging,
state::proxy::ProxyRequestCache,
utils::logging::Type,
};
use std::time::Duration;
const PROXIES_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
@@ -7,11 +15,11 @@ const PROVIDERS_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
#[tauri::command]
pub async fn get_proxies() -> CmdResult<serde_json::Value> {
let manager = IpcManager::global();
let cache = ProxyRequestCache::global();
let key = ProxyRequestCache::make_key("proxies", "default");
let value = cache
.get_or_fetch(key, PROXIES_REFRESH_INTERVAL, || async {
let manager = IpcManager::global();
manager.get_proxies().await.unwrap_or_else(|e| {
logging!(error, Type::Cmd, "Failed to fetch proxies: {e}");
serde_json::Value::Object(serde_json::Map::new())
@@ -32,11 +40,11 @@ pub async fn force_refresh_proxies() -> CmdResult<serde_json::Value> {
#[tauri::command]
pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
let manager = IpcManager::global();
let cache = ProxyRequestCache::global();
let key = ProxyRequestCache::make_key("providers", "default");
let value = cache
.get_or_fetch(key, PROVIDERS_REFRESH_INTERVAL, || async {
let manager = IpcManager::global();
manager.get_providers_proxies().await.unwrap_or_else(|e| {
logging!(error, Type::Cmd, "Failed to fetch provider proxies: {e}");
serde_json::Value::Object(serde_json::Map::new())
@@ -45,3 +53,69 @@ pub async fn get_providers_proxies() -> CmdResult<serde_json::Value> {
.await;
Ok((*value).clone())
}
/// 同步托盘和GUI的代理选择状态
#[tauri::command]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
use crate::core::tray::Tray;
match Tray::global().update_menu().await {
Ok(_) => {
logging!(info, Type::Cmd, "Tray proxy selection synced successfully");
Ok(())
}
Err(e) => {
logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e}");
Err(e.to_string())
}
}
}
/// 更新代理选择并同步托盘和GUI状态
#[tauri::command]
pub async fn update_proxy_and_sync(group: String, proxy: String) -> CmdResult<()> {
match IpcManager::global().update_proxy(&group, &proxy).await {
Ok(_) => {
logging!(
info,
Type::Cmd,
"Proxy updated successfully: {} -> {}",
group,
proxy
);
let cache = crate::state::proxy::ProxyRequestCache::global();
let key = crate::state::proxy::ProxyRequestCache::make_key("proxies", "default");
cache.map.remove(&key);
if let Err(e) = Tray::global().update_menu().await {
logging!(error, Type::Cmd, "Failed to sync tray menu: {}", e);
}
if let Some(app_handle) = Handle::global().app_handle() {
let _ = app_handle.emit("verge://force-refresh-proxies", ());
let _ = app_handle.emit("verge://refresh-proxy-config", ());
}
logging!(
info,
Type::Cmd,
"Proxy and sync completed successfully: {} -> {}",
group,
proxy
);
Ok(())
}
Err(e) => {
logging!(
error,
Type::Cmd,
"Failed to update proxy: {} -> {}, error: {}",
group,
proxy,
e
);
Err(e.to_string())
}
}
}

View File

@@ -1071,21 +1071,22 @@ impl CoreManager {
}
logging!(info, Type::Core, true, "服务可用,使用服务模式启动");
self.start_core_by_service().await?;
return Ok(());
};
// 服务不可用,检查用户偏好
let service_state = service::ServiceState::get().await;
if service_state.prefer_sidecar {
logging!(
info,
Type::Core,
true,
"服务不可用根据用户偏好使用Sidecar模式"
);
self.start_core_by_sidecar().await?;
} else {
// 服务不可用,检查用户偏好
let service_state = service::ServiceState::get().await;
if service_state.prefer_sidecar {
logging!(
info,
Type::Core,
true,
"服务不可用根据用户偏好使用Sidecar模式"
);
self.start_core_by_sidecar().await?;
} else {
logging!(info, Type::Core, true, "服务不可用使用Sidecar模式");
self.start_core_by_sidecar().await?;
}
logging!(info, Type::Core, true, "服务不可用使用Sidecar模式");
self.start_core_by_sidecar().await?;
}
Ok(())
}
@@ -1102,7 +1103,6 @@ impl CoreManager {
/// 重启内核
pub async fn restart_core(&self) -> Result<()> {
self.stop_core().await?;
self.start_core().await?;
Ok(())
}

View File

@@ -13,7 +13,7 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};
const REQUIRED_SERVICE_VERSION: &str = "1.1.1"; // 定义所需的服务版本号
const REQUIRED_SERVICE_VERSION: &str = "1.1.2"; // 定义所需的服务版本号
// 限制重装时间和次数的常量
const REINSTALL_COOLDOWN_SECS: u64 = 300; // 5分钟冷却期

View File

@@ -1,5 +1,6 @@
use once_cell::sync::OnceCell;
use tauri::tray::TrayIconBuilder;
use tauri::Emitter;
#[cfg(target_os = "macos")]
pub mod speed_rate;
use crate::ipc::Rate;
@@ -7,7 +8,9 @@ use crate::process::AsyncHandler;
use crate::{
cmd,
config::Config,
feat, logging,
feat,
ipc::IpcManager,
logging,
module::lightweight::is_in_lightweight_mode,
singleton_lazy,
utils::{dirs::find_target_icons, i18n::t},
@@ -281,6 +284,16 @@ impl Tray {
.unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode();
// 获取代理节点
let proxy_nodes_data = cmd::get_proxies().await.unwrap_or_else(|e| {
logging!(
error,
Type::Cmd,
"Failed to fetch proxies for tray menu: {e}"
);
serde_json::Value::Object(serde_json::Map::new())
});
match app_handle.tray_by_id("main") {
Some(tray) => {
let _ = tray.set_menu(Some(
@@ -291,6 +304,7 @@ impl Tray {
*tun_mode,
profile_uid_and_name,
is_lightweight_mode,
proxy_nodes_data,
)
.await?,
));
@@ -557,9 +571,21 @@ async fn create_tray_menu(
tun_mode_enabled: bool,
profile_uid_and_name: Vec<(String, String)>,
is_lightweight_mode: bool,
proxy_nodes_data: serde_json::Value,
) -> Result<tauri::menu::Menu<Wry>> {
let mode = mode.unwrap_or("");
// 获取当前配置文件的选中代理组信息
let current_profile_selected = {
let profiles_config = Config::profiles().await;
let profiles_ref = profiles_config.latest_ref();
profiles_ref
.get_current()
.and_then(|uid| profiles_ref.get_item(&uid).ok())
.and_then(|profile| profile.selected.clone())
.unwrap_or_default()
};
let version = env!("CARGO_PKG_VERSION");
let hotkeys = Config::verge()
@@ -606,12 +632,117 @@ async fn create_tray_menu(
results.into_iter().collect::<Result<Vec<_>, _>>()?
};
// 代理组子菜单
let proxy_submenus: Vec<Submenu<Wry>> = {
let mut submenus = Vec::new();
if let Some(proxies) = proxy_nodes_data.get("proxies").and_then(|v| v.as_object()) {
for (group_name, group_data) in proxies.iter() {
// Filter groups based on mode
let should_show = match mode {
"global" => group_name == "GLOBAL",
_ => group_name != "GLOBAL",
};
if !should_show {
continue;
}
let Some(all_proxies) = group_data.get("all").and_then(|v| v.as_array()) else {
continue;
};
let now_proxy = group_data.get("now").and_then(|v| v.as_str()).unwrap_or("");
// Create proxy items
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
.iter()
.filter_map(|proxy_name| proxy_name.as_str())
.filter_map(|proxy_str| {
let is_selected = proxy_str == now_proxy;
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
// Get delay for display
let delay_text = proxies
.get(proxy_str)
.and_then(|p| p.get("history"))
.and_then(|h| h.as_array())
.and_then(|h| h.last())
.and_then(|r| r.get("delay"))
.and_then(|d| d.as_i64())
.map(|delay| match delay {
-1 => "-ms".to_string(),
delay if delay >= 10000 => "-ms".to_string(),
_ => format!("{}ms", delay),
})
.unwrap_or_else(|| "-ms".to_string());
let display_text = format!("{} | {}", proxy_str, delay_text);
CheckMenuItem::with_id(
app_handle,
item_id,
display_text,
true,
is_selected,
None::<&str>,
)
.map_err(|e| log::warn!(target: "app", "创建代理菜单项失败: {}", e))
.ok()
})
.collect();
if group_items.is_empty() {
continue;
}
// Determine if group is active
let is_group_active = match mode {
"global" => group_name == "GLOBAL" && !now_proxy.is_empty(),
"direct" => false,
_ => {
current_profile_selected
.iter()
.any(|s| s.name.as_deref() == Some(group_name))
&& !now_proxy.is_empty()
}
};
let group_display_name = if is_group_active {
format!("{}", group_name)
} else {
group_name.to_string()
};
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> = group_items
.iter()
.map(|item| item as &dyn IsMenuItem<Wry>)
.collect();
if let Ok(submenu) = Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group_name),
group_display_name,
true,
&group_items_refs,
) {
submenus.push(submenu);
} else {
log::warn!(target: "app", "创建代理组子菜单失败: {}", group_name);
}
}
}
submenus
};
// Pre-fetch all localized strings
let dashboard_text = t("Dashboard").await;
let rule_mode_text = t("Rule Mode").await;
let global_mode_text = t("Global Mode").await;
let direct_mode_text = t("Direct Mode").await;
let profiles_text = t("Profiles").await;
let proxies_text = t("Proxies").await;
let system_proxy_text = t("System Proxy").await;
let tun_mode_text = t("TUN Mode").await;
let lightweight_mode_text = t("LightWeight Mode").await;
@@ -675,6 +806,24 @@ async fn create_tray_menu(
&profile_menu_items_refs,
)?;
// 创建代理主菜单
let proxies_submenu = if !proxy_submenus.is_empty() {
let proxy_submenu_refs: Vec<&dyn IsMenuItem<Wry>> = proxy_submenus
.iter()
.map(|submenu| submenu as &dyn IsMenuItem<Wry>)
.collect();
Some(Submenu::with_id_and_items(
app_handle,
"proxies",
proxies_text,
true,
&proxy_submenu_refs,
)?)
} else {
None
};
let system_proxy = &CheckMenuItem::with_id(
app_handle,
"system_proxy",
@@ -772,26 +921,37 @@ async fn create_tray_menu(
let separator = &PredefinedMenuItem::separator(app_handle)?;
// 动态构建菜单项
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![
open_window,
separator,
rule_mode,
global_mode,
direct_mode,
separator,
profiles,
];
// 如果有代理节点,添加代理节点菜单
if let Some(ref proxies_menu) = proxies_submenu {
menu_items.push(proxies_menu);
}
menu_items.extend_from_slice(&[
separator,
system_proxy as &dyn IsMenuItem<Wry>,
tun_mode as &dyn IsMenuItem<Wry>,
separator,
lighteweight_mode as &dyn IsMenuItem<Wry>,
copy_env as &dyn IsMenuItem<Wry>,
open_dir as &dyn IsMenuItem<Wry>,
more as &dyn IsMenuItem<Wry>,
separator,
quit as &dyn IsMenuItem<Wry>,
]);
let menu = tauri::menu::MenuBuilder::new(app_handle)
.items(&[
open_window,
separator,
rule_mode,
global_mode,
direct_mode,
separator,
profiles,
separator,
system_proxy,
tun_mode,
separator,
lighteweight_mode,
copy_env,
open_dir,
more,
separator,
quit,
])
.items(&menu_items)
.build()?;
Ok(menu)
}
@@ -819,11 +979,11 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}
if crate::module::lightweight::is_in_lightweight_mode() {
log::info!(target: "app", "当前在轻量模式,正在退出");
logging!(info, Type::Lightweight, true, "Exiting Lightweight Mode");
crate::module::lightweight::exit_lightweight_mode().await; // Await async function
}
let result = WindowManager::show_main_window().await; // Await async function
log::info!(target: "app", "窗口显示结果: {result:?}");
logging!(info, Type::Window, true, "Show Main Window: {result:?}");
}
"system_proxy" => {
feat::toggle_system_proxy().await; // Await async function
@@ -853,7 +1013,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
crate::module::lightweight::exit_lightweight_mode().await; // Await async function
use crate::utils::window_manager::WindowManager;
let result = WindowManager::show_main_window().await; // Await async function
log::info!(target: "app", "退出轻量模式后显示主窗口: {result:?}");
logging!(info, Type::Window, true, "Show Main Window: {result:?}");
} else {
crate::module::lightweight::entry_lightweight_mode().await; // Remove .await as it's not async
}
@@ -865,6 +1025,42 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
let profile_index = &id["profiles_".len()..];
feat::toggle_proxy_profile(profile_index.into()).await; // Await async function
}
id if id.starts_with("proxy_") => {
// proxy_{group_name}_{proxy_name}
let parts: Vec<&str> = id.splitn(3, '_').collect();
if parts.len() == 3 && parts[0] == "proxy" {
let group_name = parts[1];
let proxy_name = parts[2];
match cmd::proxy::update_proxy_and_sync(
group_name.to_string(),
proxy_name.to_string(),
)
.await
{
Ok(_) => {
log::info!(target: "app", "切换代理成功: {} -> {}", group_name, proxy_name);
}
Err(e) => {
log::error!(target: "app", "切换代理失败: {} -> {}, 错误: {:?}", group_name, proxy_name, e);
// Fallback to IPC update
if (IpcManager::global()
.update_proxy(group_name, proxy_name)
.await)
.is_ok()
{
log::info!(target: "app", "代理切换回退成功: {} -> {}", group_name, proxy_name);
if let Some(app_handle) = handle::Handle::global().app_handle() {
let _ = app_handle.emit("verge://force-refresh-proxies", ());
}
}
}
}
}
}
_ => {}
}

View File

@@ -2,11 +2,15 @@ use std::time::Duration;
use kode_bridge::{
errors::{AnyError, AnyResult},
pool::PoolConfig,
ClientConfig, IpcHttpClient, LegacyResponse,
};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use crate::{
logging, singleton_with_logging,
utils::{dirs::ipc_path, logging::Type},
};
// 定义用于URL路径的编码集合只编码真正必要的字符
const URL_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ') // 空格
@@ -16,51 +20,34 @@ const URL_PATH_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b'&') // 和号
.add(b'%'); // 百分号
use crate::{logging, singleton_with_logging, utils::dirs::ipc_path};
// Helper function to create AnyError from string
fn create_error(msg: impl Into<String>) -> AnyError {
Box::new(std::io::Error::other(msg.into()))
}
pub struct IpcManager {
ipc_path: String,
config: ClientConfig,
client: IpcHttpClient,
}
impl IpcManager {
fn new() -> Self {
let ipc_path_buf = ipc_path().unwrap_or_else(|e| {
logging!(
error,
crate::utils::logging::Type::Ipc,
true,
"Failed to get IPC path: {}",
e
);
logging!(error, Type::Ipc, true, "Failed to get IPC path: {}", e);
std::path::PathBuf::from("/tmp/clash-verge-ipc") // fallback path
});
let ipc_path = ipc_path_buf.to_str().unwrap_or_default();
Self {
ipc_path: ipc_path.to_string(),
config: ClientConfig {
default_timeout: Duration::from_secs(5),
enable_pooling: true,
max_retries: 3,
max_concurrent_requests: 32,
max_requests_per_second: Some(5.0),
pool_config: PoolConfig {
max_size: 32,
min_idle: 2,
max_idle_time_ms: 10_000,
max_retries: 3,
max_concurrent_requests: 32,
max_requests_per_second: Some(5.0),
..Default::default()
},
..Default::default()
},
}
let config = ClientConfig {
default_timeout: Duration::from_secs(5),
enable_pooling: false,
max_retries: 4,
retry_delay: Duration::from_millis(125),
max_concurrent_requests: 16,
max_requests_per_second: Some(64.0),
..Default::default()
};
#[allow(clippy::unwrap_used)]
let client = IpcHttpClient::with_config(ipc_path, config).unwrap();
Self { client }
}
}
@@ -74,8 +61,7 @@ impl IpcManager {
path: &str,
body: Option<&serde_json::Value>,
) -> AnyResult<LegacyResponse> {
let client = IpcHttpClient::with_config(&self.ipc_path, self.config.clone())?;
client.request(method, path, body).await
self.client.request(method, path, body).await
}
}

View File

@@ -20,7 +20,6 @@ use crate::{
utils::{resolve, server},
};
use config::Config;
use parking_lot::Mutex;
use tauri::AppHandle;
#[cfg(target_os = "macos")]
use tauri::Manager;
@@ -36,7 +35,7 @@ mod app_init {
/// Initialize singleton monitoring for other instances
pub fn init_singleton_check() {
AsyncHandler::spawn(move || async move {
AsyncHandler::spawn_blocking(move || async move {
logging!(info, Type::Setup, true, "开始检查单例实例...");
match timeout(Duration::from_millis(500), server::check_singleton()).await {
Ok(result) => {
@@ -81,8 +80,7 @@ mod app_init {
{
builder = builder.plugin(tauri_plugin_devtools::init());
}
builder.manage(Mutex::new(state::lightweight::LightWeightState::default()))
builder
}
/// Setup deep link handling
@@ -96,7 +94,7 @@ mod app_init {
app.deep_link().on_open_url(|event| {
let url = event.urls().first().map(|u| u.to_string());
if let Some(url) = url {
tokio::task::spawn_local(async move {
AsyncHandler::spawn(|| async {
if let Err(e) = resolve::resolve_scheme(url).await {
logging!(error, Type::Setup, true, "Failed to resolve scheme: {}", e);
}
@@ -185,6 +183,8 @@ mod app_init {
cmd::get_proxies,
cmd::force_refresh_proxies,
cmd::get_providers_proxies,
cmd::sync_tray_proxy_selection,
cmd::update_proxy_and_sync,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::check_dns_config_exists,
@@ -280,17 +280,16 @@ pub fn run() {
let desktop_env = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_uppercase();
let session_env = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
let is_kde_desktop = desktop_env.contains("KDE");
let is_wayland_session = session_env.contains("wayland");
let is_plasma_desktop = desktop_env.contains("PLASMA");
if is_kde_desktop && is_wayland_session {
std::env::set_var("GDK_BACKEND", "x11");
if is_kde_desktop || is_plasma_desktop {
std::env::set_var("GTK_CSD", "0");
logging!(
info,
Type::Setup,
true,
"KDE Wayland detected: Switched to X11 backend for better titlebar stability."
"KDE detected: Disabled GTK CSD for better titlebar stability."
);
}
}
@@ -331,9 +330,9 @@ pub fn run() {
logging!(info, Type::Setup, true, "执行主要设置操作...");
logging!(info, Type::Setup, true, "异步执行应用设置...");
resolve::resolve_setup_sync(app_handle);
resolve::resolve_setup_handle(app_handle);
resolve::resolve_setup_async();
resolve::resolve_setup_sync();
logging!(info, Type::Setup, true, "初始化完成,继续执行");
Ok(())

View File

@@ -3,16 +3,14 @@ use crate::{
core::{handle, timer::Timer, tray::Tray},
log_err, logging,
process::AsyncHandler,
state::lightweight::LightWeightState,
utils::logging::Type,
state::proxy::ProxyRequestCache,
utils::{logging::Type, window_manager::WindowManager},
};
#[cfg(target_os = "macos")]
use crate::logging_error;
use anyhow::{Context, Result};
use delay_timer::prelude::TaskBuilder;
use parking_lot::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{Listener, Manager};
@@ -21,23 +19,20 @@ const LIGHT_WEIGHT_TASK_UID: &str = "light_weight_task";
// 添加退出轻量模式的锁,防止并发调用
static EXITING_LIGHTWEIGHT: AtomicBool = AtomicBool::new(false);
fn with_lightweight_status<F, R>(f: F) -> Option<R>
where
F: FnOnce(&mut LightWeightState) -> R,
{
if let Some(app_handle) = handle::Handle::global().app_handle() {
// Try to get state, but don't panic if it's not managed yet
if let Some(state) = app_handle.try_state::<Mutex<LightWeightState>>() {
let mut guard = state.lock();
Some(f(&mut guard))
} else {
// State not managed yet, return None
None
}
static IS_IN_LIGHTWEIGHT: AtomicBool = AtomicBool::new(false);
fn inner_set_lightweight_mode(value: bool) -> bool {
if value {
logging!(info, Type::Lightweight, true, "轻量模式已开启");
} else {
// App handle not available yet
None
logging!(info, Type::Lightweight, true, "轻量模式已关闭");
}
IS_IN_LIGHTWEIGHT.store(value, Ordering::SeqCst);
value
}
fn inner_get_lightweight_mode() -> bool {
IS_IN_LIGHTWEIGHT.load(Ordering::SeqCst) || !WindowManager::is_main_window_exists()
}
pub async fn run_once_auto_lightweight() {
@@ -68,55 +63,33 @@ pub async fn run_once_auto_lightweight() {
"在静默启动的情况下,创建窗口再添加自动进入轻量模式窗口监听器"
);
if with_lightweight_status(|_| ()).is_some() {
set_lightweight_mode(false).await;
enable_auto_light_weight_mode().await;
enable_auto_light_weight_mode().await;
if let Err(e) = Tray::global().update_part().await {
log::warn!("Failed to update tray: {e}");
}
if let Err(e) = Tray::global().update_part().await {
log::warn!("Failed to update tray: {e}");
}
}
pub async fn auto_lightweight_mode_init() -> Result<()> {
if let Some(app_handle) = handle::Handle::global().app_handle() {
// Check if state is available before accessing it
if app_handle.try_state::<Mutex<LightWeightState>>().is_none() {
logging!(
warn,
Type::Lightweight,
true,
"LightWeightState 尚未初始化,跳过自动轻量模式初始化"
);
return Err(anyhow::anyhow!("LightWeightState has not been initialized"));
}
let is_silent_start =
{ Config::verge().await.latest_ref().enable_silent_start }.unwrap_or(false);
let enable_auto = {
Config::verge()
.await
.latest_ref()
.enable_auto_light_weight_mode
}
.unwrap_or(false);
let is_silent_start =
{ Config::verge().await.latest_ref().enable_silent_start }.unwrap_or(false);
let enable_auto = {
Config::verge()
.await
.latest_ref()
.enable_auto_light_weight_mode
}
.unwrap_or(false);
if enable_auto && !is_silent_start {
logging!(
info,
Type::Lightweight,
true,
"非静默启动直接挂载自动进入轻量模式监听器!"
);
set_lightweight_mode(true).await;
enable_auto_light_weight_mode().await;
// 确保托盘状态更新
if let Err(e) = Tray::global().update_part().await {
log::warn!("Failed to update tray: {e}");
return Err(e);
}
}
if enable_auto && !is_silent_start {
logging!(
info,
Type::Lightweight,
true,
"非静默启动直接挂载自动进入轻量模式监听器!"
);
set_lightweight_mode(true).await;
enable_auto_light_weight_mode().await;
}
Ok(())
@@ -124,16 +97,13 @@ pub async fn auto_lightweight_mode_init() -> Result<()> {
// 检查是否处于轻量模式
pub fn is_in_lightweight_mode() -> bool {
with_lightweight_status(|state| state.is_lightweight).unwrap_or(false)
IS_IN_LIGHTWEIGHT.load(Ordering::SeqCst)
}
// 设置轻量模式状态
pub async fn set_lightweight_mode(value: bool) {
if with_lightweight_status(|state| {
state.set_lightweight_mode(value);
})
.is_some()
{
if inner_get_lightweight_mode() != value {
inner_set_lightweight_mode(value);
// 只有在状态可用时才触发托盘更新
if let Err(e) = Tray::global().update_part().await {
log::warn!("Failed to update tray: {e}");
@@ -178,9 +148,10 @@ pub async fn entry_lightweight_mode() {
}
set_lightweight_mode(true).await;
let _ = cancel_light_weight_timer();
ProxyRequestCache::global().clean_default_keys();
// 更新托盘显示
let _tray = crate::core::tray::Tray::global();
logging_error!(Type::Lightweight, true, Tray::global().update_part().await);
}
// 添加从轻量模式恢复的函数

View File

@@ -34,6 +34,7 @@ impl AsyncHandler {
async_runtime::spawn_blocking(f)
}
#[allow(dead_code)]
#[track_caller]
pub fn block_on<Fut>(fut: Fut) -> Fut::Output
where

View File

@@ -1,36 +0,0 @@
use std::sync::{Arc, Once, OnceLock};
use crate::{logging, utils::logging::Type};
#[derive(Clone)]
pub struct LightWeightState {
#[allow(unused)]
once: Arc<Once>,
pub is_lightweight: bool,
}
impl LightWeightState {
pub fn new() -> Self {
Self {
once: Arc::new(Once::new()),
is_lightweight: false,
}
}
pub fn set_lightweight_mode(&mut self, value: bool) -> &Self {
self.is_lightweight = value;
if value {
logging!(info, Type::Lightweight, true, "轻量模式已开启");
} else {
logging!(info, Type::Lightweight, true, "轻量模式已关闭");
}
self
}
}
impl Default for LightWeightState {
fn default() -> Self {
static INSTANCE: OnceLock<LightWeightState> = OnceLock::new();
INSTANCE.get_or_init(LightWeightState::new).clone()
}
}

View File

@@ -1,5 +1,4 @@
// Tauri Manager 会进行 Arc 管理,无需额外 Arc
// https://tauri.app/develop/state-management/#do-you-need-arc
pub mod lightweight;
pub mod proxy;

View File

@@ -1,3 +1,5 @@
// use crate::utils::logging::Type;
// use crate::{logging, singleton};
use crate::singleton;
use dashmap::DashMap;
use serde_json::Value;
@@ -11,7 +13,7 @@ pub struct CacheEntry {
}
pub struct ProxyRequestCache {
pub map: DashMap<String, Arc<OnceCell<CacheEntry>>>,
pub map: DashMap<String, Arc<OnceCell<Box<CacheEntry>>>>,
}
impl ProxyRequestCache {
@@ -54,10 +56,10 @@ impl ProxyRequestCache {
// Try to set a new value
let value = fetch_fn().await;
let entry = CacheEntry {
let entry = Box::new(CacheEntry {
value: Arc::new(value),
expires_at: Instant::now() + ttl,
};
});
match cell.set(entry) {
Ok(_) => {
@@ -78,6 +80,22 @@ impl ProxyRequestCache {
}
}
}
// TODO
pub fn clean_default_keys(&self) {
// logging!(info, Type::Cache, "Cleaning proxies keys");
// let proxies_key = Self::make_key("proxies", "default");
// self.map.remove(&proxies_key);
// logging!(info, Type::Cache, "Cleaning providers keys");
// let providers_key = Self::make_key("providers", "default");
// self.map.remove(&providers_key);
// !The frontend goes crash if we clean the clash_config cache
// logging!(info, Type::Cache, "Cleaning clash config keys");
// let clash_config_key = Self::make_key("clash_config", "default");
// self.map.remove(&clash_config_key);
}
}
// Use singleton macro

View File

@@ -405,14 +405,6 @@ pub async fn init_resources() -> Result<()> {
for file in file_list.iter() {
let src_path = res_dir.join(file);
let dest_path = app_dir.join(file);
logging!(
debug,
Type::Setup,
true,
"src_path: {:?}, dest_path: {:?}",
src_path,
dest_path
);
let handle_copy = |src: PathBuf, dest: PathBuf, file: String| async move {
match fs::copy(&src, &dest).await {
@@ -445,14 +437,6 @@ pub async fn init_resources() -> Result<()> {
(Ok(src_modified), Ok(dest_modified)) => {
if src_modified > dest_modified {
handle_copy(src_path.clone(), dest_path.clone(), file.to_string()).await;
} else {
logging!(
debug,
Type::Setup,
true,
"skipping resource copy '{}'",
file
);
}
}
_ => {

View File

@@ -18,6 +18,7 @@ pub enum Type {
Network,
ProxyMode,
Ipc,
// Cache,
}
impl fmt::Display for Type {
@@ -39,6 +40,7 @@ impl fmt::Display for Type {
Type::Network => write!(f, "[Network]"),
Type::ProxyMode => write!(f, "[ProxMode]"),
Type::Ipc => write!(f, "[IPC]"),
// Type::Cache => write!(f, "[Cache]"),
}
}
}

View File

@@ -1,13 +1,17 @@
use anyhow::Result;
use isahc::http::{
header::{HeaderMap, HeaderValue, USER_AGENT},
StatusCode, Uri,
};
use base64::{engine::general_purpose, Engine as _};
use isahc::prelude::*;
use isahc::{
config::RedirectPolicy,
http::{
header::{HeaderMap, HeaderValue, USER_AGENT},
StatusCode, Uri,
},
};
use isahc::{config::SslOption, HttpClient};
use std::sync::Once;
use std::time::{Duration, Instant};
use sysproxy::Sysproxy;
use tauri::Url;
use tokio::sync::Mutex;
use tokio::time::timeout;
@@ -53,7 +57,6 @@ pub struct NetworkManager {
self_proxy_client: Mutex<Option<HttpClient>>,
system_proxy_client: Mutex<Option<HttpClient>>,
no_proxy_client: Mutex<Option<HttpClient>>,
init: Once,
last_connection_error: Mutex<Option<(Instant, String)>>,
connection_error_count: Mutex<usize>,
}
@@ -64,16 +67,11 @@ impl NetworkManager {
self_proxy_client: Mutex::new(None),
system_proxy_client: Mutex::new(None),
no_proxy_client: Mutex::new(None),
init: Once::new(),
last_connection_error: Mutex::new(None),
connection_error_count: Mutex::new(0),
}
}
pub fn init(&self) {
self.init.call_once(|| {});
}
async fn record_connection_error(&self, error: &str) {
let mut last_error = self.last_connection_error.lock().await;
*last_error = Some((Instant::now(), error.to_string()));
@@ -135,6 +133,8 @@ impl NetworkManager {
builder = builder.timeout(Duration::from_secs(secs));
}
builder = builder.redirect_policy(RedirectPolicy::Follow);
Ok(builder.build()?)
};
@@ -197,15 +197,40 @@ impl NetworkManager {
self.reset_clients().await;
}
let parsed = Url::parse(url)?;
let mut extra_headers = HeaderMap::new();
if !parsed.username().is_empty() {
if let Some(pass) = parsed.password() {
let auth_str = format!("{}:{}", parsed.username(), pass);
let encoded = general_purpose::STANDARD.encode(auth_str);
extra_headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Basic {}", encoded))?,
);
}
}
let clean_url = {
let mut no_auth = parsed.clone();
no_auth.set_username("").ok();
no_auth.set_password(None).ok();
no_auth.to_string()
};
let client = self
.create_request(proxy_type, timeout_secs, user_agent, accept_invalid_certs)
.await?;
let timeout_duration = Duration::from_secs(timeout_secs.unwrap_or(20));
let url_owned = url.to_string();
let response = match timeout(timeout_duration, async {
let mut response = client.get_async(&url_owned).await?;
let mut req = isahc::Request::get(&clean_url);
for (k, v) in extra_headers.iter() {
req = req.header(k, v);
}
let mut response = client.send_async(req.body(())?).await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await?;

View File

@@ -7,7 +7,7 @@ use crate::{
logging, logging_error,
module::lightweight::auto_lightweight_mode_init,
process::AsyncHandler,
utils::{init, logging::Type, network::NetworkManager, resolve::window::create_window, server},
utils::{init, logging::Type, resolve::window::create_window, server},
};
pub mod dns;
@@ -16,11 +16,15 @@ pub mod ui;
pub mod window;
pub mod window_script;
pub fn resolve_setup_sync(app_handle: AppHandle) {
pub fn resolve_setup_handle(app_handle: AppHandle) {
init_handle(app_handle);
init_scheme();
init_embed_server();
NetworkManager::new().init();
}
pub fn resolve_setup_sync() {
AsyncHandler::spawn(|| async {
AsyncHandler::spawn_blocking(init_scheme);
AsyncHandler::spawn_blocking(init_embed_server);
});
}
pub fn resolve_setup_async() {
@@ -33,28 +37,30 @@ pub fn resolve_setup_async() {
std::thread::current().id()
);
// AsyncHandler::spawn_blocking(|| AsyncHandler::block_on(init_work_config()));
AsyncHandler::spawn(|| async {
init_work_config().await;
init_resources().await;
init_startup_script().await;
futures::join!(
init_work_config(),
init_resources(),
init_startup_script(),
init_hotkey(),
);
init_timer().await;
init_hotkey().await;
init_auto_lightweight_mode().await;
init_verge_config().await;
init_core_manager().await;
init_system_proxy().await;
init_system_proxy().await;
AsyncHandler::spawn_blocking(|| {
init_system_proxy_guard();
});
init_window().await;
init_tray().await;
refresh_tray_menu().await
let tray_and_refresh = async {
init_tray().await;
refresh_tray_menu().await;
};
futures::join!(init_window(), tray_and_refresh,);
});
let elapsed = start_time.elapsed();

View File

@@ -44,7 +44,7 @@ pub async fn check_singleton() -> Result<()> {
pub fn embed_server() {
let port = IVerge::get_singleton_port();
AsyncHandler::spawn_blocking(move || async move {
AsyncHandler::spawn(move || async move {
let visible = warp::path!("commands" / "visible").and_then(|| async {
Ok::<_, warp::Rejection>(warp::reply::with_status(
"ok".to_string(),

View File

@@ -338,6 +338,11 @@ impl WindowManager {
}
}
/// 检查窗口是否存在
pub fn is_main_window_exists() -> bool {
Self::get_main_window().is_some()
}
/// 检查窗口是否可见
pub fn is_main_window_visible() -> bool {
Self::get_main_window()

View File

@@ -1,5 +1,5 @@
{
"version": "2.4.1",
"version": "2.4.2",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,

View File

@@ -29,10 +29,9 @@ import {
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { EnhancedCard } from "@/components/home/enhanced-card";
import { updateProxy, deleteConnection } from "@/services/cmds";
import delayManager from "@/services/delay";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
import { useProxySelection } from "@/hooks/use-proxy-selection";
// 本地存储的键名
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
@@ -94,8 +93,18 @@ export const CurrentProxyCard = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const { verge } = useVerge();
const { proxies, connections, clashConfig, refreshProxy } = useAppData();
const { proxies, clashConfig, refreshProxy } = useAppData();
// 统一代理选择器
const { handleSelectChange } = useProxySelection({
onSuccess: () => {
refreshProxy();
},
onError: (error) => {
console.error("代理切换失败", error);
refreshProxy();
},
});
// 判断模式
const mode = clashConfig?.mode?.toLowerCase() || "rule";
@@ -113,8 +122,6 @@ export const CurrentProxyCard = () => {
proxyData: {
groups: { name: string; now: string; all: string[] }[];
records: Record<string, any>;
globalProxy: string;
directProxy: any;
};
selection: {
group: string;
@@ -127,8 +134,6 @@ export const CurrentProxyCard = () => {
proxyData: {
groups: [],
records: {},
globalProxy: "",
directProxy: { name: "DIRECT" }, // 默认值避免 undefined
},
selection: {
group: "",
@@ -253,8 +258,6 @@ export const CurrentProxyCard = () => {
proxyData: {
groups: filteredGroups,
records: proxies.records || {},
globalProxy: proxies.global?.now || "",
directProxy: proxies.records?.DIRECT || { name: "DIRECT" },
},
selection: {
group: newGroup,
@@ -310,7 +313,7 @@ export const CurrentProxyCard = () => {
// 处理代理节点变更
const handleProxyChange = useCallback(
async (event: SelectChangeEvent) => {
(event: SelectChangeEvent) => {
if (isDirectMode) return;
const newProxy = event.target.value;
@@ -330,35 +333,15 @@ export const CurrentProxyCard = () => {
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
}
try {
await updateProxy(currentGroup, newProxy);
// 自动关闭连接设置
if (verge?.auto_close_connection && previousProxy) {
connections.data.forEach((conn: any) => {
if (conn.chains.includes(previousProxy)) {
deleteConnection(conn.id);
}
});
}
// 延长刷新延迟时间
setTimeout(() => {
refreshProxy();
}, 500);
} catch (error) {
console.error("更新代理失败", error);
}
const skipConfigSave = isGlobalMode || isDirectMode;
handleSelectChange(currentGroup, previousProxy, skipConfigSave)(event);
},
[
isDirectMode,
isGlobalMode,
state.proxyData.records,
state.selection,
verge?.auto_close_connection,
refreshProxy,
debouncedSetState,
connections.data,
handleSelectChange,
],
);

View File

@@ -1,16 +1,9 @@
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useLockFn } from "ahooks";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import {
getConnections,
providerHealthCheck,
updateProxy,
deleteConnection,
getGroupProxyDelays,
} from "@/services/cmds";
import { forceRefreshProxies } from "@/services/cmds";
import { useProfiles } from "@/hooks/use-profiles";
import { providerHealthCheck, getGroupProxyDelays } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { useProxySelection } from "@/hooks/use-proxy-selection";
import { BaseEmpty } from "../base";
import { useRenderList } from "./use-render-list";
import { ProxyRender } from "./proxy-render";
@@ -203,7 +196,17 @@ export const ProxyGroups = (props: Props) => {
const { renderList, onProxies, onHeadState } = useRenderList(mode);
const { verge } = useVerge();
const { current, patchCurrent } = useProfiles();
// 统代理选择
const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => {
onProxies();
},
onError: (error) => {
console.error("代理切换失败", error);
onProxies();
},
});
// 获取自动滚动开关状态,默认为 true
const enableAutoScroll = verge?.enable_hover_jump_navigator ?? true;
@@ -335,44 +338,13 @@ export const ProxyGroups = (props: Props) => {
[letterIndexMap],
);
// 切换分组的节点代理
const handleChangeProxy = useLockFn(
async (group: IProxyGroupItem, proxy: IProxyItem) => {
const handleChangeProxy = useCallback(
(group: IProxyGroupItem, proxy: IProxyItem) => {
if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return;
const { name, now } = group;
await updateProxy(name, proxy.name);
await forceRefreshProxies();
onProxies();
// 断开连接
if (verge?.auto_close_connection) {
getConnections().then(({ connections }) => {
connections.forEach((conn) => {
if (conn.chains.includes(now!)) {
deleteConnection(conn.id);
}
});
});
}
// 保存到selected中
if (!current) return;
if (!current.selected) current.selected = [];
const index = current.selected.findIndex(
(item) => item.name === group.name,
);
if (index < 0) {
current.selected.push({ name, now: proxy.name });
} else {
current.selected[index] = { name, now: proxy.name };
}
await patchCurrent({ selected: current.selected });
handleProxyGroupChange(group, proxy);
},
[handleProxyGroupChange],
);
// 测全部延迟

View File

@@ -19,7 +19,7 @@ import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { ContentCopyRounded } from "@mui/icons-material";
import { languages } from "@/services/i18n";
import { supportedLanguages } from "@/services/i18n";
import { showNotice } from "@/services/noticeService";
interface Props {
@@ -28,7 +28,7 @@ interface Props {
const OS = getSystem();
const languageOptions = Object.entries(languages).map(([code, _]) => {
const languageOptions = supportedLanguages.map((code) => {
const labels: { [key: string]: string } = {
en: "English",
ru: "Русский",
@@ -39,8 +39,13 @@ const languageOptions = Object.entries(languages).map(([code, _]) => {
ar: "العربية",
ko: "한국어",
tr: "Türkçe",
de: "Deutsch",
es: "Español",
jp: "日本語",
zhtw: "繁體中文",
};
return { code, label: labels[code] };
const label = labels[code] || code;
return { code, label };
});
const SettingVergeBasic = ({ onError }: Props) => {

View File

@@ -8,15 +8,9 @@ import {
DeleteForeverRounded,
WarningRounded,
} from "@mui/icons-material";
import {
Box,
Button,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import { Box, Typography, alpha, useTheme } from "@mui/material";
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { GuardState } from "@/components/setting/mods/guard-state";
import { SysproxyViewer } from "@/components/setting/mods/sysproxy-viewer";
import { TunViewer } from "@/components/setting/mods/tun-viewer";
@@ -100,19 +94,6 @@ const ProxyControlSwitches = ({
return (
<Box sx={{ width: "100%" }}>
{label && (
<Box
sx={{
fontSize: "15px",
fontWeight: "500",
mb: 0.5,
display: "none",
}}
>
{label}
</Box>
)}
{/* 仅显示当前选中的开关 */}
{isSystemProxyMode && (
<Box
@@ -131,50 +112,36 @@ const ProxyControlSwitches = ({
>
<Box sx={{ display: "flex", alignItems: "center" }}>
{systemProxyActualState ? (
<PlayCircleOutlineRounded
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
/>
<PlayCircleOutlineRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseCircleOutlineRounded
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
sx={{ color: "text.disabled", mr: 1 }}
/>
)}
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 500, fontSize: "15px" }}
>
{t("System Proxy")}
</Typography>
</Box>
</Box>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title={t("System Proxy Info")} arrow>
<Box
sx={{
mr: 1,
color: "text.secondary",
"&:hover": { color: "primary.main" },
cursor: "pointer",
}}
onClick={() => sysproxyRef.current?.open()}
>
<SettingsRounded fontSize="small" />
</Box>
</Tooltip>
<GuardState
value={systemProxyActualState}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => toggleSystemProxy(e)}
<Typography
variant="subtitle1"
sx={{ fontWeight: 500, fontSize: "15px" }}
>
<Switch edge="end" />
</GuardState>
{t("System Proxy")}
</Typography>
<TooltipIcon
title={t("System Proxy Info")}
icon={SettingsRounded}
onClick={() => sysproxyRef.current?.open()}
sx={{ ml: 1 }}
/>
</Box>
<GuardState
value={systemProxyActualState}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => toggleSystemProxy(e)}
>
<Switch edge="end" />
</GuardState>
</Box>
)}
@@ -196,109 +163,87 @@ const ProxyControlSwitches = ({
>
<Box sx={{ display: "flex", alignItems: "center" }}>
{enable_tun_mode ? (
<PlayCircleOutlineRounded
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
/>
<PlayCircleOutlineRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseCircleOutlineRounded
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
sx={{ color: "text.disabled", mr: 1 }}
/>
)}
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 500, fontSize: "15px" }}
>
{t("Tun Mode")}
</Typography>
</Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 500, fontSize: "15px" }}
>
{t("Tun Mode")}
</Typography>
<TooltipIcon
title={t("Tun Mode Info")}
icon={SettingsRounded}
onClick={() => tunRef.current?.open()}
sx={{ ml: 1 }}
/>
{!isTunAvailable && (
<Tooltip
<TooltipIcon
title={t("TUN requires Service Mode or Admin Mode")}
arrow
>
<WarningRounded sx={{ color: "warning.main", ml: 1 }} />
</Tooltip>
icon={WarningRounded}
sx={{ color: "warning.main", ml: 1 }}
/>
)}
</Box>
<Box sx={{ display: "flex", alignItems: "center" }}>
{!isTunAvailable && (
<Tooltip title={t("Install Service")} arrow>
<Button
variant="outlined"
color="primary"
size="small"
onClick={onInstallService}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<BuildRounded fontSize="small" />
</Button>
</Tooltip>
<TooltipIcon
title={t("Install Service")}
icon={BuildRounded}
color="primary"
onClick={onInstallService}
sx={{ ml: 1 }}
/>
)}
{isServiceMode && (
<Tooltip title={t("Uninstall Service")} arrow>
<Button
color="secondary"
size="small"
onClick={onUninstallService}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<DeleteForeverRounded fontSize="small" />
</Button>
</Tooltip>
<TooltipIcon
title={t("Uninstall Service")}
icon={DeleteForeverRounded}
color="secondary"
onClick={onUninstallService}
sx={{ ml: 1 }}
/>
)}
<Tooltip title={t("Tun Mode Info")} arrow>
<Box
sx={{
mr: 1,
color: "text.secondary",
"&:hover": { color: "primary.main" },
cursor: "pointer",
}}
onClick={() => tunRef.current?.open()}
>
<SettingsRounded fontSize="small" />
</Box>
</Tooltip>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
if (!isTunAvailable) {
showNotice(
"error",
t("TUN requires Service Mode or Admin Mode"),
);
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
if (!isTunAvailable) {
showNotice(
"error",
t("TUN requires Service Mode or Admin Mode"),
);
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
return patchVerge({ enable_tun_mode: e });
}}
>
<Switch edge="end" disabled={!isTunAvailable} />
</GuardState>
</Box>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
if (!isTunAvailable) {
showNotice(
"error",
t("TUN requires Service Mode or Admin Mode"),
);
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
if (!isTunAvailable) {
showNotice(
"error",
t("TUN requires Service Mode or Admin Mode"),
);
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
return patchVerge({ enable_tun_mode: e });
}}
>
<Switch edge="end" disabled={!isTunAvailable} />
</GuardState>
</Box>
)}

45
src/hooks/use-i18n.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { changeLanguage, supportedLanguages } from "@/services/i18n";
import { useVerge } from "./use-verge";
export const useI18n = () => {
const { i18n, t } = useTranslation();
const { patchVerge } = useVerge();
const [isLoading, setIsLoading] = useState(false);
const switchLanguage = useCallback(
async (language: string) => {
if (!supportedLanguages.includes(language)) {
console.warn(`Unsupported language: ${language}`);
return;
}
if (i18n.language === language) {
return;
}
setIsLoading(true);
try {
await changeLanguage(language);
if (patchVerge) {
await patchVerge({ language });
}
} catch (error) {
console.error("Failed to change language:", error);
} finally {
setIsLoading(false);
}
},
[i18n.language, patchVerge],
);
return {
currentLanguage: i18n.language,
supportedLanguages,
switchLanguage,
isLoading,
t,
};
};

View File

@@ -0,0 +1,143 @@
import { useCallback, useMemo } from "react";
import { useLockFn } from "ahooks";
import {
updateProxy,
updateProxyAndSync,
forceRefreshProxies,
syncTrayProxySelection,
getConnections,
deleteConnection,
} from "@/services/cmds";
import { useProfiles } from "@/hooks/use-profiles";
import { useVerge } from "@/hooks/use-verge";
// 缓存连接清理
const cleanupConnections = async (previousProxy: string) => {
try {
const { connections } = await getConnections();
const cleanupPromises = connections
.filter((conn) => conn.chains.includes(previousProxy))
.map((conn) => deleteConnection(conn.id));
if (cleanupPromises.length > 0) {
await Promise.allSettled(cleanupPromises);
console.log(`[ProxySelection] 清理了 ${cleanupPromises.length} 个连接`);
}
} catch (error) {
console.warn("[ProxySelection] 连接清理失败:", error);
}
};
export interface ProxySelectionOptions {
onSuccess?: () => void;
onError?: (error: any) => void;
enableConnectionCleanup?: boolean;
}
// 代理选择 Hook
export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const { current, patchCurrent } = useProfiles();
const { verge } = useVerge();
const { onSuccess, onError, enableConnectionCleanup = true } = options;
// 缓存
const config = useMemo(
() => ({
autoCloseConnection: verge?.auto_close_connection ?? false,
enableConnectionCleanup,
}),
[verge?.auto_close_connection, enableConnectionCleanup],
);
// 切换节点
const changeProxy = useLockFn(
async (
groupName: string,
proxyName: string,
previousProxy?: string,
skipConfigSave: boolean = false,
) => {
console.log(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`);
try {
if (current && !skipConfigSave) {
if (!current.selected) current.selected = [];
const index = current.selected.findIndex(
(item) => item.name === groupName,
);
if (index < 0) {
current.selected.push({ name: groupName, now: proxyName });
} else {
current.selected[index] = { name: groupName, now: proxyName };
}
await patchCurrent({ selected: current.selected });
}
await updateProxyAndSync(groupName, proxyName);
console.log(
`[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`,
);
onSuccess?.();
if (
config.enableConnectionCleanup &&
config.autoCloseConnection &&
previousProxy
) {
setTimeout(() => cleanupConnections(previousProxy), 0);
}
} catch (error) {
console.error(
`[ProxySelection] 代理切换失败: ${groupName} -> ${proxyName}`,
error,
);
try {
await updateProxy(groupName, proxyName);
await forceRefreshProxies();
await syncTrayProxySelection();
onSuccess?.();
console.log(
`[ProxySelection] 代理切换回退成功: ${groupName} -> ${proxyName}`,
);
} catch (fallbackError) {
console.error(
`[ProxySelection] 代理切换回退也失败: ${groupName} -> ${proxyName}`,
fallbackError,
);
onError?.(fallbackError);
}
}
},
);
const handleSelectChange = useCallback(
(
groupName: string,
previousProxy?: string,
skipConfigSave: boolean = false,
) =>
(event: { target: { value: string } }) => {
const newProxy = event.target.value;
changeProxy(groupName, newProxy, previousProxy, skipConfigSave);
},
[changeProxy],
);
const handleProxyGroupChange = useCallback(
(group: { name: string; now?: string }, proxy: { name: string }) => {
changeProxy(group.name, proxy.name, group.now);
},
[changeProxy],
);
return {
changeProxy,
handleSelectChange,
handleProxyGroupChange,
};
};

View File

@@ -13,7 +13,7 @@ import { ComposeContextProvider } from "foxact/compose-context-provider";
import { BrowserRouter } from "react-router-dom";
import { BaseErrorBoundary } from "./components/base";
import Layout from "./pages/_layout";
import "./services/i18n";
import { initializeLanguage } from "./services/i18n";
import {
LoadingCacheProvider,
ThemeModeProvider,
@@ -39,29 +39,47 @@ document.addEventListener("keydown", (event) => {
["F", "G", "H", "J", "P", "Q", "R", "U"].includes(
event.key.toUpperCase(),
));
disabledShortcuts && event.preventDefault();
if (disabledShortcuts) {
event.preventDefault();
}
});
const contexts = [
<ThemeModeProvider />,
<LoadingCacheProvider />,
<UpdateStateProvider />,
];
const initializeApp = async () => {
try {
await initializeLanguage("zh");
const root = createRoot(container);
root.render(
<React.StrictMode>
<ComposeContextProvider contexts={contexts}>
<BaseErrorBoundary>
<AppDataProvider>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AppDataProvider>
</BaseErrorBoundary>
</ComposeContextProvider>
</React.StrictMode>,
);
const contexts = [
<ThemeModeProvider key="theme" />,
<LoadingCacheProvider key="loading" />,
<UpdateStateProvider key="update" />,
];
const root = createRoot(container);
root.render(
<React.StrictMode>
<ComposeContextProvider contexts={contexts}>
<BaseErrorBoundary>
<AppDataProvider>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AppDataProvider>
</BaseErrorBoundary>
</ComposeContextProvider>
</React.StrictMode>,
);
} catch (error) {
console.error("[main.tsx] 应用初始化失败:", error);
const root = createRoot(container);
root.render(
<div style={{ padding: "20px", color: "red" }}>
: {error instanceof Error ? error.message : String(error)}
</div>,
);
}
};
initializeApp();
// 错误处理
window.addEventListener("error", (event) => {

View File

@@ -1,5 +1,4 @@
import dayjs from "dayjs";
import i18next from "i18next";
import relativeTime from "dayjs/plugin/relativeTime";
import { SWRConfig, mutate } from "swr";
import { useEffect, useCallback, useState, useRef } from "react";
@@ -11,6 +10,7 @@ import { routers } from "./_routers";
import { getAxios } from "@/services/api";
import { forceRefreshClashConfig } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { useI18n } from "@/hooks/use-i18n";
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";
@@ -158,6 +158,7 @@ const Layout = () => {
const [enableLog] = useEnableLog();
const [logLevel] = useLocalStorage<LogLevel>("log:log-level", "info");
const { language, start_page } = verge ?? {};
const { switchLanguage } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const routersEles = useRoutes(routers);
@@ -439,9 +440,9 @@ const Layout = () => {
useEffect(() => {
if (language) {
dayjs.locale(language === "zh" ? "zh-cn" : language);
i18next.changeLanguage(language);
switchLanguage(language);
}
}, [language]);
}, [language, switchLanguage]);
useEffect(() => {
if (start_page) {

View File

@@ -12,6 +12,7 @@ import {
Checkbox,
Tooltip,
Grid,
Skeleton,
} from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { useProfiles } from "@/hooks/use-profiles";
@@ -26,17 +27,34 @@ import {
import { ProxyTunCard } from "@/components/home/proxy-tun-card";
import { ClashModeCard } from "@/components/home/clash-mode-card";
import { EnhancedTrafficStats } from "@/components/home/enhanced-traffic-stats";
import { useState } from "react";
import { useState, useMemo, Suspense, lazy, useCallback } from "react";
import { HomeProfileCard } from "@/components/home/home-profile-card";
import { EnhancedCard } from "@/components/home/enhanced-card";
import { CurrentProxyCard } from "@/components/home/current-proxy-card";
import { BasePage } from "@/components/base";
import { ClashInfoCard } from "@/components/home/clash-info-card";
import { SystemInfoCard } from "@/components/home/system-info-card";
import { useLockFn } from "ahooks";
import { entry_lightweight_mode, openWebUrl } from "@/services/cmds";
import { TestCard } from "@/components/home/test-card";
import { IpInfoCard } from "@/components/home/ip-info-card";
const LazyTestCard = lazy(() =>
import("@/components/home/test-card").then((module) => ({
default: module.TestCard,
})),
);
const LazyIpInfoCard = lazy(() =>
import("@/components/home/ip-info-card").then((module) => ({
default: module.IpInfoCard,
})),
);
const LazyClashInfoCard = lazy(() =>
import("@/components/home/clash-info-card").then((module) => ({
default: module.ClashInfoCard,
})),
);
const LazySystemInfoCard = lazy(() =>
import("@/components/home/system-info-card").then((module) => ({
default: module.SystemInfoCard,
})),
);
// 定义首页卡片设置接口
interface HomeCardsSettings {
@@ -190,9 +208,11 @@ export const HomePage = () => {
// 设置弹窗的状态
const [settingsOpen, setSettingsOpen] = useState(false);
// 卡片显示状态
const [homeCards, setHomeCards] = useState<HomeCardsSettings>(
(verge?.home_cards as HomeCardsSettings) || {
const defaultCards = useMemo<HomeCardsSettings>(
() => ({
info: false,
profile: true,
proxy: true,
network: true,
@@ -202,18 +222,49 @@ export const HomePage = () => {
systeminfo: true,
test: true,
ip: true,
},
}),
[],
);
const [homeCards, setHomeCards] = useState<HomeCardsSettings>(() => {
return (verge?.home_cards as HomeCardsSettings) || defaultCards;
});
// 文档链接函数
const toGithubDoc = useLockFn(() => {
return openWebUrl("https://clash-verge-rev.github.io/index.html");
});
// 新增:打开设置弹窗
const openSettings = () => {
const openSettings = useCallback(() => {
setSettingsOpen(true);
};
}, []);
const renderCard = useCallback(
(cardKey: string, component: React.ReactNode, size: number = 6) => {
if (!homeCards[cardKey]) return null;
return (
<Grid size={size} key={cardKey}>
{component}
</Grid>
);
},
[homeCards],
);
const criticalCards = useMemo(
() => [
renderCard(
"profile",
<HomeProfileCard current={current} onProfileUpdated={mutateProfiles} />,
),
renderCard("proxy", <CurrentProxyCard />),
renderCard("network", <NetworkSettingsCard />),
renderCard("mode", <ClashModeEnhancedCard />),
],
[homeCards, current, mutateProfiles, renderCard],
);
// 新增保存设置时用requestIdleCallback/setTimeout
const handleSaveSettings = (newCards: HomeCardsSettings) => {
@@ -224,6 +275,47 @@ export const HomePage = () => {
}
};
const nonCriticalCards = useMemo(
() => [
renderCard(
"traffic",
<EnhancedCard
title={t("Traffic Stats")}
icon={<SpeedOutlined />}
iconColor="secondary"
>
<EnhancedTrafficStats />
</EnhancedCard>,
12,
),
renderCard(
"test",
<Suspense fallback={<Skeleton variant="rectangular" height={200} />}>
<LazyTestCard />
</Suspense>,
),
renderCard(
"ip",
<Suspense fallback={<Skeleton variant="rectangular" height={200} />}>
<LazyIpInfoCard />
</Suspense>,
),
renderCard(
"clashinfo",
<Suspense fallback={<Skeleton variant="rectangular" height={200} />}>
<LazyClashInfoCard />
</Suspense>,
),
renderCard(
"systeminfo",
<Suspense fallback={<Skeleton variant="rectangular" height={200} />}>
<LazySystemInfoCard />
</Suspense>,
),
],
[homeCards, t, renderCard],
);
return (
<BasePage
title={t("Label-Home")}
@@ -253,71 +345,9 @@ export const HomePage = () => {
}
>
<Grid container spacing={1.5} columns={{ xs: 6, sm: 6, md: 12 }}>
{/* 订阅和当前节点部分 */}
{homeCards.profile && (
<Grid size={6}>
<HomeProfileCard
current={current}
onProfileUpdated={mutateProfiles}
/>
</Grid>
)}
{criticalCards}
{homeCards.proxy && (
<Grid size={6}>
<CurrentProxyCard />
</Grid>
)}
{/* 代理和网络设置区域 */}
{homeCards.network && (
<Grid size={6}>
<NetworkSettingsCard />
</Grid>
)}
{homeCards.mode && (
<Grid size={6}>
<ClashModeEnhancedCard />
</Grid>
)}
{/* 增强的流量统计区域 */}
{homeCards.traffic && (
<Grid size={12}>
<EnhancedCard
title={t("Traffic Stats")}
icon={<SpeedOutlined />}
iconColor="secondary"
>
<EnhancedTrafficStats />
</EnhancedCard>
</Grid>
)}
{/* 测试网站部分 */}
{homeCards.test && (
<Grid size={6}>
<TestCard />
</Grid>
)}
{/* IP信息卡片 */}
{homeCards.ip && (
<Grid size={6}>
<IpInfoCard />
</Grid>
)}
{/* Clash信息 */}
{homeCards.clashinfo && (
<Grid size={6}>
<ClashInfoCard />
</Grid>
)}
{/* 系统信息 */}
{homeCards.systeminfo && (
<Grid size={6}>
<SystemInfoCard />
</Grid>
)}
{nonCriticalCards}
</Grid>
{/* 首页设置弹窗 */}

View File

@@ -221,16 +221,105 @@ export const AppDataProvider = ({
}
};
window.addEventListener(
"verge://refresh-clash-config",
handleRefreshClash,
);
// 监听代理配置刷新事件(托盘代理切换等)
const handleRefreshProxy = () => {
const now = Date.now();
console.log("[AppDataProvider] 代理配置刷新事件");
return () => {
window.removeEventListener(
"verge://refresh-clash-config",
handleRefreshClash,
);
if (now - lastUpdateTime > refreshThrottle) {
lastUpdateTime = now;
setTimeout(() => {
refreshProxy().catch((e) =>
console.warn("[AppDataProvider] 代理刷新失败:", e),
);
}, 100);
}
};
// 监听强制代理刷新事件(托盘代理切换立即刷新)
const handleForceRefreshProxies = () => {
console.log("[AppDataProvider] 强制代理刷新事件");
// 立即刷新,无延迟,无防抖
forceRefreshProxies()
.then(() => {
console.log("[AppDataProvider] 强制刷新代理缓存完成");
// 强制刷新完成后,立即刷新前端显示
return refreshProxy();
})
.then(() => {
console.log("[AppDataProvider] 前端代理数据刷新完成");
})
.catch((e) => {
console.warn("[AppDataProvider] 强制代理刷新失败:", e);
// 如果强制刷新失败,尝试普通刷新
refreshProxy().catch((e2) =>
console.warn("[AppDataProvider] 普通代理刷新也失败:", e2),
);
});
};
// 使用 Tauri 事件监听器替代 window 事件监听器
const setupTauriListeners = async () => {
try {
const unlistenClash = await listen(
"verge://refresh-clash-config",
handleRefreshClash,
);
const unlistenProxy = await listen(
"verge://refresh-proxy-config",
handleRefreshProxy,
);
const unlistenForceRefresh = await listen(
"verge://force-refresh-proxies",
handleForceRefreshProxies,
);
return () => {
unlistenClash();
unlistenProxy();
unlistenForceRefresh();
};
} catch (error) {
console.warn("[AppDataProvider] 设置 Tauri 事件监听器失败:", error);
// 降级到 window 事件监听器
window.addEventListener(
"verge://refresh-clash-config",
handleRefreshClash,
);
window.addEventListener(
"verge://refresh-proxy-config",
handleRefreshProxy,
);
window.addEventListener(
"verge://force-refresh-proxies",
handleForceRefreshProxies,
);
return () => {
window.removeEventListener(
"verge://refresh-clash-config",
handleRefreshClash,
);
window.removeEventListener(
"verge://refresh-proxy-config",
handleRefreshProxy,
);
window.removeEventListener(
"verge://force-refresh-proxies",
handleForceRefreshProxies,
);
};
}
};
const cleanupTauriListeners = setupTauriListeners();
return async () => {
const cleanup = await cleanupTauriListeners;
cleanup();
};
} catch (error) {
console.error("[AppDataProvider] 事件监听器设置失败:", error);

View File

@@ -143,6 +143,14 @@ export async function updateProxy(group: string, proxy: string) {
// console.log(`[API] updateProxy 耗时: ${duration}ms`);
}
export async function syncTrayProxySelection() {
return invoke<void>("sync_tray_proxy_selection");
}
export async function updateProxyAndSync(group: string, proxy: string) {
return invoke<void>("update_proxy_and_sync", { group, proxy });
}
export async function getProxies(): Promise<{
global: IProxyGroupItem;
direct: IProxyItem;

View File

@@ -74,7 +74,8 @@ class DelayManager {
if (delay >= 0 || delay === -2) return delay;
}
if (proxy.history.length > 0) {
// 添加 history 属性的安全检查
if (proxy.history && proxy.history.length > 0) {
// 0ms以error显示
return proxy.history[proxy.history.length - 1].delay || 1e6;
}

View File

@@ -1,29 +1,59 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "@/locales/en.json";
import ru from "@/locales/ru.json";
import zh from "@/locales/zh.json";
import fa from "@/locales/fa.json";
import tt from "@/locales/tt.json";
import id from "@/locales/id.json";
import ar from "@/locales/ar.json";
import ko from "@/locales/ko.json";
import tr from "@/locales/tr.json";
export const languages = { en, ru, zh, fa, tt, id, ar, ko, tr };
export const supportedLanguages = [
"en",
"ru",
"zh",
"fa",
"tt",
"id",
"ar",
"ko",
"tr",
"de",
"es",
"jp",
"zhtw",
];
const resources = Object.fromEntries(
Object.entries(languages).map(([key, value]) => [
key,
{ translation: value },
]),
export const languages: Record<string, any> = supportedLanguages.reduce(
(acc, lang) => {
acc[lang] = {};
return acc;
},
{} as Record<string, any>,
);
export const loadLanguage = async (language: string) => {
try {
const module = await import(`@/locales/${language}.json`);
return module.default;
} catch (error) {
console.warn(`Failed to load language ${language}, fallback to zh`);
const fallback = await import("@/locales/zh.json");
return fallback.default;
}
};
i18n.use(initReactI18next).init({
resources,
resources: {},
lng: "zh",
fallbackLng: "zh",
interpolation: {
escapeValue: false,
},
});
export const changeLanguage = async (language: string) => {
if (!i18n.hasResourceBundle(language, "translation")) {
const resources = await loadLanguage(language);
i18n.addResourceBundle(language, "translation", resources);
}
await i18n.changeLanguage(language);
};
export const initializeLanguage = async (initialLanguage: string = "zh") => {
await changeLanguage(initialLanguage);
};

View File

@@ -17,8 +17,8 @@ export default defineConfig({
svgr(),
react(),
legacy({
targets: ["edge>=109", "safari>=13"],
renderLegacyChunks: false,
modernTargets: ["edge>=109", "safari>=13"],
modernPolyfills: true,
additionalModernPolyfills: [
"core-js/modules/es.object.has-own.js",
@@ -42,13 +42,24 @@ export default defineConfig({
build: {
outDir: "../dist",
emptyOutDir: true,
target: "es2020",
minify: "terser",
chunkSizeWarningLimit: 4000,
reportCompressedSize: false,
sourcemap: false,
cssCodeSplit: true,
cssMinify: true,
terserOptions: {
compress: {
drop_console: false,
drop_debugger: true,
pure_funcs: ["console.debug", "console.trace"],
dead_code: true,
unused: true,
},
mangle: {
safari10: true,
},
},
rollupOptions: {
treeshake: {
preset: "recommended",
@@ -57,38 +68,41 @@ export default defineConfig({
},
output: {
compact: true,
experimentalMinChunkSize: 30000,
experimentalMinChunkSize: 100000,
dynamicImportInCjs: true,
manualChunks(id) {
if (id.includes("node_modules")) {
// Monaco Editor should be a separate chunk
if (id.includes("monaco-editor")) return "monaco-editor";
// React-related libraries (react, react-dom, react-router-dom, etc.)
// React core libraries
if (
id.includes("react") ||
id.includes("react-dom") ||
id.includes("react-router-dom") ||
id.includes("react-router-dom")
) {
return "react-core";
}
// React UI libraries
if (
id.includes("react-transition-group") ||
id.includes("react-error-boundary") ||
id.includes("react-hook-form") ||
id.includes("react-markdown") ||
id.includes("react-virtuoso")
) {
return "react";
return "react-ui";
}
// Utilities chunk: group commonly used utility libraries
// Material UI libraries (grouped together)
if (
id.includes("axios") ||
id.includes("lodash-es") ||
id.includes("dayjs") ||
id.includes("js-base64") ||
id.includes("js-yaml") ||
id.includes("cli-color") ||
id.includes("nanoid")
id.includes("@mui/material") ||
id.includes("@mui/icons-material") ||
id.includes("@mui/lab") ||
id.includes("@mui/x-data-grid")
) {
return "utils";
return "mui";
}
// Tauri-related plugins: grouping together Tauri plugins
@@ -106,22 +120,35 @@ export default defineConfig({
return "tauri-plugins";
}
// Material UI libraries (grouped together)
// Utilities chunk: group commonly used utility libraries
if (
id.includes("@mui/material") ||
id.includes("@mui/icons-material") ||
id.includes("@mui/lab") ||
id.includes("@mui/x-data-grid")
id.includes("axios") ||
id.includes("lodash-es") ||
id.includes("dayjs") ||
id.includes("js-base64") ||
id.includes("js-yaml") ||
id.includes("cli-color") ||
id.includes("nanoid")
) {
return "mui";
return "utils";
}
// Small vendor packages
// Group other vendor packages together to reduce small chunks
const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
if (pkg && pkg.length < 8) return "small-vendors";
if (pkg) {
// Large packages get their own chunks
if (
pkg.includes("monaco") ||
pkg.includes("lodash") ||
pkg.includes("antd") ||
pkg.includes("emotion")
) {
return `vendor-${pkg}`;
}
// Large vendor packages
return "large-vendor";
// Group all other packages together
return "vendor";
}
}
},
},
@@ -133,14 +160,8 @@ export default defineConfig({
"@root": path.resolve("."),
},
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
},
},
},
define: {
OS_PLATFORM: `"${process.platform}"`,
OS_PLATFORM: '"unknown"',
},
});