mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-03 13:32:16 +08:00
Preserve TUN settings and restore proxy operation after fallback. Deliver fallback notices after UI subscription and stop retrying deterministic bundle refusals. Publish fallback state atomically and avoid conflicting service startup notifications.
2878 lines
103 KiB
Rust
2878 lines
103 KiB
Rust
use crate::utils::dirs;
|
|
use crate::{
|
|
config::{Config, runtime::IRuntime},
|
|
constants,
|
|
core::{
|
|
CoreManager,
|
|
handle::Handle,
|
|
manager::RunningMode,
|
|
owner_identity::current_owner_credentials,
|
|
proxy_control,
|
|
runstate::{
|
|
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, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, RuntimeBundle,
|
|
RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
|
};
|
|
use once_cell::sync::Lazy;
|
|
use parking_lot::Mutex;
|
|
use std::{
|
|
borrow::Cow,
|
|
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: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
|
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
|
|
|
|
#[cfg(target_os = "windows")]
|
|
pub(crate) fn notify_service_fallback() {
|
|
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
|
|
Handle::notice_message("service_core::sidecar_fallback", "");
|
|
}
|
|
|
|
pub(crate) fn take_service_fallback_notice() -> bool {
|
|
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
|
|
}
|
|
|
|
/// 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)]
|
|
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
|
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,
|
|
) {
|
|
Ok(service) => {
|
|
drop(service);
|
|
Ok(true)
|
|
}
|
|
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
|
Ok(false)
|
|
}
|
|
Err(error) => Err(error).context("failed to inspect Windows service registration"),
|
|
}
|
|
}
|
|
|
|
#[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)> {
|
|
for _ in 0..32 {
|
|
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) => return Ok((temporary_path, file)),
|
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
|
Err(error) => {
|
|
return Err(error).with_context(|| {
|
|
format!(
|
|
"failed to create temporary development Service core {}",
|
|
temporary_path.display()
|
|
)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
bail!(
|
|
"failed to create a unique temporary development Service core in {}",
|
|
directory.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)
|
|
}
|
|
|
|
/// 卸载服务前以 root 清理残留 core 和 IPC 套接字。
|
|
#[cfg(target_os = "macos")]
|
|
fn macos_force_stop_core_shell() -> String {
|
|
use crate::config::IVerge;
|
|
|
|
// 只清理 root 拥有的服务内核。
|
|
let mut parts: Vec<String> = IVerge::VALID_CLASH_CORES
|
|
.iter()
|
|
.map(|core| format!("/usr/bin/pkill -U root -x {core} 2>/dev/null || true"))
|
|
.collect();
|
|
|
|
if let Ok(ipc) = dirs::ipc_path()
|
|
&& let Ok(ipc_str) = dirs::path_to_str(&ipc)
|
|
{
|
|
// 转义单引号,避免破坏 shell 参数。
|
|
let escaped = ipc_str.replace('\'', r"'\''");
|
|
parts.push(format!("/bin/rm -f '{escaped}' 2>/dev/null || true"));
|
|
}
|
|
|
|
parts.join("; ")
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn escape_osascript_double_quoted_string(value: &str) -> String {
|
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", test))]
|
|
fn shell_single_quote(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', r"'\''"))
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", test))]
|
|
fn macos_install_shell(install_path: &Path, gid: u32) -> String {
|
|
let install_quoted = shell_single_quote(&install_path.to_string_lossy());
|
|
format!("cd /; CLASH_VERGE_SERVICE_GID={gid} {install_quoted}")
|
|
}
|
|
|
|
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 = "windows")]
|
|
fn install_service() -> Result<()> {
|
|
logging!(info, Type::Service, "install service");
|
|
let cores = crate::config::IVerge::VALID_CLASH_CORES
|
|
.iter()
|
|
.map(|core| service_core_path(core, ".exe"))
|
|
.collect::<Result<Vec<_>>>()?;
|
|
install_windows_service_with_cores(&cores, run_windows_service_installer)
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn install_windows_service_with_cores(
|
|
cores: &[PathBuf],
|
|
mut run_installer: impl FnMut(&[std::ffi::OsString]) -> Result<()>,
|
|
) -> Result<()> {
|
|
let cores: Vec<_> = cores.iter().filter(|core| core.is_file()).collect();
|
|
if cores.is_empty() {
|
|
bail!("no core executable is available; restore a bundled core before installing the service");
|
|
}
|
|
let mut arguments = vec!["--install-service".into()];
|
|
for core in cores {
|
|
let digest = sha256_hex(core)?;
|
|
arguments.extend([
|
|
"--install-core".into(),
|
|
core.as_os_str().to_owned(),
|
|
"--sha256".into(),
|
|
digest.into(),
|
|
]);
|
|
}
|
|
run_installer(&arguments).context("failed to install the service and its cores; choose Repair to retry")
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn run_windows_service_installer(arguments: &[std::ffi::OsString]) -> Result<()> {
|
|
use std::process::Output;
|
|
|
|
use deelevate::{PrivilegeLevel, Token};
|
|
use runas::Command as RunasCommand;
|
|
use std::os::windows::process::CommandExt as _;
|
|
|
|
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
|
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
|
})?;
|
|
|
|
if !install_path.exists() {
|
|
bail!(format!("installer not found: {install_path:?}"));
|
|
}
|
|
|
|
let token = Token::with_current_process()?;
|
|
let level = token.privilege_level()?;
|
|
let output = match level {
|
|
PrivilegeLevel::NotPrivileged => {
|
|
let status = RunasCommand::new(&install_path).args(arguments).show(false).status()?;
|
|
Output {
|
|
status,
|
|
stdout: Vec::new(),
|
|
stderr: Vec::new(),
|
|
}
|
|
}
|
|
_ => {
|
|
// StdCommand returns Output directly
|
|
StdCommand::new(&install_path)
|
|
.args(arguments)
|
|
.creation_flags(0x08000000)
|
|
.output()?
|
|
}
|
|
};
|
|
|
|
if let Some((code, err)) = check_output_error(&output) {
|
|
logging!(
|
|
error,
|
|
Type::Service,
|
|
"failed to install service code: {}, details: {}",
|
|
code,
|
|
err
|
|
);
|
|
bail!("failed to install service code: {}, details: {}", code, err);
|
|
}
|
|
|
|
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 install_service() -> Result<()> {
|
|
logging!(info, Type::Service, "install service");
|
|
|
|
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
|
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
|
})?;
|
|
|
|
if !install_path.exists() {
|
|
bail!(format!("installer not found: {install_path:?}"));
|
|
}
|
|
|
|
let elevator = crate::utils::help::linux_elevator();
|
|
let output = if linux_running_as_root() {
|
|
StdCommand::new(&install_path).output()?
|
|
} else {
|
|
let result = StdCommand::new(&elevator)
|
|
.arg("--disable-internal-agent")
|
|
.arg(&install_path)
|
|
.output()?;
|
|
|
|
// 如果 pkexec 执行失败,回退到 sudo
|
|
if !result.status.success() && elevator.contains("pkexec") {
|
|
logging!(
|
|
warn,
|
|
Type::Service,
|
|
"pkexec failed with code {}, falling back to sudo",
|
|
result.status.code().unwrap_or(-1)
|
|
);
|
|
StdCommand::new("sudo").arg(&install_path).output()?
|
|
} else {
|
|
result
|
|
}
|
|
};
|
|
|
|
if let Some((code, err)) = check_output_error(&output) {
|
|
logging!(
|
|
error,
|
|
Type::Service,
|
|
"failed to install service code: {}, details: {}",
|
|
code,
|
|
err
|
|
);
|
|
bail!("failed to install service code: {}, details: {}", code, err);
|
|
}
|
|
|
|
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}", macos_force_stop_core_shell());
|
|
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(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn install_service() -> Result<()> {
|
|
logging!(info, Type::Service, "install service");
|
|
|
|
let binary_path = packaged_service_tool_path("clash-verge-service", dirs::service_path)?;
|
|
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
|
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
|
})?;
|
|
|
|
if !install_path.exists() {
|
|
bail!(format!("installer not found: {install_path:?}"));
|
|
}
|
|
|
|
macos_service_tool_path(&binary_path)?;
|
|
let install_path = macos_service_tool_path(&install_path)?;
|
|
|
|
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
|
|
|
let gid = tauri_plugin_clash_verge_sysinfo::current_gid();
|
|
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
|
let shell = macos_install_shell(&install_path, gid);
|
|
let shell = escape_osascript_double_quoted_string(&shell);
|
|
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
|
|
|
let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?;
|
|
if let Some((code, err)) = check_output_error(&output) {
|
|
logging!(
|
|
error,
|
|
Type::Service,
|
|
"failed to install service code: {}, details: {}",
|
|
code,
|
|
err
|
|
);
|
|
bail!("failed to install service code: {}, details: {}", code, err);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str>)> {
|
|
if output.status.success() {
|
|
return None;
|
|
}
|
|
let code = output.status.code().unwrap_or(-1);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
if !stderr.is_empty() {
|
|
return Some((code, stderr));
|
|
}
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
if !stdout.is_empty() {
|
|
return Some((code, stdout));
|
|
}
|
|
Some((code, Cow::Borrowed("Unknown error")))
|
|
}
|
|
|
|
fn reinstall_service() -> Result<()> {
|
|
logging!(info, Type::Service, "reinstall service");
|
|
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(|| {
|
|
let digest = sha256_hex(core_path)?;
|
|
run_core_install(core_path, &digest)
|
|
})
|
|
}
|
|
|
|
fn sha256_hex(path: &Path) -> Result<String> {
|
|
use sha2::{Digest as _, Sha256};
|
|
use std::io::Read as _;
|
|
|
|
let mut file = std::fs::File::open(path).with_context(|| format!("failed to open {path:?} for hashing"))?;
|
|
let mut hasher = Sha256::new();
|
|
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
|
|
loop {
|
|
let read = file
|
|
.read(&mut buffer)
|
|
.with_context(|| format!("failed to read {path:?}"))?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
hasher.update(&buffer[..read]);
|
|
}
|
|
Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
|
use deelevate::{PrivilegeLevel, Token};
|
|
use runas::Command as RunasCommand;
|
|
use std::os::windows::process::CommandExt as _;
|
|
use std::process::Output;
|
|
|
|
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
|
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
|
})?;
|
|
if !install_path.exists() {
|
|
bail!(format!("installer not found: {install_path:?}"));
|
|
}
|
|
|
|
let token = Token::with_current_process()?;
|
|
let output = match token.privilege_level()? {
|
|
PrivilegeLevel::NotPrivileged => {
|
|
let status = RunasCommand::new(&install_path)
|
|
.arg("--install-core")
|
|
.arg(core_path)
|
|
.arg("--sha256")
|
|
.arg(sha256_hex)
|
|
.show(false)
|
|
.status()?;
|
|
Output {
|
|
status,
|
|
stdout: Vec::new(),
|
|
stderr: Vec::new(),
|
|
}
|
|
}
|
|
_ => StdCommand::new(&install_path)
|
|
.creation_flags(0x08000000)
|
|
.arg("--install-core")
|
|
.arg(core_path)
|
|
.arg("--sha256")
|
|
.arg(sha256_hex)
|
|
.output()?,
|
|
};
|
|
|
|
if let Some((code, err)) = check_output_error(&output) {
|
|
bail!("failed to stage the core for the service, code {code}: {err}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
|
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
|
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
|
})?;
|
|
if !install_path.exists() {
|
|
bail!(format!("installer not found: {install_path:?}"));
|
|
}
|
|
|
|
let core_argument = core_path.as_os_str();
|
|
let output = if linux_running_as_root() {
|
|
StdCommand::new(&install_path)
|
|
.arg("--install-core")
|
|
.arg(core_argument)
|
|
.arg("--sha256")
|
|
.arg(sha256_hex)
|
|
.output()?
|
|
} else {
|
|
let elevator = crate::utils::help::linux_elevator();
|
|
let mut elevated = StdCommand::new(&elevator);
|
|
// pkexec-only option; other elevators such as sudo reject unknown flags outright.
|
|
if elevator.contains("pkexec") {
|
|
elevated.arg("--disable-internal-agent");
|
|
}
|
|
let result = elevated
|
|
.arg(&install_path)
|
|
.arg("--install-core")
|
|
.arg(core_argument)
|
|
.arg("--sha256")
|
|
.arg(sha256_hex)
|
|
.output()?;
|
|
if !result.status.success() && elevator.contains("pkexec") {
|
|
logging!(
|
|
warn,
|
|
Type::Service,
|
|
"pkexec failed with code {}, falling back to sudo",
|
|
result.status.code().unwrap_or(-1)
|
|
);
|
|
StdCommand::new("sudo")
|
|
.arg(&install_path)
|
|
.arg("--install-core")
|
|
.arg(core_argument)
|
|
.arg("--sha256")
|
|
.arg(sha256_hex)
|
|
.output()?
|
|
} else {
|
|
result
|
|
}
|
|
};
|
|
|
|
if let Some((code, err)) = check_output_error(&output) {
|
|
bail!("failed to stage the core for the service, code {code}: {err}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
|
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
|
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
|
})?;
|
|
if !install_path.exists() {
|
|
bail!(format!("installer not found: {install_path:?}"));
|
|
}
|
|
let install_path = macos_service_tool_path(&install_path)?;
|
|
|
|
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
|
let shell = format!(
|
|
"cd /; {} --install-core {} --sha256 {}",
|
|
shell_single_quote(&install_path.to_string_lossy()),
|
|
shell_single_quote(&core_path.to_string_lossy()),
|
|
shell_single_quote(sha256_hex),
|
|
);
|
|
let shell = escape_osascript_double_quoted_string(&shell);
|
|
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
|
|
|
let output = StdCommand::new("osascript").args(["-e", &command]).output()?;
|
|
if let Some((code, err)) = check_output_error(&output) {
|
|
bail!("failed to stage the core for the service, code {code}: {err}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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 {}
|
|
|
|
/// 尝试使用服务启动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
|
|
);
|
|
start_owner_monitor();
|
|
return Err(ServiceStartRefusal {
|
|
code: response.code,
|
|
core_path: request.runtime.core_path,
|
|
message: err_msg,
|
|
}
|
|
.into());
|
|
}
|
|
|
|
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.store(false, Ordering::Relaxed);
|
|
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).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).await;
|
|
}
|
|
bail!(response.message);
|
|
}
|
|
let encoded = response.data.context("服务未返回核心日志快照")?;
|
|
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
|
|
Ok(String::from_utf8_lossy(&content).into_owned())
|
|
}
|
|
|
|
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);
|
|
static PROVIDER_SYNC_SERIAL: Lazy<tokio::sync::Mutex<()>> = Lazy::new(|| tokio::sync::Mutex::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;
|
|
for attempt in 1..=RUNTIME_PROVIDER_SYNC_ATTEMPTS {
|
|
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, .. }) => return,
|
|
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
|
|
logging!(
|
|
info,
|
|
Type::Service,
|
|
"{pending} provider caches are not ready yet; retrying"
|
|
);
|
|
}
|
|
Ok(ProviderSync { pending, .. }) => {
|
|
logging!(warn, Type::Service, "{pending} provider caches were not synced");
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
logging!(
|
|
warn,
|
|
Type::Service,
|
|
"failed to sync provider caches from the service: {error:#}"
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
tokio::time::sleep(constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY).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,
|
|
"provider cache {} was not read: {error:#}",
|
|
declared.provider.destination
|
|
);
|
|
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,
|
|
"provider cache {} was not published: {error}",
|
|
cache.declared.provider.destination
|
|
);
|
|
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?;
|
|
}
|
|
let name = target
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy())
|
|
.unwrap_or_default();
|
|
for _ in 0..8 {
|
|
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) => return Ok((temp, file)),
|
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
|
Err(error) => return Err(error).with_context(|| format!("failed to create {}", temp.display())),
|
|
}
|
|
}
|
|
bail!("no free temporary name beside {}", target.display())
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
|
|
OwnerRecoveryPolicy {
|
|
reset_system_proxy: !is_macos,
|
|
}
|
|
}
|
|
|
|
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();
|
|
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 = read_owner_sample().await;
|
|
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).await;
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Samples ownership, treating every unusable reply as unreadable.
|
|
async fn read_owner_sample() -> OwnerSample {
|
|
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;
|
|
}
|
|
};
|
|
|
|
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
|
return OwnerSample::NotActive;
|
|
}
|
|
if response.code != 0 {
|
|
logging!(
|
|
debug,
|
|
Type::Service,
|
|
"service owner status returned error {}: {}",
|
|
response.code,
|
|
response.message
|
|
);
|
|
return OwnerSample::Unreadable;
|
|
}
|
|
let Some(status) = response.data else {
|
|
logging!(debug, Type::Service, "service owner status omitted data");
|
|
return OwnerSample::Unreadable;
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) {
|
|
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;
|
|
}
|
|
|
|
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;
|
|
clear_active_service_session();
|
|
CoreManager::global().core_stopped();
|
|
|
|
if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
|
return;
|
|
}
|
|
|
|
let mut last_error = None;
|
|
for attempt in 1..=3 {
|
|
match proxy_control::clear().await {
|
|
Ok(()) => return,
|
|
Err(error) => {
|
|
logging!(
|
|
warn,
|
|
Type::Service,
|
|
"proxy clear attempt {attempt}/3 after owner loss failed: {error:#}"
|
|
);
|
|
last_error = Some(error);
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
}
|
|
if let Some(error) = last_error {
|
|
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";
|
|
let config = ServiceManager::config();
|
|
let span = tracing::Span::current();
|
|
span.record("attempts", config.max_retries);
|
|
span.record("interval_ms", config.retry_delay.as_millis() as u64);
|
|
|
|
match RUN_STATE.await_ready(config.max_retries, config.retry_delay).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, macos_install_shell, 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);
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_service_install_combines_service_and_attested_cores() -> anyhow::Result<()> {
|
|
use std::ffi::OsString;
|
|
let root = TestDirectory::new("scoop install")?;
|
|
let cores: Vec<_> = crate::config::IVerge::VALID_CLASH_CORES
|
|
.iter()
|
|
.map(|name| root.path().join(format!("{name}.exe")))
|
|
.collect();
|
|
for core in &cores {
|
|
std::fs::write(core, b"abc")?;
|
|
}
|
|
let mut calls = Vec::new();
|
|
super::install_windows_service_with_cores(&cores, |arguments| {
|
|
calls.push(arguments.to_vec());
|
|
Ok(())
|
|
})?;
|
|
assert_eq!(calls.len(), 1, "service and cores must share one elevation");
|
|
let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
|
let mut expected: Vec<OsString> = vec!["--install-service".into()];
|
|
expected.extend(cores.iter().flat_map(|core| {
|
|
[
|
|
"--install-core".into(),
|
|
core.as_os_str().to_owned(),
|
|
"--sha256".into(),
|
|
digest.into(),
|
|
]
|
|
}));
|
|
assert_eq!(calls[0], expected);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_service_install_skips_unavailable_cores() -> anyhow::Result<()> {
|
|
let root = TestDirectory::new("missing-core")?;
|
|
let core = root.path().join("verge-mihomo.exe");
|
|
let missing = root.path().join("verge-mihomo-alpha.exe");
|
|
std::fs::write(&core, b"abc")?;
|
|
let cores = [core.clone(), missing.clone(), root.path().to_path_buf()];
|
|
let mut calls = Vec::new();
|
|
super::install_windows_service_with_cores(&cores, |arguments| {
|
|
calls.push(arguments.to_vec());
|
|
Ok(())
|
|
})?;
|
|
assert_eq!(calls.len(), 1);
|
|
assert_eq!(calls[0].len(), 5);
|
|
assert_eq!(calls[0][0], "--install-service");
|
|
assert_eq!(calls[0][2], core.as_os_str());
|
|
|
|
let error = super::install_windows_service_with_cores(&[missing, root.path().to_path_buf()], |_| {
|
|
panic!("no available core must fail before invoking the installer")
|
|
})
|
|
.expect_err("installation requires at least one available core");
|
|
assert!(error.to_string().contains("no core executable"));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_service_install_propagates_installer_failure() -> anyhow::Result<()> {
|
|
let root = TestDirectory::new("install-failure")?;
|
|
let core = root.path().join("verge-mihomo.exe");
|
|
std::fs::write(&core, b"abc")?;
|
|
let mut calls = 0;
|
|
let result = super::install_windows_service_with_cores(std::slice::from_ref(&core), |_| {
|
|
calls += 1;
|
|
bail!("installer failed with exit code 1")
|
|
});
|
|
let error = result.expect_err("installation failure must not be reported as success");
|
|
assert!(format!("{error:#}").contains("exit code 1"));
|
|
assert!(error.to_string().contains("Repair"));
|
|
assert_eq!(calls, 1);
|
|
Ok(())
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
#[test]
|
|
fn macos_install_shell_starts_from_root_without_nested_sudo() {
|
|
let shell = macos_install_shell(Path::new("/safe/service-tools/clash-verge-service-install"), 20);
|
|
|
|
assert_eq!(
|
|
shell,
|
|
"cd /; CLASH_VERGE_SERVICE_GID=20 '/safe/service-tools/clash-verge-service-install'"
|
|
);
|
|
assert!(!shell.contains("sudo"));
|
|
}
|
|
|
|
#[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_never_resets_machine_wide_proxy() {
|
|
for reason in [
|
|
OwnerRecoveryReason::Displaced,
|
|
OwnerRecoveryReason::SameOwnerFailure,
|
|
OwnerRecoveryReason::TransportFailure,
|
|
] {
|
|
assert!(!owner_recovery_policy(reason, true).reset_system_proxy);
|
|
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);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|