mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
feat: add launch guard and secure multi-user service ownership (#7569)
* feat(service): isolate macOS launch and owner runtime * ci(windows): run owner security tests * chore(deps): use kode-bridge v0.4.1 tag * fix(macos): classify installed service state * fix(macos): classify marked unavailable service * fix(macos): gate core startup on service migration * fix(macos): scope service init to supported platforms * feat(macos): prompt before migrating installed service * fix(macos): shorten sidecar IPC path * build(service): pin helper protocol and artifacts * fix(core): require confirmed service and sidecar readiness * fix(macos): harden launch roots and singleton state * fix(ui): refresh migration state before restart * fix(macos): linearize service migration lifecycle * fix(ui): keep service migration state fresh * feat(service): track authenticated owner sessions * fix(service): preserve newer owner sessions * feat(macos): route service proxy changes through helper * fix(macos): serialize service proxy guard * fix(macos): close service proxy owner lifecycle * fix(macos): close remaining proxy stop races * fix(app): do not cancel controlled service stop * fix(lifecycle): keep controlled updates cancellation-safe * fix(macos): stage dev core for service * test(macos): exercise staged core cleanup * fix(dev): await controlled macOS exit * chore: update changelog * fix(macos): recover sidecar after service install failure Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state. * fix(macos): stage dev service tools before elevation * fix(macos): localize launch guard prompts * fix(service): address cross-platform integration issues * feat: support seamless service migration * fix: pass Windows service installer path safely * fix(prebuild): derive service release from Cargo dependency * fix(dev): preserve service installation state * fix: resolve clippy warnings in service lifecycle
This commit is contained in:
+189
-31
@@ -3,8 +3,6 @@ use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::OnceCell;
|
||||
#[cfg(unix)]
|
||||
use std::iter;
|
||||
use std::{fs, path::PathBuf};
|
||||
use tauri::Manager as _;
|
||||
|
||||
@@ -66,6 +64,28 @@ pub fn app_home_dir() -> Result<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preinit_app_data_dir() -> Result<PathBuf> {
|
||||
if PORTABLE_FLAG.get().copied().unwrap_or(false) {
|
||||
let executable = std::env::current_exe()?;
|
||||
let parent = executable
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("portable executable has no parent directory"))?;
|
||||
return Ok(parent.join(".config").join(APP_ID));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let root = PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is unavailable"))?)
|
||||
.join("Library/Application Support");
|
||||
#[cfg(target_os = "linux")]
|
||||
let root = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share"));
|
||||
#[cfg(windows)]
|
||||
let root = PathBuf::from(std::env::var_os("APPDATA").ok_or_else(|| anyhow::anyhow!("APPDATA is unavailable"))?);
|
||||
|
||||
Ok(root.join(APP_ID))
|
||||
}
|
||||
|
||||
/// get the resources dir
|
||||
pub fn app_resources_dir() -> Result<PathBuf> {
|
||||
// 避免在Handle未初始化时崩溃
|
||||
@@ -213,40 +233,155 @@ pub fn get_encryption_key() -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn ensure_mihomo_safe_dir() -> Option<PathBuf> {
|
||||
iter::once("/tmp")
|
||||
.map(PathBuf::from)
|
||||
.find(|path| path.exists())
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").and_then(|home| {
|
||||
let home_config = PathBuf::from(home).join(".config");
|
||||
if home_config.exists() || fs::create_dir_all(&home_config).is_ok() {
|
||||
Some(home_config)
|
||||
} else {
|
||||
logging!(error, Type::File, "Failed to create safe directory: {home_config:?}");
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(clash_verge_service_ipc::mihomo_ipc_path(
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
ensure_mihomo_safe_dir()
|
||||
.map(|base_dir| base_dir.join("verge").join("verge-mihomo.sock"))
|
||||
.or_else(|| {
|
||||
app_home_dir()
|
||||
.ok()
|
||||
.map(|dir| dir.join("verge").join("verge-mihomo.sock"))
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to determine ipc path"))
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn sidecar_ipc_path() -> Result<PathBuf> {
|
||||
sidecar_ipc_path_for(
|
||||
std::path::Path::new(""),
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn ipc_path() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(r"\\.\pipe\verge-mihomo"))
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn sidecar_ipc_path() -> Result<PathBuf> {
|
||||
Ok(sidecar_ipc_path_for(
|
||||
&preinit_app_data_dir()?,
|
||||
&crate::core::owner_identity::current_owner_identity()?,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sidecar_ipc_path_for(app_root: &std::path::Path, _identity: &clash_verge_service_ipc::OwnerIdentity) -> PathBuf {
|
||||
app_root.join("verge-mihomo.sock")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn sidecar_ipc_path_for(
|
||||
_app_root: &std::path::Path,
|
||||
_identity: &clash_verge_service_ipc::OwnerIdentity,
|
||||
) -> Result<PathBuf> {
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
|
||||
|
||||
// SAFETY: A null buffer with size zero asks confstr for the required buffer length.
|
||||
let required_len = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, std::ptr::null_mut(), 0) };
|
||||
if required_len == 0 {
|
||||
return Err(anyhow::anyhow!("macOS per-user temporary directory is unavailable"));
|
||||
}
|
||||
|
||||
let mut buffer = vec![0_u8; required_len];
|
||||
// SAFETY: buffer is writable for buffer.len() bytes, as required by confstr.
|
||||
let written = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buffer.as_mut_ptr().cast(), buffer.len()) };
|
||||
if written == 0 || written > buffer.len() {
|
||||
return Err(anyhow::anyhow!("failed to read macOS per-user temporary directory"));
|
||||
}
|
||||
|
||||
let root = std::ffi::CStr::from_bytes_until_nul(&buffer)
|
||||
.map_err(|_| anyhow::anyhow!("macOS per-user temporary directory is not NUL-terminated"))?;
|
||||
#[cfg(feature = "verge-dev")]
|
||||
let filename = "verge-mihomo-dev.sock";
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
let filename = "verge-mihomo.sock";
|
||||
let path = PathBuf::from(OsStr::from_bytes(root.to_bytes())).join(filename);
|
||||
|
||||
let path_len = path.as_os_str().as_bytes().len();
|
||||
if path_len >= 104 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"macOS Sidecar IPC path is {path_len} bytes, but sockaddr_un.sun_path requires fewer than 104 bytes: {:?}",
|
||||
path,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn sidecar_ipc_path_for(_app_root: &std::path::Path, identity: &clash_verge_service_ipc::OwnerIdentity) -> PathBuf {
|
||||
PathBuf::from(sidecar_pipe_name(identity, cfg!(feature = "verge-dev")))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn sidecar_pipe_name(identity: &clash_verge_service_ipc::OwnerIdentity, is_dev: bool) -> String {
|
||||
let flavor = if is_dev { "dev" } else { "release" };
|
||||
format!(
|
||||
r"\\.\pipe\verge-mihomo-sidecar-{flavor}-{}",
|
||||
clash_verge_service_ipc::owner_key(identity)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod ipc_tests {
|
||||
use super::sidecar_ipc_path_for;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn sidecar_ipc_stays_in_the_app_root() {
|
||||
let identity = OwnerIdentity::Unix { uid: 501, gid: 20 };
|
||||
let app_root = Path::new("/home/test/.local/share/io.github.clash-verge-rev.clash-verge-rev");
|
||||
let path = sidecar_ipc_path_for(app_root, &identity);
|
||||
|
||||
assert_eq!(path, app_root.join("verge-mihomo.sock"));
|
||||
assert_ne!(
|
||||
path.to_string_lossy(),
|
||||
clash_verge_service_ipc::mihomo_ipc_path(&identity)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod ipc_tests {
|
||||
use super::sidecar_ipc_path_for;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _, path::Path};
|
||||
|
||||
#[test]
|
||||
fn sidecar_ipc_ignores_long_app_root_and_fits_sockaddr_un() -> anyhow::Result<()> {
|
||||
let identity = OwnerIdentity::Unix { uid: 501, gid: 20 };
|
||||
let app_root =
|
||||
Path::new("/Users/support/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev");
|
||||
let path = sidecar_ipc_path_for(app_root, &identity)?;
|
||||
|
||||
assert!(!path.starts_with(app_root));
|
||||
assert!(path.as_os_str().as_bytes().len() < 104);
|
||||
#[cfg(feature = "verge-dev")]
|
||||
assert_eq!(path.file_name(), Some(OsStr::new("verge-mihomo-dev.sock")));
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
assert_eq!(path.file_name(), Some(OsStr::new("verge-mihomo.sock")));
|
||||
assert_eq!(path, sidecar_ipc_path_for(Path::new("/different/root"), &identity)?);
|
||||
assert!(path.parent().is_some_and(Path::is_dir));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod ipc_tests {
|
||||
use super::sidecar_ipc_path_for;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn sidecar_ipc_uses_the_current_owners_named_pipe() {
|
||||
let identity = OwnerIdentity::Windows {
|
||||
sid: "S-1-5-21-1000".to_owned(),
|
||||
};
|
||||
let path = sidecar_ipc_path_for(Path::new(r"C:\ignored"), &identity);
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
Path::new(&format!(
|
||||
r"\\.\pipe\verge-mihomo-sidecar-{}-{}",
|
||||
if cfg!(feature = "verge-dev") { "dev" } else { "release" },
|
||||
clash_verge_service_ipc::owner_key(&identity)
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PathBufExec {
|
||||
async fn remove_if_exists(&self) -> Result<()>;
|
||||
@@ -262,3 +397,26 @@ impl PathBufExec for PathBuf {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod windows_pipe_name_tests {
|
||||
use super::sidecar_pipe_name;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
|
||||
#[test]
|
||||
fn windows_sidecar_pipe_separates_dev_and_release_for_the_same_owner() {
|
||||
let identity = OwnerIdentity::Windows {
|
||||
sid: "S-1-5-21-1000".to_owned(),
|
||||
};
|
||||
let owner_key = clash_verge_service_ipc::owner_key(&identity);
|
||||
|
||||
assert_eq!(
|
||||
sidecar_pipe_name(&identity, false),
|
||||
format!(r"\\.\pipe\verge-mihomo-sidecar-release-{owner_key}")
|
||||
);
|
||||
assert_eq!(
|
||||
sidecar_pipe_name(&identity, true),
|
||||
format!(r"\\.\pipe\verge-mihomo-sidecar-dev-{owner_key}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +213,18 @@ pub fn open_app_latest_log() -> Result<()> {
|
||||
open_latest_log(path)
|
||||
}
|
||||
|
||||
pub fn open_core_latest_log() -> Result<()> {
|
||||
let path = crate::utils::dirs::clash_latest_log()?;
|
||||
pub async fn open_core_latest_log() -> Result<()> {
|
||||
let path = if matches!(
|
||||
*crate::core::CoreManager::global().get_running_mode(),
|
||||
crate::core::manager::RunningMode::Service
|
||||
) {
|
||||
let path = crate::utils::dirs::service_log_dir()?.join("service_latest.log");
|
||||
let snapshot = crate::core::service::get_clash_log_snapshot_by_service().await?;
|
||||
tokio::fs::write(&path, snapshot).await?;
|
||||
path
|
||||
} else {
|
||||
crate::utils::dirs::clash_latest_log()?
|
||||
};
|
||||
open_latest_log(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
os::unix::ffi::OsStrExt as _,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LaunchLocation {
|
||||
Allowed { bundle: PathBuf },
|
||||
Movable { bundle: PathBuf },
|
||||
Translocated,
|
||||
Rejected { reason: LaunchRejectionReason },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LaunchRejectionReason {
|
||||
NotInApplicationBundle,
|
||||
CanonicalizeBundleFailed { error: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MoveDecision {
|
||||
SystemApplications,
|
||||
UserApplications,
|
||||
ConfirmReplacement,
|
||||
ManualInstructions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LaunchDisposition {
|
||||
Continue,
|
||||
Exit,
|
||||
}
|
||||
|
||||
pub fn evaluate_install_location(executable: &Path, home: &Path) -> LaunchLocation {
|
||||
evaluate_install_location_with_roots(executable, home, Path::new("/Applications"))
|
||||
}
|
||||
|
||||
pub fn evaluate_install_location_with_roots(
|
||||
executable: &Path,
|
||||
home: &Path,
|
||||
system_applications: &Path,
|
||||
) -> LaunchLocation {
|
||||
if executable
|
||||
.components()
|
||||
.any(|component| component.as_os_str() == "AppTranslocation")
|
||||
{
|
||||
return LaunchLocation::Translocated;
|
||||
}
|
||||
|
||||
let Some(bundle) = executable.ancestors().find(|ancestor| {
|
||||
ancestor
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("app"))
|
||||
}) else {
|
||||
return LaunchLocation::Rejected {
|
||||
reason: LaunchRejectionReason::NotInApplicationBundle,
|
||||
};
|
||||
};
|
||||
let canonical_bundle = match std::fs::canonicalize(bundle) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(error) => {
|
||||
return LaunchLocation::Rejected {
|
||||
reason: LaunchRejectionReason::CanonicalizeBundleFailed {
|
||||
error: error.to_string(),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
let system_root = canonical_allowed_root(system_applications);
|
||||
let user_root = canonical_allowed_root(&home.join("Applications"));
|
||||
let allowed = system_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| canonical_bundle.starts_with(root))
|
||||
|| user_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| canonical_bundle.starts_with(root));
|
||||
|
||||
if allowed {
|
||||
LaunchLocation::Allowed {
|
||||
bundle: canonical_bundle,
|
||||
}
|
||||
} else {
|
||||
LaunchLocation::Movable {
|
||||
bundle: canonical_bundle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_allowed_root(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = std::fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
std::fs::canonicalize(path).ok()
|
||||
}
|
||||
|
||||
fn current_user_home() -> anyhow::Result<PathBuf> {
|
||||
const DEFAULT_BUFFER_SIZE: usize = 16 * 1024;
|
||||
const MAX_BUFFER_SIZE: usize = 1024 * 1024;
|
||||
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let configured_size = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
|
||||
let mut buffer_size = if configured_size > 0 {
|
||||
usize::try_from(configured_size).unwrap_or(DEFAULT_BUFFER_SIZE)
|
||||
} else {
|
||||
DEFAULT_BUFFER_SIZE
|
||||
}
|
||||
.max(1024);
|
||||
|
||||
loop {
|
||||
if buffer_size > MAX_BUFFER_SIZE {
|
||||
anyhow::bail!("effective-user home lookup exceeded the maximum buffer size");
|
||||
}
|
||||
|
||||
let mut passwd = std::mem::MaybeUninit::<libc::passwd>::zeroed();
|
||||
let mut result = std::ptr::null_mut();
|
||||
let mut buffer = vec![0_u8; buffer_size];
|
||||
let code = unsafe {
|
||||
libc::getpwuid_r(
|
||||
uid,
|
||||
passwd.as_mut_ptr(),
|
||||
buffer.as_mut_ptr().cast(),
|
||||
buffer.len(),
|
||||
&mut result,
|
||||
)
|
||||
};
|
||||
if code == libc::ERANGE {
|
||||
buffer_size = buffer_size.saturating_mul(2);
|
||||
continue;
|
||||
}
|
||||
if code != 0 {
|
||||
return Err(std::io::Error::from_raw_os_error(code).into());
|
||||
}
|
||||
if result.is_null() {
|
||||
anyhow::bail!("effective user {uid} has no password database entry");
|
||||
}
|
||||
|
||||
let passwd = unsafe { passwd.assume_init() };
|
||||
if passwd.pw_dir.is_null() {
|
||||
anyhow::bail!("effective user {uid} has no home directory");
|
||||
}
|
||||
let home = unsafe { CStr::from_ptr(passwd.pw_dir) };
|
||||
if home.to_bytes().is_empty() {
|
||||
anyhow::bail!("effective user {uid} has an empty home directory");
|
||||
}
|
||||
return Ok(PathBuf::from(std::ffi::OsStr::from_bytes(home.to_bytes())));
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn move_decision(
|
||||
translocated: bool,
|
||||
system_applications_writable: bool,
|
||||
target_exists: bool,
|
||||
) -> MoveDecision {
|
||||
if translocated {
|
||||
MoveDecision::ManualInstructions
|
||||
} else if target_exists {
|
||||
MoveDecision::ConfirmReplacement
|
||||
} else if system_applications_writable {
|
||||
MoveDecision::SystemApplications
|
||||
} else {
|
||||
MoveDecision::UserApplications
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enforce_before_initialization() -> LaunchDisposition {
|
||||
clash_verge_i18n::sync_locale(None);
|
||||
|
||||
let executable = match std::env::current_exe() {
|
||||
Ok(executable) => executable,
|
||||
Err(error) => {
|
||||
let message = clash_verge_i18n::t!("launchGuard.currentExeError").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
};
|
||||
let home = match current_user_home() {
|
||||
Ok(home) => home,
|
||||
Err(error) => {
|
||||
let message = clash_verge_i18n::t!("launchGuard.homeDirError").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
};
|
||||
|
||||
match evaluate_install_location(&executable, &home) {
|
||||
LaunchLocation::Allowed { .. } => LaunchDisposition::Continue,
|
||||
LaunchLocation::Translocated => {
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.translocated"));
|
||||
LaunchDisposition::Exit
|
||||
}
|
||||
LaunchLocation::Rejected { reason } => {
|
||||
let reason = match reason {
|
||||
LaunchRejectionReason::NotInApplicationBundle => {
|
||||
clash_verge_i18n::t!("launchGuard.notInBundle").into_owned()
|
||||
}
|
||||
LaunchRejectionReason::CanonicalizeBundleFailed { error } => {
|
||||
clash_verge_i18n::t!("launchGuard.canonicalizeBundleError").replace("{error}", &error)
|
||||
}
|
||||
};
|
||||
let message = clash_verge_i18n::t!("launchGuard.rejected").replace("{reason}", &reason);
|
||||
show_message(&message);
|
||||
LaunchDisposition::Exit
|
||||
}
|
||||
LaunchLocation::Movable { bundle } => move_and_relaunch(&bundle, &home),
|
||||
}
|
||||
}
|
||||
|
||||
fn move_and_relaunch(bundle: &Path, home: &Path) -> LaunchDisposition {
|
||||
let system_root = Path::new("/Applications");
|
||||
let system_writable = path_is_writable(system_root);
|
||||
let destination_root = if system_writable {
|
||||
system_root.to_path_buf()
|
||||
} else {
|
||||
home.join("Applications")
|
||||
};
|
||||
let Some(bundle_name) = bundle.file_name() else {
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.bundleNameError"));
|
||||
return LaunchDisposition::Exit;
|
||||
};
|
||||
let destination = destination_root.join(bundle_name);
|
||||
let exists = destination.exists();
|
||||
let destination_display = destination.display().to_string();
|
||||
let prompt = if exists {
|
||||
clash_verge_i18n::t!("launchGuard.replacePrompt").replace("{destination}", &destination_display)
|
||||
} else {
|
||||
clash_verge_i18n::t!("launchGuard.movePrompt").replace("{destination}", &destination_display)
|
||||
};
|
||||
if !confirm(&prompt) {
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
if let Err(error) = std::fs::create_dir_all(&destination_root) {
|
||||
let message =
|
||||
clash_verge_i18n::t!("launchGuard.createApplicationsDirError").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
let staging = sibling_swap_path(&destination, "installing");
|
||||
let backup = sibling_swap_path(&destination, "backup");
|
||||
let _ = remove_existing_target(&staging);
|
||||
let _ = remove_existing_target(&backup);
|
||||
let copy = std::process::Command::new("/usr/bin/ditto")
|
||||
.arg(bundle)
|
||||
.arg(&staging)
|
||||
.status();
|
||||
if !copy.is_ok_and(|status| status.success()) {
|
||||
let _ = remove_existing_target(&staging);
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.moveFailed"));
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
let backup = match activate_staged_bundle(&staging, &destination, &backup) {
|
||||
Ok(backup) => backup,
|
||||
Err(error) => {
|
||||
let _ = remove_existing_target(&staging);
|
||||
let message = clash_verge_i18n::t!("launchGuard.replaceFailed").replace("{error}", &error.to_string());
|
||||
show_message(&message);
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
};
|
||||
let relaunched = std::process::Command::new("/usr/bin/open")
|
||||
.args(["-n"])
|
||||
.arg(&destination)
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
if !relaunched {
|
||||
if let Some(backup) = backup.as_ref() {
|
||||
let _ = remove_existing_target(&staging);
|
||||
if std::fs::rename(&destination, &staging).is_ok() {
|
||||
let _ = std::fs::rename(backup, &destination);
|
||||
let _ = remove_existing_target(&staging);
|
||||
}
|
||||
}
|
||||
show_message(&clash_verge_i18n::t!("launchGuard.relaunchFailed"));
|
||||
return LaunchDisposition::Exit;
|
||||
}
|
||||
if let Some(backup) = backup {
|
||||
let _ = remove_existing_target(&backup);
|
||||
}
|
||||
if bundle != destination {
|
||||
let _ = std::fs::remove_dir_all(bundle);
|
||||
}
|
||||
LaunchDisposition::Exit
|
||||
}
|
||||
|
||||
fn sibling_swap_path(destination: &Path, label: &str) -> PathBuf {
|
||||
let name = destination
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Clash Verge.app");
|
||||
destination.with_file_name(format!(".{name}.{label}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
fn activate_staged_bundle(staging: &Path, destination: &Path, backup: &Path) -> std::io::Result<Option<PathBuf>> {
|
||||
let staging_metadata = std::fs::symlink_metadata(staging)?;
|
||||
if staging_metadata.file_type().is_symlink() || !staging_metadata.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"staged application is not an ordinary bundle directory",
|
||||
));
|
||||
}
|
||||
|
||||
let had_destination = std::fs::symlink_metadata(destination).is_ok();
|
||||
if had_destination {
|
||||
std::fs::rename(destination, backup)?;
|
||||
}
|
||||
if let Err(error) = std::fs::rename(staging, destination) {
|
||||
if had_destination {
|
||||
let _ = std::fs::rename(backup, destination);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(had_destination.then(|| backup.to_path_buf()))
|
||||
}
|
||||
|
||||
fn remove_existing_target(path: &Path) -> std::io::Result<()> {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if metadata.file_type().is_symlink() || metadata.is_file() {
|
||||
std::fs::remove_file(path)
|
||||
} else {
|
||||
std::fs::remove_dir_all(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_is_writable(path: &Path) -> bool {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 }
|
||||
}
|
||||
|
||||
fn confirm(message: &str) -> bool {
|
||||
let cancel_button = clash_verge_i18n::t!("launchGuard.cancel");
|
||||
let move_button = clash_verge_i18n::t!("launchGuard.move");
|
||||
let script = format!(
|
||||
"display dialog \"{}\" buttons {{\"{}\", \"{}\"}} default button \"{}\" with icon caution",
|
||||
escape_osascript(message),
|
||||
escape_osascript(cancel_button.as_ref()),
|
||||
escape_osascript(move_button.as_ref()),
|
||||
escape_osascript(move_button.as_ref())
|
||||
);
|
||||
let selected_button = format!("button returned:{move_button}");
|
||||
std::process::Command::new("/usr/bin/osascript")
|
||||
.args(["-e", &script])
|
||||
.output()
|
||||
.is_ok_and(|output| {
|
||||
output.status.success() && String::from_utf8_lossy(&output.stdout).contains(&selected_button)
|
||||
})
|
||||
}
|
||||
|
||||
fn show_message(message: &str) {
|
||||
let ok_button = clash_verge_i18n::t!("launchGuard.ok");
|
||||
let script = format!(
|
||||
"display dialog \"{}\" buttons {{\"{}\"}} default button \"{}\" with icon caution",
|
||||
escape_osascript(message),
|
||||
escape_osascript(ok_button.as_ref()),
|
||||
escape_osascript(ok_button.as_ref())
|
||||
);
|
||||
let _ = std::process::Command::new("/usr/bin/osascript")
|
||||
.args(["-e", &script])
|
||||
.status();
|
||||
}
|
||||
|
||||
fn escape_osascript(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LaunchLocation, MoveDecision, activate_staged_bundle, current_user_home, evaluate_install_location_with_roots,
|
||||
move_decision,
|
||||
};
|
||||
use std::{ffi::CStr, os::unix::ffi::OsStrExt as _};
|
||||
|
||||
fn executable(bundle: &std::path::Path) -> anyhow::Result<std::path::PathBuf> {
|
||||
let executable = bundle.join("Contents/MacOS/clash-verge");
|
||||
let parent = executable
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("test executable has no parent"))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
std::fs::write(&executable, b"app")?;
|
||||
Ok(executable)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_allowed_roots_and_nested_bundles_are_accepted() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-{}", std::process::id()));
|
||||
let home = root.join("home");
|
||||
let system = root.join("Applications");
|
||||
let user = home.join("Applications");
|
||||
let system_exe = executable(&system.join("Tools/Clash Verge.app"))?;
|
||||
let user_exe = executable(&user.join("Network/Clash Verge.app"))?;
|
||||
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&system_exe, &home, &system),
|
||||
LaunchLocation::Allowed { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&user_exe, &home, &system),
|
||||
LaunchLocation::Allowed { .. }
|
||||
));
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_staged_activation_preserves_existing_application() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-swap-{}", std::process::id()));
|
||||
let destination = root.join("Clash Verge.app");
|
||||
let staging = root.join(".Clash Verge.app.installing");
|
||||
let backup = root.join(".Clash Verge.app.backup");
|
||||
std::fs::create_dir_all(&destination)?;
|
||||
std::fs::write(destination.join("old"), b"old")?;
|
||||
std::fs::create_dir_all(&staging)?;
|
||||
std::fs::write(staging.join("new"), b"new")?;
|
||||
std::fs::write(&backup, b"blocks rename")?;
|
||||
|
||||
let Err(error) = activate_staged_bundle(&staging, &destination, &backup) else {
|
||||
anyhow::bail!("occupied backup path must fail without deleting destination");
|
||||
};
|
||||
|
||||
assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
|
||||
assert_eq!(std::fs::read(destination.join("old"))?, b"old");
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn canonicalization_accepts_symlink_in_and_rejects_symlink_escape() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-links-{}", std::process::id()));
|
||||
let home = root.join("home");
|
||||
let system = root.join("Applications");
|
||||
let downloads = home.join("Downloads");
|
||||
std::fs::create_dir_all(&downloads)?;
|
||||
let allowed_bundle = system.join("Clash Verge.app");
|
||||
let allowed_exe = executable(&allowed_bundle)?;
|
||||
let link_in = downloads.join("Clash Verge.app");
|
||||
symlink(&allowed_bundle, &link_in)?;
|
||||
let escaped_bundle = downloads.join("Escaped.app");
|
||||
executable(&escaped_bundle)?;
|
||||
std::fs::create_dir_all(&system)?;
|
||||
let link_out = system.join("Escaped.app");
|
||||
symlink(&escaped_bundle, &link_out)?;
|
||||
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&link_in.join("Contents/MacOS/clash-verge"), &home, &system),
|
||||
LaunchLocation::Allowed { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&link_out.join("Contents/MacOS/clash-verge"), &home, &system),
|
||||
LaunchLocation::Movable { .. }
|
||||
));
|
||||
assert!(allowed_exe.is_file());
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn allowed_root_symlink_escape_is_not_authorized() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("launch-guard-root-link-{}", std::process::id()));
|
||||
let home = root.join("home");
|
||||
let downloads = home.join("Downloads");
|
||||
let system = root.join("SystemApplications");
|
||||
std::fs::create_dir_all(&downloads)?;
|
||||
std::fs::create_dir_all(&system)?;
|
||||
symlink(&downloads, home.join("Applications"))?;
|
||||
let escaped_exe = executable(&home.join("Applications/Clash Verge.app"))?;
|
||||
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(&escaped_exe, &home, &system),
|
||||
LaunchLocation::Movable { .. }
|
||||
));
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_home_matches_effective_user_database_entry() -> anyhow::Result<()> {
|
||||
let home = current_user_home()?;
|
||||
let passwd = unsafe { libc::getpwuid(libc::geteuid()) };
|
||||
if passwd.is_null() {
|
||||
anyhow::bail!("effective user has no password database entry");
|
||||
}
|
||||
let expected = unsafe { CStr::from_ptr((*passwd).pw_dir) };
|
||||
assert_eq!(home.as_os_str().as_bytes(), expected.to_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translocation_and_missing_bundle_are_rejected_before_side_effects() {
|
||||
let translocated =
|
||||
std::path::Path::new("/private/var/folders/AppTranslocation/Clash Verge.app/Contents/MacOS/clash-verge");
|
||||
assert_eq!(
|
||||
evaluate_install_location_with_roots(
|
||||
translocated,
|
||||
std::path::Path::new("/Users/test"),
|
||||
std::path::Path::new("/Applications")
|
||||
),
|
||||
LaunchLocation::Translocated
|
||||
);
|
||||
assert!(matches!(
|
||||
evaluate_install_location_with_roots(
|
||||
std::path::Path::new("/Users/test/Downloads/clash-verge"),
|
||||
std::path::Path::new("/Users/test"),
|
||||
std::path::Path::new("/Applications")
|
||||
),
|
||||
LaunchLocation::Rejected { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mover_decision_prefers_system_then_user_and_never_moves_translocation() {
|
||||
assert_eq!(move_decision(false, true, false), MoveDecision::SystemApplications);
|
||||
assert_eq!(move_decision(false, false, false), MoveDecision::UserApplications);
|
||||
assert_eq!(move_decision(true, true, false), MoveDecision::ManualInstructions);
|
||||
assert_eq!(move_decision(false, true, true), MoveDecision::ConfirmReplacement);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ pub mod help;
|
||||
pub mod init;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos_launch_guard;
|
||||
pub mod network;
|
||||
pub mod notification;
|
||||
pub mod resolve;
|
||||
|
||||
@@ -9,8 +9,7 @@ use crate::{
|
||||
handle::Handle,
|
||||
hotkey::Hotkey,
|
||||
logger::Logger,
|
||||
service::{SERVICE_MANAGER, ServiceManager, is_service_ipc_path_exists},
|
||||
sysopt,
|
||||
service::{SERVICE_MANAGER, ServiceManager},
|
||||
tray::Tray,
|
||||
},
|
||||
feat,
|
||||
@@ -52,6 +51,7 @@ pub fn resolve_setup_async() {
|
||||
#[cfg(target_os = "macos")]
|
||||
resolve_dock_show().await;
|
||||
init_startup_script().await;
|
||||
init_service_manager().await;
|
||||
let config_initialized = init_verge_config_before_window().await;
|
||||
init_window().await;
|
||||
init_resources().await;
|
||||
@@ -64,10 +64,7 @@ pub fn resolve_setup_async() {
|
||||
Config::verify_config_initialization().await;
|
||||
|
||||
let core_init = AsyncHandler::spawn(|| async {
|
||||
init_service_manager().await;
|
||||
init_core_manager().await;
|
||||
init_system_proxy().await;
|
||||
init_system_proxy_guard().await;
|
||||
});
|
||||
|
||||
let _ = futures::join!(
|
||||
@@ -87,7 +84,6 @@ pub fn resolve_setup_async() {
|
||||
}
|
||||
|
||||
pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
|
||||
sysopt::Sysopt::global().reset_sysproxy().await?;
|
||||
CoreManager::global().stop_core().await?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -152,7 +148,7 @@ async fn init_silent_updater() {
|
||||
// - macOS/Linux: binary is replaced, we restart the app
|
||||
if SilentUpdater::global().try_install_on_startup(app_handle).await {
|
||||
logging!(info, Type::Setup, "Update installed at startup, restarting...");
|
||||
app_handle.restart();
|
||||
feat::restart_app().await;
|
||||
}
|
||||
|
||||
// No pending install — start background check/download loop
|
||||
@@ -190,23 +186,20 @@ pub(super) async fn init_verge_config_before_window() -> bool {
|
||||
|
||||
pub(super) async fn init_service_manager() {
|
||||
clash_verge_service_ipc::set_config(Some(ServiceManager::config())).await;
|
||||
if is_service_ipc_path_exists() && SERVICE_MANAGER.init().await.is_ok() {
|
||||
logging_error!(Type::Setup, SERVICE_MANAGER.refresh().await);
|
||||
|
||||
SERVICE_MANAGER.detect_startup_status().await;
|
||||
}
|
||||
|
||||
pub(super) async fn init_core_manager() -> bool {
|
||||
match CoreManager::global().init().await {
|
||||
Ok(initialized) => initialized,
|
||||
Err(error) => {
|
||||
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn init_core_manager() {
|
||||
logging_error!(Type::Setup, CoreManager::global().init().await);
|
||||
}
|
||||
|
||||
pub(super) async fn init_system_proxy() {
|
||||
logging_error!(Type::Setup, sysopt::Sysopt::global().update_sysproxy().await);
|
||||
}
|
||||
|
||||
pub(super) async fn init_system_proxy_guard() {
|
||||
sysopt::Sysopt::global().refresh_guard().await;
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_tray_menu() {
|
||||
logging_error!(Type::Setup, Tray::global().update_part().await);
|
||||
}
|
||||
|
||||
+545
-59
@@ -1,92 +1,218 @@
|
||||
use super::resolve;
|
||||
use crate::{
|
||||
cmd::is_port_in_use,
|
||||
config::{Config, DEFAULT_PAC, IVerge},
|
||||
config::{Config, DEFAULT_PAC},
|
||||
module::lightweight,
|
||||
process::AsyncHandler,
|
||||
utils::window_manager::WindowManager,
|
||||
utils::{dirs, window_manager::WindowManager},
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::ClientBuilder;
|
||||
use smartstring::alias::String;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
use warp::Filter as _;
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
const INSTANCE_TOKEN_HEADER: &str = "x-instance-token";
|
||||
const INSTANCE_RECORD_FILE: &str = "singleton-instance.json";
|
||||
const INSTANCE_LOCK_FILE: &str = "singleton-instance.lock";
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct QueryParam {
|
||||
param: String,
|
||||
}
|
||||
|
||||
// 关闭 embedded server 的信号发送端
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct InstanceRecord {
|
||||
port: u16,
|
||||
token: String,
|
||||
}
|
||||
|
||||
static SHUTDOWN_SENDER: OnceCell<Mutex<Option<oneshot::Sender<()>>>> = OnceCell::new();
|
||||
static EMBEDDED_PORT: OnceCell<u16> = OnceCell::new();
|
||||
static INSTANCE_LOCK: OnceCell<std::fs::File> = OnceCell::new();
|
||||
const PAC_INITIAL_AVAILABLE: bool = false;
|
||||
static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE);
|
||||
static COMMANDS_READY: AtomicBool = AtomicBool::new(false);
|
||||
#[cfg(feature = "verge-dev")]
|
||||
static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// check whether there is already exists
|
||||
pub async fn check_singleton() -> Result<()> {
|
||||
let port = IVerge::get_singleton_port();
|
||||
if is_port_in_use(port) {
|
||||
let client = ClientBuilder::new().timeout(Duration::from_millis(500)).build()?;
|
||||
// 需要确保 Send
|
||||
#[allow(clippy::needless_collect)]
|
||||
let argvs: Vec<std::string::String> = std::env::args().collect();
|
||||
if argvs.len() > 1 {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let record_path = instance_record_path()?;
|
||||
let lock = open_instance_lock(&record_path.with_file_name(INSTANCE_LOCK_FILE))?;
|
||||
if !try_lock_instance(&lock)? {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(20);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Ok(record) = read_instance_record(&record_path)
|
||||
&& notify_existing_instance(&record).await
|
||||
{
|
||||
let param = argvs[1].as_str();
|
||||
if param.starts_with("clash:") {
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/commands/scheme?param={param}"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
bail!("app exists");
|
||||
}
|
||||
} else {
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/commands/visible"))
|
||||
.send()
|
||||
.await?;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
logging!(error, Type::Window, "failed to setup singleton listen server");
|
||||
bail!("app exists");
|
||||
bail!("another app instance is starting");
|
||||
}
|
||||
|
||||
let preferred = read_instance_record(&record_path).ok().map(|record| record.port);
|
||||
let listener = bind_primary_listener(preferred).await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let record = InstanceRecord {
|
||||
port,
|
||||
token: random_token()?,
|
||||
};
|
||||
write_instance_record(&record_path, &record)?;
|
||||
INSTANCE_LOCK
|
||||
.set(lock)
|
||||
.map_err(|_| anyhow::anyhow!("singleton lock is already initialized"))?;
|
||||
let _ = EMBEDDED_PORT.set(port);
|
||||
start_embedded_server(listener, record.token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The embed server only be used to implement singleton process
|
||||
/// maybe it can be used as pac server later
|
||||
pub fn embed_server() {
|
||||
async fn bind_primary_listener(preferred: Option<u16>) -> Result<tokio::net::TcpListener> {
|
||||
let preferred_port = preferred.unwrap_or(0);
|
||||
match tokio::net::TcpListener::bind(std::net::SocketAddrV4::new(
|
||||
std::net::Ipv4Addr::LOCALHOST,
|
||||
preferred_port,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(listener) => Ok(listener),
|
||||
Err(_) if preferred.is_some() => {
|
||||
Ok(tokio::net::TcpListener::bind(std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 0)).await?)
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn notify_existing_instance(record: &InstanceRecord) -> bool {
|
||||
let client = match ClientBuilder::new().timeout(Duration::from_millis(500)).build() {
|
||||
Ok(client) => client,
|
||||
Err(_) => return false,
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let request = if let Some(arg) = std::env::args().nth(1).as_deref() {
|
||||
if arg.starts_with("clash:") {
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{}/commands/scheme", record.port))
|
||||
.query(&[("param", arg)])
|
||||
} else {
|
||||
client.get(format!("http://127.0.0.1:{}/commands/visible", record.port))
|
||||
}
|
||||
} else {
|
||||
client.get(format!("http://127.0.0.1:{}/commands/visible", record.port))
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let request = client.get(format!("http://127.0.0.1:{}/commands/visible", record.port));
|
||||
|
||||
request
|
||||
.header(INSTANCE_TOKEN_HEADER, &record.token)
|
||||
.send()
|
||||
.await
|
||||
.is_ok_and(|response| response.status().is_success())
|
||||
}
|
||||
|
||||
fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp::Rejection> + Clone {
|
||||
warp::header::optional::<String>(INSTANCE_TOKEN_HEADER)
|
||||
.and_then(move |provided: Option<String>| {
|
||||
let token = token.clone();
|
||||
async move {
|
||||
if provided.as_deref() == Some(token.as_str()) {
|
||||
Ok::<(), warp::Rejection>(())
|
||||
} else {
|
||||
Err(warp::reject())
|
||||
}
|
||||
}
|
||||
})
|
||||
.untuple_one()
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
fn dev_quit_route<F>(
|
||||
token: String,
|
||||
commands_ready: &'static AtomicBool,
|
||||
quit_requested: &'static AtomicBool,
|
||||
trigger: F,
|
||||
) -> impl warp::Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
|
||||
where
|
||||
F: Fn() + Clone + Send + Sync + 'static,
|
||||
{
|
||||
instance_auth(token)
|
||||
.and(warp::path!("commands" / "dev" / "quit"))
|
||||
.and(warp::post())
|
||||
.and_then(move || {
|
||||
let trigger = trigger.clone();
|
||||
async move {
|
||||
if !commands_ready.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(warp::reply::with_status(
|
||||
String::from("starting"),
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
if quit_requested
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
trigger();
|
||||
}
|
||||
Ok(warp::reply::with_status(
|
||||
String::from("accepted"),
|
||||
warp::http::StatusCode::ACCEPTED,
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
fn release_dev_quit_latch(quit_requested: &AtomicBool, outcome: clash_verge_signal::ShutdownOutcome) {
|
||||
if matches!(outcome, clash_verge_signal::ShutdownOutcome::Canceled) {
|
||||
quit_requested.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
#[allow(clippy::expect_used)]
|
||||
SHUTDOWN_SENDER
|
||||
.set(Mutex::new(Some(shutdown_tx)))
|
||||
.expect("failed to set shutdown signal for embedded server");
|
||||
let port = IVerge::get_singleton_port();
|
||||
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
|
||||
|
||||
let auth = instance_auth(token.clone());
|
||||
|
||||
let visible = warp::path!("commands" / "visible").and_then(|| async {
|
||||
if !COMMANDS_READY.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(warp::reply::with_status(
|
||||
"starting".to_string(),
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
logging!(info, Type::Window, "检测到从单例模式恢复应用窗口");
|
||||
if !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
} else {
|
||||
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
|
||||
};
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status::<std::string::String>(
|
||||
"ok".to_string(),
|
||||
warp::http::StatusCode::OK,
|
||||
))
|
||||
if crate::APP_HANDLE.get().is_some() {
|
||||
if !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
} else {
|
||||
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
|
||||
}
|
||||
}
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
});
|
||||
|
||||
let pac = warp::path!("commands" / "pac").and_then(|| async move {
|
||||
if !PAC_AVAILABLE.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(
|
||||
warp::http::Response::builder()
|
||||
.status(warp::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||
.body("PAC endpoint is inactive".to_string())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
let verge_config = Config::verge().await;
|
||||
let clash_config = Config::clash().await;
|
||||
|
||||
let verge_data = verge_config.data_arc();
|
||||
let clash_data = clash_config.data_arc();
|
||||
|
||||
let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC);
|
||||
|
||||
let pac_port = verge_data
|
||||
.verge_mixed_port
|
||||
.unwrap_or_else(|| clash_data.get_mixed_port());
|
||||
@@ -99,25 +225,31 @@ pub fn embed_server() {
|
||||
)
|
||||
});
|
||||
|
||||
// Use map instead of and_then to avoid Send issues
|
||||
let scheme = warp::path!("commands" / "scheme")
|
||||
.and(warp::query::<QueryParam>())
|
||||
.and_then(|query: QueryParam| async move {
|
||||
if !COMMANDS_READY.load(Ordering::Acquire) {
|
||||
return Ok::<_, warp::Rejection>(warp::reply::with_status(
|
||||
"starting".to_string(),
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
AsyncHandler::spawn(|| async move {
|
||||
logging_error!(Type::Setup, resolve::resolve_scheme(&query.param).await);
|
||||
});
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status::<std::string::String>(
|
||||
"ok".to_string(),
|
||||
warp::http::StatusCode::OK,
|
||||
))
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
});
|
||||
|
||||
let commands = visible.or(scheme).or(pac);
|
||||
|
||||
let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac);
|
||||
#[cfg(feature = "verge-dev")]
|
||||
let commands = commands.or(dev_quit_route(token, &COMMANDS_READY, &DEV_QUIT_REQUESTED, || {
|
||||
AsyncHandler::spawn(|| async {
|
||||
release_dev_quit_latch(&DEV_QUIT_REQUESTED, crate::feat::quit().await);
|
||||
});
|
||||
}));
|
||||
AsyncHandler::spawn(move || async move {
|
||||
warp::serve(commands)
|
||||
.bind(([127, 0, 0, 1], port))
|
||||
.await
|
||||
.incoming(listener)
|
||||
.graceful(async {
|
||||
shutdown_rx.await.ok();
|
||||
})
|
||||
@@ -126,6 +258,31 @@ pub fn embed_server() {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_pac_available(available: bool) {
|
||||
PAC_AVAILABLE.store(available, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn set_commands_ready() {
|
||||
COMMANDS_READY.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn embed_server() {
|
||||
if EMBEDDED_PORT.get().is_none() {
|
||||
logging!(
|
||||
error,
|
||||
Type::Window,
|
||||
"embedded server was not reserved during singleton check"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn embedded_server_port() -> Result<u16> {
|
||||
EMBEDDED_PORT
|
||||
.get()
|
||||
.copied()
|
||||
.context("embedded server port is not initialized")
|
||||
}
|
||||
|
||||
pub fn shutdown_embedded_server() {
|
||||
logging!(info, Type::Window, "shutting down embedded server");
|
||||
if let Some(sender) = SHUTDOWN_SENDER.get()
|
||||
@@ -134,3 +291,332 @@ pub fn shutdown_embedded_server() {
|
||||
sender.send(()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn instance_record_path() -> Result<PathBuf> {
|
||||
Ok(dirs::preinit_app_data_dir()?.join(INSTANCE_RECORD_FILE))
|
||||
}
|
||||
|
||||
fn open_instance_lock(path: &Path) -> Result<std::fs::File> {
|
||||
let parent = path.parent().context("singleton lock has no parent")?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::core::owner_identity::open_or_create_private_current_user_file(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).write(true).create(true);
|
||||
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||||
|
||||
let file = options.open(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
bail!("singleton lock is not an ordinary file");
|
||||
}
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(file)
|
||||
}
|
||||
}
|
||||
|
||||
fn try_lock_instance(file: &std::fs::File) -> Result<bool> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsRawFd as _;
|
||||
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if result == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error
|
||||
.raw_os_error()
|
||||
.is_some_and(|code| code == libc::EWOULDBLOCK || code == libc::EAGAIN)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error).context("failed to lock singleton file")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
use windows_sys::Win32::Foundation::{ERROR_LOCK_VIOLATION, GetLastError};
|
||||
use windows_sys::Win32::Storage::FileSystem::LockFile;
|
||||
let locked = unsafe { LockFile(file.as_raw_handle(), 0, 0, u32::MAX, u32::MAX) };
|
||||
if locked != 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
if unsafe { GetLastError() } == ERROR_LOCK_VIOLATION {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(std::io::Error::last_os_error()).context("failed to lock singleton file")
|
||||
}
|
||||
}
|
||||
|
||||
fn random_token() -> Result<String> {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes)?;
|
||||
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
fn read_instance_record(path: &Path) -> Result<InstanceRecord> {
|
||||
#[cfg(windows)]
|
||||
let mut file = crate::core::owner_identity::open_private_current_user_file(path)?;
|
||||
#[cfg(unix)]
|
||||
let mut file = {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true).custom_flags(libc::O_NOFOLLOW);
|
||||
options.open(path)?
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
bail!("singleton record is not an ordinary file");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
||||
if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o777 != 0o600 {
|
||||
bail!("singleton record permissions are invalid");
|
||||
}
|
||||
}
|
||||
let mut content = Vec::new();
|
||||
file.read_to_end(&mut content)?;
|
||||
Ok(serde_json::from_slice(&content)?)
|
||||
}
|
||||
|
||||
fn write_instance_record(path: &Path, record: &InstanceRecord) -> Result<()> {
|
||||
let parent = path.parent().context("singleton record has no parent")?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let temporary = path.with_extension(format!("json.tmp-{}-{}", std::process::id(), &random_token()?[..16]));
|
||||
#[cfg(windows)]
|
||||
let mut file = crate::core::owner_identity::create_private_current_user_file(&temporary)?;
|
||||
#[cfg(unix)]
|
||||
let mut file = {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
options.mode(0o600);
|
||||
options.open(&temporary)?
|
||||
};
|
||||
file.write_all(&serde_json::to_vec(record)?)?;
|
||||
file.sync_all()?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
drop(file);
|
||||
replace_file_atomic(&temporary, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> {
|
||||
std::fs::rename(source, destination)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
|
||||
|
||||
let mut source: Vec<u16> = source.as_os_str().encode_wide().collect();
|
||||
source.push(0);
|
||||
let mut destination: Vec<u16> = destination.as_os_str().encode_wide().collect();
|
||||
destination.push(0);
|
||||
if unsafe {
|
||||
MoveFileExW(
|
||||
source.as_ptr(),
|
||||
destination.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to replace singleton record");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InstanceRecord, PAC_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record};
|
||||
#[cfg(unix)]
|
||||
use super::{open_instance_lock, try_lock_instance};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch};
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize},
|
||||
};
|
||||
#[test]
|
||||
fn pac_is_fail_closed_before_core_readiness() {
|
||||
assert!(!PAC_AVAILABLE.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dev_quit_route_requires_private_auth_ready_state_and_dispatches_once() -> anyhow::Result<()> {
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
static REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
READY.store(false, Ordering::Release);
|
||||
REQUESTED.store(false, Ordering::Release);
|
||||
let dispatches = Arc::new(AtomicUsize::new(0));
|
||||
let dispatches_for_route = Arc::clone(&dispatches);
|
||||
let token = "ab".repeat(32);
|
||||
let route = dev_quit_route(token.clone(), &READY, &REQUESTED, move || {
|
||||
dispatches_for_route.fetch_add(1, Ordering::AcqRel);
|
||||
});
|
||||
|
||||
let missing = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(missing.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let wrong_token = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, "cd".repeat(32))
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_token.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let not_ready = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(not_ready.status(), warp::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
let wrong_method = warp::test::request()
|
||||
.method("GET")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_method.status(), warp::http::StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
READY.store(true, Ordering::Release);
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(8));
|
||||
let accepted = futures::future::join_all((0..8).map(|_| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let route = route.clone();
|
||||
let token = token.clone();
|
||||
tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, token)
|
||||
.reply(&route)
|
||||
.await
|
||||
})
|
||||
}))
|
||||
.await;
|
||||
for accepted in accepted {
|
||||
let accepted = accepted?;
|
||||
assert_eq!(accepted.status(), warp::http::StatusCode::ACCEPTED);
|
||||
}
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 1);
|
||||
|
||||
release_dev_quit_latch(&REQUESTED, clash_verge_signal::ShutdownOutcome::Canceled);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
let retry = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(retry.status(), warp::http::StatusCode::ACCEPTED);
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_record_supplies_the_next_primarys_preferred_port() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-sticky-port-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let reservation = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = reservation.local_addr()?.port();
|
||||
drop(reservation);
|
||||
write_instance_record(
|
||||
&path,
|
||||
&InstanceRecord {
|
||||
port: preferred_port,
|
||||
token: "stale-token-is-not-authority".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let hint = read_instance_record(&path).ok().map(|record| record.port);
|
||||
let listener = bind_primary_listener(hint).await?;
|
||||
assert_eq!(listener.local_addr()?.port(), preferred_port);
|
||||
drop(listener);
|
||||
super::shutdown_embedded_server();
|
||||
assert!(path.is_file(), "clean shutdown must retain the private port hint");
|
||||
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn occupied_preferred_port_falls_back_without_using_stale_authority() -> anyhow::Result<()> {
|
||||
let occupied = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = occupied.local_addr()?.port();
|
||||
|
||||
let fallback = bind_primary_listener(Some(preferred_port)).await?;
|
||||
assert_ne!(fallback.local_addr()?.port(), preferred_port);
|
||||
assert!(fallback.local_addr()?.ip().is_loopback());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_record_is_private_and_round_trips() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("singleton-record-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let record = InstanceRecord {
|
||||
port: 42_123,
|
||||
token: "secret".to_string(),
|
||||
};
|
||||
|
||||
write_instance_record(&path, &record)?;
|
||||
|
||||
assert_eq!(read_instance_record(&path)?, record);
|
||||
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_lock_allows_only_one_primary() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-lock-{}", std::process::id()));
|
||||
let path = root.join("instance.lock");
|
||||
let first = open_instance_lock(&path)?;
|
||||
let second = open_instance_lock(&path)?;
|
||||
|
||||
assert!(try_lock_instance(&first)?);
|
||||
assert!(!try_lock_instance(&second)?);
|
||||
drop(first);
|
||||
assert!(try_lock_instance(&second)?);
|
||||
|
||||
drop(second);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user