feat(events): route all backend events through a single event bus and store

Adds src/services/bus (the only listen/emit site: one handler per contract
event plus the frontend-internal test-all event) and the React
context/reducer app store. Migrates the five scattered subscriptions
(layout events, pending failures, proxy refresh, profile update/timer,
test-all), deletes the old glue (events.ts, use-listen, use-layout-events,
LoadingCacheProvider), moves run state into the store, and adds an
architecture gate script wired into CI. Behavior is unchanged; the
run-state query's poll/focus safety net is preserved by the store
provider.
This commit is contained in:
Tunglies
2026-09-30 20:39:24 +08:00
parent 3b290e96e3
commit d2180e2d18
34 changed files with 828 additions and 587 deletions
+4
View File
@@ -86,6 +86,10 @@ jobs:
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit
contract-parity:
runs-on: ubuntu-22.04
steps:
+1
View File
@@ -31,6 +31,7 @@
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
+84
View File
@@ -0,0 +1,84 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* Architecture grep gates for the frontend contract flow. Select gates via
* argv:
*
* node scripts/check-architecture.mjs listen emit invoke
*
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
* like `this.emit(` are unrelated class methods) or as importing the name
* from '@tauri-apps/api/event'.
* - invoke: `invoke(` calls may exist only under src/services/**.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const SRC = path.join(ROOT, 'src')
const tauriEventImport = (name) =>
new RegExp(
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
)
const GATES = {
listen: {
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri listen outside the event bus (src/services/bus)',
},
emit: {
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri emit outside the event bus (src/services/bus)',
},
invoke: {
patterns: [/\binvoke\(/],
allowed: (file) => file.startsWith('src/services/'),
description: 'invoke( outside the services layer (src/services)',
},
}
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return walk(full)
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
})
}
const selected = process.argv.slice(2)
if (selected.length === 0) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2)
}
let violations = 0
for (const gate of selected) {
const rule = GATES[gate]
if (!rule) {
console.error(`unknown gate: ${gate}`)
process.exit(2)
}
for (const file of walk(SRC)) {
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
if (rule.allowed(relative)) continue
const lines = fs.readFileSync(file, 'utf8').split('\n')
lines.forEach((line, index) => {
if (rule.patterns.some((pattern) => pattern.test(line))) {
violations += 1
console.error(
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
)
}
})
}
}
if (violations > 0) {
console.error(`[arch] ${violations} violation(s) found`)
process.exit(1)
}
console.log('[arch] no violations')
+1 -5
View File
@@ -347,11 +347,7 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str,
"verge://refresh-verge-config",
json!("yes"),
),
(
FrontendEvent::RefreshProfiles,
"verge://refresh-profiles",
json!("yes"),
),
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
(
FrontendEvent::RefreshProxyConfig,
"verge://refresh-proxy-config",
+2 -2
View File
@@ -6,7 +6,6 @@ import {
import { isSortable } from '@dnd-kit/react/sortable'
import { Add, NetworkCheck } from '@mui/icons-material'
import { Box, IconButton, Tooltip, alpha, styled } from '@mui/material'
import { emit } from '@tauri-apps/api/event'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,6 +19,7 @@ import { SortableItem } from '@/components/base'
import { TestItem } from '@/components/test/test-item'
import { TestViewer, type TestViewerRef } from '@/components/test/test-viewer'
import { useVerge } from '@/hooks/use-verge'
import { emitTestAll } from '@/services/bus'
import { EnhancedCard } from './enhanced-card'
@@ -178,7 +178,7 @@ export const TestCard = () => {
)
const handleTestAll = useCallback(() => {
emit('verge://test-all')
emitTestAll()
}, [])
const handleCreateTest = useCallback(() => {
@@ -8,18 +8,24 @@ import {
type RunState,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useQuery } from '@/services/query-client'
import { ServiceMigrationDialog } from './service-migration-dialog'
const store = vi.hoisted(() => ({
dispatch: vi.fn(),
runState: null as RunState | null,
}))
vi.mock('react', () => ({ useState: vi.fn() }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@mui/material', () => ({ Alert: 'div' }))
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/store/app-store-context', () => ({
useAppDispatch: () => store.dispatch,
useRunState: () => store.runState,
}))
vi.mock('@/services/cmds', () => ({
getRuntimeState: vi.fn(),
installService: vi.fn(),
@@ -28,13 +34,10 @@ vi.mock('@/services/cmds', () => ({
vi.mock('@/services/notice-service', () => ({
showNotice: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/services/query-client', () => ({
useQuery: vi.fn(),
setCacheData: vi.fn(),
}))
beforeEach(() => {
vi.clearAllMocks()
store.runState = null
vi.mocked(useState)
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([false, vi.fn()])
@@ -50,9 +53,7 @@ it.each([
] satisfies Partial<RunState>[])(
'does not offer Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
store.runState = state as unknown as RunState
const dialog = ServiceMigrationDialog()
expect(dialog.props.open).toBe(true)
expect(dialog.props.disableCancel).toBe(true)
@@ -60,9 +61,10 @@ it.each([
)
it('reports the permission refusal and finishes after installation falls back to Sidecar', async () => {
vi.mocked(useQuery).mockReturnValue({
data: { service: 'notInstalled', mode: 'NotRunning' },
} as ReturnType<typeof useQuery>)
store.runState = {
service: 'notInstalled',
mode: 'NotRunning',
} as unknown as RunState
const reason = 'core path C:\\ is writable by Everyone'
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
vi.mocked(getRuntimeState).mockResolvedValue({
@@ -98,9 +100,7 @@ it.each([
] satisfies Partial<RunState>[])(
'offers Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
store.runState = state as unknown as RunState
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
},
)
@@ -3,8 +3,6 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
import { runStateQueryKey } from '@/hooks/use-system-state'
import { useVisibility } from '@/hooks/use-visibility'
import {
continueWithSidecar,
getRuntimeState,
@@ -12,25 +10,18 @@ import {
reinstallService,
repairService,
restartCore,
type RunState,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { setCacheData, useQuery } from '@/services/query-client'
import { useAppDispatch, useRunState } from '@/store/app-store-context'
export const ServiceMigrationDialog = () => {
const { t } = useTranslation()
const pageVisible = useVisibility()
const [loading, setLoading] = useState(false)
const [stateRefreshFailed, setStateRefreshFailed] = useState(false)
const [workflowIncomplete, setWorkflowIncomplete] = useState(false)
const { data: runState } = useQuery({
queryKey: runStateQueryKey,
queryFn: getRuntimeState,
enabled: true,
retry: 1,
refetchInterval: pageVisible ? 30000 : false,
})
const dispatch = useAppDispatch()
const runState = useRunState()
// Whether the service needs a decision is derived once, in Rust, and travels with the
// snapshot; a failed refresh is treated as needing one, since we cannot tell otherwise.
const needsDecision =
@@ -64,11 +55,11 @@ export const ServiceMigrationDialog = () => {
runState.service === 'unavailable')),
)
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
// One store entry to refresh, so there is nothing left to keep coherent by hand.
const refreshRunState = async () => {
try {
const data = await getRuntimeState()
await setCacheData<RunState>(runStateQueryKey, data)
dispatch({ type: 'runState/loaded', runState: data })
setStateRefreshFailed(false)
return data
} catch (error) {
+8 -13
View File
@@ -40,7 +40,10 @@ import {
viewProfile,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
import {
useProfileLoadingCache,
useSetProfileLoading,
} from '@/store/app-store-context'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
@@ -105,8 +108,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [position, setPosition] = useState({ left: 0, top: 0 })
const loadingCache = useLoadingCache()
const setLoadingCache = useSetLoadingCache()
const loadingCache = useProfileLoadingCache()
const setProfileLoading = useSetProfileLoading()
const [showNextUpdate, setShowNextUpdate] = useState(false)
const showNextUpdateRef = useRef(false)
@@ -116,17 +119,9 @@ const ProfileItemBase = (props: ProfileItemProps) => {
)
const setLoading = useCallback(
(loading: boolean) => {
setLoadingCache((cache) => {
const next = new Set(cache)
if (loading) {
next.add(itemData.uid)
} else {
next.delete(itemData.uid)
}
return next
})
setProfileLoading([itemData.uid], loading)
},
[itemData.uid, setLoadingCache],
[itemData.uid, setProfileLoading],
)
const { uid, name = 'Profile', extra, updated = 0, option } = itemData
+3 -3
View File
@@ -4,11 +4,10 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { runStateQueryKey } from '@/hooks/use-system-state'
import { useSystemState } from '@/hooks/use-system-state'
import { useAppRefreshers } from '@/providers/app-data-context'
import { openLogsDir, restartCore } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { revalidateQuery } from '@/services/query-client'
import type { ProxyEmptyStateReason } from './proxy-empty-state-model'
@@ -20,13 +19,14 @@ export const ProxyEmptyState = ({ reason }: Props) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { refreshProxy } = useAppRefreshers()
const { mutateSystemState } = useSystemState()
const [isRestarting, setIsRestarting] = useState(false)
const handleRestart = useLockFn(async () => {
setIsRestarting(true)
try {
await restartCore()
await Promise.all([refreshProxy(), revalidateQuery(runStateQueryKey)])
await Promise.all([refreshProxy(), mutateSystemState()])
} catch (error) {
showNotice.error(error)
} finally {
+15 -6
View File
@@ -1,15 +1,15 @@
import { LanguageRounded } from '@mui/icons-material'
import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseLoading } from '@/components/base'
import { useIconCache } from '@/hooks/use-icon-cache'
import { cmdTestDelay } from '@/services/cmds'
import delayManager from '@/services/delay'
import { subscribeVergeEvents } from '@/services/events'
import { showNotice } from '@/services/notice-service'
import { useTestAllCounter } from '@/store/app-store-context'
import { TestBox } from './test-box'
@@ -52,10 +52,19 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
{ label: t('shared.actions.delete'), handler: onDelete },
]
useEffect(
() => subscribeVergeEvents({ 'verge://test-all': () => onDelay() }),
[url, onDelay],
)
// Store bump replaces the per-item event subscription; skip the initial
// mount value and call through a ref so url changes do not re-trigger tests.
const testAllCounter = useTestAllCounter()
const onDelayRef = useRef(onDelay)
onDelayRef.current = onDelay
const mountedRef = useRef(false)
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true
return
}
void onDelayRef.current()
}, [testAllCounter])
return (
<Box>
-15
View File
@@ -1,15 +0,0 @@
import { listen, EventCallback } from '@tauri-apps/api/event'
import { useCallback } from 'react'
export const useListen = () => {
const addListener = useCallback(
async <T>(eventName: string, handler: EventCallback<T>) => {
return await listen(eventName, handler)
},
[],
)
return {
addListener,
}
}
+1 -8
View File
@@ -1,18 +1,11 @@
import { useCallback, useRef } from 'react'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import {
fetchCacheData,
revalidateQuery,
setCacheData,
useQuery,
} from '@/services/query-client'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug'
const profilesQueryKey = ['getProfiles'] as const
export const revalidateProfiles = () => revalidateQuery(profilesQueryKey)
export const fetchProfilesIntoCache = () =>
fetchCacheData(profilesQueryKey, getProfiles)
+10 -19
View File
@@ -5,10 +5,8 @@ import {
type RunningMode,
} from '@/services/cmds'
import { useQuery } from '@/services/query-client'
import { useAppDispatch, useRunState } from '@/store/app-store-context'
import { useVisibility } from './use-visibility'
export const runStateQueryKey = ['getRuntimeState'] as const
const appUptimeQueryKey = ['appUptime'] as const
/** Fail closed until the first snapshot so TUN never flashes as available. */
@@ -27,20 +25,9 @@ const unknownRunState: RunState = {
/** Event-driven run state; Rust owns all derived availability decisions. */
export function useSystemState() {
const pageVisible = useVisibility()
const {
data: runState = unknownRunState,
refetch: mutateSystemState,
isLoading,
} = useQuery({
queryKey: runStateQueryKey,
queryFn: getRuntimeState,
// A safety net only; transitions normally arrive by event.
refetchInterval: pageVisible ? 30000 : false,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
})
const dispatch = useAppDispatch()
const snapshot = useRunState()
const runState = snapshot ?? unknownRunState
return {
runState,
@@ -50,8 +37,12 @@ export function useSystemState() {
isServiceMode: runState.mode === 'Service',
isTunModeAvailable: runState.tunCapable,
serviceNeedsAttention: runState.serviceNeedsAttention,
mutateSystemState,
isLoading,
mutateSystemState: async () => {
const fresh = await getRuntimeState()
dispatch({ type: 'runState/loaded', runState: fresh })
return fresh
},
isLoading: snapshot == null,
}
}
+4 -8
View File
@@ -1,4 +1,3 @@
import { TauriEvent } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect, useState } from 'react'
@@ -42,13 +41,10 @@ export const useVisibility = () => {
window.addEventListener('focus', handleVisibleEvent)
const unlistenFocusChanged = appWindow.onFocusChanged(updateVisibleSoon)
const unlistenCloseRequested = appWindow.listen(
TauriEvent.WINDOW_CLOSE_REQUESTED,
() => {
setVisible(false)
updateVisibleSoon()
},
)
const unlistenCloseRequested = appWindow.onCloseRequested(() => {
setVisible(false)
updateVisibleSoon()
})
void updateVisible()
return () => {
+3 -6
View File
@@ -20,11 +20,8 @@ import {
getPreloadConfig,
} from './services/preload'
import { swrConfig } from './services/query-client'
import {
LoadingCacheProvider,
ThemeModeProvider,
UpdateStateProvider,
} from './services/states'
import { ThemeModeProvider, UpdateStateProvider } from './services/states'
import { AppStoreProvider } from './store/app-store'
import { disableWebViewShortcuts } from './utils/disable-webview-shortcuts'
if (!window.ResizeObserver) {
@@ -43,7 +40,7 @@ disableWebViewShortcuts()
const initializeApp = (initialThemeMode: 'light' | 'dark') => {
const contexts = [
<ThemeModeProvider key="theme" initialState={initialThemeMode} />,
<LoadingCacheProvider key="loading" />,
<AppStoreProvider key="app-store" />,
<UpdateStateProvider key="update" />,
]
+3 -3
View File
@@ -17,16 +17,16 @@ import {
import { useI18n } from '@/hooks/use-i18n'
import { useVerge } from '@/hooks/use-verge'
import { useWindowDecorations } from '@/hooks/use-window'
import { useEventBus } from '@/services/bus'
import { handleNoticeMessage } from '@/services/notice-handlers'
import { useThemeMode } from '@/services/states'
import getSystem from '@/utils/get-system'
import {
useCustomTheme,
useLayoutEvents,
useLoadingOverlay,
usePendingFailures,
} from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils'
import 'dayjs/locale/ru'
import 'dayjs/locale/zh-cn'
@@ -77,7 +77,7 @@ const Layout = () => {
[t, navigate],
)
useLayoutEvents(handleNotice)
useEventBus(handleNotice)
usePendingFailures()
useEffect(() => {
-1
View File
@@ -1,4 +1,3 @@
export { useLayoutEvents } from './use-layout-events'
export { usePendingFailures, useDialogFailure } from './use-pending-failures'
export { useLoadingOverlay } from './use-loading-overlay'
export { useNavMenuOrder } from './use-nav-menu-order'
@@ -1,86 +0,0 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect } from 'react'
import { revalidateProfiles } from '@/hooks/use-profiles'
import { runStateQueryKey } from '@/hooks/use-system-state'
import type { RunState } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { revalidateQueries, setCacheData } from '@/services/query-client'
import { forgetShownStartupError } from '../utils/notification-handlers'
export const useLayoutEvents = (
handleNotice: (payload: [string, string]) => void,
) => {
useEffect(() => {
let lastProfileId: string | null = null
let lastProfileUpdateTime = 0
const refreshThrottle = 800
const revalidateKeys = (keys: readonly string[]) => {
void revalidateQueries(keys.map((key) => [key]))
}
const handleProfileChanged = (newProfileId: string) => {
const now = Date.now()
if (
lastProfileId === newProfileId &&
now - lastProfileUpdateTime < refreshThrottle
) {
return
}
lastProfileId = newProfileId
lastProfileUpdateTime = now
void revalidateProfiles()
}
const unsubscribe = subscribeVergeEvents(
{
'profile-changed': handleProfileChanged,
'verge://refresh-profiles': () => void revalidateProfiles(),
'verge://refresh-clash-config': () => {
revalidateKeys([
'getProxyView',
'getVersion',
'getClashConfig',
'getClashInfo',
'getClashMode',
'getRuntimeConfig',
'getRules',
'getRuleProviders',
])
},
'verge://refresh-verge-config': () => {
revalidateKeys([
'getVergeConfig',
'getSystemProxy',
'getAutotemProxy',
])
},
// Transitions carry the full run-state snapshot, so write it directly to cache.
'verge://run-state-changed': (payload) => {
void setCacheData<RunState>(runStateQueryKey, payload)
if (payload.mode !== 'NotRunning') forgetShownStartupError()
},
'verge://notice-message': handleNotice,
},
// Re-read event-only state after subscribing to close the initial race window.
() => {
revalidateKeys(['getRuntimeState', 'getVergeConfig'])
handleNotice(['dns_override::auto_disabled', ''])
handleNotice(['enhance::discarded_keys', ''])
handleNotice(['service_core::sidecar_fallback', ''])
handleNotice(['service_core::repair_required', ''])
handleNotice(['service_core::app_data_not_owned', ''])
handleNotice(['core_start::error', ''])
},
)
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
if (payload) handleNotice(['core_start::error', ''])
})
return () => {
unsubscribe()
void unlistenFocus.then((unlisten) => unlisten())
}
}, [handleNotice])
}
+17 -74
View File
@@ -1,9 +1,9 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useCallback, useEffect, useRef, useState } from 'react'
import { getPendingFailures, type PendingFailure } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import type { PendingFailure } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { usePendingFailureList } from '@/store/app-store-context'
/** Failures handled by the recovery dialog instead of a toast. */
const CODES_SHOWN_AS_A_DIALOG = new Set<string>([
@@ -26,95 +26,38 @@ const windowIsWatched = async () => {
}
}
const usePendingFailureReader = (
onFailures: (failures: PendingFailure[]) => void,
) => {
// Avoid resubscribing when the handler changes.
const handlerRef = useRef(onFailures)
handlerRef.current = onFailures
useEffect(() => {
const read = () => {
void (async () => {
let failures: PendingFailure[]
try {
failures = await getPendingFailures()
} catch (error) {
console.warn('[pending-failures] could not be read:', error)
return
}
handlerRef.current(failures)
})()
}
const unsubscribe = subscribeVergeEvents(
{ 'verge://pending-failures-changed': read },
read,
)
const readWhenVisible = () => {
if (document.visibilityState === 'visible') read()
}
document.addEventListener('visibilitychange', readWhenVisible)
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
if (payload) read()
})
return () => {
unsubscribe()
document.removeEventListener('visibilitychange', readWhenVisible)
void unlistenFocus.then((unlisten) => unlisten())
}
}, [])
}
/** Show each pending failure sequence once when the window can be read. */
export const usePendingFailures = () => {
const failures = usePendingFailureList()
const shownSequencesRef = useRef(new Map<string, number>())
const showNewFailures = useCallback(async (failures: PendingFailure[]) => {
useEffect(() => {
const asToast = failures.filter(
(failure) => !CODES_SHOWN_AS_A_DIALOG.has(failure.code),
)
if (asToast.length === 0) return
if (!(await windowIsWatched())) return
for (const failure of asToast) {
const shown = shownSequencesRef.current.get(failure.code)
if (shown !== undefined && shown >= failure.sequence) continue
shownSequencesRef.current.set(failure.code, failure.sequence)
showNotice.error({ code: failure.code, detail: failure.detail })
}
}, [])
usePendingFailureReader(
useCallback(
(failures) => {
void showNewFailures(failures)
},
[showNewFailures],
),
)
void windowIsWatched().then((watched) => {
if (!watched) return
for (const failure of asToast) {
const shown = shownSequencesRef.current.get(failure.code)
if (shown !== undefined && shown >= failure.sequence) continue
shownSequencesRef.current.set(failure.code, failure.sequence)
showNotice.error({ code: failure.code, detail: failure.detail })
}
})
}, [failures])
}
/** Return the oldest undismissed failure owned by the dialog. */
export const useDialogFailure = () => {
const [failure, setFailure] = useState<PendingFailure | null>(null)
// Dismiss locally without retiring the backend failure.
const failures = usePendingFailureList()
const [dismissedSequence, setDismissedSequence] = useState<number | null>(
null,
)
usePendingFailureReader(
useCallback((failures) => {
setFailure(
failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ??
null,
)
}, []),
)
const failure: PendingFailure | null =
failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ?? null
const dismiss = useCallback(() => {
setDismissedSequence(failure?.sequence ?? null)
-1
View File
@@ -1,2 +1 @@
export { hideInitialOverlay } from './initial-loading-overlay'
export { handleNoticeMessage } from './notification-handlers'
+39 -90
View File
@@ -17,7 +17,7 @@ import {
TextSnippetOutlined,
} from '@mui/icons-material'
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
import { TauriEvent } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { readText } from '@tauri-apps/plugin-clipboard-manager'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { useLockFn } from 'ahooks'
@@ -39,7 +39,6 @@ import {
type ProfileViewerRef,
} from '@/components/profile/profile-viewer'
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
import { useListen } from '@/hooks/use-listen'
import { fetchProfilesIntoCache, useProfiles } from '@/hooks/use-profiles'
import {
createProfile,
@@ -50,14 +49,14 @@ import {
reorderProfile,
updateProfile,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { errorDetail, showNotice } from '@/services/notice-service'
import { revalidateQuery, useQuery } from '@/services/query-client'
import { useThemeMode } from '@/services/states'
import {
useLoadingCache,
useSetLoadingCache,
useThemeMode,
} from '@/services/states'
useProfileLoadingCache,
useProfileUpdates,
useSetProfileLoading,
} from '@/store/app-store-context'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
@@ -82,7 +81,6 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
const ProfilePage = () => {
const { t } = useTranslation()
const location = useLocation()
const { addListener } = useListen()
const [url, setUrl] = useState('')
const [disabled, setDisabled] = useState(false)
const [profileDndRevision, setProfileDndRevision] = useState(0)
@@ -92,12 +90,6 @@ const ProfilePage = () => {
string | null
>(null)
const [loading, setLoading] = useState(false)
const [timerUpdateRevisions, setTimerUpdateRevisions] = useState<
Map<string, number>
>(() => new Map())
const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState<
Map<string, number>
>(() => new Map())
const [batchMode, setBatchMode] = useState(false)
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(
@@ -128,44 +120,36 @@ const ProfilePage = () => {
}, [profiles])
useEffect(() => {
const handleFileDrop = async () => {
const unlisten = await addListener(
TauriEvent.DRAG_DROP,
async (event: any) => {
const paths = event.payload.paths
const unlisten = getCurrentWindow().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') return
const paths = event.payload.paths
for (const file of paths) {
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
showNotice.error('profiles.page.feedback.errors.onlyYaml')
continue
}
const item = {
type: 'local',
name: file.split(/\/|\\/).pop() ?? 'New Profile',
desc: '',
url: '',
option: {
with_proxy: false,
self_proxy: false,
},
} as IProfileItem
const data = await readTextFile(file)
await createProfile(item, data)
await mutateProfiles()
}
await enhanceProfiles()
},
)
return unlisten
}
const unsubscribe = handleFileDrop()
for (const file of paths) {
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
showNotice.error('profiles.page.feedback.errors.onlyYaml')
continue
}
const item = {
type: 'local',
name: file.split(/\/|\\/).pop() ?? 'New Profile',
desc: '',
url: '',
option: {
with_proxy: false,
self_proxy: false,
},
} as IProfileItem
const data = await readTextFile(file)
await createProfile(item, data)
await mutateProfiles()
}
await enhanceProfiles()
})
return () => {
unsubscribe.then((cleanup) => cleanup())
void unlisten.then((cleanup) => cleanup())
}
}, [addListener, mutateProfiles])
}, [mutateProfiles])
const onEmergencyRefresh = useLockFn(async () => {
debugLog('[紧急刷新] 开始强制刷新所有数据')
@@ -513,50 +497,15 @@ const ProfilePage = () => {
}
})
const loadingCache = useLoadingCache()
const setLoadingCache = useSetLoadingCache()
const loadingCache = useProfileLoadingCache()
const { updateRevisions: completedUpdateRevisions, timerRevisions } =
useProfileUpdates()
const setStoreLoadingProfiles = useSetProfileLoading()
const setLoadingProfiles = useCallback(
(uids: string[], loading: boolean) => {
setLoadingCache((cache) => {
const next = new Set(cache)
for (const uid of uids) {
if (loading) {
next.add(uid)
} else {
next.delete(uid)
}
}
return next
})
setStoreLoadingProfiles(uids, loading)
},
[setLoadingCache],
)
useEffect(
() =>
subscribeVergeEvents({
'profile-update-started': ({ uid }) => {
if (uid) setLoadingProfiles([uid], true)
},
'profile-update-completed': ({ uid }) => {
if (!uid) return
setLoadingProfiles([uid], false)
setCompletedUpdateRevisions((current) => {
const next = new Map(current)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
})
void mutateProfiles()
},
'verge://timer-updated': (uid) => {
setTimerUpdateRevisions((current) => {
const next = new Map(current)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
})
},
}),
[mutateProfiles, setLoadingProfiles],
[setStoreLoadingProfiles],
)
const runProfileUpdates = useCallback(
@@ -924,7 +873,7 @@ const ProfilePage = () => {
visibleSwitchingProfile === item.uid
}
itemData={item}
timerUpdateRevision={timerUpdateRevisions.get(item.uid) ?? 0}
timerUpdateRevision={timerRevisions.get(item.uid) ?? 0}
completedUpdateRevision={
completedUpdateRevisions.get(item.uid) ?? 0
}
+6 -26
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,11 +6,10 @@ import {
} from 'tauri-plugin-mihomo-api'
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
import { runStateQueryKey } from '@/hooks/use-system-state'
import { useVerge } from '@/hooks/use-verge'
import { getProxyView, getRuntimeState, getSystemProxy } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { getProxyView, getSystemProxy } from '@/services/cmds'
import { useQuery } from '@/services/query-client'
import { useRunState } from '@/store/app-store-context'
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
import {
@@ -95,12 +94,9 @@ export const AppDataProvider = ({
...TQ_DEFAULTS,
})
// Same key as `useSystemState`, so this is the one Run State cache entry, not a second one.
const { data: runState, isPending: isRunningModePending } = useQuery({
queryKey: runStateQueryKey,
queryFn: getRuntimeState,
...TQ_DEFAULTS,
})
// Store-owned run state; the event bus and the store's safety net write it.
const runState = useRunState()
const isRunningModePending = runState == null
const runningMode = runState?.mode
const refreshProxy = useStableFn(_refetchProxyView)
@@ -109,22 +105,6 @@ export const AppDataProvider = ({
const refreshSysproxy = useStableFn(_refetchSysproxy)
const refreshRuleProviders = useStableFn(_refetchRuleProviders)
useEffect(() => {
let lastProxyUpdateTime = 0
const refreshThrottle = 800
const handleRefreshProxy = () => {
const now = Date.now()
if (now - lastProxyUpdateTime <= refreshThrottle) return
lastProxyUpdateTime = now
refreshProxy().catch(() => {})
}
return subscribeVergeEvents({
'verge://refresh-proxy-config': handleRefreshProxy,
})
}, [refreshProxy])
const refreshAll = useCallback(async () => {
await Promise.all([
refreshProxy(),
@@ -6,29 +6,21 @@ import {
takeDnsOverrideNotice,
takeServiceRepairNotice,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { showNotice } from '@/services/notice-service'
import { revalidateQueries } from '@/services/query-client'
import { requestService } from '@/services/service-request'
import { useLayoutEvents } from './use-layout-events'
let handleNoticeMessage: typeof import('../utils/notification-handlers').handleNoticeMessage
import type { AppStoreAction } from '@/store/app-state'
const nativeWindow = vi.hoisted(() => ({
isVisible: vi.fn().mockResolvedValue(true),
isMinimized: vi.fn().mockResolvedValue(false),
onFocusChanged: vi.fn().mockResolvedValue(() => {}),
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => nativeWindow,
}))
vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() }))
vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() }))
vi.mock('@/services/cmds', () => ({
getCoreStartupError: vi.fn().mockResolvedValue(null),
getPendingFailures: vi.fn().mockResolvedValue([]),
takeDiscardedKeysNotice: vi.fn().mockResolvedValue(null),
takeDnsOverrideNotice: vi.fn().mockResolvedValue(false),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
@@ -41,12 +33,33 @@ vi.mock('@/services/notice-service', () => ({
}))
vi.mock('@/services/query-client', () => ({
revalidateQueries: vi.fn(),
setCacheData: vi.fn(),
}))
beforeEach(async () => {
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
// Everything (bus + notice handlers) comes from one fresh module registry per
// test: startup-error dedup state is module-scoped in notice-handlers.
const mountBus = async () => {
vi.resetModules()
;({ handleNoticeMessage } = await import('../utils/notification-handlers'))
const [{ createEventBus }, { handleNoticeMessage: notice }] =
await Promise.all([
import('./create-bus-handlers'),
import('@/services/notice-handlers'),
])
const dispatch = vi.fn<(action: AppStoreAction) => void>()
return createEventBus({
dispatch,
handleNotice: ([status, message]: [string, string]) => {
notice(status, message, (key) => key, vi.fn())
},
revalidateKeys: (keys) => void revalidateQueries(keys.map((key) => [key])),
revalidateProfiles: () => {},
refreshProxyView: () => {},
readPendingFailures: () => {},
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getCoreStartupError).mockResolvedValue(null)
nativeWindow.isVisible.mockResolvedValue(true)
@@ -54,19 +67,22 @@ beforeEach(async () => {
})
it('does not replay a recovered startup error after WebView recreation', async () => {
const first = await import('@/services/notice-handlers')
vi.mocked(getCoreStartupError).mockResolvedValue({
kind: 'startFailed',
detail: 'startup failed',
})
handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await new Promise((resolve) => setTimeout(resolve, 0))
first.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
// Mocked modules share one instance across resetModules; the notice-handlers
// module state is what a WebView recreation actually resets.
vi.mocked(getCoreStartupError).mockResolvedValue(null)
vi.resetModules()
const recreated = await import('../utils/notification-handlers')
const recreated = await import('@/services/notice-handlers')
recreated.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
})
@@ -83,20 +99,15 @@ it.each([
kind: 'startFailed',
detail,
})
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers, onSubscribed] =
vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
const bus = await mountBus()
bus.onSubscribed()
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
expect(showNotice.error).not.toHaveBeenCalled()
nativeWindow.isVisible.mockResolvedValue(true)
nativeWindow.isMinimized.mockResolvedValue(false)
const onFocus = nativeWindow.onFocusChanged.mock.calls[0][0]
onFocus({ payload: true })
await new Promise((resolve) => setTimeout(resolve, 0))
bus.onWindowFocus()
await flush()
expect(showNotice.error).toHaveBeenCalledExactlyOnceWith(
'settings.feedback.errors.clash.startFailed',
detail,
@@ -110,13 +121,10 @@ it('delivers an early core startup failure once after listeners mount', async ()
kind: 'startFailed',
detail,
})
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
const bus = await mountBus()
bus.onSubscribed()
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
expect(showNotice.error).toHaveBeenCalledExactlyOnceWith(
'settings.feedback.errors.clash.startFailed',
detail,
@@ -124,20 +132,16 @@ it('delivers an early core startup failure once after listeners mount', async ()
})
it('shows the same core failure again after the core has started in between', async () => {
const { useLayoutEvents } = await import('./use-layout-events')
const failure = { kind: 'serviceCoreStopped', detail: 'stopped' } as const
vi.mocked(getCoreStartupError).mockResolvedValue(failure)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers] = vi.mocked(subscribeVergeEvents).mock.calls[0]
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
const bus = await mountBus()
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
// The core started and stopped again before the window handled either event.
handlers['verge://run-state-changed']?.({ mode: 'Service' } as RunState)
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
bus.handlers['verge://run-state-changed']({ mode: 'Service' } as RunState)
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
expect(showNotice.error).toHaveBeenCalledTimes(2)
})
@@ -146,24 +150,18 @@ it('drains a DNS notice after listeners mount without duplicating its live event
.mockResolvedValueOnce(true)
.mockResolvedValue(false)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const bus = await mountBus()
expect(takeDnsOverrideNotice).not.toHaveBeenCalled()
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
bus.onSubscribed()
expect(takeDnsOverrideNotice).toHaveBeenCalledOnce()
handlers['verge://notice-message']?.(['dns_override::auto_disabled', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
bus.handlers['verge://notice-message'](['dns_override::auto_disabled', ''])
await flush()
expect(showNotice.info).toHaveBeenCalledExactlyOnceWith(
'settings.modals.dns.protection.autoDisabled',
)
expect(revalidateQueries).toHaveBeenCalledWith([
['getRuntimeState'],
['getVergeConfig'],
])
expect(revalidateQueries).toHaveBeenCalledWith([['getVergeConfig']])
})
it('offers service reinstallation for a startup path refusal even before listeners mount', async () => {
@@ -171,16 +169,40 @@ it('offers service reinstallation for a startup path refusal even before listene
.mockResolvedValueOnce(true)
.mockResolvedValue(false)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const bus = await mountBus()
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['service_core::repair_required', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
bus.onSubscribed()
bus.handlers['verge://notice-message'](['service_core::repair_required', ''])
await flush()
expect(requestService).toHaveBeenCalledExactlyOnceWith({
reason: 'serviceLocationRefused',
})
})
it('routes every event exactly once through one handler', async () => {
const { createEventBus } = await import('./create-bus-handlers')
const dispatch = vi.fn<(action: AppStoreAction) => void>()
const bus = createEventBus({
dispatch,
handleNotice: vi.fn(),
revalidateKeys: vi.fn(),
revalidateProfiles: vi.fn(),
refreshProxyView: vi.fn(),
readPendingFailures: vi.fn(),
})
bus.handlers['verge://run-state-changed']({ mode: 'Service' } as RunState)
bus.handlers['profile-update-started']({ uid: 'p1' })
bus.handlers['profile-update-completed']({ uid: 'p1' })
bus.handlers['verge://timer-updated']('p1')
bus.handlers['verge://test-all'](null)
expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([
'runState/loaded',
'profileUpdate/started',
'profileUpdate/completed',
'profileUpdate/timerTick',
'testAll/requested',
])
})
+125
View File
@@ -0,0 +1,125 @@
import type { VergeEventPayloads } from '@/services/contract'
import { forgetShownStartupError } from '@/services/notice-handlers'
import type { AppStoreAction } from '@/store/app-state'
/** Frontend-internal event: home test card → test items. Not in the backend contract. */
export const TEST_ALL_EVENT = 'verge://test-all'
type BusEventName = keyof VergeEventPayloads | typeof TEST_ALL_EVENT
type BusHandlers = {
[Name in BusEventName]: (
payload: Name extends keyof VergeEventPayloads
? VergeEventPayloads[Name]
: null,
) => void
}
export interface BusHandlerDeps {
dispatch: (action: AppStoreAction) => void
/** Notice-table entry point; layout owns t/navigate wiring. */
handleNotice: (payload: [string, string]) => void
revalidateKeys: (keys: readonly string[]) => void
revalidateProfiles: () => void
refreshProxyView: () => void
/** Re-read the backend pending-failure snapshot into the store. */
readPendingFailures: () => void
}
export interface EventBus {
handlers: BusHandlers
/** Re-read event-only state after listeners are live (initial race window). */
onSubscribed: () => void
/** The window regained focus: replay a possibly recovered startup error. */
onWindowFocus: () => void
}
/**
* Pure per-event handler table for the bus. Handlers may trigger documented
* side effects (SWR revalidation, notice toasts) but write store state only
* through dispatched pure actions. Created exactly once per bus mount; the
* throttles below live in the returned closure, mirroring the previous
* scattered listeners.
*/
export const createEventBus = (deps: BusHandlerDeps): EventBus => {
let lastProfileId: string | null = null
let lastProfileChangeTime = 0
let lastProxyRefreshTime = 0
const refreshThrottle = 800
const handleProfileChanged = (newProfileId: string) => {
const now = Date.now()
if (
lastProfileId === newProfileId &&
now - lastProfileChangeTime < refreshThrottle
) {
return
}
lastProfileId = newProfileId
lastProfileChangeTime = now
deps.revalidateProfiles()
}
const handleRefreshProxyConfig = () => {
const now = Date.now()
if (now - lastProxyRefreshTime <= refreshThrottle) return
lastProxyRefreshTime = now
deps.refreshProxyView()
}
const handlers: BusHandlers = {
'verge://refresh-clash-config': () =>
deps.revalidateKeys([
'getProxyView',
'getVersion',
'getClashConfig',
'getClashInfo',
'getClashMode',
'getRuntimeConfig',
'getRules',
'getRuleProviders',
]),
'verge://refresh-verge-config': () =>
deps.revalidateKeys([
'getVergeConfig',
'getSystemProxy',
'getAutotemProxy',
]),
'verge://refresh-profiles': () => deps.revalidateProfiles(),
'verge://refresh-proxy-config': handleRefreshProxyConfig,
'verge://notice-message': (payload) => deps.handleNotice(payload),
'profile-changed': handleProfileChanged,
'verge://timer-updated': (uid) =>
deps.dispatch({ type: 'profileUpdate/timerTick', uid }),
'profile-update-started': ({ uid }) =>
deps.dispatch({ type: 'profileUpdate/started', uid }),
'profile-update-completed': ({ uid }) => {
deps.dispatch({ type: 'profileUpdate/completed', uid })
deps.revalidateProfiles()
},
'verge://run-state-changed': (payload) => {
deps.dispatch({ type: 'runState/loaded', runState: payload })
// A running core resolves every failure shown before it.
if (payload.mode !== 'NotRunning') forgetShownStartupError()
},
'verge://pending-failures-changed': () => deps.readPendingFailures(),
[TEST_ALL_EVENT]: () => deps.dispatch({ type: 'testAll/requested' }),
} as BusHandlers
const onSubscribed = () => {
deps.revalidateKeys(['getVergeConfig'])
deps.readPendingFailures()
deps.handleNotice(['dns_override::auto_disabled', ''])
deps.handleNotice(['enhance::discarded_keys', ''])
deps.handleNotice(['service_core::sidecar_fallback', ''])
deps.handleNotice(['service_core::repair_required', ''])
deps.handleNotice(['service_core::app_data_not_owned', ''])
deps.handleNotice(['core_start::error', ''])
}
const onWindowFocus = () => {
deps.handleNotice(['core_start::error', ''])
}
return { handlers, onSubscribed, onWindowFocus }
}
+10
View File
@@ -0,0 +1,10 @@
import { emit } from '@tauri-apps/api/event'
import { TEST_ALL_EVENT } from './create-bus-handlers'
export { useEventBus } from './use-event-bus'
/** The only emitter of the frontend-internal test-all event. */
export const emitTestAll = () => {
void emit(TEST_ALL_EVENT)
}
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { useEventBus } from './use-event-bus'
const listen = vi.hoisted(() =>
vi.fn<
(name: string, handler: (event: unknown) => void) => Promise<() => void>
>(() => Promise.resolve(() => {})),
)
const handleNotice = vi.hoisted(() => vi.fn())
const dispatch = vi.hoisted(() => vi.fn())
vi.mock('@tauri-apps/api/event', () => ({ listen }))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
onFocusChanged: vi.fn(() => Promise.resolve(() => {})),
}),
}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
useEffect: (effect: () => void | (() => void)) => effect(),
}
})
vi.mock('@/store/app-store-context', () => ({
useAppDispatch: () => dispatch,
}))
vi.mock('@/services/cmds', () => ({
getPendingFailures: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => {
listen.mockClear()
handleNotice.mockClear()
dispatch.mockClear()
listen.mockImplementation(() => Promise.resolve(() => {}))
})
it('registers exactly one listener per contract event plus test-all', () => {
useEventBus(handleNotice)
const names = listen.mock.calls.map(([name]) => name)
expect(names).toEqual([
'verge://refresh-clash-config',
'verge://refresh-verge-config',
'verge://refresh-profiles',
'verge://refresh-proxy-config',
'verge://notice-message',
'profile-changed',
'verge://timer-updated',
'profile-update-started',
'profile-update-completed',
'verge://run-state-changed',
'verge://pending-failures-changed',
'verge://test-all',
])
expect(new Set(names).size).toBe(names.length)
})
it('drains event-only state only after every listener is registered', async () => {
const registrations: Array<() => void> = []
listen.mockImplementation(
() =>
new Promise((resolve) => {
registrations.push(() => resolve(() => {}))
}),
)
useEventBus(handleNotice)
await flush()
expect(handleNotice).not.toHaveBeenCalled()
for (const resolve of registrations.slice(0, -1)) resolve()
await flush()
expect(handleNotice).not.toHaveBeenCalled()
registrations.at(-1)?.()
await flush()
expect(handleNotice).toHaveBeenCalled()
expect(dispatch).toHaveBeenCalledWith({
type: 'pendingFailures/loaded',
failures: [],
})
})
+78
View File
@@ -0,0 +1,78 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect } from 'react'
import { getPendingFailures } from '@/services/cmds'
import { revalidateQueries } from '@/services/query-client'
import { useAppDispatch } from '@/store/app-store-context'
import { createEventBus } from './create-bus-handlers'
/**
* Mounts the event bus exactly once (layout root): one `listen` per contract
* event plus the frontend-internal test-all event. Replaces every scattered
* subscription; teardown disposes late-resolving registrations.
*/
export const useEventBus = (
handleNotice: (payload: [string, string]) => void,
) => {
const dispatch = useAppDispatch()
useEffect(() => {
const revalidateKeys = (keys: readonly string[]) => {
void revalidateQueries(keys.map((key) => [key]))
}
const readPendingFailures = () => {
getPendingFailures()
.then((failures) =>
dispatch({ type: 'pendingFailures/loaded', failures }),
)
.catch((error) => {
console.warn('[bus] pending failures could not be read:', error)
})
}
const bus = createEventBus({
dispatch,
handleNotice,
revalidateKeys,
revalidateProfiles: () => {
void revalidateQueries([['getProfiles']])
},
refreshProxyView: () => revalidateKeys(['getProxyView']),
readPendingFailures,
})
let disposed = false
const unlisteners: UnlistenFn[] = []
const registrations = (
Object.keys(bus.handlers) as Array<keyof typeof bus.handlers>
).map((name) =>
listen(name, ({ payload }) => {
;(bus.handlers[name] as (payload: unknown) => void)(payload)
}).then((unlisten) => {
if (disposed) {
unlisten()
return
}
unlisteners.push(unlisten)
}),
)
// Re-read event-only state after subscribing to close the initial race window.
void Promise.all(registrations).then(() => {
if (disposed) return
bus.onSubscribed()
})
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
if (payload) bus.onWindowFocus()
})
return () => {
disposed = true
for (const unlisten of unlisteners.splice(0)) unlisten()
void unlistenFocus.then((unlisten) => unlisten())
}
}, [dispatch, handleNotice])
}
-58
View File
@@ -1,58 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { subscribeVergeEvents } from './events'
const listen = vi.hoisted(() => vi.fn())
vi.mock('@tauri-apps/api/event', () => ({ listen }))
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => {
listen.mockReset()
})
describe('subscribeVergeEvents', () => {
it('reports readiness only after every listener is registered', async () => {
const registrations: Array<() => void> = []
listen.mockImplementation(
() =>
new Promise((resolve) => {
registrations.push(() => resolve(() => {}))
}),
)
const onSubscribed = vi.fn()
subscribeVergeEvents(
{
'verge://run-state-changed': () => {},
'verge://notice-message': () => {},
},
onSubscribed,
)
registrations[0]?.()
await flush()
expect(onSubscribed).not.toHaveBeenCalled()
registrations[1]?.()
await flush()
expect(onSubscribed).toHaveBeenCalledOnce()
})
it('delivers a payload to its matching handler', () => {
const handlers = new Map<string, (payload: unknown) => void>()
listen.mockImplementation(
(name: string, handler: (event: unknown) => void) => {
handlers.set(name, (payload) => handler({ payload }))
return Promise.resolve(() => {})
},
)
const onRunState = vi.fn()
subscribeVergeEvents({ 'verge://run-state-changed': onRunState })
handlers.get('verge://run-state-changed')?.({ mode: 'Sidecar' })
expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' })
})
})
-63
View File
@@ -1,63 +0,0 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { VergeEventPayloads } from './contract'
/** Generated wire contract plus the frontend-internal test-all event. */
interface VergeEvents extends VergeEventPayloads {
'verge://test-all': null
}
type VergeEventName = keyof VergeEvents
type VergeEventHandlers = {
[Name in VergeEventName]?: (payload: VergeEvents[Name]) => void
}
/**
* Returns synchronous teardown for asynchronous registrations. `onSubscribed` lets callers
* reread event-only state after all listeners are live, closing the initial race window.
*/
export const subscribeVergeEvents = (
handlers: VergeEventHandlers,
onSubscribed?: () => void,
): (() => void) => {
let disposed = false
const unlisteners: UnlistenFn[] = []
const registrations = Object.entries(handlers).map(([name, handler]) => {
if (!handler) return Promise.resolve()
return listen(name, ({ payload }) => {
;(handler as (payload: unknown) => void)(payload)
})
.then((unlisten) => {
// Resolved after teardown: unsubscribe immediately rather than leak.
if (disposed) {
unlisten()
return
}
unlisteners.push(unlisten)
})
.catch((error) => {
console.error(`[events] failed to subscribe to ${name}:`, error)
})
})
if (onSubscribed) {
void Promise.all(registrations).then(() => {
if (disposed) return
onSubscribed()
})
}
return () => {
disposed = true
for (const unlisten of unlisteners.splice(0)) {
try {
unlisten()
} catch (error) {
console.error('[events] teardown failed:', error)
}
}
}
}
-7
View File
@@ -4,10 +4,6 @@ const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState<
'light' | 'dark'
>()
// save the state of each profile item loading
const [LoadingCacheProvider, useLoadingCache, useSetLoadingCache] =
createContextState<Set<string>>(new Set())
// save update state
const [UpdateStateProvider, useUpdateState, useSetUpdateState] =
createContextState<boolean>(false)
@@ -16,9 +12,6 @@ export {
ThemeModeProvider,
useThemeMode,
useSetThemeMode,
LoadingCacheProvider,
useLoadingCache,
useSetLoadingCache,
UpdateStateProvider,
useUpdateState,
useSetUpdateState,
+109
View File
@@ -0,0 +1,109 @@
import type { PendingFailure, RunState } from '@/services/cmds'
/** Event-derived per-profile update tracking, shared by the profiles page and items. */
export interface ProfileUpdatesState {
readonly loading: ReadonlySet<string>
readonly updateRevisions: ReadonlyMap<string, number>
readonly timerRevisions: ReadonlyMap<string, number>
}
export interface AppStoreState {
/** Written by `run-state-changed` events and the store's safety-net poll. */
readonly runState: RunState | null
/** Latest backend pending-failure snapshot; toasts/dialogs dedupe on sequence. */
readonly pendingFailures: readonly PendingFailure[]
readonly profileUpdates: ProfileUpdatesState
/** Bumped once per test-all request; test items run their delay test per bump. */
readonly testAllCounter: number
}
export const initialAppStoreState: AppStoreState = {
runState: null,
pendingFailures: [],
profileUpdates: {
loading: new Set(),
updateRevisions: new Map(),
timerRevisions: new Map(),
},
testAllCounter: 0,
}
export type AppStoreAction =
| { type: 'runState/loaded'; runState: RunState }
| { type: 'pendingFailures/loaded'; failures: readonly PendingFailure[] }
| { type: 'profileUpdate/started'; uid: string }
| { type: 'profileUpdate/completed'; uid: string }
| { type: 'profileUpdate/timerTick'; uid: string }
| { type: 'profileLoading/set'; uids: readonly string[]; loading: boolean }
| { type: 'testAll/requested' }
const bumpRevision = (
map: ReadonlyMap<string, number>,
uid: string,
): ReadonlyMap<string, number> => {
const next = new Map(map)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
}
const withLoading = (
state: AppStoreState,
uids: readonly string[],
loading: boolean,
): AppStoreState => {
const next = new Set(state.profileUpdates.loading)
for (const uid of uids) {
if (loading) {
next.add(uid)
} else {
next.delete(uid)
}
}
return {
...state,
profileUpdates: { ...state.profileUpdates, loading: next },
}
}
export function appStoreReducer(
state: AppStoreState,
action: AppStoreAction,
): AppStoreState {
switch (action.type) {
case 'runState/loaded':
return { ...state, runState: action.runState }
case 'pendingFailures/loaded':
return { ...state, pendingFailures: action.failures }
case 'profileUpdate/started':
if (state.profileUpdates.loading.has(action.uid)) return state
return withLoading(state, [action.uid], true)
case 'profileUpdate/completed': {
const cleared = withLoading(state, [action.uid], false)
return {
...cleared,
profileUpdates: {
...cleared.profileUpdates,
updateRevisions: bumpRevision(
state.profileUpdates.updateRevisions,
action.uid,
),
},
}
}
case 'profileUpdate/timerTick':
return {
...state,
profileUpdates: {
...state.profileUpdates,
timerRevisions: bumpRevision(
state.profileUpdates.timerRevisions,
action.uid,
),
},
}
case 'profileLoading/set':
return withLoading(state, action.uids, action.loading)
case 'testAll/requested':
return { ...state, testAllCounter: state.testAllCounter + 1 }
}
}
+47
View File
@@ -0,0 +1,47 @@
import { createContext, useCallback, use } from 'react'
import type {
AppStoreAction,
AppStoreState,
ProfileUpdatesState,
} from './app-state'
export const AppStateContext = createContext<AppStoreState | null>(null)
export const AppDispatchContext =
createContext<AppStoreActionDispatcher | null>(null)
export type AppStoreActionDispatcher = (action: AppStoreAction) => void
const useSelector = <T>(select: (state: AppStoreState) => T): T => {
const state = use(AppStateContext)
if (!state) throw new Error('app store is missing its provider')
return select(state)
}
export const useAppDispatch = (): AppStoreActionDispatcher => {
const dispatch = use(AppDispatchContext)
if (!dispatch) throw new Error('app store is missing its provider')
return dispatch
}
export const useRunState = () => useSelector((s) => s.runState)
export const usePendingFailureList = () => useSelector((s) => s.pendingFailures)
export const useProfileUpdates = (): ProfileUpdatesState =>
useSelector((s) => s.profileUpdates)
export const useProfileLoadingCache = (): ReadonlySet<string> =>
useSelector((s) => s.profileUpdates.loading)
/** Mark profiles as updating outside backend events (update-all, per-item buttons). */
export const useSetProfileLoading = () => {
const dispatch = useAppDispatch()
return useCallback(
(uids: readonly string[], loading: boolean) =>
dispatch({ type: 'profileLoading/set', uids, loading }),
[dispatch],
)
}
export const useTestAllCounter = () => useSelector((s) => s.testAllCounter)
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useReducer } from 'react'
import type { ReactNode } from 'react'
import { getPendingFailures, getRuntimeState } from '@/services/cmds'
import { appStoreReducer, initialAppStoreState } from './app-state'
import { AppDispatchContext, AppStateContext } from './app-store-context'
/**
* Single app store (React context + reducer, per the frozen design). Event-bus
* handlers write through pure reducer actions; the effect below is the store's
* own data seeding (initial read plus the poll/focus safety net the old
* run-state query provided).
*/
export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState)
useEffect(() => {
const readRunState = () => {
getRuntimeState()
.then((runState) => dispatch({ type: 'runState/loaded', runState }))
.catch(() => {})
}
const readPendingFailures = () => {
getPendingFailures()
.then((failures) =>
dispatch({ type: 'pendingFailures/loaded', failures }),
)
.catch((error) => {
console.warn('[app-store] pending failures could not be read:', error)
})
}
readRunState()
readPendingFailures()
// Transitions normally arrive by event; the visible poll is the run-state
// safety net, and regaining focus re-reads both snapshots.
const pollRunState = () => {
if (document.visibilityState !== 'visible') return
readRunState()
}
const readWhenFocused = () => {
if (document.visibilityState !== 'visible') return
readRunState()
readPendingFailures()
}
window.addEventListener('focus', readWhenFocused)
document.addEventListener('visibilitychange', readWhenFocused)
const timer = window.setInterval(pollRunState, 30000)
return () => {
window.removeEventListener('focus', readWhenFocused)
document.removeEventListener('visibilitychange', readWhenFocused)
window.clearInterval(timer)
}
}, [])
return (
<AppStateContext value={state}>
<AppDispatchContext value={dispatch}>{children}</AppDispatchContext>
</AppStateContext>
)
}