mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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.
655 lines
20 KiB
TypeScript
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;
|
|
}
|