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
This commit is contained in:
Adam Weeden 2021-06-02 15:18:32 -04:00 committed by GitHub
parent ec12702359
commit 72de7b3fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1510 additions and 845 deletions

View File

@ -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,
),
});
}
}

View File

@ -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<BrowserWindow> {
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]);

View File

@ -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<void> {
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<BrowserWindow> {
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<void> => {
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 };
}

View File

@ -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);
});

View File

@ -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);
}
}
}

View File

@ -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,
};

View File

@ -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<void> {
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;
}
}

View File

@ -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();
});
});

View File

@ -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<void> {
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<void> {
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<void> {
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));
});
}

View File

@ -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();
},
);
});
});

View File

@ -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<MessageBoxReturnValue> {
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<void> {
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<void> {
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<BrowserWindow> {
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<void> {
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);
}

View File

@ -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<void> {
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 });
});

136
app/src/mocks/electron.ts Normal file
View File

@ -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<void> {
return Promise.resolve(undefined);
}
}
class MockDialog {
static showMessageBox(
browserWindow: MockBrowserWindow,
options: any,
): Promise<number> {
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<void> {
return Promise.resolve();
}
clearStorageData(): Promise<void> {
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<string> {
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,
};

View File

@ -90,6 +90,9 @@
},
"jest": {
"collectCoverage": true,
"moduleNameMapper": {
"^electron$": "<rootDir>/app/dist/mocks/electron.js"
},
"setupFiles": [
"./lib/jestSetupFiles"
],

View File

@ -66,7 +66,7 @@ export async function copyFileOrDir(
});
}
export async function downloadFile(fileUrl: string): Promise<DownloadResult> {
export function downloadFile(fileUrl: string): Promise<DownloadResult> {
log.debug(`Downloading ${fileUrl}`);
return axios
.get(fileUrl, {