chore: merge sync downgrade into once cell cleanup

This commit is contained in:
Tunglies
2026-10-03 18:48:11 +08:00
48 changed files with 497 additions and 420 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
Generated
+3 -3
View File
@@ -1079,7 +1079,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.5.7"
version = "2.5.8"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1222,8 +1222,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",
+9 -9
View File
@@ -1,29 +1,29 @@
## v2.5.7
## v2.5.8
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
**🖥️ Windows**
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
**🍎 macOS**
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:显示具体原因,并提供修复文档链接
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
</details>
+37
View File
@@ -1,3 +1,40 @@
## v2.5.7
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
**🖥️ Windows**
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
**🍎 macOS**
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
</details>
## v2.5.6
<details>
+3 -3
View File
@@ -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)
+3 -3
View File
@@ -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)
+3 -3
View File
@@ -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)
+3 -3
View File
@@ -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)
+3 -3
View File
@@ -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)
+3 -3
View File
@@ -47,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
## Продвижение
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
- Команда за рубежом, без риска побега, до 50% кэшбэка
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
- Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.7",
"version": "2.5.8",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.7"
version = "2.5.8"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -98,7 +98,7 @@ tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.1", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.5", version = "2.7.5", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client",
] }
arc-swap = "1.9.2"
+15 -11
View File
@@ -34,7 +34,10 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str)
SERVICE_MANAGER.handle_service_status(status).await
};
#[cfg(windows)]
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
return finish_service_installation(result, error_code, |reason| async move {
manager.continue_with_sidecar(Some(&reason)).await
})
.await;
#[cfg(not(windows))]
result
@@ -49,7 +52,7 @@ async fn finish_service_installation<Fallback, FallbackFuture>(
fallback: Fallback,
) -> CmdResult<ServiceInstallOutcome>
where
Fallback: FnOnce() -> FallbackFuture,
Fallback: FnOnce(String) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
{
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<ServiceInstallOutcome> {
#[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::ServiceFallbackNotice> {
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"));
}
}
+1 -1
View File
@@ -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"];
+74 -53
View File
@@ -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(())
}
+40
View File
@@ -240,6 +240,46 @@ pub fn what_was_asked() -> FailedOperation {
static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::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<String>,
}
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: LazyLock<Mutex<SidecarFailureSnapshot>> = LazyLock::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<String>) {
PENDING_FAILURES.record(operation, code, detail.into());
notify_pending_failures_changed();
+46 -18
View File
@@ -63,8 +63,8 @@ pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::Fil
}
#[cfg(windows)]
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
windows_owner::open_or_create_private_file(path)
pub(crate) fn open_or_create_current_user_lock(path: &Path) -> Result<std::fs::File> {
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<std::fs::File> {
pub(super) fn open_or_create_lock_file(path: &Path) -> Result<std::fs::File> {
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::<std::io::Error>()
.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::<SECURITY_ATTRIBUTES>() 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<std::fs::File> {
@@ -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(())
}
+1
View File
@@ -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,
+16 -199
View File
@@ -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::File> {
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(())
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.5.7",
"version": "2.5.8",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
+60
View File
@@ -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 && (
<Box sx={{ mb: 1 }}>
{t(
'settings.feedback.notifications.clashService.permissionRejectedReason',
)}{' '}
<Link
href={SERVICE_PERMISSION_GUIDE}
color="inherit"
underline="always"
onClick={(event) => {
event.preventDefault()
void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch(
showNotice.error,
)
}}
>
{t(
'settings.feedback.notifications.clashService.permissionRepairGuide',
)}
</Link>
</Box>
)}
{t('settings.feedback.errors.clashService.coreAlreadyRunning', {
pid: existingCore[1],
})}
<Box component="details" sx={{ mt: 1 }}>
<Box component="summary" sx={{ cursor: 'pointer' }}>
{t('shared.feedback.errors.details')}
</Box>
<Box
component="pre"
sx={{
m: 0,
mt: 1,
maxHeight: 160,
overflow: 'auto',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
userSelect: 'text',
fontSize: 'inherit',
}}
>
{detail}
</Box>
</Box>
</>
)
}
if (i18n.key === SERVICE_PERMISSION_NOTICE) {
return (
<>
+43 -75
View File
@@ -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<TrafficRef> }) {
const countRef = useRef(0)
const animationStartRef = useRef<number | null>(null)
const styleRef = useRef(true)
const listRef = useRef<Traffic[]>(createDefaultList())
const canvasRef = useRef<HTMLCanvasElement>(null!)
@@ -69,7 +68,6 @@ export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) {
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<TrafficRef> }) {
}, [])
useEffect(() => {
let raf = 0
let frameTimer: ReturnType<typeof setTimeout> | 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<TrafficRef> }) {
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<TrafficRef> }) {
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<TrafficRef> }) {
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<TrafficRef> }) {
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<TrafficRef> }) {
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<TrafficRef> }) {
requestDrawRef.current = () => {}
}
resizeObserver?.disconnect()
cancelPendingDraw()
animation?.cancel()
}
}, [palette])
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
return (
<div style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
<canvas
ref={canvasRef}
width={310}
style={{
width: `${((maxPoint + 1) / maxPoint) * 100}%`,
height: '100%',
}}
/>
</div>
)
}
+4 -4
View File
@@ -43,7 +43,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
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<DialogRef> }) {
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<DialogRef> }) {
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<DialogRef> }) {
mtu: 1500,
}
setValues({
stack: 'gvisor',
stack: 'mips',
device: OS === 'macos' ? 'utun1024' : 'Mihomo',
autoRoute: true,
routeExcludeAddress: '',
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "تعذّر إلغاء تثبيت الخدمة",
"reinstallFailed": "تعذّر إعادة تثبيت الخدمة",
"repairFailed": "تعذّر إصلاح الخدمة",
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar"
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar",
"coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة."
},
"sysproxy": {
"privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "حذف سرویس ناموفق بود",
"reinstallFailed": "نصب مجدد سرویس ناموفق بود",
"repairFailed": "تعمیر سرویس ناموفق بود",
"sidecarFailed": "ادامه با Sidecar ناموفق بود"
"sidecarFailed": "ادامه با Sidecar ناموفق بود",
"coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونه‌های دیگر Clash Verge یا برنامه‌ای که از این هسته استفاده می‌کند را ببندید و دوباره تلاش کنید."
},
"sysproxy": {
"privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "サービスのアンインストールに失敗しました",
"reinstallFailed": "サービスの再インストールに失敗しました",
"repairFailed": "サービスの修復に失敗しました",
"sidecarFailed": "Sidecar での続行に失敗しました"
"sidecarFailed": "Sidecar での続行に失敗しました",
"coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。"
},
"sysproxy": {
"privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "서비스를 제거하지 못했습니다",
"reinstallFailed": "서비스를 다시 설치하지 못했습니다",
"repairFailed": "서비스를 복구하지 못했습니다",
"sidecarFailed": "Sidecar로 계속하지 못했습니다"
"sidecarFailed": "Sidecar로 계속하지 못했습니다",
"coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요."
},
"sysproxy": {
"privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "Не удалось удалить службу",
"reinstallFailed": "Не удалось переустановить службу",
"repairFailed": "Не удалось восстановить службу",
"sidecarFailed": "Не удалось продолжить в режиме Sidecar"
"sidecarFailed": "Не удалось продолжить в режиме Sidecar",
"coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку."
},
"sysproxy": {
"privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "Хезмәтне бетереп булмады",
"reinstallFailed": "Хезмәтне яңадан урнаштырып булмады",
"repairFailed": "Хезмәтне төзәтеп булмады",
"sidecarFailed": "Sidecar белән дәвам итеп булмады"
"sidecarFailed": "Sidecar белән дәвам итеп булмады",
"coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз."
},
"sysproxy": {
"privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "卸载服务失败",
"reinstallFailed": "重新安装服务失败",
"repairFailed": "修复服务失败",
"sidecarFailed": "继续使用 Sidecar 失败"
"sidecarFailed": "继续使用 Sidecar 失败",
"coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。"
},
"sysproxy": {
"privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "解除安裝服務失敗",
"reinstallFailed": "重新安裝服務失敗",
"repairFailed": "修復服務失敗",
"sidecarFailed": "繼續使用 Sidecar 失敗"
"sidecarFailed": "繼續使用 Sidecar 失敗",
"coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。"
},
"sysproxy": {
"privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。",
@@ -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<string>([
@@ -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<string, number>())
useEffect(() => {
if (sidecarFailure) syncSidecarFailure(sidecarFailure)
}, [sidecarFailure])
useEffect(() => {
const asToast = failures.filter(
(failure) => !CODES_SHOWN_AS_A_DIALOG.has(failure.code),
+5
View File
@@ -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 },
})
})
+12 -1
View File
@@ -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 }),
+9
View File
@@ -483,6 +483,15 @@ export const getPendingFailures = async () => {
return invoke<PendingFailure[]>('get_pending_failures')
}
export interface SidecarFailureSnapshot {
revision: number
detail: string | null
}
export const getSidecarFailure = async () => {
return invoke<SidecarFailureSnapshot>('get_sidecar_failure')
}
export const getAppUptime = async () => {
return invoke<number>('get_app_uptime')
}
+27 -2
View File
@@ -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<Record<string, TranslationKey>> = {
let nextId = 0
let notices: NoticeItem[] = []
let sidecarFailureRevision = -1
const subscribers: Set<NoticeSubscriber> = 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 () => {
+16 -1
View File
@@ -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)
+2
View File
@@ -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)
+12 -1
View File
@@ -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 }),
+1
View File
@@ -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',
+1
View File
@@ -820,6 +820,7 @@ export interface TranslationResources {
stopFailed: string
}
clashService: {
coreAlreadyRunning: string
installFailed: string
reinstallFailed: string
repairFailed: string