feat: support tray icons by state (#1962)

Co-authored-by: Eugene <laueugene23@gmail.com>
This commit is contained in:
Eugene
2026-07-01 05:26:01 +08:00
committed by GitHub
parent 18f3974d19
commit 7a3e3cd46f
10 changed files with 259 additions and 64 deletions

View File

@@ -1,5 +1,7 @@
import { existsSync } from 'fs'
import { extname } from 'path'
import { execFileSync } from 'child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { extname, join } from 'path'
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { t } from 'i18next'
import {
@@ -47,6 +49,7 @@ let trayMenu: Menu | null = null
let macTrafficIconEnabled = false
type TrayIconStatus = 'white' | 'blue' | 'green' | 'red'
type TrayImage = Electron.NativeImage | string
type CustomTrayIconKey = keyof ICustomTrayIcons
const customTrayIconSize = 16
const customTrayIconScaleFactors = [1, 1.25, 1.5, 2, 2.5, 3]
@@ -425,8 +428,9 @@ export async function createTray(): Promise<void> {
ipcMain.removeAllListeners('trayIconUpdate')
ipcMain.on('trayIconUpdate', async (_, png: string, enabled: boolean) => {
macTrafficIconEnabled = enabled
const { customTrayIcon = '' } = await getAppConfig()
const customIcon = createCustomTrayImage(customTrayIcon)
const appConfig = await getAppConfig()
const status = await getTrayIconStatus()
const customIcon = createCustomTrayImageForStatus(appConfig, status)
if (customIcon) {
tray?.setImage(customIcon)
await updateTrayToolTip(undefined, undefined, true)
@@ -611,6 +615,29 @@ function createMultiScaleTrayImage(icon: Electron.NativeImage): Electron.NativeI
return fallback
}
function createMacIconImage(iconPath: string): Electron.NativeImage | null {
if (process.platform !== 'darwin') return null
if (!['.ico', '.icns'].includes(extname(iconPath).toLowerCase())) return null
let tempDir = ''
try {
tempDir = mkdtempSync(join(tmpdir(), 'clash-party-tray-icon-'))
const pngPath = join(tempDir, 'icon.png')
execFileSync('sips', ['-s', 'format', 'png', iconPath, '--out', pngPath], {
stdio: 'ignore',
timeout: 5000
})
const icon = nativeImage.createFromBuffer(readFileSync(pngPath))
return icon.isEmpty() ? null : icon
} catch {
return null
} finally {
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
}
}
function createCustomTrayImage(customTrayIcon: string): TrayImage | null {
if (!customTrayIcon) return null
@@ -623,10 +650,13 @@ function createCustomTrayImage(customTrayIcon: string): TrayImage | null {
if (!existsSync(customTrayIcon)) return null
const icon = nativeImage.createFromPath(customTrayIcon)
const iconExt = extname(customTrayIcon).toLowerCase()
let icon = nativeImage.createFromPath(customTrayIcon)
if (icon.isEmpty()) {
icon = createMacIconImage(customTrayIcon) || nativeImage.createEmpty()
}
if (icon.isEmpty()) return null
const iconExt = extname(customTrayIcon).toLowerCase()
if (process.platform === 'win32' && iconExt === '.ico') {
return customTrayIcon
}
@@ -637,6 +667,47 @@ function createCustomTrayImage(customTrayIcon: string): TrayImage | null {
return createMultiScaleTrayImage(icon)
}
function hasCustomTrayIcons(customTrayIcons?: ICustomTrayIcons): boolean {
return Boolean(customTrayIcons && Object.values(customTrayIcons).some(Boolean))
}
function getCustomTrayIconKey(status: TrayIconStatus): CustomTrayIconKey {
switch (status) {
case 'blue':
return 'sysProxy'
case 'green':
return 'tun'
case 'red':
return 'tun'
case 'white':
default:
return 'common'
}
}
function getCustomTrayIconForStatus(
appConfig: IAppConfig,
status: TrayIconStatus
): string | undefined {
const { customTrayIcon = '', customTrayIcons = {} } = appConfig
const iconKey = getCustomTrayIconKey(status)
if (customTrayIcons[iconKey]) return customTrayIcons[iconKey]
if (status === 'red') {
return customTrayIcons.tun || customTrayIcons.sysProxy || customTrayIcon
}
return customTrayIcon
}
function createCustomTrayImageForStatus(
appConfig: IAppConfig,
status: TrayIconStatus
): TrayImage | null {
return createCustomTrayImage(getCustomTrayIconForStatus(appConfig, status) || '')
}
async function updateTrayToolTip(
sysProxyEnabled?: boolean,
tunEnabled?: boolean,
@@ -647,7 +718,9 @@ async function updateTrayToolTip(
const [{ mode, tun }, appConfig] = await Promise.all([getControledMihomoConfig(), getAppConfig()])
const sysProxy = sysProxyEnabled ?? appConfig.sysProxy.enable
const tunStatus = tunEnabled ?? tun?.enable === true
const isCustomIcon = customIconEnabled ?? Boolean(appConfig.customTrayIcon)
const isCustomIcon =
customIconEnabled ??
Boolean(appConfig.customTrayIcon || hasCustomTrayIcons(appConfig.customTrayIcons))
const modeLabel =
mode === 'global'
@@ -687,10 +760,11 @@ export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: bo
const status = calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
const iconPaths = getIconPaths()
getAppConfig().then(async ({ disableTrayIconColor = false, customTrayIcon = '' }) => {
getAppConfig().then(async (appConfig) => {
if (!tray) return
try {
const customIcon = createCustomTrayImage(customTrayIcon)
const { disableTrayIconColor = false } = appConfig
const customIcon = createCustomTrayImageForStatus(appConfig, status)
if (customIcon) {
tray.setImage(customIcon)
await updateTrayToolTip(sysProxyEnabled, tunEnabled, true)
@@ -713,12 +787,13 @@ export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: bo
export async function updateTrayIcon(): Promise<void> {
if (!tray) return
const { disableTrayIconColor = false, customTrayIcon = '' } = await getAppConfig()
const appConfig = await getAppConfig()
const { disableTrayIconColor = false } = appConfig
const status = await getTrayIconStatus()
const iconPaths = getIconPaths()
try {
const customIcon = createCustomTrayImage(customTrayIcon)
const customIcon = createCustomTrayImageForStatus(appConfig, status)
if (customIcon) {
tray.setImage(customIcon)
await updateTrayToolTip(undefined, undefined, true)

View File

@@ -2,7 +2,7 @@ import { exec, execFile, spawn } from 'child_process'
import { readFile } from 'fs/promises'
import path from 'path'
import { promisify } from 'util'
import { app, dialog, nativeTheme, shell } from 'electron'
import { app, dialog, nativeImage, nativeTheme, shell } from 'electron'
import i18next from 'i18next'
import {
dataDir,
@@ -32,6 +32,11 @@ export async function readTextFile(filePath: string): Promise<string> {
export async function readImageFileDataURL(filePath: string): Promise<string> {
const ext = path.extname(filePath).toLowerCase()
if (['.ico', '.icns'].includes(ext)) {
const icon = nativeImage.createFromPath(filePath)
if (!icon.isEmpty()) return icon.toDataURL()
}
const mimeType =
ext === '.jpg' || ext === '.jpeg'
? 'image/jpeg'
@@ -39,7 +44,11 @@ export async function readImageFileDataURL(filePath: string): Promise<string> {
? 'image/webp'
: ext === '.gif'
? 'image/gif'
: 'image/png'
: ext === '.ico'
? 'image/x-icon'
: ext === '.icns'
? 'image/icns'
: 'image/png'
const data = await readFile(filePath)
return `data:${mimeType};base64,${data.toString('base64')}`

View File

@@ -32,6 +32,7 @@ export const defaultConfig: IAppConfig = {
trayProxyGroupStyle: 'default',
disableTrayIconColor: false,
customTrayIcon: '',
customTrayIcons: {},
maxLogDays: 7,
maxLogFileSize: 10,
disableAppLog: false,

View File

@@ -1,6 +1,16 @@
import React, { useEffect, useState } from 'react'
import { toast } from '@renderer/components/base/toast'
import { Button, Input, Select, SelectItem, Switch, Tab, Tabs, Tooltip } from '@heroui/react'
import {
Button,
Divider,
Input,
Select,
SelectItem,
Switch,
Tab,
Tabs,
Tooltip
} from '@heroui/react'
import { BiCopy, BiSolidFileImport } from 'react-icons/bi'
import useSWR from 'swr'
import {
@@ -27,7 +37,7 @@ import { useAppConfig } from '@renderer/hooks/use-app-config'
import debounce from '@renderer/utils/debounce'
import { platform } from '@renderer/utils/init'
import { useTheme } from 'next-themes'
import { IoIosHelpCircle, IoMdCloudDownload } from 'react-icons/io'
import { IoIosArrowDown, IoIosHelpCircle, IoMdCloudDownload } from 'react-icons/io'
import { MdEditDocument } from 'react-icons/md'
import { useTranslation } from 'react-i18next'
import SettingItem from '../base/base-setting-item'
@@ -37,6 +47,9 @@ import CSSEditorModal from './css-editor-modal'
import TrayIconCropModal from './tray-icon-crop-modal'
const rasterTrayIconPattern = /\.(png|jpe?g|webp)$/i
const macTrayIconPattern = /\.(ico|icns)$/i
type TrayIconCropTarget = 'custom' | keyof ICustomTrayIcons
const customTrayIconStateKeys: (keyof ICustomTrayIcons)[] = ['common', 'sysProxy', 'tun']
const GeneralConfig: React.FC = () => {
const { t, i18n } = useTranslation()
@@ -47,6 +60,8 @@ const GeneralConfig: React.FC = () => {
const [fetching, setFetching] = useState(false)
const [isRelaunching, setIsRelaunching] = useState(false)
const [trayIconCropDataURL, setTrayIconCropDataURL] = useState('')
const [trayIconCropTarget, setTrayIconCropTarget] = useState<TrayIconCropTarget>('custom')
const [trayIconDrawerOpen, setTrayIconDrawerOpen] = useState(false)
const [showHardwareAccelConfirm, setShowHardwareAccelConfirm] = useState(false)
const [pendingHardwareAccelValue, setPendingHardwareAccelValue] = useState(false)
const { setTheme } = useTheme()
@@ -61,6 +76,7 @@ const GeneralConfig: React.FC = () => {
swapTrayClick = false,
disableTrayIconColor = false,
customTrayIcon = '',
customTrayIcons = {},
disableAnimations = false,
showFloatingWindow: showFloating = false,
spinFloatingIcon = true,
@@ -88,6 +104,76 @@ const GeneralConfig: React.FC = () => {
})
}, [])
const hasCustomTrayIcons = Boolean(customTrayIcon || Object.values(customTrayIcons).some(Boolean))
const getTrayIconDisplayText = (icon?: string): string => {
if (!icon) return t('common.default')
return icon.startsWith('data:image/') ? t('settings.customTrayIconBase64') : icon
}
const patchTrayIcon = async (target: TrayIconCropTarget, icon: string): Promise<void> => {
if (target === 'custom') {
await patchAppConfig({ customTrayIcon: icon })
} else {
await patchAppConfig({
customTrayIcons: {
...customTrayIcons,
[target]: icon
}
})
}
await updateTrayIcon()
}
const selectTrayIcon = async (target: TrayIconCropTarget): Promise<void> => {
const files = await getFilePath(
['png', 'jpg', 'jpeg', 'webp', 'ico', 'icns'],
t('settings.customTrayIconSelect'),
t('settings.customTrayIcon')
)
if (!files?.[0]) return
if (
rasterTrayIconPattern.test(files[0]) ||
(platform === 'darwin' && macTrayIconPattern.test(files[0]))
) {
setTrayIconCropTarget(target)
setTrayIconCropDataURL(await readImageFileDataURL(files[0]))
return
}
await patchTrayIcon(target, files[0])
}
const resetTrayIcon = async (target: TrayIconCropTarget): Promise<void> => {
await patchTrayIcon(target, '')
}
const renderTrayIconPicker = (
target: TrayIconCropTarget,
label: string,
icon: string | undefined
): React.ReactNode => (
<div
key={target}
className="grid min-w-0 grid-cols-[7rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-md px-2 py-1.5"
>
<span className="shrink-0 text-sm text-default-600">{label}</span>
<span
className="min-w-0 truncate text-right text-xs text-default-500"
title={getTrayIconDisplayText(icon)}
>
{getTrayIconDisplayText(icon)}
</span>
<Button size="sm" variant="flat" onPress={() => selectTrayIcon(target)}>
{t(icon ? 'settings.changeTrayIcon' : 'settings.selectTrayIcon')}
</Button>
{icon && (
<Button size="sm" variant="light" onPress={() => resetTrayIcon(target)}>
{t('common.default')}
</Button>
)}
</div>
)
return (
<>
{openCSSEditor && (
@@ -128,9 +214,8 @@ const GeneralConfig: React.FC = () => {
imageDataURL={trayIconCropDataURL}
onCancel={() => setTrayIconCropDataURL('')}
onConfirm={async (dataURL) => {
await patchAppConfig({ customTrayIcon: dataURL })
await patchTrayIcon(trayIconCropTarget, dataURL)
setTrayIconCropDataURL('')
await updateTrayIcon()
}}
/>
)}
@@ -405,7 +490,7 @@ const GeneralConfig: React.FC = () => {
<Switch
size="sm"
isSelected={disableTrayIconColor}
isDisabled={Boolean(customTrayIcon)}
isDisabled={hasCustomTrayIcons}
onValueChange={async (v) => {
await patchAppConfig({ disableTrayIconColor: v })
await updateTrayIcon()
@@ -421,57 +506,50 @@ const GeneralConfig: React.FC = () => {
</Button>
</Tooltip>
}
divider
>
<div className="flex items-center justify-end gap-2 min-w-0 max-w-[65%]">
{customTrayIcon && (
<span
className="truncate text-xs text-default-500"
title={
customTrayIcon.startsWith('data:image/')
? t('settings.customTrayIconBase64')
: customTrayIcon
}
>
{customTrayIcon.startsWith('data:image/')
? t('settings.customTrayIconBase64')
: customTrayIcon}
</span>
)}
<Button
size="sm"
variant="flat"
onPress={async () => {
const files = await getFilePath(
['png', 'jpg', 'jpeg', 'webp', 'ico', 'icns'],
t('settings.customTrayIconSelect'),
t('settings.customTrayIcon')
)
if (!files?.[0]) return
if (rasterTrayIconPattern.test(files[0])) {
setTrayIconCropDataURL(await readImageFileDataURL(files[0]))
return
}
await patchAppConfig({ customTrayIcon: files[0] })
await updateTrayIcon()
}}
<div className="flex min-w-0 max-w-[68%] items-center justify-end gap-2">
<span
className="min-w-0 truncate text-xs text-default-500"
title={getTrayIconDisplayText(customTrayIcon)}
>
{getTrayIconDisplayText(customTrayIcon)}
</span>
<Button size="sm" variant="flat" onPress={() => selectTrayIcon('custom')}>
{t(customTrayIcon ? 'settings.changeTrayIcon' : 'settings.selectTrayIcon')}
</Button>
{customTrayIcon && (
<Button
size="sm"
variant="light"
onPress={async () => {
await patchAppConfig({ customTrayIcon: '' })
await updateTrayIcon()
}}
>
<Button size="sm" variant="light" onPress={() => resetTrayIcon('custom')}>
{t('common.default')}
</Button>
)}
<Button
size="sm"
variant="light"
endContent={
<IoIosArrowDown
className={`text-sm transition-transform ${trayIconDrawerOpen ? 'rotate-180' : ''}`}
/>
}
onPress={() => setTrayIconDrawerOpen((v) => !v)}
>
{t('settings.customTrayIconStates')}
</Button>
</div>
</SettingItem>
{trayIconDrawerOpen && (
<div className="mb-2 ml-4 mr-1 mt-2 rounded-lg border border-default-200 bg-default-50/40 p-2">
<div className="flex flex-col gap-1">
{customTrayIconStateKeys.map((key) =>
renderTrayIconPicker(
key,
t(`settings.customTrayIcon.${key}`),
customTrayIcons[key]
)
)}
</div>
</div>
)}
<Divider className="my-2" />
</>
)}
{platform !== 'linux' && (

View File

@@ -100,10 +100,15 @@
"settings.trayProxyGroupStyleSubmenu": "Submenu",
"settings.disableTrayIconColor": "Disable Tray Icon Color Changes",
"settings.customTrayIcon": "Custom Tray Icon",
"settings.customTrayIconTooltip": "When set, the tray always uses this icon. System proxy, TUN, and mode state are annotated in the tray tooltip.",
"settings.customTrayIconTooltip": "Set a fallback tray icon or separate icons for Off, System Proxy, and TUN. Unset states fall back to the fallback icon or the default icon.",
"settings.customTrayIconSelect": "Select Tray Icon",
"settings.cropTrayIcon": "Crop Tray Icon",
"settings.customTrayIconBase64": "Cropped icon stored",
"settings.customTrayIconFallback": "Fallback",
"settings.customTrayIconStates": "State Icons",
"settings.customTrayIcon.common": "Off",
"settings.customTrayIcon.sysProxy": "System Proxy",
"settings.customTrayIcon.tun": "TUN",
"settings.selectTrayIcon": "Select Icon",
"settings.changeTrayIcon": "Change Icon",
"settings.disableAnimations": "Disable Animation Effects",

View File

@@ -95,10 +95,15 @@
"settings.trayProxyGroupStyleSubmenu": "زیرمنو",
"settings.disableTrayIconColor": "غیرفعال کردن تغییر رنگ آیکون تری",
"settings.customTrayIcon": "آیکون سفارشی سیستم‌تری",
"settings.customTrayIconTooltip": "وقتی تنظیم شود، تری همیشه از این آیکون استفاده می‌کند. وضعیت پروکسی سیستم، TUN و حالت در راهنمای تری نمایش داده می‌شود.",
"settings.customTrayIconTooltip": "یک آیکون جایگزین یا آیکون‌های جداگانه برای خاموش، پراکسی سیستم و TUN تنظیم کنید. وضعیت‌های تنظیم‌نشده از آیکون جایگزین یا پیش‌فرض استفاده می‌کنند.",
"settings.customTrayIconSelect": "انتخاب آیکون تری",
"settings.cropTrayIcon": "برش آیکون تری",
"settings.customTrayIconBase64": "آیکون برش‌خورده ذخیره شد",
"settings.customTrayIconFallback": "جایگزین",
"settings.customTrayIconStates": "آیکون‌های وضعیت",
"settings.customTrayIcon.common": "خاموش",
"settings.customTrayIcon.sysProxy": "پراکسی سیستم",
"settings.customTrayIcon.tun": "TUN",
"settings.selectTrayIcon": "انتخاب آیکون",
"settings.changeTrayIcon": "تغییر آیکون",
"settings.disableAnimations": "غیرفعال کردن جلوه‌های انیمیشن",

View File

@@ -97,10 +97,15 @@
"settings.trayProxyGroupStyleSubmenu": "Подменю",
"settings.disableTrayIconColor": "Отключить смену цвета значка в трее",
"settings.customTrayIcon": "Пользовательский значок в трее",
"settings.customTrayIconTooltip": "Если задано, трей всегда использует этот значок. Состояние системного прокси, TUN и режима указано в подсказке трея.",
"settings.customTrayIconTooltip": "Укажите запасной значок или отдельные значки для состояний выключено, системный прокси и TUN. Незаполненные состояния используют запасной или стандартный значок.",
"settings.customTrayIconSelect": "Выбрать значок трея",
"settings.cropTrayIcon": "Обрезать значок трея",
"settings.customTrayIconBase64": "Обрезанный значок сохранен",
"settings.customTrayIconFallback": "Запасной",
"settings.customTrayIconStates": "Значки состояний",
"settings.customTrayIcon.common": "Выключено",
"settings.customTrayIcon.sysProxy": "Системный прокси",
"settings.customTrayIcon.tun": "TUN",
"settings.selectTrayIcon": "Выбрать значок",
"settings.changeTrayIcon": "Изменить значок",
"settings.disableAnimations": "Отключить анимационные эффекты",

View File

@@ -100,10 +100,15 @@
"settings.trayProxyGroupStyleSubmenu": "子菜单",
"settings.disableTrayIconColor": "禁用托盘图标颜色变化",
"settings.customTrayIcon": "自定义托盘图标",
"settings.customTrayIconTooltip": "启用后托盘将始终使用此图标系统代理、TUN 和模式状态改由托盘 Tooltip 标注。",
"settings.customTrayIconTooltip": "可设置备用图标或按关闭、系统代理、TUN 状态分别设置图标;未设置的状态将回退到备用图标或默认图标。",
"settings.customTrayIconSelect": "选择托盘图标",
"settings.cropTrayIcon": "裁剪托盘图标",
"settings.customTrayIconBase64": "已储存裁剪图标",
"settings.customTrayIconFallback": "备用",
"settings.customTrayIconStates": "状态图标",
"settings.customTrayIcon.common": "关闭",
"settings.customTrayIcon.sysProxy": "系统代理",
"settings.customTrayIcon.tun": "TUN",
"settings.selectTrayIcon": "选择图标",
"settings.changeTrayIcon": "更换图标",
"settings.disableAnimations": "禁用动画效果",

View File

@@ -100,10 +100,15 @@
"settings.trayProxyGroupStyleSubmenu": "子菜單",
"settings.disableTrayIconColor": "禁用托盤圖標顏色變化",
"settings.customTrayIcon": "自定義托盤圖標",
"settings.customTrayIconTooltip": "啟用後托盤將始終使用此圖標系統代理、TUN 和模式狀態改由托盤 Tooltip 標註。",
"settings.customTrayIconTooltip": "可設定備用圖標或按關閉、系統代理、TUN 狀態分別設定圖標;未設定的狀態將回退到備用圖標或默認圖標。",
"settings.customTrayIconSelect": "選擇托盤圖標",
"settings.cropTrayIcon": "裁剪托盤圖標",
"settings.customTrayIconBase64": "已儲存裁剪圖標",
"settings.customTrayIconFallback": "備用",
"settings.customTrayIconStates": "狀態圖標",
"settings.customTrayIcon.common": "關閉",
"settings.customTrayIcon.sysProxy": "系統代理",
"settings.customTrayIcon.tun": "TUN",
"settings.selectTrayIcon": "選擇圖標",
"settings.changeTrayIcon": "更換圖標",
"settings.disableAnimations": "禁用動畫效果",

View File

@@ -258,6 +258,12 @@ interface INetworkLatencyTarget {
url: string
}
interface ICustomTrayIcons {
common?: string
sysProxy?: string
tun?: string
}
interface IAppConfig {
core: 'mihomo' | 'mihomo-alpha' | 'mihomo-smart' | 'mihomo-specific'
specificVersion?: string
@@ -359,6 +365,7 @@ interface IAppConfig {
showTraffic?: boolean
disableTrayIconColor?: boolean
customTrayIcon?: string
customTrayIcons?: ICustomTrayIcons
trayProxyGroupStyle?: 'default' | 'submenu'
disableAnimations?: boolean
webdavUrl?: string