mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-27 16:22:11 +08:00
2072 lines
73 KiB
Rust
2072 lines
73 KiB
Rust
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
use crate::utils::dirs;
|
|
use crate::{
|
|
config::Config,
|
|
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::collect_runtime_bundle,
|
|
tray::Tray,
|
|
},
|
|
process::AsyncHandler,
|
|
};
|
|
use anyhow::{Context as _, Result, bail};
|
|
use clash_verge_logging::{Type, logging};
|
|
use clash_verge_service_ipc::{
|
|
MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome, RuntimeBundle, ServiceErrorCode, StageRuntimeOutcome,
|
|
StartClashRequest, WriterConfig,
|
|
};
|
|
use compact_str::CompactString;
|
|
use once_cell::sync::Lazy;
|
|
use parking_lot::Mutex;
|
|
use std::{
|
|
borrow::Cow,
|
|
env::current_exe,
|
|
future::Future,
|
|
path::{Path, PathBuf},
|
|
process::Command as StdCommand,
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
time::Duration,
|
|
};
|
|
|
|
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
|
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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 clear_active_service_session() {
|
|
ACTIVE_SERVICE_SESSION.lock().take();
|
|
}
|
|
|
|
/// Probes staging support without failing startup when the fast path is unavailable.
|
|
async fn probe_runtime_staging_support() -> bool {
|
|
match clash_verge_service_ipc::get_version().await {
|
|
Ok(response) if response.code == 0 => response
|
|
.data
|
|
.as_ref()
|
|
.is_some_and(clash_verge_service_ipc::ProtocolInfo::supports_runtime_staging),
|
|
Ok(response) => {
|
|
logging!(
|
|
warn,
|
|
Type::Service,
|
|
"服务协议查询返回 {}: {};配置变更将走重启路径",
|
|
response.code,
|
|
response.message
|
|
);
|
|
false
|
|
}
|
|
Err(error) => {
|
|
logging!(
|
|
warn,
|
|
Type::Service,
|
|
"无法查询服务协议版本: {error:#};配置变更将走重启路径"
|
|
);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
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<()> {
|
|
use std::process::Output;
|
|
logging!(info, Type::Service, "install service");
|
|
|
|
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).show(false).status()?;
|
|
Output {
|
|
status,
|
|
stdout: Vec::new(),
|
|
stderr: Vec::new(),
|
|
}
|
|
}
|
|
_ => {
|
|
// StdCommand returns Output directly
|
|
StdCommand::new(&install_path).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
|
|
})
|
|
}
|
|
|
|
/// 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: CompactString },
|
|
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.into(),
|
|
});
|
|
}
|
|
response
|
|
.data
|
|
.map(StageRequest::Answered)
|
|
.context("Clash Verge Service 未返回运行时暂存结果")
|
|
}
|
|
|
|
/// 尝试使用服务启动core
|
|
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
|
logging!(info, Type::Service, "尝试使用现有服务启动核心");
|
|
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) => {
|
|
start_owner_monitor();
|
|
return Err(error).context("无法连接到Clash Verge Service");
|
|
}
|
|
};
|
|
|
|
if response.code > 0 {
|
|
let err_msg = response.message;
|
|
logging!(error, Type::Service, "启动核心失败: {}", err_msg);
|
|
start_owner_monitor();
|
|
bail!(
|
|
"failed to start Service core at {}: {err_msg}",
|
|
request.runtime.core_path
|
|
);
|
|
}
|
|
|
|
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
|
|
let supports_runtime_staging = probe_runtime_staging_support().await;
|
|
*ACTIVE_SERVICE_SESSION.lock() = Some(ActiveServiceSession {
|
|
proof: OwnerSessionProof {
|
|
generation: result.session.generation,
|
|
token: proposed_session_token,
|
|
},
|
|
supports_runtime_staging,
|
|
});
|
|
|
|
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
|
start_owner_monitor();
|
|
logging!(info, Type::Service, "服务成功启动核心");
|
|
Ok(())
|
|
}
|
|
|
|
// 以服务启动core
|
|
pub(super) async fn run_core_by_service(config_file: &Path) -> Result<()> {
|
|
logging!(info, Type::Service, "正在尝试通过服务启动核心");
|
|
|
|
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<CompactString>> {
|
|
logging!(info, Type::Service, "正在获取服务模式下的 Clash 日志");
|
|
|
|
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;
|
|
logging!(error, Type::Service, "获取服务模式下的 Clash 日志失败: {}", err_msg);
|
|
bail!(err_msg);
|
|
}
|
|
|
|
logging!(info, Type::Service, "成功获取服务模式下的 Clash 日志");
|
|
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("服务未返回核心日志快照")?;
|
|
if encoded.len() % 2 != 0 {
|
|
bail!("服务返回了无效的核心日志快照");
|
|
}
|
|
let mut content = Vec::with_capacity(encoded.len() / 2);
|
|
for offset in (0..encoded.len()).step_by(2) {
|
|
content.push(u8::from_str_radix(&encoded[offset..offset + 2], 16).context("服务返回了无效的核心日志快照")?);
|
|
}
|
|
Ok(String::from_utf8_lossy(&content).into_owned())
|
|
}
|
|
|
|
/// 通过服务停止core
|
|
pub(super) async fn stop_core_by_service() -> Result<()> {
|
|
logging!(info, Type::Service, "通过服务停止核心 (IPC)");
|
|
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;
|
|
logging!(error, Type::Service, "停止核心失败: {}", err_msg);
|
|
bail!(err_msg);
|
|
}
|
|
|
|
clear_active_service_session();
|
|
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 {
|
|
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 {
|
|
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(2);
|
|
/// 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 {
|
|
let mut watch = OwnerWatch::new();
|
|
loop {
|
|
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
|
|
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
|
|
break;
|
|
}
|
|
if !matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
|
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; \
|
|
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)
|
|
}
|
|
|
|
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 _ in 0..3 {
|
|
match proxy_control::clear().await {
|
|
Ok(()) => return,
|
|
Err(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.
|
|
async fn wait_for_service_ipc() -> Result<()> {
|
|
const CONTEXT: &str = "service IPC did not become available";
|
|
let config = ServiceManager::config();
|
|
|
|
match RUN_STATE.await_ready(config.max_retries, config.retry_delay).await {
|
|
Ok(_) => Ok(()),
|
|
Err(ReadyWaitError::Unreachable(error)) => {
|
|
RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}")));
|
|
Err(error).context(CONTEXT)
|
|
}
|
|
Err(ReadyWaitError::Rejected(error)) => 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?;
|
|
Config::restore_tun_for_session().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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|