diff --git a/docs/wiki/3.02-Settings-and-Preferences.md b/docs/wiki/3.02-Settings-and-Preferences.md index 3868a4de3c..1c69028acf 100755 --- a/docs/wiki/3.02-Settings-and-Preferences.md +++ b/docs/wiki/3.02-Settings-and-Preferences.md @@ -12,6 +12,17 @@ Note: Differences between the Web app and the Desktop app are specified in [[3.0 #### global-settings.Misc-Settings +- **Show current countdown in the tray / status menu (macOS only)** — Shows the + active timer or countdown in the desktop status menu title. When this is + disabled and current-task display is enabled, the title can show the current + task instead. + +#### global-settings.Tasks + +- **Show current task in the tray / status menu (macOS/Windows only)** — Allows + the desktop tray or status menu to show the active task. On macOS, the current + countdown setting takes priority while it is enabled. + #### global-settings.Short-Syntax #### global-settings.Idle-Handling diff --git a/electron/app-control.test.cjs b/electron/app-control.test.cjs new file mode 100644 index 0000000000..76b9121bd4 --- /dev/null +++ b/electron/app-control.test.cjs @@ -0,0 +1,213 @@ +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 appControlModulePath = path.resolve(__dirname, 'ipc-handlers/app-control.ts'); + +let ipcHandlers; +let nextIsLocked; +let sharedState; +let refreshIndicatorCalls; +let localRestApiConfig; + +const resetModule = () => { + delete require.cache[appControlModulePath]; +}; + +const installMocks = () => { + Module._load = function patchedLoad(request, parent, isMain) { + if (request === 'electron') { + return { + app: { + exit: () => {}, + relaunch: () => {}, + }, + ipcMain: { + on: (eventName, handler) => { + ipcHandlers.set(eventName, handler); + }, + }, + }; + } + + if (request === '../shared-with-frontend/ipc-events.const') { + return { + IPC: { + SHUTDOWN_NOW: 'SHUTDOWN_NOW', + EXIT: 'EXIT', + RELAUNCH: 'RELAUNCH', + OPEN_DEV_TOOLS: 'OPEN_DEV_TOOLS', + RELOAD_MAIN_WIN: 'RELOAD_MAIN_WIN', + TRANSFER_SETTINGS_TO_ELECTRON: 'TRANSFER_SETTINGS_TO_ELECTRON', + UPDATE_SETTINGS: 'UPDATE_SETTINGS', + SHOW_OR_FOCUS: 'SHOW_OR_FOCUS', + LOCK_SCREEN: 'LOCK_SCREEN', + SET_PROGRESS_BAR: 'SET_PROGRESS_BAR', + FLASH_FRAME: 'FLASH_FRAME', + }, + }; + } + + if (request === '../main-window') { + return { + getWin: () => ({ + webContents: { + openDevTools: () => {}, + }, + reload: () => {}, + setProgressBar: () => {}, + flashFrame: () => {}, + once: () => {}, + }), + }; + } + + if (request === '../various-shared') { + return { + quitApp: () => {}, + showOrFocus: () => {}, + }; + } + + if (request === '../shared-state') { + return { + getIsLocked: () => nextIsLocked, + setIsMinimizeToTray: (value) => { + sharedState.isMinimizeToTray = value; + }, + setIsTrayShowCurrentTask: (value) => { + sharedState.isTrayShowCurrentTask = value; + }, + setIsTrayShowCurrentCountdown: (value) => { + sharedState.isTrayShowCurrentCountdown = value; + }, + }; + } + + if (request === '../indicator') { + return { + refreshIndicator: () => { + refreshIndicatorCalls += 1; + }, + }; + } + + if (request === '../lockscreen') { + return { + lockscreen: () => {}, + }; + } + + if (request === '../error-handler-with-frontend-inform') { + return { + errorHandlerWithFrontendInform: () => {}, + }; + } + + if (request === '../../src/app/features/config/global-config.model') { + return {}; + } + + if (request === '../simple-store') { + return { + saveSimpleStore: async () => {}, + }; + } + + if (request === '../shared-with-frontend/simple-store.const') { + return { + SimpleStoreKey: { + IS_USE_CUSTOM_WINDOW_TITLE_BAR: 'isUseCustomWindowTitleBar', + }, + }; + } + + if (request === '../local-rest-api') { + return { + updateLocalRestApiConfig: (cfg) => { + localRestApiConfig = cfg; + }, + }; + } + + return originalModuleLoad.call(this, request, parent, isMain); + }; +}; + +const loadAppControlModule = () => { + resetModule(); + return require(appControlModulePath); +}; + +test.beforeEach(() => { + ipcHandlers = new Map(); + nextIsLocked = false; + sharedState = { + isMinimizeToTray: undefined, + isTrayShowCurrentTask: undefined, + isTrayShowCurrentCountdown: undefined, + }; + refreshIndicatorCalls = 0; + localRestApiConfig = undefined; + + installMocks(); +}); + +test.afterEach(() => { + Module._load = originalModuleLoad; + resetModule(); +}); + +test('settings update reads current task tray setting from tasks config', async () => { + const { initAppControlIpc } = loadAppControlModule(); + initAppControlIpc(); + + const updateSettings = ipcHandlers.get('TRANSFER_SETTINGS_TO_ELECTRON'); + assert.equal(typeof updateSettings, 'function'); + + const cfg = { + tasks: { + isTrayShowCurrent: false, + }, + misc: { + isMinimizeToTray: true, + isTrayShowCurrentTask: true, + isTrayShowCurrentCountdown: false, + }, + }; + + await updateSettings({}, cfg); + + assert.equal(sharedState.isMinimizeToTray, true); + assert.equal(sharedState.isTrayShowCurrentTask, false); + assert.equal(sharedState.isTrayShowCurrentCountdown, false); + assert.equal(refreshIndicatorCalls, 1); + assert.equal(localRestApiConfig, cfg); +}); + +test('settings update falls back to legacy misc tray task setting', async () => { + const { initAppControlIpc } = loadAppControlModule(); + initAppControlIpc(); + + const updateSettings = ipcHandlers.get('UPDATE_SETTINGS'); + assert.equal(typeof updateSettings, 'function'); + + await updateSettings( + {}, + { + misc: { + isMinimizeToTray: false, + isTrayShowCurrentTask: true, + isTrayShowCurrentCountdown: true, + }, + }, + ); + + assert.equal(sharedState.isTrayShowCurrentTask, true); + assert.equal(sharedState.isTrayShowCurrentCountdown, true); + assert.equal(refreshIndicatorCalls, 1); +}); diff --git a/electron/indicator.test.cjs b/electron/indicator.test.cjs index ac2ce203ae..e10d120220 100644 --- a/electron/indicator.test.cjs +++ b/electron/indicator.test.cjs @@ -13,9 +13,13 @@ const indicatorModulePath = path.resolve(__dirname, 'indicator.ts'); let createdTrayArgs; let createdFromPath = []; let traySetImageCalls = []; +let traySetTitleCalls = []; +let traySetToolTipCalls = []; let ipcHandlers = new Map(); let nextNativeImageIsEmpty = false; let beforeQuitHandler = () => {}; +let mockIsTrayShowCurrentTask = false; +let mockIsTrayShowCurrentCountdown = false; const resetModule = () => { delete require.cache[indicatorModulePath]; @@ -41,9 +45,13 @@ const installMocks = () => { traySetImageCalls.push(image); } - setTitle() {} + setTitle(title) { + traySetTitleCalls.push(title); + } - setToolTip() {} + setToolTip(title) { + traySetToolTipCalls.push(title); + } destroy() {} } @@ -68,6 +76,7 @@ const installMocks = () => { iconPath, kind: 'native-image', isEmpty: () => nextNativeImageIsEmpty, + setTemplateImage: () => {}, }; }, }, @@ -95,8 +104,8 @@ const installMocks = () => { if (request === './shared-state') { return { - getIsTrayShowCurrentTask: () => false, - getIsTrayShowCurrentCountdown: () => false, + getIsTrayShowCurrentTask: () => mockIsTrayShowCurrentTask, + getIsTrayShowCurrentCountdown: () => mockIsTrayShowCurrentCountdown, }; } @@ -136,9 +145,13 @@ test.beforeEach(() => { createdTrayArgs = []; createdFromPath = []; traySetImageCalls = []; + traySetTitleCalls = []; + traySetToolTipCalls = []; ipcHandlers = new Map(); nextNativeImageIsEmpty = false; beforeQuitHandler = () => {}; + mockIsTrayShowCurrentTask = false; + mockIsTrayShowCurrentCountdown = false; Object.defineProperty(process, 'platform', { configurable: true, @@ -216,3 +229,40 @@ test('initIndicator falls back to icon path if NativeImage creation is empty', ( assert.equal(typeof createdTrayArgs[0][0], 'string'); assert.match(createdTrayArgs[0][0], /\/icons\/indicator\/stopped-d\.png$/); }); + +test('tray title shows the task title when countdown display is disabled', () => { + mockIsTrayShowCurrentTask = true; + mockIsTrayShowCurrentCountdown = false; + const { initIndicator } = loadIndicatorModule(); + + initIndicator({ + showApp: () => {}, + quitApp: () => {}, + ICONS_FOLDER: '/icons/', + forceDarkTray: false, + app: { + on: () => {}, + }, + }); + + const currentTaskUpdated = ipcHandlers.get('CURRENT_TASK_UPDATED'); + assert.equal(typeof currentTaskUpdated, 'function'); + + currentTaskUpdated( + {}, + { + id: 'T1', + title: 'Write release notes', + timeSpent: 5 * 60000, + timeEstimate: 25 * 60000, + }, + false, + 0, + false, + 0, + undefined, + ); + + assert.equal(traySetTitleCalls.at(-1), 'Write release notes'); + assert.equal(traySetToolTipCalls.at(-1), 'Write release notes'); +}); diff --git a/electron/indicator.ts b/electron/indicator.ts index efdd2c040c..dccbac90b7 100644 --- a/electron/indicator.ts +++ b/electron/indicator.ts @@ -142,6 +142,12 @@ export const ensureIndicator = (): Tray | undefined => { 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}`; @@ -431,6 +437,7 @@ const syncTray = (tr: Tray): void => { tr.setToolTip(''); } } + _lastTrayMsg = trayMsg; if (_lastCurrentTask?.title && !_lastIsFocusModeEnabled) { const progress = _lastCurrentTask.timeEstimate @@ -497,8 +504,7 @@ function createIndicatorMessage( return timeStr; } - // Fallback if no countdown is supposed to be shown, but we have a running task - return getProgressMessage(task.timeSpent); + return task.title; } return ''; diff --git a/electron/ipc-handlers/app-control.ts b/electron/ipc-handlers/app-control.ts index 7938e80f40..212eba7bb6 100644 --- a/electron/ipc-handlers/app-control.ts +++ b/electron/ipc-handlers/app-control.ts @@ -10,6 +10,7 @@ import { setIsTrayShowCurrentTask, setIsTrayShowCurrentCountdown, } from '../shared-state'; +import { refreshIndicator } from '../indicator'; import { lockscreen } from '../lockscreen'; import { errorHandlerWithFrontendInform } from '../error-handler-with-frontend-inform'; import { GlobalConfigState } from '../../src/app/features/config/global-config.model'; @@ -41,8 +42,11 @@ export const initAppControlIpc = (): void => { const updateSettings = async (ev: any, cfg: GlobalConfigState): Promise => { setIsMinimizeToTray(cfg.misc.isMinimizeToTray); - setIsTrayShowCurrentTask(!!cfg.misc.isTrayShowCurrentTask); + setIsTrayShowCurrentTask( + cfg.tasks?.isTrayShowCurrent ?? !!cfg.misc.isTrayShowCurrentTask, + ); setIsTrayShowCurrentCountdown(!!cfg.misc.isTrayShowCurrentCountdown); + refreshIndicator(); updateLocalRestApiConfig(cfg); if (cfg.misc.isUseCustomWindowTitleBar !== undefined) {