mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* feat(electron): add global shortcut for task widget toggle Adds a configurable global shortcut (`globalToggleTaskWidget`) that shows/hides the task widget without changing the persisted enabled/disabled preference. The shortcut is a no-op while the task widget feature is disabled. When the user reveals the widget via the shortcut while the main window is visible, a sticky "user-forced visible" flag keeps it up (like always-show) instead of letting the next focus/show event immediately hide it. The flag clears when the user toggles the widget off, opens the app from the widget, or the feature is disabled. Rebased onto master to drop the now-superseded tray-indicator refactor; the only main-window.ts change is the additive user-forced-visible gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(electron): handle task widget shortcut races --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
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;
|
|
// Set when the user explicitly reveals the widget via the global shortcut
|
|
// (`globalToggleTaskWidget`) while the main window is visible. Like
|
|
// `isAlwaysShow`, it suppresses the automatic "hide the widget when the main
|
|
// window is shown/focused" behavior — but only until the user hides the widget
|
|
// again (toggles off) or opens the app from the widget. This gives the shortcut
|
|
// a sticky "user-forced visible" effect instead of being immediately undone by
|
|
// the next focus event.
|
|
let isUserForcedVisible = 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 taskWidgetCreationPromise: Promise<void> | null = null;
|
|
let taskWidgetCreationGeneration = 0;
|
|
let pendingShowAfterCreate = false;
|
|
let pendingShowAfterCreateInactive = false;
|
|
|
|
const TASK_WIDGET_BOUNDS_KEY = 'taskWidgetBounds';
|
|
const LEGACY_BOUNDS_KEY = 'overlayBounds';
|
|
let boundsDebounceTimer: NodeJS.Timeout | null = null;
|
|
|
|
type ShowTaskWidgetOptions = Readonly<{
|
|
inactive?: boolean;
|
|
}>;
|
|
|
|
export const updateTaskWidgetEnabled = (isEnabled: boolean): void => {
|
|
isTaskWidgetEnabled = isEnabled;
|
|
|
|
if (!isEnabled) {
|
|
destroyTaskWidget();
|
|
return;
|
|
}
|
|
|
|
if (!taskWidgetWin && !taskWidgetCreationPromise) {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const clearPendingTaskWidgetCreation = (): void => {
|
|
taskWidgetCreationGeneration += 1;
|
|
taskWidgetCreationPromise = null;
|
|
pendingShowAfterCreate = false;
|
|
pendingShowAfterCreateInactive = false;
|
|
};
|
|
|
|
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;
|
|
isUserForcedVisible = false;
|
|
clearPendingTaskWidgetCreation();
|
|
|
|
// 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 = (): Promise<void> => {
|
|
if (taskWidgetWin) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (taskWidgetCreationPromise) {
|
|
return taskWidgetCreationPromise;
|
|
}
|
|
|
|
const creationGeneration = taskWidgetCreationGeneration;
|
|
const nextCreationPromise = createTaskWidgetWindowForGeneration(
|
|
creationGeneration,
|
|
).finally(() => {
|
|
if (taskWidgetCreationPromise === nextCreationPromise) {
|
|
taskWidgetCreationPromise = null;
|
|
}
|
|
});
|
|
|
|
taskWidgetCreationPromise = nextCreationPromise;
|
|
return nextCreationPromise;
|
|
};
|
|
|
|
const createTaskWidgetWindowForGeneration = async (
|
|
creationGeneration: number,
|
|
): Promise<void> => {
|
|
if (taskWidgetWin) {
|
|
return;
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if (
|
|
taskWidgetWin ||
|
|
!isTaskWidgetEnabled ||
|
|
creationGeneration !== taskWidgetCreationGeneration
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
// Tie "user-forced visible" to the window's lifetime: once the window is
|
|
// gone the sticky flag has no widget to keep visible, so don't let it
|
|
// linger into a future re-create.
|
|
isUserForcedVisible = false;
|
|
});
|
|
|
|
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();
|
|
|
|
updateTaskWidgetOpacity(currentOpacity);
|
|
|
|
if (pendingShowAfterCreate) {
|
|
const showInactive = pendingShowAfterCreateInactive;
|
|
pendingShowAfterCreate = false;
|
|
pendingShowAfterCreateInactive = false;
|
|
showTaskWidgetWindow({ inactive: showInactive });
|
|
}
|
|
};
|
|
|
|
const showTaskWidgetWindow = (options: ShowTaskWidgetOptions = {}): void => {
|
|
if (!taskWidgetWin || taskWidgetWin.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
if (options.inactive) {
|
|
taskWidgetWin.showInactive();
|
|
} else {
|
|
taskWidgetWin.show();
|
|
}
|
|
};
|
|
|
|
export const showTaskWidget = (options: ShowTaskWidgetOptions = {}): void => {
|
|
if (!isTaskWidgetEnabled) {
|
|
return;
|
|
}
|
|
|
|
// Recreate task widget if it was accidentally closed
|
|
if (!taskWidgetWin) {
|
|
info('Task widget window was destroyed, recreating');
|
|
pendingShowAfterCreate = true;
|
|
pendingShowAfterCreateInactive = pendingShowAfterCreateInactive || !!options.inactive;
|
|
createTaskWidgetWindow();
|
|
return;
|
|
}
|
|
|
|
if (taskWidgetWin.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
// Only show if not already visible
|
|
if (!taskWidgetWin.isVisible()) {
|
|
info('Showing task widget');
|
|
showTaskWidgetWindow(options);
|
|
} 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');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Toggles the task widget's visibility. Intended for the global shortcut
|
|
* (`globalToggleTaskWidget`): it only acts when the task widget feature is
|
|
* enabled in settings and never changes that persisted enabled/disabled
|
|
* preference — it just shows or hides the existing widget.
|
|
*/
|
|
export const toggleTaskWidgetVisibility = (): void => {
|
|
if (!isTaskWidgetEnabled) {
|
|
return;
|
|
}
|
|
|
|
if (taskWidgetWin && !taskWidgetWin.isDestroyed() && taskWidgetWin.isVisible()) {
|
|
isUserForcedVisible = false;
|
|
hideTaskWidget();
|
|
return;
|
|
}
|
|
|
|
isUserForcedVisible = true;
|
|
showTaskWidget({ inactive: true });
|
|
};
|
|
|
|
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();
|
|
// Opening the app from the widget is an explicit "I'm going to the app"
|
|
// gesture, so clear any sticky user-forced visibility and let the widget
|
|
// follow the normal companion behavior again.
|
|
isUserForcedVisible = false;
|
|
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 getIsTaskWidgetUserForcedVisible = (): boolean => isUserForcedVisible;
|
|
|
|
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')}`;
|
|
};
|