import { dialog, BrowserWindow, BrowserWindowConstructorOptions, Event, HeadersReceivedResponse, MessageBoxReturnValue, OnHeadersReceivedListenerDetails, WebPreferences, } from 'electron'; import log from 'loglevel'; import path from 'path'; import { TrayValue, WindowOptions } from '../../../shared/src/options/model'; import { getCSSToInject, isOSX, nativeTabsSupported } from './helpers'; const ZOOM_INTERVAL = 0.1; export function adjustWindowZoom(adjustment: number): void { withFocusedWindow((focusedWindow: BrowserWindow) => { focusedWindow.webContents.zoomFactor = focusedWindow.webContents.zoomFactor + adjustment; }); } export function blockExternalURL(url: string): Promise { return new Promise((resolve, reject) => { withFocusedWindow((focusedWindow) => { dialog .showMessageBox(focusedWindow, { message: `Cannot navigate to external URL: ${url}`, type: 'error', title: 'Navigation blocked', }) .then((result) => resolve(result)) .catch((err) => { reject(err); }); }); }); } export async function clearAppData(window: BrowserWindow): Promise { const response = await dialog.showMessageBox(window, { type: 'warning', buttons: ['Yes', 'Cancel'], defaultId: 1, title: 'Clear cache confirmation', message: 'This will clear all data (cookies, local storage etc) from this app. Are you sure you wish to proceed?', }); if (response.response !== 0) { return; } await clearCache(window); } export async function clearCache(window: BrowserWindow): Promise { const { session } = window.webContents; await session.clearStorageData(); await session.clearCache(); } export function createAboutBlankWindow( options: WindowOptions, setupWindow: (options: WindowOptions, window: BrowserWindow) => void, parent?: BrowserWindow, ): BrowserWindow { const window = createNewWindow(options, setupWindow, 'about:blank', parent); window.hide(); window.webContents.once('did-stop-loading', () => { if (window.webContents.getURL() === 'about:blank') { window.close(); } else { window.show(); } }); return window; } export function createNewTab( options: WindowOptions, setupWindow: (options: WindowOptions, window: BrowserWindow) => void, url: string, foreground: boolean, parent?: BrowserWindow, ): BrowserWindow | undefined { log.debug('createNewTab', { url, foreground, parent }); return withFocusedWindow((focusedWindow) => { const newTab = createNewWindow(options, setupWindow, url, parent); focusedWindow.addTabbedWindow(newTab); if (!foreground) { focusedWindow.focus(); } return newTab; }); } export function createNewWindow( options: WindowOptions, setupWindow: (options: WindowOptions, window: BrowserWindow) => void, url: string, parent?: BrowserWindow, ): BrowserWindow { log.debug('createNewWindow', { url, parent }); const window = new BrowserWindow({ parent, ...getDefaultWindowOptions(options), }); setupWindow(options, window); window.loadURL(url).catch((err) => log.error('window.loadURL ERROR', err)); return window; } export function getCurrentURL(): string { return withFocusedWindow((focusedWindow) => focusedWindow.webContents.getURL(), ) as unknown as string; } export function getDefaultWindowOptions( options: WindowOptions, ): BrowserWindowConstructorOptions { const browserwindowOptions: BrowserWindowConstructorOptions = { ...options.browserwindowOptions, }; // We're going to remove this and merge it separately into DEFAULT_WINDOW_OPTIONS.webPreferences // Otherwise the browserwindowOptions.webPreferences object will completely replace the // webPreferences specified in the DEFAULT_WINDOW_OPTIONS with itself delete browserwindowOptions.webPreferences; const webPreferences: WebPreferences = { ...(options.browserwindowOptions?.webPreferences ?? {}), }; const defaultOptions: BrowserWindowConstructorOptions = { fullscreenable: true, tabbingIdentifier: nativeTabsSupported() ? options.name : undefined, title: options.name, webPreferences: { javascript: true, nodeIntegration: false, // `true` is *insecure*, and cause trouble with messenger.com preload: path.join(__dirname, 'preload.js'), plugins: true, webSecurity: !options.insecure, zoomFactor: options.zoom, ...webPreferences, }, ...browserwindowOptions, }; log.debug('getDefaultWindowOptions', { options, webPreferences, defaultOptions, }); return defaultOptions; } export function goBack(): void { log.debug('onGoBack'); withFocusedWindow((focusedWindow) => { focusedWindow.webContents.goBack(); }); } export function goForward(): void { log.debug('onGoForward'); withFocusedWindow((focusedWindow) => { focusedWindow.webContents.goForward(); }); } export function goToURL(url: string): Promise | undefined { return withFocusedWindow((focusedWindow) => focusedWindow.loadURL(url)); } export function hideWindow( window: BrowserWindow, event: Event, fastQuit: boolean, tray: TrayValue, ): void { if (isOSX() && !fastQuit) { // this is called when exiting from clicking the cross button on the window event.preventDefault(); window.hide(); } else if (!fastQuit && tray !== 'false') { event.preventDefault(); window.hide(); } // will close the window on other platforms } export function injectCSS(browserWindow: BrowserWindow): void { const cssToInject = getCSSToInject(); if (!cssToInject) { return; } browserWindow.webContents.on('did-navigate', () => { log.debug( 'browserWindow.webContents.did-navigate', browserWindow.webContents.getURL(), ); browserWindow.webContents .insertCSS(cssToInject) .catch((err: unknown) => log.error('browserWindow.webContents.insertCSS', err), ); // We must inject css early enough; so onHeadersReceived is a good place. // Will run multiple times, see `did-finish-load` event on the window // that unsets this handler. browserWindow.webContents.session.webRequest.onHeadersReceived( { urls: [] }, // Pass an empty filter list; null will not match _any_ urls ( details: OnHeadersReceivedListenerDetails, callback: (headersReceivedResponse: HeadersReceivedResponse) => void, ) => { const contentType = details.responseHeaders && 'content-type' in details.responseHeaders ? details.responseHeaders['content-type'][0] : undefined; log.debug('onHeadersReceived', { contentType, resourceType: details.resourceType, url: details.url, }); injectCSSIntoResponse(details, contentType, cssToInject) .then((responseHeaders) => { callback({ cancel: false, responseHeaders, }); }) .catch((err) => { log.error('injectCSSIntoResponse ERROR', err); callback({ cancel: false, responseHeaders: details.responseHeaders, }); }); }, ); }); } async function injectCSSIntoResponse( details: OnHeadersReceivedListenerDetails, contentType: string | undefined, cssToInject: string, ): Promise | undefined> { // We go with a denylist rather than a whitelist (e.g. only text/html) // to avoid "whoops I didn't think this should have been CSS-injected" cases const nonInjectableContentTypes = [ /application\/.*/, /font\/.*/, /image\/.*/, ]; const nonInjectableResourceTypes = ['image', 'script', 'stylesheet', 'xhr']; if ( (contentType && nonInjectableContentTypes.filter((x) => { const matches = x.exec(contentType); return matches && matches?.length > 0; })?.length > 0) || nonInjectableResourceTypes.includes(details.resourceType) || !details.webContents ) { log.debug( `Skipping CSS injection for:\n${details.url}\nwith resourceType ${ details.resourceType } and content-type ${contentType as string}`, ); return details.responseHeaders; } log.debug( `Injecting CSS for:\n${details.url}\nwith resourceType ${ details.resourceType } and content-type ${contentType as string}`, ); await details.webContents.insertCSS(cssToInject); return details.responseHeaders; } export function sendParamsOnDidFinishLoad( options: WindowOptions, window: BrowserWindow, ): void { window.webContents.on('did-finish-load', () => { log.debug( 'sendParamsOnDidFinishLoad.window.webContents.did-finish-load', window.webContents.getURL(), ); // In children windows too: Restore pinch-to-zoom, disabled by default in recent Electron. // See https://github.com/nativefier/nativefier/issues/379#issuecomment-598612128 // and https://github.com/electron/electron/pull/12679 window.webContents .setVisualZoomLevelLimits(1, 3) .catch((err) => log.error('webContents.setVisualZoomLevelLimits', err)); window.webContents.send('params', JSON.stringify(options)); }); } export function setProxyRules( window: BrowserWindow, proxyRules?: string, ): void { window.webContents.session .setProxy({ proxyRules, pacScript: '', proxyBypassRules: '', }) .catch((err) => log.error('session.setProxy ERROR', err)); } export function withFocusedWindow( block: (window: BrowserWindow) => T, ): T | undefined { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { return block(focusedWindow); } return undefined; } export function zoomOut(): void { log.debug('zoomOut'); adjustWindowZoom(-ZOOM_INTERVAL); } export function zoomReset(options: { zoom?: number }): void { log.debug('zoomReset'); withFocusedWindow((focusedWindow) => { focusedWindow.webContents.zoomFactor = options.zoom ?? 1.0; }); } export function zoomIn(): void { log.debug('zoomIn'); adjustWindowZoom(ZOOM_INTERVAL); }