Skip to content

Commit

Permalink
Multi window support for web views
Browse files Browse the repository at this point in the history
Support for moving webview-based views into a secondary window or tab.
For webview-based views a new button becomes available in the toolbar of the view to move the view to a secondary window.
This is only supported for webview-based views and only one view can be moved into the secondary window.
There can be multiple secondary windows though.

Primary code changes:
- Add concept of extractable widgets. Only widgets implementing the interface can be extracted.
- Add SecondaryWindowHandler that encapsulates logic to move widgets to new windows.
- Only webviews can be extracted
- Configure opened secondary windows in electron
  - Hide electron menu
  - Always use the native window frame for secondary windows to get window controls
  - Do not show secondary window icon if main window uses a custom title bar
- Contribute widget extraction button in a separate new extension `widget-extraction-ui`
- Extend application shell areas with a `secondaryWindow` area that contains all extracted widgets
- Extend frontend and webpack generators to generate the base html for secondary windows and copy it to the lib folder
- Bridge plugin communication securely via secure messaging between external webview and Theia:
  Webviews only accept messages from its direct parent window. This is necessary to avoid Cross Site Scripting (XSS).
  To make the messaging work in secondary windows, messages are sent to the external widget and then delegated to the webview's iframe.
  Thereby, the secondary window only accepts messages from its opener which is the theia main window.
  To achieve this, a webview knows the secondary window it is in (if any). It then sends messages to this window instead of directly to the webview iframe.
- Patch phosphor library during install via a postinstall hook. Remove check that widget attachment target must be in same DOM.

Contributed on behalf of ST Microelectronics and Ericsson and by ARM and EclipseSource.

Co-authored-by: Stefan Dirix <[email protected]>
Co-authored-by: robmor01 <[email protected]>
Signed-off-by: Lucas Koehler <[email protected]>
  • Loading branch information
3 people committed Apr 20, 2022
1 parent c78d302 commit 5eddd35
Show file tree
Hide file tree
Showing 27 changed files with 716 additions and 40 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- [plugin] added support for `SnippetString.appendChoice` [#10969](https://github.com/eclipse-theia/theia/pull/10969) - Contributed on behalf of STMicroelectronics
- [plugin] added support for `AccessibilityInformation` [#10961](https://github.com/eclipse-theia/theia/pull/10961) - Contributed on behalf of STMicroelectronics
- [plugin] added missing properties `id`, `name` and `backgroundColor` to `StatusBarItem` [#11026](https://github.com/eclipse-theia/theia/pull/11026) - Contributed on behalf of STMicroelectronics
- [core] Added support for moving webview-based views into a secondary window/tab. Added new extension `secondary-window-ui` that contributes the UI integration to use this. [#11048](https://github.com/eclipse-theia/theia/pull/11048)- Contributed on behalf of ST Microelectronics and Ericsson and by ARM and EclipseSource

<a name="breaking_changes_1.25.0">[Breaking Changes:](#breaking_changes_1.25.0)</a>
- [debug]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class FrontendGenerator extends AbstractGenerator {
const frontendModules = this.pck.targetFrontendModules;
await this.write(this.pck.frontend('index.html'), this.compileIndexHtml(frontendModules));
await this.write(this.pck.frontend('index.js'), this.compileIndexJs(frontendModules));
await this.write(this.pck.frontend('secondary-window.html'), this.compileSecondaryWindowHtml());
if (this.pck.isElectron()) {
const electronMainModules = this.pck.targetElectronMainModules;
await this.write(this.pck.frontend('electron-main.js'), this.compileElectronMain(electronMainModules));
Expand Down Expand Up @@ -197,4 +198,48 @@ module.exports = Promise.resolve()${this.compileElectronMainModuleImports(electr
`;
}

/** HTML for secondary windows that contain an extracted widget. */
protected compileSecondaryWindowHtml(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Theia — Secondary Window</title>
<style>
html, body {
overflow: hidden;
-ms-overflow-style: none;
}
body {
margin: 0;
}
html,
head,
body,
#pwidget,
.p-Widget {
width: 100% !important;
height: 100% !important;
}
</style>
<script>
window.addEventListener('message', e => {
// Only process messages from Theia main window
if (e.source === window.opener) {
// Delegate message to iframe
document.getElementsByTagName('iframe').item(0).contentWindow.postMessage({ ...e.data }, '*');
}
});
</script>
</head>
<body>
<div id="pwidget"></div>
</body>
</html>`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class WebpackGenerator extends AbstractGenerator {
const path = require('path');
const webpack = require('webpack');
const yargs = require('yargs');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CircularDependencyPlugin = require('circular-dependency-plugin');
const CompressionPlugin = require('compression-webpack-plugin')
Expand All @@ -72,6 +73,12 @@ const { mode, staticCompression } = yargs.option('mode', {
const development = mode === 'development';
const plugins = [
new CopyWebpackPlugin({
patterns: [{
// copy secondary window html file to lib folder
from: path.resolve(__dirname, 'src-gen/frontend/secondary-window.html')
}]
}),
new webpack.ProvidePlugin({
// the Buffer class doesn't exist in the browser but some dependencies rely on it
Buffer: ['buffer', 'Buffer']
Expand Down
1 change: 1 addition & 0 deletions examples/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@theia/scm": "1.24.0",
"@theia/scm-extra": "1.24.0",
"@theia/search-in-workspace": "1.24.0",
"@theia/secondary-window-ui": "1.24.0",
"@theia/task": "1.24.0",
"@theia/terminal": "1.24.0",
"@theia/timeline": "1.24.0",
Expand Down
3 changes: 3 additions & 0 deletions examples/browser/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@
{
"path": "../../packages/search-in-workspace"
},
{
"path": "../../packages/secondary-window-ui"
},
{
"path": "../../packages/task"
},
Expand Down
1 change: 1 addition & 0 deletions examples/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@theia/scm": "1.24.0",
"@theia/scm-extra": "1.24.0",
"@theia/search-in-workspace": "1.24.0",
"@theia/secondary-window-ui": "1.24.0",
"@theia/task": "1.24.0",
"@theia/terminal": "1.24.0",
"@theia/timeline": "1.24.0",
Expand Down
3 changes: 3 additions & 0 deletions examples/electron/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@
{
"path": "../../packages/search-in-workspace"
},
{
"path": "../../packages/secondary-window-ui"
},
{
"path": "../../packages/task"
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"lint:clean": "rimraf .eslintcache",
"lint:oneshot": "node --max-old-space-size=4096 node_modules/eslint/bin/eslint.js --cache=true \"{dev-packages,packages,examples}/**/*.{ts,tsx}\"",
"preinstall": "node-gyp install",
"postinstall": "node scripts/patch-libraries.js",
"prepare": "yarn -s compile:references && lerna run prepare && yarn -s compile",
"publish:latest": "lerna publish --exact --yes --no-push && yarn -s publish:check",
"publish:next": "lerna publish preminor --exact --canary --preid next --dist-tag next --no-git-reset --no-git-tag-version --no-push --yes && yarn -s publish:check",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import { RendererHost } from './widgets';
import { TooltipService, TooltipServiceImpl } from './tooltip-service';
import { bindFrontendStopwatch, bindBackendStopwatch } from './performance';
import { SaveResourceService } from './save-resource-service';
import { SecondaryWindowHandler } from './secondary-window-handler';
import { UserWorkingDirectoryProvider } from './user-working-directory-provider';

export { bindResourceProvider, bindMessageService, bindPreferenceService };
Expand Down Expand Up @@ -400,4 +401,6 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo

bind(SaveResourceService).toSelf().inSingletonScope();
bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope();

bind(SecondaryWindowHandler).toSelf().inSingletonScope();
});
243 changes: 243 additions & 0 deletions packages/core/src/browser/secondary-window-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// *****************************************************************************
// Copyright (C) 2022 STMicroelectronics, Ericsson, ARM, EclipseSource 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 debounce = require('lodash.debounce');
import { inject, injectable } from 'inversify';
import { BoxLayout, BoxPanel, ExtractableWidget, Widget } from './widgets';
import { MessageService } from '../common/message-service';
import { ApplicationShell } from './shell/application-shell';
import { Emitter } from '../common/event';
import { WindowService } from './window/window-service';

/** Widget to be contained directly in a secondary window. */
class SecondaryWindowRootWidget extends Widget {

constructor() {
super();
this.layout = new BoxLayout();
}

addWidget(widget: Widget): void {
(this.layout as BoxLayout).addWidget(widget);
BoxPanel.setStretch(widget, 1);
}

}

/**
* Offers functionality to move a widget out of the main window to a newly created window.
* Widgets must explicitly implement the `ExtractableWidget` interface to support this.
*
* This handler manages the opened secondary windows and sets up messaging between them and the Theia main window.
* In addition, it provides access to the extracted widgets and provides notifications when widgets are added to or removed from this handler.
*
* _Note:_ This handler is used by the application shell and there should be no need for callers to directly interact with this class.
* Instead, consider using the application shell.
*/
@injectable()
export class SecondaryWindowHandler {
/** List of currently open secondary windows. Window references should be removed once the window is closed. */
protected readonly secondaryWindows: Window[] = [];
/** List of widgets in secondary windows. */
protected readonly _widgets: ExtractableWidget[] = [];

/**
* Randomized prefix to be included in opened windows' ids.
* This avoids conflicts when creating sub-windows from multiple theia instances (e.g. by opening Theia multiple times in the same browser)
*/
protected readonly prefix = crypto.getRandomValues(new Uint16Array(1))[0];

protected applicationShell: ApplicationShell;

protected readonly onDidAddWidgetEmitter = new Emitter<Widget>();
/** Subscribe to get notified when a widget is added to this handler, i.e. the widget was moved to an secondary window . */
readonly onDidAddWidget = this.onDidAddWidgetEmitter.event;

protected readonly onDidRemoveWidgetEmitter = new Emitter<Widget>();
/** Subscribe to get notified when a widget is removed from this handler, i.e. the widget's window was closed or the widget was disposed. */
readonly onDidRemoveWidget = this.onDidRemoveWidgetEmitter.event;

constructor(
@inject(MessageService) protected readonly messageService: MessageService,
@inject(WindowService) protected readonly windowService: WindowService
) { }

/** @returns List of widgets in secondary windows. */
get widgets(): ReadonlyArray<Widget> {
// Create new array in case the original changes while this is used.
return [...this._widgets];
}

/**
* Sets up message forwarding from the main window to secondary windows.
* Does nothing if this service has already been initialized.
*
* @param shell The `ApplicationShell` that widgets will be moved out from.
*/
init(shell: ApplicationShell): void {
if (this.applicationShell) {
// Already initialized
return;
}
this.applicationShell = shell;

// Set up messaging with secondary windows
window.addEventListener('message', (event: MessageEvent) => {
console.trace('Message on main window', event);
if (event.data.fromSecondary) {
console.trace('Message comes from secondary window');
return;
}
if (event.data.fromMain) {
console.trace('Message has mainWindow marker, therefore ignore it');
return;
}

// Filter setImmediate messages. Do not forward because these come in with very high frequency.
// They are not needed in secondary windows because these messages are just a work around
// to make setImmediate work in the main window: https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate
if (typeof event.data === 'string' && event.data.startsWith('setImmediate')) {
return;
}

console.trace('Delegate main window message to secondary windows', event);
this.secondaryWindows.forEach(secondaryWindow => {
if (!secondaryWindow.window.closed) {
secondaryWindow.window.postMessage({ ...event.data, fromMain: true }, '*');
}
});
});

// Close all open windows when the main window is closed.
this.windowService.onUnload(() => {
// Iterate backwards because calling window.close might remove the window from the array
for (let i = this.secondaryWindows.length - 1; i >= 0; i--) {
this.secondaryWindows[i].close();
}
});
}

/**
* Moves the given widget to a new window.
*
* @param widget the widget to extract
*/
moveWidgetToSecondaryWindow(widget: ExtractableWidget): void {
if (!this.applicationShell) {
console.error('Widget cannot be extracted because the WidgetExtractionHandler has not been initialized.');
return;
}
if (!widget.isExtractable) {
console.error('Widget is not extractable.', widget.id);
return;
}

// secondary-window.html is part of Theia's generated code. It is generated by dev-packages/application-manager/src/generator/frontend-generator.ts
const newWindow = window.open('secondary-window.html', `${this.prefix}-subwindow${this.secondaryWindows.length}`, 'popup');

if (!newWindow) {
this.messageService.error('The widget could not be moved to a secondary window because the window creation failed. Please make sure to allow popus.');
return;
}

this.secondaryWindows.push(newWindow);

newWindow.onload = () => {
// Use the widget's title as the window title
// Even if the widget's label were malicious, this should be safe against XSS because the HTML standard defines this is inserted via a text node.
// See https://html.spec.whatwg.org/multipage/dom.html#document.title
newWindow.document.title = widget.title.label;

const element = newWindow.document.getElementById('pwidget');
if (!element) {
console.error('Could not find dom element to attach to in secondary window');
return;
}

widget.secondaryWindow = newWindow;
const rootWidget = new SecondaryWindowRootWidget();
Widget.attach(rootWidget, element);
rootWidget.addWidget(widget);
widget.update();

this.addWidget(widget);

// Close widget and remove window from this handler when the window is closed.
newWindow.addEventListener('beforeunload', () => {
this.applicationShell.closeWidget(widget.id);
const extIndex = this.secondaryWindows.indexOf(newWindow);
if (extIndex > -1) {
this.secondaryWindows.splice(extIndex, 1);
}
});

// Close the window if the widget is disposed, e.g. by a command closing all widgets.
widget.disposed.connect(() => {
this.removeWidget(widget);
if (!newWindow.closed) {
newWindow.close();
}
});

// debounce to avoid rapid updates while resizing the secondary window
const updateWidget = debounce(widget.update.bind(widget), 100);
newWindow.addEventListener('resize', () => updateWidget());
};
}

/**
* If the given widget is tracked by this handler, activate it and focus its secondary window.
*
* @param widgetId The widget to activate specified by its id
* @returns The activated `ExtractableWidget` or `undefined` if the given widget id is unknown to this handler.
*/
activateWidget(widgetId: string): ExtractableWidget | undefined {
const trackedWidget = this.revealWidget(widgetId);
trackedWidget?.activate();
return trackedWidget;
}

/**
* If the given widget is tracked by this handler, reveal it by focussing its secondary window.
*
* @param widgetId The widget to reveal specified by its id
* @returns The revealed `ExtractableWidget` or `undefined` if the given widget id is unknown to this handler.
*/
revealWidget(widgetId: string): ExtractableWidget | undefined {
const trackedWidget = this._widgets.find(w => w.id === widgetId);
if (trackedWidget) {
// TODO This is not sufficient for electron
trackedWidget.secondaryWindow?.focus();
return trackedWidget;
}
return undefined;
}

protected addWidget(widget: ExtractableWidget): void {
if (!this._widgets.includes(widget)) {
this._widgets.push(widget);
this.onDidAddWidgetEmitter.fire(widget);
}
}

protected removeWidget(widget: ExtractableWidget): void {
const index = this._widgets.indexOf(widget);
if (index > -1) {
this._widgets.splice(index, 1);
this.onDidRemoveWidgetEmitter.fire(widget);
}
}
}
Loading

0 comments on commit 5eddd35

Please sign in to comment.