mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 04:12:08 +08:00
fix(ipc): reconcile Specta bindings with dev
This commit is contained in:
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
|
||||
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
|
||||
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
|
||||
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
|
||||
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
|
||||
import { Box, IconButton, type SxProps } from '@mui/material'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
@@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import delayManager from '@/services/delay'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
import { isValidLatencyTestUrl } from '@/utils/network'
|
||||
|
||||
import { BaseSearchBox, type SearchState } from '../base'
|
||||
|
||||
import { ProxyTestUrlInput } from './proxy-test-url-input'
|
||||
import type { ProxySortType } from './use-filter-sort'
|
||||
import type { HeadState } from './use-head-state'
|
||||
|
||||
@@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
|
||||
delayManager.setUrl(
|
||||
groupName,
|
||||
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
|
||||
)
|
||||
}, [groupName, testUrl, defaultLatencyUrl, url])
|
||||
|
||||
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
|
||||
@@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
)}
|
||||
|
||||
{textState === 'url' && (
|
||||
<TextField
|
||||
<ProxyTestUrlInput
|
||||
name={groupName}
|
||||
inputRef={inputRef}
|
||||
autoComplete="new-password"
|
||||
hiddenLabel
|
||||
@@ -125,7 +130,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onChange={(e) => onHeadState({ testUrl: e.target.value })}
|
||||
onCommit={(testUrl) => onHeadState({ testUrl })}
|
||||
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
|
||||
/>
|
||||
)}
|
||||
@@ -176,7 +181,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
||||
if (testUrl?.trim() && textState !== 'filter') {
|
||||
onHeadState({ textState: 'url' })
|
||||
}
|
||||
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
|
||||
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useProxySelection } from '@/hooks/use-proxy-selection'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useProxiesData, useSystemData } from '@/providers/app-data-context'
|
||||
import { syncTrayProxySelection } from '@/services/cmds'
|
||||
import delayManager from '@/services/delay'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import {
|
||||
isInteractableMember,
|
||||
resolveMember,
|
||||
@@ -133,6 +135,12 @@ function useProxyRenderState(
|
||||
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
|
||||
} finally {
|
||||
onProxies()
|
||||
mutate(() => syncTrayProxySelection(), {
|
||||
id: 'sync-tray-selection',
|
||||
errorNotice: false,
|
||||
}).catch((error) => {
|
||||
console.error('[ProxyGroups] Failed to sync tray latency:', error)
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SortByAlphaRounded,
|
||||
SortRounded,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, IconButton, TextField, type SxProps } from '@mui/material'
|
||||
import { Box, IconButton, type SxProps } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge'
|
||||
import delayManager from '@/services/delay'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
import { isValidLatencyTestUrl } from '@/utils/network'
|
||||
|
||||
import { ProxyTestUrlInput } from './proxy-test-url-input'
|
||||
import type { ProxySortType } from './use-filter-sort'
|
||||
import type { HeadState } from './use-head-state'
|
||||
|
||||
@@ -72,7 +73,10 @@ export const ProxyHead = ({
|
||||
'http://cp.cloudflare.com/generate_204'
|
||||
|
||||
useEffect(() => {
|
||||
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
|
||||
delayManager.setUrl(
|
||||
groupName,
|
||||
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
|
||||
)
|
||||
}, [groupName, testUrl, defaultLatencyUrl, url])
|
||||
|
||||
return (
|
||||
@@ -97,7 +101,7 @@ export const ProxyHead = ({
|
||||
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
|
||||
onHeadState({ textState: 'url' })
|
||||
}
|
||||
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
|
||||
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||
return
|
||||
}
|
||||
@@ -188,7 +192,8 @@ export const ProxyHead = ({
|
||||
)}
|
||||
|
||||
{textState === 'url' && (
|
||||
<TextField
|
||||
<ProxyTestUrlInput
|
||||
name={groupName}
|
||||
autoComplete="new-password"
|
||||
autoFocus={autoFocus}
|
||||
hiddenLabel
|
||||
@@ -197,7 +202,7 @@ export const ProxyHead = ({
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
|
||||
onChange={(e) => onHeadState({ testUrl: e.target.value })}
|
||||
onCommit={(testUrl) => onHeadState({ testUrl })}
|
||||
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TextField, type TextFieldProps } from '@mui/material'
|
||||
import { useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
|
||||
type Props = Omit<TextFieldProps, 'value' | 'onChange'> & {
|
||||
value: string
|
||||
onCommit: (url: string) => void
|
||||
}
|
||||
|
||||
export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => {
|
||||
const { profiles } = useProfiles()
|
||||
return (
|
||||
<DraftTestUrlInput
|
||||
key={JSON.stringify([profiles?.current, name])}
|
||||
name={name}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => {
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const pendingRef = useRef<string | null>(null)
|
||||
const commitOnUnmount = useEffectEvent(() => {
|
||||
if (pendingRef.current !== null && pendingRef.current !== value)
|
||||
onCommit(pendingRef.current)
|
||||
})
|
||||
useEffect(() => () => commitOnUnmount(), [])
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
value={draft ?? value}
|
||||
onChange={(event) => {
|
||||
pendingRef.current = event.target.value
|
||||
setDraft(event.target.value)
|
||||
}}
|
||||
onBlur={() => {
|
||||
const next = pendingRef.current
|
||||
pendingRef.current = null
|
||||
setDraft(null)
|
||||
if (next !== null && next !== value) onCommit(next)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const input = event.target as HTMLInputElement
|
||||
input.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -13,11 +13,18 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
|
||||
kind: 'node',
|
||||
ref: { kind: 'node', name, recordId: `${memberIndex}` },
|
||||
node: {
|
||||
recordId: `${memberIndex}`,
|
||||
history: [{ delay }],
|
||||
extra: {
|
||||
'http://cp.cloudflare.com/generate_204': {
|
||||
alive: true,
|
||||
history: [{ time: new Date(0).toISOString(), delay }],
|
||||
},
|
||||
},
|
||||
source: { kind: 'provider', providerName: `${memberIndex}` },
|
||||
},
|
||||
},
|
||||
}) as ResolvedMemberOccurrence
|
||||
}) as unknown as ResolvedMemberOccurrence
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -36,7 +43,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays',
|
||||
for (const cached of [false, true]) {
|
||||
const group = `sort-${cached}`
|
||||
if (cached) {
|
||||
values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay))
|
||||
values.forEach((delay, i) =>
|
||||
delayManager.setDelay(list[i].member, group, delay),
|
||||
)
|
||||
}
|
||||
for (const timeout of [10000, 20, 0, NaN]) {
|
||||
const expected = list
|
||||
@@ -66,7 +75,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
|
||||
node(3, 25),
|
||||
]
|
||||
const cachedOrder = [list[0], list[2], list[1], list[3]]
|
||||
delayManager.setDelay('0', 'expiry', 1)
|
||||
delayManager.setDelay(list[0].member, 'expiry', 1)
|
||||
const get = vi.spyOn(delayManager, 'getDelayFix')
|
||||
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
|
||||
expect(get).toHaveBeenCalledTimes(list.length)
|
||||
@@ -80,7 +89,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
|
||||
list[3],
|
||||
list[0],
|
||||
])
|
||||
delayManager.setDelay('0', 'expiry', 2)
|
||||
delayManager.setDelay(list[0].member, 'expiry', 2)
|
||||
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
|
||||
get.mockClear()
|
||||
filterSort([], 'expiry', '', 1)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useReducer } from 'react'
|
||||
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import {
|
||||
profileTestUrls,
|
||||
commitProfileTestUrl,
|
||||
} from '@/services/proxy-test-url'
|
||||
|
||||
import { ProxySortType } from './use-filter-sort'
|
||||
|
||||
@@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = {
|
||||
}
|
||||
|
||||
type HeadStateAction =
|
||||
| { type: 'reset' }
|
||||
| { type: 'replace'; payload: Record<string, HeadState> }
|
||||
| { type: 'reset'; profile: string }
|
||||
| { type: 'replace'; profile: string; payload: Record<string, HeadState> }
|
||||
| { type: 'update'; groupName: string; patch: Partial<HeadState> }
|
||||
| { type: 'urls'; urls: Record<string, string> }
|
||||
|
||||
interface LoadedHeadState {
|
||||
profile: string | null
|
||||
heads: Record<string, HeadState>
|
||||
}
|
||||
|
||||
function headStateReducer(
|
||||
state: Record<string, HeadState>,
|
||||
state: LoadedHeadState,
|
||||
action: HeadStateAction,
|
||||
): Record<string, HeadState> {
|
||||
): LoadedHeadState {
|
||||
switch (action.type) {
|
||||
case 'reset':
|
||||
return {}
|
||||
return { profile: action.profile, heads: {} }
|
||||
case 'replace':
|
||||
return action.payload
|
||||
return { profile: action.profile, heads: action.payload }
|
||||
case 'urls': {
|
||||
let heads = state.heads
|
||||
for (const [name, testUrl] of Object.entries(action.urls)) {
|
||||
if (heads[name]?.testUrl === testUrl) continue
|
||||
heads = {
|
||||
...heads,
|
||||
[name]: { ...DEFAULT_STATE, ...heads[name], testUrl },
|
||||
}
|
||||
}
|
||||
return heads === state.heads ? state : { ...state, heads }
|
||||
}
|
||||
case 'update': {
|
||||
const prev = state[action.groupName] || DEFAULT_STATE
|
||||
return { ...state, [action.groupName]: { ...prev, ...action.patch } }
|
||||
const prev = state.heads[action.groupName] || DEFAULT_STATE
|
||||
return {
|
||||
...state,
|
||||
heads: {
|
||||
...state.heads,
|
||||
[action.groupName]: { ...prev, ...action.patch },
|
||||
},
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
@@ -55,30 +82,42 @@ function headStateReducer(
|
||||
}
|
||||
|
||||
export function useHeadStateNew() {
|
||||
const { profiles } = useProfiles()
|
||||
const { profiles, current: profile } = useProfiles()
|
||||
const current = profiles?.current || ''
|
||||
|
||||
const [state, dispatch] = useReducer(headStateReducer, {})
|
||||
const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
|
||||
headStateReducer,
|
||||
{ profile: null, heads: {} },
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const urls = profile ? profileTestUrls(profile) : {}
|
||||
if (loadedProfile === current) {
|
||||
dispatch({ type: 'urls', urls })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(
|
||||
localStorage.getItem(HEAD_STATE_KEY)!,
|
||||
localStorage.getItem(HEAD_STATE_KEY) || '{}',
|
||||
) as HeadStateStorage
|
||||
|
||||
const value = data[current] || {}
|
||||
const value = data?.[current] || {}
|
||||
for (const [name, testUrl] of Object.entries(urls)) {
|
||||
value[name] = { ...DEFAULT_STATE, ...value[name], testUrl }
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
dispatch({ type: 'replace', payload: value })
|
||||
dispatch({ type: 'replace', profile: current, payload: value })
|
||||
} else {
|
||||
dispatch({ type: 'reset' })
|
||||
dispatch({ type: 'reset', profile: current })
|
||||
}
|
||||
} catch {
|
||||
dispatch({ type: 'reset' })
|
||||
dispatch({ type: 'reset', profile: current })
|
||||
}
|
||||
}, [current])
|
||||
}, [current, loadedProfile, profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedProfile !== current) return
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(HEAD_STATE_KEY)
|
||||
@@ -94,13 +133,16 @@ export function useHeadStateNew() {
|
||||
})
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [state, current])
|
||||
}, [state, current, loadedProfile])
|
||||
|
||||
const setHeadState = useCallback(
|
||||
(groupName: string, obj: Partial<HeadState>) => {
|
||||
dispatch({ type: 'update', groupName, patch: obj })
|
||||
if (current && obj.testUrl !== undefined) {
|
||||
void commitProfileTestUrl(current, groupName, obj.testUrl)
|
||||
}
|
||||
},
|
||||
[],
|
||||
[current],
|
||||
)
|
||||
|
||||
return [state, setHeadState] as const
|
||||
|
||||
@@ -117,6 +117,7 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
history: [],
|
||||
extra: {},
|
||||
members,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user