mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-03 21:52:11 +08:00
* docs(agents): add frontend liveness constraints Add six liveness rules to the frontend guardrails covering dead state, unused exposed surface, unreachable fallback paths, duplicate side effects per interaction, unreachable mounts and unbounded resource lifecycles. These target defect classes that the existing CI gates (knip, eslint, arch:check) cannot detect mechanically. * refactor(frontend): remove dead states, refs and unreachable branches Drop state that can only hold its initial value (profiles page disabled flag, traffic graph currentFPS), a write-only window controls ref with its unconsumed imperative handle, a no-op appendData ref method, an unreachable gradient branch, an invisible fadeIn shell, constant-true subexpressions in the system info card, an empty click handler and a pointless useMemo. * refactor(frontend): drop unused params, props and api surface Remove the never-read _revalidate argument of mutateVerge and its 16 call-site placeholders, seven never-passed props of the split chip editor, the unconsumed refreshDecorated context member, the now-orphaned toggleFullscreen control, the unused translate argument of the notice message handler, the reserved-but-unset ServiceConfig.timeout field and unreachable branches in parseNoticeExtras. * refactor(types): remove dead provider view types superseded by bindings Drop the handwritten IProxyProviderItem and IRuleProviderItem interfaces: IProxyProviderItem is a stale duplicate of the generated ProxyProviderView contract, and neither has any references left in the tree. Keep IProxyItem and IProxyGroupItem: IConfigData still declares 'proxy-groups'?: IProxyGroupItem[], which builds on IProxyItem. * fix(rules): refresh once per visibility trigger The mount effect fired refreshRules and refreshRuleProviders unconditionally and again under the page-visible flag, double-firing both queries on mount and on every hide-to-show transition. Gate the refresh on visibility once so each trigger performs exactly one revalidation. * fix(setting): write auto launch patch once per toggle GuardState applies the optimistic onChange write before onGuard runs, so repeating the same onChangeData patch inside the guard handler duplicated every cache write and subscriber notification for the auto launch switch. * fix(setting): build viewer form defaults from a single source sysproxy, misc and tunnels viewers duplicated their form default literals in useState and open(), and the two copies in misc-viewer had already diverged (app log max size, app log max count, auto log clean). Extract a buildInitialValues factory per viewer and unify on the open() values, which are the only ones users could ever see. * fix(setting): mount only reachable proxy control viewers ProxyControlSwitches unconditionally mounted both SysproxyViewer and TunViewer while its switch rows are mutually exclusive, so every instance carried a dialog that could never open, each firing getEmbeddedServerPort or a clash query subscription on mount. Mount each viewer under the same condition as the switch row that opens it. SettingSystem also mounted its own SysproxyViewer and TunViewer whose refs were never opened there; the reachable entries are the ProxyControlSwitches rows, so the dead pair is removed. Viewer mounts across the app drop from eight (setting-system: two own + two per instance, proxy-tun-card: two) to three (one per rendered switch variant). * fix(delay): write pending state once per node in batch checks checkListDelay pre-marked every node as checking, help() re-marked it when picked up and measureDelay marked it a third time on entry, so each batch run queued three identical pending updates per node. Keep the pre-mark (immediate feedback for queued nodes) and drop the redundant marks along the chain. * fix(services): make retries viable and align polling freshness window Share no abort signal across retry attempts: after a timeout abort the old signal stays dead and every later fetch rejects immediately, so retries only burned backoff time. Also drop the system state query's staleTime: the wrapper maps it to SWR's dedupingInterval, which swallowed every other 3s poll tick while it exceeded the interval. * refactor(traffic): drop unreachable inline monitor fallback The inline traffic monitor duplicated the worker's message loop, snapshot throttling and aging logic but only runs when Worker is undefined or its constructor throws, which no supported Tauri webview or browser hits. Keep the worker as the single implementation and remove the mode switch and the pending-message queue that could only ever buffer the init message. * refactor(shared): replace the traffic error boundary with ErrorBoundary TrafficErrorBoundary maintained a second boundary system beside BaseErrorBoundary: a report builder that only console.logged constants in the Tauri webview, an onError that re-logged what componentDidCatch already logged, and a fallback wrapper with a single consumer. Fold both mount points into react-error-boundary (the primitive behind BaseErrorBoundary) with local fallbacks that keep the panel styling, message text and retry reset, log each error once, and drop the retry attempt cap and the stack-details collapse along with it. * refactor(hooks): drop the write-only error channel and key-rotating refresh The mihomo ws subscription's next callback accepted an error half that baseNext silently discarded while traffic, memory and log consumers kept constructing fallback payloads for it. Refresh minted a fresh SWR key per call via a localStorage timestamp, abandoning old keys in the cache forever. Reduce next to its data half and reset the snapshot under a stable key instead. * refactor(hooks): trim dead surface from proxy selection Drop the connection-cleanup option nobody ever disabled (behavior now unconditional), the returned changeProxy no consumer called and the two one-caller argument adapters; both call sites use the shared select entry directly. * refactor(services): drop the bespoke retry engine from the query wrapper The custom onErrorRetry existed only to honor a function-form retryDelay, which exactly one caller (the app data provider) used for exponential backoff. Fall back to SWR's built-in error retry and express that caller's cadence with constant options.
239 lines
6.9 KiB
TypeScript
239 lines
6.9 KiB
TypeScript
import {
|
|
DeleteForeverRounded,
|
|
PauseCircleOutlineRounded,
|
|
PlayCircleOutlineRounded,
|
|
SettingsRounded,
|
|
WarningRounded,
|
|
} from '@mui/icons-material'
|
|
import { Box, Typography, alpha, useTheme } from '@mui/material'
|
|
import { useLockFn } from 'ahooks'
|
|
import { useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import { type DialogRef, Switch, TooltipIcon } from '@/components/base'
|
|
import { SysproxyViewer } from '@/components/setting/mods/sysproxy-viewer'
|
|
import { TunViewer } from '@/components/setting/mods/tun-viewer'
|
|
import { useServiceUninstaller } from '@/hooks/use-service-uninstaller'
|
|
import { useSystemProxyState } from '@/hooks/use-system-proxy-state'
|
|
import { useSystemState } from '@/hooks/use-system-state'
|
|
import { useVerge } from '@/hooks/use-verge'
|
|
import { showNotice } from '@/services/notice-service'
|
|
import { requestService } from '@/services/service-request'
|
|
|
|
interface ProxySwitchProps {
|
|
label?: string
|
|
onError?: (err: Error) => void
|
|
noRightPadding?: boolean
|
|
}
|
|
|
|
interface SwitchRowProps {
|
|
label: string
|
|
active: boolean
|
|
disabled?: boolean
|
|
infoTitle: string
|
|
onInfoClick?: () => void
|
|
extraIcons?: React.ReactNode
|
|
/** Return false to roll back without reporting an error. */
|
|
onToggle: (value: boolean) => Promise<boolean | void>
|
|
onError?: (err: Error) => void
|
|
highlight?: boolean
|
|
}
|
|
|
|
/**
|
|
* 抽取的子组件:统一的开关 UI
|
|
* active = 真实状态OS/配置 乐观更新
|
|
*/
|
|
const SwitchRow = ({
|
|
label,
|
|
active,
|
|
disabled,
|
|
infoTitle,
|
|
onInfoClick,
|
|
extraIcons,
|
|
onToggle,
|
|
onError,
|
|
highlight,
|
|
}: SwitchRowProps) => {
|
|
const theme = useTheme()
|
|
const [checked, setChecked] = useState(active)
|
|
const pendingRef = useRef(false)
|
|
|
|
if (pendingRef.current) {
|
|
if (active === checked) pendingRef.current = false
|
|
} else if (checked !== active) {
|
|
setChecked(active)
|
|
}
|
|
|
|
const handleChange = (_: React.ChangeEvent, value: boolean) => {
|
|
pendingRef.current = true
|
|
setChecked(value)
|
|
onToggle(value)
|
|
.then((applied) => {
|
|
if (applied === false) setChecked(active)
|
|
})
|
|
.catch((err: any) => {
|
|
setChecked(active)
|
|
onError?.(err)
|
|
})
|
|
.finally(() => {
|
|
pendingRef.current = false
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
p: 1,
|
|
pr: 2,
|
|
borderRadius: 1.5,
|
|
bgcolor: highlight
|
|
? alpha(theme.palette.success.main, 0.07)
|
|
: 'transparent',
|
|
opacity: disabled ? 0.6 : 1,
|
|
transition: 'background-color 0.3s',
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
{active ? (
|
|
<PlayCircleOutlineRounded sx={{ color: 'success.main', mr: 1 }} />
|
|
) : (
|
|
<PauseCircleOutlineRounded sx={{ color: 'text.disabled', mr: 1 }} />
|
|
)}
|
|
<Typography
|
|
variant="subtitle1"
|
|
sx={{ fontWeight: 500, fontSize: '15px' }}
|
|
>
|
|
{label}
|
|
</Typography>
|
|
<TooltipIcon
|
|
title={infoTitle}
|
|
icon={SettingsRounded}
|
|
onClick={onInfoClick}
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
{extraIcons}
|
|
</Box>
|
|
|
|
<Switch
|
|
edge="end"
|
|
disabled={disabled}
|
|
checked={checked}
|
|
onChange={handleChange}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const ProxyControlSwitches = ({
|
|
label,
|
|
onError,
|
|
noRightPadding = false,
|
|
}: ProxySwitchProps) => {
|
|
const { t } = useTranslation()
|
|
const { verge, mutateVerge, patchVerge } = useVerge()
|
|
const { uninstallServiceAndStartSidecar } = useServiceUninstaller()
|
|
const { indicator: systemProxyIndicator, toggleSystemProxy } =
|
|
useSystemProxyState()
|
|
const { runState, isTunModeAvailable, isLoading } = useSystemState()
|
|
// Offer to uninstall only a service that is actually there and working.
|
|
const isServiceInstallReady = runState.serviceUsable
|
|
|
|
const sysproxyRef = useRef<DialogRef>(null)
|
|
const tunRef = useRef<DialogRef>(null)
|
|
|
|
const { enable_tun_mode } = verge ?? {}
|
|
|
|
// Enabling needs a running core; disabling only writes OS state and must stay available.
|
|
const handleSystemProxyToggle = async (value: boolean) => {
|
|
if (value && !isLoading && runState.mode === 'NotRunning') {
|
|
showNotice.error('settings.feedback.errors.sysproxy.coreNotReady')
|
|
return false
|
|
}
|
|
await toggleSystemProxy(value)
|
|
}
|
|
|
|
const handleTunToggle = async (value: boolean) => {
|
|
if (value && !isTunModeAvailable) {
|
|
requestService({
|
|
reason: 'tunNeedsService',
|
|
restore: { enable_tun_mode: value },
|
|
})
|
|
return false
|
|
}
|
|
mutateVerge({ ...verge, enable_tun_mode: value })
|
|
await patchVerge({ enable_tun_mode: value })
|
|
}
|
|
|
|
const onUninstallService = useLockFn(async () => {
|
|
try {
|
|
await uninstallServiceAndStartSidecar()
|
|
} catch (err) {
|
|
showNotice.error(err)
|
|
}
|
|
})
|
|
|
|
const isSystemProxyMode =
|
|
label === t('settings.sections.system.toggles.systemProxy') || !label
|
|
const isTunMode = label === t('settings.sections.system.toggles.tunMode')
|
|
|
|
return (
|
|
<Box sx={{ width: '100%', pr: noRightPadding ? 1 : 2 }}>
|
|
{isSystemProxyMode && (
|
|
<SwitchRow
|
|
label={t('settings.sections.proxyControl.fields.systemProxy')}
|
|
active={systemProxyIndicator}
|
|
infoTitle={t('settings.sections.proxyControl.tooltips.systemProxy')}
|
|
onInfoClick={() => sysproxyRef.current?.open()}
|
|
onToggle={handleSystemProxyToggle}
|
|
onError={onError}
|
|
highlight={systemProxyIndicator}
|
|
/>
|
|
)}
|
|
|
|
{isTunMode && (
|
|
<SwitchRow
|
|
label={t('settings.sections.proxyControl.fields.tunMode')}
|
|
active={(enable_tun_mode && isTunModeAvailable) || false}
|
|
infoTitle={t('settings.sections.proxyControl.tooltips.tunMode')}
|
|
onInfoClick={() => tunRef.current?.open()}
|
|
onToggle={handleTunToggle}
|
|
onError={onError}
|
|
highlight={(enable_tun_mode && isTunModeAvailable) || false}
|
|
extraIcons={
|
|
<>
|
|
{!isTunModeAvailable && (
|
|
<TooltipIcon
|
|
title={t(
|
|
'settings.sections.proxyControl.tooltips.tunUnavailable',
|
|
)}
|
|
icon={WarningRounded}
|
|
sx={{ color: 'warning.main', ml: 1 }}
|
|
/>
|
|
)}
|
|
{isServiceInstallReady && (
|
|
<TooltipIcon
|
|
title={t(
|
|
'settings.sections.proxyControl.actions.uninstallService',
|
|
)}
|
|
icon={DeleteForeverRounded}
|
|
color="secondary"
|
|
onClick={onUninstallService}
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{isSystemProxyMode && <SysproxyViewer ref={sysproxyRef} />}
|
|
{isTunMode && <TunViewer ref={tunRef} />}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export default ProxyControlSwitches
|