diff --git a/.github/workflows/autobuild.yml b/.github/workflows/autobuild.yml index 97f234af0..eb366e654 100644 --- a/.github/workflows/autobuild.yml +++ b/.github/workflows/autobuild.yml @@ -101,7 +101,7 @@ jobs: - [常见问题](https://clash-verge-rev.github.io/faq/windows.html) ### 稳定机场VPN推荐 - - [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6) + - [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6) Created at ${{ env.BUILDTIME }}. EOF @@ -580,7 +580,7 @@ jobs: - [常见问题](https://clash-verge-rev.github.io/faq/windows.html) ### 稳定机场VPN推荐 - - [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6) + - [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6) Created at ${{ env.BUILDTIME }}. EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0130c80fb..97d748a53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,7 +144,7 @@ jobs: - [常见问题](https://clash-verge-rev.github.io/faq/windows.html) ### 稳定机场VPN推荐 - - [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6) + - [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6) Created at ${{ env.BUILDTIME }}. EOF @@ -691,7 +691,7 @@ jobs: - [常见问题](https://clash-verge-rev.github.io/faq/windows.html) ### 稳定机场VPN推荐 - - [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6) + - [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6) Created at ${{ env.BUILDTIME }}. EOF diff --git a/.github/workflows/telegram-notify.yml b/.github/workflows/telegram-notify.yml index a1515b3d8..f8d296324 100644 --- a/.github/workflows/telegram-notify.yml +++ b/.github/workflows/telegram-notify.yml @@ -90,7 +90,7 @@ jobs: - [常见问题](https://clash-verge-rev.github.io/faq/windows.html) ### 稳定机场VPN推荐 - - [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6) + - [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6) Created at ${{ env.BUILDTIME }}. EOF diff --git a/Cargo.lock b/Cargo.lock index 4ba9821ac..084a1954e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1079,7 +1079,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clash-verge" -version = "2.5.7" +version = "2.5.8" dependencies = [ "aes-gcm", "anyhow", @@ -1223,8 +1223,8 @@ dependencies = [ [[package]] name = "clash_verge_service_ipc" -version = "2.7.5" -source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.5#89acd8d8cd9da5134e886abc4b04c33c6946691a" +version = "2.7.6" +source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.6#b2a7427d6681c8d3780ab83a73f4dc21da2f22d6" dependencies = [ "anyhow", "kode-bridge", diff --git a/Changelog.md b/Changelog.md index 3f3908a96..7b3e8cd95 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,29 +1,29 @@ -## v2.5.7 +## v2.5.8
🐞 修复问题 -- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题 -- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题 +- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 **🖥️ Windows** -- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题 +- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 -**🍎 macOS** +
+ +
+ ✨ 新增功能 -- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题 -- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错 -- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
🚀 优化改进 +- 优化侧边栏流量图表的 CPU 占用 **🖥️ Windows** -- 优化 Windows 服务安全检查未通过时的启动提示:显示具体原因,并提供修复文档链接 +- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
diff --git a/docs/Changelog.history.md b/docs/Changelog.history.md index b834c2b9d..33b61d646 100644 --- a/docs/Changelog.history.md +++ b/docs/Changelog.history.md @@ -1,3 +1,40 @@ +## v2.5.7 + +
+ 🐞 修复问题 + +- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题 +- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题 + +**🖥️ Windows** + +- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题 +- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题 + +**🍎 macOS** + +- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题 +- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错 +- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题 + +**🍎/🐧 macOS/Linux** + +- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失 + +
+ +
+ 🚀 优化改进 + +- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因 + +**🖥️ Windows** + +- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接 + +
+ + ## v2.5.6
diff --git a/docs/README_en.md b/docs/README_en.md index a58e2b07e..414a75366 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -51,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements. ## Promotion -### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6) +### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6) 🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**. -🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6) +🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6) #### **Core Advantages:** @@ -67,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements. - ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client) - 🎬 Unlocks **streaming platforms and mainstream AI services** -🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) +🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6) ### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com) diff --git a/docs/README_es.md b/docs/README_es.md index 5129427b9..2df191696 100644 --- a/docs/README_es.md +++ b/docs/README_es.md @@ -51,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa ## Promociones -#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6) +#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6) - Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase. -- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6) - Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos) - Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual - Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida - Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones - Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge) - Desbloquea servicios de streaming y acceso a ChatGPT -- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6) ### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com) diff --git a/docs/README_fa.md b/docs/README_fa.md index a4b5bce1b..0c94631b8 100644 --- a/docs/README_fa.md +++ b/docs/README_fa.md @@ -50,17 +50,17 @@ ## تبلیغات -#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6) +#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6) - سرویس شبکه برون مرزی با عملکرد بالا به همراه دوره‌های آزمایشی رایگان، طرح‌های تخفیف‌دار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا. -- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6) +- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6) - کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده) - بسته تخفیف‌دار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه - توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره می‌شود - کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینت‌های قدیمی)، تأخیر فوق‌العاده کم، پخش روان 4K - اولین ارائه‌دهنده جهانی با **پروتکل QUIC**، اکنون با پروتکل‌های سریع‌تر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge) - پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT -- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) +- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6) ### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com) diff --git a/docs/README_ja.md b/docs/README_ja.md index 2d9e8d676..6a06023ca 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -51,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー ## プロモーション -#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6) +#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6) - 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。 -- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6) - Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名) - 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ - 海外チーム運営による高信頼サービス、収益シェアは最大 50% - 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適 - 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群) - ストリーミングおよび ChatGPT の利用にも対応 -- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) +- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6) ### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com) diff --git a/docs/README_ko.md b/docs/README_ko.md index 225471719..a0dee6abb 100644 --- a/docs/README_ko.md +++ b/docs/README_ko.md @@ -51,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니 ## 프로모션 -#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6) +#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6) - 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스 -- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6) +- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6) - Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회) - 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인 - 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션 - 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적 - 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합) - 스트리밍 및 ChatGPT 접근 지원 -- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6) +- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6) ### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com) diff --git a/docs/README_ru.md b/docs/README_ru.md index 7ba3fed85..81fd112bb 100644 --- a/docs/README_ru.md +++ b/docs/README_ru.md @@ -47,17 +47,17 @@ Clash Meta GUI базируется на ( fallback: Fallback, ) -> CmdResult where - Fallback: FnOnce() -> FallbackFuture, + Fallback: FnOnce(String) -> FallbackFuture, FallbackFuture: std::future::Future>, { let Err(error) = installation else { @@ -72,12 +75,9 @@ where return Err(proxy_aware_coded_error(&error, error_code)); } let reason = reasons.join("\n"); - fallback().await.map_err(|error| { - proxy_aware_coded_error( - &error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")), - "SERVICE_SIDECAR_FAILED", - ) - })?; + fallback(reason.clone()) + .await + .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?; Ok(ServiceInstallOutcome::Sidecar { reason }) } @@ -107,11 +107,16 @@ pub async fn repair_service() -> CmdResult { #[tauri::command] pub async fn continue_with_sidecar() -> CmdResult { crate::core::CoreManager::global() - .continue_with_sidecar() + .continue_with_sidecar(None) .await .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED")) } +#[tauri::command] +pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot { + crate::core::notification::sidecar_failure_snapshot() +} + #[tauri::command] pub fn take_service_fallback_notice() -> Option { crate::core::service::take_service_fallback_notice() @@ -175,7 +180,7 @@ mod tests { None => anyhow::anyhow!("{reason}"), } .context("install service failed"); - let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async { + let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async { fallback_called.set(true); anyhow::ensure!(fallback_ready, "another core is running"); Ok(()) @@ -201,7 +206,6 @@ mod tests { }) ); if rejected { - assert!(error.detail.contains(reason)); assert!(error.detail.contains("another core is running")); } } diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index f629c405f..4dc70de31 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -61,7 +61,7 @@ pub mod files { } pub mod tun { - pub const DEFAULT_STACK: &str = "gvisor"; + pub const DEFAULT_STACK: &str = "mips"; pub const DNS_HIJACK: &[&str] = &["any:53"]; diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 42fcad7e3..9444a23df 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -334,6 +334,18 @@ enum HandoffOutcome { Failed, } +fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error { + let error = match service_rejection { + Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")), + None => error, + }; + // Proxy failures have their own pending state and recovery dialog. + if SysproxyFailure::from_chain(&error).is_none() { + crate::core::notification::record_sidecar_failure(format!("{error:#}").into()); + } + error +} + impl CoreManager { async fn rollback_failed_start(&self) { proxy_control::stop_guard().await; @@ -364,9 +376,12 @@ impl CoreManager { } #[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))] - pub async fn continue_with_sidecar(&self) -> Result<()> { + pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> { if !self.try_start_config_update() { - anyhow::bail!("configuration update is already running"); + return Err(report_sidecar_failure( + anyhow::anyhow!("configuration update is already running"), + service_rejection, + )); } defer! { self.finish_config_update(); @@ -375,61 +390,66 @@ impl CoreManager { // draft without claiming it, so without this it can commit another transaction's staged patch. let config_write = Config::lock_config_write().await; let _life = self.lifecycle_lock.lock().await; - let status = SERVICE_MANAGER.current().await; - tracing::Span::current().record("status", tracing::field::debug(&status)); - let mode = self.get_running_mode(); - if !can_allow_sidecar_for_session(&mode, &status) { - anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}"); - } - #[cfg(target_os = "windows")] - if matches!(*mode, RunningMode::NotRunning) { - clash_verge_service_ipc::execution::check_sidecar_available().await?; - } - if !matches!(status, ServiceStatus::SidecarAllowed) { - SERVICE_MANAGER.allow_sidecar_for_session()?; - } - // Settling on Sidecar is what makes the verdict final, so ask only once it is recorded. - // Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off. - let prepared = async { - let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); - if crate::core::runstate::RUN_STATE - .state() - .tun_should_be_disabled(tun_enabled) - { - tracing::Span::current().record("tun_disabled", true); - Config::disable_tun_and_persist().await?; - } - Config::generate().await - } - .await; - drop(config_write); - if let Err(error) = prepared { - SERVICE_MANAGER.withdraw_sidecar_allowance(); - return Err(error); - } - // Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded. - proxy_control::stop_guard().await; + // Record failures before releasing the lifecycle lock, including preparation and rollback errors. let result = async { - self.start_core_inner().await?; - if !matches!(*self.get_running_mode(), RunningMode::Sidecar) - || self.current_core_readiness_generation().is_none() - { - anyhow::bail!("Sidecar did not become ready"); + let status = SERVICE_MANAGER.current().await; + tracing::Span::current().record("status", tracing::field::debug(&status)); + let mode = self.get_running_mode(); + if !can_allow_sidecar_for_session(&mode, &status) { + anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}"); } - tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation()); - self.apply_proxy_after_start().await + #[cfg(target_os = "windows")] + if matches!(*mode, RunningMode::NotRunning) { + clash_verge_service_ipc::execution::check_sidecar_available().await?; + } + if !matches!(status, ServiceStatus::SidecarAllowed) { + SERVICE_MANAGER.allow_sidecar_for_session()?; + } + // Settling on Sidecar is what makes the verdict final, so ask only once it is recorded. + // Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off. + let prepared = async { + let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); + if crate::core::runstate::RUN_STATE + .state() + .tun_should_be_disabled(tun_enabled) + { + tracing::Span::current().record("tun_disabled", true); + Config::disable_tun_and_persist().await?; + } + Config::generate().await + } + .await; + drop(config_write); + if let Err(error) = prepared { + SERVICE_MANAGER.withdraw_sidecar_allowance(); + return Err(error); + } + // Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded. + proxy_control::stop_guard().await; + let result = async { + self.start_core_inner().await?; + if !matches!(*self.get_running_mode(), RunningMode::Sidecar) + || self.current_core_readiness_generation().is_none() + { + anyhow::bail!("Sidecar did not become ready"); + } + tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation()); + self.apply_proxy_after_start().await + } + .await; + 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(proxy_control::rollback_failure(error, cleanup_error) + .context("failed to clear the proxy before rolling back")); + } + return Err(error); + } + Ok(()) } .await; - 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(proxy_control::rollback_failure(error, cleanup_error) - .context("failed to clear the proxy before rolling back")); - } - return Err(error); - } - Ok(()) + result.map_err(|error| report_sidecar_failure(error, service_rejection)) } #[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))] @@ -572,6 +592,7 @@ impl CoreManager { } // tell the window when a background apply lands Handle::refresh_verge(); + crate::core::notification::retire_sidecar_failure(); Ok(()) } diff --git a/src-tauri/src/core/notify/notification.rs b/src-tauri/src/core/notify/notification.rs index 5be0c9811..a1ba571dd 100644 --- a/src-tauri/src/core/notify/notification.rs +++ b/src-tauri/src/core/notify/notification.rs @@ -233,6 +233,46 @@ pub fn what_was_asked() -> FailedOperation { static PENDING_FAILURES: Lazy = Lazy::new(FailureTable::default); +/// A removal has a revision too, so a delayed read cannot restore a resolved failure. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct SidecarFailureSnapshot { + pub revision: u64, + pub detail: Option, +} + +impl SidecarFailureSnapshot { + fn record(&mut self, detail: String) { + self.revision += 1; + self.detail = Some(detail); + } + + fn recover(&mut self) -> bool { + if self.detail.take().is_none() { + return false; + } + self.revision += 1; + true + } +} + +static SIDECAR_FAILURE: Lazy> = Lazy::new(Mutex::default); + +pub fn sidecar_failure_snapshot() -> SidecarFailureSnapshot { + SIDECAR_FAILURE.lock().clone() +} + +pub fn record_sidecar_failure(detail: String) { + SIDECAR_FAILURE.lock().record(detail); + notify_pending_failures_changed(); +} + +pub fn retire_sidecar_failure() { + let changed = SIDECAR_FAILURE.lock().recover(); + if changed { + notify_pending_failures_changed(); + } +} + pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into) { PENDING_FAILURES.record(operation, code, detail.into()); notify_pending_failures_changed(); diff --git a/src-tauri/src/core/owner_identity.rs b/src-tauri/src/core/owner_identity.rs index 798abe9d6..b730fabbe 100644 --- a/src-tauri/src/core/owner_identity.rs +++ b/src-tauri/src/core/owner_identity.rs @@ -63,8 +63,8 @@ pub(crate) fn open_private_current_user_file(path: &Path) -> Result Result { - windows_owner::open_or_create_private_file(path) +pub(crate) fn open_or_create_current_user_lock(path: &Path) -> Result { + windows_owner::open_or_create_lock_file(path) } /// Repairs an application data root Windows no longer reports as owned by the current user. @@ -110,7 +110,7 @@ mod windows_owner { BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK, - GetFileInformationByHandle, GetFileType, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER, + GetFileInformationByHandle, GetFileType, OPEN_ALWAYS, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER, }; use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; @@ -215,22 +215,49 @@ mod windows_owner { Ok(file) } - pub(super) fn open_or_create_private_file(path: &Path) -> Result { + pub(super) fn open_or_create_lock_file(path: &Path) -> Result { let sid = current_sid()?; let descriptor = LocalSecurityDescriptor::from_sid(&sid)?; - match create_file(path, &descriptor) { - Ok(file) => Ok(file), - Err(error) - if error - .downcast_ref::() - .is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) => - { - let file = open_file(path)?; - validate_private_file(&file, descriptor.owner()?)?; - Ok(file) - } - Err(error) => Err(error), + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let wide = wide_path(path)?; + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | READ_CONTROL | WRITE_DAC, + // Replacing a live lock would let a second instance lock a different file. + FILE_SHARE_READ | FILE_SHARE_WRITE, + &attributes, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()).context("failed to open current-user lock file"); } + let file = unsafe { std::fs::File::from_raw_handle(handle) }; + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 { + return Err(std::io::Error::last_os_error()).context("failed to inspect current-user lock file"); + } + if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0 + || unsafe { GetFileType(file.as_raw_handle()) } != FILE_TYPE_DISK + || information.nNumberOfLinks != 1 + { + bail!("current-user lock is not an ordinary, single-link file"); + } + let expected_owner = descriptor.owner()?; + if !owner_matches(&file, expected_owner)? { + bail!("current-user lock belongs to a different Windows user"); + } + // Lock contents are never trusted; records and tokens retain their separate validation. + descriptor.apply_dacl(file.as_raw_handle())?; + validate_private_file(&file, expected_owner)?; + Ok(file) } fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result { @@ -442,7 +469,8 @@ mod windows_owner { { continue; } - // A DACL that refuses even the owner read settles it just as well. + // Failed inspection does not prove that a live lock belongs to another user. + Err(error) if name == crate::utils::server::INSTANCE_LOCK_FILE => return Err(error), Err(_) => false, }; if !ours { @@ -712,7 +740,7 @@ mod windows_owner { ) }; if status != 0 { - bail!("failed to restrict owner token DACL: Windows error {status}"); + bail!("failed to restrict current-user file DACL: Windows error {status}"); } Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2dd0c4ae1..3dfd7f55f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -146,6 +146,7 @@ mod app_init { cmd::upgrade_clash_core, cmd::get_runtime_state, cmd::get_pending_failures, + cmd::get_sidecar_failure, cmd::entry_lightweight_mode, cmd::install_service, cmd::uninstall_service, diff --git a/src-tauri/src/utils/server.rs b/src-tauri/src/utils/server.rs index 5175a02c5..10efd9d59 100644 --- a/src-tauri/src/utils/server.rs +++ b/src-tauri/src/utils/server.rs @@ -223,15 +223,20 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) { warp::http::StatusCode::SERVICE_UNAVAILABLE, )); } - logging!(info, Type::Window, "检测到从单例模式恢复应用窗口"); - if crate::APP_HANDLE.get().is_some() { - if !lightweight::exit_lightweight_mode().await { - WindowManager::show_main_window().await; - } else { - logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口"); - } - } - Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK)) + // Keep blocking UI calls off HTTP workers. + AsyncHandler::spawn_blocking(|| { + AsyncHandler::block_on(async { + logging!( + info, + Type::Window, + "Restoring the application window from a secondary instance" + ); + if crate::APP_HANDLE.get().is_some() && !lightweight::exit_lightweight_mode().await { + WindowManager::show_main_window().await; + } + }); + }); + Ok(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK)) }); let pac = warp::path!("commands" / "pac").and_then(|| async move { @@ -246,10 +251,7 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) { let verge_config = Config::verge().await; let verge_data = verge_config.data_arc(); let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC); - // Served per browser request, so this stays a configuration read rather than a - // round-trip to the Core. It reads the draft layer, which is only correct because - // whoever stages a listener port closes this endpoint across the change — see - // `MixedPort::desired`. Reaching here at all means the Core is serving. + // Port changes disable PAC, so the draft port avoids per-request Core queries. let pac_port = MixedPort::desired().await; let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}")); Ok::<_, warp::Rejection>( @@ -336,7 +338,7 @@ fn open_instance_lock(path: &Path) -> Result { std::fs::create_dir_all(parent)?; #[cfg(windows)] { - crate::core::owner_identity::open_or_create_private_current_user_file(path) + crate::core::owner_identity::open_or_create_current_user_lock(path) } #[cfg(unix)] @@ -449,188 +451,3 @@ fn write_instance_record(path: &Path, record: &InstanceRecord) -> Result<()> { std::fs::rename(&temporary, path).context("failed to replace singleton record")?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::{ - InstanceRecord, PAC_INITIAL_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record, - }; - #[cfg(unix)] - use super::{open_instance_lock, try_lock_instance}; - - #[cfg(feature = "verge-dev")] - use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch}; - #[cfg(feature = "verge-dev")] - use std::sync::atomic::Ordering; - #[cfg(feature = "verge-dev")] - use std::sync::{ - Arc, - atomic::{AtomicBool, AtomicUsize}, - }; - #[test] - fn pac_is_fail_closed_before_core_readiness() { - // Asserts the default rather than the live flag: PAC availability is now derived from - // the Running Mode by `core::runstate`, so any test that drives a Core transition - // legitimately moves the live flag. That the derivation itself can never disagree with - // the Running Mode is covered in `core::runstate` against a fake environment. - const { assert!(!PAC_INITIAL_AVAILABLE) } - } - - #[cfg(feature = "verge-dev")] - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn dev_quit_route_requires_private_auth_ready_state_and_dispatches_once() -> anyhow::Result<()> { - static READY: AtomicBool = AtomicBool::new(false); - static REQUESTED: AtomicBool = AtomicBool::new(false); - READY.store(false, Ordering::Release); - REQUESTED.store(false, Ordering::Release); - let dispatches = Arc::new(AtomicUsize::new(0)); - let dispatches_for_route = Arc::clone(&dispatches); - let token = "ab".repeat(32); - let route = dev_quit_route(token.clone(), &READY, &REQUESTED, move || { - dispatches_for_route.fetch_add(1, Ordering::AcqRel); - }); - - let missing = warp::test::request() - .method("POST") - .path("/commands/dev/quit") - .reply(&route) - .await; - assert_eq!(missing.status(), warp::http::StatusCode::NOT_FOUND); - - let wrong_token = warp::test::request() - .method("POST") - .path("/commands/dev/quit") - .header(INSTANCE_TOKEN_HEADER, "cd".repeat(32)) - .reply(&route) - .await; - assert_eq!(wrong_token.status(), warp::http::StatusCode::NOT_FOUND); - - let not_ready = warp::test::request() - .method("POST") - .path("/commands/dev/quit") - .header(INSTANCE_TOKEN_HEADER, &token) - .reply(&route) - .await; - assert_eq!(not_ready.status(), warp::http::StatusCode::SERVICE_UNAVAILABLE); - assert!(!REQUESTED.load(Ordering::Acquire)); - - let wrong_method = warp::test::request() - .method("GET") - .path("/commands/dev/quit") - .header(INSTANCE_TOKEN_HEADER, &token) - .reply(&route) - .await; - assert_eq!(wrong_method.status(), warp::http::StatusCode::METHOD_NOT_ALLOWED); - assert!(!REQUESTED.load(Ordering::Acquire)); - - READY.store(true, Ordering::Release); - let barrier = Arc::new(tokio::sync::Barrier::new(8)); - let accepted = futures::future::join_all((0..8).map(|_| { - let barrier = Arc::clone(&barrier); - let route = route.clone(); - let token = token.clone(); - tokio::spawn(async move { - barrier.wait().await; - warp::test::request() - .method("POST") - .path("/commands/dev/quit") - .header(INSTANCE_TOKEN_HEADER, token) - .reply(&route) - .await - }) - })) - .await; - for accepted in accepted { - let accepted = accepted?; - assert_eq!(accepted.status(), warp::http::StatusCode::ACCEPTED); - } - assert_eq!(dispatches.load(Ordering::Acquire), 1); - - release_dev_quit_latch(&REQUESTED, clash_verge_signal::ShutdownOutcome::Canceled); - assert!(!REQUESTED.load(Ordering::Acquire)); - let retry = warp::test::request() - .method("POST") - .path("/commands/dev/quit") - .header(INSTANCE_TOKEN_HEADER, &token) - .reply(&route) - .await; - assert_eq!(retry.status(), warp::http::StatusCode::ACCEPTED); - assert_eq!(dispatches.load(Ordering::Acquire), 2); - Ok(()) - } - - #[tokio::test] - async fn retained_record_supplies_the_next_primarys_preferred_port() -> anyhow::Result<()> { - let root = std::env::temp_dir().join(format!("singleton-sticky-port-{}", std::process::id())); - let path = root.join("instance.json"); - let reservation = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; - let preferred_port = reservation.local_addr()?.port(); - drop(reservation); - write_instance_record( - &path, - &InstanceRecord { - port: preferred_port, - token: "stale-token-is-not-authority".to_string(), - }, - )?; - - let hint = read_instance_record(&path).ok().map(|record| record.port); - let listener = bind_primary_listener(hint).await?; - assert_eq!(listener.local_addr()?.port(), preferred_port); - drop(listener); - super::shutdown_embedded_server(); - assert!(path.is_file(), "clean shutdown must retain the private port hint"); - - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[tokio::test] - async fn occupied_preferred_port_falls_back_without_using_stale_authority() -> anyhow::Result<()> { - let occupied = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; - let preferred_port = occupied.local_addr()?.port(); - - let fallback = bind_primary_listener(Some(preferred_port)).await?; - assert_ne!(fallback.local_addr()?.port(), preferred_port); - assert!(fallback.local_addr()?.ip().is_loopback()); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn instance_record_is_private_and_round_trips() -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt as _; - - let root = std::env::temp_dir().join(format!("singleton-record-{}", std::process::id())); - let path = root.join("instance.json"); - let record = InstanceRecord { - port: 42_123, - token: "secret".to_string(), - }; - - write_instance_record(&path, &record)?; - - assert_eq!(read_instance_record(&path)?, record); - assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o600); - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[cfg(unix)] - #[test] - fn instance_lock_allows_only_one_primary() -> anyhow::Result<()> { - let root = std::env::temp_dir().join(format!("singleton-lock-{}", std::process::id())); - let path = root.join("instance.lock"); - let first = open_instance_lock(&path)?; - let second = open_instance_lock(&path)?; - - assert!(try_lock_instance(&first)?); - assert!(!try_lock_instance(&second)?); - drop(first); - assert!(try_lock_instance(&second)?); - - drop(second); - std::fs::remove_dir_all(root)?; - Ok(()) - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 763b5163a..2231069d5 100755 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,5 +1,5 @@ { - "version": "2.5.7", + "version": "2.5.8", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "bundle": { "active": true, diff --git a/src/components/layout/notice-manager.tsx b/src/components/layout/notice-manager.tsx index 4e2140d61..c70bd0838 100644 --- a/src/components/layout/notice-manager.tsx +++ b/src/components/layout/notice-manager.tsx @@ -68,6 +68,66 @@ const resolveNoticeMessage = ( const i18n = notice.i18n if (!i18n) return bound(notice.message) + const detail = i18n.params?.message + const existingCore = + notice.code === 'SERVICE_SIDECAR_FAILED' && typeof detail === 'string' + ? /process verge-mihomo(?:-alpha)?(?:\.exe)? \(PID (\d+)\) is still running; refusing a second core\s*$/.exec( + detail, + ) + : null + if (existingCore && typeof detail === 'string') { + const permissionRejected = detail.startsWith('Service core rejected:') + return ( + <> + {permissionRejected && ( + + {t( + 'settings.feedback.notifications.clashService.permissionRejectedReason', + )}{' '} + { + event.preventDefault() + void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch( + showNotice.error, + ) + }} + > + {t( + 'settings.feedback.notifications.clashService.permissionRepairGuide', + )} + + + )} + {t('settings.feedback.errors.clashService.coreAlreadyRunning', { + pid: existingCore[1], + })} + + + {t('shared.feedback.errors.details')} + + + {detail} + + + + ) + } + if (i18n.key === SERVICE_PERMISSION_NOTICE) { return ( <> diff --git a/src/components/layout/traffic-graph.tsx b/src/components/layout/traffic-graph.tsx index 2b4572db7..f6893003b 100644 --- a/src/components/layout/traffic-graph.tsx +++ b/src/components/layout/traffic-graph.tsx @@ -13,7 +13,6 @@ const upLineWidth = 4 const downLineAlpha = 1 const downLineWidth = 4 const sampleIntervalMs = 1000 -const frameIntervalMs = 1000 / 15 const animationDurationMs = sampleIntervalMs const zeroTraffic: Traffic = { up: 0, down: 0, upTotal: 0, downTotal: 0 } @@ -36,7 +35,7 @@ type TrafficValueKey = 'up' | 'down' * draw the traffic graph */ export function TrafficGraph({ ref }: { ref?: Ref }) { - const countRef = useRef(0) + const animationStartRef = useRef(null) const styleRef = useRef(true) const listRef = useRef(createDefaultList()) const canvasRef = useRef(null!) @@ -69,7 +68,6 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { if (shouldAppend) { if (list.length > maxPoint + 2) list.shift() list.push(data) - countRef.current = 0 requestDrawRef.current(true) } @@ -84,11 +82,8 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { }, []) useEffect(() => { - let raf = 0 - let frameTimer: ReturnType | null = null + let animation: Animation | null = null let resizeObserver: ResizeObserver | null = null - let animationStart = 0 - let lastFrameTime = 0 const canvas = canvasRef.current! if (!canvas) return @@ -102,25 +97,13 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { const upLineColor = secondary.main || '#9c27b0' const downLineColor = primary.main || '#5b5c9d' - const cancelPendingDraw = () => { - if (frameTimer !== null) { - clearTimeout(frameTimer) - frameTimer = null - } - - if (raf) { - cancelAnimationFrame(raf) - raf = 0 - } - } - - const drawGraph = (offset = countRef.current) => { + const drawGraph = () => { const list = listRef.current const lineStyle = styleRef.current const width = canvas.width const height = canvas.height - const dx = width / maxPoint + const dx = width / (maxPoint + 1) const dy = height / 7 const l1 = dy const l2 = dy * 4 @@ -142,17 +125,17 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { const drawBezier = (list: Traffic[], valueKey: TrafficValueKey) => { if (list.length === 0) return - const firstX = (dx * -1 - offset + 3) | 0 + const firstX = (dx * -1 + 3) | 0 const firstY = countY(list[0]?.[valueKey] ?? 0) context.moveTo(firstX, firstY) for (let i = 1; i < list.length; i++) { - const p1x = (dx * (i - 1) - offset + 3) | 0 + const p1x = (dx * (i - 1) + 3) | 0 const p1y = countY(list[i]?.[valueKey] ?? 0) const hasNext = i + 1 < list.length - const p2x = hasNext ? (dx * i - offset + 3) | 0 : p1x + const p2x = hasNext ? (dx * i + 3) | 0 : p1x const p2y = hasNext ? countY(list[i + 1]?.[valueKey] ?? 0) : p1y context.quadraticCurveTo(p1x, p1y, (p1x + p2x) / 2, (p1y + p2y) / 2) @@ -162,13 +145,10 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { const drawLine = (list: Traffic[], valueKey: TrafficValueKey) => { if (list.length === 0) return - context.moveTo((dx * -1 - offset) | 0, countY(list[0]?.[valueKey] ?? 0)) + context.moveTo((dx * -1) | 0, countY(list[0]?.[valueKey] ?? 0)) for (let i = 1; i < list.length; i++) { - context.lineTo( - (dx * (i - 1) - offset) | 0, - countY(list[i]?.[valueKey] ?? 0), - ) + context.lineTo((dx * (i - 1)) | 0, countY(list[i]?.[valueKey] ?? 0)) } } @@ -211,51 +191,28 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { context.closePath() } - const drawAnimatedFrame = (timestamp: number) => { - raf = 0 - - const timeSinceLastFrame = timestamp - lastFrameTime - if (timeSinceLastFrame < frameIntervalMs) { - frameTimer = setTimeout(() => { - frameTimer = null - raf = requestAnimationFrame(drawAnimatedFrame) - }, frameIntervalMs - timeSinceLastFrame) - return - } - - lastFrameTime = timestamp - - const dx = canvas.width / maxPoint - const progress = Math.min( - (timestamp - animationStart) / animationDurationMs, - 1, - ) - const offset = progress * dx - countRef.current = offset - drawGraph(offset) - - if (progress < 1) { - raf = requestAnimationFrame(drawAnimatedFrame) - return - } - - countRef.current = dx - } - const requestDraw = (animate = false) => { - cancelPendingDraw() + if (animate) animationStartRef.current = performance.now() + animation?.cancel() + drawGraph() - if (!animate) { - raf = requestAnimationFrame(() => { - raf = 0 - drawGraph() - }) - return - } - - animationStart = performance.now() - lastFrameTime = animationStart - frameIntervalMs - raf = requestAnimationFrame(drawAnimatedFrame) + const elapsed = + animationStartRef.current === null + ? 0 + : Math.min( + performance.now() - animationStartRef.current, + animationDurationMs, + ) + const step = canvas.getBoundingClientRect().width / (maxPoint + 1) + animation = canvas.animate( + [ + { transform: 'translateX(0)' }, + { transform: `translateX(${-step}px)` }, + ], + { duration: animationDurationMs, easing: 'linear', fill: 'forwards' }, + ) + animation.currentTime = elapsed + if (animationStartRef.current === null) animation.pause() } requestDrawRef.current = requestDraw @@ -263,7 +220,7 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver(() => requestDraw(false)) - resizeObserver.observe(canvas) + resizeObserver.observe(canvas.parentElement!) } return () => { @@ -271,9 +228,20 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { requestDrawRef.current = () => {} } resizeObserver?.disconnect() - cancelPendingDraw() + animation?.cancel() } }, [palette]) - return + return ( +
+ +
+ ) } diff --git a/src/components/setting/mods/tun-viewer.tsx b/src/components/setting/mods/tun-viewer.tsx index 9abe92175..b00355162 100644 --- a/src/components/setting/mods/tun-viewer.tsx +++ b/src/components/setting/mods/tun-viewer.tsx @@ -43,7 +43,7 @@ export function TunViewer({ ref }: { ref?: Ref }) { const [open, setOpen] = useState(false) const [values, setValues] = useState({ - stack: 'mixed', + stack: 'mips', device: OS === 'macos' ? 'utun1024' : 'Mihomo', autoRoute: true, routeExcludeAddress: '', @@ -73,7 +73,7 @@ export function TunViewer({ ref }: { ref?: Ref }) { const computedAutoRedirect = OS === 'linux' ? (nextAutoRoute ? rawAutoRedirect : false) : false setValues({ - stack: clash?.tun.stack ?? 'gvisor', + stack: clash?.tun.stack ?? 'mips', device: clash?.tun.device ?? (OS === 'macos' ? 'utun1024' : 'Mihomo'), autoRoute: nextAutoRoute, routeExcludeAddress: (clash?.tun['route-exclude-address'] ?? []).join( @@ -152,7 +152,7 @@ export function TunViewer({ ref }: { ref?: Ref }) { size="small" onClick={async () => { const tun: IConfigData['tun'] = { - stack: 'gvisor', + stack: 'mips', device: OS === 'macos' ? 'utun1024' : 'Mihomo', 'auto-route': true, ...(OS === 'linux' @@ -167,7 +167,7 @@ export function TunViewer({ ref }: { ref?: Ref }) { mtu: 1500, } setValues({ - stack: 'gvisor', + stack: 'mips', device: OS === 'macos' ? 'utun1024' : 'Mihomo', autoRoute: true, routeExcludeAddress: '', diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 7816657b3..456b10271 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "تعذّر إلغاء تثبيت الخدمة", "reinstallFailed": "تعذّر إعادة تثبيت الخدمة", "repairFailed": "تعذّر إصلاح الخدمة", - "sidecarFailed": "تعذّر المتابعة باستخدام Sidecar" + "sidecarFailed": "تعذّر المتابعة باستخدام Sidecar", + "coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة." }, "sysproxy": { "privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 500d5e00a..0498d2d0e 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Dienst konnte nicht deinstalliert werden", "reinstallFailed": "Dienst konnte nicht neu installiert werden", "repairFailed": "Dienst konnte nicht repariert werden", - "sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen" + "sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen", + "coreAlreadyRunning": "Der Sidecar-Modus konnte nicht gestartet werden: Ein anderer Kern läuft noch (PID {{pid}}). Beenden Sie andere Clash-Verge-Instanzen oder die App, die diesen Kern verwendet, und versuchen Sie es erneut." }, "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.", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 7b1da70e9..c614ef0b7 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Failed to uninstall the service", "reinstallFailed": "Failed to reinstall the service", "repairFailed": "Failed to repair the service", - "sidecarFailed": "Failed to continue with Sidecar" + "sidecarFailed": "Failed to continue with Sidecar", + "coreAlreadyRunning": "Could not start normal mode (Sidecar): another core is still running (PID {{pid}}). Exit other Clash Verge instances or the app using this core, then retry." }, "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.", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 61116797c..2a3ba8c6b 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "No se pudo desinstalar el servicio", "reinstallFailed": "No se pudo reinstalar el servicio", "repairFailed": "No se pudo reparar el servicio", - "sidecarFailed": "No se pudo continuar con Sidecar" + "sidecarFailed": "No se pudo continuar con Sidecar", + "coreAlreadyRunning": "No se pudo iniciar el modo Sidecar: hay otro núcleo en ejecución (PID {{pid}}). Cierre las otras instancias de Clash Verge o la aplicación que usa ese núcleo y vuelva a intentarlo." }, "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.", diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 7f301933e..3332e07bd 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "حذف سرویس ناموفق بود", "reinstallFailed": "نصب مجدد سرویس ناموفق بود", "repairFailed": "تعمیر سرویس ناموفق بود", - "sidecarFailed": "ادامه با Sidecar ناموفق بود" + "sidecarFailed": "ادامه با Sidecar ناموفق بود", + "coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونه‌های دیگر Clash Verge یا برنامه‌ای که از این هسته استفاده می‌کند را ببندید و دوباره تلاش کنید." }, "sysproxy": { "privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 2543b6711..d523c89c4 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Gagal mencopot layanan", "reinstallFailed": "Gagal memasang ulang layanan", "repairFailed": "Gagal memperbaiki layanan", - "sidecarFailed": "Gagal melanjutkan dengan Sidecar" + "sidecarFailed": "Gagal melanjutkan dengan Sidecar", + "coreAlreadyRunning": "Tidak dapat memulai mode Sidecar: inti lain masih berjalan (PID {{pid}}). Tutup instans Clash Verge lain atau aplikasi yang menggunakan inti tersebut, lalu coba lagi." }, "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.", diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 85233efce..1259f1409 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "サービスのアンインストールに失敗しました", "reinstallFailed": "サービスの再インストールに失敗しました", "repairFailed": "サービスの修復に失敗しました", - "sidecarFailed": "Sidecar での続行に失敗しました" + "sidecarFailed": "Sidecar での続行に失敗しました", + "coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。" }, "sysproxy": { "privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index aec37f65a..9c60c69db 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "서비스를 제거하지 못했습니다", "reinstallFailed": "서비스를 다시 설치하지 못했습니다", "repairFailed": "서비스를 복구하지 못했습니다", - "sidecarFailed": "Sidecar로 계속하지 못했습니다" + "sidecarFailed": "Sidecar로 계속하지 못했습니다", + "coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요." }, "sysproxy": { "privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index f567ec32e..6111fd6a9 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Не удалось удалить службу", "reinstallFailed": "Не удалось переустановить службу", "repairFailed": "Не удалось восстановить службу", - "sidecarFailed": "Не удалось продолжить в режиме Sidecar" + "sidecarFailed": "Не удалось продолжить в режиме Sidecar", + "coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку." }, "sysproxy": { "privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 4c7e9dff3..958cd04a4 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Hizmet kaldırılamadı", "reinstallFailed": "Hizmet yeniden kurulamadı", "repairFailed": "Hizmet onarılamadı", - "sidecarFailed": "Sidecar ile devam edilemedi" + "sidecarFailed": "Sidecar ile devam edilemedi", + "coreAlreadyRunning": "Sidecar modu başlatılamadı: başka bir çekirdek hâlâ çalışıyor (PID {{pid}}). Diğer Clash Verge örneklerini veya bu çekirdeği kullanan uygulamayı kapatıp yeniden deneyin." }, "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.", diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index effbd7596..1bb591f7c 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Хезмәтне бетереп булмады", "reinstallFailed": "Хезмәтне яңадан урнаштырып булмады", "repairFailed": "Хезмәтне төзәтеп булмады", - "sidecarFailed": "Sidecar белән дәвам итеп булмады" + "sidecarFailed": "Sidecar белән дәвам итеп булмады", + "coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз." }, "sysproxy": { "privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 792b463df..7316ef9b7 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "卸载服务失败", "reinstallFailed": "重新安装服务失败", "repairFailed": "修复服务失败", - "sidecarFailed": "继续使用 Sidecar 失败" + "sidecarFailed": "继续使用 Sidecar 失败", + "coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。" }, "sysproxy": { "privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index a942e67fa..92fe5138f 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "解除安裝服務失敗", "reinstallFailed": "重新安裝服務失敗", "repairFailed": "修復服務失敗", - "sidecarFailed": "繼續使用 Sidecar 失敗" + "sidecarFailed": "繼續使用 Sidecar 失敗", + "coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。" }, "sysproxy": { "privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。", diff --git a/src/pages/_layout/hooks/use-pending-failures.ts b/src/pages/_layout/hooks/use-pending-failures.ts index 5ef98b2d9..7797aa556 100644 --- a/src/pages/_layout/hooks/use-pending-failures.ts +++ b/src/pages/_layout/hooks/use-pending-failures.ts @@ -2,8 +2,11 @@ import { getCurrentWindow } from '@tauri-apps/api/window' import { useCallback, useEffect, useRef, useState } from 'react' import type { PendingFailure } from '@/services/cmds' -import { showNotice } from '@/services/notice-service' -import { usePendingFailureList } from '@/store/app-store-context' +import { showNotice, syncSidecarFailure } from '@/services/notice-service' +import { + usePendingFailureList, + useSidecarFailure, +} from '@/store/app-store-context' /** Failures handled by the recovery dialog instead of a toast. */ const CODES_SHOWN_AS_A_DIALOG = new Set([ @@ -29,8 +32,13 @@ const windowIsWatched = async () => { /** Show each pending failure sequence once when the window can be read. */ export const usePendingFailures = () => { const failures = usePendingFailureList() + const sidecarFailure = useSidecarFailure() const shownSequencesRef = useRef(new Map()) + useEffect(() => { + if (sidecarFailure) syncSidecarFailure(sidecarFailure) + }, [sidecarFailure]) + useEffect(() => { const asToast = failures.filter( (failure) => !CODES_SHOWN_AS_A_DIALOG.has(failure.code), diff --git a/src/services/bus/use-event-bus.test.ts b/src/services/bus/use-event-bus.test.ts index c76742258..34324b3e7 100644 --- a/src/services/bus/use-event-bus.test.ts +++ b/src/services/bus/use-event-bus.test.ts @@ -28,6 +28,7 @@ vi.mock('@/store/app-store-context', () => ({ })) vi.mock('@/services/cmds', () => ({ getPendingFailures: vi.fn().mockResolvedValue([]), + getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }), getRuntimeState: vi.fn().mockResolvedValue({ mode: 'NotRunning' }), })) vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) @@ -86,4 +87,8 @@ it('drains event-only state only after every listener is registered', async () = type: 'pendingFailures/loaded', failures: [], }) + expect(dispatch).toHaveBeenCalledWith({ + type: 'sidecarFailure/loaded', + snapshot: { revision: 0, detail: null }, + }) }) diff --git a/src/services/bus/use-event-bus.ts b/src/services/bus/use-event-bus.ts index a9cecc202..4a3fe2bb0 100644 --- a/src/services/bus/use-event-bus.ts +++ b/src/services/bus/use-event-bus.ts @@ -2,7 +2,11 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { useEffect } from 'react' -import { getPendingFailures, getRuntimeState } from '@/services/cmds' +import { + getPendingFailures, + getRuntimeState, + getSidecarFailure, +} from '@/services/cmds' import { revalidateQueries } from '@/services/query-client' import { useAppDispatch } from '@/store/app-store-context' @@ -23,6 +27,13 @@ export const useEventBus = ( void revalidateQueries(keys.map((key) => [key])) } const readPendingFailures = () => { + getSidecarFailure() + .then((snapshot) => + dispatch({ type: 'sidecarFailure/loaded', snapshot }), + ) + .catch((error) => { + console.warn('[bus] Sidecar failure could not be read:', error) + }) getPendingFailures() .then((failures) => dispatch({ type: 'pendingFailures/loaded', failures }), diff --git a/src/services/cmds.ts b/src/services/cmds.ts index d35e6619b..ed06f09d4 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -483,6 +483,15 @@ export const getPendingFailures = async () => { return invoke('get_pending_failures') } +export interface SidecarFailureSnapshot { + revision: number + detail: string | null +} + +export const getSidecarFailure = async () => { + return invoke('get_sidecar_failure') +} + export const getAppUptime = async () => { return invoke('get_app_uptime') } diff --git a/src/services/notice-service.ts b/src/services/notice-service.ts index 5edecfda2..34157a1ea 100644 --- a/src/services/notice-service.ts +++ b/src/services/notice-service.ts @@ -1,7 +1,7 @@ import i18n from 'i18next' import { ReactNode, isValidElement } from 'react' -import type { FailedOperation } from '@/services/cmds' +import type { FailedOperation, SidecarFailureSnapshot } from '@/services/cmds' import type { TranslationKey } from '@/types/generated/i18n-keys' type NoticeType = 'success' | 'error' | 'info' | 'warning' @@ -97,6 +97,7 @@ const CODED_ERROR_TRANSLATION_KEYS: Readonly> = { let nextId = 0 let notices: NoticeItem[] = [] +let sidecarFailureRevision = -1 const subscribers: Set = new Set() function notifySubscribers() { @@ -461,7 +462,8 @@ const baseShowNotice = ( 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 + // Sidecar failures come only from versioned backend state, never delayed command rejections. + if (isReportedByDialog(code) || code === 'SERVICE_SIDECAR_FAILED') return id const effectiveDuration = resolveDuration(type, duration) const timerId = effectiveDuration > 0 @@ -524,6 +526,29 @@ export function hideNotice(id: number) { notifySubscribers() } +function hideNoticesByCode(code: string) { + notices + .filter((notice) => notice.code === code) + .forEach(({ id }) => hideNotice(id)) +} + +export function syncSidecarFailure(snapshot: SidecarFailureSnapshot) { + if (snapshot.revision <= sidecarFailureRevision) return + sidecarFailureRevision = snapshot.revision + hideNoticesByCode('SERVICE_SIDECAR_FAILED') + if (snapshot.detail === null) return + + const failure = { code: 'SERVICE_SIDECAR_FAILED', detail: snapshot.detail } + notices = [ + ...notices, + buildNotice(nextId++, 'error', 0, { + ...normalizeNoticeMessage(failure), + code: failure.code, + }), + ] + notifySubscribers() +} + export function subscribeNotices(subscriber: NoticeSubscriber) { subscribers.add(subscriber) return () => { diff --git a/src/store/app-state.ts b/src/store/app-state.ts index e436f29ea..8493c7769 100644 --- a/src/store/app-state.ts +++ b/src/store/app-state.ts @@ -1,4 +1,8 @@ -import type { PendingFailure, RunState } from '@/services/cmds' +import type { + PendingFailure, + RunState, + SidecarFailureSnapshot, +} from '@/services/cmds' /** Event-derived per-profile update tracking, shared by the profiles page and items. */ export interface ProfileUpdatesState { @@ -12,6 +16,7 @@ export interface AppStoreState { readonly runState: RunState | null /** Latest backend pending-failure snapshot; toasts/dialogs dedupe on sequence. */ readonly pendingFailures: readonly PendingFailure[] + readonly sidecarFailure: SidecarFailureSnapshot | null readonly profileUpdates: ProfileUpdatesState /** Bumped once per test-all request; test items run their delay test per bump. */ readonly testAllCounter: number @@ -22,6 +27,7 @@ export interface AppStoreState { export const initialAppStoreState: AppStoreState = { runState: null, pendingFailures: [], + sidecarFailure: null, profileUpdates: { loading: new Set(), updateRevisions: new Map(), @@ -34,6 +40,7 @@ export const initialAppStoreState: AppStoreState = { export type AppStoreAction = | { type: 'runState/loaded'; runState: RunState } | { type: 'pendingFailures/loaded'; failures: readonly PendingFailure[] } + | { type: 'sidecarFailure/loaded'; snapshot: SidecarFailureSnapshot } | { type: 'profileUpdate/started'; uid: string } | { type: 'profileUpdate/completed'; uid: string } | { type: 'profileUpdate/timerTick'; uid: string } @@ -79,6 +86,14 @@ export function appStoreReducer( return { ...state, runState: action.runState } case 'pendingFailures/loaded': return { ...state, pendingFailures: action.failures } + case 'sidecarFailure/loaded': + if ( + state.sidecarFailure && + action.snapshot.revision <= state.sidecarFailure.revision + ) { + return state + } + return { ...state, sidecarFailure: action.snapshot } case 'profileUpdate/started': if (state.profileUpdates.loading.has(action.uid)) return state return withLoading(state, [action.uid], true) diff --git a/src/store/app-store-context.ts b/src/store/app-store-context.ts index 3217d0037..3e9ae1f72 100644 --- a/src/store/app-store-context.ts +++ b/src/store/app-store-context.ts @@ -28,6 +28,8 @@ export const useRunState = () => useSelector((s) => s.runState) export const usePendingFailureList = () => useSelector((s) => s.pendingFailures) +export const useSidecarFailure = () => useSelector((s) => s.sidecarFailure) + export const useProfileUpdates = (): ProfileUpdatesState => useSelector((s) => s.profileUpdates) diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx index 8bf360d2d..b053b910e 100644 --- a/src/store/app-store.tsx +++ b/src/store/app-store.tsx @@ -1,7 +1,11 @@ import { useEffect, useReducer } from 'react' import type { ReactNode } from 'react' -import { getPendingFailures, getRuntimeState } from '@/services/cmds' +import { + getPendingFailures, + getRuntimeState, + getSidecarFailure, +} from '@/services/cmds' import { bindStoreDispatch } from '@/services/mutate' import { appStoreReducer, initialAppStoreState } from './app-state' @@ -28,6 +32,13 @@ export const AppStoreProvider = ({ children }: { children?: ReactNode }) => { .catch(() => {}) } const readPendingFailures = () => { + getSidecarFailure() + .then((snapshot) => + dispatch({ type: 'sidecarFailure/loaded', snapshot }), + ) + .catch((error) => { + console.warn('[app-store] Sidecar failure could not be read:', error) + }) getPendingFailures() .then((failures) => dispatch({ type: 'pendingFailures/loaded', failures }), diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 27f43316e..7f230c3d5 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -792,6 +792,7 @@ export const translationKeys = [ 'settings.feedback.errors.clashService.reinstallFailed', 'settings.feedback.errors.clashService.repairFailed', 'settings.feedback.errors.clashService.sidecarFailed', + 'settings.feedback.errors.clashService.coreAlreadyRunning', 'settings.feedback.errors.sysproxy.privilegeRequired', 'settings.feedback.errors.sysproxy.directFallback', 'settings.feedback.errors.sysproxy.guardStopped', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 5959514f0..5cd65f988 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -820,6 +820,7 @@ export interface TranslationResources { stopFailed: string } clashService: { + coreAlreadyRunning: string installFailed: string reinstallFailed: string repairFailed: string