diff --git a/package.json b/package.json index 20931aa5d01d..012483803c00 100644 --- a/package.json +++ b/package.json @@ -205,7 +205,6 @@ "colord": "^2.9.3", "crypto-browserify": "^3.12.0", "ejs-loader": "^0.5.0", - "electron-taskbar-badge": "1.1.2", "ethers": "5.7.2", "ethersV6": "npm:ethers@^6.11.1", "event-source-polyfill": "^1.0.31", diff --git a/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts b/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts index bf77fd5abf9b..e360dd338e32 100644 --- a/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts +++ b/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts @@ -2,7 +2,6 @@ import { Notification, app, systemPreferences } from 'electron'; import logger from 'electron-log/main'; import { isNil } from 'lodash'; -import { ipcMessageKeys } from '@onekeyhq/desktop/app/config'; import type { INotificationPermissionDetail, INotificationSetBadgeParams, @@ -10,17 +9,9 @@ import type { } from '@onekeyhq/shared/types/notification'; import { ENotificationPermission } from '@onekeyhq/shared/types/notification'; -import type { IDesktopApi } from './instance/IDesktopApi'; +import WindowsTaskbarBadge from './WindowsTaskbarBadge'; -// The package's ESM entry incorrectly resolves BadgeGenerator under Rspack. -// Use the CommonJS entry, whose export shape is compatible with Electron's main process. -// eslint-disable-next-line @typescript-eslint/no-require-imports -const TaskBarBadgeWindows = require('electron-taskbar-badge') as new ( - win: object, - options: object, -) => { - update(badgeNumber: number): void; -}; +import type { IDesktopApi } from './instance/IDesktopApi'; const isWin = process.platform === 'win32'; const isMac = process.platform === 'darwin'; @@ -307,9 +298,7 @@ class DesktopApiNotification { desktopApi: IDesktopApi; - private win32TaskBarBadge?: { - update(badgeNumber: number): void; - }; + private win32TaskBarBadge?: WindowsTaskbarBadge; private initWin32TaskBarBadge(APP_NAME: string) { if (process.platform === 'win32') { @@ -318,24 +307,8 @@ class DesktopApiNotification { globalThis.$desktopMainAppFunctions?.getSafelyMainWindow?.(); if (safelyMainWindow) { - // TODO not working on Windows 11 (UTM) - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - this.win32TaskBarBadge = new TaskBarBadgeWindows(safelyMainWindow, { - fontColor: '#000000', - font: '62px Microsoft Yahei', - color: '#000000', - radius: 48, - updateBadgeEvent: ipcMessageKeys.NOTIFICATION_SET_BADGE_WINDOWS, - badgeDescription: '', - invokeType: 'handle', // handle -> ipcRenderer.invoke, send -> ipcRenderer.sendSync - max: 99, - fit: false, - useSystemAccentTheme: true, - additionalFunc: (count: number) => { - console.log(`Received ${count} new notifications!`); - }, - }); - console.log('TaskBarBadgeWindows init'); + this.win32TaskBarBadge = new WindowsTaskbarBadge(safelyMainWindow); + logger.info('Windows taskbar badge initialized'); } } } @@ -376,8 +349,9 @@ class DesktopApiNotification { if (isWin) { const win = globalThis.$desktopMainAppFunctions?.getSafelyMainWindow?.(); if (win) { - if (!isNil(count) && count > 0) { - this.win32TaskBarBadge?.update(count); + const badgeCount = !isNil(count) && count > 0 ? count : 0; + if (this.win32TaskBarBadge) { + await this.win32TaskBarBadge.update(badgeCount); } else { win.setOverlayIcon(null, ''); } diff --git a/packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.test.ts b/packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.test.ts new file mode 100644 index 000000000000..704f1dc8833b --- /dev/null +++ b/packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.test.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from 'events'; + +import { systemPreferences } from 'electron'; + +import WindowsTaskbarBadge from './WindowsTaskbarBadge'; + +import type { BrowserWindow } from 'electron'; + +jest.mock('electron-log/main', () => ({ + __esModule: true, + default: { + error: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock('electron', () => { + const { EventEmitter: MockEventEmitter } = + jest.requireActual('events'); + return { + nativeImage: { + createFromDataURL: jest.fn(() => ({ + isEmpty: jest.fn(() => false), + })), + }, + systemPreferences: Object.assign(new MockEventEmitter(), { + getAccentColor: jest.fn(() => '4cc2ffff'), + }), + }; +}); + +const electronMock = jest.requireMock('electron') as { + nativeImage: { + createFromDataURL: jest.Mock; + }; +}; + +class FakeWindow extends EventEmitter { + destroyed = false; + + setOverlayIcon = jest.fn(); + + webContents = { + executeJavaScript: jest.fn, [string]>(), + }; + + isDestroyed = () => this.destroyed; +} + +function createBadge(window: FakeWindow): WindowsTaskbarBadge { + return new WindowsTaskbarBadge(window as unknown as BrowserWindow); +} + +async function flushPromises(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +describe('WindowsTaskbarBadge', () => { + beforeEach(() => { + jest.clearAllMocks(); + systemPreferences.removeAllListeners(); + }); + + test('keeps the badge cleared across window show and accent changes', async () => { + const window = new FakeWindow(); + window.webContents.executeJavaScript.mockResolvedValue( + 'data:image/png;base64,badge', + ); + const badge = createBadge(window); + + await badge.update(7); + await badge.update(0); + window.emit('show'); + systemPreferences.emit('accent-color-changed', 'ffffffff'); + await flushPromises(); + + expect(window.webContents.executeJavaScript).toHaveBeenCalledTimes(1); + expect(window.setOverlayIcon).toHaveBeenLastCalledWith(null, ''); + }); + + test('ignores a stale render that finishes after the badge is cleared', async () => { + let resolveRender: ((dataUrl: string) => void) | undefined; + const window = new FakeWindow(); + window.webContents.executeJavaScript.mockImplementation( + () => + new Promise((resolve) => { + resolveRender = resolve; + }), + ); + const badge = createBadge(window); + + const pendingRender = badge.update(7); + await badge.update(0); + resolveRender?.('data:image/png;base64,badge'); + await pendingRender; + + expect(electronMock.nativeImage.createFromDataURL.mock.calls).toHaveLength( + 0, + ); + expect(window.setOverlayIcon).toHaveBeenLastCalledWith(null, ''); + }); +}); diff --git a/packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.ts b/packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.ts new file mode 100644 index 000000000000..df82bd5c43aa --- /dev/null +++ b/packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.ts @@ -0,0 +1,166 @@ +import { nativeImage, systemPreferences } from 'electron'; +import logger from 'electron-log/main'; + +import type { BrowserWindow } from 'electron'; + +const BADGE_SIZE = 96; +const BADGE_RADIUS = 44; +const MAX_BADGE_COUNT = 99; +const DEFAULT_ACCENT_COLOR = '#4cc2ff'; + +function normalizeAccentColor(value: string): string { + const hex = value.replace(/^#/, ''); + if (/^[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(hex)) { + return `#${hex.slice(0, 6)}`; + } + return DEFAULT_ACCENT_COLOR; +} + +function getAccentColor(): string { + try { + return normalizeAccentColor(systemPreferences.getAccentColor()); + } catch (error) { + logger.warn('Failed to read Windows accent color', error); + return DEFAULT_ACCENT_COLOR; + } +} + +function getTextColor(backgroundColor: string): string { + const red = Number.parseInt(backgroundColor.slice(1, 3), 16); + const green = Number.parseInt(backgroundColor.slice(3, 5), 16); + const blue = Number.parseInt(backgroundColor.slice(5, 7), 16); + const brightness = red * 0.2126 + green * 0.7152 + blue * 0.0722; + return brightness > 160 ? '#000000' : '#ffffff'; +} + +function buildBadgeScript(count: number): string { + const label = count > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : `${count}`; + const backgroundColor = getAccentColor(); + const textColor = getTextColor(backgroundColor); + const fontSize = label.length > 2 ? 42 : 62; + + return `(() => { + const canvas = document.createElement('canvas'); + canvas.width = ${BADGE_SIZE}; + canvas.height = ${BADGE_SIZE}; + const context = canvas.getContext('2d'); + if (!context) return ''; + context.fillStyle = ${JSON.stringify(backgroundColor)}; + context.beginPath(); + context.arc(${BADGE_SIZE / 2}, ${BADGE_SIZE / 2}, ${BADGE_RADIUS}, 0, Math.PI * 2); + context.fill(); + context.fillStyle = ${JSON.stringify(textColor)}; + context.font = ${JSON.stringify(`600 ${fontSize}px "Segoe UI"`)}; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText(${JSON.stringify(label)}, ${BADGE_SIZE / 2}, ${BADGE_SIZE / 2}); + return canvas.toDataURL('image/png'); + })()`; +} + +class WindowsTaskbarBadge { + private window: BrowserWindow | undefined; + + private currentCount = 0; + + private currentOverlayIcon: ReturnType< + typeof nativeImage.createFromDataURL + > | null = null; + + private updateVersion = 0; + + constructor(window: BrowserWindow) { + this.window = window; + window.on('show', this.handleWindowShow); + window.once('closed', this.handleWindowClosed); + systemPreferences.on('accent-color-changed', this.handleAccentColorChanged); + } + + private getDescription(): string { + return this.currentCount > 0 + ? `Unread notifications: ${this.currentCount}` + : ''; + } + + private handleWindowShow = () => { + const window = this.window; + if (!window || window.isDestroyed()) { + return; + } + window.setOverlayIcon(this.currentOverlayIcon, this.getDescription()); + }; + + private handleWindowClosed = () => { + this.updateVersion += 1; + const window = this.window; + window?.removeListener('show', this.handleWindowShow); + systemPreferences.removeListener( + 'accent-color-changed', + this.handleAccentColorChanged, + ); + this.window = undefined; + this.currentOverlayIcon = null; + }; + + private handleAccentColorChanged = () => { + if (this.currentCount > 0) { + void this.update(this.currentCount); + } + }; + + async update(badgeNumber: number): Promise { + const count = + Number.isFinite(badgeNumber) && badgeNumber > 0 + ? Math.floor(badgeNumber) + : 0; + this.updateVersion += 1; + const updateVersion = this.updateVersion; + this.currentCount = count; + + const window = this.window; + if (!window || window.isDestroyed()) { + return; + } + + if (count === 0) { + this.currentOverlayIcon = null; + window.setOverlayIcon(null, ''); + return; + } + + try { + const dataUrl = await window.webContents.executeJavaScript( + buildBadgeScript(count), + ); + if ( + updateVersion !== this.updateVersion || + this.window !== window || + window.isDestroyed() + ) { + return; + } + if ( + typeof dataUrl !== 'string' || + !dataUrl.startsWith('data:image/png;base64,') + ) { + logger.warn( + 'Windows taskbar badge renderer returned invalid image data', + ); + return; + } + + const image = nativeImage.createFromDataURL(dataUrl); + if (image.isEmpty()) { + logger.warn('Windows taskbar badge renderer returned an empty image'); + return; + } + + this.currentOverlayIcon = image; + window.setOverlayIcon(image, this.getDescription()); + } catch (error) { + logger.error('Failed to update Windows taskbar badge', error); + } + } +} + +export default WindowsTaskbarBadge; diff --git a/patches/electron-taskbar-badge+1.1.2.patch b/patches/electron-taskbar-badge+1.1.2.patch deleted file mode 100644 index 3b5134328b3e..000000000000 --- a/patches/electron-taskbar-badge+1.1.2.patch +++ /dev/null @@ -1,14 +0,0 @@ -diff --git a/node_modules/electron-taskbar-badge/package.json b/node_modules/electron-taskbar-badge/package.json -index dea130a..c081e53 100644 ---- a/node_modules/electron-taskbar-badge/package.json -+++ b/node_modules/electron-taskbar-badge/package.json -@@ -7,7 +7,8 @@ - "exports": { - ".": { - "require": "./lib/cjs/index.js", -- "default": "./lib/esm/index.mjs" -+ "default": "./lib/esm/index.mjs", -+ "types": "./types/index.d.ts" - } - }, - "module": "./lib/esm/index.mjs", diff --git a/yarn.lock b/yarn.lock index ef327a8b41d4..95388fd1f7db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9811,7 +9811,6 @@ __metadata: date-fns: "npm:2.30.0" duplicate-package-checker-webpack-plugin: "npm:^3.0.0" ejs-loader: "npm:^0.5.0" - electron-taskbar-badge: "npm:1.1.2" eslint: "npm:8.57.1" eslint-config-airbnb: "npm:^19.0.4" eslint-config-airbnb-typescript: "npm:^17.0.0" @@ -27442,13 +27441,6 @@ __metadata: languageName: node linkType: hard -"electron-taskbar-badge@npm:1.1.2": - version: 1.1.2 - resolution: "electron-taskbar-badge@npm:1.1.2" - checksum: 10/97caafc8d7d7089471c111818a33dfc7d9d772feec6f9c5f1ac02f309f96ce18dded931f3a40e2932b1571183d99b3807a5b6c93e433279bc437ba76512719ec - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.4.601": version: 1.4.616 resolution: "electron-to-chromium@npm:1.4.616"