mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-12 05:32:09 +08:00
refactor: Rewrite and improve sysproxy logic (#7744)
* fix(cmd): preserve anyhow context in coded errors Use alternate error formatting so coded failures retain the full anyhow context chain without changing their wire format. * fix(macos): skip redundant proxy disable writes When both proxy modes are off, read macOS state first and skip networksetup if the OS is already clean. Update the guard target only after a successful write. * feat(proxy): classify system proxy failures Attach stable, actionable proxy failure codes to anyhow chains while preserving the original diagnostic causes. * feat(cmd): return structured command failures Replace encoded error strings with serializable code and detail fields, and update frontend notice consumers to handle the structured shape. * fix(scripts): use the js-yaml namespace import Import js-yaml through its ESM namespace so the i18n cleanup script can access load and dump. * feat(proxy): localize classified proxy failures Map privilege and direct-fallback codes to localized explanations, and let mapped causes outrank generic operation keys in notices. * fix(ui): bound notice text and render failure details Limit resolved notice text without truncating clipboard content, and normalize structured failures embedded in translation parameters. * feat(proxy): persist unseen proxy failures Store unresolved classified failures by code, notify the frontend when the table changes, and show each failure sequence once when a visible window can receive it. * feat(proxy): report tray and hotkey toggle failures Record classified failures from tray and hotkey proxy toggles so they appear when a visible window is available. * fix(ui): restore the proxy switch after failed toggles Refresh frontend proxy state after a failed toggle so the switch reflects the rolled-back configuration. * feat(proxy): stop the guard after repeated failures Report guard outcomes, stop after the failure threshold, and persist the classified failure for the frontend. * fix(macos): preserve proxy during service handoff Stop a sidecar being handed to the service without clearing proxy state that the new owner must inherit. * feat(macos): classify misplaced sidecar state Distinguish a refused sidecar proxy write from a machine that still needs the privileged service installed. * feat(ui): recover misplaced sidecar notices Offer a restart action for macOS proxy failures caused by a ready service while the core still runs as a sidecar. * feat(ui): install the service from proxy notices Let privilege notices install the macOS service, restart the core, and report the runtime mode that was actually reached. * feat(cmd): attach requested proxy operations Carry the requested proxy direction with structured command failures when the originating command knows it. * feat(sysproxy): drain guards before OS access Wait for the local proxy guard to stop before OS reads, service handoff, or final writes, and refuse transitions when it does not drain. * feat(sysproxy): recover partial proxy writes Use write progress to choose whether to restore the old guard or compensate with a disabled proxy state, while preserving actionable errors. * feat(sysproxy): skip already-applied proxy writes Compare macOS proxy snapshots with global, PAC, and disabled targets so matching state avoids redundant privileged writes. * feat(proxy): report background proxy failures Persist and surface classified proxy failures from lifecycle paths that have no direct caller awaiting the result. * feat(ui): move proxy recovery into a dialog Guide users through installing or switching to the service after a refused proxy write, and suppress duplicate toast reports. * fix(ui): close proxy dialog after service recovery Dismiss the recovery dialog once the core reaches service mode, even when the original proxy request remains pending. * feat(ui): retry proxy request after service recovery Replay the requested proxy direction after the core reaches service mode, while leaving incidental restores unchanged. * fix(proxy): refresh state after successful apply Publish the proxy refresh from the shared apply path after releasing the lifecycle lock, and remove the tray-specific duplicate. * feat(ui): show proxy recovery progress Display installation, restart, and proxy-apply stages while keeping the primary action spinner visible during recovery. * feat(ui): route unavailable TUN through service recovery Let the TUN switch request service recovery and restore its requested state after the core restarts in service mode. * fix(ui): avoid duplicate TUN recovery errors Roll back deferred TUN toggles without reporting an error, and publish configuration refreshes from the transaction commit path. * chore: update changelog & pinlock * fix(core): resolve platform-specific clippy warnings * fix(core): detect Windows wildcard TCP conflicts Windows permits a specific exclusive bind beside an existing non-exclusive wildcard listener. Consult the system TCP listener table so the probe reports that overlap as a conflict.
This commit is contained in:
@@ -24,7 +24,9 @@ pub async fn delete_local_backup(filename: String) -> CmdResult<()> {
|
||||
/// Restore local backup
|
||||
#[tauri::command]
|
||||
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
|
||||
feat::restore_local_backup(filename).await.stringify_err()
|
||||
feat::restore_local_backup(filename)
|
||||
.await
|
||||
.map_err(|error| super::proxy_aware_error(&error))
|
||||
}
|
||||
|
||||
/// Import local backup into the app's backup directory
|
||||
|
||||
+20
-25
@@ -1,4 +1,4 @@
|
||||
use super::{CmdResult, WithErrorCode as _, coded_error};
|
||||
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
|
||||
use crate::feat;
|
||||
use crate::utils::{dirs, yaml_emitter};
|
||||
use crate::{
|
||||
@@ -34,7 +34,7 @@ pub async fn get_clash_info() -> CmdResult<ClashInfo> {
|
||||
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
|
||||
feat::patch_clash(&payload)
|
||||
.await
|
||||
.with_error_code("CLASH_CONFIG_UPDATE_FAILED")
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
|
||||
}
|
||||
|
||||
/// 修改Clash模式
|
||||
@@ -59,7 +59,7 @@ pub async fn get_clash_mode() -> CmdResult<Option<String>> {
|
||||
|
||||
/// 切换Clash核心
|
||||
#[tauri::command]
|
||||
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>> {
|
||||
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> {
|
||||
logging!(info, Type::Config, "changing core to {clash_core}");
|
||||
|
||||
match CoreManager::global().change_core(&clash_core).await {
|
||||
@@ -75,18 +75,19 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
let error_msg: String = format!("Core changed but failed to restart: {err}").into();
|
||||
let failed = err.context("core changed but failed to restart");
|
||||
let error_msg: String = format!("{failed:#}").into();
|
||||
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
|
||||
logging!(error, Type::Core, "{error_msg}");
|
||||
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
|
||||
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let error_msg: String = err;
|
||||
let error_msg: String = format!("{err:#}").into();
|
||||
logging!(error, Type::Core, "failed to change core: {error_msg}");
|
||||
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
|
||||
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
|
||||
handle::Handle::notice_message("config_core::change_error", error_msg);
|
||||
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +98,7 @@ pub async fn start_core() -> CmdResult {
|
||||
let result = CoreManager::global()
|
||||
.start_core()
|
||||
.await
|
||||
.with_error_code("CORE_START_FAILED");
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "CORE_START_FAILED"));
|
||||
if result.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
@@ -111,7 +112,7 @@ pub async fn stop_core() -> CmdResult {
|
||||
let result = CoreManager::global()
|
||||
.stop_core()
|
||||
.await
|
||||
.with_error_code("CORE_STOP_FAILED");
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "CORE_STOP_FAILED"));
|
||||
if result.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
@@ -125,7 +126,7 @@ pub async fn restart_core() -> CmdResult {
|
||||
let result = CoreManager::global()
|
||||
.restart_core()
|
||||
.await
|
||||
.with_error_code("CORE_RESTART_FAILED");
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "CORE_RESTART_FAILED"));
|
||||
if result.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
@@ -195,26 +196,20 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
});
|
||||
|
||||
// 应用新配置
|
||||
CoreManager::global()
|
||||
.update_config_checked()
|
||||
.await
|
||||
.stringify_err_log(|err| {
|
||||
let err = format!("Failed to apply config with DNS: {err}");
|
||||
logging!(error, Type::Config, "{err}");
|
||||
})?;
|
||||
CoreManager::global().update_config_checked().await.map_err(|error| {
|
||||
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
|
||||
proxy_aware_error(&error)
|
||||
})?;
|
||||
|
||||
logging!(info, Type::Config, "DNS config successfully applied");
|
||||
} else {
|
||||
// 当关闭DNS设置时,重新生成配置(不加载DNS配置文件)
|
||||
logging!(info, Type::Config, "DNS settings disabled, regenerating config");
|
||||
|
||||
CoreManager::global()
|
||||
.update_config_checked()
|
||||
.await
|
||||
.stringify_err_log(|err| {
|
||||
let err = format!("Failed to apply regenerated config: {err}");
|
||||
logging!(error, Type::Config, "{err}");
|
||||
})?;
|
||||
CoreManager::global().update_config_checked().await.map_err(|error| {
|
||||
logging!(error, Type::Config, "failed to apply the regenerated config: {error:#}");
|
||||
proxy_aware_error(&error)
|
||||
})?;
|
||||
|
||||
logging!(info, Type::Config, "Config regenerated successfully");
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@ pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOu
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
|
||||
feat::save_proxy_ports(settings).await.stringify_err()
|
||||
feat::save_proxy_ports(settings)
|
||||
.await
|
||||
.map_err(|error| super::proxy_aware_error(&error))
|
||||
}
|
||||
|
||||
+240
-11
@@ -1,12 +1,94 @@
|
||||
use anyhow::Result;
|
||||
use smartstring::alias::String;
|
||||
|
||||
pub type CmdResult<T = ()> = Result<T, String>;
|
||||
pub type CmdResult<T = ()> = Result<T, CommandFailure>;
|
||||
|
||||
const USER_ERROR_PREFIX: &str = "CVR_ERROR:";
|
||||
/// Structured command failure returned to the frontend.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct CommandFailure {
|
||||
/// Stable frontend identifier, when classified.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<std::string::String>,
|
||||
/// Human-readable diagnostic detail.
|
||||
pub detail: std::string::String,
|
||||
/// Requested operation when known by the command.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operation: Option<crate::core::notification::FailedOperation>,
|
||||
}
|
||||
|
||||
pub fn coded_error(code: &str, detail: impl std::fmt::Display) -> String {
|
||||
format!("{USER_ERROR_PREFIX}{code}\n{detail}").into()
|
||||
impl CommandFailure {
|
||||
/// A failure with a stable code.
|
||||
pub fn coded(code: &str, detail: impl std::fmt::Display) -> Self {
|
||||
Self {
|
||||
code: Some(code.to_owned()),
|
||||
detail: detail.to_string(),
|
||||
operation: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A failure with nothing to classify it by.
|
||||
pub fn plain(detail: impl std::fmt::Display) -> Self {
|
||||
Self {
|
||||
code: None,
|
||||
detail: detail.to_string(),
|
||||
operation: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the requested operation when known.
|
||||
#[must_use]
|
||||
pub const fn asking_for(mut self, operation: Option<crate::core::notification::FailedOperation>) -> Self {
|
||||
self.operation = operation;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve plain-string failures without inventing a code.
|
||||
impl From<&str> for CommandFailure {
|
||||
fn from(detail: &str) -> Self {
|
||||
Self::plain(detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::string::String> for CommandFailure {
|
||||
fn from(detail: std::string::String) -> Self {
|
||||
Self {
|
||||
code: None,
|
||||
detail,
|
||||
operation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for CommandFailure {
|
||||
fn from(detail: String) -> Self {
|
||||
Self::plain(detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommandFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coded_error(code: &str, detail: impl std::fmt::Display) -> CommandFailure {
|
||||
CommandFailure::coded(code, detail)
|
||||
}
|
||||
|
||||
/// Prefer an actionable proxy classification over the operation's fallback code.
|
||||
pub fn proxy_aware_coded_error(error: &anyhow::Error, fallback: &str) -> CommandFailure {
|
||||
let code =
|
||||
crate::core::proxy_control::SysproxyFailure::from_chain(error).map_or(fallback, |failure| failure.code());
|
||||
CommandFailure::coded(code, format_args!("{error:#}"))
|
||||
}
|
||||
|
||||
/// Preserve uncoded failures unless the chain contains a proxy classification.
|
||||
pub fn proxy_aware_error(error: &anyhow::Error) -> CommandFailure {
|
||||
match crate::core::proxy_control::SysproxyFailure::from_chain(error) {
|
||||
Some(failure) => CommandFailure::coded(failure.code(), format_args!("{error:#}")),
|
||||
None => CommandFailure::plain(format_args!("{error:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Command modules
|
||||
@@ -60,7 +142,7 @@ pub trait WithErrorCode<T> {
|
||||
|
||||
impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
|
||||
fn stringify_err(self) -> CmdResult<T> {
|
||||
self.map_err(|e| e.to_string().into())
|
||||
self.map_err(CommandFailure::plain)
|
||||
}
|
||||
|
||||
fn stringify_err_log<F>(self, log_fn: F) -> CmdResult<T>
|
||||
@@ -68,28 +150,175 @@ impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
|
||||
F: Fn(&str),
|
||||
{
|
||||
self.map_err(|e| {
|
||||
let msg = String::from(e.to_string());
|
||||
log_fn(&msg);
|
||||
msg
|
||||
let failure = CommandFailure::plain(e);
|
||||
log_fn(&failure.detail);
|
||||
failure
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Display> WithErrorCode<T> for Result<T, E> {
|
||||
fn with_error_code(self, code: &str) -> CmdResult<T> {
|
||||
self.map_err(|error| coded_error(code, error))
|
||||
// Alternate formatting expands the full `anyhow` context chain.
|
||||
self.map_err(|error| CommandFailure::coded(code, format_args!("{error:#}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::coded_error;
|
||||
use super::{CommandFailure, WithErrorCode as _, coded_error, proxy_aware_coded_error, proxy_aware_error};
|
||||
use crate::core::proxy_control::SysproxyFailure;
|
||||
use anyhow::Context as _;
|
||||
|
||||
#[test]
|
||||
fn coded_error_preserves_stable_code_and_diagnostic_detail() {
|
||||
assert_eq!(
|
||||
coded_error("CORE_RESTART_FAILED", "connection refused"),
|
||||
"CVR_ERROR:CORE_RESTART_FAILED\nconnection refused"
|
||||
CommandFailure {
|
||||
code: Some("CORE_RESTART_FAILED".to_owned()),
|
||||
detail: "connection refused".to_owned(),
|
||||
operation: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_error_code_preserves_whole_anyhow_context_chain() {
|
||||
let source: anyhow::Result<()> = Err(anyhow::anyhow!("connection refused"))
|
||||
.context("failed to reach the mihomo core")
|
||||
.context("failed to restart the core");
|
||||
|
||||
let failure = source
|
||||
.with_error_code("CORE_RESTART_FAILED")
|
||||
.err()
|
||||
.unwrap_or_else(|| CommandFailure::plain("the call should have failed"));
|
||||
|
||||
assert_eq!(
|
||||
failure,
|
||||
CommandFailure {
|
||||
code: Some("CORE_RESTART_FAILED".to_owned()),
|
||||
detail: "failed to restart the core: failed to reach the mihomo core: connection refused".to_owned(),
|
||||
operation: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_error_code_leaves_plain_display_errors_untouched() {
|
||||
let source: Result<(), &str> = Err("plain failure");
|
||||
|
||||
let failure = source
|
||||
.with_error_code("PROFILE_READ_FAILED")
|
||||
.err()
|
||||
.unwrap_or_else(|| CommandFailure::plain("the call should have failed"));
|
||||
|
||||
assert_eq!(
|
||||
failure,
|
||||
CommandFailure {
|
||||
code: Some("PROFILE_READ_FAILED".to_owned()),
|
||||
detail: "plain failure".to_owned(),
|
||||
operation: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_classification_outranks_the_code_naming_the_operation() {
|
||||
let failure = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges)
|
||||
.context(SysproxyFailure::PrivilegeRequired)
|
||||
.context("failed to restart the core");
|
||||
|
||||
let reported = proxy_aware_coded_error(&failure, "CORE_RESTART_FAILED");
|
||||
|
||||
assert_eq!(reported.code.as_deref(), Some("SYSPROXY_PRIVILEGE_REQUIRED"));
|
||||
assert!(reported.detail.contains("failed to restart the core"), "{reported:?}");
|
||||
assert!(reported.detail.contains("admin privileges"), "{reported:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_classification_the_callers_code_is_used() {
|
||||
let failure = anyhow::anyhow!("connection refused").context("failed to start the core");
|
||||
|
||||
let reported = proxy_aware_coded_error(&failure, "CORE_START_FAILED");
|
||||
|
||||
assert_eq!(
|
||||
reported,
|
||||
CommandFailure {
|
||||
code: Some("CORE_START_FAILED".to_owned()),
|
||||
detail: "failed to start the core: connection refused".to_owned(),
|
||||
operation: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_report_plain_text_stay_uncoded_without_a_classification() {
|
||||
let failure = anyhow::anyhow!("disk full").context("failed to save the configuration");
|
||||
|
||||
let reported = proxy_aware_error(&failure);
|
||||
|
||||
assert_eq!(
|
||||
reported,
|
||||
CommandFailure {
|
||||
code: None,
|
||||
detail: "failed to save the configuration: disk full".to_owned(),
|
||||
operation: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_report_plain_text_still_carry_a_classification() {
|
||||
let failure = anyhow::Error::new(SysproxyFailure::DirectFallback {
|
||||
detail: "service could not set the proxy".to_owned(),
|
||||
})
|
||||
.context("failed to apply the verge patch");
|
||||
|
||||
let reported = proxy_aware_error(&failure);
|
||||
|
||||
assert_eq!(reported.code.as_deref(), Some("SYSPROXY_DIRECT_FALLBACK"));
|
||||
assert!(
|
||||
reported.detail.contains("failed to apply the verge patch"),
|
||||
"{reported:?}"
|
||||
);
|
||||
assert!(
|
||||
reported.detail.contains("service could not set the proxy"),
|
||||
"{reported:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_that_knows_the_request_can_say_which_way_it_went() {
|
||||
let asking_on = CommandFailure::coded("SYSPROXY_PRIVILEGE_REQUIRED", "refused")
|
||||
.asking_for(Some(crate::core::notification::FailedOperation::SystemProxyEnable));
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(asking_on).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::json!({
|
||||
"code": "SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"detail": "refused",
|
||||
"operation": "systemProxyEnable",
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "no").asking_for(None))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
serde_json::json!({ "code": "CORE_START_FAILED", "detail": "no" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_serialised_shape_is_what_the_frontend_reads() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(CommandFailure::coded("CORE_START_FAILED", "connection refused"))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
serde_json::json!({ "code": "CORE_START_FAILED", "detail": "connection refused" })
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(CommandFailure::plain("disk full")).unwrap_or(serde_json::Value::Null),
|
||||
serde_json::json!({ "detail": "disk full" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ async fn restore_original(
|
||||
file_path: &std::path::Path,
|
||||
original_content: &str,
|
||||
original_existed: bool,
|
||||
) -> Result<(), String> {
|
||||
) -> CmdResult<()> {
|
||||
if original_existed {
|
||||
fs::write(file_path, original_content).await.stringify_err()
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{CmdResult, WithErrorCode as _};
|
||||
use super::{CmdResult, proxy_aware_coded_error};
|
||||
use crate::core::{
|
||||
CoreManager,
|
||||
service::{SERVICE_MANAGER, ServiceStatus},
|
||||
@@ -11,12 +11,15 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str)
|
||||
&status,
|
||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||
) {
|
||||
manager.controlled_stop_core_inner().await.with_error_code(error_code)?;
|
||||
manager
|
||||
.controlled_stop_core_inner()
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
||||
}
|
||||
SERVICE_MANAGER
|
||||
.handle_service_status(status)
|
||||
.await
|
||||
.with_error_code(error_code)
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -29,7 +32,7 @@ pub async fn uninstall_service() -> CmdResult {
|
||||
CoreManager::global()
|
||||
.uninstall_service_and_start_sidecar()
|
||||
.await
|
||||
.with_error_code("SERVICE_UNINSTALL_FAILED")
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_UNINSTALL_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -47,5 +50,5 @@ pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
.continue_with_sidecar()
|
||||
.await
|
||||
.with_error_code("SERVICE_SIDECAR_FAILED")
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::core::runstate::{RUN_STATE, RunStateView};
|
||||
use crate::core::{
|
||||
notification::{self, PendingFailure},
|
||||
runstate::{RUN_STATE, RunStateView},
|
||||
};
|
||||
|
||||
/// The whole Run State in one call.
|
||||
///
|
||||
@@ -9,3 +12,9 @@ use crate::core::runstate::{RUN_STATE, RunStateView};
|
||||
pub async fn get_runtime_state() -> Result<RunStateView, String> {
|
||||
Ok(RUN_STATE.settled().await.to_view())
|
||||
}
|
||||
|
||||
/// Return unresolved failures without consuming them.
|
||||
#[tauri::command]
|
||||
pub async fn get_pending_failures() -> Vec<PendingFailure> {
|
||||
notification::pending_failures()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr as _, config::IVerge, feat};
|
||||
use super::{CmdResult, proxy_aware_error};
|
||||
use crate::{
|
||||
cmd::StringifyErr as _,
|
||||
config::IVerge,
|
||||
core::notification::{self, FailedOperation},
|
||||
feat,
|
||||
};
|
||||
use clash_verge_draft::SharedDraft;
|
||||
|
||||
/// 获取Verge配置
|
||||
@@ -11,5 +16,47 @@ pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> {
|
||||
/// 修改Verge配置
|
||||
#[tauri::command]
|
||||
pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
|
||||
feat::patch_verge(&payload, false).await.stringify_err()
|
||||
let operation = system_proxy_operation(&payload);
|
||||
let result = match operation {
|
||||
Some(operation) => notification::asking_for(operation, Box::pin(feat::patch_verge(&payload, false))).await,
|
||||
None => feat::patch_verge(&payload, false).await,
|
||||
};
|
||||
result.map_err(|error| proxy_aware_error(&error).asking_for(operation))
|
||||
}
|
||||
|
||||
/// Extract a system proxy operation from a Verge patch.
|
||||
const fn system_proxy_operation(payload: &IVerge) -> Option<FailedOperation> {
|
||||
match payload.enable_system_proxy {
|
||||
Some(true) => Some(FailedOperation::SystemProxyEnable),
|
||||
Some(false) => Some(FailedOperation::SystemProxyDisable),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::system_proxy_operation;
|
||||
use crate::{config::IVerge, core::notification::FailedOperation};
|
||||
|
||||
#[test]
|
||||
fn only_a_system_proxy_toggle_says_which_way_it_was_going() {
|
||||
let asking_on = IVerge {
|
||||
enable_system_proxy: Some(true),
|
||||
..IVerge::default()
|
||||
};
|
||||
let asking_off = IVerge {
|
||||
enable_system_proxy: Some(false),
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
system_proxy_operation(&asking_on),
|
||||
Some(FailedOperation::SystemProxyEnable)
|
||||
);
|
||||
assert_eq!(
|
||||
system_proxy_operation(&asking_off),
|
||||
Some(FailedOperation::SystemProxyDisable)
|
||||
);
|
||||
assert_eq!(system_proxy_operation(&IVerge::default()), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,5 +46,7 @@ pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
|
||||
/// 从 WebDAV 恢复备份文件
|
||||
#[tauri::command]
|
||||
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
|
||||
feat::restore_webdav_backup(filename).await.stringify_err()
|
||||
feat::restore_webdav_backup(filename)
|
||||
.await
|
||||
.map_err(|error| super::proxy_aware_error(&error))
|
||||
}
|
||||
|
||||
@@ -146,8 +146,9 @@ impl Hotkey {
|
||||
}
|
||||
HotkeyFunction::ToggleSystemProxy => {
|
||||
AsyncHandler::spawn(async move || {
|
||||
let is_proxy_enabled = feat::toggle_system_proxy().await;
|
||||
notify_event(NotificationEvent::SystemProxyToggled(is_proxy_enabled)).await;
|
||||
if let Some(is_proxy_enabled) = feat::toggle_system_proxy().await {
|
||||
notify_event(NotificationEvent::SystemProxyToggled(is_proxy_enabled)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
HotkeyFunction::ToggleTunMode => {
|
||||
|
||||
@@ -435,6 +435,13 @@ fn bind_claim(claim: &BindClaim) -> io::Result<Vec<Socket>> {
|
||||
if claim.transport == ListenerTransport::Tcp {
|
||||
socket.listen(1)?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if claim.transport == ListenerTransport::Tcp
|
||||
&& !claim.address.is_unspecified()
|
||||
&& windows_wildcard_tcp_listener_exists(claim.address, claim.port)?
|
||||
{
|
||||
return Err(io::ErrorKind::AddrInUse.into());
|
||||
}
|
||||
sockets.push(socket);
|
||||
Ok(sockets)
|
||||
}
|
||||
@@ -504,6 +511,107 @@ fn set_exclusive_address_use(socket: &Socket) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_wildcard_tcp_listener_exists(address: IpAddr, port: u16) -> io::Result<bool> {
|
||||
use windows_sys::Win32::{
|
||||
NetworkManagement::IpHelper::{MIB_TCP6ROW_OWNER_PID, MIB_TCPROW_OWNER_PID},
|
||||
Networking::WinSock::{AF_INET, AF_INET6},
|
||||
};
|
||||
|
||||
let family = match address {
|
||||
IpAddr::V4(_) => u32::from(AF_INET),
|
||||
IpAddr::V6(_) => u32::from(AF_INET6),
|
||||
};
|
||||
let table = windows_tcp_listener_table(family)?;
|
||||
match address {
|
||||
IpAddr::V4(_) => Ok(windows_tcp_listener_rows::<MIB_TCPROW_OWNER_PID>(&table)?
|
||||
.iter()
|
||||
.any(|row| row.dwLocalAddr == 0 && windows_tcp_port(row.dwLocalPort) == port)),
|
||||
IpAddr::V6(_) => Ok(windows_tcp_listener_rows::<MIB_TCP6ROW_OWNER_PID>(&table)?
|
||||
.iter()
|
||||
.any(|row| row.ucLocalAddr == [0; 16] && windows_tcp_port(row.dwLocalPort) == port)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_tcp_listener_table(family: u32) -> io::Result<Vec<u32>> {
|
||||
use std::{mem::size_of, ptr::null_mut};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::ERROR_INSUFFICIENT_BUFFER,
|
||||
NetworkManagement::IpHelper::{GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER},
|
||||
};
|
||||
|
||||
let mut byte_count = 0;
|
||||
// SAFETY: a null table is the documented size-query form; `byte_count` is writable.
|
||||
let status = unsafe {
|
||||
GetExtendedTcpTable(
|
||||
null_mut(),
|
||||
&raw mut byte_count,
|
||||
0,
|
||||
family,
|
||||
TCP_TABLE_OWNER_PID_LISTENER,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if status != ERROR_INSUFFICIENT_BUFFER {
|
||||
return Err(io::Error::from_raw_os_error(status.cast_signed()));
|
||||
}
|
||||
|
||||
loop {
|
||||
let word_count = (byte_count as usize).div_ceil(size_of::<u32>());
|
||||
let mut table = vec![0u32; word_count];
|
||||
// SAFETY: `table` has at least `byte_count` writable bytes and the API updates
|
||||
// `byte_count` when the listener table grows between calls.
|
||||
let status = unsafe {
|
||||
GetExtendedTcpTable(
|
||||
table.as_mut_ptr().cast(),
|
||||
&raw mut byte_count,
|
||||
0,
|
||||
family,
|
||||
TCP_TABLE_OWNER_PID_LISTENER,
|
||||
0,
|
||||
)
|
||||
};
|
||||
match status {
|
||||
0 => return Ok(table),
|
||||
ERROR_INSUFFICIENT_BUFFER => {}
|
||||
error => return Err(io::Error::from_raw_os_error(error.cast_signed())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_tcp_listener_rows<Row>(table: &[u32]) -> io::Result<&[Row]> {
|
||||
use std::mem::{align_of, size_of, size_of_val};
|
||||
|
||||
let Some((&row_count, _)) = table.split_first() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Windows returned an empty TCP listener table",
|
||||
));
|
||||
};
|
||||
let row_count = row_count as usize;
|
||||
let required_bytes = row_count
|
||||
.checked_mul(size_of::<Row>())
|
||||
.and_then(|rows| size_of::<u32>().checked_add(rows))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Windows TCP listener table is too large"))?;
|
||||
if required_bytes > size_of_val(table) || align_of::<Row>() > align_of::<u32>() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Windows returned a malformed TCP listener table",
|
||||
));
|
||||
}
|
||||
|
||||
// SAFETY: the size and alignment checks above cover `row_count` complete rows;
|
||||
// the API places them immediately after its u32 entry count.
|
||||
Ok(unsafe { std::slice::from_raw_parts(table.as_ptr().add(1).cast(), row_count) })
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const fn windows_tcp_port(raw_port: u32) -> u16 {
|
||||
u16::from_be(raw_port as u16)
|
||||
}
|
||||
|
||||
const fn conflict_outcome(claim: &BindClaim) -> ListenerProbeOutcome {
|
||||
ListenerProbeOutcome::Conflict {
|
||||
port: claim.port,
|
||||
|
||||
@@ -428,8 +428,8 @@ impl CoreManager {
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(error, Type::Core, "Failed to restart core: {}", err);
|
||||
Err(anyhow!("Failed to apply config: {}", err))
|
||||
logging!(error, Type::Core, "Failed to restart core: {err:#}");
|
||||
Err(err.context("failed to apply the configuration"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use super::{CoreManager, RunningMode};
|
||||
use crate::cmd::StringifyErr as _;
|
||||
use crate::config::{Config, IVerge};
|
||||
use crate::core::handle::Handle;
|
||||
use crate::core::manager::CLASH_LOGGER;
|
||||
use crate::core::proxy_control;
|
||||
use crate::core::service::{SERVICE_MANAGER, ServiceStatus};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, ensure};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
@@ -25,6 +24,22 @@ enum StartupDecision {
|
||||
Wait,
|
||||
}
|
||||
|
||||
/// Proxy action captured before a controlled stop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProxyStopIntent {
|
||||
Clear,
|
||||
/// Let the service overwrite the inherited proxy state.
|
||||
HandOverToService,
|
||||
}
|
||||
|
||||
/// Select whether a controlled stop clears or hands over proxy state.
|
||||
const fn proxy_stop_intent(is_macos: bool, running_mode: RunningMode, decision: StartupDecision) -> ProxyStopIntent {
|
||||
match (is_macos, running_mode, decision) {
|
||||
(true, RunningMode::Sidecar, StartupDecision::Service) => ProxyStopIntent::HandOverToService,
|
||||
_ => ProxyStopIntent::Clear,
|
||||
}
|
||||
}
|
||||
|
||||
const fn startup_decision(status: &ServiceStatus, service_required: bool) -> StartupDecision {
|
||||
match status {
|
||||
ServiceStatus::Ready => StartupDecision::Service,
|
||||
@@ -102,22 +117,31 @@ impl ProxyRestoreExpectation {
|
||||
async fn run_controlled_stop_transition<StopGuard, StopGuardFuture, Clear, ClearFuture, Stop, StopFuture>(
|
||||
is_macos: bool,
|
||||
running_mode: RunningMode,
|
||||
proxy_intent: ProxyStopIntent,
|
||||
stop_guard: StopGuard,
|
||||
clear_proxy: Clear,
|
||||
stop_core: Stop,
|
||||
) -> Result<()>
|
||||
where
|
||||
StopGuard: FnOnce() -> StopGuardFuture,
|
||||
StopGuardFuture: std::future::Future<Output = ()>,
|
||||
StopGuardFuture: std::future::Future<Output = bool>,
|
||||
Clear: FnOnce() -> ClearFuture,
|
||||
ClearFuture: std::future::Future<Output = Result<()>>,
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
StopFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
match running_mode {
|
||||
RunningMode::NotRunning => {}
|
||||
RunningMode::Service if is_macos => stop_guard().await,
|
||||
RunningMode::Service | RunningMode::Sidecar => clear_proxy().await?,
|
||||
match (running_mode, proxy_intent) {
|
||||
(RunningMode::NotRunning, _) => {}
|
||||
// Do not hand the OS to another owner while the old guard may still write.
|
||||
(RunningMode::Sidecar, ProxyStopIntent::HandOverToService) => ensure!(
|
||||
stop_guard().await,
|
||||
"the system proxy guard did not stop in time; not handing the proxy to the service"
|
||||
),
|
||||
(RunningMode::Service, _) if is_macos => ensure!(
|
||||
stop_guard().await,
|
||||
"the system proxy guard did not stop in time; not stopping the service-owned core"
|
||||
),
|
||||
(RunningMode::Service | RunningMode::Sidecar, _) => clear_proxy().await?,
|
||||
}
|
||||
stop_core().await
|
||||
}
|
||||
@@ -197,7 +221,7 @@ async fn run_service_config_replacement_transition<
|
||||
) -> Result<()>
|
||||
where
|
||||
StopGuard: FnOnce() -> StopGuardFuture,
|
||||
StopGuardFuture: std::future::Future<Output = ()>,
|
||||
StopGuardFuture: std::future::Future<Output = bool>,
|
||||
Clear: FnOnce() -> ClearFuture,
|
||||
ClearFuture: std::future::Future<Output = Result<()>>,
|
||||
Stop: FnOnce() -> StopFuture,
|
||||
@@ -208,7 +232,15 @@ where
|
||||
Restore: FnOnce() -> RestoreFuture,
|
||||
RestoreFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
run_controlled_stop_transition(is_macos, RunningMode::Service, stop_guard, clear_proxy, stop_core).await?;
|
||||
run_controlled_stop_transition(
|
||||
is_macos,
|
||||
RunningMode::Service,
|
||||
ProxyStopIntent::Clear,
|
||||
stop_guard,
|
||||
clear_proxy,
|
||||
stop_core,
|
||||
)
|
||||
.await?;
|
||||
run_ready_core_start_transition(start_core, core_is_ready, restore_proxy).await
|
||||
}
|
||||
|
||||
@@ -311,16 +343,16 @@ impl CoreManager {
|
||||
self.apply_proxy_after_start().await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = &result {
|
||||
if let Err(error) = result {
|
||||
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Sidecar startup failed: {error:#}; failed to clear proxy before rollback: {cleanup_error:#}"
|
||||
));
|
||||
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
||||
.context("failed to clear the proxy before rolling back"));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uninstall_service_and_start_sidecar(&self) -> Result<()> {
|
||||
@@ -354,14 +386,14 @@ impl CoreManager {
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(error) = &result
|
||||
&& let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Service uninstall transition failed: {error:#}; failed to clear proxy before rollback: {cleanup_error:#}"
|
||||
));
|
||||
if let Err(error) = result {
|
||||
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
||||
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
||||
.context("failed to clear the proxy before rolling back"));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rollback_failed_sidecar_transition(&self) -> Result<()> {
|
||||
@@ -483,10 +515,16 @@ impl CoreManager {
|
||||
|
||||
/// 调用者须已持有 `lifecycle_lock`。
|
||||
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
|
||||
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
|
||||
}
|
||||
|
||||
/// 调用者须已持有 `lifecycle_lock`。
|
||||
async fn controlled_stop_core_with_intent(&self, proxy_intent: ProxyStopIntent) -> Result<()> {
|
||||
let running_mode = *self.get_running_mode();
|
||||
run_controlled_stop_transition(
|
||||
cfg!(target_os = "macos"),
|
||||
running_mode,
|
||||
proxy_intent,
|
||||
proxy_control::stop_guard,
|
||||
proxy_control::clear,
|
||||
|| self.stop_core_unprepared_inner(),
|
||||
@@ -494,6 +532,15 @@ impl CoreManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Capture handoff intent before stopping; startup state may change afterward.
|
||||
async fn proxy_stop_intent(&self) -> ProxyStopIntent {
|
||||
let running_mode = *self.get_running_mode();
|
||||
if !cfg!(target_os = "macos") || !matches!(running_mode, RunningMode::Sidecar) {
|
||||
return ProxyStopIntent::Clear;
|
||||
}
|
||||
proxy_stop_intent(cfg!(target_os = "macos"), running_mode, self.prepare_startup().await)
|
||||
}
|
||||
|
||||
/// 调用者须已持有 `lifecycle_lock`,且已完成受控代理清理。
|
||||
async fn stop_core_unprepared_inner(&self) -> Result<()> {
|
||||
CLASH_LOGGER.clear_logs().await;
|
||||
@@ -521,8 +568,9 @@ impl CoreManager {
|
||||
// 持锁覆盖 stop+start,避免生命周期操作插入。
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
logging!(info, Type::Core, "Restarting core");
|
||||
let proxy_intent = self.proxy_stop_intent().await;
|
||||
run_core_replacement_transition(
|
||||
|| self.controlled_stop_core_inner(),
|
||||
|| self.controlled_stop_core_with_intent(proxy_intent),
|
||||
|| async {
|
||||
self.start_core_inner().await?;
|
||||
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||
@@ -535,9 +583,10 @@ impl CoreManager {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn change_core(&self, clash_core: &String) -> Result<(), String> {
|
||||
/// Switch the core binary and reload configuration.
|
||||
pub async fn change_core(&self, clash_core: &String) -> Result<()> {
|
||||
if !IVerge::VALID_CLASH_CORES.contains(&clash_core.as_str()) {
|
||||
return Err(format!("Invalid clash core: {}", clash_core).into());
|
||||
anyhow::bail!("invalid clash core: {clash_core}");
|
||||
}
|
||||
|
||||
Config::verge().await.edit_draft(|d| {
|
||||
@@ -546,10 +595,8 @@ impl CoreManager {
|
||||
Config::verge().await.apply();
|
||||
|
||||
let verge_data = Config::verge().await.latest_arc();
|
||||
verge_data.save_file().await.map_err(|e| e.to_string())?;
|
||||
|
||||
self.update_config_checked().await.stringify_err()?;
|
||||
Ok(())
|
||||
verge_data.save_file().await?;
|
||||
self.update_config_checked().await
|
||||
}
|
||||
|
||||
async fn prepare_startup(&self) -> StartupDecision {
|
||||
@@ -761,7 +808,7 @@ impl CoreManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CoreManager, ProxyRestoreExpectation, StartupDecision, can_allow_sidecar_for_session,
|
||||
CoreManager, ProxyRestoreExpectation, ProxyStopIntent, StartupDecision, can_allow_sidecar_for_session,
|
||||
run_controlled_stop_transition, run_core_replacement_transition, run_core_start_transition,
|
||||
run_ready_core_start_transition, run_service_config_replacement_transition, run_sidecar_termination_transition,
|
||||
run_uninstall_transition, should_wait_for_service, startup_decision,
|
||||
@@ -800,10 +847,11 @@ mod tests {
|
||||
true
|
||||
}
|
||||
|
||||
async fn stop_guard(&self, calls: &Mutex<Vec<&'static str>>) {
|
||||
async fn stop_guard(&self, calls: &Mutex<Vec<&'static str>>) -> bool {
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
let _operation = self.operation_lock.lock().await;
|
||||
calls.lock().push("guard-stopped");
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,7 +896,8 @@ mod tests {
|
||||
let result = run_controlled_stop_transition(
|
||||
true,
|
||||
RunningMode::Sidecar,
|
||||
|| future::ready(()),
|
||||
ProxyStopIntent::Clear,
|
||||
|| future::ready(true),
|
||||
|| transition_step(&calls, "proxy_clear", None),
|
||||
|| transition_step(&calls, "core_stop", None),
|
||||
)
|
||||
@@ -858,6 +907,112 @@ mod tests {
|
||||
assert_eq!(&*calls.lock(), &["proxy_clear", "core_stop"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_macos_sidecar_on_its_way_to_the_service_leaves_the_proxy_alone() {
|
||||
use super::proxy_stop_intent;
|
||||
|
||||
assert_eq!(
|
||||
proxy_stop_intent(true, RunningMode::Sidecar, StartupDecision::Service),
|
||||
ProxyStopIntent::HandOverToService
|
||||
);
|
||||
|
||||
for (is_macos, mode, decision) in [
|
||||
(true, RunningMode::Sidecar, StartupDecision::Sidecar),
|
||||
(true, RunningMode::Sidecar, StartupDecision::Wait),
|
||||
(true, RunningMode::Service, StartupDecision::Service),
|
||||
(true, RunningMode::NotRunning, StartupDecision::Service),
|
||||
(false, RunningMode::Sidecar, StartupDecision::Service),
|
||||
] {
|
||||
assert_eq!(
|
||||
proxy_stop_intent(is_macos, mode, decision),
|
||||
ProxyStopIntent::Clear,
|
||||
"{is_macos} {mode:?} {decision:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sidecar_handed_to_the_service_stops_its_guard_and_leaves_the_proxy_alone() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let result = run_controlled_stop_transition(
|
||||
true,
|
||||
RunningMode::Sidecar,
|
||||
ProxyStopIntent::HandOverToService,
|
||||
|| {
|
||||
calls.lock().push("guard-stopped");
|
||||
future::ready(true)
|
||||
},
|
||||
|| transition_step(&calls, "unexpected-proxy-clear", None),
|
||||
|| transition_step(&calls, "core_stop", None),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(&*calls.lock(), &["guard-stopped", "core_stop"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_guard_that_would_not_stop_keeps_the_core_where_it_is() {
|
||||
for (mode, intent) in [
|
||||
(RunningMode::Sidecar, ProxyStopIntent::HandOverToService),
|
||||
(RunningMode::Service, ProxyStopIntent::Clear),
|
||||
] {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let result = run_controlled_stop_transition(
|
||||
true,
|
||||
mode,
|
||||
intent,
|
||||
|| future::ready(false),
|
||||
|| transition_step(&calls, "proxy_clear", None),
|
||||
|| transition_step(&calls, "core_stop", None),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "{mode:?} {intent:?}");
|
||||
assert!(calls.lock().is_empty(), "{mode:?} {intent:?}: {:?}", &*calls.lock());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_sidecar_handed_over_still_stops_even_when_a_user_space_clear_would_fail() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let result = run_controlled_stop_transition(
|
||||
true,
|
||||
RunningMode::Sidecar,
|
||||
ProxyStopIntent::HandOverToService,
|
||||
|| future::ready(true),
|
||||
|| transition_step(&calls, "proxy_clear", Some("proxy_clear")),
|
||||
|| transition_step(&calls, "core_stop", None),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(&*calls.lock(), &["core_stop"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_ordinary_sidecar_stop_still_clears_even_off_macos() {
|
||||
for is_macos in [true, false] {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let result = run_controlled_stop_transition(
|
||||
is_macos,
|
||||
RunningMode::Sidecar,
|
||||
ProxyStopIntent::Clear,
|
||||
|| future::ready(true),
|
||||
|| transition_step(&calls, "proxy_clear", None),
|
||||
|| transition_step(&calls, "core_stop", None),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(&*calls.lock(), &["proxy_clear", "core_stop"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controlled_macos_service_stop_drains_guard_before_one_helper_transaction() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -865,9 +1020,10 @@ mod tests {
|
||||
let result = run_controlled_stop_transition(
|
||||
true,
|
||||
RunningMode::Service,
|
||||
ProxyStopIntent::Clear,
|
||||
|| {
|
||||
calls.lock().push("guard-stopped");
|
||||
future::ready(())
|
||||
future::ready(true)
|
||||
},
|
||||
|| transition_step(&calls, "unexpected-proxy-clear", None),
|
||||
|| transition_step(&calls, "service_stop_with_proxy_clear", None),
|
||||
@@ -904,9 +1060,8 @@ mod tests {
|
||||
let mut stop = Box::pin(run_controlled_stop_transition(
|
||||
true,
|
||||
RunningMode::Service,
|
||||
|| async {
|
||||
coordinator.stop_guard(&calls).await;
|
||||
},
|
||||
ProxyStopIntent::Clear,
|
||||
|| coordinator.stop_guard(&calls),
|
||||
|| transition_step(&calls, "unexpected-proxy-clear", None),
|
||||
|| transition_step(&calls, "service-stop-failed", Some("service-stop-failed")),
|
||||
));
|
||||
@@ -934,7 +1089,8 @@ mod tests {
|
||||
let result = run_controlled_stop_transition(
|
||||
true,
|
||||
RunningMode::Sidecar,
|
||||
|| future::ready(()),
|
||||
ProxyStopIntent::Clear,
|
||||
|| future::ready(true),
|
||||
|| transition_step(&calls, "proxy_clear", Some("proxy_clear")),
|
||||
|| transition_step(&calls, "core_stop", None),
|
||||
)
|
||||
@@ -1037,7 +1193,8 @@ mod tests {
|
||||
let result = run_controlled_stop_transition(
|
||||
false,
|
||||
RunningMode::Service,
|
||||
|| future::ready(()),
|
||||
ProxyStopIntent::Clear,
|
||||
|| future::ready(true),
|
||||
|| transition_step(&calls, "service-proxy-clear", Some("service-proxy-clear")),
|
||||
|| {
|
||||
service_alive.store(false, Ordering::Release);
|
||||
@@ -1139,7 +1296,7 @@ mod tests {
|
||||
is_macos,
|
||||
|| {
|
||||
calls.lock().push("guard-stopped");
|
||||
future::ready(())
|
||||
future::ready(true)
|
||||
},
|
||||
|| transition_step(&calls, "proxy-clear", None),
|
||||
|| {
|
||||
|
||||
@@ -280,10 +280,9 @@ impl CoreManager {
|
||||
}
|
||||
Err(fallback_error) => {
|
||||
crate::config::Config::block_startup_core(&fallback_error);
|
||||
return Err(anyhow::anyhow!(
|
||||
"core startup failed: {start_error:#}; mixed proxy port fallback failed: \
|
||||
{fallback_error:#}"
|
||||
));
|
||||
return Err(start_error.context(format!(
|
||||
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ pub mod hotkey;
|
||||
pub mod listener;
|
||||
pub mod logger;
|
||||
pub mod manager;
|
||||
mod notification;
|
||||
pub mod notification;
|
||||
pub(crate) mod owner_identity;
|
||||
pub mod proxy_control;
|
||||
pub mod proxy_view;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::json;
|
||||
use smartstring::alias::String;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -16,6 +22,236 @@ pub enum FrontendEvent<'a> {
|
||||
ProfileUpdateStarted { uid: &'a String },
|
||||
ProfileUpdateCompleted { uid: &'a String },
|
||||
RunStateChanged { state: serde_json::Value },
|
||||
PendingFailuresChanged,
|
||||
}
|
||||
|
||||
/// Operation associated with a pending failure.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FailedOperation {
|
||||
SystemProxyEnable,
|
||||
SystemProxyDisable,
|
||||
SystemProxyRestore,
|
||||
SystemProxyGuard,
|
||||
}
|
||||
|
||||
impl FailedOperation {
|
||||
/// User requests outrank incidental restores under the same code.
|
||||
const fn outranks(self, other: Self) -> bool {
|
||||
matches!(self, Self::SystemProxyEnable | Self::SystemProxyDisable) && matches!(other, Self::SystemProxyRestore)
|
||||
}
|
||||
|
||||
/// Whether reaching `enabled` resolves this operation's failure.
|
||||
const fn retired_by_system_proxy_success(self, enabled: bool) -> bool {
|
||||
match self {
|
||||
Self::SystemProxyEnable => enabled,
|
||||
Self::SystemProxyDisable => !enabled,
|
||||
Self::SystemProxyRestore => true,
|
||||
// Only replacing or stopping the guard resolves its failure.
|
||||
Self::SystemProxyGuard => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Latest unresolved failure for one stable code.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PendingFailure {
|
||||
/// Stable code used as the table key.
|
||||
pub code: String,
|
||||
/// Full diagnostic context chain.
|
||||
pub detail: String,
|
||||
pub operation: FailedOperation,
|
||||
/// Monotonic identity for repeated failures under the same code.
|
||||
pub sequence: u64,
|
||||
}
|
||||
|
||||
/// Non-destructive pending state, indexed by stable code.
|
||||
#[derive(Debug, Default)]
|
||||
struct FailureTable {
|
||||
entries: Mutex<HashMap<String, PendingFailure>>,
|
||||
sequence: AtomicU64,
|
||||
}
|
||||
|
||||
impl FailureTable {
|
||||
fn record(&self, operation: FailedOperation, code: &str, detail: String) {
|
||||
// Sequence assignment and replacement must share one ordering lock.
|
||||
let mut entries = self.entries.lock();
|
||||
// Preserve an unanswered request over a later restore.
|
||||
let operation = entries
|
||||
.get(code)
|
||||
.filter(|existing| existing.operation.outranks(operation))
|
||||
.map_or(operation, |existing| existing.operation);
|
||||
let failure = PendingFailure {
|
||||
code: code.into(),
|
||||
detail,
|
||||
operation,
|
||||
sequence: self.sequence.fetch_add(1, Ordering::AcqRel).wrapping_add(1),
|
||||
};
|
||||
entries.insert(code.into(), failure);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<PendingFailure> {
|
||||
let mut failures: Vec<PendingFailure> = self.entries.lock().values().cloned().collect();
|
||||
failures.sort_by_key(|failure| failure.sequence);
|
||||
failures
|
||||
}
|
||||
|
||||
/// Return whether a guard failure was retired.
|
||||
fn retire_guard(&self) -> bool {
|
||||
let mut entries = self.entries.lock();
|
||||
let before = entries.len();
|
||||
entries.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
|
||||
before != entries.len()
|
||||
}
|
||||
|
||||
/// Return whether any proxy failure was retired.
|
||||
fn retire_system_proxy(&self, enabled: bool) -> bool {
|
||||
let mut entries = self.entries.lock();
|
||||
let before = entries.len();
|
||||
entries.retain(|_, failure| !failure.operation.retired_by_system_proxy_success(enabled));
|
||||
before != entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod failure_table_tests {
|
||||
use super::{FailedOperation, FailureTable};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_write_nobody_asked_for_is_a_restore() {
|
||||
assert_eq!(super::what_was_asked(), FailedOperation::SystemProxyRestore);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_declared_intent_reaches_the_place_the_write_fails() {
|
||||
let asked = super::asking_for(FailedOperation::SystemProxyEnable, async {
|
||||
tokio::task::yield_now().await;
|
||||
super::what_was_asked()
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(asked, FailedOperation::SystemProxyEnable);
|
||||
assert_eq!(super::what_was_asked(), FailedOperation::SystemProxyRestore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incidental_failure_does_not_take_over_a_request_the_user_is_still_owed() {
|
||||
let table = FailureTable::default();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyEnable,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"asked for it on".into(),
|
||||
);
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"and a restart hit it too".into(),
|
||||
);
|
||||
|
||||
let entries = table.snapshot();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].operation, FailedOperation::SystemProxyEnable);
|
||||
assert_eq!(entries[0].detail, "and a restart hit it too");
|
||||
|
||||
assert!(!table.retire_system_proxy(false));
|
||||
assert!(table.retire_system_proxy(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_still_replaces_an_incidental_failure() {
|
||||
let table = FailureTable::default();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"a".into(),
|
||||
);
|
||||
table.record(
|
||||
FailedOperation::SystemProxyDisable,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"b".into(),
|
||||
);
|
||||
|
||||
let entries = table.snapshot();
|
||||
assert_eq!(entries[0].operation, FailedOperation::SystemProxyDisable);
|
||||
assert!(!table.retire_system_proxy(true));
|
||||
assert!(table.retire_system_proxy(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_incidental_failures_still_replace_each_other() {
|
||||
let table = FailureTable::default();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"first".into(),
|
||||
);
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"second".into(),
|
||||
);
|
||||
|
||||
let entries = table.snapshot();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].detail, "second");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
/// User-requested operation inherited by the current task.
|
||||
static ASKING_FOR: FailedOperation;
|
||||
}
|
||||
|
||||
/// Run `operation` with the user's intent attached to whatever failure it produces.
|
||||
pub async fn asking_for<T>(operation: FailedOperation, work: impl Future<Output = T>) -> T {
|
||||
ASKING_FOR.scope(operation, work).await
|
||||
}
|
||||
|
||||
/// What the caller asked for, or a restore when nobody asked.
|
||||
pub fn what_was_asked() -> FailedOperation {
|
||||
ASKING_FOR
|
||||
.try_with(|operation| *operation)
|
||||
.unwrap_or(FailedOperation::SystemProxyRestore)
|
||||
}
|
||||
|
||||
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
|
||||
|
||||
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
|
||||
PENDING_FAILURES.record(operation, code, detail.into());
|
||||
notify_pending_failures_changed();
|
||||
}
|
||||
|
||||
pub fn has_pending_failure(code: &str) -> bool {
|
||||
PENDING_FAILURES.entries.lock().contains_key(code)
|
||||
}
|
||||
|
||||
/// Return unresolved failures oldest first without clearing them.
|
||||
pub fn pending_failures() -> Vec<PendingFailure> {
|
||||
PENDING_FAILURES.snapshot()
|
||||
}
|
||||
|
||||
/// Retire guard failures after replacement or shutdown.
|
||||
pub fn retire_guard_failures() {
|
||||
if PENDING_FAILURES.retire_guard() {
|
||||
notify_pending_failures_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire failures resolved by the resulting proxy state.
|
||||
pub fn retire_system_proxy_failures(enabled: bool) {
|
||||
if PENDING_FAILURES.retire_system_proxy(enabled) {
|
||||
notify_pending_failures_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Nudge the window to reread pending state; the event carries no payload.
|
||||
fn notify_pending_failures_changed() {
|
||||
let Some(app_handle) = crate::APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
NotificationSystem::send_event(app_handle.clone(), FrontendEvent::PendingFailuresChanged);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -42,6 +278,7 @@ impl NotificationSystem {
|
||||
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
|
||||
FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))),
|
||||
FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)),
|
||||
FrontendEvent::PendingFailuresChanged => ("verge://pending-failures-changed", Ok(serde_json::Value::Null)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +298,151 @@ impl NotificationSystem {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FailedOperation, FailureTable};
|
||||
|
||||
fn table() -> FailureTable {
|
||||
FailureTable::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_does_not_clear() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"refused".into(),
|
||||
);
|
||||
|
||||
assert_eq!(table.snapshot(), table.snapshot());
|
||||
assert_eq!(table.snapshot().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_keeps_only_its_most_recent_failure() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"first".into(),
|
||||
);
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"second".into(),
|
||||
);
|
||||
|
||||
let failures = table.snapshot();
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert_eq!(failures[0].detail, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_failure_under_a_known_code_gets_a_new_sequence() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_DIRECT_FALLBACK",
|
||||
"a".into(),
|
||||
);
|
||||
let first = table.snapshot()[0].sequence;
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_DIRECT_FALLBACK",
|
||||
"b".into(),
|
||||
);
|
||||
|
||||
assert!(table.snapshot()[0].sequence > first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_are_reported_oldest_first() {
|
||||
let table = table();
|
||||
table.record(FailedOperation::SystemProxyRestore, "FIRST", "a".into());
|
||||
table.record(FailedOperation::SystemProxyRestore, "SECOND", "b".into());
|
||||
|
||||
let failures = table.snapshot();
|
||||
let codes: Vec<&str> = failures.iter().map(|failure| failure.code.as_str()).collect();
|
||||
assert_eq!(codes, ["FIRST", "SECOND"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_working_proxy_retires_what_it_disproves() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"refused".into(),
|
||||
);
|
||||
|
||||
assert!(table.retire_system_proxy(false));
|
||||
assert!(table.snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_users_request_is_retired_only_by_reaching_the_state_they_asked_for() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyDisable,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"refused".into(),
|
||||
);
|
||||
|
||||
assert!(!table.retire_system_proxy(true));
|
||||
assert_eq!(table.snapshot().len(), 1);
|
||||
|
||||
assert!(table.retire_system_proxy(false));
|
||||
assert!(table.snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_enable_is_retired_by_the_proxy_coming_on() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyEnable,
|
||||
"SYSPROXY_DIRECT_FALLBACK",
|
||||
"fell back".into(),
|
||||
);
|
||||
|
||||
assert!(!table.retire_system_proxy(false));
|
||||
assert!(table.retire_system_proxy(true));
|
||||
assert!(table.snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_guard_is_retired_by_a_guard_starting_again_and_not_by_an_apply() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyGuard,
|
||||
"SYSPROXY_GUARD_STOPPED",
|
||||
"gave up".into(),
|
||||
);
|
||||
|
||||
assert!(!table.retire_system_proxy(true));
|
||||
assert!(!table.retire_system_proxy(false));
|
||||
assert_eq!(table.snapshot().len(), 1);
|
||||
|
||||
assert!(table.retire_guard());
|
||||
assert!(table.snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retiring_a_guard_leaves_everything_else_alone() {
|
||||
let table = table();
|
||||
table.record(
|
||||
FailedOperation::SystemProxyEnable,
|
||||
"SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
"refused".into(),
|
||||
);
|
||||
|
||||
assert!(!table.retire_guard());
|
||||
assert_eq!(table.snapshot().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retiring_nothing_reports_nothing() {
|
||||
assert!(!table().retire_system_proxy(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge, MixedPort},
|
||||
core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt},
|
||||
core::{
|
||||
CoreManager,
|
||||
manager::RunningMode,
|
||||
notification::{self, FailedOperation},
|
||||
runstate::RUN_STATE,
|
||||
service,
|
||||
sysopt::Sysopt,
|
||||
},
|
||||
process::AsyncHandler,
|
||||
utils::server,
|
||||
utils::{
|
||||
notification::{NotificationEvent, needs_system_notification, notify_event},
|
||||
server,
|
||||
window_manager::WindowManager,
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use anyhow::{Result, ensure};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
|
||||
use std::{
|
||||
@@ -12,7 +23,146 @@ use std::{
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
|
||||
/// Actionable system-proxy failure attached to an `anyhow` chain.
|
||||
///
|
||||
/// Classification remains downcastable while the original error stays available for diagnostics.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SysproxyFailure {
|
||||
/// A local `networksetup` write lacked privileges.
|
||||
PrivilegeRequired,
|
||||
/// The service failed to apply the proxy and attempted direct fallback.
|
||||
DirectFallback { detail: String },
|
||||
/// A ready service can replace the refused sidecar write.
|
||||
SidecarWhileServiceReady,
|
||||
/// The proxy guard stopped after repeated failures.
|
||||
GuardStopped { detail: String },
|
||||
}
|
||||
|
||||
impl SysproxyFailure {
|
||||
/// Stable frontend error code.
|
||||
#[inline]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PrivilegeRequired => "SYSPROXY_PRIVILEGE_REQUIRED",
|
||||
Self::DirectFallback { .. } => "SYSPROXY_DIRECT_FALLBACK",
|
||||
Self::SidecarWhileServiceReady => "SYSPROXY_SIDECAR_WHILE_SERVICE_READY",
|
||||
Self::GuardStopped { .. } => "SYSPROXY_GUARD_STOPPED",
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a classification using `anyhow`'s context-aware downcast.
|
||||
#[inline]
|
||||
pub fn from_chain(error: &anyhow::Error) -> Option<&Self> {
|
||||
error.downcast_ref::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SysproxyFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::PrivilegeRequired => f.write_str("system proxy write requires elevated privileges"),
|
||||
Self::DirectFallback { detail } => {
|
||||
write!(f, "system proxy failed and fell back to direct: {detail}")
|
||||
}
|
||||
Self::SidecarWhileServiceReady => {
|
||||
f.write_str("system proxy write was refused locally while the service was ready")
|
||||
}
|
||||
Self::GuardStopped { detail } => {
|
||||
write!(f, "system proxy guard stopped after repeated failures: {detail}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SysproxyFailure {}
|
||||
|
||||
/// Marker for a partially applied system proxy write.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SystemProxyStateUnknown;
|
||||
|
||||
impl SystemProxyStateUnknown {
|
||||
/// Whether this marker appears in the error chain.
|
||||
#[inline]
|
||||
pub fn is_in(error: &anyhow::Error) -> bool {
|
||||
error.downcast_ref::<Self>().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SystemProxyStateUnknown {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("the system proxy was left partly applied; turning it off was attempted")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SystemProxyStateUnknown {}
|
||||
|
||||
/// Whether the error carries a frontend-mappable classification.
|
||||
#[inline]
|
||||
fn carries_a_mappable_code(error: &anyhow::Error) -> bool {
|
||||
SysproxyFailure::from_chain(error).is_some()
|
||||
}
|
||||
|
||||
/// Combine a cause with rollback failure without hiding actionable classifications.
|
||||
pub fn rollback_failure(caused_by: anyhow::Error, rollback: anyhow::Error) -> anyhow::Error {
|
||||
let state_unknown = SystemProxyStateUnknown::is_in(&caused_by) || SystemProxyStateUnknown::is_in(&rollback);
|
||||
|
||||
let combined = if carries_a_mappable_code(&caused_by) || !carries_a_mappable_code(&rollback) {
|
||||
caused_by.context(format!("the rollback that followed also failed: {rollback:#}"))
|
||||
} else {
|
||||
rollback.context(format!("this rollback followed an earlier failure: {caused_by:#}"))
|
||||
};
|
||||
|
||||
if state_unknown && !SystemProxyStateUnknown::is_in(&combined) {
|
||||
return combined.context(SystemProxyStateUnknown);
|
||||
}
|
||||
combined
|
||||
}
|
||||
|
||||
/// Classify local macOS proxy failures without replacing their source chain.
|
||||
fn classify_local_failure(error: anyhow::Error) -> anyhow::Error {
|
||||
if !was_refused_locally(&error) {
|
||||
return error;
|
||||
}
|
||||
error.context(SysproxyFailure::PrivilegeRequired)
|
||||
}
|
||||
|
||||
/// Whether macOS refused a local proxy write.
|
||||
fn was_refused_locally(error: &anyhow::Error) -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& error.chain().any(|cause| {
|
||||
matches!(
|
||||
cause.downcast_ref::<sysproxy::Error>(),
|
||||
Some(sysproxy::Error::RequiresAdminPrivileges)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Distinguish a misplaced sidecar from a missing privileged service.
|
||||
async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
|
||||
// Preserve any existing, more specific classification.
|
||||
if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) {
|
||||
return error;
|
||||
}
|
||||
let probed = timeout(SERVICE_PROBE_TIMEOUT, RUN_STATE.probe())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|probe| probe.ok())
|
||||
.map(|fresh| fresh.service_usable());
|
||||
error.context(refusal_classification(probed))
|
||||
}
|
||||
|
||||
/// Bound the diagnostic service probe.
|
||||
const SERVICE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Classify a refusal from a fresh service probe only.
|
||||
const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure {
|
||||
match service_usable {
|
||||
Some(true) => SysproxyFailure::SidecarWhileServiceReady,
|
||||
Some(false) | None => SysproxyFailure::PrivilegeRequired,
|
||||
}
|
||||
}
|
||||
|
||||
const LOOPBACK_HOST: &str = "127.0.0.1";
|
||||
const MACOS_DEFAULT_BYPASS: &str =
|
||||
@@ -56,20 +206,33 @@ impl ServiceProxyOperations {
|
||||
self.run_service_operation(operation).await
|
||||
}
|
||||
|
||||
async fn cancel_and_drain<F, Fut>(&self, cancel: F) -> u64
|
||||
/// Invalidate the guard generation and wait for cancellation.
|
||||
async fn cancel_and_drain<F, Fut, T>(&self, cancel: F) -> (u64, T)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let generation = self.invalidate_guard();
|
||||
self.run_service_operation(cancel).await;
|
||||
generation
|
||||
let cancelled = self.run_service_operation(cancel).await;
|
||||
(generation, cancelled)
|
||||
}
|
||||
|
||||
/// Record under the operation lock only for the current guard generation.
|
||||
async fn record_if_current<Record>(&self, captured_generation: u64, record: Record) -> bool
|
||||
where
|
||||
Record: FnOnce() -> bool,
|
||||
{
|
||||
self.run_service_operation(|| async move {
|
||||
guard_generation_is_current(&self.guard_generation, captured_generation) && record()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_guard_request<Mode, ActiveProof, Request, RequestFuture>(
|
||||
&self,
|
||||
captured_generation: u64,
|
||||
captured_proof: &OwnerSessionProof,
|
||||
requested: &MacosProxyConfig,
|
||||
is_service_mode: Mode,
|
||||
active_proof: ActiveProof,
|
||||
request: Request,
|
||||
@@ -87,7 +250,7 @@ impl ServiceProxyOperations {
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
service_apply_result(request().await?)?;
|
||||
service_apply_result(requested, request().await?)?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
@@ -158,10 +321,14 @@ fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Resul
|
||||
}
|
||||
}
|
||||
|
||||
fn service_apply_result(outcome: ProxyApplyOutcome) -> Result<()> {
|
||||
fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome) -> Result<()> {
|
||||
match outcome {
|
||||
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
|
||||
ProxyApplyOutcome::DirectFallback { message } => bail!(message),
|
||||
// Direct fallback satisfies a disable request.
|
||||
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
|
||||
ProxyApplyOutcome::DirectFallback { message } => {
|
||||
Err(SysproxyFailure::DirectFallback { detail: message }.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,30 +344,140 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
|
||||
service_proxy_config(verge, mixed_port, 0)
|
||||
}
|
||||
|
||||
pub async fn apply() -> Result<()> {
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
|
||||
ProxyBackendRoute::Local => Sysopt::global().update_sysproxy().await,
|
||||
ProxyBackendRoute::Service => {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let proxy = current_service_proxy_config(&verge).await?;
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.run_final_service_operation(|| async {
|
||||
service_apply_result(service::set_system_proxy_by_service(&proxy).await?)
|
||||
})
|
||||
.await
|
||||
/// Return whether a classified failure remains pending.
|
||||
pub fn is_reportable(error: &anyhow::Error) -> bool {
|
||||
is_reportable_given(error, notification::has_pending_failure)
|
||||
}
|
||||
|
||||
fn is_reportable_given(error: &anyhow::Error, still_pending: impl FnOnce(&str) -> bool) -> bool {
|
||||
SysproxyFailure::from_chain(error).is_some_and(|failure| still_pending(failure.code()))
|
||||
}
|
||||
|
||||
pub fn report_failure(operation: FailedOperation, error: &anyhow::Error) -> bool {
|
||||
let Some(failure) = SysproxyFailure::from_chain(error) else {
|
||||
return false;
|
||||
};
|
||||
notification::record_failure(operation, failure.code(), format!("{error:#}"));
|
||||
true
|
||||
}
|
||||
|
||||
/// Drain any local guard before writing through the service.
|
||||
async fn apply_through_service<Drain, DrainFuture, Write, WriteFuture>(
|
||||
drain_local_guard: Drain,
|
||||
write: Write,
|
||||
) -> Result<()>
|
||||
where
|
||||
Drain: FnOnce() -> DrainFuture,
|
||||
DrainFuture: Future<Output = bool>,
|
||||
Write: FnOnce() -> WriteFuture,
|
||||
WriteFuture: Future<Output = Result<()>>,
|
||||
{
|
||||
ensure!(
|
||||
drain_local_guard().await,
|
||||
"the system proxy guard did not stop in time; not writing the system proxy through the service"
|
||||
);
|
||||
write().await
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum TableEffect<'a> {
|
||||
Retire { enabled: bool },
|
||||
File(FailedOperation, &'a anyhow::Error),
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// A successful clear does not resolve a failed request to enable the proxy.
|
||||
fn clear_table_effect(result: &Result<()>) -> TableEffect<'_> {
|
||||
match result {
|
||||
Err(error) if SysproxyFailure::from_chain(error).is_some() => {
|
||||
TableEffect::File(notification::what_was_asked(), error)
|
||||
}
|
||||
Ok(()) | Err(_) => TableEffect::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
fn settle_table<Retire, File>(effect: TableEffect<'_>, retire: Retire, file: File)
|
||||
where
|
||||
Retire: FnOnce(bool),
|
||||
File: FnOnce(FailedOperation, &anyhow::Error),
|
||||
{
|
||||
match effect {
|
||||
TableEffect::Retire { enabled } => retire(enabled),
|
||||
TableEffect::File(operation, error) => file(operation, error),
|
||||
TableEffect::Nothing => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn table_effect(result: &Result<()>, enabled: bool) -> TableEffect<'_> {
|
||||
match result {
|
||||
Ok(()) => TableEffect::Retire { enabled },
|
||||
Err(error) if SysproxyFailure::from_chain(error).is_some() => {
|
||||
TableEffect::File(notification::what_was_asked(), error)
|
||||
}
|
||||
Err(_) => TableEffect::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply() -> Result<()> {
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
// Use the pre-apply snapshot when retiring matching failures.
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let enabled = verge.enable_system_proxy.unwrap_or_default();
|
||||
let result = match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
|
||||
ProxyBackendRoute::Local => match Sysopt::global().update_sysproxy().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(classify_local_apply_failure(error).await),
|
||||
},
|
||||
ProxyBackendRoute::Service => {
|
||||
let proxy = current_service_proxy_config(&verge).await?;
|
||||
apply_through_service(
|
||||
|| Sysopt::global().stop_proxy_guard(),
|
||||
|| async {
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.run_final_service_operation(|| async {
|
||||
service_apply_result(&proxy, service::set_system_proxy_by_service(&proxy).await?)
|
||||
})
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
// Settle failures where every proxy-apply path converges.
|
||||
settle_table(
|
||||
table_effect(&result, enabled),
|
||||
notification::retire_system_proxy_failures,
|
||||
|operation, error| {
|
||||
report_failure(operation, error);
|
||||
},
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn clear() -> Result<()> {
|
||||
let result = clear_inner().await;
|
||||
// Clear failures must be filed even when a restart aborts before apply.
|
||||
settle_table(
|
||||
clear_table_effect(&result),
|
||||
notification::retire_system_proxy_failures,
|
||||
|operation, error| {
|
||||
report_failure(operation, error);
|
||||
},
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
async fn clear_inner() -> Result<()> {
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
|
||||
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await,
|
||||
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await.map_err(classify_local_failure),
|
||||
ProxyBackendRoute::Service => {
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.run_final_service_operation(|| async {
|
||||
service_apply_result(service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?)
|
||||
service_apply_result(
|
||||
&MacosProxyConfig::Disabled,
|
||||
service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -208,7 +485,7 @@ pub async fn clear() -> Result<()> {
|
||||
}
|
||||
|
||||
pub async fn refresh_guard() -> Result<()> {
|
||||
let generation = SERVICE_PROXY_OPERATIONS
|
||||
let (generation, _drained) = SERVICE_PROXY_OPERATIONS
|
||||
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
|
||||
.await;
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
@@ -216,12 +493,16 @@ pub async fn refresh_guard() -> Result<()> {
|
||||
proxy_backend_route(cfg!(target_os = "macos"), &running_mode),
|
||||
ProxyBackendRoute::Local
|
||||
) {
|
||||
Sysopt::global().refresh_guard().await;
|
||||
// Retire the warning only after a guard is enforcing.
|
||||
if Sysopt::global().refresh_guard().await {
|
||||
notification::retire_guard_failures();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
if !verge.enable_system_proxy.unwrap_or_default() || !verge.enable_proxy_guard.unwrap_or_default() {
|
||||
notification::retire_guard_failures();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -229,31 +510,71 @@ pub async fn refresh_guard() -> Result<()> {
|
||||
let proof = service::active_service_session()?;
|
||||
let interval = Duration::from_secs(verge.proxy_guard_duration.unwrap_or(30).max(1));
|
||||
AsyncHandler::spawn(move || async move {
|
||||
let mut consecutive_failures = 0u32;
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
match SERVICE_PROXY_OPERATIONS
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&proof,
|
||||
&proxy,
|
||||
|| matches!(*CoreManager::global().get_running_mode(), RunningMode::Service),
|
||||
service::active_service_session,
|
||||
|| service::set_system_proxy_by_service_with_session(&proxy, &proof),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(true) => consecutive_failures = 0,
|
||||
Ok(false) => break,
|
||||
Err(_) => logging!(warn, Type::Core, "failed to refresh system proxy through Service"),
|
||||
Err(error) => {
|
||||
consecutive_failures += 1;
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"failed to refresh system proxy through Service ({}/{}): {:#}",
|
||||
consecutive_failures,
|
||||
GUARD_FAILURES_BEFORE_STOPPING,
|
||||
error
|
||||
);
|
||||
if consecutive_failures >= GUARD_FAILURES_BEFORE_STOPPING {
|
||||
report_guard_stopped(generation, &error).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Retire only after the replacement guard starts successfully.
|
||||
notification::retire_guard_failures();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_guard() {
|
||||
/// Consecutive failures allowed before the guard stops.
|
||||
const GUARD_FAILURES_BEFORE_STOPPING: u32 = 3;
|
||||
|
||||
/// Report that the current guard stopped.
|
||||
async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) {
|
||||
let stopped = anyhow::Error::new(SysproxyFailure::GuardStopped {
|
||||
detail: format!("{error:#}"),
|
||||
});
|
||||
// Do not publish a late failure from a replaced guard.
|
||||
let recorded = SERVICE_PROXY_OPERATIONS
|
||||
.record_if_current(captured_generation, || {
|
||||
report_failure(FailedOperation::SystemProxyGuard, &stopped)
|
||||
})
|
||||
.await;
|
||||
|
||||
if recorded && needs_system_notification(WindowManager::get_main_window_state()) {
|
||||
notify_event(NotificationEvent::SystemProxyGuardStopped).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the local guard and report whether it drained.
|
||||
pub async fn stop_guard() -> bool {
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
|
||||
.await;
|
||||
.await
|
||||
.1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -272,6 +593,15 @@ mod tests {
|
||||
use std::task::Poll;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
/// Proxy state maintained by a running guard.
|
||||
fn guarded_proxy() -> MacosProxyConfig {
|
||||
MacosProxyConfig::Global {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 7897,
|
||||
bypass: "localhost".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn proof(generation: u64, token: &str) -> OwnerSessionProof {
|
||||
OwnerSessionProof {
|
||||
generation,
|
||||
@@ -327,6 +657,7 @@ mod tests {
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
&guarded_proxy(),
|
||||
|| true,
|
||||
|| Ok(active_proof.lock().clone()),
|
||||
|| async {
|
||||
@@ -379,9 +710,11 @@ mod tests {
|
||||
blocker_started.wait().await;
|
||||
|
||||
let rpc_called = AtomicBool::new(false);
|
||||
let guarded = guarded_proxy();
|
||||
let mut guard_request = Box::pin(operations.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
&guarded,
|
||||
|| true,
|
||||
|| Ok(captured_proof.clone()),
|
||||
|| async {
|
||||
@@ -424,9 +757,11 @@ mod tests {
|
||||
};
|
||||
blocker_started.wait().await;
|
||||
|
||||
let guarded = guarded_proxy();
|
||||
let mut guard_request = Box::pin(operations.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
&guarded,
|
||||
|| true,
|
||||
|| Ok(active_proof.lock().clone()),
|
||||
|| async {
|
||||
@@ -453,6 +788,7 @@ mod tests {
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
&guarded_proxy(),
|
||||
|| true,
|
||||
|| Ok(captured_proof.clone()),
|
||||
|| async {
|
||||
@@ -466,6 +802,97 @@ mod tests {
|
||||
assert!(refreshed.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_replaced_guard_does_not_get_to_report_its_last_failure() {
|
||||
let operations = ServiceProxyOperations::new();
|
||||
let generation = operations.invalidate_guard();
|
||||
operations.invalidate_guard();
|
||||
|
||||
assert!(!operations.record_if_current(generation, || true).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_report_waiting_on_the_lock_says_nothing_if_it_is_invalidated_meanwhile() {
|
||||
// The generation check must happen after acquiring the operation lock.
|
||||
let operations = Arc::new(ServiceProxyOperations::new());
|
||||
let generation = operations.invalidate_guard();
|
||||
let blocker_started = Arc::new(Barrier::new(2));
|
||||
let release_blocker = Arc::new(Barrier::new(2));
|
||||
|
||||
let blocker = {
|
||||
let operations = Arc::clone(&operations);
|
||||
let blocker_started = Arc::clone(&blocker_started);
|
||||
let release_blocker = Arc::clone(&release_blocker);
|
||||
tokio::spawn(async move {
|
||||
operations
|
||||
.run_service_operation(|| async {
|
||||
blocker_started.wait().await;
|
||||
release_blocker.wait().await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
blocker_started.wait().await;
|
||||
|
||||
let recorded = AtomicBool::new(false);
|
||||
let mut report = Box::pin(operations.record_if_current(generation, || {
|
||||
recorded.store(true, Ordering::Release);
|
||||
true
|
||||
}));
|
||||
assert!(matches!(futures::poll!(report.as_mut()), Poll::Pending));
|
||||
operations.invalidate_guard();
|
||||
release_blocker.wait().await;
|
||||
|
||||
assert!(blocker.await.is_ok());
|
||||
assert!(!report.await);
|
||||
assert!(!recorded.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_current_guard_still_reports() {
|
||||
let operations = ServiceProxyOperations::new();
|
||||
let generation = operations.invalidate_guard();
|
||||
|
||||
assert!(operations.record_if_current(generation, || true).await);
|
||||
}
|
||||
|
||||
async fn record_service_apply(drained: bool, write_succeeds: bool) -> (Vec<&'static str>, bool) {
|
||||
let calls = Mutex::new(Vec::new());
|
||||
let result = super::apply_through_service(
|
||||
|| {
|
||||
calls.lock().push("drain");
|
||||
async move { drained }
|
||||
},
|
||||
|| {
|
||||
calls.lock().push("write");
|
||||
async move {
|
||||
if write_succeeds {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("the helper refused")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
(calls.into_inner(), result.is_ok())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_service_writes_only_once_the_local_guard_is_gone() {
|
||||
assert_eq!(record_service_apply(true, true).await, (vec!["drain", "write"], true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_write_the_service_refused_is_still_a_failure() {
|
||||
assert_eq!(record_service_apply(true, false).await, (vec!["drain", "write"], false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_local_guard_that_would_not_stop_keeps_root_from_writing() {
|
||||
assert_eq!(record_service_apply(false, true).await, (vec!["drain"], false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_proxy_config_forces_loopback_targets_and_bounds_bypass() {
|
||||
let verge = IVerge {
|
||||
@@ -498,4 +925,323 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
use super::{FailedOperation, SysproxyFailure, classify_local_failure};
|
||||
|
||||
fn wrapped_privilege_failure() -> anyhow::Error {
|
||||
anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges).context("failed to apply the system proxy")
|
||||
}
|
||||
|
||||
fn classified_privilege_failure() -> anyhow::Error {
|
||||
wrapped_privilege_failure().context(SysproxyFailure::PrivilegeRequired)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_classification_survives_the_anyhow_layers_above_it() {
|
||||
let classified = classified_privilege_failure()
|
||||
.context("failed to apply system proxy after start")
|
||||
.context("failed to restart the core");
|
||||
|
||||
assert_eq!(
|
||||
SysproxyFailure::from_chain(&classified).map(SysproxyFailure::code),
|
||||
Some("SYSPROXY_PRIVILEGE_REQUIRED")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifying_keeps_the_original_failure_underneath() {
|
||||
let classified = classified_privilege_failure();
|
||||
|
||||
assert!(
|
||||
classified.chain().any(|cause| matches!(
|
||||
cause.downcast_ref::<sysproxy::Error>(),
|
||||
Some(sysproxy::Error::RequiresAdminPrivileges)
|
||||
)),
|
||||
"the original error must survive classification: {classified:#}"
|
||||
);
|
||||
assert!(format!("{classified:#}").contains("failed to apply the system proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrelated_failure_is_left_alone() {
|
||||
let untouched = classify_local_failure(anyhow::anyhow!("port already in use"));
|
||||
|
||||
assert!(SysproxyFailure::from_chain(&untouched).is_none());
|
||||
assert_eq!(format!("{untouched:#}"), "port already in use");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_refusal_wrapped_in_write_progress_is_still_classified() {
|
||||
let wrapped = anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(0, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
})
|
||||
.context("failed to apply the system proxy");
|
||||
|
||||
let classified = classify_local_failure(wrapped);
|
||||
|
||||
assert!(matches!(
|
||||
SysproxyFailure::from_chain(&classified),
|
||||
Some(SysproxyFailure::PrivilegeRequired)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_refused_write_is_classified_on_macos() {
|
||||
let classified = classify_local_failure(wrapped_privilege_failure());
|
||||
|
||||
assert!(matches!(
|
||||
SysproxyFailure::from_chain(&classified),
|
||||
Some(SysproxyFailure::PrivilegeRequired)
|
||||
));
|
||||
|
||||
assert!(
|
||||
classified.chain().any(|cause| matches!(
|
||||
cause.downcast_ref::<sysproxy::Error>(),
|
||||
Some(sysproxy::Error::RequiresAdminPrivileges)
|
||||
)),
|
||||
"classifying must keep the original failure reachable: {classified:#}"
|
||||
);
|
||||
assert!(format!("{classified:#}").contains("failed to apply the system proxy"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn nothing_is_classified_off_macos() {
|
||||
let classified = classify_local_failure(wrapped_privilege_failure());
|
||||
|
||||
assert!(SysproxyFailure::from_chain(&classified).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_classified_failure_is_worth_recording() {
|
||||
let before = crate::core::notification::pending_failures().len();
|
||||
assert!(!super::report_failure(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
&anyhow::anyhow!("port already in use"),
|
||||
));
|
||||
assert_eq!(crate::core::notification::pending_failures().len(), before);
|
||||
|
||||
assert!(super::report_failure(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
&classified_privilege_failure().context("failed to apply system proxy after start"),
|
||||
));
|
||||
|
||||
let recorded = crate::core::notification::pending_failures();
|
||||
let failure = recorded
|
||||
.iter()
|
||||
.find(|failure| failure.code == "SYSPROXY_PRIVILEGE_REQUIRED")
|
||||
.unwrap_or_else(|| unreachable!("a classified failure must be recorded"));
|
||||
assert!(
|
||||
failure.detail.contains("failed to apply system proxy after start"),
|
||||
"{failure:?}"
|
||||
);
|
||||
assert!(failure.detail.contains("admin privileges"), "{failure:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_only_blames_the_sidecar_when_a_probe_actually_answered() {
|
||||
use super::refusal_classification;
|
||||
|
||||
assert_eq!(
|
||||
refusal_classification(Some(true)),
|
||||
SysproxyFailure::SidecarWhileServiceReady
|
||||
);
|
||||
assert_eq!(refusal_classification(Some(false)), SysproxyFailure::PrivilegeRequired);
|
||||
assert_eq!(refusal_classification(None), SysproxyFailure::PrivilegeRequired);
|
||||
}
|
||||
|
||||
use super::{SystemProxyStateUnknown, is_reportable_given, rollback_failure};
|
||||
|
||||
fn mappable() -> anyhow::Error {
|
||||
anyhow::anyhow!("networksetup refused").context(SysproxyFailure::PrivilegeRequired)
|
||||
}
|
||||
|
||||
fn record_settlement(effect: super::TableEffect<'_>) -> Vec<String> {
|
||||
let events = Mutex::new(Vec::new());
|
||||
super::settle_table(
|
||||
effect,
|
||||
|enabled| events.lock().push(format!("retire:{enabled}")),
|
||||
|operation, _| events.lock().push(format!("file:{operation:?}")),
|
||||
);
|
||||
events.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_effect_reaches_the_table_it_names() {
|
||||
assert_eq!(
|
||||
record_settlement(super::TableEffect::Retire { enabled: true }),
|
||||
vec!["retire:true"]
|
||||
);
|
||||
assert_eq!(
|
||||
record_settlement(super::TableEffect::File(
|
||||
FailedOperation::SystemProxyRestore,
|
||||
&mappable()
|
||||
)),
|
||||
vec!["file:SystemProxyRestore"]
|
||||
);
|
||||
assert!(record_settlement(super::TableEffect::Nothing).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clear_files_its_failure_and_says_nothing_when_it_works() {
|
||||
assert!(matches!(
|
||||
super::clear_table_effect(&Err(mappable())),
|
||||
super::TableEffect::File(FailedOperation::SystemProxyRestore, _)
|
||||
));
|
||||
assert!(matches!(
|
||||
super::clear_table_effect(&Ok(())),
|
||||
super::TableEffect::Nothing
|
||||
));
|
||||
assert!(matches!(
|
||||
super::clear_table_effect(&Err(anyhow::anyhow!("the core was already gone"))),
|
||||
super::TableEffect::Nothing
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_apply_retires_in_the_direction_it_reached() {
|
||||
assert!(matches!(
|
||||
super::table_effect(&Ok(()), true),
|
||||
super::TableEffect::Retire { enabled: true }
|
||||
));
|
||||
assert!(matches!(
|
||||
super::table_effect(&Ok(()), false),
|
||||
super::TableEffect::Retire { enabled: false }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_that_can_be_explained_is_filed_here_as_a_restore() {
|
||||
assert!(matches!(
|
||||
super::table_effect(&Err(mappable()), true),
|
||||
super::TableEffect::File(FailedOperation::SystemProxyRestore, _)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failure_is_filed_as_whatever_the_caller_declared() {
|
||||
let (applied, cleared) = super::notification::asking_for(FailedOperation::SystemProxyEnable, async {
|
||||
let (apply_error, clear_error) = (mappable(), mappable());
|
||||
(
|
||||
format!("{:?}", super::table_effect(&Err(apply_error), true)),
|
||||
format!("{:?}", super::clear_table_effect(&Err(clear_error))),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(applied.contains("File(SystemProxyEnable"), "{applied}");
|
||||
assert!(cleared.contains("File(SystemProxyEnable"), "{cleared}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telling_the_user_to_look_needs_something_there_to_find() {
|
||||
assert!(is_reportable_given(&mappable(), |_| true));
|
||||
assert!(!is_reportable_given(&mappable(), |_| false));
|
||||
assert!(!is_reportable_given(&anyhow::anyhow!("the core exited"), |_| true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_with_nothing_to_say_is_not_filed() {
|
||||
assert!(matches!(
|
||||
super::table_effect(&Err(anyhow::anyhow!("the core exited immediately")), true),
|
||||
super::TableEffect::Nothing
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rollback_that_can_be_explained_becomes_the_source() {
|
||||
let combined = rollback_failure(anyhow::anyhow!("could not write the file"), mappable());
|
||||
|
||||
assert!(SysproxyFailure::from_chain(&combined).is_some());
|
||||
assert!(format!("{combined:#}").contains("could not write the file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_causing_error_stays_the_source_when_it_is_the_one_that_can_be_explained() {
|
||||
let combined = rollback_failure(mappable(), anyhow::anyhow!("could not restore the file"));
|
||||
|
||||
assert!(matches!(
|
||||
SysproxyFailure::from_chain(&combined),
|
||||
Some(SysproxyFailure::PrivilegeRequired)
|
||||
));
|
||||
assert!(format!("{combined:#}").contains("could not restore the file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_marker_does_not_outrank_a_code_but_is_not_lost_to_it() {
|
||||
let combined = rollback_failure(
|
||||
anyhow::anyhow!("half applied").context(SystemProxyStateUnknown),
|
||||
mappable(),
|
||||
);
|
||||
|
||||
assert!(SysproxyFailure::from_chain(&combined).is_some());
|
||||
assert!(SystemProxyStateUnknown::is_in(&combined));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_marker_on_the_source_is_left_where_it_is() {
|
||||
let combined = rollback_failure(
|
||||
mappable().context(SystemProxyStateUnknown),
|
||||
anyhow::anyhow!("could not restore the file"),
|
||||
);
|
||||
|
||||
assert!(SystemProxyStateUnknown::is_in(&combined));
|
||||
assert!(SysproxyFailure::from_chain(&combined).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_both_mappable_the_causing_error_wins() {
|
||||
let combined = rollback_failure(
|
||||
mappable(),
|
||||
anyhow::anyhow!("the service could not set it").context(SysproxyFailure::DirectFallback {
|
||||
detail: "fell back to direct".to_owned(),
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
SysproxyFailure::from_chain(&combined),
|
||||
Some(SysproxyFailure::PrivilegeRequired)
|
||||
));
|
||||
assert!(format!("{combined:#}").contains("fell back to direct"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_neither_mappable_the_causing_error_is_kept() {
|
||||
let combined = rollback_failure(anyhow::anyhow!("persisting failed"), anyhow::anyhow!("rollback failed"));
|
||||
|
||||
assert!(SysproxyFailure::from_chain(&combined).is_none());
|
||||
assert_eq!(
|
||||
format!("{combined:#}"),
|
||||
"the rollback that followed also failed: rollback failed: persisting failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disable_that_ended_up_direct_is_what_was_asked_for() {
|
||||
let outcome = ProxyApplyOutcome::DirectFallback {
|
||||
message: "first attempt failed".to_owned(),
|
||||
};
|
||||
|
||||
assert!(super::service_apply_result(&MacosProxyConfig::Disabled, outcome).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_direct_fallback_reports_that_traffic_may_be_going_direct() {
|
||||
let outcome = ProxyApplyOutcome::DirectFallback {
|
||||
message: "service could not set the proxy".to_owned(),
|
||||
};
|
||||
|
||||
let error = super::service_apply_result(&guarded_proxy(), outcome)
|
||||
.err()
|
||||
.unwrap_or_else(|| anyhow::anyhow!("a fallback outcome must be reported as a failure"));
|
||||
|
||||
assert_eq!(
|
||||
SysproxyFailure::from_chain(&error).map(SysproxyFailure::code),
|
||||
Some("SYSPROXY_DIRECT_FALLBACK")
|
||||
);
|
||||
assert!(format!("{error:#}").contains("service could not set the proxy"));
|
||||
}
|
||||
}
|
||||
|
||||
+793
-33
@@ -1,5 +1,6 @@
|
||||
use crate::{
|
||||
config::{Config, MixedPort},
|
||||
core::proxy_control::SystemProxyStateUnknown,
|
||||
singleton,
|
||||
utils::server,
|
||||
};
|
||||
@@ -34,6 +35,191 @@ const fn proxy_apply_steps(sys_enabled: bool, auto_enabled: bool) -> [ProxyApply
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum guard drain time before OS state is unknown.
|
||||
const GUARD_DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Authoritative OS state after a failed proxy write.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AuthoritativeState {
|
||||
/// No write is known to have landed.
|
||||
Unchanged,
|
||||
/// A partial write left no known target.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Choose state from reliable evidence that a write landed.
|
||||
fn authoritative_state(error: &anyhow::Error, earlier_step_completed: bool) -> AuthoritativeState {
|
||||
// Linux setter success is not reliable write evidence.
|
||||
authoritative_state_from(!cfg!(target_os = "linux"), error, earlier_step_completed)
|
||||
}
|
||||
|
||||
/// Testable form with explicit write-evidence reliability.
|
||||
fn authoritative_state_from(
|
||||
write_evidence_is_reliable: bool,
|
||||
error: &anyhow::Error,
|
||||
earlier_step_completed: bool,
|
||||
) -> AuthoritativeState {
|
||||
if !write_evidence_is_reliable {
|
||||
return AuthoritativeState::Unchanged;
|
||||
}
|
||||
// A completed earlier setter is evidence outside the current error.
|
||||
if earlier_step_completed {
|
||||
return AuthoritativeState::Unknown;
|
||||
}
|
||||
match error.downcast_ref::<sysproxy::Error>() {
|
||||
Some(sysproxy::Error::ProxyWrite { progress, .. }) if !progress.nothing_written() => {
|
||||
AuthoritativeState::Unknown
|
||||
}
|
||||
_ => AuthoritativeState::Unchanged,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum OsProxyState {
|
||||
AlreadyApplied,
|
||||
DifferentOrUnknown,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn target_is_already_in_place(snapshot: &sysproxy::ProxySnapshot, sys: &Sysproxy, auto: &Autoproxy) -> bool {
|
||||
if auto.enable {
|
||||
// PAC writes bypass state too.
|
||||
snapshot.matches_pac(auto) && snapshot.bypass_matches(&sys.bypass)
|
||||
} else if sys.enable {
|
||||
snapshot.matches_global(sys)
|
||||
} else {
|
||||
// Bypass state is inert while every proxy is disabled.
|
||||
snapshot.is_all_disabled()
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat failed reads as different so writes are skipped only with proof.
|
||||
fn classify_os_proxy_state(snapshot: Result<bool>) -> OsProxyState {
|
||||
match snapshot {
|
||||
Ok(true) => OsProxyState::AlreadyApplied,
|
||||
_ => OsProxyState::DifferentOrUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_os_proxy_state(sys: &Sysproxy, auto: &Autoproxy) -> OsProxyState {
|
||||
// Use the per-protocol snapshot rather than the synthesized proxy view.
|
||||
let already_applied = Sysproxy::snapshot()
|
||||
.map(|snapshot| target_is_already_in_place(&snapshot, sys, auto))
|
||||
.map_err(anyhow::Error::from);
|
||||
|
||||
if let Err(err) = &already_applied {
|
||||
logging!(warn, Type::Core, "failed to read OS proxy snapshot: {err:#}");
|
||||
}
|
||||
|
||||
classify_os_proxy_state(already_applied)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn read_os_proxy_state(_sys: &Sysproxy, _auto: &Autoproxy) -> OsProxyState {
|
||||
classify_os_proxy_state(Err(anyhow::anyhow!("per-protocol proxy snapshots are macOS only")))
|
||||
}
|
||||
|
||||
/// Disable, drain a racing guard if needed, then disable again.
|
||||
async fn disable_until_the_last_write_is_ours<Disable, DisableFuture, Drain, DrainFuture>(
|
||||
drained: bool,
|
||||
disable: Disable,
|
||||
drain_again: Drain,
|
||||
) -> Result<()>
|
||||
where
|
||||
Disable: Fn() -> DisableFuture,
|
||||
DisableFuture: std::future::Future<Output = Result<()>>,
|
||||
Drain: FnOnce() -> DrainFuture,
|
||||
DrainFuture: std::future::Future<Output = bool>,
|
||||
{
|
||||
disable().await?;
|
||||
if drained {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The final disable must happen after the last in-flight guard write.
|
||||
if !drain_again().await {
|
||||
anyhow::bail!(
|
||||
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}; the OS proxy state is unknown"
|
||||
);
|
||||
}
|
||||
disable().await
|
||||
}
|
||||
|
||||
/// Return the first failure after logging every attempted write.
|
||||
fn first_failure<const N: usize>(attempts: [(&'static str, Result<()>); N]) -> Result<()> {
|
||||
let mut first: Option<anyhow::Error> = None;
|
||||
for (what, outcome) in attempts {
|
||||
let Err(error) = outcome else { continue };
|
||||
logging!(warn, Type::Core, "failed to turn the {what} off: {error:#}");
|
||||
if first.is_none() {
|
||||
first = Some(error);
|
||||
}
|
||||
}
|
||||
first.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
/// Attempt to disable both proxy kinds even if the first fails.
|
||||
fn disable_both<Global, Pac>(disable_global: Global, disable_pac: Pac) -> Result<()>
|
||||
where
|
||||
Global: FnOnce() -> Result<()>,
|
||||
Pac: FnOnce() -> Result<()>,
|
||||
{
|
||||
let global = disable_global();
|
||||
let pac = disable_pac();
|
||||
first_failure([("global proxy", global), ("PAC", pac)])
|
||||
}
|
||||
|
||||
/// Force both proxy kinds off, in one blocking hop.
|
||||
async fn disable_all_proxies(sys: Sysproxy, auto: Autoproxy) -> Result<()> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
disable_both(
|
||||
|| sys.set_system_proxy().map_err(anyhow::Error::from),
|
||||
|| auto.set_auto_proxy().map_err(anyhow::Error::from),
|
||||
)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Reconcile OS and guard state while preserving the original failure.
|
||||
async fn recover_from_failed_write<Guard, Compensate, CompensateFuture>(
|
||||
error: anyhow::Error,
|
||||
earlier_step_completed: bool,
|
||||
recover_guard: Guard,
|
||||
compensate: Compensate,
|
||||
) -> anyhow::Error
|
||||
where
|
||||
Guard: FnOnce(AuthoritativeState),
|
||||
Compensate: FnOnce() -> CompensateFuture,
|
||||
CompensateFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let state = authoritative_state(&error, earlier_step_completed);
|
||||
recover_guard(state);
|
||||
|
||||
match state {
|
||||
AuthoritativeState::Unchanged => error,
|
||||
AuthoritativeState::Unknown => {
|
||||
if let Err(compensation) = compensate().await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"failed to force the system proxy off after a partial write: {compensation:#}"
|
||||
);
|
||||
}
|
||||
error.context(SystemProxyStateUnknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState {
|
||||
tokio::task::spawn_blocking(move || read_os_proxy_state(&sys, &auto))
|
||||
.await
|
||||
.unwrap_or_else(|join_error| {
|
||||
logging!(warn, Type::Core, "failed to read OS proxy state: {join_error}");
|
||||
OsProxyState::DifferentOrUnknown
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct Sysopt {
|
||||
update_lock: TokioMutex<()>,
|
||||
guard_operation_lock: TokioMutex<()>,
|
||||
@@ -87,36 +273,41 @@ impl Sysopt {
|
||||
Arc::clone(&self.guard)
|
||||
}
|
||||
|
||||
async fn stop_proxy_guard_locked(&self) {
|
||||
loop {
|
||||
let state = self.access_guard().read().get_state();
|
||||
if state.is_pendding() {
|
||||
tokio::task::yield_now().await;
|
||||
continue;
|
||||
}
|
||||
self.access_guard().write().stop();
|
||||
return;
|
||||
/// Stop the guard and return whether it drained.
|
||||
async fn stop_proxy_guard_locked(&self) -> bool {
|
||||
// Drop the parking_lot read guard before awaiting.
|
||||
let idle = self.access_guard().read().shutdown();
|
||||
let drained = idle.wait_timeout(GUARD_DRAIN_TIMEOUT).await;
|
||||
if !drained {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}"
|
||||
);
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
pub(super) async fn stop_proxy_guard(&self) {
|
||||
/// Stop the guard before handing OS proxy ownership elsewhere.
|
||||
pub(super) async fn stop_proxy_guard(&self) -> bool {
|
||||
let _operation = self.guard_operation_lock.lock().await;
|
||||
self.stop_proxy_guard_locked().await;
|
||||
self.stop_proxy_guard_locked().await
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_guard(&self) {
|
||||
/// Reconcile guard state with configuration and report success.
|
||||
pub(super) async fn refresh_guard(&self) -> bool {
|
||||
logging!(info, Type::Core, "Refreshing system proxy guard...");
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let _operation = self.guard_operation_lock.lock().await;
|
||||
if !verge.enable_system_proxy.unwrap_or_default() {
|
||||
logging!(info, Type::Core, "System proxy is disabled.");
|
||||
self.stop_proxy_guard_locked().await;
|
||||
return;
|
||||
let _drained = self.stop_proxy_guard_locked().await;
|
||||
return true;
|
||||
}
|
||||
if !verge.enable_proxy_guard.unwrap_or_default() {
|
||||
logging!(info, Type::Core, "System proxy guard is disabled.");
|
||||
self.stop_proxy_guard_locked().await;
|
||||
return;
|
||||
let _drained = self.stop_proxy_guard_locked().await;
|
||||
return true;
|
||||
}
|
||||
logging!(
|
||||
info,
|
||||
@@ -133,11 +324,56 @@ impl Sysopt {
|
||||
logging!(info, Type::Core, "Starting system proxy guard...");
|
||||
{
|
||||
let guard = self.access_guard();
|
||||
guard.write().start();
|
||||
if !guard.read().start() {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"the system proxy guard refused to start; a previous run has not finished"
|
||||
);
|
||||
}
|
||||
}
|
||||
while self.access_guard().read().get_state().is_pendding() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
// `start()` false may also mean a guard was already running.
|
||||
!self.access_guard().read().get_state().is_stopped()
|
||||
}
|
||||
|
||||
/// Recover OS and guard state after a failed write.
|
||||
async fn recover_from_failed_write(
|
||||
&self,
|
||||
error: anyhow::Error,
|
||||
earlier_step_completed: bool,
|
||||
guard_was_running: bool,
|
||||
off: (Sysproxy, Autoproxy),
|
||||
) -> anyhow::Error {
|
||||
recover_from_failed_write(
|
||||
error,
|
||||
earlier_step_completed,
|
||||
|state| self.recover_guard_after_failure(state, guard_was_running),
|
||||
|| disable_all_proxies(off.0, off.1),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reconcile the guard with the authoritative state.
|
||||
fn recover_guard_after_failure(&self, state: AuthoritativeState, was_running: bool) {
|
||||
match state {
|
||||
AuthoritativeState::Unchanged if was_running => {
|
||||
let restarted = self.access_guard().read().start();
|
||||
if !restarted {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"the system proxy guard refused to start again after a failed write; it is not running"
|
||||
);
|
||||
}
|
||||
}
|
||||
AuthoritativeState::Unchanged => {}
|
||||
AuthoritativeState::Unknown => {
|
||||
self.access_guard().write().set_guard_type(GuardType::None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for any in-progress `update_sysproxy` to finish, so that a
|
||||
@@ -201,21 +437,69 @@ impl Sysopt {
|
||||
};
|
||||
|
||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||
self.access_guard().write().set_guard_type(guard_type);
|
||||
|
||||
// Drain the guard before any OS read or write.
|
||||
let guard_was_running = !self.access_guard().read().get_state().is_stopped();
|
||||
let idle = self.access_guard().read().shutdown();
|
||||
let drained = idle.wait_timeout(GUARD_DRAIN_TIMEOUT).await;
|
||||
if !drained {
|
||||
// A pending guard write makes OS state unknown.
|
||||
self.access_guard().write().set_guard_type(GuardType::None);
|
||||
anyhow::bail!(
|
||||
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}; the OS proxy state is unknown"
|
||||
);
|
||||
}
|
||||
|
||||
// Skip writes only when macOS exactly matches the target.
|
||||
if cfg!(target_os = "macos")
|
||||
&& current_os_proxy_state(sys.clone(), auto.clone()).await == OsProxyState::AlreadyApplied
|
||||
{
|
||||
self.access_guard().write().set_guard_type(guard_type);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let apply_steps = proxy_apply_steps(sys.enable, auto.enable);
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
for step in apply_steps {
|
||||
match step {
|
||||
ProxyApplyStep::Autoproxy => auto.set_auto_proxy()?,
|
||||
ProxyApplyStep::Sysproxy => sys.set_system_proxy()?,
|
||||
// Prepare the disabled state used to compensate a partial write.
|
||||
let compensation = {
|
||||
let (mut off_sys, mut off_auto) = (sys.clone(), auto.clone());
|
||||
off_sys.enable = false;
|
||||
off_auto.enable = false;
|
||||
(off_sys, off_auto)
|
||||
};
|
||||
|
||||
// Track whether an earlier setter already changed the OS.
|
||||
let applied = tokio::task::spawn_blocking(move || {
|
||||
for (index, step) in apply_steps.into_iter().enumerate() {
|
||||
let written = match step {
|
||||
ProxyApplyStep::Autoproxy => auto.set_auto_proxy(),
|
||||
ProxyApplyStep::Sysproxy => sys.set_system_proxy(),
|
||||
};
|
||||
if let Err(error) = written {
|
||||
return Err((index > 0, anyhow::Error::from(error)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
.await;
|
||||
|
||||
match applied {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err((earlier_step_completed, error))) => {
|
||||
return Err(self
|
||||
.recover_from_failed_write(error, earlier_step_completed, guard_was_running, compensation)
|
||||
.await);
|
||||
}
|
||||
Err(join_error) => {
|
||||
let error = anyhow::Error::from(join_error).context("the system proxy write task did not finish");
|
||||
return Err(self
|
||||
.recover_from_failed_write(error, false, guard_was_running, compensation)
|
||||
.await);
|
||||
}
|
||||
}
|
||||
|
||||
// Never point the guard at a target that failed to reach the OS.
|
||||
self.access_guard().write().set_guard_type(guard_type);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -233,7 +517,7 @@ impl Sysopt {
|
||||
}
|
||||
let _lock = self.update_lock.lock().await;
|
||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||
self.stop_proxy_guard_locked().await;
|
||||
let drained = self.stop_proxy_guard_locked().await;
|
||||
|
||||
// 直接关闭所有代理
|
||||
let (sys, auto) = {
|
||||
@@ -243,20 +527,496 @@ impl Sysopt {
|
||||
(sys.clone(), auto.clone())
|
||||
};
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
sys.set_system_proxy()?;
|
||||
auto.set_auto_proxy()?;
|
||||
Ok(())
|
||||
})
|
||||
.await??;
|
||||
// The match is trustworthy only after the guard drained.
|
||||
if cfg!(target_os = "macos")
|
||||
&& drained
|
||||
&& current_os_proxy_state(sys.clone(), auto.clone()).await == OsProxyState::AlreadyApplied
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
disable_until_the_last_write_is_ours(
|
||||
drained,
|
||||
|| disable_all_proxies(sys.clone(), auto.clone()),
|
||||
|| self.stop_proxy_guard_locked(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ProxyApplyStep, proxy_apply_steps};
|
||||
use super::{
|
||||
AuthoritativeState, OsProxyState, ProxyApplyStep, SystemProxyStateUnknown, authoritative_state,
|
||||
authoritative_state_from, classify_os_proxy_state, disable_both, disable_until_the_last_write_is_ours,
|
||||
first_failure, proxy_apply_steps, recover_from_failed_write, target_is_already_in_place,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::VecDeque;
|
||||
use sysproxy::{Autoproxy, Sysproxy};
|
||||
|
||||
async fn record_disable_sequence(
|
||||
drained: bool,
|
||||
second_drain: bool,
|
||||
disable_answers: &[bool],
|
||||
) -> (Vec<&'static str>, bool) {
|
||||
let calls = Mutex::new(Vec::new());
|
||||
let remaining = Mutex::new(disable_answers.iter().copied().collect::<VecDeque<bool>>());
|
||||
let result = disable_until_the_last_write_is_ours(
|
||||
drained,
|
||||
|| {
|
||||
calls.lock().push("disable");
|
||||
let succeeds = remaining.lock().pop_front().unwrap_or(true);
|
||||
async move {
|
||||
if succeeds {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("networksetup refused")
|
||||
}
|
||||
}
|
||||
},
|
||||
|| {
|
||||
calls.lock().push("drain");
|
||||
async move { second_drain }
|
||||
},
|
||||
)
|
||||
.await;
|
||||
(calls.into_inner(), result.is_ok())
|
||||
}
|
||||
|
||||
fn failure_text(result: anyhow::Result<()>) -> std::string::String {
|
||||
result.map_or_else(|error| format!("{error:#}"), |()| "no failure".to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_proxy_kind_is_attempted_even_after_one_of_them_failed() {
|
||||
let attempted = Mutex::new(Vec::new());
|
||||
|
||||
let failed = disable_both(
|
||||
|| {
|
||||
attempted.lock().push("global proxy");
|
||||
Err(anyhow::anyhow!("global refused"))
|
||||
},
|
||||
|| {
|
||||
attempted.lock().push("PAC");
|
||||
Err(anyhow::anyhow!("PAC refused"))
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(&*attempted.lock(), &["global proxy", "PAC"]);
|
||||
assert_eq!(failure_text(failed), "global refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_failure_is_still_reported_when_the_first_one_succeeded() {
|
||||
let failed = first_failure([("global proxy", Ok(())), ("PAC", Err(anyhow::anyhow!("PAC refused")))]);
|
||||
|
||||
assert_eq!(failure_text(failed), "PAC refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_reported_when_every_kind_went_through() {
|
||||
assert!(first_failure([("global proxy", Ok(())), ("PAC", Ok(()))]).is_ok());
|
||||
}
|
||||
|
||||
struct Recovery {
|
||||
guard_told: Option<AuthoritativeState>,
|
||||
compensated: bool,
|
||||
state_unknown: bool,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn record_recovery(error: anyhow::Error, earlier_step_completed: bool, compensation_works: bool) -> Recovery {
|
||||
let guard_told = Mutex::new(None);
|
||||
let compensated = Mutex::new(false);
|
||||
let recovered = recover_from_failed_write(
|
||||
error,
|
||||
earlier_step_completed,
|
||||
|state| *guard_told.lock() = Some(state),
|
||||
|| {
|
||||
*compensated.lock() = true;
|
||||
async move {
|
||||
if compensation_works {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("the compensation was refused too")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Recovery {
|
||||
guard_told: *guard_told.lock(),
|
||||
compensated: *compensated.lock(),
|
||||
state_unknown: SystemProxyStateUnknown::is_in(&recovered),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
message: format!("{recovered:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn refused() -> anyhow::Error {
|
||||
anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(0, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
})
|
||||
}
|
||||
|
||||
fn partly_written() -> anyhow::Error {
|
||||
anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(3, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failure_that_wrote_nothing_is_not_compensated_for() {
|
||||
let recovery = record_recovery(refused(), false, true).await;
|
||||
|
||||
assert!(!recovery.compensated);
|
||||
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged));
|
||||
assert!(!recovery.state_unknown);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn nothing_on_linux_ever_reaches_the_destructive_row() {
|
||||
for earlier_step_completed in [false, true] {
|
||||
let recovery = record_recovery(partly_written(), earlier_step_completed, true).await;
|
||||
|
||||
assert!(!recovery.compensated, "{earlier_step_completed}");
|
||||
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged));
|
||||
assert!(!recovery.state_unknown);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn a_failure_that_wrote_something_forces_the_proxy_off() {
|
||||
let recovery = record_recovery(partly_written(), false, true).await;
|
||||
|
||||
assert!(recovery.compensated);
|
||||
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown));
|
||||
assert!(recovery.state_unknown);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn a_compensation_that_fails_does_not_replace_the_diagnosis() {
|
||||
let recovery = record_recovery(partly_written(), false, false).await;
|
||||
|
||||
assert!(recovery.compensated);
|
||||
assert!(recovery.message.contains("admin privileges"));
|
||||
assert!(!recovery.message.contains("the compensation was refused too"));
|
||||
assert!(recovery.state_unknown);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn an_earlier_setter_that_finished_reaches_the_second_row_too() {
|
||||
let recovery = record_recovery(refused(), true, true).await;
|
||||
|
||||
assert!(recovery.compensated);
|
||||
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown));
|
||||
assert!(recovery.state_unknown);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_drained_guard_needs_only_one_write() {
|
||||
assert_eq!(
|
||||
record_disable_sequence(true, true, &[true]).await,
|
||||
(vec!["disable"], true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_write_that_raced_the_guard_is_repeated_after_it_finishes() {
|
||||
assert_eq!(
|
||||
record_disable_sequence(false, true, &[true, true]).await,
|
||||
(vec!["disable", "drain", "disable"], true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_write_that_fails_on_its_own_is_still_reported() {
|
||||
assert_eq!(
|
||||
record_disable_sequence(false, true, &[true, false]).await,
|
||||
(vec!["disable", "drain", "disable"], false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_guard_that_never_finishes_is_reported_rather_than_written_over() {
|
||||
assert_eq!(
|
||||
record_disable_sequence(false, false, &[true]).await,
|
||||
(vec!["disable", "drain"], false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_write_stops_before_waiting_on_anything() {
|
||||
assert_eq!(
|
||||
record_disable_sequence(false, true, &[false]).await,
|
||||
(vec!["disable"], false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_with_nothing_written_leaves_the_old_guard_worth_restoring() {
|
||||
let refused = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges);
|
||||
|
||||
assert_eq!(authoritative_state(&refused, false), AuthoritativeState::Unchanged);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn only_evidence_that_a_write_landed_stands_the_guard_down() {
|
||||
let nothing_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(0, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
});
|
||||
let something_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(3, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
authoritative_state(¬hing_written, false),
|
||||
AuthoritativeState::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
authoritative_state(&something_written, false),
|
||||
AuthoritativeState::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn a_step_that_already_finished_stands_the_guard_down_whatever_the_error_says() {
|
||||
let refused = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges);
|
||||
|
||||
assert_eq!(authoritative_state(&refused, false), AuthoritativeState::Unchanged);
|
||||
assert_eq!(authoritative_state(&refused, true), AuthoritativeState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_a_successful_setter_proves_nothing_no_failure_is_destructive() {
|
||||
let strongest_evidence = anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(3, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
});
|
||||
|
||||
for earlier_step_completed in [false, true] {
|
||||
assert_eq!(
|
||||
authoritative_state_from(false, &strongest_evidence, earlier_step_completed),
|
||||
AuthoritativeState::Unchanged,
|
||||
"{earlier_step_completed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_it_proves_something_both_kinds_of_evidence_count() {
|
||||
let nothing_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(0, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
});
|
||||
let partly_written = anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
||||
progress: sysproxy::WriteProgress::new(3, 7),
|
||||
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
authoritative_state_from(true, ¬hing_written, false),
|
||||
AuthoritativeState::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
authoritative_state_from(true, &partly_written, false),
|
||||
AuthoritativeState::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
authoritative_state_from(true, ¬hing_written, true),
|
||||
AuthoritativeState::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
fn holding_global(port: u16) -> sysproxy::ProxySnapshot {
|
||||
let endpoint = || sysproxy::ProxyEndpoint {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port,
|
||||
enable: true,
|
||||
switched_on: true,
|
||||
};
|
||||
sysproxy::ProxySnapshot {
|
||||
socks: endpoint(),
|
||||
http: endpoint(),
|
||||
https: endpoint(),
|
||||
auto: sysproxy::Autoproxy {
|
||||
url: std::string::String::new(),
|
||||
enable: false,
|
||||
},
|
||||
auto_switched_on: false,
|
||||
bypass: "localhost".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn holding_nothing() -> sysproxy::ProxySnapshot {
|
||||
let off = || sysproxy::ProxyEndpoint {
|
||||
host: std::string::String::new(),
|
||||
port: 0,
|
||||
enable: false,
|
||||
switched_on: false,
|
||||
};
|
||||
sysproxy::ProxySnapshot {
|
||||
socks: off(),
|
||||
http: off(),
|
||||
https: off(),
|
||||
auto: sysproxy::Autoproxy {
|
||||
url: std::string::String::new(),
|
||||
enable: false,
|
||||
},
|
||||
auto_switched_on: false,
|
||||
bypass: std::string::String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn global_target(port: u16) -> Sysproxy {
|
||||
Sysproxy {
|
||||
enable: true,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port,
|
||||
bypass: "localhost".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pac_mode_target() -> Sysproxy {
|
||||
Sysproxy {
|
||||
enable: false,
|
||||
..global_target(7897)
|
||||
}
|
||||
}
|
||||
|
||||
fn pac_off() -> Autoproxy {
|
||||
Autoproxy {
|
||||
url: std::string::String::new(),
|
||||
enable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_the_os_already_points_at_needs_no_write() {
|
||||
assert!(target_is_already_in_place(
|
||||
&holding_global(7897),
|
||||
&global_target(7897),
|
||||
&pac_off()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_pointing_at_a_different_port_still_has_to_be_written() {
|
||||
assert!(!target_is_already_in_place(
|
||||
&holding_global(7897),
|
||||
&global_target(7898),
|
||||
&pac_off()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pac_target_asks_the_pac_question_and_not_the_global_one() {
|
||||
let pac = Autoproxy {
|
||||
url: "http://127.0.0.1:33333/commands/pac".to_owned(),
|
||||
enable: true,
|
||||
};
|
||||
let mut os_holds_pac = holding_nothing();
|
||||
os_holds_pac.auto = pac.clone();
|
||||
os_holds_pac.auto_switched_on = true;
|
||||
os_holds_pac.bypass = "localhost".to_owned();
|
||||
|
||||
assert!(target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pac_target_whose_bypass_changed_still_has_to_be_written() {
|
||||
let pac = Autoproxy {
|
||||
url: "http://127.0.0.1:33333/commands/pac".to_owned(),
|
||||
enable: true,
|
||||
};
|
||||
let mut os_holds_pac = holding_nothing();
|
||||
os_holds_pac.auto = pac.clone();
|
||||
os_holds_pac.auto_switched_on = true;
|
||||
os_holds_pac.bypass = "localhost,example.com".to_owned();
|
||||
|
||||
assert!(!target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switch_left_on_over_nothing_is_not_already_disabled() {
|
||||
let mut stranded = holding_nothing();
|
||||
stranded.http = sysproxy::ProxyEndpoint {
|
||||
host: std::string::String::new(),
|
||||
port: 0,
|
||||
enable: false,
|
||||
switched_on: true,
|
||||
};
|
||||
|
||||
assert!(!target_is_already_in_place(
|
||||
&stranded,
|
||||
&Sysproxy {
|
||||
enable: false,
|
||||
..global_target(7897)
|
||||
},
|
||||
&pac_off()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_of_nothing_does_not_ask_about_the_bypass_list() {
|
||||
let mut os_holds_nothing = holding_nothing();
|
||||
os_holds_nothing.bypass = "something.else".to_owned();
|
||||
|
||||
assert!(target_is_already_in_place(
|
||||
&os_holds_nothing,
|
||||
&Sysproxy {
|
||||
enable: false,
|
||||
..global_target(7897)
|
||||
},
|
||||
&pac_off()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_of_nothing_asks_whether_the_os_holds_nothing() {
|
||||
let nothing_wanted = Sysproxy {
|
||||
enable: false,
|
||||
..global_target(7897)
|
||||
};
|
||||
|
||||
assert!(target_is_already_in_place(
|
||||
&holding_nothing(),
|
||||
¬hing_wanted,
|
||||
&pac_off()
|
||||
));
|
||||
assert!(!target_is_already_in_place(
|
||||
&holding_global(7897),
|
||||
¬hing_wanted,
|
||||
&pac_off()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_snapshot_that_agrees_with_the_target_skips_the_write() {
|
||||
assert_eq!(classify_os_proxy_state(Ok(true)), OsProxyState::AlreadyApplied);
|
||||
assert_eq!(classify_os_proxy_state(Ok(false)), OsProxyState::DifferentOrUnknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_read_never_counts_as_agreement() {
|
||||
assert_eq!(
|
||||
classify_os_proxy_state(Err(anyhow::anyhow!("read failed"))),
|
||||
OsProxyState::DifferentOrUnknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_sysproxy_mode_clears_pac_before_enabling_global_proxy() {
|
||||
|
||||
@@ -949,7 +949,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
};
|
||||
}
|
||||
MenuIds::SYSTEM_PROXY => {
|
||||
feat::toggle_system_proxy().await;
|
||||
let _ = feat::toggle_system_proxy().await;
|
||||
}
|
||||
MenuIds::TUN_MODE => {
|
||||
feat::toggle_tun_mode(None).await;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
config::{Config, IClashTemp, IProfiles, IVerge},
|
||||
core::backup,
|
||||
core::{backup, proxy_control::SystemProxyStateUnknown},
|
||||
process::AsyncHandler,
|
||||
utils::{
|
||||
dirs::{PathBufExec as _, app_home_dir, local_backup_dir, verge_path},
|
||||
@@ -63,6 +63,10 @@ async fn finalize_restored_verge_config(
|
||||
// Use not_save_file = true to avoid extra I/O (we already persisted the restored file).
|
||||
if let Err(err) = super::patch_verge(&restored, true).await {
|
||||
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#?}");
|
||||
// Propagate unknown proxy state; ordinary side-effect failures stay logged.
|
||||
if SystemProxyStateUnknown::is_in(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ pub async fn restart_clash_core() {
|
||||
handle::Handle::notice_message("set_config::ok", "ok");
|
||||
}
|
||||
Err(err) => {
|
||||
handle::Handle::notice_message("set_config::error", format!("{err}"));
|
||||
logging!(error, Type::Core, "{err}");
|
||||
handle::Handle::notice_message("set_config::error", format!("{err:#}"));
|
||||
logging!(error, Type::Core, "{err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
core::{CoreManager, autostart, handle, hotkey, logger::Logger, tray},
|
||||
core::{CoreManager, autostart, handle, hotkey, logger::Logger, proxy_control::SystemProxyStateUnknown, tray},
|
||||
module::{auto_backup::AutoBackupManager, lightweight},
|
||||
};
|
||||
use anyhow::Result;
|
||||
@@ -211,9 +211,6 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) ->
|
||||
CoreManager::global().update_config_checked().await?;
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::VERGE_CONFIG) {
|
||||
handle::Handle::refresh_verge();
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::LAUNCH) {
|
||||
autostart::update_launch().await?;
|
||||
}
|
||||
@@ -306,8 +303,25 @@ pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Re
|
||||
|
||||
let update_flags = determine_update_flags(patch);
|
||||
logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags);
|
||||
process_terminated_flags(update_flags, patch).await?;
|
||||
if let Err(error) = process_terminated_flags(update_flags, patch).await {
|
||||
if !may_commit_system_proxy_disabled(patch, &error) {
|
||||
return Err(error);
|
||||
}
|
||||
// Commit the safe state after a user-requested proxy patch partly lands.
|
||||
verge.edit_draft(|d| d.enable_system_proxy = Some(false));
|
||||
transaction.commit();
|
||||
announce_verge_change();
|
||||
// Persist the forced-off value even when the original patch was pre-saved.
|
||||
let unsaved = verge.data_arc().save_file().await.err();
|
||||
return Err(match unsaved {
|
||||
Some(save) => error.context(format!(
|
||||
"the system proxy state became unknown and the disabled safety value could not be saved: {save:#}"
|
||||
)),
|
||||
None => error,
|
||||
});
|
||||
}
|
||||
transaction.commit();
|
||||
announce_verge_change();
|
||||
|
||||
logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await);
|
||||
if !not_save_file {
|
||||
@@ -319,8 +333,69 @@ pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn announce_verge_change() {
|
||||
handle::Handle::refresh_verge();
|
||||
}
|
||||
|
||||
/// Whether a user proxy patch may commit disabled after a partial write.
|
||||
fn may_commit_system_proxy_disabled(patch: &IVerge, error: &anyhow::Error) -> bool {
|
||||
patch.enable_system_proxy.is_some() && SystemProxyStateUnknown::is_in(error)
|
||||
}
|
||||
|
||||
pub async fn fetch_verge_config() -> Result<SharedDraft<IVerge>> {
|
||||
let draft = Config::verge().await;
|
||||
let data = draft.data_arc();
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{IVerge, SystemProxyStateUnknown, may_commit_system_proxy_disabled};
|
||||
|
||||
fn state_unknown() -> anyhow::Error {
|
||||
anyhow::anyhow!("networksetup refused")
|
||||
.context(SystemProxyStateUnknown)
|
||||
.context("failed to apply the system proxy")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_user_turning_the_proxy_on_gets_it_committed_as_off_when_the_os_ended_up_unknown() {
|
||||
let patch = IVerge {
|
||||
enable_system_proxy: Some(true),
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
assert!(may_commit_system_proxy_disabled(&patch, &state_unknown()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patch_about_something_else_never_rewrites_the_proxy_setting() {
|
||||
let patch = IVerge {
|
||||
enable_tun_mode: Some(true),
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
assert!(!may_commit_system_proxy_disabled(&patch, &state_unknown()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_committed_when_nothing_is_known_to_have_been_written() {
|
||||
let patch = IVerge {
|
||||
enable_system_proxy: Some(true),
|
||||
..IVerge::default()
|
||||
};
|
||||
let refused = anyhow::anyhow!("networksetup refused").context("failed to apply the system proxy");
|
||||
|
||||
assert!(!may_commit_system_proxy_disabled(&patch, &refused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_the_proxy_off_and_failing_that_way_still_commits_off() {
|
||||
let patch = IVerge {
|
||||
enable_system_proxy: Some(false),
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
assert!(may_commit_system_proxy_disabled(&patch, &state_unknown()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
probe_listener as probe_listener_sync, probe_proxy_port_change,
|
||||
},
|
||||
manager::RunningMode,
|
||||
proxy_control::rollback_failure,
|
||||
validate::CoreConfigValidator,
|
||||
},
|
||||
process::AsyncHandler,
|
||||
@@ -87,17 +88,15 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
transaction.rollback();
|
||||
return match restore_files(&snapshots).await {
|
||||
Ok(()) => Err(error).context("failed to persist candidate Runtime Configuration"),
|
||||
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
|
||||
Err(rollback_error) => Err(rollback_failure(error, rollback_error)),
|
||||
};
|
||||
}
|
||||
|
||||
if was_running && let Err(activation_error) = manager.restart_core_during_config_update().await {
|
||||
transaction.rollback();
|
||||
if let Err(rollback_error) = rollback_proxy_ports(&snapshots, was_running).await {
|
||||
return Err(anyhow!(
|
||||
"failed to activate proxy port configuration: {activation_error:#}; \
|
||||
configuration rollback failed: {rollback_error:#}"
|
||||
));
|
||||
return Err(rollback_failure(activation_error, rollback_error)
|
||||
.context("failed to activate the proxy port configuration"));
|
||||
}
|
||||
|
||||
let post_failure_assessment = probe_proxy_ports(current, candidate, was_running).await?;
|
||||
@@ -116,9 +115,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
transaction.rollback();
|
||||
return match rollback_proxy_ports(&snapshots, was_running).await {
|
||||
Ok(()) => Err(persist_error),
|
||||
Err(rollback_error) => Err(anyhow!(
|
||||
"{persist_error:#}; configuration rollback failed: {rollback_error:#}"
|
||||
)),
|
||||
Err(rollback_error) => Err(rollback_failure(persist_error, rollback_error)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -245,9 +242,8 @@ async fn rollback_proxy_ports(snapshots: &[FileSnapshot], was_running: bool) ->
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
(Err(file_error), Ok(())) => Err(file_error),
|
||||
(Ok(()), Err(lifecycle_error)) => Err(lifecycle_error).context("failed to restore the previous core"),
|
||||
(Err(file_error), Err(lifecycle_error)) => Err(anyhow!(
|
||||
"failed to restore configuration files: {file_error:#}; \
|
||||
failed to restore the previous core: {lifecycle_error:#}"
|
||||
)),
|
||||
(Err(file_error), Err(lifecycle_error)) => Err(lifecycle_error
|
||||
.context(format!("failed to restore configuration files: {file_error:#}"))
|
||||
.context("failed to restore the previous core")),
|
||||
}
|
||||
}
|
||||
|
||||
+55
-16
@@ -1,13 +1,21 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge, MixedPort},
|
||||
core::handle,
|
||||
core::{
|
||||
handle,
|
||||
notification::{self, FailedOperation},
|
||||
proxy_control,
|
||||
},
|
||||
utils::{
|
||||
notification::{NotificationEvent, needs_system_notification, notify_event},
|
||||
window_manager::WindowManager,
|
||||
},
|
||||
};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use std::env;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt as _;
|
||||
|
||||
/// Toggle system proxy on/off
|
||||
pub async fn toggle_system_proxy() -> bool {
|
||||
/// Toggle system proxy, returning `None` when it does not change.
|
||||
pub async fn toggle_system_proxy() -> Option<bool> {
|
||||
let verge = Config::verge().await;
|
||||
let current = verge.latest_arc().enable_system_proxy.unwrap_or(false);
|
||||
let auto_close_connection = verge.latest_arc().auto_close_connection.unwrap_or(false);
|
||||
@@ -21,27 +29,46 @@ pub async fn toggle_system_proxy() -> bool {
|
||||
}
|
||||
|
||||
let requested = !current;
|
||||
let patch_result = super::patch_verge(
|
||||
&IVerge {
|
||||
enable_system_proxy: Some(requested),
|
||||
..IVerge::default()
|
||||
},
|
||||
false,
|
||||
let patch_result = notification::asking_for(
|
||||
toggle_operation(requested),
|
||||
Box::pin(super::patch_verge(
|
||||
&IVerge {
|
||||
enable_system_proxy: Some(requested),
|
||||
..IVerge::default()
|
||||
},
|
||||
false,
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
match patch_result {
|
||||
Ok(_) => {
|
||||
handle::Handle::refresh_verge();
|
||||
requested
|
||||
}
|
||||
// Patch processing refreshes every caller.
|
||||
Ok(_) => Some(requested),
|
||||
Err(err) => {
|
||||
logging!(error, Type::ProxyMode, "{err}");
|
||||
current
|
||||
logging!(error, Type::ProxyMode, "{err:#}");
|
||||
report_toggle_failure(&err).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify only when a classified failure remains pending.
|
||||
async fn report_toggle_failure(error: &anyhow::Error) {
|
||||
let recorded = proxy_control::is_reportable(error);
|
||||
|
||||
if recorded && needs_system_notification(WindowManager::get_main_window_state()) {
|
||||
notify_event(NotificationEvent::SystemProxyFailed).await;
|
||||
}
|
||||
}
|
||||
|
||||
const fn toggle_operation(requested: bool) -> FailedOperation {
|
||||
if requested {
|
||||
FailedOperation::SystemProxyEnable
|
||||
} else {
|
||||
FailedOperation::SystemProxyDisable
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle TUN mode on/off
|
||||
/// Returns the updated toggle state
|
||||
pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
|
||||
@@ -67,7 +94,7 @@ pub async fn toggle_tun_mode(not_save_file: Option<bool>) -> bool {
|
||||
Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false)
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(error, Type::ProxyMode, "{err}");
|
||||
logging!(error, Type::ProxyMode, "{err:#}");
|
||||
current
|
||||
}
|
||||
}
|
||||
@@ -123,3 +150,15 @@ pub async fn copy_clash_env() {
|
||||
logging!(error, Type::ProxyMode, "Failed to write to clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::toggle_operation;
|
||||
use crate::core::notification::FailedOperation;
|
||||
|
||||
#[test]
|
||||
fn a_failed_toggle_records_which_way_the_user_was_asking() {
|
||||
assert_eq!(toggle_operation(true), FailedOperation::SystemProxyEnable);
|
||||
assert_eq!(toggle_operation(false), FailedOperation::SystemProxyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ mod app_init {
|
||||
cmd::stop_core,
|
||||
cmd::restart_core,
|
||||
cmd::get_runtime_state,
|
||||
cmd::get_pending_failures,
|
||||
cmd::get_auto_launch_status,
|
||||
cmd::entry_lightweight_mode,
|
||||
cmd::exit_lightweight_mode,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::core::handle;
|
||||
use crate::{core::handle, utils::window_manager::WindowState};
|
||||
use clash_verge_i18n;
|
||||
use tauri_plugin_notification::NotificationExt as _;
|
||||
|
||||
@@ -10,6 +10,10 @@ pub enum NotificationEvent<'a> {
|
||||
mode: &'a str,
|
||||
},
|
||||
SystemProxyToggled(bool),
|
||||
/// A proxy change failed while no window could report it.
|
||||
SystemProxyFailed,
|
||||
/// The system proxy guard stopped.
|
||||
SystemProxyGuardStopped,
|
||||
TunModeToggled(bool),
|
||||
LightweightModeEntered,
|
||||
ProfilesReactivated,
|
||||
@@ -18,6 +22,14 @@ pub enum NotificationEvent<'a> {
|
||||
AppHidden,
|
||||
}
|
||||
|
||||
/// Whether no app window can currently show the failure.
|
||||
pub const fn needs_system_notification(state: WindowState) -> bool {
|
||||
match state {
|
||||
WindowState::Hidden | WindowState::Minimized | WindowState::NotExist => true,
|
||||
WindowState::VisibleFocused | WindowState::VisibleUnfocused => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn notify(title: Cow<'_, str>, body: Cow<'_, str>) {
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
app_handle.notification().builder().title(title).body(body).show().ok();
|
||||
@@ -48,6 +60,16 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
|
||||
let body = clash_verge_i18n::t!(key);
|
||||
notify(title, body);
|
||||
}
|
||||
NotificationEvent::SystemProxyFailed => {
|
||||
let title = clash_verge_i18n::t!("notifications.systemProxyFailed.title");
|
||||
let body = clash_verge_i18n::t!("notifications.systemProxyFailed.body");
|
||||
notify(title, body);
|
||||
}
|
||||
NotificationEvent::SystemProxyGuardStopped => {
|
||||
let title = clash_verge_i18n::t!("notifications.systemProxyGuardStopped.title");
|
||||
let body = clash_verge_i18n::t!("notifications.systemProxyGuardStopped.body");
|
||||
notify(title, body);
|
||||
}
|
||||
NotificationEvent::TunModeToggled(enabled) => {
|
||||
let title = clash_verge_i18n::t!("notifications.tunModeToggled.title");
|
||||
let key = if enabled {
|
||||
@@ -81,3 +103,19 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::needs_system_notification;
|
||||
use crate::utils::window_manager::WindowState;
|
||||
|
||||
#[test]
|
||||
fn a_system_notification_is_sent_exactly_where_the_window_cannot_speak() {
|
||||
assert!(needs_system_notification(WindowState::Hidden));
|
||||
assert!(needs_system_notification(WindowState::Minimized));
|
||||
assert!(needs_system_notification(WindowState::NotExist));
|
||||
|
||||
assert!(!needs_system_notification(WindowState::VisibleFocused));
|
||||
assert!(!needs_system_notification(WindowState::VisibleUnfocused));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user