mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(desktop): crisp taskbar badges and a dark badge option (#3495)
This commit is contained in:
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user