fix: harden home clash-mode card against /configs read failures

Recover from controller read/switch failures on the mode card instead of
showing a bare "communication error":

- fall back to the tolerant get_clash_mode command (then runtime config)
  when the live /configs mode is unavailable,
- optimistically reflect the clicked mode and write it through to the
  getClashConfig cache so a failed refetch can't flash back the old value,
- surface patch failures via showNotice on both the home card and the
  proxies page (patchClashMode can now reject), and
- revalidate getClashMode/getRuntimeConfig on refresh-clash-config so a
  once-empty fallback can recover.
This commit is contained in:
wonfen
2026-06-27 15:30:20 +08:00
parent d0d2f4e4f5
commit 5a96fdd9a2
5 changed files with 71 additions and 12 deletions
+44 -10
View File
@@ -5,11 +5,11 @@ import {
} from '@mui/icons-material'
import { Box, Paper, Stack, Typography } from '@mui/material'
import { useLockFn } from 'ahooks'
import { type ReactNode } from 'react'
import { type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { type BaseConfig, closeAllConnections } from 'tauri-plugin-mihomo-api'
import { useRuntimeConfig } from '@/hooks/use-clash'
import { useClashMode, useRuntimeConfig } from '@/hooks/use-clash'
import { useVerge } from '@/hooks/use-verge'
import {
useAppRefreshers,
@@ -17,6 +17,8 @@ import {
useCoreDataStatus,
} from '@/providers/app-data-context'
import { patchClashMode } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { queryClient } from '@/services/query-client'
import type { TranslationKey } from '@/types/generated/i18n-keys'
const CLASH_MODES = ['rule', 'global', 'direct'] as const
@@ -61,32 +63,64 @@ export const ClashModeCard = () => {
const { isCoreDataPending } = useCoreDataStatus()
const { refreshClashConfig } = useAppRefreshers()
// 点击后到后端确认前的乐观模式,使按钮立即响应
const [optimisticMode, setOptimisticMode] = useState<ClashMode | null>(null)
// 主源:mihomo /configs 的实时 mode(最准,但依赖严格反序列化,可能整体失败)
const controllerMode = toClashMode(clashConfig?.mode)
// 主源不可用时,启用两个容错兜底来源
const needFallback = !controllerMode
const { data: runtimeConfig, isPending: isRuntimeConfigPending } =
useRuntimeConfig(!controllerMode)
useRuntimeConfig(needFallback)
const runtimeMode = toClashMode(runtimeConfig?.mode)
const currentMode = controllerMode ?? runtimeMode
const {
data: backendMode,
isPending: isBackendModePending,
refetch: refetchBackendMode,
} = useClashMode(needFallback)
// backendMode(已保存 clash 配置)在每次切换时都会被 change_clash_mode 同步更新,
// 而 runtimeMode(生成的运行时配置)在 API 切换后不会刷新、可能陈旧,
// 因此优先用 backendMode,避免陈旧 runtime mode 遮住新值。
const fallbackMode = toClashMode(backendMode) ?? runtimeMode
const resolvedMode = controllerMode ?? fallbackMode
const currentMode = optimisticMode ?? resolvedMode
const modeDescription = currentMode
? t(MODE_META[currentMode].description)
: isCoreDataPending || isRuntimeConfigPending
: isCoreDataPending || isRuntimeConfigPending || isBackendModePending
? '\u00A0'
: t('home.components.clashMode.errors.communication')
// 切换模式的处理函数
// 切换模式的处理函数:乐观更新 + 真实失败回滚并提示
const onChangeMode = useLockFn(async (mode: ClashMode) => {
if (mode === currentMode) return
if (verge?.auto_close_connection) {
closeAllConnections()
}
// 乐观置为目标模式,按钮立即反映点击
setOptimisticMode(mode)
try {
// patchClashMode 现在会在后端 PATCH 失败时 reject
await patchClashMode(mode)
// 使用共享的刷新方法
refreshClashConfig()
} catch (error) {
console.error('Failed to change mode:', error)
// 真实失败:回滚乐观状态并提示用户
setOptimisticMode(null)
showNotice.error(error)
return
}
// 成功:写穿主源缓存,使实时 mode 立即反映新值——即使随后的 /configs refetch
// 失败(TanStack 会保留旧 data),controllerMode 也不会再压过新值导致闪回。
// 若主源从未成功过(old 为 undefined)则保持不动,改由兜底来源反映。
queryClient.setQueryData<BaseConfig | undefined>(
['getClashConfig'],
(old) => (old ? { ...old, mode } : old),
)
// 刷新主源与兜底源以与后端对齐,待数据落地后再清除乐观状态
await Promise.allSettled([refreshClashConfig(), refetchBackendMode()])
setOptimisticMode(null)
})
// 按钮样式
+11
View File
@@ -4,6 +4,7 @@ import { getVersion } from 'tauri-plugin-mihomo-api'
import {
getClashInfo,
getClashMode,
getRuntimeConfig,
patchClashConfig,
} from '@/services/cmds'
@@ -65,6 +66,16 @@ export const useRuntimeConfig = (shouldFetch: boolean = true) => {
})
}
// Fault-tolerant fallback for the current proxy mode, read straight from the
// saved clash config on the backend (bypasses the strict BaseConfig path).
export const useClashMode = (shouldFetch: boolean = true) => {
return useQuery({
queryKey: ['getClashMode'],
queryFn: getClashMode,
enabled: shouldFetch,
})
}
export const useClash = () => {
const { data: clash, refetch } = useRuntimeConfig()
@@ -47,6 +47,8 @@ export const useLayoutEvents = (
'getProxies',
'getVersion',
'getClashConfig',
'getClashMode',
'getRuntimeConfig',
'getProxyProviders',
'getRules',
'getRuleProviders',
+8 -2
View File
@@ -18,6 +18,7 @@ import {
patchClashMode,
updateProxyChainConfigInRuntime,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
const MODES = ['rule', 'global', 'direct'] as const
@@ -61,8 +62,13 @@ const ProxyPage = () => {
if (mode !== curMode && verge?.auto_close_connection) {
closeAllConnections()
}
await patchClashMode(mode)
refreshClashConfig()
try {
// patchClashMode 在后端 PATCH 失败时会 reject,需提示用户而非静默失败
await patchClashMode(mode)
refreshClashConfig()
} catch (error) {
showNotice.error(error)
}
})
const onToggleChainMode = useLockFn(async () => {
+6
View File
@@ -85,6 +85,12 @@ export async function getClashInfo() {
return invoke<IClashInfo | null>('get_clash_info')
}
// Fault-tolerant current proxy mode read (does not depend on mihomo /configs
// strict BaseConfig deserialization); used as a fallback for the home mode card.
export async function getClashMode() {
return invoke<string | null>('get_clash_mode')
}
// Get runtime config which controlled by verge
export async function getRuntimeConfig() {
return invoke<IConfigData | null>('get_runtime_config')