mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-27 08:02:09 +08:00
feat(sysproxy): re-apply the proxy when macOS gains a network service
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<CFString>::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<CFString>, (): &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:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -384,21 +384,13 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
|
||||
service_proxy_config(verge, mixed_port, 0)
|
||||
}
|
||||
|
||||
/// Wait, up to `limit`, for a network service to write the proxy on. At login the app usually
|
||||
/// comes up before the network does, and nothing re-applies a proxy the first write missed.
|
||||
pub async fn wait_for_network_service(limit: Duration) -> 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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user