import { BrowserWindow, ipcMain, Menu, app, dialog } from 'electron'; import * as path from 'path'; import * as URL from 'url'; import { Subscription, PartialObserver } from 'rxjs'; import { Emitter } from '@overflow/core/emitter'; import { encodePathAsUrl } from '@overflow/core/path'; import { registerWindowStateChangedEvents } from '../commons/model/window-state'; import { URLActionType } from '@overflow/core/parse-app-url'; import { now } from '@overflow/core/now'; import { MenuEvent } from '../commons/type'; import { LaunchState } from '../commons/model'; import { menuFromElectronMenu } from '../commons/model/app-menu'; let windowStateKeeper: any | null = null; export class AppWindow { private window: Electron.BrowserWindow; private emitter = new Emitter(); private _loadTime: number | null = null; private _rendererReadyTime: number | null = null; private minWidth = 960; private minHeight = 660; public constructor() { if (!windowStateKeeper) { // `electron-window-state` requires Electron's `screen` module, which can // only be required after the app has emitted `ready`. So require it // lazily. windowStateKeeper = require('electron-window-state'); } const savedWindowState = windowStateKeeper({ defaultWidth: this.minWidth, defaultHeight: this.minHeight, }); const windowOptions: Electron.BrowserWindowConstructorOptions = { x: savedWindowState.x, y: savedWindowState.y, width: savedWindowState.width, height: savedWindowState.height, minWidth: this.minWidth, minHeight: this.minHeight, show: false, // This fixes subpixel aliasing on Windows // See https://github.com/atom/atom/commit/683bef5b9d133cb194b476938c77cc07fd05b972 backgroundColor: '#fff', webPreferences: { // Disable auxclick event // See https://developers.google.com/web/updates/2016/10/auxclick disableBlinkFeatures: 'Auxclick', // Enable, among other things, the ResizeObserver experimentalFeatures: true, }, acceptFirstMouse: true, }; if (__DARWIN__) { windowOptions.titleBarStyle = 'hidden'; } else if (__WIN32__) { windowOptions.frame = false; } else if (__LINUX__) { windowOptions.icon = path.join(__dirname, 'static', 'icon-logo.png'); } this.window = new BrowserWindow(windowOptions); savedWindowState.manage(this.window); let quitting = false; app.on('before-quit', () => { quitting = true; }); ipcMain.on('will-quit', (event: Electron.IpcMessageEvent) => { quitting = true; event.returnValue = true; }); // on macOS, when the user closes the window we really just hide it. This // lets us activate quickly and keep all our interesting logic in the // renderer. if (__DARWIN__) { this.window.on('close', e => { if (!quitting) { e.preventDefault(); Menu.sendActionToFirstResponder('hide:'); } }); } } public load() { let startLoad = 0; // We only listen for the first of the loading events to avoid a bug in // Electron/Chromium where they can sometimes fire more than once. See // See // https://github.com/desktop/desktop/pull/513#issuecomment-253028277. This // shouldn't really matter as in production builds loading _should_ only // happen once. this.window.webContents.once('did-start-loading', () => { this._rendererReadyTime = null; this._loadTime = null; startLoad = now(); }); this.window.webContents.once('did-finish-load', () => { if (process.env.NODE_ENV === 'development') { this.window.webContents.openDevTools(); } this._loadTime = now() - startLoad; this.maybeEmitDidLoad(); }); this.window.webContents.on('did-finish-load', () => { this.window.webContents.setVisualZoomLevelLimits(1, 1); }); this.window.webContents.on('did-fail-load', () => { this.window.webContents.openDevTools(); this.window.show(); }); // TODO: This should be scoped by the window. ipcMain.once( 'renderer-ready', (event: Electron.IpcMessageEvent, readyTime: number) => { this._rendererReadyTime = readyTime; this.maybeEmitDidLoad(); } ); this.window.on('focus', () => this.window.webContents.send('focus')); this.window.on('blur', () => this.window.webContents.send('blur')); registerWindowStateChangedEvents(this.window); // this.window.loadURL(encodePathAsUrl(__dirname, 'index.html')); const indexUrl = URL.format({ pathname: path.join('//localhost:4200'), protocol: 'http:', slashes: true }); this.window.loadURL(indexUrl); } /** * Emit the `onDidLoad` event if the page has loaded and the renderer has * signalled that it's ready. */ private maybeEmitDidLoad() { if (!this.rendererLoaded) { return; } this.emitter.emit('did-load'); } /** Is the page loaded and has the renderer signalled it's ready? */ private get rendererLoaded(): boolean { return !!this.loadTime && !!this.rendererReadyTime; } public onClose(fn: () => void) { this.window.on('closed', fn); } /** * Register a function to call when the window is done loading. At that point * the page has loaded and the renderer has signalled that it is ready. */ public onDidLoad(fn: (data?: any) => void): Subscription { return this.emitter.listen('did-load', fn); } public isMinimized() { return this.window.isMinimized(); } /** Is the window currently visible? */ public isVisible() { return this.window.isVisible(); } public restore() { this.window.restore(); } public focus() { this.window.focus(); } /** Show the window. */ public show() { this.window.show(); } /** Send the menu event to the renderer. */ public sendMenuEvent(name: MenuEvent) { this.show(); this.window.webContents.send('menu-event', { name }); } /** Send the URL action to the renderer. */ public sendURLAction(action: URLActionType) { this.show(); this.window.webContents.send('url-action', { action }); } /** Send the app launch timing stats to the renderer. */ public sendLaunchTimingStats(state: LaunchState) { this.window.webContents.send('launch-timing-stats', { state }); } /** Send the app menu to the renderer. */ public sendAppMenu() { const appMenu = Menu.getApplicationMenu(); if (appMenu) { const menu = menuFromElectronMenu(appMenu); this.window.webContents.send('app-menu', { menu }); } } /** Send a certificate error to the renderer. */ public sendCertificateError( certificate: Electron.Certificate, error: string, url: string ) { this.window.webContents.send('certificate-error', { certificate, error, url, }); } public showCertificateTrustDialog( certificate: Electron.Certificate, message: string ) { // The Electron type definitions don't include `showCertificateTrustDialog` // yet. const d = dialog as any; d.showCertificateTrustDialog( this.window, { certificate, message }, () => { } ); } /** Report the exception to the renderer. */ public sendException(error: Error) { // `Error` can't be JSONified so it doesn't transport nicely over IPC. So // we'll just manually copy the properties we care about. const friendlyError = { stack: error.stack, message: error.message, name: error.name, }; this.window.webContents.send('main-process-exception', friendlyError); } /** * Get the time (in milliseconds) spent loading the page. * * This will be `null` until `onDidLoad` is called. */ public get loadTime(): number | null { return this._loadTime; } /** * Get the time (in milliseconds) elapsed from the renderer being loaded to it * signaling it was ready. * * This will be `null` until `onDidLoad` is called. */ public get rendererReadyTime(): number | null { return this._rendererReadyTime; } public destroy() { this.window.destroy(); } }