feat(desktop): crisp taskbar badges and a dark badge option (#3495)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-15 16:49:34 +02:00
1 parent 4b8ce8bf63
commit c424656bad
113 files changed
+890 -22

No files matched your search

@@ -58,6 +58,7 @@ export type ChromiumSwitchesSetting = ReadonlyArray<string> | Record<string, unk
interface PersistedDesktopWindowBehaviorSettings {
showTrayIcon?: boolean;
coloredTrayIcon?: boolean;
darkTaskbarBadge?: boolean;
useNativeTitleBar?: boolean;
minimizeToTrayV2?: boolean;
closeToTrayV2?: boolean;
@@ -91,6 +92,7 @@ function getDefaultDesktopWindowBehaviorSettings(): DesktopWindowBehaviorSetting
return {
showTrayIcon: true,
coloredTrayIcon: false,
darkTaskbarBadge: false,
minimizeToTray: false,
closeToTray: true,
startMinimized: false,
@@ -119,6 +121,9 @@ function sanitizePersistedDesktopWindowBehaviorSettings(
if (typeof value.coloredTrayIcon === 'boolean') {
settings.coloredTrayIcon = value.coloredTrayIcon;
}
if (typeof value.darkTaskbarBadge === 'boolean') {
settings.darkTaskbarBadge = value.darkTaskbarBadge;
}
if (typeof value.useNativeTitleBar === 'boolean') {
settings.useNativeTitleBar = value.useNativeTitleBar;
}
@@ -335,6 +340,10 @@ function normalizeDesktopWindowBehaviorSettings(
typeof normalizedSettings?.coloredTrayIcon === 'boolean'
? normalizedSettings.coloredTrayIcon
: defaults.coloredTrayIcon,
darkTaskbarBadge:
typeof normalizedSettings?.darkTaskbarBadge === 'boolean'
? normalizedSettings.darkTaskbarBadge
: defaults.darkTaskbarBadge,
minimizeToTray:
typeof normalizedSettings?.minimizeToTray === 'boolean'
? normalizedSettings.minimizeToTray
@@ -410,6 +419,7 @@ function serializeDesktopWindowBehaviorSettings(
return {
showTrayIcon: settings.showTrayIcon,
coloredTrayIcon: settings.coloredTrayIcon,
darkTaskbarBadge: settings.darkTaskbarBadge,
useNativeTitleBar: settings.useNativeTitleBar,
rememberWindowState: settings.rememberWindowState,
allowTransparency: settings.allowTransparency,
+1
View File
@@ -92,6 +92,7 @@ export interface DesktopInfo {
export interface DesktopWindowBehaviorSettings {
showTrayIcon: boolean;
coloredTrayIcon: boolean;
darkTaskbarBadge: boolean;
minimizeToTray: boolean;
closeToTray: boolean;
startMinimized: boolean;
@@ -242,6 +242,7 @@ function formatWindowBehavior(settings: DesktopWindowBehaviorSettings): string {
return [
`showTrayIcon=${settings.showTrayIcon}`,
`coloredTrayIcon=${settings.coloredTrayIcon}`,
`darkTaskbarBadge=${settings.darkTaskbarBadge}`,
`minimizeToTray=${settings.minimizeToTray}`,
`closeToTray=${settings.closeToTray}`,
`startMinimized=${settings.startMinimized}`,
@@ -0,0 +1,52 @@
// 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-taskbar-badge-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('dark taskbar badge setting', () => {
test('defaults to the red badge for existing and new configs', () => {
loadSettings(undefined);
assert.equal(getDesktopWindowBehaviorSettings().darkTaskbarBadge, false);
loadSettings({showTrayIcon: true, coloredTrayIcon: true});
assert.equal(getDesktopWindowBehaviorSettings().darkTaskbarBadge, false);
});
test('drops values that are not booleans', () => {
for (const value of ['true', 1, null, {}]) {
loadSettings({darkTaskbarBadge: value});
assert.equal(getDesktopWindowBehaviorSettings().darkTaskbarBadge, false, String(value));
}
});
test('persists and reloads the choice without touching the tray settings', () => {
const userDataPath = loadSettings({showTrayIcon: false, coloredTrayIcon: true});
assert.equal(setDesktopWindowBehaviorSettings({darkTaskbarBadge: true}).darkTaskbarBadge, true);
assert.equal(readPersisted(userDataPath).darkTaskbarBadge, true);
loadDesktopConfig(userDataPath);
const settings = getDesktopWindowBehaviorSettings();
assert.equal(settings.darkTaskbarBadge, true);
assert.equal(settings.showTrayIcon, false);
assert.equal(settings.coloredTrayIcon, true);
});
});
+5 -1
View File
@@ -97,7 +97,7 @@ import {
showWindow,
THEME_STUDIO_POPOUT_KEY,
} from '@electron/main/Window';
import {setWindowsBadgeOverlay} from '@electron/main/WindowsBadge';
import {refreshWindowsBadgeOverlay, setWindowsBadgeOverlay} from '@electron/main/WindowsBadge';
import {
DESKTOP_MODULE_CHANNELS,
DESKTOP_UPDATE_CHANNELS,
@@ -126,6 +126,9 @@ function normalizeDesktopWindowBehaviorUpdate(value: unknown): Partial<DesktopWi
if (typeof value.coloredTrayIcon === 'boolean') {
update.coloredTrayIcon = value.coloredTrayIcon;
}
if (typeof value.darkTaskbarBadge === 'boolean') {
update.darkTaskbarBadge = value.darkTaskbarBadge;
}
if (typeof value.minimizeToTray === 'boolean') {
update.minimizeToTray = value.minimizeToTray;
}
@@ -317,6 +320,7 @@ export function registerIpcHandlers(): void {
ipcMain.handle('desktop-window-behavior-set', (_event, settings: unknown): DesktopWindowBehaviorSettings => {
const nextSettings = setDesktopWindowBehaviorSettings(normalizeDesktopWindowBehaviorUpdate(settings));
applyDesktopWindowBehaviorSettings();
refreshWindowsBadgeOverlay(getMainWindow());
if (!nextSettings.showTrayIcon) {
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
@@ -0,0 +1,168 @@
// 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 {fileURLToPath} from 'node:url';
import {installElectronStub, installTestModuleStub} from './LocalAppTestSupport.test.mjs';
const BUILD_RESOURCES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'build_resources');
const CHANNEL_ICON_DIRECTORIES = ['icons-stable', 'icons-canary', 'icons-development'];
const BADGE_NAMES = Array.from({length: 11}, (_, offset) => [
`badge-${offset + 1}.ico`,
`badge-dark-${offset + 1}.ico`,
]).flat();
const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), 'fluxer-desktop-windows-badge-test-'));
fs.mkdirSync(path.join(resourcesPath, 'badges'));
for (const name of BADGE_NAMES) {
fs.copyFileSync(path.join(BUILD_RESOURCES, 'icons-stable', 'badges', name), path.join(resourcesPath, 'badges', name));
}
Object.defineProperty(process, 'resourcesPath', {value: resourcesPath, configurable: true});
Object.defineProperty(process, 'platform', {value: 'win32', configurable: true});
const settings = {darkTaskbarBadge: false};
globalThis.__fluxerDesktopWindowsBadgeTestSettings__ = settings;
installTestModuleStub(
'@electron/common/DesktopConfig',
'export const getDesktopWindowBehaviorSettings = () => ({...globalThis.__fluxerDesktopWindowsBadgeTestSettings__});',
);
const display = {scaleFactor: 1};
installElectronStub({
nativeImage: {
createFromBuffer: (png) => ({png, isEmpty: () => false}),
},
screen: {
getDisplayMatching: () => display,
},
});
const {pickBadgeFrameSize, readIconPngFrames, refreshWindowsBadgeOverlay, setWindowsBadgeOverlay} = await import(
'./WindowsBadge.ts'
);
const framesByName = new Map(
BADGE_NAMES.map((name) => [name, readIconPngFrames(fs.readFileSync(path.join(resourcesPath, 'badges', name)))]),
);
function frameOf(png) {
for (const [name, frames] of framesByName) {
for (const [size, frame] of frames) {
if (frame === png || frame.equals(png)) return `${name}@${size}`;
}
}
return null;
}
function createWindow() {
return {
overlays: [],
isDestroyed: () => false,
getBounds: () => ({x: 0, y: 0, width: 800, height: 600}),
setOverlayIcon(icon, description) {
this.overlays.push({name: icon ? frameOf(icon.png) : null, description});
},
};
}
function readIconFrames(file) {
const data = fs.readFileSync(file);
assert.equal(data.readUInt16LE(2), 1);
return Array.from({length: data.readUInt16LE(4)}, (_, index) => {
const entry = 6 + index * 16;
const offset = data.readUInt32LE(entry + 12);
return {
size: data[entry],
bitsPerPixel: data.readUInt16LE(entry + 6),
png: data.subarray(offset, offset + 4).toString('latin1') === '\x89PNG',
};
});
}
describe('windows taskbar badge overlay', () => {
test('redraws in the chosen color as soon as the setting changes', () => {
const win = createWindow();
refreshWindowsBadgeOverlay(win);
assert.equal(win.overlays.length, 0, 'nothing to refresh before the first count');
setWindowsBadgeOverlay(win, 3);
assert.equal(win.overlays.at(-1).name, 'badge-3.ico@16');
assert.match(win.overlays.at(-1).description, /3/);
setWindowsBadgeOverlay(win, 3);
assert.equal(win.overlays.length, 1);
settings.darkTaskbarBadge = true;
refreshWindowsBadgeOverlay(win);
assert.equal(win.overlays.at(-1).name, 'badge-dark-3.ico@16');
setWindowsBadgeOverlay(win, 3);
assert.equal(win.overlays.length, 2, 'the same dark icon is not set twice');
settings.darkTaskbarBadge = false;
setWindowsBadgeOverlay(win, 3);
assert.equal(win.overlays.length, 3, 'a color switch alone redraws the same count');
assert.equal(win.overlays.at(-1).name, 'badge-3.ico@16');
settings.darkTaskbarBadge = true;
setWindowsBadgeOverlay(win, 25);
assert.equal(win.overlays.at(-1).name, 'badge-dark-10.ico@16');
setWindowsBadgeOverlay(win, -1);
assert.equal(win.overlays.at(-1).name, 'badge-dark-11.ico@16');
display.scaleFactor = 1.5;
refreshWindowsBadgeOverlay(win);
assert.equal(win.overlays.at(-1).name, 'badge-dark-11.ico@24', 'the frame follows the display scale');
display.scaleFactor = 1;
setWindowsBadgeOverlay(win, 0);
assert.equal(win.overlays.at(-1).name, null);
assert.equal(win.overlays.at(-1).description, 'No Notifications');
});
});
describe('badge frame selection', () => {
test('uses the exact frame for each display scale and never upscales when one exists', () => {
const sizes = [16, 20, 24, 32, 40, 48];
assert.equal(pickBadgeFrameSize(sizes, 1), 16);
assert.equal(pickBadgeFrameSize(sizes, 1.25), 20);
assert.equal(pickBadgeFrameSize(sizes, 1.5), 24);
assert.equal(pickBadgeFrameSize(sizes, 1.75), 32);
assert.equal(pickBadgeFrameSize(sizes, 2), 32);
assert.equal(pickBadgeFrameSize(sizes, 3), 48);
assert.equal(pickBadgeFrameSize(sizes, 4), 48);
assert.equal(pickBadgeFrameSize([], 1), null);
});
test('ignores frames that are not PNG and malformed icon data', () => {
assert.equal(readIconPngFrames(Buffer.alloc(0)).size, 0);
assert.equal(readIconPngFrames(Buffer.from([0, 0, 2, 0, 1, 0])).size, 0);
const truncated = fs.readFileSync(path.join(resourcesPath, 'badges', 'badge-3.ico')).subarray(0, 200);
assert.ok(readIconPngFrames(truncated).size < 6);
});
});
describe('bundled taskbar badge icons', () => {
test('every channel ships the same red and dark PNG badges for every display scale', () => {
const [reference, ...others] = CHANNEL_ICON_DIRECTORIES.map((directory) =>
path.join(BUILD_RESOURCES, directory, 'badges'),
);
for (const name of BADGE_NAMES) {
const frames = readIconFrames(path.join(reference, name));
assert.deepEqual(
frames.map(({size, bitsPerPixel, png}) => [size, bitsPerPixel, png]),
[16, 20, 24, 32, 40, 48].map((size) => [size, 32, true]),
name,
);
for (const directory of others) {
assert.ok(
fs.readFileSync(path.join(directory, name)).equals(fs.readFileSync(path.join(reference, name))),
name,
);
}
}
});
});
+76 -19
View File
@@ -2,14 +2,25 @@
import fs from 'node:fs';
import path from 'node:path';
import {getDesktopWindowBehaviorSettings} from '@electron/common/DesktopConfig';
import {describeUnreadBadge, UNREAD_BADGE_DOT} from '@electron/main/UnreadBadge';
import type {BrowserWindow, NativeImage} from 'electron';
import {nativeImage} from 'electron';
import {nativeImage, screen} from 'electron';
const badgeIcons: Array<NativeImage | null> = [];
const BADGE_COUNT = 11;
const OVERLAY_BASE_SIZE = 16;
const ICON_DIRECTORY_ENTRY_BYTES = 16;
const ICON_HEADER_BYTES = 6;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
type BadgeFrames = ReadonlyMap<number, Buffer>;
const badgeFrames: Array<BadgeFrames | null> = [];
const darkBadgeFrames: Array<BadgeFrames | null> = [];
const badgeImages = new Map<Buffer, NativeImage>();
let hasInit = false;
let lastIndex: number | null = null;
let lastIcon: NativeImage | null = null;
let lastDescription: string | null = null;
let lastCount: number | null = null;
@@ -17,23 +28,73 @@ function isSupported(): boolean {
return process.platform === 'win32';
}
export function readIconPngFrames(data: Buffer): BadgeFrames {
const frames = new Map<number, Buffer>();
if (data.length < ICON_HEADER_BYTES || data.readUInt16LE(2) !== 1) return frames;
const count = data.readUInt16LE(4);
for (let index = 0; index < count; index++) {
const entry = ICON_HEADER_BYTES + index * ICON_DIRECTORY_ENTRY_BYTES;
if (entry + ICON_DIRECTORY_ENTRY_BYTES > data.length) break;
const size = data[entry] || 256;
const length = data.readUInt32LE(entry + 8);
const offset = data.readUInt32LE(entry + 12);
const frame = data.subarray(offset, offset + length);
if (frame.length === length && frame.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
frames.set(size, frame);
}
}
return frames;
}
export function pickBadgeFrameSize(sizes: Iterable<number>, scaleFactor: number): number | null {
const target = Math.round(OVERLAY_BASE_SIZE * scaleFactor);
const sorted = [...sizes].sort((left, right) => left - right);
return sorted.find((size) => size >= target) ?? sorted.at(-1) ?? null;
}
function loadBadgeFrames(badgeDir: string, name: string): BadgeFrames | null {
try {
const frames = readIconPngFrames(fs.readFileSync(path.join(badgeDir, name)));
return frames.size > 0 ? frames : null;
} catch {
return null;
}
}
function ensureInitialized(): void {
if (hasInit || !isSupported()) {
return;
}
hasInit = true;
const badgeDir = path.join(process.resourcesPath, 'badges');
for (let i = 1; i <= 11; i++) {
const iconPath = path.join(badgeDir, `badge-${i}.ico`);
if (!fs.existsSync(iconPath)) {
badgeIcons.push(null);
continue;
}
const icon = nativeImage.createFromPath(iconPath);
badgeIcons.push(icon.isEmpty() ? null : icon);
for (let i = 1; i <= BADGE_COUNT; i++) {
badgeFrames.push(loadBadgeFrames(badgeDir, `badge-${i}.ico`));
darkBadgeFrames.push(loadBadgeFrames(badgeDir, `badge-dark-${i}.ico`));
}
}
function windowScaleFactor(win: BrowserWindow): number {
try {
return screen.getDisplayMatching(win.getBounds()).scaleFactor;
} catch {
return 1;
}
}
function getBadgeIcon(index: number, scaleFactor: number): NativeImage | null {
const frames = (getDesktopWindowBehaviorSettings().darkTaskbarBadge ? darkBadgeFrames : badgeFrames)[index];
if (!frames) return null;
const size = pickBadgeFrameSize(frames.keys(), scaleFactor);
const png = size == null ? undefined : frames.get(size);
if (!png) return null;
let image = badgeImages.get(png);
if (!image) {
image = nativeImage.createFromBuffer(png, {scaleFactor: 1});
badgeImages.set(png, image);
}
return image.isEmpty() ? null : image;
}
function getOverlayIconData(count: number): {
index: number | null;
description: string;
@@ -56,14 +117,10 @@ function applyOverlay(win: BrowserWindow | null, count: number, force: boolean):
return;
}
const {index, description} = getOverlayIconData(count);
if (force || lastIndex !== index || lastDescription !== description) {
if (index == null) {
win.setOverlayIcon(null, description);
} else {
const icon = badgeIcons[index];
win.setOverlayIcon(icon ?? null, description);
}
lastIndex = index;
const icon = index == null ? null : getBadgeIcon(index, windowScaleFactor(win));
if (force || lastIcon !== icon || lastDescription !== description) {
win.setOverlayIcon(icon, description);
lastIcon = icon;
lastDescription = description;
}
lastCount = count;