mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-17 14:50:48 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7ffb21294 | ||
|
|
0d0ed72467 | ||
|
|
a36dcd319f | ||
|
|
9a879dd8d8 | ||
|
|
20141e791e | ||
|
|
e385d37e5b | ||
|
|
2f097790ed | ||
|
|
619d6ae36a | ||
|
|
de24496fa7 | ||
|
|
f58e0a0af5 | ||
|
|
a8e8fe66dc | ||
|
|
75c921b466 | ||
|
|
e3bdea4386 | ||
|
|
ce963d175a | ||
|
|
d289a608d9 | ||
|
|
8a5548af10 | ||
|
|
1d9bca13c8 | ||
|
|
6f19c3dfaa |
@@ -30,11 +30,19 @@ jobs:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
DRAFT: ${{ github.event.pull_request.draft }}
|
||||
ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
run: |
|
||||
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
|
||||
echo "Draft or no-issue-needed; linkage check skipped."
|
||||
exit 0
|
||||
fi
|
||||
# OWNER/MEMBER/COLLABORATOR and bots mirror the slop review's
|
||||
# skip-roles/skip-bots trust tier.
|
||||
if [ "$AUTHOR_TYPE" = "Bot" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
|
||||
echo "Bot or trusted author ($ASSOCIATION); linkage check skipped."
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
|
||||
echo "Issue reference found."
|
||||
else
|
||||
|
||||
@@ -3,8 +3,8 @@ name: Auto Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
|
||||
- cron: '0 4,10 * * *'
|
||||
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
|
||||
- cron: '0 10,22 * * *'
|
||||
permissions: write-all
|
||||
env:
|
||||
TAG_NAME: autobuild
|
||||
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
|
||||
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
|
||||
with:
|
||||
version: v0.89.1
|
||||
|
||||
Generated
+457
-599
File diff suppressed because it is too large
Load Diff
+18
-6
@@ -46,6 +46,12 @@ inherits = "fast-release"
|
||||
codegen-units = 1
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[patch.crates-io]
|
||||
# macOS Golden Gate fix for tray-icon click reentrancy issue
|
||||
# Remove the following patch once the fix is merged upstream(tauri)
|
||||
# https://github.com/tauri-apps/tray-icon/pull/365
|
||||
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
|
||||
|
||||
[workspace.dependencies]
|
||||
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
||||
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
|
||||
@@ -55,10 +61,10 @@ clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
||||
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
||||
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
|
||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||
tracing = "0.1"
|
||||
tracing = "0.1.44"
|
||||
|
||||
tauri = { version = "2.11.5" }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
tauri-plugin-clipboard-manager = "2.3.3"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.104"
|
||||
chrono = "0.4.45"
|
||||
@@ -70,9 +76,15 @@ tokio = { version = "1.53.1", features = [
|
||||
"sync",
|
||||
] }
|
||||
flexi_logger = "0.31.10"
|
||||
log = "0.4.33"
|
||||
log = "0.4.34"
|
||||
regex = "1.13.1"
|
||||
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
|
||||
reqwest = { version = "0.13.5", features = [
|
||||
"json",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"rustls",
|
||||
"form",
|
||||
] }
|
||||
rust_iso3166 = "0.2.0"
|
||||
|
||||
smartstring = { version = "1.0.1" }
|
||||
@@ -80,7 +92,7 @@ smartstring = { version = "1.0.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_yaml_ng = { version = "0.10.0" }
|
||||
bitflags = { version = "2.13.1" }
|
||||
bitflags = { version = "2.13.2" }
|
||||
socket2 = "0.6.5"
|
||||
|
||||
# *** For Windows platform only ***
|
||||
@@ -93,7 +105,7 @@ windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_JobObjects"
|
||||
"Win32_System_JobObjects",
|
||||
] }
|
||||
# *********************************
|
||||
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
||||
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
@@ -60,6 +63,7 @@
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-i18n = "4.2.1"
|
||||
rust-i18n = "4.2.2"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
[lints]
|
||||
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.4-rc",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
@@ -56,13 +56,13 @@
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "~2.5.9",
|
||||
"@tauri-apps/plugin-opener": "2.5.4",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"@tauri-apps/plugin-http": "~2.6.0",
|
||||
"@tauri-apps/plugin-opener": "2.5.5",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"ahooks": "^3.9.7",
|
||||
"cidr-block": "^2.3.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dayjs": "1.11.21",
|
||||
"dayjs": "1.11.23",
|
||||
"foxact": "^0.3.4",
|
||||
"foxts": "^5.4.2",
|
||||
"i18next": "^26.2.0",
|
||||
@@ -73,11 +73,11 @@
|
||||
"monaco-yaml": "^5.5.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-error-boundary": "6.1.5",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "17.0.11",
|
||||
"react-i18next": "17.0.14",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -96,12 +96,12 @@
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@wdio/tauri-service": "1.3.0",
|
||||
"@wdio/tauri-service": "1.4.0",
|
||||
"adm-zip": "^0.6.0",
|
||||
"axios": "^1.16.1",
|
||||
"cli-color": "^2.0.4",
|
||||
@@ -121,7 +121,7 @@
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.32.1",
|
||||
"knip": "6.35.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
"tar": "^7.5.15",
|
||||
@@ -131,8 +131,8 @@
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.0",
|
||||
"webdriverio": "9.30.0",
|
||||
"ws": "8.21.0"
|
||||
"webdriverio": "9.31.9",
|
||||
"ws": "8.21.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
|
||||
Generated
+369
-464
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -78,7 +78,7 @@
|
||||
},
|
||||
{
|
||||
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
|
||||
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
|
||||
"matchPackageNames": ["pnpm"],
|
||||
"groupName": "pnpm"
|
||||
},
|
||||
{
|
||||
@@ -131,6 +131,16 @@
|
||||
],
|
||||
"depNameTemplate": "rust-lang/rust",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Update pnpm in devcontainer.json",
|
||||
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
|
||||
"matchStrings": [
|
||||
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "pnpm",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
|
||||
+18
-20
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.4"
|
||||
version = "2.5.4-rc"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -59,13 +59,13 @@ smartstring = { workspace = true, features = ["serde"] }
|
||||
bitflags = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
warp = { version = "0.4.3", features = ["server"] }
|
||||
open = "5.4.1"
|
||||
open = "5.4.4"
|
||||
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
|
||||
dirs = "7.0.0"
|
||||
dunce = "1.0.5"
|
||||
nanoid = "0.5"
|
||||
chrono = { workspace = true }
|
||||
boa_engine = "0.21.1"
|
||||
boa_engine = "0.22.0"
|
||||
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { workspace = true }
|
||||
@@ -74,37 +74,37 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
||||
"guard",
|
||||
] }
|
||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-shell = "2.3.6"
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
tauri-plugin-dialog = "2.7.3"
|
||||
tauri-plugin-fs = "2.5.2"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-deep-link = "2.4.10"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
zip = "8.6.0"
|
||||
flate2 = "1.1.9"
|
||||
flate2 = "1.1.10"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.11.0" }
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
base64 = "0.23.1"
|
||||
sha2 = "0.11"
|
||||
getrandom = "0.4.3"
|
||||
futures = "0.3.34"
|
||||
gethostname = "1.1.0"
|
||||
scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-notification = "2.4.0"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.5.9"
|
||||
tauri-plugin-http = "2.6.0"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.1.0", optional = true }
|
||||
tauri-plugin-devtools = { version = "2.2.1", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.9", version = "2.6.9", features = [
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.1", version = "2.7.1", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.4"
|
||||
rustls = { version = "0.23.43", features = ["ring"] }
|
||||
tokio-rustls = "0.26.5"
|
||||
rustls = { version = "0.23.45", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
dark-light = "3.0.0"
|
||||
bytes = "1.12.1"
|
||||
@@ -114,9 +114,7 @@ tokio-util = { version = "0.7.19", features = ["time"] }
|
||||
libc = "0.2.189"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
|
||||
core-foundation = "0.9"
|
||||
system-configuration = "0.7"
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||
objc2 = "0.6.4"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
@@ -150,7 +148,7 @@ windows-sys.workspace = true
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2.5.1"
|
||||
tauri-plugin-global-shortcut = "2.3.2"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -57,6 +57,11 @@ pub async fn continue_with_sidecar() -> CmdResult {
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_fallback_notice() -> bool {
|
||||
crate::core::service::take_service_fallback_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_runtime_providers() {
|
||||
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
||||
|
||||
@@ -185,6 +185,42 @@ where
|
||||
apply_proxy().await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Fallback, FallbackFuture>(
|
||||
is_admin: bool,
|
||||
start_service: Start,
|
||||
start_sidecar: Fallback,
|
||||
) -> Result<()>
|
||||
where
|
||||
Start: FnOnce() -> StartFuture,
|
||||
StartFuture: std::future::Future<Output = Result<()>>,
|
||||
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
|
||||
FallbackFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let Err(error) = start_service().await else {
|
||||
return Ok(());
|
||||
};
|
||||
// A transport failure cannot establish whether the service already started the core.
|
||||
let location_refused = error
|
||||
.downcast_ref::<crate::core::service::ServiceStartRefusal>()
|
||||
.is_some_and(|refusal| {
|
||||
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
|
||||
});
|
||||
if !is_admin || !location_refused {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service refused the core location while app is elevated; falling back to sidecar: {error:#}"
|
||||
);
|
||||
let reason = format!("{error:#}");
|
||||
start_sidecar(error)
|
||||
.await
|
||||
.map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}")))
|
||||
}
|
||||
|
||||
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
|
||||
clear_proxy: Clear,
|
||||
terminate_sidecar: Terminate,
|
||||
@@ -476,10 +512,7 @@ impl CoreManager {
|
||||
// rather than fail — only if the watcher is actually live; a user toggling while
|
||||
// offline still fails fast.
|
||||
#[cfg(target_os = "macos")]
|
||||
let watcher_armed = crate::core::network_watch::is_armed();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let watcher_armed = false;
|
||||
if watcher_armed
|
||||
if crate::core::network_watch::is_armed()
|
||||
&& !crate::utils::resolve::is_resolve_done()
|
||||
&& Config::verge()
|
||||
.await
|
||||
@@ -553,7 +586,30 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
let result = match startup {
|
||||
StartupDecision::Service => self.start_core_by_service().await,
|
||||
StartupDecision::Service => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
run_service_start_with_sidecar_fallback(
|
||||
crate::core::runstate::RUN_STATE.state().is_admin,
|
||||
|| self.start_core_by_service(),
|
||||
|error| async move {
|
||||
use crate::core::runstate::RUN_STATE;
|
||||
|
||||
RUN_STATE.allow_sidecar_after_service_refusal(format!("{error:#}"))?;
|
||||
let result = self.start_core_by_sidecar().await;
|
||||
if result.is_err() {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
} else {
|
||||
crate::core::service::notify_service_fallback();
|
||||
}
|
||||
result
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
self.start_core_by_service().await
|
||||
}
|
||||
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
|
||||
StartupDecision::Wait => Ok(()),
|
||||
};
|
||||
|
||||
@@ -18,6 +18,44 @@ use tauri_plugin_shell::ShellExt as _;
|
||||
|
||||
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn retry_service_start<Start, StartFuture>(
|
||||
attempts: usize,
|
||||
retry_delay: std::time::Duration,
|
||||
mut start: Start,
|
||||
) -> Result<()>
|
||||
where
|
||||
Start: FnMut() -> StartFuture,
|
||||
StartFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let mut last_error = None;
|
||||
for attempt in 0..attempts {
|
||||
match start().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service start attempt {}/{} failed: {error:#}",
|
||||
attempt + 1,
|
||||
attempts
|
||||
);
|
||||
if error
|
||||
.downcast_ref::<service::ServiceStartRefusal>()
|
||||
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
last_error = Some(error);
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
/// Restores profile selections before callers enable the system proxy.
|
||||
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
|
||||
@@ -269,30 +307,15 @@ impl CoreManager {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::constants::timing;
|
||||
let mut last_err = None;
|
||||
for attempt in 0..timing::SERVICE_START_RETRIES {
|
||||
match service::run_core_by_service(config_file).await {
|
||||
Ok(()) => {
|
||||
self.mark_core_ready();
|
||||
self.core_started(RunningMode::Service);
|
||||
self.restore_selected_nodes().await;
|
||||
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service start attempt {}/{} failed: {e:#}",
|
||||
attempt + 1,
|
||||
timing::SERVICE_START_RETRIES
|
||||
);
|
||||
last_err = Some(e);
|
||||
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
|
||||
retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
|
||||
service::run_core_by_service(config_file)
|
||||
})
|
||||
.await?;
|
||||
self.mark_core_ready();
|
||||
self.core_started(RunningMode::Service);
|
||||
self.restore_selected_nodes().await;
|
||||
service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
|
||||
@@ -5,53 +5,35 @@
|
||||
|
||||
use crate::{config::Config, core::CoreManager, core::proxy_control, process::AsyncHandler};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use core_foundation::{
|
||||
array::CFArray,
|
||||
runloop::{CFRunLoop, kCFRunLoopCommonModes},
|
||||
string::CFString,
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext};
|
||||
use parking_lot::Mutex;
|
||||
use sysproxy::NetworkServiceMonitor;
|
||||
|
||||
/// Set once the subscription is live. Startup defers the proxy write to the watcher only then.
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static MONITOR: Mutex<Option<NetworkServiceMonitor>> = Mutex::new(None);
|
||||
|
||||
pub fn is_armed() -> bool {
|
||||
ARMED.load(Ordering::Acquire)
|
||||
MONITOR.lock().is_some()
|
||||
}
|
||||
|
||||
/// Subscribe to the primary IPv4 service on the main run loop. Armed on return, so a service
|
||||
/// appearing from here on is never missed.
|
||||
pub fn start() {
|
||||
let store = SCDynamicStoreBuilder::new("clash-verge-rev")
|
||||
.callback_context(SCDynamicStoreCallBackContext {
|
||||
callout: on_change,
|
||||
info: (),
|
||||
})
|
||||
.build();
|
||||
let keys = CFArray::from_CFTypes(&[CFString::from_static_string("State:/Network/Global/IPv4")]);
|
||||
let Some(source) = store
|
||||
.filter(|store| store.set_notification_keys(&keys, &CFArray::<CFString>::from_CFTypes(&[])))
|
||||
.and_then(|store| store.create_run_loop_source())
|
||||
else {
|
||||
logging!(
|
||||
match NetworkServiceMonitor::start(|| {
|
||||
AsyncHandler::spawn(reapply);
|
||||
}) {
|
||||
Ok(monitor) => {
|
||||
*MONITOR.lock() = Some(monitor);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Core,
|
||||
"network watch armed; the system proxy is re-applied on network changes"
|
||||
);
|
||||
}
|
||||
Err(error) => logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"could not watch the network; the proxy is not re-applied on changes"
|
||||
);
|
||||
return;
|
||||
};
|
||||
CFRunLoop::get_main().add_source(&source, unsafe { kCFRunLoopCommonModes });
|
||||
ARMED.store(true, Ordering::Release);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Core,
|
||||
"network watch armed; the system proxy is re-applied on network changes"
|
||||
);
|
||||
}
|
||||
|
||||
fn on_change(_: SCDynamicStore, _: CFArray<CFString>, (): &mut ()) {
|
||||
AsyncHandler::spawn(reapply);
|
||||
"could not watch the network; the proxy is not re-applied on changes: {error}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reapply() {
|
||||
@@ -66,10 +48,17 @@ async fn reapply() {
|
||||
.latest_arc()
|
||||
.enable_system_proxy
|
||||
.unwrap_or_default()
|
||||
|| !proxy_control::has_network_service().await
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !proxy_control::has_network_service().await {
|
||||
logging!(
|
||||
debug,
|
||||
Type::Core,
|
||||
"network changed without a service to write the proxy on; waiting for the next change"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = manager.apply_proxy_after_start().await {
|
||||
logging!(
|
||||
warn,
|
||||
|
||||
@@ -385,12 +385,11 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
|
||||
}
|
||||
|
||||
/// Whether macOS has a network service to write the proxy on right now.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn has_network_service() -> bool {
|
||||
tokio::task::spawn_blocking(
|
||||
|| !matches!(sysproxy::Sysproxy::get_system_proxy(), Err(error) if is_missing_network_service(&error)),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
tokio::task::spawn_blocking(|| sysproxy::Sysproxy::has_network_service().unwrap_or(true))
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn is_reportable(error: &anyhow::Error) -> bool {
|
||||
|
||||
@@ -323,6 +323,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
|
||||
let mut state = self.service.lock();
|
||||
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
|
||||
bail!("cannot fall back while a service operation is pending");
|
||||
}
|
||||
// Publish the refusal and allowance together so it never asks the UI for a repair.
|
||||
state.service.health = ServiceHealth::Unavailable(reason);
|
||||
state.service.allow_sidecar();
|
||||
state.bump();
|
||||
drop(state);
|
||||
self.announce();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Requests installation only for a confirmed absent Service.
|
||||
pub fn require_install_for_session(&self) -> Result<()> {
|
||||
let mut state = self.service.lock();
|
||||
|
||||
@@ -39,6 +39,17 @@ use std::{
|
||||
|
||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn notify_service_fallback() {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::sidecar_fallback", "");
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_fallback_notice() -> bool {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Capabilities of the service session that owns the running Core.
|
||||
/// They are discarded with that session rather than cached across service upgrades.
|
||||
@@ -564,8 +575,39 @@ fn uninstall_service() -> Result<()> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_service() -> Result<()> {
|
||||
use std::process::Output;
|
||||
logging!(info, Type::Service, "install service");
|
||||
let cores = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| service_core_path(core, ".exe"))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
install_windows_service_with_cores(&cores, run_windows_service_installer)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_windows_service_with_cores(
|
||||
cores: &[PathBuf],
|
||||
mut run_installer: impl FnMut(&[std::ffi::OsString]) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let cores: Vec<_> = cores.iter().filter(|core| core.is_file()).collect();
|
||||
if cores.is_empty() {
|
||||
bail!("no core executable is available; restore a bundled core before installing the service");
|
||||
}
|
||||
let mut arguments = vec!["--install-service".into()];
|
||||
for core in cores {
|
||||
let digest = sha256_hex(core)?;
|
||||
arguments.extend([
|
||||
"--install-core".into(),
|
||||
core.as_os_str().to_owned(),
|
||||
"--sha256".into(),
|
||||
digest.into(),
|
||||
]);
|
||||
}
|
||||
run_installer(&arguments).context("failed to install the service and its cores; choose Repair to retry")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_service_installer(arguments: &[std::ffi::OsString]) -> Result<()> {
|
||||
use std::process::Output;
|
||||
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
@@ -583,7 +625,7 @@ fn install_service() -> Result<()> {
|
||||
let level = token.privilege_level()?;
|
||||
let output = match level {
|
||||
PrivilegeLevel::NotPrivileged => {
|
||||
let status = RunasCommand::new(&install_path).show(false).status()?;
|
||||
let status = RunasCommand::new(&install_path).args(arguments).show(false).status()?;
|
||||
Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
@@ -592,7 +634,10 @@ fn install_service() -> Result<()> {
|
||||
}
|
||||
_ => {
|
||||
// StdCommand returns Output directly
|
||||
StdCommand::new(&install_path).creation_flags(0x08000000).output()?
|
||||
StdCommand::new(&install_path)
|
||||
.args(arguments)
|
||||
.creation_flags(0x08000000)
|
||||
.output()?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1042,6 +1087,25 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
|
||||
.context("Clash Verge Service 未返回运行时暂存结果")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ServiceStartRefusal {
|
||||
pub code: u16,
|
||||
pub core_path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServiceStartRefusal {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"failed to start Service core at {} (code {}): {}",
|
||||
self.core_path, self.code, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceStartRefusal {}
|
||||
|
||||
/// 尝试使用服务启动core
|
||||
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
||||
@@ -1077,10 +1141,12 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
err_msg
|
||||
);
|
||||
start_owner_monitor();
|
||||
bail!(
|
||||
"failed to start Service core at {}: {err_msg}",
|
||||
request.runtime.core_path
|
||||
);
|
||||
return Err(ServiceStartRefusal {
|
||||
code: response.code,
|
||||
core_path: request.runtime.core_path,
|
||||
message: err_msg,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
|
||||
@@ -1099,6 +1165,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
||||
start_owner_monitor();
|
||||
tracing::Span::current().record("outcome", "started");
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
@@ -2167,6 +2234,82 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_combines_service_and_attested_cores() -> anyhow::Result<()> {
|
||||
use std::ffi::OsString;
|
||||
let root = TestDirectory::new("scoop install")?;
|
||||
let cores: Vec<_> = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|name| root.path().join(format!("{name}.exe")))
|
||||
.collect();
|
||||
for core in &cores {
|
||||
std::fs::write(core, b"abc")?;
|
||||
}
|
||||
let mut calls = Vec::new();
|
||||
super::install_windows_service_with_cores(&cores, |arguments| {
|
||||
calls.push(arguments.to_vec());
|
||||
Ok(())
|
||||
})?;
|
||||
assert_eq!(calls.len(), 1, "service and cores must share one elevation");
|
||||
let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
let mut expected: Vec<OsString> = vec!["--install-service".into()];
|
||||
expected.extend(cores.iter().flat_map(|core| {
|
||||
[
|
||||
"--install-core".into(),
|
||||
core.as_os_str().to_owned(),
|
||||
"--sha256".into(),
|
||||
digest.into(),
|
||||
]
|
||||
}));
|
||||
assert_eq!(calls[0], expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_skips_unavailable_cores() -> anyhow::Result<()> {
|
||||
let root = TestDirectory::new("missing-core")?;
|
||||
let core = root.path().join("verge-mihomo.exe");
|
||||
let missing = root.path().join("verge-mihomo-alpha.exe");
|
||||
std::fs::write(&core, b"abc")?;
|
||||
let cores = [core.clone(), missing.clone(), root.path().to_path_buf()];
|
||||
let mut calls = Vec::new();
|
||||
super::install_windows_service_with_cores(&cores, |arguments| {
|
||||
calls.push(arguments.to_vec());
|
||||
Ok(())
|
||||
})?;
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].len(), 5);
|
||||
assert_eq!(calls[0][0], "--install-service");
|
||||
assert_eq!(calls[0][2], core.as_os_str());
|
||||
|
||||
let error = super::install_windows_service_with_cores(&[missing, root.path().to_path_buf()], |_| {
|
||||
panic!("no available core must fail before invoking the installer")
|
||||
})
|
||||
.expect_err("installation requires at least one available core");
|
||||
assert!(error.to_string().contains("no core executable"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_propagates_installer_failure() -> anyhow::Result<()> {
|
||||
let root = TestDirectory::new("install-failure")?;
|
||||
let core = root.path().join("verge-mihomo.exe");
|
||||
std::fs::write(&core, b"abc")?;
|
||||
let mut calls = 0;
|
||||
let result = super::install_windows_service_with_cores(std::slice::from_ref(&core), |_| {
|
||||
calls += 1;
|
||||
bail!("installer failed with exit code 1")
|
||||
});
|
||||
let error = result.expect_err("installation failure must not be reported as success");
|
||||
assert!(format!("{error:#}").contains("exit code 1"));
|
||||
assert!(error.to_string().contains("Repair"));
|
||||
assert_eq!(calls, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn staging_directory(home: &Path) -> PathBuf {
|
||||
home.join("Applications/.clash-verge-rev-dev/service-core")
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ mod app_init {
|
||||
cmd::apply_dns_config,
|
||||
cmd::set_dns_override,
|
||||
cmd::take_dns_override_notice,
|
||||
cmd::take_service_fallback_notice,
|
||||
cmd::get_dns_config_content,
|
||||
cmd::validate_dns_config,
|
||||
cmd::get_clash_logs,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.4-rc",
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
||||
@@ -13,12 +13,24 @@ interface SortableItemProps {
|
||||
index: number
|
||||
group?: string
|
||||
disabled?: boolean
|
||||
// 用于给 tanstack virtual 列表测量当前拖动元素尺寸
|
||||
measureElement?: (element: HTMLDivElement | null) => void
|
||||
dataIndex?: number
|
||||
style?: CSSProperties
|
||||
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
|
||||
}
|
||||
|
||||
export const SortableItem = (props: SortableItemProps) => {
|
||||
const { id, index, group, disabled, children, style } = props
|
||||
const {
|
||||
id,
|
||||
index,
|
||||
group,
|
||||
disabled,
|
||||
measureElement,
|
||||
dataIndex,
|
||||
children,
|
||||
style,
|
||||
} = props
|
||||
const { ref, handleRef, sortable, isDragging } = useSortable({
|
||||
id,
|
||||
index,
|
||||
@@ -30,8 +42,9 @@ export const SortableItem = (props: SortableItemProps) => {
|
||||
(element: HTMLDivElement | null) => {
|
||||
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
|
||||
ref(element)
|
||||
measureElement?.(element)
|
||||
},
|
||||
[handleRef, ref],
|
||||
[handleRef, ref, measureElement],
|
||||
)
|
||||
|
||||
const mergedStyle: CSSProperties = {
|
||||
@@ -53,7 +66,7 @@ export const SortableItem = (props: SortableItemProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setRef} style={mergedStyle}>
|
||||
<div ref={setRef} data-index={dataIndex} style={mergedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ export const formatConnectionTraffic = (value?: number) => {
|
||||
? 0
|
||||
: Math.min(Math.floor(Math.log2(value) / 10), TRAFFIC_UNITS.length - 1)
|
||||
const data = value / 1024 ** exp
|
||||
const text = data >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
||||
const text = Math.round(data) >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
||||
return `${text} ${TRAFFIC_UNITS[exp]}`
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,8 @@ export const SystemInfoCard = () => {
|
||||
goToSettings()
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice.error(err)
|
||||
console.warn('update check failed:', err)
|
||||
showNotice.warning(err)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type ServiceRequest,
|
||||
type ServiceRequestReason,
|
||||
} from '@/services/service-request'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
type Remedy = 'installAndRestart' | 'restartOnly'
|
||||
|
||||
@@ -98,19 +99,25 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
setStep('restarting')
|
||||
await restartCore()
|
||||
|
||||
// Verify that restart actually moved the core into the service.
|
||||
const runState = await getRuntimeState()
|
||||
if (runState.mode === 'Service') {
|
||||
// Retry the original request now that the service can apply it.
|
||||
const usingAdminFallback =
|
||||
getSystem() === 'windows' &&
|
||||
runState.mode === 'Sidecar' &&
|
||||
runState.isAdmin &&
|
||||
runState.sidecarAllowed &&
|
||||
runState.service === 'unavailable'
|
||||
if (runState.mode === 'Service' || usingAdminFallback) {
|
||||
if (restoring !== undefined) {
|
||||
setStep('applying')
|
||||
await patchVergeConfig(restoring)
|
||||
}
|
||||
showNotice.success(
|
||||
restoring === undefined
|
||||
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
||||
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||
)
|
||||
if (!usingAdminFallback) {
|
||||
showNotice.success(
|
||||
restoring === undefined
|
||||
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
||||
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||
)
|
||||
}
|
||||
close()
|
||||
} else {
|
||||
showNotice.error(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { move } from '@dnd-kit/helpers'
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type CSSProperties, type ReactNode, useRef, useState } from 'react'
|
||||
|
||||
import { SortableItem } from '../base'
|
||||
|
||||
type GroupedVirtualCategory = 'prepend' | 'original' | 'append'
|
||||
|
||||
export interface GroupedVirtualItem<T> {
|
||||
id: string
|
||||
category: GroupedVirtualCategory
|
||||
item: T
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function buildGroupedItems<T>(
|
||||
prepend: T[],
|
||||
original: T[],
|
||||
append: T[],
|
||||
getKey: (item: T) => string,
|
||||
): GroupedVirtualItem<T>[] {
|
||||
const build = (
|
||||
category: GroupedVirtualCategory,
|
||||
list: T[],
|
||||
): GroupedVirtualItem<T>[] =>
|
||||
list.map((item) => ({
|
||||
id: `${category}:${getKey(item)}`,
|
||||
category,
|
||||
item,
|
||||
}))
|
||||
|
||||
return [
|
||||
...build('prepend', prepend),
|
||||
...build('original', original),
|
||||
...build('append', append),
|
||||
]
|
||||
}
|
||||
|
||||
interface GroupedVirtualListProps<T> {
|
||||
items: GroupedVirtualItem<T>[]
|
||||
estimateSize?: number
|
||||
renderItem: (entry: GroupedVirtualItem<T>) => ReactNode
|
||||
onReorder: (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => void
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于编辑规则/节点/代理组功能的 prepend/original/append 分组虚拟化列表
|
||||
*/
|
||||
export function GroupedVirtualList<T>(props: GroupedVirtualListProps<T>) {
|
||||
const {
|
||||
items: itemsProp,
|
||||
estimateSize = 56,
|
||||
renderItem,
|
||||
onReorder,
|
||||
style,
|
||||
} = props
|
||||
const [items, setItems] = useState(itemsProp)
|
||||
const [prevItemsProp, setPrevItemsProp] = useState(itemsProp)
|
||||
const snapshotRef = useRef(itemsProp)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
if (itemsProp !== prevItemsProp) {
|
||||
setPrevItemsProp(itemsProp)
|
||||
setItems(itemsProp)
|
||||
}
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => estimateSize,
|
||||
overscan: 15,
|
||||
gap: 8,
|
||||
getItemKey: (index) => items[index]?.id ?? index,
|
||||
})
|
||||
|
||||
const onDragStart = () => {
|
||||
snapshotRef.current = items
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (!source || !target) return
|
||||
|
||||
const sourceEntry = items.find((entry) => entry.id === source.id)
|
||||
const targetEntry = items.find((entry) => entry.id === target.id)
|
||||
if (
|
||||
!sourceEntry ||
|
||||
!targetEntry ||
|
||||
sourceEntry.category !== targetEntry.category
|
||||
) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
setItems((current) => move(current, event))
|
||||
}
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
|
||||
if (event.canceled) {
|
||||
setItems(snapshotRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const snapshot = snapshotRef.current
|
||||
const sourceEntry = snapshot.find((entry) => entry.id === source.id)
|
||||
if (!sourceEntry) return
|
||||
|
||||
const { category } = sourceEntry
|
||||
if (category !== 'prepend' && category !== 'append') return
|
||||
|
||||
const activeIndex = snapshot
|
||||
.filter((entry) => entry.category === category)
|
||||
.findIndex((entry) => entry.id === source.id)
|
||||
const overIndex = items
|
||||
.filter((entry) => entry.category === category)
|
||||
.findIndex((entry) => entry.id === source.id)
|
||||
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) return
|
||||
|
||||
onReorder(category, activeIndex, overIndex)
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div ref={scrollRef} style={{ ...style, overflow: 'auto' }}>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: 'relative',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vi) => {
|
||||
const entry = items[vi.index]
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<SortableItem
|
||||
key={vi.key}
|
||||
id={entry.id}
|
||||
index={vi.index}
|
||||
disabled={entry.category === 'original'}
|
||||
measureElement={virtualizer.measureElement}
|
||||
dataIndex={vi.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${vi.start}px`,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{renderItem(entry)}
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
import { arrayMove } from '@dnd-kit/helpers'
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import {
|
||||
VerticalAlignBottomRounded,
|
||||
VerticalAlignTopRounded,
|
||||
@@ -43,13 +35,7 @@ import {
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BaseSearchBox,
|
||||
MonacoEditor,
|
||||
SortableItem,
|
||||
Switch,
|
||||
VirtualList,
|
||||
} from '@/components/base'
|
||||
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
||||
import { GroupItem } from '@/components/profile/group-item'
|
||||
import {
|
||||
getNetworkInterfaces,
|
||||
@@ -63,6 +49,12 @@ import type { MonacoEditorInstance } from '@/types/monaco'
|
||||
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
||||
import { parseYamlSafe } from '@/utils/yaml'
|
||||
|
||||
import {
|
||||
buildGroupedItems,
|
||||
type GroupedVirtualItem,
|
||||
GroupedVirtualList,
|
||||
} from './grouped-virtual-list'
|
||||
|
||||
interface Props {
|
||||
proxiesUid: string
|
||||
mergeUid: string
|
||||
@@ -203,106 +195,71 @@ export const GroupsEditorViewer = (props: Props) => {
|
||||
[appendSeq, match],
|
||||
)
|
||||
|
||||
const renderItem = (index: number): React.ReactNode => {
|
||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildGroupedItems(
|
||||
filteredPrependSeq,
|
||||
filteredGroupList,
|
||||
filteredAppendSeq,
|
||||
(group) => group.name,
|
||||
),
|
||||
[filteredPrependSeq, filteredGroupList, filteredAppendSeq],
|
||||
)
|
||||
|
||||
const renderItem = (entry: GroupedVirtualItem<IProxyGroupConfig>) => {
|
||||
const { category, item } = entry
|
||||
|
||||
if (category === 'original') {
|
||||
const isDeleted = deleteSeq.includes(item.name)
|
||||
return (
|
||||
<>
|
||||
{filteredPrependSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`prepend:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="prepend"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<GroupItem
|
||||
type="prepend"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(
|
||||
prependSeq.filter((v) => v.name !== item.name),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else if (index < filteredGroupList.length + shift) {
|
||||
const newIndex = index - shift
|
||||
return (
|
||||
<Box sx={{ margin: '8px 0' }}>
|
||||
<GroupItem
|
||||
key={filteredGroupList[newIndex].name}
|
||||
type={
|
||||
deleteSeq.includes(filteredGroupList[newIndex].name)
|
||||
? 'delete'
|
||||
: 'original'
|
||||
<GroupItem
|
||||
type={isDeleted ? 'delete' : 'original'}
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
if (isDeleted) {
|
||||
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, item.name])
|
||||
}
|
||||
group={filteredGroupList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter(
|
||||
(v) => v !== filteredGroupList[newIndex].name,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setDeleteSeq((prev) => [
|
||||
...prev,
|
||||
filteredGroupList[newIndex].name,
|
||||
])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (category === 'prepend') {
|
||||
return (
|
||||
<>
|
||||
{filteredAppendSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`append:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="append"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<GroupItem
|
||||
type="append"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<GroupItem
|
||||
type="prepend"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupItem
|
||||
type="append"
|
||||
group={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
overIndex,
|
||||
)
|
||||
const onReorder = (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => {
|
||||
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||
const filtered =
|
||||
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
@@ -311,55 +268,9 @@ export const GroupsEditorViewer = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
appendSeq,
|
||||
filteredAppendSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
activeRealIndex === overRealIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
|
||||
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||
}
|
||||
|
||||
// prevent drag over if source and target are in different groups
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (source?.group !== target?.group) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const { initialGroup, group } = source.sortable
|
||||
if (initialGroup !== group) return
|
||||
|
||||
if (group === 'prepend') {
|
||||
await onPrependDragEnd(event)
|
||||
} else if (group === 'append') {
|
||||
await onAppendDragEnd(event)
|
||||
}
|
||||
}
|
||||
const fetchContent = useCallback(async () => {
|
||||
hasLoadedSeqConfigRef.current = false
|
||||
const data = await readProfileFile(property)
|
||||
@@ -1152,22 +1063,12 @@ export const GroupsEditorViewer = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<VirtualList
|
||||
count={
|
||||
filteredGroupList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
estimateSize={56}
|
||||
renderItem={renderItem}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
<GroupedVirtualList
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
onReorder={onReorder}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { arrayMove } from '@dnd-kit/helpers'
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import {
|
||||
VerticalAlignBottomRounded,
|
||||
VerticalAlignTopRounded,
|
||||
@@ -35,12 +27,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BaseSearchBox,
|
||||
MonacoEditor,
|
||||
SortableItem,
|
||||
VirtualList,
|
||||
} from '@/components/base'
|
||||
import { BaseSearchBox, MonacoEditor } from '@/components/base'
|
||||
import { ProxyItem } from '@/components/profile/proxy-item'
|
||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@@ -50,6 +37,12 @@ import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
||||
import parseUri from '@/utils/uri-parser'
|
||||
import { parseYamlSafe } from '@/utils/yaml'
|
||||
|
||||
import {
|
||||
buildGroupedItems,
|
||||
type GroupedVirtualItem,
|
||||
GroupedVirtualList,
|
||||
} from './grouped-virtual-list'
|
||||
|
||||
interface Props {
|
||||
profileUid: string
|
||||
property: string
|
||||
@@ -107,106 +100,71 @@ export const ProxiesEditorViewer = (props: Props) => {
|
||||
[appendSeq, match],
|
||||
)
|
||||
|
||||
const renderItem = (index: number): React.ReactNode => {
|
||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildGroupedItems(
|
||||
filteredPrependSeq,
|
||||
filteredProxyList,
|
||||
filteredAppendSeq,
|
||||
(proxy) => proxy.name,
|
||||
),
|
||||
[filteredPrependSeq, filteredProxyList, filteredAppendSeq],
|
||||
)
|
||||
|
||||
const renderItem = (entry: GroupedVirtualItem<IProxyConfig>) => {
|
||||
const { category, item } = entry
|
||||
|
||||
if (category === 'original') {
|
||||
const isDeleted = deleteSeq.includes(item.name)
|
||||
return (
|
||||
<>
|
||||
{filteredPrependSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`prepend:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="prepend"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<ProxyItem
|
||||
type="prepend"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(
|
||||
prependSeq.filter((v) => v.name !== item.name),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else if (index < filteredProxyList.length + shift) {
|
||||
const newIndex = index - shift
|
||||
return (
|
||||
<Box sx={{ margin: '8px 0' }}>
|
||||
<ProxyItem
|
||||
key={filteredProxyList[newIndex].name}
|
||||
type={
|
||||
deleteSeq.includes(filteredProxyList[newIndex].name)
|
||||
? 'delete'
|
||||
: 'original'
|
||||
<ProxyItem
|
||||
type={isDeleted ? 'delete' : 'original'}
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
if (isDeleted) {
|
||||
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, item.name])
|
||||
}
|
||||
proxy={filteredProxyList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter(
|
||||
(v) => v !== filteredProxyList[newIndex].name,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setDeleteSeq((prev) => [
|
||||
...prev,
|
||||
filteredProxyList[newIndex].name,
|
||||
])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (category === 'prepend') {
|
||||
return (
|
||||
<>
|
||||
{filteredAppendSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.name}
|
||||
id={`append:${item.name}`}
|
||||
index={itemIndex}
|
||||
group="append"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<ProxyItem
|
||||
type="append"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
<ProxyItem
|
||||
type="prepend"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProxyItem
|
||||
type="append"
|
||||
proxy={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
overIndex,
|
||||
)
|
||||
const onReorder = (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => {
|
||||
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||
const filtered =
|
||||
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
@@ -215,52 +173,9 @@ export const ProxiesEditorViewer = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
|
||||
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
appendSeq,
|
||||
filteredAppendSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
activeRealIndex === overRealIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (source?.group !== target?.group) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const { initialGroup, group } = source.sortable
|
||||
if (initialGroup !== group) return
|
||||
|
||||
if (group === 'prepend') {
|
||||
await onPrependDragEnd(event)
|
||||
} else if (group === 'append') {
|
||||
await onAppendDragEnd(event)
|
||||
}
|
||||
}
|
||||
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
|
||||
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
|
||||
const proxies: IProxyConfig[] = []
|
||||
@@ -522,22 +437,12 @@ export const ProxiesEditorViewer = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<VirtualList
|
||||
count={
|
||||
filteredProxyList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
estimateSize={56}
|
||||
renderItem={renderItem}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
<GroupedVirtualList
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
onReorder={onReorder}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragOverEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/react'
|
||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||
import { arrayMove } from '@dnd-kit/helpers'
|
||||
import {
|
||||
VerticalAlignBottomRounded,
|
||||
VerticalAlignTopRounded,
|
||||
@@ -36,13 +29,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
BaseSearchBox,
|
||||
MonacoEditor,
|
||||
SortableItem,
|
||||
Switch,
|
||||
VirtualList,
|
||||
} from '@/components/base'
|
||||
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
||||
import { RuleItem } from '@/components/profile/rule-item'
|
||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@@ -54,6 +41,12 @@ import getSystem from '@/utils/get-system'
|
||||
import { isValidIpCidr } from '@/utils/network'
|
||||
import { parseYamlSafe } from '@/utils/yaml'
|
||||
|
||||
import {
|
||||
buildGroupedItems,
|
||||
type GroupedVirtualItem,
|
||||
GroupedVirtualList,
|
||||
} from './grouped-virtual-list'
|
||||
|
||||
interface Props {
|
||||
groupsUid: string
|
||||
mergeUid: string
|
||||
@@ -302,117 +295,83 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
[appendSeq, match],
|
||||
)
|
||||
|
||||
const renderItem = (index: number): React.ReactNode => {
|
||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
buildGroupedItems(
|
||||
filteredPrependSeq,
|
||||
filteredRuleList,
|
||||
filteredAppendSeq,
|
||||
(rule) => rule,
|
||||
),
|
||||
[filteredPrependSeq, filteredRuleList, filteredAppendSeq],
|
||||
)
|
||||
|
||||
const renderItem = (entry: GroupedVirtualItem<string>) => {
|
||||
const { category, item } = entry
|
||||
|
||||
if (category === 'original') {
|
||||
const isDeleted = deleteSeq.includes(item)
|
||||
return (
|
||||
<>
|
||||
{filteredPrependSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item}
|
||||
id={`prepend:${item}`}
|
||||
index={itemIndex}
|
||||
group="prepend"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<RuleItem
|
||||
type="prepend"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onAppend={() => {
|
||||
setAppendSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else if (index < filteredRuleList.length + shift) {
|
||||
const newIndex = index - shift
|
||||
return (
|
||||
<Box sx={{ margin: '8px 0' }}>
|
||||
<RuleItem
|
||||
key={filteredRuleList[newIndex]}
|
||||
type={
|
||||
deleteSeq.includes(filteredRuleList[newIndex])
|
||||
? 'delete'
|
||||
: 'original'
|
||||
<RuleItem
|
||||
type={isDeleted ? 'delete' : 'original'}
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
if (isDeleted) {
|
||||
setDeleteSeq(deleteSeq.filter((v) => v !== item))
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, item])
|
||||
}
|
||||
ruleRaw={filteredRuleList[newIndex]}
|
||||
onDelete={() => {
|
||||
if (deleteSeq.includes(filteredRuleList[newIndex])) {
|
||||
setDeleteSeq(
|
||||
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
|
||||
)
|
||||
} else {
|
||||
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
}
|
||||
|
||||
if (category === 'prepend') {
|
||||
return (
|
||||
<>
|
||||
{filteredAppendSeq.map((item, itemIndex) => {
|
||||
return (
|
||||
<SortableItem
|
||||
key={item}
|
||||
id={`append:${item}`}
|
||||
index={itemIndex}
|
||||
group="append"
|
||||
style={{ margin: '8px 0' }}
|
||||
>
|
||||
<RuleItem
|
||||
type="append"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onPrepend={() => {
|
||||
setPrependSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
</SortableItem>
|
||||
<RuleItem
|
||||
type="prepend"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onAppend={() => {
|
||||
setAppendSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
})}
|
||||
</>
|
||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RuleItem
|
||||
type="append"
|
||||
ruleRaw={item}
|
||||
onDelete={() => {
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
onPrepend={() => {
|
||||
setPrependSeq((prev) =>
|
||||
prev.includes(item) ? prev : [...prev, item],
|
||||
)
|
||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const reorder = (list: string[], startIndex: number, endIndex: number) => {
|
||||
const result = Array.from(list)
|
||||
const [removed] = result.splice(startIndex, 1)
|
||||
result.splice(endIndex, 0, removed)
|
||||
return result
|
||||
}
|
||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(
|
||||
prependSeq,
|
||||
filteredPrependSeq,
|
||||
overIndex,
|
||||
)
|
||||
const onReorder = (
|
||||
category: 'prepend' | 'append',
|
||||
activeIndex: number,
|
||||
overIndex: number,
|
||||
) => {
|
||||
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||
const filtered =
|
||||
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
@@ -421,52 +380,9 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
setPrependSeq(reorder(prependSeq, activeRealIndex, overRealIndex))
|
||||
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
||||
const { operation, canceled } = event
|
||||
const { source, target } = operation
|
||||
if (canceled || !target || !isSortable(source)) return
|
||||
|
||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
||||
const activeRealIndex = findRealIndex(
|
||||
appendSeq,
|
||||
filteredAppendSeq,
|
||||
activeIndex,
|
||||
)
|
||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
||||
if (
|
||||
activeRealIndex < 0 ||
|
||||
overRealIndex < 0 ||
|
||||
activeRealIndex === overRealIndex
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppendSeq(reorder(appendSeq, activeRealIndex, overRealIndex))
|
||||
}
|
||||
const onDragOver = (event: DragOverEvent) => {
|
||||
const { operation } = event
|
||||
if (!isSortableOperation(operation)) return
|
||||
|
||||
const { source, target } = operation
|
||||
if (source?.group !== target?.group) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
const source = event.operation.source
|
||||
if (!isSortable(source)) return
|
||||
|
||||
const { initialGroup, group } = source.sortable
|
||||
if (initialGroup !== group) return
|
||||
|
||||
if (group === 'prepend') {
|
||||
await onPrependDragEnd(event)
|
||||
} else if (group === 'append') {
|
||||
await onAppendDragEnd(event)
|
||||
}
|
||||
}
|
||||
const fetchContent = useCallback(async () => {
|
||||
hasLoadedSeqConfigRef.current = false
|
||||
const data = await readProfileFile(property)
|
||||
@@ -854,22 +770,12 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
<DragDropProvider
|
||||
sensors={[PointerSensor, KeyboardSensor]}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<VirtualList
|
||||
count={
|
||||
filteredRuleList.length +
|
||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
||||
}
|
||||
estimateSize={56}
|
||||
renderItem={renderItem}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
<GroupedVirtualList
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
onReorder={onReorder}
|
||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -31,6 +31,13 @@ export const StackModeSwitch = (props: Props) => {
|
||||
>
|
||||
Mixed
|
||||
</Button>
|
||||
<Button
|
||||
variant={value?.toLowerCase() === 'mips' ? 'contained' : 'outlined'}
|
||||
onClick={() => onChange?.('mips')}
|
||||
sx={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
Mips
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
|
||||
} else {
|
||||
updateRef.current?.open()
|
||||
}
|
||||
} catch (err: any) {
|
||||
showNotice.error(err)
|
||||
} catch (err) {
|
||||
console.warn('update check failed:', err)
|
||||
showNotice.warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "تم تثبيت الخدمة بنجاح",
|
||||
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
|
||||
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Service erfolgreich installiert",
|
||||
"sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.",
|
||||
"uninstallSuccess": "Service erfolgreich deinstalliert"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Service Installed Successfully",
|
||||
"sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.",
|
||||
"uninstallSuccess": "Service Uninstalled Successfully"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Servicio instalado con éxito",
|
||||
"sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.",
|
||||
"uninstallSuccess": "Servicio desinstalado con éxito"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "سرویس با موفقیت نصب شد",
|
||||
"sidecarFallback": "سرویس بهدلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
|
||||
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Layanan Berhasil Diinstal",
|
||||
"sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.",
|
||||
"uninstallSuccess": "Layanan Berhasil Dicopot"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "サービスのインストールに成功しました。",
|
||||
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
|
||||
"uninstallSuccess": "サービスのアンインストールに成功しました。"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "서비스가 설치되었습니다",
|
||||
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
|
||||
"uninstallSuccess": "서비스가 제거되었습니다"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Служба успешно установлена",
|
||||
"sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.",
|
||||
"uninstallSuccess": "Служба успешно удалена"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Hizmet Başarıyla Kuruldu",
|
||||
"sidecarFallback": "Hizmet, yol izinleri nedeniyle çekirdeği başlatamadı. Çalışmaya devam etmek için yönetici moduna geçildi.",
|
||||
"uninstallSuccess": "Hizmet Başarıyla Kaldırıldı"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Сервис уңышлы урнаштырылды",
|
||||
"sidecarFallback": "Хезмәт юлга керү хокуклары аркасында төшне эшләтеп җибәрә алмады. Эшне дәвам итү өчен администратор режимына күчелде.",
|
||||
"uninstallSuccess": "Сервис уңышлы салдырылды"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "已成功安装服务",
|
||||
"sidecarFallback": "服务因路径权限问题无法启动内核,已切回管理员模式继续运行。",
|
||||
"uninstallSuccess": "已成功卸载服务"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -683,6 +683,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "已成功安裝服務",
|
||||
"sidecarFallback": "服務因路徑權限問題無法啟動核心,已切回管理員模式繼續執行。",
|
||||
"uninstallSuccess": "已成功解除安裝服務"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -13,7 +13,10 @@ vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() }))
|
||||
vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() }))
|
||||
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
|
||||
vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() }))
|
||||
vi.mock('@/services/cmds', () => ({ takeDnsOverrideNotice: vi.fn() }))
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
takeDnsOverrideNotice: vi.fn(),
|
||||
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
|
||||
}))
|
||||
vi.mock('@/services/notice-service', () => ({
|
||||
showNotice: { info: vi.fn() },
|
||||
}))
|
||||
|
||||
@@ -64,6 +64,7 @@ export const useLayoutEvents = (
|
||||
() => {
|
||||
revalidateKeys(['getRuntimeState', 'getVergeConfig'])
|
||||
handleNotice(['dns_override::auto_disabled', ''])
|
||||
handleNotice(['service_core::sidecar_fallback', ''])
|
||||
},
|
||||
)
|
||||
}, [handleNotice])
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { takeDnsOverrideNotice } from '@/services/cmds'
|
||||
import {
|
||||
takeDnsOverrideNotice,
|
||||
takeServiceFallbackNotice,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
type NavigateFunction = (path: string, options?: any) => void
|
||||
@@ -25,6 +28,22 @@ export const handleNoticeMessage = (
|
||||
showNotice.error(msg)
|
||||
},
|
||||
'set_config::error': () => showNotice.error(msg),
|
||||
'service_core::sidecar_fallback': () => {
|
||||
void takeServiceFallbackNotice()
|
||||
.then((pending) => {
|
||||
if (pending) {
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.sidecarFallback',
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to read the pending service fallback notice',
|
||||
error,
|
||||
)
|
||||
})
|
||||
},
|
||||
'dns_override::auto_disabled': () => {
|
||||
void takeDnsOverrideNotice()
|
||||
.then((pending) => {
|
||||
|
||||
@@ -182,6 +182,10 @@ export async function takeDnsOverrideNotice() {
|
||||
return invoke<boolean>('take_dns_override_notice')
|
||||
}
|
||||
|
||||
export async function takeServiceFallbackNotice() {
|
||||
return invoke<boolean>('take_service_fallback_notice')
|
||||
}
|
||||
|
||||
export async function getSystemProxy() {
|
||||
return invoke<{
|
||||
enable: boolean
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ReactNode, isValidElement } from 'react'
|
||||
import type { FailedOperation } from '@/services/cmds'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
|
||||
type NoticeType = 'success' | 'error' | 'info'
|
||||
type NoticeType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface NoticeTranslationDescriptor {
|
||||
key: string
|
||||
@@ -47,6 +47,7 @@ type ShowNotice = ((
|
||||
success: NoticeShortcut
|
||||
error: NoticeShortcut
|
||||
info: NoticeShortcut
|
||||
warning: NoticeShortcut
|
||||
}
|
||||
|
||||
type NoticeSubscriber = () => void
|
||||
@@ -54,6 +55,7 @@ type NoticeSubscriber = () => void
|
||||
const DEFAULT_DURATIONS: Readonly<Record<NoticeType, number>> = {
|
||||
success: 3000,
|
||||
info: 5000,
|
||||
warning: 6000,
|
||||
error: 8000,
|
||||
}
|
||||
|
||||
@@ -509,6 +511,8 @@ export const showNotice: ShowNotice = Object.assign(baseShowNotice, {
|
||||
baseShowNotice('error', message, ...extras),
|
||||
info: (message: NoticeContent, ...extras: NoticeExtra[]) =>
|
||||
baseShowNotice('info', message, ...extras),
|
||||
warning: (message: NoticeContent, ...extras: NoticeExtra[]) =>
|
||||
baseShowNotice('warning', message, ...extras),
|
||||
})
|
||||
|
||||
export function hideNotice(id: number) {
|
||||
|
||||
@@ -793,6 +793,7 @@ export const translationKeys = [
|
||||
'settings.feedback.notifications.clash.changeFailed',
|
||||
'settings.feedback.notifications.clash.geoDataUpdated',
|
||||
'settings.feedback.notifications.clashService.installSuccess',
|
||||
'settings.feedback.notifications.clashService.sidecarFallback',
|
||||
'settings.feedback.notifications.clashService.uninstallSuccess',
|
||||
'settings.feedback.notifications.updater.withClashProxySuccess',
|
||||
'settings.feedback.notifications.updater.withClashProxyFailed',
|
||||
|
||||
@@ -831,6 +831,7 @@ export interface TranslationResources {
|
||||
}
|
||||
clashService: {
|
||||
installSuccess: string
|
||||
sidecarFallback: string
|
||||
uninstallSuccess: string
|
||||
}
|
||||
updater: {
|
||||
|
||||
@@ -5,7 +5,7 @@ const parseTraffic = (num?: number) => {
|
||||
const exp =
|
||||
num < 1 ? 0 : Math.min(Math.floor(Math.log2(num) / 10), UNITS.length - 1)
|
||||
const dat = num / Math.pow(1024, exp)
|
||||
const ret = dat >= 1000 ? dat.toFixed(0) : dat.toPrecision(3)
|
||||
const ret = Math.round(dat) >= 1000 ? dat.toFixed(0) : dat.toPrecision(3)
|
||||
const unit = UNITS[exp]
|
||||
|
||||
return [ret, unit]
|
||||
|
||||
Reference in New Issue
Block a user