Compare commits

..
Author SHA1 Message Date
renovate[bot] bf65f9632d chore(deps): update npm dependencies 2026-10-13 12:35:01 +00:00
13 changed files with 520 additions and 372 deletions
+31 -23
View File
@@ -1,15 +1,3 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
@@ -17,13 +5,20 @@ use std::sync::{
use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
/// Serializes signal-triggered cleanups: a signal arriving while a quit
/// attempt is still in flight is dropped instead of overlapping it.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShutdownOutcome {
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool,
}
@@ -41,18 +36,17 @@ impl ShutdownLatch {
.is_ok()
}
fn finish(&self) {
self.is_cleaning_up.store(false, Ordering::Release);
fn finish(&self, outcome: ShutdownOutcome) {
if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
}
}
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt),
@@ -67,22 +61,36 @@ where
}
});
#[cfg(unix)]
unix::register(f);
#[cfg(windows)]
windows::register(f);
}
#[cfg(test)]
mod tests {
use super::ShutdownLatch;
use super::{ShutdownLatch, ShutdownOutcome};
#[test]
fn finished_attempt_releases_latch_for_the_next_signal() {
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
assert!(!latch.try_begin());
latch.finish();
latch.finish(ShutdownOutcome::Canceled);
assert!(latch.try_begin());
}
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
}
+3 -4
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH};
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -60,8 +60,7 @@ where
}
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
f().await;
SHUTDOWN_LATCH.finish();
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
+83
View File
@@ -0,0 +1,83 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
+12 -12
View File
@@ -54,13 +54,13 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.12.1",
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-http": "2.8.0",
"@tauri-apps/plugin-opener": "2.7.0",
"@tauri-apps/plugin-updater": "2.13.1",
"@tauri-apps/api": "2.12.3",
"@tauri-apps/plugin-clipboard-manager": "2.4.2",
"@tauri-apps/plugin-dialog": "2.8.2",
"@tauri-apps/plugin-fs": "2.7.0",
"@tauri-apps/plugin-http": "2.8.2",
"@tauri-apps/plugin-opener": "2.7.1",
"@tauri-apps/plugin-updater": "2.13.3",
"ahooks": "^3.10.0",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
@@ -79,7 +79,7 @@
"react-dom": "19.3.0",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.15",
"react-i18next": "17.0.16",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
@@ -95,7 +95,7 @@
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.1",
"@tauri-apps/cli": "2.12.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
@@ -103,7 +103,7 @@
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.4.0",
"@wdio/tauri-service": "1.5.0",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"cli-color": "^2.0.4",
@@ -123,7 +123,7 @@
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.39.0",
"knip": "6.41.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
@@ -133,7 +133,7 @@
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.0.0",
"webdriverio": "10.0.2",
"ws": "8.22.0"
},
"lint-staged": {
+290 -286
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -35,6 +35,7 @@ tauri-build = { version = "2.7.1", features = [] }
clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -115,10 +116,6 @@ specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies]
libc = "0.2.190"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.5"
+3 -1
View File
@@ -130,7 +130,7 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}");
}
pub async fn quit() {
pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
logging!(debug, Type::System, "启动退出流程");
// 设置退出标志
handle::Handle::global().set_is_exiting();
@@ -165,11 +165,13 @@ pub async fn quit() {
NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
);
return clash_verge_signal::ShutdownOutcome::Canceled;
}
utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
clash_verge_signal::ShutdownOutcome::Committed
}
pub async fn clean_async() -> CleanupResult {
-1
View File
@@ -230,7 +230,6 @@ pub fn run() -> std::process::ExitCode {
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve::resolve_setup_async();
resolve::resolve_setup_sync();
#[cfg(target_os = "linux")]
resolve::init_signal();
})) {
log_setup_panic("window-core", panic);
+1 -1
View File
@@ -16,7 +16,7 @@ fn main() -> ExitCode {
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
fn main() -> ExitCode {
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let worker_limit = std::cmp::min(default_parallelism, 4);
let worker_limit = std::cmp::min(default_parallelism, 8);
let blocking_limit = 2 * worker_limit;
#[allow(clippy::unwrap_used)]
+1 -2
View File
@@ -20,6 +20,7 @@ use crate::{
utils::{init, server, window_manager::WindowManager},
};
use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_signal;
#[cfg(target_os = "macos")]
pub mod dns;
@@ -188,8 +189,6 @@ async fn init_silent_updater() {
logging!(info, Type::Setup, "Silent updater initialized");
}
// Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
#[cfg(target_os = "linux")]
pub(crate) fn init_signal() {
logging!(debug, Type::Setup, "Initializing signal handlers...");
clash_verge_signal::register(feat::quit);
+52 -1
View File
@@ -39,6 +39,8 @@ static INSTANCE_LOCK: OnceLock<std::fs::File> = OnceLock::new();
const PAC_INITIAL_AVAILABLE: bool = false;
static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE);
static COMMANDS_READY: AtomicBool = AtomicBool::new(false);
#[cfg(feature = "verge-dev")]
static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SingletonDisposition {
@@ -162,12 +164,55 @@ fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp:
.untuple_one()
}
#[cfg(feature = "verge-dev")]
fn dev_quit_route<F>(
token: String,
commands_ready: &'static AtomicBool,
quit_requested: &'static AtomicBool,
trigger: F,
) -> impl warp::Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
where
F: Fn() + Clone + Send + Sync + 'static,
{
instance_auth(token)
.and(warp::path!("commands" / "dev" / "quit"))
.and(warp::post())
.and_then(move || {
let trigger = trigger.clone();
async move {
if !commands_ready.load(Ordering::Acquire) {
return Ok::<_, warp::Rejection>(warp::reply::with_status(
String::from("starting"),
warp::http::StatusCode::SERVICE_UNAVAILABLE,
));
}
if quit_requested
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
trigger();
}
Ok(warp::reply::with_status(
String::from("accepted"),
warp::http::StatusCode::ACCEPTED,
))
}
})
}
#[cfg(feature = "verge-dev")]
fn release_dev_quit_latch(quit_requested: &AtomicBool, outcome: clash_verge_signal::ShutdownOutcome) {
if matches!(outcome, clash_verge_signal::ShutdownOutcome::Canceled) {
quit_requested.store(false, Ordering::Release);
}
}
fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
#[cfg(feature = "verge-dev")]
let auth = instance_auth(token);
let auth = instance_auth(token.clone());
#[cfg(not(feature = "verge-dev"))]
let auth = instance_auth(token);
@@ -233,6 +278,12 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
});
let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac);
#[cfg(feature = "verge-dev")]
let commands = commands.or(dev_quit_route(token, &COMMANDS_READY, &DEV_QUIT_REQUESTED, || {
AsyncHandler::spawn(|| async {
release_dev_quit_latch(&DEV_QUIT_REQUESTED, crate::feat::quit().await);
});
}));
AsyncHandler::spawn(move || async move {
warp::serve(commands)
.incoming(listener)
+11 -33
View File
@@ -27,12 +27,8 @@ const TRAY_STATUS_ITEM_EXTRA_WIDTH: f64 = 30.0;
const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0;
const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0;
/// (button_height, mono_advance, attributes).
type CachedAttrs = (f64, f64, Retained<NSDictionary<NSString, AnyObject>>);
thread_local! {
static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) };
static CACHED_ATTRS: RefCell<Option<CachedAttrs>> = const { RefCell::new(None) };
}
fn format_tray_speed(up: u64, down: u64) -> String {
@@ -78,26 +74,8 @@ fn create_attributed_string(
}
}
/// Attributes and per-glyph advance, rebuilt only when the button height changes.
fn cached_attributes(button_height: f64) -> (f64, Retained<NSDictionary<NSString, AnyObject>>) {
CACHED_ATTRS.with(|cache| {
if let Some((height, advance, attrs)) = cache.borrow().as_ref()
&& *height == button_height
{
return (*advance, attrs.clone());
}
let attrs = build_attributes(button_height);
let probe = NSString::from_str("0");
let advance = create_attributed_string(&probe, Some(&attrs)).size().width;
*cache.borrow_mut() = Some((button_height, advance, attrs.clone()));
(advance, attrs)
})
}
/// Longest line times the monospaced advance; matches `size().width` for the ASCII-only speed text.
fn arithmetic_width(text: &str, advance: f64) -> f64 {
let glyphs = text.split('\n').map(str::len).max().unwrap_or(0) as f64;
glyphs * advance
fn status_item_length_for_speed(attr_str: &NSAttributedString) -> f64 {
(attr_str.size().width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH)
}
fn sync_click_target_frame(button: &NSStatusBarButton) {
@@ -111,7 +89,7 @@ fn sync_click_target_frame(button: &NSStatusBarButton) {
}
/// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not.
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, show_speed: bool) {
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSString, show_speed: bool) {
let Some(mtm) = MainThreadMarker::new() else {
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
return;
@@ -119,19 +97,17 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, sh
let Some(button) = status_item.button(mtm) else {
return;
};
let ns_string = NSString::from_str(text);
let attr_str = if show_speed {
let (advance, attrs) = cached_attributes(button.bounds().size.height);
let attrs = build_attributes(button.bounds().size.height);
let attrs: &NSDictionary<NSString, AnyObject> = &attrs;
let attr_str = create_attributed_string(&ns_string, Some(attrs));
let width = arithmetic_width(text, advance);
let length = (width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH);
let attr_str = create_attributed_string(text, Some(attrs));
let length = status_item_length_for_speed(&attr_str);
if status_item.length() != length {
status_item.setLength(length);
}
attr_str
} else {
create_attributed_string(&ns_string, None)
create_attributed_string(text, None)
};
button.setAttributedTitle(&attr_str);
sync_click_target_frame(&button);
@@ -145,7 +121,8 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
return;
}
*last = speed_text;
apply_status_item_attributed_title(status_item, last.as_str(), true);
let ns_string = NSString::from_str(last.as_str());
apply_status_item_attributed_title(status_item, &ns_string, true);
});
}
@@ -153,6 +130,7 @@ pub fn clear_speed_attributed_title(status_item: &NSStatusItem) {
LAST_DISPLAY_STR.with(|last| {
last.borrow_mut().clear();
});
let empty = NSString::from_str("");
status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH);
apply_status_item_attributed_title(status_item, "", false);
apply_status_item_attributed_title(status_item, &empty, false);
}
@@ -1,11 +1,20 @@
import { Button, Grid, Stack, TextField } from '@mui/material'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import {
TextField,
Button,
Grid,
Stack,
IconButton,
InputAdornment,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react'
import { useState, useRef, memo, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import { createWebdavBackup, saveWebdavConfig } from '@/services/cmds'
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service'
import {
@@ -34,6 +43,7 @@ export const BackupConfigViewer = memo(
const { t } = useTranslation()
const { verge, mutateVerge } = useVerge()
const { webdav_url, webdav_username, webdav_password } = verge || {}
const [showPassword, setShowPassword] = useState(false)
const usernameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
const urlRef = useRef<HTMLInputElement>(null)
@@ -58,6 +68,10 @@ export const BackupConfigViewer = memo(
const webdavStatus = getWebdavStatus(webdavSignature)
const shouldAutoInit = webdavStatus !== 'failed'
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev)
}
useEffect(() => {
if (!webdav_url || !webdav_username || !webdav_password) {
return
@@ -201,7 +215,7 @@ export const BackupConfigViewer = memo(
<Grid size={{ xs: 6 }}>
<TextField
label={t('shared.labels.password')}
type="password"
type={showPassword ? 'text' : 'password'}
variant="outlined"
size="small"
autoCorrect="off"
@@ -209,6 +223,20 @@ export const BackupConfigViewer = memo(
spellCheck="false"
inputRef={passwordRef}
{...register('password')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
</Grid>
</Grid>