clash-verge-rev/src/providers/app-data-provider.tsx
Tunglies a73fafaf9f
refactor: migrate SWR to TanStack Query v5 (#6713)
Replace swr with @tanstack/react-query v5 across all hooks, providers,
and components. Introduce singleton QueryClient, WS subscription pattern
via useQuery+useEffect, and enforce component-layer cache access contract.
2026-04-03 08:15:51 +00:00

356 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery } from '@tanstack/react-query'
import { listen } from '@tauri-apps/api/event'
import React, { useCallback, useEffect, useMemo } from 'react'
import {
getBaseConfig,
getRuleProviders,
getRules,
} from 'tauri-plugin-mihomo-api'
import { useVerge } from '@/hooks/use-verge'
import {
calcuProxies,
calcuProxyProviders,
getAppUptime,
getRunningMode,
getSystemProxy,
} from '@/services/cmds'
import { AppDataContext, AppDataContextType } from './app-data-context'
const TQ_MIHOMO = {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: 1500,
retry: 3,
retryDelay: 2000,
} as const
const TQ_DEFAULTS = {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: 5000,
retry: 2,
} as const
// 全局数据提供者组件
export const AppDataProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const { verge } = useVerge()
const { data: proxiesData, refetch: refreshProxy } = useQuery({
queryKey: ['getProxies'],
queryFn: calcuProxies,
...TQ_MIHOMO,
})
const { data: clashConfig, refetch: refreshClashConfig } = useQuery({
queryKey: ['getClashConfig'],
queryFn: getBaseConfig,
...TQ_MIHOMO,
})
const { data: proxyProviders, refetch: refreshProxyProviders } = useQuery({
queryKey: ['getProxyProviders'],
queryFn: calcuProxyProviders,
...TQ_MIHOMO,
})
const { data: ruleProviders, refetch: refreshRuleProviders } = useQuery({
queryKey: ['getRuleProviders'],
queryFn: getRuleProviders,
...TQ_MIHOMO,
})
const { data: rulesData, refetch: refreshRules } = useQuery({
queryKey: ['getRules'],
queryFn: getRules,
...TQ_MIHOMO,
})
useEffect(() => {
let lastProfileId: string | null = null
let lastUpdateTime = 0
const refreshThrottle = 800
let isUnmounted = false
const scheduledTimeouts = new Set<number>()
const cleanupFns: Array<() => void> = []
const registerCleanup = (fn: () => void) => {
if (isUnmounted) {
try {
fn()
} catch (error) {
console.error('[DataProvider] Immediate cleanup failed:', error)
}
} else {
cleanupFns.push(fn)
}
}
const addWindowListener = (eventName: string, handler: EventListener) => {
// eslint-disable-next-line @eslint-react/web-api-no-leaked-event-listener
window.addEventListener(eventName, handler)
return () => window.removeEventListener(eventName, handler)
}
const scheduleTimeout = (
callback: () => void | Promise<void>,
delay: number,
) => {
if (isUnmounted) return -1
const timeoutId = window.setTimeout(() => {
scheduledTimeouts.delete(timeoutId)
if (!isUnmounted) {
void callback()
}
}, delay)
scheduledTimeouts.add(timeoutId)
return timeoutId
}
const clearAllTimeouts = () => {
scheduledTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
scheduledTimeouts.clear()
}
const handleProfileChanged = (event: { payload: string }) => {
const newProfileId = event.payload
const now = Date.now()
if (
lastProfileId === newProfileId &&
now - lastUpdateTime < refreshThrottle
) {
return
}
lastProfileId = newProfileId
lastUpdateTime = now
scheduleTimeout(() => {
refreshRules().catch((error) =>
console.warn('[DataProvider] Rules refresh failed:', error),
)
refreshRuleProviders().catch((error) =>
console.warn('[DataProvider] Rule providers refresh failed:', error),
)
}, 200)
}
const handleRefreshClash = () => {
const now = Date.now()
if (now - lastUpdateTime <= refreshThrottle) return
lastUpdateTime = now
scheduleTimeout(async () => {
await Promise.all([
refreshProxy().catch((error) =>
console.error('[DataProvider] Proxy refresh failed:', error),
),
refreshClashConfig().catch((error) =>
console.error('[DataProvider] Clash config refresh failed:', error),
),
])
}, 200)
}
const handleRefreshProxy = () => {
const now = Date.now()
if (now - lastUpdateTime <= refreshThrottle) return
lastUpdateTime = now
scheduleTimeout(() => {
refreshProxy().catch((error) =>
console.warn('[DataProvider] Proxy refresh failed:', error),
)
}, 200)
}
const initializeListeners = async () => {
try {
const unlistenProfile = await listen<string>(
'profile-changed',
handleProfileChanged,
)
registerCleanup(unlistenProfile)
} catch (error) {
console.error('[AppDataProvider] 监听 Profile 事件失败:', error)
}
try {
const unlistenClash = await listen(
'verge://refresh-clash-config',
handleRefreshClash,
)
const unlistenProxy = await listen(
'verge://refresh-proxy-config',
handleRefreshProxy,
)
registerCleanup(() => {
unlistenClash()
unlistenProxy()
})
} catch (error) {
console.warn('[AppDataProvider] 设置 Tauri 事件监听器失败:', error)
const fallbackHandlers: Array<[string, EventListener]> = [
['verge://refresh-clash-config', handleRefreshClash],
['verge://refresh-proxy-config', handleRefreshProxy],
]
fallbackHandlers.forEach(([eventName, handler]) => {
registerCleanup(addWindowListener(eventName, handler))
})
}
}
void initializeListeners()
return () => {
isUnmounted = true
clearAllTimeouts()
const errors: Error[] = []
cleanupFns.splice(0).forEach((fn) => {
try {
fn()
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)))
}
})
if (errors.length > 0) {
console.error(
`[DataProvider] ${errors.length} errors during cleanup:`,
errors,
)
}
}
}, [refreshProxy, refreshClashConfig, refreshRules, refreshRuleProviders])
const { data: sysproxy, refetch: refreshSysproxy } = useQuery({
queryKey: ['getSystemProxy'],
queryFn: getSystemProxy,
...TQ_DEFAULTS,
})
const { data: runningMode } = useQuery({
queryKey: ['getRunningMode'],
queryFn: getRunningMode,
...TQ_DEFAULTS,
})
const { data: uptimeData } = useQuery({
queryKey: ['appUptime'],
queryFn: getAppUptime,
...TQ_DEFAULTS,
refetchInterval: 3000,
retry: 1,
})
// 提供统一的刷新方法
const refreshAll = useCallback(async () => {
await Promise.all([
refreshProxy(),
refreshClashConfig(),
refreshRules(),
refreshSysproxy(),
refreshProxyProviders(),
refreshRuleProviders(),
])
}, [
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
])
// 聚合所有数据
const value = useMemo(() => {
// 计算系统代理地址
const calculateSystemProxyAddress = () => {
if (!verge || !clashConfig) return '-'
const isPacMode = verge.proxy_auto_config ?? false
if (isPacMode) {
// PAC模式显示我们期望设置的代理地址
const proxyHost = verge.proxy_host || '127.0.0.1'
const proxyPort =
verge.verge_mixed_port || clashConfig.mixedPort || 7897
return `${proxyHost}:${proxyPort}`
} else {
// HTTP代理模式优先使用系统地址但如果格式不正确则使用期望地址
const systemServer = sysproxy?.server
if (
systemServer &&
systemServer !== '-' &&
!systemServer.startsWith(':')
) {
return systemServer
} else {
// 系统地址无效,返回期望的代理地址
const proxyHost = verge.proxy_host || '127.0.0.1'
const proxyPort =
verge.verge_mixed_port || clashConfig.mixedPort || 7897
return `${proxyHost}:${proxyPort}`
}
}
}
return {
// 数据
proxies: proxiesData,
clashConfig,
rules: rulesData?.rules ?? [],
sysproxy,
runningMode,
uptime: uptimeData || 0,
// 提供者数据
proxyProviders: proxyProviders || {},
ruleProviders: ruleProviders?.providers || {},
systemProxyAddress: calculateSystemProxyAddress(),
// 刷新方法
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
refreshAll,
} as AppDataContextType
}, [
proxiesData,
clashConfig,
rulesData,
sysproxy,
runningMode,
uptimeData,
proxyProviders,
ruleProviders,
verge,
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
refreshAll,
])
return <AppDataContext value={value}>{children}</AppDataContext>
}