mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2026-02-10 11:40:28 +08:00
feat: show app icons in connections page
This commit is contained in:
parent
d3a23a0601
commit
e9c72ce448
@ -34,14 +34,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@types/plist": "^3.0.5",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"croner": "^9.1.0",
|
"croner": "^9.1.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"file-icon": "^6.0.0",
|
||||||
|
"file-icon-info": "^1.1.1",
|
||||||
"i18next": "^25.6.2",
|
"i18next": "^25.6.2",
|
||||||
"iconv-lite": "^0.7.1",
|
"iconv-lite": "^0.7.1",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"plist": "^3.1.0",
|
||||||
"sysproxy-rs": "file:src/native/sysproxy",
|
"sysproxy-rs": "file:src/native/sysproxy",
|
||||||
"webdav": "^5.8.0",
|
"webdav": "^5.8.0",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
|
|||||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
|||||||
'@electron-toolkit/utils':
|
'@electron-toolkit/utils':
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0(electron@37.10.0)
|
version: 4.0.0(electron@37.10.0)
|
||||||
|
'@types/plist':
|
||||||
|
specifier: ^3.0.5
|
||||||
|
version: 3.0.5
|
||||||
adm-zip:
|
adm-zip:
|
||||||
specifier: ^0.5.16
|
specifier: ^0.5.16
|
||||||
version: 0.5.16
|
version: 0.5.16
|
||||||
@ -29,12 +32,24 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.1
|
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:
|
i18next:
|
||||||
specifier: ^25.6.2
|
specifier: ^25.6.2
|
||||||
version: 25.7.3(typescript@5.9.3)
|
version: 25.7.3(typescript@5.9.3)
|
||||||
iconv-lite:
|
iconv-lite:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 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:
|
sysproxy-rs:
|
||||||
specifier: file:src/native/sysproxy
|
specifier: file:src/native/sysproxy
|
||||||
version: file:src/native/sysproxy
|
version: file:src/native/sysproxy
|
||||||
@ -2180,6 +2195,9 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/node@10.17.60':
|
||||||
|
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
|
||||||
|
|
||||||
'@types/node@22.19.3':
|
'@types/node@22.19.3':
|
||||||
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
|
resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
|
||||||
|
|
||||||
@ -3196,6 +3214,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
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:
|
filelist@1.0.4:
|
||||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
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==}
|
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
|
||||||
engines: {node: '>=10'}
|
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:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
@ -7911,6 +7940,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/node@10.17.60': {}
|
||||||
|
|
||||||
'@types/node@22.19.3':
|
'@types/node@22.19.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@ -7923,7 +7954,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.0.3
|
'@types/node': 25.0.3
|
||||||
xmlbuilder: 15.1.1
|
xmlbuilder: 15.1.1
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@types/pubsub-js@1.8.6': {}
|
'@types/pubsub-js@1.8.6': {}
|
||||||
|
|
||||||
@ -9301,6 +9331,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
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:
|
filelist@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
minimatch: 5.1.6
|
minimatch: 5.1.6
|
||||||
@ -10541,6 +10579,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
aggregate-error: 3.1.0
|
aggregate-error: 3.1.0
|
||||||
|
|
||||||
|
p-map@7.0.4: {}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
|
|||||||
63
src/main/utils/appName.ts
Normal file
63
src/main/utils/appName.ts
Normal 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()
|
||||||
|
}
|
||||||
6
src/main/utils/defaultIcon.ts
Normal file
6
src/main/utils/defaultIcon.ts
Normal file
File diff suppressed because one or more lines are too long
287
src/main/utils/icon.ts
Normal file
287
src/main/utils/icon.ts
Normal 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
|
||||||
|
}
|
||||||
@ -122,6 +122,8 @@ import { startMonitor } from '../resolve/trafficMonitor'
|
|||||||
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
|
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
|
||||||
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
|
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
|
||||||
import { getImageDataURL } from './image'
|
import { getImageDataURL } from './image'
|
||||||
|
import { getIconDataURL } from './icon'
|
||||||
|
import { getAppName } from './appName'
|
||||||
import { logDir, rulePath } from './dirs'
|
import { logDir, rulePath } from './dirs'
|
||||||
import { installMihomoCore, getGitHubTags, clearVersionCache } from './github'
|
import { installMihomoCore, getGitHubTags, clearVersionCache } from './github'
|
||||||
|
|
||||||
@ -321,6 +323,8 @@ const asyncHandlers: Record<string, AsyncFn> = {
|
|||||||
// Misc
|
// Misc
|
||||||
getGistUrl,
|
getGistUrl,
|
||||||
getImageDataURL,
|
getImageDataURL,
|
||||||
|
getIconDataURL,
|
||||||
|
getAppName,
|
||||||
changeLanguage,
|
changeLanguage,
|
||||||
setTitleBarOverlay,
|
setTitleBarOverlay,
|
||||||
registerShortcut
|
registerShortcut
|
||||||
|
|||||||
@ -146,6 +146,8 @@ const validInvokeChannels = [
|
|||||||
// Misc
|
// Misc
|
||||||
'getGistUrl',
|
'getGistUrl',
|
||||||
'getImageDataURL',
|
'getImageDataURL',
|
||||||
|
'getIconDataURL',
|
||||||
|
'getAppName',
|
||||||
'changeLanguage'
|
'changeLanguage'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@ -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 { calcTraffic } from '@renderer/utils/calc'
|
||||||
import dayjs from '@renderer/utils/dayjs'
|
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'
|
import { CgClose, CgTrash } from 'react-icons/cg'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
index: number
|
index: number
|
||||||
info: IMihomoConnectionDetail
|
info: IMihomoConnectionDetail
|
||||||
|
displayIcon?: boolean
|
||||||
|
iconUrl: string
|
||||||
|
displayName?: string
|
||||||
|
selected: IMihomoConnectionDetail | undefined
|
||||||
setSelected: React.Dispatch<React.SetStateAction<IMihomoConnectionDetail | undefined>>
|
setSelected: React.Dispatch<React.SetStateAction<IMihomoConnectionDetail | undefined>>
|
||||||
setIsDetailModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
setIsDetailModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
close: (id: string) => void
|
close: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConnectionItem: React.FC<Props> = (props) => {
|
const ConnectionItemComponent: React.FC<Props> = ({
|
||||||
const { index, info, close, setSelected, setIsDetailModalOpen } = 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 (
|
return (
|
||||||
<div className={`px-2 pb-2 ${index === 0 ? 'pt-2' : ''}`}>
|
<div className={`px-2 pb-2 ${index === 0 ? 'pt-2' : ''}`} style={{ minHeight: 80 }}>
|
||||||
<div className="relative">
|
<Card as="div" isPressable className="w-full" onPress={handleCardPress}>
|
||||||
<Card
|
<div className="w-full flex justify-between items-center">
|
||||||
isPressable
|
{displayIcon && (
|
||||||
className="w-full"
|
<div>
|
||||||
onPress={() => {
|
<Avatar
|
||||||
setSelected(info)
|
size="lg"
|
||||||
setIsDetailModalOpen(true)
|
radius="sm"
|
||||||
}}
|
src={iconUrl}
|
||||||
>
|
className="bg-transparent ml-2 w-14 h-14"
|
||||||
<div className="w-full">
|
/>
|
||||||
<div className="w-full pr-12">
|
</div>
|
||||||
<CardHeader className="pb-0 gap-1">
|
)}
|
||||||
|
<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
|
<Chip
|
||||||
color={info.isActive ? 'primary' : 'danger'}
|
color={info.isActive ? 'primary' : 'danger'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -37,26 +129,8 @@ const ConnectionItem: React.FC<Props> = (props) => {
|
|||||||
>
|
>
|
||||||
{info.metadata.type}({info.metadata.network.toUpperCase()})
|
{info.metadata.type}({info.metadata.network.toUpperCase()})
|
||||||
</Chip>
|
</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
|
<Chip
|
||||||
className="flag-emoji text-ellipsis whitespace-nowrap overflow-hidden"
|
className="flag-emoji whitespace-nowrap overflow-hidden"
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
@ -64,33 +138,35 @@ const ConnectionItem: React.FC<Props> = (props) => {
|
|||||||
{info.chains[0]}
|
{info.chains[0]}
|
||||||
</Chip>
|
</Chip>
|
||||||
<Chip size="sm" radius="sm" variant="bordered">
|
<Chip size="sm" radius="sm" variant="bordered">
|
||||||
↑ {calcTraffic(info.upload)} ↓ {calcTraffic(info.download)}
|
↑ {uploadTraffic} ↓ {downloadTraffic}
|
||||||
</Chip>
|
</Chip>
|
||||||
{info.uploadSpeed !== 0 || info.downloadSpeed !== 0 ? (
|
{hasSpeed && (
|
||||||
<Chip color="primary" size="sm" radius="sm" variant="bordered">
|
<Chip color="primary" size="sm" radius="sm" variant="bordered">
|
||||||
↑ {calcTraffic(info.uploadSpeed || 0)}/s ↓{' '}
|
↑ {uploadSpeed || '0 B'}/s ↓ {downloadSpeed || '0 B'}/s
|
||||||
{calcTraffic(info.downloadSpeed || 0)}
|
|
||||||
/s
|
|
||||||
</Chip>
|
</Chip>
|
||||||
) : null}
|
)}
|
||||||
</CardFooter>
|
</div>
|
||||||
</div>
|
</CardFooter>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
<Button
|
</Card>
|
||||||
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>
|
</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
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import BasePage from '@renderer/components/base/base-page'
|
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 { 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 { Badge, Button, Divider, Input, Select, SelectItem, Tab, Tabs , Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react'
|
||||||
import { calcTraffic } from '@renderer/utils/calc'
|
import { calcTraffic } from '@renderer/utils/calc'
|
||||||
@ -18,11 +18,18 @@ import differenceWith from 'lodash/differenceWith'
|
|||||||
import unionWith from 'lodash/unionWith'
|
import unionWith from 'lodash/unionWith'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { IoMdPause, IoMdPlay } from 'react-icons/io'
|
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[] = []
|
let cachedConnections: IMihomoConnectionDetail[] = []
|
||||||
|
const MAX_QUEUE_SIZE = 100
|
||||||
|
|
||||||
const Connections: React.FC = () => {
|
const Connections: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { controledMihomoConfig } = useControledMihomoConfig()
|
||||||
|
const { 'find-process-mode': findProcessMode = 'always' } = controledMihomoConfig || {}
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const { appConfig, patchAppConfig } = useAppConfig()
|
const { appConfig, patchAppConfig } = useAppConfig()
|
||||||
const {
|
const {
|
||||||
@ -45,7 +52,9 @@ const Connections: React.FC = () => {
|
|||||||
],
|
],
|
||||||
connectionTableColumnWidths,
|
connectionTableColumnWidths,
|
||||||
connectionTableSortColumn,
|
connectionTableSortColumn,
|
||||||
connectionTableSortDirection
|
connectionTableSortDirection,
|
||||||
|
displayIcon = true,
|
||||||
|
displayAppName = true
|
||||||
} = appConfig || {}
|
} = appConfig || {}
|
||||||
const [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>()
|
const [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>()
|
||||||
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
|
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
|
||||||
@ -58,8 +67,21 @@ const Connections: React.FC = () => {
|
|||||||
const [viewMode, setViewMode] = useState<'list' | 'table'>(connectionViewMode)
|
const [viewMode, setViewMode] = useState<'list' | 'table'>(connectionViewMode)
|
||||||
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set(connectionTableColumns))
|
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 activeConnectionsRef = useRef(activeConnections)
|
||||||
const allConnectionsRef = useRef(allConnections)
|
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(() => {
|
useEffect(() => {
|
||||||
activeConnectionsRef.current = activeConnections
|
activeConnectionsRef.current = activeConnections
|
||||||
allConnectionsRef.current = allConnections
|
allConnectionsRef.current = allConnections
|
||||||
@ -169,6 +191,176 @@ const Connections: React.FC = () => {
|
|||||||
setClosedConnections((closedConns) => closedConns.filter((conn) => conn.id !== id))
|
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(() => {
|
useEffect(() => {
|
||||||
const handler = (_e: unknown, ...args: unknown[]): void => {
|
const handler = (_e: unknown, ...args: unknown[]): void => {
|
||||||
const info = args[0] as IMihomoConnectionsInfo
|
const info = args[0] as IMihomoConnectionsInfo
|
||||||
@ -218,6 +410,43 @@ const Connections: React.FC = () => {
|
|||||||
setIsPaused((prev) => !prev)
|
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 (
|
return (
|
||||||
<BasePage
|
<BasePage
|
||||||
title={t('connections.title')}
|
title={t('connections.title')}
|
||||||
@ -452,19 +681,7 @@ const Connections: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100vh-100px)] mt-px">
|
<div className="h-[calc(100vh-100px)] mt-px">
|
||||||
{viewMode === 'list' ? (
|
{viewMode === 'list' ? (
|
||||||
<Virtuoso
|
<Virtuoso data={filteredConnections} itemContent={renderConnectionItem} />
|
||||||
data={filteredConnections}
|
|
||||||
itemContent={(i, connection) => (
|
|
||||||
<ConnectionItem
|
|
||||||
setSelected={setSelected}
|
|
||||||
setIsDetailModalOpen={setIsDetailModalOpen}
|
|
||||||
close={closeConnection}
|
|
||||||
index={i}
|
|
||||||
key={connection.id}
|
|
||||||
info={connection}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ConnectionTable
|
<ConnectionTable
|
||||||
connections={filteredConnections}
|
connections={filteredConnections}
|
||||||
|
|||||||
81
src/renderer/src/utils/icon-cache.ts
Normal file
81
src/renderer/src/utils/icon-cache.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/renderer/src/utils/image.ts
Normal file
77
src/renderer/src/utils/image.ts
Normal 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')
|
||||||
|
}
|
||||||
@ -349,3 +349,13 @@ export async function setTitleBarOverlay(overlay: TitleBarOverlayOptions): Promi
|
|||||||
export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: boolean): void {
|
export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: boolean): void {
|
||||||
window.electron.ipcRenderer.invoke('updateTrayIconImmediate', sysProxyEnabled, tunEnabled)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user