From daadb126e077c9a00cabbdf2df66e3828ffa2a22 Mon Sep 17 00:00:00 2001 From: oomeow Date: Tue, 18 Aug 2026 12:16:42 +0000 Subject: [PATCH] feat: allow manually unfixing proxy nodes (#7756) * feat(proxy): add ability to clear persisted node selection for groups * feat(hooks): make proxy selection operations asynchronous * chore: update tauri-plugin-mihomo deps * refactor: rename clear_selected_node to forget_selected_node * refactor(hooks): rename isFixed to isFixedProxy in useProxySelection * docs: update changelog with manual node cancellation support * chore(layout): remove dev-only mihomo websocket debugging interval --- Cargo.lock | 42 +++-------- Changelog.md | 1 + pnpm-lock.yaml | 8 +-- src-tauri/src/cmd/proxy.rs | 8 +++ src-tauri/src/config/profiles.rs | 55 ++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/home/current-proxy-card.tsx | 1 + src/hooks/use-proxy-selection.ts | 83 ++++++++++++++-------- src/hooks/use-record-selection.test.tsx | 48 ++++++++++++- src/hooks/use-record-selection.ts | 23 ++++-- src/pages/_layout.tsx | 19 ----- src/services/cmds.ts | 5 ++ 12 files changed, 205 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74c49e282..7d1bfd4f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,7 +1363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -2359,15 +2359,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" -[[package]] -name = "fast-uuid-v7" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65d7a30239eba18ae6a60dc3c3df98af18f00bba69d2b1d5f2816a831e83bff3" -dependencies = [ - "rand 0.10.2", -] - [[package]] name = "fastrand" version = "2.5.0" @@ -3406,12 +3397,12 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry", ] [[package]] @@ -3426,7 +3417,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4839,7 +4830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -5513,7 +5504,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.19", "tokio", "tracing", @@ -5552,7 +5543,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -7338,7 +7329,7 @@ dependencies = [ "thiserror 2.0.19", "tracing", "url", - "windows-registry 0.5.3", + "windows-registry", "windows-result 0.3.4", ] @@ -7453,11 +7444,10 @@ dependencies = [ [[package]] name = "tauri-plugin-mihomo" version = "0.5.5" -source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=refactor-use-reqwest-ipc#d9398bf8e862c0cd613b79ada45cc5d893820ed6" +source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=refactor-use-reqwest-ipc#a9c359edb2154c6e92bcf423e489a8082f2aa006" dependencies = [ "arc-swap", "clashmap", - "fast-uuid-v7", "futures-util", "http 1.5.0", "log", @@ -7472,6 +7462,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "ts-rs", + "uuid", "windows-sys 0.61.2", ] @@ -9102,7 +9093,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -9282,17 +9273,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" diff --git a/Changelog.md b/Changelog.md index 838a2aa99..e6a0704d7 100644 --- a/Changelog.md +++ b/Changelog.md @@ -25,6 +25,7 @@ - 引导窗口显示当前阶段:安装服务 / 重启内核 / 应用代理 - 系统代理失败会保留到窗口可见时再呈现,不会因窗口隐藏或最小化而丢失 - 区分「需要安装服务」与「服务已就绪但内核仍以 Sidecar 运行」,分别给出对应操作 +- 支持手动点击取消代理组中固定的代理节点 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ce616ff9..896db9e62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,7 +127,7 @@ importers: version: 2.4.2(react@19.2.8) tauri-plugin-mihomo-api: specifier: github:clash-verge-rev/tauri-plugin-mihomo#refactor-use-reqwest-ipc - version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/0a97faea2cba28501a51f17cba5aac42c8353adb + version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a9c359edb2154c6e92bcf423e489a8082f2aa006 types-pac: specifier: ^1.0.3 version: 1.0.3 @@ -3822,8 +3822,8 @@ packages: resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/0a97faea2cba28501a51f17cba5aac42c8353adb: - resolution: {gitHosted: true, tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/0a97faea2cba28501a51f17cba5aac42c8353adb} + tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a9c359edb2154c6e92bcf423e489a8082f2aa006: + resolution: {gitHosted: true, integrity: sha512-/HV/tKAz4Mw0sOpqL3Gjisn5jfUu+gkukjHHe0ngD2X2trooM4puU6kmG+546Xz5VAv/PR78gpm+gWBvm9yexQ==, tarball: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a9c359edb2154c6e92bcf423e489a8082f2aa006} version: 0.5.5 terser@5.49.0: @@ -8045,7 +8045,7 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/0a97faea2cba28501a51f17cba5aac42c8353adb: + tauri-plugin-mihomo-api@https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/a9c359edb2154c6e92bcf423e489a8082f2aa006: dependencies: '@tauri-apps/api': 2.11.1 diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index d4e1ec097..8ca8a0a72 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -32,6 +32,14 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult .stringify_err() } +/// Forget the persisted node selection for a proxy group in the current profile. +#[tauri::command] +pub async fn forget_selected_node(group_name: String) -> CmdResult<()> { + crate::config::profiles::forget_selected_node(&group_name) + .await + .stringify_err() +} + static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false); static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false); diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index a2fb332b9..2ec4f8acf 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -977,6 +977,45 @@ pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { Ok(()) } +fn remove_selected_node(selected: &mut Vec, group_name: &str) -> bool { + let original_len = selected.len(); + selected.retain(|entry| entry.name.as_deref() != Some(group_name)); + selected.len() != original_len +} + +pub async fn forget_selected_node(group_name: &str) -> Result<()> { + let cleared = Config::profiles() + .await + .with_data_modify(move |mut profiles| async move { + let Some(current) = profiles.current.clone() else { + return Ok((profiles, false)); + }; + let Some(item) = profiles + .items + .as_mut() + .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) + else { + return Ok((profiles, false)); + }; + + let mut selected = item.selected.clone().unwrap_or_default(); + if !remove_selected_node(&mut selected, group_name) { + return Ok((profiles, false)); + } + + item.selected = (!selected.is_empty()).then_some(selected); + profiles.save_file().await?; + Ok((profiles, true)) + }) + .await?; + + if cleared { + supersede_selected_activation(); + handle::Handle::refresh_profiles(); + } + Ok(()) +} + async fn persist_reconciled_selected( profile_uid: &String, original_selected: &[PrfSelected], @@ -1395,6 +1434,22 @@ mod tests { } } + #[test] + fn removes_only_the_requested_group_selection() { + let mut selections = vec![selected("Proxy", "Node A"), selected("Fallback", "Node B")]; + + assert!(remove_selected_node(&mut selections, "Proxy")); + assert_eq!(selections, vec![selected("Fallback", "Node B")]); + } + + #[test] + fn removing_a_missing_group_is_idempotent() { + let mut selections = vec![selected("Proxy", "Node A")]; + + assert!(!remove_selected_node(&mut selections, "Missing")); + assert_eq!(selections, vec![selected("Proxy", "Node A")]); + } + fn proxies(groups: Vec<(&str, &[&str], Option<&str>)>) -> Proxies { Proxies { proxies: groups diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 655bd7514..b18b9df55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -165,6 +165,7 @@ mod app_init { cmd::copy_clash_env, cmd::sync_tray_proxy_selection, cmd::record_selected_node, + cmd::forget_selected_node, cmd::save_dns_config, cmd::apply_dns_config, cmd::check_dns_config_exists, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 8706ee611..9a2e37e24 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -623,6 +623,7 @@ export const CurrentProxyCard = () => { handleSelectChange( selectedGroup.name, previousProxy, + selectedGroup.fixed, )({ target: { value: nextName }, }) diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index 08658e6b0..fe9d98400 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -1,11 +1,15 @@ -import { useCallback, useMemo, useRef } from 'react' +import { useCallback, useRef } from 'react' import { closeConnection, getConnections, selectNodeForGroup, + unfixedProxy, } from 'tauri-plugin-mihomo-api' -import { useRecordSelection } from '@/hooks/use-record-selection' +import { + useForgetSelection, + useRecordSelection, +} from '@/hooks/use-record-selection' import { useVerge } from '@/hooks/use-verge' import { syncTrayProxySelection } from '@/services/cmds' import { debugLog } from '@/utils/debug' @@ -37,25 +41,20 @@ 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(null) const isProcessingRef = useRef(false) const { onSuccess, onError, enableConnectionCleanup = true } = options - // 缓存 - const config = useMemo( - () => ({ - autoCloseConnection: verge?.auto_close_connection ?? false, - enableConnectionCleanup, - }), - [verge?.auto_close_connection, enableConnectionCleanup], - ) + const autoCloseConnection = verge?.auto_close_connection ?? false // 切换节点 const syncTraySelection = useCallback(() => { @@ -66,23 +65,33 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { const executeChange = useCallback( async (request: ProxyChangeRequest) => { - const { groupName, proxyName, previousProxy } = request - debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`) + const { groupName, proxyName, previousProxy, fixed } = request + const isFixedProxy = fixed === proxyName + if (isFixedProxy) { + debugLog(`[ProxySelection] 代理取消固定: ${groupName} -> ${proxyName}`) + } else { + debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`) + } try { - await selectNodeForGroup(groupName, proxyName) + if (isFixedProxy) { + await unfixedProxy(groupName) + } else { + await selectNodeForGroup(groupName, proxyName) + } onSuccess?.() syncTraySelection() - recordSelection(groupName, proxyName) - debugLog( - `[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`, - ) + if (isFixedProxy) { + await forgetSelection(groupName) + debugLog(`[ProxySelection] 代理和状态同步完成: ${groupName}`) + } else { + await recordSelection(groupName, proxyName) + debugLog( + `[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`, + ) + } - if ( - config.enableConnectionCleanup && - config.autoCloseConnection && - previousProxy - ) { + if (enableConnectionCleanup && autoCloseConnection && previousProxy) { void cleanupConnections(previousProxy) } } catch (error) { @@ -93,7 +102,15 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { onError?.(error) } }, - [config, onError, onSuccess, recordSelection, syncTraySelection], + [ + autoCloseConnection, + forgetSelection, + enableConnectionCleanup, + onError, + onSuccess, + recordSelection, + syncTraySelection, + ], ) const flushChangeQueue = useCallback(async () => { @@ -115,11 +132,17 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { }, [executeChange]) const changeProxy = useCallback( - (groupName: string, proxyName: string, previousProxy?: string) => { + ( + groupName: string, + proxyName: string, + previousProxy?: string, + fixed?: string, + ) => { pendingRequestRef.current = { groupName, proxyName, previousProxy, + fixed, } void flushChangeQueue() }, @@ -127,17 +150,19 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { ) const handleSelectChange = useCallback( - (groupName: string, previousProxy?: string) => + (groupName: string, previousProxy?: string, fixed?: string) => (event: { target: { value: string } }) => { - const newProxy = event.target.value - changeProxy(groupName, newProxy, previousProxy) + changeProxy(groupName, event.target.value, previousProxy, fixed) }, [changeProxy], ) const handleProxyGroupChange = useCallback( - (group: { name: string; now?: string }, proxy: { name: string }) => { - changeProxy(group.name, proxy.name, group.now) + ( + group: { name: string; now?: string; fixed?: string }, + proxy: { name: string }, + ) => { + changeProxy(group.name, proxy.name, group.now, group.fixed) }, [changeProxy], ) diff --git a/src/hooks/use-record-selection.test.tsx b/src/hooks/use-record-selection.test.tsx index 506cd7cba..a637f2393 100644 --- a/src/hooks/use-record-selection.test.tsx +++ b/src/hooks/use-record-selection.test.tsx @@ -2,11 +2,12 @@ import { renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useRecordSelection } from './use-record-selection' +import { useForgetSelection, useRecordSelection } from './use-record-selection' const recordSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve())) +const forgetSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve())) -vi.mock('@/services/cmds', () => ({ recordSelectedNode })) +vi.mock('@/services/cmds', () => ({ forgetSelectedNode, recordSelectedNode })) const record = (groupName: string, proxyName: string) => { const { result } = renderHook(() => useRecordSelection()) @@ -15,6 +16,49 @@ const record = (groupName: string, proxyName: string) => { beforeEach(() => { recordSelectedNode.mockClear() + forgetSelectedNode.mockClear() +}) + +describe('useForgetSelection', () => { + it('sends only the group name', () => { + const { result } = renderHook(() => useForgetSelection()) + + result.current('Proxy') + + expect(forgetSelectedNode).toHaveBeenCalledWith('Proxy') + }) + + it('resolves after the clear request finishes', async () => { + let resolveForget!: () => void + forgetSelectedNode.mockImplementationOnce( + () => new Promise((resolve) => (resolveForget = resolve)), + ) + const { result } = renderHook(() => useForgetSelection()) + + const forget = result.current('Proxy') + let settled = false + forget.then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + + resolveForget() + await forget + expect(settled).toBe(true) + }) + + it('reports a failed clear without throwing at the caller', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + forgetSelectedNode.mockRejectedValueOnce(new Error('no profile')) + const { result } = renderHook(() => useForgetSelection()) + + expect(() => result.current('Proxy')).not.toThrow() + + await vi.waitFor(() => expect(consoleError).toHaveBeenCalled()) + consoleError.mockRestore() + }) }) describe('useRecordSelection', () => { diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts index d0cd11edd..1f30f88f7 100644 --- a/src/hooks/use-record-selection.ts +++ b/src/hooks/use-record-selection.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import { recordSelectedNode } from '@/services/cmds' +import { forgetSelectedNode, recordSelectedNode } from '@/services/cmds' /** * Record which node a group is on, in the profile. @@ -20,9 +20,24 @@ import { recordSelectedNode } from '@/services/cmds' * stale snapshot. The merge happens on the backend, against whatever the profile holds by then. */ export const useRecordSelection = () => { - return useCallback((groupName: string, proxyName: string) => { - recordSelectedNode(groupName, proxyName).catch((error) => { + return useCallback(async (groupName: string, proxyName: string) => { + try { + await recordSelectedNode(groupName, proxyName) + } catch (error) { console.error('[Selection] 保存代理选择失败:', error) - }) + } + }, []) +} + +/** + * Forget the persisted node selection for a group after its runtime fixed node is released. + */ +export const useForgetSelection = () => { + return useCallback(async (groupName: string) => { + try { + await forgetSelectedNode(groupName) + } catch (error) { + console.error('[Selection] 清除代理选择失败:', error) + } }, []) } diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 81df4834e..c22b91bf8 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -27,7 +27,6 @@ import type { CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Outlet, useNavigate } from 'react-router' -import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' import iconDark from '@/assets/image/icon_dark.svg?react' import iconLight from '@/assets/image/icon_light.svg?react' @@ -125,24 +124,6 @@ const Layout = () => { const navigate = useNavigate() const themeReady = useMemo(() => Boolean(theme), [theme]) - // 开发环境下检测 MihomoWebSocket 的所有实例 - useEffect(() => { - let id: number - if (import.meta.env.DEV) { - id = setInterval(() => { - MihomoWebSocket.get_all_instances().then((list) => { - console.log('Mihomo ws instances', list) - }) - }, 1000) - } - - return () => { - if (id) { - clearInterval(id) - } - } - }, []) - const [menuUnlocked, setMenuUnlocked] = useState(false) const [menuContextPosition, setMenuContextPosition] = useState(null) diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 6f86de535..e626632ec 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -136,6 +136,11 @@ export async function recordSelectedNode(groupName: string, node: string) { return invoke('record_selected_node', { groupName, node }) } +/** Forget the persisted node selection for one proxy group in the current profile. */ +export async function forgetSelectedNode(groupName: string) { + return invoke('forget_selected_node', { groupName }) +} + export async function getProxyView(): Promise { const view = await invoke('get_proxy_view') if (view.schemaVersion !== 1) {