Compare commits

..
Author SHA1 Message Date
renovate[bot] 14c594d107 chore(deps): update github/gh-aw-actions action to v1.2.2 2026-11-10 05:18:55 +00:00
7 changed files with 57 additions and 164 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@406c7719e31d7fe354224d4bb8fe3d4aeb47da1c # v1.2.1
uses: github/gh-aw-actions/setup-cli@53d0749e19424a918acd82b4d140264ce48f2f14 # v1.2.2
with:
version: v1.0.1
- name: Install Rust Stable
-1
View File
@@ -29,7 +29,6 @@
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
**🍎 macOS**
+6 -2
View File
@@ -48,7 +48,10 @@ export function useProxyDelayState(
useEffect(() => {
if (isPreset) return
return delayManager.addListener(name, groupName, setDelayState)
delayManager.setListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(name, groupName)
}
}, [name, groupName, isPreset])
const updateDelay = useCallback(() => {
@@ -87,7 +90,8 @@ export function useProxyDelayState(
const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return
await delayManager.checkDelay(member, groupName, timeout)
setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
})
return {
+1 -5
View File
@@ -49,11 +49,7 @@ const ProxyPage = () => {
>(null)
const { clashConfig } = useClashConfigData()
const { refreshClashConfig, refreshProxy } = useAppRefreshers()
useEffect(() => {
refreshProxy().catch((error) => showNotice.error(error))
}, [refreshProxy])
const { refreshClashConfig } = useAppRefreshers()
const normalizedMode = clashConfig?.mode?.toLowerCase()
const curMode = isMode(normalizedMode) ? normalizedMode : undefined
+1 -11
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import {
getBaseConfig,
getRuleProviders,
@@ -6,10 +6,8 @@ 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'
@@ -55,8 +53,6 @@ export const AppDataProvider = ({
const { verge } = useVerge()
const { data: runtimeConfig } = useRuntimeConfig()
const { clashInfo } = useClashInfo()
const { profiles } = useProfiles()
const currentProfile = profiles?.current
const {
data: proxyView,
@@ -106,12 +102,6 @@ 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)
+16 -63
View File
@@ -5,32 +5,26 @@ vi.mock('tauri-plugin-mihomo-api', () => ({
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
}))
import type { InteractableProxyMember } from '@/types/proxy-view'
import type { ResolvedProxyMember } from '@/types/proxy-view'
import delayManager from './delay'
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,
},
})
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))
let settles = 0
let unsubscribe: () => void
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => {
settles = 0
@@ -45,7 +39,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, 'g', 5000, 2)
await delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
await flush()
expect(settles).toBe(1)
@@ -57,7 +51,7 @@ describe('group delay completion', () => {
other += 1
})
await delayManager.checkDelay(node('a'), 'g', 5000)
await delayManager.checkDelay(node('a') as never, 'g', 5000)
await flush()
expect(settles).toBe(1)
@@ -65,44 +59,3 @@ 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()
})
})
+32 -81
View File
@@ -29,12 +29,10 @@ 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, Set<(update: DelayUpdate) => void>>()
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
private groupListenerMap = new Map<string, Set<() => void>>()
// Consumers compare snapshot identity; replace it only when the group settles.
@@ -51,35 +49,6 @@ 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') {
@@ -105,19 +74,17 @@ class DelayManager {
this.pendingItemUpdates = new Map()
updates.forEach((queue, key) => {
const listeners = this.listenerMap.get(key)
if (!listeners) return
const listener = this.listenerMap.get(key)
if (!listener) return
queue.forEach((update) => {
for (const listener of [...listeners]) {
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
}
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
}
})
})
@@ -203,22 +170,18 @@ class DelayManager {
return url || 'http://cp.cloudflare.com/generate_204'
}
addListener(
setListener(
name: string,
group: string,
listener: (update: DelayUpdate) => void,
): () => void {
) {
const key = hashKey(name, group)
const listeners = this.listenerMap.get(key) ?? new Set()
listeners.add(listener)
this.listenerMap.set(key, listeners)
this.listenerMap.set(key, listener)
}
return () => {
const current = this.listenerMap.get(key)
if (!current) return
current.delete(listener)
if (current.size === 0) this.listenerMap.delete(key)
}
removeListener(name: string, group: string) {
const key = hashKey(name, group)
this.listenerMap.delete(key)
}
/** Multiple views may subscribe independently; notifications occur only after settle. */
@@ -313,21 +276,19 @@ class DelayManager {
member: InteractableProxyMember,
group: string,
timeout: number,
): Promise<void> {
const generation = this.generation
): Promise<DelayUpdate> {
// Entry points own the pending mark; measureDelay only writes results.
this.setDelay(member.ref.name, group, -2)
await this.measureDelay(member, group, timeout, generation)
if (generation === this.generation) this.queueGroupNotification(group)
const update = await this.measureDelay(member, group, timeout)
this.queueGroupNotification(group)
return update
}
private async measureDelay(
member: InteractableProxyMember,
group: string,
timeout: number,
generation: number,
): Promise<void> {
if (generation !== this.generation) return
): Promise<DelayUpdate> {
const name = member.ref.name
const providerName =
member.kind === 'node' ? providerNameOf(member.node) : undefined
@@ -363,18 +324,14 @@ class DelayManager {
const elapsed = elapsedTime
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
if (generation === this.generation) {
this.setDelay(name, group, delay, { elapsed })
}
return 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
if (generation === this.generation) {
this.setDelay(name, group, delay, { elapsed })
}
return this.setDelay(name, group, delay, { elapsed })
}
}
@@ -384,7 +341,6 @@ class DelayManager {
timeout: number,
concurrency = 36,
) {
const generation = this.generation
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
@@ -398,7 +354,6 @@ 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
@@ -412,15 +367,13 @@ class DelayManager {
}
// Do not reorder a list around the pointer while batch results are arriving.
await this.measureDelay(currMember, group, timeout, generation)
await this.measureDelay(currMember, group, timeout)
} catch (error) {
console.error(
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
)
if (generation === this.generation) {
this.setDelay(currName, group, 1e6)
}
this.setDelay(currName, group, 1e6)
}
return help()
@@ -438,14 +391,12 @@ class DelayManager {
await Promise.all(promiseList)
} finally {
// Always release the batch and notify; otherwise failures leave stale sort state.
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 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