From d2180e2d182010deac0c9bc17e8dcc254cef4c36 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:30:08 +0800 Subject: [PATCH] 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. --- .github/workflows/frontend-check.yml | 4 + package.json | 1 + scripts/check-architecture.mjs | 84 ++++++++++ src-tauri/src/core/notify/notification.rs | 6 +- src/components/home/test-card.tsx | 4 +- .../layout/service-migration-dialog.test.ts | 32 ++-- .../layout/service-migration-dialog.tsx | 19 +-- src/components/profile/profile-item.tsx | 21 +-- src/components/proxy/proxy-empty-state.tsx | 6 +- src/components/test/test-item.tsx | 21 ++- src/hooks/use-listen.ts | 15 -- src/hooks/use-profiles.ts | 9 +- src/hooks/use-system-state.ts | 29 ++-- src/hooks/use-visibility.ts | 12 +- src/main.tsx | 9 +- src/pages/_layout.tsx | 6 +- src/pages/_layout/hooks/index.ts | 1 - src/pages/_layout/hooks/use-layout-events.ts | 86 ---------- .../_layout/hooks/use-pending-failures.ts | 91 ++--------- src/pages/_layout/utils/index.ts | 1 - src/pages/profiles.tsx | 129 +++++---------- src/providers/app-data-provider.tsx | 32 +--- .../bus/create-bus-handlers.test.ts} | 148 ++++++++++-------- src/services/bus/create-bus-handlers.ts | 125 +++++++++++++++ src/services/bus/index.ts | 10 ++ src/services/bus/use-event-bus.test.ts | 88 +++++++++++ src/services/bus/use-event-bus.ts | 78 +++++++++ src/services/events.test.ts | 58 ------- src/services/events.ts | 63 -------- .../notice-handlers.ts} | 0 src/services/states.ts | 7 - src/store/app-state.ts | 109 +++++++++++++ src/store/app-store-context.ts | 47 ++++++ src/store/app-store.tsx | 64 ++++++++ 34 files changed, 828 insertions(+), 587 deletions(-) create mode 100644 scripts/check-architecture.mjs delete mode 100644 src/hooks/use-listen.ts delete mode 100644 src/pages/_layout/hooks/use-layout-events.ts rename src/{pages/_layout/hooks/use-layout-events.test.ts => services/bus/create-bus-handlers.test.ts} (56%) create mode 100644 src/services/bus/create-bus-handlers.ts create mode 100644 src/services/bus/index.ts create mode 100644 src/services/bus/use-event-bus.test.ts create mode 100644 src/services/bus/use-event-bus.ts delete mode 100644 src/services/events.test.ts delete mode 100644 src/services/events.ts rename src/{pages/_layout/utils/notification-handlers.ts => services/notice-handlers.ts} (100%) create mode 100644 src/store/app-state.ts create mode 100644 src/store/app-store-context.ts create mode 100644 src/store/app-store.tsx diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 0b59b186e..1d9fa4199 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -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: diff --git a/package.json b/package.json index 1e0862e2b..e97267584 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs new file mode 100644 index 000000000..429f413a6 --- /dev/null +++ b/scripts/check-architecture.mjs @@ -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: [/(? file.startsWith('src/services/bus/'), + description: 'Tauri listen outside the event bus (src/services/bus)', + }, + emit: { + patterns: [/(? 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 ... (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') diff --git a/src-tauri/src/core/notify/notification.rs b/src-tauri/src/core/notify/notification.rs index 98b17f950..73ca60b19 100644 --- a/src-tauri/src/core/notify/notification.rs +++ b/src-tauri/src/core/notify/notification.rs @@ -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", diff --git a/src/components/home/test-card.tsx b/src/components/home/test-card.tsx index 571c648ed..5081c477a 100644 --- a/src/components/home/test-card.tsx +++ b/src/components/home/test-card.tsx @@ -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(() => { diff --git a/src/components/layout/service-migration-dialog.test.ts b/src/components/layout/service-migration-dialog.test.ts index 752fbb069..c05b9b508 100644 --- a/src/components/layout/service-migration-dialog.test.ts +++ b/src/components/layout/service-migration-dialog.test.ts @@ -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[])( '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) + 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[])( '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) }, ) diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 5af87cba9..81411b541 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -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(runStateQueryKey, data) + dispatch({ type: 'runState/loaded', runState: data }) setStateRefreshFailed(false) return data } catch (error) { diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 6c0a0a3cc..6685a81aa 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -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(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 diff --git a/src/components/proxy/proxy-empty-state.tsx b/src/components/proxy/proxy-empty-state.tsx index 03b1b663e..5db4c5c2d 100644 --- a/src/components/proxy/proxy-empty-state.tsx +++ b/src/components/proxy/proxy-empty-state.tsx @@ -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 { diff --git a/src/components/test/test-item.tsx b/src/components/test/test-item.tsx index e014ca542..27e11dcbf 100644 --- a/src/components/test/test-item.tsx +++ b/src/components/test/test-item.tsx @@ -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 ( diff --git a/src/hooks/use-listen.ts b/src/hooks/use-listen.ts deleted file mode 100644 index dcabcc087..000000000 --- a/src/hooks/use-listen.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { listen, EventCallback } from '@tauri-apps/api/event' -import { useCallback } from 'react' - -export const useListen = () => { - const addListener = useCallback( - async (eventName: string, handler: EventCallback) => { - return await listen(eventName, handler) - }, - [], - ) - - return { - addListener, - } -} diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index ece68cd6f..22cee06ce 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -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) diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index a1a0faa59..15aa4ceee 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -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, } } diff --git a/src/hooks/use-visibility.ts b/src/hooks/use-visibility.ts index fc1285bfb..ffadb051c 100644 --- a/src/hooks/use-visibility.ts +++ b/src/hooks/use-visibility.ts @@ -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 () => { diff --git a/src/main.tsx b/src/main.tsx index 01a683f28..5a0f0d76d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 = [ , - , + , , ] diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 646fd9ae5..59625514e 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -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(() => { diff --git a/src/pages/_layout/hooks/index.ts b/src/pages/_layout/hooks/index.ts index 2e6bfebe4..13f1aff3c 100644 --- a/src/pages/_layout/hooks/index.ts +++ b/src/pages/_layout/hooks/index.ts @@ -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' diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts deleted file mode 100644 index ef95262c2..000000000 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ /dev/null @@ -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(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]) -} diff --git a/src/pages/_layout/hooks/use-pending-failures.ts b/src/pages/_layout/hooks/use-pending-failures.ts index 90efbd5d6..5ef98b2d9 100644 --- a/src/pages/_layout/hooks/use-pending-failures.ts +++ b/src/pages/_layout/hooks/use-pending-failures.ts @@ -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([ @@ -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()) - 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(null) - // Dismiss locally without retiring the backend failure. + const failures = usePendingFailureList() const [dismissedSequence, setDismissedSequence] = useState( 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) diff --git a/src/pages/_layout/utils/index.ts b/src/pages/_layout/utils/index.ts index d1b854e3b..5b01eb11b 100644 --- a/src/pages/_layout/utils/index.ts +++ b/src/pages/_layout/utils/index.ts @@ -1,2 +1 @@ export { hideInitialOverlay } from './initial-loading-overlay' -export { handleNoticeMessage } from './notification-handlers' diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 2bd7484d9..5b191a8a1 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -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 - >(() => new Map()) - const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState< - Map - >(() => new Map()) const [batchMode, setBatchMode] = useState(false) const [selectedProfiles, setSelectedProfiles] = useState>( @@ -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 } diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index fe2dfb6bc..731d7ea20 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -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(), diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/services/bus/create-bus-handlers.test.ts similarity index 56% rename from src/pages/_layout/hooks/use-layout-events.test.ts rename to src/services/bus/create-bus-handlers.test.ts index ee8392117..b0927b9ff 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/services/bus/create-bus-handlers.test.ts @@ -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', + ]) +}) diff --git a/src/services/bus/create-bus-handlers.ts b/src/services/bus/create-bus-handlers.ts new file mode 100644 index 000000000..b13279983 --- /dev/null +++ b/src/services/bus/create-bus-handlers.ts @@ -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 } +} diff --git a/src/services/bus/index.ts b/src/services/bus/index.ts new file mode 100644 index 000000000..9648128ff --- /dev/null +++ b/src/services/bus/index.ts @@ -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) +} diff --git a/src/services/bus/use-event-bus.test.ts b/src/services/bus/use-event-bus.test.ts new file mode 100644 index 000000000..4eb964e88 --- /dev/null +++ b/src/services/bus/use-event-bus.test.ts @@ -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() + 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: [], + }) +}) diff --git a/src/services/bus/use-event-bus.ts b/src/services/bus/use-event-bus.ts new file mode 100644 index 000000000..d4797ecfe --- /dev/null +++ b/src/services/bus/use-event-bus.ts @@ -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 + ).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]) +} diff --git a/src/services/events.test.ts b/src/services/events.test.ts deleted file mode 100644 index f14294481..000000000 --- a/src/services/events.test.ts +++ /dev/null @@ -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 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' }) - }) -}) diff --git a/src/services/events.ts b/src/services/events.ts deleted file mode 100644 index 29b884165..000000000 --- a/src/services/events.ts +++ /dev/null @@ -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) - } - } - } -} diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/services/notice-handlers.ts similarity index 100% rename from src/pages/_layout/utils/notification-handlers.ts rename to src/services/notice-handlers.ts diff --git a/src/services/states.ts b/src/services/states.ts index 3770c09aa..b48bcfa3c 100644 --- a/src/services/states.ts +++ b/src/services/states.ts @@ -4,10 +4,6 @@ const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState< 'light' | 'dark' >() -// save the state of each profile item loading -const [LoadingCacheProvider, useLoadingCache, useSetLoadingCache] = - createContextState>(new Set()) - // save update state const [UpdateStateProvider, useUpdateState, useSetUpdateState] = createContextState(false) @@ -16,9 +12,6 @@ export { ThemeModeProvider, useThemeMode, useSetThemeMode, - LoadingCacheProvider, - useLoadingCache, - useSetLoadingCache, UpdateStateProvider, useUpdateState, useSetUpdateState, diff --git a/src/store/app-state.ts b/src/store/app-state.ts new file mode 100644 index 000000000..a6158bd7a --- /dev/null +++ b/src/store/app-state.ts @@ -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 + readonly updateRevisions: ReadonlyMap + readonly timerRevisions: ReadonlyMap +} + +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, + uid: string, +): ReadonlyMap => { + 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 } + } +} diff --git a/src/store/app-store-context.ts b/src/store/app-store-context.ts new file mode 100644 index 000000000..3217d0037 --- /dev/null +++ b/src/store/app-store-context.ts @@ -0,0 +1,47 @@ +import { createContext, useCallback, use } from 'react' + +import type { + AppStoreAction, + AppStoreState, + ProfileUpdatesState, +} from './app-state' + +export const AppStateContext = createContext(null) +export const AppDispatchContext = + createContext(null) + +export type AppStoreActionDispatcher = (action: AppStoreAction) => void + +const useSelector = (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 => + 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) diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx new file mode 100644 index 000000000..c246f3052 --- /dev/null +++ b/src/store/app-store.tsx @@ -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 ( + + {children} + + ) +}