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
Generated
+15 -15
View File
@@ -2043,7 +2043,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4249,7 +4249,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.19",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4418,7 +4418,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5553,7 +5553,7 @@ dependencies = [
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6101,7 +6101,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6114,7 +6114,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6173,7 +6173,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6804,7 +6804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7017,8 +7017,8 @@ dependencies = [
[[package]]
name = "sysproxy"
version = "0.5.5"
source = "git+https://github.com/clash-verge-rev/sysproxy-rs?branch=main#ab5ba0929d56721d774b1c5c29d6a32e6634e7ce"
version = "0.6.0"
source = "git+https://github.com/clash-verge-rev/sysproxy-rs?branch=main#16500c3e91a4fa8d541108fb603c5ee4258004a6"
dependencies = [
"iptools",
"log",
@@ -7694,7 +7694,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8413,7 +8413,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.19",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8516,7 +8516,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9716,7 +9716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -86,6 +86,7 @@ socket2 = "0.6.5"
deelevate = "0.2.0"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Authorization",
+21
View File
@@ -5,11 +5,25 @@
修复 macOS 多显示器切换时托盘速率样式不同步
修复 WebSocket 在 onConnected 初始化完成前被保存的问题,避免初始化失败后留下无监听且无法重试的无效连接。
修复 Mihomo API 变更后 inline proxy-provider 被错误过滤的问题,解决节点显示 Unknown 及延迟测试失败的问题
修复 macOS 开启「访问系统范围设置需要管理员密码」时无法设置系统代理的问题
修复从未开启过系统代理的用户,每次内核启动都收到报错的问题
修复受限机器上「安装服务 → 自动切换到服务模式」整条流程无法走通的问题
修复系统代理开关切换失败后,界面停留在错误状态的问题
修复托盘与热键切换系统代理失败时没有任何提示的问题
修复代理守卫反复失败后无限重试且不告知用户的问题
修复错误提示只显示最外层信息、丢失具体原因的问题
修复过长的错误提示撑满界面的问题(右键复制仍为完整内容)
修复部分错误提示显示为 [object Object] 的问题
修复 i18n 清理脚本因导入方式错误而无法运行的问题
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示
- 系统代理或 TUN 因权限被拒时弹出引导窗口,一步安装系统服务并自动恢复原本的设置
- 引导窗口显示当前阶段:安装服务 / 重启内核 / 应用代理
- 系统代理失败会保留到窗口可见时再呈现,不会因窗口隐藏或最小化而丢失
- 区分「需要安装服务」与「服务已就绪但内核仍以 Sidecar 运行」,分别给出对应操作
</details>
@@ -27,5 +41,12 @@
- 优化首页当前节点的交互
- Linux 代理设置缺失必要命令时,给出明确命令缺失信息,避免模糊报错
- 支持在前置规则与后置规则之间直接切换规则位置
- 系统代理已经是目标状态时不再执行多余的系统写入
- 系统代理写入半途失败后,系统状态、代理守卫与配置三者保持一致
- 代理守卫连续失败后停止并告知,不再静默重试
- TUN 模式开关不再需要单独的安装服务按钮,与系统代理走同一条恢复流程
- 系统代理失败给出可操作的具体原因,而非操作系统的英文原文
- 配置变更后统一广播刷新,界面开关与托盘状态保持同步
- 重构命令层错误返回为结构化格式,保留完整诊断链
</details>
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: وكيل النظام
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: وكيل النظام
body: تعذّر ضبط وكيل النظام. افتح Clash Verge لمعرفة السبب.
systemProxyGuardStopped:
title: حارس وكيل النظام
body: توقف بعد عدة إخفاقات متتالية. افتح Clash Verge لمعرفة السبب.
tunModeToggled:
title: وضع TUN
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: Systemproxy
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Systemproxy
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
systemProxyGuardStopped:
title: Systemproxy-Wächter
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
tunModeToggled:
title: TUN-Modus
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: System Proxy
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: System Proxy
body: The system proxy could not be set. Open Clash Verge to see why.
systemProxyGuardStopped:
title: System Proxy Guard
body: The guard stopped after failing several times running. Open Clash Verge to see why.
tunModeToggled:
title: TUN Mode
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: Proxy del sistema
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Proxy del sistema
body: No se pudo establecer el proxy del sistema. Abra Clash Verge para ver por qué.
systemProxyGuardStopped:
title: Guardián del proxy del sistema
body: Detenido tras varios fallos seguidos. Abra Clash Verge para ver por qué.
tunModeToggled:
title: Modo TUN
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: پروکسی سیستم
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: پراکسی سیستم
body: تنظیم پراکسی سیستم ممکن نشد. برای دیدن دلیل، Clash Verge را باز کنید.
systemProxyGuardStopped:
title: نگهبان پراکسی سیستم
body: پس از چند شکست پیاپی متوقف شد. برای دیدن دلیل، Clash Verge را باز کنید.
tunModeToggled:
title: حالت TUN
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: Proksi Sistem
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Proxy Sistem
body: Proxy sistem tidak dapat diatur. Buka Clash Verge untuk melihat alasannya.
systemProxyGuardStopped:
title: Penjaga Proxy Sistem
body: Berhenti setelah gagal beberapa kali berturut-turut. Buka Clash Verge untuk melihat alasannya.
tunModeToggled:
title: Mode TUN
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: システムプロキシ
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: システムプロキシ
body: システムプロキシを設定できませんでした。理由は Clash Verge を開いて確認してください。
systemProxyGuardStopped:
title: システムプロキシガード
body: 連続して失敗したため停止しました。理由は Clash Verge を開いて確認してください。
tunModeToggled:
title: TUN モード
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: 시스템 프록시
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: 시스템 프록시
body: 시스템 프록시를 설정하지 못했습니다. 이유는 Clash Verge를 열어 확인하십시오.
systemProxyGuardStopped:
title: 시스템 프록시 가드
body: 연속으로 실패하여 중지되었습니다. 이유는 Clash Verge를 열어 확인하십시오.
tunModeToggled:
title: TUN 모드
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: Системный прокси
'on': Системный прокси включён.
'off': Системный прокси отключён.
systemProxyFailed:
title: Системный прокси
body: Не удалось настроить системный прокси. Откройте Clash Verge, чтобы узнать причину.
systemProxyGuardStopped:
title: Страж системного прокси
body: Остановлен после нескольких неудач подряд. Откройте Clash Verge, чтобы узнать причину.
tunModeToggled:
title: Режим TUN
'on': Режим TUN включён.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: Sistem Vekil'i
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Sistem Vekili
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
systemProxyGuardStopped:
title: Sistem Vekili Koruması
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
tunModeToggled:
title: TUN Modu
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: Системалы прокси
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Система проксие
body: Система проксиен көйләп булмады. Сәбәбен күрер өчен Clash Verge'ны ачыгыз.
systemProxyGuardStopped:
title: Система проксие сакчысы
body: Берничә тапкыр рәттән уңышсыз булгач туктады. Сәбәбен күрер өчен Clash Verge'ны ачыгыз.
tunModeToggled:
title: TUN режимы
'on': TUN mode has been enabled.
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: 系统代理
'on': 系统代理已启用。
'off': 系统代理已禁用。
systemProxyFailed:
title: 系统代理
body: 系统代理设置失败。打开 Clash Verge 查看原因。
systemProxyGuardStopped:
title: 系统代理守卫
body: 连续多次失败后已停止守卫。打开 Clash Verge 查看原因。
tunModeToggled:
title: TUN 模式
'on': TUN 模式已开启。
+6
View File
@@ -10,6 +10,12 @@ notifications:
title: 系統代理
'on': 系統代理已啟用。
'off': 系統代理已關閉。
systemProxyFailed:
title: 系統代理
body: 系統代理設定失敗。開啟 Clash Verge 查看原因。
systemProxyGuardStopped:
title: 系統代理守衛
body: 連續多次失敗後已停止守衛。開啟 Clash Verge 查看原因。
tunModeToggled:
title: 虛擬網路介面卡模式
'on': 虛擬網路介面卡模式已啟用。
+1 -1
View File
@@ -2,7 +2,7 @@ import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import yaml from 'js-yaml'
import * as yaml from 'js-yaml'
import ts from 'typescript'
const __filename = fileURLToPath(import.meta.url)
+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))
}
+3 -2
View File
@@ -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 => {
+108
View File
@@ -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,
+2 -2
View File
@@ -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"))
}
}
}
+195 -38
View File
@@ -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),
|| {
+3 -4
View File
@@ -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:#}"
)));
}
}
}
+1 -1
View File
@@ -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;
+386 -1
View File
@@ -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));
}
}
+777 -31
View File
@@ -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
View File
@@ -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(&nothing_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, &nothing_written, false),
AuthoritativeState::Unchanged
);
assert_eq!(
authoritative_state_from(true, &partly_written, false),
AuthoritativeState::Unknown
);
assert_eq!(
authoritative_state_from(true, &nothing_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(),
&nothing_wanted,
&pac_off()
));
assert!(!target_is_already_in_place(
&holding_global(7897),
&nothing_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() {
+1 -1
View File
@@ -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;
+5 -1
View File
@@ -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(())
}
+2 -2
View File
@@ -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:#}");
}
}
}
+80 -5
View File
@@ -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()));
}
}
+8 -12
View File
@@ -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
View File
@@ -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);
}
}
+1
View File
@@ -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,
+39 -1
View File
@@ -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));
}
}
+5 -10
View File
@@ -143,7 +143,8 @@ export const ProxyTunCard: FC = () => {
const { verge } = useVerge()
const { isTunModeAvailable } = useSystemState()
const { configState: systemProxyConfigState } = useSystemProxyState()
// Display the observed OS state, not the requested configuration.
const { indicator: systemProxyIndicator } = useSystemProxyState()
const { enable_tun_mode } = verge ?? {}
@@ -159,7 +160,7 @@ export const ProxyTunCard: FC = () => {
const tabDescription = useMemo(() => {
if (activeTab === 'system') {
return {
text: systemProxyConfigState
text: systemProxyIndicator
? t('home.components.proxyTun.status.systemProxyEnabled')
: t('home.components.proxyTun.status.systemProxyDisabled'),
tooltip: t('home.components.proxyTun.tooltips.systemProxy'),
@@ -174,13 +175,7 @@ export const ProxyTunCard: FC = () => {
tooltip: t('home.components.proxyTun.tooltips.tunMode'),
}
}
}, [
activeTab,
systemProxyConfigState,
enable_tun_mode,
isTunModeAvailable,
t,
])
}, [activeTab, systemProxyIndicator, enable_tun_mode, isTunModeAvailable, t])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
@@ -199,7 +194,7 @@ export const ProxyTunCard: FC = () => {
onClick={() => handleTabChange('system')}
icon={ComputerRounded}
label={t('settings.sections.system.toggles.systemProxy')}
hasIndicator={systemProxyConfigState}
hasIndicator={systemProxyIndicator}
/>
<TabButton
isActive={activeTab === 'tun'}
@@ -0,0 +1,173 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import { afterEach, beforeAll, expect, it, vi } from 'vitest'
import {
getSnapshotNotices,
hideNotice,
showNotice,
} from '@/services/notice-service'
import { NoticeManager } from './notice-manager'
const writeText = vi.fn((_text: string) => Promise.resolve())
const restartCore = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const installService = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const getRuntimeState = vi.hoisted(() => vi.fn())
vi.mock('@/services/cmds', () => ({
restartCore,
installService,
getRuntimeState,
}))
vi.mock('@/utils/get-system', () => ({ default: () => 'macos' }))
beforeAll(async () => {
await i18next.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
settings: {
modals: {
backup: {
messages: { backupFailed: 'Backup failed: {{error}}' },
},
},
},
},
},
},
interpolation: { escapeValue: false },
})
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
})
afterEach(() => {
restartCore.mockClear()
restartCore.mockImplementation(() => Promise.resolve())
installService.mockClear()
installService.mockImplementation(() => Promise.resolve())
getRuntimeState.mockReset()
getRuntimeState.mockResolvedValue({ mode: 'Service', serviceUsable: true })
cleanup()
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
writeText.mockClear()
})
const noticeText = () => screen.getByRole('alert').textContent ?? ''
it('renders a failure detail that fits, in full', () => {
showNotice.error({ code: 'CORE_START_FAILED', detail: 'connection refused' })
render(<NoticeManager />)
expect(noticeText()).toContain('connection refused')
})
it('shortens an unbounded detail', () => {
showNotice.error({
code: 'SERVICE_INSTALL_FAILED',
detail: `${'x'.repeat(4000)} tail`,
})
render(<NoticeManager />)
expect(noticeText().length).toBeLessThanOrEqual(501)
expect(noticeText()).not.toContain('tail')
expect(noticeText()).toContain('…')
})
it('shortens a detail a caller interpolated into its own message', () => {
showNotice.error('profiles.page.feedback.notices.emergencyRefreshFailed', {
message: 'y'.repeat(4000),
})
render(<NoticeManager />)
expect(noticeText().length).toBeLessThanOrEqual(501)
expect(noticeText()).toContain('…')
})
it('renders a failure a caller interpolated, not [object Object]', () => {
showNotice.error('settings.modals.backup.messages.backupFailed', {
error: { code: 'BACKUP_FAILED', detail: 'webdav returned 401' },
})
render(<NoticeManager />)
expect(noticeText()).toBe('Backup failed: webdav returned 401')
})
it('renders a failure inside a descriptor, not [object Object]', () => {
showNotice.error({
key: 'settings.modals.backup.messages.backupFailed',
params: { error: { code: 'BACKUP_FAILED', detail: 'webdav returned 401' } },
})
render(<NoticeManager />)
expect(noticeText()).toBe('Backup failed: webdav returned 401')
})
it('leaves the ordinary lifetime alone where there is nothing to offer', () => {
showNotice.error({ code: 'CORE_START_FAILED', detail: 'connection refused' })
expect(getSnapshotNotices().at(-1)?.duration).toBeGreaterThan(0)
})
it('finds the code a caller interpolated into its own message', () => {
showNotice.error('settings.modals.backup.messages.backupFailed', {
error: { code: 'SYSPROXY_GUARD_STOPPED', detail: 'the guard gave up' },
})
expect(getSnapshotNotices().at(-1)?.code).toBe('SYSPROXY_GUARD_STOPPED')
})
it('shows one notice per code, replacing rather than stacking', async () => {
showNotice.error({ code: 'SYSPROXY_GUARD_STOPPED', detail: 'first' })
showNotice.error({ code: 'SYSPROXY_GUARD_STOPPED', detail: 'second' })
render(<NoticeManager />)
expect(screen.getAllByRole('alert')).toHaveLength(1)
expect(screen.getByRole('alert').textContent).toContain('second')
})
it('remembers which way the user was asking, for whoever acts on it next', () => {
showNotice.error({
code: 'SYSPROXY_GUARD_STOPPED',
detail: 'admin privileges required to modify system proxy',
operation: 'systemProxyEnable',
})
expect(getSnapshotNotices().at(-1)?.operation).toBe('systemProxyEnable')
})
it('copies the whole detail, not the shortened one', async () => {
const detail = `${'x'.repeat(4000)} tail`
showNotice.error({ code: 'SERVICE_INSTALL_FAILED', detail })
render(<NoticeManager />)
fireEvent.contextMenu(screen.getByRole('alert'))
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
expect(writeText).toHaveBeenCalledWith(detail)
})
it('copies the whole message when the caller interpolated the detail', async () => {
const detail = 'y'.repeat(4000)
showNotice.error('profiles.page.feedback.notices.emergencyRefreshFailed', {
message: detail,
})
render(<NoticeManager />)
fireEvent.contextMenu(screen.getByRole('alert'))
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
expect(writeText.mock.calls[0]?.[0]).toContain(detail)
})
+45 -17
View File
@@ -4,12 +4,14 @@ import {
Alert,
IconButton,
Box,
Stack,
type SnackbarOrigin,
} from '@mui/material'
import React, { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import {
boundNoticeText,
subscribeNotices,
hideNotice,
getSnapshotNotices,
@@ -43,14 +45,31 @@ const getAnchorOrigin = (position: NoticePosition): SnackbarOrigin => {
return { vertical, horizontal }
}
/** Resolve notice text, bounding display output but not clipboard content. */
const resolveNoticeMessage = (
notice: NoticeItem,
t: TranslationFn,
options?: { bounded?: boolean },
): React.ReactNode => {
const i18n = notice.i18n
if (!i18n) return notice.message
const bound = (text: React.ReactNode): React.ReactNode =>
options?.bounded === false || typeof text !== 'string'
? text
: boundNoticeText(text)
const params = (i18n.params ?? {}) as Record<string, unknown>
const i18n = notice.i18n
if (!i18n) return bound(notice.message)
const source = (i18n.params ?? {}) as Record<string, unknown>
// Bound both parameters and their final interpolation.
const params =
options?.bounded === false
? source
: Object.fromEntries(
Object.entries(source).map(([key, value]) => [
key,
typeof value === 'string' ? boundNoticeText(value) : value,
]),
)
const { prefixKey, prefixParams, prefix, message, ...restParams } = params
const prefixKeyParams =
@@ -78,12 +97,14 @@ const resolveNoticeMessage = (
? `${resolvedPrefix} ${messageStr}`
: messageStr
return t(i18n.key as TranslationKey, {
defaultValue,
...restParams,
...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
...(messageStr !== undefined ? { message: messageStr } : {}),
})
return bound(
t(i18n.key as TranslationKey, {
defaultValue,
...restParams,
...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
...(messageStr !== undefined ? { message: messageStr } : {}),
}),
)
}
const extractNoticeCopyText = (input: unknown): string | undefined => {
@@ -120,8 +141,9 @@ const resolveNoticeCopyText = (
}
return (
extractNoticeCopyText(resolveNoticeMessage(notice, t)) ??
extractNoticeCopyText(notice.message)
extractNoticeCopyText(
resolveNoticeMessage(notice, t, { bounded: false }),
) ?? extractNoticeCopyText(notice.message)
)
}
@@ -202,13 +224,19 @@ export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
void handleNoticeCopy(notice)
}}
action={
<IconButton
size="small"
color="inherit"
onClick={() => handleClose(notice.id)}
<Stack
direction="row"
spacing={0.5}
sx={{ alignItems: 'center' }}
>
<CloseRounded fontSize="inherit" />
</IconButton>
<IconButton
size="small"
color="inherit"
onClick={() => handleClose(notice.id)}
>
<CloseRounded fontSize="inherit" />
</IconButton>
</Stack>
}
>
{resolveNoticeMessage(notice, t)}
@@ -0,0 +1,350 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import { afterEach, beforeAll, expect, it, vi } from 'vitest'
import type { PendingFailure } from '@/services/cmds'
import { getSnapshotNotices, hideNotice } from '@/services/notice-service'
import { clearServiceRequest, requestService } from '@/services/service-request'
const reported = (key: string) =>
getSnapshotNotices().some(
(notice) =>
notice.i18n?.key === key ||
Object.values(notice.i18n?.params ?? {}).some(
(param) =>
typeof param === 'object' &&
param !== null &&
'key' in param &&
(param as { key?: unknown }).key === key,
),
)
import { SysproxyPrivilegeDialog } from './sysproxy-privilege-dialog'
const restartCore = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const installService = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const getRuntimeState = vi.hoisted(() => vi.fn())
const getPendingFailures = vi.hoisted(() => vi.fn())
const patchVergeConfig = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const onFocusChanged = vi.hoisted(() => vi.fn(() => Promise.resolve(() => {})))
vi.mock('@/services/cmds', () => ({
restartCore,
installService,
getRuntimeState,
getPendingFailures,
patchVergeConfig,
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ onFocusChanged }),
}))
let onSubscribed: (() => void) | undefined
vi.mock('@/services/events', () => ({
subscribeVergeEvents: (
_handlers: Record<string, () => void>,
subscribed?: () => void,
) => {
onSubscribed = subscribed
return () => {}
},
}))
const failure = (
code: string,
operation: PendingFailure['operation'] = 'systemProxyEnable',
): PendingFailure => ({
code,
detail: 'admin privileges required to modify system proxy',
operation,
sequence: 1,
})
beforeAll(async () => {
await i18next.use(initReactI18next).init({
lng: 'en',
resources: { en: { translation: {} } },
interpolation: { escapeValue: false },
})
})
afterEach(() => {
restartCore.mockClear()
restartCore.mockImplementation(() => Promise.resolve())
installService.mockClear()
installService.mockImplementation(() => Promise.resolve())
getRuntimeState.mockReset()
getRuntimeState.mockResolvedValue({ mode: 'Service', serviceUsable: true })
getPendingFailures.mockReset()
patchVergeConfig.mockClear()
patchVergeConfig.mockImplementation(() => Promise.resolve())
onSubscribed = undefined
clearServiceRequest()
cleanup()
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
})
const showing = async (
code: string,
operation: PendingFailure['operation'] = 'systemProxyEnable',
) => {
getPendingFailures.mockResolvedValue([failure(code, operation)])
render(<SysproxyPrivilegeDialog />)
onSubscribed?.()
await screen.findByRole('dialog')
}
const clickPrimary = (name: string) =>
fireEvent.click(screen.getByRole('button', { name }))
it('offers to install the service when the write was refused', async () => {
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
expect(
screen.getByRole('button', {
name: 'settings.sections.proxyControl.actions.installService',
}),
).toBeTruthy()
})
it('offers only a restart when the service is already there', async () => {
await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY')
expect(
screen.getByRole('button', {
name: 'settings.sections.proxyControl.actions.switchToServiceMode',
}),
).toBeTruthy()
expect(
screen.queryByRole('button', {
name: 'settings.sections.proxyControl.actions.installService',
}),
).toBeNull()
})
it('installs the service, restarts into it, and makes the request again', async () => {
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() => expect(installService).toHaveBeenCalledOnce())
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
await waitFor(() =>
expect(patchVergeConfig).toHaveBeenCalledWith({
enable_system_proxy: true,
}),
)
await waitFor(() =>
expect(
reported(
'settings.sections.proxyControl.messages.installedProxyRestored',
),
).toBe(true),
)
})
it('replays the direction that was asked for, not always “on”', async () => {
await showing('SYSPROXY_PRIVILEGE_REQUIRED', 'systemProxyDisable')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() =>
expect(patchVergeConfig).toHaveBeenCalledWith({
enable_system_proxy: false,
}),
)
})
it('changes nothing when nobody asked for a proxy state', async () => {
await showing('SYSPROXY_PRIVILEGE_REQUIRED', 'systemProxyRestore')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
expect(patchVergeConfig).not.toHaveBeenCalled()
await waitFor(() =>
expect(
reported('settings.sections.proxyControl.messages.installedCheckProxy'),
).toBe(true),
)
})
it('does not install again when only the restart is needed', async () => {
await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY')
clickPrimary('settings.sections.proxyControl.actions.switchToServiceMode')
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
expect(installService).not.toHaveBeenCalled()
})
it('checks what actually happened rather than trusting the restart', async () => {
getRuntimeState.mockResolvedValue({ mode: 'Sidecar', serviceUsable: true })
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() =>
expect(
reported(
'settings.sections.proxyControl.messages.installedCoreNotOnService',
),
).toBe(true),
)
})
const deferred = () => {
let resolve!: () => void
const promise = new Promise<void>((r) => {
resolve = r
})
return { promise, resolve }
}
it('says which part of the wait it is in, rather than looking stuck', async () => {
const installing = deferred()
const restarting = deferred()
const applying = deferred()
installService.mockImplementation(() => installing.promise)
restartCore.mockImplementation(() => restarting.promise)
patchVergeConfig.mockImplementation(() => applying.promise)
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('settings.sections.proxyControl.actions.installService')
await screen.findByText('layout.components.sysproxyPrivilege.installing')
installing.resolve()
await screen.findByText('layout.components.sysproxyPrivilege.restarting')
restarting.resolve()
await screen.findByText('layout.components.sysproxyPrivilege.applying')
applying.resolve()
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
it('does not claim to be installing when only a restart is needed', async () => {
const restarting = deferred()
restartCore.mockImplementation(() => restarting.promise)
await showing('SYSPROXY_SIDECAR_WHILE_SERVICE_READY')
clickPrimary('settings.sections.proxyControl.actions.switchToServiceMode')
await screen.findByText('layout.components.sysproxyPrivilege.restarting')
expect(
screen.queryByText('layout.components.sysproxyPrivilege.installing'),
).toBeNull()
restarting.resolve()
})
it('offers no way out while the installer is holding a password prompt', async () => {
const installing = deferred()
installService.mockImplementation(() => installing.promise)
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('settings.sections.proxyControl.actions.installService')
await screen.findByText('layout.components.sysproxyPrivilege.installing')
expect(
screen.queryByRole('button', {
name: 'layout.components.sysproxyPrivilege.later',
}),
).toBeNull()
installing.resolve()
})
it('closes once the core is on the service, whatever the table still says', async () => {
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
it('stays open when the core did not reach the service', async () => {
getRuntimeState.mockResolvedValue({ mode: 'Sidecar', serviceUsable: false })
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
expect(screen.getByRole('dialog')).toBeTruthy()
})
it('does not go on to restart when the install itself failed', async () => {
installService.mockRejectedValue({
code: 'SERVICE_INSTALL_FAILED',
detail: 'user cancelled',
})
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() => expect(installService).toHaveBeenCalledOnce())
expect(restartCore).not.toHaveBeenCalled()
expect(getRuntimeState).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeTruthy()
})
it('closes on “later” without touching the machine', async () => {
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
clickPrimary('layout.components.sysproxyPrivilege.later')
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(installService).not.toHaveBeenCalled()
expect(restartCore).not.toHaveBeenCalled()
})
it('answers a switch that asked outright, with no failure to go on', async () => {
getPendingFailures.mockResolvedValue([])
render(<SysproxyPrivilegeDialog />)
onSubscribed?.()
act(() =>
requestService({
reason: 'tunNeedsService',
restore: { enable_tun_mode: true },
}),
)
await screen.findByText('layout.components.sysproxyPrivilege.tunMessage')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() => expect(installService).toHaveBeenCalledOnce())
await waitFor(() =>
expect(patchVergeConfig).toHaveBeenCalledWith({ enable_tun_mode: true }),
)
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
it('shows what the user is doing now over what the table still holds', async () => {
await showing('SYSPROXY_PRIVILEGE_REQUIRED')
act(() => requestService({ reason: 'tunNeedsService' }))
await screen.findByText('layout.components.sysproxyPrivilege.tunMessage')
})
it('changes nothing when a switch asked without saying what for', async () => {
getPendingFailures.mockResolvedValue([])
render(<SysproxyPrivilegeDialog />)
onSubscribed?.()
act(() => requestService({ reason: 'tunNeedsService' }))
await screen.findByRole('dialog')
clickPrimary('settings.sections.proxyControl.actions.installService')
await waitFor(() => expect(restartCore).toHaveBeenCalledOnce())
expect(patchVergeConfig).not.toHaveBeenCalled()
})
@@ -0,0 +1,155 @@
import { Alert, LinearProgress, Typography } from '@mui/material'
import { useState, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
import { useDialogFailure } from '@/pages/_layout/hooks'
import {
getRuntimeState,
installService,
patchVergeConfig,
restartCore,
type FailedOperation,
type PendingFailure,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import {
clearServiceRequest,
getServiceRequest,
subscribeServiceRequest,
type ServiceRequest,
type ServiceRequestReason,
} from '@/services/service-request'
type Remedy = 'installAndRestart' | 'restartOnly'
const remedyFor = (reason: ServiceRequestReason): Remedy =>
reason === 'sysproxySidecarReady' ? 'restartOnly' : 'installAndRestart'
const EXPLANATION = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.message',
sysproxySidecarReady:
'layout.components.sysproxyPrivilege.serviceReadyMessage',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunMessage',
} as const
const TITLE = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.title',
sysproxySidecarReady: 'layout.components.sysproxyPrivilege.title',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
} as const
const stateToRestore = (
operation: FailedOperation,
): Partial<IVergeConfig> | undefined => {
switch (operation) {
case 'systemProxyEnable':
return { enable_system_proxy: true }
case 'systemProxyDisable':
return { enable_system_proxy: false }
case 'systemProxyRestore':
case 'systemProxyGuard':
return undefined
}
}
const asServiceRequest = (failure: PendingFailure): ServiceRequest => ({
reason:
failure.code === 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY'
? 'sysproxySidecarReady'
: 'sysproxyRefused',
restore: stateToRestore(failure.operation),
})
type Step = 'idle' | 'installing' | 'restarting' | 'applying'
const STEP_MESSAGE = {
installing: 'layout.components.sysproxyPrivilege.installing',
restarting: 'layout.components.sysproxyPrivilege.restarting',
applying: 'layout.components.sysproxyPrivilege.applying',
} as const
/** Guide recovery from a refused system-proxy write. */
export const SysproxyPrivilegeDialog = () => {
const { t } = useTranslation()
const { failure, dismiss } = useDialogFailure()
const asked = useSyncExternalStore(subscribeServiceRequest, getServiceRequest)
const [step, setStep] = useState<Step>('idle')
const loading = step !== 'idle'
// Prefer the request the user just made over a pending failure.
const request = asked ?? (failure ? asServiceRequest(failure) : null)
const reason = request?.reason ?? 'sysproxyRefused'
const remedy = remedyFor(reason)
const restoring = request?.restore
const close = () => {
clearServiceRequest()
dismiss()
}
const handleFix = async () => {
try {
if (remedy === 'installAndRestart') {
setStep('installing')
await installService()
}
setStep('restarting')
await restartCore()
// Verify that restart actually moved the core into the service.
const runState = await getRuntimeState()
if (runState.mode === 'Service') {
// Retry the original request now that the service can apply it.
if (restoring !== undefined) {
setStep('applying')
await patchVergeConfig(restoring)
}
showNotice.success(
restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
close()
} else {
showNotice.error(
'settings.sections.proxyControl.messages.installedCoreNotOnService',
)
}
} catch (error) {
showNotice.error(error)
} finally {
setStep('idle')
}
}
return (
<BaseDialog
open={Boolean(request)}
title={t(TITLE[reason])}
okBtn={t(
remedy === 'installAndRestart'
? 'settings.sections.proxyControl.actions.installService'
: 'settings.sections.proxyControl.actions.switchToServiceMode',
)}
cancelBtn={t('layout.components.sysproxyPrivilege.later')}
// Keep the primary spinner visible; only cancellation is unavailable.
disableCancel={loading}
loading={loading}
onOk={() => void handleFix()}
onCancel={close}
onClose={close}
>
<Alert severity={loading ? 'info' : 'warning'} sx={{ mb: 1.5 }}>
{t(step !== 'idle' ? STEP_MESSAGE[step] : EXPLANATION[reason])}
</Alert>
{loading && <LinearProgress />}
{!loading && reason === 'sysproxyRefused' && (
<Typography variant="body2" color="text.secondary">
{t('layout.components.sysproxyPrivilege.alternative')}
</Typography>
)}
</BaseDialog>
)
}
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { errorDetail, showNotice } from '@/services/notice-service'
import {
buildWebdavSignature,
getWebdavStatus,
@@ -138,7 +138,7 @@ export const BackupConfigViewer = memo(
} catch (error) {
showNotice.error(
'settings.modals.backup.messages.webdavConfigSaveFailed',
{ error },
{ error: errorDetail(error) },
3000,
)
} finally {
@@ -19,7 +19,7 @@ import {
createWebdavBackup,
importLocalBackup,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { errorDetail, showNotice } from '@/services/notice-service'
import { buildWebdavSignature, setWebdavStatus } from '@/services/webdav-status'
import { AutoBackupSettings } from './auto-backup-settings'
@@ -68,7 +68,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
target === 'local'
? 'settings.modals.backup.messages.localBackupFailed'
: 'settings.modals.backup.messages.backupFailed',
target === 'local' ? undefined : { error },
target === 'local' ? undefined : { error: errorDetail(error) },
)
if (target === 'webdav') {
setWebdavStatus(webdavSignature, 'failed')
@@ -93,7 +93,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
console.error(error)
showNotice.error(
'settings.modals.backup.messages.localBackupImportFailed',
{ error },
{ error: errorDetail(error) },
)
} finally {
setLocalImporting(false)
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog, BaseLoadingOverlay } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import { listWebDavBackup } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { errorDetail, showNotice } from '@/services/notice-service'
import { buildWebdavSignature, setWebdavStatus } from '@/services/webdav-status'
import { BackupConfigViewer } from './backup-config-viewer'
@@ -52,7 +52,7 @@ export const BackupWebdavDialog = ({
setWebdavStatus(signature, 'failed')
showNotice.error(
'settings.modals.backup.messages.webdavRefreshFailed',
{ error },
{ error: errorDetail(error) },
)
} finally {
handleLoading(false)
@@ -0,0 +1,145 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { getSnapshotNotices, hideNotice } from '@/services/notice-service'
import {
clearServiceRequest,
getServiceRequest,
} from '@/services/service-request'
import ProxyControlSwitches from './proxy-control-switches'
const patchVerge = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mutateVerge = vi.hoisted(() => vi.fn())
const isTunModeAvailable = vi.hoisted(() => ({ current: false }))
const tunEnabled = vi.hoisted(() => ({ current: false }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/hooks/use-verge', () => ({
useVerge: () => ({
verge: { enable_tun_mode: tunEnabled.current },
mutateVerge,
patchVerge,
}),
}))
vi.mock('@/hooks/use-system-state', () => ({
useSystemState: () => ({
runState: { serviceUsable: false },
isTunModeAvailable: isTunModeAvailable.current,
}),
}))
vi.mock('@/hooks/use-system-proxy-state', () => ({
useSystemProxyState: () => ({
indicator: false,
toggleSystemProxy: vi.fn(),
}),
}))
vi.mock('@/hooks/use-service-uninstaller', () => ({
useServiceUninstaller: () => ({ uninstallServiceAndStartSidecar: vi.fn() }),
}))
vi.mock('@/components/setting/mods/sysproxy-viewer', () => ({
SysproxyViewer: () => null,
}))
vi.mock('@/components/setting/mods/tun-viewer', () => ({
TunViewer: () => null,
}))
afterEach(() => {
patchVerge.mockClear()
mutateVerge.mockClear()
isTunModeAvailable.current = false
tunEnabled.current = false
clearServiceRequest()
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
cleanup()
})
const renderTun = () =>
render(
<ProxyControlSwitches label="settings.sections.system.toggles.tunMode" />,
)
const theSwitch = () => screen.getByRole('switch') as HTMLInputElement
const flip = () => fireEvent.click(theSwitch())
it('asks for the service when TUN cannot work, saying what it was for', async () => {
renderTun()
flip()
await waitFor(() =>
expect(getServiceRequest()).toEqual({
reason: 'tunNeedsService',
restore: { enable_tun_mode: true },
}),
)
expect(patchVerge).not.toHaveBeenCalled()
})
it('leaves the switch where it was until something can carry the request', async () => {
renderTun()
flip()
await waitFor(() => expect(theSwitch().checked).toBe(false))
})
it('patches directly when the service can already carry it', async () => {
isTunModeAvailable.current = true
renderTun()
flip()
await waitFor(() =>
expect(patchVerge).toHaveBeenCalledWith({ enable_tun_mode: true }),
)
expect(getServiceRequest()).toBeNull()
})
it('no longer offers a separate install button', () => {
renderTun()
expect(
screen.queryByLabelText(
'settings.sections.proxyControl.actions.installService',
),
).toBeNull()
})
it('does not ask for a service in order to turn TUN off', async () => {
tunEnabled.current = true
renderTun()
flip()
await waitFor(() =>
expect(patchVerge).toHaveBeenCalledWith({ enable_tun_mode: false }),
)
expect(getServiceRequest()).toBeNull()
})
it('says nothing beside the dialog it just opened', async () => {
const onError = vi.fn()
render(
<ProxyControlSwitches
label="settings.sections.system.toggles.tunMode"
onError={onError}
/>,
)
flip()
await waitFor(() => expect(getServiceRequest()).not.toBeNull())
await waitFor(() => expect(theSwitch().checked).toBe(false))
expect(onError).not.toHaveBeenCalled()
expect(getSnapshotNotices()).toEqual([])
})
@@ -1,5 +1,4 @@
import {
BuildRounded,
DeleteForeverRounded,
PauseCircleOutlineRounded,
PlayCircleOutlineRounded,
@@ -8,18 +7,18 @@ import {
} from '@mui/icons-material'
import { Box, Typography, alpha, useTheme } from '@mui/material'
import { useLockFn } from 'ahooks'
import React, { useCallback, useRef, useState } from 'react'
import React, { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DialogRef, Switch, TooltipIcon } from '@/components/base'
import { SysproxyViewer } from '@/components/setting/mods/sysproxy-viewer'
import { TunViewer } from '@/components/setting/mods/tun-viewer'
import { useServiceInstaller } from '@/hooks/use-service-installer'
import { useServiceUninstaller } from '@/hooks/use-service-uninstaller'
import { useSystemProxyState } from '@/hooks/use-system-proxy-state'
import { useSystemState } from '@/hooks/use-system-state'
import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service'
import { requestService } from '@/services/service-request'
interface ProxySwitchProps {
label?: string
@@ -34,7 +33,8 @@ interface SwitchRowProps {
infoTitle: string
onInfoClick?: () => void
extraIcons?: React.ReactNode
onToggle: (value: boolean) => Promise<void>
/** Return false to roll back without reporting an error. */
onToggle: (value: boolean) => Promise<boolean | void>
onError?: (err: Error) => void
highlight?: boolean
}
@@ -68,6 +68,9 @@ const SwitchRow = ({
pendingRef.current = true
setChecked(value)
onToggle(value)
.then((applied) => {
if (applied === false) setChecked(active)
})
.catch((err: any) => {
setChecked(active)
onError?.(err)
@@ -131,11 +134,10 @@ const ProxyControlSwitches = ({
}: ProxySwitchProps) => {
const { t } = useTranslation()
const { verge, mutateVerge, patchVerge } = useVerge()
const { installServiceAndRestartCore } = useServiceInstaller()
const { uninstallServiceAndStartSidecar } = useServiceUninstaller()
const { indicator: systemProxyIndicator, toggleSystemProxy } =
useSystemProxyState()
const { runState, isTunModeAvailable, mutateSystemState } = useSystemState()
const { runState, isTunModeAvailable } = useSystemState()
// Offer to uninstall only a service that is actually there and working.
const isServiceInstallReady = runState.serviceUsable
@@ -144,30 +146,18 @@ const ProxyControlSwitches = ({
const { enable_tun_mode } = verge ?? {}
const showErrorNotice = useCallback(
(msg: string) => showNotice.error(msg),
[],
)
const handleTunToggle = async (value: boolean) => {
if (!isTunModeAvailable) {
const msgKey = 'settings.sections.proxyControl.tooltips.tunUnavailable'
showErrorNotice(msgKey)
throw new Error(t(msgKey))
if (value && !isTunModeAvailable) {
requestService({
reason: 'tunNeedsService',
restore: { enable_tun_mode: value },
})
return false
}
mutateVerge({ ...verge, enable_tun_mode: value }, false)
await patchVerge({ enable_tun_mode: value })
}
const onInstallService = useLockFn(async () => {
try {
await installServiceAndRestartCore()
await mutateSystemState()
} catch (err) {
showNotice.error(err)
}
})
const onUninstallService = useLockFn(async () => {
try {
await uninstallServiceAndStartSidecar()
@@ -202,29 +192,17 @@ const ProxyControlSwitches = ({
onInfoClick={() => tunRef.current?.open()}
onToggle={handleTunToggle}
onError={onError}
disabled={!isTunModeAvailable}
highlight={enable_tun_mode || false}
extraIcons={
<>
{!isTunModeAvailable && (
<>
<TooltipIcon
title={t(
'settings.sections.proxyControl.tooltips.tunUnavailable',
)}
icon={WarningRounded}
sx={{ color: 'warning.main', ml: 1 }}
/>
<TooltipIcon
title={t(
'settings.sections.proxyControl.actions.installService',
)}
icon={BuildRounded}
color="primary"
onClick={onInstallService}
sx={{ ml: 1 }}
/>
</>
<TooltipIcon
title={t(
'settings.sections.proxyControl.tooltips.tunUnavailable',
)}
icon={WarningRounded}
sx={{ color: 'warning.main', ml: 1 }}
/>
)}
{isServiceInstallReady && (
<TooltipIcon
+147
View File
@@ -0,0 +1,147 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useSystemProxyState } from './use-system-proxy-state'
const patchVergeConfig = vi.hoisted(() => vi.fn())
const mutateVerge = vi.hoisted(() => vi.fn())
const getCacheData = vi.hoisted(() => vi.fn())
const revalidateQueries = vi.hoisted(() => vi.fn())
const CACHED: IVergeConfig = { enable_system_proxy: false } as IVergeConfig
vi.mock('@/hooks/use-verge', () => ({
useVerge: () => ({ verge: CACHED, mutateVerge }),
}))
vi.mock('@/hooks/use-displayed-mixed-port', () => ({
useDisplayedMixedPort: () => 7897,
}))
vi.mock('@/providers/app-data-context', () => ({
useSystemData: () => ({ sysproxy: undefined }),
}))
vi.mock('@/services/cmds', () => ({
getAutotemProxy: vi.fn(),
getEmbeddedServerPort: vi.fn(),
patchVergeConfig,
}))
vi.mock('tauri-plugin-mihomo-api', () => ({
closeAllConnections: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/services/query-client', () => ({
useQuery: () => ({ data: undefined }),
getCacheData,
revalidateQueries,
}))
beforeEach(() => {
patchVergeConfig.mockReset()
mutateVerge.mockReset()
getCacheData.mockReset()
getCacheData.mockReturnValue(CACHED)
revalidateQueries.mockReset()
revalidateQueries.mockResolvedValue([])
})
afterEach(() => {
cleanup()
})
const lastWrite = () => {
const call = mutateVerge.mock.calls[mutateVerge.mock.calls.length - 1]?.[0]
return typeof call === 'function' ? call(CACHED) : call
}
it('puts the cache back when the toggle fails', async () => {
patchVergeConfig.mockRejectedValue({
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
detail: 'refused',
})
const { result } = renderHook(() => useSystemProxyState())
await act(async () => {
await expect(result.current.toggleSystemProxy(true)).rejects.toBeTruthy()
})
expect(lastWrite()).toMatchObject({ enable_system_proxy: false })
})
it('falls back to what the last successful click reached, not to where the run began', async () => {
patchVergeConfig.mockResolvedValueOnce(undefined).mockRejectedValueOnce({
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
detail: 'refused',
})
getCacheData.mockReturnValue({ enable_system_proxy: true } as IVergeConfig)
const { result } = renderHook(() => useSystemProxyState())
await act(async () => {
const inFlight = result.current.toggleSystemProxy(false)
void result.current.toggleSystemProxy(true)
await expect(inFlight).rejects.toBeTruthy()
})
expect(lastWrite()).toMatchObject({ enable_system_proxy: false })
})
it('lets a revalidation failure surface when the toggle itself worked', async () => {
patchVergeConfig.mockResolvedValue(undefined)
revalidateQueries.mockRejectedValue(new Error('network gone'))
const { result } = renderHook(() => useSystemProxyState())
await act(async () => {
await expect(result.current.toggleSystemProxy(true)).rejects.toThrow(
'network gone',
)
})
})
it('reports the failure the backend classified, not a revalidation that failed after it', async () => {
patchVergeConfig.mockRejectedValue({
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
detail: 'refused',
})
revalidateQueries.mockRejectedValue(new Error('network gone'))
const { result } = renderHook(() => useSystemProxyState())
await act(async () => {
await expect(result.current.toggleSystemProxy(true)).rejects.toMatchObject({
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
})
})
})
it('revalidates the config it wrote to, not only the two proxy reads', async () => {
patchVergeConfig.mockResolvedValue(undefined)
const { result } = renderHook(() => useSystemProxyState())
await act(async () => {
await result.current.toggleSystemProxy(true)
})
expect(revalidateQueries).toHaveBeenCalledWith([
['getVergeConfig'],
['getSystemProxy'],
['getAutotemProxy'],
])
})
it('drops a click queued behind one that failed', async () => {
let rejectFirst: ((reason: unknown) => void) | undefined
patchVergeConfig.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject
}),
)
const { result } = renderHook(() => useSystemProxyState())
let inFlight: Promise<unknown> = Promise.resolve()
await act(async () => {
inFlight = result.current.toggleSystemProxy(true)
void result.current.toggleSystemProxy(false)
rejectFirst?.({ code: 'SYSPROXY_PRIVILEGE_REQUIRED', detail: 'refused' })
await expect(inFlight).rejects.toBeTruthy()
})
expect(patchVergeConfig).toHaveBeenCalledTimes(1)
})
+47 -7
View File
@@ -4,12 +4,20 @@ import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useVerge } from '@/hooks/use-verge'
import { useSystemData } from '@/providers/app-data-context'
import { getAutotemProxy, getEmbeddedServerPort } from '@/services/cmds'
import { revalidateQueries, useQuery } from '@/services/query-client'
import {
getAutotemProxy,
getEmbeddedServerPort,
patchVergeConfig,
} from '@/services/cmds'
import {
getCacheData,
revalidateQueries,
useQuery,
} from '@/services/query-client'
// 系统代理状态检测统一逻辑
export const useSystemProxyState = () => {
const { verge, mutateVerge, patchVerge } = useVerge()
const { verge, mutateVerge } = useVerge()
const { sysproxy } = useSystemData()
const displayedMixedPort = useDisplayedMixedPort()
const { data: autoproxy } = useQuery({
@@ -23,7 +31,7 @@ export const useSystemProxyState = () => {
queryFn: getEmbeddedServerPort,
})
const { enable_system_proxy, proxy_auto_config, proxy_host } = verge ?? {}
const { proxy_auto_config, proxy_host } = verge ?? {}
// OS 实际状态:enable + 地址匹配本应用
const indicator = (() => {
@@ -43,6 +51,10 @@ export const useSystemProxyState = () => {
const busyRef = useRef(false)
const toggleSystemProxy = async (enabled: boolean) => {
// Roll failed optimistic writes back to the latest confirmed state.
let confirmed =
getCacheData<IVergeConfig>(['getVergeConfig'])?.enable_system_proxy ??
false
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
false,
@@ -52,18 +64,47 @@ export const useSystemProxyState = () => {
if (busyRef.current) return
busyRef.current = true
let failed = false
try {
while (pendingRef.current !== null) {
const target = pendingRef.current
pendingRef.current = null
await patchVerge({ enable_system_proxy: target })
// Revalidate once below so a refetch failure cannot look like a patch failure.
await patchVergeConfig({ enable_system_proxy: target })
confirmed = target
if (!target && verge?.auto_close_connection) {
await closeAllConnections().catch(() => {})
}
}
} catch (error) {
failed = true
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev),
false,
)
// Queued requests assumed the failed state was reached.
pendingRef.current = null
throw error
} finally {
busyRef.current = false
await revalidateQueries([['getSystemProxy'], ['getAutotemProxy']])
const revalidated = revalidateQueries([
['getVergeConfig'],
['getSystemProxy'],
['getAutotemProxy'],
])
if (failed) {
// Preserve the classified toggle failure.
try {
await revalidated
} catch (error) {
console.warn(
'[system-proxy] revalidating after a failed toggle failed too:',
error,
)
}
} else {
await revalidated
}
}
}
@@ -72,7 +113,6 @@ export const useSystemProxyState = () => {
return {
indicator,
configState: enable_system_proxy ?? false,
toggleSystemProxy,
invalidateProxyState,
}
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "تعذّر تحديث حالة التشغيل",
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar"
}
},
"sysproxyPrivilege": {
"title": "تعذّر ضبط وكيل النظام",
"message": "رفض macOS هذا التغيير لعدم كفاية الصلاحيات. بعد تثبيت خدمة Clash Verge ستتولى الخدمة تطبيق إعدادات الوكيل بصلاحيات المسؤول، ولن يظهر هذا التنبيه مجددًا.",
"serviceReadyMessage": "خدمة Clash Verge جاهزة، لكن النواة ما زالت تعمل كـ sidecar، ولهذا رُفض ضبط وكيل النظام. التبديل إلى وضع الخدمة يحل المشكلة دون إعادة تثبيت.",
"alternative": "بديل آخر: عطّل خيار طلب كلمة مرور المسؤول للوصول إلى الإعدادات على مستوى النظام من إعدادات النظام ← الخصوصية والأمان ← خيارات متقدمة.",
"later": "لاحقًا",
"installing": "جارٍ تثبيت خدمة النظام. قد يُطلب منك كلمة مرور المسؤول.",
"restarting": "تم تثبيت الخدمة. جارٍ إعادة تشغيل النواة من خلالها…",
"applying": "جارٍ تطبيق تغيير وكيل النظام…",
"tunTitle": "يحتاج وضع TUN إلى خدمة النظام",
"tunMessage": "يحتاج وضع TUN إلى خدمة نظام Clash Verge للتحكم في حركة الشبكة. بعد التثبيت سيتم تشغيل وضع TUN تلقائيًا."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "تثبيت الخدمة ",
"uninstallService": "Uninstall Service"
"uninstallService": "Uninstall Service",
"switchToServiceMode": "التبديل إلى وضع الخدمة"
},
"fields": {
"systemProxy": "وكيل النظام",
"tunMode": "وضع TUN"
},
"messages": {
"installedCheckProxy": "تم تثبيت الخدمة والنواة تعمل من خلالها. تحقّق من مفتاح وكيل النظام وأعد التغيير إن لم يُطبَّق.",
"installedCoreNotOnService": "تم تثبيت الخدمة، لكن النواة لا تعمل من خلالها. تحقّق من النواة ثم أعد المحاولة.",
"installedProxyRestored": "تم تثبيت الخدمة، وتعمل النواة من خلالها، وتم تطبيق تغيير وكيل النظام الخاص بك."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "تعذّر إعادة تثبيت الخدمة",
"repairFailed": "تعذّر إصلاح الخدمة",
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar"
},
"sysproxy": {
"privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.",
"directFallback": "تعذّر ضبط وكيل النظام؛ تمت إعادة حركة البيانات إلى اتصال مباشر.",
"guardStopped": "توقف حارس وكيل النظام بعد عدة إخفاقات متتالية. لم يعد وكيل النظام يُحفَظ تلقائيًا.",
"sidecarWhileServiceReady": "تعذّر ضبط وكيل النظام: خدمة Clash Verge جاهزة لكن النواة ما زالت تعمل بوضع Sidecar. أعد تشغيل النواة للتبديل إلى وضع الخدمة."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "Laufstatus konnte nicht aktualisiert werden",
"sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen"
}
},
"sysproxyPrivilege": {
"title": "Systemproxy konnte nicht gesetzt werden",
"message": "macOS hat die Änderung mangels Berechtigungen abgelehnt. Nach Installation des Clash-Verge-Dienstes nimmt dieser die Proxy-Änderungen als Administrator vor, und dieser Hinweis erscheint nicht mehr.",
"serviceReadyMessage": "Der Clash-Verge-Dienst ist bereit, der Kern läuft aber noch als Sidecar — deshalb wurde das Setzen des Systemproxys abgelehnt. Ein Wechsel in den Dienstmodus behebt das, eine Neuinstallation ist nicht nötig.",
"alternative": "Alternativ: Deaktivieren Sie in den Systemeinstellungen → Datenschutz & Sicherheit → Erweitert die Option, die für systemweite Einstellungen ein Administratorkennwort verlangt.",
"later": "Später",
"installing": "Der Systemdienst wird installiert. Möglicherweise wird ein Administratorkennwort verlangt.",
"restarting": "Dienst installiert. Der Kern wird darüber neu gestartet…",
"applying": "Ihre Systemproxy-Änderung wird angewendet…",
"tunTitle": "Der TUN-Modus benötigt den Systemdienst",
"tunMessage": "Der TUN-Modus benötigt den Clash-Verge-Systemdienst, um den Netzwerkverkehr zu übernehmen. Nach der Installation wird der TUN-Modus für Sie eingeschaltet."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "Service installieren",
"uninstallService": "Dienst deinstallieren"
"uninstallService": "Dienst deinstallieren",
"switchToServiceMode": "In den Dienstmodus wechseln"
},
"fields": {
"systemProxy": "Systemproxy",
"tunMode": "Virtual Network Interface-Modus"
},
"messages": {
"installedCheckProxy": "Der Dienst ist installiert und der Kern läuft über ihn. Prüfen Sie den Systemproxy-Schalter und wiederholen Sie Ihre Änderung, falls sie nicht übernommen wurde.",
"installedCoreNotOnService": "Der Dienst ist installiert, der Kern läuft aber nicht über ihn. Prüfen Sie den Kern und versuchen Sie es erneut.",
"installedProxyRestored": "Der Dienst ist installiert, der Kern läuft darüber, und Ihre Systemproxy-Änderung wurde angewendet."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "Dienst konnte nicht neu installiert werden",
"repairFailed": "Dienst konnte nicht repariert werden",
"sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen"
},
"sysproxy": {
"privilegeRequired": "Systemproxy kann nicht gesetzt werden: macOS hat die Änderung mangels Berechtigung abgelehnt. Installieren Sie den Clash-Verge-Dienst; als Administrator können Sie in den erweiterten Optionen unter Systemeinstellungen → Datenschutz & Sicherheit auch die Passwortpflicht für systemweite Einstellungen deaktivieren.",
"directFallback": "Systemproxy konnte nicht gesetzt werden; der Datenverkehr wurde auf eine direkte Verbindung zurückgesetzt.",
"guardStopped": "Der Systemproxy-Wächter wurde nach mehreren Fehlversuchen in Folge gestoppt. Der Systemproxy wird nicht mehr automatisch aufrechterhalten.",
"sidecarWhileServiceReady": "Der Systemproxy konnte nicht gesetzt werden: Der Clash-Verge-Dienst ist bereit, der Kern läuft aber noch als Sidecar. Starten Sie den Kern neu, um in den Dienstmodus zu wechseln."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "Failed to refresh the running state",
"sidecarFailed": "Failed to continue with Sidecar"
}
},
"sysproxyPrivilege": {
"title": "Could not set the system proxy",
"message": "macOS refused the change for lack of privileges. Installing the Clash Verge service lets it make proxy changes as an administrator, and this prompt will not come back.",
"serviceReadyMessage": "The Clash Verge service is ready, but the core is still running as a sidecar, which is why setting the system proxy was refused. Switching to service mode fixes it — no reinstall needed.",
"alternative": "Alternatively, turn off “require an administrator password to access system-wide settings” in System Settings → Privacy & Security → Advanced.",
"later": "Later",
"installing": "Installing the system service. This may ask for an administrator password.",
"restarting": "Service installed. Restarting the core through it…",
"applying": "Applying your system proxy change…",
"tunTitle": "TUN mode needs the system service",
"tunMessage": "TUN mode needs the Clash Verge system service to take over network traffic. Once it is installed, TUN mode will be turned on for you."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "Install Service",
"uninstallService": "Uninstall Service"
"uninstallService": "Uninstall Service",
"switchToServiceMode": "Switch to service mode"
},
"fields": {
"systemProxy": "System Proxy",
"tunMode": "Tun Mode"
},
"messages": {
"installedCheckProxy": "The service is installed and the core is running through it. Check the system proxy switch and make your change again if it did not take.",
"installedCoreNotOnService": "The service is installed, but the core is not running through it. Check the core and try again.",
"installedProxyRestored": "The service is installed, the core is running through it, and your system proxy change has been applied."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "Failed to reinstall the service",
"repairFailed": "Failed to repair the service",
"sidecarFailed": "Failed to continue with Sidecar"
},
"sysproxy": {
"privilegeRequired": "Cannot set the system proxy: macOS refused the change for lack of privileges. Install the Clash Verge service; an administrator can also turn off the administrator-password requirement for system-wide settings, in the advanced options of System Settings → Privacy & Security.",
"directFallback": "Failed to set the system proxy; traffic has been switched back to a direct connection.",
"guardStopped": "The system proxy guard stopped after failing several times running. The system proxy is no longer being kept in place.",
"sidecarWhileServiceReady": "The system proxy could not be set because the core is still running as a Sidecar, although the Clash Verge service is ready. Restart the core to switch to service mode."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "No se pudo actualizar el estado de ejecución",
"sidecarFailed": "No se pudo continuar con Sidecar"
}
},
"sysproxyPrivilege": {
"title": "No se pudo configurar el proxy del sistema",
"message": "macOS rechazó el cambio por falta de permisos. Al instalar el servicio de Clash Verge, este aplicará los cambios de proxy como administrador y este aviso no volverá a aparecer.",
"serviceReadyMessage": "El servicio de Clash Verge está listo, pero el núcleo sigue ejecutándose como sidecar, por eso se rechazó la configuración del proxy. Cambiar al modo servicio lo resuelve sin reinstalar.",
"alternative": "Otra opción: desactiva «requerir contraseña de administrador para acceder a los ajustes de todo el sistema» en Ajustes del Sistema → Privacidad y seguridad → Avanzado.",
"later": "Más tarde",
"installing": "Instalando el servicio del sistema. Puede pedir una contraseña de administrador.",
"restarting": "Servicio instalado. Reiniciando el núcleo a través de él…",
"applying": "Aplicando tu cambio de proxy del sistema…",
"tunTitle": "El modo TUN necesita el servicio del sistema",
"tunMessage": "El modo TUN necesita el servicio del sistema de Clash Verge para gestionar el tráfico de red. Una vez instalado, se activará el modo TUN por ti."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "Instalar servicio",
"uninstallService": "Desinstalar servicio"
"uninstallService": "Desinstalar servicio",
"switchToServiceMode": "Cambiar al modo de servicio"
},
"fields": {
"systemProxy": "Proxy del sistema",
"tunMode": "Modo de interfaz virtual (TUN)"
},
"messages": {
"installedCheckProxy": "El servicio está instalado y el núcleo se ejecuta a través de él. Revise el interruptor del proxy del sistema y repita el cambio si no se aplicó.",
"installedCoreNotOnService": "El servicio está instalado, pero el núcleo no se ejecuta a través de él. Revise el núcleo e inténtelo de nuevo.",
"installedProxyRestored": "El servicio está instalado, el núcleo se ejecuta a través de él y se ha aplicado tu cambio de proxy del sistema."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "No se pudo reinstalar el servicio",
"repairFailed": "No se pudo reparar el servicio",
"sidecarFailed": "No se pudo continuar con Sidecar"
},
"sysproxy": {
"privilegeRequired": "No se puede establecer el proxy del sistema: macOS rechazó el cambio por falta de permisos. Instale el servicio de Clash Verge; si es administrador, también puede desactivar la exigencia de contraseña de administrador para los ajustes de todo el sistema en las opciones avanzadas de Ajustes del Sistema → Privacidad y seguridad.",
"directFallback": "No se pudo establecer el proxy del sistema; el tráfico volvió a una conexión directa.",
"guardStopped": "El guardián del proxy del sistema se detuvo tras varios fallos seguidos. El proxy del sistema ya no se mantiene automáticamente.",
"sidecarWhileServiceReady": "No se pudo establecer el proxy del sistema: el servicio de Clash Verge está listo, pero el núcleo sigue ejecutándose como Sidecar. Reinicie el núcleo para cambiar al modo de servicio."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "به‌روزرسانی وضعیت اجرا ناموفق بود",
"sidecarFailed": "ادامه با Sidecar ناموفق بود"
}
},
"sysproxyPrivilege": {
"title": "تنظیم پروکسی سیستم ممکن نشد",
"message": "macOS این تغییر را به دلیل نبود دسترسی رد کرد. با نصب سرویس Clash Verge، تغییرات پروکسی توسط سرویس و با دسترسی مدیر انجام می‌شود و این پیام دیگر نمایش داده نمی‌شود.",
"serviceReadyMessage": "سرویس Clash Verge آماده است، اما هسته هنوز به‌صورت sidecar اجرا می‌شود و به همین دلیل تنظیم پروکسی رد شد. تغییر به حالت سرویس آن را حل می‌کند و نیازی به نصب مجدد نیست.",
"alternative": "راه دیگر: در تنظیمات سیستم ← حریم خصوصی و امنیت ← پیشرفته، گزینهٔ نیاز به گذرواژهٔ مدیر برای تنظیمات سراسری را خاموش کنید.",
"later": "بعداً",
"installing": "در حال نصب سرویس سیستم. ممکن است گذرواژهٔ مدیر خواسته شود.",
"restarting": "سرویس نصب شد. هسته از طریق آن راه‌اندازی مجدد می‌شود…",
"applying": "در حال اعمال تغییر پروکسی سیستم…",
"tunTitle": "حالت TUN به سرویس سیستم نیاز دارد",
"tunMessage": "حالت TUN برای در دست گرفتن ترافیک شبکه به سرویس سیستم Clash Verge نیاز دارد. پس از نصب، حالت TUN برای شما روشن می‌شود."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "نصب سرویس",
"uninstallService": "حذف سرویس"
"uninstallService": "حذف سرویس",
"switchToServiceMode": "رفتن به حالت سرویس"
},
"fields": {
"systemProxy": "پراکسی سیستم",
"tunMode": "Tun (کارت شبکه مجازی)"
},
"messages": {
"installedCheckProxy": "سرویس نصب شد و هسته از طریق آن اجرا می‌شود. کلید پراکسی سیستم را بررسی کنید و اگر تغییر شما اعمال نشده، دوباره انجامش دهید.",
"installedCoreNotOnService": "سرویس نصب شد، اما هسته از طریق آن اجرا نمی‌شود. وضعیت هسته را بررسی کنید و دوباره تلاش کنید.",
"installedProxyRestored": "سرویس نصب شد، هسته از طریق آن اجرا می‌شود و تغییر پروکسی سیستم شما اعمال شد."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "نصب مجدد سرویس ناموفق بود",
"repairFailed": "تعمیر سرویس ناموفق بود",
"sidecarFailed": "ادامه با Sidecar ناموفق بود"
},
"sysproxy": {
"privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.",
"directFallback": "تنظیم پراکسی سیستم ناموفق بود؛ ترافیک به اتصال مستقیم بازگردانده شد.",
"guardStopped": "نگهبان پراکسی سیستم پس از چند شکست پیاپی متوقف شد. پراکسی سیستم دیگر به‌طور خودکار نگه داشته نمی‌شود.",
"sidecarWhileServiceReady": "تنظیم پراکسی سیستم ممکن نشد: سرویس Clash Verge آماده است اما هسته هنوز به‌صورت Sidecar اجرا می‌شود. برای رفتن به حالت سرویس، هسته را دوباره راه‌اندازی کنید."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "Gagal menyegarkan status berjalan",
"sidecarFailed": "Gagal melanjutkan dengan Sidecar"
}
},
"sysproxyPrivilege": {
"title": "Tidak dapat mengatur proxy sistem",
"message": "macOS menolak perubahan ini karena izin tidak cukup. Setelah layanan Clash Verge dipasang, layanan akan menerapkan pengaturan proxy sebagai administrator dan pesan ini tidak akan muncul lagi.",
"serviceReadyMessage": "Layanan Clash Verge sudah siap, tetapi core masih berjalan sebagai sidecar sehingga pengaturan proxy sistem ditolak. Beralih ke mode layanan mengatasinya tanpa pemasangan ulang.",
"alternative": "Alternatif: matikan opsi yang mewajibkan kata sandi administrator untuk mengakses pengaturan seluruh sistem di Pengaturan Sistem → Privasi & Keamanan → Lanjutan.",
"later": "Nanti",
"installing": "Memasang layanan sistem. Kata sandi administrator mungkin diminta.",
"restarting": "Layanan terpasang. Memulai ulang core melaluinya…",
"applying": "Menerapkan perubahan proxy sistem Anda…",
"tunTitle": "Mode TUN memerlukan layanan sistem",
"tunMessage": "Mode TUN memerlukan layanan sistem Clash Verge untuk mengambil alih lalu lintas jaringan. Setelah terpasang, mode TUN akan diaktifkan untuk Anda."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "Instal Layanan",
"uninstallService": "Uninstall Service"
"uninstallService": "Uninstall Service",
"switchToServiceMode": "Beralih ke mode layanan"
},
"fields": {
"systemProxy": "Proksi Sistem",
"tunMode": "Mode Tun (NIC Virtual)"
},
"messages": {
"installedCheckProxy": "Layanan terpasang dan inti berjalan melaluinya. Periksa sakelar proxy sistem dan ulangi perubahan Anda jika belum diterapkan.",
"installedCoreNotOnService": "Layanan terpasang, tetapi inti tidak berjalan melaluinya. Periksa inti lalu coba lagi.",
"installedProxyRestored": "Layanan telah dipasang, core berjalan melaluinya, dan perubahan proxy sistem Anda telah diterapkan."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "Gagal memasang ulang layanan",
"repairFailed": "Gagal memperbaiki layanan",
"sidecarFailed": "Gagal melanjutkan dengan Sidecar"
},
"sysproxy": {
"privilegeRequired": "Tidak dapat mengatur proxy sistem: macOS menolak perubahan ini karena hak akses tidak mencukupi. Pasang layanan Clash Verge; jika Anda administrator, Anda juga dapat mematikan keharusan kata sandi administrator untuk pengaturan seluruh sistem di opsi lanjutan Pengaturan Sistem → Privasi & Keamanan.",
"directFallback": "Gagal mengatur proxy sistem; lalu lintas dikembalikan ke koneksi langsung.",
"guardStopped": "Penjaga proxy sistem berhenti setelah gagal beberapa kali berturut-turut. Proxy sistem tidak lagi dipertahankan secara otomatis.",
"sidecarWhileServiceReady": "Proxy sistem tidak dapat diatur: layanan Clash Verge sudah siap, tetapi inti masih berjalan sebagai Sidecar. Mulai ulang inti untuk beralih ke mode layanan."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "実行状態の更新に失敗しました",
"sidecarFailed": "Sidecar での続行に失敗しました"
}
},
"sysproxyPrivilege": {
"title": "システムプロキシを設定できません",
"message": "権限が不足しているため、macOS がこの変更を拒否しました。Clash Verge サービスをインストールすると、サービスが管理者としてプロキシを設定するようになり、この通知は表示されなくなります。",
"serviceReadyMessage": "Clash Verge サービスは準備できていますが、コアがまだ sidecar として動作しているため、システムプロキシの設定が拒否されました。サービスモードに切り替えれば、再インストールせずに解決します。",
"alternative": "別の方法として、システム設定 → プライバシーとセキュリティ → 詳細で、システム全体の設定に管理者パスワードを要求するオプションをオフにしてください。",
"later": "後で",
"installing": "システムサービスをインストールしています。管理者パスワードを求められる場合があります。",
"restarting": "サービスをインストールしました。サービス経由でコアを再起動しています…",
"applying": "システムプロキシの変更を適用しています…",
"tunTitle": "TUN モードにはシステムサービスが必要です",
"tunMessage": "TUN モードがネットワークトラフィックを処理するには Clash Verge システムサービスが必要です。インストールが完了すると TUN モードが自動で有効になります。"
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "サービスをインストール",
"uninstallService": "サービスのアンインストール"
"uninstallService": "サービスのアンインストール",
"switchToServiceMode": "サービスモードに切り替え"
},
"fields": {
"systemProxy": "システムプロキシ",
"tunMode": "仮想ネットワークカードモード"
},
"messages": {
"installedCheckProxy": "サービスをインストールし、コアはサービス経由で動作しています。システムプロキシのスイッチを確認し、先ほどの変更が反映されていなければやり直してください。",
"installedCoreNotOnService": "サービスはインストールされましたが、コアはサービス経由で動作していません。コアの状態を確認してからやり直してください。",
"installedProxyRestored": "サービスがインストールされ、コアはサービス経由で動作しており、システムプロキシの変更が適用されました。"
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "サービスの再インストールに失敗しました",
"repairFailed": "サービスの修復に失敗しました",
"sidecarFailed": "Sidecar での続行に失敗しました"
},
"sysproxy": {
"privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。",
"directFallback": "システムプロキシの設定に失敗したため、直接接続に戻しました。",
"guardStopped": "システムプロキシガードが連続して失敗したため停止しました。システムプロキシは自動的に維持されなくなりました。",
"sidecarWhileServiceReady": "システムプロキシを設定できませんでした: Clash Verge サービスは準備できていますが、コアはまだ Sidecar で動作しています。コアを再起動するとサービスモードに切り替わります。"
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "실행 상태를 새로 고치지 못했습니다",
"sidecarFailed": "Sidecar로 계속하지 못했습니다"
}
},
"sysproxyPrivilege": {
"title": "시스템 프록시를 설정할 수 없음",
"message": "macOS가 권한 부족을 이유로 이 변경을 거부했습니다. Clash Verge 서비스를 설치하면 서비스가 관리자 권한으로 프록시를 설정하며, 이 알림은 다시 나타나지 않습니다.",
"serviceReadyMessage": "Clash Verge 서비스는 준비되었지만 코어가 아직 sidecar로 실행 중이어서 시스템 프록시 설정이 거부되었습니다. 서비스 모드로 전환하면 재설치 없이 해결됩니다.",
"alternative": "다른 방법: 시스템 설정 → 개인정보 보호 및 보안 → 고급에서 시스템 전체 설정에 관리자 암호를 요구하는 옵션을 끄십시오.",
"later": "나중에",
"installing": "시스템 서비스를 설치하는 중입니다. 관리자 암호를 요청할 수 있습니다.",
"restarting": "서비스가 설치되었습니다. 서비스를 통해 코어를 다시 시작하는 중…",
"applying": "시스템 프록시 변경 사항을 적용하는 중…",
"tunTitle": "TUN 모드에는 시스템 서비스가 필요합니다",
"tunMessage": "TUN 모드가 네트워크 트래픽을 처리하려면 Clash Verge 시스템 서비스가 필요합니다. 설치가 끝나면 TUN 모드가 자동으로 켜집니다."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "서비스 설치",
"uninstallService": "서비스 제거"
"uninstallService": "서비스 제거",
"switchToServiceMode": "서비스 모드로 전환"
},
"fields": {
"systemProxy": "시스템 프록시",
"tunMode": "TUN 모드"
},
"messages": {
"installedCheckProxy": "서비스가 설치되었고 코어가 서비스를 통해 실행 중입니다. 시스템 프록시 스위치를 확인하고, 방금 한 변경이 적용되지 않았다면 다시 시도하십시오.",
"installedCoreNotOnService": "서비스는 설치되었지만 코어가 서비스를 통해 실행되고 있지 않습니다. 코어 상태를 확인한 뒤 다시 시도하십시오.",
"installedProxyRestored": "서비스가 설치되었고 코어가 서비스를 통해 실행되며, 시스템 프록시 변경 사항이 적용되었습니다."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "서비스를 다시 설치하지 못했습니다",
"repairFailed": "서비스를 복구하지 못했습니다",
"sidecarFailed": "Sidecar로 계속하지 못했습니다"
},
"sysproxy": {
"privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.",
"directFallback": "시스템 프록시 설정에 실패하여 직접 연결로 되돌렸습니다.",
"guardStopped": "시스템 프록시 가드가 연속으로 실패하여 중지되었습니다. 시스템 프록시가 더 이상 자동으로 유지되지 않습니다.",
"sidecarWhileServiceReady": "시스템 프록시를 설정하지 못했습니다: Clash Verge 서비스는 준비되었지만 코어가 아직 Sidecar로 실행 중입니다. 코어를 재시작하면 서비스 모드로 전환됩니다."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "Не удалось обновить состояние выполнения",
"sidecarFailed": "Не удалось продолжить в режиме Sidecar"
}
},
"sysproxyPrivilege": {
"title": "Не удалось настроить системный прокси",
"message": "macOS отклонила изменение из-за нехватки прав. После установки службы Clash Verge настройки прокси будет применять служба с правами администратора, и это сообщение больше не появится.",
"serviceReadyMessage": "Служба Clash Verge готова, но ядро всё ещё работает как sidecar, поэтому в настройке системного прокси отказано. Переключение в режим службы решает проблему без переустановки.",
"alternative": "Другой вариант: отключите «требовать пароль администратора для доступа к общесистемным настройкам» в Системных настройках → Конфиденциальность и безопасность → Дополнительно.",
"later": "Позже",
"installing": "Устанавливается системная служба. Может потребоваться пароль администратора.",
"restarting": "Служба установлена. Ядро перезапускается через неё…",
"applying": "Применяется изменение системного прокси…",
"tunTitle": "Для режима TUN нужна системная служба",
"tunMessage": "Режиму TUN нужна системная служба Clash Verge, чтобы взять сетевой трафик на себя. После установки режим TUN будет включён автоматически."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "Установить службу",
"uninstallService": "Удалить службу"
"uninstallService": "Удалить службу",
"switchToServiceMode": "Переключиться в режим службы"
},
"fields": {
"systemProxy": "Системный прокси",
"tunMode": "Режим TUN"
},
"messages": {
"installedCheckProxy": "Служба установлена, ядро работает через неё. Проверьте переключатель системного прокси и повторите изменение, если оно не применилось.",
"installedCoreNotOnService": "Служба установлена, но ядро работает не через неё. Проверьте состояние ядра и повторите попытку.",
"installedProxyRestored": "Служба установлена, ядро работает через неё, и ваше изменение системного прокси применено."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "Не удалось переустановить службу",
"repairFailed": "Не удалось восстановить службу",
"sidecarFailed": "Не удалось продолжить в режиме Sidecar"
},
"sysproxy": {
"privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».",
"directFallback": "Не удалось настроить системный прокси; трафик возвращён к прямому подключению.",
"guardStopped": "Страж системного прокси остановился после нескольких неудач подряд. Системный прокси больше не поддерживается автоматически.",
"sidecarWhileServiceReady": "Не удалось настроить системный прокси: служба Clash Verge готова, но ядро всё ещё работает в режиме Sidecar. Перезапустите ядро, чтобы переключиться в режим службы."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "Çalışma durumu yenilenemedi",
"sidecarFailed": "Sidecar ile devam edilemedi"
}
},
"sysproxyPrivilege": {
"title": "Sistem proxy'si ayarlanamadı",
"message": "macOS bu değişikliği yetersiz yetki nedeniyle reddetti. Clash Verge hizmeti kurulduktan sonra proxy ayarlarını hizmet yönetici olarak uygular ve bu uyarı bir daha görünmez.",
"serviceReadyMessage": "Clash Verge hizmeti hazır, ancak çekirdek hâlâ sidecar olarak çalıştığından sistem proxy'si ayarlanamadı. Hizmet moduna geçmek sorunu yeniden kurulum gerekmeden çözer.",
"alternative": "Alternatif olarak Sistem Ayarları → Gizlilik ve Güvenlik → Gelişmiş bölümünde sistem genelindeki ayarlar için yönetici parolası isteyen seçeneği kapatın.",
"later": "Daha sonra",
"installing": "Sistem hizmeti kuruluyor. Yönetici parolası istenebilir.",
"restarting": "Hizmet kuruldu. Çekirdek onun üzerinden yeniden başlatılıyor…",
"applying": "Sistem proxy değişikliğiniz uygulanıyor…",
"tunTitle": "TUN modu sistem hizmetine ihtiyaç duyar",
"tunMessage": "TUN modunun ağ trafiğini üstlenebilmesi için Clash Verge sistem hizmeti gerekir. Kurulum tamamlandığında TUN modu sizin için açılacaktır."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "Hizmeti Kur",
"uninstallService": "Uninstall Service"
"uninstallService": "Uninstall Service",
"switchToServiceMode": "Hizmet moduna geç"
},
"fields": {
"systemProxy": "Sistem Vekil'i",
"tunMode": "Tun Modu"
},
"messages": {
"installedCheckProxy": "Hizmet kuruldu ve çekirdek onun üzerinden çalışıyor. Sistem vekil sunucusu anahtarını denetleyin; değişikliğiniz uygulanmadıysa yeniden yapın.",
"installedCoreNotOnService": "Hizmet kuruldu ancak çekirdek onun üzerinden çalışmıyor. Çekirdeği denetleyip yeniden deneyin.",
"installedProxyRestored": "Hizmet kuruldu, çekirdek onun üzerinden çalışıyor ve sistem proxy değişikliğiniz uygulandı."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "Hizmet yeniden kurulamadı",
"repairFailed": "Hizmet onarılamadı",
"sidecarFailed": "Sidecar ile devam edilemedi"
},
"sysproxy": {
"privilegeRequired": "Sistem vekil sunucusu ayarlanamıyor: macOS yetki yetersizliği nedeniyle değişikliği reddetti. Clash Verge hizmetini kurun; yöneticiyseniz Sistem Ayarları → Gizlilik ve Güvenlik bölümündeki gelişmiş seçeneklerden, sistem genelindeki ayarlar için yönetici parolası zorunluluğunu da kapatabilirsiniz.",
"directFallback": "Sistem vekil sunucusu ayarlanamadı; trafik doğrudan bağlantıya geri döndürüldü.",
"guardStopped": "Sistem vekil sunucusu koruması art arda birkaç başarısızlıktan sonra durdu. Sistem vekil sunucusu artık otomatik olarak korunmuyor.",
"sidecarWhileServiceReady": "Sistem vekil sunucusu ayarlanamadı: Clash Verge hizmeti hazır ancak çekirdek hâlâ Sidecar olarak çalışıyor. Hizmet moduna geçmek için çekirdeği yeniden başlatın."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "Эшләү халәтен яңартып булмады",
"sidecarFailed": "Sidecar белән дәвам итеп булмады"
}
},
"sysproxyPrivilege": {
"title": "Система прокси көйләнмәде",
"message": "macOS бу үзгәрешне хокуклар җитмәү сәбәпле кире какты. Clash Verge хезмәтен урнаштыргач, прокси көйләүләрен хезмәт администратор буларак башкара, һәм бу хәбәр кабат күренмәс.",
"serviceReadyMessage": "Clash Verge хезмәте әзер, ләкин үзәк әле дә sidecar буларак эшли, шуңа күрә система проксиен көйләү кире кагылды. Хезмәт режимына күчү моны яңадан урнаштырмыйча хәл итә.",
"alternative": "Башка юл: Система көйләүләре → Хосусыйлык һәм куркынычсызлык → Өстәмә бүлегендә система күләмендәге көйләүләр өчен администратор паролен сорау опциясен сүндерегез.",
"later": "Соңрак",
"installing": "Система хезмәте урнаштырыла. Администратор паролен сорарга мөмкин.",
"restarting": "Хезмәт урнаштырылды. Үзәк аның аша яңадан җибәрелә…",
"applying": "Система прокси үзгәреше кулланыла…",
"tunTitle": "TUN режимына система хезмәте кирәк",
"tunMessage": "TUN режимына челтәр трафигын үз өстенә алу өчен Clash Verge система хезмәте кирәк. Урнаштырылгач, TUN режимы сезнең өчен кабызылачак."
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "Хезмәтне урнаштыру",
"uninstallService": "Uninstall Service"
"uninstallService": "Uninstall Service",
"switchToServiceMode": "Хезмәт режимына күчү"
},
"fields": {
"systemProxy": "Системалы прокси",
"tunMode": "Tun режимы (виртуаль челтәр адаптеры)"
},
"messages": {
"installedCheckProxy": "Хезмәт урнаштырылды, ядро хезмәт аша эшли. Система проксие тоткычын тикшерегез; үзгәреш эшләмәгән булса, кабатлагыз.",
"installedCoreNotOnService": "Хезмәт урнаштырылды, әмма ядро хезмәт аша эшләми. Ядро халәтен тикшереп кабат карагыз.",
"installedProxyRestored": "Хезмәт урнаштырылды, үзәк аның аша эшли, һәм система прокси үзгәрешегез кулланылды."
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "Хезмәтне яңадан урнаштырып булмады",
"repairFailed": "Хезмәтне төзәтеп булмады",
"sidecarFailed": "Sidecar белән дәвам итеп булмады"
},
"sysproxy": {
"privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.",
"directFallback": "Система проксиен көйләү уңышсыз булды; трафик туры тоташуга кайтарылды.",
"guardStopped": "Система проксие сакчысы берничә тапкыр рәттән уңышсыз булганнан соң туктады. Система проксие бүтән автоматик рәвештә саклап торылмый.",
"sidecarWhileServiceReady": "Система проксиен көйләп булмады: Clash Verge хезмәте әзер, әмма ядро һаман Sidecar буларак эшли. Хезмәт режимына күчәр өчен ядрону яңадан эшләтегез."
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "刷新运行状态失败",
"sidecarFailed": "继续使用 Sidecar 失败"
}
},
"sysproxyPrivilege": {
"title": "无法设置系统代理",
"message": "macOS 以权限不足为由拒绝了这次修改。安装 Clash Verge 系统服务后,代理设置将由服务以管理员身份完成,之后不会再出现这个提示。",
"serviceReadyMessage": "Clash Verge 系统服务已就绪,但内核仍以 Sidecar 运行,因此设置系统代理时被拒绝。切换到服务模式即可解决,无需重新安装。",
"alternative": "另一种办法:在系统设置 → 隐私与安全性 → 高级中,关闭「访问系统范围设置需要管理员密码」。",
"later": "稍后再说",
"installing": "正在安装系统服务,可能会请求管理员密码,请稍候……",
"restarting": "服务已安装,正在通过服务重启内核……",
"applying": "正在重新应用系统代理……",
"tunTitle": "TUN 模式需要系统服务",
"tunMessage": "TUN 模式需要 Clash Verge 系统服务才能接管网络流量。安装完成后会自动为你开启 TUN 模式。"
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "安装服务",
"uninstallService": "卸载服务"
"uninstallService": "卸载服务",
"switchToServiceMode": "切换到服务模式"
},
"fields": {
"systemProxy": "系统代理",
"tunMode": "虚拟网卡模式"
},
"messages": {
"installedCheckProxy": "服务已安装,内核已通过服务运行。请检查系统代理开关,若刚才的改动未生效请重新操作一次。",
"installedCoreNotOnService": "服务已安装,但内核未通过服务运行。请检查内核状态后重试。",
"installedProxyRestored": "服务已安装,内核已通过服务运行,刚才的系统代理改动已重新应用。"
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "重新安装服务失败",
"repairFailed": "修复服务失败",
"sidecarFailed": "继续使用 Sidecar 失败"
},
"sysproxy": {
"privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。",
"directFallback": "系统代理设置失败,已回退为直连。",
"guardStopped": "系统代理守卫连续多次失败后已停止,系统代理不再被自动维持。",
"sidecarWhileServiceReady": "系统代理设置失败:Clash Verge 服务已就绪,但内核仍以 Sidecar 运行。重启内核即可切换到服务模式。"
}
},
"notifications": {
+12
View File
@@ -36,6 +36,18 @@
"revalidationFailed": "重新整理執行狀態失敗",
"sidecarFailed": "繼續使用 Sidecar 失敗"
}
},
"sysproxyPrivilege": {
"title": "無法設定系統代理",
"message": "macOS 以權限不足為由拒絕了這次修改。安裝 Clash Verge 系統服務後,代理設定將由服務以管理員身分完成,之後不會再出現這個提示。",
"serviceReadyMessage": "Clash Verge 系統服務已就緒,但核心仍以 Sidecar 執行,因此設定系統代理時被拒絕。切換到服務模式即可解決,無需重新安裝。",
"alternative": "另一種辦法:在系統設定 → 隱私權與安全性 → 進階中,關閉「存取系統範圍設定需要管理員密碼」。",
"later": "稍後再說",
"installing": "正在安裝系統服務,可能會要求管理員密碼,請稍候……",
"restarting": "服務已安裝,正在透過服務重新啟動核心……",
"applying": "正在重新套用系統代理……",
"tunTitle": "TUN 模式需要系統服務",
"tunMessage": "TUN 模式需要 Clash Verge 系統服務才能接管網路流量。安裝完成後會自動為你開啟 TUN 模式。"
}
},
"feedback": {
+13 -1
View File
@@ -37,11 +37,17 @@
},
"actions": {
"installService": "安裝服務",
"uninstallService": "解除安裝服務"
"uninstallService": "解除安裝服務",
"switchToServiceMode": "切換到服務模式"
},
"fields": {
"systemProxy": "系統代理",
"tunMode": "虛擬網路介面卡模式"
},
"messages": {
"installedCheckProxy": "服務已安裝,內核已透過服務執行。請檢查系統代理開關,若剛才的變更未生效請重新操作一次。",
"installedCoreNotOnService": "服務已安裝,但內核未透過服務執行。請檢查內核狀態後重試。",
"installedProxyRestored": "服務已安裝,核心已透過服務執行,剛才的系統代理變更已重新套用。"
}
},
"externalController": {
@@ -649,6 +655,12 @@
"reinstallFailed": "重新安裝服務失敗",
"repairFailed": "修復服務失敗",
"sidecarFailed": "繼續使用 Sidecar 失敗"
},
"sysproxy": {
"privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。",
"directFallback": "系統代理設定失敗,已回退為直連。",
"guardStopped": "系統代理守衛連續多次失敗後已停止,系統代理不再被自動維持。",
"sidecarWhileServiceReady": "系統代理設定失敗:Clash Verge 服務已就緒,但內核仍以 Sidecar 執行。重新啟動內核即可切換到服務模式。"
}
},
"notifications": {
+4
View File
@@ -37,6 +37,7 @@ import { LayoutItem } from '@/components/layout/layout-item'
import { LayoutTraffic } from '@/components/layout/layout-traffic'
import { NoticeManager } from '@/components/layout/notice-manager'
import { ServiceMigrationDialog } from '@/components/layout/service-migration-dialog'
import { SysproxyPrivilegeDialog } from '@/components/layout/sysproxy-privilege-dialog'
import { UpdateButton } from '@/components/layout/update-button'
import {
WindowControls,
@@ -53,6 +54,7 @@ import {
useLayoutEvents,
useLoadingOverlay,
useNavMenuOrder,
usePendingFailures,
} from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils'
import { navItems } from './_navigation'
@@ -250,6 +252,7 @@ const Layout = () => {
)
useLayoutEvents(handleNotice)
usePendingFailures()
useEffect(() => {
if (language) {
@@ -280,6 +283,7 @@ const Layout = () => {
{/* 左侧底部窗口控制按钮 */}
<NoticeManager position={verge?.notice_position} />
<ServiceMigrationDialog />
<SysproxyPrivilegeDialog />
<div
style={{
animation: 'fadeIn 0.5s',
+1
View File
@@ -1,4 +1,5 @@
export { useLayoutEvents } from './use-layout-events'
export { usePendingFailures, useDialogFailure } from './use-pending-failures'
export { useLoadingOverlay } from './use-loading-overlay'
export { useNavMenuOrder } from './use-nav-menu-order'
export { useCustomTheme } from './use-custom-theme'
@@ -0,0 +1,228 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { PendingFailure } from '@/services/cmds'
import { getSnapshotNotices, hideNotice } from '@/services/notice-service'
import { useDialogFailure, usePendingFailures } from './use-pending-failures'
const getPendingFailures = vi.hoisted(() => vi.fn())
const onFocusChanged = vi.hoisted(() => vi.fn())
const isVisible = vi.hoisted(() => vi.fn())
const isMinimized = vi.hoisted(() => vi.fn())
vi.mock('@/services/cmds', () => ({ getPendingFailures }))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ onFocusChanged, isVisible, isMinimized }),
}))
let table: PendingFailure[] = []
let onSubscribed: (() => void) | undefined
let onTableChanged: (() => void) | undefined
let focusListener: ((event: { payload: boolean }) => void) | undefined
vi.mock('@/services/events', () => ({
subscribeVergeEvents: (
handlers: Record<string, () => void>,
subscribed?: () => void,
) => {
onTableChanged = handlers['verge://pending-failures-changed']
onSubscribed = subscribed
return () => {}
},
}))
const failure = (sequence: number, detail: string): PendingFailure => ({
code: 'SYSPROXY_GUARD_STOPPED',
detail,
operation: 'systemProxyGuard',
sequence,
})
const dialogFailure = (sequence: number): PendingFailure => ({
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
detail: 'refused',
operation: 'systemProxyEnable',
sequence,
})
const shownDetails = () =>
getSnapshotNotices().map((notice) => notice.i18n?.params?.message)
beforeEach(() => {
getPendingFailures.mockClear()
onFocusChanged.mockClear()
isVisible.mockReset()
isVisible.mockResolvedValue(true)
isMinimized.mockReset()
isMinimized.mockResolvedValue(false)
table = []
onSubscribed = undefined
onTableChanged = undefined
focusListener = undefined
getPendingFailures.mockImplementation(() => Promise.resolve(table))
onFocusChanged.mockImplementation(
(listener: (event: { payload: boolean }) => void) => {
focusListener = listener
return Promise.resolve(() => {})
},
)
})
afterEach(() => {
cleanup()
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
})
it('shows a failure that was recorded before the listeners were live', async () => {
table = [failure(1, 'refused')]
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
})
it('shows a failure recorded after the window was already up', async () => {
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(getPendingFailures).toHaveBeenCalled())
table = [failure(1, 'refused')]
onTableChanged?.()
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
})
it('does not show the same failure again when asked again', async () => {
table = [failure(1, 'refused')]
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
onTableChanged?.()
focusListener?.({ payload: true })
await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(3))
expect(shownDetails()).toEqual(['refused'])
})
it('shows the same code again when it has failed again', async () => {
table = [failure(1, 'refused')]
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
table = [failure(2, 'refused again')]
onTableChanged?.()
await waitFor(() => expect(shownDetails()).toEqual(['refused again']))
})
it('reads when the window takes focus', async () => {
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(getPendingFailures).toHaveBeenCalled())
table = [failure(1, 'refused')]
focusListener?.({ payload: true })
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
})
it('keeps a failure pending while the window is hidden', async () => {
isVisible.mockResolvedValue(false)
table = [failure(1, 'refused')]
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(isVisible).toHaveBeenCalled())
expect(shownDetails()).toEqual([])
isVisible.mockResolvedValue(true)
focusListener?.({ payload: true })
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
})
it('keeps a failure pending while the window is minimised', async () => {
isMinimized.mockResolvedValue(true)
table = [failure(1, 'refused')]
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(isMinimized).toHaveBeenCalled())
expect(shownDetails()).toEqual([])
isMinimized.mockResolvedValue(false)
focusListener?.({ payload: true })
await waitFor(() => expect(shownDetails()).toEqual(['refused']))
})
it('keeps a failure pending when the window cannot say what it is doing', async () => {
isVisible.mockRejectedValue(new Error('no window'))
table = [failure(1, 'refused')]
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(isVisible).toHaveBeenCalled())
expect(shownDetails()).toEqual([])
})
it('ignores losing focus', async () => {
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(1))
focusListener?.({ payload: false })
expect(getPendingFailures).toHaveBeenCalledTimes(1)
})
it('leaves a failure the dialog owns out of the toasts', async () => {
table = [dialogFailure(1), failure(2, 'guard gave up')]
renderHook(() => usePendingFailures())
onSubscribed?.()
await waitFor(() => expect(shownDetails()).toEqual(['guard gave up']))
})
it('offers the dialog the failure it owns, and nothing else', async () => {
table = [failure(1, 'guard gave up'), dialogFailure(2)]
const { result } = renderHook(() => useDialogFailure())
onSubscribed?.()
await waitFor(() =>
expect(result.current.failure?.code).toBe('SYSPROXY_PRIVILEGE_REQUIRED'),
)
})
it('keeps a dismissed dialog closed while the table still holds the failure', async () => {
table = [dialogFailure(1)]
const { result } = renderHook(() => useDialogFailure())
onSubscribed?.()
await waitFor(() => expect(result.current.failure).not.toBeNull())
act(() => result.current.dismiss())
expect(result.current.failure).toBeNull()
focusListener?.({ payload: true })
await waitFor(() => expect(getPendingFailures).toHaveBeenCalledTimes(2))
expect(result.current.failure).toBeNull()
})
it('opens again when the same code fails again', async () => {
table = [dialogFailure(1)]
const { result } = renderHook(() => useDialogFailure())
onSubscribed?.()
await waitFor(() => expect(result.current.failure).not.toBeNull())
act(() => result.current.dismiss())
expect(result.current.failure).toBeNull()
table = [dialogFailure(2)]
onTableChanged?.()
await waitFor(() => expect(result.current.failure?.sequence).toBe(2))
})
@@ -0,0 +1,127 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useCallback, useEffect, useRef, useState } from 'react'
import { getPendingFailures, type PendingFailure } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { showNotice } from '@/services/notice-service'
/** Failures handled by the recovery dialog instead of a toast. */
const CODES_SHOWN_AS_A_DIALOG = new Set<string>([
'SYSPROXY_PRIVILEGE_REQUIRED',
'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
])
/** Return whether a native window is visible and not minimized; fail closed. */
const windowIsWatched = async () => {
const window = getCurrentWindow()
try {
const [visible, minimized] = await Promise.all([
window.isVisible(),
window.isMinimized(),
])
return visible && !minimized
} catch (error) {
console.warn('[pending-failures] window state unavailable:', error)
return false
}
}
const usePendingFailureReader = (
onFailures: (failures: PendingFailure[]) => void,
) => {
// Avoid resubscribing when the handler changes.
const handlerRef = useRef(onFailures)
handlerRef.current = onFailures
useEffect(() => {
const read = () => {
void (async () => {
let failures: PendingFailure[]
try {
failures = await getPendingFailures()
} catch (error) {
console.warn('[pending-failures] could not be read:', error)
return
}
handlerRef.current(failures)
})()
}
const unsubscribe = subscribeVergeEvents(
{ 'verge://pending-failures-changed': read },
read,
)
const readWhenVisible = () => {
if (document.visibilityState === 'visible') read()
}
document.addEventListener('visibilitychange', readWhenVisible)
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
if (payload) read()
})
return () => {
unsubscribe()
document.removeEventListener('visibilitychange', readWhenVisible)
void unlistenFocus.then((unlisten) => unlisten())
}
}, [])
}
/** Show each pending failure sequence once when the window can be read. */
export const usePendingFailures = () => {
const shownSequencesRef = useRef(new Map<string, number>())
const showNewFailures = useCallback(async (failures: PendingFailure[]) => {
const asToast = failures.filter(
(failure) => !CODES_SHOWN_AS_A_DIALOG.has(failure.code),
)
if (asToast.length === 0) return
if (!(await windowIsWatched())) return
for (const failure of asToast) {
const shown = shownSequencesRef.current.get(failure.code)
if (shown !== undefined && shown >= failure.sequence) continue
shownSequencesRef.current.set(failure.code, failure.sequence)
showNotice.error({ code: failure.code, detail: failure.detail })
}
}, [])
usePendingFailureReader(
useCallback(
(failures) => {
void showNewFailures(failures)
},
[showNewFailures],
),
)
}
/** Return the oldest undismissed failure owned by the dialog. */
export const useDialogFailure = () => {
const [failure, setFailure] = useState<PendingFailure | null>(null)
// Dismiss locally without retiring the backend failure.
const [dismissedSequence, setDismissedSequence] = useState<number | null>(
null,
)
usePendingFailureReader(
useCallback((failures) => {
setFailure(
failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ??
null,
)
}, []),
)
const dismiss = useCallback(() => {
setDismissedSequence(failure?.sequence ?? null)
}, [failure])
const shown =
failure && failure.sequence !== dismissedSequence ? failure : null
return { failure: shown, dismiss }
}
+6 -5
View File
@@ -61,7 +61,7 @@ import {
updateProfile,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { showNotice } from '@/services/notice-service'
import { errorDetail, showNotice } from '@/services/notice-service'
import {
fetchCacheData,
revalidateQueries,
@@ -246,7 +246,7 @@ const ProfilePage = () => {
console.error('[紧急刷新] 失败:', error)
showNotice.error(
'profiles.page.feedback.notices.emergencyRefreshFailed',
{ message: String(error) },
{ message: errorDetail(error) },
4000,
)
}
@@ -297,8 +297,9 @@ const ProfilePage = () => {
} catch (initialErr) {
console.warn('[订阅导入] 首次导入失败:', initialErr)
if (String(initialErr).toLowerCase().includes('legacy tls')) {
showNotice.error(String(initialErr))
const initialDetail = errorDetail(initialErr)
if (initialDetail.toLowerCase().includes('legacy tls')) {
showNotice.error(initialErr)
return
}
@@ -316,7 +317,7 @@ const ProfilePage = () => {
// 回退导入也失败
showNotice.error(
'profiles.page.feedback.notifications.importFail',
String(retryErr),
retryErr,
)
}
} finally {
+25 -1
View File
@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import type { CommandFailure } from '@/services/notice-service'
import { showNotice } from '@/services/notice-service'
import type { ProxyViewV1 } from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
@@ -205,7 +206,7 @@ export async function getEmbeddedServerPort() {
}
export async function changeClashCore(clashCore: string) {
return invoke<string | null>('change_clash_core', { clashCore })
return invoke<CommandFailure | null>('change_clash_core', { clashCore })
}
export async function restartCore() {
@@ -406,6 +407,29 @@ export const getRuntimeState = async () => {
return invoke<RunState>('get_runtime_state')
}
/** Operation associated with a pending failure. */
export type FailedOperation =
| 'systemProxyEnable'
| 'systemProxyDisable'
| 'systemProxyRestore'
| 'systemProxyGuard'
/** Latest unresolved failure for one stable code. */
export interface PendingFailure {
/** Stable table key. */
code: string
/** Full diagnostic chain. */
detail: string
operation: FailedOperation
/** Identity for repeated failures under the same code. */
sequence: number
}
/** Read unresolved failures without consuming them. */
export const getPendingFailures = async () => {
return invoke<PendingFailure[]>('get_pending_failures')
}
// 获取应用运行时间
export const getAppUptime = async () => {
return invoke<number>('get_app_uptime')
+2
View File
@@ -25,6 +25,8 @@ interface VergeEvents {
'verge://timer-updated': string
/** How the core is running changed; carries the whole snapshot. */
'verge://run-state-changed': RunState
/** Pending failures changed; reread the table. */
'verge://pending-failures-changed': null
/** Which profile is active changed. */
'profile-changed': string
'profile-update-started': { uid?: string }
+241
View File
@@ -0,0 +1,241 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
boundNoticeText,
errorDetail,
getSnapshotNotices,
hideNotice,
showNotice,
} from '@/services/notice-service'
/** Return the last notice text that is actually rendered. */
function renderedDetail(): string | undefined {
const notices = getSnapshotNotices()
const notice = notices[notices.length - 1]
if (typeof notice?.message === 'string') return notice.message
const slot = notice?.i18n?.params?.message
return typeof slot === 'string' ? slot : undefined
}
/** Return the rendered failure notice fields. */
function renderedExplanation() {
const notices = getSnapshotNotices()
const i18n = notices[notices.length - 1]?.i18n
const readString = (value: unknown) =>
typeof value === 'string' ? value : undefined
return {
outerKey: i18n?.key,
explanationKey: readString(i18n?.params?.prefixKey),
prefix: readString(i18n?.params?.prefix),
detail: readString(i18n?.params?.message),
}
}
afterEach(() => {
getSnapshotNotices().forEach((notice) => hideNotice(notice.id))
})
describe('command failures reaching a notice', () => {
it('keeps the detail when the failure is the message', () => {
showNotice.error({
code: 'CORE_START_FAILED',
detail: 'connection refused',
})
expect(renderedDetail()).toContain('connection refused')
})
it('keeps the detail when a translation key comes first', () => {
showNotice.error('settings.feedback.errors.clash.startFailed', {
code: 'CORE_START_FAILED',
detail: 'connection refused',
})
expect(renderedDetail()).toContain('connection refused')
})
it('keeps the detail when real translation params come first', () => {
showNotice.error(
'settings.feedback.errors.clash.startFailed',
{ name: 'mihomo' },
{ code: 'CORE_START_FAILED', detail: 'connection refused' },
)
expect(renderedDetail()).toContain('connection refused')
})
it('keeps the detail for a failure that carries no code', () => {
showNotice.error('settings.feedback.errors.clash.startFailed', {
detail: 'disk full',
})
expect(renderedDetail()).toContain('disk full')
})
})
describe('system proxy failures the user can be told about', () => {
it('explains a fallback to a direct connection', () => {
showNotice.error({
code: 'SYSPROXY_DIRECT_FALLBACK',
detail: 'service could not set the proxy',
})
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.prefixedRaw',
explanationKey: 'settings.feedback.errors.sysproxy.directFallback',
prefix: undefined,
detail: 'service could not set the proxy',
})
})
it('explains a guard that gave up', () => {
showNotice.error({
code: 'SYSPROXY_GUARD_STOPPED',
detail: 'the proxy could not be reasserted',
})
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.prefixedRaw',
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
prefix: undefined,
detail: 'the proxy could not be reasserted',
})
})
it('says nothing at all about a failure a dialog is already reporting', () => {
showNotice.error({
code: 'SYSPROXY_PRIVILEGE_REQUIRED',
detail: 'admin privileges required to modify system proxy',
})
showNotice.error({
code: 'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
detail: 'the core is still a sidecar',
})
expect(getSnapshotNotices()).toEqual([])
})
it('explains the cause even when the caller names the operation', () => {
showNotice.error(
'layout.components.serviceMigration.errors.restartFailed',
{
code: 'SYSPROXY_GUARD_STOPPED',
detail: 'the proxy could not be reasserted',
},
)
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.prefixedRaw',
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
prefix: undefined,
detail: 'the proxy could not be reasserted',
})
})
it('leaves the caller key in place for a code nothing explains', () => {
showNotice.error(
'layout.components.serviceMigration.errors.restartFailed',
{
code: 'SOME_UNMAPPED_CODE',
detail: 'connection refused',
},
)
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.prefixedRaw',
explanationKey: 'layout.components.serviceMigration.errors.restartFailed',
prefix: undefined,
detail: 'connection refused',
})
})
it('leaves literal caller text in place, mapped code or not', () => {
showNotice.error('Could not reconnect the profile', {
code: 'SYSPROXY_GUARD_STOPPED',
detail: 'the proxy could not be reasserted',
})
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.prefixedRaw',
explanationKey: undefined,
prefix: 'Could not reconnect the profile',
detail: 'the proxy could not be reasserted',
})
})
it('explains a guard that gave up', () => {
showNotice.error({
code: 'SYSPROXY_GUARD_STOPPED',
detail: 'service refused three times running',
})
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.prefixedRaw',
explanationKey: 'settings.feedback.errors.sysproxy.guardStopped',
prefix: undefined,
detail: 'service refused three times running',
})
})
it('falls back to the generic message for a code nothing maps', () => {
showNotice.error({ code: 'SYSPROXY_NOT_A_REAL_CODE', detail: 'boom' })
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.prefixedRaw',
explanationKey: 'shared.feedback.errors.operationFailed',
prefix: undefined,
detail: 'boom',
})
})
})
describe('boundNoticeText', () => {
it('leaves text that fits exactly at the limit alone', () => {
const exact = 'x'.repeat(500)
expect(boundNoticeText(exact)).toBe(exact)
})
it('marks text it had to shorten', () => {
expect(boundNoticeText('x'.repeat(501))).toBe(`${'x'.repeat(500)}…`)
})
it('does not cut a surrogate pair in half', () => {
expect(boundNoticeText('🙂'.repeat(600))).toBe(`${'🙂'.repeat(500)}…`)
})
it('does not mistake a long message for a translation key', () => {
const message = `a.${'b'.repeat(400)}`
showNotice.error(message)
expect(renderedExplanation()).toEqual({
outerKey: 'shared.feedback.notices.raw',
explanationKey: undefined,
prefix: undefined,
detail: message,
})
})
it('does not leave a space before the ellipsis', () => {
expect(boundNoticeText(`${'x'.repeat(498)} yyy`)).toBe(
`${'x'.repeat(498)}…`,
)
})
})
describe('errorDetail', () => {
it('reads the detail out of a command failure', () => {
expect(errorDetail({ code: 'X', detail: 'legacy TLS not supported' })).toBe(
'legacy TLS not supported',
)
})
it('passes plain strings through untouched', () => {
expect(errorDetail('something broke')).toBe('something broke')
})
it('still reads an Error, which not every failure path replaces', () => {
expect(errorDetail(new Error('boom'))).toBe('boom')
})
})
+159 -19
View File
@@ -1,6 +1,9 @@
import i18n from 'i18next'
import { ReactNode, isValidElement } from 'react'
import type { FailedOperation } from '@/services/cmds'
import type { TranslationKey } from '@/types/generated/i18n-keys'
type NoticeType = 'success' | 'error' | 'info'
interface NoticeTranslationDescriptor {
@@ -14,9 +17,22 @@ interface NoticeItem {
readonly duration: number
readonly message?: ReactNode
readonly i18n?: NoticeTranslationDescriptor
/** Stable classified failure code. */
readonly code?: string
/** Requested operation when known. */
readonly operation?: FailedOperation
timerId?: ReturnType<typeof setTimeout>
}
/** Failures owned by the recovery dialog. */
const CODES_A_DIALOG_REPORTS = new Set<string>([
'SYSPROXY_PRIVILEGE_REQUIRED',
'SYSPROXY_SIDECAR_WHILE_SERVICE_READY',
])
export const isReportedByDialog = (code: string | undefined): boolean =>
code !== undefined && CODES_A_DIALOG_REPORTS.has(code)
type NoticeContent = unknown
type NoticeExtra = unknown
@@ -45,8 +61,8 @@ const DEFAULT_DURATIONS: Readonly<Record<NoticeType, number>> = {
}
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/
const CODED_ERROR_PATTERN = /^CVR_ERROR:([A-Z0-9_]+)(?:\n([\s\S]*))?$/
const CODED_ERROR_TRANSLATION_KEYS: Readonly<Record<string, string>> = {
/** Localized explanation for each stable failure code. */
const CODED_ERROR_TRANSLATION_KEYS: Readonly<Record<string, TranslationKey>> = {
CLASH_CONFIG_UPDATE_FAILED:
'settings.feedback.errors.clash.configUpdateFailed',
CLASH_MODE_UPDATE_FAILED: 'settings.feedback.errors.clash.modeUpdateFailed',
@@ -70,6 +86,12 @@ const CODED_ERROR_TRANSLATION_KEYS: Readonly<Record<string, string>> = {
SERVICE_SIDECAR_FAILED: 'settings.feedback.errors.clashService.sidecarFailed',
SERVICE_UNINSTALL_FAILED:
'settings.feedback.errors.clashService.uninstallFailed',
SYSPROXY_DIRECT_FALLBACK: 'settings.feedback.errors.sysproxy.directFallback',
SYSPROXY_GUARD_STOPPED: 'settings.feedback.errors.sysproxy.guardStopped',
SYSPROXY_SIDECAR_WHILE_SERVICE_READY:
'settings.feedback.errors.sysproxy.sidecarWhileServiceReady',
SYSPROXY_PRIVILEGE_REQUIRED:
'settings.feedback.errors.sysproxy.privilegeRequired',
}
let nextId = 0
@@ -84,6 +106,8 @@ interface ParsedNoticeExtras {
params?: Record<string, unknown>
raw?: unknown
duration?: number
/** Code extracted before translation parameters are flattened. */
nestedCode?: string
}
function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
@@ -100,6 +124,14 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
continue
}
// Parse command failures before generic translation params.
if (isCommandFailure(extra)) {
if (!raw) {
raw = extra
continue
}
}
if (isPlainRecord(extra)) {
if (!params) {
params = extra
@@ -126,7 +158,35 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
}
}
return { params, raw, duration }
return {
params: params && readableParams(params),
raw,
duration,
nestedCode: nestedFailureCode(params),
}
}
/** Replace structured failures in translation params with readable detail. */
function readableParams(
source: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(source).map(([key, value]) => [
key,
isCommandFailure(value) ? value.detail : value,
]),
)
}
/** Find a classified failure nested in translation parameters. */
function nestedFailureCode(
source?: Record<string, unknown>,
): string | undefined {
if (!source) return undefined
for (const value of Object.values(source)) {
if (isCommandFailure(value) && value.code) return value.code
}
return undefined
}
function resolveDuration(type: NoticeType, override?: number) {
@@ -137,7 +197,12 @@ function buildNotice(
id: number,
type: NoticeType,
duration: number,
payload: { message?: ReactNode; i18n?: NoticeTranslationDescriptor },
payload: {
message?: ReactNode
i18n?: NoticeTranslationDescriptor
code?: string
operation?: FailedOperation
},
timerId?: ReturnType<typeof setTimeout>,
): NoticeItem {
return {
@@ -178,6 +243,23 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return proto === Object.prototype || proto === null
}
/** Maximum code points rendered in a notice. */
const MAX_RENDERED_TEXT_CHARS = 500
/** Bound resolved display text by code point and append an ellipsis when truncated. */
export function boundNoticeText(text: string): string {
let end = 0
let counted = 0
for (const codePoint of text) {
if (counted === MAX_RENDERED_TEXT_CHARS) {
return `${text.slice(0, end).trimEnd()}…`
}
end += codePoint.length
counted += 1
}
return text
}
function createRawDescriptor(message: string): NoticeTranslationDescriptor {
return {
key: 'shared.feedback.notices.raw',
@@ -185,8 +267,13 @@ function createRawDescriptor(message: string): NoticeTranslationDescriptor {
}
}
/** Messages longer than any real translation key are treated as raw text. */
const MAX_TRANSLATION_KEY_CHARS = 200
function isLikelyTranslationKey(key: string) {
return TRANSLATION_KEY_PATTERN.test(key)
return (
key.length <= MAX_TRANSLATION_KEY_CHARS && TRANSLATION_KEY_PATTERN.test(key)
)
}
function shouldUseTranslationKey(
@@ -210,6 +297,7 @@ function extractDisplayText(input: unknown): string | undefined {
if (input instanceof Error) {
return input.message || input.name
}
if (isCommandFailure(input)) return input.detail
if (typeof input === 'object' && input !== null) {
const maybeMessage = (input as { message?: unknown }).message
if (typeof maybeMessage === 'string') return maybeMessage
@@ -221,16 +309,40 @@ function extractDisplayText(input: unknown): string | undefined {
}
}
function parseCodedError(input?: string) {
if (!input) return undefined
const match = input.match(CODED_ERROR_PATTERN)
if (!match) return undefined
/** What a failed Tauri command reports. */
export interface CommandFailure {
/** Stable identifier for the kind of failure, when the backend could classify it. */
code?: string
/** Requested operation when known by the command. */
operation?: FailedOperation
/** Human-readable diagnostic detail. */
detail: string
}
function isCommandFailure(input: unknown): input is CommandFailure {
if (typeof input !== 'object' || input === null) return false
const candidate = input as Partial<CommandFailure>
return (
typeof candidate.detail === 'string' &&
(candidate.code === undefined || typeof candidate.code === 'string')
)
}
/** Extract display text from a structured or legacy failure. */
export function errorDetail(input: unknown): string {
return extractDisplayText(input) ?? ''
}
/** Parse a classified command failure for notice rendering. */
function parseCommandFailure(input: unknown) {
if (!isCommandFailure(input) || !input.code) return undefined
const explanation = CODED_ERROR_TRANSLATION_KEYS[input.code]
return {
translationKey:
CODED_ERROR_TRANSLATION_KEYS[match[1]] ??
'shared.feedback.errors.operationFailed',
detail: match[2]?.trim(),
translationKey: explanation ?? 'shared.feedback.errors.operationFailed',
/** Whether this code has a specific explanation. */
explained: explanation !== undefined,
detail: input.detail.trim() || undefined,
}
}
@@ -240,7 +352,7 @@ function normalizeNoticeMessage(
raw?: unknown,
): { message?: ReactNode; i18n?: NoticeTranslationDescriptor } {
const rawText = raw !== undefined ? extractDisplayText(raw) : undefined
const parsedRawError = parseCodedError(rawText)
const parsedRawError = parseCommandFailure(raw)
const rawDetail = parsedRawError?.detail ?? rawText
if (isValidElement(message)) {
@@ -248,7 +360,7 @@ function normalizeNoticeMessage(
}
if (isMaybeTranslationDescriptor(message)) {
const originalParams = message.params ?? {}
const originalParams = readableParams(message.params ?? {})
const mergedParams = Object.keys(params ?? {}).length
? { ...originalParams, ...params }
: { ...originalParams }
@@ -275,7 +387,7 @@ function normalizeNoticeMessage(
}
}
const parsedMessageError = parseCodedError(extractDisplayText(message))
const parsedMessageError = parseCommandFailure(message)
if (parsedMessageError) {
if (!parsedMessageError.detail) {
return {
@@ -298,12 +410,17 @@ function normalizeNoticeMessage(
if (typeof message === 'string') {
if (rawDetail !== undefined) {
if (shouldUseTranslationKey(message, params)) {
// A mapped cause outranks a translation key that only names the operation.
const prefixKey = parsedRawError?.explained
? parsedRawError.translationKey
: message
return {
i18n: {
key: 'shared.feedback.notices.prefixedRaw',
params: {
...(params ?? {}),
prefixKey: message,
prefixKey,
message: rawDetail,
},
},
@@ -351,7 +468,12 @@ const baseShowNotice = (
...extras: NoticeExtra[]
): number => {
const id = nextId++
const { params, raw, duration } = parseNoticeExtras(extras)
const { params, raw, duration, nestedCode } = parseNoticeExtras(extras)
// Accept codes from raw, direct, or interpolated failures.
const code = failureCode(raw) ?? failureCode(message) ?? nestedCode
const operation = failureOperation(raw) ?? failureOperation(message)
// Suppress duplicate toasts while preserving the caller's id contract.
if (isReportedByDialog(code)) return id
const effectiveDuration = resolveDuration(type, duration)
const timerId =
effectiveDuration > 0
@@ -363,15 +485,33 @@ const baseShowNotice = (
id,
type,
effectiveDuration,
normalizedMessage,
{ ...normalizedMessage, code, operation },
timerId,
)
// Replace older notices for the same failure code.
if (code) {
notices
.filter((existing) => existing.code === code)
.forEach((existing) => {
if (existing.timerId) clearTimeout(existing.timerId)
})
notices = notices.filter((existing) => existing.code !== code)
}
notices = [...notices, notice]
notifySubscribers()
return id
}
function failureCode(input: unknown): string | undefined {
return isCommandFailure(input) ? input.code : undefined
}
function failureOperation(input: unknown): FailedOperation | undefined {
return isCommandFailure(input) ? input.operation : undefined
}
/**
* Shows a global notice; `showNotice.success / error / info` are the usual entry points.
*
+40
View File
@@ -0,0 +1,40 @@
/** Why a privileged service is needed. */
export type ServiceRequestReason =
| 'sysproxyRefused'
| 'sysproxySidecarReady'
| 'tunNeedsService'
export interface ServiceRequest {
readonly reason: ServiceRequestReason
/** Settings to apply after the core enters service mode. */
readonly restore?: Partial<IVergeConfig>
}
type Subscriber = () => void
let request: ServiceRequest | null = null
const subscribers = new Set<Subscriber>()
const notify = () => {
subscribers.forEach((subscriber) => subscriber())
}
export const requestService = (next: ServiceRequest) => {
request = next
notify()
}
export const clearServiceRequest = () => {
if (request === null) return
request = null
notify()
}
export const subscribeServiceRequest = (subscriber: Subscriber) => {
subscribers.add(subscriber)
return () => {
subscribers.delete(subscriber)
}
}
export const getServiceRequest = () => request
+18
View File
@@ -138,6 +138,16 @@ export const translationKeys = [
'layout.components.serviceMigration.errors.restartFailed',
'layout.components.serviceMigration.errors.revalidationFailed',
'layout.components.serviceMigration.errors.sidecarFailed',
'layout.components.sysproxyPrivilege.title',
'layout.components.sysproxyPrivilege.message',
'layout.components.sysproxyPrivilege.serviceReadyMessage',
'layout.components.sysproxyPrivilege.alternative',
'layout.components.sysproxyPrivilege.later',
'layout.components.sysproxyPrivilege.installing',
'layout.components.sysproxyPrivilege.restarting',
'layout.components.sysproxyPrivilege.applying',
'layout.components.sysproxyPrivilege.tunTitle',
'layout.components.sysproxyPrivilege.tunMessage',
'layout.feedback.errors.restartCoreStopFailed',
'layout.feedback.errors.quitCoreStopFailed',
'logs.page.title',
@@ -397,8 +407,12 @@ export const translationKeys = [
'settings.sections.proxyControl.tooltips.tunUnavailable',
'settings.sections.proxyControl.actions.installService',
'settings.sections.proxyControl.actions.uninstallService',
'settings.sections.proxyControl.actions.switchToServiceMode',
'settings.sections.proxyControl.fields.systemProxy',
'settings.sections.proxyControl.fields.tunMode',
'settings.sections.proxyControl.messages.installedCheckProxy',
'settings.sections.proxyControl.messages.installedCoreNotOnService',
'settings.sections.proxyControl.messages.installedProxyRestored',
'settings.sections.externalController.title',
'settings.sections.externalController.fields.enable',
'settings.sections.externalController.fields.address',
@@ -760,6 +774,10 @@ export const translationKeys = [
'settings.feedback.errors.clashService.reinstallFailed',
'settings.feedback.errors.clashService.repairFailed',
'settings.feedback.errors.clashService.sidecarFailed',
'settings.feedback.errors.sysproxy.privilegeRequired',
'settings.feedback.errors.sysproxy.directFallback',
'settings.feedback.errors.sysproxy.guardStopped',
'settings.feedback.errors.sysproxy.sidecarWhileServiceReady',
'settings.feedback.notifications.clash.restartSuccess',
'settings.feedback.notifications.clash.versionUpdated',
'settings.feedback.notifications.clash.alreadyLatestVersion',
+24
View File
@@ -231,6 +231,18 @@ export interface TranslationResources {
title: string
unavailableMessage: string
}
sysproxyPrivilege: {
alternative: string
applying: string
installing: string
later: string
message: string
restarting: string
serviceReadyMessage: string
title: string
tunMessage: string
tunTitle: string
}
}
feedback: {
errors: {
@@ -796,6 +808,12 @@ export interface TranslationResources {
sidecarFailed: string
uninstallFailed: string
}
sysproxy: {
directFallback: string
guardStopped: string
privilegeRequired: string
sidecarWhileServiceReady: string
}
}
notifications: {
clash: {
@@ -1297,12 +1315,18 @@ export interface TranslationResources {
proxyControl: {
actions: {
installService: string
switchToServiceMode: string
uninstallService: string
}
fields: {
systemProxy: string
tunMode: string
}
messages: {
installedCheckProxy: string
installedCoreNotOnService: string
installedProxyRestored: string
}
tooltips: {
systemProxy: string
tunMode: string