Compare commits

..
Author SHA1 Message Date
Tunglies 62666b5e27 fix(proxies): derive mounted delays from shared snapshots
Remove mirrored item delay state so profile cache invalidation cannot leave reused rows blank until remount. Read current node history when the shared cache is empty and preserve snapshot identity for React subscriptions.
2026-11-11 05:47:22 +08:00
Tunglies 2db9da5d06 test(proxies): narrow delay regressions to reported failures
Remove global fake timers and deferred request scaffolding. Keep only profile-switch cache isolation and shared-key listener cleanup regressions.
2026-11-10 12:15:39 +08:00
Tunglies 371ca6e907 test(proxies): keep focused delay regression coverage
Reduce added delay coverage to profile-switch isolation and shared-key subscriptions. Append the changelog entry after existing general fixes.
2026-11-10 05:58:17 +08:00
Tunglies b02fb26c0c fix(proxies): retain independent delay listeners
Notify every mounted occurrence sharing a delay key and scope cleanup to its registered listener. Cover pending results, profile reset, and either occurrence unmounting without restoring direct unfenced updates.
2026-11-10 05:55:28 +08:00
Tunglies a2d875cebd fix(proxies): invalidate delays when switching profiles
Clear cached delays and fence old measurements when the active profile changes. Refresh proxy history on page entry without forcing automatic measurements or expanding groups.
2026-11-10 05:50:55 +08:00
33 changed files with 574 additions and 1072 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "26.13.1",
"version": "26.13.0",
"pnpmVersion": "12.18.0"
},
"ghcr.io/devcontainers/features/rust:1": {
+3 -3
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v1.2.2": {
"github/gh-aw-actions/setup@v1.0.1": {
"repo": "github/gh-aw-actions/setup",
"version": "v1.2.2",
"sha": "53d0749e19424a918acd82b4d140264ce48f2f14"
"version": "v1.0.1",
"sha": "221ba4f4319de6b8e809d1a15528b3ae6a818aa5"
}
},
"containers": {
+5 -5
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -182,7 +182,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
@@ -262,7 +262,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
@@ -395,7 +395,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
@@ -479,7 +479,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6.1.1
name: Install pnpm
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
+3 -3
View File
@@ -31,9 +31,9 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@53d0749e19424a918acd82b4d140264ce48f2f14 # v1.2.2
uses: github/gh-aw-actions/setup-cli@406c7719e31d7fe354224d4bb8fe3d4aeb47da1c # v1.2.1
with:
version: v1.2.2
version: v1.0.1
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
with:
@@ -49,7 +49,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Install frontend dependencies
env:
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
File diff suppressed because one or more lines are too long
-1
View File
@@ -33,7 +33,6 @@ max-daily-ai-credits: 5000
permissions:
issues: read
pull-requests: read
copilot-requests: none
pre-agent-steps:
- name: Make Copilot quota exhaustion non-blocking
+107 -288
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -30,7 +30,6 @@ permissions:
contents: read
issues: read
pull-requests: read
copilot-requests: none
steps:
- name: Load review rules from the base revision
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
if: github.event.action != 'closed'
+8 -8
View File
@@ -46,7 +46,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- name: Validate source and publish the approved snapshot
id: source
env:
@@ -231,7 +231,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
@@ -330,7 +330,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
@@ -461,7 +461,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
@@ -542,7 +542,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
with:
run_install: false
@@ -565,7 +565,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -593,7 +593,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -661,7 +661,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
File diff suppressed because one or more lines are too long
@@ -28,7 +28,6 @@ permissions:
contents: read
issues: read
pull-requests: read
copilot-requests: none
max-ai-credits: 60
max-turns: 12
max-daily-ai-credits: 500
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 26.13.1
nodejs 26.13.0
pnpm 12.18.0
rust 1.99.1
Generated
+4 -4
View File
@@ -159,9 +159,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.5"
version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5"
checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92"
dependencies = [
"rustversion",
]
@@ -10126,9 +10126,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.5.0"
version = "9.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700e6c427aff64acdbf02e85fbfd946c2ee12319d1af013092f3a2178914ff8"
checksum = "df3845f734baabdd02de353a20580e30ceccd03897def44a4eb8e9654992e294"
dependencies = [
"aes",
"bzip2",
+1 -3
View File
@@ -24,13 +24,12 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复应用退出时无条件重写 `clash.yaml`、`verge.yaml` 和 `profiles.yaml` 导致文件创建时间丢失的问题
- 修复检测更新后又发布新版时,应用内更新失败
- 修复订阅响应过大导致内存占用过高,限制单次下载为 4 MiB
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
**🍎 macOS**
@@ -42,7 +41,6 @@
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增连接页面按网络协议(TCP/UDP)筛选连接
</details>
+4 -8
View File
@@ -74,16 +74,12 @@ impl<T: Clone> Draft<T> {
result
}
/// Commits and clears the draft, reporting whether one was staged.
/// Commits and clears the draft.
#[inline]
pub fn apply(&self) -> bool {
pub fn apply(&self) {
let mut guard = self.inner.data.lock();
match guard.1.take() {
Some(d) => {
guard.0 = d;
true
}
None => false,
if let Some(d) = guard.1.take() {
guard.0 = d;
}
}
+2 -2
View File
@@ -67,7 +67,7 @@
"dayjs": "1.11.23",
"foxact": "^0.3.12",
"foxts": "^5.9.1",
"i18next": "^27.0.0",
"i18next": "^26.4.2",
"js-yaml": "^5.4.2",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.31",
@@ -79,7 +79,7 @@
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "18.0.1",
"react-i18next": "17.1.13",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
+21 -18
View File
@@ -239,8 +239,8 @@ importers:
specifier: ^5.9.1
version: 5.9.1
i18next:
specifier: ^27.0.0
version: 27.0.1(typescript@6.0.3)
specifier: ^26.4.2
version: 26.4.2(typescript@6.0.3)
js-yaml:
specifier: ^5.4.2
version: 5.4.2
@@ -275,8 +275,8 @@ importers:
specifier: ^7.88.0
version: 7.88.0(react@19.3.1)
react-i18next:
specifier: 18.0.1
version: 18.0.1(@types/react@19.3.0)(i18next@27.0.1(typescript@6.0.3))(react@19.3.1)(typescript@6.0.3)
specifier: 17.1.13
version: 17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
react-markdown:
specifier: 10.1.0
version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0)
@@ -3689,9 +3689,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
i18next@27.0.1:
resolution: {integrity: sha512-KaE5ozP8UUeVpOJIwMVbbnpRKpyYQuxd9ZbseJoDzezE3nvLnZZQn20pkzyIJwPI2Uet/Qs3WzPTG812f6F/Eg==}
engines: {node: '>=22'}
i18next@26.4.2:
resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==}
peerDependencies:
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
@@ -4397,16 +4396,18 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@18.0.1:
resolution: {integrity: sha512-kPWXI0E/lrPj3+/umcKNzYZVnWmEedLiamMzDIHfbs+5ANf6yzvlZsjKou8f7kaGkfMMDLIZBejV+4aSN1CyBw==}
engines: {node: '>=22'}
react-i18next@17.1.13:
resolution: {integrity: sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==}
peerDependencies:
'@types/react': '*'
i18next: '>= 27.0.1'
react: '>= 18.0.0'
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
'@types/react':
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
@@ -8532,7 +8533,7 @@ snapshots:
husky@9.1.7: {}
i18next@27.0.1(typescript@6.0.3):
i18next@26.4.2(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
@@ -9356,13 +9357,15 @@ snapshots:
dependencies:
react: 19.3.1
react-i18next@18.0.1(@types/react@19.3.0)(i18next@27.0.1(typescript@6.0.3))(react@19.3.1)(typescript@6.0.3):
react-i18next@17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 4.0.1
i18next: 27.0.1(typescript@6.0.3)
i18next: 26.4.2(typescript@6.0.3)
react: 19.3.1
use-sync-external-store: 1.7.0(react@19.3.1)
optionalDependencies:
'@types/react': 19.3.0
react-dom: 19.3.1(react@19.3.1)
typescript: 6.0.3
react-is@16.13.1: {}
+2 -2
View File
@@ -77,7 +77,7 @@ tauri-plugin-dialog = "2.8.4"
tauri-plugin-fs = "2.7.1"
tauri-plugin-deep-link = "2.6.3"
tauri-plugin-window-state = "2.5.0"
zip = "9.5.0"
zip = "9.4.2"
flate2 = "1.1.11"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
@@ -97,7 +97,7 @@ async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
"client",
] }
arc-swap = "1.9.5"
arc-swap = "1.9.4"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.11"
+6 -10
View File
@@ -280,29 +280,25 @@ impl Config {
}
/// Commits drafts during exit/restart/shutdown so user changes are not lost.
/// A config with nothing staged keeps its file, so its timestamps stay put.
pub async fn apply_all_and_save_file() {
logging!(debug, Type::Config, "save all draft data");
let save_clash_task = AsyncHandler::spawn(|| async {
let clash = Self::clash().await;
if clash.apply() {
logging_error!(Type::Config, clash.data_arc().save_config().await);
}
clash.apply();
logging_error!(Type::Config, clash.data_arc().save_config().await);
});
let save_verge_task = AsyncHandler::spawn(|| async {
let verge = Self::verge().await;
if verge.apply() {
logging_error!(Type::Config, verge.data_arc().save_file().await);
}
verge.apply();
logging_error!(Type::Config, verge.data_arc().save_file().await);
});
let save_profiles_task = AsyncHandler::spawn(|| async {
let _profile_write = super::profiles::PROFILE_WRITE_LOCK.lock().await;
let profiles = Self::profiles().await;
if profiles.apply() {
logging_error!(Type::Config, profiles.data_arc().save_file().await);
}
profiles.apply();
logging_error!(Type::Config, profiles.data_arc().save_file().await);
});
let _ = tokio::join!(save_clash_task, save_verge_task, save_profiles_task);
+25 -51
View File
@@ -1,5 +1,5 @@
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer } from 'react'
import { useCallback, useSyncExternalStore } from 'react'
import { useVerge } from '@/hooks/use-verge'
import delayManager, { type DelayUpdate } from '@/services/delay'
@@ -17,13 +17,6 @@ const PRESET_PROXY_NAMES = [
'COMPATIBLE',
]
const identity = (previous: DelayUpdate, next: DelayUpdate): DelayUpdate =>
previous.delay === next.delay &&
previous.elapsed === next.elapsed &&
previous.updatedAt === next.updatedAt
? previous
: next
const INITIAL_DELAY: DelayUpdate = { delay: -1, updatedAt: 0 }
export interface UseProxyDelayState {
@@ -42,56 +35,37 @@ export function useProxyDelayState(
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved'
const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name)
const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY)
const { verge } = useVerge()
const timeout = verge?.default_latency_timeout || 10000
useEffect(() => {
if (isPreset) return
delayManager.setListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(name, groupName)
}
}, [name, groupName, isPreset])
const subscribe = useCallback(
(onChange: () => void) =>
isPreset ? () => {} : delayManager.addListener(name, groupName, onChange),
[name, groupName, isPreset],
)
const read = useCallback(
() =>
unresolved ? undefined : delayManager.getDelayUpdate(name, groupName),
[name, groupName, unresolved],
)
const cachedUpdate = useSyncExternalStore(subscribe, read, read)
const updateDelay = useCallback(() => {
if (unresolved) {
setDelayState(INITIAL_DELAY)
return
}
const cachedUpdate = delayManager.getDelayUpdate(name, groupName)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate })
return
}
const fallbackDelay = delayManager.getDelayFix(member, groupName)
if (fallbackDelay === -1) {
setDelayState({ delay: -1, updatedAt: 0 })
return
}
let updatedAt = 0
const history = details?.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
if (!Number.isNaN(parsed)) {
updatedAt = parsed
}
}
setDelayState({ delay: fallbackDelay, updatedAt })
}, [details?.history, groupName, member, name, unresolved])
useEffect(() => {
updateDelay()
}, [updateDelay])
const fallbackDelay = delayManager.getDelayFix(member, groupName)
const history = details?.history
const lastRecord = history?.[history.length - 1]
const parsed = lastRecord ? Date.parse(lastRecord.time) : NaN
const delayState =
cachedUpdate ??
(fallbackDelay === -1
? INITIAL_DELAY
: {
delay: fallbackDelay,
updatedAt: Number.isNaN(parsed) ? 0 : parsed,
})
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 {
+5 -42
View File
@@ -71,10 +71,6 @@ const ORDER_OPTIONS = [
type OrderKey = (typeof ORDER_OPTIONS)[number]['id']
type NetworkFilter = 'all' | 'tcp' | 'udp'
let savedNetworkFilter: NetworkFilter = 'all'
const orderFunctionMap = ORDER_OPTIONS.reduce<Record<OrderKey, OrderFunc>>(
(acc, option) => {
acc[option.id] = option.fn
@@ -90,9 +86,6 @@ const ConnectionsPage = () => {
const { searchState, match, onSearch } = usePageSearch('connections')
const hasSearch = searchState.text.length > 0
const [curOrderOpt, setCurOrderOpt] = useState<OrderKey>('default')
const [networkFilter, setNetworkFilter] =
useState<NetworkFilter>(savedNetworkFilter)
const hasFilter = networkFilter !== 'all'
const [connectionsType, setConnectionsType] = useState<'active' | 'closed'>(
'active',
)
@@ -119,30 +112,18 @@ const ConnectionsPage = () => {
const filterConn = useMemo(() => {
const orderFunc = orderFunctionMap[curOrderOpt]
if (isTableLayout && !hasSearch && !hasFilter) return selectedConnections
if (!hasSearch && !hasFilter) return orderFunc([...selectedConnections])
if (isTableLayout && !hasSearch) return selectedConnections
if (!hasSearch) return orderFunc([...selectedConnections])
const matchConns = selectedConnections.filter((conn) => {
const { host, destinationIP, process, network } = conn.metadata
if (networkFilter !== 'all' && network !== networkFilter) return false
const { host, destinationIP, process } = conn.metadata
return (
!hasSearch ||
match(host || '') ||
match(destinationIP || '') ||
match(process || '')
match(host || '') || match(destinationIP || '') || match(process || '')
)
})
return orderFunc ? orderFunc(matchConns) : matchConns
}, [
selectedConnections,
isTableLayout,
hasSearch,
hasFilter,
networkFilter,
match,
curOrderOpt,
])
}, [selectedConnections, isTableLayout, hasSearch, match, curOrderOpt])
const displayRows = useConnectionRowViews(
isTableLayout ? EMPTY_CONNECTIONS : filterConn,
@@ -274,24 +255,6 @@ const ConnectionsPage = () => {
))}
</BaseStyledSelect>
)}
<BaseStyledSelect
style={{ width: 80 }}
value={networkFilter}
onChange={(e) => {
savedNetworkFilter = e.target.value as NetworkFilter
setNetworkFilter(savedNetworkFilter)
}}
>
<MenuItem value="all">
<span style={{ fontSize: 14 }}>All</span>
</MenuItem>
<MenuItem value="tcp">
<span style={{ fontSize: 14 }}>TCP</span>
</MenuItem>
<MenuItem value="udp">
<span style={{ fontSize: 14 }}>UDP</span>
</MenuItem>
</BaseStyledSelect>
<Box
sx={{
flex: 1,
+5 -1
View File
@@ -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
+11 -1
View File
@@ -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)
+66 -16
View File
@@ -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,47 @@ 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)
const pending = delayManager.getDelayUpdate('a', 'g')
expect(delayManager.getDelayUpdate('a', 'g')).toBe(pending)
await flush()
stopFirst()
delayManager.setDelay('a', 'g', 120)
expect(delayManager.getDelayUpdate('a', 'g')).not.toBe(pending)
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()
})
})
+83 -33
View File
@@ -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. */
@@ -237,7 +274,8 @@ class DelayManager {
return undefined
}
return { ...entry }
// React's external-store reads must retain identity until the value changes.
return entry
}
getDelay(name: string, group: string) {
@@ -276,19 +314,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 +364,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 +385,7 @@ class DelayManager {
timeout: number,
concurrency = 36,
) {
const generation = this.generation
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
@@ -354,6 +399,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 +413,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 +439,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