From 9fa0e91e25fdc11df884da5fac8f5a470a2dc2f1 Mon Sep 17 00:00:00 2001 From: wonfen Date: Tue, 18 Aug 2026 11:05:53 +0800 Subject: [PATCH] fix(tun): fall back to sidecar when service is absent --- Changelog.md | 1 + src-tauri/src/core/runstate/health.rs | 139 +++++++++++++------------- src-tauri/src/feat/tun.rs | 41 +++++--- src-tauri/src/utils/resolve/mod.rs | 12 +-- 4 files changed, 105 insertions(+), 88 deletions(-) diff --git a/Changelog.md b/Changelog.md index 7b3630b5f..838a2aa99 100644 --- a/Changelog.md +++ b/Changelog.md @@ -8,6 +8,7 @@ 修复 macOS 开启「访问系统范围设置需要管理员密码」时无法设置系统代理的问题 修复从未开启过系统代理的用户,每次内核启动都收到报错的问题 修复受限机器上「安装服务 → 自动切换到服务模式」整条流程无法走通的问题 +修复 Windows 卸载后保留 TUN 配置、重新安装时因系统服务缺失而无法启动内核的问题,启动时会在当前会话回退到 Sidecar,并保留原 TUN 偏好 修复系统代理开关切换失败后,界面停留在错误状态的问题 修复托盘与热键切换系统代理失败时没有任何提示的问题 修复代理守卫反复失败后无限重试且不告知用户的问题 diff --git a/src-tauri/src/core/runstate/health.rs b/src-tauri/src/core/runstate/health.rs index bb0e2b1c0..7efdd3c28 100644 --- a/src-tauri/src/core/runstate/health.rs +++ b/src-tauri/src/core/runstate/health.rs @@ -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); } } diff --git a/src-tauri/src/feat/tun.rs b/src-tauri/src/feat/tun.rs index e911c24a4..ef9c1a070 100644 --- a/src-tauri/src/feat/tun.rs +++ b/src-tauri/src/feat/tun.rs @@ -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) => { diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 615d4116b..73cc815a6 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -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;