mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-24 12:42:22 +08:00
fix(tun): fall back to sidecar when service is absent
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
修复 macOS 开启「访问系统范围设置需要管理员密码」时无法设置系统代理的问题
|
||||
修复从未开启过系统代理的用户,每次内核启动都收到报错的问题
|
||||
修复受限机器上「安装服务 → 自动切换到服务模式」整条流程无法走通的问题
|
||||
修复 Windows 卸载后保留 TUN 配置、重新安装时因系统服务缺失而无法启动内核的问题,启动时会在当前会话回退到 Sidecar,并保留原 TUN 偏好
|
||||
修复系统代理开关切换失败后,界面停留在错误状态的问题
|
||||
修复托盘与热键切换系统代理失败时没有任何提示的问题
|
||||
修复代理守卫反复失败后无限重试且不告知用户的问题
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
//! Run State value types.
|
||||
//!
|
||||
//! The central distinction: [`ServiceHealth`] is what we *observed* about the Service,
|
||||
//! [`PendingAction`] is what this session *asked for*. The legacy `ServiceStatus` enum
|
||||
//! conflated the two into one slot, which is why narrowing it for the frontend had to be
|
||||
//! lossy. Nothing narrows now: [`RunStateView`] carries both, plus the derived answers.
|
||||
//!
|
||||
//! This module owns state only. Starting, stopping and restarting the Core stay in
|
||||
//! `CoreManager`: a module that both observes Service Health and drives the Core has no point
|
||||
//! at which it is consistent, because a snapshot taken mid-restart would describe a transition
|
||||
//! rather than a state.
|
||||
//! Run State separates observed Service health from session intent.
|
||||
//! Core lifecycle remains in `CoreManager`.
|
||||
|
||||
use crate::core::manager::RunningMode;
|
||||
|
||||
/// What was last observed about the Service. A fact about the machine, never a request.
|
||||
/// Last observed Service state.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum ServiceHealth {
|
||||
/// Not probed yet.
|
||||
@@ -29,7 +20,6 @@ pub enum ServiceHealth {
|
||||
}
|
||||
|
||||
impl ServiceHealth {
|
||||
/// The variant name alone, for callers that carry the reason separately.
|
||||
const fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
@@ -48,7 +38,7 @@ impl ServiceHealth {
|
||||
}
|
||||
}
|
||||
|
||||
/// A privileged operation this session asked for against the Service.
|
||||
/// Requested privileged Service operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PendingAction {
|
||||
@@ -58,10 +48,7 @@ pub enum PendingAction {
|
||||
ForceReinstall,
|
||||
}
|
||||
|
||||
/// The single consistent answer to "how is the Core running, and what backs it".
|
||||
///
|
||||
/// Every question about Core availability is answered by a method here, so that no
|
||||
/// caller writes its own formula. Construct one via `RunStateStore::state`/`settled`/`probe`.
|
||||
/// Snapshot used for Core availability decisions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RunState {
|
||||
pub health: ServiceHealth,
|
||||
@@ -74,39 +61,26 @@ pub struct RunState {
|
||||
}
|
||||
|
||||
impl RunState {
|
||||
/// The Service reported itself ready the last time we looked.
|
||||
///
|
||||
/// Says nothing about in-flight operations — prefer [`Self::service_usable`] when
|
||||
/// deciding whether to rely on the Service right now.
|
||||
/// Whether the last probe observed a ready Service; ignores in-flight operations.
|
||||
#[must_use]
|
||||
pub const fn service_ready(&self) -> bool {
|
||||
matches!(self.health, ServiceHealth::Ready)
|
||||
}
|
||||
|
||||
/// The Service can be relied upon right now: ready, and not mid-operation.
|
||||
/// Whether the Service is ready and not mid-operation.
|
||||
#[must_use]
|
||||
pub const fn service_usable(&self) -> bool {
|
||||
self.service_ready() && !self.op_in_flight
|
||||
}
|
||||
|
||||
/// TUN mode can work right now — either the app is elevated, or the Service backs it.
|
||||
/// Whether elevation or a usable Service can support TUN.
|
||||
#[must_use]
|
||||
pub const fn tun_capable(&self) -> bool {
|
||||
self.is_admin || self.service_usable()
|
||||
}
|
||||
|
||||
/// The Service cannot be used and the user has to choose what to do about it.
|
||||
///
|
||||
/// A requested action always needs an answer — unless it is already being carried out,
|
||||
/// which is a report of progress rather than a question. The legacy status could not tell
|
||||
/// the two apart because readers blocked until the operation finished and so never saw the
|
||||
/// request at all; now that every transition is pushed, an install started from the
|
||||
/// settings page would otherwise raise the migration dialog on top of it, and an uninstall
|
||||
/// would raise one offering to reinstall the very Service being removed.
|
||||
///
|
||||
/// Otherwise it is only the states where the Service is present but unusable: a Service
|
||||
/// that is simply absent, or a session that has already settled on Sidecar, needs nothing
|
||||
/// from anyone.
|
||||
/// Whether the user must resolve a pending request or a present-but-unusable Service.
|
||||
/// In-flight, absent, and accepted-Sidecar states need no decision.
|
||||
#[must_use]
|
||||
pub const fn service_needs_attention(&self) -> bool {
|
||||
if self.op_in_flight {
|
||||
@@ -124,12 +98,8 @@ impl RunState {
|
||||
)
|
||||
}
|
||||
|
||||
/// TUN is switched on but cannot work, and we know that for certain.
|
||||
///
|
||||
/// "For certain" is the whole difficulty: an unprobed Service, an operation in flight, or a
|
||||
/// decision still sitting in front of the user all mean *not yet known*, and turning TUN
|
||||
/// off on a guess would silently undo something the user asked for. This replaces the fixed
|
||||
/// startup grace period the frontend used to wait out, which guessed at the same thing.
|
||||
/// Whether enabled TUN is certainly unusable and may be disabled.
|
||||
/// Unknown, in-flight, and attention-required states are inconclusive.
|
||||
#[must_use]
|
||||
pub const fn tun_should_be_disabled(&self, tun_enabled: bool) -> bool {
|
||||
if !tun_enabled || self.tun_capable() {
|
||||
@@ -138,10 +108,14 @@ impl RunState {
|
||||
!matches!(self.health, ServiceHealth::Unknown) && !self.op_in_flight && !self.service_needs_attention()
|
||||
}
|
||||
|
||||
/// The shape sent across the IPC seam.
|
||||
///
|
||||
/// Carries the derived answers, not just the raw fields, so that no caller on the other
|
||||
/// side reinvents `tun_capable` or the "needs attention" ladder.
|
||||
/// Whether startup must suppress TUN for a confirmed absent Service.
|
||||
/// Present-but-broken Services remain in the repair flow.
|
||||
#[must_use]
|
||||
pub const fn startup_tun_should_be_disabled(&self, tun_enabled: bool) -> bool {
|
||||
matches!(self.health, ServiceHealth::NotInstalled) && self.tun_should_be_disabled(tun_enabled)
|
||||
}
|
||||
|
||||
/// Frontend view with derived availability decisions.
|
||||
#[must_use]
|
||||
pub fn to_view(&self) -> RunStateView {
|
||||
RunStateView {
|
||||
@@ -159,7 +133,7 @@ impl RunState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Run State as the frontend sees it.
|
||||
/// Frontend Run State.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunStateView {
|
||||
@@ -175,11 +149,8 @@ pub struct RunStateView {
|
||||
pub service_needs_attention: bool,
|
||||
}
|
||||
|
||||
/// The service-owned part of the Run State, stored behind one lock.
|
||||
///
|
||||
/// At most one of `pending` / `sidecar_allowed` is set at a time; the transitions in
|
||||
/// [`super::RunStateStore`] maintain that, mirroring the single-slot semantics the legacy
|
||||
/// `ServiceStatus` had.
|
||||
/// Service state protected by one lock.
|
||||
/// `pending` and `sidecar_allowed` are mutually exclusive.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(super) struct StoredService {
|
||||
pub health: ServiceHealth,
|
||||
@@ -278,11 +249,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_operation_under_way_is_never_a_decision_to_put_to_the_user() {
|
||||
// The regression this pins: an install or uninstall started from the settings page
|
||||
// pushes its pending action to the frontend the moment it is requested. Readers used
|
||||
// to block until the operation finished and so never saw it; now that every transition
|
||||
// is pushed, treating it as a decision raises the migration dialog on top of an
|
||||
// operation already running — and for an uninstall, one offering to reinstall.
|
||||
for health in [
|
||||
ServiceHealth::Unknown,
|
||||
ServiceHealth::Ready,
|
||||
@@ -346,21 +312,62 @@ mod tests {
|
||||
assert!(state(ServiceHealth::NotInstalled, false, false).tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_tun_is_disabled_only_for_a_confirmed_absent_service() {
|
||||
assert!(
|
||||
state(ServiceHealth::NotInstalled, false, false).startup_tun_should_be_disabled(true),
|
||||
"a non-elevated reinstall must not keep an unusable TUN setting"
|
||||
);
|
||||
|
||||
for health in [
|
||||
ServiceHealth::Unknown,
|
||||
ServiceHealth::Ready,
|
||||
ServiceHealth::VersionMismatch,
|
||||
ServiceHealth::Unavailable("socket refused".into()),
|
||||
] {
|
||||
assert!(
|
||||
!state(health.clone(), false, false).startup_tun_should_be_disabled(true),
|
||||
"{health:?} is not proof that the Service was removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_leaves_a_present_but_broken_service_to_the_runtime_reconciler() {
|
||||
for health in [
|
||||
ServiceHealth::VersionMismatch,
|
||||
ServiceHealth::Unavailable("socket refused".into()),
|
||||
] {
|
||||
let mut settled = state(health.clone(), false, false);
|
||||
settled.sidecar_allowed = true;
|
||||
|
||||
assert!(settled.tun_should_be_disabled(true), "{health:?}");
|
||||
assert!(!settled.startup_tun_should_be_disabled(true), "{health:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_tun_is_kept_when_disabled_elevated_or_still_undecided() {
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, false).startup_tun_should_be_disabled(false));
|
||||
assert!(!state(ServiceHealth::NotInstalled, true, false).startup_tun_should_be_disabled(true));
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, true).startup_tun_should_be_disabled(true));
|
||||
|
||||
let mut requested = state(ServiceHealth::NotInstalled, false, false);
|
||||
requested.pending = Some(PendingAction::Install);
|
||||
assert!(
|
||||
!requested.startup_tun_should_be_disabled(true),
|
||||
"an install the user has still to answer is not a settled absence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_never_disabled_while_an_operation_is_in_flight() {
|
||||
// Mid-install the service is not usable, but that says nothing about what it will be
|
||||
// once the operation finishes.
|
||||
assert!(!state(ServiceHealth::Ready, false, true).tun_should_be_disabled(true));
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, true).tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_never_disabled_while_the_core_is_not_running() {
|
||||
// The startup regression this guards: the service is observed as absent before the
|
||||
// startup path has decided whether to install it, and acting on that snapshot both
|
||||
// rewrites the user's setting and removes the prompt that would have fixed it.
|
||||
// `reconcile_tun_availability` refuses to act at all while the mode is NotRunning;
|
||||
// this pins the state that made it dangerous.
|
||||
fn runtime_tun_reconciliation_waits_until_the_core_is_running() {
|
||||
let stopped = state(ServiceHealth::NotInstalled, false, false);
|
||||
|
||||
assert_eq!(stopped.mode, RunningMode::NotRunning);
|
||||
@@ -372,7 +379,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tun_is_never_disabled_on_an_unprobed_service() {
|
||||
// The old frontend guard waited out a fixed ten seconds to approximate this.
|
||||
assert!(!state(ServiceHealth::Unknown, false, false).tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
@@ -424,7 +430,6 @@ mod tests {
|
||||
assert_eq!(stored.pending, None);
|
||||
assert!(stored.sidecar_allowed);
|
||||
|
||||
// The observation survives both requests.
|
||||
assert_eq!(stored.health, ServiceHealth::NotInstalled);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-12
@@ -1,4 +1,4 @@
|
||||
//! Disables TUN when the current Run State cannot support it.
|
||||
//! Reconciles TUN with current Core and Service availability.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -10,40 +10,57 @@ use crate::{
|
||||
core::{handle::Handle, manager::RunningMode, runstate::RUN_STATE},
|
||||
};
|
||||
|
||||
/// Set while an automatic TUN disable is being written.
|
||||
///
|
||||
/// Drops new requests during the write to prevent recursive retries. No-op reconciliations leave
|
||||
/// it clear so later state changes can still be processed.
|
||||
/// Prevents recursive runtime disable writes.
|
||||
static DISABLING_TUN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Suppress TUN for this Windows session when startup confirms the Service is absent.
|
||||
/// The saved preference remains available for a later Service install.
|
||||
pub async fn reconcile_startup_tun_availability() {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read fresh state after prior config writes complete.
|
||||
let _config_write = Config::lock_config_write().await;
|
||||
let tun_enabled = Config::verge().await.data_arc().enable_tun_mode.unwrap_or(false);
|
||||
if !RUN_STATE.state().startup_tun_should_be_disabled(tun_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::Setup,
|
||||
"Windows Service is not installed; suppressing TUN mode so the Core can start on Sidecar"
|
||||
);
|
||||
Config::suppress_tun_for_session().await;
|
||||
}
|
||||
|
||||
/// Disable TUN when the current settled Run State cannot support it.
|
||||
pub async fn reconcile_tun_availability() {
|
||||
// Coalesce all reconciliation requests while a disable write is in flight.
|
||||
if DISABLING_TUN.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for any config transaction, then decide from fresh state and committed config.
|
||||
// Re-read state and committed config after prior writes complete.
|
||||
let config_write = Config::lock_config_write().await;
|
||||
let state = RUN_STATE.state();
|
||||
|
||||
// Startup may still install the Service and uses this setting to decide whether to prompt.
|
||||
// Startup uses the narrower session-suppression path above.
|
||||
if matches!(state.mode, RunningMode::NotRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sidecar fallback suppresses TUN for this session without changing the saved preference.
|
||||
if Config::tun_suppressed_for_session() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not rewrite the saved preference based on an uncommitted draft from a legacy writer.
|
||||
// Legacy writers may still hold an uncommitted draft.
|
||||
let tun_enabled = Config::verge().await.data_arc().enable_tun_mode.unwrap_or(false);
|
||||
if !state.tun_should_be_disabled(tun_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The config lock already serializes writers; the flag only sheds newly spawned work.
|
||||
// The flag coalesces newly spawned tasks while the config lock serializes writers.
|
||||
DISABLING_TUN.store(true, Ordering::Release);
|
||||
defer! {
|
||||
DISABLING_TUN.store(false, Ordering::Release);
|
||||
@@ -59,7 +76,7 @@ pub async fn reconcile_tun_availability() {
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
// Avoid recursively reconciling this internal patch.
|
||||
// Skip recursive reconciliation for this internal patch.
|
||||
match super::apply_verge_patch_locked(&config_write, &patch, false).await {
|
||||
Ok(()) => Handle::notice_message("tun_mode::auto_disabled", ""),
|
||||
Err(error) => {
|
||||
|
||||
@@ -31,7 +31,6 @@ pub fn init_work_dir_and_logger() -> anyhow::Result<()> {
|
||||
AsyncHandler::block_on(async {
|
||||
init_work_config().await;
|
||||
logging!(info, Type::Setup, "Initializing logger");
|
||||
// #[cfg(not(feature = "tokio-trace"))]
|
||||
Logger::global().init().await?;
|
||||
Ok(())
|
||||
})
|
||||
@@ -48,8 +47,7 @@ pub fn resolve_setup_async() {
|
||||
AsyncHandler::spawn(|| async {
|
||||
logging!(info, Type::ClashVergeRev, "Version: {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Ahead of the window and init_timer: anything saved or deep-link-imported after the app
|
||||
// is up is a deliberate choice. Config files already exist (init_work_dir_and_logger).
|
||||
// Migrate before windows or timers can change the loaded config.
|
||||
logging_error!(Type::Setup, init::migrate_short_update_intervals().await);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -58,6 +56,7 @@ pub fn resolve_setup_async() {
|
||||
init_service_manager().await;
|
||||
let config_initialized = init_verge_config_before_window().await;
|
||||
init_window().await;
|
||||
feat::reconcile_startup_tun_availability().await;
|
||||
init_resources().await;
|
||||
if let Err(e) = init::init_dns_config().await {
|
||||
logging!(warn, Type::Setup, "DNS config initialization failed: {}", e);
|
||||
@@ -125,7 +124,6 @@ pub(super) async fn init_timer() {
|
||||
}
|
||||
|
||||
pub(super) async fn init_hotkey() {
|
||||
// if hotkey is not use by global, skip init it
|
||||
let skip_register_hotkeys = !Config::verge().await.latest_arc().enable_global_hotkey.unwrap_or(true);
|
||||
logging_error!(Type::Setup, Hotkey::global().init(skip_register_hotkeys).await);
|
||||
}
|
||||
@@ -146,16 +144,12 @@ async fn init_silent_updater() {
|
||||
|
||||
let app_handle = Handle::app_handle();
|
||||
|
||||
// Check for cached update and attempt install before main app initialization.
|
||||
// If install succeeds:
|
||||
// - Windows: NSIS takes over and the process exits automatically
|
||||
// - macOS/Linux: binary is replaced, we restart the app
|
||||
// Install cached updates before starting background checks.
|
||||
if SilentUpdater::global().try_install_on_startup(app_handle).await {
|
||||
logging!(info, Type::Setup, "Update installed at startup, restarting...");
|
||||
feat::restart_app().await;
|
||||
}
|
||||
|
||||
// No pending install — start background check/download loop
|
||||
let app_handle = app_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
SilentUpdater::global().start_background_check(app_handle).await;
|
||||
|
||||
Reference in New Issue
Block a user