mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0218924c3c |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>)>> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,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)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user