feat(desktop): crisp taskbar badges and a dark badge option (#3495)
No files matched your search
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||