diff --git a/electron/debug.ts b/electron/debug.ts index e63ed76e1..87377362b 100644 --- a/electron/debug.ts +++ b/electron/debug.ts @@ -93,6 +93,11 @@ export const initDebug = (opts: any, isAddReload: boolean): void => { } app.on('browser-window-created', (event, win) => { + // Skip dev tools for overlay window + if (win.title === 'Super Productivity Overlay') { + return; + } + if (opts.showDevTools) { win.webContents.once('devtools-opened', () => { // Workaround for https://github.com/electron/electron/issues/13095 diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index 2b3a7f6a0..3a0a84e58 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -103,6 +103,8 @@ export interface ElectronAPI { sendAppSettingsToElectron(globalCfg: GlobalConfigState): void; + sendSettingsUpdate(globalCfg: GlobalConfigState): void; + registerGlobalShortcuts(keyboardConfig: KeyboardConfig): void; showFullScreenBlocker(args: { msg?: string; takeABreakCfg: TakeABreakConfig }): void; @@ -123,6 +125,8 @@ export interface ElectronAPI { task: Task | null, isPomodoroEnabled: boolean, currentPomodoroSessionTime: number, + isFocusModeEnabled?: boolean, + currentFocusSessionTime?: number, ); exec(command: string): void; diff --git a/electron/indicator.ts b/electron/indicator.ts index dc2739d6a..a308b73b9 100644 --- a/electron/indicator.ts +++ b/electron/indicator.ts @@ -5,6 +5,11 @@ import { getWin } from './main-window'; import { GlobalConfigState } from '../src/app/features/config/global-config.model'; import { TaskCopy } from '../src/app/features/tasks/task.model'; import { release } from 'os'; +import { + initOverlayIndicator, + updateOverlayEnabled, + updateOverlayTask, +} from './overlay-indicator/overlay-indicator'; let tray: Tray; let DIR: string; @@ -43,6 +48,7 @@ export const initIndicator = ({ tray.on('click', () => { showApp(); }); + return tray; }; @@ -59,6 +65,23 @@ function initAppListeners(app: App): void { // 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)) { @@ -73,7 +96,22 @@ function initListeners(): void { ipcMain.on( IPC.CURRENT_TASK_UPDATED, - (ev, currentTask, isPomodoroEnabled, currentPomodoroSessionTime) => { + ( + ev, + currentTask, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled, + currentFocusSessionTime, + ) => { + updateOverlayTask( + currentTask, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled || false, + currentFocusSessionTime || 0, + ); + const mainWin = getWin(); getSettings(mainWin, (settings: GlobalConfigState) => { const isTrayShowCurrentTask = settings.misc.isTrayShowCurrentTask; diff --git a/electron/main-window.ts b/electron/main-window.ts index 991238f1a..8839ab9e0 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -18,6 +18,10 @@ import { readFileSync, stat } from 'fs'; import { error, log } from 'electron-log/main'; import { GlobalConfigState } from '../src/app/features/config/global-config.model'; import { IS_MAC } from './common.const'; +import { + showOverlayWindow, + hideOverlayWindow, +} from './overlay-indicator/overlay-indicator'; let mainWin: BrowserWindow; @@ -209,6 +213,24 @@ function initWinEventListeners(app: any): void { // TODO refactor quitting mess appCloseHandler(app); appMinimizeHandler(app); + + // Handle restore and show events to hide overlay + mainWin.on('restore', () => { + hideOverlayWindow(); + }); + + mainWin.on('show', () => { + hideOverlayWindow(); + }); + + mainWin.on('focus', () => { + hideOverlayWindow(); + }); + + // Handle hide event to show overlay + mainWin.on('hide', () => { + showOverlayWindow(); + }); } // eslint-disable-next-line prefer-arrow/prefer-arrow-functions @@ -283,6 +305,7 @@ const appCloseHandler = (app: App): void => { getSettings(mainWin, (appCfg: GlobalConfigState) => { if (appCfg && appCfg.misc.isMinimizeToTray && !(app as any).isQuiting) { mainWin.hide(); + showOverlayWindow(); return; } @@ -316,8 +339,13 @@ const appMinimizeHandler = (app: App): void => { if (appCfg.misc.isMinimizeToTray) { event.preventDefault(); mainWin.hide(); - } else if (IS_MAC) { - app.dock.show(); + showOverlayWindow(); + } else { + // For regular minimize (not to tray), also show overlay + showOverlayWindow(); + if (IS_MAC) { + app.dock.show(); + } } }); }); diff --git a/electron/overlay-indicator/overlay-api.d.ts b/electron/overlay-indicator/overlay-api.d.ts new file mode 100644 index 000000000..0d919d355 --- /dev/null +++ b/electron/overlay-indicator/overlay-api.d.ts @@ -0,0 +1,19 @@ +interface OverlayContentData { + title: string; + time: string; + mode: 'pomodoro' | 'focus' | 'task' | 'idle'; +} + +interface OverlayAPI { + setIgnoreMouseEvents: (ignore: boolean) => void; + showMainWindow: () => void; + onUpdateContent: (callback: (data: OverlayContentData) => void) => void; +} + +declare global { + interface Window { + overlayAPI: OverlayAPI; + } +} + +export {}; diff --git a/electron/overlay-indicator/overlay-indicator.ts b/electron/overlay-indicator/overlay-indicator.ts new file mode 100644 index 000000000..9318a64d2 --- /dev/null +++ b/electron/overlay-indicator/overlay-indicator.ts @@ -0,0 +1,293 @@ +import { BrowserWindow, ipcMain, screen, globalShortcut } from 'electron'; +import { join } from 'path'; +import { TaskCopy } from '../../src/app/features/tasks/task.model'; +import { info } from 'electron-log/main'; +import { IPC } from '../shared-with-frontend/ipc-events.const'; + +let overlayWindow: BrowserWindow | null = null; +let isOverlayEnabled = false; +let isOverlayVisible = true; +let currentTask: TaskCopy | null = null; +let isPomodoroEnabled = false; +let currentPomodoroSessionTime = 0; +let isFocusModeEnabled = false; +let currentFocusSessionTime = 0; + +export const initOverlayIndicator = (enabled: boolean, shortcut?: string): void => { + isOverlayEnabled = enabled; + + if (enabled) { + createOverlayWindow(); + if (shortcut) { + registerShortcut(shortcut); + } + initListeners(); + + // Show overlay and request current task state + setTimeout(() => { + const mainWindow = BrowserWindow.getAllWindows().find( + (win) => win !== overlayWindow, + ); + if (mainWindow) { + // Request current task state + mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_OVERLAY); + } + // Always show overlay when initialized + showOverlayWindow(); + }, 100); + } +}; + +export const updateOverlayEnabled = (isEnabled: boolean): void => { + isOverlayEnabled = isEnabled; + + if (isEnabled && !overlayWindow) { + createOverlayWindow(); + initListeners(); + + // Show overlay and request current task state immediately when enabling + setTimeout(() => { + const mainWindow = BrowserWindow.getAllWindows().find( + (win) => win !== overlayWindow, + ); + if (mainWindow) { + mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_OVERLAY); + } + // Always show overlay when enabled + showOverlayWindow(); + }, 100); + } else if (!isEnabled && overlayWindow) { + destroyOverlayWindow(); + } +}; + +export const updateOverlayTheme = (isDarkTheme: boolean): void => { + // No longer needed - using prefers-color-scheme CSS +}; + +export const destroyOverlayWindow = (): void => { + if (overlayWindow) { + overlayWindow.removeAllListeners('close'); + overlayWindow.destroy(); + overlayWindow = null; + } +}; + +const createOverlayWindow = (): void => { + if (overlayWindow) { + return; + } + + const primaryDisplay = screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + + overlayWindow = new BrowserWindow({ + width: 300, + height: 80, + x: width - 320, + y: 20, + title: 'Super Productivity Overlay', + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + // resizable: false, + minimizable: false, + maximizable: false, + hasShadow: false, // Disable shadow with transparent windows + autoHideMenuBar: true, + roundedCorners: false, // Disable rounded corners for better compatibility + webPreferences: { + preload: join(__dirname, 'overlay-preload.js'), + contextIsolation: true, + nodeIntegration: false, + disableDialogs: true, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + + overlayWindow.loadFile(join(__dirname, 'overlay.html')); + + // Set visible on all workspaces immediately after creation + overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + overlayWindow.on('closed', () => { + overlayWindow = null; + }); + + overlayWindow.on('ready-to-show', () => { + // Show the overlay window + isOverlayVisible = true; + overlayWindow.show(); + + // Ensure window stays on all workspaces + overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + // Request current task state from main window + const mainWindow = BrowserWindow.getAllWindows().find((win) => win !== overlayWindow); + if (mainWindow) { + mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_OVERLAY); + } + }); + + // Prevent context menu on right-click to avoid crashes + overlayWindow.webContents.on('context-menu', (e) => { + e.preventDefault(); + }); + + // Prevent any window system menu + overlayWindow.on('system-context-menu', (e) => { + e.preventDefault(); + }); + + // Prevent window close attempts that might cause issues + overlayWindow.on('close', (e) => { + if (isOverlayEnabled) { + e.preventDefault(); + overlayWindow.hide(); + isOverlayVisible = false; + } + }); + + // Don't make window click-through initially to allow dragging + // The renderer process will handle mouse events dynamically + + // Update initial state + updateOverlayContent(); +}; + +const registerShortcut = (shortcut: string): void => { + globalShortcut.register(shortcut, () => { + toggleOverlayVisibility(); + }); +}; + +const toggleOverlayVisibility = (): void => { + if (!overlayWindow) { + return; + } + + isOverlayVisible = !isOverlayVisible; + if (isOverlayVisible) { + overlayWindow.show(); + } else { + overlayWindow.hide(); + } +}; + +export const showOverlayWindow = (): void => { + if (!overlayWindow || !isOverlayEnabled) { + info( + 'Overlay show skipped: window=' + !!overlayWindow + ', enabled=' + isOverlayEnabled, + ); + return; + } + + // Only show if not already visible + if (!overlayWindow.isVisible()) { + info('Showing overlay window'); + isOverlayVisible = true; + overlayWindow.show(); + } else { + info('Overlay already visible'); + } +}; + +export const hideOverlayWindow = (): void => { + if (!overlayWindow || !isOverlayEnabled) { + info( + 'Overlay hide skipped: window=' + !!overlayWindow + ', enabled=' + isOverlayEnabled, + ); + return; + } + + // Only hide if currently visible + if (overlayWindow.isVisible()) { + info('Hiding overlay window'); + isOverlayVisible = false; + overlayWindow.hide(); + } else { + info('Overlay already hidden'); + } +}; + +const initListeners = (): void => { + // Listen for show main window request + ipcMain.on('overlay-show-main-window', () => { + const mainWindow = BrowserWindow.getAllWindows().find((win) => win !== overlayWindow); + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + hideOverlayWindow(); + } + }); +}; + +export const updateOverlayTask = ( + task: TaskCopy | null, + pomodoroEnabled: boolean, + pomodoroTime: number, + focusModeEnabled: boolean, + focusTime: number, +): void => { + currentTask = task; + isPomodoroEnabled = pomodoroEnabled; + currentPomodoroSessionTime = pomodoroTime; + isFocusModeEnabled = focusModeEnabled; + currentFocusSessionTime = focusTime; + + updateOverlayContent(); +}; + +const updateOverlayContent = (): void => { + if (!overlayWindow || !isOverlayEnabled) { + 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); + } + } + + overlayWindow.webContents.send('update-content', { + title, + time: timeStr, + mode, + }); +}; + +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')}`; +}; diff --git a/electron/overlay-indicator/overlay-preload.ts b/electron/overlay-indicator/overlay-preload.ts new file mode 100644 index 000000000..98b5dbe16 --- /dev/null +++ b/electron/overlay-indicator/overlay-preload.ts @@ -0,0 +1,13 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('overlayAPI', { + setIgnoreMouseEvents: (ignore: boolean) => { + ipcRenderer.send('overlay-set-ignore-mouse', ignore); + }, + showMainWindow: () => { + ipcRenderer.send('overlay-show-main-window'); + }, + onUpdateContent: (callback: (data: any) => void) => { + ipcRenderer.on('update-content', (event, data) => callback(data)); + }, +}); diff --git a/electron/overlay-indicator/overlay.css b/electron/overlay-indicator/overlay.css new file mode 100644 index 000000000..f800dc946 --- /dev/null +++ b/electron/overlay-indicator/overlay.css @@ -0,0 +1,158 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + overflow: hidden; + background: transparent; + user-select: none; +} + +/* Prevent all auxiliary button interactions */ +body, +body * { + pointer-events: auto; +} + +/* Disable right-click context menu globally */ +body { + -webkit-context-menu: none; +} + +#overlay-container { + display: flex; + align-items: center; + height: 100vh; + padding: 8px; + background-color: rgba(255, 255, 255, 0.95); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: + background-color 0.3s, + color 0.3s; + -webkit-app-region: drag; +} + +@media (prefers-color-scheme: dark) { + #overlay-container { + background-color: rgba(32, 32, 32, 0.95); + color: #e0e0e0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + } +} + +#content { + flex: 1; + padding: 0 16px; + min-width: 0; + display: flex; + justify-content: center; + align-content: center; +} + +#task-title { + font-size: 16px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + color: #333; +} + +@media (prefers-color-scheme: dark) { + #task-title { + color: #e0e0e0; + } +} + +#time-display { + font-size: 16px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #666; + margin-left: auto; + padding-left: 16px; +} + +@media (prefers-color-scheme: dark) { + #time-display { + color: #aaa; + } +} + +/* Mode-specific colors */ +.mode-pomodoro #time-display { + color: #dc3545; +} + +.mode-focus #time-display { + color: #007bff; +} + +.mode-task #time-display { + color: #28a745; +} + +@media (prefers-color-scheme: dark) { + .mode-pomodoro #time-display { + color: #ff6b7a; + } + + .mode-focus #time-display { + color: #5cb3ff; + } + + .mode-task #time-display { + color: #5dd66f; + } +} + +#show-main { + width: 32px; + height: 32px; + border: none; + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + cursor: pointer; + color: #666; + font-size: 12px; + transition: all 0.2s; + -webkit-app-region: no-drag; +} + +@media (prefers-color-scheme: dark) { + #show-main { + background: rgba(255, 255, 255, 0.1); + color: #aaa; + } +} + +#show-main:hover { + background: rgba(0, 0, 0, 0.1); + transform: scale(1.1); +} + +@media (prefers-color-scheme: dark) { + #show-main:hover { + background: rgba(255, 255, 255, 0.2); + } +} + +#show-main:active { + transform: scale(0.95); +} + +/* Handle resize cursor on edges */ +body { + resize: both; +} + +/* Make sure content doesn't overflow on resize */ +#overlay-container { + width: 100%; + height: 100%; +} diff --git a/electron/overlay-indicator/overlay.html b/electron/overlay-indicator/overlay.html new file mode 100644 index 000000000..a5494224f --- /dev/null +++ b/electron/overlay-indicator/overlay.html @@ -0,0 +1,26 @@ + + +
+ +