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:
wonfen
2026-08-14 23:52:37 +08:00
committed by GitHub
parent 0844ed30a8
commit 22947632c0
93 changed files with 5208 additions and 337 deletions
+3 -1
View File
@@ -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
View File
@@ -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");
}
+3 -1
View File
@@ -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
View File
@@ -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" })
);
}
}
+1 -1
View File
@@ -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 {
+8 -5
View File
@@ -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"))
}
+10 -1
View File
@@ -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()
}
+50 -3
View File
@@ -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);
}
}
+3 -1
View File
@@ -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))
}