super-productivity/electron/main-window.ts
Johannes Millan 880cfdcaf7
fix(electron): lock in-window navigation to the app's loaded origin (#8230)
* fix(electron): lock in-window navigation to the app's loaded origin

The previous will-navigate handler accepted any URL whose hostname was
'localhost' or '127.0.0.1', which left the privileged main window
reachable by any local web server (e.g. http://127.0.0.1:1337/) — the
preload bridge (window.ea) would have been exposed to whatever page that
server returned. In production the legitimate origin is file:// only, so
there is no excuse for ever navigating to http://localhost in-window.

Compare against the URL the app actually loaded (captured at loadURL
time) instead of a hostname allowlist. http/https requires exact
host+port match; file:// requires the same html pathname; data:/blob:/
javascript: are rejected outright. The same guard is also applied to
will-redirect so a same-origin start cannot be redirected onto an
attacker page mid-navigation, and a defensive did-create-window handler
destroys any unexpected child window (the deny-all setWindowOpenHandler
should make that path unreachable).

Also tightens TO_FILE_URL with the same userData deny used by the other
file-sync IPCs, so a plugin/XSS cannot launder a userData path into a
file:// URL that later flows through READ_LOCAL_IMAGE_AS_DATA_URL.

* fix(electron): reject UNC/remote-host file:// in navigation guard

`new URL('file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html').pathname`
equals the local app's pathname, so the previous pathname-only check
considered an attacker-controlled UNC host same-origin and let it load
in the privileged main window. The app's loaded file:// URL is always
local (empty host), so require `target.host === ''` explicitly.

* test(electron): add userinfo-@-trick navigation guard regression

`http://localhost:4200@evil.com/` parses with host=evil.com and
username=localhost — a naive substring or startsWith check would be
fooled. The existing host-equality check already rejects it; lock that
in with a test so a future refactor cannot regress.
2026-06-09 22:34:15 +02:00

723 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import windowStateKeeper from 'electron-window-state';
import {
App,
BrowserWindow,
BrowserWindowConstructorOptions,
ipcMain,
Menu,
MenuItemConstructorOptions,
nativeTheme,
shell,
} from 'electron';
import { errorHandlerWithFrontendInform } from './error-handler-with-frontend-inform';
import * as path from 'path';
import { join, normalize } from 'path';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { isExternalUrlSchemeAllowed } from './shared-with-frontend/is-external-url-allowed';
import { readFileSync, stat, writeFileSync } from 'fs';
import { error, log } from 'electron-log/main';
import { IS_MAC, IS_GNOME_DESKTOP } from './common.const';
import {
destroyTaskWidget,
getIsTaskWidgetAlwaysShow,
getIsTaskWidgetUserForcedVisible,
hideTaskWidget,
showTaskWidget,
} from './task-widget/task-widget';
import { ensureIndicator } from './indicator';
import { getIsMinimizeToTray, getIsQuiting, setIsQuiting } from './shared-state';
import { loadSimpleStoreAll } from './simple-store';
import { SimpleStoreKey } from './shared-with-frontend/simple-store.const';
import { markGpuStartupSuccess } from './gpu-startup-guard';
import { isAppOriginUrl } from './navigation-guard';
let mainWin: BrowserWindow;
// The URL passed to `mainWin.loadURL()` — the single source of truth for
// "what is the app's own origin?". Read by the will-navigate / will-redirect
// guards in `initWinEventListeners`. Set in `createWindow`, before listeners
// are wired, so the guard never sees `undefined` at runtime.
let appLoadedUrl: string | undefined;
// Compact WCO band on Win/Linux. Native button width is OS-controlled
// (~138px total); only height is configurable. Lower values may be
// clamped to the OS minimum (~2428px on Win11) — Electron silently
// floors instead of rejecting. Stays well clear of the vertical action
// strip which positions itself --bar-height (48px) down.
const WCO_HEIGHT = 24;
/**
* Returns theme-aware background color for titlebar overlay.
* Semi-transparent to ensure window controls are always visible.
*/
const getTitleBarColor = (isDark: boolean): string => {
// Dark: matches --bg (#131314) with 0% opacity (fully transparent)
// Light: matches --bg (#f8f8f7) with 0% opacity (fully transparent)
return isDark ? 'rgba(19, 19, 20, 0)' : 'rgba(248, 248, 247, 0)';
};
const mainWinModule: {
win?: BrowserWindow;
isAppReady: boolean;
} = {
win: undefined,
isAppReady: false,
};
export const getWin = (): BrowserWindow => {
if (!mainWinModule.win) {
throw new Error('No main window');
}
return mainWinModule.win;
};
// How long the "quit requested" intent survives before auto-clearing.
// Long enough to cover normal before-close IPC (sync, finish-day prompt);
// short enough that if the user cancels finish-day and then clicks the
// window close button, they get their normal minimize-to-tray behavior
// rather than being re-prompted indefinitely.
const QUIT_REQUEST_TIMEOUT_MS = 5_000;
let isQuitRequested = false;
let quitRequestResetTimer: NodeJS.Timeout | undefined;
const getIsQuitRequested = (): boolean => isQuitRequested;
const setIsQuitRequested = (flag: boolean): void => {
if (quitRequestResetTimer) clearTimeout(quitRequestResetTimer);
isQuitRequested = flag;
quitRequestResetTimer = flag
? setTimeout(() => {
isQuitRequested = false;
quitRequestResetTimer = undefined;
}, QUIT_REQUEST_TIMEOUT_MS)
: undefined;
};
export const closeWinAndQuit = (quitApp: () => void): void => {
if (mainWin && !mainWin.isDestroyed()) {
// Ensure the close handler takes the real close path (not the minimize-to-tray
// hide branch) so the before-close IPC flow (sync, finish-day) completes.
setIsQuitRequested(true);
mainWin.close();
} else {
// No window to drive the IPC flow through — quit directly. No flag
// needed: the close handler that reads it cannot run without a window.
quitApp();
}
};
export const getIsAppReady = (): boolean => {
return mainWinModule.isAppReady;
};
export const createWindow = async ({
IS_DEV,
ICONS_FOLDER,
quitApp,
app,
customUrl,
}: {
IS_DEV: boolean;
ICONS_FOLDER: string;
quitApp: () => void;
app: App;
customUrl?: string;
}): Promise<BrowserWindow> => {
// make sure the main window isn't already created
if (mainWin) {
errorHandlerWithFrontendInform('Main window already exists');
return mainWin;
}
// workaround for https://github.com/electron/electron/issues/16521
if (!IS_MAC) {
Menu.setApplicationMenu(null);
}
const mainWindowState = windowStateKeeper({
defaultWidth: 800,
defaultHeight: 800,
});
const simpleStore = await loadSimpleStoreAll();
const persistedIsUseCustomWindowTitleBar =
simpleStore[SimpleStoreKey.IS_USE_CUSTOM_WINDOW_TITLE_BAR];
const legacyIsUseObsidianStyleHeader =
simpleStore[SimpleStoreKey.LEGACY_IS_USE_OBSIDIAN_STYLE_HEADER];
const userPrefersCustomWindowTitleBar =
persistedIsUseCustomWindowTitleBar ??
legacyIsUseObsidianStyleHeader ??
!IS_GNOME_DESKTOP;
// GNOME + Wayland combinations can miss native controls when titleBarStyle is hidden.
// Force native decorations on GNOME to keep window controls available.
const isUseCustomWindowTitleBar = IS_GNOME_DESKTOP
? false
: userPrefersCustomWindowTitleBar;
// On macOS use 'hiddenInset' so AppKit positions the traffic lights at the
// standard inset other native apps use (Notes, Mail, VS Code) instead of
// crowding the top-left corner. Other platforms keep the existing logic.
const titleBarStyle: BrowserWindowConstructorOptions['titleBarStyle'] = IS_MAC
? 'hiddenInset'
: isUseCustomWindowTitleBar
? 'hidden'
: 'default';
// Determine initial symbol color based on system theme preference
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#fff' : '#000';
const titleBarOverlay: BrowserWindowConstructorOptions['titleBarOverlay'] =
isUseCustomWindowTitleBar && !IS_MAC
? {
color: getTitleBarColor(nativeTheme.shouldUseDarkColors),
symbolColor: initialSymbolColor,
height: WCO_HEIGHT,
}
: undefined;
// The store-screenshot pipeline forces a fixed 1280×800 window so the
// PNG dimensions match what the Mac App Store accepts (2560×1600 @2x).
// On laptop displays, menu bar + dock leave less than 800pt available
// below the menu bar, so by default macOS clamps `setBounds(800)` down
// to the available area and the captured PNG ends up 2040 px short of
// the required height. Setting `enableLargerThanScreen` lets the
// window keep its configured 800pt outer height regardless. Gated on
// the env var the screenshot fixture sets so normal users still get
// the default screen-clamping behavior.
const isScreenshotMode = process.env.SP_SCREENSHOT_MODE === '1';
mainWin = new BrowserWindow({
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minHeight: 240,
minWidth: 300,
title: IS_DEV ? 'Super Productivity D' : 'Super Productivity',
titleBarStyle,
titleBarOverlay,
enableLargerThanScreen: isScreenshotMode,
show: false,
webPreferences: {
scrollBounce: true,
backgroundThrottling: false,
webSecurity: true,
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
// make remote module work with those two settings
contextIsolation: true,
// Additional settings for better Linux/Wayland compatibility
enableBlinkFeatures: 'OverlayScrollbar',
// Disable spell checker to prevent connections to Google services (#5314)
// This maintains our "offline-first with zero data collection" promise
spellcheck: false,
},
icon: ICONS_FOLDER + '/icon_256x256.png',
// Wayland compatibility: disable transparent/frameless features that can cause issues
transparent: false,
// frame: true,
});
// see: https://pratikpc.medium.com/bypassing-cors-with-electron-ab7eaf331605
mainWin.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details;
removeKeyInAnyCase(requestHeaders, 'Origin');
removeKeyInAnyCase(requestHeaders, 'Referer');
removeKeyInAnyCase(requestHeaders, 'Cookie');
removeKeyInAnyCase(requestHeaders, 'sec-ch-ua');
removeKeyInAnyCase(requestHeaders, 'sec-ch-ua-mobile');
removeKeyInAnyCase(requestHeaders, 'sec-ch-ua-platform');
removeKeyInAnyCase(requestHeaders, 'sec-fetch-dest');
removeKeyInAnyCase(requestHeaders, 'sec-fetch-mode');
removeKeyInAnyCase(requestHeaders, 'sec-fetch-site');
removeKeyInAnyCase(requestHeaders, 'accept-encoding');
removeKeyInAnyCase(requestHeaders, 'accept-language');
removeKeyInAnyCase(requestHeaders, 'priority');
removeKeyInAnyCase(requestHeaders, 'accept');
// NOTE this is needed for GitHub api requests to work :(
// office365 needs a User-Agent as well (#4677)
if (
['github.com', 'office365.com', 'outlook.live.com'].includes(
new URL(details.url).hostname,
)
) {
removeKeyInAnyCase(requestHeaders, 'User-Agent');
}
callback({ requestHeaders });
});
mainWin.webContents.session.webRequest.onHeadersReceived((details, callback) => {
const { responseHeaders } = details;
upsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*']);
upsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*']);
upsertKeyValue(responseHeaders, 'Access-Control-Allow-Methods', ['*']);
// CORS preflight must return 2xx to pass the browser check. Force all
// OPTIONS responses to 200 OK unconditionally: some servers reject
// preflights with 401 (auth required, < 300) or 405 (>= 300), both of
// which the browser rejects even with the injected CORS headers.
const statusLine = details.method === 'OPTIONS' ? 'HTTP/1.1 200 OK' : undefined;
callback({
responseHeaders,
statusLine,
});
});
// Deny unnecessary permissions (webcam, microphone, geolocation, etc.)
// The app only needs notifications for desktop reminders
const allowedPermissions = ['notifications'];
mainWin.webContents.session.setPermissionRequestHandler(
(_webContents, permission, callback) => {
callback(allowedPermissions.includes(permission));
},
);
mainWin.webContents.session.setPermissionCheckHandler((_webContents, permission) => {
return allowedPermissions.includes(permission);
});
mainWindowState.manage(mainWin);
setWasMaximizedBeforeHide(mainWin.isMaximized());
// Fix for #7276: electron-window-state saves state in its `closed` handler,
// which calls win.isMaximized() on an already-hidden window (tray/shortcut
// hide → quit). electron#27838 makes isMaximized() return false in that
// case, so the persisted state loses the maximized flag. will-quit is the
// only process-level hook guaranteed to fire after every window `closed`
// event, so the library's write has always completed by the time we patch.
app.once('will-quit', () => {
if (!getWasMaximizedBeforeHide()) return;
const file = path.join(app.getPath('userData'), 'window-state.json');
try {
const state = JSON.parse(readFileSync(file, 'utf8'));
if (!state || typeof state !== 'object' || Array.isArray(state)) return;
if (state.isMaximized === true) return;
state.isMaximized = true;
writeFileSync(file, JSON.stringify(state));
} catch (err) {
error('Failed to patch window-state.json for maximized flag:', err);
}
});
const url = customUrl
? customUrl
: IS_DEV
? 'http://localhost:4200'
: `file://${normalize(join(__dirname, '../.tmp/angular-dist/browser/index.html'))}`;
// Capture the loaded URL so the navigation guard (initWinEventListeners →
// will-navigate) can compare against the actual app origin, not a derived
// guess. Any URL change here automatically tightens the guard.
appLoadedUrl = url;
mainWin.loadURL(url).then(() => {
// Set window title for dev mode
if (IS_DEV) {
mainWin.setTitle('Super Productivity D');
}
// load custom stylesheet if any
const CSS_FILE_PATH = app.getPath('userData') + '/styles.css';
stat(app.getPath('userData') + '/styles.css', (err) => {
if (err) {
log('No custom styles detected at ' + CSS_FILE_PATH);
} else {
log('Loading custom styles from ' + CSS_FILE_PATH);
const styles = readFileSync(CSS_FILE_PATH, { encoding: 'utf8' });
try {
mainWin.webContents.insertCSS(styles);
log('Custom styles loaded successfully');
} catch (cssError) {
error('Failed to load custom styles:', cssError);
}
}
});
});
// show gracefully
mainWin.once('ready-to-show', () => {
mainWin.show();
// Workaround for Windows phantom focus bug (electron#20464):
// show() can silently fail to acquire keyboard focus after reboot.
// blur() is not supported on Wayland and limited on macOS, so only
// apply the blur+focus cycle on Windows.
const IS_WINDOWS = process.platform === 'win32';
setTimeout(() => {
if (mainWin.isDestroyed()) return;
if (IS_WINDOWS) {
mainWin.blur();
}
mainWin.focus();
if (!mainWin.webContents.isDestroyed()) {
mainWin.webContents.focus();
}
}, 60);
});
initWinEventListeners(app);
if (IS_MAC) {
createMenu(quitApp);
} else {
mainWin.setMenu(null);
mainWin.setMenuBarVisibility(false);
}
// update prop
mainWinModule.win = mainWin;
// listen for app ready
ipcMain.on(IPC.APP_READY, () => {
mainWinModule.isAppReady = true;
// Signal the GPU startup guard that the full boot chain completed
// (including Angular init) — not just that the compositor painted a
// frame. This avoids clearing the crash counter on blank/broken
// renderers that still fire `ready-to-show`.
markGpuStartupSuccess();
});
// Register F11 key handler for fullscreen toggle
mainWin.webContents.on('before-input-event', (event, input) => {
if (input.type === 'keyDown' && input.key === 'F11') {
event.preventDefault();
mainWin.setFullScreen(!mainWin.isFullScreen());
}
});
// Notify renderer of fullscreen state changes (used for app border visibility)
mainWin.on('enter-full-screen', () => {
mainWin.webContents.send(IPC.ENTER_FULL_SCREEN);
});
mainWin.on('leave-full-screen', () => {
mainWin.webContents.send(IPC.LEAVE_FULL_SCREEN);
});
mainWin.webContents.on('did-finish-load', () => {
if (mainWin.isFullScreen()) {
mainWin.webContents.send(IPC.ENTER_FULL_SCREEN);
}
});
// Listen for theme changes to update title bar overlay color and symbol
if (isUseCustomWindowTitleBar && !IS_MAC) {
ipcMain.on(IPC.UPDATE_TITLE_BAR_DARK_MODE, (ev, isDarkMode: boolean) => {
try {
const symbolColor = isDarkMode ? '#fff' : '#000';
mainWin.setTitleBarOverlay({
color: getTitleBarColor(isDarkMode),
symbolColor,
height: WCO_HEIGHT,
});
} catch (e) {
// setTitleBarOverlay may not be available on all platforms
log('Failed to update title bar overlay:', e);
}
});
}
return mainWin;
};
// isMaximized() can return an incorrect value after hide() — this is a known issue on certain platforms/configurations (electron#27838).
// to ensure maximized window state is restored reliably across all platforms, we manually track maximized state before hiding
let wasMaximizedBeforeHide: boolean = false;
export const getWasMaximizedBeforeHide = (): boolean => wasMaximizedBeforeHide;
export const setWasMaximizedBeforeHide = (value: boolean): void => {
wasMaximizedBeforeHide = value;
};
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function initWinEventListeners(app: Electron.App): void {
const openUrlInBrowser = (url: string): void => {
// Defense in depth: never hand an unsafe scheme to the OS handler, even if
// a renderer-side guard is bypassed (e.g. a link click that falls through
// to navigation rather than the explicit openExternalUrl IPC). The blocked
// schemes are OS protocol handlers / UNC paths. See GHSA-hr87-735w-hfq3.
if (!isExternalUrlSchemeAllowed(url)) {
error('Refused to open URL with disallowed scheme via openExternal');
return;
}
// needed for mac; especially for jira urls we might have a host like this www.host.de//
const urlObj = new URL(url);
urlObj.pathname = urlObj.pathname.replace('//', '/');
const wellFormedUrl = urlObj.toString();
// shell.openExternal returns Promise<void>; surface the failure to the
// renderer (snack via IPC.ERROR) so users on sandboxed packagings
// (Flatpak without OpenURI portal, etc.) see why nothing happened.
shell.openExternal(wellFormedUrl).catch((err) => {
error('Failed to open external URL via shell.openExternal:', err);
// Best-effort renderer notification — guard against the case where the
// frontend isn't ready (e.g. during shutdown or pre-load), in which
// case errorHandlerWithFrontendInform throws synchronously.
try {
errorHandlerWithFrontendInform(
'Could not open the link in your browser. Copy the URL manually if available.',
err,
);
} catch (informErr) {
error('Could not surface open-external failure to renderer:', informErr);
}
});
};
// Compare the navigation target against the URL the app actually loaded
// (captured at loadURL time in createWindow). Anything else is treated as
// external and routed through the scheme-guarded `openUrlInBrowser`.
//
// The main window has Node integration via the preload bridge (`window.ea`).
// Allowing in-window navigation to ANY other origin — including
// http://127.0.0.1:<any-port> — would expose that bridge to whatever page
// happens to be served there (a malicious local web server, a sibling
// electron app, etc.). The previous host-only check accepted those.
//
// Hash-only changes do NOT fire will-navigate, so this never fires for
// the app's own hash routes (HashLocationStrategy in src/main.ts).
const guardNavigation = (
ev: { preventDefault: () => void },
url: string,
eventLabel: string,
): void => {
if (appLoadedUrl && isAppOriginUrl(url, appLoadedUrl)) return;
ev.preventDefault();
log(`Blocked in-window navigation (${eventLabel})`);
openUrlInBrowser(url);
};
mainWin.webContents.on('will-navigate', (ev, url) => {
guardNavigation(ev, url, 'will-navigate');
});
// Defense in depth: a same-origin navigation could redirect to a different
// origin server-side. Re-run the same check on the redirect target so a
// 302 → http://127.0.0.1:1337 cannot land the bridge on an attacker page.
mainWin.webContents.on('will-redirect', (ev, url) => {
guardNavigation(ev, url, 'will-redirect');
});
mainWin.webContents.setWindowOpenHandler((details) => {
openUrlInBrowser(details.url);
return { action: 'deny' };
});
// Defense in depth: setWindowOpenHandler already denies, so this should
// never fire. If a future code path ever enables window creation, destroy
// the spawned window rather than letting it inherit the preload bridge.
mainWin.webContents.on('did-create-window', (childWin) => {
error('did-create-window fired despite deny handler — destroying child');
try {
childWin.destroy();
} catch (e) {
error('Failed to destroy unexpected child window:', e);
}
});
// TODO refactor quitting mess
appCloseHandler(app);
appMinimizeHandler(app);
// Handle restore and show events to hide task widget. `getIsTaskWidgetUserForcedVisible()`
// keeps the widget up when the user explicitly revealed it via the global shortcut.
mainWin.on('restore', () => {
if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) {
hideTaskWidget();
}
});
mainWin.on('show', () => {
if (!getIsTaskWidgetAlwaysShow() && !getIsTaskWidgetUserForcedVisible()) {
hideTaskWidget();
}
});
mainWin.on('focus', () => {
if (
mainWin.isVisible() &&
!mainWin.isMinimized() &&
!getIsTaskWidgetAlwaysShow() &&
!getIsTaskWidgetUserForcedVisible()
) {
hideTaskWidget();
}
});
// Handle hide event to show task widget
mainWin.on('hide', () => {
showTaskWidget();
});
// Handle maximize and unmaximize events to change wasMaximizedBeforeHide flag accordingly
mainWin.on('maximize', () => {
setWasMaximizedBeforeHide(true);
});
mainWin.on('unmaximize', () => {
setWasMaximizedBeforeHide(false);
});
}
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function createMenu(quitApp: () => void): void {
// Create application menu to enable copy & pasting on MacOS
const menuTpl: MenuItemConstructorOptions[] = [
{
label: 'Application',
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{
label: 'Quit',
accelerator: 'CmdOrCtrl+Q',
click: () => closeWinAndQuit(quitApp),
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
];
// we need to set a menu to get copy & paste working for mac os x
Menu.setApplicationMenu(Menu.buildFromTemplate(menuTpl));
}
// TODO this is ugly as f+ck
const appCloseHandler = (app: App): void => {
let ids: string[] = [];
const _quitApp = (): void => {
setIsQuiting(true);
// Destroy task widget before closing main window to ensure window-all-closed fires
destroyTaskWidget();
mainWin.close();
};
ipcMain.on(IPC.REGISTER_BEFORE_CLOSE, (ev, { id }) => {
ids.push(id);
});
ipcMain.on(IPC.UNREGISTER_BEFORE_CLOSE, (ev, { id }) => {
ids = ids.filter((idIn) => idIn !== id);
});
ipcMain.on(IPC.BEFORE_CLOSE_DONE, (ev, { id }) => {
ids = ids.filter((idIn) => idIn !== id);
log(IPC.BEFORE_CLOSE_DONE, id, ids);
if (ids.length === 0) {
// Destroy task widget before closing main window
destroyTaskWidget();
mainWin.close();
}
});
mainWin.on('close', (event) => {
// NOTE: this might not work if we run a second instance of the app
log('close event: isQuiting=', getIsQuiting(), 'pendingBeforeCloseIds=', ids);
if (!getIsQuiting()) {
if (getIsMinimizeToTray() && !getIsQuitRequested()) {
const indicator = ensureIndicator();
if (indicator) {
event.preventDefault();
setWasMaximizedBeforeHide(mainWin.isMaximized());
mainWin.hide();
showTaskWidget();
return;
}
}
event.preventDefault();
if (ids.length > 0) {
log('Actions to wait for ', ids);
mainWin.webContents.send(IPC.NOTIFY_ON_CLOSE, ids);
} else {
_quitApp();
}
}
});
mainWin.on('closed', () => {
// Clear any pending reset timer so it doesn't keep the event loop alive
// after the window is gone.
setIsQuitRequested(false);
// Dereference the window object
mainWin = null;
mainWinModule.win = null;
});
mainWin.webContents.on('render-process-gone', (event, detailed) => {
log('!crashed, reason: ' + detailed.reason + ', exitCode = ' + detailed.exitCode);
if (detailed.reason == 'crashed') {
process.exit(detailed.exitCode);
// relaunch app
// app.relaunch({ args: process.argv.slice(1).concat(['--relaunch']) });
// app.exit(0);
}
});
};
const appMinimizeHandler = (app: App): void => {
if (!getIsQuiting()) {
// TODO find reason for the typing error
// @ts-ignore
mainWin.on('minimize', (event: Event) => {
if (getIsMinimizeToTray()) {
const indicator = ensureIndicator();
if (!indicator) {
return;
}
event.preventDefault();
setWasMaximizedBeforeHide(mainWin.isMaximized());
mainWin.hide();
showTaskWidget();
} else {
// For regular minimize (not to tray), also show task widget
showTaskWidget();
if (IS_MAC) {
app.dock?.show();
}
}
});
}
};
const upsertKeyValue = <T extends Record<string, any> | undefined>(
obj: T,
keyToChange: string,
value: string[],
): T => {
if (!obj) return obj;
const keyToChangeLower = keyToChange.toLowerCase();
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToChangeLower) {
// Reassign old key
(obj as any)[key] = value;
// Done
return obj;
}
}
// Insert at end instead
(obj as any)[keyToChange] = value;
return obj;
};
const removeKeyInAnyCase = <T extends Record<string, any> | undefined>(
obj: T,
keyToRemove: string,
): T => {
if (!obj) return obj;
const keyToRemoveLower = keyToRemove.toLowerCase();
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToRemoveLower) {
delete (obj as any)[key];
return obj;
}
}
return obj;
};