diff --git a/.gitpod.yml b/.gitpod.yml index 26a74f0df7af5..e334dfcd62823 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -1,7 +1,10 @@ image: file: .gitpod.dockerfile ports: -- port: 3000 +- port: 3000 # Theia +- port: 3030 # VS Code extension tests +- port: 9339 # Node.js debug port + onOpen: ignore - port: 6080 onOpen: ignore - port: 5900 @@ -10,7 +13,7 @@ tasks: - init: yarn command: > jwm & - yarn --cwd examples/browser start ../.. + yarn --cwd examples/browser start ../.. --hostname=0.0.0.0 github: prebuilds: pullRequestsFromForks: true diff --git a/.vscode/launch.json b/.vscode/launch.json index 80a9d380a0b9b..825f002eca906 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,7 +35,8 @@ "--no-app-auto-install" ], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "sourceMaps": true, "outFiles": [ @@ -63,7 +64,8 @@ "--hosted-plugin-inspect=9339" ], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "sourceMaps": true, "outFiles": [ @@ -104,7 +106,8 @@ "--no-app-auto-install" ], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "sourceMaps": true, "outFiles": [ @@ -166,7 +169,8 @@ "--hosted-plugin-inspect=9339" ], "env": { - "THEIA_DEFAULT_PLUGINS": "local-dir:${workspaceFolder}/plugins" + "THEIA_DEFAULT_PLUGINS": "local-dir:${workspaceFolder}/plugins", + "THEIA_WEBVIEW_EXTERNAL_ENDPOINT": "${env:THEIA_WEBVIEW_EXTERNAL_ENDPOINT}" }, "stopOnEntry": false, "sourceMaps": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 46769e1fa9dcb..e8e3ec689b3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ Breaking changes: - [core] renamed preference `list.openMode` to `workbench.list.openMode` [#6481](https://github.com/eclipse-theia/theia/pull/6481) - [task] changed `TaskSchemaUpdater.update()` from asynchronous to synchronous [#6483](https://github.com/eclipse-theia/theia/pull/6483) - [monaco] monaco prefix has been removed from commands [#5590](https://github.com/eclipse-theia/theia/pull/5590) +- [plugin] webviews are reimplemented to align with [VS Code browser implementation](https://blog.mattbierner.com/vscode-webview-web-learnings/) [#6465](https://github.com/eclipse-theia/theia/pull/6465) + - Security: `vscode.previewHTML` is removed, see https://code.visualstudio.com/updates/v1_33#_removing-the-vscodepreviewhtml-command + - Security: Before all webviews were deployed on [the same origin](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) + allowing them to break out and manipulate shared data as cookies, local storage or even start service workers + for the main window as well as for each other. Now each webview will be deployed on own origin by default. + - Webview origin pattern can be configured with `THEIA_WEBVIEW_EXTERNAL_ENDPOINT` env variable. The default value is `{{uuid}}.webview.{{hostname}}`. + Here `{{uuid}}` and `{{hostname}}` are placeholders which get replaced at runtime with proper webview uuid + and [hostname](https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/hostname) correspondingly. + - To switch to unsecure mode as before configure `THEIA_WEBVIEW_EXTERNAL_ENDPOINT` with `{{hostname}}` as a value. + You can also drop `{{uuid}}.` prefix, in this case, webviews still will be able to access each other but not the main window. + - Remote: Local URIs are resolved by default to the host serving Theia. + If you want to resolve to another host or change how remote URIs are constructed then + implement [ExternalUriService.resolve](./packages/core/src/browser/external-uri-service.ts) in a frontend module. + - Content loading: Webview HTTP endpoint is removed. Content loaded via [WebviewResourceLoader](./packages/plugin-ext/src/main/common/webview-protocol.ts) JSON-RPC service + with properly preserved resource URIs. Content is only loaded if it's allowed by WebviewOptions.localResourceRoots, otherwise, the service won't be called. + If you want to customize content loading then implement [WebviewResourceLoaderImpl](packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts) in a backend module. + - Theming: Theia styles are not applied to webviews anymore + instead [VS Code way of styling](https://code.visualstudio.com/api/extension-guides/webview#theming-webview-content) should be used. + VS Code color variables also available with `--theia` prefix. + - Testing: Webview can work only in secure context because they rely on service workers to load local content and redirect local to remote requests. + Most browsers define a page as served from secure context if its url has `https` scheme. For local testing `localhost` is treated as a secure context as well. + Unfortunately, it does not work nicely in FireFox, since it does not treat subdomains of localhost as secure as well, compare to Chrome. + If you want to test with FireFox you can configure it as described [here](https://github.com/eclipse-theia/theia/pull/6465#issuecomment-556443218). ## v0.12.0 diff --git a/packages/core/package.json b/packages/core/package.json index 03ed5acae678e..37e9fb5acc091 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,7 @@ "typings": "lib/common/index.d.ts", "dependencies": { "@babel/runtime": "^7.5.5", - "@phosphor/widgets": "^1.5.0", + "@phosphor/widgets": "^1.9.3", "@primer/octicons-react": "^9.0.0", "@theia/application-package": "^0.12.0", "@types/body-parser": "^1.16.4", diff --git a/packages/core/src/browser/color-application-contribution.ts b/packages/core/src/browser/color-application-contribution.ts new file mode 100644 index 0000000000000..9f03df91e57a1 --- /dev/null +++ b/packages/core/src/browser/color-application-contribution.ts @@ -0,0 +1,75 @@ +/******************************************************************************** + * Copyright (C) 2019 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, inject, named } from 'inversify'; +import { ColorRegistry } from './color-registry'; +import { Emitter } from '../common/event'; +import { ThemeService } from './theming'; +import { FrontendApplicationContribution } from './frontend-application'; +import { ContributionProvider } from '../common/contribution-provider'; +import { Disposable, DisposableCollection } from '../common/disposable'; + +export const ColorContribution = Symbol('ColorContribution'); +export interface ColorContribution { + registerColors(colors: ColorRegistry): void; +} + +@injectable() +export class ColorApplicationContribution implements FrontendApplicationContribution { + + protected readonly onDidChangeEmitter = new Emitter<void>(); + readonly onDidChange = this.onDidChangeEmitter.event; + + @inject(ColorRegistry) + protected readonly colors: ColorRegistry; + + @inject(ContributionProvider) @named(ColorContribution) + protected readonly colorContributions: ContributionProvider<ColorContribution>; + + onStart(): void { + for (const contribution of this.colorContributions.getContributions()) { + contribution.registerColors(this.colors); + } + + this.update(); + ThemeService.get().onThemeChange(() => this.update()); + } + + protected readonly toUpdate = new DisposableCollection(); + protected update(): void { + if (!document) { + return; + } + this.toUpdate.dispose(); + const theme = 'theia-' + ThemeService.get().getCurrentTheme().type; + document.body.classList.add(theme); + this.toUpdate.push(Disposable.create(() => document.body.classList.remove(theme))); + + const documentElement = document.documentElement; + if (documentElement) { + for (const id of this.colors.getColors()) { + const color = this.colors.getCurrentColor(id); + if (color) { + const propertyName = `--theia-${id.replace('.', '-')}`; + documentElement.style.setProperty(propertyName, color); + this.toUpdate.push(Disposable.create(() => documentElement.style.removeProperty(propertyName))); + } + } + } + this.onDidChangeEmitter.fire(undefined); + } + +} diff --git a/packages/core/src/browser/color-registry.ts b/packages/core/src/browser/color-registry.ts new file mode 100644 index 0000000000000..03fcc2ec19530 --- /dev/null +++ b/packages/core/src/browser/color-registry.ts @@ -0,0 +1,47 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { Disposable } from '../common/disposable'; + +export interface ColorDefaults { + light?: string + dark?: string + hc?: string +} + +export interface ColorOptions { + defaults?: ColorDefaults + description: string +} + +/** + * It should be implemented by an extension, e.g. by the monaco extension. + */ +@injectable() +export class ColorRegistry { + + *getColors(): IterableIterator<string> { } + + getCurrentColor(id: string): string | undefined { + return undefined; + } + + register(id: string, options: ColorOptions): Disposable { + return Disposable.NULL; + } + +} diff --git a/packages/core/src/browser/endpoint.ts b/packages/core/src/browser/endpoint.ts index ac26658255ec2..8d2f95da51aef 100644 --- a/packages/core/src/browser/endpoint.ts +++ b/packages/core/src/browser/endpoint.ts @@ -53,6 +53,9 @@ export class Endpoint { } protected get host(): string { + if (this.options.host) { + return this.options.host; + } if (this.location.host) { return this.location.host; } @@ -77,6 +80,9 @@ export class Endpoint { } protected get wsScheme(): string { + if (this.options.wsScheme) { + return this.options.wsScheme; + } return this.httpScheme === Endpoint.PROTO_HTTPS ? Endpoint.PROTO_WSS : Endpoint.PROTO_WS; } diff --git a/packages/core/src/browser/external-uri-service.ts b/packages/core/src/browser/external-uri-service.ts new file mode 100644 index 0000000000000..8ccf66fd69db6 --- /dev/null +++ b/packages/core/src/browser/external-uri-service.ts @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (C) 2019 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 URI from '../common/uri'; +import { MaybePromise } from '../common/types'; +import { Endpoint } from './endpoint'; + +@injectable() +export class ExternalUriService { + + /** + * Maps local to remote URLs. + * Should be no-op if the given URL is not a localhost URL. + * + * By default maps to an origin serving Theia. + * + * Use `parseLocalhost` to retrive localhost address and port information. + */ + resolve(uri: URI): MaybePromise<URI> { + const localhost = this.parseLocalhost(uri); + if (localhost) { + return this.toRemoteUrl(uri, localhost); + } + return uri; + } + + protected toRemoteUrl(uri: URI, localhost: { address: string, port: number }): URI { + const host = this.toRemoteHost(localhost); + return new Endpoint({ host }).getRestUrl().withPath(uri.path).withFragment(uri.fragment).withQuery(uri.query); + } + + protected toRemoteHost(localhost: { address: string, port: number }): string { + return `${window.location.hostname}:${localhost.port}`; + } + + parseLocalhost(uri: URI): { address: string, port: number } | undefined { + if (uri.scheme !== 'http' && uri.scheme !== 'https') { + return undefined; + } + const localhostMatch = /^(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)$/.exec(uri.authority); + if (!localhostMatch) { + return undefined; + } + return { + address: localhostMatch[1], + port: +localhostMatch[2], + }; + } + +} diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 20c91dbc4747d..676130f3ffd9e 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -83,6 +83,9 @@ import { ProgressStatusBarItem } from './progress-status-bar-item'; import { TabBarDecoratorService, TabBarDecorator } from './shell/tab-bar-decorator'; import { ContextMenuContext } from './menu/context-menu-context'; import { bindResourceProvider, bindMessageService, bindPreferenceService } from './frontend-application-bindings'; +import { ColorRegistry } from './color-registry'; +import { ColorContribution, ColorApplicationContribution } from './color-application-contribution'; +import { ExternalUriService } from './external-uri-service'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -91,6 +94,11 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo themeService.register(...BuiltinThemeProvider.themes); themeService.startupTheme(); + bind(ColorRegistry).toSelf().inSingletonScope(); + bindContributionProvider(bind, ColorContribution); + bind(ColorApplicationContribution).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(ColorApplicationContribution); + bind(FrontendApplication).toSelf().inSingletonScope(); bind(FrontendApplicationStateService).toSelf().inSingletonScope(); bind(DefaultFrontendApplicationContribution).toSelf(); @@ -131,6 +139,8 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bindContributionProvider(bind, OpenHandler); bind(DefaultOpenerService).toSelf().inSingletonScope(); bind(OpenerService).toService(DefaultOpenerService); + + bind(ExternalUriService).toSelf().inSingletonScope(); bind(HttpOpenHandler).toSelf().inSingletonScope(); bind(OpenHandler).toService(HttpOpenHandler); diff --git a/packages/core/src/browser/http-open-handler.ts b/packages/core/src/browser/http-open-handler.ts index bf23712663c6a..875c64b047aeb 100644 --- a/packages/core/src/browser/http-open-handler.ts +++ b/packages/core/src/browser/http-open-handler.ts @@ -18,6 +18,7 @@ import { injectable, inject } from 'inversify'; import URI from '../common/uri'; import { OpenHandler } from './opener-service'; import { WindowService } from './window/window-service'; +import { ExternalUriService } from './external-uri-service'; @injectable() export class HttpOpenHandler implements OpenHandler { @@ -27,12 +28,16 @@ export class HttpOpenHandler implements OpenHandler { @inject(WindowService) protected readonly windowService: WindowService; + @inject(ExternalUriService) + protected readonly externalUriService: ExternalUriService; + canHandle(uri: URI): number { - return uri.scheme.startsWith('http') ? 500 : 0; + return (uri.scheme.startsWith('http') || uri.scheme.startsWith('mailto')) ? 500 : 0; } - open(uri: URI): Window | undefined { - return this.windowService.openNewWindow(uri.toString(true), { external: true }); + async open(uri: URI): Promise<Window | undefined> { + const resolvedUri = await this.externalUriService.resolve(uri); + return this.windowService.openNewWindow(resolvedUri.toString(true), { external: true }); } } diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index a391ed3da3598..27f63b3034c98 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -1005,6 +1005,11 @@ export class ApplicationShell extends Widget { private readonly toDisposeOnActivationCheck = new DisposableCollection(); private assertActivated(widget: Widget): void { this.toDisposeOnActivationCheck.dispose(); + + const onDispose = () => this.toDisposeOnActivationCheck.dispose(); + widget.disposed.connect(onDispose); + this.toDisposeOnActivationCheck.push(Disposable.create(() => widget.disposed.disconnect(onDispose))); + let start = 0; const step: FrameRequestCallback = timestamp => { if (document.activeElement && widget.node.contains(document.activeElement)) { diff --git a/packages/core/src/browser/shell/shell-layout-restorer.ts b/packages/core/src/browser/shell/shell-layout-restorer.ts index 1e348a27fdc2c..dbd7f64761ed3 100644 --- a/packages/core/src/browser/shell/shell-layout-restorer.ts +++ b/packages/core/src/browser/shell/shell-layout-restorer.ts @@ -333,6 +333,9 @@ export class ShellLayoutRestorer implements CommandContribution { this.logger.warn(`Couldn't restore widget state for ${widget.id}. Error: ${e} `); } } + if (widget.isDisposed) { + return undefined; + } return widget; } catch (e) { if (ApplicationShellLayoutMigrationError.is(e)) { diff --git a/packages/core/src/browser/shell/theia-dock-panel.ts b/packages/core/src/browser/shell/theia-dock-panel.ts index 37f696cbdf54f..efbef03ba8258 100644 --- a/packages/core/src/browser/shell/theia-dock-panel.ts +++ b/packages/core/src/browser/shell/theia-dock-panel.ts @@ -18,6 +18,7 @@ import { find, toArray, ArrayExt } from '@phosphor/algorithm'; import { TabBar, Widget, DockPanel, Title, DockLayout } from '@phosphor/widgets'; import { Signal } from '@phosphor/signaling'; import { Disposable, DisposableCollection } from '../../common/disposable'; +import { MessageLoop } from '../widgets'; const MAXIMIZED_CLASS = 'theia-maximized'; @@ -140,14 +141,28 @@ export class TheiaDockPanel extends DockPanel { this.toDisposeOnToggleMaximized.dispose(); return; } + if (this.isAttached) { + MessageLoop.sendMessage(this, Widget.Msg.BeforeDetach); + this.node.remove(); + MessageLoop.sendMessage(this, Widget.Msg.AfterDetach); + } maximizedElement.style.display = 'block'; this.addClass(MAXIMIZED_CLASS); + MessageLoop.sendMessage(this, Widget.Msg.BeforeAttach); maximizedElement.appendChild(this.node); + MessageLoop.sendMessage(this, Widget.Msg.AfterAttach); this.fit(); this.toDisposeOnToggleMaximized.push(Disposable.create(() => { maximizedElement.style.display = 'none'; this.removeClass(MAXIMIZED_CLASS); + if (this.isAttached) { + MessageLoop.sendMessage(this, Widget.Msg.BeforeDetach); + this.node.remove(); + MessageLoop.sendMessage(this, Widget.Msg.AfterDetach); + } + MessageLoop.sendMessage(this, Widget.Msg.BeforeAttach); areaContainer.appendChild(this.node); + MessageLoop.sendMessage(this, Widget.Msg.AfterAttach); this.fit(); })); diff --git a/packages/core/src/browser/theming.ts b/packages/core/src/browser/theming.ts index a5c4c51473901..72949d198c16f 100644 --- a/packages/core/src/browser/theming.ts +++ b/packages/core/src/browser/theming.ts @@ -25,8 +25,11 @@ import { CommonMenus } from './common-frontend-contribution'; export const ThemeServiceSymbol = Symbol('ThemeService'); +export type ThemeType = 'light' | 'dark' | 'hc'; + export interface Theme { readonly id: string; + readonly type: ThemeType; readonly label: string; readonly description?: string; readonly editorTheme?: string; @@ -197,8 +200,9 @@ export class BuiltinThemeProvider { static readonly darkCss = require('../../src/browser/style/variables-dark.useable.css'); static readonly lightCss = require('../../src/browser/style/variables-bright.useable.css'); - static readonly darkTheme = { + static readonly darkTheme: Theme = { id: 'dark', + type: 'dark', label: 'Dark Theme', description: 'Bright fonts on dark backgrounds.', editorTheme: 'dark-plus', // loaded in /packages/monaco/src/browser/textmate/monaco-theme-registry.ts @@ -210,8 +214,9 @@ export class BuiltinThemeProvider { } }; - static readonly lightTheme = { + static readonly lightTheme: Theme = { id: 'light', + type: 'light', label: 'Light Theme', description: 'Dark fonts on light backgrounds.', editorTheme: 'light-plus', // loaded in /packages/monaco/src/browser/textmate/monaco-theme-registry.ts diff --git a/packages/monaco/src/browser/monaco-color-registry.ts b/packages/monaco/src/browser/monaco-color-registry.ts new file mode 100644 index 0000000000000..f776ae4d9a721 --- /dev/null +++ b/packages/monaco/src/browser/monaco-color-registry.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { ColorRegistry, ColorOptions } from '@theia/core/lib/browser/color-registry'; +import { Disposable } from '@theia/core/lib/common/disposable'; + +@injectable() +export class MonacoColorRegistry implements ColorRegistry { + + protected readonly monacoThemeService = monaco.services.StaticServices.standaloneThemeService.get(); + protected readonly monacoColorRegistry = monaco.color.getColorRegistry(); + + *getColors(): IterableIterator<string> { + for (const { id } of this.monacoColorRegistry.getColors()) { + yield id; + } + } + + getCurrentColor(id: string): string | undefined { + const color = this.monacoThemeService.getTheme().getColor(id); + return color && color.toString(); + } + + register(id: string, options: ColorOptions): Disposable { + const identifier = this.monacoColorRegistry.registerColor(id, options.defaults, options.description); + return Disposable.create(() => this.monacoColorRegistry.deregisterColor(identifier)); + } + +} diff --git a/packages/monaco/src/browser/monaco-frontend-module.ts b/packages/monaco/src/browser/monaco-frontend-module.ts index 8d205dace3dee..91f1967f016a4 100644 --- a/packages/monaco/src/browser/monaco-frontend-module.ts +++ b/packages/monaco/src/browser/monaco-frontend-module.ts @@ -18,6 +18,7 @@ import '../../src/browser/style/index.css'; import '../../src/browser/style/symbol-sprite.svg'; import '../../src/browser/style/symbol-icons.css'; +import debounce = require('lodash.debounce'); import { ContainerModule, decorate, injectable, interfaces } from 'inversify'; import { MenuContribution, CommandContribution } from '@theia/core/lib/common'; import { PreferenceScope } from '@theia/core/lib/common/preferences/preference-scope'; @@ -58,9 +59,9 @@ import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { MonacoContextKeyService } from './monaco-context-key-service'; import { MonacoMimeService } from './monaco-mime-service'; import { MimeService } from '@theia/core/lib/browser/mime-service'; - -import debounce = require('lodash.debounce'); import { MonacoEditorServices } from './monaco-editor'; +import { MonacoColorRegistry } from './monaco-color-registry'; +import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; decorate(injectable(), MonacoToProtocolConverter); decorate(injectable(), ProtocolToMonacoConverter); @@ -130,6 +131,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(MonacoMimeService).toSelf().inSingletonScope(); rebind(MimeService).toService(MonacoMimeService); + + bind(MonacoColorRegistry).toSelf().inSingletonScope(); + rebind(ColorRegistry).toService(MonacoColorRegistry); }); export const MonacoConfigurationService = Symbol('MonacoConfigurationService'); diff --git a/packages/monaco/src/browser/monaco-loader.ts b/packages/monaco/src/browser/monaco-loader.ts index 5a3a7b9b8cd3d..b7455839b92c7 100644 --- a/packages/monaco/src/browser/monaco-loader.ts +++ b/packages/monaco/src/browser/monaco-loader.ts @@ -61,6 +61,7 @@ export function loadMonaco(vsRequire: any): Promise<void> { 'vs/base/parts/quickopen/browser/quickOpenModel', 'vs/base/common/filters', 'vs/platform/theme/common/styler', + 'vs/platform/theme/common/colorRegistry', 'vs/base/common/platform', 'vs/editor/common/modes', 'vs/editor/contrib/suggest/suggest', @@ -76,7 +77,8 @@ export function loadMonaco(vsRequire: any): Promise<void> { ], (css: any, html: any, commands: any, actions: any, keybindingsRegistry: any, keybindingResolver: any, resolvedKeybinding: any, keybindingLabels: any, keyCodes: any, mime: any, editorExtensions: any, simpleServices: any, standaloneServices: any, quickOpenWidget: any, quickOpenModel: any, - filters: any, styler: any, platform: any, modes: any, suggest: any, snippetParser: any, + filters: any, styler: any, colorRegistry: any, + platform: any, modes: any, suggest: any, snippetParser: any, configuration: any, configurationModels: any, codeEditorService: any, codeEditorServiceImpl: any, markerService: any, @@ -91,6 +93,7 @@ export function loadMonaco(vsRequire: any): Promise<void> { global.monaco.quickOpen = Object.assign({}, quickOpenWidget, quickOpenModel); global.monaco.filters = filters; global.monaco.theme = styler; + global.monaco.color = colorRegistry; global.monaco.platform = platform; global.monaco.editorExtensions = editorExtensions; global.monaco.modes = modes; diff --git a/packages/monaco/src/typings/monaco/index.d.ts b/packages/monaco/src/typings/monaco/index.d.ts index 9a73d4e767b87..c71fe10fa5f73 100644 --- a/packages/monaco/src/typings/monaco/index.d.ts +++ b/packages/monaco/src/typings/monaco/index.d.ts @@ -477,6 +477,7 @@ declare module monaco.services { export interface IStandaloneTheme { tokenTheme: TokenTheme; + getColor(color: string): Color | undefined; } export interface TokenTheme { @@ -537,6 +538,23 @@ declare module monaco.theme { export function attachQuickOpenStyler(widget: IThemable, themeService: IThemeService): monaco.IDisposable; } +declare module monaco.color { + export interface ColorContribution { + readonly id: string; + } + export interface ColorDefaults { + ligh?: string; + dark?: string; + hc?: string; + } + export interface IColorRegistry { + getColors(): ColorContribution[]; + registerColor(id: string, defaults: ColorDefaults | undefined, description: string): string; + deregisterColor(id: string): void; + } + export function getColorRegistry(): IColorRegistry; +} + declare module monaco.referenceSearch { export interface Location { diff --git a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts index 94661977e454e..865f3b1c41975 100644 --- a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts +++ b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts @@ -24,7 +24,6 @@ import { EditorManager } from '@theia/editor/lib/browser'; import { TextDocumentShowOptions } from '@theia/plugin-ext/lib/common/plugin-api-rpc-model'; import { DocumentsMainImpl } from '@theia/plugin-ext/lib/main/browser/documents-main'; import { createUntitledResource } from '@theia/plugin-ext/lib/main/browser/editor/untitled-resource'; -import { WebviewWidget } from '@theia/plugin-ext/lib/main/browser/webview/webview'; import { fromViewColumn, toDocumentSymbol } from '@theia/plugin-ext/lib/plugin/type-converters'; import { ViewColumn } from '@theia/plugin-ext/lib/plugin/types-impl'; import { WorkspaceCommands } from '@theia/workspace/lib/browser'; @@ -44,10 +43,6 @@ export namespace VscodeCommands { export const SET_CONTEXT: Command = { id: 'setContext' }; - - export const PREVIEW_HTML: Command = { - id: 'vscode.previewHtml' - }; } @injectable() @@ -123,26 +118,6 @@ export class PluginVscodeCommandsContribution implements CommandContribution { this.contextKeyService.createKey(String(contextKey), contextValue); } }); - commands.registerCommand(VscodeCommands.PREVIEW_HTML, { - isVisible: () => false, - // tslint:disable-next-line: no-any - execute: async (resource: URI, position?: any, label?: string, options?: any) => { - label = label || resource.fsPath; - const view = new WebviewWidget(label, { allowScripts: true }, {}, this.mouseTracker); - const res = await this.resources(new TheiaURI(resource)); - const str = await res.readContents(); - const html = this.getHtml(str); - this.shell.addWidget(view, { area: 'main', mode: 'split-right' }); - this.shell.activateWidget(view.id); - view.setHTML(html); - - const editorWidget = await this.editorManager.getOrCreateByUri(new TheiaURI(resource)); - editorWidget.editor.onDocumentContentChanged(listener => { - view.setHTML(this.getHtml(editorWidget.editor.document.getText())); - }); - - } - }); // https://code.visualstudio.com/docs/getstarted/keybindings#_navigation /* @@ -337,8 +312,4 @@ export class PluginVscodeCommandsContribution implements CommandContribution { // see https://github.com/microsoft/vscode/blob/master/src/vs/workbench/api/common/extHostApiCommands.ts } - private getHtml(body: String): string { - return `<!DOCTYPE html><html><head></head>${body}</html>`; - } - } diff --git a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts index 97cb8684b9be1..601607dca3d85 100644 --- a/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts +++ b/packages/plugin-ext-vscode/src/node/plugin-vscode-init.ts @@ -54,33 +54,6 @@ export const doInitialization: BackendInitializationFn = (apiFactory: PluginAPIF return registerCommand(command, handler, thisArg); }; - // replace createWebviewPanel API for override html setter - const createWebviewPanel = vscode.window.createWebviewPanel; - vscode.window.createWebviewPanel = function (viewType: string, title: string, showOptions: any, options: any | undefined): any { - const panel = createWebviewPanel(viewType, title, showOptions, options); - // redefine property - Object.defineProperty(panel.webview, 'html', { - set: function (html: string): void { - const newHtml = html.replace(new RegExp('vscode-resource:/', 'g'), '/webview/'); - this.checkIsDisposed(); - if (this._html !== newHtml) { - this._html = newHtml; - this.proxy.$setHtml(this.viewId, newHtml); - } - } - }); - - // override postMessage method to replace vscode-resource: - const originalPostMessage = panel.webview.postMessage; - panel.webview.postMessage = (message: any): PromiseLike<boolean> => { - const decoded = JSON.stringify(message); - const newMessage = decoded.replace(new RegExp('vscode-resource:/', 'g'), '/webview/'); - return originalPostMessage.call(panel.webview, JSON.parse(newMessage)); - }; - - return panel; - }; - // use Theia plugin api instead vscode extensions (<any>vscode).extensions = { get all(): any[] { diff --git a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts index 82407d75674ee..ad47e1eabb6bc 100644 --- a/packages/plugin-ext-vscode/src/node/scanner-vscode.ts +++ b/packages/plugin-ext-vscode/src/node/scanner-vscode.ts @@ -28,11 +28,6 @@ export class VsCodePluginScanner extends TheiaPluginScanner implements PluginSca } getModel(plugin: PluginPackage): PluginModel { - // translate vscode builtins, as they are published with a prefix. See https://github.com/theia-ide/vscode-builtin-extensions/blob/master/src/republish.js#L50 - const built_prefix = '@theia/vscode-builtin-'; - if (plugin && plugin.name && plugin.name.startsWith(built_prefix)) { - plugin.name = plugin.name.substr(built_prefix.length); - } const result: PluginModel = { packagePath: plugin.packagePath, // see id definition: https://github.com/microsoft/vscode/blob/15916055fe0cb9411a5f36119b3b012458fe0a1d/src/vs/platform/extensions/common/extensions.ts#L167-L169 diff --git a/packages/plugin-ext/package.json b/packages/plugin-ext/package.json index 52b63f9b21154..1f42bceaf3039 100644 --- a/packages/plugin-ext/package.json +++ b/packages/plugin-ext/package.json @@ -24,14 +24,21 @@ "@theia/task": "^0.12.0", "@theia/terminal": "^0.12.0", "@theia/workspace": "^0.12.0", + "@types/connect": "^3.4.32", + "@types/mime": "^2.0.1", + "@types/serve-static": "^1.13.3", + "connect": "^3.7.0", "decompress": "^4.2.0", "escape-html": "^1.0.3", "jsonc-parser": "^2.0.2", "lodash.clonedeep": "^4.5.0", "macaddress": "^0.2.9", + "mime": "^2.4.4", "ps-tree": "^1.2.0", "request": "^2.82.0", + "serve-static": "^1.14.1", "uuid": "^3.2.1", + "vhost": "^3.0.2", "vscode-debugprotocol": "^1.32.0", "vscode-textmate": "^4.0.1" }, diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index a958e0fa5db9b..62d99bbc3cb7c 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -162,6 +162,7 @@ export interface PluginManagerInitializeParams { workspaceState: KeysToKeysToAnyValue env: EnvInit extApi?: ExtPluginApi[] + webview: WebviewInitData } export interface PluginManagerStartParams { @@ -560,6 +561,7 @@ export enum TreeViewItemCollapsibleState { export interface WindowMain { $openUri(uri: UriComponents): Promise<boolean>; + $asExternalUri(uri: UriComponents): Promise<UriComponents>; } export interface WindowStateExt { @@ -1218,6 +1220,11 @@ export interface LanguagesMain { $registerRenameProvider(handle: number, pluginInfo: PluginInfo, selector: SerializedDocumentFilter[], supportsResoveInitialValues: boolean): void; } +export interface WebviewInitData { + webviewResourceRoot: string + webviewCspSource: string +} + export interface WebviewPanelViewState { readonly active: boolean; readonly visible: boolean; @@ -1232,7 +1239,7 @@ export interface WebviewsExt { viewType: string, title: string, state: any, - position: number, + viewState: WebviewPanelViewState, options: theia.WebviewOptions & theia.WebviewPanelOptions): PromiseLike<void>; } @@ -1241,12 +1248,11 @@ export interface WebviewsMain { viewType: string, title: string, showOptions: theia.WebviewPanelShowOptions, - options: theia.WebviewPanelOptions & theia.WebviewOptions | undefined, - pluginLocation: UriComponents): void; + options: theia.WebviewPanelOptions & theia.WebviewOptions): void; $disposeWebview(handle: string): void; $reveal(handle: string, showOptions: theia.WebviewPanelShowOptions): void; $setTitle(handle: string, value: string): void; - $setIconPath(handle: string, value: { light: string, dark: string } | string | undefined): void; + $setIconPath(handle: string, value: IconUrl | undefined): void; $setHtml(handle: string, value: string): void; $setOptions(handle: string, options: theia.WebviewOptions): void; $postMessage(handle: string, value: any): Thenable<boolean>; diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index e9c554f23cc88..b30e6286ee8a9 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -53,6 +53,9 @@ import { Emitter, isCancelled } from '@theia/core'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { PluginViewRegistry } from '../../main/browser/view/plugin-view-registry'; import { TaskProviderRegistry, TaskResolverRegistry } from '@theia/task/lib/browser/task-contribution'; +import { WebviewEnvironment } from '../../main/browser/webview/webview-environment'; +import { WebviewWidget } from '../../main/browser/webview/webview'; +import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; export type PluginHost = 'frontend' | string; export type DebugActivationEvent = 'onDebugResolve' | 'onDebugInitialConfigurations' | 'onDebugAdapterProtocolTracker'; @@ -127,6 +130,12 @@ export class HostedPluginSupport { @inject(ProgressService) protected readonly progressService: ProgressService; + @inject(WebviewEnvironment) + protected readonly webviewEnvironment: WebviewEnvironment; + + @inject(WidgetManager) + protected readonly widgets: WidgetManager; + private theiaReadyPromise: Promise<any>; protected readonly managers = new Map<string, PluginManagerExt>(); @@ -154,6 +163,26 @@ export class HostedPluginSupport { this.viewRegistry.onDidExpandView(id => this.activateByView(id)); this.taskProviderRegistry.onWillProvideTaskProvider(event => this.ensureTaskActivation(event)); this.taskResolverRegistry.onWillProvideTaskResolver(event => this.ensureTaskActivation(event)); + this.widgets.onDidCreateWidget(({ factoryId, widget }) => { + if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) { + const storeState = widget.storeState.bind(widget); + const restoreState = widget.restoreState.bind(widget); + widget.storeState = () => { + if (this.webviewRevivers.has(widget.viewType)) { + return storeState(); + } + return {}; + }; + widget.restoreState = oldState => { + if (oldState.viewType) { + restoreState(oldState); + this.preserveWebview(widget); + } else { + widget.dispose(); + } + }; + } + }); } get plugins(): PluginMetadata[] { @@ -181,6 +210,7 @@ export class HostedPluginSupport { protected async doLoad(): Promise<void> { const toDisconnect = new DisposableCollection(Disposable.create(() => { /* mark as connected */ })); + toDisconnect.push(Disposable.create(() => this.preserveWebviews())); this.server.onDidCloseConnection(() => toDisconnect.dispose()); // process empty plugins as well in order to properly remove stale plugin widgets @@ -207,6 +237,7 @@ export class HostedPluginSupport { return; } await this.startPlugins(contributionsByHost, toDisconnect); + this.restoreWebviews(); } /** @@ -355,13 +386,15 @@ export class HostedPluginSupport { this.managers.set(host, manager); toDisconnect.push(Disposable.create(() => this.managers.delete(host))); - const [extApi, globalState, workspaceState] = await Promise.all([ + const [extApi, globalState, workspaceState, webviewResourceRoot, webviewCspSource] = await Promise.all([ this.server.getExtPluginAPI(), this.pluginServer.getAllStorageValues(undefined), this.pluginServer.getAllStorageValues({ workspace: this.workspaceService.workspace, roots: this.workspaceService.tryGetRoots() - }) + }), + this.webviewEnvironment.resourceRoot(), + this.webviewEnvironment.cspSource() ]); if (toDisconnect.disposed) { return undefined; @@ -372,7 +405,11 @@ export class HostedPluginSupport { globalState, workspaceState, env: { queryParams: getQueryParameters(), language: navigator.language }, - extApi + extApi, + webview: { + webviewResourceRoot, + webviewCspSource + } }); if (toDisconnect.disposed) { return undefined; @@ -554,6 +591,71 @@ export class HostedPluginSupport { console.log(`[${this.clientId}] ${prefix} of ${pluginCount} took: ${measurement()} ms`); } + protected readonly webviewsToRestore = new Set<WebviewWidget>(); + protected readonly webviewRevivers = new Map<string, (webview: WebviewWidget) => Promise<void>>(); + + registerWebviewReviver(viewType: string, reviver: (webview: WebviewWidget) => Promise<void>): void { + if (this.webviewRevivers.has(viewType)) { + throw new Error(`Reviver for ${viewType} already registered`); + } + this.webviewRevivers.set(viewType, reviver); + } + + unregisterWebviewReviver(viewType: string): void { + this.webviewRevivers.delete(viewType); + } + + protected preserveWebviews(): void { + for (const webview of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) { + this.preserveWebview(webview as WebviewWidget); + } + } + + protected preserveWebview(webview: WebviewWidget): void { + if (!this.webviewsToRestore.has(webview)) { + this.webviewsToRestore.add(webview); + webview.disposed.connect(() => this.webviewsToRestore.delete(webview)); + } + } + + protected restoreWebviews(): void { + for (const webview of this.webviewsToRestore) { + this.restoreWebview(webview); + } + this.webviewsToRestore.clear(); + } + + protected async restoreWebview(webview: WebviewWidget): Promise<void> { + await this.activateByEvent(`onWebviewPanel:${webview.viewType}`); + const restore = this.webviewRevivers.get(webview.viewType); + if (!restore) { + webview.setHTML(this.getDeserializationFailedContents(` + <p>The extension providing '${webview.viewType}' view is not capable of restoring it.</p> + <p>Want to help fix this? Please inform the extension developer to register a <a href="https://code.visualstudio.com/api/extension-guides/webview#serialization">reviver</a>.</p> + `)); + return; + } + try { + await restore(webview); + } catch (e) { + webview.setHTML(this.getDeserializationFailedContents(` + An error occurred while restoring '${webview.viewType}' view. Please check logs. + `)); + console.error('Failed to restore the webview', e); + } + } + + protected getDeserializationFailedContents(message: string): string { + return `<!DOCTYPE html> + <html> + <head> + <meta http-equiv="Content-type" content="text/html;charset=UTF-8"> + <meta http-equiv="Content-Security-Policy" content="default-src 'none';"> + </head> + <body>${message}</body> + </html>`; + } + } export class PluginContributions extends DisposableCollection { diff --git a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts index d582cbaf27329..3271f319cbb60 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts @@ -30,6 +30,7 @@ import { MessageRegistryExt } from '../../../plugin/message-registry'; import { WorkerEnvExtImpl } from './worker-env-ext'; import { ClipboardExt } from '../../../plugin/clipboard-ext'; import { KeyValueStorageProxy } from '../../../plugin/plugin-storage'; +import { WebviewsExtImpl } from '../../../plugin/webviews'; // tslint:disable-next-line:no-any const ctx = self as any; @@ -59,6 +60,7 @@ const workspaceExt = new WorkspaceExtImpl(rpc, editorsAndDocuments, messageRegis const preferenceRegistryExt = new PreferenceRegistryExtImpl(rpc, workspaceExt); const debugExt = createDebugExtStub(rpc); const clipboardExt = new ClipboardExt(rpc); +const webviewExt = new WebviewsExtImpl(rpc, workspaceExt); const pluginManager = new PluginManagerExtImpl({ // tslint:disable-next-line:no-any @@ -131,7 +133,7 @@ const pluginManager = new PluginManagerExtImpl({ } } } -}, envExt, storageProxy, preferenceRegistryExt, rpc); +}, envExt, storageProxy, preferenceRegistryExt, webviewExt, rpc); const apiFactory = createAPIFactory( rpc, @@ -142,7 +144,8 @@ const apiFactory = createAPIFactory( editorsAndDocuments, workspaceExt, messageRegistryExt, - clipboardExt + clipboardExt, + webviewExt ); let defaultApi: typeof theia; @@ -169,6 +172,7 @@ rpc.set(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, pluginManager); rpc.set(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, editorsAndDocuments); rpc.set(MAIN_RPC_CONTEXT.WORKSPACE_EXT, workspaceExt); rpc.set(MAIN_RPC_CONTEXT.PREFERENCE_REGISTRY_EXT, preferenceRegistryExt); +rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, webviewExt); function isElectron(): boolean { if (typeof navigator === 'object' && typeof navigator.userAgent === 'string' && navigator.userAgent.indexOf('Electron') >= 0) { diff --git a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts index 94d68e15acbcd..e0c05570c856f 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts @@ -29,6 +29,7 @@ import { EnvNodeExtImpl } from '../../plugin/node/env-node-ext'; import { ClipboardExt } from '../../plugin/clipboard-ext'; import { loadManifest } from './plugin-manifest-loader'; import { KeyValueStorageProxy } from '../../plugin/plugin-storage'; +import { WebviewsExtImpl } from '../../plugin/webviews'; /** * Handle the RPC calls. @@ -52,12 +53,14 @@ export class PluginHostRPC { const workspaceExt = new WorkspaceExtImpl(this.rpc, editorsAndDocumentsExt, messageRegistryExt); const preferenceRegistryExt = new PreferenceRegistryExtImpl(this.rpc, workspaceExt); const clipboardExt = new ClipboardExt(this.rpc); - this.pluginManager = this.createPluginManager(envExt, storageProxy, preferenceRegistryExt, this.rpc); + const webviewExt = new WebviewsExtImpl(this.rpc, workspaceExt); + this.pluginManager = this.createPluginManager(envExt, storageProxy, preferenceRegistryExt, webviewExt, this.rpc); this.rpc.set(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, this.pluginManager); this.rpc.set(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, editorsAndDocumentsExt); this.rpc.set(MAIN_RPC_CONTEXT.WORKSPACE_EXT, workspaceExt); this.rpc.set(MAIN_RPC_CONTEXT.PREFERENCE_REGISTRY_EXT, preferenceRegistryExt); this.rpc.set(MAIN_RPC_CONTEXT.STORAGE_EXT, storageProxy); + this.rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, webviewExt); this.apiFactory = createAPIFactory( this.rpc, @@ -68,7 +71,8 @@ export class PluginHostRPC { editorsAndDocumentsExt, workspaceExt, messageRegistryExt, - clipboardExt + clipboardExt, + webviewExt ); } @@ -84,8 +88,10 @@ export class PluginHostRPC { } } - // tslint:disable-next-line:no-any - createPluginManager(envExt: EnvExtImpl, storageProxy: KeyValueStorageProxy, preferencesManager: PreferenceRegistryExtImpl, rpc: any): PluginManagerExtImpl { + createPluginManager( + envExt: EnvExtImpl, storageProxy: KeyValueStorageProxy, preferencesManager: PreferenceRegistryExtImpl, webview: WebviewsExtImpl, + // tslint:disable-next-line:no-any + rpc: any): PluginManagerExtImpl { const { extensionTestsPath } = process.env; const self = this; const pluginManager = new PluginManagerExtImpl({ @@ -216,7 +222,7 @@ export class PluginHostRPC { `Path ${extensionTestsPath} does not point to a valid extension test runner.` ); } : undefined - }, envExt, storageProxy, preferencesManager, rpc); + }, envExt, storageProxy, preferencesManager, webview, rpc); return pluginManager; } } diff --git a/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts b/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts index 6b64f6f8b0ae2..46feb067dffac 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-manifest-loader.ts @@ -26,6 +26,11 @@ export async function loadManifest(pluginPath: string): Promise<any> { fs.readJson(path.join(pluginPath, 'package.json')), loadTranslations(pluginPath) ]); + // translate vscode builtins, as they are published with a prefix. See https://github.com/theia-ide/vscode-builtin-extensions/blob/master/src/republish.js#L50 + const built_prefix = '@theia/vscode-builtin-'; + if (manifest && manifest.name && manifest.name.startsWith(built_prefix)) { + manifest.name = manifest.name.substr(built_prefix.length); + } return manifest && translations && Object.keys(translations).length ? localize(manifest, translations) : manifest; diff --git a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts index fcd92dda94edd..108ee70b2f5a2 100644 --- a/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/menus/menus-contribution-handler.ts @@ -21,6 +21,7 @@ import { injectable, inject } from 'inversify'; import { MenuPath, ILogger, CommandRegistry, Command, Mutable, MenuAction, SelectionService, CommandHandler, Disposable, DisposableCollection } from '@theia/core'; import { EDITOR_CONTEXT_MENU, EditorWidget } from '@theia/editor/lib/browser'; import { MenuModelRegistry } from '@theia/core/lib/common'; +import { Emitter } from '@theia/core/lib/common/event'; import { TabBarToolbarRegistry, TabBarToolbarItem } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { NAVIGATOR_CONTEXT_MENU } from '@theia/navigator/lib/browser/navigator-contribution'; import { QuickCommandService } from '@theia/core/lib/browser/quick-open/quick-command-service'; @@ -38,6 +39,7 @@ import { PluginViewWidget } from '../view/plugin-view-widget'; import { ViewContextKeyService } from '../view/view-context-key-service'; import { WebviewWidget } from '../webview/webview'; import { Navigatable } from '@theia/core/lib/browser/navigatable'; +import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; type CodeEditorWidget = EditorWidget | WebviewWidget; export namespace CodeEditorWidget { @@ -80,6 +82,9 @@ export class MenusContributionPointHandler { @inject(ViewContextKeyService) protected readonly viewContextKeys: ViewContextKeyService; + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + handle(contributions: PluginContribution): Disposable { const allMenus = contributions.menus; if (!allMenus) { @@ -194,6 +199,23 @@ export class MenusContributionPointHandler { toDispose.push(this.commands.registerCommand(command, handler)); const { when } = action; + const whenKeys = when && this.contextKeyService.parseKeys(when); + let onDidChange; + if (whenKeys && whenKeys.size) { + const onDidChangeEmitter = new Emitter<void>(); + toDispose.push(onDidChangeEmitter); + onDidChange = onDidChangeEmitter.event; + this.contextKeyService.onDidChange.maxListeners = this.contextKeyService.onDidChange.maxListeners + 1; + toDispose.push(this.contextKeyService.onDidChange(event => { + if (event.affects(whenKeys)) { + onDidChangeEmitter.fire(undefined); + } + })); + toDispose.push(Disposable.create(() => { + this.contextKeyService.onDidChange.maxListeners = this.contextKeyService.onDidChange.maxListeners - 1; + })); + } + // handle group and priority // if group is empty or white space is will be set to navigation // ' ' => ['navigation', 0] @@ -202,7 +224,7 @@ export class MenusContributionPointHandler { // if priority is not a number it will be set to 0 // navigation@test => ['navigation', 0] const [group, sort] = (action.group || 'navigation').split('@'); - const item: Mutable<TabBarToolbarItem> = { id, command: id, group: group.trim() || 'navigation', priority: ~~sort || undefined, when }; + const item: Mutable<TabBarToolbarItem> = { id, command: id, group: group.trim() || 'navigation', priority: ~~sort || undefined, when, onDidChange }; toDispose.push(this.tabBarToolbar.registerItem(item)); toDispose.push(this.onDidRegisterCommand(action.command, pluginCommand => { diff --git a/packages/plugin-ext/src/main/browser/plugin-command-open-handler.ts b/packages/plugin-ext/src/main/browser/plugin-command-open-handler.ts new file mode 100644 index 0000000000000..4d685ab30a446 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/plugin-command-open-handler.ts @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (C) 2019 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, inject } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { OpenHandler } from '@theia/core/lib/browser/opener-service'; +import { Schemes } from '../../common/uri-components'; +import { CommandService } from '@theia/core/lib/common/command'; + +@injectable() +export class PluginCommandOpenHandler implements OpenHandler { + + readonly id = 'plugin-command'; + + @inject(CommandService) + protected readonly commands: CommandService; + + canHandle(uri: URI): number { + return uri.scheme === Schemes.COMMAND ? 500 : -1; + } + + async open(uri: URI): Promise<boolean> { + // tslint:disable-next-line:no-any + let args: any = []; + try { + args = JSON.parse(uri.query); + if (!Array.isArray(args)) { + args = [args]; + } + } catch (e) { + // ignore error + } + await this.commands.executeCommand(uri.path.toString(), ...args); + return true; + } + +} diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 5c6dfc110ea9d..ca0daed42b0e6 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -20,7 +20,7 @@ import '../../../src/main/browser/style/index.css'; import { ContainerModule } from 'inversify'; import { FrontendApplicationContribution, FrontendApplication, WidgetFactory, bindViewContribution, - ViewContainerIdentifier, ViewContainer, createTreeContainer, TreeImpl, TreeWidget, TreeModelImpl + ViewContainerIdentifier, ViewContainer, createTreeContainer, TreeImpl, TreeWidget, TreeModelImpl, OpenHandler } from '@theia/core/lib/browser'; import { MaybePromise, CommandContribution, ResourceResolver, bindContributionProvider } from '@theia/core/lib/common'; import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging'; @@ -63,6 +63,13 @@ import { LanguagesMainFactory, OutputChannelRegistryFactory } from '../../common import { LanguagesMainImpl } from './languages-main'; import { OutputChannelRegistryMainImpl } from './output-channel-registry-main'; import { InPluginFileSystemWatcherManager } from './in-plugin-filesystem-watcher-manager'; +import { WebviewWidget, WebviewWidgetIdentifier, WebviewWidgetExternalEndpoint } from './webview/webview'; +import { WebviewEnvironment } from './webview/webview-environment'; +import { WebviewThemeDataProvider } from './webview/webview-theme-data-provider'; +import { PluginCommandOpenHandler } from './plugin-command-open-handler'; +import { bindWebviewPreferences } from './webview/webview-preferences'; +import { WebviewResourceLoader, WebviewResourceLoaderPath } from '../common/webview-protocol'; +import { WebviewResourceCache } from './webview/webview-resource-cache'; export default new ContainerModule((bind, unbind, isBound, rebind) => { @@ -146,6 +153,33 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { } })).inSingletonScope(); + bind(PluginCommandOpenHandler).toSelf().inSingletonScope(); + bind(OpenHandler).toService(PluginCommandOpenHandler); + + bindWebviewPreferences(bind); + bind(WebviewEnvironment).toSelf().inSingletonScope(); + bind(WebviewThemeDataProvider).toSelf().inSingletonScope(); + bind(WebviewResourceLoader).toDynamicValue(ctx => + WebSocketConnectionProvider.createProxy(ctx.container, WebviewResourceLoaderPath) + ).inSingletonScope(); + bind(WebviewResourceCache).toSelf().inSingletonScope(); + bind(WebviewWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: WebviewWidget.FACTORY_ID, + createWidget: async (identifier: WebviewWidgetIdentifier) => { + const externalEndpoint = await container.get(WebviewEnvironment).externalEndpoint(); + let endpoint = externalEndpoint.replace('{{uuid}}', identifier.id); + if (endpoint[endpoint.length - 1] === '/') { + endpoint = endpoint.slice(0, endpoint.length - 1); + } + + const child = container.createChild(); + child.bind(WebviewWidgetIdentifier).toConstantValue(identifier); + child.bind(WebviewWidgetExternalEndpoint).toConstantValue(endpoint); + return child.get(WebviewWidget); + } + })).inSingletonScope(); + bind(PluginViewWidget).toSelf(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PLUGIN_VIEW_FACTORY_ID, diff --git a/packages/plugin-ext/src/main/browser/plugin-shared-style.ts b/packages/plugin-ext/src/main/browser/plugin-shared-style.ts index be303d75aaa9b..42c7c5a3419a0 100644 --- a/packages/plugin-ext/src/main/browser/plugin-shared-style.ts +++ b/packages/plugin-ext/src/main/browser/plugin-shared-style.ts @@ -78,9 +78,8 @@ export class PluginSharedStyle { }): void { const sheet = (<CSSStyleSheet>this.style.sheet); const cssBody = body(ThemeService.get().getCurrentTheme()); - sheet.insertRule(selector + ' { ' + cssBody + ' }', 0); + sheet.insertRule(selector + ' {\n' + cssBody + '\n}', 0); } - deleteRule(selector: string): void { const sheet = (<CSSStyleSheet>this.style.sheet); const rules = sheet.rules || sheet.cssRules || []; diff --git a/packages/plugin-ext/src/main/browser/style/webview.css b/packages/plugin-ext/src/main/browser/style/webview.css index 4484abee04005..61a449d613eb9 100644 --- a/packages/plugin-ext/src/main/browser/style/webview.css +++ b/packages/plugin-ext/src/main/browser/style/webview.css @@ -14,6 +14,11 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +.theia-webview.p-mod-hidden { + visibility: hidden; + display: flex !important; +} + .theia-webview { display: flex; flex-direction: column; @@ -25,12 +30,12 @@ border: none; margin: 0; padding: 0; } -.webview-icon { +.theia-webview-icon { background: none !important; min-height: 20px; } -.webview-icon::before { +.theia-webview-icon::before { background-size: 13px; background-repeat: no-repeat; vertical-align: middle; @@ -41,7 +46,7 @@ content: ""; } -.p-TabBar.theia-app-sides .webview-icon::before { +.p-TabBar.theia-app-sides .theia-webview-icon::before { width: var(--theia-private-sidebar-icon-size); height: var(--theia-private-sidebar-icon-size); background-size: contain; diff --git a/packages/plugin-ext/src/main/browser/view-column-service.ts b/packages/plugin-ext/src/main/browser/view-column-service.ts index 4c0384e2b7399..aad11a196eb6e 100644 --- a/packages/plugin-ext/src/main/browser/view-column-service.ts +++ b/packages/plugin-ext/src/main/browser/view-column-service.ts @@ -18,6 +18,7 @@ import { injectable, inject } from 'inversify'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { toArray } from '@phosphor/algorithm'; +import { TabBar, Widget } from '@phosphor/widgets'; @injectable() export class ViewColumnService { @@ -51,26 +52,59 @@ export class ViewColumnService { } updateViewColumns(): void { - const positionIds = new Map<number, string[]>(); - toArray(this.shell.mainPanel.tabBars()).forEach(tabBar => { - if (!tabBar.node.style.left) { - return; - } - const position = parseInt(tabBar.node.style.left); - const viewColumnIds = tabBar.titles.map(title => title.owner.id); - positionIds.set(position, viewColumnIds); - }); this.columnValues.clear(); this.viewColumnIds.clear(); - [...positionIds.keys()].sort((a, b) => a - b).forEach((key: number, viewColumn: number) => { - positionIds.get(key)!.forEach((id: string) => { - this.columnValues.set(id, viewColumn); - if (!this.viewColumnIds.has(viewColumn)) { - this.viewColumnIds.set(viewColumn, []); + + const rows = new Map<number, Set<number>>(); + const columns = new Map<number, Map<number, TabBar<Widget>>>(); + for (const tabBar of toArray(this.shell.mainPanel.tabBars())) { + if (!tabBar.node.style.top || !tabBar.node.style.left) { + continue; + } + const top = parseInt(tabBar.node.style.top); + const left = parseInt(tabBar.node.style.left); + + const row = rows.get(top) || new Set<number>(); + row.add(left); + rows.set(top, row); + + const column = columns.get(left) || new Map<number, TabBar<Widget>>(); + column.set(top, tabBar); + columns.set(left, column); + } + const firstRow = rows.get([...rows.keys()].sort()[0]); + if (!firstRow) { + return; + } + const lefts = [...firstRow.keys()].sort(); + for (let i = 0; i < lefts.length; i++) { + const column = columns.get(lefts[i]); + if (!column) { + break; + } + const cellIndexes = [...column.keys()].sort(); + let viewColumn = Math.min(i, 2); + for (let j = 0; j < cellIndexes.length; j++) { + const cell = column.get(cellIndexes[j]); + if (!cell) { + break; } - this.viewColumnIds.get(viewColumn)!.push(id); - }); - }); + this.setViewColumn(cell, viewColumn); + if (viewColumn < 7) { + viewColumn += 3; + } + } + } + } + + protected setViewColumn(tabBar: TabBar<Widget>, viewColumn: number): void { + const ids = []; + for (const title of tabBar.titles) { + const id = title.owner.id; + ids.push(id); + this.columnValues.set(id, viewColumn); + } + this.viewColumnIds.set(viewColumn, ids); } getViewColumnIds(viewColumn: number): string[] { diff --git a/packages/plugin-ext/src/main/browser/webview/pre/fake.html b/packages/plugin-ext/src/main/browser/webview/pre/fake.html new file mode 100644 index 0000000000000..18c40421e34bc --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/fake.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="X-UA-Compatible" content="ie=edge"> + <title>Fake</title> +</head> + +<body> +</body> + +</html> diff --git a/packages/plugin-ext/src/main/browser/webview/pre/host.js b/packages/plugin-ext/src/main/browser/webview/pre/host.js new file mode 100644 index 0000000000000..b5fa2e9cd01b3 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/host.js @@ -0,0 +1,115 @@ +/******************************************************************************** + * Copyright (C) 2019 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/pre/host.js +// @ts-check +(function () { + const id = document.location.search.match(/\bid=([\w-]+)/)[1]; + + const hostMessaging = new class HostMessaging { + constructor() { + this.handlers = new Map(); + window.addEventListener('message', (e) => { + if (e.data && (e.data.command === 'onmessage' || e.data.command === 'do-update-state')) { + // Came from inner iframe + this.postMessage(e.data.command, e.data.data); + return; + } + + const channel = e.data.channel; + const handler = this.handlers.get(channel); + if (handler) { + handler(e, e.data.args); + } else { + console.error('no handler for ', e); + } + }); + } + + postMessage(channel, data) { + window.parent.postMessage({ target: id, channel, data }, '*'); + } + + onMessage(channel, handler) { + this.handlers.set(channel, handler); + } + }(); + + const workerReady = new Promise(async (resolveWorkerReady) => { + if (!areServiceWorkersEnabled()) { + console.error('Service Workers are not enabled. Webviews will not work properly'); + return resolveWorkerReady(); + } + + const expectedWorkerVersion = 1; + + navigator.serviceWorker.register('service-worker.js').then(async registration => { + await navigator.serviceWorker.ready; + + const versionHandler = (event) => { + if (event.data.channel !== 'version') { + return; + } + + navigator.serviceWorker.removeEventListener('message', versionHandler); + if (event.data.version === expectedWorkerVersion) { + return resolveWorkerReady(); + } else { + // If we have the wrong version, try once to unregister and re-register + return registration.update() + .then(() => navigator.serviceWorker.ready) + .finally(resolveWorkerReady); + } + }; + navigator.serviceWorker.addEventListener('message', versionHandler); + registration.active.postMessage({ channel: 'version' }); + }); + + const forwardFromHostToWorker = (channel) => { + hostMessaging.onMessage(channel, event => { + navigator.serviceWorker.ready.then(registration => { + registration.active.postMessage({ channel: channel, data: event.data.args }); + }); + }); + }; + forwardFromHostToWorker('did-load-resource'); + forwardFromHostToWorker('did-load-localhost'); + + navigator.serviceWorker.addEventListener('message', event => { + if (['load-resource', 'load-localhost'].includes(event.data.channel)) { + hostMessaging.postMessage(event.data.channel, event.data); + } + }); + }); + + function areServiceWorkersEnabled() { + try { + return !!navigator.serviceWorker; + } catch (e) { + return false; + } + } + + window.createWebviewManager({ + postMessage: hostMessaging.postMessage.bind(hostMessaging), + onMessage: hostMessaging.onMessage.bind(hostMessaging), + ready: workerReady, + fakeLoad: true + }); +}()); diff --git a/packages/plugin-ext/src/main/browser/webview/pre/index.html b/packages/plugin-ext/src/main/browser/webview/pre/index.html new file mode 100644 index 0000000000000..f4ed42759569d --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/index.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en" style="width: 100%; height: 100%;"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"> + <meta http-equiv="X-UA-Compatible" content="ie=edge"> + <title>Virtual Document</title> +</head> + +<body style="margin: 0; overflow: hidden; width: 100%; height: 100%"> + <script src="main.js"></script> + <script src="host.js"></script> +</body> + +</html> diff --git a/packages/plugin-ext/src/main/browser/webview/pre/main.js b/packages/plugin-ext/src/main/browser/webview/pre/main.js new file mode 100644 index 0000000000000..b667df984dd0d --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/main.js @@ -0,0 +1,577 @@ +/******************************************************************************** + * Copyright (C) 2019 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/pre/main.js +// @ts-check + +/** + * @typedef {{ + * postMessage: (channel: string, data?: any) => void, + * onMessage: (channel: string, handler: any) => void, + * focusIframeOnCreate?: boolean, + * ready?: Promise<void>, + * onIframeLoaded?: (iframe: HTMLIFrameElement) => void, + * fakeLoad: boolean + * }} WebviewHost + */ + +(function () { + 'use strict'; + + /** + * Use polling to track focus of main webview and iframes within the webview + * + * @param {Object} handlers + * @param {() => void} handlers.onFocus + * @param {() => void} handlers.onBlur + */ + const trackFocus = ({ onFocus, onBlur }) => { + const interval = 50; + let isFocused = document.hasFocus(); + setInterval(() => { + const isCurrentlyFocused = document.hasFocus(); + if (isCurrentlyFocused === isFocused) { + return; + } + isFocused = isCurrentlyFocused; + if (isCurrentlyFocused) { + onFocus(); + } else { + onBlur(); + } + }, interval); + }; + + const getActiveFrame = () => { + return /** @type {HTMLIFrameElement} */ (document.getElementById('active-frame')); + }; + + const getPendingFrame = () => { + return /** @type {HTMLIFrameElement} */ (document.getElementById('pending-frame')); + }; + + const defaultCssRules = ` + body { + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-weight: var(--vscode-font-weight); + font-size: var(--vscode-font-size); + margin: 0; + padding: 0 20px; + } + + img { + max-width: 100%; + max-height: 100%; + } + + a { + color: var(--vscode-textLink-foreground); + } + + a:hover { + color: var(--vscode-textLink-activeForeground); + } + + a:focus, + input:focus, + select:focus, + textarea:focus { + outline: 1px solid -webkit-focus-ring-color; + outline-offset: -1px; + } + + code { + color: var(--vscode-textPreformat-foreground); + } + + blockquote { + background: var(--vscode-textBlockQuote-background); + border-color: var(--vscode-textBlockQuote-border); + } + + kbd { + color: var(--vscode-editor-foreground); + border-radius: 3px; + vertical-align: middle; + padding: 1px 3px; + + background-color: hsla(0,0%,50%,.17); + border: 1px solid rgba(71,71,71,.4); + border-bottom-color: rgba(88,88,88,.4); + box-shadow: inset 0 -1px 0 rgba(88,88,88,.4); + } + .vscode-light kbd { + background-color: hsla(0,0%,87%,.5); + border: 1px solid hsla(0,0%,80%,.7); + border-bottom-color: hsla(0,0%,73%,.7); + box-shadow: inset 0 -1px 0 hsla(0,0%,73%,.7); + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); + } + ::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); + } + ::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); + }`; + + /** + * @param {*} [state] + * @return {string} + */ + function getVsCodeApiScript(state) { + return ` + const acquireVsCodeApi = (function() { + const originalPostMessage = window.parent.postMessage.bind(window.parent); + const targetOrigin = '*'; + let acquired = false; + + let state = ${state ? `JSON.parse(${JSON.stringify(state)})` : undefined}; + + return () => { + if (acquired) { + throw new Error('An instance of the VS Code API has already been acquired'); + } + acquired = true; + return Object.freeze({ + postMessage: function(msg) { + return originalPostMessage({ command: 'onmessage', data: msg }, targetOrigin); + }, + setState: function(newState) { + state = newState; + originalPostMessage({ command: 'do-update-state', data: JSON.stringify(newState) }, targetOrigin); + return newState; + }, + getState: function() { + return state; + } + }); + }; + })(); + const acquireTheiaApi = acquireVsCodeApi; + delete window.parent; + delete window.top; + delete window.frameElement; + `; + } + + /** + * @param {WebviewHost} host + */ + function createWebviewManager(host) { + // state + let firstLoad = true; + let loadTimeout; + let pendingMessages = []; + + const initData = { + initialScrollProgress: undefined + }; + + + /** + * @param {HTMLDocument?} document + * @param {HTMLElement?} body + */ + const applyStyles = (document, body) => { + if (!document) { + return; + } + + if (body) { + body.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast'); + body.classList.add(initData.activeTheme); + } + + if (initData.styles) { + for (const variable of Object.keys(initData.styles)) { + document.documentElement.style.setProperty(`--${variable}`, initData.styles[variable]); + } + } + }; + + /** + * @param {MouseEvent} event + */ + const handleInnerClick = (event) => { + if (!event || !event.view || !event.view.document) { + return; + } + + let baseElement = event.view.document.getElementsByTagName('base')[0]; + /** @type {any} */ + let node = event.target; + while (node) { + if (node.tagName && node.tagName.toLowerCase() === 'a' && node.href) { + if (node.getAttribute('href') === '#') { + event.view.scrollTo(0, 0); + } else if (node.hash && (node.getAttribute('href') === node.hash || (baseElement && node.href.indexOf(baseElement.href) >= 0))) { + let scrollTarget = event.view.document.getElementById(node.hash.substr(1, node.hash.length - 1)); + if (scrollTarget) { + scrollTarget.scrollIntoView(); + } + } else { + host.postMessage('did-click-link', node.href.baseVal || node.href); + } + event.preventDefault(); + break; + } + node = node.parentNode; + } + }; + + /** + * @param {MouseEvent} event + */ + const handleAuxClick = + (event) => { + // Prevent middle clicks opening a broken link in the browser + if (!event.view || !event.view.document) { + return; + } + + if (event.button === 1) { + let node = /** @type {any} */ (event.target); + while (node) { + if (node.tagName && node.tagName.toLowerCase() === 'a' && node.href) { + event.preventDefault(); + break; + } + node = node.parentNode; + } + } + }; + + /** + * @param {KeyboardEvent} e + */ + const handleInnerKeydown = (e) => { + host.postMessage('did-keydown', { + key: e.key, + keyCode: e.keyCode, + code: e.code, + shiftKey: e.shiftKey, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + repeat: e.repeat + }); + }; + + let isHandlingScroll = false; + const handleInnerScroll = (event) => { + if (!event.target || !event.target.body) { + return; + } + if (isHandlingScroll) { + return; + } + + const progress = event.currentTarget.scrollY / event.target.body.clientHeight; + if (isNaN(progress)) { + return; + } + + isHandlingScroll = true; + window.requestAnimationFrame(() => { + try { + host.postMessage('did-scroll', progress); + } catch (e) { + // noop + } + isHandlingScroll = false; + }); + }; + + /** + * @return {string} + */ + function toContentHtml(data) { + const options = data.options; + const text = data.contents; + const newDocument = new DOMParser().parseFromString(text, 'text/html'); + + newDocument.querySelectorAll('a').forEach(a => { + if (!a.title) { + a.title = a.getAttribute('href'); + } + }); + + // apply default script + if (options.allowScripts) { + const defaultScript = newDocument.createElement('script'); + defaultScript.textContent = getVsCodeApiScript(data.state); + newDocument.head.prepend(defaultScript); + } + + // apply default styles + const defaultStyles = newDocument.createElement('style'); + defaultStyles.id = '_defaultStyles'; + defaultStyles.innerHTML = defaultCssRules; + newDocument.head.prepend(defaultStyles); + + applyStyles(newDocument, newDocument.body); + + // Check for CSP + const csp = newDocument.querySelector('meta[http-equiv="Content-Security-Policy"]'); + if (!csp) { + host.postMessage('no-csp-found'); + } else { + // Rewrite vscode-resource in csp + if (data.endpoint) { + try { + const endpointUrl = new URL(data.endpoint); + csp.setAttribute('content', csp.getAttribute('content').replace(/(?:vscode|theia)-resource:(?=(\s|;|$))/g, endpointUrl.origin)); + } catch (e) { + console.error('Could not rewrite csp'); + } + } + } + + // set DOCTYPE for newDocument explicitly as DOMParser.parseFromString strips it off + // and DOCTYPE is needed in the iframe to ensure that the user agent stylesheet is correctly overridden + return '<!DOCTYPE html>\n' + newDocument.documentElement.outerHTML; + } + + document.addEventListener('DOMContentLoaded', () => { + const idMatch = document.location.search.match(/\bid=([\w-]+)/); + const ID = idMatch ? idMatch[1] : undefined; + if (!document.body) { + return; + } + + host.onMessage('styles', (_event, data) => { + initData.styles = data.styles; + initData.activeTheme = data.activeTheme; + + const target = getActiveFrame(); + if (!target) { + return; + } + + if (target.contentDocument) { + applyStyles(target.contentDocument, target.contentDocument.body); + } + }); + + // propagate focus + host.onMessage('focus', () => { + const target = getActiveFrame(); + if (target) { + target.contentWindow.focus(); + } + }); + + // update iframe-contents + let updateId = 0; + host.onMessage('content', async (_event, data) => { + const currentUpdateId = ++updateId; + await host.ready; + if (currentUpdateId !== updateId) { + return; + } + + const options = data.options; + const newDocument = toContentHtml(data); + + const frame = getActiveFrame(); + const wasFirstLoad = firstLoad; + // keep current scrollY around and use later + let setInitialScrollPosition; + if (firstLoad) { + firstLoad = false; + setInitialScrollPosition = (body, window) => { + if (!isNaN(initData.initialScrollProgress)) { + if (window.scrollY === 0) { + window.scroll(0, body.clientHeight * initData.initialScrollProgress); + } + } + }; + } else { + const scrollY = frame && frame.contentDocument && frame.contentDocument.body ? frame.contentWindow.scrollY : 0; + setInitialScrollPosition = (body, window) => { + if (window.scrollY === 0) { + window.scroll(0, scrollY); + } + }; + } + + // Clean up old pending frames and set current one as new one + const previousPendingFrame = getPendingFrame(); + if (previousPendingFrame) { + previousPendingFrame.setAttribute('id', ''); + document.body.removeChild(previousPendingFrame); + } + if (!wasFirstLoad) { + pendingMessages = []; + } + + const newFrame = document.createElement('iframe'); + newFrame.setAttribute('id', 'pending-frame'); + newFrame.setAttribute('frameborder', '0'); + newFrame.setAttribute('sandbox', options.allowScripts ? 'allow-scripts allow-forms allow-same-origin' : 'allow-same-origin'); + if (host.fakeLoad) { + // We should just be able to use srcdoc, but I wasn't + // seeing the service worker applying properly. + // Fake load an empty on the correct origin and then write real html + // into it to get around this. + newFrame.src = `./fake.html?id=${ID}`; + } + newFrame.style.cssText = 'display: block; margin: 0; overflow: hidden; position: absolute; width: 100%; height: 100%; visibility: hidden'; + document.body.appendChild(newFrame); + + if (!host.fakeLoad) { + // write new content onto iframe + newFrame.contentDocument.open(); + } + + newFrame.contentWindow.addEventListener('DOMContentLoaded', e => { + // Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=978325 + setTimeout(() => { + if (host.fakeLoad) { + newFrame.contentDocument.open(); + newFrame.contentDocument.write(newDocument); + newFrame.contentDocument.close(); + hookupOnLoadHandlers(newFrame); + } + const contentDocument = e.target ? (/** @type {HTMLDocument} */ (e.target)) : undefined; + if (contentDocument) { + applyStyles(contentDocument, contentDocument.body); + } + }, 0); + }); + + const onLoad = (contentDocument, contentWindow) => { + if (contentDocument && contentDocument.body) { + // Workaround for https://github.com/Microsoft/vscode/issues/12865 + // check new scrollY and reset if neccessary + setInitialScrollPosition(contentDocument.body, contentWindow); + } + + const newFrame = getPendingFrame(); + if (newFrame && newFrame.contentDocument && newFrame.contentDocument === contentDocument) { + const oldActiveFrame = getActiveFrame(); + if (oldActiveFrame) { + document.body.removeChild(oldActiveFrame); + } + // Styles may have changed since we created the element. Make sure we re-style + applyStyles(newFrame.contentDocument, newFrame.contentDocument.body); + newFrame.setAttribute('id', 'active-frame'); + newFrame.style.visibility = 'visible'; + if (host.focusIframeOnCreate) { + newFrame.contentWindow.focus(); + } + + contentWindow.addEventListener('scroll', handleInnerScroll); + + pendingMessages.forEach((data) => { + contentWindow.postMessage(data, '*'); + }); + pendingMessages = []; + } + }; + + /** + * @param {HTMLIFrameElement} newFrame + */ + function hookupOnLoadHandlers(newFrame) { + const timeoutDelay = 5000; + clearTimeout(loadTimeout); + loadTimeout = undefined; + loadTimeout = setTimeout(() => { + clearTimeout(loadTimeout); + loadTimeout = undefined; + console.warn('Loading webview is slow, took: ' + timeoutDelay + 'ms'); + onLoad(newFrame.contentDocument, newFrame.contentWindow); + }, timeoutDelay); + + newFrame.contentWindow.addEventListener('load', function (e) { + if (loadTimeout) { + clearTimeout(loadTimeout); + loadTimeout = undefined; + onLoad(e.target, this); + } + }); + + // Bubble out various events + newFrame.contentWindow.addEventListener('click', handleInnerClick); + newFrame.contentWindow.addEventListener('auxclick', handleAuxClick); + newFrame.contentWindow.addEventListener('keydown', handleInnerKeydown); + newFrame.contentWindow.addEventListener('contextmenu', e => e.preventDefault()); + + if (host.onIframeLoaded) { + host.onIframeLoaded(newFrame); + } + } + + if (!host.fakeLoad) { + hookupOnLoadHandlers(newFrame); + } + + if (!host.fakeLoad) { + newFrame.contentDocument.write(newDocument); + newFrame.contentDocument.close(); + } + + host.postMessage('did-set-content', undefined); + }); + + // Forward message to the embedded iframe + host.onMessage('message', (_event, data) => { + const pending = getPendingFrame(); + if (!pending) { + const target = getActiveFrame(); + if (target) { + target.contentWindow.postMessage(data, '*'); + return; + } + } + pendingMessages.push(data); + }); + + host.onMessage('initial-scroll-position', (_event, progress) => { + initData.initialScrollProgress = progress; + }); + + + trackFocus({ + onFocus: () => host.postMessage('did-focus'), + onBlur: () => host.postMessage('did-blur') + }); + + // signal ready + host.postMessage('webview-ready', {}); + }); + } + + if (typeof module !== 'undefined') { + module.exports = createWebviewManager; + } else { + window.createWebviewManager = createWebviewManager; + } +}()); diff --git a/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js new file mode 100644 index 0000000000000..7a9d8675dedab --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/pre/service-worker.js @@ -0,0 +1,295 @@ +/******************************************************************************** + * Copyright (C) 2019 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +// @ts-check +const VERSION = 1; + +const rootPath = self.location.pathname.replace(/\/service-worker.js$/, ''); + +/** + * Root path for resources + */ +const resourceRoots = [rootPath + '/theia-resource', rootPath + '/vscode-resource']; + +const resolveTimeout = 30000; + +/** + * @template T + * @typedef {{ + * resolve: (x: T) => void, + * promise: Promise<T> + * }} RequestStoreEntry + */ + +/** + * @template T + */ +class RequestStore { + constructor() { + /** @type {Map<string, RequestStoreEntry<T>>} */ + this.map = new Map(); + } + + /** + * @param {string} webviewId + * @param {string} path + * @return {Promise<T> | undefined} + */ + get(webviewId, path) { + const entry = this.map.get(this._key(webviewId, path)); + return entry && entry.promise; + } + + /** + * @param {string} webviewId + * @param {string} path + * @returns {Promise<T>} + */ + create(webviewId, path) { + const existing = this.get(webviewId, path); + if (existing) { + return existing; + } + let resolve; + const promise = new Promise(r => resolve = r); + const entry = { resolve, promise }; + const key = this._key(webviewId, path); + this.map.set(key, entry); + + const dispose = () => { + clearTimeout(timeout); + const existingEntry = this.map.get(key); + if (existingEntry === entry) { + return this.map.delete(key); + } + }; + const timeout = setTimeout(dispose, resolveTimeout); + return promise; + } + + /** + * @param {string} webviewId + * @param {string} path + * @param {T} result + * @return {boolean} + */ + resolve(webviewId, path, result) { + const entry = this.map.get(this._key(webviewId, path)); + if (!entry) { + return false; + } + entry.resolve(result); + return true; + } + + /** + * @param {string} webviewId + * @param {string} path + * @return {string} + */ + _key(webviewId, path) { + return `${webviewId}@@@${path}`; + } +} + +/** + * Map of requested paths to responses. + * + * @type {RequestStore<{ body: any, mime: string } | undefined>} + */ +const resourceRequestStore = new RequestStore(); + +/** + * Map of requested localhost origins to optional redirects. + * + * @type {RequestStore<string | undefined>} + */ +const localhostRequestStore = new RequestStore(); + +const notFound = () => + new Response('Not Found', { status: 404, }); + +self.addEventListener('message', async (event) => { + switch (event.data.channel) { + case 'version': + { + self.clients.get(event.source.id).then(client => { + if (client) { + client.postMessage({ + channel: 'version', + version: VERSION + }); + } + }); + return; + } + case 'did-load-resource': + { + const webviewId = getWebviewIdForClient(event.source); + const data = event.data.data; + const response = data.status === 200 + ? { body: data.data, mime: data.mime } + : undefined; + + if (!resourceRequestStore.resolve(webviewId, data.path, response)) { + console.error('Could not resolve unknown resource', data.path); + } + return; + } + + case 'did-load-localhost': + { + const webviewId = getWebviewIdForClient(event.source); + const data = event.data.data; + if (!localhostRequestStore.resolve(webviewId, data.origin, data.location)) { + console.error('Could not resolve unknown localhost', data.origin); + } + return; + } + } + + console.error('Unknown message'); +}); + +self.addEventListener('fetch', (event) => { + const requestUrl = new URL(event.request.url); + + for (const resourceRoot of resourceRoots) { + // See if it's a resource request + if (requestUrl.origin === self.origin && requestUrl.pathname.startsWith(resourceRoot + '/')) { + return event.respondWith(processResourceRequest(event, requestUrl, resourceRoot)); + } + } + + // See if it's a localhost request + if (requestUrl.origin !== self.origin && requestUrl.host.match(/^localhost:(\d+)$/)) { + return event.respondWith(processLocalhostRequest(event, requestUrl)); + } +}); + +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); // Activate worker immediately +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); // Become available to all pages +}); + +async function processResourceRequest(event, requestUrl, resourceRoot) { + const client = await self.clients.get(event.clientId); + if (!client) { + console.error('Could not find inner client for request'); + return notFound(); + } + + const webviewId = getWebviewIdForClient(client); + const resourcePath = requestUrl.pathname.startsWith(resourceRoot + '/') ? requestUrl.pathname.slice(resourceRoot.length) : requestUrl.pathname; + + function resolveResourceEntry(entry) { + if (!entry) { + return notFound(); + } + return new Response(entry.body, { + status: 200, + headers: { 'Content-Type': entry.mime } + }); + } + + const parentClient = await getOuterIframeClient(webviewId); + if (!parentClient) { + console.error('Could not find parent client for request'); + return notFound(); + } + + // Check if we've already resolved this request + const existing = resourceRequestStore.get(webviewId, resourcePath); + if (existing) { + return existing.then(resolveResourceEntry); + } + + parentClient.postMessage({ + channel: 'load-resource', + path: resourcePath + }); + + return resourceRequestStore.create(webviewId, resourcePath) + .then(resolveResourceEntry); +} + +/** + * @param {*} event + * @param {URL} requestUrl + */ +async function processLocalhostRequest(event, requestUrl) { + const client = await self.clients.get(event.clientId); + if (!client) { + // This is expected when requesting resources on other localhost ports + // that are not spawned by vs code + return undefined; + } + const webviewId = getWebviewIdForClient(client); + const origin = requestUrl.origin; + + const resolveRedirect = redirectOrigin => { + if (!redirectOrigin) { + return fetch(event.request); + } + const location = event.request.url.replace(new RegExp(`^${requestUrl.origin}(/|$)`), `${redirectOrigin}$1`); + return new Response(null, { + status: 302, + headers: { + Location: location + } + }); + }; + + const parentClient = await getOuterIframeClient(webviewId); + if (!parentClient) { + console.error('Could not find parent client for request'); + return notFound(); + } + + // Check if we've already resolved this request + const existing = localhostRequestStore.get(webviewId, origin); + if (existing) { + return existing.then(resolveRedirect); + } + + parentClient.postMessage({ + channel: 'load-localhost', + origin: origin + }); + + return localhostRequestStore.create(webviewId, origin) + .then(resolveRedirect); +} + +function getWebviewIdForClient(client) { + const requesterClientUrl = new URL(client.url); + return requesterClientUrl.search.match(/\bid=([a-z0-9-]+)/i)[1]; +} + +async function getOuterIframeClient(webviewId) { + const allClients = await self.clients.matchAll({ includeUncontrolled: true }); + return allClients.find(client => { + const clientUrl = new URL(client.url); + return (clientUrl.pathname === `${rootPath}/` || clientUrl.pathname === `${rootPath}/index.html`) && clientUrl.search.match(new RegExp('\\bid=' + webviewId)); + }); +} diff --git a/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts b/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts deleted file mode 100644 index 23eba55151663..0000000000000 --- a/packages/plugin-ext/src/main/browser/webview/theme-rules-service.ts +++ /dev/null @@ -1,149 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2018 Red Hat, Inc. 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 { ThemeService } from '@theia/core/lib/browser/theming'; - -export const ThemeRulesServiceSymbol = Symbol('ThemeRulesService'); - -interface IconPath { - light: string, - dark: string -} - -const DEFAULT_RULE = 'body { font-size: var(--theia-ui-font-size1); color: var(--theia-ui-font-color1); }'; - -export class ThemeRulesService { - private styleElement?: HTMLStyleElement; - private icons = new Map<string, IconPath | string>(); - protected readonly themeService = ThemeService.get(); - protected readonly themeRules = new Map<string, string[]>(); - - static get(): ThemeRulesService { - const global = window as any; // tslint:disable-line - return global[ThemeRulesServiceSymbol] || new ThemeRulesService(); - } - - protected constructor() { - const global = window as any; // tslint:disable-line - global[ThemeRulesServiceSymbol] = this; - - this.themeService.onThemeChange(() => { - this.updateIconStyleElement(); - }); - } - - createStyleSheet(container: HTMLElement = document.getElementsByTagName('head')[0]): HTMLStyleElement { - const style = document.createElement('style'); - style.type = 'text/css'; - style.media = 'screen'; - container.appendChild(style); - return style; - } - - getCurrentThemeRules(): string[] { - const cssText: string[] = []; - const themeId = this.themeService.getCurrentTheme().id; - if (this.themeRules.has(themeId)) { - return <string[]>this.themeRules.get(themeId); - } - // tslint:disable-next-line:no-any - const styleElement = document.getElementById('theia-theme') as any; - if (!styleElement) { - return cssText; - } - - const sheet: { - insertRule: (rule: string, index: number) => void, - removeRule: (index: number) => void, - rules: CSSRuleList - // tslint:disable-next-line:no-any - } | undefined = (<any>styleElement).sheet; - if (!sheet || !sheet.rules || !sheet.rules.length) { - return cssText; - } - - const ruleList = sheet.rules; - for (let index = 0; index < ruleList.length; index++) { - if (ruleList[index] && ruleList[index].cssText) { - cssText.push(ruleList[index].cssText.toString()); - } - } - - if (cssText.length) { - cssText.push(DEFAULT_RULE); - } - - return cssText; - } - - setRules(styleSheet: HTMLElement, newRules: string[]): boolean { - const sheet: { - insertRule: (rule: string, index: number) => void; - removeRule: (index: number) => void; - rules: CSSRuleList; - // tslint:disable-next-line:no-any - } | undefined = (<any>styleSheet).sheet; - - if (!sheet) { - return false; - } - for (let index = sheet.rules!.length; index > 0; index--) { - sheet.removeRule(0); - } - newRules.forEach((rule: string, index: number) => { - sheet.insertRule(rule, index); - }); - return true; - } - - setIconPath(webviewId: string, iconPath: IconPath | string | undefined): void { - if (!iconPath) { - this.icons.delete(webviewId); - } else { - this.icons.set(webviewId, <IconPath | string>iconPath); - } - if (!this.styleElement) { - this.styleElement = this.createStyleSheet(); - this.styleElement.id = 'webview-icons'; - } - this.updateIconStyleElement(); - } - - private updateIconStyleElement(): void { - if (!this.styleElement) { - return; - } - const cssRules: string[] = []; - this.icons.forEach((value, key) => { - let path: string; - if (typeof value === 'string') { - path = value; - } else { - path = this.isDark() ? value.dark : value.light; - } - if (path.startsWith('/')) { - path = `/webview${path}`; - } - cssRules.push(`.webview-icon.${key}-file-icon::before { background-image: url(${path}); }`); - }); - this.setRules(this.styleElement, cssRules); - } - - private isDark(): boolean { - const currentThemeId: string = this.themeService.getCurrentTheme().id; - return !currentThemeId.includes('light'); - } -} diff --git a/packages/plugin-ext/src/main/browser/webview/webview-environment.ts b/packages/plugin-ext/src/main/browser/webview/webview-environment.ts new file mode 100644 index 0000000000000..520134bdf5f48 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-environment.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (C) 2019 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, inject, postConstruct } from 'inversify'; +import { Endpoint } from '@theia/core/lib/browser/endpoint'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import URI from '@theia/core/lib/common/uri'; +import { WebviewExternalEndpoint } from '../../common/webview-protocol'; + +@injectable() +export class WebviewEnvironment { + + @inject(EnvVariablesServer) + protected readonly environments: EnvVariablesServer; + + protected readonly externalEndpointHost = new Deferred<string>(); + + @postConstruct() + protected async init(): Promise<void> { + try { + const variable = await this.environments.getValue(WebviewExternalEndpoint.pattern); + const value = variable && variable.value || WebviewExternalEndpoint.defaultPattern; + this.externalEndpointHost.resolve(value.replace('{{hostname}}', window.location.host || 'localhost')); + } catch (e) { + this.externalEndpointHost.reject(e); + } + } + + async externalEndpointUrl(): Promise<URI> { + const host = await this.externalEndpointHost.promise; + return new Endpoint({ + host, + path: '/webview' + }).getRestUrl(); + } + + async externalEndpoint(): Promise<string> { + return (await this.externalEndpointUrl()).toString(true); + } + + async resourceRoot(): Promise<string> { + return (await this.externalEndpointUrl()).resolve('theia-resource/{{resource}}').toString(true); + } + + async cspSource(): Promise<string> { + return (await this.externalEndpointUrl()).withPath('').withQuery('').withFragment('').toString(true).replace('{{uuid}}', '*'); + } + +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview-preferences.ts b/packages/plugin-ext/src/main/browser/webview/webview-preferences.ts new file mode 100644 index 0000000000000..9935db33803d6 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-preferences.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { interfaces } from 'inversify'; +import { + createPreferenceProxy, + PreferenceProxy, + PreferenceService, + PreferenceContribution, + PreferenceSchema +} from '@theia/core/lib/browser/preferences'; + +export const WebviewConfigSchema: PreferenceSchema = { + 'type': 'object', + 'properties': { + 'webview.trace': { + 'type': 'string', + 'enum': ['off', 'on', 'verbose'], + 'description': 'Controls communication tracing with webviews.', + 'default': 'off' + } + } +}; + +export interface WebviewConfiguration { + 'webview.trace': 'off' | 'on' | 'verbose' +} + +export const WebviewPreferences = Symbol('WebviewPreferences'); +export type WebviewPreferences = PreferenceProxy<WebviewConfiguration>; + +export function createWebviewPreferences(preferences: PreferenceService): WebviewPreferences { + return createPreferenceProxy(preferences, WebviewConfigSchema); +} + +export function bindWebviewPreferences(bind: interfaces.Bind): void { + bind(WebviewPreferences).toDynamicValue(ctx => { + const preferences = ctx.container.get<PreferenceService>(PreferenceService); + return createWebviewPreferences(preferences); + }); + + bind(PreferenceContribution).toConstantValue({ schema: WebviewConfigSchema }); +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview-resource-cache.ts b/packages/plugin-ext/src/main/browser/webview/webview-resource-cache.ts new file mode 100644 index 0000000000000..816dd219333dd --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-resource-cache.ts @@ -0,0 +1,88 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { Deferred } from '@theia/core/lib/common/promise-util'; +import { MaybePromise } from '@theia/core/lib/common/types'; + +export interface WebviewResourceResponse { + eTag: string | undefined, + body(): MaybePromise<Uint8Array> +} + +/** + * Browser based cache of webview resources across all instances. + */ +@injectable() +export class WebviewResourceCache { + + protected readonly cache = new Deferred<Cache | undefined>(); + + constructor() { + this.resolveCache(); + } + + protected async resolveCache(): Promise<void> { + try { + this.cache.resolve(await caches.open('webview:v1')); + } catch (e) { + console.error('Failed to enable webview caching: ', e); + this.cache.resolve(undefined); + } + } + + async match(url: string): Promise<WebviewResourceResponse | undefined> { + const cache = await this.cache.promise; + if (!cache) { + return undefined; + } + const response = await cache.match(url); + if (!response) { + return undefined; + } + return { + eTag: response.headers.get('ETag') || undefined, + body: async () => { + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + }; + } + + async delete(url: string): Promise<boolean> { + const cache = await this.cache.promise; + if (!cache) { + return false; + } + return cache.delete(url); + } + + async put(url: string, response: WebviewResourceResponse): Promise<void> { + if (!response.eTag) { + return; + } + const cache = await this.cache.promise; + if (!cache) { + return; + } + const body = await response.body(); + await cache.put(url, new Response(body, { + status: 200, + headers: { 'ETag': response.eTag } + })); + } + +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts b/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts new file mode 100644 index 0000000000000..081cad8ec4066 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/webview/webview-theme-data-provider.ts @@ -0,0 +1,119 @@ +/******************************************************************************** + * Copyright (C) 2019 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 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/common/themeing.ts + +import { inject, postConstruct, injectable } from 'inversify'; +import { Emitter } from '@theia/core/lib/common/event'; +import { EditorPreferences, EditorConfiguration } from '@theia/editor/lib/browser/editor-preferences'; +import { ThemeService } from '@theia/core/lib/browser/theming'; +import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; +import { ColorApplicationContribution } from '@theia/core/lib/browser/color-application-contribution'; + +export type WebviewThemeType = 'vscode-light' | 'vscode-dark' | 'vscode-high-contrast'; +export interface WebviewThemeData { + readonly activeTheme: WebviewThemeType; + readonly styles: { readonly [key: string]: string | number; }; +} + +@injectable() +export class WebviewThemeDataProvider { + + protected readonly onDidChangeThemeDataEmitter = new Emitter<void>(); + readonly onDidChangeThemeData = this.onDidChangeThemeDataEmitter.event; + + @inject(EditorPreferences) + protected readonly editorPreferences: EditorPreferences; + + @inject(ColorRegistry) + protected readonly colorRegistry: ColorRegistry; + + @inject(ColorApplicationContribution) + protected readonly colorContribution: ColorApplicationContribution; + + protected themeData: WebviewThemeData | undefined; + + protected readonly editorStyles = new Map<keyof EditorConfiguration, string>([ + ['editor.fontFamily', 'editor-font-family'], + ['editor.fontWeight', 'editor-font-weight'], + ['editor.fontSize', 'editor-font-size'] + ]); + + @postConstruct() + protected init(): void { + this.colorContribution.onDidChange(() => this.reset()); + + this.editorPreferences.onPreferenceChanged(e => { + if (this.editorStyles.has(e.preferenceName)) { + this.reset(); + } + }); + } + + protected reset(): void { + if (this.themeData) { + this.themeData = undefined; + this.onDidChangeThemeDataEmitter.fire(undefined); + } + } + + getThemeData(): WebviewThemeData { + if (!this.themeData) { + this.themeData = this.computeThemeData(); + } + return this.themeData; + } + + protected computeThemeData(): WebviewThemeData { + const styles: { [key: string]: string | number; } = {}; + // tslint:disable-next-line:no-any + const addStyle = (id: string, rawValue: any) => { + if (rawValue) { + const value = typeof rawValue === 'number' || typeof rawValue === 'string' ? rawValue : String(rawValue); + styles['vscode-' + id.replace('.', '-')] = value; + styles['theia-' + id.replace('.', '-')] = value; + } + }; + + addStyle('font-family', '-apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif'); + addStyle('font-weight', 'normal'); + addStyle('font-size', '13px'); + this.editorStyles.forEach((value, key) => addStyle(value, this.editorPreferences[key])); + + for (const id of this.colorRegistry.getColors()) { + const color = this.colorRegistry.getCurrentColor(id); + if (color) { + addStyle(id, color.toString()); + } + } + + const activeTheme = this.getActiveTheme(); + return { styles, activeTheme }; + } + + protected getActiveTheme(): WebviewThemeType { + const theme = ThemeService.get().getCurrentTheme(); + switch (theme.type) { + case 'light': return 'vscode-light'; + case 'dark': return 'vscode-dark'; + default: return 'vscode-high-contrast'; + } + } + +} diff --git a/packages/plugin-ext/src/main/browser/webview/webview.ts b/packages/plugin-ext/src/main/browser/webview/webview.ts index 704b9e5d4c682..61c1852cd908a 100644 --- a/packages/plugin-ext/src/main/browser/webview/webview.ts +++ b/packages/plugin-ext/src/main/browser/webview/webview.ts @@ -13,316 +13,578 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/baseWebviewElement.ts +// copied and modified from https://github.com/microsoft/vscode/blob/ba40bd16433d5a817bfae15f3b4350e18f144af4/src/vs/workbench/contrib/webview/browser/webviewElement.ts# + +import * as mime from 'mime'; +import { JSONExt } from '@phosphor/coreutils/lib/json'; +import { injectable, inject, postConstruct } from 'inversify'; +import { WebviewPanelOptions, WebviewPortMapping } from '@theia/plugin'; import { BaseWidget, Message } from '@theia/core/lib/browser/widgets/widget'; -import { IdGenerator } from '../../../common/id-generator'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +// TODO: get rid of dependencies to the mini browser import { MiniBrowserContentStyle } from '@theia/mini-browser/lib/browser/mini-browser-content-style'; import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker'; +import { StatefulWidget } from '@theia/core/lib/browser/shell/shell-layout-restorer'; +import { WebviewPanelViewState } from '../../../common/plugin-api-rpc'; +import { IconUrl } from '../../../common/plugin-protocol'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { WebviewEnvironment } from './webview-environment'; +import URI from '@theia/core/lib/common/uri'; +import { Emitter } from '@theia/core/lib/common/event'; +import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; +import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; +import { Schemes } from '../../../common/uri-components'; +import { PluginSharedStyle } from '../plugin-shared-style'; +import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; +import { WebviewThemeDataProvider } from './webview-theme-data-provider'; +import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service'; +import { OutputChannelManager } from '@theia/output/lib/common/output-channel'; +import { WebviewPreferences } from './webview-preferences'; +import { WebviewResourceLoader } from '../../common/webview-protocol'; +import { WebviewResourceCache } from './webview-resource-cache'; +import { Endpoint } from '@theia/core/lib/browser/endpoint'; // tslint:disable:no-any -export interface WebviewWidgetOptions { +export const enum WebviewMessageChannels { + onmessage = 'onmessage', + didClickLink = 'did-click-link', + didFocus = 'did-focus', + didBlur = 'did-blur', + doUpdateState = 'do-update-state', + doReload = 'do-reload', + loadResource = 'load-resource', + loadLocalhost = 'load-localhost', + webviewReady = 'webview-ready', + didKeydown = 'did-keydown' +} + +export interface WebviewContentOptions { readonly allowScripts?: boolean; + readonly localResourceRoots?: ReadonlyArray<string>; + readonly portMapping?: ReadonlyArray<WebviewPortMapping>; + readonly enableCommandUris?: boolean; } -export interface WebviewEvents { - onMessage?(message: any): void; - onKeyboardEvent?(e: KeyboardEvent): void; - onLoad?(contentDocument: Document): void; +@injectable() +export class WebviewWidgetIdentifier { + id: string; } -export class WebviewWidget extends BaseWidget { - private static readonly ID = new IdGenerator('webview-widget-'); - private iframe: HTMLIFrameElement; - private state: { [key: string]: any } | undefined = undefined; - private loadTimeout: number | undefined; - private scrollY: number; - private readyToReceiveMessage: boolean = false; +export const WebviewWidgetExternalEndpoint = Symbol('WebviewWidgetExternalEndpoint'); + +@injectable() +export class WebviewWidget extends BaseWidget implements StatefulWidget { + + private static readonly standardSupportedLinkSchemes = new Set([ + Schemes.HTTP, + Schemes.HTTPS, + Schemes.MAILTO, + Schemes.VSCODE + ]); + + static FACTORY_ID = 'plugin-webview'; + + protected element: HTMLIFrameElement | undefined; + // tslint:disable-next-line:max-line-length - // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. On `mousedown` we put a transparent div over the `iframe` to avoid losing the mouse tacking. - protected readonly transparentOverlay: HTMLElement; - - constructor(title: string, - private options: WebviewWidgetOptions, - private eventDelegate: WebviewEvents, - protected readonly mouseTracker: ApplicationShellMouseTracker) { - super(); + // XXX This is a hack to be able to tack the mouse events when drag and dropping the widgets. + // On `mousedown` we put a transparent div over the `iframe` to avoid losing the mouse tacking. + protected transparentOverlay: HTMLElement; + + @inject(WebviewWidgetIdentifier) + readonly identifier: WebviewWidgetIdentifier; + + @inject(WebviewWidgetExternalEndpoint) + readonly externalEndpoint: string; + + @inject(ApplicationShellMouseTracker) + protected readonly mouseTracker: ApplicationShellMouseTracker; + + @inject(WebviewEnvironment) + protected readonly environment: WebviewEnvironment; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + + @inject(KeybindingRegistry) + protected readonly keybindings: KeybindingRegistry; + + @inject(PluginSharedStyle) + protected readonly sharedStyle: PluginSharedStyle; + + @inject(WebviewThemeDataProvider) + protected readonly themeDataProvider: WebviewThemeDataProvider; + + @inject(ExternalUriService) + protected readonly externalUriService: ExternalUriService; + + @inject(OutputChannelManager) + protected readonly outputManager: OutputChannelManager; + + @inject(WebviewPreferences) + protected readonly preferences: WebviewPreferences; + + @inject(WebviewResourceLoader) + protected readonly resourceLoader: WebviewResourceLoader; + + @inject(WebviewResourceCache) + protected readonly resourceCache: WebviewResourceCache; + + viewState: WebviewPanelViewState = { + visible: false, + active: false, + position: 0 + }; + + protected html = ''; + + protected _contentOptions: WebviewContentOptions = {}; + get contentOptions(): WebviewContentOptions { + return this._contentOptions; + } + + protected _state: string | undefined; + get state(): string | undefined { + return this._state; + } + + viewType: string; + options: WebviewPanelOptions = {}; + + protected ready = new Deferred<void>(); + + protected readonly onMessageEmitter = new Emitter<any>(); + readonly onMessage = this.onMessageEmitter.event; + protected readonly pendingMessages: any[] = []; + + protected readonly toHide = new DisposableCollection(); + protected hideTimeout: any | number | undefined; + + @postConstruct() + protected init(): void { this.node.tabIndex = 0; - this.id = WebviewWidget.ID.nextId(); + this.id = WebviewWidget.FACTORY_ID + ':' + this.identifier.id; this.title.closable = true; - this.title.label = title; this.addClass(WebviewWidget.Styles.WEBVIEW); - this.scrollY = 0; + + this.toDispose.push(this.onMessageEmitter); this.transparentOverlay = document.createElement('div'); this.transparentOverlay.classList.add(MiniBrowserContentStyle.TRANSPARENT_OVERLAY); this.transparentOverlay.style.display = 'none'; this.node.appendChild(this.transparentOverlay); - this.toDispose.push(this.mouseTracker.onMousedown(e => { - if (this.iframe.style.display !== 'none') { + this.toDispose.push(this.mouseTracker.onMousedown(() => { + if (this.element && this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'block'; } })); - this.toDispose.push(this.mouseTracker.onMouseup(e => { - if (this.iframe.style.display !== 'none') { + this.toDispose.push(this.mouseTracker.onMouseup(() => { + if (this.element && this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'none'; } })); } - protected handleMessage(message: any): void { - switch (message.command) { - case 'onmessage': - this.eventDelegate.onMessage!(message.data); - break; - case 'do-update-state': - this.state = message.data; - } + protected onBeforeAttach(msg: Message): void { + super.onBeforeAttach(msg); + this.doShow(); + // iframe has to be reloaded when moved to another DOM element + this.toDisposeOnDetach.push(Disposable.create(() => this.forceHide())); } - async postMessage(message: any): Promise<void> { - // wait message can be delivered - await this.waitReadyToReceiveMessage(); - this.iframe.contentWindow!.postMessage(message, '*'); + protected onBeforeShow(msg: Message): void { + super.onBeforeShow(msg); + this.doShow(); } - setOptions(options: WebviewWidgetOptions): void { - if (!this.iframe || this.options.allowScripts === options.allowScripts) { - return; - } - this.updateSandboxAttribute(this.iframe, options.allowScripts); - this.options = options; - this.reloadFrame(); + protected onAfterHide(msg: Message): void { + super.onAfterHide(msg); + this.doHide(); } - setIconClass(iconClass: string): void { - this.title.iconClass = iconClass; + protected doHide(): void { + if (this.options.retainContextWhenHidden !== true) { + if (this.hideTimeout === undefined) { + // avoid removing iframe if a widget moved quickly + this.hideTimeout = setTimeout(() => this.forceHide(), 50); + } + } } - protected readonly toDisposeOnHTML = new DisposableCollection(); + protected forceHide(): void { + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + this.toHide.dispose(); + } - setHTML(html: string): void { - const newDocument = new DOMParser().parseFromString(html, 'text/html'); - if (!newDocument || !newDocument.body) { + protected doShow(): void { + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + if (!this.toHide.disposed) { return; } - - this.toDisposeOnHTML.dispose(); - this.toDispose.push(this.toDisposeOnHTML); - - (<any>newDocument.querySelectorAll('a')).forEach((a: any) => { - if (!a.title) { - a.title = a.href; + this.toDispose.push(this.toHide); + + const element = document.createElement('iframe'); + element.className = 'webview'; + element.sandbox.add('allow-scripts', 'allow-same-origin'); + element.setAttribute('src', `${this.externalEndpoint}/index.html?id=${this.identifier.id}`); + element.style.border = 'none'; + element.style.width = '100%'; + element.style.height = '100%'; + this.element = element; + this.node.appendChild(this.element); + this.toHide.push(Disposable.create(() => { + if (this.element) { + this.element.remove(); + this.element = undefined; } + })); + + const oldReady = this.ready; + const ready = new Deferred<void>(); + ready.promise.then(() => oldReady.resolve()); + this.ready = ready; + this.toHide.push(Disposable.create(() => this.ready = new Deferred<void>())); + const subscription = this.on(WebviewMessageChannels.webviewReady, () => { + subscription.dispose(); + ready.resolve(); }); + this.toHide.push(subscription); - (window as any)[`postMessageExt${this.id}`] = (e: any) => { - this.handleMessage(e); - }; - this.toDisposeOnHTML.push(Disposable.create(() => - delete (window as any)[`postMessageExt${this.id}`] + this.toHide.push(this.on(WebviewMessageChannels.onmessage, (data: any) => this.onMessageEmitter.fire(data))); + this.toHide.push(this.on(WebviewMessageChannels.didClickLink, (uri: string) => this.openLink(new URI(uri)))); + this.toHide.push(this.on(WebviewMessageChannels.doUpdateState, (state: any) => { + this._state = state; + })); + this.toHide.push(this.on(WebviewMessageChannels.didFocus, () => + // emulate the webview focus without actually changing focus + this.node.dispatchEvent(new FocusEvent('focus')) )); - this.updateApiScript(newDocument); - - const newFrame = document.createElement('iframe'); - newFrame.setAttribute('id', 'pending-frame'); - newFrame.setAttribute('frameborder', '0'); - newFrame.style.cssText = 'display: block; margin: 0; overflow: hidden; position: absolute; width: 100%; height: 100%; visibility: hidden'; - this.node.appendChild(newFrame); - this.iframe = newFrame; - this.toDisposeOnHTML.push(Disposable.create(() => { - newFrame.setAttribute('id', ''); - this.node.removeChild(newFrame); + this.toHide.push(this.on(WebviewMessageChannels.didBlur, () => { + /* no-op: webview loses focus only if another element gains focus in the main window */ + })); + this.toHide.push(this.on(WebviewMessageChannels.doReload, () => this.reload())); + this.toHide.push(this.on(WebviewMessageChannels.loadResource, (entry: any) => this.loadResource(entry.path))); + this.toHide.push(this.on(WebviewMessageChannels.loadLocalhost, (entry: any) => + this.loadLocalhost(entry.origin) + )); + this.toHide.push(this.on(WebviewMessageChannels.didKeydown, (data: KeyboardEvent) => { + // Electron: workaround for https://github.com/electron/electron/issues/14258 + // We have to detect keyboard events in the <webview> and dispatch them to our + // keybinding service because these events do not bubble to the parent window anymore. + this.dispatchKeyDown(data); })); - newFrame.contentDocument!.open('text/html', 'replace'); + this.style(); + this.toHide.push(this.themeDataProvider.onDidChangeThemeData(() => this.style())); - const onLoad = (contentDocument: any, contentWindow: any) => { - if (newFrame && newFrame.contentDocument === contentDocument) { - newFrame.style.visibility = 'visible'; - } - if (contentDocument.body) { - if (this.eventDelegate && this.eventDelegate.onKeyboardEvent) { - const eventNames = ['keydown', 'keypress', 'click']; - // Delegate events from the `iframe` to the application. - eventNames.forEach((eventName: string) => { - contentDocument.addEventListener(eventName, this.eventDelegate.onKeyboardEvent!, true); - this.toDispose.push(Disposable.create(() => contentDocument.removeEventListener(eventName, this.eventDelegate.onKeyboardEvent!))); - }); - } - if (this.eventDelegate && this.eventDelegate.onLoad) { - this.eventDelegate.onLoad(<Document>contentDocument); + this.doUpdateContent(); + while (this.pendingMessages.length) { + this.sendMessage(this.pendingMessages.shift()); + } + } + + protected async loadLocalhost(origin: string): Promise<void> { + const redirect = await this.getRedirect(origin); + return this.doSend('did-load-localhost', { origin, location: redirect }); + } + + protected async getRedirect(url: string): Promise<string | undefined> { + const uri = new URI(url); + const localhost = this.externalUriService.parseLocalhost(uri); + if (!localhost) { + return undefined; + } + + if (this._contentOptions.portMapping) { + for (const mapping of this._contentOptions.portMapping) { + if (mapping.webviewPort === localhost.port) { + if (mapping.webviewPort !== mapping.extensionHostPort) { + return this.toRemoteUrl( + uri.withAuthority(`${localhost.address}:${mapping.extensionHostPort}`) + ); + } } } - }; + } - this.loadTimeout = window.setTimeout(() => { - clearTimeout(this.loadTimeout); - this.loadTimeout = undefined; - onLoad(newFrame.contentDocument, newFrame.contentWindow); - }, 200); - this.toDisposeOnHTML.push(Disposable.create(() => { - if (typeof this.loadTimeout === 'number') { - clearTimeout(this.loadTimeout); - this.loadTimeout = undefined; - } - })); + return this.toRemoteUrl(uri); + } - newFrame.contentWindow!.addEventListener('load', e => { - if (this.loadTimeout) { - clearTimeout(this.loadTimeout); - this.loadTimeout = undefined; - onLoad(e.target, newFrame.contentWindow); - } - }, { once: true }); - newFrame.contentDocument!.write(newDocument!.documentElement!.innerHTML); - newFrame.contentDocument!.close(); + protected async toRemoteUrl(localUri: URI): Promise<string> { + const remoteUri = await this.externalUriService.resolve(localUri); + const remoteUrl = remoteUri.toString(); + if (remoteUrl[remoteUrl.length - 1] === '/') { + return remoteUrl.slice(0, remoteUrl.length - 1); + } + return remoteUrl; + } + + setContentOptions(contentOptions: WebviewContentOptions): void { + if (JSONExt.deepEqual(<any>this.contentOptions, <any>contentOptions)) { + return; + } + this._contentOptions = contentOptions; + this.doUpdateContent(); + } - this.updateSandboxAttribute(newFrame); + protected iconUrl: IconUrl | undefined; + protected readonly toDisposeOnIcon = new DisposableCollection(); + setIconUrl(iconUrl: IconUrl | undefined): void { + if ((this.iconUrl && iconUrl && JSONExt.deepEqual(this.iconUrl, iconUrl)) || (this.iconUrl === iconUrl)) { + return; + } + this.toDisposeOnIcon.dispose(); + this.toDispose.push(this.toDisposeOnIcon); + this.iconUrl = iconUrl; + if (iconUrl) { + const darkIconUrl = typeof iconUrl === 'object' ? iconUrl.dark : iconUrl; + const lightIconUrl = typeof iconUrl === 'object' ? iconUrl.light : iconUrl; + const iconClass = `webview-${this.identifier.id}-file-icon`; + this.toDisposeOnIcon.push(this.sharedStyle.insertRule( + `.theia-webview-icon.${iconClass}::before`, + theme => `background-image: url(${theme.id === BuiltinThemeProvider.lightTheme.id ? lightIconUrl : darkIconUrl});` + )); + this.title.iconClass = `theia-webview-icon ${iconClass}`; + } else { + this.title.iconClass = ''; + } + } + + setHTML(value: string): void { + this.html = this.preprocessHtml(value); + this.doUpdateContent(); + } + + protected preprocessHtml(value: string): string { + return value + .replace(/(["'])(?:vscode|theia)-resource:(\/\/([^\s\/'"]+?)(?=\/))?([^\s'"]+?)(["'])/gi, (_, startQuote, _1, scheme, path, endQuote) => { + if (scheme) { + return `${startQuote}${this.externalEndpoint}/theia-resource/${scheme}${path}${endQuote}`; + } + return `${startQuote}${this.externalEndpoint}/theia-resource/file${path}${endQuote}`; + }); } protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); - // restore scrolling if there was one - if (this.scrollY > 0) { - this.iframe.contentWindow!.scrollTo({ top: this.scrollY }); - } + this.focus(); + } + + focus(): void { this.node.focus(); - // unblock messages - this.readyToReceiveMessage = true; + if (this.element) { + this.doSend('focus'); + } } - // block messages - protected onBeforeShow(msg: Message): void { - this.readyToReceiveMessage = false; + reload(): void { + this.doUpdateContent(); } - protected onBeforeHide(msg: Message): void { - // persist scrolling - if (this.iframe.contentWindow) { - this.scrollY = this.iframe.contentWindow.scrollY; - } - super.onBeforeHide(msg); + protected style(): void { + const { styles, activeTheme } = this.themeDataProvider.getThemeData(); + this.doSend('styles', { styles, activeTheme }); } - public reloadFrame(): void { - if (!this.iframe || !this.iframe.contentDocument || !this.iframe.contentDocument.documentElement) { - return; - } - this.setHTML(this.iframe.contentDocument.documentElement.innerHTML); + protected dispatchKeyDown(event: KeyboardEventInit): void { + // Create a fake KeyboardEvent from the data provided + const emulatedKeyboardEvent = new KeyboardEvent('keydown', event); + // Force override the target + Object.defineProperty(emulatedKeyboardEvent, 'target', { + get: () => this.element, + }); + // And re-dispatch + this.keybindings.run(emulatedKeyboardEvent); } - private updateSandboxAttribute(element: HTMLElement, isAllowScript?: boolean): void { - if (!element) { - return; + protected openLink(link: URI): void { + const supported = this.toSupportedLink(link); + if (supported) { + open(this.openerService, supported); } - const allowScripts = isAllowScript !== undefined ? isAllowScript : this.options.allowScripts; - element.setAttribute('sandbox', allowScripts ? 'allow-scripts allow-forms allow-same-origin' : 'allow-same-origin'); } - private updateApiScript(contentDocument: Document, isAllowScript?: boolean): void { - if (!contentDocument) { - return; - } - const allowScripts = isAllowScript !== undefined ? isAllowScript : this.options.allowScripts; - const scriptId = 'webview-widget-codeApi'; - if (!allowScripts) { - const script = contentDocument.getElementById(scriptId); - if (!script) { - return; + protected toSupportedLink(link: URI): URI | undefined { + if (WebviewWidget.standardSupportedLinkSchemes.has(link.scheme)) { + const linkAsString = link.toString(); + for (const resourceRoot of [this.externalEndpoint + '/theia-resource', this.externalEndpoint + '/vscode-resource']) { + if (linkAsString.startsWith(resourceRoot + '/')) { + return this.normalizeRequestUri(linkAsString.substr(resourceRoot.length)); + } } - script!.parentElement!.removeChild(script!); - return; + return link; + } + if (!!this.contentOptions.enableCommandUris && link.scheme === Schemes.COMMAND) { + return link; } + return undefined; + } + + protected async loadResource(requestPath: string): Promise<void> { + const normalizedUri = this.normalizeRequestUri(requestPath); + // browser cache does not suppot file scheme, normalize to current endpoint scheme and host + const cacheUrl = new Endpoint({ path: normalizedUri.path.toString() }).getRestUrl().toString(); - const codeApiScript = contentDocument.createElement('script'); - codeApiScript.id = scriptId; - codeApiScript.textContent = ` - window.postMessageExt = window.parent['postMessageExt${this.id}']; - const acquireVsCodeApi = (function() { - let acquired = false; - let state = ${this.state ? `JSON.parse(${JSON.stringify(this.state)})` : undefined}; - return () => { - if (acquired) { - throw new Error('An instance of the VS Code API has already been acquired'); + try { + if (this.contentOptions.localResourceRoots) { + for (const root of this.contentOptions.localResourceRoots) { + if (!new URI(root).path.isEqualOrParent(normalizedUri.path)) { + continue; } - acquired = true; - return Object.freeze({ - postMessage: function(msg) { - return window.postMessageExt({ command: 'onmessage', data: msg }, '*'); - }, - setState: function(newState) { - state = newState; - window.postMessageExt({ command: 'do-update-state', data: JSON.stringify(newState) }, '*'); - return newState; - }, - getState: function() { - return state; - } - }); - }; - })(); - const acquireTheiaApi = (function() { - let acquired = false; - let state = ${this.state ? `JSON.parse(${JSON.stringify(this.state)})` : undefined}; - return () => { - if (acquired) { - throw new Error('An instance of the VS Code API has already been acquired'); + let cached = await this.resourceCache.match(cacheUrl); + const response = await this.resourceLoader.load({ uri: normalizedUri.toString(), eTag: cached && cached.eTag }); + if (response) { + const { buffer, eTag } = response; + cached = { body: () => new Uint8Array(buffer), eTag: eTag }; + this.resourceCache.put(cacheUrl, cached); } - acquired = true; - return Object.freeze({ - postMessage: function(msg) { - return window.postMessageExt({ command: 'onmessage', data: msg }, '*'); - }, - setState: function(newState) { - state = newState; - window.postMessageExt({ command: 'do-update-state', data: JSON.stringify(newState) }, '*'); - return newState; - }, - getState: function() { - return state; - } - }); - }; - })(); - delete window.parent; - delete window.top; - delete window.frameElement; - `; - const parent = contentDocument.head ? contentDocument.head : contentDocument.body; - if (parent.hasChildNodes()) { - parent.insertBefore(codeApiScript, parent.firstChild); - } else { - parent.appendChild(codeApiScript); + if (cached) { + const data = await cached.body(); + return this.doSend('did-load-resource', { + status: 200, + path: requestPath, + mime: mime.getType(normalizedUri.path.toString()) || 'application/octet-stream', + data + }); + } + } + } + } catch { + // no-op } + + this.resourceCache.delete(cacheUrl); + return this.doSend('did-load-resource', { + status: 404, + path: requestPath + }); } - /** - * Check if given object is ready to receive message and if it is ready, resolve promise - */ - waitReceiveMessage(object: WebviewWidget, resolve: any): void { - if (object.readyToReceiveMessage) { - resolve(true); + protected normalizeRequestUri(requestPath: string): URI { + const normalizedPath = decodeURIComponent(requestPath); + const requestUri = new URI(normalizedPath.replace(/^\/(\w+)\/(.+)$/, (_, scheme, path) => scheme + ':/' + path)); + if (requestUri.scheme !== 'theia-resource' && requestUri.scheme !== 'vscode-resource') { + return requestUri; + } + + // Modern vscode-resources uris put the scheme of the requested resource as the authority + if (requestUri.authority) { + return new URI(requestUri.authority + ':' + requestUri.path); + } + + // Old style vscode-resource uris lose the scheme of the resource which means they are unable to + // load a mix of local and remote content properly. + return requestUri.withScheme('file'); + } + + sendMessage(data: any): void { + if (this.element) { + this.doSend('message', data); } else { - setTimeout(this.waitReceiveMessage, 100, object, resolve); + this.pendingMessages.push(data); } } - /** - * Block until we're able to receive message - */ - public async waitReadyToReceiveMessage(): Promise<boolean> { - return new Promise<boolean>((resolve, reject) => { - this.waitReceiveMessage(this, resolve); + protected doUpdateContent(): void { + this.doSend('content', { + contents: this.html, + options: this.contentOptions, + state: this.state }); } -} + storeState(): WebviewWidget.State { + return { + viewType: this.viewType, + title: this.title.label, + iconUrl: this.iconUrl, + options: this.options, + contentOptions: this.contentOptions, + state: this.state + }; + } + + restoreState(oldState: WebviewWidget.State): void { + const { viewType, title, iconUrl, options, contentOptions, state } = oldState; + this.viewType = viewType; + this.title.label = title; + this.setIconUrl(iconUrl); + this.options = options; + this._contentOptions = contentOptions; + this._state = state; + } + + protected async doSend(channel: string, data?: any): Promise<void> { + if (!this.element) { + return; + } + try { + await this.ready.promise; + this.postMessage(channel, data); + } catch (e) { + console.error(e); + } + } + + protected postMessage(channel: string, data?: any): void { + if (this.element) { + this.trace('out', channel, data); + this.element.contentWindow!.postMessage({ channel, args: data }, '*'); + } + } + + protected on<T = unknown>(channel: WebviewMessageChannels, handler: (data: T) => void): Disposable { + const listener = (e: any) => { + if (!e || !e.data || e.data.target !== this.identifier.id) { + return; + } + if (e.data.channel === channel) { + this.trace('in', e.data.channel, e.data.data); + handler(e.data.data); + } + }; + window.addEventListener('message', listener); + return Disposable.create(() => + window.removeEventListener('message', listener) + ); + } + + protected trace(kind: 'in' | 'out', channel: string, data?: any): void { + const value = this.preferences['webview.trace']; + if (value === 'off') { + return; + } + const output = this.outputManager.getChannel('webviews'); + output.append('\n' + this.identifier.id); + output.append(kind === 'out' ? ' => ' : ' <= '); + output.append(channel); + if (value === 'verbose') { + if (data) { + output.append('\n' + JSON.stringify(data, undefined, 2)); + } + } + } + +} export namespace WebviewWidget { export namespace Styles { - export const WEBVIEW = 'theia-webview'; - + } + export interface State { + viewType: string + title: string + iconUrl?: IconUrl + options: WebviewPanelOptions + contentOptions: WebviewContentOptions + state?: string } } diff --git a/packages/plugin-ext/src/main/browser/webviews-main.ts b/packages/plugin-ext/src/main/browser/webviews-main.ts index 8c0909b17f28d..06185115bcd8b 100644 --- a/packages/plugin-ext/src/main/browser/webviews-main.ts +++ b/packages/plugin-ext/src/main/browser/webviews-main.ts @@ -14,131 +14,80 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt } from '../../common/plugin-api-rpc'; +import debounce = require('lodash.debounce'); +import URI from 'vscode-uri'; import { interfaces } from 'inversify'; +import { WebviewsMain, MAIN_RPC_CONTEXT, WebviewsExt, WebviewPanelViewState } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; -import { UriComponents } from '../../common/uri-components'; import { WebviewOptions, WebviewPanelOptions, WebviewPanelShowOptions } from '@theia/plugin'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; -import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; -import { WebviewWidget } from './webview/webview'; -import { ThemeService } from '@theia/core/lib/browser/theming'; -import { ThemeRulesService } from './webview/theme-rules-service'; +import { WebviewWidget, WebviewWidgetIdentifier } from './webview/webview'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { ViewColumnService } from './view-column-service'; -import { ApplicationShellMouseTracker } from '@theia/core/lib/browser/shell/application-shell-mouse-tracker'; - -import debounce = require('lodash.debounce'); +import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; +import { JSONExt } from '@phosphor/coreutils/lib/json'; +import { Mutable } from '@theia/core/lib/common/types'; +import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; +import { IconUrl } from '../../common/plugin-protocol'; export class WebviewsMainImpl implements WebviewsMain, Disposable { - private readonly revivers = new Set<string>(); + private readonly proxy: WebviewsExt; protected readonly shell: ApplicationShell; + protected readonly widgets: WidgetManager; + protected readonly pluginService: HostedPluginSupport; protected readonly viewColumnService: ViewColumnService; - protected readonly keybindingRegistry: KeybindingRegistry; - protected readonly themeService = ThemeService.get(); - protected readonly themeRulesService = ThemeRulesService.get(); - protected readonly updateViewOptions: () => void; - - private readonly views = new Map<string, WebviewWidget>(); - private readonly viewsOptions = new Map<string, { - panelOptions: WebviewPanelShowOptions; - options: (WebviewPanelOptions & WebviewOptions) | undefined; - panelId: string; - active: boolean; - visible: boolean; - }>(); - - protected readonly mouseTracker: ApplicationShellMouseTracker; - private readonly toDispose = new DisposableCollection(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WEBVIEWS_EXT); this.shell = container.get(ApplicationShell); - this.mouseTracker = container.get(ApplicationShellMouseTracker); - this.keybindingRegistry = container.get(KeybindingRegistry); this.viewColumnService = container.get(ViewColumnService); - this.updateViewOptions = debounce<() => void>(() => { - for (const key of this.viewsOptions.keys()) { - this.checkViewOptions(key); - } - }, 100); - this.toDispose.push(this.shell.onDidChangeActiveWidget(() => this.updateViewOptions())); - this.toDispose.push(this.shell.onDidChangeCurrentWidget(() => this.updateViewOptions())); - this.toDispose.push(this.viewColumnService.onViewColumnChanged(() => this.updateViewOptions())); + this.widgets = container.get(WidgetManager); + this.pluginService = container.get(HostedPluginSupport); + this.toDispose.push(this.shell.onDidChangeActiveWidget(() => this.updateViewStates())); + this.toDispose.push(this.shell.onDidChangeCurrentWidget(() => this.updateViewStates())); + this.toDispose.push(this.viewColumnService.onViewColumnChanged(() => this.updateViewStates())); } dispose(): void { this.toDispose.dispose(); } - $createWebviewPanel( + async $createWebviewPanel( panelId: string, viewType: string, title: string, showOptions: WebviewPanelShowOptions, - options: (WebviewPanelOptions & WebviewOptions) | undefined, - extensionLocation: UriComponents - ): void { - const toDisposeOnClose = new DisposableCollection(); - const toDisposeOnLoad = new DisposableCollection(); - const view = new WebviewWidget(title, { - allowScripts: options ? options.enableScripts : false - }, { - onMessage: m => { - this.proxy.$onMessage(panelId, m); - }, - onKeyboardEvent: e => { - this.keybindingRegistry.run(e); - }, - onLoad: contentDocument => { - const styleId = 'webview-widget-theme'; - let styleElement: HTMLStyleElement | null | undefined; - if (!toDisposeOnLoad.disposed) { - // if reload the frame - toDisposeOnLoad.dispose(); - styleElement = <HTMLStyleElement>contentDocument.getElementById(styleId); - } - toDisposeOnClose.push(toDisposeOnLoad); - if (!styleElement) { - const parent = contentDocument.head ? contentDocument.head : contentDocument.body; - styleElement = this.themeRulesService.createStyleSheet(parent); - styleElement.id = styleId; - parent.appendChild(styleElement); - } + options: WebviewPanelOptions & WebviewOptions + ): Promise<void> { + const view = await this.widgets.getOrCreateWidget<WebviewWidget>(WebviewWidget.FACTORY_ID, <WebviewWidgetIdentifier>{ id: panelId }); + this.hookWebview(view); + view.viewType = viewType; + view.title.label = title; + const { enableFindWidget, retainContextWhenHidden, enableScripts, localResourceRoots, ...contentOptions } = options; + view.options = { enableFindWidget, retainContextWhenHidden }; + view.setContentOptions({ + allowScripts: enableScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()), + ...contentOptions + }); + this.addOrReattachWidget(view, showOptions); + } - this.themeRulesService.setRules(styleElement, this.themeRulesService.getCurrentThemeRules()); - contentDocument.body.className = `vscode-${ThemeService.get().getCurrentTheme().id}`; - toDisposeOnLoad.push(this.themeService.onThemeChange(() => { - this.themeRulesService.setRules(<HTMLElement>styleElement, this.themeRulesService.getCurrentThemeRules()); - contentDocument.body.className = `vscode-${ThemeService.get().getCurrentTheme().id}`; - })); - } - }, - this.mouseTracker); + protected hookWebview(view: WebviewWidget): void { + const handle = view.identifier.id; + this.toDispose.push(view.onDidChangeVisibility(() => this.updateViewState(view))); + this.toDispose.push(view.onMessage(data => this.proxy.$onMessage(handle, data))); view.disposed.connect(() => { - toDisposeOnClose.dispose(); - this.proxy.$onDidDisposeWebviewPanel(panelId); + if (this.toDispose.disposed) { + return; + } + this.proxy.$onDidDisposeWebviewPanel(handle); }); - this.toDispose.push(view); - - const viewId = view.id; - toDisposeOnClose.push(Disposable.create(() => this.themeRulesService.setIconPath(viewId, undefined))); - - this.views.set(panelId, view); - toDisposeOnClose.push(Disposable.create(() => this.views.delete(panelId))); - - this.viewsOptions.set(viewId, { panelOptions: showOptions, options: options, panelId, visible: false, active: false }); - toDisposeOnClose.push(Disposable.create(() => this.viewsOptions.delete(viewId))); - - this.addOrReattachWidget(panelId, showOptions); } - private addOrReattachWidget(handler: string, showOptions: WebviewPanelShowOptions): void { - const view = this.views.get(handler); - if (!view) { - return; - } + + private addOrReattachWidget(widget: WebviewWidget, showOptions: WebviewPanelShowOptions): void { const widgetOptions: ApplicationShell.WidgetOptions = { area: showOptions.area ? showOptions.area : 'main' }; let mode = 'open-to-right'; @@ -159,133 +108,150 @@ export class WebviewsMainImpl implements WebviewsMain, Disposable { widgetIds = this.viewColumnService.getViewColumnIds(showOptions.viewColumn); } } - const ref = this.shell.getWidgets(widgetOptions.area).find(widget => widget.isVisible && widgetIds.indexOf(widget.id) !== -1); + const ref = this.shell.getWidgets(widgetOptions.area).find(w => !w.isHidden && widgetIds.indexOf(w.id) !== -1); if (ref) { Object.assign(widgetOptions, { ref, mode }); } } - this.shell.addWidget(view, widgetOptions); - const visible = true; - let active: boolean; + this.shell.addWidget(widget, widgetOptions); if (showOptions.preserveFocus) { - this.shell.revealWidget(view.id); - active = false; + this.shell.revealWidget(widget.id); } else { - this.shell.activateWidget(view.id); - active = true; + this.shell.activateWidget(widget.id); } - const options = this.viewsOptions.get(view.id); - if (!options) { - return; - } - options.panelOptions = showOptions; - options.visible = visible; - options.active = active; } - $disposeWebview(handle: string): void { - const view = this.views.get(handle); + + async $disposeWebview(handle: string): Promise<void> { + const view = await this.tryGetWebview(handle); if (view) { view.dispose(); } } - $reveal(handle: string, showOptions: WebviewPanelShowOptions): void { - const view = this.getWebview(handle); - if (view.isDisposed) { + + async $reveal(handle: string, showOptions: WebviewPanelShowOptions): Promise<void> { + const widget = await this.getWebview(handle); + if (widget.isDisposed) { return; } - const options = this.viewsOptions.get(view.id); - let retain = false; - if (options && options.options && options.options.retainContextWhenHidden) { - retain = options.options.retainContextWhenHidden; - } - if ((showOptions.viewColumn !== undefined && showOptions.viewColumn !== options!.panelOptions.viewColumn) || showOptions.area !== undefined) { + if ((showOptions.viewColumn !== undefined && showOptions.viewColumn !== widget.viewState.position) || showOptions.area !== undefined) { this.viewColumnService.updateViewColumns(); - if (!options) { - return; - } const columnIds = showOptions.viewColumn ? this.viewColumnService.getViewColumnIds(showOptions.viewColumn) : []; - if (columnIds.indexOf(view.id) === -1 || options.panelOptions.area !== showOptions.area) { - this.addOrReattachWidget(options.panelId, showOptions); - options.panelOptions = showOptions; - this.checkViewOptions(view.id, options.panelOptions.viewColumn); - this.updateViewOptions(); + const area = this.shell.getAreaFor(widget); + if (columnIds.indexOf(widget.id) === -1 || area !== showOptions.area) { + this.addOrReattachWidget(widget, showOptions); return; } - } else if (!retain) { - // reload content when revealing - view.reloadFrame(); } - if (showOptions.preserveFocus) { - this.shell.revealWidget(view.id); + this.shell.revealWidget(widget.id); } else { - this.shell.activateWidget(view.id); + this.shell.activateWidget(widget.id); } } - $setTitle(handle: string, value: string): void { - const webview = this.getWebview(handle); + + async $setTitle(handle: string, value: string): Promise<void> { + const webview = await this.getWebview(handle); webview.title.label = value; } - $setIconPath(handle: string, iconPath: { light: string; dark: string; } | string | undefined): void { - const webview = this.getWebview(handle); - webview.setIconClass(iconPath ? `webview-icon ${webview.id}-file-icon` : ''); - this.themeRulesService.setIconPath(webview.id, iconPath); + + async $setIconPath(handle: string, iconUrl: IconUrl | undefined): Promise<void> { + const webview = await this.getWebview(handle); + webview.setIconUrl(iconUrl); } - $setHtml(handle: string, value: string): void { - const webview = this.getWebview(handle); + + async $setHtml(handle: string, value: string): Promise<void> { + const webview = await this.getWebview(handle); webview.setHTML(value); } - $setOptions(handle: string, options: WebviewOptions): void { - const webview = this.getWebview(handle); - webview.setOptions({ allowScripts: options ? options.enableScripts : false }); + + async $setOptions(handle: string, options: WebviewOptions): Promise<void> { + const webview = await this.getWebview(handle); + const { enableScripts, localResourceRoots, ...contentOptions } = options; + webview.setContentOptions({ + allowScripts: enableScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => root.toString()), + ...contentOptions + }); } + // tslint:disable-next-line:no-any - $postMessage(handle: string, value: any): Thenable<boolean> { - const webview = this.getWebview(handle); - if (webview) { - webview.postMessage(value); - } - return Promise.resolve(webview !== undefined); + async $postMessage(handle: string, value: any): Promise<boolean> { + const webview = await this.getWebview(handle); + webview.sendMessage(value); + return true; } + $registerSerializer(viewType: string): void { - this.revivers.add(viewType); + this.pluginService.registerWebviewReviver(viewType, widget => this.restoreWidget(widget)); this.toDispose.push(Disposable.create(() => this.$unregisterSerializer(viewType))); } + $unregisterSerializer(viewType: string): void { - this.revivers.delete(viewType); + this.pluginService.unregisterWebviewReviver(viewType); } - private async checkViewOptions(handler: string, viewColumn?: number | undefined): Promise<void> { - const options = this.viewsOptions.get(handler); - if (!options || !options.panelOptions) { - return; + protected async restoreWidget(widget: WebviewWidget): Promise<void> { + this.hookWebview(widget); + const handle = widget.identifier.id; + const title = widget.title.label; + + let state = undefined; + if (widget.state) { + try { + state = JSON.parse(widget.state); + } catch { + // noop + } } - const view = this.views.get(options.panelId); - if (!view) { - return; + + const options = widget.options; + const { allowScripts, localResourceRoots, ...contentOptions } = widget.contentOptions; + this.updateViewState(widget); + await this.proxy.$deserializeWebviewPanel(handle, widget.viewType, title, state, widget.viewState, { + enableScripts: allowScripts, + localResourceRoots: localResourceRoots && localResourceRoots.map(root => URI.parse(root)), + ...contentOptions, + ...options + }); + } + + protected readonly updateViewStates = debounce(() => { + for (const widget of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) { + if (widget instanceof WebviewWidget) { + this.updateViewState(widget); + } } - const active = !!this.shell.activeWidget ? this.shell.activeWidget.id === view!.id : false; - const visible = view!.isVisible; - if (viewColumn === undefined) { + }, 100); + + private updateViewState(widget: WebviewWidget, viewColumn?: number | undefined): void { + const viewState: Mutable<WebviewPanelViewState> = { + active: this.shell.activeWidget === widget, + visible: !widget.isHidden, + position: viewColumn || 0 + }; + if (typeof viewColumn !== 'number') { this.viewColumnService.updateViewColumns(); - viewColumn = this.viewColumnService.hasViewColumn(view.id) ? this.viewColumnService.getViewColumn(view.id)! : 0; - if (options.panelOptions.viewColumn === viewColumn && options.visible === visible && options.active === active) { - return; - } + viewState.position = this.viewColumnService.getViewColumn(widget.id) || 0; + } + // tslint:disable-next-line:no-any + if (JSONExt.deepEqual(<any>viewState, <any>widget.viewState)) { + return; } - options.active = active; - options.visible = visible; - options.panelOptions.viewColumn = viewColumn; - this.proxy.$onDidChangeWebviewPanelViewState(options.panelId, { active, visible, position: options.panelOptions.viewColumn! }); + widget.viewState = viewState; + this.proxy.$onDidChangeWebviewPanelViewState(widget.identifier.id, widget.viewState); } - private getWebview(viewId: string): WebviewWidget { - const webview = this.views.get(viewId); + private async getWebview(viewId: string): Promise<WebviewWidget> { + const webview = await this.tryGetWebview(viewId); if (!webview) { throw new Error(`Unknown Webview: ${viewId}`); } return webview; } + private async tryGetWebview(id: string): Promise<WebviewWidget | undefined> { + return this.widgets.getWidget<WebviewWidget>(WebviewWidget.FACTORY_ID, <WebviewWidgetIdentifier>{ id }); + } + } diff --git a/packages/plugin-ext/src/main/browser/window-state-main.ts b/packages/plugin-ext/src/main/browser/window-state-main.ts index 7d199850b0d36..8d0113df33517 100644 --- a/packages/plugin-ext/src/main/browser/window-state-main.ts +++ b/packages/plugin-ext/src/main/browser/window-state-main.ts @@ -15,24 +15,29 @@ ********************************************************************************/ import URI from 'vscode-uri'; +import CoreURI from '@theia/core/lib/common/uri'; import { interfaces } from 'inversify'; import { WindowStateExt, MAIN_RPC_CONTEXT, WindowMain } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { UriComponents } from '../../common/uri-components'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; -import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; +import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service'; export class WindowStateMain implements WindowMain, Disposable { private readonly proxy: WindowStateExt; - private readonly windowService: WindowService; + private readonly openerService: OpenerService; + + private readonly externalUriService: ExternalUriService; private readonly toDispose = new DisposableCollection(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.WINDOW_STATE_EXT); - this.windowService = container.get(WindowService); + this.openerService = container.get(OpenerService); + this.externalUriService = container.get(ExternalUriService); const fireDidFocus = () => this.onFocusChanged(true); window.addEventListener('focus', fireDidFocus); @@ -53,13 +58,19 @@ export class WindowStateMain implements WindowMain, Disposable { async $openUri(uriComponent: UriComponents): Promise<boolean> { const uri = URI.revive(uriComponent); - const url = encodeURI(uri.toString(true)); + const url = new CoreURI(encodeURI(uri.toString(true))); try { - this.windowService.openNewWindow(url, { external: true }); + await open(this.openerService, url); return true; } catch (e) { return false; } } + async $asExternalUri(uriComponents: UriComponents): Promise<UriComponents> { + const uri = URI.revive(uriComponents); + const resolved = await this.externalUriService.resolve(new CoreURI(uri)); + return URI.parse(resolved.toString()); + } + } diff --git a/packages/plugin-ext/src/main/common/webview-protocol.ts b/packages/plugin-ext/src/main/common/webview-protocol.ts new file mode 100644 index 0000000000000..58a06a59fccb7 --- /dev/null +++ b/packages/plugin-ext/src/main/common/webview-protocol.ts @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (C) 2019 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 + ********************************************************************************/ + +/** + * Each webview should be deployed on a unique origin (https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) + * to ensure isolation from browser shared state as cookies, local storage and so on. + * + * Use `THEIA_WEBVIEW_EXTERNAL_ENDPOINT` to customize the hostname pattern of a origin. + * By default is `{{uuid}}.webview.{{hostname}}`. Where `{{uuid}}` is a placeholder for a webview global id. + */ +export namespace WebviewExternalEndpoint { + export const pattern = 'THEIA_WEBVIEW_EXTERNAL_ENDPOINT'; + export const defaultPattern = '{{uuid}}.webview.{{hostname}}'; +} + +export interface LoadWebviewResourceParams { + uri: string + eTag?: string +} + +export interface LoadWebviewResourceResult { + buffer: number[] + eTag: string +} + +export const WebviewResourceLoader = Symbol('WebviewResourceLoader'); +export interface WebviewResourceLoader { + /** + * Loads initial webview resource data. + * Returns `undefined` if a resource has not beed modified. + * Throws if a resource cannot be loaded. + */ + load(params: LoadWebviewResourceParams): Promise<LoadWebviewResourceResult | undefined> +} +export const WebviewResourceLoaderPath = '/services/webview-resource-loader'; diff --git a/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts b/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts index 4d1b2d9047316..c364c7ae25c89 100644 --- a/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts +++ b/packages/plugin-ext/src/main/node/plugin-ext-backend-module.ts @@ -34,8 +34,17 @@ import { PluginPathsService, pluginPathsServicePath } from '../common/plugin-pat import { PluginPathsServiceImpl } from './paths/plugin-paths-service'; import { PluginServerHandler } from './plugin-server-handler'; import { PluginCliContribution } from './plugin-cli-contribution'; +import { WebviewResourceLoaderImpl } from './webview-resource-loader-impl'; +import { WebviewResourceLoaderPath } from '../common/webview-protocol'; export function bindMainBackend(bind: interfaces.Bind): void { + bind(WebviewResourceLoaderImpl).toSelf().inSingletonScope(); + bind(ConnectionHandler).toDynamicValue(ctx => + new JsonRpcConnectionHandler(WebviewResourceLoaderPath, () => + ctx.container.get(WebviewResourceLoaderImpl) + ) + ).inSingletonScope(); + bind(PluginApiContribution).toSelf().inSingletonScope(); bind(BackendApplicationContribution).toService(PluginApiContribution); diff --git a/packages/plugin-ext/src/main/node/plugin-service.ts b/packages/plugin-ext/src/main/node/plugin-service.ts index 7715448592973..1f2572f5672bb 100644 --- a/packages/plugin-ext/src/main/node/plugin-service.ts +++ b/packages/plugin-ext/src/main/node/plugin-service.ts @@ -13,23 +13,37 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ + +import * as path from 'path'; +import connect = require('connect'); +import serveStatic = require('serve-static'); +const vhost = require('vhost'); import * as express from 'express'; import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; import { injectable } from 'inversify'; -import { FileUri } from '@theia/core/lib/node'; +import { WebviewExternalEndpoint } from '../common/webview-protocol'; const pluginPath = (process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE) + './theia/plugins/'; @injectable() export class PluginApiContribution implements BackendApplicationContribution { + configure(app: express.Application): void { app.get('/plugin/:path(*)', (req, res) => { const filePath: string = req.params.path; res.sendFile(pluginPath + filePath); }); - app.get('/webview/:path(*)', (req, res) => { - res.sendFile(FileUri.fsPath('file:/' + req.params.path)); - }); + const webviewApp = connect(); + webviewApp.use('/webview', serveStatic(path.join(__dirname, '../../../src/main/browser/webview/pre'))); + const webviewExternalEndpoint = this.webviewExternalEndpoint(); + console.log(`Configuring to accept webviews on '${webviewExternalEndpoint}' hostname.`); + app.use(vhost(new RegExp(webviewExternalEndpoint, 'i'), webviewApp)); + } + + protected webviewExternalEndpoint(): string { + return (process.env[WebviewExternalEndpoint.pattern] || WebviewExternalEndpoint.defaultPattern) + .replace('{{uuid}}', '.+') + .replace('{{hostname}}', '.+'); } } diff --git a/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts b/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts new file mode 100644 index 0000000000000..16d769a327cde --- /dev/null +++ b/packages/plugin-ext/src/main/node/webview-resource-loader-impl.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (C) 2019 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 * as fs from 'fs-extra'; +import * as crypto from 'crypto'; +import { injectable } from 'inversify'; +import { WebviewResourceLoader, LoadWebviewResourceParams, LoadWebviewResourceResult } from '../common/webview-protocol'; +import { FileUri } from '@theia/core/lib/node/file-uri'; + +@injectable() +export class WebviewResourceLoaderImpl implements WebviewResourceLoader { + + async load(params: LoadWebviewResourceParams): Promise<LoadWebviewResourceResult | undefined> { + const fsPath = FileUri.fsPath(params.uri); + const stat = await fs.stat(fsPath); + const eTag = this.compileETag(fsPath, stat); + if ('eTag' in params && params.eTag === eTag) { + return undefined; + } + const buffer = await fs.readFile(FileUri.fsPath(params.uri)); + return { buffer: buffer.toJSON().data, eTag }; + } + + protected compileETag(fsPath: string, stat: fs.Stats): string { + return crypto.createHash('md5') + .update(fsPath + stat.mtime.getTime() + stat.size, 'utf8') + .digest('base64'); + } + +} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 2937388b309fd..05e05bee14978 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -130,7 +130,6 @@ import { MarkdownString } from './markdown-string'; import { TreeViewsExtImpl } from './tree/tree-views'; import { LanguagesContributionExtImpl } from './languages-contribution-ext'; import { ConnectionExtImpl } from './connection-ext'; -import { WebviewsExtImpl } from './webviews'; import { TasksExtImpl } from './tasks/tasks'; import { DebugExtImpl } from './node/debug/debug'; import { FileSystemExtImpl } from './file-system'; @@ -140,6 +139,7 @@ import { DecorationProvider, LineChange } from '@theia/plugin'; import { DecorationsExtImpl } from './decorations'; import { TextEditorExt } from './text-editor'; import { ClipboardExt } from './clipboard-ext'; +import { WebviewsExtImpl } from './webviews'; export function createAPIFactory( rpc: RPCProtocol, @@ -150,7 +150,8 @@ export function createAPIFactory( editorsAndDocumentsExt: EditorsAndDocumentsExtImpl, workspaceExt: WorkspaceExtImpl, messageRegistryExt: MessageRegistryExt, - clipboard: ClipboardExt + clipboard: ClipboardExt, + webviewExt: WebviewsExtImpl ): PluginAPIFactory { const commandRegistry = rpc.set(MAIN_RPC_CONTEXT.COMMAND_REGISTRY_EXT, new CommandRegistryImpl(rpc)); @@ -165,7 +166,6 @@ export function createAPIFactory( const outputChannelRegistryExt = rpc.set(MAIN_RPC_CONTEXT.OUTPUT_CHANNEL_REGISTRY_EXT, new OutputChannelRegistryExtImpl(rpc)); const languagesExt = rpc.set(MAIN_RPC_CONTEXT.LANGUAGES_EXT, new LanguagesExtImpl(rpc, documents, commandRegistry)); const treeViewsExt = rpc.set(MAIN_RPC_CONTEXT.TREE_VIEWS_EXT, new TreeViewsExtImpl(rpc, commandRegistry)); - const webviewExt = rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, new WebviewsExtImpl(rpc)); const tasksExt = rpc.set(MAIN_RPC_CONTEXT.TASKS_EXT, new TasksExtImpl(rpc)); const connectionExt = rpc.set(MAIN_RPC_CONTEXT.CONNECTION_EXT, new ConnectionExtImpl(rpc)); const languagesContributionExt = rpc.set(MAIN_RPC_CONTEXT.LANGUAGES_CONTRIBUTION_EXT, new LanguagesContributionExtImpl(rpc, connectionExt)); @@ -338,11 +338,11 @@ export function createAPIFactory( createWebviewPanel(viewType: string, title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, - options: theia.WebviewPanelOptions & theia.WebviewOptions): theia.WebviewPanel { - return webviewExt.createWebview(viewType, title, showOptions, options, Uri.file(plugin.pluginPath)); + options: theia.WebviewPanelOptions & theia.WebviewOptions = {}): theia.WebviewPanel { + return webviewExt.createWebview(viewType, title, showOptions, options, plugin); }, registerWebviewPanelSerializer(viewType: string, serializer: theia.WebviewPanelSerializer): theia.Disposable { - return webviewExt.registerWebviewPanelSerializer(viewType, serializer); + return webviewExt.registerWebviewPanelSerializer(viewType, serializer, plugin); }, get state(): theia.WindowState { return windowStateExt.getWindowState(); @@ -508,6 +508,9 @@ export function createAPIFactory( }, openExternal(uri: theia.Uri): PromiseLike<boolean> { return windowStateExt.openUri(uri); + }, + asExternalUri(target: theia.Uri): PromiseLike<theia.Uri> { + return windowStateExt.asExternalUri(target); } }); diff --git a/packages/plugin-ext/src/plugin/plugin-icon-path.ts b/packages/plugin-ext/src/plugin/plugin-icon-path.ts new file mode 100644 index 0000000000000..3c709ab31ce77 --- /dev/null +++ b/packages/plugin-ext/src/plugin/plugin-icon-path.ts @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (C) 2019 Red Hat, Inc. 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 * as path from 'path'; +import Uri from 'vscode-uri'; +import { IconUrl, PluginPackage } from '../common/plugin-protocol'; +import { Plugin } from '../common/plugin-api-rpc'; + +export type PluginIconPath = string | Uri | { + light: string | Uri, + dark: string | Uri +}; +export namespace PluginIconPath { + export function toUrl(iconPath: PluginIconPath | undefined, plugin: Plugin): IconUrl | undefined { + if (!iconPath) { + return undefined; + } + if (typeof iconPath === 'object' && 'light' in iconPath) { + return { + light: asString(iconPath.light, plugin), + dark: asString(iconPath.dark, plugin) + }; + } + return asString(iconPath, plugin); + } + export function asString(arg: string | Uri, plugin: Plugin): string { + arg = arg instanceof Uri && arg.scheme === 'file' ? arg.fsPath : arg; + if (typeof arg !== 'string') { + return arg.toString(true); + } + const { packagePath } = plugin.rawModel; + const absolutePath = path.isAbsolute(arg) ? arg : path.join(packagePath, arg); + const normalizedPath = path.normalize(absolutePath); + const relativePath = path.relative(packagePath, normalizedPath); + return PluginPackage.toPluginUrl(plugin.rawModel, relativePath); + } +} diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 4ccd8c4b6b0f5..4f794a2e725d1 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -39,6 +39,7 @@ import { RPCProtocol } from '../common/rpc-protocol'; import { Emitter } from '@theia/core/lib/common/event'; import * as os from 'os'; import * as fs from 'fs-extra'; +import { WebviewsExtImpl } from './webviews'; export interface PluginHost { @@ -72,7 +73,8 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { 'onDebug', 'onDebugInitialConfigurations', 'onDebugResolve', 'onDebugAdapterProtocolTracker', 'workspaceContains', 'onView', - 'onUri' + 'onUri', + 'onWebviewPanel' ]); private readonly registry = new Map<string, Plugin>(); @@ -94,6 +96,7 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { private readonly envExt: EnvExtImpl, private readonly storageProxy: KeyValueStorageProxy, private readonly preferencesManager: PreferenceRegistryExtImpl, + private readonly webview: WebviewsExtImpl, private readonly rpc: RPCProtocol ) { this.messageRegistryProxy = this.rpc.getProxy(PLUGIN_RPC_CONTEXT.MESSAGE_REGISTRY_MAIN); @@ -149,6 +152,8 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { if (params.extApi) { this.host.initExtApi(params.extApi); } + + this.webview.init(params.webview); } async $start(params: PluginManagerStartParams): Promise<void> { diff --git a/packages/plugin-ext/src/plugin/tree/tree-views.ts b/packages/plugin-ext/src/plugin/tree/tree-views.ts index 81a79ef52b60d..1ecc7fd96a728 100644 --- a/packages/plugin-ext/src/plugin/tree/tree-views.ts +++ b/packages/plugin-ext/src/plugin/tree/tree-views.ts @@ -16,8 +16,6 @@ // tslint:disable:no-any -import * as path from 'path'; -import URI from 'vscode-uri'; import { TreeDataProvider, TreeView, TreeViewExpansionEvent, TreeItem2, TreeItemLabel, TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent @@ -31,7 +29,7 @@ import { Plugin, PLUGIN_RPC_CONTEXT, TreeViewsExt, TreeViewsMain, TreeViewItem } import { RPCProtocol } from '../../common/rpc-protocol'; import { CommandRegistryImpl, CommandsConverter } from '../command-registry'; import { TreeViewSelection } from '../../common'; -import { PluginPackage } from '../../common/plugin-protocol'; +import { PluginIconPath } from '../plugin-icon-path'; export class TreeViewsExtImpl implements TreeViewsExt { @@ -279,31 +277,12 @@ class TreeViewExtImpl<T> implements Disposable { let iconUrl; let themeIconId; const { iconPath } = treeItem; - if (iconPath) { - const toUrl = (arg: string | URI) => { - arg = arg instanceof URI && arg.scheme === 'file' ? arg.fsPath : arg; - if (typeof arg !== 'string') { - return arg.toString(true); - } - const { packagePath } = this.plugin.rawModel; - const absolutePath = path.isAbsolute(arg) ? arg : path.join(packagePath, arg); - const normalizedPath = path.normalize(absolutePath); - const relativePath = path.relative(packagePath, normalizedPath); - return PluginPackage.toPluginUrl(this.plugin.rawModel, relativePath); - }; - if (typeof iconPath === 'string' && iconPath.indexOf('fa-') !== -1) { - icon = iconPath; - } else if (iconPath instanceof ThemeIcon) { - themeIconId = iconPath.id; - } else if (typeof iconPath === 'string' || iconPath instanceof URI) { - iconUrl = toUrl(iconPath); - } else { - const { light, dark } = iconPath as { light: string | URI, dark: string | URI }; - iconUrl = { - light: toUrl(light), - dark: toUrl(dark) - }; - } + if (typeof iconPath === 'string' && iconPath.indexOf('fa-') !== -1) { + icon = iconPath; + } else if (iconPath instanceof ThemeIcon) { + themeIconId = iconPath.id; + } else { + iconUrl = PluginIconPath.toUrl(<PluginIconPath | undefined>iconPath, this.plugin); } const treeViewItem = { diff --git a/packages/plugin-ext/src/plugin/webviews.ts b/packages/plugin-ext/src/plugin/webviews.ts index 7611998af941f..eeda7354eb984 100644 --- a/packages/plugin-ext/src/plugin/webviews.ts +++ b/packages/plugin-ext/src/plugin/webviews.ts @@ -14,25 +14,38 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; +import { v4 } from 'uuid'; +import { WebviewsExt, WebviewPanelViewState, WebviewsMain, PLUGIN_RPC_CONTEXT, WebviewInitData, /* WebviewsMain, PLUGIN_RPC_CONTEXT */ } from '../common/plugin-api-rpc'; import * as theia from '@theia/plugin'; import { RPCProtocol } from '../common/rpc-protocol'; -import URI from 'vscode-uri/lib/umd'; +import { Plugin } from '../common/plugin-api-rpc'; +import URI from 'vscode-uri'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { fromViewColumn, toViewColumn, toWebviewPanelShowOptions } from './type-converters'; -import { IdGenerator } from '../common/id-generator'; import { Disposable, WebviewPanelTargetArea } from './types-impl'; +import { WorkspaceExtImpl } from './workspace'; +import { PluginIconPath } from './plugin-icon-path'; export class WebviewsExtImpl implements WebviewsExt { private readonly proxy: WebviewsMain; - private readonly idGenerator = new IdGenerator('v'); private readonly webviewPanels = new Map<string, WebviewPanelImpl>(); - private readonly serializers = new Map<string, theia.WebviewPanelSerializer>(); - - constructor(rpc: RPCProtocol) { + private readonly serializers = new Map<string, { + serializer: theia.WebviewPanelSerializer, + plugin: Plugin + }>(); + private initData: WebviewInitData | undefined; + + constructor( + rpc: RPCProtocol, + private readonly workspace: WorkspaceExtImpl, + ) { this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.WEBVIEWS_MAIN); } + init(initData: WebviewInitData): void { + this.initData = initData; + } + // tslint:disable-next-line:no-any $onMessage(handle: string, message: any): void { const panel = this.getWebviewPanel(handle); @@ -65,45 +78,55 @@ export class WebviewsExtImpl implements WebviewsExt { title: string, // tslint:disable-next-line:no-any state: any, - position: number, + viewState: WebviewPanelViewState, options: theia.WebviewOptions & theia.WebviewPanelOptions): PromiseLike<void> { - const serializer = this.serializers.get(viewType); - if (!serializer) { + if (!this.initData) { + return Promise.reject(new Error('Webviews are not initialized')); + } + const entry = this.serializers.get(viewType); + if (!entry) { return Promise.reject(new Error(`No serializer found for '${viewType}'`)); } + const { serializer, plugin } = entry; - const webview = new WebviewImpl(viewId, this.proxy, options); - const revivedPanel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, toViewColumn(position)!, options, webview); + const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); + const revivedPanel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, toViewColumn(viewState.position)!, options, webview); + revivedPanel.setActive(viewState.active); + revivedPanel.setVisible(viewState.visible); this.webviewPanels.set(viewId, revivedPanel); return serializer.deserializeWebviewPanel(revivedPanel, state); } - createWebview(viewType: string, + createWebview( + viewType: string, title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, - options: (theia.WebviewPanelOptions & theia.WebviewOptions) | undefined, - extensionLocation: URI): theia.WebviewPanel { - + options: theia.WebviewPanelOptions & theia.WebviewOptions, + plugin: Plugin + ): theia.WebviewPanel { + if (!this.initData) { + throw new Error('Webviews are not initialized'); + } const webviewShowOptions = toWebviewPanelShowOptions(showOptions); - const viewId = this.idGenerator.nextId(); - this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, options, extensionLocation); + const viewId = v4(); + this.proxy.$createWebviewPanel(viewId, viewType, title, webviewShowOptions, WebviewImpl.toWebviewOptions(options, this.workspace, plugin)); - const webview = new WebviewImpl(viewId, this.proxy, options); + const webview = new WebviewImpl(viewId, this.proxy, options, this.initData, this.workspace, plugin); const panel = new WebviewPanelImpl(viewId, this.proxy, viewType, title, webviewShowOptions, options, webview); this.webviewPanels.set(viewId, panel); return panel; - } registerWebviewPanelSerializer( viewType: string, - serializer: theia.WebviewPanelSerializer + serializer: theia.WebviewPanelSerializer, + plugin: Plugin ): theia.Disposable { if (this.serializers.has(viewType)) { throw new Error(`Serializer for '${viewType}' already registered`); } - this.serializers.set(viewType, serializer); + this.serializers.set(viewType, { serializer, plugin }); this.proxy.$registerSerializer(viewType); return new Disposable(() => { @@ -131,10 +154,15 @@ export class WebviewImpl implements theia.Webview { // tslint:disable-next-line:no-any public readonly onDidReceiveMessage: Event<any> = this.onMessageEmitter.event; - constructor(private readonly viewId: string, + constructor( + private readonly viewId: string, private readonly proxy: WebviewsMain, - options: theia.WebviewOptions | undefined) { - this._options = options!; + options: theia.WebviewOptions, + private readonly initData: WebviewInitData, + private readonly workspace: WorkspaceExtImpl, + readonly plugin: Plugin + ) { + this._options = options; } dispose(): void { @@ -145,33 +173,30 @@ export class WebviewImpl implements theia.Webview { this.onMessageEmitter.dispose(); } - // tslint:disable-next-line:no-any - postMessage(message: any): PromiseLike<boolean> { + asWebviewUri(resource: theia.Uri): theia.Uri { + const uri = this.initData.webviewResourceRoot + // Make sure we preserve the scheme of the resource but convert it into a normal path segment + // The scheme is important as we need to know if we are requesting a local or a remote resource. + .replace('{{resource}}', resource.scheme + resource.toString().replace(/^\S+?:/, '')) + .replace('{{uuid}}', this.viewId); + return URI.parse(uri); + } + + get cspSource(): string { + return this.initData.webviewCspSource.replace('{{uuid}}', this.viewId); + } + + get html(): string { this.checkIsDisposed(); - // replace theia-resource: content in the given message - const decoded = JSON.stringify(message); - let newMessage = decoded.replace(new RegExp('theia-resource:/', 'g'), '/webview/'); - if (this._options && this._options.localResourceRoots) { - newMessage = this.filterLocalRoots(newMessage, this._options.localResourceRoots); - } - return this.proxy.$postMessage(this.viewId, JSON.parse(newMessage)); + return this._html; } - protected filterLocalRoots(content: string, localResourceRoots: ReadonlyArray<theia.Uri>): string { - const webViewsRegExp = /"(\/webview\/.*?)\"/g; - let m; - while ((m = webViewsRegExp.exec(content)) !== null) { - if (m.index === webViewsRegExp.lastIndex) { - webViewsRegExp.lastIndex++; - } - // take group 1 which is webview URL - const url = m[1]; - const isIncluded = localResourceRoots.some((uri): boolean => url.substring('/webview'.length).startsWith(uri.fsPath)); - if (!isIncluded) { - content = content.replace(url, url.replace('/webview', '/webview-disallowed-localroot')); - } + set html(value: string) { + this.checkIsDisposed(); + if (this._html !== value) { + this._html = value; + this.proxy.$setHtml(this.viewId, value); } - return content; } get options(): theia.WebviewOptions { @@ -181,26 +206,14 @@ export class WebviewImpl implements theia.Webview { set options(newOptions: theia.WebviewOptions) { this.checkIsDisposed(); - this.proxy.$setOptions(this.viewId, newOptions); + this.proxy.$setOptions(this.viewId, WebviewImpl.toWebviewOptions(newOptions, this.workspace, this.plugin)); this._options = newOptions; } - get html(): string { - this.checkIsDisposed(); - return this._html; - } - - set html(html: string) { - let newHtml = html.replace(new RegExp('theia-resource:/', 'g'), '/webview/'); - if (this._options && this._options.localResourceRoots) { - newHtml = this.filterLocalRoots(newHtml, this._options.localResourceRoots); - } - + // tslint:disable-next-line:no-any + postMessage(message: any): PromiseLike<boolean> { this.checkIsDisposed(); - if (this._html !== newHtml) { - this._html = newHtml; - this.proxy.$setHtml(this.viewId, newHtml); - } + return this.proxy.$postMessage(this.viewId, message); } private checkIsDisposed(): void { @@ -208,6 +221,16 @@ export class WebviewImpl implements theia.Webview { throw new Error('This Webview is disposed!'); } } + + static toWebviewOptions(options: theia.WebviewOptions, workspace: WorkspaceExtImpl, plugin: Plugin): theia.WebviewOptions { + return { + ...options, + localResourceRoots: options.localResourceRoots || [ + ...(workspace.workspaceFolders || []).map(x => x.uri), + URI.file(plugin.pluginFolder) + ] + }; + } } export class WebviewPanelImpl implements theia.WebviewPanel { @@ -216,6 +239,7 @@ export class WebviewPanelImpl implements theia.WebviewPanel { private _active = true; private _visible = true; private _showOptions: theia.WebviewPanelShowOptions; + private _iconPath: theia.Uri | { light: theia.Uri; dark: theia.Uri } | undefined; readonly onDisposeEmitter = new Emitter<void>(); public readonly onDidDispose: Event<void> = this.onDisposeEmitter.event; @@ -228,11 +252,10 @@ export class WebviewPanelImpl implements theia.WebviewPanel { private readonly _viewType: string, private _title: string, showOptions: theia.ViewColumn | theia.WebviewPanelShowOptions, - private readonly _options: theia.WebviewPanelOptions | undefined, + private readonly _options: theia.WebviewPanelOptions, private readonly _webview: WebviewImpl ) { this._showOptions = typeof showOptions === 'object' ? showOptions : { viewColumn: showOptions as theia.ViewColumn }; - this.setViewColumn(undefined); } dispose(): void { @@ -268,19 +291,15 @@ export class WebviewPanelImpl implements theia.WebviewPanel { } } - set iconPath(iconPath: theia.Uri | { light: theia.Uri; dark: theia.Uri }) { + get iconPath(): theia.Uri | { light: theia.Uri; dark: theia.Uri } | undefined { + return this._iconPath; + } + + set iconPath(iconPath: theia.Uri | { light: theia.Uri; dark: theia.Uri } | undefined) { this.checkIsDisposed(); - if (URI.isUri(iconPath)) { - if ('http' === iconPath.scheme || 'https' === iconPath.scheme) { - this.proxy.$setIconPath(this.viewId, iconPath.toString()); - } else { - this.proxy.$setIconPath(this.viewId, (<theia.Uri>iconPath).path); - } - } else { - this.proxy.$setIconPath(this.viewId, { - light: (<{ light: theia.Uri; dark: theia.Uri }>iconPath).light.path, - dark: (<{ light: theia.Uri; dark: theia.Uri }>iconPath).dark.path - }); + if (this._iconPath !== iconPath) { + this._iconPath = iconPath; + this.proxy.$setIconPath(this.viewId, PluginIconPath.toUrl(iconPath, this._webview.plugin)); } } @@ -291,7 +310,7 @@ export class WebviewPanelImpl implements theia.WebviewPanel { get options(): theia.WebviewPanelOptions { this.checkIsDisposed(); - return this._options!; + return this._options; } get viewColumn(): theia.ViewColumn | undefined { diff --git a/packages/plugin-ext/src/plugin/window-state.ts b/packages/plugin-ext/src/plugin/window-state.ts index a75a34cb3bca5..eb5c5a065d031 100644 --- a/packages/plugin-ext/src/plugin/window-state.ts +++ b/packages/plugin-ext/src/plugin/window-state.ts @@ -19,6 +19,7 @@ import { WindowState } from '@theia/plugin'; import { WindowStateExt, WindowMain, PLUGIN_RPC_CONTEXT } from '../common/plugin-api-rpc'; import { Event, Emitter } from '@theia/core/lib/common/event'; import { RPCProtocol } from '../common/rpc-protocol'; +import { Schemes } from '../common/uri-components'; export class WindowStateExtImpl implements WindowStateExt { @@ -52,4 +53,16 @@ export class WindowStateExtImpl implements WindowStateExt { return this.proxy.$openUri(uri); } + async asExternalUri(target: URI): Promise<URI> { + if (!target.scheme.trim().length) { + throw new Error('Invalid scheme - cannot be empty'); + } + if (Schemes.HTTP !== target.scheme && Schemes.HTTPS !== target.scheme) { + throw new Error(`Invalid scheme '${target.scheme}'`); + } + + const uri = await this.proxy.$asExternalUri(target); + return URI.revive(uri); + } + } diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 562c9e4926f0d..271666d6210cd 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -2718,6 +2718,21 @@ declare module '@theia/plugin' { update(key: string, value: any): PromiseLike<void>; } + /** + * Defines a port mapping used for localhost inside the webview. + */ + export interface WebviewPortMapping { + /** + * Localhost port to remap inside the webview. + */ + readonly webviewPort: number; + + /** + * Destination port. The `webviewPort` is resolved to this port. + */ + readonly extensionHostPort: number; + } + /** * Content settings for a webview. */ @@ -2744,6 +2759,21 @@ declare module '@theia/plugin' { * Pass in an empty array to disallow access to any local resources. */ readonly localResourceRoots?: ReadonlyArray<Uri>; + + /** + * Mappings of localhost ports used inside the webview. + * + * Port mapping allow webviews to transparently define how localhost ports are resolved. This can be used + * to allow using a static localhost port inside the webview that is resolved to random port that a service is + * running on. + * + * If a webview accesses localhost content, we recommend that you specify port mappings even if + * the `webviewPort` and `extensionHostPort` ports are the same. + * + * *Note* that port mappings only work for `http` or `https` urls. Websocket urls (e.g. `ws://localhost:3000`) + * cannot be mapped to another port. + */ + readonly portMapping?: ReadonlyArray<WebviewPortMapping>; } /** @@ -2775,6 +2805,30 @@ declare module '@theia/plugin' { * @param message Body of the message. */ postMessage(message: any): PromiseLike<boolean>; + + /** + * Convert a uri for the local file system to one that can be used inside webviews. + * + * Webviews cannot directly load resources from the workspace or local file system using `file:` uris. The + * `asWebviewUri` function takes a local `file:` uri and converts it into a uri that can be used inside of + * a webview to load the same resource: + * + * ```ts + * webview.html = `<img src="${webview.asWebviewUri(vscode.Uri.file('/Users/codey/workspace/cat.gif'))}">` + * ``` + */ + asWebviewUri(localResource: Uri): Uri; + + /** + * Content security policy source for webview resources. + * + * This is the origin that should be used in a content security policy rule: + * + * ``` + * img-src https: ${webview.cspSource} ...; + * ``` + */ + readonly cspSource: string; } /** @@ -4824,6 +4878,28 @@ declare module '@theia/plugin' { */ export function openExternal(target: Uri): PromiseLike<boolean>; + /** + * Resolves an *external* uri, such as a `http:` or `https:` link, from where the extension is running to a + * uri to the same resource on the client machine. + * + * This is a no-op if the extension is running on the client machine. Currently only supports + * `https:` and `http:` uris. + * + * If the extension is running remotely, this function automatically establishes a port forwarding tunnel + * from the local machine to `target` on the remote and returns a local uri to the tunnel. The lifetime of + * the port fowarding tunnel is managed by VS Code and the tunnel can be closed by the user. + * + * Extensions should not cache the result of `asExternalUri` as the resolved uri may become invalid due to + * a system or user action — for example, in remote cases, a user may close a port forwardng tunnel + * that was opened by `asExternalUri`. + * + * *Note* that uris passed through `openExternal` are automatically resolved and you should not call `asExternalUri` + * on them. + * + * @return A uri that can be used on the client machine. + */ + export function asExternalUri(target: Uri): PromiseLike<Uri>; + } /** @@ -7533,9 +7609,9 @@ declare module '@theia/plugin' { */ readonly name: string; - /** - * The "resolved" [debug configuration](#DebugConfiguration) of this session. - */ + /** + * The "resolved" [debug configuration](#DebugConfiguration) of this session. + */ readonly configuration: DebugConfiguration; /** @@ -8424,10 +8500,10 @@ declare module '@theia/plugin' { } export interface TaskFilter { - /** - * The task version as used in the tasks.json file. - * The string support the package.json semver notation. - */ + /** + * The task version as used in the tasks.json file. + * The string support the package.json semver notation. + */ version?: string; /** @@ -8457,11 +8533,11 @@ declare module '@theia/plugin' { export function fetchTasks(filter?: TaskFilter): PromiseLike<Task[]>; /** - * Executes a task that is managed by VS Code. The returned - * task execution can be used to terminate the task. - * - * @param task the task to execute - */ + * Executes a task that is managed by VS Code. The returned + * task execution can be used to terminate the task. + * + * @param task the task to execute + */ export function executeTask(task: Task): PromiseLike<TaskExecution>; /** diff --git a/yarn.lock b/yarn.lock index 2fd7fefc56f47..0fe1495f4dcce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -767,7 +767,7 @@ dependencies: "@phosphor/algorithm" "^1.2.0" -"@phosphor/widgets@^1.5.0": +"@phosphor/widgets@^1.9.3": version "1.9.3" resolved "https://registry.yarnpkg.com/@phosphor/widgets/-/widgets-1.9.3.tgz#b8b7ad69fd7cc7af8e8c312ebead0e0965a4cefd" integrity sha512-61jsxloDrW/+WWQs8wOgsS5waQ/MSsXBuhONt0o6mtdeL93HVz7CYO5krOoot5owammfF6oX1z0sDaUYIYgcPA== @@ -898,7 +898,7 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.4.tgz#8936cffad3c96ec470a2dc26a38c3ba8b9b6f619" integrity sha512-7qvf9F9tMTzo0akeswHPGqgUx/gIaJqrOEET/FCD8CFRkSUHlygQiM5yB6OvjrtdxBVLSyw7COJubsFYs0683g== -"@types/connect@*": +"@types/connect@*", "@types/connect@^3.4.32": version "3.4.32" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.32.tgz#aa0e9616b9435ccad02bc52b5b454ffc2c70ba28" integrity sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg== @@ -1060,6 +1060,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== +"@types/mime@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d" + integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw== + "@types/minimatch@*", "@types/minimatch@3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -1175,7 +1180,7 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ== -"@types/serve-static@*": +"@types/serve-static@*", "@types/serve-static@^1.13.3": version "1.13.3" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1" integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g== @@ -3623,6 +3628,16 @@ conf@^2.0.0: pkg-up "^2.0.0" write-file-atomic "^2.3.0" +connect@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" @@ -5402,7 +5417,7 @@ fill-range@^4.0.0: repeat-string "^1.6.1" to-regex-range "^2.1.0" -finalhandler@~1.1.2: +finalhandler@1.1.2, finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== @@ -8287,6 +8302,11 @@ mime@^2.0.3: resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== +mime@^2.4.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" + integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -11000,7 +11020,7 @@ serialize-javascript@^1.4.0, serialize-javascript@^1.7.0: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.9.1.tgz#cfc200aef77b600c47da9bb8149c943e798c2fdb" integrity sha512-0Vb/54WJ6k5v8sSWN09S0ora+Hnr+cX40r9F170nT+mSkaxltoE/7R3OrIdBSUv1OoiobH1QoWQbCnAO+e8J1A== -serve-static@1.14.1: +serve-static@1.14.1, serve-static@^1.14.1: version "1.14.1" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== @@ -12532,6 +12552,11 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vhost@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/vhost/-/vhost-3.0.2.tgz#2fb1decd4c466aa88b0f9341af33dc1aff2478d5" + integrity sha1-L7HezUxGaqiLD5NBrzPcGv8keNU= + vinyl-file@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a"