mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 12:22:07 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db9da5d06 | ||
|
|
371ca6e907 | ||
|
|
b02fb26c0c | ||
|
|
a2d875cebd |
+1
-1
@@ -29,6 +29,7 @@
|
||||
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
|
||||
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
|
||||
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
|
||||
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
@@ -48,7 +49,6 @@
|
||||
|
||||
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
|
||||
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
|
||||
- 优化连接和规则页面的资源占用,减少连接列表更新及排序时的卡顿
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('dayjs', () => ({ default: () => ({ from: () => '' }) }))
|
||||
vi.mock('react', () => ({
|
||||
memo: (component: unknown) => component,
|
||||
useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps at most one interval while visible and none while hidden or unsubscribed', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('window', globalThis)
|
||||
const visibility = Object.assign(new EventTarget(), { hidden: true })
|
||||
vi.stubGlobal('document', visibility)
|
||||
const { RelativeTime } = await import('./connection-relative-time')
|
||||
RelativeTime({ start: '' })
|
||||
const [subscribe] = vi.mocked(useSyncExternalStore).mock.calls[0]
|
||||
const unsubscribeFirst = subscribe(() => {})
|
||||
const unsubscribeSecond = subscribe(() => {})
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
visibility.hidden = false
|
||||
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
visibility.hidden = true
|
||||
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
visibility.hidden = false
|
||||
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unsubscribeFirst()
|
||||
unsubscribeSecond()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
visibility.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -8,7 +8,7 @@ let timerId: number | null = null
|
||||
const listeners = new Set<RelativeTimeListener>()
|
||||
|
||||
const startTimer = () => {
|
||||
if (timerId !== null || document.hidden || listeners.size === 0) return
|
||||
if (timerId !== null) return
|
||||
|
||||
currentTime = Date.now()
|
||||
timerId = window.setInterval(() => {
|
||||
@@ -18,34 +18,19 @@ const startTimer = () => {
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timerId === null) return
|
||||
if (listeners.size > 0 || timerId === null) return
|
||||
|
||||
window.clearInterval(timerId)
|
||||
timerId = null
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopTimer()
|
||||
} else {
|
||||
startTimer()
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
}
|
||||
|
||||
const subscribeRelativeTime = (listener: RelativeTimeListener) => {
|
||||
listeners.add(listener)
|
||||
if (listeners.size === 1) {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
startTimer()
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) {
|
||||
stopTimer()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatConnectionTraffic } from './connection-row-view'
|
||||
import { ConnectionTable } from './connection-table'
|
||||
|
||||
const sorting = vi.hoisted(() => ({ id: 'download', desc: false }))
|
||||
|
||||
vi.mock('react', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react')>()),
|
||||
useState: (initial: unknown) => [
|
||||
initial === null ? sorting : initial,
|
||||
vi.fn(),
|
||||
],
|
||||
}))
|
||||
vi.mock('foxact/use-local-storage', () => ({
|
||||
useLocalStorage: (_key: string, initial: unknown) => [initial, vi.fn()],
|
||||
}))
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
vi.mock('./connection-column-manager', () => ({
|
||||
ConnectionColumnManager: () => null,
|
||||
}))
|
||||
vi.mock('./connection-relative-time', () => ({
|
||||
RelativeTime: () => null,
|
||||
}))
|
||||
vi.mock('./connection-row-view', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('./connection-row-view')>()
|
||||
return {
|
||||
...original,
|
||||
formatConnectionTraffic: vi.fn(original.formatConnectionTraffic),
|
||||
}
|
||||
})
|
||||
|
||||
it('formats only virtual rows when sorting numeric columns above the cache bound', () => {
|
||||
const connections: IConnectionsItem[] = Array.from(
|
||||
{ length: 2_101 },
|
||||
(_, i) => ({
|
||||
id: `${i}`,
|
||||
metadata: {
|
||||
network: 'tcp',
|
||||
type: 'HTTP',
|
||||
host: `host-${i}`,
|
||||
sourceIP: '',
|
||||
sourcePort: '',
|
||||
destinationPort: '443',
|
||||
},
|
||||
chains: [],
|
||||
rule: '',
|
||||
rulePayload: '',
|
||||
start: '',
|
||||
upload: (i * 3) % 2_101,
|
||||
download: (i * 5) % 2_101,
|
||||
curUpload: (i * 7) % 2_101,
|
||||
curDownload: (i * 11) % 2_101,
|
||||
}),
|
||||
)
|
||||
|
||||
for (const column of ['download', 'upload', 'dlSpeed', 'ulSpeed']) {
|
||||
sorting.id = column
|
||||
vi.mocked(formatConnectionTraffic).mockClear()
|
||||
renderToStaticMarkup(
|
||||
createElement(ConnectionTable, {
|
||||
connections,
|
||||
onShowDetail: vi.fn(),
|
||||
columnManagerOpen: false,
|
||||
onCloseColumnManager: vi.fn(),
|
||||
}),
|
||||
)
|
||||
expect(formatConnectionTraffic).toHaveBeenCalledTimes(24)
|
||||
}
|
||||
})
|
||||
@@ -223,16 +223,6 @@ const compareConnectionCellValue = (
|
||||
right: IConnectionsItem,
|
||||
getSnapshot: (row: IConnectionsItem) => TableRowSnapshot,
|
||||
) => {
|
||||
switch (field) {
|
||||
case 'download':
|
||||
return (left.download ?? 0) - (right.download ?? 0)
|
||||
case 'upload':
|
||||
return (left.upload ?? 0) - (right.upload ?? 0)
|
||||
case 'dlSpeed':
|
||||
return (left.curDownload ?? 0) - (right.curDownload ?? 0)
|
||||
case 'ulSpeed':
|
||||
return (left.curUpload ?? 0) - (right.curUpload ?? 0)
|
||||
}
|
||||
const leftValue = getConnectionCellValue(field, getSnapshot(left))
|
||||
const rightValue = getConnectionCellValue(field, getSnapshot(right))
|
||||
|
||||
@@ -501,8 +491,6 @@ export const ConnectionTable = (props: Props) => {
|
||||
const [sorting, setSorting] = useState<SortingState | null>(null)
|
||||
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(() => () => resizeCleanupRef.current?.(), [])
|
||||
const rowSnapshotCacheRef = useRef(new Map<string, TableRowSnapshot>())
|
||||
const getRowSnapshot = useCallback((row: IConnectionsItem) => {
|
||||
const cache = rowSnapshotCacheRef.current
|
||||
@@ -676,7 +664,6 @@ export const ConnectionTable = (props: Props) => {
|
||||
minWidth: number,
|
||||
maxWidth: number | undefined,
|
||||
) => {
|
||||
resizeCleanupRef.current?.()
|
||||
const handleMove = (clientX: number) => {
|
||||
const nextWidth = Math.max(
|
||||
minWidth,
|
||||
@@ -699,10 +686,8 @@ export const ConnectionTable = (props: Props) => {
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', cleanup)
|
||||
window.removeEventListener('touchcancel', cleanup)
|
||||
resizeCleanupRef.current = null
|
||||
}
|
||||
|
||||
resizeCleanupRef.current = cleanup
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', cleanup)
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||
|
||||
@@ -16,8 +16,7 @@ const COLOR = [
|
||||
]
|
||||
|
||||
interface Props {
|
||||
value: Rule
|
||||
lineNo: number
|
||||
value: Rule & { lineNo: number }
|
||||
}
|
||||
|
||||
const parseColor = (text: string) => {
|
||||
@@ -32,7 +31,7 @@ const parseColor = (text: string) => {
|
||||
}
|
||||
|
||||
const RuleItem = (props: Props) => {
|
||||
const { value, lineNo } = props
|
||||
const { value } = props
|
||||
|
||||
return (
|
||||
<Item sx={{ borderBottom: '1px solid var(--divider-color)' }}>
|
||||
@@ -41,7 +40,7 @@ const RuleItem = (props: Props) => {
|
||||
variant="body2"
|
||||
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: 'center' }}
|
||||
>
|
||||
{lineNo}
|
||||
{value.lineNo}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ userSelect: 'text' }}>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const socket = vi.hoisted(() => ({
|
||||
addListener:
|
||||
vi.fn<
|
||||
(listener: (message: { type: string; data: string }) => void) => void
|
||||
>(),
|
||||
close: async () => {},
|
||||
}))
|
||||
|
||||
vi.mock('react', () => ({
|
||||
useCallback: (callback: unknown) => callback,
|
||||
useMemo: (factory: () => unknown) => factory(),
|
||||
useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()),
|
||||
}))
|
||||
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||
MihomoWebSocket: { connect_connections: async () => socket },
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reuses unchanged snapshot and list references without notifying subscribers', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
vi.stubGlobal('window', globalThis)
|
||||
const { useConnectionData: readConnectionData } = await import(
|
||||
'./use-connection-data'
|
||||
)
|
||||
readConnectionData()
|
||||
const [subscribe, read] = vi
|
||||
.mocked(
|
||||
useSyncExternalStore<
|
||||
ReturnType<typeof readConnectionData>['response']['data']
|
||||
>,
|
||||
)
|
||||
.mock.calls.at(-1)!
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribe(listener)
|
||||
await Promise.resolve()
|
||||
const onMessage = socket.addListener.mock.calls[0][0]
|
||||
const connections: IConnectionsItem[] = [
|
||||
{
|
||||
id: 'a',
|
||||
metadata: {
|
||||
network: 'tcp',
|
||||
type: 'HTTP',
|
||||
host: 'example.com',
|
||||
sourceIP: '',
|
||||
sourcePort: '',
|
||||
destinationPort: '443',
|
||||
},
|
||||
chains: [],
|
||||
rule: '',
|
||||
rulePayload: '',
|
||||
start: '',
|
||||
upload: 0,
|
||||
download: 0,
|
||||
},
|
||||
]
|
||||
const send = (uploadTotal = 0) => {
|
||||
vi.advanceTimersByTime(500)
|
||||
onMessage({
|
||||
type: 'Text',
|
||||
data: JSON.stringify({ connections, uploadTotal, downloadTotal: 0 }),
|
||||
})
|
||||
return read()
|
||||
}
|
||||
const first = send()
|
||||
expect(send()).toBe(first)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
const totalsOnly = send(100)
|
||||
expect(totalsOnly.activeConnections).toBe(first.activeConnections)
|
||||
expect(totalsOnly.closedConnections).toBe(first.closedConnections)
|
||||
unsubscribe()
|
||||
})
|
||||
@@ -150,49 +150,25 @@ const mergeConnectionSnapshot = (
|
||||
const nextConnections = payload.connections ?? []
|
||||
const previousActive = previous.activeConnections ?? []
|
||||
const previousClosed = previous.closedConnections ?? []
|
||||
const sameOrder =
|
||||
nextConnections.length === previousActive.length &&
|
||||
nextConnections.every(
|
||||
(connection, i) => connection.id === previousActive[i].id,
|
||||
)
|
||||
let previousActiveById: Map<string, IConnectionsItem> | undefined
|
||||
const previousActiveById = new Map<string, IConnectionsItem>()
|
||||
|
||||
if (!sameOrder) {
|
||||
previousActiveById = new Map()
|
||||
for (let i = 0; i < previousActive.length; i++) {
|
||||
const previousConnection = previousActive[i]
|
||||
previousActiveById.set(previousConnection.id, previousConnection)
|
||||
}
|
||||
for (let i = 0; i < previousActive.length; i++) {
|
||||
const previousConnection = previousActive[i]
|
||||
previousActiveById.set(previousConnection.id, previousConnection)
|
||||
}
|
||||
|
||||
let activeConnections = sameOrder ? previousActive : []
|
||||
const activeConnections: IConnectionsItem[] = []
|
||||
for (let i = 0; i < nextConnections.length; i++) {
|
||||
const connection = nextConnections[i]
|
||||
const previousConnection = sameOrder
|
||||
? previousActive[i]
|
||||
: previousActiveById?.get(connection.id)
|
||||
previousActiveById?.delete(connection.id)
|
||||
const normalized = normalizeConnection(connection, previousConnection)
|
||||
if (activeConnections[i] === normalized) continue
|
||||
if (activeConnections === previousActive) {
|
||||
activeConnections = previousActive.slice()
|
||||
}
|
||||
activeConnections[i] = normalized
|
||||
const previousConnection = previousActiveById.get(connection.id)
|
||||
if (previousConnection) previousActiveById.delete(connection.id)
|
||||
activeConnections.push(normalizeConnection(connection, previousConnection))
|
||||
}
|
||||
|
||||
const uploadTotal = payload.uploadTotal ?? 0
|
||||
const downloadTotal = payload.downloadTotal ?? 0
|
||||
if (!previousActiveById?.size) {
|
||||
if (
|
||||
activeConnections === previousActive &&
|
||||
uploadTotal === previous.uploadTotal &&
|
||||
downloadTotal === previous.downloadTotal
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
if (previousActiveById.size === 0) {
|
||||
return {
|
||||
uploadTotal,
|
||||
downloadTotal,
|
||||
uploadTotal: payload.uploadTotal ?? 0,
|
||||
downloadTotal: payload.downloadTotal ?? 0,
|
||||
activeConnections,
|
||||
closedConnections: previousClosed,
|
||||
}
|
||||
@@ -222,8 +198,8 @@ const mergeConnectionSnapshot = (
|
||||
}
|
||||
|
||||
return {
|
||||
uploadTotal,
|
||||
downloadTotal,
|
||||
uploadTotal: payload.uploadTotal ?? 0,
|
||||
downloadTotal: payload.downloadTotal ?? 0,
|
||||
activeConnections,
|
||||
closedConnections,
|
||||
}
|
||||
@@ -251,9 +227,7 @@ const flushPendingMessage = () => {
|
||||
|
||||
lastFlushAt = Date.now()
|
||||
|
||||
const nextData = mergeConnectionSnapshot(payload, connectionData)
|
||||
if (nextData === connectionData) return
|
||||
connectionData = nextData
|
||||
connectionData = mergeConnectionSnapshot(payload, connectionData)
|
||||
notifyConnectionListeners()
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,7 @@ export function useProxyDelayState(
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreset) return
|
||||
delayManager.setListener(name, groupName, setDelayState)
|
||||
return () => {
|
||||
delayManager.removeListener(name, groupName)
|
||||
}
|
||||
return delayManager.addListener(name, groupName, setDelayState)
|
||||
}, [name, groupName, isPreset])
|
||||
|
||||
const updateDelay = useCallback(() => {
|
||||
@@ -90,8 +87,7 @@ export function useProxyDelayState(
|
||||
|
||||
const onDelay = useLockFn(async () => {
|
||||
if (!isInteractableMember(member)) return
|
||||
setDelayState({ delay: -2, updatedAt: Date.now() })
|
||||
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
|
||||
await delayManager.checkDelay(member, groupName, timeout)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,7 +49,11 @@ const ProxyPage = () => {
|
||||
>(null)
|
||||
|
||||
const { clashConfig } = useClashConfigData()
|
||||
const { refreshClashConfig } = useAppRefreshers()
|
||||
const { refreshClashConfig, refreshProxy } = useAppRefreshers()
|
||||
|
||||
useEffect(() => {
|
||||
refreshProxy().catch((error) => showNotice.error(error))
|
||||
}, [refreshProxy])
|
||||
|
||||
const normalizedMode = clashConfig?.mode?.toLowerCase()
|
||||
const curMode = isMode(normalizedMode) ? normalizedMode : undefined
|
||||
|
||||
+8
-11
@@ -34,11 +34,13 @@ const RulesPage = () => {
|
||||
}, [refreshRules, refreshRuleProviders, pageVisible])
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const indices: number[] = []
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
if (match(rules[i].payload ?? '')) indices.push(i)
|
||||
}
|
||||
return indices
|
||||
const rulesWithLineNo = rules.map((item, index) => ({
|
||||
...item,
|
||||
// UI-only derived data; keep app context/SWR data immutable
|
||||
lineNo: index + 1,
|
||||
}))
|
||||
|
||||
return rulesWithLineNo.filter((item) => match(item.payload ?? ''))
|
||||
}, [rules, match])
|
||||
|
||||
const handleScroll = useCallback((e: Event) => {
|
||||
@@ -88,12 +90,7 @@ const RulesPage = () => {
|
||||
ref={virtuosoRef}
|
||||
count={filteredRules.length}
|
||||
estimateSize={40}
|
||||
renderItem={(i) => (
|
||||
<RuleItem
|
||||
value={rules[filteredRules[i]]}
|
||||
lineNo={filteredRules[i] + 1}
|
||||
/>
|
||||
)}
|
||||
renderItem={(i) => <RuleItem value={filteredRules[i]} />}
|
||||
style={{ flex: 1 }}
|
||||
onScroll={handleScroll}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
getBaseConfig,
|
||||
getRuleProviders,
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
} from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { getProxyView, getSystemProxy } from '@/services/cmds'
|
||||
import delayManager from '@/services/delay'
|
||||
import { useQuery } from '@/services/query-client'
|
||||
import { useRunState } from '@/store/app-store-context'
|
||||
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
|
||||
@@ -53,6 +55,8 @@ export const AppDataProvider = ({
|
||||
const { verge } = useVerge()
|
||||
const { data: runtimeConfig } = useRuntimeConfig()
|
||||
const { clashInfo } = useClashInfo()
|
||||
const { profiles } = useProfiles()
|
||||
const currentProfile = profiles?.current
|
||||
|
||||
const {
|
||||
data: proxyView,
|
||||
@@ -102,6 +106,12 @@ export const AppDataProvider = ({
|
||||
const runningMode = runState?.mode
|
||||
|
||||
const refreshProxy = useStableFn(_refetchProxyView)
|
||||
useEffect(() => {
|
||||
if (currentProfile === undefined) return
|
||||
delayManager.setProfile(currentProfile)
|
||||
void refreshProxy()
|
||||
}, [currentProfile, refreshProxy])
|
||||
|
||||
const refreshClashConfig = useStableFn(_refetchClashConfig)
|
||||
const refreshRules = useStableFn(_refetchRules)
|
||||
const refreshSysproxy = useStableFn(_refetchSysproxy)
|
||||
|
||||
+63
-16
@@ -5,26 +5,32 @@ vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
||||
}))
|
||||
|
||||
import type { ResolvedProxyMember } from '@/types/proxy-view'
|
||||
import type { InteractableProxyMember } from '@/types/proxy-view'
|
||||
|
||||
import delayManager from './delay'
|
||||
|
||||
const node = (name: string) =>
|
||||
({
|
||||
kind: 'node',
|
||||
ref: { kind: 'node', name, recordId: `r:${name}` },
|
||||
node: {
|
||||
recordId: `r:${name}`,
|
||||
name,
|
||||
history: [],
|
||||
source: { kind: 'core', proxyName: name },
|
||||
},
|
||||
}) as unknown as ResolvedProxyMember
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const node = (
|
||||
name: string,
|
||||
): Extract<InteractableProxyMember, { kind: 'node' }> => ({
|
||||
kind: 'node',
|
||||
ref: { kind: 'node', name, recordId: `r:${name}` },
|
||||
node: {
|
||||
recordId: `r:${name}`,
|
||||
name,
|
||||
type: 'Shadowsocks',
|
||||
history: [],
|
||||
source: { kind: 'core', proxyName: name },
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
},
|
||||
})
|
||||
|
||||
let settles = 0
|
||||
let unsubscribe: () => void
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
beforeEach(() => {
|
||||
settles = 0
|
||||
@@ -39,7 +45,7 @@ describe('group delay completion', () => {
|
||||
test('notifies once after a batch settles', async () => {
|
||||
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
|
||||
|
||||
await delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
||||
await delayManager.checkListDelay(proxies, 'g', 5000, 2)
|
||||
await flush()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
@@ -51,7 +57,7 @@ describe('group delay completion', () => {
|
||||
other += 1
|
||||
})
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await delayManager.checkDelay(node('a'), 'g', 5000)
|
||||
await flush()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
@@ -59,3 +65,44 @@ describe('group delay completion', () => {
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile delay isolation', () => {
|
||||
test('notifies both shared-key occurrences and preserves the surviving subscription', async () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const stopFirst = delayManager.addListener('a', 'g', first)
|
||||
const stopSecond = delayManager.addListener('a', 'g', second)
|
||||
try {
|
||||
delayManager.setDelay('a', 'g', -2)
|
||||
await flush()
|
||||
stopFirst()
|
||||
delayManager.setDelay('a', 'g', 120)
|
||||
await flush()
|
||||
expect(first.mock.calls.map(([update]) => update.delay)).toEqual([-2])
|
||||
expect(second.mock.calls.map(([update]) => update.delay)).toEqual([
|
||||
-2, 120,
|
||||
])
|
||||
} finally {
|
||||
stopFirst()
|
||||
stopSecond()
|
||||
}
|
||||
})
|
||||
|
||||
test('clears cached delays and fences an old batch across a profile switch', async () => {
|
||||
delayManager.setProfile('initial')
|
||||
delayManager.setDelay('cached', 'g', 900)
|
||||
const oldBatch = delayManager.checkListDelay(
|
||||
[node('a'), node('not-started')],
|
||||
'g',
|
||||
5000,
|
||||
1,
|
||||
)
|
||||
delayManager.setProfile('next')
|
||||
expect(delayManager.getDelayUpdate('cached', 'g')).toBeUndefined()
|
||||
delayManager.setDelay('a', 'g', 42)
|
||||
await oldBatch
|
||||
await flush()
|
||||
expect(delayManager.getDelay('a', 'g')).toBe(42)
|
||||
expect(delayManager.getDelayUpdate('not-started', 'g')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
+81
-32
@@ -29,10 +29,12 @@ export interface DelayUpdate {
|
||||
const CACHE_TTL = 30 * 60 * 1000
|
||||
|
||||
class DelayManager {
|
||||
private profile: string | null = null
|
||||
private generation = 0
|
||||
private cache = new Map<string, DelayUpdate>()
|
||||
private urlMap = new Map<string, string>()
|
||||
|
||||
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
|
||||
private listenerMap = new Map<string, Set<(update: DelayUpdate) => void>>()
|
||||
|
||||
private groupListenerMap = new Map<string, Set<() => void>>()
|
||||
// Consumers compare snapshot identity; replace it only when the group settles.
|
||||
@@ -49,6 +51,35 @@ class DelayManager {
|
||||
private itemFlushScheduled = false
|
||||
private groupFlushScheduled = false
|
||||
|
||||
setProfile(profile: string | null) {
|
||||
if (this.profile === profile) return
|
||||
this.profile = profile
|
||||
this.generation += 1
|
||||
this.cache.clear()
|
||||
this.activeBatches.clear()
|
||||
this.pendingItemUpdates.clear()
|
||||
this.pendingGroupUpdates.clear()
|
||||
this.groupSnapshots.clear()
|
||||
this.groupSetSnapshots.clear()
|
||||
|
||||
// Reset before a refreshed proxy snapshot can publish newer history.
|
||||
for (const [key, listeners] of this.listenerMap) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener({ delay: -1, updatedAt: 0 })
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DelayManager] Failed to reset delay listener: ${key}`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of this.groupListenerMap.keys()) {
|
||||
this.queueGroupNotification(group)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleOnNextFrame(run: () => void): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
@@ -74,17 +105,19 @@ class DelayManager {
|
||||
this.pendingItemUpdates = new Map()
|
||||
|
||||
updates.forEach((queue, key) => {
|
||||
const listener = this.listenerMap.get(key)
|
||||
if (!listener) return
|
||||
const listeners = this.listenerMap.get(key)
|
||||
if (!listeners) return
|
||||
|
||||
queue.forEach((update) => {
|
||||
try {
|
||||
listener(update)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
|
||||
error,
|
||||
)
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(update)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -170,18 +203,22 @@ class DelayManager {
|
||||
return url || 'http://cp.cloudflare.com/generate_204'
|
||||
}
|
||||
|
||||
setListener(
|
||||
addListener(
|
||||
name: string,
|
||||
group: string,
|
||||
listener: (update: DelayUpdate) => void,
|
||||
) {
|
||||
): () => void {
|
||||
const key = hashKey(name, group)
|
||||
this.listenerMap.set(key, listener)
|
||||
}
|
||||
const listeners = this.listenerMap.get(key) ?? new Set()
|
||||
listeners.add(listener)
|
||||
this.listenerMap.set(key, listeners)
|
||||
|
||||
removeListener(name: string, group: string) {
|
||||
const key = hashKey(name, group)
|
||||
this.listenerMap.delete(key)
|
||||
return () => {
|
||||
const current = this.listenerMap.get(key)
|
||||
if (!current) return
|
||||
current.delete(listener)
|
||||
if (current.size === 0) this.listenerMap.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple views may subscribe independently; notifications occur only after settle. */
|
||||
@@ -276,19 +313,21 @@ class DelayManager {
|
||||
member: InteractableProxyMember,
|
||||
group: string,
|
||||
timeout: number,
|
||||
): Promise<DelayUpdate> {
|
||||
): Promise<void> {
|
||||
const generation = this.generation
|
||||
// Entry points own the pending mark; measureDelay only writes results.
|
||||
this.setDelay(member.ref.name, group, -2)
|
||||
const update = await this.measureDelay(member, group, timeout)
|
||||
this.queueGroupNotification(group)
|
||||
return update
|
||||
await this.measureDelay(member, group, timeout, generation)
|
||||
if (generation === this.generation) this.queueGroupNotification(group)
|
||||
}
|
||||
|
||||
private async measureDelay(
|
||||
member: InteractableProxyMember,
|
||||
group: string,
|
||||
timeout: number,
|
||||
): Promise<DelayUpdate> {
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
if (generation !== this.generation) return
|
||||
const name = member.ref.name
|
||||
const providerName =
|
||||
member.kind === 'node' ? providerNameOf(member.node) : undefined
|
||||
@@ -324,14 +363,18 @@ class DelayManager {
|
||||
const elapsed = elapsedTime
|
||||
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
|
||||
|
||||
return this.setDelay(name, group, delay, { elapsed })
|
||||
if (generation === this.generation) {
|
||||
this.setDelay(name, group, delay, { elapsed })
|
||||
}
|
||||
} catch (error) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
|
||||
const delay = 1e6 // error
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
return this.setDelay(name, group, delay, { elapsed })
|
||||
if (generation === this.generation) {
|
||||
this.setDelay(name, group, delay, { elapsed })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +384,7 @@ class DelayManager {
|
||||
timeout: number,
|
||||
concurrency = 36,
|
||||
) {
|
||||
const generation = this.generation
|
||||
debugLog(
|
||||
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
|
||||
)
|
||||
@@ -354,6 +398,7 @@ class DelayManager {
|
||||
const startTime = Date.now()
|
||||
|
||||
const help = async (): Promise<void> => {
|
||||
if (generation !== this.generation) return
|
||||
const currMember = proxies[index++]
|
||||
if (!currMember) return
|
||||
const currName = currMember.ref.name
|
||||
@@ -367,13 +412,15 @@ class DelayManager {
|
||||
}
|
||||
|
||||
// Do not reorder a list around the pointer while batch results are arriving.
|
||||
await this.measureDelay(currMember, group, timeout)
|
||||
await this.measureDelay(currMember, group, timeout, generation)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
|
||||
error,
|
||||
)
|
||||
this.setDelay(currName, group, 1e6)
|
||||
if (generation === this.generation) {
|
||||
this.setDelay(currName, group, 1e6)
|
||||
}
|
||||
}
|
||||
|
||||
return help()
|
||||
@@ -391,12 +438,14 @@ class DelayManager {
|
||||
await Promise.all(promiseList)
|
||||
} finally {
|
||||
// Always release the batch and notify; otherwise failures leave stale sort state.
|
||||
const remaining = (this.activeBatches.get(group) ?? 1) - 1
|
||||
if (remaining > 0) {
|
||||
this.activeBatches.set(group, remaining)
|
||||
} else {
|
||||
this.activeBatches.delete(group)
|
||||
this.queueGroupNotification(group)
|
||||
if (generation === this.generation) {
|
||||
const remaining = (this.activeBatches.get(group) ?? 1) - 1
|
||||
if (remaining > 0) {
|
||||
this.activeBatches.set(group, remaining)
|
||||
} else {
|
||||
this.activeBatches.delete(group)
|
||||
this.queueGroupNotification(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalTime = Date.now() - startTime
|
||||
|
||||
Reference in New Issue
Block a user