From 72de7b3fca98310625b6f364bb6fc9cf68fd4e18 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 2 Jun 2021 15:18:32 -0400 Subject: [PATCH] Refactor app window creation/events + add some unit tests; fix #1197 (#1203) * Catch promise errors better * Move subFunctions to bottom of createNewWindow * Use parents when creating child BrowserWindow instances * Some about:blank pages have an anchor (for some reason) * Inject browserWindowOptions better * Interim refactor to MainWindow object * Split up the window functions/helpers/events some * Further separate out window functions + tests * Add a mock for unit testing functions that access electron * Add unit tests for onWillPreventUnload * Improve windowEvents tests * Add the first test for windowHelpers * Move WebRequest event handling to node * insertCSS completely under test * clearAppData completely under test * Fix contextMenu require bug * More tests + fixes * Fix + add to createNewTab tests * Convert createMainWindow back to func + work out gremlins * Move setupWindow away from main since its shared * Make sure contextMenu is handling promises --- app/src/components/contextMenu.ts | 44 +- app/src/components/loginWindow.ts | 15 +- app/src/components/mainWindow.ts | 595 +++++-------------- app/src/components/mainWindowHelpers.test.ts | 238 -------- app/src/components/mainWindowHelpers.ts | 35 -- app/src/components/menu.ts | 17 +- app/src/helpers/helpers.ts | 168 +++--- app/src/helpers/windowEvents.test.ts | 357 +++++++++++ app/src/helpers/windowEvents.ts | 194 ++++++ app/src/helpers/windowHelpers.test.ts | 188 ++++++ app/src/helpers/windowHelpers.ts | 302 ++++++++++ app/src/main.ts | 61 +- app/src/mocks/electron.ts | 136 +++++ package.json | 3 + src/helpers/helpers.ts | 2 +- 15 files changed, 1510 insertions(+), 845 deletions(-) delete mode 100644 app/src/components/mainWindowHelpers.test.ts delete mode 100644 app/src/components/mainWindowHelpers.ts create mode 100644 app/src/helpers/windowEvents.test.ts create mode 100644 app/src/helpers/windowEvents.ts create mode 100644 app/src/helpers/windowHelpers.test.ts create mode 100644 app/src/helpers/windowHelpers.ts create mode 100644 app/src/mocks/electron.ts diff --git a/app/src/components/contextMenu.ts b/app/src/components/contextMenu.ts index 6b8d395..d51519d 100644 --- a/app/src/components/contextMenu.ts +++ b/app/src/components/contextMenu.ts @@ -1,29 +1,51 @@ -import { shell } from 'electron'; -import contextMenu from 'electron-context-menu'; +import { BrowserWindow } from 'electron'; +import log from 'loglevel'; +import { nativeTabsSupported, openExternal } from '../helpers/helpers'; +import { setupNativefierWindow } from '../helpers/windowEvents'; +import { createNewTab, createNewWindow } from '../helpers/windowHelpers'; + +export function initContextMenu(options, window?: BrowserWindow): void { + // Require this at runtime, otherwise its child dependency 'electron-is-dev' + // throws an error during unit testing. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const contextMenu = require('electron-context-menu'); + + log.debug('initContextMenu', { options, window }); -export function initContextMenu(createNewWindow, createNewTab): void { contextMenu({ prepend: (actions, params) => { + log.debug('contextMenu.prepend', { actions, params }); const items = []; if (params.linkURL) { items.push({ label: 'Open Link in Default Browser', click: () => { - shell.openExternal(params.linkURL); // eslint-disable-line @typescript-eslint/no-floating-promises + openExternal(params.linkURL).catch((err) => + log.error('contextMenu Open Link in Default Browser ERROR', err), + ); }, }); items.push({ label: 'Open Link in New Window', - click: () => { - createNewWindow(params.linkURL); - }, + click: () => + createNewWindow( + options, + setupNativefierWindow, + params.linkURL, + window, + ), }); - if (createNewTab) { + if (nativeTabsSupported()) { items.push({ label: 'Open Link in New Tab', - click: () => { - createNewTab(params.linkURL, false); - }, + click: () => + createNewTab( + options, + setupNativefierWindow, + params.linkURL, + true, + window, + ), }); } } diff --git a/app/src/components/loginWindow.ts b/app/src/components/loginWindow.ts index 272bffa..d8b361a 100644 --- a/app/src/components/loginWindow.ts +++ b/app/src/components/loginWindow.ts @@ -1,9 +1,17 @@ import * as path from 'path'; +import * as log from 'loglevel'; + import { BrowserWindow, ipcMain } from 'electron'; -export function createLoginWindow(loginCallback): BrowserWindow { +export async function createLoginWindow( + loginCallback, + parent?: BrowserWindow, +): Promise { + log.debug('createLoginWindow', loginCallback, parent); + const loginWindow = new BrowserWindow({ + parent, width: 300, height: 400, frame: false, @@ -12,8 +20,9 @@ export function createLoginWindow(loginCallback): BrowserWindow { nodeIntegration: true, // TODO work around this; insecure }, }); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - loginWindow.loadURL(`file://${path.join(__dirname, 'static/login.html')}`); + await loginWindow.loadURL( + `file://${path.join(__dirname, 'static/login.html')}`, + ); ipcMain.once('login-message', (event, usernameAndPassword) => { loginCallback(usernameAndPassword[0], usernameAndPassword[1]); diff --git a/app/src/components/mainWindow.ts b/app/src/components/mainWindow.ts index 8320fb1..1cb01e7 100644 --- a/app/src/components/mainWindow.ts +++ b/app/src/components/mainWindow.ts @@ -1,34 +1,35 @@ import * as fs from 'fs'; import * as path from 'path'; -import { - BrowserWindow, - shell, - ipcMain, - dialog, - Event, - HeadersReceivedResponse, - OnHeadersReceivedListenerDetails, - WebContents, -} from 'electron'; +import { ipcMain, BrowserWindow, IpcMainEvent } from 'electron'; import windowStateKeeper from 'electron-window-state'; import log from 'loglevel'; import { - isOSX, - linkIsInternal, - getCssToInject, - shouldInjectCss, getAppIcon, - nativeTabsSupported, getCounterValue, + isOSX, + nativeTabsSupported, + openExternal, } from '../helpers/helpers'; +import { setupNativefierWindow } from '../helpers/windowEvents'; +import { + clearAppData, + clearCache, + getCurrentURL, + getDefaultWindowOptions, + goBack, + goForward, + goToURL, + hideWindow, + zoomIn, + zoomOut, + zoomReset, +} from '../helpers/windowHelpers'; import { initContextMenu } from './contextMenu'; -import { onNewWindowHelper } from './mainWindowHelpers'; import { createMenu } from './menu'; export const APP_ARGS_FILE_PATH = path.join(__dirname, '..', 'nativefier.json'); -const ZOOM_INTERVAL = 0.1; type SessionInteractionRequest = { id?: string; @@ -44,119 +45,23 @@ type SessionInteractionResult = { error?: Error; }; -function hideWindow( - window: BrowserWindow, - event: Event, - fastQuit: boolean, - tray, -): 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) { - event.preventDefault(); - window.hide(); - } - // will close the window on other platforms -} - -function injectCss(browserWindow: BrowserWindow): void { - if (!shouldInjectCss()) { - return; - } - - const cssToInject = getCssToInject(); - - browserWindow.webContents.on('did-navigate', () => { - log.debug('browserWindow.webContents.did-navigate'); - // We must inject css early enough; so onHeadersReceived is a good place. - // Will run multiple times, see `did-finish-load` below 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, - ) => { - log.debug( - 'browserWindow.webContents.session.webRequest.onHeadersReceived', - { details, callback }, - ); - if (details.webContents) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - details.webContents.insertCSS(cssToInject); - } - callback({ cancel: false, responseHeaders: details.responseHeaders }); - }, - ); - }); -} - -async function clearCache(browserWindow: BrowserWindow): Promise { - const { session } = browserWindow.webContents; - await session.clearStorageData(); - await session.clearCache(); -} - -function setProxyRules(browserWindow: BrowserWindow, proxyRules): void { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - browserWindow.webContents.session.setProxy({ - proxyRules, - pacScript: '', - proxyBypassRules: '', - }); -} - -export function saveAppArgs(newAppArgs: any) { - try { - fs.writeFileSync(APP_ARGS_FILE_PATH, JSON.stringify(newAppArgs)); - } catch (err) { - // eslint-disable-next-line no-console - log.warn( - `WARNING: Ignored nativefier.json rewrital (${( - err as Error - ).toString()})`, - ); - } -} - -export type createWindowResult = { - window: BrowserWindow; - setupWindow: (window: BrowserWindow) => void; -}; - /** * @param {{}} nativefierOptions AppArgs from nativefier.json * @param {function} onAppQuit * @param {function} setDockBadge */ -export function createMainWindow( +export async function createMainWindow( nativefierOptions, - onAppQuit, - setDockBadge, -): createWindowResult { + onAppQuit: () => void, + setDockBadge: (value: number | string, bounce?: boolean) => void, +): Promise { const options = { ...nativefierOptions }; + const mainWindowState = windowStateKeeper({ defaultWidth: options.width || 1280, defaultHeight: options.height || 800, }); - const DEFAULT_WINDOW_OPTIONS = { - // Convert dashes to spaces because on linux the app name is joined with dashes - title: options.name, - tabbingIdentifier: nativeTabsSupported() ? options.name : undefined, - webPreferences: { - javascript: true, - plugins: true, - nodeIntegration: false, // `true` is *insecure*, and cause trouble with messenger.com - webSecurity: !options.insecure, - preload: path.join(__dirname, 'preload.js'), - zoomFactor: options.zoom, - }, - }; - - const browserwindowOptions = { ...options.browserwindowOptions }; - const mainWindow = new BrowserWindow({ frame: !options.hideWindowFrame, width: mainWindowState.width, @@ -170,14 +75,13 @@ export function createMainWindow( autoHideMenuBar: !options.showMenuBar, icon: getAppIcon(), // set to undefined and not false because explicitly setting to false will disable full screen - fullscreen: options.fullScreen || undefined, + fullscreen: options.fullScreen ?? undefined, // Whether the window should always stay on top of other windows. Default is false. alwaysOnTop: options.alwaysOnTop, titleBarStyle: options.titleBarStyle, show: options.tray !== 'start-in-tray', backgroundColor: options.backgroundColor, - ...DEFAULT_WINDOW_OPTIONS, - ...browserwindowOptions, + ...getDefaultWindowOptions(options), }); mainWindowState.manage(mainWindow); @@ -193,274 +97,14 @@ export function createMainWindow( mainWindow.hide(); } - const withFocusedWindow = (block: (window: BrowserWindow) => void): void => { - const focusedWindow = BrowserWindow.getFocusedWindow(); - if (focusedWindow) { - return block(focusedWindow); - } - return undefined; - }; - - const adjustWindowZoom = ( - window: BrowserWindow, - adjustment: number, - ): void => { - window.webContents.zoomFactor = window.webContents.zoomFactor + adjustment; - }; - - const onZoomIn = (): void => { - log.debug('onZoomIn'); - withFocusedWindow((focusedWindow: BrowserWindow) => - adjustWindowZoom(focusedWindow, ZOOM_INTERVAL), - ); - }; - - const onZoomOut = (): void => { - log.debug('onZoomOut'); - withFocusedWindow((focusedWindow: BrowserWindow) => - adjustWindowZoom(focusedWindow, -ZOOM_INTERVAL), - ); - }; - - const onZoomReset = (): void => { - log.debug('onZoomReset'); - withFocusedWindow((focusedWindow: BrowserWindow) => { - focusedWindow.webContents.zoomFactor = options.zoom; - }); - }; - - const clearAppData = async (): Promise => { - const response = await dialog.showMessageBox(mainWindow, { - 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(mainWindow); - }; - - const onGoBack = (): void => { - log.debug('onGoBack'); - withFocusedWindow((focusedWindow) => { - focusedWindow.webContents.goBack(); - }); - }; - - const onGoForward = (): void => { - log.debug('onGoForward'); - withFocusedWindow((focusedWindow) => { - focusedWindow.webContents.goForward(); - }); - }; - - const getCurrentUrl = (): void => - withFocusedWindow((focusedWindow) => focusedWindow.webContents.getURL()); - - const gotoUrl = (url: string): void => - withFocusedWindow((focusedWindow) => void focusedWindow.loadURL(url)); - - const onBlockedExternalUrl = (url: string) => { - log.debug('onBlockedExternalUrl', url); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - dialog.showMessageBox(mainWindow, { - message: `Cannot navigate to external URL: ${url}`, - type: 'error', - title: 'Navigation blocked', - }); - }; - - const onWillNavigate = (event: Event, urlToGo: string): void => { - log.debug('onWillNavigate', { event, urlToGo }); - if (!linkIsInternal(options.targetUrl, urlToGo, options.internalUrls)) { - event.preventDefault(); - if (options.blockExternalUrls) { - onBlockedExternalUrl(urlToGo); - } else { - shell.openExternal(urlToGo); // eslint-disable-line @typescript-eslint/no-floating-promises - } - } - }; - - const onWillPreventUnload = (event: Event): void => { - log.debug('onWillPreventUnload', event); - const eventAny = event as any; - if (eventAny.sender === undefined) { - return; - } - const webContents: WebContents = eventAny.sender; - const browserWindow = BrowserWindow.fromWebContents(webContents); - const choice = dialog.showMessageBoxSync(browserWindow, { - type: 'question', - buttons: ['Proceed', 'Stay'], - message: - 'You may have unsaved changes, are you sure you want to proceed?', - title: 'Changes you made may not be saved.', - defaultId: 0, - cancelId: 1, - }); - if (choice === 0) { - event.preventDefault(); - } - }; - - const createNewWindow: (url: string) => BrowserWindow = (url: string) => { - const window = new BrowserWindow(DEFAULT_WINDOW_OPTIONS); - setupWindow(window); - window.loadURL(url); // eslint-disable-line @typescript-eslint/no-floating-promises - return window; - }; - - function setupWindow(window: BrowserWindow): void { - if (options.userAgent) { - window.webContents.userAgent = options.userAgent; - } - - if (options.proxyRules) { - setProxyRules(window, options.proxyRules); - } - - injectCss(window); - sendParamsOnDidFinishLoad(window); - window.webContents.on('new-window', onNewWindow); - window.webContents.on('will-navigate', onWillNavigate); - window.webContents.on('will-prevent-unload', onWillPreventUnload); - } - - const createNewTab = (url: string, foreground: boolean): BrowserWindow => { - log.debug('createNewTab', { url, foreground }); - withFocusedWindow((focusedWindow) => { - const newTab = createNewWindow(url); - focusedWindow.addTabbedWindow(newTab); - if (!foreground) { - focusedWindow.focus(); - } - return newTab; - }); - return undefined; - }; - - const createAboutBlankWindow = (): BrowserWindow => { - const window = createNewWindow('about:blank'); - window.hide(); - window.webContents.once('did-stop-loading', () => { - if (window.webContents.getURL() === 'about:blank') { - window.close(); - } else { - window.show(); - } - }); - return window; - }; - - const onNewWindow = ( - event: Event & { newGuest?: any }, - urlToGo: string, - frameName: string, - disposition: - | 'default' - | 'foreground-tab' - | 'background-tab' - | 'new-window' - | 'save-to-disk' - | 'other', - ): void => { - log.debug('onNewWindow', { event, urlToGo, frameName, disposition }); - const preventDefault = (newGuest: any): void => { - event.preventDefault(); - if (newGuest) { - event.newGuest = newGuest; - } - }; - onNewWindowHelper( - urlToGo, - disposition, - options.targetUrl, - options.internalUrls, - preventDefault, - shell.openExternal.bind(this), - createAboutBlankWindow, - nativeTabsSupported, - createNewTab, - options.blockExternalUrls, - onBlockedExternalUrl, - ); - }; - - const sendParamsOnDidFinishLoad = (window: BrowserWindow): void => { - window.webContents.on('did-finish-load', () => { - log.debug('sendParamsOnDidFinishLoad.window.webContents.did-finish-load'); - // 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 - // eslint-disable-next-line @typescript-eslint/no-floating-promises - window.webContents.setVisualZoomLevelLimits(1, 3); - - window.webContents.send('params', JSON.stringify(options)); - }); - }; - - const menuOptions = { - nativefierVersion: options.nativefierVersion, - appQuit: onAppQuit, - zoomIn: onZoomIn, - zoomOut: onZoomOut, - zoomReset: onZoomReset, - zoomBuildTimeValue: options.zoom, - goBack: onGoBack, - goForward: onGoForward, - getCurrentUrl, - gotoUrl, - clearAppData, - disableDevTools: options.disableDevTools, - }; - - createMenu(menuOptions); - if (!options.disableContextMenu) { - initContextMenu( - createNewWindow, - nativeTabsSupported() ? createNewTab : undefined, - ); - } - - if (options.userAgent) { - mainWindow.webContents.userAgent = options.userAgent; - } - - if (options.proxyRules) { - setProxyRules(mainWindow, options.proxyRules); - } - - injectCss(mainWindow); - sendParamsOnDidFinishLoad(mainWindow); + createMainMenu(options, mainWindow, onAppQuit); + createContextMenu(options, mainWindow); + setupNativefierWindow(options, mainWindow); if (options.counter) { - mainWindow.on('page-title-updated', (event, title) => { - log.debug('mainWindow.page-title-updated', { event, title }); - const counterValue = getCounterValue(title); - if (counterValue) { - setDockBadge(counterValue, options.bounce); - } else { - setDockBadge(''); - } - }); + setupCounter(options, mainWindow, setDockBadge); } else { - ipcMain.on('notification', () => { - log.debug('ipcMain.notification'); - if (!isOSX() || mainWindow.isFocused()) { - return; - } - setDockBadge('•', options.bounce); - }); - mainWindow.on('focus', () => { - log.debug('mainWindow.focus'); - setDockBadge(''); - }); + setupNotificationBadge(options, mainWindow, setDockBadge); } ipcMain.on('notification-click', () => { @@ -468,6 +112,117 @@ export function createMainWindow( mainWindow.show(); }); + setupSessionInteraction(options, mainWindow); + + if (options.clearCache) { + await clearCache(mainWindow); + } + + await mainWindow.loadURL(options.targetUrl); + + setupCloseEvent(options, mainWindow); + + return mainWindow; +} + +function createContextMenu(options, window: BrowserWindow): void { + if (!options.disableContextMenu) { + initContextMenu(options, window); + } +} + +export function saveAppArgs(newAppArgs: any) { + try { + fs.writeFileSync(APP_ARGS_FILE_PATH, JSON.stringify(newAppArgs)); + } catch (err) { + // eslint-disable-next-line no-console + log.warn( + `WARNING: Ignored nativefier.json rewrital (${( + err as Error + ).toString()})`, + ); + } +} + +function setupCloseEvent(options, window: BrowserWindow) { + window.on('close', (event: IpcMainEvent) => { + log.debug('mainWindow.close', event); + if (window.isFullScreen()) { + if (nativeTabsSupported()) { + window.moveTabToNewWindow(); + } + window.setFullScreen(false); + window.once('leave-full-screen', (event: IpcMainEvent) => + hideWindow(window, event, options.fastQuit, options.tray), + ); + } + hideWindow(window, event, options.fastQuit, options.tray); + + if (options.clearCache) { + clearCache(window).catch((err) => log.error('clearCache ERROR', err)); + } + }); +} + +function setupCounter( + options, + window: BrowserWindow, + setDockBadge: (value: number | string, bounce?: boolean) => void, +) { + window.on('page-title-updated', (event, title) => { + log.debug('mainWindow.page-title-updated', { event, title }); + const counterValue = getCounterValue(title); + if (counterValue) { + setDockBadge(counterValue, options.bounce); + } else { + setDockBadge(''); + } + }); +} + +function createMainMenu( + options: any, + window: BrowserWindow, + onAppQuit: () => void, +) { + const menuOptions = { + nativefierVersion: options.nativefierVersion, + appQuit: onAppQuit, + clearAppData: () => clearAppData(window), + disableDevTools: options.disableDevTools, + getCurrentURL, + goBack, + goForward, + goToURL, + openExternal, + zoomBuildTimeValue: options.zoom, + zoomIn, + zoomOut, + zoomReset, + }; + + createMenu(menuOptions); +} + +function setupNotificationBadge( + options, + window: BrowserWindow, + setDockBadge: (value: number | string, bounce?: boolean) => void, +): void { + ipcMain.on('notification', () => { + log.debug('ipcMain.notification'); + if (!isOSX() || window.isFocused()) { + return; + } + setDockBadge('•', options.bounce); + }); + window.on('focus', () => { + log.debug('mainWindow.focus'); + setDockBadge(''); + }); +} + +function setupSessionInteraction(options, window: BrowserWindow): void { // See API.md / "Accessing The Electron Session" ipcMain.on( 'session-interaction', @@ -489,7 +244,7 @@ export function createMainWindow( } // Call func with funcArgs - result.value = mainWindow.webContents.session[request.func]( + result.value = window.webContents.session[request.func]( ...request.funcArgs, ); @@ -498,22 +253,26 @@ export function createMainWindow( typeof result.value['then'] === 'function' ) { // This is a promise. We'll resolve it here otherwise it will blow up trying to serialize it in the reply - result.value.then((trueResultValue) => { - result.value = trueResultValue; - log.debug('ipcMain.session-interaction:result', result); - event.reply('session-interaction-reply', result); - }); + result.value + .then((trueResultValue) => { + result.value = trueResultValue; + log.debug('ipcMain.session-interaction:result', result); + event.reply('session-interaction-reply', result); + }) + .catch((err) => + log.error('session-interaction ERROR', request, err), + ); awaitingPromise = true; } } else if (request.property !== undefined) { if (request.propertyValue !== undefined) { // Set the property - mainWindow.webContents.session[request.property] = + window.webContents.session[request.property] = request.propertyValue; } // Get the property value - result.value = mainWindow.webContents.session[request.property]; + result.value = window.webContents.session[request.property]; } else { // Why even send the event if you're going to do this? You're just wasting time! ;) throw Error( @@ -534,52 +293,4 @@ export function createMainWindow( } }, ); - - mainWindow.webContents.on('new-window', onNewWindow); - mainWindow.webContents.on('will-navigate', onWillNavigate); - mainWindow.webContents.on('will-prevent-unload', onWillPreventUnload); - mainWindow.webContents.on('did-finish-load', () => { - log.debug('mainWindow.webContents.did-finish-load'); - // Restore pinch-to-zoom, disabled by default in recent Electron. - // See https://github.com/nativefier/nativefier/issues/379#issuecomment-598309817 - // and https://github.com/electron/electron/pull/12679 - // eslint-disable-next-line @typescript-eslint/no-floating-promises - mainWindow.webContents.setVisualZoomLevelLimits(1, 3); - - // Remove potential css injection code set in `did-navigate`) (see injectCss code) - mainWindow.webContents.session.webRequest.onHeadersReceived(null); - }); - - if (options.clearCache) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clearCache(mainWindow); - } - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - mainWindow.loadURL(options.targetUrl); - - // @ts-ignore - mainWindow.on('new-tab', () => createNewTab(options.targetUrl, true)); - - mainWindow.on('close', (event) => { - log.debug('mainWindow.close', event); - if (mainWindow.isFullScreen()) { - if (nativeTabsSupported()) { - mainWindow.moveTabToNewWindow(); - } - mainWindow.setFullScreen(false); - mainWindow.once( - 'leave-full-screen', - hideWindow.bind(this, mainWindow, event, options.fastQuit), - ); - } - hideWindow(mainWindow, event, options.fastQuit, options.tray); - - if (options.clearCache) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - clearCache(mainWindow); - } - }); - - return { window: mainWindow, setupWindow }; } diff --git a/app/src/components/mainWindowHelpers.test.ts b/app/src/components/mainWindowHelpers.test.ts deleted file mode 100644 index 667aff6..0000000 --- a/app/src/components/mainWindowHelpers.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { onNewWindowHelper } from './mainWindowHelpers'; - -const originalUrl = 'https://medium.com/'; -const internalUrl = 'https://medium.com/topics/technology'; -const externalUrl = 'https://www.wikipedia.org/wiki/Electron'; -const foregroundDisposition = 'foreground-tab'; -const backgroundDisposition = 'background-tab'; -const blockExternal = false; - -const nativeTabsSupported = () => true; -const nativeTabsNotSupported = () => false; - -test('internal urls should not be handled', () => { - const preventDefault = jest.fn(); - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - - onNewWindowHelper( - internalUrl, - undefined, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsNotSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(0); - expect(createAboutBlankWindow.mock.calls.length).toBe(0); - expect(createNewTab.mock.calls.length).toBe(0); - expect(preventDefault.mock.calls.length).toBe(0); - expect(onBlockedExternalUrl.mock.calls.length).toBe(0); -}); - -test('external urls should be opened externally', () => { - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const preventDefault = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - - onNewWindowHelper( - externalUrl, - undefined, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsNotSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(1); - expect(createAboutBlankWindow.mock.calls.length).toBe(0); - expect(createNewTab.mock.calls.length).toBe(0); - expect(preventDefault.mock.calls.length).toBe(1); - expect(onBlockedExternalUrl.mock.calls.length).toBe(0); -}); - -test('external urls should be ignored if blockExternal is true', () => { - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const preventDefault = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - const blockExternal = true; - - onNewWindowHelper( - externalUrl, - undefined, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsNotSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(0); - expect(createAboutBlankWindow.mock.calls.length).toBe(0); - expect(createNewTab.mock.calls.length).toBe(0); - expect(preventDefault.mock.calls.length).toBe(1); - expect(onBlockedExternalUrl.mock.calls.length).toBe(1); -}); - -test('tab disposition should be ignored if tabs are not enabled', () => { - const preventDefault = jest.fn(); - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - - onNewWindowHelper( - internalUrl, - foregroundDisposition, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsNotSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(0); - expect(createAboutBlankWindow.mock.calls.length).toBe(0); - expect(createNewTab.mock.calls.length).toBe(0); - expect(preventDefault.mock.calls.length).toBe(0); - expect(onBlockedExternalUrl.mock.calls.length).toBe(0); -}); - -test('tab disposition should be ignored if url is external', () => { - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const preventDefault = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - - onNewWindowHelper( - externalUrl, - foregroundDisposition, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(1); - expect(createAboutBlankWindow.mock.calls.length).toBe(0); - expect(createNewTab.mock.calls.length).toBe(0); - expect(preventDefault.mock.calls.length).toBe(1); - expect(onBlockedExternalUrl.mock.calls.length).toBe(0); -}); - -test('foreground tabs with internal urls should be opened in the foreground', () => { - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const preventDefault = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - - onNewWindowHelper( - internalUrl, - foregroundDisposition, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(0); - expect(createAboutBlankWindow.mock.calls.length).toBe(0); - expect(createNewTab.mock.calls.length).toBe(1); - expect(createNewTab.mock.calls[0][1]).toBe(true); - expect(preventDefault.mock.calls.length).toBe(1); - expect(onBlockedExternalUrl.mock.calls.length).toBe(0); -}); - -test('background tabs with internal urls should be opened in background tabs', () => { - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const preventDefault = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - - onNewWindowHelper( - internalUrl, - backgroundDisposition, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(0); - expect(createAboutBlankWindow.mock.calls.length).toBe(0); - expect(createNewTab.mock.calls.length).toBe(1); - expect(createNewTab.mock.calls[0][1]).toBe(false); - expect(preventDefault.mock.calls.length).toBe(1); - expect(onBlockedExternalUrl.mock.calls.length).toBe(0); -}); - -test('about:blank urls should be handled', () => { - const preventDefault = jest.fn(); - const openExternal = jest.fn(); - const createAboutBlankWindow = jest.fn(); - const createNewTab = jest.fn(); - const onBlockedExternalUrl = jest.fn(); - - onNewWindowHelper( - 'about:blank', - undefined, - originalUrl, - undefined, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsNotSupported, - createNewTab, - blockExternal, - onBlockedExternalUrl, - ); - - expect(openExternal.mock.calls.length).toBe(0); - expect(createAboutBlankWindow.mock.calls.length).toBe(1); - expect(createNewTab.mock.calls.length).toBe(0); - expect(preventDefault.mock.calls.length).toBe(1); - expect(onBlockedExternalUrl.mock.calls.length).toBe(0); -}); diff --git a/app/src/components/mainWindowHelpers.ts b/app/src/components/mainWindowHelpers.ts deleted file mode 100644 index 8735bde..0000000 --- a/app/src/components/mainWindowHelpers.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { linkIsInternal } from '../helpers/helpers'; - -export function onNewWindowHelper( - urlToGo: string, - disposition: string, - targetUrl: string, - internalUrls: string | RegExp, - preventDefault, - openExternal, - createAboutBlankWindow, - nativeTabsSupported, - createNewTab, - blockExternal: boolean, - onBlockedExternalUrl: (url: string) => void, -): void { - if (!linkIsInternal(targetUrl, urlToGo, internalUrls)) { - preventDefault(); - if (blockExternal) { - onBlockedExternalUrl(urlToGo); - } else { - openExternal(urlToGo); - } - } else if (urlToGo === 'about:blank') { - const newWindow = createAboutBlankWindow(); - preventDefault(newWindow); - } else if (nativeTabsSupported()) { - if (disposition === 'background-tab') { - const newTab = createNewTab(urlToGo, false); - preventDefault(newTab); - } else if (disposition === 'foreground-tab') { - const newTab = createNewTab(urlToGo, true); - preventDefault(newTab); - } - } -} diff --git a/app/src/components/menu.ts b/app/src/components/menu.ts index d8db8fb..c7d6595 100644 --- a/app/src/components/menu.ts +++ b/app/src/components/menu.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import path from 'path'; -import { Menu, clipboard, shell, MenuItemConstructorOptions } from 'electron'; +import { clipboard, Menu, MenuItemConstructorOptions } from 'electron'; import * as log from 'loglevel'; type BookmarksLink = { @@ -28,10 +28,11 @@ export function createMenu({ zoomBuildTimeValue, goBack, goForward, - getCurrentUrl, - gotoUrl, + getCurrentURL, + goToURL, clearAppData, disableDevTools, + openExternal, }): void { const zoomResetLabel = zoomBuildTimeValue === 1.0 @@ -68,7 +69,7 @@ export function createMenu({ label: 'Copy Current URL', accelerator: 'CmdOrCtrl+L', click: () => { - const currentURL = getCurrentUrl(); + const currentURL = getCurrentURL(); clipboard.writeText(currentURL); }, }, @@ -240,15 +241,13 @@ export function createMenu({ { label: `Built with Nativefier v${nativefierVersion}`, click: () => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - shell.openExternal('https://github.com/nativefier/nativefier'); + openExternal('https://github.com/nativefier/nativefier'); }, }, { label: 'Report an Issue', click: () => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - shell.openExternal('https://github.com/nativefier/nativefier/issues'); + openExternal('https://github.com/nativefier/nativefier/issues'); }, }, ], @@ -333,7 +332,7 @@ export function createMenu({ return { label: bookmark.title, click: () => { - gotoUrl(bookmark.url); + goToURL(bookmark.url); }, accelerator: accelerator, }; diff --git a/app/src/helpers/helpers.ts b/app/src/helpers/helpers.ts index cf9ff01..c9a6233 100644 --- a/app/src/helpers/helpers.ts +++ b/app/src/helpers/helpers.ts @@ -2,11 +2,83 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { BrowserWindow } from 'electron'; +import { BrowserWindow, OpenExternalOptions, shell } from 'electron'; import * as log from 'loglevel'; export const INJECT_DIR = path.join(__dirname, '..', 'inject'); +/** + * Helper to print debug messages from the main process in the browser window + */ +export function debugLog(browserWindow: BrowserWindow, message: string): void { + // Need a delay, as it takes time for the preloaded js to be loaded by the window + setTimeout(() => { + browserWindow.webContents.send('debug', message); + }, 3000); + log.debug(message); +} + +/** + * Helper to determine domain-ish equality for many cases, the trivial ones + * and the trickier ones, e.g. `blog.foo.com` and `shop.foo.com`, + * in a way that is "good enough", and doesn't need a list of SLDs. + * See chat at https://github.com/nativefier/nativefier/pull/1171#pullrequestreview-649132523 + */ +function domainify(url: string): string { + // So here's what we're doing here: + // Get the hostname from the url + const hostname = new URL(url).hostname; + // Drop the first section if the domain + const domain = hostname.split('.').slice(1).join('.'); + // Check the length, if it's too short, the hostname was probably the domain + // Or if the domain doesn't have a . in it we went too far + if (domain.length < 6 || domain.split('.').length === 0) { + return hostname; + } + // This SHOULD be the domain, but nothing is 100% guaranteed + return domain; +} + +export function getAppIcon(): string { + // Prefer ICO under Windows, see + // https://www.electronjs.org/docs/api/browser-window#new-browserwindowoptions + // https://www.electronjs.org/docs/api/native-image#supported-formats + if (isWindows()) { + const ico = path.join(__dirname, '..', 'icon.ico'); + if (fs.existsSync(ico)) { + return ico; + } + } + const png = path.join(__dirname, '..', 'icon.png'); + if (fs.existsSync(png)) { + return png; + } +} + +export function getCounterValue(title: string): string { + const itemCountRegex = /[([{]([\d.,]*)\+?[}\])]/; + const match = itemCountRegex.exec(title); + return match ? match[1] : undefined; +} + +export function getCSSToInject(): string { + let cssToInject = ''; + const cssFiles = fs + .readdirSync(INJECT_DIR, { withFileTypes: true }) + .filter( + (injectFile) => injectFile.isFile() && injectFile.name.endsWith('.css'), + ) + .map((cssFileStat) => + path.resolve(path.join(INJECT_DIR, cssFileStat.name)), + ); + for (const cssFile of cssFiles) { + log.debug('Injecting CSS file', cssFile); + const cssFileData = fs.readFileSync(cssFile); + cssToInject += `/* ${cssFile} */\n\n ${cssFileData}\n\n`; + } + return cssToInject; +} + export function isOSX(): boolean { return os.platform() === 'darwin'; } @@ -44,7 +116,8 @@ export function linkIsInternal( newUrl: string, internalUrlRegex: string | RegExp, ): boolean { - if (newUrl === 'about:blank') { + log.debug('linkIsInternal', { currentUrl, newUrl, internalUrlRegex }); + if (newUrl.split('#')[0] === 'about:blank') { return true; } @@ -80,87 +153,16 @@ export function linkIsInternal( } } -/** - * Helper to determine domain-ish equality for many cases, the trivial ones - * and the trickier ones, e.g. `blog.foo.com` and `shop.foo.com`, - * in a way that is "good enough", and doesn't need a list of SLDs. - * See chat at https://github.com/nativefier/nativefier/pull/1171#pullrequestreview-649132523 - */ -function domainify(url: string): string { - // So here's what we're doing here: - // Get the hostname from the url - const hostname = new URL(url).hostname; - // Drop the first section if the domain - const domain = hostname.split('.').slice(1).join('.'); - // Check the length, if it's too short, the hostname was probably the domain - // Or if the domain doesn't have a . in it we went too far - if (domain.length < 6 || domain.split('.').length === 0) { - return hostname; - } - // This SHOULD be the domain, but nothing is 100% guaranteed - return domain; -} - -export function shouldInjectCss(): boolean { - try { - return fs.existsSync(INJECT_DIR); - } catch (e) { - return false; - } -} - -export function getCssToInject(): string { - let cssToInject = ''; - const cssFiles = fs - .readdirSync(INJECT_DIR, { withFileTypes: true }) - .filter( - (injectFile) => injectFile.isFile() && injectFile.name.endsWith('.css'), - ) - .map((cssFileStat) => - path.resolve(path.join(INJECT_DIR, cssFileStat.name)), - ); - for (const cssFile of cssFiles) { - log.debug('Injecting CSS file', cssFile); - const cssFileData = fs.readFileSync(cssFile); - cssToInject += `/* ${cssFile} */\n\n ${cssFileData}\n\n`; - } - return cssToInject; -} -/** - * Helper to print debug messages from the main process in the browser window - */ -export function debugLog(browserWindow: BrowserWindow, message: string): void { - // Need a delay, as it takes time for the preloaded js to be loaded by the window - setTimeout(() => { - browserWindow.webContents.send('debug', message); - }, 3000); - log.info(message); -} - -export function getAppIcon(): string { - // Prefer ICO under Windows, see - // https://www.electronjs.org/docs/api/browser-window#new-browserwindowoptions - // https://www.electronjs.org/docs/api/native-image#supported-formats - if (isWindows()) { - const ico = path.join(__dirname, '..', 'icon.ico'); - if (fs.existsSync(ico)) { - return ico; - } - } - const png = path.join(__dirname, '..', 'icon.png'); - if (fs.existsSync(png)) { - return png; - } -} - export function nativeTabsSupported(): boolean { return isOSX(); } -export function getCounterValue(title: string): string { - const itemCountRegex = /[([{]([\d.,]*)\+?[}\])]/; - const match = itemCountRegex.exec(title); - return match ? match[1] : undefined; +export function openExternal( + url: string, + options?: OpenExternalOptions, +): Promise { + log.debug('openExternal', { url, options }); + return shell.openExternal(url, options); } export function removeUserAgentSpecifics( @@ -177,3 +179,11 @@ export function removeUserAgentSpecifics( .replace(`Electron/${process.versions.electron} `, '') .replace(`${appName}/${appVersion} `, ' '); } + +export function shouldInjectCSS(): boolean { + try { + return fs.existsSync(INJECT_DIR); + } catch (e) { + return false; + } +} diff --git a/app/src/helpers/windowEvents.test.ts b/app/src/helpers/windowEvents.test.ts new file mode 100644 index 0000000..24f3c2a --- /dev/null +++ b/app/src/helpers/windowEvents.test.ts @@ -0,0 +1,357 @@ +jest.mock('./helpers'); +jest.mock('./windowEvents'); +jest.mock('./windowHelpers'); + +import { dialog, BrowserWindow, WebContents } from 'electron'; +import { linkIsInternal, openExternal, nativeTabsSupported } from './helpers'; +const { onNewWindowHelper, onWillNavigate, onWillPreventUnload } = + jest.requireActual('./windowEvents'); +import { + blockExternalURL, + createAboutBlankWindow, + createNewTab, +} from './windowHelpers'; + +describe('onNewWindowHelper', () => { + const originalURL = 'https://medium.com/'; + const internalURL = 'https://medium.com/topics/technology'; + const externalURL = 'https://www.wikipedia.org/wiki/Electron'; + const foregroundDisposition = 'foreground-tab'; + const backgroundDisposition = 'background-tab'; + + const mockBlockExternalURL: jest.SpyInstance = blockExternalURL as jest.Mock; + const mockCreateAboutBlank: jest.SpyInstance = + createAboutBlankWindow as jest.Mock; + const mockCreateNewTab: jest.SpyInstance = createNewTab as jest.Mock; + const mockLinkIsInternal: jest.SpyInstance = ( + linkIsInternal as jest.Mock + ).mockImplementation(() => true); + const mockNativeTabsSupported: jest.SpyInstance = + nativeTabsSupported as jest.Mock; + const mockOpenExternal: jest.SpyInstance = openExternal as jest.Mock; + const preventDefault = jest.fn(); + const setupWindow = jest.fn(); + + beforeEach(() => { + mockBlockExternalURL + .mockReset() + .mockReturnValue(Promise.resolve(undefined)); + mockCreateAboutBlank.mockReset(); + mockCreateNewTab.mockReset(); + mockLinkIsInternal.mockReset().mockReturnValue(true); + mockNativeTabsSupported.mockReset().mockReturnValue(false); + mockOpenExternal.mockReset(); + preventDefault.mockReset(); + setupWindow.mockReset(); + }); + + afterAll(() => { + mockBlockExternalURL.mockRestore(); + mockCreateAboutBlank.mockRestore(); + mockCreateNewTab.mockRestore(); + mockLinkIsInternal.mockRestore(); + mockNativeTabsSupported.mockRestore(); + mockOpenExternal.mockRestore(); + }); + + test('internal urls should not be handled', () => { + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + + onNewWindowHelper( + options, + setupWindow, + internalURL, + undefined, + preventDefault, + ); + + expect(mockCreateAboutBlank).not.toHaveBeenCalled(); + expect(mockCreateNewTab).not.toHaveBeenCalled(); + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + test('external urls should be opened externally', () => { + mockLinkIsInternal.mockReturnValue(false); + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + onNewWindowHelper( + options, + setupWindow, + externalURL, + undefined, + preventDefault, + ); + + expect(mockCreateAboutBlank).not.toHaveBeenCalled(); + expect(mockCreateNewTab).not.toHaveBeenCalled(); + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + test('external urls should be ignored if blockExternalUrls is true', () => { + mockLinkIsInternal.mockReturnValue(false); + const options = { + blockExternalUrls: true, + targetUrl: originalURL, + }; + onNewWindowHelper( + options, + setupWindow, + externalURL, + undefined, + preventDefault, + ); + + expect(mockCreateAboutBlank).not.toHaveBeenCalled(); + expect(mockCreateNewTab).not.toHaveBeenCalled(); + expect(mockBlockExternalURL).toHaveBeenCalledTimes(1); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + test('tab disposition should be ignored if tabs are not enabled', () => { + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + onNewWindowHelper( + options, + setupWindow, + internalURL, + foregroundDisposition, + preventDefault, + ); + + expect(mockCreateAboutBlank).not.toHaveBeenCalled(); + expect(mockCreateNewTab).not.toHaveBeenCalled(); + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + test('tab disposition should be ignored if url is external', () => { + mockLinkIsInternal.mockReturnValue(false); + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + onNewWindowHelper( + options, + setupWindow, + externalURL, + foregroundDisposition, + preventDefault, + ); + + expect(mockCreateAboutBlank).not.toHaveBeenCalled(); + expect(mockCreateNewTab).not.toHaveBeenCalled(); + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + test('foreground tabs with internal urls should be opened in the foreground', () => { + mockNativeTabsSupported.mockReturnValue(true); + + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + onNewWindowHelper( + options, + setupWindow, + internalURL, + foregroundDisposition, + preventDefault, + ); + + expect(mockCreateAboutBlank).not.toHaveBeenCalled(); + expect(mockCreateNewTab).toHaveBeenCalledTimes(1); + expect(mockCreateNewTab).toHaveBeenCalledWith( + options, + setupWindow, + internalURL, + true, + undefined, + ); + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + test('background tabs with internal urls should be opened in background tabs', () => { + mockNativeTabsSupported.mockReturnValue(true); + + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + onNewWindowHelper( + options, + setupWindow, + internalURL, + backgroundDisposition, + preventDefault, + ); + + expect(mockCreateAboutBlank).not.toHaveBeenCalled(); + expect(mockCreateNewTab).toHaveBeenCalledTimes(1); + expect(mockCreateNewTab).toHaveBeenCalledWith( + options, + setupWindow, + internalURL, + false, + undefined, + ); + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + test('about:blank urls should be handled', () => { + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + onNewWindowHelper( + options, + setupWindow, + 'about:blank', + undefined, + preventDefault, + ); + + expect(mockCreateAboutBlank).toHaveBeenCalledTimes(1); + expect(mockCreateNewTab).not.toHaveBeenCalled(); + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); +}); + +describe('onWillNavigate', () => { + const originalURL = 'https://medium.com/'; + const internalURL = 'https://medium.com/topics/technology'; + const externalURL = 'https://www.wikipedia.org/wiki/Electron'; + + const mockBlockExternalURL: jest.SpyInstance = blockExternalURL as jest.Mock; + const mockLinkIsInternal: jest.SpyInstance = linkIsInternal as jest.Mock; + const mockOpenExternal: jest.SpyInstance = openExternal as jest.Mock; + const preventDefault = jest.fn(); + + beforeEach(() => { + mockBlockExternalURL + .mockReset() + .mockReturnValue(Promise.resolve(undefined)); + mockLinkIsInternal.mockReset().mockReturnValue(false); + mockOpenExternal.mockReset(); + preventDefault.mockReset(); + }); + + afterAll(() => { + mockBlockExternalURL.mockRestore(); + mockLinkIsInternal.mockRestore(); + mockOpenExternal.mockRestore(); + }); + + test('internal urls should not be handled', () => { + mockLinkIsInternal.mockReturnValue(true); + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + const event = { preventDefault }; + onWillNavigate(options, event, internalURL); + + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + test('external urls should be opened externally', () => { + const options = { + blockExternalUrls: false, + targetUrl: originalURL, + }; + const event = { preventDefault }; + onWillNavigate(options, event, externalURL); + + expect(mockBlockExternalURL).not.toHaveBeenCalled(); + expect(mockOpenExternal).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + test('external urls should be ignored if blockExternalUrls is true', () => { + const options = { + blockExternalUrls: true, + targetUrl: originalURL, + }; + const event = { preventDefault }; + onWillNavigate(options, event, externalURL); + + expect(mockBlockExternalURL).toHaveBeenCalledTimes(1); + expect(mockOpenExternal).not.toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); +}); + +describe('onWillPreventUnload', () => { + const mockFromWebContents: jest.SpyInstance = jest + .spyOn(BrowserWindow, 'fromWebContents') + .mockImplementation(() => new BrowserWindow()); + const mockShowDialog: jest.SpyInstance = jest.spyOn( + dialog, + 'showMessageBoxSync', + ); + const preventDefault: jest.SpyInstance = jest.fn(); + + beforeEach(() => { + mockFromWebContents.mockReset(); + mockShowDialog.mockReset().mockReturnValue(undefined); + preventDefault.mockReset(); + }); + + afterAll(() => { + mockFromWebContents.mockRestore(); + mockShowDialog.mockRestore(); + }); + + test('with no sender', () => { + const event = {}; + onWillPreventUnload(event); + + expect(mockFromWebContents).not.toHaveBeenCalled(); + expect(mockShowDialog).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + test('shows dialog and calls preventDefault on ok', () => { + mockShowDialog.mockReturnValue(0); + + const event = { preventDefault, sender: new WebContents() }; + onWillPreventUnload(event); + + expect(mockFromWebContents).toHaveBeenCalledWith(event.sender); + expect(mockShowDialog).toHaveBeenCalled(); + expect(preventDefault).toHaveBeenCalledWith(); + }); + + test('shows dialog and does not call preventDefault on cancel', () => { + mockShowDialog.mockReturnValue(1); + + const event = { preventDefault, sender: new WebContents() }; + onWillPreventUnload(event); + + expect(mockFromWebContents).toHaveBeenCalledWith(event.sender); + expect(mockShowDialog).toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/helpers/windowEvents.ts b/app/src/helpers/windowEvents.ts new file mode 100644 index 0000000..f18d16f --- /dev/null +++ b/app/src/helpers/windowEvents.ts @@ -0,0 +1,194 @@ +import { dialog, BrowserWindow, IpcMainEvent, WebContents } from 'electron'; +import log from 'loglevel'; + +import { linkIsInternal, nativeTabsSupported, openExternal } from './helpers'; +import { + blockExternalURL, + createAboutBlankWindow, + createNewTab, + injectCSS, + sendParamsOnDidFinishLoad, + setProxyRules, +} from './windowHelpers'; + +export function onNewWindow( + options, + setupWindow: (...args) => void, + event: Event & { newGuest?: any }, + urlToGo: string, + frameName: string, + disposition: + | 'default' + | 'foreground-tab' + | 'background-tab' + | 'new-window' + | 'save-to-disk' + | 'other', + parent?: BrowserWindow, +): Promise { + log.debug('onNewWindow', { + event, + urlToGo, + frameName, + disposition, + parent, + }); + const preventDefault = (newGuest: any): void => { + event.preventDefault(); + if (newGuest) { + event.newGuest = newGuest; + } + }; + return onNewWindowHelper( + options, + setupWindow, + urlToGo, + disposition, + preventDefault, + parent, + ); +} + +export function onNewWindowHelper( + options, + setupWindow: (...args) => void, + urlToGo: string, + disposition: string, + preventDefault, + parent?: BrowserWindow, +): Promise { + log.debug('onNewWindowHelper', { + urlToGo, + disposition, + preventDefault, + parent, + }); + try { + if (!linkIsInternal(options.targetUrl, urlToGo, options.internalUrls)) { + preventDefault(); + if (options.blockExternalUrls) { + return blockExternalURL(urlToGo).then(() => null); + } else { + return openExternal(urlToGo); + } + } else if (urlToGo === 'about:blank') { + const newWindow = createAboutBlankWindow(options, setupWindow, parent); + return Promise.resolve(preventDefault(newWindow)); + } else if (nativeTabsSupported()) { + if (disposition === 'background-tab') { + const newTab = createNewTab( + options, + setupWindow, + urlToGo, + false, + parent, + ); + return Promise.resolve(preventDefault(newTab)); + } else if (disposition === 'foreground-tab') { + const newTab = createNewTab( + options, + setupWindow, + urlToGo, + true, + parent, + ); + return Promise.resolve(preventDefault(newTab)); + } + } + return Promise.resolve(undefined); + } catch (err) { + return Promise.reject(err); + } +} + +export function onWillNavigate( + options, + event: IpcMainEvent, + urlToGo: string, +): Promise { + log.debug('onWillNavigate', { options, event, urlToGo }); + if (!linkIsInternal(options.targetUrl, urlToGo, options.internalUrls)) { + event.preventDefault(); + if (options.blockExternalUrls) { + return blockExternalURL(urlToGo).then(() => null); + } else { + return openExternal(urlToGo); + } + } + return Promise.resolve(undefined); +} + +export function onWillPreventUnload(event: IpcMainEvent): void { + log.debug('onWillPreventUnload', event); + + const webContents: WebContents = event.sender; + if (webContents === undefined) { + return; + } + + const browserWindow = BrowserWindow.fromWebContents(webContents); + const choice = dialog.showMessageBoxSync(browserWindow, { + type: 'question', + buttons: ['Proceed', 'Stay'], + message: 'You may have unsaved changes, are you sure you want to proceed?', + title: 'Changes you made may not be saved.', + defaultId: 0, + cancelId: 1, + }); + if (choice === 0) { + event.preventDefault(); + } +} + +export function setupNativefierWindow(options, window: BrowserWindow): void { + if (options.userAgent) { + window.webContents.userAgent = options.userAgent; + } + + if (options.proxyRules) { + setProxyRules(window, options.proxyRules); + } + + injectCSS(window); + + // .on('new-window', ...) is deprected in favor of setWindowOpenHandler(...) + // We can't quite cut over to that yet for a few reasons: + // 1. Our version of Electron does not yet support a parameter to + // setWindowOpenHandler that contains `disposition', which we need. + // See https://github.com/electron/electron/issues/28380 + // 2. setWindowOpenHandler doesn't support newGuest as well + // Though at this point, 'new-window' bugs seem to be coming up and downstream + // users are being pointed to use setWindowOpenHandler. + // E.g., https://github.com/electron/electron/issues/28374 + + window.webContents.on('new-window', (event, url, frameName, disposition) => { + onNewWindow( + options, + setupNativefierWindow, + event, + url, + frameName, + disposition, + ).catch((err) => log.error('onNewWindow ERROR', err)); + }); + window.webContents.on('will-navigate', (event: IpcMainEvent, url: string) => { + onWillNavigate(options, event, url).catch((err) => { + log.error(' window.webContents.on.will-navigate ERROR', err); + event.preventDefault(); + }); + }); + window.webContents.on('will-prevent-unload', onWillPreventUnload); + + sendParamsOnDidFinishLoad(options, window); + + // @ts-ignore new-tab isn't in the type definition, but it does exist + window.on('new-tab', () => { + createNewTab( + options, + setupNativefierWindow, + options.targetUrl, + true, + window, + ).catch((err) => log.error('new-tab ERROR', err)); + }); +} diff --git a/app/src/helpers/windowHelpers.test.ts b/app/src/helpers/windowHelpers.test.ts new file mode 100644 index 0000000..f6f6676 --- /dev/null +++ b/app/src/helpers/windowHelpers.test.ts @@ -0,0 +1,188 @@ +import { + dialog, + BrowserWindow, + HeadersReceivedResponse, + WebContents, +} from 'electron'; +jest.mock('loglevel'); +import { error } from 'loglevel'; + +jest.mock('./helpers'); +import { getCSSToInject, shouldInjectCSS } from './helpers'; +jest.mock('./windowEvents'); +import { clearAppData, createNewTab, injectCSS } from './windowHelpers'; + +describe('clearAppData', () => { + let window: BrowserWindow; + let mockClearCache: jest.SpyInstance; + let mockClearStorageData: jest.SpyInstance; + const mockShowDialog: jest.SpyInstance = jest.spyOn(dialog, 'showMessageBox'); + + beforeEach(() => { + window = new BrowserWindow(); + mockClearCache = jest.spyOn(window.webContents.session, 'clearCache'); + mockClearStorageData = jest.spyOn( + window.webContents.session, + 'clearStorageData', + ); + mockShowDialog.mockReset().mockResolvedValue(undefined); + }); + + afterAll(() => { + mockClearCache.mockRestore(); + mockClearStorageData.mockRestore(); + mockShowDialog.mockRestore(); + }); + + test('will not clear app data if dialog canceled', async () => { + mockShowDialog.mockResolvedValue(1); + + await clearAppData(window); + + expect(mockShowDialog).toHaveBeenCalledTimes(1); + expect(mockClearCache).not.toHaveBeenCalled(); + expect(mockClearStorageData).not.toHaveBeenCalled(); + }); + + test('will clear app data if ok is clicked', async () => { + mockShowDialog.mockResolvedValue(0); + + await clearAppData(window); + + expect(mockShowDialog).toHaveBeenCalledTimes(1); + expect(mockClearCache).not.toHaveBeenCalledTimes(1); + expect(mockClearStorageData).not.toHaveBeenCalledTimes(1); + }); +}); + +describe('createNewTab', () => { + const window = new BrowserWindow(); + const options = {}; + const setupWindow = jest.fn(); + const url = 'https://github.com/nativefier/nativefier'; + const mockAddTabbedWindow: jest.SpyInstance = jest.spyOn( + BrowserWindow.prototype, + 'addTabbedWindow', + ); + const mockFocus: jest.SpyInstance = jest.spyOn( + BrowserWindow.prototype, + 'focus', + ); + const mockLoadURL: jest.SpyInstance = jest.spyOn( + BrowserWindow.prototype, + 'loadURL', + ); + + test('creates new foreground tab', () => { + const foreground = true; + + const tab = createNewTab(options, setupWindow, url, foreground, window); + + expect(mockAddTabbedWindow).toHaveBeenCalledWith(tab); + expect(setupWindow).toHaveBeenCalledWith(options, tab); + expect(mockLoadURL).toHaveBeenCalledWith(url); + expect(mockFocus).not.toHaveBeenCalled(); + }); + + test('creates new background tab', () => { + const foreground = false; + + const tab = createNewTab(options, setupWindow, url, foreground, window); + + expect(mockAddTabbedWindow).toHaveBeenCalledWith(tab); + expect(setupWindow).toHaveBeenCalledWith(options, tab); + expect(mockLoadURL).toHaveBeenCalledWith(url); + expect(mockFocus).toHaveBeenCalledTimes(1); + }); +}); + +describe('injectCSS', () => { + const mockGetCSSToInject: jest.SpyInstance = getCSSToInject as jest.Mock; + const mockLogError: jest.SpyInstance = error as jest.Mock; + const mockShouldInjectCSS: jest.SpyInstance = shouldInjectCSS as jest.Mock; + const mockWebContentsInsertCSS: jest.SpyInstance = jest.spyOn( + WebContents.prototype, + 'insertCSS', + ); + + const css = 'body { color: white; }'; + const responseHeaders = { 'x-header': 'value' }; + + beforeEach(() => { + mockGetCSSToInject.mockReset().mockReturnValue(''); + mockLogError.mockReset(); + mockShouldInjectCSS.mockReset().mockReturnValue(true); + mockWebContentsInsertCSS.mockReset().mockResolvedValue(undefined); + }); + + afterAll(() => { + mockGetCSSToInject.mockRestore(); + mockLogError.mockRestore(); + mockShouldInjectCSS.mockRestore(); + mockWebContentsInsertCSS.mockRestore(); + }); + + test('will not inject if shouldInjectCSS is false', () => { + mockShouldInjectCSS.mockReturnValue(false); + + const window = new BrowserWindow(); + + injectCSS(window); + + expect(mockGetCSSToInject).not.toHaveBeenCalled(); + expect(mockWebContentsInsertCSS).not.toHaveBeenCalled(); + }); + + test('will inject on did-navigate + onHeadersReceived', (done) => { + mockGetCSSToInject.mockReturnValue(css); + const window = new BrowserWindow(); + + injectCSS(window); + + expect(mockGetCSSToInject).toHaveBeenCalled(); + + window.webContents.emit('did-navigate'); + // @ts-ignore this function doesn't exist in the actual electron version, but will in our mock + window.webContents.session.webRequest.send( + 'onHeadersReceived', + { responseHeaders, webContents: window.webContents }, + (result: HeadersReceivedResponse) => { + expect(mockWebContentsInsertCSS).toHaveBeenCalledWith(css); + expect(result.cancel).toBe(false); + expect(result.responseHeaders).toBe(responseHeaders); + done(); + }, + ); + }); + + test('will catch errors inserting CSS', (done) => { + mockGetCSSToInject.mockReturnValue(css); + + mockWebContentsInsertCSS.mockReturnValue( + Promise.reject('css insertion error'), + ); + + const window = new BrowserWindow(); + + injectCSS(window); + + expect(mockGetCSSToInject).toHaveBeenCalled(); + + window.webContents.emit('did-navigate'); + // @ts-ignore this function doesn't exist in the actual electron version, but will in our mock + window.webContents.session.webRequest.send( + 'onHeadersReceived', + { responseHeaders, webContents: window.webContents }, + (result: HeadersReceivedResponse) => { + expect(mockWebContentsInsertCSS).toHaveBeenCalledWith(css); + expect(mockLogError).toHaveBeenCalledWith( + 'webContents.insertCSS ERROR', + 'css insertion error', + ); + expect(result.cancel).toBe(false); + expect(result.responseHeaders).toBe(responseHeaders); + done(); + }, + ); + }); +}); diff --git a/app/src/helpers/windowHelpers.ts b/app/src/helpers/windowHelpers.ts new file mode 100644 index 0000000..13e5615 --- /dev/null +++ b/app/src/helpers/windowHelpers.ts @@ -0,0 +1,302 @@ +import { + BrowserWindow, + BrowserWindowConstructorOptions, + dialog, + HeadersReceivedResponse, + IpcMainEvent, + MessageBoxReturnValue, + OnHeadersReceivedListenerDetails, +} from 'electron'; + +import log from 'loglevel'; +import path from 'path'; +import { + getCSSToInject, + isOSX, + nativeTabsSupported, + shouldInjectCSS, +} 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, + setupWindow: (...args) => 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, + setupWindow, + url: string, + foreground: boolean, + parent?: BrowserWindow, +): Promise { + 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, + setupWindow: (...args) => 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, +): 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 = { + ...(options.browserwindowOptions?.webPreferences ?? {}), + }; + + const defaultOptions = { + // Convert dashes to spaces because on linux the app name is joined with dashes + title: options.name, + tabbingIdentifier: nativeTabsSupported() ? options.name : undefined, + webPreferences: { + javascript: true, + plugins: true, + nodeIntegration: false, // `true` is *insecure*, and cause trouble with messenger.com + webSecurity: !options.insecure, + preload: path.join(__dirname, 'preload.js'), + 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 { + return withFocusedWindow((focusedWindow) => focusedWindow.loadURL(url)); +} + +export function hideWindow( + window: BrowserWindow, + event: IpcMainEvent, + fastQuit: boolean, + tray, +): 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) { + event.preventDefault(); + window.hide(); + } + // will close the window on other platforms +} + +export function injectCSS(browserWindow: BrowserWindow): void { + if (!shouldInjectCSS()) { + return; + } + + const cssToInject = getCSSToInject(); + + browserWindow.webContents.on('did-navigate', () => { + log.debug( + 'browserWindow.webContents.did-navigate', + browserWindow.webContents.getURL(), + ); + // 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, + ) => { + log.debug( + 'browserWindow.webContents.session.webRequest.onHeadersReceived', + { details, callback }, + ); + if (details.webContents) { + details.webContents + .insertCSS(cssToInject) + .catch((err) => { + log.error('webContents.insertCSS ERROR', err); + }) + .finally(() => + callback({ + cancel: false, + responseHeaders: details.responseHeaders, + }), + ); + } else { + callback({ + cancel: false, + responseHeaders: details.responseHeaders, + }); + } + }, + ); + }); +} + +export function sendParamsOnDidFinishLoad( + options, + 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): void { + window.webContents.session + .setProxy({ + proxyRules, + pacScript: '', + proxyBypassRules: '', + }) + .catch((err) => log.error('session.setProxy ERROR', err)); +} + +export function withFocusedWindow(block: (window: BrowserWindow) => any): any { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) { + return block(focusedWindow); + } + + return null; +} + +export function zoomOut(): void { + log.debug('zoomOut'); + adjustWindowZoom(-ZOOM_INTERVAL); +} + +export function zoomReset(options): void { + log.debug('zoomReset'); + withFocusedWindow((focusedWindow) => { + focusedWindow.webContents.zoomFactor = options.zoom; + }); +} + +export function zoomIn(): void { + log.debug('zoomIn'); + adjustWindowZoom(ZOOM_INTERVAL); +} diff --git a/app/src/main.ts b/app/src/main.ts index f55c2b1..43778d8 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -10,19 +10,21 @@ import { globalShortcut, systemPreferences, BrowserWindow, + IpcMainEvent, } from 'electron'; import electronDownload from 'electron-dl'; import * as log from 'loglevel'; import { createLoginWindow } from './components/loginWindow'; import { - createMainWindow, saveAppArgs, APP_ARGS_FILE_PATH, + createMainWindow, } from './components/mainWindow'; import { createTrayIcon } from './components/trayIcon'; import { isOSX, removeUserAgentSpecifics } from './helpers/helpers'; import { inferFlashPath } from './helpers/inferFlash'; +import { setupNativefierWindow } from './helpers/windowEvents'; // Entrypoint for Squirrel, a windows update framework. See https://github.com/nativefier/nativefier/pull/744 if (require('electron-squirrel-startup')) { @@ -31,6 +33,8 @@ if (require('electron-squirrel-startup')) { if (process.argv.indexOf('--verbose') > -1) { log.setLevel('DEBUG'); + process.traceDeprecation = true; + process.traceProcessWarnings = true; } const appArgs = JSON.parse(fs.readFileSync(APP_ARGS_FILE_PATH, 'utf8')); @@ -95,7 +99,6 @@ if (appArgs.processEnvs) { } let mainWindow: BrowserWindow; -let setupWindow: (BrowserWindow) => void; if (typeof appArgs.flashPluginDir === 'string') { app.commandLine.appendSwitch('ppapi-flash-path', appArgs.flashPluginDir); @@ -233,7 +236,7 @@ if (shouldQuit) { // @ts-ignore This event only appears on the widevine version of electron, which we'd see at runtime app.on('widevine-ready', (version: string, lastVersion: string) => { log.debug('app.widevine-ready', { version, lastVersion }); - onReady(); + onReady().catch((err) => log.error('onReady ERROR', err)); }); app.on( @@ -254,20 +257,18 @@ if (shouldQuit) { } else { app.on('ready', () => { log.debug('ready'); - onReady(); + onReady().catch((err) => log.error('onReady ERROR', err)); }); } } -function onReady(): void { - const createWindowResult = createMainWindow( +async function onReady(): Promise { + const mainWindow = await createMainWindow( appArgs, app.quit.bind(this), setDockBadge, ); - log.debug('onReady', createWindowResult); - mainWindow = createWindowResult.window; - setupWindow = createWindowResult.setupWindow; + createTrayIcon(appArgs, mainWindow); // Register global shortcuts @@ -333,17 +334,20 @@ function onReady(): void { const oldBuildWarningText = appArgs.oldBuildWarningText || 'This app was built a long time ago. Nativefier uses the Chrome browser (through Electron), and it is insecure to keep using an old version of it. Please upgrade Nativefier and rebuild this app.'; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - dialog.showMessageBox(null, { - type: 'warning', - message: 'Old build detected', - detail: oldBuildWarningText, - }); + dialog + .showMessageBox(null, { + type: 'warning', + message: 'Old build detected', + detail: oldBuildWarningText, + }) + .catch((err) => log.error('dialog.showMessageBox ERROR', err)); } } app.on('new-window-for-tab', () => { log.debug('app.new-window-for-tab'); - mainWindow.emit('new-tab'); + if (mainWindow) { + mainWindow.emit('new-tab'); + } }); app.on('login', (event, webContents, request, authInfo, callback) => { @@ -357,13 +361,15 @@ app.on('login', (event, webContents, request, authInfo, callback) => { ) { callback(appArgs.basicAuthUsername, appArgs.basicAuthPassword); } else { - createLoginWindow(callback); + createLoginWindow(callback, mainWindow).catch((err) => + log.error('createLoginWindow ERROR', err), + ); } }); app.on( 'accessibility-support-changed', - (event: Event, accessibilitySupportEnabled: boolean) => { + (event: IpcMainEvent, accessibilitySupportEnabled: boolean) => { log.debug('app.accessibility-support-changed', { event, accessibilitySupportEnabled, @@ -373,22 +379,23 @@ app.on( app.on( 'activity-was-continued', - (event: Event, type: string, userInfo: any) => { + (event: IpcMainEvent, type: string, userInfo: any) => { log.debug('app.activity-was-continued', { event, type, userInfo }); }, ); -app.on('browser-window-blur', (event: Event, window: BrowserWindow) => { +app.on('browser-window-blur', (event: IpcMainEvent, window: BrowserWindow) => { log.debug('app.browser-window-blur', { event, window }); }); -app.on('browser-window-created', (event: Event, window: BrowserWindow) => { - log.debug('app.browser-window-created', { event, window }); - if (setupWindow !== undefined) { - setupWindow(window); - } -}); +app.on( + 'browser-window-created', + (event: IpcMainEvent, window: BrowserWindow) => { + log.debug('app.browser-window-created', { event, window }); + setupNativefierWindow(appArgs, window); + }, +); -app.on('browser-window-focus', (event: Event, window: BrowserWindow) => { +app.on('browser-window-focus', (event: IpcMainEvent, window: BrowserWindow) => { log.debug('app.browser-window-focus', { event, window }); }); diff --git a/app/src/mocks/electron.ts b/app/src/mocks/electron.ts new file mode 100644 index 0000000..ac15294 --- /dev/null +++ b/app/src/mocks/electron.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { EventEmitter } from 'events'; + +/* + These mocks are PURPOSEFULLY minimal. A few reasons as to why: + 1. I'm l̶a̶z̶y̶ a busy person :) + 2. The less we have in here, the less we'll need to fix if an electron API changes + 3. Only mocking what we need as we need it helps reveal areas under test where electron + is being accessed in previously unaccounted for ways + 4. These mocks will get fleshed out as more unit tests are added, so if you need + something here as you are adding unit tests, then feel free to add exactly what you + need (and no more than that please). + + As well, please resist the urge to turn this into a reimplimentation of electron. + When adding functions/classes, keep your implementation to only the minimal amount of code + it takes for TypeScript to recognize what you are doing. For anything more complex (including + implementation code and return values) please do that within your tests via jest with + mockImplementation or mockReturnValue. +*/ + +class MockBrowserWindow extends EventEmitter { + webContents: MockWebContents; + + constructor(options?: any) { + super(options); + this.webContents = new MockWebContents(); + } + + addTabbedWindow(tab: MockBrowserWindow) { + return; + } + + focus(): void { + return; + } + + static fromWebContents(webContents: MockWebContents): MockBrowserWindow { + return new MockBrowserWindow(); + } + + static getFocusedWindow(window: MockBrowserWindow): MockBrowserWindow { + return window ?? new MockBrowserWindow(); + } + + loadURL(url: string, options?: any): Promise { + return Promise.resolve(undefined); + } +} + +class MockDialog { + static showMessageBox( + browserWindow: MockBrowserWindow, + options: any, + ): Promise { + return Promise.resolve(undefined); + } + + static showMessageBoxSync( + browserWindow: MockBrowserWindow, + options: any, + ): number { + return undefined; + } +} + +class MockSession extends EventEmitter { + webRequest: MockWebRequest; + + constructor() { + super(); + this.webRequest = new MockWebRequest(); + } + + clearCache(): Promise { + return Promise.resolve(); + } + + clearStorageData(): Promise { + return Promise.resolve(); + } +} + +class MockWebContents extends EventEmitter { + session: MockSession; + + constructor() { + super(); + this.session = new MockSession(); + } + + getURL(): string { + return undefined; + } + + insertCSS(css: string, options?: any): Promise { + return Promise.resolve(undefined); + } +} + +class MockWebRequest { + emitter: InternalEmitter; + + constructor() { + this.emitter = new InternalEmitter(); + } + + onHeadersReceived( + filter: any, + listener: + | (( + details: any, + callback: (headersReceivedResponse: any) => void, + ) => void) + | null, + ): void { + this.emitter.addListener( + 'onHeadersReceived', + (details: any, callback: (headersReceivedResponse: any) => void) => + listener(details, callback), + ); + } + + send(event: string, ...args: any[]): void { + this.emitter.emit(event, ...args); + } +} + +class InternalEmitter extends EventEmitter {} + +export { + MockDialog as dialog, + MockBrowserWindow as BrowserWindow, + MockSession as Session, + MockWebContents as WebContents, + MockWebRequest as WebRequest, +}; diff --git a/package.json b/package.json index ead63db..2dfe161 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,9 @@ }, "jest": { "collectCoverage": true, + "moduleNameMapper": { + "^electron$": "/app/dist/mocks/electron.js" + }, "setupFiles": [ "./lib/jestSetupFiles" ], diff --git a/src/helpers/helpers.ts b/src/helpers/helpers.ts index 213981c..ae02556 100644 --- a/src/helpers/helpers.ts +++ b/src/helpers/helpers.ts @@ -66,7 +66,7 @@ export async function copyFileOrDir( }); } -export async function downloadFile(fileUrl: string): Promise { +export function downloadFile(fileUrl: string): Promise { log.debug(`Downloading ${fileUrl}`); return axios .get(fileUrl, {