mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* 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.
541 lines
17 KiB
TypeScript
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')}`;
|
|
};
|