feat(desktop): tray unread dot and monochrome tray icon (#3477)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 20:00:40 +02:00
1 parent 36095ab66f
commit b3f373c94c
51 files changed
+1076 -47

No files matched your search

@@ -57,6 +57,7 @@ export type ChromiumSwitchesSetting = ReadonlyArray<string> | Record<string, unk
interface PersistedDesktopWindowBehaviorSettings {
showTrayIcon?: boolean;
coloredTrayIcon?: boolean;
useNativeTitleBar?: boolean;
minimizeToTrayV2?: boolean;
closeToTrayV2?: boolean;
@@ -87,6 +88,7 @@ function getDefaultDesktopTroubleshootingSettings(): DesktopTroubleshootingSetti
function getDefaultDesktopWindowBehaviorSettings(): DesktopWindowBehaviorSettings {
return {
showTrayIcon: true,
coloredTrayIcon: false,
minimizeToTray: false,
closeToTray: true,
startMinimized: false,
@@ -112,6 +114,9 @@ function sanitizePersistedDesktopWindowBehaviorSettings(
if (typeof value.showTrayIcon === 'boolean') {
settings.showTrayIcon = value.showTrayIcon;
}
if (typeof value.coloredTrayIcon === 'boolean') {
settings.coloredTrayIcon = value.coloredTrayIcon;
}
if (typeof value.useNativeTitleBar === 'boolean') {
settings.useNativeTitleBar = value.useNativeTitleBar;
}
@@ -321,6 +326,10 @@ function normalizeDesktopWindowBehaviorSettings(
const normalized = {
showTrayIcon:
typeof normalizedSettings?.showTrayIcon === 'boolean' ? normalizedSettings.showTrayIcon : defaults.showTrayIcon,
coloredTrayIcon:
typeof normalizedSettings?.coloredTrayIcon === 'boolean'
? normalizedSettings.coloredTrayIcon
: defaults.coloredTrayIcon,
minimizeToTray:
typeof normalizedSettings?.minimizeToTray === 'boolean'
? normalizedSettings.minimizeToTray
@@ -395,6 +404,7 @@ function serializeDesktopWindowBehaviorSettings(
): PersistedDesktopWindowBehaviorSettings {
return {
showTrayIcon: settings.showTrayIcon,
coloredTrayIcon: settings.coloredTrayIcon,
useNativeTitleBar: settings.useNativeTitleBar,
rememberWindowState: settings.rememberWindowState,
allowTransparency: settings.allowTransparency,
+2
View File
@@ -90,6 +90,7 @@ export interface DesktopInfo {
export interface DesktopWindowBehaviorSettings {
showTrayIcon: boolean;
coloredTrayIcon: boolean;
minimizeToTray: boolean;
closeToTray: boolean;
startMinimized: boolean;
@@ -303,6 +304,7 @@ export interface ElectronAPI {
closeNotifications: (ids: Array<string>) => void;
onNotificationClick: (callback: (id: string, url?: string) => void) => () => void;
setBadgeCount: (count: number) => void;
setUnreadBadge: (badge: number) => void;
setNativeLocale: (locale: string, strings: Record<string, string>) => void;
flashFrame: (persistent?: boolean) => void;
stopFlashFrame: () => void;
@@ -231,6 +231,7 @@ export async function getDesktopDebugInfo(
function formatWindowBehavior(settings: DesktopWindowBehaviorSettings): string {
return [
`showTrayIcon=${settings.showTrayIcon}`,
`coloredTrayIcon=${settings.coloredTrayIcon}`,
`minimizeToTray=${settings.minimizeToTray}`,
`closeToTray=${settings.closeToTray}`,
`startMinimized=${settings.startMinimized}`,
+58 -12
View File
@@ -16,7 +16,9 @@ import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/m
import {areModuleUpdatesDisabled} from '@electron/main/LaunchOptions';
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
import {onLocaleChange, t} from '@electron/main/MainI18n';
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
import {renderTrayIcon, resolveTrayIconAppearance} from '@electron/main/TrayIconImage';
import {describeUnreadBadge} from '@electron/main/UnreadBadge';
import {app, type BrowserWindow, clipboard, Menu, nativeImage, nativeTheme, Tray} from 'electron';
const logger = createChildLogger('DesktopTray');
const ICON_DIR_NAMES: Record<BuildChannel, string> = {
@@ -53,6 +55,10 @@ let pendingTrayActionFlush: NodeJS.Timeout | null = null;
let trayActionBridgeWebContentsId: number | null = null;
let trayForcedExitTimer: NodeJS.Timeout | null = null;
let pendingTrayActions: Array<TrayActionPayload> = [];
let trayUnreadBadge = 0;
let trayBaseIcon: Electron.NativeImage | null = null;
let appliedTrayIconKey: string | null = null;
const trayIconCache = new Map<string, Electron.NativeImage>();
const BIDI_FIRST_STRONG_ISOLATE = '\u2068';
const BIDI_POP_DIRECTIONAL_ISOLATE = '\u2069';
@@ -240,6 +246,47 @@ function createTrayIcon(): Electron.NativeImage | null {
return createTrayImage(['256x256.png', '512x512.png', '128x128.png', '64x64.png', '48x48.png', 'icon.png']);
}
function resolveTrayIcon(): {key: string; image: Electron.NativeImage} | null {
trayBaseIcon ??= createTrayIcon();
if (!trayBaseIcon) return null;
const appearance = resolveTrayIconAppearance({
platform: process.platform,
coloredTrayIcon: getDesktopWindowBehaviorSettings().coloredTrayIcon,
darkSystemUI: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI,
});
const indicator = trayUnreadBadge !== 0;
const key = `${appearance}:${indicator}`;
let image = trayIconCache.get(key);
if (!image) {
image = renderTrayIcon(trayBaseIcon, appearance, indicator);
trayIconCache.set(key, image);
}
return {key, image};
}
function getTrayToolTip(): string {
const description = describeUnreadBadge(trayUnreadBadge);
return description ? `${APP_NAME} - ${description}` : APP_NAME;
}
function syncTrayIcon(force: boolean): void {
if (!tray || tray.isDestroyed()) return;
const icon = resolveTrayIcon();
if (!icon) {
logger.warn('Tray icon asset could not be reloaded; keeping existing tray image');
} else if (force || icon.key !== appliedTrayIconKey) {
tray.setImage(icon.image);
appliedTrayIconKey = icon.key;
}
tray.setToolTip(getTrayToolTip());
}
export function setDesktopTrayUnreadBadge(badge: number): void {
if (badge === trayUnreadBadge) return;
trayUnreadBadge = badge;
syncTrayIcon(false);
}
function ensureMainWindowVisible(): void {
if (!controller) return;
const mainWindow = controller.getMainWindow();
@@ -434,14 +481,16 @@ function buildTrayMenu(): Menu {
}
function createTray(): Tray | null {
const trayIcon = createTrayIcon();
const trayIcon = resolveTrayIcon();
if (!trayIcon) {
logger.warn('Tray icon asset could not be resolved; tray disabled');
return null;
}
try {
const nextTray = process.platform === 'darwin' ? new Tray(trayIcon, TRAY_POSITION_GUID) : new Tray(trayIcon);
nextTray.setToolTip(APP_NAME);
const nextTray =
process.platform === 'darwin' ? new Tray(trayIcon.image, TRAY_POSITION_GUID) : new Tray(trayIcon.image);
appliedTrayIconKey = trayIcon.key;
nextTray.setToolTip(getTrayToolTip());
nextTray.setContextMenu(buildTrayMenu());
if (process.platform !== 'darwin') {
nextTray.on('click', toggleMainWindow);
@@ -520,13 +569,7 @@ export function applyDesktopWindowBehaviorSettings(): void {
tray = createTray();
}
} else {
const nextTrayIcon = createTrayIcon();
if (nextTrayIcon) {
tray.setImage(nextTrayIcon);
} else {
logger.warn('Tray icon asset could not be reloaded; keeping existing tray image');
}
tray.setToolTip(APP_NAME);
syncTrayIcon(true);
}
refreshDesktopTrayMenu();
revealHiddenWindowIfTrayUnavailable();
@@ -546,10 +589,13 @@ export function initializeDesktopTray(nextController: DesktopTrayController): vo
trayLocaleSubscribed = true;
onLocaleChange(() => {
if (tray && !tray.isDestroyed()) {
tray.setToolTip(APP_NAME);
tray.setToolTip(getTrayToolTip());
}
refreshDesktopTrayMenu();
});
if (process.platform === 'win32') {
nativeTheme.on('updated', () => syncTrayIcon(false));
}
observeDesktopUpdateState(refreshDesktopTrayMenu);
}
}
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {describe, test} from 'node:test';
import {installElectronStub, installTestModuleStub} from './LocalAppTestSupport.test.mjs';
import {fakeNativeImageModule, pixel} from './TrayIconTestSupport.test.mjs';
const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-desktop-tray-icon-test-'));
fs.mkdirSync(path.join(resourcesPath, 'icons'));
fs.writeFileSync(path.join(resourcesPath, 'icons', '16x16.png'), '');
Object.defineProperty(process, 'resourcesPath', {value: resourcesPath, configurable: true});
Object.defineProperty(process, 'platform', {value: 'win32', configurable: true});
const settings = {showTrayIcon: true, coloredTrayIcon: false};
globalThis.__fluxerDesktopTrayIconTestSettings__ = settings;
installTestModuleStub(
'@electron/common/DesktopConfig',
'export const getDesktopWindowBehaviorSettings = () => ({...globalThis.__fluxerDesktopTrayIconTestSettings__});',
);
installTestModuleStub(
'@electron/main/DesktopUpdatePrompt',
'export const checkForUpdatesFromShell = async () => {}; export const startDesktopUpdateFromShell = () => {};',
);
installTestModuleStub('@electron/main/LinuxLaunchPath', 'export const relaunchStableLaunchPath = () => {};');
const trays = [];
class FakeTray {
constructor(image) {
this.images = [image];
this.toolTips = [];
this.destroyed = false;
trays.push(this);
}
get image() {
return this.images.at(-1);
}
setImage(image) {
this.images.push(image);
}
setToolTip(toolTip) {
this.toolTips.push(toolTip);
}
setContextMenu() {}
on() {}
removeAllListeners() {}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
}
}
const themeListeners = [];
const nativeTheme = {
shouldUseDarkColorsForSystemIntegratedUI: true,
on: (event, listener) => {
if (event === 'updated') themeListeners.push(listener);
},
};
installElectronStub({
nativeImage: fakeNativeImageModule,
nativeTheme,
Tray: FakeTray,
Menu: {buildFromTemplate: () => ({})},
clipboard: {},
app: {getPath: () => resourcesPath},
});
const {applyDesktopWindowBehaviorSettings, initializeDesktopTray, setDesktopTrayUnreadBadge} = await import(
'./DesktopTray.ts'
);
const controller = {
createWindow: () => null,
getMainWindow: () => null,
isMainWindowTakenOver: () => false,
hideWindow: () => {},
setQuitting: () => {},
showWindow: () => {},
};
function topRight(image) {
return pixel(image.toBitmap(), 16, 12, 3);
}
describe('desktop tray icon state', () => {
test('follows the unread badge, theme and colored setting, and survives tray recreation', () => {
initializeDesktopTray(controller);
const [tray] = trays;
const plainWhite = tray.image;
assert.deepEqual(topRight(plainWhite), [0xff, 0xff, 0xff, 0xff], 'white glyph on a dark taskbar');
assert.match(tray.toolTips.at(-1), /^Fluxer[^-]*$/);
setDesktopTrayUnreadBadge(3);
assert.equal(tray.images.length, 2);
assert.deepEqual(topRight(tray.image), [0x00, 0x00, 0xdd, 0xff], 'red dot');
assert.match(tray.toolTips.at(-1), / - Unread messages: 3$/);
const dotted = tray.image;
setDesktopTrayUnreadBadge(4);
setDesktopTrayUnreadBadge(4);
setDesktopTrayUnreadBadge(-1);
assert.equal(tray.images.length, 2, 'the image is only set when the dot appears or disappears');
assert.match(tray.toolTips.at(-1), / - Unread messages$/);
setDesktopTrayUnreadBadge(0);
assert.equal(tray.image, plainWhite, 'clearing reuses the cached plain image');
setDesktopTrayUnreadBadge(2);
assert.equal(tray.image, dotted, 'the dotted image is cached too');
nativeTheme.shouldUseDarkColorsForSystemIntegratedUI = false;
for (const listener of themeListeners) listener();
assert.deepEqual(topRight(tray.image), [0x00, 0x00, 0xdd, 0xff]);
assert.deepEqual(pixel(tray.image.toBitmap(), 16, 2, 8), [0x1c, 0x1c, 0x1c, 0xff], 'dark glyph on a light taskbar');
settings.coloredTrayIcon = true;
applyDesktopWindowBehaviorSettings();
assert.deepEqual(pixel(tray.image.toBitmap(), 16, 2, 8), [0xd9, 0x41, 0x46, 0xff], 'colored icon');
assert.deepEqual(topRight(tray.image), [0x00, 0x00, 0xdd, 0xff], 'the dot stays on the colored icon');
settings.showTrayIcon = false;
applyDesktopWindowBehaviorSettings();
assert.equal(tray.destroyed, true);
setDesktopTrayUnreadBadge(0);
settings.showTrayIcon = true;
applyDesktopWindowBehaviorSettings();
assert.equal(trays.length, 2);
assert.deepEqual(trays[1].images.length, 1);
assert.deepEqual(pixel(trays[1].image.toBitmap(), 16, 2, 8), [0xd9, 0x41, 0x46, 0xff]);
assert.notDeepEqual(topRight(trays[1].image), [0x00, 0x00, 0xdd, 0xff], 'a recreated tray shows the current state');
setDesktopTrayUnreadBadge(1);
assert.deepEqual(topRight(trays[1].image), [0x00, 0x00, 0xdd, 0xff]);
});
});
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
installElectronStub();
const {getDesktopWindowBehaviorSettings, loadDesktopConfig, setDesktopWindowBehaviorSettings} = await import(
'../common/DesktopConfig.ts'
);
function loadSettings(windowBehavior) {
const userDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-desktop-tray-setting-test-'));
fs.writeFileSync(path.join(userDataPath, 'settings.json'), JSON.stringify({window_behavior: windowBehavior}));
loadDesktopConfig(userDataPath);
return userDataPath;
}
function readPersisted(userDataPath) {
return JSON.parse(fs.readFileSync(path.join(userDataPath, 'settings.json'), 'utf8')).window_behavior;
}
describe('colored tray icon setting', () => {
test('defaults to the monochrome icon for existing and new configs', () => {
loadSettings(undefined);
assert.equal(getDesktopWindowBehaviorSettings().coloredTrayIcon, false);
loadSettings({showTrayIcon: true});
assert.equal(getDesktopWindowBehaviorSettings().coloredTrayIcon, false);
});
test('drops values that are not booleans', () => {
for (const value of ['true', 1, null, {}]) {
loadSettings({coloredTrayIcon: value});
assert.equal(getDesktopWindowBehaviorSettings().coloredTrayIcon, false, String(value));
}
});
test('persists and reloads the choice without touching the tray visibility', () => {
const userDataPath = loadSettings({showTrayIcon: false});
assert.equal(setDesktopWindowBehaviorSettings({coloredTrayIcon: true}).coloredTrayIcon, true);
assert.equal(readPersisted(userDataPath).coloredTrayIcon, true);
loadDesktopConfig(userDataPath);
const settings = getDesktopWindowBehaviorSettings();
assert.equal(settings.coloredTrayIcon, true);
assert.equal(settings.showTrayIcon, false);
});
});
+26 -8
View File
@@ -26,6 +26,7 @@ import {
applyDesktopWindowBehaviorSettings,
desktopTrayChangePendingRestart,
hasActiveDesktopTray,
setDesktopTrayUnreadBadge,
updateTrayRuntimeState,
} from '@electron/main/DesktopTray';
import {getDesktopUpdateState, observeDesktopUpdateState, startDesktopUpdate} from '@electron/main/DesktopUpdateGate';
@@ -57,6 +58,7 @@ import {registerPasskeyHandlers} from '@electron/main/Passkeys';
import {getAppMetricsSnapshot, getDesktopInfo, getGpuInfo} from '@electron/main/PlatformInfo';
import {
createPrivilegedRendererDocumentOwners,
isPrivilegedRendererDocumentSender,
requirePrivilegedRendererDocumentSender,
} from '@electron/main/PrivilegedRendererDocuments';
import {getDesktopSelectedInstanceClient} from '@electron/main/SelectedInstanceFetch';
@@ -72,6 +74,7 @@ import {
setHardwareAccelerationDisabled,
setHardwareAccelerationDisabledAndRestart,
} from '@electron/main/Troubleshooting';
import {parseUnreadBadge, UNREAD_BADGE_DOT} from '@electron/main/UnreadBadge';
import {registerVoiceBackgroundMediaCacheHandlers} from '@electron/main/VoiceBackgroundMediaCache';
import {
focusVoiceDebugEventSinkPopout,
@@ -119,6 +122,9 @@ function normalizeDesktopWindowBehaviorUpdate(value: unknown): Partial<DesktopWi
if (typeof value.showTrayIcon === 'boolean') {
update.showTrayIcon = value.showTrayIcon;
}
if (typeof value.coloredTrayIcon === 'boolean') {
update.coloredTrayIcon = value.coloredTrayIcon;
}
if (typeof value.minimizeToTray === 'boolean') {
update.minimizeToTray = value.minimizeToTray;
}
@@ -154,6 +160,17 @@ function getActiveMiddleClickAutoscroll(): boolean {
return process.platform === 'linux' && hasEnabledBlinkFeature(MIDDLE_CLICK_AUTOSCROLL_BLINK_FEATURE);
}
function applyUnreadBadge(badge: number): void {
if (process.platform === 'win32') {
setWindowsBadgeOverlay(getMainWindow(), badge);
} else if (process.platform === 'darwin' && badge === UNREAD_BADGE_DOT) {
app.dock?.setBadge('•');
} else {
app.setBadgeCount(Math.max(badge, 0));
}
setDesktopTrayUnreadBadge(badge);
}
export function registerIpcHandlers(): void {
registerVoiceDebugEventSinkPopoutIpcHandlers();
registerVoiceBackgroundMediaCacheHandlers();
@@ -467,14 +484,15 @@ export function registerIpcHandlers(): void {
if (surface === 'input-monitoring' && status === 'granted') retryBlockedGlobalShortcutHooks();
},
});
ipcMain.on('set-badge-count', (_event, count: number) => {
if (process.platform === 'darwin') {
app.setBadgeCount(count);
} else if (process.platform === 'win32') {
setWindowsBadgeOverlay(getMainWindow(), count);
} else {
app.setBadgeCount(count);
}
ipcMain.on('set-badge-count', (event, count: unknown) => {
const badge = parseUnreadBadge(count);
if (badge === null || !isPrivilegedRendererDocumentSender(event)) return;
applyUnreadBadge(Math.max(badge, 0));
});
ipcMain.on('set-unread-badge', (event, value: unknown) => {
const badge = parseUnreadBadge(value);
if (badge === null || !isPrivilegedRendererDocumentSender(event)) return;
applyUnreadBadge(badge);
});
ipcMain.on('set-zoom-factor', (event, factor: number) => {
const win = BrowserWindow.fromWebContents(event.sender);
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
import {BLUE_BGRA, discBitmap, FakeNativeImage, fakeNativeImageModule, pixel} from './TrayIconTestSupport.test.mjs';
installElectronStub({nativeImage: fakeNativeImageModule});
const {paintMonochromeGlyph, paintTrayIndicatorDot, renderTrayIcon, resolveTrayIconAppearance} = await import(
'./TrayIconImage.ts'
);
describe('tray icon appearance', () => {
test('macOS always uses the template image', () => {
for (const coloredTrayIcon of [false, true]) {
for (const darkSystemUI of [false, true]) {
assert.equal(resolveTrayIconAppearance({platform: 'darwin', coloredTrayIcon, darkSystemUI}), 'template');
}
}
});
test('Windows follows the taskbar theme unless the colored icon is chosen', () => {
assert.equal(resolveTrayIconAppearance({platform: 'win32', coloredTrayIcon: false, darkSystemUI: true}), 'white');
assert.equal(resolveTrayIconAppearance({platform: 'win32', coloredTrayIcon: false, darkSystemUI: false}), 'black');
assert.equal(resolveTrayIconAppearance({platform: 'win32', coloredTrayIcon: true, darkSystemUI: false}), 'colored');
});
test('Linux uses a white glyph whatever the system theme says', () => {
assert.equal(resolveTrayIconAppearance({platform: 'linux', coloredTrayIcon: false, darkSystemUI: false}), 'white');
assert.equal(resolveTrayIconAppearance({platform: 'linux', coloredTrayIcon: true, darkSystemUI: false}), 'colored');
});
});
describe('monochrome glyph', () => {
test('keeps the body, cuts out the light mark and leaves the outside transparent', () => {
const bitmap = discBitmap(32);
paintMonochromeGlyph(bitmap, [0xff, 0xff, 0xff]);
assert.deepEqual(pixel(bitmap, 32, 16, 6), [0xff, 0xff, 0xff, 0xff]);
assert.deepEqual(pixel(bitmap, 32, 16, 16), [0, 0, 0, 0]);
assert.deepEqual(pixel(bitmap, 32, 0, 0), [0, 0, 0, 0]);
});
test('tints the glyph with premultiplied color', () => {
const bitmap = discBitmap(32);
paintMonochromeGlyph(bitmap, [0x1c, 0x1c, 0x1c]);
assert.deepEqual(pixel(bitmap, 32, 16, 6), [0x1c, 0x1c, 0x1c, 0xff]);
});
test('an icon without a light mark keeps its whole silhouette', () => {
const bitmap = Buffer.alloc(4 * 4 * 4);
for (let offset = 0; offset < bitmap.length; offset += 4) bitmap.set(BLUE_BGRA, offset);
bitmap.set([0, 0, 0, 0], 0);
paintMonochromeGlyph(bitmap, [0xff, 0xff, 0xff]);
assert.deepEqual(pixel(bitmap, 4, 0, 0), [0, 0, 0, 0]);
assert.deepEqual(pixel(bitmap, 4, 2, 2), [0xff, 0xff, 0xff, 0xff]);
});
});
describe('unread dot', () => {
test('draws an opaque dot in the top-right corner with a transparent gap around it', () => {
const size = 64;
const bitmap = Buffer.alloc(size * size * 4, 0xff);
paintTrayIndicatorDot(bitmap, size, size, [0x00, 0x00, 0xdd]);
const radius = size * 0.22;
assert.deepEqual(pixel(bitmap, size, Math.floor(size - radius), Math.floor(radius)), [0x00, 0x00, 0xdd, 0xff]);
assert.deepEqual(pixel(bitmap, size, Math.floor(size - radius), Math.floor(radius * 2 + 2)), [0, 0, 0, 0]);
assert.deepEqual(pixel(bitmap, size, 4, size - 4), [0xff, 0xff, 0xff, 0xff]);
});
});
describe('renderTrayIcon', () => {
test('returns the base image untouched for the colored icon without a dot', () => {
const base = FakeNativeImage.fromBitmap(16);
assert.equal(renderTrayIcon(base, 'colored', false), base);
const template = FakeNativeImage.fromBitmap(16);
template.setTemplateImage(true);
assert.equal(renderTrayIcon(template, 'template', false), template);
});
test('paints every scale factor and keeps the template flag', () => {
const base = FakeNativeImage.fromBitmap(16);
base.addRepresentation({scaleFactor: 2, width: 32, height: 32, buffer: discBitmap(32)});
base.setTemplateImage(true);
const result = renderTrayIcon(base, 'template', true);
assert.notEqual(result, base);
assert.deepEqual(result.getScaleFactors(), [1, 2]);
assert.equal(result.isTemplateImage(), true);
assert.deepEqual(pixel(result.toBitmap({scaleFactor: 2}), 32, 25, 7), [0, 0, 0, 0xff]);
assert.deepEqual(pixel(base.toBitmap({scaleFactor: 2}), 32, 25, 7), BLUE_BGRA, 'the base image is not mutated');
});
test('combines the monochrome glyph with a red dot', () => {
const result = renderTrayIcon(FakeNativeImage.fromBitmap(32), 'white', true);
assert.equal(result.isTemplateImage(), false);
const bitmap = result.toBitmap();
assert.deepEqual(pixel(bitmap, 32, 25, 7), [0x00, 0x00, 0xdd, 0xff]);
assert.deepEqual(pixel(bitmap, 32, 6, 16), [0xff, 0xff, 0xff, 0xff]);
});
test('skips representations whose bitmap does not match their size', () => {
const base = FakeNativeImage.fromBitmap(16);
base.toBitmap = () => Buffer.alloc(3);
assert.equal(renderTrayIcon(base, 'white', true), base);
});
});
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type NativeImage, nativeImage} from 'electron';
export type TrayIconAppearance = 'colored' | 'template' | 'white' | 'black';
type Bgr = Readonly<[number, number, number]>;
const DOT_RADIUS_RATIO = 0.22;
const DOT_GAP_RATIO = 0.0625;
const SUBSAMPLES = 4;
const MIN_GLYPH_CONTRAST = 32;
const GLYPH_CUTOUT_START = 0.1;
const GLYPH_CUTOUT_END = 0.5;
const DOT_BGR: Bgr = [0x00, 0x00, 0xdd];
const TEMPLATE_DOT_BGR: Bgr = [0x00, 0x00, 0x00];
const GLYPH_BGR: Readonly<Record<'white' | 'black', Bgr>> = {
white: [0xff, 0xff, 0xff],
black: [0x1c, 0x1c, 0x1c],
};
export function resolveTrayIconAppearance(options: {
platform: NodeJS.Platform;
coloredTrayIcon: boolean;
darkSystemUI: boolean;
}): TrayIconAppearance {
if (options.platform === 'darwin') return 'template';
if (options.coloredTrayIcon) return 'colored';
if (options.platform === 'win32' && !options.darkSystemUI) return 'black';
return 'white';
}
function coverage(x: number, y: number, centerX: number, centerY: number, radius: number): number {
let hits = 0;
for (let sy = 0; sy < SUBSAMPLES; sy++) {
const dy = y + (sy + 0.5) / SUBSAMPLES - centerY;
for (let sx = 0; sx < SUBSAMPLES; sx++) {
const dx = x + (sx + 0.5) / SUBSAMPLES - centerX;
if (dx * dx + dy * dy <= radius * radius) {
hits++;
}
}
}
return hits / (SUBSAMPLES * SUBSAMPLES);
}
function luminance(bitmap: Buffer, offset: number): number {
const alpha = bitmap[offset + 3];
if (alpha === 0) return 0;
return (0.0722 * bitmap[offset] + 0.7152 * bitmap[offset + 1] + 0.2126 * bitmap[offset + 2]) * (255 / alpha);
}
function bodyLuminance(bitmap: Buffer): number | null {
const histogram = new Array<number>(256).fill(0);
let opaque = 0;
for (let offset = 0; offset < bitmap.length; offset += 4) {
if (bitmap[offset + 3] !== 255) continue;
histogram[Math.min(255, Math.round(luminance(bitmap, offset)))]++;
opaque++;
}
let seen = 0;
for (let level = 0; level < 256; level++) {
seen += histogram[level];
if (seen * 2 >= opaque && opaque > 0) return level;
}
return null;
}
export function paintMonochromeGlyph(bitmap: Buffer, glyphBgr: Bgr): void {
const body = bodyLuminance(bitmap);
let highlight = 0;
for (let offset = 0; offset < bitmap.length; offset += 4) {
if (bitmap[offset + 3] >= 128) {
highlight = Math.max(highlight, luminance(bitmap, offset));
}
}
const contrast = body === null ? 0 : highlight - body;
for (let offset = 0; offset < bitmap.length; offset += 4) {
let alpha = bitmap[offset + 3];
if (body !== null && contrast >= MIN_GLYPH_CONTRAST) {
const position = (luminance(bitmap, offset) - body) / contrast;
const cutout = Math.min(
1,
Math.max(0, (position - GLYPH_CUTOUT_START) / (GLYPH_CUTOUT_END - GLYPH_CUTOUT_START)),
);
alpha = Math.round(alpha * (1 - cutout));
}
for (let channel = 0; channel < 3; channel++) {
bitmap[offset + channel] = Math.round((glyphBgr[channel] * alpha) / 255);
}
bitmap[offset + 3] = alpha;
}
}
export function paintTrayIndicatorDot(bitmap: Buffer, width: number, height: number, dotBgr: Bgr): void {
const size = Math.min(width, height);
const radius = size * DOT_RADIUS_RATIO;
const cutRadius = radius + size * DOT_GAP_RATIO;
const centerX = width - radius;
const centerY = radius;
const minX = Math.max(0, Math.floor(centerX - cutRadius));
const maxY = Math.min(height, Math.ceil(centerY + cutRadius));
for (let y = 0; y < maxY; y++) {
for (let x = minX; x < width; x++) {
const cut = coverage(x, y, centerX, centerY, cutRadius);
if (cut === 0) continue;
const dot = coverage(x, y, centerX, centerY, radius);
const offset = (y * width + x) * 4;
for (let channel = 0; channel < 3; channel++) {
bitmap[offset + channel] = Math.round(bitmap[offset + channel] * (1 - cut) + dotBgr[channel] * dot);
}
bitmap[offset + 3] = Math.round(bitmap[offset + 3] * (1 - cut) + 255 * dot);
}
}
}
export function renderTrayIcon(base: NativeImage, appearance: TrayIconAppearance, indicator: boolean): NativeImage {
const monochrome = appearance === 'white' || appearance === 'black';
if (!monochrome && !indicator) {
return base;
}
const result = nativeImage.createEmpty();
const scaleFactors = base.getScaleFactors();
for (const scaleFactor of scaleFactors.length > 0 ? scaleFactors : [1]) {
const size = base.getSize(scaleFactor);
const width = Math.round(size.width * scaleFactor);
const height = Math.round(size.height * scaleFactor);
const bitmap = Buffer.from(base.toBitmap({scaleFactor}));
if (width === 0 || height === 0 || bitmap.length !== width * height * 4) continue;
if (monochrome) {
paintMonochromeGlyph(bitmap, GLYPH_BGR[appearance]);
}
if (indicator) {
paintTrayIndicatorDot(bitmap, width, height, appearance === 'template' ? TEMPLATE_DOT_BGR : DOT_BGR);
}
result.addRepresentation({scaleFactor, width, height, buffer: bitmap});
}
if (result.isEmpty()) {
return base;
}
result.setTemplateImage(appearance === 'template');
return result;
}
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const BLUE_BGRA = [0xd9, 0x41, 0x46, 0xff];
export const WHITE_BGRA = [0xff, 0xff, 0xff, 0xff];
export function pixel(bitmap, width, x, y) {
const offset = (y * width + x) * 4;
return [...bitmap.subarray(offset, offset + 4)];
}
export function discBitmap(size) {
const bitmap = Buffer.alloc(size * size * 4);
const center = size / 2;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x + 0.5 - center;
const dy = y + 0.5 - center;
if (dx * dx + dy * dy > center * center) continue;
const color = Math.abs(dy) < size / 16 && Math.abs(dx) < size / 4 ? WHITE_BGRA : BLUE_BGRA;
bitmap.set(color, (y * size + x) * 4);
}
}
return bitmap;
}
export class FakeNativeImage {
constructor() {
this.representations = new Map();
this.template = false;
}
static fromBitmap(size, scaleFactor = 1) {
const image = new FakeNativeImage();
const width = size * scaleFactor;
image.addRepresentation({scaleFactor, width, height: width, buffer: discBitmap(width)});
return image;
}
addRepresentation({scaleFactor = 1, width, height, buffer}) {
this.representations.set(scaleFactor, {width, height, buffer: Buffer.from(buffer)});
}
isEmpty() {
return this.representations.size === 0;
}
getScaleFactors() {
return [...this.representations.keys()];
}
getSize(scaleFactor = 1) {
const representation = this.representations.get(scaleFactor) ?? [...this.representations.values()][0];
if (!representation) return {width: 0, height: 0};
return {width: representation.width / scaleFactor, height: representation.height / scaleFactor};
}
toBitmap({scaleFactor = 1} = {}) {
return Buffer.from(this.representations.get(scaleFactor).buffer);
}
resize() {
return this;
}
setTemplateImage(template) {
this.template = template;
}
isTemplateImage() {
return this.template;
}
}
export const fakeNativeImageModule = {
createEmpty: () => new FakeNativeImage(),
createFromPath: () => FakeNativeImage.fromBitmap(16),
};
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {describe, test} from 'node:test';
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
installElectronStub();
const {UNREAD_BADGE_DOT, describeUnreadBadge, parseUnreadBadge} = await import('./UnreadBadge.ts');
describe('parseUnreadBadge', () => {
test('accepts the dot, zero and positive counts', () => {
assert.equal(parseUnreadBadge(UNREAD_BADGE_DOT), -1);
assert.equal(parseUnreadBadge(0), 0);
assert.equal(parseUnreadBadge(7), 7);
assert.equal(parseUnreadBadge(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER);
});
test('rejects anything that is not a safe integer of at least -1', () => {
for (const value of [
-2,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
2 ** 53,
'3',
null,
undefined,
{count: 1},
true,
]) {
assert.equal(parseUnreadBadge(value), null, String(value));
}
});
});
describe('describeUnreadBadge', () => {
test('describes the dot and counts and nothing for zero', () => {
assert.equal(describeUnreadBadge(UNREAD_BADGE_DOT), 'Unread messages');
assert.equal(describeUnreadBadge(1234), 'Unread messages: 1,234');
assert.equal(describeUnreadBadge(0), null);
});
});
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getNativeLocale, t} from '@electron/main/MainI18n';
export const UNREAD_BADGE_DOT = -1;
export function parseUnreadBadge(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < UNREAD_BADGE_DOT) {
return null;
}
return value;
}
function formatBadgeCount(count: number): string {
try {
return count.toLocaleString(getNativeLocale());
} catch {
return String(count);
}
}
export function describeUnreadBadge(badge: number): string | null {
if (badge === UNREAD_BADGE_DOT) {
return t('desktop.badge.unreadMessages');
}
if (badge <= 0) {
return null;
}
return t('desktop.badge.unreadMessagesCount', {count: formatBadgeCount(badge)});
}
+7 -24
View File
@@ -2,7 +2,7 @@
import fs from 'node:fs';
import path from 'node:path';
import {getNativeLocale, t} from '@electron/main/MainI18n';
import {describeUnreadBadge, UNREAD_BADGE_DOT} from '@electron/main/UnreadBadge';
import type {BrowserWindow, NativeImage} from 'electron';
import {nativeImage} from 'electron';
@@ -34,35 +34,18 @@ function ensureInitialized(): void {
}
}
function formatBadgeCount(count: number): string {
try {
return count.toLocaleString(getNativeLocale());
} catch {
return String(count);
}
}
function getOverlayIconData(count: number): {
index: number | null;
description: string;
} {
if (count === -1) {
return {
index: 10,
description: t('desktop.badge.unreadMessages'),
};
const description = describeUnreadBadge(count) ?? 'No Notifications';
if (count === UNREAD_BADGE_DOT) {
return {index: 10, description};
}
if (count === 0) {
return {
index: null,
description: 'No Notifications',
};
if (count <= 0) {
return {index: null, description};
}
const index = Math.max(1, Math.min(count, 10)) - 1;
return {
index,
description: t('desktop.badge.unreadMessagesCount', {count: formatBadgeCount(count)}),
};
return {index: Math.min(count, 10) - 1, description};
}
function applyOverlay(win: BrowserWindow | null, count: number, force: boolean): void {
+3
View File
@@ -587,6 +587,9 @@ const api: ElectronAPI = {
setBadgeCount: (count: number): void => {
ipcRenderer.send('set-badge-count', count);
},
setUnreadBadge: (badge: number): void => {
ipcRenderer.send('set-unread-badge', badge);
},
setNativeLocale: (locale: string, strings: Record<string, string>): void => {
ipcRenderer.send('native-locale-set', {locale, strings});
},