clash-verge-rev/src/providers/app-data-provider.tsx
Tunglies 437fef1c30
fix: eliminate error flash on startup by distinguishing loading from error state
- Change TQ_MIHOMO retryDelay from fixed 2000ms to exponential backoff
  (200ms → 400ms → 800ms, cap 3s) so core-dependent queries retry faster
- Expose isCoreDataPending from AppDataProvider to distinguish between
  data still loading vs actual errors
- ClashModeCard: show placeholder instead of "communication error" while
  core data is pending
- CurrentProxyCard: show empty space instead of "no active node" while
  core data is pending
2026-04-06 02:14:33 +08:00

369 lines
9.3 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: (attempt: number) => Math.min(200 * 2 ** attempt, 3000),
} 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,
isPending: isProxiesPending,
refetch: refreshProxy,
} = useQuery({
queryKey: ['getProxies'],
queryFn: calcuProxies,
...TQ_MIHOMO,
})
const {
data: clashConfig,
isPending: isClashConfigPending,
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(),
// core 数据加载状态
isCoreDataPending: isProxiesPending || isClashConfigPending,
// 刷新方法
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
refreshAll,
} as AppDataContextType
}, [
proxiesData,
clashConfig,
isProxiesPending,
isClashConfigPending,
rulesData,
sysproxy,
runningMode,
uptimeData,
proxyProviders,
ruleProviders,
verge,
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
refreshAll,
])
return <AppDataContext value={value}>{children}</AppDataContext>
}