refactor: knip cleanup (#7468)

* chore(frontend): expand Knip reports

* chore: remove unused Mihomo changelog sync script

* refactor: remove unused value exports

* refactor: remove unused type exports

* refactor: remove circular route dependencies

* chore(frontend): move Knip scope to config

* chore(i18n): remove stale portable update message
This commit is contained in:
Tunglies
2026-07-13 11:29:25 +08:00
committed by GitHub
parent 6f9fb97b31
commit 7ca20fc4ed
44 changed files with 266 additions and 602 deletions
+1 -1
View File
@@ -75,6 +75,6 @@ jobs:
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm typecheck
- name: Report unused frontend files
- name: Report unused frontend code and dependencies
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:report
+1 -1
View File
@@ -28,7 +28,7 @@ args = ["typecheck"]
command = "pnpm.cmd"
[tasks.knip-report]
description = "Report unused frontend files"
description = "Report unused frontend code and dependencies"
command = "pnpm"
args = ["knip:report"]
+21 -1
View File
@@ -1,5 +1,25 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/polyfills/*.js!"],
"project": ["src/**/*.{js,jsx,ts,tsx}!"]
"project": ["src/**/*.{js,jsx,ts,tsx}!", "scripts/**/*.{js,mjs}!"],
"include": [
"files",
"dependencies",
"devDependencies",
"unlisted",
"unresolved",
"binaries",
"exports",
"types",
"duplicates",
"cycles"
],
"ignoreBinaries": ["rustc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"]
},
"cycles": {
"dynamicImports": true
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"typecheck": "tsc --noEmit",
"knip:report": "knip --files --no-exit-code"
"knip:report": "knip --no-exit-code"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
-104
View File
@@ -1,104 +0,0 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
const VERSION_URL =
'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
const DEFAULT_CHANGELOG = 'Changelog.md'
const args = process.argv.slice(2)
const CHECK = args.includes('--check')
const DRY_RUN = args.includes('--dry-run')
const logError = (message, ...optionalParams) =>
console.error(message, ...optionalParams)
const logInfo = (message, ...optionalParams) =>
console.log(message, ...optionalParams)
function readArgValue(name) {
const index = args.indexOf(name)
if (index === -1) return null
return args[index + 1] ?? null
}
function normalizeVersion(version) {
const normalized = version.trim()
return normalized.startsWith('v') ? normalized : `v${normalized}`
}
async function fetchLatestVersion() {
const explicitVersion = readArgValue('--version')
if (explicitVersion) return normalizeVersion(explicitVersion)
const response = await fetch(VERSION_URL, {
method: 'GET',
})
if (!response.ok) {
throw new Error(`Failed to fetch ${VERSION_URL}: ${response.status}`)
}
return normalizeVersion(await response.text())
}
function replaceExistingLine(content, version) {
const mihomoLine =
/^(\s*-\s*(?:\*\*)?\s*Mihomo\(Meta\)\s*内核升级至\s*)v?\d+(?:\.\d+)+(?:[-+][^\s*]+)?((?:\*\*)?\s*)$/m
if (!mihomoLine.test(content)) return null
return content.replace(mihomoLine, `$1${version}$2`)
}
function insertMihomoLine(content, version) {
const title = /^## .+$/m
const match = content.match(title)
if (!match || match.index === undefined) {
throw new Error('could not find changelog title')
}
const titleEnd = match.index + match[0].length
const before = content.slice(0, titleEnd)
const after = content.slice(titleEnd).replace(/^\n*/, '\n\n')
return `${before}\n\n- **Mihomo(Meta) 内核升级至 ${version}**${after}`
}
async function main() {
const cwd = process.cwd()
const changelogArg = readArgValue('--file')
const changelogPath = path.join(cwd, changelogArg || DEFAULT_CHANGELOG)
if (!fs.existsSync(changelogPath)) {
throw new Error(`could not find ${path.relative(cwd, changelogPath)}`)
}
const version = await fetchLatestVersion()
const content = await fsp.readFile(changelogPath, 'utf-8')
const nextContent =
replaceExistingLine(content, version) ?? insertMihomoLine(content, version)
if (nextContent === content) {
logInfo(`Mihomo version in changelog is already ${version}`)
return
}
if (CHECK) {
logError(`Mihomo version in changelog is not ${version}`)
process.exit(1)
}
if (DRY_RUN) {
logInfo(`Would update Mihomo version in changelog to ${version}`)
return
}
await fsp.writeFile(changelogPath, nextContent)
logInfo(`Updated Mihomo version in changelog to ${version}`)
}
main().catch((error) => {
logError('sync-mihomo-version failed:', error.message)
process.exit(1)
})
@@ -13,7 +13,7 @@ import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type BaseSplitChipEditorMode = 'visual' | 'advanced'
type BaseSplitChipEditorMode = 'visual' | 'advanced'
interface BaseSplitChipEditorProps {
value?: string
+1 -4
View File
@@ -6,10 +6,7 @@ export { BaseLoading } from './base-loading'
export { BaseLoadingOverlay } from './base-loading-overlay'
export { BasePage } from './base-page'
export { BaseSearchBox, type SearchState } from './base-search-box'
export {
BaseSplitChipEditor,
type BaseSplitChipEditorMode,
} from './base-split-chip-editor'
export { BaseSplitChipEditor } from './base-split-chip-editor'
export { BaseStyledSelect } from './base-styled-select'
export { BaseStyledTextField } from './base-styled-text-field'
export { MonacoEditor } from './monaco-editor'
+1 -1
View File
@@ -28,7 +28,7 @@ import getSystem from '@/utils/get-system'
const appWindow = getCurrentWebviewWindow()
export type EditorLanguage = 'yaml' | 'javascript' | 'css'
type EditorLanguage = 'yaml' | 'javascript' | 'css'
export interface EditorViewerProps {
open: boolean
-86
View File
@@ -1,6 +1,3 @@
import { useEffect, useMemo, useReducer, useRef } from 'react'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { compileStringMatcher } from '@/utils/search-matcher'
@@ -13,89 +10,6 @@ export type ProxySearchState = {
useRegularExpression?: boolean
}
export default function useFilterSort(
proxies: IProxyItem[],
groupName: string,
filterText: string,
sortType: ProxySortType,
searchState?: ProxySearchState,
) {
const { verge } = useVerge()
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0)
const lastInputRef = useRef<{ text: string; sort: ProxySortType } | null>(
null,
)
const debounceTimerRef = useRef<number | null>(null)
useEffect(() => {
let last = 0
delayManager.setGroupListener(groupName, () => {
// 简单节流
const now = Date.now()
if (now - last > 666) {
last = now
bumpRefresh()
}
})
return () => {
delayManager.removeGroupListener(groupName)
}
}, [groupName])
const compute = useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText, searchState)
const sp = sortProxies(
fp,
groupName,
sortType,
verge?.default_latency_timeout,
)
return sp
}, [
proxies,
groupName,
filterText,
sortType,
searchState,
verge?.default_latency_timeout,
])
const [result, setResult] = useReducer(
(_prev: IProxyItem[], next: IProxyItem[]) => next,
compute,
)
useEffect(() => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
}
const prev = lastInputRef.current
const stableInputs =
prev && prev.text === filterText && prev.sort === sortType
lastInputRef.current = { text: filterText, sort: sortType }
const delay = stableInputs ? 0 : 150
debounceTimerRef.current = window.setTimeout(() => {
setResult(compute)
debounceTimerRef.current = null
}, delay)
return () => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
}
}
}, [compute, filterText, sortType])
return result
}
export function filterSort(
proxies: IProxyItem[],
groupName: string,
@@ -17,7 +17,6 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown'
import { BaseDialog, DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update'
import { portableFlag } from '@/pages/_layout'
import { showNotice } from '@/services/notice-service'
import { useSetUpdateState, useUpdateState } from '@/services/states'
@@ -160,10 +159,6 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}, [updateInfo])
const onUpdate = useLockFn(async () => {
if (portableFlag) {
showNotice.error('settings.modals.update.messages.portableError')
return
}
if (!updateInfo?.body) return
if (breakChangeFlag) {
showNotice.error('settings.modals.update.messages.breakChangeError')
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import { DialogRef, TooltipIcon } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import { navItems } from '@/pages/_routers'
import { navigationItems } from '@/pages/_navigation-meta'
import { copyClashEnv } from '@/services/cmds'
import { supportedLanguages } from '@/services/i18n'
import { showNotice } from '@/services/notice-service'
@@ -187,7 +187,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
onGuard={(e) => patchVerge({ start_page: e })}
>
<Select size="small" sx={{ width: 140, '> div': { py: '7.5px' } }}>
{navItems.map((page: { label: string; path: string }) => {
{Object.values(navigationItems).map((page) => {
return (
<MenuItem key={page.path} value={page.path}>
{t(page.label)}
+4 -4
View File
@@ -10,25 +10,25 @@ type ConnectionListener = () => void
const metadataValue = (value?: string) => value || ''
export const initConnData: ConnectionMonitorData = {
const initConnData: ConnectionMonitorData = {
uploadTotal: 0,
downloadTotal: 0,
activeConnections: [],
closedConnections: [],
}
export interface ConnectionMonitorData {
interface ConnectionMonitorData {
uploadTotal: number
downloadTotal: number
activeConnections: IConnectionsItem[]
closedConnections: IConnectionsItem[]
}
export interface ConnectionSummaryData {
interface ConnectionSummaryData {
activeConnectionCount: number
}
export const initConnSummaryData: ConnectionSummaryData = {
const initConnSummaryData: ConnectionSummaryData = {
activeConnectionCount: 0,
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
useQuery,
} from '@/services/query-client'
export const RECONNECT_DELAY_MS = 1000
const RECONNECT_DELAY_MS = 1000
interface SharedSubscriptionOwner {
handleMessage: (data: string) => void
+1 -1
View File
@@ -7,7 +7,7 @@ import { useQuery } from '@/services/query-client'
import { useVerge } from './use-verge'
import { useVisibility } from './use-visibility'
export interface SystemState {
interface SystemState {
runningMode: 'Sidecar' | 'Service'
isAdminMode: boolean
isServiceOk: boolean
-8
View File
@@ -3,14 +3,6 @@ import { checkUpdateSafe } from '@/services/update'
import { useVerge } from './use-verge'
export interface UpdateInfo {
version: string
body: string
date: string
available: boolean
downloadAndInstall: (onEvent?: any) => Promise<void>
}
const LAST_CHECK_KEY = 'last_check_update'
export const readLastCheckTime = (): number | null => {
+1 -1
View File
@@ -2,7 +2,7 @@ import { use } from 'react'
import { WindowContext, type WindowContextType } from '@/providers/window'
export const useWindow = () => {
const useWindow = () => {
const context = use(WindowContext)
if (context === undefined) {
throw new Error('useWindow must be used within WindowProvider')
-1
View File
@@ -429,7 +429,6 @@
"update": "تحديث"
},
"messages": {
"portableError": "الإصدار المحمول لا يدعم التحديث داخل التطبيق. يرجى التنزيل والاستبدال يدويًا",
"breakChangeError": "هذا الإصدار هو تحديث رئيسي ولا يدعم التحديث داخل التطبيق. يرجى إلغاء التثبيت وتنزيل الإصدار الجديد وتثبيته يدويًا"
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "Aktualisieren"
},
"messages": {
"portableError": "Die portable Version unterstützt keine In-App-Aktualisierung. Bitte laden Sie die Dateien manuell herunter und ersetzen Sie sie.",
"breakChangeError": "Dies ist eine wichtige Aktualisierung. Die In-App-Aktualisierung wird nicht unterstützt. Bitte deinstallieren Sie die Software und laden Sie die neue Version manuell herunter und installieren Sie sie."
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "Update"
},
"messages": {
"portableError": "The portable version does not support in-app updates. Please manually download and replace it",
"breakChangeError": "This version is a major update and does not support in-app updates. Please uninstall it and manually download and install the new version"
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "Actualizar"
},
"messages": {
"portableError": "La versión portátil no admite la actualización desde dentro de la aplicación. Descargue e instale manualmente la actualización.",
"breakChangeError": "Esta es una actualización importante y no se admite la actualización desde dentro de la aplicación. Desinstale e instale manualmente la nueva versión."
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "به‌روزرسانی"
},
"messages": {
"portableError": "نسخه پرتابل از به‌روزرسانی درون برنامه‌ای پشتیبانی نمی‌کند. لطفاً به صورت دستی دانلود و جایگزین کنید",
"breakChangeError": "این نسخه یک به‌روزرسانی اساسی است و پشتیبانی از به‌روزرسانی درون برنامه را پشتیبانی نمی‌کند. لطفاً پس از حذف، دستی دانلود و نصب کنید."
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "Perbarui"
},
"messages": {
"portableError": "Versi portabel tidak mendukung pembaruan dalam aplikasi. Harap unduh dan ganti secara manual",
"breakChangeError": "Versi ini adalah pembaruan besar dan tidak mendukung pembaruan dalam aplikasi. Harap hapus instalasi dan unduh serta instal versi baru secara manual"
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "更新"
},
"messages": {
"portableError": "ポータブル版ではアプリケーション内での更新はサポートされていません。手動でダウンロードして置き換えてください。",
"breakChangeError": "このバージョンは重大な更新であり、アプリケーション内での更新はサポートされていません。アンインストールしてから手動でダウンロードしてインストールしてください。"
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "업데이트"
},
"messages": {
"portableError": "포터블 버전은 앱 내 업데이트를 지원하지 않습니다. 수동으로 다운로드하여 교체하세요",
"breakChangeError": "이 버전은 메이저 업데이트로 앱 내 업데이트를 지원하지 않습니다. 프로그램을 제거한 뒤 새 버전을 수동으로 설치하세요"
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "Обновить"
},
"messages": {
"portableError": "Портативная версия не поддерживает обновление внутри приложения. Пожалуйста, скачайте и замените файлы вручную.",
"breakChangeError": "Это крупное обновление, которое не поддерживает обновление внутри приложения. Пожалуйста, удалите текущую версию и загрузите установочный файл вручную."
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "Güncelle"
},
"messages": {
"portableError": "Taşınabilir sürüm uygulama içi güncellemeleri desteklemez. Lütfen manuel olarak indirip değiştirin",
"breakChangeError": "Bu sürüm büyük bir güncellemedir ve uygulama içi güncellemeleri desteklemez. Lütfen kaldırın ve yeni sürümü manuel olarak indirip kurun"
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "Яңарту"
},
"messages": {
"portableError": "Портатив версиядә кушымта эчендә яңарту хупланмый, кулдан төшереп алыгыз",
"breakChangeError": "Бу зур яңарту, ул кушымта эчендә яңартылмый. Борып алып ташлап, яңадан урнаштыру сорала."
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "更新"
},
"messages": {
"portableError": "便携版不支持应用内更新,请手动下载替换",
"breakChangeError": "此版本为重大更新,不支持应用内更新,请卸载后手动下载安装"
}
},
-1
View File
@@ -429,7 +429,6 @@
"update": "更新"
},
"messages": {
"portableError": "可攜式版不支援應用程式內更新,請手動下載替換",
"breakChangeError": "此版本為重大更新,不支援應用程式內更新,請解除安裝後手動下載安裝"
}
},
+5 -3
View File
@@ -62,13 +62,15 @@ import {
useNavMenuOrder,
} from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils'
import { navItems, preloadLogsPage, preloadNavigationRoutes } from './_routers'
import {
navItems,
preloadLogsPage,
preloadNavigationRoutes,
} from './_navigation'
import 'dayjs/locale/ru'
import 'dayjs/locale/zh-cn'
export const portableFlag = false
const LogsPage = lazy(() => preloadLogsPage())
type NavItem = (typeof navItems)[number]
+25
View File
@@ -0,0 +1,25 @@
export const navigationItems = {
home: { label: 'layout.components.navigation.tabs.home', path: '/' },
proxies: {
label: 'layout.components.navigation.tabs.proxies',
path: '/proxies',
},
profiles: {
label: 'layout.components.navigation.tabs.profiles',
path: '/profile',
},
connections: {
label: 'layout.components.navigation.tabs.connections',
path: '/connections',
},
rules: { label: 'layout.components.navigation.tabs.rules', path: '/rules' },
logs: { label: 'layout.components.navigation.tabs.logs', path: '/logs' },
unlock: {
label: 'layout.components.navigation.tabs.unlock',
path: '/unlock',
},
settings: {
label: 'layout.components.navigation.tabs.settings',
path: '/settings',
},
} as const
+185
View File
@@ -0,0 +1,185 @@
import DnsRoundedIcon from '@mui/icons-material/DnsRounded'
import ForkRightRoundedIcon from '@mui/icons-material/ForkRightRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded'
import LockOpenRoundedIcon from '@mui/icons-material/LockOpenRounded'
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'
import SubjectRoundedIcon from '@mui/icons-material/SubjectRounded'
import WifiRoundedIcon from '@mui/icons-material/WifiRounded'
import { Box } from '@mui/material'
import { lazy, Suspense, type ComponentType, type ReactNode } from 'react'
import ConnectionsSvg from '@/assets/image/itemicon/connections.svg?react'
import HomeSvg from '@/assets/image/itemicon/home.svg?react'
import LogsSvg from '@/assets/image/itemicon/logs.svg?react'
import ProfilesSvg from '@/assets/image/itemicon/profiles.svg?react'
import ProxiesSvg from '@/assets/image/itemicon/proxies.svg?react'
import RulesSvg from '@/assets/image/itemicon/rules.svg?react'
import SettingsSvg from '@/assets/image/itemicon/settings.svg?react'
import UnlockSvg from '@/assets/image/itemicon/unlock.svg?react'
import { BaseLoading } from '@/components/base'
import { ensureLanguageSections } from '@/services/i18n'
import { navigationItems } from './_navigation-meta'
import HomePage from './home'
type NavigationItem = {
label: (typeof navigationItems)[keyof typeof navigationItems]['label']
path: string
icon: ReactNode[]
Component: ComponentType
preload?: () => Promise<{ default: ComponentType }>
}
const waitForWarmupIdle = (signal: AbortSignal) =>
new Promise<void>((resolve) => {
let idleId: number | undefined
let timeoutId: number | undefined
const cleanup = () => {
signal.removeEventListener('abort', finish)
if (idleId !== undefined) {
window.cancelIdleCallback(idleId)
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId)
}
}
const finish = () => {
cleanup()
resolve()
}
if (signal.aborted) {
resolve()
return
}
signal.addEventListener('abort', finish, { once: true })
if (window.requestIdleCallback) {
idleId = window.requestIdleCallback(finish, { timeout: 500 })
} else {
timeoutId = window.setTimeout(finish, 120)
}
})
const createRoutePreload = (
load: () => Promise<{ default: ComponentType }>,
sections?: string | readonly string[],
) => {
let componentPromise: Promise<{ default: ComponentType }> | undefined
const loadComponent = () => {
componentPromise ??= load().catch((error) => {
componentPromise = undefined
throw error
})
return componentPromise
}
if (!sections) {
return loadComponent
}
return async () => {
const [component] = await Promise.all([
loadComponent(),
ensureLanguageSections(sections),
])
return component
}
}
const createLazyRoute = (
load: () => Promise<{ default: ComponentType }>,
sections?: string | readonly string[],
) => {
const preload = createRoutePreload(load, sections)
const Component = lazy(preload)
const LazyRoute = () => (
<Suspense
fallback={
<Box
sx={{
display: 'flex',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BaseLoading />
</Box>
}
>
<Component />
</Suspense>
)
return { Component: LazyRoute, preload }
}
export const preloadLogsPage = createRoutePreload(
() => import('./logs'),
'logs',
)
export const navItems: NavigationItem[] = [
{
...navigationItems.home,
icon: [<HomeRoundedIcon key="mui" />, <HomeSvg key="svg" />],
Component: HomePage,
},
{
...navigationItems.proxies,
icon: [<WifiRoundedIcon key="mui" />, <ProxiesSvg key="svg" />],
...createLazyRoute(() => import('./proxies')),
},
{
...navigationItems.profiles,
icon: [<DnsRoundedIcon key="mui" />, <ProfilesSvg key="svg" />],
...createLazyRoute(() => import('./profiles'), 'rules'),
},
{
...navigationItems.connections,
icon: [<LanguageRoundedIcon key="mui" />, <ConnectionsSvg key="svg" />],
...createLazyRoute(() => import('./connections'), 'connections'),
},
{
...navigationItems.rules,
icon: [<ForkRightRoundedIcon key="mui" />, <RulesSvg key="svg" />],
...createLazyRoute(() => import('./rules'), 'rules'),
},
{
...navigationItems.logs,
icon: [<SubjectRoundedIcon key="mui" />, <LogsSvg key="svg" />],
Component: () => null /* LogsPage rendered in Layout only on /logs route */,
preload: preloadLogsPage,
},
{
...navigationItems.unlock,
icon: [<LockOpenRoundedIcon key="mui" />, <UnlockSvg key="svg" />],
...createLazyRoute(() => import('./unlock')),
},
{
...navigationItems.settings,
icon: [<SettingsRoundedIcon key="mui" />, <SettingsSvg key="svg" />],
...createLazyRoute(() => import('./settings')),
},
]
export const preloadNavigationRoutes = async (signal: AbortSignal) => {
await waitForWarmupIdle(signal)
if (signal.aborted) {
return
}
await Promise.all(
navItems.map((item) => {
const preload = 'preload' in item ? item.preload : undefined
return preload?.().catch(() => {})
}),
)
}
+1 -183
View File
@@ -1,189 +1,7 @@
import DnsRoundedIcon from '@mui/icons-material/DnsRounded'
import ForkRightRoundedIcon from '@mui/icons-material/ForkRightRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import LanguageRoundedIcon from '@mui/icons-material/LanguageRounded'
import LockOpenRoundedIcon from '@mui/icons-material/LockOpenRounded'
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'
import SubjectRoundedIcon from '@mui/icons-material/SubjectRounded'
import WifiRoundedIcon from '@mui/icons-material/WifiRounded'
import { Box } from '@mui/material'
import { lazy, Suspense, type ComponentType } from 'react'
import { createBrowserRouter, RouteObject } from 'react-router'
import ConnectionsSvg from '@/assets/image/itemicon/connections.svg?react'
import HomeSvg from '@/assets/image/itemicon/home.svg?react'
import LogsSvg from '@/assets/image/itemicon/logs.svg?react'
import ProfilesSvg from '@/assets/image/itemicon/profiles.svg?react'
import ProxiesSvg from '@/assets/image/itemicon/proxies.svg?react'
import RulesSvg from '@/assets/image/itemicon/rules.svg?react'
import SettingsSvg from '@/assets/image/itemicon/settings.svg?react'
import UnlockSvg from '@/assets/image/itemicon/unlock.svg?react'
import { BaseLoading } from '@/components/base'
import { ensureLanguageSections } from '@/services/i18n'
import Layout from './_layout'
import HomePage from './home'
const waitForWarmupIdle = (signal: AbortSignal) =>
new Promise<void>((resolve) => {
let idleId: number | undefined
let timeoutId: number | undefined
const cleanup = () => {
signal.removeEventListener('abort', finish)
if (idleId !== undefined) {
window.cancelIdleCallback(idleId)
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId)
}
}
const finish = () => {
cleanup()
resolve()
}
if (signal.aborted) {
resolve()
return
}
signal.addEventListener('abort', finish, { once: true })
if (window.requestIdleCallback) {
idleId = window.requestIdleCallback(finish, { timeout: 500 })
} else {
timeoutId = window.setTimeout(finish, 120)
}
})
const createRoutePreload = (
load: () => Promise<{ default: ComponentType }>,
sections?: string | readonly string[],
) => {
let componentPromise: Promise<{ default: ComponentType }> | undefined
const loadComponent = () => {
componentPromise ??= load().catch((error) => {
componentPromise = undefined
throw error
})
return componentPromise
}
if (!sections) {
return loadComponent
}
return async () => {
const [component] = await Promise.all([
loadComponent(),
ensureLanguageSections(sections),
])
return component
}
}
const createLazyRoute = (
load: () => Promise<{ default: ComponentType }>,
sections?: string | readonly string[],
) => {
const preload = createRoutePreload(load, sections)
const Component = lazy(preload)
const LazyRoute = () => (
<Suspense
fallback={
<Box
sx={{
display: 'flex',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BaseLoading />
</Box>
}
>
<Component />
</Suspense>
)
return { Component: LazyRoute, preload }
}
export const preloadLogsPage = createRoutePreload(
() => import('./logs'),
'logs',
)
export const navItems = [
{
label: 'layout.components.navigation.tabs.home',
path: '/',
icon: [<HomeRoundedIcon key="mui" />, <HomeSvg key="svg" />],
Component: HomePage,
},
{
label: 'layout.components.navigation.tabs.proxies',
path: '/proxies',
icon: [<WifiRoundedIcon key="mui" />, <ProxiesSvg key="svg" />],
...createLazyRoute(() => import('./proxies')),
},
{
label: 'layout.components.navigation.tabs.profiles',
path: '/profile',
icon: [<DnsRoundedIcon key="mui" />, <ProfilesSvg key="svg" />],
...createLazyRoute(() => import('./profiles'), 'rules'),
},
{
label: 'layout.components.navigation.tabs.connections',
path: '/connections',
icon: [<LanguageRoundedIcon key="mui" />, <ConnectionsSvg key="svg" />],
...createLazyRoute(() => import('./connections'), 'connections'),
},
{
label: 'layout.components.navigation.tabs.rules',
path: '/rules',
icon: [<ForkRightRoundedIcon key="mui" />, <RulesSvg key="svg" />],
...createLazyRoute(() => import('./rules'), 'rules'),
},
{
label: 'layout.components.navigation.tabs.logs',
path: '/logs',
icon: [<SubjectRoundedIcon key="mui" />, <LogsSvg key="svg" />],
Component: () => null /* LogsPage rendered in Layout only on /logs route */,
preload: preloadLogsPage,
},
{
label: 'layout.components.navigation.tabs.unlock',
path: '/unlock',
icon: [<LockOpenRoundedIcon key="mui" />, <UnlockSvg key="svg" />],
...createLazyRoute(() => import('./unlock')),
},
{
label: 'layout.components.navigation.tabs.settings',
path: '/settings',
icon: [<SettingsRoundedIcon key="mui" />, <SettingsSvg key="svg" />],
...createLazyRoute(() => import('./settings')),
},
]
export const preloadNavigationRoutes = async (signal: AbortSignal) => {
await waitForWarmupIdle(signal)
if (signal.aborted) {
return
}
await Promise.all(
navItems.map((item) => {
const preload = 'preload' in item ? item.preload : undefined
return preload?.().catch(() => {})
}),
)
}
import { navItems } from './_navigation'
export const router = createBrowserRouter([
{
-57
View File
@@ -6,39 +6,6 @@ import {
RuleProvider,
} from 'tauri-plugin-mihomo-api'
export interface AppDataContextType {
proxies: any
clashConfig: BaseConfig
rules: Rule[]
sysproxy: any
runningMode?: string
uptime: number
proxyProviders: Record<string, ProxyProvider>
ruleProviders: Record<string, RuleProvider>
systemProxyAddress: string
isCoreDataPending: boolean
refreshProxy: () => Promise<any>
refreshClashConfig: () => Promise<any>
refreshRules: () => Promise<any>
refreshSysproxy: () => Promise<any>
refreshProxyProviders: () => Promise<any>
refreshRuleProviders: () => Promise<any>
refreshAll: () => Promise<any>
}
export interface ConnectionWithSpeed extends IConnectionsItem {
curUpload: number
curDownload: number
}
export interface ConnectionSpeedData {
id: string
upload: number
download: number
timestamp: number
}
export interface ProxiesContextType {
proxies: any
proxyProviders: Record<string, ProxyProvider | undefined>
@@ -134,27 +101,3 @@ export const useAppRefreshers = (): RefreshersContextType =>
export const useCoreDataStatus = (): CoreDataStatusContextType =>
useCtx(CoreDataStatusContext, 'useCoreDataStatus')
export const useAppData = (): AppDataContextType => {
const { proxies, proxyProviders } = useProxiesData()
const { rules, ruleProviders } = useRulesData()
const { clashConfig } = useClashConfigData()
const { sysproxy, runningMode, systemProxyAddress } = useSystemData()
const { uptime } = useUptimeData()
const { isCoreDataPending } = useCoreDataStatus()
const refreshers = useAppRefreshers()
return {
proxies,
clashConfig: clashConfig as BaseConfig,
rules,
sysproxy,
runningMode,
uptime,
proxyProviders: proxyProviders as Record<string, ProxyProvider>,
ruleProviders: ruleProviders as Record<string, RuleProvider>,
systemProxyAddress,
isCoreDataPending,
...refreshers,
}
}
-79
View File
@@ -100,10 +100,6 @@ export async function getRuntimeYaml() {
return invoke<string | null>('get_runtime_yaml')
}
export async function getRuntimeExists() {
return invoke<string[]>('get_runtime_exists')
}
export async function getRuntimeLogs() {
return invoke<Record<string, [string, string][]>>('get_runtime_logs')
}
@@ -264,10 +260,6 @@ export async function getClashLogs() {
}, [])
}
export async function clearLogs() {
return invoke<void>('clear_logs')
}
export async function getVergeConfig() {
return invoke<IVergeConfig>('get_verge_config')
}
@@ -302,23 +294,10 @@ export async function getAutotemProxy() {
}
}
export async function getAutoLaunchStatus() {
try {
return await invoke<boolean>('get_auto_launch_status')
} catch (error) {
console.error('获取自启动状态失败:', error)
return false
}
}
export async function changeClashCore(clashCore: string) {
return invoke<string | null>('change_clash_core', { clashCore })
}
export async function startCore() {
return invoke<void>('start_core')
}
export async function stopCore() {
return invoke<void>('stop_core')
}
@@ -355,38 +334,6 @@ export const openWebUrl = async (url: string) => {
}
}
export async function cmdGetProxyDelay(
name: string,
timeout: number,
url?: string,
) {
// 确保URL不为空
const testUrl = url || 'http://cp.cloudflare.com/generate_204'
try {
// 不再在前端编码代理名称,由后端统一处理编码
const result = await invoke<{ delay: number }>(
'clash_api_get_proxy_delay',
{
name,
url: testUrl, // 传递经过验证的URL
timeout,
},
)
// 验证返回结果中是否有delay字段,并且值是一个有效的数字
if (result && typeof result.delay === 'number') {
return result
} else {
// 返回一个有效的结果对象,但标记为超时
return { delay: 1e6 }
}
} catch {
// 返回一个有效的结果对象,但标记为错误
return { delay: 1e6 }
}
}
export async function cmdTestDelay(url: string) {
return invoke<number>('test_delay', { url })
}
@@ -397,10 +344,6 @@ export async function invoke_uwp_tool() {
)
}
export async function getPortableFlag() {
return invoke<boolean>('get_portable_flag')
}
export async function openDevTools() {
return invoke('open_devtools')
}
@@ -518,14 +461,6 @@ export async function listLocalBackup() {
return invoke<ILocalBackupFile[]>('list_local_backup')
}
export async function scriptValidateNotice(status: string, msg: string) {
return invoke<void>('script_validate_notice', { status, msg })
}
export async function validateScriptFile(filePath: string) {
return invoke<ValidationOutcome>('validate_script_file', { filePath })
}
// 获取当前运行模式
export const getRunningMode = async () => {
return invoke<string>('get_running_mode')
@@ -546,16 +481,6 @@ export const uninstallService = async () => {
return invoke<void>('uninstall_service')
}
// 重装系统服务
export const reinstallService = async () => {
return invoke<void>('reinstall_service')
}
// 修复系统服务
export const repairService = async () => {
return invoke<void>('repair_service')
}
// 系统服务是否可用
export const isServiceAvailable = async () => {
try {
@@ -569,10 +494,6 @@ export const entry_lightweight_mode = async () => {
return invoke<void>('entry_lightweight_mode')
}
export const exit_lightweight_mode = async () => {
return invoke<void>('exit_lightweight_mode')
}
export const isAdmin = async () => {
try {
return await invoke<boolean>('app_is_admin')
-11
View File
@@ -105,14 +105,6 @@ const localeLoaders = Object.entries(localeModules).reduce<
return acc
}, {})
export const languages: Record<string, any> = supportedLanguages.reduce(
(acc, lang) => {
acc[lang] = {}
return acc
},
{} as Record<string, any>,
)
const loadLanguageSections = async (
language: string,
sections: readonly string[],
@@ -144,9 +136,6 @@ const loadLanguageSections = async (
}
}
export const loadLanguage = async (language: string) =>
loadLanguageSections(language, STARTUP_LANGUAGE_SECTIONS)
const getLoadedLanguageSections = (language: string) =>
Object.keys(i18n.getResourceBundle(language, 'translation') ?? {})
+1 -1
View File
@@ -3,7 +3,7 @@ import { ReactNode, isValidElement } from 'react'
type NoticeType = 'success' | 'error' | 'info'
export interface NoticeTranslationDescriptor {
interface NoticeTranslationDescriptor {
key: string
params?: Record<string, unknown>
}
+2 -2
View File
@@ -45,7 +45,7 @@ export const setPreloadConfig = (config: IVergeConfig | null) => {
export const getPreloadConfig = () => vergeConfigCache
export const preloadConfig = async () => {
const preloadConfig = async () => {
try {
const config = await getVergeConfig()
setPreloadConfig(config)
@@ -57,7 +57,7 @@ export const preloadConfig = async () => {
}
}
export const preloadLanguage = async (
const preloadLanguage = async (
vergeConfig?: IVergeConfig | null,
loadConfig: () => Promise<IVergeConfig | null> = preloadConfig,
) => {
+1 -1
View File
@@ -31,7 +31,7 @@ type QueryResult<T> = SWRResponse<T> & {
const serializeQueryKey = (queryKey: QueryKey) => unstable_serialize(queryKey)
export const queryCache = new Map<string, unknown>()
const queryCache = new Map<string, unknown>()
const setCachedData = <T>(queryKey: QueryKey, data: T | undefined) => {
const cacheKey = serializeQueryKey(queryKey)
+7 -16
View File
@@ -6,7 +6,7 @@ import {
import { version as appVersion } from '@root/package.json'
export type VersionParts = {
type VersionParts = {
main: number[]
pre: (number | string)[]
}
@@ -16,33 +16,27 @@ const SEMVER_FULL_REGEX =
const SEMVER_SEARCH_REGEX =
/v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/i
export const normalizeVersion = (
input: string | null | undefined,
): string | null => {
const normalizeVersion = (input: string | null | undefined): string | null => {
if (typeof input !== 'string') return null
const trimmed = input.trim()
if (!trimmed) return null
return trimmed.replace(/^v/i, '')
}
export const ensureSemver = (
input: string | null | undefined,
): string | null => {
const ensureSemver = (input: string | null | undefined): string | null => {
const normalized = normalizeVersion(input)
if (!normalized) return null
return SEMVER_FULL_REGEX.test(normalized) ? normalized : null
}
export const extractSemver = (
input: string | null | undefined,
): string | null => {
const extractSemver = (input: string | null | undefined): string | null => {
if (typeof input !== 'string') return null
const match = input.match(SEMVER_SEARCH_REGEX)
if (!match) return null
return normalizeVersion(match[0])
}
export const splitVersion = (version: string | null): VersionParts | null => {
const splitVersion = (version: string | null): VersionParts | null => {
if (!version) return null
const [mainPart, preRelease] = version.split('-')
const main = mainPart
@@ -93,17 +87,14 @@ const compareVersionParts = (a: VersionParts, b: VersionParts): number => {
return 0
}
export const compareVersions = (
a: string | null,
b: string | null,
): number | null => {
const compareVersions = (a: string | null, b: string | null): number | null => {
const partsA = splitVersion(a)
const partsB = splitVersion(b)
if (!partsA || !partsB) return null
return compareVersionParts(partsA, partsB)
}
export const resolveRemoteVersion = (update: Update): string | null => {
const resolveRemoteVersion = (update: Update): string | null => {
const primary = ensureSemver(update.version)
if (primary) return primary
-1
View File
@@ -572,7 +572,6 @@ export const translationKeys = [
'settings.modals.update.title',
'settings.modals.update.actions.goToRelease',
'settings.modals.update.actions.update',
'settings.modals.update.messages.portableError',
'settings.modals.update.messages.breakChangeError',
'settings.modals.sysproxy.title',
'settings.modals.sysproxy.fieldsets.currentStatus',
-1
View File
@@ -1042,7 +1042,6 @@ export interface TranslationResources {
}
messages: {
breakChangeError: string
portableError: string
}
title: string
}
+2 -11
View File
@@ -4,9 +4,7 @@
* - env flag `VITE_ENABLE_DEBUG_LOGS` is truthy (1/true/yes)
* - page sets `window.__VERGE_ENABLE_DEBUG_LOGS__ = true`
* - localStorage item `VERGE_DEBUG_LOGS` is truthy (1/true/yes)
* Use `setDebugLoggingEnabled` to force-enable/disable at runtime.
*/
let runtimeOverride: boolean | undefined
let cachedDebugEnabled: boolean | undefined
const parseStringFlag = (value: unknown) => {
@@ -46,15 +44,8 @@ const computeDebugEnabled = (): boolean => {
return false
}
export const setDebugLoggingEnabled = (enabled: boolean) => {
runtimeOverride = enabled
cachedDebugEnabled = enabled
}
export const isDebugLoggingEnabled = () =>
runtimeOverride ??
cachedDebugEnabled ??
(cachedDebugEnabled = computeDebugEnabled())
const isDebugLoggingEnabled = () =>
cachedDebugEnabled ?? (cachedDebugEnabled = computeDebugEnabled())
/**
* Logs to the console only when debug logging is enabled.
+1 -1
View File
@@ -9,7 +9,7 @@ export type CompileStringMatcherResult = {
isValid: boolean
}
export const escapeRegex = (value: string) => {
const escapeRegex = (value: string) => {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}