Typescript - Add support for child window

This commit is contained in:
Kiran Niranjan 2018-12-26 23:47:54 +05:30
parent 81f0a1460e
commit cbb920ef1d
10 changed files with 292 additions and 101 deletions

View File

@ -1,4 +1,4 @@
import { app, Menu, session, shell } from 'electron';
import { app, dialog, Menu, session, shell } from 'electron';
import { isMac, isWindowsOS } from '../common/env';
import { i18n, LocaleType } from '../common/i18n';
@ -305,9 +305,25 @@ export class AppMenu {
click: () => exportCrashDumps(),
label: isMac ? i18n.t('Show crash dump in Finder')() : i18n.t('Show crash dump in Explorer')(),
}, {
click: () => windowHandler.createMoreInfoWindow(),
label: i18n.t('More Information')(),
} ],
label: i18n.t('Toggle Developer Tools')(),
accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click(_item, focusedWindow) {
const devToolsEnabled = config.getGlobalConfigFields([ 'devToolsEnabled' ]);
if (focusedWindow && devToolsEnabled) {
focusedWindow.webContents.toggleDevTools();
} else {
dialog.showMessageBox(focusedWindow, {
type: 'warning',
buttons: [ 'Ok' ],
title: i18n.t('Dev Tools disabled')(),
message: i18n.t('Dev Tools has been disabled! Please contact your system administrator to enable it!')(),
});
}
},
},{
click: () => windowHandler.createMoreInfoWindow(),
label: i18n.t('More Information')(),
}],
} ],
};
}

View File

@ -0,0 +1,147 @@
import { BrowserWindow, WebContents } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { parse as parseQuerystring } from 'querystring';
import { format, parse, Url } from 'url';
import { isWindowsOS } from '../common/env';
import { getGuid } from '../common/utils';
import { enterFullScreen, leaveFullScreen, throttledWindowChanges } from './window-actions';
import { ICustomBrowserWindow, windowHandler } from './window-handler';
import { getBounds, preventWindowNavigation } from './window-utils';
const DEFAULT_POP_OUT_WIDTH = 300;
const DEFAULT_POP_OUT_HEIGHT = 600;
const MIN_WIDTH = 300;
const MIN_HEIGHT = 300;
/**
* Verifies if the url is valid and
* forcefully appends https if not present
*
* @param configURL {string}
*/
const getParsedUrl = (configURL: string): Url => {
const parsedUrl = parse(configURL);
if (!parsedUrl.protocol || parsedUrl.protocol !== 'https') {
parsedUrl.protocol = 'https:';
parsedUrl.slashes = true;
}
return parse(format(parsedUrl));
};
export const handleChildWindow = (webContents: WebContents): void => {
const childWindow = (event, newWinUrl, frameName, disposition, newWinOptions): void => {
const mainWindow = windowHandler.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!windowHandler.url) return;
if (!newWinOptions.webPreferences) {
newWinOptions.webPreferences = {};
}
Object.assign(newWinOptions.webPreferences, webContents);
const newWinParsedUrl = getParsedUrl(newWinUrl);
const mainWinParsedUrl = getParsedUrl(windowHandler.url);
const newWinHost = newWinParsedUrl && newWinParsedUrl.host;
const mainWinHost = mainWinParsedUrl && mainWinParsedUrl.host;
const emptyUrlString = 'about:blank';
const dispositionWhitelist = ['new-window', 'foreground-tab'];
// only allow window.open to succeed is if coming from same hsot,
// otherwise open in default browser.
if ((newWinHost === mainWinHost || newWinUrl === emptyUrlString) && dispositionWhitelist.includes(disposition)) {
const newWinKey = getGuid();
if (!frameName) {
// abort - no frame name provided.
return;
}
const width = newWinOptions.width || DEFAULT_POP_OUT_WIDTH;
const height = newWinOptions.height || DEFAULT_POP_OUT_HEIGHT;
// try getting x and y position from query parameters
const query = newWinParsedUrl && parseQuerystring(newWinParsedUrl.query as string);
if (query && query.x && query.y) {
const newX = Number.parseInt(query.x as string, 10);
const newY = Number.parseInt(query.y as string, 10);
// only accept if both are successfully parsed.
if (Number.isInteger(newX) && Number.isInteger(newY)) {
const newWinRect = { x: newX, y: newY, width, height };
const { x, y } = getBounds(newWinRect, DEFAULT_POP_OUT_WIDTH, DEFAULT_POP_OUT_HEIGHT);
newWinOptions.x = x;
newWinOptions.y = y;
} else {
newWinOptions.x = 0;
newWinOptions.y = 0;
}
} else {
// create new window at slight offset from main window.
const { x, y } = mainWindow.getBounds();
newWinOptions.x = x + 50;
newWinOptions.y = y + 50;
}
newWinOptions.width = Math.max(width, DEFAULT_POP_OUT_WIDTH);
newWinOptions.height = Math.max(height, DEFAULT_POP_OUT_HEIGHT);
newWinOptions.minWidth = MIN_WIDTH;
newWinOptions.minHeight = MIN_HEIGHT;
newWinOptions.alwaysOnTop = mainWindow.isAlwaysOnTop();
newWinOptions.frame = true;
newWinOptions.winKey = newWinKey;
const childWebContents = newWinOptions.webContents;
// Event needed to hide native menu bar
childWebContents.once('did-start-loading', () => {
const browserWin = BrowserWindow.fromWebContents(childWebContents);
if (isWindowsOS && browserWin && !browserWin.isDestroyed()) {
browserWin.setMenuBarVisibility(false);
}
});
childWebContents.once('did-finish-load', () => {
const browserWin = BrowserWindow.fromWebContents(childWebContents) as ICustomBrowserWindow;
if (!browserWin) return;
windowHandler.addWindow(newWinKey, browserWin);
browserWin.webContents.send('page-load', { isWindowsOS });
browserWin.webContents.insertCSS(
fs.readFileSync(path.join(__dirname, '..', '/renderer/styles/snack-bar.css'), 'utf8').toString(),
);
browserWin.winName = frameName;
browserWin.setAlwaysOnTop(mainWindow.isAlwaysOnTop());
// prevents window from navigating
preventWindowNavigation(browserWin, true);
// Monitor window for events
const eventNames = [ 'move', 'resize', 'maximize', 'unmaximize' ];
eventNames.forEach((e: string) => {
// @ts-ignore
if (this.mainWindow) this.mainWindow.on(e, throttledWindowChanges);
});
browserWin.on('enter-full-screen', enterFullScreen);
browserWin.on('leave-full-screen', leaveFullScreen);
// Remove the attached event listeners when the window is about to close
browserWin.once('close', () => {
browserWin.removeListener('close', throttledWindowChanges);
browserWin.removeListener('resize', throttledWindowChanges);
browserWin.removeListener('maximize', throttledWindowChanges);
browserWin.removeListener('unmaximize', throttledWindowChanges);
browserWin.removeListener('enter-full-screen', leaveFullScreen);
browserWin.removeListener('leave-full-screen', leaveFullScreen);
});
// TODO: handle Permission Requests & setCertificateVerifyProc
});
} else {
event.preventDefault();
windowHandler.openUrlInDefaultBrowser(newWinUrl);
}
};
webContents.on('new-window', childWindow);
};

View File

@ -2,7 +2,7 @@ import { BrowserWindow } from 'electron';
import { IBoundsChange, KeyCodes } from '../common/api-interface';
import { isWindowsOS } from '../common/env';
import { throttle } from '../common/utils';
import { throttle } from '../common/throttle';
import { config } from './config-handler';
import { ICustomBrowserWindow, windowHandler } from './window-handler';
import { showPopupMenu } from './window-utils';

View File

@ -2,7 +2,7 @@ import * as electron from 'electron';
import { BrowserWindow, crashReporter, ipcMain, webContents } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';
import { format, parse } from 'url';
import { buildNumber, clientVersion, version } from '../../package.json';
import DesktopCapturerSource = Electron.DesktopCapturerSource;
@ -11,8 +11,9 @@ import { isMac, isWindowsOS } from '../common/env';
import { getCommandLineArgs, getGuid } from '../common/utils';
import { AppMenu } from './app-menu';
import { config, IConfig } from './config-handler';
import { handleChildWindow } from './pop-out-window-handler';
import { enterFullScreen, leaveFullScreen, throttledWindowChanges } from './window-actions';
import { createComponentWindow } from './window-utils';
import { createComponentWindow, getBounds } from './window-utils';
interface ICustomBrowserWindowConstructorOpts extends Electron.BrowserWindowConstructorOptions {
winKey: string;
@ -74,19 +75,20 @@ export class WindowHandler {
*
* @param configURL {string}
*/
private static validateURL(configURL: string): string {
const parsedUrl = url.parse(configURL);
private static getValidUrl(configURL: string): string {
const parsedUrl = parse(configURL);
if (!parsedUrl.protocol || parsedUrl.protocol !== 'https') {
parsedUrl.protocol = 'https:';
parsedUrl.slashes = true;
}
return url.format(parsedUrl);
return format(parsedUrl);
}
public appMenu: AppMenu | null;
public isAutoReload: boolean;
public isOnline: boolean;
public url: string | undefined;
private readonly windowOpts: ICustomBrowserWindowConstructorOpts;
private readonly globalConfig: IConfig;
@ -134,7 +136,7 @@ export class WindowHandler {
public createApplication() {
// set window opts with additional config
this.mainWindow = new BrowserWindow({
...this.windowOpts, ...this.getBounds(this.config.mainWinPos),
...this.windowOpts, ...getBounds(this.config.mainWinPos, DEFAULT_WIDTH, DEFAULT_HEIGHT),
}) as ICustomBrowserWindow;
this.mainWindow.winName = 'main';
@ -146,7 +148,8 @@ export class WindowHandler {
});
const urlFromCmd = getCommandLineArgs(process.argv, '--url=', false);
this.mainWindow.loadURL(urlFromCmd && urlFromCmd.substr(6) || WindowHandler.validateURL(this.globalConfig.url));
this.url = urlFromCmd && urlFromCmd.substr(6) || WindowHandler.getValidUrl(this.globalConfig.url);
this.mainWindow.loadURL(this.url);
this.mainWindow.webContents.on('did-finish-load', () => {
// close the loading window when
// the main windows finished loading
@ -175,6 +178,9 @@ export class WindowHandler {
});
this.mainWindow.webContents.toggleDevTools();
this.addWindow(this.windowOpts.winKey, this.mainWindow);
// Handle pop-outs window
handleChildWindow(this.mainWindow.webContents);
return this.mainWindow;
}
@ -289,13 +295,24 @@ export class WindowHandler {
});
}
/**
* Opens an external url in the system's default browser
*
* @param urlToOpen
*/
public openUrlInDefaultBrowser(urlToOpen) {
if (urlToOpen) {
electron.shell.openExternal(urlToOpen);
}
}
/**
* Stores information of all the window we have created
*
* @param key {string}
* @param browserWindow {Electron.BrowserWindow}
*/
private addWindow(key: string, browserWindow: Electron.BrowserWindow): void {
public addWindow(key: string, browserWindow: Electron.BrowserWindow): void {
this.windows[ key ] = browserWindow;
}
@ -304,7 +321,7 @@ export class WindowHandler {
*
* @param key {string}
*/
private removeWindow(key): void {
public removeWindow(key): void {
delete this.windows[ key ];
}
@ -323,29 +340,6 @@ export class WindowHandler {
}
}
/**
* Returns the config stored rectangle if it is contained within the workArea of at
* least one of the screens else returns the default rectangle value with out x, y
* as the default is to center the window
*
* @param mainWinPos {Electron.Rectangle}
* @return {x?: Number, y?: Number, width: Number, height: Number}
*/
private getBounds(mainWinPos): Partial<Electron.Rectangle> {
if (!mainWinPos) return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
const displays = electron.screen.getAllDisplays();
for (let i = 0, len = displays.length; i < len; i++) {
const workArea = displays[ i ].workArea;
if (mainWinPos.x >= workArea.x && mainWinPos.y >= workArea.y &&
((mainWinPos.x + mainWinPos.width) <= (workArea.x + workArea.width)) &&
((mainWinPos.y + mainWinPos.height) <= (workArea.y + workArea.height))) {
return mainWinPos;
}
}
return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
}
/**
* Main window opts
*/
@ -358,10 +352,9 @@ export class WindowHandler {
show: false,
title: 'Symphony',
webPreferences: {
nativeWindowOpen: true,
nodeIntegration: false,
preload: path.join(__dirname, '../renderer/preload-main'),
sandbox: false,
preload: path.join(__dirname, '../renderer/_preload-main.js'),
sandbox: true,
},
winKey: getGuid(),
};

View File

@ -1,3 +1,4 @@
import * as electron from 'electron';
import { app, BrowserWindow, nativeImage } from 'electron';
import * as path from 'path';
import * as url from 'url';
@ -14,16 +15,27 @@ const checkValidWindow = true;
* Prevents window from navigating
*
* @param browserWindow
* @param isPopOutWindow
*/
export const preventWindowNavigation = (browserWindow: Electron.BrowserWindow): void => {
export const preventWindowNavigation = (browserWindow: Electron.BrowserWindow, isPopOutWindow: boolean = false): void => {
const listener = (e: Electron.Event, winUrl: string) => {
if (isPopOutWindow && !winUrl.startsWith('http' || 'https')) {
e.preventDefault();
return;
}
if (browserWindow.isDestroyed()
|| browserWindow.webContents.isDestroyed()
|| winUrl === browserWindow.webContents.getURL()) return;
e.preventDefault();
};
browserWindow.webContents.on('will-navigate', listener);
browserWindow.once('close', () => {
browserWindow.webContents.removeListener('will-navigate', listener);
});
};
/**
@ -186,4 +198,29 @@ export const sanitize = (windowName: string): void => {
// Closes all the child windows
// windowMgr.cleanUpChildWindows();
}
};
/**
* Returns the config stored rectangle if it is contained within the workArea of at
* least one of the screens else returns the default rectangle value with out x, y
* as the default is to center the window
*
* @param winPos {Electron.Rectangle}
* @param defaultWidth
* @param defaultHeight
* @return {x?: Number, y?: Number, width: Number, height: Number}
*/
export const getBounds = (winPos: Electron.Rectangle, defaultWidth: number, defaultHeight: number): Partial<Electron.Rectangle> => {
if (!winPos) return { width: defaultWidth, height: defaultHeight };
const displays = electron.screen.getAllDisplays();
for (let i = 0, len = displays.length; i < len; i++) {
const workArea = displays[ i ].workArea;
if (winPos.x >= workArea.x && winPos.y >= workArea.y &&
((winPos.x + winPos.width) <= (workArea.x + workArea.width)) &&
((winPos.y + winPos.height) <= (workArea.y + workArea.height))) {
return winPos;
}
}
return { width: defaultWidth, height: defaultHeight };
};

View File

@ -0,0 +1,30 @@
/**
* Formats a string with dynamic values
* @param str {String} String to be formatted
* @param data {Object} - Data to be added
*
* @example
* StringFormat(this will log {time}`, { time: '1234' })
*
* result:
* this will log 1234
*
* @return {*}
*/
export const formatString = (str: string, data?: object): string => {
if (!str || !data) return str;
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
return str.replace(/({([^}]+)})/g, (i) => {
const replacedKey = i.replace(/{/, '').replace(/}/, '');
if (!data[replacedKey]) {
return i;
}
return data[replacedKey];
});
}
}
return str;
};

View File

@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { formatString } from './utils';
import { formatString } from './format-string';
const localeCodeRegex = /^([a-z]{2})-([A-Z]{2})$/;

22
src/common/throttle.ts Normal file
View File

@ -0,0 +1,22 @@
/**
* Limits your function to be called at most every milliseconds
*
* @param func
* @param wait
* @example const throttled = throttle(anyFunc, 500);
*/
export const throttle = (func: (...args) => void, wait: number): (...args) => void => {
if (wait <= 0) {
throw Error('throttle: invalid throttleTime arg, must be a number: ' + wait);
}
let isCalled: boolean = false;
return (...args) => {
if (!isCalled) {
func(...args);
isCalled = true;
setTimeout(() => isCalled = false, wait);
}
};
};

View File

@ -1,9 +1,9 @@
// regex match the semver (semantic version) this checks for the pattern X.Y.Z
// ex-valid v1.2.0, 1.2.0, 2.3.4-r51
import archiver from 'archiver';
import * as archiver from 'archiver';
import * as fs from 'fs';
import * as path from 'path';
// regex match the semver (semantic version) this checks for the pattern X.Y.Z
// ex-valid v1.2.0, 1.2.0, 2.3.4-r51
const semver = /^v?(?:\d+)(\.(?:[x*]|\d+)(\.(?:[x*]|\d+)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?)?)?$/i;
const patch = /-([0-9A-Za-z-.]+)/;
@ -145,60 +145,6 @@ const pick = (object: object, fields: string[]) => {
return obj;
};
/**
* Formats a string with dynamic values
* @param str {String} String to be formatted
* @param data {Object} - Data to be added
*
* @example
* StringFormat(this will log {time}`, { time: '1234' })
*
* result:
* this will log 1234
*
* @return {*}
*/
const formatString = (str: string, data?: object): string => {
if (!str || !data) return str;
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
return str.replace(/({([^}]+)})/g, (i) => {
const replacedKey = i.replace(/{/, '').replace(/}/, '');
if (!data[replacedKey]) {
return i;
}
return data[replacedKey];
});
}
}
return str;
};
/**
* Limits your function to be called at most every milliseconds
*
* @param func
* @param wait
* @example const throttled = throttle(anyFunc, 500);
*/
const throttle = (func: (...args) => void, wait: number): (...args) => void => {
if (wait <= 0) {
throw Error('throttle: invalid throttleTime arg, must be a number: ' + wait);
}
let isCalled: boolean = false;
return (...args) => {
if (!isCalled) {
func(...args);
isCalled = true;
setTimeout(() => isCalled = false, wait);
}
};
};
/**
* Archives files in the source directory
* that matches the given file extension
@ -245,4 +191,4 @@ const generateArchiveForDirectory = (source: string, destination: string, fileEx
});
};
export { compareVersions, getCommandLineArgs, getGuid, pick, formatString, throttle, generateArchiveForDirectory };
export { compareVersions, getCommandLineArgs, getGuid, pick, generateArchiveForDirectory };

View File

@ -10,7 +10,7 @@ import {
IScreenSnippet, KeyCodes,
} from '../common/api-interface';
import { i18n, LocaleType } from '../common/i18n';
import { throttle } from '../common/utils';
import { throttle } from '../common/throttle';
import { getSource } from './desktop-capturer';
let isAltKey = false;