From 4a125681bded38cce47a21156a3d2f8792abc7e5 Mon Sep 17 00:00:00 2001 From: wonfen Date: Thu, 8 Oct 2026 11:13:51 +0800 Subject: [PATCH 01/15] fix(windows): pin muda fork to prevent tray menu command ID exhaustion --- Cargo.lock | 3 +-- Cargo.toml | 3 +++ Changelog.md | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 56333e428..ced8f2827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4213,8 +4213,7 @@ dependencies = [ [[package]] name = "muda" version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +source = "git+https://github.com/wonfen/muda.git?rev=547de49352791653fbdfa785f20f3c7ece8468e2#547de49352791653fbdfa785f20f3c7ece8468e2" dependencies = [ "crossbeam-channel", "dpi", diff --git a/Cargo.toml b/Cargo.toml index faa6e77b9..6c5718cbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ codegen-units = 1 split-debuginfo = "unpacked" [patch.crates-io] +# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch. +muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" } + # macOS Golden Gate fix for tray-icon click reentrancy issue # Remove the following patch once the fix is merged upstream(tauri) # https://github.com/tauri-apps/tray-icon/pull/365 diff --git a/Changelog.md b/Changelog.md index f2033f2d2..139b72ea6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -15,6 +15,7 @@ - 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 - 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 +- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题 **🍎 macOS** From 6b4d4e1551310f811c21766db299b077ceb7a1fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Pastorello?= Date: Thu, 8 Oct 2026 01:48:30 -0300 Subject: [PATCH 02/15] feat(i18n): add Brazilian Portuguese locale (#8203) Add the `pt` frontend and backend locale bundles, register the language in the language selector, and add a Portuguese README linked from the other READMEs. Co-authored-by: Nicolas Pastorello Co-authored-by: Claude Sonnet 5.5 --- README.md | 7 +- crates/clash-verge-i18n/locales/pt.yml | 90 +++ docs/README_en.md | 7 +- docs/README_es.md | 7 +- docs/README_fa.md | 7 +- docs/README_ja.md | 7 +- docs/README_ko.md | 7 +- docs/README_pt.md | 125 +++ docs/README_ru.md | 7 +- .../setting/setting-verge-basic.tsx | 1 + src/locales/pt/connections.json | 34 + src/locales/pt/home.json | 156 ++++ src/locales/pt/layout.json | 61 ++ src/locales/pt/logs.json | 9 + src/locales/pt/profiles.json | 221 ++++++ src/locales/pt/proxies.json | 122 +++ src/locales/pt/rules.json | 82 ++ src/locales/pt/settings.json | 716 ++++++++++++++++++ src/locales/pt/shared.json | 139 ++++ src/locales/pt/tests.json | 60 ++ src/services/i18n.ts | 1 + 21 files changed, 1845 insertions(+), 21 deletions(-) create mode 100644 crates/clash-verge-i18n/locales/pt.yml create mode 100644 docs/README_pt.md create mode 100644 src/locales/pt/connections.json create mode 100644 src/locales/pt/home.json create mode 100644 src/locales/pt/layout.json create mode 100644 src/locales/pt/logs.json create mode 100644 src/locales/pt/profiles.json create mode 100644 src/locales/pt/proxies.json create mode 100644 src/locales/pt/rules.json create mode 100644 src/locales/pt/settings.json create mode 100644 src/locales/pt/shared.json create mode 100644 src/locales/pt/tests.json diff --git a/README.md b/README.md index 2a65c2e6b..551997718 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,14 @@ A Clash Meta GUI based on Tauri Languages: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Preview diff --git a/crates/clash-verge-i18n/locales/pt.yml b/crates/clash-verge-i18n/locales/pt.yml new file mode 100644 index 000000000..1f0a31682 --- /dev/null +++ b/crates/clash-verge-i18n/locales/pt.yml @@ -0,0 +1,90 @@ +_version: 1 +notifications: + dashboardToggled: + title: Painel + body: A visibilidade do painel foi atualizada. + clashModeChanged: + title: Troca de modo + body: Alterado para {mode}. + systemProxyToggled: + title: Proxy do sistema + 'on': O proxy do sistema foi ativado. + 'off': O proxy do sistema foi desativado. + systemProxyFailed: + title: Proxy do sistema + body: Não foi possível definir o proxy do sistema. Abra o Clash Verge para ver o motivo. + systemProxyGuardStopped: + title: Proteção do proxy do sistema + body: A proteção parou após várias falhas de execução. Abra o Clash Verge para ver o motivo. + tunModeToggled: + title: Modo TUN + 'on': O modo TUN foi ativado. + 'off': O modo TUN foi desativado. + lightweightModeEntered: + title: Modo leve + body: Modo leve ativado. + profilesReactivated: + title: Perfis + body: Perfil reativado. + appQuit: + title: Prestes a sair + body: O Clash Verge está prestes a sair. + updateReady: + title: Atualização do Clash Verge + body: Uma nova versão (v{version}) foi baixada e está pronta para instalar. + installNow: Instalar agora + later: Depois +service: + adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador. + adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador. +launchGuard: + currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}' + homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}' + translocated: O macOS está executando este aplicativo a partir de um caminho temporário de App Translocation. No Finder, mova-o manualmente para /Applications ou ~/Applications e abra-o novamente. + rejected: |- + Este aplicativo deve ser iniciado a partir de um diretório de Aplicativos. + + {reason} + notInBundle: O executável não está dentro de um pacote de aplicativo. + canonicalizeBundleError: 'Não foi possível resolver o caminho do pacote do aplicativo: {error}' + bundleNameError: Não foi possível determinar o nome do pacote do aplicativo. O aplicativo será encerrado. + replacePrompt: Já existe um aplicativo em {destination}. Substituí-lo e reabrir a partir de Aplicativos? + movePrompt: Para iniciar o serviço em segundo plano com segurança, o aplicativo precisa ser movido para {destination}. Mover e reabrir agora? + createApplicationsDirError: 'Não foi possível criar o diretório de Aplicativos: {error}' + moveFailed: Falha ao mover o aplicativo. Mova-o manualmente no Finder e tente novamente. + replaceFailed: 'Não foi possível substituir o aplicativo existente com segurança: {error}' + relaunchFailed: O aplicativo foi copiado, mas não pôde ser reaberto automaticamente. Abra-o manualmente a partir de Aplicativos. + cancel: Cancelar + move: Mover + ok: OK +tray: + dashboard: Painel + ruleMode: Modo regra + globalMode: Modo global + directMode: Modo direto + outboundModes: Modos de saída + rule: Regra + direct: Direto + global: Global + profiles: Perfis + proxies: Proxies + systemProxy: Proxy do sistema + tunMode: Modo TUN + closeAllConnections: Fechar todas as conexões + lightweightMode: Modo leve + copyEnv: Copiar variáveis de ambiente + confDir: Diretório de configuração + coreDir: Diretório do núcleo + logsDir: Diretório de logs + openDir: Abrir diretório + appLog: Log do aplicativo + coreLog: Log do núcleo + restartClash: Reiniciar o núcleo do Clash + restartApp: Reiniciar o aplicativo + vergeVersion: Versão do Verge + more: Mais + exit: Sair + tooltip: + systemProxy: Proxy do sistema + tun: TUN + profile: Perfil diff --git a/docs/README_en.md b/docs/README_en.md index 414a75366..6652cc35b 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -11,13 +11,14 @@ A Clash Meta GUI built with Tauri<

Languages: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Preview diff --git a/docs/README_es.md b/docs/README_es.md index 2df191696..de797c82e 100644 --- a/docs/README_es.md +++ b/docs/README_es.md @@ -11,13 +11,14 @@ Una interfaz gráfica para Clash Meta construida con Idiomas: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Vista previa diff --git a/docs/README_fa.md b/docs/README_fa.md index 0c94631b8..b8ee7383f 100644 --- a/docs/README_fa.md +++ b/docs/README_fa.md @@ -11,13 +11,14 @@

زبان‌ها: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## پیش‌نمایش diff --git a/docs/README_ja.md b/docs/README_ja.md index 6a06023ca..186a5f8d3 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -11,13 +11,14 @@

言語: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## プレビュー diff --git a/docs/README_ko.md b/docs/README_ko.md index a0dee6abb..d0ffbb3a5 100644 --- a/docs/README_ko.md +++ b/docs/README_ko.md @@ -11,13 +11,14 @@

언어: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## 미리보기 diff --git a/docs/README_pt.md b/docs/README_pt.md new file mode 100644 index 000000000..2b8b7bb50 --- /dev/null +++ b/docs/README_pt.md @@ -0,0 +1,125 @@ +

+ Clash +
+ Continuação do Clash Verge +
+

+ +

+Uma interface gráfica para o Clash Meta construída com Tauri. +

+ +

+ Idiomas: + English · + Español · + فارسی · + 日本語 · + 한국어 · + Português · + Русский · + 简体中文 +

+ +## Pré-visualização + +| Escuro | Claro | +| -------------------------------------- | -------------------------------------- | +| ![Visual escuro](./preview_dark.png) | ![Visual claro](./preview_light.png) | + +## Instalação + +Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.
+Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple). + +#### Como escolher o canal de lançamento + +| Canal | Descrição | Link | +| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- | +| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) | +| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) | +| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) | + +#### Guias de instalação e perguntas frequentes + +Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes. + +### Canal do Telegram + +Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades. + +--- + +## Promoções + +#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6) + +- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria. +- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6) +- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos) +- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual +- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada +- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos +- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge) +- Desbloqueia serviços de streaming e acesso ao ChatGPT +- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6) + +### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com) + +- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas. +- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem. +- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados. +- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente. +- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com) +- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai) + +--- + +## Funcionalidades + +- Baseado em Rust de alto desempenho e no framework Tauri 2 +- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha` +- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection` +- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações +- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual) +- Editores visuais para nós e regras +- Backup e sincronização via WebDAV + +### Perguntas frequentes + +Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma. + +### Doações + +[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev) + +## Desenvolvimento + +Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição. + +Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com: + +```shell +pnpm i +pnpm run prebuild +pnpm dev +``` + +## Contribuições + +Issues e pull requests são bem-vindos. + +## Agradecimentos + +O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles: + +- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux. +- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web. +- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go. +- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go. +- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS. +- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida. + +## Licença + +Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes. diff --git a/docs/README_ru.md b/docs/README_ru.md index 81fd112bb..e0de81b24 100644 --- a/docs/README_ru.md +++ b/docs/README_ru.md @@ -11,13 +11,14 @@ Clash Meta GUI базируется на Языки: - 简体中文 · English · Español · - Русский · + فارسی · 日本語 · 한국어 · - فارسی + Português · + Русский · + 简体中文

## Предпросмотр diff --git a/src/components/setting/setting-verge-basic.tsx b/src/components/setting/setting-verge-basic.tsx index f1e4082ac..913e9859e 100644 --- a/src/components/setting/setting-verge-basic.tsx +++ b/src/components/setting/setting-verge-basic.tsx @@ -44,6 +44,7 @@ const languageOptions = supportedLanguages.map((code) => { es: 'Español', jp: '日本語', zhtw: '繁體中文', + pt: 'Português', } const label = labels[code] || code return { code, label } diff --git a/src/locales/pt/connections.json b/src/locales/pt/connections.json new file mode 100644 index 000000000..8034e7599 --- /dev/null +++ b/src/locales/pt/connections.json @@ -0,0 +1,34 @@ +{ + "page": { + "title": "Conexões" + }, + "components": { + "fields": { + "host": "Host", + "dlSpeed": "Vel. de download", + "ulSpeed": "Vel. de upload", + "chains": "Cadeias", + "rule": "Regra", + "process": "Processo", + "time": "Hora", + "source": "Origem", + "destination": "Destino", + "destinationPort": "Porta de destino", + "type": "Tipo" + }, + "order": { + "default": "Padrão", + "uploadSpeed": "Velocidade de upload", + "downloadSpeed": "Velocidade de download" + }, + "actions": { + "active": "Ativas", + "closed": "Fechadas", + "closeConnection": "Fechar conexão" + }, + "columnManager": { + "title": "Colunas", + "dragHandle": "Alça de arrastar" + } + } +} diff --git a/src/locales/pt/home.json b/src/locales/pt/home.json new file mode 100644 index 000000000..8df3dcc77 --- /dev/null +++ b/src/locales/pt/home.json @@ -0,0 +1,156 @@ +{ + "page": { + "tooltips": { + "lightweightMode": "Modo leve", + "manual": "Manual", + "settings": "Configurações do início" + }, + "cards": { + "trafficStats": "Estatísticas de tráfego", + "networkSettings": "Configurações de rede", + "proxyMode": "Modo de proxy" + }, + "settings": { + "cards": { + "profile": "Cartão de perfil", + "currentProxy": "Cartão do proxy atual", + "network": "Cartão de configurações de rede", + "proxyMode": "Cartão do modo de proxy", + "traffic": "Cartão de estatísticas de tráfego", + "tests": "Cartão de testes de sites", + "ip": "Cartão de informações de IP", + "clashInfo": "Cartões de informações do Clash", + "systemInfo": "Cartões de informações do sistema" + }, + "title": "Configurações do início" + }, + "title": "Início" + }, + "components": { + "proxyTun": { + "status": { + "systemProxyEnabled": "Proxy do sistema ativado", + "systemProxyDisabled": "Proxy do sistema desativado", + "tunModeServiceRequired": "Modo TUN requer o serviço", + "tunModeEnabled": "Modo TUN ativado", + "tunModeDisabled": "Modo TUN desativado" + }, + "tooltips": { + "systemProxy": "Informações do proxy do sistema", + "tunMode": "Informações de interceptação do modo TUN" + } + }, + "clashInfo": { + "title": "Informações do Clash", + "fields": { + "coreVersion": "Versão do núcleo", + "systemProxyAddress": "Endereço do proxy do sistema", + "mixedPort": "Porta mista", + "uptime": "Tempo ativo", + "rulesCount": "Quantidade de regras" + } + }, + "systemInfo": { + "title": "Informações do sistema", + "fields": { + "osInfo": "Informações do SO", + "autoLaunch": "Início automático", + "runningMode": "Modo de execução", + "lastCheckUpdate": "Última verificação de atualização", + "vergeVersion": "Versão do Verge" + }, + "actions": { + "settings": "Configurações" + }, + "badges": { + "notRunning": "Núcleo não está em execução", + "adminMode": "Modo administrador", + "serviceMode": "Modo serviço", + "sidecarMode": "Modo usuário", + "adminServiceMode": "Modo admin + serviço" + } + }, + "ipInfo": { + "title": "Informações de IP", + "labels": { + "ip": "IP", + "asn": "ASN", + "isp": "ISP", + "org": "ORG", + "location": "Localização", + "timezone": "Fuso horário", + "autoRefresh": "Atualização automática", + "unknown": "Desconhecido" + }, + "errors": { + "load": "Falha ao obter informações de IP", + "loadWithDetails": "Falha ao obter informações de IP: {{message}}", + "noServices": "Nenhum serviço de detecção de IP disponível" + } + }, + "currentProxy": { + "title": "Nó atual", + "actions": { + "refreshDelay": "Teste de latência" + }, + "labels": { + "globalMode": "Modo global", + "directMode": "Modo direto", + "group": "Grupo", + "proxy": "Proxy", + "noActiveNode": "Nenhum nó de proxy ativo" + }, + "status": { + "testing": "Testando", + "untested": "Não testado", + "error": "Erro", + "timeout": "Tempo esgotado", + "latencyHigh": "Latência alta", + "latencyMedium": "Latência moderada", + "latencyGood": "Latência boa", + "latencyExcellent": "Latência excelente", + "uninitialized": "Não inicializado", + "noProxyNode": "Nenhum nó de proxy" + } + }, + "tests": { + "title": "Testes de sites" + }, + "traffic": { + "metrics": { + "uploadSpeed": "Velocidade de upload", + "downloadSpeed": "Velocidade de download", + "activeConnections": "Conexões ativas", + "memoryUsage": "Uso do núcleo" + }, + "legends": { + "upload": "Envio", + "download": "Recebimento" + }, + "patterns": { + "minutes": "{{time}} minutos" + }, + "unknownTime": "Horário desconhecido", + "chartStyles": { + "smooth": "Suave", + "linear": "Linear" + }, + "diagnostics": "Pontos: {{points}} | Agrupados: {{compressed}} | FPS: {{fps}}" + }, + "clashMode": { + "errors": { + "communication": "Erro de comunicação com o núcleo, reinicie o computador e tente novamente" + }, + "labels": { + "rule": "Regra", + "global": "Global", + "direct": "Direto" + }, + "descriptions": { + "rule": "Escolhe proxies automaticamente de acordo com o conjunto de regras.", + "global": "Encaminha todas as requisições de rede pelo proxy selecionado.", + "direct": "Ignora o proxy e conecta diretamente à internet." + } + } + } +} diff --git a/src/locales/pt/layout.json b/src/locales/pt/layout.json new file mode 100644 index 000000000..6c1a2142b --- /dev/null +++ b/src/locales/pt/layout.json @@ -0,0 +1,61 @@ +{ + "components": { + "navigation": { + "tabs": { + "home": "Início", + "proxies": "Proxies", + "profiles": "Perfis", + "connections": "Conexões", + "rules": "Regras", + "logs": "Logs", + "unlock": "Teste", + "settings": "Ajustes" + }, + "menu": { + "reorderMode": "Modo de reordenação do menu", + "restoreDefaultOrder": "Restaurar ordem padrão", + "unlock": "Desbloquear ordem do menu", + "lock": "Bloquear ordem do menu", + "collapseNavBar": "Recolher barra de navegação", + "expandNavBar": "Expandir barra de navegação" + } + }, + "serviceMigration": { + "locationRefusedMessage": "O serviço não pode iniciar o núcleo porque o caminho de instalação ou as permissões do diretório falharam em uma verificação de segurança. Reinstale o serviço para restaurar seus arquivos e permissões e tente novamente. O Windows pode solicitar aprovação de administrador.", + "notAutoStartedMessage": "O tipo de inicialização do serviço Clash Verge foi alterado para Manual ou Desativado (geralmente por ferramentas de otimização do sistema), então ele não inicia mais com o Windows; esta sessão está usando Sidecar. Reinstale o serviço para restaurar a inicialização automática. O Windows pode solicitar aprovação de administrador.", + "title": "Atualização do serviço necessária", + "message": "A versão do serviço Clash Verge não corresponde à versão exigida por este app. Escolha Reinstalar serviço para desinstalar o serviço existente e instalar a versão exigida, ou continue com o Sidecar nesta sessão.", + "checkingMessage": "Verificando o status do serviço do sistema e o modo de execução atual. Aguarde…", + "unavailableMessage": "O serviço Clash Verge necessário não está disponível. Instale ou repare-o, ou continue com o Sidecar nesta sessão.", + "reinstall": "Reinstalar serviço", + "repair": "Reparar serviço", + "continueSidecar": "Continuar com Sidecar", + "success": "O serviço Clash Verge foi atualizado com sucesso", + "errors": { + "actionFailed": "A operação do serviço falhou", + "stateRefreshFailed": "Falha ao atualizar o estado do serviço", + "restartFailed": "Falha ao reiniciar o núcleo", + "revalidationFailed": "Falha ao atualizar o estado de execução", + "sidecarFailed": "Falha ao continuar com o Sidecar" + } + }, + "sysproxyPrivilege": { + "title": "Não foi possível definir o proxy do sistema", + "message": "O macOS recusou a alteração por falta de privilégios. Instalar o serviço Clash Verge permite que ele faça alterações de proxy como administrador, e este aviso não voltará.", + "serviceReadyMessage": "O serviço Clash Verge está pronto, mas o núcleo ainda está em execução como sidecar, por isso a definição do proxy do sistema foi recusada. Mudar para o modo serviço resolve — sem precisar reinstalar.", + "alternative": "Como alternativa, desative “exigir senha de administrador para acessar as configurações do sistema” em Ajustes do Sistema → Privacidade e Segurança → Avançado.", + "later": "Depois", + "installing": "Instalando o serviço do sistema. Pode ser solicitada a senha de administrador.", + "restarting": "Serviço instalado. Reiniciando o núcleo por meio dele…", + "applying": "Aplicando a alteração do proxy do sistema…", + "tunTitle": "O modo TUN precisa do serviço do sistema", + "tunMessage": "O modo TUN precisa do serviço do sistema Clash Verge para assumir o tráfego de rede. Depois de instalado, o modo TUN será ativado para você." + } + }, + "feedback": { + "errors": { + "restartCoreStopFailed": "Não foi possível parar o núcleo com segurança, então a reinicialização foi cancelada", + "quitCoreStopFailed": "Não foi possível parar o núcleo com segurança, então a saída foi cancelada" + } + } +} diff --git a/src/locales/pt/logs.json b/src/locales/pt/logs.json new file mode 100644 index 000000000..0a73fd4bb --- /dev/null +++ b/src/locales/pt/logs.json @@ -0,0 +1,9 @@ +{ + "page": { + "title": "Logs" + }, + "actions": { + "showDescending": "Mais recentes primeiro", + "showAscending": "Mais antigos primeiro" + } +} diff --git a/src/locales/pt/profiles.json b/src/locales/pt/profiles.json new file mode 100644 index 000000000..0fa7fe50d --- /dev/null +++ b/src/locales/pt/profiles.json @@ -0,0 +1,221 @@ +{ + "page": { + "actions": { + "updateAll": "Atualizar todos os perfis", + "viewRuntimeConfig": "Ver configuração em execução", + "reactivate": "Reativar perfis", + "import": "Importar" + }, + "batch": { + "actions": { + "delete": "Excluir perfis selecionados", + "selectAll": "Selecionar tudo", + "deselectAll": "Desmarcar tudo", + "done": "Concluído" + }, + "summary": { + "selected": "Selecionados", + "items": "itens" + }, + "title": "Operações em lote" + }, + "importForm": { + "placeholder": "URL do perfil", + "actions": { + "paste": "Colar" + } + }, + "feedback": { + "errors": { + "invalidUrl": "URL de perfil inválida. Insira uma URL começando com http:// ou https://", + "onlyYaml": "Somente arquivos YAML são suportados", + "createFailed": "Falha ao criar o perfil", + "deleteFailed": "Falha ao excluir o perfil", + "enhanceFailed": "Falha ao reativar os perfis", + "importFailed": "Falha ao importar o perfil", + "openFailed": "Falha ao abrir o perfil", + "readFailed": "Falha ao ler o perfil", + "reorderFailed": "Falha ao reordenar os perfis", + "switchFailed": "Falha ao trocar de perfil", + "updateFailed": "Falha ao atualizar o perfil" + }, + "notifications": { + "importRetry": "Falha na importação, tentando novamente com o proxy Clash...", + "importFail": "Falha na importação mesmo com o proxy Clash", + "importNeedsRefresh": "Perfil importado, mas pode precisar de atualização manual", + "importSuccess": "Perfil importado com sucesso, reinicie se não aparecer", + "profileSwitched": "Perfil trocado", + "profileReactivated": "Perfil reativado", + "switchBusy": "A troca de perfil já está em andamento", + "batchDeleted": "Perfis selecionados excluídos com sucesso" + }, + "notices": { + "forceRefreshCompleted": "Atualização forçada concluída", + "discardedKeys": "As extensões gravaram {{keys}}, que são gerenciados pelas Configurações, então esses valores foram descartados", + "emergencyRefreshFailed": "Falha na atualização de emergência: {{message}}" + }, + "tooltips": { + "forceRefreshStaleData": "Os dados do perfil estão indisponíveis; clique para forçar a atualização" + } + }, + "title": "Perfis" + }, + "components": { + "card": { + "labels": { + "clickToImport": "Clique para importar a assinatura" + } + }, + "fileInput": { + "chooseFile": "Escolher arquivo" + }, + "menu": { + "home": "Início", + "select": "Selecionar", + "shareQrCode": "Compartilhar QR Code", + "editInfo": "Editar informações", + "editFile": "Editar arquivo", + "editRules": "Editar regras", + "editProxies": "Editar proxies", + "editGroups": "Editar grupos de proxy", + "extendConfig": "Configuração de extensão", + "extendScript": "Script de extensão", + "openFile": "Abrir arquivo", + "update": "Atualizar", + "updateViaProxy": "Atualizar via proxy" + }, + "more": { + "global": { + "merge": "Extensão global", + "script": "Script de extensão global" + }, + "chips": { + "merge": "Mesclar", + "script": "Script" + } + }, + "profileItem": { + "tooltips": { + "showLast": "Clique para mostrar a última atualização", + "showNext": "Clique para mostrar a próxima atualização" + }, + "status": { + "lastUpdateFailed": "Falha na última atualização", + "nextUp": "Próxima", + "noSchedule": "Sem agendamento", + "unknown": "Desconhecido", + "autoUpdateDisabled": "Atualização automática desativada" + } + } + }, + "modals": { + "profileForm": { + "title": { + "create": "Criar perfil", + "edit": "Editar perfil" + }, + "fields": { + "type": "Tipo", + "description": "Descrição", + "subscriptionUrl": "URL da assinatura", + "userAgent": "User Agent", + "httpTimeout": "Tempo limite HTTP", + "updateInterval": "Intervalo de atualização", + "useSystemProxy": "Usar proxy do sistema", + "useClashProxy": "Usar proxy Clash", + "acceptInvalidCerts": "Permitir certificados inválidos (perigoso)", + "allowAutoUpdate": "Permitir atualização automática" + }, + "types": { + "remote": "Remoto", + "local": "Local" + }, + "errors": { + "typeRequired": "Selecione um tipo de perfil", + "urlRequired": "Insira a URL da assinatura", + "uidMissing": "O identificador do perfil está ausente", + "saveFailed": "Falha ao salvar o perfil" + }, + "warnings": { + "frequentUpdate": "Um intervalo curto recarrega o núcleo com essa frequência e derruba as conexões existentes. Recomendados: {{minutes}} minutos (24 horas)." + }, + "feedback": { + "notifications": { + "creationRetry": "Falha ao criar o perfil, tentando novamente com o proxy Clash...", + "creationSuccess": "Perfil criado com sucesso com o proxy Clash" + } + } + }, + "proxiesEditor": { + "title": "Editar proxies", + "placeholders": { + "multiUri": "Use quebras de linha para várias URIs (Base64 suportado)" + }, + "actions": { + "prepend": "Adicionar proxy no início", + "append": "Adicionar proxy no fim" + } + }, + "groupsEditor": { + "title": "Editar grupos de proxy", + "errors": { + "nameRequired": "Nome do grupo obrigatório", + "nameExists": "O nome do grupo já existe" + }, + "fields": { + "type": "Tipo de grupo", + "name": "Nome do grupo", + "icon": "Ícone do grupo de proxy", + "proxies": "Usar proxies", + "provider": "Usar provedor", + "healthCheckUrl": "URL de verificação de saúde", + "expectedStatus": "Status esperado", + "interval": "Intervalo", + "maxFailedTimes": "Máximo de falhas", + "interfaceName": "Nome da interface", + "routingMark": "Marca de roteamento", + "filter": "Filtro", + "excludeFilter": "Filtro de exclusão", + "excludeType": "Tipo de exclusão", + "includeAll": "Incluir todos os proxies e provedores", + "includeAllProxies": "Incluir todos os proxies", + "includeAllProviders": "Incluir todos os provedores" + }, + "toggles": { + "lazy": "Lazy", + "disableUdp": "Desativar UDP", + "hidden": "Oculto" + }, + "actions": { + "prepend": "Adicionar grupo no início", + "append": "Adicionar grupo no fim" + } + }, + "editor": { + "actions": { + "format": "Formatar documento" + }, + "messages": { + "readOnly": "Não é possível editar em um editor somente leitura" + }, + "enhance": { + "globalMerge": "Aplica-se a todos os perfis, após as configurações do app. O script global, a configuração do perfil e o script do perfil ainda podem alterar o resultado.", + "globalScript": "Aplica-se a todos os perfis, usando o resultado da mesclagem da configuração global. A configuração do perfil e o script do perfil ainda podem alterar o resultado.", + "profileMerge": "Aplica-se somente a este perfil, após a configuração global e o script global. O script do perfil ainda pode alterar o resultado.", + "profileScript": "Aplica-se somente a este perfil, usando o resultado da mesclagem da configuração do perfil. Esta é a última etapa de extensão antes de as configurações do app serem reaplicadas.", + "order": "Ordem: Perfil original → Configurações do app → Configuração de extensão global → Script de extensão global → Configuração de extensão do perfil → Script de extensão do perfil → Configurações do app reaplicadas", + "settingsPriority": "Nota: os campos gerenciados pelas configurações do app sempre usam essas configurações na configuração final. Alterar esses campos nas extensões não tem efeito." + } + }, + "confirmDelete": { + "title": "Confirmar exclusão", + "message": "Esta operação não é reversível" + }, + "logViewer": { + "title": "Console de scripts" + }, + "qrViewer": { + "title": "QR Code da assinatura" + } + } +} diff --git a/src/locales/pt/proxies.json b/src/locales/pt/proxies.json new file mode 100644 index 000000000..1740f6043 --- /dev/null +++ b/src/locales/pt/proxies.json @@ -0,0 +1,122 @@ +{ + "page": { + "modes": { + "rule": "Regra", + "global": "Global", + "direct": "Direto" + }, + "actions": { + "toggleChain": "Proxy em cadeia", + "connect": "Conectar", + "disconnect": "Desconectar", + "connecting": "Conectando...", + "clearChainConfig": "Excluir configuração da cadeia" + }, + "provider": { + "title": "Provedor de proxy", + "actions": { + "updateAll": "Atualizar tudo", + "update": "Atualizar" + } + }, + "rules": { + "title": "Regras de proxy", + "select": "Selecionar regras" + }, + "labels": { + "proxyCount": "Quantidade de proxies", + "delayCheckReset": "Teste de latência para cancelar a fixação", + "nodeCount": "{{count}} nós" + }, + "tooltips": { + "locate": "localizar", + "delayCheck": "Teste de latência", + "sortDefault": "Ordenar por padrão", + "sortDelay": "Ordenar por latência", + "sortName": "Ordenar por nome", + "delayCheckUrl": "URL do teste de latência", + "showBasic": "Proxy básico", + "showDetail": "Proxy detalhado", + "filter": "Filtro" + }, + "placeholders": { + "delayCheckUrl": "URL do teste de latência" + }, + "empty": { + "noAvailableGroups": "Nenhum grupo de proxy disponível", + "noProxies": "Nenhum proxy", + "noSubscriptions": { + "title": "Nenhuma assinatura importada", + "description": "Não há nada para exibir na página Proxies. Vá para Perfis para importar e ativar uma assinatura." + }, + "inactiveSubscription": { + "title": "Nenhuma assinatura ativa", + "description": "Foram encontradas assinaturas, mas nenhuma está ativa. Vá para Perfis e selecione uma para ativar." + }, + "coreUnavailable": { + "title": "Núcleo temporariamente indisponível", + "description": "Não foi possível obter as informações de proxy do núcleo. Tente reiniciá-lo. Se o problema persistir, abra a pasta de logs e verifique os logs do núcleo." + }, + "noProxyInfo": { + "title": "Nenhuma informação de proxy disponível", + "description": "Não há grupos de proxy nem nós para exibir. Verifique se a assinatura ativa contém grupos e nós de proxy válidos, ou reinicie o núcleo e revise os logs." + }, + "actions": { + "openProfiles": "Ir para Perfis", + "restartCore": "Reiniciar núcleo", + "openLogs": "Abrir pasta de logs" + } + }, + "chain": { + "header": "Configuração do proxy em cadeia", + "warning": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", + "empty": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", + "instruction": "Clique nos nós em ordem para adicioná-los à cadeia de proxy", + "minimumNodes": "O proxy em cadeia requer pelo menos 2 nós", + "minimumNodesHint": "O proxy em cadeia requer pelo menos 2 nós. Adicione mais um nó.", + "connectFailed": "Falha ao conectar à cadeia de proxy", + "disconnectFailed": "Falha ao desconectar da cadeia de proxy", + "duplicateNode": "O nó de proxy já existe na cadeia", + "entryNode": "Entrada", + "exitNode": "Saída" + }, + "messages": { + "directMode": "Modo direto" + }, + "title": { + "default": "Grupos de proxy", + "chainMode": "Modo de proxy em cadeia" + } + }, + "feedback": { + "warnings": { + "invalidTestUrl": "A URL de teste deve começar com http:// ou https://" + }, + "notifications": { + "provider": { + "updateSuccess": "{{name}} atualizado com sucesso", + "updateFailed": "Falha ao atualizar {{name}}: {{message}}", + "genericError": "Falha na atualização: {{message}}", + "none": "Nenhum provedor disponível para atualizar", + "allUpdated": "Todos os provedores atualizados com sucesso" + } + } + }, + "components": { + "enums": { + "strategies": { + "select": "Selecionar proxy manualmente", + "url-test": "Selecionar proxy com base na latência do teste de URL", + "fallback": "Alternar para outro proxy em caso de erro", + "load-balance": "Distribuir proxies com balanceamento de carga", + "relay": "Passar pela cadeia de proxy definida" + }, + "policies": { + "DIRECT": "Os dados saem diretamente (DIRECT)", + "REJECT": "Intercepta as requisições (REJECT)", + "REJECT-DROP": "Descarta as requisições (REJECT-DROP)", + "PASS": "Ignora esta regra quando houver correspondência (PASS)" + } + } + } +} diff --git a/src/locales/pt/rules.json b/src/locales/pt/rules.json new file mode 100644 index 000000000..b335b527b --- /dev/null +++ b/src/locales/pt/rules.json @@ -0,0 +1,82 @@ +{ + "page": { + "provider": { + "trigger": "Provedor de regras", + "dialogTitle": "Provedores de regras", + "actions": { + "updateAll": "Atualizar tudo", + "update": "Atualizar" + } + }, + "title": "Regras" + }, + "feedback": { + "notifications": { + "provider": { + "updateSuccess": "{{name}} atualizado com sucesso", + "updateFailed": "Falha ao atualizar {{name}}: {{message}}", + "genericError": "Falha na atualização: {{message}}", + "none": "Nenhum provedor disponível para atualizar", + "allUpdated": "Todos os provedores foram atualizados com sucesso" + } + } + }, + "modals": { + "editor": { + "form": { + "labels": { + "type": "Tipo de regra", + "content": "Conteúdo da regra", + "proxyPolicy": "Política de proxy" + }, + "toggles": { + "noResolve": "Sem resolução" + }, + "actions": { + "prependRule": "Adicionar regra no início", + "appendRule": "Adicionar regra no fim" + }, + "validation": { + "conditionRequired": "Condição da regra obrigatória", + "invalidRule": "Regra inválida" + } + }, + "ruleTypes": { + "DOMAIN": "Corresponder ao domínio completo (DOMAIN)", + "DOMAIN-SUFFIX": "Corresponder ao sufixo de domínio (DOMAIN-SUFFIX)", + "DOMAIN-KEYWORD": "Corresponder à palavra-chave do domínio (DOMAIN-KEYWORD)", + "DOMAIN-REGEX": "Corresponder ao domínio por regex (DOMAIN-REGEX)", + "GEOSITE": "Corresponder a domínios do GeoSite (GEOSITE)", + "GEOIP": "Corresponder ao código de país do IP (GEOIP)", + "SRC-GEOIP": "Corresponder ao código de país do IP de origem (SRC-GEOIP)", + "IP-ASN": "Corresponder ao ASN do IP (IP-ASN)", + "SRC-IP-ASN": "Corresponder ao ASN do IP de origem (SRC-IP-ASN)", + "IP-CIDR": "Corresponder à faixa de endereços IP (IP-CIDR)", + "IP-CIDR6": "Corresponder à faixa de endereços IPv6 (IP-CIDR6)", + "SRC-IP-CIDR": "Corresponder à faixa de endereços IP de origem (SRC-IP-CIDR)", + "IP-SUFFIX": "Corresponder à faixa de sufixo de IP (IP-SUFFIX)", + "SRC-IP-SUFFIX": "Corresponder à faixa de sufixo de IP de origem (SRC-IP-SUFFIX)", + "SRC-PORT": "Corresponder à faixa de portas de origem (SRC-PORT)", + "DST-PORT": "Corresponder à faixa de portas de destino (DST-PORT)", + "IN-PORT": "Corresponder à porta de entrada (IN-PORT)", + "DSCP": "Tag DSCP (apenas entrada TPROXY UDP) (DSCP)", + "PROCESS-NAME": "Corresponder ao nome do processo (PROCESS-NAME)", + "PROCESS-PATH": "Corresponder ao caminho completo do processo (PROCESS-PATH)", + "PROCESS-NAME-REGEX": "Corresponder ao nome do processo por regex (PROCESS-NAME-REGEX)", + "PROCESS-PATH-REGEX": "Corresponder ao caminho completo do processo por regex (PROCESS-PATH-REGEX)", + "NETWORK": "Corresponder ao protocolo de rede (TCP/UDP) (NETWORK)", + "UID": "Corresponder ao ID de usuário Linux (UID)", + "IN-TYPE": "Corresponder ao tipo de entrada (IN-TYPE)", + "IN-USER": "Corresponder ao usuário de entrada (IN-USER)", + "IN-NAME": "Corresponder ao nome da entrada (IN-NAME)", + "SUB-RULE": "Sub-regra (SUB-RULE)", + "RULE-SET": "Corresponder ao conjunto de regras (RULE-SET)", + "AND": "E lógico (AND)", + "OR": "OU lógico (OR)", + "NOT": "NÃO lógico (NOT)", + "MATCH": "Corresponder a todas as requisições (MATCH)" + }, + "title": "Editar regras" + } + } +} diff --git a/src/locales/pt/settings.json b/src/locales/pt/settings.json new file mode 100644 index 000000000..8e2aac51e --- /dev/null +++ b/src/locales/pt/settings.json @@ -0,0 +1,716 @@ +{ + "page": { + "actions": { + "manual": "Manual", + "telegram": "Canal do Telegram", + "github": "Repositório do Github" + }, + "actionsGroupLabel": "Links das configurações", + "title": "Configurações" + }, + "sections": { + "system": { + "title": "Configurações do Sistema", + "toggles": { + "tunMode": "Modo Tun", + "systemProxy": "Proxy do Sistema" + }, + "tooltips": { + "silentStart": "Inicia o programa em segundo plano sem exibir o painel" + }, + "fields": { + "autoLaunch": "Iniciar com o Sistema", + "silentStart": "Início Silencioso" + }, + "notifications": { + "tunMode": { + "autoDisabled": "Modo TUN desativado automaticamente porque o serviço está indisponível", + "autoDisableFailed": "Falha ao desativar o Modo TUN automaticamente" + } + } + }, + "proxyControl": { + "tooltips": { + "systemProxy": "Ative para modificar as configurações de proxy do sistema operacional. Se a ativação falhar, modifique as configurações de proxy do sistema operacional manualmente", + "tunMode": "Modo Tun (placa de rede virtual): captura todo o tráfego do sistema. Quando ativado, não é necessário ativar o proxy do sistema.", + "tunUnavailable": "O TUN requer o Modo Serviço ou o Modo Administrador" + }, + "actions": { + "installService": "Instalar Serviço", + "uninstallService": "Desinstalar Serviço", + "switchToServiceMode": "Mudar para o modo serviço" + }, + "fields": { + "systemProxy": "Proxy do Sistema", + "tunMode": "Modo Tun" + }, + "messages": { + "installedCheckProxy": "O serviço está instalado e o núcleo está em execução por meio dele. Verifique o interruptor do proxy do sistema e refaça a alteração se ela não tiver sido aplicada.", + "installedCoreNotOnService": "O serviço está instalado, mas o núcleo não está em execução por meio dele. Verifique o núcleo e tente novamente.", + "installedProxyRestored": "O serviço está instalado, o núcleo está em execução por meio dele e a alteração do proxy do sistema foi aplicada." + } + }, + "externalController": { + "title": "Controlador Externo", + "fields": { + "enable": "Ativar Controlador Externo", + "address": "Controlador Externo", + "secret": "Segredo do Núcleo" + }, + "placeholders": { + "address": "Obrigatório", + "secret": "Recomendado" + }, + "tooltips": { + "copy": "Copiar para a área de transferência" + }, + "messages": { + "addressRequired": "O endereço do controlador não pode estar vazio", + "secretRequired": "O segredo não pode estar vazio", + "copyFailed": "Falha ao copiar", + "controllerCopied": "Endereço do controlador copiado para a área de transferência", + "secretCopied": "Segredo copiado para a área de transferência" + } + }, + "externalCors": { + "title": "Configuração de CORS Externo", + "fields": { + "allowPrivateNetwork": "Permitir acesso à rede privada", + "allowedOrigins": "Origens Permitidas" + }, + "placeholders": { + "origin": "Insira uma URL válida" + }, + "actions": { + "add": "Adicionar" + }, + "messages": { + "alwaysIncluded": "Origens sempre incluídas: {{urls}}" + }, + "tooltips": { + "open": "Configurações de CORS Externo" + } + }, + "appearance": { + "light": "Claro", + "dark": "Escuro", + "system": "Sistema" + }, + "clash": { + "title": "Configurações do Clash", + "form": { + "fields": { + "allowLan": "Permitir LAN", + "dnsOverwrite": "Sobrescrever DNS", + "ipv6": "IPv6", + "unifiedDelay": "Atraso Unificado", + "logLevel": "Nível de Log", + "portConfig": "Configuração de Portas", + "external": "Externo", + "webUI": "Web UI", + "clashCore": "Núcleo do Clash", + "openUwpTool": "Abrir ferramenta UWP", + "updateGeoData": "Atualizar GeoData", + "tunnels": { + "title": "Gerenciamento de Túneis", + "localAddr": "Endereço de Escuta Local", + "localPort": "Porta de Escuta Local", + "targetAddr": "Endereço de Destino", + "targetPort": "Porta de Destino", + "proxyGroup": "Grupo de Proxy", + "proxyNode": "Nó de Proxy", + "protocols": "Protocolo", + "existing": "Túneis Existentes", + "default": "Seguir a Configuração Atual", + "optional": "Opcional", + "messages": { + "incomplete": "Preencha todos os campos obrigatórios do túnel", + "invalidLocalAddr": "Endereço de escuta local inválido", + "invalidLocalPort": "Porta de escuta local inválida", + "invalidTargetAddr": "Endereço de destino inválido", + "invalidTargetPort": "Porta de destino inválida" + }, + "actions": { + "add": "Adicionar", + "addNew": "Adicionar Novo Túnel" + } + } + }, + "tooltips": { + "networkInterface": "Interface de Rede", + "unifiedDelay": "Com o atraso unificado ativado, dois testes de atraso são realizados para eliminar as diferenças de atraso entre diferentes tipos de nós causadas por handshakes de conexão, etc.", + "logLevel": "Este parâmetro é válido apenas para os arquivos de log do núcleo na pasta Service do diretório de logs", + "openUwpTool": "Desde o Windows 8, os aplicativos UWP (como a Microsoft Store) são impedidos de acessar diretamente os serviços de rede do host local, e esta ferramenta pode ser usada para contornar essa restrição" + }, + "options": { + "logLevel": { + "debug": "Debug", + "info": "Info", + "warning": "Aviso", + "error": "Erro", + "silent": "Silencioso" + } + } + } + } + }, + "components": { + "verge": { + "basic": { + "title": "Configurações Básicas do Verge", + "actions": { + "browse": "Procurar" + }, + "trayOptions": { + "showMainWindow": "Abrir Janela", + "showTrayMenu": "Abrir Menu", + "disable": "Desativar" + }, + "fields": { + "language": "Idioma", + "themeMode": "Modo do Tema", + "trayClickEvent": "Ação de Clique na Bandeja", + "copyEnvType": "Tipo de Env ao Copiar", + "startPage": "Página Inicial", + "startupScript": "Script de Inicialização", + "themeSetting": "Configurações do Tema", + "layoutSetting": "Configurações de Layout", + "misc": "Diversos", + "hotkeySetting": "Configurações de Atalhos" + } + }, + "advanced": { + "title": "Configurações Avançadas do Verge", + "tooltips": { + "backupInfo": "Suporta backup local ou via WebDAV dos arquivos de configuração", + "openConfDir": "Se o software funcionar de forma anormal, FAÇA BACKUP e exclua todos os arquivos desta pasta e reinicie o software", + "liteMode": "Fecha a interface gráfica e mantém apenas o núcleo em execução" + }, + "actions": { + "copyVersion": "Copiar Versão" + }, + "notifications": { + "latestVersion": "Você já está na versão mais recente", + "versionCopied": "Versão copiada para a área de transferência" + }, + "fields": { + "backupSetting": "Configurações de Backup", + "runtimeConfig": "Configuração em Execução", + "openConfDir": "Abrir Pasta de Configuração", + "openCoreDir": "Abrir Pasta do Núcleo", + "openLogsDir": "Abrir Pasta de Logs", + "checkUpdates": "Verificar Atualizações", + "openDevTools": "Ferramentas de Desenvolvedor", + "liteModeSettings": "Configurações do Modo Leve", + "exit": "Sair", + "exportDiagnostics": "Exportar Informações de Diagnóstico", + "vergeVersion": "Versão do Verge" + } + }, + "theme": { + "title": "Configurações do Tema", + "fields": { + "primaryColor": "Cor Primária", + "secondaryColor": "Cor Secundária", + "primaryText": "Texto Primário", + "secondaryText": "Texto Secundário", + "infoColor": "Cor de Informação", + "warningColor": "Cor de Aviso", + "errorColor": "Cor de Erro", + "successColor": "Cor de Sucesso", + "fontFamily": "Família da Fonte", + "cssInjection": "Injeção de CSS" + }, + "actions": { + "editCss": "Editar CSS" + }, + "dialogs": { + "editCssTitle": "Editar CSS" + } + }, + "layout": { + "title": "Configurações de Layout", + "fields": { + "preferSystemTitlebar": "Preferir Barra de Título do Sistema", + "trafficGraph": "Gráfico de Tráfego", + "memoryUsage": "Uso do Núcleo", + "proxyGroupIcon": "Ícone do Grupo de Proxy", + "toastPosition": "Posição das Notificações", + "hoverNavigator": "Navegador de Salto ao Passar o Mouse", + "hoverNavigatorDelay": "Atraso do Navegador de Salto ao Passar o Mouse", + "navIcon": "Ícone de Navegação", + "collapseNavBar": "Recolher Barra de Navegação", + "trayIcon": "Ícone da Bandeja", + "proxyGroupsDisplayMode": "Modo de Exibição dos Grupos de Proxy", + "showOutboundModesInline": "Mostrar Modos de Saída em Linha", + "commonTrayIcon": "Ícone Comum da Bandeja", + "systemProxyTrayIcon": "Ícone da Bandeja do Proxy do Sistema", + "tunTrayIcon": "Ícone da Bandeja do Tun", + "enableTrayIcon": "Ativar Ícone da Bandeja", + "enableTraySpeed": "Ativar Velocidade na Bandeja", + "pauseRenderTrafficStatsOnBlur": "Pausar a renderização das estatísticas de tráfego quando sem foco" + }, + "tooltips": { + "hoverNavigator": "Rola automaticamente até o grupo de proxy correspondente ao passar o mouse sobre as letras do alfabeto", + "hoverNavigatorDelay": "Atraso antes da rolagem automática ao passar o mouse, em milissegundos" + }, + "options": { + "icon": { + "monochrome": "Monocromático", + "colorful": "Colorido", + "disable": "Desativar" + }, + "toastPosition": { + "topLeft": "Superior Esquerdo", + "topRight": "Superior Direito", + "bottomLeft": "Inferior Esquerdo", + "bottomRight": "Inferior Direito" + }, + "proxyGroupsDisplayMode": { + "default": "Padrão", + "inline": "Em Linha", + "disable": "Desativar" + } + } + } + } + }, + "modals": { + "clashPort": { + "title": "Configuração de Portas", + "fields": { + "mixed": "Porta Mista", + "socks": "Porta Socks", + "http": "Porta Http(s)", + "redir": "Porta Redir", + "tproxy": "Porta Tproxy" + }, + "actions": { + "random": "Porta Aleatória" + }, + "messages": { + "portInUse": "A porta {{port}} já está em uso", + "runningPort": "Usando atualmente a porta {{port}}", + "duplicatePort": "Os listeners ativados devem usar portas diferentes", + "invalidPort": "Insira uma porta entre 1 e 65535", + "saved": "Configurações de porta salvas", + "saveFailed": "Falha ao salvar as configurações de porta", + "automaticFallback": "A porta {{oldPort}} estava em uso, então esta sessão é executada em {{newPort}}. Sua configuração não foi alterada e {{oldPort}} será tentada novamente na próxima inicialização.", + "automaticFallbackFailed": "Falha no fallback automático da porta mista do proxy: {{error}}", + "portTooLow": "A porta deve ser 1000 ou maior", + "portTooHigh": "A porta deve ser 65535 ou menor" + } + }, + "clashCore": { + "variants": { + "release": "Versão Release", + "alpha": "Versão Alpha" + } + }, + "liteMode": { + "title": "Configurações do Modo Leve", + "actions": { + "enterNow": "Entrar no Modo Leve Agora" + }, + "toggles": { + "autoEnter": "Entrar Automaticamente no Modo Leve" + }, + "tooltips": { + "autoEnter": "Ative para acionar automaticamente o Modo Leve depois que a janela ficar fechada por um período" + }, + "fields": { + "delay": "Atraso para Entrar Automaticamente no Modo Leve" + }, + "messages": { + "autoEnterHint": "Ao fechar a janela, o Modo Leve será ativado automaticamente após {{n}} minutos" + } + }, + "backup": { + "title": "Configurações de Backup", + "tabs": { + "local": "Backup local", + "webdav": "Backup WebDAV" + }, + "actions": { + "selectTarget": "Selecionar destino do backup", + "backup": "Backup", + "export": "Exportar", + "exportBackup": "Exportar Backup", + "importBackup": "Importar Backup", + "deleteBackup": "Excluir Backup", + "restore": "Restaurar", + "restoreBackup": "Restaurar Backup", + "viewHistory": "Ver histórico" + }, + "fields": { + "webdavUrl": "URL do Servidor WebDAV", + "username": "Usuário", + "info": "Os backups são armazenados localmente no diretório de dados do aplicativo. Use a lista abaixo para restaurar ou excluir backups." + }, + "messages": { + "webdavUrlRequired": "A URL do WebDAV não pode estar vazia", + "invalidWebdavUrl": "Formato de URL do WebDAV inválido", + "usernameRequired": "O usuário não pode estar vazio", + "passwordRequired": "A senha não pode estar vazia", + "webdavConfigSaved": "Configuração do WebDAV salva com sucesso", + "webdavConfigSaveFailed": "Falha ao salvar a configuração do WebDAV: {{error}}", + "backupCreated": "Backup criado com sucesso", + "backupFailed": "Falha no backup: {{error}}", + "localBackupCreated": "Backup local criado com sucesso", + "localBackupFailed": "Falha no backup local", + "restoreSuccess": "Restauração concluída, o aplicativo será reiniciado em 1s", + "localBackupExported": "Backup local exportado com sucesso", + "localBackupExportFailed": "Falha ao exportar o backup local", + "localBackupImported": "Backup local importado com sucesso", + "localBackupImportFailed": "Falha ao importar o backup local: {{error}}", + "webdavRefreshSuccess": "Atualização do WebDAV concluída com sucesso", + "webdavRefreshFailed": "Falha na atualização do WebDAV: {{error}}", + "confirmDelete": "Confirmar a exclusão deste arquivo de backup?", + "confirmRestore": "Confirmar a restauração deste arquivo de backup?" + }, + "auto": { + "title": "Backup automático", + "scheduleLabel": "Ativar backup agendado", + "scheduleHelper": "Cria backups locais em segundo plano no intervalo configurado.", + "intervalLabel": "Frequência do backup", + "changeLabel": "Backup em alterações críticas", + "changeHelper": "Faz backup automaticamente quando a Configuração/Script Global Estendido é alterado.", + "options": { + "hours": "A cada {{n}} horas", + "days": "A cada {{n}} dias" + } + }, + "manual": { + "title": "Backup manual", + "local": "Cria um snapshot neste dispositivo, armazenado no diretório de dados do aplicativo.", + "webdav": "Envia um snapshot para o seu servidor WebDAV assim que as credenciais forem definidas.", + "configureWebdav": "Configurar WebDAV" + }, + "history": { + "title": "Histórico de backups", + "summary": "{{count}} backups • mais recente {{recent}}", + "empty": "Nenhum backup disponível", + "unknownPlatform": "desconhecida", + "unknownTime": "Hora desconhecida" + }, + "webdav": { + "title": "Configurações do WebDAV" + }, + "table": { + "filename": "Nome do Arquivo", + "backupTime": "Hora do Backup", + "actions": "Ações", + "noBackups": "Nenhum backup disponível", + "rowsPerPage": "Linhas por página" + } + }, + "misc": { + "title": "Diversos", + "fields": { + "appLogLevel": "Nível de Log do Aplicativo", + "appLogMaxSize": "Tamanho Máximo do Log do Aplicativo", + "appLogMaxCount": "Quantidade Máxima de Logs do Aplicativo", + "autoCloseConnections": "Fechar Conexões Automaticamente", + "autoCheckUpdate": "Verificar Atualizações Automaticamente", + "enableBuiltinEnhanced": "Ativar Aprimoramento Integrado", + "proxyLayoutColumns": "Colunas do Layout de Proxy", + "autoLogClean": "Limpeza Automática de Logs", + "autoDelayDetection": "Detecção Automática de Atraso", + "autoDelayDetectionInterval": "Intervalo da Detecção Automática de Atraso", + "defaultLatencyTest": "Teste de Latência Padrão", + "defaultLatencyTimeout": "Tempo Limite de Latência Padrão" + }, + "tooltips": { + "autoCloseConnections": "Encerra as conexões estabelecidas quando a seleção do grupo de proxy ou o modo de proxy muda", + "enableBuiltinEnhanced": "Tratamento de compatibilidade para o arquivo de configuração", + "autoDelayDetection": "Testa periodicamente a latência do nó atual em segundo plano", + "defaultLatencyTest": "Usado apenas para testes de requisição do cliente HTTP e não afeta o arquivo de configuração" + }, + "options": { + "proxyLayoutColumns": { + "auto": "Colunas Automáticas" + }, + "autoLogClean": { + "never": "Nunca Limpar", + "retainDays": "Manter {{n}} Dias" + } + } + }, + "update": { + "title": "Nova Versão v{{version}}", + "actions": { + "goToRelease": "Ir para a Página da Release", + "update": "Atualizar" + }, + "messages": { + "breakChangeError": "Esta versão é uma atualização importante e não oferece suporte a atualizações dentro do aplicativo. Desinstale-a e baixe e instale a nova versão manualmente", + "available": "Uma nova versão está disponível" + }, + "alerts": { + "note": "Nota", + "tip": "Dica", + "important": "Importante", + "warning": "Aviso", + "caution": "Cuidado" + } + }, + "sysproxy": { + "title": "Configurações do Proxy do Sistema", + "fieldsets": { + "currentStatus": "Proxy do Sistema Atual" + }, + "fields": { + "enableStatus": "Status de Ativação:", + "serverAddr": "Endereço do Servidor: ", + "pacUrl": "URL do PAC: ", + "proxyHost": "Host do Proxy", + "usePacMode": "Usar Modo PAC", + "proxyGuard": "Proteção do Proxy", + "guardDuration": "Duração da Proteção", + "alwaysUseDefaultBypass": "Sempre usar o Bypass Padrão", + "enableBypassCheck": "Validar Formato do Bypass do Proxy", + "proxyBypass": "Configurações de Bypass do Proxy: ", + "bypass": "Bypass: ", + "pacScriptContent": "Conteúdo do Script PAC" + }, + "tooltips": { + "proxyGuard": "Ative para impedir que outros softwares modifiquem as configurações de proxy do sistema operacional" + }, + "messages": { + "durationTooShort": "A duração do daemon do proxy não pode ser menor que 1 segundo", + "invalidBypass": "Formato de Bypass inválido", + "invalidProxyHost": "Formato de host do proxy inválido" + }, + "actions": { + "editPac": "Editar PAC" + } + }, + "tun": { + "title": "Modo Tun", + "fields": { + "stack": "Stack do Tun", + "device": "Nome do Dispositivo", + "autoRoute": "Rota Automática", + "routeExcludeAddress": "Endereços Excluídos da Rota", + "strictRoute": "Rota Estrita", + "autoDetectInterface": "Detectar Interface Automaticamente", + "dnsHijack": "Sequestro de DNS", + "mtu": "Unidade Máxima de Transmissão", + "autoRedirect": "Redirecionamento Automático" + }, + "tooltips": { + "dnsHijack": "Use , para separar vários servidores DNS", + "autoRedirect": "Configura automaticamente os redirecionamentos TCP do nftables/iptables" + }, + "messages": { + "applied": "Configurações Aplicadas", + "invalidRouteExcludeAddress": "Insira um bloco CIDR válido", + "routeExcludeAddressHint": "Somente CIDR IPv4/IPv6 é suportado, como 192.168.0.0/16 ou fd00::/8" + } + }, + "dns": { + "dialog": { + "title": "Sobrescrever DNS", + "profileScope": "A opção de sobrescrever DNS é salva separadamente para cada perfil e se aplica somente a esse perfil.", + "warning": "Se você não está familiarizado com estas configurações, não as modifique e mantenha a Sobrescrita de DNS desativada" + }, + "protection": { + "title": "Ativar Sobrescrita de DNS?", + "message": "Esta assinatura contém configurações de DNS personalizadas. A Sobrescrita de DNS pode substituí-las, impedindo que os nós se conectem ou que alguns sites sejam carregados. Ativar mesmo assim?", + "enableAnyway": "Ativar mesmo assim", + "keepDisabled": "Manter desativada", + "autoDisabled": "A Sobrescrita de DNS foi desativada para preservar as configurações de DNS personalizadas da assinatura atual." + }, + "sections": { + "general": "Configurações de DNS", + "fallbackFilter": "Configurações do Filtro de Fallback", + "hosts": "Configurações de Hosts" + }, + "fields": { + "enable": "Ativar DNS", + "listen": "Escuta do DNS", + "enhancedMode": "Modo Aprimorado", + "fakeIpRange": "Intervalo de Fake IP", + "fakeIpRange6": "Intervalo de Fake IP (IPv6)", + "fakeIpFilterMode": "Modo do Filtro de Fake IP", + "ipv6": { + "label": "IPv6", + "description": "Ativar resolução DNS IPv6" + }, + "preferH3": { + "label": "Preferir H3", + "description": "O DNS DOH usa HTTP/3" + }, + "respectRules": { + "label": "Respeitar Regras", + "description": "As conexões DNS seguem as regras de roteamento" + }, + "useHosts": { + "label": "Usar Hosts", + "description": "Ative para resolver hosts por meio do arquivo hosts" + }, + "useSystemHosts": { + "label": "Usar Hosts do Sistema", + "description": "Ative para resolver hosts por meio do arquivo hosts do sistema" + }, + "directPolicy": { + "label": "Nameserver Direto Segue a Política", + "description": "Define se deve seguir a política de nameserver" + }, + "defaultNameserver": { + "label": "Nameserver Padrão", + "description": "Servidores DNS padrão usados para resolver servidores DNS" + }, + "nameserver": { + "label": "Nameserver", + "description": "Lista de servidores DNS, separados por vírgula" + }, + "fallback": { + "label": "Fallback", + "description": "Lista de servidores DNS de fallback, separados por vírgula" + }, + "proxy": { + "label": "Nameserver do Servidor Proxy", + "description": "Servidores DNS para resolução de domínios dos nós de proxy" + }, + "directNameserver": { + "label": "Nameserver Direto", + "description": "Servidores DNS para resolução de domínios de saída direta, suporta a palavra-chave 'system', separados por vírgula" + }, + "fakeIpFilter": { + "label": "Filtro de Fake IP", + "description": "Domínios que ignoram a resolução de fake IP, separados por vírgula" + }, + "nameserverPolicy": { + "label": "Política de Nameserver", + "description": "Servidor DNS específico por domínio, vários servidores separados por ponto e vírgula, formato: domínio=servidor1;servidor2" + }, + "geoipFiltering": { + "label": "Filtragem GeoIP", + "description": "Ativar filtragem GeoIP para o fallback" + }, + "geoipCode": "Código GeoIP", + "fallbackIpCidr": { + "label": "CIDR de IP do Fallback", + "description": "CIDRs de IP que não usam servidores de fallback, separados por vírgula" + }, + "fallbackDomain": { + "label": "Domínio do Fallback", + "description": "Domínios que usam servidores de fallback, separados por vírgula" + }, + "hosts": { + "label": "Hosts", + "description": "Mapeamento personalizado de domínio para IP ou domínio" + } + }, + "messages": { + "saved": "Configurações de DNS salvas", + "configError": "Erro na configuração de DNS:" + }, + "errors": { + "invalid": "Configuração inválida", + "invalidYaml": "Formato YAML inválido" + } + }, + "webUI": { + "actions": { + "openUrl": "Abrir URL" + }, + "title": "Web UI", + "messages": { + "supportedPlaceholders": "Suporta %host, %port, %secret", + "placeholderInstruction": "Substitua host, port, secret por %host, %port, %secret" + }, + "errors": { + "clashInfoUnavailable": "As informações de conexão do núcleo não estão disponíveis", + "invalidServer": "O endereço do servidor do núcleo \"{{server}}\" é inválido", + "openFailed": "Falha ao abrir a Web UI" + } + }, + "hotkey": { + "toggles": { + "enableGlobal": "Ativar Atalho Global" + }, + "title": "Configurações de Atalhos", + "functions": { + "rule": "Modo Regra", + "global": "Modo Global", + "openOrCloseDashboard": "Abrir/Fechar Painel", + "toggleSystemProxy": "Ativar/Desativar Proxy do Sistema", + "toggleTunMode": "Ativar/Desativar Modo Tun", + "entryLightweightMode": "Entrar no Modo Leve", + "direct": "Modo Direto", + "reactivateProfiles": "Reativar Perfis" + } + }, + "networkInterface": { + "title": "Interface de Rede", + "fields": { + "ipAddress": "Endereço IP", + "macAddress": "Endereço MAC" + } + } + }, + "feedback": { + "errors": { + "clash": { + "configUpdateFailed": "Falha ao atualizar a configuração do núcleo", + "modeUpdateFailed": "Falha ao alterar o modo de proxy", + "restartFailed": "Falha ao reiniciar o núcleo", + "startFailed": "Falha ao iniciar o núcleo", + "serviceCoreStopped": "O núcleo do serviço parou inesperadamente", + "stopFailed": "Falha ao parar o núcleo" + }, + "clashService": { + "installFailed": "Falha ao instalar o serviço", + "uninstallFailed": "Falha ao desinstalar o serviço", + "reinstallFailed": "Falha ao reinstalar o serviço", + "repairFailed": "Falha ao reparar o serviço", + "sidecarFailed": "Falha ao continuar com o Sidecar", + "coreAlreadyRunning": "Não foi possível iniciar o modo normal (Sidecar): outro núcleo ainda está em execução (PID {{pid}}). Saia das outras instâncias do Clash Verge ou do aplicativo que usa este núcleo e tente novamente." + }, + "sysproxy": { + "privilegeRequired": "Não é possível definir o proxy do sistema: o macOS recusou a alteração por falta de privilégios. Instale o serviço do Clash Verge; um administrador também pode desativar a exigência de senha de administrador para configurações do sistema, nas opções avançadas em Ajustes do Sistema → Privacidade e Segurança.", + "directFallback": "Falha ao definir o proxy do sistema; o tráfego foi revertido para conexão direta.", + "guardStopped": "A proteção do proxy do sistema parou após falhar várias vezes ao executar. O proxy do sistema não está mais sendo mantido.", + "sidecarWhileServiceReady": "Não foi possível definir o proxy do sistema porque o núcleo ainda está em execução como Sidecar, embora o serviço do Clash Verge esteja pronto. Reinicie o núcleo para mudar para o modo serviço.", + "coreNotReady": "Não foi possível ativar o proxy do sistema porque o núcleo não está em execução. Inicie o núcleo (ou instale/repare o serviço do Clash Verge) e tente novamente.", + "systemCallFailed": "Não foi possível gravar o proxy do sistema porque uma chamada de sistema do Windows falhou. Geralmente é temporário, então tente novamente." + } + }, + "notifications": { + "clash": { + "restartSuccess": "Núcleo do Clash Reiniciado", + "versionUpdated": "Versão do Núcleo Atualizada", + "alreadyLatestVersion": "Já Está Usando a Versão Mais Recente do Núcleo", + "changeSuccess": "Núcleo alterado com sucesso", + "changeFailed": "Falha ao alterar o núcleo", + "geoDataUpdated": "GeoData Atualizado" + }, + "clashService": { + "appDataNotOwned": "O serviço se recusou a iniciar o núcleo porque, no disco, a pasta de dados do aplicativo não pertence à sua conta (comum quando a pasta pessoal está em um disco externo). Reparar o serviço não resolverá isso. Execute este comando em um terminal e reinicie o aplicativo:", + "installSuccess": "Serviço Instalado com Sucesso", + "permissionFallback": "{{reason}} Mudou para o modo normal (Sidecar); o TUN requer privilégios de administrador. Siga o guia para corrigir e reinstalar o serviço. Se preferir não corrigir ou não souber como, execute o aplicativo como administrador sem instalar o serviço.", + "permissionWritableReason": "Contas comuns podem modificar os arquivos do núcleo ou uma pasta superior, então o serviço se recusou a executar o núcleo com privilégios elevados.", + "permissionRejectedReason": "O serviço não conseguiu confirmar que os arquivos e pastas do núcleo são seguros para execução com privilégios elevados.", + "permissionRepairGuide": "Ver instruções de reparo", + "sidecarFallback": "O serviço não conseguiu iniciar o núcleo. Mudou para o modo Sidecar; o serviço continua indisponível.", + "uninstallSuccess": "Serviço Desinstalado com Sucesso" + }, + "updater": { + "withClashProxySuccess": "Atualização com o proxy do Clash concluída com sucesso", + "withClashProxyFailed": "A atualização falhou mesmo com o proxy do Clash" + } + } + }, + "statuses": { + "clash": { + "stopping": "Parando o Núcleo...", + "restarting": "Reiniciando o Núcleo..." + }, + "clashService": { + "installing": "Instalando o Serviço...", + "uninstalling": "Desinstalando o Serviço..." + } + } +} diff --git a/src/locales/pt/shared.json b/src/locales/pt/shared.json new file mode 100644 index 000000000..c6840b87e --- /dev/null +++ b/src/locales/pt/shared.json @@ -0,0 +1,139 @@ +{ + "actions": { + "cancel": "Cancelar", + "close": "Fechar", + "confirm": "Confirmar", + "save": "Salvar", + "delete": "Excluir", + "edit": "Editar", + "new": "Novo", + "enable": "Ativar", + "upgrade": "Atualizar versão", + "restart": "Reiniciar", + "resetToDefault": "Restaurar padrão", + "refresh": "Atualizar", + "retry": "Tentar novamente", + "refreshPage": "Recarregar página", + "showDetails": "Mostrar detalhes", + "hideDetails": "Ocultar detalhes", + "listView": "Visualização em lista", + "tableView": "Visualização em tabela", + "pause": "Pausar", + "resume": "Retomar", + "closeAll": "Fechar tudo", + "clear": "Limpar", + "previous": "Anterior", + "next": "Próximo", + "check": "Verificar" + }, + "labels": { + "updateAt": "Atualizado em", + "timeout": "Tempo limite", + "icon": "Ícone", + "name": "Nome", + "readOnly": "Somente leitura", + "expireTime": "Expira em", + "updateTime": "Hora da atualização", + "usedTotal": "Usado / Total", + "from": "De", + "password": "Senha", + "retryAttempts": "Tentativas", + "downloaded": "Baixado", + "uploaded": "Enviado" + }, + "statuses": { + "enabled": "Ativado", + "disabled": "Desativado", + "saving": "Salvando...", + "empty": "Vazio", + "loading": "Carregando..." + }, + "units": { + "milliseconds": "ms", + "seconds": "segundos", + "minutes": "min", + "hours": "h", + "kilobytes": "KB", + "files": "Arquivos" + }, + "placeholders": { + "resetInput": "Limpar entrada", + "filter": "Condições do filtro", + "matchCase": "Diferenciar maiúsculas", + "matchWholeWord": "Palavra inteira", + "useRegex": "Usar expressão regular" + }, + "validation": { + "invalidRegex": "Expressão regular inválida" + }, + "window": { + "maximize": "Maximizar", + "minimize": "Minimizar" + }, + "editorModes": { + "visualization": "Visualização", + "advanced": "Avançado" + }, + "feedback": { + "errors": { + "unexpected": "Algo deu errado", + "label": "Erro", + "unknown": "Erro desconhecido", + "details": "Detalhes do erro", + "stack": "Pilha de erros", + "componentStack": "Pilha de componentes", + "trafficUnavailable": "Dados de tráfego indisponíveis", + "operationFailed": "A operação falhou", + "trafficStats": "Erro nas estatísticas de tráfego", + "trafficStatsDescription": "O componente de estatísticas de tráfego encontrou um erro e foi desativado para evitar falhas." + }, + "notices": { + "raw": "{{message}}", + "prefixedRaw": "{{prefix}} {{message}}" + }, + "notifications": { + "importSuccess": "Perfil importado com sucesso", + "importSubscriptionSuccess": "Assinatura importada com sucesso", + "importWithClashProxy": "Perfil importado com o proxy do Clash", + "updateAvailable": "Atualização disponível", + "saved": "Salvo com sucesso", + "common": { + "copySuccess": "Copiado com sucesso", + "saveSuccess": "Configuração salva com sucesso", + "saveFailed": "Falha ao salvar a configuração", + "refreshFailed": "Falha ao atualizar; exibindo o último valor carregado" + } + }, + "validation": { + "config": { + "failed": "A validação da configuração da assinatura falhou. Verifique o arquivo de configuração da assinatura; as modificações foram revertidas.", + "bootFailed": "A validação da configuração da assinatura na inicialização falhou. Iniciado com a configuração padrão; verifique o arquivo de configuração da assinatura.", + "processTerminated": "O processo de validação foi encerrado. Verifique se algum software de segurança está impedindo a execução do núcleo ou se a CPU e o ambiente de execução do seu computador são compatíveis com o executável do núcleo." + }, + "script": { + "syntaxError": "Erro de sintaxe no script, alterações revertidas", + "missingMain": "Erro no script, alterações revertidas", + "fileNotFound": "Arquivo ausente, alterações revertidas", + "fileError": "Erro no arquivo de script, alterações revertidas" + }, + "yaml": { + "syntaxError": "Erro de sintaxe YAML, alterações revertidas", + "readError": "Erro de leitura YAML, alterações revertidas", + "mappingError": "Erro de mapeamento YAML, alterações revertidas" + }, + "merge": { + "syntaxError": "Erro de sintaxe no arquivo de mesclagem, alterações revertidas", + "mappingError": "Erro de mapeamento no arquivo de mesclagem, alterações revertidas" + } + } + }, + "filters": { + "logLevels": { + "all": "ALL", + "debug": "DEBUG", + "info": "INFO", + "warn": "WARN", + "error": "ERROR" + } + } +} diff --git a/src/locales/pt/tests.json b/src/locales/pt/tests.json new file mode 100644 index 000000000..20c04d542 --- /dev/null +++ b/src/locales/pt/tests.json @@ -0,0 +1,60 @@ +{ + "page": { + "actions": { + "testAll": "Testar tudo" + }, + "title": "Testar" + }, + "components": { + "item": { + "actions": { + "test": "Testar" + } + } + }, + "modals": { + "test": { + "title": { + "create": "Criar teste", + "edit": "Editar teste" + }, + "fields": { + "url": "URL de teste" + }, + "errors": { + "nameRequired": "Informe um nome", + "urlRequired": "Informe uma URL de teste", + "invalidSvg": "O ícone não é um SVG válido", + "uidMissing": "O identificador do item de teste está ausente", + "saveFailed": "Falha ao salvar o item de teste" + } + } + }, + "statuses": { + "test": { + "pending": "Pendente", + "yes": "Sim", + "no": "Não", + "failed": "Falhou", + "completed": "Concluído", + "disallowedIsp": "ISP não permitido", + "originalsOnly": "Somente originais", + "noDisney": "Não (IP banido pelo Disney+)", + "unsupportedRegion": "País/região não suportado", + "failedNetwork": "Falhou (conexão de rede)" + } + }, + "unlock": { + "page": { + "actions": { + "testing": "Testando..." + }, + "empty": "Nenhum item de teste de desbloqueio", + "messages": { + "detectionFailedWithName": "Falha na detecção de {{name}}", + "detectionTimeout": "Tempo limite da detecção esgotado ou falhou" + }, + "title": "Teste de desbloqueio" + } + } +} diff --git a/src/services/i18n.ts b/src/services/i18n.ts index d614c5e90..588de9ea5 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -15,6 +15,7 @@ export const supportedLanguages = [ 'es', 'jp', 'zhtw', + 'pt', ] export const FALLBACK_LANGUAGE = 'zh' From d9dc18a57b5837c5979b1be6e88a493c2768a61d Mon Sep 17 00:00:00 2001 From: MrHard <74858909+mrhard9090@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:31:57 +0000 Subject: [PATCH 03/15] fix(profiles): cap the profile grid at four columns (#8068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(profiles): cap the profile grid at four columns The move to CSS grid in #7819 let wide windows fit six or more profile cards per row, so long profile names were cut off. Keep the 260px minimum width but never use more than four columns, as the old Grid layout did. * fix(profiles): let very wide windows use more than four columns --------- Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com> --- Changelog.md | 1 + src/pages/profiles.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index 139b72ea6..62a5a1b29 100644 --- a/Changelog.md +++ b/Changelog.md @@ -10,6 +10,7 @@ - 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题 - 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题 - 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题 +- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题 **🖥️ Windows** diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 85d7d78b2..78c38f973 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -888,7 +888,9 @@ const ProfilePage = () => { mb: 1.5, display: 'grid', overflow: 'hidden', - gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', + // Up to 4 columns until a column would pass 480px, then more; 24px is the 3 gaps from `gap: 1`. + gridTemplateColumns: + 'repeat(auto-fill, minmax(clamp(260px, calc((100% - 24px) / 4), 480px), 1fr))', gap: 1, px: 0.5, }} From cb751052a423c017e75cf0379adb12848e3f0fae Mon Sep 17 00:00:00 2001 From: LokHsu <71050409+LokHsu@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:11:07 +0800 Subject: [PATCH 04/15] feat(tray): add a per-group latency test (#8173) * feat(tray): add a per-group latency test The tray's proxy group submenus offered no way to test a group: refreshing the delays shown there meant opening the dashboard and running a test from the proxy page. Add a "Test Latency" entry at the head of each group submenu. It measures the group through the proxy page's own view, so member resolution and provider attribution match the page exactly: provider nodes are health checked on their provider, anything else goes through the proxy delay endpoint, and members the page cannot resolve are skipped. Nested groups are therefore tested through their currently selected node, as the page does. The menu is rebuilt when the run finishes. The test URL follows the page's chain -- the group's own test URL, then the configured default, then the built-in one -- including the page's validity check, so a group with a malformed URL falls back where the page falls back. A group test started from the app rebuilds the tray as well, so the two views do not drift. Co-Authored-By: Claude * fix(tray): keep latency tests and displayed results consistent * fix(tray): isolate latency histories and preserve usable menus * fix(tray): keep settings usable when proxy reads fail * fix(tray): show selected node latency for proxy groups and display timeouts as T/O Co-authored-by: wonfen * fix(tray): refresh proxy latency on menu interaction --------- Co-authored-by: Claude Co-authored-by: wonfen --- Changelog.md | 7 + crates/clash-verge-i18n/locales/ar.yml | 2 + crates/clash-verge-i18n/locales/de.yml | 2 + crates/clash-verge-i18n/locales/en.yml | 2 + crates/clash-verge-i18n/locales/es.yml | 2 + crates/clash-verge-i18n/locales/fa.yml | 2 + crates/clash-verge-i18n/locales/id.yml | 2 + crates/clash-verge-i18n/locales/jp.yml | 2 + crates/clash-verge-i18n/locales/ko.yml | 2 + crates/clash-verge-i18n/locales/ru.yml | 2 + crates/clash-verge-i18n/locales/tr.yml | 2 + crates/clash-verge-i18n/locales/tt.yml | 2 + crates/clash-verge-i18n/locales/zh.yml | 2 + crates/clash-verge-i18n/locales/zhtw.yml | 2 + src-tauri/src/cmd/proxy.rs | 16 +- src-tauri/src/config/prfitem.rs | 5 + src-tauri/src/config/profiles.rs | 1 + src-tauri/src/core/proxy_view.rs | 75 ++- src-tauri/src/core/tray/latency.rs | 156 ++++++ src-tauri/src/core/tray/menu_def.rs | 1 + src-tauri/src/core/tray/mod.rs | 493 +++++++++++++----- src/components/home/current-proxy-card.tsx | 23 + src/components/proxy/proxy-group-tools.tsx | 17 +- src/components/proxy/proxy-groups.tsx | 8 + src/components/proxy/proxy-head.tsx | 17 +- src/components/proxy/proxy-test-url-input.tsx | 55 ++ src/components/proxy/use-filter-sort.test.ts | 17 +- src/components/proxy/use-head-state.ts | 78 ++- src/hooks/use-profiles.ts | 7 +- src/hooks/use-proxy-delay-state.ts | 21 +- src/services/delay.test.ts | 137 +++++ src/services/delay.ts | 96 ++-- src/services/proxy-test-url.test.ts | 103 ++++ src/services/proxy-test-url.ts | 101 ++++ src/types/global.d.ts | 1 + src/types/proxy-view.ts | 2 + src/utils/network.ts | 13 + 37 files changed, 1251 insertions(+), 225 deletions(-) create mode 100644 src-tauri/src/core/tray/latency.rs create mode 100644 src/components/proxy/proxy-test-url-input.tsx create mode 100644 src/services/proxy-test-url.test.ts create mode 100644 src/services/proxy-test-url.ts diff --git a/Changelog.md b/Changelog.md index 62a5a1b29..64ac74ab7 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,8 @@
🐞 修复问题 +- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题 +- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题 - 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 - 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 - 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 @@ -22,11 +24,16 @@ - 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 +**🖥️/🍎 Windows/macOS** + +- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题 +
✨ 新增功能 +- 新增系统托盘代理组中的「测试延迟」菜单项
diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index 0441ea1be..4fa132513 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -68,6 +68,8 @@ tray: global: عام profiles: الملفات التعريفية proxies: وكلاء + testLatency: اختبار التأخير + testingLatency: جارٍ اختبار التأخير… systemProxy: وكيل النظام tunMode: وضع TUN closeAllConnections: إغلاق كل الاتصالات diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index 6bd24bd95..d31a24f07 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Profile proxies: Proxy + testLatency: Latenz testen + testingLatency: Latenz wird getestet… systemProxy: Systemproxy tunMode: TUN-Modus closeAllConnections: Alle Verbindungen schließen diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index 4a32fdc65..82aa2eb64 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Profiles proxies: Proxies + testLatency: Test Latency + testingLatency: Testing latency… systemProxy: System Proxy tunMode: TUN Mode closeAllConnections: Close All Connections diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index 87f115333..4689a3b35 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Perfiles proxies: Proxies + testLatency: Probar latencia + testingLatency: Probando latencia… systemProxy: Proxy del sistema tunMode: Modo TUN closeAllConnections: Cerrar todas las conexiones diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index 8c538a4a7..6925a3834 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -68,6 +68,8 @@ tray: global: سراسری profiles: پروفایل‌ها proxies: پروکسی‌ها + testLatency: آزمایش تأخیر + testingLatency: در حال آزمایش تأخیر… systemProxy: پروکسی سیستم tunMode: حالت TUN closeAllConnections: بستن همه اتصال‌ها diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index 00c4788be..ef9e39414 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -68,6 +68,8 @@ tray: global: Global profiles: Profil proxies: Proksi + testLatency: Uji Latensi + testingLatency: Menguji latensi… systemProxy: Proksi Sistem tunMode: Mode TUN closeAllConnections: Tutup Semua Koneksi diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index eea0c41d1..723cb3fb3 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -68,6 +68,8 @@ tray: global: グローバル profiles: プロファイル proxies: プロキシ + testLatency: 遅延テスト + testingLatency: 遅延をテスト中… systemProxy: システムプロキシ tunMode: TUN モード closeAllConnections: すべての接続を閉じる diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index 7166e8c72..ef15f6517 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -68,6 +68,8 @@ tray: global: 글로벌 profiles: 프로필 proxies: 프록시 + testLatency: 지연 테스트 + testingLatency: 지연 테스트 중… systemProxy: 시스템 프록시 tunMode: TUN 모드 closeAllConnections: 모든 연결 닫기 diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 2c931fa1d..990920f72 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -68,6 +68,8 @@ tray: global: Глобальный profiles: Профили proxies: Прокси + testLatency: Проверить задержку + testingLatency: Проверка задержки… systemProxy: Системный прокси tunMode: Режим TUN closeAllConnections: Закрыть все соединения diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index cf3c666a4..7587ab2fe 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -68,6 +68,8 @@ tray: global: Küresel profiles: Profiller proxies: Vekil'ler + testLatency: Gecikmeyi Test Et + testingLatency: Gecikme test ediliyor… systemProxy: Sistem Vekil'i tunMode: TUN Modu closeAllConnections: Tüm Bağlantıları Kapat diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index 4067dfdd7..a9d8fcb3d 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -68,6 +68,8 @@ tray: global: Глобаль profiles: Профильләр proxies: Проксилар + testLatency: Кичектерүне тикшерү + testingLatency: Кичектерү тикшерелә… systemProxy: Системалы прокси tunMode: TUN режимы closeAllConnections: Барлык тоташуларны ябу diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index 8774cbefb..ca2afd3ce 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -68,6 +68,8 @@ tray: global: 全局 profiles: 订阅 proxies: 代理 + testLatency: 测试延迟 + testingLatency: 正在测试延迟… systemProxy: 系统代理 tunMode: TUN 模式 closeAllConnections: 关闭所有连接 diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index da73ba929..bebc5c058 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -68,6 +68,8 @@ tray: global: 全域 profiles: 訂閱 proxies: 代理 + testLatency: 測試延遲 + testingLatency: 正在測試延遲… systemProxy: 系統代理 tunMode: 虛擬網路介面卡模式 closeAllConnections: 關閉所有連線 diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index d91ae9f4e..f22c38e9b 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -105,20 +105,32 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap CmdResult { + proxy_view(None).await +} + +pub(crate) async fn proxy_view(provider_timeout: Option) -> CmdResult { let runtime = Config::runtime().await; let latest_runtime = runtime.latest_arc(); let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref()); let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref()); let mihomo = Handle::mihomo(); - let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),); + let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async { + match provider_timeout { + Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers()) + .await + .ok() + .and_then(Result::ok), + None => mihomo.get_proxy_providers().await.ok(), + } + }); let proxies = proxies.stringify_err()?; Ok(ProxyViewBuilder::build(ProxyViewInput { runtime_group_order, group_scopes, proxies, - providers: providers.ok(), + providers, })) } diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index f7c84de55..d09e14ed0 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -48,6 +48,9 @@ pub struct PrfItem { #[serde(skip_serializing_if = "Option::is_none")] pub selected: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_test_urls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option, @@ -217,6 +220,7 @@ impl PrfItem { desc: Some(desc), file: Some(file), url: None, + latency_test_urls: None, selected: None, extra: None, option: Some(PrfOption { @@ -396,6 +400,7 @@ impl PrfItem { desc: desc.cloned(), file: Some(file), url: Some(url.as_str().into()), + latency_test_urls: None, selected: None, extra, option: Some(PrfOption { diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index 30f1c9c1b..793619e7d 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -248,6 +248,7 @@ impl IProfiles { patch!(each, item, file); patch!(each, item, url); patch!(each, item, selected); + patch!(each, item, latency_test_urls); patch!(each, item, extra); patch!(each, item, updated); patch!(each, item, option); diff --git a/src-tauri/src/core/proxy_view.rs b/src-tauri/src/core/proxy_view.rs index 4506bd0ce..9d9db585c 100644 --- a/src-tauri/src/core/proxy_view.rs +++ b/src-tauri/src/core/proxy_view.rs @@ -1,8 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use serde::Serialize; use tauri_plugin_mihomo::models::{ - DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, + DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, }; pub struct ProxyViewInput { @@ -75,6 +75,7 @@ pub struct ProxyGroupView { #[serde(skip_serializing_if = "Option::is_none")] pub test_url: Option, pub history: Vec, + pub extra: HashMap, #[serde(flatten)] pub capabilities: ProxyCapabilities, pub members: Vec, @@ -89,6 +90,7 @@ pub struct ProxyNodeView { pub proxy_type: ProxyType, pub alive: bool, pub history: Vec, + pub extra: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -254,7 +256,7 @@ impl ProxyViewBuilder { } else { ProxyViewProviderState::Unavailable }; - let (mut core_groups, core_nodes) = partition_core(proxies); + let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some()); let (mut records, core_node_ids) = build_core_records(core_nodes); let (providers, provider_candidates) = build_provider_records(providers, &mut records); let resolver = MemberResolver { @@ -287,14 +289,14 @@ impl ProxyViewBuilder { } } -fn partition_core(proxies: Proxies) -> (BTreeMap, BTreeMap) { +fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap, BTreeMap) { let mut groups = BTreeMap::new(); let mut nodes = BTreeMap::new(); for (name, proxy) in proxies.proxies { if proxy.all.is_some() { groups.insert(name, proxy); - } else { + } else if !providers_available || proxy.provider_name.is_empty() { nodes.insert(name, proxy); } } @@ -402,6 +404,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro test_url, alive, history, + extra, udp, xudp, tfo, @@ -435,6 +438,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro icon, test_url, history, + extra, capabilities: ProxyCapabilities { udp, xudp, @@ -454,6 +458,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo test_url, alive, history, + extra, udp, xudp, tfo, @@ -469,6 +474,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo proxy_type, alive, history, + extra, id, hidden, icon, @@ -522,3 +528,62 @@ fn build_standalone(core_node_ids: &BTreeMap) -> Vec { ); standalone } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> { + let view = ProxyViewBuilder::build(ProxyViewInput { + runtime_group_order: vec!["A".into(), "B".into()], + group_scopes: ["A", "B"] + .into_iter() + .map(|name| { + ( + name.into(), + GroupScope { + providers: vec![name.into()], + exclude_types: vec![], + }, + ) + }) + .collect(), + proxies: serde_json::from_value(json!({"proxies": { + "A": {"all": ["node"], "type": "Selector"}, + "B": {"all": ["node"], "type": "Selector"}, + "node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]} + }}))?, + providers: Some(serde_json::from_value(json!({"providers": { + "A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]}, + "B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]} + }}))?), + }); + for (group, expected) in view.groups.iter().zip([32, 123]) { + let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else { + anyhow::bail!("Expected node") + }; + let record = &view.records[record_id]; + assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected)); + assert!( + matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name) + ); + } + Ok(()) + } + #[test] + fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> { + let view = ProxyViewBuilder::build(ProxyViewInput { + runtime_group_order: vec!["A".into()], + group_scopes: BTreeMap::new(), + proxies: serde_json::from_value(json!({"proxies": { + "A": {"all": ["node"], "type": "Selector"}, + "node": {"name": "node", "provider-name": "provider"} + }}))?, + providers: None, + }); + assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. })); + Ok(()) + } +} diff --git a/src-tauri/src/core/tray/latency.rs b/src-tauri/src/core/tray/latency.rs new file mode 100644 index 000000000..5c1106ede --- /dev/null +++ b/src-tauri/src/core/tray/latency.rs @@ -0,0 +1,156 @@ +use super::Tray; +use crate::{ + Type, cmd, + config::Config, + core::{ + handle, + notify::{Refresh, announce}, + proxy_view::{ProxyMemberRef, ProxyNodeSource}, + }, + logging, +}; + +const TRAY_DELAY_TEST_CONCURRENCY: usize = 10; +const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204"; + +fn is_latency_test_url(raw: &str) -> bool { + let raw = raw.trim(); + raw.split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) + && tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https")) +} + +pub(super) fn test_url( + group: &crate::core::proxy_view::ProxyGroupView, + verge: &crate::config::IVerge, + profiles: &crate::config::IProfiles, +) -> String { + let custom_url = profiles + .current + .as_ref() + .and_then(|uid| profiles.get_item(uid).ok()) + .and_then(|profile| profile.latency_test_urls.as_ref()) + .and_then(|urls| urls.get(group.name.as_str())) + .map(|url| url.as_str()) + .filter(|url| !url.trim().is_empty()); + let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty()); + let default_url = verge + .default_latency_test + .as_deref() + .filter(|url| !url.trim().is_empty()); + custom_url + .or(group_url) + .or(default_url) + .filter(|&url| is_latency_test_url(url)) + .unwrap_or(TRAY_DEFAULT_LATENCY_URL) + .trim() + .to_owned() +} + +static RUNNING: parking_lot::Mutex> = + parking_lot::Mutex::new(std::collections::BTreeSet::new()); + +pub(super) fn is_running(group_name: &str) -> bool { + RUNNING.lock().contains(group_name) +} + +pub(super) async fn test_proxy_group_delay(group_name: &str) { + if !RUNNING.lock().insert(group_name.to_owned()) { + return; + } + Tray::global().refresh_latency_item(group_name); + let _running = scopeguard::guard(group_name, |name| { + RUNNING.lock().remove(name); + Tray::global().refresh_latency_item(name); + }); + use futures::{StreamExt as _, stream}; + + let view = match cmd::proxy::get_proxy_view().await { + Ok(view) => view, + Err(err) => { + logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}"); + return; + } + }; + let verge = Config::verge().await.latest_arc(); + + let group = view + .groups + .iter() + .chain(view.global.iter()) + .find(|group| group.name.as_str() == group_name); + let Some(group) = group else { + logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}"); + return; + }; + + let profiles = Config::profiles().await.latest_arc(); + let url = test_url(group, &verge, &profiles); + let timeout = verge + .default_latency_timeout + .filter(|timeout| *timeout > 0) + .unwrap_or(10000) as u32; + + let mut targets: Vec<(std::string::String, Option)> = Vec::with_capacity(group.members.len()); + for member in &group.members { + match member { + ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) { + Some(ProxyNodeSource::Provider { + provider_name, + proxy_name, + }) => targets.push((proxy_name.clone(), Some(provider_name.clone()))), + _ => targets.push((name.clone(), None)), + }, + ProxyMemberRef::Group { name } => targets.push((name.clone(), None)), + ProxyMemberRef::Unresolved { .. } => {} + } + } + let mihomo = handle::Handle::mihomo(); + + stream::iter(targets.into_iter().map(move |(member, provider)| { + let url = url.clone(); + async move { + let result = match provider { + Some(provider_name) => { + mihomo + .healthcheck_node_in_provider(&provider_name, &member, &url, timeout) + .await + } + None => mihomo.delay_proxy_by_name(&member, &url, timeout).await, + }; + if let Err(err) = result { + logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}"); + } + } + })) + .buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY) + .for_each(|()| std::future::ready(())) + .await; + + if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await { + logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}"); + } + + announce(Refresh::Proxies); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latency_urls_require_a_valid_http_host_and_port() { + for invalid in [ + "http://?", + "http://host:invalid", + "http://bad host/", + "ftp://localhost", + "http:localhost", + ] { + assert!(!is_latency_test_url(invalid), "{invalid}"); + } + for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] { + assert!(is_latency_test_url(valid), "{valid}"); + } + } +} diff --git a/src-tauri/src/core/tray/menu_def.rs b/src-tauri/src/core/tray/menu_def.rs index 7012ce219..6c9641773 100644 --- a/src-tauri/src/core/tray/menu_def.rs +++ b/src-tauri/src/core/tray/menu_def.rs @@ -32,6 +32,7 @@ define_menu! { outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes", profiles => PROFILES, "tray_profiles", "tray.profiles", proxies => PROXIES, "tray_proxies", "tray.proxies", + test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency", system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy", tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode", close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections", diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 326f51fa0..8348b67a0 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -1,5 +1,6 @@ use crate::config::{IProfilePreview, IVerge}; use crate::core::lightweight; +use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1}; use crate::core::tray::menu_def::TrayAction; use crate::process::AsyncHandler; use crate::singleton; @@ -14,7 +15,6 @@ use crate::{ use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use clash_verge_logging::logging_error; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; -use tauri_plugin_mihomo::models::Proxies; use tokio::fs; use super::handle; @@ -28,6 +28,7 @@ use tauri::{ menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; +mod latency; mod menu_def; #[cfg(target_os = "macos")] mod speed_task; @@ -55,8 +56,25 @@ enum IconKind { Tun, } +struct LatencyMenuItem { + item: MenuItem, + running: bool, + proxies: Vec, + provider_state: ProxyViewProviderState, +} + +struct ProxyLatencyItem { + item: CheckMenuItem, + name: std::string::String, + provider_name: Option, + text: std::string::String, +} + pub struct Tray { limiter: SystemLimiter, + proxy_hover_limiter: SystemLimiter, + menu_update: tokio::sync::Mutex<()>, + latency_items: parking_lot::Mutex>, #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController, } @@ -130,6 +148,9 @@ impl Default for Tray { fn default() -> Self { Self { limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), + proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock), + menu_update: tokio::sync::Mutex::new(()), + latency_items: parking_lot::Mutex::new(HashMap::new()), #[cfg(target_os = "macos")] speed_controller: speed_task::TraySpeedController::new(), } @@ -195,6 +216,7 @@ impl Tray { } async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> { + let _update = self.menu_update.lock().await; let Some(tray) = app_handle.tray_by_id(TRAY_ID) else { logging!(warn, Type::Tray, "Failed to update tray menu: tray not found"); return Ok(()); @@ -219,29 +241,133 @@ impl Tray { let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default(); let is_lightweight_mode = is_in_lightweight_mode(); - logging_error!( - Type::Tray, - tray.set_menu(Some( - create_tray_menu( - app_handle, - Some(mode.as_str()), - *system_proxy, - *tun_mode, - tun_mode_available, - profiles_preview, - TrayMenuOptions { - is_lightweight_mode, - include_proxy_groups, - }, - ) - .await?, - )) - ); + let (menu, latency_items) = create_tray_menu( + app_handle, + Some(mode.as_str()), + *system_proxy, + *tun_mode, + tun_mode_available, + profiles_preview, + TrayMenuOptions { + is_lightweight_mode, + include_proxy_groups, + }, + ) + .await?; + tray.set_menu(Some(menu))?; + let groups: Vec<_> = latency_items.keys().cloned().collect(); + *self.latency_items.lock() = latency_items; + for group in groups { + self.refresh_latency_item(&group); + } logging!(debug, Type::Tray, "托盘菜单更新成功"); Ok(()) } + async fn refresh_proxy_items(&'static self) { + let Ok(update) = self.menu_update.try_lock() else { + return; + }; + if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() { + return; + } + let Ok(Ok(view)) = tokio::time::timeout( + Duration::from_secs(1), + cmd::proxy::proxy_view(Some(Duration::from_millis(750))), + ) + .await + else { + return; + }; + if self + .latency_items + .lock() + .values() + .any(|entry| entry.provider_state != view.provider_state) + { + // Provider fallback changes member identities; rebuild through the existing queue. + drop(update); + logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await); + return; + } + let verge = Config::verge().await.latest_arc(); + let profiles = Config::profiles().await.latest_arc(); + logging_error!( + Type::Tray, + handle::Handle::app_handle().run_on_main_thread(move || { + // Keep rebuilding serialized until these handles have received their snapshot. + let _update = update; + let mut items = self.latency_items.lock(); + for (group_name, entry) in items.iter_mut() { + let Some(group) = find_group(&view, group_name) else { + continue; + }; + let url = latency::test_url(group, &verge, &profiles); + let mut members = HashMap::with_capacity(group.members.len()); + for member in &group.members { + members + .entry((member_name(member), member_provider(&view, member))) + .or_insert(member); + } + for proxy in &mut entry.proxies { + let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else { + continue; + }; + let text = proxy_item_text(&view, member, &url, &verge); + let checked = group.now.as_ref() == Some(&proxy.name); + if text != proxy.text { + match proxy.item.set_text(&text) { + Ok(()) => proxy.text = text, + Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"), + } + } + match proxy.item.is_checked() { + Ok(current) if current != checked => { + logging_error!(Type::Tray, proxy.item.set_checked(checked)) + } + Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"), + _ => {} + } + } + } + }) + ); + } + + fn refresh_latency_item(&self, group_name: &str) { + let group_name = group_name.to_owned(); + logging_error!( + Type::Tray, + handle::Handle::app_handle().run_on_main_thread(move || { + Self::global().refresh_latency_item_on_main_thread(&group_name); + }) + ); + } + + fn refresh_latency_item_on_main_thread(&self, group_name: &str) { + let running = latency::is_running(group_name); + let item = { + let mut items = self.latency_items.lock(); + items.get_mut(group_name).and_then(|entry| { + if entry.running == running { + return None; + } + entry.running = running; + Some(entry.item.clone()) + }) + }; + if let Some(item) = item { + let label = if running { + clash_verge_i18n::t!("tray.testingLatency") + } else { + clash_verge_i18n::t!("tray.testLatency") + }; + logging_error!(Type::Tray, item.set_text(label)); + logging_error!(Type::Tray, item.set_enabled(!running)); + } + } + pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { if handle::Handle::global().is_exiting() { logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); @@ -457,96 +583,175 @@ fn create_profile_menu_item( .collect() } +const UNTESTED_DELAY_TEXT: &str = "-ms"; +const TIMEOUT_DELAY_TEXT: &str = "T/O"; +const TRAY_GROUP_DEPTH_LIMIT: usize = 8; + +const fn member_name(member: &ProxyMemberRef) -> &str { + match member { + ProxyMemberRef::Node { name, .. } + | ProxyMemberRef::Group { name } + | ProxyMemberRef::Unresolved { name, .. } => name.as_str(), + } +} + +fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> { + view.groups + .iter() + .chain(view.global.iter()) + .find(|group| group.name.as_str() == name) +} + +/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a +/// node the core tested on its own still shows a value. +fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option { + view.records + .get(record_id) + .map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history)) + .and_then(|history| history.last()) + .map(|history| history.delay) +} + +/// A group row shows the delay of the node the group currently selects. The core never writes a +/// history for the group itself, so `now` is followed until the chain reaches a node. +fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option { + let mut current = group_name; + for _ in 0..TRAY_GROUP_DEPTH_LIMIT { + let Some(group) = find_group(view, current) else { break }; + let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else { + break; + }; + let Some(member) = group.members.iter().find(|member| member_name(member) == now) else { + break; + }; + match member { + ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url), + ProxyMemberRef::Group { name } => current = name.as_str(), + ProxyMemberRef::Unresolved { .. } => break, + } + } + find_group(view, group_name) + .map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history)) + .and_then(|history| history.last()) + .map(|history| history.delay) +} + +fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> { + let ProxyMemberRef::Node { record_id, .. } = member else { + return None; + }; + match &view.records.get(record_id)?.source { + ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name), + ProxyNodeSource::Core { .. } => None, + } +} + +fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String { + let timeout = verge + .default_latency_timeout + .filter(|timeout| *timeout > 0) + .unwrap_or(10000); + let delay = match member { + ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url), + ProxyMemberRef::Group { name } => selected_member_delay(view, name, url), + ProxyMemberRef::Unresolved { .. } => None, + }; + let delay_text = delay.map_or_else( + || UNTESTED_DELAY_TEXT.to_owned(), + |delay| { + if delay == 0 || u32::from(delay) >= timeout as u32 { + TIMEOUT_DELAY_TEXT.to_owned() + } else { + format!("{delay}ms") + } + }, + ); + format!("{} | {delay_text}", member_name(member)) +} + fn create_subcreate_proxy_menu_item( app_handle: &AppHandle, proxy_mode: &str, - proxy_group_order_map: Option>, - proxy_nodes_data: Option, + view: Option, + latency_items: &mut HashMap, + texts: &MenuTexts, + verge: &IVerge, + profiles: &crate::config::IProfiles, ) -> Vec> { - let proxy_submenus: Vec> = { - let mut submenus: Vec<(String, usize, Submenu)> = Vec::new(); - - // TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 - if let Some(proxy_nodes_data) = proxy_nodes_data { - for (group_name, group_data) in proxy_nodes_data.proxies.iter() { - let should_show = match proxy_mode { - "global" => group_name == "GLOBAL", - _ => group_name != "GLOBAL", - } && !group_data.hidden.unwrap_or_default(); - - if !should_show { - continue; - } - - let Some(all_proxies) = group_data.all.as_ref() else { - continue; - }; - - let now_proxy = group_data.now.as_deref().unwrap_or_default(); - - let group_items: Vec> = all_proxies - .iter() - .filter_map(|proxy_str| { - let is_selected = *proxy_str == now_proxy; - let item_id = format!("proxy_{}_{}", group_name, proxy_str); - - let delay_text = proxy_nodes_data - .proxies - .get(proxy_str) - .and_then(|h| h.history.last()) - .map(|h| match h.delay { - 0 => "-ms".into(), - delay if delay >= 10000 => "-ms".into(), - _ => format!("{}ms", h.delay), - }) - .unwrap_or_else(|| "-ms".into()); - - let display_text = format!("{} | {}", proxy_str, delay_text); - - CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>) - .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e)) - .ok() - }) - .collect(); - - if group_items.is_empty() { - continue; - } - - let group_display_name = group_name.to_string(); - - let group_items_refs: Vec<&dyn IsMenuItem> = - group_items.iter().map(|item| item as &dyn IsMenuItem).collect(); - - if let Ok(submenu) = Submenu::with_id_and_items( - app_handle, - format!("proxy_group_{}", group_name), - group_display_name, - true, - &group_items_refs, - ) { - let insertion_index = submenus.len(); - submenus.push((group_name.into(), insertion_index, submenu)); - } else { - logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name); - } + let Some(view) = view else { return Vec::new() }; + view.groups + .iter() + .chain(view.global.iter()) + .filter_map(|group| { + if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") { + return None; } - } - - if let Some(order_map) = proxy_group_order_map.as_ref() { - submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| { - match (order_map.get(name_a), order_map.get(name_b)) { - (Some(index_a), Some(index_b)) => index_a.cmp(index_b), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => original_index_a.cmp(original_index_b), - } - }); - } - - submenus.into_iter().map(|(_, _, submenu)| submenu).collect() - }; - proxy_submenus + let url = latency::test_url(group, verge, profiles); + let proxies: Vec = group + .members + .iter() + .filter_map(|member| { + let name = member_name(member); + let text = proxy_item_text(&view, member, &url, verge); + let checked = group.now.as_deref() == Some(name); + CheckMenuItem::with_id( + app_handle, + format!("proxy_{}_{}", group.name, name), + &text, + true, + checked, + None::<&str>, + ) + .map(|item| ProxyLatencyItem { + item, + name: name.to_owned(), + provider_name: member_provider(&view, member).map(str::to_owned), + text, + }) + .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}")) + .ok() + }) + .collect(); + let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect(); + if group_items.is_empty() { + return None; + } + let running = latency::is_running(&group.name); + let test_item = MenuItem::with_id( + app_handle, + format!("{}_{}", MenuIds::TEST_LATENCY, group.name), + if running { + clash_verge_i18n::t!("tray.testingLatency") + } else { + texts.test_latency.clone() + }, + !running, + None::<&str>, + ) + .ok()?; + latency_items.insert( + group.name.clone(), + LatencyMenuItem { + item: test_item.clone(), + running, + proxies, + provider_state: view.provider_state, + }, + ); + let separator = PredefinedMenuItem::separator(app_handle).ok()?; + let mut items: Vec<&dyn IsMenuItem> = vec![&test_item, &separator]; + items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem)); + Submenu::with_id_and_items( + app_handle, + format!("proxy_group_{}", group.name), + &group.name, + true, + &items, + ) + .map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}")) + .ok() + }) + .collect() } fn create_proxy_menu_item( @@ -593,54 +798,38 @@ async fn create_tray_menu( tun_mode_available: bool, profiles_preview: Vec>, options: TrayMenuOptions, -) -> Result> { +) -> Result<(tauri::menu::Menu, HashMap)> { let current_proxy_mode = mode.unwrap_or(""); let mut verge_settings = Config::verge().await.latest_arc(); let fetch_proxy_groups = options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable"); - // TODO: should update tray menu again when it was timeout error - let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups { - let proxy_nodes_data = - tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies()) - .await - .map_or(None, |res| res.ok()); - - let runtime = Config::runtime().await.latest_arc(); - let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| { - config - .get("proxy-groups") - .and_then(|groups| groups.as_sequence()) - .map(|groups| { - groups - .iter() - .filter_map(|group| group.get("name")) - .filter_map(|name| name.as_str()) - .enumerate() - .map(|(index, name)| (name.into(), index)) - .collect::>() - }) - .unwrap_or_default() - }); - - (proxy_nodes_data, runtime_proxy_groups_order) + // Proxy data must remain optional so a stopped Core cannot roll back unrelated settings. + let proxy_view = if fetch_proxy_groups { + tokio::time::timeout( + Duration::from_millis(1000), + cmd::proxy::proxy_view(Some(Duration::from_millis(750))), + ) + .await + .ok() + .and_then(Result::ok) } else { - (None, None) + None }; if fetch_proxy_groups { verge_settings = Config::verge().await.latest_arc(); } + let profiles_config = Config::profiles().await.latest_arc(); + let tray_proxy_groups_display_mode = verge_settings .tray_proxy_groups_display_mode .as_deref() .unwrap_or("default"); let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable"; - let proxy_group_order_map = runtime_proxy_groups_order; - let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false); let version = env!("CARGO_PKG_VERSION"); @@ -720,9 +909,17 @@ async fn create_tray_menu( &profile_menu_items_refs, )?; + let mut latency_items = HashMap::new(); let (proxies_menu, inline_proxy_items) = if include_proxy_groups { - let proxy_sub_menus = - create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data); + let proxy_sub_menus = create_subcreate_proxy_menu_item( + app_handle, + current_proxy_mode, + proxy_view, + &mut latency_items, + &texts, + &verge_settings, + &profiles_config, + ); match tray_proxy_groups_display_mode { "default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?, @@ -866,10 +1063,25 @@ async fn create_tray_menu( ]); let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?; - Ok(menu) + Ok((menu, latency_items)) } fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) { + let refresh_proxies = match tray_event { + TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(), + TrayIconEvent::Click { + button: MouseButton::Left | MouseButton::Right, + button_state: MouseButtonState::Down, + .. + } => true, + _ => false, + }; + if refresh_proxies { + AsyncHandler::spawn(|| async { + Tray::global().refresh_proxy_items().await; + }); + } + if matches!( tray_event, TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. } @@ -983,6 +1195,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { }; feat::toggle_proxy_profile(profile_index.into()).await; } + id if id.starts_with(MenuIds::TEST_LATENCY) => { + let group_name = id + .strip_prefix(MenuIds::TEST_LATENCY) + .and_then(|rest| rest.strip_prefix('_')); + if let Some(group_name) = group_name { + logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name); + latency::test_proxy_group_delay(group_name).await; + } + } id if id.starts_with("proxy_") => { let rest = match id.strip_prefix("proxy_") { Some(r) => r, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index e9768b7e4..5e1ec2cdf 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -47,7 +47,10 @@ import { useCoreDataStatus, useProxiesData, } from '@/providers/app-data-context' +import { syncTrayProxySelection } from '@/services/cmds' import delayManager from '@/services/delay' +import { mutate } from '@/services/mutate' +import { profileTestUrls } from '@/services/proxy-test-url' import { findCurrentGroupMember, getRecord, @@ -675,6 +678,20 @@ export const CurrentProxyCard = () => { [proxyView], ) + useEffect(() => { + if (!selectedGroup) return + const customUrl = currentProfile + ? profileTestUrls(currentProfile)[selectedGroup.name] + : undefined + delayManager.setUrl( + selectedGroup.name, + customUrl?.trim() || + selectedGroup.testUrl?.trim() || + verge?.default_latency_test?.trim() || + 'http://cp.cloudflare.com/generate_204', + ) + }, [selectedGroup, currentProfile, verge?.default_latency_test]) + const unsortedProxyOptions = useMemo( () => optionsForGroup(selectedGroup), [optionsForGroup, selectedGroup], @@ -907,6 +924,12 @@ export const CurrentProxyCard = () => { } refreshProxy() + mutate(() => syncTrayProxySelection(), { + id: 'sync-tray-selection', + errorNotice: false, + }).catch((error) => { + console.error('[CurrentProxyCard] Failed to sync tray latency:', error) + }) }) const proxyOptions = useMemo( diff --git a/src/components/proxy/proxy-group-tools.tsx b/src/components/proxy/proxy-group-tools.tsx index 44cc8a3da..194b1cde4 100644 --- a/src/components/proxy/proxy-group-tools.tsx +++ b/src/components/proxy/proxy-group-tools.tsx @@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded' import VisibilityRounded from '@mui/icons-material/VisibilityRounded' import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded' import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded' -import { Box, IconButton, type SxProps, TextField } from '@mui/material' +import { Box, IconButton, type SxProps } from '@mui/material' import { useDebounceFn } from 'ahooks' import { memo, useEffect, useRef } from 'react' import { flushSync } from 'react-dom' @@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next' import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' import { showNotice } from '@/services/notice-service' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' import { BaseSearchBox, type SearchState } from '../base' +import { ProxyTestUrlInput } from './proxy-test-url-input' import type { ProxySortType } from './use-filter-sort' import type { HeadState } from './use-head-state' @@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { const inputRef = useRef(null) useEffect(() => { - delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) + delayManager.setUrl( + groupName, + testUrl?.trim() || url?.trim() || defaultLatencyUrl, + ) }, [groupName, testUrl, defaultLatencyUrl, url]) // 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染, @@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { )} {textState === 'url' && ( - onHeadState({ testUrl: e.target.value })} + onCommit={(testUrl) => onHeadState({ testUrl })} sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} @@ -176,7 +181,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { if (testUrl?.trim() && textState !== 'filter') { onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidUrl(testUrl)) { + if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { showNotice.warning('proxies.feedback.warnings.invalidTestUrl') return } diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 2489b2ab0..0b4fa96b1 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles' import { useProxySelection } from '@/hooks/use-proxy-selection' import { useVerge } from '@/hooks/use-verge' import { useProxiesData, useSystemData } from '@/providers/app-data-context' +import { syncTrayProxySelection } from '@/services/cmds' import delayManager from '@/services/delay' +import { mutate } from '@/services/mutate' import { isInteractableMember, resolveMember, @@ -133,6 +135,12 @@ function useProxyRenderState( console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) } finally { onProxies() + mutate(() => syncTrayProxySelection(), { + id: 'sync-tray-selection', + errorNotice: false, + }).catch((error) => { + console.error('[ProxyGroups] Failed to sync tray latency:', error) + }) } }), ) diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx index 4db54f36f..4fa9aefc7 100644 --- a/src/components/proxy/proxy-head.tsx +++ b/src/components/proxy/proxy-head.tsx @@ -11,7 +11,7 @@ import { SortByAlphaRounded, SortRounded, } from '@mui/icons-material' -import { Box, IconButton, TextField, type SxProps } from '@mui/material' +import { Box, IconButton, type SxProps } from '@mui/material' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' import { showNotice } from '@/services/notice-service' import { debugLog } from '@/utils/debug' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' +import { ProxyTestUrlInput } from './proxy-test-url-input' import type { ProxySortType } from './use-filter-sort' import type { HeadState } from './use-head-state' @@ -72,7 +73,10 @@ export const ProxyHead = ({ 'http://cp.cloudflare.com/generate_204' useEffect(() => { - delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) + delayManager.setUrl( + groupName, + testUrl?.trim() || url?.trim() || defaultLatencyUrl, + ) }, [groupName, testUrl, defaultLatencyUrl, url]) return ( @@ -97,7 +101,7 @@ export const ProxyHead = ({ debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`) onHeadState({ textState: 'url' }) } - if (testUrl?.trim() && !isValidUrl(testUrl)) { + if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) { showNotice.warning('proxies.feedback.warnings.invalidTestUrl') return } @@ -188,7 +192,8 @@ export const ProxyHead = ({ )} {textState === 'url' && ( - onHeadState({ testUrl: e.target.value })} + onCommit={(testUrl) => onHeadState({ testUrl })} sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} diff --git a/src/components/proxy/proxy-test-url-input.tsx b/src/components/proxy/proxy-test-url-input.tsx new file mode 100644 index 000000000..287e2f0f5 --- /dev/null +++ b/src/components/proxy/proxy-test-url-input.tsx @@ -0,0 +1,55 @@ +import { TextField, type TextFieldProps } from '@mui/material' +import { useEffect, useEffectEvent, useRef, useState } from 'react' + +import { useProfiles } from '@/hooks/use-profiles' + +type Props = Omit & { + value: string + onCommit: (url: string) => void +} + +export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => { + const { profiles } = useProfiles() + return ( + + ) +} + +const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => { + const [draft, setDraft] = useState(null) + const pendingRef = useRef(null) + const commitOnUnmount = useEffectEvent(() => { + if (pendingRef.current !== null && pendingRef.current !== value) + onCommit(pendingRef.current) + }) + useEffect(() => () => commitOnUnmount(), []) + + return ( + { + pendingRef.current = event.target.value + setDraft(event.target.value) + }} + onBlur={() => { + const next = pendingRef.current + pendingRef.current = null + setDraft(null) + if (next !== null && next !== value) onCommit(next) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + const input = event.target as HTMLInputElement + input.blur() + } + }} + /> + ) +} diff --git a/src/components/proxy/use-filter-sort.test.ts b/src/components/proxy/use-filter-sort.test.ts index 2f8d0d46e..08730cd5e 100644 --- a/src/components/proxy/use-filter-sort.test.ts +++ b/src/components/proxy/use-filter-sort.test.ts @@ -13,11 +13,18 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) => kind: 'node', ref: { kind: 'node', name, recordId: `${memberIndex}` }, node: { + recordId: `${memberIndex}`, history: [{ delay }], + extra: { + 'http://cp.cloudflare.com/generate_204': { + alive: true, + history: [{ time: new Date(0).toISOString(), delay }], + }, + }, source: { kind: 'provider', providerName: `${memberIndex}` }, }, }, - }) as ResolvedMemberOccurrence + }) as unknown as ResolvedMemberOccurrence afterEach(() => vi.restoreAllMocks()) @@ -36,7 +43,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays', for (const cached of [false, true]) { const group = `sort-${cached}` if (cached) { - values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay)) + values.forEach((delay, i) => + delayManager.setDelay(list[i].member, group, delay), + ) } for (const timeout of [10000, 20, 0, NaN]) { const expected = list @@ -66,7 +75,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne node(3, 25), ] const cachedOrder = [list[0], list[2], list[1], list[3]] - delayManager.setDelay('0', 'expiry', 1) + delayManager.setDelay(list[0].member, 'expiry', 1) const get = vi.spyOn(delayManager, 'getDelayFix') expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(get).toHaveBeenCalledTimes(list.length) @@ -80,7 +89,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne list[3], list[0], ]) - delayManager.setDelay('0', 'expiry', 2) + delayManager.setDelay(list[0].member, 'expiry', 2) expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) get.mockClear() filterSort([], 'expiry', '', 1) diff --git a/src/components/proxy/use-head-state.ts b/src/components/proxy/use-head-state.ts index 5d30c5e1c..d536564cc 100644 --- a/src/components/proxy/use-head-state.ts +++ b/src/components/proxy/use-head-state.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useReducer } from 'react' import { useProfiles } from '@/hooks/use-profiles' +import { + profileTestUrls, + commitProfileTestUrl, +} from '@/services/proxy-test-url' import { ProxySortType } from './use-filter-sort' @@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = { } type HeadStateAction = - | { type: 'reset' } - | { type: 'replace'; payload: Record } + | { type: 'reset'; profile: string } + | { type: 'replace'; profile: string; payload: Record } | { type: 'update'; groupName: string; patch: Partial } + | { type: 'urls'; urls: Record } + +interface LoadedHeadState { + profile: string | null + heads: Record +} function headStateReducer( - state: Record, + state: LoadedHeadState, action: HeadStateAction, -): Record { +): LoadedHeadState { switch (action.type) { case 'reset': - return {} + return { profile: action.profile, heads: {} } case 'replace': - return action.payload + return { profile: action.profile, heads: action.payload } + case 'urls': { + let heads = state.heads + for (const [name, testUrl] of Object.entries(action.urls)) { + if (heads[name]?.testUrl === testUrl) continue + heads = { + ...heads, + [name]: { ...DEFAULT_STATE, ...heads[name], testUrl }, + } + } + return heads === state.heads ? state : { ...state, heads } + } case 'update': { - const prev = state[action.groupName] || DEFAULT_STATE - return { ...state, [action.groupName]: { ...prev, ...action.patch } } + const prev = state.heads[action.groupName] || DEFAULT_STATE + return { + ...state, + heads: { + ...state.heads, + [action.groupName]: { ...prev, ...action.patch }, + }, + } } default: return state @@ -55,30 +82,42 @@ function headStateReducer( } export function useHeadStateNew() { - const { profiles } = useProfiles() + const { profiles, current: profile } = useProfiles() const current = profiles?.current || '' - const [state, dispatch] = useReducer(headStateReducer, {}) + const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer( + headStateReducer, + { profile: null, heads: {} }, + ) useEffect(() => { + const urls = profile ? profileTestUrls(profile) : {} + if (loadedProfile === current) { + dispatch({ type: 'urls', urls }) + return + } try { const data = JSON.parse( - localStorage.getItem(HEAD_STATE_KEY)!, + localStorage.getItem(HEAD_STATE_KEY) || '{}', ) as HeadStateStorage - const value = data[current] || {} + const value = data?.[current] || {} + for (const [name, testUrl] of Object.entries(urls)) { + value[name] = { ...DEFAULT_STATE, ...value[name], testUrl } + } if (value && typeof value === 'object') { - dispatch({ type: 'replace', payload: value }) + dispatch({ type: 'replace', profile: current, payload: value }) } else { - dispatch({ type: 'reset' }) + dispatch({ type: 'reset', profile: current }) } } catch { - dispatch({ type: 'reset' }) + dispatch({ type: 'reset', profile: current }) } - }, [current]) + }, [current, loadedProfile, profile]) useEffect(() => { + if (loadedProfile !== current) return const timer = setTimeout(() => { try { const item = localStorage.getItem(HEAD_STATE_KEY) @@ -94,13 +133,16 @@ export function useHeadStateNew() { }) return () => clearTimeout(timer) - }, [state, current]) + }, [state, current, loadedProfile]) const setHeadState = useCallback( (groupName: string, obj: Partial) => { dispatch({ type: 'update', groupName, patch: obj }) + if (current && obj.testUrl !== undefined) { + void commitProfileTestUrl(current, groupName, obj.testUrl) + } }, - [], + [current], ) return [state, setHeadState] as const diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 306013afa..f4416bd76 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,7 +1,8 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { mutate } from '@/services/mutate' +import { migrateProfileTestUrls } from '@/services/proxy-test-url' import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client' import { debugLog } from '@/utils/debug' @@ -34,6 +35,10 @@ export const useProfiles = () => { refetchInterval: false, }) + useEffect(() => { + if (profiles) migrateProfileTestUrls(profiles) + }, [profiles]) + const refetchRef = useRef(refetch) refetchRef.current = refetch const mutateProfiles = useCallback(async () => { diff --git a/src/hooks/use-proxy-delay-state.ts b/src/hooks/use-proxy-delay-state.ts index f8a07f299..8b999edba 100644 --- a/src/hooks/use-proxy-delay-state.ts +++ b/src/hooks/use-proxy-delay-state.ts @@ -34,7 +34,6 @@ export function useProxyDelayState( groupName: string, ): UseProxyDelayState { const name = member.ref.name - const details = memberDetails(member) const unresolved = member.kind === 'unresolved' const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name) const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY) @@ -43,18 +42,18 @@ export function useProxyDelayState( useEffect(() => { if (isPreset) return - delayManager.setListener(name, groupName, setDelayState) + delayManager.setListener(member, groupName, setDelayState) return () => { - delayManager.removeListener(name, groupName) + delayManager.removeListener(member, groupName) } - }, [name, groupName, isPreset]) + }, [member, groupName, isPreset]) const updateDelay = useCallback(() => { if (unresolved) { setDelayState(INITIAL_DELAY) return } - const cachedUpdate = delayManager.getDelayUpdate(name, groupName) + const cachedUpdate = delayManager.getDelayUpdate(member, groupName) if (cachedUpdate) { setDelayState({ ...cachedUpdate }) return @@ -67,7 +66,9 @@ export function useProxyDelayState( } let updatedAt = 0 - const history = details?.history + const history = + delayManager.getHistory(member, groupName) ?? + memberDetails(member)?.history if (history && history.length > 0) { const lastRecord = history[history.length - 1] const parsed = Date.parse(lastRecord.time) @@ -77,16 +78,18 @@ export function useProxyDelayState( } setDelayState({ delay: fallbackDelay, updatedAt }) - }, [details?.history, groupName, member, name, unresolved]) + }, [groupName, member, unresolved]) useEffect(() => { updateDelay() - }, [updateDelay]) + return delayManager.addGroupListener(groupName, updateDelay) + }, [groupName, updateDelay]) const onDelay = useLockFn(async () => { if (!isInteractableMember(member)) return setDelayState({ delay: -2, updatedAt: Date.now() }) - setDelayState(await delayManager.checkDelay(member, groupName, timeout)) + await delayManager.checkDelay(member, groupName, timeout) + updateDelay() }) return { diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index a4d7c92fd..5f6a51afe 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -59,3 +59,140 @@ describe('group delay completion', () => { stop() }) }) + +test('uses newer core history after a tray test without hiding an active page test', () => { + const member = node('tray-refresh') + if (member.kind !== 'node') throw new Error('Expected node') + const cached = delayManager.setDelay(member, 'tray-group', 50) + member.node.extra = { + [delayManager.getUrl('tray-group')]: { + alive: true, + history: [ + { time: new Date(cached.updatedAt + 1000).toISOString(), delay: 300 }, + ], + }, + } + + expect(delayManager.getDelayFix(member, 'tray-group')).toBe(300) + expect(delayManager.getDelayUpdate(member, 'tray-group')?.delay).toBe(300) + + delayManager.setDelay(member, 'tray-group', -2) + expect(delayManager.getDelayFix(member, 'tray-group')).toBe(-2) +}) + +test('latency URL validation rejects malformed hosts and ports and trims valid URLs', () => { + for (const url of [ + 'http://?', + 'http://host:invalid', + 'http://bad host/', + 'ftp://localhost', + 'http:localhost', + ]) { + delayManager.setUrl('url-validation', url) + expect(delayManager.getUrl('url-validation')).toBe( + 'http://cp.cloudflare.com/generate_204', + ) + } + delayManager.setUrl('url-validation', ' HTTP://localhost:8080/ ') + expect(delayManager.getUrl('url-validation')).toBe('HTTP://localhost:8080/') +}) + +test('another URL cannot replace a group measurement, but a newer matching URL can', () => { + const member = node('url-isolation') + if (member.kind !== 'node') throw new Error('Expected node') + delayManager.setUrl('url-a', 'http://localhost/a') + const cached = delayManager.setDelay(member, 'url-a', 50) + const newer = new Date(cached.updatedAt + 1000).toISOString() + Object.assign(member.node, { + history: [{ time: newer, delay: 126 }], + extra: { + 'http://localhost/a': { + alive: true, + history: [ + { time: new Date(cached.updatedAt - 1000).toISOString(), delay: 49 }, + ], + }, + 'http://localhost/b': { + alive: true, + history: [{ time: newer, delay: 126 }], + }, + }, + }) + expect(delayManager.getDelayFix(member, 'url-a')).toBe(50) + Object.assign(member.node, { + extra: { + 'http://localhost/a': { + alive: true, + history: [{ time: newer, delay: 75 }], + }, + }, + }) + expect(delayManager.getDelayFix(member, 'url-a')).toBe(75) +}) + +test('same-named provider members keep separate measurements and listeners', async () => { + const first = node('same-provider-name') + const second = node('same-provider-name') + if (first.kind !== 'node' || second.kind !== 'node') + throw new Error('Expected nodes') + first.node.recordId = 'provider-a:N' + first.node.source = { kind: 'provider', providerName: 'a', proxyName: 'N' } + second.node.recordId = 'provider-b:N' + second.node.source = { kind: 'provider', providerName: 'b', proxyName: 'N' } + const { healthcheckNodeInProvider } = await import('tauri-plugin-mihomo-api') + vi.mocked(healthcheckNodeInProvider) + .mockResolvedValueOnce({ delay: 40 }) + .mockResolvedValueOnce({ delay: 125 }) + const firstListener = vi.fn() + const secondListener = vi.fn() + delayManager.setListener(first, 'provider-collision', firstListener) + delayManager.setListener(second, 'provider-collision', secondListener) + await delayManager.checkListDelay([first, second], 'provider-collision', 5000) + await flush() + expect(firstListener).toHaveBeenLastCalledWith( + expect.objectContaining({ delay: 40 }), + ) + expect(secondListener).toHaveBeenLastCalledWith( + expect.objectContaining({ delay: 125 }), + ) + delayManager.removeListener(first, 'provider-collision') + delayManager.removeListener(second, 'provider-collision') + expect(delayManager.getDelayFix(first, 'provider-collision')).toBe(40) + expect(delayManager.getDelayFix(second, 'provider-collision')).toBe(125) +}) + +test.each(['core', 'provider'] as const)( + 'cached %s delays follow source identity after record IDs move', + (kind) => { + const original = node(`stable-${kind}`) + const replacement = node(`replacement-${kind}`) + if (original.kind !== 'node' || replacement.kind !== 'node') + throw new Error('Expected nodes') + const source = (proxyName: string) => + kind === 'core' + ? { kind, proxyName } + : { kind, providerName: 'subscription', proxyName } + original.node.source = source(original.ref.name) + replacement.node.source = source(replacement.ref.name) + original.node.recordId = 'position:0' + replacement.node.recordId = 'position:0' + delayManager.setDelay(original, `reorder-${kind}`, 40) + expect(delayManager.getDelayFix(replacement, `reorder-${kind}`)).toBe(-1) + original.node.recordId = 'position:1' + expect(delayManager.getDelayFix(original, `reorder-${kind}`)).toBe(40) + }, +) + +test('unmeasured selectors show automatic history without replacing a manual URL result', () => { + const member = node('selector-member') + if (member.kind !== 'node') throw new Error('Expected node') + member.node.history = [ + { time: new Date(Date.now() + 1000).toISOString(), delay: 126 }, + ] + member.node.extra = { + 'http://localhost/automatic': { alive: true, history: member.node.history }, + } + expect(delayManager.getDelayFix(member, 'selector')).toBe(126) + delayManager.setDelay(member, 'selector', 50) + expect(delayManager.getDelayFix(member, 'selector')).toBe(50) +}) diff --git a/src/services/delay.ts b/src/services/delay.ts index 18602faaf..323809b67 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -12,13 +12,23 @@ import { } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' -import { isValidUrl } from '@/utils/network' +import { isValidLatencyTestUrl } from '@/utils/network' export type DelaySnapshot = { of: (member: ResolvedProxyMember) => number } -const hashKey = (name: string, group: string) => `${group ?? ''}::${name}` +const hashKey = (member: ResolvedProxyMember, group: string) => { + if (member.kind !== 'node') + return JSON.stringify([group, member.kind, member.ref.name]) + const source = member.node.source + return JSON.stringify([ + group, + source.kind, + source.kind === 'provider' ? source.providerName : null, + source.proxyName, + ]) +} export interface DelayUpdate { delay: number @@ -29,7 +39,7 @@ export interface DelayUpdate { const CACHE_TTL = 30 * 60 * 1000 class DelayManager { - private cache = new Map() + private cache = new Map() private urlMap = new Map() private listenerMap = new Map void>() @@ -153,13 +163,13 @@ class DelayManager { } setUrl(group: string, url: string) { - if (!isValidUrl(url)) { - debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`) + const previous = this.getUrl(group) + if (isValidLatencyTestUrl(url)) { + this.urlMap.set(group, url.trim()) + } else { this.urlMap.delete(group) - return } - debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`) - this.urlMap.set(group, url) + if (previous !== this.getUrl(group)) this.queueGroupNotification(group) } getUrl(group: string) { @@ -171,16 +181,16 @@ class DelayManager { } setListener( - name: string, + member: ResolvedProxyMember, group: string, listener: (update: DelayUpdate) => void, ) { - const key = hashKey(name, group) + const key = hashKey(member, group) this.listenerMap.set(key, listener) } - removeListener(name: string, group: string) { - const key = hashKey(name, group) + removeListener(member: ResolvedProxyMember, group: string) { + const key = hashKey(member, group) this.listenerMap.delete(key) } @@ -199,14 +209,14 @@ class DelayManager { } setDelay( - name: string, + member: ResolvedProxyMember, group: string, delay: number, - meta?: { elapsed?: number }, + meta?: { elapsed?: number; url?: string }, ): DelayUpdate { - const key = hashKey(name, group) + const key = hashKey(member, group) debugLog( - `[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`, + `[DelayManager] 设置延迟,代理: ${member.ref.name}, 组: ${group}, 延迟: ${delay}`, ) const update: DelayUpdate = { delay, @@ -214,7 +224,9 @@ class DelayManager { updatedAt: Date.now(), } - this.cache.set(key, update) + const url = meta?.url ?? this.getUrl(group) + if (url !== this.getUrl(group)) return update + this.cache.set(key, { ...update, url }) const queue = this.pendingItemUpdates.get(key) if (queue) { @@ -227,9 +239,16 @@ class DelayManager { return update } - getDelayUpdate(name: string, group: string) { - const key = hashKey(name, group) - const entry = this.cache.get(key) + getDelayUpdate(member: ResolvedProxyMember, group: string) { + const history = this.getHistory(member, group) + const key = hashKey(member, group) + const cached = this.cache.get(key) + const entry = cached?.url === this.getUrl(group) ? cached : undefined + const latest = history?.at(-1) + const updatedAt = latest ? Date.parse(latest.time) : 0 + if (latest && entry?.delay !== -2 && updatedAt > (entry?.updatedAt ?? 0)) { + return { delay: latest.delay || 1e6, updatedAt } + } if (!entry) return undefined if (Date.now() - entry.updatedAt > CACHE_TTL) { @@ -240,23 +259,25 @@ class DelayManager { return { ...entry } } - getDelay(name: string, group: string) { - const update = this.getDelayUpdate(name, group) + getDelay(member: ResolvedProxyMember, group: string) { + const update = this.getDelayUpdate(member, group) return update ? update.delay : -1 } + getHistory(member: ResolvedProxyMember, group: string) { + return memberDetails(member)?.extra?.[this.getUrl(group)]?.history + } + getDelayFix(member: ResolvedProxyMember, group: string) { if (member.kind === 'unresolved') return -1 - const details = memberDetails(member) - const name = member.ref.name - const update = this.getDelayUpdate(name, group) + const history = + this.getHistory(member, group) ?? memberDetails(member)?.history + const update = this.getDelayUpdate(member, group) if (update && (update.delay >= 0 || update.delay === -2)) { return update.delay } - if (details?.history && details.history.length > 0) { - return details.history[details.history.length - 1].delay || 1e6 - } + if (history?.length) return history[history.length - 1].delay || 1e6 return -1 } @@ -298,12 +319,12 @@ class DelayManager { `[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`, ) - this.setDelay(name, group, -2) + const url = this.getUrl(group) + this.setDelay(member, group, -2, { url }) const startTime = Date.now() try { - const url = this.getUrl(group) debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`) const timeoutPromise = new Promise((resolve) => { @@ -324,14 +345,14 @@ class DelayManager { const elapsed = elapsedTime debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`) - return this.setDelay(name, group, delay, { elapsed }) + return this.setDelay(member, group, delay, { elapsed, url }) } catch (error) { await new Promise((resolve) => setTimeout(resolve, 500)) console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error) const delay = 1e6 // error const elapsed = Date.now() - startTime - return this.setDelay(name, group, delay, { elapsed }) + return this.setDelay(member, group, delay, { elapsed, url }) } } @@ -344,10 +365,9 @@ class DelayManager { debugLog( `[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`, ) - const names = proxies.map((member) => member.ref.name) this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1) - names.forEach((name) => { - this.setDelay(name, group, -2) + proxies.forEach((member) => { + this.setDelay(member, group, -2) }) let index = 0 @@ -359,7 +379,7 @@ class DelayManager { const currName = currMember.ref.name try { - this.setDelay(currName, group, -2) + this.setDelay(currMember, group, -2) // Stagger requests so a batch does not hit the core at once. if (index > 1) { @@ -375,13 +395,13 @@ class DelayManager { `[DelayManager] 批量测试单个代理出错,代理: ${currName}`, error, ) - this.setDelay(currName, group, 1e6) + this.setDelay(currMember, group, 1e6) } return help() } - const actualConcurrency = Math.min(concurrency, names.length, 10) + const actualConcurrency = Math.min(concurrency, proxies.length, 10) debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`) const promiseList: Promise[] = [] diff --git a/src/services/proxy-test-url.test.ts b/src/services/proxy-test-url.test.ts new file mode 100644 index 000000000..e1e348f11 --- /dev/null +++ b/src/services/proxy-test-url.test.ts @@ -0,0 +1,103 @@ +import { afterEach, expect, test, vi } from 'vitest' + +vi.mock('@/services/cmds', () => ({ + getProfiles: vi.fn(), + patchProfile: vi.fn().mockResolvedValue(undefined), + syncTrayProxySelection: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) +vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } })) + +import { + getProfiles, + patchProfile, + syncTrayProxySelection, +} from '@/services/cmds' +import { revalidateQueries } from '@/services/query-client' + +import { + profileTestUrls, + saveProfileTestUrls, + commitProfileTestUrl, +} from './proxy-test-url' + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +test('committed edits retain their profile and merge with fresh saved URLs', async () => { + vi.mocked(getProfiles).mockResolvedValue({ + current: 'new-profile', + items: [ + { + uid: 'old-profile', + latency_test_urls: { untouched: 'https://example.com/' }, + }, + ], + }) + await commitProfileTestUrl('old-profile', 'A', ' http://localhost/new ') + expect(patchProfile).toHaveBeenCalledExactlyOnceWith('old-profile', { + latency_test_urls: { + untouched: 'https://example.com/', + A: 'http://localhost/new', + }, + }) + expect(revalidateQueries).toHaveBeenCalledWith([['getProfiles']]) + expect(syncTrayProxySelection).toHaveBeenCalledOnce() +}) + +test('migration cannot overwrite a previously saved or cleared override', async () => { + vi.mocked(getProfiles).mockResolvedValue({ + items: [{ uid: 'p', latency_test_urls: { A: '' } }], + }) + await saveProfileTestUrls('p', { A: 'http://localhost/stale' }, true) + expect(patchProfile).not.toHaveBeenCalled() +}) + +test('pending edits survive a reload and older save acknowledgements', async () => { + vi.useFakeTimers() + const profile = { + uid: 'pending-profile', + latency_test_urls: { A: 'http://localhost/old' }, + } + vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) + let finish!: () => void + vi.mocked(patchProfile).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const first = commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + await vi.advanceTimersByTimeAsync(300) + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + const second = commitProfileTestUrl( + profile.uid, + 'A', + 'http://localhost/newest', + ) + finish() + await first + expect(profileTestUrls(profile).A).toBe('http://localhost/newest') + await second + expect(patchProfile).toHaveBeenLastCalledWith(profile.uid, { + latency_test_urls: { A: 'http://localhost/newest' }, + }) +}) + +test('a failed save preserves the committed input across profile refreshes', async () => { + const profile = { + uid: 'failed-profile', + latency_test_urls: { A: 'http://localhost/old' }, + } + vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) + vi.mocked(patchProfile).mockRejectedValueOnce(new Error('save failed')) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/new') + await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') + expect(profileTestUrls(profile).A).toBe('http://localhost/old') + log.mockRestore() +}) diff --git a/src/services/proxy-test-url.ts b/src/services/proxy-test-url.ts new file mode 100644 index 000000000..acd597aba --- /dev/null +++ b/src/services/proxy-test-url.ts @@ -0,0 +1,101 @@ +import { + getProfiles, + patchProfile, + syncTrayProxySelection, +} from '@/services/cmds' +import { mutate } from '@/services/mutate' + +export function readLegacyTestUrls(uid: string): Record { + try { + const states = JSON.parse(localStorage.getItem('proxy-head-state') || '{}')[ + uid + ] + return Object.fromEntries( + Object.entries(states || {}).flatMap(([name, state]) => { + const url = (state as { testUrl?: string }).testUrl + return typeof url === 'string' && url.trim() ? [[name, url.trim()]] : [] + }), + ) + } catch { + return {} + } +} + +export async function saveProfileTestUrls( + uid: string, + urls: Record, + migrateOnly = false, +) { + return mutate( + async () => { + const profiles = await getProfiles() + const profile = profiles.items?.find((item) => item.uid === uid) + if (!profile || (migrateOnly && profile.latency_test_urls !== undefined)) + return + await patchProfile(uid, { + latency_test_urls: { + ...(profile.latency_test_urls ?? readLegacyTestUrls(uid)), + ...urls, + }, + }) + await syncTrayProxySelection().catch((error) => { + console.error( + 'Failed to refresh tray after saving latency test URL:', + error, + ) + }) + }, + { id: `patch-profile:${uid}`, revalidate: [['getProfiles']] }, + ) +} + +const migrating = new Set() + +export function migrateProfileTestUrls(profiles: IProfilesConfig) { + for (const profile of profiles.items || []) { + if (profile.latency_test_urls !== undefined || migrating.has(profile.uid)) + continue + const urls = readLegacyTestUrls(profile.uid) + if (!Object.keys(urls).length) continue + migrating.add(profile.uid) + void saveProfileTestUrls(profile.uid, urls, true) + .catch((error) => { + console.error('Failed to migrate latency test URLs:', error) + }) + .finally(() => migrating.delete(profile.uid)) + } +} + +const pending = new Map>() + +export async function commitProfileTestUrl( + uid: string, + group: string, + url: string, +) { + const edit = { url: url.trim() } + const urls = { ...pending.get(uid), [group]: edit } + pending.set(uid, urls) + try { + await saveProfileTestUrls(uid, { [group]: edit.url }) + const current = pending.get(uid) + if (current?.[group] === edit) { + delete current[group] + if (!Object.keys(current).length) pending.delete(uid) + } + } catch (error) { + console.error('Failed to save latency test URL:', error) + } +} + +export function profileTestUrls(profile: IProfileItem): Record { + return { + ...profile.latency_test_urls, + ...Object.fromEntries( + Object.entries(pending.get(profile.uid) ?? {}).map(([group, edit]) => [ + group, + edit.url, + ]), + ), + } +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 13da2141e..4083bc95d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -218,6 +218,7 @@ interface IClashInfo { } interface IProfileItem { + latency_test_urls?: Record uid: string type?: 'local' | 'remote' | 'merge' | 'script' name?: string diff --git a/src/types/proxy-view.ts b/src/types/proxy-view.ts index 3f4522f52..7b41bafc5 100644 --- a/src/types/proxy-view.ts +++ b/src/types/proxy-view.ts @@ -33,6 +33,7 @@ export interface ProxyGroupView extends ProxyCapabilities { icon?: string testUrl?: string history: DelayHistory[] + extra?: Record members: ProxyMemberRef[] } @@ -42,6 +43,7 @@ export interface ProxyNodeView extends ProxyCapabilities { type: string alive: boolean history: DelayHistory[] + extra?: Record id?: string hidden?: boolean icon?: string diff --git a/src/utils/network.ts b/src/utils/network.ts index 647efafc1..99db4a6f9 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -20,6 +20,19 @@ export const isValidUrl = (value: string) => require_tld: false, }) +export const isValidLatencyTestUrl = (value: string) => { + const raw = value.trim() + if (!/^https?:\/\//i.test(raw)) return false + try { + const url = new URL(raw) + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && !!url.hostname + ) + } catch { + return false + } +} + export const isValidPort = (value: string) => isPort(value.trim()) export const normalizeHost = (value: string): string | null => { From 4337bd85758d54d7b9044b2da3c9ac1176eae1f3 Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 9 Oct 2026 10:03:12 +0800 Subject: [PATCH 05/15] fix(core): clarify and localize existing core errors --- Changelog.md | 1 + crates/clash-verge-i18n/locales/ar.yml | 2 + crates/clash-verge-i18n/locales/de.yml | 2 + crates/clash-verge-i18n/locales/en.yml | 2 + crates/clash-verge-i18n/locales/es.yml | 2 + crates/clash-verge-i18n/locales/fa.yml | 2 + crates/clash-verge-i18n/locales/id.yml | 2 + crates/clash-verge-i18n/locales/jp.yml | 2 + crates/clash-verge-i18n/locales/ko.yml | 2 + crates/clash-verge-i18n/locales/pt.yml | 2 + crates/clash-verge-i18n/locales/ru.yml | 2 + crates/clash-verge-i18n/locales/tr.yml | 2 + crates/clash-verge-i18n/locales/tt.yml | 2 + crates/clash-verge-i18n/locales/zh.yml | 2 + crates/clash-verge-i18n/locales/zhtw.yml | 2 + src-tauri/src/core/manager/lifecycle.rs | 15 ++++- src-tauri/src/core/manager/state.rs | 26 +++++++- src/components/layout/notice-manager.test.ts | 67 ++++++++++++++++++++ src/components/layout/notice-manager.tsx | 9 ++- src/locales/ar/settings.json | 2 +- src/locales/de/settings.json | 2 +- src/locales/en/settings.json | 2 +- src/locales/es/settings.json | 2 +- src/locales/fa/settings.json | 2 +- src/locales/id/settings.json | 2 +- src/locales/jp/settings.json | 2 +- src/locales/ko/settings.json | 2 +- src/locales/pt/settings.json | 2 +- src/locales/ru/settings.json | 2 +- src/locales/tr/settings.json | 2 +- src/locales/tt/settings.json | 2 +- src/locales/zh/settings.json | 2 +- src/locales/zhtw/settings.json | 2 +- 33 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 src/components/layout/notice-manager.test.ts diff --git a/Changelog.md b/Changelog.md index 64ac74ab7..0d7bba18f 100644 --- a/Changelog.md +++ b/Changelog.md @@ -6,6 +6,7 @@ - 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题 - 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题 - 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 +- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题 - 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 - 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 - 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题 diff --git a/crates/clash-verge-i18n/locales/ar.yml b/crates/clash-verge-i18n/locales/ar.yml index 4fa132513..81dec23dc 100644 --- a/crates/clash-verge-i18n/locales/ar.yml +++ b/crates/clash-verge-i18n/locales/ar.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول. adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول. +core: + alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.' launchGuard: currentExeError: 'تعذر تحديد موقع التطبيق: {error}' homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}' diff --git a/crates/clash-verge-i18n/locales/de.yml b/crates/clash-verge-i18n/locales/de.yml index d31a24f07..058f3b3d3 100644 --- a/crates/clash-verge-i18n/locales/de.yml +++ b/crates/clash-verge-i18n/locales/de.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich. adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich. +core: + alreadyRunning: 'Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu.' launchGuard: currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}' homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}' diff --git a/crates/clash-verge-i18n/locales/en.yml b/crates/clash-verge-i18n/locales/en.yml index 82aa2eb64..9db8ff3cd 100644 --- a/crates/clash-verge-i18n/locales/en.yml +++ b/crates/clash-verge-i18n/locales/en.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges. +core: + alreadyRunning: 'Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer.' launchGuard: currentExeError: 'Unable to determine the application location: {error}' homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}' diff --git a/crates/clash-verge-i18n/locales/es.yml b/crates/clash-verge-i18n/locales/es.yml index 4689a3b35..85ad7d95d 100644 --- a/crates/clash-verge-i18n/locales/es.yml +++ b/crates/clash-verge-i18n/locales/es.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador. adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador. +core: + alreadyRunning: 'No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo.' launchGuard: currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}' homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}' diff --git a/crates/clash-verge-i18n/locales/fa.yml b/crates/clash-verge-i18n/locales/fa.yml index 6925a3834..2a3453405 100644 --- a/crates/clash-verge-i18n/locales/fa.yml +++ b/crates/clash-verge-i18n/locales/fa.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد. adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد. +core: + alreadyRunning: 'هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید.' launchGuard: currentExeError: 'مکان برنامه مشخص نشد: {error}' homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}' diff --git a/crates/clash-verge-i18n/locales/id.yml b/crates/clash-verge-i18n/locales/id.yml index ef9e39414..9c037b90d 100644 --- a/crates/clash-verge-i18n/locales/id.yml +++ b/crates/clash-verge-i18n/locales/id.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator. adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator. +core: + alreadyRunning: 'Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer.' launchGuard: currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}' homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}' diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index 723cb3fb3..327383e8b 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。 adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。 +core: + alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。 launchGuard: currentExeError: アプリケーションの場所を確認できませんでした:{error} homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error} diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index ef15f6517..9fcaa6a79 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다. adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다. +core: + alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요. launchGuard: currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}' homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}' diff --git a/crates/clash-verge-i18n/locales/pt.yml b/crates/clash-verge-i18n/locales/pt.yml index 1f0a31682..2373529ee 100644 --- a/crates/clash-verge-i18n/locales/pt.yml +++ b/crates/clash-verge-i18n/locales/pt.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador. adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador. +core: + alreadyRunning: 'Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador.' launchGuard: currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}' homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}' diff --git a/crates/clash-verge-i18n/locales/ru.yml b/crates/clash-verge-i18n/locales/ru.yml index 990920f72..630366a8f 100644 --- a/crates/clash-verge-i18n/locales/ru.yml +++ b/crates/clash-verge-i18n/locales/ru.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Для установки службы Clash Verge требуются права администратора. adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора. +core: + alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.' launchGuard: currentExeError: 'Не удалось определить расположение приложения: {error}' homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}' diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index 7587ab2fe..3ced8c82d 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir. adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir. +core: + alreadyRunning: 'Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın.' launchGuard: currentExeError: 'Uygulamanın konumu belirlenemedi: {error}' homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}' diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index a9d8fcb3d..2f067cc07 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк. adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк. +core: + alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.' launchGuard: currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}' homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}' diff --git a/crates/clash-verge-i18n/locales/zh.yml b/crates/clash-verge-i18n/locales/zh.yml index ca2afd3ce..dbb40569b 100644 --- a/crates/clash-verge-i18n/locales/zh.yml +++ b/crates/clash-verge-i18n/locales/zh.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限 adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限 +core: + alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。 launchGuard: currentExeError: 无法确定应用位置:{error} homeDirError: 无法确定当前用户主目录,应用将退出:{error} diff --git a/crates/clash-verge-i18n/locales/zhtw.yml b/crates/clash-verge-i18n/locales/zhtw.yml index bebc5c058..ccf2ab2ac 100644 --- a/crates/clash-verge-i18n/locales/zhtw.yml +++ b/crates/clash-verge-i18n/locales/zhtw.yml @@ -37,6 +37,8 @@ notifications: service: adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限 adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限 +core: + alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。 launchGuard: currentExeError: 無法判斷應用程式位置:{error} homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error} diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index e7366aec3..ad67b6b0a 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -400,7 +400,9 @@ impl CoreManager { } #[cfg(target_os = "windows")] if matches!(*mode, RunningMode::NotRunning) { - clash_verge_service_ipc::execution::check_sidecar_available().await?; + clash_verge_service_ipc::execution::check_sidecar_available() + .await + .map_err(super::state::explain_core_occupancy)?; } if !matches!(status, ServiceStatus::SidecarAllowed) { SERVICE_MANAGER.allow_sidecar_for_session()?; @@ -640,7 +642,11 @@ impl CoreManager { { run_service_start_with_sidecar_fallback( || self.start_core_by_service(), - clash_verge_service_ipc::execution::check_sidecar_available, + || async { + clash_verge_service_ipc::execution::check_sidecar_available() + .await + .map_err(super::state::explain_core_occupancy) + }, |error| self.start_sidecar_after_service_failure(error), |reason| { crate::core::runstate::RUN_STATE @@ -685,7 +691,10 @@ impl CoreManager { ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(), _ => return Ok(()), }; - if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await { + if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available() + .await + .map_err(super::state::explain_core_occupancy) + { logging!( warn, Type::Core, diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index 6cd39a300..d30549b91 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -17,6 +17,15 @@ use std::path::Path; use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_shell::ShellExt as _; +pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error { + let detail = error.to_string(); + if detail.starts_with("process verge-mihomo") && detail.ends_with("; refusing a second core") { + error.context(clash_verge_i18n::t!("core.alreadyRunning").into_owned()) + } else { + error + } +} + #[cfg(target_os = "windows")] fn sidecar_config_without_tun(yaml: &str) -> Result { use serde_yaml_ng::{Mapping, Value}; @@ -154,7 +163,8 @@ impl CoreManager { self.wait_for_sidecar_exit().await?; let execution = clash_verge_service_ipc::execution::reserve_sidecar() .await - .inspect_err(service::record_residual_service)?; + .inspect_err(service::record_residual_service) + .map_err(explain_core_occupancy)?; self.core_stopped(); let sidecar_ipc = dirs::sidecar_ipc_path()?; @@ -511,6 +521,20 @@ mod readiness_tests { time::Duration, }; + #[test] + fn core_occupancy_explanation_preserves_the_original_error() { + for detail in [ + "process verge-mihomo remains after IPC failure; refusing a second core", + "process verge-mihomo-alpha.exe (PID 123) is still running; refusing a second core", + ] { + let error = super::explain_core_occupancy(anyhow::anyhow!(detail)); + assert_eq!(error.to_string(), clash_verge_i18n::t!("core.alreadyRunning")); + assert_eq!(error.root_cause().to_string(), detail); + } + let error = super::explain_core_occupancy(anyhow::anyhow!("could not inspect remaining cores")); + assert_eq!(error.to_string(), "could not inspect remaining cores"); + } + #[tokio::test] async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> { let attempts = Arc::new(AtomicUsize::new(0)); diff --git a/src/components/layout/notice-manager.test.ts b/src/components/layout/notice-manager.test.ts new file mode 100644 index 000000000..e55139545 --- /dev/null +++ b/src/components/layout/notice-manager.test.ts @@ -0,0 +1,67 @@ +import { createInstance } from 'i18next' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { I18nextProvider } from 'react-i18next' +import { afterEach, expect, it, vi } from 'vitest' + +import settings from '@/locales/en/settings.json' +import { + getSnapshotNotices, + hideNotice, + showNotice, + syncSidecarFailure, +} from '@/services/notice-service' + +import { NoticeManager } from './notice-manager' + +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), +})) +vi.mock('@/utils/open-external-url', () => ({ openExternalUrl: vi.fn() })) + +afterEach(() => { + getSnapshotNotices().forEach(({ id }) => hideNotice(id)) +}) + +it.each([ + [ + 'startup', + 'process verge-mihomo remains after IPC failure; refusing a second core', + ], + [ + 'restart', + 'process verge-mihomo-alpha remains after IPC failure; refusing a second core', + ], + [ + 'sidecar', + 'Service core rejected: permission denied; Sidecar fallback failed: process verge-mihomo.exe (PID 123) is still running; refusing a second core', + ], +])( + 'explains an occupied core during %s and preserves diagnostics', + async (operation, detail) => { + if (operation === 'startup') { + showNotice.error('settings.feedback.errors.clash.startFailed', detail) + } else if (operation === 'restart') { + showNotice.error({ code: 'CORE_RESTART_FAILED', detail }) + } else { + syncSidecarFailure({ revision: 1, detail }) + } + const i18n = createInstance() + await i18n.init({ + lng: 'en', + resources: { en: { translation: { settings } } }, + }) + const html = renderToStaticMarkup( + createElement(I18nextProvider, { i18n }, createElement(NoticeManager)), + ) + expect(html).toContain('a core process is already running') + expect(html).toContain('Close the related app or stop the leftover process') + expect(html).toContain(detail) + if (operation === 'sidecar') { + expect(html).toContain('service-core-permissions') + expect(html).toContain('PID 123') + } + }, +) diff --git a/src/components/layout/notice-manager.tsx b/src/components/layout/notice-manager.tsx index c70bd0838..8769a28ae 100644 --- a/src/components/layout/notice-manager.tsx +++ b/src/components/layout/notice-manager.tsx @@ -70,8 +70,8 @@ const resolveNoticeMessage = ( 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( + typeof detail === 'string' + ? /process verge-mihomo(?:-alpha|-al)?(?:\.exe)? (?:\(PID (\d+)\) is still running|remains after IPC failure); refusing a second core\s*$/.exec( detail, ) : null @@ -101,9 +101,8 @@ const resolveNoticeMessage = ( )} - {t('settings.feedback.errors.clashService.coreAlreadyRunning', { - pid: existingCore[1], - })} + {t('settings.feedback.errors.clashService.coreAlreadyRunning')} + {existingCore[1] && PID {existingCore[1]}} {t('shared.feedback.errors.details')} diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index a92d77c6d..379e17e96 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "تعذّر إعادة تثبيت الخدمة", "repairFailed": "تعذّر إصلاح الخدمة", "sidecarFailed": "تعذّر المتابعة باستخدام Sidecar", - "coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة." + "coreAlreadyRunning": "تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر." }, "sysproxy": { "privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index c072b63d9..1556a404e 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Dienst konnte nicht neu installiert werden", "repairFailed": "Dienst konnte nicht repariert werden", "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." + "coreAlreadyRunning": "Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu." }, "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 83fc780c8..277c0fc27 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Failed to reinstall the service", "repairFailed": "Failed to repair the service", "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." + "coreAlreadyRunning": "Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer." }, "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 4d00690cd..5d7252513 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "No se pudo reinstalar el servicio", "repairFailed": "No se pudo reparar el servicio", "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." + "coreAlreadyRunning": "No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo." }, "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 573c5c2c3..79dcc96f9 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "نصب مجدد سرویس ناموفق بود", "repairFailed": "تعمیر سرویس ناموفق بود", "sidecarFailed": "ادامه با Sidecar ناموفق بود", - "coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونه‌های دیگر Clash Verge یا برنامه‌ای که از این هسته استفاده می‌کند را ببندید و دوباره تلاش کنید." + "coreAlreadyRunning": "هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید." }, "sysproxy": { "privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index e7d425c08..341cf9676 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Gagal memasang ulang layanan", "repairFailed": "Gagal memperbaiki layanan", "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." + "coreAlreadyRunning": "Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer." }, "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 6a8530aa4..06af65f1c 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "サービスの再インストールに失敗しました", "repairFailed": "サービスの修復に失敗しました", "sidecarFailed": "Sidecar での続行に失敗しました", - "coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。" + "coreAlreadyRunning": "コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。" }, "sysproxy": { "privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 241f4f9fa..6bbcf08a0 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "서비스를 다시 설치하지 못했습니다", "repairFailed": "서비스를 복구하지 못했습니다", "sidecarFailed": "Sidecar로 계속하지 못했습니다", - "coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요." + "coreAlreadyRunning": "코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요." }, "sysproxy": { "privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.", diff --git a/src/locales/pt/settings.json b/src/locales/pt/settings.json index 8e2aac51e..1f95164bc 100644 --- a/src/locales/pt/settings.json +++ b/src/locales/pt/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Falha ao reinstalar o serviço", "repairFailed": "Falha ao reparar o serviço", "sidecarFailed": "Falha ao continuar com o Sidecar", - "coreAlreadyRunning": "Não foi possível iniciar o modo normal (Sidecar): outro núcleo ainda está em execução (PID {{pid}}). Saia das outras instâncias do Clash Verge ou do aplicativo que usa este núcleo e tente novamente." + "coreAlreadyRunning": "Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador." }, "sysproxy": { "privilegeRequired": "Não é possível definir o proxy do sistema: o macOS recusou a alteração por falta de privilégios. Instale o serviço do Clash Verge; um administrador também pode desativar a exigência de senha de administrador para configurações do sistema, nas opções avançadas em Ajustes do Sistema → Privacidade e Segurança.", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index e8541bdd8..550bd9615 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Не удалось переустановить службу", "repairFailed": "Не удалось восстановить службу", "sidecarFailed": "Не удалось продолжить в режиме Sidecar", - "coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку." + "coreAlreadyRunning": "Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер." }, "sysproxy": { "privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 262f730c0..fad3447e0 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Hizmet yeniden kurulamadı", "repairFailed": "Hizmet onarılamadı", "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." + "coreAlreadyRunning": "Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın." }, "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 774b90f0b..68154eae4 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "Хезмәтне яңадан урнаштырып булмады", "repairFailed": "Хезмәтне төзәтеп булмады", "sidecarFailed": "Sidecar белән дәвам итеп булмады", - "coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз." + "coreAlreadyRunning": "Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз." }, "sysproxy": { "privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 38e37127d..936f2cb15 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "重新安装服务失败", "repairFailed": "修复服务失败", "sidecarFailed": "继续使用 Sidecar 失败", - "coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。" + "coreAlreadyRunning": "无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。" }, "sysproxy": { "privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 35e956b28..569ce3347 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -667,7 +667,7 @@ "reinstallFailed": "重新安裝服務失敗", "repairFailed": "修復服務失敗", "sidecarFailed": "繼續使用 Sidecar 失敗", - "coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。" + "coreAlreadyRunning": "無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。" }, "sysproxy": { "privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。", From 9975322b9bb6d98638a55044472f268543b08a65 Mon Sep 17 00:00:00 2001 From: aq <141202172+KHG420@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:05:12 +0000 Subject: [PATCH 06/15] fix(ci): handle plain Copilot 429 in PR review (#8116) * fix(ci): handle plain Copilot 429 in PR review Recognize unclassified CLI rate-limit output in the existing non-blocking quota guard. Regenerate the pinned gh-aw workflow so review checks do not fail when Copilot cannot accept a request. * fix(ci): avoid duplicate quota predicates in Copilot review --- .github/workflows/pr-ai-slop-review.lock.yml | 4 ++-- .github/workflows/pr-ai-slop-review.md | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-ai-slop-review.lock.yml b/.github/workflows/pr-ai-slop-review.lock.yml index 54f62e8e7..f6ea859e3 100644 --- a/.github/workflows/pr-ai-slop-review.lock.yml +++ b/.github/workflows/pr-ai-slop-review.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ff92739239d50701d56d10eb77fff5083a556fe471496674aa066c4f73b6b6d2","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8296ef86aad04ad225d29c7fe51dd18dfa50f258c46e8bd24e443193cc6f80fc","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"924af5fdc64061cfbf66fb584c8b07e2ac230c60","version":"v0.89.21"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23","digest":"sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23","digest":"sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23","digest":"sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.25","digest":"sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"image":"ghcr.io/github/github-mcp-server:v1.12.2","digest":"sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6","pinned_image":"ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"}],"has_pull_request_target":true,"mcp_servers":[{"name":"github","tools":["get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","issue_read","list_issue_types","list_issues","list_pull_requests","pull_request_read","search_issues","search_pull_requests"]},{"name":"safeoutputs","tools":["add_comment","add_labels","missing_data","missing_tool","noop","remove_labels"]}]} # This file was automatically generated by gh-aw (v0.89.21). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -541,7 +541,7 @@ jobs: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Make Copilot quota exhaustion non-blocking - run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' return { action: \"stop\", exitCode: 0 };',\n ' }',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE" + run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' let quotaErrorMsg = null;',\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' quotaErrorMsg = { action: \"stop\" };',\n ' }',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' quotaErrorMsg = { action: \"stop\", exitCode: 0 };',\n ' }',\n ' if (quotaErrorMsg) {',\n ' return quotaErrorMsg;',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE" - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64 ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0 ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086 ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6 diff --git a/.github/workflows/pr-ai-slop-review.md b/.github/workflows/pr-ai-slop-review.md index d23fd17cd..63c8799c7 100644 --- a/.github/workflows/pr-ai-slop-review.md +++ b/.github/workflows/pr-ai-slop-review.md @@ -41,13 +41,17 @@ pre-agent-steps: ' }', ].join('\n'); const nonBlockingQuotaBranch = [ + ' let quotaErrorMsg = null;', ' if (isQuotaExceeded) {', ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);', - ' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {', - ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);', - ' return { action: "stop", exitCode: 0 };', - ' }', - ' return { action: "stop" };', + ' quotaErrorMsg = { action: "stop" };', + ' }', + ' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {', + ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);', + ' quotaErrorMsg = { action: "stop", exitCode: 0 };', + ' }', + ' if (quotaErrorMsg) {', + ' return quotaErrorMsg;', ' }', ].join('\n'); From b6d94d45d561f78636fea98d8bc8d6cdd23512df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9D=A4=E6=98=AF=E7=BA=B1=E9=9B=BE=E9=85=B1=E5=93=9F?= =?UTF-8?q?=EF=BD=9E?= <49941141+Dragon1573@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:11:48 +0000 Subject: [PATCH 07/15] chore(deps): update dependency pnpm to v12.10.1 (#8217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update dependency pnpm to v12.10.1 * fix(knip): remove unnecessary function `export` Remove export for function `readLegacyTestUrls`, as it didn't be imported somewhere else Assisted-by: vscode-copilot-sdk:copilot:claude-haiku-5.5 codegraph Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com> --------- Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .devcontainer/devcontainer.json | 2 +- .tool-versions | 2 +- package.json | 2 +- pnpm-lock.yaml | 122 ++++++++++++++++---------------- src/services/proxy-test-url.ts | 2 +- 5 files changed, 65 insertions(+), 65 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e58151f9a..30b7aa948 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "features": { "ghcr.io/devcontainers/features/node:2": { "version": "24.21.0", - "pnpmVersion": "12.10.0" + "pnpmVersion": "12.10.1" }, "ghcr.io/devcontainers/features/rust:1": { "version": "1.99.0", diff --git a/.tool-versions b/.tool-versions index f1b1d92d7..df95322ab 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ nodejs 24.21.0 -pnpm 12.10.0 +pnpm 12.10.1 rust 1.99.0 diff --git a/package.json b/package.json index f80ad179d..a26ade07c 100644 --- a/package.json +++ b/package.json @@ -146,5 +146,5 @@ ] }, "type": "module", - "packageManager": "pnpm@12.10.0" + "packageManager": "pnpm@12.10.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24d7b008e..448ca2839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.10.0 - version: 12.10.0 + specifier: 12.10.1 + version: 12.10.1 packages: - '@pnpm/exe.android-arm64@12.10.0': - resolution: {integrity: sha512-QMMRzOI/7CU4xLbKY6O2BZn3VI4+a8LLyim18H7it8llBWuBPMqDqUURE8045k+7lI8/lI2/tElhyKptEviKDQ==} + '@pnpm/exe.android-arm64@12.10.1': + resolution: {integrity: sha512-CVTlHPwNvadQCJD9bGSMzWIhFX8QpjtqC2c27dhdnn/9iKzEjZ3qL62o3gHpKbIuo6qn4WxTKRoaokgNdReBig==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.10.0': - resolution: {integrity: sha512-41zwlROW+t523Nfqo4rWp8IPiSK5vnAWrnCey26Je45rX1SMNc4ABY7OFmPw41/t+Y0CE+4tjTj+0s9YGqPM5w==} + '@pnpm/exe.android-x64@12.10.1': + resolution: {integrity: sha512-NFiYLIaodY7da0XIebwcU3JoP7IK/lzF7zgKSSP+9H+QcFnQ1GDfBkuBKCYJzZ+Xu0WH54neIPLBBCaOjaaauA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.10.0': - resolution: {integrity: sha512-L+r7AD8Fjf8Vcc4ZRJl+Pjzc27CIeQ/FiRwPmOluFO2UcXubmGHdW+OuMUxrU0vkoXiGR61Ft0RtaPwTRY4W/g==} + '@pnpm/exe.darwin-arm64@12.10.1': + resolution: {integrity: sha512-0O8tdQ8ijlPJudXspLMTcitMkdBEz6uUflazL5uQE0WMs85oQIp0U+5hfEktMFB64QqEhjqlvvujTwbzdr9oMg==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.10.0': - resolution: {integrity: sha512-/TCF1K6E+4PnbkLgqGS1Iqlf5bHp6FDzuXgu6++PMMAFkF7Yk5EF3BcfG1gYYjpkvlJBccvVMsRBnWFbSyqu1w==} + '@pnpm/exe.darwin-x64@12.10.1': + resolution: {integrity: sha512-ud+1/uZOKEo8P/AsPynOJc/LNwfI0j6WwCElibj71rU9l4nIBnsSn+wWTIPZPkZ4BBSH5+82azSnAARMyJ1lNA==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.10.0': - resolution: {integrity: sha512-k7/WFA91E2qhnR/2EOEv402U8bjP758y2pybbdiB5Hrd4KhgviVOJp5cFrXpKgd8HZJs/RZN7cdmMvU2Q2BVgg==} + '@pnpm/exe.freebsd-x64@12.10.1': + resolution: {integrity: sha512-mj9PE5rf8uAfQQ2K66HSATMSut+lNiB4eSqFd5+azFVCMnxNSy/32xpZQJT21PhnN3Y4qbK+iKHe5nWAxSuRhw==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.10.0': - resolution: {integrity: sha512-ChD8bN0lRCRaL+CMOaMUgZQ4WHzEkDXmDHrZgXgdoo6yymQKKzr66Ic7Sl/D7Ky1cVUcFEzHjlq4fGDJwt6Lqg==} + '@pnpm/exe.linux-arm64-musl@12.10.1': + resolution: {integrity: sha512-xba+00wZJ8Um6rBJJjWl88uJj0tydB8dtomvjlCLvi0uL32vpa9CRP8Vv26FIXvbiZ1bo2RCzYt/eChxJVV52w==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.10.0': - resolution: {integrity: sha512-egl5RUSQFh7kETBdmMhaTuaQKP1pjH6Cxq370zgpFFKJXF+GGkIzK+jLNazrjX9pKIWF3iqn7kUeyP6yKxzBoA==} + '@pnpm/exe.linux-arm64@12.10.1': + resolution: {integrity: sha512-oBdMp69ScC9UnniG4qZAQVr2fBDSXjm4yk/72vkEVyrlwS23yMxcRQl7Qdo3sgXPAygY6TWnPRKm4rXzjoyW7w==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.10.0': - resolution: {integrity: sha512-YJ6bEZ+stPpv71CGfGQQeyIpMx/zSMEcAXQzG+rjBtzVfyNsxYciRevhJ+PE9b2qKqN78Mh/1tOMmx9ly4VXGw==} + '@pnpm/exe.linux-ppc64@12.10.1': + resolution: {integrity: sha512-Yyb3wu09h3c7mLme270uHFiVaAIUHC411Ru9LIU2BKUIstrGlXa9OqqVWoXaRgYRdFdOFknkwG7GNn6ygwLbnw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.10.0': - resolution: {integrity: sha512-njfd4euctC1zH1nVBbm6+r1c+0y/TtgqN523rk/KMSxqItpV5QAVa9Yz48Rt5DOIKmuLq7xXOhsQodBlbTk3pw==} + '@pnpm/exe.linux-riscv64@12.10.1': + resolution: {integrity: sha512-PdkeUFFkybknnGkWP0H22eMpIiS4OZXL5BcQzF4lh+vmrW3IaC2HOkbANKgot/PKO0Kl77pzA/6PKFxgbgfXIQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.10.0': - resolution: {integrity: sha512-4JI2F8pYGMcsG6ny61OiBSvZeDozc64TbPozbTU2X5uCsaFCsCvcpr/WwyypwCv+/mNjcIhaKhlxpwJYsnteIg==} + '@pnpm/exe.linux-s390x@12.10.1': + resolution: {integrity: sha512-1okKuHKJwJPAbofDxfhZWAUxEWtee4ZSf7Pah8kcvocaJkf2WxFJ5KVTiNIvDml5GLidDQsgf7Pcf/WHbCak7Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.10.0': - resolution: {integrity: sha512-xayd2iG8ugdvjlGeOvsfm+iSe0bFu9RTX3/8HWOLAQmHdbLKi4hPbxP0S4c9tj8pNej/IxVBQLJksRXFlhNjPw==} + '@pnpm/exe.linux-x64-musl@12.10.1': + resolution: {integrity: sha512-e6IqzVNkPa7srFKJKnY23uWJ+qz0LaRWiTUo/sHfFXRRMRzB5uNOr6qWWip/jqhdGwU91n9J7LrEYZ6e0tRczw==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.10.0': - resolution: {integrity: sha512-PFkzWEghFLKuocxMHtn57mlJ59f5aBbqjX6FCUtiunEHqxTxOibSduQPs3Llm6VWbAXBjD3VrX29Z+bjU1Dpyw==} + '@pnpm/exe.linux-x64@12.10.1': + resolution: {integrity: sha512-ZByV0DRgWzGaDSjT5ddRBASdwz+r/qPMYh7o6RGmPQjtRGNFbSl94xY2DcAM8mqCOZJUUDtAXQ+wpBXcdpKlkg==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.10.0': - resolution: {integrity: sha512-0HtTS+TLONWd/dNSczyOkK0UeZ36cbdhC/2ljZ61DPhvc4jTb7ur0yU47On0in7Im3yXwzTY4vtF8ykrDwc+NQ==} + '@pnpm/exe.win32-arm64@12.10.1': + resolution: {integrity: sha512-I7QZHcjioG7orFeCRUFUHByJ1G0zFPnAlaGGyrUvb6MGkv5oeNwNPl7t+Gp+7zHfnMcgLGJ1a+Wfim0IDw3lOw==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.10.0': - resolution: {integrity: sha512-IcW3rehTGzJ7YnmHEWnbnJakjbBmnH0r29qiajK9+6b9psMF9IjocFr0dyitRVSfXxyW/FI5PIK3mOswGoyFTg==} + '@pnpm/exe.win32-x64@12.10.1': + resolution: {integrity: sha512-4WJFdp58EKlCj0zLobrXU0vpszSZIr4X/3y/uNbAQqGW0zE+VdruZB0gFc3L2kzsUzVwTYbaCn+XEhnCYSAlTg==} cpu: [x64] os: [win32] - pnpm@12.10.0: - resolution: {integrity: sha512-Eip7bd+dQCaLwNsal4CJm5Xk+out/Ke9RQYVrYz3wmWfX1acIO18U4hrx7CNDVClT0rVBwaEf1aC7GxQSEFlOQ==} + pnpm@12.10.1: + resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.10.0': + '@pnpm/exe.android-arm64@12.10.1': optional: true - '@pnpm/exe.android-x64@12.10.0': + '@pnpm/exe.android-x64@12.10.1': optional: true - '@pnpm/exe.darwin-arm64@12.10.0': + '@pnpm/exe.darwin-arm64@12.10.1': optional: true - '@pnpm/exe.darwin-x64@12.10.0': + '@pnpm/exe.darwin-x64@12.10.1': optional: true - '@pnpm/exe.freebsd-x64@12.10.0': + '@pnpm/exe.freebsd-x64@12.10.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.10.0': + '@pnpm/exe.linux-arm64-musl@12.10.1': optional: true - '@pnpm/exe.linux-arm64@12.10.0': + '@pnpm/exe.linux-arm64@12.10.1': optional: true - '@pnpm/exe.linux-ppc64@12.10.0': + '@pnpm/exe.linux-ppc64@12.10.1': optional: true - '@pnpm/exe.linux-riscv64@12.10.0': + '@pnpm/exe.linux-riscv64@12.10.1': optional: true - '@pnpm/exe.linux-s390x@12.10.0': + '@pnpm/exe.linux-s390x@12.10.1': optional: true - '@pnpm/exe.linux-x64-musl@12.10.0': + '@pnpm/exe.linux-x64-musl@12.10.1': optional: true - '@pnpm/exe.linux-x64@12.10.0': + '@pnpm/exe.linux-x64@12.10.1': optional: true - '@pnpm/exe.win32-arm64@12.10.0': + '@pnpm/exe.win32-arm64@12.10.1': optional: true - '@pnpm/exe.win32-x64@12.10.0': + '@pnpm/exe.win32-x64@12.10.1': optional: true - pnpm@12.10.0: + pnpm@12.10.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.10.0 - '@pnpm/exe.android-x64': 12.10.0 - '@pnpm/exe.darwin-arm64': 12.10.0 - '@pnpm/exe.darwin-x64': 12.10.0 - '@pnpm/exe.freebsd-x64': 12.10.0 - '@pnpm/exe.linux-arm64': 12.10.0 - '@pnpm/exe.linux-arm64-musl': 12.10.0 - '@pnpm/exe.linux-ppc64': 12.10.0 - '@pnpm/exe.linux-riscv64': 12.10.0 - '@pnpm/exe.linux-s390x': 12.10.0 - '@pnpm/exe.linux-x64': 12.10.0 - '@pnpm/exe.linux-x64-musl': 12.10.0 - '@pnpm/exe.win32-arm64': 12.10.0 - '@pnpm/exe.win32-x64': 12.10.0 + '@pnpm/exe.android-arm64': 12.10.1 + '@pnpm/exe.android-x64': 12.10.1 + '@pnpm/exe.darwin-arm64': 12.10.1 + '@pnpm/exe.darwin-x64': 12.10.1 + '@pnpm/exe.freebsd-x64': 12.10.1 + '@pnpm/exe.linux-arm64': 12.10.1 + '@pnpm/exe.linux-arm64-musl': 12.10.1 + '@pnpm/exe.linux-ppc64': 12.10.1 + '@pnpm/exe.linux-riscv64': 12.10.1 + '@pnpm/exe.linux-s390x': 12.10.1 + '@pnpm/exe.linux-x64': 12.10.1 + '@pnpm/exe.linux-x64-musl': 12.10.1 + '@pnpm/exe.win32-arm64': 12.10.1 + '@pnpm/exe.win32-x64': 12.10.1 --- lockfileVersion: '9.0' diff --git a/src/services/proxy-test-url.ts b/src/services/proxy-test-url.ts index acd597aba..cdee55d5d 100644 --- a/src/services/proxy-test-url.ts +++ b/src/services/proxy-test-url.ts @@ -5,7 +5,7 @@ import { } from '@/services/cmds' import { mutate } from '@/services/mutate' -export function readLegacyTestUrls(uid: string): Record { +function readLegacyTestUrls(uid: string): Record { try { const states = JSON.parse(localStorage.getItem('proxy-head-state') || '{}')[ uid From 0fefa7a7ff26fc317a328c0b1ac7e5f2bbde605c Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 9 Oct 2026 13:43:25 +0800 Subject: [PATCH 08/15] chore: bump version --- Cargo.lock | 2 +- Changelog.md | 34 +------------------------ docs/Changelog.history.md | 52 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 57 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ced8f2827..835f84693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1083,7 +1083,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clash-verge" -version = "2.5.8" +version = "2.5.9" dependencies = [ "aes-gcm", "anyhow", diff --git a/Changelog.md b/Changelog.md index 0d7bba18f..9fc2ff1fc 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,51 +1,19 @@ -## v2.5.8 +## v2.5.9
🐞 修复问题 -- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题 -- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题 -- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 -- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题 -- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 -- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 -- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题 -- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题 -- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题 -- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题 -- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题 - -**🖥️ Windows** - -- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 -- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 -- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题 - -**🍎 macOS** - -- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 - -**🖥️/🍎 Windows/macOS** - -- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
✨ 新增功能 -- 新增系统托盘代理组中的「测试延迟」菜单项
🚀 优化改进 -- 优化端口设置窗口的实现,精简重复代码 -- 优化侧边栏流量图表的 CPU 占用 - -**🖥️ Windows** - -- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
diff --git a/docs/Changelog.history.md b/docs/Changelog.history.md index 33b61d646..c50784676 100644 --- a/docs/Changelog.history.md +++ b/docs/Changelog.history.md @@ -1,3 +1,55 @@ +## v2.5.8 + +
+ 🐞 修复问题 + +- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题 +- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题 +- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 +- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题 +- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 +- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 +- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题 +- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题 +- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题 +- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题 +- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题 + +**🖥️ Windows** + +- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 +- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 +- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题 + +**🍎 macOS** + +- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 + +**🖥️/🍎 Windows/macOS** + +- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题 + +
+ +
+ ✨ 新增功能 + +- 新增系统托盘代理组中的「测试延迟」菜单项 + +
+ +
+ 🚀 优化改进 + +- 优化端口设置窗口的实现,精简重复代码 +- 优化侧边栏流量图表的 CPU 占用 + +**🖥️ Windows** + +- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法 + +
+ ## v2.5.7
diff --git a/package.json b/package.json index a26ade07c..7f9ebe593 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clash-verge", - "version": "2.5.8", + "version": "2.5.9", "license": "GPL-3.0-only", "scripts": { "prepare": "husky || true", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 49a555be6..73bb361b1 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clash-verge" -version = "2.5.8" +version = "2.5.9" description = "clash verge" authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] license = "GPL-3.0-only" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2231069d5..db0d56c2b 100755 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,5 +1,5 @@ { - "version": "2.5.8", + "version": "2.5.9", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "bundle": { "active": true, From 44477d97d4f568e8016d6a2b9a0001d52d8f56a1 Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 9 Oct 2026 13:53:14 +0800 Subject: [PATCH 09/15] fix(deps): remove obsolete tray-icon patch --- Cargo.lock | 5 ----- Cargo.toml | 5 ----- 2 files changed, 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 835f84693..962e3df28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10187,8 +10187,3 @@ dependencies = [ "syn 3.0.5", "winnow 1.0.4", ] - -[[patch.unused]] -name = "tray-icon" -version = "0.24.1" -source = "git+https://github.com/Tunglies/tray-icon.git?branch=fix%2Fmacos-27-click-reentrancy#5750c67d02d32f2af100e120ad665959d4268cef" diff --git a/Cargo.toml b/Cargo.toml index 6c5718cbc..3dffbca78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,11 +50,6 @@ split-debuginfo = "unpacked" # Backport Windows menu command ID recycling on the muda 0.20 compatibility branch. muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" } -# macOS Golden Gate fix for tray-icon click reentrancy issue -# Remove the following patch once the fix is merged upstream(tauri) -# https://github.com/tauri-apps/tray-icon/pull/365 -tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" } - [workspace.dependencies] clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" } From 52dca35acb4fa39bc37e258800a6324fd9a582c9 Mon Sep 17 00:00:00 2001 From: wonfen <96291150+wonfen@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:16:30 +0000 Subject: [PATCH 10/15] ci(signpath): add manual Windows test signing workflow (#8239) --- .github/workflows/signpath-test.yml | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .github/workflows/signpath-test.yml diff --git a/.github/workflows/signpath-test.yml b/.github/workflows/signpath-test.yml new file mode 100644 index 000000000..6d8c0c864 --- /dev/null +++ b/.github/workflows/signpath-test.yml @@ -0,0 +1,90 @@ +name: SignPath Test + +on: + workflow_dispatch: + +permissions: + contents: read + actions: read + +env: + CARGO_INCREMENTAL: 0 + HUSKY: 0 + +jobs: + sign-windows: + runs-on: windows-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.99.0' + targets: x86_64-pc-windows-msvc + + - name: Install Node + uses: actions/setup-node@v7 + with: + node-version: '24.21.0' + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare build resources + run: pnpm run prebuild x86_64-pc-windows-msvc + + - name: Build test installer + shell: pwsh + env: + NODE_OPTIONS: '--max-old-space-size=4096' + run: | + $configPath = Join-Path $env:RUNNER_TEMP 'signpath-test.json' + '{"bundle":{"createUpdaterArtifacts":false}}' | + Set-Content -LiteralPath $configPath -Encoding utf8 + pnpm exec tauri build --target x86_64-pc-windows-msvc --bundles nsis --config $configPath + + - name: Upload unsigned installer + id: unsigned + uses: actions/upload-artifact@v7 + with: + name: windows-x64-unsigned + path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe + if-no-files-found: error + + - name: Sign with SignPath test certificate + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: '5b298f4d-010d-4bae-b665-70551a58839c' + project-slug: 'clash-verge-rev' + signing-policy-slug: 'test-signing' + artifact-configuration-slug: 'initial' + github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: signed + + - name: Inspect installer signature + shell: pwsh + run: | + Get-ChildItem -Path signed/*-setup.exe | ForEach-Object { + $signature = Get-AuthenticodeSignature -LiteralPath $_.FullName + if (-not $signature.SignerCertificate) { + throw "No Authenticode signer certificate found for $($_.Name)" + } + $signature | Format-List Path, Status, StatusMessage, SignerCertificate + } + + - name: Upload signed test installer + uses: actions/upload-artifact@v7 + with: + name: windows-x64-test-signed + path: signed/*-setup.exe + if-no-files-found: error From 63ee674dcba6cf1b3784df4f5c340421b89b4d88 Mon Sep 17 00:00:00 2001 From: Magicapple <78581469+magicapple123@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:40:21 +0000 Subject: [PATCH 11/15] fix(ui): disable auto-capitalization for rule content input (#8052) On macOS, WKWebView applies system auto-capitalization to text inputs, so typing a lowercase rule payload (e.g. a domain) in the rule editor dialog turned its first letter into uppercase and required a manual correction. Apply the same input-hint attributes already used by other text fields in the app (autoComplete/autoCorrect/autoCapitalize/spellCheck) to the rule content field. Fixes #8038 --- src/components/profile/rules-editor-viewer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index 540981203..54d0d65bf 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -684,6 +684,9 @@ export const RulesEditorViewer = (props: Props) => { ruleType.name !== 'SUB-RULE' && ( Date: Sat, 10 Oct 2026 16:32:27 +0000 Subject: [PATCH 12/15] fix(sysproxy): bypass *.local by default on Windows (#8054) Hostnames such as mi13.local are resolved by mDNS on the local network, which the proxy core cannot do, so they failed with 'couldn't find ip' while the system proxy was on. The macOS default already bypasses *.local. Co-authored-by: Claude Opus 5.5 --- Changelog.md | 4 ++++ src-tauri/src/core/sysopt.rs | 2 +- src/components/setting/mods/sysproxy-viewer.tsx | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Changelog.md b/Changelog.md index 9fc2ff1fc..94b6045e2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -16,4 +16,8 @@ 🚀 优化改进 +**🖥️ Windows** + +- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理 +
diff --git a/src-tauri/src/core/sysopt.rs b/src-tauri/src/core/sysopt.rs index 140e1d089..9b3d158dc 100644 --- a/src-tauri/src/core/sysopt.rs +++ b/src-tauri/src/core/sysopt.rs @@ -261,7 +261,7 @@ impl Default for Sysopt { } #[cfg(target_os = "windows")] -static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; +static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;*.local;"; #[cfg(target_os = "windows")] static BYPASS_SEPARATOR: &str = ";"; #[cfg(target_os = "linux")] diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index e96a96bc4..a4080b774 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -138,7 +138,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { const defaultBypass = () => { if (isWindows) { - return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;' + return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;*.local;' } if (systemName === 'linux') { return 'localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1' From d6a2717fc461c05e48395bf1c013cee65c82a32c Mon Sep 17 00:00:00 2001 From: oomeow Date: Sun, 11 Oct 2026 12:01:50 +0000 Subject: [PATCH 13/15] fix(profiles): skip reloading current profile on URL import (#8092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com> --- Changelog.md | 1 + crates/clash-verge-i18n/locales/pt.yml | 2 ++ src/pages/profiles.tsx | 18 ++++++++++++------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Changelog.md b/Changelog.md index 94b6045e2..ee0c8c66c 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,7 @@
🐞 修复问题 +- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
diff --git a/crates/clash-verge-i18n/locales/pt.yml b/crates/clash-verge-i18n/locales/pt.yml index 2373529ee..5999d1b91 100644 --- a/crates/clash-verge-i18n/locales/pt.yml +++ b/crates/clash-verge-i18n/locales/pt.yml @@ -70,6 +70,8 @@ tray: global: Global profiles: Perfis proxies: Proxies + testLatency: Test Latency + testingLatency: Testing latency… systemProxy: Proxy do sistema tunMode: Modo TUN closeAllConnections: Fechar todas as conexões diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 78c38f973..e7b243028 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -212,10 +212,12 @@ const ProfilePage = () => { } setLoading(true) + const prevCurrent = profiles.current + const handleImportSuccess = async (noticeKey: string) => { showNotice.success(noticeKey) setUrl('') - await performRobustRefresh() + await performRobustRefresh(prevCurrent) } try { await mutate(() => importProfile(url), { @@ -261,7 +263,7 @@ const ProfilePage = () => { } // `useProfiles` already retries three times; add only one business-level retry. - const performRobustRefresh = async () => { + const performRobustRefresh = async (prevCurrent?: string) => { let retryCount = 0 const maxRetries = 1 const baseDelay = 200 @@ -270,13 +272,15 @@ const ProfilePage = () => { try { debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`) - await mutateProfiles() + const fresh = await fetchProfilesIntoCache() await new Promise((resolve) => setTimeout(resolve, baseDelay * (retryCount + 1)), ) - await onEnhance(false) + if (fresh?.current !== prevCurrent) { + await onEnhance(false) + } return } catch (error) { console.error(`[导入刷新] 第${retryCount + 1}次刷新失败:`, error) @@ -289,8 +293,10 @@ const ProfilePage = () => { console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`) try { - await fetchProfilesIntoCache() - await onEnhance(false) + const fresh = await fetchProfilesIntoCache() + if (fresh?.current !== prevCurrent) { + await onEnhance(false) + } showNotice.error( 'profiles.page.feedback.notifications.importNeedsRefresh', 3000, From 585c67f8c5baa6da1667312a155fcd85cb0d78f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:23:40 +0000 Subject: [PATCH 14/15] chore(deps): update rust crate zip to v9 (#8248) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 11 ++++++----- src-tauri/Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 962e3df28..83f2ef7ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,7 +1166,7 @@ dependencies = [ "windows-service", "windows-sys 0.61.2", "winreg 0.56.0", - "zip 8.6.0", + "zip 9.0.1", ] [[package]] @@ -4038,9 +4038,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.16.5" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" +checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b" dependencies = [ "sha2 0.11.0", ] @@ -10055,9 +10055,9 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" dependencies = [ "aes", "bzip2", @@ -10070,6 +10070,7 @@ dependencies = [ "indexmap 2.14.0", "lzma-rust2", "memchr", + "nix", "pbkdf2", "ppmd-rust", "sha1 0.11.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 73bb361b1..b5ad51225 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -80,7 +80,7 @@ tauri-plugin-fs = "2.6.0" tauri-plugin-process = "2.4.0" tauri-plugin-deep-link = "2.6.1" tauri-plugin-window-state = "2.5.0" -zip = "8.6.0" +zip = "9.0.1" flate2 = "1.1.10" reqwest_dav = "0.3.3" aes-gcm = { version = "0.11.1" } From 27efaf34f457f357bf73daa242df909fef1fa9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9D=A4=E6=98=AF=E7=BA=B1=E9=9B=BE=E9=85=B1=E5=93=9F?= =?UTF-8?q?=EF=BD=9E?= <49941141+Dragon1573@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:06:46 +0000 Subject: [PATCH 15/15] chore(deps): update dependency pnpm to v12.11.2 (#8252) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .devcontainer/devcontainer.json | 2 +- .tool-versions | 2 +- package.json | 2 +- pnpm-lock.yaml | 122 ++++++++++++++++---------------- 4 files changed, 64 insertions(+), 64 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 30b7aa948..27de2822c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "features": { "ghcr.io/devcontainers/features/node:2": { "version": "24.21.0", - "pnpmVersion": "12.10.1" + "pnpmVersion": "12.11.2" }, "ghcr.io/devcontainers/features/rust:1": { "version": "1.99.0", diff --git a/.tool-versions b/.tool-versions index df95322ab..25be9ef6d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ nodejs 24.21.0 -pnpm 12.10.1 +pnpm 12.11.2 rust 1.99.0 diff --git a/package.json b/package.json index 7f9ebe593..9ef745150 100644 --- a/package.json +++ b/package.json @@ -146,5 +146,5 @@ ] }, "type": "module", - "packageManager": "pnpm@12.10.1" + "packageManager": "pnpm@12.11.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 448ca2839..ceac328bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.10.1 - version: 12.10.1 + specifier: 12.11.2 + version: 12.11.2 packages: - '@pnpm/exe.android-arm64@12.10.1': - resolution: {integrity: sha512-CVTlHPwNvadQCJD9bGSMzWIhFX8QpjtqC2c27dhdnn/9iKzEjZ3qL62o3gHpKbIuo6qn4WxTKRoaokgNdReBig==} + '@pnpm/exe.android-arm64@12.11.2': + resolution: {integrity: sha512-owLw13OfPkEoY+hLBNeeG+dCFhywr7q2d/EHPz4mNL2RhKnv8gNwMARkjkiDQu234aItVwFNDeGnmQXm+ikkAg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.10.1': - resolution: {integrity: sha512-NFiYLIaodY7da0XIebwcU3JoP7IK/lzF7zgKSSP+9H+QcFnQ1GDfBkuBKCYJzZ+Xu0WH54neIPLBBCaOjaaauA==} + '@pnpm/exe.android-x64@12.11.2': + resolution: {integrity: sha512-GYPkVebl4iokOA/4MPSpGoUVfFlsth7uqOCe1XiaiM23LAdvUOMJBObqHJijr89ZqjwHm9pO6vO45GRwJ2U2qA==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.10.1': - resolution: {integrity: sha512-0O8tdQ8ijlPJudXspLMTcitMkdBEz6uUflazL5uQE0WMs85oQIp0U+5hfEktMFB64QqEhjqlvvujTwbzdr9oMg==} + '@pnpm/exe.darwin-arm64@12.11.2': + resolution: {integrity: sha512-n9M4On66y8FDxUAKgMO1FXt0qihyG4e+56p3/F0uo8Bx9KplW8BwKVnXigzCJ8aNM6sVVPkOE6M9XXbeVIxnZA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.10.1': - resolution: {integrity: sha512-ud+1/uZOKEo8P/AsPynOJc/LNwfI0j6WwCElibj71rU9l4nIBnsSn+wWTIPZPkZ4BBSH5+82azSnAARMyJ1lNA==} + '@pnpm/exe.darwin-x64@12.11.2': + resolution: {integrity: sha512-oSW7n3sBT0c0cyk4kj10T2Y6Mpm+OFEiCAWozgX039tdoI6yqJF1uaFD3sw7ugLdu6VV3+uY0pa1TUZIPdxDWA==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.10.1': - resolution: {integrity: sha512-mj9PE5rf8uAfQQ2K66HSATMSut+lNiB4eSqFd5+azFVCMnxNSy/32xpZQJT21PhnN3Y4qbK+iKHe5nWAxSuRhw==} + '@pnpm/exe.freebsd-x64@12.11.2': + resolution: {integrity: sha512-qbzc3KoDpa/Lkf8vs3jnDe6dUUbso+6eLIyssYFMiliLpYNxn8dirk11YoNnOQcdHVB50v5RFls1Yid6ttT5pg==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.10.1': - resolution: {integrity: sha512-xba+00wZJ8Um6rBJJjWl88uJj0tydB8dtomvjlCLvi0uL32vpa9CRP8Vv26FIXvbiZ1bo2RCzYt/eChxJVV52w==} + '@pnpm/exe.linux-arm64-musl@12.11.2': + resolution: {integrity: sha512-uMEYDwKRktGwANubQ+kYCwmhNwg3Ik6qK89ahsbQ5cP30R/5FVJb/T99vr9XRUBC/A7mZI6eeT92oyVas9ACyg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.10.1': - resolution: {integrity: sha512-oBdMp69ScC9UnniG4qZAQVr2fBDSXjm4yk/72vkEVyrlwS23yMxcRQl7Qdo3sgXPAygY6TWnPRKm4rXzjoyW7w==} + '@pnpm/exe.linux-arm64@12.11.2': + resolution: {integrity: sha512-6e8ZAQUwFXAWHHHv5FesWqf/kEK04wuitBv5f/gxzkC0DiBfaixr+f+6JQede8SF4Ji5LcMwVFJbzAtffpWxEg==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.10.1': - resolution: {integrity: sha512-Yyb3wu09h3c7mLme270uHFiVaAIUHC411Ru9LIU2BKUIstrGlXa9OqqVWoXaRgYRdFdOFknkwG7GNn6ygwLbnw==} + '@pnpm/exe.linux-ppc64@12.11.2': + resolution: {integrity: sha512-Ui8A4nepkVupx2VpuFfysEluMvWK6gTB5rgVts7Ss/+8dj0RD28x3F/seNeH/zSypgC6yKdV6qsKVQh/uffTuQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.10.1': - resolution: {integrity: sha512-PdkeUFFkybknnGkWP0H22eMpIiS4OZXL5BcQzF4lh+vmrW3IaC2HOkbANKgot/PKO0Kl77pzA/6PKFxgbgfXIQ==} + '@pnpm/exe.linux-riscv64@12.11.2': + resolution: {integrity: sha512-i1yLdQ5bu+wjQ+srtFoJAaY2gN/+oXHFdjul4llCeoDxHE0rTIG3R41NhHfCjyFPW4+CpbBT58933CL9sMOCnA==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.10.1': - resolution: {integrity: sha512-1okKuHKJwJPAbofDxfhZWAUxEWtee4ZSf7Pah8kcvocaJkf2WxFJ5KVTiNIvDml5GLidDQsgf7Pcf/WHbCak7Q==} + '@pnpm/exe.linux-s390x@12.11.2': + resolution: {integrity: sha512-YS1/lhPjjTsN4QL50VaFZKRa3Uc/bDnhE8eKbJMFvuX06UuFS5YaiDg04APoZdfVSGqTfX27QAg7rfLMSWaqxA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.10.1': - resolution: {integrity: sha512-e6IqzVNkPa7srFKJKnY23uWJ+qz0LaRWiTUo/sHfFXRRMRzB5uNOr6qWWip/jqhdGwU91n9J7LrEYZ6e0tRczw==} + '@pnpm/exe.linux-x64-musl@12.11.2': + resolution: {integrity: sha512-7eIxQR05fvJxCujUSKp5KTtehJ7QqrgqP/HD1+BcB/SaV2Be4XI1PEHiOgA1zxsiMIeMvTF6E4G4dYHrFjeXRg==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.10.1': - resolution: {integrity: sha512-ZByV0DRgWzGaDSjT5ddRBASdwz+r/qPMYh7o6RGmPQjtRGNFbSl94xY2DcAM8mqCOZJUUDtAXQ+wpBXcdpKlkg==} + '@pnpm/exe.linux-x64@12.11.2': + resolution: {integrity: sha512-FK7QKe24AYgppJwO3mpooYptduuvIGqKJPi5ac7MdnWkjUy46JBfYhCW0grnkWKC8YOf0x+vGviuQqJEj3SpQQ==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.10.1': - resolution: {integrity: sha512-I7QZHcjioG7orFeCRUFUHByJ1G0zFPnAlaGGyrUvb6MGkv5oeNwNPl7t+Gp+7zHfnMcgLGJ1a+Wfim0IDw3lOw==} + '@pnpm/exe.win32-arm64@12.11.2': + resolution: {integrity: sha512-PCTjVDdJb6VvraZxrEeFv6EqEA0hhIsAySx28e95J2Tqpi88hLpgOXgNqV0vpxq9A+b8GLQfyk/D+1UlJPv/YQ==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.10.1': - resolution: {integrity: sha512-4WJFdp58EKlCj0zLobrXU0vpszSZIr4X/3y/uNbAQqGW0zE+VdruZB0gFc3L2kzsUzVwTYbaCn+XEhnCYSAlTg==} + '@pnpm/exe.win32-x64@12.11.2': + resolution: {integrity: sha512-8sgX86OvRjJ3yZs/TvGR2L31wH9ScPZ+f+geKI3ikuB0RRiK3i4LlTRO8OXN6pdRqj8epMde4312MCjj7JsJdA==} cpu: [x64] os: [win32] - pnpm@12.10.1: - resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==} + pnpm@12.11.2: + resolution: {integrity: sha512-JaUWUqGWeFr18ovabf6NxYLr76SsHRgCIc4TkCMcX7dmN4IupLbkYzznSs3LCnf3Ja8RutOBd/3t2u6BipVcuQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.10.1': + '@pnpm/exe.android-arm64@12.11.2': optional: true - '@pnpm/exe.android-x64@12.10.1': + '@pnpm/exe.android-x64@12.11.2': optional: true - '@pnpm/exe.darwin-arm64@12.10.1': + '@pnpm/exe.darwin-arm64@12.11.2': optional: true - '@pnpm/exe.darwin-x64@12.10.1': + '@pnpm/exe.darwin-x64@12.11.2': optional: true - '@pnpm/exe.freebsd-x64@12.10.1': + '@pnpm/exe.freebsd-x64@12.11.2': optional: true - '@pnpm/exe.linux-arm64-musl@12.10.1': + '@pnpm/exe.linux-arm64-musl@12.11.2': optional: true - '@pnpm/exe.linux-arm64@12.10.1': + '@pnpm/exe.linux-arm64@12.11.2': optional: true - '@pnpm/exe.linux-ppc64@12.10.1': + '@pnpm/exe.linux-ppc64@12.11.2': optional: true - '@pnpm/exe.linux-riscv64@12.10.1': + '@pnpm/exe.linux-riscv64@12.11.2': optional: true - '@pnpm/exe.linux-s390x@12.10.1': + '@pnpm/exe.linux-s390x@12.11.2': optional: true - '@pnpm/exe.linux-x64-musl@12.10.1': + '@pnpm/exe.linux-x64-musl@12.11.2': optional: true - '@pnpm/exe.linux-x64@12.10.1': + '@pnpm/exe.linux-x64@12.11.2': optional: true - '@pnpm/exe.win32-arm64@12.10.1': + '@pnpm/exe.win32-arm64@12.11.2': optional: true - '@pnpm/exe.win32-x64@12.10.1': + '@pnpm/exe.win32-x64@12.11.2': optional: true - pnpm@12.10.1: + pnpm@12.11.2: optionalDependencies: - '@pnpm/exe.android-arm64': 12.10.1 - '@pnpm/exe.android-x64': 12.10.1 - '@pnpm/exe.darwin-arm64': 12.10.1 - '@pnpm/exe.darwin-x64': 12.10.1 - '@pnpm/exe.freebsd-x64': 12.10.1 - '@pnpm/exe.linux-arm64': 12.10.1 - '@pnpm/exe.linux-arm64-musl': 12.10.1 - '@pnpm/exe.linux-ppc64': 12.10.1 - '@pnpm/exe.linux-riscv64': 12.10.1 - '@pnpm/exe.linux-s390x': 12.10.1 - '@pnpm/exe.linux-x64': 12.10.1 - '@pnpm/exe.linux-x64-musl': 12.10.1 - '@pnpm/exe.win32-arm64': 12.10.1 - '@pnpm/exe.win32-x64': 12.10.1 + '@pnpm/exe.android-arm64': 12.11.2 + '@pnpm/exe.android-x64': 12.11.2 + '@pnpm/exe.darwin-arm64': 12.11.2 + '@pnpm/exe.darwin-x64': 12.11.2 + '@pnpm/exe.freebsd-x64': 12.11.2 + '@pnpm/exe.linux-arm64': 12.11.2 + '@pnpm/exe.linux-arm64-musl': 12.11.2 + '@pnpm/exe.linux-ppc64': 12.11.2 + '@pnpm/exe.linux-riscv64': 12.11.2 + '@pnpm/exe.linux-s390x': 12.11.2 + '@pnpm/exe.linux-x64': 12.11.2 + '@pnpm/exe.linux-x64-musl': 12.11.2 + '@pnpm/exe.win32-arm64': 12.11.2 + '@pnpm/exe.win32-x64': 12.11.2 --- lockfileVersion: '9.0'