feat: show app icons in connections page

This commit is contained in:
Memory 2026-01-25 14:59:48 +08:00 committed by GitHub
parent d3a23a0601
commit e9c72ce448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 942 additions and 74 deletions

View File

@ -34,14 +34,19 @@
},
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
"@types/plist": "^3.0.5",
"adm-zip": "^0.5.16",
"axios": "^1.13.2",
"chokidar": "^5.0.0",
"croner": "^9.1.0",
"crypto-js": "^4.2.0",
"express": "^5.1.0",
"file-icon": "^6.0.0",
"file-icon-info": "^1.1.1",
"i18next": "^25.6.2",
"iconv-lite": "^0.7.1",
"js-yaml": "^4.1.1",
"plist": "^3.1.0",
"sysproxy-rs": "file:src/native/sysproxy",
"webdav": "^5.8.0",
"ws": "^8.18.3",

42
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ importers:
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@37.10.0)
'@types/plist':
specifier: ^3.0.5
version: 3.0.5
adm-zip:
specifier: ^0.5.16
version: 0.5.16
@ -29,12 +32,24 @@ importers:
express:
specifier: ^5.1.0
version: 5.2.1
file-icon:
specifier: ^6.0.0
version: 6.0.0
file-icon-info:
specifier: ^1.1.1
version: 1.1.1
i18next:
specifier: ^25.6.2
version: 25.7.3(typescript@5.9.3)
iconv-lite:
specifier: ^0.7.1
version: 0.7.1
js-yaml:
specifier: ^4.1.1
version: 4.1.1
plist:
specifier: ^3.1.0
version: 3.1.0
sysproxy-rs:
specifier: file:src/native/sysproxy
version: file:src/native/sysproxy
@ -2180,6 +2195,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@22.19.3':
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
@ -3196,6 +3214,13 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-icon-info@1.1.1:
resolution: {integrity: sha512-yM76gzXQwRzFq0p4h9iTROr2XvsMTSWWxffBIZGD1yTc/r3ayxPC0GBvQYyoPveVABre5ArfZktyfF2KJC+kuw==}
file-icon@6.0.0:
resolution: {integrity: sha512-cNWEJlqKoqcCt8v9ybKL1k69oHqbmEdNSHKUI2o/RmrCiBnG+yJXThfIeljiqfEs/PW1HeazJj2/SJABhtaawQ==}
engines: {node: '>=20'}
filelist@1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
@ -4260,6 +4285,10 @@ packages:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
p-map@7.0.4:
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
engines: {node: '>=18'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -7911,6 +7940,8 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@10.17.60': {}
'@types/node@22.19.3':
dependencies:
undici-types: 6.21.0
@ -7923,7 +7954,6 @@ snapshots:
dependencies:
'@types/node': 25.0.3
xmlbuilder: 15.1.1
optional: true
'@types/pubsub-js@1.8.6': {}
@ -9301,6 +9331,14 @@ snapshots:
dependencies:
flat-cache: 4.0.1
file-icon-info@1.1.1:
dependencies:
'@types/node': 10.17.60
file-icon@6.0.0:
dependencies:
p-map: 7.0.4
filelist@1.0.4:
dependencies:
minimatch: 5.1.6
@ -10541,6 +10579,8 @@ snapshots:
dependencies:
aggregate-error: 3.1.0
p-map@7.0.4: {}
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:

63
src/main/utils/appName.ts Normal file
View File

@ -0,0 +1,63 @@
import fs from 'fs'
import path from 'path'
import plist from 'plist'
import { findBestAppPath, isIOSApp } from './icon'
import { spawnSync } from 'child_process'
export async function getAppName(appPath: string): Promise<string> {
if (process.platform === 'darwin') {
try {
const targetPath = findBestAppPath(appPath)
if (!targetPath) return ''
if (isIOSApp(targetPath)) {
const plistPath = path.join(targetPath, 'Info.plist')
const xml = fs.readFileSync(plistPath, 'utf-8')
const parsed = plist.parse(xml) as Record<string, unknown>
return (parsed.CFBundleDisplayName as string) || (parsed.CFBundleName as string) || ''
}
try {
const appName = getLocalizedAppName(targetPath)
if (appName) return appName
} catch (err) {
// ignore
}
const plistPath = path.join(targetPath, 'Contents', 'Info.plist')
if (fs.existsSync(plistPath)) {
const xml = fs.readFileSync(plistPath, 'utf-8')
const parsed = plist.parse(xml) as Record<string, unknown>
return (parsed.CFBundleDisplayName as string) || (parsed.CFBundleName as string) || ''
} else {
// ignore
}
} catch (err) {
// ignore
}
}
return ''
}
function getLocalizedAppName(appPath: string): string {
const escapedPath = appPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
const jxa = `
ObjC.import('Foundation');
const fm = $.NSFileManager.defaultManager;
const name = fm.displayNameAtPath('${escapedPath}');
name.js;
`
const res = spawnSync('osascript', ['-l', 'JavaScript'], {
input: jxa,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
})
if (res.error) {
throw res.error
}
if (res.status !== 0) {
throw new Error(res.stderr.trim() || `osascript exited ${res.status}`)
}
return res.stdout.trim()
}

File diff suppressed because one or more lines are too long

287
src/main/utils/icon.ts Normal file
View File

@ -0,0 +1,287 @@
import { exec } from 'child_process'
import fs, { existsSync } from 'fs'
import os from 'os'
import path from 'path'
import crypto from 'crypto'
import axios from 'axios'
import { getIcon } from 'file-icon-info'
import { app } from 'electron'
import { getControledMihomoConfig } from '../config'
import { windowsDefaultIcon, darwinDefaultIcon, otherDevicesIcon } from './defaultIcon'
export function isIOSApp(appPath: string): boolean {
const appDir = appPath.endsWith('.app')
? appPath
: appPath.includes('.app')
? appPath.substring(0, appPath.indexOf('.app') + 4)
: path.dirname(appPath)
return !fs.existsSync(path.join(appDir, 'Contents'))
}
function hasIOSAppIcon(appPath: string): boolean {
try {
const items = fs.readdirSync(appPath)
return items.some((item) => {
const lower = item.toLowerCase()
const ext = path.extname(item).toLowerCase()
return lower.startsWith('appicon') && (ext === '.png' || ext === '.jpg' || ext === '.jpeg')
})
} catch {
return false
}
}
function hasMacOSAppIcon(appPath: string): boolean {
const resourcesDir = path.join(appPath, 'Contents', 'Resources')
if (!fs.existsSync(resourcesDir)) {
return false
}
try {
const items = fs.readdirSync(resourcesDir)
return items.some((item) => path.extname(item).toLowerCase() === '.icns')
} catch {
return false
}
}
export function findBestAppPath(appPath: string): string | null {
if (!appPath.includes('.app') && !appPath.includes('.xpc')) {
return null
}
const parts = appPath.split(path.sep)
const appPaths: string[] = []
for (let i = 0; i < parts.length; i++) {
if (parts[i].endsWith('.app') || parts[i].endsWith('.xpc')) {
const fullPath = parts.slice(0, i + 1).join(path.sep)
appPaths.push(fullPath)
}
}
if (appPaths.length === 0) {
return null
}
if (appPaths.length === 1) {
return appPaths[0]
}
for (let i = appPaths.length - 1; i >= 0; i--) {
const appDir = appPaths[i]
if (isIOSApp(appDir)) {
if (hasIOSAppIcon(appDir)) {
return appDir
}
} else {
if (hasMacOSAppIcon(appDir)) {
return appDir
}
}
}
return appPaths[0]
}
async function findDesktopFile(appPath: string): Promise<string | null> {
try {
const execName = path.isAbsolute(appPath) ? path.basename(appPath) : appPath
const desktopDirs = ['/usr/share/applications', `${process.env.HOME}/.local/share/applications`]
for (const dir of desktopDirs) {
if (!existsSync(dir)) continue
const files = fs.readdirSync(dir)
const desktopFiles = files.filter((file) => file.endsWith('.desktop'))
for (const file of desktopFiles) {
const fullPath = path.join(dir, file)
try {
const content = fs.readFileSync(fullPath, 'utf-8')
const execMatch = content.match(/^Exec\s*=\s*(.+?)$/m)
if (execMatch) {
const execLine = execMatch[1].trim()
const execCmd = execLine.split(/\s+/)[0]
const execBasename = path.basename(execCmd)
if (
execCmd === appPath ||
execBasename === execName ||
execCmd.endsWith(appPath) ||
appPath.endsWith(execBasename)
) {
return fullPath
}
}
const nameRegex = new RegExp(`^Name\\s*=\\s*${appPath}\\s*$`, 'im')
const genericNameRegex = new RegExp(`^GenericName\\s*=\\s*${appPath}\\s*$`, 'im')
if (nameRegex.test(content) || genericNameRegex.test(content)) {
return fullPath
}
} catch {
continue
}
}
}
} catch {
// ignore
}
return null
}
function parseIconNameFromDesktopFile(content: string): string | null {
const match = content.match(/^Icon\s*=\s*(.+?)$/m)
return match ? match[1].trim() : null
}
function resolveIconPath(iconName: string): string | null {
if (path.isAbsolute(iconName) && existsSync(iconName)) {
return iconName
}
const searchPaths: string[] = []
const sizes = ['512x512', '256x256', '128x128', '64x64', '48x48', '32x32', '24x24', '16x16']
const extensions = ['png', 'svg', 'xpm']
const iconDirs = [
'/usr/share/icons/hicolor',
'/usr/share/pixmaps',
'/usr/share/icons/Adwaita',
`${process.env.HOME}/.local/share/icons`
]
for (const dir of iconDirs) {
for (const size of sizes) {
for (const ext of extensions) {
searchPaths.push(path.join(dir, size, 'apps', `${iconName}.${ext}`))
}
}
}
for (const ext of extensions) {
searchPaths.push(`/usr/share/pixmaps/${iconName}.${ext}`)
}
for (const dir of iconDirs) {
for (const ext of extensions) {
searchPaths.push(path.join(dir, `${iconName}.${ext}`))
}
}
return searchPaths.find((iconPath) => existsSync(iconPath)) || null
}
export async function getIconDataURL(appPath: string): Promise<string> {
if (!appPath) {
return otherDevicesIcon
}
if (appPath === 'mihomo') {
appPath = app.getPath('exe')
}
if (process.platform === 'darwin') {
if (!appPath.includes('.app') && !appPath.includes('.xpc')) {
return darwinDefaultIcon
}
const { fileIconToBuffer } = await import('file-icon')
const targetPath = findBestAppPath(appPath)
if (!targetPath) {
return darwinDefaultIcon
}
const iconBuffer = await fileIconToBuffer(targetPath, { size: 512 })
const base64Icon = Buffer.from(iconBuffer).toString('base64')
return `data:image/png;base64,${base64Icon}`
}
if (process.platform === 'win32') {
if (fs.existsSync(appPath) && /\.(exe|dll)$/i.test(appPath)) {
try {
let targetPath = appPath
let tempLinkPath: string | null = null
if (/[\u4e00-\u9fff]/.test(appPath)) {
const tempDir = os.tmpdir()
const randomName = crypto.randomBytes(8).toString('hex')
const fileExt = path.extname(appPath)
tempLinkPath = path.join(tempDir, `${randomName}${fileExt}`)
try {
await new Promise<void>((resolve) => {
exec(`mklink "${tempLinkPath}" "${appPath}"`, (error) => {
if (!error && tempLinkPath && fs.existsSync(tempLinkPath)) {
targetPath = tempLinkPath
}
resolve()
})
})
} catch {
// ignore mklink errors
}
}
try {
const iconBuffer = await new Promise<Buffer>((resolve, reject) => {
getIcon(targetPath, (b64d) => {
try {
resolve(Buffer.from(b64d, 'base64'))
} catch (error) {
reject(error)
}
})
})
return `data:image/png;base64,${iconBuffer.toString('base64')}`
} finally {
if (tempLinkPath && fs.existsSync(tempLinkPath)) {
try {
fs.unlinkSync(tempLinkPath)
} catch {
// ignore cleanup errors
}
}
}
} catch {
return windowsDefaultIcon
}
} else {
return windowsDefaultIcon
}
} else if (process.platform === 'linux') {
const desktopFile = await findDesktopFile(appPath)
if (desktopFile) {
const content = fs.readFileSync(desktopFile, 'utf-8')
const iconName = parseIconNameFromDesktopFile(content)
if (iconName) {
const iconPath = resolveIconPath(iconName)
if (iconPath) {
try {
const iconBuffer = fs.readFileSync(iconPath)
return `data:image/png;base64,${iconBuffer.toString('base64')}`
} catch {
return darwinDefaultIcon
}
}
}
} else {
return darwinDefaultIcon
}
}
return ''
}
export async function getImageDataURL(url: string): Promise<string> {
const { 'mixed-port': port = 7890 } = await getControledMihomoConfig()
const res = await axios.get(url, {
responseType: 'arraybuffer',
...(port !== 0 && {
proxy: {
protocol: 'http',
host: '127.0.0.1',
port
}
})
})
const mimeType = res.headers['content-type']
const dataURL = `data:${mimeType};base64,${Buffer.from(res.data).toString('base64')}`
return dataURL
}

View File

@ -122,6 +122,8 @@ import { startMonitor } from '../resolve/trafficMonitor'
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
import { getImageDataURL } from './image'
import { getIconDataURL } from './icon'
import { getAppName } from './appName'
import { logDir, rulePath } from './dirs'
import { installMihomoCore, getGitHubTags, clearVersionCache } from './github'
@ -321,6 +323,8 @@ const asyncHandlers: Record<string, AsyncFn> = {
// Misc
getGistUrl,
getImageDataURL,
getIconDataURL,
getAppName,
changeLanguage,
setTitleBarOverlay,
registerShortcut

View File

@ -146,6 +146,8 @@ const validInvokeChannels = [
// Misc
'getGistUrl',
'getImageDataURL',
'getIconDataURL',
'getAppName',
'changeLanguage'
] as const

View File

@ -1,34 +1,126 @@
import { Button, Card, CardFooter, CardHeader, Chip } from '@heroui/react'
import { Avatar, Button, Card, CardFooter, CardHeader, Chip } from '@heroui/react'
import { calcTraffic } from '@renderer/utils/calc'
import dayjs from '@renderer/utils/dayjs'
import React from 'react'
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { CgClose, CgTrash } from 'react-icons/cg'
interface Props {
index: number
info: IMihomoConnectionDetail
displayIcon?: boolean
iconUrl: string
displayName?: string
selected: IMihomoConnectionDetail | undefined
setSelected: React.Dispatch<React.SetStateAction<IMihomoConnectionDetail | undefined>>
setIsDetailModalOpen: React.Dispatch<React.SetStateAction<boolean>>
close: (id: string) => void
}
const ConnectionItem: React.FC<Props> = (props) => {
const { index, info, close, setSelected, setIsDetailModalOpen } = props
const ConnectionItemComponent: React.FC<Props> = ({
index,
info,
displayIcon,
iconUrl,
displayName,
close,
setSelected,
setIsDetailModalOpen
}) => {
const fallbackProcessName = useMemo(
() => info.metadata.process?.replace(/\.exe$/, '') || info.metadata.sourceIP,
[info.metadata.process, info.metadata.sourceIP]
)
const processName = displayName || fallbackProcessName
const destination = useMemo(
() =>
info.metadata.host ||
info.metadata.sniffHost ||
info.metadata.destinationIP ||
info.metadata.remoteDestination,
[
info.metadata.host,
info.metadata.sniffHost,
info.metadata.destinationIP,
info.metadata.remoteDestination
]
)
const [timeAgo, setTimeAgo] = useState(() => dayjs(info.start).fromNow())
useEffect(() => {
const timer = setInterval(() => {
setTimeAgo(dayjs(info.start).fromNow())
}, 60000)
return () => clearInterval(timer)
}, [info.start])
const uploadTraffic = useMemo(() => calcTraffic(info.upload), [info.upload])
const downloadTraffic = useMemo(() => calcTraffic(info.download), [info.download])
const uploadSpeed = useMemo(
() => (info.uploadSpeed ? calcTraffic(info.uploadSpeed) : null),
[info.uploadSpeed]
)
const downloadSpeed = useMemo(
() => (info.downloadSpeed ? calcTraffic(info.downloadSpeed) : null),
[info.downloadSpeed]
)
const hasSpeed = useMemo(
() => Boolean(info.uploadSpeed || info.downloadSpeed),
[info.uploadSpeed, info.downloadSpeed]
)
const handleCardPress = useCallback(() => {
setSelected(info)
setIsDetailModalOpen(true)
}, [info, setSelected, setIsDetailModalOpen])
const handleClose = useCallback(() => {
close(info.id)
}, [close, info.id])
return (
<div className={`px-2 pb-2 ${index === 0 ? 'pt-2' : ''}`}>
<div className="relative">
<Card
isPressable
className="w-full"
onPress={() => {
setSelected(info)
setIsDetailModalOpen(true)
}}
>
<div className="w-full">
<div className="w-full pr-12">
<CardHeader className="pb-0 gap-1">
<div className={`px-2 pb-2 ${index === 0 ? 'pt-2' : ''}`} style={{ minHeight: 80 }}>
<Card as="div" isPressable className="w-full" onPress={handleCardPress}>
<div className="w-full flex justify-between items-center">
{displayIcon && (
<div>
<Avatar
size="lg"
radius="sm"
src={iconUrl}
className="bg-transparent ml-2 w-14 h-14"
/>
</div>
)}
<div
className={`w-full flex flex-col justify-start truncate relative ${displayIcon ? '-ml-2' : ''}`}
>
<CardHeader className="pb-0 gap-1 flex items-center pr-12 relative">
<div className="ml-2 flex-1 text-ellipsis whitespace-nowrap overflow-hidden text-left">
<span style={{ textAlign: 'left' }}>
{processName} {destination}
</span>
</div>
<small className="ml-2 whitespace-nowrap text-foreground-500">{timeAgo}</small>
<Button
color={info.isActive ? 'warning' : 'danger'}
variant="light"
isIconOnly
size="sm"
className="absolute right-2 transform"
onPress={handleClose}
>
{info.isActive ? <CgClose className="text-lg" /> : <CgTrash className="text-lg" />}
</Button>
</CardHeader>
<CardFooter className="pt-2">
<div className="flex gap-1 overflow-x-auto no-scrollbar">
<Chip
color={info.isActive ? 'primary' : 'danger'}
size="sm"
@ -37,26 +129,8 @@ const ConnectionItem: React.FC<Props> = (props) => {
>
{info.metadata.type}({info.metadata.network.toUpperCase()})
</Chip>
<div className="text-ellipsis whitespace-nowrap overflow-hidden">
{info.metadata.process || info.metadata.sourceIP}
{' -> '}
{info.metadata.host ||
info.metadata.sniffHost ||
info.metadata.destinationIP ||
info.metadata.remoteDestination}
</div>
<small className="whitespace-nowrap text-foreground-500">
{dayjs(info.start).fromNow()}
</small>
</CardHeader>
<CardFooter
onWheel={(e) => {
e.currentTarget.scrollLeft += e.deltaY
}}
className="overscroll-contain pt-2 flex justify-start gap-1 overflow-x-auto no-scrollbar"
>
<Chip
className="flag-emoji text-ellipsis whitespace-nowrap overflow-hidden"
className="flag-emoji whitespace-nowrap overflow-hidden"
size="sm"
radius="sm"
variant="bordered"
@ -64,33 +138,35 @@ const ConnectionItem: React.FC<Props> = (props) => {
{info.chains[0]}
</Chip>
<Chip size="sm" radius="sm" variant="bordered">
{calcTraffic(info.upload)} {calcTraffic(info.download)}
{uploadTraffic} {downloadTraffic}
</Chip>
{info.uploadSpeed !== 0 || info.downloadSpeed !== 0 ? (
{hasSpeed && (
<Chip color="primary" size="sm" radius="sm" variant="bordered">
{calcTraffic(info.uploadSpeed || 0)}/s {' '}
{calcTraffic(info.downloadSpeed || 0)}
/s
{uploadSpeed || '0 B'}/s {downloadSpeed || '0 B'}/s
</Chip>
) : null}
</CardFooter>
</div>
)}
</div>
</CardFooter>
</div>
</Card>
<Button
color={info.isActive ? 'warning' : 'danger'}
variant="light"
isIconOnly
className="absolute right-2 top-1/2 -translate-y-1/2"
onPress={() => {
close(info.id)
}}
>
{info.isActive ? <CgClose className="text-lg" /> : <CgTrash className="text-lg" />}
</Button>
</div>
</div>
</Card>
</div>
)
}
export default ConnectionItem
const ConnectionItem = memo(ConnectionItemComponent, (prevProps, nextProps) => {
return (
prevProps.info.id === nextProps.info.id &&
prevProps.info.upload === nextProps.info.upload &&
prevProps.info.download === nextProps.info.download &&
prevProps.info.uploadSpeed === nextProps.info.uploadSpeed &&
prevProps.info.downloadSpeed === nextProps.info.downloadSpeed &&
prevProps.info.isActive === nextProps.info.isActive &&
prevProps.iconUrl === nextProps.iconUrl &&
prevProps.displayIcon === nextProps.displayIcon &&
prevProps.displayName === nextProps.displayName &&
prevProps.selected?.id === nextProps.selected?.id
)
})
export default ConnectionItem

View File

@ -1,5 +1,5 @@
import BasePage from '@renderer/components/base/base-page'
import { mihomoCloseAllConnections, mihomoCloseConnection } from '@renderer/utils/ipc'
import { mihomoCloseAllConnections, mihomoCloseConnection, getIconDataURL, getAppName } from '@renderer/utils/ipc'
import { Key, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Divider, Input, Select, SelectItem, Tab, Tabs , Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react'
import { calcTraffic } from '@renderer/utils/calc'
@ -18,11 +18,18 @@ import differenceWith from 'lodash/differenceWith'
import unionWith from 'lodash/unionWith'
import { useTranslation } from 'react-i18next'
import { IoMdPause, IoMdPlay } from 'react-icons/io'
import { saveIconToCache, getIconFromCache } from '@renderer/utils/icon-cache'
import { cropAndPadTransparent } from '@renderer/utils/image'
import { platform } from '@renderer/utils/init'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
let cachedConnections: IMihomoConnectionDetail[] = []
const MAX_QUEUE_SIZE = 100
const Connections: React.FC = () => {
const { t } = useTranslation()
const { controledMihomoConfig } = useControledMihomoConfig()
const { 'find-process-mode': findProcessMode = 'always' } = controledMihomoConfig || {}
const [filter, setFilter] = useState('')
const { appConfig, patchAppConfig } = useAppConfig()
const {
@ -45,7 +52,9 @@ const Connections: React.FC = () => {
],
connectionTableColumnWidths,
connectionTableSortColumn,
connectionTableSortDirection
connectionTableSortDirection,
displayIcon = true,
displayAppName = true
} = appConfig || {}
const [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>()
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
@ -58,8 +67,21 @@ const Connections: React.FC = () => {
const [viewMode, setViewMode] = useState<'list' | 'table'>(connectionViewMode)
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set(connectionTableColumns))
const [iconMap, setIconMap] = useState<Record<string, string>>({})
const [appNameCache, setAppNameCache] = useState<Record<string, string>>({})
const [firstItemRefreshTrigger, setFirstItemRefreshTrigger] = useState(0)
const activeConnectionsRef = useRef(activeConnections)
const allConnectionsRef = useRef(allConnections)
const iconRequestQueue = useRef(new Set<string>())
const processingIcons = useRef(new Set<string>())
const processIconTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const processIconIdleCallback = useRef<number | null>(null)
const appNameRequestQueue = useRef(new Set<string>())
const processingAppNames = useRef(new Set<string>())
const processAppNameTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
activeConnectionsRef.current = activeConnections
allConnectionsRef.current = allConnections
@ -169,6 +191,176 @@ const Connections: React.FC = () => {
setClosedConnections((closedConns) => closedConns.filter((conn) => conn.id !== id))
}
const processAppNameQueue = useCallback(async () => {
if (processingAppNames.current.size >= 3 || appNameRequestQueue.current.size === 0) return
const pathsToProcess = Array.from(appNameRequestQueue.current).slice(0, 3)
pathsToProcess.forEach((path) => appNameRequestQueue.current.delete(path))
const promises = pathsToProcess.map(async (path) => {
if (processingAppNames.current.has(path)) return
processingAppNames.current.add(path)
try {
const appName = await getAppName(path)
if (appName) {
setAppNameCache((prev) => ({ ...prev, [path]: appName }))
}
} catch {
// ignore
} finally {
processingAppNames.current.delete(path)
}
})
await Promise.all(promises)
if (appNameRequestQueue.current.size > 0) {
processAppNameTimer.current = setTimeout(processAppNameQueue, 100)
}
}, [])
const processIconQueue = useCallback(async () => {
if (processingIcons.current.size >= 5 || iconRequestQueue.current.size === 0) return
const pathsToProcess = Array.from(iconRequestQueue.current).slice(0, 5)
pathsToProcess.forEach((path) => iconRequestQueue.current.delete(path))
const promises = pathsToProcess.map(async (path) => {
if (processingIcons.current.has(path)) return
processingIcons.current.add(path)
try {
const rawBase64 = await getIconDataURL(path)
if (!rawBase64) return
const fullDataURL = rawBase64.startsWith('data:')
? rawBase64
: `data:image/png;base64,${rawBase64}`
let processedDataURL = fullDataURL
if (platform != 'darwin') {
processedDataURL = await cropAndPadTransparent(fullDataURL)
}
saveIconToCache(path, processedDataURL)
setIconMap((prev) => ({ ...prev, [path]: processedDataURL }))
const firstConnection = filteredConnections[0]
if (firstConnection?.metadata.processPath === path) {
setFirstItemRefreshTrigger((prev) => prev + 1)
}
} catch {
// ignore
} finally {
processingIcons.current.delete(path)
}
})
await Promise.all(promises)
if (iconRequestQueue.current.size > 0) {
if ('requestIdleCallback' in window) {
processIconIdleCallback.current = requestIdleCallback(() => processIconQueue(), {
timeout: 1000
})
} else {
processIconTimer.current = setTimeout(processIconQueue, 50)
}
}
}, [filteredConnections])
useEffect(() => {
if (!displayIcon || findProcessMode === 'off') return
const visiblePaths = new Set<string>()
const otherPaths = new Set<string>()
const visibleConnections = filteredConnections.slice(0, 20)
visibleConnections.forEach((c) => {
const path = c.metadata.processPath || ''
visiblePaths.add(path)
})
const collectPaths = (connections: IMihomoConnectionDetail[]) => {
for (const c of connections) {
const path = c.metadata.processPath || ''
if (!visiblePaths.has(path)) {
otherPaths.add(path)
}
}
}
collectPaths(activeConnections)
collectPaths(closedConnections)
const loadIcon = (path: string, isVisible: boolean = false): void => {
if (iconMap[path] || processingIcons.current.has(path)) return
if (iconRequestQueue.current.size >= MAX_QUEUE_SIZE) return
const fromCache = getIconFromCache(path)
if (fromCache) {
setIconMap((prev) => ({ ...prev, [path]: fromCache }))
if (isVisible && filteredConnections[0]?.metadata.processPath === path) {
setFirstItemRefreshTrigger((prev) => prev + 1)
}
return
}
iconRequestQueue.current.add(path)
}
const loadAppName = (path: string): void => {
if (appNameCache[path] || processingAppNames.current.has(path)) return
if (appNameRequestQueue.current.size >= MAX_QUEUE_SIZE) return
appNameRequestQueue.current.add(path)
}
visiblePaths.forEach((path) => {
loadIcon(path, true)
if (displayAppName) loadAppName(path)
})
if (otherPaths.size > 0) {
const loadOtherPaths = () => {
otherPaths.forEach((path) => {
loadIcon(path, false)
if (displayAppName) loadAppName(path)
})
}
setTimeout(loadOtherPaths, 100)
}
if (processIconTimer.current) clearTimeout(processIconTimer.current)
if (processIconIdleCallback.current) cancelIdleCallback(processIconIdleCallback.current)
if (processAppNameTimer.current) clearTimeout(processAppNameTimer.current)
processIconTimer.current = setTimeout(processIconQueue, 10)
if (displayAppName) {
processAppNameTimer.current = setTimeout(processAppNameQueue, 10)
}
return (): void => {
if (processIconTimer.current) clearTimeout(processIconTimer.current)
if (processIconIdleCallback.current) cancelIdleCallback(processIconIdleCallback.current)
if (processAppNameTimer.current) clearTimeout(processAppNameTimer.current)
}
}, [
activeConnections,
closedConnections,
iconMap,
appNameCache,
displayIcon,
filteredConnections,
processIconQueue,
processAppNameQueue,
displayAppName,
findProcessMode
])
useEffect(() => {
const handler = (_e: unknown, ...args: unknown[]): void => {
const info = args[0] as IMihomoConnectionsInfo
@ -218,6 +410,43 @@ const Connections: React.FC = () => {
setIsPaused((prev) => !prev)
}, [])
const renderConnectionItem = useCallback(
(i: number, connection: IMihomoConnectionDetail) => {
const path = connection.metadata.processPath || ''
const iconUrl = (displayIcon && findProcessMode !== 'off' && iconMap[path]) || ''
const itemKey = i === 0 ? `${connection.id}-${firstItemRefreshTrigger}` : connection.id
const displayName =
displayAppName && connection.metadata.processPath
? appNameCache[connection.metadata.processPath]
: undefined
return (
<ConnectionItem
setSelected={setSelected}
setIsDetailModalOpen={setIsDetailModalOpen}
selected={selected}
iconUrl={iconUrl}
displayIcon={displayIcon && findProcessMode !== 'off'}
displayName={displayName}
close={closeConnection}
index={i}
key={itemKey}
info={connection}
/>
)
},
[
displayIcon,
iconMap,
firstItemRefreshTrigger,
selected,
closeConnection,
appNameCache,
findProcessMode,
displayAppName
]
)
return (
<BasePage
title={t('connections.title')}
@ -452,19 +681,7 @@ const Connections: React.FC = () => {
</div>
<div className="h-[calc(100vh-100px)] mt-px">
{viewMode === 'list' ? (
<Virtuoso
data={filteredConnections}
itemContent={(i, connection) => (
<ConnectionItem
setSelected={setSelected}
setIsDetailModalOpen={setIsDetailModalOpen}
close={closeConnection}
index={i}
key={connection.id}
info={connection}
/>
)}
/>
<Virtuoso data={filteredConnections} itemContent={renderConnectionItem} />
) : (
<ConnectionTable
connections={filteredConnections}

View File

@ -0,0 +1,81 @@
const ICON_CACHE_MAX_SIZE = 500
const ICON_CACHE_KEY_PREFIX = 'icon_'
const ICON_CACHE_INDEX_KEY = 'icon_cache_index'
export function saveIconToCache(path: string, dataURL: string): void {
try {
const indexStr = localStorage.getItem(ICON_CACHE_INDEX_KEY)
const index: string[] = indexStr ? JSON.parse(indexStr) : []
const existingIdx = index.indexOf(path)
if (existingIdx !== -1) {
index.splice(existingIdx, 1)
}
index.push(path)
while (index.length > ICON_CACHE_MAX_SIZE) {
const oldestPath = index.shift()
if (oldestPath) {
localStorage.removeItem(ICON_CACHE_KEY_PREFIX + oldestPath)
}
}
localStorage.setItem(ICON_CACHE_KEY_PREFIX + path, dataURL)
localStorage.setItem(ICON_CACHE_INDEX_KEY, JSON.stringify(index))
} catch {
clearHalfIconCache()
try {
localStorage.setItem(ICON_CACHE_KEY_PREFIX + path, dataURL)
const indexStr = localStorage.getItem(ICON_CACHE_INDEX_KEY)
const index: string[] = indexStr ? JSON.parse(indexStr) : []
const existingIdx = index.indexOf(path)
if (existingIdx !== -1) {
index.splice(existingIdx, 1)
}
index.push(path)
localStorage.setItem(ICON_CACHE_INDEX_KEY, JSON.stringify(index))
} catch {
// ignore
}
}
}
export function getIconFromCache(path: string): string | null {
try {
const dataURL = localStorage.getItem(ICON_CACHE_KEY_PREFIX + path)
if (dataURL) {
const indexStr = localStorage.getItem(ICON_CACHE_INDEX_KEY)
if (indexStr) {
const index: string[] = JSON.parse(indexStr)
const existingIdx = index.indexOf(path)
if (existingIdx !== -1) {
index.splice(existingIdx, 1)
index.push(path)
localStorage.setItem(ICON_CACHE_INDEX_KEY, JSON.stringify(index))
}
}
}
return dataURL
} catch {
return null
}
}
function clearHalfIconCache(): void {
try {
const indexStr = localStorage.getItem(ICON_CACHE_INDEX_KEY)
if (!indexStr) return
const index: string[] = JSON.parse(indexStr)
const halfLength = Math.floor(index.length / 2)
const toRemove = index.splice(0, halfLength)
toRemove.forEach((path) => {
localStorage.removeItem(ICON_CACHE_KEY_PREFIX + path)
})
localStorage.setItem(ICON_CACHE_INDEX_KEY, JSON.stringify(index))
} catch {
// ignore
}
}

View File

@ -0,0 +1,77 @@
export async function cropAndPadTransparent(
base64: string,
finalSize = 256,
border = 24
): Promise<string> {
const img = new Image()
img.src = base64
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const { data, width, height } = imgData
let top = height,
bottom = 0,
left = width,
right = 0
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4 + 3
if (data[i] > 10) {
if (x < left) left = x
if (x > right) right = x
if (y < top) top = y
if (y > bottom) bottom = y
}
}
}
if (right < left || bottom < top) return base64
const cropWidth = right - left + 1
const cropHeight = bottom - top + 1
const contentSize = finalSize - 2 * border
const aspectRatio = cropWidth / cropHeight
let drawWidth = contentSize
let drawHeight = contentSize
let offsetX = border
let offsetY = border
if (aspectRatio > 1) {
drawHeight = contentSize / aspectRatio
offsetY = border + (contentSize - drawHeight) / 2
} else {
drawWidth = contentSize * aspectRatio
offsetX = border + (contentSize - drawWidth) / 2
}
const outCanvas = document.createElement('canvas')
outCanvas.width = finalSize
outCanvas.height = finalSize
const outCtx = outCanvas.getContext('2d')!
outCtx.clearRect(0, 0, finalSize, finalSize)
outCtx.drawImage(
canvas,
left,
top,
cropWidth,
cropHeight,
offsetX,
offsetY,
drawWidth,
drawHeight
)
return outCanvas.toDataURL('image/png')
}

View File

@ -349,3 +349,13 @@ export async function setTitleBarOverlay(overlay: TitleBarOverlayOptions): Promi
export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: boolean): void {
window.electron.ipcRenderer.invoke('updateTrayIconImmediate', sysProxyEnabled, tunEnabled)
}
// getAppName: 获取应用程序名称
export async function getAppName(appPath: string): Promise<string> {
return invoke<string>('getAppName', appPath)
}
// getIconDataURL: 获取应用图标的Base64数据
export async function getIconDataURL(appPath: string): Promise<string> {
return invoke<string>('getIconDataURL', appPath)
}