/********************************************************************************
 * Copyright (C) 2020 Ericsson and others.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License v. 2.0 are satisfied: GNU General Public License, version 2
 * with the GNU Classpath Exception which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 ********************************************************************************/ import { inject , injectable , named } from 'inversify' ; import { screen , globalShortcut , ipcMain , app , BrowserWindow , BrowserWindowConstructorOptions , Event as ElectronEvent } from '../../shared/electron' ; import * as path from 'path' ; import { Argv } from 'yargs' ; import { AddressInfo } from 'net' ; import { promises as fs } from 'fs' ; import { fork , ForkOptions } from 'child_process' ; import { FrontendApplicationConfig } from '@theia/application-package/lib/application-props' ; import URI from '../common/uri' ; import { FileUri } from '../node/file-uri' ; import { Deferred } from '../common/promise-util' ; import { MaybePromise } from '../common/types' ; import { ContributionProvider } from '../common/contribution-provider' ; import { ElectronSecurityTokenService } from './electron-security-token-service' ; import { ElectronSecurityToken } from '../electron-common/electron-token' ; import Storage = require ( 'electron-store' ) ; import { isOSX , isWindows } from '../common' ; import { CLOSE_REQUESTED_SIGNAL , RELOAD_REQUESTED_SIGNAL , RequestTitleBarStyle , Restart , StopReason , TitleBarStyleAtStartup , TitleBarStyleChanged } from '../electron-common/messaging/electron-messages' ; import { DEFAULT_WINDOW_HASH } from '../common/window' ; const createYargs = require ( 'yargs/yargs' ) ; /**
 * Theia tracks the maximized state of Electron Browser Windows.
 */ export interface TheiaBrowserWindowOptions extends BrowserWindowConstructorOptions { isMaximized ? : boolean ; isFullScreen ? : boolean ; /**
     * Represents the complete screen layout for all available displays.
     * This field is used to determine if the layout was updated since the electron window was last opened,
     * in which case we want to invalidate the stored options and use the default options instead.
     */ screenLayout ? : string ; } /**
 * Options passed to the main/default command handler.
 */ export interface ElectronMainCommandOptions { /**
     * By default, the first positional argument. Should be either a relative or absolute file-system path pointing to a file or a folder.
     */ readonly file ? : string ; } /**
 * Fields related to a launch event.
 *
 * This kind of event is triggered in two different contexts:
 *  1. The app is launched for the first time, `secondInstance` is false.
 *  2. The app is already running but user relaunches it, `secondInstance` is true.
 */ export interface ElectronMainExecutionParams { readonly secondInstance : boolean ; readonly argv : string [ ] ; readonly cwd : string ; } export const ElectronMainApplicationGlobals = Symbol ( 'ElectronMainApplicationGlobals' ) ; export interface ElectronMainApplicationGlobals { readonly THEIA_APP_PROJECT_PATH : string ; readonly THEIA_BACKEND_MAIN_PATH : string ; readonly THEIA_FRONTEND_HTML_PATH : string ; } /**
 * The default entrypoint will handle a very rudimentary CLI to open workspaces by doing `app path/to/workspace`. To override this behavior, you can extend and rebind the
 * `ElectronMainApplication` class and overriding the `launch` method.
 * A JSON-RPC communication between the Electron Main Process and the Renderer Processes is available: You can bind services using the `ElectronConnectionHandler` and
 * `ElectronIpcConnectionProvider` APIs, example:
 *
 * From an `electron-main` module:
 *
 *     bind(ElectronConnectionHandler).toDynamicValue(context =>
 *          new JsonRpcConnectionHandler(electronMainWindowServicePath,
 *          () => context.container.get(ElectronMainWindowService))
 *     ).inSingletonScope();
 *
 * And from the `electron-browser` module:
 *
 *     bind(ElectronMainWindowService).toDynamicValue(context =>
 *          ElectronIpcConnectionProvider.createProxy(context.container, electronMainWindowServicePath)
 *     ).inSingletonScope();
 */ export const ElectronMainApplicationContribution = Symbol ( 'ElectronMainApplicationContribution' ) ; export interface ElectronMainApplicationContribution { /**
     * The application is ready and is starting. This is the time to initialize
     * services global to this process.
     *
     * Invoked when the electron-main process starts for the first time.
     */ onStart ? ( application ) : MaybePromise < void > ; /**
     * The application is stopping. Contributions must perform only synchronous operations.
     */ onStop ? ( application ) : void ; } @ injectable ( ) export class ElectronMainProcessArgv { protected get processArgvBinIndex ( ) : number { if ( this . isBundledElectronApp ) { return 0 ; } return 1 ; } protected get isBundledElectronApp ( ) : boolean { return this . isElectronApp && ! ( process as ElectronMainProcessArgv . ElectronMainProcess ) . defaultApp ; } protected get isElectronApp ( ) : boolean { return ! ! ( process as ElectronMainProcessArgv . ElectronMainProcess ) . versions . electron ; } getProcessArgvWithoutBin ( argv = process . argv ) { return argv . slice ( this . processArgvBinIndex + 1 ) ; } getProcessArgvBin ( argv = process . argv ) { return argv [ this . processArgvBinIndex ] ; } } export namespace ElectronMainProcessArgv { export interface ElectronMainProcess extends NodeJS . Process { readonly defaultApp : boolean ; readonly versions : NodeJS . ProcessVersions & { readonly electron : string ; } ; } } @ injectable ( ) export class ElectronMainApplication { @ inject ( ContributionProvider ) @ named ( ElectronMainApplicationContribution ) protected readonly contributions : ContributionProvider < ElectronMainApplicationContribution > ; @ inject ( ElectronMainApplicationGlobals ) protected readonly globals : ElectronMainApplicationGlobals ; @ inject ( ElectronMainProcessArgv ) protected processArgv : ElectronMainProcessArgv ; @ inject ( ElectronSecurityTokenService ) protected electronSecurityTokenService : ElectronSecurityTokenService ; @ inject ( ElectronSecurityToken ) protected readonly electronSecurityToken : ElectronSecurityToken ; protected readonly electronStore = new Storage ( ) ; protected readonly _backendPort = new Deferred < number > ( ) ; readonly backendPort = this . _backendPort . promise ; protected _config : FrontendApplicationConfig | undefined ; protected useNativeWindowFrame : boolean = true ; protected didUseNativeWindowFrameOnStart = new Map < number , boolean > ( ) ; protected restarting = false ; protected closeIsConfirmed = new Set < number > ( ) ; protected closeRequested = 0 ; get config ( ) : FrontendApplicationConfig { if ( ! this . _config ) { throw new Error ( 'You have to start the application first.' ) ; } return this . _config ; } async start ( config ) { this . useNativeWindowFrame = this . getTitleBarStyle ( config ) === 'native' ; this . _config = config ; this . hookApplicationEvents ( ) ; const port = await this . startBackend ( ) ; this . _backendPort . resolve ( port ) ; await app . whenReady ( ) ; await this . attachElectronSecurityToken ( port ) ; await this . startContributions ( ) ; await this . launch ( { secondInstance : false , argv : this . processArgv . getProcessArgvWithoutBin ( process . argv ) , cwd : process . cwd ( ) } ) ; } protected getTitleBarStyle ( config ) { if ( isOSX ) { return 'native' ; } const storedFrame = this . electronStore . get ( 'windowstate' ) ?. frame ; if ( storedFrame !== undefined ) { return ! ! storedFrame ? 'native' : 'custom' ; } if ( config . preferences && config . preferences [ 'window.titleBarStyle' ] ) { const titleBarStyle = config . preferences [ 'window.titleBarStyle' ] ; if ( titleBarStyle === 'native' || titleBarStyle === 'custom' ) { return titleBarStyle ; } } return isWindows ? 'custom' : 'native' ; } protected async launch ( params ) { createYargs ( params . argv , params . cwd ) . command ( '$0 [file]' , false , cmd => cmd . positional ( 'file' , { type : 'string' } ) , args => this . handleMainCommand ( params , { file : args . file } ) ) . parse ( ) ; } param options async createWindow ( asyncOptions = this . getDefaultTheiaWindowOptions ( ) ) { let options = await asyncOptions ; options = this . avoidOverlap ( options ) ; const electronWindow = new BrowserWindow ( options ) ; electronWindow . setMenuBarVisibility ( false ) ; this . attachReadyToShow ( electronWindow ) ; this . attachSaveWindowState ( electronWindow ) ; this . attachGlobalShortcuts ( electronWindow ) ; this . restoreMaximizedState ( electronWindow , options ) ; this . attachCloseListeners ( electronWindow , options ) ; return electronWindow ; } async getLastWindowOptions ( ) { const previousWindowState = this . electronStore . get ( 'windowstate' ) ; const windowState = previousWindowState ?. screenLayout === this . getCurrentScreenLayout ( ) ? previousWindowState : this . getDefaultTheiaWindowOptions ( ) ; return { frame : this . useNativeWindowFrame , ... this . getDefaultOptions ( ) , ... windowState } ; } protected avoidOverlap ( options ) { const existingWindowsBounds = BrowserWindow . getAllWindows ( ) . map ( window => window . getBounds ( ) ) ; if ( existingWindowsBounds . length > 0 ) { while ( existingWindowsBounds . some ( window => window . x === options . x || window . y === options . y ) ) { if ( options . isMaximized || options . isFullScreen ) { options = this . getDefaultTheiaWindowOptions ( ) ; } options . x = options . x ! + 30 ; options . y = options . y ! + 30 ; } } return options ; } protected getDefaultOptions ( ) { return { show : false , title : this . config . applicationName , minWidth : 200 , minHeight : 120 , webPreferences : { nodeIntegration : true , nodeIntegrationInWorker : false , } , ... this . config . electron ?. windowOptions || { } , } ; } async openDefaultWindow ( ) { const [ uri , electronWindow ] = await Promise . all ( [ this . createWindowUri ( ) , this . createWindow ( ) ] ) ; electronWindow . loadURL ( uri . withFragment ( DEFAULT_WINDOW_HASH ) . toString ( true ) ) ; return electronWindow ; } protected async openWindowWithWorkspace ( workspacePath ) { const options = await this . getLastWindowOptions ( ) ; const [ uri , electronWindow ] = await Promise . all ( [ this . createWindowUri ( ) , this . createWindow ( options ) ] ) ; electronWindow . loadURL ( uri . withFragment ( encodeURI ( workspacePath ) ) . toString ( true ) ) ; return electronWindow ; } /**
     * "Gently" close all windows, application will not stop if a `beforeunload` handler returns `false`.
     */ requestStop ( ) { app . quit ( ) ; } protected async handleMainCommand ( params , options ) { if ( params . secondInstance === false ) { await this . openWindowWithWorkspace ( '' ) ; } else if ( options . file === undefined ) { await this . openDefaultWindow ( ) ; } else { let workspacePath ; try { workspacePath = await fs . realpath ( path . resolve ( params . cwd , options . file ) ) ; } catch { console . error ( `Could not resolve the workspace path. "${ options . file }" is not a valid 'file' option. Falling back to the default workspace location.` ) ; } if ( workspacePath === undefined ) { await this . openDefaultWindow ( ) ; } else { await this . openWindowWithWorkspace ( workspacePath ) ; } } } protected async createWindowUri ( ) { return FileUri . create ( this . globals . THEIA_FRONTEND_HTML_PATH ) . withQuery ( `port=${ await this . backendPort }` ) ; } protected getDefaultTheiaWindowOptions ( ) { const { bounds } = screen . getDisplayNearestPoint ( screen . getCursorScreenPoint ( ) ) ; const height = Math . round ( bounds . height * ( 2 / 3 ) ) ; const width = Math . round ( bounds . width * ( 2 / 3 ) ) ; const y = Math . round ( bounds . y + ( bounds . height - height ) / 2 ) ; const x = Math . round ( bounds . x + ( bounds . width - width ) / 2 ) ; return { frame : this . useNativeWindowFrame , isFullScreen : false , isMaximized : false , width , height , x , y , ... this . getDefaultOptions ( ) } ; } /**
     * Only show the window when the content is ready.
     */ protected attachReadyToShow ( electronWindow ) { electronWindow . on ( 'ready-to-show' , ( ) => electronWindow . show ( ) ) ; } /**
     * Save the window geometry state on every change.
     */ protected attachSaveWindowState ( electronWindow ) { let delayedSaveTimeout ; const saveWindowStateDelayed = ( ) => { if ( delayedSaveTimeout ) { clearTimeout ( delayedSaveTimeout ) ; } delayedSaveTimeout = setTimeout ( ( ) => this . saveWindowState ( electronWindow ) , 1000 ) ; } ; electronWindow . on ( 'close' , ( ) => { this . saveWindowState ( electronWindow ) ; this . didUseNativeWindowFrameOnStart . delete ( electronWindow . id ) ; } ) ; electronWindow . on ( 'resize' , saveWindowStateDelayed ) ; electronWindow . on ( 'move' , saveWindowStateDelayed ) ; this . didUseNativeWindowFrameOnStart . set ( electronWindow . id , this . useNativeWindowFrame ) ; } protected saveWindowState ( electronWindow ) { if ( ! electronWindow ) { return ; } try { const bounds = electronWindow . getBounds ( ) ; this . electronStore . set ( 'windowstate' , { isFullScreen : electronWindow . isFullScreen ( ) , isMaximized : electronWindow . isMaximized ( ) , width : bounds . width , height : bounds . height , x : bounds . x , y : bounds . y , frame : this . useNativeWindowFrame , screenLayout : this . getCurrentScreenLayout ( ) , } as TheiaBrowserWindowOptions ) ; } catch ( e ) { console . error ( 'Error while saving window state:' , e ) ; } } /**
     * Return a string unique to the current display layout.
     */ protected getCurrentScreenLayout ( ) { return screen . getAllDisplays ( ) . map ( display => `${ display . bounds . x }:${ display . bounds . y }:${ display . bounds . width }:${ display . bounds . height }` ) . sort ( ) . join ( '-' ) ; } /**
     * Catch certain keybindings to prevent reloading the window using keyboard shortcuts.
     */ protected attachGlobalShortcuts ( electronWindow ) { const handler = this . config . electron ?. disallowReloadKeybinding ? ( ) => { } : ( ) => this . reload ( electronWindow ) ; const accelerators = [ 'CmdOrCtrl+R' , 'F5' ] ; electronWindow . on ( 'focus' , ( ) => { for ( const accelerator of accelerators ) { globalShortcut . register ( accelerator , handler ) ; } } ) ; electronWindow . on ( 'blur' , ( ) => { for ( const accelerator of accelerators ) { globalShortcut . unregister ( accelerator ) ; } } ) ; } protected restoreMaximizedState ( electronWindow , options ) { if ( options . isMaximized ) { electronWindow . maximize ( ) ; } else { electronWindow . unmaximize ( ) ; } } protected attachCloseListeners ( electronWindow , options ) { electronWindow . on ( 'close' , async ( event ) => { if ( this . closeIsConfirmed . has ( electronWindow . id ) ) { this . closeIsConfirmed . delete ( electronWindow . id ) ; return ; } event . preventDefault ( ) ; this . handleStopRequest ( electronWindow , ( ) => this . doCloseWindow ( electronWindow ) , StopReason . Close ) ; } ) ; } protected doCloseWindow ( electronWindow ) { this . closeIsConfirmed . add ( electronWindow . id ) ; electronWindow . close ( ) ; } protected async handleStopRequest ( electronWindow , onSafeCallback , reason ) { let currentUrl = electronWindow . webContents . getURL ( ) ; let frontendUri = this . globals . THEIA_FRONTEND_HTML_PATH ; if ( isWindows ) { currentUrl = currentUrl . replace ( /\\/g , '/' ) ; frontendUri = frontendUri . replace ( /\\/g , '/' ) ; } const safeToClose = ! currentUrl . includes ( frontendUri ) || await this . checkSafeToStop ( electronWindow , reason ) ; if ( safeToClose ) { onSafeCallback ( ) ; } } protected checkSafeToStop ( electronWindow , reason ) { const closeRequest = this . closeRequested ++ ; const confirmChannel = `safeToClose-${ electronWindow . id }-${ closeRequest }` ; const cancelChannel = `notSafeToClose-${ electronWindow . id }-${ closeRequest }` ; return new Promise < boolean > ( resolve => { electronWindow . webContents . send ( CLOSE_REQUESTED_SIGNAL , { confirmChannel , cancelChannel , reason } ) ; ipcMain . once ( confirmChannel , ( ) => resolve ( true ) ) ; ipcMain . once ( cancelChannel , ( ) => resolve ( false ) ) ; } ) ; } return protected async startBackend ( ) { const noBackendFork = process . argv . indexOf ( '--no-cluster' ) !== - 1 ; process . env . THEIA_APP_PROJECT_PATH = this . globals . THEIA_APP_PROJECT_PATH ; process . env . THEIA_ELECTRON_VERSION = process . versions . electron ; if ( noBackendFork ) { process . env [ ElectronSecurityToken ] = JSON . stringify ( this . electronSecurityToken ) ; const address = await require ( this . globals . THEIA_BACKEND_MAIN_PATH ) ; return address . port ; } else { const backendProcess = fork ( this . globals . THEIA_BACKEND_MAIN_PATH , this . processArgv . getProcessArgvWithoutBin ( ) , await this . getForkOptions ( ) ) ; return new Promise ( ( resolve , reject ) => { backendProcess . on ( 'message' , ( address ) => { resolve ( address . port ) ; } ) ; backendProcess . on ( 'error' , error => { reject ( error ) ; } ) ; app . on ( 'quit' , ( ) => { if ( backendProcess . exitCode === null && backendProcess . signalCode === null ) { try { process . kill ( backendProcess . pid ) ; } catch ( error ) { if ( error . code === 'ESRCH' ) { return ; } throw error ; } } } ) ; } ) ; } } protected async getForkOptions ( ) { return { detached : process . platform !== 'win32' , env : { ... process . env , [ ElectronSecurityToken ] : JSON . stringify ( this . electronSecurityToken ) , } , } ; } protected async attachElectronSecurityToken ( port ) { await this . electronSecurityTokenService . setElectronSecurityTokenCookie ( `http://localhost:${ port }` ) ; } protected hookApplicationEvents ( ) { app . on ( 'will-quit' , this . onWillQuit . bind ( this ) ) ; app . on ( 'second-instance' , this . onSecondInstance . bind ( this ) ) ; app . on ( 'window-all-closed' , this . onWindowAllClosed . bind ( this ) ) ; ipcMain . on ( TitleBarStyleChanged , ( { sender } , titleBarStyle ) => { this . useNativeWindowFrame = titleBarStyle === 'native' ; this . saveWindowState ( BrowserWindow . fromId ( sender . id ) ) ; } ) ; ipcMain . on ( Restart , ( { sender } ) => { this . restart ( sender . id ) ; } ) ; ipcMain . on ( RELOAD_REQUESTED_SIGNAL , event => this . handleReload ( event ) ) ; ipcMain . on ( RequestTitleBarStyle , ( { sender } ) => { sender . send ( TitleBarStyleAtStartup , this . didUseNativeWindowFrameOnStart . get ( sender . id ) ? 'native' : 'custom' ) ; } ) ; } protected onWillQuit ( event ) { this . stopContributions ( ) ; } protected async onSecondInstance ( event , argv , cwd ) { const electronWindows = BrowserWindow . getAllWindows ( ) ; if ( electronWindows . length > 0 ) { const electronWindow = electronWindows [ 0 ] ; if ( electronWindow . isMinimized ( ) ) { electronWindow . restore ( ) ; } electronWindow . focus ( ) ; } } protected onWindowAllClosed ( event ) { if ( ! this . restarting ) { this . requestStop ( ) ; } } protected restart ( id ) { this . restarting = true ; const window = BrowserWindow . fromId ( id ) ; window . on ( 'closed' , async ( ) => { await this . launch ( { secondInstance : false , argv : this . processArgv . getProcessArgvWithoutBin ( process . argv ) , cwd : process . cwd ( ) } ) ; this . restarting = false ; } ) ; this . handleStopRequest ( window , ( ) => this . doCloseWindow ( window ) , StopReason . Restart ) ; } protected async handleReload ( event ) { const window = BrowserWindow . fromId ( event . sender . id ) ; this . reload ( window ) ; } protected reload ( electronWindow ) { this . handleStopRequest ( electronWindow , ( ) => electronWindow . reload ( ) , StopReason . Reload ) ; } protected async startContributions ( ) { const promises = [ ] ; for ( const contribution of this . contributions . getContributions ( ) ) { if ( contribution . onStart ) { promises . push ( contribution . onStart ( this ) ) ; } } await Promise . all ( promises ) ; } protected stopContributions ( ) { for ( const contribution of this . contributions . getContributions ( ) ) { if ( contribution . onStop ) { contribution . onStop ( this ) ; } } } }