diff --git a/Cargo.lock b/Cargo.lock index 3cbb7b231..e99b8cd63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 28f9d48f6..72fe7b772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/Changelog.md b/Changelog.md index 0b9c5c15c..fbefc0d83 100644 --- a/Changelog.md +++ b/Changelog.md @@ -5,11 +5,25 @@ 修复 macOS 多显示器切换时托盘速率样式不同步 修复 WebSocket 在 onConnected 初始化完成前被保存的问题,避免初始化失败后留下无监听且无法重试的无效连接。 修复 Mihomo API 变更后 inline proxy-provider 被错误过滤的问题,解决节点显示 Unknown 及延迟测试失败的问题 +修复 macOS 开启「访问系统范围设置需要管理员密码」时无法设置系统代理的问题 +修复从未开启过系统代理的用户,每次内核启动都收到报错的问题 +修复受限机器上「安装服务 → 自动切换到服务模式」整条流程无法走通的问题 +修复系统代理开关切换失败后,界面停留在错误状态的问题 +修复托盘与热键切换系统代理失败时没有任何提示的问题 +修复代理守卫反复失败后无限重试且不告知用户的问题 +修复错误提示只显示最外层信息、丢失具体原因的问题 +修复过长的错误提示撑满界面的问题(右键复制仍为完整内容) +修复部分错误提示显示为 [object Object] 的问题 +修复 i18n 清理脚本因导入方式错误而无法运行的问题
✨ 新增功能 - 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示 +- 系统代理或 TUN 因权限被拒时弹出引导窗口,一步安装系统服务并自动恢复原本的设置 +- 引导窗口显示当前阶段:安装服务 / 重启内核 / 应用代理 +- 系统代理失败会保留到窗口可见时再呈现,不会因窗口隐藏或最小化而丢失 +- 区分「需要安装服务」与「服务已就绪但内核仍以 Sidecar 运行」,分别给出对应操作
@@ -27,5 +41,12 @@ - 优化首页当前节点的交互 - Linux 代理设置缺失必要命令时,给出明确命令缺失信息,避免模糊报错 - 支持在前置规则与后置规则之间直接切换规则位置 +- 系统代理已经是目标状态时不再执行多余的系统写入 +- 系统代理写入半途失败后,系统状态、代理守卫与配置三者保持一致 +- 代理守卫连续失败后停止并告知,不再静默重试 +- TUN 模式开关不再需要单独的安装服务按钮,与系统代理走同一条恢复流程 +- 系统代理失败给出可操作的具体原因,而非操作系统的英文原文 +- 配置变更后统一广播刷新,界面开关与托盘状态保持同步 +- 重构命令层错误返回为结构化格式,保留完整诊断链 diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index d408d9efa..c267deedd 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index 1421706ff..5d6d8077b 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index 7df4a95b7..c028f0a37 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index cd9ee3710..213e8cfca 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index 9ce9b2bac..f13f7bdb8 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index 0ac806901..84e3b2dec 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index c789d268d..36dbf276b 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index 1a9ba8b23..daf96ba4a 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 6bc2060df..b2699cf6f 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -10,6 +10,12 @@ notifications: title: Системный прокси 'on': Системный прокси включён. 'off': Системный прокси отключён. + systemProxyFailed: + title: Системный прокси + body: Не удалось настроить системный прокси. Откройте Clash Verge, чтобы узнать причину. + systemProxyGuardStopped: + title: Страж системного прокси + body: Остановлен после нескольких неудач подряд. Откройте Clash Verge, чтобы узнать причину. tunModeToggled: title: Режим TUN 'on': Режим TUN включён. diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index 4a44466a9..630d156fd 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index 62a3e0800..b7a5ef0f6 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -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. diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index fe7922c1a..49c1203ce 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -10,6 +10,12 @@ notifications: title: 系统代理 'on': 系统代理已启用。 'off': 系统代理已禁用。 + systemProxyFailed: + title: 系统代理 + body: 系统代理设置失败。打开 Clash Verge 查看原因。 + systemProxyGuardStopped: + title: 系统代理守卫 + body: 连续多次失败后已停止守卫。打开 Clash Verge 查看原因。 tunModeToggled: title: TUN 模式 'on': TUN 模式已开启。 diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index c0a68d275..6ea5d9c59 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -10,6 +10,12 @@ notifications: title: 系統代理 'on': 系統代理已啟用。 'off': 系統代理已關閉。 + systemProxyFailed: + title: 系統代理 + body: 系統代理設定失敗。開啟 Clash Verge 查看原因。 + systemProxyGuardStopped: + title: 系統代理守衛 + body: 連續多次失敗後已停止守衛。開啟 Clash Verge 查看原因。 tunModeToggled: title: 虛擬網路介面卡模式 'on': 虛擬網路介面卡模式已啟用。 diff --git a/scripts/cleanup-unused-i18n.mjs b/scripts/cleanup-unused-i18n.mjs index 5143e03bc..0515541b9 100644 --- a/scripts/cleanup-unused-i18n.mjs +++ b/scripts/cleanup-unused-i18n.mjs @@ -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) diff --git a/src-tauri/src/cmd/backup.rs b/src-tauri/src/cmd/backup.rs index fc80bc9b4..ccd651271 100644 --- a/src-tauri/src/cmd/backup.rs +++ b/src-tauri/src/cmd/backup.rs @@ -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 diff --git a/src-tauri/src/cmd/clash.rs b/src-tauri/src/cmd/clash.rs index d18bf6dd9..c93c38a5c 100644 --- a/src-tauri/src/cmd/clash.rs +++ b/src-tauri/src/cmd/clash.rs @@ -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 { 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> { /// 切换Clash核心 #[tauri::command] -pub async fn change_clash_core(clash_core: String) -> CmdResult> { +pub async fn change_clash_core(clash_core: String) -> CmdResult> { 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> 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"); } diff --git a/src-tauri/src/cmd/listener.rs b/src-tauri/src/cmd/listener.rs index b96c26bec..610a2628f 100644 --- a/src-tauri/src/cmd/listener.rs +++ b/src-tauri/src/cmd/listener.rs @@ -11,5 +11,7 @@ pub async fn probe_listener(request: ListenerProbe) -> CmdResult CmdResult { - feat::save_proxy_ports(settings).await.stringify_err() + feat::save_proxy_ports(settings) + .await + .map_err(|error| super::proxy_aware_error(&error)) } diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 4a588ba0f..2497e54fe 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -1,12 +1,94 @@ use anyhow::Result; use smartstring::alias::String; -pub type CmdResult = Result; +pub type CmdResult = Result; -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, + /// 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, +} -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) -> 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 for CommandFailure { + fn from(detail: std::string::String) -> Self { + Self { + code: None, + detail, + operation: None, + } + } +} + +impl From 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 { impl StringifyErr for Result { fn stringify_err(self) -> CmdResult { - self.map_err(|e| e.to_string().into()) + self.map_err(CommandFailure::plain) } fn stringify_err_log(self, log_fn: F) -> CmdResult @@ -68,28 +150,175 @@ impl StringifyErr for Result { 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 WithErrorCode for Result { fn with_error_code(self, code: &str) -> CmdResult { - 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" }) ); } } diff --git a/src-tauri/src/cmd/save_profile.rs b/src-tauri/src/cmd/save_profile.rs index a615992ff..e5c7d74d9 100644 --- a/src-tauri/src/cmd/save_profile.rs +++ b/src-tauri/src/cmd/save_profile.rs @@ -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 { diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 9e2470954..4d5aed4d4 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -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")) } diff --git a/src-tauri/src/cmd/system.rs b/src-tauri/src/cmd/system.rs index bb460bafb..fc309ee5c 100644 --- a/src-tauri/src/cmd/system.rs +++ b/src-tauri/src/cmd/system.rs @@ -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 { Ok(RUN_STATE.settled().await.to_view()) } + +/// Return unresolved failures without consuming them. +#[tauri::command] +pub async fn get_pending_failures() -> Vec { + notification::pending_failures() +} diff --git a/src-tauri/src/cmd/verge.rs b/src-tauri/src/cmd/verge.rs index fc83b098f..b71c45ef9 100644 --- a/src-tauri/src/cmd/verge.rs +++ b/src-tauri/src/cmd/verge.rs @@ -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> { /// 修改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 { + 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); + } } diff --git a/src-tauri/src/cmd/webdav.rs b/src-tauri/src/cmd/webdav.rs index 5f9f00fcc..27f0ee23d 100644 --- a/src-tauri/src/cmd/webdav.rs +++ b/src-tauri/src/cmd/webdav.rs @@ -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)) } diff --git a/src-tauri/src/core/hotkey.rs b/src-tauri/src/core/hotkey.rs index 6f4cd08e7..520094015 100755 --- a/src-tauri/src/core/hotkey.rs +++ b/src-tauri/src/core/hotkey.rs @@ -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 => { diff --git a/src-tauri/src/core/listener.rs b/src-tauri/src/core/listener.rs index 79cd9a3ce..33f7e51a1 100644 --- a/src-tauri/src/core/listener.rs +++ b/src-tauri/src/core/listener.rs @@ -435,6 +435,13 @@ fn bind_claim(claim: &BindClaim) -> io::Result> { 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 { + 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::(&table)? + .iter() + .any(|row| row.dwLocalAddr == 0 && windows_tcp_port(row.dwLocalPort) == port)), + IpAddr::V6(_) => Ok(windows_tcp_listener_rows::(&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> { + 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::()); + 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(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::()) + .and_then(|rows| size_of::().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::() > align_of::() { + 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, diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index 6da368917..72d64ff17 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -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")) } } } diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 2235ffea8..1ec318747 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -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( 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, + StopGuardFuture: std::future::Future, Clear: FnOnce() -> ClearFuture, ClearFuture: std::future::Future>, Stop: FnOnce() -> StopFuture, StopFuture: std::future::Future>, { - 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, + StopGuardFuture: std::future::Future, Clear: FnOnce() -> ClearFuture, ClearFuture: std::future::Future>, Stop: FnOnce() -> StopFuture, @@ -208,7 +232,15 @@ where Restore: FnOnce() -> RestoreFuture, RestoreFuture: std::future::Future>, { - 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>) { + async fn stop_guard(&self, calls: &Mutex>) -> 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), || { diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index 38dd941e7..f62947e8e 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -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:#}" + ))); } } } diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index c718c41f2..3178ba987 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -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; diff --git a/src-tauri/src/core/notification.rs b/src-tauri/src/core/notification.rs index 4f2c5931a..dcae04646 100644 --- a/src-tauri/src/core/notification.rs +++ b/src-tauri/src/core/notification.rs @@ -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>, + 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 { + let mut failures: Vec = 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(operation: FailedOperation, work: impl Future) -> 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 = Lazy::new(FailureTable::default); + +pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into) { + 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 { + 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)); + } +} diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs index 2d2db4d50..f5566492a 100644 --- a/src-tauri/src/core/proxy_control.rs +++ b/src-tauri/src/core/proxy_control.rs @@ -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::() + } +} + +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::().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::(), + 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) -> 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(&self, cancel: F) -> u64 + /// Invalidate the guard generation and wait for cancellation. + async fn cancel_and_drain(&self, cancel: F) -> (u64, T) where F: FnOnce() -> Fut, - Fut: Future, + Fut: Future, { 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(&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( &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 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_local_guard: Drain, + write: Write, +) -> Result<()> +where + Drain: FnOnce() -> DrainFuture, + DrainFuture: Future, + Write: FnOnce() -> WriteFuture, + WriteFuture: Future>, +{ + 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(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::(), + 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::(), + 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 { + 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")); + } } diff --git a/src-tauri/src/core/sysopt.rs b/src-tauri/src/core/sysopt.rs index 7073c9fa3..fc83d76f8 100644 --- a/src-tauri/src/core/sysopt.rs +++ b/src-tauri/src/core/sysopt.rs @@ -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::() { + 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) -> 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( + drained: bool, + disable: Disable, + drain_again: Drain, +) -> Result<()> +where + Disable: Fn() -> DisableFuture, + DisableFuture: std::future::Future>, + Drain: FnOnce() -> DrainFuture, + DrainFuture: std::future::Future, +{ + 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(attempts: [(&'static str, Result<()>); N]) -> Result<()> { + let mut first: Option = 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(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( + 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>, +{ + 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::>()); + 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, + compensated: bool, + state_unknown: bool, + #[cfg(not(target_os = "linux"))] + message: String, + } + + async fn record_recovery(error: anyhow::Error, earlier_step_completed: bool, compensation_works: bool) -> Recovery { + let guard_told = Mutex::new(None); + let compensated = Mutex::new(false); + let recovered = recover_from_failed_write( + error, + earlier_step_completed, + |state| *guard_told.lock() = Some(state), + || { + *compensated.lock() = true; + async move { + if compensation_works { + Ok(()) + } else { + anyhow::bail!("the compensation was refused too") + } + } + }, + ) + .await; + + Recovery { + guard_told: *guard_told.lock(), + compensated: *compensated.lock(), + state_unknown: SystemProxyStateUnknown::is_in(&recovered), + #[cfg(not(target_os = "linux"))] + message: format!("{recovered:#}"), + } + } + + fn refused() -> anyhow::Error { + anyhow::Error::new(sysproxy::Error::ProxyWrite { + progress: sysproxy::WriteProgress::new(0, 7), + source: Box::new(sysproxy::Error::RequiresAdminPrivileges), + }) + } + + fn partly_written() -> anyhow::Error { + anyhow::Error::new(sysproxy::Error::ProxyWrite { + progress: sysproxy::WriteProgress::new(3, 7), + source: Box::new(sysproxy::Error::RequiresAdminPrivileges), + }) + } + + #[tokio::test] + async fn a_failure_that_wrote_nothing_is_not_compensated_for() { + let recovery = record_recovery(refused(), false, true).await; + + assert!(!recovery.compensated); + assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged)); + assert!(!recovery.state_unknown); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn nothing_on_linux_ever_reaches_the_destructive_row() { + for earlier_step_completed in [false, true] { + let recovery = record_recovery(partly_written(), earlier_step_completed, true).await; + + assert!(!recovery.compensated, "{earlier_step_completed}"); + assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged)); + assert!(!recovery.state_unknown); + } + } + + #[cfg(not(target_os = "linux"))] + #[tokio::test] + async fn a_failure_that_wrote_something_forces_the_proxy_off() { + let recovery = record_recovery(partly_written(), false, true).await; + + assert!(recovery.compensated); + assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown)); + assert!(recovery.state_unknown); + } + + #[cfg(not(target_os = "linux"))] + #[tokio::test] + async fn a_compensation_that_fails_does_not_replace_the_diagnosis() { + let recovery = record_recovery(partly_written(), false, false).await; + + assert!(recovery.compensated); + assert!(recovery.message.contains("admin privileges")); + assert!(!recovery.message.contains("the compensation was refused too")); + assert!(recovery.state_unknown); + } + + #[cfg(not(target_os = "linux"))] + #[tokio::test] + async fn an_earlier_setter_that_finished_reaches_the_second_row_too() { + let recovery = record_recovery(refused(), true, true).await; + + assert!(recovery.compensated); + assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown)); + assert!(recovery.state_unknown); + } + + #[tokio::test] + async fn a_drained_guard_needs_only_one_write() { + assert_eq!( + record_disable_sequence(true, true, &[true]).await, + (vec!["disable"], true) + ); + } + + #[tokio::test] + async fn a_write_that_raced_the_guard_is_repeated_after_it_finishes() { + assert_eq!( + record_disable_sequence(false, true, &[true, true]).await, + (vec!["disable", "drain", "disable"], true) + ); + } + + #[tokio::test] + async fn a_second_write_that_fails_on_its_own_is_still_reported() { + assert_eq!( + record_disable_sequence(false, true, &[true, false]).await, + (vec!["disable", "drain", "disable"], false) + ); + } + + #[tokio::test] + async fn a_guard_that_never_finishes_is_reported_rather_than_written_over() { + assert_eq!( + record_disable_sequence(false, false, &[true]).await, + (vec!["disable", "drain"], false) + ); + } + + #[tokio::test] + async fn a_refused_write_stops_before_waiting_on_anything() { + assert_eq!( + record_disable_sequence(false, true, &[false]).await, + (vec!["disable"], false) + ); + } + + #[test] + fn a_failure_with_nothing_written_leaves_the_old_guard_worth_restoring() { + let refused = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges); + + assert_eq!(authoritative_state(&refused, false), AuthoritativeState::Unchanged); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn only_evidence_that_a_write_landed_stands_the_guard_down() { + let nothing_written = anyhow::Error::new(sysproxy::Error::ProxyWrite { + progress: sysproxy::WriteProgress::new(0, 7), + source: Box::new(sysproxy::Error::RequiresAdminPrivileges), + }); + let something_written = anyhow::Error::new(sysproxy::Error::ProxyWrite { + progress: sysproxy::WriteProgress::new(3, 7), + source: Box::new(sysproxy::Error::RequiresAdminPrivileges), + }); + + assert_eq!( + authoritative_state(¬hing_written, false), + AuthoritativeState::Unchanged + ); + assert_eq!( + authoritative_state(&something_written, false), + AuthoritativeState::Unknown + ); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn a_step_that_already_finished_stands_the_guard_down_whatever_the_error_says() { + let refused = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges); + + assert_eq!(authoritative_state(&refused, false), AuthoritativeState::Unchanged); + assert_eq!(authoritative_state(&refused, true), AuthoritativeState::Unknown); + } + + #[test] + fn where_a_successful_setter_proves_nothing_no_failure_is_destructive() { + let strongest_evidence = anyhow::Error::new(sysproxy::Error::ProxyWrite { + progress: sysproxy::WriteProgress::new(3, 7), + source: Box::new(sysproxy::Error::RequiresAdminPrivileges), + }); + + for earlier_step_completed in [false, true] { + assert_eq!( + authoritative_state_from(false, &strongest_evidence, earlier_step_completed), + AuthoritativeState::Unchanged, + "{earlier_step_completed}" + ); + } + } + + #[test] + fn where_it_proves_something_both_kinds_of_evidence_count() { + let nothing_written = anyhow::Error::new(sysproxy::Error::ProxyWrite { + progress: sysproxy::WriteProgress::new(0, 7), + source: Box::new(sysproxy::Error::RequiresAdminPrivileges), + }); + let partly_written = anyhow::Error::new(sysproxy::Error::ProxyWrite { + progress: sysproxy::WriteProgress::new(3, 7), + source: Box::new(sysproxy::Error::RequiresAdminPrivileges), + }); + + assert_eq!( + authoritative_state_from(true, ¬hing_written, false), + AuthoritativeState::Unchanged + ); + assert_eq!( + authoritative_state_from(true, &partly_written, false), + AuthoritativeState::Unknown + ); + assert_eq!( + authoritative_state_from(true, ¬hing_written, true), + AuthoritativeState::Unknown + ); + } + + fn holding_global(port: u16) -> sysproxy::ProxySnapshot { + let endpoint = || sysproxy::ProxyEndpoint { + host: "127.0.0.1".to_owned(), + port, + enable: true, + switched_on: true, + }; + sysproxy::ProxySnapshot { + socks: endpoint(), + http: endpoint(), + https: endpoint(), + auto: sysproxy::Autoproxy { + url: std::string::String::new(), + enable: false, + }, + auto_switched_on: false, + bypass: "localhost".to_owned(), + } + } + + fn holding_nothing() -> sysproxy::ProxySnapshot { + let off = || sysproxy::ProxyEndpoint { + host: std::string::String::new(), + port: 0, + enable: false, + switched_on: false, + }; + sysproxy::ProxySnapshot { + socks: off(), + http: off(), + https: off(), + auto: sysproxy::Autoproxy { + url: std::string::String::new(), + enable: false, + }, + auto_switched_on: false, + bypass: std::string::String::new(), + } + } + + fn global_target(port: u16) -> Sysproxy { + Sysproxy { + enable: true, + host: "127.0.0.1".to_owned(), + port, + bypass: "localhost".to_owned(), + } + } + + fn pac_mode_target() -> Sysproxy { + Sysproxy { + enable: false, + ..global_target(7897) + } + } + + fn pac_off() -> Autoproxy { + Autoproxy { + url: std::string::String::new(), + enable: false, + } + } + + #[test] + fn a_proxy_the_os_already_points_at_needs_no_write() { + assert!(target_is_already_in_place( + &holding_global(7897), + &global_target(7897), + &pac_off() + )); + } + + #[test] + fn a_proxy_pointing_at_a_different_port_still_has_to_be_written() { + assert!(!target_is_already_in_place( + &holding_global(7897), + &global_target(7898), + &pac_off() + )); + } + + #[test] + fn a_pac_target_asks_the_pac_question_and_not_the_global_one() { + let pac = Autoproxy { + url: "http://127.0.0.1:33333/commands/pac".to_owned(), + enable: true, + }; + let mut os_holds_pac = holding_nothing(); + os_holds_pac.auto = pac.clone(); + os_holds_pac.auto_switched_on = true; + os_holds_pac.bypass = "localhost".to_owned(); + + assert!(target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac)); + } + + #[test] + fn a_pac_target_whose_bypass_changed_still_has_to_be_written() { + let pac = Autoproxy { + url: "http://127.0.0.1:33333/commands/pac".to_owned(), + enable: true, + }; + let mut os_holds_pac = holding_nothing(); + os_holds_pac.auto = pac.clone(); + os_holds_pac.auto_switched_on = true; + os_holds_pac.bypass = "localhost,example.com".to_owned(); + + assert!(!target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac)); + } + + #[test] + fn a_switch_left_on_over_nothing_is_not_already_disabled() { + let mut stranded = holding_nothing(); + stranded.http = sysproxy::ProxyEndpoint { + host: std::string::String::new(), + port: 0, + enable: false, + switched_on: true, + }; + + assert!(!target_is_already_in_place( + &stranded, + &Sysproxy { + enable: false, + ..global_target(7897) + }, + &pac_off() + )); + } + + #[test] + fn a_target_of_nothing_does_not_ask_about_the_bypass_list() { + let mut os_holds_nothing = holding_nothing(); + os_holds_nothing.bypass = "something.else".to_owned(); + + assert!(target_is_already_in_place( + &os_holds_nothing, + &Sysproxy { + enable: false, + ..global_target(7897) + }, + &pac_off() + )); + } + + #[test] + fn a_target_of_nothing_asks_whether_the_os_holds_nothing() { + let nothing_wanted = Sysproxy { + enable: false, + ..global_target(7897) + }; + + assert!(target_is_already_in_place( + &holding_nothing(), + ¬hing_wanted, + &pac_off() + )); + assert!(!target_is_already_in_place( + &holding_global(7897), + ¬hing_wanted, + &pac_off() + )); + } + + #[test] + fn only_a_snapshot_that_agrees_with_the_target_skips_the_write() { + assert_eq!(classify_os_proxy_state(Ok(true)), OsProxyState::AlreadyApplied); + assert_eq!(classify_os_proxy_state(Ok(false)), OsProxyState::DifferentOrUnknown); + } + + #[test] + fn a_failed_read_never_counts_as_agreement() { + assert_eq!( + classify_os_proxy_state(Err(anyhow::anyhow!("read failed"))), + OsProxyState::DifferentOrUnknown + ); + } #[test] fn pure_sysproxy_mode_clears_pac_before_enabling_global_proxy() { diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 5526427b8..747b9b9cb 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -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; diff --git a/src-tauri/src/feat/backup.rs b/src-tauri/src/feat/backup.rs index f64d36fc7..679d14890 100644 --- a/src-tauri/src/feat/backup.rs +++ b/src-tauri/src/feat/backup.rs @@ -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(()) } diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 4c4bf321d..76a3f71c1 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -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:#}"); } } } diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 66befa2dc..b571ea3cd 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -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> { 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())); + } +} diff --git a/src-tauri/src/feat/listener.rs b/src-tauri/src/feat/listener.rs index 797e0dc80..7ebc4150b 100644 --- a/src-tauri/src/feat/listener.rs +++ b/src-tauri/src/feat/listener.rs @@ -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 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 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")), } } diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index d1bb554ba..e5fca7702 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -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 { 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 { @@ -67,7 +94,7 @@ pub async fn toggle_tun_mode(not_save_file: Option) -> 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); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a89f6048..bd83206d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/utils/notification.rs b/src-tauri/src/utils/notification.rs index 1bafcb0d6..35f5b50f0 100644 --- a/src-tauri/src/utils/notification.rs +++ b/src-tauri/src/utils/notification.rs @@ -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)); + } +} diff --git a/src/components/home/proxy-tun-card.tsx b/src/components/home/proxy-tun-card.tsx index c4be87a81..ca47d964d 100644 --- a/src/components/home/proxy-tun-card.tsx +++ b/src/components/home/proxy-tun-card.tsx @@ -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 ( @@ -199,7 +194,7 @@ export const ProxyTunCard: FC = () => { onClick={() => handleTabChange('system')} icon={ComputerRounded} label={t('settings.sections.system.toggles.systemProxy')} - hasIndicator={systemProxyConfigState} + hasIndicator={systemProxyIndicator} /> 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() + + expect(noticeText()).toContain('connection refused') +}) + +it('shortens an unbounded detail', () => { + showNotice.error({ + code: 'SERVICE_INSTALL_FAILED', + detail: `${'x'.repeat(4000)} tail`, + }) + render() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + fireEvent.contextMenu(screen.getByRole('alert')) + + await waitFor(() => expect(writeText).toHaveBeenCalledOnce()) + expect(writeText.mock.calls[0]?.[0]).toContain(detail) +}) diff --git a/src/components/layout/notice-manager.tsx b/src/components/layout/notice-manager.tsx index 059fae1d4..c825ab106 100644 --- a/src/components/layout/notice-manager.tsx +++ b/src/components/layout/notice-manager.tsx @@ -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 + const i18n = notice.i18n + if (!i18n) return bound(notice.message) + + const source = (i18n.params ?? {}) as Record + // 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 = ({ position }) => { void handleNoticeCopy(notice) }} action={ - handleClose(notice.id)} + - - + handleClose(notice.id)} + > + + + } > {resolveNoticeMessage(notice, t)} diff --git a/src/components/layout/sysproxy-privilege-dialog.test.tsx b/src/components/layout/sysproxy-privilege-dialog.test.tsx new file mode 100644 index 000000000..64a24ef19 --- /dev/null +++ b/src/components/layout/sysproxy-privilege-dialog.test.tsx @@ -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 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() + 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((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() + 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() + onSubscribed?.() + + act(() => requestService({ reason: 'tunNeedsService' })) + await screen.findByRole('dialog') + clickPrimary('settings.sections.proxyControl.actions.installService') + + await waitFor(() => expect(restartCore).toHaveBeenCalledOnce()) + expect(patchVergeConfig).not.toHaveBeenCalled() +}) diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx new file mode 100644 index 000000000..c193c4c59 --- /dev/null +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -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 | 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('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 ( + void handleFix()} + onCancel={close} + onClose={close} + > + + {t(step !== 'idle' ? STEP_MESSAGE[step] : EXPLANATION[reason])} + + {loading && } + {!loading && reason === 'sysproxyRefused' && ( + + {t('layout.components.sysproxyPrivilege.alternative')} + + )} + + ) +} diff --git a/src/components/setting/mods/backup-config-viewer.tsx b/src/components/setting/mods/backup-config-viewer.tsx index 5e3fb8e14..babc1b122 100644 --- a/src/components/setting/mods/backup-config-viewer.tsx +++ b/src/components/setting/mods/backup-config-viewer.tsx @@ -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 { diff --git a/src/components/setting/mods/backup-viewer.tsx b/src/components/setting/mods/backup-viewer.tsx index dcc368dc5..8261723ef 100644 --- a/src/components/setting/mods/backup-viewer.tsx +++ b/src/components/setting/mods/backup-viewer.tsx @@ -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 }) { 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 }) { console.error(error) showNotice.error( 'settings.modals.backup.messages.localBackupImportFailed', - { error }, + { error: errorDetail(error) }, ) } finally { setLocalImporting(false) diff --git a/src/components/setting/mods/backup-webdav-dialog.tsx b/src/components/setting/mods/backup-webdav-dialog.tsx index 090d3dd5c..2e0b348f6 100644 --- a/src/components/setting/mods/backup-webdav-dialog.tsx +++ b/src/components/setting/mods/backup-webdav-dialog.tsx @@ -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) diff --git a/src/components/shared/proxy-control-switches.test.tsx b/src/components/shared/proxy-control-switches.test.tsx new file mode 100644 index 000000000..aa89de350 --- /dev/null +++ b/src/components/shared/proxy-control-switches.test.tsx @@ -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( + , + ) + +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( + , + ) + + flip() + + await waitFor(() => expect(getServiceRequest()).not.toBeNull()) + await waitFor(() => expect(theSwitch().checked).toBe(false)) + expect(onError).not.toHaveBeenCalled() + expect(getSnapshotNotices()).toEqual([]) +}) diff --git a/src/components/shared/proxy-control-switches.tsx b/src/components/shared/proxy-control-switches.tsx index d196d6e60..dbd3f66d7 100644 --- a/src/components/shared/proxy-control-switches.tsx +++ b/src/components/shared/proxy-control-switches.tsx @@ -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 + /** Return false to roll back without reporting an error. */ + onToggle: (value: boolean) => Promise 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 && ( - <> - - - + )} {isServiceInstallReady && ( 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 = 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) +}) diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 1abde6091..731773381 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -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(['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, } diff --git a/src/locales/ar/layout.json b/src/locales/ar/layout.json index a3881e18e..bac453d76 100644 --- a/src/locales/ar/layout.json +++ b/src/locales/ar/layout.json @@ -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": { diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 1d1b0a9b4..4b23056fd 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -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": { diff --git a/src/locales/de/layout.json b/src/locales/de/layout.json index 095890074..8492e1647 100644 --- a/src/locales/de/layout.json +++ b/src/locales/de/layout.json @@ -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": { diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 981ecdfca..4c78bd4de 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -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": { diff --git a/src/locales/en/layout.json b/src/locales/en/layout.json index bea0ecce2..2df3c6d63 100644 --- a/src/locales/en/layout.json +++ b/src/locales/en/layout.json @@ -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": { diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index fe0718e73..5e16b4fc7 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -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": { diff --git a/src/locales/es/layout.json b/src/locales/es/layout.json index ece0d655f..b881589fe 100644 --- a/src/locales/es/layout.json +++ b/src/locales/es/layout.json @@ -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": { diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index d253ea2bf..2c34af229 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -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": { diff --git a/src/locales/fa/layout.json b/src/locales/fa/layout.json index 1969364f0..a6b7ee442 100644 --- a/src/locales/fa/layout.json +++ b/src/locales/fa/layout.json @@ -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": { diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 32681ed29..0882892ab 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -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": { diff --git a/src/locales/id/layout.json b/src/locales/id/layout.json index b93f6ccaa..f0ac20d14 100644 --- a/src/locales/id/layout.json +++ b/src/locales/id/layout.json @@ -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": { diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 3876feede..79d5bafaa 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -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": { diff --git a/src/locales/jp/layout.json b/src/locales/jp/layout.json index dd7d242e1..b14e8c3ba 100644 --- a/src/locales/jp/layout.json +++ b/src/locales/jp/layout.json @@ -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": { diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 981f0810d..9afda431c 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -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": { diff --git a/src/locales/ko/layout.json b/src/locales/ko/layout.json index 169c73373..cf70b7ee9 100644 --- a/src/locales/ko/layout.json +++ b/src/locales/ko/layout.json @@ -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": { diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 2e594d487..d82e1dec6 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -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": { diff --git a/src/locales/ru/layout.json b/src/locales/ru/layout.json index 95ddf5100..6e3b73764 100644 --- a/src/locales/ru/layout.json +++ b/src/locales/ru/layout.json @@ -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": { diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 86e341922..be258a365 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -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": { diff --git a/src/locales/tr/layout.json b/src/locales/tr/layout.json index 7a9fc8719..667169abf 100644 --- a/src/locales/tr/layout.json +++ b/src/locales/tr/layout.json @@ -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": { diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 3b2e70568..bcb91920b 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -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": { diff --git a/src/locales/tt/layout.json b/src/locales/tt/layout.json index fe962a3a9..783cf9871 100644 --- a/src/locales/tt/layout.json +++ b/src/locales/tt/layout.json @@ -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": { diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 23220ad54..9d18cc19f 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -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": { diff --git a/src/locales/zh/layout.json b/src/locales/zh/layout.json index 177ae7eb3..6a595ac92 100644 --- a/src/locales/zh/layout.json +++ b/src/locales/zh/layout.json @@ -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": { diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 6810de12c..30fa4d24a 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -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": { diff --git a/src/locales/zhtw/layout.json b/src/locales/zhtw/layout.json index 5be24de85..fe412902c 100644 --- a/src/locales/zhtw/layout.json +++ b/src/locales/zhtw/layout.json @@ -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": { diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index b0412853d..dc3f8d758 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -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": { diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 4908750f2..81df4834e 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -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 = () => { {/* 左侧底部窗口控制按钮 */} +
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 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)) +}) diff --git a/src/pages/_layout/hooks/use-pending-failures.ts b/src/pages/_layout/hooks/use-pending-failures.ts new file mode 100644 index 000000000..90efbd5d6 --- /dev/null +++ b/src/pages/_layout/hooks/use-pending-failures.ts @@ -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([ + '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()) + + 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(null) + // Dismiss locally without retiring the backend failure. + const [dismissedSequence, setDismissedSequence] = useState( + 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 } +} diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index f274255d5..cbb3ac7e7 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -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 { diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 93b25e797..6f86de535 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -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('change_clash_core', { clashCore }) + return invoke('change_clash_core', { clashCore }) } export async function restartCore() { @@ -406,6 +407,29 @@ export const getRuntimeState = async () => { return invoke('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('get_pending_failures') +} + // 获取应用运行时间 export const getAppUptime = async () => { return invoke('get_app_uptime') diff --git a/src/services/events.ts b/src/services/events.ts index 59d3afdc7..2edf58500 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -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 } diff --git a/src/services/notice-service.test.ts b/src/services/notice-service.test.ts new file mode 100644 index 000000000..531ca4706 --- /dev/null +++ b/src/services/notice-service.test.ts @@ -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') + }) +}) diff --git a/src/services/notice-service.ts b/src/services/notice-service.ts index 050e95069..299bd4e63 100644 --- a/src/services/notice-service.ts +++ b/src/services/notice-service.ts @@ -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 } +/** Failures owned by the recovery dialog. */ +const CODES_A_DIALOG_REPORTS = new Set([ + '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> = { } 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> = { +/** Localized explanation for each stable failure code. */ +const CODED_ERROR_TRANSLATION_KEYS: Readonly> = { 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> = { 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 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, +): Record { + 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 | 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, ): NoticeItem { return { @@ -178,6 +243,23 @@ function isPlainRecord(value: unknown): value is Record { 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 + 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. * diff --git a/src/services/service-request.ts b/src/services/service-request.ts new file mode 100644 index 000000000..46c7436a2 --- /dev/null +++ b/src/services/service-request.ts @@ -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 +} + +type Subscriber = () => void + +let request: ServiceRequest | null = null +const subscribers = new Set() + +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 diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 222a02d89..99a6dbb8a 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -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', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index d505d7cae..4b8858324 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -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