From 1f234a108f2fd660d4ffaef7f9485577b0392554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Mar=C3=A9chal?= Date: Tue, 29 Oct 2019 16:15:46 -0400 Subject: [PATCH 1/4] electron: use inversify in main process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All the logic for the electron main process currently has to be added to our generators, making it hard to extend without committing to Theia. This commit re-arranges the way Electron is launched to allow people to more easily change the behavior of their application. Add a basic CLI to open a workspace by doing `app path/to/workspace`. CLI can be overriden by application makers by extending and rebinding `ElectronApplication.launch` and handling yourself the `ExecutionParams`. Signed-off-by: Paul Maréchal Co-Authored-By: Akos Kitta --- CHANGELOG.md | 1 + .../src/generator/abstract-generator.ts | 4 + .../src/generator/frontend-generator.ts | 290 ++--------- .../src/application-package.ts | 12 + .../src/extension-package.ts | 1 + examples/electron/package.json | 1 + .../messaging/ws-connection-provider.ts | 91 +--- .../messaging/abstract-connection-provider.ts | 123 +++++ .../electron-ipc-connection-provider.ts | 50 ++ .../electron-messaging-frontend-module.ts | 2 + .../electron-ws-connection-provider.ts | 5 + .../window/electron-window-module.ts | 5 + .../window/electron-window-service.ts | 13 +- .../electron-window-service.ts | 23 + .../messaging/electron-connection-handler.ts | 30 ++ .../electron-application-module.ts | 51 ++ .../src/electron-main/electron-application.ts | 451 ++++++++++++++++++ .../electron-main/electron-native-keymap.ts | 40 ++ .../electron-window-service-impl.ts | 38 ++ .../electron-messaging-contribution.ts | 132 +++++ .../messaging/electron-messaging-service.ts | 40 ++ packages/core/src/node/cli.ts | 47 +- tsconfig.json | 2 +- 23 files changed, 1086 insertions(+), 366 deletions(-) create mode 100644 packages/core/src/common/messaging/abstract-connection-provider.ts create mode 100644 packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts create mode 100644 packages/core/src/electron-common/electron-window-service.ts create mode 100644 packages/core/src/electron-common/messaging/electron-connection-handler.ts create mode 100644 packages/core/src/electron-main/electron-application-module.ts create mode 100644 packages/core/src/electron-main/electron-application.ts create mode 100644 packages/core/src/electron-main/electron-native-keymap.ts create mode 100644 packages/core/src/electron-main/electron-window-service-impl.ts create mode 100644 packages/core/src/electron-main/messaging/electron-messaging-contribution.ts create mode 100644 packages/core/src/electron-main/messaging/electron-messaging-service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 958bc3f061195..c109bfa17bce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Breaking Changes: - [task] Widened the scope of some methods in TaskManager and TaskConfigurations from string to TaskConfigurationScope. This is only breaking for extenders, not callers. [#7928](https://github.com/eclipse-theia/theia/pull/7928) - [shell] `ApplicationShell.TrackableWidgetProvider.getTrackableWidgets` is sync to register child widgets in the same tick, use `ApplicationShell.TrackableWidgetProvider.onDidChangeTrackableWidgets` if child widgets are added async +- [electron] Electron applications can now be configured/extended through inversify. Added new `electronMain` theia extension points to provide inversify container modules. ## v1.2.0 diff --git a/dev-packages/application-manager/src/generator/abstract-generator.ts b/dev-packages/application-manager/src/generator/abstract-generator.ts index 2d449d94034d2..e1814d7641569 100644 --- a/dev-packages/application-manager/src/generator/abstract-generator.ts +++ b/dev-packages/application-manager/src/generator/abstract-generator.ts @@ -48,6 +48,10 @@ export abstract class AbstractGenerator { return this.compileModuleImports(modules, 'require'); } + protected compileElectronMainModuleImports(modules?: Map): string { + return modules && this.compileModuleImports(modules, 'require') || ''; + } + protected compileModuleImports(modules: Map, fn: 'import' | 'require'): string { if (modules.size === 0) { return ''; diff --git a/dev-packages/application-manager/src/generator/frontend-generator.ts b/dev-packages/application-manager/src/generator/frontend-generator.ts index 79c40fd6e933c..5e0c21cce2338 100644 --- a/dev-packages/application-manager/src/generator/frontend-generator.ts +++ b/dev-packages/application-manager/src/generator/frontend-generator.ts @@ -26,7 +26,8 @@ export class FrontendGenerator extends AbstractGenerator { await this.write(this.pck.frontend('index.html'), this.compileIndexHtml(frontendModules)); await this.write(this.pck.frontend('index.js'), this.compileIndexJs(frontendModules)); if (this.pck.isElectron()) { - await this.write(this.pck.frontend('electron-main.js'), this.compileElectronMain()); + const electronMainModules = this.pck.targetElectronMainModules; + await this.write(this.pck.frontend('electron-main.js'), this.compileElectronMain(electronMainModules)); } } @@ -112,9 +113,11 @@ module.exports = Promise.resolve()${this.compileFrontendModuleImports(frontendMo });`; } - protected compileElectronMain(): string { + protected compileElectronMain(electronMainModules?: Map): string { return `// @ts-check +require('reflect-metadata'); + // Useful for Electron/NW.js apps as GUI apps on macOS doesn't inherit the \`$PATH\` define // in your dotfiles (.bashrc/.bash_profile/.zshrc/etc). // https://github.com/electron/electron/issues/550#issuecomment-162037357 @@ -130,18 +133,14 @@ if (process.env.LC_ALL) { } process.env.LC_NUMERIC = 'C'; -const { v4 } = require('uuid'); -const electron = require('electron'); -const { join, resolve } = require('path'); -const { fork } = require('child_process'); -const { app, dialog, shell, BrowserWindow, ipcMain, Menu, globalShortcut } = electron; -const { ElectronSecurityToken } = require('@theia/core/lib/electron-common/electron-token'); +const { default: electronApplicationModule } = require('@theia/core/lib/electron-main/electron-application-module'); +const { ElectronApplication, ElectronApplicationGlobals } = require('@theia/core/lib/electron-main/electron-application'); +const { Container } = require('inversify'); +const { resolve } = require('path'); +const { app } = require('electron'); -const applicationName = \`${this.pck.props.frontend.config.applicationName}\`; +const config = ${this.prettyStringify(this.pck.props.frontend.config)}; const isSingleInstance = ${this.pck.props.backend.config.singleInstance === true ? 'true' : 'false'}; -const disallowReloadKeybinding = ${this.pck.props.frontend.config.electron?.disallowReloadKeybinding === true ? 'true' : 'false'}; -const defaultWindowOptionsAdditions = ${this.prettyStringify(this.pck.props.frontend.config.electron?.windowOptions || {})}; - if (isSingleInstance && !app.requestSingleInstanceLock()) { // There is another instance running, exit now. The other instance will request focus. @@ -149,255 +148,32 @@ if (isSingleInstance && !app.requestSingleInstanceLock()) { return; } -const nativeKeymap = require('native-keymap'); -const Storage = require('electron-store'); -const electronStore = new Storage(); - -const electronSecurityToken = { - value: v4(), -}; - -// Make it easy for renderer process to fetch the ElectronSecurityToken: -global[ElectronSecurityToken] = electronSecurityToken; - -app.on('ready', () => { - - // Explicitly set the app name to have better menu items on macOS. ("About", "Hide", and "Quit") - // See: https://github.com/electron-userland/electron-builder/issues/2468 - app.setName(applicationName); - - const { screen } = electron; - - // Remove the default electron menus, waiting for the application to set its own. - Menu.setApplicationMenu(Menu.buildFromTemplate([{ - role: 'help', submenu: [{ role: 'toggledevtools'}] - }])); - - function createNewWindow(theUrl) { - - // We must center by hand because \`browserWindow.center()\` fails on multi-screen setups - // See: https://github.com/electron/electron/issues/3490 - const { bounds } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); - const height = Math.floor(bounds.height * (2/3)); - const width = Math.floor(bounds.width * (2/3)); - - const y = Math.floor(bounds.y + (bounds.height - height) / 2); - const x = Math.floor(bounds.x + (bounds.width - width) / 2); - - const WINDOW_STATE = 'windowstate'; - const windowState = electronStore.get(WINDOW_STATE, { - width, height, x, y - }); - - const persistedWindowOptionsAdditions = electronStore.get('windowOptions', {}); - - const windowOptionsAdditions = { - ...defaultWindowOptionsAdditions, - ...persistedWindowOptionsAdditions - }; - - let windowOptions = { - show: false, - title: applicationName, - width: windowState.width, - height: windowState.height, - minWidth: 200, - minHeight: 120, - x: windowState.x, - y: windowState.y, - isMaximized: windowState.isMaximized, - ...windowOptionsAdditions - }; - - // Always hide the window, we will show the window when it is ready to be shown in any case. - const newWindow = new BrowserWindow(windowOptions); - if (windowOptions.isMaximized) { - newWindow.maximize(); - } - newWindow.on('ready-to-show', () => newWindow.show()); - if (disallowReloadKeybinding) { - newWindow.on('focus', event => { - for (const accelerator of ['CmdOrCtrl+R','F5']) { - globalShortcut.register(accelerator, () => {}); - } - }); - newWindow.on('blur', event => globalShortcut.unregisterAll()); - } - - // Prevent calls to "window.open" from opening an ElectronBrowser window, - // and rather open in the OS default web browser. - newWindow.webContents.on('new-window', (event, url) => { - event.preventDefault(); - shell.openExternal(url); - }); - - // Save the window geometry state on every change - const saveWindowState = () => { - try { - let bounds; - if (newWindow.isMaximized()) { - bounds = electronStore.get(WINDOW_STATE, {}); - } else { - bounds = newWindow.getBounds(); - } - electronStore.set(WINDOW_STATE, { - isMaximized: newWindow.isMaximized(), - width: bounds.width, - height: bounds.height, - x: bounds.x, - y: bounds.y - }); - } catch (e) { - console.error("Error while saving window state.", e); - } - }; - let delayedSaveTimeout; - const saveWindowStateDelayed = () => { - if (delayedSaveTimeout) { - clearTimeout(delayedSaveTimeout); - } - delayedSaveTimeout = setTimeout(saveWindowState, 1000); - }; - newWindow.on('close', saveWindowState); - newWindow.on('resize', saveWindowStateDelayed); - newWindow.on('move', saveWindowStateDelayed); - - // Fired when a beforeunload handler tries to prevent the page unloading - newWindow.webContents.on('will-prevent-unload', event => { - const preventStop = 0 !== dialog.showMessageBox(newWindow, { - type: 'question', - buttons: ['Yes', 'No'], - title: 'Confirm', - message: 'Are you sure you want to quit?', - detail: 'Any unsaved changes will not be saved.' - }); - - if (!preventStop) { - // This ignores the beforeunload callback, allowing the page to unload - event.preventDefault(); - } - }); - - // Notify the renderer process on keyboard layout change - nativeKeymap.onDidChangeKeyboardLayout(() => { - if (!newWindow.isDestroyed()) { - const newLayout = { - info: nativeKeymap.getCurrentKeyboardLayout(), - mapping: nativeKeymap.getKeyMap() - }; - newWindow.webContents.send('keyboardLayoutChanged', newLayout); - } - }); - - if (!!theUrl) { - newWindow.loadURL(theUrl); - } - return newWindow; - } - - app.on('window-all-closed', () => { - app.quit(); - }); - ipcMain.on('create-new-window', (event, url) => { - createNewWindow(url); - }); - ipcMain.on('open-external', (event, url) => { - shell.openExternal(url); - }); - ipcMain.on('set-window-options', (event, options) => { - electronStore.set('windowOptions', options); - }); - ipcMain.on('get-persisted-window-options-additions', event => { - event.returnValue = electronStore.get('windowOptions', {}); - }); +const container = new Container(); +container.load(electronApplicationModule); +container.bind(ElectronApplicationGlobals).toConstantValue({ + THEIA_APP_PROJECT_PATH: resolve(__dirname, '..', '..'), + THEIA_BACKEND_MAIN_PATH: resolve(__dirname, '..', 'backend', 'main.js'), + THEIA_FRONTEND_HTML_PATH: resolve(__dirname, '..', '..', 'lib', 'index.html'), +}); - // Check whether we are in bundled application or development mode. - // @ts-ignore - const devMode = process.defaultApp || /node_modules[\/]electron[\/]/.test(process.execPath); - // Check if we should run everything as one process. - const noBackendFork = process.argv.includes('--no-cluster'); - const mainWindow = createNewWindow(); - - if (isSingleInstance) { - app.on('second-instance', (event, commandLine, workingDirectory) => { - // Someone tried to run a second instance, we should focus our window. - if (mainWindow && !mainWindow.isDestroyed()) { - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.focus() - } - }) - } +function load(raw) { + return Promise.resolve(raw.default).then(module => + container.load(module) + ); +} - const setElectronSecurityToken = port => { - return new Promise((resolve, reject) => { - electron.session.defaultSession.cookies.set({ - url: \`http://localhost:\${port}/\`, - name: ElectronSecurityToken, - value: JSON.stringify(electronSecurityToken), - httpOnly: true, - }, error => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }) - } +async function start() { + const application = container.get(ElectronApplication); + await application.start(config); +} - const loadMainWindow = port => { - if (!mainWindow.isDestroyed()) { - mainWindow.loadURL('file://' + join(__dirname, '../../lib/index.html') + '?port=' + port); +module.exports = Promise.resolve()${this.compileElectronMainModuleImports(electronMainModules)} + .then(start).catch(reason => { + console.error('Failed to start the electron application.'); + if (reason) { + console.error(reason); } - }; - - // We cannot use the \`process.cwd()\` as the application project path (the location of the \`package.json\` in other words) - // in a bundled electron application because it depends on the way we start it. For instance, on OS X, these are a differences: - // https://github.com/eclipse-theia/theia/issues/3297#issuecomment-439172274 - process.env.THEIA_APP_PROJECT_PATH = resolve(__dirname, '..', '..'); - - // Set the electron version for both the dev and the production mode. (https://github.com/eclipse-theia/theia/issues/3254) - // Otherwise, the forked backend processes will not know that they're serving the electron frontend. - // The forked backend should patch its \`process.versions.electron\` with this value if it is missing. - process.env.THEIA_ELECTRON_VERSION = process.versions.electron; - - const mainPath = join(__dirname, '..', 'backend', 'main'); - // We spawn a separate process for the backend for Express to not run in the Electron main process. - // See: https://github.com/eclipse-theia/theia/pull/7361#issuecomment-601272212 - // But when in debugging we want to run everything in the same process to make things easier. - if (noBackendFork) { - process.env[ElectronSecurityToken] = JSON.stringify(electronSecurityToken); - require(mainPath).then(async (address) => { - await setElectronSecurityToken(address.port); - loadMainWindow(address.port); - }).catch((error) => { - console.error(error); - app.exit(1); - }); - } else { - // We want to pass flags passed to the Electron app to the backend process. - // Quirk: When developing from sources, we execute Electron as \`electron.exe electron-main.js ...args\`, but when bundled, - // the command looks like \`bundled-application.exe ...args\`. - const cp = fork(mainPath, process.argv.slice(devMode ? 2 : 1), { env: Object.assign({ - [ElectronSecurityToken]: JSON.stringify(electronSecurityToken), - }, process.env) }); - cp.on('message', async (address) => { - await setElectronSecurityToken(address.port); - loadMainWindow(address.port); - }); - cp.on('error', (error) => { - console.error(error); - app.exit(1); - }); - app.on('quit', () => { - // If we forked the process for the clusters, we need to manually terminate it. - // See: https://github.com/eclipse-theia/theia/issues/835 - process.kill(cp.pid); - }); - } -}); + }); `; } diff --git a/dev-packages/application-package/src/application-package.ts b/dev-packages/application-package/src/application-package.ts index e29c519d9100a..0824bf722aa5e 100644 --- a/dev-packages/application-package/src/application-package.ts +++ b/dev-packages/application-package/src/application-package.ts @@ -102,6 +102,7 @@ export class ApplicationPackage { protected _frontendElectronModules: Map | undefined; protected _backendModules: Map | undefined; protected _backendElectronModules: Map | undefined; + protected _electronMainModules: Map | undefined; protected _extensionPackages: ReadonlyArray | undefined; /** @@ -163,6 +164,13 @@ export class ApplicationPackage { return this._backendElectronModules; } + get electronMainModules(): Map { + if (!this._electronMainModules) { + this._electronMainModules = this.computeModules('electronMain'); + } + return this._electronMainModules; + } + protected computeModules

(primary: P, secondary?: S): Map { const result = new Map(); let moduleIndex = 1; @@ -238,6 +246,10 @@ export class ApplicationPackage { return this.ifBrowser(this.frontendModules, this.frontendElectronModules); } + get targetElectronMainModules(): Map { + return this.ifElectron(this.electronMainModules, new Map()); + } + setDependency(name: string, version: string | undefined): boolean { const dependencies = this.pck.dependencies || {}; const currentVersion = dependencies[name]; diff --git a/dev-packages/application-package/src/extension-package.ts b/dev-packages/application-package/src/extension-package.ts index 442e1d600ecf5..d48985382550c 100644 --- a/dev-packages/application-package/src/extension-package.ts +++ b/dev-packages/application-package/src/extension-package.ts @@ -24,6 +24,7 @@ export interface Extension { frontendElectron?: string; backend?: string; backendElectron?: string; + electronMain?: string; } export class ExtensionPackage { diff --git a/examples/electron/package.json b/examples/electron/package.json index c14ac18917b97..e2dddb1908728 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -21,6 +21,7 @@ "@theia/debug": "^1.2.0", "@theia/editor": "^1.2.0", "@theia/editor-preview": "^1.2.0", + "@theia/electron": "^1.2.0", "@theia/file-search": "^1.2.0", "@theia/filesystem": "^1.2.0", "@theia/getting-started": "^1.2.0", diff --git a/packages/core/src/browser/messaging/ws-connection-provider.ts b/packages/core/src/browser/messaging/ws-connection-provider.ts index d2a9b8a881444..83a367de72a44 100644 --- a/packages/core/src/browser/messaging/ws-connection-provider.ts +++ b/packages/core/src/browser/messaging/ws-connection-provider.ts @@ -15,11 +15,11 @@ ********************************************************************************/ import { injectable, interfaces, decorate, unmanaged } from 'inversify'; -import { createWebSocketConnection, Logger, ConsoleLogger } from 'vscode-ws-jsonrpc/lib'; -import { ConnectionHandler, JsonRpcProxyFactory, JsonRpcProxy, Emitter, Event } from '../../common'; +import { JsonRpcProxyFactory, JsonRpcProxy } from '../../common'; import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; import { Endpoint } from '../endpoint'; import ReconnectingWebSocket from 'reconnecting-websocket'; +import { AbstractConnectionProvider } from '../../common/messaging/abstract-connection-provider'; decorate(injectable(), JsonRpcProxyFactory); decorate(unmanaged(), JsonRpcProxyFactory, 0); @@ -32,33 +32,16 @@ export interface WebSocketOptions { } @injectable() -export class WebSocketConnectionProvider { +export class WebSocketConnectionProvider extends AbstractConnectionProvider { - /** - * Create a proxy object to remote interface of T type - * over a web socket connection for the given path and proxy factory. - */ - static createProxy(container: interfaces.Container, path: string, factory: JsonRpcProxyFactory): JsonRpcProxy; - /** - * Create a proxy object to remote interface of T type - * over a web socket connection for the given path. - * - * An optional target can be provided to handle - * notifications and requests from a remote side. - */ - static createProxy(container: interfaces.Container, path: string, target?: object): JsonRpcProxy; static createProxy(container: interfaces.Container, path: string, arg?: object): JsonRpcProxy { return container.get(WebSocketConnectionProvider).createProxy(path, arg); } - protected channelIdSeq = 0; protected readonly socket: ReconnectingWebSocket; - protected readonly channels = new Map(); - - protected readonly onIncomingMessageActivityEmitter: Emitter = new Emitter(); - public onIncomingMessageActivity: Event = this.onIncomingMessageActivityEmitter.event; constructor() { + super(); const url = this.createWebSocketUrl(WebSocketChannel.wsPath); const socket = this.createWebSocket(url); socket.onerror = console.error; @@ -68,54 +51,14 @@ export class WebSocketConnectionProvider { } }; socket.onmessage = ({ data }) => { - const message: WebSocketChannel.Message = JSON.parse(data); - const channel = this.channels.get(message.id); - if (channel) { - channel.handleMessage(message); - } else { - console.error('The ws channel does not exist', message.id); - } - this.onIncomingMessageActivityEmitter.fire(undefined); + this.handleIncomingRawMessage(data); }; this.socket = socket; } - /** - * Create a proxy object to remote interface of T type - * over a web socket connection for the given path and proxy factory. - */ - createProxy(path: string, factory: JsonRpcProxyFactory): JsonRpcProxy; - /** - * Create a proxy object to remote interface of T type - * over a web socket connection for the given path. - * - * An optional target can be provided to handle - * notifications and requests from a remote side. - */ - createProxy(path: string, target?: object): JsonRpcProxy; - createProxy(path: string, arg?: object): JsonRpcProxy { - const factory = arg instanceof JsonRpcProxyFactory ? arg : new JsonRpcProxyFactory(arg); - this.listen({ - path, - onConnection: c => factory.listen(c) - }); - return factory.createProxy(); - } - - /** - * Install a connection handler for the given path. - */ - listen(handler: ConnectionHandler, options?: WebSocketOptions): void { - this.openChannel(handler.path, channel => { - const connection = createWebSocketConnection(channel, this.createLogger()); - connection.onDispose(() => channel.close()); - handler.onConnection(connection); - }, options); - } - openChannel(path: string, handler: (channel: WebSocketChannel) => void, options?: WebSocketOptions): void { if (this.socket.readyState === WebSocket.OPEN) { - this.doOpenChannel(path, handler, options); + super.openChannel(path, handler, options); } else { const openChannel = () => { this.socket.removeEventListener('open', openChannel); @@ -125,24 +68,6 @@ export class WebSocketConnectionProvider { } } - protected doOpenChannel(path: string, handler: (channel: WebSocketChannel) => void, options?: WebSocketOptions): void { - const id = this.channelIdSeq++; - const channel = this.createChannel(id); - this.channels.set(id, channel); - channel.onClose(() => { - if (this.channels.delete(channel.id)) { - const { reconnecting } = { reconnecting: true, ...options }; - if (reconnecting) { - this.openChannel(path, handler, options); - } - } else { - console.error('The ws channel does not exist', channel.id); - } - }); - channel.onOpen(() => handler(channel)); - channel.open(path); - } - protected createChannel(id: number): WebSocketChannel { return new WebSocketChannel(id, content => { if (this.socket.readyState < WebSocket.CLOSING) { @@ -151,10 +76,6 @@ export class WebSocketConnectionProvider { }); } - protected createLogger(): Logger { - return new ConsoleLogger(); - } - /** * Creates a websocket URL to the current location */ diff --git a/packages/core/src/common/messaging/abstract-connection-provider.ts b/packages/core/src/common/messaging/abstract-connection-provider.ts new file mode 100644 index 0000000000000..f1f92186c5eb7 --- /dev/null +++ b/packages/core/src/common/messaging/abstract-connection-provider.ts @@ -0,0 +1,123 @@ +/******************************************************************************** + * 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 { injectable, interfaces } from 'inversify'; +import { ConsoleLogger, createWebSocketConnection, Logger } from 'vscode-ws-jsonrpc'; +import { Emitter, Event } from '../event'; +import { ConnectionHandler } from './handler'; +import { JsonRpcProxy, JsonRpcProxyFactory } from './proxy-factory'; +import { WebSocketChannel } from './web-socket-channel'; + +/** + * Factor common logic according to `ElectronIpcConnectionProvider` and + * `WebSocketConnectionProvider`. This class handles channels in a somewhat + * generic way. + */ +@injectable() +export abstract class AbstractConnectionProvider { + + /** + * Create a proxy object to remote interface of T type + * over an electron ipc connection for the given path and proxy factory. + */ + static createProxy(container: interfaces.Container, path: string, factory: JsonRpcProxyFactory): JsonRpcProxy; + /** + * Create a proxy object to remote interface of T type + * over an electron ipc connection for the given path. + * + * An optional target can be provided to handle + * notifications and requests from a remote side. + */ + static createProxy(container: interfaces.Container, path: string, target?: object): JsonRpcProxy; + static createProxy(container: interfaces.Container, path: string, arg?: object): JsonRpcProxy { + throw new Error('abstract'); + } + + protected channelIdSeq = 0; + protected readonly channels = new Map(); + + protected readonly onIncomingMessageActivityEmitter: Emitter = new Emitter(); + public onIncomingMessageActivity: Event = this.onIncomingMessageActivityEmitter.event; + + /** + * Create a proxy object to remote interface of T type + * over a web socket connection for the given path and proxy factory. + */ + createProxy(path: string, factory: JsonRpcProxyFactory): JsonRpcProxy; + /** + * Create a proxy object to remote interface of T type + * over a web socket connection for the given path. + * + * An optional target can be provided to handle + * notifications and requests from a remote side. + */ + createProxy(path: string, target?: object): JsonRpcProxy; + createProxy(path: string, arg?: object): JsonRpcProxy { + const factory = arg instanceof JsonRpcProxyFactory ? arg : new JsonRpcProxyFactory(arg); + this.listen({ + path, + onConnection: c => factory.listen(c) + }); + return factory.createProxy(); + } + + /** + * Install a connection handler for the given path. + */ + listen(handler: ConnectionHandler, options?: AbstractOptions): void { + this.openChannel(handler.path, channel => { + const connection = createWebSocketConnection(channel, this.createLogger()); + connection.onDispose(() => channel.close()); + handler.onConnection(connection); + }, options); + } + + openChannel(path: string, handler: (channel: WebSocketChannel) => void, options?: AbstractOptions): void { + const id = this.channelIdSeq++; + const channel = this.createChannel(id); + this.channels.set(id, channel); + channel.onClose(() => { + if (this.channels.delete(channel.id)) { + const { reconnecting } = { reconnecting: true, ...options }; + if (reconnecting) { + this.openChannel(path, handler, options); + } + } else { + console.error('The ws channel does not exist', channel.id); + } + }); + channel.onOpen(() => handler(channel)); + channel.open(path); + } + + protected abstract createChannel(id: number): WebSocketChannel; + + protected handleIncomingRawMessage(data: string): void { + const message: WebSocketChannel.Message = JSON.parse(data); + const channel = this.channels.get(message.id); + if (channel) { + channel.handleMessage(message); + } else { + console.error('The ws channel does not exist', message.id); + } + this.onIncomingMessageActivityEmitter.fire(undefined); + } + + protected createLogger(): Logger { + return new ConsoleLogger(); + } + +} diff --git a/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts b/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts new file mode 100644 index 0000000000000..ecd88f72605f1 --- /dev/null +++ b/packages/core/src/electron-browser/messaging/electron-ipc-connection-provider.ts @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (C) 2018 TypeFox 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 { injectable, interfaces } from 'inversify'; +import { Event as ElectronEvent, ipcRenderer } from 'electron'; +import { JsonRpcProxy } from '../../common/messaging'; +import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; +import { AbstractConnectionProvider } from '../../common/messaging/abstract-connection-provider'; +import { THEIA_ELECTRON_IPC_CHANNEL_NAME } from '../../electron-common/messaging/electron-connection-handler'; + +export interface ElectronIpcOptions { +} + +/** + * Connection provider between the Theia frontend and the electron-main process via IPC. + */ +@injectable() +export class ElectronIpcConnectionProvider extends AbstractConnectionProvider { + + static createProxy(container: interfaces.Container, path: string, arg?: object): JsonRpcProxy { + return container.get(ElectronIpcConnectionProvider).createProxy(path, arg); + } + + constructor() { + super(); + ipcRenderer.on(THEIA_ELECTRON_IPC_CHANNEL_NAME, (event: ElectronEvent, data: string) => { + this.handleIncomingRawMessage(data); + }); + } + + protected createChannel(id: number): WebSocketChannel { + return new WebSocketChannel(id, content => { + ipcRenderer.send(THEIA_ELECTRON_IPC_CHANNEL_NAME, content); + }); + } + +} diff --git a/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts b/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts index 6f8db65b589db..ff4c4524851a5 100644 --- a/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts +++ b/packages/core/src/electron-browser/messaging/electron-messaging-frontend-module.ts @@ -18,9 +18,11 @@ import { ContainerModule } from 'inversify'; import { FrontendApplicationContribution } from '../../browser/frontend-application'; import { WebSocketConnectionProvider } from '../../browser/messaging/ws-connection-provider'; import { ElectronWebSocketConnectionProvider } from './electron-ws-connection-provider'; +import { ElectronIpcConnectionProvider } from './electron-ipc-connection-provider'; export const messagingFrontendModule = new ContainerModule(bind => { bind(ElectronWebSocketConnectionProvider).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(ElectronWebSocketConnectionProvider); bind(WebSocketConnectionProvider).toService(ElectronWebSocketConnectionProvider); + bind(ElectronIpcConnectionProvider).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts b/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts index 2bba1570874c3..b0620be047843 100644 --- a/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts +++ b/packages/core/src/electron-browser/messaging/electron-ws-connection-provider.ts @@ -19,6 +19,11 @@ import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; import { WebSocketConnectionProvider, WebSocketOptions } from '../../browser/messaging/ws-connection-provider'; import { FrontendApplicationContribution } from '../../browser/frontend-application'; +/** + * Customized connection provider between the frontend and the backend in electron environment. + * This customized connection provider makes sure the websocket connection does not try to reconnect + * once the electron-browser window is refreshed. Otherwise, backend resources are not disposed. + */ @injectable() export class ElectronWebSocketConnectionProvider extends WebSocketConnectionProvider implements FrontendApplicationContribution { 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 edad94a14c24e..5fffc7f24ba79 100644 --- a/packages/core/src/electron-browser/window/electron-window-module.ts +++ b/packages/core/src/electron-browser/window/electron-window-module.ts @@ -20,8 +20,13 @@ import { ElectronWindowService } from './electron-window-service'; import { FrontendApplicationContribution } from '../../browser/frontend-application'; import { ElectronClipboardService } from '../electron-clipboard-service'; import { ClipboardService } from '../../browser/clipboard-service'; +import { ElectronWindowService as ElectronMainWindowService, electronMainWindowServicePath } from '../../electron-common/electron-window-service'; +import { ElectronIpcConnectionProvider } from '../messaging/electron-ipc-connection-provider'; export default new ContainerModule(bind => { + bind(ElectronMainWindowService).toDynamicValue(context => + ElectronIpcConnectionProvider.createProxy(context.container, electronMainWindowServicePath) + ).inSingletonScope(); bind(WindowService).to(ElectronWindowService).inSingletonScope(); bind(FrontendApplicationContribution).toService(WindowService); bind(ClipboardService).to(ElectronClipboardService).inSingletonScope(); diff --git a/packages/core/src/electron-browser/window/electron-window-service.ts b/packages/core/src/electron-browser/window/electron-window-service.ts index a8c71e761b705..94a10e5e51550 100644 --- a/packages/core/src/electron-browser/window/electron-window-service.ts +++ b/packages/core/src/electron-browser/window/electron-window-service.ts @@ -14,20 +14,19 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; -import { ipcRenderer } from 'electron'; +import { injectable, inject } from 'inversify'; import { NewWindowOptions } from '../../browser/window/window-service'; import { DefaultWindowService } from '../../browser/window/default-window-service'; +import { ElectronWindowService as ElectronMainWindowService } from '../../electron-common/electron-window-service'; @injectable() export class ElectronWindowService extends DefaultWindowService { + @inject(ElectronMainWindowService) + protected readonly delegate: ElectronMainWindowService; + openNewWindow(url: string, { external }: NewWindowOptions = {}): undefined { - if (external) { - ipcRenderer.send('open-external', url); - } else { - ipcRenderer.send('create-new-window', url); - } + this.delegate.openNewWindow(url, { external }); return undefined; } diff --git a/packages/core/src/electron-common/electron-window-service.ts b/packages/core/src/electron-common/electron-window-service.ts new file mode 100644 index 0000000000000..36141c3e15335 --- /dev/null +++ b/packages/core/src/electron-common/electron-window-service.ts @@ -0,0 +1,23 @@ +/******************************************************************************** + * 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 { NewWindowOptions } from '../browser/window/window-service'; + +export const electronMainWindowServicePath = '/services/electron-window'; +export const ElectronWindowService = Symbol('ElectronWindowService'); +export interface ElectronWindowService { + openNewWindow(url: string, options?: NewWindowOptions): undefined; +} diff --git a/packages/core/src/electron-common/messaging/electron-connection-handler.ts b/packages/core/src/electron-common/messaging/electron-connection-handler.ts new file mode 100644 index 0000000000000..267f4ee26be17 --- /dev/null +++ b/packages/core/src/electron-common/messaging/electron-connection-handler.ts @@ -0,0 +1,30 @@ +/******************************************************************************** + * 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 { ConnectionHandler } from '../../common/messaging/handler'; + +/** + * Name of the channel used with `ipcMain.on/emit`. + */ +export const THEIA_ELECTRON_IPC_CHANNEL_NAME = 'theia-electron-ipc'; + +/** + * Electron-IPC-specific connection handler. + * Use this if you want to establish communication between the frontend and the electron-main process. + */ +export const ElectronConnectionHandler = Symbol('ElectronConnectionHandler'); +export interface ElectronConnectionHandler extends ConnectionHandler { +} diff --git a/packages/core/src/electron-main/electron-application-module.ts b/packages/core/src/electron-main/electron-application-module.ts new file mode 100644 index 0000000000000..ea361f6077001 --- /dev/null +++ b/packages/core/src/electron-main/electron-application-module.ts @@ -0,0 +1,51 @@ +/******************************************************************************** + * 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 { ContainerModule } from 'inversify'; +import { v4 } from 'uuid'; +import { bindContributionProvider } from '../common/contribution-provider'; +import { JsonRpcConnectionHandler } from '../common/messaging/proxy-factory'; +import { ElectronSecurityToken } from '../electron-common/electron-token'; +import { ElectronWindowService, electronMainWindowServicePath } from '../electron-common/electron-window-service'; +import { ElectronApplication, ElectronMainContribution, ProcessArgv } from './electron-application'; +import { ElectronWindowServiceImpl } from './electron-window-service-impl'; +import { ElectronMessagingContribution } from './messaging/electron-messaging-contribution'; +import { ElectronMessagingService } from './messaging/electron-messaging-service'; +import { ElectronConnectionHandler } from '../electron-common/messaging/electron-connection-handler'; + +const electronSecurityToken: ElectronSecurityToken = { value: v4() }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any)[ElectronSecurityToken] = electronSecurityToken; + +export default new ContainerModule(bind => { + bind(ElectronApplication).toSelf().inSingletonScope(); + bind(ElectronMessagingContribution).toSelf().inSingletonScope(); + bind(ElectronSecurityToken).toConstantValue(electronSecurityToken); + + bindContributionProvider(bind, ElectronConnectionHandler); + bindContributionProvider(bind, ElectronMessagingService.Contribution); + bindContributionProvider(bind, ElectronMainContribution); + + bind(ElectronMainContribution).toService(ElectronMessagingContribution); + + bind(ElectronWindowService).to(ElectronWindowServiceImpl).inSingletonScope(); + bind(ElectronConnectionHandler).toDynamicValue(context => + new JsonRpcConnectionHandler(electronMainWindowServicePath, + () => context.container.get(ElectronWindowService)) + ).inSingletonScope(); + + bind(ProcessArgv).toSelf().inSingletonScope(); +}); diff --git a/packages/core/src/electron-main/electron-application.ts b/packages/core/src/electron-main/electron-application.ts new file mode 100644 index 0000000000000..35e76d3d35e8d --- /dev/null +++ b/packages/core/src/electron-main/electron-application.ts @@ -0,0 +1,451 @@ +/******************************************************************************** + * 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 { session, screen, globalShortcut, app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent, shell, dialog } from '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 { ElectronSecurityToken } from '../electron-common/electron-token'; +const Storage = require('electron-store'); +const createYargs: (argv?: string[], cwd?: string) => Argv = require('yargs/yargs'); + +/** + * Options passed to the main/default command handler. + */ +export interface MainCommandOptions { + + /** + * 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 ExecutionParams { + readonly secondInstance: boolean; + readonly argv: string[]; + readonly cwd: string; +} + +export const ElectronApplicationGlobals = Symbol('ElectronApplicationSettings'); +export interface ElectronApplicationGlobals { + readonly THEIA_APP_PROJECT_PATH: string + readonly THEIA_BACKEND_MAIN_PATH: string + readonly THEIA_FRONTEND_HTML_PATH: string +} + +export const ElectronMainContribution = Symbol('ElectronApplicationContribution'); +export interface ElectronMainContribution { + /** + * The application is ready and is starting. This is the time to initialize + * services global to this process. + * + * This event is fired when the process starts for the first time. + */ + onStart?(application?: ElectronApplication): MaybePromise; + /** + * The application is stopping. Contributions must perform only synchronous operations. + */ + onStop?(application?: ElectronApplication): void; +} + +// Extracted the functionality from `yargs@15.4.0-beta.0`. +// Based on https://github.com/yargs/yargs/blob/522b019c9a50924605986a1e6e0cb716d47bcbca/lib/process-argv.ts +@injectable() +export class ProcessArgv { + + protected get processArgvBinIndex(): number { + // The binary name is the first command line argument for: + // - bundled Electron apps: bin argv1 argv2 ... argvn + if (this.isBundledElectronApp) { + return 0; + } + // or the second one (default) for: + // - standard node apps: node bin.js argv1 argv2 ... argvn + // - unbundled Electron apps: electron bin.js argv1 arg2 ... argvn + return 1; + } + + protected get isBundledElectronApp(): boolean { + // process.defaultApp is either set by electron in an electron unbundled app, or undefined + // see https://github.com/electron/electron/blob/master/docs/api/process.md#processdefaultapp-readonly + return this.isElectronApp && !(process as ProcessArgv.ElectronProcess).defaultApp; + } + + protected get isElectronApp(): boolean { + // process.versions.electron is either set by electron, or undefined + // see https://github.com/electron/electron/blob/master/docs/api/process.md#processversionselectron-readonly + return !!(process as ProcessArgv.ElectronProcess).versions.electron; + } + + get processArgvWithoutBin(): Array { + return process.argv.slice(this.processArgvBinIndex + 1); + } + + get ProcessArgvBin(): string { + return process.argv[this.processArgvBinIndex]; + } + +} + +export namespace ProcessArgv { + export interface ElectronProcess extends NodeJS.Process { + readonly defaultApp?: boolean; + readonly versions: NodeJS.ProcessVersions & { + readonly electron: string; + }; + } +} + +@injectable() +export class ElectronApplication { + + @inject(ContributionProvider) @named(ElectronMainContribution) + protected readonly electronApplicationContributions: ContributionProvider; + + @inject(ElectronApplicationGlobals) + protected readonly globals: ElectronApplicationGlobals; + + @inject(ElectronSecurityToken) + protected electronSecurityToken: ElectronSecurityToken; + + @inject(ProcessArgv) + protected processArgv: ProcessArgv; + + protected readonly electronStore = new Storage(); + + protected config: FrontendApplicationConfig; + readonly backendPort = new Deferred(); + + async start(config: FrontendApplicationConfig): Promise { + 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.processArgvWithoutBin, + cwd: process.cwd() + }); + } + + async launch(params: ExecutionParams): Promise { + createYargs(params.argv, params.cwd) + .command('$0 []', false, + cmd => cmd + .positional('file', { type: 'string' }), + args => this.handleMainCommand(params, { file: args.file }), + ).parse(); + } + + /** + * Use this rather than creating `BrowserWindow` instances from scratch, since some security parameters need to be set, this method will do it. + * + * @param options + */ + async createWindow(options: BrowserWindowConstructorOptions): Promise { + const electronWindow = new BrowserWindow(options); + this.attachReadyToShow(electronWindow); + this.attachWebContentsNewWindow(electronWindow); + this.attachSaveWindowState(electronWindow); + this.attachWillPreventUnload(electronWindow); + this.attachGlobalShortcuts(electronWindow); + return electronWindow; + } + + async openDefaultWindow(): Promise { + const [uri, electronWindow] = await Promise.all([ + this.createWindowUri(), + this.createWindow(this.getBrowserWindowOptions()) + ]); + electronWindow.loadURL(uri.toString(true)); + return electronWindow; + } + + async openWindowWithWorkspace(url: string): Promise { + const electronWindow = await this.createWindow(this.getBrowserWindowOptions()); + electronWindow.loadURL(url); + return electronWindow; + } + + /** + * "Gently" close all windows, application will not stop if a `beforeunload` handler returns `false`. + */ + requestStop(): void { + app.quit(); + } + + protected async handleMainCommand(params: ExecutionParams, options: MainCommandOptions): Promise { + if (typeof options.file === 'undefined') { + await this.openDefaultWindow(); + } else { + let workspacePath: string | undefined = undefined; + 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 { + const uri = (await this.createWindowUri()).withFragment(workspacePath); + await this.openWindowWithWorkspace(uri.toString(true)); + } + } + } + + protected async createWindowUri(): Promise { + const port = await this.backendPort.promise; + return FileUri.create(this.globals.THEIA_FRONTEND_HTML_PATH).withQuery(`port=${port}`); + } + + protected getBrowserWindowOptions(): BrowserWindowConstructorOptions { + let windowState: BrowserWindowConstructorOptions | undefined = this.electronStore.get('windowstate', undefined); + if (typeof windowState === 'undefined') { + windowState = this.getDefaultWindowState(); + } + return { + ...windowState, + show: false, + title: this.config.applicationName, + minWidth: 200, + minHeight: 120, + }; + } + + protected getDefaultWindowState(): BrowserWindowConstructorOptions { + // The `screen` API must be required when the application is ready. + // See: https://electronjs.org/docs/api/screen#screen + // We must center by hand because `browserWindow.center()` fails on multi-screen setups + // See: https://github.com/electron/electron/issues/3490 + const { bounds } = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const height = Math.floor(bounds.height * (2 / 3)); + const width = Math.floor(bounds.width * (2 / 3)); + const y = Math.floor(bounds.y + (bounds.height - height) / 2); + const x = Math.floor(bounds.x + (bounds.width - width) / 2); + return { width, height, x, y }; + } + + /** + * Prevent opening links into new electron browser windows by default. + */ + protected attachWebContentsNewWindow(electronWindow: BrowserWindow): void { + electronWindow.webContents.on('new-window', (event, url) => { + event.preventDefault(); + shell.openExternal(url); + }); + } + + /** + * 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 saveWindowState = () => { + try { + let bounds; + if (electronWindow.isMaximized()) { + bounds = this.electronStore.get('windowstate', {}); + } else { + bounds = electronWindow.getBounds(); + } + this.electronStore.set('windowstate', { + isMaximized: electronWindow.isMaximized(), + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y + }); + } catch (e) { + console.error('Error while saving window state:', e); + } + }; + let delayedSaveTimeout: NodeJS.Timer | undefined; + const saveWindowStateDelayed = () => { + if (delayedSaveTimeout) { + clearTimeout(delayedSaveTimeout); + } + delayedSaveTimeout = setTimeout(saveWindowState, 1000); + }; + electronWindow.on('close', saveWindowState); + electronWindow.on('resize', saveWindowStateDelayed); + electronWindow.on('move', saveWindowStateDelayed); + } + + /** + * Catch window closing event and display a confirmation window. + */ + protected attachWillPreventUnload(electronWindow: BrowserWindow): void { + // Fired when a beforeunload handler tries to prevent the page unloading + electronWindow.webContents.on('will-prevent-unload', event => { + const preventStop = 0 !== dialog.showMessageBox(electronWindow, { + type: 'question', + buttons: ['Yes', 'No'], + title: 'Confirm', + message: 'Are you sure you want to quit?', + detail: 'Any unsaved changes will not be saved.' + }); + + if (!preventStop) { + // This ignores the beforeunload callback, allowing the page to unload + event.preventDefault(); + } + }); + } + + /** + * Catch certain keybindings to prevent reloading the window using keyboard shortcuts. + */ + protected attachGlobalShortcuts(electronWindow: BrowserWindow): void { + if (this.config.electron?.disallowReloadKeybinding) { + const accelerators = ['CmdOrCtrl+R', 'F5']; + electronWindow.on('focus', () => { + for (const accelerator of accelerators) { + globalShortcut.register(accelerator, () => { }); + } + }); + electronWindow.on('blur', () => { + for (const accelerator of accelerators) { + globalShortcut.unregister(accelerator); + } + }); + } + } + + /** + * Start the NodeJS backend server. + * + * @return Running server's port promise. + */ + protected async startBackend(): Promise { + // Check if we should run everything as one process. + const noBackendFork = process.argv.indexOf('--no-cluster') !== -1; + // Any flag/argument passed after `--` will be forwarded to the backend process. + const backendArgvMarkerIndex = process.argv.indexOf('--'); + const backendArgv = backendArgvMarkerIndex === -1 ? [] : process.argv.slice(backendArgvMarkerIndex + 1); + // We cannot use the `process.cwd()` as the application project path (the location of the `package.json` in other words) + // in a bundled electron application because it depends on the way we start it. For instance, on OS X, these are a differences: + // https://github.com/eclipse-theia/theia/issues/3297#issuecomment-439172274 + process.env.THEIA_APP_PROJECT_PATH = this.globals.THEIA_APP_PROJECT_PATH; + // Set the electron version for both the dev and the production mode. (https://github.com/eclipse-theia/theia/issues/3254) + // Otherwise, the forked backend processes will not know that they're serving the electron frontend. + process.env.THEIA_ELECTRON_VERSION = process.versions.electron; + if (noBackendFork) { + process.env[ElectronSecurityToken] = JSON.stringify(this.electronSecurityToken); + // The backend server main file is supposed to export a promise resolving with the port used by the http(s) server. + const address: AddressInfo = await require(this.globals.THEIA_BACKEND_MAIN_PATH); + return address.port; + } else { + const backendProcess = fork(this.globals.THEIA_BACKEND_MAIN_PATH, backendArgv, await this.getForkOptions()); + return new Promise((resolve, reject) => { + // The backend server main file is also supposed to send the resolved http(s) server port via IPC. + backendProcess.on('message', (address: AddressInfo) => { + resolve(address.port); + }); + backendProcess.on('error', error => { + reject(error); + }); + app.on('quit', () => { + // If we forked the process for the clusters, we need to manually terminate it. + // See: https://github.com/eclipse-theia/theia/issues/835 + process.kill(backendProcess.pid); + }); + }); + } + } + + protected async getForkOptions(): Promise { + return { + env: { + ...process.env, + [ElectronSecurityToken]: JSON.stringify(this.electronSecurityToken), + }, + }; + } + + protected async attachElectronSecurityToken(port: number): Promise { + await new Promise((resolve, reject) => { + session.defaultSession!.cookies.set({ + url: `http://localhost:${port}`, + name: ElectronSecurityToken, + value: JSON.stringify(this.electronSecurityToken), + httpOnly: true, + }, error => error ? reject(error) : resolve()); + }); + } + + protected hookApplicationEvents(): void { + 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)); + } + + protected onWillQuit(event: ElectronEvent): void { + this.stopContributions(); + } + + protected async onSecondInstance(event: ElectronEvent, argv: string[], cwd: string): Promise { + await this.launch({ argv, cwd, secondInstance: true }); + } + + protected onWindowAllClosed(event: ElectronEvent): void { + this.requestStop(); + } + + protected async startContributions(): Promise { + const promises = []; + for (const contribution of this.electronApplicationContributions.getContributions()) { + if (contribution.onStart) { + promises.push(contribution.onStart(this)); + } + } + await Promise.all(promises); + } + + protected stopContributions(): void { + for (const contribution of this.electronApplicationContributions.getContributions()) { + if (contribution.onStop) { + contribution.onStop(this); + } + } + } + +} diff --git a/packages/core/src/electron-main/electron-native-keymap.ts b/packages/core/src/electron-main/electron-native-keymap.ts new file mode 100644 index 0000000000000..1f906b5ee8faf --- /dev/null +++ b/packages/core/src/electron-main/electron-native-keymap.ts @@ -0,0 +1,40 @@ +/******************************************************************************** + * 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 { webContents } from 'electron'; +import { injectable } from 'inversify'; +import { ElectronMainContribution } from './electron-application'; +const nativeKeymap = require('native-keymap'); + +@injectable() +export class ElectronNativeKeymap implements ElectronMainContribution { + + /** + * Notify all renderer processes on keyboard layout change. + */ + onStart(): void { + nativeKeymap.onDidChangeKeyboardLayout(() => { + const newLayout = { + info: nativeKeymap.getCurrentKeyboardLayout(), + mapping: nativeKeymap.getKeyMap() + }; + for (const webContent of webContents.getAllWebContents()) { + webContent.send('keyboardLayoutChanged', newLayout); + } + }); + } + +} diff --git a/packages/core/src/electron-main/electron-window-service-impl.ts b/packages/core/src/electron-main/electron-window-service-impl.ts new file mode 100644 index 0000000000000..489e13ab120f3 --- /dev/null +++ b/packages/core/src/electron-main/electron-window-service-impl.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * 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 { shell } from 'electron'; +import { injectable, inject } from 'inversify'; +import { ElectronWindowService } from '../electron-common/electron-window-service'; +import { ElectronApplication } from './electron-application'; +import { NewWindowOptions } from '../browser/window/window-service'; + +@injectable() +export class ElectronWindowServiceImpl implements ElectronWindowService { + + @inject(ElectronApplication) + protected readonly app: ElectronApplication; + + openNewWindow(url: string, { external }: NewWindowOptions): undefined { + if (!!external) { + shell.openExternal(url); + } else { + this.app.openWindowWithWorkspace(url); + } + return undefined; + } + +} diff --git a/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts new file mode 100644 index 0000000000000..273e02baafee0 --- /dev/null +++ b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts @@ -0,0 +1,132 @@ +/******************************************************************************** + * 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 { Event as ElectronEvent, ipcMain, WebContents } from 'electron'; +import { inject, injectable, named, postConstruct } from 'inversify'; +import { MessageConnection } from 'vscode-jsonrpc'; +import { createWebSocketConnection } from 'vscode-ws-jsonrpc/lib/socket/connection'; +import { ContributionProvider } from '../../common/contribution-provider'; +import { WebSocketChannel } from '../../common/messaging/web-socket-channel'; +import { MessagingContribution } from '../../node/messaging/messaging-contribution'; +import { ConsoleLogger } from '../../node/messaging/logger'; +import { THEIA_ELECTRON_IPC_CHANNEL_NAME } from '../../electron-common/messaging/electron-connection-handler'; +import { ElectronMainContribution } from '../electron-application'; +import { ElectronMessagingService } from './electron-messaging-service'; +import { ElectronConnectionHandler } from '../../electron-common/messaging/electron-connection-handler'; + +/** + * This component replicates the role filled by `MessagingContribution` but for Electron. + * Unlike the WebSocket based implementation, we do not expect to receive + * connection events. Instead, we'll create channels based on incoming `open` + * events on the `ipcMain` channel. + * + * This component allows communication between renderer process (frontend) and electron main process. + */ +@injectable() +export class ElectronMessagingContribution implements ElectronMainContribution, ElectronMessagingService { + + @inject(ContributionProvider) @named(ElectronMessagingService.Contribution) + protected readonly messagingContributions: ContributionProvider; + + @inject(ContributionProvider) @named(ElectronConnectionHandler) + protected readonly connectionHandlers: ContributionProvider; + + protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers(); + protected readonly windowChannels = new Map>(); + + @postConstruct() + protected init(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcMain.on(THEIA_ELECTRON_IPC_CHANNEL_NAME, (event: ElectronEvent, data: string) => { + this.handleIpcMessage(event, data); + }); + } + + onStart(): void { + for (const contribution of this.messagingContributions.getContributions()) { + contribution.configure(this); + } + for (const connectionHandler of this.connectionHandlers.getContributions()) { + this.channelHandlers.push(connectionHandler.path, (params, channel) => { + const connection = createWebSocketConnection(channel, new ConsoleLogger()); + connectionHandler.onConnection(connection); + }); + } + } + + listen(spec: string, callback: (params: ElectronMessagingService.PathParams, connection: MessageConnection) => void): void { + this.ipcChannel(spec, (params, channel) => { + const connection = createWebSocketConnection(channel, new ConsoleLogger()); + callback(params, connection); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcChannel(spec: string, callback: (params: any, channel: WebSocketChannel) => void): void { + this.channelHandlers.push(spec, callback); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected handleIpcMessage(event: ElectronEvent, data: string): void { + const sender = event.sender; + try { + // Get the channel map for a given window id + let channels = this.windowChannels.get(sender.id)!; + if (!channels) { + this.windowChannels.set(sender.id, channels = new Map()); + } + // Start parsing the message to extract the channel id and route + const message: WebSocketChannel.Message = JSON.parse(data.toString()); + // Someone wants to open a logical channel + if (message.kind === 'open') { + const { id, path } = message; + const channel = this.createChannel(id, sender); + if (this.channelHandlers.route(path, channel)) { + channel.ready(); + channels.set(id, channel); + channel.onClose(() => channels.delete(id)); + } else { + console.error('Cannot find a service for the path: ' + path); + } + } else { + const { id } = message; + const channel = channels.get(id); + if (channel) { + channel.handleMessage(message); + } else { + console.error('The ipc channel does not exist', id); + } + } + sender.on('destroyed', () => { + for (const channel of Array.from(channels.values())) { + channel.close(undefined, 'webContent destroyed'); + } + channels.clear(); + }); + } catch (error) { + console.error('IPC: Failed to handle message', { error, data }); + } + } + + protected createChannel(id: number, sender: WebContents): WebSocketChannel { + return new WebSocketChannel(id, content => { + if (!sender.isDestroyed()) { + sender.send(THEIA_ELECTRON_IPC_CHANNEL_NAME, content); + } + }); + } + +} diff --git a/packages/core/src/electron-main/messaging/electron-messaging-service.ts b/packages/core/src/electron-main/messaging/electron-messaging-service.ts new file mode 100644 index 0000000000000..41fe58b843f69 --- /dev/null +++ b/packages/core/src/electron-main/messaging/electron-messaging-service.ts @@ -0,0 +1,40 @@ +/******************************************************************************** + * 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 type { MessageConnection } from 'vscode-jsonrpc'; +import type { WebSocketChannel } from '../../common/messaging/web-socket-channel'; + +export interface ElectronMessagingService { + /** + * Accept a JSON-RPC connection on the given path. + * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. + */ + listen(path: string, callback: (params: ElectronMessagingService.PathParams, connection: MessageConnection) => void): void; + /** + * Accept an ipc channel on the given path. + * A path supports the route syntax: https://github.com/rcs/route-parser#what-can-i-use-in-my-routes. + */ + ipcChannel(path: string, callback: (params: ElectronMessagingService.PathParams, socket: WebSocketChannel) => void): void; +} +export namespace ElectronMessagingService { + export interface PathParams { + [name: string]: string + } + export const Contribution = Symbol('ElectronMessagingService.Contribution'); + export interface Contribution { + configure(service: ElectronMessagingService): void; + } +} diff --git a/packages/core/src/node/cli.ts b/packages/core/src/node/cli.ts index b2d2b7a54a738..35dd28417a1df 100644 --- a/packages/core/src/node/cli.ts +++ b/packages/core/src/node/cli.ts @@ -25,32 +25,47 @@ export const CliContribution = Symbol('CliContribution'); * Call back for extension to contribute options to the cli. */ export interface CliContribution { - configure(conf: yargs.Argv): void; + /** + * Configure the `yargs.Argv` parser with your options. + */ + configure(conf: yargs.Argv): MaybePromise; + /** + * Fetch the parsed options. + */ setArguments(args: yargs.Arguments): MaybePromise; } @injectable() export class CliManager { - constructor(@inject(ContributionProvider) @named(CliContribution) - protected readonly contributionsProvider: ContributionProvider) { } + protected readonly parser: yargs.Argv; - async initializeCli(argv: string[]): Promise { + constructor( + @inject(ContributionProvider) @named(CliContribution) + protected readonly contributionsProvider: ContributionProvider, + ) { const pack = require('../../package.json'); - const version = pack.version; - const command = yargs.version(version); - command.exitProcess(this.isExit()); - for (const contrib of this.contributionsProvider.getContributions()) { - contrib.configure(command); - } - const args = command + this.parser = yargs + .version(pack.version) + .exitProcess(this.isExit()) .detectLocale(false) .showHelpOnFail(false, 'Specify --help for available options') - .help('help') - .parse(argv); - for (const contrib of this.contributionsProvider.getContributions()) { - await contrib.setArguments(args); - } + .help('help'); + } + + async parse(argv: string[]): Promise { + return this.parser.parse(argv); + } + + async initializeCli(argv: string[]): Promise { + const contributions = Array.from(this.contributionsProvider.getContributions()); + await Promise.all(contributions.map( + contrib => contrib.configure(this.parser), + )); + const args = await this.parse(argv); + await Promise.all(contributions.map( + contrib => contrib.setArguments(args), + )); } protected isExit(): boolean { diff --git a/tsconfig.json b/tsconfig.json index 4445c3b8712ac..058750263137e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -167,4 +167,4 @@ ] } } -} \ No newline at end of file +} From f4439713f92dc471309bf72ac5715381fe7a4135 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Sun, 21 Jun 2020 15:50:41 +0200 Subject: [PATCH 2/4] Added a dummy electron-updater sample. Signed-off-by: Akos Kitta --- examples/api-samples/package.json | 6 + .../src/common/updater/sample-updater.ts | 37 ++++ .../sample-updater-frontend-contribution.ts | 182 ++++++++++++++++++ .../updater/sample-updater-frontend-module.ts | 34 ++++ .../update/sample-updater-impl.ts | 91 +++++++++ .../update/sample-updater-main-module.ts | 36 ++++ .../electron-application-module.ts | 2 +- 7 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 examples/api-samples/src/common/updater/sample-updater.ts create mode 100644 examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts create mode 100644 examples/api-samples/src/electron-browser/updater/sample-updater-frontend-module.ts create mode 100644 examples/api-samples/src/electron-main/update/sample-updater-impl.ts create mode 100644 examples/api-samples/src/electron-main/update/sample-updater-main-module.ts diff --git a/examples/api-samples/package.json b/examples/api-samples/package.json index 4404804a63489..ec9edb4ad053e 100644 --- a/examples/api-samples/package.json +++ b/examples/api-samples/package.json @@ -10,6 +10,12 @@ "theiaExtensions": [ { "frontend": "lib/browser/api-samples-frontend-module" + }, + { + "electronMain": "lib/electron-main/update/sample-updater-main-module" + }, + { + "frontendElectron": "lib/electron-browser/updater/sample-updater-frontend-module" } ], "keywords": [ diff --git a/examples/api-samples/src/common/updater/sample-updater.ts b/examples/api-samples/src/common/updater/sample-updater.ts new file mode 100644 index 0000000000000..92daf919dcaea --- /dev/null +++ b/examples/api-samples/src/common/updater/sample-updater.ts @@ -0,0 +1,37 @@ +/******************************************************************************** + * Copyright (C) 2020 TypeFox 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 { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; + +export enum UpdateStatus { + InProgress = 'in-progress', + Available = 'available', + NotAvailable = 'not-available' +} + +export const SampleUpdaterPath = '/services/sample-updater'; +export const SampleUpdater = Symbol('SampleUpdater'); +export interface SampleUpdater extends JsonRpcServer { + checkForUpdates(): Promise<{ status: UpdateStatus }>; + onRestartToUpdateRequested(): void; + disconnectClient(client: SampleUpdaterClient): void; + + setUpdateAvailable(available: boolean): Promise; // Mock +} + +export const SampleUpdaterClient = Symbol('SampleUpdaterClient'); +export interface SampleUpdaterClient { + notifyReadyToInstall(): void; +} diff --git a/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts b/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts new file mode 100644 index 0000000000000..c129c2133f4d0 --- /dev/null +++ b/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-contribution.ts @@ -0,0 +1,182 @@ +/******************************************************************************** + * Copyright (C) 2020 TypeFox 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 { remote, Menu, BrowserWindow } from 'electron'; +import { inject, injectable, postConstruct } from 'inversify'; +import { isOSX } from '@theia/core/lib/common/os'; +import { CommonMenus } from '@theia/core/lib/browser'; +import { + Emitter, + Command, + MenuPath, + MessageService, + MenuModelRegistry, + MenuContribution, + CommandRegistry, + CommandContribution +} from '@theia/core/lib/common'; +import { ElectronMainMenuFactory } from '@theia/core/lib/electron-browser/menu/electron-main-menu-factory'; +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; +import { SampleUpdater, UpdateStatus, SampleUpdaterClient } from '../../common/updater/sample-updater'; + +export namespace SampleUpdaterCommands { + + const category = 'Electron Updater Sample'; + + export const CHECK_FOR_UPDATES: Command = { + id: 'electron-sample:check-for-updates', + label: 'Check for Updates...', + category + }; + + export const RESTART_TO_UPDATE: Command = { + id: 'electron-sample:restart-to-update', + label: 'Restart to Update', + category + }; + + // Mock + export const MOCK_UPDATE_AVAILABLE: Command = { + id: 'electron-sample:mock-update-available', + label: 'Mock - Available', + category + }; + + export const MOCK_UPDATE_NOT_AVAILABLE: Command = { + id: 'electron-sample:mock-update-not-available', + label: 'Mock - Not Available', + category + }; + +} + +export namespace SampleUpdaterMenu { + export const MENU_PATH: MenuPath = [...CommonMenus.FILE_SETTINGS_SUBMENU, '3_settings_submenu_update']; +} + +@injectable() +export class SampleUpdaterClientImpl implements SampleUpdaterClient { + + protected readonly onReadyToInstallEmitter = new Emitter(); + readonly onReadyToInstall = this.onReadyToInstallEmitter.event; + + notifyReadyToInstall(): void { + this.onReadyToInstallEmitter.fire(); + } + +} + +// Dynamic menus aren't yet supported by electron: https://github.com/eclipse-theia/theia/issues/446 +@injectable() +export class ElectronMenuUpdater { + + @inject(ElectronMainMenuFactory) + protected readonly factory: ElectronMainMenuFactory; + + public update(): void { + this.setMenu(); + } + + private setMenu(menu: Menu = this.factory.createMenuBar(), electronWindow: BrowserWindow = remote.getCurrentWindow()): void { + if (isOSX) { + remote.Menu.setApplicationMenu(menu); + } else { + electronWindow.setMenu(menu); + } + } + +} + +@injectable() +export class SampleUpdaterFrontendContribution implements CommandContribution, MenuContribution { + + @inject(MessageService) + protected readonly messageService: MessageService; + + @inject(ElectronMenuUpdater) + protected readonly menuUpdater: ElectronMenuUpdater; + + @inject(SampleUpdater) + protected readonly updater: SampleUpdater; + + @inject(SampleUpdaterClientImpl) + protected readonly updaterClient: SampleUpdaterClientImpl; + + protected readyToUpdate = false; + + @postConstruct() + protected init(): void { + this.updaterClient.onReadyToInstall(async () => { + this.readyToUpdate = true; + this.menuUpdater.update(); + this.handleUpdatesAvailable(); + }); + } + + registerCommands(registry: CommandRegistry): void { + registry.registerCommand(SampleUpdaterCommands.CHECK_FOR_UPDATES, { + execute: async () => { + const { status } = await this.updater.checkForUpdates(); + switch (status) { + case UpdateStatus.Available: { + this.handleUpdatesAvailable(); + break; + } + case UpdateStatus.NotAvailable: { + const { applicationName } = FrontendApplicationConfigProvider.get(); + this.messageService.info(`[Not Available]: You’re all good. You’ve got the latest version of ${applicationName}.`, { timeout: 3000 }); + break; + } + case UpdateStatus.InProgress: { + this.messageService.warn('[Downloading]: Work in progress...', { timeout: 3000 }); + break; + } + default: throw new Error(`Unexpected status: ${status}`); + } + }, + isEnabled: () => !this.readyToUpdate, + isVisible: () => !this.readyToUpdate + }); + registry.registerCommand(SampleUpdaterCommands.RESTART_TO_UPDATE, { + execute: () => this.updater.onRestartToUpdateRequested(), + isEnabled: () => this.readyToUpdate, + isVisible: () => this.readyToUpdate + }); + registry.registerCommand(SampleUpdaterCommands.MOCK_UPDATE_AVAILABLE, { + execute: () => this.updater.setUpdateAvailable(true) + }); + registry.registerCommand(SampleUpdaterCommands.MOCK_UPDATE_NOT_AVAILABLE, { + execute: () => this.updater.setUpdateAvailable(false) + }); + } + + registerMenus(registry: MenuModelRegistry): void { + registry.registerMenuAction(SampleUpdaterMenu.MENU_PATH, { + commandId: SampleUpdaterCommands.CHECK_FOR_UPDATES.id + }); + registry.registerMenuAction(SampleUpdaterMenu.MENU_PATH, { + commandId: SampleUpdaterCommands.RESTART_TO_UPDATE.id + }); + } + + protected async handleUpdatesAvailable(): Promise { + const answer = await this.messageService.info('[Available]: Found updates, do you want update now?', 'No', 'Yes'); + if (answer === 'Yes') { + this.updater.onRestartToUpdateRequested(); + } + } + +} diff --git a/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-module.ts b/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-module.ts new file mode 100644 index 0000000000000..1120bd75bd3fa --- /dev/null +++ b/examples/api-samples/src/electron-browser/updater/sample-updater-frontend-module.ts @@ -0,0 +1,34 @@ +/******************************************************************************** + * Copyright (C) 2020 TypeFox 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 { ContainerModule } from 'inversify'; +import { ElectronIpcConnectionProvider } from '@theia/core/lib/electron-browser/messaging/electron-ipc-connection-provider'; +import { CommandContribution, MenuContribution } from '@theia/core/lib/common'; +import { SampleUpdater, SampleUpdaterPath, SampleUpdaterClient } from '../../common/updater/sample-updater'; +import { SampleUpdaterFrontendContribution, ElectronMenuUpdater, SampleUpdaterClientImpl } from './sample-updater-frontend-contribution'; + +export default new ContainerModule(bind => { + bind(ElectronMenuUpdater).toSelf().inSingletonScope(); + bind(SampleUpdaterClientImpl).toSelf().inSingletonScope(); + bind(SampleUpdaterClient).toService(SampleUpdaterClientImpl); + bind(SampleUpdater).toDynamicValue(context => { + const client = context.container.get(SampleUpdaterClientImpl); + return ElectronIpcConnectionProvider.createProxy(context.container, SampleUpdaterPath, client); + }).inSingletonScope(); + bind(SampleUpdaterFrontendContribution).toSelf().inSingletonScope(); + bind(MenuContribution).toService(SampleUpdaterFrontendContribution); + bind(CommandContribution).toService(SampleUpdaterFrontendContribution); +}); diff --git a/examples/api-samples/src/electron-main/update/sample-updater-impl.ts b/examples/api-samples/src/electron-main/update/sample-updater-impl.ts new file mode 100644 index 0000000000000..294b3300c4eca --- /dev/null +++ b/examples/api-samples/src/electron-main/update/sample-updater-impl.ts @@ -0,0 +1,91 @@ +/******************************************************************************** + * Copyright (C) 2020 TypeFox 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 { injectable } from 'inversify'; +import { ElectronMainContribution } from '@theia/core/lib/electron-main/electron-application'; +import { SampleUpdater, SampleUpdaterClient, UpdateStatus } from '../../common/updater/sample-updater'; + +@injectable() +export class SampleUpdaterImpl implements SampleUpdater, ElectronMainContribution { + + protected clients: Array = []; + protected inProgressTimer: NodeJS.Timer | undefined; + protected available = false; + + async checkForUpdates(): Promise<{ status: UpdateStatus }> { + if (this.inProgressTimer) { + return { status: UpdateStatus.InProgress }; + } + return { status: this.available ? UpdateStatus.Available : UpdateStatus.NotAvailable }; + } + + onRestartToUpdateRequested(): void { + console.info("'Update to Restart' was requested by the frontend."); + // Here comes your install and restart implementation. For example: `autoUpdater.quitAndInstall();` + } + + async setUpdateAvailable(available: boolean): Promise { + if (this.inProgressTimer) { + clearTimeout(this.inProgressTimer); + } + if (!available) { + this.inProgressTimer = undefined; + this.available = false; + } else { + this.inProgressTimer = setTimeout(() => { + this.inProgressTimer = undefined; + this.available = true; + for (const client of this.clients) { + client.notifyReadyToInstall(); + } + }, 5000); + } + } + + onStart(): void { + // Called when the contribution is starting. You can use both async and sync code from here. + } + + onStop(): void { + // Invoked when the contribution is stopping. You can clean up things here. You are not allowed call async code from here. + } + + setClient(client: SampleUpdaterClient | undefined): void { + if (client) { + this.clients.push(client); + console.info('Registered a new sample updater client.'); + } else { + console.warn("Couldn't register undefined client."); + } + } + + disconnectClient(client: SampleUpdaterClient): void { + const index = this.clients.indexOf(client); + if (index !== -1) { + this.clients.splice(index, 1); + console.info('Disposed a sample updater client.'); + } else { + console.warn("Couldn't dispose client; it was not registered."); + } + } + + dispose(): void { + console.info('>>> Disposing sample updater service...'); + this.clients.forEach(this.disconnectClient.bind(this)); + console.info('>>> Disposed sample updater service.'); + } + +} diff --git a/examples/api-samples/src/electron-main/update/sample-updater-main-module.ts b/examples/api-samples/src/electron-main/update/sample-updater-main-module.ts new file mode 100644 index 0000000000000..485e4c6140ebc --- /dev/null +++ b/examples/api-samples/src/electron-main/update/sample-updater-main-module.ts @@ -0,0 +1,36 @@ +/******************************************************************************** + * Copyright (C) 2020 TypeFox 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 { ContainerModule } from 'inversify'; +import { JsonRpcConnectionHandler } from '@theia/core/lib/common/messaging/proxy-factory'; +import { ElectronMainContribution } from '@theia/core/lib/electron-main/electron-application'; +import { ElectronConnectionHandler } from '@theia/core/lib/electron-common/messaging/electron-connection-handler'; +import { SampleUpdaterPath, SampleUpdater, SampleUpdaterClient } from '../../common/updater/sample-updater'; +import { SampleUpdaterImpl } from './sample-updater-impl'; + +export default new ContainerModule(bind => { + bind(SampleUpdaterImpl).toSelf().inSingletonScope(); + bind(SampleUpdater).toService(SampleUpdaterImpl); + bind(ElectronMainContribution).toService(SampleUpdater); + bind(ElectronConnectionHandler).toDynamicValue(context => + new JsonRpcConnectionHandler(SampleUpdaterPath, client => { + const server = context.container.get(SampleUpdater); + server.setClient(client); + client.onDidCloseConnection(() => server.disconnectClient(client)); + return server; + }) + ).inSingletonScope(); +}); diff --git a/packages/core/src/electron-main/electron-application-module.ts b/packages/core/src/electron-main/electron-application-module.ts index ea361f6077001..cdafc62c995cb 100644 --- a/packages/core/src/electron-main/electron-application-module.ts +++ b/packages/core/src/electron-main/electron-application-module.ts @@ -33,7 +33,7 @@ const electronSecurityToken: ElectronSecurityToken = { value: v4() }; export default new ContainerModule(bind => { bind(ElectronApplication).toSelf().inSingletonScope(); bind(ElectronMessagingContribution).toSelf().inSingletonScope(); - bind(ElectronSecurityToken).toConstantValue(electronSecurityToken); + bind(ElectronSecurityToken).toConstantValue(electronSecurityToken); bindContributionProvider(bind, ElectronConnectionHandler); bindContributionProvider(bind, ElectronMessagingService.Contribution); From 0bf31fbf3f13ed6c75261113308580c1cb9a2aad Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Jun 2020 13:41:02 +0200 Subject: [PATCH 3/4] fixed disposal when refreshig the browser window Signed-off-by: Akos Kitta --- examples/electron/package.json | 4 ++-- .../messaging/ws-connection-provider.ts | 20 ++++++++++++++++++- .../messaging/abstract-connection-provider.ts | 3 +-- .../src/electron-main/electron-application.ts | 15 ++------------ .../electron-messaging-contribution.ts | 6 ++++-- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/examples/electron/package.json b/examples/electron/package.json index e2dddb1908728..be8aa369ff216 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -60,8 +60,8 @@ "build": "theiaext compile && yarn bundle", "bundle": "theia build --mode development", "watch": "concurrently -n compile,bundle \"theiaext watch --preserveWatchOutput\" \"theia build --watch --mode development\"", - "start": "theia start --plugins=local-dir:../../plugins", - "start:debug": "yarn start --log-level=debug", + "start": "theia start ../.. --plugins=local-dir:../../plugins", + "start:debug": "yarn start ../.. --log-level=debug", "test": "electron-mocha --timeout 60000 \"./lib/test/**/*.espec.js\"" }, "devDependencies": { diff --git a/packages/core/src/browser/messaging/ws-connection-provider.ts b/packages/core/src/browser/messaging/ws-connection-provider.ts index 83a367de72a44..bce60f2f36199 100644 --- a/packages/core/src/browser/messaging/ws-connection-provider.ts +++ b/packages/core/src/browser/messaging/ws-connection-provider.ts @@ -58,7 +58,7 @@ export class WebSocketConnectionProvider extends AbstractConnectionProvider void, options?: WebSocketOptions): void { if (this.socket.readyState === WebSocket.OPEN) { - super.openChannel(path, handler, options); + this.doOpenChannel(path, handler, options); } else { const openChannel = () => { this.socket.removeEventListener('open', openChannel); @@ -68,6 +68,24 @@ export class WebSocketConnectionProvider extends AbstractConnectionProvider void, options?: WebSocketOptions): void { + const id = this.channelIdSeq++; + const channel = this.createChannel(id); + this.channels.set(id, channel); + channel.onClose(() => { + if (this.channels.delete(channel.id)) { + const { reconnecting } = { reconnecting: true, ...options }; + if (reconnecting) { + this.openChannel(path, handler, options); + } + } else { + console.error('The ws channel does not exist', channel.id); + } + }); + channel.onOpen(() => handler(channel)); + channel.open(path); + } + protected createChannel(id: number): WebSocketChannel { return new WebSocketChannel(id, content => { if (this.socket.readyState < WebSocket.CLOSING) { diff --git a/packages/core/src/common/messaging/abstract-connection-provider.ts b/packages/core/src/common/messaging/abstract-connection-provider.ts index f1f92186c5eb7..dc8e1a6104baa 100644 --- a/packages/core/src/common/messaging/abstract-connection-provider.ts +++ b/packages/core/src/common/messaging/abstract-connection-provider.ts @@ -41,8 +41,7 @@ export abstract class AbstractConnectionProvider * An optional target can be provided to handle * notifications and requests from a remote side. */ - static createProxy(container: interfaces.Container, path: string, target?: object): JsonRpcProxy; - static createProxy(container: interfaces.Container, path: string, arg?: object): JsonRpcProxy { + static createProxy(container: interfaces.Container, path: string, target?: object): JsonRpcProxy { throw new Error('abstract'); } diff --git a/packages/core/src/electron-main/electron-application.ts b/packages/core/src/electron-main/electron-application.ts index 35e76d3d35e8d..0115106f8cb2e 100644 --- a/packages/core/src/electron-main/electron-application.ts +++ b/packages/core/src/electron-main/electron-application.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { inject, injectable, named } from 'inversify'; -import { session, screen, globalShortcut, app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent, shell, dialog } from 'electron'; +import { session, screen, globalShortcut, app, BrowserWindow, BrowserWindowConstructorOptions, Event as ElectronEvent, dialog } from 'electron'; import * as path from 'path'; import { Argv } from 'yargs'; import { AddressInfo } from 'net'; @@ -142,9 +142,9 @@ export class ElectronApplication { protected processArgv: ProcessArgv; protected readonly electronStore = new Storage(); + protected readonly backendPort = new Deferred(); protected config: FrontendApplicationConfig; - readonly backendPort = new Deferred(); async start(config: FrontendApplicationConfig): Promise { this.config = config; @@ -178,7 +178,6 @@ export class ElectronApplication { async createWindow(options: BrowserWindowConstructorOptions): Promise { const electronWindow = new BrowserWindow(options); this.attachReadyToShow(electronWindow); - this.attachWebContentsNewWindow(electronWindow); this.attachSaveWindowState(electronWindow); this.attachWillPreventUnload(electronWindow); this.attachGlobalShortcuts(electronWindow); @@ -258,16 +257,6 @@ export class ElectronApplication { return { width, height, x, y }; } - /** - * Prevent opening links into new electron browser windows by default. - */ - protected attachWebContentsNewWindow(electronWindow: BrowserWindow): void { - electronWindow.webContents.on('new-window', (event, url) => { - event.preventDefault(); - shell.openExternal(url); - }); - } - /** * Only show the window when the content is ready. */ diff --git a/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts index 273e02baafee0..5d3047d797929 100644 --- a/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts +++ b/packages/core/src/electron-main/messaging/electron-messaging-contribution.ts @@ -110,12 +110,14 @@ export class ElectronMessagingContribution implements ElectronMainContribution, console.error('The ipc channel does not exist', id); } } - sender.on('destroyed', () => { + const close = () => { for (const channel of Array.from(channels.values())) { channel.close(undefined, 'webContent destroyed'); } channels.clear(); - }); + }; + sender.once('did-navigate', close); // When refreshing the browser windows. + sender.once('destroyed', close); // When browser window is closed } catch (error) { console.error('IPC: Failed to handle message', { error, data }); } From ce0ca5bbf3be3f75de74383acc54da03d46d7faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Mar=C3=A9chal?= Date: Tue, 30 Jun 2020 18:57:25 -0400 Subject: [PATCH 4/4] !fixup: fixes and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Paul Maréchal --- .../src/electron-main/electron-application.ts | 76 ++++++++++--------- .../electron-window-service-impl.ts | 6 +- packages/core/src/node/cli.ts | 47 ++++-------- 3 files changed, 60 insertions(+), 69 deletions(-) diff --git a/packages/core/src/electron-main/electron-application.ts b/packages/core/src/electron-main/electron-application.ts index 0115106f8cb2e..f80fb8b57590f 100644 --- a/packages/core/src/electron-main/electron-application.ts +++ b/packages/core/src/electron-main/electron-application.ts @@ -80,6 +80,7 @@ export interface ElectronMainContribution { // Extracted the functionality from `yargs@15.4.0-beta.0`. // Based on https://github.com/yargs/yargs/blob/522b019c9a50924605986a1e6e0cb716d47bcbca/lib/process-argv.ts +// Modified. @injectable() export class ProcessArgv { @@ -107,12 +108,12 @@ export class ProcessArgv { return !!(process as ProcessArgv.ElectronProcess).versions.electron; } - get processArgvWithoutBin(): Array { - return process.argv.slice(this.processArgvBinIndex + 1); + getProcessArgvWithoutBin(argv = process.argv): Array { + return argv.slice(this.processArgvBinIndex + 1); } - get ProcessArgvBin(): string { - return process.argv[this.processArgvBinIndex]; + getProcessArgvBin(argv = process.argv): string { + return argv[this.processArgvBinIndex]; } } @@ -156,14 +157,15 @@ export class ElectronApplication { await this.startContributions(); await this.launch({ secondInstance: false, - argv: this.processArgv.processArgvWithoutBin, + argv: this.processArgv.getProcessArgvWithoutBin(process.argv), cwd: process.cwd() }); } async launch(params: ExecutionParams): Promise { + console.log('LAUNCHED!', JSON.stringify(params)); createYargs(params.argv, params.cwd) - .command('$0 []', false, + .command('$0 [file]', false, cmd => cmd .positional('file', { type: 'string' }), args => this.handleMainCommand(params, { file: args.file }), @@ -175,7 +177,10 @@ export class ElectronApplication { * * @param options */ - async createWindow(options: BrowserWindowConstructorOptions): Promise { + async createWindow(options?: BrowserWindowConstructorOptions): Promise { + if (typeof options === 'undefined') { + options = await this.getDefaultBrowserWindowOptions(); + } const electronWindow = new BrowserWindow(options); this.attachReadyToShow(electronWindow); this.attachSaveWindowState(electronWindow); @@ -184,18 +189,31 @@ export class ElectronApplication { return electronWindow; } + async getDefaultBrowserWindowOptions(): Promise { + let windowState: BrowserWindowConstructorOptions | undefined = this.electronStore.get('windowstate', undefined); + if (typeof windowState === 'undefined') { + windowState = this.getDefaultWindowState(); + } + return { + ...windowState, + show: false, + title: this.config.applicationName, + minWidth: 200, + minHeight: 120, + }; + } + async openDefaultWindow(): Promise { - const [uri, electronWindow] = await Promise.all([ - this.createWindowUri(), - this.createWindow(this.getBrowserWindowOptions()) - ]); - electronWindow.loadURL(uri.toString(true)); + const uriPromise = this.createWindowUri(); + const electronWindow = await this.createWindow(); + electronWindow.loadURL((await uriPromise).toString(true)); return electronWindow; } - async openWindowWithWorkspace(url: string): Promise { - const electronWindow = await this.createWindow(this.getBrowserWindowOptions()); - electronWindow.loadURL(url); + async openWindowWithWorkspace(workspacePath: string): Promise { + const uriPromise = this.createWindowUri(); + const electronWindow = await this.createWindow(); + electronWindow.loadURL((await uriPromise).withFragment(workspacePath).toString(true)); return electronWindow; } @@ -219,8 +237,7 @@ export class ElectronApplication { if (workspacePath === undefined) { await this.openDefaultWindow(); } else { - const uri = (await this.createWindowUri()).withFragment(workspacePath); - await this.openWindowWithWorkspace(uri.toString(true)); + await this.openWindowWithWorkspace(workspacePath); } } } @@ -230,20 +247,6 @@ export class ElectronApplication { return FileUri.create(this.globals.THEIA_FRONTEND_HTML_PATH).withQuery(`port=${port}`); } - protected getBrowserWindowOptions(): BrowserWindowConstructorOptions { - let windowState: BrowserWindowConstructorOptions | undefined = this.electronStore.get('windowstate', undefined); - if (typeof windowState === 'undefined') { - windowState = this.getDefaultWindowState(); - } - return { - ...windowState, - show: false, - title: this.config.applicationName, - minWidth: 200, - minHeight: 120, - }; - } - protected getDefaultWindowState(): BrowserWindowConstructorOptions { // The `screen` API must be required when the application is ready. // See: https://electronjs.org/docs/api/screen#screen @@ -347,9 +350,6 @@ export class ElectronApplication { protected async startBackend(): Promise { // Check if we should run everything as one process. const noBackendFork = process.argv.indexOf('--no-cluster') !== -1; - // Any flag/argument passed after `--` will be forwarded to the backend process. - const backendArgvMarkerIndex = process.argv.indexOf('--'); - const backendArgv = backendArgvMarkerIndex === -1 ? [] : process.argv.slice(backendArgvMarkerIndex + 1); // We cannot use the `process.cwd()` as the application project path (the location of the `package.json` in other words) // in a bundled electron application because it depends on the way we start it. For instance, on OS X, these are a differences: // https://github.com/eclipse-theia/theia/issues/3297#issuecomment-439172274 @@ -363,7 +363,11 @@ export class ElectronApplication { const address: AddressInfo = await require(this.globals.THEIA_BACKEND_MAIN_PATH); return address.port; } else { - const backendProcess = fork(this.globals.THEIA_BACKEND_MAIN_PATH, backendArgv, await this.getForkOptions()); + const backendProcess = fork( + this.globals.THEIA_BACKEND_MAIN_PATH, + this.processArgv.getProcessArgvWithoutBin(), + await this.getForkOptions(), + ); return new Promise((resolve, reject) => { // The backend server main file is also supposed to send the resolved http(s) server port via IPC. backendProcess.on('message', (address: AddressInfo) => { @@ -412,7 +416,7 @@ export class ElectronApplication { } protected async onSecondInstance(event: ElectronEvent, argv: string[], cwd: string): Promise { - await this.launch({ argv, cwd, secondInstance: true }); + await this.launch({ argv: this.processArgv.getProcessArgvWithoutBin(argv), cwd, secondInstance: true }); } protected onWindowAllClosed(event: ElectronEvent): void { diff --git a/packages/core/src/electron-main/electron-window-service-impl.ts b/packages/core/src/electron-main/electron-window-service-impl.ts index 489e13ab120f3..1f088c37dcfb6 100644 --- a/packages/core/src/electron-main/electron-window-service-impl.ts +++ b/packages/core/src/electron-main/electron-window-service-impl.ts @@ -27,10 +27,12 @@ export class ElectronWindowServiceImpl implements ElectronWindowService { protected readonly app: ElectronApplication; openNewWindow(url: string, { external }: NewWindowOptions): undefined { - if (!!external) { + if (external) { shell.openExternal(url); } else { - this.app.openWindowWithWorkspace(url); + this.app.createWindow().then(electronWindow => { + electronWindow.loadURL(url); + }); } return undefined; } diff --git a/packages/core/src/node/cli.ts b/packages/core/src/node/cli.ts index 35dd28417a1df..b2d2b7a54a738 100644 --- a/packages/core/src/node/cli.ts +++ b/packages/core/src/node/cli.ts @@ -25,47 +25,32 @@ export const CliContribution = Symbol('CliContribution'); * Call back for extension to contribute options to the cli. */ export interface CliContribution { - /** - * Configure the `yargs.Argv` parser with your options. - */ - configure(conf: yargs.Argv): MaybePromise; - /** - * Fetch the parsed options. - */ + configure(conf: yargs.Argv): void; setArguments(args: yargs.Arguments): MaybePromise; } @injectable() export class CliManager { - protected readonly parser: yargs.Argv; + constructor(@inject(ContributionProvider) @named(CliContribution) + protected readonly contributionsProvider: ContributionProvider) { } - constructor( - @inject(ContributionProvider) @named(CliContribution) - protected readonly contributionsProvider: ContributionProvider, - ) { + async initializeCli(argv: string[]): Promise { const pack = require('../../package.json'); - this.parser = yargs - .version(pack.version) - .exitProcess(this.isExit()) + const version = pack.version; + const command = yargs.version(version); + command.exitProcess(this.isExit()); + for (const contrib of this.contributionsProvider.getContributions()) { + contrib.configure(command); + } + const args = command .detectLocale(false) .showHelpOnFail(false, 'Specify --help for available options') - .help('help'); - } - - async parse(argv: string[]): Promise { - return this.parser.parse(argv); - } - - async initializeCli(argv: string[]): Promise { - const contributions = Array.from(this.contributionsProvider.getContributions()); - await Promise.all(contributions.map( - contrib => contrib.configure(this.parser), - )); - const args = await this.parse(argv); - await Promise.all(contributions.map( - contrib => contrib.setArguments(args), - )); + .help('help') + .parse(argv); + for (const contrib of this.contributionsProvider.getContributions()) { + await contrib.setArguments(args); + } } protected isExit(): boolean {