mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 12:22:07 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db9da5d06 | ||
|
|
371ca6e907 | ||
|
|
b02fb26c0c | ||
|
|
a2d875cebd |
@@ -29,6 +29,7 @@
|
|||||||
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
|
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
|
||||||
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
|
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
|
||||||
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
|
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
|
||||||
|
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
|
||||||
|
|
||||||
**🍎 macOS**
|
**🍎 macOS**
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ export function useProxyDelayState(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPreset) return
|
if (isPreset) return
|
||||||
delayManager.setListener(name, groupName, setDelayState)
|
return delayManager.addListener(name, groupName, setDelayState)
|
||||||
return () => {
|
|
||||||
delayManager.removeListener(name, groupName)
|
|
||||||
}
|
|
||||||
}, [name, groupName, isPreset])
|
}, [name, groupName, isPreset])
|
||||||
|
|
||||||
const updateDelay = useCallback(() => {
|
const updateDelay = useCallback(() => {
|
||||||
@@ -90,8 +87,7 @@ export function useProxyDelayState(
|
|||||||
|
|
||||||
const onDelay = useLockFn(async () => {
|
const onDelay = useLockFn(async () => {
|
||||||
if (!isInteractableMember(member)) return
|
if (!isInteractableMember(member)) return
|
||||||
setDelayState({ delay: -2, updatedAt: Date.now() })
|
await delayManager.checkDelay(member, groupName, timeout)
|
||||||
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ const ProxyPage = () => {
|
|||||||
>(null)
|
>(null)
|
||||||
|
|
||||||
const { clashConfig } = useClashConfigData()
|
const { clashConfig } = useClashConfigData()
|
||||||
const { refreshClashConfig } = useAppRefreshers()
|
const { refreshClashConfig, refreshProxy } = useAppRefreshers()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshProxy().catch((error) => showNotice.error(error))
|
||||||
|
}, [refreshProxy])
|
||||||
|
|
||||||
const normalizedMode = clashConfig?.mode?.toLowerCase()
|
const normalizedMode = clashConfig?.mode?.toLowerCase()
|
||||||
const curMode = isMode(normalizedMode) ? normalizedMode : undefined
|
const curMode = isMode(normalizedMode) ? normalizedMode : undefined
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
getBaseConfig,
|
getBaseConfig,
|
||||||
getRuleProviders,
|
getRuleProviders,
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
} from 'tauri-plugin-mihomo-api'
|
} from 'tauri-plugin-mihomo-api'
|
||||||
|
|
||||||
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
|
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
|
||||||
|
import { useProfiles } from '@/hooks/use-profiles'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
import { getProxyView, getSystemProxy } from '@/services/cmds'
|
import { getProxyView, getSystemProxy } from '@/services/cmds'
|
||||||
|
import delayManager from '@/services/delay'
|
||||||
import { useQuery } from '@/services/query-client'
|
import { useQuery } from '@/services/query-client'
|
||||||
import { useRunState } from '@/store/app-store-context'
|
import { useRunState } from '@/store/app-store-context'
|
||||||
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
|
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
|
||||||
@@ -53,6 +55,8 @@ export const AppDataProvider = ({
|
|||||||
const { verge } = useVerge()
|
const { verge } = useVerge()
|
||||||
const { data: runtimeConfig } = useRuntimeConfig()
|
const { data: runtimeConfig } = useRuntimeConfig()
|
||||||
const { clashInfo } = useClashInfo()
|
const { clashInfo } = useClashInfo()
|
||||||
|
const { profiles } = useProfiles()
|
||||||
|
const currentProfile = profiles?.current
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: proxyView,
|
data: proxyView,
|
||||||
@@ -102,6 +106,12 @@ export const AppDataProvider = ({
|
|||||||
const runningMode = runState?.mode
|
const runningMode = runState?.mode
|
||||||
|
|
||||||
const refreshProxy = useStableFn(_refetchProxyView)
|
const refreshProxy = useStableFn(_refetchProxyView)
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentProfile === undefined) return
|
||||||
|
delayManager.setProfile(currentProfile)
|
||||||
|
void refreshProxy()
|
||||||
|
}, [currentProfile, refreshProxy])
|
||||||
|
|
||||||
const refreshClashConfig = useStableFn(_refetchClashConfig)
|
const refreshClashConfig = useStableFn(_refetchClashConfig)
|
||||||
const refreshRules = useStableFn(_refetchRules)
|
const refreshRules = useStableFn(_refetchRules)
|
||||||
const refreshSysproxy = useStableFn(_refetchSysproxy)
|
const refreshSysproxy = useStableFn(_refetchSysproxy)
|
||||||
|
|||||||
+63
-16
@@ -5,26 +5,32 @@ vi.mock('tauri-plugin-mihomo-api', () => ({
|
|||||||
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import type { ResolvedProxyMember } from '@/types/proxy-view'
|
import type { InteractableProxyMember } from '@/types/proxy-view'
|
||||||
|
|
||||||
import delayManager from './delay'
|
import delayManager from './delay'
|
||||||
|
|
||||||
const node = (name: string) =>
|
const node = (
|
||||||
({
|
name: string,
|
||||||
kind: 'node',
|
): Extract<InteractableProxyMember, { kind: 'node' }> => ({
|
||||||
ref: { kind: 'node', name, recordId: `r:${name}` },
|
kind: 'node',
|
||||||
node: {
|
ref: { kind: 'node', name, recordId: `r:${name}` },
|
||||||
recordId: `r:${name}`,
|
node: {
|
||||||
name,
|
recordId: `r:${name}`,
|
||||||
history: [],
|
name,
|
||||||
source: { kind: 'core', proxyName: name },
|
type: 'Shadowsocks',
|
||||||
},
|
history: [],
|
||||||
}) as unknown as ResolvedProxyMember
|
source: { kind: 'core', proxyName: name },
|
||||||
|
udp: false,
|
||||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
xudp: false,
|
||||||
|
tfo: false,
|
||||||
|
mptcp: false,
|
||||||
|
smux: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
let settles = 0
|
let settles = 0
|
||||||
let unsubscribe: () => void
|
let unsubscribe: () => void
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
settles = 0
|
settles = 0
|
||||||
@@ -39,7 +45,7 @@ describe('group delay completion', () => {
|
|||||||
test('notifies once after a batch settles', async () => {
|
test('notifies once after a batch settles', async () => {
|
||||||
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
|
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()
|
await flush()
|
||||||
|
|
||||||
expect(settles).toBe(1)
|
expect(settles).toBe(1)
|
||||||
@@ -51,7 +57,7 @@ describe('group delay completion', () => {
|
|||||||
other += 1
|
other += 1
|
||||||
})
|
})
|
||||||
|
|
||||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
await delayManager.checkDelay(node('a'), 'g', 5000)
|
||||||
await flush()
|
await flush()
|
||||||
|
|
||||||
expect(settles).toBe(1)
|
expect(settles).toBe(1)
|
||||||
@@ -59,3 +65,44 @@ describe('group delay completion', () => {
|
|||||||
stop()
|
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
|
const CACHE_TTL = 30 * 60 * 1000
|
||||||
|
|
||||||
class DelayManager {
|
class DelayManager {
|
||||||
|
private profile: string | null = null
|
||||||
|
private generation = 0
|
||||||
private cache = new Map<string, DelayUpdate>()
|
private cache = new Map<string, DelayUpdate>()
|
||||||
private urlMap = new Map<string, string>()
|
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>>()
|
private groupListenerMap = new Map<string, Set<() => void>>()
|
||||||
// Consumers compare snapshot identity; replace it only when the group settles.
|
// Consumers compare snapshot identity; replace it only when the group settles.
|
||||||
@@ -49,6 +51,35 @@ class DelayManager {
|
|||||||
private itemFlushScheduled = false
|
private itemFlushScheduled = false
|
||||||
private groupFlushScheduled = 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 {
|
private scheduleOnNextFrame(run: () => void): void {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
if (typeof window.requestAnimationFrame === 'function') {
|
if (typeof window.requestAnimationFrame === 'function') {
|
||||||
@@ -74,17 +105,19 @@ class DelayManager {
|
|||||||
this.pendingItemUpdates = new Map()
|
this.pendingItemUpdates = new Map()
|
||||||
|
|
||||||
updates.forEach((queue, key) => {
|
updates.forEach((queue, key) => {
|
||||||
const listener = this.listenerMap.get(key)
|
const listeners = this.listenerMap.get(key)
|
||||||
if (!listener) return
|
if (!listeners) return
|
||||||
|
|
||||||
queue.forEach((update) => {
|
queue.forEach((update) => {
|
||||||
try {
|
for (const listener of [...listeners]) {
|
||||||
listener(update)
|
try {
|
||||||
} catch (error) {
|
listener(update)
|
||||||
console.error(
|
} catch (error) {
|
||||||
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
|
console.error(
|
||||||
error,
|
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
|
||||||
)
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -170,18 +203,22 @@ class DelayManager {
|
|||||||
return url || 'http://cp.cloudflare.com/generate_204'
|
return url || 'http://cp.cloudflare.com/generate_204'
|
||||||
}
|
}
|
||||||
|
|
||||||
setListener(
|
addListener(
|
||||||
name: string,
|
name: string,
|
||||||
group: string,
|
group: string,
|
||||||
listener: (update: DelayUpdate) => void,
|
listener: (update: DelayUpdate) => void,
|
||||||
) {
|
): () => void {
|
||||||
const key = hashKey(name, group)
|
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) {
|
return () => {
|
||||||
const key = hashKey(name, group)
|
const current = this.listenerMap.get(key)
|
||||||
this.listenerMap.delete(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. */
|
/** Multiple views may subscribe independently; notifications occur only after settle. */
|
||||||
@@ -276,19 +313,21 @@ class DelayManager {
|
|||||||
member: InteractableProxyMember,
|
member: InteractableProxyMember,
|
||||||
group: string,
|
group: string,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
): Promise<DelayUpdate> {
|
): Promise<void> {
|
||||||
|
const generation = this.generation
|
||||||
// Entry points own the pending mark; measureDelay only writes results.
|
// Entry points own the pending mark; measureDelay only writes results.
|
||||||
this.setDelay(member.ref.name, group, -2)
|
this.setDelay(member.ref.name, group, -2)
|
||||||
const update = await this.measureDelay(member, group, timeout)
|
await this.measureDelay(member, group, timeout, generation)
|
||||||
this.queueGroupNotification(group)
|
if (generation === this.generation) this.queueGroupNotification(group)
|
||||||
return update
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async measureDelay(
|
private async measureDelay(
|
||||||
member: InteractableProxyMember,
|
member: InteractableProxyMember,
|
||||||
group: string,
|
group: string,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
): Promise<DelayUpdate> {
|
generation: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (generation !== this.generation) return
|
||||||
const name = member.ref.name
|
const name = member.ref.name
|
||||||
const providerName =
|
const providerName =
|
||||||
member.kind === 'node' ? providerNameOf(member.node) : undefined
|
member.kind === 'node' ? providerNameOf(member.node) : undefined
|
||||||
@@ -324,14 +363,18 @@ class DelayManager {
|
|||||||
const elapsed = elapsedTime
|
const elapsed = elapsedTime
|
||||||
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
|
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
|
||||||
|
|
||||||
return this.setDelay(name, group, delay, { elapsed })
|
if (generation === this.generation) {
|
||||||
|
this.setDelay(name, group, delay, { elapsed })
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
|
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
|
||||||
const delay = 1e6 // error
|
const delay = 1e6 // error
|
||||||
const elapsed = Date.now() - startTime
|
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,
|
timeout: number,
|
||||||
concurrency = 36,
|
concurrency = 36,
|
||||||
) {
|
) {
|
||||||
|
const generation = this.generation
|
||||||
debugLog(
|
debugLog(
|
||||||
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
|
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
|
||||||
)
|
)
|
||||||
@@ -354,6 +398,7 @@ class DelayManager {
|
|||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
const help = async (): Promise<void> => {
|
const help = async (): Promise<void> => {
|
||||||
|
if (generation !== this.generation) return
|
||||||
const currMember = proxies[index++]
|
const currMember = proxies[index++]
|
||||||
if (!currMember) return
|
if (!currMember) return
|
||||||
const currName = currMember.ref.name
|
const currName = currMember.ref.name
|
||||||
@@ -367,13 +412,15 @@ class DelayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Do not reorder a list around the pointer while batch results are arriving.
|
// 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) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
|
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
this.setDelay(currName, group, 1e6)
|
if (generation === this.generation) {
|
||||||
|
this.setDelay(currName, group, 1e6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return help()
|
return help()
|
||||||
@@ -391,12 +438,14 @@ class DelayManager {
|
|||||||
await Promise.all(promiseList)
|
await Promise.all(promiseList)
|
||||||
} finally {
|
} finally {
|
||||||
// Always release the batch and notify; otherwise failures leave stale sort state.
|
// Always release the batch and notify; otherwise failures leave stale sort state.
|
||||||
const remaining = (this.activeBatches.get(group) ?? 1) - 1
|
if (generation === this.generation) {
|
||||||
if (remaining > 0) {
|
const remaining = (this.activeBatches.get(group) ?? 1) - 1
|
||||||
this.activeBatches.set(group, remaining)
|
if (remaining > 0) {
|
||||||
} else {
|
this.activeBatches.set(group, remaining)
|
||||||
this.activeBatches.delete(group)
|
} else {
|
||||||
this.queueGroupNotification(group)
|
this.activeBatches.delete(group)
|
||||||
|
this.queueGroupNotification(group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const totalTime = Date.now() - startTime
|
const totalTime = Date.now() - startTime
|
||||||
|
|||||||
Reference in New Issue
Block a user