super-productivity/electron/indicator.ts
Johannes Millan f7278a43ec
fix(electron): crisp, flicker-free Linux tray icon (#4905) (#8484)
Linux StatusNotifierItem hosts redraw the whole tray item on every
Tray.setImage(), so cycling the 16 progress-animation frames made the
icon flicker between a full and a partial ring. Show a single static
running icon on Linux; Windows/macOS keep the animation.

Also re-render the static stopped/running icons (light+dark, @2x) from
vector sources at 24/48px so GNOME no longer upscales a 16px source.
2026-06-19 12:43:00 +02:00

655 lines
20 KiB
TypeScript

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 { getDistChannel } from './shared-with-frontend/get-dist-channel';
import { getIsTrayShowCurrentTask, getIsTrayShowCurrentCountdown } from './shared-state';
import { TaskCopy } from '../src/app/features/tasks/task.model';
import { release } from 'os';
import {
initTaskWidgetSettingsListener,
updateTaskWidgetTask,
} from './task-widget/task-widget';
import { getWin } from './main-window';
type IndicatorConfig = {
showApp: () => void;
quitApp: () => void;
app: App;
ICONS_FOLDER: string;
forceDarkTray: boolean;
};
let tray: Tray | undefined;
let indicatorConfig: IndicatorConfig | undefined;
let _showApp: () => void;
let _quitApp: () => void;
let _todayTasks: {
id: string;
title: string;
timeEstimate: number;
timeSpent: number;
}[] = [];
let _isRunning: boolean = false;
let _currentTaskId: string | null = null;
let DIR: string;
let shouldUseDarkColors: boolean;
// Caching variables for preventing Linux tray menu flickering
let _lastMsg: string | undefined;
let _lastTrayMsg: string | undefined;
let _lastIsRunning: boolean | undefined;
let _lastCurrentTaskId: string | null | undefined;
let _lastTodayTasksStr: string | undefined;
let _lastCurrentTask: any;
let _lastIsPomodoroEnabled: boolean;
let _lastCurrentPomodoroSessionTime: number;
let _lastIsFocusModeEnabled: boolean;
let _lastCurrentFocusSessionTime: number;
let _lastFocusModeMode: string;
let _isAppListenersInitialized = false;
let _isListenersInitialized = false;
const IS_MAC = process.platform === 'darwin';
const IS_LINUX = process.platform === 'linux';
const IS_WINDOWS = process.platform === 'win32';
// Stable GUID for the NSIS (installer) build only.
// Per Electron's Tray docs, a tray-icon GUID binds to the code-signing
// signature only when that signature carries an organization in its subject;
// otherwise it binds to the executable's full path, and changing the path
// breaks tray-icon creation until a new GUID is used. The GitHub NSIS build is
// signed and installs to a fixed path, so its GUID stays valid. The Store
// (MSIX) and portable/scoop builds run from versioned directories whose path
// changes on every update, so the GUID goes stale and Shell_NotifyIcon(NIM_ADD)
// fails silently (Electron raises no JS error) — leaving an invisible tray icon
// and an unreachable window (#7282). Those builds therefore create the tray
// without a GUID, so Windows identifies it by window handle and it stays visible.
// WARNING: This GUID must never change once deployed; Windows would treat a new
// value as a new icon and reset it to the overflow area.
// Retired GUIDs (shipped in v18.10.0, now GUID-less — never reuse for a new icon):
// portable f7c06d50-4d3e-4f8d-b9a0-2c8e7f5a1b3d, store 19b9d3fe-aa50-4792-917e-60ada97f3088
// See https://www.electronjs.org/docs/latest/api/tray (guid) and
// https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataa
const WINDOWS_TRAY_NSIS_GUID = 'a2512177-8bee-4b70-a0a8-f3d18e0eab90';
const getWindowsTrayGuid = (): string | undefined =>
getDistChannel() === 'win-nsis' ? WINDOWS_TRAY_NSIS_GUID : undefined;
export const initIndicator = ({
showApp,
quitApp,
app,
ICONS_FOLDER,
forceDarkTray,
}: {
showApp: () => void;
quitApp: () => void;
app: App;
ICONS_FOLDER: string;
forceDarkTray: boolean;
}): Tray | undefined => {
indicatorConfig = {
showApp,
quitApp,
app,
ICONS_FOLDER,
forceDarkTray,
};
DIR = ICONS_FOLDER + 'indicator/';
// On macOS we always load the black (`-l`) icons and mark them as template
// images in getTrayImage(); the system auto-inverts them for the current
// menu bar appearance, so the static dark/light choice doesn't apply.
shouldUseDarkColors =
!IS_MAC &&
(forceDarkTray ||
IS_LINUX ||
(IS_WINDOWS && !isWindows11()) ||
nativeTheme.shouldUseDarkColors);
_showApp = showApp;
_quitApp = quitApp;
initAppListeners(app);
initListeners();
return ensureIndicator();
};
export const ensureIndicator = (): Tray | undefined => {
if (!indicatorConfig) {
return undefined;
}
if (tray) {
return tray;
}
tray = createTray();
syncTray(tray);
return tray;
};
export const refreshIndicator = (): void => {
if (tray) {
syncTray(tray);
}
};
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();
if (guid) {
try {
nextTray = new Tray(trayIcon, guid);
log('Tray created on Windows with GUID:', guid);
} catch (e) {
// Log only the message, not the full error: log history is exportable
// and the error/stack can embed the install path (e.g. C:\Users\<name>\).
log(
'Tray creation with GUID failed, retrying without GUID:',
e instanceof Error ? e.message : e,
);
nextTray = new Tray(trayIcon);
log('Tray created on Windows without GUID');
}
} else {
nextTray = new Tray(trayIcon);
log('Tray created on Windows without GUID (versioned install path)');
}
} else {
nextTray = new Tray(trayIcon);
}
nextTray.setContextMenu(createContextMenu());
nextTray.on('click', () => {
indicatorConfig?.showApp();
});
return nextTray;
};
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function initAppListeners(app: App): void {
if (_isAppListenersInitialized) {
return;
}
_isAppListenersInitialized = true;
app.on('before-quit', () => {
if (tray) {
destroyTray();
}
});
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function initListeners(): void {
if (_isListenersInitialized) {
return;
}
_isListenersInitialized = true;
// Task widget settings are per-instance (not synced) — handled via a
// dedicated IPC channel in task-widget.ts.
initTaskWidgetSettingsListener();
ipcMain.on(IPC.SET_PROGRESS_BAR, (ev: IpcMainEvent, { progress }) => {
if (_isRunning && tray) {
setTrayIcon(tray, getRunningIconPath(progress));
}
// Also update the context menu and tray title during the progress bar tick
// This perfectly synchronizes the text "blinking" with the pie chart animation
if (_lastCurrentTask && tray) {
const isTrayShowCurrentTask = getIsTrayShowCurrentTask();
const isTrayShowCurrentCountdown = getIsTrayShowCurrentCountdown();
const menuMsg = createIndicatorMessage(
_lastCurrentTask,
_lastIsPomodoroEnabled || false,
_lastCurrentPomodoroSessionTime || 0,
true, // always show countdown in menu
_lastIsFocusModeEnabled || false,
_lastCurrentFocusSessionTime || 0,
_lastFocusModeMode,
);
const trayMsg = createIndicatorMessage(
_lastCurrentTask,
_lastIsPomodoroEnabled || false,
_lastCurrentPomodoroSessionTime || 0,
isTrayShowCurrentCountdown,
_lastIsFocusModeEnabled || false,
_lastCurrentFocusSessionTime || 0,
_lastFocusModeMode,
);
const todayTasksStr = JSON.stringify(
(_todayTasks || []).map((t) => ({ id: t.id, title: t.title })),
);
const isMenuChanged =
menuMsg !== _lastMsg ||
_isRunning !== _lastIsRunning ||
_currentTaskId !== _lastCurrentTaskId ||
todayTasksStr !== _lastTodayTasksStr;
if (isMenuChanged) {
tray.setContextMenu(createContextMenu(menuMsg));
_lastMsg = menuMsg;
_lastIsRunning = _isRunning;
_lastCurrentTaskId = _currentTaskId;
_lastTodayTasksStr = todayTasksStr;
}
if (_lastTrayMsg !== trayMsg) {
if (isTrayShowCurrentTask) {
tray.setTitle(trayMsg);
if (!IS_MAC) tray.setToolTip(trayMsg);
} else {
tray.setTitle('');
if (!IS_MAC) tray.setToolTip('');
}
_lastTrayMsg = trayMsg;
}
}
});
ipcMain.on(
IPC.CURRENT_TASK_UPDATED,
(
ev: IpcMainEvent,
currentTask: any,
isPomodoroEnabled: boolean,
currentPomodoroSessionTime: number,
isFocusModeEnabled: boolean,
currentFocusSessionTime: number,
focusModeMode: string,
) => {
_isRunning = !!currentTask;
_currentTaskId = currentTask ? currentTask.id : null;
// Store current task details so SET_PROGRESS_BAR can re-render text
_lastCurrentTask = currentTask;
_lastIsPomodoroEnabled = isPomodoroEnabled;
_lastCurrentPomodoroSessionTime = currentPomodoroSessionTime;
_lastIsFocusModeEnabled = isFocusModeEnabled;
_lastCurrentFocusSessionTime = currentFocusSessionTime;
_lastFocusModeMode = focusModeMode;
updateTaskWidgetTask(
currentTask,
isPomodoroEnabled,
currentPomodoroSessionTime,
isFocusModeEnabled || false,
currentFocusSessionTime || 0,
);
const isTrayShowCurrentTask = getIsTrayShowCurrentTask();
const isTrayShowCurrentCountdown = getIsTrayShowCurrentCountdown();
const menuMsg = currentTask
? createIndicatorMessage(
currentTask,
isPomodoroEnabled,
currentPomodoroSessionTime,
true, // isTrayShowCurrentCountdown: true for context menu to always show timer
isFocusModeEnabled || false,
currentFocusSessionTime || 0,
focusModeMode,
)
: '';
const trayMsg = currentTask
? createIndicatorMessage(
currentTask,
isPomodoroEnabled,
currentPomodoroSessionTime,
isTrayShowCurrentCountdown,
isFocusModeEnabled || false,
currentFocusSessionTime || 0,
focusModeMode,
)
: '';
if (tray) {
// tray handling
const todayTasksStr = JSON.stringify(
(_todayTasks || []).map((t) => ({ id: t.id, title: t.title })),
);
const isMenuChanged =
menuMsg !== _lastMsg ||
_isRunning !== _lastIsRunning ||
_currentTaskId !== _lastCurrentTaskId ||
todayTasksStr !== _lastTodayTasksStr;
if (isMenuChanged) {
tray.setContextMenu(createContextMenu(menuMsg));
_lastMsg = menuMsg;
_lastIsRunning = _isRunning;
_lastCurrentTaskId = _currentTaskId;
_lastTodayTasksStr = todayTasksStr;
}
if (_lastTrayMsg !== trayMsg) {
if (currentTask && currentTask.title) {
if (isTrayShowCurrentTask) {
tray.setTitle(trayMsg);
if (!IS_MAC) {
// NOTE apparently this has no effect for gnome
tray.setToolTip(trayMsg);
}
} else {
tray.setTitle('');
if (!IS_MAC) {
tray.setToolTip('');
}
}
} else {
tray.setTitle('');
if (!IS_MAC) {
tray.setToolTip('');
}
const suf = shouldUseDarkColors ? '-d.png' : '-l.png';
setTrayIcon(tray, DIR + `stopped${suf}`);
}
_lastTrayMsg = trayMsg;
}
// Set running icon immediately so it doesn't wait for the next tracking interval tick
if (currentTask && currentTask.title && !_lastIsFocusModeEnabled) {
const progress = currentTask.timeEstimate
? currentTask.timeSpent / currentTask.timeEstimate
: undefined;
setTrayIcon(tray, getRunningIconPath(progress));
}
}
},
);
ipcMain.on(IPC.TODAY_TASKS_UPDATED, (ev: IpcMainEvent, tasks: any[]) => {
_todayTasks = tasks;
if (tray) {
const todayTasksStr = JSON.stringify(
(_todayTasks || []).map((t) => ({ id: t.id, title: t.title })),
);
if (todayTasksStr !== _lastTodayTasksStr) {
tray.setContextMenu(createContextMenu(_lastMsg));
_lastTodayTasksStr = todayTasksStr;
}
}
});
// ipcMain.on(IPC.POMODORO_UPDATE, (ev, params) => {
// const isOnBreak = params.isOnBreak;
// const currentSessionTime = params.currentSessionTime;
// const currentSessionInitialTime = params.currentSessionInitialTime;
// if (isGnomeShellExtInstalled) {
// dbus.updatePomodoro(isOnBreak, currentSessionTime, currentSessionInitialTime);
// }
// });
}
const syncTray = (tr: Tray): void => {
const menuMsg = _lastCurrentTask
? createIndicatorMessage(
_lastCurrentTask,
_lastIsPomodoroEnabled || false,
_lastCurrentPomodoroSessionTime || 0,
true,
_lastIsFocusModeEnabled || false,
_lastCurrentFocusSessionTime || 0,
_lastFocusModeMode,
)
: _lastMsg;
tr.setContextMenu(createContextMenu(menuMsg));
const isTrayShowCurrentTask = getIsTrayShowCurrentTask();
const isTrayShowCurrentCountdown = getIsTrayShowCurrentCountdown();
const trayMsg = _lastCurrentTask
? createIndicatorMessage(
_lastCurrentTask,
_lastIsPomodoroEnabled || false,
_lastCurrentPomodoroSessionTime || 0,
isTrayShowCurrentCountdown,
_lastIsFocusModeEnabled || false,
_lastCurrentFocusSessionTime || 0,
_lastFocusModeMode,
)
: '';
if (_lastCurrentTask?.title && isTrayShowCurrentTask) {
tr.setTitle(trayMsg);
if (!IS_MAC) {
tr.setToolTip(trayMsg);
}
} else {
tr.setTitle('');
if (!IS_MAC) {
tr.setToolTip('');
}
}
_lastTrayMsg = trayMsg;
if (_lastCurrentTask?.title && !_lastIsFocusModeEnabled) {
const progress = _lastCurrentTask.timeEstimate
? _lastCurrentTask.timeSpent / _lastCurrentTask.timeEstimate
: undefined;
setTrayIcon(tr, getRunningIconPath(progress));
} else {
const suf = shouldUseDarkColors ? '-d.png' : '-l.png';
setTrayIcon(tr, DIR + `stopped${suf}`);
}
};
const destroyTray = (): void => {
tray?.destroy();
tray = undefined;
curIco = undefined;
};
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function createIndicatorMessage(
task: TaskCopy,
isPomodoroEnabled: boolean,
currentPomodoroSessionTime: number,
isTrayShowCurrentCountdown: boolean,
isFocusModeEnabled: boolean,
currentFocusSessionTime: number,
focusModeMode: string | undefined,
): string {
if (task && task.title) {
let timeStr = '';
if (isTrayShowCurrentCountdown) {
// Priority 1: Focus mode with countdown/pomodoro (show countdown)
if (isFocusModeEnabled && focusModeMode && focusModeMode !== 'Flowtime') {
timeStr = getProgressMessage(currentFocusSessionTime);
return timeStr;
}
// Priority 2: Flowtime mode (show nothing or task estimate)
if (isFocusModeEnabled && focusModeMode === 'Flowtime') {
if (task.timeEstimate) {
const restOfTime = Math.max(task.timeEstimate - task.timeSpent, 0);
timeStr = getProgressMessage(task.timeSpent, restOfTime, '-');
return timeStr;
}
return getProgressMessage(task.timeSpent);
}
// Priority 3: Legacy pomodoro (if still used)
if (isPomodoroEnabled) {
timeStr = getProgressMessage(currentPomodoroSessionTime);
return timeStr;
}
// Priority 4: Normal task time (no focus mode)
if (task.timeEstimate) {
let restOfTime = task.timeEstimate - task.timeSpent;
const prefix = restOfTime >= 0 ? '-' : '+';
restOfTime = Math.abs(restOfTime);
timeStr = getProgressMessage(task.timeSpent, restOfTime, prefix);
} else if (task.timeSpent) {
timeStr = getProgressMessage(task.timeSpent);
}
return timeStr;
}
return task.title;
}
return '';
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function createContextMenu(msg?: string): Menu {
const template: any[] = [];
// Either show the time string (if task is running) or "Super Productivity"
if (msg) {
template.push({ label: msg, enabled: false });
template.push({ type: 'separator' });
}
if (_todayTasks && _todayTasks.length > 0) {
_todayTasks.forEach((t) => {
template.push({
label: t.title.length > 40 ? t.title.substring(0, 37) + '...' : t.title,
type: 'radio',
checked: _currentTaskId === t.id && _isRunning,
click: () => {
const mainWindow = getWin();
if (mainWindow) {
if (_currentTaskId === t.id) {
// Clicked the active task again -> toggle start/pause
mainWindow.webContents.send(IPC.TASK_TOGGLE_START);
} else {
// Clicked a different task -> switch to it
mainWindow.webContents.send(IPC.SWITCH_TASK, t.id);
}
}
},
});
});
template.push({ type: 'separator' });
}
template.push({ label: 'Show App', click: _showApp });
template.push({ label: 'Quit', click: _quitApp });
return Menu.buildFromTemplate(template);
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function getRunningIconPath(progress?: number): string {
const suf = shouldUseDarkColors ? '-d' : '-l';
// Linux StatusNotifierItem hosts redraw the whole tray item on every
// Tray.setImage(); cycling through the 16 progress-animation frames makes the
// icon visibly flicker between a full and a partial ring (#4905). Show a single
// static running icon there instead — elapsed/remaining time still shows in the
// tray context-menu label (and the title/OS taskbar where the host supports it).
if (!IS_LINUX && typeof progress === 'number' && progress > 0 && isFinite(progress)) {
const f = Math.min(Math.round(progress * 15), 15);
return DIR + `running-anim${suf}/${f || 0}.png`;
}
return DIR + `running${suf}.png`;
}
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.
// On macOS we also need a NativeImage so we can mark it as a template image —
// the system then inverts it for the current menu bar appearance (light/dark,
// highlight state, accessibility), so it stays visible on any background.
const getTrayImage = (icoPath: string): string | Electron.NativeImage => {
if (!IS_LINUX && !IS_MAC) {
return icoPath;
}
const image = nativeImage.createFromPath(icoPath);
if (image.isEmpty()) {
log('Tray icon NativeImage is empty, falling back to icon path:', icoPath);
return icoPath;
}
if (IS_MAC) {
image.setTemplateImage(true);
}
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(getTrayImage(icoPath));
}
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function isWindows11(): boolean {
if (!IS_WINDOWS) {
return false;
}
const v = release();
let isWin11 = false;
if (v.startsWith('11.')) {
isWin11 = true;
} else if (v.startsWith('10.')) {
const ss = v.split('.');
isWin11 = ss.length > 2 && parseInt(ss[2]) >= 22000 ? true : false;
}
return isWin11;
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function getProgressMessage(
elapsedMs: number,
diffMs?: number,
prefix: string = '',
): string {
const formatTime = (ms: number): string => {
const numValue = Number(ms) || 0;
const hours = Math.floor(numValue / 3600000);
const minutes = Math.floor((numValue - hours * 3600000) / 60000); // eslint-disable-line no-mixed-operators
const parsed = (hours > 0 ? hours + 'h ' : '') + (minutes > 0 ? minutes + 'm ' : '');
return parsed.trim() || '0m';
};
const elapsedStr = formatTime(elapsedMs);
if (diffMs !== undefined) {
// If the difference rounds to exactly 0 minutes, avoid "-0m"
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes === 0) {
return `${elapsedStr}`;
}
const diffStr = formatTime(diffMs);
return `${elapsedStr} (${prefix}${diffStr})`;
}
return elapsedStr;
}