Skip to content

Add experimental support for using iframes for webviews #100991

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface StandardTsServerRequests {
'completions': [Proto.CompletionsRequestArgs, Proto.CompletionsResponse];
'configure': [Proto.ConfigureRequestArguments, Proto.ConfigureResponse];
'definition': [Proto.FileLocationRequestArgs, Proto.DefinitionResponse];
'definitionAndBoundSpan': [Proto.FileLocationRequestArgs, Proto.DefinitionInfoAndBoundSpanReponse];
'definitionAndBoundSpan': [Proto.FileLocationRequestArgs, Proto.DefinitionInfoAndBoundSpanResponse];
'docCommentTemplate': [Proto.FileLocationRequestArgs, Proto.DocCommandTemplateResponse];
'documentHighlights': [Proto.DocumentHighlightsRequestArgs, Proto.DocumentHighlightsResponse];
'format': [Proto.FormatRequestArgs, Proto.FormatResponse];
Expand Down
3 changes: 2 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ setCurrentWorkingDirectory();
// Register custom schemes with privileges
protocol.registerSchemesAsPrivileged([
{
scheme: 'vscode-resource',
scheme: 'vscode-webview',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
Expand Down
8 changes: 8 additions & 0 deletions src/vs/base/common/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export namespace Schemas {

export const webviewPanel = 'webview-panel';

/**
* Scheme used for loading the wrapper html and script in webviews.
*/
export const vscodeWebview = 'vscode-webview';

/**
* Scheme used for loading resources inside of webviews.
*/
export const vscodeWebviewResource = 'vscode-webview-resource';

/**
Expand Down
2 changes: 1 addition & 1 deletion src/vs/code/electron-browser/workbench/workbench.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' https: data: blob: vscode-remote-resource:; media-src 'none'; frame-src 'self' https://*.vscode-webview-test.com; object-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; font-src 'self' https: vscode-remote-resource:;">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' https: data: blob: vscode-remote-resource:; media-src 'none'; frame-src 'self' vscode-webview: https://*.vscode-webview-test.com; object-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; font-src 'self' https: vscode-remote-resource:;">
</head>
<body aria-label="">
</body>
Expand Down
7 changes: 4 additions & 3 deletions src/vs/code/electron-main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,12 @@ export class CodeApplication extends Disposable {
return false;
}

if (source === 'data:text/html;charset=utf-8,%3C%21DOCTYPE%20html%3E%0D%0A%3Chtml%20lang%3D%22en%22%20style%3D%22width%3A%20100%25%3B%20height%3A%20100%25%22%3E%0D%0A%3Chead%3E%0D%0A%3Ctitle%3EVirtual%20Document%3C%2Ftitle%3E%0D%0A%3C%2Fhead%3E%0D%0A%3Cbody%20style%3D%22margin%3A%200%3B%20overflow%3A%20hidden%3B%20width%3A%20100%25%3B%20height%3A%20100%25%22%20role%3D%22document%22%3E%0D%0A%3C%2Fbody%3E%0D%0A%3C%2Fhtml%3E') {
return true;
const uri = URI.parse(source);
if (uri.scheme === Schemas.vscodeWebview) {
return uri.path === '/index.html' || uri.path === '/electron-browser/index.html';
}

const srcUri = URI.parse(source).fsPath.toLowerCase();
const srcUri = uri.fsPath.toLowerCase();
const rootUri = URI.file(this.environmentService.appRoot).fsPath.toLowerCase();

return srcUri.startsWith(rootUri + sep);
Expand Down
2 changes: 1 addition & 1 deletion src/vs/platform/webview/common/webviewManagerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const IWebviewManagerService = createDecorator<IWebviewManagerService>('w
export interface IWebviewManagerService {
_serviceBrand: unknown;

registerWebview(id: string, webContentsId: number, metadata: RegisterWebviewMetadata): Promise<void>;
registerWebview(id: string, webContentsId: number | undefined, metadata: RegisterWebviewMetadata): Promise<void>;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to figure out what the equivalent of webContentsId for iframe based webviews. We need this for intercepting the localhost request for remote

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@deepak1556 Here is the problem I am trying to solve:

  1. For port forwarding in remote cases, we use a onBeforeRequest that looks at requests to localhost and potentially rewrites them

  2. There is a single onBeforeRequest handler for all webviews

  3. When using the <webview> tag, we were able to identify which webview was making the request to localhost using the web contents id. We would then use this id to lookup the port mapping information for that webview

  4. However iframes do not have a unique web contents id (at least, not by default) I cannot find any way to map a request that comes in to onBeforeRequest back to the webview that triggered the request.

Here are the fields I see on the details object in onBeforeRequest

Screen Shot 2020-06-24 at 5 01 02 PM

Any thoughts on how we could handle this mapping?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mjbvz I can extend the webrequest api to provide some addtional event details that would include the frame origin and frame name, do you think its good enough to manage the port map ?

vscode-webview://787e559f-185c-4d86-aebc-400191530e52/...

<iframe name="test" >

then details will have

{
  isSubFrame: true, // if the request originated from a subframe
  origin: '787e559f-185c-4d86-aebc-400191530e52', // last committed origin of the frame
  name: 'test', // value of the name attribute for a subframe
  ....
}

Copy link
Collaborator Author

@mjbvz mjbvz Jun 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that would work great for us here. Should I open a feature request against electron?

Copy link
Collaborator

@deepak1556 deepak1556 Jun 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please do, I will put up a PR targeting it. Thanks!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! Opened electron/electron#24303 for this

unregisterWebview(id: string): Promise<void>;
updateWebviewMetadata(id: string, metadataDelta: Partial<RegisterWebviewMetadata>): Promise<void>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer
this.portMappingProvider = this._register(new WebviewPortMappingProvider(tunnelService));
}

public async registerWebview(id: string, webContentsId: number, metadata: RegisterWebviewMetadata): Promise<void> {
public async registerWebview(id: string, webContentsId: number | undefined, metadata: RegisterWebviewMetadata): Promise<void> {
const extensionLocation = metadata.extensionLocation ? URI.from(metadata.extensionLocation) : undefined;

this.protocolProvider.registerWebview(id, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface PortMappingData {
export class WebviewPortMappingProvider extends Disposable {

private readonly _webviewData = new Map<string, {
readonly webContentsId: number;
readonly webContentsId: number | undefined;
readonly manager: WebviewPortMappingManager;
metadata: PortMappingData;
}>();
Expand Down Expand Up @@ -56,22 +56,26 @@ export class WebviewPortMappingProvider extends Disposable {
});
}

public async registerWebview(id: string, webContentsId: number, metadata: PortMappingData): Promise<void> {
public async registerWebview(id: string, webContentsId: number | undefined, metadata: PortMappingData): Promise<void> {
const manager = new WebviewPortMappingManager(
() => this._webviewData.get(id)?.metadata.extensionLocation,
() => this._webviewData.get(id)?.metadata.mappings || [],
this._tunnelService);

this._webviewData.set(id, { webContentsId, metadata, manager });
this._webContentsIdsToWebviewIds.set(webContentsId, id);
if (typeof webContentsId === 'number') {
this._webContentsIdsToWebviewIds.set(webContentsId, id);
}
}

public unregisterWebview(id: string): void {
const existing = this._webviewData.get(id);
if (existing) {
existing.manager.dispose();
this._webviewData.delete(id);
this._webContentsIdsToWebviewIds.delete(existing.webContentsId);
if (typeof existing.webContentsId === 'number') {
this._webContentsIdsToWebviewIds.delete(existing.webContentsId);
}
}
}

Expand Down
158 changes: 101 additions & 57 deletions src/vs/platform/webview/electron-main/webviewProtocolProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { session } from 'electron';
import { session, protocol } from 'electron';
import { Readable } from 'stream';
import { VSBufferReadableStream } from 'vs/base/common/buffer';
import { Disposable, toDisposable } from 'vs/base/common/lifecycle';
Expand All @@ -23,6 +23,13 @@ interface WebviewMetadata {

export class WebviewProtocolProvider extends Disposable {

private static validWebviewFilePaths = new Map([
['/index.html', 'index.html'],
['/electron-browser/index.html', 'index.html'],
['/main.js', 'main.js'],
['/host.js', 'host.js'],
]);

private readonly webviewMetadata = new Map<string, WebviewMetadata>();

constructor(
Expand All @@ -33,62 +40,22 @@ export class WebviewProtocolProvider extends Disposable {

const sess = session.fromPartition(webviewPartitionId);

sess.protocol.registerStreamProtocol(Schemas.vscodeWebviewResource, async (request, callback): Promise<void> => {
try {
const uri = URI.parse(request.url);

const id = uri.authority;
const metadata = this.webviewMetadata.get(id);
if (metadata) {

// Try to further rewrite remote uris so that they go to the resolved server on the main thread
let rewriteUri: undefined | ((uri: URI) => URI);
if (metadata.remoteConnectionData) {
rewriteUri = (uri) => {
if (metadata.remoteConnectionData) {
if (uri.scheme === Schemas.vscodeRemote || (metadata.extensionLocation?.scheme === REMOTE_HOST_SCHEME)) {
const scheme = metadata.remoteConnectionData.host === 'localhost' || metadata.remoteConnectionData.host === '127.0.0.1' ? 'http' : 'https';
return URI.parse(`${scheme}://${metadata.remoteConnectionData.host}:${metadata.remoteConnectionData.port}`).with({
path: '/vscode-remote-resource',
query: `tkn=${metadata.remoteConnectionData.connectionToken}&path=${encodeURIComponent(uri.path)}`,
});
}
}
return uri;
};
}

const result = await loadLocalResource(uri, {
extensionLocation: metadata.extensionLocation,
roots: metadata.localResourceRoots,
remoteConnectionData: metadata.remoteConnectionData,
rewriteUri,
}, this.fileService, this.requestService);

if (result.type === WebviewResourceResponse.Type.Success) {
return callback({
statusCode: 200,
data: this.streamToNodeReadable(result.stream),
headers: {
'Content-Type': result.mimeType,
'Access-Control-Allow-Origin': '*',
}
});
}

if (result.type === WebviewResourceResponse.Type.AccessDenied) {
console.error('Webview: Cannot load resource outside of protocol root');
return callback({ data: null, statusCode: 401 });
}
}
} catch {
// noop
}

return callback({ data: null, statusCode: 404 });
});

this._register(toDisposable(() => sess.protocol.unregisterProtocol(Schemas.vscodeWebviewResource)));
// Register the protocol loading webview html
const webviewHandler = this.handleWebviewRequest.bind(this);
protocol.registerFileProtocol(Schemas.vscodeWebview, webviewHandler);
sess.protocol.registerFileProtocol(Schemas.vscodeWebview, webviewHandler);

// Register the protocol loading webview resources both inside the webview and at the top level
const webviewResourceHandler = this.handleWebviewResourceRequest.bind(this);
protocol.registerStreamProtocol(Schemas.vscodeWebviewResource, webviewResourceHandler);
sess.protocol.registerStreamProtocol(Schemas.vscodeWebviewResource, webviewResourceHandler);

this._register(toDisposable(() => {
protocol.unregisterProtocol(Schemas.vscodeWebviewResource);
sess.protocol.unregisterProtocol(Schemas.vscodeWebviewResource);
protocol.unregisterProtocol(Schemas.vscodeWebview);
sess.protocol.unregisterProtocol(Schemas.vscodeWebview);
}));
}

private streamToNodeReadable(stream: VSBufferReadableStream): Readable {
Expand Down Expand Up @@ -152,4 +119,81 @@ export class WebviewProtocolProvider extends Disposable {
});
}
}

private async handleWebviewRequest(request: Electron.Request, callback: any) {
try {
const uri = URI.parse(request.url);
const entry = WebviewProtocolProvider.validWebviewFilePaths.get(uri.path);
if (typeof entry === 'string') {
let url: string;
if (uri.path.startsWith('/electron-browser')) {
url = require.toUrl(`vs/workbench/contrib/webview/electron-browser/pre/${entry}`);
} else {
url = require.toUrl(`vs/workbench/contrib/webview/browser/pre/${entry}`);
}
return callback(url.replace('file://', ''));
}
} catch {
// noop
}
callback({ error: -10 /* ACCESS_DENIED - https://cs.chromium.org/chromium/src/net/base/net_error_list.h?l=32 */ });
}

private async handleWebviewResourceRequest(
request: Electron.Request,
callback: (stream?: NodeJS.ReadableStream | Electron.StreamProtocolResponse | undefined) => void
) {
try {
const uri = URI.parse(request.url);

const id = uri.authority;
const metadata = this.webviewMetadata.get(id);
if (metadata) {

// Try to further rewrite remote uris so that they go to the resolved server on the main thread
let rewriteUri: undefined | ((uri: URI) => URI);
if (metadata.remoteConnectionData) {
rewriteUri = (uri) => {
if (metadata.remoteConnectionData) {
if (uri.scheme === Schemas.vscodeRemote || (metadata.extensionLocation?.scheme === REMOTE_HOST_SCHEME)) {
const scheme = metadata.remoteConnectionData.host === 'localhost' || metadata.remoteConnectionData.host === '127.0.0.1' ? 'http' : 'https';
return URI.parse(`${scheme}://${metadata.remoteConnectionData.host}:${metadata.remoteConnectionData.port}`).with({
path: '/vscode-remote-resource',
query: `tkn=${metadata.remoteConnectionData.connectionToken}&path=${encodeURIComponent(uri.path)}`,
});
}
}
return uri;
};
}

const result = await loadLocalResource(uri, {
extensionLocation: metadata.extensionLocation,
roots: metadata.localResourceRoots,
remoteConnectionData: metadata.remoteConnectionData,
rewriteUri,
}, this.fileService, this.requestService);

if (result.type === WebviewResourceResponse.Type.Success) {
return callback({
statusCode: 200,
data: this.streamToNodeReadable(result.stream),
headers: {
'Content-Type': result.mimeType,
'Access-Control-Allow-Origin': '*',
}
});
}

if (result.type === WebviewResourceResponse.Type.AccessDenied) {
console.error('Webview: Cannot load resource outside of protocol root');
return callback({ data: null, statusCode: 401 });
}
}
} catch {
// noop
}

return callback({ data: null, statusCode: 404 });
}
}
19 changes: 14 additions & 5 deletions src/vs/workbench/contrib/webview/browser/pre/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// @ts-check
(function () {
const id = document.location.search.match(/\bid=([\w-]+)/)[1];
const onElectron = /platform=electron/.test(document.location.search);

const hostMessaging = new class HostMessaging {
constructor() {
Expand Down Expand Up @@ -36,6 +37,10 @@
}();

const workerReady = new Promise(async (resolveWorkerReady) => {
if (onElectron) {
return resolveWorkerReady();
}

if (!areServiceWorkersEnabled()) {
console.log('Service Workers are not enabled. Webviews will not work properly');
return resolveWorkerReady();
Expand Down Expand Up @@ -95,11 +100,15 @@
postMessage: hostMessaging.postMessage.bind(hostMessaging),
onMessage: hostMessaging.onMessage.bind(hostMessaging),
ready: workerReady,
fakeLoad: true,
rewriteCSP: (csp, endpoint) => {
const endpointUrl = new URL(endpoint);
return csp.replace(/(vscode-webview-resource|vscode-resource):(?=(\s|;|$))/g, endpointUrl.origin);
}
fakeLoad: !onElectron,
rewriteCSP: onElectron
? (csp) => {
return csp.replace(/vscode-resource:(?=(\s|;|$))/g, 'vscode-webview-resource:');
}
: (csp, endpoint) => {
const endpointUrl = new URL(endpoint);
return csp.replace(/(vscode-webview-resource|vscode-resource):(?=(\s|;|$))/g, endpointUrl.origin);
}
};

(/** @type {any} */ (window)).createWebviewManager(host);
Expand Down
Loading