fix(ipc): reconcile Specta bindings with dev

This commit is contained in:
Tunglies
2026-10-12 02:19:25 +08:00
94 changed files with 3551 additions and 391 deletions
+11 -6
View File
@@ -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
}
+8
View File
@@ -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 -6
View File
@@ -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 -4
View File
@@ -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)
+60 -18
View File
@@ -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
+1
View File
@@ -117,6 +117,7 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
mptcp: false,
smux: false,
history: [],
extra: {},
members,
})