From 043f9065b33fe5f7f8cdc0d438fd049ecb38f524 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 11 Jul 2026 15:52:43 +0800 Subject: [PATCH 1/2] fix: keep Windows notification badge state in sync --- .../src/desktopApis/DesktopApiNotification.ts | 5 +- patches/electron-taskbar-badge+1.1.2.patch | 82 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts b/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts index bf77fd5abf9b..0642402db0b1 100644 --- a/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts +++ b/packages/kit-bg/src/desktopApis/DesktopApiNotification.ts @@ -376,8 +376,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) { + this.win32TaskBarBadge.update(badgeCount); } else { win.setOverlayIcon(null, ''); } diff --git a/patches/electron-taskbar-badge+1.1.2.patch b/patches/electron-taskbar-badge+1.1.2.patch index 3b5134328b3e..f34066d9fbfd 100644 --- a/patches/electron-taskbar-badge+1.1.2.patch +++ b/patches/electron-taskbar-badge+1.1.2.patch @@ -1,3 +1,85 @@ +diff --git a/node_modules/electron-taskbar-badge/lib/cjs/index.js b/node_modules/electron-taskbar-badge/lib/cjs/index.js +index 0677a54..3241e49 100644 +--- a/node_modules/electron-taskbar-badge/lib/cjs/index.js ++++ b/node_modules/electron-taskbar-badge/lib/cjs/index.js +@@ -43,6 +43,8 @@ module.exports = class Badge { + if (process.platform !== 'win32') console.warn('Only win32 environments are supported!'); + this.win = win; + this.opts = opts; ++ // OneKey patch: invalidate badge renders that finish after a newer update. ++ this.updateVersion = 0; + const accentColor = getLightAccentColor(powershell); + this.generator = new BadgeGenerator(win, opts, accentColor); + systemPreferences.on('accent-color-changed', () => { +@@ -66,15 +68,19 @@ module.exports = class Badge { + if (typeof badgeNumber !== 'number' && badgeNumber != null) { + throw new TypeError(`Invalid badgeNumber specified.\nExpected: number\nGot: ${typeof badgeNumber}`); + } ++ const updateVersion = ++this.updateVersion; ++ // OneKey patch: keep show and accent-color listeners in sync when clearing. ++ currentNumber = badgeNumber; + if (badgeNumber) { + this.generator.generate(badgeNumber).then((base64) => { ++ // OneKey patch: do not restore a stale badge after clear or window close. ++ if (updateVersion !== this.updateVersion || !this.win) return; + const image = nativeImage.createFromDataURL(base64); + currentOverlayIcon = { + image, + badgeDescription, + }; + this.win.setOverlayIcon(currentOverlayIcon.image, currentOverlayIcon.badgeDescription); +- currentNumber = badgeNumber; + }); + } else { + currentOverlayIcon = { +@@ -125,4 +131,4 @@ function rgbToHex(r, g, b) { + + const rgb = blue | (green << 8) | (red << 16); + return '#' + rgb.toString(16); +-} +\ No newline at end of file ++} +diff --git a/node_modules/electron-taskbar-badge/lib/esm/index.mjs b/node_modules/electron-taskbar-badge/lib/esm/index.mjs +index 2eecb35..27a9fd4 100644 +--- a/node_modules/electron-taskbar-badge/lib/esm/index.mjs ++++ b/node_modules/electron-taskbar-badge/lib/esm/index.mjs +@@ -43,6 +43,8 @@ export default class Badge { + if (process.platform !== 'win32') console.warn('Only win32 environments are supported!'); + this.win = win; + this.opts = opts; ++ // OneKey patch: invalidate badge renders that finish after a newer update. ++ this.updateVersion = 0; + const accentColor = getLightAccentColor(powershell); + this.generator = new BadgeGenerator(win, opts, accentColor); + systemPreferences.on('accent-color-changed', () => { +@@ -66,15 +68,19 @@ export default class Badge { + if (typeof badgeNumber !== 'number' && badgeNumber != null) { + throw new TypeError(`Invalid badgeNumber specified.\nExpected: number\nGot: ${typeof badgeNumber}`); + } ++ const updateVersion = ++this.updateVersion; ++ // OneKey patch: keep show and accent-color listeners in sync when clearing. ++ currentNumber = badgeNumber; + if (badgeNumber) { + this.generator.generate(badgeNumber).then((base64) => { ++ // OneKey patch: do not restore a stale badge after clear or window close. ++ if (updateVersion !== this.updateVersion || !this.win) return; + const image = nativeImage.createFromDataURL(base64); + currentOverlayIcon = { + image, + badgeDescription, + }; + this.win.setOverlayIcon(currentOverlayIcon.image, currentOverlayIcon.badgeDescription); +- currentNumber = badgeNumber; + }); + } else { + currentOverlayIcon = { +@@ -124,4 +130,4 @@ function rgbToHex(r, g, b) { + + const rgb = blue | (green << 8) | (red << 16); + return '#' + rgb.toString(16); +-} +\ No newline at end of file ++} 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 From b1edf82ae6cedff36524f352ca85295959d5b6b0 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 11 Jul 2026 16:13:12 +0800 Subject: [PATCH 2/2] refactor: own Windows taskbar badge implementation --- package.json | 1 - .../src/desktopApis/DesktopApiNotification.ts | 39 +--- .../desktopApis/WindowsTaskbarBadge.test.ts | 104 +++++++++++ .../src/desktopApis/WindowsTaskbarBadge.ts | 166 ++++++++++++++++++ patches/electron-taskbar-badge+1.1.2.patch | 96 ---------- yarn.lock | 8 - 6 files changed, 276 insertions(+), 138 deletions(-) create mode 100644 packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.test.ts create mode 100644 packages/kit-bg/src/desktopApis/WindowsTaskbarBadge.ts delete mode 100644 patches/electron-taskbar-badge+1.1.2.patch diff --git a/package.json b/package.json index 9b63fe962fd4..0357f5eb26d5 100644 --- a/package.json +++ b/package.json @@ -208,7 +208,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 0642402db0b1..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'); } } } @@ -378,7 +351,7 @@ class DesktopApiNotification { if (win) { const badgeCount = !isNil(count) && count > 0 ? count : 0; if (this.win32TaskBarBadge) { - this.win32TaskBarBadge.update(badgeCount); + 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 f34066d9fbfd..000000000000 --- a/patches/electron-taskbar-badge+1.1.2.patch +++ /dev/null @@ -1,96 +0,0 @@ -diff --git a/node_modules/electron-taskbar-badge/lib/cjs/index.js b/node_modules/electron-taskbar-badge/lib/cjs/index.js -index 0677a54..3241e49 100644 ---- a/node_modules/electron-taskbar-badge/lib/cjs/index.js -+++ b/node_modules/electron-taskbar-badge/lib/cjs/index.js -@@ -43,6 +43,8 @@ module.exports = class Badge { - if (process.platform !== 'win32') console.warn('Only win32 environments are supported!'); - this.win = win; - this.opts = opts; -+ // OneKey patch: invalidate badge renders that finish after a newer update. -+ this.updateVersion = 0; - const accentColor = getLightAccentColor(powershell); - this.generator = new BadgeGenerator(win, opts, accentColor); - systemPreferences.on('accent-color-changed', () => { -@@ -66,15 +68,19 @@ module.exports = class Badge { - if (typeof badgeNumber !== 'number' && badgeNumber != null) { - throw new TypeError(`Invalid badgeNumber specified.\nExpected: number\nGot: ${typeof badgeNumber}`); - } -+ const updateVersion = ++this.updateVersion; -+ // OneKey patch: keep show and accent-color listeners in sync when clearing. -+ currentNumber = badgeNumber; - if (badgeNumber) { - this.generator.generate(badgeNumber).then((base64) => { -+ // OneKey patch: do not restore a stale badge after clear or window close. -+ if (updateVersion !== this.updateVersion || !this.win) return; - const image = nativeImage.createFromDataURL(base64); - currentOverlayIcon = { - image, - badgeDescription, - }; - this.win.setOverlayIcon(currentOverlayIcon.image, currentOverlayIcon.badgeDescription); -- currentNumber = badgeNumber; - }); - } else { - currentOverlayIcon = { -@@ -125,4 +131,4 @@ function rgbToHex(r, g, b) { - - const rgb = blue | (green << 8) | (red << 16); - return '#' + rgb.toString(16); --} -\ No newline at end of file -+} -diff --git a/node_modules/electron-taskbar-badge/lib/esm/index.mjs b/node_modules/electron-taskbar-badge/lib/esm/index.mjs -index 2eecb35..27a9fd4 100644 ---- a/node_modules/electron-taskbar-badge/lib/esm/index.mjs -+++ b/node_modules/electron-taskbar-badge/lib/esm/index.mjs -@@ -43,6 +43,8 @@ export default class Badge { - if (process.platform !== 'win32') console.warn('Only win32 environments are supported!'); - this.win = win; - this.opts = opts; -+ // OneKey patch: invalidate badge renders that finish after a newer update. -+ this.updateVersion = 0; - const accentColor = getLightAccentColor(powershell); - this.generator = new BadgeGenerator(win, opts, accentColor); - systemPreferences.on('accent-color-changed', () => { -@@ -66,15 +68,19 @@ export default class Badge { - if (typeof badgeNumber !== 'number' && badgeNumber != null) { - throw new TypeError(`Invalid badgeNumber specified.\nExpected: number\nGot: ${typeof badgeNumber}`); - } -+ const updateVersion = ++this.updateVersion; -+ // OneKey patch: keep show and accent-color listeners in sync when clearing. -+ currentNumber = badgeNumber; - if (badgeNumber) { - this.generator.generate(badgeNumber).then((base64) => { -+ // OneKey patch: do not restore a stale badge after clear or window close. -+ if (updateVersion !== this.updateVersion || !this.win) return; - const image = nativeImage.createFromDataURL(base64); - currentOverlayIcon = { - image, - badgeDescription, - }; - this.win.setOverlayIcon(currentOverlayIcon.image, currentOverlayIcon.badgeDescription); -- currentNumber = badgeNumber; - }); - } else { - currentOverlayIcon = { -@@ -124,4 +130,4 @@ function rgbToHex(r, g, b) { - - const rgb = blue | (green << 8) | (red << 16); - return '#' + rgb.toString(16); --} -\ No newline at end of file -+} -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 f6a2bf1b3282..205136f9b7ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9903,7 +9903,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" @@ -27624,13 +27623,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"