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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+1
View File
@@ -10,6 +10,7 @@
"main": "dist/main/index.js",
"scripts": {
"build": "node scripts/build.mjs",
"generate:windows-badges": "electron scripts/generate-windows-badges.mjs",
"set-channel": "../tools/ci/run.sh build-desktop --step set_build_channel",
"test": "pnpm test:main",
"test:main": "pnpm set-channel && node --test 'src/main/*.test.mjs' 'src/preload/*.test.mjs' 'src/common/*.test.mjs'",
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as zlib from 'node:zlib';
import {app, BrowserWindow} from 'electron';
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
const BUILD_RESOURCES_DIR = path.join(ROOT_DIR, 'build_resources');
const FONT_FILE = path.join(ROOT_DIR, '..', 'packages', 'fonts', 'files', 'FluxerSans', 'FluxerSans-SemiBold.woff2');
const ICON_DIRECTORIES = Object.freeze(['icons-stable', 'icons-canary', 'icons-development']);
const FRAME_SIZES = Object.freeze([16, 20, 24, 32, 40, 48]);
const SUPERSAMPLE = 8;
const BADGE_COUNT = 11;
const DOT_INDEX = 11;
const CIRCLE_DIAMETER_RATIO = 0.875;
const DIGIT_HEIGHT_RATIO = 0.49;
const WIDE_LABEL_MAX_WIDTH_RATIO = 0.7;
const DOT_DIAMETER_RATIO = 0.375;
const GLYPH_COLOR = '#ffffff';
const VARIANTS = Object.freeze([
{prefix: 'badge', fill: '#f35a5a'},
{prefix: 'badge-dark', fill: '#26252d'},
]);
function labelFor(index) {
if (index === DOT_INDEX) return null;
if (index === BADGE_COUNT - 1) return '9+';
return String(index);
}
function renderInPage(options) {
return document.fonts.load(`600 100px BadgeFont`).then(() => {
const results = [];
for (const job of options.jobs) {
const size = job.size * options.supersample;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d', {willReadFrequently: true});
context.fillStyle = job.fill;
context.beginPath();
context.arc(size / 2, size / 2, (size * options.circleDiameterRatio) / 2, 0, Math.PI * 2);
context.fill();
context.fillStyle = options.glyphColor;
if (job.label === null) {
context.beginPath();
context.arc(size / 2, size / 2, (size * options.dotDiameterRatio) / 2, 0, Math.PI * 2);
context.fill();
} else {
context.font = '600 100px BadgeFont';
const reference = context.measureText('8');
const referenceHeight = reference.actualBoundingBoxAscent + reference.actualBoundingBoxDescent;
let fontSize = (100 * size * options.digitHeightRatio) / referenceHeight;
context.font = `600 ${fontSize}px BadgeFont`;
let metrics = context.measureText(job.label);
let width = metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;
const maxWidth = size * options.wideLabelMaxWidthRatio;
if (width > maxWidth) {
fontSize *= maxWidth / width;
context.font = `600 ${fontSize}px BadgeFont`;
metrics = context.measureText(job.label);
width = metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;
}
const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
const x = (size - width) / 2 + metrics.actualBoundingBoxLeft;
const y = (size - height) / 2 + metrics.actualBoundingBoxAscent;
context.fillText(job.label, x, y);
}
results.push(Array.from(context.getImageData(0, 0, size, size).data));
}
return results;
});
}
function downsample(rgba, size, factor) {
const source = size * factor;
const output = Buffer.alloc(size * size * 4);
const samples = factor * factor;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let red = 0;
let green = 0;
let blue = 0;
let alpha = 0;
for (let sy = 0; sy < factor; sy++) {
for (let sx = 0; sx < factor; sx++) {
const offset = ((y * factor + sy) * source + (x * factor + sx)) * 4;
const weight = rgba[offset + 3];
red += rgba[offset] * weight;
green += rgba[offset + 1] * weight;
blue += rgba[offset + 2] * weight;
alpha += weight;
}
}
const target = (y * size + x) * 4;
if (alpha === 0) continue;
output[target] = Math.round(red / alpha);
output[target + 1] = Math.round(green / alpha);
output[target + 2] = Math.round(blue / alpha);
output[target + 3] = Math.round(alpha / samples);
}
}
return output;
}
function pngChunk(type, data) {
const chunk = Buffer.alloc(12 + data.length);
chunk.writeUInt32BE(data.length, 0);
chunk.write(type, 4, 'latin1');
data.copy(chunk, 8);
chunk.writeUInt32BE(zlib.crc32(chunk.subarray(4, 8 + data.length)), 8 + data.length);
return chunk;
}
function encodePng(rgba, size) {
const header = Buffer.alloc(13);
header.writeUInt32BE(size, 0);
header.writeUInt32BE(size, 4);
header.writeUInt8(8, 8);
header.writeUInt8(6, 9);
const rows = Buffer.alloc(size * (size * 4 + 1));
for (let y = 0; y < size; y++) {
rgba.copy(rows, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk('IHDR', header),
pngChunk('IDAT', zlib.deflateSync(rows, {level: 9})),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
function encodeIcon(frames) {
const header = Buffer.alloc(6 + frames.length * 16);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(frames.length, 4);
let offset = header.length;
frames.forEach(({size, data}, index) => {
const entry = 6 + index * 16;
header.writeUInt8(size, entry);
header.writeUInt8(size, entry + 1);
header.writeUInt16LE(1, entry + 4);
header.writeUInt16LE(32, entry + 6);
header.writeUInt32LE(data.length, entry + 8);
header.writeUInt32LE(offset, entry + 12);
offset += data.length;
});
return Buffer.concat([header, ...frames.map(({data}) => data)]);
}
async function generate() {
const font = fs.readFileSync(FONT_FILE).toString('base64');
const window = new BrowserWindow({show: false, webPreferences: {offscreen: true}});
const html = `<style>@font-face{font-family:BadgeFont;font-weight:600;src:url(data:font/woff2;base64,${font}) format('woff2')}</style>`;
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
const jobs = VARIANTS.flatMap((variant) =>
Array.from({length: BADGE_COUNT}, (_, offset) => offset + 1).flatMap((index) =>
FRAME_SIZES.map((size) => ({variant: variant.prefix, index, size, fill: variant.fill, label: labelFor(index)})),
),
);
const options = {
jobs: jobs.map(({size, fill, label}) => ({size, fill, label})),
supersample: SUPERSAMPLE,
circleDiameterRatio: CIRCLE_DIAMETER_RATIO,
digitHeightRatio: DIGIT_HEIGHT_RATIO,
wideLabelMaxWidthRatio: WIDE_LABEL_MAX_WIDTH_RATIO,
dotDiameterRatio: DOT_DIAMETER_RATIO,
glyphColor: GLYPH_COLOR,
};
const rendered = await window.webContents.executeJavaScript(
`(${renderInPage.toString()})(${JSON.stringify(options)})`,
);
window.destroy();
const icons = new Map();
jobs.forEach((job, position) => {
const name = `${job.variant}-${job.index}.ico`;
const frames = icons.get(name) ?? [];
const pixels = downsample(rendered[position], job.size, SUPERSAMPLE);
frames.push({size: job.size, data: encodePng(pixels, job.size)});
icons.set(name, frames);
});
for (const directory of ICON_DIRECTORIES) {
const target = path.join(BUILD_RESOURCES_DIR, directory, 'badges');
fs.mkdirSync(target, {recursive: true});
for (const [name, frames] of icons) {
fs.writeFileSync(path.join(target, name), encodeIcon(frames));
}
}
console.log(`Wrote ${icons.size} badge icons to ${ICON_DIRECTORIES.length} channel directories`);
}
app
.whenReady()
.then(generate)
.then(() => app.exit(0))
.catch((error) => {
console.error(error);
app.exit(1);
});
@@ -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;