Compare commits

...
24 Commits
Author SHA1 Message Date
Tunglies 69c75e92f4 Release 2.5.0 2026-05-19 13:07:20 +08:00
Tunglies 977783e39c refactor: simplify media unlock results 2026-05-18 23:14:27 +08:00
Tunglies d95e9ffab9 chore: bump version mihomo to 1.19.25 2026-05-18 22:03:26 +08:00
Tunglies 513b4cf6b2 chore: automate mihomo changelog sync 2026-05-18 21:52:53 +08:00
Tunglies 09e491885f fix: stop disabled system proxy guard
close #7004
2026-05-18 21:31:19 +08:00
wonfen e11b74206d feat: add warning tooltip for proxy chain 2026-05-18 07:53:29 +08:00
Tunglies ae7915c66f chore: update tauri-plugin-mihomo to 0.1.9 2026-05-17 18:13:25 +08:00
Tunglies edf5246e7f fix: mask subscription URLs in backend logs 2026-05-17 13:13:16 +08:00
Tunglies 4ade3a840f refactor: run exit cleanup on exit request 2026-05-17 00:47:25 +08:00
Tunglies 07cbecb01d fix: prevent lightweight mode exit cleanup (#6973)
close #6973
2026-05-17 00:28:11 +08:00
Tunglies fc2ff69bdb fix(proxy): stabilize proxy group sticky scroll #6982
Close #6982
2026-05-16 02:48:51 +08:00
Tunglies bd10ef8ec9 fix: improve youtube premium unlock check 2026-05-16 01:11:34 +08:00
Tunglies 662dd20be1 fix(linux): show window after page load #7002 2026-05-14 21:52:03 +08:00
Tunglies b7ae54b9f3 fix(wayland): disable webkit dmabuf renderer on legacy wayland versions (<1.23) #6999 (#7000) 2026-05-14 21:11:08 +08:00
Tunglies a60ef75061 perf: lazy load monaco editor 2026-05-14 02:31:20 +08:00
Tunglies d79ad8976a chore: bump tauri-plugin-mihomo to 0.1.8 2026-05-13 02:18:29 +08:00
Tunglies e538fa0573 fix: comment out useless exit handling 2026-05-11 00:11:02 +08:00
mocxda1andmocxda1 d58b054d93 fix(hotkey): parse key 'OS' as 'CMD' instead of keeping 'OS' clash-verge-rev#6899 (#6967)
Co-authored-by: mocxda1 <mocxda1@imashr.com>
2026-05-10 09:07:39 +00:00
Tunglies 165cc685bb chore: flag report-style PR bodies in AI review 2026-05-10 17:17:43 +08:00
Tunglies 243594c5d1 fix: isolate titlebar drag region #6968 2026-05-10 16:16:11 +08:00
Tunglies 753dad7c40 fix: move connection columns control 2026-05-10 13:21:06 +08:00
Tunglies 3956b0d034 fix(tray): include macOS speed text in click target 2026-05-09 02:32:27 +08:00
Tunglies 1d28cf7b5a style: improve update notes readability 2026-05-07 14:03:37 +08:00
Tunglies 8c1c143b5e fix: render GitHub alert syntax in update notes 2026-05-07 13:44:07 +08:00
65 changed files with 1120 additions and 948 deletions
+2
View File
@@ -71,6 +71,7 @@ If the PR references an issue, inspect that issue as well and compare the stated
- No referenced issue, or only vague claims like "fixes multiple issues" without a concrete issue number
- Single large commit or a very small number of commits covering many unrelated areas
- PR body reads like a generated report rather than a maintainer-owned change description
- PR body or author replies use stock report sections such as "Root Cause", "Root Case", "Checklist", or "Check List" without concrete issue evidence, reproduction context, or project-specific reasoning
- Explicit AI provenance links or bot-authored commits from coding agents
- Large-scale mechanical edits with little behavioral justification
- Random renames, comment rewrites, or same-meaning text changes that do not support the fix
@@ -87,6 +88,7 @@ If the PR references an issue, inspect that issue as well and compare the stated
- Tests that directly validate the reported regression or expected behavior
- Clear explanation of why each changed area is necessary for the fix
- Cross-Contextual Logic, the author explains *why* a change was made in a way that shows understanding of the project's specific constraints, rather than just repeating the issue text.
- Report-style sections are backed by concrete reproduction steps, failure evidence, or repository-specific constraints; template-required checklists should not count as a slop signal by themselves
- Evidence of established repository ownership or ongoing stewardship may reduce slop likelihood, but must never be disclosed in the public comment
## Decision Rules
@@ -0,0 +1,78 @@
name: Sync Mihomo Changelog
on:
workflow_dispatch:
schedule:
# UTC+8 Monday 11:30
- cron: '30 3 * * 1'
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-mihomo-changelog
cancel-in-progress: false
jobs:
sync:
name: Sync Changelog
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ github.event.repository.default_branch }}
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: '24.15.0'
- name: Sync Mihomo version
run: node scripts/sync-mihomo-version.mjs
- name: Detect changelog changes
id: changes
run: |
if git diff --quiet -- Changelog.md; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
mihomo_version="$(grep -Eo 'Mihomo\(Meta\).*v[0-9]+(\.[0-9]+)+' Changelog.md | grep -Eo 'v[0-9]+(\.[0-9]+)+' | head -n 1)"
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "mihomo_version=$mihomo_version" >> "$GITHUB_OUTPUT"
- name: Open pull request
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BASE_BRANCH: ${{ github.event.repository.default_branch }}
MIHOMO_VERSION: ${{ steps.changes.outputs.mihomo_version }}
run: |
branch="chore/sync-mihomo-changelog"
title="chore: sync Mihomo changelog to ${MIHOMO_VERSION}"
body="Automated update for the Mihomo core version recorded in Changelog.md."
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin "$branch:refs/remotes/origin/$branch" || true
git checkout -B "$branch"
git add Changelog.md
git commit -m "$title"
git push --force-with-lease origin "$branch"
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
gh pr edit "$branch" \
--repo "$GITHUB_REPOSITORY" \
--title "$title" \
--body "$body"
else
gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base "$BASE_BRANCH" \
--head "$branch" \
--title "$title" \
--body "$body"
fi
Generated
+6 -55
View File
@@ -1150,7 +1150,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.5.0-rc"
version = "2.5.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1665,19 +1665,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -1706,15 +1693,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -3490,7 +3468,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.4.10",
"tokio",
"tower-service",
"tracing",
@@ -3582,7 +3560,7 @@ dependencies = [
"tokio",
"tower-service",
"tracing",
"windows-registry 0.6.1",
"windows-registry",
]
[[package]]
@@ -7256,16 +7234,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -7817,7 +7785,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry 0.5.3",
"windows-registry",
"windows-result 0.3.4",
]
@@ -7931,18 +7899,12 @@ dependencies = [
[[package]]
name = "tauri-plugin-mihomo"
version = "0.1.5"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=revert#1cc80bc0fbe1245315617f4cecd93710a152325b"
version = "0.1.9"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo#a185fe1a8802367d0b3d50d402cb823759d2c57e"
dependencies = [
"base64 0.22.1",
"bytes",
"crossbeam",
"futures-util",
"http 1.4.0",
"http-body-util",
"httparse",
"hyper 1.9.0",
"libc",
"log",
"pin-project",
"rand 0.9.4",
@@ -9841,17 +9803,6 @@ dependencies = [
"windows-strings 0.4.2",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
+5 -2
View File
@@ -3,7 +3,7 @@
> [!IMPORTANT]
> 关于版本的说明:Clash Verge 版本号遵循 x.y.z:x 为重大架构变更,y 为功能新增,z 为 Bug 修复。
- **Mihomo(Meta) 内核升级至 v1.19.24**
- **Mihomo(Meta) 内核升级至 v1.19.25**
### 🐞 修复问题
@@ -12,6 +12,9 @@
- 修复修改定时自动更新后记时未及时刷新
- 修复 Linux 关闭 TUN 不立即生效
- 修复系统代理关闭序列逻辑(防止快速退出时系统代理关闭状态没有保存)
- 修复 Linux 快捷键映射错误
- 修复 Linux Wayland 兼容性导致渲染错误
- 修复 Linux 边缘情况无法加载页面
### ✨ 新增功能
@@ -29,4 +32,4 @@
### 👙 界面样式
- 代理组实现sticky scroll的效果
- 代理组实现 Sticky Scroll 效果
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.0-rc",
"version": "2.5.0",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -76,7 +76,7 @@
"react-markdown": "10.1.0",
"react-router": "^7.13.1",
"rehype-raw": "^7.0.0",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo",
"types-pac": "^1.0.3",
"validator": "^13.15.26"
},
+6 -6
View File
@@ -135,8 +135,8 @@ importers:
specifier: ^7.0.0
version: 7.0.0
tauri-plugin-mihomo-api:
specifier: github:clash-verge-rev/tauri-plugin-mihomo#revert
version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b
specifier: github:clash-verge-rev/tauri-plugin-mihomo
version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a185fe1a8802367d0b3d50d402cb823759d2c57e
types-pac:
specifier: ^1.0.3
version: 1.0.3
@@ -3361,9 +3361,9 @@ packages:
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
engines: {node: '>=18'}
tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b:
resolution: {tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b}
version: 0.1.5
tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a185fe1a8802367d0b3d50d402cb823759d2c57e:
resolution: {tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a185fe1a8802367d0b3d50d402cb823759d2c57e}
version: 0.1.9
terser@5.46.2:
resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==}
@@ -7045,7 +7045,7 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b:
tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a185fe1a8802367d0b3d50d402cb823759d2c57e:
dependencies:
'@tauri-apps/api': 2.10.1
+104
View File
@@ -0,0 +1,104 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
const VERSION_URL =
'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
const DEFAULT_CHANGELOG = 'Changelog.md'
const args = process.argv.slice(2)
const CHECK = args.includes('--check')
const DRY_RUN = args.includes('--dry-run')
const logError = (message, ...optionalParams) =>
console.error(message, ...optionalParams)
const logInfo = (message, ...optionalParams) =>
console.log(message, ...optionalParams)
function readArgValue(name) {
const index = args.indexOf(name)
if (index === -1) return null
return args[index + 1] ?? null
}
function normalizeVersion(version) {
const normalized = version.trim()
return normalized.startsWith('v') ? normalized : `v${normalized}`
}
async function fetchLatestVersion() {
const explicitVersion = readArgValue('--version')
if (explicitVersion) return normalizeVersion(explicitVersion)
const response = await fetch(VERSION_URL, {
method: 'GET',
})
if (!response.ok) {
throw new Error(`Failed to fetch ${VERSION_URL}: ${response.status}`)
}
return normalizeVersion(await response.text())
}
function replaceExistingLine(content, version) {
const mihomoLine =
/^(\s*-\s*(?:\*\*)?\s*Mihomo\(Meta\)\s*内核升级至\s*)v?\d+(?:\.\d+)+(?:[-+][^\s*]+)?((?:\*\*)?\s*)$/m
if (!mihomoLine.test(content)) return null
return content.replace(mihomoLine, `$1${version}$2`)
}
function insertMihomoLine(content, version) {
const title = /^## .+$/m
const match = content.match(title)
if (!match || match.index === undefined) {
throw new Error('could not find changelog title')
}
const titleEnd = match.index + match[0].length
const before = content.slice(0, titleEnd)
const after = content.slice(titleEnd).replace(/^\n*/, '\n\n')
return `${before}\n\n- **Mihomo(Meta) 内核升级至 ${version}**${after}`
}
async function main() {
const cwd = process.cwd()
const changelogArg = readArgValue('--file')
const changelogPath = path.join(cwd, changelogArg || DEFAULT_CHANGELOG)
if (!fs.existsSync(changelogPath)) {
throw new Error(`could not find ${path.relative(cwd, changelogPath)}`)
}
const version = await fetchLatestVersion()
const content = await fsp.readFile(changelogPath, 'utf-8')
const nextContent =
replaceExistingLine(content, version) ?? insertMihomoLine(content, version)
if (nextContent === content) {
logInfo(`Mihomo version in changelog is already ${version}`)
return
}
if (CHECK) {
logError(`Mihomo version in changelog is not ${version}`)
process.exit(1)
}
if (DRY_RUN) {
logInfo(`Would update Mihomo version in changelog to ${version}`)
return
}
await fsp.writeFile(changelogPath, nextContent)
logInfo(`Updated Mihomo version in changelog to ${version}`)
}
main().catch((error) => {
logError('sync-mihomo-version failed:', error.message)
process.exit(1)
})
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.0-rc"
version = "2.5.0"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -95,7 +95,7 @@ backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.5.7"
console-subscriber = { version = "0.5.0", optional = true }
tauri-plugin-devtools = { version = "2.0.1" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "revert" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" }
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
async-trait = "0.1.89"
clash_verge_service_ipc = { version = "2.3.0", features = [
@@ -5,7 +5,6 @@ use regex::Regex;
use reqwest::{Client, cookie::Jar};
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem {
let cookie_store = Arc::new(Jar::default());
@@ -54,12 +53,7 @@ pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem {
};
if device_id.is_empty() {
return UnlockItem {
name: "Bahamut Anime".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return UnlockItem::checked("Bahamut Anime", "Failed", None);
}
let url = format!("https://ani.gamer.com.tw/ajax/token.php?adID=89422&sn=37783&device={device_id}");
@@ -79,12 +73,7 @@ pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem {
};
if token_result.is_none() {
return UnlockItem {
name: "Bahamut Anime".to_string(),
status: "No".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return UnlockItem::checked("Bahamut Anime", "No", None);
}
let region = match client_with_cookies.get("https://ani.gamer.com.tw/").send().await {
@@ -92,8 +81,7 @@ pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem {
Ok(body) => match Regex::new(r#"data-geo="([^"]+)"#) {
Ok(region_re) => region_re.captures(&body).and_then(|caps| caps.get(1)).map(|m| {
let country_code = m.as_str();
let emoji = country_code_to_emoji(country_code);
format!("{emoji}{country_code}")
UnlockItem::region_label(country_code)
}),
Err(e) => {
logging!(
@@ -110,10 +98,5 @@ pub(super) async fn check_bahamut_anime(client: &Client) -> UnlockItem {
Err(_) => None,
};
UnlockItem {
name: "Bahamut Anime".to_string(),
status: "Yes".to_string(),
region,
check_time: Some(get_local_date_string()),
}
UnlockItem::checked("Bahamut Anime", "Yes", region)
}
@@ -2,7 +2,6 @@ use reqwest::Client;
use serde_json::Value;
use super::UnlockItem;
use super::utils::get_local_date_string;
pub(super) async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem {
let url = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi";
@@ -24,26 +23,11 @@ pub(super) async fn check_bilibili_china_mainland(client: &Client) -> UnlockItem
})
.unwrap_or("Failed");
UnlockItem {
name: "哔哩哔哩大陆".to_string(),
status: status.to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
UnlockItem::checked("哔哩哔哩大陆", status, None)
}
Err(_) => UnlockItem {
name: "哔哩哔哩大陆".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
},
},
Err(_) => UnlockItem {
name: "哔哩哔哩大陆".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
Err(_) => UnlockItem::checked("哔哩哔哩大陆", "Failed", None),
},
Err(_) => UnlockItem::checked("哔哩哔哩大陆", "Failed", None),
}
}
@@ -67,25 +51,10 @@ pub(super) async fn check_bilibili_hk_mc_tw(client: &Client) -> UnlockItem {
})
.unwrap_or("Failed");
UnlockItem {
name: "哔哩哔哩港澳台".to_string(),
status: status.to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
UnlockItem::checked("哔哩哔哩港澳台", status, None)
}
Err(_) => UnlockItem {
name: "哔哩哔哩港澳台".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
},
},
Err(_) => UnlockItem {
name: "哔哩哔哩港澳台".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
Err(_) => UnlockItem::checked("哔哩哔哩港澳台", "Failed", None),
},
Err(_) => UnlockItem::checked("哔哩哔哩港澳台", "Failed", None),
}
}
@@ -3,7 +3,6 @@ use std::collections::HashMap;
use reqwest::Client;
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
pub(super) async fn check_chatgpt_combined(client: &Client) -> Vec<UnlockItem> {
let mut results = Vec::new();
@@ -23,10 +22,7 @@ pub(super) async fn check_chatgpt_combined(client: &Client) -> Vec<UnlockItem> {
}
}
map.get("loc").map(|loc| {
let emoji = country_code_to_emoji(loc);
format!("{emoji}{loc}")
})
map.get("loc").map(|loc| UnlockItem::region_label(loc))
} else {
None
}
@@ -76,19 +72,9 @@ pub(super) async fn check_chatgpt_combined(client: &Client) -> Vec<UnlockItem> {
Err(_) => "Failed",
};
results.push(UnlockItem {
name: "ChatGPT iOS".to_string(),
status: ios_status.to_string(),
region: region.clone(),
check_time: Some(get_local_date_string()),
});
results.push(UnlockItem::checked("ChatGPT iOS", ios_status, region.clone()));
results.push(UnlockItem {
name: "ChatGPT Web".to_string(),
status: web_status.to_string(),
region,
check_time: Some(get_local_date_string()),
});
results.push(UnlockItem::checked("ChatGPT Web", web_status, region));
results
}
@@ -1,7 +1,6 @@
use reqwest::Client;
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
const BLOCKED_CODES: [&str; 10] = ["AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"];
@@ -21,40 +20,19 @@ pub(super) async fn check_claude(client: &Client) -> UnlockItem {
}
if let Some(code) = country_code {
let emoji = country_code_to_emoji(&code);
let status = if BLOCKED_CODES.contains(&code.as_str()) {
"No"
} else {
"Yes"
};
UnlockItem {
name: "Claude".to_string(),
status: status.to_string(),
region: Some(format!("{emoji}{code}")),
check_time: Some(get_local_date_string()),
}
UnlockItem::checked_region("Claude", status, &code)
} else {
UnlockItem {
name: "Claude".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
UnlockItem::checked("Claude", "Failed", None)
}
}
Err(_) => UnlockItem {
name: "Claude".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
},
},
Err(_) => UnlockItem {
name: "Claude".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
Err(_) => UnlockItem::checked("Claude", "Failed", None),
},
Err(_) => UnlockItem::checked("Claude", "Failed", None),
}
}
@@ -4,12 +4,51 @@ use reqwest::Client;
use clash_verge_logging::{Type, logging};
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
const DISNEY_PLUS: &str = "Disney+";
const AUTH_HEADER: &str = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84";
fn disney_item(status: impl Into<String>, region: Option<String>) -> UnlockItem {
UnlockItem::checked(DISNEY_PLUS, status, region)
}
async fn fetch_main_page_region(client: &Client) -> Option<String> {
let body = client
.get("https://www.disneyplus.com/")
.send()
.await
.ok()?
.text()
.await
.ok()?;
let region_re = match Regex::new(r#"region"\s*:\s*"([^"]+)"#) {
Ok(region_re) => region_re,
Err(e) => {
logging!(
error,
Type::Network,
"Failed to compile Disney+ main page region regex: {}",
e
);
return None;
}
};
region_re
.captures(&body)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
}
async fn disney_region_from_main_page(client: &Client) -> Option<UnlockItem> {
let region = fetch_main_page_region(client).await?;
Some(disney_item(
"Yes",
Some(format!("{} (from main page)", UnlockItem::region_label(&region))),
))
}
#[allow(clippy::cognitive_complexity)]
pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
let device_api_url = "https://disney.api.edge.bamgrid.com/devices";
let auth_header = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84";
let device_req_body = serde_json::json!({
"deviceFamily": "browser",
@@ -20,52 +59,27 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
let device_result = client
.post(device_api_url)
.header("authorization", auth_header)
.header("authorization", AUTH_HEADER)
.header("content-type", "application/json; charset=UTF-8")
.json(&device_req_body)
.send()
.await;
if device_result.is_err() {
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
}
let device_response = match device_result {
Ok(response) => response,
Err(e) => {
logging!(error, Type::Network, "Failed to get Disney+ device response: {}", e);
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
Err(_) => {
return disney_item("Failed (Network Connection)", None);
}
};
if device_response.status().as_u16() == 403 {
return UnlockItem {
name: "Disney+".to_string(),
status: "No (IP Banned By Disney+)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("No (IP Banned By Disney+)", None);
}
let device_body = match device_response.text().await {
Ok(body) => body,
Err(_) => {
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Error: Cannot read response)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("Failed (Error: Cannot read response)", None);
}
};
@@ -78,12 +92,7 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
"Failed to compile assertion regex for Disney+: {}",
e
);
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Regex Error)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("Failed (Regex Error)", None);
}
};
let assertion = match re.captures(&device_body) {
@@ -92,27 +101,11 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
};
if assertion.is_none() {
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Error: Cannot extract assertion)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("Failed (Error: Cannot extract assertion)", None);
}
let token_url = "https://disney.api.edge.bamgrid.com/token";
let assertion_str = match assertion {
Some(assertion) => assertion,
None => {
logging!(error, Type::Network, "No assertion found for Disney+");
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (No Assertion)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
}
};
let assertion_str = assertion.unwrap_or_default();
let token_body = [
("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"),
("latitude", "0"),
@@ -124,31 +117,16 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
let token_result = client
.post(token_url)
.header("authorization", auth_header)
.header("authorization", AUTH_HEADER)
.header("content-type", "application/x-www-form-urlencoded")
.form(&token_body)
.send()
.await;
if token_result.is_err() {
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
}
let token_response = match token_result {
Ok(response) => response,
Err(e) => {
logging!(error, Type::Network, "Failed to get Disney+ token response: {}", e);
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
Err(_) => {
return disney_item("Failed (Network Connection)", None);
}
};
let token_status = token_response.status();
@@ -156,22 +134,12 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
let token_body_text = match token_response.text().await {
Ok(body) => body,
Err(_) => {
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Error: Cannot read token response)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("Failed (Error: Cannot read token response)", None);
}
};
if token_body_text.contains("forbidden-location") || token_body_text.contains("403 ERROR") {
return UnlockItem {
name: "Disney+".to_string(),
status: "No (IP Banned By Disney+)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("No (IP Banned By Disney+)", None);
}
let token_json: Result<serde_json::Value, _> = serde_json::from_str(&token_body_text);
@@ -198,16 +166,14 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
};
if refresh_token.is_none() {
return UnlockItem {
name: "Disney+".to_string(),
status: format!(
return disney_item(
format!(
"Failed (Error: Cannot extract refresh token, status: {}, response: {})",
token_status.as_u16(),
token_body_text.chars().take(100).collect::<String>() + "..."
),
region: None,
check_time: Some(get_local_date_string()),
};
None,
);
}
let graphql_url = "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql";
@@ -219,21 +185,12 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
let graphql_result = client
.post(graphql_url)
.header("authorization", auth_header)
.header("authorization", AUTH_HEADER)
.header("content-type", "application/json")
.body(graphql_payload)
.send()
.await;
if graphql_result.is_err() {
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
}
let preview_check = client.get("https://disneyplus.com").send().await;
let is_unavailable = match preview_check {
@@ -246,14 +203,8 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
let graphql_response = match graphql_result {
Ok(response) => response,
Err(e) => {
logging!(error, Type::Network, "Failed to get Disney+ GraphQL response: {}", e);
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
Err(_) => {
return disney_item("Failed (Network Connection)", None);
}
};
let graphql_status = graphql_response.status();
@@ -271,58 +222,27 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
};
if graphql_body_text.is_empty() || graphql_status.as_u16() >= 400 {
let region_from_main = match client.get("https://www.disneyplus.com/").send().await {
Ok(response) => match response.text().await {
Ok(body) => match Regex::new(r#"region"\s*:\s*"([^"]+)"#) {
Ok(region_re) => region_re
.captures(&body)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())),
Err(e) => {
logging!(
error,
Type::Network,
"Failed to compile Disney+ main page region regex: {}",
e
);
None
}
},
Err(_) => None,
},
Err(_) => None,
};
if let Some(region) = region_from_main {
let emoji = country_code_to_emoji(&region);
return UnlockItem {
name: "Disney+".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{region} (from main page)")),
check_time: Some(get_local_date_string()),
};
if let Some(item) = disney_region_from_main_page(client).await {
return item;
}
if graphql_body_text.is_empty() {
return UnlockItem {
name: "Disney+".to_string(),
status: format!(
return disney_item(
format!(
"Failed (GraphQL error: empty response, status: {})",
graphql_status.as_u16()
),
region: None,
check_time: Some(get_local_date_string()),
};
None,
);
}
return UnlockItem {
name: "Disney+".to_string(),
status: format!(
return disney_item(
format!(
"Failed (GraphQL error: {}, status: {})",
graphql_body_text.chars().take(50).collect::<String>() + "...",
graphql_status.as_u16()
),
region: None,
check_time: Some(get_local_date_string()),
};
None,
);
}
let region_re = match Regex::new(r#""countryCode"\s*:\s*"([^"]+)"#) {
@@ -334,12 +254,7 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
"Failed to compile Disney+ countryCode regex: {}",
e
);
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Regex Error)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("Failed (Regex Error)", None);
}
};
let region_code = region_re
@@ -355,12 +270,7 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
"Failed to compile Disney+ supported location regex: {}",
e
);
return UnlockItem {
name: "Disney+".to_string(),
status: "Failed (Regex Error)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("Failed (Regex Error)", None);
}
};
let in_supported_location = supported_re
@@ -368,101 +278,29 @@ pub(super) async fn check_disney_plus(client: &Client) -> UnlockItem {
.and_then(|caps| caps.get(1).map(|m| m.as_str() == "true"));
if region_code.is_none() {
let region_from_main = match client.get("https://www.disneyplus.com/").send().await {
Ok(response) => match response.text().await {
Ok(body) => match Regex::new(r#"region"\s*:\s*"([^"]+)"#) {
Ok(region_re) => region_re
.captures(&body)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string())),
Err(e) => {
logging!(
error,
Type::Network,
"Failed to compile Disney+ main page region regex: {}",
e
);
None
}
},
Err(_) => None,
},
Err(_) => None,
};
if let Some(region) = region_from_main {
let emoji = country_code_to_emoji(&region);
return UnlockItem {
name: "Disney+".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{region} (from main page)")),
check_time: Some(get_local_date_string()),
};
if let Some(item) = disney_region_from_main_page(client).await {
return item;
}
return UnlockItem {
name: "Disney+".to_string(),
status: "No".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("No", None);
}
let region = match region_code {
Some(code) => code,
None => {
logging!(error, Type::Network, "No region code found for Disney+");
return UnlockItem {
name: "Disney+".to_string(),
status: "No".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
}
};
let region = region_code.unwrap_or_default();
if region == "JP" {
let emoji = country_code_to_emoji("JP");
return UnlockItem {
name: "Disney+".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{region}")),
check_time: Some(get_local_date_string()),
};
return UnlockItem::checked_region(DISNEY_PLUS, "Yes", &region);
}
if is_unavailable {
return UnlockItem {
name: "Disney+".to_string(),
status: "No".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return disney_item("No", None);
}
match in_supported_location {
Some(false) => {
let emoji = country_code_to_emoji(&region);
UnlockItem {
name: "Disney+".to_string(),
status: "Soon".to_string(),
region: Some(format!("{emoji}{region}(即将上线)")),
check_time: Some(get_local_date_string()),
}
}
Some(true) => {
let emoji = country_code_to_emoji(&region);
UnlockItem {
name: "Disney+".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{region}")),
check_time: Some(get_local_date_string()),
}
}
None => UnlockItem {
name: "Disney+".to_string(),
status: format!("Failed (Error: Unknown region status for {region})"),
region: None,
check_time: Some(get_local_date_string()),
},
Some(false) => disney_item(
"Soon",
Some(format!("{}(即将上线)", UnlockItem::region_label(&region))),
),
Some(true) => UnlockItem::checked_region(DISNEY_PLUS, "Yes", &region),
None => disney_item(format!("Failed (Error: Unknown region status for {region})"), None),
}
}
@@ -1,19 +1,13 @@
use reqwest::Client;
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
const BLOCKED_CODES: [&str; 9] = ["CHN", "RUS", "BLR", "CUB", "IRN", "PRK", "SYR", "HKG", "MAC"];
const REGION_MARKER: &str = ",2,1,200,\"";
pub(super) async fn check_gemini(client: &Client) -> UnlockItem {
let url = "https://gemini.google.com";
let failed = || UnlockItem {
name: "Gemini".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
let failed = || UnlockItem::checked("Gemini", "Failed", None);
let response = match client.get(url).send().await {
Ok(r) => r,
@@ -34,14 +28,8 @@ pub(super) async fn check_gemini(client: &Client) -> UnlockItem {
match country_code {
Some(code) => {
let emoji = country_code_to_emoji(code);
let status = if BLOCKED_CODES.contains(&code) { "No" } else { "Yes" };
UnlockItem {
name: "Gemini".to_string(),
status: status.to_string(),
region: Some(format!("{emoji}{code}")),
check_time: Some(get_local_date_string()),
}
UnlockItem::checked_region("Gemini", status, code)
}
None => failed(),
}
@@ -4,7 +4,12 @@ use serde_json::Value;
use clash_verge_logging::{Type, logging};
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
const NETFLIX: &str = "Netflix";
fn netflix_item(status: impl Into<String>, region: Option<String>) -> UnlockItem {
UnlockItem::checked(NETFLIX, status, region)
}
pub(super) async fn check_netflix(client: &Client) -> UnlockItem {
let cdn_result = check_netflix_cdn(client).await;
@@ -23,12 +28,7 @@ pub(super) async fn check_netflix(client: &Client) -> UnlockItem {
if let Err(e) = &result1 {
logging!(error, Type::Network, "Netflix请求错误: {e}");
return UnlockItem {
name: "Netflix".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return netflix_item("Failed", None);
}
let result2 = client
@@ -39,24 +39,14 @@ pub(super) async fn check_netflix(client: &Client) -> UnlockItem {
if let Err(e) = &result2 {
logging!(error, Type::Network, "Netflix请求错误: {e}");
return UnlockItem {
name: "Netflix".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return netflix_item("Failed", None);
}
let status1 = match result1 {
Ok(response) => response.status().as_u16(),
Err(e) => {
logging!(error, Type::Network, "Failed to get Netflix response 1: {}", e);
return UnlockItem {
name: "Netflix".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return netflix_item("Failed", None);
}
};
@@ -64,31 +54,16 @@ pub(super) async fn check_netflix(client: &Client) -> UnlockItem {
Ok(response) => response.status().as_u16(),
Err(e) => {
logging!(error, Type::Network, "Failed to get Netflix response 2: {}", e);
return UnlockItem {
name: "Netflix".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return netflix_item("Failed", None);
}
};
if status1 == 404 && status2 == 404 {
return UnlockItem {
name: "Netflix".to_string(),
status: "Originals Only".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return netflix_item("Originals Only", None);
}
if status1 == 403 || status2 == 403 {
return UnlockItem {
name: "Netflix".to_string(),
status: "No".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return netflix_item("No", None);
}
if status1 == 200 || status1 == 301 || status2 == 200 || status2 == 301 {
@@ -106,41 +81,19 @@ pub(super) async fn check_netflix(client: &Client) -> UnlockItem {
let parts: Vec<&str> = location_str.split('/').collect();
if parts.len() >= 4 {
let region_code = parts[3].split('-').next().unwrap_or("unknown");
let emoji = country_code_to_emoji(region_code);
return UnlockItem {
name: "Netflix".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{region_code}")),
check_time: Some(get_local_date_string()),
};
return UnlockItem::checked_region(NETFLIX, "Yes", region_code);
}
}
let emoji = country_code_to_emoji("us");
UnlockItem {
name: "Netflix".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{}", "us")),
check_time: Some(get_local_date_string()),
}
UnlockItem::checked_region(NETFLIX, "Yes", "us")
}
Err(e) => {
logging!(error, Type::Network, "获取Netflix区域信息失败: {e}");
UnlockItem {
name: "Netflix".to_string(),
status: "Yes (但无法获取区域)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
netflix_item("Yes (但无法获取区域)", None)
}
}
} else {
UnlockItem {
name: "Netflix".to_string(),
status: format!("Failed (状态码: {status1}_{status2}"),
region: None,
check_time: Some(get_local_date_string()),
}
netflix_item(format!("Failed (状态码: {status1}_{status2}"), None)
}
}
@@ -150,12 +103,7 @@ async fn check_netflix_cdn(client: &Client) -> UnlockItem {
match client.get(url).timeout(std::time::Duration::from_secs(30)).send().await {
Ok(response) => {
if response.status().as_u16() == 403 {
return UnlockItem {
name: "Netflix".to_string(),
status: "No (IP Banned By Netflix)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return netflix_item("No (IP Banned By Netflix)", None);
}
match response.json::<Value>().await {
@@ -165,41 +113,20 @@ async fn check_netflix_cdn(client: &Client) -> UnlockItem {
&& let Some(location) = targets[0].get("location")
&& let Some(country) = location.get("country").and_then(|c| c.as_str())
{
let emoji = country_code_to_emoji(country);
return UnlockItem {
name: "Netflix".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{country}")),
check_time: Some(get_local_date_string()),
};
return UnlockItem::checked_region(NETFLIX, "Yes", country);
}
UnlockItem {
name: "Netflix".to_string(),
status: "Unknown".to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
netflix_item("Unknown", None)
}
Err(e) => {
logging!(error, Type::Network, "解析Fast.com API响应失败: {e}");
UnlockItem {
name: "Netflix".to_string(),
status: "Failed (解析错误)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
netflix_item("Failed (解析错误)", None)
}
}
}
Err(e) => {
logging!(error, Type::Network, "Fast.com API请求失败: {e}");
UnlockItem {
name: "Netflix".to_string(),
status: "Failed (CDN API)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
netflix_item("Failed (CDN API)", None)
}
}
}
@@ -4,32 +4,22 @@ use reqwest::Client;
use clash_verge_logging::{Type, logging};
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
const PRIME_VIDEO: &str = "Prime Video";
fn prime_video_item(status: impl Into<String>, region: Option<String>) -> UnlockItem {
UnlockItem::checked(PRIME_VIDEO, status, region)
}
pub(super) async fn check_prime_video(client: &Client) -> UnlockItem {
let url = "https://www.primevideo.com";
let result = client.get(url).send().await;
if result.is_err() {
return UnlockItem {
name: "Prime Video".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
}
let response = match result {
Ok(response) => response,
Err(e) => {
logging!(error, Type::Network, "Failed to get Prime Video response: {}", e);
return UnlockItem {
name: "Prime Video".to_string(),
status: "Failed (Network Connection)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
Err(_) => {
return prime_video_item("Failed (Network Connection)", None);
}
};
@@ -46,12 +36,7 @@ pub(super) async fn check_prime_video(client: &Client) -> UnlockItem {
"Failed to compile Prime Video region regex: {}",
e
);
return UnlockItem {
name: "Prime Video".to_string(),
status: "Failed (Regex Error)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return prime_video_item("Failed (Regex Error)", None);
}
};
let region_code = region_re
@@ -59,45 +44,19 @@ pub(super) async fn check_prime_video(client: &Client) -> UnlockItem {
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()));
if is_blocked {
return UnlockItem {
name: "Prime Video".to_string(),
status: "No (Service Not Available)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return prime_video_item("No (Service Not Available)", None);
}
if let Some(region) = region_code {
let emoji = country_code_to_emoji(&region);
return UnlockItem {
name: "Prime Video".to_string(),
status: "Yes".to_string(),
region: Some(format!("{emoji}{region}")),
check_time: Some(get_local_date_string()),
};
return UnlockItem::checked_region(PRIME_VIDEO, "Yes", &region);
}
if !is_blocked {
return UnlockItem {
name: "Prime Video".to_string(),
status: "Failed (Error: PAGE ERROR)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
};
return prime_video_item("Failed (Error: PAGE ERROR)", None);
}
UnlockItem {
name: "Prime Video".to_string(),
status: "Failed (Error: Unknown Region)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
prime_video_item("Failed (Error: Unknown Region)", None)
}
Err(_) => UnlockItem {
name: "Prime Video".to_string(),
status: "Failed (Error: Cannot read response)".to_string(),
region: None,
check_time: Some(get_local_date_string()),
},
Err(_) => prime_video_item("Failed (Error: Cannot read response)", None),
}
}
@@ -1,7 +1,6 @@
use reqwest::{Client, Url};
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
pub(super) async fn check_spotify(client: &Client) -> UnlockItem {
let url = "https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json";
@@ -15,19 +14,9 @@ pub(super) async fn check_spotify(client: &Client) -> UnlockItem {
let region = extract_region(&final_url).or_else(|| extract_region_from_body(&body));
let status = determine_status(status_code.as_u16(), &body);
UnlockItem {
name: "Spotify".to_string(),
status: status.to_string(),
region,
check_time: Some(get_local_date_string()),
}
UnlockItem::checked("Spotify", status, region)
}
Err(_) => UnlockItem {
name: "Spotify".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
},
Err(_) => UnlockItem::checked("Spotify", "Failed", None),
}
}
@@ -58,8 +47,7 @@ fn extract_region(url: &Url) -> Option<String> {
let country_code = first_segment.split('-').next().unwrap_or(first_segment);
let upper = country_code.to_uppercase();
let emoji = country_code_to_emoji(&upper);
Some(format!("{emoji}{upper}"))
Some(UnlockItem::region_label(&upper))
}
fn extract_region_from_body(body: &str) -> Option<String> {
@@ -70,8 +58,7 @@ fn extract_region_from_body(body: &str) -> Option<String> {
if let Some(end) = rest.find('"') {
let code = rest[..end].to_uppercase();
if !code.is_empty() {
let emoji = country_code_to_emoji(&code);
return Some(format!("{emoji}{code}"));
return Some(UnlockItem::region_label(&code));
}
}
}
@@ -4,7 +4,6 @@ use regex::Regex;
use reqwest::Client;
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
pub(super) async fn check_tiktok(client: &Client) -> UnlockItem {
let trace_url = "https://www.tiktok.com/cdn-cgi/trace";
@@ -38,12 +37,7 @@ pub(super) async fn check_tiktok(client: &Client) -> UnlockItem {
}
}
UnlockItem {
name: "TikTok".to_string(),
status,
region,
check_time: Some(get_local_date_string()),
}
UnlockItem::checked("TikTok", status, region)
}
fn determine_status(status: u16, body: &str) -> &'static str {
@@ -78,8 +72,7 @@ fn extract_region_from_body(body: &str) -> Option<String> {
let raw = matched.as_str();
let country_code = raw.split('-').next().unwrap_or(raw).to_uppercase();
if !country_code.is_empty() {
let emoji = country_code_to_emoji(&country_code);
return Some(format!("{emoji}{country_code}"));
return Some(UnlockItem::region_label(&country_code));
}
}
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use super::utils::{country_code_to_emoji, get_local_date_string};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnlockItem {
pub name: String,
@@ -9,6 +11,24 @@ pub struct UnlockItem {
}
impl UnlockItem {
pub fn checked(name: &str, status: impl Into<String>, region: Option<String>) -> Self {
Self {
name: name.to_string(),
status: status.into(),
region,
check_time: Some(get_local_date_string()),
}
}
pub fn checked_region(name: &str, status: impl Into<String>, country_code: &str) -> Self {
Self::checked(name, status, Some(Self::region_label(country_code)))
}
pub fn region_label(country_code: &str) -> String {
let emoji = country_code_to_emoji(country_code);
format!("{emoji}{country_code}")
}
pub fn pending(name: &str) -> Self {
Self {
name: name.to_string(),
@@ -4,58 +4,56 @@ use reqwest::Client;
use clash_verge_logging::{Type, logging};
use super::UnlockItem;
use super::utils::{country_code_to_emoji, get_local_date_string};
pub(super) async fn check_youtube_premium(client: &Client) -> UnlockItem {
let url = "https://www.youtube.com/premium";
let url = "https://www.youtube.com/premium?hl=en";
let item = |status: &str, region: Option<String>| UnlockItem::checked("YouTube Premium", status, region);
match client.get(url).send().await {
Ok(response) => {
let status_code = response.status().as_u16();
if let Ok(body) = response.text().await {
let body_lower = body.to_lowercase();
let mut status = "Failed";
let mut region = None;
if body_lower.contains("youtube premium is not available in your country") {
status = "No";
} else if body_lower.contains("ad-free") {
match Regex::new(r#"id="country-code"[^>]*>([^<]+)<"#) {
Ok(re) => {
if let Some(caps) = re.captures(&body)
&& let Some(m) = caps.get(1)
{
let country_code = m.as_str().trim();
let emoji = country_code_to_emoji(country_code);
region = Some(format!("{emoji}{country_code}"));
status = "Yes";
}
}
Err(e) => {
logging!(error, Type::Network, "Failed to compile YouTube Premium regex: {}", e);
}
let body_lower = body.to_ascii_lowercase();
let region = [
r#"id=["']country-code["'][^>]*>\s*([A-Za-z]{2,3})\s*<"#,
r#""GL"\s*:\s*"([A-Za-z]{2})""#,
r#""countryCode"\s*:\s*"([A-Za-z]{2})""#,
r#""country_code"\s*:\s*"([A-Za-z]{2})""#,
]
.iter()
.find_map(|pattern| match Regex::new(pattern) {
Ok(re) => re
.captures(&body)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_ascii_uppercase()),
Err(e) => {
logging!(error, Type::Network, "Failed to compile YouTube Premium regex: {}", e);
None
}
}
})
.map(|country_code| UnlockItem::region_label(&country_code));
UnlockItem {
name: "YouTube Premium".to_string(),
status: status.to_string(),
region,
check_time: Some(get_local_date_string()),
}
let status = if body_lower.contains("youtube premium is not available in your country")
|| body_lower.contains("premium is not available in your country")
|| body_lower.contains("premium is not available in your region")
{
"No"
} else if (200..300).contains(&status_code)
&& (body_lower.contains("youtube premium")
|| body_lower.contains("ad-free")
|| body_lower.contains(r#""browseid":"spunlimited""#))
{
"Yes"
} else {
"Failed"
};
item(status, region)
} else {
UnlockItem {
name: "YouTube Premium".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
}
item("Failed", None)
}
}
Err(_) => UnlockItem {
name: "YouTube Premium".to_string(),
status: "Failed".to_string(),
region: None,
check_time: Some(get_local_date_string()),
},
Err(_) => item("Failed", None),
}
}
+2 -2
View File
@@ -591,9 +591,9 @@ fn fix_dirty_url(input: &str) -> Result<Url> {
Ok(u) => u,
Err(e) => {
return Err(anyhow::anyhow!(
"failed to parse deep link url: {:?}, input: {:?}",
"failed to parse subscription URL: {:?}, input: {}",
e,
input
help::mask_url(input)
));
}
};
+1
View File
@@ -101,6 +101,7 @@ impl Sysopt {
}
if !verge.enable_proxy_guard.unwrap_or_default() {
logging!(info, Type::Core, "System proxy guard is disabled.");
self.access_guard().write().stop();
return;
}
logging!(
+8 -6
View File
@@ -6,7 +6,7 @@ use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use tauri::async_runtime::JoinHandle;
use tauri_plugin_mihomo::models::ConnectionId;
use tauri_plugin_mihomo::models::WebSocketConnectionId;
/// 托盘速率流异常后的重连间隔。
const TRAY_SPEED_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -19,7 +19,7 @@ const TRAY_SPEED_STALE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone)]
pub struct TraySpeedController {
speed_task: Arc<Mutex<Option<JoinHandle<()>>>>,
speed_connection_id: Arc<Mutex<Option<ConnectionId>>>,
speed_connection_id: Arc<Mutex<Option<WebSocketConnectionId>>>,
}
impl Default for TraySpeedController {
@@ -162,17 +162,19 @@ impl TraySpeedController {
}
fn set_speed_connection_id(
speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>,
connection_id: Option<ConnectionId>,
speed_connection_id: &Arc<Mutex<Option<WebSocketConnectionId>>>,
connection_id: Option<WebSocketConnectionId>,
) {
*speed_connection_id.lock() = connection_id;
}
fn take_speed_connection_id(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) -> Option<ConnectionId> {
fn take_speed_connection_id(
speed_connection_id: &Arc<Mutex<Option<WebSocketConnectionId>>>,
) -> Option<WebSocketConnectionId> {
speed_connection_id.lock().take()
}
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) {
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<WebSocketConnectionId>>>) {
if let Some(connection_id) = Self::take_speed_connection_id(speed_connection_id) {
connections_stream::disconnect_connection(connection_id).await;
}
+14 -24
View File
@@ -24,7 +24,6 @@ use tauri::{AppHandle, Manager as _};
#[cfg(target_os = "macos")]
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _;
use tauri_plugin_mihomo::RejectPolicy;
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
/// Application initialization helper functions
@@ -59,15 +58,6 @@ mod app_init {
tauri_plugin_mihomo::Builder::new()
.protocol(tauri_plugin_mihomo::models::Protocol::LocalSocket)
.socket_path(crate::config::IClashTemp::guard_external_controller_ipc())
.pool_config(
tauri_plugin_mihomo::IpcPoolConfigBuilder::new()
.min_connections(3)
.max_connections(32)
.idle_timeout(std::time::Duration::from_secs(60))
.health_check_interval(std::time::Duration::from_secs(60))
.reject_policy(RejectPolicy::Wait)
.build(),
)
.build(),
);
@@ -226,6 +216,8 @@ pub fn run() {
#[cfg(target_os = "linux")]
utils::linux::workarounds::apply_nvidia_dmabuf_renderer_workaround();
#[cfg(target_os = "linux")]
utils::linux::workarounds::apply_wayland_webkit_fix();
let _ = utils::dirs::init_portable_flag();
@@ -395,22 +387,20 @@ pub fn run() {
event_handlers::handle_reopen(has_visible_windows).await;
});
}
tauri::RunEvent::Exit => AsyncHandler::block_on(async {
if !handle::Handle::global().is_exiting() {
feat::quit().await;
}
}),
tauri::RunEvent::Exit => {
logging!(info, Type::System, "Application exited");
}
#[allow(unused_variables)]
tauri::RunEvent::ExitRequested { api, code, .. } => {
if core::handle::Handle::global().is_exiting() {
return;
}
AsyncHandler::block_on(async {
let _ = handle::Handle::mihomo().await.clear_all_ws_connections().await;
});
if code.is_none() {
if module::lightweight::is_in_lightweight_mode() && !handle::Handle::global().is_exiting() {
api.prevent_exit();
} else if code.is_none() {
api.prevent_exit();
if !handle::Handle::global().is_exiting() {
AsyncHandler::spawn(|| async {
feat::quit().await;
});
}
}
}
tauri::RunEvent::WindowEvent { label, event, .. } if label == "main" => match event {
+3 -3
View File
@@ -3,7 +3,7 @@ use anyhow::Result;
use serde::Deserialize;
use serde_json::Value;
use std::time::Duration;
use tauri_plugin_mihomo::models::{ConnectionId, WebSocketMessage};
use tauri_plugin_mihomo::models::{WebSocketConnectionId, WebSocketMessage};
use tokio::sync::mpsc;
use tokio::time::Instant;
@@ -39,7 +39,7 @@ enum InternalWsEvent<T> {
/// Mihomo WebSocket 订阅句柄(通用事件流)。
pub struct MihomoWsEventStream<T> {
/// 当前订阅连接 ID,用于主动断开。
pub connection_id: ConnectionId,
pub connection_id: WebSocketConnectionId,
/// 当前订阅消息接收器。
receiver: mpsc::Receiver<InternalWsEvent<T>>,
/// 最近一次收到有效事件的时间戳。
@@ -162,7 +162,7 @@ impl<T> MihomoWsEventStream<T> {
///
/// # Arguments
/// * `connection_id` - 目标连接 ID
pub async fn disconnect_connection(connection_id: ConnectionId) {
pub async fn disconnect_connection(connection_id: WebSocketConnectionId) {
if let Err(err) = handle::Handle::mihomo()
.await
.disconnect(connection_id, Some(MIHOMO_WS_STREAM_CLOSE_CODE))
+24 -1
View File
@@ -4,7 +4,7 @@
//! These helpers are not fixes and may stop working as environments change.
use clash_verge_logging::{Type, logging};
use std::{fs, path::Path};
use std::{env, fs, path::Path, process::Command};
pub fn apply_nvidia_dmabuf_renderer_workaround() {
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some() {
@@ -23,6 +23,29 @@ pub fn apply_nvidia_dmabuf_renderer_workaround() {
}
}
/// !Might cause more memory footpoint
pub fn apply_wayland_webkit_fix() {
let is_wayland = env::var("XDG_SESSION_TYPE").unwrap_or_default() == "wayland";
if !is_wayland {
return;
}
let version = Command::new("pkg-config")
.args(["--modversion", "wayland-client"])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok());
if let Some(v) = version
&& v.trim() <= "1.23.0"
{
unsafe {
env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
}
fn has_nvidia_gpu() -> bool {
if Path::new("/proc/driver/nvidia/version").exists()
|| Path::new("/sys/module/nvidia").exists()
+11 -5
View File
@@ -8,12 +8,11 @@ use tauri::Url;
use crate::{
config::{Config, PrfItem, profiles},
core::{CoreManager, handle},
utils::help,
};
use clash_verge_logging::{Type, logging, logging_error};
pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
logging!(info, Type::Config, "received deep link: {param}");
let param_str = if param.starts_with("[") && param.len() > 4 {
param
.get(2..param.len() - 2)
@@ -21,12 +20,19 @@ pub(super) async fn resolve_scheme(param: &str) -> Result<()> {
} else {
param
};
let masked_deep_link = help::mask_url(param_str);
let link_parsed =
Url::parse(param_str).map_err(|e| anyhow::anyhow!("failed to parse deep link: {:?}, param: {:?}", e, param))?;
logging!(debug, Type::Config, "received deep link: {masked_deep_link}");
let link_parsed = Url::parse(param_str)
.map_err(|e| anyhow::anyhow!("failed to parse deep link: {e:?}, param: {masked_deep_link}"))?;
let Some((url, name)) = extract_subscription_info(&link_parsed) else {
logging!(error, Type::Config, "missing url parameter in deep link: {}", param_str);
logging!(
warn,
Type::Config,
"missing url parameter in deep link: {masked_deep_link}"
);
return Ok(());
};
+10 -1
View File
@@ -1,5 +1,6 @@
use dark_light::{Mode as SystemTheme, detect as detect_system_theme};
use tauri::utils::config::Color;
use tauri::webview::PageLoadEvent;
use tauri::{Theme, WebviewWindow};
use crate::{config::Config, core::handle, utils::resolve::window_script::build_window_initial_script};
@@ -67,7 +68,15 @@ pub async fn build_new_window() -> Result<WebviewWindow, String> {
.inner_size(DEFAULT_WIDTH, DEFAULT_HEIGHT)
.min_inner_size(MINIMAL_WIDTH, MINIMAL_HEIGHT)
.visible(false) // 等待主题色准备好后再展示,避免启动色差
.initialization_script(&initial_script);
.initialization_script(&initial_script)
.on_page_load(move |window, payload| {
if payload.event() != PageLoadEvent::Finished {
return;
}
logging_error!(Type::Window, window.show());
logging_error!(Type::Window, window.set_focus());
});
if let Some(theme) = resolved_theme {
builder = builder.theme(Some(theme));
+13 -2
View File
@@ -12,8 +12,8 @@ use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_app_kit::{
NSBaselineOffsetAttributeName, NSColor, NSFont, NSFontAttributeName, NSFontWeightRegular,
NSForegroundColorAttributeName, NSMutableParagraphStyle, NSParagraphStyleAttributeName, NSStatusItem,
NSTextAlignment,
NSForegroundColorAttributeName, NSMutableParagraphStyle, NSParagraphStyleAttributeName, NSStatusBarButton,
NSStatusItem, NSTextAlignment,
};
use objc2_foundation::{NSAttributedString, NSDictionary, NSNumber, NSString};
@@ -90,6 +90,16 @@ fn create_attributed_string(
}
}
fn sync_click_target_frame(button: &NSStatusBarButton) {
let bounds = button.bounds();
let subviews = button.subviews();
for index in 0..subviews.count() {
let subview = subviews.objectAtIndex(index);
subview.setFrame(bounds);
}
}
/// 在主线程下设置 NSStatusItem 按钮的富文本标题
///
/// 依赖 Tauri `with_inner_tray_icon` 保证回调在主线程执行;
@@ -113,6 +123,7 @@ fn apply_status_item_attributed_title(
};
let attr_str = create_attributed_string(text, attrs);
button.setAttributedTitle(&attr_str);
sync_click_target_frame(&button);
}
/// 将速率以富文本形式设置到 NSStatusItem 的按钮上
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.5.0-rc",
"version": "2.5.0",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
+11
View File
@@ -120,6 +120,11 @@
box-sizing: border-box;
height: 36px;
border-bottom: 1px solid var(--divider-color);
&-drag-region {
align-self: stretch;
flex: 1 1 auto;
}
}
.layout-content__left .the-logo {
@@ -148,6 +153,12 @@
box-sizing: border-box;
height: 36px;
border-bottom: 1px solid var(--divider-color);
&-drag-region {
align-self: stretch;
flex: 1 1 auto;
order: 1;
}
}
.layout-content__left {
+1
View File
@@ -12,6 +12,7 @@ export {
} from './base-split-chip-editor'
export { BaseStyledSelect } from './base-styled-select'
export { BaseStyledTextField } from './base-styled-text-field'
export { MonacoEditor } from './monaco-editor'
export { Switch } from './base-switch'
export { TooltipIcon } from './base-tooltip-icon'
export { VirtualList, type VirtualListHandle } from './virtual-list'
+32
View File
@@ -0,0 +1,32 @@
import { Suspense, lazy } from 'react'
import { loadMonacoEditor } from '@/services/monaco'
type MonacoEditorProps = import('@monaco-editor/react').EditorProps
let monacoEditorBundle: Awaited<ReturnType<typeof loadMonacoEditor>>
const MonacoEditorView = ({ beforeMount, ...props }: MonacoEditorProps) => {
const { Editor, beforeEditorMount } = monacoEditorBundle
return (
<Editor
{...props}
beforeMount={(monaco) => {
beforeEditorMount()
beforeMount?.(monaco)
}}
/>
)
}
const MonacoEditorContent = lazy(async () => {
monacoEditorBundle = await loadMonacoEditor()
return { default: MonacoEditorView }
})
export const MonacoEditor = (props: MonacoEditorProps) => (
<Suspense fallback={null}>
<MonacoEditorContent {...props} />
</Suspense>
)
+1 -27
View File
@@ -1,5 +1,4 @@
import { ViewColumnRounded } from '@mui/icons-material'
import { Box, IconButton, Tooltip } from '@mui/material'
import { Box } from '@mui/material'
import {
ColumnDef,
ColumnOrderState,
@@ -128,20 +127,6 @@ const SX_RESIZE_HANDLE: React.ComponentProps<typeof Box>['sx'] = {
},
}
const SX_ICON_BTN: React.ComponentProps<typeof IconButton>['sx'] = {
position: 'absolute',
top: 4,
right: 4,
zIndex: 3,
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? theme.palette.background.default
: theme.palette.background.paper,
'&:hover': {
backgroundColor: (theme) => theme.palette.action.hover,
},
}
const SX_HEADER_ROW: React.ComponentProps<typeof Box>['sx'] = {
display: 'flex',
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
@@ -311,7 +296,6 @@ interface Props {
connections: IConnectionsItem[]
onShowDetail: (data: IConnectionsItem) => void
columnManagerOpen: boolean
onOpenColumnManager: () => void
onCloseColumnManager: () => void
}
@@ -320,7 +304,6 @@ export const ConnectionTable = (props: Props) => {
connections,
onShowDetail: rawOnShowDetail,
columnManagerOpen,
onOpenColumnManager,
onCloseColumnManager,
} = props
const onShowDetailRef = useRef(rawOnShowDetail)
@@ -633,15 +616,6 @@ export const ConnectionTable = (props: Props) => {
return (
<>
<Box sx={SX_OUTER}>
<Tooltip title={t('connections.components.columnManager.title')}>
<IconButton
size="small"
onClick={onOpenColumnManager}
sx={SX_ICON_BTN}
>
<ViewColumnRounded fontSize="small" />
</IconButton>
</Tooltip>
<Box ref={tableContainerRef} sx={SX_SCROLL_CONTAINER}>
<Box
sx={{
+4 -7
View File
@@ -1,4 +1,3 @@
import MonacoEditor from '@monaco-editor/react'
import {
CloseFullscreenRounded,
ContentPasteRounded,
@@ -16,14 +15,13 @@ import {
} from '@mui/material'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useLockFn } from 'ahooks'
import type { editor } from 'monaco-editor'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseLoadingOverlay } from '@/components/base'
import { beforeEditorMount } from '@/services/monaco'
import { BaseLoadingOverlay, MonacoEditor } from '@/components/base'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { MonacoEditorInstance, MonacoMarker } from '@/types/monaco'
import debounce from '@/utils/debounce'
import getSystem from '@/utils/get-system'
@@ -44,7 +42,7 @@ export interface EditorViewerProps {
onChange?: (value: string) => void
onSave?: () => void | Promise<void>
onClose: () => void
onValidate?: (markers: editor.IMarker[]) => void
onValidate?: (markers: MonacoMarker[]) => void
}
export const EditorViewer = ({
@@ -65,7 +63,7 @@ export const EditorViewer = ({
const { t } = useTranslation()
const themeMode = useThemeMode()
const [isMaximized, setIsMaximized] = useState(false)
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const editorRef = useRef<MonacoEditorInstance | null>(null)
const resolvedTitle = title ?? t('profiles.components.menu.editFile')
const disableSave = loading || saveDisabled || dirty === false
@@ -218,7 +216,6 @@ export const EditorViewer = ({
loading={null}
saveViewState
keepCurrentModel={false}
beforeMount={beforeEditorMount}
onMount={(editorInstance) => {
editorRef.current = editorInstance
syncEditorValue()
@@ -8,7 +8,6 @@ import {
useSensors,
} from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import MonacoEditor from '@monaco-editor/react'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -34,7 +33,6 @@ import {
requestIdleCallback,
} from 'foxact/request-idle-callback'
import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import {
startTransition,
useCallback,
@@ -46,7 +44,12 @@ import {
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, Switch, VirtualList } from '@/components/base'
import {
BaseSearchBox,
MonacoEditor,
Switch,
VirtualList,
} from '@/components/base'
import { GroupItem } from '@/components/profile/group-item'
import {
getNetworkInterfaces,
@@ -56,6 +59,7 @@ import {
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import type { MonacoEditorInstance } from '@/types/monaco'
import getSystem from '@/utils/get-system'
interface Props {
@@ -151,7 +155,7 @@ export const GroupsEditorViewer = (props: Props) => {
[t],
)
const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const editorRef = useRef<MonacoEditorInstance | null>(null)
const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true)
@@ -8,7 +8,6 @@ import {
useSensors,
} from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import MonacoEditor from '@monaco-editor/react'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -27,7 +26,6 @@ import {
} from '@mui/material'
import { useLockFn } from 'ahooks'
import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import {
startTransition,
useCallback,
@@ -38,11 +36,12 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, VirtualList } from '@/components/base'
import { BaseSearchBox, MonacoEditor, VirtualList } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { MonacoEditorInstance } from '@/types/monaco'
import getSystem from '@/utils/get-system'
import parseUri from '@/utils/uri-parser'
@@ -58,7 +57,7 @@ export const ProxiesEditorViewer = (props: Props) => {
const { profileUid, property, open, onClose, onSave } = props
const { t } = useTranslation()
const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const editorRef = useRef<MonacoEditorInstance | null>(null)
const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true)
@@ -8,7 +8,6 @@ import {
useSensors,
} from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import MonacoEditor from '@monaco-editor/react'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -29,7 +28,6 @@ import {
} from '@mui/material'
import { useLockFn } from 'ahooks'
import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import {
startTransition,
useCallback,
@@ -40,12 +38,18 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox, Switch, VirtualList } from '@/components/base'
import {
BaseSearchBox,
MonacoEditor,
Switch,
VirtualList,
} from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import type { MonacoEditorInstance } from '@/types/monaco'
import getSystem from '@/utils/get-system'
import { isValidIpCidr } from '@/utils/network'
@@ -253,7 +257,7 @@ export const RulesEditorViewer = (props: Props) => {
const { t } = useTranslation()
const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const editorRef = useRef<MonacoEditorInstance | null>(null)
const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('')
+12 -1
View File
@@ -21,6 +21,7 @@ import {
DragIndicator,
Link,
LinkOff,
WarningRounded,
} from '@mui/icons-material'
import {
Alert,
@@ -40,6 +41,7 @@ import {
selectNodeForGroup,
} from 'tauri-plugin-mihomo-api'
import { TooltipIcon } from '@/components/base'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import { debugLog } from '@/utils/debug'
@@ -251,6 +253,7 @@ export const ProxyChain = ({
}: ProxyChainProps) => {
const theme = useTheme()
const { t } = useTranslation()
const chainWarning = t('proxies.page.chain.warning')
const { proxies } = useProxiesData()
const { refreshProxy } = useAppRefreshers()
const [isConnecting, setIsConnecting] = useState(false)
@@ -499,7 +502,15 @@ export const ProxyChain = ({
mb: 2,
}}
>
<Typography variant="h6">{t('proxies.page.chain.header')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Typography variant="h6">{t('proxies.page.chain.header')}</Typography>
<TooltipIcon
title={chainWarning}
icon={WarningRounded}
color="warning"
sx={{ p: 0.25 }}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{proxyChain.length > 0 && (
<IconButton
+99 -98
View File
@@ -9,8 +9,9 @@ import {
Snackbar,
Typography,
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { useQuery } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { useLockFn } from 'ahooks'
import {
type Key,
@@ -18,11 +19,13 @@ import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router'
import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
import { BaseEmpty } from '@/components/base'
@@ -65,6 +68,7 @@ interface ProxyChainItem {
export const ProxyGroups = (props: Props) => {
const { t } = useTranslation()
const { pathname } = useLocation()
const { mode, isChainMode = false, chainConfigData } = props
// Drive 3s polling on the shared TQ cache; data is read via granular context below
@@ -158,8 +162,40 @@ export const ProxyGroups = (props: Props) => {
const parentRef = useRef<HTMLDivElement>(null)
const scrollPositionRef = useRef<Record<string, number>>({})
const scrollTopRef = useRef(0)
const showScrollTopRef = useRef(false)
const activeStickyIndexRef = useRef<number | null>(null)
const restoredScrollKeyRef = useRef<string | null>(null)
const [showScrollTop, setShowScrollTop] = useState(false)
const scrollPositionKey = useMemo(
() =>
isChainMode
? `${mode}:chain:${activeSelectedGroup ?? 'all'}`
: `${mode}:normal`,
[activeSelectedGroup, isChainMode, mode],
)
const stickyGroupIndexes = useMemo(
() =>
renderList.flatMap((item, index) =>
item.type === 0 && !item.group.hidden ? [index] : [],
),
[renderList],
)
const rangeExtractor = useCallback(
(range: Parameters<typeof defaultRangeExtractor>[0]) => {
const activeStickyIndex = [...stickyGroupIndexes]
.reverse()
.find((index) => index <= range.startIndex)
activeStickyIndexRef.current = activeStickyIndex ?? null
const indexes = defaultRangeExtractor(range)
return activeStickyIndex == null || indexes.includes(activeStickyIndex)
? indexes
: [activeStickyIndex, ...indexes]
},
[stickyGroupIndexes],
)
const virtualizer = useVirtualizer({
count: renderList.length,
@@ -167,58 +203,49 @@ export const ProxyGroups = (props: Props) => {
estimateSize: () => 56,
overscan: 15,
getItemKey: (index) => renderList[index]?.key ?? index,
rangeExtractor,
})
const virtualItems = virtualizer.getVirtualItems()
const stickyGroupByIndex = useMemo(
() => getStickyGroupByIndex(renderList),
[renderList],
)
const stickyGroupItem = getStickyGroupItem(
stickyGroupByIndex,
virtualItems,
virtualizer.scrollOffset ?? 0,
)
const activeStickyIndex = activeStickyIndexRef.current
// 从 localStorage 恢复滚动位置
useEffect(() => {
useLayoutEffect(() => {
if (renderList.length === 0) return
let restoreTimer: ReturnType<typeof setTimeout> | null = null
const node = parentRef.current
if (!node) return
if (
restoredScrollKeyRef.current === scrollPositionKey &&
node.scrollTop === scrollTopRef.current
) {
return
}
try {
const savedPositions = localStorage.getItem('proxy-scroll-positions')
if (savedPositions) {
const positions = JSON.parse(savedPositions)
scrollPositionRef.current = positions
const savedPosition = positions[mode]
const savedPosition = positions[scrollPositionKey]
if (savedPosition !== undefined) {
restoreTimer = setTimeout(() => {
if (parentRef.current) {
parentRef.current.scrollTop = savedPosition
const nextShowScrollTop = savedPosition > 100
showScrollTopRef.current = nextShowScrollTop
setShowScrollTop(nextShowScrollTop)
}
}, 100)
node.scrollTop = savedPosition
scrollTopRef.current = savedPosition
const nextShowScrollTop = savedPosition > 100
showScrollTopRef.current = nextShowScrollTop
queueMicrotask(() => setShowScrollTop(nextShowScrollTop))
}
}
} catch (e) {
console.error('Error restoring scroll position:', e)
}
return () => {
if (restoreTimer) {
clearTimeout(restoreTimer)
}
}
}, [mode, renderList.length])
restoredScrollKeyRef.current = scrollPositionKey
}, [pathname, renderList.length, scrollPositionKey])
// 改为使用节流函数保存滚动位置
const saveScrollPosition = useCallback(
(scrollTop: number) => {
try {
scrollPositionRef.current[mode] = scrollTop
scrollPositionRef.current[scrollPositionKey] = scrollTop
localStorage.setItem(
'proxy-scroll-positions',
JSON.stringify(scrollPositionRef.current),
@@ -227,7 +254,7 @@ export const ProxyGroups = (props: Props) => {
console.error('Error saving scroll position:', e)
}
},
[mode],
[scrollPositionKey],
)
const saveScrollPositionThrottled = useMemo(
@@ -240,6 +267,7 @@ export const ProxyGroups = (props: Props) => {
const target = event.target as HTMLElement | null
const nextScrollTop = target?.scrollTop ?? 0
const nextShowScrollTop = nextScrollTop > 100
scrollTopRef.current = nextScrollTop
if (showScrollTopRef.current !== nextShowScrollTop) {
showScrollTopRef.current = nextShowScrollTop
@@ -262,9 +290,12 @@ export const ProxyGroups = (props: Props) => {
node.addEventListener('scroll', listener, options)
return () => {
if (restoredScrollKeyRef.current === scrollPositionKey) {
saveScrollPosition(scrollTopRef.current)
}
node.removeEventListener('scroll', listener, options)
}
}, [handleScroll])
}, [handleScroll, saveScrollPosition, scrollPositionKey])
// 滚动到顶部
const scrollToTop = useCallback(() => {
@@ -272,6 +303,7 @@ export const ProxyGroups = (props: Props) => {
top: 0,
behavior: 'smooth',
})
scrollTopRef.current = 0
saveScrollPosition(0)
}, [saveScrollPosition])
@@ -453,7 +485,7 @@ export const ProxyGroups = (props: Props) => {
totalSize={virtualizer.getTotalSize()}
virtualItems={virtualItems}
renderList={renderList}
stickyItem={stickyGroupItem}
activeStickyIndex={activeStickyIndex}
indent={mode === 'rule' || mode === 'script'}
isChainMode={isChainMode}
measureElement={virtualizer.measureElement}
@@ -564,7 +596,7 @@ interface ProxyVirtualListProps {
totalSize: number
virtualItems: VirtualListItem[]
renderList: IRenderItem[]
stickyItem: IRenderItem | null
activeStickyIndex: number | null
indent: boolean
isChainMode?: boolean
measureElement: (node: Element | null) => void
@@ -728,7 +760,7 @@ function ProxyVirtualList({
totalSize,
virtualItems,
renderList,
stickyItem,
activeStickyIndex,
indent,
isChainMode,
measureElement,
@@ -737,39 +769,12 @@ function ProxyVirtualList({
onHeadState,
onChangeProxy,
}: ProxyVirtualListProps) {
const theme = useTheme()
const stickyBackground =
theme.palette.mode === 'dark' ? '#1e1f27' : 'var(--background-color)'
return (
<div ref={parentRef} style={{ height, overflow: 'auto' }}>
{stickyItem && (
<Box
sx={{
position: 'sticky',
top: 0,
height: 0,
zIndex: 5,
pointerEvents: 'none',
}}
>
<Box
sx={{
pointerEvents: 'auto',
'& .MuiListItemButton-root': {
boxShadow: 3,
},
}}
>
<ProxyRender
item={stickyItem}
indent={indent}
onLocation={onLocation}
onCheckAll={onCheckAll}
onHeadState={onHeadState}
onChangeProxy={onChangeProxy}
isChainMode={isChainMode}
/>
</Box>
</Box>
)}
<div style={{ height: totalSize, position: 'relative' }}>
{virtualItems.map((virtualItem) => (
<div
@@ -777,11 +782,24 @@ function ProxyVirtualList({
data-index={virtualItem.index}
ref={measureElement}
style={{
position: 'absolute',
position:
virtualItem.index === activeStickyIndex ? 'sticky' : 'absolute',
top: 0,
left: 0,
zIndex: virtualItem.index === activeStickyIndex ? 5 : undefined,
display:
virtualItem.index === activeStickyIndex
? 'flow-root'
: undefined,
backgroundColor:
virtualItem.index === activeStickyIndex
? stickyBackground
: undefined,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
transform:
virtualItem.index === activeStickyIndex
? undefined
: `translateY(${virtualItem.start}px)`,
}}
>
<ProxyRender
@@ -801,30 +819,6 @@ function ProxyVirtualList({
)
}
function getStickyGroupByIndex(renderList: IRenderItem[]) {
let stickyGroup: IRenderItem | null = null
return renderList.map((item) => {
if (item?.type === 0 && item.group && !item.group.hidden) {
stickyGroup = item
}
return stickyGroup
})
}
function getStickyGroupItem(
stickyGroupByIndex: (IRenderItem | null)[],
virtualItems: VirtualListItem[],
scrollTop: number,
) {
const firstVisibleItem =
virtualItems.find((item) => item.end > scrollTop + 1) ?? virtualItems[0]
if (!firstVisibleItem) return null
return stickyGroupByIndex[firstVisibleItem.index] ?? null
}
// 替换简单防抖函数为更优的节流函数
function throttle<T extends (...args: any[]) => any>(
func: T,
@@ -832,23 +826,30 @@ function throttle<T extends (...args: any[]) => any>(
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
let previous = 0
let lastArgs: Parameters<T> | null = null
const run = (args: Parameters<T>) => {
previous = Date.now()
timer = null
lastArgs = null
func(...args)
}
return function (...args: Parameters<T>) {
const now = Date.now()
const remaining = wait - (now - previous)
lastArgs = args
if (remaining <= 0 || remaining > wait) {
if (timer) {
clearTimeout(timer)
timer = null
}
previous = now
func(...args)
run(args)
} else if (!timer) {
timer = setTimeout(() => {
previous = Date.now()
timer = null
func(...args)
if (lastArgs) {
run(lastArgs)
}
}, remaining)
}
}
+3 -4
View File
@@ -1,4 +1,3 @@
import MonacoEditor from '@monaco-editor/react'
import { RestartAltRounded } from '@mui/icons-material'
import {
Box,
@@ -16,7 +15,6 @@ import {
import { invoke } from '@tauri-apps/api/core'
import { useLockFn } from 'ahooks'
import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import type { Ref } from 'react'
import {
useCallback,
@@ -28,10 +26,11 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch } from '@/components/base'
import { BaseDialog, DialogRef, MonacoEditor, Switch } from '@/components/base'
import { useClash } from '@/hooks/use-clash'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { MonacoEditorInstance } from '@/types/monaco'
import { debugLog } from '@/utils/debug'
import getSystem from '@/utils/get-system'
@@ -190,7 +189,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [open, setOpen] = useState(false)
const [visualization, setVisualization] = useState(true)
const skipYamlSyncRef = useRef(false)
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const editorRef = useRef<MonacoEditorInstance | null>(null)
const [values, setValues] = useState<{
enable: boolean
listen: string
+290 -21
View File
@@ -1,4 +1,4 @@
import { Box, Button, LinearProgress } from '@mui/material'
import { alpha, Box, Button, LinearProgress } from '@mui/material'
import { relaunch } from '@tauri-apps/plugin-process'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import type { DownloadEvent } from '@tauri-apps/plugin-updater'
@@ -15,6 +15,95 @@ import { portableFlag } from '@/pages/_layout'
import { showNotice } from '@/services/notice-service'
import { useSetUpdateState, useUpdateState } from '@/services/states'
type MarkdownNode = {
type: string
value?: string
children?: MarkdownNode[]
data?: {
hProperties?: Record<string, unknown>
}
}
const GITHUB_ALERTS = {
note: { label: 'Note', color: '#0969da' },
tip: { label: 'Tip', color: '#1a7f37' },
important: { label: 'Important', color: '#8250df' },
warning: { label: 'Warning', color: '#9a6700' },
caution: { label: 'Caution', color: '#cf222e' },
} as const
type GitHubAlertType = keyof typeof GITHUB_ALERTS
const GITHUB_ALERT_PATTERN =
/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][\t ]*\n?/i
const GITHUB_ALERT_CLASS_PATTERN =
/markdown-alert-(note|tip|important|warning|caution)/
const getAlertTypeFromClassName = (
className: unknown,
): GitHubAlertType | null => {
const value = Array.isArray(className)
? className.join(' ')
: typeof className === 'string'
? className
: ''
const match = value.match(GITHUB_ALERT_CLASS_PATTERN)
return match?.[1] as GitHubAlertType | null
}
const findFirstTextNode = (node: MarkdownNode): MarkdownNode | null => {
if (node.type === 'text') return node
for (const child of node.children ?? []) {
const result = findFirstTextNode(child)
if (result) return result
}
return null
}
const remarkGitHubAlerts = () => {
const visit = (node: MarkdownNode) => {
for (const child of node.children ?? []) {
visit(child)
}
if (node.type !== 'blockquote') return
const firstTextNode = findFirstTextNode(node)
const match = firstTextNode?.value?.match(GITHUB_ALERT_PATTERN)
if (!firstTextNode?.value || !match) return
const alertType = match[1].toLowerCase() as GitHubAlertType
firstTextNode.value = firstTextNode.value
.replace(GITHUB_ALERT_PATTERN, '')
.replace(/^\n+/, '')
node.data = {
...(node.data ?? {}),
hProperties: {
...(node.data?.hProperties ?? {}),
className: ['markdown-alert', `markdown-alert-${alertType}`],
},
}
node.children?.unshift({
type: 'paragraph',
data: {
hProperties: {
className: 'markdown-alert-title',
},
},
children: [
{
type: 'text',
value: GITHUB_ALERTS[alertType].label,
},
],
})
}
return visit
}
export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation()
@@ -112,44 +201,224 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
<BaseDialog
open={open}
title={
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
{t('settings.modals.update.title', {
version: updateInfo?.version ?? '',
})}
<Box>
<Button
variant="contained"
size="small"
onClick={() => {
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
)
}}
>
{t('settings.modals.update.actions.goToRelease')}
</Button>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
minWidth: 0,
}}
>
<Box
component="span"
sx={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{t('settings.modals.update.title', {
version: updateInfo?.version ?? '',
})}
</Box>
<Button
variant="contained"
size="small"
sx={{ whiteSpace: 'nowrap' }}
onClick={() => {
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
)
}}
>
{t('settings.modals.update.actions.goToRelease')}
</Button>
</Box>
}
contentSx={{ minWidth: 360, maxWidth: 400, height: '50vh' }}
contentSx={{
width: { xs: 'calc(100vw - 56px)', sm: 560 },
maxWidth: 'calc(100vw - 56px)',
height: 'min(64vh, 680px)',
display: 'flex',
flexDirection: 'column',
pb: 1,
}}
okBtn={t('settings.modals.update.actions.update')}
cancelBtn={t('shared.actions.cancel')}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onUpdate}
>
<Box sx={{ height: 'calc(100% - 10px)', overflow: 'auto' }}>
<Box
sx={{
flex: 1,
minHeight: 0,
overflow: 'auto',
pr: 1.5,
mr: -1,
fontSize: 14,
lineHeight: 1.65,
color: 'text.primary',
overflowWrap: 'break-word',
'& > :first-child': {
mt: 0,
},
'& > :last-child': {
mb: 0,
},
'& h1': {
mt: 0,
mb: 1.5,
fontSize: 24,
lineHeight: 1.25,
},
'& h2': {
mt: 2.25,
mb: 1,
fontSize: 19,
lineHeight: 1.3,
},
'& h3': {
mt: 2,
mb: 0.75,
fontSize: 16,
lineHeight: 1.35,
},
'& h4, & h5, & h6': {
mt: 1.5,
mb: 0.75,
fontSize: 14,
lineHeight: 1.4,
},
'& p': {
my: 1,
},
'& ul, & ol': {
my: 1,
pl: 2.75,
},
'& li': {
my: 0.35,
pl: 0.25,
},
'& a': {
color: 'primary.main',
overflowWrap: 'anywhere',
},
'& strong': {
fontWeight: 700,
},
'& code': {
px: 0.5,
py: 0.125,
borderRadius: 0.5,
bgcolor: 'action.hover',
fontSize: '0.92em',
},
'& pre': {
my: 1.5,
p: 1.5,
overflow: 'auto',
borderRadius: 1,
bgcolor: 'action.hover',
},
'& pre code': {
p: 0,
bgcolor: 'transparent',
fontSize: '0.9em',
},
'& table': {
display: 'block',
width: '100%',
my: 1.5,
overflowX: 'auto',
borderCollapse: 'collapse',
},
'& th, & td': {
px: 1,
py: 0.75,
border: '1px solid',
borderColor: 'divider',
verticalAlign: 'top',
},
'& th': {
bgcolor: 'action.hover',
fontWeight: 700,
},
'& hr': {
my: 2,
border: 0,
borderTop: '1px solid',
borderColor: 'divider',
},
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: 1,
},
'& blockquote:not(.markdown-alert)': {
m: '12px 0 18px',
pl: 2,
color: 'text.secondary',
borderLeft: '4px solid',
borderColor: 'divider',
},
}}
>
<ReactMarkdown
remarkPlugins={[remarkGitHubAlerts]}
rehypePlugins={[rehypeRaw]}
components={{
a: ({ ...props }) => {
const { children } = props
return (
<a {...props} target="_blank">
<a {...props} target="_blank" rel="noreferrer">
{children}
</a>
)
},
blockquote: ({ className, children }) => {
const alertType = getAlertTypeFromClassName(className)
if (!alertType) {
return <blockquote className={className}>{children}</blockquote>
}
return (
<Box
component="blockquote"
className={className}
sx={(theme) => {
const color = GITHUB_ALERTS[alertType].color
return {
m: '12px 0 18px',
px: 2,
py: 1,
borderLeft: `4px solid ${color}`,
borderRadius: 1,
bgcolor: alpha(
color,
theme.palette.mode === 'dark' ? 0.16 : 0.08,
),
'& p': {
my: 0.75,
},
'& .markdown-alert-title': {
display: 'flex',
alignItems: 'center',
gap: 0.75,
fontWeight: 700,
lineHeight: 1.4,
},
}
}}
>
{children}
</Box>
)
},
}}
>
{markdownContent}
@@ -159,7 +428,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
<LinearProgress
variant={total > 0 ? 'determinate' : 'indeterminate'}
value={progress}
sx={{ marginTop: '5px' }}
sx={{ mt: 1 }}
/>
)}
</BaseDialog>
-10
View File
@@ -53,16 +53,6 @@
</head>
<body>
<div id="initial-loading-overlay"></div>
<script>
if (window.__TAURI_INTERNALS__) {
window.__TAURI_INTERNALS__
.invoke('plugin:window|show', { label: 'main' })
.catch(function () {});
window.__TAURI_INTERNALS__
.invoke('plugin:window|set_focus', { label: 'main' })
.catch(function () {});
}
</script>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "체인 프록시 구성",
"empty": "구성된 프록시 체인이 없습니다",
"warning": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"empty": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"instruction": "프록시 체인에 추가하려면 순서대로 노드를 클릭하세요",
"minimumNodes": "체인 프록시는 최소 2개의 노드가 필요합니다",
"minimumNodesHint": "체인 프록시는 최소 2개의 노드가 필요합니다. 하나 더 추가하세요.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Цепочка прокси",
"empty": "Цепочка прокси не настроена",
"warning": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"empty": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"instruction": "Нажимайте на узлы по порядку, чтобы добавить их в цепочку",
"minimumNodes": "Для цепочки требуется минимум 2 узла",
"minimumNodesHint": "Для цепочки требуется минимум 2 узла. Добавьте ещё один узел.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "Chain Proxy Config",
"empty": "No proxy chain configured",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "代理链配置",
"empty": "暂无代理链配置",
"warning": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"empty": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"instruction": "顺序点击节点添加到代理链中",
"minimumNodes": "链式代理至少需要 2 个节点",
"minimumNodesHint": "链式代理至少需要 2 个节点,请再添加一个节点。",
+2 -1
View File
@@ -43,7 +43,8 @@
},
"chain": {
"header": "鏈式代理設定",
"empty": "暫無鏈式代理設定",
"warning": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"empty": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"instruction": "依序點擊節點新增到鏈式代理中",
"minimumNodes": "鏈式代理至少需要 2 個節點",
"minimumNodesHint": "鏈式代理至少需要 2 個節點,請再新增一個節點。",
-1
View File
@@ -1,5 +1,4 @@
import './assets/styles/index.scss'
import './services/monaco'
import { ResizeObserver } from '@juggle/resize-observer'
import { QueryClientProvider } from '@tanstack/react-query'
+5 -1
View File
@@ -208,7 +208,11 @@ const Layout = () => {
const customTitlebar = useMemo(
() =>
!decorated ? (
<div className="the_titlebar" data-tauri-drag-region="true">
<div className="the_titlebar">
<div
className="the_titlebar-drag-region"
data-tauri-drag-region="true"
/>
<WindowControls ref={windowControlsRef} />
</div>
) : null,
+14 -1
View File
@@ -2,6 +2,7 @@ import {
DeleteForeverRounded,
TableChartRounded,
TableRowsRounded,
ViewColumnRounded,
} from '@mui/icons-material'
import {
Box,
@@ -10,6 +11,7 @@ import {
Fab,
IconButton,
MenuItem,
Tooltip,
Zoom,
} from '@mui/material'
import { useLockFn } from 'ahooks'
@@ -228,6 +230,18 @@ const ConnectionsPage = () => {
>
<BaseSearchBox onSearch={handleSearch} />
</Box>
{isTableLayout && hasTableData && (
<Tooltip title={t('connections.components.columnManager.title')}>
<IconButton
size="small"
aria-label={t('connections.components.columnManager.title')}
onClick={() => setIsColumnManagerOpen(true)}
sx={{ flex: '0 0 auto' }}
>
<ViewColumnRounded fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{!hasTableData ? (
@@ -239,7 +253,6 @@ const ConnectionsPage = () => {
detailRef.current?.open(detail, connectionsType === 'closed')
}
columnManagerOpen={isTableLayout && isColumnManagerOpen}
onOpenColumnManager={() => setIsColumnManagerOpen(true)}
onCloseColumnManager={() => setIsColumnManagerOpen(false)}
/>
) : (
+20 -5
View File
@@ -1,11 +1,11 @@
import { LanOutlined, LanRounded } from '@mui/icons-material'
import { LanOutlined, LanRounded, WarningRounded } from '@mui/icons-material'
import { Box, Button, ButtonGroup } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { BasePage } from '@/components/base'
import { BasePage, TooltipIcon } from '@/components/base'
import { ProviderButton } from '@/components/proxy/provider-button'
import { ProxyGroups } from '@/components/proxy/proxy-groups'
import { useVerge } from '@/hooks/use-verge'
@@ -54,6 +54,7 @@ const ProxyPage = () => {
const normalizedMode = clashConfig?.mode?.toLowerCase()
const curMode = isMode(normalizedMode) ? normalizedMode : undefined
const chainWarning = t('proxies.page.chain.warning')
const onChangeMode = useLockFn(async (mode: Mode) => {
// 断开连接
@@ -134,9 +135,23 @@ const ProxyPage = () => {
full
contentStyle={{ height: '101.5%' }}
title={
isChainMode
? t('proxies.page.title.chainMode')
: t('proxies.page.title.default')
isChainMode ? (
<Box
component="span"
data-tauri-drag-region="true"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}
>
{t('proxies.page.title.chainMode')}
<TooltipIcon
title={chainWarning}
icon={WarningRounded}
color="warning"
sx={{ p: 0.25 }}
/>
</Box>
) : (
t('proxies.page.title.default')
)
}
header={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
+79 -56
View File
@@ -1,70 +1,93 @@
import { loader } from '@monaco-editor/react'
import metaSchema from 'meta-json-schema/schemas/meta-json-schema.json'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { configureMonacoYaml, JSONSchema } from 'monaco-yaml'
import pac from 'types-pac/pac.d.ts?raw'
type MonacoModule = typeof import('monaco-editor')
type MonacoEditorPackage = typeof import('@monaco-editor/react')
import yamlWorker from '@/utils/yaml.worker?worker'
self.MonacoEnvironment = {
getWorker(_, label) {
switch (label) {
case 'css':
case 'less':
case 'scss':
return new cssWorker()
case 'typescript':
case 'javascript':
return new tsWorker()
case 'yaml':
return new yamlWorker()
default:
return new editorWorker()
}
},
type MonacoEditorBundle = {
Editor: MonacoEditorPackage['default']
beforeEditorMount: () => void
}
loader.config({ monaco })
let loadPromise: Promise<MonacoEditorBundle> | undefined
let monacoConfigured = false
// Work around https://github.com/remcohaszing/monaco-yaml/issues/272.
const patchCreateWebWorker = () => {
const oldCreateWebWorker = monaco.editor.createWebWorker
const patchCreateWebWorker = (monaco: MonacoModule) => {
const createWebWorker = monaco.editor.createWebWorker
monaco.editor.createWebWorker = (
options: monaco.IWebWorkerOptions | monaco.editor.IInternalWebWorkerOptions,
) => {
if ('worker' in options) {
return oldCreateWebWorker(options)
}
type CreateWebWorker = typeof createWebWorker
type WorkerOptions = Parameters<CreateWebWorker>[0] & { worker?: unknown }
return monaco.createWebWorker(options)
}
monaco.editor.createWebWorker = ((options: WorkerOptions) =>
'worker' in options
? createWebWorker(options)
: monaco.createWebWorker(options)) as CreateWebWorker
}
let mounted = false
export const loadMonacoEditor = () => {
loadPromise ??= Promise.all([
import('@monaco-editor/react'),
import('monaco-editor'),
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker'),
import('monaco-yaml'),
import('meta-json-schema/schemas/meta-json-schema.json'),
import('types-pac/pac.d.ts?raw'),
import('@/utils/yaml.worker?worker'),
]).then(
([
editorModule,
monaco,
{ default: EditorWorker },
{ default: CssWorker },
{ default: TsWorker },
{ configureMonacoYaml },
{ default: metaSchema },
{ default: pac },
{ default: YamlWorker },
]) => {
const workers = {
css: CssWorker,
less: CssWorker,
scss: CssWorker,
typescript: TsWorker,
javascript: TsWorker,
yaml: YamlWorker,
}
export const beforeEditorMount = () => {
if (mounted) return
self.MonacoEnvironment = {
getWorker(_, label) {
return new (workers[label as keyof typeof workers] ?? EditorWorker)()
},
}
patchCreateWebWorker()
editorModule.loader.config({ monaco })
monaco.typescript.javascriptDefaults.addExtraLib(pac, 'pac.d.ts')
return {
Editor: editorModule.default,
beforeEditorMount: () => {
if (monacoConfigured) return
configureMonacoYaml(monaco, {
validate: true,
enableSchemaRequest: true,
completion: true,
schemas: [
{
uri: 'http://example.com/meta-json-schema.json',
fileMatch: ['**/*.yaml', '**/*.yml'],
schema: metaSchema as unknown as JSONSchema, // JSON import is inferred as a literal type
},
],
})
patchCreateWebWorker(monaco)
monaco.typescript.javascriptDefaults.addExtraLib(pac, 'pac.d.ts')
mounted = true
configureMonacoYaml(monaco, {
validate: true,
enableSchemaRequest: true,
completion: true,
schemas: [
{
uri: 'http://example.com/meta-json-schema.json',
fileMatch: ['**/*.yaml', '**/*.yml'],
schema:
metaSchema as unknown as import('monaco-yaml').JSONSchema,
},
],
})
monacoConfigured = true
},
}
},
)
return loadPromise
}
+1
View File
@@ -238,6 +238,7 @@ export const translationKeys = [
'proxies.page.tooltips.filter',
'proxies.page.placeholders.delayCheckUrl',
'proxies.page.chain.header',
'proxies.page.chain.warning',
'proxies.page.chain.empty',
'proxies.page.chain.instruction',
'proxies.page.chain.minimumNodes',
+1
View File
@@ -439,6 +439,7 @@ export interface TranslationResources {
instruction: string
minimumNodes: string
minimumNodesHint: string
warning: string
}
labels: {
delayCheckReset: string
+4
View File
@@ -0,0 +1,4 @@
export type MonacoEditorInstance =
import('monaco-editor').editor.IStandaloneCodeEditor
export type MonacoMarker = import('monaco-editor').editor.IMarker
+1
View File
@@ -31,6 +31,7 @@ export const parseHotkey = (keyEvent: KeyboardEvent) => {
return 'ALT'
}
case 'META':
case 'OS':
return 'CMD'
case ' ':
return 'SPACE'