From 1319038ec00d775bc8f34253ccf5e42d808a6927 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Sat, 15 Jun 2019 12:35:18 -0700 Subject: [PATCH] Add experimental service-worker based loading of webview content ## Problem We use a custom `vscode-resource` protocol to control access to local resources inside of webviews. This will not work on the web, but we still would prefer a way to intercept webview requests from the main client ## Proposed solution Move webviews into their own origin and register a service worker on this origin. This service worker can talk with the outer iframe of our webview. When a request for a resource comes in to the service worker: * In the service worker, add the request to a map and post a message back to the client saying we want to load this resource * The outer iframe gets the message from the sercice worker and forwards it to our main process * This process handles the message and use the normal file system api to read the resource (also restricting which files can be read) * We post the result back into the inner iframe which fowards it back to the service worker * The service worker now resolves the pending request. The prototype version in this change works but does not correctly handle multiple clients existing at the same time (plus probably a lot of other edge cases too) --- src/vs/code/browser/workbench/workbench.html | 3 +- .../environment/common/environment.ts | 2 + src/vs/workbench/browser/web.main.ts | 6 +- .../workbench/browser/web.simpleservices.ts | 1 + .../contrib/webview/browser/pre/fake.html | 0 .../contrib/webview/browser/pre/main.js | 49 +++++++++-- .../webview/browser/pre/service-worker.js | 84 +++++++++++++++++++ .../contrib/webview/browser/webviewElement.ts | 37 +++++++- 8 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 src/vs/workbench/contrib/webview/browser/pre/fake.html create mode 100644 src/vs/workbench/contrib/webview/browser/pre/service-worker.js diff --git a/src/vs/code/browser/workbench/workbench.html b/src/vs/code/browser/workbench/workbench.html index a11d9901d77..8a35fe1ee16 100644 --- a/src/vs/code/browser/workbench/workbench.html +++ b/src/vs/code/browser/workbench/workbench.html @@ -17,7 +17,8 @@ folderUri: '{{FOLDER}}', workspaceUri: '{{WORKSPACE}}', userDataUri: '{{USER_DATA}}', - authority: '{{AUTHORITY}}' + authority: '{{AUTHORITY}}', + webviewEndpoint: '{{WEBVIEW_ENDPOINT}}' } diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index adcdb08cfb1..b2d41431207 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -151,4 +151,6 @@ export interface IEnvironmentService { driverHandle?: string; driverVerbose: boolean; + + webviewEndpoint?: string; } diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 9f39bae7e51..ec54aaab3e8 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -41,6 +41,7 @@ interface IWindowConfiguration { userDataUri: URI; folderUri?: URI; workspaceUri?: URI; + webviewEndpoint?: string; } class CodeRendererMain extends Disposable { @@ -149,6 +150,7 @@ class CodeRendererMain extends Disposable { port: null, break: false }; + environmentService.webviewEndpoint = this.configuration.webviewEndpoint; return environmentService; } @@ -189,6 +191,7 @@ export interface IWindowConfigurationContents { userDataUri: UriComponents; folderUri?: UriComponents; workspaceUri?: UriComponents; + webviewEndpoint?: string; } export function main(windowConfigurationContents: IWindowConfigurationContents): Promise { @@ -196,7 +199,8 @@ export function main(windowConfigurationContents: IWindowConfigurationContents): userDataUri: URI.revive(windowConfigurationContents.userDataUri), remoteAuthority: windowConfigurationContents.authority, folderUri: windowConfigurationContents.folderUri ? URI.revive(windowConfigurationContents.folderUri) : undefined, - workspaceUri: windowConfigurationContents.workspaceUri ? URI.revive(windowConfigurationContents.workspaceUri) : undefined + workspaceUri: windowConfigurationContents.workspaceUri ? URI.revive(windowConfigurationContents.workspaceUri) : undefined, + webviewEndpoint: windowConfigurationContents.webviewEndpoint }; const renderer = new CodeRendererMain(windowConfiguration); diff --git a/src/vs/workbench/browser/web.simpleservices.ts b/src/vs/workbench/browser/web.simpleservices.ts index 67866d6b239..92493e0c8da 100644 --- a/src/vs/workbench/browser/web.simpleservices.ts +++ b/src/vs/workbench/browser/web.simpleservices.ts @@ -228,6 +228,7 @@ export class SimpleWorkbenchEnvironmentService implements IWorkbenchEnvironmentS disableCrashReporter: boolean; driverHandle?: string; driverVerbose: boolean; + webviewEndpoint?: string; } diff --git a/src/vs/workbench/contrib/webview/browser/pre/fake.html b/src/vs/workbench/contrib/webview/browser/pre/fake.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/vs/workbench/contrib/webview/browser/pre/main.js b/src/vs/workbench/contrib/webview/browser/pre/main.js index e1c46415bb9..2b706c812da 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/main.js +++ b/src/vs/workbench/contrib/webview/browser/pre/main.js @@ -118,6 +118,28 @@ initialScrollProgress: undefined }; + // Service worker for resource loading + const FAKE_LOAD = !!navigator.serviceWorker; + if (navigator.serviceWorker) { + navigator.serviceWorker.register('service-worker.js'); + + navigator.serviceWorker.ready.then(registration => { + registration.active.postMessage('ping'); + + host.onMessage('loaded-resource', event => { + registration.active.postMessage({ channel: 'loaded-resource', data: event.data.args }); + }); + }); + + navigator.serviceWorker.addEventListener('message', event => { + switch (event.data.channel) { + case 'load-resource': + host.postMessage('load-resource', { path: event.data.path }); + return; + } + }); + } + /** * @param {HTMLDocument?} document * @param {HTMLElement?} body @@ -337,15 +359,30 @@ 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 (FAKE_LOAD) { + // 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'; + } newFrame.style.cssText = 'display: block; margin: 0; overflow: hidden; position: absolute; width: 100%; height: 100%; visibility: hidden'; document.body.appendChild(newFrame); - // write new content onto iframe - newFrame.contentDocument.open('text/html', 'replace'); + if (!FAKE_LOAD) { + // write new content onto iframe + newFrame.contentDocument.open(); + } newFrame.contentWindow.addEventListener('keydown', handleInnerKeydown); newFrame.contentWindow.addEventListener('DOMContentLoaded', e => { + if (FAKE_LOAD) { + newFrame.contentDocument.open(); + newFrame.contentDocument.write(''); + newFrame.contentDocument.write(newDocument.documentElement.innerHTML); + newFrame.contentDocument.close(); + } const contentDocument = e.target ? (/** @type {HTMLDocument} */ (e.target)) : undefined; if (contentDocument) { applyStyles(contentDocument, contentDocument.body); @@ -414,9 +451,11 @@ // 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 - newFrame.contentDocument.write(''); - newFrame.contentDocument.write(newDocument.documentElement.innerHTML); - newFrame.contentDocument.close(); + if (!FAKE_LOAD) { + newFrame.contentDocument.write(''); + newFrame.contentDocument.write(newDocument.documentElement.innerHTML); + newFrame.contentDocument.close(); + } host.postMessage('did-set-content', undefined); }); diff --git a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js new file mode 100644 index 00000000000..98f46d57477 --- /dev/null +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Listen for messages from clients. +const resolvedPaths = new Map(); + +self.addEventListener('message', (event) => { + switch (event.data.channel) { + case 'loaded-resource': + { + const data = event.data.data; + const target = resolvedPaths.get(data.path); + if (!target) { + console.log('Loaded unknown resource', data.path); + return; + } + + if (data.status === 200) { + target.resolve(new Response(data.data, { + status: 200, + headers: { 'Content-Type': data.mime }, + }).clone()); + } else { + target.resolve(new Response('Not Found', { + status: 404, + }).clone()); + } + } + return; + } +}); + +var clients; +const resourceRoot = '/vscode-resource'; + +self.addEventListener('fetch', (event) => { + const requestUrl = new URL(event.request.url); + + if (!requestUrl.pathname.startsWith(resourceRoot + '/')) { + return event.respondWith(fetch(event.request)); + } + + event.respondWith((async () => { + const resourcePath = requestUrl.pathname.replace(resourceRoot, ''); + + const existing = resolvedPaths.get(resourcePath); + if (existing) { + return existing.promise.then(r => r.clone()); + } + + const allClients = await clients.matchAll({ + includeUncontrolled: true + }); + + for (const client of allClients) { + const clientUrl = new URL(client.url); + if (clientUrl.pathname === '/') { + client.postMessage({ + channel: 'load-resource', + path: resourcePath + }); + + if (resolvedPaths.has(resourcePath)) { + // Someone else added it in the mean time + return resolvedPaths.get(resolvedPaths).promise; + } + + let resolve; + const promise = new Promise(r => resolve = r); + resolvedPaths.set(resourcePath, { resolve, promise }); + return promise.then(r => r.clone()); + } + } + })()); +}); + +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 +}); diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index c40c6ec2bf9..52936cb045c 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -16,6 +16,7 @@ import { areWebviewInputOptionsEqual } from 'vs/workbench/contrib/webview/browse import { addDisposableListener, addClass } from 'vs/base/browser/dom'; import { getWebviewThemeData } from 'vs/workbench/contrib/webview/common/themeing'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { loadLocalResource } from 'vs/workbench/contrib/webview/common/resourceLoader'; interface WebviewContent { readonly html: string; @@ -34,12 +35,12 @@ export class IFrameWebview extends Disposable implements Webview { private readonly id: string; constructor( - _options: WebviewOptions, + private _options: WebviewOptions, contentOptions: WebviewContentOptions, @IInstantiationService instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @IEnvironmentService environmentService: IEnvironmentService, - @IFileService fileService: IFileService, + @IFileService private readonly fileService: IFileService, @ITelemetryService telemetryService: ITelemetryService, @IConfigurationService private readonly _configurationService: IConfigurationService, ) { @@ -55,7 +56,7 @@ export class IFrameWebview extends Disposable implements Webview { this.element = document.createElement('iframe'); this.element.sandbox.add('allow-scripts'); this.element.sandbox.add('allow-same-origin'); - this.element.setAttribute('src', `/src/vs/workbench/contrib/webview/browser/pre/index.html?id=${this.id}`); + this.element.setAttribute('src', `${environmentService.webviewEndpoint}?id=${this.id}`); // TODO: get this from env service this.element.style.border = 'none'; this.element.style.width = '100%'; this.element.style.height = '100%'; @@ -108,6 +109,13 @@ export class IFrameWebview extends Disposable implements Webview { this.handleFocusChange(false); return; + case 'load-resource': + { + const path = e.data.data.path; + const uri = URI.file(path); + this.loadResource(uri); + return; + } } })); @@ -262,5 +270,28 @@ export class IFrameWebview extends Disposable implements Webview { const { styles, activeTheme } = getWebviewThemeData(theme, this._configurationService); this._send('styles', { styles, activeTheme }); } + + private async loadResource(uri: URI) { + try { + const result = await loadLocalResource(uri, this.fileService, this._options.extension ? this._options.extension.location : undefined, + () => (this.content.options.localResourceRoots || [])); + + if (result.type === 'success') { + return this._send('loaded-resource', { + status: 200, + path: uri.path, + mime: result.mimeType, + data: result.data.buffer + }); + } + } catch { + // noop + } + + return this._send('loaded-resource', { + status: 404, + path: uri.path + }); + } }