From 077bed154bc9be4746f928f1abc2ca432b56fcb7 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Thu, 23 Apr 2026 15:31:59 +0200 Subject: [PATCH] fix(electron): use native images for Linux tray icons Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- electron/indicator.test.cjs | 214 ++++++++++++++++++++++++++++++++++++ electron/indicator.ts | 36 +++++- 2 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 electron/indicator.test.cjs diff --git a/electron/indicator.test.cjs b/electron/indicator.test.cjs new file mode 100644 index 0000000000..09b35d20bb --- /dev/null +++ b/electron/indicator.test.cjs @@ -0,0 +1,214 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const Module = require('node:module'); + +require('ts-node/register/transpile-only'); + +const originalModuleLoad = Module._load; +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + +const indicatorModulePath = path.resolve(__dirname, 'indicator.ts'); + +let createdTrayArgs; +let createdFromPath = []; +let traySetImageCalls = []; +let ipcHandlers = new Map(); +let nextNativeImageIsEmpty = false; +let beforeQuitHandler = () => {}; + +const resetModule = () => { + delete require.cache[indicatorModulePath]; +}; + +const installMocks = () => { + Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'electron') { + class FakeTray { + constructor(image, guid) { + createdTrayArgs.push([image, guid]); + } + + setContextMenu() {} + + on(eventName, handler) { + if (eventName === 'click') { + this._clickHandler = handler; + } + } + + setImage(image) { + traySetImageCalls.push(image); + } + + setTitle() {} + + setToolTip() {} + + destroy() {} + } + + return { + Tray: FakeTray, + Menu: { + buildFromTemplate: (template) => ({ template }), + }, + ipcMain: { + on: (eventName, handler) => { + ipcHandlers.set(eventName, handler); + }, + }, + nativeTheme: { + shouldUseDarkColors: false, + }, + nativeImage: { + createFromPath: (iconPath) => { + createdFromPath.push(iconPath); + return { + iconPath, + kind: 'native-image', + isEmpty: () => nextNativeImageIsEmpty, + }; + }, + }, + }; + } + + if (request === 'electron-log/main') { + return { + log: () => {}, + }; + } + + if (request === './shared-with-frontend/ipc-events.const') { + return { + IPC: { + UPDATE_SETTINGS: 'UPDATE_SETTINGS', + SET_PROGRESS_BAR: 'SET_PROGRESS_BAR', + CURRENT_TASK_UPDATED: 'CURRENT_TASK_UPDATED', + TODAY_TASKS_UPDATED: 'TODAY_TASKS_UPDATED', + TASK_TOGGLE_START: 'TASK_TOGGLE_START', + SWITCH_TASK: 'SWITCH_TASK', + }, + }; + } + + if (request === './shared-state') { + return { + getIsTrayShowCurrentTask: () => false, + getIsTrayShowCurrentCountdown: () => false, + }; + } + + if (request === './task-widget/task-widget') { + return { + updateTaskWidgetAlwaysShow: () => {}, + updateTaskWidgetEnabled: () => {}, + updateTaskWidgetOpacity: () => {}, + updateTaskWidgetTask: () => {}, + }; + } + + if (request === './main-window') { + return { + getWin: () => undefined, + }; + } + + if ( + request === '../src/app/features/tasks/task.model' || + request === '../src/app/features/config/global-config.model' + ) { + return {}; + } + + return originalModuleLoad.call(this, request, parent, isMain); + }; +}; + +const loadIndicatorModule = () => { + resetModule(); + return require(indicatorModulePath); +}; + +test.beforeEach(() => { + createdTrayArgs = []; + createdFromPath = []; + traySetImageCalls = []; + ipcHandlers = new Map(); + nextNativeImageIsEmpty = false; + beforeQuitHandler = () => {}; + + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'linux', + }); + + installMocks(); +}); + +test.afterEach(() => { + Module._load = originalModuleLoad; + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + resetModule(); +}); + +test('initIndicator uses NativeImage for Linux tray creation and updates', () => { + const { initIndicator } = loadIndicatorModule(); + + initIndicator({ + showApp: () => {}, + quitApp: () => {}, + ICONS_FOLDER: '/icons/', + forceDarkTray: false, + app: { + on: (eventName, handler) => { + if (eventName === 'before-quit') { + beforeQuitHandler = handler; + } + }, + }, + }); + + assert.equal(createdTrayArgs.length, 1); + assert.equal(createdTrayArgs[0][0].kind, 'native-image'); + assert.match(createdTrayArgs[0][0].iconPath, /\/icons\/indicator\/stopped-d\.png$/); + assert.match(createdFromPath[0], /\/icons\/indicator\/stopped-d\.png$/); + + const currentTaskUpdated = ipcHandlers.get('CURRENT_TASK_UPDATED'); + assert.equal(typeof currentTaskUpdated, 'function'); + + currentTaskUpdated( + {}, + { id: 'T1', title: 'Task', timeSpent: 5 * 60000, timeEstimate: 25 * 60000 }, + false, + 0, + false, + 0, + undefined, + ); + + assert.equal(traySetImageCalls.at(-1).kind, 'native-image'); + assert.match(traySetImageCalls.at(-1).iconPath, /\/icons\/indicator\/running-anim-d\/3\.png$/); + + beforeQuitHandler(); +}); + +test('initIndicator falls back to icon path if NativeImage creation is empty', () => { + nextNativeImageIsEmpty = true; + const { initIndicator } = loadIndicatorModule(); + + initIndicator({ + showApp: () => {}, + quitApp: () => {}, + ICONS_FOLDER: '/icons/', + forceDarkTray: false, + app: { + on: () => {}, + }, + }); + + assert.equal(createdTrayArgs.length, 1); + assert.equal(typeof createdTrayArgs[0][0], 'string'); + assert.match(createdTrayArgs[0][0], /\/icons\/indicator\/stopped-d\.png$/); +}); diff --git a/electron/indicator.ts b/electron/indicator.ts index c296d47c66..1694d9171f 100644 --- a/electron/indicator.ts +++ b/electron/indicator.ts @@ -1,4 +1,12 @@ -import { App, ipcMain, IpcMainEvent, Menu, nativeTheme, Tray } from 'electron'; +import { + App, + ipcMain, + IpcMainEvent, + Menu, + nativeImage, + nativeTheme, + Tray, +} from 'electron'; import { log } from 'electron-log/main'; import { IPC } from './shared-with-frontend/ipc-events.const'; import { getIsTrayShowCurrentTask, getIsTrayShowCurrentCountdown } from './shared-state'; @@ -134,19 +142,20 @@ export const ensureIndicator = (): Tray | undefined => { const createTray = (): Tray => { const suf = shouldUseDarkColors ? '-d.png' : '-l.png'; const trayIconPath = DIR + `stopped${suf}`; + const trayIcon = getTrayImage(trayIconPath); let nextTray: Tray; if (IS_WINDOWS) { const guid = getWindowsTrayGuid(); try { - nextTray = new Tray(trayIconPath, guid); + nextTray = new Tray(trayIcon, guid); log('Tray created on Windows with GUID:', guid); } catch (e) { log('Tray creation with GUID failed, retrying without GUID:', e); - nextTray = new Tray(trayIconPath); + nextTray = new Tray(trayIcon); log('Tray created on Windows without GUID'); } } else { - nextTray = new Tray(trayIconPath); + nextTray = new Tray(trayIcon); } nextTray.setContextMenu(createContextMenu()); @@ -558,11 +567,28 @@ function getRunningIconPath(progress?: number): string { let curIco: string | undefined; +// GNOME AppIndicator can fall back to a generic "three dots" icon for +// sandboxed Electron apps when given only a file path. Passing a NativeImage +// keeps the actual pixel data attached to the tray item. +const getTrayImage = (icoPath: string): string | Electron.NativeImage => { + if (!IS_LINUX) { + return icoPath; + } + + const image = nativeImage.createFromPath(icoPath); + if (image.isEmpty()) { + log('Tray icon NativeImage is empty, falling back to icon path:', icoPath); + return icoPath; + } + + return image; +}; + // eslint-disable-next-line prefer-arrow/prefer-arrow-functions function setTrayIcon(tr: Tray, icoPath: string): void { if (icoPath !== curIco) { curIco = icoPath; - tr.setImage(icoPath); + tr.setImage(getTrayImage(icoPath)); } }