diff --git a/CHANGELOG.md b/CHANGELOG.md index 147c4b2ed7bcc..4edbe1f11dc1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ - `TerminalServer` - `TimelineTreeWidget` - `TypeHierarchyTreeWidget` - +- [core] Moved methods `attachReadyToShow`, `restoreMaximizedState`, `attachCloseListeners`, `handleStopRequest`, `checkSafeToStop`, `handleReload`, `reload` from `ElectronMainAPplication` into new class `TheiaElectronWindow`. [#10600](https://github.com/eclipse-theia/theia/pull/10600) ## v1.22.0 - 1/27/2022 [1.22.0 Milestone](https://github.com/eclipse-theia/theia/milestone/30) diff --git a/packages/core/src/browser/frontend-application-state.ts b/packages/core/src/browser/frontend-application-state.ts index b8992dd898b7f..ae3ca2806d2a6 100644 --- a/packages/core/src/browser/frontend-application-state.ts +++ b/packages/core/src/browser/frontend-application-state.ts @@ -18,14 +18,9 @@ import { injectable, inject } from 'inversify'; import { Emitter, Event } from '../common/event'; import { Deferred } from '../common/promise-util'; import { ILogger } from '../common/logger'; +import { FrontendApplicationState } from '../common/frontend-application-state'; -export type FrontendApplicationState = - 'init' - | 'started_contributions' - | 'attached_shell' - | 'initialized_layout' - | 'ready' - | 'closing_window'; +export { FrontendApplicationState }; @injectable() export class FrontendApplicationStateService { @@ -44,17 +39,7 @@ export class FrontendApplicationStateService { set state(state: FrontendApplicationState) { if (state !== this._state) { - if (this.deferred[this._state] === undefined) { - this.deferred[this._state] = new Deferred(); - } - const oldState = this._state; - this._state = state; - if (this.deferred[state] === undefined) { - this.deferred[state] = new Deferred(); - } - this.deferred[state].resolve(); - this.logger.info(`Changed application state from '${oldState}' to '${this._state}'.`); - this.stateChanged.fire(state); + this.doSetState(state); } } @@ -62,6 +47,20 @@ export class FrontendApplicationStateService { return this.stateChanged.event; } + protected doSetState(state: FrontendApplicationState): void { + if (this.deferred[this._state] === undefined) { + this.deferred[this._state] = new Deferred(); + } + const oldState = this._state; + this._state = state; + if (this.deferred[state] === undefined) { + this.deferred[state] = new Deferred(); + } + this.deferred[state].resolve(); + this.logger.info(`Changed application state from '${oldState}' to '${this._state}'.`); + this.stateChanged.fire(state); + } + reachedState(state: FrontendApplicationState): Promise { if (this.deferred[state] === undefined) { this.deferred[state] = new Deferred(); @@ -72,5 +71,4 @@ export class FrontendApplicationStateService { reachedAnyState(...states: FrontendApplicationState[]): Promise { return Promise.race(states.map(s => this.reachedState(s))); } - } diff --git a/packages/core/src/common/frontend-application-state.ts b/packages/core/src/common/frontend-application-state.ts new file mode 100644 index 0000000000000..c4a70940972e4 --- /dev/null +++ b/packages/core/src/common/frontend-application-state.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * Copyright (C) 2022 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 + ********************************************************************************/ + +export type FrontendApplicationState = + 'init' + | 'started_contributions' + | 'attached_shell' + | 'initialized_layout' + | 'ready' + | 'closing_window'; diff --git a/packages/core/src/electron-browser/window/electron-frontend-application-state.ts b/packages/core/src/electron-browser/window/electron-frontend-application-state.ts new file mode 100644 index 0000000000000..18abe4203642c --- /dev/null +++ b/packages/core/src/electron-browser/window/electron-frontend-application-state.ts @@ -0,0 +1,28 @@ +/******************************************************************************** + * Copyright (C) 2022 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 { ipcRenderer } from '../../../electron-shared/electron'; +import { injectable } from 'inversify'; +import { APPLICATION_STATE_CHANGE_SIGNAL } from '../../electron-common/messaging/electron-messages'; +import { FrontendApplicationState, FrontendApplicationStateService } from '../../browser/frontend-application-state'; + +@injectable() +export class ElectronFrontendApplicationStateService extends FrontendApplicationStateService { + protected override doSetState(state: FrontendApplicationState): void { + super.doSetState(state); + ipcRenderer.send(APPLICATION_STATE_CHANGE_SIGNAL, state); + } +} diff --git a/packages/core/src/electron-browser/window/electron-window-module.ts b/packages/core/src/electron-browser/window/electron-window-module.ts index 750ac9387ee58..5648354e2c0f2 100644 --- a/packages/core/src/electron-browser/window/electron-window-module.ts +++ b/packages/core/src/electron-browser/window/electron-window-module.ts @@ -23,8 +23,10 @@ import { ClipboardService } from '../../browser/clipboard-service'; import { ElectronMainWindowService, electronMainWindowServicePath } from '../../electron-common/electron-main-window-service'; import { ElectronIpcConnectionProvider } from '../messaging/electron-ipc-connection-provider'; import { bindWindowPreferences } from './electron-window-preferences'; +import { FrontendApplicationStateService } from '../../browser/frontend-application-state'; +import { ElectronFrontendApplicationStateService } from './electron-frontend-application-state'; -export default new ContainerModule(bind => { +export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ElectronMainWindowService).toDynamicValue(context => ElectronIpcConnectionProvider.createProxy(context.container, electronMainWindowServicePath) ).inSingletonScope(); @@ -32,4 +34,5 @@ export default new ContainerModule(bind => { bind(WindowService).to(ElectronWindowService).inSingletonScope(); bind(FrontendApplicationContribution).toService(WindowService); bind(ClipboardService).to(ElectronClipboardService).inSingletonScope(); + rebind(FrontendApplicationStateService).to(ElectronFrontendApplicationStateService).inSingletonScope(); }); diff --git a/packages/core/src/electron-common/messaging/electron-messages.ts b/packages/core/src/electron-common/messaging/electron-messages.ts index 5a01665af1269..5d33b062ece04 100644 --- a/packages/core/src/electron-common/messaging/electron-messages.ts +++ b/packages/core/src/electron-common/messaging/electron-messages.ts @@ -26,6 +26,10 @@ export const CLOSE_REQUESTED_SIGNAL = 'close-requested'; * Emitted by window when a reload is requested. */ export const RELOAD_REQUESTED_SIGNAL = 'reload-requested'; +/** + * Emitted by the window when the application changes state + */ +export const APPLICATION_STATE_CHANGE_SIGNAL = 'application-state-changed'; export enum StopReason { /** diff --git a/packages/core/src/electron-main/electron-main-application-module.ts b/packages/core/src/electron-main/electron-main-application-module.ts index eb9db9abef77f..76ef0acc1e0fc 100644 --- a/packages/core/src/electron-main/electron-main-application-module.ts +++ b/packages/core/src/electron-main/electron-main-application-module.ts @@ -26,6 +26,7 @@ import { ElectronMessagingContribution } from './messaging/electron-messaging-co import { ElectronMessagingService } from './messaging/electron-messaging-service'; import { ElectronConnectionHandler } from '../electron-common/messaging/electron-connection-handler'; import { ElectronSecurityTokenService } from './electron-security-token-service'; +import { TheiaBrowserWindowOptions, TheiaElectronWindow, TheiaElectronWindowFactory, WindowApplicationConfig } from './theia-electron-window'; const electronSecurityToken: ElectronSecurityToken = { value: v4() }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -50,4 +51,12 @@ export default new ContainerModule(bind => { ).inSingletonScope(); bind(ElectronMainProcessArgv).toSelf().inSingletonScope(); + + bind(TheiaElectronWindow).toSelf(); + bind(TheiaElectronWindowFactory).toFactory(({ container }) => (options, config) => { + const child = container.createChild(); + child.bind(TheiaBrowserWindowOptions).toConstantValue(options); + child.bind(WindowApplicationConfig).toConstantValue(config); + return child.get(TheiaElectronWindow); + }); }); diff --git a/packages/core/src/electron-main/electron-main-application.ts b/packages/core/src/electron-main/electron-main-application.ts index 2393da1f8191e..e40ce74f00510 100644 --- a/packages/core/src/electron-main/electron-main-application.ts +++ b/packages/core/src/electron-main/electron-main-application.ts @@ -16,7 +16,7 @@ import { inject, injectable, named } from 'inversify'; import * as electronRemoteMain from '../../electron-shared/@electron/remote/main'; -import { screen, ipcMain, app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent } from '../../electron-shared/electron'; +import { screen, ipcMain, app, BrowserWindow, Event as ElectronEvent } from '../../electron-shared/electron'; import * as path from 'path'; import { Argv } from 'yargs'; import { AddressInfo } from 'net'; @@ -31,32 +31,21 @@ 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 { Disposable, DisposableCollection, 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'; +import { TheiaBrowserWindowOptions, TheiaElectronWindow, TheiaElectronWindowFactory } from './theia-electron-window'; +import { ElectronMainApplicationGlobals } from './electron-main-constants'; +import { createDisposableListener } from './event-utils'; -const createYargs: (argv?: string[], cwd?: string) => Argv = require('yargs/yargs'); +export { ElectronMainApplicationGlobals }; -/** - * 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; -} +const createYargs: (argv?: string[], cwd?: string) => Argv = require('yargs/yargs'); /** * Options passed to the main/default command handler. @@ -83,13 +72,6 @@ export interface ElectronMainExecutionParams { 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. @@ -191,6 +173,9 @@ export class ElectronMainApplication { @inject(ElectronSecurityToken) protected readonly electronSecurityToken: ElectronSecurityToken; + @inject(TheiaElectronWindowFactory) + protected readonly windowFactory: TheiaElectronWindowFactory; + protected readonly electronStore = new Storage<{ windowstate?: TheiaBrowserWindowOptions }>(); @@ -201,9 +186,8 @@ export class ElectronMainApplication { protected _config: FrontendApplicationConfig | undefined; protected useNativeWindowFrame: boolean = true; protected didUseNativeWindowFrameOnStart = new Map(); + protected windows = new Map(); protected restarting = false; - protected closeIsConfirmed = new Set(); - protected closeRequested = 0; get config(): FrontendApplicationConfig { if (!this._config) { @@ -262,14 +246,13 @@ export class ElectronMainApplication { async createWindow(asyncOptions: MaybePromise = this.getDefaultTheiaWindowOptions()): Promise { let options = await asyncOptions; options = this.avoidOverlap(options); - const electronWindow = new BrowserWindow(options); - electronWindow.setMenuBarVisibility(false); - this.attachReadyToShow(electronWindow); - this.attachSaveWindowState(electronWindow); - this.restoreMaximizedState(electronWindow, options); - this.attachCloseListeners(electronWindow, options); - electronRemoteMain.enable(electronWindow.webContents); - return electronWindow; + const electronWindow = this.windowFactory(options, this.config); + const { window: { id } } = electronWindow; + this.windows.set(id, electronWindow); + electronWindow.onDidClose(() => this.windows.delete(id)); + this.attachSaveWindowState(electronWindow.window); + electronRemoteMain.enable(electronWindow.window.webContents); + return electronWindow.window; } async getLastWindowOptions(): Promise { @@ -386,17 +369,11 @@ export class ElectronMainApplication { }; } - /** - * Only show the window when the content is ready. - */ - protected attachReadyToShow(electronWindow: BrowserWindow): void { - electronWindow.on('ready-to-show', () => electronWindow.show()); - } - /** * Save the window geometry state on every change. */ protected attachSaveWindowState(electronWindow: BrowserWindow): void { + const windowStateListeners = new DisposableCollection(); let delayedSaveTimeout: NodeJS.Timer | undefined; const saveWindowStateDelayed = () => { if (delayedSaveTimeout) { @@ -404,13 +381,14 @@ export class ElectronMainApplication { } delayedSaveTimeout = setTimeout(() => this.saveWindowState(electronWindow), 1000); }; - electronWindow.on('close', () => { + createDisposableListener(electronWindow, 'close', () => { this.saveWindowState(electronWindow); - this.didUseNativeWindowFrameOnStart.delete(electronWindow.id); - }); - electronWindow.on('resize', saveWindowStateDelayed); - electronWindow.on('move', saveWindowStateDelayed); + }, windowStateListeners); + createDisposableListener(electronWindow, 'resize', saveWindowStateDelayed, windowStateListeners); + createDisposableListener(electronWindow, 'move', saveWindowStateDelayed, windowStateListeners); + windowStateListeners.push(Disposable.create(() => { try { this.didUseNativeWindowFrameOnStart.delete(electronWindow.id); } catch { } })); this.didUseNativeWindowFrameOnStart.set(electronWindow.id, this.useNativeWindowFrame); + electronWindow.once('closed', () => windowStateListeners.dispose()); } protected saveWindowState(electronWindow: BrowserWindow): void { @@ -420,7 +398,7 @@ export class ElectronMainApplication { } try { const bounds = electronWindow.getBounds(); - this.electronStore.set('windowstate', { + const options: TheiaBrowserWindowOptions = { isFullScreen: electronWindow.isFullScreen(), isMaximized: electronWindow.isMaximized(), width: bounds.width, @@ -429,7 +407,8 @@ export class ElectronMainApplication { y: bounds.y, frame: this.useNativeWindowFrame, screenLayout: this.getCurrentScreenLayout(), - } as TheiaBrowserWindowOptions); + }; + this.electronStore.set('windowstate', options); } catch (e) { console.error('Error while saving window state:', e); } @@ -444,58 +423,6 @@ export class ElectronMainApplication { ).sort().join('-'); } - protected restoreMaximizedState(electronWindow: BrowserWindow, options: TheiaBrowserWindowOptions): void { - if (options.isMaximized) { - electronWindow.maximize(); - } else { - electronWindow.unmaximize(); - } - } - - protected attachCloseListeners(electronWindow: BrowserWindow, options: TheiaBrowserWindowOptions): void { - electronWindow.on('close', async event => { - // User has already indicated that it is OK to close this window. - 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: BrowserWindow): void { - this.closeIsConfirmed.add(electronWindow.id); - electronWindow.close(); - } - - protected async handleStopRequest(electronWindow: BrowserWindow, onSafeCallback: () => unknown, reason: StopReason): Promise { - // Only confirm close to windows that have loaded our front end. - let currentUrl = electronWindow.webContents.getURL(); - let frontendUri = this.globals.THEIA_FRONTEND_HTML_PATH; - // Since our resolved frontend HTML path might contain backward slashes on Windows, we normalize everything first. - 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: BrowserWindow, reason: StopReason): Promise { - const closeRequest = this.closeRequested++; - const confirmChannel = `safeToClose-${electronWindow.id}-${closeRequest}`; - const cancelChannel = `notSafeToClose-${electronWindow.id}-${closeRequest}`; - return new Promise(resolve => { - electronWindow.webContents.send(CLOSE_REQUESTED_SIGNAL, { confirmChannel, cancelChannel, reason }); - ipcMain.once(confirmChannel, () => resolve(true)); - ipcMain.once(cancelChannel, () => resolve(false)); - }); - } - /** * Start the NodeJS backend server. * @@ -586,8 +513,6 @@ export class ElectronMainApplication { 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'); }); @@ -614,33 +539,25 @@ export class ElectronMainApplication { } } - protected restart(id: number): void { + protected async restart(id: number): Promise { this.restarting = true; - const browserWindow = BrowserWindow.fromId(id); - if (!browserWindow) { - throw new Error(`no BrowserWindow with id: ${id}`); - } - browserWindow.on('closed', async () => { - await this.launch({ - secondInstance: false, - argv: this.processArgv.getProcessArgvWithoutBin(process.argv), - cwd: process.cwd() + const window = BrowserWindow.fromId(id); + const wrapper = this.windows.get(window?.id as number); // If it's not a number, we won't get anything. + if (wrapper) { + const listener = wrapper.onDidClose(async () => { + listener.dispose(); + await this.launch({ + secondInstance: false, + argv: this.processArgv.getProcessArgvWithoutBin(process.argv), + cwd: process.cwd() + }); + this.restarting = false; }); - this.restarting = false; - }); - this.handleStopRequest(browserWindow, () => this.doCloseWindow(browserWindow), StopReason.Restart); - } - - protected async handleReload(event: Electron.IpcMainEvent): Promise { - const browserWindow = BrowserWindow.fromId(event.sender.id); - if (!browserWindow) { - throw new Error(`no BrowserWindow with id: ${event.sender.id}`); + // If close failed or was cancelled on this occasion, don't keep listening for it. + if (!await wrapper.close(StopReason.Restart)) { + listener.dispose(); + } } - this.reload(browserWindow); - } - - protected reload(electronWindow: BrowserWindow): void { - this.handleStopRequest(electronWindow, () => electronWindow.reload(), StopReason.Reload); } protected async startContributions(): Promise { diff --git a/packages/core/src/electron-main/electron-main-constants.ts b/packages/core/src/electron-main/electron-main-constants.ts new file mode 100644 index 0000000000000..2b43e3bb8c45e --- /dev/null +++ b/packages/core/src/electron-main/electron-main-constants.ts @@ -0,0 +1,22 @@ +/******************************************************************************** + * Copyright (C) 2022 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 + ********************************************************************************/ + +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 +} diff --git a/packages/core/src/electron-main/event-utils.ts b/packages/core/src/electron-main/event-utils.ts new file mode 100644 index 0000000000000..63fb46f6b9a24 --- /dev/null +++ b/packages/core/src/electron-main/event-utils.ts @@ -0,0 +1,36 @@ +/******************************************************************************** + * Copyright (C) 2022 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 { Disposable, DisposableCollection } from '../common'; + +/** + * @param collection If a collection is passed in, the new disposable is added to that collection. Otherwise, the new disposable is returned. + */ +export function createDisposableListener( + emitter: NodeJS.EventEmitter, signal: string, handler: (event: K, ...args: unknown[]) => unknown, collection: DisposableCollection +): void; +export function createDisposableListener(emitter: NodeJS.EventEmitter, signal: string, handler: (event: K, ...args: unknown[]) => unknown): Disposable; +export function createDisposableListener( + emitter: NodeJS.EventEmitter, signal: string, handler: (event: K, ...args: unknown[]) => unknown, collection?: DisposableCollection +): Disposable | void { + emitter.on(signal, handler); + const disposable = Disposable.create(() => { try { emitter.off(signal, handler); } catch { } }); + if (collection) { + collection.push(disposable); + } else { + return disposable; + } +} diff --git a/packages/core/src/electron-main/theia-electron-window.ts b/packages/core/src/electron-main/theia-electron-window.ts new file mode 100644 index 0000000000000..715cddf8c55cc --- /dev/null +++ b/packages/core/src/electron-main/theia-electron-window.ts @@ -0,0 +1,214 @@ +/******************************************************************************** + * Copyright (C) 2022 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 { FrontendApplicationConfig } from '@theia/application-package'; +import { FrontendApplicationState } from '../common/frontend-application-state'; +import { APPLICATION_STATE_CHANGE_SIGNAL, CLOSE_REQUESTED_SIGNAL, RELOAD_REQUESTED_SIGNAL, StopReason } from '../electron-common/messaging/electron-messages'; +import { BrowserWindow, BrowserWindowConstructorOptions, globalShortcut, ipcMain, IpcMainEvent } from '../../electron-shared/electron'; +import { inject, injectable, postConstruct } from '../../shared/inversify'; +import { ElectronMainApplicationGlobals } from './electron-main-constants'; +import { DisposableCollection, Emitter, Event, isWindows } from '../common'; +import { createDisposableListener } from './event-utils'; + +/** + * 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; +} + +export const TheiaBrowserWindowOptions = Symbol('TheiaBrowserWindowOptions'); + +export const WindowApplicationConfig = Symbol('WindowApplicationConfig'); +export type WindowApplicationConfig = FrontendApplicationConfig; + +@injectable() +export class TheiaElectronWindow { + @inject(TheiaBrowserWindowOptions) protected readonly options: TheiaBrowserWindowOptions; + @inject(WindowApplicationConfig) protected readonly config: WindowApplicationConfig; + @inject(ElectronMainApplicationGlobals) protected readonly globals: ElectronMainApplicationGlobals; + + protected onDidCloseEmitter = new Emitter(); + + get onDidClose(): Event { + return this.onDidCloseEmitter.event; + } + + protected readonly toDispose = new DisposableCollection(this.onDidCloseEmitter); + + protected _window: BrowserWindow; + get window(): BrowserWindow { + return this._window; + } + + protected closeIsConfirmed = false; + protected applicationState: FrontendApplicationState = 'init'; + + @postConstruct() + protected init(): void { + this._window = new BrowserWindow(this.options); + this._window.setMenuBarVisibility(false); + this.attachReadyToShow(); + this.attachGlobalShortcuts(); + this.restoreMaximizedState(); + this.attachCloseListeners(); + this.trackApplicationState(); + this.attachReloadListener(); + } + + /** + * Only show the window when the content is ready. + */ + protected attachReadyToShow(): void { + this._window.once('ready-to-show', () => this._window.show()); + } + + protected attachCloseListeners(): void { + createDisposableListener(this._window, 'closed', () => { + this.onDidCloseEmitter.fire(); + this.dispose(); + }, this.toDispose); + createDisposableListener(this._window, 'close', async event => { + // User has already indicated that it is OK to close this window, or the window is being closed before it's ready. + if (this.closeIsConfirmed || this.applicationState !== 'ready') { + return; + } + event.preventDefault(); + this.handleStopRequest(() => this.doCloseWindow(), StopReason.Close); + }, this.toDispose); + } + + protected doCloseWindow(): void { + this.closeIsConfirmed = true; + this._window.close(); + } + + /** + * Catch certain keybindings to prevent reloading the window using keyboard shortcuts. + */ + protected attachGlobalShortcuts(): void { + const handler = this.config.electron?.disallowReloadKeybinding + ? () => { } + : () => this.reload(); + const accelerators = ['CmdOrCtrl+R', 'F5']; + createDisposableListener(this._window, 'focus', () => { + for (const accelerator of accelerators) { + globalShortcut.register(accelerator, handler); + } + }, this.toDispose); + createDisposableListener(this._window, 'blur', () => { + for (const accelerator of accelerators) { + globalShortcut.unregister(accelerator); + } + }, this.toDispose); + } + + close(reason: StopReason = StopReason.Close): Promise { + return this.handleStopRequest(() => this.doCloseWindow(), reason); + } + + protected reload(): void { + this.handleStopRequest(() => { + this.applicationState = 'init'; + this._window.reload(); + }, StopReason.Reload); + } + + protected async handleStopRequest(onSafeCallback: () => unknown, reason: StopReason): Promise { + // Only confirm close to windows that have loaded our front end. + let currentUrl = this.window.webContents.getURL(); + let frontendUri = this.globals.THEIA_FRONTEND_HTML_PATH; + // Since our resolved frontend HTML path might contain backward slashes on Windows, we normalize everything first. + if (isWindows) { + currentUrl = currentUrl.replace(/\\/g, '/'); + frontendUri = frontendUri.replace(/\\/g, '/'); + } + const safeToClose = !currentUrl.includes(frontendUri) || await this.checkSafeToStop(reason); + if (safeToClose) { + try { + await onSafeCallback(); + return true; + } catch (e) { + console.warn(`Request ${StopReason[reason]} failed.`, e); + } + } + return false; + } + + protected checkSafeToStop(reason: StopReason): Promise { + const confirmChannel = `safe-to-close-${this._window.id}`; + const cancelChannel = `notSafeToClose-${this._window.id}`; + const temporaryDisposables = new DisposableCollection(); + return new Promise(resolve => { + this._window.webContents.send(CLOSE_REQUESTED_SIGNAL, { confirmChannel, cancelChannel, reason }); + createDisposableListener(ipcMain, confirmChannel, (e: IpcMainEvent) => { + if (this.isSender(e)) { + resolve(true); + } + }, temporaryDisposables); + createDisposableListener(ipcMain, cancelChannel, (e: IpcMainEvent) => { + if (this.isSender(e)) { + resolve(false); + } + }, temporaryDisposables); + }).finally(() => temporaryDisposables.dispose()); + } + + protected restoreMaximizedState(): void { + if (this.options.isMaximized) { + this._window.maximize(); + } else { + this._window.unmaximize(); + } + } + + protected trackApplicationState(): void { + createDisposableListener(ipcMain, APPLICATION_STATE_CHANGE_SIGNAL, (e: IpcMainEvent, state: FrontendApplicationState) => { + if (this.isSender(e)) { + this.applicationState = state; + } + }, this.toDispose); + } + + protected attachReloadListener(): void { + createDisposableListener(ipcMain, RELOAD_REQUESTED_SIGNAL, (e: IpcMainEvent) => { + if (this.isSender(e)) { + this.reload(); + } + }, this.toDispose); + } + + protected isSender(e: IpcMainEvent): boolean { + return BrowserWindow.fromId(e.sender.id) === this._window; + } + + dispose(): void { + this.toDispose.dispose(); + } +} + +export interface TheiaElectronWindowFactory { + (options: TheiaBrowserWindowOptions, config: FrontendApplicationConfig): TheiaElectronWindow; +} + +export const TheiaElectronWindowFactory = Symbol('TheiaElectronWindowFactory');