mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-28 00:42:08 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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" />,
|
||||
]
|
||||
|
||||
|
||||
@@ -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,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])
|
||||
}
|
||||
@@ -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,2 +1 @@
|
||||
export { hideInitialOverlay } from './initial-loading-overlay'
|
||||
export { handleNoticeMessage } from './notification-handlers'
|
||||
|
||||
+39
-90
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+85
-63
@@ -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',
|
||||
])
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user