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