From e57d115110a69ebbe93e08bb3adf69d4c6c29deb Mon Sep 17 00:00:00 2001 From: wonfen Date: Thu, 3 Sep 2026 13:21:19 +0800 Subject: [PATCH] feat(sysproxy): re-apply the proxy when macOS gains a network service --- Cargo.lock | 2 + src-tauri/Cargo.toml | 2 + src-tauri/src/core/manager/lifecycle.rs | 20 +++--- src-tauri/src/core/mod.rs | 2 + src-tauri/src/core/network_watch.rs | 91 +++++++++++++++++++++++++ src-tauri/src/core/proxy_control.rs | 20 ++---- src-tauri/src/utils/resolve/mod.rs | 4 ++ 7 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 src-tauri/src/core/network_watch.rs diff --git a/Cargo.lock b/Cargo.lock index f1729b62a..40b123df9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1124,6 +1124,7 @@ dependencies = [ "clash_verge_service_ipc", "compact_str", "console-subscriber", + "core-foundation 0.9.4", "criterion", "dark-light", "deelevate", @@ -1157,6 +1158,7 @@ dependencies = [ "smartstring", "socket2 0.6.5", "sysproxy", + "system-configuration", "tauri", "tauri-build", "tauri-plugin-autostart", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 103126a5c..9bb4be725 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -113,6 +113,8 @@ tokio-util = { version = "0.7.19", features = ["time"] } libc = "0.2.189" [target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" +system-configuration = "0.7" objc2 = "0.6.4" objc2-foundation = { version = "0.3.2", features = [ "NSString", diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 62e9a1873..5b95dd98c 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -17,9 +17,6 @@ const fn should_wait_for_service(tun_enabled: bool, service_ready: bool, is_admi tun_enabled && !service_ready && !is_admin } -/// How long a login-time start waits for the network before writing the system proxy. -const STARTUP_NETWORK_WAIT: std::time::Duration = std::time::Duration::from_secs(30); - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StartupDecision { Service, @@ -468,21 +465,28 @@ impl CoreManager { .ok_or_else(|| { anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady) })?; - // Startup only; a user toggling while offline should fail fast. - if cfg!(target_os = "macos") + // At login the app usually beats the network. Leave the write to the network watcher + // 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 && !crate::utils::resolve::is_resolve_done() && Config::verge() .await .latest_arc() .enable_system_proxy .unwrap_or_default() - && !proxy_control::wait_for_network_service(STARTUP_NETWORK_WAIT).await + && !proxy_control::has_network_service().await { logging!( - warn, + info, Type::Core, - "no network service {STARTUP_NETWORK_WAIT:?} after startup; applying the system proxy anyway" + "no network service yet; the system proxy is applied once one appears" ); + return Ok(()); } proxy_control::apply().await?; if !expectation.is_valid( diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 3178ba987..2b3021bc1 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -5,6 +5,8 @@ pub mod hotkey; pub mod listener; pub mod logger; pub mod manager; +#[cfg(target_os = "macos")] +pub mod network_watch; pub mod notification; pub(crate) mod owner_identity; pub mod proxy_control; diff --git a/src-tauri/src/core/network_watch.rs b/src-tauri/src/core/network_watch.rs new file mode 100644 index 000000000..861f1eacf --- /dev/null +++ b/src-tauri/src/core/network_watch.rs @@ -0,0 +1,91 @@ +//! Re-apply the system proxy when macOS gains or switches its primary network service. +//! +//! Proxy settings live per network service. A write that found no service wrote nothing, and a +//! switch to another service leaves the proxy on the old one; both need the write repeated. + +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}, + time::Duration, +}; +use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext}; + +/// Set once the subscription is live. Startup defers the proxy write to the watcher only then. +static ARMED: AtomicBool = AtomicBool::new(false); + +pub fn is_armed() -> bool { + ARMED.load(Ordering::Acquire) +} + +/// Watch the primary IPv4 service from a dedicated run loop thread. Returns once the +/// subscription is live, so a service appearing from here on is never missed. +pub async fn start() { + const ARM_TIMEOUT: Duration = Duration::from_secs(5); + let (ready, armed) = tokio::sync::oneshot::channel(); + let spawned = std::thread::Builder::new().name("network-watch".into()).spawn(move || { + 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 source = store + .filter(|store| store.set_notification_keys(&keys, &CFArray::::from_CFTypes(&[]))) + .and_then(|store| store.create_run_loop_source()); + let Some(source) = source else { + logging!( + warn, + Type::Core, + "could not watch the network; the proxy is not re-applied on changes" + ); + return; + }; + CFRunLoop::get_current().add_source(&source, unsafe { kCFRunLoopCommonModes }); + ARMED.store(true, Ordering::Release); + let _ = ready.send(()); + CFRunLoop::run_current(); + }); + match spawned { + // A thread that gave up drops `ready`; a stuck one must not hold up startup either. + Ok(_) => { + let _ = tokio::time::timeout(ARM_TIMEOUT, armed).await; + } + Err(error) => logging!(warn, Type::Core, "could not start the network watcher: {error}"), + } +} + +fn on_change(_: SCDynamicStore, _: CFArray, (): &mut ()) { + AsyncHandler::spawn(reapply); +} + +async fn reapply() { + // Config lock first, then lifecycle: the repo's order. Holding both also means the config + // read below is committed state, not a patch that may still roll back. + let _config_write = Config::lock_config_write().await; + let manager = CoreManager::global(); + let _life = manager.lifecycle_lock.lock().await; + // Checked under the locks: a task that queued behind a restart may be stale by now. + if !Config::verge() + .await + .latest_arc() + .enable_system_proxy + .unwrap_or_default() + || !proxy_control::has_network_service().await + { + return; + } + if let Err(error) = manager.apply_proxy_after_start().await { + logging!( + warn, + Type::Core, + "failed to re-apply the system proxy after a network change: {error:#}" + ); + } +} diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs index 0d342f33f..9df31e3da 100644 --- a/src-tauri/src/core/proxy_control.rs +++ b/src-tauri/src/core/proxy_control.rs @@ -384,21 +384,13 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result bool { - let no_service = || { - tokio::task::spawn_blocking( - || matches!(sysproxy::Sysproxy::get_system_proxy(), Err(error) if is_missing_network_service(&error)), - ) - }; - tokio::time::timeout(limit, async { - while no_service().await.unwrap_or(false) { - tokio::time::sleep(Duration::from_secs(1)).await; - } - }) +/// Whether macOS has a network service to write the proxy on right now. +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 - .is_ok() + .unwrap_or(true) } pub fn is_reportable(error: &anyhow::Error) -> bool { diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 0c749288c..ed21589ba 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -68,6 +68,10 @@ pub(crate) fn resolve_setup_async() { } Config::verify_config_initialization().await; + // Live before the core starts, so a service appearing mid-start is not missed. + #[cfg(target_os = "macos")] + crate::core::network_watch::start().await; + let core_init = AsyncHandler::spawn(|| async { init_core_manager().await; });