// ***************************************************************************** // 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-only 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 '@theia/core/shared/@lumino/coreutils'; import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { WebviewPanelOptions, WebviewPortMapping } from '@theia/plugin'; import { BaseWidget, Message, codicon } from '@theia/core/lib/browser/widgets/widget'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; 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, ThemeIcon as ThemeIconDTO } from '../../../common/plugin-api-rpc'; import { ThemeIcon } from '@theia/monaco-editor-core/esm/vs/base/common/themables'; 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 { WebviewThemeDataProvider } from './webview-theme-data-provider'; import { ExternalUriService } from '@theia/core/lib/browser/external-uri-service'; import { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; import { WebviewResourceCache } from './webview-resource-cache'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { isFirefox } from '@theia/core/lib/browser/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileOperationError, FileOperationResult } from '@theia/filesystem/lib/common/files'; import { BinaryBufferReadableStream } from '@theia/core/lib/common/buffer'; import { ExtractableWidget } from '@theia/core/lib/browser/widgets/extractable-widget'; import { MenuPath } from '@theia/core'; import { ContextMenuRenderer } from '@theia/core/lib/browser'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { PluginViewWidget } from '../view/plugin-view-widget'; import { WebviewPreferences } from '../../common/webview-preferences'; // Style from core const TRANSPARENT_OVERLAY_STYLE = 'theia-transparent-overlay'; export const WEBVIEW_CONTEXT_MENU: MenuPath = ['webview-context-menu']; /* eslint-disable @typescript-eslint/no-explicit-any */ 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', didMouseDown = 'did-mousedown', didMouseUp = 'did-mouseup', onconsole = 'onconsole', didcontextmenu = 'did-context-menu' } export interface WebviewContentOptions { readonly allowScripts?: boolean; readonly allowForms?: boolean; readonly localResourceRoots?: ReadonlyArray; readonly portMapping?: ReadonlyArray; readonly enableCommandUris?: boolean | readonly string[]; } export interface WebviewConsoleLog { level: Extract; message?: string; optionalParams?: string; } @injectable() export class WebviewWidgetIdentifier { id: string; viewId?: string; } export const WebviewWidgetExternalEndpoint = Symbol('WebviewWidgetExternalEndpoint'); @injectable() export class WebviewWidget extends BaseWidget implements StatefulWidget, ExtractableWidget { private static readonly standardSupportedLinkSchemes = new Set([ Schemes.http, Schemes.https, Schemes.mailto, Schemes.vscode, Schemes.vscodeNotebookCell ]); static FACTORY_ID = 'plugin-webview'; protected element: HTMLIFrameElement | undefined; // eslint-disable-next-line max-len // 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(FileService) protected readonly fileService: FileService; @inject(WebviewResourceCache) protected readonly resourceCache: WebviewResourceCache; @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; 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(); protected readonly onMessageEmitter = new Emitter(); readonly onMessage = this.onMessageEmitter.event; protected readonly pendingMessages: any[] = []; protected readonly toHide = new DisposableCollection(); protected hideTimeout: any | number | undefined; isExtractable: boolean = true; secondaryWindow: Window | undefined = undefined; @postConstruct() protected init(): void { this.node.tabIndex = 0; this.id = WebviewWidget.FACTORY_ID + ':' + this.identifier.id; this.title.closable = true; this.addClass(WebviewWidget.Styles.WEBVIEW); this.toDispose.push(this.onMessageEmitter); this.transparentOverlay = document.createElement('div'); this.transparentOverlay.classList.add(TRANSPARENT_OVERLAY_STYLE); this.transparentOverlay.style.display = 'none'; this.node.appendChild(this.transparentOverlay); 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(() => { if (this.element && this.element.style.display !== 'none') { this.transparentOverlay.style.display = 'none'; } })); } protected override 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())); } protected override onAfterAttach(msg: Message): void { super.onAfterAttach(msg); this.addEventListener(this.node, 'focus', () => { if (this.element) { this.doSend('focus'); } }); } protected override onBeforeShow(msg: Message): void { super.onBeforeShow(msg); this.doShow(); } protected override onAfterHide(msg: Message): void { super.onAfterHide(msg); this.doHide(); } 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 forceHide(): void { clearTimeout(this.hideTimeout); this.hideTimeout = undefined; this.toHide.dispose(); } protected doShow(): void { clearTimeout(this.hideTimeout); this.hideTimeout = undefined; if (!this.toHide.disposed) { return; } this.toDispose.push(this.toHide); const element = document.createElement('iframe'); element.className = 'webview'; element.sandbox.add('allow-scripts', 'allow-forms', 'allow-same-origin', 'allow-downloads'); if (!isFirefox) { element.setAttribute('allow', 'clipboard-read; clipboard-write; usb; serial; hid;'); } 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(); ready.promise.then(() => oldReady.resolve()); this.ready = ready; this.toHide.push(Disposable.create(() => this.ready = new Deferred())); const subscription = this.on(WebviewMessageChannels.webviewReady, () => { subscription.dispose(); ready.resolve(); }); this.toHide.push(subscription); this.toHide.push(this.on(WebviewMessageChannels.onmessage, (data: any) => this.onMessageEmitter.fire(data))); this.toHide.push(this.on(WebviewMessageChannels.onconsole, (data: WebviewConsoleLog) => this.forwardConsoleLog(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.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, entry.query))); 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 and dispatch them to our // keybinding service because these events do not bubble to the parent window anymore. this.keybindings.dispatchKeyDown(data, this.element); })); this.toHide.push(this.on(WebviewMessageChannels.didMouseDown, (data: MouseEvent) => { // We have to dispatch mousedown events so menus will be closed when clicking inside webviews. // See: https://github.com/eclipse-theia/theia/issues/7752 this.dispatchMouseEvent('mousedown', data); })); this.toHide.push(this.on(WebviewMessageChannels.didMouseUp, (data: MouseEvent) => { this.dispatchMouseEvent('mouseup', data); })); this.toHide.push(this.on(WebviewMessageChannels.didcontextmenu, (event: { clientX: number, clientY: number, context: any }) => { this.handleContextMenu(event); })); this.style(); this.toHide.push(this.themeDataProvider.onDidChangeThemeData(() => this.style())); this.doUpdateContent(); while (this.pendingMessages.length) { this.sendMessage(this.pendingMessages.shift()); } } protected async loadLocalhost(origin: string): Promise { const redirect = await this.getRedirect(origin); return this.doSend('did-load-localhost', { origin, location: redirect }); } protected dispatchMouseEvent(type: string, data: MouseEvent): void { const domRect = this.node.getBoundingClientRect(); document.dispatchEvent(new MouseEvent(type, { ...data, clientX: domRect.x + data.clientX, clientY: domRect.y + data.clientY })); } handleContextMenu(event: { clientX: number, clientY: number, context: any }): void { const domRect = this.node.getBoundingClientRect(); this.contextKeyService.with(this.parent instanceof PluginViewWidget ? { webviewId: this.parent.options.viewId, ...event.context } : {}, () => { this.contextMenuRenderer.render({ menuPath: WEBVIEW_CONTEXT_MENU, args: [event.context], anchor: { x: domRect.x + event.clientX, y: domRect.y + event.clientY }, context: this.node }); }); } protected async getRedirect(url: string): Promise { 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}`) ); } } } } return this.toRemoteUrl(uri); } protected async toRemoteUrl(localUri: URI): Promise { 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(this.contentOptions, contentOptions)) { return; } this._contentOptions = contentOptions; this.doUpdateContent(); } protected iconUrl: IconUrl | ThemeIconDTO | undefined; protected readonly toDisposeOnIcon = new DisposableCollection(); setIconUrl(iconUrl: IconUrl | ThemeIconDTO | undefined): void { if (this.iconUrl === iconUrl || (this.iconUrl && iconUrl && JSON.stringify(this.iconUrl) === JSON.stringify(iconUrl))) { return; } this.toDisposeOnIcon.dispose(); this.toDispose.push(this.toDisposeOnIcon); this.iconUrl = iconUrl; if (ThemeIcon.isThemeIcon(iconUrl)) { this.title.iconClass = codicon(iconUrl.id); } else 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(${this.toEndpoint(theme.type === 'light' ? lightIconUrl : darkIconUrl)});` )); this.title.iconClass = `theia-webview-icon ${iconClass}`; } else { this.title.iconClass = ''; } } protected toEndpoint(pathname: string): string { return new Endpoint({ path: pathname }).getRestUrl().toString(); } 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 override onActivateRequest(msg: Message): void { super.onActivateRequest(msg); this.node.focus(); } reload(): void { this.doUpdateContent(); } protected forwardConsoleLog(log: WebviewConsoleLog): void { const message = `[webview: ${this.identifier.id}] ${log.message ? JSON.parse(log.message) : undefined}`; if (log.optionalParams !== undefined) { console[log.level](message, ...JSON.parse(log.optionalParams)); } else { console[log.level](message); } } protected style(): void { const { styles, activeThemeType, activeThemeName } = this.themeDataProvider.getThemeData(); this.doSend('styles', { styles, activeThemeType, activeThemeName }); } protected openLink(link: URI): void { const supported = this.toSupportedLink(link); if (supported) { open(this.openerService, supported); } } 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.substring(resourceRoot.length)); } } return link; } if (link.scheme === Schemes.command) { if (Array.isArray(this.contentOptions.enableCommandUris) && this.contentOptions.enableCommandUris.some(value => value === link.path.toString())) { return link; } else if (this.contentOptions.enableCommandUris === true) { return link; } } return undefined; } protected async loadResource(requestPath: string, requestQuery: string = ''): Promise { const normalizedUri = this.normalizeRequestUri(requestPath).withQuery(decodeURIComponent(requestQuery)); // browser cache does not support file scheme, normalize to current endpoint scheme and host // use requestPath rather than normalizedUri.path to preserve the scheme of the requested resource as a path segment const cacheUrl = new Endpoint({ path: requestPath }).getRestUrl().withQuery(decodeURIComponent(requestQuery)).toString(); try { if (this.contentOptions.localResourceRoots) { for (const root of this.contentOptions.localResourceRoots) { if (!new URI(root).path.isEqualOrParent(normalizedUri.path)) { continue; } let cached = await this.resourceCache.match(cacheUrl); try { const result = await this.fileService.readFileStream(normalizedUri, { etag: cached?.eTag }); const { buffer } = await BinaryBufferReadableStream.toBuffer(result.value); cached = { body: () => buffer, eTag: result.etag }; this.resourceCache.put(cacheUrl, cached); } catch (e) { if (!(e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_MODIFIED_SINCE)) { throw e; } } 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 }); } protected normalizeRequestUri(requestPath: string): URI { const normalizedPath = decodeURIComponent(requestPath); const requestUri = new URI(normalizedPath.replace(/^\/([a-zA-Z0-9.\-+]+)\/(.+)$/, (_, 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 { this.pendingMessages.push(data); } } 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; } setIframeHeight(height: number): void { if (this.element) { this.element.style.height = `${height}px`; } } protected async doSend(channel: string, data?: any): Promise { 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); if (this.secondaryWindow) { this.secondaryWindow.postMessage({ channel, args: data }, '*'); } else { this.element.contentWindow!.postMessage({ channel, args: data }, '*'); } } } protected on(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 | ThemeIconDTO options: WebviewPanelOptions contentOptions: WebviewContentOptions state?: string } }