Files
clash-verge-rev/src-tauri/src/core/service.rs
T
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* refactor(ipc): generate bindings with Specta

* fix(profile): project IPC inputs to consumed fields

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

* fix(proxy): confirm runtime chain updates before UI changes

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
2026-10-12 06:16:05 +00:00

2720 lines
100 KiB
Rust

use crate::core::notify::NoticeStatus;
use crate::utils::dirs;
use crate::{
config::{Config, runtime::IRuntime},
constants,
core::{
CoreManager,
handle::Handle,
manager::{CoreFailure, RunningMode},
owner_identity::current_owner_credentials,
proxy_control,
runstate::{
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
},
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
tray::Tray,
},
process::AsyncHandler,
};
use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{
MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome,
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
StageRuntimeOutcome, StartClashRequest, WriterConfig,
};
use parking_lot::Mutex;
use std::{
collections::HashMap,
env::current_exe,
future::Future,
path::{Path, PathBuf},
process::Command as StdCommand,
sync::atomic::{AtomicBool, AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(None);
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
/// Why the Service was unavailable when the core fell back to Sidecar.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
pub enum ServiceFallbackNotice {
Unavailable,
CoreRejected(String),
NotAutoStarted,
}
/// The release installer registers AutoStart.
const SERVICE_NOT_AUTO_STARTED: &str = "the Windows service is stopped and no longer starts with Windows";
pub(crate) fn is_not_auto_started(reason: &str) -> bool {
// Detection prefixes its own context to the reason.
reason.contains(SERVICE_NOT_AUTO_STARTED)
}
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback(reason: &str) {
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
ServiceFallbackNotice::CoreRejected(reason)
} else if is_not_auto_started(&reason) {
ServiceFallbackNotice::NotAutoStarted
} else {
ServiceFallbackNotice::Unavailable
})
}
pub(crate) fn take_service_repair_notice() -> bool {
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
}
/// Returns the command that gives the app data root back to this account.
pub(crate) fn take_service_owner_notice() -> Option<String> {
PENDING_SERVICE_OWNER_NOTICE.lock().take()
}
fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
let OwnerIdentity::Unix { uid, gid } = credentials.identity else {
return None;
};
let path = credentials.app_data_dir.replace('\'', r"'\''");
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
}
/// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades.
#[derive(Clone)]
struct ActiveServiceSession {
proof: OwnerSessionProof,
supports_runtime_staging: bool,
supports_runtime_file_read: bool,
}
fn generate_service_session_token() -> Result<String> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes).context("failed to generate service owner session")?;
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
}
pub(crate) fn active_service_session() -> Result<OwnerSessionProof> {
ACTIVE_SERVICE_SESSION
.lock()
.as_ref()
.map(|session| session.proof.clone())
.context("service owner session is not active")
}
/// Returns false unless the active service session explicitly supports in-place staging.
pub(crate) fn active_service_supports_runtime_staging() -> bool {
ACTIVE_SERVICE_SESSION
.lock()
.as_ref()
.is_some_and(|session| session.supports_runtime_staging)
}
pub(crate) fn active_service_supports_runtime_file_read() -> bool {
ACTIVE_SERVICE_SESSION
.lock()
.as_ref()
.is_some_and(|session| session.supports_runtime_file_read)
}
pub(crate) fn clear_active_service_session() {
ACTIVE_SERVICE_SESSION.lock().take();
}
#[derive(Clone, Copy, Default)]
struct ServiceCapabilities {
runtime_staging: bool,
runtime_file_read: bool,
}
impl ServiceCapabilities {
const fn of(info: &ProtocolInfo) -> Self {
Self {
runtime_staging: info.supports_runtime_staging(),
runtime_file_read: info.supports_runtime_file_read(),
}
}
}
/// Failed capability probes must not block startup.
#[tracing::instrument(skip_all, level = "info", fields(supported = tracing::field::Empty))]
async fn probe_service_capabilities() -> ServiceCapabilities {
match clash_verge_service_ipc::get_version().await {
Ok(response) if response.code == 0 => {
let capabilities = response
.data
.as_ref()
.map_or_else(ServiceCapabilities::default, ServiceCapabilities::of);
tracing::Span::current().record("supported", capabilities.runtime_staging);
capabilities
}
Ok(response) => {
tracing::Span::current().record("supported", false);
logging!(
warn,
Type::Service,
"服务协议查询返回 {}: {};配置变更将走重启路径",
response.code,
response.message
);
ServiceCapabilities::default()
}
Err(error) => {
tracing::Span::current().record("supported", false);
logging!(
warn,
Type::Service,
"无法查询服务协议版本: {error:#};配置变更将走重启路径"
);
ServiceCapabilities::default()
}
}
}
fn session_matches_status(proof: &OwnerSessionProof, is_active: bool, active_generation: Option<u64>) -> bool {
is_active && active_generation == Some(proof.generation)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceStatus {
Checking,
Ready,
NotInstalled,
NeedsReinstall,
InstallRequired,
UninstallRequired,
ReinstallRequired,
ForceReinstallRequired,
SidecarAllowed,
Unavailable(String),
}
impl ServiceStatus {
/// Flattens Run State using legacy precedence: action, Sidecar allowance, then health.
fn from_run_state(state: &RunState) -> Self {
if let Some(action) = state.pending {
return match action {
PendingAction::Install => Self::InstallRequired,
PendingAction::Uninstall => Self::UninstallRequired,
PendingAction::Reinstall => Self::ReinstallRequired,
PendingAction::ForceReinstall => Self::ForceReinstallRequired,
};
}
if state.sidecar_allowed {
return Self::SidecarAllowed;
}
match &state.health {
ServiceHealth::Unknown => Self::Checking,
ServiceHealth::Ready => Self::Ready,
ServiceHealth::NotInstalled => Self::NotInstalled,
ServiceHealth::VersionMismatch => Self::NeedsReinstall,
ServiceHealth::Unavailable(reason) => Self::Unavailable(reason.clone()),
}
}
}
#[cfg(target_os = "macos")]
fn path_entry_exists_without_follow(path: &Path) -> std::io::Result<bool> {
match std::fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
#[cfg(target_os = "macos")]
fn macos_service_install_markers() -> Vec<String> {
vec![
format!(
"/Library/LaunchDaemons/{}.plist",
clash_verge_service_ipc::MACOS_SERVICE_ID
),
format!(
"/Library/PrivilegedHelperTools/{}.bundle",
clash_verge_service_ipc::MACOS_SERVICE_ID
),
#[cfg(not(feature = "verge-dev"))]
"/Library/LaunchDaemons/io.github.clashverge.helper.plist".to_owned(),
#[cfg(not(feature = "verge-dev"))]
"/Library/PrivilegedHelperTools/io.github.clashverge.helper".to_owned(),
]
}
#[cfg(target_os = "macos")]
fn macos_service_install_marker_exists() -> std::io::Result<bool> {
for marker in macos_service_install_markers() {
if path_entry_exists_without_follow(Path::new(&marker))? {
return Ok(true);
}
}
Ok(false)
}
#[cfg(windows)]
fn open_registered_service() -> Result<Option<windows_service::service::Service>> {
use windows_service::{
Error as WindowsServiceError,
service::ServiceAccess,
service_manager::{ServiceManager as WindowsServiceManager, ServiceManagerAccess},
};
const ERROR_SERVICE_DOES_NOT_EXIST: i32 = 1060;
let manager = WindowsServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
match manager.open_service(
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::QUERY_CONFIG,
) {
Ok(service) => Ok(Some(service)),
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
Ok(None)
}
Err(error) => Err(error).context("failed to inspect Windows service registration"),
}
}
#[cfg(windows)]
pub(crate) fn trusted_service_evidence() -> Result<bool> {
Ok(open_registered_service()?.is_some())
}
/// Why IPC cannot succeed until the service is started again, if it cannot. A starting service,
/// or an AutoStart one the SCM has yet to start this boot, is left to the IPC retries.
#[cfg(windows)]
pub(crate) fn service_stop_reason() -> Result<Option<&'static str>> {
use windows_service::service::{ServiceExitCode, ServiceStartType, ServiceState};
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
const NOT_RUNNING: &str = "the Windows service is not running";
let Some(service) = open_registered_service()? else {
return Ok(Some(NOT_RUNNING));
};
let status = service
.query_status()
.context("failed to query Windows service status")?;
if status.current_state != ServiceState::Stopped {
return Ok(None);
}
// The development channel registers an on-demand start.
if !cfg!(feature = "verge-dev")
&& service
.query_config()
.context("failed to query Windows service configuration")?
.start_type
!= ServiceStartType::AutoStart
{
return Ok(Some(SERVICE_NOT_AUTO_STARTED));
}
Ok((status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED)).then_some(NOT_RUNNING))
}
#[cfg(target_os = "linux")]
pub(crate) fn trusted_service_evidence() -> Result<bool> {
let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG);
let output = StdCommand::new("systemctl")
.args(["show", "--property=LoadState", "--value", &unit])
.output()
.context("failed to inspect systemd service registration")?;
if !output.status.success() {
bail!(
"systemd service registration probe failed with status {}",
output.status
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim() != "not-found")
}
#[cfg(target_os = "macos")]
pub(crate) fn trusted_service_evidence() -> Result<bool> {
macos_service_install_marker_exists().context("failed to inspect launchd service registration")
}
/// Stateless legacy façade over [`RUN_STATE`] retained for existing call sites.
pub struct ServiceManager;
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
static SERVICE_CORE_STAGING_GENERATION: AtomicU64 = AtomicU64::new(0);
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
fn create_service_core_staging_file(directory: &Path, core_name: &std::ffi::OsStr) -> Result<(PathBuf, std::fs::File)> {
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
retry_sync(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(31),
std::time::Duration::ZERO,
),
|_| {
let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
let temporary_name = format!(
".{}.{}.{generation}.tmp",
core_name.to_string_lossy(),
std::process::id()
);
let temporary_path = directory.join(temporary_name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary_path)
{
Ok(file) => Ok((temporary_path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Err(RetryError::Retry(anyhow::anyhow!(
"failed to create a unique temporary development Service core in {}",
directory.display()
)))
}
Err(error) => Err(RetryError::Stop(anyhow::Error::from(error).context({
format!(
"failed to create temporary development Service core {}",
temporary_path.display()
)
}))),
}
},
)
}
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
fn service_core_path_for(source: &Path, home: Option<&Path>, stage_for_macos_dev: bool) -> Result<PathBuf> {
service_core_path_for_with_publisher(
source,
home,
stage_for_macos_dev,
"service-core",
|temporary_path, final_path| {
std::fs::rename(temporary_path, final_path).with_context(|| {
format!(
"failed to publish development Service core {} over {}",
temporary_path.display(),
final_path.display()
)
})
},
)
}
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), all(test, unix)))]
fn service_tool_path_for(source: &Path, home: Option<&Path>, stage_for_macos_dev: bool) -> Result<PathBuf> {
service_core_path_for_with_publisher(
source,
home,
stage_for_macos_dev,
"service-tools",
|temporary_path, final_path| {
std::fs::rename(temporary_path, final_path).with_context(|| {
format!(
"failed to publish development Service tool {} over {}",
temporary_path.display(),
final_path.display()
)
})
},
)
}
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
#[cfg_attr(not(unix), allow(unreachable_code, unused_assignments, unused_variables))]
fn service_core_path_for_with_publisher<F>(
source: &Path,
home: Option<&Path>,
stage_for_macos_dev: bool,
staging_directory_name: &str,
publisher: F,
) -> Result<PathBuf>
where
F: FnOnce(&Path, &Path) -> Result<()>,
{
if !stage_for_macos_dev {
return Ok(source.to_path_buf());
}
let home = home
.filter(|path| !path.as_os_str().is_empty())
.context("HOME is unavailable for development Service core staging")?;
let core_name = source
.file_name()
.filter(|name| !name.is_empty())
.with_context(|| format!("development Service core source has no file name: {}", source.display()))?;
let source_metadata = std::fs::symlink_metadata(source)
.with_context(|| format!("failed to inspect development Service core source {}", source.display()))?;
if !source_metadata.file_type().is_file() {
bail!(
"development Service core source is not an ordinary file: {}",
source.display()
);
}
let mut source_file = std::fs::File::open(source)
.with_context(|| format!("failed to open development Service core source {}", source.display()))?;
let staging_directory = home
.join("Applications/.clash-verge-rev-dev")
.join(staging_directory_name);
std::fs::create_dir_all(&staging_directory).with_context(|| {
format!(
"failed to create development Service core staging directory {}",
staging_directory.display()
)
})?;
let final_path = staging_directory.join(core_name);
let (temporary_path, mut temporary_file) = create_service_core_staging_file(&staging_directory, core_name)?;
let publish_result = (|| -> Result<()> {
std::io::copy(&mut source_file, &mut temporary_file).with_context(|| {
format!(
"failed to copy development Service core from {} to {}",
source.display(),
temporary_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mut permissions = temporary_file
.metadata()
.with_context(|| format!("failed to inspect temporary Service core {}", temporary_path.display()))?
.permissions();
permissions.set_mode(0o755);
temporary_file.set_permissions(permissions).with_context(|| {
format!(
"failed to set executable permissions on temporary Service core {}",
temporary_path.display()
)
})?;
}
#[cfg(not(unix))]
bail!("development Service core staging requires Unix executable permissions");
temporary_file
.sync_all()
.with_context(|| format!("failed to sync temporary Service core {}", temporary_path.display()))?;
drop(temporary_file);
publisher(&temporary_path, &final_path)?;
Ok(())
})();
if let Err(error) = publish_result {
match std::fs::remove_file(&temporary_path) {
Ok(()) => return Err(error),
Err(cleanup_error) if cleanup_error.kind() == std::io::ErrorKind::NotFound => return Err(error),
Err(cleanup_error) => {
return Err(error).with_context(|| {
format!(
"failed to clean temporary development Service core {}: {cleanup_error}",
temporary_path.display()
)
});
}
}
}
Ok(final_path)
}
#[cfg(target_os = "macos")]
#[cfg_attr(not(feature = "verge-dev"), allow(clippy::unnecessary_wraps))]
fn macos_service_tool_path(source: &Path) -> Result<PathBuf> {
#[cfg(feature = "verge-dev")]
{
let home = std::env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from);
service_tool_path_for(source, home.as_deref(), true)
}
#[cfg(not(feature = "verge-dev"))]
Ok(source.to_path_buf())
}
fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
let sibling = current_exe()
.map_err(|error| {
anyhow::anyhow!(
"failed to locate the current executable while resolving Service core {clash_core:?}: {error}"
)
})?
.with_file_name(format!("{clash_core}{bin_ext}"));
#[cfg(all(target_os = "macos", feature = "verge-dev"))]
{
let home = std::env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from);
service_core_path_for(&sibling, home.as_deref(), true)
}
#[cfg(not(all(target_os = "macos", feature = "verge-dev")))]
Ok(sibling)
}
#[cfg(target_os = "macos")]
fn escape_osascript_double_quoted_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
#[cfg(feature = "verge-dev")]
{
drop(packaged_path);
let directory = std::env::var_os("CLASH_VERGE_DEV_SERVICE_DIR")
.context("CLASH_VERGE_DEV_SERVICE_DIR is missing from the development session")?;
let directory = PathBuf::from(directory);
if !directory.is_absolute() {
bail!("CLASH_VERGE_DEV_SERVICE_DIR must be an absolute path");
}
Ok(directory.join(file_name))
}
#[cfg(not(feature = "verge-dev"))]
{
let _ = file_name;
packaged_path()
}
}
#[cfg(target_os = "windows")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::os::windows::process::CommandExt as _;
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
let status = match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(uninstall_path).show(false).status()?,
_ => StdCommand::new(uninstall_path).creation_flags(0x08000000).status()?,
};
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let elevator = crate::utils::help::linux_elevator();
let status = if linux_running_as_root() {
StdCommand::new(&uninstall_path).status()?
} else {
let result = StdCommand::new(&elevator)
.arg("--disable-internal-agent")
.arg(&uninstall_path)
.status()?;
// 如果 pkexec 执行失败,回退到 sudo
if !result.success() && elevator.contains("pkexec") {
logging!(
warn,
Type::Service,
"pkexec failed with code {}, falling back to sudo",
result.code().unwrap_or(-1)
);
StdCommand::new("sudo").arg(&uninstall_path).status()?
} else {
result
}
};
logging!(
info,
Type::Service,
"uninstall status code:{}",
status.code().unwrap_or(-1)
);
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn linux_running_as_root() -> bool {
use crate::core::handle;
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
let app_handle = handle::Handle::app_handle();
is_current_app_handle_admin(app_handle)
}
#[cfg(target_os = "macos")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let uninstall_path = macos_service_tool_path(&uninstall_path)?;
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
let uninstall_quoted = shell_single_quote(&uninstall_shell);
let shell = format!("cd /; {uninstall_quoted}");
let shell = escape_osascript_double_quoted_string(&shell);
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
// logging!(debug, Type::Service, "uninstall command: {}", command);
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
fn install_service() -> Result<()> {
let executable = current_exe()?;
let cores = crate::config::IVerge::VALID_CLASH_CORES
.iter()
.map(|core| {
let name = format!("{core}{}", std::env::consts::EXE_SUFFIX);
clash_verge_service_ipc::management::CoreSource {
path: executable.with_file_name(&name),
name,
}
})
.collect::<Vec<_>>();
invoke_service_install(&cores, false)
}
fn invoke_service_install(cores: &[clash_verge_service_ipc::management::CoreSource], core_only: bool) -> Result<()> {
let name = format!("clash-verge-service-install{}", std::env::consts::EXE_SUFFIX);
let installer = packaged_service_tool_path(&name, || {
#[cfg(target_os = "linux")]
let executable = tauri::utils::platform::current_exe()?;
#[cfg(not(target_os = "linux"))]
let executable = dirs::service_path()?;
Ok(executable.with_file_name(&name))
})?;
#[cfg(unix)]
let gid = Some(tauri_plugin_clash_verge_sysinfo::current_gid());
#[cfg(windows)]
let gid = None;
clash_verge_service_ipc::management::install(
&installer,
cores,
core_only,
gid,
&clash_verge_i18n::t!("service.adminInstallPrompt"),
)
}
fn reinstall_service() -> Result<()> {
logging!(info, Type::Service, "reinstall service");
// The installer replaces an existing registration and its cores in one elevation.
install_service()
}
/// 强制重装服务(UI修复按钮)
fn force_reinstall_service() -> Result<()> {
logging!(info, Type::Service, "用户请求强制重装服务");
install_service().map_err(|err| {
logging!(error, Type::Service, "强制重装服务失败: {}", err);
err
})
}
/// Publishes a core into the Service's approved directory through the elevated installer.
///
/// The Service only executes administrator-approved copies from its own directory, never the file
/// beside the app, so a freshly replaced core has to be handed over before a service-mode restart
/// asks for it. The digest pins the exact bytes being attested: it rides the elevated process's
/// command line, which no other local account can alter, so a file swapped on disk after this
/// hash was computed is refused by the installer instead of published.
pub fn stage_approved_core(core_path: &Path) -> Result<()> {
tokio::task::block_in_place(|| {
invoke_service_install(
&[clash_verge_service_ipc::management::CoreSource {
name: core_path
.file_name()
.context("core has no filename")?
.to_string_lossy()
.into_owned(),
path: core_path.to_path_buf(),
}],
true,
)
})
}
/// Dispatches a privileged platform operation on a blocking thread.
pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> {
let (operation, label): (fn() -> Result<()>, &'static str) = match action {
PendingAction::Install => (install_service, "install service"),
PendingAction::Uninstall => (uninstall_service, "uninstall service"),
PendingAction::Reinstall => (reinstall_service, "reinstall service"),
PendingAction::ForceReinstall => (force_reinstall_service, "force reinstall service"),
};
tokio::task::block_in_place(operation).with_context(|| format!("{label} failed"))
}
/// Builds the same runtime bundle description for both service start and staging.
async fn collect_service_runtime_bundle(config_file: &Path) -> Result<RuntimeBundle> {
let verge_config = Config::verge().await;
let clash_core = verge_config.latest_arc().get_valid_clash_core();
drop(verge_config);
let bin_ext = if cfg!(windows) { ".exe" } else { "" };
let bin_path = service_core_path(&clash_core, bin_ext)?;
collect_runtime_bundle(config_file, &bin_path).await
}
/// A staging response whose refusal code tells callers whether a fresh start can help.
pub(super) enum StageRequest {
Refused { code: u16, message: String },
Answered(StageRuntimeOutcome),
}
impl StageRequest {
/// Whether a refusal is about the bundle, and so would be repeated by starting from it.
pub(super) const fn is_about_the_bundle(code: u16) -> bool {
code == ServiceErrorCode::InvalidRuntimeAsset as u16 || code == ServiceErrorCode::InvalidInstallLocation as u16
}
}
/// Requests in-place staging. `Err` means no answer; refusals are returned for caller policy.
pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<StageRequest> {
let session = active_service_session()?;
let credentials = current_owner_credentials()?;
let runtime = collect_service_runtime_bundle(config_file).await?;
let response = clash_verge_service_ipc::stage_runtime(&credentials, &session, &runtime)
.await
.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
return Ok(StageRequest::Refused {
code: response.code,
message: response.message,
});
}
response
.data
.map(StageRequest::Answered)
.context("Clash Verge Service 未返回运行时暂存结果")
}
#[derive(Debug)]
pub(super) struct ServiceStartRefusal {
pub code: u16,
pub core_path: String,
pub message: String,
}
impl std::fmt::Display for ServiceStartRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"failed to start Service core at {} (code {}): {}",
self.core_path, self.code, self.message
)
}
}
impl std::error::Error for ServiceStartRefusal {}
fn record_service_start_refusal<E: RunStateEnv>(
store: &RunStateStore<E>,
refusal: ServiceStartRefusal,
) -> anyhow::Error {
// A proxy clear failure is about the system network settings; repairing the service cannot fix it.
if store.state().mode == crate::core::manager::RunningMode::NotRunning
&& refusal.code != ServiceErrorCode::ProxyClearFailed as u16
{
store.observe(ServiceHealth::Unavailable(refusal.to_string()));
}
refusal.into()
}
/// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair.
pub(super) fn record_residual_service(error: &anyhow::Error) {
if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() {
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string()));
}
}
/// 尝试使用服务启动core
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
clear_active_service_session();
let credentials = current_owner_credentials()?;
let runtime = collect_service_runtime_bundle(config_file).await?;
let proposed_session_token = generate_service_session_token()?;
let request = StartClashRequest {
runtime,
proposed_session_token: proposed_session_token.clone(),
macos_proxy: None,
};
let response = match clash_verge_service_ipc::start_clash(&credentials, &request).await {
Ok(response) => response,
Err(error) => {
tracing::Span::current().record("outcome", "ipc-unreachable");
start_owner_monitor();
return Err(error).context("无法连接到Clash Verge Service");
}
};
if response.code > 0 {
tracing::Span::current().record("code", response.code);
tracing::Span::current().record("outcome", "refused");
let err_msg = response.message;
logging!(
error,
Type::Service,
"启动核心失败 (code {}): {}",
response.code,
err_msg
);
#[cfg(target_os = "windows")]
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
Handle::notice(NoticeStatus::ServiceCoreRepairRequired, "");
}
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
Handle::notice(NoticeStatus::ServiceCoreAppDataNotOwned, "");
}
start_owner_monitor();
return Err(record_service_start_refusal(
&RUN_STATE,
ServiceStartRefusal {
code: response.code,
core_path: request.runtime.core_path,
message: err_msg,
},
));
}
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
tracing::Span::current().record("generation", result.session.generation);
let capabilities = probe_service_capabilities().await;
tracing::Span::current().record("staging", capabilities.runtime_staging);
*ACTIVE_SERVICE_SESSION.lock() = Some(ActiveServiceSession {
proof: OwnerSessionProof {
generation: result.session.generation,
token: proposed_session_token,
},
supports_runtime_staging: capabilities.runtime_staging,
supports_runtime_file_read: capabilities.runtime_file_read,
});
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
start_owner_monitor();
tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.lock().take();
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_OWNER_NOTICE.lock().take();
logging!(
info,
Type::Service,
"服务成功启动核心 (session generation {})",
result.session.generation
);
Ok(())
}
// 以服务启动core
pub(super) async fn run_core_by_service(config_file: &Path) -> Result<()> {
SERVICE_MANAGER.refresh().await?;
let status = SERVICE_MANAGER.current().await;
if !matches!(status, ServiceStatus::Ready) {
bail!("service is not ready after refresh: {status:?}");
}
logging!(info, Type::Service, "服务已运行且版本匹配,直接使用");
start_with_existing_service(config_file).await
}
async fn capture_generation_before<F, Fut, T>(generation: &AtomicU64, operation: F) -> (u64, T)
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
let captured = generation.load(Ordering::Acquire);
(captured, operation().await)
}
pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
// Frontend-polled: no per-call logging here.
let credentials = current_owner_credentials()?;
let (generation, response) = capture_generation_before(&OWNER_MONITOR_GENERATION, || {
clash_verge_service_ipc::get_clash_logs(&credentials)
})
.await;
let response = response.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
}
let err_msg = response.message;
bail!(err_msg);
}
Ok(response.data.unwrap_or_default())
}
pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
let credentials = current_owner_credentials()?;
let (generation, response) = capture_generation_before(&OWNER_MONITOR_GENERATION, || {
clash_verge_service_ipc::get_clash_log_snapshot(&credentials)
})
.await;
let response = response.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
}
bail!(response.message);
}
let encoded = response.data.context("服务未返回核心日志快照")?;
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
Ok(String::from_utf8_lossy_owned(content))
}
fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
if !encoded.len().is_multiple_of(2) {
bail!("hex payload has an odd length");
}
(0..encoded.len())
.step_by(2)
.map(|offset| u8::from_str_radix(&encoded[offset..offset + 2], 16).context("hex payload is malformed"))
.collect()
}
static PROVIDER_SYNC_QUEUED: AtomicBool = AtomicBool::new(false);
// Serializing the whole read-and-publish pass prevents an older cache from replacing a newer one.
static PROVIDER_SYNC_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
static SYNC_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const RUNTIME_PROVIDER_SYNC_ATTEMPTS: u32 = 4;
const CONTENT_COMPARE_CHUNK: usize = 64 * 1024;
pub(crate) fn request_runtime_provider_sync(delay: Duration) {
if PROVIDER_SYNC_QUEUED.swap(true, Ordering::AcqRel) {
return;
}
AsyncHandler::spawn(move || async move {
tokio::time::sleep(delay).await;
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let _ = retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add((RUNTIME_PROVIDER_SYNC_ATTEMPTS - 1) as usize),
constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY,
),
|index| async move {
let attempt = index as u32 + 1;
let outcome = {
let _serial = PROVIDER_SYNC_SERIAL.lock().await;
if attempt == 1 {
PROVIDER_SYNC_QUEUED.store(false, Ordering::Release);
}
sync_runtime_providers_by_service().await
};
match outcome {
Ok(ProviderSync { pending: 0, .. }) => Ok(()),
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
logging!(
info,
Type::Service,
"{pending} provider caches are not ready yet; retrying"
);
Err(RetryError::Retry(()))
}
Ok(ProviderSync { pending, .. }) => {
logging!(warn, Type::Service, "{pending} provider caches were not synced");
Ok(())
}
Err(error) => {
logging!(
warn,
Type::Service,
"failed to sync provider caches from the service: {error:#}"
);
Ok(())
}
}
},
)
.await;
});
}
#[derive(Debug, Default)]
struct ProviderSync {
synced: usize,
pending: usize,
}
struct FetchedCache {
declared: RemoteProviderRef,
temp: PathBuf,
len: u64,
mtime_ns: Option<u64>,
}
/// Core load timestamps keyed by provider section and name.
type ProviderLoadTimes = HashMap<(&'static str, String), Option<u64>>;
#[tracing::instrument(level = "info", fields(synced = tracing::field::Empty, pending = tracing::field::Empty))]
async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
if !active_service_supports_runtime_file_read() {
return Ok(ProviderSync::default());
}
let credentials = current_owner_credentials()?;
let session = active_service_session()?;
let app_dir = dirs::app_home_dir()?;
let runtime = Config::runtime().await;
let mut outcome = ProviderSync::default();
let loaded_before = provider_load_times().await;
let mut fetched = Vec::new();
for declared in applied_remote_providers(&runtime, &app_dir)? {
let (temp, file) = match create_sync_temp(&app_dir.join(&declared.provider.destination)).await {
Ok(created) => created,
Err(error) => {
logging!(warn, Type::Service, "{error:#}");
outcome.pending += 1;
continue;
}
};
match fetch_runtime_file(&credentials, &session, &declared.provider.destination, file).await {
Ok(Fetch::Complete { len, mtime_ns }) => fetched.push(FetchedCache {
declared,
temp,
len,
mtime_ns,
}),
Ok(Fetch::NotReady) => {
outcome.pending += 1;
remove_temp(&temp).await;
}
Ok(Fetch::SessionLost) => {
remove_temp(&temp).await;
discard_fetched(fetched).await;
bail!("the service session ended while provider caches were being read");
}
Err(error) => {
logging!(
warn,
Type::Service,
"failed to read provider cache {} into {}: {error:#}",
declared.provider.destination,
temp.display()
);
outcome.pending += 1;
remove_temp(&temp).await;
}
}
}
let (loaded_before, loaded_after) = match (loaded_before, provider_load_times().await) {
(Ok(before), Ok(after)) => (before, after),
(Err(error), _) | (_, Err(error)) => {
logging!(warn, Type::Service, "provider caches were not synced: {error:#}");
outcome.pending += fetched.len();
discard_fetched(fetched).await;
return Ok(outcome);
}
};
let mut ready = Vec::new();
for cache in fetched {
let key = (cache.declared.section, cache.declared.name.clone());
let updated_at = loaded_after.get(&key).copied().flatten();
let complete = loaded_before.get(&key) == loaded_after.get(&key)
&& finished_loading(cache.mtime_ns, updated_at)
&& match recheck_identity(&credentials, &session, &cache).await {
Ok(Fetch::Complete { .. }) => true,
Ok(Fetch::NotReady) | Err(_) => false,
Ok(Fetch::SessionLost) => {
remove_temp(&cache.temp).await;
discard_fetched(ready).await;
bail!("the service session ended while provider caches were being verified");
}
};
if complete {
ready.push(cache);
} else {
outcome.pending += 1;
remove_temp(&cache.temp).await;
}
}
outcome.synced = publish_fetched(&app_dir, &runtime, &session, ready).await?;
tracing::Span::current().record("synced", outcome.synced);
tracing::Span::current().record("pending", outcome.pending);
Ok(outcome)
}
// The on-disk YAML may describe a rejected apply; use the committed runtime.
fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> {
let applied = runtime.data_arc();
match applied.config.as_ref() {
Some(config) => remote_providers_of(config, app_dir),
None => Ok(Vec::new()),
}
}
// Hold the lifecycle lock through publication to exclude mode and config switches.
async fn publish_fetched(
app_dir: &Path,
runtime: &Draft<IRuntime>,
session: &OwnerSessionProof,
fetched: Vec<FetchedCache>,
) -> Result<usize> {
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
let unchanged = matches!(*manager.get_running_mode(), RunningMode::Service)
&& active_service_session().is_ok_and(|current| current == *session);
let declared = if unchanged {
match applied_remote_providers(runtime, app_dir) {
Ok(declared) => declared,
Err(error) => {
discard_fetched(fetched).await;
return Err(error);
}
}
} else {
Vec::new()
};
let mut synced = 0;
for cache in fetched {
let target = app_dir.join(&cache.declared.provider.destination);
if !declared.contains(&cache.declared) || same_contents(&cache.temp, &target).await {
remove_temp(&cache.temp).await;
continue;
}
match tokio::fs::rename(&cache.temp, &target).await {
Ok(()) => synced += 1,
Err(error) => {
logging!(
warn,
Type::Service,
"failed to rename provider cache {} to {}: {error}",
cache.temp.display(),
target.display()
);
remove_temp(&cache.temp).await;
}
}
}
Ok(synced)
}
enum Fetch {
Complete { len: u64, mtime_ns: Option<u64> },
NotReady,
SessionLost,
}
// mihomo rewrites caches in place; reject changing or recently modified files.
async fn fetch_runtime_file(
credentials: &OwnerCredentials,
session: &OwnerSessionProof,
destination: &str,
mut file: tokio::fs::File,
) -> Result<Fetch> {
use tokio::io::AsyncWriteExt as _;
let mut written = 0_u64;
let mut identity = None;
loop {
let (hex, len, mtime_ns) = match read_chunk(credentials, session, destination, written).await? {
Some(chunk) => chunk,
None => return Ok(Fetch::SessionLost),
};
let Some((hex, len, mtime_ns)) = hex.map(|hex| (hex, len, mtime_ns)) else {
return Ok(Fetch::NotReady);
};
if len == 0 || !has_settled(mtime_ns) || *identity.get_or_insert((len, mtime_ns)) != (len, mtime_ns) {
return Ok(Fetch::NotReady);
}
if written == len {
file.flush().await?;
return Ok(Fetch::Complete { len, mtime_ns });
}
let chunk = decode_hex(&hex).context("服务返回了无效的运行时文件")?;
if chunk.is_empty() {
bail!("the service returned an empty chunk before the end of the file");
}
file.write_all(&chunk).await?;
written += chunk.len() as u64;
if written > len {
return Ok(Fetch::NotReady);
}
}
}
async fn recheck_identity(
credentials: &OwnerCredentials,
session: &OwnerSessionProof,
cache: &FetchedCache,
) -> Result<Fetch> {
let destination = &cache.declared.provider.destination;
Ok(match read_chunk(credentials, session, destination, cache.len).await? {
None => Fetch::SessionLost,
Some((Some(hex), len, mtime_ns)) if hex.is_empty() && (len, mtime_ns) == (cache.len, cache.mtime_ns) => {
Fetch::Complete { len, mtime_ns }
}
Some(_) => Fetch::NotReady,
})
}
/// Outer `None` means session loss; missing hex means an absent file.
async fn read_chunk(
credentials: &OwnerCredentials,
session: &OwnerSessionProof,
destination: &str,
offset: u64,
) -> Result<Option<(Option<String>, u64, Option<u64>)>> {
let request = RuntimeFileRequest {
destination: destination.to_owned(),
offset,
};
let response = clash_verge_service_ipc::read_runtime_file(credentials, session, &request)
.await
.context("无法连接到Clash Verge Service")?;
if response.code == ServiceErrorCode::NotActive as u16
|| response.code == ServiceErrorCode::StaleOwnerSession as u16
{
return Ok(None);
}
if response.code > 0 {
bail!(response.message);
}
Ok(Some(match response.data.context("服务未返回运行时文件")? {
RuntimeFileOutcome::Absent => (None, 0, None),
RuntimeFileOutcome::Chunk { hex, len, mtime_ns } => (Some(hex), len, mtime_ns),
}))
}
// updatedAt changes after cache writes; compare it before and after readback.
async fn provider_load_times() -> Result<ProviderLoadTimes> {
let mihomo = Handle::mihomo();
let (rules, proxies) = tokio::join!(mihomo.get_rule_providers(), mihomo.get_proxy_providers());
let rules = rules.map_err(|error| anyhow!("failed to query rule providers: {error}"))?;
let proxies = proxies.map_err(|error| anyhow!("failed to query proxy providers: {error}"))?;
let mut loaded = ProviderLoadTimes::new();
for (name, provider) in rules.providers {
loaded.insert(("rule-providers", name), epoch_nanos(&provider.updated_at));
}
for (name, provider) in proxies.providers {
loaded.insert(
("proxy-providers", name),
provider.updated_at.as_deref().and_then(epoch_nanos),
);
}
Ok(loaded)
}
fn epoch_nanos(rfc3339: &str) -> Option<u64> {
let parsed = chrono::DateTime::parse_from_rfc3339(rfc3339).ok()?;
u64::try_from(parsed.timestamp_nanos_opt()?).ok()
}
// Allow timestamp granularity; missing load times fall back to settling.
// Windows may retain the old mtime until a writer closes its handle.
fn finished_loading(mtime_ns: Option<u64>, updated_at_ns: Option<u64>) -> bool {
match (mtime_ns, updated_at_ns) {
(Some(mtime), Some(updated_at)) => {
u128::from(mtime) <= u128::from(updated_at) + constants::timing::RUNTIME_PROVIDER_SETTLE.as_nanos()
}
_ => true,
}
}
fn has_settled(mtime_ns: Option<u64>) -> bool {
let Some(mtime_ns) = mtime_ns else {
return true;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since_epoch| since_epoch.as_nanos());
now.saturating_sub(u128::from(mtime_ns)) >= constants::timing::RUNTIME_PROVIDER_SETTLE.as_nanos()
}
// Exclusive creation protects existing files from truncation and cleanup.
async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("failed to create provider cache directory {}", parent.display()))?;
}
let name = target
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
use crate::utils::retry::{RetryError, RetryPolicy, retry};
retry(
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(7), std::time::Duration::ZERO),
|_| async {
let sequence = SYNC_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let temp = target.with_file_name(format!(".{name}.sync-{}-{sequence}.tmp", std::process::id()));
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)
.await
{
Ok(file) => Ok((temp, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(RetryError::Retry(
anyhow::anyhow!("no free temporary name beside {}", target.display()),
)),
Err(error) => Err(RetryError::Stop(
anyhow::anyhow!(error).context(format!("failed to create {}", temp.display())),
)),
}
},
)
.await
}
async fn same_contents(temp: &Path, target: &Path) -> bool {
use tokio::io::AsyncReadExt as _;
let (Ok(mut left), Ok(mut right)) = (tokio::fs::File::open(temp).await, tokio::fs::File::open(target).await) else {
return false;
};
let (Ok(left_meta), Ok(right_meta)) = (left.metadata().await, right.metadata().await) else {
return false;
};
if left_meta.len() != right_meta.len() {
return false;
}
let mut remaining = left_meta.len();
let mut left_buf = vec![0_u8; CONTENT_COMPARE_CHUNK];
let mut right_buf = vec![0_u8; CONTENT_COMPARE_CHUNK];
while remaining > 0 {
let take = usize::try_from(remaining).map_or(CONTENT_COMPARE_CHUNK, |rest| rest.min(CONTENT_COMPARE_CHUNK));
if left.read_exact(&mut left_buf[..take]).await.is_err()
|| right.read_exact(&mut right_buf[..take]).await.is_err()
{
return false;
}
if left_buf[..take] != right_buf[..take] {
return false;
}
remaining -= take as u64;
}
true
}
async fn remove_temp(temp: &Path) {
let _ = tokio::fs::remove_file(temp).await;
}
async fn discard_fetched(fetched: Vec<FetchedCache>) {
for cache in fetched {
remove_temp(&cache.temp).await;
}
}
/// 通过服务停止core
#[tracing::instrument(skip_all, level = "info", fields(code = tracing::field::Empty, outcome = tracing::field::Empty))]
pub(super) async fn stop_core_by_service() -> Result<()> {
cancel_owner_monitors();
let credentials = match current_owner_credentials() {
Ok(credentials) => credentials,
Err(error) => {
start_owner_monitor();
return Err(error);
}
};
let session = match active_service_session() {
Ok(session) => session,
Err(error) => {
start_owner_monitor();
return Err(error);
}
};
let response = match clash_verge_service_ipc::stop_clash(&credentials, &session).await {
Ok(response) => response,
Err(error) => {
start_owner_monitor();
return Err(error).context("无法连接到Clash Verge Service");
}
};
if response.code > 0 {
if matches!(
response.code,
code if code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16
|| code == clash_verge_service_ipc::ServiceErrorCode::StaleOwnerSession as u16
) {
recover_after_owner_loss_while_locked(OwnerRecoveryReason::Displaced).await;
} else {
start_owner_monitor();
}
let err_msg = response.message;
tracing::Span::current().record("code", response.code);
tracing::Span::current().record("outcome", "refused");
logging!(
error,
Type::Service,
"停止核心失败 (code {}): {}",
response.code,
err_msg
);
bail!(err_msg);
}
clear_active_service_session();
tracing::Span::current().record("outcome", "stopped");
logging!(info, Type::Service, "服务成功停止核心");
Ok(())
}
pub(crate) async fn update_writer_by_service(writer: &WriterConfig) -> Result<()> {
let credentials = current_owner_credentials()?;
let session = active_service_session()?;
let response = clash_verge_service_ipc::update_writer(&credentials, &session, writer)
.await
.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
logging!(
warn,
Type::Service,
"update writer rejected by service: code={}, {}",
response.code,
response.message
);
bail!(response.message);
}
Ok(())
}
pub(super) async fn set_system_proxy_by_service(proxy: &MacosProxyConfig) -> Result<ProxyApplyOutcome> {
let session = active_service_session()?;
set_system_proxy_by_service_with_session(proxy, &session).await
}
pub(super) async fn set_system_proxy_by_service_with_session(
proxy: &MacosProxyConfig,
session: &OwnerSessionProof,
) -> Result<ProxyApplyOutcome> {
let credentials = current_owner_credentials()?;
let response = clash_verge_service_ipc::set_system_proxy(&credentials, session, proxy)
.await
.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
logging!(
warn,
Type::Service,
"set system proxy rejected by service: code={}, {}",
response.code,
response.message
);
bail!(response.message);
}
response.data.context("Clash Verge Service 未返回系统代理结果")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct OwnerRecoveryPolicy {
reset_system_proxy: bool,
}
/// The macOS proxy is machine-wide and only the helper may write it, for the session it still
/// honours; a displaced or unreachable owner must leave it alone.
const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
OwnerRecoveryPolicy {
reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure),
}
}
fn mark_service_unavailable_after_owner_loss<E: RunStateEnv>(store: &RunStateStore<E>, reason: OwnerRecoveryReason) {
if matches!(reason, OwnerRecoveryReason::TransportFailure) {
store.observe(ServiceHealth::Unavailable(
"service control IPC unavailable after sustained transport failure".to_owned(),
));
}
}
/// How often the owner monitor samples Service status.
const OWNER_MONITOR_INTERVAL: Duration = Duration::from_secs(5);
/// Mirrors `OwnerWatch`'s tolerance, for the log line only.
const SUSTAINED_OWNER_SAMPLES: u8 = 3;
fn start_owner_monitor() {
let generation = OWNER_MONITOR_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
AsyncHandler::spawn(move || async move {
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
let mut watch = OwnerWatch::new();
let mut core_restarts = None;
loop {
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
logging!(
debug,
Type::Service,
"owner monitor superseded (generation {generation})"
);
break;
}
if !matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
logging!(
debug,
Type::Service,
"owner monitor stopped; core no longer in service mode (generation {generation})"
);
break;
}
let (sample, status) = read_owner_sample().await;
if let Some(status) = &status {
log_core_restarts(&mut core_restarts, status);
}
let mut step = watch.observe(sample);
if matches!(step, OwnerStep::VerifyTransport) {
if watch.just_became_sustained() {
logging!(
warn,
Type::Service,
"service owner status unavailable for {SUSTAINED_OWNER_SAMPLES} samples (generation {generation}); \
preserving local proxy state while the core endpoint still answers"
);
}
let owner_endpoint_available = Handle::mihomo().get_version().await.is_ok();
step = watch.resolve_transport(owner_endpoint_available);
}
if let OwnerStep::Recover(reason) = step {
recover_after_owner_loss(generation, reason, status.as_ref()).await;
break;
}
}
});
}
/// The Service logs its watchdog restarts where the app cannot read them.
fn log_core_restarts(seen: &mut Option<u32>, status: &ServiceStatusSnapshot) {
if seen.is_some_and(|seen| status.restart_count > seen) {
logging!(
warn,
Type::Service,
"service restarted the core ({} restarts so far); last exit: {}",
status.restart_count,
status.last_core_exit_reason.as_deref().unwrap_or("unknown")
);
}
*seen = Some(status.restart_count);
}
fn report_service_core_stopped(status: &ServiceStatusSnapshot) {
let detail = format!(
"service state {:?}, {} restarts, last exit: {}",
status.service_state,
status.restart_count,
status.last_core_exit_reason.as_deref().unwrap_or("none reported")
);
logging!(error, Type::Service, "service core stopped: {detail}");
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
Handle::notice(NoticeStatus::CoreStartError, "");
}
/// Samples ownership, treating every unusable reply as unreadable.
async fn read_owner_sample() -> (OwnerSample, Option<ServiceStatusSnapshot>) {
let response = match current_owner_credentials() {
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
Err(error) => Err(error),
};
let response = match response {
Ok(response) => response,
Err(error) => {
logging!(debug, Type::Service, "service owner status was unreadable: {error:#}");
return (OwnerSample::Unreadable, None);
}
};
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
return (OwnerSample::NotActive, None);
}
if response.code != 0 {
logging!(
debug,
Type::Service,
"service owner status returned error {}: {}",
response.code,
response.message
);
return (OwnerSample::Unreadable, None);
}
let Some(status) = response.data else {
logging!(debug, Type::Service, "service owner status omitted data");
return (OwnerSample::Unreadable, None);
};
// A session that no longer matches is another owner's, whatever the flags say.
if !session_matches_active_status(status.is_active, status.active_generation) {
return (OwnerSample::NotActive, None);
}
let sample = OwnerSample::Status {
is_active: status.is_active,
desired_core_should_be_running: status.desired_core_should_be_running,
service_state: status.service_state,
core_pid: status.core_pid,
};
(sample, Some(status))
}
fn session_matches_active_status(is_active: bool, active_generation: Option<u64>) -> bool {
ACTIVE_SERVICE_SESSION
.lock()
.as_ref()
.is_some_and(|session| session_matches_status(&session.proof, is_active, active_generation))
}
fn cancel_owner_monitors() {
OWNER_MONITOR_GENERATION.fetch_add(1, Ordering::AcqRel);
}
pub(crate) fn owner_monitor_generation() -> u64 {
OWNER_MONITOR_GENERATION.load(Ordering::Acquire)
}
/// `status` is the sample that led to the recovery, if any; a failed core is reported from it.
async fn recover_after_owner_loss(
generation: u64,
reason: OwnerRecoveryReason,
status: Option<&ServiceStatusSnapshot>,
) {
let manager = CoreManager::global();
if !matches!(*manager.get_running_mode(), RunningMode::Service) {
return;
}
let Some(recovery_generation) = claim_owner_recovery_generation(&OWNER_MONITOR_GENERATION, generation) else {
return;
};
manager.invalidate_core_readiness();
let _lifecycle = manager.lifecycle_lock.lock().await;
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != recovery_generation
|| !matches!(*manager.get_running_mode(), RunningMode::Service)
{
return;
}
recover_after_owner_loss_while_locked(reason).await;
// Still under the lifecycle lock, so a later start is the one that clears it.
if let (OwnerRecoveryReason::SameOwnerFailure, Some(status)) = (reason, status) {
report_service_core_stopped(status);
}
}
fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option<u64> {
let recovery_generation = captured_generation.wrapping_add(1);
generation
.compare_exchange(
captured_generation,
recovery_generation,
Ordering::AcqRel,
Ordering::Acquire,
)
.ok()
.map(|_| recovery_generation)
}
#[tracing::instrument(skip_all, level = "info", fields(reason = ?reason))]
async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
logging!(
warn,
Type::Service,
"service owner recovery ({reason:?}); clearing local proxy and PAC state"
);
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
proxy_control::stop_guard().await;
let policy = owner_recovery_policy(reason, cfg!(target_os = "macos"));
// Clear while still in Service mode with the session: on macOS it routes through the helper.
if policy.reset_system_proxy {
clear_proxy_after_owner_loss().await;
}
clear_active_service_session();
CoreManager::global().core_stopped();
// A displaced Service may still run TUN for its new owner, and DNS settings are system-wide.
#[cfg(target_os = "macos")]
if policy.reset_system_proxy {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
async fn clear_proxy_after_owner_loss() {
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let result = retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(2),
Duration::from_millis(100),
),
|index| async move {
let attempt = index + 1;
proxy_control::clear().await.map_err(|error| {
logging!(
warn,
Type::Service,
"proxy clear attempt {attempt}/3 after owner loss failed: {error:#}"
);
RetryError::Retry(error)
})
},
)
.await;
if let Err(error) = result {
// The existing recovery path also waits after its final failed clear.
tokio::time::sleep(Duration::from_millis(100)).await;
logging!(
error,
Type::Service,
"failed to clear local proxy after owner loss: {error:#}"
);
}
}
/// Waits for a repaired service, preserving readable rejection details but classifying sustained
/// silence as unavailable.
#[tracing::instrument(skip_all, level = "info", fields(attempts = tracing::field::Empty, interval_ms = tracing::field::Empty, outcome = tracing::field::Empty))]
async fn wait_for_service_ipc() -> Result<()> {
const CONTEXT: &str = "service IPC did not become available";
const READY_ATTEMPTS: usize = 61;
const READY_INTERVAL: Duration = Duration::from_millis(500);
let span = tracing::Span::current();
span.record("attempts", READY_ATTEMPTS);
span.record("interval_ms", READY_INTERVAL.as_millis() as u64);
match RUN_STATE.await_ready(READY_ATTEMPTS, READY_INTERVAL).await {
Ok(_) => {
tracing::Span::current().record("outcome", "ready");
Ok(())
}
Err(ReadyWaitError::Unreachable(error)) => {
tracing::Span::current().record("outcome", "unreachable");
RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}")));
Err(error).context(CONTEXT)
}
Err(ReadyWaitError::Rejected(error)) => {
tracing::Span::current().record("outcome", "rejected");
Err(error).context(CONTEXT)
}
}
}
impl ServiceManager {
pub const fn config() -> clash_verge_service_ipc::IpcConfig {
clash_verge_service_ipc::IpcConfig {
default_timeout: Duration::from_millis(1000),
retry_delay: Duration::from_millis(500),
max_retries: 20,
}
}
pub async fn confirm_ready(&self) -> Result<()> {
RUN_STATE.probe().await.map(|_| ())
}
pub async fn current(&self) -> ServiceStatus {
ServiceStatus::from_run_state(&RUN_STATE.settled().await)
}
pub fn allow_sidecar_for_session(&self) -> Result<()> {
RUN_STATE.allow_sidecar_for_session()
}
pub fn require_install_for_session(&self) -> Result<()> {
RUN_STATE.require_install_for_session()
}
pub(crate) fn withdraw_sidecar_allowance(&self) -> bool {
RUN_STATE.withdraw_sidecar_allowance()
}
pub async fn detect_startup_status(&self) {
if cfg!(feature = "dev-sidecar") {
RUN_STATE.accept_sidecar();
return;
}
RUN_STATE.observe_current_health().await;
}
fn set_status(&self, status: ServiceStatus) {
record_status(&RUN_STATE, status);
}
async fn run_operation(&self, operation: impl Future<Output = Result<()>>) -> Result<()> {
run_operation_and_then(&RUN_STATE, operation, || async {
if let Err(error) = Tray::global().update_menu().await {
logging!(
warn,
Type::Service,
"failed to refresh tray after service operation: {error:#}"
);
}
Ok(())
})
.await
}
pub async fn refresh(&self) -> Result<()> {
self.run_operation(async { self.confirm_ready().await }).await
}
pub async fn handle_service_status(&self, status: ServiceStatus) -> Result<()> {
// Box the large operation future once instead of carrying it in every calling command.
self.run_operation(Box::pin(self.apply_service_status(status))).await
}
async fn apply_service_status(&self, status: ServiceStatus) -> Result<()> {
// Use the caller's action; a racing observation may clear the stored pending action.
let Some(action) = requested_action(&status) else {
self.set_status(status.clone());
return report_non_actionable_status(status);
};
// Atomically record the request and capture the Sidecar allowance it clears.
let sidecar_allowed_before = RUN_STATE.request_action(action);
logging!(info, Type::Service, "running privileged service action {action:?}");
run_action_restoring_sidecar(&RUN_STATE, sidecar_allowed_before, async move {
RUN_STATE.perform(action).await?;
if !matches!(action, PendingAction::Uninstall) {
wait_for_service_ipc().await?;
}
Ok(())
})
.await
}
}
/// Runs through readiness and restores a displaced Sidecar allowance on failure.
async fn run_action_restoring_sidecar<E: RunStateEnv>(
store: &RunStateStore<E>,
was_allowed: bool,
action: impl Future<Output = Result<()>>,
) -> Result<()> {
let outcome = action.await;
if outcome.is_err() && was_allowed && store.restore_sidecar_allowance() {
logging!(
info,
Type::Service,
"restored the Sidecar this session had already settled on"
);
}
outcome
}
/// Explain a status that asks for no privileged action, refusing the ones we cannot act on.
fn report_non_actionable_status(status: ServiceStatus) -> Result<()> {
match status {
ServiceStatus::Checking => bail!("service status is still being checked"),
ServiceStatus::Ready => logging!(info, Type::Service, "服务就绪,直接启动"),
ServiceStatus::NotInstalled => {
logging!(info, Type::Service, "service is not installed; Sidecar is available");
}
ServiceStatus::NeedsReinstall => {
bail!("service needs reinstall; explicit authorization is required");
}
ServiceStatus::Unavailable(reason) => {
logging!(info, Type::Service, "服务不可用: {},将使用Sidecar模式", reason);
bail!("服务不可用: {}", reason);
}
ServiceStatus::SidecarAllowed => {
logging!(
info,
Type::Service,
"Sidecar was explicitly allowed for this app session"
);
}
ServiceStatus::InstallRequired
| ServiceStatus::UninstallRequired
| ServiceStatus::ReinstallRequired
| ServiceStatus::ForceReinstallRequired => {
bail!("a requested action should have been handled as a privileged operation")
}
}
Ok(())
}
/// Releases the Run State operation slot before post-operation observers refresh.
async fn run_operation_and_then<E, Post, PostFuture>(
store: &RunStateStore<E>,
operation: impl Future<Output = Result<()>>,
post_operation: Post,
) -> Result<()>
where
E: RunStateEnv,
Post: FnOnce() -> PostFuture,
PostFuture: Future<Output = Result<()>>,
{
let result = {
let _operation = store.begin_operation()?;
operation.await
};
result?;
post_operation().await
}
/// Maps a legacy status to its requested action without racing a store reread.
const fn requested_action(status: &ServiceStatus) -> Option<PendingAction> {
match status {
ServiceStatus::InstallRequired => Some(PendingAction::Install),
ServiceStatus::UninstallRequired => Some(PendingAction::Uninstall),
ServiceStatus::ReinstallRequired => Some(PendingAction::Reinstall),
ServiceStatus::ForceReinstallRequired => Some(PendingAction::ForceReinstall),
ServiceStatus::Checking
| ServiceStatus::Ready
| ServiceStatus::NotInstalled
| ServiceStatus::NeedsReinstall
| ServiceStatus::SidecarAllowed
| ServiceStatus::Unavailable(_) => None,
}
}
fn record_status<E: RunStateEnv>(store: &RunStateStore<E>, status: ServiceStatus) {
if let Some(action) = requested_action(&status) {
store.request_action(action);
return;
}
match status {
ServiceStatus::SidecarAllowed => store.accept_sidecar(),
ServiceStatus::Checking => store.observe(ServiceHealth::Unknown),
ServiceStatus::Ready => store.observe(ServiceHealth::Ready),
ServiceStatus::NotInstalled => store.observe(ServiceHealth::NotInstalled),
ServiceStatus::NeedsReinstall => store.observe(ServiceHealth::VersionMismatch),
ServiceStatus::Unavailable(reason) => store.observe(ServiceHealth::Unavailable(reason)),
ServiceStatus::InstallRequired
| ServiceStatus::UninstallRequired
| ServiceStatus::ReinstallRequired
| ServiceStatus::ForceReinstallRequired => {
// Recorded by the early return above; listed so a new variant still fails to
// compile here rather than falling through a catch-all.
}
}
}
pub static SERVICE_MANAGER: ServiceManager = ServiceManager;
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
use super::{
ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation,
generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy,
service_core_path_for, session_matches_status,
};
#[cfg(unix)]
use super::{service_core_path_for_with_publisher, service_tool_path_for};
use crate::core::runstate::{FakeEnv, OwnerRecoveryReason, PendingAction, RunStateStore};
use anyhow::bail;
use clash_verge_service_ipc::OwnerSessionProof;
#[cfg(unix)]
use std::cell::Cell;
use std::{
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
/// A Run State backed by a scripted environment, so these tests never touch the global.
fn fake_store() -> RunStateStore<FakeEnv> {
RunStateStore::new(FakeEnv::new())
}
/// The legacy single-slot view of a store, for assertions carried over from before the split.
fn status_of(store: &RunStateStore<FakeEnv>) -> ServiceStatus {
ServiceStatus::from_run_state(&store.state())
}
async fn status_of_settled(store: &RunStateStore<FakeEnv>) -> ServiceStatus {
ServiceStatus::from_run_state(&store.settled().await)
}
static TEST_DIRECTORY_GENERATION: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new(label: &str) -> anyhow::Result<Self> {
let generation = TEST_DIRECTORY_GENERATION.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"clash-verge-rev-service-{label}-{}-{generation}",
std::process::id()
));
std::fs::create_dir(&path)?;
Ok(Self(path))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn staging_directory(home: &Path) -> PathBuf {
home.join("Applications/.clash-verge-rev-dev/service-core")
}
#[cfg(unix)]
fn service_tools_staging_directory(home: &Path) -> PathBuf {
home.join("Applications/.clash-verge-rev-dev/service-tools")
}
#[cfg(unix)]
fn staging_temporary_entries(home: &Path, core_name: &str) -> anyhow::Result<Vec<PathBuf>> {
let directory = staging_directory(home);
if !directory.exists() {
return Ok(Vec::new());
}
Ok(std::fs::read_dir(directory)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(&format!(".{core_name}.")) && name.ends_with(".tmp"))
})
.collect())
}
#[test]
fn nondevelopment_service_core_selection_preserves_sibling_without_staging() -> anyhow::Result<()> {
let root = TestDirectory::new("release-path")?;
let home = root.path().join("home");
let source = root.path().join("target/debug/verge-mihomo");
let selected = service_core_path_for(&source, Some(&home), false)?;
assert_eq!(selected, source);
assert!(!staging_directory(&home).exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn development_service_core_uses_exact_layout_and_executable_bytes() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let root = TestDirectory::new("development-path")?;
let home = root.path().join("home");
let source = root.path().join("verge-mihomo");
std::fs::write(&source, b"development core")?;
let selected = service_core_path_for(&source, Some(&home), true)?;
assert_eq!(
selected,
home.join("Applications/.clash-verge-rev-dev/service-core/verge-mihomo")
);
assert_eq!(std::fs::read(&selected)?, b"development core");
let metadata = std::fs::symlink_metadata(&selected)?;
assert!(metadata.file_type().is_file());
assert_ne!(metadata.permissions().mode() & 0o111, 0);
Ok(())
}
#[cfg(unix)]
#[test]
fn development_service_tool_uses_safe_layout_and_executable_bytes() -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let root = TestDirectory::new("development-service-tool")?;
let home = root.path().join("home");
let source = root.path().join("clash-verge-service-install");
std::fs::write(&source, b"development installer")?;
let selected = service_tool_path_for(&source, Some(&home), true)?;
assert_eq!(
selected,
service_tools_staging_directory(&home).join("clash-verge-service-install")
);
assert_eq!(std::fs::read(&selected)?, b"development installer");
assert_ne!(std::fs::metadata(&selected)?.permissions().mode() & 0o111, 0);
Ok(())
}
#[cfg(unix)]
#[test]
fn development_service_core_refresh_atomically_replaces_bytes() -> anyhow::Result<()> {
let root = TestDirectory::new("refresh")?;
let home = root.path().join("home");
let source = root.path().join("verge-mihomo");
std::fs::write(&source, b"first core")?;
let selected = service_core_path_for(&source, Some(&home), true)?;
assert_eq!(
selected,
home.join("Applications/.clash-verge-rev-dev/service-core/verge-mihomo")
);
std::fs::write(&source, b"second core")?;
let refreshed = service_core_path_for(&source, Some(&home), true)?;
assert_eq!(refreshed, selected);
assert_eq!(std::fs::read(&refreshed)?, b"second core");
assert!(staging_temporary_entries(&home, "verge-mihomo")?.is_empty());
Ok(())
}
#[cfg(unix)]
#[test]
fn failed_development_refresh_preserves_good_core_and_cleans_temporary_entry() -> anyhow::Result<()> {
let root = TestDirectory::new("failed-refresh")?;
let home = root.path().join("home");
let source = root.path().join("verge-mihomo");
std::fs::write(&source, b"known good core")?;
let selected = service_core_path_for(&source, Some(&home), true)?;
std::fs::write(&source, b"replacement core")?;
let publish_attempted = Cell::new(false);
let result = service_core_path_for_with_publisher(
&source,
Some(&home),
true,
"service-core",
|temporary, final_path| {
publish_attempted.set(true);
assert_ne!(temporary, final_path, "publisher must receive the temporary path");
assert!(std::fs::symlink_metadata(temporary)?.file_type().is_file());
assert_eq!(std::fs::read(temporary)?, b"replacement core");
anyhow::bail!("injected post-creation publish failure")
},
);
let error = match result {
Ok(path) => anyhow::bail!("failed publication selected {}", path.display()),
Err(error) => error.to_string(),
};
assert!(publish_attempted.get());
assert!(error.contains("injected post-creation publish failure"));
assert_eq!(std::fs::read(&selected)?, b"known good core");
assert!(staging_temporary_entries(&home, "verge-mihomo")?.is_empty());
Ok(())
}
#[cfg(unix)]
#[test]
fn development_service_core_replaces_final_symlink_without_following_it() -> anyhow::Result<()> {
use std::os::unix::fs::symlink;
let root = TestDirectory::new("symlink")?;
let home = root.path().join("home");
let source = root.path().join("verge-mihomo");
std::fs::write(&source, b"selected core")?;
let final_path = home.join("Applications/.clash-verge-rev-dev/service-core/verge-mihomo");
std::fs::create_dir_all(final_path.parent().unwrap_or_else(|| Path::new(".")))?;
let symlink_target = root.path().join("must-not-change");
std::fs::write(&symlink_target, b"target bytes")?;
symlink(&symlink_target, &final_path)?;
let selected = service_core_path_for(&source, Some(&home), true)?;
assert_eq!(selected, final_path);
assert!(std::fs::symlink_metadata(&selected)?.file_type().is_file());
assert_eq!(std::fs::read(&selected)?, b"selected core");
assert_eq!(std::fs::read(&symlink_target)?, b"target bytes");
Ok(())
}
#[test]
fn mismatched_active_generation_displaces_local_session() {
let proof = OwnerSessionProof {
generation: 7,
token: "11".repeat(32),
};
assert!(session_matches_status(&proof, true, Some(7)));
assert!(!session_matches_status(&proof, true, Some(8)));
assert!(!session_matches_status(&proof, false, Some(7)));
}
#[test]
fn a_stale_monitor_cannot_displace_a_newer_session() {
// Sample classification now lives in `core::runstate::owner`; what stays here is the
// guard that stops a monitor from a previous Core from tearing down the current one.
let generation = AtomicU64::new(8);
let newer_proof = OwnerSessionProof {
generation: 8,
token: "22".repeat(32),
};
let session = parking_lot::Mutex::new(Some(newer_proof.clone()));
// A monitor started at generation 7 decides it has been displaced and tries to recover.
if claim_owner_recovery_generation(&generation, 7).is_some() {
session.lock().take();
}
assert_eq!(generation.load(Ordering::Acquire), 8, "the newer generation stands");
assert_eq!(session.lock().as_ref(), Some(&newer_proof));
}
#[test]
fn generated_service_session_token_is_lower_hex() -> anyhow::Result<()> {
let token = generate_service_session_token()?;
assert_eq!(token.len(), 64);
assert!(
token
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
);
Ok(())
}
#[test]
fn macos_recovery_resets_machine_wide_proxy_only_for_its_own_failed_core() {
for reason in [
OwnerRecoveryReason::Displaced,
OwnerRecoveryReason::SameOwnerFailure,
OwnerRecoveryReason::TransportFailure,
] {
assert_eq!(
owner_recovery_policy(reason, true).reset_system_proxy,
reason == OwnerRecoveryReason::SameOwnerFailure
);
assert!(owner_recovery_policy(reason, false).reset_system_proxy);
}
let generation = AtomicU64::new(7);
assert_eq!(claim_owner_recovery_generation(&generation, 7), Some(8));
assert_eq!(generation.load(Ordering::Acquire), 8);
assert_eq!(claim_owner_recovery_generation(&generation, 7), None);
}
#[test]
fn cached_readiness_reflects_confirmed_state_without_mutating_it() {
let store = fake_store();
store.observe(ServiceHealth::Ready);
let generation = store.generation_count();
assert!(store.state().service_usable());
assert_eq!(status_of(&store), ServiceStatus::Ready);
assert_eq!(store.generation_count(), generation, "reading must not change state");
store.observe(ServiceHealth::NotInstalled);
assert!(!store.state().service_usable());
assert_eq!(status_of(&store), ServiceStatus::NotInstalled);
assert_eq!(store.generation_count(), generation + 1);
}
#[tokio::test]
async fn refused_core_start_can_continue_with_sidecar() -> anyhow::Result<()> {
use clash_verge_service_ipc::ServiceErrorCode;
for code in [
ServiceErrorCode::InvalidInstallLocation,
ServiceErrorCode::InvalidRuntimeAsset,
ServiceErrorCode::OwnerSwitchFailed,
] {
let store = fake_store();
store.observe(ServiceHealth::Ready);
let error = super::record_service_start_refusal(
&store,
super::ServiceStartRefusal {
code: code as u16,
core_path: "/development/service-core/verge-mihomo".into(),
message: "no administrator-approved copy is installed".into(),
},
);
assert!(error.downcast_ref::<super::ServiceStartRefusal>().is_some());
let state = store.settled().await;
assert_eq!(state.mode, crate::core::manager::RunningMode::NotRunning);
assert!(state.service_needs_attention());
assert!(!state.service_usable());
assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_)));
store.allow_sidecar_for_session()?;
assert_eq!(status_of(&store), ServiceStatus::SidecarAllowed);
assert!(store.state().tun_should_be_disabled(true));
}
Ok(())
}
#[test]
fn proxy_clear_refusal_does_not_ask_for_service_repair() {
let store = fake_store();
store.observe(ServiceHealth::Ready);
let _ = super::record_service_start_refusal(
&store,
super::ServiceStartRefusal {
code: clash_verge_service_ipc::ServiceErrorCode::ProxyClearFailed as u16,
core_path: "/development/service-core/verge-mihomo".into(),
message: "SystemConfiguration operation failed: lock preferences (status 3002)".into(),
},
);
assert!(store.state().service_usable());
assert!(!store.state().service_needs_attention());
}
#[test]
fn cached_readiness_is_false_while_a_service_operation_is_running() {
let store = fake_store();
store.observe(ServiceHealth::Ready);
let _operation = store.begin_operation().expect("slot should be free");
assert!(!store.state().service_usable());
// The confirmed observation survives — only usability is withheld.
assert_eq!(status_of(&store), ServiceStatus::Ready);
}
#[tokio::test]
async fn service_operation_finishes_before_post_operation_refresh() {
let store = fake_store();
let result = super::run_operation_and_then(
&store,
async {
store.observe(ServiceHealth::Ready);
Ok(())
},
|| async {
assert!(!store.operation_in_flight());
assert_eq!(status_of_settled(&store).await, ServiceStatus::Ready);
Ok(())
},
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn owner_generation_is_captured_before_async_request_runs() {
let generation = AtomicU64::new(7);
let (captured, response) = capture_generation_before(&generation, || async {
generation.store(8, Ordering::Release);
"not-active"
})
.await;
assert_eq!(captured, 7);
assert_eq!(response, "not-active");
assert_eq!(generation.load(Ordering::Acquire), 8);
}
#[test]
fn only_transport_owner_loss_marks_cached_readiness_unavailable() {
for reason in [OwnerRecoveryReason::Displaced, OwnerRecoveryReason::SameOwnerFailure] {
let store = fake_store();
store.observe(ServiceHealth::Ready);
let generation = store.generation_count();
mark_service_unavailable_after_owner_loss(&store, reason);
assert!(store.state().service_usable(), "{reason:?} must not affect readiness");
assert_eq!(status_of(&store), ServiceStatus::Ready);
assert_eq!(store.generation_count(), generation);
}
let store = fake_store();
store.observe(ServiceHealth::Ready);
mark_service_unavailable_after_owner_loss(&store, OwnerRecoveryReason::TransportFailure);
assert!(!store.state().service_usable());
assert!(matches!(
status_of(&store),
ServiceStatus::Unavailable(reason) if reason.contains("service control IPC unavailable")
));
}
#[cfg(target_os = "macos")]
#[test]
fn legacy_socket_alone_is_not_install_evidence() {
assert!(
!super::macos_service_install_markers()
.iter()
.any(|marker| marker == "/tmp/verge/clash-verge-service.sock")
);
}
#[test]
fn failed_install_status_can_be_replaced_with_sidecar_allowance() {
let store = fake_store();
store.request_action(super::PendingAction::Install);
assert_eq!(status_of(&store), ServiceStatus::InstallRequired);
let generation = store.generation_count();
assert!(store.allow_sidecar_for_session().is_ok());
assert_eq!(status_of(&store), ServiceStatus::SidecarAllowed);
assert_eq!(store.generation_count(), generation + 1);
}
#[test]
fn only_explicit_action_states_ask_for_a_privileged_operation() {
for status in [
ServiceStatus::Checking,
ServiceStatus::Ready,
ServiceStatus::NotInstalled,
ServiceStatus::NeedsReinstall,
ServiceStatus::SidecarAllowed,
ServiceStatus::Unavailable("offline".into()),
] {
let store = fake_store();
super::record_status(&store, status.clone());
assert_eq!(
store.state().pending,
None,
"{status:?} is an observation, not a request"
);
}
for (status, expected) in [
(ServiceStatus::InstallRequired, PendingAction::Install),
(ServiceStatus::UninstallRequired, PendingAction::Uninstall),
(ServiceStatus::ReinstallRequired, PendingAction::Reinstall),
(ServiceStatus::ForceReinstallRequired, PendingAction::ForceReinstall),
] {
let store = fake_store();
super::record_status(&store, status.clone());
assert_eq!(store.state().pending, Some(expected), "{status:?}");
}
}
#[tokio::test]
async fn a_failed_uninstall_asks_the_machine_rather_than_condemning_the_service() {
// A cancelled uninstall may leave the Service healthy; use the fresh probe result.
let store = RunStateStore::new(
FakeEnv::new()
.service_ready()
.privileged_operations_fail("no authorization"),
);
store.observe(ServiceHealth::Ready);
let error = store
.perform(PendingAction::Uninstall)
.await
.expect_err("an unauthorized uninstall should fail");
assert!(error.to_string().contains("no authorization"));
assert_eq!(status_of(&store), ServiceStatus::Ready);
assert!(!store.state().service_needs_attention());
}
#[tokio::test]
async fn a_failed_uninstall_still_reports_a_service_the_uninstaller_broke() {
// A failed uninstaller can still leave the Service registered but unreachable.
let store = RunStateStore::new(
FakeEnv::new()
.service_unreachable()
.privileged_operations_fail("uninstaller exited with 1"),
);
store.observe(ServiceHealth::Ready);
store
.perform(PendingAction::Uninstall)
.await
.expect_err("a broken uninstall should fail");
assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_)));
assert!(store.state().service_needs_attention());
}
#[tokio::test]
async fn a_successful_uninstall_records_an_absent_service() {
let store = fake_store();
store.observe(ServiceHealth::Ready);
store
.perform(PendingAction::Uninstall)
.await
.expect("uninstall should succeed");
assert_eq!(status_of(&store), ServiceStatus::NotInstalled);
assert_eq!(store.env().privileged_actions(), vec![PendingAction::Uninstall]);
}
#[tokio::test]
async fn a_cancelled_install_leaves_no_question_for_the_user() {
// A cancelled action must retire its request or the attention dialog reopens.
let store = RunStateStore::new(FakeEnv::new().privileged_operations_fail("User canceled. (-128)"));
store.observe(ServiceHealth::NotInstalled);
super::record_status(&store, ServiceStatus::InstallRequired);
store
.perform(PendingAction::Install)
.await
.expect_err("a cancelled install should fail");
assert_eq!(status_of(&store), ServiceStatus::NotInstalled);
assert!(
!store.state().service_needs_attention(),
"a service that is merely absent asks the user nothing"
);
}
#[tokio::test]
async fn a_failed_install_still_reports_a_service_that_really_is_broken() {
// A fresh probe must preserve a real fault left by a failed installer.
let store = RunStateStore::new(
FakeEnv::new()
.service_unreachable()
.privileged_operations_fail("installer exited with 1"),
);
super::record_status(&store, ServiceStatus::ForceReinstallRequired);
store
.perform(PendingAction::ForceReinstall)
.await
.expect_err("a broken repair should fail");
assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_)));
assert!(store.state().service_needs_attention());
}
/// Build a session where the user already accepted Sidecar for an unhealthy Service.
fn store_settled_on_sidecar(env: FakeEnv) -> RunStateStore<FakeEnv> {
let store = RunStateStore::new(env);
store.observe(ServiceHealth::VersionMismatch);
store.accept_sidecar();
assert!(!store.state().service_needs_attention(), "the question was answered");
store
}
#[tokio::test]
async fn a_cancelled_action_gives_back_the_sidecar_the_session_had_settled_on() {
// A failed action must restore the Sidecar decision displaced by its request.
let store = store_settled_on_sidecar(
FakeEnv::new()
.service_version_mismatch()
.privileged_operations_fail("User canceled. (-128)"),
);
// Recording the request returns the allowance it displaced.
let was_allowed = store.request_action(PendingAction::Install);
assert!(was_allowed, "the request displaced the session's answer");
assert!(!store.state().sidecar_allowed);
let outcome = super::run_action_restoring_sidecar(&store, was_allowed, async {
store.perform(PendingAction::Install).await
})
.await;
assert!(outcome.is_err(), "the failure is still reported to the caller");
assert!(store.state().sidecar_allowed);
assert!(!store.state().service_needs_attention());
}
#[tokio::test]
async fn an_authorised_action_that_never_became_ready_also_gives_the_sidecar_back() {
// Roll back the full workflow, including readiness failures after the action succeeds.
let store = store_settled_on_sidecar(FakeEnv::new().service_version_mismatch());
let was_allowed = store.request_action(PendingAction::Install);
let outcome = super::run_action_restoring_sidecar(&store, was_allowed, async {
store.perform(PendingAction::Install).await?;
// Simulate `wait_for_service_ipc` recording health before it fails.
store.observe(ServiceHealth::Unavailable("service never answered".to_owned()));
bail!("service IPC did not become available")
})
.await;
assert!(outcome.is_err());
assert!(store.state().sidecar_allowed);
assert!(!store.state().service_needs_attention());
}
#[tokio::test]
async fn an_action_that_lands_keeps_the_session_on_the_service() {
let store = RunStateStore::new(FakeEnv::new().service_ready());
store.observe(ServiceHealth::NotInstalled);
store.request_action(PendingAction::Install);
super::run_action_restoring_sidecar(&store, true, async {
store.perform(PendingAction::Install).await?;
store.observe(ServiceHealth::Ready);
Ok(())
})
.await
.expect("the install landed");
assert!(
!store.state().sidecar_allowed,
"no fallback is owed to a working Service"
);
assert_eq!(status_of(&store), ServiceStatus::Ready);
}
#[tokio::test]
async fn a_session_that_never_chose_sidecar_is_not_given_one() {
let store = RunStateStore::new(FakeEnv::new().service_version_mismatch());
store.observe(ServiceHealth::VersionMismatch);
super::run_action_restoring_sidecar(&store, false, async { bail!("refused") })
.await
.expect_err("the failure is reported");
assert!(!store.state().sidecar_allowed);
assert!(store.state().service_needs_attention());
}
#[test]
fn a_service_that_came_back_ready_is_never_shadowed_by_a_restored_sidecar() {
// The atomic ready check prevents Sidecar from shadowing a ready Service.
let store = RunStateStore::new(FakeEnv::new().service_ready());
store.observe(ServiceHealth::Ready);
assert!(!store.restore_sidecar_allowance());
assert!(!store.state().sidecar_allowed);
assert_eq!(status_of(&store), ServiceStatus::Ready);
}
}