diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index aa5521833..4b90b9e49 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -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 { let mut seen = HashSet::new(); @@ -124,48 +115,3 @@ pub async fn get_proxy_view() -> CmdResult { 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; - } - } -} diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index f7fa4a6d6..3ff99b6ed 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -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); diff --git a/src-tauri/src/feat/profile.rs b/src-tauri/src/feat/profile.rs index dc171a5a7..22e27507c 100644 --- a/src-tauri/src/feat/profile.rs +++ b/src-tauri/src/feat/profile.rs @@ -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)>> { diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 334ef1ccb..102cf4b55 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -45,9 +45,8 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 970fa0f14..e537c8594 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -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], ) diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 4a6a124a6..f115966cb 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -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], ) diff --git a/src/hooks/use-proxy-selection.test.ts b/src/hooks/use-proxy-selection.test.ts index c382fc76f..f81210ada 100644 --- a/src/hooks/use-proxy-selection.test.ts +++ b/src/hooks/use-proxy-selection.test.ts @@ -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() }) diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index d773ef361..ca921e558 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -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(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() diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts index 66e5736a8..f4e44cc29 100644 --- a/src/hooks/use-record-selection.ts +++ b/src/hooks/use-record-selection.ts @@ -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) - } - }, []) -} diff --git a/src/services/bindings.ts b/src/services/bindings.ts index 02e633c49..a609c12f8 100644 --- a/src/services/bindings.ts +++ b/src/services/bindings.ts @@ -78,13 +78,11 @@ export const commands = { }), invokeUwpTool: () => __TAURI_INVOKE('invoke_uwp_tool'), copyClashEnv: () => __TAURI_INVOKE('copy_clash_env'), - syncTrayProxySelection: () => - __TAURI_INVOKE('sync_tray_proxy_selection'), /** Merges one selection pair into fresh backend state to avoid stale-list overwrites. */ recordSelectedNode: (groupName: string, node: string) => __TAURI_INVOKE('record_selected_node', { groupName, node }), - forgetSelectedNode: (groupName: string) => - __TAURI_INVOKE('forget_selected_node', { groupName }), + switchProxyNode: (groupName: string, proxyName: string, unfix: boolean) => + __TAURI_INVOKE('switch_proxy_node', { groupName, proxyName, unfix }), saveDnsConfig: (dnsConfig: YamlMapping) => __TAURI_INVOKE('save_dns_config', { dnsConfig }), applyDnsConfig: (apply: boolean) => diff --git a/src/services/bus/create-bus-handlers.ts b/src/services/bus/create-bus-handlers.ts index c41442704..99da99e15 100644 --- a/src/services/bus/create-bus-handlers.ts +++ b/src/services/bus/create-bus-handlers.ts @@ -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) => diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 6b118d34a..59876b473 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -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 {