feat: add custom tray icon support

This commit is contained in:
zjdndjf
2026-04-20 18:43:11 +08:00
parent de60c12ff2
commit 2ecded88b3
13 changed files with 203 additions and 31 deletions

View File

@@ -1,3 +1,4 @@
import { existsSync } from 'fs'
import { app, clipboard, ipcMain, Menu, nativeImage, shell, Tray } from 'electron'
import { t } from 'i18next'
import {
@@ -40,6 +41,7 @@ import { floatingWindow, triggerFloatingWindow } from './floatingWindow'
export let tray: Tray | null = null
// macOS 流量显示状态,避免异步读取配置导致的时序问题
let macTrafficIconEnabled = false
type TrayIconStatus = 'white' | 'blue' | 'green' | 'red'
export const buildContextMenu = async (): Promise<Menu> => {
// 添加调试日志
@@ -385,7 +387,7 @@ export async function createTray(): Promise<void> {
if (process.platform === 'win32') {
tray = new Tray(icoIcon)
}
tray?.setToolTip('Clash Party')
await updateTrayToolTip()
tray?.setIgnoreDoubleClickEvents(true)
await updateTrayIcon()
@@ -398,9 +400,17 @@ 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)
if (customIcon) {
tray?.setImage(customIcon)
await updateTrayToolTip(undefined, undefined, true)
return
}
const image = nativeImage.createFromDataURL(png).resize({ height: 16 })
image.setTemplateImage(true)
tray?.setImage(image)
await updateTrayToolTip(undefined, undefined, false)
})
// macOS 默认行为:左键显示窗口,右键显示菜单
tray?.addListener('click', async () => {
@@ -521,7 +531,7 @@ export async function hideDockIcon(): Promise<void> {
}
}
const getIconPaths = () => {
const getIconPaths = (): Record<TrayIconStatus, string> => {
if (process.platform === 'win32') {
return {
white: icoIcon,
@@ -539,27 +549,82 @@ const getIconPaths = () => {
}
}
function createCustomTrayImage(customTrayIcon: string): Electron.NativeImage | null {
if (!customTrayIcon || !existsSync(customTrayIcon)) return null
const icon = nativeImage.createFromPath(customTrayIcon)
if (icon.isEmpty()) return null
return icon.resize({ height: 16 })
}
async function updateTrayToolTip(
sysProxyEnabled?: boolean,
tunEnabled?: boolean,
customIconEnabled?: boolean
): Promise<void> {
if (!tray) return
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 modeLabel =
mode === 'global'
? t('tray.globalMode')
: mode === 'direct'
? t('tray.directMode')
: t('tray.ruleMode')
const status = [
`${t('tray.tooltip.mode')}: ${modeLabel}`,
`${t('tray.systemProxy')}: ${sysProxy ? t('tray.tooltip.enabled') : t('tray.tooltip.disabled')}`,
`${t('tray.tun')}: ${tunStatus ? t('tray.tooltip.enabled') : t('tray.tooltip.disabled')}`
]
if (isCustomIcon) {
status.push(t('tray.tooltip.customIcon'))
}
tray.setToolTip(['Clash Party', ...status].join('\n'))
}
function setTrayImage(iconPath: string): void {
if (!tray) return
if (process.platform === 'darwin') {
const icon = nativeImage.createFromPath(iconPath).resize({ height: 16 })
tray.setImage(icon)
} else if (process.platform === 'win32') {
tray.setImage(iconPath)
} else if (process.platform === 'linux') {
tray.setImage(iconPath)
}
}
export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: boolean): void {
if (!tray) return
// macOS 流量显示开启时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && macTrafficIconEnabled) return
const status = calculateTrayIconStatus(sysProxyEnabled, tunEnabled)
const iconPaths = getIconPaths()
getAppConfig().then(({ disableTrayIconColor = false }) => {
getAppConfig().then(async ({ disableTrayIconColor = false, customTrayIcon = '' }) => {
if (!tray) return
if (process.platform === 'darwin' && macTrafficIconEnabled) return
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
try {
if (process.platform === 'darwin') {
const icon = nativeImage.createFromPath(iconPath).resize({ height: 16 })
tray.setImage(icon)
} else if (process.platform === 'win32') {
tray.setImage(iconPath)
} else if (process.platform === 'linux') {
tray.setImage(iconPath)
const customIcon = createCustomTrayImage(customTrayIcon)
if (customIcon) {
tray.setImage(customIcon)
await updateTrayToolTip(sysProxyEnabled, tunEnabled, true)
return
}
// macOS 流量显示开启时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && macTrafficIconEnabled) {
await updateTrayToolTip(sysProxyEnabled, tunEnabled, false)
return
}
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
setTrayImage(iconPath)
await updateTrayToolTip(sysProxyEnabled, tunEnabled, false)
} catch {
// Failed to update tray icon
}
@@ -568,23 +633,26 @@ export function updateTrayIconImmediate(sysProxyEnabled: boolean, tunEnabled: bo
export async function updateTrayIcon(): Promise<void> {
if (!tray) return
// macOS 流量显示开启时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && macTrafficIconEnabled) return
const { disableTrayIconColor = false } = await getAppConfig()
const { disableTrayIconColor = false, customTrayIcon = '' } = await getAppConfig()
const status = await getTrayIconStatus()
const iconPaths = getIconPaths()
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
try {
if (process.platform === 'darwin') {
const icon = nativeImage.createFromPath(iconPath).resize({ height: 16 })
tray.setImage(icon)
} else if (process.platform === 'win32') {
tray.setImage(iconPath)
} else if (process.platform === 'linux') {
tray.setImage(iconPath)
const customIcon = createCustomTrayImage(customTrayIcon)
if (customIcon) {
tray.setImage(customIcon)
await updateTrayToolTip(undefined, undefined, true)
return
}
// macOS 流量显示开启时,由 trayIconUpdate 负责图标更新
if (process.platform === 'darwin' && macTrafficIconEnabled) {
await updateTrayToolTip(undefined, undefined, false)
return
}
const iconPath = disableTrayIconColor ? iconPaths.white : iconPaths[status]
setTrayImage(iconPath)
await updateTrayToolTip(undefined, undefined, false)
} catch {
// Failed to update tray icon
}

View File

@@ -13,10 +13,14 @@ import {
resourcesDir
} from '../utils/dirs'
export function getFilePath(ext: string[]): string[] | undefined {
export function getFilePath(
ext: string[],
title?: string,
filterName?: string
): string[] | undefined {
return dialog.showOpenDialogSync({
title: i18next.t('common.dialog.selectSubscriptionFile'),
filters: [{ name: `${ext} file`, extensions: ext }],
title: title || i18next.t('common.dialog.selectSubscriptionFile'),
filters: [{ name: filterName || `${ext} file`, extensions: ext }],
properties: ['openFile']
})
}

View File

@@ -5,6 +5,7 @@ import { getAppConfig, patchAppConfig, patchControledMihomoConfig } from '../con
import { patchMihomoConfig } from '../core/mihomoApi'
import { mainWindow } from '../window'
import { getDefaultDevice } from '../core/manager'
import { updateTrayIcon } from '../resolve/tray'
export async function getCurrentSSID(): Promise<string | undefined> {
if (process.platform === 'win32') {
@@ -51,6 +52,7 @@ export async function checkSSID(): Promise<void> {
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('appConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
} else {
// DNS 恢复逻辑已移至 patchControledMihomoConfig会在模式从 direct 切换到 rule/global 时自动触发
await patchControledMihomoConfig({ mode: 'rule' })
@@ -58,6 +60,7 @@ export async function checkSSID(): Promise<void> {
mainWindow?.webContents.send('controledMihomoConfigUpdated')
mainWindow?.webContents.send('appConfigUpdated')
ipcMain.emit('updateTrayMenu')
await updateTrayIcon()
}
} catch {
// ignore

View File

@@ -12,6 +12,7 @@ export const defaultConfig: IAppConfig = {
showCurrentProxyInTray: false,
trayProxyGroupStyle: 'default',
disableTrayIconColor: false,
customTrayIcon: '',
maxLogDays: 7,
maxLogFileSize: 10,
proxyCols: 'auto',

View File

@@ -55,6 +55,7 @@ const GeneralConfig: React.FC = () => {
disableTray = false,
swapTrayClick = false,
disableTrayIconColor = false,
customTrayIcon = '',
disableAnimations = false,
showFloatingWindow: showFloating = false,
spinFloatingIcon = true,
@@ -118,7 +119,7 @@ const GeneralConfig: React.FC = () => {
<SettingItem title={t('settings.language')} divider>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
className="w-[150px]"
className="w-37.5"
size="sm"
selectedKeys={[language]}
aria-label={t('settings.language')}
@@ -362,12 +363,60 @@ const GeneralConfig: React.FC = () => {
<Switch
size="sm"
isSelected={disableTrayIconColor}
isDisabled={Boolean(customTrayIcon)}
onValueChange={async (v) => {
await patchAppConfig({ disableTrayIconColor: v })
await updateTrayIcon()
}}
/>
</SettingItem>
<SettingItem
title={t('settings.customTrayIcon')}
actions={
<Tooltip content={t('settings.customTrayIconTooltip')}>
<Button isIconOnly size="sm" variant="light">
<IoIosHelpCircle className="text-lg" />
</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}>
{customTrayIcon}
</span>
)}
<Button
size="sm"
variant="flat"
onPress={async () => {
const files = await getFilePath(
['png', 'jpg', 'jpeg', 'ico', 'icns'],
t('settings.customTrayIconSelect'),
t('settings.customTrayIcon')
)
if (!files?.[0]) return
await patchAppConfig({ customTrayIcon: files[0] })
await updateTrayIcon()
}}
>
{t(customTrayIcon ? 'settings.changeTrayIcon' : 'settings.selectTrayIcon')}
</Button>
{customTrayIcon && (
<Button
size="sm"
variant="light"
onPress={async () => {
await patchAppConfig({ customTrayIcon: '' })
await updateTrayIcon()
}}
>
{t('common.default')}
</Button>
)}
</div>
</SettingItem>
</>
)}
{platform !== 'linux' && (

View File

@@ -2,7 +2,7 @@ import { Tabs, Tab } from '@heroui/react'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { useControledMihomoConfig } from '@renderer/hooks/use-controled-mihomo-config'
import { useGroups } from '@renderer/hooks/use-groups'
import { mihomoCloseAllConnections, patchMihomoConfig } from '@renderer/utils/ipc'
import { mihomoCloseAllConnections, patchMihomoConfig, updateTrayIcon } from '@renderer/utils/ipc'
import { Key } from 'react'
import { useTranslation } from 'react-i18next'
@@ -22,6 +22,7 @@ const OutboundModeSwitcher: React.FC = () => {
}
mutateGroups()
window.electron.ipcRenderer.send('updateTrayMenu')
await updateTrayIcon()
}
if (!mode) return null
return (

View File

@@ -96,6 +96,11 @@
"settings.trayProxyGroupStyleDefault": "Default",
"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.customTrayIconSelect": "Select Tray Icon",
"settings.selectTrayIcon": "Select Icon",
"settings.changeTrayIcon": "Change Icon",
"settings.disableAnimations": "Disable Animation Effects",
"settings.showTraffic_windows": "Show Network Speed in Taskbar",
"settings.showTraffic_mac": "Show Network Speed in Status Bar",
@@ -693,6 +698,10 @@
"tray.openDirectories.coreDir": "Core Directory",
"tray.openDirectories.logDir": "Log Directory",
"tray.copyEnv": "Copy Environment Variables",
"tray.tooltip.mode": "Mode",
"tray.tooltip.enabled": "On",
"tray.tooltip.disabled": "Off",
"tray.tooltip.customIcon": "Custom icon",
"guide.welcome.title": "Welcome to Clash Party",
"guide.welcome.description": "This is an interactive tutorial. If you are already familiar with the software, you can click the close button in the top right corner. You can always open this tutorial again from the settings.",
"guide.sider.title": "Navigation Bar",

View File

@@ -91,6 +91,11 @@
"settings.trayProxyGroupStyleDefault": "پیش‌فرض",
"settings.trayProxyGroupStyleSubmenu": "زیرمنو",
"settings.disableTrayIconColor": "غیرفعال کردن تغییر رنگ آیکون تری",
"settings.customTrayIcon": "آیکون سفارشی سیستم‌تری",
"settings.customTrayIconTooltip": "وقتی تنظیم شود، تری همیشه از این آیکون استفاده می‌کند. وضعیت پروکسی سیستم، TUN و حالت در راهنمای تری نمایش داده می‌شود.",
"settings.customTrayIconSelect": "انتخاب آیکون تری",
"settings.selectTrayIcon": "انتخاب آیکون",
"settings.changeTrayIcon": "تغییر آیکون",
"settings.disableAnimations": "غیرفعال کردن جلوه‌های انیمیشن",
"settings.showTraffic_windows": "نمایش سرعت شبکه در نوار وظیفه",
"settings.showTraffic_mac": "نمایش سرعت شبکه در نوار وضعیت",
@@ -657,6 +662,10 @@
"tray.openDirectories.coreDir": "پوشه هسته",
"tray.openDirectories.logDir": "پوشه گزارش‌ها",
"tray.copyEnv": "کپی متغیرهای محیطی",
"tray.tooltip.mode": "حالت",
"tray.tooltip.enabled": "روشن",
"tray.tooltip.disabled": "خاموش",
"tray.tooltip.customIcon": "آیکون سفارشی",
"guide.welcome.title": "به میهومو پارتی خوش آمدید",
"guide.welcome.description": "این یک آموزش تعاملی است. اگر با نرم‌افزار آشنا هستید، می‌توانید دکمه بستن را در گوشه بالا سمت راست کلیک کنید. همیشه می‌توانید این آموزش را دوباره از تنظیمات باز کنید.",
"guide.sider.title": "نوار پیمایش",

View File

@@ -93,6 +93,11 @@
"settings.trayProxyGroupStyleDefault": "По умолчанию",
"settings.trayProxyGroupStyleSubmenu": "Подменю",
"settings.disableTrayIconColor": "Отключить смену цвета значка в трее",
"settings.customTrayIcon": "Пользовательский значок в трее",
"settings.customTrayIconTooltip": "Если задано, трей всегда использует этот значок. Состояние системного прокси, TUN и режима указано в подсказке трея.",
"settings.customTrayIconSelect": "Выбрать значок трея",
"settings.selectTrayIcon": "Выбрать значок",
"settings.changeTrayIcon": "Изменить значок",
"settings.disableAnimations": "Отключить анимационные эффекты",
"settings.showTraffic_windows": "Показывать скорость в панели задач",
"settings.showTraffic_mac": "Показывать скорость в строке состояния",
@@ -659,6 +664,10 @@
"tray.openDirectories.coreDir": "Директория ядра",
"tray.openDirectories.logDir": "Директория логов",
"tray.copyEnv": "Копировать переменные среды",
"tray.tooltip.mode": "Режим",
"tray.tooltip.enabled": "Вкл",
"tray.tooltip.disabled": "Выкл",
"tray.tooltip.customIcon": "Пользовательский значок",
"guide.welcome.title": "Добро пожаловать в Clash Party",
"guide.welcome.description": "Это интерактивное руководство. Если вы уже знакомы с программой, вы можете закрыть его, нажав кнопку в правом верхнем углу. Вы всегда можете открыть это руководство снова в настройках.",
"guide.sider.title": "Панель навигации",

View File

@@ -96,6 +96,11 @@
"settings.trayProxyGroupStyleDefault": "默认",
"settings.trayProxyGroupStyleSubmenu": "子菜单",
"settings.disableTrayIconColor": "禁用托盘图标颜色变化",
"settings.customTrayIcon": "自定义托盘图标",
"settings.customTrayIconTooltip": "启用后托盘将始终使用此图标系统代理、TUN 和模式状态改由托盘 Tooltip 标注。",
"settings.customTrayIconSelect": "选择托盘图标",
"settings.selectTrayIcon": "选择图标",
"settings.changeTrayIcon": "更换图标",
"settings.disableAnimations": "禁用动画效果",
"settings.showTraffic_windows": "在任务栏显示网速",
"settings.showTraffic_mac": "在状态栏显示网速",
@@ -693,6 +698,10 @@
"tray.openDirectories.coreDir": "内核目录",
"tray.openDirectories.logDir": "日志目录",
"tray.copyEnv": "复制环境变量",
"tray.tooltip.mode": "模式",
"tray.tooltip.enabled": "开启",
"tray.tooltip.disabled": "关闭",
"tray.tooltip.customIcon": "自定义图标",
"guide.welcome.title": "欢迎使用 Clash Party",
"guide.welcome.description": "这是一份交互式使用教程,如果您已经完全熟悉本软件的操作,可以直接点击右上角关闭按钮,后续您可以随时从设置中打开本教程",
"guide.sider.title": "导航栏",

View File

@@ -96,6 +96,11 @@
"settings.trayProxyGroupStyleDefault": "默認",
"settings.trayProxyGroupStyleSubmenu": "子菜單",
"settings.disableTrayIconColor": "禁用托盤圖標顏色變化",
"settings.customTrayIcon": "自定義托盤圖標",
"settings.customTrayIconTooltip": "啟用後托盤將始終使用此圖標系統代理、TUN 和模式狀態改由托盤 Tooltip 標註。",
"settings.customTrayIconSelect": "選擇托盤圖標",
"settings.selectTrayIcon": "選擇圖標",
"settings.changeTrayIcon": "更換圖標",
"settings.disableAnimations": "禁用動畫效果",
"settings.showTraffic_windows": "在任务列顯示網速",
"settings.showTraffic_mac": "在狀態欄顯示網速",
@@ -693,6 +698,10 @@
"tray.openDirectories.coreDir": "內核目錄",
"tray.openDirectories.logDir": "日誌目錄",
"tray.copyEnv": "複製環境變數",
"tray.tooltip.mode": "模式",
"tray.tooltip.enabled": "開啟",
"tray.tooltip.disabled": "關閉",
"tray.tooltip.customIcon": "自定義圖標",
"guide.welcome.title": "歡迎使用 Clash Party",
"guide.welcome.description": "這是一份交互式使用教程,如果您已經完全熟悉本軟件的操作,可以直接點擊右上角關閉按鈕,後續您可以隨時從設置中打開本教程",
"guide.sider.title": "導航欄",

View File

@@ -77,7 +77,7 @@ interface IpcApi {
getRuntimeConfigStr: () => Promise<string>
getRuleStr: (id: string) => Promise<string>
setRuleStr: (id: string, str: string) => Promise<void>
getFilePath: (ext: string[]) => Promise<string[] | undefined>
getFilePath: (ext: string[], title?: string, filterName?: string) => Promise<string[] | undefined>
readTextFile: (filePath: string) => Promise<string>
openFile: (type: 'profile' | 'override', id: string, ext?: 'yaml' | 'js') => Promise<void>
// Core

View File

@@ -325,6 +325,7 @@ interface IAppConfig {
useDockIcon?: boolean
showTraffic?: boolean
disableTrayIconColor?: boolean
customTrayIcon?: string
trayProxyGroupStyle?: 'default' | 'submenu'
disableAnimations?: boolean
webdavUrl?: string