diff --git a/resources/trayTemplate.png b/resources/trayTemplate.png
new file mode 100644
index 0000000..d0fc06b
Binary files /dev/null and b/resources/trayTemplate.png differ
diff --git a/resources/trayTemplate.svg b/resources/trayTemplate.svg
new file mode 100644
index 0000000..6fffd2f
--- /dev/null
+++ b/resources/trayTemplate.svg
@@ -0,0 +1,13 @@
+
diff --git a/resources/trayTemplate@2x.png b/resources/trayTemplate@2x.png
new file mode 100644
index 0000000..7140283
Binary files /dev/null and b/resources/trayTemplate@2x.png differ
diff --git a/src/main/index.ts b/src/main/index.ts
index 8cc1012..b81417b 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -21,7 +21,10 @@ import { dirname, extname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
+import trayTemplateIcon from '../../resources/trayTemplate.png?asset'
+import trayTemplateRetinaIcon from '../../resources/trayTemplate@2x.png?asset'
import { WechatDb } from './wechat-db'
+import { createTrayImage } from './tray-icon'
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
@@ -370,6 +373,18 @@ function configureRecallProtection(
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
+const packagedTrayTemplateIconPath = join(process.resourcesPath, 'resources', 'trayTemplate.png')
+const packagedTrayTemplateRetinaIconPath = join(
+ process.resourcesPath,
+ 'resources',
+ 'trayTemplate@2x.png'
+)
+const trayTemplateIconPath = existsSync(packagedTrayTemplateIconPath)
+ ? packagedTrayTemplateIconPath
+ : trayTemplateIcon
+const trayTemplateRetinaIconPath = existsSync(packagedTrayTemplateRetinaIconPath)
+ ? packagedTrayTemplateRetinaIconPath
+ : trayTemplateRetinaIcon
protocol.registerSchemesAsPrivileged([
{
@@ -921,8 +936,6 @@ app.whenReady().then(async () => {
// 设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.tracememo.app')
- if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
-
// 开发环境中默认使用 F12 打开或关闭 DevTools
// 生产环境中忽略 CommandOrControl + R
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -2875,11 +2888,12 @@ function buildTrayMenu(): Menu {
function setupTray(): void {
if (tray) return
try {
- const image = nativeImage.createFromPath(appIconPath)
- const traySize = process.platform === 'darwin' ? 20 : 24
- const trayImage = image.isEmpty()
- ? nativeImage.createEmpty()
- : image.resize({ width: traySize, height: traySize, quality: 'best' })
+ const trayImage = createTrayImage(
+ process.platform,
+ appIconPath,
+ { oneX: trayTemplateIconPath, twoX: trayTemplateRetinaIconPath },
+ nativeImage
+ )
tray = new Tray(trayImage)
tray.setToolTip('TraceMemo')
// macOS may show a Tray context menu on a primary click when it is set
diff --git a/src/main/tray-icon.ts b/src/main/tray-icon.ts
new file mode 100644
index 0000000..65eaa37
--- /dev/null
+++ b/src/main/tray-icon.ts
@@ -0,0 +1,51 @@
+import { readFileSync } from 'fs'
+
+export interface TrayNativeImage {
+ addRepresentation(options: { scaleFactor: number; dataURL: string }): void
+ isEmpty(): boolean
+ resize(options: { width: number; height: number; quality: 'best' }): TrayNativeImage
+ setTemplateImage(option: boolean): void
+}
+
+export interface TrayNativeImageFactory {
+ createEmpty(): T
+ createFromPath(path: string): T
+}
+
+export interface TrayTemplateIconPaths {
+ oneX: string
+ twoX: string
+}
+
+type ReadImageFile = (path: string) => Buffer
+
+function toPngDataUrl(buffer: Buffer): string {
+ return `data:image/png;base64,${buffer.toString('base64')}`
+}
+
+export function createTrayImage(
+ platform: NodeJS.Platform,
+ appIconPath: string,
+ templateIconPaths: TrayTemplateIconPaths,
+ nativeImage: TrayNativeImageFactory,
+ readImageFile: ReadImageFile = readFileSync
+): T {
+ if (platform === 'darwin') {
+ const image = nativeImage.createEmpty()
+ image.addRepresentation({
+ scaleFactor: 1,
+ dataURL: toPngDataUrl(readImageFile(templateIconPaths.oneX))
+ })
+ image.addRepresentation({
+ scaleFactor: 2,
+ dataURL: toPngDataUrl(readImageFile(templateIconPaths.twoX))
+ })
+ image.setTemplateImage(true)
+ return image
+ }
+
+ const image = nativeImage.createFromPath(appIconPath)
+ return image.isEmpty()
+ ? nativeImage.createEmpty()
+ : (image.resize({ width: 24, height: 24, quality: 'best' }) as T)
+}
diff --git a/src/renderer/src/components/account/AccountSummary.tsx b/src/renderer/src/components/account/AccountSummary.tsx
index 06d905d..a0b0687 100644
--- a/src/renderer/src/components/account/AccountSummary.tsx
+++ b/src/renderer/src/components/account/AccountSummary.tsx
@@ -26,11 +26,7 @@ export function AccountSummary({
const displayName =
showAccount && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
const subtitle = showAccount && selfInfo ? selfInfo.wxid : '打开设置'
- const statusText = dbReady
- ? '数据库已连接'
- : dbConnecting
- ? '正在连接数据库'
- : '数据库未连接'
+ const statusText = dbReady ? '数据库已连接' : dbConnecting ? '正在连接数据库' : '数据库未连接'
const statusClass = dbReady ? 'ready' : dbConnecting ? 'connecting' : ''
const initial = (displayName || '?').charAt(0)
const title = `${displayName}\n${subtitle}`
@@ -66,8 +62,8 @@ export function AccountSummary({
diff --git a/src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx b/src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx
index a460193..30a43c2 100644
--- a/src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx
+++ b/src/renderer/src/features/settings/account-database/ConnectionHealthSection.tsx
@@ -4,11 +4,36 @@ function DiagnosticIcon({ status }: { status: DiagnosticStatus }): React.ReactEl
if (status === 'checking') {
return
}
- const symbol =
- status === 'success' ? '✓' : status === 'warning' ? '!' : status === 'error' ? '×' : '—'
+
+ const icon =
+ status === 'success' ? (
+
+ ) : status === 'warning' ? (
+ <>
+
+
+ >
+ ) : status === 'error' ? (
+
+ ) : (
+
+ )
+
return (
- {symbol}
+
)
}
diff --git a/src/renderer/src/styles/settings.scss b/src/renderer/src/styles/settings.scss
index 610dd8a..a571ef6 100644
--- a/src/renderer/src/styles/settings.scss
+++ b/src/renderer/src/styles/settings.scss
@@ -416,8 +416,9 @@
border: 1px solid #929a96;
border-radius: 50%;
color: #929a96;
- font-size: 11px;
- font-weight: 700;
+}
+.settings-diagnostic-icon svg {
+ display: block;
}
.settings-diagnostic-icon.success {
border-color: #2e8b68;
diff --git a/tests/component/account-database-controls.test.tsx b/tests/component/account-database-controls.test.tsx
index 160945a..1725859 100644
--- a/tests/component/account-database-controls.test.tsx
+++ b/tests/component/account-database-controls.test.tsx
@@ -1,7 +1,10 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
+import { AccountSummary } from '../../src/renderer/src/components/account/AccountSummary'
import { AccountOverview } from '../../src/renderer/src/features/settings/account-database/AccountOverview'
+import { ConnectionHealthSection } from '../../src/renderer/src/features/settings/account-database/ConnectionHealthSection'
+import type { ConnectionDiagnostic } from '../../src/renderer/src/features/settings/account-database/types'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
describe('account database controls', () => {
@@ -61,4 +64,54 @@ describe('account database controls', () => {
expect(screen.getByRole('button', { name: '打开账号目录' })).toBeDisabled()
expect(screen.getByRole('button', { name: '复制账号目录' })).toBeDisabled()
})
+
+ it('renders connection states with SVG glyphs and keeps checking accessible', () => {
+ const diagnostics: ConnectionDiagnostic[] = [
+ { id: 'db-key', label: '数据库密钥', status: 'success', result: '已用于当前连接' },
+ { id: 'contacts', label: '联系人索引', status: 'warning', result: '索引待更新' },
+ { id: 'messages', label: '消息数据库', status: 'error', result: '读取失败' },
+ { id: 'identity', label: '账号身份', status: 'idle', result: '尚未检测' },
+ { id: 'image-key', label: '图片解密密钥', status: 'unavailable', result: '未配置' }
+ ]
+ const { container, rerender } = render()
+
+ const icons = container.querySelectorAll('.settings-diagnostic-icon:not(.checking)')
+ expect(icons).toHaveLength(diagnostics.length)
+ icons.forEach((icon) => {
+ expect(icon).toHaveAttribute('aria-hidden', 'true')
+ expect(icon.querySelector('svg')).toHaveAttribute('viewBox', '0 0 24 24')
+ })
+ expect(container.textContent).not.toMatch(/[✓!×—]/)
+
+ rerender(
+
+ )
+ expect(screen.getByLabelText('检测中')).toHaveClass('checking')
+ expect(container.querySelector('.checking svg')).toBeNull()
+ })
+
+ it('keeps the account settings button clickable with a centered-viewBox SVG', async () => {
+ const user = userEvent.setup()
+ const onClick = vi.fn()
+ render(
+
+ )
+
+ const button = screen.getByRole('button', { name: '设置' })
+ expect(button.querySelector('svg')).toHaveAttribute('viewBox', '0 0 24 24')
+ expect(button.querySelector('svg circle')).toHaveAttribute('cx', '12')
+ expect(button.querySelector('svg circle')).toHaveAttribute('cy', '12')
+ await user.click(button)
+ expect(onClick).toHaveBeenCalledOnce()
+ })
})
diff --git a/tests/unit/tray-icon.test.ts b/tests/unit/tray-icon.test.ts
new file mode 100644
index 0000000..ebcbfc5
--- /dev/null
+++ b/tests/unit/tray-icon.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createTrayImage, type TrayNativeImage } from '../../src/main/tray-icon'
+
+function makeImage(empty = false): TrayNativeImage {
+ const image = {
+ addRepresentation: vi.fn(),
+ isEmpty: vi.fn(() => empty),
+ resize: vi.fn(),
+ setTemplateImage: vi.fn()
+ }
+ image.resize.mockReturnValue(image)
+ return image as unknown as TrayNativeImage
+}
+
+describe('tray icon creation', () => {
+ it('uses a monochrome 1x/2x template image on macOS', () => {
+ const image = makeImage()
+ const readImageFile = vi.fn((path: string) => Buffer.from(path))
+ const nativeImage = {
+ createEmpty: vi.fn(() => image),
+ createFromPath: vi.fn(() => makeImage())
+ }
+
+ const result = createTrayImage(
+ 'darwin',
+ '/app/icon.png',
+ { oneX: '/resources/trayTemplate.png', twoX: '/resources/trayTemplate@2x.png' },
+ nativeImage,
+ readImageFile
+ )
+
+ expect(result).toBe(image)
+ expect(readImageFile.mock.calls.map(([path]) => path)).toEqual([
+ '/resources/trayTemplate.png',
+ '/resources/trayTemplate@2x.png'
+ ])
+ expect(image.addRepresentation).toHaveBeenNthCalledWith(1, {
+ scaleFactor: 1,
+ dataURL: `data:image/png;base64,${Buffer.from('/resources/trayTemplate.png').toString('base64')}`
+ })
+ expect(image.addRepresentation).toHaveBeenNthCalledWith(2, {
+ scaleFactor: 2,
+ dataURL: `data:image/png;base64,${Buffer.from('/resources/trayTemplate@2x.png').toString('base64')}`
+ })
+ expect(image.setTemplateImage).toHaveBeenCalledWith(true)
+ expect(nativeImage.createFromPath).not.toHaveBeenCalled()
+ })
+
+ it('preserves the app icon resize policy on Windows', () => {
+ const appImage = makeImage()
+ const nativeImage = {
+ createEmpty: vi.fn(() => makeImage()),
+ createFromPath: vi.fn(() => appImage)
+ }
+
+ const result = createTrayImage(
+ 'win32',
+ '/app/icon.png',
+ { oneX: '/resources/trayTemplate.png', twoX: '/resources/trayTemplate@2x.png' },
+ nativeImage,
+ vi.fn(() => Buffer.alloc(0))
+ )
+
+ expect(nativeImage.createFromPath).toHaveBeenCalledWith('/app/icon.png')
+ expect(appImage.resize).toHaveBeenCalledWith({ width: 24, height: 24, quality: 'best' })
+ expect(appImage.setTemplateImage).not.toHaveBeenCalled()
+ expect(result).toBe(appImage)
+ })
+})