Compare commits

..
Author SHA1 Message Date
Tunglies 0218924c3c fix(proxy): unify node switching and connection cleanup
Route frontend and tray node selections through the same backend flow for
selection, persistence, state refresh, and connection cleanup. Read the
group's previous selection before switching and preserve the original
rule of closing only connections whose chains contain that selection.

Run optional cleanup in the background and keep its failures separate
from successful selection. Remove redundant frontend effects and unused
IPC commands, and refresh the proxy view for every successful switch.

Refs: 32095daf90
Refs: bea0dde074
2026-11-07 19:53:24 +08:00
14 changed files with 199 additions and 359 deletions
+2 -2
View File
@@ -79,7 +79,7 @@
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.1.13",
"react-i18next": "17.1.10",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
@@ -132,7 +132,7 @@
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.2.0",
"webdriverio": "10.1.0",
"ws": "8.22.1"
},
"lint-staged": {
+82 -102
View File
@@ -275,8 +275,8 @@ importers:
specifier: ^7.88.0
version: 7.88.0(react@19.3.1)
react-i18next:
specifier: 17.1.13
version: 17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
specifier: 17.1.10
version: 17.1.10(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
react-markdown:
specifier: 10.1.0
version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0)
@@ -340,7 +340,7 @@ importers:
version: 6.1.1(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
'@wdio/tauri-service':
specifier: 1.5.1
version: 1.5.1(expect-webdriverio@6.1.0)(supports-color@7.2.0)(webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0))
version: 1.5.1(expect-webdriverio@6.1.0)(supports-color@7.2.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))
adm-zip:
specifier: ^0.6.1
version: 0.6.1
@@ -429,8 +429,8 @@ importers:
specifier: ^5.0.1
version: 5.0.1(@types/node@26.6.5)(vite@8.3.3(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.104.1)(terser@5.51.2)(yaml@2.9.1))
webdriverio:
specifier: 10.2.0
version: 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
specifier: 10.1.0
version: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
ws:
specifier: 8.22.1
version: 8.22.1
@@ -1381,9 +1381,6 @@ packages:
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@isaacs/nonrecursive-glob-match@1.1.0':
resolution: {integrity: sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==}
'@jest/diff-sequences@30.5.0':
resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -2601,9 +2598,9 @@ packages:
'@vscode/l10n@0.0.18':
resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==}
'@wdio/config@10.2.0':
resolution: {integrity: sha512-dCsMVpgmyL8fLRQVcxVyYXBG/pgtJeY897+LUxU/Vw+JvJOV2iTycHn5xUD5EehbNsstfxgJwkLCVunhJcbXdg==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
'@wdio/config@10.1.0':
resolution: {integrity: sha512-V4G0uIhLTlSJRKLfr0ypu3SJLL90+LCuo9niBgqTmpNpJz4K3nwK2Mz//JuAKmz/OVXPb1HxJlrm2snfSOI2WQ==}
engines: {node: '>=22.19.0'}
'@wdio/globals@9.31.3':
resolution: {integrity: sha512-ZzmRKUlwUBahNoNxHbAzst+9dNInEjLQc7Z3c86OhDRxAf3Ivw8k2rc56UCbyYiZnkd1dOpuA91zFiTrUNNORw==}
@@ -2612,9 +2609,9 @@ packages:
expect-webdriverio: ^6.0.9
webdriverio: ^9.28.0
'@wdio/logger@10.2.0':
resolution: {integrity: sha512-C7qpFSA+Zuu/6xbx+ALoEeOiyIWlD02/Llb8ufjypCOpe9rAy77JgAHeQlKYWg7aitRQRt7aG2oeix0mNhTk5A==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
'@wdio/logger@10.1.0':
resolution: {integrity: sha512-zZn9TMaxzTAHOGX5fLTs9E8Gw436l6xSFh+wZyXfRpgnzgeS5W/U0I6pBAjsvMn8rr5HAJC8Vy+WhiUxmbKsvQ==}
engines: {node: '>=22.19.0'}
'@wdio/logger@9.29.1':
resolution: {integrity: sha512-0ZAEIo6PNyMIJPlOGkIgyOJUjcd0pC8/QHlVAAe1c91/IcjZ1X+k0yidXHaboJdN7dq1XPUacmhRdtua0U5EZg==}
@@ -2641,13 +2638,13 @@ packages:
tsx:
optional: true
'@wdio/protocols@10.2.0':
resolution: {integrity: sha512-HuSsvpv7hV8hbhFdlMraz7c+Bpu9O4FiKg2XdztZupj59wx0c7n7YPnZRGEY5yW3/wRt9PfqWkBI0EO81Y+Zqw==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
'@wdio/protocols@10.0.2':
resolution: {integrity: sha512-ewuQuu+niq/ZpV1TLn904nDA4F9n4LLu9X1U8Juwwm1ahq09dpEP8lEytDnpaD0f21uIx//mFBKmBAjSexMdEA==}
engines: {node: '>=22.19.0'}
'@wdio/repl@10.2.0':
resolution: {integrity: sha512-bVx6C9c1LgAzOyWevg796NwghExw28oPLp0NlfYGc0gErxO8wtpvLJGwCYLXQ/V372b2en5H4OZ05QXoGmXOWw==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
'@wdio/repl@10.0.0':
resolution: {integrity: sha512-wAC9ksdWWv2E3Sj86eNKxB3IUt0gEyw9rgDGAWjk/GWgi6K3e+/n3erGNweI9JMJyC/+RNe0qHgVPQpRJMRVpg==}
engines: {node: '>=22.19.0'}
'@wdio/reporter@9.31.2':
resolution: {integrity: sha512-3cKE6vCOsro/Ep9dGTIbo3FBn3WNvJ++BIn32lFd9m19pFdjuwlVgLtPXHb9jWNCJs740h5AIUcVkcscqUYhAA==}
@@ -2663,17 +2660,17 @@ packages:
peerDependencies:
webdriverio: ^9.0.0
'@wdio/types@10.2.0':
resolution: {integrity: sha512-a9qwqHzf0t2ctrXs+qUEhobH/VK0PmeoErCBWdN6DRxhTP8YiXQco4wjTj1z7H4k5xwZYxMT0KC0QzN/e4B73Q==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
'@wdio/types@10.1.0':
resolution: {integrity: sha512-MxGhIjP74w2xaIhqtzBfrtzDc41qyb9cMqvHgdIDC7MbaREJxpUd/paAg8qmouIph7hBp8MruXap/BPKlVXmVA==}
engines: {node: '>=22.19.0'}
'@wdio/types@9.31.2':
resolution: {integrity: sha512-lQKaiQDCJJ6VC1K/cKzaAxCpwB9pL1U0VbmX2uGksMxO4EDbNuLvIxWryuUDJ7OaSYTjxILTKYFErdbT/xEikg==}
engines: {node: '>=18.20.0'}
'@wdio/utils@10.2.0':
resolution: {integrity: sha512-C4ok/WatCBV3gHmIYGjj4Aa43tQTD53eTFs3okpKxuc1wmFy/1zDFOYLDjeTq4xSFygoRBI+ZMyc9LBEiok3ng==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
'@wdio/utils@10.1.0':
resolution: {integrity: sha512-BABJY+L4JidOD2bfQe3icuRZ0jQeUimTwUEe66I4BHVGWwaa5QBeVU+JZmdNQLjMA9q+Nk385aL96Mb1/PbKYw==}
engines: {node: '>=22.19.0'}
'@zip.js/zip.js@2.24.0':
resolution: {integrity: sha512-WlaVKzdqBK+RF3bdiXZWbdCnvGB6PdO4IHC1mn/2Yfl3bASgqUNegnU3+PNg61iecwoPfUi5BgLUPqBukXpyWw==}
@@ -3089,10 +3086,6 @@ packages:
resolution: {integrity: sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
decamelize@7.0.0:
resolution: {integrity: sha512-uT/0KzZIx9u4b5vW3HLWtSYwjLTuY2QFVY3xNmfXXUmTqnfgzfT8DEA2gY93go5sXEjnbqP66KcURdVxm0rDXA==}
engines: {node: '>=22'}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -3587,8 +3580,8 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@14.0.4:
resolution: {integrity: sha512-mMiwNKoJZ4HAIRVJxotO6LsNRReA30gQwP9E2/p94tfLv+jygCNpKDjhhSL6OLY6LE371EZm5EAf7RpYuUqlsw==}
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
globals@17.12.0:
@@ -4128,10 +4121,6 @@ packages:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimatch@11.0.5:
resolution: {integrity: sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==}
engines: {node: 20 || >=22}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4289,8 +4278,8 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@3.0.0:
resolution: {integrity: sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-type@4.0.0:
@@ -4399,8 +4388,8 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.1.13:
resolution: {integrity: sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==}
react-i18next@17.1.10:
resolution: {integrity: sha512-XuUuA/rxukE4hXuR//PN308DuSQkWqKNNWBUj0dDTR087l3+6WLid3NC/w55ALrXA/dtWb7lychJ4rQLR3DCDg==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
@@ -5027,13 +5016,13 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webdriver@10.2.0:
resolution: {integrity: sha512-RV2+j+CAgJgJb59QliIqMik2DmP4l9dW38tDSmzWdilonCAxM5fKZAydpBt6CNlToG0jqpZ9nL3Mo6YnQh/xqw==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
webdriver@10.1.0:
resolution: {integrity: sha512-W3lNUfcjsD5T62U7HackcsfKefrBoUect9PkyObhuByjPFjR+JwmkWAjbFYHlATpZwFZN5d5h28VBanG4BUe4g==}
engines: {node: '>=22.19.0'}
webdriverio@10.2.0:
resolution: {integrity: sha512-efmXasjo/+tWN+C5HCqsaPIJDJLH7smua1aeizL/DoEDg7f4YYVLgD9Zhav9DYrrouag9C04Mmj6bo6a2KA3hA==}
engines: {node: '>=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1'}
webdriverio@10.1.0:
resolution: {integrity: sha512-RDfW0GkHFM/ItdKEw8YvmLgcb5szUlJOKEVUoRgIeeQQ4+a7UMVavC6TVK438wNI32IHu0R1MoMrSW/6DRWYmQ==}
engines: {node: '>=22.19.0'}
peerDependencies:
puppeteer-core: '>=24 <26'
peerDependenciesMeta:
@@ -6253,8 +6242,6 @@ snapshots:
dependencies:
minipass: 7.1.3
'@isaacs/nonrecursive-glob-match@1.1.0': {}
'@jest/diff-sequences@30.5.0': {}
'@jest/expect-utils@30.5.2':
@@ -7233,13 +7220,13 @@ snapshots:
'@vscode/l10n@0.0.18': {}
'@wdio/config@10.2.0(supports-color@7.2.0)(yauzl@2.10.0)':
'@wdio/config@10.1.0(supports-color@7.2.0)(yauzl@2.10.0)':
dependencies:
'@wdio/logger': 10.2.0
'@wdio/types': 10.2.0
'@wdio/utils': 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/logger': 10.1.0
'@wdio/types': 10.1.0
'@wdio/utils': 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
deepmerge-ts: 8.0.2
glob: 14.0.4
glob: 13.0.6
import-meta-resolve: 4.2.0
jiti: 2.7.0
transitivePeerDependencies:
@@ -7247,12 +7234,12 @@ snapshots:
- supports-color
- yauzl
'@wdio/globals@9.31.3(expect-webdriverio@6.1.0)(webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0))':
'@wdio/globals@9.31.3(expect-webdriverio@6.1.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))':
dependencies:
expect-webdriverio: 6.1.0(@wdio/globals@9.31.3)(@wdio/logger@9.29.1)(webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0))
webdriverio: 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
expect-webdriverio: 6.1.0(@wdio/globals@9.31.3)(@wdio/logger@9.29.1)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))
webdriverio: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/logger@10.2.0':
'@wdio/logger@10.1.0':
dependencies:
chalk: 6.0.1
loglevel: 1.9.2
@@ -7296,9 +7283,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@wdio/protocols@10.2.0': {}
'@wdio/protocols@10.0.2': {}
'@wdio/repl@10.2.0':
'@wdio/repl@10.0.0':
dependencies:
'@types/node': 22.20.5
@@ -7318,9 +7305,9 @@ snapshots:
easy-table: 1.2.0
pretty-ms: 9.3.1
'@wdio/tauri-service@1.5.1(expect-webdriverio@6.1.0)(supports-color@7.2.0)(webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0))':
'@wdio/tauri-service@1.5.1(expect-webdriverio@6.1.0)(supports-color@7.2.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))':
dependencies:
'@wdio/globals': 9.31.3(expect-webdriverio@6.1.0)(webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0))
'@wdio/globals': 9.31.3(expect-webdriverio@6.1.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))
'@wdio/logger': 9.29.1
'@wdio/native-core': 1.3.1(supports-color@7.2.0)
'@wdio/native-spy': 1.4.1
@@ -7331,13 +7318,13 @@ snapshots:
debug: 4.4.3(supports-color@7.2.0)
get-port: 7.2.0
tslib: 2.8.1
webdriverio: 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
webdriverio: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
transitivePeerDependencies:
- expect-webdriverio
- supports-color
- tsx
'@wdio/types@10.2.0':
'@wdio/types@10.1.0':
dependencies:
'@types/node': 22.20.5
@@ -7345,18 +7332,18 @@ snapshots:
dependencies:
'@types/node': 20.19.43
'@wdio/utils@10.2.0(supports-color@7.2.0)(yauzl@2.10.0)':
'@wdio/utils@10.1.0(supports-color@7.2.0)(yauzl@2.10.0)':
dependencies:
'@puppeteer/browsers': 3.2.3(yauzl@2.10.0)
'@types/split2': 4.2.3
'@wdio/logger': 10.2.0
'@wdio/types': 10.2.0
'@wdio/logger': 10.1.0
'@wdio/types': 10.1.0
aria-query: 5.3.2
decamelize: 7.0.0
decamelize: 6.0.1
deepmerge-ts: 8.0.2
edgedriver: 8.0.0(@wdio/logger@10.2.0)
edgedriver: 8.0.0(@wdio/logger@10.1.0)
electron-to-chromium: 1.5.444
geckodriver: 8.0.0(@wdio/logger@10.2.0)
geckodriver: 8.0.0(@wdio/logger@10.1.0)
get-port: 7.2.0
import-meta-resolve: 4.2.0
locate-app: 2.5.0
@@ -7767,8 +7754,6 @@ snapshots:
decamelize@6.0.1: {}
decamelize@7.0.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -7840,9 +7825,9 @@ snapshots:
optionalDependencies:
wcwidth: 1.0.1
edgedriver@8.0.0(@wdio/logger@10.2.0):
edgedriver@8.0.0(@wdio/logger@10.1.0):
dependencies:
'@wdio/logger': 10.2.0
'@wdio/logger': 10.1.0
'@zip.js/zip.js': 2.24.0
decamelize: 6.0.1
undici: 8.12.3
@@ -8233,15 +8218,15 @@ snapshots:
expect-type@1.4.0: {}
expect-webdriverio@6.1.0(@wdio/globals@9.31.3)(@wdio/logger@9.29.1)(webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0)):
expect-webdriverio@6.1.0(@wdio/globals@9.31.3)(@wdio/logger@9.29.1)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0)):
dependencies:
'@vitest/snapshot': 4.1.11
'@wdio/globals': 9.31.3(expect-webdriverio@6.1.0)(webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0))
'@wdio/globals': 9.31.3(expect-webdriverio@6.1.0)(webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0))
'@wdio/logger': 9.29.1
deep-eql: 5.0.2
expect: 30.5.2
jest-matcher-utils: 30.5.2
webdriverio: 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
webdriverio: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
expect@30.5.2:
dependencies:
@@ -8340,9 +8325,9 @@ snapshots:
function-bind@1.1.2: {}
geckodriver@8.0.0(@wdio/logger@10.2.0):
geckodriver@8.0.0(@wdio/logger@10.1.0):
dependencies:
'@wdio/logger': 10.2.0
'@wdio/logger': 10.1.0
'@zip.js/zip.js': 2.24.0
decamelize: 6.0.1
modern-tar: 0.8.5
@@ -8382,11 +8367,11 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@14.0.4:
glob@13.0.6:
dependencies:
minimatch: 11.0.5
minimatch: 10.2.6
minipass: 7.1.3
path-scurry: 3.0.0
path-scurry: 2.0.2
globals@17.12.0: {}
@@ -9078,11 +9063,6 @@ snapshots:
dependencies:
brace-expansion: 5.0.12
minimatch@11.0.5:
dependencies:
'@isaacs/nonrecursive-glob-match': 1.1.0
brace-expansion: 5.0.12
minipass@7.1.3: {}
minizlib@3.1.0:
@@ -9278,7 +9258,7 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@3.0.0:
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.3
minipass: 7.1.3
@@ -9362,7 +9342,7 @@ snapshots:
dependencies:
react: 19.3.1
react-i18next@17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
react-i18next@17.1.10(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 4.0.1
@@ -9995,15 +9975,15 @@ snapshots:
web-namespaces@2.0.1: {}
webdriver@10.2.0(supports-color@7.2.0)(yauzl@2.10.0):
webdriver@10.1.0(supports-color@7.2.0)(yauzl@2.10.0):
dependencies:
'@types/node': 22.20.5
'@types/ws': 8.18.1
'@wdio/config': 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/logger': 10.2.0
'@wdio/protocols': 10.2.0
'@wdio/types': 10.2.0
'@wdio/utils': 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/config': 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/logger': 10.1.0
'@wdio/protocols': 10.0.2
'@wdio/types': 10.1.0
'@wdio/utils': 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
deepmerge-ts: 8.0.2
https-proxy-agent: 9.1.0(supports-color@7.2.0)
undici: 8.12.3
@@ -10016,16 +9996,16 @@ snapshots:
- utf-8-validate
- yauzl
webdriverio@10.2.0(supports-color@7.2.0)(yauzl@2.10.0):
webdriverio@10.1.0(supports-color@7.2.0)(yauzl@2.10.0):
dependencies:
'@types/node': 22.20.5
'@types/sinonjs__fake-timers': 15.0.1
'@wdio/config': 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/logger': 10.2.0
'@wdio/protocols': 10.2.0
'@wdio/repl': 10.2.0
'@wdio/types': 10.2.0
'@wdio/utils': 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/config': 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
'@wdio/logger': 10.1.0
'@wdio/protocols': 10.0.2
'@wdio/repl': 10.0.0
'@wdio/types': 10.1.0
'@wdio/utils': 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
archiver: 8.0.0
aria-query: 5.3.2
cheerio: 1.2.0
@@ -10040,7 +10020,7 @@ snapshots:
rgb2hex: 0.2.5
serialize-error: 13.0.2
urlpattern-polyfill: 10.1.0
webdriver: 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
webdriver: 10.1.0(supports-color@7.2.0)(yauzl@2.10.0)
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
+3 -57
View File
@@ -5,16 +5,10 @@ use crate::{
core::{
handle::Handle,
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use std::{
collections::{BTreeMap, HashSet},
sync::atomic::{AtomicBool, Ordering},
};
use std::collections::{BTreeMap, HashSet};
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command]
@@ -27,15 +21,12 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
#[tauri::command]
#[specta::specta]
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name)
pub async fn switch_proxy_node(group_name: String, proxy_name: String, unfix: bool) -> CmdResult<()> {
crate::feat::switch_proxy_node(&group_name, &proxy_name, unfix)
.await
.stringify_err()
}
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
let mut seen = HashSet::new();
@@ -124,48 +115,3 @@ pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
providers: providers.ok(),
}))
}
#[tauri::command]
#[specta::specta]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
AsyncHandler::spawn(move || async move {
run_tray_sync_loop().await;
});
} else {
TRAY_SYNC_PENDING.store(true, Ordering::Release);
}
Ok(())
}
async fn run_tray_sync_loop() {
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
loop {
match Tray::global().update_menu().await {
Ok(_) => {
logging!(debug, Type::Cmd, "Tray proxy selection synced successfully");
}
Err(e) => {
logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e:#}");
}
}
if !TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel) {
TRAY_SYNC_RUNNING.store(false, Ordering::Release);
if TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel)
&& TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
continue;
}
break;
}
}
}
+3 -1
View File
@@ -1000,7 +1000,9 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
Some((g, p)) => (g, p),
None => return,
};
feat::switch_proxy_node(group_name, proxy_name).await;
if let Err(err) = feat::switch_proxy_node(group_name, proxy_name, false).await {
logging!(error, Type::Tray, "Failed to switch proxy: {err:#}");
}
}
_ => {
logging!(debug, Type::Tray, "Unhandled tray menu event: {:?}", event.id);
+73 -24
View File
@@ -4,6 +4,7 @@ use crate::{
cmd,
config::{Config, PrfItem, PrfOption, profiles::profiles_update_item_safe},
core::{CoreManager, handle, tray, validate::ValidationOutcome},
process::AsyncHandler,
utils::help::{mask_err, mask_url},
};
use anyhow::{Result, bail};
@@ -21,37 +22,85 @@ pub async fn toggle_proxy_profile(profile_index: String) {
}
}
/// Tell the profile which node this group is on now.
///
/// The core is already switched by the time this runs, so failing to record is not a reason to
/// report the switch as failed — but it is worth a log line, because what the profile holds is
/// what gets re-applied the next time a core starts.
async fn record_switched_node(group_name: &str, proxy_name: &str) {
if let Err(error) = crate::config::profiles::record_selected_node(group_name, proxy_name).await {
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str, unfix: bool) -> Result<()> {
let mihomo = handle::Handle::mihomo();
let previous_proxy = if Config::verge().await.data_arc().auto_close_connection.unwrap_or(false) {
match mihomo.get_proxy_by_name(group_name).await {
Ok(group) => group.now.filter(|name| !name.is_empty()),
Err(err) => {
logging!(
warn,
Type::Core,
"Failed to read previous proxy selection for {group_name}: {err}"
);
None
}
}
} else {
None
};
if unfix {
mihomo.unfixed_proxy(group_name).await?;
} else {
mihomo.select_node_for_group(group_name, proxy_name).await?;
}
let save_result = if unfix {
crate::config::profiles::forget_selected_node(group_name).await
} else {
crate::config::profiles::record_selected_node(group_name, proxy_name).await
};
// The core selection is already applied; persistence failure must not report it as failed.
if let Err(error) = save_result {
logging!(
warn,
Type::Config,
"切换代理成功但未能记录到配置: {} -> {}, 错误: {error:#}",
group_name,
proxy_name
"Failed to persist proxy selection for {group_name}: {error:#}"
);
}
}
announce(Refresh::Proxies);
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
.await
{
Ok(_) => {
record_switched_node(group_name, proxy_name).await;
announce(Refresh::Proxies);
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
logging!(error, Type::Tray, "切换代理失败: {err:?}");
}
if let Some(previous_proxy) = previous_proxy {
AsyncHandler::spawn(move || async move {
let connections = match mihomo.get_connections().await {
Ok(connections) => connections,
Err(err) => {
logging!(
warn,
Type::Core,
"Failed to read connections after switching proxy: {err}"
);
return;
}
};
futures::future::join_all(
connections
.connections
.unwrap_or_default()
.into_iter()
.filter(|connection| connection.chains.contains(&previous_proxy))
.map(|connection| async move {
if let Err(err) = mihomo.close_connection(&connection.id).await {
logging!(
warn,
Type::Core,
"Failed to close connection {} after switching proxy: {err}",
connection.id
);
}
}),
)
.await;
});
}
if let Err(err) = tray::Tray::global().update_menu().await {
logging!(
warn,
Type::Tray,
"Failed to refresh tray after switching proxy: {err:#}"
);
}
Ok(())
}
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
+1 -2
View File
@@ -45,9 +45,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
cmd::update_proxy_chain_config_in_runtime,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
cmd::record_selected_node,
cmd::forget_selected_node,
cmd::switch_proxy_node,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::set_dns_override,
+1 -5
View File
@@ -606,9 +606,6 @@ export const CurrentProxyCard = () => {
)
const { select } = useProxySelection({
onSuccess: () => {
refreshProxy()
},
onError: (error) => {
console.error('代理切换失败', error)
refreshProxy()
@@ -755,10 +752,9 @@ export const CurrentProxyCard = () => {
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
return
}
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
// The profile selection is the durable source across core restarts and run modes.
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
select(selectedGroup.name, nextName, selectedGroup.fixed!)
},
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
)
+2 -5
View File
@@ -470,9 +470,6 @@ function NormalProxyGroups(props: { mode: string }) {
}, [handleScroll])
const { select } = useProxySelection({
onSuccess: () => {
onProxies()
},
onError: (error) => {
console.error('代理切换失败', error)
onProxies()
@@ -487,12 +484,12 @@ function NormalProxyGroups(props: { mode: string }) {
if (group.name === globalGroupName) {
pendingRevealRef.current =
member.kind === 'group' ? member.ref.name : null
// The selection refetches the view, so skip onHeadState's refetch.
// The backend selection event refreshes the view.
if (member.kind === 'group') {
setHeadState(member.ref.name, { open: true })
}
}
select(group.name, member.ref.name, group.now!, group.fixed!)
select(group.name, member.ref.name, group.fixed!)
},
[globalGroupName, select, setHeadState],
)
+13 -29
View File
@@ -1,51 +1,35 @@
import { selectNodeForGroup } from 'tauri-plugin-mihomo-api'
import { expect, it, vi } from 'vitest'
import { switchProxyNode } from '@/services/cmds'
import { useProxySelection } from './use-proxy-selection'
const record = vi.hoisted(() => vi.fn())
vi.mock('react', () => ({
useCallback: (callback: unknown) => callback,
useRef: (current: unknown) => ({ current }),
}))
vi.mock('@/hooks/use-record-selection', () => ({
useRecordSelection: () => record,
useForgetSelection: () => vi.fn(),
}))
vi.mock('@/hooks/use-verge', () => ({ useVerge: () => ({ verge: {} }) }))
vi.mock('tauri-plugin-mihomo-api', () => ({
selectNodeForGroup: vi.fn(),
unfixedProxy: vi.fn(),
}))
vi.mock('@/services/cmds', () => ({ syncTrayProxySelection: vi.fn() }))
vi.mock('@/services/cmds', () => ({ switchProxyNode: vi.fn() }))
vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } }))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
it('applies and records independent group selections while another group is pending', async () => {
it('requests independent group selections while another group is pending', async () => {
let finishFirst!: () => void
vi.mocked(selectNodeForGroup)
vi.mocked(switchProxyNode)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirst = resolve
finishFirst = () => resolve(null)
}),
)
.mockResolvedValueOnce(undefined)
const firstSuccess = vi.fn()
const secondSuccess = vi.fn()
const first = useProxySelection({ onSuccess: firstSuccess })
const second = useProxySelection({ onSuccess: secondSuccess })
.mockResolvedValueOnce(null)
const first = useProxySelection()
const second = useProxySelection()
first.select('Group A', 'Node A')
second.select('Group B', 'Node B')
second.select('Group B', 'Node B', 'Node B')
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
expect(switchProxyNode).toHaveBeenCalledWith('Group B', 'Node B', true),
)
expect(selectNodeForGroup).toHaveBeenCalledTimes(2)
expect(firstSuccess).not.toHaveBeenCalled()
expect(secondSuccess).toHaveBeenCalledOnce()
expect(switchProxyNode).toHaveBeenCalledWith('Group A', 'Node A', false)
expect(switchProxyNode).toHaveBeenCalledTimes(2)
finishFirst()
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group A', 'Node A'),
)
expect(firstSuccess).toHaveBeenCalledOnce()
})
+8 -96
View File
@@ -1,121 +1,39 @@
import { useCallback, useRef } from 'react'
import {
closeConnection,
getConnections,
selectNodeForGroup,
unfixedProxy,
} from 'tauri-plugin-mihomo-api'
import {
useForgetSelection,
useRecordSelection,
} from '@/hooks/use-record-selection'
import { useVerge } from '@/hooks/use-verge'
import { syncTrayProxySelection } from '@/services/cmds'
import { switchProxyNode } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { debugLog } from '@/utils/debug'
// 缓存连接清理
const cleanupConnections = async (previousProxy: string) => {
try {
const { connections } = await getConnections()
const cleanupPromises = (connections ?? [])
.filter((conn) => conn.chains.includes(previousProxy))
.map((conn) => closeConnection(conn.id))
if (cleanupPromises.length > 0) {
await Promise.allSettled(cleanupPromises)
debugLog(`[ProxySelection] 清理了 ${cleanupPromises.length} 个连接`)
}
} catch (error) {
console.warn('[ProxySelection] 连接清理失败:', error)
}
}
interface ProxySelectionOptions {
onSuccess?: () => void
onError?: (error: any) => void
}
interface ProxyChangeRequest {
groupName: string
proxyName: string
previousProxy?: string
fixed?: string
}
// 代理选择 Hook
export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const recordSelection = useRecordSelection()
const forgetSelection = useForgetSelection()
const { verge } = useVerge()
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
const isProcessingRef = useRef(false)
const { onSuccess, onError } = options
const autoCloseConnection = verge?.auto_close_connection ?? false
// 切换节点
const syncTraySelection = useCallback(() => {
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxySelection] 托盘状态同步失败:', error)
})
}, [])
const { onError } = options
const executeChange = useCallback(
async (request: ProxyChangeRequest) => {
const { groupName, proxyName, previousProxy, fixed } = request
const isFixedProxy = fixed === proxyName
if (isFixedProxy) {
debugLog(`[ProxySelection] 代理取消固定: ${groupName} -> ${proxyName}`)
} else {
debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`)
}
async ({ groupName, proxyName, fixed }: ProxyChangeRequest) => {
try {
const result = await mutate(
() =>
isFixedProxy
? unfixedProxy(groupName)
: selectNodeForGroup(groupName, proxyName),
await mutate(
() => switchProxyNode(groupName, proxyName, fixed === proxyName),
{ id: `select-proxy-node:${groupName}`, errorNotice: false },
)
if (!result.ok) return
onSuccess?.()
syncTraySelection()
if (isFixedProxy) {
await forgetSelection(groupName)
debugLog(`[ProxySelection] 代理和状态同步完成: ${groupName}`)
} else {
await recordSelection(groupName, proxyName)
debugLog(
`[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`,
)
}
if (autoCloseConnection && previousProxy) {
void cleanupConnections(previousProxy)
}
} catch (error) {
console.error(
`[ProxySelection] 代理切换失败: ${groupName} -> ${proxyName}`,
`[ProxySelection] Failed to switch proxy: ${groupName} -> ${proxyName}`,
error,
)
onError?.(error)
}
},
[
autoCloseConnection,
forgetSelection,
onError,
onSuccess,
recordSelection,
syncTraySelection,
],
[onError],
)
const flushChangeQueue = useCallback(async () => {
@@ -137,16 +55,10 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
}, [executeChange])
const select = useCallback(
(
groupName: string,
proxyName: string,
previousProxy?: string,
fixed?: string,
) => {
(groupName: string, proxyName: string, fixed?: string) => {
pendingRequestRef.current = {
groupName,
proxyName,
previousProxy,
fixed,
}
void flushChangeQueue()
+1 -14
View File
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { forgetSelectedNode, recordSelectedNode } from '@/services/cmds'
import { recordSelectedNode } from '@/services/cmds'
import { mutate } from '@/services/mutate'
/**
@@ -19,16 +19,3 @@ export const useRecordSelection = () => {
}
}, [])
}
export const useForgetSelection = () => {
return useCallback(async (groupName: string) => {
try {
await mutate(() => forgetSelectedNode(groupName), {
id: `persist-selected-node:${groupName}`,
errorNotice: false,
})
} catch (error) {
console.error('[Selection] 清除代理选择失败:', error)
}
}, [])
}
+2 -4
View File
@@ -78,13 +78,11 @@ export const commands = {
}),
invokeUwpTool: () => __TAURI_INVOKE<null>('invoke_uwp_tool'),
copyClashEnv: () => __TAURI_INVOKE<null>('copy_clash_env'),
syncTrayProxySelection: () =>
__TAURI_INVOKE<null>('sync_tray_proxy_selection'),
/** Merges one selection pair into fresh backend state to avoid stale-list overwrites. */
recordSelectedNode: (groupName: string, node: string) =>
__TAURI_INVOKE<null>('record_selected_node', { groupName, node }),
forgetSelectedNode: (groupName: string) =>
__TAURI_INVOKE<null>('forget_selected_node', { groupName }),
switchProxyNode: (groupName: string, proxyName: string, unfix: boolean) =>
__TAURI_INVOKE<null>('switch_proxy_node', { groupName, proxyName, unfix }),
saveDnsConfig: (dnsConfig: YamlMapping) =>
__TAURI_INVOKE<null>('save_dns_config', { dnsConfig }),
applyDnsConfig: (apply: boolean) =>
+2 -12
View File
@@ -29,14 +29,11 @@ export interface BusHandlerDeps {
/**
* Pure per-event handler table for the bus. Handlers may trigger documented
* side effects (SWR revalidation, notice toasts) but write store state only
* through dispatched pure actions. Created exactly once per bus mount; the
* throttles below live in the returned closure, mirroring the previous
* scattered listeners.
* through dispatched pure actions. Created exactly once per bus mount.
*/
export const createEventBus = (deps: BusHandlerDeps) => {
let lastProfileId: string | null = null
let lastProfileChangeTime = 0
let lastProxyRefreshTime = 0
const refreshThrottle = 800
const handleProfileChanged = (newProfileId: string) => {
@@ -52,13 +49,6 @@ export const createEventBus = (deps: BusHandlerDeps) => {
deps.revalidateKeys(['getProfiles'])
}
const handleRefreshProxyConfig = () => {
const now = Date.now()
if (now - lastProxyRefreshTime <= refreshThrottle) return
lastProxyRefreshTime = now
deps.revalidateKeys(['getProxyView'])
}
const handlers: BusHandlers = {
'verge://refresh-clash-config': () =>
deps.revalidateKeys([
@@ -74,7 +64,7 @@ export const createEventBus = (deps: BusHandlerDeps) => {
'verge://refresh-verge-config': () =>
deps.revalidateKeys(['getVergeConfig', 'getSystemProxy', 'getAutoProxy']),
'verge://refresh-profiles': () => deps.revalidateKeys(['getProfiles']),
'verge://refresh-proxy-config': handleRefreshProxyConfig,
'verge://refresh-proxy-config': () => deps.revalidateKeys(['getProxyView']),
'verge://notice-message': (payload) => deps.handleNotice(payload),
'profile-changed': handleProfileChanged,
'verge://timer-updated': (uid) =>
+6 -6
View File
@@ -152,17 +152,17 @@ export async function patchClashMode(payload: string) {
return commands.patchClashMode(payload)
}
export async function syncTrayProxySelection() {
return commands.syncTrayProxySelection()
}
/** Sends one selection pair so the backend can merge against current profile state. */
export async function recordSelectedNode(groupName: string, node: string) {
return commands.recordSelectedNode(groupName, node)
}
export async function forgetSelectedNode(groupName: string) {
return commands.forgetSelectedNode(groupName)
export async function switchProxyNode(
groupName: string,
proxyName: string,
unfix: boolean,
) {
return commands.switchProxyNode(groupName, proxyName, unfix)
}
export async function getProxyView(): Promise<ProxyViewV1> {