Compare commits

...

2 Commits

21 changed files with 183 additions and 169 deletions

View File

@ -250,7 +250,7 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
authToken: item.authToken, authToken: item.authToken,
userAgent: item.userAgent, userAgent: item.userAgent,
updated: new Date().getTime(), updated: new Date().getTime(),
updateTimeout: item.updateTimeout || 5 updateTimeout: item.updateTimeout
} }
// Local // Local
@ -264,7 +264,10 @@ export async function createProfile(item: Partial<IProfileItem>): Promise<IProfi
const { userAgent, subscriptionTimeout = 30000 } = await getAppConfig() const { userAgent, subscriptionTimeout = 30000 } = await getAppConfig()
const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig() const { 'mixed-port': mixedPort = 7890 } = await getControledMihomoConfig()
const userItemTimeoutMs = (newItem.updateTimeout || 5) * 1000 const userItemTimeoutMs =
typeof newItem.updateTimeout === 'number' && newItem.updateTimeout > 0
? newItem.updateTimeout * 1000
: subscriptionTimeout
const baseOptions: Omit<FetchOptions, 'useProxy' | 'timeout'> = { const baseOptions: Omit<FetchOptions, 'useProxy' | 'timeout'> = {
url: item.url, url: item.url,
@ -454,7 +457,7 @@ export async function convertMrsRuleset(filePath: string, behavior: string): Pro
try { try {
// 使用 mihomo convert-ruleset 命令转换 MRS 文件为 text 格式 // 使用 mihomo convert-ruleset 命令转换 MRS 文件为 text 格式
// 命令格式: mihomo convert-ruleset <behavior> <format> <source> // 命令格式mihomo convert-ruleset <behavior> <format> <source>
await execAsync(`"${corePath}" convert-ruleset ${behavior} mrs "${fullPath}" "${tempFilePath}"`) await execAsync(`"${corePath}" convert-ruleset ${behavior} mrs "${fullPath}" "${tempFilePath}"`)
const content = await readFile(tempFilePath, 'utf-8') const content = await readFile(tempFilePath, 'utf-8')
await unlink(tempFilePath) await unlink(tempFilePath)

View File

@ -1,30 +1,6 @@
import { execSync } from 'child_process' import { execSync } from 'child_process'
import { electronApp, optimizer } from '@electron-toolkit/utils' import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, dialog } from 'electron' import { app, dialog } from 'electron'
if (process.platform === 'win32') {
try {
const stdout = execSync('powershell -NoProfile -Command "$PSVersionTable.PSVersion.Major"', {
encoding: 'utf8',
timeout: 5000
})
const major = parseInt(stdout.trim(), 10)
if (!isNaN(major) && major < 5) {
const isZh = Intl.DateTimeFormat().resolvedOptions().locale?.startsWith('zh')
const title = isZh ? '需要更新 PowerShell' : 'PowerShell Update Required'
const message = isZh
? `检测到您的 PowerShell 版本为 ${major}.x部分功能需要 PowerShell 5.1 才能正常运行。\\n\\n请访问 Microsoft 官网下载并安装 Windows Management Framework 5.1。`
: `Detected PowerShell version ${major}.x. Some features require PowerShell 5.1.\\n\\nPlease install Windows Management Framework 5.1 from the Microsoft website.`
execSync(
`mshta "javascript:var sh=new ActiveXObject('WScript.Shell');sh.Popup('${message}',0,'${title}',48);close()"`,
{ timeout: 60000 }
)
process.exit(0)
}
} catch {
// ignore
}
}
import i18next from 'i18next' import i18next from 'i18next'
import { initI18n } from '../shared/i18n' import { initI18n } from '../shared/i18n'
import { registerIpcMainHandlers } from './utils/ipc' import { registerIpcMainHandlers } from './utils/ipc'
@ -61,6 +37,30 @@ import {
getSystemLanguage getSystemLanguage
} from './lifecycle' } from './lifecycle'
if (process.platform === 'win32') {
try {
const stdout = execSync('powershell -NoProfile -Command "$PSVersionTable.PSVersion.Major"', {
encoding: 'utf8',
timeout: 5000
})
const major = parseInt(stdout.trim(), 10)
if (!isNaN(major) && major < 5) {
const isZh = Intl.DateTimeFormat().resolvedOptions().locale?.startsWith('zh')
const title = isZh ? '需要更新 PowerShell' : 'PowerShell Update Required'
const message = isZh
? `检测到您的 PowerShell 版本为 ${major}.x部分功能需要 PowerShell 5.1 才能正常运行。\\n\\n请访问 Microsoft 官网下载并安装 Windows Management Framework 5.1。`
: `Detected PowerShell version ${major}.x. Some features require PowerShell 5.1.\\n\\nPlease install Windows Management Framework 5.1 from the Microsoft website.`
execSync(
`mshta "javascript:var sh=new ActiveXObject('WScript.Shell');sh.Popup('${message}',0,'${title}',48);close()"`,
{ timeout: 60000 }
)
process.exit(0)
}
} catch {
// ignore
}
}
const mainLogger = createLogger('Main') const mainLogger = createLogger('Main')
export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow } export { mainWindow, showMainWindow, triggerMainWindow, closeMainWindow }

View File

@ -117,19 +117,17 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
{ proxy, responseType: 'text' } { proxy, responseType: 'text' }
) )
const expectedHash = (sha256Res.data as string).trim().split(/\s+/)[0] const expectedHash = (sha256Res.data as string).trim().split(/\s+/)[0]
const res = await tryDownload( const res = await tryDownload(buildDownloadUrls(`${githubBase}${file}`, githubProxy), {
buildDownloadUrls(`${githubBase}${file}`, githubProxy), responseType: 'arraybuffer',
{ timeout: 0,
responseType: 'arraybuffer', proxy,
timeout: 0, headers: { 'Content-Type': 'application/octet-stream' },
proxy, onProgress: (loaded: number, total: number) => {
headers: { 'Content-Type': 'application/octet-stream' }, mainWindow?.webContents.send('updateDownloadProgress', {
onProgress: (loaded: number, total: number) => { status: 'downloading',
mainWindow?.webContents.send('updateDownloadProgress', { percent: Math.round((loaded / total) * 100)
status: 'downloading', })
percent: Math.round((loaded / total) * 100) }
})
}
}) })
mainWindow?.webContents.send('updateDownloadProgress', { status: 'verifying' }) mainWindow?.webContents.send('updateDownloadProgress', { status: 'verifying' })
const fileBuffer = Buffer.from(res.data as ArrayBuffer) const fileBuffer = Buffer.from(res.data as ArrayBuffer)

View File

@ -104,8 +104,7 @@ export async function webdavBackup(): Promise<boolean> {
if (webdavMaxBackups > 0) { if (webdavMaxBackups > 0) {
try { try {
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' }) const fileList = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
const fileList = Array.isArray(files) ? files : files.data
const currentPlatformFiles = fileList.filter((file) => { const currentPlatformFiles = fileList.filter((file) => {
return file.basename.startsWith(`${process.platform}_`) return file.basename.startsWith(`${process.platform}_`)
@ -147,11 +146,7 @@ export async function webdavRestore(filename: string): Promise<void> {
export async function listWebdavBackups(): Promise<string[]> { export async function listWebdavBackups(): Promise<string[]> {
const { client, webdavDir } = await getWebDAVClient() const { client, webdavDir } = await getWebDAVClient()
const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' }) const files = await client.getDirectoryContents(webdavDir, { glob: '*.zip' })
if (Array.isArray(files)) { return files.map((file) => file.basename)
return files.map((file) => file.basename)
} else {
return files.data.map((file) => file.basename)
}
} }
export async function webdavDelete(filename: string): Promise<void> { export async function webdavDelete(filename: string): Promise<void> {

View File

@ -6,10 +6,10 @@ import { join } from 'path'
import { createGunzip } from 'zlib' import { createGunzip } from 'zlib'
import AdmZip from 'adm-zip' import AdmZip from 'adm-zip'
import { stopCore } from '../core/manager' import { stopCore } from '../core/manager'
import { getAppConfig } from '../config'
import { mihomoCoreDir } from './dirs' import { mihomoCoreDir } from './dirs'
import * as chromeRequest from './chromeRequest' import * as chromeRequest from './chromeRequest'
import { createLogger } from './logger' import { createLogger } from './logger'
import { getAppConfig } from '../config'
const log = createLogger('GitHub') const log = createLogger('GitHub')

View File

@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'
const ErrorFallback = ({ error }: FallbackProps): React.ReactElement => { const ErrorFallback = ({ error }: FallbackProps): React.ReactElement => {
const { t } = useTranslation() const { t } = useTranslation()
const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? (error.stack ?? '') : ''
return ( return (
<div className="p-4"> <div className="p-4">
@ -33,17 +35,17 @@ const ErrorFallback = ({ error }: FallbackProps): React.ReactElement => {
variant="flat" variant="flat"
className="ml-2" className="ml-2"
onPress={() => onPress={() =>
navigator.clipboard.writeText('```\n' + error.message + '\n' + error.stack + '\n```') navigator.clipboard.writeText('```\n' + errorMessage + '\n' + errorStack + '\n```')
} }
> >
{t('common.error.copyErrorMessage')} {t('common.error.copyErrorMessage')}
</Button> </Button>
<p className="my-2">{error.message}</p> <p className="my-2">{errorMessage}</p>
<details title="Error Stack"> <details title="Error Stack">
<summary>Error Stack</summary> <summary>Error Stack</summary>
<pre>{error.stack}</pre> <pre>{errorStack}</pre>
</details> </details>
</div> </div>
) )

View File

@ -1,8 +1,10 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react' import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'
import * as d3 from 'd3' import * as d3 from 'd3'
import { Button } from '@heroui/react' import { Button } from '@heroui/react'
import { IoPauseOutline, IoPlayOutline, IoGitNetworkOutline } from 'react-icons/io5'
import { import {
IoPauseOutline,
IoPlayOutline,
IoGitNetworkOutline,
IoDesktopOutline, IoDesktopOutline,
IoServerOutline, IoServerOutline,
IoFunnelOutline IoFunnelOutline
@ -101,7 +103,8 @@ function buildHierarchy(
proxies: new Map() proxies: new Map()
}) })
} }
const groupEntry = groupsMap.get(group)! const groupEntry = groupsMap.get(group)
if (!groupEntry) continue
groupEntry.data.connections++ groupEntry.data.connections++
groupEntry.data.traffic += traffic groupEntry.data.traffic += traffic
@ -117,7 +120,8 @@ function buildHierarchy(
rules: new Map() rules: new Map()
}) })
} }
const proxyEntry = groupEntry.proxies.get(proxy)! const proxyEntry = groupEntry.proxies.get(proxy)
if (!proxyEntry) continue
proxyEntry.data.connections++ proxyEntry.data.connections++
proxyEntry.data.traffic += traffic proxyEntry.data.traffic += traffic
@ -133,7 +137,8 @@ function buildHierarchy(
clients: new Map() clients: new Map()
}) })
} }
const ruleEntry = proxyEntry.rules.get(fullRule)! const ruleEntry = proxyEntry.rules.get(fullRule)
if (!ruleEntry) continue
ruleEntry.data.connections++ ruleEntry.data.connections++
ruleEntry.data.traffic += traffic ruleEntry.data.traffic += traffic
@ -149,7 +154,8 @@ function buildHierarchy(
ports: new Map() ports: new Map()
}) })
} }
const clientEntry = ruleEntry.clients.get(clientIP)! const clientEntry = ruleEntry.clients.get(clientIP)
if (!clientEntry) continue
clientEntry.data.connections++ clientEntry.data.connections++
clientEntry.data.traffic += traffic clientEntry.data.traffic += traffic
@ -162,7 +168,8 @@ function buildHierarchy(
traffic: 0 traffic: 0
}) })
} }
const portNode = clientEntry.ports.get(sourcePort)! const portNode = clientEntry.ports.get(sourcePort)
if (!portNode) continue
portNode.connections++ portNode.connections++
portNode.traffic += traffic portNode.traffic += traffic
} }
@ -179,13 +186,16 @@ function buildHierarchy(
} }
groupsMap.forEach((groupEntry) => { groupsMap.forEach((groupEntry) => {
const groupNode: TopologyNodeData = { ...groupEntry.data, children: [] } const groupChildren: TopologyNodeData[] = []
const groupNode: TopologyNodeData = { ...groupEntry.data, children: groupChildren }
groupEntry.proxies.forEach((proxyEntry) => { groupEntry.proxies.forEach((proxyEntry) => {
const proxyNode: TopologyNodeData = { ...proxyEntry.data, children: [] } const proxyChildren: TopologyNodeData[] = []
const proxyNode: TopologyNodeData = { ...proxyEntry.data, children: proxyChildren }
proxyEntry.rules.forEach((ruleEntry) => { proxyEntry.rules.forEach((ruleEntry) => {
const ruleNode: TopologyNodeData = { ...ruleEntry.data, children: [] } const ruleChildren: TopologyNodeData[] = []
const ruleNode: TopologyNodeData = { ...ruleEntry.data, children: ruleChildren }
ruleEntry.clients.forEach((clientEntry) => { ruleEntry.clients.forEach((clientEntry) => {
const portChildren = Array.from(clientEntry.ports.values()) const portChildren = Array.from(clientEntry.ports.values())
@ -193,21 +203,21 @@ function buildHierarchy(
const clientNode: TopologyNodeData = isClientCollapsed const clientNode: TopologyNodeData = isClientCollapsed
? { ...clientEntry.data, _children: portChildren, children: undefined, collapsed: true } ? { ...clientEntry.data, _children: portChildren, children: undefined, collapsed: true }
: { ...clientEntry.data, children: portChildren, collapsed: false } : { ...clientEntry.data, children: portChildren, collapsed: false }
ruleNode.children!.push(clientNode) ruleChildren.push(clientNode)
}) })
const isRuleCollapsed = !collapsedNodes.has(`expanded-${ruleEntry.data.id}`) const isRuleCollapsed = !collapsedNodes.has(`expanded-${ruleEntry.data.id}`)
if (isRuleCollapsed && ruleNode.children!.length > 0) { if (isRuleCollapsed && ruleChildren.length > 0) {
ruleNode._children = ruleNode.children ruleNode._children = ruleChildren
ruleNode.children = undefined ruleNode.children = undefined
ruleNode.collapsed = true ruleNode.collapsed = true
} else { } else {
ruleNode.collapsed = false ruleNode.collapsed = false
} }
proxyNode.children!.push(ruleNode) proxyChildren.push(ruleNode)
}) })
groupNode.children!.push(applyCollapse(proxyNode)) groupChildren.push(applyCollapse(proxyNode))
}) })
rootChildren.push(applyCollapse(groupNode)) rootChildren.push(applyCollapse(groupNode))
@ -293,29 +303,26 @@ const NetworkTopologyCard: React.FC = () => {
// Toggle collapse // Toggle collapse
const toggleCollapseRef = useRef<(nodeId: string, isCollapsed: boolean) => void>(() => {}) const toggleCollapseRef = useRef<(nodeId: string, isCollapsed: boolean) => void>(() => {})
toggleCollapseRef.current = useCallback( toggleCollapseRef.current = useCallback((nodeId: string, isCurrentlyCollapsed: boolean) => {
(nodeId: string, isCurrentlyCollapsed: boolean) => { const expandedKey = `expanded-${nodeId}`
const expandedKey = `expanded-${nodeId}` setCollapsedNodes((prev) => {
setCollapsedNodes((prev) => { const next = new Set(prev)
const next = new Set(prev) if (isCurrentlyCollapsed) {
if (isCurrentlyCollapsed) { if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) {
if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) { next.add(expandedKey)
next.add(expandedKey)
} else {
next.delete(nodeId)
}
} else { } else {
if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) { next.delete(nodeId)
next.delete(expandedKey)
} else {
next.add(nodeId)
}
} }
return next } else {
}) if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) {
}, next.delete(expandedKey)
[] } else {
) next.add(nodeId)
}
}
return next
})
}, [])
// D3 render // D3 render
useEffect(() => { useEffect(() => {
@ -357,8 +364,7 @@ const NetworkTopologyCard: React.FC = () => {
} }
} }
const getNodeWidth = (d: d3.HierarchyNode<TopologyNodeData>) => const getNodeWidth = (d: d3.HierarchyNode<TopologyNodeData>) => nodeWidths.get(d.data.id) ?? 80
nodeWidths.get(d.data.id) ?? 80
// Max width per depth // Max width per depth
const maxWidthPerLevel = new Map<number, number>() const maxWidthPerLevel = new Map<number, number>()
@ -487,7 +493,7 @@ const NetworkTopologyCard: React.FC = () => {
.filter((d) => .filter((d) =>
Boolean( Boolean(
(d.data.children && d.data.children.length > 0) || (d.data.children && d.data.children.length > 0) ||
(d.data._children && d.data._children.length > 0) (d.data._children && d.data._children.length > 0)
) )
) )
.append('text') .append('text')
@ -513,8 +519,7 @@ const NetworkTopologyCard: React.FC = () => {
nodes nodes
.append('title') .append('title')
.text( .text(
(d) => (d) => `${d.data.name}\n${d.data.connections} connections\n${calcTraffic(d.data.traffic)}`
`${d.data.name}\n${d.data.connections} connections\n${calcTraffic(d.data.traffic)}`
) )
}, [hierarchyData, resolvedTheme]) }, [hierarchyData, resolvedTheme])
@ -555,13 +560,21 @@ const NetworkTopologyCard: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Stats */} {/* Stats */}
<div className="hidden flex-wrap gap-x-2 text-[12px] text-foreground/50 sm:flex"> <div className="hidden flex-wrap gap-x-2 text-[12px] text-foreground/50 sm:flex">
<span>{stats.clientCount} {t('network.topology.clients')}</span> <span>
{stats.clientCount} {t('network.topology.clients')}
</span>
<span>·</span> <span>·</span>
<span>{stats.ruleCount} {t('network.topology.rules')}</span> <span>
{stats.ruleCount} {t('network.topology.rules')}
</span>
<span>·</span> <span>·</span>
<span>{stats.groupCount} {t('network.topology.groups')}</span> <span>
{stats.groupCount} {t('network.topology.groups')}
</span>
<span>·</span> <span>·</span>
<span>{stats.proxyCount} {t('network.topology.nodes')}</span> <span>
{stats.proxyCount} {t('network.topology.nodes')}
</span>
<span>·</span> <span>·</span>
<span>{calcTraffic(stats.totalTraffic)}</span> <span>{calcTraffic(stats.totalTraffic)}</span>
</div> </div>

View File

@ -33,8 +33,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
const { overrideConfig } = useOverrideConfig() const { overrideConfig } = useOverrideConfig()
const { items: overrideItems = [] } = overrideConfig || {} const { items: overrideItems = [] } = overrideConfig || {}
const [values, setValues] = useState({ const [values, setValues] = useState({
...item, ...item
updateTimeout: item.updateTimeout ?? 5
}) })
const inputWidth = 'w-[400px] md:w-[400px] lg:w-[600px] xl:w-[800px]' const inputWidth = 'w-[400px] md:w-[400px] lg:w-[600px] xl:w-[800px]'
const { t } = useTranslation() const { t } = useTranslation()
@ -43,7 +42,6 @@ const EditInfoModal: React.FC<Props> = (props) => {
try { try {
const updatedItem = { const updatedItem = {
...values, ...values,
updateTimeout: values.updateTimeout ?? 5,
override: values.override?.filter( override: values.override?.filter(
(i) => (i) =>
overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global overrideItems.find((t) => t.id === i) && !overrideItems.find((t) => t.id === i)?.global
@ -215,7 +213,7 @@ const EditInfoModal: React.FC<Props> = (props) => {
value={values.updateTimeout?.toString() ?? ''} value={values.updateTimeout?.toString() ?? ''}
onValueChange={(v) => { onValueChange={(v) => {
if (v === '') { if (v === '') {
setValues({ ...values, updateTimeout: undefined as unknown as number }) setValues({ ...values, updateTimeout: undefined })
return return
} }
if (/^\d+$/.test(v)) { if (/^\d+$/.test(v)) {

View File

@ -1346,7 +1346,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
const originalIndex = ruleIndexMap.get(rule) ?? -1 const originalIndex = ruleIndexMap.get(rule) ?? -1
return `${originalIndex}-${index}` return `${originalIndex}-${index}`
}} }}
itemContent={(index, rule) => { itemContent={(_index, rule) => {
const originalIndex = ruleIndexMap.get(rule) ?? -1 const originalIndex = ruleIndexMap.get(rule) ?? -1
const isDeleted = deletedRules.has(originalIndex) const isDeleted = deletedRules.has(originalIndex)
const isPrependOrAppend = const isPrependOrAppend =

View File

@ -59,34 +59,34 @@ const RuleItem: React.FC<RuleItemProps> = (props) => {
</Chip> </Chip>
</div> </div>
</div> </div>
</div> </div>
{extra && (() => { {extra &&
const total = extra.hitCount + extra.missCount (() => {
const rate = total > 0 ? (extra.hitCount / total) * 100 : 0 const total = extra.hitCount + extra.missCount
return ( const rate = total > 0 ? (extra.hitCount / total) * 100 : 0
<> return (
<div className="flex items-center gap-3 text-xs shrink-0"> <>
<span className="text-foreground-500 whitespace-nowrap"> <div className="flex items-center gap-3 text-xs shrink-0">
{formatRelativeTime(extra.hitAt || extra.missAt)} <span className="text-foreground-500 whitespace-nowrap">
</span> {formatRelativeTime(extra.hitAt || extra.missAt)}
<span className="text-foreground-600 font-medium whitespace-nowrap"> </span>
{extra.hitCount}/{total} <span className="text-foreground-600 font-medium whitespace-nowrap">
</span> {extra.hitCount}/{total}
<Chip size="sm" variant="flat" color="primary" className="text-xs"> </span>
{rate.toFixed(1)}% <Chip size="sm" variant="flat" color="primary" className="text-xs">
</Chip> {rate.toFixed(1)}%
</div> </Chip>
<Switch </div>
size="sm" <Switch
isSelected={isEnabled} size="sm"
onValueChange={handleToggle} isSelected={isEnabled}
aria-label="Toggle rule" onValueChange={handleToggle}
/> aria-label="Toggle rule"
</> />
) </>
})()} )
})()}
</div> </div>
</CardBody> </CardBody>
</Card> </Card>

View File

@ -128,30 +128,27 @@ const ConnCard: React.FC<Props> = (props) => {
const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null
// 使用 useCallback 创建稳定的 handler 引用,通过 ref 读取 showTraffic 避免重建 // 使用 useCallback 创建稳定的 handler 引用,通过 ref 读取 showTraffic 避免重建
const handleTraffic = useCallback( const handleTraffic = useCallback((_e: unknown, ...args: unknown[]) => {
(_e: unknown, ...args: unknown[]) => { const info = args[0] as IMihomoTrafficInfo
const info = args[0] as IMihomoTrafficInfo setUpload(info.up)
setUpload(info.up) setDownload(info.down)
setDownload(info.down) setSeries((prev) => {
setSeries((prev) => { const data = [...prev]
const data = [...prev] data.shift()
data.shift() data.push(info.up + info.down)
data.push(info.up + info.down) return data
return data })
}) if (platform === 'darwin' && showTrafficRef.current) {
if (platform === 'darwin' && showTrafficRef.current) { const up = info.up
const up = info.up const down = info.down
const down = info.down if (up !== currentUploadRef.current || down !== currentDownloadRef.current) {
if (up !== currentUploadRef.current || down !== currentDownloadRef.current) { currentUploadRef.current = up
currentUploadRef.current = up currentDownloadRef.current = down
currentDownloadRef.current = down const png = renderTrafficIcon(up, down)
const png = renderTrafficIcon(up, down) window.electron.ipcRenderer.send('trayIconUpdate', png, true)
window.electron.ipcRenderer.send('trayIconUpdate', png, true)
}
} }
}, }
[] // eslint-disable-line react-hooks/exhaustive-deps }, [])
)
useEffect(() => { useEffect(() => {
window.electron.ipcRenderer.on('mihomoTraffic', handleTraffic) window.electron.ipcRenderer.on('mihomoTraffic', handleTraffic)
@ -295,7 +292,6 @@ const ConnCard: React.FC<Props> = (props) => {
export default ConnCard export default ConnCard
const trayIconBase64 = `data:image/png;base64,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` const trayIconBase64 = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsSAAALEgHS3X78AAAMu0lEQVR4nO1dQXLbuBJ9+fX39j+BNSew5wRBNtyxxtlxF+YEcU4wyglGOUHknXZxSjtthj5BlBOMfIKJTpC/QDNmLIDsBhsgKetVuSqhyAZIPDSARnfjxY8fP3DC88V/UheY5YVJXWYbsry4GroOQyI5AQCUA5TpRJYX5wCuh67HkEhKAOr9Y+pxVwDM0JUYEqk1gAFwmeXFLHG5PlwDeDl0JYZEagLUvb9MXO4BSP2X9O8xaaWkGEIDAMCfI/jocwBn9G8zXDWGRTICUIOfNS4tqRcmR5YXJYB3jUtDk3EwpNQATz/yJYAqNQmo8T89uWxS1mFMSEkA47hWk2CWogJZXtzgsPEB4GJEE9Ok+G/Csnxq9hLANsuL+Wa9WsQomBp3ifYZ/xWAXYzyJfAYyrab9ep7jPJepDAFk5r/l3HrA4D5Zr1aKpU7g53svWHc/nGzXt1olMtFwxB1DUvAi5bb9wAqAHcA7rQIkYoABsDfgkf2oBcFUElelhr9GnaJdyko836zXhnB/cEQEtOHWwDLzXpV9alLKgLMAfzZQ8Q3WPW8pf/v6M807jGwvai50hBhs169CH2WA+rxC/Rr+Kf4AuBms17tQh5ORYAK07C4verbo3zI8uIadh4STNAW7AGUm/XqTvpgqlXAFBofiGQPIA34GXEaHyT3c5YX4kl0dAKMwOIngdEWmOXFEv2GPwneUXlspNAAJkEZWjCawqjna473HLyRkCAFAaakAc60DEI05qfq+U/xhiyenThpgEOYvgJotr/sXZN++MQZfqMSgHpTm3FjjNDQWEvEm/BJ0DkpjK0BpqT+a5g+D5PR6w+VmvTHy66hIDYBTGT5MXDZc4cyqTmZgXnbjycN4EZQvWnIG0vvr3HR5okdmwBTMQA9hQl8bqwexqXvh2gEGJv/vxAm8LmxEsD4foipAaaq/oHwuo9V43kdXmISwESUHRtnUhP2BEzezvqNgQD3AN4C+B3A/wC8AvAe1jmkD24BvAbwW0PuR9idMw6MsLxBHFwFcBIgiksYqZsuQ4hvC7OivwXtbr2DDA8Arjfr1fbJ9QrW/3AOa6jpmq1Le/TYNYATsTSA6fh9D8B07V+Ti9Z7QbkPAK4cjd+U+X2zXl3DOlK0wQjKBcavAZyIRYCu3rBoa6QmyFH0nlluKXAfK9E+HFxwDUKk8cZmAGJhCA2w36xXc6E8zv33Em8eIkqXXNP2Y5YX52RqvcM4bP9iqBOAek2bM6bYbYkatmvyJpbLeMaryajh/4WNM5A4nw6FnetiDA3Qpf53gXK7hgzWkNIEw5HSuC6SyncFmIwZO9fFGAQwEWQOBZ9hZ3ITPt/wOAQBZoFyuzSLeBnG8f5xmbRpAsu1J4wB3kn0EEOA2F5OjdA1yQqxw3Oe8b1PFVDeUFj6flAlgCME3IUzCtKUYM6456VkA4omq5x6+GTO8agF9rB2hTFqhTrKygltDcBVw3Ou7ZzIwt1kkeQcWIDnruas52a92m7Wq3PYYJJzMi7NAHxglp8KrXGEqpFB5I7MdYPujGYJDCn7RnKdq4KGw6bEceM3SehVlhc7jMMXcg9rGd35btDeCzCCe+tolnvYBtlu1qttI5PYDcI+4iWAr1le3MKqvi2A7ySzDhqVGm0MZF6+JWTBsLGw6CKumgYQhIBPEeLQ8cCNLE2wop015wCdhU0YJuCZOexwNAT2YK6KNAkwye1QJsSmXpp4GaQnQb3TytoUO2kAJkJ8HAcgQd34bLO4JgHG6g+nBRPyUIMEXf4HffENHb4QLqisAibuAcxF8BBHJLgmm8Yc+lvHHwK22AHoaYBjHv9rmL4CyLllBplvYhtuYW0U81ABKsvALC/uML6ImBj4XapifWjkKi4hm2R+g7VvLEPzAjWhZQh6DhoAsO+pQgAaFhawzq/neExydY5fv+cOjwmythqN3kRvDUBbqv+o1Gb8uN2sV+XQldCExhzAKMiYCo5O02kQ4Og+Sgv6ho6PDicNIIcZugKa0CDAFDxiNXFUGq8XAZ6JAegpzNAV0ERfDWA0KjExHJXJuy8BjkodcjGBUHA2ThogDGboCmghmABMD+BjxUkD4Ig+QgDM0BXQQh8CGK1KTBBHc8jUSQOE4yjeP4gAjBDw5wAzdAU0EKoBjGYlJornqwFwJC/fE0dhEDppgB44BlN4KAGOgv0KMENXoC/ELmHHZAZVgPNbUFBrjR0e07PstF26+iLEJ9BoV2LCMJ7r1/CskrK8AKxj55b+7oYkRcgQcNIAj/AdMtXlOHoJG0b/F4B/srzYZXmxGEK7hhDAaFdi4jCOa5VQxgVsJPHXLC+23BO/NCAiwEQPgYoN47hW9ZB3CXvi1y4FEaQa4KT+D3HwTWhM7xv5cwFLhG3M5aaUACZGJSYOn6dwpSUfwN80R1D3SD5pAB24votKBFED72DT3au2gZQAJwOQG8ZxbQF7SEX99xo2g9gXhB+GcQlLArWzidihYTQOjSHx0RjBysfTBPVkA3lwaI23m/VqGfDcL5BoANO3sCOGWC1TnsHFZr26gj0u51Yo4pPGKkFCgNP474f4kKkmiAwl7PlGEiL0JkEsDfAFv45/HzBcxiwO9rAf/i0e6/wesqWc6VuJzXq1IyK8An+ewDol3AfWHEAYAu4dD6miN+BnE42NB9iULc50qjTZ+syUpRo6Tku+BXjfag9gJjgu5ye4GsAIZFa+HxqqTsLwWPgIm1Rp6ftwXYdaPYFRqdVj2d/pW71l3H6GsBNT2ARQHf/p8IIryCc+GtjDJni+6eoxQsML+5ApCWimzyFB51HxLsTQADccl+kGw1OSoM6jVzHvXwjlG+H9LAhIILYWds4BAnMA72GTK3+HHV9brWLCLOOh6EyiSO9ap303kBu+xDmFJWBmTxeljOMQwKC/AegBwLzNcJEg05g3wxdprDn6k1BsEJIiy4sK7cQUTQg5Q4DhCOpAvbNVtaioEvEmhu9bGr+EtdtraKAUpvIS7cvTMwiOz0lFgBovYW3ZByQgxpaKZdW4pwSNByCV+gmKQa6xPYVpq7lrbsIehjgE0LYA1hsaLhJU0J8Ulq6L1POlp5FwkMJiukC7FrjkGodaCRAxBPwS/hM45orl3LocLqmXxjr40USS+xOkLZca9ejSACwhgfjDtW6lBtPSAvOnFxpnBsVCqj2TrmFA5cCI2C/jW7fOFWQ7ez/CzyLiIknoOL1b2/4Ka0I6pAYA7PByMGGhl+ubX/+ghwjOCuwLk6AMoMPtjDMP8BKAPlYKD+AbjxZY9pD5zbPsu0GatDaphoGq4/dZl4A2DWAEFekD57qVNmJCPWuXnutloDwpTKJydh2/h2sAzsOKKD3Xg3a4XM/R1m6qmIYkyTM0zi4YgwYA7E7WzHG9CpD14Jn8qTlScjCV0PE2AqT2ADaOa1WAHJ/WcMmPidTlBcFJgIHY65oH7CDfHzhQizQbTh3SZhKXFwSfBpilrATBN+eohHJc9xuhDA3MBihTjDERwGdAkUx09p7xfwiP5ugaR8PgpHlwpAb6hlj57j1Wl/ZZx++7LgHPhQDHmtPQdPy+6xIwNgLMnl4Qujq7XLuPtfcDHUtbju/j6AlAuGc+XzmuHdUhTzVo/G/TbKxAHB8BtEObuYjRW4fSALEjocqO3yuOEB8BdoKKaMK3UcMlpOu+oTTALpZg5q7mkiPLSQCyMWscbqwF1jwgJDQqIqqIsrt2NR+4+wRtc4BKUiMtRJi0GWV5XFQxhNLYr9L7gXYChO7E9UWoyh6TxmL3wAAs0e3TwI5o8hKAgjjG8lErxj1DTVxdkIaUsUARVF2bdB8lQ2HXMjDKixw56rA4VWR5wQkV30PoT8khwFi0wFSw0J6MUs9/x7h1Li27lQAkbC4R+MzxAEWtmeXFVZYX3LA1bwRUGzotgSSUa4nTgGsS2GctP+vxrBSlRu/P8mJGvf4rePsYewR6PHHTxV/DGjZSeNTe4HAFUjKee5nlxVVz9k2OLakcQT4I8g78goYDjkFLqnkP6rD3IOJJ8gRewc7GU5DgC+zQUw9B3MjdPSxZtrAfc4E09fXmByIVHnM3sle+QDYBgOQkmAraGn+OOAGoAJFdmMfoACICAD9JcIdT2nigJSMIfaevkcp9AHAd2y3cCSr0Cv1Dt6aMPYDXLY1/jniW1C+w2c1UDF9iDdAEBVss8Ly0wS0Ab4YxavwK+uP+A5WrSqxeBKhBYd5zHDcRbmENLTvfDZEavzO/Uh+oEKAGjXsl7Ax86n54e9jGvIMnk2gTyo3/jWQtI24qAVAmwFM01rdXmIZr1hZ26Sk+34/e1fQouwJ4fnyaiEqAE8aPsTmFnpAY/wf5GzQ3i3FS8AAAAABJRU5ErkJggg==`
// 固定宽高,同步渲染避免闪烁 // 固定宽高,同步渲染避免闪烁
@ -317,7 +313,10 @@ function renderTrafficIcon(upload: number, download: number): string {
trafficCanvas.height = ICON_H trafficCanvas.height = ICON_H
trafficCtx = trafficCanvas.getContext('2d') trafficCtx = trafficCanvas.getContext('2d')
} }
const ctx = trafficCtx! if (!trafficCtx) {
return trayIconBase64
}
const ctx = trafficCtx
ctx.clearRect(0, 0, ICON_W, ICON_H) ctx.clearRect(0, 0, ICON_W, ICON_H)
if (trafficIconLoaded) { if (trafficIconLoaded) {
ctx.drawImage(trafficIcon, 0, 0, ICON_H, ICON_H) ctx.drawImage(trafficIcon, 0, 0, ICON_H, ICON_H)

View File

@ -708,7 +708,6 @@
"network.topology.waiting": "Waiting for connections...", "network.topology.waiting": "Waiting for connections...",
"network.topology.pause": "Pause", "network.topology.pause": "Pause",
"network.topology.resume": "Resume", "network.topology.resume": "Resume",
"guide.end.description": "Now that you understand the basic usage of the software, import your subscription and start using it. Enjoy!\nYou can also join our official <a href=\"https://t.me/mihomo_party_group\" target=\"_blank\">Telegram group</a> for the latest news.",
"settings.githubProxy": "GitHub Download Proxy", "settings.githubProxy": "GitHub Download Proxy",
"settings.githubProxy.auto": "Auto (proxy first)", "settings.githubProxy.auto": "Auto (proxy first)",
"settings.githubProxy.direct": "Direct" "settings.githubProxy.direct": "Direct"

View File

@ -50,6 +50,7 @@ const Connections: React.FC = () => {
const { 'find-process-mode': findProcessMode = 'always' } = controledMihomoConfig || {} const { 'find-process-mode': findProcessMode = 'always' } = controledMihomoConfig || {}
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const { appConfig, patchAppConfig } = useAppConfig() const { appConfig, patchAppConfig } = useAppConfig()
const appConfigValues: Partial<IAppConfig> = appConfig ?? {}
const { const {
connectionDirection = 'asc', connectionDirection = 'asc',
connectionOrderBy = 'time', connectionOrderBy = 'time',
@ -73,7 +74,7 @@ const Connections: React.FC = () => {
connectionTableSortDirection, connectionTableSortDirection,
displayIcon = true, displayIcon = true,
displayAppName = true displayAppName = true
} = appConfig || {} } = appConfigValues
const [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>() const [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>()
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections) const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
const [activeConnections, setActiveConnections] = useState<IMihomoConnectionDetail[]>([]) const [activeConnections, setActiveConnections] = useState<IMihomoConnectionDetail[]>([])

View File

@ -123,10 +123,11 @@ const DNS: React.FC = () => {
const handleSubkeyChange = (type: string, domain: string, value: string, index: number): void => { const handleSubkeyChange = (type: string, domain: string, value: string, index: number): void => {
const list = [...values[type]] const list = [...values[type]]
const parts = value.split(',').map((s: string) => s.trim()).filter(Boolean) const parts = value
const processedValue = type === 'hosts' .split(',')
? parts .map((s: string) => s.trim())
: (parts.length > 1 ? parts : value.trim()) .filter(Boolean)
const processedValue = type === 'hosts' ? parts : parts.length > 1 ? parts : value.trim()
if (domain || parts.length > 0) list[index] = { domain: domain.trim(), value: processedValue } if (domain || parts.length > 0) list[index] = { domain: domain.trim(), value: processedValue }
else list.splice(index, 1) else list.splice(index, 1)
setValues({ ...values, [type]: list }) setValues({ ...values, [type]: list })

View File

@ -196,10 +196,10 @@ const IPPage: React.FC = () => {
const averageLatency = (() => { const averageLatency = (() => {
const successes = LATENCY_TARGETS.map((t) => latencyResults[t.url]).filter( const successes = LATENCY_TARGETS.map((t) => latencyResults[t.url]).filter(
(r) => r?.status === 'success' && r.latency !== null (r): r is LatencyResult & { latency: number } => r?.status === 'success' && r.latency !== null
) )
if (successes.length === 0) return null if (successes.length === 0) return null
return Math.round(successes.reduce((acc, r) => acc + (r!.latency ?? 0), 0) / successes.length) return Math.round(successes.reduce((acc, r) => acc + r.latency, 0) / successes.length)
})() })()
const fetchIP = useCallback( const fetchIP = useCallback(
@ -292,7 +292,9 @@ const IPPage: React.FC = () => {
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{/* IP 地址高亮行(负 margin 贴边) */} {/* IP 地址高亮行(负 margin 贴边) */}
<div className="-mx-1 -mt-1 mb-1 flex items-center justify-between gap-3 rounded-lg border border-primary/20 bg-primary/8 px-2.5 py-2"> <div className="-mx-1 -mt-1 mb-1 flex items-center justify-between gap-3 rounded-lg border border-primary/20 bg-primary/8 px-2.5 py-2">
<span className="shrink-0 text-[13px] text-foreground/60">{t('network.ipAddress')}</span> <span className="shrink-0 text-[13px] text-foreground/60">
{t('network.ipAddress')}
</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="overflow-hidden text-right font-mono text-[13px] font-semibold text-primary text-ellipsis whitespace-nowrap"> <span className="overflow-hidden text-right font-mono text-[13px] font-semibold text-primary text-ellipsis whitespace-nowrap">
{hidden ? '••••••••••••••' : ipInfo.ip} {hidden ? '••••••••••••••' : ipInfo.ip}

View File

@ -401,13 +401,14 @@ const Proxies: React.FC = () => {
groupCounts, groupCounts,
isOpen, isOpen,
proxyDisplayMode, proxyDisplayMode,
t,
searchValue, searchValue,
delaying, delaying,
cols, mutate,
allProxies,
virtuosoRef,
t,
setIsOpen, setIsOpen,
allProxies,
cols,
virtuosoRef,
onGroupDelay onGroupDelay
] ]
) )

View File

@ -258,6 +258,8 @@ interface IAppConfig {
connectionTableColumnWidths?: Record<string, number> connectionTableColumnWidths?: Record<string, number>
connectionTableSortColumn?: string connectionTableSortColumn?: string
connectionTableSortDirection?: 'asc' | 'desc' connectionTableSortDirection?: 'asc' | 'desc'
displayIcon?: boolean
displayAppName?: boolean
spinFloatingIcon?: boolean spinFloatingIcon?: boolean
disableTray?: boolean disableTray?: boolean
swapTrayClick?: boolean swapTrayClick?: boolean