Files
clash-verge-rev/src/pages/_layout.tsx
T
wonfen 22947632c0 refactor: Rewrite and improve sysproxy logic (#7744)
* fix(cmd): preserve anyhow context in coded errors

Use alternate error formatting so coded failures retain the full anyhow context chain without changing their wire format.

* fix(macos): skip redundant proxy disable writes

When both proxy modes are off, read macOS state first and skip networksetup if the OS is already clean. Update the guard target only after a successful write.

* feat(proxy): classify system proxy failures

Attach stable, actionable proxy failure codes to anyhow chains while preserving the original diagnostic causes.

* feat(cmd): return structured command failures

Replace encoded error strings with serializable code and detail fields, and update frontend notice consumers to handle the structured shape.

* fix(scripts): use the js-yaml namespace import

Import js-yaml through its ESM namespace so the i18n cleanup script can access load and dump.

* feat(proxy): localize classified proxy failures

Map privilege and direct-fallback codes to localized explanations, and let mapped causes outrank generic operation keys in notices.

* fix(ui): bound notice text and render failure details

Limit resolved notice text without truncating clipboard content, and normalize structured failures embedded in translation parameters.

* feat(proxy): persist unseen proxy failures

Store unresolved classified failures by code, notify the frontend when the table changes, and show each failure sequence once when a visible window can receive it.

* feat(proxy): report tray and hotkey toggle failures

Record classified failures from tray and hotkey proxy toggles so they appear when a visible window is available.

* fix(ui): restore the proxy switch after failed toggles

Refresh frontend proxy state after a failed toggle so the switch reflects the rolled-back configuration.

* feat(proxy): stop the guard after repeated failures

Report guard outcomes, stop after the failure threshold, and persist the classified failure for the frontend.

* fix(macos): preserve proxy during service handoff

Stop a sidecar being handed to the service without clearing proxy state that the new owner must inherit.

* feat(macos): classify misplaced sidecar state

Distinguish a refused sidecar proxy write from a machine that still needs the privileged service installed.

* feat(ui): recover misplaced sidecar notices

Offer a restart action for macOS proxy failures caused by a ready service while the core still runs as a sidecar.

* feat(ui): install the service from proxy notices

Let privilege notices install the macOS service, restart the core, and report the runtime mode that was actually reached.

* feat(cmd): attach requested proxy operations

Carry the requested proxy direction with structured command failures when the originating command knows it.

* feat(sysproxy): drain guards before OS access

Wait for the local proxy guard to stop before OS reads, service handoff, or final writes, and refuse transitions when it does not drain.

* feat(sysproxy): recover partial proxy writes

Use write progress to choose whether to restore the old guard or compensate with a disabled proxy state, while preserving actionable errors.

* feat(sysproxy): skip already-applied proxy writes

Compare macOS proxy snapshots with global, PAC, and disabled targets so matching state avoids redundant privileged writes.

* feat(proxy): report background proxy failures

Persist and surface classified proxy failures from lifecycle paths that have no direct caller awaiting the result.

* feat(ui): move proxy recovery into a dialog

Guide users through installing or switching to the service after a refused proxy write, and suppress duplicate toast reports.

* fix(ui): close proxy dialog after service recovery

Dismiss the recovery dialog once the core reaches service mode, even when the original proxy request remains pending.

* feat(ui): retry proxy request after service recovery

Replay the requested proxy direction after the core reaches service mode, while leaving incidental restores unchanged.

* fix(proxy): refresh state after successful apply

Publish the proxy refresh from the shared apply path after releasing the lifecycle lock, and remove the tray-specific duplicate.

* feat(ui): show proxy recovery progress

Display installation, restart, and proxy-apply stages while keeping the primary action spinner visible during recovery.

* feat(ui): route unavailable TUN through service recovery

Let the TUN switch request service recovery and restore its requested state after the core restarts in service mode.

* fix(ui): avoid duplicate TUN recovery errors

Roll back deferred TUN toggles without reporting an error, and publish configuration refreshes from the transaction commit path.

* chore: update changelog & pinlock

* fix(core): resolve platform-specific clippy warnings

* fix(core): detect Windows wildcard TCP conflicts

Windows permits a specific exclusive bind beside an existing non-exclusive wildcard listener. Consult the system TCP listener table so the probe reports that overlap as a conflict.
2026-08-14 23:52:37 +08:00

490 lines
13 KiB
TypeScript

import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
Box,
List,
Menu,
MenuItem,
Paper,
SvgIcon,
ThemeProvider,
} from '@mui/material'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import type { CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useNavigate } from 'react-router'
import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import iconDark from '@/assets/image/icon_dark.svg?react'
import iconLight from '@/assets/image/icon_light.svg?react'
import LogoSvg from '@/assets/image/logo.svg?react'
import { BaseErrorBoundary } from '@/components/base'
import { LayoutItem } from '@/components/layout/layout-item'
import { LayoutTraffic } from '@/components/layout/layout-traffic'
import { NoticeManager } from '@/components/layout/notice-manager'
import { ServiceMigrationDialog } from '@/components/layout/service-migration-dialog'
import { SysproxyPrivilegeDialog } from '@/components/layout/sysproxy-privilege-dialog'
import { UpdateButton } from '@/components/layout/update-button'
import {
WindowControls,
WindowResizeHandles,
} from '@/components/layout/window-controller'
import { useI18n } from '@/hooks/use-i18n'
import { useVerge } from '@/hooks/use-verge'
import { useWindowDecorations } from '@/hooks/use-window'
import { useThemeMode } from '@/services/states'
import getSystem from '@/utils/get-system'
import {
useCustomTheme,
useLayoutEvents,
useLoadingOverlay,
useNavMenuOrder,
usePendingFailures,
} from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils'
import { navItems } from './_navigation'
import 'dayjs/locale/ru'
import 'dayjs/locale/zh-cn'
type NavItem = (typeof navItems)[number]
type MenuContextPosition = { top: number; left: number }
interface SortableNavMenuItemProps {
item: NavItem
label: string
}
const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.path,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
}
if (isDragging) {
style.zIndex = 100
}
return (
<LayoutItem
to={item.path}
icon={item.icon}
sortable={{
setNodeRef,
attributes,
listeners,
style,
isDragging,
}}
>
{label}
</LayoutItem>
)
}
dayjs.extend(relativeTime)
const OS = getSystem()
const Layout = () => {
const mode = useThemeMode()
const isDark = mode !== 'light'
const { t } = useTranslation()
const { theme } = useCustomTheme()
const { verge, mutateVerge, patchVerge } = useVerge()
const { language } = verge ?? {}
const navCollapsed = verge?.collapse_navbar ?? false
const { switchLanguage } = useI18n()
const navigate = useNavigate()
const themeReady = useMemo(() => Boolean(theme), [theme])
// 开发环境下检测 MihomoWebSocket 的所有实例
useEffect(() => {
let id: number
if (import.meta.env.DEV) {
id = setInterval(() => {
MihomoWebSocket.get_all_instances().then((list) => {
console.log('Mihomo ws instances', list)
})
}, 1000)
}
return () => {
if (id) {
clearInterval(id)
}
}
}, [])
const [menuUnlocked, setMenuUnlocked] = useState(false)
const [menuContextPosition, setMenuContextPosition] =
useState<MenuContextPosition | null>(null)
const windowControlsRef = useRef<any>(null)
const { decorated } = useWindowDecorations()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const handleMenuOrderOptimisticUpdate = useCallback(
(order: string[]) => {
mutateVerge(
(prev) => (prev ? { ...prev, menu_order: order } : prev),
false,
)
},
[mutateVerge],
)
const handleMenuOrderPersist = useCallback(
(order: string[]) => patchVerge({ menu_order: order }),
[patchVerge],
)
const {
menuOrder,
navItemMap,
handleMenuDragEnd,
isDefaultOrder,
resetMenuOrder,
} = useNavMenuOrder({
enabled: menuUnlocked,
items: navItems,
storedOrder: verge?.menu_order,
onOptimisticUpdate: handleMenuOrderOptimisticUpdate,
onPersist: handleMenuOrderPersist,
})
const handleMenuContextMenu = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
event.preventDefault()
event.stopPropagation()
setMenuContextPosition({ top: event.clientY, left: event.clientX })
},
[],
)
const handleMenuContextClose = useCallback(() => {
setMenuContextPosition(null)
}, [])
const handleResetMenuOrder = useCallback(() => {
setMenuContextPosition(null)
void resetMenuOrder()
}, [resetMenuOrder])
const handleUnlockMenu = useCallback(() => {
setMenuUnlocked(true)
setMenuContextPosition(null)
}, [])
const handleLockMenu = useCallback(() => {
setMenuUnlocked(false)
setMenuContextPosition(null)
}, [])
const handleToggleNavCollapsed = useCallback(() => {
setMenuContextPosition(null)
void patchVerge({ collapse_navbar: !navCollapsed })
}, [navCollapsed, patchVerge])
const customTitlebar = useMemo(
() =>
decorated === false ? (
<div className="the_titlebar">
<div
className="the_titlebar-drag-region"
data-tauri-drag-region="true"
/>
<WindowControls ref={windowControlsRef} />
</div>
) : null,
[decorated],
)
useLoadingOverlay(themeReady)
const handleNotice = useCallback(
(payload: [string, string]) => {
const [status, msg] = payload
try {
handleNoticeMessage(status, msg, t, navigate)
} catch (error) {
console.error('[通知处理] 失败:', error)
}
},
[t, navigate],
)
useLayoutEvents(handleNotice)
usePendingFailures()
useEffect(() => {
if (language) {
dayjs.locale(language === 'zh' ? 'zh-cn' : language)
switchLanguage(language)
}
}, [language, switchLanguage])
if (!themeReady) {
return (
<div
style={{
width: '100vw',
height: '100vh',
background: mode === 'light' ? '#fff' : '#181a1b',
transition: 'background 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: mode === 'light' ? '#333' : '#fff',
}}
></div>
)
}
return (
<ThemeProvider theme={theme}>
{/* 左侧底部窗口控制按钮 */}
<NoticeManager position={verge?.notice_position} />
<ServiceMigrationDialog />
<SysproxyPrivilegeDialog />
<div
style={{
animation: 'fadeIn 0.5s',
WebkitAnimation: 'fadeIn 0.5s',
}}
/>
<style>
{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`}
</style>
<Paper
square
elevation={0}
className={`${OS} layout${navCollapsed ? ' layout--nav-collapsed' : ''}`}
style={{
borderTopLeftRadius: '0px',
borderTopRightRadius: '0px',
}}
onContextMenu={(e) => {
if (
OS === 'windows' &&
!['input', 'textarea'].includes(
e.currentTarget.tagName.toLowerCase(),
) &&
!e.currentTarget.isContentEditable
) {
e.preventDefault()
}
}}
sx={[
({ palette }) => ({ bgcolor: palette.background.paper }),
OS === 'linux'
? {
borderRadius: '8px',
width: '100vw',
height: '100vh',
}
: {},
]}
>
{decorated === false && <WindowResizeHandles />}
{/* Custom titlebar - rendered only when decorated is false, memoized for performance */}
{customTitlebar}
<div className="layout-content">
<div className="layout-content__left">
<div className="the-logo" data-tauri-drag-region="false">
<div
data-tauri-drag-region="true"
style={{
height: '27px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<SvgIcon
component={isDark ? iconDark : iconLight}
style={{
height: '36px',
width: '36px',
marginTop: '-3px',
marginRight: '5px',
marginLeft: '-3px',
}}
inheritViewBox
/>
<LogoSvg fill={isDark ? 'white' : 'black'} />
</div>
<UpdateButton className="the-newbtn" />
</div>
{menuUnlocked && (
<Box
sx={(theme) => ({
px: 1.5,
py: 0.75,
mx: 'auto',
mb: 1,
maxWidth: 250,
borderRadius: 1.5,
fontSize: 12,
fontWeight: 600,
textAlign: 'center',
color: theme.palette.warning.contrastText,
bgcolor:
theme.palette.mode === 'light'
? theme.palette.warning.main
: theme.palette.warning.dark,
})}
>
{t('layout.components.navigation.menu.reorderMode')}
</Box>
)}
{menuUnlocked ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
>
<SortableContext items={menuOrder}>
<List
className="the-menu"
onContextMenu={handleMenuContextMenu}
>
{menuOrder.map((path) => {
const item = navItemMap.get(path)
if (!item) {
return null
}
return (
<SortableNavMenuItem
key={item.path}
item={item}
label={t(item.label)}
/>
)
})}
</List>
</SortableContext>
</DndContext>
) : (
<List className="the-menu" onContextMenu={handleMenuContextMenu}>
{menuOrder.map((path) => {
const item = navItemMap.get(path)
if (!item) {
return null
}
return (
<LayoutItem key={item.path} to={item.path} icon={item.icon}>
{t(item.label)}
</LayoutItem>
)
})}
</List>
)}
<Menu
open={Boolean(menuContextPosition)}
onClose={handleMenuContextClose}
anchorReference="anchorPosition"
anchorPosition={
menuContextPosition
? {
top: menuContextPosition.top,
left: menuContextPosition.left,
}
: undefined
}
transitionDuration={200}
slotProps={{
list: {
sx: { py: 0.5 },
},
}}
>
<MenuItem onClick={handleToggleNavCollapsed} dense>
{navCollapsed
? t('layout.components.navigation.menu.expandNavBar')
: t('layout.components.navigation.menu.collapseNavBar')}
</MenuItem>
<MenuItem
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
dense
>
{menuUnlocked
? t('layout.components.navigation.menu.lock')
: t('layout.components.navigation.menu.unlock')}
</MenuItem>
<MenuItem
onClick={handleResetMenuOrder}
dense
disabled={isDefaultOrder}
>
{t('layout.components.navigation.menu.restoreDefaultOrder')}
</MenuItem>
</Menu>
<div className="the-traffic">
<LayoutTraffic />
</div>
</div>
<div className="layout-content__right">
<div className="the-bar"></div>
<div className="the-content">
<BaseErrorBoundary>
<Outlet />
</BaseErrorBoundary>
</div>
</div>
</div>
</Paper>
</ThemeProvider>
)
}
export default Layout