import { BrowserWindow, ipcMain, screen } from 'electron'; import { join } from 'path'; import { TaskCopy } from '../../src/app/features/tasks/task.model'; import { TaskWidgetConfig } from '../../src/app/features/config/global-config.model'; import { info } from 'electron-log/main'; import { IPC } from '../shared-with-frontend/ipc-events.const'; import { loadSimpleStoreAll, saveSimpleStore } from '../simple-store'; import { IS_MAC } from '../common.const'; let taskWidgetWin: BrowserWindow | null = null; let isTaskWidgetEnabled = false; let isAlwaysShow = false; let currentTask: TaskCopy | null = null; let isPomodoroEnabled = false; let currentPomodoroSessionTime = 0; let isFocusModeEnabled = false; let currentFocusSessionTime = 0; let initTimeoutId: NodeJS.Timeout | null = null; let currentOpacity = 95; let listenersRegistered = false; let isCreatingWindow = false; const TASK_WIDGET_BOUNDS_KEY = 'taskWidgetBounds'; const LEGACY_BOUNDS_KEY = 'overlayBounds'; let boundsDebounceTimer: NodeJS.Timeout | null = null; export const updateTaskWidgetEnabled = (isEnabled: boolean): void => { isTaskWidgetEnabled = isEnabled; if (isEnabled && !taskWidgetWin && !isCreatingWindow) { initListeners(); createTaskWidgetWindow().then(() => { // Window creation is async; re-apply the cached opacity here because // updateTaskWidgetOpacity() is a no-op while taskWidgetWin is still null, // and on macOS BrowserWindow.setOpacity() defaults to 1 (no CSS fallback). if (taskWidgetWin && !taskWidgetWin.isDestroyed()) { updateTaskWidgetOpacity(currentOpacity); } // Request current task state after window is ready const mainWindow = BrowserWindow.getAllWindows().find( (win) => win !== taskWidgetWin, ); if (mainWindow) { mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_TASK_WIDGET); } }); } else if (!isEnabled && taskWidgetWin) { destroyTaskWidget(); } }; export const destroyTaskWidget = (): void => { // Clear any pending timeouts if (initTimeoutId) { clearTimeout(initTimeoutId); initTimeoutId = null; } // Clear bounds debounce timer if (boundsDebounceTimer) { clearTimeout(boundsDebounceTimer); boundsDebounceTimer = null; } // Disable task widget to prevent close event prevention isTaskWidgetEnabled = false; isCreatingWindow = false; // Remove IPC listeners ipcMain.removeAllListeners('task-widget-show-main-window'); listenersRegistered = false; if (taskWidgetWin && !taskWidgetWin.isDestroyed()) { try { // Remove ALL event listeners taskWidgetWin.removeAllListeners(); // Remove webContents listeners if (taskWidgetWin.webContents && !taskWidgetWin.webContents.isDestroyed()) { taskWidgetWin.webContents.removeAllListeners(); } // Hide first to prevent visual issues taskWidgetWin.hide(); // Set closable to ensure we can close it taskWidgetWin.setClosable(true); // Force destroy the window taskWidgetWin.destroy(); } catch (e) { // Window might already be destroyed console.error('Error destroying task widget window:', e); } taskWidgetWin = null; } }; const createTaskWidgetWindow = async (): Promise => { if (taskWidgetWin || isCreatingWindow) { return; } isCreatingWindow = true; const primaryDisplay = screen.getPrimaryDisplay(); const { width: screenWidth } = primaryDisplay.workAreaSize; const defaultBounds = { width: 300, height: 80, x: screenWidth - 320, y: 20 }; // Restore persisted bounds or use defaults let bounds = defaultBounds; try { const store = await loadSimpleStoreAll(); // Try new key first, fall back to legacy key for migration const saved = (store[TASK_WIDGET_BOUNDS_KEY] || store[LEGACY_BOUNDS_KEY]) as | { width: number; height: number; x: number; y: number } | undefined; if ( saved && typeof saved.width === 'number' && saved.width > 0 && typeof saved.height === 'number' && saved.height > 0 && typeof saved.x === 'number' && typeof saved.y === 'number' ) { // Validate saved bounds are visible on any connected display const matchingDisplay = screen.getDisplayMatching({ x: saved.x, y: saved.y, width: saved.width, height: saved.height, }); const isOnScreen = matchingDisplay && saved.x + saved.width > matchingDisplay.bounds.x && saved.x < matchingDisplay.bounds.x + matchingDisplay.bounds.width && saved.y >= matchingDisplay.bounds.y && saved.y < matchingDisplay.bounds.y + matchingDisplay.bounds.height; bounds = isOnScreen ? saved : defaultBounds; } } catch (_e) { // Use defaults (file may not exist on first run) } isCreatingWindow = false; // On macOS, transparent + frameless windows do not support native window // dragging or edge resizing (see Electron's BrowserWindow docs: "Transparent // windows are not resizable. Setting `resizable` to `true` may make a // transparent window stop working on some platforms."). Use a solid window // instead and rely on BrowserWindow.setOpacity() for the user-set opacity so // the OS keeps native drag/resize behavior intact. taskWidgetWin = new BrowserWindow({ width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, title: 'Super Productivity Task Widget', frame: false, transparent: !IS_MAC, backgroundColor: IS_MAC ? '#00000000' : undefined, alwaysOnTop: true, skipTaskbar: true, resizable: true, minWidth: 60, minHeight: 24, maxWidth: 700, maxHeight: 120, minimizable: false, maximizable: false, closable: true, // Ensure window is closable hasShadow: IS_MAC, // Mac: solid window can keep native shadow autoHideMenuBar: true, roundedCorners: IS_MAC, // Mac: rely on OS-native rounded corners webPreferences: { preload: join(__dirname, 'task-widget-preload.js'), contextIsolation: true, nodeIntegration: false, disableDialogs: true, webSecurity: true, allowRunningInsecureContent: false, backgroundThrottling: false, // Prevent throttling when hidden }, }); taskWidgetWin.loadFile(join(__dirname, 'task-widget.html')); // Set visible on all workspaces immediately after creation taskWidgetWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); taskWidgetWin.on('closed', () => { taskWidgetWin = null; }); taskWidgetWin.on('ready-to-show', () => { if (!taskWidgetWin || taskWidgetWin.isDestroyed()) return; // Ensure window stays on all workspaces taskWidgetWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); // Request current task state from main window const mainWindow = BrowserWindow.getAllWindows().find((win) => win !== taskWidgetWin); if (mainWindow) { mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_TASK_WIDGET); } // Don't show task widget here - it should only show when main window is minimized }); const persistBoundsDebounced = (): void => { if (boundsDebounceTimer) clearTimeout(boundsDebounceTimer); boundsDebounceTimer = setTimeout(() => { if (taskWidgetWin && !taskWidgetWin.isDestroyed()) { saveSimpleStore(TASK_WIDGET_BOUNDS_KEY, taskWidgetWin.getBounds()); } }, 300); }; taskWidgetWin.on('resize', persistBoundsDebounced); taskWidgetWin.on('move', persistBoundsDebounced); // Prevent context menu on right-click to avoid crashes taskWidgetWin.webContents.on('context-menu', (e) => { e.preventDefault(); }); // Prevent any window system menu taskWidgetWin.on('system-context-menu', (e) => { e.preventDefault(); }); // Don't make window click-through initially to allow dragging // The renderer process will handle mouse events dynamically // Update initial state updateTaskWidgetContent(); }; export const showTaskWidget = (): void => { if (!isTaskWidgetEnabled) { return; } // Recreate task widget if it was accidentally closed if (!taskWidgetWin) { info('Task widget window was destroyed, recreating'); createTaskWidgetWindow().then(() => { if (taskWidgetWin && !taskWidgetWin.isDestroyed()) { updateTaskWidgetOpacity(currentOpacity); taskWidgetWin.show(); } }); return; } if (taskWidgetWin.isDestroyed()) { return; } // Only show if not already visible if (!taskWidgetWin.isVisible()) { info('Showing task widget'); taskWidgetWin.show(); } else { info('Task widget already visible'); } }; export const hideTaskWidget = (): void => { if (!taskWidgetWin || !isTaskWidgetEnabled) { info( 'Task widget hide skipped: window=' + !!taskWidgetWin + ', enabled=' + isTaskWidgetEnabled, ); return; } // Only hide if currently visible if (taskWidgetWin.isVisible()) { info('Hiding task widget'); taskWidgetWin.hide(); } else { info('Task widget already hidden'); } }; const initListeners = (): void => { if (listenersRegistered) { return; } listenersRegistered = true; // Listen for show main window request ipcMain.on('task-widget-show-main-window', () => { const mainWindow = BrowserWindow.getAllWindows().find((win) => win !== taskWidgetWin); if (mainWindow) { // Mirror showOrFocus() logic: restore() before show() to handle the case where // the window is minimized+hidden (e.g. minimize-to-tray on Linux where // event.preventDefault() on 'minimize' has no effect). mainWindow.restore(); mainWindow.show(); if (!isAlwaysShow) { hideTaskWidget(); } setTimeout(() => { if (!mainWindow.isDestroyed()) { mainWindow.focus(); if (!mainWindow.webContents.isDestroyed()) { mainWindow.webContents.focus(); } } }, 60); } }); }; export const updateTaskWidgetTask = ( task: TaskCopy | null, pomodoroEnabled: boolean, pomodoroTime: number, focusModeEnabled: boolean, focusTime: number, ): void => { currentTask = task; isPomodoroEnabled = pomodoroEnabled; currentPomodoroSessionTime = pomodoroTime; isFocusModeEnabled = focusModeEnabled; currentFocusSessionTime = focusTime; updateTaskWidgetContent(); }; const updateTaskWidgetContent = (): void => { if (!taskWidgetWin || !isTaskWidgetEnabled) { return; } let title = ''; let timeStr = ''; let mode: 'pomodoro' | 'focus' | 'task' | 'idle' = 'idle'; if (currentTask && currentTask.title) { title = currentTask.title; if (title.length > 40) { title = title.substring(0, 37) + '...'; } if (isPomodoroEnabled) { mode = 'pomodoro'; timeStr = formatTime(currentPomodoroSessionTime); } else if (isFocusModeEnabled) { mode = 'focus'; timeStr = formatTime(currentFocusSessionTime); } else if (currentTask.timeEstimate) { mode = 'task'; const remainingTime = Math.max(currentTask.timeEstimate - currentTask.timeSpent, 0); timeStr = formatTime(remainingTime); } else if (currentTask.timeSpent) { mode = 'task'; timeStr = formatTime(currentTask.timeSpent); } } taskWidgetWin.webContents.send('update-content', { title, time: timeStr, mode, }); }; export const updateTaskWidgetAlwaysShow = (alwaysShow: boolean): void => { isAlwaysShow = alwaysShow; }; export const getIsTaskWidgetAlwaysShow = (): boolean => isAlwaysShow; export const updateTaskWidgetOpacity = (opacity: number): void => { currentOpacity = opacity; if (!taskWidgetWin || taskWidgetWin.isDestroyed()) { return; } const clamped = Math.max(0.1, Math.min(1, opacity / 100)); if (IS_MAC) { // On Mac the window is solid (transparent: false), so opacity is applied // at the window level rather than via CSS background alpha. taskWidgetWin.setOpacity(clamped); } else { taskWidgetWin.webContents.send('update-opacity', clamped); } }; // Apply the per-instance task widget settings sent by the renderer. const applyTaskWidgetSettings = (cfg: TaskWidgetConfig | undefined): void => { const isEnabled = !!cfg?.isEnabled; updateTaskWidgetEnabled(isEnabled); if (isEnabled) { updateTaskWidgetOpacity(cfg?.opacity ?? 95); updateTaskWidgetAlwaysShow(!!cfg?.isAlwaysShow); } else { updateTaskWidgetAlwaysShow(false); } }; let taskWidgetSettingsListenerRegistered = false; export const initTaskWidgetSettingsListener = (): void => { if (taskWidgetSettingsListenerRegistered) return; taskWidgetSettingsListenerRegistered = true; ipcMain.on(IPC.UPDATE_TASK_WIDGET_SETTINGS, (_ev, cfg: TaskWidgetConfig) => { applyTaskWidgetSettings(cfg); }); }; const formatTime = (timeMs: number): string => { const totalSeconds = Math.floor(timeMs / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds .toString() .padStart(2, '0')}`; } return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; };