super-productivity/electron/indicator.ts
Johannes Millan 9f2d2b9a6e fix(focus-mode): prevent tray indicator jumping during focus sessions
The tray icon was jumping between showing task time and focus session time
when focus mode was active with a task being tracked. This occurred because
the tray display logic didn't know which focus mode was active.

Changes:
- Send focus mode type (Countdown/Pomodoro/Flowtime) from frontend to Electron
- Update IPC handler to receive and pass focus mode type to tray message creation
- Implement three-mode priority logic in createIndicatorMessage():
  1. Countdown/Pomodoro modes: Show focus session countdown timer
  2. Flowtime mode: Show task estimate or title (no timer)
  3. No focus mode: Show normal task time (existing behavior)
- Update TypeScript type definitions for updateCurrentTask()
- Update unit tests to mock the new mode() signal

This ensures the tray displays the correct timer without jumping based on
the active focus mode, matching the behavior of the overlay indicator.

Fixes jumping tray indicator during focus mode + tracking
2026-01-20 17:07:23 +01:00

270 lines
8.1 KiB
TypeScript

import { App, ipcMain, Menu, nativeTheme, Tray } from 'electron';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { getIsTrayShowCurrentTask, getIsTrayShowCurrentCountdown } from './shared-state';
import { TaskCopy } from '../src/app/features/tasks/task.model';
import { GlobalConfigState } from '../src/app/features/config/global-config.model';
import { release } from 'os';
import {
initOverlayIndicator,
updateOverlayEnabled,
updateOverlayTask,
} from './overlay-indicator/overlay-indicator';
let tray: Tray;
let DIR: string;
let shouldUseDarkColors: boolean;
const IS_MAC = process.platform === 'darwin';
const IS_LINUX = process.platform === 'linux';
const IS_WINDOWS = process.platform === 'win32';
// Static GUID for Windows tray icon position persistence across updates.
// This allows Windows to remember tray icon visibility/position even when
// the executable path changes (e.g., after Microsoft Store updates).
// WARNING: This GUID must never change once deployed.
const WINDOWS_TRAY_GUID = 'f7c06d50-4d3e-4f8d-b9a0-2c8e7f5a1b3d';
export const initIndicator = ({
showApp,
quitApp,
app,
ICONS_FOLDER,
forceDarkTray,
}: {
showApp: () => void;
quitApp: () => void;
app: App;
ICONS_FOLDER: string;
forceDarkTray: boolean;
}): Tray => {
DIR = ICONS_FOLDER + 'indicator/';
shouldUseDarkColors =
forceDarkTray ||
IS_LINUX ||
(IS_WINDOWS && !isWindows11()) ||
nativeTheme.shouldUseDarkColors;
initAppListeners(app);
initListeners();
const suf = shouldUseDarkColors ? '-d.png' : '-l.png';
const trayIconPath = DIR + `stopped${suf}`;
tray = IS_WINDOWS ? new Tray(trayIconPath, WINDOWS_TRAY_GUID) : new Tray(trayIconPath);
tray.setContextMenu(createContextMenu(showApp, quitApp));
tray.on('click', () => {
showApp();
});
return tray;
};
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function initAppListeners(app: App): void {
if (tray) {
app.on('before-quit', () => {
if (tray) {
tray.destroy();
}
});
}
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function initListeners(): void {
let isOverlayEnabled = false;
// Listen for settings updates to handle overlay enable/disable
ipcMain.on(IPC.UPDATE_SETTINGS, (ev, settings: GlobalConfigState) => {
const isOverlayEnabledNew = settings?.misc?.isOverlayIndicatorEnabled || false;
if (isOverlayEnabledNew === isOverlayEnabled) {
return;
}
isOverlayEnabled = isOverlayEnabledNew;
updateOverlayEnabled(isOverlayEnabled);
// Initialize overlay without shortcut (overlay doesn't need shortcut, that's for focus mode)
if (isOverlayEnabled) {
initOverlayIndicator(isOverlayEnabled);
}
});
ipcMain.on(IPC.SET_PROGRESS_BAR, (ev, { progress }) => {
const suf = shouldUseDarkColors ? '-d' : '-l';
if (typeof progress === 'number' && progress > 0 && isFinite(progress)) {
const f = Math.min(Math.round(progress * 15), 15);
const t = DIR + `running-anim${suf}/${f || 0}.png`;
setTrayIcon(tray, t);
} else {
const t = DIR + `running${suf}.png`;
setTrayIcon(tray, t);
}
});
ipcMain.on(
IPC.CURRENT_TASK_UPDATED,
(
ev,
currentTask,
isPomodoroEnabled,
currentPomodoroSessionTime,
isFocusModeEnabled,
currentFocusSessionTime,
focusModeMode,
) => {
updateOverlayTask(
currentTask,
isPomodoroEnabled,
currentPomodoroSessionTime,
isFocusModeEnabled || false,
currentFocusSessionTime || 0,
);
const isTrayShowCurrentTask = getIsTrayShowCurrentTask();
const isTrayShowCurrentCountdown = getIsTrayShowCurrentCountdown();
const msg =
isTrayShowCurrentTask && currentTask
? createIndicatorMessage(
currentTask,
isPomodoroEnabled,
currentPomodoroSessionTime,
isTrayShowCurrentCountdown,
isFocusModeEnabled || false,
currentFocusSessionTime || 0,
focusModeMode,
)
: '';
if (tray) {
// tray handling
if (currentTask && currentTask.title) {
tray.setTitle(msg);
if (!IS_MAC) {
// NOTE apparently this has no effect for gnome
tray.setToolTip(msg);
}
} else {
tray.setTitle('');
if (!IS_MAC) {
// NOTE apparently this has no effect for gnome
tray.setToolTip(msg);
}
const suf = shouldUseDarkColors ? '-d.png' : '-l.png';
setTrayIcon(tray, DIR + `stopped${suf}`);
}
}
},
);
// 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);
// }
// });
}
// 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 title = task.title;
let timeStr = '';
if (title.length > 40) {
title = title.substring(0, 37) + '...';
}
if (isTrayShowCurrentCountdown) {
// Priority 1: Focus mode with countdown/pomodoro (show countdown)
if (isFocusModeEnabled && focusModeMode && focusModeMode !== 'Flowtime') {
timeStr = getCountdownMessage(currentFocusSessionTime);
return `${title} ${timeStr}`;
}
// Priority 2: Flowtime mode (show nothing or task estimate)
if (isFocusModeEnabled && focusModeMode === 'Flowtime') {
// Don't show timer for flowtime mode
// Optionally show task estimate if available
if (task.timeEstimate) {
const restOfTime = Math.max(task.timeEstimate - task.timeSpent, 0);
timeStr = getCountdownMessage(restOfTime);
return `${title} ${timeStr}`;
}
return title; // No timer for flowtime
}
// Priority 3: Legacy pomodoro (if still used)
if (isPomodoroEnabled) {
timeStr = getCountdownMessage(currentPomodoroSessionTime);
return `${title} ${timeStr}`;
}
// Priority 4: Normal task time (no focus mode)
if (task.timeEstimate) {
const restOfTime = Math.max(task.timeEstimate - task.timeSpent, 0);
timeStr = getCountdownMessage(restOfTime);
} else if (task.timeSpent) {
timeStr = getCountdownMessage(task.timeSpent);
}
return `${title} ${timeStr}`;
}
return title;
}
// NOTE: we need to make sure that this is always a string
return '';
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function createContextMenu(showApp: () => void, quitApp: () => void): Menu {
return Menu.buildFromTemplate([
{ label: 'Show App', click: showApp },
{ label: 'Quit', click: quitApp },
]);
}
let curIco: string;
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function setTrayIcon(tr: Tray, icoPath: string): void {
if (icoPath !== curIco) {
curIco = icoPath;
tr.setImage(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 getCountdownMessage(countdownMs: number): string {
const totalSeconds = Math.floor(countdownMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}