super-productivity/electron/task-widget/task-widget.ts
Johannes Millan 463709e2de
fix(electron): assert renderer IPC boundary at window creation (#9018)
* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(electron): assert renderer IPC boundary at window creation

Every IPC trust boundary (Jira one-shot capability, plugin node-exec
consent, the window.ea preload bridge) rests on the renderer main world
not having require/ipcRenderer, which is guaranteed solely by
contextIsolation: true + nodeIntegration: false and sub-frames not
getting node integration. If that webPreferences ever silently regressed
(a refactor spreading a shared options object, a bad merge), every gate
would collapse at once while still looking correct in review.

Add web-preferences-guard.ts (assertSecureWebPreferences) and fail closed
before creating a window if the boundary is not intact. It rejects a
non-true contextIsolation, a non-false nodeIntegration, and (fail-closed)
a nodeIntegrationInSubFrames that is not explicitly false; it also
directionally rejects an explicit sandbox: false, nodeIntegrationInWorker:
true, and webviewTag: true (each off by default, so no call site is
forced to set it). Wire it at all three new BrowserWindow sites (main
window, task widget, full-screen blocker); the full-screen blocker
previously relied on Electron defaults, so set its webPreferences
explicitly. A *.test.cjs backs it with behavioral coverage plus a wiring
guard that counts constructor sites vs guard calls per file, so a future
window cannot silently ship without the check.

Closes #9015

* fix(electron): extend webPreferences guard to webSecurity

Follow-up hardening from the multi-agent review of #9018:

- Reject an explicit `webSecurity: false` (directional, like the sandbox
  /worker/webviewTag trio). With the app's blanket
  Access-Control-Allow-Origin: *, disabling the same-origin policy in a
  node-bridged renderer would widen cross-origin reach — and no call site
  currently guards against it.
- Broaden the wiring-guard test to also require the assert for
  `new BrowserView` / `new WebContentsView`, closing the tripwire's blind
  spot for future non-BrowserWindow renderers (none exist today).
- Correct the fail() comment: the `throw` narrows the type regardless of
  return-vs-throw; fail() returns an Error only to DRY the message.

230/230 electron tests pass; checkFile + prettier clean.
2026-07-15 10:37:10 +02:00

541 lines
17 KiB
TypeScript

import {
BrowserWindow,
BrowserWindowConstructorOptions,
ipcMain,
screen,
} from 'electron';
import { join } from 'path';
import { assertSecureWebPreferences } from '../web-preferences-guard';
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;
}
const webPreferences: BrowserWindowConstructorOptions['webPreferences'] = {
preload: join(__dirname, 'task-widget-preload.js'),
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
disableDialogs: true,
webSecurity: true,
allowRunningInsecureContent: false,
backgroundThrottling: false, // Prevent throttling when hidden
};
// Keep the widget renderer's IPC boundary as tight as the main window's.
assertSecureWebPreferences(webPreferences, 'task-widget');
// 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,
});
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')}`;
};