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 ( ) } 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) + }) +})