mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 12:22:07 +08:00
feat: add launch guard and secure multi-user service ownership (#7569)
* feat(service): isolate macOS launch and owner runtime * ci(windows): run owner security tests * chore(deps): use kode-bridge v0.4.1 tag * fix(macos): classify installed service state * fix(macos): classify marked unavailable service * fix(macos): gate core startup on service migration * fix(macos): scope service init to supported platforms * feat(macos): prompt before migrating installed service * fix(macos): shorten sidecar IPC path * build(service): pin helper protocol and artifacts * fix(core): require confirmed service and sidecar readiness * fix(macos): harden launch roots and singleton state * fix(ui): refresh migration state before restart * fix(macos): linearize service migration lifecycle * fix(ui): keep service migration state fresh * feat(service): track authenticated owner sessions * fix(service): preserve newer owner sessions * feat(macos): route service proxy changes through helper * fix(macos): serialize service proxy guard * fix(macos): close service proxy owner lifecycle * fix(macos): close remaining proxy stop races * fix(app): do not cancel controlled service stop * fix(lifecycle): keep controlled updates cancellation-safe * fix(macos): stage dev core for service * test(macos): exercise staged core cleanup * fix(dev): await controlled macOS exit * chore: update changelog * fix(macos): recover sidecar after service install failure Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state. * fix(macos): stage dev service tools before elevation * fix(macos): localize launch guard prompts * fix(service): address cross-platform integration issues * feat: support seamless service migration * fix: pass Windows service installer path safely * fix(prebuild): derive service release from Cargo dependency * fix(dev): preserve service installation state * fix: resolve clippy warnings in service lifecycle
This commit is contained in:
@@ -60,6 +60,11 @@ pub async fn get_auto_proxy() -> CmdResult<Mapping> {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_embedded_server_port() -> CmdResult<u16> {
|
||||
crate::utils::server::embedded_server_port().stringify_err()
|
||||
}
|
||||
|
||||
/// 获取系统主机名
|
||||
#[tauri::command]
|
||||
pub fn get_system_hostname() -> String {
|
||||
|
||||
@@ -21,7 +21,6 @@ use clash_verge_logging::{Type, logging, logging_error};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -276,12 +275,14 @@ async fn handle_update_error<E: std::fmt::Display>(
|
||||
Ok(ValidationOutcome::invalid_from_message(message))
|
||||
}
|
||||
|
||||
async fn handle_timeout(current_profile: Option<&String>) -> CmdResult<ValidationOutcome> {
|
||||
let timeout_msg: String = "配置更新超时(30秒),可能是配置验证或核心通信阻塞".into();
|
||||
logging!(error, Type::Cmd, "{}", timeout_msg);
|
||||
discard_and_restore(current_profile).await?;
|
||||
handle::Handle::notice_message("config_validate::timeout", timeout_msg.clone());
|
||||
Ok(ValidationOutcome::invalid_from_message(timeout_msg))
|
||||
async fn run_profile_config_update_transition<Update, UpdateFuture>(
|
||||
update_config: Update,
|
||||
) -> anyhow::Result<ValidationOutcome>
|
||||
where
|
||||
Update: FnOnce() -> UpdateFuture,
|
||||
UpdateFuture: std::future::Future<Output = anyhow::Result<ValidationOutcome>>,
|
||||
{
|
||||
update_config().await
|
||||
}
|
||||
|
||||
async fn perform_config_update(
|
||||
@@ -291,14 +292,12 @@ async fn perform_config_update(
|
||||
defer! {
|
||||
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
|
||||
}
|
||||
let update_result =
|
||||
tokio::time::timeout(Duration::from_secs(30), CoreManager::global().update_config_forced()).await;
|
||||
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
|
||||
|
||||
match update_result {
|
||||
Ok(Ok(outcome)) if outcome.is_valid() => handle_success(current_value).await,
|
||||
Ok(Ok(outcome)) => handle_validation_failure(outcome, current_profile).await,
|
||||
Ok(Err(e)) => handle_update_error(e, current_profile).await,
|
||||
Err(_) => handle_timeout(current_profile).await,
|
||||
Ok(outcome) if outcome.is_valid() => handle_success(current_value).await,
|
||||
Ok(outcome) => handle_validation_failure(outcome, current_profile).await,
|
||||
Err(e) => handle_update_error(e, current_profile).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,9 +429,32 @@ pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::commit_current_profile;
|
||||
use super::{commit_current_profile, run_profile_config_update_transition};
|
||||
use crate::config::{IProfiles, PrfItem};
|
||||
use crate::core::validate::ValidationOutcome;
|
||||
use clash_verge_draft::Draft;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
struct CancellationProbe {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for CancellationProbe {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed.load(Ordering::Acquire) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(uid: &str) -> PrfItem {
|
||||
PrfItem {
|
||||
@@ -467,4 +489,42 @@ mod tests {
|
||||
assert!(committed.get_item("new").is_ok());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn profile_config_update_runs_past_former_deadline_without_cancellation() -> anyhow::Result<()> {
|
||||
let update_started = Arc::new(Barrier::new(2));
|
||||
let release_update = Arc::new(Barrier::new(2));
|
||||
let update_cancelled = Arc::new(AtomicBool::new(false));
|
||||
let update_completed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let mut update = Box::pin(run_profile_config_update_transition({
|
||||
let update_started = Arc::clone(&update_started);
|
||||
let release_update = Arc::clone(&release_update);
|
||||
let update_cancelled = Arc::clone(&update_cancelled);
|
||||
let update_completed = Arc::clone(&update_completed);
|
||||
move || async move {
|
||||
let _probe = CancellationProbe {
|
||||
cancelled: update_cancelled,
|
||||
completed: Arc::clone(&update_completed),
|
||||
};
|
||||
update_started.wait().await;
|
||||
release_update.wait().await;
|
||||
update_completed.store(true, Ordering::Release);
|
||||
Ok(ValidationOutcome::Valid)
|
||||
}
|
||||
}));
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
update_started.wait().await;
|
||||
tokio::time::advance(Duration::from_secs(31)).await;
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
|
||||
release_update.wait().await;
|
||||
assert!(update.await?.is_valid());
|
||||
assert!(update_completed.load(Ordering::Acquire));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
use super::{CmdResult, StringifyErr as _};
|
||||
use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus};
|
||||
use crate::core::{
|
||||
CoreManager,
|
||||
service::{self, SERVICE_MANAGER, ServiceStatus},
|
||||
};
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult {
|
||||
let manager = CoreManager::global();
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
if matches!(
|
||||
&status,
|
||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||
) {
|
||||
manager
|
||||
.controlled_stop_core_inner()
|
||||
.await
|
||||
.map_err(|error| format!("{op_type} Service failed: {error:#}"))?;
|
||||
}
|
||||
SERVICE_MANAGER
|
||||
.handle_service_status(status)
|
||||
.await
|
||||
@@ -15,7 +29,10 @@ pub async fn install_service() -> CmdResult {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_service() -> CmdResult {
|
||||
execute_service_operation_sync(ServiceStatus::UninstallRequired, "Uninstall").await
|
||||
CoreManager::global()
|
||||
.uninstall_service_and_start_sidecar()
|
||||
.await
|
||||
.map_err(|error| format!("Uninstall Service failed: {error:#}").into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -30,6 +47,18 @@ pub async fn repair_service() -> CmdResult {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_service_available() -> CmdResult<bool> {
|
||||
service::is_service_available().await.stringify_err()?;
|
||||
Ok(true)
|
||||
Ok(service::is_service_available().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_service_install_state() -> CmdResult<service::ServiceInstallState> {
|
||||
Ok(SERVICE_MANAGER.install_state().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
.continue_with_sidecar()
|
||||
.await
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::config::Config;
|
||||
use crate::constants::{network, tun as tun_const};
|
||||
use crate::utils::dirs::{ipc_path, path_to_str};
|
||||
use crate::utils::dirs::{path_to_str, sidecar_ipc_path};
|
||||
use crate::utils::{dirs, help};
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
@@ -326,7 +326,7 @@ impl IClashTemp {
|
||||
|
||||
pub fn guard_external_controller_ipc() -> String {
|
||||
// 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致
|
||||
ipc_path()
|
||||
sidecar_ipc_path()
|
||||
.ok()
|
||||
.and_then(|path| path_to_str(&path).ok().map(|s| s.into()))
|
||||
.unwrap_or_else(|| {
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
core::{
|
||||
CoreManager,
|
||||
handle::{self, Handle},
|
||||
service, tray,
|
||||
tray,
|
||||
validate::CoreConfigValidator,
|
||||
},
|
||||
enhance,
|
||||
@@ -18,8 +18,11 @@ use clash_verge_draft::Draft;
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use smartstring::alias::String;
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -30,6 +33,8 @@ pub struct Config {
|
||||
runtime_config: Draft<IRuntime>,
|
||||
}
|
||||
|
||||
static TUN_SESSION_SUPPRESSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
impl Config {
|
||||
pub async fn global() -> &'static Self {
|
||||
static CONFIG: OnceCell<Config> = OnceCell::const_new();
|
||||
@@ -73,23 +78,35 @@ impl Config {
|
||||
let verge = Self::verge().await.latest_arc();
|
||||
clash_verge_i18n::sync_locale(verge.language.as_deref());
|
||||
|
||||
// init Tun mode
|
||||
let handle = Handle::app_handle();
|
||||
let is_admin = is_current_app_handle_admin(handle);
|
||||
let is_service_available = service::is_service_available().await.is_ok();
|
||||
if !is_admin && !is_service_available {
|
||||
let verge = Self::verge().await;
|
||||
verge.edit_draft(|d| {
|
||||
d.enable_tun_mode = Some(false);
|
||||
});
|
||||
verge.apply();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 分离数据获取和异步调用避免Send问题
|
||||
let verge_data = Self::verge().await.latest_arc();
|
||||
logging_error!(Type::Core, verge_data.save_file().await);
|
||||
}
|
||||
pub fn tun_suppressed_for_session() -> bool {
|
||||
TUN_SESSION_SUPPRESSED.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) async fn suppress_tun_for_session() {
|
||||
TUN_SESSION_SUPPRESSED.store(true, Ordering::Release);
|
||||
Handle::refresh_verge();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_tun_for_session() {
|
||||
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
|
||||
Handle::refresh_verge();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn disable_tun_and_persist() -> Result<()> {
|
||||
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
|
||||
let verge = Self::verge().await;
|
||||
verge.edit_draft(|draft| {
|
||||
draft.enable_tun_mode = Some(false);
|
||||
});
|
||||
verge.apply();
|
||||
verge.data_arc().save_file().await?;
|
||||
Handle::refresh_verge();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -556,10 +556,6 @@ impl IVerge {
|
||||
patch!(enable_external_controller);
|
||||
}
|
||||
|
||||
pub const fn get_singleton_port() -> u16 {
|
||||
crate::constants::network::ports::SINGLETON_SERVER
|
||||
}
|
||||
|
||||
/// 获取日志等级
|
||||
pub fn get_log_level(&self) -> LevelFilter {
|
||||
if let Some(level) = self.app_log_level.as_ref() {
|
||||
|
||||
@@ -11,11 +11,6 @@ pub mod network {
|
||||
pub const DEFAULT_MIXED: u16 = 7897;
|
||||
pub const DEFAULT_SOCKS: u16 = 7898;
|
||||
pub const DEFAULT_HTTP: u16 = 7899;
|
||||
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
pub const SINGLETON_SERVER: u16 = 33331;
|
||||
#[cfg(feature = "verge-dev")]
|
||||
pub const SINGLETON_SERVER: u16 = 11233;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ impl Handle {
|
||||
self.is_exiting.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn clear_is_exiting(&self) {
|
||||
self.is_exiting.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn is_exiting(&self) -> bool {
|
||||
self.is_exiting.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use std::{
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::WriterConfig;
|
||||
use compact_str::CompactString;
|
||||
use flexi_logger::{
|
||||
Cleanup, Criterion, DeferredNow, FileSpec, LogSpecBuilder, LogSpecification, LoggerHandle,
|
||||
@@ -20,7 +19,7 @@ use parking_lot::{Mutex, RwLock};
|
||||
use crate::{
|
||||
core::service,
|
||||
singleton,
|
||||
utils::dirs::{self, service_log_dir, sidecar_log_dir},
|
||||
utils::dirs::{self, sidecar_log_dir},
|
||||
};
|
||||
|
||||
pub struct Logger {
|
||||
@@ -180,10 +179,9 @@ impl Logger {
|
||||
*self.sidecar_file_writer.write() = Some(sidecar_writer);
|
||||
|
||||
// update service writer config
|
||||
if service::is_service_ipc_path_exists() && service::is_service_available().await.is_ok() {
|
||||
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
|
||||
clash_verge_service_ipc::update_writer(&WriterConfig {
|
||||
directory: service_log_dir,
|
||||
if service::is_service_available().await {
|
||||
service::update_writer_by_service(&clash_verge_service_ipc::WriterConfig {
|
||||
directory: String::new(),
|
||||
max_log_size: log_max_size * 1024,
|
||||
max_log_files: log_max_count,
|
||||
})
|
||||
@@ -225,17 +223,4 @@ impl Logger {
|
||||
logging!(error, Type::System, "failed to get sidecar file log writer");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service_writer_config(&self) -> Result<WriterConfig> {
|
||||
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
|
||||
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
|
||||
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
|
||||
let writer_config = WriterConfig {
|
||||
directory: service_log_dir,
|
||||
max_log_size: log_max_size * 1024,
|
||||
max_log_files: log_max_count,
|
||||
};
|
||||
|
||||
Ok(writer_config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::CoreManager;
|
||||
use super::{CoreManager, RunningMode};
|
||||
use crate::{
|
||||
config::{Config, ConfigType, runtime::IRuntime},
|
||||
constants::timing,
|
||||
@@ -136,6 +136,27 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
async fn apply_config(&self, path: PathBuf) -> Result<()> {
|
||||
if matches!(*self.get_running_mode(), RunningMode::Service) {
|
||||
let _lifecycle = self.lifecycle_lock.lock().await;
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Service) {
|
||||
Config::runtime().await.discard();
|
||||
return Err(anyhow!("core mode changed while applying service configuration"));
|
||||
}
|
||||
let result = self.replace_service_core_with_config(&path).await;
|
||||
|
||||
return match result {
|
||||
Ok(()) => {
|
||||
Config::runtime().await.apply();
|
||||
logging!(info, Type::Core, "Configuration materialized and applied by service");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
Config::runtime().await.discard();
|
||||
Err(error)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let path = dirs::path_to_str(&path)?;
|
||||
match self.reload_config(path).await {
|
||||
Ok(_) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ use std::{
|
||||
fmt,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
@@ -22,7 +22,37 @@ use std::os::windows::io::OwnedHandle;
|
||||
|
||||
pub(crate) static CLASH_LOGGER: Lazy<Arc<AsyncLogger>> = Lazy::new(|| Arc::new(AsyncLogger::new()));
|
||||
|
||||
#[derive(Debug, serde::Serialize, PartialEq, Eq)]
|
||||
const CORE_READINESS_ACTIVE_BIT: u64 = 1;
|
||||
const CORE_READINESS_GENERATION_STEP: u64 = 1 << 1;
|
||||
|
||||
const fn next_active_core_readiness_state(state: u64) -> u64 {
|
||||
(state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT) | CORE_READINESS_ACTIVE_BIT
|
||||
}
|
||||
|
||||
const fn inactive_core_readiness_state(state: u64) -> u64 {
|
||||
state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT
|
||||
}
|
||||
|
||||
const fn active_core_readiness_generation(state: u64) -> Option<u64> {
|
||||
if state & CORE_READINESS_ACTIVE_BIT != 0 {
|
||||
Some(state)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64) -> bool {
|
||||
state
|
||||
.compare_exchange(
|
||||
captured_generation,
|
||||
inactive_core_readiness_state(captured_generation),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)]
|
||||
pub enum RunningMode {
|
||||
Service,
|
||||
Sidecar,
|
||||
@@ -46,9 +76,10 @@ pub struct CoreManager {
|
||||
#[cfg(target_os = "windows")]
|
||||
job_handle: ArcSwapOption<OwnedHandle>,
|
||||
config_update_in_progress: AtomicBool,
|
||||
core_readiness_state: AtomicU64,
|
||||
// 串行化 start/stop/restart 和 sidecar→service 交接。
|
||||
// 锁序固定为 config_update_in_progress → lifecycle_lock。
|
||||
lifecycle_lock: tokio::sync::Mutex<()>,
|
||||
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
|
||||
// sidecar→service 交接 watcher 单实例标志。
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool,
|
||||
@@ -77,6 +108,7 @@ impl Default for CoreManager {
|
||||
#[cfg(target_os = "windows")]
|
||||
job_handle: ArcSwapOption::new(None),
|
||||
config_update_in_progress: AtomicBool::new(false),
|
||||
core_readiness_state: AtomicU64::new(0),
|
||||
lifecycle_lock: tokio::sync::Mutex::new(()),
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool::new(false),
|
||||
@@ -101,11 +133,18 @@ impl CoreManager {
|
||||
.and_then(|arc| Arc::try_unwrap(arc).ok())
|
||||
}
|
||||
|
||||
pub fn get_running_sidecar_pid(&self) -> Option<u32> {
|
||||
self.state.load().child_sidecar.load_full().map(|child| child.pid())
|
||||
}
|
||||
|
||||
pub fn get_last_update(&self) -> Option<Arc<Instant>> {
|
||||
self.last_update.load_full()
|
||||
}
|
||||
|
||||
pub fn set_running_mode(&self, mode: RunningMode) {
|
||||
if matches!(mode, RunningMode::NotRunning) {
|
||||
self.invalidate_core_readiness();
|
||||
}
|
||||
let state = self.state.load();
|
||||
state.running_mode.store(Arc::new(mode));
|
||||
}
|
||||
@@ -115,6 +154,36 @@ impl CoreManager {
|
||||
state.child_sidecar.store(Some(Arc::new(child)));
|
||||
}
|
||||
|
||||
fn mark_core_ready(&self) -> u64 {
|
||||
let mut current = self.core_readiness_state.load(Ordering::Acquire);
|
||||
loop {
|
||||
let next = next_active_core_readiness_state(current);
|
||||
match self
|
||||
.core_readiness_state
|
||||
.compare_exchange(current, next, Ordering::AcqRel, Ordering::Acquire)
|
||||
{
|
||||
Ok(_) => return next,
|
||||
Err(changed) => current = changed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_core_readiness_generation(&self) -> Option<u64> {
|
||||
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_core_readiness(&self) {
|
||||
let _ = self
|
||||
.core_readiness_state
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
active_core_readiness_generation(current).map(inactive_core_readiness_state)
|
||||
});
|
||||
}
|
||||
|
||||
fn invalidate_core_readiness_if(&self, captured_generation: u64) -> bool {
|
||||
claim_core_readiness_generation(&self.core_readiness_state, captured_generation)
|
||||
}
|
||||
|
||||
pub fn set_last_update(&self, time: Instant) {
|
||||
self.last_update.store(Some(Arc::new(time)));
|
||||
}
|
||||
@@ -138,9 +207,9 @@ impl CoreManager {
|
||||
self.config_update_in_progress.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
pub async fn init(&self) -> Result<bool> {
|
||||
self.start_core().await?;
|
||||
Ok(())
|
||||
Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
#[cfg(test)]
|
||||
use super::claim_core_readiness_generation;
|
||||
use super::{CoreManager, RunningMode};
|
||||
use crate::{
|
||||
AsyncHandler,
|
||||
config::{Config, IClashTemp},
|
||||
core::{handle, logger::Logger, manager::CLASH_LOGGER, service},
|
||||
config::Config,
|
||||
core::{handle, logger::Logger, manager::CLASH_LOGGER, proxy_control, service},
|
||||
logging,
|
||||
utils::dirs,
|
||||
utils::{dirs, server},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_logging::Type;
|
||||
use compact_str::CompactString;
|
||||
use log::Level;
|
||||
use scopeguard::defer;
|
||||
use std::path::Path;
|
||||
use tauri_plugin_mihomo::MihomoExt as _;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
|
||||
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
|
||||
const SIDECAR_READINESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
const SIDECAR_READINESS_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
async fn poll_sidecar_readiness<F, Fut>(
|
||||
max_attempts: usize,
|
||||
retry_delay: std::time::Duration,
|
||||
mut probe: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let mut last_error = None;
|
||||
for attempt in 0..max_attempts {
|
||||
match probe().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
if attempt + 1 < max_attempts {
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
}
|
||||
}
|
||||
Err(last_error
|
||||
.unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts"))
|
||||
.context("Mihomo API did not become ready"))
|
||||
}
|
||||
|
||||
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
|
||||
matches!(running_mode, RunningMode::Sidecar) && current_pid == Some(terminated_pid)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use {
|
||||
std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle},
|
||||
@@ -39,33 +75,43 @@ impl CoreManager {
|
||||
|
||||
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Starting core in sidecar mode");
|
||||
server::set_pac_available(false);
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
|
||||
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
||||
handle::Handle::app_handle()
|
||||
.mihomo()
|
||||
.write()
|
||||
.await
|
||||
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
|
||||
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
|
||||
let config_dir = dirs::app_home_dir()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o007) };
|
||||
let (mut rx, child) = app_handle
|
||||
.shell()
|
||||
.sidecar(clash_core.as_str())?
|
||||
.args([
|
||||
"-d",
|
||||
dirs::path_to_str(&config_dir)?,
|
||||
"-f",
|
||||
dirs::path_to_str(&config_file)?,
|
||||
if cfg!(windows) {
|
||||
"-ext-ctl-pipe"
|
||||
} else {
|
||||
"-ext-ctl-unix"
|
||||
},
|
||||
&IClashTemp::guard_external_controller_ipc(),
|
||||
])
|
||||
.spawn()?;
|
||||
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o077) };
|
||||
let command = app_handle.shell().sidecar(clash_core.as_str())?.args([
|
||||
"-d",
|
||||
dirs::path_to_str(&config_dir)?,
|
||||
"-f",
|
||||
dirs::path_to_str(&config_file)?,
|
||||
if cfg!(windows) {
|
||||
"-ext-ctl-pipe"
|
||||
} else {
|
||||
"-ext-ctl-unix"
|
||||
},
|
||||
dirs::path_to_str(&sidecar_ipc)?,
|
||||
]);
|
||||
#[cfg(windows)]
|
||||
let command = command.env(
|
||||
"LISTEN_NAMEDPIPE_SDDL",
|
||||
crate::core::owner_identity::current_user_pipe_sddl()?,
|
||||
);
|
||||
let (mut rx, child) = command.spawn()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let job = match create_and_assign_sidecar_job(child.pid()) {
|
||||
let job = {
|
||||
match create_and_assign_sidecar_job(child.pid()) {
|
||||
Ok(job) => job,
|
||||
Err(job_error) => {
|
||||
let pid = child.pid();
|
||||
@@ -81,9 +127,8 @@ impl CoreManager {
|
||||
logging!(error, Type::Core, "Failed to start sidecar: {error:#}");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.set_job_handle(Some(job));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
@@ -93,10 +138,36 @@ impl CoreManager {
|
||||
let pid = child.pid();
|
||||
logging!(trace, Type::Core, "Sidecar started with PID: {}", pid);
|
||||
|
||||
self.set_running_child_sidecar(child);
|
||||
self.set_running_mode(RunningMode::Sidecar);
|
||||
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
|
||||
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
|
||||
handle::Handle::mihomo().await.get_version().await
|
||||
})
|
||||
.await
|
||||
.context("Mihomo readiness probe timed out")??;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(readiness_error) = readiness {
|
||||
proxy_control::stop_guard().await;
|
||||
self.invalidate_core_readiness();
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
server::set_pac_available(false);
|
||||
return match child.kill() {
|
||||
Ok(()) => Err(readiness_error),
|
||||
Err(kill_error) => Err(anyhow::anyhow!(
|
||||
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
AsyncHandler::spawn(|| async move {
|
||||
#[cfg(target_os = "windows")]
|
||||
self.set_job_handle(Some(job));
|
||||
self.set_running_child_sidecar(child);
|
||||
let core_readiness_generation = self.mark_core_ready();
|
||||
self.set_running_mode(RunningMode::Sidecar);
|
||||
server::set_pac_available(true);
|
||||
|
||||
AsyncHandler::spawn(move || async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
||||
@@ -106,6 +177,8 @@ impl CoreManager {
|
||||
CLASH_LOGGER.append_log(message).await;
|
||||
}
|
||||
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
|
||||
let manager = Self::global();
|
||||
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
|
||||
let message = if let Some(code) = term.code {
|
||||
CompactString::from(format!("Process terminated with code: {}", code))
|
||||
} else if let Some(signal) = term.signal {
|
||||
@@ -115,6 +188,7 @@ impl CoreManager {
|
||||
};
|
||||
Logger::global().writer_sidecar_log(Level::Info, &message);
|
||||
CLASH_LOGGER.clear_logs().await;
|
||||
manager.clear_terminated_sidecar(pid).await;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
@@ -125,9 +199,12 @@ impl CoreManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn stop_core_by_sidecar(&self) {
|
||||
/// Terminates the sidecar after its caller has successfully cleared the
|
||||
/// system proxy.
|
||||
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
|
||||
logging!(info, Type::Core, "Stopping sidecar");
|
||||
defer! {
|
||||
server::set_pac_available(false);
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
}
|
||||
if let Some(child) = self.take_child_sidecar() {
|
||||
@@ -159,16 +236,28 @@ impl CoreManager {
|
||||
|
||||
pub(super) async fn start_core_by_service(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Starting core in service mode");
|
||||
server::set_pac_available(false);
|
||||
let service_ipc = dirs::ipc_path()?;
|
||||
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
|
||||
handle::Handle::app_handle()
|
||||
.mihomo()
|
||||
.write()
|
||||
.await
|
||||
.update_socket_path(dirs::path_to_str(&service_ipc)?.to_owned())?;
|
||||
|
||||
self.start_core_by_service_with_config(&config_file).await
|
||||
}
|
||||
|
||||
pub(super) async fn start_core_by_service_with_config(&self, config_file: &Path) -> Result<()> {
|
||||
// 交接时等待 sidecar 释放 ext-controller 通道。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::constants::timing;
|
||||
let mut last_err = None;
|
||||
for attempt in 0..timing::SERVICE_START_RETRIES {
|
||||
match service::run_core_by_service(&config_file).await {
|
||||
match service::run_core_by_service(config_file).await {
|
||||
Ok(()) => {
|
||||
self.mark_core_ready();
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -191,7 +280,8 @@ impl CoreManager {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
service::run_core_by_service(&config_file).await?;
|
||||
service::run_core_by_service(config_file).await?;
|
||||
self.mark_core_ready();
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
Ok(())
|
||||
}
|
||||
@@ -199,12 +289,100 @@ impl CoreManager {
|
||||
|
||||
pub(super) async fn stop_core_by_service(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Stopping service");
|
||||
defer! {
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
}
|
||||
service::stop_core_by_service().await?;
|
||||
server::set_pac_available(false);
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_terminated_sidecar(&self, terminated_pid: u32) {
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
if !should_clear_terminated_sidecar(&self.get_running_mode(), self.get_running_sidecar_pid(), terminated_pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = self.take_child_sidecar();
|
||||
#[cfg(target_os = "windows")]
|
||||
self.set_job_handle(None);
|
||||
proxy_control::stop_guard().await;
|
||||
server::set_pac_available(false);
|
||||
self.invalidate_core_readiness();
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.after_core_process();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod readiness_tests {
|
||||
use super::{claim_core_readiness_generation, poll_sidecar_readiness, should_clear_terminated_sidecar};
|
||||
use crate::core::manager::{CoreManager, RunningMode};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let probe_attempts = Arc::clone(&attempts);
|
||||
poll_sidecar_readiness(3, Duration::ZERO, move || {
|
||||
let attempt = probe_attempts.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
async move {
|
||||
if attempt == 3 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("not ready"))
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
||||
|
||||
let failed_attempts = Arc::new(AtomicUsize::new(0));
|
||||
let probe_attempts = Arc::clone(&failed_attempts);
|
||||
assert!(
|
||||
poll_sidecar_readiness(3, Duration::ZERO, move || {
|
||||
probe_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err(anyhow::anyhow!("still unavailable")) }
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(failed_attempts.load(Ordering::SeqCst), 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_current_sidecar_termination_clears_local_state() {
|
||||
assert!(should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(42), 42));
|
||||
assert!(!should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(43), 42));
|
||||
assert!(!should_clear_terminated_sidecar(&RunningMode::Service, Some(42), 42));
|
||||
assert!(!should_clear_terminated_sidecar(&RunningMode::NotRunning, None, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_readiness_generation_can_only_be_claimed_once() {
|
||||
let generation = AtomicU64::new(7);
|
||||
|
||||
assert!(claim_core_readiness_generation(&generation, 7));
|
||||
assert_eq!(generation.load(Ordering::Acquire), 8);
|
||||
assert!(!claim_core_readiness_generation(&generation, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_core_readiness_cannot_be_recaptured_from_stale_mode() {
|
||||
let manager = CoreManager::default();
|
||||
manager.mark_core_ready();
|
||||
manager.set_running_mode(RunningMode::Service);
|
||||
|
||||
manager.invalidate_core_readiness();
|
||||
|
||||
assert_eq!(*manager.get_running_mode(), RunningMode::Service);
|
||||
assert_eq!(manager.current_core_readiness_generation(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -5,7 +5,10 @@ pub mod hotkey;
|
||||
pub mod logger;
|
||||
pub mod manager;
|
||||
mod notification;
|
||||
pub(crate) mod owner_identity;
|
||||
pub mod proxy_control;
|
||||
pub mod proxy_view;
|
||||
mod runtime_bundle;
|
||||
pub mod service;
|
||||
pub mod sysopt;
|
||||
pub mod timer;
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
use crate::utils::dirs;
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_service_ipc::{OwnerCredentials, OwnerIdentity};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn current_owner_credentials() -> Result<OwnerCredentials> {
|
||||
current_owner_credentials_for_root(&dirs::app_home_dir()?)
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)] // Windows SID discovery is fallible; Unix keeps the shared API.
|
||||
pub(crate) fn current_owner_identity() -> Result<OwnerIdentity> {
|
||||
#[cfg(unix)]
|
||||
return Ok(OwnerIdentity::Unix {
|
||||
uid: unsafe { libc::geteuid() },
|
||||
gid: unsafe { libc::getegid() },
|
||||
});
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_current_identity()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_owner_credentials_for_root(app_root: &Path) -> Result<OwnerCredentials> {
|
||||
let app_data_root = std::fs::canonicalize(app_root)
|
||||
.with_context(|| format!("failed to canonicalize application data root {app_root:?}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let (identity, token) = (current_owner_identity()?, None);
|
||||
|
||||
#[cfg(windows)]
|
||||
let (identity, token) = windows_owner_credentials(&app_data_root)?;
|
||||
|
||||
Ok(OwnerCredentials {
|
||||
identity,
|
||||
app_data_dir: app_data_root.to_string_lossy().into_owned(),
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option<String>)> {
|
||||
let sid = windows_owner::current_sid()?;
|
||||
let token = windows_owner::load_or_create_token(app_data_root, &sid)?;
|
||||
Ok((OwnerIdentity::Windows { sid }, Some(token)))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_current_identity() -> Result<OwnerIdentity> {
|
||||
Ok(OwnerIdentity::Windows {
|
||||
sid: windows_owner::current_sid()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::create_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_or_create_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn current_user_pipe_sddl() -> Result<String> {
|
||||
let sid = windows_owner::current_sid()?;
|
||||
Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_owner {
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_service_ipc::OWNER_TOKEN_FILE_NAME;
|
||||
use std::ffi::c_void;
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _};
|
||||
use std::path::Path;
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, ERROR_FILE_EXISTS, GENERIC_READ, GENERIC_WRITE, GetLastError, INVALID_HANDLE_VALUE, LocalFree,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SDDL_REVISION_1,
|
||||
SE_FILE_OBJECT, SetSecurityInfo,
|
||||
};
|
||||
use windows_sys::Win32::Security::{
|
||||
ACCESS_ALLOWED_ACE, ACL, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetSecurityDescriptorControl,
|
||||
GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, IsValidSid, IsWellKnownSid,
|
||||
OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED,
|
||||
SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
|
||||
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK, GetFileInformationByHandle, GetFileType,
|
||||
OPEN_EXISTING, READ_CONTROL, WRITE_DAC,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
|
||||
|
||||
pub(super) fn current_sid() -> Result<String> {
|
||||
let mut token = std::ptr::null_mut();
|
||||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to open process token");
|
||||
}
|
||||
let token = OwnedHandle(token);
|
||||
|
||||
let mut required = 0_u32;
|
||||
unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut required) };
|
||||
if required == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to size process SID buffer");
|
||||
}
|
||||
let words = (required as usize).div_ceil(std::mem::size_of::<usize>());
|
||||
let mut buffer = vec![0_usize; words];
|
||||
if unsafe { GetTokenInformation(token.0, TokenUser, buffer.as_mut_ptr().cast(), required, &mut required) } == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to read process SID");
|
||||
}
|
||||
let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
|
||||
sid_to_string(token_user.User.Sid)
|
||||
}
|
||||
|
||||
pub(super) fn load_or_create_token(app_data_root: &Path, sid: &str) -> Result<String> {
|
||||
let token_path = app_data_root.join(OWNER_TOKEN_FILE_NAME);
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(sid)?;
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: descriptor.0,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
let wide = wide_path(&token_path)?;
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
&attributes,
|
||||
CREATE_NEW,
|
||||
FILE_ATTRIBUTE_HIDDEN | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if handle != INVALID_HANDLE_VALUE {
|
||||
let mut file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
let mut token = [0_u8; TOKEN_BYTES];
|
||||
getrandom::fill(&mut token).context("failed to generate owner token")?;
|
||||
file.write_all(&token).context("failed to write owner token")?;
|
||||
file.sync_all().context("failed to flush owner token")?;
|
||||
return Ok(encode_token(&token));
|
||||
}
|
||||
if unsafe { GetLastError() } != ERROR_FILE_EXISTS {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to create owner token");
|
||||
}
|
||||
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to open owner token");
|
||||
}
|
||||
let mut file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
validate_token_file(&file, descriptor.owner()?)?;
|
||||
descriptor.apply_dacl(file.as_raw_handle())?;
|
||||
|
||||
let mut token = [0_u8; TOKEN_BYTES];
|
||||
file.read_exact(&mut token).context("failed to read owner token")?;
|
||||
Ok(encode_token(&token))
|
||||
}
|
||||
|
||||
pub(super) fn create_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
create_file(path, &descriptor)
|
||||
}
|
||||
|
||||
pub(super) fn open_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
let file = open_file(path)?;
|
||||
validate_private_file(&file, descriptor.owner()?)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
match create_file(path, &descriptor) {
|
||||
Ok(file) => Ok(file),
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) =>
|
||||
{
|
||||
let file = open_file(path)?;
|
||||
validate_private_file(&file, descriptor.owner()?)?;
|
||||
Ok(file)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: descriptor.0,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
let wide = wide_path(path)?;
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
&attributes,
|
||||
CREATE_NEW,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to create private current-user file");
|
||||
}
|
||||
let file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
validate_private_file(&file, descriptor.owner()?)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn open_file(path: &Path) -> Result<std::fs::File> {
|
||||
let wide = wide_path(path)?;
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to open private current-user file");
|
||||
}
|
||||
Ok(unsafe { std::fs::File::from_raw_handle(handle) })
|
||||
}
|
||||
|
||||
fn validate_private_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> {
|
||||
let handle = file.as_raw_handle();
|
||||
let mut information = BY_HANDLE_FILE_INFORMATION::default();
|
||||
if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to inspect private file metadata");
|
||||
}
|
||||
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
||||
|| unsafe { GetFileType(handle) } != FILE_TYPE_DISK
|
||||
{
|
||||
bail!("current-user file is not an ordinary file");
|
||||
}
|
||||
|
||||
let mut owner = std::ptr::null_mut();
|
||||
let mut dacl: *mut ACL = std::ptr::null_mut();
|
||||
let mut security = std::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
GetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
||||
&mut owner,
|
||||
std::ptr::null_mut(),
|
||||
&mut dacl,
|
||||
std::ptr::null_mut(),
|
||||
&mut security,
|
||||
)
|
||||
};
|
||||
if status != 0 || security.is_null() {
|
||||
bail!("failed to inspect private file security: Windows error {status}");
|
||||
}
|
||||
let security = LocalSecurityDescriptor(security);
|
||||
if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 || dacl.is_null() {
|
||||
bail!("current-user file owner or DACL is invalid");
|
||||
}
|
||||
let mut control = 0_u16;
|
||||
let mut revision = 0_u32;
|
||||
if unsafe { GetSecurityDescriptorControl(security.0, &mut control, &mut revision) } == 0
|
||||
|| control & SE_DACL_PROTECTED == 0
|
||||
{
|
||||
bail!("current-user file DACL is not protected");
|
||||
}
|
||||
|
||||
let mut owner_ace = false;
|
||||
let mut system_ace = false;
|
||||
let mut administrators_ace = false;
|
||||
for index in 0..unsafe { (*dacl).AceCount } as u32 {
|
||||
let mut ace = std::ptr::null_mut();
|
||||
if unsafe { GetAce(dacl, index, &mut ace) } == 0 || ace.is_null() {
|
||||
bail!("current-user file DACL could not be inspected");
|
||||
}
|
||||
let allowed = unsafe { &*ace.cast::<ACCESS_ALLOWED_ACE>() };
|
||||
if allowed.Header.AceType != ACCESS_ALLOWED_ACE_TYPE || allowed.Mask & FILE_ALL_ACCESS != FILE_ALL_ACCESS {
|
||||
bail!("current-user file DACL contains an unexpected ACE");
|
||||
}
|
||||
let ace_sid = std::ptr::addr_of!(allowed.SidStart).cast_mut().cast::<c_void>();
|
||||
if unsafe { IsValidSid(ace_sid) } == 0 {
|
||||
bail!("current-user file DACL contains an invalid SID");
|
||||
}
|
||||
if unsafe { EqualSid(ace_sid, expected_owner) } != 0 {
|
||||
owner_ace = true;
|
||||
} else if unsafe { IsWellKnownSid(ace_sid, WinLocalSystemSid) } != 0 {
|
||||
system_ace = true;
|
||||
} else if unsafe { IsWellKnownSid(ace_sid, WinBuiltinAdministratorsSid) } != 0 {
|
||||
administrators_ace = true;
|
||||
} else {
|
||||
bail!("current-user file DACL grants access to another SID");
|
||||
}
|
||||
}
|
||||
if unsafe { (*dacl).AceCount } != 3 || !owner_ace || !system_ace || !administrators_ace {
|
||||
bail!("current-user file DACL is missing a required principal");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_token_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> {
|
||||
let handle = file.as_raw_handle();
|
||||
let mut information = BY_HANDLE_FILE_INFORMATION::default();
|
||||
if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to inspect owner token metadata");
|
||||
}
|
||||
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
||||
|| unsafe { GetFileType(handle) } != FILE_TYPE_DISK
|
||||
|| information.nFileSizeHigh != 0
|
||||
|| information.nFileSizeLow != TOKEN_BYTES as u32
|
||||
{
|
||||
bail!("owner token is not an ordinary 32-byte file");
|
||||
}
|
||||
|
||||
let mut owner = std::ptr::null_mut();
|
||||
let mut security = std::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
GetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION,
|
||||
&mut owner,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut security,
|
||||
)
|
||||
};
|
||||
if status != 0 || security.is_null() {
|
||||
bail!("failed to inspect owner token security: Windows error {status}");
|
||||
}
|
||||
let security = LocalSecurityDescriptor(security);
|
||||
if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 {
|
||||
bail!("owner token belongs to a different Windows user");
|
||||
}
|
||||
drop(security);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sid_to_string(sid: PSID) -> Result<String> {
|
||||
let mut value = std::ptr::null_mut();
|
||||
if unsafe { ConvertSidToStringSidW(sid, &mut value) } == 0 || value.is_null() {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to format process SID");
|
||||
}
|
||||
let value = LocalWideString(value);
|
||||
let length = (0..).take_while(|index| unsafe { *value.0.add(*index) } != 0).count();
|
||||
String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) })
|
||||
.context("process SID is not valid UTF-16")
|
||||
}
|
||||
|
||||
fn encode_token(token: &[u8; TOKEN_BYTES]) -> String {
|
||||
let mut encoded = String::with_capacity(TOKEN_BYTES * 2);
|
||||
for byte in token {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(encoded, "{byte:02x}");
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn wide_path(path: &Path) -> Result<Vec<u16>> {
|
||||
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
|
||||
if wide.contains(&0) {
|
||||
bail!("owner token path contains NUL");
|
||||
}
|
||||
wide.push(0);
|
||||
Ok(wide)
|
||||
}
|
||||
|
||||
struct OwnedHandle(*mut c_void);
|
||||
|
||||
impl Drop for OwnedHandle {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
|
||||
unsafe { CloseHandle(self.0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalWideString(*mut u16);
|
||||
|
||||
impl Drop for LocalWideString {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe { LocalFree(self.0.cast()) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalSecurityDescriptor(PSECURITY_DESCRIPTOR);
|
||||
|
||||
impl LocalSecurityDescriptor {
|
||||
fn from_sid(sid: &str) -> Result<Self> {
|
||||
let sddl = format!("O:{sid}D:P(A;;FA;;;{sid})(A;;FA;;;SY)(A;;FA;;;BA)");
|
||||
let mut wide: Vec<u16> = sddl.encode_utf16().collect();
|
||||
wide.push(0);
|
||||
let mut descriptor = std::ptr::null_mut();
|
||||
if unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut descriptor,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} == 0
|
||||
|| descriptor.is_null()
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to build owner token security descriptor");
|
||||
}
|
||||
Ok(Self(descriptor))
|
||||
}
|
||||
|
||||
fn owner(&self) -> Result<PSID> {
|
||||
let mut owner = std::ptr::null_mut();
|
||||
let mut defaulted = 0;
|
||||
if unsafe { GetSecurityDescriptorOwner(self.0, &mut owner, &mut defaulted) } == 0 || owner.is_null() {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to read token descriptor owner");
|
||||
}
|
||||
Ok(owner)
|
||||
}
|
||||
|
||||
fn apply_dacl(&self, handle: *mut c_void) -> Result<()> {
|
||||
let mut present = 0;
|
||||
let mut defaulted = 0;
|
||||
let mut dacl = std::ptr::null_mut();
|
||||
if unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut dacl, &mut defaulted) } == 0
|
||||
|| present == 0
|
||||
|| dacl.is_null()
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to read token descriptor DACL");
|
||||
}
|
||||
let status = unsafe {
|
||||
SetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
dacl,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
bail!("failed to restrict owner token DACL: Windows error {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LocalSecurityDescriptor {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe { LocalFree(self.0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::current_owner_credentials_for_root;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_credentials_come_from_process_and_have_no_token() -> anyhow::Result<()> {
|
||||
let app_root = std::env::temp_dir();
|
||||
|
||||
let credentials = current_owner_credentials_for_root(&app_root)?;
|
||||
|
||||
assert_eq!(
|
||||
credentials.identity,
|
||||
OwnerIdentity::Unix {
|
||||
uid: unsafe { libc::geteuid() },
|
||||
gid: unsafe { libc::getegid() },
|
||||
}
|
||||
);
|
||||
assert_eq!(credentials.token, None);
|
||||
assert_eq!(
|
||||
std::path::PathBuf::from(credentials.app_data_dir),
|
||||
std::fs::canonicalize(app_root)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_credentials_use_stable_sid_and_private_token() -> anyhow::Result<()> {
|
||||
let app_root = std::env::temp_dir().join(format!("cvr-owner-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&app_root);
|
||||
std::fs::create_dir(&app_root)?;
|
||||
|
||||
let first = current_owner_credentials_for_root(&app_root)?;
|
||||
let second = current_owner_credentials_for_root(&app_root)?;
|
||||
|
||||
let OwnerIdentity::Windows { sid } = &first.identity else {
|
||||
anyhow::bail!("expected Windows owner identity");
|
||||
};
|
||||
assert!(sid.starts_with("S-1-5-"));
|
||||
assert_eq!(first.identity, second.identity);
|
||||
assert_eq!(first.token, second.token);
|
||||
assert_eq!(first.token.as_deref().map(str::len), Some(64));
|
||||
|
||||
std::fs::remove_dir_all(app_root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn private_file_open_rejects_directories_and_reparse_points() -> anyhow::Result<()> {
|
||||
use super::{create_private_current_user_file, open_private_current_user_file};
|
||||
|
||||
let root = std::env::temp_dir().join(format!("cvr-private-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir(&root)?;
|
||||
|
||||
let private_file = root.join("record.json");
|
||||
drop(create_private_current_user_file(&private_file)?);
|
||||
drop(open_private_current_user_file(&private_file)?);
|
||||
|
||||
let directory = root.join("directory");
|
||||
std::fs::create_dir(&directory)?;
|
||||
assert!(open_private_current_user_file(&directory).is_err());
|
||||
|
||||
let link = root.join("record-link.json");
|
||||
if let Err(error) = std::os::windows::fs::symlink_file(&private_file, &link) {
|
||||
if error.raw_os_error() == Some(1314) {
|
||||
std::fs::remove_dir_all(root)?;
|
||||
return Ok(());
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
assert!(open_private_current_user_file(&link).is_err());
|
||||
|
||||
std::fs::remove_file(link)?;
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt},
|
||||
process::AsyncHandler,
|
||||
utils::server,
|
||||
};
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
|
||||
use std::{
|
||||
future::Future,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const LOOPBACK_HOST: &str = "127.0.0.1";
|
||||
const MACOS_DEFAULT_BYPASS: &str =
|
||||
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
||||
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
|
||||
|
||||
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
|
||||
|
||||
struct ServiceProxyOperations {
|
||||
guard_generation: AtomicU64,
|
||||
operation_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl ServiceProxyOperations {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
guard_generation: AtomicU64::new(0),
|
||||
operation_lock: Mutex::const_new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_guard(&self) -> u64 {
|
||||
self.guard_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
|
||||
}
|
||||
|
||||
async fn run_service_operation<F, Fut, T>(&self, operation: F) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let _operation = self.operation_lock.lock().await;
|
||||
operation().await
|
||||
}
|
||||
|
||||
async fn run_final_service_operation<F, Fut, T>(&self, operation: F) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
self.invalidate_guard();
|
||||
self.run_service_operation(operation).await
|
||||
}
|
||||
|
||||
async fn cancel_and_drain<F, Fut>(&self, cancel: F) -> u64
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let generation = self.invalidate_guard();
|
||||
self.run_service_operation(cancel).await;
|
||||
generation
|
||||
}
|
||||
|
||||
async fn run_guard_request<Mode, ActiveProof, Request, RequestFuture>(
|
||||
&self,
|
||||
captured_generation: u64,
|
||||
captured_proof: &OwnerSessionProof,
|
||||
is_service_mode: Mode,
|
||||
active_proof: ActiveProof,
|
||||
request: Request,
|
||||
) -> Result<bool>
|
||||
where
|
||||
Mode: FnOnce() -> bool,
|
||||
ActiveProof: FnOnce() -> Result<OwnerSessionProof>,
|
||||
Request: FnOnce() -> RequestFuture,
|
||||
RequestFuture: Future<Output = Result<ProxyApplyOutcome>>,
|
||||
{
|
||||
self.run_service_operation(|| async move {
|
||||
if !guard_generation_is_current(&self.guard_generation, captured_generation)
|
||||
|| !is_service_mode()
|
||||
|| !active_proof().is_ok_and(|active| active == *captured_proof)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
service_apply_result(request().await?)?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProxyBackendRoute {
|
||||
Local,
|
||||
Service,
|
||||
}
|
||||
|
||||
const fn proxy_backend_route(is_macos: bool, running_mode: &RunningMode) -> ProxyBackendRoute {
|
||||
if is_macos && matches!(running_mode, RunningMode::Service) {
|
||||
ProxyBackendRoute::Service
|
||||
} else {
|
||||
ProxyBackendRoute::Local
|
||||
}
|
||||
}
|
||||
|
||||
fn guard_generation_is_current(generation: &AtomicU64, captured_generation: u64) -> bool {
|
||||
generation.load(Ordering::Acquire) == captured_generation
|
||||
}
|
||||
|
||||
fn truncate_utf8(value: &mut String, max_bytes: usize) {
|
||||
if value.len() <= max_bytes {
|
||||
return;
|
||||
}
|
||||
let mut boundary = max_bytes;
|
||||
while !value.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
value.truncate(boundary);
|
||||
}
|
||||
|
||||
fn service_bypass(verge: &IVerge) -> Result<String> {
|
||||
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
|
||||
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
|
||||
|
||||
let mut bypass = if custom.is_empty() {
|
||||
MACOS_DEFAULT_BYPASS.to_owned()
|
||||
} else if verge.use_default_bypass.unwrap_or(true) {
|
||||
format!("{MACOS_DEFAULT_BYPASS},{custom}")
|
||||
} else {
|
||||
custom.to_owned()
|
||||
};
|
||||
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
|
||||
Ok(bypass)
|
||||
}
|
||||
|
||||
fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Result<MacosProxyConfig> {
|
||||
if !verge.enable_system_proxy.unwrap_or_default() {
|
||||
return Ok(MacosProxyConfig::Disabled);
|
||||
}
|
||||
|
||||
if verge.proxy_auto_config.unwrap_or_default() {
|
||||
ensure!(pac_port != 0, "embedded PAC server port must not be zero");
|
||||
Ok(MacosProxyConfig::Pac {
|
||||
url: format!("http://{LOOPBACK_HOST}:{pac_port}/commands/pac"),
|
||||
})
|
||||
} else {
|
||||
ensure!(mixed_port != 0, "system proxy port must not be zero");
|
||||
Ok(MacosProxyConfig::Global {
|
||||
host: LOOPBACK_HOST.to_owned(),
|
||||
port: mixed_port,
|
||||
bypass: service_bypass(verge)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn service_apply_result(outcome: ProxyApplyOutcome) -> Result<()> {
|
||||
match outcome {
|
||||
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
|
||||
ProxyApplyOutcome::DirectFallback { message } => bail!(message),
|
||||
}
|
||||
}
|
||||
|
||||
async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig> {
|
||||
if !verge.enable_system_proxy.unwrap_or_default() {
|
||||
return Ok(MacosProxyConfig::Disabled);
|
||||
}
|
||||
if verge.proxy_auto_config.unwrap_or_default() {
|
||||
return service_proxy_config(verge, 0, server::embedded_server_port()?);
|
||||
}
|
||||
let mixed_port = match verge.verge_mixed_port {
|
||||
Some(port) => port,
|
||||
None => Config::clash().await.latest_arc().get_mixed_port(),
|
||||
};
|
||||
service_proxy_config(verge, mixed_port, 0)
|
||||
}
|
||||
|
||||
pub async fn apply() -> Result<()> {
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
|
||||
ProxyBackendRoute::Local => Sysopt::global().update_sysproxy().await,
|
||||
ProxyBackendRoute::Service => {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let proxy = current_service_proxy_config(&verge).await?;
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.run_final_service_operation(|| async {
|
||||
service_apply_result(service::set_system_proxy_by_service(&proxy).await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear() -> Result<()> {
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
|
||||
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await,
|
||||
ProxyBackendRoute::Service => {
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.run_final_service_operation(|| async {
|
||||
service_apply_result(service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_guard() -> Result<()> {
|
||||
let generation = SERVICE_PROXY_OPERATIONS
|
||||
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
|
||||
.await;
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
if matches!(
|
||||
proxy_backend_route(cfg!(target_os = "macos"), &running_mode),
|
||||
ProxyBackendRoute::Local
|
||||
) {
|
||||
Sysopt::global().refresh_guard().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
if !verge.enable_system_proxy.unwrap_or_default() || !verge.enable_proxy_guard.unwrap_or_default() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let proxy = current_service_proxy_config(&verge).await?;
|
||||
let proof = service::active_service_session()?;
|
||||
let interval = Duration::from_secs(verge.proxy_guard_duration.unwrap_or(30).max(1));
|
||||
AsyncHandler::spawn(move || async move {
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
match SERVICE_PROXY_OPERATIONS
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&proof,
|
||||
|| matches!(*CoreManager::global().get_running_mode(), RunningMode::Service),
|
||||
service::active_service_session,
|
||||
|| service::set_system_proxy_by_service_with_session(&proxy, &proof),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(_) => logging!(warn, Type::Core, "failed to refresh system proxy through Service"),
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_guard() {
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ProxyBackendRoute, ServiceProxyOperations, guard_generation_is_current, proxy_backend_route,
|
||||
service_proxy_config,
|
||||
};
|
||||
use crate::{config::IVerge, core::manager::RunningMode};
|
||||
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
use std::task::Poll;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
fn proof(generation: u64, token: &str) -> OwnerSessionProof {
|
||||
OwnerSessionProof {
|
||||
generation,
|
||||
token: token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_macos_service_routes_proxy_to_helper() {
|
||||
assert_eq!(
|
||||
proxy_backend_route(true, &RunningMode::Service),
|
||||
ProxyBackendRoute::Service
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_backend_route(true, &RunningMode::Sidecar),
|
||||
ProxyBackendRoute::Local
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_backend_route(false, &RunningMode::Service),
|
||||
ProxyBackendRoute::Local
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_periodic_refresh_stops_after_owner_loss() {
|
||||
let generation = AtomicU64::new(7);
|
||||
let captured_generation = generation.load(Ordering::Acquire);
|
||||
|
||||
assert!(guard_generation_is_current(&generation, captured_generation));
|
||||
generation.fetch_add(1, Ordering::AcqRel);
|
||||
assert!(!guard_generation_is_current(&generation, captured_generation));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_service_mutation_finishes_after_in_flight_guard_request() {
|
||||
let operations = Arc::new(ServiceProxyOperations::new());
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
let active_proof = Arc::new(Mutex::new(captured_proof.clone()));
|
||||
let guard_started = Arc::new(Barrier::new(2));
|
||||
let release_guard = Arc::new(Barrier::new(2));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let guard_task = {
|
||||
let operations = Arc::clone(&operations);
|
||||
let captured_proof = captured_proof.clone();
|
||||
let active_proof = Arc::clone(&active_proof);
|
||||
let guard_started = Arc::clone(&guard_started);
|
||||
let release_guard = Arc::clone(&release_guard);
|
||||
let events = Arc::clone(&events);
|
||||
tokio::spawn(async move {
|
||||
operations
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(active_proof.lock().clone()),
|
||||
|| async {
|
||||
events.lock().push("guard-started");
|
||||
guard_started.wait().await;
|
||||
release_guard.wait().await;
|
||||
events.lock().push("guard-finished");
|
||||
Ok(ProxyApplyOutcome::Applied)
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
};
|
||||
guard_started.wait().await;
|
||||
|
||||
let mut final_operation = Box::pin(operations.run_final_service_operation(|| async {
|
||||
events.lock().push("final");
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}));
|
||||
assert!(matches!(futures::poll!(final_operation.as_mut()), Poll::Pending));
|
||||
assert!(!guard_generation_is_current(&operations.guard_generation, generation));
|
||||
release_guard.wait().await;
|
||||
|
||||
assert!(matches!(guard_task.await, Ok(Ok(true))));
|
||||
assert!(matches!(final_operation.await, Ok(())));
|
||||
assert_eq!(&*events.lock(), &["guard-started", "guard-finished", "final"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generation_invalidation_while_guard_waits_prevents_rpc() {
|
||||
let operations = Arc::new(ServiceProxyOperations::new());
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
let blocker_started = Arc::new(Barrier::new(2));
|
||||
let release_blocker = Arc::new(Barrier::new(2));
|
||||
|
||||
let blocker = {
|
||||
let operations = Arc::clone(&operations);
|
||||
let blocker_started = Arc::clone(&blocker_started);
|
||||
let release_blocker = Arc::clone(&release_blocker);
|
||||
tokio::spawn(async move {
|
||||
operations
|
||||
.run_service_operation(|| async {
|
||||
blocker_started.wait().await;
|
||||
release_blocker.wait().await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
blocker_started.wait().await;
|
||||
|
||||
let rpc_called = AtomicBool::new(false);
|
||||
let mut guard_request = Box::pin(operations.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(captured_proof.clone()),
|
||||
|| async {
|
||||
rpc_called.store(true, Ordering::Release);
|
||||
Ok(ProxyApplyOutcome::Applied)
|
||||
},
|
||||
));
|
||||
assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending));
|
||||
operations.invalidate_guard();
|
||||
release_blocker.wait().await;
|
||||
|
||||
assert!(blocker.await.is_ok());
|
||||
assert!(matches!(guard_request.await, Ok(false)));
|
||||
assert!(!rpc_called.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_guard_loop_does_not_adopt_replacement_proof() {
|
||||
let operations = Arc::new(ServiceProxyOperations::new());
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
let replacement_proof = proof(2, "replacement");
|
||||
let active_proof = Mutex::new(captured_proof.clone());
|
||||
let rpc_called = AtomicBool::new(false);
|
||||
let blocker_started = Arc::new(Barrier::new(2));
|
||||
let release_blocker = Arc::new(Barrier::new(2));
|
||||
|
||||
let blocker = {
|
||||
let operations = Arc::clone(&operations);
|
||||
let blocker_started = Arc::clone(&blocker_started);
|
||||
let release_blocker = Arc::clone(&release_blocker);
|
||||
tokio::spawn(async move {
|
||||
operations
|
||||
.run_service_operation(|| async {
|
||||
blocker_started.wait().await;
|
||||
release_blocker.wait().await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
blocker_started.wait().await;
|
||||
|
||||
let mut guard_request = Box::pin(operations.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(active_proof.lock().clone()),
|
||||
|| async {
|
||||
rpc_called.store(true, Ordering::Release);
|
||||
Ok(ProxyApplyOutcome::Applied)
|
||||
},
|
||||
));
|
||||
assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending));
|
||||
*active_proof.lock() = replacement_proof;
|
||||
release_blocker.wait().await;
|
||||
|
||||
assert!(blocker.await.is_ok());
|
||||
assert!(matches!(guard_request.await, Ok(false)));
|
||||
assert!(!rpc_called.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_refresh_rejects_direct_fallback() {
|
||||
let operations = ServiceProxyOperations::new();
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
|
||||
let refreshed = operations
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(captured_proof.clone()),
|
||||
|| async {
|
||||
Ok(ProxyApplyOutcome::DirectFallback {
|
||||
message: "fallback detail".to_owned(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(refreshed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_proxy_config_forces_loopback_targets_and_bounds_bypass() {
|
||||
let verge = IVerge {
|
||||
enable_system_proxy: Some(true),
|
||||
proxy_auto_config: Some(false),
|
||||
proxy_host: Some("192.0.2.1".into()),
|
||||
system_proxy_bypass: Some(format!("{}界", "x".repeat(8191)).into()),
|
||||
use_default_bypass: Some(false),
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
let proxy = service_proxy_config(&verge, 7897, 3333).unwrap_or_else(|_| unreachable!());
|
||||
let MacosProxyConfig::Global { host, port, bypass } = proxy else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
assert_eq!(host, "127.0.0.1");
|
||||
assert_eq!(port, 7897);
|
||||
assert!(bypass.len() <= 8192);
|
||||
assert!(bypass.is_char_boundary(bypass.len()));
|
||||
|
||||
let pac_verge = IVerge {
|
||||
proxy_auto_config: Some(true),
|
||||
..verge
|
||||
};
|
||||
assert_eq!(
|
||||
service_proxy_config(&pac_verge, 7897, 3333).unwrap_or_else(|_| unreachable!()),
|
||||
MacosProxyConfig::Pac {
|
||||
url: "http://127.0.0.1:3333/commands/pac".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_service_ipc::{RuntimeAsset, RuntimeBundle};
|
||||
use serde_yaml_ng::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
const GEO_ASSETS: &[&str] = &[
|
||||
"Country.mmdb",
|
||||
"geoip.dat",
|
||||
"geosite.dat",
|
||||
"geoip.metadb",
|
||||
"GeoSite.dat",
|
||||
];
|
||||
|
||||
pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) -> Result<RuntimeBundle> {
|
||||
let yaml = tokio::fs::read_to_string(config_file)
|
||||
.await
|
||||
.with_context(|| format!("failed to read runtime config {config_file:?}"))?;
|
||||
let mut config: Value =
|
||||
serde_yaml_ng::from_str(&yaml).with_context(|| format!("failed to parse runtime config {config_file:?}"))?;
|
||||
let config_root = config_file
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("runtime config has no parent directory"))?;
|
||||
let config_root = std::fs::canonicalize(config_root)?;
|
||||
let mut assets = Vec::new();
|
||||
let mut destinations = HashSet::new();
|
||||
|
||||
collect_provider_assets(
|
||||
&mut config,
|
||||
"proxy-providers",
|
||||
&config_root,
|
||||
&mut destinations,
|
||||
&mut assets,
|
||||
)?;
|
||||
collect_provider_assets(
|
||||
&mut config,
|
||||
"rule-providers",
|
||||
&config_root,
|
||||
&mut destinations,
|
||||
&mut assets,
|
||||
)?;
|
||||
for filename in GEO_ASSETS {
|
||||
let source = config_root.join(filename);
|
||||
if source.is_file() && destinations.insert((*filename).to_string()) {
|
||||
assets.push(RuntimeAsset {
|
||||
source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(),
|
||||
destination: (*filename).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RuntimeBundle {
|
||||
yaml: serde_yaml_ng::to_string(&config).context("failed to serialize service runtime config")?,
|
||||
assets,
|
||||
core_path: core_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_provider_assets(
|
||||
config: &mut Value,
|
||||
section: &str,
|
||||
config_root: &Path,
|
||||
destinations: &mut HashSet<String>,
|
||||
assets: &mut Vec<RuntimeAsset>,
|
||||
) -> Result<()> {
|
||||
let Some(providers) = config
|
||||
.as_mapping_mut()
|
||||
.and_then(|mapping| mapping.get_mut(section))
|
||||
.and_then(Value::as_mapping_mut)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for provider in providers.values_mut().filter_map(Value::as_mapping_mut) {
|
||||
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let is_remote = provider.get("url").and_then(Value::as_str).is_some();
|
||||
let destination = if is_remote {
|
||||
provider_destination(config_root, raw_path)?
|
||||
} else {
|
||||
let source = local_provider_source(config_root, raw_path)?;
|
||||
let destination = destination_below_root(config_root, &source)?;
|
||||
if destinations.insert(destination.clone()) {
|
||||
assets.push(RuntimeAsset {
|
||||
source: source.to_string_lossy().into_owned(),
|
||||
destination: destination.clone(),
|
||||
});
|
||||
}
|
||||
destination
|
||||
};
|
||||
provider.insert(Value::String("path".to_owned()), Value::String(destination));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
|
||||
let path = Path::new(raw_path);
|
||||
let source = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
config_root.join(path)
|
||||
};
|
||||
let canonical_source =
|
||||
std::fs::canonicalize(&source).with_context(|| format!("local runtime provider is unavailable: {source:?}"))?;
|
||||
canonical_source
|
||||
.strip_prefix(config_root)
|
||||
.map_err(|_| anyhow::anyhow!("local runtime provider is outside the config root: {canonical_source:?}"))?;
|
||||
Ok(canonical_source)
|
||||
}
|
||||
|
||||
fn provider_destination(config_root: &Path, raw_path: &str) -> Result<String> {
|
||||
let path = Path::new(raw_path);
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
bail!("runtime provider destination traverses outside the runtime");
|
||||
}
|
||||
if path.is_absolute() {
|
||||
let normalized = canonicalize_with_missing_tail(path)?;
|
||||
destination_below_root(config_root, &normalized)
|
||||
} else {
|
||||
normalized_destination(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_with_missing_tail(path: &Path) -> Result<PathBuf> {
|
||||
let mut ancestor = path;
|
||||
let mut tail = Vec::new();
|
||||
while !ancestor.exists() {
|
||||
let name = ancestor
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?;
|
||||
tail.push(name.to_owned());
|
||||
ancestor = ancestor
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?;
|
||||
}
|
||||
let mut normalized = std::fs::canonicalize(ancestor)?;
|
||||
for component in tail.iter().rev() {
|
||||
normalized.push(component);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn destination_below_root(config_root: &Path, path: &Path) -> Result<String> {
|
||||
let relative = path
|
||||
.strip_prefix(config_root)
|
||||
.map_err(|_| anyhow::anyhow!("runtime provider path is outside the config root: {path:?}"))?;
|
||||
normalized_destination(relative)
|
||||
}
|
||||
|
||||
fn normalized_destination(relative: &Path) -> Result<String> {
|
||||
let mut destination = PathBuf::new();
|
||||
for component in relative.components() {
|
||||
match component {
|
||||
Component::Normal(component) => destination.push(component),
|
||||
Component::CurDir => {}
|
||||
_ => bail!("local runtime provider destination traverses outside the runtime"),
|
||||
}
|
||||
}
|
||||
if destination.as_os_str().is_empty() {
|
||||
bail!("local runtime provider destination is empty");
|
||||
}
|
||||
Ok(destination.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::collect_runtime_bundle;
|
||||
|
||||
#[tokio::test]
|
||||
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("providers"))?;
|
||||
std::fs::write(root.join("providers/local.yaml"), b"proxies: []\n")?;
|
||||
std::fs::write(root.join("Country.mmdb"), b"geo")?;
|
||||
let config = root.join("config.yaml");
|
||||
let absolute_local = root.join("providers/local.yaml");
|
||||
let absolute_remote = root.join("providers/remote.yaml");
|
||||
std::fs::write(
|
||||
&config,
|
||||
format!(
|
||||
"proxy-providers:\n local:\n type: file\n path: {}\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\nrule-providers: {{}}\n",
|
||||
absolute_local.display(),
|
||||
absolute_remote.display()
|
||||
),
|
||||
)?;
|
||||
let core = root.join("mihomo");
|
||||
std::fs::write(&core, b"core")?;
|
||||
|
||||
let bundle = collect_runtime_bundle(&config, &core).await?;
|
||||
|
||||
let bundled_config: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?;
|
||||
assert_eq!(
|
||||
bundled_config["proxy-providers"]["local"]["path"].as_str(),
|
||||
Some("providers/local.yaml")
|
||||
);
|
||||
assert_eq!(
|
||||
bundled_config["proxy-providers"]["remote"]["path"].as_str(),
|
||||
Some("providers/remote.yaml")
|
||||
);
|
||||
assert!(!bundle.yaml.contains(&root.to_string_lossy().into_owned()));
|
||||
assert!(
|
||||
bundle
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.destination == "providers/local.yaml")
|
||||
);
|
||||
assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb"));
|
||||
assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote")));
|
||||
assert!(
|
||||
!bundle
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.destination.contains("geoip.dat"))
|
||||
);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_provider_paths_outside_the_config_root() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-outside-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let config = root.join("config.yaml");
|
||||
let outside = root
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("test root has no parent"))?
|
||||
.join(format!("outside-{}.yaml", std::process::id()));
|
||||
std::fs::write(
|
||||
&config,
|
||||
format!(
|
||||
"proxy-providers:\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\n",
|
||||
outside.display()
|
||||
),
|
||||
)?;
|
||||
let core = root.join("mihomo");
|
||||
std::fs::write(&core, b"core")?;
|
||||
|
||||
let Err(error) = collect_runtime_bundle(&config, &core).await else {
|
||||
anyhow::bail!("outside provider path must be rejected");
|
||||
};
|
||||
|
||||
assert!(error.to_string().contains("outside the config root"));
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+1866
-263
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,4 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
singleton,
|
||||
};
|
||||
use crate::{config::Config, singleton, utils::server};
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::RwLock;
|
||||
@@ -33,8 +30,9 @@ const fn proxy_apply_steps(sys_enabled: bool, auto_enabled: bool) -> [ProxyApply
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Sysopt {
|
||||
pub(crate) struct Sysopt {
|
||||
update_lock: TokioMutex<()>,
|
||||
guard_operation_lock: TokioMutex<()>,
|
||||
reset_sysproxy: AtomicBool,
|
||||
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
|
||||
guard: Arc<RwLock<GuardMonitor>>,
|
||||
@@ -44,6 +42,7 @@ impl Default for Sysopt {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
update_lock: TokioMutex::new(()),
|
||||
guard_operation_lock: TokioMutex::new(()),
|
||||
reset_sysproxy: AtomicBool::new(false),
|
||||
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
|
||||
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
|
||||
@@ -84,17 +83,35 @@ impl Sysopt {
|
||||
Arc::clone(&self.guard)
|
||||
}
|
||||
|
||||
pub async fn refresh_guard(&self) {
|
||||
async fn stop_proxy_guard_locked(&self) {
|
||||
loop {
|
||||
let state = self.access_guard().read().get_state();
|
||||
if state.is_pendding() {
|
||||
tokio::task::yield_now().await;
|
||||
continue;
|
||||
}
|
||||
self.access_guard().write().stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn stop_proxy_guard(&self) {
|
||||
let _operation = self.guard_operation_lock.lock().await;
|
||||
self.stop_proxy_guard_locked().await;
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_guard(&self) {
|
||||
logging!(info, Type::Core, "Refreshing system proxy guard...");
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let _operation = self.guard_operation_lock.lock().await;
|
||||
if !verge.enable_system_proxy.unwrap_or_default() {
|
||||
logging!(info, Type::Core, "System proxy is disabled.");
|
||||
self.access_guard().write().stop();
|
||||
self.stop_proxy_guard_locked().await;
|
||||
return;
|
||||
}
|
||||
if !verge.enable_proxy_guard.unwrap_or_default() {
|
||||
logging!(info, Type::Core, "System proxy guard is disabled.");
|
||||
self.access_guard().write().stop();
|
||||
self.stop_proxy_guard_locked().await;
|
||||
return;
|
||||
}
|
||||
logging!(
|
||||
@@ -114,26 +131,28 @@ impl Sysopt {
|
||||
let guard = self.access_guard();
|
||||
guard.write().start();
|
||||
}
|
||||
while self.access_guard().read().get_state().is_pendding() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for any in-progress `update_sysproxy` to finish, so that a
|
||||
/// subsequent read of OS-level sysproxy state sees a fully applied
|
||||
/// configuration instead of a partially-applied one (e.g. SOCKS already
|
||||
/// disabled but HTTP still enabled mid-transition).
|
||||
pub async fn wait_idle(&self) {
|
||||
pub(crate) async fn wait_idle(&self) {
|
||||
let _ = self.update_lock.lock().await;
|
||||
}
|
||||
|
||||
/// init the sysproxy
|
||||
pub async fn update_sysproxy(&self) -> Result<()> {
|
||||
pub(super) async fn update_sysproxy(&self) -> Result<()> {
|
||||
let _lock = self.update_lock.lock().await;
|
||||
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let port = match verge.verge_mixed_port {
|
||||
Some(port) => port,
|
||||
None => Config::clash().await.latest_arc().get_mixed_port(),
|
||||
};
|
||||
let pac_port = IVerge::get_singleton_port();
|
||||
let pac_port = server::embedded_server_port()?;
|
||||
// 先 await, 避免持有锁导致的 Send 问题
|
||||
let bypass = get_bypass().await;
|
||||
|
||||
@@ -178,6 +197,7 @@ impl Sysopt {
|
||||
(sys.clone(), auto.clone(), guard_type)
|
||||
};
|
||||
|
||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||
self.access_guard().write().set_guard_type(guard_type);
|
||||
|
||||
let apply_steps = proxy_apply_steps(sys.enable, auto.enable);
|
||||
@@ -197,7 +217,7 @@ impl Sysopt {
|
||||
}
|
||||
|
||||
/// reset the sysproxy
|
||||
pub async fn reset_sysproxy(&self) -> Result<()> {
|
||||
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
|
||||
if self
|
||||
.reset_sysproxy
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
@@ -208,9 +228,9 @@ impl Sysopt {
|
||||
defer! {
|
||||
self.reset_sysproxy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// close proxy guard
|
||||
self.access_guard().write().set_guard_type(GuardType::None);
|
||||
let _lock = self.update_lock.lock().await;
|
||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||
self.stop_proxy_guard_locked().await;
|
||||
|
||||
// 直接关闭所有代理
|
||||
let (sys, auto) = {
|
||||
|
||||
+127
-26
@@ -36,6 +36,24 @@ struct TaskState {
|
||||
running: bool,
|
||||
}
|
||||
|
||||
async fn run_timer_profile_update_transition<Started, Update, UpdateFuture, Finished, Completed, Output>(
|
||||
update_started: Started,
|
||||
update_profile: Update,
|
||||
update_finished: Finished,
|
||||
update_completed: Completed,
|
||||
) where
|
||||
Started: FnOnce(),
|
||||
Update: FnOnce() -> UpdateFuture,
|
||||
UpdateFuture: std::future::Future<Output = Output>,
|
||||
Finished: FnOnce(Output),
|
||||
Completed: FnOnce(),
|
||||
{
|
||||
update_started();
|
||||
let result = update_profile().await;
|
||||
update_finished(result);
|
||||
update_completed();
|
||||
}
|
||||
|
||||
impl TaskState {
|
||||
const fn new(key: Key, interval_minutes: u64) -> Self {
|
||||
Self {
|
||||
@@ -386,36 +404,35 @@ impl Timer {
|
||||
let task_start = std::time::Instant::now();
|
||||
logging!(debug, Type::Timer, "Running timer task for profile: {}", uid);
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(40), async {
|
||||
Self::emit_update_event(uid, true);
|
||||
|
||||
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Profile {} is current active profile: {}",
|
||||
uid,
|
||||
is_current
|
||||
);
|
||||
|
||||
feat::update_profile(uid, None, is_current, false, false).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
run_timer_profile_update_transition(
|
||||
|| Self::emit_update_event(uid, true),
|
||||
|| async {
|
||||
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
|
||||
logging!(
|
||||
info,
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Timer task completed for uid: {} (took {}ms)",
|
||||
"Profile {} is current active profile: {}",
|
||||
uid,
|
||||
task_start.elapsed().as_millis()
|
||||
is_current
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
|
||||
Err(_) => logging_error!(Type::Timer, "Timer task timed out for uid: {}", uid),
|
||||
}
|
||||
|
||||
Self::emit_update_event(uid, false);
|
||||
feat::update_profile(uid, None, is_current, false, false).await
|
||||
},
|
||||
|result| match result {
|
||||
Ok(_) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::Timer,
|
||||
"Timer task completed for uid: {} (took {}ms)",
|
||||
uid,
|
||||
task_start.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
|
||||
},
|
||||
|| Self::emit_update_event(uid, false),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn wait_until_resolve_done(max_wait: Duration) {
|
||||
@@ -431,8 +448,31 @@ impl Timer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Timer;
|
||||
use super::{Timer, run_timer_profile_update_transition};
|
||||
use crate::config::{PrfItem, PrfOption};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
struct CancellationProbe {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for CancellationProbe {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed.load(Ordering::Acquire) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_profile(uid: &str, allow_auto_update: Option<bool>, update_interval: Option<u64>) -> PrfItem {
|
||||
PrfItem {
|
||||
@@ -463,4 +503,65 @@ mod tests {
|
||||
assert_eq!(map.get("enabled"), Some(&30));
|
||||
assert_eq!(map.get("missing-flag"), Some(&30));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn timer_profile_update_runs_past_former_deadline_and_pairs_events() -> anyhow::Result<()> {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let update_started = Arc::new(Barrier::new(2));
|
||||
let release_update = Arc::new(Barrier::new(2));
|
||||
let update_cancelled = Arc::new(AtomicBool::new(false));
|
||||
let update_completed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let mut update = Box::pin(run_timer_profile_update_transition(
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move || calls.lock().push("update-started")
|
||||
},
|
||||
{
|
||||
let update_started = Arc::clone(&update_started);
|
||||
let release_update = Arc::clone(&release_update);
|
||||
let update_cancelled = Arc::clone(&update_cancelled);
|
||||
let update_completed = Arc::clone(&update_completed);
|
||||
move || async move {
|
||||
let _probe = CancellationProbe {
|
||||
cancelled: update_cancelled,
|
||||
completed: Arc::clone(&update_completed),
|
||||
};
|
||||
update_started.wait().await;
|
||||
release_update.wait().await;
|
||||
update_completed.store(true, Ordering::Release);
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
},
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move |result: anyhow::Result<()>| {
|
||||
assert!(result.is_ok());
|
||||
calls.lock().push("terminal-result");
|
||||
}
|
||||
},
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move || calls.lock().push("update-completed")
|
||||
},
|
||||
));
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
update_started.wait().await;
|
||||
tokio::time::advance(Duration::from_secs(41)).await;
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(&*calls.lock(), &["update-started"]);
|
||||
|
||||
release_update.wait().await;
|
||||
update.await;
|
||||
assert!(update_completed.load(Ordering::Acquire));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(
|
||||
&*calls.lock(),
|
||||
&["update-started", "terminal-result", "update-completed"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,8 +208,7 @@ impl Tray {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||
let tun_mode_available =
|
||||
is_current_app_handle_admin(app_handle) || service::is_service_available().await.is_ok();
|
||||
let tun_mode_available = is_current_app_handle_admin(app_handle) || service::is_service_available().await;
|
||||
let mode = {
|
||||
Config::clash()
|
||||
.await
|
||||
@@ -994,7 +993,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
let _ = help::open_app_latest_log();
|
||||
}
|
||||
MenuIds::CORE_LOG => {
|
||||
let _ = help::open_core_latest_log();
|
||||
let _ = help::open_core_latest_log().await;
|
||||
}
|
||||
MenuIds::RESTART_CLASH => feat::restart_clash_core().await,
|
||||
MenuIds::RESTART_APP => feat::restart_app().await,
|
||||
|
||||
@@ -121,7 +121,7 @@ async fn get_config_values() -> ConfigValues {
|
||||
|
||||
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
||||
Some(verge_arc.get_valid_clash_core()),
|
||||
enable_tun_mode.unwrap_or(false),
|
||||
enable_tun_mode.unwrap_or(false) && !Config::tun_suppressed_for_session(),
|
||||
enable_builtin_enhanced.unwrap_or(true),
|
||||
verge_socks_enabled.unwrap_or(false),
|
||||
verge_http_enabled.unwrap_or(false),
|
||||
|
||||
@@ -43,7 +43,6 @@ pub async fn restart_app() {
|
||||
// 设置退出标志
|
||||
handle::Handle::global().set_is_exiting();
|
||||
|
||||
utils::server::shutdown_embedded_server();
|
||||
Config::apply_all_and_save_file().await;
|
||||
|
||||
logging!(info, Type::System, "开始异步清理资源");
|
||||
@@ -53,9 +52,19 @@ pub async fn restart_app() {
|
||||
info,
|
||||
Type::System,
|
||||
"资源清理完成,退出代码: {}",
|
||||
if cleanup_result { 0 } else { 1 }
|
||||
if cleanup_result.all_success { 0 } else { 1 }
|
||||
);
|
||||
|
||||
if !cleanup_result.core_stopped {
|
||||
handle::Handle::global().clear_is_exiting();
|
||||
handle::Handle::notice_message(
|
||||
"set_config::error",
|
||||
"Failed to stop the core safely; restart was cancelled",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
utils::server::shutdown_embedded_server();
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
app_handle.restart();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
core::{CoreManager, autostart, handle, hotkey, logger::Logger, sysopt, tray},
|
||||
core::{CoreManager, autostart, handle, hotkey, logger::Logger, tray},
|
||||
module::{auto_backup::AutoBackupManager, lightweight},
|
||||
};
|
||||
use anyhow::Result;
|
||||
@@ -223,8 +223,9 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) ->
|
||||
clash_verge_i18n::set_locale(language.as_str());
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::SYS_PROXY) {
|
||||
sysopt::Sysopt::global().update_sysproxy().await?;
|
||||
sysopt::Sysopt::global().refresh_guard().await;
|
||||
let manager = CoreManager::global();
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
manager.apply_proxy_after_start().await?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::HOTKEY)
|
||||
&& let Some(hotkeys) = &patch.hotkeys
|
||||
|
||||
+352
-114
@@ -1,10 +1,103 @@
|
||||
use crate::config::Config;
|
||||
use crate::core::{CoreManager, handle, sysopt};
|
||||
use crate::core::{CoreManager, handle};
|
||||
use crate::module::lightweight;
|
||||
use crate::utils;
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use tokio::time::{Duration, timeout};
|
||||
use tokio::time::Duration;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CleanupResult {
|
||||
pub all_success: bool,
|
||||
pub core_stopped: bool,
|
||||
}
|
||||
|
||||
const fn should_abort_exit_after_cleanup(core_stopped: bool) -> bool {
|
||||
!core_stopped
|
||||
}
|
||||
|
||||
async fn run_exit_cleanup_transition<Stop, StopFuture, Ancillary, AncillaryFuture>(
|
||||
stop_core: Stop,
|
||||
ancillary_cleanup: Ancillary,
|
||||
) -> CleanupResult
|
||||
where
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
StopFuture: std::future::Future<Output = bool>,
|
||||
Ancillary: FnOnce() -> AncillaryFuture,
|
||||
AncillaryFuture: std::future::Future<Output = bool>,
|
||||
{
|
||||
if !stop_core().await {
|
||||
return CleanupResult {
|
||||
all_success: false,
|
||||
core_stopped: false,
|
||||
};
|
||||
}
|
||||
CleanupResult {
|
||||
all_success: ancillary_cleanup().await,
|
||||
core_stopped: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_interactive_cleanup_transition<Stop, StopFuture, Ancillary, AncillaryFuture>(
|
||||
stop_core: Stop,
|
||||
ancillary_cleanup: Ancillary,
|
||||
) -> CleanupResult
|
||||
where
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
StopFuture: std::future::Future<Output = bool>,
|
||||
Ancillary: FnOnce() -> AncillaryFuture,
|
||||
AncillaryFuture: std::future::Future<Output = bool>,
|
||||
{
|
||||
run_exit_cleanup_transition(stop_core, ancillary_cleanup).await
|
||||
}
|
||||
|
||||
async fn run_session_ending_cleanup_transition<Stop, StopFuture, DeadlineFuture, Ancillary, AncillaryFuture>(
|
||||
stop_core: Stop,
|
||||
stop_deadline: DeadlineFuture,
|
||||
ancillary_cleanup: Ancillary,
|
||||
) -> CleanupResult
|
||||
where
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
StopFuture: std::future::Future<Output = bool>,
|
||||
DeadlineFuture: std::future::Future<Output = ()>,
|
||||
Ancillary: FnOnce() -> AncillaryFuture,
|
||||
AncillaryFuture: std::future::Future<Output = bool>,
|
||||
{
|
||||
run_exit_cleanup_transition(
|
||||
|| async {
|
||||
tokio::select! {
|
||||
biased;
|
||||
stopped = stop_core() => stopped,
|
||||
() = stop_deadline => false,
|
||||
}
|
||||
},
|
||||
ancillary_cleanup,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restore_dns_after_core_stop() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
match timeout(
|
||||
Duration::from_millis(1000),
|
||||
crate::utils::resolve::dns::restore_public_dns(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Window, "DNS设置已恢复");
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
logging!(warn, Type::Window, "Warning: 恢复DNS设置超时");
|
||||
false
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn open_or_close_dashboard() {
|
||||
if lightweight::is_in_lightweight_mode() {
|
||||
@@ -16,12 +109,11 @@ 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();
|
||||
|
||||
utils::server::shutdown_embedded_server();
|
||||
Config::apply_all_and_save_file().await;
|
||||
|
||||
logging!(info, Type::System, "开始异步清理资源");
|
||||
@@ -31,135 +123,116 @@ pub async fn quit() {
|
||||
info,
|
||||
Type::System,
|
||||
"资源清理完成,退出代码: {}",
|
||||
if cleanup_result { 0 } else { 1 }
|
||||
if cleanup_result.all_success { 0 } else { 1 }
|
||||
);
|
||||
|
||||
if should_abort_exit_after_cleanup(cleanup_result.core_stopped) {
|
||||
handle::Handle::global().clear_is_exiting();
|
||||
handle::Handle::notice_message(
|
||||
"set_config::error",
|
||||
"Failed to stop the core safely; quit was cancelled",
|
||||
);
|
||||
return clash_verge_signal::ShutdownOutcome::Canceled;
|
||||
}
|
||||
|
||||
utils::server::shutdown_embedded_server();
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
app_handle.exit(if cleanup_result { 0 } else { 1 });
|
||||
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
|
||||
clash_verge_signal::ShutdownOutcome::Committed
|
||||
}
|
||||
|
||||
pub async fn clean_async() -> bool {
|
||||
logging!(info, Type::System, "开始执行异步清理操作...");
|
||||
pub async fn clean_async() -> CleanupResult {
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"Starting interactive cleanup; controlled core stop will be awaited to completion"
|
||||
);
|
||||
|
||||
// 重置系统代理
|
||||
let proxy_task = tokio::task::spawn(async {
|
||||
let sys_proxy_enabled = Config::verge().await.data_arc().enable_system_proxy.unwrap_or(false);
|
||||
if !sys_proxy_enabled {
|
||||
logging!(info, Type::Window, "系统代理未启用,跳过重置");
|
||||
return true;
|
||||
}
|
||||
|
||||
logging!(info, Type::Window, "开始重置系统代理...");
|
||||
match timeout(Duration::from_millis(1500), sysopt::Sysopt::global().reset_sysproxy()).await {
|
||||
Ok(Ok(_)) => {
|
||||
logging!(info, Type::Window, "系统代理已重置");
|
||||
true
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
logging!(warn, Type::Window, "Warning: 重置系统代理失败: {e}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
logging!(warn, Type::Window, "Warning: 重置系统代理超时,继续退出");
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭 Tun 模式 + 停止核心服务
|
||||
let core_task = tokio::task::spawn(async {
|
||||
logging!(info, Type::System, "disable tun");
|
||||
let tun_enabled = Config::verge().await.data_arc().enable_tun_mode.unwrap_or(false);
|
||||
if tun_enabled {
|
||||
let disable_tun = serde_json::json!({ "tun": { "enable": false } });
|
||||
|
||||
logging!(info, Type::System, "send disable tun request to mihomo");
|
||||
match timeout(
|
||||
Duration::from_millis(1000),
|
||||
handle::Handle::mihomo().await.patch_base_config(&disable_tun),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
logging!(info, Type::Window, "TUN模式已禁用");
|
||||
let result = run_interactive_cleanup_transition(
|
||||
|| async {
|
||||
logging!(info, Type::System, "Stopping core for interactive quit or restart");
|
||||
match CoreManager::global().stop_core().await {
|
||||
Ok(()) => {
|
||||
logging!(info, Type::Window, "Core stopped for interactive quit or restart");
|
||||
true
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
logging!(warn, Type::Window, "Warning: 禁用TUN模式失败: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Window,
|
||||
"Warning: 禁用TUN模式超时(可能系统正在关机),继续退出流程"
|
||||
"Controlled core stop failed; interactive quit or restart must remain cancelled: {error:#}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let stop_timeout = Duration::from_secs(2);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let stop_timeout = Duration::from_secs(3);
|
||||
|
||||
logging!(info, Type::System, "stop core");
|
||||
match timeout(stop_timeout, CoreManager::global().stop_core()).await {
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Window, "core已停止");
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Window,
|
||||
"Warning: 停止core超时(可能系统正在关机),继续退出"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DNS恢复(仅macOS)
|
||||
let dns_task = tokio::task::spawn(async {
|
||||
#[cfg(target_os = "macos")]
|
||||
match timeout(
|
||||
Duration::from_millis(1000),
|
||||
crate::utils::resolve::dns::restore_public_dns(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Window, "DNS设置已恢复");
|
||||
true
|
||||
}
|
||||
Err(_) => {
|
||||
logging!(warn, Type::Window, "Warning: 恢复DNS设置超时");
|
||||
false
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
true
|
||||
});
|
||||
|
||||
// 并行执行清理任务
|
||||
let (proxy_result, core_result, dns_result) = tokio::join!(proxy_task, core_task, dns_task);
|
||||
|
||||
let proxy_success = proxy_result.unwrap_or_default();
|
||||
let core_success = core_result.unwrap_or_default();
|
||||
let dns_success = dns_result.unwrap_or_default();
|
||||
|
||||
let all_success = proxy_success && core_success && dns_success;
|
||||
},
|
||||
restore_dns_after_core_stop,
|
||||
)
|
||||
.await;
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"异步关闭操作完成 - 代理: {}, 核心: {}, DNS: {}, 总体: {}",
|
||||
proxy_success,
|
||||
core_success,
|
||||
dns_success,
|
||||
all_success
|
||||
"Interactive cleanup complete - core stopped: {}, all cleanup successful: {}",
|
||||
result.core_stopped,
|
||||
result.all_success
|
||||
);
|
||||
|
||||
all_success
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn clean_session_ending_best_effort() -> CleanupResult {
|
||||
#[cfg(target_os = "windows")]
|
||||
let stop_timeout = Duration::from_secs(2);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let stop_timeout = Duration::from_secs(3);
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"Starting bounded session-ending best-effort cleanup"
|
||||
);
|
||||
|
||||
let result = run_session_ending_cleanup_transition(
|
||||
|| async {
|
||||
logging!(info, Type::System, "Stopping core during session-ending best-effort cleanup");
|
||||
match CoreManager::global().stop_core().await {
|
||||
Ok(()) => {
|
||||
logging!(info, Type::Window, "Core stopped during session-ending best-effort cleanup");
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Window,
|
||||
"Session-ending best-effort core stop failed; OS or session exit is already in progress: {error:#}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
async move {
|
||||
tokio::time::sleep(stop_timeout).await;
|
||||
logging!(
|
||||
warn,
|
||||
Type::Window,
|
||||
"Session-ending best-effort core stop timed out after {} seconds; OS or session exit is already in progress",
|
||||
stop_timeout.as_secs()
|
||||
);
|
||||
},
|
||||
restore_dns_after_core_stop,
|
||||
)
|
||||
.await;
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"Session-ending best-effort cleanup finished - core stopped: {}, all cleanup successful: {}",
|
||||
result.core_stopped,
|
||||
result.all_success
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -183,3 +256,168 @@ pub async fn hide() {
|
||||
}
|
||||
handle::Handle::global().set_activation_policy_accessory();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
run_interactive_cleanup_transition, run_session_ending_cleanup_transition, should_abort_exit_after_cleanup,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
future::pending,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
};
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
struct CancellationProbe {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for CancellationProbe {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed.load(Ordering::Acquire) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_aborts_when_controlled_core_stop_fails() {
|
||||
assert!(should_abort_exit_after_cleanup(false));
|
||||
assert!(!should_abort_exit_after_cleanup(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interactive_cleanup_awaits_barrier_controlled_stop_without_cancellation() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_started = Arc::new(Barrier::new(2));
|
||||
let release_stop = Arc::new(Barrier::new(2));
|
||||
let stop_cancelled = Arc::new(AtomicBool::new(false));
|
||||
let stop_completed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let mut cleanup = Box::pin(run_interactive_cleanup_transition(
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
let stop_started = Arc::clone(&stop_started);
|
||||
let release_stop = Arc::clone(&release_stop);
|
||||
let stop_cancelled = Arc::clone(&stop_cancelled);
|
||||
let stop_completed = Arc::clone(&stop_completed);
|
||||
move || async move {
|
||||
let _probe = CancellationProbe {
|
||||
cancelled: stop_cancelled,
|
||||
completed: Arc::clone(&stop_completed),
|
||||
};
|
||||
calls.lock().push("core_stop");
|
||||
stop_started.wait().await;
|
||||
release_stop.wait().await;
|
||||
stop_completed.store(true, Ordering::Release);
|
||||
true
|
||||
}
|
||||
},
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move || async move {
|
||||
calls.lock().push("ancillary_cleanup");
|
||||
true
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending));
|
||||
stop_started.wait().await;
|
||||
assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending));
|
||||
assert!(!stop_cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(&*calls.lock(), &["core_stop"]);
|
||||
|
||||
release_stop.wait().await;
|
||||
let result = cleanup.await;
|
||||
|
||||
assert!(result.core_stopped);
|
||||
assert!(result.all_success);
|
||||
assert!(!stop_cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(&*calls.lock(), &["core_stop", "ancillary_cleanup"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interactive_cleanup_does_not_run_ancillary_cleanup_after_stop_failure() {
|
||||
let calls = Mutex::new(Vec::new());
|
||||
|
||||
let result = run_interactive_cleanup_transition(
|
||||
|| async {
|
||||
calls.lock().push("core_stop");
|
||||
false
|
||||
},
|
||||
|| async {
|
||||
calls.lock().push("ancillary_cleanup");
|
||||
true
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!result.core_stopped);
|
||||
assert!(!result.all_success);
|
||||
assert_eq!(&*calls.lock(), &["core_stop"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_ending_cleanup_may_cancel_stop_and_skips_ancillary_after_timeout() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop_started = Arc::new(Barrier::new(2));
|
||||
let deadline_started = Arc::new(Barrier::new(2));
|
||||
let release_deadline = Arc::new(Barrier::new(2));
|
||||
let stop_cancelled = Arc::new(AtomicBool::new(false));
|
||||
let stop_completed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let mut cleanup = Box::pin(run_session_ending_cleanup_transition(
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
let stop_started = Arc::clone(&stop_started);
|
||||
let stop_cancelled = Arc::clone(&stop_cancelled);
|
||||
let stop_completed = Arc::clone(&stop_completed);
|
||||
move || async move {
|
||||
let _probe = CancellationProbe {
|
||||
cancelled: stop_cancelled,
|
||||
completed: stop_completed,
|
||||
};
|
||||
calls.lock().push("core_stop");
|
||||
stop_started.wait().await;
|
||||
pending::<bool>().await
|
||||
}
|
||||
},
|
||||
{
|
||||
let deadline_started = Arc::clone(&deadline_started);
|
||||
let release_deadline = Arc::clone(&release_deadline);
|
||||
async move {
|
||||
deadline_started.wait().await;
|
||||
release_deadline.wait().await;
|
||||
}
|
||||
},
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move || async move {
|
||||
calls.lock().push("ancillary_cleanup");
|
||||
true
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending));
|
||||
stop_started.wait().await;
|
||||
deadline_started.wait().await;
|
||||
assert!(matches!(futures::poll!(cleanup.as_mut()), Poll::Pending));
|
||||
assert!(!stop_cancelled.load(Ordering::Acquire));
|
||||
|
||||
release_deadline.wait().await;
|
||||
let result = cleanup.await;
|
||||
|
||||
assert!(!result.core_stopped);
|
||||
assert!(!result.all_success);
|
||||
assert!(stop_cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(&*calls.lock(), &["core_stop"]);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-3
@@ -137,6 +137,7 @@ mod app_init {
|
||||
cmd::is_port_in_use,
|
||||
cmd::get_sys_proxy,
|
||||
cmd::get_auto_proxy,
|
||||
cmd::get_embedded_server_port,
|
||||
cmd::open_app_dir,
|
||||
cmd::open_logs_dir,
|
||||
cmd::open_web_url,
|
||||
@@ -157,6 +158,8 @@ mod app_init {
|
||||
cmd::reinstall_service,
|
||||
cmd::repair_service,
|
||||
cmd::is_service_available,
|
||||
cmd::get_service_install_state,
|
||||
cmd::continue_with_sidecar,
|
||||
cmd::get_clash_info,
|
||||
cmd::patch_clash_config,
|
||||
cmd::patch_clash_mode,
|
||||
@@ -220,6 +223,14 @@ mod app_init {
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
#[cfg(all(target_os = "macos", not(debug_assertions), not(test), not(feature = "verge-dev")))]
|
||||
if utils::macos_launch_guard::enforce_before_initialization() == utils::macos_launch_guard::LaunchDisposition::Exit
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = utils::dirs::init_portable_flag();
|
||||
|
||||
if app_init::init_singleton_check().is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -229,8 +240,6 @@ pub fn run() {
|
||||
#[cfg(target_os = "linux")]
|
||||
utils::linux::workarounds::apply_wayland_webkit_fix();
|
||||
|
||||
let _ = utils::dirs::init_portable_flag();
|
||||
|
||||
let builder = app_init::setup_plugins(tauri::Builder::default())
|
||||
.setup(|app| {
|
||||
// Logger may not be ready yet, so mirror setup panics to stderr.
|
||||
@@ -317,6 +326,7 @@ pub fn run() {
|
||||
}
|
||||
|
||||
logging!(info, Type::System, "应用就绪");
|
||||
crate::utils::server::set_commands_ready();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = _app_handle.get_webview_window("main") {
|
||||
@@ -437,7 +447,15 @@ pub fn run() {
|
||||
// Windows session ending currently reaches Tao as WM_ENDSESSION and
|
||||
// destroys the loop without a preventable ExitRequested event.
|
||||
if !handle::Handle::global().is_exiting() {
|
||||
feat::quit().await;
|
||||
handle::Handle::global().set_is_exiting();
|
||||
let cleanup_result = feat::clean_session_ending_best_effort().await;
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"Unpreventable session-ending best-effort cleanup returned - core stopped: {}, all cleanup successful: {}",
|
||||
cleanup_result.core_stopped,
|
||||
cleanup_result.all_success
|
||||
);
|
||||
}
|
||||
logging!(info, Type::System, "Application exited");
|
||||
}),
|
||||
|
||||
+189
-31
@@ -3,8 +3,6 @@ use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::OnceCell;
|
||||
#[cfg(unix)]
|
||||
use std::iter;
|
||||
use std::{fs, path::PathBuf};
|
||||
use tauri::Manager as _;
|
||||
|
||||
@@ -66,6 +64,28 @@ pub fn app_home_dir() -> Result<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preinit_app_data_dir() -> Result<PathBuf> {
|
||||
if PORTABLE_FLAG.get().copied().unwrap_or(false) {
|
||||
let executable = std::env::current_exe()?;
|
||||
let parent = executable
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("portable executable has no parent directory"))?;
|
||||
return Ok(parent.join(".config").join(APP_ID));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let root = PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is unavailable"))?)
|
||||
.join("Library/Application Support");
|
||||
#[cfg(target_os = "linux")]
|
||||
let root = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share"));
|
||||
#[cfg(windows)]
|
||||
let root = PathBuf::from(std::env::var_os("APPDATA").ok_or_else(|| anyhow::anyhow!("APPDATA is unavailable"))?);
|
||||
|
||||
Ok(root.join(APP_ID))
|
||||
}
|
||||
|
||||
/// get the resources dir
|
||||
pub fn app_resources_dir() -> Result<PathBuf> {
|
||||
// 避免在Handle未初始化时崩溃
|
||||
@@ -213,40 +233,155 @@ pub fn get_encryption_key() -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn ensure_mihomo_safe_dir() -> Option<PathBuf> {
|
||||
iter::once("/tmp")
|
||||
.map(PathBuf::from)
|
||||
.find(|path| path.exists())
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").and_then(|home| {
|
||||
let home_config = PathBuf::from(home).join(".config");
|
||||
if home_config.exists() || fs::create_dir_all(&home_config).is_ok() {
|
||||
Some(home_config)
|
||||
} else {
|
||||
logging!(error, Type::File, "Failed to create safe directory: {home_config:?}");
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(clash_verge_service_ipc::mihomo_ipc_path(
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
ensure_mihomo_safe_dir()
|
||||
.map(|base_dir| base_dir.join("verge").join("verge-mihomo.sock"))
|
||||
.or_else(|| {
|
||||
app_home_dir()
|
||||
.ok()
|
||||
.map(|dir| dir.join("verge").join("verge-mihomo.sock"))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to determine ipc path"))
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn sidecar_ipc_path() -> Result<PathBuf> {
|
||||
sidecar_ipc_path_for(
|
||||
std::path::Path::new(""),
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(r"\\.\pipe\verge-mihomo"))
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn sidecar_ipc_path() -> Result<PathBuf> {
|
||||
Ok(sidecar_ipc_path_for(
|
||||
&preinit_app_data_dir()?,
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sidecar_ipc_path_for(app_root: &std::path::Path, _identity: &clash_verge_service_ipc::OwnerIdentity) -> PathBuf {
|
||||
app_root.join("verge-mihomo.sock")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn sidecar_ipc_path_for(
|
||||
_app_root: &std::path::Path,
|
||||
_identity: &clash_verge_service_ipc::OwnerIdentity,
|
||||
) -> Result<PathBuf> {
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
|
||||
|
||||
// SAFETY: A null buffer with size zero asks confstr for the required buffer length.
|
||||
let required_len = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, std::ptr::null_mut(), 0) };
|
||||
if required_len == 0 {
|
||||
return Err(anyhow::anyhow!("macOS per-user temporary directory is unavailable"));
|
||||
}
|
||||
|
||||
let mut buffer = vec![0_u8; required_len];
|
||||
// SAFETY: buffer is writable for buffer.len() bytes, as required by confstr.
|
||||
let written = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buffer.as_mut_ptr().cast(), buffer.len()) };
|
||||
if written == 0 || written > buffer.len() {
|
||||
return Err(anyhow::anyhow!("failed to read macOS per-user temporary directory"));
|
||||
}
|
||||
|
||||
let root = std::ffi::CStr::from_bytes_until_nul(&buffer)
|
||||
.map_err(|_| anyhow::anyhow!("macOS per-user temporary directory is not NUL-terminated"))?;
|
||||
#[cfg(feature = "verge-dev")]
|
||||
let filename = "verge-mihomo-dev.sock";
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
let filename = "verge-mihomo.sock";
|
||||
let path = PathBuf::from(OsStr::from_bytes(root.to_bytes())).join(filename);
|
||||
|
||||
let path_len = path.as_os_str().as_bytes().len();
|
||||
if path_len >= 104 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"macOS Sidecar IPC path is {path_len} bytes, but sockaddr_un.sun_path requires fewer than 104 bytes: {:?}",
|
||||
path,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn sidecar_ipc_path_for(_app_root: &std::path::Path, identity: &clash_verge_service_ipc::OwnerIdentity) -> PathBuf {
|
||||
PathBuf::from(sidecar_pipe_name(identity, cfg!(feature = "verge-dev")))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn sidecar_pipe_name(identity: &clash_verge_service_ipc::OwnerIdentity, is_dev: bool) -> String {
|
||||
let flavor = if is_dev { "dev" } else { "release" };
|
||||
format!(
|
||||
r"\\.\pipe\verge-mihomo-sidecar-{flavor}-{}",
|
||||
clash_verge_service_ipc::owner_key(identity)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod ipc_tests {
|
||||
use super::sidecar_ipc_path_for;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn sidecar_ipc_stays_in_the_app_root() {
|
||||
let identity = OwnerIdentity::Unix { uid: 501, gid: 20 };
|
||||
let app_root = Path::new("/home/test/.local/share/io.github.clash-verge-rev.clash-verge-rev");
|
||||
let path = sidecar_ipc_path_for(app_root, &identity);
|
||||
|
||||
assert_eq!(path, app_root.join("verge-mihomo.sock"));
|
||||
assert_ne!(
|
||||
path.to_string_lossy(),
|
||||
clash_verge_service_ipc::mihomo_ipc_path(&identity)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod ipc_tests {
|
||||
use super::sidecar_ipc_path_for;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _, path::Path};
|
||||
|
||||
#[test]
|
||||
fn sidecar_ipc_ignores_long_app_root_and_fits_sockaddr_un() -> anyhow::Result<()> {
|
||||
let identity = OwnerIdentity::Unix { uid: 501, gid: 20 };
|
||||
let app_root =
|
||||
Path::new("/Users/support/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev");
|
||||
let path = sidecar_ipc_path_for(app_root, &identity)?;
|
||||
|
||||
assert!(!path.starts_with(app_root));
|
||||
assert!(path.as_os_str().as_bytes().len() < 104);
|
||||
#[cfg(feature = "verge-dev")]
|
||||
assert_eq!(path.file_name(), Some(OsStr::new("verge-mihomo-dev.sock")));
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
assert_eq!(path.file_name(), Some(OsStr::new("verge-mihomo.sock")));
|
||||
assert_eq!(path, sidecar_ipc_path_for(Path::new("/different/root"), &identity)?);
|
||||
assert!(path.parent().is_some_and(Path::is_dir));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod ipc_tests {
|
||||
use super::sidecar_ipc_path_for;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn sidecar_ipc_uses_the_current_owners_named_pipe() {
|
||||
let identity = OwnerIdentity::Windows {
|
||||
sid: "S-1-5-21-1000".to_owned(),
|
||||
};
|
||||
let path = sidecar_ipc_path_for(Path::new(r"C:\ignored"), &identity);
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
Path::new(&format!(
|
||||
r"\\.\pipe\verge-mihomo-sidecar-{}-{}",
|
||||
if cfg!(feature = "verge-dev") { "dev" } else { "release" },
|
||||
clash_verge_service_ipc::owner_key(&identity)
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PathBufExec {
|
||||
async fn remove_if_exists(&self) -> Result<()>;
|
||||
@@ -262,3 +397,26 @@ impl PathBufExec for PathBuf {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod windows_pipe_name_tests {
|
||||
use super::sidecar_pipe_name;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
|
||||
#[test]
|
||||
fn windows_sidecar_pipe_separates_dev_and_release_for_the_same_owner() {
|
||||
let identity = OwnerIdentity::Windows {
|
||||
sid: "S-1-5-21-1000".to_owned(),
|
||||
};
|
||||
let owner_key = clash_verge_service_ipc::owner_key(&identity);
|
||||
|
||||
assert_eq!(
|
||||
sidecar_pipe_name(&identity, false),
|
||||
format!(r"\\.\pipe\verge-mihomo-sidecar-release-{owner_key}")
|
||||
);
|
||||
assert_eq!(
|
||||
sidecar_pipe_name(&identity, true),
|
||||
format!(r"\\.\pipe\verge-mihomo-sidecar-dev-{owner_key}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +213,18 @@ pub fn open_app_latest_log() -> Result<()> {
|
||||
open_latest_log(path)
|
||||
}
|
||||
|
||||
pub fn open_core_latest_log() -> Result<()> {
|
||||
let path = crate::utils::dirs::clash_latest_log()?;
|
||||
pub async fn open_core_latest_log() -> Result<()> {
|
||||
let path = if matches!(
|
||||
*crate::core::CoreManager::global().get_running_mode(),
|
||||
crate::core::manager::RunningMode::Service
|
||||
) {
|
||||
let path = crate::utils::dirs::service_log_dir()?.join("service_latest.log");
|
||||
let snapshot = crate::core::service::get_clash_log_snapshot_by_service().await?;
|
||||
tokio::fs::write(&path, snapshot).await?;
|
||||
path
|
||||
} else {
|
||||
crate::utils::dirs::clash_latest_log()?
|
||||
};
|
||||
open_latest_log(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
os::unix::ffi::OsStrExt as _,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LaunchLocation {
|
||||
Allowed { bundle: PathBuf },
|
||||
Movable { bundle: PathBuf },
|
||||
Translocated,
|
||||
Rejected { reason: LaunchRejectionReason },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LaunchRejectionReason {
|
||||
NotInApplicationBundle,
|
||||
CanonicalizeBundleFailed { error: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MoveDecision {
|
||||
SystemApplications,
|
||||
UserApplications,
|
||||
ConfirmReplacement,
|
||||
ManualInstructions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LaunchDisposition {
|
||||
Continue,
|
||||
Exit,
|
||||
}
|
||||
|
||||
pub fn evaluate_install_location(executable: &Path, home: &Path) -> LaunchLocation {
|
||||
evaluate_install_location_with_roots(executable, home, Path::new("/Applications"))
|
||||
}
|
||||
|
||||
pub fn evaluate_install_location_with_roots(
|
||||
executable: &Path,
|
||||
home: &Path,
|
||||
system_applications: &Path,
|
||||
) -> LaunchLocation {
|
||||
if executable
|
||||
.components()
|
||||
.any(|component| component.as_os_str() == "AppTranslocation")
|
||||
{
|
||||
return LaunchLocation::Translocated;
|
||||
}
|
||||
|
||||
let Some(bundle) = executable.ancestors().find(|ancestor| {
|
||||
ancestor
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("app"))
|
||||
}) else {
|
||||
return LaunchLocation::Rejected {
|
||||
reason: LaunchRejectionReason::NotInApplicationBundle,
|
||||
};
|
||||
};
|
||||
let canonical_bundle = match std::fs::canonicalize(bundle) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(error) => {
|
||||
return LaunchLocation::Rejected {
|
||||
reason: LaunchRejectionReason::CanonicalizeBundleFailed {
|
||||
error: error.to_string(),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
let system_root = canonical_allowed_root(system_applications);
|
||||
let user_root = canonical_allowed_root(&home.join("Applications"));
|
||||
let allowed = system_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| canonical_bundle.starts_with(root))
|
||||
|| user_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| canonical_bundle.starts_with(root));
|
||||
|
||||
if allowed {
|
||||
LaunchLocation::Allowed {
|
||||
bundle: canonical_bundle,
|
||||
}
|
||||
} else {
|
||||
LaunchLocation::Movable {
|
||||
bundle: canonical_bundle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_allowed_root(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = std::fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
std::fs::canonicalize(path).ok()
|
||||
}
|
||||
|
||||
fn current_user_home() -> anyhow::Result<PathBuf> {
|
||||
const DEFAULT_BUFFER_SIZE: usize = 16 * 1024;
|
||||
const MAX_BUFFER_SIZE: usize = 1024 * 1024;
|
||||
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let configured_size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
|
||||
let mut buffer_size = if configured_size > 0 {
|
||||
usize::try_from(configured_size).unwrap_or(DEFAULT_BUFFER_SIZE)
|
||||
} else {
|
||||
DEFAULT_BUFFER_SIZE
|
||||
}
|
||||
.max(1024);
|
||||
|
||||
loop {
|
||||
if buffer_size > MAX_BUFFER_SIZE {
|
||||
anyhow::bail!("effective-user home lookup exceeded the maximum buffer size");
|
||||
}
|
||||
|
||||
let mut passwd = std::mem::MaybeUninit::<libc::passwd>::zeroed();
|
||||
let mut result = std::ptr::null_mut();
|
||||
let mut buffer = vec![0_u8; buffer_size];
|
||||
let code = unsafe {
|
||||
libc::getpwuid_r(
|
||||
uid,
|
||||
passwd.as_mut_ptr(),
|
||||
buffer.as_mut_ptr().cast(),
|
||||
buffer.len(),
|
||||
&mut result,
|
||||
)
|
||||
};
|
||||
if code == libc::ERANGE {
|
||||
buffer_size = buffer_size.saturating_mul(2);
|
||||
continue;
|
||||
}
|
||||
if code != 0 {
|
||||
return Err(std::io::Error::from_raw_os_error(code).into());
|
||||
}
|
||||
if result.is_null() {
|
||||
anyhow::bail!("effective user {uid} has no password database entry");
|
||||
}
|
||||
|
||||
let passwd = unsafe { passwd.assume_init() };
|
||||
if passwd.pw_dir.is_null() {
|
||||
anyhow::bail!("effective user {uid} has no home directory");
|
||||
}
|
||||
let home = unsafe { CStr::from_ptr(passwd.pw_dir) };
|
||||
if home.to_bytes().is_empty() {
|
||||
anyhow::bail!("effective user {uid} has an empty home directory");
|
||||
}
|
||||
return Ok(PathBuf::from(std::ffi::OsStr::from_bytes(home.to_bytes())));
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn move_decision(
|
||||
translocated: bool,
|
||||
system_applications_writable: bool,
|
||||
target_exists: bool,
|
||||
) -> MoveDecision {
|
||||
if translocated {
|
||||
MoveDecision::ManualInstructions
|
||||
} else if target_exists {
|
||||
MoveDecision::ConfirmReplacement
|
||||
} else if system_applications_writable {
|
||||
MoveDecision::SystemApplications
|
||||
} else {
|
||||
MoveDecision::UserApplications
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enforce_before_initialization() -> LaunchDisposition {
|
||||
clash_verge_i18n::sync_locale(None);
|
||||
|
||||
let executable = match std::env::current_exe() {
|
||||
Ok(executable) => executable,
|
||||
Err(error) => {
|
||||
let message = clash_verge_i18n::t!("launchGuard.currentExeError").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
};
|
||||
let home = match current_user_home() {
|
||||
Ok(home) => home,
|
||||
Err(error) => {
|
||||
let message = clash_verge_i18n::t!("launchGuard.homeDirError").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
};
|
||||
|
||||
match evaluate_install_location(&executable, &home) {
|
||||
LaunchLocation::Allowed { .. } => LaunchDisposition::Continue,
|
||||
LaunchLocation::Translocated => {
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.translocated"));
|
||||
LaunchDisposition::Exit
|
||||
}
|
||||
LaunchLocation::Rejected { reason } => {
|
||||
let reason = match reason {
|
||||
LaunchRejectionReason::NotInApplicationBundle => {
|
||||
clash_verge_i18n::t!("launchGuard.notInBundle").into_owned()
|
||||
}
|
||||
LaunchRejectionReason::CanonicalizeBundleFailed { error } => {
|
||||
clash_verge_i18n::t!("launchGuard.canonicalizeBundleError").replace("{error}", &error)
|
||||
}
|
||||
};
|
||||
let message = clash_verge_i18n::t!("launchGuard.rejected").replace("{reason}", &reason);
|
||||
show_message(&message);
|
||||
LaunchDisposition::Exit
|
||||
}
|
||||
LaunchLocation::Movable { bundle } => move_and_relaunch(&bundle, &home),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_and_relaunch(bundle: &Path, home: &Path) -> LaunchDisposition {
|
||||
let system_root = Path::new("/Applications");
|
||||
let system_writable = path_is_writable(system_root);
|
||||
let destination_root = if system_writable {
|
||||
system_root.to_path_buf()
|
||||
} else {
|
||||
home.join("Applications")
|
||||
};
|
||||
let Some(bundle_name) = bundle.file_name() else {
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.bundleNameError"));
|
||||
return LaunchDisposition::Exit;
|
||||
};
|
||||
let destination = destination_root.join(bundle_name);
|
||||
let exists = destination.exists();
|
||||
let destination_display = destination.display().to_string();
|
||||
let prompt = if exists {
|
||||
clash_verge_i18n::t!("launchGuard.replacePrompt").replace("{destination}", &destination_display)
|
||||
} else {
|
||||
clash_verge_i18n::t!("launchGuard.movePrompt").replace("{destination}", &destination_display)
|
||||
};
|
||||
if !confirm(&prompt) {
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
if let Err(error) = std::fs::create_dir_all(&destination_root) {
|
||||
let message =
|
||||
clash_verge_i18n::t!("launchGuard.createApplicationsDirError").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
let staging = sibling_swap_path(&destination, "installing");
|
||||
let backup = sibling_swap_path(&destination, "backup");
|
||||
let _ = remove_existing_target(&staging);
|
||||
let _ = remove_existing_target(&backup);
|
||||
let copy = std::process::Command::new("/usr/bin/ditto")
|
||||
.arg(bundle)
|
||||
.arg(&staging)
|
||||
.status();
|
||||
if !copy.is_ok_and(|status| status.success()) {
|
||||
let _ = remove_existing_target(&staging);
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.moveFailed"));
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
let backup = match activate_staged_bundle(&staging, &destination, &backup) {
|
||||
Ok(backup) => backup,
|
||||
Err(error) => {
|
||||
let _ = remove_existing_target(&staging);
|
||||
let message = clash_verge_i18n::t!("launchGuard.replaceFailed").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
};
|
||||
let relaunched = std::process::Command::new("/usr/bin/open")
|
||||
.args(["-n"])
|
||||
.arg(&destination)
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
if !relaunched {
|
||||
if let Some(backup) = backup.as_ref() {
|
||||
let _ = remove_existing_target(&staging);
|
||||
if std::fs::rename(&destination, &staging).is_ok() {
|
||||
let _ = std::fs::rename(backup, &destination);
|
||||
let _ = remove_existing_target(&staging);
|
||||
}
|
||||
}
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.relaunchFailed"));
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
if let Some(backup) = backup {
|
||||
let _ = remove_existing_target(&backup);
|
||||
}
|
||||
if bundle != destination {
|
||||
let _ = std::fs::remove_dir_all(bundle);
|
||||
}
|
||||
LaunchDisposition::Exit
|
||||
}
|
||||
|
||||
fn sibling_swap_path(destination: &Path, label: &str) -> PathBuf {
|
||||
let name = destination
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Clash Verge.app");
|
||||
destination.with_file_name(format!(".{name}.{label}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
fn activate_staged_bundle(staging: &Path, destination: &Path, backup: &Path) -> std::io::Result<Option<PathBuf>> {
|
||||
let staging_metadata = std::fs::symlink_metadata(staging)?;
|
||||
if staging_metadata.file_type().is_symlink() || !staging_metadata.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"staged application is not an ordinary bundle directory",
|
||||
));
|
||||
}
|
||||
|
||||
let had_destination = std::fs::symlink_metadata(destination).is_ok();
|
||||
if had_destination {
|
||||
std::fs::rename(destination, backup)?;
|
||||
}
|
||||
if let Err(error) = std::fs::rename(staging, destination) {
|
||||
if had_destination {
|
||||
let _ = std::fs::rename(backup, destination);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(had_destination.then(|| backup.to_path_buf()))
|
||||
}
|
||||
|
||||
fn remove_existing_target(path: &Path) -> std::io::Result<()> {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || metadata.is_file() {
|
||||
std::fs::remove_file(path)
|
||||
} else {
|
||||
std::fs::remove_dir_all(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_is_writable(path: &Path) -> bool {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 }
|
||||
}
|
||||
|
||||
fn confirm(message: &str) -> bool {
|
||||
let cancel_button = clash_verge_i18n::t!("launchGuard.cancel");
|
||||
let move_button = clash_verge_i18n::t!("launchGuard.move");
|
||||
let script = format!(
|
||||
"display dialog \"{}\" buttons {{\"{}\", \"{}\"}} default button \"{}\" with icon caution",
|
||||
escape_osascript(message),
|
||||
escape_osascript(cancel_button.as_ref()),
|
||||
escape_osascript(move_button.as_ref()),
|
||||
escape_osascript(move_button.as_ref())
|
||||
);
|
||||
let selected_button = format!("button returned:{move_button}");
|
||||
std::process::Command::new("/usr/bin/osascript")
|
||||
.args(["-e", &script])
|
||||
.output()
|
||||
.is_ok_and(|output| {
|
||||
output.status.success() && String::from_utf8_lossy(&output.stdout).contains(&selected_button)
|
||||
})
|
||||
}
|
||||
|
||||
fn show_message(message: &str) {
|
||||
let ok_button = clash_verge_i18n::t!("launchGuard.ok");
|
||||
let script = format!(
|
||||
"display dialog \"{}\" buttons {{\"{}\"}} default button \"{}\" with icon caution",
|
||||
escape_osascript(message),
|
||||
escape_osascript(ok_button.as_ref()),
|
||||
escape_osascript(ok_button.as_ref())
|
||||
);
|
||||
let _ = std::process::Command::new("/usr/bin/osascript")
|
||||
.args(["-e", &script])
|
||||
.status();
|
||||
}
|
||||
|
||||
fn escape_osascript(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LaunchLocation, MoveDecision, activate_staged_bundle, current_user_home, evaluate_install_location_with_roots,
|
||||
move_decision,
|
||||
};
|
||||
use std::{ffi::CStr, os::unix::ffi::OsStrExt as _};
|
||||
|
||||
fn executable(bundle: &std::path::Path) -> anyhow::Result<std::path::PathBuf> {
|
||||
let executable = bundle.join("Contents/MacOS/clash-verge");
|
||||
let parent = executable
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("test executable has no parent"))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
std::fs::write(&executable, b"app")?;
|
||||
Ok(executable)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_allowed_roots_and_nested_bundles_are_accepted() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-{}", std::process::id()));
|
||||
let home = root.join("home");
|
||||
let system = root.join("Applications");
|
||||
let user = home.join("Applications");
|
||||
let system_exe = executable(&system.join("Tools/Clash Verge.app"))?;
|
||||
let user_exe = executable(&user.join("Network/Clash Verge.app"))?;
|
||||
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&system_exe, &home, &system),
|
||||
LaunchLocation::Allowed { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&user_exe, &home, &system),
|
||||
LaunchLocation::Allowed { .. }
|
||||
));
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_staged_activation_preserves_existing_application() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-swap-{}", std::process::id()));
|
||||
let destination = root.join("Clash Verge.app");
|
||||
let staging = root.join(".Clash Verge.app.installing");
|
||||
let backup = root.join(".Clash Verge.app.backup");
|
||||
std::fs::create_dir_all(&destination)?;
|
||||
std::fs::write(destination.join("old"), b"old")?;
|
||||
std::fs::create_dir_all(&staging)?;
|
||||
std::fs::write(staging.join("new"), b"new")?;
|
||||
std::fs::write(&backup, b"blocks rename")?;
|
||||
|
||||
let Err(error) = activate_staged_bundle(&staging, &destination, &backup) else {
|
||||
anyhow::bail!("occupied backup path must fail without deleting destination");
|
||||
};
|
||||
|
||||
assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
|
||||
assert_eq!(std::fs::read(destination.join("old"))?, b"old");
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn canonicalization_accepts_symlink_in_and_rejects_symlink_escape() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-links-{}", std::process::id()));
|
||||
let home = root.join("home");
|
||||
let system = root.join("Applications");
|
||||
let downloads = home.join("Downloads");
|
||||
std::fs::create_dir_all(&downloads)?;
|
||||
let allowed_bundle = system.join("Clash Verge.app");
|
||||
let allowed_exe = executable(&allowed_bundle)?;
|
||||
let link_in = downloads.join("Clash Verge.app");
|
||||
symlink(&allowed_bundle, &link_in)?;
|
||||
let escaped_bundle = downloads.join("Escaped.app");
|
||||
executable(&escaped_bundle)?;
|
||||
std::fs::create_dir_all(&system)?;
|
||||
let link_out = system.join("Escaped.app");
|
||||
symlink(&escaped_bundle, &link_out)?;
|
||||
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&link_in.join("Contents/MacOS/clash-verge"), &home, &system),
|
||||
LaunchLocation::Allowed { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&link_out.join("Contents/MacOS/clash-verge"), &home, &system),
|
||||
LaunchLocation::Movable { .. }
|
||||
));
|
||||
assert!(allowed_exe.is_file());
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn allowed_root_symlink_escape_is_not_authorized() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-root-link-{}", std::process::id()));
|
||||
let home = root.join("home");
|
||||
let downloads = home.join("Downloads");
|
||||
let system = root.join("SystemApplications");
|
||||
std::fs::create_dir_all(&downloads)?;
|
||||
std::fs::create_dir_all(&system)?;
|
||||
symlink(&downloads, home.join("Applications"))?;
|
||||
let escaped_exe = executable(&home.join("Applications/Clash Verge.app"))?;
|
||||
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&escaped_exe, &home, &system),
|
||||
LaunchLocation::Movable { .. }
|
||||
));
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_home_matches_effective_user_database_entry() -> anyhow::Result<()> {
|
||||
let home = current_user_home()?;
|
||||
let passwd = unsafe { libc::getpwuid(libc::geteuid()) };
|
||||
if passwd.is_null() {
|
||||
anyhow::bail!("effective user has no password database entry");
|
||||
}
|
||||
let expected = unsafe { CStr::from_ptr((*passwd).pw_dir) };
|
||||
assert_eq!(home.as_os_str().as_bytes(), expected.to_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translocation_and_missing_bundle_are_rejected_before_side_effects() {
|
||||
let translocated =
|
||||
std::path::Path::new("/private/var/folders/AppTranslocation/Clash Verge.app/Contents/MacOS/clash-verge");
|
||||
assert_eq!(
|
||||
evaluate_install_location_with_roots(
|
||||
translocated,
|
||||
std::path::Path::new("/Users/test"),
|
||||
std::path::Path::new("/Applications")
|
||||
),
|
||||
LaunchLocation::Translocated
|
||||
);
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(
|
||||
std::path::Path::new("/Users/test/Downloads/clash-verge"),
|
||||
std::path::Path::new("/Users/test"),
|
||||
std::path::Path::new("/Applications")
|
||||
),
|
||||
LaunchLocation::Rejected { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mover_decision_prefers_system_then_user_and_never_moves_translocation() {
|
||||
assert_eq!(move_decision(false, true, false), MoveDecision::SystemApplications);
|
||||
assert_eq!(move_decision(false, false, false), MoveDecision::UserApplications);
|
||||
assert_eq!(move_decision(true, true, false), MoveDecision::ManualInstructions);
|
||||
assert_eq!(move_decision(false, true, true), MoveDecision::ConfirmReplacement);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ pub mod help;
|
||||
pub mod init;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos_launch_guard;
|
||||
pub mod network;
|
||||
pub mod notification;
|
||||
pub mod resolve;
|
||||
|
||||
@@ -9,8 +9,7 @@ use crate::{
|
||||
handle::Handle,
|
||||
hotkey::Hotkey,
|
||||
logger::Logger,
|
||||
service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists},
|
||||
sysopt,
|
||||
service::{SERVICE_MANAGER, ServiceManager},
|
||||
tray::Tray,
|
||||
},
|
||||
feat,
|
||||
@@ -52,6 +51,7 @@ pub fn resolve_setup_async() {
|
||||
#[cfg(target_os = "macos")]
|
||||
resolve_dock_show().await;
|
||||
init_startup_script().await;
|
||||
init_service_manager().await;
|
||||
let config_initialized = init_verge_config_before_window().await;
|
||||
init_window().await;
|
||||
init_resources().await;
|
||||
@@ -64,10 +64,7 @@ pub fn resolve_setup_async() {
|
||||
Config::verify_config_initialization().await;
|
||||
|
||||
let core_init = AsyncHandler::spawn(|| async {
|
||||
init_service_manager().await;
|
||||
init_core_manager().await;
|
||||
init_system_proxy().await;
|
||||
init_system_proxy_guard().await;
|
||||
});
|
||||
|
||||
let _ = futures::join!(
|
||||
@@ -87,7 +84,6 @@ pub fn resolve_setup_async() {
|
||||
}
|
||||
|
||||
pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
|
||||
sysopt::Sysopt::global().reset_sysproxy().await?;
|
||||
CoreManager::global().stop_core().await?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -152,7 +148,7 @@ async fn init_silent_updater() {
|
||||
// - macOS/Linux: binary is replaced, we restart the app
|
||||
if SilentUpdater::global().try_install_on_startup(app_handle).await {
|
||||
logging!(info, Type::Setup, "Update installed at startup, restarting...");
|
||||
app_handle.restart();
|
||||
feat::restart_app().await;
|
||||
}
|
||||
|
||||
// No pending install — start background check/download loop
|
||||
@@ -190,23 +186,20 @@ pub(super) async fn init_verge_config_before_window() -> bool {
|
||||
|
||||
pub(super) async fn init_service_manager() {
|
||||
clash_verge_service_ipc::set_config(Some(ServiceManager::config())).await;
|
||||
if is_service_ipc_path_exists() && SERVICE_MANAGER.init().await.is_ok() {
|
||||
logging_error!(Type::Setup, SERVICE_MANAGER.refresh().await);
|
||||
|
||||
SERVICE_MANAGER.detect_startup_status().await;
|
||||
}
|
||||
|
||||
pub(super) async fn init_core_manager() -> bool {
|
||||
match CoreManager::global().init().await {
|
||||
Ok(initialized) => initialized,
|
||||
Err(error) => {
|
||||
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn init_core_manager() {
|
||||
logging_error!(Type::Setup, CoreManager::global().init().await);
|
||||
}
|
||||
|
||||
pub(super) async fn init_system_proxy() {
|
||||
logging_error!(Type::Setup, sysopt::Sysopt::global().update_sysproxy().await);
|
||||
}
|
||||
|
||||
pub(super) async fn init_system_proxy_guard() {
|
||||
sysopt::Sysopt::global().refresh_guard().await;
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_tray_menu() {
|
||||
logging_error!(Type::Setup, Tray::global().update_part().await);
|
||||
}
|
||||
|
||||
+545
-59
@@ -1,92 +1,218 @@
|
||||
use super::resolve;
|
||||
use crate::{
|
||||
cmd::is_port_in_use,
|
||||
config::{Config, DEFAULT_PAC, IVerge},
|
||||
config::{Config, DEFAULT_PAC},
|
||||
module::lightweight,
|
||||
process::AsyncHandler,
|
||||
utils::window_manager::WindowManager,
|
||||
utils::{dirs, window_manager::WindowManager},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::ClientBuilder;
|
||||
use smartstring::alias::String;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
use warp::Filter as _;
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
const INSTANCE_TOKEN_HEADER: &str = "x-instance-token";
|
||||
const INSTANCE_RECORD_FILE: &str = "singleton-instance.json";
|
||||
const INSTANCE_LOCK_FILE: &str = "singleton-instance.lock";
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct QueryParam {
|
||||
param: String,
|
||||
}
|
||||
|
||||
// 关闭 embedded server 的信号发送端
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct InstanceRecord {
|
||||
port: u16,
|
||||
token: String,
|
||||
}
|
||||
|
||||
static SHUTDOWN_SENDER: OnceCell<Mutex<Option<oneshot::Sender<()>>>> = OnceCell::new();
|
||||
static EMBEDDED_PORT: OnceCell<u16> = OnceCell::new();
|
||||
static INSTANCE_LOCK: OnceCell<std::fs::File> = OnceCell::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);
|
||||
|
||||
/// check whether there is already exists
|
||||
pub async fn check_singleton() -> Result<()> {
|
||||
let port = IVerge::get_singleton_port();
|
||||
if is_port_in_use(port) {
|
||||
let client = ClientBuilder::new().timeout(Duration::from_millis(500)).build()?;
|
||||
// 需要确保 Send
|
||||
#[allow(clippy::needless_collect)]
|
||||
let argvs: Vec<std::string::String> = std::env::args().collect();
|
||||
if argvs.len() > 1 {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let record_path = instance_record_path()?;
|
||||
let lock = open_instance_lock(&record_path.with_file_name(INSTANCE_LOCK_FILE))?;
|
||||
if !try_lock_instance(&lock)? {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Ok(record) = read_instance_record(&record_path)
|
||||
&& notify_existing_instance(&record).await
|
||||
{
|
||||
let param = argvs[1].as_str();
|
||||
if param.starts_with("clash:") {
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/commands/scheme?param={param}"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
bail!("app exists");
|
||||
}
|
||||
} else {
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/commands/visible"))
|
||||
.send()
|
||||
.await?;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
logging!(error, Type::Window, "failed to setup singleton listen server");
|
||||
bail!("app exists");
|
||||
bail!("another app instance is starting");
|
||||
}
|
||||
|
||||
let preferred = read_instance_record(&record_path).ok().map(|record| record.port);
|
||||
let listener = bind_primary_listener(preferred).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let record = InstanceRecord {
|
||||
port,
|
||||
token: random_token()?,
|
||||
};
|
||||
write_instance_record(&record_path, &record)?;
|
||||
INSTANCE_LOCK
|
||||
.set(lock)
|
||||
.map_err(|_| anyhow::anyhow!("singleton lock is already initialized"))?;
|
||||
let _ = EMBEDDED_PORT.set(port);
|
||||
start_embedded_server(listener, record.token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The embed server only be used to implement singleton process
|
||||
/// maybe it can be used as pac server later
|
||||
pub fn embed_server() {
|
||||
async fn bind_primary_listener(preferred: Option<u16>) -> Result<tokio::net::TcpListener> {
|
||||
let preferred_port = preferred.unwrap_or(0);
|
||||
match tokio::net::TcpListener::bind(std::net::SocketAddrV4::new(
|
||||
std::net::Ipv4Addr::LOCALHOST,
|
||||
preferred_port,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(listener) => Ok(listener),
|
||||
Err(_) if preferred.is_some() => {
|
||||
Ok(tokio::net::TcpListener::bind(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 0)).await?)
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn notify_existing_instance(record: &InstanceRecord) -> bool {
|
||||
let client = match ClientBuilder::new().timeout(Duration::from_millis(500)).build() {
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let request = if let Some(arg) = std::env::args().nth(1).as_deref() {
|
||||
if arg.starts_with("clash:") {
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{}/commands/scheme", record.port))
|
||||
.query(&[("param", arg)])
|
||||
} else {
|
||||
client.get(format!("http://127.0.0.1:{}/commands/visible", record.port))
|
||||
}
|
||||
} else {
|
||||
client.get(format!("http://127.0.0.1:{}/commands/visible", record.port))
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let request = client.get(format!("http://127.0.0.1:{}/commands/visible", record.port));
|
||||
|
||||
request
|
||||
.header(INSTANCE_TOKEN_HEADER, &record.token)
|
||||
.send()
|
||||
.await
|
||||
.is_ok_and(|response| response.status().is_success())
|
||||
}
|
||||
|
||||
fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp::Rejection> + Clone {
|
||||
warp::header::optional::<String>(INSTANCE_TOKEN_HEADER)
|
||||
.and_then(move |provided: Option<String>| {
|
||||
let token = token.clone();
|
||||
async move {
|
||||
if provided.as_deref() == Some(token.as_str()) {
|
||||
Ok::<(), warp::Rejection>(())
|
||||
} else {
|
||||
Err(warp::reject())
|
||||
}
|
||||
}
|
||||
})
|
||||
.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();
|
||||
#[allow(clippy::expect_used)]
|
||||
SHUTDOWN_SENDER
|
||||
.set(Mutex::new(Some(shutdown_tx)))
|
||||
.expect("failed to set shutdown signal for embedded server");
|
||||
let port = IVerge::get_singleton_port();
|
||||
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
|
||||
|
||||
let auth = instance_auth(token.clone());
|
||||
|
||||
let visible = warp::path!("commands" / "visible").and_then(|| async {
|
||||
if !COMMANDS_READY.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(warp::reply::with_status(
|
||||
"starting".to_string(),
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
logging!(info, Type::Window, "检测到从单例模式恢复应用窗口");
|
||||
if !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
} else {
|
||||
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
|
||||
};
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status::<std::string::String>(
|
||||
"ok".to_string(),
|
||||
warp::http::StatusCode::OK,
|
||||
))
|
||||
if crate::APP_HANDLE.get().is_some() {
|
||||
if !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
} else {
|
||||
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
|
||||
}
|
||||
}
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
});
|
||||
|
||||
let pac = warp::path!("commands" / "pac").and_then(|| async move {
|
||||
if !PAC_AVAILABLE.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(
|
||||
warp::http::Response::builder()
|
||||
.status(warp::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body("PAC endpoint is inactive".to_string())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
let verge_config = Config::verge().await;
|
||||
let clash_config = Config::clash().await;
|
||||
|
||||
let verge_data = verge_config.data_arc();
|
||||
let clash_data = clash_config.data_arc();
|
||||
|
||||
let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC);
|
||||
|
||||
let pac_port = verge_data
|
||||
.verge_mixed_port
|
||||
.unwrap_or_else(|| clash_data.get_mixed_port());
|
||||
@@ -99,25 +225,31 @@ pub fn embed_server() {
|
||||
)
|
||||
});
|
||||
|
||||
// Use map instead of and_then to avoid Send issues
|
||||
let scheme = warp::path!("commands" / "scheme")
|
||||
.and(warp::query::<QueryParam>())
|
||||
.and_then(|query: QueryParam| async move {
|
||||
if !COMMANDS_READY.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(warp::reply::with_status(
|
||||
"starting".to_string(),
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
AsyncHandler::spawn(|| async move {
|
||||
logging_error!(Type::Setup, resolve::resolve_scheme(&query.param).await);
|
||||
});
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status::<std::string::String>(
|
||||
"ok".to_string(),
|
||||
warp::http::StatusCode::OK,
|
||||
))
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
});
|
||||
|
||||
let commands = visible.or(scheme).or(pac);
|
||||
|
||||
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)
|
||||
.bind(([127, 0, 0, 1], port))
|
||||
.await
|
||||
.incoming(listener)
|
||||
.graceful(async {
|
||||
shutdown_rx.await.ok();
|
||||
})
|
||||
@@ -126,6 +258,31 @@ pub fn embed_server() {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_pac_available(available: bool) {
|
||||
PAC_AVAILABLE.store(available, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn set_commands_ready() {
|
||||
COMMANDS_READY.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn embed_server() {
|
||||
if EMBEDDED_PORT.get().is_none() {
|
||||
logging!(
|
||||
error,
|
||||
Type::Window,
|
||||
"embedded server was not reserved during singleton check"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn embedded_server_port() -> Result<u16> {
|
||||
EMBEDDED_PORT
|
||||
.get()
|
||||
.copied()
|
||||
.context("embedded server port is not initialized")
|
||||
}
|
||||
|
||||
pub fn shutdown_embedded_server() {
|
||||
logging!(info, Type::Window, "shutting down embedded server");
|
||||
if let Some(sender) = SHUTDOWN_SENDER.get()
|
||||
@@ -134,3 +291,332 @@ pub fn shutdown_embedded_server() {
|
||||
sender.send(()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn instance_record_path() -> Result<PathBuf> {
|
||||
Ok(dirs::preinit_app_data_dir()?.join(INSTANCE_RECORD_FILE))
|
||||
}
|
||||
|
||||
fn open_instance_lock(path: &Path) -> Result<std::fs::File> {
|
||||
let parent = path.parent().context("singleton lock has no parent")?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::core::owner_identity::open_or_create_private_current_user_file(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).write(true).create(true);
|
||||
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
|
||||
let file = options.open(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
bail!("singleton lock is not an ordinary file");
|
||||
}
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(file)
|
||||
}
|
||||
}
|
||||
|
||||
fn try_lock_instance(file: &std::fs::File) -> Result<bool> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsRawFd as _;
|
||||
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if result == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error
|
||||
.raw_os_error()
|
||||
.is_some_and(|code| code == libc::EWOULDBLOCK || code == libc::EAGAIN)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error).context("failed to lock singleton file")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
use windows_sys::Win32::Foundation::{ERROR_LOCK_VIOLATION, GetLastError};
|
||||
use windows_sys::Win32::Storage::FileSystem::LockFile;
|
||||
let locked = unsafe { LockFile(file.as_raw_handle(), 0, 0, u32::MAX, u32::MAX) };
|
||||
if locked != 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
if unsafe { GetLastError() } == ERROR_LOCK_VIOLATION {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(std::io::Error::last_os_error()).context("failed to lock singleton file")
|
||||
}
|
||||
}
|
||||
|
||||
fn random_token() -> Result<String> {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes)?;
|
||||
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
fn read_instance_record(path: &Path) -> Result<InstanceRecord> {
|
||||
#[cfg(windows)]
|
||||
let mut file = crate::core::owner_identity::open_private_current_user_file(path)?;
|
||||
#[cfg(unix)]
|
||||
let mut file = {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).custom_flags(libc::O_NOFOLLOW);
|
||||
options.open(path)?
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
bail!("singleton record is not an ordinary file");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o777 != 0o600 {
|
||||
bail!("singleton record permissions are invalid");
|
||||
}
|
||||
}
|
||||
let mut content = Vec::new();
|
||||
file.read_to_end(&mut content)?;
|
||||
Ok(serde_json::from_slice(&content)?)
|
||||
}
|
||||
|
||||
fn write_instance_record(path: &Path, record: &InstanceRecord) -> Result<()> {
|
||||
let parent = path.parent().context("singleton record has no parent")?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let temporary = path.with_extension(format!("json.tmp-{}-{}", std::process::id(), &random_token()?[..16]));
|
||||
#[cfg(windows)]
|
||||
let mut file = crate::core::owner_identity::create_private_current_user_file(&temporary)?;
|
||||
#[cfg(unix)]
|
||||
let mut file = {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
options.mode(0o600);
|
||||
options.open(&temporary)?
|
||||
};
|
||||
file.write_all(&serde_json::to_vec(record)?)?;
|
||||
file.sync_all()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
drop(file);
|
||||
replace_file_atomic(&temporary, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> {
|
||||
std::fs::rename(source, destination)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
|
||||
|
||||
let mut source: Vec<u16> = source.as_os_str().encode_wide().collect();
|
||||
source.push(0);
|
||||
let mut destination: Vec<u16> = destination.as_os_str().encode_wide().collect();
|
||||
destination.push(0);
|
||||
if unsafe {
|
||||
MoveFileExW(
|
||||
source.as_ptr(),
|
||||
destination.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to replace singleton record");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InstanceRecord, PAC_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record};
|
||||
#[cfg(unix)]
|
||||
use super::{open_instance_lock, try_lock_instance};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch};
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize},
|
||||
};
|
||||
#[test]
|
||||
fn pac_is_fail_closed_before_core_readiness() {
|
||||
assert!(!PAC_AVAILABLE.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dev_quit_route_requires_private_auth_ready_state_and_dispatches_once() -> anyhow::Result<()> {
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
static REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
READY.store(false, Ordering::Release);
|
||||
REQUESTED.store(false, Ordering::Release);
|
||||
let dispatches = Arc::new(AtomicUsize::new(0));
|
||||
let dispatches_for_route = Arc::clone(&dispatches);
|
||||
let token = "ab".repeat(32);
|
||||
let route = dev_quit_route(token.clone(), &READY, &REQUESTED, move || {
|
||||
dispatches_for_route.fetch_add(1, Ordering::AcqRel);
|
||||
});
|
||||
|
||||
let missing = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(missing.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let wrong_token = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, "cd".repeat(32))
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_token.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let not_ready = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(not_ready.status(), warp::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
let wrong_method = warp::test::request()
|
||||
.method("GET")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_method.status(), warp::http::StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
READY.store(true, Ordering::Release);
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(8));
|
||||
let accepted = futures::future::join_all((0..8).map(|_| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let route = route.clone();
|
||||
let token = token.clone();
|
||||
tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, token)
|
||||
.reply(&route)
|
||||
.await
|
||||
})
|
||||
}))
|
||||
.await;
|
||||
for accepted in accepted {
|
||||
let accepted = accepted?;
|
||||
assert_eq!(accepted.status(), warp::http::StatusCode::ACCEPTED);
|
||||
}
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 1);
|
||||
|
||||
release_dev_quit_latch(&REQUESTED, clash_verge_signal::ShutdownOutcome::Canceled);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
let retry = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(retry.status(), warp::http::StatusCode::ACCEPTED);
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_record_supplies_the_next_primarys_preferred_port() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-sticky-port-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let reservation = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = reservation.local_addr()?.port();
|
||||
drop(reservation);
|
||||
write_instance_record(
|
||||
&path,
|
||||
&InstanceRecord {
|
||||
port: preferred_port,
|
||||
token: "stale-token-is-not-authority".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let hint = read_instance_record(&path).ok().map(|record| record.port);
|
||||
let listener = bind_primary_listener(hint).await?;
|
||||
assert_eq!(listener.local_addr()?.port(), preferred_port);
|
||||
drop(listener);
|
||||
super::shutdown_embedded_server();
|
||||
assert!(path.is_file(), "clean shutdown must retain the private port hint");
|
||||
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn occupied_preferred_port_falls_back_without_using_stale_authority() -> anyhow::Result<()> {
|
||||
let occupied = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = occupied.local_addr()?.port();
|
||||
|
||||
let fallback = bind_primary_listener(Some(preferred_port)).await?;
|
||||
assert_ne!(fallback.local_addr()?.port(), preferred_port);
|
||||
assert!(fallback.local_addr()?.ip().is_loopback());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_record_is_private_and_round_trips() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("singleton-record-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let record = InstanceRecord {
|
||||
port: 42_123,
|
||||
token: "secret".to_string(),
|
||||
};
|
||||
|
||||
write_instance_record(&path, &record)?;
|
||||
|
||||
assert_eq!(read_instance_record(&path)?, record);
|
||||
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_lock_allows_only_one_primary() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-lock-{}", std::process::id()));
|
||||
let path = root.join("instance.lock");
|
||||
let first = open_instance_lock(&path)?;
|
||||
let second = open_instance_lock(&path)?;
|
||||
|
||||
assert!(try_lock_instance(&first)?);
|
||||
assert!(!try_lock_instance(&second)?);
|
||||
drop(first);
|
||||
assert!(try_lock_instance(&second)?);
|
||||
|
||||
drop(second);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user